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.
This commit is contained in:
ldm0
2026-10-10 18:33:52 +08:00
committed by Donough Liu
parent f9fd34422a
commit f1e5bf23a3
2 changed files with 146 additions and 1 deletions
+31 -1
View File
@@ -98,7 +98,7 @@ pub enum SvgGeometryElement {
}
pub fn segments_for_element(element: SvgGeometryElement) -> Vec<SvgGeometrySegment> {
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<BezPath> {
// 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<BezPath> {
match element {
SvgGeometryElement::Circle { cx, cy, r } => Some(if *r > 0.0 {
+115
View File
@@ -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,