Compare commits

...

23 Commits

Author SHA1 Message Date
59d6bc164e improve examples 2026-05-21 21:50:13 +02:00
ece26bfe04 create a svg log instead of caching the commands where they do not
belong...
2026-05-21 21:28:14 +02:00
156301f272 remove dead code and translate german comments to english 2026-05-21 17:35:59 +02:00
96b02f61be improve circle construction and rendering 2026-05-21 17:29:27 +02:00
998cffdcbf make the drwing of the center of the circle a regular feature. 2026-05-21 15:05:23 +02:00
6b558ca8a0 more consistent use of angle types 2026-05-21 15:02:57 +02:00
3c076fdd03 improve command handling 2026-05-17 07:23:13 +02:00
44046abe12 improve command handling 2026-05-16 16:03:09 +02:00
a7570911d8 reduce exposed internal functions and fields 2026-05-16 15:31:10 +02:00
cd589b2513
Merge pull request #4 from enaut/copilot/change-license-to-mit-or-apache2
Add dual MIT/Apache-2.0 license files
2026-01-16 19:43:28 +01:00
copilot-swe-agent[bot]
4ce176f44d Add LICENSE-MIT and LICENSE-APACHE files for dual licensing
Co-authored-by: enaut <290005+enaut@users.noreply.github.com>
2026-01-16 18:34:55 +00:00
copilot-swe-agent[bot]
ef6e8e4c76 Initial plan 2026-01-16 18:31:28 +00:00
8a4ed0bf32 avoid cfg svg in public macro 2026-01-02 14:47:21 +01:00
402a8be205 additional feature flag to suppress a warning 2026-01-02 13:50:13 +01:00
3820f20048
Merge pull request #2 from enaut/copilot/add-cmdline-parameters-svg-export
Add --export-svg CLI parameter to turtle_main macro
2026-01-02 12:09:03 +01:00
6f29d97bb6 unify comandline parsing into function 2026-01-02 11:53:18 +01:00
cadc5a6798 fix cartesian axes example 2026-01-02 11:45:20 +01:00
copilot-swe-agent[bot]
9568bc10d9 Add comment explaining intentional code duplication in proc macro
Co-authored-by: enaut <290005+enaut@users.noreply.github.com>
2026-01-01 20:44:27 +00:00
copilot-swe-agent[bot]
cbe249b9b7 Update README with CLI SVG export documentation and examples
Co-authored-by: enaut <290005+enaut@users.noreply.github.com>
2026-01-01 20:42:53 +00:00
copilot-swe-agent[bot]
f5140361d5 Add documentation comment explaining magic number in instant speed
Co-authored-by: enaut <290005+enaut@users.noreply.github.com>
2026-01-01 20:41:52 +00:00
copilot-swe-agent[bot]
c806570156 Implement CLI --export-svg parameter for instant SVG export
Co-authored-by: enaut <290005+enaut@users.noreply.github.com>
2026-01-01 20:40:06 +00:00
copilot-swe-agent[bot]
d85a9c7d26 Add --export-svg CLI parameter support to turtle_main macro
Co-authored-by: enaut <290005+enaut@users.noreply.github.com>
2026-01-01 20:31:58 +00:00
copilot-swe-agent[bot]
2b64be29a8 Initial plan 2026-01-01 20:27:41 +00:00
24 changed files with 1626 additions and 1145 deletions

201
LICENSE-APACHE Normal file
View File

@ -0,0 +1,201 @@
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 Normal file
View File

@ -0,0 +1,21 @@
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.

View File

@ -195,7 +195,32 @@ Add the `svg` feature to enable SVG export functionality:
cargo run --example export_svg --features svg cargo run --example export_svg --features svg
``` ```
### Usage ### Command-Line SVG Export
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::*;
@ -239,6 +264,10 @@ 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

View File

@ -16,6 +16,14 @@ 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
/// ///
@ -45,6 +53,13 @@ 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
@ -53,6 +68,10 @@ 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
@ -63,6 +82,8 @@ 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();
@ -97,15 +118,29 @@ 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
#fn_name(&mut turtle); build_commands(&mut turtle);
let mut app = turtle_lib::TurtleApp::new() let mut app = turtle_lib::TurtleApp::new()
.with_commands(turtle.build()); .with_commands(turtle.build());
@ -139,10 +174,18 @@ 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() {
let mut turtle = turtle_lib::create_turtle_plan(); // Build function reused for both export and normal rendering
let mut build_commands = |turtle: &mut turtle_lib::TurtlePlan| {
// Inline the user's code let turtle = turtle;
#fn_block #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();
build_commands(&mut turtle);
let mut app = turtle_lib::TurtleApp::new() let mut app = turtle_lib::TurtleApp::new()
.with_commands(turtle.build()); .with_commands(turtle.build());

View File

@ -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(120.0, 100.0), "A(2|1)"), (vec2(100.0, 50.0), "A(2|1)"),
(vec2(-120.0, 100.0), "B(-2|1)"), (vec2(-100.0, 50.0), "B(-2|1)"),
(vec2(-120.0, -100.0), "C(-2|-1)"), (vec2(-100.0, -50.0), "C(-2|-1)"),
(vec2(120.0, -100.0), "D(2|-1)"), (vec2(100.0, -50.0), "D(2|-1)"),
]; ];
for (position, label) in points { for (position, label) in points {

View File

@ -1,5 +1,6 @@
//! 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")]

View File

@ -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(1001); turtle.set_speed(5000);
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(4, turtle, 300.0); koch(6, turtle, 300.0);
turtle.right(120.0); turtle.right(120.0);
turtle.set_speed(1200); turtle.set_speed(1200);
} }

View File

@ -0,0 +1,36 @@
//! 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();
}

View File

@ -23,5 +23,6 @@ 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();
} }

View File

