fix(hatch): only direct children become holes in nested pick

Follow-up to #374. resolve_hatch_rings flagged every outline nested in
the fill ring as a hole, so with 3+ nesting levels a click in an outer
band added grandchild islands too — even-odd fill then re-filled the
innermost shape. Skip a candidate when another outline sits strictly
between it and the fill ring, so only the immediate next level holes out.

Closes #377.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-13 17:56:20 +03:00
1 parent b4a25701de
commit 1884f622c2
1 file changed
+38 -8
+38 -8
View File
@@ -100,12 +100,14 @@ fn polygon_contains_polygon(outer: &[[f32; 2]], inner: &[[f32; 2]]) -> bool {
/// Resolve the hatch boundary for a "pick inside" click.
///
/// The outer ring is the *smallest* outline containing the click point — the
/// innermost region the point belongs to. Any other outline fully enclosed by
/// that ring but which does **not** itself contain the point becomes a hole.
/// This yields the intuitive result for nested boundaries (a small rectangle
/// inside a big one), independent of draw order:
/// * click inside the small shape → hatch just that shape,
/// * click in the gap → hatch the ring (big minus small).
/// innermost region the point belongs to. Its holes are that ring's **direct
/// children**: outlines nested one level inside it with no other outline in
/// between. Deeper (grandchild) outlines belong to those children's own fills,
/// so they are left out — otherwise even-odd rasterisation would flip the
/// innermost island back on for 3+ nesting levels. The result is intuitive and
/// draw-order independent:
/// * click inside the innermost shape → hatch just that shape,
/// * click in a gap → hatch that ring, with the next level in as holes.
fn resolve_hatch_rings(
outlines: &[Vec<[f32; 2]>],
p: [f32; 2],
@@ -130,8 +132,22 @@ fn resolve_hatch_rings(
if i == outer_idx {
continue;
}
// A nested outline the click is NOT inside becomes a hole.
if polygon_contains_polygon(outer, o) && !point_in_polygon(p, o) {
// Candidate hole: fully nested inside the fill outline. (An outline the
// click sits in cannot qualify — it would have been the smaller fill.)
if !polygon_contains_polygon(outer, o) || point_in_polygon(p, o) {
continue;
}
// Only DIRECT children become holes. If another outline sits strictly
// between `outer` and `o` (inside `outer`, and enclosing `o`), then `o`
// belongs to that intermediate region's own fill; flagging it here would
// re-fill it under even-odd once nesting reaches three levels.
let has_intermediate = outlines.iter().enumerate().any(|(k, x)| {
k != i
&& k != outer_idx
&& polygon_contains_polygon(outer, x)
&& polygon_contains_polygon(x, o)
});
if !has_intermediate {
rings.push(o.iter().map(|&[x, y]| [x as f64, y as f64]).collect());
}
}
@@ -647,4 +663,18 @@ mod tests {
let rings = resolve_hatch_rings(&[a, b, c], [0.0, 0.0]).unwrap();
assert_eq!(rings.len(), 1, "innermost fill has no hole");
}
#[test]
fn click_outer_band_only_direct_child_is_hole() {
let a = rect(-30.0, -30.0, 30.0, 30.0);
let b = rect(-15.0, -15.0, 15.0, 15.0);
let c = rect(-5.0, -5.0, 5.0, 5.0);
// Click in the outermost band (between a and b): fill = a with only its
// direct child b as a hole. The grandchild c must be excluded — adding
// it would flip the innermost square back on under even-odd fill.
let rings = resolve_hatch_rings(&[a, b, c], [20.0, 0.0]).unwrap();
assert_eq!(rings.len(), 2, "outer band = a with b as its only hole");
assert!((rings[0][0][0] - (-30.0)).abs() < 1e-9, "outer ring is a");
assert!((rings[1][0][0] - (-15.0)).abs() < 1e-9, "hole is direct child b");
}
}