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",
|
||||
"configurations": [
|
||||
{
|
||||
@@ -26,58 +23,57 @@
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"name": "Debug executable 'turtle-example'",
|
||||
"cargo": {
|
||||
"args": [
|
||||
"build",
|
||||
"--bin=turtle-example",
|
||||
"--package=turtle-example"
|
||||
],
|
||||
"filter": {
|
||||
"name": "turtle-example",
|
||||
"kind": "bin"
|
||||
}
|
||||
},
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"name": "Debug unit tests in executable 'turtle-example'",
|
||||
"cargo": {
|
||||
"args": [
|
||||
"test",
|
||||
"--no-run",
|
||||
"--bin=turtle-example",
|
||||
"--package=turtle-example"
|
||||
],
|
||||
"filter": {
|
||||
"name": "turtle-example",
|
||||
"kind": "bin"
|
||||
}
|
||||
},
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"name": "Debug unit tests in library 'turtle-ui'",
|
||||
"name": "Debug unit tests in 'turtle-lib-macros'",
|
||||
"cargo": {
|
||||
"args": [
|
||||
"test",
|
||||
"--no-run",
|
||||
"--lib",
|
||||
"--package=turtle-ui"
|
||||
"--package=turtle-lib-macros"
|
||||
],
|
||||
"filter": {
|
||||
"name": "turtle-ui",
|
||||
"name": "turtle-lib-macros",
|
||||
"kind": "lib"
|
||||
}
|
||||
},
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"name": "Debug example 'hello_turtle'",
|
||||
"cargo": {
|
||||
"args": [
|
||||
"build",
|
||||
"--example=hello_turtle",
|
||||
"--package=turtle-lib"
|
||||
],
|
||||
"filter": {
|
||||
"name": "hello_turtle",
|
||||
"kind": "example"
|
||||
}
|
||||
},
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"name": "Debug example 'breadboard'",
|
||||
"cargo": {
|
||||
"args": [
|
||||
"build",
|
||||
"--example=breadboard",
|
||||
"--package=turtle-lib"
|
||||
],
|
||||
"filter": {
|
||||
"name": "breadboard",
|
||||
"kind": "example"
|
||||
}
|
||||
},
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -8,21 +8,23 @@ Rust workspace with turtle graphics implementations. **Primary focus: `turtle-li
|
||||
```
|
||||
turtlers/
|
||||
├── turtle-lib/ # MAIN LIBRARY - Macroquad + Lyon (focus here)
|
||||
├── turtle-lib-macros/ # Proc macro for turtle_main
|
||||
└── examples/ # 15+ examples including threading patterns
|
||||
│ └── examples/ # 30 examples including threading patterns
|
||||
└── turtle-lib-macros/ # Proc macro for turtle_main
|
||||
```
|
||||
|
||||
## Architecture (`turtle-lib`)
|
||||
|
||||
### Core Design Pattern: Persistent Controllers + Command Queues
|
||||
### Core Design Pattern: Turtle Entities + Persistent Animation Controllers
|
||||
- **Builder API** (`TurtlePlan`) accumulates commands into immutable `CommandQueue`
|
||||
- **TurtleApp** maintains persistent `Vec<TweenController>` (one per turtle with embedded turtle_id)
|
||||
- **TweenController** manages command execution and animation state
|
||||
- **TurtleWorld** maintains persistent `Vec<Turtle>` (`world.turtles`), each encapsulating state and a `TweenController`
|
||||
- **TweenController** manages command execution, queue consumption, and animation interpolation per turtle
|
||||
- **Lyon Tessellation** converts all primitives to GPU meshes
|
||||
- **Multi-Turtle** support: Create multiple turtles with `add_turtle()` or threading channels
|
||||
|
||||
### Key Architectural Decision: Turtle ID Storage
|
||||
**Critical**: After recent refactoring, `turtle_id` is now **stored in TweenController** (not derived from Vec index). This makes rendering robust when turtles/controllers are sparse or deleted.
|
||||
### Key Architectural Decision: Turtle Entity Encapsulation
|
||||
Each turtle in `TurtleWorld` is represented by a `Turtle` struct (`state.rs`) storing its own `turtle_id`, `TurtleParams`, fill state, tessellated drawing commands, SVG log, and an embedded `TweenController`.
|
||||
- `turtle_id` is stored directly on `Turtle` (and passed to `TweenController::update` for logging and side effects); `TweenController` itself manages animation state without needing turtle identity.
|
||||
- Rendering (`drawing.rs`) iterates through `world.turtles` sequentially, directly accessing each turtle's `tween_controller.current_tween()`, `commands`, and `filling`.
|
||||
|
||||
### Key Files
|
||||
```
|
||||
@@ -31,7 +33,7 @@ src/
|
||||
├── builders.rs - Fluent API traits (forward/right/circle/reset/etc)
|
||||
├── commands.rs - TurtleCommand enum (Move/Turn/Circle/Reset/etc)
|
||||
├── execution.rs - Command execution (immediate) + state updates
|
||||
├── tweening.rs - Animation + tween interpolation (CommandTween embeds turtle_id)
|
||||
├── tweening.rs - Animation + tween interpolation (TweenController per Turtle)
|
||||
├── drawing.rs - Lyon mesh rendering with Macroquad
|
||||
├── state.rs - Turtle, TurtleParams, TurtleWorld (persistent state)
|
||||
├── tessellation.rs - Lyon integration (polygons/strokes/fills/arcs)
|
||||
@@ -54,15 +56,15 @@ src/
|
||||
- Example: Donut = outer circle (pen_down) → pen_up → inner circle → end_fill
|
||||
|
||||
**3. Animation Modes**:
|
||||
- Speed `>= 999`: Instant mode (no tweening, executes immediately)
|
||||
- Speed `< 999`: Animated mode (tweens with CubicInOut easing, ~duration based on distance/speed)
|
||||
- Speed `>= 1000`: Instant mode (no tweening, executes with `max(1, speed - 1000)` draw calls per frame)
|
||||
- Speed `< 1000`: Animated mode (tweens with CubicInOut easing, duration based on distance/speed)
|
||||
- Dynamic switching via `SetSpeed` command mid-animation
|
||||
|
||||
**4. Multi-Turtle Architecture**:
|
||||
- Each turtle owns a persistent `TweenController` with embedded `turtle_id`
|
||||
- Rendering finds active tween by checking `controller.current_tween().turtle_id` (not Vec index)
|
||||
- Each turtle in `world.turtles` owns its persistent `TweenController`
|
||||
- Rendering directly inspects each turtle's active tween via `turtle.tween_controller.current_tween()` during sequential traversal of `world.turtles`
|
||||
- Supports concurrent animation of multiple turtles
|
||||
- Example: Hangman uses `turtle_command_channel()` for blocking stdin on separate thread
|
||||
- Threading channels: `create_turtle_channel(buffer_size)` returns `TurtleCommandSender`, with the receiver managed internally by `TurtleApp`
|
||||
|
||||
**5. Threading Pattern** (for interactive apps like Hangman):
|
||||
- `create_turtle_channel(buffer_size)` returns `TurtleCommandSender` (clonable, Send)
|
||||
@@ -122,7 +124,7 @@ let tx = turtle_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
loop {
|
||||
let letter = get_input(); // Blocks
|
||||
let mut plan = create_turtle();
|
||||
let mut plan = create_turtle_plan();
|
||||
plan.forward(50.0);
|
||||
tx.send(plan.build()).ok();
|
||||
}
|
||||
@@ -191,11 +193,10 @@ RUST_LOG=turtle_lib=debug cargo run --example yinyang
|
||||
- Preserves `turtle_id` after reset
|
||||
- Called via `execute_command()` in both instant and animated modes
|
||||
|
||||
### Turtle ID Robustness
|
||||
- **Before**: `turtle_id` derived from Vec index (fragile if controllers deleted)
|
||||
- **After**: `turtle_id` embedded in `TweenController` and `CommandTween`
|
||||
- Rendering finds active tween via `find_map(|c| c.current_tween())` → uses `tween.turtle_id` directly
|
||||
- Safe for sparse/dynamic turtle creation
|
||||
### Turtle Entity and State Encapsulation
|
||||
- Each `Turtle` struct owns its `turtle_id`, visual `params`, `filling` state, tessellated `commands`, `svg_log`, and `tween_controller`
|
||||
- `TweenController` is responsible purely for command queue management and interpolation, decoupled from turtle identity
|
||||
- Rendering iterates sequentially over `world.turtles`, accessing each turtle's `commands`, active tween (`turtle.tween_controller.current_tween()`), and live fill preview in place
|
||||
|
||||
### Lyon Tessellation
|
||||
- All drawing → `tessellate_arc/stroke/circle/multi_contour` → `MeshData` → Macroquad `Mesh`
|
||||
@@ -208,13 +209,13 @@ RUST_LOG=turtle_lib=debug cargo run --example yinyang
|
||||
### Main Dependencies
|
||||
- `macroquad = "0.4"` - Window/rendering framework
|
||||
- `lyon = "1.0"` - Tessellation (fills, strokes, circles)
|
||||
- `tween = "2.1.0"` - Animation easing (CubicInOut)
|
||||
- `tween = "2.2.0"` - Animation easing (CubicInOut)
|
||||
- `tracing = "0.1"` - Optional logging (zero cost when unused)
|
||||
- `crossbeam-channel` - Threading pattern support (if used)
|
||||
- `crossbeam = "0.8"` - Threading pattern support (channels)
|
||||
|
||||
## What NOT to Do
|
||||
|
||||
- Don't derive `turtle_id` from Vec index for rendering (use embedded id)
|
||||
- Don't assume `TweenController` stores `turtle_id` or look up active tweens globally by ID (rendering iterates `world.turtles` and inspects `turtle.tween_controller` directly)
|
||||
- Don't add `use macroquad::prelude::*` without explicit need (causes unused imports)
|
||||
- Don't manually triangulate—always use Lyon `tessellate_*` functions
|
||||
- Don't separate Forward/Backward—use negative `Move` values
|
||||
@@ -6,7 +6,7 @@ A modern turtle graphics library for Rust built on [Macroquad](https://macroquad
|
||||
|
||||
- 🎨 **Simple Builder API**: Chain commands like `forward(100).right(90)`
|
||||
- ⚡ **Smooth Animations**: Tweening support with easing functions and live fill preview
|
||||
- 🚀 **Instant Mode**: Execute commands immediately without animation (speed ≥ 999)
|
||||
- 🚀 **Instant Mode**: Execute commands immediately without animation (speed ≥ 1000)
|
||||
- 🎯 **High-Quality Rendering**: Complete Lyon tessellation pipeline with GPU acceleration
|
||||
- **Multi-Contour Fills**: Automatic hole detection with EvenOdd fill rule - draw cheese with holes!
|
||||
- 📐 **Self-Intersecting Paths**: Stars, complex shapes - all handled correctly
|
||||
@@ -99,14 +99,14 @@ plan.begin_fill();
|
||||
plan.end_fill(); // Auto-closes and applies fill
|
||||
|
||||
// Appearance
|
||||
plan.set_color(RED);
|
||||
plan.set_pen_color(RED);
|
||||
plan.set_pen_width(5.0);
|
||||
plan.hide();
|
||||
plan.show();
|
||||
|
||||
// Speed control (dynamic)
|
||||
plan.set_speed(100); // Animated mode (< 999)
|
||||
plan.set_speed(1000); // Instant mode (>= 999)
|
||||
plan.set_speed(100); // Animated mode (< 1000)
|
||||
plan.set_speed(1000); // Instant mode (>= 1000)
|
||||
|
||||
// Turtle shapes
|
||||
plan.shape(ShapeType::Triangle);
|
||||
@@ -136,7 +136,7 @@ let mut plan = create_turtle_plan();
|
||||
// Fast initial positioning (instant mode)
|
||||
plan.set_speed(1000);
|
||||
plan.pen_up();
|
||||
plan.goto(vec2(-100.0, -100.0));
|
||||
plan.go_to(vec2(-100.0, -100.0));
|
||||
|
||||
// Slow animated drawing
|
||||
plan.set_speed(50);
|
||||
@@ -219,7 +219,7 @@ You can also export SVG programmatically from your code:
|
||||
use turtle_lib::*;
|
||||
|
||||
// Create your drawing
|
||||
let mut plan = create_turtle();
|
||||
let mut plan = create_turtle_plan();
|
||||
plan.forward(100).right(90).forward(100);
|
||||
|
||||
// Create app
|
||||
@@ -252,8 +252,8 @@ cargo run --example square
|
||||
cargo run --example koch
|
||||
cargo run --example shapes
|
||||
cargo run --example yinyang
|
||||
cargo run --example stern
|
||||
cargo run --example nikolaus
|
||||
cargo run --example star
|
||||
cargo run --example house_of_nikolaus
|
||||
|
||||
# SVG export example (requires --features svg)
|
||||
cargo run --example export_svg --features svg
|
||||
@@ -274,8 +274,8 @@ RUST_LOG=turtle_lib=debug cargo run --example logging_example
|
||||
- **square.rs**: Basic square drawing
|
||||
- **koch.rs**: Koch snowflake fractal
|
||||
- **shapes.rs**: Demonstrates different turtle shapes
|
||||
- **stern.rs**: Star pattern drawing
|
||||
- **nikolaus.rs**: Nikolaus (Santa) drawing
|
||||
- **star.rs**: Star pattern drawing
|
||||
- **house_of_nikolaus.rs**: House of Nikolaus (Eulerian path puzzle)
|
||||
|
||||
#### Fill Examples
|
||||
|
||||
@@ -297,7 +297,7 @@ RUST_LOG=turtle_lib=debug cargo run --example logging_example
|
||||
### Basic Fill
|
||||
|
||||
```rust
|
||||
let mut plan = create_turtle();
|
||||
let mut plan = create_turtle_plan();
|
||||
plan.set_fill_color(RED);
|
||||
plan.begin_fill();
|
||||
|
||||
@@ -321,7 +321,7 @@ plan.circle_left(90.0, 360.0, 72);
|
||||
|
||||
// pen_up() closes current contour
|
||||
plan.pen_up();
|
||||
plan.goto(vec2(0.0, -30.0));
|
||||
plan.go_to(vec2(0.0, -30.0));
|
||||
|
||||
// pen_down() starts new contour
|
||||
plan.pen_down();
|
||||
@@ -341,7 +341,7 @@ turtle-lib/src/
|
||||
├── lib.rs - Public API and TurtleApp
|
||||
├── state.rs - TurtleState and TurtleWorld
|
||||
├── commands.rs - TurtleCommand enum (consolidated commands)
|
||||
├── builders.rs - Builder traits (DirectionalMovement, Turnable, etc.)
|
||||
├── builders.rs - Builder traits (Movement, Rotation, Pen, Fill, Cursor, Text)
|
||||
├── execution.rs - Command execution with fill support
|
||||
├── tweening.rs - Animation/tweening controller with dynamic speed
|
||||
├── drawing.rs - Rendering with Lyon tessellation
|
||||
@@ -378,6 +378,16 @@ cargo build --release
|
||||
cargo build --features svg
|
||||
```
|
||||
|
||||
### Optional: Faster Linker Setup
|
||||
|
||||
For significantly faster incremental build and linking times during development on Linux, you can optionally configure `mold` or `lld` in your user-level Cargo configuration (`~/.cargo/config.toml`):
|
||||
|
||||
```toml
|
||||
[target.x86_64-unknown-linux-gnu]
|
||||
linker = "clang"
|
||||
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
|
||||
```
|
||||
|
||||
## Development Status
|
||||
|
||||
### ✅ Completed
|
||||
|
||||
@@ -10,4 +10,4 @@ proc-macro = true
|
||||
[dependencies]
|
||||
proc-macro2 = "1.0"
|
||||
quote = "1.0"
|
||||
syn = { version = "2.0", features = ["full"] }
|
||||
syn = { version = "3.0", features = ["full"] }
|
||||
|
||||
+448
-87
@@ -6,7 +6,7 @@
|
||||
|
||||
use proc_macro::TokenStream;
|
||||
use quote::quote;
|
||||
use syn::{parse_macro_input, ItemFn};
|
||||
use syn::ItemFn;
|
||||
|
||||
/// A convenience macro that wraps your turtle drawing code with the necessary
|
||||
/// boilerplate for running a turtle graphics program.
|
||||
@@ -63,44 +63,145 @@ use syn::{parse_macro_input, ItemFn};
|
||||
/// This expands to approximately:
|
||||
///
|
||||
/// ```ignore
|
||||
/// use macroquad::prelude::*;
|
||||
/// use turtle_lib::*;
|
||||
///
|
||||
/// #[macroquad::main("My Turtle Drawing")]
|
||||
/// async fn main() {
|
||||
/// // Parse CLI args for --export-svg flag
|
||||
/// let args: Vec<String> = std::env::args().collect();
|
||||
/// // ... (argument parsing logic)
|
||||
/// fn main() {
|
||||
/// // Handle optional SVG export headlessly without opening a window
|
||||
/// if let Some(filename) = turtle_lib::export::parse_svg_export_arg() {
|
||||
/// let mut build_commands = |turtle: &mut turtle_lib::TurtlePlan| {
|
||||
/// my_drawing(turtle);
|
||||
/// };
|
||||
/// if let Err(e) = turtle_lib::export::run_headless_svg_export(&mut build_commands, &filename) {
|
||||
/// eprintln!("Error exporting SVG: {:?}", e);
|
||||
/// std::process::exit(1);
|
||||
/// }
|
||||
/// return;
|
||||
/// }
|
||||
///
|
||||
/// // Normal interactive GUI mode with window
|
||||
/// turtle_lib::macroquad::Window::new("My Turtle Drawing", async {
|
||||
/// let mut turtle = create_turtle_plan();
|
||||
///
|
||||
/// // Your drawing code here
|
||||
/// turtle.set_pen_color(RED);
|
||||
/// turtle.forward(100.0);
|
||||
/// turtle.right(90.0);
|
||||
/// turtle.forward(100.0);
|
||||
/// my_drawing(&mut turtle);
|
||||
///
|
||||
/// let mut app = TurtleApp::new().with_commands(turtle.build());
|
||||
///
|
||||
/// // If --export-svg flag is present, export and exit
|
||||
/// // Otherwise, enter normal rendering loop
|
||||
/// loop {
|
||||
/// clear_background(WHITE);
|
||||
/// turtle_lib::macroquad::prelude::clear_background(turtle_lib::macroquad::prelude::WHITE);
|
||||
/// app.update();
|
||||
/// app.render();
|
||||
/// draw_text("Press ESC or Q to quit", 10.0, 40.0, 16.0, DARKGRAY);
|
||||
/// turtle_lib::macroquad::prelude::draw_text("Press ESC or Q to quit", 10.0, 40.0, 16.0, turtle_lib::macroquad::prelude::DARKGRAY);
|
||||
///
|
||||
/// if is_key_pressed(KeyCode::Escape) || is_key_pressed(KeyCode::Q) {
|
||||
/// if turtle_lib::macroquad::prelude::is_key_pressed(turtle_lib::macroquad::prelude::KeyCode::Escape)
|
||||
/// || turtle_lib::macroquad::prelude::is_key_pressed(turtle_lib::macroquad::prelude::KeyCode::Q)
|
||||
/// {
|
||||
/// break;
|
||||
/// }
|
||||
///
|
||||
/// next_frame().await;
|
||||
/// turtle_lib::macroquad::prelude::next_frame().await;
|
||||
/// }
|
||||
/// });
|
||||
/// }
|
||||
/// ```
|
||||
fn validate_parameter_type(ty: &syn::Type) -> Result<(), syn::Error> {
|
||||
match ty {
|
||||
syn::Type::Reference(type_ref) => {
|
||||
if type_ref.mutability.is_none() {
|
||||
return Err(syn::Error::new_spanned(
|
||||
type_ref,
|
||||
"#[turtle_main] parameter must be a mutable reference: `&mut TurtlePlan`",
|
||||
));
|
||||
}
|
||||
|
||||
if let syn::Type::Path(type_path) = &*type_ref.elem {
|
||||
let is_turtle_plan = type_path
|
||||
.path
|
||||
.segments
|
||||
.last()
|
||||
.is_some_and(|seg| seg.ident == "TurtlePlan");
|
||||
|
||||
if is_turtle_plan {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
Err(syn::Error::new_spanned(
|
||||
&type_ref.elem,
|
||||
"#[turtle_main] expected reference to `TurtlePlan`, e.g. `&mut TurtlePlan`",
|
||||
))
|
||||
}
|
||||
_ => Err(syn::Error::new_spanned(
|
||||
ty,
|
||||
"#[turtle_main] parameter must be of type `&mut TurtlePlan`",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_input(input_fn: &ItemFn) -> Result<(), syn::Error> {
|
||||
if input_fn.sig.asyncness.is_some() {
|
||||
return Err(syn::Error::new_spanned(
|
||||
input_fn.sig.fn_token,
|
||||
"#[turtle_main] functions cannot be async",
|
||||
));
|
||||
}
|
||||
|
||||
if input_fn.sig.inputs.len() > 1 {
|
||||
return Err(syn::Error::new_spanned(
|
||||
&input_fn.sig.inputs,
|
||||
"#[turtle_main] functions must take either 0 arguments or a single `&mut TurtlePlan`",
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(arg) = input_fn.sig.inputs.first() {
|
||||
match arg {
|
||||
syn::FnArg::Receiver(receiver) => {
|
||||
return Err(syn::Error::new_spanned(
|
||||
receiver,
|
||||
"#[turtle_main] functions cannot take a `self` parameter",
|
||||
));
|
||||
}
|
||||
syn::FnArg::Typed(pat_type) => {
|
||||
match &*pat_type.pat {
|
||||
syn::Pat::Ident(pat_ident)
|
||||
if pat_ident.by_ref.is_none() && pat_ident.subpat.is_none() => {}
|
||||
syn::Pat::Wild(_) => {}
|
||||
_ => {
|
||||
return Err(syn::Error::new_spanned(
|
||||
&pat_type.pat,
|
||||
"#[turtle_main] unsupported parameter pattern; expected an identifier like `turtle` or `t`",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
validate_parameter_type(&pat_type.ty)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if matches!(input_fn.sig.output, syn::ReturnType::Type(..)) {
|
||||
return Err(syn::Error::new_spanned(
|
||||
&input_fn.sig.output,
|
||||
"#[turtle_main] functions cannot have a return type",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[proc_macro_attribute]
|
||||
pub fn turtle_main(args: TokenStream, input: TokenStream) -> TokenStream {
|
||||
let input_fn = parse_macro_input!(input as ItemFn);
|
||||
turtle_main_impl(&args.into(), input.into())
|
||||
.unwrap_or_else(|err| err.to_compile_error())
|
||||
.into()
|
||||
}
|
||||
|
||||
fn turtle_main_impl(
|
||||
args: &proc_macro2::TokenStream,
|
||||
input: proc_macro2::TokenStream,
|
||||
) -> Result<proc_macro2::TokenStream, syn::Error> {
|
||||
let input_fn: ItemFn = syn::parse2(input)?;
|
||||
|
||||
// Validate function signature
|
||||
validate_input(&input_fn)?;
|
||||
|
||||
// Parse the window title from args (default to "Turtle Graphics")
|
||||
let window_title = if args.is_empty() {
|
||||
@@ -114,105 +215,365 @@ pub fn turtle_main(args: TokenStream, input: TokenStream) -> TokenStream {
|
||||
|
||||
let fn_name = &input_fn.sig.ident;
|
||||
let fn_block = &input_fn.block;
|
||||
|
||||
// Check if the function has the expected signature
|
||||
let fn_attrs = &input_fn.attrs;
|
||||
let fn_vis = if fn_name == "main" {
|
||||
None
|
||||
} else {
|
||||
Some(&input_fn.vis)
|
||||
};
|
||||
let has_turtle_param = input_fn.sig.inputs.len() == 1;
|
||||
|
||||
// Note: The following code has some duplication between the two branches
|
||||
// (with/without turtle parameter). This is intentional in proc macros as
|
||||
// we're generating different code paths, and extracting the common parts
|
||||
// into helper functions would make the macro more complex without significant benefit.
|
||||
|
||||
let expanded = if has_turtle_param {
|
||||
// Function takes a turtle parameter
|
||||
quote! {
|
||||
#[macroquad::main(#window_title)]
|
||||
async fn main() {
|
||||
// Build function reused for both export and normal rendering
|
||||
let mut build_commands = |turtle: &mut turtle_lib::TurtlePlan| {
|
||||
#fn_name(turtle);
|
||||
let helper_name = if fn_name == "main" {
|
||||
quote::format_ident!("__turtle_main_draw")
|
||||
} else {
|
||||
fn_name.clone()
|
||||
};
|
||||
|
||||
// Handle optional SVG export internally in turtle-lib
|
||||
turtle_lib::export::handle_svg_export(&mut build_commands);
|
||||
|
||||
// Normal rendering mode (with window)
|
||||
let mut turtle = turtle_lib::create_turtle_plan();
|
||||
|
||||
// Call the user's function with the turtle
|
||||
build_commands(&mut turtle);
|
||||
|
||||
let mut app = turtle_lib::TurtleApp::new()
|
||||
.with_commands(turtle.build());
|
||||
|
||||
loop {
|
||||
macroquad::prelude::clear_background(macroquad::prelude::WHITE);
|
||||
app.update();
|
||||
app.render();
|
||||
macroquad::prelude::draw_text(
|
||||
"Press ESC or Q to quit",
|
||||
10.0,
|
||||
40.0,
|
||||
16.0,
|
||||
macroquad::prelude::DARKGRAY
|
||||
);
|
||||
|
||||
if macroquad::prelude::is_key_pressed(macroquad::prelude::KeyCode::Escape)
|
||||
|| macroquad::prelude::is_key_pressed(macroquad::prelude::KeyCode::Q)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
macroquad::prelude::next_frame().await;
|
||||
}
|
||||
}
|
||||
|
||||
fn #fn_name(turtle: &mut turtle_lib::TurtlePlan) #fn_block
|
||||
let helper_fn = if has_turtle_param {
|
||||
let param = &input_fn.sig.inputs[0];
|
||||
quote! {
|
||||
#(#fn_attrs)*
|
||||
#fn_vis fn #helper_name(#param) #fn_block
|
||||
}
|
||||
} else {
|
||||
// Function takes no parameters - inline the code
|
||||
quote! {
|
||||
#[macroquad::main(#window_title)]
|
||||
async fn main() {
|
||||
// Build function reused for both export and normal rendering
|
||||
let mut build_commands = |turtle: &mut turtle_lib::TurtlePlan| {
|
||||
#(#fn_attrs)*
|
||||
#fn_vis fn #helper_name(turtle: &mut turtle_lib::TurtlePlan) {
|
||||
let turtle = turtle;
|
||||
#fn_block
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle optional SVG export internally in turtle-lib
|
||||
turtle_lib::export::handle_svg_export(&mut build_commands);
|
||||
let expanded = quote! {
|
||||
fn main() {
|
||||
let mut build_commands = |turtle: &mut turtle_lib::TurtlePlan| {
|
||||
#helper_name(turtle);
|
||||
};
|
||||
|
||||
// Normal rendering mode (with window)
|
||||
// If --export-svg flag is present, export headlessly without opening a window
|
||||
if let Some(filename) = turtle_lib::export::parse_svg_export_arg() {
|
||||
match turtle_lib::export::run_headless_svg_export(&mut build_commands, &filename) {
|
||||
Ok(()) => {
|
||||
println!("SVG exported successfully to: {}", filename);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error exporting SVG: {:?}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Normal rendering mode (interactive window)
|
||||
turtle_lib::macroquad::Window::new(#window_title, async {
|
||||
let mut turtle = turtle_lib::create_turtle_plan();
|
||||
build_commands(&mut turtle);
|
||||
#helper_name(&mut turtle);
|
||||
|
||||
let mut app = turtle_lib::TurtleApp::new()
|
||||
.with_commands(turtle.build());
|
||||
|
||||
loop {
|
||||
macroquad::prelude::clear_background(macroquad::prelude::WHITE);
|
||||
turtle_lib::macroquad::prelude::clear_background(turtle_lib::macroquad::prelude::WHITE);
|
||||
app.update();
|
||||
app.render();
|
||||
macroquad::prelude::draw_text(
|
||||
turtle_lib::macroquad::prelude::draw_text(
|
||||
"Press ESC or Q to quit",
|
||||
10.0,
|
||||
40.0,
|
||||
16.0,
|
||||
macroquad::prelude::DARKGRAY
|
||||
turtle_lib::macroquad::prelude::DARKGRAY
|
||||
);
|
||||
|
||||
if macroquad::prelude::is_key_pressed(macroquad::prelude::KeyCode::Escape)
|
||||
|| macroquad::prelude::is_key_pressed(macroquad::prelude::KeyCode::Q)
|
||||
if turtle_lib::macroquad::prelude::is_key_pressed(turtle_lib::macroquad::prelude::KeyCode::Escape)
|
||||
|| turtle_lib::macroquad::prelude::is_key_pressed(turtle_lib::macroquad::prelude::KeyCode::Q)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
macroquad::prelude::next_frame().await;
|
||||
}
|
||||
turtle_lib::macroquad::prelude::next_frame().await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#helper_fn
|
||||
};
|
||||
|
||||
TokenStream::from(expanded)
|
||||
Ok(expanded)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use syn::parse_quote;
|
||||
|
||||
#[test]
|
||||
fn test_valid_zero_args() {
|
||||
let input: ItemFn = parse_quote! {
|
||||
fn my_draw() {
|
||||
turtle.forward(100.0);
|
||||
}
|
||||
};
|
||||
assert!(validate_input(&input).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_one_arg() {
|
||||
let input: ItemFn = parse_quote! {
|
||||
fn my_draw(t: &mut TurtlePlan) {
|
||||
t.forward(100.0);
|
||||
}
|
||||
};
|
||||
assert!(validate_input(&input).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_mut_arg() {
|
||||
let input: ItemFn = parse_quote! {
|
||||
fn my_draw(mut t: &mut TurtlePlan) {
|
||||
t.forward(100.0);
|
||||
}
|
||||
};
|
||||
assert!(validate_input(&input).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_wildcard_arg() {
|
||||
let input: ItemFn = parse_quote! {
|
||||
fn my_draw(_: &mut TurtlePlan) {}
|
||||
};
|
||||
assert!(validate_input(&input).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rejects_async() {
|
||||
let input: ItemFn = parse_quote! {
|
||||
async fn my_draw() {}
|
||||
};
|
||||
let err = validate_input(&input).unwrap_err();
|
||||
assert!(err.to_string().contains("cannot be async"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rejects_multiple_args() {
|
||||
let input: ItemFn = parse_quote! {
|
||||
fn my_draw(t: &mut TurtlePlan, extra: i32) {}
|
||||
};
|
||||
let err = validate_input(&input).unwrap_err();
|
||||
assert!(err.to_string().contains("must take either 0 arguments or a single"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rejects_self() {
|
||||
let input: ItemFn = parse_quote! {
|
||||
fn my_draw(&mut self) {}
|
||||
};
|
||||
let err = validate_input(&input).unwrap_err();
|
||||
assert!(err.to_string().contains("cannot take a `self` parameter"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rejects_unsupported_pattern() {
|
||||
let input: ItemFn = parse_quote! {
|
||||
fn my_draw((a, b): &mut TurtlePlan) {}
|
||||
};
|
||||
let err = validate_input(&input).unwrap_err();
|
||||
assert!(err.to_string().contains("unsupported parameter pattern"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rejects_return_type() {
|
||||
let input: ItemFn = parse_quote! {
|
||||
fn my_draw() -> i32 {
|
||||
42
|
||||
}
|
||||
};
|
||||
let err = validate_input(&input).unwrap_err();
|
||||
assert!(err.to_string().contains("cannot have a return type"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_qualified_type() {
|
||||
let input: ItemFn = parse_quote! {
|
||||
fn my_draw(t: &mut turtle_lib::TurtlePlan) {}
|
||||
};
|
||||
assert!(validate_input(&input).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rejects_wrong_type() {
|
||||
let input: ItemFn = parse_quote! {
|
||||
fn my_draw(value: i32) {}
|
||||
};
|
||||
let err = validate_input(&input).unwrap_err();
|
||||
assert!(err.to_string().contains("parameter must be of type `&mut TurtlePlan`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rejects_immutable_reference() {
|
||||
let input: ItemFn = parse_quote! {
|
||||
fn my_draw(t: &TurtlePlan) {}
|
||||
};
|
||||
let err = validate_input(&input).unwrap_err();
|
||||
assert!(err.to_string().contains("parameter must be a mutable reference: `&mut TurtlePlan`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rejects_owned_type() {
|
||||
let input: ItemFn = parse_quote! {
|
||||
fn my_draw(t: TurtlePlan) {}
|
||||
};
|
||||
let err = validate_input(&input).unwrap_err();
|
||||
assert!(err.to_string().contains("parameter must be of type `&mut TurtlePlan`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rejects_wrong_reference_type() {
|
||||
let input: ItemFn = parse_quote! {
|
||||
fn my_draw(t: &mut i32) {}
|
||||
};
|
||||
let err = validate_input(&input).unwrap_err();
|
||||
assert!(err.to_string().contains("expected reference to `TurtlePlan`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expansion_preserves_custom_param_name() {
|
||||
let input = quote! {
|
||||
fn my_draw(t: &mut TurtlePlan) {
|
||||
t.forward(100.0);
|
||||
}
|
||||
};
|
||||
let output = turtle_main_impl("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]
|
||||
# For examples and testing
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
dialog = "*"
|
||||
dialog = "0.3"
|
||||
chrono = "0.4"
|
||||
|
||||
[features]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Cubic Bézier curve example
|
||||
//! <https://en.wikipedia.org/wiki/B%C3%A9zier_curve>
|
||||
|
||||
use turtle_lib::{turtle_main, vec2};
|
||||
use turtle_lib::*;
|
||||
|
||||
struct CubicBezier {
|
||||
point0: (f32, f32),
|
||||
|
||||
@@ -1,46 +1,10 @@
|
||||
//! Breadboard circuit diagram example.
|
||||
//!
|
||||
//! Demonstrates structured procedural drawing of an electronics solderless breadboard.
|
||||
//! Run normally to display on screen, or export to SVG with:
|
||||
//! `cargo run --package turtle-lib --example breadboard --features svg -- --export-svg breadboard.svg`
|
||||
|
||||
use turtle_lib::*;
|
||||
#[cfg(feature = "svg")]
|
||||
#[macroquad::main("Export SVG")]
|
||||
|
||||
async fn main() {
|
||||
// Create turtle plan
|
||||
let mut turtle = create_turtle_plan();
|
||||
|
||||
// Set instant mode so commands execute imqmediately
|
||||
turtle.set_speed(1200).set_pen_width(0.5);
|
||||
|
||||
breadboard(&mut turtle, 65);
|
||||
|
||||
turtle.hide();
|
||||
let mut app = TurtleApp::new().with_commands(turtle.build());
|
||||
use macroquad::{
|
||||
input::{is_key_pressed, KeyCode},
|
||||
text::draw_text,
|
||||
window::{clear_background, next_frame},
|
||||
};
|
||||
|
||||
loop {
|
||||
clear_background(WHITE);
|
||||
app.update();
|
||||
app.render();
|
||||
|
||||
draw_text("Drücke E für SVG-Export", 20.0, 40.0, 32.0, BLACK);
|
||||
|
||||
if is_key_pressed(KeyCode::E) {
|
||||
match app.export_drawing("test.svg", export::DrawingFormat::Svg) {
|
||||
Ok(_) => println!("SVG exportiert nach test.svg"),
|
||||
Err(e) => println!("Fehler beim Export: {:?}", e),
|
||||
}
|
||||
}
|
||||
|
||||
next_frame().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "svg"))]
|
||||
fn main() {
|
||||
println!("SVG-Export ist nicht aktiviert. Baue mit --features svg");
|
||||
}
|
||||
|
||||
fn pin(t: &mut TurtlePlan, size: f32) {
|
||||
t.left(90.0).forward(size / 2.0);
|
||||
@@ -50,53 +14,60 @@ fn pin(t: &mut TurtlePlan, size: f32) {
|
||||
t.right(90.0).forward(size / 2.0).left(90.0);
|
||||
}
|
||||
|
||||
fn pin_reihe(t: &mut TurtlePlan, anzahl: usize) {
|
||||
for x in 0..anzahl {
|
||||
fn pin_row(t: &mut TurtlePlan, count: usize) {
|
||||
for x in 0..count {
|
||||
pin(t, 5.0);
|
||||
if x < anzahl - 1 {
|
||||
if x < count - 1 {
|
||||
t.forward(5.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pin_spalte(t: &mut TurtlePlan, anzahl: usize, x_coord: f32) {
|
||||
for x in 0..anzahl {
|
||||
fn pin_column(t: &mut TurtlePlan, count: usize, x_coord: f32) {
|
||||
for x in 0..count {
|
||||
t.pen_up().go_to(vec2(x_coord, x as f32 * 10.0)).pen_down();
|
||||
pin_reihe(t, 5);
|
||||
pin_row(t, 5);
|
||||
}
|
||||
}
|
||||
|
||||
fn pin_seite(t: &mut TurtlePlan, anzahl: usize, x_coord: f32, color: Color) {
|
||||
fn pin_side(t: &mut TurtlePlan, count: usize, x_coord: f32, color: Color) {
|
||||
t.pen_up()
|
||||
.go_to(vec2(x_coord, -2.5))
|
||||
.pen_down()
|
||||
.set_pen_color(color)
|
||||
.set_heading(90.0);
|
||||
for x in 0..anzahl {
|
||||
for x in 0..count {
|
||||
pin(t, 5.0);
|
||||
if x < anzahl - 1 {
|
||||
if x < count - 1 {
|
||||
t.forward(5.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn breadboard(t: &mut TurtlePlan, anzahl_reihen: usize) {
|
||||
pin_spalte(t, anzahl_reihen, 0.0);
|
||||
pin_spalte(t, anzahl_reihen, 65.0);
|
||||
pin_seite(t, anzahl_reihen, -15.0, BLUE);
|
||||
pin_seite(t, anzahl_reihen, -25.0, RED);
|
||||
pin_seite(t, anzahl_reihen, 125.0, BLUE);
|
||||
pin_seite(t, anzahl_reihen, 135.0, RED);
|
||||
fn draw_breadboard(t: &mut TurtlePlan, row_count: usize) {
|
||||
pin_column(t, row_count, 0.0);
|
||||
pin_column(t, row_count, 65.0);
|
||||
pin_side(t, row_count, -15.0, BLUE);
|
||||
pin_side(t, row_count, -25.0, RED);
|
||||
pin_side(t, row_count, 125.0, BLUE);
|
||||
pin_side(t, row_count, 135.0, RED);
|
||||
|
||||
// draw outline
|
||||
t.pen_up().go_to(vec2(-30.0, -5.0)).pen_down();
|
||||
t.set_pen_color(BLACK)
|
||||
.forward(anzahl_reihen as f32 * 10.0 + 10.0)
|
||||
.forward(row_count as f32 * 10.0 + 10.0)
|
||||
.right(90.0)
|
||||
.forward(170.0)
|
||||
.right(90.0)
|
||||
.forward(anzahl_reihen as f32 * 10.0 + 10.0)
|
||||
.forward(row_count as f32 * 10.0 + 10.0)
|
||||
.right(90.0)
|
||||
.forward(170.0)
|
||||
.right(90.0);
|
||||
}
|
||||
|
||||
#[turtle_main("Breadboard")]
|
||||
fn main(turtle: &mut TurtlePlan) {
|
||||
turtle.set_speed(1200).set_pen_width(0.5);
|
||||
draw_breadboard(turtle, 65);
|
||||
turtle.hide();
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ fn draw(turtle: &mut TurtlePlan) {
|
||||
turtle.set_pen_color(RED);
|
||||
turtle.set_pen_width(0.5);
|
||||
turtle.left(90.0);
|
||||
turtle.set_speed(999);
|
||||
turtle.set_speed(1000);
|
||||
turtle.circle_left(100.0, 540.0, 72); // partial circle to the left
|
||||
|
||||
turtle.begin_fill();
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
use chrono::{Local, Timelike};
|
||||
use macroquad::prelude::{clear_background, is_key_pressed, next_frame, KeyCode, WHITE};
|
||||
use turtle_lib::{create_turtle_plan, vec2, DirectionalMovement, Turnable, TurtleApp};
|
||||
use turtle_lib::*;
|
||||
|
||||
#[macroquad::main("Clock")]
|
||||
async fn main() {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
use chrono::{Local, Timelike};
|
||||
use macroquad::prelude::{clear_background, is_key_pressed, next_frame, KeyCode, WHITE};
|
||||
use turtle_lib::{create_turtle_plan, vec2, DirectionalMovement, Turnable, TurtleApp};
|
||||
use turtle_lib::*;
|
||||
|
||||
#[macroquad::main("Clock (Threaded)")]
|
||||
async fn main() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Dashed circle example ported from sunjay/turtle
|
||||
//! This draws a dashed circle but uses `circle_left` arcs for each segment instead of individual short lines.
|
||||
|
||||
use turtle_lib::{turtle_main, vec2, CurvedMovement, Turnable};
|
||||
use turtle_lib::*;
|
||||
|
||||
#[turtle_main("Dashed Circle")]
|
||||
fn draw(turtle: &mut TurtlePlan) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Beispiel: Exportiere ein SVG aus einer einfachen Zeichnung
|
||||
//! Example: Export an SVG from a simple drawing
|
||||
|
||||
#[cfg(feature = "svg")]
|
||||
use turtle_lib::*;
|
||||
@@ -52,12 +52,12 @@ async fn main() {
|
||||
app.update();
|
||||
app.render();
|
||||
|
||||
draw_text("Drücke E für SVG-Export", 20.0, 40.0, 32.0, BLACK);
|
||||
draw_text("Press E for SVG export", 20.0, 40.0, 32.0, BLACK);
|
||||
|
||||
if is_key_pressed(KeyCode::E) {
|
||||
match app.export_drawing("test.svg", export::DrawingFormat::Svg) {
|
||||
Ok(_) => println!("SVG exportiert nach test.svg"),
|
||||
Err(e) => println!("Fehler beim Export: {:?}", e),
|
||||
Ok(_) => println!("SVG exported to test.svg"),
|
||||
Err(e) => eprintln!("Export error: {:?}", e),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,5 +67,5 @@ async fn main() {
|
||||
|
||||
#[cfg(not(feature = "svg"))]
|
||||
fn main() {
|
||||
println!("SVG-Export ist nicht aktiviert. Baue mit --features svg");
|
||||
println!("SVG export is not enabled. Build with --features svg");
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -31,8 +31,8 @@ impl TurtleCommand {
|
||||
pub(crate) fn apply_to_params(&self, params: &mut TurtleParams) {
|
||||
match self {
|
||||
TurtleCommand::Move(dist) => {
|
||||
let dx = dist * params.heading.cos();
|
||||
let dy = dist * params.heading.sin();
|
||||
let dx = dist.value() * params.heading.cos();
|
||||
let dy = dist.value() * params.heading.sin();
|
||||
params.position = vec2(params.position.x + dx, params.position.y + dy);
|
||||
}
|
||||
TurtleCommand::Turn(angle) => {
|
||||
@@ -47,7 +47,7 @@ impl TurtleCommand {
|
||||
let geom = CircleGeometry::new(
|
||||
params.position,
|
||||
Radians::new(params.heading),
|
||||
*radius,
|
||||
radius.value(),
|
||||
*direction,
|
||||
);
|
||||
let angle_rad = angle.as_radians().value();
|
||||
@@ -62,7 +62,7 @@ impl TurtleCommand {
|
||||
params.position = vec2(coord.x, -coord.y);
|
||||
}
|
||||
TurtleCommand::SetHeading(heading) => {
|
||||
params.heading = normalize_angle(heading.value());
|
||||
params.heading = normalize_angle(-heading.as_radians().value());
|
||||
}
|
||||
TurtleCommand::SetColor(color) => {
|
||||
params.color = *color;
|
||||
@@ -119,15 +119,16 @@ impl TurtleCommand {
|
||||
}
|
||||
|
||||
let base: f32 = match self {
|
||||
TurtleCommand::Move(dist) => dist.abs() / spd,
|
||||
TurtleCommand::Move(dist) => dist.value().abs() / spd,
|
||||
TurtleCommand::Turn(angle) => angle.value().abs() / (spd * 1.8),
|
||||
TurtleCommand::Circle { radius, angle, .. } => {
|
||||
let arc_length = radius * angle.as_radians().value().abs();
|
||||
let arc_length = radius.value() * angle.as_radians().value().abs();
|
||||
arc_length / spd
|
||||
}
|
||||
TurtleCommand::Goto(target) => {
|
||||
let dx = target.x - params.position.x;
|
||||
let dy = target.y - params.position.y;
|
||||
let screen_target = vec2(target.x, -target.y);
|
||||
let dx = screen_target.x - params.position.x;
|
||||
let dy = screen_target.y - params.position.y;
|
||||
(dx * dx + dy * dy).sqrt() / spd
|
||||
}
|
||||
_ => 0.0,
|
||||
@@ -148,3 +149,77 @@ impl TurtleCommand {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::general::{AnimationSpeed, Color, Coordinate, Degrees};
|
||||
use crate::shapes::TurtleShape;
|
||||
|
||||
fn make_test_params() -> TurtleParams {
|
||||
TurtleParams {
|
||||
position: vec2(0.0, 0.0),
|
||||
heading: 0.0,
|
||||
pen_down: true,
|
||||
pen_width: 1.0,
|
||||
color: Color::new(0.0, 0.0, 0.0, 1.0),
|
||||
fill_color: None,
|
||||
visible: true,
|
||||
shape: TurtleShape::turtle(),
|
||||
speed: AnimationSpeed::Animated(100.0),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_goto_duration_cartesian_inversion() {
|
||||
let mut params = make_test_params();
|
||||
// Set screen position to (0, 100), which corresponds to Cartesian (0, -100)
|
||||
params.position = vec2(0.0, 100.0);
|
||||
|
||||
// Move to Cartesian (0, 100), which in screen space is (0, -100)
|
||||
// Distance should be 200 pixels!
|
||||
let cmd = TurtleCommand::Goto(Coordinate::new(0.0, 100.0));
|
||||
let duration = cmd.animation_duration(¶ms, AnimationSpeed::Animated(100.0));
|
||||
|
||||
// At 100 px/sec across 200 pixels, duration should be 2.0 seconds
|
||||
assert!(
|
||||
(duration - 2.0).abs() < 0.01,
|
||||
"Expected duration ~2.0s for 200px move, got {duration}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_heading_degrees_and_instant_duration() {
|
||||
let mut params = make_test_params();
|
||||
|
||||
// 90° = North (in screen coordinates: -pi/2)
|
||||
let cmd_north = TurtleCommand::SetHeading(Degrees::new(90.0));
|
||||
cmd_north.apply_to_params(&mut params);
|
||||
let expected_north = -std::f32::consts::FRAC_PI_2;
|
||||
assert!(
|
||||
(params.heading - expected_north).abs() < 0.001,
|
||||
"Heading 90° should be North (-π/2), got {}",
|
||||
params.heading
|
||||
);
|
||||
|
||||
// SetHeading should be instant (0.01 minimum duration)
|
||||
let duration = cmd_north.animation_duration(¶ms, AnimationSpeed::Animated(100.0));
|
||||
assert!((duration - 0.01).abs() < 0.001);
|
||||
|
||||
// 0° = East (0 radians)
|
||||
let cmd_east = TurtleCommand::SetHeading(Degrees::new(0.0));
|
||||
cmd_east.apply_to_params(&mut params);
|
||||
assert!((params.heading - 0.0).abs() < 0.001);
|
||||
|
||||
// 270° = South (+pi/2 radians in screen coords)
|
||||
let cmd_south = TurtleCommand::SetHeading(Degrees::new(270.0));
|
||||
cmd_south.apply_to_params(&mut params);
|
||||
let expected_south = std::f32::consts::FRAC_PI_2;
|
||||
assert!(
|
||||
(params.heading - expected_south).abs() < 0.001,
|
||||
"Heading 270° should be South (π/2), got {}",
|
||||
params.heading
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
//! Turtle commands and command queue
|
||||
|
||||
use crate::general::{AnimationSpeed, Color, Coordinate, Degrees, FontSize, Precision, Radians};
|
||||
use crate::general::{AnimationSpeed, Color, Coordinate, Degrees, FontSize, Length, Precision};
|
||||
use crate::shapes::TurtleShape;
|
||||
|
||||
/// Individual turtle commands
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum TurtleCommand {
|
||||
// Movement (positive = forward, negative = backward)
|
||||
Move(Precision),
|
||||
Move(Length),
|
||||
|
||||
// Rotation (positive = right/clockwise, negative = left/counter-clockwise)
|
||||
// Stored in degrees — the natural unit at the user-facing API boundary.
|
||||
@@ -15,7 +15,7 @@ pub enum TurtleCommand {
|
||||
|
||||
// Circle drawing
|
||||
Circle {
|
||||
radius: Precision,
|
||||
radius: Length,
|
||||
angle: Degrees, // sweep angle — degrees, as supplied by the user
|
||||
steps: usize,
|
||||
direction: crate::circle_geometry::CircleDirection,
|
||||
@@ -34,10 +34,9 @@ pub enum TurtleCommand {
|
||||
|
||||
// Position
|
||||
Goto(Coordinate),
|
||||
/// Heading stored as internal radians (Y-down render-space convention).
|
||||
/// Values passed via `TurtlePlan::set_heading` are converted from
|
||||
/// user-facing degrees before this command is enqueued.
|
||||
SetHeading(Radians),
|
||||
/// Heading stored in user degrees (Cartesian convention: 0° = East, 90° = North).
|
||||
/// Conversion to internal screen-space heading is performed when executed.
|
||||
SetHeading(Degrees),
|
||||
|
||||
// Visibility
|
||||
ShowTurtle,
|
||||
@@ -60,8 +59,8 @@ pub enum TurtleCommand {
|
||||
/// A pure-data sequence of turtle commands.
|
||||
///
|
||||
/// `CommandQueue` is intentionally *not* an `Iterator` — it carries no cursor
|
||||
/// state. Execution state ("which command are we on?") belongs to the
|
||||
/// consumer; `TweenController` owns the cursor that walks this queue.
|
||||
/// state. Execution state belongs to the consumer; `TweenController` consumes
|
||||
/// this queue as commands execute.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CommandQueue {
|
||||
commands: Vec<TurtleCommand>,
|
||||
@@ -116,7 +115,7 @@ impl Default for CommandQueue {
|
||||
///
|
||||
/// This is used by `CommandQueue::extend` and `TweenController::append_commands`
|
||||
/// to drain one queue into another. It does *not* imply that `CommandQueue`
|
||||
/// itself is stateful; the cursor always lives in the consumer.
|
||||
/// itself is stateful; execution state is managed by the consumer.
|
||||
impl IntoIterator for CommandQueue {
|
||||
type Item = TurtleCommand;
|
||||
type IntoIter = std::vec::IntoIter<TurtleCommand>;
|
||||
|
||||
@@ -77,7 +77,6 @@ pub struct TurtleCommandSender {
|
||||
/// Paired with `TurtleCommandSender` via `turtle_command_channel()`.
|
||||
/// Automatically managed by `TurtleApp::process_commands()`.
|
||||
pub(crate) struct TurtleCommandReceiver {
|
||||
turtle_id: usize,
|
||||
rx: Receiver<CommandQueue>,
|
||||
}
|
||||
|
||||
@@ -142,12 +141,6 @@ impl TurtleCommandSender {
|
||||
}
|
||||
|
||||
impl TurtleCommandReceiver {
|
||||
/// Get the turtle ID this receiver is bound to
|
||||
#[must_use]
|
||||
pub fn turtle_id(&self) -> usize {
|
||||
self.turtle_id
|
||||
}
|
||||
|
||||
/// Drain all pending commands for this turtle (non-blocking)
|
||||
///
|
||||
/// # Examples
|
||||
@@ -169,24 +162,6 @@ impl TurtleCommandReceiver {
|
||||
pub fn recv_all(&self) -> Vec<CommandQueue> {
|
||||
self.rx.try_iter().collect()
|
||||
}
|
||||
|
||||
/// Try to receive one command batch (non-blocking)
|
||||
#[must_use]
|
||||
pub fn try_recv(&self) -> Option<CommandQueue> {
|
||||
self.rx.try_recv().ok()
|
||||
}
|
||||
|
||||
/// Check if this receiver's queue is empty
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.rx.is_empty()
|
||||
}
|
||||
|
||||
/// Get the number of pending command batches
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.rx.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a command channel for a specific turtle
|
||||
@@ -203,13 +178,10 @@ impl TurtleCommandReceiver {
|
||||
/// Panics if `buffer_size` is 0.
|
||||
///
|
||||
/// # Examples
|
||||
/// ```no_run
|
||||
/// # use turtle_lib::*;
|
||||
/// # fn example() {
|
||||
/// ```ignore
|
||||
/// let (tx, _rx) = turtle_command_channel(0, 100);
|
||||
/// // Sender goes to game threads
|
||||
/// // Receiver stays in render thread (or `TurtleApp`)
|
||||
/// # }
|
||||
/// ```
|
||||
#[must_use]
|
||||
pub(crate) fn turtle_command_channel(
|
||||
@@ -220,6 +192,6 @@ pub(crate) fn turtle_command_channel(
|
||||
let (tx, rx) = bounded(buffer_size);
|
||||
(
|
||||
TurtleCommandSender { turtle_id, tx },
|
||||
TurtleCommandReceiver { turtle_id, rx },
|
||||
TurtleCommandReceiver { rx },
|
||||
)
|
||||
}
|
||||
|
||||
+20
-38
@@ -5,18 +5,11 @@ use crate::state::{DrawCommand, TurtleParams, TurtleWorld};
|
||||
use crate::tessellation;
|
||||
use macroquad::prelude::*;
|
||||
|
||||
// Import the easing function from the tween crate
|
||||
// To change the easing, change both this import and the usage in the draw_tween_arc function below
|
||||
// Available options: Linear, SineInOut, QuadInOut, CubicInOut, QuartInOut, QuintInOut,
|
||||
// ExpoInOut, CircInOut, BackInOut, ElasticInOut, BounceInOut, etc.
|
||||
// See https://easings.net/ for visual demonstrations
|
||||
use tween::CubicInOut;
|
||||
|
||||
/// Render the turtle world with active tween visualization.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub(crate) fn render_world_with_tweens(world: &TurtleWorld, zoom_level: f32) {
|
||||
// Update camera zoom based on current screen size to prevent stretching
|
||||
// Apply user zoom level by dividing by it (smaller zoom value = more zoomed in)
|
||||
// Apply user zoom level by dividing by it
|
||||
let camera = Camera2D {
|
||||
zoom: vec2(
|
||||
1.0 / screen_width() * 2.0 / zoom_level,
|
||||
@@ -33,8 +26,8 @@ pub(crate) fn render_world_with_tweens(world: &TurtleWorld, zoom_level: f32) {
|
||||
for turtle in &world.turtles {
|
||||
for cmd in &turtle.commands {
|
||||
match cmd {
|
||||
DrawCommand::Mesh { data } => {
|
||||
draw_mesh(&data.to_mesh());
|
||||
DrawCommand::Mesh(mesh) => {
|
||||
draw_mesh(mesh);
|
||||
}
|
||||
DrawCommand::Text {
|
||||
text,
|
||||
@@ -62,7 +55,7 @@ pub(crate) fn render_world_with_tweens(world: &TurtleWorld, zoom_level: f32) {
|
||||
direction,
|
||||
} => {
|
||||
// Draw arc segments from start to current position
|
||||
draw_tween_arc(tween, *radius, *angle, *steps, *direction);
|
||||
draw_tween_arc(tween, radius.value(), *angle, *steps, *direction);
|
||||
}
|
||||
_ if should_draw_tween_line(&tween.command) => {
|
||||
// Draw straight line for other movement commands (use tween's current position)
|
||||
@@ -133,21 +126,17 @@ pub(crate) fn render_world_with_tweens(world: &TurtleWorld, zoom_level: f32) {
|
||||
let geom = CircleGeometry::new(
|
||||
tween.start_params.position,
|
||||
Radians::new(tween.start_params.heading),
|
||||
*radius,
|
||||
radius.value(),
|
||||
*direction,
|
||||
);
|
||||
let elapsed = get_time() - tween.start_time;
|
||||
let progress = (elapsed / tween.duration).min(1.0);
|
||||
let eased_progress = CubicInOut.tween(1.0, progress as f32);
|
||||
|
||||
// Delegate to the shared arc_points function — same sampling
|
||||
// strategy as tessellate_arc, eliminating the divergence.
|
||||
// strategy as tessellate_arc, using tween.progress directly.
|
||||
let samples_to_draw =
|
||||
(((*steps).max(1) as f32 * eased_progress) as usize).max(1);
|
||||
let sweep_so_far = angle.as_radians().value() * eased_progress;
|
||||
(((*steps).max(1) as f32 * tween.progress) as usize).max(1);
|
||||
let sweep_so_far = angle.as_radians().value() * tween.progress;
|
||||
for pt in arc_points(
|
||||
geom.center,
|
||||
*radius,
|
||||
radius.value(),
|
||||
geom.start_angle_from_center,
|
||||
sweep_so_far,
|
||||
samples_to_draw,
|
||||
@@ -224,8 +213,8 @@ pub(crate) fn render_world_with_tweens(world: &TurtleWorld, zoom_level: f32) {
|
||||
&all_contours,
|
||||
fill_state.fill_color,
|
||||
) {
|
||||
Ok(mesh_data) => {
|
||||
draw_mesh(&mesh_data.to_mesh());
|
||||
Ok(mesh) => {
|
||||
draw_mesh(&mesh);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to tessellate fill preview: {:?}", e);
|
||||
@@ -304,29 +293,22 @@ fn draw_tween_arc(
|
||||
);
|
||||
|
||||
// Draw center using Lyon tessellation this helps visualizing what is done.
|
||||
if let Ok(mesh_data) = crate::tessellation::tessellate_circle(geom.center, 5.0, GRAY, true, 1.0)
|
||||
{
|
||||
draw_mesh(&mesh_data.to_mesh());
|
||||
if let Ok(mesh) = crate::tessellation::tessellate_circle(geom.center, 5.0, GRAY, true, 1.0) {
|
||||
draw_mesh(&mesh);
|
||||
}
|
||||
|
||||
// Calculate how much of the arc we've traveled based on tween progress
|
||||
// Use the same eased progress as the turtle position for synchronized animation
|
||||
let elapsed = get_time() - tween.start_time;
|
||||
let t = (elapsed / tween.duration).min(1.0);
|
||||
let progress = CubicInOut.tween(1.0, t as f32); // tween from 0 to 1
|
||||
|
||||
// Use Lyon to tessellate and draw the partial arc
|
||||
if let Ok(mesh_data) = crate::tessellation::tessellate_arc(
|
||||
// Draw the partial arc traveled based on tween progress
|
||||
if let Ok(mesh) = crate::tessellation::tessellate_arc(
|
||||
geom.center,
|
||||
radius,
|
||||
geom.start_angle_from_center.to_degrees(),
|
||||
total_angle.value() * progress,
|
||||
total_angle.value() * tween.progress,
|
||||
tween.start_params.color,
|
||||
tween.start_params.pen_width,
|
||||
((steps as f32 * progress).ceil() as usize).max(1),
|
||||
((steps as f32 * tween.progress).ceil() as usize).max(1),
|
||||
direction,
|
||||
) {
|
||||
draw_mesh(&mesh_data.to_mesh());
|
||||
draw_mesh(&mesh);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,10 +325,10 @@ pub(crate) fn draw_turtle(turtle_params: &TurtleParams) {
|
||||
.collect();
|
||||
|
||||
// Use Lyon for turtle shape too
|
||||
if let Ok(mesh_data) =
|
||||
if let Ok(mesh) =
|
||||
tessellation::tessellate_polygon(&absolute_vertices, Color::new(0.0, 0.5, 1.0, 1.0))
|
||||
{
|
||||
draw_mesh(&mesh_data.to_mesh());
|
||||
draw_mesh(&mesh);
|
||||
} else {
|
||||
// Fallback to simple triangle fan if Lyon fails
|
||||
let first = absolute_vertices[0];
|
||||
|
||||
+17
-33
@@ -93,7 +93,6 @@ pub(crate) fn execute_command_side_effects(
|
||||
BLACK
|
||||
});
|
||||
*filling = Some(FillState {
|
||||
start_position: params.position,
|
||||
contours: Vec::new(),
|
||||
current_contour: vec![params.position],
|
||||
fill_color,
|
||||
@@ -123,7 +122,7 @@ pub(crate) fn execute_command_side_effects(
|
||||
}
|
||||
|
||||
if !fill_state.contours.is_empty() {
|
||||
if let Ok(mesh_data) = tessellation::tessellate_multi_contour(
|
||||
if let Ok(mesh) = tessellation::tessellate_multi_contour(
|
||||
&fill_state.contours,
|
||||
fill_state.fill_color,
|
||||
) {
|
||||
@@ -132,7 +131,7 @@ pub(crate) fn execute_command_side_effects(
|
||||
contours = fill_state.contours.len(),
|
||||
"Successfully created fill mesh - persisting to commands"
|
||||
);
|
||||
commands.push(DrawCommand::Mesh { data: mesh_data });
|
||||
commands.push(DrawCommand::Mesh(mesh));
|
||||
#[cfg(feature = "svg")]
|
||||
svg_log.push(crate::state::SvgRecord::Fill {
|
||||
contours: fill_state.contours,
|
||||
@@ -242,7 +241,7 @@ pub(crate) fn record_fill_vertices_after_movement(
|
||||
let geom = CircleGeometry::new(
|
||||
start_state.position,
|
||||
Radians::new(start_state.heading),
|
||||
*radius,
|
||||
radius.value(),
|
||||
*direction,
|
||||
);
|
||||
if let Some(ref mut fill_state) = filling {
|
||||
@@ -252,7 +251,7 @@ pub(crate) fn record_fill_vertices_after_movement(
|
||||
turtle_id,
|
||||
center_x = geom.center.x,
|
||||
center_y = geom.center.y,
|
||||
radius,
|
||||
radius = radius.value(),
|
||||
steps,
|
||||
num_samples,
|
||||
"Recording arc vertices"
|
||||
@@ -268,8 +267,8 @@ pub(crate) fn record_fill_vertices_after_movement(
|
||||
}
|
||||
};
|
||||
let vertex = Coordinate::new(
|
||||
geom.center.x + radius * current_angle.cos(),
|
||||
geom.center.y + radius * current_angle.sin(),
|
||||
geom.center.x + radius.value() * current_angle.cos(),
|
||||
geom.center.y + radius.value() * current_angle.sin(),
|
||||
);
|
||||
tracing::trace!(
|
||||
turtle_id,
|
||||
@@ -326,7 +325,7 @@ pub(crate) fn tessellate_command(
|
||||
|
||||
match command {
|
||||
TurtleCommand::Move(_) | TurtleCommand::Goto(_) => {
|
||||
let mesh_data = tessellation::tessellate_stroke(
|
||||
let mesh = tessellation::tessellate_stroke(
|
||||
&[start.position, end_position],
|
||||
start.color,
|
||||
start.pen_width,
|
||||
@@ -334,7 +333,7 @@ pub(crate) fn tessellate_command(
|
||||
)
|
||||
.ok()?;
|
||||
|
||||
Some(DrawCommand::Mesh { data: mesh_data })
|
||||
Some(DrawCommand::Mesh(mesh))
|
||||
}
|
||||
|
||||
TurtleCommand::Circle {
|
||||
@@ -347,12 +346,12 @@ pub(crate) fn tessellate_command(
|
||||
let geom = CircleGeometry::new(
|
||||
start.position,
|
||||
Radians::new(start.heading),
|
||||
*radius,
|
||||
radius.value(),
|
||||
*direction,
|
||||
);
|
||||
let mesh_data = tessellation::tessellate_arc(
|
||||
let mesh = tessellation::tessellate_arc(
|
||||
geom.center,
|
||||
*radius,
|
||||
radius.value(),
|
||||
geom.start_angle_from_center.to_degrees(),
|
||||
angle.value(),
|
||||
start.color,
|
||||
@@ -362,7 +361,7 @@ pub(crate) fn tessellate_command(
|
||||
)
|
||||
.ok()?;
|
||||
|
||||
Some(DrawCommand::Mesh { data: mesh_data })
|
||||
Some(DrawCommand::Mesh(mesh))
|
||||
}
|
||||
|
||||
// `produces_drawing()` guards entry — this arm is only reachable if
|
||||
@@ -402,7 +401,7 @@ pub(crate) fn push_svg_for_draw(
|
||||
svg_log.push(SvgRecord::Arc {
|
||||
start_position: start.position,
|
||||
start_heading: start.heading,
|
||||
radius: *radius,
|
||||
radius: radius.value(),
|
||||
angle: *angle,
|
||||
direction: *direction,
|
||||
color: start.color,
|
||||
@@ -474,7 +473,7 @@ pub(crate) fn execute_command_with_id(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::commands::TurtleCommand;
|
||||
use crate::general::Degrees;
|
||||
use crate::general::{Degrees, Length};
|
||||
use crate::shapes::TurtleShape;
|
||||
use crate::tweening::TweenController;
|
||||
|
||||
@@ -484,7 +483,7 @@ mod tests {
|
||||
// the turtle ends up at (100, -50) from initial position (0, 0)
|
||||
use crate::state::TurtleParams;
|
||||
|
||||
let state = Turtle {
|
||||
let mut state = Turtle {
|
||||
turtle_id: 0,
|
||||
params: TurtleParams {
|
||||
position: vec2(0.0, 0.0),
|
||||
@@ -503,28 +502,13 @@ mod tests {
|
||||
tween_controller: TweenController::default(),
|
||||
};
|
||||
|
||||
// We'll use a dummy world but won't actually call drawing commands
|
||||
let world = TurtleWorld {
|
||||
turtles: vec![state.clone()],
|
||||
camera: macroquad::camera::Camera2D {
|
||||
zoom: vec2(1.0, 1.0),
|
||||
target: vec2(0.0, 0.0),
|
||||
offset: vec2(0.0, 0.0),
|
||||
rotation: 0.0,
|
||||
render_target: None,
|
||||
viewport: None,
|
||||
},
|
||||
background_color: Color::new(1.0, 1.0, 1.0, 1.0),
|
||||
};
|
||||
let mut state = world.turtles[0].clone();
|
||||
|
||||
// Initial state: position (0, 0), heading 0 (east)
|
||||
assert_eq!(state.params.position.x, 0.0);
|
||||
assert_eq!(state.params.position.y, 0.0);
|
||||
assert_eq!(state.params.heading, 0.0);
|
||||
|
||||
// Forward 100 - should move to (100, 0)
|
||||
execute_command(&TurtleCommand::Move(100.0), &mut state);
|
||||
execute_command(&TurtleCommand::Move(Length::new(100.0)), &mut state);
|
||||
assert!(
|
||||
(state.params.position.x - 100.0).abs() < 0.01,
|
||||
"After forward(100): x = {}",
|
||||
@@ -559,7 +543,7 @@ mod tests {
|
||||
);
|
||||
|
||||
// Forward 50 - should move north (negative Y) to (100, -50)
|
||||
execute_command(&TurtleCommand::Move(50.0), &mut state);
|
||||
execute_command(&TurtleCommand::Move(Length::new(50.0)), &mut state);
|
||||
assert!(
|
||||
(state.params.position.x - 100.0).abs() < 0.01,
|
||||
"Final position: x = {} (expected 100.0)",
|
||||
|
||||
+38
-30
@@ -1,5 +1,5 @@
|
||||
//! Export backend trait and core export types.
|
||||
|
||||
#[cfg(feature = "svg")]
|
||||
use crate::state::TurtleWorld;
|
||||
use crate::TurtlePlan;
|
||||
|
||||
@@ -17,6 +17,7 @@ pub enum DrawingFormat {
|
||||
// Additional formats: Png, Pdf, …
|
||||
}
|
||||
|
||||
#[cfg(feature = "svg")]
|
||||
pub(crate) trait DrawingExporter {
|
||||
/// Export the drawing to the specified format and filename
|
||||
///
|
||||
@@ -26,7 +27,9 @@ pub(crate) trait DrawingExporter {
|
||||
fn export(&self, world: &TurtleWorld, filename: &str) -> Result<(), ExportError>;
|
||||
}
|
||||
|
||||
pub(crate) fn parse_svg_export_arg() -> Option<String> {
|
||||
/// Check command-line arguments for the `--export-svg <filename>` flag.
|
||||
#[must_use]
|
||||
pub fn parse_svg_export_arg() -> Option<String> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let mut i = 1;
|
||||
while i < args.len() {
|
||||
@@ -38,49 +41,54 @@ pub(crate) fn parse_svg_export_arg() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Handle the optional `--export-svg` CLI flag.
|
||||
/// Headless SVG export that executes drawing commands and writes an SVG file
|
||||
/// without opening a graphics window and without calling `std::process::exit`.
|
||||
///
|
||||
/// The feature gating lives inside `turtle-lib`, so the `turtle_main` macro
|
||||
/// no longer needs to reference cfg flags from the consuming crate.
|
||||
pub fn handle_svg_export<F>(build_commands: F)
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ExportError` if file I/O fails or if the `svg` feature is not enabled.
|
||||
pub fn run_headless_svg_export<F>(mut build_commands: F, filename: &str) -> Result<(), ExportError>
|
||||
where
|
||||
F: FnMut(&mut TurtlePlan),
|
||||
{
|
||||
// Avoid unused warnings when the feature is disabled
|
||||
let _ = &build_commands;
|
||||
|
||||
if let Some(filename) = parse_svg_export_arg() {
|
||||
#[cfg(feature = "svg")]
|
||||
{
|
||||
let mut build_commands = build_commands;
|
||||
let mut turtle = crate::create_turtle_plan();
|
||||
build_commands(&mut turtle);
|
||||
|
||||
let mut app = crate::TurtleApp::new().with_commands(turtle.build());
|
||||
app.set_all_turtles_speed(crate::AnimationSpeed::Instant(1000));
|
||||
let mut app = crate::TurtleApp::new();
|
||||
app.execute_immediate(0, turtle);
|
||||
|
||||
while !app.all_animations_complete() {
|
||||
app.update();
|
||||
}
|
||||
|
||||
match app.export_drawing(&filename, crate::export::DrawingFormat::Svg) {
|
||||
Ok(_) => {
|
||||
println!("SVG exported successfully to: {}", filename);
|
||||
std::process::exit(0);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error exporting SVG: {:?}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
app.export_drawing(filename, crate::export::DrawingFormat::Svg)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "svg"))]
|
||||
{
|
||||
let _ = &filename;
|
||||
eprintln!("Error: SVG export feature is not enabled.");
|
||||
eprintln!("Please rebuild with --features svg");
|
||||
let _ = &mut build_commands;
|
||||
let _ = filename;
|
||||
Err(ExportError::Format(
|
||||
"SVG export feature is not enabled. Please rebuild with --features svg".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle the optional `--export-svg` CLI flag.
|
||||
///
|
||||
/// Delegates to [`run_headless_svg_export`].
|
||||
pub fn handle_svg_export<F>(build_commands: F)
|
||||
where
|
||||
F: FnMut(&mut TurtlePlan),
|
||||
{
|
||||
if let Some(filename) = parse_svg_export_arg() {
|
||||
match run_headless_svg_export(build_commands, &filename) {
|
||||
Ok(()) => {
|
||||
println!("SVG exported successfully to: {filename}");
|
||||
std::process::exit(0);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error exporting SVG: {e:?}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,23 +4,13 @@
|
||||
pub mod svg_export {
|
||||
use crate::export::{DrawingExporter, ExportError};
|
||||
use crate::state::{SvgRecord, TurtleWorld};
|
||||
use std::fmt::Write;
|
||||
use std::fs::File;
|
||||
use svg::{
|
||||
node::element::{Circle, Line, Text as SvgText},
|
||||
Document,
|
||||
};
|
||||
|
||||
pub struct SvgExporter;
|
||||
|
||||
impl DrawingExporter for SvgExporter {
|
||||
fn export(&self, world: &TurtleWorld, filename: &str) -> Result<(), ExportError> {
|
||||
let mut doc = Document::new();
|
||||
|
||||
let mut min_x = f32::INFINITY;
|
||||
let mut max_x = f32::NEG_INFINITY;
|
||||
let mut min_y = f32::INFINITY;
|
||||
let mut max_y = f32::NEG_INFINITY;
|
||||
|
||||
fn update_bounds(
|
||||
min_x: &mut f32,
|
||||
max_x: &mut f32,
|
||||
@@ -35,6 +25,18 @@ pub mod svg_export {
|
||||
*max_y = max_y.max(y);
|
||||
}
|
||||
|
||||
pub struct SvgExporter;
|
||||
|
||||
impl DrawingExporter for SvgExporter {
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn export(&self, world: &TurtleWorld, filename: &str) -> Result<(), ExportError> {
|
||||
let mut doc = Document::new();
|
||||
|
||||
let mut min_x = f32::INFINITY;
|
||||
let mut max_x = f32::NEG_INFINITY;
|
||||
let mut min_y = f32::INFINITY;
|
||||
let mut max_y = f32::NEG_INFINITY;
|
||||
|
||||
for turtle in &world.turtles {
|
||||
for record in &turtle.svg_log.records {
|
||||
match record {
|
||||
@@ -110,7 +112,7 @@ pub mod svg_export {
|
||||
} else {
|
||||
// Partial arc — emit as <path A …>
|
||||
let end = geom.position_at_angle(angle.as_radians().value());
|
||||
let large_arc = if angle.value() > 180.0 { 1 } else { 0 };
|
||||
let large_arc = i32::from(angle.value() > 180.0);
|
||||
let sweep = match direction {
|
||||
crate::circle_geometry::CircleDirection::Left => 0,
|
||||
crate::circle_geometry::CircleDirection::Right => 1,
|
||||
@@ -154,9 +156,9 @@ pub mod svg_export {
|
||||
if i > 0 {
|
||||
d.push(' ');
|
||||
}
|
||||
d.push_str(&format!("M {} {}", contour[0].x, contour[0].y));
|
||||
let _ = write!(d, "M {} {}", contour[0].x, contour[0].y);
|
||||
for point in contour.iter().skip(1) {
|
||||
d.push_str(&format!(" L {} {}", point.x, point.y));
|
||||
let _ = write!(d, " L {} {}", point.x, point.y);
|
||||
}
|
||||
d.push_str(" Z");
|
||||
}
|
||||
@@ -227,9 +229,9 @@ pub mod svg_export {
|
||||
let g = (color.g * 255.0) as u8;
|
||||
let b = (color.b * 255.0) as u8;
|
||||
if color.a < 1.0 {
|
||||
format!("rgba({},{},{},{})", r, g, b, color.a)
|
||||
format!("rgba({r},{g},{b},{})", color.a)
|
||||
} else {
|
||||
format!("rgb({},{},{})", r, g, b)
|
||||
format!("rgb({r},{g},{b})")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,7 @@ pub type Precision = f32;
|
||||
/// - internal render-space state uses Macroquad-style Y-down coordinates
|
||||
pub type Coordinate = Vec2;
|
||||
|
||||
/// Visibility flag for turtle
|
||||
pub type Visibility = bool;
|
||||
|
||||
|
||||
/// Execution speed setting
|
||||
/// - `Instant(draw_calls)`: Fast execution with limited draw calls per frame (speed - 1000, minimum 1)
|
||||
@@ -80,11 +79,52 @@ impl From<f32> for AnimationSpeed {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<f64> for AnimationSpeed {
|
||||
fn from(speed: f64) -> Self {
|
||||
AnimationSpeed::from_value(speed as f32)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<u32> for AnimationSpeed {
|
||||
fn from(speed: u32) -> Self {
|
||||
AnimationSpeed::from_u32(speed)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<i32> for AnimationSpeed {
|
||||
fn from(speed: i32) -> Self {
|
||||
AnimationSpeed::from_value(speed as f32)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<usize> for AnimationSpeed {
|
||||
fn from(speed: usize) -> Self {
|
||||
AnimationSpeed::from_value(speed as f32)
|
||||
}
|
||||
}
|
||||
|
||||
/// Color type re-export from macroquad
|
||||
pub use macroquad::color::Color;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn animation_speed_conversions() {
|
||||
assert_eq!(
|
||||
AnimationSpeed::from(50.0_f64),
|
||||
AnimationSpeed::Animated(50.0)
|
||||
);
|
||||
assert_eq!(
|
||||
AnimationSpeed::from(100.0_f32),
|
||||
AnimationSpeed::Animated(100.0)
|
||||
);
|
||||
assert_eq!(AnimationSpeed::from(1000_i32), AnimationSpeed::Instant(1));
|
||||
assert_eq!(AnimationSpeed::from(1200_u32), AnimationSpeed::Instant(200));
|
||||
assert_eq!(
|
||||
AnimationSpeed::from(1500_usize),
|
||||
AnimationSpeed::Instant(500)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,12 @@ impl From<f32> for Degrees {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<f64> for Degrees {
|
||||
fn from(v: f64) -> Self {
|
||||
Self(v as Precision)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<i32> for Degrees {
|
||||
fn from(v: i32) -> Self {
|
||||
Self(v as Precision)
|
||||
@@ -77,6 +83,12 @@ impl From<i16> for Degrees {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<usize> for Degrees {
|
||||
fn from(v: usize) -> Self {
|
||||
Self(v as Precision)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// An angle measured in radians.
|
||||
@@ -158,5 +170,13 @@ mod tests {
|
||||
assert_eq!(d, Degrees::new(90.0));
|
||||
let d2: Degrees = 45_i16.into();
|
||||
assert_eq!(d2, Degrees::new(45.0));
|
||||
let d3: Degrees = 180_usize.into();
|
||||
assert_eq!(d3, Degrees::new(180.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_f64() {
|
||||
let d: Degrees = 90.0_f64.into();
|
||||
assert_eq!(d, Degrees::new(90.0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ impl FontSize {
|
||||
|
||||
/// Get the inner u16 value
|
||||
#[must_use]
|
||||
pub const fn value(&self) -> u16 {
|
||||
pub const fn value(self) -> u16 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
@@ -41,8 +41,29 @@ impl From<i16> for FontSize {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<f64> for FontSize {
|
||||
fn from(f: f64) -> Self {
|
||||
Self(f.max(1.0) as u16)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<usize> for FontSize {
|
||||
fn from(size: usize) -> Self {
|
||||
Self((size as u16).max(1))
|
||||
Self(u16::try_from(size).unwrap_or(u16::MAX).max(1))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn font_size_conversions() {
|
||||
assert_eq!(FontSize::from(16_u16).value(), 16);
|
||||
assert_eq!(FontSize::from(24_i32).value(), 24);
|
||||
assert_eq!(FontSize::from(18_i16).value(), 18);
|
||||
assert_eq!(FontSize::from(32_usize).value(), 32);
|
||||
assert_eq!(FontSize::from(20.5_f32).value(), 20);
|
||||
assert_eq!(FontSize::from(28.0_f64).value(), 28);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,33 @@
|
||||
//! Length type for distance measurements
|
||||
|
||||
use super::Precision;
|
||||
use std::ops::Neg;
|
||||
|
||||
#[derive(Default, Copy, Clone, Debug, PartialEq)]
|
||||
/// A spatial distance or length measurement.
|
||||
///
|
||||
/// Used at the public API boundary for movement distances and arc radii.
|
||||
#[derive(Default, Copy, Clone, Debug, PartialEq, PartialOrd)]
|
||||
pub struct Length(pub Precision);
|
||||
|
||||
impl From<i16> for Length {
|
||||
fn from(i: i16) -> Self {
|
||||
Self(Precision::from(i))
|
||||
impl Length {
|
||||
/// Create a new `Length` from a raw value.
|
||||
#[must_use]
|
||||
pub const fn new(v: Precision) -> Self {
|
||||
Self(v)
|
||||
}
|
||||
|
||||
/// Extract the raw `Precision` (`f32`) value.
|
||||
#[must_use]
|
||||
pub const fn value(self) -> Precision {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Neg for Length {
|
||||
type Output = Self;
|
||||
|
||||
fn neg(self) -> Self {
|
||||
Self(-self.0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +37,56 @@ impl From<f32> for Length {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<f64> for Length {
|
||||
fn from(f: f64) -> Self {
|
||||
Self(f as Precision)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<i16> for Length {
|
||||
fn from(i: i16) -> Self {
|
||||
Self(Precision::from(i))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<i32> for Length {
|
||||
fn from(i: i32) -> Self {
|
||||
Self(i as Precision)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<usize> for Length {
|
||||
fn from(u: usize) -> Self {
|
||||
Self(u as Precision)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_length_conversions_and_negation() {
|
||||
let l_f32: Length = 42.5_f32.into();
|
||||
assert_eq!(l_f32.value(), 42.5);
|
||||
|
||||
let l_f64: Length = 100.0_f64.into();
|
||||
assert_eq!(l_f64.value(), 100.0);
|
||||
|
||||
let l_i32: Length = 50_i32.into();
|
||||
assert_eq!(l_i32.value(), 50.0);
|
||||
|
||||
let l_i16: Length = 25_i16.into();
|
||||
assert_eq!(l_i16.value(), 25.0);
|
||||
|
||||
let l_usize: Length = 10_usize.into();
|
||||
assert_eq!(l_usize.value(), 10.0);
|
||||
|
||||
let neg = -l_f32;
|
||||
assert_eq!(neg.value(), -42.5);
|
||||
|
||||
assert!(Length::new(10.0) < Length::new(20.0));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
+20
-18
@@ -59,8 +59,9 @@ pub(crate) mod state;
|
||||
pub(crate) mod tessellation;
|
||||
pub(crate) mod tweening;
|
||||
|
||||
// Re-export commonly used types
|
||||
pub use builders::{CurvedMovement, DirectionalMovement, Turnable, TurtlePlan, WithCommands};
|
||||
pub use builders::{
|
||||
Cursor, Fill, Movement, Pen, Rotation, Text, TurtlePlan, WithCommands,
|
||||
};
|
||||
pub use commands::{CommandQueue, TurtleCommand};
|
||||
pub use commands_channel::TurtleCommandSender;
|
||||
pub use general::{AnimationSpeed, Color, Coordinate, Degrees, Length, Precision, Radians};
|
||||
@@ -73,6 +74,9 @@ pub(crate) mod export_svg;
|
||||
// Re-export the turtle_main macro
|
||||
pub use turtle_lib_macros::turtle_main;
|
||||
|
||||
// Re-export the macroquad crate so generated macro code can access it directly
|
||||
pub use macroquad;
|
||||
|
||||
// Re-export common macroquad types and colors for convenience
|
||||
pub use macroquad::prelude::{
|
||||
vec2, BLACK, BLUE, DARKGRAY, GOLD, GREEN, ORANGE, PURPLE, RED, WHITE, YELLOW,
|
||||
@@ -106,6 +110,7 @@ impl TurtleApp {
|
||||
filename: &str,
|
||||
format: export::DrawingFormat,
|
||||
) -> Result<(), export::ExportError> {
|
||||
let _ = filename;
|
||||
match format {
|
||||
#[cfg(feature = "svg")]
|
||||
export::DrawingFormat::Svg => {
|
||||
@@ -249,6 +254,11 @@ impl TurtleApp {
|
||||
|
||||
/// Execute a plan immediately on a specific turtle (no animation)
|
||||
pub fn execute_immediate(&mut self, turtle_id: usize, plan: TurtlePlan) {
|
||||
// Ensure turtle exists
|
||||
while self.world.turtles.len() <= turtle_id {
|
||||
self.world.add_turtle();
|
||||
}
|
||||
|
||||
for ref cmd in plan.build() {
|
||||
execution::execute_command_with_id(cmd, turtle_id, &mut self.world);
|
||||
}
|
||||
@@ -281,12 +291,19 @@ impl TurtleApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Update animation state (call every frame)
|
||||
/// Update animation state and process window mouse events (call every frame in GUI loop)
|
||||
pub fn update(&mut self) {
|
||||
// Handle mouse panning and zoom
|
||||
self.handle_mouse_panning();
|
||||
self.handle_mouse_zoom();
|
||||
|
||||
self.step_animations();
|
||||
}
|
||||
|
||||
/// Drive animation updates for all turtles without querying window or mouse events.
|
||||
///
|
||||
/// Suitable for headless execution (such as CLI SVG export) where no graphics window exists.
|
||||
pub fn step_animations(&mut self) {
|
||||
// Update all turtles' tween controllers
|
||||
for turtle in &mut self.world.turtles {
|
||||
// Drive this turtle's animation controller for one frame.
|
||||
@@ -371,12 +388,6 @@ impl TurtleApp {
|
||||
.all(|turtle| turtle.tween_controller.is_complete())
|
||||
}
|
||||
|
||||
/// Check if all animations are complete (alias for is_complete)
|
||||
#[must_use]
|
||||
pub fn all_animations_complete(&self) -> bool {
|
||||
self.is_complete()
|
||||
}
|
||||
|
||||
/// Set the animation speed for all turtles
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -388,16 +399,7 @@ impl TurtleApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get reference to the world state
|
||||
#[must_use]
|
||||
pub(crate) fn world(&self) -> &TurtleWorld {
|
||||
&self.world
|
||||
}
|
||||
|
||||
/// Get mutable reference to the world state
|
||||
pub(crate) fn world_mut(&mut self) -> &mut TurtleWorld {
|
||||
&mut self.world
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TurtleApp {
|
||||
|
||||
+3
-223
@@ -9,9 +9,6 @@ use macroquad::prelude::*;
|
||||
/// State during active fill operation
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct FillState {
|
||||
/// Starting position of the fill
|
||||
pub(crate) start_position: Coordinate,
|
||||
|
||||
/// All contours collected so far. Each contour is a separate closed path.
|
||||
/// The first contour is the outer boundary, subsequent contours are holes.
|
||||
pub(crate) contours: Vec<Vec<Coordinate>>,
|
||||
@@ -55,8 +52,8 @@ impl Default for TurtleParams {
|
||||
}
|
||||
|
||||
/// State of a single turtle
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Turtle {
|
||||
#[allow(clippy::struct_field_names)]
|
||||
pub(crate) turtle_id: usize,
|
||||
pub(crate) params: TurtleParams,
|
||||
|
||||
@@ -91,26 +88,6 @@ impl Turtle {
|
||||
self.params.speed = speed;
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn heading_angle(&self) -> crate::general::Radians {
|
||||
crate::general::Radians::new(self.params.heading)
|
||||
}
|
||||
|
||||
/// Reset turtle to default state (preserves `turtle_id` and queued commands)
|
||||
pub fn reset(&mut self) {
|
||||
// Clear all drawings
|
||||
self.commands.clear();
|
||||
self.svg_log.clear();
|
||||
|
||||
// Clear fill state
|
||||
self.filling = None;
|
||||
|
||||
// Reset parameters to defaults
|
||||
self.params = TurtleParams::default();
|
||||
|
||||
// Keep turtle_id and tween_controller (preserves queued commands)
|
||||
}
|
||||
|
||||
/// Drive the animation controller for one frame.
|
||||
///
|
||||
/// Returns `(command, start_params, end_params)` for every command that
|
||||
@@ -131,156 +108,6 @@ impl Turtle {
|
||||
&mut self.svg_log,
|
||||
)
|
||||
}
|
||||
|
||||
/// Start recording fill vertices
|
||||
pub fn begin_fill(&mut self, fill_color: Color) {
|
||||
self.filling = Some(FillState {
|
||||
start_position: self.params.position,
|
||||
contours: Vec::new(),
|
||||
current_contour: vec![self.params.position],
|
||||
fill_color,
|
||||
});
|
||||
}
|
||||
|
||||
/// Record current position if filling and pen is down
|
||||
pub fn record_fill_vertex(&mut self) {
|
||||
if let Some(ref mut fill_state) = self.filling {
|
||||
if self.params.pen_down {
|
||||
tracing::trace!(
|
||||
turtle_id = self.turtle_id,
|
||||
x = self.params.position.x,
|
||||
y = self.params.position.y,
|
||||
vertices = fill_state.current_contour.len() + 1,
|
||||
"Adding vertex to current contour"
|
||||
);
|
||||
fill_state.current_contour.push(self.params.position);
|
||||
} else {
|
||||
tracing::trace!(turtle_id = self.turtle_id, "Skipping vertex (pen is up)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the current contour and prepare for a new one (called on `pen_up`)
|
||||
pub fn close_fill_contour(&mut self) {
|
||||
if let Some(ref mut fill_state) = self.filling {
|
||||
tracing::debug!(
|
||||
turtle_id = self.turtle_id,
|
||||
vertices = fill_state.current_contour.len(),
|
||||
"close_fill_contour called"
|
||||
);
|
||||
// Only close if we have vertices in current contour
|
||||
if fill_state.current_contour.len() >= 2 {
|
||||
tracing::debug!(
|
||||
turtle_id = self.turtle_id,
|
||||
vertices = fill_state.current_contour.len(),
|
||||
first_x = fill_state.current_contour[0].x,
|
||||
first_y = fill_state.current_contour[0].y,
|
||||
last_x = fill_state.current_contour[fill_state.current_contour.len() - 1].x,
|
||||
last_y = fill_state.current_contour[fill_state.current_contour.len() - 1].y,
|
||||
"Closing contour"
|
||||
);
|
||||
// Move current contour to completed contours
|
||||
let contour = std::mem::take(&mut fill_state.current_contour);
|
||||
fill_state.contours.push(contour);
|
||||
tracing::debug!(
|
||||
turtle_id = self.turtle_id,
|
||||
completed_contours = fill_state.contours.len(),
|
||||
"Contour moved to completed list"
|
||||
);
|
||||
} else if !fill_state.current_contour.is_empty() {
|
||||
tracing::warn!(
|
||||
turtle_id = self.turtle_id,
|
||||
vertices = fill_state.current_contour.len(),
|
||||
"Current contour has insufficient vertices, not closing"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
turtle_id = self.turtle_id,
|
||||
"Current contour is empty, nothing to close"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
turtle_id = self.turtle_id,
|
||||
"close_fill_contour called but no active fill state"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a new contour (called on `pen_down`)
|
||||
pub fn start_fill_contour(&mut self) {
|
||||
if let Some(ref mut fill_state) = self.filling {
|
||||
// Start new contour at current position
|
||||
tracing::debug!(
|
||||
x = self.params.position.x,
|
||||
y = self.params.position.y,
|
||||
completed_contours = fill_state.contours.len(),
|
||||
self.turtle_id = self.turtle_id,
|
||||
"Starting new contour"
|
||||
);
|
||||
fill_state.current_contour = vec![self.params.position];
|
||||
}
|
||||
}
|
||||
|
||||
/// Record multiple vertices along a circle arc for filling
|
||||
/// This ensures circles are properly filled by sampling points along the arc
|
||||
pub fn record_fill_vertices_for_arc(
|
||||
&mut self,
|
||||
center: Coordinate,
|
||||
radius: f32,
|
||||
start_angle: f32,
|
||||
angle_traveled: f32,
|
||||
direction: crate::circle_geometry::CircleDirection,
|
||||
steps: u32,
|
||||
) {
|
||||
if let Some(ref mut fill_state) = self.filling {
|
||||
if self.params.pen_down {
|
||||
// Sample points along the arc based on steps
|
||||
let num_samples = steps.max(1);
|
||||
|
||||
tracing::trace!(
|
||||
turtle_id = self.turtle_id,
|
||||
center_x = center.x,
|
||||
center_y = center.y,
|
||||
radius = radius,
|
||||
steps = steps,
|
||||
num_samples = num_samples,
|
||||
"Recording arc vertices"
|
||||
);
|
||||
|
||||
for i in 1..=num_samples {
|
||||
let progress = i as f32 / num_samples as f32;
|
||||
let current_angle = match direction {
|
||||
crate::circle_geometry::CircleDirection::Left => {
|
||||
start_angle - angle_traveled * progress
|
||||
}
|
||||
crate::circle_geometry::CircleDirection::Right => {
|
||||
start_angle + angle_traveled * progress
|
||||
}
|
||||
};
|
||||
|
||||
let vertex = Coordinate::new(
|
||||
center.x + radius * current_angle.cos(),
|
||||
center.y + radius * current_angle.sin(),
|
||||
);
|
||||
tracing::trace!(
|
||||
turtle_id = self.turtle_id,
|
||||
vertex_idx = i,
|
||||
x = vertex.x,
|
||||
y = vertex.y,
|
||||
angle_degrees = current_angle.to_degrees(),
|
||||
"Arc vertex"
|
||||
);
|
||||
fill_state.current_contour.push(vertex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear fill state (called after `end_fill`)
|
||||
pub fn reset_fill(&mut self) {
|
||||
self.filling = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// The draw-event log for SVG export.
|
||||
@@ -349,30 +176,11 @@ pub(crate) enum SvgRecord {
|
||||
},
|
||||
}
|
||||
|
||||
/// Cached mesh data that can be cloned and converted to Mesh when needed
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct MeshData {
|
||||
pub(crate) vertices: Vec<macroquad::prelude::Vertex>,
|
||||
pub(crate) indices: Vec<u16>,
|
||||
}
|
||||
|
||||
impl MeshData {
|
||||
#[must_use]
|
||||
pub fn to_mesh(&self) -> macroquad::prelude::Mesh {
|
||||
macroquad::prelude::Mesh {
|
||||
vertices: self.vertices.clone(),
|
||||
indices: self.indices.clone(),
|
||||
texture: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drawable elements in the world.
|
||||
/// All drawing is done via Lyon-tessellated meshes for consistency and quality.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum DrawCommand {
|
||||
/// Pre-tessellated mesh data (lines, arcs, circles, polygons — all use this).
|
||||
Mesh { data: MeshData },
|
||||
Mesh(macroquad::prelude::Mesh),
|
||||
/// Text rendering command.
|
||||
Text {
|
||||
text: String,
|
||||
@@ -388,7 +196,6 @@ pub(crate) struct TurtleWorld {
|
||||
/// All turtles in the world (indexed by turtle ID)
|
||||
pub(crate) turtles: Vec<Turtle>,
|
||||
pub(crate) camera: Camera2D,
|
||||
pub(crate) background_color: Color,
|
||||
}
|
||||
|
||||
impl TurtleWorld {
|
||||
@@ -396,12 +203,7 @@ impl TurtleWorld {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
turtles: vec![], // Start with no turtles
|
||||
camera: Camera2D {
|
||||
zoom: vec2(1.0 / screen_width() * 2.0, 1.0 / screen_height() * 2.0),
|
||||
target: vec2(0.0, 0.0),
|
||||
..Default::default()
|
||||
},
|
||||
background_color: WHITE,
|
||||
camera: Camera2D::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,32 +218,10 @@ impl TurtleWorld {
|
||||
turtle_id
|
||||
}
|
||||
|
||||
/// Get turtle by ID
|
||||
#[must_use]
|
||||
pub fn get_turtle(&self, id: usize) -> Option<&Turtle> {
|
||||
self.turtles.get(id)
|
||||
}
|
||||
|
||||
/// Get mutable turtle by ID
|
||||
pub fn get_turtle_mut(&mut self, id: usize) -> Option<&mut Turtle> {
|
||||
self.turtles.get_mut(id)
|
||||
}
|
||||
|
||||
/// Reset a specific turtle to default state and remove all its drawings
|
||||
pub fn reset_turtle(&mut self, turtle_id: usize) {
|
||||
if let Some(turtle) = self.get_turtle_mut(turtle_id) {
|
||||
turtle.reset();
|
||||
turtle.turtle_id = turtle_id; // Preserve turtle_id after reset
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all drawings and reset all turtle states
|
||||
pub fn clear(&mut self) {
|
||||
for (id, turtle) in self.turtles.iter_mut().enumerate() {
|
||||
turtle.reset();
|
||||
turtle.turtle_id = id; // Preserve turtle_id after reset
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TurtleWorld {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
//! This module provides helper functions to tessellate paths using Lyon,
|
||||
//! which replaces the manual triangulation with GPU-optimized tessellation.
|
||||
|
||||
use crate::state::MeshData;
|
||||
use lyon::math::{point, Point};
|
||||
use lyon::path::{LineCap, LineJoin, Path};
|
||||
use lyon::tessellation::{
|
||||
@@ -31,13 +30,13 @@ pub(crate) struct SimpleVertex {
|
||||
pub(crate) position: [f32; 2],
|
||||
}
|
||||
|
||||
/// Build mesh data from Lyon tessellation
|
||||
/// Build mesh from Lyon tessellation
|
||||
#[must_use]
|
||||
pub(crate) fn build_mesh_data(
|
||||
pub(crate) fn build_mesh(
|
||||
vertices: &[SimpleVertex],
|
||||
indices: &[u16],
|
||||
color: Color,
|
||||
) -> MeshData {
|
||||
) -> Mesh {
|
||||
let verts: Vec<Vertex> = vertices
|
||||
.iter()
|
||||
.map(|v| Vertex {
|
||||
@@ -53,9 +52,10 @@ pub(crate) fn build_mesh_data(
|
||||
})
|
||||
.collect();
|
||||
|
||||
MeshData {
|
||||
Mesh {
|
||||
vertices: verts,
|
||||
indices: indices.to_vec(),
|
||||
texture: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ pub(crate) fn build_mesh_data(
|
||||
pub(crate) fn tessellate_polygon(
|
||||
vertices: &[Vec2],
|
||||
color: Color,
|
||||
) -> Result<MeshData, Box<dyn std::error::Error>> {
|
||||
) -> Result<Mesh, Box<dyn std::error::Error>> {
|
||||
if vertices.is_empty() {
|
||||
return Err("No vertices provided".into());
|
||||
}
|
||||
@@ -96,7 +96,7 @@ pub(crate) fn tessellate_polygon(
|
||||
}),
|
||||
)?;
|
||||
|
||||
Ok(build_mesh_data(
|
||||
Ok(build_mesh(
|
||||
&geometry.vertices,
|
||||
&geometry.indices,
|
||||
color,
|
||||
@@ -114,7 +114,7 @@ pub(crate) fn tessellate_polygon(
|
||||
pub(crate) fn tessellate_multi_contour(
|
||||
contours: &[Vec<Vec2>],
|
||||
color: Color,
|
||||
) -> Result<MeshData, Box<dyn std::error::Error>> {
|
||||
) -> Result<Mesh, Box<dyn std::error::Error>> {
|
||||
if contours.is_empty() {
|
||||
return Err("No contours provided".into());
|
||||
}
|
||||
@@ -195,7 +195,7 @@ pub(crate) fn tessellate_multi_contour(
|
||||
}
|
||||
}
|
||||
|
||||
Ok(build_mesh_data(
|
||||
Ok(build_mesh(
|
||||
&geometry.vertices,
|
||||
&geometry.indices,
|
||||
color,
|
||||
@@ -212,7 +212,7 @@ pub(crate) fn tessellate_stroke(
|
||||
color: Color,
|
||||
width: f32,
|
||||
closed: bool,
|
||||
) -> Result<MeshData, Box<dyn std::error::Error>> {
|
||||
) -> Result<Mesh, Box<dyn std::error::Error>> {
|
||||
if vertices.is_empty() {
|
||||
return Err("No vertices provided".into());
|
||||
}
|
||||
@@ -241,7 +241,7 @@ pub(crate) fn tessellate_stroke(
|
||||
}),
|
||||
)?;
|
||||
|
||||
Ok(build_mesh_data(
|
||||
Ok(build_mesh(
|
||||
&geometry.vertices,
|
||||
&geometry.indices,
|
||||
color,
|
||||
@@ -259,7 +259,7 @@ pub(crate) fn tessellate_circle(
|
||||
color: Color,
|
||||
filled: bool,
|
||||
stroke_width: f32,
|
||||
) -> Result<MeshData, Box<dyn std::error::Error>> {
|
||||
) -> Result<Mesh, Box<dyn std::error::Error>> {
|
||||
let mut builder = Path::builder();
|
||||
builder.add_circle(to_lyon_point(center), radius, lyon::path::Winding::Positive);
|
||||
let path = builder.build();
|
||||
@@ -286,7 +286,7 @@ pub(crate) fn tessellate_circle(
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(build_mesh_data(
|
||||
Ok(build_mesh(
|
||||
&geometry.vertices,
|
||||
&geometry.indices,
|
||||
color,
|
||||
@@ -308,7 +308,7 @@ pub(crate) fn tessellate_arc(
|
||||
stroke_width: f32,
|
||||
segments: usize,
|
||||
direction: crate::circle_geometry::CircleDirection,
|
||||
) -> Result<MeshData, Box<dyn std::error::Error>> {
|
||||
) -> Result<Mesh, Box<dyn std::error::Error>> {
|
||||
use crate::circle_geometry::arc_points;
|
||||
|
||||
let start_angle = start_angle_degrees.to_radians();
|
||||
@@ -352,7 +352,7 @@ pub(crate) fn tessellate_arc(
|
||||
}),
|
||||
)?;
|
||||
|
||||
Ok(build_mesh_data(
|
||||
Ok(build_mesh(
|
||||
&geometry.vertices,
|
||||
&geometry.indices,
|
||||
color,
|
||||
|
||||
+203
-24
@@ -5,6 +5,7 @@ use crate::commands::{CommandQueue, TurtleCommand};
|
||||
use crate::general::{AnimationSpeed, Radians};
|
||||
use crate::state::{DrawCommand, FillState, TurtleParams};
|
||||
use macroquad::prelude::*;
|
||||
use std::collections::VecDeque;
|
||||
use tween::{CubicInOut, TweenValue, Tweener};
|
||||
|
||||
// Newtype wrapper for Vec2 to implement TweenValue
|
||||
@@ -46,25 +47,21 @@ impl From<TweenVec2> for Vec2 {
|
||||
/// Controls tweening of turtle commands
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct TweenController {
|
||||
queue: CommandQueue,
|
||||
/// Cursor into `queue` — tracks which command executes next.
|
||||
/// Lives here, not in `CommandQueue`, so that cloning or appending to the
|
||||
/// queue never silently resets or mid-stream-shifts the execution position.
|
||||
cursor: usize,
|
||||
queue: VecDeque<TurtleCommand>,
|
||||
current_tween: Option<CommandTween>,
|
||||
speed: AnimationSpeed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct CommandTween {
|
||||
pub(crate) turtle_id: usize,
|
||||
pub(crate) command: TurtleCommand,
|
||||
pub(crate) start_time: f64,
|
||||
pub(crate) duration: f64,
|
||||
pub(crate) progress: f32,
|
||||
pub(crate) start_params: TurtleParams,
|
||||
pub(crate) target_params: TurtleParams,
|
||||
pub(crate) current_position: Vec2,
|
||||
pub(crate) current_heading: f32,
|
||||
start_time: f64,
|
||||
duration: f64,
|
||||
position_tweener: Tweener<TweenVec2, f64, CubicInOut>,
|
||||
heading_tweener: Tweener<f32, f64, CubicInOut>,
|
||||
pen_width_tweener: Tweener<f32, f64, CubicInOut>,
|
||||
@@ -74,8 +71,7 @@ impl TweenController {
|
||||
#[must_use]
|
||||
pub fn new(queue: CommandQueue, speed: AnimationSpeed) -> Self {
|
||||
Self {
|
||||
queue,
|
||||
cursor: 0,
|
||||
queue: queue.into_iter().collect(),
|
||||
current_tween: None,
|
||||
speed,
|
||||
}
|
||||
@@ -87,8 +83,8 @@ impl TweenController {
|
||||
|
||||
/// Append commands to the queue.
|
||||
///
|
||||
/// The cursor is **not** reset — commands already consumed remain consumed,
|
||||
/// and the new commands are picked up naturally as the cursor advances.
|
||||
/// Consumed commands are removed as they execute, and new commands
|
||||
/// are queued at the back.
|
||||
pub fn append_commands(&mut self, new_queue: CommandQueue) {
|
||||
self.queue.extend(new_queue);
|
||||
}
|
||||
@@ -117,9 +113,8 @@ impl TweenController {
|
||||
Vec::new();
|
||||
let mut draw_call_count = 0;
|
||||
|
||||
// Advance cursor through the queue for each command consumed
|
||||
while let Some(command) = self.queue.get(self.cursor).cloned() {
|
||||
self.cursor += 1;
|
||||
// Consume commands from the front of the queue
|
||||
while let Some(command) = self.queue.pop_front() {
|
||||
// Handle SetSpeed command to potentially switch modes
|
||||
if let TurtleCommand::SetSpeed(new_speed) = &command {
|
||||
params.speed = *new_speed;
|
||||
@@ -168,11 +163,12 @@ impl TweenController {
|
||||
|
||||
// Process current tween
|
||||
if let Some(ref mut tween) = self.current_tween {
|
||||
let elapsed = get_time() - tween.start_time;
|
||||
let elapsed = current_time() - tween.start_time;
|
||||
|
||||
// Use tweeners to calculate current values
|
||||
// For circles, calculate position along the arc instead of straight line
|
||||
let progress = tween.heading_tweener.move_to(elapsed);
|
||||
let progress = tween.heading_tweener.move_to(elapsed).clamp(0.0, 1.0);
|
||||
tween.progress = progress;
|
||||
|
||||
let current_position = match &tween.command {
|
||||
TurtleCommand::Circle {
|
||||
@@ -185,7 +181,7 @@ impl TweenController {
|
||||
calculate_circle_position(
|
||||
tween.start_params.position,
|
||||
Radians::new(tween.start_params.heading),
|
||||
*radius,
|
||||
radius.value(),
|
||||
angle_traveled,
|
||||
*direction,
|
||||
)
|
||||
@@ -273,9 +269,7 @@ impl TweenController {
|
||||
}
|
||||
|
||||
// Start next tween
|
||||
if let Some(command) = self.queue.get(self.cursor).cloned() {
|
||||
self.cursor += 1;
|
||||
|
||||
if let Some(command) = self.queue.pop_front() {
|
||||
// Handle commands that should execute immediately (no animation)
|
||||
match &command {
|
||||
TurtleCommand::SetSpeed(new_speed) => {
|
||||
@@ -323,9 +317,9 @@ impl TweenController {
|
||||
);
|
||||
|
||||
self.current_tween = Some(CommandTween {
|
||||
turtle_id,
|
||||
command,
|
||||
start_time: get_time(),
|
||||
progress: 0.0,
|
||||
start_time: current_time(),
|
||||
duration,
|
||||
start_params: params.clone(),
|
||||
target_params: target_state.clone(),
|
||||
@@ -342,7 +336,7 @@ impl TweenController {
|
||||
|
||||
#[must_use]
|
||||
pub fn is_complete(&self) -> bool {
|
||||
self.current_tween.is_none() && self.cursor >= self.queue.len()
|
||||
self.current_tween.is_none() && self.queue.is_empty()
|
||||
}
|
||||
|
||||
/// Get the current active tween if one is in progress
|
||||
@@ -400,3 +394,188 @@ pub(crate) fn normalize_angle(angle: f32) -> f32 {
|
||||
|
||||
normalized
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn current_time() -> f64 {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Instant;
|
||||
static START: OnceLock<Instant> = OnceLock::new();
|
||||
START.get_or_init(Instant::now).elapsed().as_secs_f64()
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
macroquad::time::get_time()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::commands::TurtleCommand;
|
||||
use crate::general::{Degrees, Length};
|
||||
use crate::state::TurtleParams;
|
||||
|
||||
fn make_test_params() -> TurtleParams {
|
||||
TurtleParams {
|
||||
position: vec2(0.0, 0.0),
|
||||
heading: 0.0,
|
||||
pen_down: true,
|
||||
pen_width: 1.0,
|
||||
color: Color::new(0.0, 0.0, 0.0, 1.0),
|
||||
fill_color: None,
|
||||
visible: true,
|
||||
shape: crate::shapes::TurtleShape::turtle(),
|
||||
speed: AnimationSpeed::Instant(100),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_instant_mode_drains_queue() {
|
||||
let mut queue = CommandQueue::new();
|
||||
queue.push(TurtleCommand::Move(Length::new(100.0)));
|
||||
queue.push(TurtleCommand::Turn(Degrees::new(90.0)));
|
||||
queue.push(TurtleCommand::PenUp);
|
||||
queue.push(TurtleCommand::Move(Length::new(50.0)));
|
||||
|
||||
let mut controller = TweenController::new(queue, AnimationSpeed::Instant(100));
|
||||
assert_eq!(controller.queue.len(), 4);
|
||||
assert!(!controller.is_complete());
|
||||
|
||||
let mut params = make_test_params();
|
||||
let mut filling = None;
|
||||
let mut commands = Vec::new();
|
||||
let mut svg_log = crate::state::SvgLog::default();
|
||||
|
||||
let completed = controller.update(0, &mut params, &mut filling, &mut commands, &mut svg_log);
|
||||
|
||||
assert_eq!(controller.queue.len(), 0, "Queue must be empty after instant update");
|
||||
assert!(controller.is_complete(), "Controller must be complete when queue is drained");
|
||||
assert!(!completed.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_streaming_append_commands_does_not_accumulate() {
|
||||
let mut controller = TweenController::new(CommandQueue::new(), AnimationSpeed::Instant(100));
|
||||
let mut params = make_test_params();
|
||||
let mut filling = None;
|
||||
let mut commands = Vec::new();
|
||||
let mut svg_log = crate::state::SvgLog::default();
|
||||
|
||||
// Simulate streaming commands across 50 frames (like clock_threaded)
|
||||
for _ in 0..50 {
|
||||
let mut batch = CommandQueue::new();
|
||||
batch.push(TurtleCommand::Reset);
|
||||
batch.push(TurtleCommand::PenDown);
|
||||
batch.push(TurtleCommand::Move(Length::new(10.0)));
|
||||
batch.push(TurtleCommand::Turn(Degrees::new(30.0)));
|
||||
|
||||
controller.append_commands(batch);
|
||||
assert_eq!(controller.queue.len(), 4);
|
||||
|
||||
controller.update(0, &mut params, &mut filling, &mut commands, &mut svg_log);
|
||||
|
||||
// Verify queue is pruned back to 0 — no memory leak / accumulation
|
||||
assert_eq!(controller.queue.len(), 0);
|
||||
assert!(controller.is_complete());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_instant_mode_respects_batch_limit_and_retains_pending() {
|
||||
let mut queue = CommandQueue::new();
|
||||
// 5 drawing commands
|
||||
queue.push(TurtleCommand::Move(Length::new(10.0)));
|
||||
queue.push(TurtleCommand::Move(Length::new(20.0)));
|
||||
queue.push(TurtleCommand::Move(Length::new(30.0)));
|
||||
queue.push(TurtleCommand::Move(Length::new(40.0)));
|
||||
queue.push(TurtleCommand::Move(Length::new(50.0)));
|
||||
|
||||
// Limit to 2 draw calls per frame
|
||||
let mut controller = TweenController::new(queue, AnimationSpeed::Instant(2));
|
||||
let mut params = make_test_params();
|
||||
let mut filling = None;
|
||||
let mut commands = Vec::new();
|
||||
let mut svg_log = crate::state::SvgLog::default();
|
||||
|
||||
// Frame 1: processes 2 drawing commands
|
||||
let completed1 = controller.update(0, &mut params, &mut filling, &mut commands, &mut svg_log);
|
||||
assert_eq!(completed1.len(), 2);
|
||||
assert_eq!(controller.queue.len(), 3, "3 commands should remain in queue");
|
||||
assert!(!controller.is_complete());
|
||||
|
||||
// Frame 2: processes next 2 drawing commands
|
||||
let completed2 = controller.update(0, &mut params, &mut filling, &mut commands, &mut svg_log);
|
||||
assert_eq!(completed2.len(), 2);
|
||||
assert_eq!(controller.queue.len(), 1, "1 command should remain in queue");
|
||||
assert!(!controller.is_complete());
|
||||
|
||||
// Frame 3: processes last drawing command
|
||||
let completed3 = controller.update(0, &mut params, &mut filling, &mut commands, &mut svg_log);
|
||||
assert_eq!(completed3.len(), 1);
|
||||
assert_eq!(controller.queue.len(), 0, "Queue must be completely drained");
|
||||
assert!(controller.is_complete());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_animated_mode_pops_to_current_tween() {
|
||||
let mut queue = CommandQueue::new();
|
||||
queue.push(TurtleCommand::Move(Length::new(100.0)));
|
||||
queue.push(TurtleCommand::Move(Length::new(50.0)));
|
||||
|
||||
let mut controller = TweenController::new(
|
||||
queue,
|
||||
AnimationSpeed::Animated(100.0),
|
||||
);
|
||||
assert_eq!(controller.queue.len(), 2);
|
||||
assert!(!controller.is_complete());
|
||||
|
||||
let mut params = make_test_params();
|
||||
let mut filling = None;
|
||||
let mut commands = Vec::new();
|
||||
let mut svg_log = crate::state::SvgLog::default();
|
||||
|
||||
// Calling update should pop the first command into current_tween
|
||||
controller.update(0, &mut params, &mut filling, &mut commands, &mut svg_log);
|
||||
|
||||
assert_eq!(controller.queue.len(), 1, "First command must be popped into current_tween");
|
||||
let active = controller.current_tween().expect("Must have active tween");
|
||||
assert_eq!(active.progress, 0.0, "New tween must initialize progress to 0.0");
|
||||
assert!(!controller.is_complete());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_animated_mode_progress_advances_and_clamps() {
|
||||
let mut queue = CommandQueue::new();
|
||||
// Circle command with speed 10.0 and radius 100 => duration ~62.8s
|
||||
queue.push(TurtleCommand::Circle {
|
||||
radius: Length::new(100.0),
|
||||
angle: Degrees::new(360.0),
|
||||
steps: 36,
|
||||
direction: CircleDirection::Right,
|
||||
});
|
||||
|
||||
let mut controller = TweenController::new(
|
||||
queue,
|
||||
AnimationSpeed::Animated(10.0),
|
||||
);
|
||||
let mut params = make_test_params();
|
||||
let mut filling = None;
|
||||
let mut commands = Vec::new();
|
||||
let mut svg_log = crate::state::SvgLog::default();
|
||||
|
||||
// Frame 0: pops into current_tween with progress = 0.0
|
||||
controller.update(0, &mut params, &mut filling, &mut commands, &mut svg_log);
|
||||
let initial_progress = controller.current_tween().unwrap().progress;
|
||||
assert_eq!(initial_progress, 0.0);
|
||||
|
||||
// Advance time by sleeping briefly
|
||||
std::thread::sleep(std::time::Duration::from_millis(15));
|
||||
controller.update(0, &mut params, &mut filling, &mut commands, &mut svg_log);
|
||||
if let Some(tween) = controller.current_tween() {
|
||||
assert!(tween.progress >= 0.0 && tween.progress <= 1.0);
|
||||
assert!(tween.progress >= initial_progress);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user