Merge pull request #6 from enaut/usability-ergonomics-fixes

Usability ergonomics fixes
This commit is contained in:
2026-09-19 22:05:21 +02:00
committed by GitHub
34 changed files with 1803 additions and 1389 deletions
-3
View File
@@ -1,3 +0,0 @@
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=/usr/bin/mold"]
+39 -43
View File
@@ -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}"
}
]
}
+23 -22
View File
@@ -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<TweenController>` (one per turtle with embedded turtle_id)
- **TweenController** manages command execution and animation state
- **TurtleWorld** maintains persistent `Vec<Turtle>` (`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
+23 -13
View File
@@ -6,7 +6,7 @@ A modern turtle graphics library for Rust built on [Macroquad](https://macroquad
- 🎨 **Simple Builder API**: Chain commands like `forward(100).right(90)`
- ⚡ **Smooth Animations**: Tweening support with easing functions and live fill preview
- 🚀 **Instant Mode**: Execute commands immediately without animation (speed ≥ 999)
- 🚀 **Instant Mode**: Execute commands immediately without animation (speed ≥ 1000)
- 🎯 **High-Quality Rendering**: Complete Lyon tessellation pipeline with GPU acceleration
- 🫟 **Multi-Contour Fills**: Automatic hole detection with EvenOdd fill rule - draw cheese with holes!
- 📐 **Self-Intersecting Paths**: Stars, complex shapes - all handled correctly
@@ -99,14 +99,14 @@ plan.begin_fill();
plan.end_fill(); // Auto-closes and applies fill
// Appearance
plan.set_color(RED);
plan.set_pen_color(RED);
plan.set_pen_width(5.0);
plan.hide();
plan.show();
// Speed control (dynamic)
plan.set_speed(100); // Animated mode (< 999)
plan.set_speed(1000); // Instant mode (>= 999)
plan.set_speed(100); // Animated mode (< 1000)
plan.set_speed(1000); // Instant mode (>= 1000)
// Turtle shapes
plan.shape(ShapeType::Triangle);
@@ -136,7 +136,7 @@ let mut plan = create_turtle_plan();
// Fast initial positioning (instant mode)
plan.set_speed(1000);
plan.pen_up();
plan.goto(vec2(-100.0, -100.0));
plan.go_to(vec2(-100.0, -100.0));
// Slow animated drawing
plan.set_speed(50);
@@ -219,7 +219,7 @@ You can also export SVG programmatically from your code:
use turtle_lib::*;
// Create your drawing
let mut plan = create_turtle();
let mut plan = create_turtle_plan();
plan.forward(100).right(90).forward(100);
// Create app
@@ -252,8 +252,8 @@ cargo run --example square
cargo run --example koch
cargo run --example shapes
cargo run --example yinyang
cargo run --example stern
cargo run --example nikolaus
cargo run --example star
cargo run --example house_of_nikolaus
# SVG export example (requires --features svg)
cargo run --example export_svg --features svg
@@ -274,8 +274,8 @@ RUST_LOG=turtle_lib=debug cargo run --example logging_example
- **square.rs**: Basic square drawing
- **koch.rs**: Koch snowflake fractal
- **shapes.rs**: Demonstrates different turtle shapes
- **stern.rs**: Star pattern drawing
- **nikolaus.rs**: Nikolaus (Santa) drawing
- **star.rs**: Star pattern drawing
- **house_of_nikolaus.rs**: House of Nikolaus (Eulerian path puzzle)
#### Fill Examples
@@ -297,7 +297,7 @@ RUST_LOG=turtle_lib=debug cargo run --example logging_example
### Basic Fill
```rust
let mut plan = create_turtle();
let mut plan = create_turtle_plan();
plan.set_fill_color(RED);
plan.begin_fill();
@@ -321,7 +321,7 @@ plan.circle_left(90.0, 360.0, 72);
// pen_up() closes current contour
plan.pen_up();
plan.goto(vec2(0.0, -30.0));
plan.go_to(vec2(0.0, -30.0));
// pen_down() starts new contour
plan.pen_down();
@@ -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
+1 -1
View File
@@ -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"] }
+448 -87
View File
@@ -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<String> = std::env::args().collect();
/// // ... (argument parsing logic)
/// 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);
/// }
/// return;
/// }
///
/// // Normal interactive GUI mode with window
/// turtle_lib::macroquad::Window::new("My Turtle Drawing", async {
/// 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);
/// my_drawing(&mut turtle);
///
/// 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);
/// turtle_lib::macroquad::prelude::clear_background(turtle_lib::macroquad::prelude::WHITE);
/// app.update();
/// app.render();
/// draw_text("Press ESC or Q to quit", 10.0, 40.0, 16.0, DARKGRAY);
/// turtle_lib::macroquad::prelude::draw_text("Press ESC or Q to quit", 10.0, 40.0, 16.0, turtle_lib::macroquad::prelude::DARKGRAY);
///
/// if is_key_pressed(KeyCode::Escape) || is_key_pressed(KeyCode::Q) {
/// 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;
/// }
///
/// next_frame().await;
/// 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<proc_macro2::TokenStream, syn::Error> {
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 expanded = if has_turtle_param {
// Function takes a turtle parameter
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);
let helper_name = if fn_name == "main" {
quote::format_ident!("__turtle_main_draw")
} else {
fn_name.clone()
};
// 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
let helper_fn = if has_turtle_param {
let param = &input_fn.sig.inputs[0];
quote! {
#(#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| {
#(#fn_attrs)*
#fn_vis fn #helper_name(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);
let expanded = quote! {
fn main() {
let mut build_commands = |turtle: &mut turtle_lib::TurtlePlan| {
#helper_name(turtle);
};
// Normal rendering mode (with window)
// 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();
build_commands(&mut turtle);
#helper_name(&mut turtle);
let mut app = turtle_lib::TurtleApp::new()
.with_commands(turtle.build());
loop {
macroquad::prelude::clear_background(macroquad::prelude::WHITE);
turtle_lib::macroquad::prelude::clear_background(turtle_lib::macroquad::prelude::WHITE);
app.update();
app.render();
macroquad::prelude::draw_text(
turtle_lib::macroquad::prelude::draw_text(
"Press ESC or Q to quit",
10.0,
40.0,
16.0,
macroquad::prelude::DARKGRAY
turtle_lib::macroquad::prelude::DARKGRAY
);
if macroquad::prelude::is_key_pressed(macroquad::prelude::KeyCode::Escape)
|| macroquad::prelude::is_key_pressed(macroquad::prelude::KeyCode::Q)
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;
}
macroquad::prelude::next_frame().await;
}
turtle_lib::macroquad::prelude::next_frame().await;
}
});
}
#helper_fn
};
TokenStream::from(expanded)
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(&quote!(), 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(&quote!(), 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(&quote!(), 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(&quote!(), 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");
}
}
}
+1 -1
View File
@@ -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]
+1 -1
View File
@@ -1,7 +1,7 @@
//! Cubic Bézier curve example
//! <https://en.wikipedia.org/wiki/B%C3%A9zier_curve>
use turtle_lib::{turtle_main, vec2};
use turtle_lib::*;
struct CubicBezier {
point0: (f32, f32),
+31 -60
View File
@@ -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();
}
+1 -1
View File
@@ -10,7 +10,7 @@ fn draw(turtle: &mut TurtlePlan) {
turtle.set_pen_color(RED);
turtle.set_pen_width(0.5);
turtle.left(90.0);
turtle.set_speed(999);
turtle.set_speed(1000);
turtle.circle_left(100.0, 540.0, 72); // partial circle to the left
turtle.begin_fill();
+1 -1
View File
@@ -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() {
+1 -1
View File
@@ -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() {
+1 -1
View File
@@ -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) {
+5 -5
View File
@@ -1,4 +1,4 @@
//! Beispiel: Exportiere ein SVG aus einer einfachen Zeichnung
//! Example: Export an SVG from a simple drawing
#[cfg(feature = "svg")]
use turtle_lib::*;
@@ -52,12 +52,12 @@ async fn main() {
app.update();
app.render();
draw_text("Drücke E für SVG-Export", 20.0, 40.0, 32.0, BLACK);
draw_text("Press E for SVG export", 20.0, 40.0, 32.0, BLACK);
if is_key_pressed(KeyCode::E) {
match app.export_drawing("test.svg", export::DrawingFormat::Svg) {
Ok(_) => println!("SVG exportiert nach test.svg"),
Err(e) => println!("Fehler beim Export: {:?}", e),
Ok(_) => println!("SVG exported to test.svg"),
Err(e) => eprintln!("Export error: {:?}", e),
}
}
@@ -67,5 +67,5 @@ async fn main() {
#[cfg(not(feature = "svg"))]
fn main() {
println!("SVG-Export ist nicht aktiviert. Baue mit --features svg");
println!("SVG export is not enabled. Build with --features svg");
}
+57
View File
@@ -0,0 +1,57 @@
//! House of Nikolaus example - draws the classic house figure (Eulerian path puzzle)
use turtle_lib::*;
fn house_square(turtle: &mut TurtlePlan, size: f32) {
turtle.forward(size);
turtle.left(90.0);
turtle.forward(size);
turtle.left(90.0);
turtle.forward(size);
turtle.left(90.0);
turtle.forward(size);
turtle.left(90.0);
}
fn house_diagonal(turtle: &mut TurtlePlan, size: f32) {
let square = size * size;
let diag = (square + square).sqrt();
turtle.left(45.0);
turtle.forward(diag);
turtle.left(45.0);
house_roof(turtle, size);
turtle.left(45.0);
turtle.forward(diag);
turtle.left(45.0);
}
fn house_roof(turtle: &mut TurtlePlan, size: f32) {
let square = size * size;
let diag = (square + square).sqrt();
turtle.left(45.0);
turtle.forward(diag / 2.0);
turtle.left(90.0);
turtle.forward(diag / 2.0);
turtle.left(45.0);
}
fn house_of_nikolaus(turtle: &mut TurtlePlan, size: f32) {
house_square(turtle, size);
house_diagonal(turtle, size);
}
#[turtle_main("House of Nikolaus")]
fn draw(turtle: &mut TurtlePlan) {
turtle.shape(ShapeType::Turtle);
// Position the turtle (pen up, move, pen down)
turtle.pen_up();
turtle.backward(80.0);
turtle.left(90.0);
turtle.backward(50.0);
turtle.right(90.0);
turtle.pen_down();
house_of_nikolaus(turtle, 100.0);
}
-57
View File
@@ -1,57 +0,0 @@
//! Nikolaus example - draws a house-like figure
use turtle_lib::*;
fn nikolausquadrat(turtle: &mut TurtlePlan, groesse: f32) {
turtle.forward(groesse);
turtle.left(90.0);
turtle.forward(groesse);
turtle.left(90.0);
turtle.forward(groesse);
turtle.left(90.0);
turtle.forward(groesse);
turtle.left(90.0);
}
fn nikolausdiag(turtle: &mut TurtlePlan, groesse: f32) {
let quadrat = groesse * groesse;
let diag = (quadrat + quadrat).sqrt();
turtle.left(45.0);
turtle.forward(diag);
turtle.left(45.0);
nikolausdach2(turtle, groesse);
turtle.left(45.0);
turtle.forward(diag);
turtle.left(45.0);
}
fn nikolausdach2(turtle: &mut TurtlePlan, groesse: f32) {
let quadrat = groesse * groesse;
let diag = (quadrat + quadrat).sqrt();
turtle.left(45.0);
turtle.forward(diag / 2.0);
turtle.left(90.0);
turtle.forward(diag / 2.0);
turtle.left(45.0);
}
fn nikolaus(turtle: &mut TurtlePlan, groesse: f32) {
nikolausquadrat(turtle, groesse);
nikolausdiag(turtle, groesse);
}
#[turtle_main("Nikolaus")]
fn draw(turtle: &mut TurtlePlan) {
turtle.shape(ShapeType::Turtle);
// Position the turtle (pen up, move, pen down)
turtle.pen_up();
turtle.backward(80.0);
turtle.left(90.0);
turtle.backward(50.0);
turtle.right(90.0);
turtle.pen_down();
nikolaus(turtle, 100.0);
}
+541 -528
View File
File diff suppressed because it is too large Load Diff
-67
View File
@@ -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)]
+83 -8
View File
@@ -31,8 +31,8 @@ impl TurtleCommand {
pub(crate) fn apply_to_params(&self, params: &mut TurtleParams) {
match self {
TurtleCommand::Move(dist) => {
let dx = dist * params.heading.cos();
let dy = dist * params.heading.sin();
let dx = dist.value() * params.heading.cos();
let dy = dist.value() * params.heading.sin();
params.position = vec2(params.position.x + dx, params.position.y + dy);
}
TurtleCommand::Turn(angle) => {
@@ -47,7 +47,7 @@ impl TurtleCommand {
let geom = CircleGeometry::new(
params.position,
Radians::new(params.heading),
*radius,
radius.value(),
*direction,
);
let angle_rad = angle.as_radians().value();
@@ -62,7 +62,7 @@ impl TurtleCommand {
params.position = vec2(coord.x, -coord.y);
}
TurtleCommand::SetHeading(heading) => {
params.heading = normalize_angle(heading.value());
params.heading = normalize_angle(-heading.as_radians().value());
}
TurtleCommand::SetColor(color) => {
params.color = *color;
@@ -119,15 +119,16 @@ impl TurtleCommand {
}
let base: f32 = match self {
TurtleCommand::Move(dist) => dist.abs() / spd,
TurtleCommand::Move(dist) => dist.value().abs() / spd,
TurtleCommand::Turn(angle) => angle.value().abs() / (spd * 1.8),
TurtleCommand::Circle { radius, angle, .. } => {
let arc_length = radius * angle.as_radians().value().abs();
let arc_length = radius.value() * angle.as_radians().value().abs();
arc_length / spd
}
TurtleCommand::Goto(target) => {
let dx = target.x - params.position.x;
let dy = target.y - params.position.y;
let screen_target = vec2(target.x, -target.y);
let dx = screen_target.x - params.position.x;
let dy = screen_target.y - params.position.y;
(dx * dx + dy * dy).sqrt() / spd
}
_ => 0.0,
@@ -148,3 +149,77 @@ impl TurtleCommand {
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::general::{AnimationSpeed, Color, Coordinate, Degrees};
use crate::shapes::TurtleShape;
fn make_test_params() -> TurtleParams {
TurtleParams {
position: vec2(0.0, 0.0),
heading: 0.0,
pen_down: true,
pen_width: 1.0,
color: Color::new(0.0, 0.0, 0.0, 1.0),
fill_color: None,
visible: true,
shape: TurtleShape::turtle(),
speed: AnimationSpeed::Animated(100.0),
}
}
#[test]
fn test_goto_duration_cartesian_inversion() {
let mut params = make_test_params();
// Set screen position to (0, 100), which corresponds to Cartesian (0, -100)
params.position = vec2(0.0, 100.0);
// Move to Cartesian (0, 100), which in screen space is (0, -100)
// Distance should be 200 pixels!
let cmd = TurtleCommand::Goto(Coordinate::new(0.0, 100.0));
let duration = cmd.animation_duration(&params, AnimationSpeed::Animated(100.0));
// At 100 px/sec across 200 pixels, duration should be 2.0 seconds
assert!(
(duration - 2.0).abs() < 0.01,
"Expected duration ~2.0s for 200px move, got {duration}"
);
}
#[test]
fn test_set_heading_degrees_and_instant_duration() {
let mut params = make_test_params();
// 90° = North (in screen coordinates: -pi/2)
let cmd_north = TurtleCommand::SetHeading(Degrees::new(90.0));
cmd_north.apply_to_params(&mut params);
let expected_north = -std::f32::consts::FRAC_PI_2;
assert!(
(params.heading - expected_north).abs() < 0.001,
"Heading 90° should be North (-π/2), got {}",
params.heading
);
// SetHeading should be instant (0.01 minimum duration)
let duration = cmd_north.animation_duration(&params, AnimationSpeed::Animated(100.0));
assert!((duration - 0.01).abs() < 0.001);
// 0° = East (0 radians)
let cmd_east = TurtleCommand::SetHeading(Degrees::new(0.0));
cmd_east.apply_to_params(&mut params);
assert!((params.heading - 0.0).abs() < 0.001);
// 270° = South (+pi/2 radians in screen coords)
let cmd_south = TurtleCommand::SetHeading(Degrees::new(270.0));
cmd_south.apply_to_params(&mut params);
let expected_south = std::f32::consts::FRAC_PI_2;
assert!(
(params.heading - expected_south).abs() < 0.001,
"Heading 270° should be South (π/2), got {}",
params.heading
);
}
}
+9 -10
View File
@@ -1,13 +1,13 @@
//! Turtle commands and command queue
use crate::general::{AnimationSpeed, Color, Coordinate, Degrees, FontSize, Precision, Radians};
use crate::general::{AnimationSpeed, Color, Coordinate, Degrees, FontSize, Length, Precision};
use crate::shapes::TurtleShape;
/// Individual turtle commands
#[derive(Clone, Debug)]
pub enum TurtleCommand {
// Movement (positive = forward, negative = backward)
Move(Precision),
Move(Length),
// Rotation (positive = right/clockwise, negative = left/counter-clockwise)
// Stored in degrees — the natural unit at the user-facing API boundary.
@@ -15,7 +15,7 @@ pub enum TurtleCommand {
// Circle drawing
Circle {
radius: Precision,
radius: Length,
angle: Degrees, // sweep angle — degrees, as supplied by the user
steps: usize,
direction: crate::circle_geometry::CircleDirection,
@@ -34,10 +34,9 @@ pub enum TurtleCommand {
// Position
Goto(Coordinate),
/// Heading stored as internal radians (Y-down render-space convention).
/// Values passed via `TurtlePlan::set_heading` are converted from
/// user-facing degrees before this command is enqueued.
SetHeading(Radians),
/// Heading stored in user degrees (Cartesian convention: 0° = East, 90° = North).
/// Conversion to internal screen-space heading is performed when executed.
SetHeading(Degrees),
// Visibility
ShowTurtle,
@@ -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<TurtleCommand>,
@@ -116,7 +115,7 @@ impl Default for CommandQueue {
///
/// 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.
/// itself is stateful; execution state is managed by the consumer.
impl IntoIterator for CommandQueue {
type Item = TurtleCommand;
type IntoIter = std::vec::IntoIter<TurtleCommand>;
+2 -30
View File
@@ -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<CommandQueue>,
}
@@ -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<CommandQueue> {
self.rx.try_iter().collect()
}
/// Try to receive one command batch (non-blocking)
#[must_use]
pub fn try_recv(&self) -> Option<CommandQueue> {
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 },
)
}
+20 -38
View File
@@ -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];
+17 -33
View File
@@ -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)",
+38 -30
View File
@@ -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<String> {
/// Check command-line arguments for the `--export-svg <filename>` flag.
#[must_use]
pub fn parse_svg_export_arg() -> Option<String> {
let args: Vec<String> = std::env::args().collect();
let mut i = 1;
while i < args.len() {
@@ -38,49 +41,54 @@ pub(crate) fn parse_svg_export_arg() -> Option<String> {
None
}
/// Handle the optional `--export-svg` CLI flag.
/// Headless SVG export that executes drawing commands and writes an SVG file
/// without opening a graphics window and without calling `std::process::exit`.
///
/// The feature gating lives inside `turtle-lib`, so the `turtle_main` macro
/// no longer needs to reference cfg flags from the consuming crate.
pub fn handle_svg_export<F>(build_commands: F)
/// # Errors
///
/// Returns `ExportError` if file I/O fails or if the `svg` feature is not enabled.
pub fn run_headless_svg_export<F>(mut build_commands: F, filename: &str) -> Result<(), ExportError>
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));
let mut app = crate::TurtleApp::new();
app.execute_immediate(0, turtle);
while !app.all_animations_complete() {
app.update();
}
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);
}
}
app.export_drawing(filename, crate::export::DrawingFormat::Svg)
}
#[cfg(not(feature = "svg"))]
{
let _ = &filename;
eprintln!("Error: SVG export feature is not enabled.");
eprintln!("Please rebuild with --features 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.
///
/// Delegates to [`run_headless_svg_export`].
pub fn handle_svg_export<F>(build_commands: F)
where
F: FnMut(&mut TurtlePlan),
{
if let Some(filename) = parse_svg_export_arg() {
match run_headless_svg_export(build_commands, &filename) {
Ok(()) => {
println!("SVG exported successfully to: {filename}");
std::process::exit(0);
}
Err(e) => {
eprintln!("Error exporting SVG: {e:?}");
std::process::exit(1);
}
}
}
}
+18 -16
View File
@@ -4,23 +4,13 @@
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,
};
pub struct SvgExporter;
impl DrawingExporter for SvgExporter {
fn export(&self, world: &TurtleWorld, filename: &str) -> Result<(), ExportError> {
let mut doc = Document::new();
let mut min_x = f32::INFINITY;
let mut max_x = f32::NEG_INFINITY;
let mut min_y = f32::INFINITY;
let mut max_y = f32::NEG_INFINITY;
fn update_bounds(
min_x: &mut f32,
max_x: &mut f32,
@@ -35,6 +25,18 @@ pub mod svg_export {
*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();
let mut min_x = f32::INFINITY;
let mut max_x = f32::NEG_INFINITY;
let mut min_y = f32::INFINITY;
let mut max_y = f32::NEG_INFINITY;
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 <path A …>
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})")
}
}
}
+42 -2
View File
@@ -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<f32> for AnimationSpeed {
}
}
impl From<f64> for AnimationSpeed {
fn from(speed: f64) -> Self {
AnimationSpeed::from_value(speed as f32)
}
}
impl From<u32> for AnimationSpeed {
fn from(speed: u32) -> Self {
AnimationSpeed::from_u32(speed)
}
}
impl From<i32> for AnimationSpeed {
fn from(speed: i32) -> Self {
AnimationSpeed::from_value(speed as f32)
}
}
impl From<usize> 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)
);
}
}
+20
View File
@@ -65,6 +65,12 @@ impl From<f32> for Degrees {
}
}
impl From<f64> for Degrees {
fn from(v: f64) -> Self {
Self(v as Precision)
}
}
impl From<i32> for Degrees {
fn from(v: i32) -> Self {
Self(v as Precision)
@@ -77,6 +83,12 @@ impl From<i16> for Degrees {
}
}
impl From<usize> 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));
}
}
+23 -2
View File
@@ -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<i16> for FontSize {
}
}
impl From<f64> for FontSize {
fn from(f: f64) -> Self {
Self(f.max(1.0) as u16)
}
}
impl From<usize> 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);
}
}
+72 -4
View File
@@ -1,13 +1,33 @@
//! Length type for distance measurements
use super::Precision;
use std::ops::Neg;
#[derive(Default, Copy, Clone, Debug, PartialEq)]
/// A spatial distance or length measurement.
///
/// Used at the public API boundary for movement distances and arc radii.
#[derive(Default, Copy, Clone, Debug, PartialEq, PartialOrd)]
pub struct Length(pub Precision);
impl From<i16> for Length {
fn from(i: i16) -> Self {
Self(Precision::from(i))
impl Length {
/// Create a new `Length` from a raw value.
#[must_use]
pub const fn new(v: Precision) -> Self {
Self(v)
}
/// Extract the raw `Precision` (`f32`) value.
#[must_use]
pub const fn value(self) -> Precision {
self.0
}
}
impl Neg for Length {
type Output = Self;
fn neg(self) -> Self {
Self(-self.0)
}
}
@@ -17,8 +37,56 @@ impl From<f32> for Length {
}
}
impl From<f64> for Length {
fn from(f: f64) -> Self {
Self(f as Precision)
}
}
impl From<i16> for Length {
fn from(i: i16) -> Self {
Self(Precision::from(i))
}
}
impl From<i32> for Length {
fn from(i: i32) -> Self {
Self(i as Precision)
}
}
impl From<usize> for Length {
fn from(u: usize) -> Self {
Self(u as Precision)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_length_conversions_and_negation() {
let l_f32: Length = 42.5_f32.into();
assert_eq!(l_f32.value(), 42.5);
let l_f64: Length = 100.0_f64.into();
assert_eq!(l_f64.value(), 100.0);
let l_i32: Length = 50_i32.into();
assert_eq!(l_i32.value(), 50.0);
let l_i16: Length = 25_i16.into();
assert_eq!(l_i16.value(), 25.0);
let l_usize: Length = 10_usize.into();
assert_eq!(l_usize.value(), 10.0);
let neg = -l_f32;
assert_eq!(neg.value(), -42.5);
assert!(Length::new(10.0) < Length::new(20.0));
}
}
+20 -18
View File
@@ -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 {
+3 -223
View File
@@ -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<Vec<Coordinate>>,
@@ -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<macroquad::prelude::Vertex>,
pub(crate) indices: Vec<u16>,
}
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<Turtle>,
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 {
+15 -15
View File
@@ -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<Vertex> = 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<MeshData, Box<dyn std::error::Error>> {
) -> Result<Mesh, Box<dyn std::error::Error>> {
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<Vec2>],
color: Color,
) -> Result<MeshData, Box<dyn std::error::Error>> {
) -> Result<Mesh, Box<dyn std::error::Error>> {
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<MeshData, Box<dyn std::error::Error>> {
) -> Result<Mesh, Box<dyn std::error::Error>> {
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<MeshData, Box<dyn std::error::Error>> {
) -> Result<Mesh, Box<dyn std::error::Error>> {
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<MeshData, Box<dyn std::error::Error>> {
) -> Result<Mesh, Box<dyn std::error::Error>> {
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,
+203 -24
View File
@@ -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<TweenVec2> 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<TurtleCommand>,
current_tween: Option<CommandTween>,
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<TweenVec2, f64, CubicInOut>,
heading_tweener: Tweener<f32, f64, CubicInOut>,
pen_width_tweener: Tweener<f32, f64, CubicInOut>,
@@ -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<Instant> = 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);
}
}
}