Consistency Refactoring

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<f32>`, `From<f64>`, `From<i32>`, `From<i16>`,
  `From<usize>`).
- **Adopted Across API**:
  - `TurtleCommand::Move(Length)` in `commands.rs`.
  - `TurtleCommand::Circle { radius: Length, ... }` in `commands.rs`.
  - `forward<T: Into<Length>>` and `backward<T: Into<Length>>` 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<Length>`.
  - 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.
This commit is contained in:
2026-09-19 07:51:55 +02:00
parent c12cb7c3ee
commit 76d07ab009
18 changed files with 304 additions and 168 deletions
+1 -1
View File
@@ -124,7 +124,7 @@ let tx = turtle_tx.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
loop { loop {
let letter = get_input(); // Blocks let letter = get_input(); // Blocks
let mut plan = create_turtle(); let mut plan = create_turtle_plan();
plan.forward(50.0); plan.forward(50.0);
tx.send(plan.build()).ok(); tx.send(plan.build()).ok();
} }
+12 -12
View File
@@ -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)` - 🎨 **Simple Builder API**: Chain commands like `forward(100).right(90)`
- ⚡ **Smooth Animations**: Tweening support with easing functions and live fill preview - ⚡ **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 - 🎯 **High-Quality Rendering**: Complete Lyon tessellation pipeline with GPU acceleration
- 🫟 **Multi-Contour Fills**: Automatic hole detection with EvenOdd fill rule - draw cheese with holes! - 🫟 **Multi-Contour Fills**: Automatic hole detection with EvenOdd fill rule - draw cheese with holes!
- 📐 **Self-Intersecting Paths**: Stars, complex shapes - all handled correctly - 📐 **Self-Intersecting Paths**: Stars, complex shapes - all handled correctly
@@ -99,14 +99,14 @@ plan.begin_fill();
plan.end_fill(); // Auto-closes and applies fill plan.end_fill(); // Auto-closes and applies fill
// Appearance // Appearance
plan.set_color(RED); plan.set_pen_color(RED);
plan.set_pen_width(5.0); plan.set_pen_width(5.0);
plan.hide(); plan.hide();
plan.show(); plan.show();
// Speed control (dynamic) // Speed control (dynamic)
plan.set_speed(100); // Animated mode (< 999) plan.set_speed(100); // Animated mode (< 1000)
plan.set_speed(1000); // Instant mode (>= 999) plan.set_speed(1000); // Instant mode (>= 1000)
// Turtle shapes // Turtle shapes
plan.shape(ShapeType::Triangle); plan.shape(ShapeType::Triangle);
@@ -136,7 +136,7 @@ let mut plan = create_turtle_plan();
// Fast initial positioning (instant mode) // Fast initial positioning (instant mode)
plan.set_speed(1000); plan.set_speed(1000);
plan.pen_up(); plan.pen_up();
plan.goto(vec2(-100.0, -100.0)); plan.go_to(vec2(-100.0, -100.0));
// Slow animated drawing // Slow animated drawing
plan.set_speed(50); plan.set_speed(50);
@@ -219,7 +219,7 @@ You can also export SVG programmatically from your code:
use turtle_lib::*; use turtle_lib::*;
// Create your drawing // Create your drawing
let mut plan = create_turtle(); let mut plan = create_turtle_plan();
plan.forward(100).right(90).forward(100); plan.forward(100).right(90).forward(100);
// Create app // Create app
@@ -252,8 +252,8 @@ cargo run --example square
cargo run --example koch cargo run --example koch
cargo run --example shapes cargo run --example shapes
cargo run --example yinyang cargo run --example yinyang
cargo run --example stern cargo run --example star
cargo run --example nikolaus cargo run --example house_of_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
@@ -274,8 +274,8 @@ RUST_LOG=turtle_lib=debug cargo run --example logging_example
- **square.rs**: Basic square drawing - **square.rs**: Basic square drawing
- **koch.rs**: Koch snowflake fractal - **koch.rs**: Koch snowflake fractal
- **shapes.rs**: Demonstrates different turtle shapes - **shapes.rs**: Demonstrates different turtle shapes
- **stern.rs**: Star pattern drawing - **star.rs**: Star pattern drawing
- **nikolaus.rs**: Nikolaus (Santa) drawing - **house_of_nikolaus.rs**: House of Nikolaus (Eulerian path puzzle)
#### Fill Examples #### Fill Examples
@@ -297,7 +297,7 @@ RUST_LOG=turtle_lib=debug cargo run --example logging_example
### Basic Fill ### Basic Fill
```rust ```rust
let mut plan = create_turtle(); let mut plan = create_turtle_plan();
plan.set_fill_color(RED); plan.set_fill_color(RED);
plan.begin_fill(); plan.begin_fill();
@@ -321,7 +321,7 @@ plan.circle_left(90.0, 360.0, 72);
// pen_up() closes current contour // pen_up() closes current contour
plan.pen_up(); plan.pen_up();
plan.goto(vec2(0.0, -30.0)); plan.go_to(vec2(0.0, -30.0));
// pen_down() starts new contour // pen_down() starts new contour
plan.pen_down(); plan.pen_down();
+29 -24
View File
@@ -1,12 +1,12 @@
use turtle_lib::*; use turtle_lib::*;
#[cfg(feature = "svg")] #[cfg(feature = "svg")]
#[macroquad::main("Export SVG")] #[macroquad::main("Export SVG")]
async fn main() { async fn main() {
// Create turtle plan // Create turtle plan
let mut turtle = 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); turtle.set_speed(1200).set_pen_width(0.5);
breadboard(&mut turtle, 65); breadboard(&mut turtle, 65);
@@ -24,12 +24,12 @@ async fn main() {
app.update(); app.update();
app.render(); 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) { if is_key_pressed(KeyCode::E) {
match app.export_drawing("test.svg", export::DrawingFormat::Svg) { match app.export_drawing("test.svg", export::DrawingFormat::Svg) {
Ok(_) => println!("SVG exportiert nach test.svg"), Ok(_) => println!("SVG exported to test.svg"),
Err(e) => println!("Fehler beim Export: {:?}", e), Err(e) => eprintln!("Export error: {:?}", e),
} }
} }
@@ -39,9 +39,10 @@ async fn main() {
#[cfg(not(feature = "svg"))] #[cfg(not(feature = "svg"))]
fn main() { 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) { fn pin(t: &mut TurtlePlan, size: f32) {
t.left(90.0).forward(size / 2.0); t.left(90.0).forward(size / 2.0);
for _ in 0..5 { 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); t.right(90.0).forward(size / 2.0).left(90.0);
} }
fn pin_reihe(t: &mut TurtlePlan, anzahl: usize) { #[cfg(feature = "svg")]
for x in 0..anzahl { fn pin_row(t: &mut TurtlePlan, count: usize) {
for x in 0..count {
pin(t, 5.0); pin(t, 5.0);
if x < anzahl - 1 { if x < count - 1 {
t.forward(5.0); t.forward(5.0);
} }
} }
} }
fn pin_spalte(t: &mut TurtlePlan, anzahl: usize, x_coord: f32) { #[cfg(feature = "svg")]
for x in 0..anzahl { 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(); 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() t.pen_up()
.go_to(vec2(x_coord, -2.5)) .go_to(vec2(x_coord, -2.5))
.pen_down() .pen_down()
.set_pen_color(color) .set_pen_color(color)
.set_heading(90.0); .set_heading(90.0);
for x in 0..anzahl { for x in 0..count {
pin(t, 5.0); pin(t, 5.0);
if x < anzahl - 1 { if x < count - 1 {
t.forward(5.0); t.forward(5.0);
} }
} }
} }
fn breadboard(t: &mut TurtlePlan, anzahl_reihen: usize) { #[cfg(feature = "svg")]
pin_spalte(t, anzahl_reihen, 0.0); fn breadboard(t: &mut TurtlePlan, row_count: usize) {
pin_spalte(t, anzahl_reihen, 65.0); pin_column(t, row_count, 0.0);
pin_seite(t, anzahl_reihen, -15.0, BLUE); pin_column(t, row_count, 65.0);
pin_seite(t, anzahl_reihen, -25.0, RED); pin_side(t, row_count, -15.0, BLUE);
pin_seite(t, anzahl_reihen, 125.0, BLUE); pin_side(t, row_count, -25.0, RED);
pin_seite(t, anzahl_reihen, 135.0, RED); pin_side(t, row_count, 125.0, BLUE);
pin_side(t, row_count, 135.0, RED);
// draw outline // draw outline
t.pen_up().go_to(vec2(-30.0, -5.0)).pen_down(); t.pen_up().go_to(vec2(-30.0, -5.0)).pen_down();
t.set_pen_color(BLACK) 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) .right(90.0)
.forward(170.0) .forward(170.0)
.right(90.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) .right(90.0)
.forward(170.0) .forward(170.0)
.right(90.0); .right(90.0);
+1 -1
View File
@@ -10,7 +10,7 @@ fn draw(turtle: &mut TurtlePlan) {
turtle.set_pen_color(RED); turtle.set_pen_color(RED);
turtle.set_pen_width(0.5); turtle.set_pen_width(0.5);
turtle.left(90.0); 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.circle_left(100.0, 540.0, 72); // partial circle to the left
turtle.begin_fill(); turtle.begin_fill();
+5 -5
View File
@@ -1,4 +1,4 @@
//! Beispiel: Exportiere ein SVG aus einer einfachen Zeichnung //! Example: Export an SVG from a simple drawing
#[cfg(feature = "svg")] #[cfg(feature = "svg")]
use turtle_lib::*; use turtle_lib::*;
@@ -52,12 +52,12 @@ async fn main() {
app.update(); app.update();
app.render(); 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) { if is_key_pressed(KeyCode::E) {
match app.export_drawing("test.svg", export::DrawingFormat::Svg) { match app.export_drawing("test.svg", export::DrawingFormat::Svg) {
Ok(_) => println!("SVG exportiert nach test.svg"), Ok(_) => println!("SVG exported to test.svg"),
Err(e) => println!("Fehler beim Export: {:?}", e), Err(e) => eprintln!("Export error: {:?}", e),
} }
} }
@@ -67,5 +67,5 @@ async fn main() {
#[cfg(not(feature = "svg"))] #[cfg(not(feature = "svg"))]
fn main() { fn main() {
println!("SVG-Export ist nicht aktiviert. Baue mit --features svg"); println!("SVG export is not enabled. Build with --features svg");
} }
+57
View File
@@ -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);
}
-57
View File
@@ -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);
}
+10 -13
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, Degrees, FontSize, Precision}; use crate::general::{AnimationSpeed, Color, Coordinate, Degrees, FontSize, Length, 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
@@ -33,9 +33,9 @@ pub trait DirectionalMovement: WithCommands {
/// ``` /// ```
fn forward<T>(&mut self, distance: T) -> &mut Self fn forward<T>(&mut self, distance: T) -> &mut Self
where where
T: Into<Precision>, T: Into<Length>,
{ {
let dist: Precision = distance.into(); let dist: Length = distance.into();
self.get_commands_mut().push(TurtleCommand::Move(dist)); self.get_commands_mut().push(TurtleCommand::Move(dist));
self self
} }
@@ -61,9 +61,9 @@ pub trait DirectionalMovement: WithCommands {
/// ``` /// ```
fn backward<T>(&mut self, distance: T) -> &mut Self fn backward<T>(&mut self, distance: T) -> &mut Self
where where
T: Into<Precision>, T: Into<Length>,
{ {
let dist: Precision = distance.into(); let dist: Length = distance.into();
self.get_commands_mut().push(TurtleCommand::Move(-dist)); self.get_commands_mut().push(TurtleCommand::Move(-dist));
self self
} }
@@ -159,10 +159,10 @@ 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<Length>,
A: Into<Degrees>, A: Into<Degrees>,
{ {
let r: Precision = radius.into(); let r: Length = radius.into();
self.get_commands_mut().push(TurtleCommand::Circle { self.get_commands_mut().push(TurtleCommand::Circle {
radius: r, radius: r,
angle: angle.into(), angle: angle.into(),
@@ -205,10 +205,10 @@ 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<Length>,
A: Into<Degrees>, A: Into<Degrees>,
{ {
let r: Precision = radius.into(); let r: Length = radius.into();
self.get_commands_mut().push(TurtleCommand::Circle { self.get_commands_mut().push(TurtleCommand::Circle {
radius: r, radius: r,
angle: angle.into(), angle: angle.into(),
@@ -370,10 +370,7 @@ impl TurtlePlan {
/// } /// }
/// ``` /// ```
pub fn set_heading<T: Into<Degrees>>(&mut self, heading: T) -> &mut Self { pub fn set_heading<T: Into<Degrees>>(&mut self, heading: T) -> &mut Self {
// Convert user-facing turtle heading (degrees, Y-up mental model) self.queue.push(TurtleCommand::SetHeading(heading.into()));
// to internal radians used by the render-space pipeline.
self.queue
.push(TurtleCommand::SetHeading(-heading.into().as_radians()));
self self
} }
+83 -8
View File
@@ -31,8 +31,8 @@ impl TurtleCommand {
pub(crate) fn apply_to_params(&self, params: &mut TurtleParams) { pub(crate) fn apply_to_params(&self, params: &mut TurtleParams) {
match self { match self {
TurtleCommand::Move(dist) => { TurtleCommand::Move(dist) => {
let dx = dist * params.heading.cos(); let dx = dist.value() * params.heading.cos();
let dy = dist * params.heading.sin(); let dy = dist.value() * params.heading.sin();
params.position = vec2(params.position.x + dx, params.position.y + dy); params.position = vec2(params.position.x + dx, params.position.y + dy);
} }
TurtleCommand::Turn(angle) => { TurtleCommand::Turn(angle) => {
@@ -47,7 +47,7 @@ impl TurtleCommand {
let geom = CircleGeometry::new( let geom = CircleGeometry::new(
params.position, params.position,
Radians::new(params.heading), Radians::new(params.heading),
*radius, radius.value(),
*direction, *direction,
); );
let angle_rad = angle.as_radians().value(); let angle_rad = angle.as_radians().value();
@@ -62,7 +62,7 @@ impl TurtleCommand {
params.position = vec2(coord.x, -coord.y); params.position = vec2(coord.x, -coord.y);
} }
TurtleCommand::SetHeading(heading) => { TurtleCommand::SetHeading(heading) => {
params.heading = normalize_angle(heading.value()); params.heading = normalize_angle(-heading.as_radians().value());
} }
TurtleCommand::SetColor(color) => { TurtleCommand::SetColor(color) => {
params.color = *color; params.color = *color;
@@ -119,15 +119,16 @@ impl TurtleCommand {
} }
let base: f32 = match self { 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::Turn(angle) => angle.value().abs() / (spd * 1.8),
TurtleCommand::Circle { radius, angle, .. } => { 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 arc_length / spd
} }
TurtleCommand::Goto(target) => { TurtleCommand::Goto(target) => {
let dx = target.x - params.position.x; let screen_target = vec2(target.x, -target.y);
let dy = target.y - params.position.y; let dx = screen_target.x - params.position.x;
let dy = screen_target.y - params.position.y;
(dx * dx + dy * dy).sqrt() / spd (dx * dx + dy * dy).sqrt() / spd
} }
_ => 0.0, _ => 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(&params, 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(&params, 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
);
}
}
+6 -7
View File
@@ -1,13 +1,13 @@
//! Turtle commands and command queue //! Turtle commands and command queue
use crate::general::{AnimationSpeed, Color, Coordinate, Degrees, FontSize, Precision, Radians}; use crate::general::{AnimationSpeed, Color, Coordinate, Degrees, FontSize, Length, Precision};
use crate::shapes::TurtleShape; use crate::shapes::TurtleShape;
/// Individual turtle commands /// Individual turtle commands
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub enum TurtleCommand { pub enum TurtleCommand {
// Movement (positive = forward, negative = backward) // Movement (positive = forward, negative = backward)
Move(Precision), Move(Length),
// Rotation (positive = right/clockwise, negative = left/counter-clockwise) // Rotation (positive = right/clockwise, negative = left/counter-clockwise)
// Stored in degrees — the natural unit at the user-facing API boundary. // Stored in degrees — the natural unit at the user-facing API boundary.
@@ -15,7 +15,7 @@ pub enum TurtleCommand {
// Circle drawing // Circle drawing
Circle { Circle {
radius: Precision, radius: Length,
angle: Degrees, // sweep angle — degrees, as supplied by the user angle: Degrees, // sweep angle — degrees, as supplied by the user
steps: usize, steps: usize,
direction: crate::circle_geometry::CircleDirection, direction: crate::circle_geometry::CircleDirection,
@@ -34,10 +34,9 @@ pub enum TurtleCommand {
// Position // Position
Goto(Coordinate), Goto(Coordinate),
/// Heading stored as internal radians (Y-down render-space convention). /// Heading stored in user degrees (Cartesian convention: 0° = East, 90° = North).
/// Values passed via `TurtlePlan::set_heading` are converted from /// Conversion to internal screen-space heading is performed when executed.
/// user-facing degrees before this command is enqueued. SetHeading(Degrees),
SetHeading(Radians),
// Visibility // Visibility
ShowTurtle, ShowTurtle,
+1 -4
View File
@@ -203,13 +203,10 @@ impl TurtleCommandReceiver {
/// Panics if `buffer_size` is 0. /// Panics if `buffer_size` is 0.
/// ///
/// # Examples /// # Examples
/// ```no_run /// ```ignore
/// # use turtle_lib::*;
/// # fn example() {
/// let (tx, _rx) = turtle_command_channel(0, 100); /// let (tx, _rx) = turtle_command_channel(0, 100);
/// // Sender goes to game threads /// // Sender goes to game threads
/// // Receiver stays in render thread (or `TurtleApp`) /// // Receiver stays in render thread (or `TurtleApp`)
/// # }
/// ``` /// ```
#[must_use] #[must_use]
pub(crate) fn turtle_command_channel( pub(crate) fn turtle_command_channel(
+3 -3
View File
@@ -62,7 +62,7 @@ pub(crate) fn render_world_with_tweens(world: &TurtleWorld, zoom_level: f32) {
direction, direction,
} => { } => {
// Draw arc segments from start to current position // 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) => { _ if should_draw_tween_line(&tween.command) => {
// Draw straight line for other movement commands (use tween's current position) // 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( let geom = CircleGeometry::new(
tween.start_params.position, tween.start_params.position,
Radians::new(tween.start_params.heading), Radians::new(tween.start_params.heading),
*radius, radius.value(),
*direction, *direction,
); );
let elapsed = get_time() - tween.start_time; 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; let sweep_so_far = angle.as_radians().value() * eased_progress;
for pt in arc_points( for pt in arc_points(
geom.center, geom.center,
*radius, radius.value(),
geom.start_angle_from_center, geom.start_angle_from_center,
sweep_so_far, sweep_so_far,
samples_to_draw, samples_to_draw,
+10 -10
View File
@@ -242,7 +242,7 @@ pub(crate) fn record_fill_vertices_after_movement(
let geom = CircleGeometry::new( let geom = CircleGeometry::new(
start_state.position, start_state.position,
Radians::new(start_state.heading), Radians::new(start_state.heading),
*radius, radius.value(),
*direction, *direction,
); );
if let Some(ref mut fill_state) = filling { if let Some(ref mut fill_state) = filling {
@@ -252,7 +252,7 @@ pub(crate) fn record_fill_vertices_after_movement(
turtle_id, turtle_id,
center_x = geom.center.x, center_x = geom.center.x,
center_y = geom.center.y, center_y = geom.center.y,
radius, radius = radius.value(),
steps, steps,
num_samples, num_samples,
"Recording arc vertices" "Recording arc vertices"
@@ -268,8 +268,8 @@ pub(crate) fn record_fill_vertices_after_movement(
} }
}; };
let vertex = Coordinate::new( let vertex = Coordinate::new(
geom.center.x + radius * current_angle.cos(), geom.center.x + radius.value() * current_angle.cos(),
geom.center.y + radius * current_angle.sin(), geom.center.y + radius.value() * current_angle.sin(),
); );
tracing::trace!( tracing::trace!(
turtle_id, turtle_id,
@@ -347,12 +347,12 @@ pub(crate) fn tessellate_command(
let geom = CircleGeometry::new( let geom = CircleGeometry::new(
start.position, start.position,
Radians::new(start.heading), Radians::new(start.heading),
*radius, radius.value(),
*direction, *direction,
); );
let mesh = tessellation::tessellate_arc( let mesh = tessellation::tessellate_arc(
geom.center, geom.center,
*radius, radius.value(),
geom.start_angle_from_center.to_degrees(), geom.start_angle_from_center.to_degrees(),
angle.value(), angle.value(),
start.color, start.color,
@@ -402,7 +402,7 @@ pub(crate) fn push_svg_for_draw(
svg_log.push(SvgRecord::Arc { svg_log.push(SvgRecord::Arc {
start_position: start.position, start_position: start.position,
start_heading: start.heading, start_heading: start.heading,
radius: *radius, radius: radius.value(),
angle: *angle, angle: *angle,
direction: *direction, direction: *direction,
color: start.color, color: start.color,
@@ -474,7 +474,7 @@ pub(crate) fn execute_command_with_id(
mod tests { mod tests {
use super::*; use super::*;
use crate::commands::TurtleCommand; use crate::commands::TurtleCommand;
use crate::general::Degrees; use crate::general::{Degrees, Length};
use crate::shapes::TurtleShape; use crate::shapes::TurtleShape;
use crate::tweening::TweenController; use crate::tweening::TweenController;
@@ -509,7 +509,7 @@ mod tests {
assert_eq!(state.params.heading, 0.0); assert_eq!(state.params.heading, 0.0);
// Forward 100 - should move to (100, 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!( assert!(
(state.params.position.x - 100.0).abs() < 0.01, (state.params.position.x - 100.0).abs() < 0.01,
"After forward(100): x = {}", "After forward(100): x = {}",
@@ -544,7 +544,7 @@ mod tests {
); );
// Forward 50 - should move north (negative Y) to (100, -50) // 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!( assert!(
(state.params.position.x - 100.0).abs() < 0.01, (state.params.position.x - 100.0).abs() < 0.01,
"Final position: x = {} (expected 100.0)", "Final position: x = {} (expected 100.0)",
+1 -1
View File
@@ -59,7 +59,7 @@ where
let mut app = crate::TurtleApp::new().with_commands(turtle.build()); let mut app = crate::TurtleApp::new().with_commands(turtle.build());
app.set_all_turtles_speed(crate::AnimationSpeed::Instant(1000)); app.set_all_turtles_speed(crate::AnimationSpeed::Instant(1000));
while !app.all_animations_complete() { while !app.is_complete() {
app.update(); app.update();
} }
+72 -4
View File
@@ -1,13 +1,33 @@
//! Length type for distance measurements //! Length type for distance measurements
use super::Precision; 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); pub struct Length(pub Precision);
impl From<i16> for Length { impl Length {
fn from(i: i16) -> Self { /// Create a new `Length` from a raw value.
Self(Precision::from(i)) #[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<f32> for Length {
} }
} }
impl From<f64> for Length {
fn from(f: f64) -> Self {
Self(f as Precision)
}
}
impl From<i16> for Length {
fn from(i: i16) -> Self {
Self(Precision::from(i))
}
}
impl From<i32> for Length { impl From<i32> for Length {
fn from(i: i32) -> Self { fn from(i: i32) -> Self {
Self(i as Precision) Self(i as Precision)
} }
} }
impl From<usize> 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));
}
}
+1 -6
View File
@@ -106,6 +106,7 @@ impl TurtleApp {
filename: &str, filename: &str,
format: export::DrawingFormat, format: export::DrawingFormat,
) -> Result<(), export::ExportError> { ) -> Result<(), export::ExportError> {
let _ = filename;
match format { match format {
#[cfg(feature = "svg")] #[cfg(feature = "svg")]
export::DrawingFormat::Svg => { export::DrawingFormat::Svg => {
@@ -371,12 +372,6 @@ 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 /// Set the animation speed for all turtles
/// ///
/// # Arguments /// # Arguments
+12 -12
View File
@@ -180,7 +180,7 @@ impl TweenController {
calculate_circle_position( calculate_circle_position(
tween.start_params.position, tween.start_params.position,
Radians::new(tween.start_params.heading), Radians::new(tween.start_params.heading),
*radius, radius.value(),
angle_traveled, angle_traveled,
*direction, *direction,
) )
@@ -413,7 +413,7 @@ fn current_time() -> f64 {
mod tests { mod tests {
use super::*; use super::*;
use crate::commands::TurtleCommand; use crate::commands::TurtleCommand;
use crate::general::Degrees; use crate::general::{Degrees, Length};
use crate::state::TurtleParams; use crate::state::TurtleParams;
fn make_test_params() -> TurtleParams { fn make_test_params() -> TurtleParams {
@@ -433,10 +433,10 @@ mod tests {
#[test] #[test]
fn test_instant_mode_drains_queue() { fn test_instant_mode_drains_queue() {
let mut queue = CommandQueue::new(); 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::Turn(Degrees::new(90.0)));
queue.push(TurtleCommand::PenUp); 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)); let mut controller = TweenController::new(queue, AnimationSpeed::Instant(100));
assert_eq!(controller.queue.len(), 4); assert_eq!(controller.queue.len(), 4);
@@ -467,7 +467,7 @@ mod tests {
let mut batch = CommandQueue::new(); let mut batch = CommandQueue::new();
batch.push(TurtleCommand::Reset); batch.push(TurtleCommand::Reset);
batch.push(TurtleCommand::PenDown); 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))); batch.push(TurtleCommand::Turn(Degrees::new(30.0)));
controller.append_commands(batch); controller.append_commands(batch);
@@ -485,11 +485,11 @@ mod tests {
fn test_instant_mode_respects_batch_limit_and_retains_pending() { fn test_instant_mode_respects_batch_limit_and_retains_pending() {
let mut queue = CommandQueue::new(); let mut queue = CommandQueue::new();
// 5 drawing commands // 5 drawing commands
queue.push(TurtleCommand::Move(10.0)); queue.push(TurtleCommand::Move(Length::new(10.0)));
queue.push(TurtleCommand::Move(20.0)); queue.push(TurtleCommand::Move(Length::new(20.0)));
queue.push(TurtleCommand::Move(30.0)); queue.push(TurtleCommand::Move(Length::new(30.0)));
queue.push(TurtleCommand::Move(40.0)); queue.push(TurtleCommand::Move(Length::new(40.0)));
queue.push(TurtleCommand::Move(50.0)); queue.push(TurtleCommand::Move(Length::new(50.0)));
// Limit to 2 draw calls per frame // Limit to 2 draw calls per frame
let mut controller = TweenController::new(queue, AnimationSpeed::Instant(2)); let mut controller = TweenController::new(queue, AnimationSpeed::Instant(2));
@@ -520,8 +520,8 @@ mod tests {
#[test] #[test]
fn test_animated_mode_pops_to_current_tween() { fn test_animated_mode_pops_to_current_tween() {
let mut queue = CommandQueue::new(); let mut queue = CommandQueue::new();
queue.push(TurtleCommand::Move(100.0)); queue.push(TurtleCommand::Move(Length::new(100.0)));
queue.push(TurtleCommand::Move(50.0)); queue.push(TurtleCommand::Move(Length::new(50.0)));
let mut controller = TweenController::new( let mut controller = TweenController::new(
queue, queue,