Compare 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
7 changed files with 385 additions and 64 deletions
+3 -20
View File
@@ -66,17 +66,11 @@ use syn::ItemFn;
/// use turtle_lib::*;
///
/// fn main() {
/// // Handle optional SVG export headlessly without opening a window
/// if let Some(filename) = turtle_lib::export::parse_svg_export_arg() {
/// let mut build_commands = |turtle: &mut turtle_lib::TurtlePlan| {
/// my_drawing(turtle);
/// };
/// if let Err(e) = turtle_lib::export::run_headless_svg_export(&mut build_commands, &filename) {
/// eprintln!("Error exporting SVG: {:?}", e);
/// std::process::exit(1);
/// }
/// return;
/// }
/// // Handle optional SVG export headlessly without opening a window
/// 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 {
+1
View File
@@ -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
@@ -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;
+4 -4
View File
@@ -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
+239 -15
View File
@@ -7,9 +7,31 @@ use crate::TurtlePlan;
pub enum ExportError {
Io(std::io::Error),
Format(String),
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)]
pub enum DrawingFormat {
#[cfg(feature = "svg")]
@@ -27,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<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 args: Vec<String> = 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()
}
/// Headless SVG export that executes drawing commands and writes an SVG file
@@ -46,27 +77,57 @@ pub fn parse_svg_export_arg() -> Option<String> {
///
/// # 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<F>(mut build_commands: F, filename: &str) -> Result<(), ExportError>
where
F: FnMut(&mut TurtlePlan),
{
#[cfg(feature = "svg")]
{
let mut turtle = crate::create_turtle_plan();
build_commands(&mut turtle);
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::Format(
Err(ExportError::MissingFeature(
"SVG export feature is not enabled. Please rebuild with --features svg".to_string(),
))
}
@@ -79,16 +140,179 @@ pub fn handle_svg_export<F>(build_commands: F)
where
F: FnMut(&mut TurtlePlan),
{
if let Some(filename) = parse_svg_export_arg() {
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 exporting SVG: {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:?}"),
}
}
}
+120 -15
View File
@@ -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);
}
@@ -80,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(
@@ -87,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 <circle>
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");
@@ -112,17 +116,19 @@ pub mod svg_export {
} else {
// Partial arc — emit as <path A …>
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,
@@ -132,6 +138,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 +225,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 +248,95 @@ 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}"
);
}
#[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}"
);
}
}
}
+1
View File
@@ -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();