Merge pull request #6 from enaut/usability-ergonomics-fixes
Usability ergonomics fixes
This commit is contained in:
@@ -1,3 +0,0 @@
|
|||||||
[target.x86_64-unknown-linux-gnu]
|
|
||||||
linker = "clang"
|
|
||||||
rustflags = ["-C", "link-arg=-fuse-ld=/usr/bin/mold"]
|
|
||||||
Vendored
+39
-43
@@ -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",
|
"version": "0.2.0",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
@@ -26,58 +23,57 @@
|
|||||||
{
|
{
|
||||||
"type": "lldb",
|
"type": "lldb",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"name": "Debug executable 'turtle-example'",
|
"name": "Debug unit tests in 'turtle-lib-macros'",
|
||||||
"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'",
|
|
||||||
"cargo": {
|
"cargo": {
|
||||||
"args": [
|
"args": [
|
||||||
"test",
|
"test",
|
||||||
"--no-run",
|
"--no-run",
|
||||||
"--lib",
|
"--lib",
|
||||||
"--package=turtle-ui"
|
"--package=turtle-lib-macros"
|
||||||
],
|
],
|
||||||
"filter": {
|
"filter": {
|
||||||
"name": "turtle-ui",
|
"name": "turtle-lib-macros",
|
||||||
"kind": "lib"
|
"kind": "lib"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"args": [],
|
"args": [],
|
||||||
"cwd": "${workspaceFolder}"
|
"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}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -8,21 +8,23 @@ Rust workspace with turtle graphics implementations. **Primary focus: `turtle-li
|
|||||||
```
|
```
|
||||||
turtlers/
|
turtlers/
|
||||||
├── turtle-lib/ # MAIN LIBRARY - Macroquad + Lyon (focus here)
|
├── turtle-lib/ # MAIN LIBRARY - Macroquad + Lyon (focus here)
|
||||||
├── turtle-lib-macros/ # Proc macro for turtle_main
|
│ └── examples/ # 30 examples including threading patterns
|
||||||
└── examples/ # 15+ examples including threading patterns
|
└── turtle-lib-macros/ # Proc macro for turtle_main
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture (`turtle-lib`)
|
## 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`
|
- **Builder API** (`TurtlePlan`) accumulates commands into immutable `CommandQueue`
|
||||||
- **TurtleApp** maintains persistent `Vec<TweenController>` (one per turtle with embedded turtle_id)
|
- **TurtleWorld** maintains persistent `Vec<Turtle>` (`world.turtles`), each encapsulating state and a `TweenController`
|
||||||
- **TweenController** manages command execution and animation state
|
- **TweenController** manages command execution, queue consumption, and animation interpolation per turtle
|
||||||
- **Lyon Tessellation** converts all primitives to GPU meshes
|
- **Lyon Tessellation** converts all primitives to GPU meshes
|
||||||
- **Multi-Turtle** support: Create multiple turtles with `add_turtle()` or threading channels
|
- **Multi-Turtle** support: Create multiple turtles with `add_turtle()` or threading channels
|
||||||
|
|
||||||
### Key Architectural Decision: Turtle ID Storage
|
### Key Architectural Decision: Turtle Entity Encapsulation
|
||||||
**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.
|
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
|
### Key Files
|
||||||
```
|
```
|
||||||
@@ -31,7 +33,7 @@ src/
|
|||||||
├── builders.rs - Fluent API traits (forward/right/circle/reset/etc)
|
├── builders.rs - Fluent API traits (forward/right/circle/reset/etc)
|
||||||
├── commands.rs - TurtleCommand enum (Move/Turn/Circle/Reset/etc)
|
├── commands.rs - TurtleCommand enum (Move/Turn/Circle/Reset/etc)
|
||||||
├── execution.rs - Command execution (immediate) + state updates
|
├── 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
|
├── drawing.rs - Lyon mesh rendering with Macroquad
|
||||||
├── state.rs - Turtle, TurtleParams, TurtleWorld (persistent state)
|
├── state.rs - Turtle, TurtleParams, TurtleWorld (persistent state)
|
||||||
├── tessellation.rs - Lyon integration (polygons/strokes/fills/arcs)
|
├── 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
|
- Example: Donut = outer circle (pen_down) → pen_up → inner circle → end_fill
|
||||||
|
|
||||||
**3. Animation Modes**:
|
**3. Animation Modes**:
|
||||||
- Speed `>= 999`: Instant mode (no tweening, executes immediately)
|
- Speed `>= 1000`: Instant mode (no tweening, executes with `max(1, speed - 1000)` draw calls per frame)
|
||||||
- Speed `< 999`: Animated mode (tweens with CubicInOut easing, ~duration based on distance/speed)
|
- Speed `< 1000`: Animated mode (tweens with CubicInOut easing, duration based on distance/speed)
|
||||||
- Dynamic switching via `SetSpeed` command mid-animation
|
- Dynamic switching via `SetSpeed` command mid-animation
|
||||||
|
|
||||||
**4. Multi-Turtle Architecture**:
|
**4. Multi-Turtle Architecture**:
|
||||||
- Each turtle owns a persistent `TweenController` with embedded `turtle_id`
|
- Each turtle in `world.turtles` owns its persistent `TweenController`
|
||||||
- Rendering finds active tween by checking `controller.current_tween().turtle_id` (not Vec index)
|
- 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
|
- 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):
|
**5. Threading Pattern** (for interactive apps like Hangman):
|
||||||
- `create_turtle_channel(buffer_size)` returns `TurtleCommandSender` (clonable, Send)
|
- `create_turtle_channel(buffer_size)` returns `TurtleCommandSender` (clonable, Send)
|
||||||
@@ -122,7 +124,7 @@ let tx = turtle_tx.clone();
|
|||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
loop {
|
loop {
|
||||||
let letter = get_input(); // Blocks
|
let letter = get_input(); // Blocks
|
||||||
let mut plan = create_turtle();
|
let mut plan = create_turtle_plan();
|
||||||
plan.forward(50.0);
|
plan.forward(50.0);
|
||||||
tx.send(plan.build()).ok();
|
tx.send(plan.build()).ok();
|
||||||
}
|
}
|
||||||
@@ -191,11 +193,10 @@ RUST_LOG=turtle_lib=debug cargo run --example yinyang
|
|||||||
- Preserves `turtle_id` after reset
|
- Preserves `turtle_id` after reset
|
||||||
- Called via `execute_command()` in both instant and animated modes
|
- Called via `execute_command()` in both instant and animated modes
|
||||||
|
|
||||||
### Turtle ID Robustness
|
### Turtle Entity and State Encapsulation
|
||||||
- **Before**: `turtle_id` derived from Vec index (fragile if controllers deleted)
|
- Each `Turtle` struct owns its `turtle_id`, visual `params`, `filling` state, tessellated `commands`, `svg_log`, and `tween_controller`
|
||||||
- **After**: `turtle_id` embedded in `TweenController` and `CommandTween`
|
- `TweenController` is responsible purely for command queue management and interpolation, decoupled from turtle identity
|
||||||
- Rendering finds active tween via `find_map(|c| c.current_tween())` → uses `tween.turtle_id` directly
|
- Rendering iterates sequentially over `world.turtles`, accessing each turtle's `commands`, active tween (`turtle.tween_controller.current_tween()`), and live fill preview in place
|
||||||
- Safe for sparse/dynamic turtle creation
|
|
||||||
|
|
||||||
### Lyon Tessellation
|
### Lyon Tessellation
|
||||||
- All drawing → `tessellate_arc/stroke/circle/multi_contour` → `MeshData` → Macroquad `Mesh`
|
- 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
|
### Main Dependencies
|
||||||
- `macroquad = "0.4"` - Window/rendering framework
|
- `macroquad = "0.4"` - Window/rendering framework
|
||||||
- `lyon = "1.0"` - Tessellation (fills, strokes, circles)
|
- `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)
|
- `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
|
## 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 add `use macroquad::prelude::*` without explicit need (causes unused imports)
|
||||||
- Don't manually triangulate—always use Lyon `tessellate_*` functions
|
- Don't manually triangulate—always use Lyon `tessellate_*` functions
|
||||||
- Don't separate Forward/Backward—use negative `Move` values
|
- Don't separate Forward/Backward—use negative `Move` values
|
||||||
@@ -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)`
|
- 🎨 **Simple Builder API**: Chain commands like `forward(100).right(90)`
|
||||||
- ⚡ **Smooth Animations**: Tweening support with easing functions and live fill preview
|
- ⚡ **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
|
- 🎯 **High-Quality Rendering**: Complete Lyon tessellation pipeline with GPU acceleration
|
||||||
- **Multi-Contour Fills**: Automatic hole detection with EvenOdd fill rule - draw cheese with holes!
|
- **Multi-Contour Fills**: Automatic hole detection with EvenOdd fill rule - draw cheese with holes!
|
||||||
- 📐 **Self-Intersecting Paths**: Stars, complex shapes - all handled correctly
|
- 📐 **Self-Intersecting Paths**: Stars, complex shapes - all handled correctly
|
||||||
@@ -99,14 +99,14 @@ plan.begin_fill();
|
|||||||
plan.end_fill(); // Auto-closes and applies fill
|
plan.end_fill(); // Auto-closes and applies fill
|
||||||
|
|
||||||
// Appearance
|
// Appearance
|
||||||
plan.set_color(RED);
|
plan.set_pen_color(RED);
|
||||||
plan.set_pen_width(5.0);
|
plan.set_pen_width(5.0);
|
||||||
plan.hide();
|
plan.hide();
|
||||||
plan.show();
|
plan.show();
|
||||||
|
|
||||||
// Speed control (dynamic)
|
// Speed control (dynamic)
|
||||||
plan.set_speed(100); // Animated mode (< 999)
|
plan.set_speed(100); // Animated mode (< 1000)
|
||||||
plan.set_speed(1000); // Instant mode (>= 999)
|
plan.set_speed(1000); // Instant mode (>= 1000)
|
||||||
|
|
||||||
// Turtle shapes
|
// Turtle shapes
|
||||||
plan.shape(ShapeType::Triangle);
|
plan.shape(ShapeType::Triangle);
|
||||||
@@ -136,7 +136,7 @@ let mut plan = create_turtle_plan();
|
|||||||
// Fast initial positioning (instant mode)
|
// Fast initial positioning (instant mode)
|
||||||
plan.set_speed(1000);
|
plan.set_speed(1000);
|
||||||
plan.pen_up();
|
plan.pen_up();
|
||||||
plan.goto(vec2(-100.0, -100.0));
|
plan.go_to(vec2(-100.0, -100.0));
|
||||||
|
|
||||||
// Slow animated drawing
|
// Slow animated drawing
|
||||||
plan.set_speed(50);
|
plan.set_speed(50);
|
||||||
@@ -219,7 +219,7 @@ You can also export SVG programmatically from your code:
|
|||||||
use turtle_lib::*;
|
use turtle_lib::*;
|
||||||
|
|
||||||
// Create your drawing
|
// Create your drawing
|
||||||
let mut plan = create_turtle();
|
let mut plan = create_turtle_plan();
|
||||||
plan.forward(100).right(90).forward(100);
|
plan.forward(100).right(90).forward(100);
|
||||||
|
|
||||||
// Create app
|
// Create app
|
||||||
@@ -252,8 +252,8 @@ cargo run --example square
|
|||||||
cargo run --example koch
|
cargo run --example koch
|
||||||
cargo run --example shapes
|
cargo run --example shapes
|
||||||
cargo run --example yinyang
|
cargo run --example yinyang
|
||||||
cargo run --example stern
|
cargo run --example star
|
||||||
cargo run --example nikolaus
|
cargo run --example house_of_nikolaus
|
||||||
|
|
||||||
# SVG export example (requires --features svg)
|
# SVG export example (requires --features svg)
|
||||||
cargo run --example export_svg --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
|
- **square.rs**: Basic square drawing
|
||||||
- **koch.rs**: Koch snowflake fractal
|
- **koch.rs**: Koch snowflake fractal
|
||||||
- **shapes.rs**: Demonstrates different turtle shapes
|
- **shapes.rs**: Demonstrates different turtle shapes
|
||||||
- **stern.rs**: Star pattern drawing
|
- **star.rs**: Star pattern drawing
|
||||||
- **nikolaus.rs**: Nikolaus (Santa) drawing
|
- **house_of_nikolaus.rs**: House of Nikolaus (Eulerian path puzzle)
|
||||||
|
|
||||||
#### Fill Examples
|
#### Fill Examples
|
||||||
|
|
||||||
@@ -297,7 +297,7 @@ RUST_LOG=turtle_lib=debug cargo run --example logging_example
|
|||||||
### Basic Fill
|
### Basic Fill
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
let mut plan = create_turtle();
|
let mut plan = create_turtle_plan();
|
||||||
plan.set_fill_color(RED);
|
plan.set_fill_color(RED);
|
||||||
plan.begin_fill();
|
plan.begin_fill();
|
||||||
|
|
||||||
@@ -321,7 +321,7 @@ plan.circle_left(90.0, 360.0, 72);
|
|||||||
|
|
||||||
// pen_up() closes current contour
|
// pen_up() closes current contour
|
||||||
plan.pen_up();
|
plan.pen_up();
|
||||||
plan.goto(vec2(0.0, -30.0));
|
plan.go_to(vec2(0.0, -30.0));
|
||||||
|
|
||||||
// pen_down() starts new contour
|
// pen_down() starts new contour
|
||||||
plan.pen_down();
|
plan.pen_down();
|
||||||
@@ -341,7 +341,7 @@ turtle-lib/src/
|
|||||||
├── lib.rs - Public API and TurtleApp
|
├── lib.rs - Public API and TurtleApp
|
||||||
├── state.rs - TurtleState and TurtleWorld
|
├── state.rs - TurtleState and TurtleWorld
|
||||||
├── commands.rs - TurtleCommand enum (consolidated commands)
|
├── 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
|
├── execution.rs - Command execution with fill support
|
||||||
├── tweening.rs - Animation/tweening controller with dynamic speed
|
├── tweening.rs - Animation/tweening controller with dynamic speed
|
||||||
├── drawing.rs - Rendering with Lyon tessellation
|
├── drawing.rs - Rendering with Lyon tessellation
|
||||||
@@ -378,6 +378,16 @@ cargo build --release
|
|||||||
cargo build --features svg
|
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
|
## Development Status
|
||||||
|
|
||||||
### ✅ Completed
|
### ✅ Completed
|
||||||
|
|||||||
@@ -10,4 +10,4 @@ proc-macro = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
proc-macro2 = "1.0"
|
proc-macro2 = "1.0"
|
||||||
quote = "1.0"
|
quote = "1.0"
|
||||||
syn = { version = "2.0", features = ["full"] }
|
syn = { version = "3.0", features = ["full"] }
|
||||||
|
|||||||
+482
-121
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
use proc_macro::TokenStream;
|
use proc_macro::TokenStream;
|
||||||
use quote::quote;
|
use quote::quote;
|
||||||
use syn::{parse_macro_input, ItemFn};
|
use syn::ItemFn;
|
||||||
|
|
||||||
/// A convenience macro that wraps your turtle drawing code with the necessary
|
/// A convenience macro that wraps your turtle drawing code with the necessary
|
||||||
/// boilerplate for running a turtle graphics program.
|
/// boilerplate for running a turtle graphics program.
|
||||||
@@ -63,44 +63,145 @@ use syn::{parse_macro_input, ItemFn};
|
|||||||
/// This expands to approximately:
|
/// This expands to approximately:
|
||||||
///
|
///
|
||||||
/// ```ignore
|
/// ```ignore
|
||||||
/// use macroquad::prelude::*;
|
|
||||||
/// use turtle_lib::*;
|
/// use turtle_lib::*;
|
||||||
///
|
///
|
||||||
/// #[macroquad::main("My Turtle Drawing")]
|
/// fn main() {
|
||||||
/// async fn main() {
|
/// // Handle optional SVG export headlessly without opening a window
|
||||||
/// // Parse CLI args for --export-svg flag
|
/// if let Some(filename) = turtle_lib::export::parse_svg_export_arg() {
|
||||||
/// let args: Vec<String> = std::env::args().collect();
|
/// let mut build_commands = |turtle: &mut turtle_lib::TurtlePlan| {
|
||||||
/// // ... (argument parsing logic)
|
/// my_drawing(turtle);
|
||||||
///
|
/// };
|
||||||
/// let mut turtle = create_turtle_plan();
|
/// if let Err(e) = turtle_lib::export::run_headless_svg_export(&mut build_commands, &filename) {
|
||||||
///
|
/// eprintln!("Error exporting SVG: {:?}", e);
|
||||||
/// // Your drawing code here
|
/// std::process::exit(1);
|
||||||
/// 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;
|
|
||||||
/// }
|
/// }
|
||||||
///
|
/// return;
|
||||||
/// next_frame().await;
|
|
||||||
/// }
|
/// }
|
||||||
|
///
|
||||||
|
/// // 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]
|
#[proc_macro_attribute]
|
||||||
pub fn turtle_main(args: TokenStream, input: TokenStream) -> TokenStream {
|
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")
|
// Parse the window title from args (default to "Turtle Graphics")
|
||||||
let window_title = if args.is_empty() {
|
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_name = &input_fn.sig.ident;
|
||||||
let fn_block = &input_fn.block;
|
let fn_block = &input_fn.block;
|
||||||
|
let fn_attrs = &input_fn.attrs;
|
||||||
// Check if the function has the expected signature
|
let fn_vis = if fn_name == "main" {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(&input_fn.vis)
|
||||||
|
};
|
||||||
let has_turtle_param = input_fn.sig.inputs.len() == 1;
|
let has_turtle_param = input_fn.sig.inputs.len() == 1;
|
||||||
|
|
||||||
// Note: The following code has some duplication between the two branches
|
let helper_name = if fn_name == "main" {
|
||||||
// (with/without turtle parameter). This is intentional in proc macros as
|
quote::format_ident!("__turtle_main_draw")
|
||||||
// we're generating different code paths, and extracting the common parts
|
} else {
|
||||||
// into helper functions would make the macro more complex without significant benefit.
|
fn_name.clone()
|
||||||
|
};
|
||||||
|
|
||||||
let expanded = if has_turtle_param {
|
let helper_fn = if has_turtle_param {
|
||||||
// Function takes a turtle parameter
|
let param = &input_fn.sig.inputs[0];
|
||||||
quote! {
|
quote! {
|
||||||
#[macroquad::main(#window_title)]
|
#(#fn_attrs)*
|
||||||
async fn main() {
|
#fn_vis fn #helper_name(#param) #fn_block
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Function takes no parameters - inline the code
|
|
||||||
quote! {
|
quote! {
|
||||||
#[macroquad::main(#window_title)]
|
#(#fn_attrs)*
|
||||||
async fn main() {
|
#fn_vis fn #helper_name(turtle: &mut turtle_lib::TurtlePlan) {
|
||||||
// Build function reused for both export and normal rendering
|
let turtle = turtle;
|
||||||
let mut build_commands = |turtle: &mut turtle_lib::TurtlePlan| {
|
#fn_block
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ crossbeam = "0.8"
|
|||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
# For examples and testing
|
# For examples and testing
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||||
dialog = "*"
|
dialog = "0.3"
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! Cubic Bézier curve example
|
//! Cubic Bézier curve example
|
||||||
//! <https://en.wikipedia.org/wiki/B%C3%A9zier_curve>
|
//! <https://en.wikipedia.org/wiki/B%C3%A9zier_curve>
|
||||||
|
|
||||||
use turtle_lib::{turtle_main, vec2};
|
use turtle_lib::*;
|
||||||
|
|
||||||
struct CubicBezier {
|
struct CubicBezier {
|
||||||
point0: (f32, f32),
|
point0: (f32, f32),
|
||||||
|
|||||||
@@ -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::*;
|
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) {
|
fn pin(t: &mut TurtlePlan, size: f32) {
|
||||||
t.left(90.0).forward(size / 2.0);
|
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);
|
t.right(90.0).forward(size / 2.0).left(90.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pin_reihe(t: &mut TurtlePlan, anzahl: usize) {
|
fn pin_row(t: &mut TurtlePlan, count: usize) {
|
||||||
for x in 0..anzahl {
|
for x in 0..count {
|
||||||
pin(t, 5.0);
|
pin(t, 5.0);
|
||||||
if x < anzahl - 1 {
|
if x < count - 1 {
|
||||||
t.forward(5.0);
|
t.forward(5.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pin_spalte(t: &mut TurtlePlan, anzahl: usize, x_coord: f32) {
|
fn pin_column(t: &mut TurtlePlan, count: usize, x_coord: f32) {
|
||||||
for x in 0..anzahl {
|
for x in 0..count {
|
||||||
t.pen_up().go_to(vec2(x_coord, x as f32 * 10.0)).pen_down();
|
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()
|
t.pen_up()
|
||||||
.go_to(vec2(x_coord, -2.5))
|
.go_to(vec2(x_coord, -2.5))
|
||||||
.pen_down()
|
.pen_down()
|
||||||
.set_pen_color(color)
|
.set_pen_color(color)
|
||||||
.set_heading(90.0);
|
.set_heading(90.0);
|
||||||
for x in 0..anzahl {
|
for x in 0..count {
|
||||||
pin(t, 5.0);
|
pin(t, 5.0);
|
||||||
if x < anzahl - 1 {
|
if x < count - 1 {
|
||||||
t.forward(5.0);
|
t.forward(5.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn breadboard(t: &mut TurtlePlan, anzahl_reihen: usize) {
|
fn draw_breadboard(t: &mut TurtlePlan, row_count: usize) {
|
||||||
pin_spalte(t, anzahl_reihen, 0.0);
|
pin_column(t, row_count, 0.0);
|
||||||
pin_spalte(t, anzahl_reihen, 65.0);
|
pin_column(t, row_count, 65.0);
|
||||||
pin_seite(t, anzahl_reihen, -15.0, BLUE);
|
pin_side(t, row_count, -15.0, BLUE);
|
||||||
pin_seite(t, anzahl_reihen, -25.0, RED);
|
pin_side(t, row_count, -25.0, RED);
|
||||||
pin_seite(t, anzahl_reihen, 125.0, BLUE);
|
pin_side(t, row_count, 125.0, BLUE);
|
||||||
pin_seite(t, anzahl_reihen, 135.0, RED);
|
pin_side(t, row_count, 135.0, RED);
|
||||||
|
|
||||||
// draw outline
|
// draw outline
|
||||||
t.pen_up().go_to(vec2(-30.0, -5.0)).pen_down();
|
t.pen_up().go_to(vec2(-30.0, -5.0)).pen_down();
|
||||||
t.set_pen_color(BLACK)
|
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)
|
.right(90.0)
|
||||||
.forward(170.0)
|
.forward(170.0)
|
||||||
.right(90.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)
|
.right(90.0)
|
||||||
.forward(170.0)
|
.forward(170.0)
|
||||||
.right(90.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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ fn draw(turtle: &mut TurtlePlan) {
|
|||||||
turtle.set_pen_color(RED);
|
turtle.set_pen_color(RED);
|
||||||
turtle.set_pen_width(0.5);
|
turtle.set_pen_width(0.5);
|
||||||
turtle.left(90.0);
|
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.circle_left(100.0, 540.0, 72); // partial circle to the left
|
||||||
|
|
||||||
turtle.begin_fill();
|
turtle.begin_fill();
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
use chrono::{Local, Timelike};
|
use chrono::{Local, Timelike};
|
||||||
use macroquad::prelude::{clear_background, is_key_pressed, next_frame, KeyCode, WHITE};
|
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")]
|
#[macroquad::main("Clock")]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
use chrono::{Local, Timelike};
|
use chrono::{Local, Timelike};
|
||||||
use macroquad::prelude::{clear_background, is_key_pressed, next_frame, KeyCode, WHITE};
|
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)")]
|
#[macroquad::main("Clock (Threaded)")]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! Dashed circle example ported from sunjay/turtle
|
//! 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.
|
//! 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")]
|
#[turtle_main("Dashed Circle")]
|
||||||
fn draw(turtle: &mut TurtlePlan) {
|
fn draw(turtle: &mut TurtlePlan) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! Beispiel: Exportiere ein SVG aus einer einfachen Zeichnung
|
//! Example: Export an SVG from a simple drawing
|
||||||
|
|
||||||
#[cfg(feature = "svg")]
|
#[cfg(feature = "svg")]
|
||||||
use turtle_lib::*;
|
use turtle_lib::*;
|
||||||
@@ -52,12 +52,12 @@ async fn main() {
|
|||||||
app.update();
|
app.update();
|
||||||
app.render();
|
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) {
|
if is_key_pressed(KeyCode::E) {
|
||||||
match app.export_drawing("test.svg", export::DrawingFormat::Svg) {
|
match app.export_drawing("test.svg", export::DrawingFormat::Svg) {
|
||||||
Ok(_) => println!("SVG exportiert nach test.svg"),
|
Ok(_) => println!("SVG exported to test.svg"),
|
||||||
Err(e) => println!("Fehler beim Export: {:?}", e),
|
Err(e) => eprintln!("Export error: {:?}", e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,5 +67,5 @@ async fn main() {
|
|||||||
|
|
||||||
#[cfg(not(feature = "svg"))]
|
#[cfg(not(feature = "svg"))]
|
||||||
fn main() {
|
fn main() {
|
||||||
println!("SVG-Export ist nicht aktiviert. Baue mit --features svg");
|
println!("SVG export is not enabled. Build with --features svg");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ impl TurtleCommand {
|
|||||||
pub(crate) fn apply_to_params(&self, params: &mut TurtleParams) {
|
pub(crate) fn apply_to_params(&self, params: &mut TurtleParams) {
|
||||||
match self {
|
match self {
|
||||||
TurtleCommand::Move(dist) => {
|
TurtleCommand::Move(dist) => {
|
||||||
let dx = dist * params.heading.cos();
|
let dx = dist.value() * params.heading.cos();
|
||||||
let dy = dist * params.heading.sin();
|
let dy = dist.value() * params.heading.sin();
|
||||||
params.position = vec2(params.position.x + dx, params.position.y + dy);
|
params.position = vec2(params.position.x + dx, params.position.y + dy);
|
||||||
}
|
}
|
||||||
TurtleCommand::Turn(angle) => {
|
TurtleCommand::Turn(angle) => {
|
||||||
@@ -47,7 +47,7 @@ impl TurtleCommand {
|
|||||||
let geom = CircleGeometry::new(
|
let geom = CircleGeometry::new(
|
||||||
params.position,
|
params.position,
|
||||||
Radians::new(params.heading),
|
Radians::new(params.heading),
|
||||||
*radius,
|
radius.value(),
|
||||||
*direction,
|
*direction,
|
||||||
);
|
);
|
||||||
let angle_rad = angle.as_radians().value();
|
let angle_rad = angle.as_radians().value();
|
||||||
@@ -62,7 +62,7 @@ impl TurtleCommand {
|
|||||||
params.position = vec2(coord.x, -coord.y);
|
params.position = vec2(coord.x, -coord.y);
|
||||||
}
|
}
|
||||||
TurtleCommand::SetHeading(heading) => {
|
TurtleCommand::SetHeading(heading) => {
|
||||||
params.heading = normalize_angle(heading.value());
|
params.heading = normalize_angle(-heading.as_radians().value());
|
||||||
}
|
}
|
||||||
TurtleCommand::SetColor(color) => {
|
TurtleCommand::SetColor(color) => {
|
||||||
params.color = *color;
|
params.color = *color;
|
||||||
@@ -119,15 +119,16 @@ impl TurtleCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let base: f32 = match self {
|
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::Turn(angle) => angle.value().abs() / (spd * 1.8),
|
||||||
TurtleCommand::Circle { radius, angle, .. } => {
|
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
|
arc_length / spd
|
||||||
}
|
}
|
||||||
TurtleCommand::Goto(target) => {
|
TurtleCommand::Goto(target) => {
|
||||||
let dx = target.x - params.position.x;
|
let screen_target = vec2(target.x, -target.y);
|
||||||
let dy = target.y - params.position.y;
|
let dx = screen_target.x - params.position.x;
|
||||||
|
let dy = screen_target.y - params.position.y;
|
||||||
(dx * dx + dy * dy).sqrt() / spd
|
(dx * dx + dy * dy).sqrt() / spd
|
||||||
}
|
}
|
||||||
_ => 0.0,
|
_ => 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-11
@@ -1,13 +1,13 @@
|
|||||||
//! Turtle commands and command queue
|
//! 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;
|
use crate::shapes::TurtleShape;
|
||||||
|
|
||||||
/// Individual turtle commands
|
/// Individual turtle commands
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub enum TurtleCommand {
|
pub enum TurtleCommand {
|
||||||
// Movement (positive = forward, negative = backward)
|
// Movement (positive = forward, negative = backward)
|
||||||
Move(Precision),
|
Move(Length),
|
||||||
|
|
||||||
// Rotation (positive = right/clockwise, negative = left/counter-clockwise)
|
// Rotation (positive = right/clockwise, negative = left/counter-clockwise)
|
||||||
// Stored in degrees — the natural unit at the user-facing API boundary.
|
// Stored in degrees — the natural unit at the user-facing API boundary.
|
||||||
@@ -15,7 +15,7 @@ pub enum TurtleCommand {
|
|||||||
|
|
||||||
// Circle drawing
|
// Circle drawing
|
||||||
Circle {
|
Circle {
|
||||||
radius: Precision,
|
radius: Length,
|
||||||
angle: Degrees, // sweep angle — degrees, as supplied by the user
|
angle: Degrees, // sweep angle — degrees, as supplied by the user
|
||||||
steps: usize,
|
steps: usize,
|
||||||
direction: crate::circle_geometry::CircleDirection,
|
direction: crate::circle_geometry::CircleDirection,
|
||||||
@@ -34,10 +34,9 @@ pub enum TurtleCommand {
|
|||||||
|
|
||||||
// Position
|
// Position
|
||||||
Goto(Coordinate),
|
Goto(Coordinate),
|
||||||
/// Heading stored as internal radians (Y-down render-space convention).
|
/// Heading stored in user degrees (Cartesian convention: 0° = East, 90° = North).
|
||||||
/// Values passed via `TurtlePlan::set_heading` are converted from
|
/// Conversion to internal screen-space heading is performed when executed.
|
||||||
/// user-facing degrees before this command is enqueued.
|
SetHeading(Degrees),
|
||||||
SetHeading(Radians),
|
|
||||||
|
|
||||||
// Visibility
|
// Visibility
|
||||||
ShowTurtle,
|
ShowTurtle,
|
||||||
@@ -60,8 +59,8 @@ pub enum TurtleCommand {
|
|||||||
/// A pure-data sequence of turtle commands.
|
/// A pure-data sequence of turtle commands.
|
||||||
///
|
///
|
||||||
/// `CommandQueue` is intentionally *not* an `Iterator` — it carries no cursor
|
/// `CommandQueue` is intentionally *not* an `Iterator` — it carries no cursor
|
||||||
/// state. Execution state ("which command are we on?") belongs to the
|
/// state. Execution state belongs to the consumer; `TweenController` consumes
|
||||||
/// consumer; `TweenController` owns the cursor that walks this queue.
|
/// this queue as commands execute.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct CommandQueue {
|
pub struct CommandQueue {
|
||||||
commands: Vec<TurtleCommand>,
|
commands: Vec<TurtleCommand>,
|
||||||
@@ -115,8 +114,8 @@ impl Default for CommandQueue {
|
|||||||
/// Consuming iteration — yields every command in order.
|
/// Consuming iteration — yields every command in order.
|
||||||
///
|
///
|
||||||
/// This is used by `CommandQueue::extend` and `TweenController::append_commands`
|
/// This is used by `CommandQueue::extend` and `TweenController::append_commands`
|
||||||
/// to drain one queue into another. It does *not* imply that `CommandQueue`
|
/// 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 {
|
impl IntoIterator for CommandQueue {
|
||||||
type Item = TurtleCommand;
|
type Item = TurtleCommand;
|
||||||
type IntoIter = std::vec::IntoIter<TurtleCommand>;
|
type IntoIter = std::vec::IntoIter<TurtleCommand>;
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ pub struct TurtleCommandSender {
|
|||||||
/// Paired with `TurtleCommandSender` via `turtle_command_channel()`.
|
/// Paired with `TurtleCommandSender` via `turtle_command_channel()`.
|
||||||
/// Automatically managed by `TurtleApp::process_commands()`.
|
/// Automatically managed by `TurtleApp::process_commands()`.
|
||||||
pub(crate) struct TurtleCommandReceiver {
|
pub(crate) struct TurtleCommandReceiver {
|
||||||
turtle_id: usize,
|
|
||||||
rx: Receiver<CommandQueue>,
|
rx: Receiver<CommandQueue>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,12 +141,6 @@ impl TurtleCommandSender {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl TurtleCommandReceiver {
|
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)
|
/// Drain all pending commands for this turtle (non-blocking)
|
||||||
///
|
///
|
||||||
/// # Examples
|
/// # Examples
|
||||||
@@ -169,24 +162,6 @@ impl TurtleCommandReceiver {
|
|||||||
pub fn recv_all(&self) -> Vec<CommandQueue> {
|
pub fn recv_all(&self) -> Vec<CommandQueue> {
|
||||||
self.rx.try_iter().collect()
|
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
|
/// Create a command channel for a specific turtle
|
||||||
@@ -203,13 +178,10 @@ impl TurtleCommandReceiver {
|
|||||||
/// Panics if `buffer_size` is 0.
|
/// Panics if `buffer_size` is 0.
|
||||||
///
|
///
|
||||||
/// # Examples
|
/// # Examples
|
||||||
/// ```no_run
|
/// ```ignore
|
||||||
/// # use turtle_lib::*;
|
|
||||||
/// # fn example() {
|
|
||||||
/// let (tx, _rx) = turtle_command_channel(0, 100);
|
/// let (tx, _rx) = turtle_command_channel(0, 100);
|
||||||
/// // Sender goes to game threads
|
/// // Sender goes to game threads
|
||||||
/// // Receiver stays in render thread (or `TurtleApp`)
|
/// // Receiver stays in render thread (or `TurtleApp`)
|
||||||
/// # }
|
|
||||||
/// ```
|
/// ```
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub(crate) fn turtle_command_channel(
|
pub(crate) fn turtle_command_channel(
|
||||||
@@ -220,6 +192,6 @@ pub(crate) fn turtle_command_channel(
|
|||||||
let (tx, rx) = bounded(buffer_size);
|
let (tx, rx) = bounded(buffer_size);
|
||||||
(
|
(
|
||||||
TurtleCommandSender { turtle_id, tx },
|
TurtleCommandSender { turtle_id, tx },
|
||||||
TurtleCommandReceiver { turtle_id, rx },
|
TurtleCommandReceiver { rx },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-38
@@ -5,18 +5,11 @@ use crate::state::{DrawCommand, TurtleParams, TurtleWorld};
|
|||||||
use crate::tessellation;
|
use crate::tessellation;
|
||||||
use macroquad::prelude::*;
|
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.
|
/// Render the turtle world with active tween visualization.
|
||||||
#[allow(clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines)]
|
||||||
pub(crate) fn render_world_with_tweens(world: &TurtleWorld, zoom_level: f32) {
|
pub(crate) fn render_world_with_tweens(world: &TurtleWorld, zoom_level: f32) {
|
||||||
// Update camera zoom based on current screen size to prevent stretching
|
// 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 {
|
let camera = Camera2D {
|
||||||
zoom: vec2(
|
zoom: vec2(
|
||||||
1.0 / screen_width() * 2.0 / zoom_level,
|
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 turtle in &world.turtles {
|
||||||
for cmd in &turtle.commands {
|
for cmd in &turtle.commands {
|
||||||
match cmd {
|
match cmd {
|
||||||
DrawCommand::Mesh { data } => {
|
DrawCommand::Mesh(mesh) => {
|
||||||
draw_mesh(&data.to_mesh());
|
draw_mesh(mesh);
|
||||||
}
|
}
|
||||||
DrawCommand::Text {
|
DrawCommand::Text {
|
||||||
text,
|
text,
|
||||||
@@ -62,7 +55,7 @@ pub(crate) fn render_world_with_tweens(world: &TurtleWorld, zoom_level: f32) {
|
|||||||
direction,
|
direction,
|
||||||
} => {
|
} => {
|
||||||
// Draw arc segments from start to current position
|
// 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) => {
|
_ if should_draw_tween_line(&tween.command) => {
|
||||||
// Draw straight line for other movement commands (use tween's current position)
|
// 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(
|
let geom = CircleGeometry::new(
|
||||||
tween.start_params.position,
|
tween.start_params.position,
|
||||||
Radians::new(tween.start_params.heading),
|
Radians::new(tween.start_params.heading),
|
||||||
*radius,
|
radius.value(),
|
||||||
*direction,
|
*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
|
// 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 =
|
let samples_to_draw =
|
||||||
(((*steps).max(1) as f32 * eased_progress) as usize).max(1);
|
(((*steps).max(1) as f32 * tween.progress) as usize).max(1);
|
||||||
let sweep_so_far = angle.as_radians().value() * eased_progress;
|
let sweep_so_far = angle.as_radians().value() * tween.progress;
|
||||||
for pt in arc_points(
|
for pt in arc_points(
|
||||||
geom.center,
|
geom.center,
|
||||||
*radius,
|
radius.value(),
|
||||||
geom.start_angle_from_center,
|
geom.start_angle_from_center,
|
||||||
sweep_so_far,
|
sweep_so_far,
|
||||||
samples_to_draw,
|
samples_to_draw,
|
||||||
@@ -224,8 +213,8 @@ pub(crate) fn render_world_with_tweens(world: &TurtleWorld, zoom_level: f32) {
|
|||||||
&all_contours,
|
&all_contours,
|
||||||
fill_state.fill_color,
|
fill_state.fill_color,
|
||||||
) {
|
) {
|
||||||
Ok(mesh_data) => {
|
Ok(mesh) => {
|
||||||
draw_mesh(&mesh_data.to_mesh());
|
draw_mesh(&mesh);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("Failed to tessellate fill preview: {:?}", 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.
|
// Draw center using Lyon tessellation this helps visualizing what is done.
|
||||||
if let Ok(mesh_data) = crate::tessellation::tessellate_circle(geom.center, 5.0, GRAY, true, 1.0)
|
if let Ok(mesh) = crate::tessellation::tessellate_circle(geom.center, 5.0, GRAY, true, 1.0) {
|
||||||
{
|
draw_mesh(&mesh);
|
||||||
draw_mesh(&mesh_data.to_mesh());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate how much of the arc we've traveled based on tween progress
|
// Draw the partial arc traveled based on tween progress
|
||||||
// Use the same eased progress as the turtle position for synchronized animation
|
if let Ok(mesh) = crate::tessellation::tessellate_arc(
|
||||||
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(
|
|
||||||
geom.center,
|
geom.center,
|
||||||
radius,
|
radius,
|
||||||
geom.start_angle_from_center.to_degrees(),
|
geom.start_angle_from_center.to_degrees(),
|
||||||
total_angle.value() * progress,
|
total_angle.value() * tween.progress,
|
||||||
tween.start_params.color,
|
tween.start_params.color,
|
||||||
tween.start_params.pen_width,
|
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,
|
direction,
|
||||||
) {
|
) {
|
||||||
draw_mesh(&mesh_data.to_mesh());
|
draw_mesh(&mesh);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,10 +325,10 @@ pub(crate) fn draw_turtle(turtle_params: &TurtleParams) {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Use Lyon for turtle shape too
|
// 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))
|
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 {
|
} else {
|
||||||
// Fallback to simple triangle fan if Lyon fails
|
// Fallback to simple triangle fan if Lyon fails
|
||||||
let first = absolute_vertices[0];
|
let first = absolute_vertices[0];
|
||||||
|
|||||||
+17
-33
@@ -93,7 +93,6 @@ pub(crate) fn execute_command_side_effects(
|
|||||||
BLACK
|
BLACK
|
||||||
});
|
});
|
||||||
*filling = Some(FillState {
|
*filling = Some(FillState {
|
||||||
start_position: params.position,
|
|
||||||
contours: Vec::new(),
|
contours: Vec::new(),
|
||||||
current_contour: vec![params.position],
|
current_contour: vec![params.position],
|
||||||
fill_color,
|
fill_color,
|
||||||
@@ -123,7 +122,7 @@ pub(crate) fn execute_command_side_effects(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !fill_state.contours.is_empty() {
|
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.contours,
|
||||||
fill_state.fill_color,
|
fill_state.fill_color,
|
||||||
) {
|
) {
|
||||||
@@ -132,7 +131,7 @@ pub(crate) fn execute_command_side_effects(
|
|||||||
contours = fill_state.contours.len(),
|
contours = fill_state.contours.len(),
|
||||||
"Successfully created fill mesh - persisting to commands"
|
"Successfully created fill mesh - persisting to commands"
|
||||||
);
|
);
|
||||||
commands.push(DrawCommand::Mesh { data: mesh_data });
|
commands.push(DrawCommand::Mesh(mesh));
|
||||||
#[cfg(feature = "svg")]
|
#[cfg(feature = "svg")]
|
||||||
svg_log.push(crate::state::SvgRecord::Fill {
|
svg_log.push(crate::state::SvgRecord::Fill {
|
||||||
contours: fill_state.contours,
|
contours: fill_state.contours,
|
||||||
@@ -242,7 +241,7 @@ pub(crate) fn record_fill_vertices_after_movement(
|
|||||||
let geom = CircleGeometry::new(
|
let geom = CircleGeometry::new(
|
||||||
start_state.position,
|
start_state.position,
|
||||||
Radians::new(start_state.heading),
|
Radians::new(start_state.heading),
|
||||||
*radius,
|
radius.value(),
|
||||||
*direction,
|
*direction,
|
||||||
);
|
);
|
||||||
if let Some(ref mut fill_state) = filling {
|
if let Some(ref mut fill_state) = filling {
|
||||||
@@ -252,7 +251,7 @@ pub(crate) fn record_fill_vertices_after_movement(
|
|||||||
turtle_id,
|
turtle_id,
|
||||||
center_x = geom.center.x,
|
center_x = geom.center.x,
|
||||||
center_y = geom.center.y,
|
center_y = geom.center.y,
|
||||||
radius,
|
radius = radius.value(),
|
||||||
steps,
|
steps,
|
||||||
num_samples,
|
num_samples,
|
||||||
"Recording arc vertices"
|
"Recording arc vertices"
|
||||||
@@ -268,8 +267,8 @@ pub(crate) fn record_fill_vertices_after_movement(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let vertex = Coordinate::new(
|
let vertex = Coordinate::new(
|
||||||
geom.center.x + radius * current_angle.cos(),
|
geom.center.x + radius.value() * current_angle.cos(),
|
||||||
geom.center.y + radius * current_angle.sin(),
|
geom.center.y + radius.value() * current_angle.sin(),
|
||||||
);
|
);
|
||||||
tracing::trace!(
|
tracing::trace!(
|
||||||
turtle_id,
|
turtle_id,
|
||||||
@@ -326,7 +325,7 @@ pub(crate) fn tessellate_command(
|
|||||||
|
|
||||||
match command {
|
match command {
|
||||||
TurtleCommand::Move(_) | TurtleCommand::Goto(_) => {
|
TurtleCommand::Move(_) | TurtleCommand::Goto(_) => {
|
||||||
let mesh_data = tessellation::tessellate_stroke(
|
let mesh = tessellation::tessellate_stroke(
|
||||||
&[start.position, end_position],
|
&[start.position, end_position],
|
||||||
start.color,
|
start.color,
|
||||||
start.pen_width,
|
start.pen_width,
|
||||||
@@ -334,7 +333,7 @@ pub(crate) fn tessellate_command(
|
|||||||
)
|
)
|
||||||
.ok()?;
|
.ok()?;
|
||||||
|
|
||||||
Some(DrawCommand::Mesh { data: mesh_data })
|
Some(DrawCommand::Mesh(mesh))
|
||||||
}
|
}
|
||||||
|
|
||||||
TurtleCommand::Circle {
|
TurtleCommand::Circle {
|
||||||
@@ -347,12 +346,12 @@ pub(crate) fn tessellate_command(
|
|||||||
let geom = CircleGeometry::new(
|
let geom = CircleGeometry::new(
|
||||||
start.position,
|
start.position,
|
||||||
Radians::new(start.heading),
|
Radians::new(start.heading),
|
||||||
*radius,
|
radius.value(),
|
||||||
*direction,
|
*direction,
|
||||||
);
|
);
|
||||||
let mesh_data = tessellation::tessellate_arc(
|
let mesh = tessellation::tessellate_arc(
|
||||||
geom.center,
|
geom.center,
|
||||||
*radius,
|
radius.value(),
|
||||||
geom.start_angle_from_center.to_degrees(),
|
geom.start_angle_from_center.to_degrees(),
|
||||||
angle.value(),
|
angle.value(),
|
||||||
start.color,
|
start.color,
|
||||||
@@ -362,7 +361,7 @@ pub(crate) fn tessellate_command(
|
|||||||
)
|
)
|
||||||
.ok()?;
|
.ok()?;
|
||||||
|
|
||||||
Some(DrawCommand::Mesh { data: mesh_data })
|
Some(DrawCommand::Mesh(mesh))
|
||||||
}
|
}
|
||||||
|
|
||||||
// `produces_drawing()` guards entry — this arm is only reachable if
|
// `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 {
|
svg_log.push(SvgRecord::Arc {
|
||||||
start_position: start.position,
|
start_position: start.position,
|
||||||
start_heading: start.heading,
|
start_heading: start.heading,
|
||||||
radius: *radius,
|
radius: radius.value(),
|
||||||
angle: *angle,
|
angle: *angle,
|
||||||
direction: *direction,
|
direction: *direction,
|
||||||
color: start.color,
|
color: start.color,
|
||||||
@@ -474,7 +473,7 @@ pub(crate) fn execute_command_with_id(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::commands::TurtleCommand;
|
use crate::commands::TurtleCommand;
|
||||||
use crate::general::Degrees;
|
use crate::general::{Degrees, Length};
|
||||||
use crate::shapes::TurtleShape;
|
use crate::shapes::TurtleShape;
|
||||||
use crate::tweening::TweenController;
|
use crate::tweening::TweenController;
|
||||||
|
|
||||||
@@ -484,7 +483,7 @@ mod tests {
|
|||||||
// the turtle ends up at (100, -50) from initial position (0, 0)
|
// the turtle ends up at (100, -50) from initial position (0, 0)
|
||||||
use crate::state::TurtleParams;
|
use crate::state::TurtleParams;
|
||||||
|
|
||||||
let state = Turtle {
|
let mut state = Turtle {
|
||||||
turtle_id: 0,
|
turtle_id: 0,
|
||||||
params: TurtleParams {
|
params: TurtleParams {
|
||||||
position: vec2(0.0, 0.0),
|
position: vec2(0.0, 0.0),
|
||||||
@@ -503,28 +502,13 @@ mod tests {
|
|||||||
tween_controller: TweenController::default(),
|
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)
|
// Initial state: position (0, 0), heading 0 (east)
|
||||||
assert_eq!(state.params.position.x, 0.0);
|
assert_eq!(state.params.position.x, 0.0);
|
||||||
assert_eq!(state.params.position.y, 0.0);
|
assert_eq!(state.params.position.y, 0.0);
|
||||||
assert_eq!(state.params.heading, 0.0);
|
assert_eq!(state.params.heading, 0.0);
|
||||||
|
|
||||||
// Forward 100 - should move to (100, 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!(
|
assert!(
|
||||||
(state.params.position.x - 100.0).abs() < 0.01,
|
(state.params.position.x - 100.0).abs() < 0.01,
|
||||||
"After forward(100): x = {}",
|
"After forward(100): x = {}",
|
||||||
@@ -559,7 +543,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Forward 50 - should move north (negative Y) to (100, -50)
|
// 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!(
|
assert!(
|
||||||
(state.params.position.x - 100.0).abs() < 0.01,
|
(state.params.position.x - 100.0).abs() < 0.01,
|
||||||
"Final position: x = {} (expected 100.0)",
|
"Final position: x = {} (expected 100.0)",
|
||||||
|
|||||||
+44
-36
@@ -1,5 +1,5 @@
|
|||||||
//! Export backend trait and core export types.
|
//! Export backend trait and core export types.
|
||||||
|
#[cfg(feature = "svg")]
|
||||||
use crate::state::TurtleWorld;
|
use crate::state::TurtleWorld;
|
||||||
use crate::TurtlePlan;
|
use crate::TurtlePlan;
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ pub enum DrawingFormat {
|
|||||||
// Additional formats: Png, Pdf, …
|
// Additional formats: Png, Pdf, …
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "svg")]
|
||||||
pub(crate) trait DrawingExporter {
|
pub(crate) trait DrawingExporter {
|
||||||
/// Export the drawing to the specified format and filename
|
/// 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>;
|
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 args: Vec<String> = std::env::args().collect();
|
||||||
let mut i = 1;
|
let mut i = 1;
|
||||||
while i < args.len() {
|
while i < args.len() {
|
||||||
@@ -38,49 +41,54 @@ pub(crate) fn parse_svg_export_arg() -> Option<String> {
|
|||||||
None
|
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<F>(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.
|
/// Handle the optional `--export-svg` CLI flag.
|
||||||
///
|
///
|
||||||
/// The feature gating lives inside `turtle-lib`, so the `turtle_main` macro
|
/// Delegates to [`run_headless_svg_export`].
|
||||||
/// no longer needs to reference cfg flags from the consuming crate.
|
|
||||||
pub fn handle_svg_export<F>(build_commands: F)
|
pub fn handle_svg_export<F>(build_commands: F)
|
||||||
where
|
where
|
||||||
F: FnMut(&mut TurtlePlan),
|
F: FnMut(&mut TurtlePlan),
|
||||||
{
|
{
|
||||||
// Avoid unused warnings when the feature is disabled
|
|
||||||
let _ = &build_commands;
|
|
||||||
|
|
||||||
if let Some(filename) = parse_svg_export_arg() {
|
if let Some(filename) = parse_svg_export_arg() {
|
||||||
#[cfg(feature = "svg")]
|
match run_headless_svg_export(build_commands, &filename) {
|
||||||
{
|
Ok(()) => {
|
||||||
let mut build_commands = build_commands;
|
println!("SVG exported successfully to: {filename}");
|
||||||
let mut turtle = crate::create_turtle_plan();
|
std::process::exit(0);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
Err(e) => {
|
||||||
match app.export_drawing(&filename, crate::export::DrawingFormat::Svg) {
|
eprintln!("Error exporting SVG: {e:?}");
|
||||||
Ok(_) => {
|
std::process::exit(1);
|
||||||
println!("SVG exported successfully to: {}", filename);
|
|
||||||
std::process::exit(0);
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,31 @@
|
|||||||
pub mod svg_export {
|
pub mod svg_export {
|
||||||
use crate::export::{DrawingExporter, ExportError};
|
use crate::export::{DrawingExporter, ExportError};
|
||||||
use crate::state::{SvgRecord, TurtleWorld};
|
use crate::state::{SvgRecord, TurtleWorld};
|
||||||
|
use std::fmt::Write;
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use svg::{
|
use svg::{
|
||||||
node::element::{Circle, Line, Text as SvgText},
|
node::element::{Circle, Line, Text as SvgText},
|
||||||
Document,
|
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;
|
pub struct SvgExporter;
|
||||||
|
|
||||||
impl DrawingExporter for SvgExporter {
|
impl DrawingExporter for SvgExporter {
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
fn export(&self, world: &TurtleWorld, filename: &str) -> Result<(), ExportError> {
|
fn export(&self, world: &TurtleWorld, filename: &str) -> Result<(), ExportError> {
|
||||||
let mut doc = Document::new();
|
let mut doc = Document::new();
|
||||||
|
|
||||||
@@ -21,20 +37,6 @@ pub mod svg_export {
|
|||||||
let mut min_y = f32::INFINITY;
|
let mut min_y = f32::INFINITY;
|
||||||
let mut max_y = f32::NEG_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 turtle in &world.turtles {
|
||||||
for record in &turtle.svg_log.records {
|
for record in &turtle.svg_log.records {
|
||||||
match record {
|
match record {
|
||||||
@@ -110,7 +112,7 @@ pub mod svg_export {
|
|||||||
} else {
|
} else {
|
||||||
// Partial arc — emit as <path A …>
|
// Partial arc — emit as <path A …>
|
||||||
let end = geom.position_at_angle(angle.as_radians().value());
|
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 {
|
let sweep = match direction {
|
||||||
crate::circle_geometry::CircleDirection::Left => 0,
|
crate::circle_geometry::CircleDirection::Left => 0,
|
||||||
crate::circle_geometry::CircleDirection::Right => 1,
|
crate::circle_geometry::CircleDirection::Right => 1,
|
||||||
@@ -154,9 +156,9 @@ pub mod svg_export {
|
|||||||
if i > 0 {
|
if i > 0 {
|
||||||
d.push(' ');
|
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) {
|
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");
|
d.push_str(" Z");
|
||||||
}
|
}
|
||||||
@@ -227,9 +229,9 @@ pub mod svg_export {
|
|||||||
let g = (color.g * 255.0) as u8;
|
let g = (color.g * 255.0) as u8;
|
||||||
let b = (color.b * 255.0) as u8;
|
let b = (color.b * 255.0) as u8;
|
||||||
if color.a < 1.0 {
|
if color.a < 1.0 {
|
||||||
format!("rgba({},{},{},{})", r, g, b, color.a)
|
format!("rgba({r},{g},{b},{})", color.a)
|
||||||
} else {
|
} else {
|
||||||
format!("rgb({},{},{})", r, g, b)
|
format!("rgb({r},{g},{b})")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,7 @@ pub type Precision = f32;
|
|||||||
/// - internal render-space state uses Macroquad-style Y-down coordinates
|
/// - internal render-space state uses Macroquad-style Y-down coordinates
|
||||||
pub type Coordinate = Vec2;
|
pub type Coordinate = Vec2;
|
||||||
|
|
||||||
/// Visibility flag for turtle
|
|
||||||
pub type Visibility = bool;
|
|
||||||
|
|
||||||
/// Execution speed setting
|
/// Execution speed setting
|
||||||
/// - `Instant(draw_calls)`: Fast execution with limited draw calls per frame (speed - 1000, minimum 1)
|
/// - `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 {
|
impl From<u32> for AnimationSpeed {
|
||||||
fn from(speed: u32) -> Self {
|
fn from(speed: u32) -> Self {
|
||||||
AnimationSpeed::from_u32(speed)
|
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
|
/// Color type re-export from macroquad
|
||||||
pub use macroquad::color::Color;
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
impl From<i32> for Degrees {
|
||||||
fn from(v: i32) -> Self {
|
fn from(v: i32) -> Self {
|
||||||
Self(v as Precision)
|
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.
|
/// An angle measured in radians.
|
||||||
@@ -158,5 +170,13 @@ mod tests {
|
|||||||
assert_eq!(d, Degrees::new(90.0));
|
assert_eq!(d, Degrees::new(90.0));
|
||||||
let d2: Degrees = 45_i16.into();
|
let d2: Degrees = 45_i16.into();
|
||||||
assert_eq!(d2, Degrees::new(45.0));
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ impl FontSize {
|
|||||||
|
|
||||||
/// Get the inner u16 value
|
/// Get the inner u16 value
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn value(&self) -> u16 {
|
pub const fn value(self) -> u16 {
|
||||||
self.0
|
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 {
|
impl From<usize> for FontSize {
|
||||||
fn from(size: usize) -> Self {
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,33 @@
|
|||||||
//! Length type for distance measurements
|
//! Length type for distance measurements
|
||||||
|
|
||||||
use super::Precision;
|
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);
|
pub struct Length(pub Precision);
|
||||||
|
|
||||||
impl From<i16> for Length {
|
impl Length {
|
||||||
fn from(i: i16) -> Self {
|
/// Create a new `Length` from a raw value.
|
||||||
Self(Precision::from(i))
|
#[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 {
|
impl From<i32> for Length {
|
||||||
fn from(i: i32) -> Self {
|
fn from(i: i32) -> Self {
|
||||||
Self(i as Precision)
|
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
@@ -59,8 +59,9 @@ pub(crate) mod state;
|
|||||||
pub(crate) mod tessellation;
|
pub(crate) mod tessellation;
|
||||||
pub(crate) mod tweening;
|
pub(crate) mod tweening;
|
||||||
|
|
||||||
// Re-export commonly used types
|
pub use builders::{
|
||||||
pub use builders::{CurvedMovement, DirectionalMovement, Turnable, TurtlePlan, WithCommands};
|
Cursor, Fill, Movement, Pen, Rotation, Text, TurtlePlan, WithCommands,
|
||||||
|
};
|
||||||
pub use commands::{CommandQueue, TurtleCommand};
|
pub use commands::{CommandQueue, TurtleCommand};
|
||||||
pub use commands_channel::TurtleCommandSender;
|
pub use commands_channel::TurtleCommandSender;
|
||||||
pub use general::{AnimationSpeed, Color, Coordinate, Degrees, Length, Precision, Radians};
|
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
|
// Re-export the turtle_main macro
|
||||||
pub use turtle_lib_macros::turtle_main;
|
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
|
// Re-export common macroquad types and colors for convenience
|
||||||
pub use macroquad::prelude::{
|
pub use macroquad::prelude::{
|
||||||
vec2, BLACK, BLUE, DARKGRAY, GOLD, GREEN, ORANGE, PURPLE, RED, WHITE, YELLOW,
|
vec2, BLACK, BLUE, DARKGRAY, GOLD, GREEN, ORANGE, PURPLE, RED, WHITE, YELLOW,
|
||||||
@@ -106,6 +110,7 @@ impl TurtleApp {
|
|||||||
filename: &str,
|
filename: &str,
|
||||||
format: export::DrawingFormat,
|
format: export::DrawingFormat,
|
||||||
) -> Result<(), export::ExportError> {
|
) -> Result<(), export::ExportError> {
|
||||||
|
let _ = filename;
|
||||||
match format {
|
match format {
|
||||||
#[cfg(feature = "svg")]
|
#[cfg(feature = "svg")]
|
||||||
export::DrawingFormat::Svg => {
|
export::DrawingFormat::Svg => {
|
||||||
@@ -249,6 +254,11 @@ impl TurtleApp {
|
|||||||
|
|
||||||
/// Execute a plan immediately on a specific turtle (no animation)
|
/// Execute a plan immediately on a specific turtle (no animation)
|
||||||
pub fn execute_immediate(&mut self, turtle_id: usize, plan: TurtlePlan) {
|
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() {
|
for ref cmd in plan.build() {
|
||||||
execution::execute_command_with_id(cmd, turtle_id, &mut self.world);
|
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) {
|
pub fn update(&mut self) {
|
||||||
// Handle mouse panning and zoom
|
// Handle mouse panning and zoom
|
||||||
self.handle_mouse_panning();
|
self.handle_mouse_panning();
|
||||||
self.handle_mouse_zoom();
|
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
|
// Update all turtles' tween controllers
|
||||||
for turtle in &mut self.world.turtles {
|
for turtle in &mut self.world.turtles {
|
||||||
// Drive this turtle's animation controller for one frame.
|
// Drive this turtle's animation controller for one frame.
|
||||||
@@ -371,12 +388,6 @@ impl TurtleApp {
|
|||||||
.all(|turtle| turtle.tween_controller.is_complete())
|
.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
|
/// Set the animation speed for all turtles
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # 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 {
|
impl Default for TurtleApp {
|
||||||
|
|||||||
+3
-223
@@ -9,9 +9,6 @@ use macroquad::prelude::*;
|
|||||||
/// State during active fill operation
|
/// State during active fill operation
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub(crate) struct FillState {
|
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.
|
/// All contours collected so far. Each contour is a separate closed path.
|
||||||
/// The first contour is the outer boundary, subsequent contours are holes.
|
/// The first contour is the outer boundary, subsequent contours are holes.
|
||||||
pub(crate) contours: Vec<Vec<Coordinate>>,
|
pub(crate) contours: Vec<Vec<Coordinate>>,
|
||||||
@@ -55,8 +52,8 @@ impl Default for TurtleParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// State of a single turtle
|
/// State of a single turtle
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub(crate) struct Turtle {
|
pub(crate) struct Turtle {
|
||||||
|
#[allow(clippy::struct_field_names)]
|
||||||
pub(crate) turtle_id: usize,
|
pub(crate) turtle_id: usize,
|
||||||
pub(crate) params: TurtleParams,
|
pub(crate) params: TurtleParams,
|
||||||
|
|
||||||
@@ -91,26 +88,6 @@ impl Turtle {
|
|||||||
self.params.speed = speed;
|
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.
|
/// Drive the animation controller for one frame.
|
||||||
///
|
///
|
||||||
/// Returns `(command, start_params, end_params)` for every command that
|
/// Returns `(command, start_params, end_params)` for every command that
|
||||||
@@ -131,156 +108,6 @@ impl Turtle {
|
|||||||
&mut self.svg_log,
|
&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.
|
/// 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.
|
/// Drawable elements in the world.
|
||||||
/// All drawing is done via Lyon-tessellated meshes for consistency and quality.
|
/// All drawing is done via Lyon-tessellated meshes for consistency and quality.
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub(crate) enum DrawCommand {
|
pub(crate) enum DrawCommand {
|
||||||
/// Pre-tessellated mesh data (lines, arcs, circles, polygons — all use this).
|
/// Pre-tessellated mesh data (lines, arcs, circles, polygons — all use this).
|
||||||
Mesh { data: MeshData },
|
Mesh(macroquad::prelude::Mesh),
|
||||||
/// Text rendering command.
|
/// Text rendering command.
|
||||||
Text {
|
Text {
|
||||||
text: String,
|
text: String,
|
||||||
@@ -388,7 +196,6 @@ pub(crate) struct TurtleWorld {
|
|||||||
/// All turtles in the world (indexed by turtle ID)
|
/// All turtles in the world (indexed by turtle ID)
|
||||||
pub(crate) turtles: Vec<Turtle>,
|
pub(crate) turtles: Vec<Turtle>,
|
||||||
pub(crate) camera: Camera2D,
|
pub(crate) camera: Camera2D,
|
||||||
pub(crate) background_color: Color,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TurtleWorld {
|
impl TurtleWorld {
|
||||||
@@ -396,12 +203,7 @@ impl TurtleWorld {
|
|||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
turtles: vec![], // Start with no turtles
|
turtles: vec![], // Start with no turtles
|
||||||
camera: Camera2D {
|
camera: Camera2D::default(),
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,32 +218,10 @@ impl TurtleWorld {
|
|||||||
turtle_id
|
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
|
/// Get mutable turtle by ID
|
||||||
pub fn get_turtle_mut(&mut self, id: usize) -> Option<&mut Turtle> {
|
pub fn get_turtle_mut(&mut self, id: usize) -> Option<&mut Turtle> {
|
||||||
self.turtles.get_mut(id)
|
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 {
|
impl Default for TurtleWorld {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
//! This module provides helper functions to tessellate paths using Lyon,
|
//! This module provides helper functions to tessellate paths using Lyon,
|
||||||
//! which replaces the manual triangulation with GPU-optimized tessellation.
|
//! which replaces the manual triangulation with GPU-optimized tessellation.
|
||||||
|
|
||||||
use crate::state::MeshData;
|
|
||||||
use lyon::math::{point, Point};
|
use lyon::math::{point, Point};
|
||||||
use lyon::path::{LineCap, LineJoin, Path};
|
use lyon::path::{LineCap, LineJoin, Path};
|
||||||
use lyon::tessellation::{
|
use lyon::tessellation::{
|
||||||
@@ -31,13 +30,13 @@ pub(crate) struct SimpleVertex {
|
|||||||
pub(crate) position: [f32; 2],
|
pub(crate) position: [f32; 2],
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build mesh data from Lyon tessellation
|
/// Build mesh from Lyon tessellation
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub(crate) fn build_mesh_data(
|
pub(crate) fn build_mesh(
|
||||||
vertices: &[SimpleVertex],
|
vertices: &[SimpleVertex],
|
||||||
indices: &[u16],
|
indices: &[u16],
|
||||||
color: Color,
|
color: Color,
|
||||||
) -> MeshData {
|
) -> Mesh {
|
||||||
let verts: Vec<Vertex> = vertices
|
let verts: Vec<Vertex> = vertices
|
||||||
.iter()
|
.iter()
|
||||||
.map(|v| Vertex {
|
.map(|v| Vertex {
|
||||||
@@ -53,9 +52,10 @@ pub(crate) fn build_mesh_data(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
MeshData {
|
Mesh {
|
||||||
vertices: verts,
|
vertices: verts,
|
||||||
indices: indices.to_vec(),
|
indices: indices.to_vec(),
|
||||||
|
texture: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ pub(crate) fn build_mesh_data(
|
|||||||
pub(crate) fn tessellate_polygon(
|
pub(crate) fn tessellate_polygon(
|
||||||
vertices: &[Vec2],
|
vertices: &[Vec2],
|
||||||
color: Color,
|
color: Color,
|
||||||
) -> Result<MeshData, Box<dyn std::error::Error>> {
|
) -> Result<Mesh, Box<dyn std::error::Error>> {
|
||||||
if vertices.is_empty() {
|
if vertices.is_empty() {
|
||||||
return Err("No vertices provided".into());
|
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.vertices,
|
||||||
&geometry.indices,
|
&geometry.indices,
|
||||||
color,
|
color,
|
||||||
@@ -114,7 +114,7 @@ pub(crate) fn tessellate_polygon(
|
|||||||
pub(crate) fn tessellate_multi_contour(
|
pub(crate) fn tessellate_multi_contour(
|
||||||
contours: &[Vec<Vec2>],
|
contours: &[Vec<Vec2>],
|
||||||
color: Color,
|
color: Color,
|
||||||
) -> Result<MeshData, Box<dyn std::error::Error>> {
|
) -> Result<Mesh, Box<dyn std::error::Error>> {
|
||||||
if contours.is_empty() {
|
if contours.is_empty() {
|
||||||
return Err("No contours provided".into());
|
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.vertices,
|
||||||
&geometry.indices,
|
&geometry.indices,
|
||||||
color,
|
color,
|
||||||
@@ -212,7 +212,7 @@ pub(crate) fn tessellate_stroke(
|
|||||||
color: Color,
|
color: Color,
|
||||||
width: f32,
|
width: f32,
|
||||||
closed: bool,
|
closed: bool,
|
||||||
) -> Result<MeshData, Box<dyn std::error::Error>> {
|
) -> Result<Mesh, Box<dyn std::error::Error>> {
|
||||||
if vertices.is_empty() {
|
if vertices.is_empty() {
|
||||||
return Err("No vertices provided".into());
|
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.vertices,
|
||||||
&geometry.indices,
|
&geometry.indices,
|
||||||
color,
|
color,
|
||||||
@@ -259,7 +259,7 @@ pub(crate) fn tessellate_circle(
|
|||||||
color: Color,
|
color: Color,
|
||||||
filled: bool,
|
filled: bool,
|
||||||
stroke_width: f32,
|
stroke_width: f32,
|
||||||
) -> Result<MeshData, Box<dyn std::error::Error>> {
|
) -> Result<Mesh, Box<dyn std::error::Error>> {
|
||||||
let mut builder = Path::builder();
|
let mut builder = Path::builder();
|
||||||
builder.add_circle(to_lyon_point(center), radius, lyon::path::Winding::Positive);
|
builder.add_circle(to_lyon_point(center), radius, lyon::path::Winding::Positive);
|
||||||
let path = builder.build();
|
let path = builder.build();
|
||||||
@@ -286,7 +286,7 @@ pub(crate) fn tessellate_circle(
|
|||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(build_mesh_data(
|
Ok(build_mesh(
|
||||||
&geometry.vertices,
|
&geometry.vertices,
|
||||||
&geometry.indices,
|
&geometry.indices,
|
||||||
color,
|
color,
|
||||||
@@ -308,7 +308,7 @@ pub(crate) fn tessellate_arc(
|
|||||||
stroke_width: f32,
|
stroke_width: f32,
|
||||||
segments: usize,
|
segments: usize,
|
||||||
direction: crate::circle_geometry::CircleDirection,
|
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;
|
use crate::circle_geometry::arc_points;
|
||||||
|
|
||||||
let start_angle = start_angle_degrees.to_radians();
|
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.vertices,
|
||||||
&geometry.indices,
|
&geometry.indices,
|
||||||
color,
|
color,
|
||||||
|
|||||||
+203
-24
@@ -5,6 +5,7 @@ use crate::commands::{CommandQueue, TurtleCommand};
|
|||||||
use crate::general::{AnimationSpeed, Radians};
|
use crate::general::{AnimationSpeed, Radians};
|
||||||
use crate::state::{DrawCommand, FillState, TurtleParams};
|
use crate::state::{DrawCommand, FillState, TurtleParams};
|
||||||
use macroquad::prelude::*;
|
use macroquad::prelude::*;
|
||||||
|
use std::collections::VecDeque;
|
||||||
use tween::{CubicInOut, TweenValue, Tweener};
|
use tween::{CubicInOut, TweenValue, Tweener};
|
||||||
|
|
||||||
// Newtype wrapper for Vec2 to implement TweenValue
|
// Newtype wrapper for Vec2 to implement TweenValue
|
||||||
@@ -46,25 +47,21 @@ impl From<TweenVec2> for Vec2 {
|
|||||||
/// Controls tweening of turtle commands
|
/// Controls tweening of turtle commands
|
||||||
#[derive(Clone, Debug, Default)]
|
#[derive(Clone, Debug, Default)]
|
||||||
pub(crate) struct TweenController {
|
pub(crate) struct TweenController {
|
||||||
queue: CommandQueue,
|
queue: VecDeque<TurtleCommand>,
|
||||||
/// 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,
|
|
||||||
current_tween: Option<CommandTween>,
|
current_tween: Option<CommandTween>,
|
||||||
speed: AnimationSpeed,
|
speed: AnimationSpeed,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub(crate) struct CommandTween {
|
pub(crate) struct CommandTween {
|
||||||
pub(crate) turtle_id: usize,
|
|
||||||
pub(crate) command: TurtleCommand,
|
pub(crate) command: TurtleCommand,
|
||||||
pub(crate) start_time: f64,
|
pub(crate) progress: f32,
|
||||||
pub(crate) duration: f64,
|
|
||||||
pub(crate) start_params: TurtleParams,
|
pub(crate) start_params: TurtleParams,
|
||||||
pub(crate) target_params: TurtleParams,
|
pub(crate) target_params: TurtleParams,
|
||||||
pub(crate) current_position: Vec2,
|
pub(crate) current_position: Vec2,
|
||||||
pub(crate) current_heading: f32,
|
pub(crate) current_heading: f32,
|
||||||
|
start_time: f64,
|
||||||
|
duration: f64,
|
||||||
position_tweener: Tweener<TweenVec2, f64, CubicInOut>,
|
position_tweener: Tweener<TweenVec2, f64, CubicInOut>,
|
||||||
heading_tweener: Tweener<f32, f64, CubicInOut>,
|
heading_tweener: Tweener<f32, f64, CubicInOut>,
|
||||||
pen_width_tweener: Tweener<f32, f64, CubicInOut>,
|
pen_width_tweener: Tweener<f32, f64, CubicInOut>,
|
||||||
@@ -74,8 +71,7 @@ impl TweenController {
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(queue: CommandQueue, speed: AnimationSpeed) -> Self {
|
pub fn new(queue: CommandQueue, speed: AnimationSpeed) -> Self {
|
||||||
Self {
|
Self {
|
||||||
queue,
|
queue: queue.into_iter().collect(),
|
||||||
cursor: 0,
|
|
||||||
current_tween: None,
|
current_tween: None,
|
||||||
speed,
|
speed,
|
||||||
}
|
}
|
||||||
@@ -87,8 +83,8 @@ impl TweenController {
|
|||||||
|
|
||||||
/// Append commands to the queue.
|
/// Append commands to the queue.
|
||||||
///
|
///
|
||||||
/// The cursor is **not** reset — commands already consumed remain consumed,
|
/// Consumed commands are removed as they execute, and new commands
|
||||||
/// and the new commands are picked up naturally as the cursor advances.
|
/// are queued at the back.
|
||||||
pub fn append_commands(&mut self, new_queue: CommandQueue) {
|
pub fn append_commands(&mut self, new_queue: CommandQueue) {
|
||||||
self.queue.extend(new_queue);
|
self.queue.extend(new_queue);
|
||||||
}
|
}
|
||||||
@@ -117,9 +113,8 @@ impl TweenController {
|
|||||||
Vec::new();
|
Vec::new();
|
||||||
let mut draw_call_count = 0;
|
let mut draw_call_count = 0;
|
||||||
|
|
||||||
// Advance cursor through the queue for each command consumed
|
// Consume commands from the front of the queue
|
||||||
while let Some(command) = self.queue.get(self.cursor).cloned() {
|
while let Some(command) = self.queue.pop_front() {
|
||||||
self.cursor += 1;
|
|
||||||
// Handle SetSpeed command to potentially switch modes
|
// Handle SetSpeed command to potentially switch modes
|
||||||
if let TurtleCommand::SetSpeed(new_speed) = &command {
|
if let TurtleCommand::SetSpeed(new_speed) = &command {
|
||||||
params.speed = *new_speed;
|
params.speed = *new_speed;
|
||||||
@@ -168,11 +163,12 @@ impl TweenController {
|
|||||||
|
|
||||||
// Process current tween
|
// Process current tween
|
||||||
if let Some(ref mut tween) = self.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
|
// Use tweeners to calculate current values
|
||||||
// For circles, calculate position along the arc instead of straight line
|
// 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 {
|
let current_position = match &tween.command {
|
||||||
TurtleCommand::Circle {
|
TurtleCommand::Circle {
|
||||||
@@ -185,7 +181,7 @@ impl TweenController {
|
|||||||
calculate_circle_position(
|
calculate_circle_position(
|
||||||
tween.start_params.position,
|
tween.start_params.position,
|
||||||
Radians::new(tween.start_params.heading),
|
Radians::new(tween.start_params.heading),
|
||||||
*radius,
|
radius.value(),
|
||||||
angle_traveled,
|
angle_traveled,
|
||||||
*direction,
|
*direction,
|
||||||
)
|
)
|
||||||
@@ -273,9 +269,7 @@ impl TweenController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start next tween
|
// Start next tween
|
||||||
if let Some(command) = self.queue.get(self.cursor).cloned() {
|
if let Some(command) = self.queue.pop_front() {
|
||||||
self.cursor += 1;
|
|
||||||
|
|
||||||
// Handle commands that should execute immediately (no animation)
|
// Handle commands that should execute immediately (no animation)
|
||||||
match &command {
|
match &command {
|
||||||
TurtleCommand::SetSpeed(new_speed) => {
|
TurtleCommand::SetSpeed(new_speed) => {
|
||||||
@@ -323,9 +317,9 @@ impl TweenController {
|
|||||||
);
|
);
|
||||||
|
|
||||||
self.current_tween = Some(CommandTween {
|
self.current_tween = Some(CommandTween {
|
||||||
turtle_id,
|
|
||||||
command,
|
command,
|
||||||
start_time: get_time(),
|
progress: 0.0,
|
||||||
|
start_time: current_time(),
|
||||||
duration,
|
duration,
|
||||||
start_params: params.clone(),
|
start_params: params.clone(),
|
||||||
target_params: target_state.clone(),
|
target_params: target_state.clone(),
|
||||||
@@ -342,7 +336,7 @@ impl TweenController {
|
|||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn is_complete(&self) -> bool {
|
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
|
/// Get the current active tween if one is in progress
|
||||||
@@ -400,3 +394,188 @@ pub(crate) fn normalize_angle(angle: f32) -> f32 {
|
|||||||
|
|
||||||
normalized
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user