Skip to content

fix(boolean): restrict analytic FF curves + merge coincident junction edges#795

Merged
andymai merged 3 commits into
mainfrom
fix/analytic-ff-trimming
Jun 13, 2026
Merged

fix(boolean): restrict analytic FF curves + merge coincident junction edges#795
andymai merged 3 commits into
mainfrom
fix/analytic-ff-trimming

Conversation

@andymai

@andymai andymai commented Jun 13, 2026

Copy link
Copy Markdown
Owner

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' extent

compute_raw_curves intersects 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 new restrict_curves_to_faces builds a lightweight per-face extent (planar boundary polygon, or analytic v-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 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 that open the shell. unify_coincident_boundary_edges snaps 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: loft samples 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 ignored fuse_coincident_rrect_cap_with_frustum test. 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.

⚠️ Review note

phase_ff is core GFA (pave_filler) and runs on every analytic FF pair → HIGH-risk. Not set to auto-merge; requesting human review before merge.

… 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.
Copilot AI review requested due to automatic review settings June 13, 2026 23:03
@github-actions

github-actions Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

WASM Binary Size

File main PR Delta
brepkit_wasm.wasm 4.92 MB 4.94 MB +22.3 KB (+.4%)

⚠️ Size increased slightly.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_ff to drop analytic intersection curves that only graze the mutual face extents.
  • Add a post-GFA healing pass in operations::boolean to 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.

Comment thread crates/algo/src/pave_filler/phase_ff.rs Outdated
EdgeCurve::Line => poly.push(frame.project(oriented_start)),
curve => {
let (t0, t1) = curve.domain_with_endpoints(s3, e3);
for i in 0..16 {
Comment thread crates/algo/src/pave_filler/phase_ff.rs Outdated
Comment on lines +354 to +356
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 {
Comment on lines +2742 to +2756
// 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));
}
Comment on lines +485 to +491
if has_free_edges(topo, result).unwrap_or(false) {
let _ = unify_coincident_boundary_edges(
topo,
result,
(tol.linear * 10.0).max(1e-6),
);
}
@greptile-apps

greptile-apps Bot commented Jun 13, 2026

Copy link
Copy Markdown

Greptile Summary

Two targeted robustness fixes for coincident curved-surface fuses: phase_ff now discards unbounded analytic intersection curves that only graze the mutual face extent (preventing phantom inner wires), and boolean now merges coincident duplicate junction edges left by sub-micron loft noise (closing free edges). Both paths are conservative and gated so clean booleans are unaffected.

  • phase_ff.rs: New FaceExtent abstraction + restrict_curves_to_faces drops non-Line curves whose longest in-both-faces sample run spans fewer than 2 segments, treating them as tangency/grazing points that never split either face.
  • boolean/mod.rs: New unify_coincident_boundary_edges, invoked only when has_free_edges fires, rebuilds all wires against a canonical-edge map keyed by endpoint quanta + midpoint + curve type; inner-wire bail-out now matches the outer-wire behaviour (prior review concern addressed).
  • tests.rs: Adds an #[ignore] reproducer documenting the residual loft arc-vs-chord corner limitation out of scope for this PR.

Confidence Score: 5/5

Safe 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

Filename Overview
crates/algo/src/pave_filler/phase_ff.rs Adds FaceExtent enum + restrict_curves_to_faces to discard unbounded analytic intersection curves that only graze the mutual face region. Logic is conservative.
crates/operations/src/boolean/mod.rs Adds unify_coincident_boundary_edges gated on has_free_edges. Two minor P2 points: midpoint key uses original vertex positions; edges before Wire::new bail-out become orphaned.
crates/operations/src/boolean/tests.rs Adds an #[ignore] regression test documenting the residual loft-faceting limitation. Construction and assertions look correct.

Reviews (3): Last reviewed commit: "fix(boolean): bail on inner-wire rebuild..." | Re-trigger Greptile

Comment thread crates/operations/src/boolean/mod.rs Outdated
Comment thread crates/algo/src/pave_filler/phase_ff.rs Outdated
Comment thread crates/algo/src/pave_filler/phase_ff.rs
… 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.
@andymai

andymai commented Jun 13, 2026

Copy link
Copy Markdown
Owner Author

Addressed the Copilot review in 872af0f:

  • Arc endpoint sampling (phase_ff boundary polygon): now samples 0..=16 so the curve segment's t1 endpoint is included — no artificial closing chord.
  • Inner wires / holes: FaceExtent::Plane now builds hole polygons from the face's inner wires and contains() returns false for points strictly inside a hole (beyond the boundary margin), so a curve through a hole is no longer treated as inside the trimmed face.
  • Drop-threshold wording: clarified the doc + inline comment — b1 - b0 < 2 keeps runs of ≥2 segments (≥3 in-both samples); the text now says "fewer than two segments" rather than "under two samples".
  • changed always-true bug: the unify pass now reuses the original edge when its endpoints didn't move, only allocating (and flagging a change) on a genuine merge / degenerate drop / vertex snap. It's now a true no-op on a free-edge shell that has no coincident duplicates, instead of rebuilding the whole outer shell every time.
  • Silently-dropped error: the unify call now logs at debug level on error (still best-effort, doesn't abort the boolean).

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.
@andymai

andymai commented Jun 13, 2026

Copy link
Copy Markdown
Owner Author

Thanks — addressed the Greptile review:

Issue 1 (inner-wire silent loss) — fixed in 286a09b. unify_coincident_boundary_edges now bails with Ok(false) when an inner wire fails to rebuild, consistent with the outer-wire path, so a hole can't be silently dropped (which would change topology and remove free edges, undetectable downstream).

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 b1 - b0 < 2.

Issue 3 (hardcoded 1e-20 in point_to_polygon_dist) — keeping this one as-is. The 1e-20 is a div-by-zero guard for a degenerate (zero-length) polygon segment, not a tolerance decision. Substituting tol.linear² (≈1e-14) would treat legitimately short ~1e-7-length boundary segments as degenerate points and skip them in the nearest-point search, which is less correct for this purpose. The guard intentionally only excludes truly-zero-length segments.

Test job green on the prior commit; CI re-running on 286a09b.

@andymai andymai merged commit b52fa56 into main Jun 13, 2026
18 checks passed
@andymai andymai deleted the fix/analytic-ff-trimming branch June 13, 2026 23:36
@brepkit brepkit Bot mentioned this pull request Jun 13, 2026
brepkit Bot added a commit that referenced this pull request Jun 13, 2026
🤖 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>
andymai added a commit that referenced this pull request Jun 14, 2026
## 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants