Boolean fidelity matrix, and the three torus bugs it found - #807
Conversation
Booleans always return a BRep now, so "is it a BRep?" no longer separates a real result from a degraded one — every mesh-CSG fallback is re-wrapped as a triangle-soup BRep that still passes can_export_step() and still exports STEP, just as thousands of facets instead of the cylinder the part was authored from. Nothing reported that until export time. Adds a representation-fidelity seam and a characterisation matrix over it: - vcad-kernel gains a provenance ledger (SolidFidelity, LossKind, DegradeEvent) and Solid::try_boolean_reported, which separates "failed" from "succeeded but degraded" and names which fallback fired. - vcad-torture gains a `fidelity` mode running 34 curated arrangements x 3 booleans = 102 cells, over operation x operand surface pair x configuration (generic overlap, tangent, coincident face, through-cut break-out, bore, non-manifold contact). Writes docs/boolean-fidelity-matrix.md and a checked-in baseline. Result: 15 of 102 cells degrade, 0 produce wrong geometry. The boundary is curved-vs-curved intersection curves — break-out into a cylinder, cone or sphere wall, and the Steinmetz cross. Plane-vs-anything is analytic everywhere. Kept honest by a baseline drift test (VCAD_FIDELITY_BLESS=1 to re-bless; only the fidelity class is compared, since volumes differ across architectures) and an overlap guard asserting each fixture's difference actually removes volume — a cell reading "analytic" proves nothing if the operands never met. NURBS is left out rather than faked: loft emits ruled planar faces (LoftMode::Smooth is unimplemented), so STEP import is the only source of B-spline faces. That gap is stated in the report. The overlap guard failed on its first run, which unwound into three silent bugs: 1. tessellate_brep had no Torus arm — torus faces fell through to the planar tessellator. A whole donut (four half-edges on one seam vertex) meshed to zero triangles, so Solid::torus(10, 2.5, 32) had volume 0 and every boolean against a torus returned the other operand while reporting Analytic. A fillet blend patch (four distinct corners) meshed as a flat quad, so blends drew as chamfers but stayed watertight — which is why nothing was red. Volume now matches 2*pi^2*R*r^2 to 1.28%, the expected inscribed-polygon error. 2. tessellate_toroidal_face applied `reversed` twice in its winding correction, so every cavity torus face came out outward-wound. A torus subtracted from a 30^3 cube added its volume: 27389.7 instead of 26605. 3. Blend patches did not weld once they actually meshed — an arc-profile fillet had 6173mm of open boundary against a 276mm tolerance (22x), so fillet() rejected its own result and returned the input unfilleted. Fixed by giving toruses the cylinder's ruled-two-chain treatment (tessellate_torus_two_chain): pair equal-length rails index-wise instead of union-gridding them (the rails' u differ by ~0.01 rad and every invented column is a crack), try all loop rotations since a rail spanning the loop closure is split by any linear scan, and let chains share a corner where rails meet at a vertex rather than via a connector edge. 22.35x -> 0.83x, boundary edges 1764 -> 265. n_v is span-adaptive so patches sharing a v range derive the same count and their shared end edges stay welded; a fixed n_v=2 measured 1.65x while 1 and 4 passed, and that parity is luck rather than a fix. test_torus_boolean_subtract was passing vacuously — it only asserted volume <= 27000, which a cut that removes nothing satisfies. Pinned to the closed form. Also adds a torus tessellation regression test against 2*pi^2*R*r^2. 314 test suites pass; clippy and fmt clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Conflicts were all in crates/vcad-kernel/src/lib.rs, where main's fail-closed blend rework (#804 and friends) landed on the same methods this branch touched to add the provenance ledger. Resolution, hunk by hunk: - `try_boolean_reported`/`op_name` (ours) and `non_brep_error` (theirs) are unrelated additions at the same spot — kept both. - `chamfer`, `fillet`, `shell_reported`: main rewrote these to return `Result<_, BlendError>` with a `BlendReport` instead of silently returning the input unchanged. That supersedes our side of those bodies, so theirs was taken wholesale; the only edit was threading `provenance: self.provenance.clone()` through each `Solid` literal, since the field is new on this branch and main's new literals don't set it. Note the two changes agree in spirit: main made blend failure *reportable* rather than silent, which is the same argument this branch makes for boolean representation loss. 318 test suites pass (up from 314 — main's new fillet_torture and face_queries suites); clippy and fmt clean. The fidelity baseline did not drift, so main's kernel changes do not move any cell in the matrix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Net +6 passing (678 → 684 of 752). Eighteen cases move, and the direction
is not uniform, so both halves are worth stating plainly.
Twelve improvements. All ten `tess-torus*` cases went bad-geometry → pass:
they were tessellation-watertightness cases on a primitive that meshed to
zero triangles, so they had been failing outright. `rand-159` and `rand-367`
are boolean cases that now come out clean.
Six regressions, all boolean cases with a torus operand — rand-078, -166,
-298, -306, -358, -372. These were **passing vacuously**. With torus faces
meshing to nothing, the boolean was a no-op that returned a watertight
operand, so the grader saw a closed mesh and called it a pass. The geometry
was always wrong; only now is it visible.
The underlying gap is in the boolean, not the tessellator, and it is
pre-existing: inverting the loop vertices of the torus faces these cases
produce gives (0,0) four times over — the analytic whole-torus seam loop —
so the splitter never trimmed the torus at all. `test_torus_boolean_subtract`
has carried a comment saying as much ("full torus subtraction requires
torus-plane SSI to produce proper split curves") since long before this
branch. The fidelity matrix reports the same thing from the other side:
`generic-overlap/plane-torus/difference` degrades to triangle-soup. Fixing
torus SSI splitting is its own piece of work; this commit does not attempt
it, and these six cases are the honest record of where it stands.
Baseline taken from the CI `torture-scorecard` artifact (run 31921298869),
not a local run, per the platform-specific-baseline note in
torture-track.yml — as of #758 five cases land differently on x86_64 Linux
than on aarch64 macOS. CI and local agreed on all eighteen here, including
the open-edge counts, and no other case moved.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Merged Merge conflicts were all in Torture track: net +6 passing (678 → 684 of 752). Eighteen cases move and the direction isn't uniform:
The gap behind those six is in the boolean, not the tessellator, and it predates this branch: inverting the loop vertices of the torus faces these cases produce gives Baseline was taken from the CI Verified on the merged tree: 318 test suites pass (up from 314 — main's new suites), torture gate reports no regressions, clippy and 🤖 Addressed by Claude Code |
Why
Booleans always return a
BRepnow, so "is it a BRep?" no longer separates a real result from a degraded one. Every mesh-CSG fallback is re-wrapped as a triangle-soup BRep that still passescan_export_step()and still exports STEP — just as thousands of facets instead of the cylinder the part was authored from. Nothing reported that until export time.What changed
A fidelity seam.
vcad-kernelgains a provenance ledger (SolidFidelity,LossKind,DegradeEvent) andSolid::try_boolean_reported, which separates failed from succeeded but degraded and names which fallback fired.A characterisation matrix over it.
vcad-torturegains afidelitymode running 34 curated arrangements × 3 booleans = 102 cells, across operation × operand surface pair × configuration (generic overlap, tangent, coincident face, through-cut break-out, bore, non-manifold contact):Result: 15 of 102 cells degrade, 0 produce wrong geometry. The boundary is curved-vs-curved intersection curves — break-out into a cylinder, cone or sphere wall, plus the Steinmetz cross. Plane-vs-anything is analytic everywhere. Full table in
docs/boolean-fidelity-matrix.md.Keeping it honest
VCAD_FIDELITY_BLESS=1to re-bless. Only the fidelity class is compared, not volumes or face counts, since those already differ across architectures.analyticproves nothing if the operands never met; this caught the dead torus on its first run, and then four of my own misplaced fixtures (curved primitives are origin-centred whilecubeis0..size).loftemits ruled planar faces (LoftMode::Smoothis unimplemented), so STEP import is the only source ofSurfaceKind::BSpline. Stated as a coverage gap in the report.The three bugs the guard found
All silent — nothing failed, the answers were just wrong.
tessellate_brephad noTorusarm. Torus faces fell through to the planar tessellator. A whole donut (four half-edges on one seam vertex) meshed to zero triangles, soSolid::torus(10, 2.5, 32)had volume 0 and every boolean against a torus returned the other operand while reportingAnalytic. A fillet blend patch (four distinct corners) meshed as a flat quad, so blends drew as chamfers but stayed watertight — which is why nothing was red. There is atessellate_toroidal_face; it was only reachable from a second dispatch site. Volume now matches 2π²Rr² to 1.28%, the expected inscribed-polygon error.Cavity toruses wound inside-out.
tessellate_toroidal_faceappliedreversedtwice in its winding correction (flipalready encodes the desired orientation). A torus subtracted from a 30³ cube added its volume: 27389.7 instead of 26605.Blend patches didn't weld once they actually meshed. An arc-profile fillet had 6173mm of open boundary against a 276mm tolerance (22×), so
fillet()rejected its own result and returned the input unfilleted. Fixed withtessellate_torus_two_chain, the torus analogue of the cylinder's ruled-two-chain path. Three things mattered, in descending order of effect:uvalues routinely differ by ~0.01 rad; a union grid invents a column there, and every invented column is a vertex the neighbour lacks. Alone: 1181 → 342.Net: 22.35× → 0.83×, boundary edges 1764 → 265.
Reviewer notes
n_vis span-adaptive (ceil(n_full · v_span / 2π)), not a constant, so patches sharing avrange derive the same count and their shared end edges stay welded. A fixedn_v=2measured 1.65× while 1 and 4 passed — that parity is luck, not a fix, and pinning a constant would be tuning to it.test_torus_boolean_subtractwas passing vacuously. It only assertedvolume <= 27000, which a cut that removes nothing satisfies. Now pinned to30³ − 2π²Rr²within 2%. Any "should not exceed" assertion on a boolean is a candidate for the same failure mode.blend_result_is_validalready documents as tolerated by design.packages/kernel-wasm/vcad_kernel_wasm*artifacts are touched.Verification
cargo test --workspace --exclude vcad-desktop— 314 suites pass, 0 failcargo clippy --workspace --exclude vcad-desktop -- -D warnings— cleancargo fmt --all --check— clean🤖 Generated with Claude Code