Skip to content

Plan the filling of several coplanar holes at once - #6560

Draft
Grantim wants to merge 2 commits into
masterfrom
planar-fill-plan-multiple-holes
Draft

Plan the filling of several coplanar holes at once#6560
Grantim wants to merge 2 commits into
masterfrom
planar-fill-plan-multiple-holes

Conversation

@Grantim

@Grantim Grantim commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

fillContours2DPlan plans one hole at a time, so a caller holding several holes that lie in one plane can only plan and execute them one by one. That cannot express a filling whose triangles connect two different holes — the ring between two nested borders being the obvious case — and it is exactly what a caller filling the cut faces of a boolean needs, since one original face can be left with several holes at once.

This adds an overload taking all the hole edges of one plane. They are tracked into loops, triangulated together by the same mesh-space triangulateDisjointContours the single-hole path uses since #6555 (one sweep for the whole group, sharing the Newell normal and the hairline-edge guard), and the resulting patch is converted into a single HoleFillPlan. The single-hole overload becomes a wrapper over it, so there is one patch-to-plan conversion instead of two.

Why the conversion had to be generalized

executeHoleFillPlan resolves a negative edge code to the forward direction of an earlier plan edge (EdgeId( plan.items[-(code+1)].edgeCode1 )); nothing can name its sym. A plan therefore cannot just replay the order in which the sweep spliced its own edges, because the sweep anchors on syms (holeLoop[prev] = newE.sym()).

The existing conversion sidesteps that by peeling ears off the boundary ring and addressing positions by patch vertex id (i1 = int( pTp.dest( np[i0] ) )), which holds only while vertex ids coincide with positions around a single loop. With several loops in one patch, and vertices shared between them, it no longer does.

The generalized conversion keeps the same ear discipline but tracks the polygon explicitly: for each boundary position, the patch edge currently sitting there, its plan code, and the ring successor. Rings are then plain successor cycles, so several coexist and a vertex shared by two loops is unambiguous. Each ear records its closing edge as a chord between the polygon edges at its two ends — precisely what makeNewEdge splices right after them — and every later chord at the same vertex lands closer to its anchor, which reproduces the patch's angular order.

The peeling state is two buffers (second commit): one entry per boundary position holding its current patch edge and its ring successor, and one code per patch edge saying how the plan can name it — an absolute mesh edge on the borders, a plan edge once a chord for it is planned, nothing while it cannot be named yet. That single code subsumes what would otherwise be a boundary map plus two masks, and the number of chords to plan follows from 3F = n + 2C rather than a scan over every patch edge.

Two rings are joined by a bridge chord. A bridge is expressible only when it is the first new edge after the current polygon edge at both of its ends, and the face left of its reverse must be clipped before it, so that the bridge's sym ends up between the anchor and that clip. Whatever the encoding cannot express is refused rather than mis-encoded.

Holes that need no new edges

A triangular hole is filled by one face and no chord, and executeHoleFillPlan only creates faces adjacent to edges the plan creates, so such a hole cannot ride inside a shared plan. They are reported through outSingleFaceHoles for the caller to fill one by one; passing no output makes such a group an error rather than a silently unfilled hole.

Verification

  • MRTest: 335/335.
  • New MRMesh.fillContours2DPlanMultipleHoles covers the case the single-hole overload cannot express: trimming a hollow sphere leaves an outer and an inner coplanar border, and one plan executed from a single edge closes both, reuses every border vertex in place, adds exactly numTris faces, and produces no degenerate ones.
  • Single-hole plans are unchanged. Over a corpus of 539 holes — spheres at 11 resolutions, each trimmed by 7 differently oriented planes at 7 offsets — master and this branch produce identical plans: the same 511 successes and 28 declines (the hairline guard), the same 50365 items and 50876 triangles, and the same FNV hash over every FillHoleItem. So for one hole the shared conversion reproduces the old one exactly; only the multi-hole entry point is new behaviour.
corpus of 539 holes master this PR
plans produced / declined 511 / 28 511 / 28
total items / triangles 50365 / 50876 50365 / 50876
FNV hash of all items 16595764369074226670 16595764369074226670

The same corpus also pins the second commit: the hash is unchanged by it, so shrinking the peeling state is not a behaviour change.

🤖 Generated with Claude Code

Grantim and others added 2 commits August 7, 2026 20:35
fillContours2DPlan gets an overload taking all hole edges lying in one plane:
they are triangulated together as fillContours2D does, and the patch is
converted into a single HoleFillPlan whose new edges may connect different
holes, like the ring between two nested borders. The single-hole overload now
shares that conversion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The position ring and one code per patch edge carry all the peeling state: the
boundary-to-mesh map, the planned mask and the boundary mask collapse into that
code, and the chord count follows from 3F = n + 2C instead of a scan over every
patch edge. The produced plans are unchanged.

Co-Authored-By: Claude Fable 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