From 76d07ab009d78e95fa3aac7d58914a01f8a5315f Mon Sep 17 00:00:00 2001 From: Franz Dietrich Date: Sat, 19 Sep 2026 07:51:55 +0200 Subject: [PATCH] Consistency Refactoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 6 consistency review items have been resolved, verified with new unit tests, automated test suites, and clean example builds. - **Fixed `Goto` Duration Bug**: In [`turtle-lib/src/command_behavior.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/command_behavior.rs), mapped Cartesian target coordinates to screen space (`vec2(target.x, -target.y)`) before calculating $\Delta x$ and $\Delta y$ in `animation_duration`. - **Unit Test**: Added `test_goto_duration_cartesian_inversion` testing that moving from screen $(0, 100)$ (Cartesian $(0, -100)$) to Cartesian $(0, 100)$ computes duration based on the actual 200px Euclidean distance ($2.0\text{s}$ at $100\text{ px/s}$). - **Stored `Degrees`**: Updated `TurtleCommand::SetHeading(Degrees)` in [`turtle-lib/src/commands.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/commands.rs) to store user degrees directly ($0^\circ = \text{East}$, $90^\circ = \text{North}$), matching `Turn(Degrees)` and `Circle { angle: Degrees }`. - **Deferred Screen-Space Conversion**: Converted to internal screen radians (`normalize_angle(-heading.as_radians().value())`) inside `apply_to_params` in [`turtle-lib/src/command_behavior.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/command_behavior.rs). - **Instant Duration**: Maintained instant transition ($0.01\text{s}$ minimum) in `animation_duration`. - **Unit Test**: Added `test_set_heading_degrees_and_instant_duration` verifying $90^\circ$ maps to North ($-\frac{\pi}{2}$ in screen space), $0^\circ$ to East ($0$), $270^\circ$ to South ($+\frac{\pi}{2}$), and duration is $0.01\text{s}$. - **Enhanced `Length` Type**: Expanded [`turtle-lib/src/general/length.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/general/length.rs) with `new()`, `value()`, `Neg`, `PartialOrd`, and conversion implementations (`From`, `From`, `From`, `From`, `From`). - **Adopted Across API**: - `TurtleCommand::Move(Length)` in `commands.rs`. - `TurtleCommand::Circle { radius: Length, ... }` in `commands.rs`. - `forward>` and `backward>` in [`turtle-lib/src/builders.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/builders.rs). - `circle_left`, `circle_right` accept `radius: impl Into`. - Kept stroke attribute `pen_width` as `Precision` (`f32`). - **Unit Test**: Added `test_length_conversions_and_negation` in `general::length`. - **Removed Duplicate Alias**: Removed `all_animations_complete(&self)` from `TurtleApp` in [`turtle-lib/src/lib.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/lib.rs); updated call in [`turtle-lib/src/export.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/export.rs) to use canonical `is_complete(&self)`. - **Corrected [`README.md`](file:///home/dietrich/Projekte/Source/turtlers/README.md)**: - `plan.goto(...)` $\rightarrow$ `plan.go_to(...)` - `plan.set_color(...)` $\rightarrow$ `plan.set_pen_color(...)` - `create_turtle()` $\rightarrow$ `create_turtle_plan()` - **Corrected [`AGENTS.md`](file:///home/dietrich/Projekte/Source/turtlers/AGENTS.md)**: - `create_turtle()` $\rightarrow$ `create_turtle_plan()` in Threading Pattern. - **Unified on `1000.0`**: - Updated [`README.md`](file:///home/dietrich/Projekte/Source/turtlers/README.md) lines 9, 108, 109 to state `speed >= 1000` is Instant and `speed < 1000` is Animated. - Updated [`turtle-lib/examples/circle_test.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/examples/circle_test.rs) to use `turtle.set_speed(1000)`. - **Renamed Examples**: - `turtle-lib/examples/stern.rs` $\rightarrow$ [`turtle-lib/examples/star.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/examples/star.rs) - `turtle-lib/examples/nikolaus.rs` $\rightarrow$ [`turtle-lib/examples/house_of_nikolaus.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/examples/house_of_nikolaus.rs) - **Translated Functions & Parameters**: - `house_of_nikolaus.rs`: Translated `nikolausquadrat` $\rightarrow$ `house_square`, `nikolausdiag` $\rightarrow$ `house_diagonal`, `nikolausdach2` $\rightarrow$ `house_roof`, parameter `groesse` $\rightarrow$ `size`. Added doc comment explaining the Eulerian path puzzle. - `breadboard.rs`: Translated `pin_reihe` $\rightarrow$ `pin_row`, `pin_spalte` $\rightarrow$ `pin_column`, `pin_seite` $\rightarrow$ `pin_side`, parameter `anzahl` $\rightarrow$ `count`, `anzahl_reihen` $\rightarrow$ `row_count`. Added `#[cfg(feature = "svg")]` to helper functions to eliminate dead-code warnings when SVG feature is not enabled. - **Translated UI Text & Print Messages**: - `"Drücke E für SVG-Export"` $\rightarrow$ `"Press E for SVG export"` - `"SVG exportiert nach test.svg"` $\rightarrow$ `"SVG exported to test.svg"` - `"Fehler beim Export: {:?}"` $\rightarrow$ `"Export error: {:?}"` - `"SVG-Export ist nicht aktiviert..."` $\rightarrow$ `"SVG export is not enabled..."` - Translated module doc in `export_svg.rs`. - **Updated [`README.md`](file:///home/dietrich/Projekte/Source/turtlers/README.md)** example lists and CLI commands to reference `star` and `house_of_nikolaus`. --- - `cargo test --package turtle-lib`: - 13 unit tests passed (including 3 new targeted unit tests) - 34 doctests passed (1 ignored internal helper) - `cargo check --workspace --all-targets --all-features`: Passed with 0 errors. - `cargo check --examples --package turtle-lib --all-features`: All 30 examples compiled cleanly with 0 errors. - `cargo clippy --package turtle-lib -- -Wclippy::pedantic -Aclippy::cast_precision_loss -Aclippy::cast_sign_loss -Aclippy::cast_possible_truncation`: Passed with 0 errors. --- AGENTS.md | 2 +- README.md | 24 +++--- turtle-lib/examples/breadboard.rs | 53 +++++++------ turtle-lib/examples/circle_test.rs | 2 +- turtle-lib/examples/export_svg.rs | 10 +-- turtle-lib/examples/house_of_nikolaus.rs | 57 ++++++++++++++ turtle-lib/examples/nikolaus.rs | 57 -------------- turtle-lib/examples/{stern.rs => star.rs} | 0 turtle-lib/src/builders.rs | 23 +++--- turtle-lib/src/command_behavior.rs | 91 +++++++++++++++++++++-- turtle-lib/src/commands.rs | 13 ++-- turtle-lib/src/commands_channel.rs | 5 +- turtle-lib/src/drawing.rs | 6 +- turtle-lib/src/execution.rs | 20 ++--- turtle-lib/src/export.rs | 2 +- turtle-lib/src/general/length.rs | 76 ++++++++++++++++++- turtle-lib/src/lib.rs | 7 +- turtle-lib/src/tweening.rs | 24 +++--- 18 files changed, 304 insertions(+), 168 deletions(-) create mode 100644 turtle-lib/examples/house_of_nikolaus.rs delete mode 100644 turtle-lib/examples/nikolaus.rs rename turtle-lib/examples/{stern.rs => star.rs} (100%) diff --git a/AGENTS.md b/AGENTS.md index d49c632..52884c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -124,7 +124,7 @@ let tx = turtle_tx.clone(); std::thread::spawn(move || { loop { let letter = get_input(); // Blocks - let mut plan = create_turtle(); + let mut plan = create_turtle_plan(); plan.forward(50.0); tx.send(plan.build()).ok(); } diff --git a/README.md b/README.md index b4b5089..8aa6467 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A modern turtle graphics library for Rust built on [Macroquad](https://macroquad - 🎨 **Simple Builder API**: Chain commands like `forward(100).right(90)` - ⚡ **Smooth Animations**: Tweening support with easing functions and live fill preview -- 🚀 **Instant Mode**: Execute commands immediately without animation (speed ≥ 999) +- 🚀 **Instant Mode**: Execute commands immediately without animation (speed ≥ 1000) - 🎯 **High-Quality Rendering**: Complete Lyon tessellation pipeline with GPU acceleration - 🫟 **Multi-Contour Fills**: Automatic hole detection with EvenOdd fill rule - draw cheese with holes! - 📐 **Self-Intersecting Paths**: Stars, complex shapes - all handled correctly @@ -99,14 +99,14 @@ plan.begin_fill(); plan.end_fill(); // Auto-closes and applies fill // Appearance -plan.set_color(RED); +plan.set_pen_color(RED); plan.set_pen_width(5.0); plan.hide(); plan.show(); // Speed control (dynamic) -plan.set_speed(100); // Animated mode (< 999) -plan.set_speed(1000); // Instant mode (>= 999) +plan.set_speed(100); // Animated mode (< 1000) +plan.set_speed(1000); // Instant mode (>= 1000) // Turtle shapes plan.shape(ShapeType::Triangle); @@ -136,7 +136,7 @@ let mut plan = create_turtle_plan(); // Fast initial positioning (instant mode) plan.set_speed(1000); plan.pen_up(); -plan.goto(vec2(-100.0, -100.0)); +plan.go_to(vec2(-100.0, -100.0)); // Slow animated drawing plan.set_speed(50); @@ -219,7 +219,7 @@ You can also export SVG programmatically from your code: use turtle_lib::*; // Create your drawing -let mut plan = create_turtle(); +let mut plan = create_turtle_plan(); plan.forward(100).right(90).forward(100); // Create app @@ -252,8 +252,8 @@ cargo run --example square cargo run --example koch cargo run --example shapes cargo run --example yinyang -cargo run --example stern -cargo run --example nikolaus +cargo run --example star +cargo run --example house_of_nikolaus # SVG export example (requires --features svg) cargo run --example export_svg --features svg @@ -274,8 +274,8 @@ RUST_LOG=turtle_lib=debug cargo run --example logging_example - **square.rs**: Basic square drawing - **koch.rs**: Koch snowflake fractal - **shapes.rs**: Demonstrates different turtle shapes -- **stern.rs**: Star pattern drawing -- **nikolaus.rs**: Nikolaus (Santa) drawing +- **star.rs**: Star pattern drawing +- **house_of_nikolaus.rs**: House of Nikolaus (Eulerian path puzzle) #### Fill Examples @@ -297,7 +297,7 @@ RUST_LOG=turtle_lib=debug cargo run --example logging_example ### Basic Fill ```rust -let mut plan = create_turtle(); +let mut plan = create_turtle_plan(); plan.set_fill_color(RED); plan.begin_fill(); @@ -321,7 +321,7 @@ plan.circle_left(90.0, 360.0, 72); // pen_up() closes current contour plan.pen_up(); -plan.goto(vec2(0.0, -30.0)); +plan.go_to(vec2(0.0, -30.0)); // pen_down() starts new contour plan.pen_down(); diff --git a/turtle-lib/examples/breadboard.rs b/turtle-lib/examples/breadboard.rs index 0466417..aece5b8 100644 --- a/turtle-lib/examples/breadboard.rs +++ b/turtle-lib/examples/breadboard.rs @@ -1,12 +1,12 @@ use turtle_lib::*; + #[cfg(feature = "svg")] #[macroquad::main("Export SVG")] - async fn main() { // Create turtle plan let mut turtle = create_turtle_plan(); - // Set instant mode so commands execute imqmediately + // Set instant mode so commands execute immediately turtle.set_speed(1200).set_pen_width(0.5); breadboard(&mut turtle, 65); @@ -24,12 +24,12 @@ async fn main() { app.update(); app.render(); - draw_text("Drücke E für SVG-Export", 20.0, 40.0, 32.0, BLACK); + draw_text("Press E for SVG export", 20.0, 40.0, 32.0, BLACK); if is_key_pressed(KeyCode::E) { match app.export_drawing("test.svg", export::DrawingFormat::Svg) { - Ok(_) => println!("SVG exportiert nach test.svg"), - Err(e) => println!("Fehler beim Export: {:?}", e), + Ok(_) => println!("SVG exported to test.svg"), + Err(e) => eprintln!("Export error: {:?}", e), } } @@ -39,9 +39,10 @@ async fn main() { #[cfg(not(feature = "svg"))] fn main() { - println!("SVG-Export ist nicht aktiviert. Baue mit --features svg"); + println!("SVG export is not enabled. Build with --features svg"); } +#[cfg(feature = "svg")] fn pin(t: &mut TurtlePlan, size: f32) { t.left(90.0).forward(size / 2.0); for _ in 0..5 { @@ -50,52 +51,56 @@ fn pin(t: &mut TurtlePlan, size: f32) { t.right(90.0).forward(size / 2.0).left(90.0); } -fn pin_reihe(t: &mut TurtlePlan, anzahl: usize) { - for x in 0..anzahl { +#[cfg(feature = "svg")] +fn pin_row(t: &mut TurtlePlan, count: usize) { + for x in 0..count { pin(t, 5.0); - if x < anzahl - 1 { + if x < count - 1 { t.forward(5.0); } } } -fn pin_spalte(t: &mut TurtlePlan, anzahl: usize, x_coord: f32) { - for x in 0..anzahl { +#[cfg(feature = "svg")] +fn pin_column(t: &mut TurtlePlan, count: usize, x_coord: f32) { + for x in 0..count { t.pen_up().go_to(vec2(x_coord, x as f32 * 10.0)).pen_down(); - pin_reihe(t, 5); + pin_row(t, 5); } } -fn pin_seite(t: &mut TurtlePlan, anzahl: usize, x_coord: f32, color: Color) { +#[cfg(feature = "svg")] +fn pin_side(t: &mut TurtlePlan, count: usize, x_coord: f32, color: Color) { t.pen_up() .go_to(vec2(x_coord, -2.5)) .pen_down() .set_pen_color(color) .set_heading(90.0); - for x in 0..anzahl { + for x in 0..count { pin(t, 5.0); - if x < anzahl - 1 { + if x < count - 1 { t.forward(5.0); } } } -fn breadboard(t: &mut TurtlePlan, anzahl_reihen: usize) { - pin_spalte(t, anzahl_reihen, 0.0); - pin_spalte(t, anzahl_reihen, 65.0); - pin_seite(t, anzahl_reihen, -15.0, BLUE); - pin_seite(t, anzahl_reihen, -25.0, RED); - pin_seite(t, anzahl_reihen, 125.0, BLUE); - pin_seite(t, anzahl_reihen, 135.0, RED); +#[cfg(feature = "svg")] +fn breadboard(t: &mut TurtlePlan, row_count: usize) { + pin_column(t, row_count, 0.0); + pin_column(t, row_count, 65.0); + pin_side(t, row_count, -15.0, BLUE); + pin_side(t, row_count, -25.0, RED); + pin_side(t, row_count, 125.0, BLUE); + pin_side(t, row_count, 135.0, RED); // draw outline t.pen_up().go_to(vec2(-30.0, -5.0)).pen_down(); t.set_pen_color(BLACK) - .forward(anzahl_reihen as f32 * 10.0 + 10.0) + .forward(row_count as f32 * 10.0 + 10.0) .right(90.0) .forward(170.0) .right(90.0) - .forward(anzahl_reihen as f32 * 10.0 + 10.0) + .forward(row_count as f32 * 10.0 + 10.0) .right(90.0) .forward(170.0) .right(90.0); diff --git a/turtle-lib/examples/circle_test.rs b/turtle-lib/examples/circle_test.rs index 3284075..05e7efc 100644 --- a/turtle-lib/examples/circle_test.rs +++ b/turtle-lib/examples/circle_test.rs @@ -10,7 +10,7 @@ fn draw(turtle: &mut TurtlePlan) { turtle.set_pen_color(RED); turtle.set_pen_width(0.5); turtle.left(90.0); - turtle.set_speed(999); + turtle.set_speed(1000); turtle.circle_left(100.0, 540.0, 72); // partial circle to the left turtle.begin_fill(); diff --git a/turtle-lib/examples/export_svg.rs b/turtle-lib/examples/export_svg.rs index 1cf011d..f40b91d 100644 --- a/turtle-lib/examples/export_svg.rs +++ b/turtle-lib/examples/export_svg.rs @@ -1,4 +1,4 @@ -//! Beispiel: Exportiere ein SVG aus einer einfachen Zeichnung +//! Example: Export an SVG from a simple drawing #[cfg(feature = "svg")] use turtle_lib::*; @@ -52,12 +52,12 @@ async fn main() { app.update(); app.render(); - draw_text("Drücke E für SVG-Export", 20.0, 40.0, 32.0, BLACK); + draw_text("Press E for SVG export", 20.0, 40.0, 32.0, BLACK); if is_key_pressed(KeyCode::E) { match app.export_drawing("test.svg", export::DrawingFormat::Svg) { - Ok(_) => println!("SVG exportiert nach test.svg"), - Err(e) => println!("Fehler beim Export: {:?}", e), + Ok(_) => println!("SVG exported to test.svg"), + Err(e) => eprintln!("Export error: {:?}", e), } } @@ -67,5 +67,5 @@ async fn main() { #[cfg(not(feature = "svg"))] fn main() { - println!("SVG-Export ist nicht aktiviert. Baue mit --features svg"); + println!("SVG export is not enabled. Build with --features svg"); } diff --git a/turtle-lib/examples/house_of_nikolaus.rs b/turtle-lib/examples/house_of_nikolaus.rs new file mode 100644 index 0000000..f711af1 --- /dev/null +++ b/turtle-lib/examples/house_of_nikolaus.rs @@ -0,0 +1,57 @@ +//! House of Nikolaus example - draws the classic house figure (Eulerian path puzzle) + +use turtle_lib::*; + +fn house_square(turtle: &mut TurtlePlan, size: f32) { + turtle.forward(size); + turtle.left(90.0); + turtle.forward(size); + turtle.left(90.0); + turtle.forward(size); + turtle.left(90.0); + turtle.forward(size); + turtle.left(90.0); +} + +fn house_diagonal(turtle: &mut TurtlePlan, size: f32) { + let square = size * size; + let diag = (square + square).sqrt(); + + turtle.left(45.0); + turtle.forward(diag); + turtle.left(45.0); + house_roof(turtle, size); + turtle.left(45.0); + turtle.forward(diag); + turtle.left(45.0); +} + +fn house_roof(turtle: &mut TurtlePlan, size: f32) { + let square = size * size; + let diag = (square + square).sqrt(); + turtle.left(45.0); + turtle.forward(diag / 2.0); + turtle.left(90.0); + turtle.forward(diag / 2.0); + turtle.left(45.0); +} + +fn house_of_nikolaus(turtle: &mut TurtlePlan, size: f32) { + house_square(turtle, size); + house_diagonal(turtle, size); +} + +#[turtle_main("House of Nikolaus")] +fn draw(turtle: &mut TurtlePlan) { + turtle.shape(ShapeType::Turtle); + + // Position the turtle (pen up, move, pen down) + turtle.pen_up(); + turtle.backward(80.0); + turtle.left(90.0); + turtle.backward(50.0); + turtle.right(90.0); + turtle.pen_down(); + + house_of_nikolaus(turtle, 100.0); +} diff --git a/turtle-lib/examples/nikolaus.rs b/turtle-lib/examples/nikolaus.rs deleted file mode 100644 index 74db8be..0000000 --- a/turtle-lib/examples/nikolaus.rs +++ /dev/null @@ -1,57 +0,0 @@ -//! Nikolaus example - draws a house-like figure - -use turtle_lib::*; - -fn nikolausquadrat(turtle: &mut TurtlePlan, groesse: f32) { - turtle.forward(groesse); - turtle.left(90.0); - turtle.forward(groesse); - turtle.left(90.0); - turtle.forward(groesse); - turtle.left(90.0); - turtle.forward(groesse); - turtle.left(90.0); -} - -fn nikolausdiag(turtle: &mut TurtlePlan, groesse: f32) { - let quadrat = groesse * groesse; - let diag = (quadrat + quadrat).sqrt(); - - turtle.left(45.0); - turtle.forward(diag); - turtle.left(45.0); - nikolausdach2(turtle, groesse); - turtle.left(45.0); - turtle.forward(diag); - turtle.left(45.0); -} - -fn nikolausdach2(turtle: &mut TurtlePlan, groesse: f32) { - let quadrat = groesse * groesse; - let diag = (quadrat + quadrat).sqrt(); - turtle.left(45.0); - turtle.forward(diag / 2.0); - turtle.left(90.0); - turtle.forward(diag / 2.0); - turtle.left(45.0); -} - -fn nikolaus(turtle: &mut TurtlePlan, groesse: f32) { - nikolausquadrat(turtle, groesse); - nikolausdiag(turtle, groesse); -} - -#[turtle_main("Nikolaus")] -fn draw(turtle: &mut TurtlePlan) { - turtle.shape(ShapeType::Turtle); - - // Position the turtle (pen up, move, pen down) - turtle.pen_up(); - turtle.backward(80.0); - turtle.left(90.0); - turtle.backward(50.0); - turtle.right(90.0); - turtle.pen_down(); - - nikolaus(turtle, 100.0); -} diff --git a/turtle-lib/examples/stern.rs b/turtle-lib/examples/star.rs similarity index 100% rename from turtle-lib/examples/stern.rs rename to turtle-lib/examples/star.rs diff --git a/turtle-lib/src/builders.rs b/turtle-lib/src/builders.rs index 1cc5ec8..b7fcab1 100644 --- a/turtle-lib/src/builders.rs +++ b/turtle-lib/src/builders.rs @@ -1,7 +1,7 @@ //! Builder pattern traits for creating turtle command sequences use crate::commands::{CommandQueue, TurtleCommand}; -use crate::general::{AnimationSpeed, Color, Coordinate, Degrees, FontSize, Precision}; +use crate::general::{AnimationSpeed, Color, Coordinate, Degrees, FontSize, Length, Precision}; use crate::shapes::{ShapeType, TurtleShape}; /// Trait for adding commands to a queue @@ -33,9 +33,9 @@ pub trait DirectionalMovement: WithCommands { /// ``` fn forward(&mut self, distance: T) -> &mut Self where - T: Into, + T: Into, { - let dist: Precision = distance.into(); + let dist: Length = distance.into(); self.get_commands_mut().push(TurtleCommand::Move(dist)); self } @@ -61,9 +61,9 @@ pub trait DirectionalMovement: WithCommands { /// ``` fn backward(&mut self, distance: T) -> &mut Self where - T: Into, + T: Into, { - let dist: Precision = distance.into(); + let dist: Length = distance.into(); self.get_commands_mut().push(TurtleCommand::Move(-dist)); self } @@ -159,10 +159,10 @@ pub trait CurvedMovement: WithCommands { /// ``` fn circle_left(&mut self, radius: R, angle: A, steps: usize) -> &mut Self where - R: Into, + R: Into, A: Into, { - let r: Precision = radius.into(); + let r: Length = radius.into(); self.get_commands_mut().push(TurtleCommand::Circle { radius: r, angle: angle.into(), @@ -205,10 +205,10 @@ pub trait CurvedMovement: WithCommands { /// ``` fn circle_right(&mut self, radius: R, angle: A, steps: usize) -> &mut Self where - R: Into, + R: Into, A: Into, { - let r: Precision = radius.into(); + let r: Length = radius.into(); self.get_commands_mut().push(TurtleCommand::Circle { radius: r, angle: angle.into(), @@ -370,10 +370,7 @@ impl TurtlePlan { /// } /// ``` pub fn set_heading>(&mut self, heading: T) -> &mut Self { - // Convert user-facing turtle heading (degrees, Y-up mental model) - // to internal radians used by the render-space pipeline. - self.queue - .push(TurtleCommand::SetHeading(-heading.into().as_radians())); + self.queue.push(TurtleCommand::SetHeading(heading.into())); self } diff --git a/turtle-lib/src/command_behavior.rs b/turtle-lib/src/command_behavior.rs index c27146b..ece90f4 100644 --- a/turtle-lib/src/command_behavior.rs +++ b/turtle-lib/src/command_behavior.rs @@ -31,8 +31,8 @@ impl TurtleCommand { 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(); + let dx = dist.value() * params.heading.cos(); + let dy = dist.value() * params.heading.sin(); params.position = vec2(params.position.x + dx, params.position.y + dy); } TurtleCommand::Turn(angle) => { @@ -47,7 +47,7 @@ impl TurtleCommand { let geom = CircleGeometry::new( params.position, Radians::new(params.heading), - *radius, + radius.value(), *direction, ); let angle_rad = angle.as_radians().value(); @@ -62,7 +62,7 @@ impl TurtleCommand { params.position = vec2(coord.x, -coord.y); } TurtleCommand::SetHeading(heading) => { - params.heading = normalize_angle(heading.value()); + params.heading = normalize_angle(-heading.as_radians().value()); } TurtleCommand::SetColor(color) => { params.color = *color; @@ -119,15 +119,16 @@ impl TurtleCommand { } let base: f32 = match self { - TurtleCommand::Move(dist) => dist.abs() / spd, + TurtleCommand::Move(dist) => dist.value().abs() / spd, TurtleCommand::Turn(angle) => angle.value().abs() / (spd * 1.8), TurtleCommand::Circle { radius, angle, .. } => { - let arc_length = radius * angle.as_radians().value().abs(); + let arc_length = radius.value() * 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; + let screen_target = vec2(target.x, -target.y); + let dx = screen_target.x - params.position.x; + let dy = screen_target.y - params.position.y; (dx * dx + dy * dy).sqrt() / spd } _ => 0.0, @@ -148,3 +149,77 @@ impl TurtleCommand { ) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::general::{AnimationSpeed, Color, Coordinate, Degrees}; + use crate::shapes::TurtleShape; + + fn make_test_params() -> TurtleParams { + TurtleParams { + position: vec2(0.0, 0.0), + heading: 0.0, + pen_down: true, + pen_width: 1.0, + color: Color::new(0.0, 0.0, 0.0, 1.0), + fill_color: None, + visible: true, + shape: TurtleShape::turtle(), + speed: AnimationSpeed::Animated(100.0), + } + } + + #[test] + fn test_goto_duration_cartesian_inversion() { + let mut params = make_test_params(); + // Set screen position to (0, 100), which corresponds to Cartesian (0, -100) + params.position = vec2(0.0, 100.0); + + // Move to Cartesian (0, 100), which in screen space is (0, -100) + // Distance should be 200 pixels! + let cmd = TurtleCommand::Goto(Coordinate::new(0.0, 100.0)); + let duration = cmd.animation_duration(¶ms, AnimationSpeed::Animated(100.0)); + + // At 100 px/sec across 200 pixels, duration should be 2.0 seconds + assert!( + (duration - 2.0).abs() < 0.01, + "Expected duration ~2.0s for 200px move, got {duration}" + ); + } + + #[test] + fn test_set_heading_degrees_and_instant_duration() { + let mut params = make_test_params(); + + // 90° = North (in screen coordinates: -pi/2) + let cmd_north = TurtleCommand::SetHeading(Degrees::new(90.0)); + cmd_north.apply_to_params(&mut params); + let expected_north = -std::f32::consts::FRAC_PI_2; + assert!( + (params.heading - expected_north).abs() < 0.001, + "Heading 90° should be North (-π/2), got {}", + params.heading + ); + + // SetHeading should be instant (0.01 minimum duration) + let duration = cmd_north.animation_duration(¶ms, AnimationSpeed::Animated(100.0)); + assert!((duration - 0.01).abs() < 0.001); + + // 0° = East (0 radians) + let cmd_east = TurtleCommand::SetHeading(Degrees::new(0.0)); + cmd_east.apply_to_params(&mut params); + assert!((params.heading - 0.0).abs() < 0.001); + + // 270° = South (+pi/2 radians in screen coords) + let cmd_south = TurtleCommand::SetHeading(Degrees::new(270.0)); + cmd_south.apply_to_params(&mut params); + let expected_south = std::f32::consts::FRAC_PI_2; + assert!( + (params.heading - expected_south).abs() < 0.001, + "Heading 270° should be South (π/2), got {}", + params.heading + ); + } +} + diff --git a/turtle-lib/src/commands.rs b/turtle-lib/src/commands.rs index 1f91773..a5559b1 100644 --- a/turtle-lib/src/commands.rs +++ b/turtle-lib/src/commands.rs @@ -1,13 +1,13 @@ //! Turtle commands and command queue -use crate::general::{AnimationSpeed, Color, Coordinate, Degrees, FontSize, Precision, Radians}; +use crate::general::{AnimationSpeed, Color, Coordinate, Degrees, FontSize, Length, Precision}; use crate::shapes::TurtleShape; /// Individual turtle commands #[derive(Clone, Debug)] pub enum TurtleCommand { // Movement (positive = forward, negative = backward) - Move(Precision), + Move(Length), // Rotation (positive = right/clockwise, negative = left/counter-clockwise) // Stored in degrees — the natural unit at the user-facing API boundary. @@ -15,7 +15,7 @@ pub enum TurtleCommand { // Circle drawing Circle { - radius: Precision, + radius: Length, angle: Degrees, // sweep angle — degrees, as supplied by the user steps: usize, direction: crate::circle_geometry::CircleDirection, @@ -34,10 +34,9 @@ pub enum TurtleCommand { // Position Goto(Coordinate), - /// Heading stored as internal radians (Y-down render-space convention). - /// Values passed via `TurtlePlan::set_heading` are converted from - /// user-facing degrees before this command is enqueued. - SetHeading(Radians), + /// Heading stored in user degrees (Cartesian convention: 0° = East, 90° = North). + /// Conversion to internal screen-space heading is performed when executed. + SetHeading(Degrees), // Visibility ShowTurtle, diff --git a/turtle-lib/src/commands_channel.rs b/turtle-lib/src/commands_channel.rs index 7fd1128..627aa60 100644 --- a/turtle-lib/src/commands_channel.rs +++ b/turtle-lib/src/commands_channel.rs @@ -203,13 +203,10 @@ impl TurtleCommandReceiver { /// Panics if `buffer_size` is 0. /// /// # Examples -/// ```no_run -/// # use turtle_lib::*; -/// # fn example() { +/// ```ignore /// let (tx, _rx) = turtle_command_channel(0, 100); /// // Sender goes to game threads /// // Receiver stays in render thread (or `TurtleApp`) -/// # } /// ``` #[must_use] pub(crate) fn turtle_command_channel( diff --git a/turtle-lib/src/drawing.rs b/turtle-lib/src/drawing.rs index 1d2971d..9f02241 100644 --- a/turtle-lib/src/drawing.rs +++ b/turtle-lib/src/drawing.rs @@ -62,7 +62,7 @@ pub(crate) fn render_world_with_tweens(world: &TurtleWorld, zoom_level: f32) { direction, } => { // Draw arc segments from start to current position - draw_tween_arc(tween, *radius, *angle, *steps, *direction); + draw_tween_arc(tween, radius.value(), *angle, *steps, *direction); } _ if should_draw_tween_line(&tween.command) => { // Draw straight line for other movement commands (use tween's current position) @@ -133,7 +133,7 @@ pub(crate) fn render_world_with_tweens(world: &TurtleWorld, zoom_level: f32) { let geom = CircleGeometry::new( tween.start_params.position, Radians::new(tween.start_params.heading), - *radius, + radius.value(), *direction, ); let elapsed = get_time() - tween.start_time; @@ -147,7 +147,7 @@ pub(crate) fn render_world_with_tweens(world: &TurtleWorld, zoom_level: f32) { let sweep_so_far = angle.as_radians().value() * eased_progress; for pt in arc_points( geom.center, - *radius, + radius.value(), geom.start_angle_from_center, sweep_so_far, samples_to_draw, diff --git a/turtle-lib/src/execution.rs b/turtle-lib/src/execution.rs index 7e0ffd3..503d5f7 100644 --- a/turtle-lib/src/execution.rs +++ b/turtle-lib/src/execution.rs @@ -242,7 +242,7 @@ pub(crate) fn record_fill_vertices_after_movement( let geom = CircleGeometry::new( start_state.position, Radians::new(start_state.heading), - *radius, + radius.value(), *direction, ); if let Some(ref mut fill_state) = filling { @@ -252,7 +252,7 @@ pub(crate) fn record_fill_vertices_after_movement( turtle_id, center_x = geom.center.x, center_y = geom.center.y, - radius, + radius = radius.value(), steps, num_samples, "Recording arc vertices" @@ -268,8 +268,8 @@ pub(crate) fn record_fill_vertices_after_movement( } }; let vertex = Coordinate::new( - geom.center.x + radius * current_angle.cos(), - geom.center.y + radius * current_angle.sin(), + geom.center.x + radius.value() * current_angle.cos(), + geom.center.y + radius.value() * current_angle.sin(), ); tracing::trace!( turtle_id, @@ -347,12 +347,12 @@ pub(crate) fn tessellate_command( let geom = CircleGeometry::new( start.position, Radians::new(start.heading), - *radius, + radius.value(), *direction, ); let mesh = tessellation::tessellate_arc( geom.center, - *radius, + radius.value(), geom.start_angle_from_center.to_degrees(), angle.value(), start.color, @@ -402,7 +402,7 @@ pub(crate) fn push_svg_for_draw( svg_log.push(SvgRecord::Arc { start_position: start.position, start_heading: start.heading, - radius: *radius, + radius: radius.value(), angle: *angle, direction: *direction, color: start.color, @@ -474,7 +474,7 @@ pub(crate) fn execute_command_with_id( mod tests { use super::*; use crate::commands::TurtleCommand; - use crate::general::Degrees; + use crate::general::{Degrees, Length}; use crate::shapes::TurtleShape; use crate::tweening::TweenController; @@ -509,7 +509,7 @@ mod tests { assert_eq!(state.params.heading, 0.0); // Forward 100 - should move to (100, 0) - execute_command(&TurtleCommand::Move(100.0), &mut state); + execute_command(&TurtleCommand::Move(Length::new(100.0)), &mut state); assert!( (state.params.position.x - 100.0).abs() < 0.01, "After forward(100): x = {}", @@ -544,7 +544,7 @@ mod tests { ); // Forward 50 - should move north (negative Y) to (100, -50) - execute_command(&TurtleCommand::Move(50.0), &mut state); + execute_command(&TurtleCommand::Move(Length::new(50.0)), &mut state); assert!( (state.params.position.x - 100.0).abs() < 0.01, "Final position: x = {} (expected 100.0)", diff --git a/turtle-lib/src/export.rs b/turtle-lib/src/export.rs index 2d22678..e1624ba 100644 --- a/turtle-lib/src/export.rs +++ b/turtle-lib/src/export.rs @@ -59,7 +59,7 @@ where let mut app = crate::TurtleApp::new().with_commands(turtle.build()); app.set_all_turtles_speed(crate::AnimationSpeed::Instant(1000)); - while !app.all_animations_complete() { + while !app.is_complete() { app.update(); } diff --git a/turtle-lib/src/general/length.rs b/turtle-lib/src/general/length.rs index 4603050..767ac4c 100644 --- a/turtle-lib/src/general/length.rs +++ b/turtle-lib/src/general/length.rs @@ -1,13 +1,33 @@ //! Length type for distance measurements use super::Precision; +use std::ops::Neg; -#[derive(Default, Copy, Clone, Debug, PartialEq)] +/// A spatial distance or length measurement. +/// +/// Used at the public API boundary for movement distances and arc radii. +#[derive(Default, Copy, Clone, Debug, PartialEq, PartialOrd)] pub struct Length(pub Precision); -impl From for Length { - fn from(i: i16) -> Self { - Self(Precision::from(i)) +impl Length { + /// Create a new `Length` from a raw value. + #[must_use] + pub const fn new(v: Precision) -> Self { + Self(v) + } + + /// Extract the raw `Precision` (`f32`) value. + #[must_use] + pub const fn value(self) -> Precision { + self.0 + } +} + +impl Neg for Length { + type Output = Self; + + fn neg(self) -> Self { + Self(-self.0) } } @@ -17,8 +37,56 @@ impl From for Length { } } +impl From for Length { + fn from(f: f64) -> Self { + Self(f as Precision) + } +} + +impl From for Length { + fn from(i: i16) -> Self { + Self(Precision::from(i)) + } +} + impl From for Length { fn from(i: i32) -> Self { Self(i as Precision) } } + +impl From for Length { + fn from(u: usize) -> Self { + Self(u as Precision) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_length_conversions_and_negation() { + let l_f32: Length = 42.5_f32.into(); + assert_eq!(l_f32.value(), 42.5); + + let l_f64: Length = 100.0_f64.into(); + assert_eq!(l_f64.value(), 100.0); + + let l_i32: Length = 50_i32.into(); + assert_eq!(l_i32.value(), 50.0); + + let l_i16: Length = 25_i16.into(); + assert_eq!(l_i16.value(), 25.0); + + let l_usize: Length = 10_usize.into(); + assert_eq!(l_usize.value(), 10.0); + + let neg = -l_f32; + assert_eq!(neg.value(), -42.5); + + assert!(Length::new(10.0) < Length::new(20.0)); + } +} + + diff --git a/turtle-lib/src/lib.rs b/turtle-lib/src/lib.rs index a52a04e..d4c8694 100644 --- a/turtle-lib/src/lib.rs +++ b/turtle-lib/src/lib.rs @@ -106,6 +106,7 @@ impl TurtleApp { filename: &str, format: export::DrawingFormat, ) -> Result<(), export::ExportError> { + let _ = filename; match format { #[cfg(feature = "svg")] export::DrawingFormat::Svg => { @@ -371,12 +372,6 @@ impl TurtleApp { .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 diff --git a/turtle-lib/src/tweening.rs b/turtle-lib/src/tweening.rs index f93176f..a4b2a70 100644 --- a/turtle-lib/src/tweening.rs +++ b/turtle-lib/src/tweening.rs @@ -180,7 +180,7 @@ impl TweenController { calculate_circle_position( tween.start_params.position, Radians::new(tween.start_params.heading), - *radius, + radius.value(), angle_traveled, *direction, ) @@ -413,7 +413,7 @@ fn current_time() -> f64 { mod tests { use super::*; use crate::commands::TurtleCommand; - use crate::general::Degrees; + use crate::general::{Degrees, Length}; use crate::state::TurtleParams; fn make_test_params() -> TurtleParams { @@ -433,10 +433,10 @@ mod tests { #[test] fn test_instant_mode_drains_queue() { let mut queue = CommandQueue::new(); - queue.push(TurtleCommand::Move(100.0)); + queue.push(TurtleCommand::Move(Length::new(100.0))); queue.push(TurtleCommand::Turn(Degrees::new(90.0))); queue.push(TurtleCommand::PenUp); - queue.push(TurtleCommand::Move(50.0)); + queue.push(TurtleCommand::Move(Length::new(50.0))); let mut controller = TweenController::new(queue, AnimationSpeed::Instant(100)); assert_eq!(controller.queue.len(), 4); @@ -467,7 +467,7 @@ mod tests { let mut batch = CommandQueue::new(); batch.push(TurtleCommand::Reset); batch.push(TurtleCommand::PenDown); - batch.push(TurtleCommand::Move(10.0)); + batch.push(TurtleCommand::Move(Length::new(10.0))); batch.push(TurtleCommand::Turn(Degrees::new(30.0))); controller.append_commands(batch); @@ -485,11 +485,11 @@ mod tests { fn test_instant_mode_respects_batch_limit_and_retains_pending() { let mut queue = CommandQueue::new(); // 5 drawing commands - queue.push(TurtleCommand::Move(10.0)); - queue.push(TurtleCommand::Move(20.0)); - queue.push(TurtleCommand::Move(30.0)); - queue.push(TurtleCommand::Move(40.0)); - queue.push(TurtleCommand::Move(50.0)); + queue.push(TurtleCommand::Move(Length::new(10.0))); + queue.push(TurtleCommand::Move(Length::new(20.0))); + queue.push(TurtleCommand::Move(Length::new(30.0))); + queue.push(TurtleCommand::Move(Length::new(40.0))); + queue.push(TurtleCommand::Move(Length::new(50.0))); // Limit to 2 draw calls per frame let mut controller = TweenController::new(queue, AnimationSpeed::Instant(2)); @@ -520,8 +520,8 @@ mod tests { #[test] fn test_animated_mode_pops_to_current_tween() { let mut queue = CommandQueue::new(); - queue.push(TurtleCommand::Move(100.0)); - queue.push(TurtleCommand::Move(50.0)); + queue.push(TurtleCommand::Move(Length::new(100.0))); + queue.push(TurtleCommand::Move(Length::new(50.0))); let mut controller = TweenController::new( queue,