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.**
This commit is contained in:
2026-09-19 09:24:10 +02:00
parent 823bf13c24
commit f4357cb73d
9 changed files with 35 additions and 332 deletions
-67
View File
@@ -111,74 +111,7 @@ impl CircleGeometry {
)
}
/// Calculate position at a given progress (0.0 to 1.0) through `total_angle`
#[must_use]
pub fn position_at_progress(&self, total_angle: f32, progress: f32) -> Vec2 {
let angle_traveled = total_angle * progress;
self.position_at_angle(angle_traveled)
}
/// Get the angle traveled from start position to a given position
#[must_use]
pub fn angle_to_position(&self, position: Vec2) -> f32 {
let displacement = position - self.center;
let current_angle = displacement.y.atan2(displacement.x);
let mut angle_diff = match self.direction {
CircleDirection::Left => self.start_angle_from_center - current_angle,
CircleDirection::Right => current_angle - self.start_angle_from_center,
};
// Normalize to [0, 2π)
if angle_diff < 0.0 {
angle_diff += 2.0 * std::f32::consts::PI;
}
angle_diff
}
/// Get `draw_arc` parameters for the full arc
/// Returns (`rotation_degrees`, `arc_degrees`) for macroquad's `draw_arc`
#[must_use]
pub fn draw_arc_params(&self, total_angle_degrees: f32) -> (f32, f32) {
match self.direction {
CircleDirection::Left => {
// For left (counter-clockwise), we need to draw counter-clockwise from end back to start
// so we start at (start - total_angle) and draw total_angle counter-clockwise
let end_angle = self.start_angle_from_center - total_angle_degrees.to_radians();
(end_angle.to_degrees(), total_angle_degrees)
}
CircleDirection::Right => {
// For right (clockwise), draw from start
(
self.start_angle_from_center.to_degrees(),
total_angle_degrees,
)
}
}
}
/// Get `draw_arc` parameters for a partial arc (during tweening)
/// Returns (`rotation_degrees`, `arc_degrees`) for macroquad's `draw_arc`
#[must_use]
pub fn draw_arc_params_partial(&self, angle_traveled: f32) -> (f32, f32) {
let angle_traveled_degrees = angle_traveled.to_degrees();
match self.direction {
CircleDirection::Left => {
// Draw from current position backwards (counter-clockwise) to start
let current_angle = self.start_angle_from_center - angle_traveled;
(current_angle.to_degrees(), angle_traveled_degrees)
}
CircleDirection::Right => {
// Draw from start, counter-clockwise
(
self.start_angle_from_center.to_degrees(),
angle_traveled_degrees,
)
}
}
}
}
#[cfg(test)]
+1 -26
View File
@@ -77,7 +77,6 @@ pub struct TurtleCommandSender {
/// Paired with `TurtleCommandSender` via `turtle_command_channel()`.
/// Automatically managed by `TurtleApp::process_commands()`.
pub(crate) struct TurtleCommandReceiver {
turtle_id: usize,
rx: Receiver<CommandQueue>,
}
@@ -142,12 +141,6 @@ impl TurtleCommandSender {
}
impl TurtleCommandReceiver {
/// Get the turtle ID this receiver is bound to
#[must_use]
pub fn turtle_id(&self) -> usize {
self.turtle_id
}
/// Drain all pending commands for this turtle (non-blocking)
///
/// # Examples
@@ -169,24 +162,6 @@ impl TurtleCommandReceiver {
pub fn recv_all(&self) -> Vec<CommandQueue> {
self.rx.try_iter().collect()
}
/// Try to receive one command batch (non-blocking)
#[must_use]
pub fn try_recv(&self) -> Option<CommandQueue> {
self.rx.try_recv().ok()
}
/// Check if this receiver's queue is empty
#[must_use]
pub fn is_empty(&self) -> bool {
self.rx.is_empty()
}
/// Get the number of pending command batches
#[must_use]
pub fn len(&self) -> usize {
self.rx.len()
}
}
/// Create a command channel for a specific turtle
@@ -217,6 +192,6 @@ pub(crate) fn turtle_command_channel(
let (tx, rx) = bounded(buffer_size);
(
TurtleCommandSender { turtle_id, tx },
TurtleCommandReceiver { turtle_id, rx },
TurtleCommandReceiver { rx },
)
}
-1
View File
@@ -93,7 +93,6 @@ pub(crate) fn execute_command_side_effects(
BLACK
});
*filling = Some(FillState {
start_position: params.position,
contours: Vec::new(),
current_contour: vec![params.position],
fill_color,
+4 -7
View File
@@ -1,5 +1,5 @@
//! Export backend trait and core export types.
#[cfg(feature = "svg")]
use crate::state::TurtleWorld;
use crate::TurtlePlan;
@@ -17,6 +17,7 @@ pub enum DrawingFormat {
// Additional formats: Png, Pdf, …
}
#[cfg(feature = "svg")]
pub(crate) trait DrawingExporter {
/// Export the drawing to the specified format and filename
///
@@ -55,12 +56,8 @@ where
let mut turtle = crate::create_turtle_plan();
build_commands(&mut turtle);
let mut app = crate::TurtleApp::new().with_commands(turtle.build());
app.set_all_turtles_speed(crate::AnimationSpeed::Instant(1000));
while !app.is_complete() {
app.step_animations();
}
let mut app = crate::TurtleApp::new();
app.execute_immediate(0, turtle);
app.export_drawing(filename, crate::export::DrawingFormat::Svg)
}
+18 -16
View File
@@ -4,23 +4,13 @@
pub mod svg_export {
use crate::export::{DrawingExporter, ExportError};
use crate::state::{SvgRecord, TurtleWorld};
use std::fmt::Write;
use std::fs::File;
use svg::{
node::element::{Circle, Line, Text as SvgText},
Document,
};
pub struct SvgExporter;
impl DrawingExporter for SvgExporter {
fn export(&self, world: &TurtleWorld, filename: &str) -> Result<(), ExportError> {
let mut doc = Document::new();
let mut min_x = f32::INFINITY;
let mut max_x = f32::NEG_INFINITY;
let mut min_y = f32::INFINITY;
let mut max_y = f32::NEG_INFINITY;
fn update_bounds(
min_x: &mut f32,
max_x: &mut f32,
@@ -35,6 +25,18 @@ pub mod svg_export {
*max_y = max_y.max(y);
}
pub struct SvgExporter;
impl DrawingExporter for SvgExporter {
#[allow(clippy::too_many_lines)]
fn export(&self, world: &TurtleWorld, filename: &str) -> Result<(), ExportError> {
let mut doc = Document::new();
let mut min_x = f32::INFINITY;
let mut max_x = f32::NEG_INFINITY;
let mut min_y = f32::INFINITY;
let mut max_y = f32::NEG_INFINITY;
for turtle in &world.turtles {
for record in &turtle.svg_log.records {
match record {
@@ -110,7 +112,7 @@ pub mod svg_export {
} else {
// Partial arc — emit as <path A …>
let end = geom.position_at_angle(angle.as_radians().value());
let large_arc = if angle.value() > 180.0 { 1 } else { 0 };
let large_arc = i32::from(angle.value() > 180.0);
let sweep = match direction {
crate::circle_geometry::CircleDirection::Left => 0,
crate::circle_geometry::CircleDirection::Right => 1,
@@ -154,9 +156,9 @@ pub mod svg_export {
if i > 0 {
d.push(' ');
}
d.push_str(&format!("M {} {}", contour[0].x, contour[0].y));
let _ = write!(d, "M {} {}", contour[0].x, contour[0].y);
for point in contour.iter().skip(1) {
d.push_str(&format!(" L {} {}", point.x, point.y));
let _ = write!(d, " L {} {}", point.x, point.y);
}
d.push_str(" Z");
}
@@ -227,9 +229,9 @@ pub mod svg_export {
let g = (color.g * 255.0) as u8;
let b = (color.b * 255.0) as u8;
if color.a < 1.0 {
format!("rgba({},{},{},{})", r, g, b, color.a)
format!("rgba({r},{g},{b},{})", color.a)
} else {
format!("rgb({},{},{})", r, g, b)
format!("rgb({r},{g},{b})")
}
}
}
+1 -2
View File
@@ -20,8 +20,7 @@ pub type Precision = f32;
/// - internal render-space state uses Macroquad-style Y-down coordinates
pub type Coordinate = Vec2;
/// Visibility flag for turtle
pub type Visibility = bool;
/// Execution speed setting
/// - `Instant(draw_calls)`: Fast execution with limited draw calls per frame (speed - 1000, minimum 1)
+5 -9
View File
@@ -253,6 +253,11 @@ impl TurtleApp {
/// Execute a plan immediately on a specific turtle (no animation)
pub fn execute_immediate(&mut self, turtle_id: usize, plan: TurtlePlan) {
// Ensure turtle exists
while self.world.turtles.len() <= turtle_id {
self.world.add_turtle();
}
for ref cmd in plan.build() {
execution::execute_command_with_id(cmd, turtle_id, &mut self.world);
}
@@ -393,16 +398,7 @@ impl TurtleApp {
}
}
/// Get reference to the world state
#[must_use]
pub(crate) fn world(&self) -> &TurtleWorld {
&self.world
}
/// Get mutable reference to the world state
pub(crate) fn world_mut(&mut self) -> &mut TurtleWorld {
&mut self.world
}
}
impl Default for TurtleApp {
+1 -197
View File
@@ -9,9 +9,6 @@ use macroquad::prelude::*;
/// State during active fill operation
#[derive(Clone, Debug)]
pub(crate) struct FillState {
/// Starting position of the fill
pub(crate) start_position: Coordinate,
/// All contours collected so far. Each contour is a separate closed path.
/// The first contour is the outer boundary, subsequent contours are holes.
pub(crate) contours: Vec<Vec<Coordinate>>,
@@ -56,6 +53,7 @@ impl Default for TurtleParams {
/// State of a single turtle
pub(crate) struct Turtle {
#[allow(clippy::struct_field_names)]
pub(crate) turtle_id: usize,
pub(crate) params: TurtleParams,
@@ -90,26 +88,6 @@ impl Turtle {
self.params.speed = speed;
}
#[must_use]
pub fn heading_angle(&self) -> crate::general::Radians {
crate::general::Radians::new(self.params.heading)
}
/// Reset turtle to default state (preserves `turtle_id` and queued commands)
pub fn reset(&mut self) {
// Clear all drawings
self.commands.clear();
self.svg_log.clear();
// Clear fill state
self.filling = None;
// Reset parameters to defaults
self.params = TurtleParams::default();
// Keep turtle_id and tween_controller (preserves queued commands)
}
/// Drive the animation controller for one frame.
///
/// Returns `(command, start_params, end_params)` for every command that
@@ -130,156 +108,6 @@ impl Turtle {
&mut self.svg_log,
)
}
/// Start recording fill vertices
pub fn begin_fill(&mut self, fill_color: Color) {
self.filling = Some(FillState {
start_position: self.params.position,
contours: Vec::new(),
current_contour: vec![self.params.position],
fill_color,
});
}
/// Record current position if filling and pen is down
pub fn record_fill_vertex(&mut self) {
if let Some(ref mut fill_state) = self.filling {
if self.params.pen_down {
tracing::trace!(
turtle_id = self.turtle_id,
x = self.params.position.x,
y = self.params.position.y,
vertices = fill_state.current_contour.len() + 1,
"Adding vertex to current contour"
);
fill_state.current_contour.push(self.params.position);
} else {
tracing::trace!(turtle_id = self.turtle_id, "Skipping vertex (pen is up)");
}
}
}
/// Close the current contour and prepare for a new one (called on `pen_up`)
pub fn close_fill_contour(&mut self) {
if let Some(ref mut fill_state) = self.filling {
tracing::debug!(
turtle_id = self.turtle_id,
vertices = fill_state.current_contour.len(),
"close_fill_contour called"
);
// Only close if we have vertices in current contour
if fill_state.current_contour.len() >= 2 {
tracing::debug!(
turtle_id = self.turtle_id,
vertices = fill_state.current_contour.len(),
first_x = fill_state.current_contour[0].x,
first_y = fill_state.current_contour[0].y,
last_x = fill_state.current_contour[fill_state.current_contour.len() - 1].x,
last_y = fill_state.current_contour[fill_state.current_contour.len() - 1].y,
"Closing contour"
);
// Move current contour to completed contours
let contour = std::mem::take(&mut fill_state.current_contour);
fill_state.contours.push(contour);
tracing::debug!(
turtle_id = self.turtle_id,
completed_contours = fill_state.contours.len(),
"Contour moved to completed list"
);
} else if !fill_state.current_contour.is_empty() {
tracing::warn!(
turtle_id = self.turtle_id,
vertices = fill_state.current_contour.len(),
"Current contour has insufficient vertices, not closing"
);
} else {
tracing::warn!(
turtle_id = self.turtle_id,
"Current contour is empty, nothing to close"
);
}
} else {
tracing::warn!(
turtle_id = self.turtle_id,
"close_fill_contour called but no active fill state"
);
}
}
/// Start a new contour (called on `pen_down`)
pub fn start_fill_contour(&mut self) {
if let Some(ref mut fill_state) = self.filling {
// Start new contour at current position
tracing::debug!(
x = self.params.position.x,
y = self.params.position.y,
completed_contours = fill_state.contours.len(),
self.turtle_id = self.turtle_id,
"Starting new contour"
);
fill_state.current_contour = vec![self.params.position];
}
}
/// Record multiple vertices along a circle arc for filling
/// This ensures circles are properly filled by sampling points along the arc
pub fn record_fill_vertices_for_arc(
&mut self,
center: Coordinate,
radius: f32,
start_angle: f32,
angle_traveled: f32,
direction: crate::circle_geometry::CircleDirection,
steps: u32,
) {
if let Some(ref mut fill_state) = self.filling {
if self.params.pen_down {
// Sample points along the arc based on steps
let num_samples = steps.max(1);
tracing::trace!(
turtle_id = self.turtle_id,
center_x = center.x,
center_y = center.y,
radius = radius,
steps = steps,
num_samples = num_samples,
"Recording arc vertices"
);
for i in 1..=num_samples {
let progress = i as f32 / num_samples as f32;
let current_angle = match direction {
crate::circle_geometry::CircleDirection::Left => {
start_angle - angle_traveled * progress
}
crate::circle_geometry::CircleDirection::Right => {
start_angle + angle_traveled * progress
}
};
let vertex = Coordinate::new(
center.x + radius * current_angle.cos(),
center.y + radius * current_angle.sin(),
);
tracing::trace!(
turtle_id = self.turtle_id,
vertex_idx = i,
x = vertex.x,
y = vertex.y,
angle_degrees = current_angle.to_degrees(),
"Arc vertex"
);
fill_state.current_contour.push(vertex);
}
}
}
}
/// Clear fill state (called after `end_fill`)
pub fn reset_fill(&mut self) {
self.filling = None;
}
}
/// The draw-event log for SVG export.
@@ -368,7 +196,6 @@ pub(crate) struct TurtleWorld {
/// All turtles in the world (indexed by turtle ID)
pub(crate) turtles: Vec<Turtle>,
pub(crate) camera: Camera2D,
pub(crate) background_color: Color,
}
impl TurtleWorld {
@@ -377,7 +204,6 @@ impl TurtleWorld {
Self {
turtles: vec![], // Start with no turtles
camera: Camera2D::default(),
background_color: WHITE,
}
}
@@ -392,32 +218,10 @@ impl TurtleWorld {
turtle_id
}
/// Get turtle by ID
#[must_use]
pub fn get_turtle(&self, id: usize) -> Option<&Turtle> {
self.turtles.get(id)
}
/// Get mutable turtle by ID
pub fn get_turtle_mut(&mut self, id: usize) -> Option<&mut Turtle> {
self.turtles.get_mut(id)
}
/// Reset a specific turtle to default state and remove all its drawings
pub fn reset_turtle(&mut self, turtle_id: usize) {
if let Some(turtle) = self.get_turtle_mut(turtle_id) {
turtle.reset();
turtle.turtle_id = turtle_id; // Preserve turtle_id after reset
}
}
/// Clear all drawings and reset all turtle states
pub fn clear(&mut self) {
for (id, turtle) in self.turtles.iter_mut().enumerate() {
turtle.reset();
turtle.turtle_id = id; // Preserve turtle_id after reset
}
}
}
impl Default for TurtleWorld {
+2 -4
View File
@@ -54,7 +54,6 @@ pub(crate) struct TweenController {
#[derive(Clone, Debug)]
pub(crate) struct CommandTween {
pub(crate) turtle_id: usize,
pub(crate) command: TurtleCommand,
pub(crate) start_time: f64,
pub(crate) duration: f64,
@@ -316,7 +315,6 @@ impl TweenController {
);
self.current_tween = Some(CommandTween {
turtle_id,
command,
start_time: current_time(),
duration,
@@ -396,14 +394,14 @@ pub(crate) fn normalize_angle(angle: f32) -> f32 {
#[inline]
fn current_time() -> f64 {
#[cfg(test)]
#[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(not(test))]
#[cfg(target_arch = "wasm32")]
{
macroquad::time::get_time()
}