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 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/.github/copilot-instructions.md b/AGENTS.md similarity index 74% rename from .github/copilot-instructions.md rename to AGENTS.md index 5251f3b..52884c7 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) @@ -122,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(); } @@ -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 diff --git a/README.md b/README.md index b4b5089..b46ae37 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(); @@ -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 @@ -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/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-macros/src/lib.rs b/turtle-lib-macros/src/lib.rs index 6ed75ff..b9faed1 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. @@ -63,44 +63,145 @@ 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_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( + 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 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`", + )); + } + } + + validate_parameter_type(&pat_type.ty)?; + } + } + } + + 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); + 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 + validate_input(&input_fn)?; // Parse the window title from args (default to "Turtle Graphics") let window_title = if args.is_empty() { @@ -114,105 +215,365 @@ 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 fn_vis = if fn_name == "main" { + None + } else { + Some(&input_fn.vis) + }; 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 { + let param = &input_fn.sig.inputs[0]; 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_vis fn #helper_name(#param) #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_vis fn #helper_name(turtle: &mut turtle_lib::TurtlePlan) { + let turtle = turtle; + #fn_block } } }; - TokenStream::from(expanded) + 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 + }; + + Ok(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_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! { + 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_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! { + fn my_draw() -> i32 { + 42 + } + }; + let err = validate_input(&input).unwrap_err(); + 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! { + 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"); + } + } } 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/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/breadboard.rs b/turtle-lib/examples/breadboard.rs index 0466417..c876cc1 100644 --- a/turtle-lib/examples/breadboard.rs +++ b/turtle-lib/examples/breadboard.rs @@ -1,46 +1,10 @@ +//! 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 imqmediately - 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("Drücke E für 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), - } - } - - next_frame().await; - } -} - -#[cfg(not(feature = "svg"))] -fn main() { - println!("SVG-Export ist nicht aktiviert. Baue mit --features svg"); -} fn pin(t: &mut TurtlePlan, size: f32) { t.left(90.0).forward(size / 2.0); @@ -50,53 +14,60 @@ 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 { +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 { +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) { +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); +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); + 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); } + +#[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/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/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/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..26a2988 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 @@ -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. @@ -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,16 +61,139 @@ 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 } + + /// 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)); + /// } + /// ``` + fn go_to(&mut self, coord: impl Into) -> &mut Self { + self.get_commands_mut() + .push(TurtleCommand::Goto(coord.into())); + self + } + + /// Draws a circular arc turning to the left (counter-clockwise). + /// + /// The turtle draws a circular arc with the specified radius, sweeping through + /// the given angle. The circle center is positioned to the left of the turtle. + /// + /// # Parameters + /// + /// - `radius`: Distance from turtle to circle center (in pixels) + /// - `angle`: Arc sweep angle in degrees (360° = full circle) + /// - `steps`: Number of line segments to approximate the arc (more = smoother) + /// + /// # Examples + /// + /// ```no_run + /// # use turtle_lib::*; + /// # + /// #[turtle_main("Circle Left Example")] + /// fn draw(turtle: &mut TurtlePlan) { + /// // Draw a full circle + /// turtle.circle_left(50.0, 360.0, 36); + /// + /// // Filled circle + /// turtle.pen_up().go_to(vec2(100.0, 0.0)).pen_down(); + /// turtle.set_fill_color(RED) + /// .begin_fill() + /// .circle_left(50.0, 360.0, 72) + /// .end_fill(); + /// } + /// ``` + fn circle_left(&mut self, radius: R, angle: A, steps: usize) -> &mut Self + where + R: Into, + A: Into, + { + let r: Length = radius.into(); + self.get_commands_mut().push(TurtleCommand::Circle { + radius: r, + angle: angle.into(), + steps, + direction: crate::circle_geometry::CircleDirection::Left, + }); + self + } + + /// Draws a circular arc turning to the right (clockwise). + /// + /// The turtle draws a circular arc with the specified radius, sweeping through + /// the given angle. The circle center is positioned to the right of the turtle. + /// + /// # Parameters + /// + /// - `radius`: Distance from turtle to circle center (in pixels) + /// - `angle`: Arc sweep angle in degrees (360° = full circle) + /// - `steps`: Number of line segments to approximate the arc (more = smoother) + /// + /// # Examples + /// + /// ```no_run + /// # use turtle_lib::*; + /// # + /// #[turtle_main("Circle Right Example")] + /// fn draw(turtle: &mut TurtlePlan) { + /// // Draw an S-curve using both directions + /// turtle.circle_left(50.0, 180.0, 36) + /// .circle_right(50.0, 180.0, 36); + /// + /// // Yin-yang pattern uses circle_left and circle_right + /// turtle.set_fill_color(BLACK) + /// .begin_fill() + /// .circle_right(100.0, 180.0, 36) + /// .circle_right(50.0, 180.0, 36) + /// .circle_left(50.0, 180.0, 36) + /// .end_fill(); + /// } + /// ``` + fn circle_right(&mut self, radius: R, angle: A, steps: usize) -> &mut Self + where + R: Into, + A: Into, + { + let r: Length = radius.into(); + self.get_commands_mut().push(TurtleCommand::Circle { + radius: r, + angle: angle.into(), + steps, + direction: crate::circle_geometry::CircleDirection::Right, + }); + self + } } -/// Trait for turning operations -pub trait Turnable: 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. @@ -124,96 +247,442 @@ pub trait Turnable: WithCommands { .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). + /// Sets the turtle's absolute heading direction in degrees. /// - /// The turtle draws a circular arc with the specified radius, sweeping through - /// the given angle. The circle center is positioned to the left of the turtle. + /// - `0°` points to the right (east) + /// - `90°` points up (north) + /// - `180°` points left (west) + /// - `270°` points down (south) /// - /// # Parameters - /// - /// - `radius`: Distance from turtle to circle center (in pixels) - /// - `angle`: Arc sweep angle in degrees (360° = full circle) - /// - `steps`: Number of line segments to approximate the arc (more = smoother) + /// 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("Circle Left Example")] + /// #[turtle_main("Heading Example")] /// fn draw(turtle: &mut TurtlePlan) { - /// // Draw a full circle - /// turtle.circle_left(50.0, 360.0, 36); + /// // Point upward + /// turtle.set_heading(90.0) + /// .forward(100.0); /// - /// // Filled circle - /// turtle.pen_up().go_to(vec2(100.0, 0.0)).pen_down(); - /// turtle.set_fill_color(RED) + /// // 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(50.0, 360.0, 72) + /// .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 circle_left(&mut self, radius: R, angle: A, steps: usize) -> &mut Self - where - R: Into, - A: Into, - { - let r: Precision = radius.into(); - self.get_commands_mut().push(TurtleCommand::Circle { - radius: r, - angle: angle.into(), - steps, - direction: crate::circle_geometry::CircleDirection::Left, - }); + fn pen_up(&mut self) -> &mut Self { + self.get_commands_mut().push(TurtleCommand::PenUp); self } - /// Draws a circular arc turning to the right (clockwise). + /// Lowers the pen so the turtle draws when moving. /// - /// The turtle draws a circular arc with the specified radius, sweeping through - /// the given angle. The circle center is positioned to the right of the turtle. - /// - /// # Parameters - /// - /// - `radius`: Distance from turtle to circle center (in pixels) - /// - `angle`: Arc sweep angle in degrees (360° = full circle) - /// - `steps`: Number of line segments to approximate the arc (more = smoother) + /// 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("Circle Right Example")] + /// #[turtle_main("Pen Down Example")] /// fn draw(turtle: &mut TurtlePlan) { - /// // Draw an S-curve using both directions - /// turtle.circle_left(50.0, 180.0, 36) - /// .circle_right(50.0, 180.0, 36); + /// 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. /// - /// // Yin-yang pattern uses circle_left and circle_right - /// turtle.set_fill_color(BLACK) + /// 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_right(100.0, 180.0, 36) - /// .circle_right(50.0, 180.0, 36) - /// .circle_left(50.0, 180.0, 36) + /// .circle_left(50.0, 360.0, 36) /// .end_fill(); /// } /// ``` - fn circle_right(&mut self, radius: R, angle: A, steps: usize) -> &mut Self + 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 - R: Into, - A: Into, + T: Into, { - let r: Precision = radius.into(); - self.get_commands_mut().push(TurtleCommand::Circle { - radius: r, - angle: angle.into(), - steps, - direction: crate::circle_geometry::CircleDirection::Right, + self.get_commands_mut().push(TurtleCommand::WriteText { + text: text.into(), + font_size: font_size.into(), }); self } @@ -262,465 +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 { - // 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 - } - - /// 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`. @@ -754,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/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/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 37dd17a..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, @@ -60,8 +59,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 +114,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/commands_channel.rs b/turtle-lib/src/commands_channel.rs index 7fd1128..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 @@ -203,13 +178,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( @@ -220,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/drawing.rs b/turtle-lib/src/drawing.rs index c31c095..62db295 100644 --- a/turtle-lib/src/drawing.rs +++ b/turtle-lib/src/drawing.rs @@ -5,18 +5,11 @@ 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)] 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, @@ -33,8 +26,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, @@ -62,7 +55,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,21 +126,17 @@ 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; - 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, + radius.value(), geom.start_angle_from_center, sweep_so_far, samples_to_draw, @@ -224,8 +213,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,29 +293,22 @@ 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) - { - draw_mesh(&mesh_data.to_mesh()); + if let Ok(mesh) = crate::tessellation::tessellate_circle(geom.center, 5.0, GRAY, true, 1.0) { + 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 - if let Ok(mesh_data) = crate::tessellation::tessellate_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_data.to_mesh()); + draw_mesh(&mesh); } } @@ -343,10 +325,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..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, @@ -123,7 +122,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 +131,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, @@ -242,7 +241,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 +251,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 +267,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, @@ -326,7 +325,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 +333,7 @@ pub(crate) fn tessellate_command( ) .ok()?; - Some(DrawCommand::Mesh { data: mesh_data }) + Some(DrawCommand::Mesh(mesh)) } TurtleCommand::Circle { @@ -347,12 +346,12 @@ pub(crate) fn tessellate_command( let geom = CircleGeometry::new( start.position, Radians::new(start.heading), - *radius, + radius.value(), *direction, ); - let mesh_data = tessellation::tessellate_arc( + let mesh = tessellation::tessellate_arc( geom.center, - *radius, + radius.value(), geom.start_angle_from_center.to_degrees(), angle.value(), start.color, @@ -362,7 +361,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 @@ -402,7 +401,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 +473,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; @@ -484,7 +483,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,28 +502,13 @@ 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); 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 = {}", @@ -559,7 +543,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..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 /// @@ -26,7 +27,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 +41,54 @@ 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(); + app.execute_immediate(0, turtle); + + 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.all_animations_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/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 b399104..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) @@ -80,11 +79,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..802f1b4 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)) + Self(u16::try_from(size).unwrap_or(u16::MAX).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/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..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}; @@ -73,6 +74,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, @@ -106,6 +110,7 @@ impl TurtleApp { filename: &str, format: export::DrawingFormat, ) -> Result<(), export::ExportError> { + let _ = filename; match format { #[cfg(feature = "svg")] export::DrawingFormat::Svg => { @@ -249,6 +254,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); } @@ -281,12 +291,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. @@ -371,12 +388,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 @@ -388,16 +399,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 fb58ba6..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>, @@ -55,8 +52,8 @@ impl Default for TurtleParams { } /// State of a single turtle -#[derive(Clone, Debug)] pub(crate) struct Turtle { + #[allow(clippy::struct_field_names)] pub(crate) turtle_id: usize, pub(crate) params: TurtleParams, @@ -91,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 @@ -131,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. @@ -349,30 +176,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, @@ -388,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 { @@ -396,12 +203,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() - }, - background_color: WHITE, + camera: Camera2D::default(), } } @@ -416,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/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, diff --git a/turtle-lib/src/tweening.rs b/turtle-lib/src/tweening.rs index cc6cb59..c167ebc 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,25 +47,21 @@ 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, } #[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, + 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, @@ -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,11 +163,12 @@ 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 - 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 { @@ -185,7 +181,7 @@ impl TweenController { calculate_circle_position( tween.start_params.position, Radians::new(tween.start_params.heading), - *radius, + radius.value(), angle_traveled, *direction, ) @@ -273,9 +269,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) => { @@ -323,9 +317,9 @@ impl TweenController { ); self.current_tween = Some(CommandTween { - turtle_id, command, - start_time: get_time(), + progress: 0.0, + start_time: current_time(), duration, start_params: params.clone(), target_params: target_state.clone(), @@ -342,7 +336,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 +394,188 @@ pub(crate) fn normalize_angle(angle: f32) -> f32 { normalized } + +#[inline] +fn current_time() -> f64 { + #[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(target_arch = "wasm32")] + { + macroquad::time::get_time() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::TurtleCommand; + use crate::general::{Degrees, Length}; + 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(Length::new(100.0))); + queue.push(TurtleCommand::Turn(Degrees::new(90.0))); + queue.push(TurtleCommand::PenUp); + queue.push(TurtleCommand::Move(Length::new(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(Length::new(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(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)); + 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(Length::new(100.0))); + queue.push(TurtleCommand::Move(Length::new(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"); + 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); + } + } +}