fix(boolean): restrict analytic FF curves + merge coincident junction edges#795
Conversation
… edges Two robustness improvements for coincident curved-surface fuses, surfaced while isolating the curved-loft socket family. algo/phase_ff — restrict surface-surface intersection curves to the region inside BOTH faces. compute_raw_curves works on the unbounded surfaces, so a plane-analytic / algebraic ellipse (e.g. a cylinder vs a tilted wall) can reach far past the faces' shared region and slit the partner face's wire, leaving phantom inner wires. Drop non-Line curves whose longest contiguous in-both sample run is under two samples — a tangency/point that never splits either face. Conservative: if either face's extent can't be built, curves pass through unchanged. boolean — merge geometrically-coincident duplicate boundary edges on results that still have free edges. A coincident-junction fuse annihilates the shared cap but leaves each argument carrying its OWN copy of the junction-wire edges, differing by sub-micron numerical noise (independent construction); the tight-tolerance vertex merge leaves them distinct, each used once -> free edges. Snap vertices at a looser tolerance and rebuild every wire against a canonical-edge map keyed by unordered endpoints + curve type + geometric midpoint (so a line and an arc between the same endpoints stay distinct); degenerate edges are dropped. Gated on free-edge results so clean booleans keep their exact topology. Together these take the rrect-cap + frustum reproducer's raw GFA output from euler=-7 / 8 phantom holes / 30+ free edges down to euler=-2 with only the four arc-vs-chord corner mismatches remaining. Those remaining mismatches are a loft limitation — loft samples profiles to polygons and builds straight ring edges, so a rounded-rect loft becomes an octagonal frustum whose chord-cornered cap can't match a box's arc-cornered cap. That residual is tracked by the ignored fuse_coincident_rrect_cap_with_frustum test; closing it needs a curve-preserving loft, not a change in the fuse pipeline. Zero regressions: algo 113, operations 662, io 156, wasm 210.
WASM Binary Size
|
There was a problem hiding this comment.
Pull request overview
This PR improves robustness of coincident curved-surface boolean fuses by (1) restricting unbounded face-face intersection (FF) curves to the mutual trimmed face extents and (2) merging numerically-near-duplicate junction boundary edges that can otherwise leave free edges and open shells. It also adds an ignored regression test capturing a known loft faceting limitation.
Changes:
- Add FF-curve restriction logic in
pave_filler/phase_ffto drop analytic intersection curves that only graze the mutual face extents. - Add a post-GFA healing pass in
operations::booleanto unify coincident duplicate boundary edges when the result shell has free edges. - Add an ignored integration test reproducer for a coincident rounded-rect cap fuse with a lofted frustum.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| crates/algo/src/pave_filler/phase_ff.rs | Adds face-extent construction and FF-curve restriction to avoid untrimmed analytic intersections slitting partner face wires. |
| crates/operations/src/boolean/mod.rs | Adds a gated healing step to merge coincident duplicate boundary edges on open-shell results. |
| crates/operations/src/boolean/tests.rs | Adds an ignored reproducer test documenting the remaining loft faceting limitation. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| EdgeCurve::Line => poly.push(frame.project(oriented_start)), | ||
| curve => { | ||
| let (t0, t1) = curve.domain_with_endpoints(s3, e3); | ||
| for i in 0..16 { |
| let face = topo.face(face_id).ok()?; | ||
| let wire = topo.wire(face.outer_wire()).ok()?; | ||
| let first = wire.edges().first()?; |
| // (or no presence at all) — such a curve never splits either face, so | ||
| // drop it. Curves with a real in-both span are kept whole (the | ||
| // downstream splitter trims them to the face boundary). | ||
| if b1 - b0 < 2 { |
| // Physical traversal start vertex (after canonicalization). | ||
| let trav_start = if *fwd { cs } else { ce }; | ||
| if let Some(&(c_eid, c_start, _c_end)) = ecanon.get(&key) { | ||
| if c_eid != *eid { | ||
| *changed = true; | ||
| } | ||
| out.push(OrientedEdge::new(c_eid, c_start == trav_start)); | ||
| } else { | ||
| let new_eid = topo.add_edge(Edge::with_tolerance(cs, ce, curve.clone(), *etol)); | ||
| if new_eid != *eid { | ||
| *changed = true; | ||
| } | ||
| ecanon.insert(key, (new_eid, cs, ce)); | ||
| out.push(OrientedEdge::new(new_eid, cs == trav_start)); | ||
| } |
| if has_free_edges(topo, result).unwrap_or(false) { | ||
| let _ = unify_coincident_boundary_edges( | ||
| topo, | ||
| result, | ||
| (tol.linear * 10.0).max(1e-6), | ||
| ); | ||
| } |
Greptile SummaryTwo targeted robustness fixes for coincident curved-surface fuses:
Confidence Score: 5/5Safe to merge after a human second-read of phase_ff — the filter is conservative and all 1141 tests pass with no regressions. Both new code paths degrade gracefully to the original topology on any failure. The ecanon midpoint key using pre-snap positions is a minor robustness gap well outside the sub-micron noise range targeted here. Orphaned edges on bail-out are an arena-lifetime leak, not a correctness hazard. The previous inner-wire-loss concern is now properly addressed. phase_ff.rs — core GFA path touched by every analytic FF pair; worth a second human read of FaceExtent::contains and the N=24 sampling threshold. Important Files Changed
Reviews (3): Last reviewed commit: "fix(boolean): bail on inner-wire rebuild..." | Re-trigger Greptile |
… FF extent Copilot review on #795: - phase_ff FaceExtent::Plane now samples arc edges inclusive of the endpoint (0..=16) and subtracts inner-wire (hole) polygons, so a point inside a hole is no longer treated as inside the face. - Correct the drop-threshold wording: `b1 - b0 < 2` keeps runs of >=2 segments (>=3 in-both samples); doc/comment now say "fewer than two segments". - unify_coincident_boundary_edges: reuse the original edge when its endpoints didn't move instead of always allocating a fresh one, so `changed` is only set on a real merge / degenerate drop / vertex snap — the pass is now a true no-op on a free-edge shell with no coincident duplicates. - Log (don't silently drop) an error from the unify pass. Zero regressions: algo 113, operations 662; clippy clean; flake-stable.
|
Addressed the Copilot review in 872af0f:
Zero regressions (algo 113, operations 662), clippy clean, flake-stable. |
…erge Greptile review on #795: unify_coincident_boundary_edges silently dropped a face's inner wire (hole) when its rebuilt wire was invalid, while outer-wire failure bailed with Ok(false). Losing a hole changes topology AND removes free edges, so the downstream has_free_edges gate can't detect it. Handle the inner wire consistently — bail out, leaving the original solid untouched.
|
Thanks — addressed the Greptile review: Issue 1 (inner-wire silent loss) — fixed in 286a09b. Issue 2 (drop-threshold comment) — already corrected in 872af0f: the doc/comment now say the filter drops runs spanning "fewer than two segments (at most two consecutive in-both samples)", matching Issue 3 (hardcoded Test job green on the prior commit; CI re-running on 286a09b. |
🤖 I have created a release *beep* *boop* --- ## [2.102.13](v2.102.12...v2.102.13) (2026-06-13) ### Bug Fixes * **boolean:** restrict analytic FF curves + merge coincident junction edges ([#795](#795)) ([b52fa56](b52fa56)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: brepkit[bot] <265643962+brepkit[bot]@users.noreply.github.com>
## Summary `loft` sampled every profile to a polygon and built straight `Line` ring edges with planar side faces, so a **rounded-rect loft became an octagonal frustum**. A coincident-cap fuse of such a faceted frustum against an arc-cornered solid could then never annihilate the cap cleanly — the long-open **`fuse_coincident_rrect_cap_with_frustum` (#790-residual)** case. This adds a **curve-preserving path for two-profile lofts**. When both profiles share the same boundary edge structure (same edge count, matching per-edge curve type) and wind CCW, the loft preserves each edge's curve: ring edges keep their `EdgeCurve`, arc corners become ruled side faces, and the caps are the real arc-edged profile wires. Mirrors the reference `BRepFill_Generator`: - **Coaxial** corner arcs (centers differ only along the axis) → exact **`Cylinder`** (equal radii) / **`Cone`** (unequal radii) — analytic surfaces the boolean handles robustly. - Genuinely **non-coaxial** corner arcs → a degree-(1,2) **ruled NURBS**. - `Line` edges keep planar quad side faces. Orientation is set per face against the outward direction. ## Scope (deliberately conservative) Gated to **two profiles** (a single ruled band). Multi-section curve lofts (e.g. a 5-section gridfinity lip) chain curved bands whose downstream cut/fuse/shell sequences hit near-degenerate corner-junction robustness gaps — those **fall back to the existing polygon loft**, which already handles them. So gridfinity and every other multi-profile loft are **unchanged**. The deeper multi-section curved-corner boolean robustness (cut → fixed via cone/cylinder recognition on a WIP branch; fuse → isolated to near-degenerate tiny-corner junctions) is tracked for a future effort. ## Testing - ✅ Closes `fuse_coincident_rrect_cap_with_frustum` (now passing, was `#[ignore]`d). - ✅ Adds `loft_rounded_rect_preserves_arc_corners` (10 faces, 4 NURBS arc corners, watertight, euler=2). - **Zero regressions**: operations 664, algo 113, io 156, wasm 211 (gridfinity 24). Clippy + layer boundaries clean. ## Review note Contained to the loft (operations L3), gated to the two-profile single-band case. It does feed curved-corner solids into the boolean, but only the well-conditioned single-band geometry the FF-trimming + edge-merge from #795 already handle. Not set to auto-merge — flagging for review given the history.
Summary
Two robustness improvements for coincident curved-surface fuses, surfaced while isolating the curved-loft socket family (
fuse_shelled_box_with_socket_loft).1.
algo/phase_ff— restrict analytic FF curves to both faces' extentcompute_raw_curvesintersects the unbounded surfaces, so a plane-analytic / algebraic ellipse (e.g. a cylinder vs a tilted wall) can reach far past the faces' shared region and slit the partner face's wire, leaving phantom inner wires. The newrestrict_curves_to_facesbuilds a lightweight per-face extent (planar boundary polygon, or analyticv-range) and drops non-Line curves whose longest contiguous in-both sample run is under two samples — a tangency/point that never splits either face. Conservative: if either extent can't be built, curves pass through unchanged.2.
boolean— merge coincident duplicate junction edgesA coincident-junction fuse annihilates the shared cap but leaves each argument carrying its own copy of the junction-wire edges, differing by sub-micron numerical noise (independent construction). The tight-tolerance vertex merge leaves them distinct → each used once → free edges that open the shell.
unify_coincident_boundary_edgessnaps vertices at a looser tolerance and rebuilds every wire against a canonical-edge map keyed by unordered endpoints + curve type + geometric midpoint (so a line and an arc between the same endpoints stay distinct); degenerate edges are dropped. Gated on free-edge results so clean booleans keep exact topology.Effect
Together these take the rrect-cap + frustum reproducer's raw GFA output from euler=−7 / 8 phantom holes / 30+ free edges down to euler=−2 with only the 4 arc-vs-chord corner mismatches left.
Known residual (not addressed here)
The 4 remaining corner mismatches are a loft limitation:
loftsamples each profile to a polygon and builds straight (Line) ring edges with planar side faces, so a rounded-rect loft becomes an octagonal frustum whose chord-cornered cap can't match a box's arc-cornered cap. Tracked by the new ignoredfuse_coincident_rrect_cap_with_frustumtest. Closing it needs a curve-preserving loft, not a change in the fuse pipeline.Testing
Zero regressions: algo 113, operations 662, io 156, wasm 210. Clippy + boundaries clean. 5× flake gate on the boolean suite stable.
phase_ffis core GFA (pave_filler) and runs on every analytic FF pair → HIGH-risk. Not set to auto-merge; requesting human review before merge.