@ -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, FontSize, Precision}; use crate::general::{AnimationSpeed, Color, Coordinate, Degrees, 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<Precision>, T: Into<Degrees>,
{ {
let degrees: Precision = angle.into(); self.get_commands_mut()
self.get_commands_mut().push(TurtleCommand::Turn(-degrees)); .push(TurtleCommand::Turn(-angle.into()));
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<Precision>, T: Into<Degrees>,
{ {
let degrees: Precision = angle.into(); self.get_commands_mut()
self.get_commands_mut().push(TurtleCommand::Turn(degrees)); .push(TurtleCommand::Turn(angle.into()));
self self
} }
} }
@ -160,13 +160,12 @@ 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<Precision>, A: Into<Degrees>,
{ {
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: a, angle: angle.into(),
steps, steps,
direction: crate::circle_geometry::CircleDirection::Left, direction: crate::circle_geometry::CircleDirection::Left,
}); });
@ -207,13 +206,12 @@ 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<Precision>, A: Into<Degrees>,
{ {
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: a, angle: angle.into(),
steps, steps,
direction: crate::circle_geometry::CircleDirection::Right, direction: crate::circle_geometry::CircleDirection::Right,
}); });
@ -367,9 +365,9 @@ impl TurtlePlan {
/// .forward(100.0); /// .forward(100.0);
/// } /// }
/// ``` /// ```
pub fn set_heading(&mut self, heading: Precision) -> &mut Self { pub fn set_heading<T: Into<Degrees>>(&mut self, heading: T) -> &mut Self {
self.queue self.queue
.push(TurtleCommand::SetHeading(-heading.to_radians())); .push(TurtleCommand::SetHeading(-heading.into().as_radians()));
self self
} }

View File

@ -1,7 +1,43 @@
//! 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 {
@ -10,11 +46,11 @@ pub enum CircleDirection {
} }
/// Encapsulates all geometry for a circular arc /// Encapsulates all geometry for a circular arc
pub struct CircleGeometry { pub(crate) struct CircleGeometry {
pub center: Vec2, pub(crate) center: Vec2,
pub radius: f32, pub(crate) radius: f32,
pub start_angle_from_center: f32, // radians pub(crate) start_angle_from_center: f32, // radians
pub direction: CircleDirection, pub(crate) direction: CircleDirection,
} }
impl CircleGeometry { impl CircleGeometry {
@ -22,12 +58,15 @@ impl CircleGeometry {
#[must_use] #[must_use]
pub fn new( pub fn new(
turtle_pos: Vec2, turtle_pos: Vec2,
turtle_heading: f32, turtle_heading: Radians,
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
@ -35,8 +74,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 => turtle_heading - FRAC_PI_2, CircleDirection::Left => heading - FRAC_PI_2,
CircleDirection::Right => turtle_heading + FRAC_PI_2, CircleDirection::Right => heading + FRAC_PI_2,
}; };
let center = vec2( let center = vec2(
@ -46,8 +85,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 => turtle_heading + FRAC_PI_2, CircleDirection::Left => heading + FRAC_PI_2,
CircleDirection::Right => turtle_heading - FRAC_PI_2, CircleDirection::Right => heading - FRAC_PI_2,
}; };
Self { Self {
@ -151,7 +190,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),
0.0, // heading east (0 radians) Radians::new(0.0), // heading east (0 radians)
100.0, 100.0,
CircleDirection::Left, CircleDirection::Left,
); );
@ -183,7 +222,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),
0.0, // heading east Radians::new(0.0), // heading east
100.0, 100.0,
CircleDirection::Right, CircleDirection::Right,
); );

View File

@ -0,0 +1,150 @@
//! 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(_)
)
}
}

View File

@ -1,6 +1,6 @@
//! Turtle commands and command queue //! Turtle commands and command queue
use crate::general::{AnimationSpeed, Color, Coordinate, FontSize, Precision}; use crate::general::{AnimationSpeed, Color, Coordinate, Degrees, FontSize, Precision, Radians};
use crate::shapes::TurtleShape; use crate::shapes::TurtleShape;
/// Individual turtle commands /// Individual turtle commands
@ -9,13 +9,14 @@ 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 in degrees) // Rotation (positive = right/clockwise, negative = left/counter-clockwise)
Turn(Precision), // Stored in degrees — the natural unit at the user-facing API boundary.
Turn(Degrees),
// Circle drawing // Circle drawing
Circle { Circle {
radius: Precision, radius: Precision,
angle: Precision, // degrees angle: Degrees, // sweep angle — degrees, as supplied by the user
steps: usize, steps: usize,
direction: crate::circle_geometry::CircleDirection, direction: crate::circle_geometry::CircleDirection,
}, },
@ -33,7 +34,8 @@ pub enum TurtleCommand {
// Position // Position
Goto(Coordinate), Goto(Coordinate),
SetHeading(Precision), // radians /// Heading stored as radians — already converted by the builder.
SetHeading(Radians),
// Visibility // Visibility
ShowTurtle, ShowTurtle,
@ -53,11 +55,14 @@ pub enum TurtleCommand {
Reset, Reset,
} }
/// Queue of turtle commands with execution state /// A pure-data sequence of turtle commands.
///
/// `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 {
@ -65,14 +70,12 @@ 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,
} }
} }
@ -83,26 +86,22 @@ 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 is_complete(&self) -> bool { pub fn get(&self, index: usize) -> Option<&TurtleCommand> {
self.current_index >= self.commands.len() self.commands.get(index)
}
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 {
@ -111,16 +110,16 @@ impl Default for CommandQueue {
} }
} }
impl Iterator for CommandQueue { /// Consuming iteration — yields every command in order.
///
/// 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 next(&mut self) -> Option<Self::Item> { fn into_iter(self) -> Self::IntoIter {
if self.current_index < self.commands.len() { self.commands.into_iter()
let cmd = self.commands[self.current_index].clone();
self.current_index += 1;
Some(cmd)
} else {
None
}
} }
} }

View File

