M6.10: Boolean operations hold their area identities on almost-horizontal edges

The sweep compares a curve with another segment over a y-span shorter than
its tolerance by bounding boxes only, so an edge rising by less than that
tolerance (e.g. a rectangle edge cut through a circle's cap, whose computed
intersection end sits ~1e-12 off the horizontal) was mis-ordered and the
result lost a corner: xor/difference/area disagreed by up to 10%. Every
sweep input now snaps on-curve y values within the tolerance to one level
(handles follow and are levelled with their anchor), so such edges become
exact horizontals, which the sweep handles exactly.

- tests/regressions.rs: the six minimal failures found by the properties,
  the root cause in isolation, and the self-crossing "star" polygon.
- The star-polygon strategy rejected no angular step of half a turn or
  more, so it sometimes drew self-crossing polygons (the shoelace failure).
- Property suites run from a fixed seed (PROPTEST_CASES / PROPTEST_RNG_SEED
  still override), so CI cannot fail at random.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-05 03:36:30 +07:00
co-authored by Claude Opus 5.5
parent bc59c53b5a
commit be7006ccf5
8 changed files with 301 additions and 10 deletions
+80 -4
View File
@@ -146,6 +146,79 @@ fn el_points(el: &kurbo::PathEl) -> impl Iterator<Item = Point> {
v.into_iter().flatten()
}
/// Make edges that rise by less than the sweep tolerance `eps` exactly horizontal.
///
/// The sweep orders a curve against another segment over a y-span shorter than `eps` by bounding
/// boxes alone, so an almost-horizontal edge (typically a boolean result's edge whose end, an
/// intersection point, is a rounding error off the input's horizontal) can be mis-ordered against
/// a curve it passes and the output loses a corner; exact horizontals are handled exactly. So
/// on-curve y coordinates within `eps` of each other snap to one value (the lowest of each
/// cluster, keeping distinct values at least `eps` apart), handles move with their anchors, and a
/// handle within `eps` of its anchor's height is levelled with it (no y-extremum is left in a
/// sub-`eps` sliver of a curve). Anchors move by less than `eps` and handles by less than `2·eps`,
/// the order of the sweep's own tolerance.
fn snap_horizontals(paths: &mut [&mut BezPath], eps: f64) {
use kurbo::PathEl::*;
let mut ys: Vec<f64> = paths.iter().flat_map(|p| p.elements().iter().filter_map(kurbo::PathEl::end_point)).map(|p| p.y).collect();
ys.sort_by(f64::total_cmp);
ys.dedup();
let mut start = f64::NEG_INFINITY;
let levels: Vec<f64> = ys
.iter()
.map(|&y| {
if y - start >= eps {
start = y;
}
start
})
.collect();
let snap = |p: &mut Point| {
let y = levels[ys.partition_point(|&v| v < p.y)];
let moved = y - p.y;
p.y = y;
moved
};
// Shift a handle with its anchor, then level it with the anchor when within `eps`.
let follow = |h: &mut Point, anchor: Point, moved: f64| {
h.y += moved;
if (h.y - anchor.y).abs() < eps {
h.y = anchor.y;
}
};
for p in paths.iter_mut() {
// The current point (snapped) and how far snapping moved it; the subpath's start.
let (mut cur, mut moved, mut first) = (Point::ZERO, 0.0, Point::ZERO);
for el in p.elements_mut() {
match el {
MoveTo(q) => {
moved = snap(q);
(cur, first) = (*q, *q);
}
LineTo(q) => {
moved = snap(q);
cur = *q;
}
QuadTo(c, q) => {
let m = snap(q);
follow(c, cur, moved);
follow(c, *q, 0.0);
(cur, moved) = (*q, m);
}
CurveTo(c1, c2, q) => {
let m = snap(q);
follow(c1, cur, moved);
follow(c2, *q, m);
(cur, moved) = (*q, m);
}
ClosePath => {
// A later segment without a MoveTo starts from the subpath's (snapped) start.
(cur, moved) = (first, 0.0);
}
}
}
}
}
/// An arrangement (planar subdivision) of N filled paths.
pub(crate) struct Arrangement {
pub(crate) top: Topology<Multi>,
@@ -194,8 +267,9 @@ impl Tidy {
}
impl Arrangement {
pub(crate) fn new(paths: &[(BezPath, FillRule)]) -> Result<Self, PathOpsError> {
pub(crate) fn new(mut paths: Vec<(BezPath, FillRule)>) -> Result<Self, PathOpsError> {
let eps = eps_for(paths.iter().map(|p| &p.0))?;
snap_horizontals(&mut paths.iter_mut().map(|p| &mut p.0).collect::<Vec<_>>(), eps);
// The sweep can panic on rare near-degenerate input; that must never take the app down,
// so it becomes an error (callers fall back to leaving the art unchanged).
let sweep = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
@@ -456,7 +530,9 @@ pub fn try_normalize(path: &PathData, rule: FillRule) -> Result<PathData, PathOp
pub(crate) fn normalize_bez(bp: &BezPath, rule: FillRule) -> Result<Contours, PathOpsError> {
let eps = eps_for([bp])?;
let top = Topology::<i32>::from_path(bp, eps).map_err(|_| PathOpsError::OpenPath)?;
let mut bp = bp.clone();
snap_horizontals(&mut [&mut bp], eps);
let top = Topology::<i32>::from_path(&bp, eps).map_err(|_| PathOpsError::OpenPath)?;
Ok(top.contours(|w| inside(rule, *w)))
}
@@ -467,7 +543,7 @@ pub fn boolean(a: &PathData, a_rule: FillRule, b: &PathData, b_rule: FillRule, o
/// Boolean operation with an explicit refit `precision`; errors on non-finite input.
pub fn try_boolean(a: &PathData, a_rule: FillRule, b: &PathData, b_rule: FillRule, op: BoolOp, precision: f64) -> Result<PathData, PathOpsError> {
let arr = Arrangement::new(&[(fill_bezpath(a), a_rule), (fill_bezpath(b), b_rule)])?;
let arr = Arrangement::new(vec![(fill_bezpath(a), a_rule), (fill_bezpath(b), b_rule)])?;
let c = arr.contours(|m| op.apply(m[0], m[1]));
let tidy = Tidy { precision, ..arr.tidy };
Ok(all_contours_to_path(&c, &tidy))
@@ -477,7 +553,7 @@ pub fn try_boolean(a: &PathData, a_rule: FillRule, b: &PathData, b_rule: FillRul
/// point is inside `paths[i]` under its fill rule.
pub fn boolean_n(paths: &[(&PathData, FillRule)], pred: impl Fn(&[bool]) -> bool) -> PathData {
let bps: Vec<(BezPath, FillRule)> = paths.iter().map(|(p, r)| (fill_bezpath(p), *r)).collect();
match Arrangement::new(&bps) {
match Arrangement::new(bps) {
Ok(arr) => all_contours_to_path(&arr.contours(pred), &arr.tidy),
Err(_) => PathData::default(),
}