Compare commits

48 Commits
Author SHA1 Message Date
dietrich d0c3f4c096 Merge pull request #7 from enaut/usability-ergonomics-fixes
Fix and improve svg export
2026-09-20 13:59:07 +02:00
dietrich 95b467a76f Remove Process-Wide Panic Hook in Headless SVG Export
Greptile flagged that
[`run_headless_svg_export`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/export.rs#L80)
was setting and taking the global, process-wide panic hook without
thread coordination. In concurrent environments (such as multi-threaded
test runners or multi-threaded host applications), this caused race
conditions that could clobber custom hooks, restore stale hooks, or
capture another thread's panic hook.

[export.rs](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/export.rs)

1. **Removed Global Panic Hook Manipulation**:
   - Removed `PanicHookFn` and `PanicHookGuard`.
   - Eliminated calls to `std::panic::take_hook()` and
     `std::panic::set_hook()`.
   - Removed `AtomicBool` and `Arc` overhead.
2. **Inspect Panic Payload Directly in
   [`run_headless_svg_export`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/export.rs#L80)**:
   - Evaluates user command closures inside `std::panic::catch_unwind`.
   - Downcasts caught panic payload to `&str` or `String` and checks for
     Macroquad assertion signatures (`THREAD_ID.is_some()` or
     `macroquad`).
   - Packages diagnostic guidance directly into
     `ExportError::Execution(...)` without emitting unsolicited messages
     to `eprintln!`.
3. **Banner in CLI Entrypoint
   [`handle_svg_export`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/export.rs#L137)**:
   - When executed via `--export-svg`, if `run_headless_svg_export`
     fails due to Macroquad window/GUI functions, formats and displays
     the friendly "Headless SVG Export Note" banner to `eprintln!`.
4. **Unit Tests**:
   - Added `test_headless_svg_export_success`: checks complete headless
     SVG export output.
   - Added `test_headless_svg_export_macroquad_panic`: verifies proper
     detection and error message for simulated Macroquad panics.
   - Added `test_headless_svg_export_custom_panic`: verifies that
     non-Macroquad panics retain their original error message.

---

- **Default tests**:
  ```bash
  cargo test --package turtle-lib
  ```
  Result: 26 unit tests passed, 34 doc-tests passed (0 failures).

- **SVG feature tests**:
  ```bash
  cargo test --package turtle-lib --features svg
  ```
  Result: 32 unit tests passed, 34 doc-tests passed (0 failures).

- **Clippy**:
  ```bash
  cargo clippy --package turtle-lib --features svg -- -Wclippy::pedantic
  \
  -Aclippy::cast_precision_loss -Aclippy::cast_sign_loss
  -Aclippy::cast_possible_truncation
  ```
  Result: 0 warnings, clean.

- **SVG export example**:
  ```bash
  cargo run --package turtle-lib --example test_svg_export --features
  svg -- --export-svg /tmp/test_output.svg
  ```
  Result: Successfully generated valid SVG file `/tmp/test_output.svg`.
2026-09-20 13:53:15 +02:00
dietrich b19919f8b7 CLI --export-svg Error Handling with pico-args
We resolved two issues when running turtle programs with `--export-svg`:
1. **Missing filename**: Previously, running `--export-svg` without a
   filename caused the CLI parser to ignore the flag and open the GUI
   window. Now, it emits `Error: --export-svg: option requires an
   argument` and terminates with exit code 1.
2. **Missing `svg` feature**: Previously, running `--export-svg` without
   the `svg` feature enabled ignored the flag if no filename was passed
   and opened the window. Now, any invocation of `--export-svg` without
   `--features svg` emits `Error: SVG export feature is not enabled.
   Please rebuild with --features svg` and terminates with exit code 1.

---

- Added [`pico-args = { version = "0.5", features = ["eq-separator"]
  }`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/Cargo.toml)
  under `[dependencies]`.
- In
  [`turtle-lib/src/export.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/export.rs):
  - Added `MissingFeature(String)` to `ExportError` for clean error
    display.
  - Implemented `parse_svg_export_from_args(args: &mut
    pico_args::Arguments) -> Result<Option<String>, pico_args::Error>`
    to parse `--export-svg <file>` and `--export-svg=<file>`, treating
    empty or flag arguments as missing values (`OptionWithoutAValue`).
  - Updated `parse_svg_export_arg() -> Option<String>` for backward
    compatibility.
  - In `handle_svg_export`:
    - Under `#[cfg(not(feature = "svg"))]`: detects any presence of
      `--export-svg`, outputs `Error: SVG export feature is not enabled.
      Please rebuild with --features svg`, and exits with code 1.
    - Under `#[cfg(feature = "svg")]`: handles missing arguments with
      `Error: --export-svg: option requires an argument` (exit 1), valid
      exports (exit 0), and leaves unknown arguments untouched so
      Macroquad or user flags continue to work.
  - Added unit tests covering all flag parsing variants and error
    displays.

- In
  [`turtle-lib-macros/src/lib.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib-macros/src/lib.rs):
  - Simplified the macro expansion in `turtle_main` to delegate directly
    to `turtle_lib::export::handle_svg_export(&mut build_commands);`.
  - Updated documentation comments to match.

---

```bash
cargo test --package turtle-lib
cargo test --package turtle-lib --features svg
cargo test --package turtle-lib-macros
cargo clippy --package turtle-lib -- -Wclippy::pedantic
-Aclippy::cast_precision_loss -Aclippy::cast_sign_loss
-Aclippy::cast_possible_truncation
cargo clippy --package turtle-lib --features svg -- -Wclippy::pedantic
-Aclippy::cast_precision_loss -Aclippy::cast_sign_loss
-Aclippy::cast_possible_truncation
cargo clippy --package turtle-lib-macros -- -Wclippy::pedantic
```
- **Unit tests**: All 29 unit tests in `turtle-lib` and 18 unit tests in
  `turtle-lib-macros` passed.
- **Doctests**: All 34 doctests passed.
- **Clippy**: Passed with zero warnings across all crates and feature
  configurations.

| Scenario | Command | Result | Exit Code | Window Opened? |
|---|---|---|---|---|
| Missing feature, missing filename | `cargo run --example hello_turtle
-- --export-svg` | `Error: SVG export feature is not enabled. Please
rebuild with --features svg` | 1 | No |
| Missing feature, with filename | `cargo run --example hello_turtle --
--export-svg test.svg` | `Error: SVG export feature is not enabled.
Please rebuild with --features svg` | 1 | No |
| Feature enabled, missing filename | `cargo run --example hello_turtle
--features svg -- --export-svg` | `Error: --export-svg: option requires
an argument` | 1 | No |
| Feature enabled, with filename | `cargo run --example hello_turtle
--features svg -- --export-svg /tmp/pico_test.svg` | `SVG exported
successfully to: /tmp/pico_test.svg` | 0 | No |
| Feature enabled, equals syntax | `cargo run --example hello_turtle
--features svg -- --export-svg=/tmp/pico_test_eq.svg` | `SVG exported
successfully to: /tmp/pico_test_eq.svg` | 0 | No |
| Feature enabled, extra flags | `cargo run --example hello_turtle
--features svg -- --export-svg /tmp/pico_test_extra.svg --custom-flag` |
`SVG exported successfully to: /tmp/pico_test_extra.svg` | 0 | No |
| Normal run (no flag) | `cargo run --example hello_turtle` |
Interactive window opens normally | 0 | Yes |
2026-09-20 12:36:15 +02:00
dietrich d855750970 Headless SVG Export Macroquad Panic Clarification
Clarified and improved error reporting when drawing routines invoke
Macroquad window or rendering functions (such as `screen_width()` or
`screen_height()`) during headless SVG export (`--export-svg`).

[export.rs](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/export.rs)
- Added `ExportError::Execution(String)` variant to represent drawing
  execution failures and panics.
- Implemented `std::fmt::Display` and `std::error::Error` for
  `ExportError`.
- Created `PanicHookGuard` RAII struct ensuring any installed panic
  hooks are automatically restored after execution.
- In `run_headless_svg_export`:
  - Chains onto the existing panic hook to preserve standard panic
    backtrace and line number information.
  - Detects if the panic originated from uninitialized Macroquad context
    (`THREAD_ID.is_some()`).
  - Emits a clear, prominent diagnostic banner explaining that
    window/GUI functions are unavailable in headless mode.
  - Catches the panic via `std::panic::catch_unwind` and returns
    `Err(ExportError::Execution(...))`.
  - In `handle_svg_export`, uses `{e}` (Display) rather than `{e:?}`
    (Debug) for cleaner error reporting.

[lib.rs](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib-macros/src/lib.rs)
- Updated the expanded `main` function generated by `#[turtle_main]` to
  format errors with `{}` (Display) instead of `{:?}` (Debug).

[sierpinski_triangle.rs](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/examples/sierpinski_triangle.rs)
- Added documentation notes to the module header and
  `sierpinski_triangle_auto` noting that the example requires an active
  graphics window and cannot be exported to SVG headlessly because it
  queries window dimensions.

---

Executed:
```bash
RUST_BACKTRACE=1 cargo run --example sierpinski_triangle --features svg
-- --export-svg sier.svg
```

Output:
```
thread 'main' (49702) panicked at
/home/dietrich/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/macroquad-0.4.16/src/lib.rs:172:13:
assertion failed: THREAD_ID.is_some()
stack backtrace:
0: __rustc::rust_begin_unwind
...
5: macroquad::window::screen_width
6: sierpinski_triangle::sierpinski_triangle_auto
at ./turtle-lib/examples/sierpinski_triangle.rs:100:20
7: sierpinski_triangle::draw_sierpinski
at ./turtle-lib/examples/sierpinski_triangle.rs:39:5
...
================================================================================
Headless SVG Export Note:
A Macroquad window/rendering function (e.g. `screen_width()`,
`screen_height()`,
or input check) was called while running in headless export mode.
Headless export does not initialize a graphics window. To resolve this:
  - Use relative turtle commands or fixed coordinates instead of window
    queries, or
  - Run the program in windowed GUI mode without the `--export-svg`
    flag.
    ================================================================================

Error exporting SVG: execution error: Drawing function called Macroquad
window/GUI functions (e.g. `screen_width()`, `screen_height()`) which
are unavailable in headless SVG export mode.
```
- Process exited cleanly with exit code 1.

Executed:
```bash
cargo run --example koch --features svg -- --export-svg /tmp/koch.svg
```
Output:
```
SVG exported successfully to: /tmp/koch.svg
```
Exit code 0.

```bash
cargo test --package turtle-lib
cargo clippy --package turtle-lib --features svg -- -Wclippy::pedantic
-Aclippy::cast_precision_loss -Aclippy::cast_sign_loss
-Aclippy::cast_possible_truncation
```
- 17 unit tests + 34 doctests passed (1 ignored doctest).
- Clippy completed with zero warnings.
2026-09-20 11:11:59 +02:00
dietrich 05c2654005 Fix: Svg export of arcs with negative angle were wrong
In `flower.rs`, several arc commands are drawn with negative angles (or
negative extents), for example:
- Right leaves: [`circle_right(RIGHT_LEAF_TOP_RADIUS,
  -RIGHT_LEAF_TOP_EXTENT,
  75)`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/examples/flower.rs#L94)
- Petals: [`circle_right(PETALS_SPACE_RADIUS, -PETALS_SPACE_EXTENT,
  30)`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/examples/flower.rs#L116)
- Petal finish: [`circle_left(PETALS_SIDE_RADIUS + 1.0, 3.0 -
  PETALS_SIDE_EXTENT,
  87)`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/examples/flower.rs#L124)

In SVG path arc syntax (`A rx ry x-axis-rotation large-arc-flag
sweep-flag x y`):
- `sweep-flag`: `1` specifies clockwise motion along the circle; `0`
  specifies counter-clockwise motion.
- `large-arc-flag`: `1` specifies an arc sweep $> 180^\circ$; `0`
  specifies $\le 180^\circ$.

In
[`turtle-lib/src/export_svg.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/export_svg.rs):
1. **Sweep direction**: `sweep` was mapped purely based on
   [`CircleDirection`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/circle_geometry.rs#L43)
   (`Right => 1`, `Left => 0`), without taking the sign of `angle` into
   account. Traveling a negative angle reverses the angular motion along
   the circle (e.g. `Right` with a negative angle moves
   counter-clockwise, needing `sweep = 0`). Because `sweep` was not
   flipped for negative angles, SVG drew the arc on the opposite side of
   the circle/chord, resulting in arcs bulging in the wrong direction
   across the leaves and flower petals.
2. **Large arc flag**: `large_arc` was computed using `angle.value() >
   180.0` rather than checking absolute magnitude `angle.value().abs() >
   180.0`.
   3. **Full circle check**: Full circle check compared `angle.value() -
   360.0` instead of `angle.value().abs() - 360.0`.

In
[`turtle-lib/src/export_svg.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/export_svg.rs#L116-L135):
- Determined `sweep` from both `direction` and `angle.value() >= 0.0`:
  ```rust
  let sweep = match (direction, angle.value() >= 0.0) {
  (CircleDirection::Right, true) | (CircleDirection::Left, false) => 1,
  (CircleDirection::Left, true) | (CircleDirection::Right, false) => 0,
  };
  ```
- Evaluated `large_arc` and full-circle checks using
  `angle.value().abs()`.
- Added a unit test
  [`test_svg_export_arc_negative_angle_sweep`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/export_svg.rs#L302-L340)
  to verify SVG arc sweep flags for negative angles.
2026-09-20 11:02:29 +02:00
dietrich 368497f97a Line caps on exported SVG lines and arcs are now configured to be round
(stroke-linecap="round"), matching the on-screen Lyon tessellation.

Summary of Changes
turtle-lib/src/export_svg.rs:
Added .set("stroke-linecap", "round") to SVG <line> elements.
Added .set("stroke-linecap", "round") to partial arc <path> elements.
Extracted SvgExporter::to_svg_document(&TurtleWorld) -> Document to
allow in-memory SVG inspection and testing.
Added unit tests verifying that both lines and arcs export with
stroke-linecap="round".

turtle-lib/src/state.rs:
Added #[allow(clippy::unused_self)] to SvgLog::clear for clean clippy
passes when compiling without the svg feature.
2026-09-20 10:33:38 +02:00
dietrich 401cc0945f Merge pull request #6 from enaut/usability-ergonomics-fixes
Usability ergonomics fixes
2026-09-19 22:05:21 +02:00
dietrich c4831967be Parameter Type Validation Helper: Added
validate_parameter_type(&syn::Type):

Verifies the type is a reference (syn::Type::Reference).
Ensures the reference is mutable (type_ref.mutability.is_some()),
reporting: #[turtle_main] parameter must be a mutable reference: '&mut
TurtlePlan'.
Checks that the target type (type_ref.elem) is a path whose trailing
identifier is TurtlePlan, allowing both &mut TurtlePlan and qualified
paths like &mut turtle_lib::TurtlePlan.
Rejects other types (like value: i32 or owned t: TurtlePlan), reporting:
Integration into validate_input: Called
validate_parameter_type(&pat_type.ty)?; directly within
syn::FnArg::Typed(pat_type).

Unit Tests Added:

test_valid_qualified_type: verifies &mut turtle_lib::TurtlePlan is
accepted.
test_rejects_wrong_type: verifies value: i32 is rejected with an
informative error.
test_rejects_immutable_reference: verifies t: &TurtlePlan is rejected.
test_rejects_owned_type: verifies t: TurtlePlan is rejected.
test_rejects_wrong_reference_type: verifies t: &mut i32 is rejected.
2026-09-19 20:55:00 +02:00
dietrich 99a51ef40e Fix Greptile review comment regarding parameter name preservation in
turtle-lib-macros

Summary of Changes
Preserve Parameter Pattern in Macro Expansion:

In
turtle-lib-macros/src/lib.rs
, updated the helper function generation when has_turtle_param is true:
rust

let param = &input_fn.sig.inputs[0];
quote! {
}
This retains the exact parameter pattern and identifier (e.g. t: &mut
TurtlePlan, mut t: &mut TurtlePlan, etc.) rather than replacing it with
turtle: &mut turtle_lib::TurtlePlan.
Preserves user function visibility (#fn_vis) for named non-main helper
functions.
Validation of Parameters:

In validate_input, explicitly reject self receivers (FnArg::Receiver)
with an informative error message.
Reject unsupported patterns (e.g., tuple destructuring or struct
patterns) during validation, only accepting identifier patterns
(syn::Pat::Ident) and wildcards (syn::Pat::Wild).
Macro Expansion Testing:

Factored macro expansion logic into turtle_main_impl(&args, input) ->
Result<proc_macro2::TokenStream, syn::Error> so macro expansions can be
parsed into syn::File and verified directly in unit tests.
Added unit tests verifying:
Expansion with custom parameter names like t: &mut TurtlePlan preserves
t
Expansion with mutable parameters like mut t: &mut TurtlePlan
Expansion when the function name is main renames to __turtle_main_draw
while preserving parameter t
Expansion for zero-argument functions generates parameter turtle
Rejection of &mut self
Rejection of unsupported destructuring patterns like (a, b)
Verification
cargo test --package turtle-lib-macros: All 13 tests passed.
cargo clippy --package turtle-lib-macros -- -Wclippy::pedantic: Passed
with 0 warnings.
cargo test --all-targets --all-features: All unit and doc tests across
the workspace passed.
cargo check --package turtle-lib --examples: All 30 examples compiled
cleanly.
2026-09-19 12:34:33 +02:00
dietrichandgreptile-apps[bot] a5261feca3 Update turtle-lib/src/general/fontsize.rs
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-09-19 11:58:17 +02:00
dietrich 68593ba64d Builder Pattern Trait Hierarchy Refactoring
We refactored the builder pattern in
[`turtle-lib`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib)
to eliminate inherent method asymmetry and organize all turtle
capabilities into six cohesive traits.

[`builders.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/builders.rs)

The legacy traits (`DirectionalMovement`, `Turnable`, `CurvedMovement`)
and orphaned inherent methods have been reorganized into six
domain-focused traits:

-
  **[`Movement`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/builders.rs#L14)**:
  - `forward()`
  - `backward()`
  - `go_to()`
  - `circle_left()`
  - `circle_right()`
-
  **[`Rotation`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/builders.rs#L191)**:
  - `left()`
  - `right()`
  - `set_heading()`
-
  **[`Pen`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/builders.rs#L279)**:
  - `pen_up()`
  - `pen_down()`
  - `set_pen_color()`
  - `set_pen_width()`
-
  **[`Fill`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/builders.rs#L393)**:
  - `begin_fill()`
  - `end_fill()`
  - `set_fill_color()`
-
  **[`Cursor`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/builders.rs#L481)**:
  - `hide()`
  - `show()`
  - `shape()`
  - `set_shape()`
  - `set_speed()`
  - `reset()`
-
  **[`Text`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/builders.rs#L646)**:
  - `write_text()`

[`TurtlePlan`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/builders.rs#L688)

`TurtlePlan`'s inherent methods are now strictly builder lifecycle
controls:
- `new() -> Self`
- `build(self) -> CommandQueue`

`TurtlePlan` implements `WithCommands`, `Movement`, `Rotation`, `Pen`,
`Fill`, `Cursor`, and `Text`.

-
  **[`lib.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/lib.rs#L62-L65)**:
  Re-exports `Cursor`, `Fill`, `Movement`, `Pen`, `Rotation`, `Text`,
  `TurtlePlan`, `WithCommands`.
- **Examples**: Updated
  [`clock.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/examples/clock.rs#L8),
  [`clock_threaded.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/examples/clock_threaded.rs#L9),
  [`dashed_circle.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/examples/dashed_circle.rs#L4),
  and
  [`bezier.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/examples/bezier.rs#L4)
  to use `use turtle_lib::*;`.
-
  **[`README.md`](file:///home/dietrich/Projekte/Source/turtlers/README.md#L344)**:
  Updated trait references in the architecture outline.

---

- **Unit & Doc Tests**:
  ```bash
  cargo test --package turtle-lib
  ```
  Result: 17 unit tests passed; 34 doc-tests passed (0 failed).
- **All Examples**:
  ```bash
  cargo check --package turtle-lib --examples
  ```
  Result: Successfully compiled all 30 examples.
- **Clippy**:
  ```bash
  cargo clippy --package turtle-lib -- -Wclippy::pedantic \
  -Aclippy::cast_precision_loss -Aclippy::cast_sign_loss
  -Aclippy::cast_possible_truncation
  ```
  Result: 0 warnings in `builders.rs`.
2026-09-19 11:45:57 +02:00
dietrich f117361950 Fix Timing Clocks Divergence
Fixed the timing clock divergence between
[tweening.rs](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/tweening.rs)
and
[drawing.rs](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/drawing.rs)
by delegating animation progress evaluation exclusively to
`TweenController`.

`current_time()` in `tweening.rs` was using a monotonic `Instant` epoch
(to support headless execution and unit tests without panicking on
missing Macroquad context), while `drawing.rs` was measuring progress
via `macroquad::time::get_time() - tween.start_time`. Because these two
clocks operate on completely different epochs, subtracting them caused
negative elapsed times, premature clamping, jitter, or animation lockup.

In
[`turtle-lib/src/tweening.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/tweening.rs):
- Added `pub(crate) progress: f32` to
  [`CommandTween`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/tweening.rs#L55-L68)
  to track the current evaluated, eased progress in `[0.0, 1.0]`.
- Made `start_time: f64` and `duration: f64` private to `tweening.rs` so
  outside modules cannot accidentally perform desynchronized clock
  arithmetic.
- Initialized `progress: 0.0` when constructing a new `CommandTween`.
- In `TweenController::update`, evaluated and clamped `tween.progress =
  tween.heading_tweener.move_to(elapsed).clamp(0.0, 1.0)`.

In
[`turtle-lib/src/drawing.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/drawing.rs):
- Removed `use tween::CubicInOut;` and duplicate easing calculations.
- Replaced `get_time() - tween.start_time` and `CubicInOut.tween(...)`
  in in-flight fill preview with `tween.progress`.
- Replaced `get_time() - tween.start_time` and `CubicInOut.tween(...)`
  in `draw_tween_arc` with `tween.progress`.
- Ensured `drawing.rs` has zero clock queries and strictly renders the
  state provided by `TweenController`.

In
[`turtle-lib/src/tweening.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/tweening.rs#L540-L579):
- Updated `test_animated_mode_pops_to_current_tween` to assert that
  `progress` initializes to `0.0`.
- Added `test_animated_mode_progress_advances_and_clamps` to verify that
  `progress` advances monotonically across frames and stays within
  `[0.0, 1.0]`.

---

```bash
cargo test --package turtle-lib --features svg
```
Output:
```text
running 17 tests
test circle_geometry::tests::test_circle_left_geometry ... ok
test circle_geometry::tests::test_circle_right_geometry ... ok
test command_behavior::tests::test_goto_duration_cartesian_inversion ...
ok
test
command_behavior::tests::test_set_heading_degrees_and_instant_duration
... ok
test execution::tests::test_forward_left_forward ... ok
test general::angle::tests::degrees_to_radians_roundtrip ... ok
test general::angle::tests::from_f64 ... ok
test general::angle::tests::from_integer ... ok
test general::angle::tests::negation ... ok
test general::fontsize::tests::font_size_conversions ... ok
test general::length::tests::test_length_conversions_and_negation ... ok
test general::tests::animation_speed_conversions ... ok
test tweening::tests::test_animated_mode_pops_to_current_tween ... ok
test tweening::tests::test_instant_mode_drains_queue ... ok
test
tweening::tests::test_instant_mode_respects_batch_limit_and_retains_pending
... ok
test tweening::tests::test_streaming_append_commands_does_not_accumulate
... ok
test tweening::tests::test_animated_mode_progress_advances_and_clamps
... ok

test result: ok. 17 passed; 0 failed; 0 ignored; 0 measured; 0 filtered
out; finished in 0.02s

Doc-tests turtle_lib: 34 passed; 0 failed; 1 ignored; 0 measured; 0
filtered out; finished in 0.26s
```

```bash
cargo clippy --package turtle-lib --features svg -- -Wclippy::pedantic \
-Aclippy::cast_precision_loss -Aclippy::cast_sign_loss
-Aclippy::cast_possible_truncation
```
Output:
```text
Finished `dev` profile [optimized + debuginfo] target(s) in 0.82s
```
**0 warnings.**

```bash
cargo run --example yinyang --features svg -- --export-svg
/tmp/yinyang_test.svg
```
Output:
```text
SVG exported successfully to: /tmp/yinyang_test.svg
```

```bash
cargo build --package turtle-lib --examples
```
Output:
```text
Finished `dev` profile [optimized + debuginfo] target(s) in 6.74s
```
**All 30 examples compiled cleanly with 0 errors and 0 warnings.**
2026-09-19 10:52:00 +02:00
dietrich f4357cb73d When running:
```bash
cargo run --example yinyang --features svg -- --export-svg yinyang.svg
```
the application crashed with:
```text
thread 'main' panicked at macroquad-0.4.16/src/lib.rs:172:13:
assertion failed: THREAD_ID.is_some()
```
along with 10 compiler dead-code warnings in `turtle-lib`.

1. **Headless Execution Path**: When `--export-svg` is provided,
   `turtle_main` runs `run_headless_svg_export` headlessly without
   creating a graphics window (`macroquad::Window::new` is bypassed).
2. **Speed Overwrite in Headless Mode**: `run_headless_svg_export`
   originally called `app.set_all_turtles_speed(Instant(1000))` and
   stepped animations with `while !app.is_complete() {
   app.step_animations(); }`. However, `yinyang.rs` contains
   `turtle.set_speed(100)` in its plan. When `TweenController` processed
   `SetSpeed(100)`, it switched to animated mode.
3. **Macroquad Context Assertion**: In animated mode,
   `TweenController::update` creates a `CommandTween` and called
   `current_time()`. In `turtle-lib/src/tweening.rs`, `current_time()`
   called `macroquad::time::get_time()`, which queried Macroquad's
   context (`get_context()`). Because no window was created, Macroquad
   asserted `THREAD_ID.is_some()` and panicked.
4. **Dead Code Warnings**: Types previously made `pub(crate)` had dead
   fields and obsolete helper methods that were never called internally
   or were superseded by `execution.rs`.

---

-
  **[`turtle-lib/src/export.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/export.rs)**:
  - Updated `run_headless_svg_export` to use `app.execute_immediate(0,
    turtle)` instead of queuing commands and stepping animations in a
    loop.
  - Headless SVG export now executes all commands synchronously in under
    0.1s regardless of any `set_speed` in the drawing plan.
-
  **[`turtle-lib/src/lib.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/lib.rs)**:
  - In `TurtleApp::execute_immediate`: ensured the turtle exists in
    `self.world` before executing.
  - Removed unused `pub(crate) fn world` and `pub(crate) fn world_mut`.
-
  **[`turtle-lib/src/tweening.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/tweening.rs)**:
  - Updated `current_time()` to use monotonic `std::time::Instant` on
    non-WASM targets (`#[cfg(not(target_arch = "wasm32"))]`) and
    `macroquad::time::get_time()` on WASM (`#[cfg(target_arch =
    "wasm32")]`).
  - Removed unused `turtle_id` field on `CommandTween`.

-
  **[`turtle-lib/src/circle_geometry.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/circle_geometry.rs)**:
  - Removed obsolete unused methods: `position_at_progress`,
    `angle_to_position`, `draw_arc_params`, and
    `draw_arc_params_partial`.
-
  **[`turtle-lib/src/commands_channel.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/commands_channel.rs)**:
  - Removed unused `turtle_id` field from `TurtleCommandReceiver`.
  - Removed unused methods `turtle_id`, `try_recv`, `is_empty`, and
    `len` from `TurtleCommandReceiver`.
-
  **[`turtle-lib/src/general.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/general.rs)**:
  - Removed unused `Visibility` type alias.
-
  **[`turtle-lib/src/state.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/state.rs)**
  &
  **[`turtle-lib/src/execution.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/execution.rs)**:
  - Removed unused `start_position` field on `FillState`.
  - Removed unused superseded methods on `Turtle`: `heading_angle`,
    `reset`, `begin_fill`, `record_fill_vertex`, `close_fill_contour`,
    `start_fill_contour`, `record_fill_vertices_for_arc`, `reset_fill`.
  - Removed unused `background_color` field and unused methods
    `get_turtle`, `reset_turtle`, `clear` from `TurtleWorld`.
  - Added `#[allow(clippy::struct_field_names)]` on `Turtle::turtle_id`.
-
  **[`turtle-lib/src/export_svg.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/export_svg.rs)**:
  - Moved `update_bounds` outside `export`.
  - Replaced `if angle.value() > 180.0 { 1 } else { 0 }` with
    `i32::from(angle.value() > 180.0)`.
  - Replaced `d.push_str(&format!(...))` with `write!(d, ...)`.
  - Inlined format arguments in `color_to_svg`.

---

```bash
cargo run --example yinyang --features svg -- --export-svg yinyang.svg
```
Output:
```text
Finished `dev` profile [optimized + debuginfo] target(s) in 0.07s
Running `target/debug/examples/yinyang --export-svg yinyang.svg`
SVG exported successfully to: yinyang.svg
```
Completed cleanly in **0.07s** with **0 compiler warnings** and **0
errors**.

Inspected `yinyang.svg`: contains all expected paths, outer arcs, inner
S-curve, and EvenOdd fill contours.

```bash
cargo test --package turtle-lib --features svg
```
Output:
```text
test result: ok. 16 passed; 0 failed; 0 ignored; 0 measured; 0 filtered
out; finished in 0.00s
test result: ok. 34 passed; 0 failed; 1 ignored; 0 measured; 0 filtered
out; finished in 0.25s
```

```bash
cargo clippy --package turtle-lib --features svg -- -Wclippy::pedantic \
-Aclippy::cast_precision_loss -Aclippy::cast_sign_loss
-Aclippy::cast_possible_truncation
```
Output:
```text
Checking turtle-lib v0.2.0
(/home/dietrich/Projekte/Source/turtlers/turtle-lib)
Finished `dev` profile [optimized + debuginfo] target(s) in 0.51s
```
**0 warnings.**
2026-09-19 09:24:10 +02:00
dietrich 823bf13c24 Usability & Ergonomics Improvements
All issues identified across Sections 3.1, 3.2, and 3.3 have been
resolved, verified with unit tests, automated compilation checks under
`-D float_literal_f32_fallback`, and headless SVG export runs.

-
  **[angle.rs](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/general/angle.rs)**:
  - Implemented `From<f64>` and `From<usize>` for `Degrees`.
  - Added unit tests `from_integer` and `from_f64` to verify conversion
    accuracy.
-
  **[fontsize.rs](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/general/fontsize.rs)**:
  - Implemented `From<f64>` for `FontSize`.
  - Refactored `FontSize::value(self)` to pass Copy type by value.
  - Added unit test `font_size_conversions`.
-
  **[general.rs](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/general.rs)**:
  - Implemented `From<f64>`, `From<i32>`, and `From<usize>` for
    `AnimationSpeed`.
  - Added unit test `animation_speed_conversions`.
  - Re-exported `macroquad` crate (`pub use macroquad;`) so downstream
    code and macro expansions have reliable direct access.

-
  **[export.rs](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/export.rs)**:
  - Made `parse_svg_export_arg()` public.
  - Implemented `run_headless_svg_export<F>(mut build_commands: F,
    filename: &str) -> Result<(), ExportError>` that executes commands
    using `app.step_animations()`, avoiding all window/GUI dependencies
    and never calling `std::process::exit`.
  - Updated `handle_svg_export` to delegate to
    `run_headless_svg_export`.
-
  **[lib.rs](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/lib.rs)**:
  - Extracted `pub fn step_animations(&mut self)` from `update(&mut
    self)`, allowing command queue draining and tween stepping
    headlessly without querying window mouse position or events.
-
  **[state.rs](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/state.rs)**:
  - Changed `TurtleWorld::new()` to initialize camera with
    `Camera2D::default()` instead of querying `screen_width()` /
    `screen_height()`, eliminating panics when running without a
    Macroquad window.
-
  **[turtle-lib-macros/src/lib.rs](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib-macros/src/lib.rs)**:
  - Added `validate_input` helper providing clean compile diagnostics
    with spans for:
    - Multiple arguments: `#[turtle_main] functions must take either 0
      arguments or a single &mut TurtlePlan`
    - Non-unit return types: `#[turtle_main] functions cannot have a
      return type`
    - Async functions: `#[turtle_main] functions cannot be async`
  - Replaced `#[macroquad::main]` wrapper expansion with a native `fn
    main()` that inspects CLI arguments first. If `--export-svg` is
    present, it runs `run_headless_svg_export` directly and returns
    cleanly without opening a window. Otherwise, it launches
    `macroquad::Window::new(#window_title, async { ... })`.
  - Added 5 unit tests in `turtle-lib-macros` testing signature
    validation.

-
  **[.vscode/launch.json](file:///home/dietrich/Projekte/Source/turtlers/.vscode/launch.json)**:
  - Removed stale references to nonexistent `turtle-example` and
    `turtle-ui`.
  - Added debug configurations for `turtle-lib` tests,
    `turtle-lib-macros` tests, `hello_turtle`, and `breadboard`.
-
  **[breadboard.rs](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/examples/breadboard.rs)**:
  - Refactored example to use `#[turtle_main("Breadboard")]`.
  - Removed `#[cfg(feature = "svg")]` and the early-exit message; the
    example now renders directly on screen by default and supports
    `--features svg -- --export-svg breadboard.svg`.
-
  **[README.md](file:///home/dietrich/Projekte/Source/turtlers/README.md)**:
  - Documented optional user-level `~/.cargo/config.toml` mold/lld
    fast-linking configuration under "Building and Running".

---

```bash
cargo test --workspace
```
- **Result**: 21 passed (16 in `turtle-lib`, 5 in `turtle-lib-macros`),
  34 doctests passed, 0 failed.

```bash
RUSTFLAGS="-D float_literal_f32_fallback" cargo check --workspace
--all-targets --all-features
RUSTFLAGS="-D float_literal_f32_fallback" cargo check --package
turtle-lib --examples --all-features
```
- **Result**: Passed with 0 errors and 0 fallback warnings across all
  workspace crates and all 30 examples.

```bash
cargo run --package turtle-lib --example hello_turtle --features svg --
--export-svg hello.svg
cargo run --package turtle-lib --example breadboard --features svg --
--export-svg breadboard.svg
```
- **Result**: Both exported SVG files successfully and exited with code
  0 without creating or flashing a graphical window.

```bash
cargo check --package turtle-lib --example breadboard
```
- **Result**: Compiled cleanly with 0 errors when SVG feature is
  disabled.
2026-09-19 08:19:11 +02:00
dietrich 76d07ab009 Consistency Refactoring
All 6 consistency review items have been resolved, verified with new
unit tests, automated test suites, and clean example builds.

- **Fixed `Goto` Duration Bug**: In
  [`turtle-lib/src/command_behavior.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/command_behavior.rs),
  mapped Cartesian target coordinates to screen space (`vec2(target.x,
  -target.y)`) before calculating $\Delta x$ and $\Delta y$ in
  `animation_duration`.
- **Unit Test**: Added `test_goto_duration_cartesian_inversion` testing
  that moving from screen $(0, 100)$ (Cartesian $(0, -100)$) to
  Cartesian $(0, 100)$ computes duration based on the actual 200px
  Euclidean distance ($2.0\text{s}$ at $100\text{ px/s}$).

- **Stored `Degrees`**: Updated `TurtleCommand::SetHeading(Degrees)` in
  [`turtle-lib/src/commands.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/commands.rs)
  to store user degrees directly ($0^\circ = \text{East}$, $90^\circ =
  \text{North}$), matching `Turn(Degrees)` and `Circle { angle: Degrees
  }`.
- **Deferred Screen-Space Conversion**: Converted to internal screen
  radians (`normalize_angle(-heading.as_radians().value())`) inside
  `apply_to_params` in
  [`turtle-lib/src/command_behavior.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/command_behavior.rs).
- **Instant Duration**: Maintained instant transition ($0.01\text{s}$
  minimum) in `animation_duration`.
- **Unit Test**: Added `test_set_heading_degrees_and_instant_duration`
  verifying $90^\circ$ maps to North ($-\frac{\pi}{2}$ in screen space),
  $0^\circ$ to East ($0$), $270^\circ$ to South ($+\frac{\pi}{2}$), and
  duration is $0.01\text{s}$.

- **Enhanced `Length` Type**: Expanded
  [`turtle-lib/src/general/length.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/general/length.rs)
  with `new()`, `value()`, `Neg`, `PartialOrd`, and conversion
  implementations (`From<f32>`, `From<f64>`, `From<i32>`, `From<i16>`,
  `From<usize>`).
- **Adopted Across API**:
  - `TurtleCommand::Move(Length)` in `commands.rs`.
  - `TurtleCommand::Circle { radius: Length, ... }` in `commands.rs`.
  - `forward<T: Into<Length>>` and `backward<T: Into<Length>>` in
    [`turtle-lib/src/builders.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/builders.rs).
  - `circle_left`, `circle_right` accept `radius: impl Into<Length>`.
  - Kept stroke attribute `pen_width` as `Precision` (`f32`).
- **Unit Test**: Added `test_length_conversions_and_negation` in
  `general::length`.

- **Removed Duplicate Alias**: Removed `all_animations_complete(&self)`
  from `TurtleApp` in
  [`turtle-lib/src/lib.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/lib.rs);
  updated call in
  [`turtle-lib/src/export.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/export.rs)
  to use canonical `is_complete(&self)`.
- **Corrected
  [`README.md`](file:///home/dietrich/Projekte/Source/turtlers/README.md)**:
  - `plan.goto(...)` $\rightarrow$ `plan.go_to(...)`
  - `plan.set_color(...)` $\rightarrow$ `plan.set_pen_color(...)`
  - `create_turtle()` $\rightarrow$ `create_turtle_plan()`
- **Corrected
  [`AGENTS.md`](file:///home/dietrich/Projekte/Source/turtlers/AGENTS.md)**:
  - `create_turtle()` $\rightarrow$ `create_turtle_plan()` in Threading
    Pattern.

- **Unified on `1000.0`**:
  - Updated
    [`README.md`](file:///home/dietrich/Projekte/Source/turtlers/README.md)
    lines 9, 108, 109 to state `speed >= 1000` is Instant and `speed <
    1000` is Animated.
  - Updated
    [`turtle-lib/examples/circle_test.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/examples/circle_test.rs)
    to use `turtle.set_speed(1000)`.

- **Renamed Examples**:
  - `turtle-lib/examples/stern.rs` $\rightarrow$
    [`turtle-lib/examples/star.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/examples/star.rs)
  - `turtle-lib/examples/nikolaus.rs` $\rightarrow$
    [`turtle-lib/examples/house_of_nikolaus.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/examples/house_of_nikolaus.rs)
- **Translated Functions & Parameters**:
  - `house_of_nikolaus.rs`: Translated `nikolausquadrat` $\rightarrow$
    `house_square`, `nikolausdiag` $\rightarrow$ `house_diagonal`,
    `nikolausdach2` $\rightarrow$ `house_roof`, parameter `groesse`
    $\rightarrow$ `size`. Added doc comment explaining the Eulerian path
    puzzle.
  - `breadboard.rs`: Translated `pin_reihe` $\rightarrow$ `pin_row`,
    `pin_spalte` $\rightarrow$ `pin_column`, `pin_seite` $\rightarrow$
    `pin_side`, parameter `anzahl` $\rightarrow$ `count`,
    `anzahl_reihen` $\rightarrow$ `row_count`. Added `#[cfg(feature =
    "svg")]` to helper functions to eliminate dead-code warnings when
    SVG feature is not enabled.
- **Translated UI Text & Print Messages**:
  - `"Drücke E für SVG-Export"` $\rightarrow$ `"Press E for SVG export"`
  - `"SVG exportiert nach test.svg"` $\rightarrow$ `"SVG exported to
    test.svg"`
  - `"Fehler beim Export: {:?}"` $\rightarrow$ `"Export error: {:?}"`
  - `"SVG-Export ist nicht aktiviert..."` $\rightarrow$ `"SVG export is
    not enabled..."`
  - Translated module doc in `export_svg.rs`.
- **Updated
  [`README.md`](file:///home/dietrich/Projekte/Source/turtlers/README.md)**
  example lists and CLI commands to reference `star` and
  `house_of_nikolaus`.

---

- `cargo test --package turtle-lib`:
  - 13 unit tests passed (including 3 new targeted unit tests)
  - 34 doctests passed (1 ignored internal helper)
- `cargo check --workspace --all-targets --all-features`: Passed with 0
  errors.
- `cargo check --examples --package turtle-lib --all-features`: All 30
  examples compiled cleanly with 0 errors.
- `cargo clippy --package turtle-lib -- -Wclippy::pedantic
  -Aclippy::cast_precision_loss -Aclippy::cast_sign_loss
  -Aclippy::cast_possible_truncation`: Passed with 0 errors.
2026-09-19 07:51:55 +02:00
dietrich c12cb7c3ee update agents.md 2026-09-18 21:50:09 +02:00
dietrich ea9a8839c4 Eliminate Per-Frame Allocation in Rendering Loop
We resolved **Issue 4.3 (Severe Per-Frame Allocation in Rendering
Loop)** by removing the intermediate `MeshData` structure and storing
`macroquad::prelude::Mesh` directly in
[`DrawCommand::Mesh`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/state.rs).

[`DrawCommand`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/state.rs)
- Dropped `Clone` and `Debug` derives from
  [`Turtle`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/state.rs#L58)
  and
  [`DrawCommand`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/state.rs#L351),
  accommodating Macroquad's `Mesh` which does not implement these
  traits.
- Changed
  [`DrawCommand::Mesh`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/state.rs#L353)
  to the tuple variant `DrawCommand::Mesh(macroquad::prelude::Mesh)`.
- Deleted `struct MeshData` and its `to_mesh` method.

[`tessellation.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/tessellation.rs)
- Renamed `build_mesh_data` to
  [`build_mesh`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/tessellation.rs#L35)
  returning `macroquad::prelude::Mesh { vertices, indices, texture: None
  }` directly.
- Updated
  [`tessellate_polygon`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/tessellation.rs#L71),
  [`tessellate_multi_contour`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/tessellation.rs#L116),
  [`tessellate_stroke`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/tessellation.rs#L214),
  [`tessellate_circle`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/tessellation.rs#L259),
  and
  [`tessellate_arc`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/tessellation.rs#L308)
  to return `Result<Mesh, Box<dyn std::error::Error>>`.

[`drawing.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/drawing.rs)
- In the main drawing loop
  ([`drawing.rs:36-38`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/drawing.rs#L36-L38)):
  ```rust
  DrawCommand::Mesh(mesh) => {
  draw_mesh(mesh);
  }
  ```
  Now borrows `&Mesh` directly from the command vector with **0 heap
  allocations and 0 vector clones per frame**.
- Updated fill preview ([line
  228](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/drawing.rs#L228)),
  tween arc/center indicators ([lines 309,
  329](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/drawing.rs#L309)),
  and turtle shape drawing ([line
  349](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/drawing.rs#L349))
  to pass `&mesh` directly to `draw_mesh(&mesh)` without vector cloning.

[`execution.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/execution.rs)
- Updated
  [`commands.push(DrawCommand::Mesh(mesh))`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/execution.rs#L135)
  and
  [`tessellate_command`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/execution.rs#L337).
- Updated
  [`test_forward_left_forward`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/execution.rs#L487)
  to instantiate `mut state` directly without dummy cloning.

---

```bash
cargo test --lib
```
Output:
```
running 10 tests
test circle_geometry::tests::test_circle_left_geometry ... ok
test circle_geometry::tests::test_circle_right_geometry ... ok
test general::angle::tests::degrees_to_radians_roundtrip ... ok
test general::angle::tests::from_integer ... ok
test execution::tests::test_forward_left_forward ... ok
test general::angle::tests::negation ... ok
test tweening::tests::test_animated_mode_pops_to_current_tween ... ok
test tweening::tests::test_instant_mode_drains_queue ... ok
test
tweening::tests::test_instant_mode_respects_batch_limit_and_retains_pending
... ok
test tweening::tests::test_streaming_append_commands_does_not_accumulate
... ok

test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered
out; finished in 0.00s
```

All targets verified with `cargo check --all-targets` and `cargo clippy
--lib`.
2026-09-18 21:34:07 +02:00
dietrich 1d0e149d22 Fix Unbounded Memory Growth (Memory Leak) in TweenController
In
[`TweenController`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/tweening.rs),
commands are currently accumulated in `queue: CommandQueue` (which wraps
a `Vec<TurtleCommand>`). When commands execute in `update()`, an
internal `cursor: usize` increments, but executed commands are never
removed. In streaming and threaded applications (such as
[`examples/clock_threaded.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/examples/clock_threaded.rs)),
new commands are appended continuously, causing `queue` to grow
unboundedly over time and exhaust memory.

We will replace `queue: CommandQueue` and `cursor: usize` with `queue:
std::collections::VecDeque<TurtleCommand>` inside `TweenController`.
Consumed commands will be removed immediately using `pop_front()`,
completely freeing memory as commands complete.

> [!NOTE]
> All design decisions were aligned during the `/grill-me` session:
  > - `TweenController` will internally store
  `std::collections::VecDeque<TurtleCommand>` and consume items via
  `pop_front()`.
  > - `self.cursor` is eliminated entirely.
  > - `CommandQueue` remains the public API container for passing
  batches of commands into `TweenController::new` and
  `TweenController::append_commands`.
  > - `TurtleCommand::Reset` retains its normal semantics (clearing
  drawings and parameters without clearing future queued commands), as
  continuous `pop_front()` already discards consumed commands.
  > - Standard `VecDeque` capacity behavior is maintained (no
  auto-shrinking) to prevent reallocation jitter in streaming scenarios.

[tweening.rs](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/tweening.rs)
- Import `std::collections::VecDeque`.
- Update `TweenController` struct definition:
  - Replace `queue: CommandQueue` and `cursor: usize` with `queue:
    VecDeque<TurtleCommand>`.
- In `TweenController::new`:
  - Initialize `queue` using `queue.into_iter().collect()`.
  - Remove `cursor` initialization.
- In `TweenController::append_commands`:
  - Extend `self.queue` directly from `new_queue: CommandQueue`.
- In `TweenController::update`:
  - **Instant mode**: Replace `while let Some(command) =
    self.queue.get(self.cursor).cloned()` with `while let Some(command)
    = self.queue.pop_front()`, removing `self.cursor += 1` and
    unnecessary command cloning.
  - **Animated mode**: Replace `if let Some(command) =
    self.queue.get(self.cursor).cloned()` with `if let Some(command) =
    self.queue.pop_front()`, removing `self.cursor += 1` and unnecessary
    command cloning.
- In `TweenController::is_complete`:
  - Update check to `self.current_tween.is_none() &&
    self.queue.is_empty()`.
- Add unit tests in `#[cfg(test)] mod tests`:
  - Verify queue drains to 0 length in Instant mode.
  - Verify queue drains in Animated mode with multiple frames.
  - Verify streaming `append_commands` calls continuously drain and do
    not accumulate.
  - Verify `is_complete()` transitions correctly.

---

[commands.rs](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/commands.rs)
- Update doc comments referencing `TweenController`'s cursor to reflect
  the queue consumption model.

- Run `cargo test -p turtle-lib --lib tweening` to execute the new unit
  tests.
- Run `cargo test -p turtle-lib` to ensure all existing tests in
  `turtle-lib` continue to pass.
- Run `cargo check --examples` to ensure all examples (including
  `clock_threaded.rs`) compile cleanly.

- Review memory behavior or run `examples/clock_threaded.rs` in debug to
  confirm streaming updates run smoothly.
2026-09-18 21:13:46 +02:00
dietrich 4752075d08 remove custom linker 2026-09-18 20:57:00 +02:00
Dietrich bc925ffe26 svg text size and orientation 2026-07-20 13:39:42 +02:00
dietrich 5e0e0e5be3 update the dependencies 2026-05-22 15:13:22 +02:00
dietrich 106dd79677 improve README 2026-05-21 22:51:19 +02:00
dietrich 89c778289c Merge pull request #5 from enaut/copilot/research-turtle-lib-architecture
Align turtle-lib API docs with actual speed/reset/coordinate/heading semantics
2026-05-21 22:17:42 +02:00
copilot-swe-agent[bot]andenaut 77ca219621 Refine SetHeading conversion wording
Agent-Logs-Url: https://github.com/enaut/turtlers/sessions/5fb2f5ed-163f-4c2d-8180-1e96150e7e46

Co-authored-by: enaut <290005+enaut@users.noreply.github.com>
2026-05-21 20:09:15 +00:00
copilot-swe-agent[bot]andenaut 2ac52ecc92 Fix turtle-lib API docs and invariant wording
Agent-Logs-Url: https://github.com/enaut/turtlers/sessions/5fb2f5ed-163f-4c2d-8180-1e96150e7e46

Co-authored-by: enaut <290005+enaut@users.noreply.github.com>
2026-05-21 20:08:40 +00:00
dietrich 59d6bc164e improve examples 2026-05-21 21:50:13 +02:00
dietrich ece26bfe04 create a svg log instead of caching the commands where they do not
belong...
2026-05-21 21:28:14 +02:00
dietrich 156301f272 remove dead code and translate german comments to english 2026-05-21 17:35:59 +02:00
dietrich 96b02f61be improve circle construction and rendering 2026-05-21 17:29:27 +02:00
dietrich 998cffdcbf make the drwing of the center of the circle a regular feature. 2026-05-21 15:05:23 +02:00
dietrich 6b558ca8a0 more consistent use of angle types 2026-05-21 15:02:57 +02:00
dietrich 3c076fdd03 improve command handling 2026-05-17 07:23:13 +02:00
dietrich 44046abe12 improve command handling 2026-05-16 16:03:09 +02:00
dietrich a7570911d8 reduce exposed internal functions and fields 2026-05-16 15:31:10 +02:00
dietrich cd589b2513 Merge pull request #4 from enaut/copilot/change-license-to-mit-or-apache2
Add dual MIT/Apache-2.0 license files
2026-01-16 19:43:28 +01:00
copilot-swe-agent[bot]andenaut 4ce176f44d Add LICENSE-MIT and LICENSE-APACHE files for dual licensing
Co-authored-by: enaut <290005+enaut@users.noreply.github.com>
2026-01-16 18:34:55 +00:00
copilot-swe-agent[bot] ef6e8e4c76 Initial plan 2026-01-16 18:31:28 +00:00
dietrich 8a4ed0bf32 avoid cfg svg in public macro 2026-01-02 14:47:21 +01:00
dietrich 402a8be205 additional feature flag to suppress a warning 2026-01-02 13:50:13 +01:00
dietrich 3820f20048 Merge pull request #2 from enaut/copilot/add-cmdline-parameters-svg-export
Add --export-svg CLI parameter to turtle_main macro
2026-01-02 12:09:03 +01:00
dietrich 6f29d97bb6 unify comandline parsing into function 2026-01-02 11:53:18 +01:00
dietrich cadc5a6798 fix cartesian axes example 2026-01-02 11:45:20 +01:00
copilot-swe-agent[bot]andenaut 9568bc10d9 Add comment explaining intentional code duplication in proc macro
Co-authored-by: enaut <290005+enaut@users.noreply.github.com>
2026-01-01 20:44:27 +00:00
copilot-swe-agent[bot]andenaut cbe249b9b7 Update README with CLI SVG export documentation and examples
Co-authored-by: enaut <290005+enaut@users.noreply.github.com>
2026-01-01 20:42:53 +00:00
copilot-swe-agent[bot]andenaut f5140361d5 Add documentation comment explaining magic number in instant speed
Co-authored-by: enaut <290005+enaut@users.noreply.github.com>
2026-01-01 20:41:52 +00:00
copilot-swe-agent[bot]andenaut c806570156 Implement CLI --export-svg parameter for instant SVG export
Co-authored-by: enaut <290005+enaut@users.noreply.github.com>
2026-01-01 20:40:06 +00:00
copilot-swe-agent[bot]andenaut d85a9c7d26 Add --export-svg CLI parameter support to turtle_main macro
Co-authored-by: enaut <290005+enaut@users.noreply.github.com>
2026-01-01 20:31:58 +00:00
copilot-swe-agent[bot] 2b64be29a8 Initial plan 2026-01-01 20:27:41 +00:00
41 changed files with 3451 additions and 2130 deletions
-3
View File
@@ -1,3 +0,0 @@
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=/usr/bin/mold"]
+39 -43
View File
@@ -1,7 +1,4 @@
{ {
// Verwendet IntelliSense zum Ermitteln möglicher Attribute.
// Zeigen Sie auf vorhandene Attribute, um die zugehörigen Beschreibungen anzuzeigen.
// Weitere Informationen finden Sie unter https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
@@ -26,58 +23,57 @@
{ {
"type": "lldb", "type": "lldb",
"request": "launch", "request": "launch",
"name": "Debug executable 'turtle-example'", "name": "Debug unit tests in 'turtle-lib-macros'",
"cargo": {
"args": [
"build",
"--bin=turtle-example",
"--package=turtle-example"
],
"filter": {
"name": "turtle-example",
"kind": "bin"
}
},
"args": [],
"cwd": "${workspaceFolder}"
},
{
"type": "lldb",
"request": "launch",
"name": "Debug unit tests in executable 'turtle-example'",
"cargo": {
"args": [
"test",
"--no-run",
"--bin=turtle-example",
"--package=turtle-example"
],
"filter": {
"name": "turtle-example",
"kind": "bin"
}
},
"args": [],
"cwd": "${workspaceFolder}"
},
{
"type": "lldb",
"request": "launch",
"name": "Debug unit tests in library 'turtle-ui'",
"cargo": { "cargo": {
"args": [ "args": [
"test", "test",
"--no-run", "--no-run",
"--lib", "--lib",
"--package=turtle-ui" "--package=turtle-lib-macros"
], ],
"filter": { "filter": {
"name": "turtle-ui", "name": "turtle-lib-macros",
"kind": "lib" "kind": "lib"
} }
}, },
"args": [], "args": [],
"cwd": "${workspaceFolder}" "cwd": "${workspaceFolder}"
},
{
"type": "lldb",
"request": "launch",
"name": "Debug example 'hello_turtle'",
"cargo": {
"args": [
"build",
"--example=hello_turtle",
"--package=turtle-lib"
],
"filter": {
"name": "hello_turtle",
"kind": "example"
}
},
"args": [],
"cwd": "${workspaceFolder}"
},
{
"type": "lldb",
"request": "launch",
"name": "Debug example 'breadboard'",
"cargo": {
"args": [
"build",
"--example=breadboard",
"--package=turtle-lib"
],
"filter": {
"name": "breadboard",
"kind": "example"
}
},
"args": [],
"cwd": "${workspaceFolder}"
} }
] ]
} }
+23 -22
View File
@@ -8,21 +8,23 @@ Rust workspace with turtle graphics implementations. **Primary focus: `turtle-li
``` ```
turtlers/ turtlers/
├── turtle-lib/ # MAIN LIBRARY - Macroquad + Lyon (focus here) ├── turtle-lib/ # MAIN LIBRARY - Macroquad + Lyon (focus here)
├── turtle-lib-macros/ # Proc macro for turtle_main │ └── examples/ # 30 examples including threading patterns
└── examples/ # 15+ examples including threading patterns └── turtle-lib-macros/ # Proc macro for turtle_main
``` ```
## Architecture (`turtle-lib`) ## Architecture (`turtle-lib`)
### Core Design Pattern: Persistent Controllers + Command Queues ### Core Design Pattern: Turtle Entities + Persistent Animation Controllers
- **Builder API** (`TurtlePlan`) accumulates commands into immutable `CommandQueue` - **Builder API** (`TurtlePlan`) accumulates commands into immutable `CommandQueue`
- **TurtleApp** maintains persistent `Vec<TweenController>` (one per turtle with embedded turtle_id) - **TurtleWorld** maintains persistent `Vec<Turtle>` (`world.turtles`), each encapsulating state and a `TweenController`
- **TweenController** manages command execution and animation state - **TweenController** manages command execution, queue consumption, and animation interpolation per turtle
- **Lyon Tessellation** converts all primitives to GPU meshes - **Lyon Tessellation** converts all primitives to GPU meshes
- **Multi-Turtle** support: Create multiple turtles with `add_turtle()` or threading channels - **Multi-Turtle** support: Create multiple turtles with `add_turtle()` or threading channels
### Key Architectural Decision: Turtle ID Storage ### Key Architectural Decision: Turtle Entity Encapsulation
**Critical**: After recent refactoring, `turtle_id` is now **stored in TweenController** (not derived from Vec index). This makes rendering robust when turtles/controllers are sparse or deleted. Each turtle in `TurtleWorld` is represented by a `Turtle` struct (`state.rs`) storing its own `turtle_id`, `TurtleParams`, fill state, tessellated drawing commands, SVG log, and an embedded `TweenController`.
- `turtle_id` is stored directly on `Turtle` (and passed to `TweenController::update` for logging and side effects); `TweenController` itself manages animation state without needing turtle identity.
- Rendering (`drawing.rs`) iterates through `world.turtles` sequentially, directly accessing each turtle's `tween_controller.current_tween()`, `commands`, and `filling`.
### Key Files ### Key Files
``` ```
@@ -31,7 +33,7 @@ src/
├── builders.rs - Fluent API traits (forward/right/circle/reset/etc) ├── builders.rs - Fluent API traits (forward/right/circle/reset/etc)
├── commands.rs - TurtleCommand enum (Move/Turn/Circle/Reset/etc) ├── commands.rs - TurtleCommand enum (Move/Turn/Circle/Reset/etc)
├── execution.rs - Command execution (immediate) + state updates ├── execution.rs - Command execution (immediate) + state updates
├── tweening.rs - Animation + tween interpolation (CommandTween embeds turtle_id) ├── tweening.rs - Animation + tween interpolation (TweenController per Turtle)
├── drawing.rs - Lyon mesh rendering with Macroquad ├── drawing.rs - Lyon mesh rendering with Macroquad
├── state.rs - Turtle, TurtleParams, TurtleWorld (persistent state) ├── state.rs - Turtle, TurtleParams, TurtleWorld (persistent state)
├── tessellation.rs - Lyon integration (polygons/strokes/fills/arcs) ├── tessellation.rs - Lyon integration (polygons/strokes/fills/arcs)
@@ -54,15 +56,15 @@ src/
- Example: Donut = outer circle (pen_down) → pen_up → inner circle → end_fill - Example: Donut = outer circle (pen_down) → pen_up → inner circle → end_fill
**3. Animation Modes**: **3. Animation Modes**:
- Speed `>= 999`: Instant mode (no tweening, executes immediately) - Speed `>= 1000`: Instant mode (no tweening, executes with `max(1, speed - 1000)` draw calls per frame)
- Speed `< 999`: Animated mode (tweens with CubicInOut easing, ~duration based on distance/speed) - Speed `< 1000`: Animated mode (tweens with CubicInOut easing, duration based on distance/speed)
- Dynamic switching via `SetSpeed` command mid-animation - Dynamic switching via `SetSpeed` command mid-animation
**4. Multi-Turtle Architecture**: **4. Multi-Turtle Architecture**:
- Each turtle owns a persistent `TweenController` with embedded `turtle_id` - Each turtle in `world.turtles` owns its persistent `TweenController`
- Rendering finds active tween by checking `controller.current_tween().turtle_id` (not Vec index) - Rendering directly inspects each turtle's active tween via `turtle.tween_controller.current_tween()` during sequential traversal of `world.turtles`
- Supports concurrent animation of multiple turtles - Supports concurrent animation of multiple turtles
- Example: Hangman uses `turtle_command_channel()` for blocking stdin on separate thread - Threading channels: `create_turtle_channel(buffer_size)` returns `TurtleCommandSender`, with the receiver managed internally by `TurtleApp`
**5. Threading Pattern** (for interactive apps like Hangman): **5. Threading Pattern** (for interactive apps like Hangman):
- `create_turtle_channel(buffer_size)` returns `TurtleCommandSender` (clonable, Send) - `create_turtle_channel(buffer_size)` returns `TurtleCommandSender` (clonable, Send)
@@ -122,7 +124,7 @@ let tx = turtle_tx.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
loop { loop {
let letter = get_input(); // Blocks let letter = get_input(); // Blocks
let mut plan = create_turtle(); let mut plan = create_turtle_plan();
plan.forward(50.0); plan.forward(50.0);
tx.send(plan.build()).ok(); tx.send(plan.build()).ok();
} }
@@ -191,11 +193,10 @@ RUST_LOG=turtle_lib=debug cargo run --example yinyang
- Preserves `turtle_id` after reset - Preserves `turtle_id` after reset
- Called via `execute_command()` in both instant and animated modes - Called via `execute_command()` in both instant and animated modes
### Turtle ID Robustness ### Turtle Entity and State Encapsulation
- **Before**: `turtle_id` derived from Vec index (fragile if controllers deleted) - Each `Turtle` struct owns its `turtle_id`, visual `params`, `filling` state, tessellated `commands`, `svg_log`, and `tween_controller`
- **After**: `turtle_id` embedded in `TweenController` and `CommandTween` - `TweenController` is responsible purely for command queue management and interpolation, decoupled from turtle identity
- Rendering finds active tween via `find_map(|c| c.current_tween())` → uses `tween.turtle_id` directly - Rendering iterates sequentially over `world.turtles`, accessing each turtle's `commands`, active tween (`turtle.tween_controller.current_tween()`), and live fill preview in place
- Safe for sparse/dynamic turtle creation
### Lyon Tessellation ### Lyon Tessellation
- All drawing → `tessellate_arc/stroke/circle/multi_contour` → `MeshData` → Macroquad `Mesh` - All drawing → `tessellate_arc/stroke/circle/multi_contour` → `MeshData` → Macroquad `Mesh`
@@ -208,13 +209,13 @@ RUST_LOG=turtle_lib=debug cargo run --example yinyang
### Main Dependencies ### Main Dependencies
- `macroquad = "0.4"` - Window/rendering framework - `macroquad = "0.4"` - Window/rendering framework
- `lyon = "1.0"` - Tessellation (fills, strokes, circles) - `lyon = "1.0"` - Tessellation (fills, strokes, circles)
- `tween = "2.1.0"` - Animation easing (CubicInOut) - `tween = "2.2.0"` - Animation easing (CubicInOut)
- `tracing = "0.1"` - Optional logging (zero cost when unused) - `tracing = "0.1"` - Optional logging (zero cost when unused)
- `crossbeam-channel` - Threading pattern support (if used) - `crossbeam = "0.8"` - Threading pattern support (channels)
## What NOT to Do ## What NOT to Do
- Don't derive `turtle_id` from Vec index for rendering (use embedded id) - Don't assume `TweenController` stores `turtle_id` or look up active tweens globally by ID (rendering iterates `world.turtles` and inspects `turtle.tween_controller` directly)
- Don't add `use macroquad::prelude::*` without explicit need (causes unused imports) - Don't add `use macroquad::prelude::*` without explicit need (causes unused imports)
- Don't manually triangulate—always use Lyon `tessellate_*` functions - Don't manually triangulate—always use Lyon `tessellate_*` functions
- Don't separate Forward/Backward—use negative `Move` values - Don't separate Forward/Backward—use negative `Move` values
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Turtle Graphics Library Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+64 -61
View File
@@ -2,15 +2,11 @@
A modern turtle graphics library for Rust built on [Macroquad](https://macroquad.rs/) with [Lyon](https://github.com/nical/lyon) for high-quality GPU-accelerated rendering. A modern turtle graphics library for Rust built on [Macroquad](https://macroquad.rs/) with [Lyon](https://github.com/nical/lyon) for high-quality GPU-accelerated rendering.
## Project Status
✅ **Stable** - Complete Lyon integration with multi-contour fill system and live animation preview.
## Features ## Features
- 🎨 **Simple Builder API**: Chain commands like `forward(100).right(90)` - 🎨 **Simple Builder API**: Chain commands like `forward(100).right(90)`
- ⚡ **Smooth Animations**: Tweening support with easing functions and live fill preview - ⚡ **Smooth Animations**: Tweening support with easing functions and live fill preview
- 🚀 **Instant Mode**: Execute commands immediately without animation (speed ≥ 999) - 🚀 **Instant Mode**: Execute commands immediately without animation (speed ≥ 1000)
- 🎯 **High-Quality Rendering**: Complete Lyon tessellation pipeline with GPU acceleration - 🎯 **High-Quality Rendering**: Complete Lyon tessellation pipeline with GPU acceleration
- 🫟 **Multi-Contour Fills**: Automatic hole detection with EvenOdd fill rule - draw cheese with holes! - 🫟 **Multi-Contour Fills**: Automatic hole detection with EvenOdd fill rule - draw cheese with holes!
- 📐 **Self-Intersecting Paths**: Stars, complex shapes - all handled correctly - 📐 **Self-Intersecting Paths**: Stars, complex shapes - all handled correctly
@@ -39,6 +35,7 @@ fn hello() {
} }
} }
``` ```
The turtle starts at the center of the window, facing right (0 degrees). The above code draws a blue square. The turtle starts at the center of the window, facing right (0 degrees). The above code draws a blue square.
The `turtle_main` macro sets up the Macroquad window, turtle initialization, and main loop for you. It expands to code similar to this: The `turtle_main` macro sets up the Macroquad window, turtle initialization, and main loop for you. It expands to code similar to this:
@@ -50,7 +47,7 @@ use turtle_lib::*;
#[macroquad::main("Turtle")] #[macroquad::main("Turtle")]
async fn main() { async fn main() {
// Create a turtle plan // Create a turtle plan
let mut plan = create_turtle(); let mut plan = create_turtle_plan();
// Set speed (part of the plan) // Set speed (part of the plan)
plan.set_speed(100); plan.set_speed(100);
@@ -77,7 +74,7 @@ async fn main() {
### Creating Plans ### Creating Plans
```rust ```rust
let mut plan = create_turtle(); let mut plan = create_turtle_plan();
// Movement // Movement
plan.forward(100); plan.forward(100);
@@ -102,14 +99,14 @@ plan.begin_fill();
plan.end_fill(); // Auto-closes and applies fill plan.end_fill(); // Auto-closes and applies fill
// Appearance // Appearance
plan.set_color(RED); plan.set_pen_color(RED);
plan.set_pen_width(5.0); plan.set_pen_width(5.0);
plan.hide(); plan.hide();
plan.show(); plan.show();
// Speed control (dynamic) // Speed control (dynamic)
plan.set_speed(100); // Animated mode (< 999) plan.set_speed(100); // Animated mode (< 1000)
plan.set_speed(1000); // Instant mode (>= 999) plan.set_speed(1000); // Instant mode (>= 1000)
// Turtle shapes // Turtle shapes
plan.shape(ShapeType::Triangle); plan.shape(ShapeType::Triangle);
@@ -134,12 +131,12 @@ plan.forward(100).right(90).forward(50);
Speed controlled via commands, allowing dynamic switching during execution: Speed controlled via commands, allowing dynamic switching during execution:
```rust ```rust
let mut plan = create_turtle(); let mut plan = create_turtle_plan();
// Fast initial positioning (instant mode) // Fast initial positioning (instant mode)
plan.set_speed(1000); plan.set_speed(1000);
plan.pen_up(); plan.pen_up();
plan.goto(vec2(-100.0, -100.0)); plan.go_to(vec2(-100.0, -100.0));
// Slow animated drawing // Slow animated drawing
plan.set_speed(50); plan.set_speed(50);
@@ -152,8 +149,9 @@ let app = TurtleApp::new().with_commands(plan.build());
``` ```
**Speed Modes:** **Speed Modes:**
- **Speed < 999**: Animated mode with smooth tweening
- **Speed >= 999**: Instant mode (no animation) the bigger the number the more segments will be added per frame. - **Speed < 1000**: Animated mode with smooth tweening
- **Speed >= 1000**: Instant mode (no animation) the bigger the number the more segments will be added per frame.
- Default speed is 100.0 if not specified - Default speed is 100.0 if not specified
## Debugging and Logging ## Debugging and Logging
@@ -195,13 +193,33 @@ Add the `svg` feature to enable SVG export functionality:
cargo run --example export_svg --features svg cargo run --example export_svg --features svg
``` ```
### Usage ### Command-Line SVG Export
When using the `turtle_main` macro with the `svg` feature enabled, you can export drawings directly to SVG files using the `--export-svg` command-line parameter:
```bash
# Export any example to SVG without showing the window
cargo run --example macro_demo --features svg -- --export-svg output.svg
# Works with all turtle_main-based examples
cargo run --example hello_turtle --features svg -- --export-svg square.svg
```
This will:
- Execute all drawing commands instantly (no animation)
- Export the result to an SVG file
- Exit immediately without opening a window
### Programmatic SVG Export
You can also export SVG programmatically from your code:
```rust ```rust
use turtle_lib::*; use turtle_lib::*;
// Create your drawing // Create your drawing
let mut plan = create_turtle(); let mut plan = create_turtle_plan();
plan.forward(100).right(90).forward(100); plan.forward(100).right(90).forward(100);
// Create app // Create app
@@ -228,17 +246,22 @@ The exported SVG can be opened in any web browser or vector graphics application
## Examples ## Examples
Run examples with: Run examples with:
```bash ```bash
cargo run --example square cargo run --example square
cargo run --example koch cargo run --example koch
cargo run --example shapes cargo run --example shapes
cargo run --example yinyang cargo run --example yinyang
cargo run --example stern cargo run --example star
cargo run --example nikolaus cargo run --example house_of_nikolaus
# SVG export example (requires --features svg) # SVG export example (requires --features svg)
cargo run --example export_svg --features svg cargo run --example export_svg --features svg
# Export any example to SVG using CLI parameter (requires --features svg)
cargo run --example macro_demo --features svg -- --export-svg output.svg
cargo run --example hello_turtle --features svg -- --export-svg square.svg
# Logging example - shows how to enable debug output # Logging example - shows how to enable debug output
cargo run --example logging_example cargo run --example logging_example
RUST_LOG=turtle_lib=debug cargo run --example logging_example RUST_LOG=turtle_lib=debug cargo run --example logging_example
@@ -247,13 +270,15 @@ RUST_LOG=turtle_lib=debug cargo run --example logging_example
### Available Examples ### Available Examples
#### Basic Drawing #### Basic Drawing
- **square.rs**: Basic square drawing - **square.rs**: Basic square drawing
- **koch.rs**: Koch snowflake fractal - **koch.rs**: Koch snowflake fractal
- **shapes.rs**: Demonstrates different turtle shapes - **shapes.rs**: Demonstrates different turtle shapes
- **stern.rs**: Star pattern drawing - **star.rs**: Star pattern drawing
- **nikolaus.rs**: Nikolaus (Santa) drawing - **house_of_nikolaus.rs**: House of Nikolaus (Eulerian path puzzle)
#### Fill Examples #### Fill Examples
- **yinyang.rs**: Yin-yang symbol with automatic hole detection - **yinyang.rs**: Yin-yang symbol with automatic hole detection
- **fill_demo.rs**: Donut shape with hole - **fill_demo.rs**: Donut shape with hole
- **fill_requirements.rs**: Circle with red fill - **fill_requirements.rs**: Circle with red fill
@@ -262,21 +287,17 @@ RUST_LOG=turtle_lib=debug cargo run --example logging_example
- **fill_instant_test.rs**: Quick fill test in instant mode - **fill_instant_test.rs**: Quick fill test in instant mode
#### Export #### Export
- **export_svg.rs**: Demonstrates SVG export functionality (requires `--features svg`) - **export_svg.rs**: Demonstrates SVG export functionality (requires `--features svg`)
#### Debugging #### Debugging
- **logging_example.rs**: Demonstrates how to enable and use tracing/logging output - **logging_example.rs**: Demonstrates how to enable and use tracing/logging output
## Why Lyon?
- Automatic hole detection via EvenOdd fill rule
- Handles any self-intersecting path
- GPU-accelerated rendering
- Standards-compliant (matches SVG, HTML Canvas)
### Basic Fill ### Basic Fill
```rust ```rust
let mut plan = create_turtle(); let mut plan = create_turtle_plan();
plan.set_fill_color(RED); plan.set_fill_color(RED);
plan.begin_fill(); plan.begin_fill();
@@ -290,6 +311,7 @@ plan.end_fill(); // Auto-closes and fills
``` ```
### Fill with Holes (Multi-Contour) ### Fill with Holes (Multi-Contour)
```rust ```rust
plan.set_fill_color(BLUE); plan.set_fill_color(BLUE);
plan.begin_fill(); plan.begin_fill();
@@ -299,7 +321,7 @@ plan.circle_left(90.0, 360.0, 72);
// pen_up() closes current contour // pen_up() closes current contour
plan.pen_up(); plan.pen_up();
plan.goto(vec2(0.0, -30.0)); plan.go_to(vec2(0.0, -30.0));
// pen_down() starts new contour // pen_down() starts new contour
plan.pen_down(); plan.pen_down();
@@ -310,14 +332,6 @@ plan.circle_left(30.0, 360.0, 36);
plan.end_fill(); // Auto-detects holes and fills correctly plan.end_fill(); // Auto-detects holes and fills correctly
``` ```
### Fill Features
- ✅ **Live Preview** - See fills progressively during animation
- ✅ **Auto-Close** - Automatically connects end point to start on `end_fill()`
- ✅ **Multi-Contour** - `pen_up()` closes contour, `pen_down()` opens next one
- ✅ **Automatic Hole Detection** - EvenOdd fill rule handles any complexity
- ✅ **Self-Intersecting Paths** - Stars and complex shapes work perfectly
## Architecture ## Architecture
### Module Structure ### Module Structure
@@ -327,7 +341,7 @@ turtle-lib/src/
├── lib.rs - Public API and TurtleApp ├── lib.rs - Public API and TurtleApp
├── state.rs - TurtleState and TurtleWorld ├── state.rs - TurtleState and TurtleWorld
├── commands.rs - TurtleCommand enum (consolidated commands) ├── commands.rs - TurtleCommand enum (consolidated commands)
├── builders.rs - Builder traits (DirectionalMovement, Turnable, etc.) ├── builders.rs - Builder traits (Movement, Rotation, Pen, Fill, Cursor, Text)
├── execution.rs - Command execution with fill support ├── execution.rs - Command execution with fill support
├── tweening.rs - Animation/tweening controller with dynamic speed ├── tweening.rs - Animation/tweening controller with dynamic speed
├── drawing.rs - Rendering with Lyon tessellation ├── drawing.rs - Rendering with Lyon tessellation
@@ -364,15 +378,26 @@ cargo build --release
cargo build --features svg cargo build --features svg
``` ```
### Optional: Faster Linker Setup
For significantly faster incremental build and linking times during development on Linux, you can optionally configure `mold` or `lld` in your user-level Cargo configuration (`~/.cargo/config.toml`):
```toml
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
```
## Development Status ## Development Status
### ✅ Completed ### ✅ Completed
- **Turtle movement** and rotation (consolidated Move/Turn commands) - **Turtle movement** and rotation (consolidated Move/Turn commands)
- **Pen control** (up/down) with contour management - **Pen control** (up/down) with contour management
- **Color** and **pen width** - **Color** and **pen width**
- **Circle arcs** (left/right with unified Circle command) - **Circle arcs** (left/right with unified Circle command)
- **Dynamic speed control** via SetSpeed commands - **Dynamic speed control** via SetSpeed commands
- **Instant mode** (speed ≥ 999) and animated mode (speed < 999) - **Instant mode** (speed ≥ 1000) and **animated mode** (speed < 1000)
- **Multi-contour fill** system with automatic hole detection - **Multi-contour fill** system with automatic hole detection
- **Lyon integration** for all drawing primitives - **Lyon integration** for all drawing primitives
- **Multiple turtle shapes** with custom shape support - **Multiple turtle shapes** with custom shape support
@@ -380,30 +405,8 @@ cargo build --features svg
- **EvenOdd fill rule** for complex self-intersecting paths - **EvenOdd fill rule** for complex self-intersecting paths
- **Live fill preview** during animation with progressive rendering - **Live fill preview** during animation with progressive rendering
- **Multi-contour support** - pen_up/pen_down manage contours - **Multi-contour support** - pen_up/pen_down manage contours
- **Command consolidation**
- **SVG Export** - Export drawings to SVG with viewBox and padding (feature-gated) - **SVG Export** - Export drawings to SVG with viewBox and padding (feature-gated)
## What's New
### Complete Lyon Migration ✨
All drawing operations now use GPU-accelerated Lyon tessellation:
- **Unified pipeline**: Lines, arcs, circles, and fills - all use the same high-quality rendering
- **Simplified codebase**: ~410 lines of code eliminated
- **Better performance**: GPU tessellation is faster than CPU-based primitives
- **Consistent quality**: No more mixed rendering approaches
### Multi-Contour Fill System 🕳️
Advanced fill capabilities with automatic hole detection:
- **EvenOdd fill rule**: Draw shapes with holes - works like SVG and HTML Canvas
- **Pen state management**: `pen_up()` closes contour, `pen_down()` opens next
- **Live preview**: See fills progressively during animations
- **Self-intersecting paths**: Stars, complex shapes - all handled correctly
### Architectural Improvements 🏗️
- **Command consolidation**: Unified Move/Turn/Circle commands (~250 lines eliminated)
- **Dynamic speed control**: Change speed during execution via commands
- **Live animation preview**: Progressive fill rendering during circle/arc drawing
## License ## License
MIT OR Apache-2.0 MIT OR Apache-2.0
+1 -1
View File
@@ -10,4 +10,4 @@ proc-macro = true
[dependencies] [dependencies]
proc-macro2 = "1.0" proc-macro2 = "1.0"
quote = "1.0" quote = "1.0"
syn = { version = "2.0", features = ["full"] } syn = { version = "3.0", features = ["full"] }
+470 -83
View File
@@ -6,7 +6,7 @@
use proc_macro::TokenStream; use proc_macro::TokenStream;
use quote::quote; use quote::quote;
use syn::{parse_macro_input, ItemFn}; use syn::ItemFn;
/// A convenience macro that wraps your turtle drawing code with the necessary /// A convenience macro that wraps your turtle drawing code with the necessary
/// boilerplate for running a turtle graphics program. /// boilerplate for running a turtle graphics program.
@@ -16,6 +16,14 @@ use syn::{parse_macro_input, ItemFn};
/// - Creates a turtle instance (`turtle`) /// - Creates a turtle instance (`turtle`)
/// - Sets up the `TurtleApp` with your drawing commands /// - Sets up the `TurtleApp` with your drawing commands
/// - Provides a main loop with rendering and quit handling (ESC or Q) /// - Provides a main loop with rendering and quit handling (ESC or Q)
/// - Adds command-line parameter support for SVG export (when `svg` feature is enabled)
///
/// # Command-Line Parameters
///
/// When the `svg` feature is enabled, the following command-line parameter is available:
///
/// * `--export-svg <filename>` - Exports the drawing to an SVG file and exits immediately
/// without opening the window. Example: `cargo run --features svg -- --export-svg output.svg`
/// ///
/// # Example /// # Example
/// ///
@@ -45,41 +53,149 @@ use syn::{parse_macro_input, ItemFn};
/// } /// }
/// ``` /// ```
/// ///
/// # SVG Export Example
///
/// ```bash
/// # Run with SVG export (requires svg feature)
/// cargo run --package turtle-lib --example macro_demo --features svg -- --export-svg output.svg
/// ```
///
/// This expands to approximately: /// This expands to approximately:
/// ///
/// ```ignore /// ```ignore
/// use macroquad::prelude::*;
/// use turtle_lib::*; /// use turtle_lib::*;
/// ///
/// #[macroquad::main("My Turtle Drawing")] /// fn main() {
/// async fn main() { /// let mut build_commands = |turtle: &mut turtle_lib::TurtlePlan| {
/// let mut turtle = create_turtle_plan(); /// my_drawing(turtle);
/// };
/// // Handle optional SVG export headlessly without opening a window
/// turtle_lib::export::handle_svg_export(&mut build_commands);
/// ///
/// // Your drawing code here /// // Normal interactive GUI mode with window
/// turtle.set_pen_color(RED); /// turtle_lib::macroquad::Window::new("My Turtle Drawing", async {
/// turtle.forward(100.0); /// let mut turtle = create_turtle_plan();
/// turtle.right(90.0); /// my_drawing(&mut turtle);
/// turtle.forward(100.0);
/// ///
/// let mut app = TurtleApp::new().with_commands(turtle.build()); /// let mut app = TurtleApp::new().with_commands(turtle.build());
/// ///
/// loop { /// loop {
/// clear_background(WHITE); /// turtle_lib::macroquad::prelude::clear_background(turtle_lib::macroquad::prelude::WHITE);
/// app.update(); /// app.update();
/// app.render(); /// 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; /// 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] #[proc_macro_attribute]
pub fn turtle_main(args: TokenStream, input: TokenStream) -> TokenStream { pub fn turtle_main(args: TokenStream, input: TokenStream) -> TokenStream {
let input_fn = parse_macro_input!(input as ItemFn); turtle_main_impl(&args.into(), input.into())
.unwrap_or_else(|err| err.to_compile_error())
.into()
}
fn turtle_main_impl(
args: &proc_macro2::TokenStream,
input: proc_macro2::TokenStream,
) -> Result<proc_macro2::TokenStream, syn::Error> {
let input_fn: ItemFn = syn::parse2(input)?;
// Validate function signature
validate_input(&input_fn)?;
// Parse the window title from args (default to "Turtle Graphics") // Parse the window title from args (default to "Turtle Graphics")
let window_title = if args.is_empty() { let window_title = if args.is_empty() {
@@ -93,83 +209,354 @@ pub fn turtle_main(args: TokenStream, input: TokenStream) -> TokenStream {
let fn_name = &input_fn.sig.ident; let fn_name = &input_fn.sig.ident;
let fn_block = &input_fn.block; let fn_block = &input_fn.block;
let fn_attrs = &input_fn.attrs;
// Check if the function has the expected signature let fn_vis = if fn_name == "main" {
None
} else {
Some(&input_fn.vis)
};
let has_turtle_param = input_fn.sig.inputs.len() == 1; let has_turtle_param = input_fn.sig.inputs.len() == 1;
let expanded = if has_turtle_param { let helper_name = if fn_name == "main" {
// Function takes a turtle parameter quote::format_ident!("__turtle_main_draw")
} else {
fn_name.clone()
};
let helper_fn = if has_turtle_param {
let param = &input_fn.sig.inputs[0];
quote! { quote! {
#[macroquad::main(#window_title)] #(#fn_attrs)*
async fn main() { #fn_vis fn #helper_name(#param) #fn_block
let mut turtle = turtle_lib::create_turtle_plan();
// Call the user's function with the turtle
#fn_name(&mut turtle);
let mut app = turtle_lib::TurtleApp::new()
.with_commands(turtle.build());
loop {
macroquad::prelude::clear_background(macroquad::prelude::WHITE);
app.update();
app.render();
macroquad::prelude::draw_text(
"Press ESC or Q to quit",
10.0,
40.0,
16.0,
macroquad::prelude::DARKGRAY
);
if macroquad::prelude::is_key_pressed(macroquad::prelude::KeyCode::Escape)
|| macroquad::prelude::is_key_pressed(macroquad::prelude::KeyCode::Q)
{
break;
}
macroquad::prelude::next_frame().await;
}
}
fn #fn_name(turtle: &mut turtle_lib::TurtlePlan) #fn_block
} }
} else { } else {
// Function takes no parameters - inline the code
quote! { quote! {
#[macroquad::main(#window_title)] #(#fn_attrs)*
async fn main() { #fn_vis fn #helper_name(turtle: &mut turtle_lib::TurtlePlan) {
let mut turtle = turtle_lib::create_turtle_plan(); let turtle = turtle;
// Inline the user's code
#fn_block #fn_block
let mut app = turtle_lib::TurtleApp::new()
.with_commands(turtle.build());
loop {
macroquad::prelude::clear_background(macroquad::prelude::WHITE);
app.update();
app.render();
macroquad::prelude::draw_text(
"Press ESC or Q to quit",
10.0,
40.0,
16.0,
macroquad::prelude::DARKGRAY
);
if macroquad::prelude::is_key_pressed(macroquad::prelude::KeyCode::Escape)
|| macroquad::prelude::is_key_pressed(macroquad::prelude::KeyCode::Q)
{
break;
}
macroquad::prelude::next_frame().await;
}
} }
} }
}; };
TokenStream::from(expanded) let expanded = quote! {
fn main() {
let mut build_commands = |turtle: &mut turtle_lib::TurtlePlan| {
#helper_name(turtle);
};
// If --export-svg flag is present, export headlessly without opening a window
turtle_lib::export::handle_svg_export(&mut build_commands);
// Normal rendering mode (interactive window)
turtle_lib::macroquad::Window::new(#window_title, async {
let mut turtle = turtle_lib::create_turtle_plan();
#helper_name(&mut turtle);
let mut app = turtle_lib::TurtleApp::new()
.with_commands(turtle.build());
loop {
turtle_lib::macroquad::prelude::clear_background(turtle_lib::macroquad::prelude::WHITE);
app.update();
app.render();
turtle_lib::macroquad::prelude::draw_text(
"Press ESC or Q to quit",
10.0,
40.0,
16.0,
turtle_lib::macroquad::prelude::DARKGRAY
);
if turtle_lib::macroquad::prelude::is_key_pressed(turtle_lib::macroquad::prelude::KeyCode::Escape)
|| turtle_lib::macroquad::prelude::is_key_pressed(turtle_lib::macroquad::prelude::KeyCode::Q)
{
break;
}
turtle_lib::macroquad::prelude::next_frame().await;
}
});
}
#helper_fn
};
Ok(expanded)
}
#[cfg(test)]
mod tests {
use super::*;
use syn::parse_quote;
#[test]
fn test_valid_zero_args() {
let input: ItemFn = parse_quote! {
fn my_draw() {
turtle.forward(100.0);
}
};
assert!(validate_input(&input).is_ok());
}
#[test]
fn test_valid_one_arg() {
let input: ItemFn = parse_quote! {
fn my_draw(t: &mut TurtlePlan) {
t.forward(100.0);
}
};
assert!(validate_input(&input).is_ok());
}
#[test]
fn test_valid_mut_arg() {
let input: ItemFn = parse_quote! {
fn my_draw(mut t: &mut TurtlePlan) {
t.forward(100.0);
}
};
assert!(validate_input(&input).is_ok());
}
#[test]
fn test_valid_wildcard_arg() {
let input: ItemFn = parse_quote! {
fn my_draw(_: &mut TurtlePlan) {}
};
assert!(validate_input(&input).is_ok());
}
#[test]
fn test_rejects_async() {
let input: ItemFn = parse_quote! {
async fn my_draw() {}
};
let err = validate_input(&input).unwrap_err();
assert!(err.to_string().contains("cannot be async"));
}
#[test]
fn test_rejects_multiple_args() {
let input: ItemFn = parse_quote! {
fn my_draw(t: &mut TurtlePlan, extra: i32) {}
};
let err = validate_input(&input).unwrap_err();
assert!(err.to_string().contains("must take either 0 arguments or a single"));
}
#[test]
fn test_rejects_self() {
let input: ItemFn = parse_quote! {
fn my_draw(&mut self) {}
};
let err = validate_input(&input).unwrap_err();
assert!(err.to_string().contains("cannot take a `self` parameter"));
}
#[test]
fn test_rejects_unsupported_pattern() {
let input: ItemFn = parse_quote! {
fn my_draw((a, b): &mut TurtlePlan) {}
};
let err = validate_input(&input).unwrap_err();
assert!(err.to_string().contains("unsupported parameter pattern"));
}
#[test]
fn test_rejects_return_type() {
let input: ItemFn = parse_quote! {
fn my_draw() -> i32 {
42
}
};
let err = validate_input(&input).unwrap_err();
assert!(err.to_string().contains("cannot have a return type"));
}
#[test]
fn test_valid_qualified_type() {
let input: ItemFn = parse_quote! {
fn my_draw(t: &mut turtle_lib::TurtlePlan) {}
};
assert!(validate_input(&input).is_ok());
}
#[test]
fn test_rejects_wrong_type() {
let input: ItemFn = parse_quote! {
fn my_draw(value: i32) {}
};
let err = validate_input(&input).unwrap_err();
assert!(err.to_string().contains("parameter must be of type `&mut TurtlePlan`"));
}
#[test]
fn test_rejects_immutable_reference() {
let input: ItemFn = parse_quote! {
fn my_draw(t: &TurtlePlan) {}
};
let err = validate_input(&input).unwrap_err();
assert!(err.to_string().contains("parameter must be a mutable reference: `&mut TurtlePlan`"));
}
#[test]
fn test_rejects_owned_type() {
let input: ItemFn = parse_quote! {
fn my_draw(t: TurtlePlan) {}
};
let err = validate_input(&input).unwrap_err();
assert!(err.to_string().contains("parameter must be of type `&mut TurtlePlan`"));
}
#[test]
fn test_rejects_wrong_reference_type() {
let input: ItemFn = parse_quote! {
fn my_draw(t: &mut i32) {}
};
let err = validate_input(&input).unwrap_err();
assert!(err.to_string().contains("expected reference to `TurtlePlan`"));
}
#[test]
fn test_expansion_preserves_custom_param_name() {
let input = quote! {
fn my_draw(t: &mut TurtlePlan) {
t.forward(100.0);
}
};
let output = turtle_main_impl(&quote!(), input).unwrap();
let file: syn::File = syn::parse2(output).unwrap();
let helper_fn = file
.items
.iter()
.find_map(|item| {
if let syn::Item::Fn(f) = item {
if f.sig.ident == "my_draw" {
return Some(f);
}
}
None
})
.expect("helper fn `my_draw` should exist");
let first_arg = helper_fn.sig.inputs.first().expect("should have 1 arg");
if let syn::FnArg::Typed(pat_type) = first_arg {
if let syn::Pat::Ident(pat_ident) = &*pat_type.pat {
assert_eq!(pat_ident.ident, "t");
} else {
panic!("expected ident pattern");
}
} else {
panic!("expected typed arg");
}
}
#[test]
fn test_expansion_preserves_mut_param() {
let input = quote! {
fn my_draw(mut t: &mut TurtlePlan) {
t.forward(100.0);
}
};
let output = turtle_main_impl(&quote!(), input).unwrap();
let file: syn::File = syn::parse2(output).unwrap();
let helper_fn = file
.items
.iter()
.find_map(|item| {
if let syn::Item::Fn(f) = item {
if f.sig.ident == "my_draw" {
return Some(f);
}
}
None
})
.expect("helper fn `my_draw` should exist");
let first_arg = helper_fn.sig.inputs.first().expect("should have 1 arg");
if let syn::FnArg::Typed(pat_type) = first_arg {
if let syn::Pat::Ident(pat_ident) = &*pat_type.pat {
assert_eq!(pat_ident.ident, "t");
assert!(pat_ident.mutability.is_some());
} else {
panic!("expected ident pattern");
}
} else {
panic!("expected typed arg");
}
}
#[test]
fn test_expansion_main_fn_renamed() {
let input = quote! {
fn main(t: &mut TurtlePlan) {
t.forward(100.0);
}
};
let output = turtle_main_impl(&quote!(), input).unwrap();
let file: syn::File = syn::parse2(output).unwrap();
let helper_fn = file
.items
.iter()
.find_map(|item| {
if let syn::Item::Fn(f) = item {
if f.sig.ident == "__turtle_main_draw" {
return Some(f);
}
}
None
})
.expect("helper fn `__turtle_main_draw` should exist");
let first_arg = helper_fn.sig.inputs.first().expect("should have 1 arg");
if let syn::FnArg::Typed(pat_type) = first_arg {
if let syn::Pat::Ident(pat_ident) = &*pat_type.pat {
assert_eq!(pat_ident.ident, "t");
} else {
panic!("expected ident pattern");
}
} else {
panic!("expected typed arg");
}
}
#[test]
fn test_expansion_zero_args() {
let input = quote! {
fn my_draw() {
turtle.forward(100.0);
}
};
let output = turtle_main_impl(&quote!(), input).unwrap();
let file: syn::File = syn::parse2(output).unwrap();
let helper_fn = file
.items
.iter()
.find_map(|item| {
if let syn::Item::Fn(f) = item {
if f.sig.ident == "my_draw" {
return Some(f);
}
}
None
})
.expect("helper fn `my_draw` should exist");
let first_arg = helper_fn.sig.inputs.first().expect("should have 1 arg");
if let syn::FnArg::Typed(pat_type) = first_arg {
if let syn::Pat::Ident(pat_ident) = &*pat_type.pat {
assert_eq!(pat_ident.ident, "turtle");
} else {
panic!("expected ident pattern");
}
} else {
panic!("expected typed arg");
}
}
} }
+4 -3
View File
@@ -6,7 +6,7 @@ license = "MIT OR Apache-2.0"
[dependencies] [dependencies]
macroquad = "0.4" macroquad = "0.4"
tween = "2.1.0" tween = "2.2.0"
lyon = "1.0" lyon = "1.0"
tracing = { version = "0.1", features = [ tracing = { version = "0.1", features = [
"log", "log",
@@ -14,16 +14,17 @@ tracing = { version = "0.1", features = [
], default-features = false } ], default-features = false }
turtle-lib-macros = { path = "../turtle-lib-macros" } turtle-lib-macros = { path = "../turtle-lib-macros" }
crossbeam = "0.8" crossbeam = "0.8"
pico-args = { version = "0.5", features = ["eq-separator"] }
[dev-dependencies] [dev-dependencies]
# For examples and testing # For examples and testing
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
dialog = "*" dialog = "0.3"
chrono = "0.4" chrono = "0.4"
[features] [features]
svg = ["dep:svg"] svg = ["dep:svg"]
[dependencies.svg] [dependencies.svg]
version = "0.13" version = "0.18"
optional = true optional = true
+1 -1
View File
@@ -1,7 +1,7 @@
//! Cubic Bézier curve example //! Cubic Bézier curve example
//! <https://en.wikipedia.org/wiki/B%C3%A9zier_curve> //! <https://en.wikipedia.org/wiki/B%C3%A9zier_curve>
use turtle_lib::{turtle_main, vec2}; use turtle_lib::*;
struct CubicBezier { struct CubicBezier {
point0: (f32, f32), point0: (f32, f32),
+73
View File
@@ -0,0 +1,73 @@
//! 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::*;
fn pin(t: &mut TurtlePlan, size: f32) {
t.left(90.0).forward(size / 2.0);
for _ in 0..5 {
t.right(90.0).forward(size);
}
t.right(90.0).forward(size / 2.0).left(90.0);
}
fn pin_row(t: &mut TurtlePlan, count: usize) {
for x in 0..count {
pin(t, 5.0);
if x < count - 1 {
t.forward(5.0);
}
}
}
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_row(t, 5);
}
}
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..count {
pin(t, 5.0);
if x < count - 1 {
t.forward(5.0);
}
}
}
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(row_count as f32 * 10.0 + 10.0)
.right(90.0)
.forward(170.0)
.right(90.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();
}
+4 -4
View File
@@ -80,10 +80,10 @@ fn draw(turtle: &mut TurtlePlan) {
// Draw and label 4 points (one per quadrant) // Draw and label 4 points (one per quadrant)
let points = vec![ let points = vec![
(vec2(120.0, 100.0), "A(2|1)"), (vec2(100.0, 50.0), "A(2|1)"),
(vec2(-120.0, 100.0), "B(-2|1)"), (vec2(-100.0, 50.0), "B(-2|1)"),
(vec2(-120.0, -100.0), "C(-2|-1)"), (vec2(-100.0, -50.0), "C(-2|-1)"),
(vec2(120.0, -100.0), "D(2|-1)"), (vec2(100.0, -50.0), "D(2|-1)"),
]; ];
for (position, label) in points { for (position, label) in points {
+1 -1
View File
@@ -10,7 +10,7 @@ fn draw(turtle: &mut TurtlePlan) {
turtle.set_pen_color(RED); turtle.set_pen_color(RED);
turtle.set_pen_width(0.5); turtle.set_pen_width(0.5);
turtle.left(90.0); turtle.left(90.0);
turtle.set_speed(999); turtle.set_speed(1000);
turtle.circle_left(100.0, 540.0, 72); // partial circle to the left turtle.circle_left(100.0, 540.0, 72); // partial circle to the left
turtle.begin_fill(); turtle.begin_fill();
+1 -1
View File
@@ -5,7 +5,7 @@
use chrono::{Local, Timelike}; use chrono::{Local, Timelike};
use macroquad::prelude::{clear_background, is_key_pressed, next_frame, KeyCode, WHITE}; use macroquad::prelude::{clear_background, is_key_pressed, next_frame, KeyCode, WHITE};
use turtle_lib::{create_turtle_plan, vec2, DirectionalMovement, Turnable, TurtleApp}; use turtle_lib::*;
#[macroquad::main("Clock")] #[macroquad::main("Clock")]
async fn main() { async fn main() {
+1 -1
View File
@@ -6,7 +6,7 @@
use chrono::{Local, Timelike}; use chrono::{Local, Timelike};
use macroquad::prelude::{clear_background, is_key_pressed, next_frame, KeyCode, WHITE}; use macroquad::prelude::{clear_background, is_key_pressed, next_frame, KeyCode, WHITE};
use turtle_lib::{create_turtle_plan, vec2, DirectionalMovement, Turnable, TurtleApp}; use turtle_lib::*;
#[macroquad::main("Clock (Threaded)")] #[macroquad::main("Clock (Threaded)")]
async fn main() { async fn main() {
+1 -1
View File
@@ -1,7 +1,7 @@
//! Dashed circle example ported from sunjay/turtle //! Dashed circle example ported from sunjay/turtle
//! This draws a dashed circle but uses `circle_left` arcs for each segment instead of individual short lines. //! This draws a dashed circle but uses `circle_left` arcs for each segment instead of individual short lines.
use turtle_lib::{turtle_main, vec2, CurvedMovement, Turnable}; use turtle_lib::*;
#[turtle_main("Dashed Circle")] #[turtle_main("Dashed Circle")]
fn draw(turtle: &mut TurtlePlan) { fn draw(turtle: &mut TurtlePlan) {
+12 -8
View File
@@ -1,5 +1,6 @@
//! Beispiel: Exportiere ein SVG aus einer einfachen Zeichnung //! Example: Export an SVG from a simple drawing
#[cfg(feature = "svg")]
use turtle_lib::*; use turtle_lib::*;
#[cfg(feature = "svg")] #[cfg(feature = "svg")]
@@ -8,6 +9,7 @@ async fn main() {
let mut plan = create_turtle_plan(); let mut plan = create_turtle_plan();
plan.forward(100.0) plan.forward(100.0)
.right(90.0) .right(90.0)
.set_speed(950.0)
.forward(100.0) .forward(100.0)
.set_pen_color(macroquad::color::GRAY) .set_pen_color(macroquad::color::GRAY)
.set_pen_width(8.0) .set_pen_width(8.0)
@@ -16,9 +18,11 @@ async fn main() {
.forward(100.0) .forward(100.0)
.right(90.0) .right(90.0)
.forward(200.0) .forward(200.0)
.end_fill() .end_fill();
.circle_left(200., 180., 24) for size in 0..9 {
.circle_left(90.0, 180.0, 36) plan.circle_left(200., 20., 6).write_text("a", size * 10);
}
plan.circle_left(90.0, 180.0, 36)
.begin_fill() .begin_fill()
.circle_left(90.0, 180.0, 36) .circle_left(90.0, 180.0, 36)
.circle_left(45.0, 180.0, 26) .circle_left(45.0, 180.0, 26)
@@ -48,12 +52,12 @@ async fn main() {
app.update(); app.update();
app.render(); app.render();
draw_text("Drücke E für SVG-Export", 20.0, 40.0, 32.0, BLACK); draw_text("Press E for SVG export", 20.0, 40.0, 32.0, BLACK);
if is_key_pressed(KeyCode::E) { if is_key_pressed(KeyCode::E) {
match app.export_drawing("test.svg", export::DrawingFormat::Svg) { match app.export_drawing("test.svg", export::DrawingFormat::Svg) {
Ok(_) => println!("SVG exportiert nach test.svg"), Ok(_) => println!("SVG exported to test.svg"),
Err(e) => println!("Fehler beim Export: {:?}", e), Err(e) => eprintln!("Export error: {:?}", e),
} }
} }
@@ -63,5 +67,5 @@ async fn main() {
#[cfg(not(feature = "svg"))] #[cfg(not(feature = "svg"))]
fn main() { fn main() {
println!("SVG-Export ist nicht aktiviert. Baue mit --features svg"); println!("SVG export is not enabled. Build with --features svg");
} }
+57
View File
@@ -0,0 +1,57 @@
//! House of Nikolaus example - draws the classic house figure (Eulerian path puzzle)
use turtle_lib::*;
fn house_square(turtle: &mut TurtlePlan, size: f32) {
turtle.forward(size);
turtle.left(90.0);
turtle.forward(size);
turtle.left(90.0);
turtle.forward(size);
turtle.left(90.0);
turtle.forward(size);
turtle.left(90.0);
}
fn house_diagonal(turtle: &mut TurtlePlan, size: f32) {
let square = size * size;
let diag = (square + square).sqrt();
turtle.left(45.0);
turtle.forward(diag);
turtle.left(45.0);
house_roof(turtle, size);
turtle.left(45.0);
turtle.forward(diag);
turtle.left(45.0);
}
fn house_roof(turtle: &mut TurtlePlan, size: f32) {
let square = size * size;
let diag = (square + square).sqrt();
turtle.left(45.0);
turtle.forward(diag / 2.0);
turtle.left(90.0);
turtle.forward(diag / 2.0);
turtle.left(45.0);
}
fn house_of_nikolaus(turtle: &mut TurtlePlan, size: f32) {
house_square(turtle, size);
house_diagonal(turtle, size);
}
#[turtle_main("House of Nikolaus")]
fn draw(turtle: &mut TurtlePlan) {
turtle.shape(ShapeType::Turtle);
// Position the turtle (pen up, move, pen down)
turtle.pen_up();
turtle.backward(80.0);
turtle.left(90.0);
turtle.backward(50.0);
turtle.right(90.0);
turtle.pen_down();
house_of_nikolaus(turtle, 100.0);
}
+2 -2
View File
@@ -20,7 +20,7 @@ fn koch(depth: u32, turtle: &mut TurtlePlan, distance: f32) {
#[turtle_main("Koch Snowflake")] #[turtle_main("Koch Snowflake")]
fn draw(turtle: &mut TurtlePlan) { fn draw(turtle: &mut TurtlePlan) {
// Position turtle // Position turtle
turtle.set_speed(1001); turtle.set_speed(5000);
turtle.pen_up(); turtle.pen_up();
turtle.backward(150.0); turtle.backward(150.0);
@@ -28,7 +28,7 @@ fn draw(turtle: &mut TurtlePlan) {
// Draw Koch snowflake (triangle of Koch curves) // Draw Koch snowflake (triangle of Koch curves)
for _ in 0..3 { for _ in 0..3 {
koch(4, turtle, 300.0); koch(6, turtle, 300.0);
turtle.right(120.0); turtle.right(120.0);
turtle.set_speed(1200); turtle.set_speed(1200);
} }
-57
View File
@@ -1,57 +0,0 @@
//! Nikolaus example - draws a house-like figure
use turtle_lib::*;
fn nikolausquadrat(turtle: &mut TurtlePlan, groesse: f32) {
turtle.forward(groesse);
turtle.left(90.0);
turtle.forward(groesse);
turtle.left(90.0);
turtle.forward(groesse);
turtle.left(90.0);
turtle.forward(groesse);
turtle.left(90.0);
}
fn nikolausdiag(turtle: &mut TurtlePlan, groesse: f32) {
let quadrat = groesse * groesse;
let diag = (quadrat + quadrat).sqrt();
turtle.left(45.0);
turtle.forward(diag);
turtle.left(45.0);
nikolausdach2(turtle, groesse);
turtle.left(45.0);
turtle.forward(diag);
turtle.left(45.0);
}
fn nikolausdach2(turtle: &mut TurtlePlan, groesse: f32) {
let quadrat = groesse * groesse;
let diag = (quadrat + quadrat).sqrt();
turtle.left(45.0);
turtle.forward(diag / 2.0);
turtle.left(90.0);
turtle.forward(diag / 2.0);
turtle.left(45.0);
}
fn nikolaus(turtle: &mut TurtlePlan, groesse: f32) {
nikolausquadrat(turtle, groesse);
nikolausdiag(turtle, groesse);
}
#[turtle_main("Nikolaus")]
fn draw(turtle: &mut TurtlePlan) {
turtle.shape(ShapeType::Turtle);
// Position the turtle (pen up, move, pen down)
turtle.pen_up();
turtle.backward(80.0);
turtle.left(90.0);
turtle.backward(50.0);
turtle.right(90.0);
turtle.pen_down();
nikolaus(turtle, 100.0);
}
@@ -17,6 +17,10 @@
//! That is relatively easy to implement, as long as you follow these steps and let recursion do //! That is relatively easy to implement, as long as you follow these steps and let recursion do
//! the rest. Another little bonus this example provides is the ability to customize the drawing //! the rest. Another little bonus this example provides is the ability to customize the drawing
//! size: the triangle will stay correctly sized and positioned automatically. //! size: the triangle will stay correctly sized and positioned automatically.
//!
//! Note: This example queries `screen_width()` and `screen_height()` to calculate positioning
//! relative to the window. Consequently, it requires an active graphics window and does not
//! support headless SVG export (`--export-svg`).
use macroquad::window::{screen_height, screen_width}; use macroquad::window::{screen_height, screen_width};
use turtle_lib::*; use turtle_lib::*;
@@ -86,6 +90,9 @@ fn sierpinski_triangle(turtle: &mut TurtlePlan, level: u8, size: f32) {
/// `level` is still required, it can't be computed automatically. However, given the used /// `level` is still required, it can't be computed automatically. However, given the used
/// canvas size, it will compute the appropriate size and start point so the triangle gets /// canvas size, it will compute the appropriate size and start point so the triangle gets
/// centered and occupies as much drawing space as possible while staying in bounds. /// centered and occupies as much drawing space as possible while staying in bounds.
///
/// Note: Because this function queries window dimensions, it requires an active graphics
/// window and cannot be run in headless SVG export mode.
fn sierpinski_triangle_auto(turtle: &mut TurtlePlan, level: u8) { fn sierpinski_triangle_auto(turtle: &mut TurtlePlan, level: u8) {
let size = TRIANGLE_SIZE; let size = TRIANGLE_SIZE;
@@ -9,11 +9,11 @@ fn draw(turtle: &mut TurtlePlan) {
turtle.set_pen_width(0.5); turtle.set_pen_width(0.5);
// Draw a 5-pointed star pattern repeatedly // Draw a 5-pointed star pattern repeatedly
for _i in 0..50000 { for _i in 0..500 {
turtle.forward(200.0); turtle.forward(200.0);
turtle.circle_left(10.0, 72.0, 1000); turtle.circle_left(10.0, 72.0, 100);
turtle.circle_right(5.0, 360.0, 1000); turtle.circle_right(5.0, 360.0, 100);
turtle.circle_left(10.0, 72.0, 1000); turtle.circle_left(10.0, 72.0, 100);
} }
// Set animation speed // Set animation speed
+36
View File
@@ -0,0 +1,36 @@
//! Test example for CLI SVG export feature
//!
//! Run this with: cargo run --package turtle-lib --example test_svg_export --features svg -- --export-svg test_output.svg
use turtle_lib::*;
#[turtle_main("SVG Export Test")]
fn draw_test(turtle: &mut TurtlePlan) {
turtle.set_pen_color(RED);
turtle.set_pen_width(3.0);
// Draw a square
for _ in 0..4 {
turtle.forward(100.0);
turtle.right(90.0);
}
// Draw a circle
turtle.set_pen_color(BLUE);
turtle.pen_up();
turtle.forward(150.0);
turtle.pen_down();
turtle.circle_left(50.0, 360.0, 36);
// Draw a filled triangle
turtle.set_fill_color(GREEN);
turtle.pen_up();
turtle.go_to(vec2(-50.0, 100.0));
turtle.pen_down();
turtle.begin_fill();
for _ in 0..3 {
turtle.forward(80.0);
turtle.right(120.0);
}
turtle.end_fill();
}
+2 -1
View File
@@ -23,5 +23,6 @@ fn draw(turtle: &mut TurtlePlan) {
.left(90.0) .left(90.0)
.pen_down() .pen_down()
.circle_right(8.0, 360.0, 12) .circle_right(8.0, 360.0, 12)
.end_fill(); .end_fill()
.hide();
} }
+537 -515
View File
File diff suppressed because it is too large Load Diff
+51 -79
View File
@@ -1,7 +1,43 @@
//! Circle geometry calculations - single source of truth for `circle_left` and `circle_right` //! Circle geometry calculations - single source of truth for `circle_left` and `circle_right`
use crate::general::Radians;
use macroquad::prelude::*; use macroquad::prelude::*;
/// Generate evenly-spaced points along a circular arc.
///
/// Returns exactly `steps` points, uniformly distributed from (not including)
/// the arc start to (including) the arc end. This is the **single source of
/// truth** for arc sampling used by tessellation, tween stroke drawing, and
/// fill-polygon preview.
///
/// # Arguments
/// * `center` — centre of the circle
/// * `radius` — arc radius
/// * `start_angle` — angle from `center` to the turtle's start position (radians)
/// * `sweep_angle` — total arc sweep in radians (absolute; sign comes from `direction`)
/// * `steps` — number of sample points (clamped to ≥ 1)
/// * `direction` — which way the arc curves
pub(crate) fn arc_points(
center: Vec2,
radius: f32,
start_angle: f32,
sweep_angle: f32,
steps: usize,
direction: CircleDirection,
) -> Vec<Vec2> {
let n = steps.max(1);
let step_size = sweep_angle / n as f32;
(1..=n)
.map(|i| {
let a = match direction {
CircleDirection::Left => start_angle - step_size * i as f32,
CircleDirection::Right => start_angle + step_size * i as f32,
};
Vec2::new(center.x + radius * a.cos(), center.y + radius * a.sin())
})
.collect()
}
/// Direction of circular motion (in screen coordinates with Y-down) /// Direction of circular motion (in screen coordinates with Y-down)
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CircleDirection { pub enum CircleDirection {
@@ -10,11 +46,11 @@ pub enum CircleDirection {
} }
/// Encapsulates all geometry for a circular arc /// Encapsulates all geometry for a circular arc
pub struct CircleGeometry { pub(crate) struct CircleGeometry {
pub center: Vec2, pub(crate) center: Vec2,
pub radius: f32, pub(crate) radius: f32,
pub start_angle_from_center: f32, // radians pub(crate) start_angle_from_center: f32, // radians
pub direction: CircleDirection, pub(crate) direction: CircleDirection,
} }
impl CircleGeometry { impl CircleGeometry {
@@ -22,12 +58,15 @@ impl CircleGeometry {
#[must_use] #[must_use]
pub fn new( pub fn new(
turtle_pos: Vec2, turtle_pos: Vec2,
turtle_heading: f32, turtle_heading: Radians,
radius: f32, radius: f32,
direction: CircleDirection, direction: CircleDirection,
) -> Self { ) -> Self {
use std::f32::consts::FRAC_PI_2; use std::f32::consts::FRAC_PI_2;
// Extract raw f32 once — all arithmetic below is in radians
let heading = turtle_heading.value();
// Calculate center based on direction // Calculate center based on direction
// In screen coordinates (Y-down): // In screen coordinates (Y-down):
// - Left turn (counter-clockwise visually): center is perpendicular-left from turtle's perspective // - Left turn (counter-clockwise visually): center is perpendicular-left from turtle's perspective
@@ -35,8 +74,8 @@ impl CircleGeometry {
// - Right turn (clockwise visually): center is perpendicular-right from turtle's perspective // - Right turn (clockwise visually): center is perpendicular-right from turtle's perspective
// which is heading + π/2 (rotated counter-clockwise from heading vector) // which is heading + π/2 (rotated counter-clockwise from heading vector)
let center_offset_angle = match direction { let center_offset_angle = match direction {
CircleDirection::Left => turtle_heading - FRAC_PI_2, CircleDirection::Left => heading - FRAC_PI_2,
CircleDirection::Right => turtle_heading + FRAC_PI_2, CircleDirection::Right => heading + FRAC_PI_2,
}; };
let center = vec2( let center = vec2(
@@ -46,8 +85,8 @@ impl CircleGeometry {
// Angle from center back to turtle position // Angle from center back to turtle position
let start_angle_from_center = match direction { let start_angle_from_center = match direction {
CircleDirection::Left => turtle_heading + FRAC_PI_2, CircleDirection::Left => heading + FRAC_PI_2,
CircleDirection::Right => turtle_heading - FRAC_PI_2, CircleDirection::Right => heading - FRAC_PI_2,
}; };
Self { Self {
@@ -72,74 +111,7 @@ impl CircleGeometry {
) )
} }
/// Calculate position at a given progress (0.0 to 1.0) through `total_angle`
#[must_use]
pub fn position_at_progress(&self, total_angle: f32, progress: f32) -> Vec2 {
let angle_traveled = total_angle * progress;
self.position_at_angle(angle_traveled)
}
/// Get the angle traveled from start position to a given position
#[must_use]
pub fn angle_to_position(&self, position: Vec2) -> f32 {
let displacement = position - self.center;
let current_angle = displacement.y.atan2(displacement.x);
let mut angle_diff = match self.direction {
CircleDirection::Left => self.start_angle_from_center - current_angle,
CircleDirection::Right => current_angle - self.start_angle_from_center,
};
// Normalize to [0, 2π)
if angle_diff < 0.0 {
angle_diff += 2.0 * std::f32::consts::PI;
}
angle_diff
}
/// Get `draw_arc` parameters for the full arc
/// Returns (`rotation_degrees`, `arc_degrees`) for macroquad's `draw_arc`
#[must_use]
pub fn draw_arc_params(&self, total_angle_degrees: f32) -> (f32, f32) {
match self.direction {
CircleDirection::Left => {
// For left (counter-clockwise), we need to draw counter-clockwise from end back to start
// so we start at (start - total_angle) and draw total_angle counter-clockwise
let end_angle = self.start_angle_from_center - total_angle_degrees.to_radians();
(end_angle.to_degrees(), total_angle_degrees)
}
CircleDirection::Right => {
// For right (clockwise), draw from start
(
self.start_angle_from_center.to_degrees(),
total_angle_degrees,
)
}
}
}
/// Get `draw_arc` parameters for a partial arc (during tweening)
/// Returns (`rotation_degrees`, `arc_degrees`) for macroquad's `draw_arc`
#[must_use]
pub fn draw_arc_params_partial(&self, angle_traveled: f32) -> (f32, f32) {
let angle_traveled_degrees = angle_traveled.to_degrees();
match self.direction {
CircleDirection::Left => {
// Draw from current position backwards (counter-clockwise) to start
let current_angle = self.start_angle_from_center - angle_traveled;
(current_angle.to_degrees(), angle_traveled_degrees)
}
CircleDirection::Right => {
// Draw from start, counter-clockwise
(
self.start_angle_from_center.to_degrees(),
angle_traveled_degrees,
)
}
}
}
} }
#[cfg(test)] #[cfg(test)]
@@ -151,7 +123,7 @@ mod tests {
fn test_circle_left_geometry() { fn test_circle_left_geometry() {
let geom = CircleGeometry::new( let geom = CircleGeometry::new(
vec2(0.0, 0.0), vec2(0.0, 0.0),
0.0, // heading east (0 radians) Radians::new(0.0), // heading east (0 radians)
100.0, 100.0,
CircleDirection::Left, CircleDirection::Left,
); );
@@ -183,7 +155,7 @@ mod tests {
fn test_circle_right_geometry() { fn test_circle_right_geometry() {
let geom = CircleGeometry::new( let geom = CircleGeometry::new(
vec2(0.0, 0.0), vec2(0.0, 0.0),
0.0, // heading east Radians::new(0.0), // heading east
100.0, 100.0,
CircleDirection::Right, CircleDirection::Right,
); );
+225
View File
@@ -0,0 +1,225 @@
//! Centralised behavioural contract for `TurtleCommand`.
//!
//! All knowledge about what a command does to `TurtleParams`, how long it
//! animates, and whether it produces a drawable stroke lives here.
//!
//! Adding a new `TurtleCommand` variant requires editing this file (and
//! `execute_command_side_effects` / `tessellate_command` in `execution.rs`
//! if the variant has side effects or produces a mesh).
use crate::circle_geometry::{CircleDirection, CircleGeometry};
use crate::commands::TurtleCommand;
use crate::general::{AnimationSpeed, Radians};
use crate::state::TurtleParams;
use crate::tweening::normalize_angle;
use macroquad::prelude::vec2;
impl TurtleCommand {
/// Apply this command's effect to `params` in place.
///
/// This is the **single source of truth** for what a command changes in
/// `TurtleParams`. Used by:
/// - `execute_command()` — instant-mode path, after side-effects return `false`
/// - `TweenController::calculate_target_state()` — animated-mode target computation
///
/// Variants handled by `execute_command_side_effects` (`BeginFill`, `EndFill`,
/// `PenUp`, `PenDown`, `WriteText`, `Reset`) are included here so that
/// `calculate_target_state` can produce a correct tween target. In the
/// `execute_command()` call path those variants never reach this method because
/// `execute_command_side_effects` returns `true` and the caller returns early —
/// there is no double-application.
pub(crate) fn apply_to_params(&self, params: &mut TurtleParams) {
match self {
TurtleCommand::Move(dist) => {
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) => {
params.heading = normalize_angle(params.heading + angle.as_radians().value());
}
TurtleCommand::Circle {
radius,
angle,
direction,
..
} => {
let geom = CircleGeometry::new(
params.position,
Radians::new(params.heading),
radius.value(),
*direction,
);
let angle_rad = angle.as_radians().value();
params.position = geom.position_at_angle(angle_rad);
params.heading = normalize_angle(match direction {
CircleDirection::Left => params.heading - angle_rad,
CircleDirection::Right => params.heading + angle_rad,
});
}
TurtleCommand::Goto(coord) => {
// Y-flip: turtle graphics Y+ = up; Macroquad Y+ = down
params.position = vec2(coord.x, -coord.y);
}
TurtleCommand::SetHeading(heading) => {
params.heading = normalize_angle(-heading.as_radians().value());
}
TurtleCommand::SetColor(color) => {
params.color = *color;
}
TurtleCommand::SetFillColor(color) => {
params.fill_color = *color;
}
TurtleCommand::SetPenWidth(width) => {
params.pen_width = *width;
}
TurtleCommand::SetSpeed(speed) => {
params.speed = *speed;
}
TurtleCommand::SetShape(shape) => {
params.shape = shape.clone();
}
TurtleCommand::PenUp => {
params.pen_down = false;
}
TurtleCommand::PenDown => {
params.pen_down = true;
}
TurtleCommand::ShowTurtle => {
params.visible = true;
}
TurtleCommand::HideTurtle => {
params.visible = false;
}
TurtleCommand::Reset => {
*params = TurtleParams::default();
}
// Fill/text commands do not change TurtleParams for tweening purposes;
// their effects are handled entirely by execute_command_side_effects.
TurtleCommand::BeginFill | TurtleCommand::EndFill | TurtleCommand::WriteText { .. } => {
}
}
}
/// Duration in seconds for this command's animation at the given speed.
///
/// Returns `0.01` (minimum) for commands that have no animated component.
/// This is the **single source of truth**; replaces
/// `TweenController::calculate_duration_with_state` in `tweening.rs`.
pub(crate) fn animation_duration(&self, params: &TurtleParams, speed: AnimationSpeed) -> f64 {
let AnimationSpeed::Animated(mut spd) = speed else {
// Instant mode — duration is irrelevant; return the minimum so tweener
// infrastructure still has a valid duration if called accidentally.
return f64::from(0.01_f32);
};
// Exponential speed scaling for high values (matches original behaviour)
if spd > 100.0 {
spd *= spd / 100.0;
}
let base: f32 = match self {
TurtleCommand::Move(dist) => dist.value().abs() / spd,
TurtleCommand::Turn(angle) => angle.value().abs() / (spd * 1.8),
TurtleCommand::Circle { radius, angle, .. } => {
let arc_length = radius.value() * angle.as_radians().value().abs();
arc_length / spd
}
TurtleCommand::Goto(target) => {
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,
};
f64::from(base.max(0.01))
}
/// Whether executing this command (when pen is down) produces a stroke or fill mesh.
///
/// This is the **single source of truth**; replaces
/// `TweenController::command_creates_drawing` in `tweening.rs`.
#[must_use]
pub(crate) fn produces_drawing(&self) -> bool {
matches!(
self,
TurtleCommand::Move(_) | TurtleCommand::Circle { .. } | TurtleCommand::Goto(_)
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::general::{AnimationSpeed, Color, Coordinate, Degrees};
use crate::shapes::TurtleShape;
fn make_test_params() -> TurtleParams {
TurtleParams {
position: vec2(0.0, 0.0),
heading: 0.0,
pen_down: true,
pen_width: 1.0,
color: Color::new(0.0, 0.0, 0.0, 1.0),
fill_color: None,
visible: true,
shape: TurtleShape::turtle(),
speed: AnimationSpeed::Animated(100.0),
}
}
#[test]
fn test_goto_duration_cartesian_inversion() {
let mut params = make_test_params();
// Set screen position to (0, 100), which corresponds to Cartesian (0, -100)
params.position = vec2(0.0, 100.0);
// Move to Cartesian (0, 100), which in screen space is (0, -100)
// Distance should be 200 pixels!
let cmd = TurtleCommand::Goto(Coordinate::new(0.0, 100.0));
let duration = cmd.animation_duration(&params, AnimationSpeed::Animated(100.0));
// At 100 px/sec across 200 pixels, duration should be 2.0 seconds
assert!(
(duration - 2.0).abs() < 0.01,
"Expected duration ~2.0s for 200px move, got {duration}"
);
}
#[test]
fn test_set_heading_degrees_and_instant_duration() {
let mut params = make_test_params();
// 90° = North (in screen coordinates: -pi/2)
let cmd_north = TurtleCommand::SetHeading(Degrees::new(90.0));
cmd_north.apply_to_params(&mut params);
let expected_north = -std::f32::consts::FRAC_PI_2;
assert!(
(params.heading - expected_north).abs() < 0.001,
"Heading 90° should be North (-π/2), got {}",
params.heading
);
// SetHeading should be instant (0.01 minimum duration)
let duration = cmd_north.animation_duration(&params, AnimationSpeed::Animated(100.0));
assert!((duration - 0.01).abs() < 0.001);
// 0° = East (0 radians)
let cmd_east = TurtleCommand::SetHeading(Degrees::new(0.0));
cmd_east.apply_to_params(&mut params);
assert!((params.heading - 0.0).abs() < 0.001);
// 270° = South (+pi/2 radians in screen coords)
let cmd_south = TurtleCommand::SetHeading(Degrees::new(270.0));
cmd_south.apply_to_params(&mut params);
let expected_south = std::f32::consts::FRAC_PI_2;
assert!(
(params.heading - expected_south).abs() < 0.001,
"Heading 270° should be South (π/2), got {}",
params.heading
);
}
}
+30 -30
View File
@@ -1,21 +1,22 @@
//! Turtle commands and command queue //! Turtle commands and command queue
use crate::general::{AnimationSpeed, Color, Coordinate, FontSize, Precision}; use crate::general::{AnimationSpeed, Color, Coordinate, Degrees, FontSize, Length, Precision};
use crate::shapes::TurtleShape; use crate::shapes::TurtleShape;
/// Individual turtle commands /// Individual turtle commands
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub enum TurtleCommand { pub enum TurtleCommand {
// Movement (positive = forward, negative = backward) // Movement (positive = forward, negative = backward)
Move(Precision), Move(Length),
// Rotation (positive = right/clockwise, negative = left/counter-clockwise in degrees) // Rotation (positive = right/clockwise, negative = left/counter-clockwise)
Turn(Precision), // Stored in degrees — the natural unit at the user-facing API boundary.
Turn(Degrees),
// Circle drawing // Circle drawing
Circle { Circle {
radius: Precision, radius: Length,
angle: Precision, // degrees angle: Degrees, // sweep angle — degrees, as supplied by the user
steps: usize, steps: usize,
direction: crate::circle_geometry::CircleDirection, direction: crate::circle_geometry::CircleDirection,
}, },
@@ -33,7 +34,9 @@ pub enum TurtleCommand {
// Position // Position
Goto(Coordinate), Goto(Coordinate),
SetHeading(Precision), // 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 // Visibility
ShowTurtle, ShowTurtle,
@@ -53,11 +56,14 @@ pub enum TurtleCommand {
Reset, Reset,
} }
/// Queue of turtle commands with execution state /// A pure-data sequence of turtle commands.
///
/// `CommandQueue` is intentionally *not* an `Iterator` — it carries no cursor
/// state. Execution state belongs to the consumer; `TweenController` consumes
/// this queue as commands execute.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct CommandQueue { pub struct CommandQueue {
commands: Vec<TurtleCommand>, commands: Vec<TurtleCommand>,
current_index: usize,
} }
impl CommandQueue { impl CommandQueue {
@@ -65,14 +71,12 @@ impl CommandQueue {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
commands: Vec::new(), commands: Vec::new(),
current_index: 0,
} }
} }
#[must_use] #[must_use]
pub fn with_capacity(capacity: usize) -> Self { pub fn with_capacity(capacity: usize) -> Self {
Self { Self {
commands: Vec::with_capacity(capacity), commands: Vec::with_capacity(capacity),
current_index: 0,
} }
} }
@@ -83,26 +87,22 @@ impl CommandQueue {
pub fn extend(&mut self, commands: impl IntoIterator<Item = TurtleCommand>) { pub fn extend(&mut self, commands: impl IntoIterator<Item = TurtleCommand>) {
self.commands.extend(commands); self.commands.extend(commands);
} }
/// Return a reference to the command at `index`, or `None` if out of range.
#[must_use] #[must_use]
pub fn is_complete(&self) -> bool { pub fn get(&self, index: usize) -> Option<&TurtleCommand> {
self.current_index >= self.commands.len() self.commands.get(index)
}
pub fn reset(&mut self) {
self.current_index = 0;
} }
#[must_use] #[must_use]
pub fn len(&self) -> usize { pub fn len(&self) -> usize {
self.commands.len() self.commands.len()
} }
#[must_use] #[must_use]
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
self.commands.is_empty() self.commands.is_empty()
} }
#[must_use]
pub fn remaining(&self) -> usize {
self.commands.len().saturating_sub(self.current_index)
}
} }
impl Default for CommandQueue { impl Default for CommandQueue {
@@ -111,16 +111,16 @@ impl Default for CommandQueue {
} }
} }
impl Iterator for CommandQueue { /// Consuming iteration — yields every command in order.
///
/// This is used by `CommandQueue::extend` and `TweenController::append_commands`
/// to drain one queue into another. It does *not* imply that `CommandQueue`
/// itself is stateful; execution state is managed by the consumer.
impl IntoIterator for CommandQueue {
type Item = TurtleCommand; type Item = TurtleCommand;
type IntoIter = std::vec::IntoIter<TurtleCommand>;
fn next(&mut self) -> Option<Self::Item> { fn into_iter(self) -> Self::IntoIter {
if self.current_index < self.commands.len() { self.commands.into_iter()
let cmd = self.commands[self.current_index].clone();
self.current_index += 1;
Some(cmd)
} else {
None
}
} }
} }
+4 -32
View File
@@ -76,8 +76,7 @@ pub struct TurtleCommandSender {
/// ///
/// Paired with `TurtleCommandSender` via `turtle_command_channel()`. /// Paired with `TurtleCommandSender` via `turtle_command_channel()`.
/// Automatically managed by `TurtleApp::process_commands()`. /// Automatically managed by `TurtleApp::process_commands()`.
pub struct TurtleCommandReceiver { pub(crate) struct TurtleCommandReceiver {
turtle_id: usize,
rx: Receiver<CommandQueue>, rx: Receiver<CommandQueue>,
} }
@@ -142,12 +141,6 @@ impl TurtleCommandSender {
} }
impl TurtleCommandReceiver { impl TurtleCommandReceiver {
/// Get the turtle ID this receiver is bound to
#[must_use]
pub fn turtle_id(&self) -> usize {
self.turtle_id
}
/// Drain all pending commands for this turtle (non-blocking) /// Drain all pending commands for this turtle (non-blocking)
/// ///
/// # Examples /// # Examples
@@ -169,24 +162,6 @@ impl TurtleCommandReceiver {
pub fn recv_all(&self) -> Vec<CommandQueue> { pub fn recv_all(&self) -> Vec<CommandQueue> {
self.rx.try_iter().collect() self.rx.try_iter().collect()
} }
/// Try to receive one command batch (non-blocking)
#[must_use]
pub fn try_recv(&self) -> Option<CommandQueue> {
self.rx.try_recv().ok()
}
/// Check if this receiver's queue is empty
#[must_use]
pub fn is_empty(&self) -> bool {
self.rx.is_empty()
}
/// Get the number of pending command batches
#[must_use]
pub fn len(&self) -> usize {
self.rx.len()
}
} }
/// Create a command channel for a specific turtle /// Create a command channel for a specific turtle
@@ -203,16 +178,13 @@ impl TurtleCommandReceiver {
/// Panics if `buffer_size` is 0. /// Panics if `buffer_size` is 0.
/// ///
/// # Examples /// # Examples
/// ```no_run /// ```ignore
/// # use turtle_lib::*;
/// # fn example() {
/// let (tx, _rx) = turtle_command_channel(0, 100); /// let (tx, _rx) = turtle_command_channel(0, 100);
/// // Sender goes to game threads /// // Sender goes to game threads
/// // Receiver stays in render thread (or `TurtleApp`) /// // Receiver stays in render thread (or `TurtleApp`)
/// # }
/// ``` /// ```
#[must_use] #[must_use]
pub fn turtle_command_channel( pub(crate) fn turtle_command_channel(
turtle_id: usize, turtle_id: usize,
buffer_size: usize, buffer_size: usize,
) -> (TurtleCommandSender, TurtleCommandReceiver) { ) -> (TurtleCommandSender, TurtleCommandReceiver) {
@@ -220,6 +192,6 @@ pub fn turtle_command_channel(
let (tx, rx) = bounded(buffer_size); let (tx, rx) = bounded(buffer_size);
( (
TurtleCommandSender { turtle_id, tx }, TurtleCommandSender { turtle_id, tx },
TurtleCommandReceiver { turtle_id, rx }, TurtleCommandReceiver { rx },
) )
} }
+40 -113
View File
@@ -5,65 +5,11 @@ use crate::state::{DrawCommand, TurtleParams, TurtleWorld};
use crate::tessellation; use crate::tessellation;
use macroquad::prelude::*; use macroquad::prelude::*;
// Import the easing function from the tween crate /// Render the turtle world with active tween visualization.
// 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 entire turtle world
pub fn render_world(world: &TurtleWorld) {
// Update camera zoom based on current screen size to prevent stretching
let camera = Camera2D {
zoom: vec2(1.0 / screen_width() * 2.0, 1.0 / screen_height() * 2.0),
target: world.camera.target,
..Default::default()
};
// Set camera
set_camera(&camera);
// Draw all accumulated commands from all turtles
for turtle in &world.turtles {
for cmd in &turtle.commands {
match cmd {
DrawCommand::Mesh { data, source: _ } => {
// Rendering wie bisher
draw_mesh(&data.to_mesh());
// Hier könnte man das source für Debug/Export loggen
}
DrawCommand::Text {
text,
position,
heading,
font_size,
color,
source: _,
} => {
draw_text_command(text, *position, *heading, *font_size, *color);
// Hier könnte man das source für Debug/Export loggen
}
}
}
}
// Draw all visible turtles
for turtle in &world.turtles {
if turtle.params.visible {
draw_turtle(&turtle.params);
}
}
// Reset to default camera
set_default_camera();
}
/// Render the turtle world with active tween visualization
#[allow(clippy::too_many_lines)] #[allow(clippy::too_many_lines)]
pub fn render_world_with_tweens(world: &TurtleWorld, zoom_level: f32) { pub(crate) fn render_world_with_tweens(world: &TurtleWorld, zoom_level: f32) {
// Update camera zoom based on current screen size to prevent stretching // Update camera zoom based on current screen size to prevent stretching
// Apply user zoom level by dividing by it (smaller zoom value = more zoomed in) // Apply user zoom level by dividing by it
let camera = Camera2D { let camera = Camera2D {
zoom: vec2( zoom: vec2(
1.0 / screen_width() * 2.0 / zoom_level, 1.0 / screen_width() * 2.0 / zoom_level,
@@ -80,8 +26,8 @@ pub fn render_world_with_tweens(world: &TurtleWorld, zoom_level: f32) {
for turtle in &world.turtles { for turtle in &world.turtles {
for cmd in &turtle.commands { for cmd in &turtle.commands {
match cmd { match cmd {
DrawCommand::Mesh { data, source: _ } => { DrawCommand::Mesh(mesh) => {
draw_mesh(&data.to_mesh()); draw_mesh(mesh);
} }
DrawCommand::Text { DrawCommand::Text {
text, text,
@@ -89,7 +35,6 @@ pub fn render_world_with_tweens(world: &TurtleWorld, zoom_level: f32) {
heading, heading,
font_size, font_size,
color, color,
source: _,
} => { } => {
draw_text_command(text, *position, *heading, *font_size, *color); draw_text_command(text, *position, *heading, *font_size, *color);
} }
@@ -110,7 +55,7 @@ pub fn render_world_with_tweens(world: &TurtleWorld, zoom_level: f32) {
direction, direction,
} => { } => {
// Draw arc segments from start to current position // Draw arc segments from start to current position
draw_tween_arc(tween, *radius, *angle, *steps, *direction); draw_tween_arc(tween, radius.value(), *angle, *steps, *direction);
} }
_ if should_draw_tween_line(&tween.command) => { _ if should_draw_tween_line(&tween.command) => {
// Draw straight line for other movement commands (use tween's current position) // Draw straight line for other movement commands (use tween's current position)
@@ -176,40 +121,28 @@ pub fn render_world_with_tweens(world: &TurtleWorld, zoom_level: f32) {
} = &tween.command } = &tween.command
{ {
// Calculate partial arc vertices based on current progress // Calculate partial arc vertices based on current progress
use crate::circle_geometry::CircleGeometry; use crate::circle_geometry::{arc_points, CircleGeometry};
use crate::general::Radians;
let geom = CircleGeometry::new( let geom = CircleGeometry::new(
tween.start_params.position, tween.start_params.position,
tween.start_params.heading, Radians::new(tween.start_params.heading),
*radius, radius.value(),
*direction, *direction,
); // Calculate progress
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);
// Generate arc vertices for the partial arc
let num_samples = *steps.max(&1);
let samples_to_draw =
((num_samples as f32 * eased_progress) as usize).max(1);
for i in 1..=samples_to_draw {
let sample_progress = i as f32 / num_samples as f32;
let current_angle = match direction {
crate::circle_geometry::CircleDirection::Left => {
geom.start_angle_from_center
- angle.to_radians() * sample_progress
}
crate::circle_geometry::CircleDirection::Right => {
geom.start_angle_from_center
+ angle.to_radians() * sample_progress
}
};
let vertex = Vec2::new(
geom.center.x + radius * current_angle.cos(),
geom.center.y + radius * current_angle.sin(),
); );
current_preview.push(vertex); // Delegate to the shared arc_points function — same sampling
// strategy as tessellate_arc, using tween.progress directly.
let samples_to_draw =
(((*steps).max(1) as f32 * tween.progress) as usize).max(1);
let sweep_so_far = angle.as_radians().value() * tween.progress;
for pt in arc_points(
geom.center,
radius.value(),
geom.start_angle_from_center,
sweep_so_far,
samples_to_draw,
*direction,
) {
current_preview.push(pt);
} }
} else if matches!( } else if matches!(
&tween.command, &tween.command,
@@ -280,8 +213,8 @@ pub fn render_world_with_tweens(world: &TurtleWorld, zoom_level: f32) {
&all_contours, &all_contours,
fill_state.fill_color, fill_state.fill_color,
) { ) {
Ok(mesh_data) => { Ok(mesh) => {
draw_mesh(&mesh_data.to_mesh()); draw_mesh(&mesh);
} }
Err(e) => { Err(e) => {
tracing::error!("Failed to tessellate fill preview: {:?}", e); tracing::error!("Failed to tessellate fill preview: {:?}", e);
@@ -347,46 +280,40 @@ fn draw_text_command(
fn draw_tween_arc( fn draw_tween_arc(
tween: &crate::tweening::CommandTween, tween: &crate::tweening::CommandTween,
radius: f32, radius: f32,
total_angle: f32, total_angle: crate::general::Degrees,
steps: usize, steps: usize,
direction: CircleDirection, direction: CircleDirection,
) { ) {
use crate::general::Radians;
let geom = CircleGeometry::new( let geom = CircleGeometry::new(
tween.start_params.position, tween.start_params.position,
tween.start_params.heading, Radians::new(tween.start_params.heading),
radius, radius,
direction, direction,
); );
// Debug: draw center using Lyon tessellation // Draw center using Lyon tessellation this helps visualizing what is done.
if let Ok(mesh_data) = crate::tessellation::tessellate_circle(geom.center, 5.0, GRAY, true, 1.0) if let Ok(mesh) = crate::tessellation::tessellate_circle(geom.center, 5.0, GRAY, true, 1.0) {
{ draw_mesh(&mesh);
draw_mesh(&mesh_data.to_mesh());
} }
// Calculate how much of the arc we've traveled based on tween progress // Draw the partial arc traveled based on tween progress
// Use the same eased progress as the turtle position for synchronized animation if let Ok(mesh) = crate::tessellation::tessellate_arc(
let elapsed = get_time() - tween.start_time;
let t = (elapsed / tween.duration).min(1.0);
let progress = CubicInOut.tween(1.0, t as f32); // tween from 0 to 1
// Use Lyon to tessellate and draw the partial arc
if let Ok(mesh_data) = crate::tessellation::tessellate_arc(
geom.center, geom.center,
radius, radius,
geom.start_angle_from_center.to_degrees(), geom.start_angle_from_center.to_degrees(),
total_angle * progress, total_angle.value() * tween.progress,
tween.start_params.color, tween.start_params.color,
tween.start_params.pen_width, tween.start_params.pen_width,
((steps as f32 * progress).ceil() as usize).max(1), ((steps as f32 * tween.progress).ceil() as usize).max(1),
direction, direction,
) { ) {
draw_mesh(&mesh_data.to_mesh()); draw_mesh(&mesh);
} }
} }
/// Draw the turtle shape /// Draw the turtle shape
pub fn draw_turtle(turtle_params: &TurtleParams) { pub(crate) fn draw_turtle(turtle_params: &TurtleParams) {
let rotated_vertices = turtle_params.shape.rotated_vertices(turtle_params.heading); let rotated_vertices = turtle_params.shape.rotated_vertices(turtle_params.heading);
if turtle_params.shape.filled { if turtle_params.shape.filled {
@@ -398,10 +325,10 @@ pub fn draw_turtle(turtle_params: &TurtleParams) {
.collect(); .collect();
// Use Lyon for turtle shape too // Use Lyon for turtle shape too
if let Ok(mesh_data) = if let Ok(mesh) =
tessellation::tessellate_polygon(&absolute_vertices, Color::new(0.0, 0.5, 1.0, 1.0)) tessellation::tessellate_polygon(&absolute_vertices, Color::new(0.0, 0.5, 1.0, 1.0))
{ {
draw_mesh(&mesh_data.to_mesh()); draw_mesh(&mesh);
} else { } else {
// Fallback to simple triangle fan if Lyon fails // Fallback to simple triangle fan if Lyon fails
let first = absolute_vertices[0]; let first = absolute_vertices[0];
+334 -311
View File
@@ -2,51 +2,119 @@
use crate::circle_geometry::{CircleDirection, CircleGeometry}; use crate::circle_geometry::{CircleDirection, CircleGeometry};
use crate::commands::TurtleCommand; use crate::commands::TurtleCommand;
use crate::state::{DrawCommand, Turtle, TurtleParams, TurtleWorld}; use crate::general::{Coordinate, Radians};
use crate::state::{DrawCommand, FillState, Turtle, TurtleParams, TurtleWorld};
use crate::tessellation; use crate::tessellation;
use macroquad::prelude::*; use macroquad::prelude::*;
#[cfg(test)] #[cfg(test)]
use crate::general::AnimationSpeed; use crate::general::AnimationSpeed;
/// Execute side effects for commands that don't involve movement /// Close the current open fill contour (factored out of `Turtle::close_fill_contour`).
/// Returns true if the command was handled (caller should skip movement processing) fn close_fill_contour(turtle_id: usize, filling: &mut Option<FillState>) {
#[allow(clippy::too_many_lines)] if let Some(ref mut fill_state) = filling {
pub fn execute_command_side_effects(command: &TurtleCommand, state: &mut Turtle) -> bool { tracing::debug!(
match command { turtle_id,
TurtleCommand::BeginFill => { vertices = fill_state.current_contour.len(),
if state.filling.is_some() { "close_fill_contour called"
);
if fill_state.current_contour.len() >= 2 {
tracing::debug!(
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"
);
let contour = std::mem::take(&mut fill_state.current_contour);
fill_state.contours.push(contour);
tracing::debug!(
turtle_id,
completed_contours = fill_state.contours.len(),
"Contour moved to completed list"
);
} else if !fill_state.current_contour.is_empty() {
tracing::warn!( tracing::warn!(
turtle_id = state.turtle_id, turtle_id,
"begin_fill() called while already filling" vertices = fill_state.current_contour.len(),
"Current contour has insufficient vertices, not closing"
);
} else {
tracing::warn!(turtle_id, "Current contour is empty, nothing to close");
}
} else {
tracing::warn!(
turtle_id,
"close_fill_contour called but no active fill state"
); );
} }
let fill_color = state.params.fill_color.unwrap_or_else(|| { }
tracing::warn!(
turtle_id = state.turtle_id, /// Begin a new fill contour at `position` (factored out of `Turtle::start_fill_contour`).
"No fill_color set, using black" fn start_fill_contour(turtle_id: usize, position: Coordinate, filling: &mut Option<FillState>) {
if let Some(ref mut fill_state) = filling {
tracing::debug!(
x = position.x,
y = position.y,
completed_contours = fill_state.contours.len(),
turtle_id,
"Starting new contour"
); );
fill_state.current_contour = vec![position];
}
}
/// Execute side effects for commands that don't involve movement.
///
/// Returns `true` if the command was fully handled; the caller should skip
/// params-update and tessellation when this returns `true`.
///
/// Accepts the three logically-separate pieces of turtle state as disjoint
/// mutable borrows so that this function can be called from
/// `TweenController::update(&mut self, …)` without requiring a `&mut Turtle`.
#[allow(clippy::too_many_lines)]
pub(crate) fn execute_command_side_effects(
command: &TurtleCommand,
turtle_id: usize,
params: &mut TurtleParams,
filling: &mut Option<FillState>,
commands: &mut Vec<DrawCommand>,
svg_log: &mut crate::state::SvgLog,
) -> bool {
match command {
TurtleCommand::BeginFill => {
if filling.is_some() {
tracing::warn!(turtle_id, "begin_fill() called while already filling");
}
let fill_color = params.fill_color.unwrap_or_else(|| {
tracing::warn!(turtle_id, "No fill_color set, using black");
BLACK BLACK
}); });
state.begin_fill(fill_color); *filling = Some(FillState {
contours: Vec::new(),
current_contour: vec![params.position],
fill_color,
});
true true
} }
TurtleCommand::EndFill => { TurtleCommand::EndFill => {
if let Some(mut fill_state) = state.filling.take() { if let Some(mut fill_state) = filling.take() {
if !fill_state.current_contour.is_empty() { if !fill_state.current_contour.is_empty() {
fill_state.contours.push(fill_state.current_contour); fill_state.contours.push(fill_state.current_contour);
} }
let span = tracing::debug_span!( let span = tracing::debug_span!(
"end_fill", "end_fill",
turtle_id = state.turtle_id, turtle_id,
contours = fill_state.contours.len() contours = fill_state.contours.len()
); );
let _enter = span.enter(); let _enter = span.enter();
for (i, contour) in fill_state.contours.iter().enumerate() { for (i, contour) in fill_state.contours.iter().enumerate() {
tracing::debug!( tracing::debug!(
turtle_id = state.turtle_id, turtle_id,
contour_idx = i, contour_idx = i,
vertices = contour.len(), vertices = contour.len(),
"Contour info" "Contour info"
@@ -54,90 +122,76 @@ pub fn execute_command_side_effects(command: &TurtleCommand, state: &mut Turtle)
} }
if !fill_state.contours.is_empty() { if !fill_state.contours.is_empty() {
if let Ok(mesh_data) = tessellation::tessellate_multi_contour( if let Ok(mesh) = tessellation::tessellate_multi_contour(
&fill_state.contours, &fill_state.contours,
fill_state.fill_color, fill_state.fill_color,
) { ) {
tracing::debug!( tracing::debug!(
turtle_id = state.turtle_id, turtle_id,
contours = fill_state.contours.len(), contours = fill_state.contours.len(),
"Successfully created fill mesh - persisting to commands" "Successfully created fill mesh - persisting to commands"
); );
state.commands.push(DrawCommand::Mesh { commands.push(DrawCommand::Mesh(mesh));
data: mesh_data, #[cfg(feature = "svg")]
source: crate::state::TurtleSource { svg_log.push(crate::state::SvgRecord::Fill {
command: crate::commands::TurtleCommand::EndFill, contours: fill_state.contours,
color: state.params.color,
fill_color: fill_state.fill_color, fill_color: fill_state.fill_color,
pen_width: state.params.pen_width, stroke_color: params.color,
start_position: fill_state.start_position,
end_position: fill_state.start_position,
start_heading: state.params.heading,
contours: Some(fill_state.contours.clone()),
},
}); });
} else { } else {
tracing::error!( tracing::error!(turtle_id, "Failed to tessellate contours");
turtle_id = state.turtle_id,
"Failed to tessellate contours"
);
} }
} }
} else { } else {
tracing::warn!( tracing::warn!(turtle_id, "end_fill() called without begin_fill()");
turtle_id = state.turtle_id,
"end_fill() called without begin_fill()"
);
} }
true true
} }
TurtleCommand::PenUp => { TurtleCommand::PenUp => {
state.params.pen_down = false; params.pen_down = false;
if state.filling.is_some() { if filling.is_some() {
tracing::debug!( tracing::debug!(turtle_id, "PenUp: Closing current contour");
turtle_id = state.turtle_id,
"PenUp: Closing current contour"
);
} }
state.close_fill_contour(); close_fill_contour(turtle_id, filling);
true true
} }
TurtleCommand::PenDown => { TurtleCommand::PenDown => {
state.params.pen_down = true; params.pen_down = true;
if state.filling.is_some() { if filling.is_some() {
tracing::debug!( tracing::debug!(
turtle_id = state.turtle_id, turtle_id,
x = state.params.position.x, x = params.position.x,
y = state.params.position.y, y = params.position.y,
"PenDown: Starting new contour" "PenDown: Starting new contour"
); );
} }
state.start_fill_contour(); start_fill_contour(turtle_id, params.position, filling);
true true
} }
TurtleCommand::Reset => { TurtleCommand::Reset => {
state.reset(); commands.clear();
svg_log.clear();
*filling = None;
*params = TurtleParams::default();
true true
} }
TurtleCommand::WriteText { text, font_size } => { TurtleCommand::WriteText { text, font_size } => {
state.commands.push(DrawCommand::Text { commands.push(DrawCommand::Text {
text: text.clone(), text: text.clone(),
position: state.params.position, position: params.position,
heading: state.params.heading, heading: params.heading,
font_size: *font_size, font_size: *font_size,
color: state.params.color, color: params.color,
source: crate::state::TurtleSource { });
command: command.clone(), #[cfg(feature = "svg")]
color: state.params.color, svg_log.push(crate::state::SvgRecord::Text {
fill_color: state.params.fill_color.unwrap_or(BLACK), text: text.clone(),
pen_width: state.params.pen_width, position: params.position,
start_position: state.params.position, heading: params.heading,
end_position: state.params.position, font_size: *font_size,
start_heading: state.params.heading, color: params.color,
contours: None,
},
}); });
true true
} }
@@ -157,14 +211,23 @@ pub fn execute_command_side_effects(command: &TurtleCommand, state: &mut Turtle)
} }
} }
/// Record fill vertices after movement commands have updated state /// Record fill vertices after movement commands have updated state.
#[tracing::instrument] ///
pub fn record_fill_vertices_after_movement( /// `start_state` is the params snapshot taken **before** the command ran.
/// `params` is the current (post-movement) state — `params.position` is the
/// endpoint that gets pushed into the active fill contour.
///
/// Accepts disjoint borrows so it can be called from `TweenController::update`
/// without needing a `&mut Turtle`.
#[tracing::instrument(skip(params, filling))]
pub(crate) fn record_fill_vertices_after_movement(
command: &TurtleCommand, command: &TurtleCommand,
start_state: &TurtleParams, start_state: &TurtleParams,
state: &mut Turtle, turtle_id: usize,
params: &TurtleParams,
filling: &mut Option<FillState>,
) { ) {
if state.filling.is_none() { if filling.is_none() {
return; return;
} }
@@ -177,268 +240,242 @@ pub fn record_fill_vertices_after_movement(
} => { } => {
let geom = CircleGeometry::new( let geom = CircleGeometry::new(
start_state.position, start_state.position,
start_state.heading, Radians::new(start_state.heading),
*radius, radius.value(),
*direction, *direction,
); );
state.record_fill_vertices_for_arc( if let Some(ref mut fill_state) = filling {
geom.center, if params.pen_down {
*radius, let num_samples = (*steps as u32).max(1);
geom.start_angle_from_center, tracing::trace!(
angle.to_radians(), turtle_id,
*direction, center_x = geom.center.x,
*steps as u32, center_y = geom.center.y,
radius = radius.value(),
steps,
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 {
CircleDirection::Left => {
geom.start_angle_from_center - angle.as_radians().value() * progress
}
CircleDirection::Right => {
geom.start_angle_from_center + angle.as_radians().value() * progress
}
};
let vertex = Coordinate::new(
geom.center.x + radius.value() * current_angle.cos(),
geom.center.y + radius.value() * current_angle.sin(),
);
tracing::trace!(
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);
}
}
}
} }
TurtleCommand::Move(_) | TurtleCommand::Goto(_) => { TurtleCommand::Move(_) | TurtleCommand::Goto(_) => {
state.record_fill_vertex(); if let Some(ref mut fill_state) = filling {
if params.pen_down {
tracing::trace!(
turtle_id,
x = params.position.x,
y = params.position.y,
vertices = fill_state.current_contour.len() + 1,
"Adding vertex to current contour"
);
fill_state.current_contour.push(params.position);
} else {
tracing::trace!(turtle_id, "Skipping vertex (pen is up)");
}
}
} }
_ => {} _ => {}
} }
} }
/// Execute a single turtle command, updating state and adding draw commands /// Tessellate a completed movement command into a [`DrawCommand`] mesh.
#[tracing::instrument] ///
#[allow(clippy::too_many_lines)] /// Returns `None` if the pen was up or the command does not produce a drawing.
pub fn execute_command(command: &TurtleCommand, state: &mut Turtle) { ///
// Try to execute as side-effect-only command first /// `end_position` is the turtle's position after the command completed:
if execute_command_side_effects(command, state) { /// - instant-mode: `state.params.position` after [`TurtleCommand::apply_to_params`]
return; // Command fully handled /// - animated-mode: `tween.target_params.position` when the tween finishes
} ///
/// This is the **single** tessellation site for all committed line/arc meshes.
// Store start state for fill vertex recording /// It replaces both the inline tessellation inside `execute_command` and the
let start_state = state.clone(); /// now-deleted `add_draw_for_completed_tween`.
pub(crate) fn tessellate_command(
// Execute movement and appearance commands
match command {
TurtleCommand::Move(distance) => {
let start = state.params.position;
let dx = distance * state.params.heading.cos();
let dy = distance * state.params.heading.sin();
state.params.position =
vec2(state.params.position.x + dx, state.params.position.y + dy);
if state.params.pen_down {
// Draw line segment with round caps (caps handled by tessellate_stroke)
if let Ok(mesh_data) = tessellation::tessellate_stroke(
&[start, state.params.position],
state.params.color,
state.params.pen_width,
false, // not closed
) {
state.commands.push(DrawCommand::Mesh {
data: mesh_data,
source: crate::state::TurtleSource {
command: command.clone(),
color: state.params.color,
fill_color: state.params.fill_color.unwrap_or(BLACK),
pen_width: state.params.pen_width,
start_position: start,
end_position: state.params.position,
start_heading: state.params.heading,
contours: None,
},
});
}
}
}
TurtleCommand::Turn(degrees) => {
state.params.heading += degrees.to_radians();
}
TurtleCommand::Circle {
radius,
angle,
steps,
direction,
} => {
let start_heading = state.params.heading;
let geom =
CircleGeometry::new(state.params.position, start_heading, *radius, *direction);
if state.params.pen_down {
// Use Lyon to tessellate the arc
if let Ok(mesh_data) = tessellation::tessellate_arc(
geom.center,
*radius,
geom.start_angle_from_center.to_degrees(),
*angle,
state.params.color,
state.params.pen_width,
*steps,
*direction,
) {
state.commands.push(DrawCommand::Mesh {
data: mesh_data,
source: crate::state::TurtleSource {
command: command.clone(),
color: state.params.color,
fill_color: state.params.fill_color.unwrap_or(BLACK),
pen_width: state.params.pen_width,
start_position: state.params.position,
end_position: geom.position_at_angle(angle.to_radians()),
start_heading,
contours: None,
},
});
}
}
// Update turtle position and heading
state.params.position = geom.position_at_angle(angle.to_radians());
state.params.heading = match direction {
CircleDirection::Left => start_heading - angle.to_radians(),
CircleDirection::Right => start_heading + angle.to_radians(),
};
}
TurtleCommand::Goto(coord) => {
let start = state.params.position;
// Flip Y coordinate: turtle graphics uses Y+ = up, but Macroquad uses Y+ = down
state.params.position = vec2(coord.x, -coord.y);
if state.params.pen_down {
// Draw line segment with round caps
if let Ok(mesh_data) = tessellation::tessellate_stroke(
&[start, state.params.position],
state.params.color,
state.params.pen_width,
false, // not closed
) {
state.commands.push(DrawCommand::Mesh {
data: mesh_data,
source: crate::state::TurtleSource {
command: command.clone(),
color: state.params.color,
fill_color: state.params.fill_color.unwrap_or(BLACK),
pen_width: state.params.pen_width,
start_position: start,
end_position: state.params.position,
start_heading: state.params.heading,
contours: None,
},
});
}
}
}
// Appearance commands
TurtleCommand::SetColor(color) => state.params.color = *color,
TurtleCommand::SetFillColor(color) => state.params.fill_color = *color,
TurtleCommand::SetPenWidth(width) => state.params.pen_width = *width,
TurtleCommand::SetSpeed(speed) => state.set_speed(*speed),
TurtleCommand::SetShape(shape) => state.params.shape = shape.clone(),
TurtleCommand::SetHeading(heading) => state.params.heading = *heading,
TurtleCommand::ShowTurtle => state.params.visible = true,
TurtleCommand::HideTurtle => state.params.visible = false,
// Reset
TurtleCommand::Reset => {
state.reset();
}
_ => {} // Already handled by execute_command_side_effects
}
// Record fill vertices AFTER movement
record_fill_vertices_after_movement(command, &start_state.params, state);
}
/// Execute command on a specific turtle by ID
pub fn execute_command_with_id(command: &TurtleCommand, turtle_id: usize, world: &mut TurtleWorld) {
// Clone turtle state to avoid borrow checker issues
if let Some(turtle) = world.get_turtle(turtle_id) {
let mut state = turtle.clone();
execute_command(command, &mut state);
// Update the turtle state back
if let Some(turtle_mut) = world.get_turtle_mut(turtle_id) {
*turtle_mut = state;
}
}
}
/// Add drawing command for a completed tween
pub fn add_draw_for_completed_tween(
command: &TurtleCommand, command: &TurtleCommand,
start_state: &TurtleParams, start: &TurtleParams,
end_state: &mut TurtleParams, end_position: Vec2,
) -> Option<DrawCommand> { ) -> Option<DrawCommand> {
if !start.pen_down || !command.produces_drawing() {
return None;
}
match command { match command {
TurtleCommand::Move(_) | TurtleCommand::Goto(_) => { TurtleCommand::Move(_) | TurtleCommand::Goto(_) => {
if start_state.pen_down { let mesh = tessellation::tessellate_stroke(
if let Ok(mesh_data) = tessellation::tessellate_stroke( &[start.position, end_position],
&[start_state.position, end_state.position], start.color,
start_state.color, start.pen_width,
start_state.pen_width,
false, false,
) { )
return Some(DrawCommand::Mesh { .ok()?;
data: mesh_data,
source: crate::state::TurtleSource { Some(DrawCommand::Mesh(mesh))
command: command.clone(),
color: start_state.color,
fill_color: start_state.fill_color.unwrap_or(BLACK),
pen_width: start_state.pen_width,
start_position: start_state.position,
end_position: end_state.position,
start_heading: start_state.heading,
contours: None,
},
});
}
}
} }
TurtleCommand::Circle { TurtleCommand::Circle {
radius, radius,
angle, angle,
steps, steps,
direction, direction,
} => { } => {
if start_state.pen_down { use crate::circle_geometry::CircleGeometry;
let geom = CircleGeometry::new( let geom = CircleGeometry::new(
start_state.position, start.position,
start_state.heading, Radians::new(start.heading),
*radius, radius.value(),
*direction, *direction,
); );
if let Ok(mesh_data) = tessellation::tessellate_arc( let mesh = tessellation::tessellate_arc(
geom.center, geom.center,
*radius, radius.value(),
geom.start_angle_from_center.to_degrees(), geom.start_angle_from_center.to_degrees(),
*angle, angle.value(),
start_state.color, start.color,
start_state.pen_width, start.pen_width,
*steps, *steps,
*direction, *direction,
)
.ok()?;
Some(DrawCommand::Mesh(mesh))
}
// `produces_drawing()` guards entry — this arm is only reachable if
// `produces_drawing` and the match above diverge, which would be a bug.
_ => None,
}
}
/// Push an [`SvgRecord`] for a completed line or arc drawing command.
///
/// Only compiled when the `svg` feature is enabled.
/// Must be called at the same call sites as `tessellate_command` so that
/// `svg_log` stays in sync with `commands`.
#[cfg(feature = "svg")]
pub(crate) fn push_svg_for_draw(
command: &TurtleCommand,
start: &TurtleParams,
end_position: Vec2,
svg_log: &mut crate::state::SvgLog,
) { ) {
return Some(DrawCommand::Mesh { use crate::state::SvgRecord;
data: mesh_data, match command {
source: crate::state::TurtleSource { TurtleCommand::Move(_) | TurtleCommand::Goto(_) => {
command: command.clone(), svg_log.push(SvgRecord::Line {
color: start_state.color, start: start.position,
fill_color: start_state.fill_color.unwrap_or(BLACK), end: end_position,
pen_width: start_state.pen_width, color: start.color,
start_position: start_state.position, pen_width: start.pen_width,
end_position: end_state.position,
start_heading: start_state.heading,
contours: None,
},
}); });
} }
TurtleCommand::Circle {
radius,
angle,
direction,
..
} => {
svg_log.push(SvgRecord::Arc {
start_position: start.position,
start_heading: start.heading,
radius: radius.value(),
angle: *angle,
direction: *direction,
color: start.color,
pen_width: start.pen_width,
});
}
_ => {}
} }
} }
_ => (),
/// Execute a single turtle command, updating state and adding draw commands.
#[tracing::instrument(skip(state))]
pub(crate) fn execute_command(command: &TurtleCommand, state: &mut Turtle) {
// Phase 1: side effects (fills, pen contours, reset, text).
// Returns true if the command is fully handled — no params update or tessellation needed.
if execute_command_side_effects(
command,
state.turtle_id,
&mut state.params,
&mut state.filling,
&mut state.commands,
&mut state.svg_log,
) {
return;
}
// Phase 2: update TurtleParams (position, heading, colour, speed, etc.)
let start_params = state.params.clone();
command.apply_to_params(&mut state.params);
// Phase 3: record fill vertices after movement (must follow params update)
record_fill_vertices_after_movement(
command,
&start_params,
state.turtle_id,
&state.params,
&mut state.filling,
);
// Phase 4: tessellate, push SVG record, and persist the committed drawing
if let Some(draw_cmd) = tessellate_command(command, &start_params, state.params.position) {
#[cfg(feature = "svg")]
push_svg_for_draw(
command,
&start_params,
state.params.position,
&mut state.svg_log,
);
state.commands.push(draw_cmd);
}
}
/// Execute command on a specific turtle by ID.
///
/// There is no ownership conflict here: `execute_command` only needs `&mut Turtle`
/// and never touches `TurtleWorld`, so we can obtain the mutable reference directly
/// from `get_turtle_mut` without any intermediate clone.
pub(crate) fn execute_command_with_id(
command: &TurtleCommand,
turtle_id: usize,
world: &mut TurtleWorld,
) {
if let Some(turtle) = world.get_turtle_mut(turtle_id) {
execute_command(command, turtle);
} }
None
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::commands::TurtleCommand; use crate::commands::TurtleCommand;
use crate::general::{Degrees, Length};
use crate::shapes::TurtleShape; use crate::shapes::TurtleShape;
use crate::TweenController; use crate::tweening::TweenController;
#[test] #[test]
fn test_forward_left_forward() { fn test_forward_left_forward() {
@@ -446,7 +483,7 @@ mod tests {
// the turtle ends up at (100, -50) from initial position (0, 0) // the turtle ends up at (100, -50) from initial position (0, 0)
use crate::state::TurtleParams; use crate::state::TurtleParams;
let state = Turtle { let mut state = Turtle {
turtle_id: 0, turtle_id: 0,
params: TurtleParams { params: TurtleParams {
position: vec2(0.0, 0.0), position: vec2(0.0, 0.0),
@@ -461,31 +498,17 @@ mod tests {
}, },
filling: None, filling: None,
commands: Vec::new(), commands: Vec::new(),
svg_log: crate::state::SvgLog::default(),
tween_controller: TweenController::default(), tween_controller: TweenController::default(),
}; };
// We'll use a dummy world but won't actually call drawing commands
let world = TurtleWorld {
turtles: vec![state.clone()],
camera: macroquad::camera::Camera2D {
zoom: vec2(1.0, 1.0),
target: vec2(0.0, 0.0),
offset: vec2(0.0, 0.0),
rotation: 0.0,
render_target: None,
viewport: None,
},
background_color: Color::new(1.0, 1.0, 1.0, 1.0),
};
let mut state = world.turtles[0].clone();
// Initial state: position (0, 0), heading 0 (east) // Initial state: position (0, 0), heading 0 (east)
assert_eq!(state.params.position.x, 0.0); assert_eq!(state.params.position.x, 0.0);
assert_eq!(state.params.position.y, 0.0); assert_eq!(state.params.position.y, 0.0);
assert_eq!(state.params.heading, 0.0); assert_eq!(state.params.heading, 0.0);
// Forward 100 - should move to (100, 0) // Forward 100 - should move to (100, 0)
execute_command(&TurtleCommand::Move(100.0), &mut state); execute_command(&TurtleCommand::Move(Length::new(100.0)), &mut state);
assert!( assert!(
(state.params.position.x - 100.0).abs() < 0.01, (state.params.position.x - 100.0).abs() < 0.01,
"After forward(100): x = {}", "After forward(100): x = {}",
@@ -500,7 +523,7 @@ mod tests {
// Left 90 degrees - should face north (heading decreases by 90°) // Left 90 degrees - should face north (heading decreases by 90°)
// In screen coords: north = -90° = -π/2 // In screen coords: north = -90° = -π/2
execute_command(&TurtleCommand::Turn(-90.0), &mut state); execute_command(&TurtleCommand::Turn(Degrees::new(-90.0)), &mut state);
assert!( assert!(
(state.params.position.x - 100.0).abs() < 0.01, (state.params.position.x - 100.0).abs() < 0.01,
"After left(90): x = {}", "After left(90): x = {}",
@@ -520,7 +543,7 @@ mod tests {
); );
// Forward 50 - should move north (negative Y) to (100, -50) // Forward 50 - should move north (negative Y) to (100, -50)
execute_command(&TurtleCommand::Move(50.0), &mut state); execute_command(&TurtleCommand::Move(Length::new(50.0)), &mut state);
assert!( assert!(
(state.params.position.x - 100.0).abs() < 0.01, (state.params.position.x - 100.0).abs() < 0.01,
"Final position: x = {} (expected 100.0)", "Final position: x = {} (expected 100.0)",
+297 -5
View File
@@ -1,22 +1,46 @@
//! Export-Backend-Trait und zentrale Export-Typen //! Export backend trait and core export types.
#[cfg(feature = "svg")]
use crate::state::TurtleWorld; use crate::state::TurtleWorld;
use crate::TurtlePlan;
#[derive(Debug)] #[derive(Debug)]
pub enum ExportError { pub enum ExportError {
Io(std::io::Error), Io(std::io::Error),
Format(String), Format(String),
// Weitere Formate können ergänzt werden Execution(String),
MissingFeature(String),
// Additional formats can be added here.
}
impl std::fmt::Display for ExportError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(err) => write!(f, "I/O error: {err}"),
Self::Format(msg) => write!(f, "format error: {msg}"),
Self::Execution(msg) => write!(f, "execution error: {msg}"),
Self::MissingFeature(msg) => write!(f, "{msg}"),
}
}
}
impl std::error::Error for ExportError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(err) => Some(err),
Self::Format(_) | Self::Execution(_) | Self::MissingFeature(_) => None,
}
}
} }
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
pub enum DrawingFormat { pub enum DrawingFormat {
#[cfg(feature = "svg")] #[cfg(feature = "svg")]
Svg, Svg,
// Weitere Formate wie Png, Pdf, ... // Additional formats: Png, Pdf, …
} }
pub trait DrawingExporter { #[cfg(feature = "svg")]
pub(crate) trait DrawingExporter {
/// Export the drawing to the specified format and filename /// Export the drawing to the specified format and filename
/// ///
/// # Errors /// # Errors
@@ -24,3 +48,271 @@ pub trait DrawingExporter {
/// Returns an error if the export fails (e.g., file I/O error) /// Returns an error if the export fails (e.g., file I/O error)
fn export(&self, world: &TurtleWorld, filename: &str) -> Result<(), ExportError>; fn export(&self, world: &TurtleWorld, filename: &str) -> Result<(), ExportError>;
} }
/// Parse the `--export-svg` parameter from a `pico_args::Arguments` instance.
///
/// # Errors
///
/// Returns `pico_args::Error` if `--export-svg` is provided without a valid filename.
pub fn parse_svg_export_from_args(
args: &mut pico_args::Arguments,
) -> Result<Option<String>, pico_args::Error> {
match args.opt_value_from_str::<_, String>("--export-svg") {
Ok(Some(s)) if s.trim().is_empty() || s.starts_with('-') => {
Err(pico_args::Error::OptionWithoutAValue("--export-svg"))
}
other => other,
}
}
/// Check command-line arguments for the `--export-svg <filename>` flag.
#[must_use]
pub fn parse_svg_export_arg() -> Option<String> {
let mut args = pico_args::Arguments::from_env();
parse_svg_export_from_args(&mut args).ok().flatten()
}
/// Headless SVG export that executes drawing commands and writes an SVG file
/// without opening a graphics window and without calling `std::process::exit`.
///
/// # Errors
///
/// Returns `ExportError` if file I/O fails, drawing execution panics, or if the `svg` feature is not enabled.
pub fn run_headless_svg_export<F>(mut build_commands: F, filename: &str) -> Result<(), ExportError>
where
F: FnMut(&mut TurtlePlan),
{
#[cfg(feature = "svg")]
{
use std::panic::AssertUnwindSafe;
let mut turtle = crate::create_turtle_plan();
let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
build_commands(&mut turtle);
}));
match result {
Ok(()) => {
let mut app = crate::TurtleApp::new();
app.execute_immediate(0, turtle);
app.export_drawing(filename, crate::export::DrawingFormat::Svg)
}
Err(payload) => {
let raw_msg = if let Some(s) = payload.downcast_ref::<&str>() {
(*s).to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
String::new()
};
let is_context_panic = raw_msg.contains("THREAD_ID.is_some()")
|| raw_msg.contains("macroquad");
let err_msg = if is_context_panic {
"Drawing function called Macroquad window/GUI functions (e.g. `screen_width()`, `screen_height()`) which are unavailable in headless SVG export mode.".to_string()
} else if !raw_msg.is_empty() {
raw_msg
} else {
"Drawing function panicked during execution".to_string()
};
Err(ExportError::Execution(err_msg))
}
}
}
#[cfg(not(feature = "svg"))]
{
let _ = &mut build_commands;
let _ = filename;
Err(ExportError::MissingFeature(
"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),
{
let mut args = pico_args::Arguments::from_env();
let export_arg = parse_svg_export_from_args(&mut args);
#[cfg(not(feature = "svg"))]
{
let _ = build_commands;
if export_arg.is_err() || matches!(export_arg, Ok(Some(_))) {
eprintln!("Error: SVG export feature is not enabled. Please rebuild with --features svg");
std::process::exit(1);
}
}
#[cfg(feature = "svg")]
{
match export_arg {
Ok(None) => {}
Err(_) => {
eprintln!("Error: --export-svg: option requires an argument");
std::process::exit(1);
}
Ok(Some(filename)) => {
match run_headless_svg_export(build_commands, &filename) {
Ok(()) => {
println!("SVG exported successfully to: {filename}");
std::process::exit(0);
}
Err(e) => {
if let ExportError::Execution(ref msg) = e {
if msg.contains("unavailable in headless SVG export mode") {
eprintln!("\n================================================================================");
eprintln!("Headless SVG Export Note:");
eprintln!("A Macroquad window/rendering function (e.g. `screen_width()`, `screen_height()`,");
eprintln!("or input check) was called while running in headless export mode.");
eprintln!("Headless export does not initialize a graphics window. To resolve this:");
eprintln!(" - Use relative turtle commands or fixed coordinates instead of window queries, or");
eprintln!(" - Run the program in windowed GUI mode without the `--export-svg` flag.");
eprintln!("================================================================================\n");
}
}
eprintln!("Error: {e}");
std::process::exit(1);
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsString;
fn make_args(args: &[&str]) -> pico_args::Arguments {
let os_args: Vec<OsString> = args.iter().map(OsString::from).collect();
pico_args::Arguments::from_vec(os_args)
}
#[test]
fn test_parse_svg_export_with_filename() {
let mut args = make_args(&["--export-svg", "output.svg"]);
let res = parse_svg_export_from_args(&mut args);
assert_eq!(res.unwrap(), Some("output.svg".to_string()));
}
#[test]
fn test_parse_svg_export_with_equals() {
let mut args = make_args(&["--export-svg=output.svg"]);
let res = parse_svg_export_from_args(&mut args);
assert_eq!(res.unwrap(), Some("output.svg".to_string()));
}
#[test]
fn test_parse_svg_export_missing_argument_at_end() {
let mut args = make_args(&["--export-svg"]);
let res = parse_svg_export_from_args(&mut args);
assert!(matches!(res, Err(pico_args::Error::OptionWithoutAValue(_))));
}
#[test]
fn test_parse_svg_export_missing_argument_followed_by_flag() {
let mut args = make_args(&["--export-svg", "--verbose"]);
let res = parse_svg_export_from_args(&mut args);
assert!(matches!(res, Err(pico_args::Error::OptionWithoutAValue(_))));
}
#[test]
fn test_parse_svg_export_empty_string() {
let mut args = make_args(&["--export-svg", ""]);
let res = parse_svg_export_from_args(&mut args);
assert!(matches!(res, Err(pico_args::Error::OptionWithoutAValue(_))));
}
#[test]
fn test_parse_svg_export_empty_equals() {
let mut args = make_args(&["--export-svg="]);
let res = parse_svg_export_from_args(&mut args);
assert!(matches!(res, Err(pico_args::Error::OptionWithoutAValue(_))));
}
#[test]
fn test_parse_svg_export_not_present() {
let mut args = make_args(&["--verbose", "input.txt"]);
let res = parse_svg_export_from_args(&mut args);
assert_eq!(res.unwrap(), None);
}
#[test]
fn test_parse_svg_export_preserves_unknown_args() {
let mut args = make_args(&["--other-flag", "--export-svg", "out.svg", "positional"]);
let res = parse_svg_export_from_args(&mut args);
assert_eq!(res.unwrap(), Some("out.svg".to_string()));
assert!(args.contains("--other-flag"));
}
#[test]
fn test_export_error_display_missing_feature() {
let err = ExportError::MissingFeature(
"SVG export feature is not enabled. Please rebuild with --features svg".to_string(),
);
assert_eq!(
err.to_string(),
"SVG export feature is not enabled. Please rebuild with --features svg"
);
}
#[cfg(feature = "svg")]
#[test]
fn test_headless_svg_export_success() {
use crate::Movement;
let temp_dir = std::env::temp_dir();
let path = temp_dir.join(format!("turtle_test_export_{}.svg", std::process::id()));
let path_str = path.to_str().expect("valid utf-8 path");
let res = run_headless_svg_export(|turtle| {
turtle.forward(10.0);
}, path_str);
assert!(res.is_ok());
assert!(path.exists());
let content = std::fs::read_to_string(&path).expect("read temp svg");
assert!(content.contains("<svg"));
let _ = std::fs::remove_file(&path);
}
#[cfg(feature = "svg")]
#[test]
fn test_headless_svg_export_macroquad_panic() {
let res = run_headless_svg_export(|_turtle| {
panic!("assertion failed: THREAD_ID.is_some()");
}, "unused.svg");
match res {
Err(ExportError::Execution(msg)) => {
assert!(msg.contains("unavailable in headless SVG export mode"));
}
other => panic!("expected ExportError::Execution, got {other:?}"),
}
}
#[cfg(feature = "svg")]
#[test]
fn test_headless_svg_export_custom_panic() {
let res = run_headless_svg_export(|_turtle| {
panic!("custom user panic occurred");
}, "unused.svg");
match res {
Err(ExportError::Execution(msg)) => {
assert_eq!(msg, "custom user panic occurred");
}
other => panic!("expected ExportError::Execution, got {other:?}"),
}
}
}
+205 -146
View File
@@ -1,27 +1,16 @@
//! SVG-Export-Backend für TurtleWorld //! SVG export backend for `TurtleWorld`.
#[cfg(feature = "svg")] #[cfg(feature = "svg")]
pub mod svg_export { pub mod svg_export {
use crate::commands::TurtleCommand;
use crate::export::{DrawingExporter, ExportError}; use crate::export::{DrawingExporter, ExportError};
use crate::state::{DrawCommand, TurtleWorld}; use crate::state::{SvgRecord, TurtleWorld};
use std::fmt::Write;
use std::fs::File; use std::fs::File;
use svg::{ use svg::{
node::element::{Circle, Line, Polygon, Text as SvgText}, node::element::{Circle, Line, Text as SvgText},
Document, 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( fn update_bounds(
min_x: &mut f32, min_x: &mut f32,
max_x: &mut f32, max_x: &mut f32,
@@ -36,125 +25,135 @@ pub mod svg_export {
*max_y = max_y.max(y); *max_y = max_y.max(y);
} }
pub struct SvgExporter;
impl SvgExporter {
/// Generate an SVG [`Document`] from the given [`TurtleWorld`].
#[must_use]
#[allow(clippy::too_many_lines)]
pub fn to_svg_document(world: &TurtleWorld) -> Document {
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 turtle in &world.turtles {
for cmd in &turtle.commands { for record in &turtle.svg_log.records {
match cmd { match record {
DrawCommand::Mesh { source, .. } => { SvgRecord::Line {
match &source.command { start,
TurtleCommand::Move(_) | TurtleCommand::Goto(_) => { end,
// Linie als <line> color,
let start = source.start_position; pen_width,
let end = source.end_position; } => {
update_bounds( update_bounds(
&mut min_x, &mut max_x, &mut min_y, &mut max_y, start.x, &mut min_x, &mut max_x, &mut min_y, &mut max_y, start.x, start.y,
start.y,
); );
update_bounds( update_bounds(
&mut min_x, &mut max_x, &mut min_y, &mut max_y, end.x, &mut min_x, &mut max_x, &mut min_y, &mut max_y, end.x, end.y,
end.y,
); );
let line = Line::new() let line = Line::new()
.set("x1", start.x) .set("x1", start.x)
.set("y1", start.y) .set("y1", start.y)
.set("x2", end.x) .set("x2", end.x)
.set("y2", end.y) .set("y2", end.y)
.set("stroke", color_to_svg(source.color)) .set("stroke", color_to_svg(*color))
.set("stroke-width", source.pen_width); .set("stroke-width", *pen_width)
.set("stroke-linecap", "round");
doc = doc.add(line); doc = doc.add(line);
} }
TurtleCommand::Circle {
SvgRecord::Arc {
start_position,
start_heading,
radius, radius,
angle, angle,
direction, direction,
.. color,
pen_width,
} => { } => {
use crate::circle_geometry::CircleGeometry; use crate::circle_geometry::CircleGeometry;
use crate::general::Radians;
let geom = CircleGeometry::new( let geom = CircleGeometry::new(
source.start_position, *start_position,
source.start_heading, Radians::new(*start_heading),
*radius, *radius,
*direction, *direction,
); );
let center = geom.center; let center = geom.center;
if (*angle - 360.0).abs() < 1e-3 { let radius_val = radius.abs();
// Voller Kreis // Include the bounding box of the full circle so partial arcs
// are never clipped.
update_bounds( update_bounds(
&mut min_x, &mut min_x,
&mut max_x, &mut max_x,
&mut min_y, &mut min_y,
&mut max_y, &mut max_y,
center.x - radius, center.x - radius_val,
center.y - radius, center.y - radius_val,
); );
update_bounds( update_bounds(
&mut min_x, &mut min_x,
&mut max_x, &mut max_x,
&mut min_y, &mut min_y,
&mut max_y, &mut max_y,
center.x + radius, center.x + radius_val,
center.y + radius, center.y + radius_val,
); );
if (angle.value().abs() - 360.0).abs() < 1e-3 {
// Full circle — emit as <circle>
let circle = Circle::new() let circle = Circle::new()
.set("cx", center.x) .set("cx", center.x)
.set("cy", center.y) .set("cy", center.y)
.set("r", *radius) .set("r", radius_val)
.set("stroke", color_to_svg(source.color)) .set("stroke", color_to_svg(*color))
.set("stroke-width", source.pen_width) .set("stroke-width", *pen_width)
.set("fill", "none"); .set("fill", "none");
doc = doc.add(circle); doc = doc.add(circle);
} else { } else {
// Kreisbogen als <path> // Partial arc — emit as <path A …>
let start = source.start_position; let end = geom.position_at_angle(angle.as_radians().value());
let end = source.end_position; let large_arc = i32::from(angle.value().abs() > 180.0);
// For arcs, include the full circle bounds to ensure complete visibility let sweep = match (direction, angle.value() >= 0.0) {
update_bounds( (crate::circle_geometry::CircleDirection::Right, true)
&mut min_x, | (crate::circle_geometry::CircleDirection::Left, false) => 1,
&mut max_x, (crate::circle_geometry::CircleDirection::Left, true)
&mut min_y, | (crate::circle_geometry::CircleDirection::Right, false) => 0,
&mut max_y,
center.x - radius,
center.y - radius,
);
update_bounds(
&mut min_x,
&mut max_x,
&mut min_y,
&mut max_y,
center.x + radius,
center.y + radius,
);
let large_arc = if *angle > 180.0 { 1 } else { 0 };
let sweep = match direction {
crate::circle_geometry::CircleDirection::Left => 0,
crate::circle_geometry::CircleDirection::Right => 1,
}; };
let d = format!( let d = format!(
"M {} {} A {} {} 0 {} {} {} {}", "M {} {} A {} {} 0 {} {} {} {}",
start.x, start_position.x,
start.y, start_position.y,
radius, radius_val,
radius, radius_val,
large_arc, large_arc,
sweep, sweep,
end.x, end.x,
end.y end.y,
); );
let path = svg::node::element::Path::new() let path = svg::node::element::Path::new()
.set("d", d) .set("d", d)
.set("stroke", color_to_svg(source.color)) .set("stroke", color_to_svg(*color))
.set("stroke-width", source.pen_width) .set("stroke-width", *pen_width)
.set("stroke-linecap", "round")
.set("fill", "none"); .set("fill", "none");
doc = doc.add(path); doc = doc.add(path);
} }
} }
TurtleCommand::EndFill => {
// Fills werden als <path> mit Konturen ausgegeben SvgRecord::Fill {
if let Some(contours) = &source.contours { contours,
fill_color,
stroke_color,
} => {
for contour in contours { for contour in contours {
for point in contour { for point in contour {
update_bounds( update_bounds(
&mut min_x, &mut max_x, &mut min_y, &mut max_y, &mut min_x, &mut max_x, &mut min_y, &mut max_y, point.x,
point.x, point.y, point.y,
); );
} }
} }
@@ -164,15 +163,9 @@ pub mod svg_export {
if i > 0 { if i > 0 {
d.push(' '); d.push(' ');
} }
d.push_str(&format!( let _ = write!(d, "M {} {}", contour[0].x, contour[0].y);
"M {} {}",
contour[0].x, contour[0].y
));
for point in contour.iter().skip(1) { for point in contour.iter().skip(1) {
d.push_str(&format!( let _ = write!(d, " L {} {}", point.x, point.y);
" L {} {}",
point.x, point.y
));
} }
d.push_str(" Z"); d.push_str(" Z");
} }
@@ -180,73 +173,42 @@ pub mod svg_export {
if !d.is_empty() { if !d.is_empty() {
let path = svg::node::element::Path::new() let path = svg::node::element::Path::new()
.set("d", d) .set("d", d)
.set("fill", color_to_svg(source.fill_color)) .set("fill", color_to_svg(*fill_color))
.set("fill-rule", "evenodd") .set("fill-rule", "evenodd")
.set("stroke", color_to_svg(source.color)); .set("stroke", color_to_svg(*stroke_color));
doc = doc.add(path); doc = doc.add(path);
} }
} else {
// Fallback: Dummy-Polygon
update_bounds(
&mut min_x,
&mut max_x,
&mut min_y,
&mut max_y,
source.start_position.x,
source.start_position.y,
);
update_bounds(
&mut min_x,
&mut max_x,
&mut min_y,
&mut max_y,
source.start_position.x + 10.0,
source.start_position.y + 10.0,
);
update_bounds(
&mut min_x,
&mut max_x,
&mut min_y,
&mut max_y,
source.start_position.x + 5.0,
source.start_position.y + 15.0,
);
let poly = Polygon::new()
.set(
"points",
format!(
"{},{} {},{} {},{}",
source.start_position.x,
source.start_position.y,
source.start_position.x + 10.0,
source.start_position.y + 10.0,
source.start_position.x + 5.0,
source.start_position.y + 15.0
),
)
.set("fill", color_to_svg(source.fill_color))
.set("stroke", color_to_svg(source.color));
doc = doc.add(poly);
} }
}
_ => {} SvgRecord::Text {
}
}
DrawCommand::Text {
text, text,
position, position,
source, heading,
font_size,
color,
.. ..
} => { } => {
// Match the offset/rotation applied by the on-screen renderer
// (see draw_text_command in drawing.rs) so the SVG export lines up.
let font_size_val = f32::from(font_size.value());
let offset_distance = font_size_val / 3.0;
let perpendicular_angle = *heading - std::f32::consts::PI / 2.0;
let text_x = position.x + offset_distance * perpendicular_angle.cos();
let text_y = position.y + offset_distance * perpendicular_angle.sin();
update_bounds( update_bounds(
&mut min_x, &mut max_x, &mut min_y, &mut max_y, position.x, &mut min_x, &mut max_x, &mut min_y, &mut max_y, text_x, text_y,
position.y, );
let rotation_deg = heading.to_degrees();
let txt = SvgText::new(text.clone())
.set("x", text_x)
.set("y", text_y)
.set("fill", color_to_svg(*color))
.set("font-size", font_size_val)
.set(
"transform",
format!("rotate({rotation_deg}, {text_x}, {text_y})"),
); );
let txt = SvgText::new()
.set("x", position.x)
.set("y", position.y)
.set("fill", color_to_svg(source.color))
.add(svg::node::Text::new(text.clone()));
doc = doc.add(txt); doc = doc.add(txt);
} }
} }
@@ -260,10 +222,16 @@ pub mod svg_export {
let view_box = format!("{} {} {} {}", min_x - 20.0, min_y - 20.0, width, height); let view_box = format!("{} {} {} {}", min_x - 20.0, min_y - 20.0, width, height);
doc = doc.set("viewBox", view_box); doc = doc.set("viewBox", view_box);
} else { } else {
// Default viewBox if no elements
doc = doc.set("viewBox", "0 0 400 400"); doc = doc.set("viewBox", "0 0 400 400");
} }
doc
}
}
impl DrawingExporter for SvgExporter {
fn export(&self, world: &TurtleWorld, filename: &str) -> Result<(), ExportError> {
let doc = Self::to_svg_document(world);
let mut file = File::create(filename).map_err(ExportError::Io)?; let mut file = File::create(filename).map_err(ExportError::Io)?;
svg::write(&mut file, &doc).map_err(ExportError::Io)?; svg::write(&mut file, &doc).map_err(ExportError::Io)?;
Ok(()) Ok(())
@@ -275,9 +243,100 @@ pub mod svg_export {
let g = (color.g * 255.0) as u8; let g = (color.g * 255.0) as u8;
let b = (color.b * 255.0) as u8; let b = (color.b * 255.0) as u8;
if color.a < 1.0 { if color.a < 1.0 {
format!("rgba({},{},{},{})", r, g, b, color.a) format!("rgba({r},{g},{b},{})", color.a)
} else { } else {
format!("rgb({},{},{})", r, g, b) format!("rgb({r},{g},{b})")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::circle_geometry::CircleDirection;
use crate::general::{Color, Coordinate, Degrees};
use crate::state::{SvgRecord, Turtle};
#[test]
fn test_svg_export_line_has_round_line_caps() {
let mut world = TurtleWorld::new();
let mut turtle = Turtle::default();
turtle.svg_log.push(SvgRecord::Line {
start: Coordinate::new(0.0, 0.0),
end: Coordinate::new(100.0, 0.0),
color: Color::new(0.0, 0.0, 0.0, 1.0),
pen_width: 2.0,
});
world.turtles.push(turtle);
let doc = SvgExporter::to_svg_document(&world);
let svg_string = doc.to_string();
assert!(
svg_string.contains(r#"stroke-linecap="round""#),
"SVG export of lines should have round line caps: {svg_string}"
);
}
#[test]
fn test_svg_export_arc_has_round_line_caps() {
let mut world = TurtleWorld::new();
let mut turtle = Turtle::default();
turtle.svg_log.push(SvgRecord::Arc {
start_position: Coordinate::new(0.0, 0.0),
start_heading: 0.0,
radius: 50.0,
angle: Degrees::new(90.0),
direction: CircleDirection::Right,
color: Color::new(0.0, 0.0, 0.0, 1.0),
pen_width: 2.0,
});
world.turtles.push(turtle);
let doc = SvgExporter::to_svg_document(&world);
let svg_string = doc.to_string();
assert!(
svg_string.contains(r#"stroke-linecap="round""#),
"SVG export of partial arcs should have round line caps: {svg_string}"
);
}
#[test]
fn test_svg_export_arc_negative_angle_sweep() {
let mut world = TurtleWorld::new();
let mut turtle = Turtle::default();
// Circle right with negative angle should sweep counter-clockwise (sweep = 0)
turtle.svg_log.push(SvgRecord::Arc {
start_position: Coordinate::new(0.0, 0.0),
start_heading: 0.0,
radius: 50.0,
angle: Degrees::new(-90.0),
direction: CircleDirection::Right,
color: Color::new(0.0, 0.0, 0.0, 1.0),
pen_width: 2.0,
});
// Circle left with negative angle should sweep clockwise (sweep = 1)
turtle.svg_log.push(SvgRecord::Arc {
start_position: Coordinate::new(100.0, 100.0),
start_heading: 0.0,
radius: 50.0,
angle: Degrees::new(-90.0),
direction: CircleDirection::Left,
color: Color::new(0.0, 0.0, 0.0, 1.0),
pen_width: 2.0,
});
world.turtles.push(turtle);
let doc = SvgExporter::to_svg_document(&world);
let svg_string = doc.to_string();
// Right with negative angle: large_arc=0, sweep=0 -> "0 0 0"
assert!(
svg_string.contains("A 50 50 0 0 0"),
"Circle right with negative angle should have sweep=0: {svg_string}"
);
// Left with negative angle: large_arc=0, sweep=1 -> "0 0 1"
assert!(
svg_string.contains("A 50 50 0 0 1"),
"Circle left with negative angle should have sweep=1: {svg_string}"
);
} }
} }
} }
+48 -4
View File
@@ -6,18 +6,21 @@ pub mod angle;
pub mod fontsize; pub mod fontsize;
pub mod length; pub mod length;
pub use angle::Angle; pub use angle::{Degrees, Radians};
pub use fontsize::FontSize; pub use fontsize::FontSize;
pub use length::Length; pub use length::Length;
/// Precision type for calculations /// Precision type for calculations
pub type Precision = f32; pub type Precision = f32;
/// 2D coordinate in screen space /// 2D coordinate value (`Vec2`) used by turtle commands and state.
///
/// Coordinate orientation depends on API context:
/// - `go_to()` input uses turtle-style coordinates (Y+ is up)
/// - internal render-space state uses Macroquad-style Y-down coordinates
pub type Coordinate = Vec2; pub type Coordinate = Vec2;
/// Visibility flag for turtle
pub type Visibility = bool;
/// Execution speed setting /// Execution speed setting
/// - `Instant(draw_calls)`: Fast execution with limited draw calls per frame (speed - 1000, minimum 1) /// - `Instant(draw_calls)`: Fast execution with limited draw calls per frame (speed - 1000, minimum 1)
@@ -76,11 +79,52 @@ impl From<f32> for AnimationSpeed {
} }
} }
impl From<f64> for AnimationSpeed {
fn from(speed: f64) -> Self {
AnimationSpeed::from_value(speed as f32)
}
}
impl From<u32> for AnimationSpeed { impl From<u32> for AnimationSpeed {
fn from(speed: u32) -> Self { fn from(speed: u32) -> Self {
AnimationSpeed::from_u32(speed) AnimationSpeed::from_u32(speed)
} }
} }
impl From<i32> for AnimationSpeed {
fn from(speed: i32) -> Self {
AnimationSpeed::from_value(speed as f32)
}
}
impl From<usize> for AnimationSpeed {
fn from(speed: usize) -> Self {
AnimationSpeed::from_value(speed as f32)
}
}
/// Color type re-export from macroquad /// Color type re-export from macroquad
pub use macroquad::color::Color; pub use macroquad::color::Color;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn animation_speed_conversions() {
assert_eq!(
AnimationSpeed::from(50.0_f64),
AnimationSpeed::Animated(50.0)
);
assert_eq!(
AnimationSpeed::from(100.0_f32),
AnimationSpeed::Animated(100.0)
);
assert_eq!(AnimationSpeed::from(1000_i32), AnimationSpeed::Instant(1));
assert_eq!(AnimationSpeed::from(1200_u32), AnimationSpeed::Instant(200));
assert_eq!(
AnimationSpeed::from(1500_usize),
AnimationSpeed::Instant(500)
);
}
}
+150 -173
View File
@@ -1,205 +1,182 @@
//! Angle type with degrees and radians support //! Angle unit newtypes: `Degrees` and `Radians`.
//!
//! ## Design
//!
//! Two separate types instead of a single enum so that function signatures are
//! self-documenting and the compiler rejects wrong-unit arguments.
//!
//! - **`Degrees`** — public API boundary. Builder methods and `TurtleCommand`
//! fields that originate from user input store this type. Convert with
//! `as_radians()` before entering the rendering pipeline.
//!
//! - **`Radians`** — internal pipeline. All geometry functions and
//! `TurtleParams` arithmetic work in radians. Extract the raw `f32` with
//! `value()` only where stdlib trig functions (`sin`, `cos`, …) require it.
//!
//! There is intentionally **no** conversion from `Radians` back to `f32` that
//! strips the unit tag silently — use `.value()` explicitly and at the last
//! possible moment.
use super::Precision; use super::Precision;
use std::ops::{Add, Div, Mul, Neg, Rem, Sub}; use std::ops::Neg;
#[derive(Copy, Clone, Debug, PartialEq)] /// An angle measured in degrees.
pub enum AngleUnit { ///
Degrees(Precision), /// Used at the public API boundary. Convert to [`Radians`] with `as_radians()`
Radians(Precision), /// before passing into internal rendering functions.
#[derive(Copy, Clone, Debug, PartialEq, PartialOrd, Default)]
pub struct Degrees(pub Precision);
impl Degrees {
/// Construct from a raw degrees value.
#[must_use]
pub fn new(v: Precision) -> Self {
Self(v)
} }
impl Default for AngleUnit { /// Convert to [`Radians`] for use in the rendering pipeline.
fn default() -> Self { ///
Self::Degrees(0.0) /// This is the **only** correct way to enter the internal math layer.
#[must_use]
pub fn as_radians(self) -> Radians {
Radians(self.0.to_radians())
}
/// The raw degrees value.
///
/// Use only for degree-to-degree arithmetic (e.g. negating a turn angle
/// before storing it as a command). Do not pass this to trig functions.
#[must_use]
pub fn value(self) -> Precision {
self.0
} }
} }
#[derive(Copy, Clone, Debug, PartialEq)] impl Neg for Degrees {
pub struct Angle {
value: AngleUnit,
}
impl Default for Angle {
fn default() -> Self {
Self {
value: AngleUnit::Degrees(0.0),
}
}
}
impl From<i16> for Angle {
fn from(i: i16) -> Self {
Self {
value: AngleUnit::Degrees(Precision::from(i)),
}
}
}
impl From<f32> for Angle {
fn from(f: f32) -> Self {
Self {
value: AngleUnit::Degrees(f),
}
}
}
impl Rem<Precision> for Angle {
type Output = Self; type Output = Self;
fn neg(self) -> Self {
fn rem(self, rhs: Precision) -> Self::Output { Self(-self.0)
match self.value {
AngleUnit::Degrees(v) => Self::degrees(v % rhs),
AngleUnit::Radians(v) => Self::radians(v % rhs),
}
} }
} }
impl Mul<Precision> for Angle { impl From<f32> for Degrees {
fn from(v: f32) -> Self {
Self(v)
}
}
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)
}
}
impl From<i16> for Degrees {
fn from(v: i16) -> Self {
Self(Precision::from(v))
}
}
impl From<usize> for Degrees {
fn from(v: usize) -> Self {
Self(v as Precision)
}
}
// ─────────────────────────────────────────────────────────────────────────────
/// An angle measured in radians.
///
/// Used in all internal function signatures and geometry math. Extract the
/// raw `f32` with [`value()`](Radians::value) only when calling stdlib trig
/// functions (`sin`, `cos`, etc.).
#[derive(Copy, Clone, Debug, PartialEq, PartialOrd, Default)]
pub struct Radians(pub Precision);
impl Radians {
/// Construct from a raw radians value.
#[must_use]
pub fn new(v: Precision) -> Self {
Self(v)
}
/// Convert to [`Degrees`] for display or user-facing output.
#[must_use]
pub fn as_degrees(self) -> Degrees {
Degrees(self.0.to_degrees())
}
/// The raw radians value.
///
/// Use only when calling stdlib trig functions or other `f32`-based
/// math APIs. Keep `Radians` as the type at all internal function
/// boundaries.
#[must_use]
pub fn value(self) -> Precision {
self.0
}
}
impl Neg for Radians {
type Output = Self; type Output = Self;
fn neg(self) -> Self {
fn mul(self, rhs: Precision) -> Self::Output { Self(-self.0)
match self.value {
AngleUnit::Degrees(v) => Self::degrees(v * rhs),
AngleUnit::Radians(v) => Self::radians(v * rhs),
}
}
}
impl Div<Precision> for Angle {
type Output = Self;
fn div(self, rhs: Precision) -> Self::Output {
match self.value {
AngleUnit::Degrees(v) => Self::degrees(v / rhs),
AngleUnit::Radians(v) => Self::radians(v / rhs),
}
}
}
impl Neg for Angle {
type Output = Self;
fn neg(self) -> Self::Output {
match self.value {
AngleUnit::Degrees(v) => Self::degrees(-v),
AngleUnit::Radians(v) => Self::radians(-v),
}
}
}
impl Neg for &Angle {
type Output = Angle;
fn neg(self) -> Self::Output {
match self.value {
AngleUnit::Degrees(v) => Angle::degrees(-v),
AngleUnit::Radians(v) => Angle::radians(-v),
}
}
}
impl Add for Angle {
type Output = Angle;
fn add(self, rhs: Self) -> Self::Output {
match (self.value, rhs.value) {
(AngleUnit::Degrees(v), AngleUnit::Degrees(o)) => Self::degrees(v + o),
(AngleUnit::Degrees(v), AngleUnit::Radians(o)) => Self::radians(v.to_radians() + o),
(AngleUnit::Radians(v), AngleUnit::Degrees(o)) => Self::radians(v + o.to_radians()),
(AngleUnit::Radians(v), AngleUnit::Radians(o)) => Self::radians(v + o),
}
}
}
impl Sub for Angle {
type Output = Angle;
fn sub(self, rhs: Self) -> Self::Output {
match (self.value, rhs.value) {
(AngleUnit::Degrees(v), AngleUnit::Degrees(o)) => Self::degrees(v - o),
(AngleUnit::Degrees(v), AngleUnit::Radians(o)) => Self::radians(v.to_radians() - o),
(AngleUnit::Radians(v), AngleUnit::Degrees(o)) => Self::radians(v - o.to_radians()),
(AngleUnit::Radians(v), AngleUnit::Radians(o)) => Self::radians(v - o),
}
}
}
impl Angle {
#[must_use]
pub fn degrees(value: Precision) -> Self {
Self {
value: AngleUnit::Degrees(value),
} }
} }
#[must_use] impl From<f32> for Radians {
pub fn radians(value: Precision) -> Self { fn from(v: f32) -> Self {
Self { Self(v)
value: AngleUnit::Radians(value),
}
}
#[must_use]
pub fn value(&self) -> Precision {
match self.value {
AngleUnit::Degrees(v) | AngleUnit::Radians(v) => v,
}
}
#[must_use]
pub fn to_radians(self) -> Self {
match self.value {
AngleUnit::Degrees(v) => Self::radians(v.to_radians()),
AngleUnit::Radians(_) => self,
}
}
#[must_use]
pub fn to_degrees(self) -> Self {
match self.value {
AngleUnit::Degrees(_) => self,
AngleUnit::Radians(v) => Self::degrees(v.to_degrees()),
}
}
#[must_use]
pub fn limit_smaller_than_full_circle(self) -> Self {
use std::f32::consts::PI;
match self.value {
AngleUnit::Degrees(v) => Self::degrees(v % 360.0),
AngleUnit::Radians(v) => Self::radians(v % (2.0 * PI)),
}
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::f32::consts::PI;
#[test] #[test]
fn convert_to_radians() { fn degrees_to_radians_roundtrip() {
let radi = Angle::radians(30f32.to_radians()); let deg = Degrees::new(180.0);
let degr = Angle::degrees(30f32); let rad = deg.as_radians();
let converted = degr.to_radians(); assert!(
assert!((radi.value() - converted.value()).abs() < 0.0001); (rad.value() - PI).abs() < 1e-6,
"expected π, got {}",
rad.value()
);
let back = rad.as_degrees();
assert!(
(back.value() - 180.0).abs() < 1e-4,
"expected 180°, got {}",
back.value()
);
} }
#[test] #[test]
fn sum_degrees() { fn negation() {
let fst = Angle::degrees(30f32); assert_eq!(-Degrees::new(90.0), Degrees::new(-90.0));
let snd = Angle::degrees(30f32); assert_eq!(-Radians::new(1.0), Radians::new(-1.0));
let sum = fst + snd;
assert!((sum.value() - 60f32).abs() < 0.0001);
assert!((sum.to_radians().value() - 60f32.to_radians()).abs() < 0.0001);
} }
#[test] #[test]
fn sum_mixed() { fn from_integer() {
let fst = Angle::degrees(30f32); let d: Degrees = 90_i32.into();
let snd = Angle::radians(30f32.to_radians()); assert_eq!(d, Degrees::new(90.0));
let sum = fst + snd; let d2: Degrees = 45_i16.into();
assert!((sum.to_degrees().value() - 60f32).abs() < 0.0001); assert_eq!(d2, Degrees::new(45.0));
assert!((sum.to_radians().value() - 60f32.to_radians()).abs() < 0.0001); let d3: Degrees = 180_usize.into();
assert_eq!(d3, Degrees::new(180.0));
}
#[test]
fn from_f64() {
let d: Degrees = 90.0_f64.into();
assert_eq!(d, Degrees::new(90.0));
} }
} }
+23 -2
View File
@@ -12,7 +12,7 @@ impl FontSize {
/// Get the inner u16 value /// Get the inner u16 value
#[must_use] #[must_use]
pub const fn value(&self) -> u16 { pub const fn value(self) -> u16 {
self.0 self.0
} }
} }
@@ -41,8 +41,29 @@ impl From<i16> for FontSize {
} }
} }
impl From<f64> for FontSize {
fn from(f: f64) -> Self {
Self(f.max(1.0) as u16)
}
}
impl From<usize> for FontSize { impl From<usize> for FontSize {
fn from(size: usize) -> Self { fn from(size: usize) -> Self {
Self((size as u16).max(1)) Self(u16::try_from(size).unwrap_or(u16::MAX).max(1))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn font_size_conversions() {
assert_eq!(FontSize::from(16_u16).value(), 16);
assert_eq!(FontSize::from(24_i32).value(), 24);
assert_eq!(FontSize::from(18_i16).value(), 18);
assert_eq!(FontSize::from(32_usize).value(), 32);
assert_eq!(FontSize::from(20.5_f32).value(), 20);
assert_eq!(FontSize::from(28.0_f64).value(), 28);
} }
} }
+72 -4
View File
@@ -1,13 +1,33 @@
//! Length type for distance measurements //! Length type for distance measurements
use super::Precision; use super::Precision;
use std::ops::Neg;
#[derive(Default, Copy, Clone, Debug, PartialEq)] /// A spatial distance or length measurement.
///
/// Used at the public API boundary for movement distances and arc radii.
#[derive(Default, Copy, Clone, Debug, PartialEq, PartialOrd)]
pub struct Length(pub Precision); pub struct Length(pub Precision);
impl From<i16> for Length { impl Length {
fn from(i: i16) -> Self { /// Create a new `Length` from a raw value.
Self(Precision::from(i)) #[must_use]
pub const fn new(v: Precision) -> Self {
Self(v)
}
/// Extract the raw `Precision` (`f32`) value.
#[must_use]
pub const fn value(self) -> Precision {
self.0
}
}
impl Neg for Length {
type Output = Self;
fn neg(self) -> Self {
Self(-self.0)
} }
} }
@@ -17,8 +37,56 @@ impl From<f32> for Length {
} }
} }
impl From<f64> for Length {
fn from(f: f64) -> Self {
Self(f as Precision)
}
}
impl From<i16> for Length {
fn from(i: i16) -> Self {
Self(Precision::from(i))
}
}
impl From<i32> for Length { impl From<i32> for Length {
fn from(i: i32) -> Self { fn from(i: i32) -> Self {
Self(i as Precision) Self(i as Precision)
} }
} }
impl From<usize> for Length {
fn from(u: usize) -> Self {
Self(u as Precision)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_length_conversions_and_negation() {
let l_f32: Length = 42.5_f32.into();
assert_eq!(l_f32.value(), 42.5);
let l_f64: Length = 100.0_f64.into();
assert_eq!(l_f64.value(), 100.0);
let l_i32: Length = 50_i32.into();
assert_eq!(l_i32.value(), 50.0);
let l_i16: Length = 25_i16.into();
assert_eq!(l_i16.value(), 25.0);
let l_usize: Length = 10_usize.into();
assert_eq!(l_usize.value(), 10.0);
let neg = -l_f32;
assert_eq!(neg.value(), -42.5);
assert!(Length::new(10.0) < Length::new(20.0));
}
}
+66 -43
View File
@@ -46,39 +46,44 @@
//! } //! }
//! ``` //! ```
pub mod builders; pub(crate) mod builders;
pub mod circle_geometry; pub(crate) mod circle_geometry;
pub mod commands; pub(crate) mod command_behavior;
pub mod commands_channel; pub(crate) mod commands;
pub mod drawing; pub(crate) mod commands_channel;
pub mod execution; pub(crate) mod drawing;
pub mod general; pub(crate) mod execution;
pub mod shapes; pub(crate) mod general;
pub mod state; pub(crate) mod shapes;
pub mod tessellation; pub(crate) mod state;
pub mod tweening; pub(crate) mod tessellation;
pub(crate) mod tweening;
// Re-export commonly used types pub use builders::{
pub use builders::{CurvedMovement, DirectionalMovement, Turnable, TurtlePlan, WithCommands}; Cursor, Fill, Movement, Pen, Rotation, Text, TurtlePlan, WithCommands,
};
pub use commands::{CommandQueue, TurtleCommand}; pub use commands::{CommandQueue, TurtleCommand};
pub use commands_channel::{turtle_command_channel, TurtleCommandReceiver, TurtleCommandSender}; pub use commands_channel::TurtleCommandSender;
pub use general::{Angle, AnimationSpeed, Color, Coordinate, Length, Precision}; pub use general::{AnimationSpeed, Color, Coordinate, Degrees, Length, Precision, Radians};
pub use shapes::{ShapeType, TurtleShape}; pub use shapes::{ShapeType, TurtleShape};
pub use state::{DrawCommand, Turtle, TurtleWorld};
pub use tweening::TweenController;
pub mod export; pub mod export;
#[cfg(feature = "svg")] #[cfg(feature = "svg")]
pub mod export_svg; pub(crate) mod export_svg;
// Re-export the turtle_main macro // Re-export the turtle_main macro
pub use turtle_lib_macros::turtle_main; pub use turtle_lib_macros::turtle_main;
// Re-export the macroquad crate so generated macro code can access it directly
pub use macroquad;
// Re-export common macroquad types and colors for convenience // Re-export common macroquad types and colors for convenience
pub use macroquad::prelude::{ pub use macroquad::prelude::{
vec2, BLACK, BLUE, DARKGRAY, GOLD, GREEN, ORANGE, PURPLE, RED, WHITE, YELLOW, vec2, BLACK, BLUE, DARKGRAY, GOLD, GREEN, ORANGE, PURPLE, RED, WHITE, YELLOW,
}; };
use crate::commands_channel::TurtleCommandReceiver;
use crate::state::TurtleWorld;
use macroquad::prelude::*; use macroquad::prelude::*;
use std::collections::HashMap; use std::collections::HashMap;
@@ -95,9 +100,7 @@ pub struct TurtleApp {
} }
impl TurtleApp { impl TurtleApp {
/// Exportiere das aktuelle Drawing in das gewünschte Format /// Export the current drawing to a file in the specified format.
#[allow(unused_variables)]
/// Export the current drawing to a file in the specified format
/// ///
/// # Errors /// # Errors
/// ///
@@ -107,6 +110,7 @@ impl TurtleApp {
filename: &str, filename: &str,
format: export::DrawingFormat, format: export::DrawingFormat,
) -> Result<(), export::ExportError> { ) -> Result<(), export::ExportError> {
let _ = filename;
match format { match format {
#[cfg(feature = "svg")] #[cfg(feature = "svg")]
export::DrawingFormat::Svg => { export::DrawingFormat::Svg => {
@@ -115,10 +119,10 @@ impl TurtleApp {
let exporter = SvgExporter; let exporter = SvgExporter;
exporter.export(&self.world, filename) exporter.export(&self.world, filename)
} }
// Weitere Formate können hier ergänzt werden // Additional formats can be registered here.
#[allow(unreachable_patterns)] #[allow(unreachable_patterns)]
_ => Err(export::ExportError::Format( _ => Err(export::ExportError::Format(
"Export-Format nicht unterstützt".to_string(), "Unsupported export format".to_string(),
)), )),
} }
} }
@@ -216,7 +220,7 @@ impl TurtleApp {
/// ///
/// Speed is controlled by `SetSpeed` commands in the queue. /// Speed is controlled by `SetSpeed` commands in the queue.
/// Use `set_speed()` on the turtle plan to set animation speed. /// Use `set_speed()` on the turtle plan to set animation speed.
/// Speed >= 999 = instant mode, speed < 999 = animated mode. /// Speed >= 1000 = instant mode, speed < 1000 = animated mode.
/// ///
/// # Arguments /// # Arguments
/// * `queue` - The command queue to execute /// * `queue` - The command queue to execute
@@ -229,7 +233,7 @@ impl TurtleApp {
/// ///
/// Speed is controlled by `SetSpeed` commands in the queue. /// Speed is controlled by `SetSpeed` commands in the queue.
/// Use `set_speed()` on the turtle plan to set animation speed. /// Use `set_speed()` on the turtle plan to set animation speed.
/// Speed >= 999 = instant mode, speed < 999 = animated mode. /// Speed >= 1000 = instant mode, speed < 1000 = animated mode.
/// ///
/// # Arguments /// # Arguments
/// * `turtle_id` - The ID of the turtle to control /// * `turtle_id` - The ID of the turtle to control
@@ -250,6 +254,11 @@ impl TurtleApp {
/// Execute a plan immediately on a specific turtle (no animation) /// Execute a plan immediately on a specific turtle (no animation)
pub fn execute_immediate(&mut self, turtle_id: usize, plan: TurtlePlan) { pub fn execute_immediate(&mut self, turtle_id: usize, plan: TurtlePlan) {
// Ensure turtle exists
while self.world.turtles.len() <= turtle_id {
self.world.add_turtle();
}
for ref cmd in plan.build() { for ref cmd in plan.build() {
execution::execute_command_with_id(cmd, turtle_id, &mut self.world); execution::execute_command_with_id(cmd, turtle_id, &mut self.world);
} }
@@ -282,28 +291,40 @@ impl TurtleApp {
} }
} }
/// Update animation state (call every frame) /// Update animation state and process window mouse events (call every frame in GUI loop)
pub fn update(&mut self) { pub fn update(&mut self) {
// Handle mouse panning and zoom // Handle mouse panning and zoom
self.handle_mouse_panning(); self.handle_mouse_panning();
self.handle_mouse_zoom(); self.handle_mouse_zoom();
self.step_animations();
}
/// Drive animation updates for all turtles without querying window or mouse events.
///
/// Suitable for headless execution (such as CLI SVG export) where no graphics window exists.
pub fn step_animations(&mut self) {
// Update all turtles' tween controllers // Update all turtles' tween controllers
for turtle in &mut self.world.turtles { for turtle in &mut self.world.turtles {
// Extract draw_commands and controller temporarily to avoid borrow conflicts // Drive this turtle's animation controller for one frame.
// `update_tweens` splits &mut Turtle into disjoint field borrows so
// Update the controller // TweenController::update can be a proper &mut self method.
let completed_commands = TweenController::update(turtle); let completed_commands = turtle.update_tweens();
// Process all completed commands and add to the turtle's commands // Process all completed commands and add to the turtle's commands
for (completed_cmd, tween_start, mut end_state) in completed_commands { for (completed_cmd, tween_start, end_state) in completed_commands {
let draw_command = execution::add_draw_for_completed_tween( if let Some(draw_cmd) =
execution::tessellate_command(&completed_cmd, &tween_start, end_state.position)
{
#[cfg(feature = "svg")]
execution::push_svg_for_draw(
&completed_cmd, &completed_cmd,
&tween_start, &tween_start,
&mut end_state, end_state.position,
&mut turtle.svg_log,
); );
// Add the new draw commands to the turtle turtle.commands.push(draw_cmd);
turtle.commands.extend(draw_command); }
} }
} }
} }
@@ -367,16 +388,18 @@ impl TurtleApp {
.all(|turtle| turtle.tween_controller.is_complete()) .all(|turtle| turtle.tween_controller.is_complete())
} }
/// Get reference to the world state /// Set the animation speed for all turtles
#[must_use] ///
pub fn world(&self) -> &TurtleWorld { /// # Arguments
&self.world /// * `speed` - The animation speed to set for all turtles
pub fn set_all_turtles_speed(&mut self, speed: AnimationSpeed) {
for turtle in &mut self.world.turtles {
turtle.set_speed(speed);
turtle.tween_controller.set_speed(speed);
}
} }
/// Get mutable reference to the world state
pub fn world_mut(&mut self) -> &mut TurtleWorld {
&mut self.world
}
} }
impl Default for TurtleApp { impl Default for TurtleApp {
+116 -259
View File
@@ -1,40 +1,37 @@
//! Turtle state and world state management //! Turtle state and world state management
use crate::commands::CommandQueue; use crate::commands::CommandQueue;
use crate::general::{Angle, AnimationSpeed, Color, Coordinate}; use crate::general::{AnimationSpeed, Color, Coordinate};
use crate::shapes::TurtleShape; use crate::shapes::TurtleShape;
use crate::tweening::TweenController; use crate::tweening::TweenController;
use macroquad::prelude::*; use macroquad::prelude::*;
/// State during active fill operation /// State during active fill operation
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct FillState { pub(crate) struct FillState {
/// Starting position of the fill
pub start_position: Coordinate,
/// All contours collected so far. Each contour is a separate closed path. /// All contours collected so far. Each contour is a separate closed path.
/// The first contour is the outer boundary, subsequent contours are holes. /// The first contour is the outer boundary, subsequent contours are holes.
pub contours: Vec<Vec<Coordinate>>, pub(crate) contours: Vec<Vec<Coordinate>>,
/// Current contour being built (vertices for the active `pen_down` segment) /// Current contour being built (vertices for the active `pen_down` segment)
pub current_contour: Vec<Coordinate>, pub(crate) current_contour: Vec<Coordinate>,
/// Fill color (cached from when `begin_fill` was called) /// Fill color (cached from when `begin_fill` was called)
pub fill_color: Color, pub(crate) fill_color: Color,
} }
/// Parameters that define a turtle's visual state /// Parameters that define a turtle's visual state
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct TurtleParams { pub(crate) struct TurtleParams {
pub position: Vec2, pub(crate) position: Vec2,
pub heading: f32, pub(crate) heading: f32,
pub pen_down: bool, pub(crate) pen_down: bool,
pub pen_width: f32, pub(crate) pen_width: f32,
pub color: Color, pub(crate) color: Color,
pub fill_color: Option<Color>, pub(crate) fill_color: Option<Color>,
pub visible: bool, pub(crate) visible: bool,
pub shape: crate::shapes::TurtleShape, pub(crate) shape: crate::shapes::TurtleShape,
pub speed: AnimationSpeed, pub(crate) speed: AnimationSpeed,
} }
impl Default for TurtleParams { impl Default for TurtleParams {
@@ -55,19 +52,22 @@ impl Default for TurtleParams {
} }
/// State of a single turtle /// State of a single turtle
#[derive(Clone, Debug)] pub(crate) struct Turtle {
pub struct Turtle { #[allow(clippy::struct_field_names)]
pub turtle_id: usize, pub(crate) turtle_id: usize,
pub params: TurtleParams, pub(crate) params: TurtleParams,
// Fill tracking // Fill tracking
pub filling: Option<FillState>, pub(crate) filling: Option<FillState>,
// Drawing commands created by this turtle // Drawing commands created by this turtle
pub commands: Vec<DrawCommand>, pub(crate) commands: Vec<DrawCommand>,
// SVG draw-event log — populated alongside `commands`, consumed by the SVG exporter
pub(crate) svg_log: SvgLog,
// Animation controller for this turtle // Animation controller for this turtle
pub tween_controller: TweenController, pub(crate) tween_controller: TweenController,
} }
impl Default for Turtle { impl Default for Turtle {
@@ -77,6 +77,7 @@ impl Default for Turtle {
params: TurtleParams::default(), params: TurtleParams::default(),
filling: None, filling: None,
commands: Vec::new(), commands: Vec::new(),
svg_log: SvgLog::default(),
tween_controller: TweenController::new(CommandQueue::new(), AnimationSpeed::default()), tween_controller: TweenController::new(CommandQueue::new(), AnimationSpeed::default()),
} }
} }
@@ -87,232 +88,115 @@ impl Turtle {
self.params.speed = speed; self.params.speed = speed;
} }
#[must_use] /// Drive the animation controller for one frame.
pub fn heading_angle(&self) -> Angle { ///
Angle::radians(self.params.heading) /// Returns `(command, start_params, end_params)` for every command that
} /// completed this frame and whose stroke needs to be tessellated by the
/// caller into a `DrawCommand`.
/// Reset turtle to default state (preserves `turtle_id` and queued commands) ///
pub fn reset(&mut self) { /// This method performs the correct disjoint field-borrow split so that
// Clear all drawings /// `TweenController::update` can be a proper `&mut self` method instead
self.commands.clear(); /// of the old static-method borrow-checker workaround.
pub fn update_tweens(
// Clear fill state
self.filling = None;
// Reset parameters to defaults
self.params = TurtleParams::default();
// Keep turtle_id and tween_controller (preserves queued commands)
}
/// 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, &mut self,
center: Coordinate, ) -> Vec<(crate::commands::TurtleCommand, TurtleParams, TurtleParams)> {
radius: f32, self.tween_controller.update(
start_angle: f32, self.turtle_id,
angle_traveled: f32, &mut self.params,
direction: crate::circle_geometry::CircleDirection, &mut self.filling,
steps: u32, &mut self.commands,
) { &mut self.svg_log,
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`) /// The draw-event log for SVG export.
pub fn reset_fill(&mut self) { ///
self.filling = None; /// When the `svg` feature is **disabled** this is a zero-sized type (ZST) with
/// no fields — it compiles away entirely and adds zero overhead to `Turtle`.
/// When the feature is **enabled** it owns a `Vec<SvgRecord>` that the SVG
/// exporter consumes after rendering.
///
/// All methods are always callable so function signatures that accept
/// `&mut SvgLog` need no feature-gating at the parameter level.
#[derive(Clone, Debug, Default)]
pub(crate) struct SvgLog {
#[cfg(feature = "svg")]
pub(crate) records: Vec<SvgRecord>,
}
impl SvgLog {
#[allow(clippy::unused_self)]
pub(crate) fn clear(&mut self) {
#[cfg(feature = "svg")]
self.records.clear();
}
#[cfg(feature = "svg")]
pub(crate) fn push(&mut self, record: SvgRecord) {
self.records.push(record);
} }
} }
/// Cached mesh data that can be cloned and converted to Mesh when needed /// A drawing event captured for SVG export.
///
/// Only compiled when the `svg` feature is enabled.
#[cfg(feature = "svg")]
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct MeshData { pub(crate) enum SvgRecord {
pub vertices: Vec<macroquad::prelude::Vertex>, /// A straight-line stroke.
pub indices: Vec<u16>, Line {
} start: Vec2,
end: Vec2,
impl MeshData { color: Color,
#[must_use] pen_width: f32,
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 struct TurtleSource {
pub command: crate::commands::TurtleCommand,
pub color: Color,
pub fill_color: Color,
pub pen_width: f32,
pub start_position: Vec2,
pub end_position: Vec2,
pub start_heading: f32,
pub contours: Option<Vec<Vec<crate::general::Coordinate>>>,
}
#[derive(Clone, Debug)]
pub enum DrawCommand {
/// Pre-tessellated mesh data (lines, arcs, circles, polygons - all use this)
Mesh {
data: MeshData,
source: TurtleSource,
}, },
/// Text rendering command /// An arc or full-circle stroke.
Arc {
start_position: Vec2,
start_heading: f32,
radius: crate::general::Precision,
angle: crate::general::Degrees,
direction: crate::circle_geometry::CircleDirection,
color: Color,
pen_width: f32,
},
/// A filled region (potentially with holes via the even-odd rule).
Fill {
contours: Vec<Vec<crate::general::Coordinate>>,
fill_color: Color,
stroke_color: Color,
},
/// A text element.
Text {
text: String,
position: Vec2,
heading: f32,
font_size: crate::general::FontSize,
color: Color,
},
}
/// Drawable elements in the world.
/// All drawing is done via Lyon-tessellated meshes for consistency and quality.
pub(crate) enum DrawCommand {
/// Pre-tessellated mesh data (lines, arcs, circles, polygons — all use this).
Mesh(macroquad::prelude::Mesh),
/// Text rendering command.
Text { Text {
text: String, text: String,
position: Vec2, position: Vec2,
heading: f32, heading: f32,
font_size: crate::general::FontSize, font_size: crate::general::FontSize,
color: Color, color: Color,
source: TurtleSource,
}, },
} }
/// The complete turtle world containing all drawing state /// The complete turtle world containing all drawing state
pub struct TurtleWorld { pub(crate) struct TurtleWorld {
/// All turtles in the world (indexed by turtle ID) /// All turtles in the world (indexed by turtle ID)
pub turtles: Vec<Turtle>, pub(crate) turtles: Vec<Turtle>,
pub camera: Camera2D, pub(crate) camera: Camera2D,
pub background_color: Color,
} }
impl TurtleWorld { impl TurtleWorld {
@@ -320,12 +204,7 @@ impl TurtleWorld {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
turtles: vec![], // Start with no turtles turtles: vec![], // Start with no turtles
camera: Camera2D { camera: Camera2D::default(),
zoom: vec2(1.0 / screen_width() * 2.0, 1.0 / screen_height() * 2.0),
target: vec2(0.0, 0.0),
..Default::default()
},
background_color: WHITE,
} }
} }
@@ -340,32 +219,10 @@ impl TurtleWorld {
turtle_id turtle_id
} }
/// Get turtle by ID
#[must_use]
pub fn get_turtle(&self, id: usize) -> Option<&Turtle> {
self.turtles.get(id)
}
/// Get mutable turtle by ID /// Get mutable turtle by ID
pub fn get_turtle_mut(&mut self, id: usize) -> Option<&mut Turtle> { pub fn get_turtle_mut(&mut self, id: usize) -> Option<&mut Turtle> {
self.turtles.get_mut(id) self.turtles.get_mut(id)
} }
/// Reset a specific turtle to default state and remove all its drawings
pub fn reset_turtle(&mut self, turtle_id: usize) {
if let Some(turtle) = self.get_turtle_mut(turtle_id) {
turtle.reset();
turtle.turtle_id = turtle_id; // Preserve turtle_id after reset
}
}
/// Clear all drawings and reset all turtle states
pub fn clear(&mut self) {
for (id, turtle) in self.turtles.iter_mut().enumerate() {
turtle.reset();
turtle.turtle_id = id; // Preserve turtle_id after reset
}
}
} }
impl Default for TurtleWorld { impl Default for TurtleWorld {
+45 -49
View File
@@ -3,7 +3,6 @@
//! This module provides helper functions to tessellate paths using Lyon, //! This module provides helper functions to tessellate paths using Lyon,
//! which replaces the manual triangulation with GPU-optimized tessellation. //! which replaces the manual triangulation with GPU-optimized tessellation.
use crate::state::MeshData;
use lyon::math::{point, Point}; use lyon::math::{point, Point};
use lyon::path::{LineCap, LineJoin, Path}; use lyon::path::{LineCap, LineJoin, Path};
use lyon::tessellation::{ use lyon::tessellation::{
@@ -14,26 +13,30 @@ use macroquad::prelude::*;
/// Convert macroquad Vec2 to Lyon Point /// Convert macroquad Vec2 to Lyon Point
#[must_use] #[must_use]
pub fn to_lyon_point(v: Vec2) -> Point { pub(crate) fn to_lyon_point(v: Vec2) -> Point {
point(v.x, v.y) point(v.x, v.y)
} }
/// Convert Lyon Point to macroquad Vec2 /// Convert Lyon Point to macroquad Vec2
#[allow(dead_code)] #[allow(dead_code)]
#[must_use] #[must_use]
pub fn to_macroquad_vec2(p: Point) -> Vec2 { pub(crate) fn to_macroquad_vec2(p: Point) -> Vec2 {
vec2(p.x, p.y) vec2(p.x, p.y)
} }
/// Simple vertex type for Lyon tessellation /// Simple vertex type for Lyon tessellation
#[derive(Copy, Clone, Debug)] #[derive(Copy, Clone, Debug)]
pub struct SimpleVertex { pub(crate) struct SimpleVertex {
pub position: [f32; 2], pub(crate) position: [f32; 2],
} }
/// Build mesh data from Lyon tessellation /// Build mesh from Lyon tessellation
#[must_use] #[must_use]
pub fn build_mesh_data(vertices: &[SimpleVertex], indices: &[u16], color: Color) -> MeshData { pub(crate) fn build_mesh(
vertices: &[SimpleVertex],
indices: &[u16],
color: Color,
) -> Mesh {
let verts: Vec<Vertex> = vertices let verts: Vec<Vertex> = vertices
.iter() .iter()
.map(|v| Vertex { .map(|v| Vertex {
@@ -49,9 +52,10 @@ pub fn build_mesh_data(vertices: &[SimpleVertex], indices: &[u16], color: Color)
}) })
.collect(); .collect();
MeshData { Mesh {
vertices: verts, vertices: verts,
indices: indices.to_vec(), indices: indices.to_vec(),
texture: None,
} }
} }
@@ -62,10 +66,10 @@ pub fn build_mesh_data(vertices: &[SimpleVertex], indices: &[u16], color: Color)
/// # Errors /// # Errors
/// ///
/// Returns an error if no vertices are provided or if tessellation fails. /// Returns an error if no vertices are provided or if tessellation fails.
pub fn tessellate_polygon( pub(crate) fn tessellate_polygon(
vertices: &[Vec2], vertices: &[Vec2],
color: Color, color: Color,
) -> Result<MeshData, Box<dyn std::error::Error>> { ) -> Result<Mesh, Box<dyn std::error::Error>> {
if vertices.is_empty() { if vertices.is_empty() {
return Err("No vertices provided".into()); return Err("No vertices provided".into());
} }
@@ -92,7 +96,7 @@ pub fn tessellate_polygon(
}), }),
)?; )?;
Ok(build_mesh_data( Ok(build_mesh(
&geometry.vertices, &geometry.vertices,
&geometry.indices, &geometry.indices,
color, color,
@@ -107,10 +111,10 @@ pub fn tessellate_polygon(
/// # Errors /// # Errors
/// ///
/// Returns an error if no contours are provided or if tessellation fails. /// Returns an error if no contours are provided or if tessellation fails.
pub fn tessellate_multi_contour( pub(crate) fn tessellate_multi_contour(
contours: &[Vec<Vec2>], contours: &[Vec<Vec2>],
color: Color, color: Color,
) -> Result<MeshData, Box<dyn std::error::Error>> { ) -> Result<Mesh, Box<dyn std::error::Error>> {
if contours.is_empty() { if contours.is_empty() {
return Err("No contours provided".into()); return Err("No contours provided".into());
} }
@@ -191,7 +195,7 @@ pub fn tessellate_multi_contour(
} }
} }
Ok(build_mesh_data( Ok(build_mesh(
&geometry.vertices, &geometry.vertices,
&geometry.indices, &geometry.indices,
color, color,
@@ -203,12 +207,12 @@ pub fn tessellate_multi_contour(
/// # Errors /// # Errors
/// ///
/// Returns an error if no vertices are provided or if tessellation fails. /// Returns an error if no vertices are provided or if tessellation fails.
pub fn tessellate_stroke( pub(crate) fn tessellate_stroke(
vertices: &[Vec2], vertices: &[Vec2],
color: Color, color: Color,
width: f32, width: f32,
closed: bool, closed: bool,
) -> Result<MeshData, Box<dyn std::error::Error>> { ) -> Result<Mesh, Box<dyn std::error::Error>> {
if vertices.is_empty() { if vertices.is_empty() {
return Err("No vertices provided".into()); return Err("No vertices provided".into());
} }
@@ -237,7 +241,7 @@ pub fn tessellate_stroke(
}), }),
)?; )?;
Ok(build_mesh_data( Ok(build_mesh(
&geometry.vertices, &geometry.vertices,
&geometry.indices, &geometry.indices,
color, color,
@@ -249,13 +253,13 @@ pub fn tessellate_stroke(
/// # Errors /// # Errors
/// ///
/// Returns an error if tessellation fails. /// Returns an error if tessellation fails.
pub fn tessellate_circle( pub(crate) fn tessellate_circle(
center: Vec2, center: Vec2,
radius: f32, radius: f32,
color: Color, color: Color,
filled: bool, filled: bool,
stroke_width: f32, stroke_width: f32,
) -> Result<MeshData, Box<dyn std::error::Error>> { ) -> Result<Mesh, Box<dyn std::error::Error>> {
let mut builder = Path::builder(); let mut builder = Path::builder();
builder.add_circle(to_lyon_point(center), radius, lyon::path::Winding::Positive); builder.add_circle(to_lyon_point(center), radius, lyon::path::Winding::Positive);
let path = builder.build(); let path = builder.build();
@@ -282,7 +286,7 @@ pub fn tessellate_circle(
)?; )?;
} }
Ok(build_mesh_data( Ok(build_mesh(
&geometry.vertices, &geometry.vertices,
&geometry.indices, &geometry.indices,
color, color,
@@ -295,7 +299,7 @@ pub fn tessellate_circle(
/// ///
/// Returns an error if tessellation fails. /// Returns an error if tessellation fails.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn tessellate_arc( pub(crate) fn tessellate_arc(
center: Vec2, center: Vec2,
radius: f32, radius: f32,
start_angle_degrees: f32, start_angle_degrees: f32,
@@ -304,41 +308,33 @@ pub fn tessellate_arc(
stroke_width: f32, stroke_width: f32,
segments: usize, segments: usize,
direction: crate::circle_geometry::CircleDirection, direction: crate::circle_geometry::CircleDirection,
) -> Result<MeshData, Box<dyn std::error::Error>> { ) -> Result<Mesh, Box<dyn std::error::Error>> {
// Build arc path manually from segments use crate::circle_geometry::arc_points;
let mut builder = Path::builder();
let start_angle = start_angle_degrees.to_radians(); let start_angle = start_angle_degrees.to_radians();
let arc_angle = arc_angle_degrees.to_radians(); let sweep_angle = arc_angle_degrees.to_radians();
let step = arc_angle / segments as f32;
// Calculate first point let mut builder = Path::builder();
let first_point = point(
// Start point of the arc (arc_points returns everything *after* this)
builder.begin(point(
center.x + radius * start_angle.cos(), center.x + radius * start_angle.cos(),
center.y + radius * start_angle.sin(), center.y + radius * start_angle.sin(),
); ));
builder.begin(first_point);
// Add remaining points - direction matters! // Remaining points — single source of truth for arc sampling
for i in 1..=segments { for pt in arc_points(
let angle = match direction { center,
crate::circle_geometry::CircleDirection::Left => { radius,
// Counter-clockwise: subtract angle start_angle,
start_angle - step * i as f32 sweep_angle,
} segments,
crate::circle_geometry::CircleDirection::Right => { direction,
// Clockwise: add angle ) {
start_angle + step * i as f32 builder.line_to(point(pt.x, pt.y));
}
};
let pt = point(
center.x + radius * angle.cos(),
center.y + radius * angle.sin(),
);
builder.line_to(pt);
} }
builder.end(false); // Don't close the arc builder.end(false); // open arc, not a closed polygon
let path = builder.build(); let path = builder.build();
// Tessellate stroke // Tessellate stroke
@@ -356,7 +352,7 @@ pub fn tessellate_arc(
}), }),
)?; )?;
Ok(build_mesh_data( Ok(build_mesh(
&geometry.vertices, &geometry.vertices,
&geometry.indices, &geometry.indices,
color, color,
+301 -186
View File
@@ -2,9 +2,10 @@
use crate::circle_geometry::{CircleDirection, CircleGeometry}; use crate::circle_geometry::{CircleDirection, CircleGeometry};
use crate::commands::{CommandQueue, TurtleCommand}; use crate::commands::{CommandQueue, TurtleCommand};
use crate::general::AnimationSpeed; use crate::general::{AnimationSpeed, Radians};
use crate::state::{Turtle, TurtleParams}; use crate::state::{DrawCommand, FillState, TurtleParams};
use macroquad::prelude::*; use macroquad::prelude::*;
use std::collections::VecDeque;
use tween::{CubicInOut, TweenValue, Tweener}; use tween::{CubicInOut, TweenValue, Tweener};
// Newtype wrapper for Vec2 to implement TweenValue // Newtype wrapper for Vec2 to implement TweenValue
@@ -45,22 +46,22 @@ impl From<TweenVec2> for Vec2 {
/// Controls tweening of turtle commands /// Controls tweening of turtle commands
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
pub struct TweenController { pub(crate) struct TweenController {
queue: CommandQueue, queue: VecDeque<TurtleCommand>,
current_tween: Option<CommandTween>, current_tween: Option<CommandTween>,
speed: AnimationSpeed, speed: AnimationSpeed,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct CommandTween { pub(crate) struct CommandTween {
pub turtle_id: usize, pub(crate) command: TurtleCommand,
pub command: TurtleCommand, pub(crate) progress: f32,
pub start_time: f64, pub(crate) start_params: TurtleParams,
pub duration: f64, pub(crate) target_params: TurtleParams,
pub start_params: TurtleParams, pub(crate) current_position: Vec2,
pub target_params: TurtleParams, pub(crate) current_heading: f32,
pub current_position: Vec2, start_time: f64,
pub current_heading: f32, duration: f64,
position_tweener: Tweener<TweenVec2, f64, CubicInOut>, position_tweener: Tweener<TweenVec2, f64, CubicInOut>,
heading_tweener: Tweener<f32, f64, CubicInOut>, heading_tweener: Tweener<f32, f64, CubicInOut>,
pen_width_tweener: Tweener<f32, f64, CubicInOut>, pen_width_tweener: Tweener<f32, f64, CubicInOut>,
@@ -70,7 +71,7 @@ impl TweenController {
#[must_use] #[must_use]
pub fn new(queue: CommandQueue, speed: AnimationSpeed) -> Self { pub fn new(queue: CommandQueue, speed: AnimationSpeed) -> Self {
Self { Self {
queue, queue: queue.into_iter().collect(),
current_tween: None, current_tween: None,
speed, speed,
} }
@@ -80,51 +81,71 @@ impl TweenController {
self.speed = speed; self.speed = speed;
} }
/// Append commands to the queue /// Append commands to the queue.
///
/// Consumed commands are removed as they execute, and new commands
/// are queued at the back.
pub fn append_commands(&mut self, new_queue: CommandQueue) { pub fn append_commands(&mut self, new_queue: CommandQueue) {
self.queue.extend(new_queue); self.queue.extend(new_queue);
} }
/// Update the tween, returns `Vec` of (`command`, `start_state`, `end_state`) for all completed commands this frame /// Drive the animation controller for one frame.
/// Also takes commands vec to handle side effects like fill operations ///
/// Each `command` has its own `start_state` and `end_state` pair /// Returns `(command, start_params, end_params)` for every command that
/// completed this frame and whose stroke needs to be tessellated by the
/// caller.
///
/// By accepting `params`, `filling`, and `commands` as separate mutable
/// borrows the caller can split `&mut Turtle` into disjoint field borrows,
/// eliminating the old static-method borrow-checker workaround.
#[allow(clippy::too_many_lines)] #[allow(clippy::too_many_lines)]
pub fn update(state: &mut Turtle) -> Vec<(TurtleCommand, TurtleParams, TurtleParams)> { pub fn update(
&mut self,
turtle_id: usize,
params: &mut TurtleParams,
filling: &mut Option<FillState>,
commands: &mut Vec<DrawCommand>,
svg_log: &mut crate::state::SvgLog,
) -> Vec<(TurtleCommand, TurtleParams, TurtleParams)> {
// In instant mode, execute commands up to the draw calls per frame limit // In instant mode, execute commands up to the draw calls per frame limit
if let AnimationSpeed::Instant(max_draw_calls) = state.tween_controller.speed { if let AnimationSpeed::Instant(max_draw_calls) = self.speed {
let mut completed_commands: Vec<(TurtleCommand, TurtleParams, TurtleParams)> = let mut completed_commands: Vec<(TurtleCommand, TurtleParams, TurtleParams)> =
Vec::new(); Vec::new();
let mut draw_call_count = 0; let mut draw_call_count = 0;
// Consume commands from the real queue so the current_index advances // Consume commands from the front of the queue
while let Some(command) = state.tween_controller.queue.next() { while let Some(command) = self.queue.pop_front() {
// Handle SetSpeed command to potentially switch modes // Handle SetSpeed command to potentially switch modes
if let TurtleCommand::SetSpeed(new_speed) = &command { if let TurtleCommand::SetSpeed(new_speed) = &command {
state.params.speed = *new_speed; params.speed = *new_speed;
state.tween_controller.speed = *new_speed; self.speed = *new_speed;
if matches!(state.tween_controller.speed, AnimationSpeed::Animated(_)) { if matches!(self.speed, AnimationSpeed::Animated(_)) {
break; break;
} }
continue; continue;
} }
// Execute side-effect-only commands using centralized helper // Execute side-effect-only commands using centralized helper
if crate::execution::execute_command_side_effects(&command, state) { if crate::execution::execute_command_side_effects(
&command, turtle_id, params, filling, commands, svg_log,
) {
continue; // Command fully handled continue; // Command fully handled
} }
// Save start state and compute target state // Save start state and compute target state
let start_params = state.params.clone(); let start_params = params.clone();
let target_params = Self::calculate_target_state(&start_params, &command); let target_params = Self::calculate_target_state(&start_params, &command);
// Update state to the target (instant execution) // Update state to the target (instant execution)
state.params = target_params.clone(); *params = target_params.clone();
// Record fill vertices AFTER movement // Record fill vertices AFTER movement
crate::execution::record_fill_vertices_after_movement( crate::execution::record_fill_vertices_after_movement(
&command, &command,
&start_params, &start_params,
state, turtle_id,
params,
filling,
); );
// Collect drawable commands (return start and target so caller can create draw meshes) // Collect drawable commands (return start and target so caller can create draw meshes)
@@ -141,12 +162,13 @@ impl TweenController {
} }
// Process current tween // Process current tween
if let Some(ref mut tween) = state.tween_controller.current_tween { if let Some(ref mut tween) = self.current_tween {
let elapsed = get_time() - tween.start_time; let elapsed = current_time() - tween.start_time;
// Use tweeners to calculate current values // Use tweeners to calculate current values
// For circles, calculate position along the arc instead of straight line // For circles, calculate position along the arc instead of straight line
let progress = tween.heading_tweener.move_to(elapsed); let progress = tween.heading_tweener.move_to(elapsed).clamp(0.0, 1.0);
tween.progress = progress;
let current_position = match &tween.command { let current_position = match &tween.command {
TurtleCommand::Circle { TurtleCommand::Circle {
@@ -155,11 +177,11 @@ impl TweenController {
direction, direction,
.. ..
} => { } => {
let angle_traveled = angle.to_radians() * progress; let angle_traveled = angle.as_radians().value() * progress;
calculate_circle_position( calculate_circle_position(
tween.start_params.position, tween.start_params.position,
tween.start_params.heading, Radians::new(tween.start_params.heading),
*radius, radius.value(),
angle_traveled, angle_traveled,
*direction, *direction,
) )
@@ -170,7 +192,7 @@ impl TweenController {
} }
}; };
state.params.position = current_position; params.position = current_position;
tween.current_position = current_position; tween.current_position = current_position;
// Heading changes proportionally with progress for all commands // Heading changes proportionally with progress for all commands
@@ -179,14 +201,14 @@ impl TweenController {
angle, direction, .. angle, direction, ..
} => match direction { } => match direction {
CircleDirection::Left => { CircleDirection::Left => {
tween.start_params.heading - angle.to_radians() * progress tween.start_params.heading - angle.as_radians().value() * progress
} }
CircleDirection::Right => { CircleDirection::Right => {
tween.start_params.heading + angle.to_radians() * progress tween.start_params.heading + angle.as_radians().value() * progress
} }
}, },
TurtleCommand::Turn(angle) => { TurtleCommand::Turn(angle) => {
tween.start_params.heading + angle.to_radians() * progress tween.start_params.heading + angle.as_radians().value() * progress
} }
_ => { _ => {
// For other commands that change heading, lerp directly // For other commands that change heading, lerp directly
@@ -195,18 +217,18 @@ impl TweenController {
} }
}); });
state.params.heading = current_heading; params.heading = current_heading;
tween.current_heading = current_heading; tween.current_heading = current_heading;
state.params.pen_width = tween.pen_width_tweener.move_to(elapsed); params.pen_width = tween.pen_width_tweener.move_to(elapsed);
// Discrete properties (switch at 50% progress) // Discrete properties (switch at 50% progress)
let progress = (elapsed / tween.duration).min(1.0); let progress = (elapsed / tween.duration).min(1.0);
if progress >= 0.5 { if progress >= 0.5 {
state.params.pen_down = tween.target_params.pen_down; params.pen_down = tween.target_params.pen_down;
state.params.color = tween.target_params.color; params.color = tween.target_params.color;
state.params.fill_color = tween.target_params.fill_color; params.fill_color = tween.target_params.fill_color;
state.params.visible = tween.target_params.visible; params.visible = tween.target_params.visible;
state.params.shape = tween.target_params.shape.clone(); params.shape = tween.target_params.shape.clone();
} }
// Check if tween is finished (use heading_tweener as it's used by all commands) // Check if tween is finished (use heading_tweener as it's used by all commands)
@@ -215,20 +237,24 @@ impl TweenController {
let target_params = tween.target_params.clone(); let target_params = tween.target_params.clone();
let command = tween.command.clone(); let command = tween.command.clone();
// Drop the mutable borrow of tween before mutably borrowing state // tween borrow ends here (NLL) — safe to reassign self.current_tween below
state.params = target_params.clone(); *params = target_params.clone();
crate::execution::record_fill_vertices_after_movement( crate::execution::record_fill_vertices_after_movement(
&command, &command,
&start_params, &start_params,
state, turtle_id,
params,
filling,
); );
state.tween_controller.current_tween = None; self.current_tween = None;
// Execute side-effect-only commands using centralized helper // Execute side-effect-only commands using centralized helper
if crate::execution::execute_command_side_effects(&command, state) { if crate::execution::execute_command_side_effects(
return Self::update(state); // Continue to next command &command, turtle_id, params, filling, commands, svg_log,
) {
return self.update(turtle_id, params, filling, commands, svg_log);
} }
// Return drawable commands using the original start and target params // Return drawable commands using the original start and target params
@@ -236,43 +262,43 @@ impl TweenController {
return vec![(command, start_params.clone(), target_params.clone())]; return vec![(command, start_params.clone(), target_params.clone())];
} }
return Self::update(state); // Continue to next command return self.update(turtle_id, params, filling, commands, svg_log);
} }
return Vec::new(); return Vec::new();
} }
// Start next tween // Start next tween
if let Some(command) = state.tween_controller.queue.next() { if let Some(command) = self.queue.pop_front() {
let command_clone = command.clone();
// Handle commands that should execute immediately (no animation) // Handle commands that should execute immediately (no animation)
match &command_clone { match &command {
TurtleCommand::SetSpeed(new_speed) => { TurtleCommand::SetSpeed(new_speed) => {
state.set_speed(*new_speed); params.speed = *new_speed;
state.tween_controller.speed = *new_speed; self.speed = *new_speed;
if matches!(state.tween_controller.speed, AnimationSpeed::Instant(_)) { if matches!(self.speed, AnimationSpeed::Instant(_)) {
return Self::update(state); return self.update(turtle_id, params, filling, commands, svg_log);
} }
return Self::update(state); return self.update(turtle_id, params, filling, commands, svg_log);
} }
_ => { _ => {
// Use centralized helper for side effects // Use centralized helper for side effects
if crate::execution::execute_command_side_effects(&command_clone, state) { if crate::execution::execute_command_side_effects(
return Self::update(state); &command, turtle_id, params, filling, commands, svg_log,
) {
return self.update(turtle_id, params, filling, commands, svg_log);
} }
} }
} }
let speed = state.tween_controller.speed; // Extract speed before borrowing self let speed = self.speed;
let duration = Self::calculate_duration_with_state(&command_clone, state, speed); let duration = Self::calculate_duration_with_state(&command, params, speed);
// Calculate target state // Calculate target state
let target_state = Self::calculate_target_state(&state.params, &command_clone); let target_state = Self::calculate_target_state(params, &command);
// Create tweeners for smooth animation // Create tweeners for smooth animation
let position_tweener = Tweener::new( let position_tweener = Tweener::new(
TweenVec2::from(state.params.position), TweenVec2::from(params.position),
TweenVec2::from(target_state.position), TweenVec2::from(target_state.position),
duration, duration,
CubicInOut, CubicInOut,
@@ -284,21 +310,21 @@ impl TweenController {
); );
let pen_width_tweener = Tweener::new( let pen_width_tweener = Tweener::new(
state.params.pen_width, params.pen_width,
target_state.pen_width, target_state.pen_width,
duration, duration,
CubicInOut, CubicInOut,
); );
state.tween_controller.current_tween = Some(CommandTween { self.current_tween = Some(CommandTween {
turtle_id: state.turtle_id, command,
command: command_clone, progress: 0.0,
start_time: get_time(), start_time: current_time(),
duration, duration,
start_params: state.params.clone(), start_params: params.clone(),
target_params: target_state.clone(), target_params: target_state.clone(),
current_position: state.params.position, current_position: params.position,
current_heading: state.params.heading, current_heading: params.heading,
position_tweener, position_tweener,
heading_tweener, heading_tweener,
pen_width_tweener, pen_width_tweener,
@@ -310,7 +336,7 @@ impl TweenController {
#[must_use] #[must_use]
pub fn is_complete(&self) -> bool { pub fn is_complete(&self) -> bool {
self.current_tween.is_none() && self.queue.is_complete() self.current_tween.is_none() && self.queue.is_empty()
} }
/// Get the current active tween if one is in progress /// Get the current active tween if one is in progress
@@ -319,131 +345,35 @@ impl TweenController {
} }
fn command_creates_drawing(command: &TurtleCommand) -> bool { fn command_creates_drawing(command: &TurtleCommand) -> bool {
matches!( command.produces_drawing()
command,
TurtleCommand::Move(_) | TurtleCommand::Circle { .. } | TurtleCommand::Goto(_)
)
} }
fn calculate_duration_with_state( fn calculate_duration_with_state(
command: &TurtleCommand, command: &TurtleCommand,
current: &Turtle, params: &TurtleParams,
speed: AnimationSpeed, speed: AnimationSpeed,
) -> f64 { ) -> f64 {
let mut speed = speed.value(); command.animation_duration(params, speed)
// For high speeds, make animation even faster by scaling speed exponentially
if speed > 100.0 {
speed *= speed / 100.0;
}
let base_time = match command {
TurtleCommand::Move(dist) => dist.abs() / speed,
TurtleCommand::Turn(angle) => {
// Rotation speed: assume 180 degrees per second at speed 100
angle.abs() / (speed * 1.8)
}
TurtleCommand::Circle { radius, angle, .. } => {
let arc_length = radius * angle.to_radians().abs();
arc_length / speed
}
TurtleCommand::Goto(target) => {
// Calculate actual distance from current position to target
let dx = target.x - current.params.position.x;
let dy = target.y - current.params.position.y;
let distance = (dx * dx + dy * dy).sqrt();
distance / speed
}
_ => 0.0, // Instant commands
};
f64::from(base_time.max(0.01)) // Minimum duration
} }
fn calculate_target_state(current: &TurtleParams, command: &TurtleCommand) -> TurtleParams { fn calculate_target_state(current: &TurtleParams, command: &TurtleCommand) -> TurtleParams {
let mut target = current.clone(); let mut target = current.clone();
command.apply_to_params(&mut target);
match command {
TurtleCommand::Move(dist) => {
let dx = dist * current.heading.cos();
let dy = dist * current.heading.sin();
target.position = vec2(current.position.x + dx, current.position.y + dy);
}
TurtleCommand::Turn(angle) => {
target.heading = normalize_angle(current.heading + angle.to_radians());
}
TurtleCommand::Circle {
radius,
angle,
direction,
..
} => {
// Use helper function to calculate final position
target.position = calculate_circle_position(
current.position,
current.heading,
*radius,
angle.to_radians(),
*direction,
);
target.heading = normalize_angle(match direction {
CircleDirection::Left => current.heading - angle.to_radians(),
CircleDirection::Right => current.heading + angle.to_radians(),
});
}
TurtleCommand::Goto(coord) => {
// Flip Y coordinate: turtle graphics uses Y+ = up, but Macroquad uses Y+ = down
target.position = vec2(coord.x, -coord.y);
}
TurtleCommand::SetHeading(heading) => {
target.heading = normalize_angle(*heading);
}
TurtleCommand::SetColor(color) => {
target.color = *color;
}
TurtleCommand::SetPenWidth(width) => {
target.pen_width = *width;
}
TurtleCommand::SetSpeed(speed) => {
target.speed = *speed;
}
TurtleCommand::SetShape(shape) => {
target.shape = shape.clone();
}
TurtleCommand::PenUp => {
target.pen_down = false;
}
TurtleCommand::PenDown => {
target.pen_down = true;
}
TurtleCommand::ShowTurtle => {
target.visible = true;
}
TurtleCommand::HideTurtle => {
target.visible = false;
}
TurtleCommand::SetFillColor(color) => {
target.fill_color = *color;
}
TurtleCommand::BeginFill | TurtleCommand::EndFill | TurtleCommand::WriteText { .. } => {
// Fill and text commands don't change turtle state for tweening purposes
// They're handled directly in execution
}
TurtleCommand::Reset => {
// Reset returns to default state
target = TurtleParams::default();
}
}
target target
} }
} }
/// Calculate position on a circular arc /// Calculate position on a circular arc.
///
/// `start_heading` is in radians (typed as `Radians` to make the unit
/// explicit at every call site). `angle_traveled` is already a raw `f32`
/// radians value produced by multiplying `Degrees::as_radians().value()`
/// by a tween progress scalar.
fn calculate_circle_position( fn calculate_circle_position(
start_pos: Vec2, start_pos: Vec2,
start_heading: f32, start_heading: Radians,
radius: f32, radius: f32,
angle_traveled: f32, // How much of the total angle we've traveled (in radians) angle_traveled: f32,
direction: CircleDirection, direction: CircleDirection,
) -> Vec2 { ) -> Vec2 {
let geom = CircleGeometry::new(start_pos, start_heading, radius, direction); let geom = CircleGeometry::new(start_pos, start_heading, radius, direction);
@@ -451,7 +381,7 @@ fn calculate_circle_position(
} }
/// Normalize angle to range [-PI, PI] to prevent floating-point drift /// Normalize angle to range [-PI, PI] to prevent floating-point drift
fn normalize_angle(angle: f32) -> f32 { pub(crate) fn normalize_angle(angle: f32) -> f32 {
let two_pi = std::f32::consts::PI * 2.0; let two_pi = std::f32::consts::PI * 2.0;
let mut normalized = angle % two_pi; let mut normalized = angle % two_pi;
@@ -464,3 +394,188 @@ fn normalize_angle(angle: f32) -> f32 {
normalized normalized
} }
#[inline]
fn current_time() -> f64 {
#[cfg(not(target_arch = "wasm32"))]
{
use std::sync::OnceLock;
use std::time::Instant;
static START: OnceLock<Instant> = OnceLock::new();
START.get_or_init(Instant::now).elapsed().as_secs_f64()
}
#[cfg(target_arch = "wasm32")]
{
macroquad::time::get_time()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::TurtleCommand;
use crate::general::{Degrees, Length};
use crate::state::TurtleParams;
fn make_test_params() -> TurtleParams {
TurtleParams {
position: vec2(0.0, 0.0),
heading: 0.0,
pen_down: true,
pen_width: 1.0,
color: Color::new(0.0, 0.0, 0.0, 1.0),
fill_color: None,
visible: true,
shape: crate::shapes::TurtleShape::turtle(),
speed: AnimationSpeed::Instant(100),
}
}
#[test]
fn test_instant_mode_drains_queue() {
let mut queue = CommandQueue::new();
queue.push(TurtleCommand::Move(Length::new(100.0)));
queue.push(TurtleCommand::Turn(Degrees::new(90.0)));
queue.push(TurtleCommand::PenUp);
queue.push(TurtleCommand::Move(Length::new(50.0)));
let mut controller = TweenController::new(queue, AnimationSpeed::Instant(100));
assert_eq!(controller.queue.len(), 4);
assert!(!controller.is_complete());
let mut params = make_test_params();
let mut filling = None;
let mut commands = Vec::new();
let mut svg_log = crate::state::SvgLog::default();
let completed = controller.update(0, &mut params, &mut filling, &mut commands, &mut svg_log);
assert_eq!(controller.queue.len(), 0, "Queue must be empty after instant update");
assert!(controller.is_complete(), "Controller must be complete when queue is drained");
assert!(!completed.is_empty());
}
#[test]
fn test_streaming_append_commands_does_not_accumulate() {
let mut controller = TweenController::new(CommandQueue::new(), AnimationSpeed::Instant(100));
let mut params = make_test_params();
let mut filling = None;
let mut commands = Vec::new();
let mut svg_log = crate::state::SvgLog::default();
// Simulate streaming commands across 50 frames (like clock_threaded)
for _ in 0..50 {
let mut batch = CommandQueue::new();
batch.push(TurtleCommand::Reset);
batch.push(TurtleCommand::PenDown);
batch.push(TurtleCommand::Move(Length::new(10.0)));
batch.push(TurtleCommand::Turn(Degrees::new(30.0)));
controller.append_commands(batch);
assert_eq!(controller.queue.len(), 4);
controller.update(0, &mut params, &mut filling, &mut commands, &mut svg_log);
// Verify queue is pruned back to 0 — no memory leak / accumulation
assert_eq!(controller.queue.len(), 0);
assert!(controller.is_complete());
}
}
#[test]
fn test_instant_mode_respects_batch_limit_and_retains_pending() {
let mut queue = CommandQueue::new();
// 5 drawing commands
queue.push(TurtleCommand::Move(Length::new(10.0)));
queue.push(TurtleCommand::Move(Length::new(20.0)));
queue.push(TurtleCommand::Move(Length::new(30.0)));
queue.push(TurtleCommand::Move(Length::new(40.0)));
queue.push(TurtleCommand::Move(Length::new(50.0)));
// Limit to 2 draw calls per frame
let mut controller = TweenController::new(queue, AnimationSpeed::Instant(2));
let mut params = make_test_params();
let mut filling = None;
let mut commands = Vec::new();
let mut svg_log = crate::state::SvgLog::default();
// Frame 1: processes 2 drawing commands
let completed1 = controller.update(0, &mut params, &mut filling, &mut commands, &mut svg_log);
assert_eq!(completed1.len(), 2);
assert_eq!(controller.queue.len(), 3, "3 commands should remain in queue");
assert!(!controller.is_complete());
// Frame 2: processes next 2 drawing commands
let completed2 = controller.update(0, &mut params, &mut filling, &mut commands, &mut svg_log);
assert_eq!(completed2.len(), 2);
assert_eq!(controller.queue.len(), 1, "1 command should remain in queue");
assert!(!controller.is_complete());
// Frame 3: processes last drawing command
let completed3 = controller.update(0, &mut params, &mut filling, &mut commands, &mut svg_log);
assert_eq!(completed3.len(), 1);
assert_eq!(controller.queue.len(), 0, "Queue must be completely drained");
assert!(controller.is_complete());
}
#[test]
fn test_animated_mode_pops_to_current_tween() {
let mut queue = CommandQueue::new();
queue.push(TurtleCommand::Move(Length::new(100.0)));
queue.push(TurtleCommand::Move(Length::new(50.0)));
let mut controller = TweenController::new(
queue,
AnimationSpeed::Animated(100.0),
);
assert_eq!(controller.queue.len(), 2);
assert!(!controller.is_complete());
let mut params = make_test_params();
let mut filling = None;
let mut commands = Vec::new();
let mut svg_log = crate::state::SvgLog::default();
// Calling update should pop the first command into current_tween
controller.update(0, &mut params, &mut filling, &mut commands, &mut svg_log);
assert_eq!(controller.queue.len(), 1, "First command must be popped into current_tween");
let active = controller.current_tween().expect("Must have active tween");
assert_eq!(active.progress, 0.0, "New tween must initialize progress to 0.0");
assert!(!controller.is_complete());
}
#[test]
fn test_animated_mode_progress_advances_and_clamps() {
let mut queue = CommandQueue::new();
// Circle command with speed 10.0 and radius 100 => duration ~62.8s
queue.push(TurtleCommand::Circle {
radius: Length::new(100.0),
angle: Degrees::new(360.0),
steps: 36,
direction: CircleDirection::Right,
});
let mut controller = TweenController::new(
queue,
AnimationSpeed::Animated(10.0),
);
let mut params = make_test_params();
let mut filling = None;
let mut commands = Vec::new();
let mut svg_log = crate::state::SvgLog::default();
// Frame 0: pops into current_tween with progress = 0.0
controller.update(0, &mut params, &mut filling, &mut commands, &mut svg_log);
let initial_progress = controller.current_tween().unwrap().progress;
assert_eq!(initial_progress, 0.0);
// Advance time by sleeping briefly
std::thread::sleep(std::time::Duration::from_millis(15));
controller.update(0, &mut params, &mut filling, &mut commands, &mut svg_log);
if let Some(tween) = controller.current_tween() {
assert!(tween.progress >= 0.0 && tween.progress <= 1.0);
assert!(tween.progress >= initial_progress);
}
}
}