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 || {
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();
}
+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)`
- ⚡ **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();
+29 -24
View File
@@ -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);
+1 -1
View File
@@ -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();
+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")]
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");
}
+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
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<T>(&mut self, distance: T) -> &mut Self
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
}
@@ -61,9 +61,9 @@ pub trait DirectionalMovement: WithCommands {
/// ```
fn backward<T>(&mut self, distance: T) -> &mut Self
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
}
@@ -159,10 +159,10 @@ pub trait CurvedMovement: WithCommands {
/// ```
fn circle_left<R, A>(&mut self, radius: R, angle: A, steps: usize) -> &mut Self
where
R: Into<Precision>,
R: Into<Length>,
A: Into<Degrees>,
{
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<R, A>(&mut self, radius: R, angle: A, steps: usize) -> &mut Self
where
R: Into<Precision>,
R: Into<Length>,
A: Into<Degrees>,
{
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<T: Into<Degrees>>(&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
}
+83 -8
View File
@@ -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(&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
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,
+1 -4
View File
@@ -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(
+3 -3
View File
@@ -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,
+10 -10
View File
@@ -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)",
+1 -1
View File
@@ -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();
}
+72 -4
View File
@@ -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<i16> 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<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 {
fn from(i: i32) -> Self {
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,
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
+12 -12
View File
@@ -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,