@ -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 struct TurtleCommandReceiver { pub(crate) 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 fn turtle_command_channel( pub(crate) fn turtle_command_channel(
turtle_id: usize, turtle_id: usize,
buffer_size: usize, buffer_size: usize,
) -> (TurtleCommandSender, TurtleCommandReceiver) { ) -> (TurtleCommandSender, TurtleCommandReceiver) {

View File

@ -12,56 +12,9 @@ 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 entire turtle world /// Render the turtle world with active tween visualization.
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 fn render_world_with_tweens(world: &TurtleWorld, zoom_level: f32) { pub(crate) 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 {
@ -80,7 +33,7 @@ pub 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, source: _ } => { DrawCommand::Mesh { data } => {
draw_mesh(&data.to_mesh()); draw_mesh(&data.to_mesh());
} }
DrawCommand::Text { DrawCommand::Text {
@ -89,7 +42,6 @@ pub 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);
} }
@ -176,40 +128,32 @@ pub 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::CircleGeometry; use crate::circle_geometry::{arc_points, CircleGeometry};
use crate::general::Radians;
let geom = CircleGeometry::new( let geom = CircleGeometry::new(
tween.start_params.position, tween.start_params.position,
tween.start_params.heading, Radians::new(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);
// Generate arc vertices for the partial arc // Delegate to the shared arc_points function — same sampling
let num_samples = *steps.max(&1); // strategy as tessellate_arc, eliminating the divergence.
let samples_to_draw = let samples_to_draw =
((num_samples as f32 * eased_progress) as usize).max(1); (((*steps).max(1) as f32 * eased_progress) as usize).max(1);
let sweep_so_far = angle.as_radians().value() * eased_progress;
for i in 1..=samples_to_draw { for pt in arc_points(
let sample_progress = i as f32 / num_samples as f32; geom.center,
let current_angle = match direction { *radius,
crate::circle_geometry::CircleDirection::Left => { geom.start_angle_from_center,
geom.start_angle_from_center sweep_so_far,
- angle.to_radians() * sample_progress samples_to_draw,
} *direction,
crate::circle_geometry::CircleDirection::Right => { ) {
geom.start_angle_from_center current_preview.push(pt);
+ 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,
@ -347,18 +291,19 @@ 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: f32, total_angle: crate::general::Degrees,
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,
tween.start_params.heading, Radians::new(tween.start_params.heading),
radius, radius,
direction, direction,
); );
// Debug: draw center using Lyon tessellation // Draw center using Lyon tessellation this helps visualizing what is done.
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());
@ -375,7 +320,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 * progress, total_angle.value() * 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),
@ -386,7 +331,7 @@ fn draw_tween_arc(
} }
/// Draw the turtle shape /// Draw the turtle shape
pub fn draw_turtle(turtle_params: &TurtleParams) { pub(crate) 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 {

View File

@ -2,51 +2,120 @@
use crate::circle_geometry::{CircleDirection, CircleGeometry}; use crate::circle_geometry::{CircleDirection, CircleGeometry};
use crate::commands::TurtleCommand; use crate::commands::TurtleCommand;
use crate::state::{DrawCommand, Turtle, TurtleParams, TurtleWorld}; use crate::general::{Coordinate, Radians};
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;
/// Execute side effects for commands that don't involve movement /// Close the current open fill contour (factored out of `Turtle::close_fill_contour`).
/// Returns true if the command was handled (caller should skip movement processing) fn close_fill_contour(turtle_id: usize, filling: &mut Option<FillState>) {
#[allow(clippy::too_many_lines)] if let Some(ref mut fill_state) = filling {
pub fn execute_command_side_effects(command: &TurtleCommand, state: &mut Turtle) -> bool { tracing::debug!(
match command { turtle_id,
TurtleCommand::BeginFill => { vertices = fill_state.current_contour.len(),
if state.filling.is_some() { "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!( tracing::warn!(
turtle_id = state.turtle_id, turtle_id,
"begin_fill() called while already filling" 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"
); );
} }
let fill_color = state.params.fill_color.unwrap_or_else(|| { }
tracing::warn!(
turtle_id = state.turtle_id, /// Begin a new fill contour at `position` (factored out of `Turtle::start_fill_contour`).
"No fill_color set, using black" 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)]
pub(crate) fn execute_command_side_effects(
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 {
TurtleCommand::BeginFill => {
if filling.is_some() {
tracing::warn!(turtle_id, "begin_fill() called while already filling");
}
let fill_color = params.fill_color.unwrap_or_else(|| {
tracing::warn!(turtle_id, "No fill_color set, using black");
BLACK BLACK
}); });
state.begin_fill(fill_color); *filling = Some(FillState {
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) = state.filling.take() { if let Some(mut fill_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 = state.turtle_id, 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 = state.turtle_id, turtle_id,
contour_idx = i, contour_idx = i,
vertices = contour.len(), vertices = contour.len(),
"Contour info" "Contour info"
@ -59,85 +128,69 @@ pub fn execute_command_side_effects(command: &TurtleCommand, state: &mut Turtle)
fill_state.fill_color, fill_state.fill_color,
) { ) {
tracing::debug!( tracing::debug!(
turtle_id = state.turtle_id, 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"
); );
state.commands.push(DrawCommand::Mesh { commands.push(DrawCommand::Mesh { data: mesh_data });
data: mesh_data, #[cfg(feature = "svg")]
source: crate::state::TurtleSource { svg_log.push(crate::state::SvgRecord::Fill {
command: crate::commands::TurtleCommand::EndFill, contours: fill_state.contours,
color: state.params.color,
fill_color: fill_state.fill_color, fill_color: fill_state.fill_color,
pen_width: state.params.pen_width, stroke_color: params.color,
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!( tracing::error!(turtle_id, "Failed to tessellate contours");
turtle_id = state.turtle_id,
"Failed to tessellate contours"
);
} }
} }
} else { } else {
tracing::warn!( tracing::warn!(turtle_id, "end_fill() called without begin_fill()");
turtle_id = state.turtle_id,
"end_fill() called without begin_fill()"
);
} }
true true
} }
TurtleCommand::PenUp => { TurtleCommand::PenUp => {
state.params.pen_down = false; params.pen_down = false;
if state.filling.is_some() { if filling.is_some() {
tracing::debug!( tracing::debug!(turtle_id, "PenUp: Closing current contour");
turtle_id = state.turtle_id,
"PenUp: Closing current contour"
);
} }
state.close_fill_contour(); close_fill_contour(turtle_id, filling);
true true
} }
TurtleCommand::PenDown => { TurtleCommand::PenDown => {
state.params.pen_down = true; params.pen_down = true;
if state.filling.is_some() { if filling.is_some() {
tracing::debug!( tracing::debug!(
turtle_id = state.turtle_id, turtle_id,
x = state.params.position.x, x = params.position.x,
y = state.params.position.y, y = params.position.y,
"PenDown: Starting new contour" "PenDown: Starting new contour"
); );
} }
state.start_fill_contour(); start_fill_contour(turtle_id, params.position, filling);
true true
} }
TurtleCommand::Reset => { TurtleCommand::Reset => {
state.reset(); commands.clear();
svg_log.clear();
*filling = None;
*params = TurtleParams::default();
true true
} }
TurtleCommand::WriteText { text, font_size } => { TurtleCommand::WriteText { text, font_size } => {
state.commands.push(DrawCommand::Text { commands.push(DrawCommand::Text {
text: text.clone(), text: text.clone(),
position: state.params.position, position: params.position,
heading: state.params.heading, heading: params.heading,
font_size: *font_size, font_size: *font_size,
color: state.params.color, color: params.color,
source: crate::state::TurtleSource { });
command: command.clone(), #[cfg(feature = "svg")]
color: state.params.color, svg_log.push(crate::state::SvgRecord::Text {
fill_color: state.params.fill_color.unwrap_or(BLACK), text: text.clone(),
pen_width: state.params.pen_width, position: params.position,
start_position: state.params.position, color: params.color,
end_position: state.params.position,
start_heading: state.params.heading,
contours: None,
},
}); });
true true
} }
@ -157,14 +210,23 @@ pub fn execute_command_side_effects(command: &TurtleCommand, state: &mut Turtle)
} }
} }
/// Record fill vertices after movement commands have updated state /// Record fill vertices after movement commands have updated state.
#[tracing::instrument] ///
pub fn record_fill_vertices_after_movement( /// `start_state` is the params snapshot taken **before** the command ran.
/// `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,
state: &mut Turtle, turtle_id: usize,
params: &TurtleParams,
filling: &mut Option<FillState>,
) { ) {
if state.filling.is_none() { if filling.is_none() {
return; return;
} }
@ -177,268 +239,242 @@ pub fn record_fill_vertices_after_movement(
} => { } => {
let geom = CircleGeometry::new( let geom = CircleGeometry::new(
start_state.position, start_state.position,
start_state.heading, Radians::new(start_state.heading),
*radius, *radius,
*direction, *direction,
); );
state.record_fill_vertices_for_arc( if let Some(ref mut fill_state) = filling {
geom.center, if params.pen_down {
*radius, let num_samples = (*steps as u32).max(1);
geom.start_angle_from_center, tracing::trace!(
angle.to_radians(), turtle_id,
*direction, center_x = geom.center.x,
*steps as u32, center_y = geom.center.y,
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(_) => {
state.record_fill_vertex(); if let Some(ref mut fill_state) = filling {
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)");
}
}
} }
_ => {} _ => {}
} }
} }
/// Execute a single turtle command, updating state and adding draw commands /// Tessellate a completed movement command into a [`DrawCommand`] mesh.
#[tracing::instrument] ///
#[allow(clippy::too_many_lines)] /// Returns `None` if the pen was up or the command does not produce a drawing.
pub fn execute_command(command: &TurtleCommand, state: &mut Turtle) { ///
// Try to execute as side-effect-only command first /// `end_position` is the turtle's position after the command completed:
if execute_command_side_effects(command, state) { /// - instant-mode: `state.params.position` after [`TurtleCommand::apply_to_params`]
return; // Command fully handled /// - animated-mode: `tween.target_params.position` when the tween finishes
} ///
/// This is the **single** tessellation site for all committed line/arc meshes.
// Store start state for fill vertex recording /// It replaces both the inline tessellation inside `execute_command` and the
let start_state = state.clone(); /// now-deleted `add_draw_for_completed_tween`.
pub(crate) fn tessellate_command(
// 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 {
radius,
angle,
steps,
direction,
} => {
let start_heading = state.params.heading;
let geom =
CircleGeometry::new(state.params.position, start_heading, *radius, *direction);
if state.params.pen_down {
// Use Lyon to tessellate the arc
if let Ok(mesh_data) = tessellation::tessellate_arc(
geom.center,
*radius,
geom.start_angle_from_center.to_degrees(),
*angle,
state.params.color,
state.params.pen_width,
*steps,
*direction,
) {
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: state.params.position,
end_position: geom.position_at_angle(angle.to_radians()),
start_heading,
contours: None,
},
});
}
}
// Update turtle position and heading
state.params.position = geom.position_at_angle(angle.to_radians());
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;
}
}
}
/// Add drawing command for a completed tween
pub fn add_draw_for_completed_tween(
command: &TurtleCommand, command: &TurtleCommand,
start_state: &TurtleParams, start: &TurtleParams,
end_state: &mut TurtleParams, end_position: Vec2,
) -> Option<DrawCommand> { ) -> Option<DrawCommand> {
if !start.pen_down || !command.produces_drawing() {
return None;
}
match command { match command {
TurtleCommand::Move(_) | TurtleCommand::Goto(_) => { TurtleCommand::Move(_) | TurtleCommand::Goto(_) => {
if start_state.pen_down { let mesh_data = tessellation::tessellate_stroke(
if let Ok(mesh_data) = tessellation::tessellate_stroke( &[start.position, end_position],
&[start_state.position, end_state.position], start.color,
start_state.color, start.pen_width,
start_state.pen_width,
false, false,
) { )
return Some(DrawCommand::Mesh { .ok()?;
data: mesh_data,
source: crate::state::TurtleSource { Some(DrawCommand::Mesh { data: mesh_data })
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, steps,
direction, direction,
} => { } => {
if start_state.pen_down { use crate::circle_geometry::CircleGeometry;
let geom = CircleGeometry::new( let geom = CircleGeometry::new(
start_state.position, start.position,
start_state.heading, Radians::new(start.heading),
*radius, *radius,
*direction, *direction,
); );
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, angle.value(),
start_state.color, start.color,
start_state.pen_width, start.pen_width,
*steps, *steps,
*direction, *direction,
) { )
return Some(DrawCommand::Mesh { .ok()?;
data: mesh_data,
source: crate::state::TurtleSource { Some(DrawCommand::Mesh { data: mesh_data })
command: command.clone(), }
color: start_state.color,
fill_color: start_state.fill_color.unwrap_or(BLACK), // `produces_drawing()` guards entry — this arm is only reachable if
pen_width: start_state.pen_width, // `produces_drawing` and the match above diverge, which would be a bug.
start_position: start_state.position, _ => None,
end_position: end_state.position, }
start_heading: start_state.heading, }
contours: None,
}, /// Push an [`SvgRecord`] for a completed line or arc drawing command.
///
/// 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,
start: &TurtleParams,
end_position: Vec2,
svg_log: &mut crate::state::SvgLog,
) {
use crate::state::SvgRecord;
match command {
TurtleCommand::Move(_) | TurtleCommand::Goto(_) => {
svg_log.push(SvgRecord::Line {
start: start.position,
end: end_position,
color: start.color,
pen_width: start.pen_width,
}); });
} }
TurtleCommand::Circle {
radius,
angle,
direction,
..
} => {
svg_log.push(SvgRecord::Arc {
start_position: start.position,
start_heading: start.heading,
radius: *radius,
angle: *angle,
direction: *direction,
color: start.color,
pen_width: start.pen_width,
});
} }
_ => {}
} }
_ => (), }
/// 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::TweenController; use crate::tweening::TweenController;
#[test] #[test]
fn test_forward_left_forward() { fn test_forward_left_forward() {
@ -461,6 +497,7 @@ 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(),
}; };
@ -500,7 +537,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(-90.0), &mut state); execute_command(&TurtleCommand::Turn(Degrees::new(-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 = {}",

View File

@ -1,22 +1,23 @@
//! Export-Backend-Trait und zentrale Export-Typen //! Export backend trait and core export types.
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),
// Weitere Formate können ergänzt werden // Additional formats can be added here.
} }
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
pub enum DrawingFormat { pub enum DrawingFormat {
#[cfg(feature = "svg")] #[cfg(feature = "svg")]
Svg, Svg,
// Weitere Formate wie Png, Pdf, ... // Additional formats: Png, Pdf, …
} }
pub trait DrawingExporter { pub(crate) trait DrawingExporter {
/// Export the drawing to the specified format and filename /// Export the drawing to the specified format and filename
/// ///
/// # Errors /// # Errors
@ -24,3 +25,62 @@ pub 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);
}
}
}

