mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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]>
187 lines
11 KiB
Rust
187 lines
11 KiB
Rust
//! 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}");
|
||
}
|