Skip to content

Boolean: connect lone contours by monotone chords instead of subdividing their faces - #7209

Open
Grantim wants to merge 27 commits into
masterfrom
agent/mlib-boolean-lone-monotone
Open

Grantim wants to merge 27 commits into
masterfrom
agent/mlib-boolean-lone-monotone

Conversation

@Grantim

@Grantim Grantim commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

A lone contour lies inside one face and crosses none of its edges, as where a tip of the other mesh pokes through a big triangle. Until now, booleanImpl subdivided the faces of such contours, updated the AABB tree, found the collisions again and repeated, up to 100 times (selfBoolean: 3), and removed the contours that never resolved. This PR replaces that loop: cutMesh connects every lone contour with the rest of its face by new edges, so there is one collision pass, no subdivision and no tree update.

What

  • cutMesh: before the cut, it notes the faces of lone contours. After the edges are cut, it takes all hole loops of each such face (the parts of the face between the contours that cross its sides, and both sides of every island or slit of its lone contours). It then adds the chords that split them into monotone parts, and the usual hole filling triangulates them. The faces are planned in parallel and changed sequentially. A face that cannot be planned goes to fbsWithContourIntersections and is put back as it was, so filling the bad faces leaves no hole along its sides. So does a face whose plan has no chord, as where an open contour ending on its side made the cut of that side fill the face again first: its island cannot be connected.
  • PlanarTriangulation::getMonotonePlan: a new overload for a triangle cut by the boolean (not bound, as PreciseCutPoint is not). It takes the triangle and the exact definition of every loop vertex, and runs the sweep on CutTrianglePredicates (Exact predicates for the planar sweep inside cut triangles #7165), which never contradict the boolean's predicates. Every loop vertex is added to the predicates before the sweep, and the plan is std::nullopt if a definition is invalid or two vertices are defined alike, so the sweep never runs a predicate on invalid points. Float predicates are not enough here: on nda/case_00 they failed on 206 of 218 lone faces, because contour points coincide with face corners in float.
  • Without sortData (callers other than the boolean), the plan runs on the float predicates, oriented by the normal of the face from its corners in doubles (free of the rounding of the islands' points), normalized before the conversion to float. The header declares the exact overload with forward declarations, so it includes nothing new.
  • The sweep: loops of 2 edges (a one-edge slit taken both ways) are swept only for getMonotonePlan (SweepLineParams::allowTwoEdgeLoops), so open lone contours are connected too instead of being dropped; fillContours2D, fillContours2DPlan and triangulateDisjointContours keep the minimum of 3 edges. With abortWhenIntersect, it stops at the first crossing it detects, without computing the crossing point. This is stricter than master for triangulateDisjointContours, fillContours2D and fillContours2DPlan: master could drop a pending crossing whose rounded point sorted after the end of one of its edges while another pending crossing came first, and then succeed with crossing edges; such runs now fail, and their callers fall back. None of our regression cases hits it. findClosestToFront falls through to a valid index, which only inconsistent predicates (CutTrianglePredicates after a failure, whose run is discarded) can reach.
  • cutMesh: one helper, preciseVert, gives the exact coordinates of a vertex of mesh A or B, for compareEdgeTrisIntersections (bit-identical sorting) and the point definitions of lone faces.
  • cutMesh: the faces and edges filled on the right of a contour running along an existing edge map (new2OldMap, new2oldEdgesMap) to that edge's right face, not to its left one, when the right face is removed afterwards by another contour or by the connection of a lone contour. This was already wrong on master for a contour crossing the right face; only the maps change, not the cut.
  • booleanImpl: one collision pass; no subdivision, mesh copies for sorting or remaps of subdivided faces; the progress callback gets the whole range. Closed contours of two points are dropped before either mesh is cut, as master dropped them: such a contour (two triangles with the same vertices whose two shared edges cross one triangle of the other mesh) has zero area, since its two segments coincide.
  • selfBoolean: one pass; it sorts on the input mesh instead of a copy, and drops closed contours of two points with both contours of their pair.
  • BooleanParameters::outPreCutA / outPreCutB return the meshes and contours without subdividing the faces of lone contours, together with the boolean's data for its exact cut: BooleanPreCutResult also holds the ordered intersections, the float-to-int converter, a copy of rigidB2A, the vertex count of mesh A and whether the mesh is A, and sortData( otherMesh ) gives the SortIntersectionsData for cutMesh( mesh, contours ). The outputs now take the meshes after both cuts: before, for InsideB/OutsideB (InsideA/OutsideA) the cut of the other mesh sorted by a mesh already moved into the output, or raced with the task still reading it.
  • embedStructureToTerrain cuts the structure from outPreCutB with that sorting data, so its lone contours are connected on the exact predicates. It also no longer reads one past the end of filtBowTiesMap for the last section of every closed wall contour (on master too; found by the iterator-debug CI job through the new test).

Tests

  • MRMesh.BooleanLoneContours: a cube with a small sphere poking through one of its big triangles, alone, with another sphere crossing the edges of the same triangle, and with a torus cutting the triangle along two nested circles around a small sphere; Union, Intersection and DifferenceAB, both orders, with and without a rotating rigidB2A. It checks the volumes, that there are no holes, the number of components (an island left in its triangle would make one more) and, through a mapper, that the new faces inside the lone triangle map back to it. It fails without this change.
  • MRMesh.SelfBooleanLoneContour: selfBoolean of a cube with a small sphere piercing one of its triangles; the result is valid, closed and keeps the sum of the volumes.
  • MRMesh.BooleanTwoPointLoneContour: the tip of makeMeshWithMultipleEdge (two triangles with the same vertices) through a side of a cube, booleans and selfBoolean, give valid closed meshes.
  • MRMesh.BooleanMultipleEdgeThroughLoneFace: a multiple edge crossing a lone face; a valid result or the bad face reported, without asserts.
  • MRMesh.BooleanVertexOnFace: a corner of a small cube, or a vertex of a small sphere, exactly on a big triangle of a cube, both orders: valid closed results with the volumes of meshes touching at a point.
  • MRMesh.BooleanPreCutOfUncutMesh: outPreCutB with InsideA and outPreCutA with InsideB cut the cube as without the outputs (crashed before).
  • MRMesh.BooleanPreCutExactCut: the output of A, of B and of both at once, with and without a rotated rigidB2A: cutMesh of it with sortData( the other mesh ) gives the boolean's own cut.
  • MRMesh.EmbedStructureLoneContour: a terrain peak through a big triangle of the structure; far from the origin its contour points coincide in float, where the float cut connected the island inside out and the embedding failed.
  • MRMesh.CutMeshLoneContour: cutMesh without sortData on closed contours inside one triangle, their open versions and one-segment slits, 200 random cases (taken straight from std::mt19937, the same on every platform) near the origin and at 1e5 from it. It checks that no face is reported bad, the topology is valid, the hole count and the area are kept, the cut path, and that every new edge, the chords among them, maps to the triangle in new2oldEdgesMap. On master every closed case loses a part of the face.
  • MRMesh.CutMeshLoneSlitExact: an open lone contour cut with the boolean's sortData, the patch as mesh A and as mesh B.
  • MRMesh.CutMeshLoneContourWithCrossingContour: an island in a triangle that another contour crosses, on the float path: connected, the area and the maps kept.
  • MRMesh.CutMeshLoneContourInRefilledFace: an island in a triangle and an open contour ending on its side: the triangle is reported (before: a silent success with the island as a separate double-sided disk).
  • MRMesh.CutMeshContourAlongSideMaps: a contour along a side of a triangle with an island or another contour in it: every new face and edge maps to its triangle (before: to the neighbor when the contour along the side came first).
  • MRMesh.CutMeshSelfCrossingLoneContour: a lone contour crossing itself; its triangle is reported bad and stays in the mesh, with ForceFill::None, Good and All.
  • MRMesh.PlanarTriangulationMonotonePlanSameDefinitions: the exact getMonotonePlan rejects loops with two vertices defined alike and invalid definitions.

MRTest passes in Release and Debug on Windows.

On our local Boolean regression set (3097 Release and 1262 Debug results, against master 29d5815; unchanged by the later commits):

  • Every case keeps its status (ok, or the expected error). The harness's own checks pass: valid topology, closed results for closed inputs, and the volume identities.
  • Debug has no asserts, and every Debug result equals its Release one.
  • 1357 Release results differ from master's, since the faces of lone contours are triangulated differently. Face counts change. Volumes of closed results change by at most 4.2e-6 relative, and by at most 1.5e-9 absolute in the 3366-sphere runs (relative 2.6e-4 for rotation 899, whose result is tiny). Parts that are not closed (InsideB/OutsideB) change by up to 8e-3.
  • 6 results have more components, all Intersection or Union: nda/case_00, 03, 06 and 09 (e.g. 245 -> 403). They are tips of one mesh poking through the other by about 3e-7, below the float precision of the coordinates. The exact chords keep them. Master's subdivision put its new vertex at the float centroid of the face, off the face's plane, and lost them.
  • The selfBoolean of a quantized input with triple overlaps fails on both with "nested intersections", on 612 faces instead of 611.

On our local contour cut set and its border sweep, every result equals master's (c3ac207).

Benchmark

Measured at 329a164. Alternating runs of Release builds of master 29d5815 and this PR, 4 rounds (the first master round dropped as disturbed); best of 10 per operation, in ms:

case operation master this PR change
dental A-B 25.45 19.92 -21.7%
dental intersection 24.98 18.62 -25.5%
dental union 24.69 19.53 -20.9%
dental union, B moved 19.91 20.09 +0.9%
nef A-B 111.86 111.51 -0.3%
nef intersection 113.29 113.53 +0.2%
nef union 130.59 129.60 -0.8%
nef union, B moved 131.61 131.26 -0.3%
nef-copy-noise (many lone contours) union 232.37 190.71 -17.9%

3366 spheres (MeshInspector's Boolean benchmark defaults, 2000 rotations), ms per boolean (AVG and MEDIAN averaged over the rounds, MIN and MAX over all runs), in the same session and in a second one of spheres only (3 undisturbed rounds each):

AVG MEDIAN MIN MAX
master 11.935 11.988 2.812 21.278
this PR 11.600 (-2.8%) 11.399 2.527 23.109
master, second session 11.910 11.930 2.969 20.718
this PR, second session 11.408 (-4.2%) 11.230 2.603 21.441

The PR's round averages ranged from 11.39 to 11.82 ms over both sessions, master's from 11.87 to 11.97 ms. The runs without lone contours (nef, dental with B moved) stay within ±0.9%: the new code costs nothing where there is nothing to connect.

Known limits

  • Without sortData, a lone contour whose points coincide in float can be connected inside out without being reported; only the boolean and the terrain embedding pass sortData.
  • Without sortData, if an open contour ends on a side of a lone face, cutOneEdge re-triangulates that face first, so its island cannot be connected; the face is reported in fbsWithContourIntersections (master left it unconnected silently).

Open questions

  • detectLoneContours, removeLoneContours, removeLoneDegeneratedContours and updateCollidingEdgeTrisPrecise are now used only by tests. Remove them here or in a follow-up? subdivideLoneContours is still used by embedStructureToTerrain for the terrain's own cut.

Generated with Claude Code

Grantim and others added 27 commits October 20, 2026 19:34
…ead of subdividing the faces

A contour inside one face that crosses none of its edges (a lone contour) used to make
booleanImpl and selfBoolean subdivide that face, update the AABB tree, find the collisions
again and repeat, up to 100 times, dropping the contours that never resolved.

Now cutMesh connects them: before the cut it notes the faces of lone contours, and after
cutting the edges it takes all hole loops of each such face (the face boundary and the islands
or slits of its lone contours) and adds the chords that split the hole into monotone parts.
The chords come from the sweep of PlanarTriangulation::getMonotonePlan on CutTrianglePredicates,
which never contradict the boolean's exact predicates, so the plans hold where float
predicates failed. The faces are planned in parallel and filled sequentially; a face that
cannot be planned goes to fbsWithContourIntersections.

- getMonotonePlan: a new overload for a cut triangle with exact point definitions; loops of
  2 edges (a one-edge slit taken both ways) are accepted; with abortWhenIntersect the sweep
  stops at the first crossing it detects, without computing the crossing point.
- booleanImpl: one collision pass; no subdivision, mesh copies for sorting or face map remaps.
- selfBoolean: one pass, sorting on the input mesh.

Test: MRMesh.BooleanLoneContours.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts corners, and a test

The normal summed over the hole loops took in the islands, whose float points add rounding: in a
numeric check, far from the origin with an island filling most of the face, it picked another
projection axis in up to 40% of the trials and flipped the orientation in up to 3%. From the
corners in doubles, every component of the face normal has the exact sign.

Test MRMesh.CutMeshLoneContour: closed contours inside one triangle, near and far from the origin,
filling half or most of it; cutMesh without sortData must connect and fill them. It fails on master,
which loses a part of the face in every trial.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The exact coordinates of a vertex of the mesh being cut (its id shifted by meshAVertsNum and
rigidB2A applied in float if it is mesh B) were built in compareEdgeTrisIntersections and again in
connectLoneContours; now both take them from one helper, the same float transform before the
converter, so the sorting stays bit-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sweep on mesh loops skipped the loops of fewer than 3 edges, which the previous commits
took in for every caller, so that fillContours2D, fillContours2DPlan and triangulateDisjointContours
met them too (a digon of two distinct edges failed the whole run as a multiple edge). Only
getMonotonePlan needs them, for the slits of open lone contours: SweepLineParams::allowTwoEdgeLoops,
set by getMonotonePlan alone, keeps master's behavior for the other callers, and master's
capacity hints.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
connectLoneContours removes a lone face that no other contour cut before planning its chords;
if the plan failed, the face only went to fbsWithContourIntersections and stayed removed, while
cutMesh finds the holes to fill only through the cut paths, and the old sides of the face have
none: with ForceFill::All or Good the mesh kept a triangle-shaped hole there. Now such a face is
put back (no chord of it was added, so its old sides still make its ring).

Test MRMesh.CutMeshSelfCrossingLoneContour: a bowtie contour inside one triangle of a cube; the
float sweep stops at its crossing, so the triangle is bad and must stay, without a hole along
its sides, with ForceFill::None and All. It fails without the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…alike are rejected before the sweep

Two loop vertices with one definition (e.g. both edges of a multiple edge of the other mesh, or
two triangles with the same vertices, crossing a lone face) make CutTrianglePredicates fail inside
a predicate; the sweep then went on with arbitrary answers, where findClosestToFront could fall
through to its last return and an index of -1 reached rightGoingCache_ (guarded by an assert only),
and the perturbation expanded for the coincident points took 0.5 s (Release) to 23 s (Debug) for
six points. Now the definitions of the distinct loop vertices are found once, sorted by small id
keys (a vertex by its id, a crossing by the ids of its segment and triangle in any order), and
a repeated key fails the plan before the sweep, which takes the definitions from that list.

Tests: MRMesh.PlanarTriangulationMonotonePlanSameDefinitions (an island vertex defined as another,
also with the ends and the triangle in another order), MRMesh.BooleanMultipleEdgeThroughLoneFace
(the boolean reports the bad face, without asserting). Both also passed without the check, only
slower, since the final failed() check discarded the plans.

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

- connectLoneContours without sortData: the normal of the face is normalized in doubles before the
  conversion to float, which could flush it to zero or overflow at extreme coordinate scales.
- booleanImpl: params.cb gets the whole range; the 0.8 offset was left from the removed loop over
  lone contours, so the progress jumped from 0 to 0.8.
- getMonotonePlan on exact predicates: a comment on the predicates it leaves empty, which the sweep
  on loops never calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ndom cases on every platform

- MRMesh.BooleanLoneContours: also the small sphere alone, so that its triangle of the cube is cut
  by the lone contour only (removed by connectLoneContours, its corners as vertex points); before,
  the crossing sphere always cut the same triangle too.
- MRMesh.SelfBooleanLoneContour: selfBoolean of a cube with a small sphere piercing one of its
  triangles, added as one mesh; the result is valid, closed, and keeps the sum of their volumes.
- MRMesh.CutMeshLoneContour: uniform values straight from std::mt19937, whose output the standard
  specifies, unlike that of std::normal_distribution, taken in a fixed order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
preciseOtherVert and preciseOwnVert differed only in the mesh and in which of the two gets the
id shift: now preciseVert takes the mesh and whether it is A (the ids of B shifted by
meshAVertsNum, its points moved by rigidB2A in float before the converter), preciseOtherVert
calls it for the other mesh, and compareEdgeTrisIntersections and connectLoneContours call it
for the mesh being cut, so the sorting stays bit-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The flag skipped no loop shorter than 3 edges, so it took in a loop of one edge too, which is no
slit; now with it the loops of 2 or more edges are swept, and the shorter ones skipped as before.

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

The points went to CutTrianglePredicates::addPoint from the sweep's addInputPoint, after the
sweep had ordered some of them, and a failed addPoint was stopped by a Debug assert only, while
the contract allows no predicate after it (in Release less could be cyclic in the parallel sort,
or findClosestToFront fall through to its last return). Now the distinct loop vertices are added
right after reset, under their indices in the sorted vertex list, the plan fails at once if
an addPoint failed (as for a vertex point off the corners or a segment not crossing the plane of
its triangle, which pointOf's fallbacks can give), and the sweep's predicates translate its
vertex ids to these indices; the final failed() check stays for what only a predicate finds.

- The static template getMonotonePlan is getMonotonePlanImpl, so that a call in the .cpp cannot
  pick it instead of a public overload.
- MR2DContoursTriangulation.h declares PreciseVertCoords and PreciseCutPoint instead of including
  MRCutTrianglePredicates.h and <functional>.

Test: MRMesh.PlanarTriangulationMonotonePlanSameDefinitions also with an island vertex defined as
a vertex point off the corners and as a segment not reaching the plane; without the fix both
fire the assert in Debug.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Where two triangles with the same vertices (as at a tip of makeMeshWithMultipleEdge) have both
edges that cross one triangle of the other mesh in common, the contour is closed with two points.
Such a contour is always lone, as both points are crossings of edges of one mesh with one
triangle of the other (in a contour of both kinds, the two segments would be on both sides of
one edge), and its two segments coincide, so it has zero area. The cut made it a digon of two
edges between the same two vertices in the hole of that triangle, the sweep failed on it as
a multiple edge, and the boolean returned "Bad contour" (selfBoolean: "nested intersections"),
while master dropped such contours and succeeded. Now booleanImpl and selfBoolean drop them right
after ordering the contours, before anything is cut, so both meshes stay consistent; in
selfBoolean the two contours of a pair have the same points, so both go together.

Test: MRMesh.BooleanTwoPointLoneContour (the tip of makeMeshWithMultipleEdge through a side of
a cube, both orders, three operations, and selfBoolean of both as one mesh: valid closed
results); without the fix every boolean reports a bad face and selfBoolean nested intersections.

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

- connectLoneContours fills vertInters only with sortData, as only the exact definitions of
  the points (pointOf) take it.
- The comment on the normal of the float path says what holds: the corners in doubles give
  a normal free of the islands' rounding, of which only the dominant component and its sign
  matter (the products are rounded too, so not every component has the exact sign).
- Mesh::updateCachesAfterSplits no longer refers to the boolean's lone-contour iterations,
  which are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- MRMesh.CutMeshLoneContour: in every trial also the same contour without its last segment
  and a slit of one segment (a hole loop of 2 edges, for which the sweep takes loops of 2 edges),
  with the cut path checked along the contour in resultCut.
- MRMesh.CutMeshSelfCrossingLoneContour: also ForceFill::Good, which fills no hole of the bad
  triangle, so its two sides of the island stay open as with None.

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

createCutStructure_ took the pre-cut structure from boolean( ..., outPreCutB ) and cut it with cutMesh without sortData.
The boolean no longer subdivides the faces of lone contours, so such contours reached that cut and were connected on float
predicates: when their points round together, the island is connected inside out, cutMesh reports success and the embedding
fails later. Now the function runs the boolean's steps before the cut itself and passes cutMesh the same sortData as booleanImpl,
dropping closed contours of two points as the boolean does. BooleanPreCutResult says that its contours can include lone ones.

Test: MRMesh.EmbedStructureLoneContour (fails on the float cut with "Cannot embed structure beyond terrain").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The loop of findClosestToFront always returns with consistent predicates, but CutTrianglePredicates
after a failure found inside a predicate answers arbitrarily (its run is discarded afterwards), and
then the loop could fall through to `return 0`: updateStartRightGoingCache_ made it position -1,
reading rightGoingCache_[-1] and rotating from begin() - 1, and findCCWPrev returned the lone edge
itself, which splice then skipped, leaving the edge without an origin. Now it returns 1, the first
candidate, valid for every caller; the assert stays, and the float predicates never get there.

No test: the failures a predicate finds need points that the checks of getMonotonePlan let through
(two points defined alike are rejected before the sweep, three crossings on one cut line need
input that no valid cut gives), and no small input made one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Where the operation does not cut one mesh (A for InsideB and OutsideB, B for InsideA and OutsideA),
the sorting data of the other mesh's cut refers to that mesh itself, not to a copy. booleanImpl moved
meshA into outPreCutA before cutting B, so B's cut read a moved-from mesh, and moved meshB into
outPreCutB on the main thread while the task cutting A could still read it. Now both outputs are
filled right after the cuts end, before the bad-contour checks, so they stay filled as before when
a cut fails. Pre-existing on master; the cut of a lone contour reads the other mesh for every lone face.

Test: MRMesh.BooleanPreCutOfUncutMesh (a cube with a lone contour of a small sphere; InsideA with
outPreCutB and InsideB with outPreCutA cut the cube as the operations without the outputs do, and
the outputs hold the uncut sphere); without the fix both crash with an access violation in Release,
reading the sphere's points in connectLoneContours.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- MRMesh.BooleanLoneContours: rigidB2A rotates about a generic axis besides translating, so that
  the float transform of B's points in the cut's exact definitions must match the one of
  findCollidingEdgeTrisPrecise; a third configuration has a torus lying in the plane of the lone
  triangle, cutting it along two nested circles, with a small sphere in its hole (three islands
  in one face, two of them nested); every result has a mapper, which must map every face to a valid
  face of one input, the faces strictly inside the lone triangle to it, and those of the triangle
  inside it.
- MRMesh.CutMeshLoneSlitExact: a small square pierces a big triangle of a cube, which is cut along
  an open lone contour of three points on one line with the sorting data of the boolean's steps
  before its cut (as in the terrain embedding), with the square as mesh A and as mesh B: no bad
  faces, no holes, the same area, one cut path of two edges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The float overload said its loops bound one region, while connectLoneContours passes the loops of all
the holes of one old face: the parts split by the contours that cross its sides, and both sides of
every island. The doc now says that such holes work as one region, since an edge taken both ways adds
zero winding, and that a loop of one edge is skipped; the exact overload says it takes the loops of all
the holes of the triangle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The EXPECT_* macros expand to an if/else, so one directly under an if without braces fails the GCC
builds with -Werror=dangling-else, as in the mapper check of MRMesh.BooleanLoneContours; the macros
under the if and else of MRMesh.BooleanMultipleEdgeThroughLoneFace are braced too.

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

The connector that prepareTerrainCut passes surfaceLineToMeshContour maps the indices of the section
ends in cont (the points of a contour without bow ties, the first one repeated at the end) through
filtBowTiesMap, which has no entry for the repeated point, so the last section of every closed
contour read one past the end of the map: an out-of-range std::vector access (the assert of the
iterator-debug build), otherwise an arbitrary index for the plane the section is tracked in. Now that
end index wraps to the first point. Pre-existing on master; MRMesh.EmbedStructureLoneContour is the
first test that runs embedStructureToTerrain, and reaches it in both of its cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every island or slit inside a face needs a chord to the rest of the face, so a valid plan of a lone
face always has one. A plan without any came where the face was gone before connectLoneContours: an
open contour from a neighbor face ending on a side of the lone face makes cutOneEdge fill the face
again with that end as a new corner, so only the two sides of the island were planned, with zero
winding and no chords; the hole filling then put two overlapping disks of opposite orientation on
them, a separate component, and the cut reported nothing. Now such a face goes to the bad faces.
On the boolean's contours (no contour ends on a side of a face with another face beyond it) and
wherever the chords are found, nothing changes.

Test: MRMesh.CutMeshLoneContourInRefilledFace (an island in one triangle of a square of two, an open
contour from the other triangle to the middle of their common side, both orders): the triangle is
reported; before, the cut succeeded with two components and the area 104 of 100.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…at edge's right face

A path edge along an edge of the old mesh (between two contour points in vertices) removes only the
face on its left, which removedFaces records, while the face on its right can be removed later: by
another contour crossing it, or by connectLoneContours when it holds a lone contour. The hole on the
right was then found from that path edge with the face on its left, so new2OldMap and new2oldEdgesMap
took the faces and edges filled in the right face to the left one, whenever that contour came before
the others in the face. Now doPreCutMesh records the right face of such an edge, and the hole on its
right gets it. Pre-existing on master for a contour crossing the right face; the lone contours made
it reachable with an island in the right face. Only the maps change, not the cut.

Test: MRMesh.CutMeshContourAlongSideMaps (a square of two triangles, a contour along the diagonal in
both directions with an island in one triangle or a contour cutting off its corner, both orders: valid
cut, no bad faces, one component, the area kept, every new face and edge mapped to its triangle);
without the fix the faces and edges right of the diagonal map to the other triangle when the contour
from 0_v to 2_v comes first, with the island and with the corner contour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tex on a face

- MRMesh.CutMeshLoneContour passes new2oldEdgesMap and checks that every new edge, the chords
  connecting the contour with the sides among them, maps to the triangle, as surface offset needs;
  its comment says what holds across platforms: the generator's values are the same, the points
  made of them with std::cos and std::sin can differ in their last bits.
- MRMesh.CutMeshLoneContourWithCrossingContour: on the float path, an island in one triangle of
  a square of two with a contour crossing that triangle's sides, both orders: valid cut, no bad
  faces, one component, the area kept, every new face and edge mapped to its triangle.
- MRMesh.BooleanVertexOnFace: a corner of a small cube, or the lowest vertex of a small sphere,
  exactly on a big triangle of a cube, both orders, Union, Intersection and DifferenceAB: valid
  closed results with the volumes of meshes touching at a point, whether simulation of simplicity
  makes a lone contour of points that are all the same in float (the big cube as mesh A) or no
  contact (as mesh B).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
BooleanPreCutResult held only the mesh and its contours, so a caller cutting it could not pass cutMesh
the boolean's sorting data, without which the lone contours are connected on float predicates. Now it
also holds the boolean's ordered intersections (after the drop of closed contours of two points), its
float-to-int converter, a copy of rigidB2A, the vertex count of mesh A before any cut, and whether the
mesh is A; sortData( otherMesh ) makes the SortIntersectionsData of the boolean's own cut of the mesh.
booleanImpl fills them with the meshes after the cuts: the intersections are copied into the output of
A and moved into the last output, as nothing reads them afterwards. The vertex count of A is taken
once before the cuts and also given to the sorting data of both cuts, the same value as before.

Test: MRMesh.BooleanPreCutExactCut (a cube with a lone contour of a small sphere, Intersection, the
cube as A and as B, without and with a rotated rigidB2A, the output of A, of B, and of both at once):
each output holds its uncut mesh and the boolean's data, and cutMesh of it with sortData( the other
mesh ) gives the boolean's own cut2origin without bad faces.

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

createCutStructure_ repeated the boolean's steps before its cut to get the exact sorting data. Now it
takes the precut structure from boolean( terrain, structure, InsideB ) with outPreCutB again, as on
master, and cuts it with sortData( terrain ) of that output, so the boolean's own intersections,
converter and the drop of closed contours of two points are used.

Test: MRMesh.EmbedStructureLoneContour (fails on the float cut).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Grantim
Grantim marked this pull request as ready for review October 21, 2026 09:38

@Fedr Fedr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Two notes outside the diff:

  • MREmbedTerrainStructure.cpp:360 (prepareTerrainCut): the terrain still subdivides its lone contours with subdivideLoneContours and retries, failing with "Cannot resolve lone cut on terrain", although cutMesh in cutTerrain now connects lone contours itself. The boolean dropped this mechanism, so the terrain could too, and then there would be one way of handling lone contours instead of two.
  • MRIntersectionContour.cpp:315: after this PR, detectLoneContours, removeLoneContours, removeLoneDegeneratedContours and updateCollidingEdgeTrisPrecise have no callers in MeshLib, and subdivideLoneContours is used only by the terrain loop above. Should they be deprecated or removed, or should their docs at least say that cutMesh now handles lone contours?

// find and order intersections
contours = orderIntersectionContours( meshA.topology, meshB.topology,
findCollidingEdgeTrisPrecise( meshA, meshB, converters.toInt, params.rigidB2A ) );
removeTwoPointLoops( contours );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Master dropped the lone contours it could not resolve (removeLoneContours after the iteration limit, removeLoneDegeneratedContours) and the boolean succeeded. Here, a lone face whose exact plan fails becomes a bad contour face, so boolean() without forceCut (and selfBoolean()) returns an error on the same input. Examples: a multiple edge through a lone face, two loop vertices defined alike, or three crossings of one cut on one line. With forceCut (ForceFill::All), the face is put back, and the island is also filled on both sides, overlapping it.

BooleanMultipleEdgeThroughLoneFace accepts "a valid result or the bad face reported", so it would not catch this regression. Should a face whose plan fails drop its lone contours, as master did, instead of failing the whole boolean?

// 1 is a valid index for every caller, while 0 is the queried edge itself (a position of -1 for the start vertex)
assert( false );
return 0;
return 1;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The comment says that inconsistent CutTrianglePredicates (a failure found during the sweep) get here and the run is discarded. Yet assert( false ) stays, so Debug, iterator-debug and sanitizer builds abort on input that Release handles. One example is the case named in getMonotonePlan: three crossings of one cut on one line, which the addPoint checks miss.

That comment also says the sweep "goes on with arbitrary answers that can break it", so other sweep asserts and invariants can fire too. Could the sweep stop as soon as preds.failed() is set, or could this return a failure that the caller checks, instead of an index chosen to be in range?

// normalized before the conversion to float, which could flush it to zero or overflow at extreme scales
const auto& p = mesh.points;
const auto normal = dirDblArea( Triangle3d{ Vector3d( p[lf.verts[0]] ), Vector3d( p[lf.verts[1]] ), Vector3d( p[lf.verts[2]] ) } );
plans[i] = PlanarTriangulation::getMonotonePlan( mesh, loops, Vector3f( normal.normalized() ) );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

On the float path (no sortData), a lone contour in a zero-area face gives dirDblArea == 0. normalized() then returns (0,0,0), and getMonotonePlan sweeps on a meaningless projection: the plan fails (where master left the island) or adds chords picked on a collapsed plane. A needle or degenerate triangle passed to cutMeshByContours can hit this. Fall back to a normal summed over the loops when the corner normal is zero, or report the face up front?

if ( bSubdivided || !intParams.originalMeshB )
if ( !intParams.originalMeshB )
{
Timer t( "meshBCopyBuffer" );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When outPreCutB is set, mesh B is never cut, and it now stays in place until fillPreCut. Even so, this branch still deep-copies it into meshBCopyBuffer for the sort data of A, and the same happens for A with outPreCutA. embedStructureToTerrain (InsideB + outPreCutB) copies the whole structure for nothing, and with both outputs set, both meshes are copied while neither is cut. Copy only when needCutMeshB && !params.outPreCutB (likewise for A)?

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