Files
bflatasticandClaude Opus 5.5 be7006ccf5 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 <[email protected]>
2026-10-05 03:36:30 +07:00

187 lines
11 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Minimal failures found by the property suites (`props.rs`, `prop_pathops.rs`), kept as
//! deterministic tests.
use kurbo::{BezPath, Shape as _};
use vectorcraft_geom::{Anchor, FillRule, PathData, SubPath};
use vectorcraft_pathops::*;
const NZ: FillRule = FillRule::NonZero;
/// A closed path from `[x, y, in_x, in_y, out_x, out_y]` anchors.
fn curved(anchors: &[[f64; 6]]) -> PathData {
let a = |v: &[f64; 6]| Anchor::with_handles((v[0], v[1]).into(), (v[2], v[3]).into(), (v[4], v[5]).into());
PathData::single(SubPath::new(anchors.iter().map(a).collect(), true))
}
fn poly(pts: &[(f64, f64)]) -> PathData {
PathData::single(SubPath::polyline(&pts.iter().map(|&p| p.into()).collect::<Vec<_>>(), true))
}
fn close(a: f64, b: f64, rel: f64) -> bool {
(a - b).abs() <= rel * a.abs().max(b.abs()).max(1.0)
}
/// Area identities between the four boolean operations of `a` and `b`.
fn assert_identities(a: &PathData, b: &PathData) {
let op = |p: &PathData, q: &PathData, op| area(&boolean(p, NZ, q, NZ, op), NZ);
let (aa, ab) = (area(a, NZ), area(b, NZ));
let (u, i, x) = (op(a, b, BoolOp::Union), op(a, b, BoolOp::Intersect), op(a, b, BoolOp::Xor));
let (d1, d2) = (op(a, b, BoolOp::Difference), op(b, a, BoolOp::Difference));
let tol = 1e-6;
assert!(close(d1 + d2, x, tol), "a−b {d1} + b−a {d2} != xor {x}");
assert!(close(u - i, x, tol), "union {u} − intersection {i} != xor {x}");
assert!(close(u + i, aa + ab, tol), "union {u} + intersection {i} != {aa} + {ab}");
assert!(close(d1 + i, aa, tol), "a−b {d1} + intersection {i} != a {aa}");
assert!(close(d2 + i, ab, tol), "b−a {d2} + intersection {i} != b {ab}");
}
/// A rectangle edge cutting a circle near its top or bottom: the cut leaves an edge that is
/// horizontal up to the intersection's rounding (a rise of ~1e-12), which the sweep used to
/// mis-order against the circle's arc, dropping a corner (`difference_and_xor_consistent`).
#[test]
fn rect_edge_through_circle_cap() {
let cases = [
(
curved(&[
[19.32739846439343, 15.797214477812503, 19.32739846439343, 14.207108832129435, 19.32739846439343, 17.38732012349557],
[17.360120998998184, 19.882308227700324, 18.60331565324336, 18.890893573738936, 16.116926344753008, 20.873722881661713],
[12.939688659402073, 20.89124306319632, 14.489927036696397, 21.245074856562926, 11.389450282107749, 20.537411269829715],
[9.394777157707882, 18.064270474652044, 10.084698140848792, 19.49690615808836, 8.704856174566974, 16.631634791215724],
[9.394777157707882, 13.530158480972963, 8.704856174566974, 14.962794164409281, 10.08469814084879, 12.097522797536644],
[12.939688659402071, 10.703185892428685, 11.389450282107747, 11.05701768579529, 14.489927036696395, 10.34935409906208],
[17.360120998998184, 11.712120727924681, 16.116926344753004, 10.720706073963292, 18.60331565324336, 12.70353538188607],
]),
poly(&[
(0.0, 20.17376246815288),
(37.21494402256868, 20.17376246815288),
(37.21494402256868, 21.17376246815288),
(0.0, 21.17376246815288),
]),
),
(
curved(&[
[23.57990847002715, 27.750243942757283, 23.57990847002715, 26.818683968734558, 23.57990847002715, 28.68180391678001],
[22.276172537520765, 30.00838081751153, 23.08292714017322, 29.542600830500167, 21.46941793486831, 30.474160804522892],
[19.668700672507995, 30.00838081751153, 20.47545527516045, 30.474160804522892, 18.86194606985554, 29.542600830500167],
[18.36496474000161, 27.750243942757283, 18.36496474000161, 28.68180391678001, 18.36496474000161, 26.818683968734558],
[19.668700672507995, 25.492107068003037, 18.86194606985554, 25.9578870550144, 20.475455275160446, 25.026327080991674],
[22.276172537520765, 25.492107068003037, 21.46941793486831, 25.02632708099167, 23.08292714017322, 25.9578870550144],
]),
poly(&[
(0.0, 30.068083063105394),
(34.105297058743425, 30.068083063105394),
(34.105297058743425, 31.068083063105394),
(0.0, 31.068083063105394),
]),
),
(
poly(&[
(9.700636443853261, 23.969159526578938),
(26.555106262718724, 23.969159526578938),
(26.555106262718724, 54.82336332868054),
(9.700636443853261, 54.82336332868054),
]),
curved(&[
[27.958532449581877, 19.508138986349657, 27.958532449581877, 17.819765427048214, 27.958532449581877, 21.1965125456511],
[25.869677998055128, 23.845689949270323, 27.1897016008836, 22.79300625331794, 24.549654395226657, 24.898373645222705],
[21.176064650340088, 24.916976567196787, 22.822107159492838, 25.292675028479692, 19.530022141187338, 24.541278105913882],
[17.412078989530123, 21.91529816656589, 18.144636822467053, 23.436470180886086, 16.67952115659319, 20.39412615224569],
[17.412078989530123, 17.100979806133427, 16.67952115659319, 18.622151820453624, 18.14463682246705, 15.579807791813227],
[21.176064650340088, 14.099301405502526, 19.530022141187338, 14.474999866785431, 22.822107159492838, 13.723602944219623],
[25.869677998055128, 15.17058802342899, 24.549654395226657, 14.117904327476609, 27.1897016008836, 16.223271719381373],
]),
),
(
poly(&[
(0.0, 36.673784808936425),
(36.25829503976694, 36.673784808936425),
(36.25829503976694, 61.69546684624716),
(0.0, 61.69546684624716),
]),
curved(&[
[38.42179318629935, 31.567929837130343, 38.42179318629935, 29.68809407962987, 38.42179318629935, 33.447765594630816],
[36.096061852400304, 36.39736071465043, 37.56577662948331, 35.225302290679494, 34.6263470753173, 37.56941913862136],
[30.870190981402537, 37.59013163896124, 32.70289533170721, 38.00843444740472, 29.037486631097863, 37.171828830517754],
[26.679367927506128, 34.24806177596396, 27.49499809489732, 35.94173527024165, 25.863737760114933, 32.55438828168628],
[26.679367927506128, 28.887797898296725, 25.863737760114933, 30.58147139257441, 27.49499809489732, 27.194124404019036],
[30.870190981402533, 25.545728035299447, 29.037486631097863, 25.96403084374293, 32.70289533170721, 25.127425226855966],
[36.096061852400304, 26.738498959610258, 34.6263470753173, 25.566440535639327, 37.56577662948331, 27.91055738358119],
]),
),
(
poly(&[
(13.843112028864478, 38.445768817380575),
(40.14690269607463, 38.445768817380575),
(40.14690269607463, 39.445768817380575),
(13.843112028864478, 39.445768817380575),
]),
curved(&[
[39.74299892595637, 33.15627685561371, 39.74299892595637, 31.327165913276996, 39.74299892595637, 34.98538779795043],
[37.48002429383932, 37.855392099105174, 38.91008081348493, 36.714960080090016, 36.04996777419372, 38.99582411812034],
[32.39516645174228, 39.01597771976541, 34.17841776390354, 39.42299319496389, 30.61191513958101, 38.608962244566925],
[28.317427066961493, 35.76408907360503, 29.111048561883386, 37.412061088590065, 27.523805572039596, 34.11611705861999],
[28.317427066961493, 30.548464637622395, 27.523805572039596, 32.19643665260743, 29.111048561883386, 28.90049262263736],
[32.39516645174227, 27.296575991462014, 30.61191513958101, 27.703591466660495, 34.17841776390354, 26.889560516263533],
[37.48002429383932, 28.457161612122245, 36.04996777419371, 27.316729593107084, 38.91008081348493, 29.597593631137407],
]),
),
(
poly(&[
(29.861827337105368, 18.136938945855064),
(66.57181423170378, 18.136938945855064),
(66.57181423170378, 20.881417410214578),
(29.861827337105368, 20.881417410214578),
]),
curved(&[
[50.05371460858578, 9.878937912496232, 50.05371460858578, 6.762209599042626, 50.05371460858578, 12.995666225949838],
[46.19770076858542, 17.886033156736026, 48.634457086342934, 15.94278483813333, 43.76094445082791, 19.829281475338718],
[37.53331631845236, 19.863622367086386, 40.57190174592566, 20.55715966228417, 34.494730890979056, 19.1700850718886],
[30.585019470370003, 14.32254053268522, 31.937317204824815, 17.130615712816933, 29.23272173591519, 11.51446535255351],
[30.58501947037, 5.435335292307244, 29.23272173591519, 8.243410472438956, 31.937317204824815, 2.6272601121755335],
[37.53331631845236, -0.10574654209391987, 34.494730890979056, 0.5877907531038655, 40.57190174592566, -0.7992838372917053],
[46.19770076858542, 1.8718426682564377, 43.76094445082791, -0.07140565034625546, 48.634457086342934, 3.815090986859132],
]),
),
];
for (a, b) in &cases {
assert_identities(a, b);
}
}
/// The root cause in isolation: a strip whose top edge rises by less than the sweep tolerance
/// (1e-6 here) and ends level with the point where an arc meets a horizontal edge of a second
/// contour. Every tilt must keep the strip's full area.
#[test]
fn almost_horizontal_edge_meeting_an_arc_end() {
for dy in [1e-14, 1e-12, 1e-10, 1e-8, 1e-7, 5e-7, 9e-7] {
let svg = format!("M10,10 L20,10 L20,16 C19,18 18,19 17,20 L11,20 Z M0,21 L0,20 L11,20 L13,20.9 L16,{} L37,20 L37,21 Z", 20.0 - dy);
let bp = BezPath::from_svg(&svg).unwrap();
// The contours overlap by at most a `dy`-thin sliver (and levelling the edge moves it by
// `dy`), so the filled area is the plain (Green's theorem) area up to `dy` × its length.
let want = bp.area().abs();
let got = area(&PathData::from_bezpath(&bp), NZ);
assert!((got - want).abs() <= dy * 40.0, "rise {dy}: area {got}, want {want}");
}
}
/// `polygon_area_matches_shoelace` once drew this "star" polygon, whose wide angular step made it
/// cross itself (the strategy now rejects those): its shoelace area cancels across the crossing,
/// while the filled area is that of the two loops.
#[test]
fn self_crossing_quad_fills_both_loops() {
let [p0, p1, p2, p3]: [kurbo::Point; 4] = [
(43.48211493626725, 43.58815768474916),
(39.45699865107951, 58.097908614334145),
(56.35982114265447, 23.14196428649071),
(72.23596128912416, 39.99999999999999),
]
.map(Into::into);
// Where p1→p2 crosses p3→p0.
let (d1, d2) = (p2 - p1, p0 - p3);
let x = p1 + d1 * ((p3 - p1).cross(d2) / d1.cross(d2));
let tri = |a: kurbo::Point, b: kurbo::Point, c: kurbo::Point| (b - a).cross(c - a).abs() / 2.0;
let want = tri(p0, p1, x) + tri(x, p2, p3);
let got = area(&poly(&[p0, p1, p2, p3].map(|p| (p.x, p.y))), NZ);
assert!(close(got, want, 1e-9), "area {got}, want {want}");
}