Merge pull request #5683 from storytold/fix/planar-faces-cover-regions

Pathfinder: holes that touch at a point stay holes of their region
This commit is contained in:
BFlat
2026-11-05 03:02:21 +07:00
committed by GitHub
3 changed files with 104 additions and 1 deletions
@@ -0,0 +1,7 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 799771bbae95181944a44d3a40559928606eb57d3d6a3cf4916ed37bc9bf2c15 # shrinks to shapes = [Shape { path: PathData { subpaths: [SubPath { anchors: [Anchor { p: (20.0, 85.0), h_in: (20.0, 87.76142374915396), h_out: (20.0, 82.23857625084604), kind: Smooth }, Anchor { p: (25.0, 80.0), h_in: (22.23857625084603, 80.0), h_out: (27.76142374915397, 80.0), kind: Smooth }, Anchor { p: (30.0, 85.0), h_in: (30.0, 82.23857625084604), h_out: (30.0, 87.76142374915396), kind: Smooth }, Anchor { p: (25.0, 90.0), h_in: (27.76142374915397, 90.0), h_out: (22.23857625084603, 90.0), kind: Smooth }], closed: true }] }, rule: EvenOdd, key: 0 }, Shape { path: PathData { subpaths: [SubPath { anchors: [Anchor { p: (10.0, 80.0), h_in: (10.0, 91.04569499661588), h_out: (10.0, 68.95430500338412), kind: Smooth }, Anchor { p: (30.0, 60.0), h_in: (18.95430500338413, 60.0), h_out: (41.04569499661587, 60.0), kind: Smooth }, Anchor { p: (50.0, 80.0), h_in: (50.0, 68.95430500338412), h_out: (50.0, 91.04569499661588), kind: Smooth }, Anchor { p: (30.0, 100.0), h_in: (41.04569499661587, 100.0), h_out: (18.95430500338413, 100.0), kind: Smooth }], closed: true }] }, rule: EvenOdd, key: 0 }, Shape { path: PathData { subpaths: [SubPath { anchors: [Anchor { p: (30.0, 85.0), h_in: (30.0, 87.76142374915396), h_out: (30.0, 82.23857625084604), kind: Smooth }, Anchor { p: (35.0, 80.0), h_in: (32.23857625084603, 80.0), h_out: (37.76142374915397, 80.0), kind: Smooth }, Anchor { p: (40.0, 85.0), h_in: (40.0, 82.23857625084604), h_out: (40.0, 87.76142374915396), kind: Smooth }, Anchor { p: (35.0, 90.0), h_in: (37.76142374915397, 90.0), h_out: (32.23857625084603, 90.0), kind: Smooth }], closed: true }] }, rule: EvenOdd, key: 0 }]
+78 -1
View File
@@ -330,7 +330,18 @@ impl CoverIds {
/// enclosing contour of its mask, as in that mask's own pass; an outer contour always starts a face
/// of its own, even inside a hole of its mask (an island left in a cutout is a separate face, not
/// part of the frame around it).
fn face_groups(c: &linesweeper::topology::Contours, owner: &[usize]) -> Vec<(usize, Vec<ContourIdx>)> {
///
/// The sweep's tree of contours is wrong where it cut a loop off a contour at a point the contour
/// visits twice and the loop isn't nested in it: two holes touching at a point (the second comes
/// out an island inside the first), or an outer contour touching its own hole (it comes out an
/// island inside the hole). Such a loop winds the same way as the contour it was cut from, against
/// its flag ([`Contour::outer`](linesweeper::topology::Contour::outer)), and the pass is then
/// grouped by where the contours lie instead ([`face_groups_by_winding`]).
fn face_groups(c: &Contours, owner: &[usize]) -> Vec<(usize, Vec<ContourIdx>)> {
let areas: Vec<f64> = c.contours().map(|ct| ct.path.area()).collect();
if c.contours().zip(&areas).any(|(ct, &a)| ct.outer != (a > 0.0)) {
return face_groups_by_winding(c, owner, &areas);
}
let mut children = vec![Vec::new(); owner.len()];
let mut roots = Vec::new();
for (i, &m) in owner.iter().enumerate() {
@@ -358,6 +369,47 @@ fn face_groups(c: &linesweeper::topology::Contours, owner: &[usize]) -> Vec<(usi
.collect()
}
/// [`face_groups`] without the sweep's tree: each hole (a contour whose area in `areas` isn't
/// positive) goes with the smallest outer contour of its mask around it, and each outer contour,
/// or hole with none around it, starts a face. Faces come in the order of their first contour,
/// each with its contours in pass order.
fn face_groups_by_winding(c: &Contours, owner: &[usize], areas: &[f64]) -> Vec<(usize, Vec<ContourIdx>)> {
let boxes: Vec<kurbo::Rect> = c.contours().map(|ct| ct.path.bounding_box()).collect();
let outer = |i: usize| areas.get(i).is_some_and(|&a| a > 0.0);
let mut root: Vec<usize> = (0..owner.len()).collect();
for (i, ct) in c.contours().enumerate() {
// Contours of one mask share no edge, so the middle of a hole's first piece is strictly
// inside or outside each outer contour of its mask.
let (false, Some(p), Some(b)) = (outer(i), ct.path.segments().next().map(|s| s.eval(0.5)), boxes.get(i)) else {
continue;
};
let around = c
.contours()
.enumerate()
.filter(|&(j, o)| {
outer(j) && owner.get(j) == owner.get(i) && boxes.get(j).is_some_and(|bj| bj.union(*b) == *bj) && o.path.winding(p) != 0
})
.min_by(|(j, _), (k, _)| areas.get(*j).unwrap_or(&f64::INFINITY).total_cmp(areas.get(*k).unwrap_or(&f64::INFINITY)))
.map(|(j, _)| j);
if let (Some(j), Some(r)) = (around, root.get_mut(i)) {
*r = j;
}
}
let mut slot: Vec<Option<usize>> = vec![None; owner.len()];
let mut groups: Vec<(usize, Vec<ContourIdx>)> = Vec::new();
for (i, &r) in root.iter().enumerate() {
let (Some(s), Some(&m)) = (slot.get_mut(r), owner.get(r)) else { continue };
let k = *s.get_or_insert_with(|| {
groups.push((m, Vec::new()));
groups.len() - 1
});
if let Some((_, g)) = groups.get_mut(k) {
g.push(ContourIdx(i));
}
}
groups
}
/// A point as a map key (the arrangement's vertices are exact).
fn vertex_key(p: Point) -> (u64, u64) {
(p.x.to_bits(), p.y.to_bits())
@@ -1020,6 +1072,31 @@ mod tests {
.prop_map(|(p, even_odd, key)| Shape::new(p, if even_odd { FillRule::EvenOdd } else { FillRule::NonZero }, key))
}
/// Contours that touch at a point, which the sweep's tree of contours gets wrong
/// ([`face_groups`]): each face keeps its holes, and no hole is a face of its own.
#[test]
fn contours_touching_at_a_point_keep_their_faces() {
let sorted = |shapes: &[Shape]| {
let mut v: Vec<(Vec<usize>, f64)> =
regions(shapes).iter().map(|r| (r.sources.clone(), crate::area(&r.path, FillRule::NonZero))).collect();
v.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)));
v
};
// A frame whose cutout corner (60,110) touches its hole: the sweep makes the frame an
// island inside the hole.
let mut frame = rect(50.0, 100.0, 80.0, 130.0);
frame.subpaths.extend(rect(60.0, 110.0, 65.0, 115.0).subpaths);
let s = [Shape::new(rect(20.0, 70.0, 60.0, 110.0), FillRule::NonZero, 0), Shape::new(frame, FillRule::EvenOdd, 0)];
assert_eq!(sorted(&s), [(vec![0], 1500.0), (vec![0, 1], 100.0), (vec![1], 775.0)]);
// Two holes touching at (30,50): the sweep makes the second an island inside the first.
let s = [
Shape::new(rect(0.0, 0.0, 100.0, 100.0), FillRule::NonZero, 0),
Shape::new(rect(20.0, 40.0, 30.0, 50.0), FillRule::NonZero, 0),
Shape::new(rect(30.0, 50.0, 40.0, 60.0), FillRule::NonZero, 0),
];
assert_eq!(sorted(&s), [(vec![0], 9800.0), (vec![0, 1], 100.0), (vec![0, 2], 100.0)]);
}
#[test]
fn crossing_strips_match_one_mask_at_a_time() {
let mut v = Vec::new();
+19
View File
@@ -1075,3 +1075,22 @@ fn many_lines_stay_quick() {
assert!(faces.len() > 500, "{}", faces.len());
assert!(started.elapsed().as_secs_f64() < 5.0, "{:?}", started.elapsed());
}
#[test]
fn holes_touching_at_a_point_stay_holes() {
// Two small circles inside a big one, touching each other at (30,85): the big circle's face
// has both as holes, and every face and region is one of the three.
let circle = |cx: f64, cy: f64, r: f64| Shape::new(shapes::ellipse(Rect::new(cx - r, cy - r, cx + r, cy + r)), FillRule::EvenOdd, 0);
let closed = [circle(25.0, 85.0, 5.0), circle(30.0, 80.0, 20.0), circle(35.0, 85.0, 5.0)];
let (big, small) = (400.0 * std::f64::consts::PI, 25.0 * std::f64::consts::PI);
let (faces, _) = live_paint(&closed);
let regions = crate::regions(&closed);
for found in [&faces, &regions] {
let mut sizes: Vec<(Vec<usize>, f64)> = found.iter().map(|r| (r.sources.clone(), area(r))).collect();
sizes.sort_by(|a, b| a.0.cmp(&b.0));
assert_eq!(sizes.iter().map(|s| s.0.clone()).collect::<Vec<_>>(), [vec![0, 1], vec![1], vec![1, 2]], "{sizes:?}");
for ((_, a), want) in sizes.iter().zip([small, big - 2.0 * small, small]) {
assert!((a - want).abs() < 0.5, "{a} vs {want}: {sizes:?}");
}
}
}