From 4752075d08f4ff93d97933cc2f8d2181f3cb9e36 Mon Sep 17 00:00:00 2001 From: Franz Dietrich Date: Fri, 18 Sep 2026 20:57:00 +0200 Subject: [PATCH 01/12] remove custom linker --- .cargo/config.toml | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 .cargo/config.toml diff --git a/.cargo/config.toml b/.cargo/config.toml deleted file mode 100644 index 2c3dbcd..0000000 --- a/.cargo/config.toml +++ /dev/null @@ -1,3 +0,0 @@ -[target.x86_64-unknown-linux-gnu] -linker = "clang" -rustflags = ["-C", "link-arg=-fuse-ld=/usr/bin/mold"] \ No newline at end of file From 1d0e149d22ceba45067b173c65382c523ff1290c Mon Sep 17 00:00:00 2001 From: Franz Dietrich Date: Fri, 18 Sep 2026 21:12:30 +0200 Subject: [PATCH 02/12] Fix Unbounded Memory Growth (Memory Leak) in TweenController In [`TweenController`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/tweening.rs), commands are currently accumulated in `queue: CommandQueue` (which wraps a `Vec`). When commands execute in `update()`, an internal `cursor: usize` increments, but executed commands are never removed. In streaming and threaded applications (such as [`examples/clock_threaded.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/examples/clock_threaded.rs)), new commands are appended continuously, causing `queue` to grow unboundedly over time and exhaust memory. We will replace `queue: CommandQueue` and `cursor: usize` with `queue: std::collections::VecDeque` inside `TweenController`. Consumed commands will be removed immediately using `pop_front()`, completely freeing memory as commands complete. > [!NOTE] > All design decisions were aligned during the `/grill-me` session: > - `TweenController` will internally store `std::collections::VecDeque` and consume items via `pop_front()`. > - `self.cursor` is eliminated entirely. > - `CommandQueue` remains the public API container for passing batches of commands into `TweenController::new` and `TweenController::append_commands`. > - `TurtleCommand::Reset` retains its normal semantics (clearing drawings and parameters without clearing future queued commands), as continuous `pop_front()` already discards consumed commands. > - Standard `VecDeque` capacity behavior is maintained (no auto-shrinking) to prevent reallocation jitter in streaming scenarios. [tweening.rs](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/tweening.rs) - Import `std::collections::VecDeque`. - Update `TweenController` struct definition: - Replace `queue: CommandQueue` and `cursor: usize` with `queue: VecDeque`. - In `TweenController::new`: - Initialize `queue` using `queue.into_iter().collect()`. - Remove `cursor` initialization. - In `TweenController::append_commands`: - Extend `self.queue` directly from `new_queue: CommandQueue`. - In `TweenController::update`: - **Instant mode**: Replace `while let Some(command) = self.queue.get(self.cursor).cloned()` with `while let Some(command) = self.queue.pop_front()`, removing `self.cursor += 1` and unnecessary command cloning. - **Animated mode**: Replace `if let Some(command) = self.queue.get(self.cursor).cloned()` with `if let Some(command) = self.queue.pop_front()`, removing `self.cursor += 1` and unnecessary command cloning. - In `TweenController::is_complete`: - Update check to `self.current_tween.is_none() && self.queue.is_empty()`. - Add unit tests in `#[cfg(test)] mod tests`: - Verify queue drains to 0 length in Instant mode. - Verify queue drains in Animated mode with multiple frames. - Verify streaming `append_commands` calls continuously drain and do not accumulate. - Verify `is_complete()` transitions correctly. --- [commands.rs](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/commands.rs) - Update doc comments referencing `TweenController`'s cursor to reflect the queue consumption model. - Run `cargo test -p turtle-lib --lib tweening` to execute the new unit tests. - Run `cargo test -p turtle-lib` to ensure all existing tests in `turtle-lib` continue to pass. - Run `cargo check --examples` to ensure all examples (including `clock_threaded.rs`) compile cleanly. - Review memory behavior or run `examples/clock_threaded.rs` in debug to confirm streaming updates run smoothly. --- turtle-lib/src/commands.rs | 8 +- turtle-lib/src/tweening.rs | 179 +++++++++++++++++++++++++++++++++---- 2 files changed, 165 insertions(+), 22 deletions(-) diff --git a/turtle-lib/src/commands.rs b/turtle-lib/src/commands.rs index 37dd17a..1f91773 100644 --- a/turtle-lib/src/commands.rs +++ b/turtle-lib/src/commands.rs @@ -60,8 +60,8 @@ pub enum TurtleCommand { /// A pure-data sequence of turtle commands. /// /// `CommandQueue` is intentionally *not* an `Iterator` — it carries no cursor -/// state. Execution state ("which command are we on?") belongs to the -/// consumer; `TweenController` owns the cursor that walks this queue. +/// state. Execution state belongs to the consumer; `TweenController` consumes +/// this queue as commands execute. #[derive(Clone, Debug)] pub struct CommandQueue { commands: Vec, @@ -115,8 +115,8 @@ impl Default for CommandQueue { /// Consuming iteration — yields every command in order. /// /// This is used by `CommandQueue::extend` and `TweenController::append_commands` -/// to drain one queue into another. It does *not* imply that `CommandQueue` -/// itself is stateful; the cursor always lives in the consumer. +/// to drain one queue into another. It does *not* imply that `CommandQueue` +/// itself is stateful; execution state is managed by the consumer. impl IntoIterator for CommandQueue { type Item = TurtleCommand; type IntoIter = std::vec::IntoIter; diff --git a/turtle-lib/src/tweening.rs b/turtle-lib/src/tweening.rs index cc6cb59..f93176f 100644 --- a/turtle-lib/src/tweening.rs +++ b/turtle-lib/src/tweening.rs @@ -5,6 +5,7 @@ use crate::commands::{CommandQueue, TurtleCommand}; use crate::general::{AnimationSpeed, Radians}; use crate::state::{DrawCommand, FillState, TurtleParams}; use macroquad::prelude::*; +use std::collections::VecDeque; use tween::{CubicInOut, TweenValue, Tweener}; // Newtype wrapper for Vec2 to implement TweenValue @@ -46,11 +47,7 @@ impl From for Vec2 { /// Controls tweening of turtle commands #[derive(Clone, Debug, Default)] pub(crate) struct TweenController { - queue: CommandQueue, - /// Cursor into `queue` — tracks which command executes next. - /// Lives here, not in `CommandQueue`, so that cloning or appending to the - /// queue never silently resets or mid-stream-shifts the execution position. - cursor: usize, + queue: VecDeque, current_tween: Option, speed: AnimationSpeed, } @@ -74,8 +71,7 @@ impl TweenController { #[must_use] pub fn new(queue: CommandQueue, speed: AnimationSpeed) -> Self { Self { - queue, - cursor: 0, + queue: queue.into_iter().collect(), current_tween: None, speed, } @@ -87,8 +83,8 @@ impl TweenController { /// Append commands to the queue. /// - /// The cursor is **not** reset — commands already consumed remain consumed, - /// and the new commands are picked up naturally as the cursor advances. + /// Consumed commands are removed as they execute, and new commands + /// are queued at the back. pub fn append_commands(&mut self, new_queue: CommandQueue) { self.queue.extend(new_queue); } @@ -117,9 +113,8 @@ impl TweenController { Vec::new(); let mut draw_call_count = 0; - // Advance cursor through the queue for each command consumed - while let Some(command) = self.queue.get(self.cursor).cloned() { - self.cursor += 1; + // Consume commands from the front of the queue + while let Some(command) = self.queue.pop_front() { // Handle SetSpeed command to potentially switch modes if let TurtleCommand::SetSpeed(new_speed) = &command { params.speed = *new_speed; @@ -168,7 +163,7 @@ impl TweenController { // Process current tween if let Some(ref mut tween) = self.current_tween { - let elapsed = get_time() - tween.start_time; + let elapsed = current_time() - tween.start_time; // Use tweeners to calculate current values // For circles, calculate position along the arc instead of straight line @@ -273,9 +268,7 @@ impl TweenController { } // Start next tween - if let Some(command) = self.queue.get(self.cursor).cloned() { - self.cursor += 1; - + if let Some(command) = self.queue.pop_front() { // Handle commands that should execute immediately (no animation) match &command { TurtleCommand::SetSpeed(new_speed) => { @@ -325,7 +318,7 @@ impl TweenController { self.current_tween = Some(CommandTween { turtle_id, command, - start_time: get_time(), + start_time: current_time(), duration, start_params: params.clone(), target_params: target_state.clone(), @@ -342,7 +335,7 @@ impl TweenController { #[must_use] pub fn is_complete(&self) -> bool { - self.current_tween.is_none() && self.cursor >= self.queue.len() + self.current_tween.is_none() && self.queue.is_empty() } /// Get the current active tween if one is in progress @@ -400,3 +393,153 @@ pub(crate) fn normalize_angle(angle: f32) -> f32 { normalized } + +#[inline] +fn current_time() -> f64 { + #[cfg(test)] + { + use std::sync::OnceLock; + use std::time::Instant; + static START: OnceLock = OnceLock::new(); + START.get_or_init(Instant::now).elapsed().as_secs_f64() + } + #[cfg(not(test))] + { + macroquad::time::get_time() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::TurtleCommand; + use crate::general::Degrees; + use crate::state::TurtleParams; + + 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: crate::shapes::TurtleShape::turtle(), + speed: AnimationSpeed::Instant(100), + } + } + + #[test] + fn test_instant_mode_drains_queue() { + let mut queue = CommandQueue::new(); + queue.push(TurtleCommand::Move(100.0)); + queue.push(TurtleCommand::Turn(Degrees::new(90.0))); + queue.push(TurtleCommand::PenUp); + queue.push(TurtleCommand::Move(50.0)); + + let mut controller = TweenController::new(queue, AnimationSpeed::Instant(100)); + assert_eq!(controller.queue.len(), 4); + assert!(!controller.is_complete()); + + let mut params = make_test_params(); + let mut filling = None; + let mut commands = Vec::new(); + let mut svg_log = crate::state::SvgLog::default(); + + let completed = controller.update(0, &mut params, &mut filling, &mut commands, &mut svg_log); + + assert_eq!(controller.queue.len(), 0, "Queue must be empty after instant update"); + assert!(controller.is_complete(), "Controller must be complete when queue is drained"); + assert!(!completed.is_empty()); + } + + #[test] + fn test_streaming_append_commands_does_not_accumulate() { + let mut controller = TweenController::new(CommandQueue::new(), AnimationSpeed::Instant(100)); + let mut params = make_test_params(); + let mut filling = None; + let mut commands = Vec::new(); + let mut svg_log = crate::state::SvgLog::default(); + + // Simulate streaming commands across 50 frames (like clock_threaded) + for _ in 0..50 { + let mut batch = CommandQueue::new(); + batch.push(TurtleCommand::Reset); + batch.push(TurtleCommand::PenDown); + batch.push(TurtleCommand::Move(10.0)); + batch.push(TurtleCommand::Turn(Degrees::new(30.0))); + + controller.append_commands(batch); + assert_eq!(controller.queue.len(), 4); + + controller.update(0, &mut params, &mut filling, &mut commands, &mut svg_log); + + // Verify queue is pruned back to 0 — no memory leak / accumulation + assert_eq!(controller.queue.len(), 0); + assert!(controller.is_complete()); + } + } + + #[test] + 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)); + + // Limit to 2 draw calls per frame + let mut controller = TweenController::new(queue, AnimationSpeed::Instant(2)); + let mut params = make_test_params(); + let mut filling = None; + let mut commands = Vec::new(); + let mut svg_log = crate::state::SvgLog::default(); + + // Frame 1: processes 2 drawing commands + let completed1 = controller.update(0, &mut params, &mut filling, &mut commands, &mut svg_log); + assert_eq!(completed1.len(), 2); + assert_eq!(controller.queue.len(), 3, "3 commands should remain in queue"); + assert!(!controller.is_complete()); + + // Frame 2: processes next 2 drawing commands + let completed2 = controller.update(0, &mut params, &mut filling, &mut commands, &mut svg_log); + assert_eq!(completed2.len(), 2); + assert_eq!(controller.queue.len(), 1, "1 command should remain in queue"); + assert!(!controller.is_complete()); + + // Frame 3: processes last drawing command + let completed3 = controller.update(0, &mut params, &mut filling, &mut commands, &mut svg_log); + assert_eq!(completed3.len(), 1); + assert_eq!(controller.queue.len(), 0, "Queue must be completely drained"); + assert!(controller.is_complete()); + } + + #[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)); + + let mut controller = TweenController::new( + queue, + AnimationSpeed::Animated(100.0), + ); + assert_eq!(controller.queue.len(), 2); + assert!(!controller.is_complete()); + + let mut params = make_test_params(); + let mut filling = None; + let mut commands = Vec::new(); + let mut svg_log = crate::state::SvgLog::default(); + + // Calling update should pop the first command into current_tween + controller.update(0, &mut params, &mut filling, &mut commands, &mut svg_log); + + assert_eq!(controller.queue.len(), 1, "First command must be popped into current_tween"); + assert!(controller.current_tween().is_some()); + assert!(!controller.is_complete()); + } +} From ea9a8839c4aec7c4041038cc2578f80f24e0f9a0 Mon Sep 17 00:00:00 2001 From: Franz Dietrich Date: Fri, 18 Sep 2026 21:34:07 +0200 Subject: [PATCH 03/12] Eliminate Per-Frame Allocation in Rendering Loop We resolved **Issue 4.3 (Severe Per-Frame Allocation in Rendering Loop)** by removing the intermediate `MeshData` structure and storing `macroquad::prelude::Mesh` directly in [`DrawCommand::Mesh`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/state.rs). [`DrawCommand`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/state.rs) - Dropped `Clone` and `Debug` derives from [`Turtle`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/state.rs#L58) and [`DrawCommand`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/state.rs#L351), accommodating Macroquad's `Mesh` which does not implement these traits. - Changed [`DrawCommand::Mesh`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/state.rs#L353) to the tuple variant `DrawCommand::Mesh(macroquad::prelude::Mesh)`. - Deleted `struct MeshData` and its `to_mesh` method. [`tessellation.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/tessellation.rs) - Renamed `build_mesh_data` to [`build_mesh`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/tessellation.rs#L35) returning `macroquad::prelude::Mesh { vertices, indices, texture: None }` directly. - Updated [`tessellate_polygon`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/tessellation.rs#L71), [`tessellate_multi_contour`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/tessellation.rs#L116), [`tessellate_stroke`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/tessellation.rs#L214), [`tessellate_circle`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/tessellation.rs#L259), and [`tessellate_arc`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/tessellation.rs#L308) to return `Result>`. [`drawing.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/drawing.rs) - In the main drawing loop ([`drawing.rs:36-38`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/drawing.rs#L36-L38)): ```rust DrawCommand::Mesh(mesh) => { draw_mesh(mesh); } ``` Now borrows `&Mesh` directly from the command vector with **0 heap allocations and 0 vector clones per frame**. - Updated fill preview ([line 228](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/drawing.rs#L228)), tween arc/center indicators ([lines 309, 329](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/drawing.rs#L309)), and turtle shape drawing ([line 349](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/drawing.rs#L349)) to pass `&mesh` directly to `draw_mesh(&mesh)` without vector cloning. [`execution.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/execution.rs) - Updated [`commands.push(DrawCommand::Mesh(mesh))`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/execution.rs#L135) and [`tessellate_command`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/execution.rs#L337). - Updated [`test_forward_left_forward`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/execution.rs#L487) to instantiate `mut state` directly without dummy cloning. --- ```bash cargo test --lib ``` Output: ``` running 10 tests test circle_geometry::tests::test_circle_left_geometry ... ok test circle_geometry::tests::test_circle_right_geometry ... ok test general::angle::tests::degrees_to_radians_roundtrip ... ok test general::angle::tests::from_integer ... ok test execution::tests::test_forward_left_forward ... ok test general::angle::tests::negation ... ok test tweening::tests::test_animated_mode_pops_to_current_tween ... ok test tweening::tests::test_instant_mode_drains_queue ... ok test tweening::tests::test_instant_mode_respects_batch_limit_and_retains_pending ... ok test tweening::tests::test_streaming_append_commands_does_not_accumulate ... ok test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s ``` All targets verified with `cargo check --all-targets` and `cargo clippy --lib`. --- turtle-lib/src/drawing.rs | 20 ++++++++++---------- turtle-lib/src/execution.rs | 29 +++++++---------------------- turtle-lib/src/state.rs | 22 +--------------------- turtle-lib/src/tessellation.rs | 30 +++++++++++++++--------------- 4 files changed, 33 insertions(+), 68 deletions(-) diff --git a/turtle-lib/src/drawing.rs b/turtle-lib/src/drawing.rs index c31c095..1d2971d 100644 --- a/turtle-lib/src/drawing.rs +++ b/turtle-lib/src/drawing.rs @@ -33,8 +33,8 @@ pub(crate) fn render_world_with_tweens(world: &TurtleWorld, zoom_level: f32) { for turtle in &world.turtles { for cmd in &turtle.commands { match cmd { - DrawCommand::Mesh { data } => { - draw_mesh(&data.to_mesh()); + DrawCommand::Mesh(mesh) => { + draw_mesh(mesh); } DrawCommand::Text { text, @@ -224,8 +224,8 @@ pub(crate) fn render_world_with_tweens(world: &TurtleWorld, zoom_level: f32) { &all_contours, fill_state.fill_color, ) { - Ok(mesh_data) => { - draw_mesh(&mesh_data.to_mesh()); + Ok(mesh) => { + draw_mesh(&mesh); } Err(e) => { tracing::error!("Failed to tessellate fill preview: {:?}", e); @@ -304,9 +304,9 @@ fn draw_tween_arc( ); // Draw center using Lyon tessellation this helps visualizing what is done. - if let Ok(mesh_data) = crate::tessellation::tessellate_circle(geom.center, 5.0, GRAY, true, 1.0) + if let Ok(mesh) = crate::tessellation::tessellate_circle(geom.center, 5.0, GRAY, true, 1.0) { - draw_mesh(&mesh_data.to_mesh()); + draw_mesh(&mesh); } // Calculate how much of the arc we've traveled based on tween progress @@ -316,7 +316,7 @@ fn draw_tween_arc( let progress = CubicInOut.tween(1.0, t as f32); // tween from 0 to 1 // Use Lyon to tessellate and draw the partial arc - if let Ok(mesh_data) = crate::tessellation::tessellate_arc( + if let Ok(mesh) = crate::tessellation::tessellate_arc( geom.center, radius, geom.start_angle_from_center.to_degrees(), @@ -326,7 +326,7 @@ fn draw_tween_arc( ((steps as f32 * progress).ceil() as usize).max(1), direction, ) { - draw_mesh(&mesh_data.to_mesh()); + draw_mesh(&mesh); } } @@ -343,10 +343,10 @@ pub(crate) fn draw_turtle(turtle_params: &TurtleParams) { .collect(); // Use Lyon for turtle shape too - if let Ok(mesh_data) = + if let Ok(mesh) = tessellation::tessellate_polygon(&absolute_vertices, Color::new(0.0, 0.5, 1.0, 1.0)) { - draw_mesh(&mesh_data.to_mesh()); + draw_mesh(&mesh); } else { // Fallback to simple triangle fan if Lyon fails let first = absolute_vertices[0]; diff --git a/turtle-lib/src/execution.rs b/turtle-lib/src/execution.rs index 57c005f..7e0ffd3 100644 --- a/turtle-lib/src/execution.rs +++ b/turtle-lib/src/execution.rs @@ -123,7 +123,7 @@ pub(crate) fn execute_command_side_effects( } if !fill_state.contours.is_empty() { - if let Ok(mesh_data) = tessellation::tessellate_multi_contour( + if let Ok(mesh) = tessellation::tessellate_multi_contour( &fill_state.contours, fill_state.fill_color, ) { @@ -132,7 +132,7 @@ pub(crate) fn execute_command_side_effects( contours = fill_state.contours.len(), "Successfully created fill mesh - persisting to commands" ); - commands.push(DrawCommand::Mesh { data: mesh_data }); + commands.push(DrawCommand::Mesh(mesh)); #[cfg(feature = "svg")] svg_log.push(crate::state::SvgRecord::Fill { contours: fill_state.contours, @@ -326,7 +326,7 @@ pub(crate) fn tessellate_command( match command { TurtleCommand::Move(_) | TurtleCommand::Goto(_) => { - let mesh_data = tessellation::tessellate_stroke( + let mesh = tessellation::tessellate_stroke( &[start.position, end_position], start.color, start.pen_width, @@ -334,7 +334,7 @@ pub(crate) fn tessellate_command( ) .ok()?; - Some(DrawCommand::Mesh { data: mesh_data }) + Some(DrawCommand::Mesh(mesh)) } TurtleCommand::Circle { @@ -350,7 +350,7 @@ pub(crate) fn tessellate_command( *radius, *direction, ); - let mesh_data = tessellation::tessellate_arc( + let mesh = tessellation::tessellate_arc( geom.center, *radius, geom.start_angle_from_center.to_degrees(), @@ -362,7 +362,7 @@ pub(crate) fn tessellate_command( ) .ok()?; - Some(DrawCommand::Mesh { data: mesh_data }) + Some(DrawCommand::Mesh(mesh)) } // `produces_drawing()` guards entry — this arm is only reachable if @@ -484,7 +484,7 @@ mod tests { // the turtle ends up at (100, -50) from initial position (0, 0) use crate::state::TurtleParams; - let state = Turtle { + let mut state = Turtle { turtle_id: 0, params: TurtleParams { position: vec2(0.0, 0.0), @@ -503,21 +503,6 @@ mod tests { tween_controller: TweenController::default(), }; - // We'll use a dummy world but won't actually call drawing commands - let world = TurtleWorld { - turtles: vec![state.clone()], - camera: macroquad::camera::Camera2D { - zoom: vec2(1.0, 1.0), - target: vec2(0.0, 0.0), - offset: vec2(0.0, 0.0), - rotation: 0.0, - render_target: None, - viewport: None, - }, - background_color: Color::new(1.0, 1.0, 1.0, 1.0), - }; - let mut state = world.turtles[0].clone(); - // Initial state: position (0, 0), heading 0 (east) assert_eq!(state.params.position.x, 0.0); assert_eq!(state.params.position.y, 0.0); diff --git a/turtle-lib/src/state.rs b/turtle-lib/src/state.rs index fb58ba6..e84126c 100644 --- a/turtle-lib/src/state.rs +++ b/turtle-lib/src/state.rs @@ -55,7 +55,6 @@ impl Default for TurtleParams { } /// State of a single turtle -#[derive(Clone, Debug)] pub(crate) struct Turtle { pub(crate) turtle_id: usize, pub(crate) params: TurtleParams, @@ -349,30 +348,11 @@ pub(crate) enum SvgRecord { }, } -/// Cached mesh data that can be cloned and converted to Mesh when needed -#[derive(Clone, Debug)] -pub(crate) struct MeshData { - pub(crate) vertices: Vec, - pub(crate) indices: Vec, -} - -impl MeshData { - #[must_use] - pub fn to_mesh(&self) -> macroquad::prelude::Mesh { - macroquad::prelude::Mesh { - vertices: self.vertices.clone(), - indices: self.indices.clone(), - texture: None, - } - } -} - /// Drawable elements in the world. /// All drawing is done via Lyon-tessellated meshes for consistency and quality. -#[derive(Clone, Debug)] pub(crate) enum DrawCommand { /// Pre-tessellated mesh data (lines, arcs, circles, polygons — all use this). - Mesh { data: MeshData }, + Mesh(macroquad::prelude::Mesh), /// Text rendering command. Text { text: String, diff --git a/turtle-lib/src/tessellation.rs b/turtle-lib/src/tessellation.rs index 196acb4..c30961b 100644 --- a/turtle-lib/src/tessellation.rs +++ b/turtle-lib/src/tessellation.rs @@ -3,7 +3,6 @@ //! This module provides helper functions to tessellate paths using Lyon, //! which replaces the manual triangulation with GPU-optimized tessellation. -use crate::state::MeshData; use lyon::math::{point, Point}; use lyon::path::{LineCap, LineJoin, Path}; use lyon::tessellation::{ @@ -31,13 +30,13 @@ pub(crate) struct SimpleVertex { pub(crate) position: [f32; 2], } -/// Build mesh data from Lyon tessellation +/// Build mesh from Lyon tessellation #[must_use] -pub(crate) fn build_mesh_data( +pub(crate) fn build_mesh( vertices: &[SimpleVertex], indices: &[u16], color: Color, -) -> MeshData { +) -> Mesh { let verts: Vec = vertices .iter() .map(|v| Vertex { @@ -53,9 +52,10 @@ pub(crate) fn build_mesh_data( }) .collect(); - MeshData { + Mesh { vertices: verts, indices: indices.to_vec(), + texture: None, } } @@ -69,7 +69,7 @@ pub(crate) fn build_mesh_data( pub(crate) fn tessellate_polygon( vertices: &[Vec2], color: Color, -) -> Result> { +) -> Result> { if vertices.is_empty() { return Err("No vertices provided".into()); } @@ -96,7 +96,7 @@ pub(crate) fn tessellate_polygon( }), )?; - Ok(build_mesh_data( + Ok(build_mesh( &geometry.vertices, &geometry.indices, color, @@ -114,7 +114,7 @@ pub(crate) fn tessellate_polygon( pub(crate) fn tessellate_multi_contour( contours: &[Vec], color: Color, -) -> Result> { +) -> Result> { if contours.is_empty() { return Err("No contours provided".into()); } @@ -195,7 +195,7 @@ pub(crate) fn tessellate_multi_contour( } } - Ok(build_mesh_data( + Ok(build_mesh( &geometry.vertices, &geometry.indices, color, @@ -212,7 +212,7 @@ pub(crate) fn tessellate_stroke( color: Color, width: f32, closed: bool, -) -> Result> { +) -> Result> { if vertices.is_empty() { return Err("No vertices provided".into()); } @@ -241,7 +241,7 @@ pub(crate) fn tessellate_stroke( }), )?; - Ok(build_mesh_data( + Ok(build_mesh( &geometry.vertices, &geometry.indices, color, @@ -259,7 +259,7 @@ pub(crate) fn tessellate_circle( color: Color, filled: bool, stroke_width: f32, -) -> Result> { +) -> Result> { let mut builder = Path::builder(); builder.add_circle(to_lyon_point(center), radius, lyon::path::Winding::Positive); let path = builder.build(); @@ -286,7 +286,7 @@ pub(crate) fn tessellate_circle( )?; } - Ok(build_mesh_data( + Ok(build_mesh( &geometry.vertices, &geometry.indices, color, @@ -308,7 +308,7 @@ pub(crate) fn tessellate_arc( stroke_width: f32, segments: usize, direction: crate::circle_geometry::CircleDirection, -) -> Result> { +) -> Result> { use crate::circle_geometry::arc_points; let start_angle = start_angle_degrees.to_radians(); @@ -352,7 +352,7 @@ pub(crate) fn tessellate_arc( }), )?; - Ok(build_mesh_data( + Ok(build_mesh( &geometry.vertices, &geometry.indices, color, From c12cb7c3ee6c990c0d1b362b7e8d144f77c20071 Mon Sep 17 00:00:00 2001 From: Franz Dietrich Date: Fri, 18 Sep 2026 21:50:09 +0200 Subject: [PATCH 04/12] update agents.md --- .github/copilot-instructions.md => AGENTS.md | 43 ++++++++++---------- 1 file changed, 22 insertions(+), 21 deletions(-) rename .github/copilot-instructions.md => AGENTS.md (75%) diff --git a/.github/copilot-instructions.md b/AGENTS.md similarity index 75% rename from .github/copilot-instructions.md rename to AGENTS.md index 5251f3b..d49c632 100644 --- a/.github/copilot-instructions.md +++ b/AGENTS.md @@ -8,21 +8,23 @@ Rust workspace with turtle graphics implementations. **Primary focus: `turtle-li ``` turtlers/ ├── turtle-lib/ # MAIN LIBRARY - Macroquad + Lyon (focus here) -├── turtle-lib-macros/ # Proc macro for turtle_main -└── examples/ # 15+ examples including threading patterns +│ └── examples/ # 30 examples including threading patterns +└── turtle-lib-macros/ # Proc macro for turtle_main ``` ## Architecture (`turtle-lib`) -### Core Design Pattern: Persistent Controllers + Command Queues +### Core Design Pattern: Turtle Entities + Persistent Animation Controllers - **Builder API** (`TurtlePlan`) accumulates commands into immutable `CommandQueue` -- **TurtleApp** maintains persistent `Vec` (one per turtle with embedded turtle_id) -- **TweenController** manages command execution and animation state +- **TurtleWorld** maintains persistent `Vec` (`world.turtles`), each encapsulating state and a `TweenController` +- **TweenController** manages command execution, queue consumption, and animation interpolation per turtle - **Lyon Tessellation** converts all primitives to GPU meshes - **Multi-Turtle** support: Create multiple turtles with `add_turtle()` or threading channels -### Key Architectural Decision: Turtle ID Storage -**Critical**: After recent refactoring, `turtle_id` is now **stored in TweenController** (not derived from Vec index). This makes rendering robust when turtles/controllers are sparse or deleted. +### Key Architectural Decision: Turtle Entity Encapsulation +Each turtle in `TurtleWorld` is represented by a `Turtle` struct (`state.rs`) storing its own `turtle_id`, `TurtleParams`, fill state, tessellated drawing commands, SVG log, and an embedded `TweenController`. +- `turtle_id` is stored directly on `Turtle` (and passed to `TweenController::update` for logging and side effects); `TweenController` itself manages animation state without needing turtle identity. +- Rendering (`drawing.rs`) iterates through `world.turtles` sequentially, directly accessing each turtle's `tween_controller.current_tween()`, `commands`, and `filling`. ### Key Files ``` @@ -31,7 +33,7 @@ src/ ├── builders.rs - Fluent API traits (forward/right/circle/reset/etc) ├── commands.rs - TurtleCommand enum (Move/Turn/Circle/Reset/etc) ├── execution.rs - Command execution (immediate) + state updates -├── tweening.rs - Animation + tween interpolation (CommandTween embeds turtle_id) +├── tweening.rs - Animation + tween interpolation (TweenController per Turtle) ├── drawing.rs - Lyon mesh rendering with Macroquad ├── state.rs - Turtle, TurtleParams, TurtleWorld (persistent state) ├── tessellation.rs - Lyon integration (polygons/strokes/fills/arcs) @@ -54,15 +56,15 @@ src/ - Example: Donut = outer circle (pen_down) → pen_up → inner circle → end_fill **3. Animation Modes**: -- Speed `>= 999`: Instant mode (no tweening, executes immediately) -- Speed `< 999`: Animated mode (tweens with CubicInOut easing, ~duration based on distance/speed) +- Speed `>= 1000`: Instant mode (no tweening, executes with `max(1, speed - 1000)` draw calls per frame) +- Speed `< 1000`: Animated mode (tweens with CubicInOut easing, duration based on distance/speed) - Dynamic switching via `SetSpeed` command mid-animation **4. Multi-Turtle Architecture**: -- Each turtle owns a persistent `TweenController` with embedded `turtle_id` -- Rendering finds active tween by checking `controller.current_tween().turtle_id` (not Vec index) +- Each turtle in `world.turtles` owns its persistent `TweenController` +- Rendering directly inspects each turtle's active tween via `turtle.tween_controller.current_tween()` during sequential traversal of `world.turtles` - Supports concurrent animation of multiple turtles -- Example: Hangman uses `turtle_command_channel()` for blocking stdin on separate thread +- Threading channels: `create_turtle_channel(buffer_size)` returns `TurtleCommandSender`, with the receiver managed internally by `TurtleApp` **5. Threading Pattern** (for interactive apps like Hangman): - `create_turtle_channel(buffer_size)` returns `TurtleCommandSender` (clonable, Send) @@ -191,11 +193,10 @@ RUST_LOG=turtle_lib=debug cargo run --example yinyang - Preserves `turtle_id` after reset - Called via `execute_command()` in both instant and animated modes -### Turtle ID Robustness -- **Before**: `turtle_id` derived from Vec index (fragile if controllers deleted) -- **After**: `turtle_id` embedded in `TweenController` and `CommandTween` -- Rendering finds active tween via `find_map(|c| c.current_tween())` → uses `tween.turtle_id` directly -- Safe for sparse/dynamic turtle creation +### Turtle Entity and State Encapsulation +- Each `Turtle` struct owns its `turtle_id`, visual `params`, `filling` state, tessellated `commands`, `svg_log`, and `tween_controller` +- `TweenController` is responsible purely for command queue management and interpolation, decoupled from turtle identity +- Rendering iterates sequentially over `world.turtles`, accessing each turtle's `commands`, active tween (`turtle.tween_controller.current_tween()`), and live fill preview in place ### Lyon Tessellation - All drawing → `tessellate_arc/stroke/circle/multi_contour` → `MeshData` → Macroquad `Mesh` @@ -208,13 +209,13 @@ RUST_LOG=turtle_lib=debug cargo run --example yinyang ### Main Dependencies - `macroquad = "0.4"` - Window/rendering framework - `lyon = "1.0"` - Tessellation (fills, strokes, circles) -- `tween = "2.1.0"` - Animation easing (CubicInOut) +- `tween = "2.2.0"` - Animation easing (CubicInOut) - `tracing = "0.1"` - Optional logging (zero cost when unused) -- `crossbeam-channel` - Threading pattern support (if used) +- `crossbeam = "0.8"` - Threading pattern support (channels) ## What NOT to Do -- Don't derive `turtle_id` from Vec index for rendering (use embedded id) +- Don't assume `TweenController` stores `turtle_id` or look up active tweens globally by ID (rendering iterates `world.turtles` and inspects `turtle.tween_controller` directly) - Don't add `use macroquad::prelude::*` without explicit need (causes unused imports) - Don't manually triangulate—always use Lyon `tessellate_*` functions - Don't separate Forward/Backward—use negative `Move` values From 76d07ab009d78e95fa3aac7d58914a01f8a5315f Mon Sep 17 00:00:00 2001 From: Franz Dietrich Date: Sat, 19 Sep 2026 07:51:55 +0200 Subject: [PATCH 05/12] 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, From 823bf13c24397c6588c19ae1f84b6bee70cb19d3 Mon Sep 17 00:00:00 2001 From: Franz Dietrich Date: Sat, 19 Sep 2026 08:19:11 +0200 Subject: [PATCH 06/12] Usability & Ergonomics Improvements All issues identified across Sections 3.1, 3.2, and 3.3 have been resolved, verified with unit tests, automated compilation checks under `-D float_literal_f32_fallback`, and headless SVG export runs. - **[angle.rs](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/general/angle.rs)**: - Implemented `From` and `From` for `Degrees`. - Added unit tests `from_integer` and `from_f64` to verify conversion accuracy. - **[fontsize.rs](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/general/fontsize.rs)**: - Implemented `From` for `FontSize`. - Refactored `FontSize::value(self)` to pass Copy type by value. - Added unit test `font_size_conversions`. - **[general.rs](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/general.rs)**: - Implemented `From`, `From`, and `From` for `AnimationSpeed`. - Added unit test `animation_speed_conversions`. - Re-exported `macroquad` crate (`pub use macroquad;`) so downstream code and macro expansions have reliable direct access. - **[export.rs](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/export.rs)**: - Made `parse_svg_export_arg()` public. - Implemented `run_headless_svg_export(mut build_commands: F, filename: &str) -> Result<(), ExportError>` that executes commands using `app.step_animations()`, avoiding all window/GUI dependencies and never calling `std::process::exit`. - Updated `handle_svg_export` to delegate to `run_headless_svg_export`. - **[lib.rs](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/lib.rs)**: - Extracted `pub fn step_animations(&mut self)` from `update(&mut self)`, allowing command queue draining and tween stepping headlessly without querying window mouse position or events. - **[state.rs](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/state.rs)**: - Changed `TurtleWorld::new()` to initialize camera with `Camera2D::default()` instead of querying `screen_width()` / `screen_height()`, eliminating panics when running without a Macroquad window. - **[turtle-lib-macros/src/lib.rs](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib-macros/src/lib.rs)**: - Added `validate_input` helper providing clean compile diagnostics with spans for: - Multiple arguments: `#[turtle_main] functions must take either 0 arguments or a single &mut TurtlePlan` - Non-unit return types: `#[turtle_main] functions cannot have a return type` - Async functions: `#[turtle_main] functions cannot be async` - Replaced `#[macroquad::main]` wrapper expansion with a native `fn main()` that inspects CLI arguments first. If `--export-svg` is present, it runs `run_headless_svg_export` directly and returns cleanly without opening a window. Otherwise, it launches `macroquad::Window::new(#window_title, async { ... })`. - Added 5 unit tests in `turtle-lib-macros` testing signature validation. - **[.vscode/launch.json](file:///home/dietrich/Projekte/Source/turtlers/.vscode/launch.json)**: - Removed stale references to nonexistent `turtle-example` and `turtle-ui`. - Added debug configurations for `turtle-lib` tests, `turtle-lib-macros` tests, `hello_turtle`, and `breadboard`. - **[breadboard.rs](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/examples/breadboard.rs)**: - Refactored example to use `#[turtle_main("Breadboard")]`. - Removed `#[cfg(feature = "svg")]` and the early-exit message; the example now renders directly on screen by default and supports `--features svg -- --export-svg breadboard.svg`. - **[README.md](file:///home/dietrich/Projekte/Source/turtlers/README.md)**: - Documented optional user-level `~/.cargo/config.toml` mold/lld fast-linking configuration under "Building and Running". --- ```bash cargo test --workspace ``` - **Result**: 21 passed (16 in `turtle-lib`, 5 in `turtle-lib-macros`), 34 doctests passed, 0 failed. ```bash RUSTFLAGS="-D float_literal_f32_fallback" cargo check --workspace --all-targets --all-features RUSTFLAGS="-D float_literal_f32_fallback" cargo check --package turtle-lib --examples --all-features ``` - **Result**: Passed with 0 errors and 0 fallback warnings across all workspace crates and all 30 examples. ```bash cargo run --package turtle-lib --example hello_turtle --features svg -- --export-svg hello.svg cargo run --package turtle-lib --example breadboard --features svg -- --export-svg breadboard.svg ``` - **Result**: Both exported SVG files successfully and exited with code 0 without creating or flashing a graphical window. ```bash cargo check --package turtle-lib --example breadboard ``` - **Result**: Compiled cleanly with 0 errors when SVG feature is disabled. --- .vscode/launch.json | 82 ++++---- README.md | 10 + turtle-lib-macros/src/lib.rs | 303 ++++++++++++++++++----------- turtle-lib/examples/breadboard.rs | 62 ++---- turtle-lib/src/export.rs | 81 ++++---- turtle-lib/src/general.rs | 41 ++++ turtle-lib/src/general/angle.rs | 20 ++ turtle-lib/src/general/fontsize.rs | 23 ++- turtle-lib/src/lib.rs | 12 +- turtle-lib/src/state.rs | 6 +- 10 files changed, 389 insertions(+), 251 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 8a6c427..24394b0 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,7 +1,4 @@ { - // Verwendet IntelliSense zum Ermitteln möglicher Attribute. - // Zeigen Sie auf vorhandene Attribute, um die zugehörigen Beschreibungen anzuzeigen. - // Weitere Informationen finden Sie unter https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { @@ -26,58 +23,57 @@ { "type": "lldb", "request": "launch", - "name": "Debug executable 'turtle-example'", - "cargo": { - "args": [ - "build", - "--bin=turtle-example", - "--package=turtle-example" - ], - "filter": { - "name": "turtle-example", - "kind": "bin" - } - }, - "args": [], - "cwd": "${workspaceFolder}" - }, - { - "type": "lldb", - "request": "launch", - "name": "Debug unit tests in executable 'turtle-example'", - "cargo": { - "args": [ - "test", - "--no-run", - "--bin=turtle-example", - "--package=turtle-example" - ], - "filter": { - "name": "turtle-example", - "kind": "bin" - } - }, - "args": [], - "cwd": "${workspaceFolder}" - }, - { - "type": "lldb", - "request": "launch", - "name": "Debug unit tests in library 'turtle-ui'", + "name": "Debug unit tests in 'turtle-lib-macros'", "cargo": { "args": [ "test", "--no-run", "--lib", - "--package=turtle-ui" + "--package=turtle-lib-macros" ], "filter": { - "name": "turtle-ui", + "name": "turtle-lib-macros", "kind": "lib" } }, "args": [], "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug example 'hello_turtle'", + "cargo": { + "args": [ + "build", + "--example=hello_turtle", + "--package=turtle-lib" + ], + "filter": { + "name": "hello_turtle", + "kind": "example" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug example 'breadboard'", + "cargo": { + "args": [ + "build", + "--example=breadboard", + "--package=turtle-lib" + ], + "filter": { + "name": "breadboard", + "kind": "example" + } + }, + "args": [], + "cwd": "${workspaceFolder}" } ] } \ No newline at end of file diff --git a/README.md b/README.md index 8aa6467..c1e1ea7 100644 --- a/README.md +++ b/README.md @@ -378,6 +378,16 @@ cargo build --release cargo build --features svg ``` +### Optional: Faster Linker Setup + +For significantly faster incremental build and linking times during development on Linux, you can optionally configure `mold` or `lld` in your user-level Cargo configuration (`~/.cargo/config.toml`): + +```toml +[target.x86_64-unknown-linux-gnu] +linker = "clang" +rustflags = ["-C", "link-arg=-fuse-ld=mold"] +``` + ## Development Status ### ✅ Completed diff --git a/turtle-lib-macros/src/lib.rs b/turtle-lib-macros/src/lib.rs index 6ed75ff..e433c9b 100644 --- a/turtle-lib-macros/src/lib.rs +++ b/turtle-lib-macros/src/lib.rs @@ -63,45 +63,79 @@ use syn::{parse_macro_input, ItemFn}; /// This expands to approximately: /// /// ```ignore -/// use macroquad::prelude::*; /// use turtle_lib::*; /// -/// #[macroquad::main("My Turtle Drawing")] -/// async fn main() { -/// // Parse CLI args for --export-svg flag -/// let args: Vec = std::env::args().collect(); -/// // ... (argument parsing logic) -/// -/// let mut turtle = create_turtle_plan(); -/// -/// // Your drawing code here -/// turtle.set_pen_color(RED); -/// turtle.forward(100.0); -/// turtle.right(90.0); -/// turtle.forward(100.0); -/// -/// let mut app = TurtleApp::new().with_commands(turtle.build()); -/// -/// // If --export-svg flag is present, export and exit -/// // Otherwise, enter normal rendering loop -/// loop { -/// clear_background(WHITE); -/// app.update(); -/// app.render(); -/// draw_text("Press ESC or Q to quit", 10.0, 40.0, 16.0, DARKGRAY); -/// -/// if is_key_pressed(KeyCode::Escape) || is_key_pressed(KeyCode::Q) { -/// break; +/// fn main() { +/// // Handle optional SVG export headlessly without opening a window +/// if let Some(filename) = turtle_lib::export::parse_svg_export_arg() { +/// let mut build_commands = |turtle: &mut turtle_lib::TurtlePlan| { +/// my_drawing(turtle); +/// }; +/// if let Err(e) = turtle_lib::export::run_headless_svg_export(&mut build_commands, &filename) { +/// eprintln!("Error exporting SVG: {:?}", e); +/// std::process::exit(1); /// } -/// -/// next_frame().await; +/// return; /// } +/// +/// // Normal interactive GUI mode with window +/// turtle_lib::macroquad::Window::new("My Turtle Drawing", async { +/// let mut turtle = create_turtle_plan(); +/// my_drawing(&mut turtle); +/// +/// let mut app = TurtleApp::new().with_commands(turtle.build()); +/// +/// loop { +/// turtle_lib::macroquad::prelude::clear_background(turtle_lib::macroquad::prelude::WHITE); +/// app.update(); +/// app.render(); +/// turtle_lib::macroquad::prelude::draw_text("Press ESC or Q to quit", 10.0, 40.0, 16.0, turtle_lib::macroquad::prelude::DARKGRAY); +/// +/// if turtle_lib::macroquad::prelude::is_key_pressed(turtle_lib::macroquad::prelude::KeyCode::Escape) +/// || turtle_lib::macroquad::prelude::is_key_pressed(turtle_lib::macroquad::prelude::KeyCode::Q) +/// { +/// break; +/// } +/// +/// turtle_lib::macroquad::prelude::next_frame().await; +/// } +/// }); /// } /// ``` +fn validate_input(input_fn: &ItemFn) -> Result<(), syn::Error> { + if input_fn.sig.asyncness.is_some() { + return Err(syn::Error::new_spanned( + input_fn.sig.fn_token, + "#[turtle_main] functions cannot be async", + )); + } + + if input_fn.sig.inputs.len() > 1 { + return Err(syn::Error::new_spanned( + &input_fn.sig.inputs, + "#[turtle_main] functions must take either 0 arguments or a single `&mut TurtlePlan`", + )); + } + + if matches!(input_fn.sig.output, syn::ReturnType::Type(..)) { + return Err(syn::Error::new_spanned( + &input_fn.sig.output, + "#[turtle_main] functions cannot have a return type", + )); + } + + Ok(()) +} + #[proc_macro_attribute] pub fn turtle_main(args: TokenStream, input: TokenStream) -> TokenStream { let input_fn = parse_macro_input!(input as ItemFn); + // Validate function signature + if let Err(err) = validate_input(&input_fn) { + return err.to_compile_error().into(); + } + // Parse the window title from args (default to "Turtle Graphics") let window_title = if args.is_empty() { quote! { "Turtle Graphics" } @@ -114,105 +148,138 @@ pub fn turtle_main(args: TokenStream, input: TokenStream) -> TokenStream { let fn_name = &input_fn.sig.ident; let fn_block = &input_fn.block; - - // Check if the function has the expected signature + let fn_attrs = &input_fn.attrs; let has_turtle_param = input_fn.sig.inputs.len() == 1; - // Note: The following code has some duplication between the two branches - // (with/without turtle parameter). This is intentional in proc macros as - // we're generating different code paths, and extracting the common parts - // into helper functions would make the macro more complex without significant benefit. + let helper_name = if fn_name == "main" { + quote::format_ident!("__turtle_main_draw") + } else { + fn_name.clone() + }; - let expanded = if has_turtle_param { - // Function takes a turtle parameter + let helper_fn = if has_turtle_param { quote! { - #[macroquad::main(#window_title)] - async fn main() { - // Build function reused for both export and normal rendering - let mut build_commands = |turtle: &mut turtle_lib::TurtlePlan| { - #fn_name(turtle); - }; - - // Handle optional SVG export internally in turtle-lib - turtle_lib::export::handle_svg_export(&mut build_commands); - - // Normal rendering mode (with window) - let mut turtle = turtle_lib::create_turtle_plan(); - - // Call the user's function with the turtle - build_commands(&mut turtle); - - let mut app = turtle_lib::TurtleApp::new() - .with_commands(turtle.build()); - - loop { - macroquad::prelude::clear_background(macroquad::prelude::WHITE); - app.update(); - app.render(); - macroquad::prelude::draw_text( - "Press ESC or Q to quit", - 10.0, - 40.0, - 16.0, - macroquad::prelude::DARKGRAY - ); - - if macroquad::prelude::is_key_pressed(macroquad::prelude::KeyCode::Escape) - || macroquad::prelude::is_key_pressed(macroquad::prelude::KeyCode::Q) - { - break; - } - - macroquad::prelude::next_frame().await; - } - } - - fn #fn_name(turtle: &mut turtle_lib::TurtlePlan) #fn_block + #(#fn_attrs)* + fn #helper_name(turtle: &mut turtle_lib::TurtlePlan) #fn_block } } else { - // Function takes no parameters - inline the code quote! { - #[macroquad::main(#window_title)] - async fn main() { - // Build function reused for both export and normal rendering - let mut build_commands = |turtle: &mut turtle_lib::TurtlePlan| { - let turtle = turtle; - #fn_block - }; - - // Handle optional SVG export internally in turtle-lib - turtle_lib::export::handle_svg_export(&mut build_commands); - - // Normal rendering mode (with window) - let mut turtle = turtle_lib::create_turtle_plan(); - build_commands(&mut turtle); - - let mut app = turtle_lib::TurtleApp::new() - .with_commands(turtle.build()); - - loop { - macroquad::prelude::clear_background(macroquad::prelude::WHITE); - app.update(); - app.render(); - macroquad::prelude::draw_text( - "Press ESC or Q to quit", - 10.0, - 40.0, - 16.0, - macroquad::prelude::DARKGRAY - ); - - if macroquad::prelude::is_key_pressed(macroquad::prelude::KeyCode::Escape) - || macroquad::prelude::is_key_pressed(macroquad::prelude::KeyCode::Q) - { - break; - } - - macroquad::prelude::next_frame().await; - } + #(#fn_attrs)* + fn #helper_name(turtle: &mut turtle_lib::TurtlePlan) { + let turtle = turtle; + #fn_block } } }; + let expanded = quote! { + fn main() { + let mut build_commands = |turtle: &mut turtle_lib::TurtlePlan| { + #helper_name(turtle); + }; + + // If --export-svg flag is present, export headlessly without opening a window + if let Some(filename) = turtle_lib::export::parse_svg_export_arg() { + match turtle_lib::export::run_headless_svg_export(&mut build_commands, &filename) { + Ok(()) => { + println!("SVG exported successfully to: {}", filename); + return; + } + Err(e) => { + eprintln!("Error exporting SVG: {:?}", e); + std::process::exit(1); + } + } + } + + // Normal rendering mode (interactive window) + turtle_lib::macroquad::Window::new(#window_title, async { + let mut turtle = turtle_lib::create_turtle_plan(); + #helper_name(&mut turtle); + + let mut app = turtle_lib::TurtleApp::new() + .with_commands(turtle.build()); + + loop { + turtle_lib::macroquad::prelude::clear_background(turtle_lib::macroquad::prelude::WHITE); + app.update(); + app.render(); + turtle_lib::macroquad::prelude::draw_text( + "Press ESC or Q to quit", + 10.0, + 40.0, + 16.0, + turtle_lib::macroquad::prelude::DARKGRAY + ); + + if turtle_lib::macroquad::prelude::is_key_pressed(turtle_lib::macroquad::prelude::KeyCode::Escape) + || turtle_lib::macroquad::prelude::is_key_pressed(turtle_lib::macroquad::prelude::KeyCode::Q) + { + break; + } + + turtle_lib::macroquad::prelude::next_frame().await; + } + }); + } + + #helper_fn + }; + TokenStream::from(expanded) } + +#[cfg(test)] +mod tests { + use super::*; + use syn::parse_quote; + + #[test] + fn test_valid_zero_args() { + let input: ItemFn = parse_quote! { + fn my_draw() { + turtle.forward(100.0); + } + }; + assert!(validate_input(&input).is_ok()); + } + + #[test] + fn test_valid_one_arg() { + let input: ItemFn = parse_quote! { + fn my_draw(t: &mut TurtlePlan) { + t.forward(100.0); + } + }; + assert!(validate_input(&input).is_ok()); + } + + #[test] + fn test_rejects_async() { + let input: ItemFn = parse_quote! { + async fn my_draw() {} + }; + let err = validate_input(&input).unwrap_err(); + assert!(err.to_string().contains("cannot be async")); + } + + #[test] + fn test_rejects_multiple_args() { + let input: ItemFn = parse_quote! { + fn my_draw(t: &mut TurtlePlan, extra: i32) {} + }; + let err = validate_input(&input).unwrap_err(); + assert!(err.to_string().contains("must take either 0 arguments or a single")); + } + + #[test] + fn test_rejects_return_type() { + let input: ItemFn = parse_quote! { + fn my_draw() -> i32 { + 42 + } + }; + let err = validate_input(&input).unwrap_err(); + assert!(err.to_string().contains("cannot have a return type")); + } +} diff --git a/turtle-lib/examples/breadboard.rs b/turtle-lib/examples/breadboard.rs index aece5b8..c876cc1 100644 --- a/turtle-lib/examples/breadboard.rs +++ b/turtle-lib/examples/breadboard.rs @@ -1,48 +1,11 @@ +//! Breadboard circuit diagram example. +//! +//! Demonstrates structured procedural drawing of an electronics solderless breadboard. +//! Run normally to display on screen, or export to SVG with: +//! `cargo run --package turtle-lib --example breadboard --features svg -- --export-svg breadboard.svg` + 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 immediately - turtle.set_speed(1200).set_pen_width(0.5); - - breadboard(&mut turtle, 65); - - turtle.hide(); - let mut app = TurtleApp::new().with_commands(turtle.build()); - use macroquad::{ - input::{is_key_pressed, KeyCode}, - text::draw_text, - window::{clear_background, next_frame}, - }; - - loop { - clear_background(WHITE); - app.update(); - app.render(); - - 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 exported to test.svg"), - Err(e) => eprintln!("Export error: {:?}", e), - } - } - - next_frame().await; - } -} - -#[cfg(not(feature = "svg"))] -fn main() { - 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 { @@ -51,7 +14,6 @@ fn pin(t: &mut TurtlePlan, size: f32) { t.right(90.0).forward(size / 2.0).left(90.0); } -#[cfg(feature = "svg")] fn pin_row(t: &mut TurtlePlan, count: usize) { for x in 0..count { pin(t, 5.0); @@ -61,7 +23,6 @@ fn pin_row(t: &mut TurtlePlan, count: usize) { } } -#[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(); @@ -69,7 +30,6 @@ fn pin_column(t: &mut TurtlePlan, count: usize, x_coord: f32) { } } -#[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)) @@ -84,8 +44,7 @@ fn pin_side(t: &mut TurtlePlan, count: usize, x_coord: f32, color: Color) { } } -#[cfg(feature = "svg")] -fn breadboard(t: &mut TurtlePlan, row_count: usize) { +fn draw_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); @@ -105,3 +64,10 @@ fn breadboard(t: &mut TurtlePlan, row_count: usize) { .forward(170.0) .right(90.0); } + +#[turtle_main("Breadboard")] +fn main(turtle: &mut TurtlePlan) { + turtle.set_speed(1200).set_pen_width(0.5); + draw_breadboard(turtle, 65); + turtle.hide(); +} diff --git a/turtle-lib/src/export.rs b/turtle-lib/src/export.rs index e1624ba..3fa2f3d 100644 --- a/turtle-lib/src/export.rs +++ b/turtle-lib/src/export.rs @@ -26,7 +26,9 @@ pub(crate) trait DrawingExporter { fn export(&self, world: &TurtleWorld, filename: &str) -> Result<(), ExportError>; } -pub(crate) fn parse_svg_export_arg() -> Option { +/// Check command-line arguments for the `--export-svg ` flag. +#[must_use] +pub fn parse_svg_export_arg() -> Option { let args: Vec = std::env::args().collect(); let mut i = 1; while i < args.len() { @@ -38,49 +40,58 @@ pub(crate) fn parse_svg_export_arg() -> Option { None } +/// Headless SVG export that executes drawing commands and writes an SVG file +/// without opening a graphics window and without calling `std::process::exit`. +/// +/// # Errors +/// +/// Returns `ExportError` if file I/O fails or if the `svg` feature is not enabled. +pub fn run_headless_svg_export(mut build_commands: F, filename: &str) -> Result<(), ExportError> +where + F: FnMut(&mut TurtlePlan), +{ + #[cfg(feature = "svg")] + { + let mut turtle = crate::create_turtle_plan(); + build_commands(&mut turtle); + + let mut app = crate::TurtleApp::new().with_commands(turtle.build()); + app.set_all_turtles_speed(crate::AnimationSpeed::Instant(1000)); + + while !app.is_complete() { + app.step_animations(); + } + + app.export_drawing(filename, crate::export::DrawingFormat::Svg) + } + + #[cfg(not(feature = "svg"))] + { + let _ = &mut build_commands; + let _ = filename; + Err(ExportError::Format( + "SVG export feature is not enabled. Please rebuild with --features svg".to_string(), + )) + } +} + /// Handle the optional `--export-svg` CLI flag. /// -/// The feature gating lives inside `turtle-lib`, so the `turtle_main` macro -/// no longer needs to reference cfg flags from the consuming crate. +/// Delegates to [`run_headless_svg_export`]. pub fn handle_svg_export(build_commands: F) where F: FnMut(&mut TurtlePlan), { - // Avoid unused warnings when the feature is disabled - let _ = &build_commands; - if let Some(filename) = parse_svg_export_arg() { - #[cfg(feature = "svg")] - { - let mut build_commands = build_commands; - let mut turtle = crate::create_turtle_plan(); - build_commands(&mut turtle); - - let mut app = crate::TurtleApp::new().with_commands(turtle.build()); - app.set_all_turtles_speed(crate::AnimationSpeed::Instant(1000)); - - while !app.is_complete() { - app.update(); + match run_headless_svg_export(build_commands, &filename) { + Ok(()) => { + println!("SVG exported successfully to: {filename}"); + std::process::exit(0); } - - match app.export_drawing(&filename, crate::export::DrawingFormat::Svg) { - Ok(_) => { - println!("SVG exported successfully to: {}", filename); - std::process::exit(0); - } - Err(e) => { - eprintln!("Error exporting SVG: {:?}", e); - std::process::exit(1); - } + Err(e) => { + eprintln!("Error exporting SVG: {e:?}"); + std::process::exit(1); } } - - #[cfg(not(feature = "svg"))] - { - let _ = &filename; - eprintln!("Error: SVG export feature is not enabled."); - eprintln!("Please rebuild with --features svg"); - std::process::exit(1); - } } } diff --git a/turtle-lib/src/general.rs b/turtle-lib/src/general.rs index b399104..463f9fd 100644 --- a/turtle-lib/src/general.rs +++ b/turtle-lib/src/general.rs @@ -80,11 +80,52 @@ impl From for AnimationSpeed { } } +impl From for AnimationSpeed { + fn from(speed: f64) -> Self { + AnimationSpeed::from_value(speed as f32) + } +} + impl From for AnimationSpeed { fn from(speed: u32) -> Self { AnimationSpeed::from_u32(speed) } } +impl From for AnimationSpeed { + fn from(speed: i32) -> Self { + AnimationSpeed::from_value(speed as f32) + } +} + +impl From for AnimationSpeed { + fn from(speed: usize) -> Self { + AnimationSpeed::from_value(speed as f32) + } +} + /// Color type re-export from macroquad pub use macroquad::color::Color; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn animation_speed_conversions() { + assert_eq!( + AnimationSpeed::from(50.0_f64), + AnimationSpeed::Animated(50.0) + ); + assert_eq!( + AnimationSpeed::from(100.0_f32), + AnimationSpeed::Animated(100.0) + ); + assert_eq!(AnimationSpeed::from(1000_i32), AnimationSpeed::Instant(1)); + assert_eq!(AnimationSpeed::from(1200_u32), AnimationSpeed::Instant(200)); + assert_eq!( + AnimationSpeed::from(1500_usize), + AnimationSpeed::Instant(500) + ); + } +} diff --git a/turtle-lib/src/general/angle.rs b/turtle-lib/src/general/angle.rs index e5814f6..5e0e22a 100644 --- a/turtle-lib/src/general/angle.rs +++ b/turtle-lib/src/general/angle.rs @@ -65,6 +65,12 @@ impl From for Degrees { } } +impl From for Degrees { + fn from(v: f64) -> Self { + Self(v as Precision) + } +} + impl From for Degrees { fn from(v: i32) -> Self { Self(v as Precision) @@ -77,6 +83,12 @@ impl From for Degrees { } } +impl From for Degrees { + fn from(v: usize) -> Self { + Self(v as Precision) + } +} + // ───────────────────────────────────────────────────────────────────────────── /// An angle measured in radians. @@ -158,5 +170,13 @@ mod tests { assert_eq!(d, Degrees::new(90.0)); let d2: Degrees = 45_i16.into(); assert_eq!(d2, Degrees::new(45.0)); + let d3: Degrees = 180_usize.into(); + assert_eq!(d3, Degrees::new(180.0)); + } + + #[test] + fn from_f64() { + let d: Degrees = 90.0_f64.into(); + assert_eq!(d, Degrees::new(90.0)); } } diff --git a/turtle-lib/src/general/fontsize.rs b/turtle-lib/src/general/fontsize.rs index e702fec..42f7fe2 100644 --- a/turtle-lib/src/general/fontsize.rs +++ b/turtle-lib/src/general/fontsize.rs @@ -12,7 +12,7 @@ impl FontSize { /// Get the inner u16 value #[must_use] - pub const fn value(&self) -> u16 { + pub const fn value(self) -> u16 { self.0 } } @@ -41,8 +41,29 @@ impl From for FontSize { } } +impl From for FontSize { + fn from(f: f64) -> Self { + Self(f.max(1.0) as u16) + } +} + impl From for FontSize { fn from(size: usize) -> Self { Self((size as u16).max(1)) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn font_size_conversions() { + assert_eq!(FontSize::from(16_u16).value(), 16); + assert_eq!(FontSize::from(24_i32).value(), 24); + assert_eq!(FontSize::from(18_i16).value(), 18); + assert_eq!(FontSize::from(32_usize).value(), 32); + assert_eq!(FontSize::from(20.5_f32).value(), 20); + assert_eq!(FontSize::from(28.0_f64).value(), 28); + } +} diff --git a/turtle-lib/src/lib.rs b/turtle-lib/src/lib.rs index d4c8694..640417f 100644 --- a/turtle-lib/src/lib.rs +++ b/turtle-lib/src/lib.rs @@ -73,6 +73,9 @@ pub(crate) mod export_svg; // Re-export the turtle_main macro pub use turtle_lib_macros::turtle_main; +// Re-export the macroquad crate so generated macro code can access it directly +pub use macroquad; + // Re-export common macroquad types and colors for convenience pub use macroquad::prelude::{ vec2, BLACK, BLUE, DARKGRAY, GOLD, GREEN, ORANGE, PURPLE, RED, WHITE, YELLOW, @@ -282,12 +285,19 @@ impl TurtleApp { } } - /// Update animation state (call every frame) + /// Update animation state and process window mouse events (call every frame in GUI loop) pub fn update(&mut self) { // Handle mouse panning and zoom self.handle_mouse_panning(); self.handle_mouse_zoom(); + self.step_animations(); + } + + /// Drive animation updates for all turtles without querying window or mouse events. + /// + /// Suitable for headless execution (such as CLI SVG export) where no graphics window exists. + pub fn step_animations(&mut self) { // Update all turtles' tween controllers for turtle in &mut self.world.turtles { // Drive this turtle's animation controller for one frame. diff --git a/turtle-lib/src/state.rs b/turtle-lib/src/state.rs index e84126c..fe4c13d 100644 --- a/turtle-lib/src/state.rs +++ b/turtle-lib/src/state.rs @@ -376,11 +376,7 @@ impl TurtleWorld { pub fn new() -> Self { Self { turtles: vec![], // Start with no turtles - camera: Camera2D { - zoom: vec2(1.0 / screen_width() * 2.0, 1.0 / screen_height() * 2.0), - target: vec2(0.0, 0.0), - ..Default::default() - }, + camera: Camera2D::default(), background_color: WHITE, } } From f4357cb73d2334ba8b8ba22e799f28a20d394cfd Mon Sep 17 00:00:00 2001 From: Franz Dietrich Date: Sat, 19 Sep 2026 09:24:10 +0200 Subject: [PATCH 07/12] When running: ```bash cargo run --example yinyang --features svg -- --export-svg yinyang.svg ``` the application crashed with: ```text thread 'main' panicked at macroquad-0.4.16/src/lib.rs:172:13: assertion failed: THREAD_ID.is_some() ``` along with 10 compiler dead-code warnings in `turtle-lib`. 1. **Headless Execution Path**: When `--export-svg` is provided, `turtle_main` runs `run_headless_svg_export` headlessly without creating a graphics window (`macroquad::Window::new` is bypassed). 2. **Speed Overwrite in Headless Mode**: `run_headless_svg_export` originally called `app.set_all_turtles_speed(Instant(1000))` and stepped animations with `while !app.is_complete() { app.step_animations(); }`. However, `yinyang.rs` contains `turtle.set_speed(100)` in its plan. When `TweenController` processed `SetSpeed(100)`, it switched to animated mode. 3. **Macroquad Context Assertion**: In animated mode, `TweenController::update` creates a `CommandTween` and called `current_time()`. In `turtle-lib/src/tweening.rs`, `current_time()` called `macroquad::time::get_time()`, which queried Macroquad's context (`get_context()`). Because no window was created, Macroquad asserted `THREAD_ID.is_some()` and panicked. 4. **Dead Code Warnings**: Types previously made `pub(crate)` had dead fields and obsolete helper methods that were never called internally or were superseded by `execution.rs`. --- - **[`turtle-lib/src/export.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/export.rs)**: - Updated `run_headless_svg_export` to use `app.execute_immediate(0, turtle)` instead of queuing commands and stepping animations in a loop. - Headless SVG export now executes all commands synchronously in under 0.1s regardless of any `set_speed` in the drawing plan. - **[`turtle-lib/src/lib.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/lib.rs)**: - In `TurtleApp::execute_immediate`: ensured the turtle exists in `self.world` before executing. - Removed unused `pub(crate) fn world` and `pub(crate) fn world_mut`. - **[`turtle-lib/src/tweening.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/tweening.rs)**: - Updated `current_time()` to use monotonic `std::time::Instant` on non-WASM targets (`#[cfg(not(target_arch = "wasm32"))]`) and `macroquad::time::get_time()` on WASM (`#[cfg(target_arch = "wasm32")]`). - Removed unused `turtle_id` field on `CommandTween`. - **[`turtle-lib/src/circle_geometry.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/circle_geometry.rs)**: - Removed obsolete unused methods: `position_at_progress`, `angle_to_position`, `draw_arc_params`, and `draw_arc_params_partial`. - **[`turtle-lib/src/commands_channel.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/commands_channel.rs)**: - Removed unused `turtle_id` field from `TurtleCommandReceiver`. - Removed unused methods `turtle_id`, `try_recv`, `is_empty`, and `len` from `TurtleCommandReceiver`. - **[`turtle-lib/src/general.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/general.rs)**: - Removed unused `Visibility` type alias. - **[`turtle-lib/src/state.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/state.rs)** & **[`turtle-lib/src/execution.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/execution.rs)**: - Removed unused `start_position` field on `FillState`. - Removed unused superseded methods on `Turtle`: `heading_angle`, `reset`, `begin_fill`, `record_fill_vertex`, `close_fill_contour`, `start_fill_contour`, `record_fill_vertices_for_arc`, `reset_fill`. - Removed unused `background_color` field and unused methods `get_turtle`, `reset_turtle`, `clear` from `TurtleWorld`. - Added `#[allow(clippy::struct_field_names)]` on `Turtle::turtle_id`. - **[`turtle-lib/src/export_svg.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/export_svg.rs)**: - Moved `update_bounds` outside `export`. - Replaced `if angle.value() > 180.0 { 1 } else { 0 }` with `i32::from(angle.value() > 180.0)`. - Replaced `d.push_str(&format!(...))` with `write!(d, ...)`. - Inlined format arguments in `color_to_svg`. --- ```bash cargo run --example yinyang --features svg -- --export-svg yinyang.svg ``` Output: ```text Finished `dev` profile [optimized + debuginfo] target(s) in 0.07s Running `target/debug/examples/yinyang --export-svg yinyang.svg` SVG exported successfully to: yinyang.svg ``` Completed cleanly in **0.07s** with **0 compiler warnings** and **0 errors**. Inspected `yinyang.svg`: contains all expected paths, outer arcs, inner S-curve, and EvenOdd fill contours. ```bash cargo test --package turtle-lib --features svg ``` Output: ```text test result: ok. 16 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s test result: ok. 34 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.25s ``` ```bash cargo clippy --package turtle-lib --features svg -- -Wclippy::pedantic \ -Aclippy::cast_precision_loss -Aclippy::cast_sign_loss -Aclippy::cast_possible_truncation ``` Output: ```text Checking turtle-lib v0.2.0 (/home/dietrich/Projekte/Source/turtlers/turtle-lib) Finished `dev` profile [optimized + debuginfo] target(s) in 0.51s ``` **0 warnings.** --- turtle-lib/src/circle_geometry.rs | 67 ---------- turtle-lib/src/commands_channel.rs | 27 +--- turtle-lib/src/execution.rs | 1 - turtle-lib/src/export.rs | 11 +- turtle-lib/src/export_svg.rs | 40 +++--- turtle-lib/src/general.rs | 3 +- turtle-lib/src/lib.rs | 14 +- turtle-lib/src/state.rs | 198 +---------------------------- turtle-lib/src/tweening.rs | 6 +- 9 files changed, 35 insertions(+), 332 deletions(-) diff --git a/turtle-lib/src/circle_geometry.rs b/turtle-lib/src/circle_geometry.rs index c9d31a2..7f7896a 100644 --- a/turtle-lib/src/circle_geometry.rs +++ b/turtle-lib/src/circle_geometry.rs @@ -111,74 +111,7 @@ impl CircleGeometry { ) } - /// Calculate position at a given progress (0.0 to 1.0) through `total_angle` - #[must_use] - pub fn position_at_progress(&self, total_angle: f32, progress: f32) -> Vec2 { - let angle_traveled = total_angle * progress; - self.position_at_angle(angle_traveled) - } - /// Get the angle traveled from start position to a given position - #[must_use] - pub fn angle_to_position(&self, position: Vec2) -> f32 { - let displacement = position - self.center; - let current_angle = displacement.y.atan2(displacement.x); - - let mut angle_diff = match self.direction { - CircleDirection::Left => self.start_angle_from_center - current_angle, - CircleDirection::Right => current_angle - self.start_angle_from_center, - }; - - // Normalize to [0, 2π) - if angle_diff < 0.0 { - angle_diff += 2.0 * std::f32::consts::PI; - } - - angle_diff - } - - /// Get `draw_arc` parameters for the full arc - /// Returns (`rotation_degrees`, `arc_degrees`) for macroquad's `draw_arc` - #[must_use] - pub fn draw_arc_params(&self, total_angle_degrees: f32) -> (f32, f32) { - match self.direction { - CircleDirection::Left => { - // For left (counter-clockwise), we need to draw counter-clockwise from end back to start - // so we start at (start - total_angle) and draw total_angle counter-clockwise - let end_angle = self.start_angle_from_center - total_angle_degrees.to_radians(); - (end_angle.to_degrees(), total_angle_degrees) - } - CircleDirection::Right => { - // For right (clockwise), draw from start - ( - self.start_angle_from_center.to_degrees(), - total_angle_degrees, - ) - } - } - } - - /// Get `draw_arc` parameters for a partial arc (during tweening) - /// Returns (`rotation_degrees`, `arc_degrees`) for macroquad's `draw_arc` - #[must_use] - pub fn draw_arc_params_partial(&self, angle_traveled: f32) -> (f32, f32) { - let angle_traveled_degrees = angle_traveled.to_degrees(); - - match self.direction { - CircleDirection::Left => { - // Draw from current position backwards (counter-clockwise) to start - let current_angle = self.start_angle_from_center - angle_traveled; - (current_angle.to_degrees(), angle_traveled_degrees) - } - CircleDirection::Right => { - // Draw from start, counter-clockwise - ( - self.start_angle_from_center.to_degrees(), - angle_traveled_degrees, - ) - } - } - } } #[cfg(test)] diff --git a/turtle-lib/src/commands_channel.rs b/turtle-lib/src/commands_channel.rs index 627aa60..fd5873c 100644 --- a/turtle-lib/src/commands_channel.rs +++ b/turtle-lib/src/commands_channel.rs @@ -77,7 +77,6 @@ pub struct TurtleCommandSender { /// Paired with `TurtleCommandSender` via `turtle_command_channel()`. /// Automatically managed by `TurtleApp::process_commands()`. pub(crate) struct TurtleCommandReceiver { - turtle_id: usize, rx: Receiver, } @@ -142,12 +141,6 @@ impl TurtleCommandSender { } impl TurtleCommandReceiver { - /// Get the turtle ID this receiver is bound to - #[must_use] - pub fn turtle_id(&self) -> usize { - self.turtle_id - } - /// Drain all pending commands for this turtle (non-blocking) /// /// # Examples @@ -169,24 +162,6 @@ impl TurtleCommandReceiver { pub fn recv_all(&self) -> Vec { self.rx.try_iter().collect() } - - /// Try to receive one command batch (non-blocking) - #[must_use] - pub fn try_recv(&self) -> Option { - self.rx.try_recv().ok() - } - - /// Check if this receiver's queue is empty - #[must_use] - pub fn is_empty(&self) -> bool { - self.rx.is_empty() - } - - /// Get the number of pending command batches - #[must_use] - pub fn len(&self) -> usize { - self.rx.len() - } } /// Create a command channel for a specific turtle @@ -217,6 +192,6 @@ pub(crate) fn turtle_command_channel( let (tx, rx) = bounded(buffer_size); ( TurtleCommandSender { turtle_id, tx }, - TurtleCommandReceiver { turtle_id, rx }, + TurtleCommandReceiver { rx }, ) } diff --git a/turtle-lib/src/execution.rs b/turtle-lib/src/execution.rs index 503d5f7..e65d477 100644 --- a/turtle-lib/src/execution.rs +++ b/turtle-lib/src/execution.rs @@ -93,7 +93,6 @@ pub(crate) fn execute_command_side_effects( BLACK }); *filling = Some(FillState { - start_position: params.position, contours: Vec::new(), current_contour: vec![params.position], fill_color, diff --git a/turtle-lib/src/export.rs b/turtle-lib/src/export.rs index 3fa2f3d..0baf558 100644 --- a/turtle-lib/src/export.rs +++ b/turtle-lib/src/export.rs @@ -1,5 +1,5 @@ //! Export backend trait and core export types. - +#[cfg(feature = "svg")] use crate::state::TurtleWorld; use crate::TurtlePlan; @@ -17,6 +17,7 @@ pub enum DrawingFormat { // Additional formats: Png, Pdf, … } +#[cfg(feature = "svg")] pub(crate) trait DrawingExporter { /// Export the drawing to the specified format and filename /// @@ -55,12 +56,8 @@ where let mut turtle = crate::create_turtle_plan(); build_commands(&mut turtle); - let mut app = crate::TurtleApp::new().with_commands(turtle.build()); - app.set_all_turtles_speed(crate::AnimationSpeed::Instant(1000)); - - while !app.is_complete() { - app.step_animations(); - } + let mut app = crate::TurtleApp::new(); + app.execute_immediate(0, turtle); app.export_drawing(filename, crate::export::DrawingFormat::Svg) } diff --git a/turtle-lib/src/export_svg.rs b/turtle-lib/src/export_svg.rs index 71c1c79..5579b3b 100644 --- a/turtle-lib/src/export_svg.rs +++ b/turtle-lib/src/export_svg.rs @@ -4,15 +4,31 @@ pub mod svg_export { use crate::export::{DrawingExporter, ExportError}; use crate::state::{SvgRecord, TurtleWorld}; + use std::fmt::Write; use std::fs::File; use svg::{ node::element::{Circle, Line, Text as SvgText}, Document, }; + fn update_bounds( + min_x: &mut f32, + max_x: &mut f32, + min_y: &mut f32, + max_y: &mut f32, + x: f32, + y: f32, + ) { + *min_x = min_x.min(x); + *max_x = max_x.max(x); + *min_y = min_y.min(y); + *max_y = max_y.max(y); + } + pub struct SvgExporter; impl DrawingExporter for SvgExporter { + #[allow(clippy::too_many_lines)] fn export(&self, world: &TurtleWorld, filename: &str) -> Result<(), ExportError> { let mut doc = Document::new(); @@ -21,20 +37,6 @@ pub mod svg_export { let mut min_y = f32::INFINITY; let mut max_y = f32::NEG_INFINITY; - fn update_bounds( - min_x: &mut f32, - max_x: &mut f32, - min_y: &mut f32, - max_y: &mut f32, - x: f32, - y: f32, - ) { - *min_x = min_x.min(x); - *max_x = max_x.max(x); - *min_y = min_y.min(y); - *max_y = max_y.max(y); - } - for turtle in &world.turtles { for record in &turtle.svg_log.records { match record { @@ -110,7 +112,7 @@ pub mod svg_export { } else { // Partial arc — emit as let end = geom.position_at_angle(angle.as_radians().value()); - let large_arc = if angle.value() > 180.0 { 1 } else { 0 }; + let large_arc = i32::from(angle.value() > 180.0); let sweep = match direction { crate::circle_geometry::CircleDirection::Left => 0, crate::circle_geometry::CircleDirection::Right => 1, @@ -154,9 +156,9 @@ pub mod svg_export { if i > 0 { d.push(' '); } - d.push_str(&format!("M {} {}", contour[0].x, contour[0].y)); + let _ = write!(d, "M {} {}", contour[0].x, contour[0].y); for point in contour.iter().skip(1) { - d.push_str(&format!(" L {} {}", point.x, point.y)); + let _ = write!(d, " L {} {}", point.x, point.y); } d.push_str(" Z"); } @@ -227,9 +229,9 @@ pub mod svg_export { let g = (color.g * 255.0) as u8; let b = (color.b * 255.0) as u8; if color.a < 1.0 { - format!("rgba({},{},{},{})", r, g, b, color.a) + format!("rgba({r},{g},{b},{})", color.a) } else { - format!("rgb({},{},{})", r, g, b) + format!("rgb({r},{g},{b})") } } } diff --git a/turtle-lib/src/general.rs b/turtle-lib/src/general.rs index 463f9fd..015cd45 100644 --- a/turtle-lib/src/general.rs +++ b/turtle-lib/src/general.rs @@ -20,8 +20,7 @@ pub type Precision = f32; /// - internal render-space state uses Macroquad-style Y-down coordinates pub type Coordinate = Vec2; -/// Visibility flag for turtle -pub type Visibility = bool; + /// Execution speed setting /// - `Instant(draw_calls)`: Fast execution with limited draw calls per frame (speed - 1000, minimum 1) diff --git a/turtle-lib/src/lib.rs b/turtle-lib/src/lib.rs index 640417f..9b456d9 100644 --- a/turtle-lib/src/lib.rs +++ b/turtle-lib/src/lib.rs @@ -253,6 +253,11 @@ impl TurtleApp { /// Execute a plan immediately on a specific turtle (no animation) pub fn execute_immediate(&mut self, turtle_id: usize, plan: TurtlePlan) { + // Ensure turtle exists + while self.world.turtles.len() <= turtle_id { + self.world.add_turtle(); + } + for ref cmd in plan.build() { execution::execute_command_with_id(cmd, turtle_id, &mut self.world); } @@ -393,16 +398,7 @@ impl TurtleApp { } } - /// Get reference to the world state - #[must_use] - pub(crate) fn world(&self) -> &TurtleWorld { - &self.world - } - /// Get mutable reference to the world state - pub(crate) fn world_mut(&mut self) -> &mut TurtleWorld { - &mut self.world - } } impl Default for TurtleApp { diff --git a/turtle-lib/src/state.rs b/turtle-lib/src/state.rs index fe4c13d..24da2fc 100644 --- a/turtle-lib/src/state.rs +++ b/turtle-lib/src/state.rs @@ -9,9 +9,6 @@ use macroquad::prelude::*; /// State during active fill operation #[derive(Clone, Debug)] pub(crate) struct FillState { - /// Starting position of the fill - pub(crate) start_position: Coordinate, - /// All contours collected so far. Each contour is a separate closed path. /// The first contour is the outer boundary, subsequent contours are holes. pub(crate) contours: Vec>, @@ -56,6 +53,7 @@ impl Default for TurtleParams { /// State of a single turtle pub(crate) struct Turtle { + #[allow(clippy::struct_field_names)] pub(crate) turtle_id: usize, pub(crate) params: TurtleParams, @@ -90,26 +88,6 @@ impl Turtle { self.params.speed = speed; } - #[must_use] - pub fn heading_angle(&self) -> crate::general::Radians { - crate::general::Radians::new(self.params.heading) - } - - /// Reset turtle to default state (preserves `turtle_id` and queued commands) - pub fn reset(&mut self) { - // Clear all drawings - self.commands.clear(); - self.svg_log.clear(); - - // Clear fill state - self.filling = None; - - // Reset parameters to defaults - self.params = TurtleParams::default(); - - // Keep turtle_id and tween_controller (preserves queued commands) - } - /// Drive the animation controller for one frame. /// /// Returns `(command, start_params, end_params)` for every command that @@ -130,156 +108,6 @@ impl Turtle { &mut self.svg_log, ) } - - /// Start recording fill vertices - pub fn begin_fill(&mut self, fill_color: Color) { - self.filling = Some(FillState { - start_position: self.params.position, - contours: Vec::new(), - current_contour: vec![self.params.position], - fill_color, - }); - } - - /// Record current position if filling and pen is down - pub fn record_fill_vertex(&mut self) { - if let Some(ref mut fill_state) = self.filling { - if self.params.pen_down { - tracing::trace!( - turtle_id = self.turtle_id, - x = self.params.position.x, - y = self.params.position.y, - vertices = fill_state.current_contour.len() + 1, - "Adding vertex to current contour" - ); - fill_state.current_contour.push(self.params.position); - } else { - tracing::trace!(turtle_id = self.turtle_id, "Skipping vertex (pen is up)"); - } - } - } - - /// Close the current contour and prepare for a new one (called on `pen_up`) - pub fn close_fill_contour(&mut self) { - if let Some(ref mut fill_state) = self.filling { - tracing::debug!( - turtle_id = self.turtle_id, - vertices = fill_state.current_contour.len(), - "close_fill_contour called" - ); - // Only close if we have vertices in current contour - if fill_state.current_contour.len() >= 2 { - tracing::debug!( - turtle_id = self.turtle_id, - vertices = fill_state.current_contour.len(), - first_x = fill_state.current_contour[0].x, - first_y = fill_state.current_contour[0].y, - last_x = fill_state.current_contour[fill_state.current_contour.len() - 1].x, - last_y = fill_state.current_contour[fill_state.current_contour.len() - 1].y, - "Closing contour" - ); - // Move current contour to completed contours - let contour = std::mem::take(&mut fill_state.current_contour); - fill_state.contours.push(contour); - tracing::debug!( - turtle_id = self.turtle_id, - completed_contours = fill_state.contours.len(), - "Contour moved to completed list" - ); - } else if !fill_state.current_contour.is_empty() { - tracing::warn!( - turtle_id = self.turtle_id, - vertices = fill_state.current_contour.len(), - "Current contour has insufficient vertices, not closing" - ); - } else { - tracing::warn!( - turtle_id = self.turtle_id, - "Current contour is empty, nothing to close" - ); - } - } else { - tracing::warn!( - turtle_id = self.turtle_id, - "close_fill_contour called but no active fill state" - ); - } - } - - /// Start a new contour (called on `pen_down`) - pub fn start_fill_contour(&mut self) { - if let Some(ref mut fill_state) = self.filling { - // Start new contour at current position - tracing::debug!( - x = self.params.position.x, - y = self.params.position.y, - completed_contours = fill_state.contours.len(), - self.turtle_id = self.turtle_id, - "Starting new contour" - ); - fill_state.current_contour = vec![self.params.position]; - } - } - - /// Record multiple vertices along a circle arc for filling - /// This ensures circles are properly filled by sampling points along the arc - pub fn record_fill_vertices_for_arc( - &mut self, - center: Coordinate, - radius: f32, - start_angle: f32, - angle_traveled: f32, - direction: crate::circle_geometry::CircleDirection, - steps: u32, - ) { - if let Some(ref mut fill_state) = self.filling { - if self.params.pen_down { - // Sample points along the arc based on steps - let num_samples = steps.max(1); - - tracing::trace!( - turtle_id = self.turtle_id, - center_x = center.x, - center_y = center.y, - radius = radius, - steps = steps, - num_samples = num_samples, - "Recording arc vertices" - ); - - for i in 1..=num_samples { - let progress = i as f32 / num_samples as f32; - let current_angle = match direction { - crate::circle_geometry::CircleDirection::Left => { - start_angle - angle_traveled * progress - } - crate::circle_geometry::CircleDirection::Right => { - start_angle + angle_traveled * progress - } - }; - - let vertex = Coordinate::new( - center.x + radius * current_angle.cos(), - center.y + radius * current_angle.sin(), - ); - tracing::trace!( - turtle_id = self.turtle_id, - vertex_idx = i, - x = vertex.x, - y = vertex.y, - angle_degrees = current_angle.to_degrees(), - "Arc vertex" - ); - fill_state.current_contour.push(vertex); - } - } - } - } - - /// Clear fill state (called after `end_fill`) - pub fn reset_fill(&mut self) { - self.filling = None; - } } /// The draw-event log for SVG export. @@ -368,7 +196,6 @@ pub(crate) struct TurtleWorld { /// All turtles in the world (indexed by turtle ID) pub(crate) turtles: Vec, pub(crate) camera: Camera2D, - pub(crate) background_color: Color, } impl TurtleWorld { @@ -377,7 +204,6 @@ impl TurtleWorld { Self { turtles: vec![], // Start with no turtles camera: Camera2D::default(), - background_color: WHITE, } } @@ -392,32 +218,10 @@ impl TurtleWorld { turtle_id } - /// Get turtle by ID - #[must_use] - pub fn get_turtle(&self, id: usize) -> Option<&Turtle> { - self.turtles.get(id) - } - /// Get mutable turtle by ID pub fn get_turtle_mut(&mut self, id: usize) -> Option<&mut Turtle> { self.turtles.get_mut(id) } - - /// Reset a specific turtle to default state and remove all its drawings - pub fn reset_turtle(&mut self, turtle_id: usize) { - if let Some(turtle) = self.get_turtle_mut(turtle_id) { - turtle.reset(); - turtle.turtle_id = turtle_id; // Preserve turtle_id after reset - } - } - - /// Clear all drawings and reset all turtle states - pub fn clear(&mut self) { - for (id, turtle) in self.turtles.iter_mut().enumerate() { - turtle.reset(); - turtle.turtle_id = id; // Preserve turtle_id after reset - } - } } impl Default for TurtleWorld { diff --git a/turtle-lib/src/tweening.rs b/turtle-lib/src/tweening.rs index a4b2a70..6e124e2 100644 --- a/turtle-lib/src/tweening.rs +++ b/turtle-lib/src/tweening.rs @@ -54,7 +54,6 @@ pub(crate) struct TweenController { #[derive(Clone, Debug)] pub(crate) struct CommandTween { - pub(crate) turtle_id: usize, pub(crate) command: TurtleCommand, pub(crate) start_time: f64, pub(crate) duration: f64, @@ -316,7 +315,6 @@ impl TweenController { ); self.current_tween = Some(CommandTween { - turtle_id, command, start_time: current_time(), duration, @@ -396,14 +394,14 @@ pub(crate) fn normalize_angle(angle: f32) -> f32 { #[inline] fn current_time() -> f64 { - #[cfg(test)] + #[cfg(not(target_arch = "wasm32"))] { use std::sync::OnceLock; use std::time::Instant; static START: OnceLock = OnceLock::new(); START.get_or_init(Instant::now).elapsed().as_secs_f64() } - #[cfg(not(test))] + #[cfg(target_arch = "wasm32")] { macroquad::time::get_time() } From f117361950533b4d61341305e1013c0c2eebc2fb Mon Sep 17 00:00:00 2001 From: Franz Dietrich Date: Sat, 19 Sep 2026 10:52:00 +0200 Subject: [PATCH 08/12] Fix Timing Clocks Divergence Fixed the timing clock divergence between [tweening.rs](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/tweening.rs) and [drawing.rs](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/drawing.rs) by delegating animation progress evaluation exclusively to `TweenController`. `current_time()` in `tweening.rs` was using a monotonic `Instant` epoch (to support headless execution and unit tests without panicking on missing Macroquad context), while `drawing.rs` was measuring progress via `macroquad::time::get_time() - tween.start_time`. Because these two clocks operate on completely different epochs, subtracting them caused negative elapsed times, premature clamping, jitter, or animation lockup. In [`turtle-lib/src/tweening.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/tweening.rs): - Added `pub(crate) progress: f32` to [`CommandTween`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/tweening.rs#L55-L68) to track the current evaluated, eased progress in `[0.0, 1.0]`. - Made `start_time: f64` and `duration: f64` private to `tweening.rs` so outside modules cannot accidentally perform desynchronized clock arithmetic. - Initialized `progress: 0.0` when constructing a new `CommandTween`. - In `TweenController::update`, evaluated and clamped `tween.progress = tween.heading_tweener.move_to(elapsed).clamp(0.0, 1.0)`. In [`turtle-lib/src/drawing.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/drawing.rs): - Removed `use tween::CubicInOut;` and duplicate easing calculations. - Replaced `get_time() - tween.start_time` and `CubicInOut.tween(...)` in in-flight fill preview with `tween.progress`. - Replaced `get_time() - tween.start_time` and `CubicInOut.tween(...)` in `draw_tween_arc` with `tween.progress`. - Ensured `drawing.rs` has zero clock queries and strictly renders the state provided by `TweenController`. In [`turtle-lib/src/tweening.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/tweening.rs#L540-L579): - Updated `test_animated_mode_pops_to_current_tween` to assert that `progress` initializes to `0.0`. - Added `test_animated_mode_progress_advances_and_clamps` to verify that `progress` advances monotonically across frames and stays within `[0.0, 1.0]`. --- ```bash cargo test --package turtle-lib --features svg ``` Output: ```text running 17 tests test circle_geometry::tests::test_circle_left_geometry ... ok test circle_geometry::tests::test_circle_right_geometry ... ok test command_behavior::tests::test_goto_duration_cartesian_inversion ... ok test command_behavior::tests::test_set_heading_degrees_and_instant_duration ... ok test execution::tests::test_forward_left_forward ... ok test general::angle::tests::degrees_to_radians_roundtrip ... ok test general::angle::tests::from_f64 ... ok test general::angle::tests::from_integer ... ok test general::angle::tests::negation ... ok test general::fontsize::tests::font_size_conversions ... ok test general::length::tests::test_length_conversions_and_negation ... ok test general::tests::animation_speed_conversions ... ok test tweening::tests::test_animated_mode_pops_to_current_tween ... ok test tweening::tests::test_instant_mode_drains_queue ... ok test tweening::tests::test_instant_mode_respects_batch_limit_and_retains_pending ... ok test tweening::tests::test_streaming_append_commands_does_not_accumulate ... ok test tweening::tests::test_animated_mode_progress_advances_and_clamps ... ok test result: ok. 17 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s Doc-tests turtle_lib: 34 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.26s ``` ```bash cargo clippy --package turtle-lib --features svg -- -Wclippy::pedantic \ -Aclippy::cast_precision_loss -Aclippy::cast_sign_loss -Aclippy::cast_possible_truncation ``` Output: ```text Finished `dev` profile [optimized + debuginfo] target(s) in 0.82s ``` **0 warnings.** ```bash cargo run --example yinyang --features svg -- --export-svg /tmp/yinyang_test.svg ``` Output: ```text SVG exported successfully to: /tmp/yinyang_test.svg ``` ```bash cargo build --package turtle-lib --examples ``` Output: ```text Finished `dev` profile [optimized + debuginfo] target(s) in 6.74s ``` **All 30 examples compiled cleanly with 0 errors and 0 warnings.** --- turtle-lib-macros/Cargo.toml | 2 +- turtle-lib/Cargo.toml | 2 +- turtle-lib/src/drawing.rs | 29 ++++++----------------- turtle-lib/src/tweening.rs | 46 ++++++++++++++++++++++++++++++++---- 4 files changed, 51 insertions(+), 28 deletions(-) diff --git a/turtle-lib-macros/Cargo.toml b/turtle-lib-macros/Cargo.toml index fe9f050..7d677b2 100644 --- a/turtle-lib-macros/Cargo.toml +++ b/turtle-lib-macros/Cargo.toml @@ -10,4 +10,4 @@ proc-macro = true [dependencies] proc-macro2 = "1.0" quote = "1.0" -syn = { version = "2.0", features = ["full"] } +syn = { version = "3.0", features = ["full"] } diff --git a/turtle-lib/Cargo.toml b/turtle-lib/Cargo.toml index d096df5..9b485c3 100644 --- a/turtle-lib/Cargo.toml +++ b/turtle-lib/Cargo.toml @@ -18,7 +18,7 @@ crossbeam = "0.8" [dev-dependencies] # For examples and testing tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } -dialog = "*" +dialog = "0.3" chrono = "0.4" [features] diff --git a/turtle-lib/src/drawing.rs b/turtle-lib/src/drawing.rs index 9f02241..fb2a505 100644 --- a/turtle-lib/src/drawing.rs +++ b/turtle-lib/src/drawing.rs @@ -5,12 +5,7 @@ use crate::state::{DrawCommand, TurtleParams, TurtleWorld}; use crate::tessellation; use macroquad::prelude::*; -// Import the easing function from the tween crate -// To change the easing, change both this import and the usage in the draw_tween_arc function below -// Available options: Linear, SineInOut, QuadInOut, CubicInOut, QuartInOut, QuintInOut, -// ExpoInOut, CircInOut, BackInOut, ElasticInOut, BounceInOut, etc. -// See https://easings.net/ for visual demonstrations -use tween::CubicInOut; + /// Render the turtle world with active tween visualization. #[allow(clippy::too_many_lines)] @@ -136,15 +131,11 @@ pub(crate) fn render_world_with_tweens(world: &TurtleWorld, zoom_level: f32) { radius.value(), *direction, ); - let elapsed = get_time() - tween.start_time; - let progress = (elapsed / tween.duration).min(1.0); - let eased_progress = CubicInOut.tween(1.0, progress as f32); - // Delegate to the shared arc_points function — same sampling - // strategy as tessellate_arc, eliminating the divergence. + // strategy as tessellate_arc, using tween.progress directly. let samples_to_draw = - (((*steps).max(1) as f32 * eased_progress) as usize).max(1); - let sweep_so_far = angle.as_radians().value() * eased_progress; + (((*steps).max(1) as f32 * tween.progress) as usize).max(1); + let sweep_so_far = angle.as_radians().value() * tween.progress; for pt in arc_points( geom.center, radius.value(), @@ -309,21 +300,15 @@ fn draw_tween_arc( draw_mesh(&mesh); } - // Calculate how much of the arc we've traveled based on tween progress - // Use the same eased progress as the turtle position for synchronized animation - let elapsed = get_time() - tween.start_time; - let t = (elapsed / tween.duration).min(1.0); - let progress = CubicInOut.tween(1.0, t as f32); // tween from 0 to 1 - - // Use Lyon to tessellate and draw the partial arc + // Draw the partial arc traveled based on tween progress if let Ok(mesh) = crate::tessellation::tessellate_arc( geom.center, radius, geom.start_angle_from_center.to_degrees(), - total_angle.value() * progress, + total_angle.value() * tween.progress, tween.start_params.color, tween.start_params.pen_width, - ((steps as f32 * progress).ceil() as usize).max(1), + ((steps as f32 * tween.progress).ceil() as usize).max(1), direction, ) { draw_mesh(&mesh); diff --git a/turtle-lib/src/tweening.rs b/turtle-lib/src/tweening.rs index 6e124e2..c167ebc 100644 --- a/turtle-lib/src/tweening.rs +++ b/turtle-lib/src/tweening.rs @@ -55,12 +55,13 @@ pub(crate) struct TweenController { #[derive(Clone, Debug)] pub(crate) struct CommandTween { pub(crate) command: TurtleCommand, - pub(crate) start_time: f64, - pub(crate) duration: f64, + pub(crate) progress: f32, pub(crate) start_params: TurtleParams, pub(crate) target_params: TurtleParams, pub(crate) current_position: Vec2, pub(crate) current_heading: f32, + start_time: f64, + duration: f64, position_tweener: Tweener, heading_tweener: Tweener, pen_width_tweener: Tweener, @@ -166,7 +167,8 @@ impl TweenController { // Use tweeners to calculate current values // For circles, calculate position along the arc instead of straight line - let progress = tween.heading_tweener.move_to(elapsed); + let progress = tween.heading_tweener.move_to(elapsed).clamp(0.0, 1.0); + tween.progress = progress; let current_position = match &tween.command { TurtleCommand::Circle { @@ -316,6 +318,7 @@ impl TweenController { self.current_tween = Some(CommandTween { command, + progress: 0.0, start_time: current_time(), duration, start_params: params.clone(), @@ -537,7 +540,42 @@ mod tests { controller.update(0, &mut params, &mut filling, &mut commands, &mut svg_log); assert_eq!(controller.queue.len(), 1, "First command must be popped into current_tween"); - assert!(controller.current_tween().is_some()); + let active = controller.current_tween().expect("Must have active tween"); + assert_eq!(active.progress, 0.0, "New tween must initialize progress to 0.0"); assert!(!controller.is_complete()); } + + #[test] + fn test_animated_mode_progress_advances_and_clamps() { + let mut queue = CommandQueue::new(); + // Circle command with speed 10.0 and radius 100 => duration ~62.8s + queue.push(TurtleCommand::Circle { + radius: Length::new(100.0), + angle: Degrees::new(360.0), + steps: 36, + direction: CircleDirection::Right, + }); + + let mut controller = TweenController::new( + queue, + AnimationSpeed::Animated(10.0), + ); + let mut params = make_test_params(); + let mut filling = None; + let mut commands = Vec::new(); + let mut svg_log = crate::state::SvgLog::default(); + + // Frame 0: pops into current_tween with progress = 0.0 + controller.update(0, &mut params, &mut filling, &mut commands, &mut svg_log); + let initial_progress = controller.current_tween().unwrap().progress; + assert_eq!(initial_progress, 0.0); + + // Advance time by sleeping briefly + std::thread::sleep(std::time::Duration::from_millis(15)); + controller.update(0, &mut params, &mut filling, &mut commands, &mut svg_log); + if let Some(tween) = controller.current_tween() { + assert!(tween.progress >= 0.0 && tween.progress <= 1.0); + assert!(tween.progress >= initial_progress); + } + } } From 68593ba64df8aa94ff504db9a3a88e1946d53d4c Mon Sep 17 00:00:00 2001 From: Franz Dietrich Date: Sat, 19 Sep 2026 11:45:57 +0200 Subject: [PATCH 09/12] Builder Pattern Trait Hierarchy Refactoring We refactored the builder pattern in [`turtle-lib`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib) to eliminate inherent method asymmetry and organize all turtle capabilities into six cohesive traits. [`builders.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/builders.rs) The legacy traits (`DirectionalMovement`, `Turnable`, `CurvedMovement`) and orphaned inherent methods have been reorganized into six domain-focused traits: - **[`Movement`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/builders.rs#L14)**: - `forward()` - `backward()` - `go_to()` - `circle_left()` - `circle_right()` - **[`Rotation`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/builders.rs#L191)**: - `left()` - `right()` - `set_heading()` - **[`Pen`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/builders.rs#L279)**: - `pen_up()` - `pen_down()` - `set_pen_color()` - `set_pen_width()` - **[`Fill`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/builders.rs#L393)**: - `begin_fill()` - `end_fill()` - `set_fill_color()` - **[`Cursor`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/builders.rs#L481)**: - `hide()` - `show()` - `shape()` - `set_shape()` - `set_speed()` - `reset()` - **[`Text`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/builders.rs#L646)**: - `write_text()` [`TurtlePlan`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/builders.rs#L688) `TurtlePlan`'s inherent methods are now strictly builder lifecycle controls: - `new() -> Self` - `build(self) -> CommandQueue` `TurtlePlan` implements `WithCommands`, `Movement`, `Rotation`, `Pen`, `Fill`, `Cursor`, and `Text`. - **[`lib.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/lib.rs#L62-L65)**: Re-exports `Cursor`, `Fill`, `Movement`, `Pen`, `Rotation`, `Text`, `TurtlePlan`, `WithCommands`. - **Examples**: Updated [`clock.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/examples/clock.rs#L8), [`clock_threaded.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/examples/clock_threaded.rs#L9), [`dashed_circle.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/examples/dashed_circle.rs#L4), and [`bezier.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/examples/bezier.rs#L4) to use `use turtle_lib::*;`. - **[`README.md`](file:///home/dietrich/Projekte/Source/turtlers/README.md#L344)**: Updated trait references in the architecture outline. --- - **Unit & Doc Tests**: ```bash cargo test --package turtle-lib ``` Result: 17 unit tests passed; 34 doc-tests passed (0 failed). - **All Examples**: ```bash cargo check --package turtle-lib --examples ``` Result: Successfully compiled all 30 examples. - **Clippy**: ```bash cargo clippy --package turtle-lib -- -Wclippy::pedantic \ -Aclippy::cast_precision_loss -Aclippy::cast_sign_loss -Aclippy::cast_possible_truncation ``` Result: 0 warnings in `builders.rs`. --- README.md | 2 +- turtle-lib/examples/bezier.rs | 2 +- turtle-lib/examples/clock.rs | 2 +- turtle-lib/examples/clock_threaded.rs | 2 +- turtle-lib/examples/dashed_circle.rs | 2 +- turtle-lib/src/builders.rs | 1030 +++++++++++++------------ turtle-lib/src/drawing.rs | 7 +- turtle-lib/src/lib.rs | 5 +- 8 files changed, 533 insertions(+), 519 deletions(-) diff --git a/README.md b/README.md index c1e1ea7..b46ae37 100644 --- a/README.md +++ b/README.md @@ -341,7 +341,7 @@ turtle-lib/src/ ├── lib.rs - Public API and TurtleApp ├── state.rs - TurtleState and TurtleWorld ├── commands.rs - TurtleCommand enum (consolidated commands) -├── builders.rs - Builder traits (DirectionalMovement, Turnable, etc.) +├── builders.rs - Builder traits (Movement, Rotation, Pen, Fill, Cursor, Text) ├── execution.rs - Command execution with fill support ├── tweening.rs - Animation/tweening controller with dynamic speed ├── drawing.rs - Rendering with Lyon tessellation diff --git a/turtle-lib/examples/bezier.rs b/turtle-lib/examples/bezier.rs index 34fd65e..33160b0 100644 --- a/turtle-lib/examples/bezier.rs +++ b/turtle-lib/examples/bezier.rs @@ -1,7 +1,7 @@ //! Cubic Bézier curve example //! -use turtle_lib::{turtle_main, vec2}; +use turtle_lib::*; struct CubicBezier { point0: (f32, f32), diff --git a/turtle-lib/examples/clock.rs b/turtle-lib/examples/clock.rs index 44c27a9..a442d7e 100644 --- a/turtle-lib/examples/clock.rs +++ b/turtle-lib/examples/clock.rs @@ -5,7 +5,7 @@ use chrono::{Local, Timelike}; use macroquad::prelude::{clear_background, is_key_pressed, next_frame, KeyCode, WHITE}; -use turtle_lib::{create_turtle_plan, vec2, DirectionalMovement, Turnable, TurtleApp}; +use turtle_lib::*; #[macroquad::main("Clock")] async fn main() { diff --git a/turtle-lib/examples/clock_threaded.rs b/turtle-lib/examples/clock_threaded.rs index 6dba085..38eabd4 100644 --- a/turtle-lib/examples/clock_threaded.rs +++ b/turtle-lib/examples/clock_threaded.rs @@ -6,7 +6,7 @@ use chrono::{Local, Timelike}; use macroquad::prelude::{clear_background, is_key_pressed, next_frame, KeyCode, WHITE}; -use turtle_lib::{create_turtle_plan, vec2, DirectionalMovement, Turnable, TurtleApp}; +use turtle_lib::*; #[macroquad::main("Clock (Threaded)")] async fn main() { diff --git a/turtle-lib/examples/dashed_circle.rs b/turtle-lib/examples/dashed_circle.rs index a29c776..9b0efdc 100644 --- a/turtle-lib/examples/dashed_circle.rs +++ b/turtle-lib/examples/dashed_circle.rs @@ -1,7 +1,7 @@ //! Dashed circle example ported from sunjay/turtle //! This draws a dashed circle but uses `circle_left` arcs for each segment instead of individual short lines. -use turtle_lib::{turtle_main, vec2, CurvedMovement, Turnable}; +use turtle_lib::*; #[turtle_main("Dashed Circle")] fn draw(turtle: &mut TurtlePlan) { diff --git a/turtle-lib/src/builders.rs b/turtle-lib/src/builders.rs index b7fcab1..26a2988 100644 --- a/turtle-lib/src/builders.rs +++ b/turtle-lib/src/builders.rs @@ -10,8 +10,8 @@ pub trait WithCommands { fn get_commands(self) -> CommandQueue; } -/// Trait for forward/backward movement -pub trait DirectionalMovement: WithCommands { +/// Trait for turtle movement (linear, curved, and absolute positioning) +pub trait Movement: WithCommands { /// Moves the turtle forward by the specified distance. /// /// The turtle moves in the direction of its current heading. @@ -67,67 +67,40 @@ pub trait DirectionalMovement: WithCommands { self.get_commands_mut().push(TurtleCommand::Move(-dist)); self } -} -/// Trait for turning operations -pub trait Turnable: WithCommands { - /// Turns the turtle left (counter-clockwise) by the specified angle in degrees. + /// Moves the turtle to an absolute position. /// - /// Changes the turtle's heading without moving its position. - /// Does not draw anything. + /// The turtle moves in a straight line to the specified coordinates. + /// If the pen is down, a line is drawn. The turtle's heading is not changed. + /// + /// Coordinates use turtle-style Cartesian space: + /// - `(0, 0)` is at the center + /// - Positive x goes right + /// - Positive y goes up + /// + /// Internally, Macroquad uses Y-down screen coordinates; this command + /// performs the Y-axis conversion when executed. /// /// # Examples /// /// ```no_run /// # use turtle_lib::*; /// # - /// #[turtle_main("Left Turn Example")] + /// #[turtle_main("Goto Example")] /// fn draw(turtle: &mut TurtlePlan) { - /// // Draw a square using left turns - /// for _ in 0..4 { - /// turtle.forward(100.0).left(90.0); - /// } + /// // Draw a triangle by connecting points + /// turtle.go_to(vec2(0.0, 0.0)); + /// turtle.go_to(vec2(100.0, 0.0)); + /// turtle.go_to(vec2(50.0, 86.6)); + /// turtle.go_to(vec2(0.0, 0.0)); /// } /// ``` - fn left(&mut self, angle: T) -> &mut Self - where - T: Into, - { + fn go_to(&mut self, coord: impl Into) -> &mut Self { self.get_commands_mut() - .push(TurtleCommand::Turn(-angle.into())); + .push(TurtleCommand::Goto(coord.into())); self } - /// Turns the turtle right (clockwise) by the specified angle in degrees. - /// - /// Changes the turtle's heading without moving its position. - /// Does not draw anything. - /// - /// # Examples - /// - /// ```no_run - /// # use turtle_lib::*; - /// # - /// #[turtle_main("Right Turn Example")] - /// fn draw(turtle: &mut TurtlePlan) { - /// // Draw a triangle using right turns - /// for _ in 0..3 { - /// turtle.forward(100.0).right(120.0); - /// } - /// } - /// ``` - fn right(&mut self, angle: T) -> &mut Self - where - T: Into, - { - self.get_commands_mut() - .push(TurtleCommand::Turn(angle.into())); - self - } -} - -/// Trait for curved movement (circles) -pub trait CurvedMovement: WithCommands { /// Draws a circular arc turning to the left (counter-clockwise). /// /// The turtle draws a circular arc with the specified radius, sweeping through @@ -219,6 +192,502 @@ pub trait CurvedMovement: WithCommands { } } +/// Trait for turning and heading operations +pub trait Rotation: WithCommands { + /// Turns the turtle left (counter-clockwise) by the specified angle in degrees. + /// + /// Changes the turtle's heading without moving its position. + /// Does not draw anything. + /// + /// # Examples + /// + /// ```no_run + /// # use turtle_lib::*; + /// # + /// #[turtle_main("Left Turn Example")] + /// fn draw(turtle: &mut TurtlePlan) { + /// // Draw a square using left turns + /// for _ in 0..4 { + /// turtle.forward(100.0).left(90.0); + /// } + /// } + /// ``` + fn left(&mut self, angle: T) -> &mut Self + where + T: Into, + { + self.get_commands_mut() + .push(TurtleCommand::Turn(-angle.into())); + self + } + + /// Turns the turtle right (clockwise) by the specified angle in degrees. + /// + /// Changes the turtle's heading without moving its position. + /// Does not draw anything. + /// + /// # Examples + /// + /// ```no_run + /// # use turtle_lib::*; + /// # + /// #[turtle_main("Right Turn Example")] + /// fn draw(turtle: &mut TurtlePlan) { + /// // Draw a triangle using right turns + /// for _ in 0..3 { + /// turtle.forward(100.0).right(120.0); + /// } + /// } + /// ``` + fn right(&mut self, angle: T) -> &mut Self + where + T: Into, + { + self.get_commands_mut() + .push(TurtleCommand::Turn(angle.into())); + self + } + + /// Sets the turtle's absolute heading direction in degrees. + /// + /// - `0°` points to the right (east) + /// - `90°` points up (north) + /// - `180°` points left (west) + /// - `270°` points down (south) + /// + /// Internally, turtle heading is stored in radians in a Y-down render space. + /// This method converts from user-facing degrees (Y-up mental model) to that + /// internal representation. + /// + /// # Examples + /// + /// ```no_run + /// # use turtle_lib::*; + /// # + /// #[turtle_main("Heading Example")] + /// fn draw(turtle: &mut TurtlePlan) { + /// // Point upward + /// turtle.set_heading(90.0) + /// .forward(100.0); + /// + /// // Point left + /// turtle.set_heading(180.0) + /// .forward(100.0); + /// } + /// ``` + fn set_heading>(&mut self, heading: T) -> &mut Self { + self.get_commands_mut() + .push(TurtleCommand::SetHeading(heading.into())); + self + } +} + +/// Trait for pen control (state, color, width) +pub trait Pen: WithCommands { + /// Lifts the pen up so the turtle can move without drawing. + /// + /// When filling shapes, `pen_up()` also closes the current contour, + /// allowing you to create multi-contour fills (e.g., shapes with holes). + /// + /// # Examples + /// + /// ```no_run + /// # use turtle_lib::*; + /// # + /// #[turtle_main("Pen Up/Down Example")] + /// fn draw(turtle: &mut TurtlePlan) { + /// // Move without drawing + /// turtle.pen_up() + /// .forward(100.0) // No line drawn + /// .pen_down() + /// .forward(100.0); // Line drawn + /// + /// // Create a donut shape (outer circle with inner hole) + /// turtle.set_fill_color(BLUE) + /// .begin_fill() + /// .circle_left(100.0, 360.0, 72) // Outer circle + /// .pen_up() // Close first contour + /// .go_to(vec2(0.0, -30.0)) + /// .pen_down() // Start second contour + /// .circle_left(30.0, 360.0, 36) // Inner circle (becomes hole) + /// .end_fill(); + /// } + /// ``` + fn pen_up(&mut self) -> &mut Self { + self.get_commands_mut().push(TurtleCommand::PenUp); + self + } + + /// Lowers the pen so the turtle draws when moving. + /// + /// This is the default state. When filling shapes, `pen_down()` starts + /// a new contour after `pen_up()` was called. + /// + /// # Examples + /// + /// ```no_run + /// # use turtle_lib::*; + /// # + /// #[turtle_main("Pen Down Example")] + /// fn draw(turtle: &mut TurtlePlan) { + /// turtle.pen_up() + /// .forward(50.0) // Move without drawing + /// .pen_down() // Start drawing + /// .forward(100.0); // Line appears + /// } + /// ``` + fn pen_down(&mut self) -> &mut Self { + self.get_commands_mut().push(TurtleCommand::PenDown); + self + } + + /// Sets the pen color for drawing lines. + /// + /// The pen color affects all subsequent drawing operations (forward, backward, circles) + /// until changed again. Does not affect fill color. + /// + /// # Examples + /// + /// ```no_run + /// # use turtle_lib::*; + /// # + /// #[turtle_main("Pen Color Example")] + /// fn draw(turtle: &mut TurtlePlan) { + /// // Draw with predefined colors + /// turtle.set_pen_color(RED) + /// .forward(100.0) + /// .set_pen_color(BLUE) + /// .right(90.0) + /// .forward(100.0); + /// } + /// ``` + fn set_pen_color(&mut self, color: Color) -> &mut Self { + self.get_commands_mut().push(TurtleCommand::SetColor(color)); + self + } + + /// Sets the pen width (thickness) for drawing lines. + /// + /// The width is measured in pixels. Default is typically 2.0. + /// + /// # Examples + /// + /// ```no_run + /// # use turtle_lib::*; + /// # + /// #[turtle_main("Pen Width Example")] + /// fn draw(turtle: &mut TurtlePlan) { + /// // Thin line + /// turtle.set_pen_width(1.0) + /// .forward(100.0); + /// // Thick line + /// turtle.set_pen_width(10.0) + /// .forward(100.0); + /// } + /// ``` + fn set_pen_width(&mut self, width: Precision) -> &mut Self { + self.get_commands_mut() + .push(TurtleCommand::SetPenWidth(width)); + self + } +} + +/// Trait for shape fill operations +pub trait Fill: WithCommands { + /// Starts recording a shape to be filled. + /// + /// All turtle movements between `begin_fill()` and `end_fill()` define + /// the shape's outline. The shape is filled using the fill color when + /// `end_fill()` is called. + /// + /// Multiple contours can be created using `pen_up()` and `pen_down()`. + /// The `EvenOdd` fill rule automatically creates holes for inner contours. + /// + /// # Examples + /// + /// ```no_run + /// # use turtle_lib::*; + /// # + /// #[turtle_main("Fill Example")] + /// fn draw(turtle: &mut TurtlePlan) { + /// // Fill a square + /// turtle.set_fill_color(BLUE) + /// .begin_fill(); + /// for _ in 0..4 { + /// turtle.forward(100.0).right(90.0); + /// } + /// turtle.end_fill(); + /// + /// // Fill a circle + /// turtle.pen_up().go_to(vec2(150.0, 0.0)).pen_down(); + /// turtle.set_fill_color(RED) + /// .begin_fill() + /// .circle_left(50.0, 360.0, 36) + /// .end_fill(); + /// } + /// ``` + fn begin_fill(&mut self) -> &mut Self { + self.get_commands_mut().push(TurtleCommand::BeginFill); + self + } + + /// Completes the fill operation started with `begin_fill()`. + /// + /// Closes the current shape and fills it with the fill color. + /// All contours recorded since `begin_fill()` are filled together. + /// + /// # Examples + /// + /// ```no_run + /// # use turtle_lib::*; + /// # + /// #[turtle_main("End Fill Example")] + /// fn draw(turtle: &mut TurtlePlan) { + /// // Triangle with fill + /// turtle.set_fill_color(GREEN) + /// .begin_fill(); + /// for _ in 0..3 { + /// turtle.forward(100.0).right(120.0); + /// } + /// turtle.end_fill(); + /// } + /// ``` + fn end_fill(&mut self) -> &mut Self { + self.get_commands_mut().push(TurtleCommand::EndFill); + self + } + + /// Sets the color used to fill shapes. + /// + /// This affects all shapes filled with `begin_fill()`/`end_fill()`. + /// Independent from the pen color used for outlines. + /// + /// # Examples + /// + /// ```no_run + /// # use turtle_lib::*; + /// # + /// #[turtle_main("Fill Color Example")] + /// fn draw(turtle: &mut TurtlePlan) { + /// // Yellow fill with blue outline + /// turtle.set_fill_color(YELLOW) + /// .set_pen_color(BLUE) + /// .begin_fill() + /// .circle_left(50.0, 360.0, 36) + /// .end_fill(); + /// } + /// ``` + fn set_fill_color(&mut self, color: impl Into) -> &mut Self { + self.get_commands_mut() + .push(TurtleCommand::SetFillColor(Some(color.into()))); + self + } +} + +/// Trait for turtle cursor visibility, shape, animation speed, and reset +pub trait Cursor: WithCommands { + /// Hides the turtle cursor from view. + /// + /// The turtle will still execute commands and draw, but the cursor + /// (typically an arrow or triangle) won't be visible. + /// + /// # Examples + /// + /// ```no_run + /// # use turtle_lib::*; + /// # + /// #[turtle_main("Hide Turtle Example")] + /// fn draw(turtle: &mut TurtlePlan) { + /// turtle.hide() // Turtle cursor invisible + /// .forward(100.0) + /// .right(90.0) + /// .forward(100.0); + /// } + /// ``` + fn hide(&mut self) -> &mut Self { + self.get_commands_mut().push(TurtleCommand::HideTurtle); + self + } + + /// Shows the turtle cursor. + /// + /// Makes the turtle cursor visible if it was previously hidden. + /// This is the default state. + /// + /// # Examples + /// + /// ```no_run + /// # use turtle_lib::*; + /// # + /// #[turtle_main("Show Turtle Example")] + /// fn draw(turtle: &mut TurtlePlan) { + /// turtle.hide() + /// .forward(100.0) + /// .show() // Turtle becomes visible again + /// .forward(100.0); + /// } + /// ``` + fn show(&mut self) -> &mut Self { + self.get_commands_mut().push(TurtleCommand::ShowTurtle); + self + } + + /// Sets the turtle's shape using a `TurtleShape` object. + /// + /// For most use cases, prefer using `shape()` which accepts a `ShapeType` enum. + /// + /// # Examples + /// + /// ``` + /// # use turtle_lib::*; + /// # + /// #[turtle_main("Shape Example")] + /// fn draw(turtle: &mut TurtlePlan) { + /// let custom_shape = ShapeType::Arrow.to_shape(); + /// turtle.set_shape(custom_shape); + /// } + /// ``` + fn set_shape(&mut self, shape: TurtleShape) -> &mut Self { + self.get_commands_mut() + .push(TurtleCommand::SetShape(shape)); + self + } + + /// Sets the turtle's visual appearance. + /// + /// Available shapes: `Arrow`, `Triangle`, `Square`, `Circle`. + /// + /// # Examples + /// + /// ```no_run + /// # use turtle_lib::*; + /// # + /// #[turtle_main("Shape Example")] + /// fn draw(turtle: &mut TurtlePlan) { + /// // Use different shapes + /// turtle.shape(ShapeType::Arrow) + /// .forward(50.0) + /// .shape(ShapeType::Circle) + /// .forward(50.0); + /// } + /// ``` + fn shape(&mut self, shape_type: ShapeType) -> &mut Self { + self.set_shape(shape_type.to_shape()) + } + + /// Sets the animation speed for turtle movements. + /// + /// Speed controls how fast the turtle moves during animations: + /// - Values `>= 1000`: Instant mode - commands execute immediately without animation. + /// The bigger the number, the more segments are drawn per frame. + /// - Values `< 1000`: Animated mode - turtle moves at specified pixels per second + /// + /// You can dynamically switch between instant and animated modes during execution. + /// + /// # Examples + /// + /// ```no_run + /// # use turtle_lib::*; + /// # + /// #[turtle_main("Speed Example")] + /// fn draw(turtle: &mut TurtlePlan) { + /// // Slow animation at 50 pixels/second + /// turtle.set_speed(50.0) + /// .forward(100.0); + /// + /// // Switch to instant mode + /// turtle.set_speed(1000.0) + /// .forward(100.0); // Executes immediately + /// } + /// ``` + fn set_speed(&mut self, speed: impl Into) -> &mut Self { + self.get_commands_mut() + .push(TurtleCommand::SetSpeed(speed.into())); + self + } + + /// Resets the turtle to its default state. + /// + /// This clears all drawings, clears active fill state, and resets turtle parameters: + /// - Position: (0, 0) + /// - Heading: 0° (facing right) + /// - Pen: down + /// - Pen width: 2.0 + /// - Pen color: black + /// - Fill color: none + /// - Visibility: visible + /// - Shape: arrow + /// - Speed: default + /// + /// Note: queued commands are not removed; `reset()` itself is a command in the queue. + /// + /// # Examples + /// + /// ```no_run + /// # use turtle_lib::*; + /// # + /// #[turtle_main("Reset Example")] + /// fn draw(turtle: &mut TurtlePlan) { + /// // Draw something + /// turtle.forward(100.0); + /// + /// // Reset everything back to default + /// turtle.reset(); + /// + /// // Start fresh + /// turtle.forward(50.0); + /// } + /// ``` + fn reset(&mut self) -> &mut Self { + self.get_commands_mut().push(TurtleCommand::Reset); + self + } +} + +/// Trait for drawing text with the turtle +pub trait Text: WithCommands { + /// Writes text at the turtle's current position, oriented along its heading direction. + /// + /// The text is rendered with its baseline positioned slightly above the turtle's current position, + /// and rotated to align with the turtle's current heading. + /// + /// # Arguments + /// + /// * `text` - The text to render (can be `&str` or `String`) + /// * `font_size` - The font size, can be any type that converts to `FontSize` (e.g., `f32`, `u16`, `i32`) + /// + /// # Examples + /// + /// ```no_run + /// # use turtle_lib::*; + /// # + /// #[turtle_main("Text Example")] + /// fn draw(turtle: &mut TurtlePlan) { + /// // Write text at current position (heading 0° = horizontal) + /// turtle.write_text("Hello", 20.0); + /// + /// // Move forward and write at an angle + /// turtle.forward(100.0) + /// .right(45.0) + /// .write_text("World", 24); + /// + /// // Chain with other commands + /// turtle.forward(50.0) + /// .write_text("End", 16u16); + /// } + /// ``` + fn write_text(&mut self, text: impl Into, font_size: T) -> &mut Self + where + T: Into, + { + self.get_commands_mut().push(TurtleCommand::WriteText { + text: text.into(), + font_size: font_size.into(), + }); + self + } +} + /// Builder for creating turtle command sequences #[derive(Clone, Default, Debug)] pub struct TurtlePlan { @@ -262,462 +731,6 @@ impl TurtlePlan { } } - /// Sets the animation speed for turtle movements. - /// - /// Speed controls how fast the turtle moves during animations: - /// - Values `>= 1000`: Instant mode - commands execute immediately without animation. - /// The bigger the number, the more segments are drawn per frame. - /// - Values `< 1000`: Animated mode - turtle moves at specified pixels per second - /// - /// You can dynamically switch between instant and animated modes during execution. - /// - /// # Examples - /// - /// ```no_run - /// # use turtle_lib::*; - /// # - /// #[turtle_main("Speed Example")] - /// fn draw(turtle: &mut TurtlePlan) { - /// // Slow animation at 50 pixels/second - /// turtle.set_speed(50.0) - /// .forward(100.0); - /// - /// // Switch to instant mode - /// turtle.set_speed(1000.0) - /// .forward(100.0); // Executes immediately - /// } - /// ``` - pub fn set_speed(&mut self, speed: impl Into) -> &mut Self { - self.queue.push(TurtleCommand::SetSpeed(speed.into())); - self - } - - /// Sets the pen color for drawing lines. - /// - /// The pen color affects all subsequent drawing operations (forward, backward, circles) - /// until changed again. Does not affect fill color. - /// - /// # Examples - /// - /// ```no_run - /// # use turtle_lib::*; - /// # - /// #[turtle_main("Pen Color Example")] - /// fn draw(turtle: &mut TurtlePlan) { - /// // Draw with predefined colors - /// turtle.set_pen_color(RED) - /// .forward(100.0) - /// .set_pen_color(BLUE) - /// .right(90.0) - /// .forward(100.0); - /// } - /// ``` - pub fn set_pen_color(&mut self, color: Color) -> &mut Self { - self.queue.push(TurtleCommand::SetColor(color)); - self - } - - /// Sets the pen width (thickness) for drawing lines. - /// - /// The width is measured in pixels. Default is typically 2.0. - /// - /// # Examples - /// - /// ```no_run - /// # use turtle_lib::*; - /// # - /// #[turtle_main("Pen Width Example")] - /// fn draw(turtle: &mut TurtlePlan) { - /// // Thin line - /// turtle.set_pen_width(1.0) - /// .forward(100.0); - /// - /// // Thick line - /// turtle.set_pen_width(10.0) - /// .forward(100.0); - /// } - /// ``` - pub fn set_pen_width(&mut self, width: Precision) -> &mut Self { - self.queue.push(TurtleCommand::SetPenWidth(width)); - self - } - - /// Sets the turtle's absolute heading direction in degrees. - /// - /// - `0°` points to the right (east) - /// - `90°` points up (north) - /// - `180°` points left (west) - /// - `270°` points down (south) - /// - /// Internally, turtle heading is stored in radians in a Y-down render space. - /// This method converts from user-facing degrees (Y-up mental model) to that - /// internal representation. - /// - /// # Examples - /// - /// ```no_run - /// # use turtle_lib::*; - /// # - /// #[turtle_main("Heading Example")] - /// fn draw(turtle: &mut TurtlePlan) { - /// // Point upward - /// turtle.set_heading(90.0) - /// .forward(100.0); - /// - /// // Point left - /// turtle.set_heading(180.0) - /// .forward(100.0); - /// } - /// ``` - pub fn set_heading>(&mut self, heading: T) -> &mut Self { - self.queue.push(TurtleCommand::SetHeading(heading.into())); - self - } - - /// Lifts the pen up so the turtle can move without drawing. - /// - /// When filling shapes, `pen_up()` also closes the current contour, - /// allowing you to create multi-contour fills (e.g., shapes with holes). - /// - /// # Examples - /// - /// ```no_run - /// # use turtle_lib::*; - /// # - /// #[turtle_main("Pen Up/Down Example")] - /// fn draw(turtle: &mut TurtlePlan) { - /// // Move without drawing - /// turtle.pen_up() - /// .forward(100.0) // No line drawn - /// .pen_down() - /// .forward(100.0); // Line drawn - /// - /// // Create a donut shape (outer circle with inner hole) - /// turtle.set_fill_color(BLUE) - /// .begin_fill() - /// .circle_left(100.0, 360.0, 72) // Outer circle - /// .pen_up() // Close first contour - /// .go_to(vec2(0.0, -30.0)) - /// .pen_down() // Start second contour - /// .circle_left(30.0, 360.0, 36) // Inner circle (becomes hole) - /// .end_fill(); - /// } - /// ``` - pub fn pen_up(&mut self) -> &mut Self { - self.queue.push(TurtleCommand::PenUp); - self - } - - /// Lowers the pen so the turtle draws when moving. - /// - /// This is the default state. When filling shapes, `pen_down()` starts - /// a new contour after `pen_up()` was called. - /// - /// # Examples - /// - /// ```no_run - /// # use turtle_lib::*; - /// # - /// #[turtle_main("Pen Down Example")] - /// fn draw(turtle: &mut TurtlePlan) { - /// turtle.pen_up() - /// .forward(50.0) // Move without drawing - /// .pen_down() // Start drawing - /// .forward(100.0); // Line appears - /// } - /// ``` - pub fn pen_down(&mut self) -> &mut Self { - self.queue.push(TurtleCommand::PenDown); - self - } - - /// Hides the turtle cursor from view. - /// - /// The turtle will still execute commands and draw, but the cursor - /// (typically an arrow or triangle) won't be visible. - /// - /// # Examples - /// - /// ```no_run - /// # use turtle_lib::*; - /// # - /// #[turtle_main("Hide Turtle Example")] - /// fn draw(turtle: &mut TurtlePlan) { - /// turtle.hide() // Turtle cursor invisible - /// .forward(100.0) - /// .right(90.0) - /// .forward(100.0); - /// } - /// ``` - pub fn hide(&mut self) -> &mut Self { - self.queue.push(TurtleCommand::HideTurtle); - self - } - - /// Shows the turtle cursor. - /// - /// Makes the turtle cursor visible if it was previously hidden. - /// This is the default state. - /// - /// # Examples - /// - /// ```no_run - /// # use turtle_lib::*; - /// # - /// #[turtle_main("Show Turtle Example")] - /// fn draw(turtle: &mut TurtlePlan) { - /// turtle.hide() - /// .forward(100.0) - /// .show() // Turtle becomes visible again - /// .forward(100.0); - /// } - /// ``` - pub fn show(&mut self) -> &mut Self { - self.queue.push(TurtleCommand::ShowTurtle); - self - } - - /// Sets the turtle's shape using a `TurtleShape` object. - /// - /// For most use cases, prefer using `shape()` which accepts a `ShapeType` enum. - /// - /// # Examples - /// - /// ``` - /// # use turtle_lib::*; - /// # - /// #[turtle_main("Shape Example")] - /// fn draw(turtle: &mut TurtlePlan) { - /// let custom_shape = ShapeType::Arrow.to_shape(); - /// turtle.set_shape(custom_shape); - /// } - /// ``` - pub fn set_shape(&mut self, shape: TurtleShape) -> &mut Self { - self.queue.push(TurtleCommand::SetShape(shape)); - self - } - - /// Sets the turtle's visual appearance. - /// - /// Available shapes: `Arrow`, `Triangle`, `Square`, `Circle`. - /// - /// # Examples - /// - /// ```no_run - /// # use turtle_lib::*; - /// # - /// #[turtle_main("Shape Example")] - /// fn draw(turtle: &mut TurtlePlan) { - /// // Use different shapes - /// turtle.shape(ShapeType::Arrow) - /// .forward(50.0) - /// .shape(ShapeType::Circle) - /// .forward(50.0); - /// } - /// ``` - pub fn shape(&mut self, shape_type: ShapeType) -> &mut Self { - self.set_shape(shape_type.to_shape()) - } - - /// Starts recording a shape to be filled. - /// - /// All turtle movements between `begin_fill()` and `end_fill()` define - /// the shape's outline. The shape is filled using the fill color when - /// `end_fill()` is called. - /// - /// Multiple contours can be created using `pen_up()` and `pen_down()`. - /// The `EvenOdd` fill rule automatically creates holes for inner contours. - /// - /// # Examples - /// - /// ```no_run - /// # use turtle_lib::*; - /// # - /// #[turtle_main("Fill Example")] - /// fn draw(turtle: &mut TurtlePlan) { - /// // Fill a square - /// turtle.set_fill_color(BLUE) - /// .begin_fill(); - /// for _ in 0..4 { - /// turtle.forward(100.0).right(90.0); - /// } - /// turtle.end_fill(); - /// - /// // Fill a circle - /// turtle.pen_up().go_to(vec2(150.0, 0.0)).pen_down(); - /// turtle.set_fill_color(RED) - /// .begin_fill() - /// .circle_left(50.0, 360.0, 36) - /// .end_fill(); - /// } - /// ``` - pub fn begin_fill(&mut self) -> &mut Self { - self.queue.push(TurtleCommand::BeginFill); - self - } - - /// Completes the fill operation started with `begin_fill()`. - /// - /// Closes the current shape and fills it with the fill color. - /// All contours recorded since `begin_fill()` are filled together. - /// - /// # Examples - /// - /// ```no_run - /// # use turtle_lib::*; - /// # - /// #[turtle_main("End Fill Example")] - /// fn draw(turtle: &mut TurtlePlan) { - /// // Triangle with fill - /// turtle.set_fill_color(GREEN) - /// .begin_fill(); - /// for _ in 0..3 { - /// turtle.forward(100.0).right(120.0); - /// } - /// turtle.end_fill(); - /// } - /// ``` - pub fn end_fill(&mut self) -> &mut Self { - self.queue.push(TurtleCommand::EndFill); - self - } - - /// Sets the color used to fill shapes. - /// - /// This affects all shapes filled with `begin_fill()`/`end_fill()`. - /// Independent from the pen color used for outlines. - /// - /// # Examples - /// - /// ```no_run - /// # use turtle_lib::*; - /// # - /// #[turtle_main("Fill Color Example")] - /// fn draw(turtle: &mut TurtlePlan) { - /// // Yellow fill with blue outline - /// turtle.set_fill_color(YELLOW) - /// .set_pen_color(BLUE) - /// .begin_fill() - /// .circle_left(50.0, 360.0, 36) - /// .end_fill(); - /// } - /// ``` - pub fn set_fill_color(&mut self, color: impl Into) -> &mut Self { - self.queue - .push(TurtleCommand::SetFillColor(Some(color.into()))); - self - } - - /// Moves the turtle to an absolute position. - /// - /// The turtle moves in a straight line to the specified coordinates. - /// If the pen is down, a line is drawn. The turtle's heading is not changed. - /// - /// Coordinates use turtle-style Cartesian space: - /// - `(0, 0)` is at the center - /// - Positive x goes right - /// - Positive y goes up - /// - /// Internally, Macroquad uses Y-down screen coordinates; this command - /// performs the Y-axis conversion when executed. - /// - /// # Examples - /// - /// ```no_run - /// # use turtle_lib::*; - /// # - /// #[turtle_main("Goto Example")] - /// fn draw(turtle: &mut TurtlePlan) { - /// // Draw a triangle by connecting points - /// turtle.go_to(vec2(0.0, 0.0)); - /// turtle.go_to(vec2(100.0, 0.0)); - /// turtle.go_to(vec2(50.0, 86.6)); - /// turtle.go_to(vec2(0.0, 0.0)); - /// } - /// ``` - pub fn go_to(&mut self, coord: impl Into) -> &mut Self { - self.queue.push(TurtleCommand::Goto(coord.into())); - self - } - - /// Writes text at the turtle's current position, oriented along its heading direction. - /// - /// The text is rendered with its baseline positioned slightly above the turtle's current position, - /// and rotated to align with the turtle's current heading. - /// - /// # Arguments - /// - /// * `text` - The text to render (can be `&str` or `String`) - /// * `font_size` - The font size, can be any type that converts to `FontSize` (e.g., `f32`, `u16`, `i32`) - /// - /// # Examples - /// - /// ```no_run - /// # use turtle_lib::*; - /// # - /// #[turtle_main("Text Example")] - /// fn draw(turtle: &mut TurtlePlan) { - /// // Write text at current position (heading 0° = horizontal) - /// turtle.write_text("Hello", 20.0); - /// - /// // Move forward and write at an angle - /// turtle.forward(100.0) - /// .right(45.0) - /// .write_text("World", 24); - /// - /// // Chain with other commands - /// turtle.forward(50.0) - /// .write_text("End", 16u16); - /// } - /// ``` - pub fn write_text(&mut self, text: impl Into, font_size: T) -> &mut Self - where - T: Into, - { - self.queue.push(TurtleCommand::WriteText { - text: text.into(), - font_size: font_size.into(), - }); - self - } - - /// Resets the turtle to its default state. - /// - /// This clears all drawings, clears active fill state, and resets turtle parameters: - /// - Position: (0, 0) - /// - Heading: 0° (facing right) - /// - Pen: down - /// - Pen width: 2.0 - /// - Pen color: black - /// - Fill color: none - /// - Visibility: visible - /// - Shape: arrow - /// - Speed: default - /// - /// Note: queued commands are not removed; `reset()` itself is a command in the queue. - /// - /// # Examples - /// - /// ```no_run - /// # use turtle_lib::*; - /// # - /// #[turtle_main("Reset Example")] - /// fn draw(turtle: &mut TurtlePlan) { - /// // Draw something - /// turtle.forward(100.0); - /// - /// // Reset everything back to default - /// turtle.reset(); - /// - /// // Start fresh - /// turtle.forward(50.0); - /// } - /// ``` - pub fn reset(&mut self) -> &mut Self { - self.queue.push(TurtleCommand::Reset); - self - } - /// Consumes the `TurtlePlan` and returns the command queue. /// /// Use this to finalize the turtle commands and pass them to `TurtleApp`. @@ -751,6 +764,9 @@ impl WithCommands for TurtlePlan { } } -impl DirectionalMovement for TurtlePlan {} -impl Turnable for TurtlePlan {} -impl CurvedMovement for TurtlePlan {} +impl Movement for TurtlePlan {} +impl Rotation for TurtlePlan {} +impl Pen for TurtlePlan {} +impl Fill for TurtlePlan {} +impl Cursor for TurtlePlan {} +impl Text for TurtlePlan {} diff --git a/turtle-lib/src/drawing.rs b/turtle-lib/src/drawing.rs index fb2a505..62db295 100644 --- a/turtle-lib/src/drawing.rs +++ b/turtle-lib/src/drawing.rs @@ -5,13 +5,11 @@ use crate::state::{DrawCommand, TurtleParams, TurtleWorld}; use crate::tessellation; use macroquad::prelude::*; - - /// Render the turtle world with active tween visualization. #[allow(clippy::too_many_lines)] pub(crate) fn render_world_with_tweens(world: &TurtleWorld, zoom_level: f32) { // Update camera zoom based on current screen size to prevent stretching - // Apply user zoom level by dividing by it (smaller zoom value = more zoomed in) + // Apply user zoom level by dividing by it let camera = Camera2D { zoom: vec2( 1.0 / screen_width() * 2.0 / zoom_level, @@ -295,8 +293,7 @@ fn draw_tween_arc( ); // Draw center using Lyon tessellation this helps visualizing what is done. - if let Ok(mesh) = crate::tessellation::tessellate_circle(geom.center, 5.0, GRAY, true, 1.0) - { + if let Ok(mesh) = crate::tessellation::tessellate_circle(geom.center, 5.0, GRAY, true, 1.0) { draw_mesh(&mesh); } diff --git a/turtle-lib/src/lib.rs b/turtle-lib/src/lib.rs index 9b456d9..d2289f6 100644 --- a/turtle-lib/src/lib.rs +++ b/turtle-lib/src/lib.rs @@ -59,8 +59,9 @@ pub(crate) mod state; pub(crate) mod tessellation; pub(crate) mod tweening; -// Re-export commonly used types -pub use builders::{CurvedMovement, DirectionalMovement, Turnable, TurtlePlan, WithCommands}; +pub use builders::{ + Cursor, Fill, Movement, Pen, Rotation, Text, TurtlePlan, WithCommands, +}; pub use commands::{CommandQueue, TurtleCommand}; pub use commands_channel::TurtleCommandSender; pub use general::{AnimationSpeed, Color, Coordinate, Degrees, Length, Precision, Radians}; From a5261feca3480e6d5f86bd11fbe60c31916d067a Mon Sep 17 00:00:00 2001 From: Franz Dietrich Date: Sat, 19 Sep 2026 11:58:17 +0200 Subject: [PATCH 10/12] Update turtle-lib/src/general/fontsize.rs Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- turtle-lib/src/general/fontsize.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/turtle-lib/src/general/fontsize.rs b/turtle-lib/src/general/fontsize.rs index 42f7fe2..802f1b4 100644 --- a/turtle-lib/src/general/fontsize.rs +++ b/turtle-lib/src/general/fontsize.rs @@ -49,7 +49,7 @@ impl From for FontSize { impl From for FontSize { fn from(size: usize) -> Self { - Self((size as u16).max(1)) + Self(u16::try_from(size).unwrap_or(u16::MAX).max(1)) } } From 99a51ef40e1c1e868d0ec10b2174445a5c2b44d0 Mon Sep 17 00:00:00 2001 From: Franz Dietrich Date: Sat, 19 Sep 2026 12:34:01 +0200 Subject: [PATCH 11/12] Fix Greptile review comment regarding parameter name preservation in turtle-lib-macros Summary of Changes Preserve Parameter Pattern in Macro Expansion: In turtle-lib-macros/src/lib.rs , updated the helper function generation when has_turtle_param is true: rust let param = &input_fn.sig.inputs[0]; quote! { } This retains the exact parameter pattern and identifier (e.g. t: &mut TurtlePlan, mut t: &mut TurtlePlan, etc.) rather than replacing it with turtle: &mut turtle_lib::TurtlePlan. Preserves user function visibility (#fn_vis) for named non-main helper functions. Validation of Parameters: In validate_input, explicitly reject self receivers (FnArg::Receiver) with an informative error message. Reject unsupported patterns (e.g., tuple destructuring or struct patterns) during validation, only accepting identifier patterns (syn::Pat::Ident) and wildcards (syn::Pat::Wild). Macro Expansion Testing: Factored macro expansion logic into turtle_main_impl(&args, input) -> Result so macro expansions can be parsed into syn::File and verified directly in unit tests. Added unit tests verifying: Expansion with custom parameter names like t: &mut TurtlePlan preserves t Expansion with mutable parameters like mut t: &mut TurtlePlan Expansion when the function name is main renames to __turtle_main_draw while preserving parameter t Expansion for zero-argument functions generates parameter turtle Rejection of &mut self Rejection of unsupported destructuring patterns like (a, b) Verification cargo test --package turtle-lib-macros: All 13 tests passed. cargo clippy --package turtle-lib-macros -- -Wclippy::pedantic: Passed with 0 warnings. cargo test --all-targets --all-features: All unit and doc tests across the workspace passed. cargo check --package turtle-lib --examples: All 30 examples compiled cleanly. --- turtle-lib-macros/src/lib.rs | 230 +++++++++++++++++++++++++++++++++-- 1 file changed, 222 insertions(+), 8 deletions(-) diff --git a/turtle-lib-macros/src/lib.rs b/turtle-lib-macros/src/lib.rs index e433c9b..45d32be 100644 --- a/turtle-lib-macros/src/lib.rs +++ b/turtle-lib-macros/src/lib.rs @@ -6,7 +6,7 @@ use proc_macro::TokenStream; use quote::quote; -use syn::{parse_macro_input, ItemFn}; +use syn::ItemFn; /// A convenience macro that wraps your turtle drawing code with the necessary /// boilerplate for running a turtle graphics program. @@ -117,6 +117,30 @@ fn validate_input(input_fn: &ItemFn) -> Result<(), syn::Error> { )); } + if let Some(arg) = input_fn.sig.inputs.first() { + match arg { + syn::FnArg::Receiver(receiver) => { + return Err(syn::Error::new_spanned( + receiver, + "#[turtle_main] functions cannot take a `self` parameter", + )); + } + syn::FnArg::Typed(pat_type) => { + match &*pat_type.pat { + syn::Pat::Ident(pat_ident) + if pat_ident.by_ref.is_none() && pat_ident.subpat.is_none() => {} + syn::Pat::Wild(_) => {} + _ => { + return Err(syn::Error::new_spanned( + &pat_type.pat, + "#[turtle_main] unsupported parameter pattern; expected an identifier like `turtle` or `t`", + )); + } + } + } + } + } + if matches!(input_fn.sig.output, syn::ReturnType::Type(..)) { return Err(syn::Error::new_spanned( &input_fn.sig.output, @@ -129,12 +153,19 @@ fn validate_input(input_fn: &ItemFn) -> Result<(), syn::Error> { #[proc_macro_attribute] pub fn turtle_main(args: TokenStream, input: TokenStream) -> TokenStream { - let input_fn = parse_macro_input!(input as ItemFn); + turtle_main_impl(&args.into(), input.into()) + .unwrap_or_else(|err| err.to_compile_error()) + .into() +} + +fn turtle_main_impl( + args: &proc_macro2::TokenStream, + input: proc_macro2::TokenStream, +) -> Result { + let input_fn: ItemFn = syn::parse2(input)?; // Validate function signature - if let Err(err) = validate_input(&input_fn) { - return err.to_compile_error().into(); - } + validate_input(&input_fn)?; // Parse the window title from args (default to "Turtle Graphics") let window_title = if args.is_empty() { @@ -149,6 +180,11 @@ pub fn turtle_main(args: TokenStream, input: TokenStream) -> TokenStream { let fn_name = &input_fn.sig.ident; let fn_block = &input_fn.block; let fn_attrs = &input_fn.attrs; + let fn_vis = if fn_name == "main" { + None + } else { + Some(&input_fn.vis) + }; let has_turtle_param = input_fn.sig.inputs.len() == 1; let helper_name = if fn_name == "main" { @@ -158,14 +194,15 @@ pub fn turtle_main(args: TokenStream, input: TokenStream) -> TokenStream { }; let helper_fn = if has_turtle_param { + let param = &input_fn.sig.inputs[0]; quote! { #(#fn_attrs)* - fn #helper_name(turtle: &mut turtle_lib::TurtlePlan) #fn_block + #fn_vis fn #helper_name(#param) #fn_block } } else { quote! { #(#fn_attrs)* - fn #helper_name(turtle: &mut turtle_lib::TurtlePlan) { + #fn_vis fn #helper_name(turtle: &mut turtle_lib::TurtlePlan) { let turtle = turtle; #fn_block } @@ -226,7 +263,7 @@ pub fn turtle_main(args: TokenStream, input: TokenStream) -> TokenStream { #helper_fn }; - TokenStream::from(expanded) + Ok(expanded) } #[cfg(test)] @@ -254,6 +291,24 @@ mod tests { assert!(validate_input(&input).is_ok()); } + #[test] + fn test_valid_mut_arg() { + let input: ItemFn = parse_quote! { + fn my_draw(mut t: &mut TurtlePlan) { + t.forward(100.0); + } + }; + assert!(validate_input(&input).is_ok()); + } + + #[test] + fn test_valid_wildcard_arg() { + let input: ItemFn = parse_quote! { + fn my_draw(_: &mut TurtlePlan) {} + }; + assert!(validate_input(&input).is_ok()); + } + #[test] fn test_rejects_async() { let input: ItemFn = parse_quote! { @@ -272,6 +327,24 @@ mod tests { assert!(err.to_string().contains("must take either 0 arguments or a single")); } + #[test] + fn test_rejects_self() { + let input: ItemFn = parse_quote! { + fn my_draw(&mut self) {} + }; + let err = validate_input(&input).unwrap_err(); + assert!(err.to_string().contains("cannot take a `self` parameter")); + } + + #[test] + fn test_rejects_unsupported_pattern() { + let input: ItemFn = parse_quote! { + fn my_draw((a, b): &mut TurtlePlan) {} + }; + let err = validate_input(&input).unwrap_err(); + assert!(err.to_string().contains("unsupported parameter pattern")); + } + #[test] fn test_rejects_return_type() { let input: ItemFn = parse_quote! { @@ -282,4 +355,145 @@ mod tests { let err = validate_input(&input).unwrap_err(); assert!(err.to_string().contains("cannot have a return type")); } + + #[test] + fn test_expansion_preserves_custom_param_name() { + let input = quote! { + fn my_draw(t: &mut TurtlePlan) { + t.forward(100.0); + } + }; + let output = turtle_main_impl("e!(), input).unwrap(); + let file: syn::File = syn::parse2(output).unwrap(); + + let helper_fn = file + .items + .iter() + .find_map(|item| { + if let syn::Item::Fn(f) = item { + if f.sig.ident == "my_draw" { + return Some(f); + } + } + None + }) + .expect("helper fn `my_draw` should exist"); + + let first_arg = helper_fn.sig.inputs.first().expect("should have 1 arg"); + if let syn::FnArg::Typed(pat_type) = first_arg { + if let syn::Pat::Ident(pat_ident) = &*pat_type.pat { + assert_eq!(pat_ident.ident, "t"); + } else { + panic!("expected ident pattern"); + } + } else { + panic!("expected typed arg"); + } + } + + #[test] + fn test_expansion_preserves_mut_param() { + let input = quote! { + fn my_draw(mut t: &mut TurtlePlan) { + t.forward(100.0); + } + }; + let output = turtle_main_impl("e!(), input).unwrap(); + let file: syn::File = syn::parse2(output).unwrap(); + + let helper_fn = file + .items + .iter() + .find_map(|item| { + if let syn::Item::Fn(f) = item { + if f.sig.ident == "my_draw" { + return Some(f); + } + } + None + }) + .expect("helper fn `my_draw` should exist"); + + let first_arg = helper_fn.sig.inputs.first().expect("should have 1 arg"); + if let syn::FnArg::Typed(pat_type) = first_arg { + if let syn::Pat::Ident(pat_ident) = &*pat_type.pat { + assert_eq!(pat_ident.ident, "t"); + assert!(pat_ident.mutability.is_some()); + } else { + panic!("expected ident pattern"); + } + } else { + panic!("expected typed arg"); + } + } + + #[test] + fn test_expansion_main_fn_renamed() { + let input = quote! { + fn main(t: &mut TurtlePlan) { + t.forward(100.0); + } + }; + let output = turtle_main_impl("e!(), input).unwrap(); + let file: syn::File = syn::parse2(output).unwrap(); + + let helper_fn = file + .items + .iter() + .find_map(|item| { + if let syn::Item::Fn(f) = item { + if f.sig.ident == "__turtle_main_draw" { + return Some(f); + } + } + None + }) + .expect("helper fn `__turtle_main_draw` should exist"); + + let first_arg = helper_fn.sig.inputs.first().expect("should have 1 arg"); + if let syn::FnArg::Typed(pat_type) = first_arg { + if let syn::Pat::Ident(pat_ident) = &*pat_type.pat { + assert_eq!(pat_ident.ident, "t"); + } else { + panic!("expected ident pattern"); + } + } else { + panic!("expected typed arg"); + } + } + + #[test] + fn test_expansion_zero_args() { + let input = quote! { + fn my_draw() { + turtle.forward(100.0); + } + }; + let output = turtle_main_impl("e!(), input).unwrap(); + let file: syn::File = syn::parse2(output).unwrap(); + + let helper_fn = file + .items + .iter() + .find_map(|item| { + if let syn::Item::Fn(f) = item { + if f.sig.ident == "my_draw" { + return Some(f); + } + } + None + }) + .expect("helper fn `my_draw` should exist"); + + let first_arg = helper_fn.sig.inputs.first().expect("should have 1 arg"); + if let syn::FnArg::Typed(pat_type) = first_arg { + if let syn::Pat::Ident(pat_ident) = &*pat_type.pat { + assert_eq!(pat_ident.ident, "turtle"); + } else { + panic!("expected ident pattern"); + } + } else { + panic!("expected typed arg"); + } + } } From c4831967be8cf1d4b350f10938e352da338c344d Mon Sep 17 00:00:00 2001 From: Franz Dietrich Date: Sat, 19 Sep 2026 20:55:00 +0200 Subject: [PATCH 12/12] Parameter Type Validation Helper: Added validate_parameter_type(&syn::Type): Verifies the type is a reference (syn::Type::Reference). Ensures the reference is mutable (type_ref.mutability.is_some()), reporting: #[turtle_main] parameter must be a mutable reference: '&mut TurtlePlan'. Checks that the target type (type_ref.elem) is a path whose trailing identifier is TurtlePlan, allowing both &mut TurtlePlan and qualified paths like &mut turtle_lib::TurtlePlan. Rejects other types (like value: i32 or owned t: TurtlePlan), reporting: Integration into validate_input: Called validate_parameter_type(&pat_type.ty)?; directly within syn::FnArg::Typed(pat_type). Unit Tests Added: test_valid_qualified_type: verifies &mut turtle_lib::TurtlePlan is accepted. test_rejects_wrong_type: verifies value: i32 is rejected with an informative error. test_rejects_immutable_reference: verifies t: &TurtlePlan is rejected. test_rejects_owned_type: verifies t: TurtlePlan is rejected. test_rejects_wrong_reference_type: verifies t: &mut i32 is rejected. --- turtle-lib-macros/src/lib.rs | 80 ++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/turtle-lib-macros/src/lib.rs b/turtle-lib-macros/src/lib.rs index 45d32be..b9faed1 100644 --- a/turtle-lib-macros/src/lib.rs +++ b/turtle-lib-macros/src/lib.rs @@ -102,6 +102,40 @@ use syn::ItemFn; /// }); /// } /// ``` +fn validate_parameter_type(ty: &syn::Type) -> Result<(), syn::Error> { + match ty { + syn::Type::Reference(type_ref) => { + if type_ref.mutability.is_none() { + return Err(syn::Error::new_spanned( + type_ref, + "#[turtle_main] parameter must be a mutable reference: `&mut TurtlePlan`", + )); + } + + if let syn::Type::Path(type_path) = &*type_ref.elem { + let is_turtle_plan = type_path + .path + .segments + .last() + .is_some_and(|seg| seg.ident == "TurtlePlan"); + + if is_turtle_plan { + return Ok(()); + } + } + + Err(syn::Error::new_spanned( + &type_ref.elem, + "#[turtle_main] expected reference to `TurtlePlan`, e.g. `&mut TurtlePlan`", + )) + } + _ => Err(syn::Error::new_spanned( + ty, + "#[turtle_main] parameter must be of type `&mut TurtlePlan`", + )), + } +} + fn validate_input(input_fn: &ItemFn) -> Result<(), syn::Error> { if input_fn.sig.asyncness.is_some() { return Err(syn::Error::new_spanned( @@ -137,6 +171,8 @@ fn validate_input(input_fn: &ItemFn) -> Result<(), syn::Error> { )); } } + + validate_parameter_type(&pat_type.ty)?; } } } @@ -356,6 +392,50 @@ mod tests { assert!(err.to_string().contains("cannot have a return type")); } + #[test] + fn test_valid_qualified_type() { + let input: ItemFn = parse_quote! { + fn my_draw(t: &mut turtle_lib::TurtlePlan) {} + }; + assert!(validate_input(&input).is_ok()); + } + + #[test] + fn test_rejects_wrong_type() { + let input: ItemFn = parse_quote! { + fn my_draw(value: i32) {} + }; + let err = validate_input(&input).unwrap_err(); + assert!(err.to_string().contains("parameter must be of type `&mut TurtlePlan`")); + } + + #[test] + fn test_rejects_immutable_reference() { + let input: ItemFn = parse_quote! { + fn my_draw(t: &TurtlePlan) {} + }; + let err = validate_input(&input).unwrap_err(); + assert!(err.to_string().contains("parameter must be a mutable reference: `&mut TurtlePlan`")); + } + + #[test] + fn test_rejects_owned_type() { + let input: ItemFn = parse_quote! { + fn my_draw(t: TurtlePlan) {} + }; + let err = validate_input(&input).unwrap_err(); + assert!(err.to_string().contains("parameter must be of type `&mut TurtlePlan`")); + } + + #[test] + fn test_rejects_wrong_reference_type() { + let input: ItemFn = parse_quote! { + fn my_draw(t: &mut i32) {} + }; + let err = validate_input(&input).unwrap_err(); + assert!(err.to_string().contains("expected reference to `TurtlePlan`")); + } + #[test] fn test_expansion_preserves_custom_param_name() { let input = quote! {