View File

@ -1,13 +1,12 @@
//! SVG-Export-Backend für TurtleWorld //! SVG export backend for `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::{DrawCommand, TurtleWorld}; use crate::state::{SvgRecord, TurtleWorld};
use std::fs::File; use std::fs::File;
use svg::{ use svg::{
node::element::{Circle, Line, Polygon, Text as SvgText}, node::element::{Circle, Line, Text as SvgText},
Document, Document,
}; };
@ -37,47 +36,50 @@ pub mod svg_export {
} }
for turtle in &world.turtles { for turtle in &world.turtles {
for cmd in &turtle.commands { for record in &turtle.svg_log.records {
match cmd { match record {
DrawCommand::Mesh { source, .. } => { SvgRecord::Line {
match &source.command { start,
TurtleCommand::Move(_) | TurtleCommand::Goto(_) => { end,
// Linie als <line> color,
let start = source.start_position; pen_width,
let end = source.end_position; } => {
update_bounds( update_bounds(
&mut min_x, &mut max_x, &mut min_y, &mut max_y, start.x, &mut min_x, &mut max_x, &mut min_y, &mut max_y, start.x, start.y,
start.y,
); );
update_bounds( update_bounds(
&mut min_x, &mut max_x, &mut min_y, &mut max_y, end.x, &mut min_x, &mut max_x, &mut min_y, &mut max_y, end.x, end.y,
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(source.color)) .set("stroke", color_to_svg(*color))
.set("stroke-width", source.pen_width); .set("stroke-width", *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(
source.start_position, *start_position,
source.start_heading, Radians::new(*start_heading),
*radius, *radius,
*direction, *direction,
); );
let center = geom.center; let center = geom.center;
if (*angle - 360.0).abs() < 1e-3 { // Include the bounding box of the full circle so partial arcs
// Voller Kreis // are never clipped.
update_bounds( update_bounds(
&mut min_x, &mut min_x,
&mut max_x, &mut max_x,
@ -94,67 +96,55 @@ 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(source.color)) .set("stroke", color_to_svg(*color))
.set("stroke-width", source.pen_width) .set("stroke-width", *pen_width)
.set("fill", "none"); .set("fill", "none");
doc = doc.add(circle); doc = doc.add(circle);
} else { } else {
// Kreisbogen als <path> // Partial arc — emit as <path A …>
let start = source.start_position; let end = geom.position_at_angle(angle.as_radians().value());
let end = source.end_position; let large_arc = if angle.value() > 180.0 { 1 } else { 0 };
// 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.x, start_position.x,
start.y, start_position.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(source.color)) .set("stroke", color_to_svg(*color))
.set("stroke-width", source.pen_width) .set("stroke-width", *pen_width)
.set("fill", "none"); .set("fill", "none");
doc = doc.add(path); doc = doc.add(path);
} }
} }
TurtleCommand::EndFill => {
// Fills werden als <path> mit Konturen ausgegeben SvgRecord::Fill {
if let Some(contours) = &source.contours { 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, &mut min_x, &mut max_x, &mut min_y, &mut max_y, point.x,
point.x, point.y, point.y,
); );
} }
} }
@ -164,15 +154,9 @@ pub mod svg_export {
if i > 0 { if i > 0 {
d.push(' '); d.push(' ');
} }
d.push_str(&format!( d.push_str(&format!("M {} {}", contour[0].x, contour[0].y));
"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!( d.push_str(&format!(" L {} {}", point.x, point.y));
" L {} {}",
point.x, point.y
));
} }
d.push_str(" Z"); d.push_str(" Z");
} }
@ -180,63 +164,17 @@ 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(source.fill_color)) .set("fill", color_to_svg(*fill_color))
.set("fill-rule", "evenodd") .set("fill-rule", "evenodd")
.set("stroke", color_to_svg(source.color)); .set("stroke", color_to_svg(*stroke_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,
source, color,
..
} => { } => {
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,
@ -245,7 +183,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(source.color)) .set("fill", color_to_svg(*color))
.add(svg::node::Text::new(text.clone())); .add(svg::node::Text::new(text.clone()));
doc = doc.add(txt); doc = doc.add(txt);
} }
@ -260,7 +198,6 @@ 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");
} }

