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.
This commit is contained in:
2026-09-20 11:02:29 +02:00
parent 368497f97a
commit 05c2654005
+55 -12
View File
@@ -83,6 +83,7 @@ pub mod svg_export {
*direction, *direction,
); );
let center = geom.center; let center = geom.center;
let radius_val = radius.abs();
// Include the bounding box of the full circle so partial arcs // Include the bounding box of the full circle so partial arcs
// are never clipped. // are never clipped.
update_bounds( update_bounds(
@@ -90,24 +91,24 @@ pub mod svg_export {
&mut max_x, &mut max_x,
&mut min_y, &mut min_y,
&mut max_y, &mut max_y,
center.x - radius, center.x - radius_val,
center.y - radius, center.y - radius_val,
); );
update_bounds( update_bounds(
&mut min_x, &mut min_x,
&mut max_x, &mut max_x,
&mut min_y, &mut min_y,
&mut max_y, &mut max_y,
center.x + radius, center.x + radius_val,
center.y + radius, 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> // Full circle — emit as <circle>
let circle = Circle::new() let circle = Circle::new()
.set("cx", center.x) .set("cx", center.x)
.set("cy", center.y) .set("cy", center.y)
.set("r", *radius) .set("r", radius_val)
.set("stroke", color_to_svg(*color)) .set("stroke", color_to_svg(*color))
.set("stroke-width", *pen_width) .set("stroke-width", *pen_width)
.set("fill", "none"); .set("fill", "none");
@@ -115,17 +116,19 @@ pub mod svg_export {
} else { } else {
// Partial arc — emit as <path A …> // Partial arc — emit as <path A …>
let end = geom.position_at_angle(angle.as_radians().value()); let end = geom.position_at_angle(angle.as_radians().value());
let large_arc = i32::from(angle.value() > 180.0); let large_arc = i32::from(angle.value().abs() > 180.0);
let sweep = match direction { let sweep = match (direction, angle.value() >= 0.0) {
crate::circle_geometry::CircleDirection::Left => 0, (crate::circle_geometry::CircleDirection::Right, true)
crate::circle_geometry::CircleDirection::Right => 1, | (crate::circle_geometry::CircleDirection::Left, false) => 1,
(crate::circle_geometry::CircleDirection::Left, true)
| (crate::circle_geometry::CircleDirection::Right, false) => 0,
}; };
let d = format!( let d = format!(
"M {} {} A {} {} 0 {} {} {} {}", "M {} {} A {} {} 0 {} {} {} {}",
start_position.x, start_position.x,
start_position.y, start_position.y,
radius, radius_val,
radius, radius_val,
large_arc, large_arc,
sweep, sweep,
end.x, end.x,
@@ -295,5 +298,45 @@ pub mod svg_export {
"SVG export of partial arcs should have round line caps: {svg_string}" "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}"
);
}
} }
} }