Files
turtle/turtle-lib/src/circle_geometry.rs
T
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

186 lines
6.6 KiB
Rust

//! Circle geometry calculations - single source of truth for `circle_left` and `circle_right`
use crate::general::Radians;
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)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CircleDirection {
Left, // Counter-clockwise visually, heading decreases
Right, // Clockwise visually, heading increases
}
/// Encapsulates all geometry for a circular arc
pub(crate) struct CircleGeometry {
pub(crate) center: Vec2,
pub(crate) radius: f32,
pub(crate) start_angle_from_center: f32, // radians
pub(crate) direction: CircleDirection,
}
impl CircleGeometry {
/// Create geometry for a circle command
#[must_use]
pub fn new(
turtle_pos: Vec2,
turtle_heading: Radians,
radius: f32,
direction: CircleDirection,
) -> Self {
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
// In screen coordinates (Y-down):
// - Left turn (counter-clockwise visually): center is perpendicular-left from turtle's perspective
// which is heading - π/2 (rotated clockwise from heading vector)
// - Right turn (clockwise visually): center is perpendicular-right from turtle's perspective
// which is heading + π/2 (rotated counter-clockwise from heading vector)
let center_offset_angle = match direction {
CircleDirection::Left => heading - FRAC_PI_2,
CircleDirection::Right => heading + FRAC_PI_2,
};
let center = vec2(
turtle_pos.x + radius * center_offset_angle.cos(),
turtle_pos.y + radius * center_offset_angle.sin(),
);
// Angle from center back to turtle position
let start_angle_from_center = match direction {
CircleDirection::Left => heading + FRAC_PI_2,
CircleDirection::Right => heading - FRAC_PI_2,
};
Self {
center,
radius,
start_angle_from_center,
direction,
}
}
/// Calculate position after traveling an angle along the arc
#[must_use]
pub fn position_at_angle(&self, angle_traveled: f32) -> Vec2 {
let current_angle = match self.direction {
CircleDirection::Left => self.start_angle_from_center - angle_traveled,
CircleDirection::Right => self.start_angle_from_center + angle_traveled,
};
vec2(
self.center.x + self.radius * current_angle.cos(),
self.center.y + self.radius * current_angle.sin(),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::f32::consts::{FRAC_PI_2, PI};
#[test]
fn test_circle_left_geometry() {
let geom = CircleGeometry::new(
vec2(0.0, 0.0),
Radians::new(0.0), // heading east (0 radians)
100.0,
CircleDirection::Left,
);
// For left turn with heading east (0), center should be at heading - π/2
// That's -π/2 radians = south
// Center = start + 100 * (cos(-π/2), sin(-π/2)) = (0, 0) + (0, -100) = (0, -100)
assert!(
(geom.center.x - 0.0).abs() < 0.01,
"center.x = {}",
geom.center.x
);
assert!(
(geom.center.y - (-100.0)).abs() < 0.01,
"center.y = {}",
geom.center.y
);
// After π/2 radians counter-clockwise around a circle centered at (0, -100):
// start_angle = π/2 (pointing north from center, which is where (0,0) is)
// after π/2 counter-clockwise (subtract in screen coords): angle = π/2 - π/2 = 0 (pointing east from center)
// pos = (0, -100) + 100 * (cos(0), sin(0)) = (0, -100) + (100, 0) = (100, -100)
let pos = geom.position_at_angle(FRAC_PI_2);
assert!((pos.x - 100.0).abs() < 0.01, "pos.x = {}", pos.x);
assert!((pos.y - (-100.0)).abs() < 0.01, "pos.y = {}", pos.y);
}
#[test]
fn test_circle_right_geometry() {
let geom = CircleGeometry::new(
vec2(0.0, 0.0),
Radians::new(0.0), // heading east
100.0,
CircleDirection::Right,
);
// For right turn with heading east (0), center should be at heading + π/2
// That's π/2 radians = north
// Center = start + 100 * (cos(π/2), sin(π/2)) = (0, 0) + (0, 100) = (0, 100)
assert!(
(geom.center.x - 0.0).abs() < 0.01,
"center.x = {}",
geom.center.x
);
assert!(
(geom.center.y - 100.0).abs() < 0.01,
"center.y = {}",
geom.center.y
);
// After π/2 radians clockwise around a circle centered at (0, 100):
// start_angle = -π/2 (pointing south from center, which is where (0,0) is)
// after π/2 clockwise (add in screen coords): angle = -π/2 + π/2 = 0 (pointing east from center)
// pos = (0, 100) + 100 * (cos(0), sin(0)) = (0, 100) + (100, 0) = (100, 100)
let pos = geom.position_at_angle(PI / 2.0);
assert!((pos.x - 100.0).abs() < 0.01, "pos.x = {}", pos.x);
assert!((pos.y - 100.0).abs() < 0.01, "pos.y = {}", pos.y);
}
}