Compare commits
No commits in common. "59d6bc164e3e350ec65e64cffbc51dca8af27f0e" and "9831008b9ff42cf09e3ce80b091d0f701025a917" have entirely different histories.
59d6bc164e
...
9831008b9f
201
LICENSE-APACHE
201
LICENSE-APACHE
@ -1,201 +0,0 @@
|
|||||||
Apache License
|
|
||||||
Version 2.0, January 2004
|
|
||||||
http://www.apache.org/licenses/
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
||||||
|
|
||||||
1. Definitions.
|
|
||||||
|
|
||||||
"License" shall mean the terms and conditions for use, reproduction,
|
|
||||||
and distribution as defined by Sections 1 through 9 of this document.
|
|
||||||
|
|
||||||
"Licensor" shall mean the copyright owner or entity authorized by
|
|
||||||
the copyright owner that is granting the License.
|
|
||||||
|
|
||||||
"Legal Entity" shall mean the union of the acting entity and all
|
|
||||||
other entities that control, are controlled by, or are under common
|
|
||||||
control with that entity. For the purposes of this definition,
|
|
||||||
"control" means (i) the power, direct or indirect, to cause the
|
|
||||||
direction or management of such entity, whether by contract or
|
|
||||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
||||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
||||||
|
|
||||||
"You" (or "Your") shall mean an individual or Legal Entity
|
|
||||||
exercising permissions granted by this License.
|
|
||||||
|
|
||||||
"Source" form shall mean the preferred form for making modifications,
|
|
||||||
including but not limited to software source code, documentation
|
|
||||||
source, and configuration files.
|
|
||||||
|
|
||||||
"Object" form shall mean any form resulting from mechanical
|
|
||||||
transformation or translation of a Source form, including but
|
|
||||||
not limited to compiled object code, generated documentation,
|
|
||||||
and conversions to other media types.
|
|
||||||
|
|
||||||
"Work" shall mean the work of authorship, whether in Source or
|
|
||||||
Object form, made available under the License, as indicated by a
|
|
||||||
copyright notice that is included in or attached to the work
|
|
||||||
(an example is provided in the Appendix below).
|
|
||||||
|
|
||||||
"Derivative Works" shall mean any work, whether in Source or Object
|
|
||||||
form, that is based on (or derived from) the Work and for which the
|
|
||||||
editorial revisions, annotations, elaborations, or other modifications
|
|
||||||
represent, as a whole, an original work of authorship. For the purposes
|
|
||||||
of this License, Derivative Works shall not include works that remain
|
|
||||||
separable from, or merely link (or bind by name) to the interfaces of,
|
|
||||||
the Work and Derivative Works thereof.
|
|
||||||
|
|
||||||
"Contribution" shall mean any work of authorship, including
|
|
||||||
the original version of the Work and any modifications or additions
|
|
||||||
to that Work or Derivative Works thereof, that is intentionally
|
|
||||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
||||||
or by an individual or Legal Entity authorized to submit on behalf of
|
|
||||||
the copyright owner. For the purposes of this definition, "submitted"
|
|
||||||
means any form of electronic, verbal, or written communication sent
|
|
||||||
to the Licensor or its representatives, including but not limited to
|
|
||||||
communication on electronic mailing lists, source code control systems,
|
|
||||||
and issue tracking systems that are managed by, or on behalf of, the
|
|
||||||
Licensor for the purpose of discussing and improving the Work, but
|
|
||||||
excluding communication that is conspicuously marked or otherwise
|
|
||||||
designated in writing by the copyright owner as "Not a Contribution."
|
|
||||||
|
|
||||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
||||||
on behalf of whom a Contribution has been received by Licensor and
|
|
||||||
subsequently incorporated within the Work.
|
|
||||||
|
|
||||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
copyright license to reproduce, prepare Derivative Works of,
|
|
||||||
publicly display, publicly perform, sublicense, and distribute the
|
|
||||||
Work and such Derivative Works in Source or Object form.
|
|
||||||
|
|
||||||
3. Grant of Patent License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
(except as stated in this section) patent license to make, have made,
|
|
||||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
||||||
where such license applies only to those patent claims licensable
|
|
||||||
by such Contributor that are necessarily infringed by their
|
|
||||||
Contribution(s) alone or by combination of their Contribution(s)
|
|
||||||
with the Work to which such Contribution(s) was submitted. If You
|
|
||||||
institute patent litigation against any entity (including a
|
|
||||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
||||||
or a Contribution incorporated within the Work constitutes direct
|
|
||||||
or contributory patent infringement, then any patent licenses
|
|
||||||
granted to You under this License for that Work shall terminate
|
|
||||||
as of the date such litigation is filed.
|
|
||||||
|
|
||||||
4. Redistribution. You may reproduce and distribute copies of the
|
|
||||||
Work or Derivative Works thereof in any medium, with or without
|
|
||||||
modifications, and in Source or Object form, provided that You
|
|
||||||
meet the following conditions:
|
|
||||||
|
|
||||||
(a) You must give any other recipients of the Work or
|
|
||||||
Derivative Works a copy of this License; and
|
|
||||||
|
|
||||||
(b) You must cause any modified files to carry prominent notices
|
|
||||||
stating that You changed the files; and
|
|
||||||
|
|
||||||
(c) You must retain, in the Source form of any Derivative Works
|
|
||||||
that You distribute, all copyright, patent, trademark, and
|
|
||||||
attribution notices from the Source form of the Work,
|
|
||||||
excluding those notices that do not pertain to any part of
|
|
||||||
the Derivative Works; and
|
|
||||||
|
|
||||||
(d) If the Work includes a "NOTICE" text file as part of its
|
|
||||||
distribution, then any Derivative Works that You distribute must
|
|
||||||
include a readable copy of the attribution notices contained
|
|
||||||
within such NOTICE file, excluding those notices that do not
|
|
||||||
pertain to any part of the Derivative Works, in at least one
|
|
||||||
of the following places: within a NOTICE text file distributed
|
|
||||||
as part of the Derivative Works; within the Source form or
|
|
||||||
documentation, if provided along with the Derivative Works; or,
|
|
||||||
within a display generated by the Derivative Works, if and
|
|
||||||
wherever such third-party notices normally appear. The contents
|
|
||||||
of the NOTICE file are for informational purposes only and
|
|
||||||
do not modify the License. You may add Your own attribution
|
|
||||||
notices within Derivative Works that You distribute, alongside
|
|
||||||
or as an addendum to the NOTICE text from the Work, provided
|
|
||||||
that such additional attribution notices cannot be construed
|
|
||||||
as modifying the License.
|
|
||||||
|
|
||||||
You may add Your own copyright statement to Your modifications and
|
|
||||||
may provide additional or different license terms and conditions
|
|
||||||
for use, reproduction, or distribution of Your modifications, or
|
|
||||||
for any such Derivative Works as a whole, provided Your use,
|
|
||||||
reproduction, and distribution of the Work otherwise complies with
|
|
||||||
the conditions stated in this License.
|
|
||||||
|
|
||||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
||||||
any Contribution intentionally submitted for inclusion in the Work
|
|
||||||
by You to the Licensor shall be under the terms and conditions of
|
|
||||||
this License, without any additional terms or conditions.
|
|
||||||
Notwithstanding the above, nothing herein shall supersede or modify
|
|
||||||
the terms of any separate license agreement you may have executed
|
|
||||||
with Licensor regarding such Contributions.
|
|
||||||
|
|
||||||
6. Trademarks. This License does not grant permission to use the trade
|
|
||||||
names, trademarks, service marks, or product names of the Licensor,
|
|
||||||
except as required for reasonable and customary use in describing the
|
|
||||||
origin of the Work and reproducing the content of the NOTICE file.
|
|
||||||
|
|
||||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
||||||
agreed to in writing, Licensor provides the Work (and each
|
|
||||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
||||||
implied, including, without limitation, any warranties or conditions
|
|
||||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
||||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
||||||
appropriateness of using or redistributing the Work and assume any
|
|
||||||
risks associated with Your exercise of permissions under this License.
|
|
||||||
|
|
||||||
8. Limitation of Liability. In no event and under no legal theory,
|
|
||||||
whether in tort (including negligence), contract, or otherwise,
|
|
||||||
unless required by applicable law (such as deliberate and grossly
|
|
||||||
negligent acts) or agreed to in writing, shall any Contributor be
|
|
||||||
liable to You for damages, including any direct, indirect, special,
|
|
||||||
incidental, or consequential damages of any character arising as a
|
|
||||||
result of this License or out of the use or inability to use the
|
|
||||||
Work (including but not limited to damages for loss of goodwill,
|
|
||||||
work stoppage, computer failure or malfunction, or any and all
|
|
||||||
other commercial damages or losses), even if such Contributor
|
|
||||||
has been advised of the possibility of such damages.
|
|
||||||
|
|
||||||
9. Accepting Warranty or Additional Liability. While redistributing
|
|
||||||
the Work or Derivative Works thereof, You may choose to offer,
|
|
||||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
||||||
or other liability obligations and/or rights consistent with this
|
|
||||||
License. However, in accepting such obligations, You may act only
|
|
||||||
on Your own behalf and on Your sole responsibility, not on behalf
|
|
||||||
of any other Contributor, and only if You agree to indemnify,
|
|
||||||
defend, and hold each Contributor harmless for any liability
|
|
||||||
incurred by, or claims asserted against, such Contributor by reason
|
|
||||||
of your accepting any such warranty or additional liability.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
APPENDIX: How to apply the Apache License to your work.
|
|
||||||
|
|
||||||
To apply the Apache License to your work, attach the following
|
|
||||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
||||||
replaced with your own identifying information. (Don't include
|
|
||||||
the brackets!) The text should be enclosed in the appropriate
|
|
||||||
comment syntax for the file format. We also recommend that a
|
|
||||||
file or class name and description of purpose be included on the
|
|
||||||
same "printed page" as the copyright notice for easier
|
|
||||||
identification within third-party archives.
|
|
||||||
|
|
||||||
Copyright [yyyy] [name of copyright owner]
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
21
LICENSE-MIT
21
LICENSE-MIT
@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2024 Turtle Graphics Library Contributors
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
31
README.md
31
README.md
@ -195,32 +195,7 @@ Add the `svg` feature to enable SVG export functionality:
|
|||||||
cargo run --example export_svg --features svg
|
cargo run --example export_svg --features svg
|
||||||
```
|
```
|
||||||
|
|
||||||
### Command-Line SVG Export
|
### Usage
|
||||||
|
|
||||||
When using the `turtle_main` macro with the `svg` feature enabled, you can export drawings directly to SVG files using the `--export-svg` command-line parameter:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Export any example to SVG without showing the window
|
|
||||||
cargo run --example macro_demo --features svg -- --export-svg output.svg
|
|
||||||
|
|
||||||
# Works with all turtle_main-based examples
|
|
||||||
cargo run --example hello_turtle --features svg -- --export-svg square.svg
|
|
||||||
```
|
|
||||||
|
|
||||||
This will:
|
|
||||||
- Execute all drawing commands instantly (no animation)
|
|
||||||
- Export the result to an SVG file
|
|
||||||
- Exit immediately without opening a window
|
|
||||||
|
|
||||||
**Note**: The program still requires a display context to initialize. In headless environments, use `xvfb-run`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
xvfb-run -a cargo run --example macro_demo --features svg -- --export-svg output.svg
|
|
||||||
```
|
|
||||||
|
|
||||||
### Programmatic SVG Export
|
|
||||||
|
|
||||||
You can also export SVG programmatically from your code:
|
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use turtle_lib::*;
|
use turtle_lib::*;
|
||||||
@ -264,10 +239,6 @@ cargo run --example nikolaus
|
|||||||
# SVG export example (requires --features svg)
|
# SVG export example (requires --features svg)
|
||||||
cargo run --example export_svg --features svg
|
cargo run --example export_svg --features svg
|
||||||
|
|
||||||
# Export any example to SVG using CLI parameter (requires --features svg)
|
|
||||||
cargo run --example macro_demo --features svg -- --export-svg output.svg
|
|
||||||
cargo run --example hello_turtle --features svg -- --export-svg square.svg
|
|
||||||
|
|
||||||
# Logging example - shows how to enable debug output
|
# Logging example - shows how to enable debug output
|
||||||
cargo run --example logging_example
|
cargo run --example logging_example
|
||||||
RUST_LOG=turtle_lib=debug cargo run --example logging_example
|
RUST_LOG=turtle_lib=debug cargo run --example logging_example
|
||||||
|
|||||||
@ -16,14 +16,6 @@ use syn::{parse_macro_input, ItemFn};
|
|||||||
/// - Creates a turtle instance (`turtle`)
|
/// - Creates a turtle instance (`turtle`)
|
||||||
/// - Sets up the `TurtleApp` with your drawing commands
|
/// - Sets up the `TurtleApp` with your drawing commands
|
||||||
/// - Provides a main loop with rendering and quit handling (ESC or Q)
|
/// - Provides a main loop with rendering and quit handling (ESC or Q)
|
||||||
/// - Adds command-line parameter support for SVG export (when `svg` feature is enabled)
|
|
||||||
///
|
|
||||||
/// # Command-Line Parameters
|
|
||||||
///
|
|
||||||
/// When the `svg` feature is enabled, the following command-line parameter is available:
|
|
||||||
///
|
|
||||||
/// * `--export-svg <filename>` - Exports the drawing to an SVG file and exits immediately
|
|
||||||
/// without opening the window. Example: `cargo run --features svg -- --export-svg output.svg`
|
|
||||||
///
|
///
|
||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
@ -53,13 +45,6 @@ use syn::{parse_macro_input, ItemFn};
|
|||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
///
|
///
|
||||||
/// # SVG Export Example
|
|
||||||
///
|
|
||||||
/// ```bash
|
|
||||||
/// # Run with SVG export (requires svg feature)
|
|
||||||
/// cargo run --package turtle-lib --example macro_demo --features svg -- --export-svg output.svg
|
|
||||||
/// ```
|
|
||||||
///
|
|
||||||
/// This expands to approximately:
|
/// This expands to approximately:
|
||||||
///
|
///
|
||||||
/// ```ignore
|
/// ```ignore
|
||||||
@ -68,10 +53,6 @@ use syn::{parse_macro_input, ItemFn};
|
|||||||
///
|
///
|
||||||
/// #[macroquad::main("My Turtle Drawing")]
|
/// #[macroquad::main("My Turtle Drawing")]
|
||||||
/// async fn main() {
|
/// async fn main() {
|
||||||
/// // Parse CLI args for --export-svg flag
|
|
||||||
/// let args: Vec<String> = std::env::args().collect();
|
|
||||||
/// // ... (argument parsing logic)
|
|
||||||
///
|
|
||||||
/// let mut turtle = create_turtle_plan();
|
/// let mut turtle = create_turtle_plan();
|
||||||
///
|
///
|
||||||
/// // Your drawing code here
|
/// // Your drawing code here
|
||||||
@ -82,8 +63,6 @@ use syn::{parse_macro_input, ItemFn};
|
|||||||
///
|
///
|
||||||
/// let mut app = TurtleApp::new().with_commands(turtle.build());
|
/// let mut app = TurtleApp::new().with_commands(turtle.build());
|
||||||
///
|
///
|
||||||
/// // If --export-svg flag is present, export and exit
|
|
||||||
/// // Otherwise, enter normal rendering loop
|
|
||||||
/// loop {
|
/// loop {
|
||||||
/// clear_background(WHITE);
|
/// clear_background(WHITE);
|
||||||
/// app.update();
|
/// app.update();
|
||||||
@ -118,29 +97,15 @@ pub fn turtle_main(args: TokenStream, input: TokenStream) -> TokenStream {
|
|||||||
// Check if the function has the expected signature
|
// Check if the function has the expected signature
|
||||||
let has_turtle_param = input_fn.sig.inputs.len() == 1;
|
let has_turtle_param = input_fn.sig.inputs.len() == 1;
|
||||||
|
|
||||||
// Note: The following code has some duplication between the two branches
|
|
||||||
// (with/without turtle parameter). This is intentional in proc macros as
|
|
||||||
// we're generating different code paths, and extracting the common parts
|
|
||||||
// into helper functions would make the macro more complex without significant benefit.
|
|
||||||
|
|
||||||
let expanded = if has_turtle_param {
|
let expanded = if has_turtle_param {
|
||||||
// Function takes a turtle parameter
|
// Function takes a turtle parameter
|
||||||
quote! {
|
quote! {
|
||||||
#[macroquad::main(#window_title)]
|
#[macroquad::main(#window_title)]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
// Build function reused for both export and normal rendering
|
|
||||||
let mut build_commands = |turtle: &mut turtle_lib::TurtlePlan| {
|
|
||||||
#fn_name(turtle);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle optional SVG export internally in turtle-lib
|
|
||||||
turtle_lib::export::handle_svg_export(&mut build_commands);
|
|
||||||
|
|
||||||
// Normal rendering mode (with window)
|
|
||||||
let mut turtle = turtle_lib::create_turtle_plan();
|
let mut turtle = turtle_lib::create_turtle_plan();
|
||||||
|
|
||||||
// Call the user's function with the turtle
|
// Call the user's function with the turtle
|
||||||
build_commands(&mut turtle);
|
#fn_name(&mut turtle);
|
||||||
|
|
||||||
let mut app = turtle_lib::TurtleApp::new()
|
let mut app = turtle_lib::TurtleApp::new()
|
||||||
.with_commands(turtle.build());
|
.with_commands(turtle.build());
|
||||||
@ -174,18 +139,10 @@ pub fn turtle_main(args: TokenStream, input: TokenStream) -> TokenStream {
|
|||||||
quote! {
|
quote! {
|
||||||
#[macroquad::main(#window_title)]
|
#[macroquad::main(#window_title)]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
// Build function reused for both export and normal rendering
|
|
||||||
let mut build_commands = |turtle: &mut turtle_lib::TurtlePlan| {
|
|
||||||
let turtle = turtle;
|
|
||||||
#fn_block
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle optional SVG export internally in turtle-lib
|
|
||||||
turtle_lib::export::handle_svg_export(&mut build_commands);
|
|
||||||
|
|
||||||
// Normal rendering mode (with window)
|
|
||||||
let mut turtle = turtle_lib::create_turtle_plan();
|
let mut turtle = turtle_lib::create_turtle_plan();
|
||||||
build_commands(&mut turtle);
|
|
||||||
|
// Inline the user's code
|
||||||
|
#fn_block
|
||||||
|
|
||||||
let mut app = turtle_lib::TurtleApp::new()
|
let mut app = turtle_lib::TurtleApp::new()
|
||||||
.with_commands(turtle.build());
|
.with_commands(turtle.build());
|
||||||
|
|||||||
@ -80,10 +80,10 @@ fn draw(turtle: &mut TurtlePlan) {
|
|||||||
|
|
||||||
// Draw and label 4 points (one per quadrant)
|
// Draw and label 4 points (one per quadrant)
|
||||||
let points = vec![
|
let points = vec![
|
||||||
(vec2(100.0, 50.0), "A(2|1)"),
|
(vec2(120.0, 100.0), "A(2|1)"),
|
||||||
(vec2(-100.0, 50.0), "B(-2|1)"),
|
(vec2(-120.0, 100.0), "B(-2|1)"),
|
||||||
(vec2(-100.0, -50.0), "C(-2|-1)"),
|
(vec2(-120.0, -100.0), "C(-2|-1)"),
|
||||||
(vec2(100.0, -50.0), "D(2|-1)"),
|
(vec2(120.0, -100.0), "D(2|-1)"),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (position, label) in points {
|
for (position, label) in points {
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
//! Beispiel: Exportiere ein SVG aus einer einfachen Zeichnung
|
//! Beispiel: Exportiere ein SVG aus einer einfachen Zeichnung
|
||||||
|
|
||||||
#[cfg(feature = "svg")]
|
|
||||||
use turtle_lib::*;
|
use turtle_lib::*;
|
||||||
|
|
||||||
#[cfg(feature = "svg")]
|
#[cfg(feature = "svg")]
|
||||||
|
|||||||
@ -20,7 +20,7 @@ fn koch(depth: u32, turtle: &mut TurtlePlan, distance: f32) {
|
|||||||
#[turtle_main("Koch Snowflake")]
|
#[turtle_main("Koch Snowflake")]
|
||||||
fn draw(turtle: &mut TurtlePlan) {
|
fn draw(turtle: &mut TurtlePlan) {
|
||||||
// Position turtle
|
// Position turtle
|
||||||
turtle.set_speed(5000);
|
turtle.set_speed(1001);
|
||||||
turtle.pen_up();
|
turtle.pen_up();
|
||||||
turtle.backward(150.0);
|
turtle.backward(150.0);
|
||||||
|
|
||||||
@ -28,7 +28,7 @@ fn draw(turtle: &mut TurtlePlan) {
|
|||||||
|
|
||||||
// Draw Koch snowflake (triangle of Koch curves)
|
// Draw Koch snowflake (triangle of Koch curves)
|
||||||
for _ in 0..3 {
|
for _ in 0..3 {
|
||||||
koch(6, turtle, 300.0);
|
koch(4, turtle, 300.0);
|
||||||
turtle.right(120.0);
|
turtle.right(120.0);
|
||||||
turtle.set_speed(1200);
|
turtle.set_speed(1200);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,36 +0,0 @@
|
|||||||
//! Test example for CLI SVG export feature
|
|
||||||
//!
|
|
||||||
//! Run this with: cargo run --package turtle-lib --example test_svg_export --features svg -- --export-svg test_output.svg
|
|
||||||
|
|
||||||
use turtle_lib::*;
|
|
||||||
|
|
||||||
#[turtle_main("SVG Export Test")]
|
|
||||||
fn draw_test(turtle: &mut TurtlePlan) {
|
|
||||||
turtle.set_pen_color(RED);
|
|
||||||
turtle.set_pen_width(3.0);
|
|
||||||
|
|
||||||
// Draw a square
|
|
||||||
for _ in 0..4 {
|
|
||||||
turtle.forward(100.0);
|
|
||||||
turtle.right(90.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw a circle
|
|
||||||
turtle.set_pen_color(BLUE);
|
|
||||||
turtle.pen_up();
|
|
||||||
turtle.forward(150.0);
|
|
||||||
turtle.pen_down();
|
|
||||||
turtle.circle_left(50.0, 360.0, 36);
|
|
||||||
|
|
||||||
// Draw a filled triangle
|
|
||||||
turtle.set_fill_color(GREEN);
|
|
||||||
turtle.pen_up();
|
|
||||||
turtle.go_to(vec2(-50.0, 100.0));
|
|
||||||
turtle.pen_down();
|
|
||||||
turtle.begin_fill();
|
|
||||||
for _ in 0..3 {
|
|
||||||
turtle.forward(80.0);
|
|
||||||
turtle.right(120.0);
|
|
||||||
}
|
|
||||||
turtle.end_fill();
|
|
||||||
}
|
|
||||||
@ -23,6 +23,5 @@ fn draw(turtle: &mut TurtlePlan) {
|
|||||||
.left(90.0)
|
.left(90.0)
|
||||||
.pen_down()
|
.pen_down()
|
||||||
.circle_right(8.0, 360.0, 12)
|
.circle_right(8.0, 360.0, 12)
|
||||||
.end_fill()
|
.end_fill();
|
||||||
.hide();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
//! Builder pattern traits for creating turtle command sequences
|
//! Builder pattern traits for creating turtle command sequences
|
||||||
|
|
||||||
use crate::commands::{CommandQueue, TurtleCommand};
|
use crate::commands::{CommandQueue, TurtleCommand};
|
||||||
use crate::general::{AnimationSpeed, Color, Coordinate, Degrees, FontSize, Precision};
|
use crate::general::{AnimationSpeed, Color, Coordinate, FontSize, Precision};
|
||||||
use crate::shapes::{ShapeType, TurtleShape};
|
use crate::shapes::{ShapeType, TurtleShape};
|
||||||
|
|
||||||
/// Trait for adding commands to a queue
|
/// Trait for adding commands to a queue
|
||||||
@ -91,10 +91,10 @@ pub trait Turnable: WithCommands {
|
|||||||
/// ```
|
/// ```
|
||||||
fn left<T>(&mut self, angle: T) -> &mut Self
|
fn left<T>(&mut self, angle: T) -> &mut Self
|
||||||
where
|
where
|
||||||
T: Into<Degrees>,
|
T: Into<Precision>,
|
||||||
{
|
{
|
||||||
self.get_commands_mut()
|
let degrees: Precision = angle.into();
|
||||||
.push(TurtleCommand::Turn(-angle.into()));
|
self.get_commands_mut().push(TurtleCommand::Turn(-degrees));
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -118,10 +118,10 @@ pub trait Turnable: WithCommands {
|
|||||||
/// ```
|
/// ```
|
||||||
fn right<T>(&mut self, angle: T) -> &mut Self
|
fn right<T>(&mut self, angle: T) -> &mut Self
|
||||||
where
|
where
|
||||||
T: Into<Degrees>,
|
T: Into<Precision>,
|
||||||
{
|
{
|
||||||
self.get_commands_mut()
|
let degrees: Precision = angle.into();
|
||||||
.push(TurtleCommand::Turn(angle.into()));
|
self.get_commands_mut().push(TurtleCommand::Turn(degrees));
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -160,12 +160,13 @@ pub trait CurvedMovement: WithCommands {
|
|||||||
fn circle_left<R, A>(&mut self, radius: R, angle: A, steps: usize) -> &mut Self
|
fn circle_left<R, A>(&mut self, radius: R, angle: A, steps: usize) -> &mut Self
|
||||||
where
|
where
|
||||||
R: Into<Precision>,
|
R: Into<Precision>,
|
||||||
A: Into<Degrees>,
|
A: Into<Precision>,
|
||||||
{
|
{
|
||||||
let r: Precision = radius.into();
|
let r: Precision = radius.into();
|
||||||
|
let a: Precision = angle.into();
|
||||||
self.get_commands_mut().push(TurtleCommand::Circle {
|
self.get_commands_mut().push(TurtleCommand::Circle {
|
||||||
radius: r,
|
radius: r,
|
||||||
angle: angle.into(),
|
angle: a,
|
||||||
steps,
|
steps,
|
||||||
direction: crate::circle_geometry::CircleDirection::Left,
|
direction: crate::circle_geometry::CircleDirection::Left,
|
||||||
});
|
});
|
||||||
@ -206,12 +207,13 @@ pub trait CurvedMovement: WithCommands {
|
|||||||
fn circle_right<R, A>(&mut self, radius: R, angle: A, steps: usize) -> &mut Self
|
fn circle_right<R, A>(&mut self, radius: R, angle: A, steps: usize) -> &mut Self
|
||||||
where
|
where
|
||||||
R: Into<Precision>,
|
R: Into<Precision>,
|
||||||
A: Into<Degrees>,
|
A: Into<Precision>,
|
||||||
{
|
{
|
||||||
let r: Precision = radius.into();
|
let r: Precision = radius.into();
|
||||||
|
let a: Precision = angle.into();
|
||||||
self.get_commands_mut().push(TurtleCommand::Circle {
|
self.get_commands_mut().push(TurtleCommand::Circle {
|
||||||
radius: r,
|
radius: r,
|
||||||
angle: angle.into(),
|
angle: a,
|
||||||
steps,
|
steps,
|
||||||
direction: crate::circle_geometry::CircleDirection::Right,
|
direction: crate::circle_geometry::CircleDirection::Right,
|
||||||
});
|
});
|
||||||
@ -365,9 +367,9 @@ impl TurtlePlan {
|
|||||||
/// .forward(100.0);
|
/// .forward(100.0);
|
||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
pub fn set_heading<T: Into<Degrees>>(&mut self, heading: T) -> &mut Self {
|
pub fn set_heading(&mut self, heading: Precision) -> &mut Self {
|
||||||
self.queue
|
self.queue
|
||||||
.push(TurtleCommand::SetHeading(-heading.into().as_radians()));
|
.push(TurtleCommand::SetHeading(-heading.to_radians()));
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,43 +1,7 @@
|
|||||||
//! Circle geometry calculations - single source of truth for `circle_left` and `circle_right`
|
//! Circle geometry calculations - single source of truth for `circle_left` and `circle_right`
|
||||||
|
|
||||||
use crate::general::Radians;
|
|
||||||
use macroquad::prelude::*;
|
use macroquad::prelude::*;
|
||||||
|
|
||||||
/// Generate evenly-spaced points along a circular arc.
|
|
||||||
///
|
|
||||||
/// Returns exactly `steps` points, uniformly distributed from (not including)
|
|
||||||
/// the arc start to (including) the arc end. This is the **single source of
|
|
||||||
/// truth** for arc sampling used by tessellation, tween stroke drawing, and
|
|
||||||
/// fill-polygon preview.
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
/// * `center` — centre of the circle
|
|
||||||
/// * `radius` — arc radius
|
|
||||||
/// * `start_angle` — angle from `center` to the turtle's start position (radians)
|
|
||||||
/// * `sweep_angle` — total arc sweep in radians (absolute; sign comes from `direction`)
|
|
||||||
/// * `steps` — number of sample points (clamped to ≥ 1)
|
|
||||||
/// * `direction` — which way the arc curves
|
|
||||||
pub(crate) fn arc_points(
|
|
||||||
center: Vec2,
|
|
||||||
radius: f32,
|
|
||||||
start_angle: f32,
|
|
||||||
sweep_angle: f32,
|
|
||||||
steps: usize,
|
|
||||||
direction: CircleDirection,
|
|
||||||
) -> Vec<Vec2> {
|
|
||||||
let n = steps.max(1);
|
|
||||||
let step_size = sweep_angle / n as f32;
|
|
||||||
(1..=n)
|
|
||||||
.map(|i| {
|
|
||||||
let a = match direction {
|
|
||||||
CircleDirection::Left => start_angle - step_size * i as f32,
|
|
||||||
CircleDirection::Right => start_angle + step_size * i as f32,
|
|
||||||
};
|
|
||||||
Vec2::new(center.x + radius * a.cos(), center.y + radius * a.sin())
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Direction of circular motion (in screen coordinates with Y-down)
|
/// Direction of circular motion (in screen coordinates with Y-down)
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum CircleDirection {
|
pub enum CircleDirection {
|
||||||
@ -46,11 +10,11 @@ pub enum CircleDirection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Encapsulates all geometry for a circular arc
|
/// Encapsulates all geometry for a circular arc
|
||||||
pub(crate) struct CircleGeometry {
|
pub struct CircleGeometry {
|
||||||
pub(crate) center: Vec2,
|
pub center: Vec2,
|
||||||
pub(crate) radius: f32,
|
pub radius: f32,
|
||||||
pub(crate) start_angle_from_center: f32, // radians
|
pub start_angle_from_center: f32, // radians
|
||||||
pub(crate) direction: CircleDirection,
|
pub direction: CircleDirection,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CircleGeometry {
|
impl CircleGeometry {
|
||||||
@ -58,15 +22,12 @@ impl CircleGeometry {
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
turtle_pos: Vec2,
|
turtle_pos: Vec2,
|
||||||
turtle_heading: Radians,
|
turtle_heading: f32,
|
||||||
radius: f32,
|
radius: f32,
|
||||||
direction: CircleDirection,
|
direction: CircleDirection,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
use std::f32::consts::FRAC_PI_2;
|
use std::f32::consts::FRAC_PI_2;
|
||||||
|
|
||||||
// Extract raw f32 once — all arithmetic below is in radians
|
|
||||||
let heading = turtle_heading.value();
|
|
||||||
|
|
||||||
// Calculate center based on direction
|
// Calculate center based on direction
|
||||||
// In screen coordinates (Y-down):
|
// In screen coordinates (Y-down):
|
||||||
// - Left turn (counter-clockwise visually): center is perpendicular-left from turtle's perspective
|
// - Left turn (counter-clockwise visually): center is perpendicular-left from turtle's perspective
|
||||||
@ -74,8 +35,8 @@ impl CircleGeometry {
|
|||||||
// - Right turn (clockwise visually): center is perpendicular-right from turtle's perspective
|
// - Right turn (clockwise visually): center is perpendicular-right from turtle's perspective
|
||||||
// which is heading + π/2 (rotated counter-clockwise from heading vector)
|
// which is heading + π/2 (rotated counter-clockwise from heading vector)
|
||||||
let center_offset_angle = match direction {
|
let center_offset_angle = match direction {
|
||||||
CircleDirection::Left => heading - FRAC_PI_2,
|
CircleDirection::Left => turtle_heading - FRAC_PI_2,
|
||||||
CircleDirection::Right => heading + FRAC_PI_2,
|
CircleDirection::Right => turtle_heading + FRAC_PI_2,
|
||||||
};
|
};
|
||||||
|
|
||||||
let center = vec2(
|
let center = vec2(
|
||||||
@ -85,8 +46,8 @@ impl CircleGeometry {
|
|||||||
|
|
||||||
// Angle from center back to turtle position
|
// Angle from center back to turtle position
|
||||||
let start_angle_from_center = match direction {
|
let start_angle_from_center = match direction {
|
||||||
CircleDirection::Left => heading + FRAC_PI_2,
|
CircleDirection::Left => turtle_heading + FRAC_PI_2,
|
||||||
CircleDirection::Right => heading - FRAC_PI_2,
|
CircleDirection::Right => turtle_heading - FRAC_PI_2,
|
||||||
};
|
};
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
@ -190,7 +151,7 @@ mod tests {
|
|||||||
fn test_circle_left_geometry() {
|
fn test_circle_left_geometry() {
|
||||||
let geom = CircleGeometry::new(
|
let geom = CircleGeometry::new(
|
||||||
vec2(0.0, 0.0),
|
vec2(0.0, 0.0),
|
||||||
Radians::new(0.0), // heading east (0 radians)
|
0.0, // heading east (0 radians)
|
||||||
100.0,
|
100.0,
|
||||||
CircleDirection::Left,
|
CircleDirection::Left,
|
||||||
);
|
);
|
||||||
@ -222,7 +183,7 @@ mod tests {
|
|||||||
fn test_circle_right_geometry() {
|
fn test_circle_right_geometry() {
|
||||||
let geom = CircleGeometry::new(
|
let geom = CircleGeometry::new(
|
||||||
vec2(0.0, 0.0),
|
vec2(0.0, 0.0),
|
||||||
Radians::new(0.0), // heading east
|
0.0, // heading east
|
||||||
100.0,
|
100.0,
|
||||||
CircleDirection::Right,
|
CircleDirection::Right,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,150 +0,0 @@
|
|||||||
//! Centralised behavioural contract for `TurtleCommand`.
|
|
||||||
//!
|
|
||||||
//! All knowledge about what a command does to `TurtleParams`, how long it
|
|
||||||
//! animates, and whether it produces a drawable stroke lives here.
|
|
||||||
//!
|
|
||||||
//! Adding a new `TurtleCommand` variant requires editing this file (and
|
|
||||||
//! `execute_command_side_effects` / `tessellate_command` in `execution.rs`
|
|
||||||
//! if the variant has side effects or produces a mesh).
|
|
||||||
|
|
||||||
use crate::circle_geometry::{CircleDirection, CircleGeometry};
|
|
||||||
use crate::commands::TurtleCommand;
|
|
||||||
use crate::general::{AnimationSpeed, Radians};
|
|
||||||
use crate::state::TurtleParams;
|
|
||||||
use crate::tweening::normalize_angle;
|
|
||||||
use macroquad::prelude::vec2;
|
|
||||||
|
|
||||||
impl TurtleCommand {
|
|
||||||
/// Apply this command's effect to `params` in place.
|
|
||||||
///
|
|
||||||
/// This is the **single source of truth** for what a command changes in
|
|
||||||
/// `TurtleParams`. Used by:
|
|
||||||
/// - `execute_command()` — instant-mode path, after side-effects return `false`
|
|
||||||
/// - `TweenController::calculate_target_state()` — animated-mode target computation
|
|
||||||
///
|
|
||||||
/// Variants handled by `execute_command_side_effects` (`BeginFill`, `EndFill`,
|
|
||||||
/// `PenUp`, `PenDown`, `WriteText`, `Reset`) are included here so that
|
|
||||||
/// `calculate_target_state` can produce a correct tween target. In the
|
|
||||||
/// `execute_command()` call path those variants never reach this method because
|
|
||||||
/// `execute_command_side_effects` returns `true` and the caller returns early —
|
|
||||||
/// there is no double-application.
|
|
||||||
pub(crate) fn apply_to_params(&self, params: &mut TurtleParams) {
|
|
||||||
match self {
|
|
||||||
TurtleCommand::Move(dist) => {
|
|
||||||
let dx = dist * params.heading.cos();
|
|
||||||
let dy = dist * params.heading.sin();
|
|
||||||
params.position = vec2(params.position.x + dx, params.position.y + dy);
|
|
||||||
}
|
|
||||||
TurtleCommand::Turn(angle) => {
|
|
||||||
params.heading = normalize_angle(params.heading + angle.as_radians().value());
|
|
||||||
}
|
|
||||||
TurtleCommand::Circle {
|
|
||||||
radius,
|
|
||||||
angle,
|
|
||||||
direction,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
let geom = CircleGeometry::new(
|
|
||||||
params.position,
|
|
||||||
Radians::new(params.heading),
|
|
||||||
*radius,
|
|
||||||
*direction,
|
|
||||||
);
|
|
||||||
let angle_rad = angle.as_radians().value();
|
|
||||||
params.position = geom.position_at_angle(angle_rad);
|
|
||||||
params.heading = normalize_angle(match direction {
|
|
||||||
CircleDirection::Left => params.heading - angle_rad,
|
|
||||||
CircleDirection::Right => params.heading + angle_rad,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
TurtleCommand::Goto(coord) => {
|
|
||||||
// Y-flip: turtle graphics Y+ = up; Macroquad Y+ = down
|
|
||||||
params.position = vec2(coord.x, -coord.y);
|
|
||||||
}
|
|
||||||
TurtleCommand::SetHeading(heading) => {
|
|
||||||
params.heading = normalize_angle(heading.value());
|
|
||||||
}
|
|
||||||
TurtleCommand::SetColor(color) => {
|
|
||||||
params.color = *color;
|
|
||||||
}
|
|
||||||
TurtleCommand::SetFillColor(color) => {
|
|
||||||
params.fill_color = *color;
|
|
||||||
}
|
|
||||||
TurtleCommand::SetPenWidth(width) => {
|
|
||||||
params.pen_width = *width;
|
|
||||||
}
|
|
||||||
TurtleCommand::SetSpeed(speed) => {
|
|
||||||
params.speed = *speed;
|
|
||||||
}
|
|
||||||
TurtleCommand::SetShape(shape) => {
|
|
||||||
params.shape = shape.clone();
|
|
||||||
}
|
|
||||||
TurtleCommand::PenUp => {
|
|
||||||
params.pen_down = false;
|
|
||||||
}
|
|
||||||
TurtleCommand::PenDown => {
|
|
||||||
params.pen_down = true;
|
|
||||||
}
|
|
||||||
TurtleCommand::ShowTurtle => {
|
|
||||||
params.visible = true;
|
|
||||||
}
|
|
||||||
TurtleCommand::HideTurtle => {
|
|
||||||
params.visible = false;
|
|
||||||
}
|
|
||||||
TurtleCommand::Reset => {
|
|
||||||
*params = TurtleParams::default();
|
|
||||||
}
|
|
||||||
// Fill/text commands do not change TurtleParams for tweening purposes;
|
|
||||||
// their effects are handled entirely by execute_command_side_effects.
|
|
||||||
TurtleCommand::BeginFill | TurtleCommand::EndFill | TurtleCommand::WriteText { .. } => {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Duration in seconds for this command's animation at the given speed.
|
|
||||||
///
|
|
||||||
/// Returns `0.01` (minimum) for commands that have no animated component.
|
|
||||||
/// This is the **single source of truth**; replaces
|
|
||||||
/// `TweenController::calculate_duration_with_state` in `tweening.rs`.
|
|
||||||
pub(crate) fn animation_duration(&self, params: &TurtleParams, speed: AnimationSpeed) -> f64 {
|
|
||||||
let AnimationSpeed::Animated(mut spd) = speed else {
|
|
||||||
// Instant mode — duration is irrelevant; return the minimum so tweener
|
|
||||||
// infrastructure still has a valid duration if called accidentally.
|
|
||||||
return f64::from(0.01_f32);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Exponential speed scaling for high values (matches original behaviour)
|
|
||||||
if spd > 100.0 {
|
|
||||||
spd *= spd / 100.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let base: f32 = match self {
|
|
||||||
TurtleCommand::Move(dist) => dist.abs() / spd,
|
|
||||||
TurtleCommand::Turn(angle) => angle.value().abs() / (spd * 1.8),
|
|
||||||
TurtleCommand::Circle { radius, angle, .. } => {
|
|
||||||
let arc_length = radius * angle.as_radians().value().abs();
|
|
||||||
arc_length / spd
|
|
||||||
}
|
|
||||||
TurtleCommand::Goto(target) => {
|
|
||||||
let dx = target.x - params.position.x;
|
|
||||||
let dy = target.y - params.position.y;
|
|
||||||
(dx * dx + dy * dy).sqrt() / spd
|
|
||||||
}
|
|
||||||
_ => 0.0,
|
|
||||||
};
|
|
||||||
|
|
||||||
f64::from(base.max(0.01))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether executing this command (when pen is down) produces a stroke or fill mesh.
|
|
||||||
///
|
|
||||||
/// This is the **single source of truth**; replaces
|
|
||||||
/// `TweenController::command_creates_drawing` in `tweening.rs`.
|
|
||||||
#[must_use]
|
|
||||||
pub(crate) fn produces_drawing(&self) -> bool {
|
|
||||||
matches!(
|
|
||||||
self,
|
|
||||||
TurtleCommand::Move(_) | TurtleCommand::Circle { .. } | TurtleCommand::Goto(_)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
//! Turtle commands and command queue
|
//! Turtle commands and command queue
|
||||||
|
|
||||||
use crate::general::{AnimationSpeed, Color, Coordinate, Degrees, FontSize, Precision, Radians};
|
use crate::general::{AnimationSpeed, Color, Coordinate, FontSize, Precision};
|
||||||
use crate::shapes::TurtleShape;
|
use crate::shapes::TurtleShape;
|
||||||
|
|
||||||
/// Individual turtle commands
|
/// Individual turtle commands
|
||||||
@ -9,14 +9,13 @@ pub enum TurtleCommand {
|
|||||||
// Movement (positive = forward, negative = backward)
|
// Movement (positive = forward, negative = backward)
|
||||||
Move(Precision),
|
Move(Precision),
|
||||||
|
|
||||||
// Rotation (positive = right/clockwise, negative = left/counter-clockwise)
|
// Rotation (positive = right/clockwise, negative = left/counter-clockwise in degrees)
|
||||||
// Stored in degrees — the natural unit at the user-facing API boundary.
|
Turn(Precision),
|
||||||
Turn(Degrees),
|
|
||||||
|
|
||||||
// Circle drawing
|
// Circle drawing
|
||||||
Circle {
|
Circle {
|
||||||
radius: Precision,
|
radius: Precision,
|
||||||
angle: Degrees, // sweep angle — degrees, as supplied by the user
|
angle: Precision, // degrees
|
||||||
steps: usize,
|
steps: usize,
|
||||||
direction: crate::circle_geometry::CircleDirection,
|
direction: crate::circle_geometry::CircleDirection,
|
||||||
},
|
},
|
||||||
@ -34,8 +33,7 @@ pub enum TurtleCommand {
|
|||||||
|
|
||||||
// Position
|
// Position
|
||||||
Goto(Coordinate),
|
Goto(Coordinate),
|
||||||
/// Heading stored as radians — already converted by the builder.
|
SetHeading(Precision), // radians
|
||||||
SetHeading(Radians),
|
|
||||||
|
|
||||||
// Visibility
|
// Visibility
|
||||||
ShowTurtle,
|
ShowTurtle,
|
||||||
@ -55,14 +53,11 @@ pub enum TurtleCommand {
|
|||||||
Reset,
|
Reset,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A pure-data sequence of turtle commands.
|
/// Queue of turtle commands with execution state
|
||||||
///
|
|
||||||
/// `CommandQueue` is intentionally *not* an `Iterator` — it carries no cursor
|
|
||||||
/// state. Execution state ("which command are we on?") belongs to the
|
|
||||||
/// consumer; `TweenController` owns the cursor that walks this queue.
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct CommandQueue {
|
pub struct CommandQueue {
|
||||||
commands: Vec<TurtleCommand>,
|
commands: Vec<TurtleCommand>,
|
||||||
|
current_index: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommandQueue {
|
impl CommandQueue {
|
||||||
@ -70,12 +65,14 @@ impl CommandQueue {
|
|||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
commands: Vec::new(),
|
commands: Vec::new(),
|
||||||
|
current_index: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn with_capacity(capacity: usize) -> Self {
|
pub fn with_capacity(capacity: usize) -> Self {
|
||||||
Self {
|
Self {
|
||||||
commands: Vec::with_capacity(capacity),
|
commands: Vec::with_capacity(capacity),
|
||||||
|
current_index: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -86,22 +83,26 @@ impl CommandQueue {
|
|||||||
pub fn extend(&mut self, commands: impl IntoIterator<Item = TurtleCommand>) {
|
pub fn extend(&mut self, commands: impl IntoIterator<Item = TurtleCommand>) {
|
||||||
self.commands.extend(commands);
|
self.commands.extend(commands);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return a reference to the command at `index`, or `None` if out of range.
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn get(&self, index: usize) -> Option<&TurtleCommand> {
|
pub fn is_complete(&self) -> bool {
|
||||||
self.commands.get(index)
|
self.current_index >= self.commands.len()
|
||||||
|
}
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.current_index = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn len(&self) -> usize {
|
pub fn len(&self) -> usize {
|
||||||
self.commands.len()
|
self.commands.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
self.commands.is_empty()
|
self.commands.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn remaining(&self) -> usize {
|
||||||
|
self.commands.len().saturating_sub(self.current_index)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for CommandQueue {
|
impl Default for CommandQueue {
|
||||||
@ -110,16 +111,16 @@ impl Default for CommandQueue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Consuming iteration — yields every command in order.
|
impl Iterator for CommandQueue {
|
||||||
///
|
|
||||||
/// This is used by `CommandQueue::extend` and `TweenController::append_commands`
|
|
||||||
/// to drain one queue into another. It does *not* imply that `CommandQueue`
|
|
||||||
/// itself is stateful; the cursor always lives in the consumer.
|
|
||||||
impl IntoIterator for CommandQueue {
|
|
||||||
type Item = TurtleCommand;
|
type Item = TurtleCommand;
|
||||||
type IntoIter = std::vec::IntoIter<TurtleCommand>;
|
|
||||||
|
|
||||||
fn into_iter(self) -> Self::IntoIter {
|
fn next(&mut self) -> Option<Self::Item> {
|
||||||
self.commands.into_iter()
|
if self.current_index < self.commands.len() {
|
||||||
|
let cmd = self.commands[self.current_index].clone();
|
||||||
|
self.current_index += 1;
|
||||||
|
Some(cmd)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -76,7 +76,7 @@ pub struct TurtleCommandSender {
|
|||||||
///
|
///
|
||||||
/// Paired with `TurtleCommandSender` via `turtle_command_channel()`.
|
/// Paired with `TurtleCommandSender` via `turtle_command_channel()`.
|
||||||
/// Automatically managed by `TurtleApp::process_commands()`.
|
/// Automatically managed by `TurtleApp::process_commands()`.
|
||||||
pub(crate) struct TurtleCommandReceiver {
|
pub struct TurtleCommandReceiver {
|
||||||
turtle_id: usize,
|
turtle_id: usize,
|
||||||
rx: Receiver<CommandQueue>,
|
rx: Receiver<CommandQueue>,
|
||||||
}
|
}
|
||||||
@ -212,7 +212,7 @@ impl TurtleCommandReceiver {
|
|||||||
/// # }
|
/// # }
|
||||||
/// ```
|
/// ```
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub(crate) fn turtle_command_channel(
|
pub fn turtle_command_channel(
|
||||||
turtle_id: usize,
|
turtle_id: usize,
|
||||||
buffer_size: usize,
|
buffer_size: usize,
|
||||||
) -> (TurtleCommandSender, TurtleCommandReceiver) {
|
) -> (TurtleCommandSender, TurtleCommandReceiver) {
|
||||||
|
|||||||
@ -12,9 +12,56 @@ use macroquad::prelude::*;
|
|||||||
// See https://easings.net/ for visual demonstrations
|
// See https://easings.net/ for visual demonstrations
|
||||||
use tween::CubicInOut;
|
use tween::CubicInOut;
|
||||||
|
|
||||||
/// Render the turtle world with active tween visualization.
|
/// Render the entire turtle world
|
||||||
|
pub fn render_world(world: &TurtleWorld) {
|
||||||
|
// Update camera zoom based on current screen size to prevent stretching
|
||||||
|
let camera = Camera2D {
|
||||||
|
zoom: vec2(1.0 / screen_width() * 2.0, 1.0 / screen_height() * 2.0),
|
||||||
|
target: world.camera.target,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set camera
|
||||||
|
set_camera(&camera);
|
||||||
|
|
||||||
|
// Draw all accumulated commands from all turtles
|
||||||
|
for turtle in &world.turtles {
|
||||||
|
for cmd in &turtle.commands {
|
||||||
|
match cmd {
|
||||||
|
DrawCommand::Mesh { data, source: _ } => {
|
||||||
|
// Rendering wie bisher
|
||||||
|
draw_mesh(&data.to_mesh());
|
||||||
|
// Hier könnte man das source für Debug/Export loggen
|
||||||
|
}
|
||||||
|
DrawCommand::Text {
|
||||||
|
text,
|
||||||
|
position,
|
||||||
|
heading,
|
||||||
|
font_size,
|
||||||
|
color,
|
||||||
|
source: _,
|
||||||
|
} => {
|
||||||
|
draw_text_command(text, *position, *heading, *font_size, *color);
|
||||||
|
// Hier könnte man das source für Debug/Export loggen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw all visible turtles
|
||||||
|
for turtle in &world.turtles {
|
||||||
|
if turtle.params.visible {
|
||||||
|
draw_turtle(&turtle.params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset to default camera
|
||||||
|
set_default_camera();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the turtle world with active tween visualization
|
||||||
#[allow(clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines)]
|
||||||
pub(crate) fn render_world_with_tweens(world: &TurtleWorld, zoom_level: f32) {
|
pub fn render_world_with_tweens(world: &TurtleWorld, zoom_level: f32) {
|
||||||
// Update camera zoom based on current screen size to prevent stretching
|
// Update camera zoom based on current screen size to prevent stretching
|
||||||
// Apply user zoom level by dividing by it (smaller zoom value = more zoomed in)
|
// Apply user zoom level by dividing by it (smaller zoom value = more zoomed in)
|
||||||
let camera = Camera2D {
|
let camera = Camera2D {
|
||||||
@ -33,7 +80,7 @@ pub(crate) fn render_world_with_tweens(world: &TurtleWorld, zoom_level: f32) {
|
|||||||
for turtle in &world.turtles {
|
for turtle in &world.turtles {
|
||||||
for cmd in &turtle.commands {
|
for cmd in &turtle.commands {
|
||||||
match cmd {
|
match cmd {
|
||||||
DrawCommand::Mesh { data } => {
|
DrawCommand::Mesh { data, source: _ } => {
|
||||||
draw_mesh(&data.to_mesh());
|
draw_mesh(&data.to_mesh());
|
||||||
}
|
}
|
||||||
DrawCommand::Text {
|
DrawCommand::Text {
|
||||||
@ -42,6 +89,7 @@ pub(crate) fn render_world_with_tweens(world: &TurtleWorld, zoom_level: f32) {
|
|||||||
heading,
|
heading,
|
||||||
font_size,
|
font_size,
|
||||||
color,
|
color,
|
||||||
|
source: _,
|
||||||
} => {
|
} => {
|
||||||
draw_text_command(text, *position, *heading, *font_size, *color);
|
draw_text_command(text, *position, *heading, *font_size, *color);
|
||||||
}
|
}
|
||||||
@ -128,32 +176,40 @@ pub(crate) fn render_world_with_tweens(world: &TurtleWorld, zoom_level: f32) {
|
|||||||
} = &tween.command
|
} = &tween.command
|
||||||
{
|
{
|
||||||
// Calculate partial arc vertices based on current progress
|
// Calculate partial arc vertices based on current progress
|
||||||
use crate::circle_geometry::{arc_points, CircleGeometry};
|
use crate::circle_geometry::CircleGeometry;
|
||||||
use crate::general::Radians;
|
|
||||||
let geom = CircleGeometry::new(
|
let geom = CircleGeometry::new(
|
||||||
tween.start_params.position,
|
tween.start_params.position,
|
||||||
Radians::new(tween.start_params.heading),
|
tween.start_params.heading,
|
||||||
*radius,
|
*radius,
|
||||||
*direction,
|
*direction,
|
||||||
);
|
); // Calculate progress
|
||||||
let elapsed = get_time() - tween.start_time;
|
let elapsed = get_time() - tween.start_time;
|
||||||
let progress = (elapsed / tween.duration).min(1.0);
|
let progress = (elapsed / tween.duration).min(1.0);
|
||||||
let eased_progress = CubicInOut.tween(1.0, progress as f32);
|
let eased_progress = CubicInOut.tween(1.0, progress as f32);
|
||||||
|
|
||||||
// Delegate to the shared arc_points function — same sampling
|
// Generate arc vertices for the partial arc
|
||||||
// strategy as tessellate_arc, eliminating the divergence.
|
let num_samples = *steps.max(&1);
|
||||||
let samples_to_draw =
|
let samples_to_draw =
|
||||||
(((*steps).max(1) as f32 * eased_progress) as usize).max(1);
|
((num_samples as f32 * eased_progress) as usize).max(1);
|
||||||
let sweep_so_far = angle.as_radians().value() * eased_progress;
|
|
||||||
for pt in arc_points(
|
for i in 1..=samples_to_draw {
|
||||||
geom.center,
|
let sample_progress = i as f32 / num_samples as f32;
|
||||||
*radius,
|
let current_angle = match direction {
|
||||||
geom.start_angle_from_center,
|
crate::circle_geometry::CircleDirection::Left => {
|
||||||
sweep_so_far,
|
geom.start_angle_from_center
|
||||||
samples_to_draw,
|
- angle.to_radians() * sample_progress
|
||||||
*direction,
|
}
|
||||||
) {
|
crate::circle_geometry::CircleDirection::Right => {
|
||||||
current_preview.push(pt);
|
geom.start_angle_from_center
|
||||||
|
+ angle.to_radians() * sample_progress
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let vertex = Vec2::new(
|
||||||
|
geom.center.x + radius * current_angle.cos(),
|
||||||
|
geom.center.y + radius * current_angle.sin(),
|
||||||
|
);
|
||||||
|
current_preview.push(vertex);
|
||||||
}
|
}
|
||||||
} else if matches!(
|
} else if matches!(
|
||||||
&tween.command,
|
&tween.command,
|
||||||
@ -291,19 +347,18 @@ fn draw_text_command(
|
|||||||
fn draw_tween_arc(
|
fn draw_tween_arc(
|
||||||
tween: &crate::tweening::CommandTween,
|
tween: &crate::tweening::CommandTween,
|
||||||
radius: f32,
|
radius: f32,
|
||||||
total_angle: crate::general::Degrees,
|
total_angle: f32,
|
||||||
steps: usize,
|
steps: usize,
|
||||||
direction: CircleDirection,
|
direction: CircleDirection,
|
||||||
) {
|
) {
|
||||||
use crate::general::Radians;
|
|
||||||
let geom = CircleGeometry::new(
|
let geom = CircleGeometry::new(
|
||||||
tween.start_params.position,
|
tween.start_params.position,
|
||||||
Radians::new(tween.start_params.heading),
|
tween.start_params.heading,
|
||||||
radius,
|
radius,
|
||||||
direction,
|
direction,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Draw center using Lyon tessellation this helps visualizing what is done.
|
// Debug: draw center using Lyon tessellation
|
||||||
if let Ok(mesh_data) = crate::tessellation::tessellate_circle(geom.center, 5.0, GRAY, true, 1.0)
|
if let Ok(mesh_data) = crate::tessellation::tessellate_circle(geom.center, 5.0, GRAY, true, 1.0)
|
||||||
{
|
{
|
||||||
draw_mesh(&mesh_data.to_mesh());
|
draw_mesh(&mesh_data.to_mesh());
|
||||||
@ -320,7 +375,7 @@ fn draw_tween_arc(
|
|||||||
geom.center,
|
geom.center,
|
||||||
radius,
|
radius,
|
||||||
geom.start_angle_from_center.to_degrees(),
|
geom.start_angle_from_center.to_degrees(),
|
||||||
total_angle.value() * progress,
|
total_angle * progress,
|
||||||
tween.start_params.color,
|
tween.start_params.color,
|
||||||
tween.start_params.pen_width,
|
tween.start_params.pen_width,
|
||||||
((steps as f32 * progress).ceil() as usize).max(1),
|
((steps as f32 * progress).ceil() as usize).max(1),
|
||||||
@ -331,7 +386,7 @@ fn draw_tween_arc(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Draw the turtle shape
|
/// Draw the turtle shape
|
||||||
pub(crate) fn draw_turtle(turtle_params: &TurtleParams) {
|
pub fn draw_turtle(turtle_params: &TurtleParams) {
|
||||||
let rotated_vertices = turtle_params.shape.rotated_vertices(turtle_params.heading);
|
let rotated_vertices = turtle_params.shape.rotated_vertices(turtle_params.heading);
|
||||||
|
|
||||||
if turtle_params.shape.filled {
|
if turtle_params.shape.filled {
|
||||||
|
|||||||
@ -2,120 +2,51 @@
|
|||||||
|
|
||||||
use crate::circle_geometry::{CircleDirection, CircleGeometry};
|
use crate::circle_geometry::{CircleDirection, CircleGeometry};
|
||||||
use crate::commands::TurtleCommand;
|
use crate::commands::TurtleCommand;
|
||||||
use crate::general::{Coordinate, Radians};
|
use crate::state::{DrawCommand, Turtle, TurtleParams, TurtleWorld};
|
||||||
use crate::state::{DrawCommand, FillState, Turtle, TurtleParams, TurtleWorld};
|
|
||||||
use crate::tessellation;
|
use crate::tessellation;
|
||||||
use macroquad::prelude::*;
|
use macroquad::prelude::*;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use crate::general::AnimationSpeed;
|
use crate::general::AnimationSpeed;
|
||||||
|
|
||||||
/// Close the current open fill contour (factored out of `Turtle::close_fill_contour`).
|
/// Execute side effects for commands that don't involve movement
|
||||||
fn close_fill_contour(turtle_id: usize, filling: &mut Option<FillState>) {
|
/// Returns true if the command was handled (caller should skip movement processing)
|
||||||
if let Some(ref mut fill_state) = filling {
|
|
||||||
tracing::debug!(
|
|
||||||
turtle_id,
|
|
||||||
vertices = fill_state.current_contour.len(),
|
|
||||||
"close_fill_contour called"
|
|
||||||
);
|
|
||||||
if fill_state.current_contour.len() >= 2 {
|
|
||||||
tracing::debug!(
|
|
||||||
turtle_id,
|
|
||||||
vertices = fill_state.current_contour.len(),
|
|
||||||
first_x = fill_state.current_contour[0].x,
|
|
||||||
first_y = fill_state.current_contour[0].y,
|
|
||||||
last_x = fill_state.current_contour[fill_state.current_contour.len() - 1].x,
|
|
||||||
last_y = fill_state.current_contour[fill_state.current_contour.len() - 1].y,
|
|
||||||
"Closing contour"
|
|
||||||
);
|
|
||||||
let contour = std::mem::take(&mut fill_state.current_contour);
|
|
||||||
fill_state.contours.push(contour);
|
|
||||||
tracing::debug!(
|
|
||||||
turtle_id,
|
|
||||||
completed_contours = fill_state.contours.len(),
|
|
||||||
"Contour moved to completed list"
|
|
||||||
);
|
|
||||||
} else if !fill_state.current_contour.is_empty() {
|
|
||||||
tracing::warn!(
|
|
||||||
turtle_id,
|
|
||||||
vertices = fill_state.current_contour.len(),
|
|
||||||
"Current contour has insufficient vertices, not closing"
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
tracing::warn!(turtle_id, "Current contour is empty, nothing to close");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
tracing::warn!(
|
|
||||||
turtle_id,
|
|
||||||
"close_fill_contour called but no active fill state"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Begin a new fill contour at `position` (factored out of `Turtle::start_fill_contour`).
|
|
||||||
fn start_fill_contour(turtle_id: usize, position: Coordinate, filling: &mut Option<FillState>) {
|
|
||||||
if let Some(ref mut fill_state) = filling {
|
|
||||||
tracing::debug!(
|
|
||||||
x = position.x,
|
|
||||||
y = position.y,
|
|
||||||
completed_contours = fill_state.contours.len(),
|
|
||||||
turtle_id,
|
|
||||||
"Starting new contour"
|
|
||||||
);
|
|
||||||
fill_state.current_contour = vec![position];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Execute side effects for commands that don't involve movement.
|
|
||||||
///
|
|
||||||
/// Returns `true` if the command was fully handled; the caller should skip
|
|
||||||
/// params-update and tessellation when this returns `true`.
|
|
||||||
///
|
|
||||||
/// Accepts the three logically-separate pieces of turtle state as disjoint
|
|
||||||
/// mutable borrows so that this function can be called from
|
|
||||||
/// `TweenController::update(&mut self, …)` without requiring a `&mut Turtle`.
|
|
||||||
#[allow(clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines)]
|
||||||
pub(crate) fn execute_command_side_effects(
|
pub fn execute_command_side_effects(command: &TurtleCommand, state: &mut Turtle) -> bool {
|
||||||
command: &TurtleCommand,
|
|
||||||
turtle_id: usize,
|
|
||||||
params: &mut TurtleParams,
|
|
||||||
filling: &mut Option<FillState>,
|
|
||||||
commands: &mut Vec<DrawCommand>,
|
|
||||||
svg_log: &mut crate::state::SvgLog,
|
|
||||||
) -> bool {
|
|
||||||
match command {
|
match command {
|
||||||
TurtleCommand::BeginFill => {
|
TurtleCommand::BeginFill => {
|
||||||
if filling.is_some() {
|
if state.filling.is_some() {
|
||||||
tracing::warn!(turtle_id, "begin_fill() called while already filling");
|
tracing::warn!(
|
||||||
|
turtle_id = state.turtle_id,
|
||||||
|
"begin_fill() called while already filling"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let fill_color = params.fill_color.unwrap_or_else(|| {
|
let fill_color = state.params.fill_color.unwrap_or_else(|| {
|
||||||
tracing::warn!(turtle_id, "No fill_color set, using black");
|
tracing::warn!(
|
||||||
|
turtle_id = state.turtle_id,
|
||||||
|
"No fill_color set, using black"
|
||||||
|
);
|
||||||
BLACK
|
BLACK
|
||||||
});
|
});
|
||||||
*filling = Some(FillState {
|
state.begin_fill(fill_color);
|
||||||
start_position: params.position,
|
|
||||||
contours: Vec::new(),
|
|
||||||
current_contour: vec![params.position],
|
|
||||||
fill_color,
|
|
||||||
});
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
TurtleCommand::EndFill => {
|
TurtleCommand::EndFill => {
|
||||||
if let Some(mut fill_state) = filling.take() {
|
if let Some(mut fill_state) = state.filling.take() {
|
||||||
if !fill_state.current_contour.is_empty() {
|
if !fill_state.current_contour.is_empty() {
|
||||||
fill_state.contours.push(fill_state.current_contour);
|
fill_state.contours.push(fill_state.current_contour);
|
||||||
}
|
}
|
||||||
|
|
||||||
let span = tracing::debug_span!(
|
let span = tracing::debug_span!(
|
||||||
"end_fill",
|
"end_fill",
|
||||||
turtle_id,
|
turtle_id = state.turtle_id,
|
||||||
contours = fill_state.contours.len()
|
contours = fill_state.contours.len()
|
||||||
);
|
);
|
||||||
let _enter = span.enter();
|
let _enter = span.enter();
|
||||||
|
|
||||||
for (i, contour) in fill_state.contours.iter().enumerate() {
|
for (i, contour) in fill_state.contours.iter().enumerate() {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
turtle_id,
|
turtle_id = state.turtle_id,
|
||||||
contour_idx = i,
|
contour_idx = i,
|
||||||
vertices = contour.len(),
|
vertices = contour.len(),
|
||||||
"Contour info"
|
"Contour info"
|
||||||
@ -128,69 +59,85 @@ pub(crate) fn execute_command_side_effects(
|
|||||||
fill_state.fill_color,
|
fill_state.fill_color,
|
||||||
) {
|
) {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
turtle_id,
|
turtle_id = state.turtle_id,
|
||||||
contours = fill_state.contours.len(),
|
contours = fill_state.contours.len(),
|
||||||
"Successfully created fill mesh - persisting to commands"
|
"Successfully created fill mesh - persisting to commands"
|
||||||
);
|
);
|
||||||
commands.push(DrawCommand::Mesh { data: mesh_data });
|
state.commands.push(DrawCommand::Mesh {
|
||||||
#[cfg(feature = "svg")]
|
data: mesh_data,
|
||||||
svg_log.push(crate::state::SvgRecord::Fill {
|
source: crate::state::TurtleSource {
|
||||||
contours: fill_state.contours,
|
command: crate::commands::TurtleCommand::EndFill,
|
||||||
|
color: state.params.color,
|
||||||
fill_color: fill_state.fill_color,
|
fill_color: fill_state.fill_color,
|
||||||
stroke_color: params.color,
|
pen_width: state.params.pen_width,
|
||||||
|
start_position: fill_state.start_position,
|
||||||
|
end_position: fill_state.start_position,
|
||||||
|
start_heading: state.params.heading,
|
||||||
|
contours: Some(fill_state.contours.clone()),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
tracing::error!(turtle_id, "Failed to tessellate contours");
|
tracing::error!(
|
||||||
|
turtle_id = state.turtle_id,
|
||||||
|
"Failed to tessellate contours"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!(turtle_id, "end_fill() called without begin_fill()");
|
tracing::warn!(
|
||||||
|
turtle_id = state.turtle_id,
|
||||||
|
"end_fill() called without begin_fill()"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
TurtleCommand::PenUp => {
|
TurtleCommand::PenUp => {
|
||||||
params.pen_down = false;
|
state.params.pen_down = false;
|
||||||
if filling.is_some() {
|
if state.filling.is_some() {
|
||||||
tracing::debug!(turtle_id, "PenUp: Closing current contour");
|
tracing::debug!(
|
||||||
|
turtle_id = state.turtle_id,
|
||||||
|
"PenUp: Closing current contour"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
close_fill_contour(turtle_id, filling);
|
state.close_fill_contour();
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
TurtleCommand::PenDown => {
|
TurtleCommand::PenDown => {
|
||||||
params.pen_down = true;
|
state.params.pen_down = true;
|
||||||
if filling.is_some() {
|
if state.filling.is_some() {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
turtle_id,
|
turtle_id = state.turtle_id,
|
||||||
x = params.position.x,
|
x = state.params.position.x,
|
||||||
y = params.position.y,
|
y = state.params.position.y,
|
||||||
"PenDown: Starting new contour"
|
"PenDown: Starting new contour"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
start_fill_contour(turtle_id, params.position, filling);
|
state.start_fill_contour();
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
TurtleCommand::Reset => {
|
TurtleCommand::Reset => {
|
||||||
commands.clear();
|
state.reset();
|
||||||
svg_log.clear();
|
|
||||||
*filling = None;
|
|
||||||
*params = TurtleParams::default();
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
TurtleCommand::WriteText { text, font_size } => {
|
TurtleCommand::WriteText { text, font_size } => {
|
||||||
commands.push(DrawCommand::Text {
|
state.commands.push(DrawCommand::Text {
|
||||||
text: text.clone(),
|
text: text.clone(),
|
||||||
position: params.position,
|
position: state.params.position,
|
||||||
heading: params.heading,
|
heading: state.params.heading,
|
||||||
font_size: *font_size,
|
font_size: *font_size,
|
||||||
color: params.color,
|
color: state.params.color,
|
||||||
});
|
source: crate::state::TurtleSource {
|
||||||
#[cfg(feature = "svg")]
|
command: command.clone(),
|
||||||
svg_log.push(crate::state::SvgRecord::Text {
|
color: state.params.color,
|
||||||
text: text.clone(),
|
fill_color: state.params.fill_color.unwrap_or(BLACK),
|
||||||
position: params.position,
|
pen_width: state.params.pen_width,
|
||||||
color: params.color,
|
start_position: state.params.position,
|
||||||
|
end_position: state.params.position,
|
||||||
|
start_heading: state.params.heading,
|
||||||
|
contours: None,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@ -210,23 +157,14 @@ pub(crate) fn execute_command_side_effects(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record fill vertices after movement commands have updated state.
|
/// Record fill vertices after movement commands have updated state
|
||||||
///
|
#[tracing::instrument]
|
||||||
/// `start_state` is the params snapshot taken **before** the command ran.
|
pub fn record_fill_vertices_after_movement(
|
||||||
/// `params` is the current (post-movement) state — `params.position` is the
|
|
||||||
/// endpoint that gets pushed into the active fill contour.
|
|
||||||
///
|
|
||||||
/// Accepts disjoint borrows so it can be called from `TweenController::update`
|
|
||||||
/// without needing a `&mut Turtle`.
|
|
||||||
#[tracing::instrument(skip(params, filling))]
|
|
||||||
pub(crate) fn record_fill_vertices_after_movement(
|
|
||||||
command: &TurtleCommand,
|
command: &TurtleCommand,
|
||||||
start_state: &TurtleParams,
|
start_state: &TurtleParams,
|
||||||
turtle_id: usize,
|
state: &mut Turtle,
|
||||||
params: &TurtleParams,
|
|
||||||
filling: &mut Option<FillState>,
|
|
||||||
) {
|
) {
|
||||||
if filling.is_none() {
|
if state.filling.is_none() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -239,100 +177,74 @@ pub(crate) fn record_fill_vertices_after_movement(
|
|||||||
} => {
|
} => {
|
||||||
let geom = CircleGeometry::new(
|
let geom = CircleGeometry::new(
|
||||||
start_state.position,
|
start_state.position,
|
||||||
Radians::new(start_state.heading),
|
start_state.heading,
|
||||||
*radius,
|
*radius,
|
||||||
*direction,
|
*direction,
|
||||||
);
|
);
|
||||||
if let Some(ref mut fill_state) = filling {
|
state.record_fill_vertices_for_arc(
|
||||||
if params.pen_down {
|
geom.center,
|
||||||
let num_samples = (*steps as u32).max(1);
|
*radius,
|
||||||
tracing::trace!(
|
geom.start_angle_from_center,
|
||||||
turtle_id,
|
angle.to_radians(),
|
||||||
center_x = geom.center.x,
|
*direction,
|
||||||
center_y = geom.center.y,
|
*steps as u32,
|
||||||
radius,
|
|
||||||
steps,
|
|
||||||
num_samples,
|
|
||||||
"Recording arc vertices"
|
|
||||||
);
|
);
|
||||||
for i in 1..=num_samples {
|
|
||||||
let progress = i as f32 / num_samples as f32;
|
|
||||||
let current_angle = match direction {
|
|
||||||
CircleDirection::Left => {
|
|
||||||
geom.start_angle_from_center - angle.as_radians().value() * progress
|
|
||||||
}
|
|
||||||
CircleDirection::Right => {
|
|
||||||
geom.start_angle_from_center + angle.as_radians().value() * progress
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let vertex = Coordinate::new(
|
|
||||||
geom.center.x + radius * current_angle.cos(),
|
|
||||||
geom.center.y + radius * current_angle.sin(),
|
|
||||||
);
|
|
||||||
tracing::trace!(
|
|
||||||
turtle_id,
|
|
||||||
vertex_idx = i,
|
|
||||||
x = vertex.x,
|
|
||||||
y = vertex.y,
|
|
||||||
angle_degrees = current_angle.to_degrees(),
|
|
||||||
"Arc vertex"
|
|
||||||
);
|
|
||||||
fill_state.current_contour.push(vertex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
TurtleCommand::Move(_) | TurtleCommand::Goto(_) => {
|
TurtleCommand::Move(_) | TurtleCommand::Goto(_) => {
|
||||||
if let Some(ref mut fill_state) = filling {
|
state.record_fill_vertex();
|
||||||
if params.pen_down {
|
|
||||||
tracing::trace!(
|
|
||||||
turtle_id,
|
|
||||||
x = params.position.x,
|
|
||||||
y = params.position.y,
|
|
||||||
vertices = fill_state.current_contour.len() + 1,
|
|
||||||
"Adding vertex to current contour"
|
|
||||||
);
|
|
||||||
fill_state.current_contour.push(params.position);
|
|
||||||
} else {
|
|
||||||
tracing::trace!(turtle_id, "Skipping vertex (pen is up)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tessellate a completed movement command into a [`DrawCommand`] mesh.
|
/// Execute a single turtle command, updating state and adding draw commands
|
||||||
///
|
#[tracing::instrument]
|
||||||
/// Returns `None` if the pen was up or the command does not produce a drawing.
|
#[allow(clippy::too_many_lines)]
|
||||||
///
|
pub fn execute_command(command: &TurtleCommand, state: &mut Turtle) {
|
||||||
/// `end_position` is the turtle's position after the command completed:
|
// Try to execute as side-effect-only command first
|
||||||
/// - instant-mode: `state.params.position` after [`TurtleCommand::apply_to_params`]
|
if execute_command_side_effects(command, state) {
|
||||||
/// - animated-mode: `tween.target_params.position` when the tween finishes
|
return; // Command fully handled
|
||||||
///
|
|
||||||
/// This is the **single** tessellation site for all committed line/arc meshes.
|
|
||||||
/// It replaces both the inline tessellation inside `execute_command` and the
|
|
||||||
/// now-deleted `add_draw_for_completed_tween`.
|
|
||||||
pub(crate) fn tessellate_command(
|
|
||||||
command: &TurtleCommand,
|
|
||||||
start: &TurtleParams,
|
|
||||||
end_position: Vec2,
|
|
||||||
) -> Option<DrawCommand> {
|
|
||||||
if !start.pen_down || !command.produces_drawing() {
|
|
||||||
return None;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
match command {
|
// Store start state for fill vertex recording
|
||||||
TurtleCommand::Move(_) | TurtleCommand::Goto(_) => {
|
let start_state = state.clone();
|
||||||
let mesh_data = tessellation::tessellate_stroke(
|
|
||||||
&[start.position, end_position],
|
|
||||||
start.color,
|
|
||||||
start.pen_width,
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
.ok()?;
|
|
||||||
|
|
||||||
Some(DrawCommand::Mesh { data: mesh_data })
|
// Execute movement and appearance commands
|
||||||
|
match command {
|
||||||
|
TurtleCommand::Move(distance) => {
|
||||||
|
let start = state.params.position;
|
||||||
|
let dx = distance * state.params.heading.cos();
|
||||||
|
let dy = distance * state.params.heading.sin();
|
||||||
|
state.params.position =
|
||||||
|
vec2(state.params.position.x + dx, state.params.position.y + dy);
|
||||||
|
|
||||||
|
if state.params.pen_down {
|
||||||
|
// Draw line segment with round caps (caps handled by tessellate_stroke)
|
||||||
|
if let Ok(mesh_data) = tessellation::tessellate_stroke(
|
||||||
|
&[start, state.params.position],
|
||||||
|
state.params.color,
|
||||||
|
state.params.pen_width,
|
||||||
|
false, // not closed
|
||||||
|
) {
|
||||||
|
state.commands.push(DrawCommand::Mesh {
|
||||||
|
data: mesh_data,
|
||||||
|
source: crate::state::TurtleSource {
|
||||||
|
command: command.clone(),
|
||||||
|
color: state.params.color,
|
||||||
|
fill_color: state.params.fill_color.unwrap_or(BLACK),
|
||||||
|
pen_width: state.params.pen_width,
|
||||||
|
start_position: start,
|
||||||
|
end_position: state.params.position,
|
||||||
|
start_heading: state.params.heading,
|
||||||
|
contours: None,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TurtleCommand::Turn(degrees) => {
|
||||||
|
state.params.heading += degrees.to_radians();
|
||||||
}
|
}
|
||||||
|
|
||||||
TurtleCommand::Circle {
|
TurtleCommand::Circle {
|
||||||
@ -341,140 +253,192 @@ pub(crate) fn tessellate_command(
|
|||||||
steps,
|
steps,
|
||||||
direction,
|
direction,
|
||||||
} => {
|
} => {
|
||||||
use crate::circle_geometry::CircleGeometry;
|
let start_heading = state.params.heading;
|
||||||
let geom = CircleGeometry::new(
|
let geom =
|
||||||
start.position,
|
CircleGeometry::new(state.params.position, start_heading, *radius, *direction);
|
||||||
Radians::new(start.heading),
|
|
||||||
*radius,
|
if state.params.pen_down {
|
||||||
*direction,
|
// Use Lyon to tessellate the arc
|
||||||
);
|
if let Ok(mesh_data) = tessellation::tessellate_arc(
|
||||||
let mesh_data = tessellation::tessellate_arc(
|
|
||||||
geom.center,
|
geom.center,
|
||||||
*radius,
|
*radius,
|
||||||
geom.start_angle_from_center.to_degrees(),
|
geom.start_angle_from_center.to_degrees(),
|
||||||
angle.value(),
|
*angle,
|
||||||
start.color,
|
state.params.color,
|
||||||
start.pen_width,
|
state.params.pen_width,
|
||||||
*steps,
|
*steps,
|
||||||
*direction,
|
*direction,
|
||||||
)
|
) {
|
||||||
.ok()?;
|
state.commands.push(DrawCommand::Mesh {
|
||||||
|
data: mesh_data,
|
||||||
Some(DrawCommand::Mesh { data: mesh_data })
|
source: crate::state::TurtleSource {
|
||||||
|
command: command.clone(),
|
||||||
|
color: state.params.color,
|
||||||
|
fill_color: state.params.fill_color.unwrap_or(BLACK),
|
||||||
|
pen_width: state.params.pen_width,
|
||||||
|
start_position: state.params.position,
|
||||||
|
end_position: geom.position_at_angle(angle.to_radians()),
|
||||||
|
start_heading,
|
||||||
|
contours: None,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// `produces_drawing()` guards entry — this arm is only reachable if
|
// Update turtle position and heading
|
||||||
// `produces_drawing` and the match above diverge, which would be a bug.
|
state.params.position = geom.position_at_angle(angle.to_radians());
|
||||||
_ => None,
|
state.params.heading = match direction {
|
||||||
|
CircleDirection::Left => start_heading - angle.to_radians(),
|
||||||
|
CircleDirection::Right => start_heading + angle.to_radians(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
TurtleCommand::Goto(coord) => {
|
||||||
|
let start = state.params.position;
|
||||||
|
// Flip Y coordinate: turtle graphics uses Y+ = up, but Macroquad uses Y+ = down
|
||||||
|
state.params.position = vec2(coord.x, -coord.y);
|
||||||
|
|
||||||
|
if state.params.pen_down {
|
||||||
|
// Draw line segment with round caps
|
||||||
|
if let Ok(mesh_data) = tessellation::tessellate_stroke(
|
||||||
|
&[start, state.params.position],
|
||||||
|
state.params.color,
|
||||||
|
state.params.pen_width,
|
||||||
|
false, // not closed
|
||||||
|
) {
|
||||||
|
state.commands.push(DrawCommand::Mesh {
|
||||||
|
data: mesh_data,
|
||||||
|
source: crate::state::TurtleSource {
|
||||||
|
command: command.clone(),
|
||||||
|
color: state.params.color,
|
||||||
|
fill_color: state.params.fill_color.unwrap_or(BLACK),
|
||||||
|
pen_width: state.params.pen_width,
|
||||||
|
start_position: start,
|
||||||
|
end_position: state.params.position,
|
||||||
|
start_heading: state.params.heading,
|
||||||
|
contours: None,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Appearance commands
|
||||||
|
TurtleCommand::SetColor(color) => state.params.color = *color,
|
||||||
|
TurtleCommand::SetFillColor(color) => state.params.fill_color = *color,
|
||||||
|
TurtleCommand::SetPenWidth(width) => state.params.pen_width = *width,
|
||||||
|
TurtleCommand::SetSpeed(speed) => state.set_speed(*speed),
|
||||||
|
TurtleCommand::SetShape(shape) => state.params.shape = shape.clone(),
|
||||||
|
TurtleCommand::SetHeading(heading) => state.params.heading = *heading,
|
||||||
|
TurtleCommand::ShowTurtle => state.params.visible = true,
|
||||||
|
TurtleCommand::HideTurtle => state.params.visible = false,
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
TurtleCommand::Reset => {
|
||||||
|
state.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => {} // Already handled by execute_command_side_effects
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record fill vertices AFTER movement
|
||||||
|
record_fill_vertices_after_movement(command, &start_state.params, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute command on a specific turtle by ID
|
||||||
|
pub fn execute_command_with_id(command: &TurtleCommand, turtle_id: usize, world: &mut TurtleWorld) {
|
||||||
|
// Clone turtle state to avoid borrow checker issues
|
||||||
|
if let Some(turtle) = world.get_turtle(turtle_id) {
|
||||||
|
let mut state = turtle.clone();
|
||||||
|
execute_command(command, &mut state);
|
||||||
|
// Update the turtle state back
|
||||||
|
if let Some(turtle_mut) = world.get_turtle_mut(turtle_id) {
|
||||||
|
*turtle_mut = state;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Push an [`SvgRecord`] for a completed line or arc drawing command.
|
/// Add drawing command for a completed tween
|
||||||
///
|
pub fn add_draw_for_completed_tween(
|
||||||
/// Only compiled when the `svg` feature is enabled.
|
|
||||||
/// Must be called at the same call sites as `tessellate_command` so that
|
|
||||||
/// `svg_log` stays in sync with `commands`.
|
|
||||||
#[cfg(feature = "svg")]
|
|
||||||
pub(crate) fn push_svg_for_draw(
|
|
||||||
command: &TurtleCommand,
|
command: &TurtleCommand,
|
||||||
start: &TurtleParams,
|
start_state: &TurtleParams,
|
||||||
end_position: Vec2,
|
end_state: &mut TurtleParams,
|
||||||
svg_log: &mut crate::state::SvgLog,
|
) -> Option<DrawCommand> {
|
||||||
) {
|
|
||||||
use crate::state::SvgRecord;
|
|
||||||
match command {
|
match command {
|
||||||
TurtleCommand::Move(_) | TurtleCommand::Goto(_) => {
|
TurtleCommand::Move(_) | TurtleCommand::Goto(_) => {
|
||||||
svg_log.push(SvgRecord::Line {
|
if start_state.pen_down {
|
||||||
start: start.position,
|
if let Ok(mesh_data) = tessellation::tessellate_stroke(
|
||||||
end: end_position,
|
&[start_state.position, end_state.position],
|
||||||
color: start.color,
|
start_state.color,
|
||||||
pen_width: start.pen_width,
|
start_state.pen_width,
|
||||||
|
false,
|
||||||
|
) {
|
||||||
|
return Some(DrawCommand::Mesh {
|
||||||
|
data: mesh_data,
|
||||||
|
source: crate::state::TurtleSource {
|
||||||
|
command: command.clone(),
|
||||||
|
color: start_state.color,
|
||||||
|
fill_color: start_state.fill_color.unwrap_or(BLACK),
|
||||||
|
pen_width: start_state.pen_width,
|
||||||
|
start_position: start_state.position,
|
||||||
|
end_position: end_state.position,
|
||||||
|
start_heading: start_state.heading,
|
||||||
|
contours: None,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
TurtleCommand::Circle {
|
TurtleCommand::Circle {
|
||||||
radius,
|
radius,
|
||||||
angle,
|
angle,
|
||||||
|
steps,
|
||||||
direction,
|
direction,
|
||||||
..
|
|
||||||
} => {
|
} => {
|
||||||
svg_log.push(SvgRecord::Arc {
|
if start_state.pen_down {
|
||||||
start_position: start.position,
|
let geom = CircleGeometry::new(
|
||||||
start_heading: start.heading,
|
start_state.position,
|
||||||
radius: *radius,
|
start_state.heading,
|
||||||
angle: *angle,
|
*radius,
|
||||||
direction: *direction,
|
*direction,
|
||||||
color: start.color,
|
);
|
||||||
pen_width: start.pen_width,
|
if let Ok(mesh_data) = tessellation::tessellate_arc(
|
||||||
|
geom.center,
|
||||||
|
*radius,
|
||||||
|
geom.start_angle_from_center.to_degrees(),
|
||||||
|
*angle,
|
||||||
|
start_state.color,
|
||||||
|
start_state.pen_width,
|
||||||
|
*steps,
|
||||||
|
*direction,
|
||||||
|
) {
|
||||||
|
return Some(DrawCommand::Mesh {
|
||||||
|
data: mesh_data,
|
||||||
|
source: crate::state::TurtleSource {
|
||||||
|
command: command.clone(),
|
||||||
|
color: start_state.color,
|
||||||
|
fill_color: start_state.fill_color.unwrap_or(BLACK),
|
||||||
|
pen_width: start_state.pen_width,
|
||||||
|
start_position: start_state.position,
|
||||||
|
end_position: end_state.position,
|
||||||
|
start_heading: start_state.heading,
|
||||||
|
contours: None,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Execute a single turtle command, updating state and adding draw commands.
|
|
||||||
#[tracing::instrument(skip(state))]
|
|
||||||
pub(crate) fn execute_command(command: &TurtleCommand, state: &mut Turtle) {
|
|
||||||
// Phase 1: side effects (fills, pen contours, reset, text).
|
|
||||||
// Returns true if the command is fully handled — no params update or tessellation needed.
|
|
||||||
if execute_command_side_effects(
|
|
||||||
command,
|
|
||||||
state.turtle_id,
|
|
||||||
&mut state.params,
|
|
||||||
&mut state.filling,
|
|
||||||
&mut state.commands,
|
|
||||||
&mut state.svg_log,
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
_ => (),
|
||||||
// Phase 2: update TurtleParams (position, heading, colour, speed, etc.)
|
|
||||||
let start_params = state.params.clone();
|
|
||||||
command.apply_to_params(&mut state.params);
|
|
||||||
|
|
||||||
// Phase 3: record fill vertices after movement (must follow params update)
|
|
||||||
record_fill_vertices_after_movement(
|
|
||||||
command,
|
|
||||||
&start_params,
|
|
||||||
state.turtle_id,
|
|
||||||
&state.params,
|
|
||||||
&mut state.filling,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Phase 4: tessellate, push SVG record, and persist the committed drawing
|
|
||||||
if let Some(draw_cmd) = tessellate_command(command, &start_params, state.params.position) {
|
|
||||||
#[cfg(feature = "svg")]
|
|
||||||
push_svg_for_draw(
|
|
||||||
command,
|
|
||||||
&start_params,
|
|
||||||
state.params.position,
|
|
||||||
&mut state.svg_log,
|
|
||||||
);
|
|
||||||
state.commands.push(draw_cmd);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Execute command on a specific turtle by ID.
|
|
||||||
///
|
|
||||||
/// There is no ownership conflict here: `execute_command` only needs `&mut Turtle`
|
|
||||||
/// and never touches `TurtleWorld`, so we can obtain the mutable reference directly
|
|
||||||
/// from `get_turtle_mut` without any intermediate clone.
|
|
||||||
pub(crate) fn execute_command_with_id(
|
|
||||||
command: &TurtleCommand,
|
|
||||||
turtle_id: usize,
|
|
||||||
world: &mut TurtleWorld,
|
|
||||||
) {
|
|
||||||
if let Some(turtle) = world.get_turtle_mut(turtle_id) {
|
|
||||||
execute_command(command, turtle);
|
|
||||||
}
|
}
|
||||||
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::commands::TurtleCommand;
|
use crate::commands::TurtleCommand;
|
||||||
use crate::general::Degrees;
|
|
||||||
use crate::shapes::TurtleShape;
|
use crate::shapes::TurtleShape;
|
||||||
use crate::tweening::TweenController;
|
use crate::TweenController;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_forward_left_forward() {
|
fn test_forward_left_forward() {
|
||||||
@ -497,7 +461,6 @@ mod tests {
|
|||||||
},
|
},
|
||||||
filling: None,
|
filling: None,
|
||||||
commands: Vec::new(),
|
commands: Vec::new(),
|
||||||
svg_log: crate::state::SvgLog::default(),
|
|
||||||
tween_controller: TweenController::default(),
|
tween_controller: TweenController::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -537,7 +500,7 @@ mod tests {
|
|||||||
|
|
||||||
// Left 90 degrees - should face north (heading decreases by 90°)
|
// Left 90 degrees - should face north (heading decreases by 90°)
|
||||||
// In screen coords: north = -90° = -π/2
|
// In screen coords: north = -90° = -π/2
|
||||||
execute_command(&TurtleCommand::Turn(Degrees::new(-90.0)), &mut state);
|
execute_command(&TurtleCommand::Turn(-90.0), &mut state);
|
||||||
assert!(
|
assert!(
|
||||||
(state.params.position.x - 100.0).abs() < 0.01,
|
(state.params.position.x - 100.0).abs() < 0.01,
|
||||||
"After left(90): x = {}",
|
"After left(90): x = {}",
|
||||||
|
|||||||
@ -1,23 +1,22 @@
|
|||||||
//! Export backend trait and core export types.
|
//! Export-Backend-Trait und zentrale Export-Typen
|
||||||
|
|
||||||
use crate::state::TurtleWorld;
|
use crate::state::TurtleWorld;
|
||||||
use crate::TurtlePlan;
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum ExportError {
|
pub enum ExportError {
|
||||||
Io(std::io::Error),
|
Io(std::io::Error),
|
||||||
Format(String),
|
Format(String),
|
||||||
// Additional formats can be added here.
|
// Weitere Formate können ergänzt werden
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
pub enum DrawingFormat {
|
pub enum DrawingFormat {
|
||||||
#[cfg(feature = "svg")]
|
#[cfg(feature = "svg")]
|
||||||
Svg,
|
Svg,
|
||||||
// Additional formats: Png, Pdf, …
|
// Weitere Formate wie Png, Pdf, ...
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) trait DrawingExporter {
|
pub trait DrawingExporter {
|
||||||
/// Export the drawing to the specified format and filename
|
/// Export the drawing to the specified format and filename
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
@ -25,62 +24,3 @@ pub(crate) trait DrawingExporter {
|
|||||||
/// Returns an error if the export fails (e.g., file I/O error)
|
/// Returns an error if the export fails (e.g., file I/O error)
|
||||||
fn export(&self, world: &TurtleWorld, filename: &str) -> Result<(), ExportError>;
|
fn export(&self, world: &TurtleWorld, filename: &str) -> Result<(), ExportError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn parse_svg_export_arg() -> Option<String> {
|
|
||||||
let args: Vec<String> = std::env::args().collect();
|
|
||||||
let mut i = 1;
|
|
||||||
while i < args.len() {
|
|
||||||
if args[i] == "--export-svg" && i + 1 < args.len() {
|
|
||||||
return Some(args[i + 1].clone());
|
|
||||||
}
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle the optional `--export-svg` CLI flag.
|
|
||||||
///
|
|
||||||
/// The feature gating lives inside `turtle-lib`, so the `turtle_main` macro
|
|
||||||
/// no longer needs to reference cfg flags from the consuming crate.
|
|
||||||
pub fn handle_svg_export<F>(build_commands: F)
|
|
||||||
where
|
|
||||||
F: FnMut(&mut TurtlePlan),
|
|
||||||
{
|
|
||||||
// Avoid unused warnings when the feature is disabled
|
|
||||||
let _ = &build_commands;
|
|
||||||
|
|
||||||
if let Some(filename) = parse_svg_export_arg() {
|
|
||||||
#[cfg(feature = "svg")]
|
|
||||||
{
|
|
||||||
let mut build_commands = build_commands;
|
|
||||||
let mut turtle = crate::create_turtle_plan();
|
|
||||||
build_commands(&mut turtle);
|
|
||||||
|
|
||||||
let mut app = crate::TurtleApp::new().with_commands(turtle.build());
|
|
||||||
app.set_all_turtles_speed(crate::AnimationSpeed::Instant(1000));
|
|
||||||
|
|
||||||
while !app.all_animations_complete() {
|
|
||||||
app.update();
|
|
||||||
}
|
|
||||||
|
|
||||||
match app.export_drawing(&filename, crate::export::DrawingFormat::Svg) {
|
|
||||||
Ok(_) => {
|
|
||||||
println!("SVG exported successfully to: {}", filename);
|
|
||||||
std::process::exit(0);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Error exporting SVG: {:?}", e);
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(feature = "svg"))]
|
|
||||||
{
|
|
||||||
let _ = &filename;
|
|
||||||
eprintln!("Error: SVG export feature is not enabled.");
|
|
||||||
eprintln!("Please rebuild with --features svg");
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
//! SVG export backend for `TurtleWorld`.
|
//! SVG-Export-Backend für TurtleWorld
|
||||||
|
|
||||||
#[cfg(feature = "svg")]
|
#[cfg(feature = "svg")]
|
||||||
pub mod svg_export {
|
pub mod svg_export {
|
||||||
|
use crate::commands::TurtleCommand;
|
||||||
use crate::export::{DrawingExporter, ExportError};
|
use crate::export::{DrawingExporter, ExportError};
|
||||||
use crate::state::{SvgRecord, TurtleWorld};
|
use crate::state::{DrawCommand, TurtleWorld};
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use svg::{
|
use svg::{
|
||||||
node::element::{Circle, Line, Text as SvgText},
|
node::element::{Circle, Line, Polygon, Text as SvgText},
|
||||||
Document,
|
Document,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -36,50 +37,47 @@ pub mod svg_export {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for turtle in &world.turtles {
|
for turtle in &world.turtles {
|
||||||
for record in &turtle.svg_log.records {
|
for cmd in &turtle.commands {
|
||||||
match record {
|
match cmd {
|
||||||
SvgRecord::Line {
|
DrawCommand::Mesh { source, .. } => {
|
||||||
start,
|
match &source.command {
|
||||||
end,
|
TurtleCommand::Move(_) | TurtleCommand::Goto(_) => {
|
||||||
color,
|
// Linie als <line>
|
||||||
pen_width,
|
let start = source.start_position;
|
||||||
} => {
|
let end = source.end_position;
|
||||||
update_bounds(
|
update_bounds(
|
||||||
&mut min_x, &mut max_x, &mut min_y, &mut max_y, start.x, start.y,
|
&mut min_x, &mut max_x, &mut min_y, &mut max_y, start.x,
|
||||||
|
start.y,
|
||||||
);
|
);
|
||||||
update_bounds(
|
update_bounds(
|
||||||
&mut min_x, &mut max_x, &mut min_y, &mut max_y, end.x, end.y,
|
&mut min_x, &mut max_x, &mut min_y, &mut max_y, end.x,
|
||||||
|
end.y,
|
||||||
);
|
);
|
||||||
let line = Line::new()
|
let line = Line::new()
|
||||||
.set("x1", start.x)
|
.set("x1", start.x)
|
||||||
.set("y1", start.y)
|
.set("y1", start.y)
|
||||||
.set("x2", end.x)
|
.set("x2", end.x)
|
||||||
.set("y2", end.y)
|
.set("y2", end.y)
|
||||||
.set("stroke", color_to_svg(*color))
|
.set("stroke", color_to_svg(source.color))
|
||||||
.set("stroke-width", *pen_width);
|
.set("stroke-width", source.pen_width);
|
||||||
doc = doc.add(line);
|
doc = doc.add(line);
|
||||||
}
|
}
|
||||||
|
TurtleCommand::Circle {
|
||||||
SvgRecord::Arc {
|
|
||||||
start_position,
|
|
||||||
start_heading,
|
|
||||||
radius,
|
radius,
|
||||||
angle,
|
angle,
|
||||||
direction,
|
direction,
|
||||||
color,
|
..
|
||||||
pen_width,
|
|
||||||
} => {
|
} => {
|
||||||
use crate::circle_geometry::CircleGeometry;
|
use crate::circle_geometry::CircleGeometry;
|
||||||
use crate::general::Radians;
|
|
||||||
let geom = CircleGeometry::new(
|
let geom = CircleGeometry::new(
|
||||||
*start_position,
|
source.start_position,
|
||||||
Radians::new(*start_heading),
|
source.start_heading,
|
||||||
*radius,
|
*radius,
|
||||||
*direction,
|
*direction,
|
||||||
);
|
);
|
||||||
let center = geom.center;
|
let center = geom.center;
|
||||||
// Include the bounding box of the full circle so partial arcs
|
if (*angle - 360.0).abs() < 1e-3 {
|
||||||
// are never clipped.
|
// Voller Kreis
|
||||||
update_bounds(
|
update_bounds(
|
||||||
&mut min_x,
|
&mut min_x,
|
||||||
&mut max_x,
|
&mut max_x,
|
||||||
@ -96,55 +94,67 @@ pub mod svg_export {
|
|||||||
center.x + radius,
|
center.x + radius,
|
||||||
center.y + radius,
|
center.y + radius,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (angle.value() - 360.0).abs() < 1e-3 {
|
|
||||||
// Full circle — emit as <circle>
|
|
||||||
let circle = Circle::new()
|
let circle = Circle::new()
|
||||||
.set("cx", center.x)
|
.set("cx", center.x)
|
||||||
.set("cy", center.y)
|
.set("cy", center.y)
|
||||||
.set("r", *radius)
|
.set("r", *radius)
|
||||||
.set("stroke", color_to_svg(*color))
|
.set("stroke", color_to_svg(source.color))
|
||||||
.set("stroke-width", *pen_width)
|
.set("stroke-width", source.pen_width)
|
||||||
.set("fill", "none");
|
.set("fill", "none");
|
||||||
doc = doc.add(circle);
|
doc = doc.add(circle);
|
||||||
} else {
|
} else {
|
||||||
// Partial arc — emit as <path A …>
|
// Kreisbogen als <path>
|
||||||
let end = geom.position_at_angle(angle.as_radians().value());
|
let start = source.start_position;
|
||||||
let large_arc = if angle.value() > 180.0 { 1 } else { 0 };
|
let end = source.end_position;
|
||||||
|
// For arcs, include the full circle bounds to ensure complete visibility
|
||||||
|
update_bounds(
|
||||||
|
&mut min_x,
|
||||||
|
&mut max_x,
|
||||||
|
&mut min_y,
|
||||||
|
&mut max_y,
|
||||||
|
center.x - radius,
|
||||||
|
center.y - radius,
|
||||||
|
);
|
||||||
|
update_bounds(
|
||||||
|
&mut min_x,
|
||||||
|
&mut max_x,
|
||||||
|
&mut min_y,
|
||||||
|
&mut max_y,
|
||||||
|
center.x + radius,
|
||||||
|
center.y + radius,
|
||||||
|
);
|
||||||
|
let large_arc = if *angle > 180.0 { 1 } else { 0 };
|
||||||
let sweep = match direction {
|
let sweep = match direction {
|
||||||
crate::circle_geometry::CircleDirection::Left => 0,
|
crate::circle_geometry::CircleDirection::Left => 0,
|
||||||
crate::circle_geometry::CircleDirection::Right => 1,
|
crate::circle_geometry::CircleDirection::Right => 1,
|
||||||
};
|
};
|
||||||
let d = format!(
|
let d = format!(
|
||||||
"M {} {} A {} {} 0 {} {} {} {}",
|
"M {} {} A {} {} 0 {} {} {} {}",
|
||||||
start_position.x,
|
start.x,
|
||||||
start_position.y,
|
start.y,
|
||||||
radius,
|
radius,
|
||||||
radius,
|
radius,
|
||||||
large_arc,
|
large_arc,
|
||||||
sweep,
|
sweep,
|
||||||
end.x,
|
end.x,
|
||||||
end.y,
|
end.y
|
||||||
);
|
);
|
||||||
let path = svg::node::element::Path::new()
|
let path = svg::node::element::Path::new()
|
||||||
.set("d", d)
|
.set("d", d)
|
||||||
.set("stroke", color_to_svg(*color))
|
.set("stroke", color_to_svg(source.color))
|
||||||
.set("stroke-width", *pen_width)
|
.set("stroke-width", source.pen_width)
|
||||||
.set("fill", "none");
|
.set("fill", "none");
|
||||||
doc = doc.add(path);
|
doc = doc.add(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
TurtleCommand::EndFill => {
|
||||||
SvgRecord::Fill {
|
// Fills werden als <path> mit Konturen ausgegeben
|
||||||
contours,
|
if let Some(contours) = &source.contours {
|
||||||
fill_color,
|
|
||||||
stroke_color,
|
|
||||||
} => {
|
|
||||||
for contour in contours {
|
for contour in contours {
|
||||||
for point in contour {
|
for point in contour {
|
||||||
update_bounds(
|
update_bounds(
|
||||||
&mut min_x, &mut max_x, &mut min_y, &mut max_y, point.x,
|
&mut min_x, &mut max_x, &mut min_y, &mut max_y,
|
||||||
point.y,
|
point.x, point.y,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -154,9 +164,15 @@ pub mod svg_export {
|
|||||||
if i > 0 {
|
if i > 0 {
|
||||||
d.push(' ');
|
d.push(' ');
|
||||||
}
|
}
|
||||||
d.push_str(&format!("M {} {}", contour[0].x, contour[0].y));
|
d.push_str(&format!(
|
||||||
|
"M {} {}",
|
||||||
|
contour[0].x, contour[0].y
|
||||||
|
));
|
||||||
for point in contour.iter().skip(1) {
|
for point in contour.iter().skip(1) {
|
||||||
d.push_str(&format!(" L {} {}", point.x, point.y));
|
d.push_str(&format!(
|
||||||
|
" L {} {}",
|
||||||
|
point.x, point.y
|
||||||
|
));
|
||||||
}
|
}
|
||||||
d.push_str(" Z");
|
d.push_str(" Z");
|
||||||
}
|
}
|
||||||
@ -164,17 +180,63 @@ pub mod svg_export {
|
|||||||
if !d.is_empty() {
|
if !d.is_empty() {
|
||||||
let path = svg::node::element::Path::new()
|
let path = svg::node::element::Path::new()
|
||||||
.set("d", d)
|
.set("d", d)
|
||||||
.set("fill", color_to_svg(*fill_color))
|
.set("fill", color_to_svg(source.fill_color))
|
||||||
.set("fill-rule", "evenodd")
|
.set("fill-rule", "evenodd")
|
||||||
.set("stroke", color_to_svg(*stroke_color));
|
.set("stroke", color_to_svg(source.color));
|
||||||
doc = doc.add(path);
|
doc = doc.add(path);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: Dummy-Polygon
|
||||||
|
update_bounds(
|
||||||
|
&mut min_x,
|
||||||
|
&mut max_x,
|
||||||
|
&mut min_y,
|
||||||
|
&mut max_y,
|
||||||
|
source.start_position.x,
|
||||||
|
source.start_position.y,
|
||||||
|
);
|
||||||
|
update_bounds(
|
||||||
|
&mut min_x,
|
||||||
|
&mut max_x,
|
||||||
|
&mut min_y,
|
||||||
|
&mut max_y,
|
||||||
|
source.start_position.x + 10.0,
|
||||||
|
source.start_position.y + 10.0,
|
||||||
|
);
|
||||||
|
update_bounds(
|
||||||
|
&mut min_x,
|
||||||
|
&mut max_x,
|
||||||
|
&mut min_y,
|
||||||
|
&mut max_y,
|
||||||
|
source.start_position.x + 5.0,
|
||||||
|
source.start_position.y + 15.0,
|
||||||
|
);
|
||||||
|
let poly = Polygon::new()
|
||||||
|
.set(
|
||||||
|
"points",
|
||||||
|
format!(
|
||||||
|
"{},{} {},{} {},{}",
|
||||||
|
source.start_position.x,
|
||||||
|
source.start_position.y,
|
||||||
|
source.start_position.x + 10.0,
|
||||||
|
source.start_position.y + 10.0,
|
||||||
|
source.start_position.x + 5.0,
|
||||||
|
source.start_position.y + 15.0
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.set("fill", color_to_svg(source.fill_color))
|
||||||
|
.set("stroke", color_to_svg(source.color));
|
||||||
|
doc = doc.add(poly);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
SvgRecord::Text {
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DrawCommand::Text {
|
||||||
text,
|
text,
|
||||||
position,
|
position,
|
||||||
color,
|
source,
|
||||||
|
..
|
||||||
} => {
|
} => {
|
||||||
update_bounds(
|
update_bounds(
|
||||||
&mut min_x, &mut max_x, &mut min_y, &mut max_y, position.x,
|
&mut min_x, &mut max_x, &mut min_y, &mut max_y, position.x,
|
||||||
@ -183,7 +245,7 @@ pub mod svg_export {
|
|||||||
let txt = SvgText::new()
|
let txt = SvgText::new()
|
||||||
.set("x", position.x)
|
.set("x", position.x)
|
||||||
.set("y", position.y)
|
.set("y", position.y)
|
||||||
.set("fill", color_to_svg(*color))
|
.set("fill", color_to_svg(source.color))
|
||||||
.add(svg::node::Text::new(text.clone()));
|
.add(svg::node::Text::new(text.clone()));
|
||||||
doc = doc.add(txt);
|
doc = doc.add(txt);
|
||||||
}
|
}
|
||||||
@ -198,6 +260,7 @@ pub mod svg_export {
|
|||||||
let view_box = format!("{} {} {} {}", min_x - 20.0, min_y - 20.0, width, height);
|
let view_box = format!("{} {} {} {}", min_x - 20.0, min_y - 20.0, width, height);
|
||||||
doc = doc.set("viewBox", view_box);
|
doc = doc.set("viewBox", view_box);
|
||||||
} else {
|
} else {
|
||||||
|
// Default viewBox if no elements
|
||||||
doc = doc.set("viewBox", "0 0 400 400");
|
doc = doc.set("viewBox", "0 0 400 400");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,7 @@ pub mod angle;
|
|||||||
pub mod fontsize;
|
pub mod fontsize;
|
||||||
pub mod length;
|
pub mod length;
|
||||||
|
|
||||||
pub use angle::{Degrees, Radians};
|
pub use angle::Angle;
|
||||||
pub use fontsize::FontSize;
|
pub use fontsize::FontSize;
|
||||||
pub use length::Length;
|
pub use length::Length;
|
||||||
|
|
||||||
|
|||||||
@ -1,162 +1,205 @@
|
|||||||
//! Angle unit newtypes: `Degrees` and `Radians`.
|
//! Angle type with degrees and radians support
|
||||||
//!
|
|
||||||
//! ## Design
|
|
||||||
//!
|
|
||||||
//! Two separate types instead of a single enum so that function signatures are
|
|
||||||
//! self-documenting and the compiler rejects wrong-unit arguments.
|
|
||||||
//!
|
|
||||||
//! - **`Degrees`** — public API boundary. Builder methods and `TurtleCommand`
|
|
||||||
//! fields that originate from user input store this type. Convert with
|
|
||||||
//! `as_radians()` before entering the rendering pipeline.
|
|
||||||
//!
|
|
||||||
//! - **`Radians`** — internal pipeline. All geometry functions and
|
|
||||||
//! `TurtleParams` arithmetic work in radians. Extract the raw `f32` with
|
|
||||||
//! `value()` only where stdlib trig functions (`sin`, `cos`, …) require it.
|
|
||||||
//!
|
|
||||||
//! There is intentionally **no** conversion from `Radians` back to `f32` that
|
|
||||||
//! strips the unit tag silently — use `.value()` explicitly and at the last
|
|
||||||
//! possible moment.
|
|
||||||
|
|
||||||
use super::Precision;
|
use super::Precision;
|
||||||
use std::ops::Neg;
|
use std::ops::{Add, Div, Mul, Neg, Rem, Sub};
|
||||||
|
|
||||||
/// An angle measured in degrees.
|
#[derive(Copy, Clone, Debug, PartialEq)]
|
||||||
///
|
pub enum AngleUnit {
|
||||||
/// Used at the public API boundary. Convert to [`Radians`] with `as_radians()`
|
Degrees(Precision),
|
||||||
/// before passing into internal rendering functions.
|
Radians(Precision),
|
||||||
#[derive(Copy, Clone, Debug, PartialEq, PartialOrd, Default)]
|
}
|
||||||
pub struct Degrees(pub Precision);
|
|
||||||
|
|
||||||
impl Degrees {
|
impl Default for AngleUnit {
|
||||||
/// Construct from a raw degrees value.
|
fn default() -> Self {
|
||||||
#[must_use]
|
Self::Degrees(0.0)
|
||||||
pub fn new(v: Precision) -> Self {
|
|
||||||
Self(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert to [`Radians`] for use in the rendering pipeline.
|
|
||||||
///
|
|
||||||
/// This is the **only** correct way to enter the internal math layer.
|
|
||||||
#[must_use]
|
|
||||||
pub fn as_radians(self) -> Radians {
|
|
||||||
Radians(self.0.to_radians())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The raw degrees value.
|
|
||||||
///
|
|
||||||
/// Use only for degree-to-degree arithmetic (e.g. negating a turn angle
|
|
||||||
/// before storing it as a command). Do not pass this to trig functions.
|
|
||||||
#[must_use]
|
|
||||||
pub fn value(self) -> Precision {
|
|
||||||
self.0
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Neg for Degrees {
|
#[derive(Copy, Clone, Debug, PartialEq)]
|
||||||
|
pub struct Angle {
|
||||||
|
value: AngleUnit,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Angle {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
value: AngleUnit::Degrees(0.0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<i16> for Angle {
|
||||||
|
fn from(i: i16) -> Self {
|
||||||
|
Self {
|
||||||
|
value: AngleUnit::Degrees(Precision::from(i)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<f32> for Angle {
|
||||||
|
fn from(f: f32) -> Self {
|
||||||
|
Self {
|
||||||
|
value: AngleUnit::Degrees(f),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Rem<Precision> for Angle {
|
||||||
type Output = Self;
|
type Output = Self;
|
||||||
fn neg(self) -> Self {
|
|
||||||
Self(-self.0)
|
fn rem(self, rhs: Precision) -> Self::Output {
|
||||||
|
match self.value {
|
||||||
|
AngleUnit::Degrees(v) => Self::degrees(v % rhs),
|
||||||
|
AngleUnit::Radians(v) => Self::radians(v % rhs),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<f32> for Degrees {
|
impl Mul<Precision> for Angle {
|
||||||
fn from(v: f32) -> Self {
|
|
||||||
Self(v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<i32> for Degrees {
|
|
||||||
fn from(v: i32) -> Self {
|
|
||||||
Self(v as Precision)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<i16> for Degrees {
|
|
||||||
fn from(v: i16) -> Self {
|
|
||||||
Self(Precision::from(v))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// An angle measured in radians.
|
|
||||||
///
|
|
||||||
/// Used in all internal function signatures and geometry math. Extract the
|
|
||||||
/// raw `f32` with [`value()`](Radians::value) only when calling stdlib trig
|
|
||||||
/// functions (`sin`, `cos`, etc.).
|
|
||||||
#[derive(Copy, Clone, Debug, PartialEq, PartialOrd, Default)]
|
|
||||||
pub struct Radians(pub Precision);
|
|
||||||
|
|
||||||
impl Radians {
|
|
||||||
/// Construct from a raw radians value.
|
|
||||||
#[must_use]
|
|
||||||
pub fn new(v: Precision) -> Self {
|
|
||||||
Self(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert to [`Degrees`] for display or user-facing output.
|
|
||||||
#[must_use]
|
|
||||||
pub fn as_degrees(self) -> Degrees {
|
|
||||||
Degrees(self.0.to_degrees())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The raw radians value.
|
|
||||||
///
|
|
||||||
/// Use only when calling stdlib trig functions or other `f32`-based
|
|
||||||
/// math APIs. Keep `Radians` as the type at all internal function
|
|
||||||
/// boundaries.
|
|
||||||
#[must_use]
|
|
||||||
pub fn value(self) -> Precision {
|
|
||||||
self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Neg for Radians {
|
|
||||||
type Output = Self;
|
type Output = Self;
|
||||||
fn neg(self) -> Self {
|
|
||||||
Self(-self.0)
|
fn mul(self, rhs: Precision) -> Self::Output {
|
||||||
|
match self.value {
|
||||||
|
AngleUnit::Degrees(v) => Self::degrees(v * rhs),
|
||||||
|
AngleUnit::Radians(v) => Self::radians(v * rhs),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<f32> for Radians {
|
impl Div<Precision> for Angle {
|
||||||
fn from(v: f32) -> Self {
|
type Output = Self;
|
||||||
Self(v)
|
|
||||||
|
fn div(self, rhs: Precision) -> Self::Output {
|
||||||
|
match self.value {
|
||||||
|
AngleUnit::Degrees(v) => Self::degrees(v / rhs),
|
||||||
|
AngleUnit::Radians(v) => Self::radians(v / rhs),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Neg for Angle {
|
||||||
|
type Output = Self;
|
||||||
|
|
||||||
|
fn neg(self) -> Self::Output {
|
||||||
|
match self.value {
|
||||||
|
AngleUnit::Degrees(v) => Self::degrees(-v),
|
||||||
|
AngleUnit::Radians(v) => Self::radians(-v),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Neg for &Angle {
|
||||||
|
type Output = Angle;
|
||||||
|
|
||||||
|
fn neg(self) -> Self::Output {
|
||||||
|
match self.value {
|
||||||
|
AngleUnit::Degrees(v) => Angle::degrees(-v),
|
||||||
|
AngleUnit::Radians(v) => Angle::radians(-v),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Add for Angle {
|
||||||
|
type Output = Angle;
|
||||||
|
|
||||||
|
fn add(self, rhs: Self) -> Self::Output {
|
||||||
|
match (self.value, rhs.value) {
|
||||||
|
(AngleUnit::Degrees(v), AngleUnit::Degrees(o)) => Self::degrees(v + o),
|
||||||
|
(AngleUnit::Degrees(v), AngleUnit::Radians(o)) => Self::radians(v.to_radians() + o),
|
||||||
|
(AngleUnit::Radians(v), AngleUnit::Degrees(o)) => Self::radians(v + o.to_radians()),
|
||||||
|
(AngleUnit::Radians(v), AngleUnit::Radians(o)) => Self::radians(v + o),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Sub for Angle {
|
||||||
|
type Output = Angle;
|
||||||
|
|
||||||
|
fn sub(self, rhs: Self) -> Self::Output {
|
||||||
|
match (self.value, rhs.value) {
|
||||||
|
(AngleUnit::Degrees(v), AngleUnit::Degrees(o)) => Self::degrees(v - o),
|
||||||
|
(AngleUnit::Degrees(v), AngleUnit::Radians(o)) => Self::radians(v.to_radians() - o),
|
||||||
|
(AngleUnit::Radians(v), AngleUnit::Degrees(o)) => Self::radians(v - o.to_radians()),
|
||||||
|
(AngleUnit::Radians(v), AngleUnit::Radians(o)) => Self::radians(v - o),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Angle {
|
||||||
|
#[must_use]
|
||||||
|
pub fn degrees(value: Precision) -> Self {
|
||||||
|
Self {
|
||||||
|
value: AngleUnit::Degrees(value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn radians(value: Precision) -> Self {
|
||||||
|
Self {
|
||||||
|
value: AngleUnit::Radians(value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn value(&self) -> Precision {
|
||||||
|
match self.value {
|
||||||
|
AngleUnit::Degrees(v) | AngleUnit::Radians(v) => v,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn to_radians(self) -> Self {
|
||||||
|
match self.value {
|
||||||
|
AngleUnit::Degrees(v) => Self::radians(v.to_radians()),
|
||||||
|
AngleUnit::Radians(_) => self,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn to_degrees(self) -> Self {
|
||||||
|
match self.value {
|
||||||
|
AngleUnit::Degrees(_) => self,
|
||||||
|
AngleUnit::Radians(v) => Self::degrees(v.to_degrees()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn limit_smaller_than_full_circle(self) -> Self {
|
||||||
|
use std::f32::consts::PI;
|
||||||
|
match self.value {
|
||||||
|
AngleUnit::Degrees(v) => Self::degrees(v % 360.0),
|
||||||
|
AngleUnit::Radians(v) => Self::radians(v % (2.0 * PI)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::f32::consts::PI;
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn degrees_to_radians_roundtrip() {
|
fn convert_to_radians() {
|
||||||
let deg = Degrees::new(180.0);
|
let radi = Angle::radians(30f32.to_radians());
|
||||||
let rad = deg.as_radians();
|
let degr = Angle::degrees(30f32);
|
||||||
assert!(
|
let converted = degr.to_radians();
|
||||||
(rad.value() - PI).abs() < 1e-6,
|
assert!((radi.value() - converted.value()).abs() < 0.0001);
|
||||||
"expected π, got {}",
|
|
||||||
rad.value()
|
|
||||||
);
|
|
||||||
let back = rad.as_degrees();
|
|
||||||
assert!(
|
|
||||||
(back.value() - 180.0).abs() < 1e-4,
|
|
||||||
"expected 180°, got {}",
|
|
||||||
back.value()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn negation() {
|
fn sum_degrees() {
|
||||||
assert_eq!(-Degrees::new(90.0), Degrees::new(-90.0));
|
let fst = Angle::degrees(30f32);
|
||||||
assert_eq!(-Radians::new(1.0), Radians::new(-1.0));
|
let snd = Angle::degrees(30f32);
|
||||||
|
let sum = fst + snd;
|
||||||
|
assert!((sum.value() - 60f32).abs() < 0.0001);
|
||||||
|
assert!((sum.to_radians().value() - 60f32.to_radians()).abs() < 0.0001);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn from_integer() {
|
fn sum_mixed() {
|
||||||
let d: Degrees = 90_i32.into();
|
let fst = Angle::degrees(30f32);
|
||||||
assert_eq!(d, Degrees::new(90.0));
|
let snd = Angle::radians(30f32.to_radians());
|
||||||
let d2: Degrees = 45_i16.into();
|
let sum = fst + snd;
|
||||||
assert_eq!(d2, Degrees::new(45.0));
|
assert!((sum.to_degrees().value() - 60f32).abs() < 0.0001);
|
||||||
|
assert!((sum.to_radians().value() - 60f32.to_radians()).abs() < 0.0001);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,29 +46,30 @@
|
|||||||
//! }
|
//! }
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
pub(crate) mod builders;
|
pub mod builders;
|
||||||
pub(crate) mod circle_geometry;
|
pub mod circle_geometry;
|
||||||
pub(crate) mod command_behavior;
|
pub mod commands;
|
||||||
pub(crate) mod commands;
|
pub mod commands_channel;
|
||||||
pub(crate) mod commands_channel;
|
pub mod drawing;
|
||||||
pub(crate) mod drawing;
|
pub mod execution;
|
||||||
pub(crate) mod execution;
|
pub mod general;
|
||||||
pub(crate) mod general;
|
pub mod shapes;
|
||||||
pub(crate) mod shapes;
|
pub mod state;
|
||||||
pub(crate) mod state;
|
pub mod tessellation;
|
||||||
pub(crate) mod tessellation;
|
pub mod tweening;
|
||||||
pub(crate) mod tweening;
|
|
||||||
|
|
||||||
// Re-export commonly used types
|
// Re-export commonly used types
|
||||||
pub use builders::{CurvedMovement, DirectionalMovement, Turnable, TurtlePlan, WithCommands};
|
pub use builders::{CurvedMovement, DirectionalMovement, Turnable, TurtlePlan, WithCommands};
|
||||||
pub use commands::{CommandQueue, TurtleCommand};
|
pub use commands::{CommandQueue, TurtleCommand};
|
||||||
pub use commands_channel::TurtleCommandSender;
|
pub use commands_channel::{turtle_command_channel, TurtleCommandReceiver, TurtleCommandSender};
|
||||||
pub use general::{AnimationSpeed, Color, Coordinate, Degrees, Length, Precision, Radians};
|
pub use general::{Angle, AnimationSpeed, Color, Coordinate, Length, Precision};
|
||||||
pub use shapes::{ShapeType, TurtleShape};
|
pub use shapes::{ShapeType, TurtleShape};
|
||||||
|
pub use state::{DrawCommand, Turtle, TurtleWorld};
|
||||||
|
pub use tweening::TweenController;
|
||||||
|
|
||||||
pub mod export;
|
pub mod export;
|
||||||
#[cfg(feature = "svg")]
|
#[cfg(feature = "svg")]
|
||||||
pub(crate) mod export_svg;
|
pub mod export_svg;
|
||||||
|
|
||||||
// Re-export the turtle_main macro
|
// Re-export the turtle_main macro
|
||||||
pub use turtle_lib_macros::turtle_main;
|
pub use turtle_lib_macros::turtle_main;
|
||||||
@ -78,8 +79,6 @@ pub use macroquad::prelude::{
|
|||||||
vec2, BLACK, BLUE, DARKGRAY, GOLD, GREEN, ORANGE, PURPLE, RED, WHITE, YELLOW,
|
vec2, BLACK, BLUE, DARKGRAY, GOLD, GREEN, ORANGE, PURPLE, RED, WHITE, YELLOW,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::commands_channel::TurtleCommandReceiver;
|
|
||||||
use crate::state::TurtleWorld;
|
|
||||||
use macroquad::prelude::*;
|
use macroquad::prelude::*;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
@ -96,7 +95,9 @@ pub struct TurtleApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl TurtleApp {
|
impl TurtleApp {
|
||||||
/// Export the current drawing to a file in the specified format.
|
/// Exportiere das aktuelle Drawing in das gewünschte Format
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
/// Export the current drawing to a file in the specified format
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
@ -114,10 +115,10 @@ impl TurtleApp {
|
|||||||
let exporter = SvgExporter;
|
let exporter = SvgExporter;
|
||||||
exporter.export(&self.world, filename)
|
exporter.export(&self.world, filename)
|
||||||
}
|
}
|
||||||
// Additional formats can be registered here.
|
// Weitere Formate können hier ergänzt werden
|
||||||
#[allow(unreachable_patterns)]
|
#[allow(unreachable_patterns)]
|
||||||
_ => Err(export::ExportError::Format(
|
_ => Err(export::ExportError::Format(
|
||||||
"Unsupported export format".to_string(),
|
"Export-Format nicht unterstützt".to_string(),
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -289,25 +290,20 @@ impl TurtleApp {
|
|||||||
|
|
||||||
// Update all turtles' tween controllers
|
// Update all turtles' tween controllers
|
||||||
for turtle in &mut self.world.turtles {
|
for turtle in &mut self.world.turtles {
|
||||||
// Drive this turtle's animation controller for one frame.
|
// Extract draw_commands and controller temporarily to avoid borrow conflicts
|
||||||
// `update_tweens` splits &mut Turtle into disjoint field borrows so
|
|
||||||
// TweenController::update can be a proper &mut self method.
|
// Update the controller
|
||||||
let completed_commands = turtle.update_tweens();
|
let completed_commands = TweenController::update(turtle);
|
||||||
|
|
||||||
// Process all completed commands and add to the turtle's commands
|
// Process all completed commands and add to the turtle's commands
|
||||||
for (completed_cmd, tween_start, end_state) in completed_commands {
|
for (completed_cmd, tween_start, mut end_state) in completed_commands {
|
||||||
if let Some(draw_cmd) =
|
let draw_command = execution::add_draw_for_completed_tween(
|
||||||
execution::tessellate_command(&completed_cmd, &tween_start, end_state.position)
|
|
||||||
{
|
|
||||||
#[cfg(feature = "svg")]
|
|
||||||
execution::push_svg_for_draw(
|
|
||||||
&completed_cmd,
|
&completed_cmd,
|
||||||
&tween_start,
|
&tween_start,
|
||||||
end_state.position,
|
&mut end_state,
|
||||||
&mut turtle.svg_log,
|
|
||||||
);
|
);
|
||||||
turtle.commands.push(draw_cmd);
|
// Add the new draw commands to the turtle
|
||||||
}
|
turtle.commands.extend(draw_command);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -371,31 +367,14 @@ impl TurtleApp {
|
|||||||
.all(|turtle| turtle.tween_controller.is_complete())
|
.all(|turtle| turtle.tween_controller.is_complete())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if all animations are complete (alias for is_complete)
|
|
||||||
#[must_use]
|
|
||||||
pub fn all_animations_complete(&self) -> bool {
|
|
||||||
self.is_complete()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the animation speed for all turtles
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
/// * `speed` - The animation speed to set for all turtles
|
|
||||||
pub fn set_all_turtles_speed(&mut self, speed: AnimationSpeed) {
|
|
||||||
for turtle in &mut self.world.turtles {
|
|
||||||
turtle.set_speed(speed);
|
|
||||||
turtle.tween_controller.set_speed(speed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get reference to the world state
|
/// Get reference to the world state
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub(crate) fn world(&self) -> &TurtleWorld {
|
pub fn world(&self) -> &TurtleWorld {
|
||||||
&self.world
|
&self.world
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get mutable reference to the world state
|
/// Get mutable reference to the world state
|
||||||
pub(crate) fn world_mut(&mut self) -> &mut TurtleWorld {
|
pub fn world_mut(&mut self) -> &mut TurtleWorld {
|
||||||
&mut self.world
|
&mut self.world
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,40 +1,40 @@
|
|||||||
//! Turtle state and world state management
|
//! Turtle state and world state management
|
||||||
|
|
||||||
use crate::commands::CommandQueue;
|
use crate::commands::CommandQueue;
|
||||||
use crate::general::{AnimationSpeed, Color, Coordinate};
|
use crate::general::{Angle, AnimationSpeed, Color, Coordinate};
|
||||||
use crate::shapes::TurtleShape;
|
use crate::shapes::TurtleShape;
|
||||||
use crate::tweening::TweenController;
|
use crate::tweening::TweenController;
|
||||||
use macroquad::prelude::*;
|
use macroquad::prelude::*;
|
||||||
|
|
||||||
/// State during active fill operation
|
/// State during active fill operation
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub(crate) struct FillState {
|
pub struct FillState {
|
||||||
/// Starting position of the fill
|
/// Starting position of the fill
|
||||||
pub(crate) start_position: Coordinate,
|
pub start_position: Coordinate,
|
||||||
|
|
||||||
/// All contours collected so far. Each contour is a separate closed path.
|
/// All contours collected so far. Each contour is a separate closed path.
|
||||||
/// The first contour is the outer boundary, subsequent contours are holes.
|
/// The first contour is the outer boundary, subsequent contours are holes.
|
||||||
pub(crate) contours: Vec<Vec<Coordinate>>,
|
pub contours: Vec<Vec<Coordinate>>,
|
||||||
|
|
||||||
/// Current contour being built (vertices for the active `pen_down` segment)
|
/// Current contour being built (vertices for the active `pen_down` segment)
|
||||||
pub(crate) current_contour: Vec<Coordinate>,
|
pub current_contour: Vec<Coordinate>,
|
||||||
|
|
||||||
/// Fill color (cached from when `begin_fill` was called)
|
/// Fill color (cached from when `begin_fill` was called)
|
||||||
pub(crate) fill_color: Color,
|
pub fill_color: Color,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parameters that define a turtle's visual state
|
/// Parameters that define a turtle's visual state
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub(crate) struct TurtleParams {
|
pub struct TurtleParams {
|
||||||
pub(crate) position: Vec2,
|
pub position: Vec2,
|
||||||
pub(crate) heading: f32,
|
pub heading: f32,
|
||||||
pub(crate) pen_down: bool,
|
pub pen_down: bool,
|
||||||
pub(crate) pen_width: f32,
|
pub pen_width: f32,
|
||||||
pub(crate) color: Color,
|
pub color: Color,
|
||||||
pub(crate) fill_color: Option<Color>,
|
pub fill_color: Option<Color>,
|
||||||
pub(crate) visible: bool,
|
pub visible: bool,
|
||||||
pub(crate) shape: crate::shapes::TurtleShape,
|
pub shape: crate::shapes::TurtleShape,
|
||||||
pub(crate) speed: AnimationSpeed,
|
pub speed: AnimationSpeed,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for TurtleParams {
|
impl Default for TurtleParams {
|
||||||
@ -56,21 +56,18 @@ impl Default for TurtleParams {
|
|||||||
|
|
||||||
/// State of a single turtle
|
/// State of a single turtle
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub(crate) struct Turtle {
|
pub struct Turtle {
|
||||||
pub(crate) turtle_id: usize,
|
pub turtle_id: usize,
|
||||||
pub(crate) params: TurtleParams,
|
pub params: TurtleParams,
|
||||||
|
|
||||||
// Fill tracking
|
// Fill tracking
|
||||||
pub(crate) filling: Option<FillState>,
|
pub filling: Option<FillState>,
|
||||||
|
|
||||||
// Drawing commands created by this turtle
|
// Drawing commands created by this turtle
|
||||||
pub(crate) commands: Vec<DrawCommand>,
|
pub commands: Vec<DrawCommand>,
|
||||||
|
|
||||||
// SVG draw-event log — populated alongside `commands`, consumed by the SVG exporter
|
|
||||||
pub(crate) svg_log: SvgLog,
|
|
||||||
|
|
||||||
// Animation controller for this turtle
|
// Animation controller for this turtle
|
||||||
pub(crate) tween_controller: TweenController,
|
pub tween_controller: TweenController,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Turtle {
|
impl Default for Turtle {
|
||||||
@ -80,7 +77,6 @@ impl Default for Turtle {
|
|||||||
params: TurtleParams::default(),
|
params: TurtleParams::default(),
|
||||||
filling: None,
|
filling: None,
|
||||||
commands: Vec::new(),
|
commands: Vec::new(),
|
||||||
svg_log: SvgLog::default(),
|
|
||||||
tween_controller: TweenController::new(CommandQueue::new(), AnimationSpeed::default()),
|
tween_controller: TweenController::new(CommandQueue::new(), AnimationSpeed::default()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -92,15 +88,14 @@ impl Turtle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn heading_angle(&self) -> crate::general::Radians {
|
pub fn heading_angle(&self) -> Angle {
|
||||||
crate::general::Radians::new(self.params.heading)
|
Angle::radians(self.params.heading)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reset turtle to default state (preserves `turtle_id` and queued commands)
|
/// Reset turtle to default state (preserves `turtle_id` and queued commands)
|
||||||
pub fn reset(&mut self) {
|
pub fn reset(&mut self) {
|
||||||
// Clear all drawings
|
// Clear all drawings
|
||||||
self.commands.clear();
|
self.commands.clear();
|
||||||
self.svg_log.clear();
|
|
||||||
|
|
||||||
// Clear fill state
|
// Clear fill state
|
||||||
self.filling = None;
|
self.filling = None;
|
||||||
@ -111,27 +106,6 @@ impl Turtle {
|
|||||||
// Keep turtle_id and tween_controller (preserves queued commands)
|
// Keep turtle_id and tween_controller (preserves queued commands)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drive the animation controller for one frame.
|
|
||||||
///
|
|
||||||
/// Returns `(command, start_params, end_params)` for every command that
|
|
||||||
/// completed this frame and whose stroke needs to be tessellated by the
|
|
||||||
/// caller into a `DrawCommand`.
|
|
||||||
///
|
|
||||||
/// This method performs the correct disjoint field-borrow split so that
|
|
||||||
/// `TweenController::update` can be a proper `&mut self` method instead
|
|
||||||
/// of the old static-method borrow-checker workaround.
|
|
||||||
pub fn update_tweens(
|
|
||||||
&mut self,
|
|
||||||
) -> Vec<(crate::commands::TurtleCommand, TurtleParams, TurtleParams)> {
|
|
||||||
self.tween_controller.update(
|
|
||||||
self.turtle_id,
|
|
||||||
&mut self.params,
|
|
||||||
&mut self.filling,
|
|
||||||
&mut self.commands,
|
|
||||||
&mut self.svg_log,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start recording fill vertices
|
/// Start recording fill vertices
|
||||||
pub fn begin_fill(&mut self, fill_color: Color) {
|
pub fn begin_fill(&mut self, fill_color: Color) {
|
||||||
self.filling = Some(FillState {
|
self.filling = Some(FillState {
|
||||||
@ -283,75 +257,11 @@ impl Turtle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The draw-event log for SVG export.
|
|
||||||
///
|
|
||||||
/// When the `svg` feature is **disabled** this is a zero-sized type (ZST) with
|
|
||||||
/// no fields — it compiles away entirely and adds zero overhead to `Turtle`.
|
|
||||||
/// When the feature is **enabled** it owns a `Vec<SvgRecord>` that the SVG
|
|
||||||
/// exporter consumes after rendering.
|
|
||||||
///
|
|
||||||
/// All methods are always callable so function signatures that accept
|
|
||||||
/// `&mut SvgLog` need no feature-gating at the parameter level.
|
|
||||||
#[derive(Clone, Debug, Default)]
|
|
||||||
pub(crate) struct SvgLog {
|
|
||||||
#[cfg(feature = "svg")]
|
|
||||||
pub(crate) records: Vec<SvgRecord>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SvgLog {
|
|
||||||
pub(crate) fn clear(&mut self) {
|
|
||||||
#[cfg(feature = "svg")]
|
|
||||||
self.records.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "svg")]
|
|
||||||
pub(crate) fn push(&mut self, record: SvgRecord) {
|
|
||||||
self.records.push(record);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A drawing event captured for SVG export.
|
|
||||||
///
|
|
||||||
/// Only compiled when the `svg` feature is enabled.
|
|
||||||
#[cfg(feature = "svg")]
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub(crate) enum SvgRecord {
|
|
||||||
/// A straight-line stroke.
|
|
||||||
Line {
|
|
||||||
start: Vec2,
|
|
||||||
end: Vec2,
|
|
||||||
color: Color,
|
|
||||||
pen_width: f32,
|
|
||||||
},
|
|
||||||
/// An arc or full-circle stroke.
|
|
||||||
Arc {
|
|
||||||
start_position: Vec2,
|
|
||||||
start_heading: f32,
|
|
||||||
radius: crate::general::Precision,
|
|
||||||
angle: crate::general::Degrees,
|
|
||||||
direction: crate::circle_geometry::CircleDirection,
|
|
||||||
color: Color,
|
|
||||||
pen_width: f32,
|
|
||||||
},
|
|
||||||
/// A filled region (potentially with holes via the even-odd rule).
|
|
||||||
Fill {
|
|
||||||
contours: Vec<Vec<crate::general::Coordinate>>,
|
|
||||||
fill_color: Color,
|
|
||||||
stroke_color: Color,
|
|
||||||
},
|
|
||||||
/// A text element.
|
|
||||||
Text {
|
|
||||||
text: String,
|
|
||||||
position: Vec2,
|
|
||||||
color: Color,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cached mesh data that can be cloned and converted to Mesh when needed
|
/// Cached mesh data that can be cloned and converted to Mesh when needed
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub(crate) struct MeshData {
|
pub struct MeshData {
|
||||||
pub(crate) vertices: Vec<macroquad::prelude::Vertex>,
|
pub vertices: Vec<macroquad::prelude::Vertex>,
|
||||||
pub(crate) indices: Vec<u16>,
|
pub indices: Vec<u16>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MeshData {
|
impl MeshData {
|
||||||
@ -365,28 +275,44 @@ impl MeshData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drawable elements in the world.
|
/// Drawable elements in the world
|
||||||
/// All drawing is done via Lyon-tessellated meshes for consistency and quality.
|
/// All drawing is done via Lyon-tessellated meshes for consistency and quality
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub(crate) enum DrawCommand {
|
pub struct TurtleSource {
|
||||||
/// Pre-tessellated mesh data (lines, arcs, circles, polygons — all use this).
|
pub command: crate::commands::TurtleCommand,
|
||||||
Mesh { data: MeshData },
|
pub color: Color,
|
||||||
/// Text rendering command.
|
pub fill_color: Color,
|
||||||
|
pub pen_width: f32,
|
||||||
|
pub start_position: Vec2,
|
||||||
|
pub end_position: Vec2,
|
||||||
|
pub start_heading: f32,
|
||||||
|
pub contours: Option<Vec<Vec<crate::general::Coordinate>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum DrawCommand {
|
||||||
|
/// Pre-tessellated mesh data (lines, arcs, circles, polygons - all use this)
|
||||||
|
Mesh {
|
||||||
|
data: MeshData,
|
||||||
|
source: TurtleSource,
|
||||||
|
},
|
||||||
|
/// Text rendering command
|
||||||
Text {
|
Text {
|
||||||
text: String,
|
text: String,
|
||||||
position: Vec2,
|
position: Vec2,
|
||||||
heading: f32,
|
heading: f32,
|
||||||
font_size: crate::general::FontSize,
|
font_size: crate::general::FontSize,
|
||||||
color: Color,
|
color: Color,
|
||||||
|
source: TurtleSource,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The complete turtle world containing all drawing state
|
/// The complete turtle world containing all drawing state
|
||||||
pub(crate) struct TurtleWorld {
|
pub struct TurtleWorld {
|
||||||
/// All turtles in the world (indexed by turtle ID)
|
/// All turtles in the world (indexed by turtle ID)
|
||||||
pub(crate) turtles: Vec<Turtle>,
|
pub turtles: Vec<Turtle>,
|
||||||
pub(crate) camera: Camera2D,
|
pub camera: Camera2D,
|
||||||
pub(crate) background_color: Color,
|
pub background_color: Color,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TurtleWorld {
|
impl TurtleWorld {
|
||||||
|
|||||||
@ -14,30 +14,26 @@ use macroquad::prelude::*;
|
|||||||
|
|
||||||
/// Convert macroquad Vec2 to Lyon Point
|
/// Convert macroquad Vec2 to Lyon Point
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub(crate) fn to_lyon_point(v: Vec2) -> Point {
|
pub fn to_lyon_point(v: Vec2) -> Point {
|
||||||
point(v.x, v.y)
|
point(v.x, v.y)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert Lyon Point to macroquad Vec2
|
/// Convert Lyon Point to macroquad Vec2
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub(crate) fn to_macroquad_vec2(p: Point) -> Vec2 {
|
pub fn to_macroquad_vec2(p: Point) -> Vec2 {
|
||||||
vec2(p.x, p.y)
|
vec2(p.x, p.y)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Simple vertex type for Lyon tessellation
|
/// Simple vertex type for Lyon tessellation
|
||||||
#[derive(Copy, Clone, Debug)]
|
#[derive(Copy, Clone, Debug)]
|
||||||
pub(crate) struct SimpleVertex {
|
pub struct SimpleVertex {
|
||||||
pub(crate) position: [f32; 2],
|
pub position: [f32; 2],
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build mesh data from Lyon tessellation
|
/// Build mesh data from Lyon tessellation
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub(crate) fn build_mesh_data(
|
pub fn build_mesh_data(vertices: &[SimpleVertex], indices: &[u16], color: Color) -> MeshData {
|
||||||
vertices: &[SimpleVertex],
|
|
||||||
indices: &[u16],
|
|
||||||
color: Color,
|
|
||||||
) -> MeshData {
|
|
||||||
let verts: Vec<Vertex> = vertices
|
let verts: Vec<Vertex> = vertices
|
||||||
.iter()
|
.iter()
|
||||||
.map(|v| Vertex {
|
.map(|v| Vertex {
|
||||||
@ -66,7 +62,7 @@ pub(crate) fn build_mesh_data(
|
|||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Returns an error if no vertices are provided or if tessellation fails.
|
/// Returns an error if no vertices are provided or if tessellation fails.
|
||||||
pub(crate) fn tessellate_polygon(
|
pub fn tessellate_polygon(
|
||||||
vertices: &[Vec2],
|
vertices: &[Vec2],
|
||||||
color: Color,
|
color: Color,
|
||||||
) -> Result<MeshData, Box<dyn std::error::Error>> {
|
) -> Result<MeshData, Box<dyn std::error::Error>> {
|
||||||
@ -111,7 +107,7 @@ pub(crate) fn tessellate_polygon(
|
|||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Returns an error if no contours are provided or if tessellation fails.
|
/// Returns an error if no contours are provided or if tessellation fails.
|
||||||
pub(crate) fn tessellate_multi_contour(
|
pub fn tessellate_multi_contour(
|
||||||
contours: &[Vec<Vec2>],
|
contours: &[Vec<Vec2>],
|
||||||
color: Color,
|
color: Color,
|
||||||
) -> Result<MeshData, Box<dyn std::error::Error>> {
|
) -> Result<MeshData, Box<dyn std::error::Error>> {
|
||||||
@ -207,7 +203,7 @@ pub(crate) fn tessellate_multi_contour(
|
|||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Returns an error if no vertices are provided or if tessellation fails.
|
/// Returns an error if no vertices are provided or if tessellation fails.
|
||||||
pub(crate) fn tessellate_stroke(
|
pub fn tessellate_stroke(
|
||||||
vertices: &[Vec2],
|
vertices: &[Vec2],
|
||||||
color: Color,
|
color: Color,
|
||||||
width: f32,
|
width: f32,
|
||||||
@ -253,7 +249,7 @@ pub(crate) fn tessellate_stroke(
|
|||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Returns an error if tessellation fails.
|
/// Returns an error if tessellation fails.
|
||||||
pub(crate) fn tessellate_circle(
|
pub fn tessellate_circle(
|
||||||
center: Vec2,
|
center: Vec2,
|
||||||
radius: f32,
|
radius: f32,
|
||||||
color: Color,
|
color: Color,
|
||||||
@ -299,7 +295,7 @@ pub(crate) fn tessellate_circle(
|
|||||||
///
|
///
|
||||||
/// Returns an error if tessellation fails.
|
/// Returns an error if tessellation fails.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub(crate) fn tessellate_arc(
|
pub fn tessellate_arc(
|
||||||
center: Vec2,
|
center: Vec2,
|
||||||
radius: f32,
|
radius: f32,
|
||||||
start_angle_degrees: f32,
|
start_angle_degrees: f32,
|
||||||
@ -309,32 +305,40 @@ pub(crate) fn tessellate_arc(
|
|||||||
segments: usize,
|
segments: usize,
|
||||||
direction: crate::circle_geometry::CircleDirection,
|
direction: crate::circle_geometry::CircleDirection,
|
||||||
) -> Result<MeshData, Box<dyn std::error::Error>> {
|
) -> Result<MeshData, Box<dyn std::error::Error>> {
|
||||||
use crate::circle_geometry::arc_points;
|
// Build arc path manually from segments
|
||||||
|
|
||||||
let start_angle = start_angle_degrees.to_radians();
|
|
||||||
let sweep_angle = arc_angle_degrees.to_radians();
|
|
||||||
|
|
||||||
let mut builder = Path::builder();
|
let mut builder = Path::builder();
|
||||||
|
|
||||||
// Start point of the arc (arc_points returns everything *after* this)
|
let start_angle = start_angle_degrees.to_radians();
|
||||||
builder.begin(point(
|
let arc_angle = arc_angle_degrees.to_radians();
|
||||||
|
let step = arc_angle / segments as f32;
|
||||||
|
|
||||||
|
// Calculate first point
|
||||||
|
let first_point = point(
|
||||||
center.x + radius * start_angle.cos(),
|
center.x + radius * start_angle.cos(),
|
||||||
center.y + radius * start_angle.sin(),
|
center.y + radius * start_angle.sin(),
|
||||||
));
|
);
|
||||||
|
builder.begin(first_point);
|
||||||
|
|
||||||
// Remaining points — single source of truth for arc sampling
|
// Add remaining points - direction matters!
|
||||||
for pt in arc_points(
|
for i in 1..=segments {
|
||||||
center,
|
let angle = match direction {
|
||||||
radius,
|
crate::circle_geometry::CircleDirection::Left => {
|
||||||
start_angle,
|
// Counter-clockwise: subtract angle
|
||||||
sweep_angle,
|
start_angle - step * i as f32
|
||||||
segments,
|
}
|
||||||
direction,
|
crate::circle_geometry::CircleDirection::Right => {
|
||||||
) {
|
// Clockwise: add angle
|
||||||
builder.line_to(point(pt.x, pt.y));
|
start_angle + step * i as f32
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let pt = point(
|
||||||
|
center.x + radius * angle.cos(),
|
||||||
|
center.y + radius * angle.sin(),
|
||||||
|
);
|
||||||
|
builder.line_to(pt);
|
||||||
}
|
}
|
||||||
|
|
||||||
builder.end(false); // open arc, not a closed polygon
|
builder.end(false); // Don't close the arc
|
||||||
let path = builder.build();
|
let path = builder.build();
|
||||||
|
|
||||||
// Tessellate stroke
|
// Tessellate stroke
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
use crate::circle_geometry::{CircleDirection, CircleGeometry};
|
use crate::circle_geometry::{CircleDirection, CircleGeometry};
|
||||||
use crate::commands::{CommandQueue, TurtleCommand};
|
use crate::commands::{CommandQueue, TurtleCommand};
|
||||||
use crate::general::{AnimationSpeed, Radians};
|
use crate::general::AnimationSpeed;
|
||||||
use crate::state::{DrawCommand, FillState, TurtleParams};
|
use crate::state::{Turtle, TurtleParams};
|
||||||
use macroquad::prelude::*;
|
use macroquad::prelude::*;
|
||||||
use tween::{CubicInOut, TweenValue, Tweener};
|
use tween::{CubicInOut, TweenValue, Tweener};
|
||||||
|
|
||||||
@ -45,26 +45,22 @@ impl From<TweenVec2> for Vec2 {
|
|||||||
|
|
||||||
/// Controls tweening of turtle commands
|
/// Controls tweening of turtle commands
|
||||||
#[derive(Clone, Debug, Default)]
|
#[derive(Clone, Debug, Default)]
|
||||||
pub(crate) struct TweenController {
|
pub struct TweenController {
|
||||||
queue: CommandQueue,
|
queue: CommandQueue,
|
||||||
/// Cursor into `queue` — tracks which command executes next.
|
|
||||||
/// Lives here, not in `CommandQueue`, so that cloning or appending to the
|
|
||||||
/// queue never silently resets or mid-stream-shifts the execution position.
|
|
||||||
cursor: usize,
|
|
||||||
current_tween: Option<CommandTween>,
|
current_tween: Option<CommandTween>,
|
||||||
speed: AnimationSpeed,
|
speed: AnimationSpeed,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub(crate) struct CommandTween {
|
pub struct CommandTween {
|
||||||
pub(crate) turtle_id: usize,
|
pub turtle_id: usize,
|
||||||
pub(crate) command: TurtleCommand,
|
pub command: TurtleCommand,
|
||||||
pub(crate) start_time: f64,
|
pub start_time: f64,
|
||||||
pub(crate) duration: f64,
|
pub duration: f64,
|
||||||
pub(crate) start_params: TurtleParams,
|
pub start_params: TurtleParams,
|
||||||
pub(crate) target_params: TurtleParams,
|
pub target_params: TurtleParams,
|
||||||
pub(crate) current_position: Vec2,
|
pub current_position: Vec2,
|
||||||
pub(crate) current_heading: f32,
|
pub current_heading: f32,
|
||||||
position_tweener: Tweener<TweenVec2, f64, CubicInOut>,
|
position_tweener: Tweener<TweenVec2, f64, CubicInOut>,
|
||||||
heading_tweener: Tweener<f32, f64, CubicInOut>,
|
heading_tweener: Tweener<f32, f64, CubicInOut>,
|
||||||
pen_width_tweener: Tweener<f32, f64, CubicInOut>,
|
pen_width_tweener: Tweener<f32, f64, CubicInOut>,
|
||||||
@ -75,7 +71,6 @@ impl TweenController {
|
|||||||
pub fn new(queue: CommandQueue, speed: AnimationSpeed) -> Self {
|
pub fn new(queue: CommandQueue, speed: AnimationSpeed) -> Self {
|
||||||
Self {
|
Self {
|
||||||
queue,
|
queue,
|
||||||
cursor: 0,
|
|
||||||
current_tween: None,
|
current_tween: None,
|
||||||
speed,
|
speed,
|
||||||
}
|
}
|
||||||
@ -85,72 +80,51 @@ impl TweenController {
|
|||||||
self.speed = speed;
|
self.speed = speed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Append commands to the queue.
|
/// Append commands to the queue
|
||||||
///
|
|
||||||
/// The cursor is **not** reset — commands already consumed remain consumed,
|
|
||||||
/// and the new commands are picked up naturally as the cursor advances.
|
|
||||||
pub fn append_commands(&mut self, new_queue: CommandQueue) {
|
pub fn append_commands(&mut self, new_queue: CommandQueue) {
|
||||||
self.queue.extend(new_queue);
|
self.queue.extend(new_queue);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drive the animation controller for one frame.
|
/// Update the tween, returns `Vec` of (`command`, `start_state`, `end_state`) for all completed commands this frame
|
||||||
///
|
/// Also takes commands vec to handle side effects like fill operations
|
||||||
/// Returns `(command, start_params, end_params)` for every command that
|
/// Each `command` has its own `start_state` and `end_state` pair
|
||||||
/// completed this frame and whose stroke needs to be tessellated by the
|
|
||||||
/// caller.
|
|
||||||
///
|
|
||||||
/// By accepting `params`, `filling`, and `commands` as separate mutable
|
|
||||||
/// borrows the caller can split `&mut Turtle` into disjoint field borrows,
|
|
||||||
/// eliminating the old static-method borrow-checker workaround.
|
|
||||||
#[allow(clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines)]
|
||||||
pub fn update(
|
pub fn update(state: &mut Turtle) -> Vec<(TurtleCommand, TurtleParams, TurtleParams)> {
|
||||||
&mut self,
|
|
||||||
turtle_id: usize,
|
|
||||||
params: &mut TurtleParams,
|
|
||||||
filling: &mut Option<FillState>,
|
|
||||||
commands: &mut Vec<DrawCommand>,
|
|
||||||
svg_log: &mut crate::state::SvgLog,
|
|
||||||
) -> Vec<(TurtleCommand, TurtleParams, TurtleParams)> {
|
|
||||||
// In instant mode, execute commands up to the draw calls per frame limit
|
// In instant mode, execute commands up to the draw calls per frame limit
|
||||||
if let AnimationSpeed::Instant(max_draw_calls) = self.speed {
|
if let AnimationSpeed::Instant(max_draw_calls) = state.tween_controller.speed {
|
||||||
let mut completed_commands: Vec<(TurtleCommand, TurtleParams, TurtleParams)> =
|
let mut completed_commands: Vec<(TurtleCommand, TurtleParams, TurtleParams)> =
|
||||||
Vec::new();
|
Vec::new();
|
||||||
let mut draw_call_count = 0;
|
let mut draw_call_count = 0;
|
||||||
|
|
||||||
// Advance cursor through the queue for each command consumed
|
// Consume commands from the real queue so the current_index advances
|
||||||
while let Some(command) = self.queue.get(self.cursor).cloned() {
|
while let Some(command) = state.tween_controller.queue.next() {
|
||||||
self.cursor += 1;
|
|
||||||
// Handle SetSpeed command to potentially switch modes
|
// Handle SetSpeed command to potentially switch modes
|
||||||
if let TurtleCommand::SetSpeed(new_speed) = &command {
|
if let TurtleCommand::SetSpeed(new_speed) = &command {
|
||||||
params.speed = *new_speed;
|
state.params.speed = *new_speed;
|
||||||
self.speed = *new_speed;
|
state.tween_controller.speed = *new_speed;
|
||||||
if matches!(self.speed, AnimationSpeed::Animated(_)) {
|
if matches!(state.tween_controller.speed, AnimationSpeed::Animated(_)) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute side-effect-only commands using centralized helper
|
// Execute side-effect-only commands using centralized helper
|
||||||
if crate::execution::execute_command_side_effects(
|
if crate::execution::execute_command_side_effects(&command, state) {
|
||||||
&command, turtle_id, params, filling, commands, svg_log,
|
|
||||||
) {
|
|
||||||
continue; // Command fully handled
|
continue; // Command fully handled
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save start state and compute target state
|
// Save start state and compute target state
|
||||||
let start_params = params.clone();
|
let start_params = state.params.clone();
|
||||||
let target_params = Self::calculate_target_state(&start_params, &command);
|
let target_params = Self::calculate_target_state(&start_params, &command);
|
||||||
|
|
||||||
// Update state to the target (instant execution)
|
// Update state to the target (instant execution)
|
||||||
*params = target_params.clone();
|
state.params = target_params.clone();
|
||||||
|
|
||||||
// Record fill vertices AFTER movement
|
// Record fill vertices AFTER movement
|
||||||
crate::execution::record_fill_vertices_after_movement(
|
crate::execution::record_fill_vertices_after_movement(
|
||||||
&command,
|
&command,
|
||||||
&start_params,
|
&start_params,
|
||||||
turtle_id,
|
state,
|
||||||
params,
|
|
||||||
filling,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Collect drawable commands (return start and target so caller can create draw meshes)
|
// Collect drawable commands (return start and target so caller can create draw meshes)
|
||||||
@ -167,7 +141,7 @@ impl TweenController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Process current tween
|
// Process current tween
|
||||||
if let Some(ref mut tween) = self.current_tween {
|
if let Some(ref mut tween) = state.tween_controller.current_tween {
|
||||||
let elapsed = get_time() - tween.start_time;
|
let elapsed = get_time() - tween.start_time;
|
||||||
|
|
||||||
// Use tweeners to calculate current values
|
// Use tweeners to calculate current values
|
||||||
@ -181,10 +155,10 @@ impl TweenController {
|
|||||||
direction,
|
direction,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
let angle_traveled = angle.as_radians().value() * progress;
|
let angle_traveled = angle.to_radians() * progress;
|
||||||
calculate_circle_position(
|
calculate_circle_position(
|
||||||
tween.start_params.position,
|
tween.start_params.position,
|
||||||
Radians::new(tween.start_params.heading),
|
tween.start_params.heading,
|
||||||
*radius,
|
*radius,
|
||||||
angle_traveled,
|
angle_traveled,
|
||||||
*direction,
|
*direction,
|
||||||
@ -196,7 +170,7 @@ impl TweenController {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
params.position = current_position;
|
state.params.position = current_position;
|
||||||
tween.current_position = current_position;
|
tween.current_position = current_position;
|
||||||
|
|
||||||
// Heading changes proportionally with progress for all commands
|
// Heading changes proportionally with progress for all commands
|
||||||
@ -205,14 +179,14 @@ impl TweenController {
|
|||||||
angle, direction, ..
|
angle, direction, ..
|
||||||
} => match direction {
|
} => match direction {
|
||||||
CircleDirection::Left => {
|
CircleDirection::Left => {
|
||||||
tween.start_params.heading - angle.as_radians().value() * progress
|
tween.start_params.heading - angle.to_radians() * progress
|
||||||
}
|
}
|
||||||
CircleDirection::Right => {
|
CircleDirection::Right => {
|
||||||
tween.start_params.heading + angle.as_radians().value() * progress
|
tween.start_params.heading + angle.to_radians() * progress
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
TurtleCommand::Turn(angle) => {
|
TurtleCommand::Turn(angle) => {
|
||||||
tween.start_params.heading + angle.as_radians().value() * progress
|
tween.start_params.heading + angle.to_radians() * progress
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
// For other commands that change heading, lerp directly
|
// For other commands that change heading, lerp directly
|
||||||
@ -221,18 +195,18 @@ impl TweenController {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
params.heading = current_heading;
|
state.params.heading = current_heading;
|
||||||
tween.current_heading = current_heading;
|
tween.current_heading = current_heading;
|
||||||
params.pen_width = tween.pen_width_tweener.move_to(elapsed);
|
state.params.pen_width = tween.pen_width_tweener.move_to(elapsed);
|
||||||
|
|
||||||
// Discrete properties (switch at 50% progress)
|
// Discrete properties (switch at 50% progress)
|
||||||
let progress = (elapsed / tween.duration).min(1.0);
|
let progress = (elapsed / tween.duration).min(1.0);
|
||||||
if progress >= 0.5 {
|
if progress >= 0.5 {
|
||||||
params.pen_down = tween.target_params.pen_down;
|
state.params.pen_down = tween.target_params.pen_down;
|
||||||
params.color = tween.target_params.color;
|
state.params.color = tween.target_params.color;
|
||||||
params.fill_color = tween.target_params.fill_color;
|
state.params.fill_color = tween.target_params.fill_color;
|
||||||
params.visible = tween.target_params.visible;
|
state.params.visible = tween.target_params.visible;
|
||||||
params.shape = tween.target_params.shape.clone();
|
state.params.shape = tween.target_params.shape.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if tween is finished (use heading_tweener as it's used by all commands)
|
// Check if tween is finished (use heading_tweener as it's used by all commands)
|
||||||
@ -241,24 +215,20 @@ impl TweenController {
|
|||||||
let target_params = tween.target_params.clone();
|
let target_params = tween.target_params.clone();
|
||||||
let command = tween.command.clone();
|
let command = tween.command.clone();
|
||||||
|
|
||||||
// tween borrow ends here (NLL) — safe to reassign self.current_tween below
|
// Drop the mutable borrow of tween before mutably borrowing state
|
||||||
*params = target_params.clone();
|
state.params = target_params.clone();
|
||||||
|
|
||||||
crate::execution::record_fill_vertices_after_movement(
|
crate::execution::record_fill_vertices_after_movement(
|
||||||
&command,
|
&command,
|
||||||
&start_params,
|
&start_params,
|
||||||
turtle_id,
|
state,
|
||||||
params,
|
|
||||||
filling,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
self.current_tween = None;
|
state.tween_controller.current_tween = None;
|
||||||
|
|
||||||
// Execute side-effect-only commands using centralized helper
|
// Execute side-effect-only commands using centralized helper
|
||||||
if crate::execution::execute_command_side_effects(
|
if crate::execution::execute_command_side_effects(&command, state) {
|
||||||
&command, turtle_id, params, filling, commands, svg_log,
|
return Self::update(state); // Continue to next command
|
||||||
) {
|
|
||||||
return self.update(turtle_id, params, filling, commands, svg_log);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return drawable commands using the original start and target params
|
// Return drawable commands using the original start and target params
|
||||||
@ -266,45 +236,43 @@ impl TweenController {
|
|||||||
return vec![(command, start_params.clone(), target_params.clone())];
|
return vec![(command, start_params.clone(), target_params.clone())];
|
||||||
}
|
}
|
||||||
|
|
||||||
return self.update(turtle_id, params, filling, commands, svg_log);
|
return Self::update(state); // Continue to next command
|
||||||
}
|
}
|
||||||
|
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start next tween
|
// Start next tween
|
||||||
if let Some(command) = self.queue.get(self.cursor).cloned() {
|
if let Some(command) = state.tween_controller.queue.next() {
|
||||||
self.cursor += 1;
|
let command_clone = command.clone();
|
||||||
|
|
||||||
// Handle commands that should execute immediately (no animation)
|
// Handle commands that should execute immediately (no animation)
|
||||||
match &command {
|
match &command_clone {
|
||||||
TurtleCommand::SetSpeed(new_speed) => {
|
TurtleCommand::SetSpeed(new_speed) => {
|
||||||
params.speed = *new_speed;
|
state.set_speed(*new_speed);
|
||||||
self.speed = *new_speed;
|
state.tween_controller.speed = *new_speed;
|
||||||
if matches!(self.speed, AnimationSpeed::Instant(_)) {
|
if matches!(state.tween_controller.speed, AnimationSpeed::Instant(_)) {
|
||||||
return self.update(turtle_id, params, filling, commands, svg_log);
|
return Self::update(state);
|
||||||
}
|
}
|
||||||
return self.update(turtle_id, params, filling, commands, svg_log);
|
return Self::update(state);
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
// Use centralized helper for side effects
|
// Use centralized helper for side effects
|
||||||
if crate::execution::execute_command_side_effects(
|
if crate::execution::execute_command_side_effects(&command_clone, state) {
|
||||||
&command, turtle_id, params, filling, commands, svg_log,
|
return Self::update(state);
|
||||||
) {
|
|
||||||
return self.update(turtle_id, params, filling, commands, svg_log);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let speed = self.speed;
|
let speed = state.tween_controller.speed; // Extract speed before borrowing self
|
||||||
let duration = Self::calculate_duration_with_state(&command, params, speed);
|
let duration = Self::calculate_duration_with_state(&command_clone, state, speed);
|
||||||
|
|
||||||
// Calculate target state
|
// Calculate target state
|
||||||
let target_state = Self::calculate_target_state(params, &command);
|
let target_state = Self::calculate_target_state(&state.params, &command_clone);
|
||||||
|
|
||||||
// Create tweeners for smooth animation
|
// Create tweeners for smooth animation
|
||||||
let position_tweener = Tweener::new(
|
let position_tweener = Tweener::new(
|
||||||
TweenVec2::from(params.position),
|
TweenVec2::from(state.params.position),
|
||||||
TweenVec2::from(target_state.position),
|
TweenVec2::from(target_state.position),
|
||||||
duration,
|
duration,
|
||||||
CubicInOut,
|
CubicInOut,
|
||||||
@ -316,21 +284,21 @@ impl TweenController {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let pen_width_tweener = Tweener::new(
|
let pen_width_tweener = Tweener::new(
|
||||||
params.pen_width,
|
state.params.pen_width,
|
||||||
target_state.pen_width,
|
target_state.pen_width,
|
||||||
duration,
|
duration,
|
||||||
CubicInOut,
|
CubicInOut,
|
||||||
);
|
);
|
||||||
|
|
||||||
self.current_tween = Some(CommandTween {
|
state.tween_controller.current_tween = Some(CommandTween {
|
||||||
turtle_id,
|
turtle_id: state.turtle_id,
|
||||||
command,
|
command: command_clone,
|
||||||
start_time: get_time(),
|
start_time: get_time(),
|
||||||
duration,
|
duration,
|
||||||
start_params: params.clone(),
|
start_params: state.params.clone(),
|
||||||
target_params: target_state.clone(),
|
target_params: target_state.clone(),
|
||||||
current_position: params.position,
|
current_position: state.params.position,
|
||||||
current_heading: params.heading,
|
current_heading: state.params.heading,
|
||||||
position_tweener,
|
position_tweener,
|
||||||
heading_tweener,
|
heading_tweener,
|
||||||
pen_width_tweener,
|
pen_width_tweener,
|
||||||
@ -342,7 +310,7 @@ impl TweenController {
|
|||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn is_complete(&self) -> bool {
|
pub fn is_complete(&self) -> bool {
|
||||||
self.current_tween.is_none() && self.cursor >= self.queue.len()
|
self.current_tween.is_none() && self.queue.is_complete()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the current active tween if one is in progress
|
/// Get the current active tween if one is in progress
|
||||||
@ -351,35 +319,131 @@ impl TweenController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn command_creates_drawing(command: &TurtleCommand) -> bool {
|
fn command_creates_drawing(command: &TurtleCommand) -> bool {
|
||||||
command.produces_drawing()
|
matches!(
|
||||||
|
command,
|
||||||
|
TurtleCommand::Move(_) | TurtleCommand::Circle { .. } | TurtleCommand::Goto(_)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn calculate_duration_with_state(
|
fn calculate_duration_with_state(
|
||||||
command: &TurtleCommand,
|
command: &TurtleCommand,
|
||||||
params: &TurtleParams,
|
current: &Turtle,
|
||||||
speed: AnimationSpeed,
|
speed: AnimationSpeed,
|
||||||
) -> f64 {
|
) -> f64 {
|
||||||
command.animation_duration(params, speed)
|
let mut speed = speed.value();
|
||||||
|
|
||||||
|
// For high speeds, make animation even faster by scaling speed exponentially
|
||||||
|
if speed > 100.0 {
|
||||||
|
speed *= speed / 100.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let base_time = match command {
|
||||||
|
TurtleCommand::Move(dist) => dist.abs() / speed,
|
||||||
|
TurtleCommand::Turn(angle) => {
|
||||||
|
// Rotation speed: assume 180 degrees per second at speed 100
|
||||||
|
angle.abs() / (speed * 1.8)
|
||||||
|
}
|
||||||
|
TurtleCommand::Circle { radius, angle, .. } => {
|
||||||
|
let arc_length = radius * angle.to_radians().abs();
|
||||||
|
arc_length / speed
|
||||||
|
}
|
||||||
|
TurtleCommand::Goto(target) => {
|
||||||
|
// Calculate actual distance from current position to target
|
||||||
|
let dx = target.x - current.params.position.x;
|
||||||
|
let dy = target.y - current.params.position.y;
|
||||||
|
let distance = (dx * dx + dy * dy).sqrt();
|
||||||
|
distance / speed
|
||||||
|
}
|
||||||
|
_ => 0.0, // Instant commands
|
||||||
|
};
|
||||||
|
f64::from(base_time.max(0.01)) // Minimum duration
|
||||||
}
|
}
|
||||||
|
|
||||||
fn calculate_target_state(current: &TurtleParams, command: &TurtleCommand) -> TurtleParams {
|
fn calculate_target_state(current: &TurtleParams, command: &TurtleCommand) -> TurtleParams {
|
||||||
let mut target = current.clone();
|
let mut target = current.clone();
|
||||||
command.apply_to_params(&mut target);
|
|
||||||
|
match command {
|
||||||
|
TurtleCommand::Move(dist) => {
|
||||||
|
let dx = dist * current.heading.cos();
|
||||||
|
let dy = dist * current.heading.sin();
|
||||||
|
target.position = vec2(current.position.x + dx, current.position.y + dy);
|
||||||
|
}
|
||||||
|
TurtleCommand::Turn(angle) => {
|
||||||
|
target.heading = normalize_angle(current.heading + angle.to_radians());
|
||||||
|
}
|
||||||
|
TurtleCommand::Circle {
|
||||||
|
radius,
|
||||||
|
angle,
|
||||||
|
direction,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
// Use helper function to calculate final position
|
||||||
|
target.position = calculate_circle_position(
|
||||||
|
current.position,
|
||||||
|
current.heading,
|
||||||
|
*radius,
|
||||||
|
angle.to_radians(),
|
||||||
|
*direction,
|
||||||
|
);
|
||||||
|
target.heading = normalize_angle(match direction {
|
||||||
|
CircleDirection::Left => current.heading - angle.to_radians(),
|
||||||
|
CircleDirection::Right => current.heading + angle.to_radians(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
TurtleCommand::Goto(coord) => {
|
||||||
|
// Flip Y coordinate: turtle graphics uses Y+ = up, but Macroquad uses Y+ = down
|
||||||
|
target.position = vec2(coord.x, -coord.y);
|
||||||
|
}
|
||||||
|
TurtleCommand::SetHeading(heading) => {
|
||||||
|
target.heading = normalize_angle(*heading);
|
||||||
|
}
|
||||||
|
TurtleCommand::SetColor(color) => {
|
||||||
|
target.color = *color;
|
||||||
|
}
|
||||||
|
TurtleCommand::SetPenWidth(width) => {
|
||||||
|
target.pen_width = *width;
|
||||||
|
}
|
||||||
|
TurtleCommand::SetSpeed(speed) => {
|
||||||
|
target.speed = *speed;
|
||||||
|
}
|
||||||
|
TurtleCommand::SetShape(shape) => {
|
||||||
|
target.shape = shape.clone();
|
||||||
|
}
|
||||||
|
TurtleCommand::PenUp => {
|
||||||
|
target.pen_down = false;
|
||||||
|
}
|
||||||
|
TurtleCommand::PenDown => {
|
||||||
|
target.pen_down = true;
|
||||||
|
}
|
||||||
|
TurtleCommand::ShowTurtle => {
|
||||||
|
target.visible = true;
|
||||||
|
}
|
||||||
|
TurtleCommand::HideTurtle => {
|
||||||
|
target.visible = false;
|
||||||
|
}
|
||||||
|
TurtleCommand::SetFillColor(color) => {
|
||||||
|
target.fill_color = *color;
|
||||||
|
}
|
||||||
|
TurtleCommand::BeginFill | TurtleCommand::EndFill | TurtleCommand::WriteText { .. } => {
|
||||||
|
// Fill and text commands don't change turtle state for tweening purposes
|
||||||
|
// They're handled directly in execution
|
||||||
|
}
|
||||||
|
TurtleCommand::Reset => {
|
||||||
|
// Reset returns to default state
|
||||||
|
target = TurtleParams::default();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
target
|
target
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate position on a circular arc.
|
/// Calculate position on a circular arc
|
||||||
///
|
|
||||||
/// `start_heading` is in radians (typed as `Radians` to make the unit
|
|
||||||
/// explicit at every call site). `angle_traveled` is already a raw `f32`
|
|
||||||
/// radians value produced by multiplying `Degrees::as_radians().value()`
|
|
||||||
/// by a tween progress scalar.
|
|
||||||
fn calculate_circle_position(
|
fn calculate_circle_position(
|
||||||
start_pos: Vec2,
|
start_pos: Vec2,
|
||||||
start_heading: Radians,
|
start_heading: f32,
|
||||||
radius: f32,
|
radius: f32,
|
||||||
angle_traveled: f32,
|
angle_traveled: f32, // How much of the total angle we've traveled (in radians)
|
||||||
direction: CircleDirection,
|
direction: CircleDirection,
|
||||||
) -> Vec2 {
|
) -> Vec2 {
|
||||||
let geom = CircleGeometry::new(start_pos, start_heading, radius, direction);
|
let geom = CircleGeometry::new(start_pos, start_heading, radius, direction);
|
||||||
@ -387,7 +451,7 @@ fn calculate_circle_position(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Normalize angle to range [-PI, PI] to prevent floating-point drift
|
/// Normalize angle to range [-PI, PI] to prevent floating-point drift
|
||||||
pub(crate) fn normalize_angle(angle: f32) -> f32 {
|
fn normalize_angle(angle: f32) -> f32 {
|
||||||
let two_pi = std::f32::consts::PI * 2.0;
|
let two_pi = std::f32::consts::PI * 2.0;
|
||||||
let mut normalized = angle % two_pi;
|
let mut normalized = angle % two_pi;
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user