From f1e5bf23a36030e1aba89d97d0cb3fb39ac5b2e0 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Wed, 7 Oct 2026 07:29:05 +0800 Subject: [PATCH] fix(svg): preserve distance paths for shapes with one zero axis Use a separate equivalent path for distance queries on rectangles and ellipses with one zero axis. Preserve their starting points and sweep while retaining empty rendered bounds. Core regression tests verify total length, endpoint and intermediate point queries, and distinguish fully empty geometry from a shape collapsed along one axis. --- moli-svg/src/geometry.rs | 32 ++++++++++- moli-svg/src/lib.rs | 115 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 1 deletion(-) diff --git a/moli-svg/src/geometry.rs b/moli-svg/src/geometry.rs index 8aa1c949ea..83fe8c23d4 100644 --- a/moli-svg/src/geometry.rs +++ b/moli-svg/src/geometry.rs @@ -98,7 +98,7 @@ pub enum SvgGeometryElement { } pub fn segments_for_element(element: SvgGeometryElement) -> Vec { - let path = geometry_path(&element).unwrap_or_default(); + let path = distance_query_path(&element).unwrap_or_default(); let segments = path .segments() .map(SvgGeometrySegment::new) @@ -229,6 +229,36 @@ pub fn point_at_length(segments: &[SvgGeometrySegment], distance: f64) -> SvgGeo .unwrap_or(SvgGeometryPoint::new(0.0, 0.0)) } +fn distance_query_path(element: &SvgGeometryElement) -> Option { + // A zero axis disables rendering, but distance queries can still compute + // the equivalent path along the other axis. + match element { + SvgGeometryElement::Rect { + x, + y, + width, + height, + .. + } if (*width == 0.0 && *height > 0.0) || (*height == 0.0 && *width > 0.0) => { + Some(Rect::new(*x, *y, *x + *width, *y + *height).to_path(SHAPE_PATH_TOLERANCE)) + } + SvgGeometryElement::Ellipse { cx, cy, rx, ry } + if (*rx == 0.0 && *ry > 0.0) || (*ry == 0.0 && *rx > 0.0) => + { + // Collapsed quarter arcs are lines. Preserve SVG's starting point + // and sweep instead of normalizing the ellipse's axes. + let mut path = BezPath::new(); + path.move_to((*cx + *rx, *cy)); + path.line_to((*cx, *cy + *ry)); + path.line_to((*cx - *rx, *cy)); + path.line_to((*cx, *cy - *ry)); + path.close_path(); + Some(path) + } + _ => geometry_path(element), + } +} + fn geometry_path(element: &SvgGeometryElement) -> Option { match element { SvgGeometryElement::Circle { cx, cy, r } => Some(if *r > 0.0 { diff --git a/moli-svg/src/lib.rs b/moli-svg/src/lib.rs index bf6853b8f2..6ab8b2f2d7 100644 --- a/moli-svg/src/lib.rs +++ b/moli-svg/src/lib.rs @@ -74,6 +74,121 @@ mod tests { }) } + #[test] + fn degenerate_shapes_keep_distance_paths_without_rendered_bounds() { + for (element, length, endpoint) in [ + ( + SvgGeometryElement::Rect { + x: 7.0, + y: 9.0, + width: 0.0, + height: 10.0, + rx: 0.0, + ry: 0.0, + }, + 20.0, + SvgGeometryPoint::new(7.0, 9.0), + ), + ( + SvgGeometryElement::Rect { + x: 7.0, + y: 9.0, + width: 10.0, + height: 0.0, + rx: 0.0, + ry: 0.0, + }, + 20.0, + SvgGeometryPoint::new(7.0, 9.0), + ), + ( + SvgGeometryElement::Ellipse { + cx: 7.0, + cy: 9.0, + rx: 0.0, + ry: 10.0, + }, + 40.0, + SvgGeometryPoint::new(7.0, 9.0), + ), + ( + SvgGeometryElement::Ellipse { + cx: 7.0, + cy: 9.0, + rx: 10.0, + ry: 0.0, + }, + 40.0, + SvgGeometryPoint::new(17.0, 9.0), + ), + ] { + assert!(bounding_box_for_element(&element).is_none()); + let segments = segments_for_element(element); + assert_near( + segments.iter().map(SvgGeometrySegment::length).sum(), + length, + 1e-6, + ); + let point = point_at_length(&segments, 300.0); + assert_close(point.x, endpoint.x); + assert_close(point.y, endpoint.y); + } + for element in [ + SvgGeometryElement::Rect { + x: 7.0, + y: 9.0, + width: 0.0, + height: 0.0, + rx: 0.0, + ry: 0.0, + }, + SvgGeometryElement::Ellipse { + cx: 7.0, + cy: 9.0, + rx: 0.0, + ry: 0.0, + }, + SvgGeometryElement::Circle { + cx: 7.0, + cy: 9.0, + r: 0.0, + }, + SvgGeometryElement::Path { d: String::new() }, + ] { + assert!(segments_for_element(element).is_empty()); + } + for (rx, ry, points) in [ + ( + 0.0, + 10.0, + [(7.0, 9.0), (7.0, 19.0), (7.0, 9.0), (7.0, -1.0), (7.0, 9.0)], + ), + ( + 10.0, + 0.0, + [ + (17.0, 9.0), + (7.0, 9.0), + (-3.0, 9.0), + (7.0, 9.0), + (17.0, 9.0), + ], + ), + ] { + let segments = segments_for_element(SvgGeometryElement::Ellipse { + cx: 7.0, + cy: 9.0, + rx, + ry, + }); + for (distance, (x, y)) in [0.0, 10.0, 20.0, 30.0, 40.0].into_iter().zip(points) { + let point = point_at_length(&segments, distance); + assert_close(point.x, x); + assert_close(point.y, y); + } + } + } + fn assert_close(actual: f64, expected: f64) { assert!( (actual - expected).abs() < 1e-9,