mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-31 13:17:19 -06:00
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:
co-authored by
Claude Opus 5.5
parent
bc59c53b5a
commit
be7006ccf5
@@ -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(),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user