Files
turtle/turtle-lib/src/command_behavior.rs
T
dietrich 76d07ab009 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.
2026-09-19 07:51:55 +02:00

226 lines
8.8 KiB
Rust

//! Centralised behavioural contract for `TurtleCommand`.
//!
//! All knowledge about what a command does to `TurtleParams`, how long it
//! animates, and whether it produces a drawable stroke lives here.
//!
//! Adding a new `TurtleCommand` variant requires editing this file (and
//! `execute_command_side_effects` / `tessellate_command` in `execution.rs`
//! if the variant has side effects or produces a mesh).
use crate::circle_geometry::{CircleDirection, CircleGeometry};
use crate::commands::TurtleCommand;
use crate::general::{AnimationSpeed, Radians};
use crate::state::TurtleParams;
use crate::tweening::normalize_angle;
use macroquad::prelude::vec2;
impl TurtleCommand {
/// Apply this command's effect to `params` in place.
///
/// This is the **single source of truth** for what a command changes in
/// `TurtleParams`. Used by:
/// - `execute_command()` — instant-mode path, after side-effects return `false`
/// - `TweenController::calculate_target_state()` — animated-mode target computation
///
/// Variants handled by `execute_command_side_effects` (`BeginFill`, `EndFill`,
/// `PenUp`, `PenDown`, `WriteText`, `Reset`) are included here so that
/// `calculate_target_state` can produce a correct tween target. In the
/// `execute_command()` call path those variants never reach this method because
/// `execute_command_side_effects` returns `true` and the caller returns early —
/// there is no double-application.
pub(crate) fn apply_to_params(&self, params: &mut TurtleParams) {
match self {
TurtleCommand::Move(dist) => {
let dx = dist.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) => {
params.heading = normalize_angle(params.heading + angle.as_radians().value());
}
TurtleCommand::Circle {
radius,
angle,
direction,
..
} => {
let geom = CircleGeometry::new(
params.position,
Radians::new(params.heading),
radius.value(),
*direction,
);
let angle_rad = angle.as_radians().value();
params.position = geom.position_at_angle(angle_rad);
params.heading = normalize_angle(match direction {
CircleDirection::Left => params.heading - angle_rad,
CircleDirection::Right => params.heading + angle_rad,
});
}
TurtleCommand::Goto(coord) => {
// Y-flip: turtle graphics Y+ = up; Macroquad Y+ = down
params.position = vec2(coord.x, -coord.y);
}
TurtleCommand::SetHeading(heading) => {
params.heading = normalize_angle(-heading.as_radians().value());
}
TurtleCommand::SetColor(color) => {
params.color = *color;
}
TurtleCommand::SetFillColor(color) => {
params.fill_color = *color;
}
TurtleCommand::SetPenWidth(width) => {
params.pen_width = *width;
}
TurtleCommand::SetSpeed(speed) => {
params.speed = *speed;
}
TurtleCommand::SetShape(shape) => {
params.shape = shape.clone();
}
TurtleCommand::PenUp => {
params.pen_down = false;
}
TurtleCommand::PenDown => {
params.pen_down = true;
}
TurtleCommand::ShowTurtle => {
params.visible = true;
}
TurtleCommand::HideTurtle => {
params.visible = false;
}
TurtleCommand::Reset => {
*params = TurtleParams::default();
}
// Fill/text commands do not change TurtleParams for tweening purposes;
// their effects are handled entirely by execute_command_side_effects.
TurtleCommand::BeginFill | TurtleCommand::EndFill | TurtleCommand::WriteText { .. } => {
}
}
}
/// Duration in seconds for this command's animation at the given speed.
///
/// Returns `0.01` (minimum) for commands that have no animated component.
/// This is the **single source of truth**; replaces
/// `TweenController::calculate_duration_with_state` in `tweening.rs`.
pub(crate) fn animation_duration(&self, params: &TurtleParams, speed: AnimationSpeed) -> f64 {
let AnimationSpeed::Animated(mut spd) = speed else {
// Instant mode — duration is irrelevant; return the minimum so tweener
// infrastructure still has a valid duration if called accidentally.
return f64::from(0.01_f32);
};
// Exponential speed scaling for high values (matches original behaviour)
if spd > 100.0 {
spd *= spd / 100.0;
}
let base: f32 = match self {
TurtleCommand::Move(dist) => dist.value().abs() / spd,
TurtleCommand::Turn(angle) => angle.value().abs() / (spd * 1.8),
TurtleCommand::Circle { radius, angle, .. } => {
let arc_length = radius.value() * angle.as_radians().value().abs();
arc_length / spd
}
TurtleCommand::Goto(target) => {
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,
};
f64::from(base.max(0.01))
}
/// Whether executing this command (when pen is down) produces a stroke or fill mesh.
///
/// This is the **single source of truth**; replaces
/// `TweenController::command_creates_drawing` in `tweening.rs`.
#[must_use]
pub(crate) fn produces_drawing(&self) -> bool {
matches!(
self,
TurtleCommand::Move(_) | TurtleCommand::Circle { .. } | TurtleCommand::Goto(_)
)
}
}
#[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
);
}
}