Skip to content

cutMesh: split crossing contours in bad faces under forceFill - #7231

Draft
Grantim wants to merge 14 commits into
masterfrom
agent/mlib-cut-force-crossings
Draft

Grantim wants to merge 14 commits into
masterfrom
agent/mlib-cut-force-crossings

Conversation

@Grantim

@Grantim Grantim commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

What

Under force fill, cutMesh now splits the contours where they cross inside bad faces, using the exact predicates of #7222, when it has the boolean's sorting data. The crossing vertices also go into resultCut. A forced boolean with a self-intersecting mesh now cuts the faces where its contours cross. Before, it kept such a face whole and left the islands in it as separate shells. It now tells the parts of the cut mesh apart by the winding number of the other mesh around them, and runs the graph cut only where the paths are open.

  • CutMeshParameters::forceFill: the enum forceFillMode (ForceFill { None, Good, All }) becomes bool forceFill, where false = None and true = All. Good had no caller. All callers are updated: the boolean's forceCut, offsetSurfaceLineToMeshContours, the tool path smoothing, the Wasm binding and the tests.
  • Finding the crossings: the new PlanarTriangulation::findCrossings runs the sweep of the exact getMonotonePlan over all loops of one cut triangle. Each crossing it meets becomes a point of CutTrianglePredicates, defined by the planes of the two cuts. It returns the crossings in sweep order, which is also their order along every edge. The face fails if an edge without a cut (a side of the face) crosses another edge, or if the predicates fail. The exact getMonotonePlan and findCrossings share the setup of the points and predicates (setLoopPredicates), whose check for points defined alike now also covers crossings of two cuts.
  • Splitting (in cutMesh, under forceFill with sortData):
    • The crossings of all bad faces are found in parallel.
    • Each crossing gets one new vertex shared by both edges, at the middle of their closest points; an edge with several crossings is split in their order.
    • The pieces replace the edge in the paths, so they appear in resultCut, and keep the edge's face and side info.
    • A lone face then gets its monotone chords again, on the split loops, so its islands are connected. A lone face put back after a failed plan is taken out again for this, and put back if nothing changes.
    • The faces with split crossings stay in fbsWithContourIntersections: their contours do cross there.
  • Shared code: the exact point definitions of the lone step (pointOf in connectLoneContours) moved into a struct used by both steps. The new step adds the crossings to it, and the cut of every path edge. The faces the contours enter are collected before the cut, only under forceFill with sortData.
  • Bad faces no longer depend on the walk order: getBadFacesAfterCut flagged a loop only if it came back along the reverse of the edge it started from. A loop that ran another edge both ways was missed if it was walked from an edge whose two sides are in different loops. For example, two contours whose chords cross in a face, where one chord crosses two others, gave one bad face out of two. Now every new edge records the start edge of the walk that passed it, and a walk that meets one of its own new edges again flags the face. Only new edges (contour pieces, chords) can bound one face's hole on both sides in valid loops. This also affects plain cuts: such faces are reported as bad instead of being filled with overlapping triangles. On the regression set this only changes three selfBoolean errors (more nested-intersection faces, see below).
  • Boolean part separation (findMeshPart, forced boolean only):
    • When the contours cross, a part of the cut mesh can be left of one path and right of another, for example inside only one of two overlapping boxes. So can a part between nested boxes: the ring inside a box and outside a smaller box inside it. The left/right split then merges both sides ("Cannot separate").
    • Instead, the winding numbers of the other mesh around the parts are taken from the paths between them (0 outside, 1 inside one box, 2 inside both), and the parts with a positive number are inside. This applies only where some part is on both sides, so every other forced boolean keeps the left/right split.
    • A part reached with two numbers keeps the first one, so the boolean does not fail. That happens around a face whose crossings are not reported bad (see below).
    • The graph cut now runs only when some path is open, as on a mesh with holes, where it completes the separation. With closed paths, the paths alone bound the parts. A part on both sides then reaches the winding numbers whole; the graph cut used to split it along its shortest edges first. The forced boolean is also much faster without it (see the benchmark).

Not covered yet:

  • faces where contours cross but getBadFacesAfterCut reports nothing, because every chord crosses an even number of others (two boxes whose sides cross twice in one face). Nothing splits them, and their fill overlaps; the windings around them can be wrong, so the forced result can be wrong well beyond that face. The plain boolean misses these faces too and returns the overlapping fill without an error;
  • a shell of the other mesh that encloses a whole set of linked parts: the winding numbers are relative, from 0 at the lowest part, as the left/right split of the plain boolean is;
  • a double-sided triangle (two triangles on the same three vertices) of the other mesh crossing an edge: the two crossings of a cut with it are defined alike, so its face is not split;
  • cuts without sortData, which have no exact predicates.

