From 05c2654005dd015a24945c1f716a6c237abb5057 Mon Sep 17 00:00:00 2001 From: Franz Dietrich Date: Sun, 20 Sep 2026 11:02:29 +0200 Subject: [PATCH] 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}" + ); + } } }