View File

@ -6,7 +6,7 @@ pub mod angle;
pub mod fontsize; pub mod fontsize;
pub mod length; pub mod length;
pub use angle::Angle; pub use angle::{Degrees, Radians};
pub use fontsize::FontSize; pub use fontsize::FontSize;
pub use length::Length; pub use length::Length;

View File

@ -1,205 +1,162 @@
//! Angle type with degrees and radians support //! Angle unit newtypes: `Degrees` and `Radians`.
//!
//! ## 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::{Add, Div, Mul, Neg, Rem, Sub}; use std::ops::Neg;
#[derive(Copy, Clone, Debug, PartialEq)] /// An angle measured in degrees.
pub enum AngleUnit { ///
Degrees(Precision), /// Used at the public API boundary. Convert to [`Radians`] with `as_radians()`
Radians(Precision), /// before passing into internal rendering functions.
} #[derive(Copy, Clone, Debug, PartialEq, PartialOrd, Default)]
pub struct Degrees(pub Precision);
impl Default for AngleUnit { impl Degrees {
fn default() -> Self { /// Construct from a raw degrees value.
Self::Degrees(0.0) #[must_use]
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
} }
} }
#[derive(Copy, Clone, Debug, PartialEq)] impl Neg for Degrees {
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 {
fn rem(self, rhs: Precision) -> Self::Output { Self(-self.0)
match self.value {
AngleUnit::Degrees(v) => Self::degrees(v % rhs),
AngleUnit::Radians(v) => Self::radians(v % rhs),
}
} }
} }
impl Mul<Precision> for Angle { impl From<f32> for Degrees {
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 {
fn mul(self, rhs: Precision) -> Self::Output { Self(-self.0)
match self.value {
AngleUnit::Degrees(v) => Self::degrees(v * rhs),
AngleUnit::Radians(v) => Self::radians(v * rhs),
}
} }
} }
impl Div<Precision> for Angle { impl From<f32> for Radians {
type Output = Self; fn from(v: f32) -> 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 convert_to_radians() { fn degrees_to_radians_roundtrip() {
let radi = Angle::radians(30f32.to_radians()); let deg = Degrees::new(180.0);
let degr = Angle::degrees(30f32); let rad = deg.as_radians();
let converted = degr.to_radians(); assert!(
assert!((radi.value() - converted.value()).abs() < 0.0001); (rad.value() - PI).abs() < 1e-6,
"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 sum_degrees() { fn negation() {
let fst = Angle::degrees(30f32); assert_eq!(-Degrees::new(90.0), Degrees::new(-90.0));
let snd = Angle::degrees(30f32); assert_eq!(-Radians::new(1.0), Radians::new(-1.0));
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 sum_mixed() { fn from_integer() {
let fst = Angle::degrees(30f32); let d: Degrees = 90_i32.into();
let snd = Angle::radians(30f32.to_radians()); assert_eq!(d, Degrees::new(90.0));
let sum = fst + snd; let d2: Degrees = 45_i16.into();
assert!((sum.to_degrees().value() - 60f32).abs() < 0.0001); assert_eq!(d2, Degrees::new(45.0));
assert!((sum.to_radians().value() - 60f32.to_radians()).abs() < 0.0001);
} }
} }

View File

@ -46,30 +46,29 @@
//! } //! }
//! ``` //! ```
pub mod builders; pub(crate) mod builders;
pub mod circle_geometry; pub(crate) mod circle_geometry;
pub mod commands; pub(crate) mod command_behavior;
pub mod commands_channel; pub(crate) mod commands;
pub mod drawing; pub(crate) mod commands_channel;
pub mod execution; pub(crate) mod drawing;
pub mod general; pub(crate) mod execution;
pub mod shapes; pub(crate) mod general;
pub mod state; pub(crate) mod shapes;
pub mod tessellation; pub(crate) mod state;
pub mod tweening; pub(crate) mod tessellation;
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::{turtle_command_channel, TurtleCommandReceiver, TurtleCommandSender}; pub use commands_channel::TurtleCommandSender;
pub use general::{Angle, AnimationSpeed, Color, Coordinate, Length, Precision}; pub use general::{AnimationSpeed, Color, Coordinate, Degrees, Length, Precision, Radians};
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 mod export_svg; pub(crate) 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;
@ -79,6 +78,8 @@ 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;
@ -95,9 +96,7 @@ pub struct TurtleApp {
} }
impl TurtleApp { impl TurtleApp {
/// Exportiere das aktuelle Drawing in das gewünschte Format /// Export the current drawing to a file in the specified format.
#[allow(unused_variables)]
/// Export the current drawing to a file in the specified format
/// ///
/// # Errors /// # Errors
/// ///
@ -115,10 +114,10 @@ impl TurtleApp {
let exporter = SvgExporter; let exporter = SvgExporter;
exporter.export(&self.world, filename) exporter.export(&self.world, filename)
} }
// Weitere Formate können hier ergänzt werden // Additional formats can be registered here.
#[allow(unreachable_patterns)] #[allow(unreachable_patterns)]
_ => Err(export::ExportError::Format( _ => Err(export::ExportError::Format(
"Export-Format nicht unterstützt".to_string(), "Unsupported export format".to_string(),
)), )),
} }
} }
@ -290,20 +289,25 @@ 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 {
// Extract draw_commands and controller temporarily to avoid borrow conflicts // Drive this turtle's animation controller for one frame.
// `update_tweens` splits &mut Turtle into disjoint field borrows so
// Update the controller // TweenController::update can be a proper &mut self method.
let completed_commands = TweenController::update(turtle); let completed_commands = turtle.update_tweens();
// 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, mut end_state) in completed_commands { for (completed_cmd, tween_start, end_state) in completed_commands {
let draw_command = execution::add_draw_for_completed_tween( if let Some(draw_cmd) =
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,
&mut end_state, end_state.position,
&mut turtle.svg_log,
); );
// Add the new draw commands to the turtle turtle.commands.push(draw_cmd);
turtle.commands.extend(draw_command); }
} }
} }
} }
@ -367,14 +371,31 @@ 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 fn world(&self) -> &TurtleWorld { pub(crate) fn world(&self) -> &TurtleWorld {
&self.world &self.world
} }
/// Get mutable reference to the world state /// Get mutable reference to the world state
pub fn world_mut(&mut self) -> &mut TurtleWorld { pub(crate) fn world_mut(&mut self) -> &mut TurtleWorld {
&mut self.world &mut self.world
} }
} }

View File

@ -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::{Angle, AnimationSpeed, Color, Coordinate}; use crate::general::{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 struct FillState { pub(crate) struct FillState {
/// Starting position of the fill /// Starting position of the fill
pub start_position: Coordinate, pub(crate) 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 contours: Vec<Vec<Coordinate>>, pub(crate) 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 current_contour: Vec<Coordinate>, pub(crate) current_contour: Vec<Coordinate>,
/// Fill color (cached from when `begin_fill` was called) /// Fill color (cached from when `begin_fill` was called)
pub fill_color: Color, pub(crate) 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 struct TurtleParams { pub(crate) struct TurtleParams {
pub position: Vec2, pub(crate) position: Vec2,
pub heading: f32, pub(crate) heading: f32,
pub pen_down: bool, pub(crate) pen_down: bool,
pub pen_width: f32, pub(crate) pen_width: f32,
pub color: Color, pub(crate) color: Color,
pub fill_color: Option<Color>, pub(crate) fill_color: Option<Color>,
pub visible: bool, pub(crate) visible: bool,
pub shape: crate::shapes::TurtleShape, pub(crate) shape: crate::shapes::TurtleShape,
pub speed: AnimationSpeed, pub(crate) speed: AnimationSpeed,
} }
impl Default for TurtleParams { impl Default for TurtleParams {
@ -56,18 +56,21 @@ impl Default for TurtleParams {
/// State of a single turtle /// State of a single turtle
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Turtle { pub(crate) struct Turtle {
pub turtle_id: usize, pub(crate) turtle_id: usize,
pub params: TurtleParams, pub(crate) params: TurtleParams,
// Fill tracking // Fill tracking
pub filling: Option<FillState>, pub(crate) filling: Option<FillState>,
// Drawing commands created by this turtle // Drawing commands created by this turtle
pub commands: Vec<DrawCommand>, pub(crate) 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 tween_controller: TweenController, pub(crate) tween_controller: TweenController,
} }
impl Default for Turtle { impl Default for Turtle {
@ -77,6 +80,7 @@ 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()),
} }
} }
@ -88,14 +92,15 @@ impl Turtle {
} }
#[must_use] #[must_use]
pub fn heading_angle(&self) -> Angle { pub fn heading_angle(&self) -> crate::general::Radians {
Angle::radians(self.params.heading) crate::general::Radians::new(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;
@ -106,6 +111,27 @@ 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 {
@ -257,11 +283,75 @@ 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 struct MeshData { pub(crate) struct MeshData {
pub vertices: Vec<macroquad::prelude::Vertex>, pub(crate) vertices: Vec<macroquad::prelude::Vertex>,
pub indices: Vec<u16>, pub(crate) indices: Vec<u16>,
} }
impl MeshData { impl MeshData {
@ -275,44 +365,28 @@ 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 struct TurtleSource { pub(crate) enum DrawCommand {
pub command: crate::commands::TurtleCommand, /// Pre-tessellated mesh data (lines, arcs, circles, polygons — all use this).
pub color: Color, Mesh { data: MeshData },
pub fill_color: Color, /// Text rendering command.
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 struct TurtleWorld { pub(crate) struct TurtleWorld {
/// All turtles in the world (indexed by turtle ID) /// All turtles in the world (indexed by turtle ID)
pub turtles: Vec<Turtle>, pub(crate) turtles: Vec<Turtle>,
pub camera: Camera2D, pub(crate) camera: Camera2D,
pub background_color: Color, pub(crate) background_color: Color,
} }
impl TurtleWorld { impl TurtleWorld {

View File

@ -14,26 +14,30 @@ use macroquad::prelude::*;
/// Convert macroquad Vec2 to Lyon Point /// Convert macroquad Vec2 to Lyon Point
#[must_use] #[must_use]
pub fn to_lyon_point(v: Vec2) -> Point { pub(crate) 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 fn to_macroquad_vec2(p: Point) -> Vec2 { pub(crate) 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 struct SimpleVertex { pub(crate) struct SimpleVertex {
pub position: [f32; 2], pub(crate) position: [f32; 2],
} }
/// Build mesh data from Lyon tessellation /// Build mesh data from Lyon tessellation
#[must_use] #[must_use]
pub fn build_mesh_data(vertices: &[SimpleVertex], indices: &[u16], color: Color) -> MeshData { pub(crate) fn build_mesh_data(
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 {
@ -62,7 +66,7 @@ pub fn build_mesh_data(vertices: &[SimpleVertex], indices: &[u16], color: Color)
/// # 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 fn tessellate_polygon( pub(crate) 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>> {
@ -107,7 +111,7 @@ pub 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 fn tessellate_multi_contour( pub(crate) 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>> {
@ -203,7 +207,7 @@ pub 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 fn tessellate_stroke( pub(crate) fn tessellate_stroke(
vertices: &[Vec2], vertices: &[Vec2],
color: Color, color: Color,
width: f32, width: f32,
@ -249,7 +253,7 @@ pub fn tessellate_stroke(
/// # Errors /// # Errors
/// ///
/// Returns an error if tessellation fails. /// Returns an error if tessellation fails.
pub fn tessellate_circle( pub(crate) fn tessellate_circle(
center: Vec2, center: Vec2,
radius: f32, radius: f32,
color: Color, color: Color,
@ -295,7 +299,7 @@ pub 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 fn tessellate_arc( pub(crate) fn tessellate_arc(
center: Vec2, center: Vec2,
radius: f32, radius: f32,
start_angle_degrees: f32, start_angle_degrees: f32,
@ -305,40 +309,32 @@ pub 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>> {
// Build arc path manually from segments use crate::circle_geometry::arc_points;
let mut builder = Path::builder();
let start_angle = start_angle_degrees.to_radians(); let start_angle = start_angle_degrees.to_radians();
let arc_angle = arc_angle_degrees.to_radians(); let sweep_angle = arc_angle_degrees.to_radians();
let step = arc_angle / segments as f32;
// Calculate first point let mut builder = Path::builder();
let first_point = point(
// Start point of the arc (arc_points returns everything *after* this)
builder.begin(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);
// Add remaining points - direction matters! // Remaining points — single source of truth for arc sampling
for i in 1..=segments { for pt in arc_points(
let angle = match direction { center,
crate::circle_geometry::CircleDirection::Left => { radius,
// Counter-clockwise: subtract angle start_angle,
start_angle - step * i as f32 sweep_angle,
} segments,
crate::circle_geometry::CircleDirection::Right => { direction,
// Clockwise: add angle ) {
start_angle + step * i as f32 builder.line_to(point(pt.x, pt.y));
}
};
let pt = point(
center.x + radius * angle.cos(),
center.y + radius * angle.sin(),
);
builder.line_to(pt);
} }
builder.end(false); // Don't close the arc builder.end(false); // open arc, not a closed polygon
let path = builder.build(); let path = builder.build();
// Tessellate stroke // Tessellate stroke

View File

@ -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; use crate::general::{AnimationSpeed, Radians};
use crate::state::{Turtle, TurtleParams}; use crate::state::{DrawCommand, FillState, TurtleParams};
use macroquad::prelude::*; use macroquad::prelude::*;
use tween::{CubicInOut, TweenValue, Tweener}; use tween::{CubicInOut, TweenValue, Tweener};
@ -45,22 +45,26 @@ impl From<TweenVec2> for Vec2 {
/// Controls tweening of turtle commands /// Controls tweening of turtle commands
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
pub struct TweenController { pub(crate) 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 struct CommandTween { pub(crate) struct CommandTween {
pub turtle_id: usize, pub(crate) turtle_id: usize,
pub command: TurtleCommand, pub(crate) command: TurtleCommand,
pub start_time: f64, pub(crate) start_time: f64,
pub duration: f64, pub(crate) duration: f64,
pub start_params: TurtleParams, pub(crate) start_params: TurtleParams,
pub target_params: TurtleParams, pub(crate) target_params: TurtleParams,
pub current_position: Vec2, pub(crate) current_position: Vec2,
pub current_heading: f32, pub(crate) 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>,
@ -71,6 +75,7 @@ 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,
} }
@ -80,51 +85,72 @@ 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);
} }
/// Update the tween, returns `Vec` of (`command`, `start_state`, `end_state`) for all completed commands this frame /// Drive the animation controller for one frame.
/// Also takes commands vec to handle side effects like fill operations ///
/// Each `command` has its own `start_state` and `end_state` pair /// Returns `(command, start_params, end_params)` for every command that
/// 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(state: &mut Turtle) -> Vec<(TurtleCommand, TurtleParams, TurtleParams)> { pub fn update(
&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) = state.tween_controller.speed { if let AnimationSpeed::Instant(max_draw_calls) = self.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;
// Consume commands from the real queue so the current_index advances // Advance cursor through the queue for each command consumed
while let Some(command) = state.tween_controller.queue.next() { while let Some(command) = self.queue.get(self.cursor).cloned() {
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 {
state.params.speed = *new_speed; params.speed = *new_speed;
state.tween_controller.speed = *new_speed; self.speed = *new_speed;
if matches!(state.tween_controller.speed, AnimationSpeed::Animated(_)) { if matches!(self.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(&command, state) { if crate::execution::execute_command_side_effects(
&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 = state.params.clone(); let start_params = 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)
state.params = target_params.clone(); *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,
state, turtle_id,
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)
@ -141,7 +167,7 @@ impl TweenController {
} }
// Process current tween // Process current tween
if let Some(ref mut tween) = state.tween_controller.current_tween { if let Some(ref mut tween) = self.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
@ -155,10 +181,10 @@ impl TweenController {
direction, direction,
.. ..
} => { } => {
let angle_traveled = angle.to_radians() * progress; let angle_traveled = angle.as_radians().value() * progress;
calculate_circle_position( calculate_circle_position(
tween.start_params.position, tween.start_params.position,
tween.start_params.heading, Radians::new(tween.start_params.heading),
*radius, *radius,
angle_traveled, angle_traveled,
*direction, *direction,
@ -170,7 +196,7 @@ impl TweenController {
} }
}; };
state.params.position = current_position; 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
@ -179,14 +205,14 @@ impl TweenController {
angle, direction, .. angle, direction, ..
} => match direction { } => match direction {
CircleDirection::Left => { CircleDirection::Left => {
tween.start_params.heading - angle.to_radians() * progress tween.start_params.heading - angle.as_radians().value() * progress
} }
CircleDirection::Right => { CircleDirection::Right => {
tween.start_params.heading + angle.to_radians() * progress tween.start_params.heading + angle.as_radians().value() * progress
} }
}, },
TurtleCommand::Turn(angle) => { TurtleCommand::Turn(angle) => {
tween.start_params.heading + angle.to_radians() * progress tween.start_params.heading + angle.as_radians().value() * progress
} }
_ => { _ => {
// For other commands that change heading, lerp directly // For other commands that change heading, lerp directly
@ -195,18 +221,18 @@ impl TweenController {
} }
}); });
state.params.heading = current_heading; params.heading = current_heading;
tween.current_heading = current_heading; tween.current_heading = current_heading;
state.params.pen_width = tween.pen_width_tweener.move_to(elapsed); 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 {
state.params.pen_down = tween.target_params.pen_down; params.pen_down = tween.target_params.pen_down;
state.params.color = tween.target_params.color; params.color = tween.target_params.color;
state.params.fill_color = tween.target_params.fill_color; params.fill_color = tween.target_params.fill_color;
state.params.visible = tween.target_params.visible; params.visible = tween.target_params.visible;
state.params.shape = tween.target_params.shape.clone(); 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)
@ -215,20 +241,24 @@ 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();
// Drop the mutable borrow of tween before mutably borrowing state // tween borrow ends here (NLL) — safe to reassign self.current_tween below
state.params = target_params.clone(); *params = target_params.clone();
crate::execution::record_fill_vertices_after_movement( crate::execution::record_fill_vertices_after_movement(
&command, &command,
&start_params, &start_params,
state, turtle_id,
params,
filling,
); );
state.tween_controller.current_tween = None; self.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(&command, state) { if crate::execution::execute_command_side_effects(
return Self::update(state); // Continue to next command &command, turtle_id, params, filling, commands, svg_log,
) {
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
@ -236,43 +266,45 @@ impl TweenController {
return vec![(command, start_params.clone(), target_params.clone())]; return vec![(command, start_params.clone(), target_params.clone())];
} }
return Self::update(state); // Continue to next command return self.update(turtle_id, params, filling, commands, svg_log);
} }
return Vec::new(); return Vec::new();
} }
// Start next tween // Start next tween
if let Some(command) = state.tween_controller.queue.next() { if let Some(command) = self.queue.get(self.cursor).cloned() {
let command_clone = command.clone(); self.cursor += 1;
// Handle commands that should execute immediately (no animation) // Handle commands that should execute immediately (no animation)
match &command_clone { match &command {
TurtleCommand::SetSpeed(new_speed) => { TurtleCommand::SetSpeed(new_speed) => {
state.set_speed(*new_speed); params.speed = *new_speed;
state.tween_controller.speed = *new_speed; self.speed = *new_speed;
if matches!(state.tween_controller.speed, AnimationSpeed::Instant(_)) { if matches!(self.speed, AnimationSpeed::Instant(_)) {
return Self::update(state); return self.update(turtle_id, params, filling, commands, svg_log);
} }
return Self::update(state); return self.update(turtle_id, params, filling, commands, svg_log);
} }
_ => { _ => {
// Use centralized helper for side effects // Use centralized helper for side effects
if crate::execution::execute_command_side_effects(&command_clone, state) { if crate::execution::execute_command_side_effects(
return Self::update(state); &command, turtle_id, params, filling, commands, svg_log,
) {
return self.update(turtle_id, params, filling, commands, svg_log);
} }
} }
} }
let speed = state.tween_controller.speed; // Extract speed before borrowing self let speed = self.speed;
let duration = Self::calculate_duration_with_state(&command_clone, state, speed); let duration = Self::calculate_duration_with_state(&command, params, speed);
// Calculate target state // Calculate target state
let target_state = Self::calculate_target_state(&state.params, &command_clone); let target_state = Self::calculate_target_state(params, &command);
// Create tweeners for smooth animation // Create tweeners for smooth animation
let position_tweener = Tweener::new( let position_tweener = Tweener::new(
TweenVec2::from(state.params.position), TweenVec2::from(params.position),
TweenVec2::from(target_state.position), TweenVec2::from(target_state.position),
duration, duration,
CubicInOut, CubicInOut,
@ -284,21 +316,21 @@ impl TweenController {
); );
let pen_width_tweener = Tweener::new( let pen_width_tweener = Tweener::new(
state.params.pen_width, params.pen_width,
target_state.pen_width, target_state.pen_width,
duration, duration,
CubicInOut, CubicInOut,
); );
state.tween_controller.current_tween = Some(CommandTween { self.current_tween = Some(CommandTween {
turtle_id: state.turtle_id, turtle_id,
command: command_clone, command,
start_time: get_time(), start_time: get_time(),
duration, duration,
start_params: state.params.clone(), start_params: params.clone(),
target_params: target_state.clone(), target_params: target_state.clone(),
current_position: state.params.position, current_position: params.position,
current_heading: state.params.heading, current_heading: params.heading,
position_tweener, position_tweener,
heading_tweener, heading_tweener,
pen_width_tweener, pen_width_tweener,
@ -310,7 +342,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.queue.is_complete() self.current_tween.is_none() && self.cursor >= self.queue.len()
} }
/// Get the current active tween if one is in progress /// Get the current active tween if one is in progress
@ -319,131 +351,35 @@ impl TweenController {
} }
fn command_creates_drawing(command: &TurtleCommand) -> bool { fn command_creates_drawing(command: &TurtleCommand) -> bool {
matches!( command.produces_drawing()
command,
TurtleCommand::Move(_) | TurtleCommand::Circle { .. } | TurtleCommand::Goto(_)
)
} }
fn calculate_duration_with_state( fn calculate_duration_with_state(
command: &TurtleCommand, command: &TurtleCommand,
current: &Turtle, params: &TurtleParams,
speed: AnimationSpeed, speed: AnimationSpeed,
) -> f64 { ) -> f64 {
let mut speed = speed.value(); command.animation_duration(params, speed)
// 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: f32, start_heading: Radians,
radius: f32, radius: f32,
angle_traveled: f32, // How much of the total angle we've traveled (in radians) angle_traveled: f32,
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);
@ -451,7 +387,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
fn normalize_angle(angle: f32) -> f32 { pub(crate) 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;