Tests

  • CutMeshForceFillCrossingIslands: two overlapping boxes poke through one triangle of a cube, giving two lone contours that cross twice.
  • CutMeshForceFillCrossingContours: two long boxes across the top of the cube cross four times, twice in each of its triangles.
  • CutMeshForceFillIslandCrossingContour: an island crosses a contour that goes through the face.
  • Each of the three runs with the cube as mesh A and as mesh B, with the sorting data the boolean gives. The forced cut reports the expected bad faces, and its topology is valid without holes. Every cube face is cut into pieces of its own orientation that add up to its area. The paths are closed and follow the contours, and every crossing is a vertex of two paths.
  • BooleanForceCutCrossingContours: the boxes of the first test, with the cube as A and as B.
    • forceCut keeps the top of the cube outside the union of the footprints (inside it for the intersection).
    • forceBoolean gives one closed shell. Master kept that triangle whole, with each box tip as its own shell.

MRTest passes in Release and Debug (605).

Boolean regression set against master ad45339 (no Boolean change since): no regressions. 3093 of 3097 Release rows and 1259 of 1262 Debug rows are bit-identical. The changed rows:

  • selfBoolean errors that count more nested-intersection faces (the order-independent bad-face check): Quantized_20 612 → 772 (Release only, its Debug run is skipped), 12_rnd_spheres 67 → 70, 40_rnd_spheres 563 → 576;
  • the forced Union of spec_SelfIntSec, now without the graph cut: 4 components instead of 43 (the smallest of 84 faces instead of 1), volume 1.44 instead of 3.17. Its mesh A crosses itself too deeply for selfBoolean, so there is no reference.

Contour regression set: bit-identical to master in Release and Debug.

Random check against an exact reference: a cube and 2-3 random overlapping boxes or low-resolution spheres through its top; about 56,000 pairs over six kinds (rotated, on a grid, axis-aligned). The forced Union keeps the cube's surface outside B. The reference is the plain Union of the cube and B's pieces united first.

  • This PR: no errors, and the cube's part matches the reference wherever the bad faces are found. It misses 12-26% of the pairs (under 1% with small boxes). All but 5 of the misses have a face with crossings that is not reported bad.
  • Master: also no errors, but it is wrong on every pair with bad faces, since they are not split: 63% of the first kind of pairs.

Benchmark

3 alternating rounds per comparison, no forced run dropped as disturbed; every MRTest was opted out of Windows power throttling, which otherwise halves the speed while the launching window is in the background.

Plain boolean against master 7291b95 (the last commit does not change it: within noise against the previous head, 3366 spheres -0.2%):

case operation master, ms this PR, ms
dental Union 19.12 19.31 +1.0%
dental Intersection 18.54 18.46 -0.4%
dental DifferenceAB 19.48 19.61 +0.6%
dental Union, B rotated 19.61 19.59 -0.1%
nef Union 124.75 126.39 +1.3%
nef Intersection 108.29 108.36 +0.1%
nef DifferenceAB 107.86 108.57 +0.7%
nef Union, B rotated 126.61 126.47 -0.1%
nef-copy-noise Union 185.03 182.96 -1.1%

3366 spheres (DifferenceAB, 2000 rotations): AVG 11.024 → 11.031 ms (+0.1%), MEDIAN 10.848 → 10.864.

Forced boolean (forceCut), the last commit against the previous head of this PR. That head was within noise of master here: big cases -1.3..+2.9%, spheres +1.4%, since both ran the graph cut.

case operation before, ms this PR, ms
dental Union 60.04 20.25 -66.3%
dental Intersection 59.06 18.88 -68.0%
dental DifferenceAB 59.56 20.18 -66.1%
dental Union, B rotated 60.17 20.77 -65.5%
nef Union 457.25 131.61 -71.2%
nef Intersection 436.69 113.41 -74.0%
nef DifferenceAB 437.83 113.43 -74.1%
nef Union, B rotated 459.41 131.81 -71.3%
nef-copy-noise Union 582.87 199.40 -65.8%

3366 spheres (DifferenceAB, 2000 rotations): AVG 19.806 → 14.088 ms (-28.9%), MEDIAN 19.570 → 13.873.

Generated with Claude Code

Grantim and others added 14 commits October 22, 2026 18:05
CutMeshParameters::forceFillMode (enum ForceFill) becomes bool forceFill.
With the boolean's sorting data, the forced cut finds where the contours
cross in bad faces on the exact predicates (PlanarTriangulation::
findCrossings), splits both edges at one new vertex, puts the pieces in
resultCut, and connects the islands of lone contours left there.
getBadFacesAfterCut checks every edge of a loop, not only the one it
starts from. The forced boolean tells the parts of a mesh cut along
crossing contours by the winding numbers of the other mesh around them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ew edges

The second bitset of the current loop, the loop vector and the clearing pass
cost +34% of the function (spheres benchmark); the walk number of every new
edge costs +10% and finds the same faces where the loops are valid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
makeFaceHoles collects the path edges inside the faces, removes the faces
that no other contour removed and finds their hole loops, for both
connectLoneContours and splitContourCrossings; the cut triangles of the
path edges are found once there instead of keeping their contour
segments. splitCrossings adds its points by Mesh::addPoint, insertPieces
inserts the pieces at once, shorter comments. Same results.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ExactCutPoints::crossings holds the PreciseCutPoint of a split crossing,
made from the cut triangles of its two edges, instead of the two triangle
ids that point() converted on every call. Same results.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…iece loop

Same results.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… point

Same results; makeBox comment names hx.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…th sides

findCutFaces marks the faces in a FaceBitSet instead of sorting every
contour face; windingsAroundParts returns at once unless a part is to the
left of a path and to the right of another one, as a number above 1 needs.
Same results; the forced 3366 spheres went from +12.4% to +1.7% of master.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…orter helpers

- cutMesh: split crossings only under forceFill again (no second scan of the bad faces)
- ExactCutPoints takes sortData by pointer and is passed by reference; point() as on master
- addMonotoneChords keeps master's names; makeFaceHoles sizes its result at once
- splitCrossings: one splice pair moves an edge off its origin; ring from the crossing
- findCutFaces: one loop for both modes, both sides of an edge, shorter CutFace
- windingsAroundParts -> uniteByWindings (no dead aboveOne/maxW); findMeshPart one if
- cutOf via getAt, crossing key by std::minmax, shorter comments and test

Same results.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…Ring, setLoopPredicates

walkOf keeps for every new undirected edge the start edge of the last walk
that met it, and the face is marked where a loop passes an edge both ways;
holeLoops takes the rings from MeshTopology::getLeftRing; addLoopPoints and
loopPointsPredicates become one setLoopPredicates. Same results and speed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…crossing split

- addMonotoneChords adds the faces where it fails to badFaces and puts them back
  as master's connectLoneContours did; the island pass passes its own removedHere
- splitContourCrossings filters the moved contour faces in place, runs the
  crossings with a running vertex id
- findMeshPart keeps master's comment; shorter doc comments
- the new tests sit in master's anonymous namespace and after its square tests;
  topArea by the triangle centers

Same results.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The graph cut of the forced separation now runs only when a cut path is open, as on a mesh with holes.
With closed paths the paths alone bound the parts, so a part on both sides of them reaches the winding numbers
whole: for example the ring between a box and a smaller box inside it, which the graph cut used to split along
its shortest edges. A part reached with two numbers, as through a face whose crossings are not reported bad,
keeps the first one instead of failing the boolean with "Cannot separate".

Without the graph cut, the forced boolean is 66-74% faster on the big cases and 29% faster on 3366 spheres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only a loop passing an edge both ways can have a bad face, and that is a property of the loop, not of the walk's
start edge. So all loops are first walked in parallel, each thread walking a loop once; the serial walks, as before,
start only from the path edges of the loops that pass an edge twice, in the same order, so they give the same faces.
In a valid cut there are none. A loop of up to 32 edges is checked by a 256-bit mask of the edge ids met, a longer one
by the number of the walk kept per new edge.

The function alone is 2.6-10x faster on all threads and 0.85-1.03x on one; the results of the boolean test cases and
the contour test cases are bit-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…arts

uniteByWindings numbered every set of parts linked by the paths from 0 at its lowest part, which is right only if that part
is outside the other mesh. A part below the outside (an inside-out shell, a zero-volume shell or double-sided triangle that
the perturbation turns inside out, an inside-out pocket of a self-crossing shell, or conflicting links) moved the outside
to 1 and flipped the whole cut mesh; and a shell of the other mesh enclosing the linked parts without crossing them made
them all outside.

Now every set is numbered from its part with the most path edges among those with a vertex not on the paths (an original
vertex), whose number is the exact winding number of the other mesh there: the signed count of the other mesh's triangles
that a segment from the vertex straight up crosses, by doTriangleSegmentIntersect with the cut's int coordinates and its
vertex id order, so the simulation of simplicity agrees with the paths. The other mesh's AABB boxes are transformed by
rigidB2A and compared in unrounded int units with a margin, since a transformed box can exceed the int range. The old
numbering stays where the other mesh is open, without converters, or where no part of a set has such a vertex.

New test BooleanForceCutExactWindings: a box enclosing the cube, and an inside-out box crossing another box, both with and
without rigidB2A; it fails before this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This reverts commit fd7078c: the parallel getBadFacesAfterCut moves to its own pull request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant