From 368497f97a0f3f858cee20da5f0b2a83304b66bb Mon Sep 17 00:00:00 2001 From: Franz Dietrich Date: Sun, 20 Sep 2026 10:33:38 +0200 Subject: [PATCH 1/5] 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 elements. Added .set("stroke-linecap", "round") to partial arc 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. --- turtle-lib/examples/star.rs | 8 ++--- turtle-lib/src/export_svg.rs | 68 ++++++++++++++++++++++++++++++++++-- turtle-lib/src/state.rs | 1 + 3 files changed, 70 insertions(+), 7 deletions(-) diff --git a/turtle-lib/examples/star.rs b/turtle-lib/examples/star.rs index 7c98d1b..bf02444 100644 --- a/turtle-lib/examples/star.rs +++ b/turtle-lib/examples/star.rs @@ -9,11 +9,11 @@ fn draw(turtle: &mut TurtlePlan) { turtle.set_pen_width(0.5); // Draw a 5-pointed star pattern repeatedly - for _i in 0..50000 { + for _i in 0..500 { turtle.forward(200.0); - turtle.circle_left(10.0, 72.0, 1000); - turtle.circle_right(5.0, 360.0, 1000); - turtle.circle_left(10.0, 72.0, 1000); + turtle.circle_left(10.0, 72.0, 100); + turtle.circle_right(5.0, 360.0, 100); + turtle.circle_left(10.0, 72.0, 100); } // Set animation speed diff --git a/turtle-lib/src/export_svg.rs b/turtle-lib/src/export_svg.rs index 5579b3b..0c6a84b 100644 --- a/turtle-lib/src/export_svg.rs +++ b/turtle-lib/src/export_svg.rs @@ -27,9 +27,11 @@ pub mod svg_export { pub struct SvgExporter; - impl DrawingExporter for SvgExporter { + impl SvgExporter { + /// Generate an SVG [`Document`] from the given [`TurtleWorld`]. + #[must_use] #[allow(clippy::too_many_lines)] - fn export(&self, world: &TurtleWorld, filename: &str) -> Result<(), ExportError> { + pub fn to_svg_document(world: &TurtleWorld) -> Document { let mut doc = Document::new(); let mut min_x = f32::INFINITY; @@ -58,7 +60,8 @@ pub mod svg_export { .set("x2", end.x) .set("y2", end.y) .set("stroke", color_to_svg(*color)) - .set("stroke-width", *pen_width); + .set("stroke-width", *pen_width) + .set("stroke-linecap", "round"); doc = doc.add(line); } @@ -132,6 +135,7 @@ pub mod svg_export { .set("d", d) .set("stroke", color_to_svg(*color)) .set("stroke-width", *pen_width) + .set("stroke-linecap", "round") .set("fill", "none"); doc = doc.add(path); } @@ -218,6 +222,13 @@ pub mod svg_export { 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)?; svg::write(&mut file, &doc).map_err(ExportError::Io)?; Ok(()) @@ -234,4 +245,55 @@ pub mod svg_export { 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}" + ); + } + } } diff --git a/turtle-lib/src/state.rs b/turtle-lib/src/state.rs index 24da2fc..b293ecc 100644 --- a/turtle-lib/src/state.rs +++ b/turtle-lib/src/state.rs @@ -126,6 +126,7 @@ pub(crate) struct SvgLog { } impl SvgLog { + #[allow(clippy::unused_self)] pub(crate) fn clear(&mut self) { #[cfg(feature = "svg")] self.records.clear(); From 05c2654005dd015a24945c1f716a6c237abb5057 Mon Sep 17 00:00:00 2001 From: Franz Dietrich Date: Sun, 20 Sep 2026 11:02:29 +0200 Subject: [PATCH 2/5] 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. --- turtle-lib/src/export_svg.rs | 67 +++++++++++++++++++++++++++++------- 1 file changed, 55 insertions(+), 12 deletions(-) diff --git a/turtle-lib/src/export_svg.rs b/turtle-lib/src/export_svg.rs index 0c6a84b..2103d9b 100644 --- a/turtle-lib/src/export_svg.rs +++ b/turtle-lib/src/export_svg.rs @@ -83,6 +83,7 @@ pub mod svg_export { *direction, ); let center = geom.center; + let radius_val = radius.abs(); // Include the bounding box of the full circle so partial arcs // are never clipped. update_bounds( @@ -90,24 +91,24 @@ pub mod svg_export { &mut max_x, &mut min_y, &mut max_y, - center.x - radius, - center.y - radius, + center.x - radius_val, + center.y - radius_val, ); update_bounds( &mut min_x, &mut max_x, &mut min_y, &mut max_y, - center.x + radius, - center.y + radius, + center.x + radius_val, + center.y + radius_val, ); - if (angle.value() - 360.0).abs() < 1e-3 { + if (angle.value().abs() - 360.0).abs() < 1e-3 { // Full circle — emit as let circle = Circle::new() .set("cx", center.x) .set("cy", center.y) - .set("r", *radius) + .set("r", radius_val) .set("stroke", color_to_svg(*color)) .set("stroke-width", *pen_width) .set("fill", "none"); @@ -115,17 +116,19 @@ pub mod svg_export { } else { // Partial arc — emit as let end = geom.position_at_angle(angle.as_radians().value()); - 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, + let large_arc = i32::from(angle.value().abs() > 180.0); + let sweep = match (direction, angle.value() >= 0.0) { + (crate::circle_geometry::CircleDirection::Right, true) + | (crate::circle_geometry::CircleDirection::Left, false) => 1, + (crate::circle_geometry::CircleDirection::Left, true) + | (crate::circle_geometry::CircleDirection::Right, false) => 0, }; let d = format!( "M {} {} A {} {} 0 {} {} {} {}", start_position.x, start_position.y, - radius, - radius, + radius_val, + radius_val, large_arc, sweep, end.x, @@ -295,5 +298,45 @@ pub mod svg_export { "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}" + ); + } } } From d85575097083ce17cc879b84738eb8b9e8e6f339 Mon Sep 17 00:00:00 2001 From: Franz Dietrich Date: Sun, 20 Sep 2026 11:11:59 +0200 Subject: [PATCH 3/5] 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. --- turtle-lib-macros/src/lib.rs | 2 +- turtle-lib/examples/sierpinski_triangle.rs | 7 ++ turtle-lib/src/export.rs | 112 +++++++++++++++++++-- 3 files changed, 114 insertions(+), 7 deletions(-) diff --git a/turtle-lib-macros/src/lib.rs b/turtle-lib-macros/src/lib.rs index b9faed1..e2724ba 100644 --- a/turtle-lib-macros/src/lib.rs +++ b/turtle-lib-macros/src/lib.rs @@ -259,7 +259,7 @@ fn turtle_main_impl( return; } Err(e) => { - eprintln!("Error exporting SVG: {:?}", e); + eprintln!("Error exporting SVG: {}", e); std::process::exit(1); } } diff --git a/turtle-lib/examples/sierpinski_triangle.rs b/turtle-lib/examples/sierpinski_triangle.rs index e451f6c..7992bb8 100644 --- a/turtle-lib/examples/sierpinski_triangle.rs +++ b/turtle-lib/examples/sierpinski_triangle.rs @@ -17,6 +17,10 @@ //! 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 //! 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 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 /// 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. +/// +/// 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) { let size = TRIANGLE_SIZE; diff --git a/turtle-lib/src/export.rs b/turtle-lib/src/export.rs index 0baf558..1a20016 100644 --- a/turtle-lib/src/export.rs +++ b/turtle-lib/src/export.rs @@ -7,9 +7,29 @@ use crate::TurtlePlan; pub enum ExportError { Io(std::io::Error), Format(String), + Execution(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}"), + } + } +} + +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(_) => None, + } + } +} + #[derive(Clone, Copy, Debug)] pub enum DrawingFormat { #[cfg(feature = "svg")] @@ -41,25 +61,105 @@ pub fn parse_svg_export_arg() -> Option { None } +#[cfg(feature = "svg")] +type PanicHookFn = Box) + Sync + Send + 'static>; + +#[cfg(feature = "svg")] +struct PanicHookGuard { + prev_hook: Option>, +} + +#[cfg(feature = "svg")] +impl Drop for PanicHookGuard { + fn drop(&mut self) { + if let Some(prev) = self.prev_hook.take() { + std::panic::set_hook(Box::new(move |info| prev(info))); + } + } +} + /// Headless SVG export that executes drawing commands and writes an SVG file /// without opening a graphics window and without calling `std::process::exit`. /// /// # Errors /// -/// Returns `ExportError` if file I/O fails or if the `svg` feature is not enabled. +/// Returns `ExportError` if file I/O fails, drawing execution panics, or if the `svg` feature is not enabled. pub fn run_headless_svg_export(mut build_commands: F, filename: &str) -> Result<(), ExportError> where F: FnMut(&mut TurtlePlan), { #[cfg(feature = "svg")] { + use std::panic::AssertUnwindSafe; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; + let mut turtle = crate::create_turtle_plan(); - build_commands(&mut turtle); - let mut app = crate::TurtleApp::new(); - app.execute_immediate(0, turtle); + let is_mq_panic = Arc::new(AtomicBool::new(false)); + let is_mq_clone = Arc::clone(&is_mq_panic); - app.export_drawing(filename, crate::export::DrawingFormat::Svg) + let prev_hook = Arc::new(std::panic::take_hook()); + let prev_hook_for_closure = Arc::clone(&prev_hook); + + let guard = PanicHookGuard { + prev_hook: Some(prev_hook), + }; + + std::panic::set_hook(Box::new(move |info| { + prev_hook_for_closure(info); + + let msg = if let Some(s) = info.payload().downcast_ref::<&str>() { + *s + } else if let Some(s) = info.payload().downcast_ref::() { + s.as_str() + } else { + "" + }; + + let loc_file = info.location().map_or("", std::panic::Location::file); + let is_context_panic = msg.contains("THREAD_ID.is_some()") + || (loc_file.contains("macroquad") && msg.contains("assertion failed")); + + if is_context_panic { + is_mq_clone.store(true, Ordering::SeqCst); + 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"); + } + })); + + let result = std::panic::catch_unwind(AssertUnwindSafe(|| { + build_commands(&mut turtle); + })); + + drop(guard); + + 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 err_msg = if is_mq_panic.load(Ordering::SeqCst) { + "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 let Some(s) = payload.downcast_ref::<&str>() { + (*s).to_string() + } else if let Some(s) = payload.downcast_ref::() { + s.clone() + } else { + "Drawing function panicked during execution".to_string() + }; + Err(ExportError::Execution(err_msg)) + } + } } #[cfg(not(feature = "svg"))] @@ -86,7 +186,7 @@ where std::process::exit(0); } Err(e) => { - eprintln!("Error exporting SVG: {e:?}"); + eprintln!("Error exporting SVG: {e}"); std::process::exit(1); } } From b19919f8b79461ad0e62eda9b7ef45dda8f8a109 Mon Sep 17 00:00:00 2001 From: Franz Dietrich Date: Sun, 20 Sep 2026 12:36:15 +0200 Subject: [PATCH 4/5] 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, pico_args::Error>` to parse `--export-svg ` and `--export-svg=`, treating empty or flag arguments as missing values (`OptionWithoutAValue`). - Updated `parse_svg_export_arg() -> Option` 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 | --- turtle-lib-macros/src/lib.rs | 27 ++----- turtle-lib/Cargo.toml | 1 + turtle-lib/src/export.rs | 150 ++++++++++++++++++++++++++++++----- 3 files changed, 137 insertions(+), 41 deletions(-) diff --git a/turtle-lib-macros/src/lib.rs b/turtle-lib-macros/src/lib.rs index e2724ba..0b88344 100644 --- a/turtle-lib-macros/src/lib.rs +++ b/turtle-lib-macros/src/lib.rs @@ -66,17 +66,11 @@ use syn::ItemFn; /// use turtle_lib::*; /// /// fn main() { +/// let mut build_commands = |turtle: &mut turtle_lib::TurtlePlan| { +/// my_drawing(turtle); +/// }; /// // Handle optional SVG export headlessly without opening a window -/// if let Some(filename) = turtle_lib::export::parse_svg_export_arg() { -/// let mut build_commands = |turtle: &mut turtle_lib::TurtlePlan| { -/// my_drawing(turtle); -/// }; -/// if let Err(e) = turtle_lib::export::run_headless_svg_export(&mut build_commands, &filename) { -/// eprintln!("Error exporting SVG: {:?}", e); -/// std::process::exit(1); -/// } -/// return; -/// } +/// turtle_lib::export::handle_svg_export(&mut build_commands); /// /// // Normal interactive GUI mode with window /// turtle_lib::macroquad::Window::new("My Turtle Drawing", async { @@ -252,18 +246,7 @@ fn turtle_main_impl( }; // If --export-svg flag is present, export headlessly without opening a window - if let Some(filename) = turtle_lib::export::parse_svg_export_arg() { - match turtle_lib::export::run_headless_svg_export(&mut build_commands, &filename) { - Ok(()) => { - println!("SVG exported successfully to: {}", filename); - return; - } - Err(e) => { - eprintln!("Error exporting SVG: {}", e); - std::process::exit(1); - } - } - } + turtle_lib::export::handle_svg_export(&mut build_commands); // Normal rendering mode (interactive window) turtle_lib::macroquad::Window::new(#window_title, async { diff --git a/turtle-lib/Cargo.toml b/turtle-lib/Cargo.toml index 9b485c3..5ae159c 100644 --- a/turtle-lib/Cargo.toml +++ b/turtle-lib/Cargo.toml @@ -14,6 +14,7 @@ tracing = { version = "0.1", features = [ ], default-features = false } turtle-lib-macros = { path = "../turtle-lib-macros" } crossbeam = "0.8" +pico-args = { version = "0.5", features = ["eq-separator"] } [dev-dependencies] # For examples and testing diff --git a/turtle-lib/src/export.rs b/turtle-lib/src/export.rs index 1a20016..68f6e19 100644 --- a/turtle-lib/src/export.rs +++ b/turtle-lib/src/export.rs @@ -8,6 +8,7 @@ pub enum ExportError { Io(std::io::Error), Format(String), Execution(String), + MissingFeature(String), // Additional formats can be added here. } @@ -17,6 +18,7 @@ impl std::fmt::Display for ExportError { 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}"), } } } @@ -25,7 +27,7 @@ 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(_) => None, + Self::Format(_) | Self::Execution(_) | Self::MissingFeature(_) => None, } } } @@ -47,18 +49,27 @@ pub(crate) trait DrawingExporter { 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, 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 ` flag. #[must_use] pub fn parse_svg_export_arg() -> Option { - let args: Vec = std::env::args().collect(); - let mut i = 1; - while i < args.len() { - if args[i] == "--export-svg" && i + 1 < args.len() { - return Some(args[i + 1].clone()); - } - i += 1; - } - None + let mut args = pico_args::Arguments::from_env(); + parse_svg_export_from_args(&mut args).ok().flatten() } #[cfg(feature = "svg")] @@ -166,7 +177,7 @@ where { let _ = &mut build_commands; let _ = filename; - Err(ExportError::Format( + Err(ExportError::MissingFeature( "SVG export feature is not enabled. Please rebuild with --features svg".to_string(), )) } @@ -179,16 +190,117 @@ pub fn handle_svg_export(build_commands: F) where F: FnMut(&mut TurtlePlan), { - if let Some(filename) = parse_svg_export_arg() { - match run_headless_svg_export(build_commands, &filename) { - Ok(()) => { - println!("SVG exported successfully to: {filename}"); - std::process::exit(0); - } - Err(e) => { - eprintln!("Error exporting SVG: {e}"); + 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) => { + 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 = 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" + ); + } +} From 95b467a76f71514e938545b7c793eeb6663fa5ed Mon Sep 17 00:00:00 2001 From: Franz Dietrich Date: Sun, 20 Sep 2026 13:53:15 +0200 Subject: [PATCH 5/5] 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`. --- turtle-lib/src/export.rs | 136 +++++++++++++++++++++------------------ 1 file changed, 74 insertions(+), 62 deletions(-) diff --git a/turtle-lib/src/export.rs b/turtle-lib/src/export.rs index 68f6e19..decde53 100644 --- a/turtle-lib/src/export.rs +++ b/turtle-lib/src/export.rs @@ -72,23 +72,6 @@ pub fn parse_svg_export_arg() -> Option { parse_svg_export_from_args(&mut args).ok().flatten() } -#[cfg(feature = "svg")] -type PanicHookFn = Box) + Sync + Send + 'static>; - -#[cfg(feature = "svg")] -struct PanicHookGuard { - prev_hook: Option>, -} - -#[cfg(feature = "svg")] -impl Drop for PanicHookGuard { - fn drop(&mut self) { - if let Some(prev) = self.prev_hook.take() { - std::panic::set_hook(Box::new(move |info| prev(info))); - } - } -} - /// Headless SVG export that executes drawing commands and writes an SVG file /// without opening a graphics window and without calling `std::process::exit`. /// @@ -102,55 +85,13 @@ where #[cfg(feature = "svg")] { use std::panic::AssertUnwindSafe; - use std::sync::atomic::{AtomicBool, Ordering}; - use std::sync::Arc; let mut turtle = crate::create_turtle_plan(); - let is_mq_panic = Arc::new(AtomicBool::new(false)); - let is_mq_clone = Arc::clone(&is_mq_panic); - - let prev_hook = Arc::new(std::panic::take_hook()); - let prev_hook_for_closure = Arc::clone(&prev_hook); - - let guard = PanicHookGuard { - prev_hook: Some(prev_hook), - }; - - std::panic::set_hook(Box::new(move |info| { - prev_hook_for_closure(info); - - let msg = if let Some(s) = info.payload().downcast_ref::<&str>() { - *s - } else if let Some(s) = info.payload().downcast_ref::() { - s.as_str() - } else { - "" - }; - - let loc_file = info.location().map_or("", std::panic::Location::file); - let is_context_panic = msg.contains("THREAD_ID.is_some()") - || (loc_file.contains("macroquad") && msg.contains("assertion failed")); - - if is_context_panic { - is_mq_clone.store(true, Ordering::SeqCst); - 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"); - } - })); - let result = std::panic::catch_unwind(AssertUnwindSafe(|| { build_commands(&mut turtle); })); - drop(guard); - match result { Ok(()) => { let mut app = crate::TurtleApp::new(); @@ -159,12 +100,21 @@ where app.export_drawing(filename, crate::export::DrawingFormat::Svg) } Err(payload) => { - let err_msg = if is_mq_panic.load(Ordering::SeqCst) { - "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 let Some(s) = payload.downcast_ref::<&str>() { + let raw_msg = if let Some(s) = payload.downcast_ref::<&str>() { (*s).to_string() } else if let Some(s) = payload.downcast_ref::() { 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() }; @@ -217,6 +167,18 @@ where 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); } @@ -303,4 +265,54 @@ mod tests { "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(" { + 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:?}"), + } + } }