Surface Boolean Without CSG — Closing the Seam After Split-and-Classify

Hello ThreeJS gang,

I have been down the route of robust AI conversations with multiple AIs, and I am still struggling to find a resolution. I am like 99.5% there and really just seeking some advice on some out there options or solutions that others might have had luck with.

I just can’t get this surface to close.

There is a link to the current beta app on my GitHub.

Context:
I’m building a mining/blasting app (vanilla JS + Three.js r170) that needs a Maptek Style - Vulcan TRIBOOL - style surface boolean — two overlapping triangulated surfaces (e.g. terrain + pit design), split along their intersection, user picks which regions to keep, merge into one surface, NOT a solid CSG — these are open triangle meshes (DTM surfaces). CSG does work to an extent on merging the mesh, but not a solid union as such. I can’t close the mesh terrain meshes prior to as they are convoluted and would cross.

The Journey (4 commits of pain)

Commit 1 — Initial Implementation:
Built the full pipeline from scratch:

  • Moller tri-tri intersection to find intersection segments between surfaces
  • Tag each segment with source triangle indices on both surfaces
  • Split crossed triangles along intersection segments (project to triangle-local 2D frame to handle steep/vertical pit walls correctly)
  • Classify non-crossed triangles as above/below the other surface via centroid Z interpolation against a spatial grid
  • Interactive 3D pick UI — user clicks regions to keep/remove, then Apply merges kept triangles

Commit 2 — Refinement:
The splitting worked but classification was fragile:

  • Triangles outside the other surface’s XY extent got classified as “outside” — disconnected from their actual connected region. Fixed with adjacency
    flood-fill to propagate above/below labels into outside regions.
  • Added a secondary straddling triangle refinement pass: after primary tri-tri split, some large triangles still straddle the boundary. Detect via
    per-vertex Z comparison against the other surface, split at Z-crossings with binary search for coverage-boundary cases.
  • Section plane tool added for debugging.

Commit 3 — Normals + More Closing Attempts:
Added surface normal tools (flip/align/Z-up) since boolean results often have inconsistent winding. Added stitchByProximity() — match boundary edges whose
endpoints are within tolerance, fill with triangles.

Commit 4 — The Seam Problem (current state):
The boolean split itself works well. The problem is closing the seam where the two surfaces were cut and merged. After applying:

  • ~170 boundary (open) edges remain
  • ~35 non-manifold (over-shared) edges from splitting artifacts

I’ve thrown every AI option at it:

  1. weldVertices(tris, tolerance) — spatial-grid O(n) vertex welding, snaps coincident points
  2. weldBoundaryVertices(tris, tolerance) — higher-tolerance weld targeting only boundary-edge endpoints (union-find clustering)
  3. cleanCrossingTriangles(tris) — removes duplicate triangles causing over-shared edges (fingerprint by sorted vertex key)
  4. removeOverlappingTriangles(tris, tolerance) — spatial grid finds triangles with close centroids + anti-parallel or parallel normals, removes internal
    wall pairs
  5. stitchByProximity(tris, tolerance) — for each boundary edge, find nearest boundary edge within tolerance, fill the quad with 2 triangles
  6. generateClosingTriangles(tris, maxDist) — for each remaining boundary edge, find nearest vertex that can form a valid triangle
  7. forceCloseIndexedMesh(points, triangles) — iterative pass (up to 30 iterations) on indexed mesh: for each boundary edge, find nearest point, add
    triangle, update edge counts
  8. buildCurtainAndCap(tris, floorOffset) — extrude remaining open edges vertically to a floor + earcut bottom cap
  9. capBoundaryLoops(tris) — extract boundary loops, triangulate each loop with Constrainautor/Delaunator

The pipeline order is: collect → weld → clean crossings → remove overlaps → weld boundary → stitch → generate closing tris → curtain+cap → re-weld →
force-close → log stats.

The Core Problem

The two surfaces meet along the intersection polyline, but after splitting, surface A’s boundary vertices and surface B’s boundary vertices along that seam are close but not identical. They were computed independently by different triangle splits. Even after welding with tolerance, the seam has:

  • Small gaps where vertex positions don’t quite match
  • Occasional T-junctions (vertex of one surface lands mid-edge on the other)
  • Non-manifold edges from overlapping split fragments

What I think I need is a way to enforce that both surfaces share exactly the same vertices along the intersection polyline during the split phase, rather than trying to fix it after the fact. Currently, each surface’s triangles are split independently — they each compute their own crossing points on their own edges, which produces slightly different vertex positions at the seam.

Question for the Community

Has anyone solved this “shared intersection vertices” problem for triangle mesh booleans without full CSG? Specifically:

  1. During the split phase, is there a robust way to ensure both meshes produce identical vertices along the intersection? I’m thinking: compute intersection polyline vertices once, then snap both surfaces’ split points to those shared vertices.
  2. After the merge, what’s the most reliable approach to close a seam between two open mesh regions that almost share a boundary? My stitch-by-proximity
    and force-close approaches work for ~80% of edges but leave stubborn gaps.
  3. Is there a known algorithm for “zip-closing” two boundary loops that roughly overlap? Like a two-pointer greedy walk that pairs vertices and fills with triangles?

Any pointers to papers, libraries, or Three.js examples appreciated. The meshes are 10k–50k triangles typically, so performance matters but isn’t the primary concern — correctness is.

I’m not aware of any, but if you have ordered vertices of both edges, distance-based stitching should be straightforward. See my attempt of stitching edges with random number of vertices.

However, if edges are too rigged, this approach may generate wrong triangles – the two arrows point to almost, but not yet wrong triangles. If such cases are possible with your data, maybe angle-based stitching would give better results.

4 Likes

Of course, your circumstances and restrictions are different, but my examples may still be helpful to you.

In one case, I calculated the vertices at the boundaries of geometries so that they fit.

Inner Geometry (Triangulation)

sandbox => InnerGeometryTHREEi_01


In another case, I created a separate connection geometry. This is deliberately quite large in the example, but smaller ones can also be implemented.

Single-branched geometry organically shaped in the web editor

2 Likes

If I understand the problem correctly, there are two closely spaced fronts between which triangles must be formed.

Possibly limited by a vertical cut?

It may also be possible to adapt E. Hartmann’s triangulation method here.

Page 82

I have already adapted it specifically to a few scenarios.
I don’t usually start with a hexagon, but rather with a simple triangle.

In this example, you can clearly see the progress of the formation of triangles.
Triangulation sphere with holes see the example TriangulationSphereWithHoles

I would first determine the smallest distance between two adjacent vertices in both fronts. Possibly also the minimum distance between the two fronts. This measurement is then taken as the approximate triangle length of the triangulation.

You need at least an approximate normal for the current point. With a sphere, this is trivial, of course, but it was also possible with SDFs.
Generation of SDFGeometry online and locally

2 Likes

This looks promising. Thanks.

1 Like

Here is the online demo: https://codepen.io/boytchev/full/OPXKodL.

It does not construct the triangles, only draws the red segments for illustration. The triangles are easy to make, if needed.

1 Like

[Resolved] Surface Boolean Without CSG — The Full Solution

Follow-up to my original post: Surface Boolean Without CSG — Closing the Seam After Split-and-Classify

Thanks to @PavelBoytchev and @hofk for the helpful suggestions — the edge-stitching demo and Hartmann triangulation reference got me going towards the light. I wanted to post the full-resolution version since this is a problem I couldn’t find a complete solution to online, and others working with open triangle meshes (terrain, geological surfaces, DTMs) may hit the same wall.

I abandoned the "split both surfaces independently, then try to stitch the seam afterwards" approach entirely. *
*
:100: **The solution was to classify triangles geometrically (ray-casting + flood fill) rather than splitting them at the seam first, then only split the straddling triangles using Constrained Delaunay Triangulation with shared Steiner points.

This eliminates the seam mismatch problem at its root.**

Image of Extruded Solid and Terrain Mesh from OBJ :up_arrow:

Image of Selection Panel :up_arrow:

Image of Split Selection Panel :up_arrow:

Image of Selected Splits to create a Solid :up_arrow:

Image of Completed Solid and Statistics :up_arrow:


The Problem (recap)

Two overlapping open triangle meshes (e.g. terrain surface + pit design), need to be split along their intersection and selectively merged — a Maptek Vulcan “TRIBOOL” style operation. Not solid CSG — these are open DTM surfaces that can’t be closed into watertight solids.

My original approach split both surfaces along the intersection segments independently, which produced near-coincident but non-identical vertices along the seam. No amount of post-hoc stitching, welding, or capping could reliably close the resulting gaps.

What Actually Works — The Algorithm

The working solution has 9 steps. Here’s the pipeline:

Step 1–2: Intersection Detection

Extract triangle soups from both surfaces, then run Möller tri-tri intersection on all A×B triangle pairs, accelerated with a spatial grid. Each intersection produces a segment {p0, p1} tagged with source triangle indices {idxA, idxB} on both surfaces. For a typical terrain (27k tris) vs pit shell (40 tris), this produces ~230 intersection segments in under a second.

Step 3: Build Crossed-Triangle Maps

From the tagged segments, build a map per surface: triIndex → [segments that cross this triangle]. Any triangle appearing in this map is “crossed” — it straddles the intersection boundary. All other triangles are cleanly on one side.

Step 4: Spatial Grids for Classification

Build XY spatial grids over both surfaces for fast ray-casting lookups (used in the next step). Cell size is ~2× the average edge length.

Step 5: Flood-Fill Classification (the key insight)

This is where the old approach went wrong. Instead of trying to classify by Z-height interpolation or signed distance, I use connected-component flood fill with ray-cast seeding:

  1. Build edge adjacency for all non-crossed triangles (crossed triangles are excluded — they form the boundary)

  2. BFS flood fill finds connected components — regions of triangles that share edges but are separated by crossed triangles

  3. Each component gets classified by one seed triangle: cast a +Z ray from the seed’s centroid through the other surface’s triangles, count crossings. Odd = inside, even = outside.

  4. The seed’s classification propagates to the entire component.

This works because crossed triangles naturally break the adjacency — triangles on opposite sides of the intersection can’t share edges with non-crossed triangles that span the boundary. The flood fill respects this natural partition.

// Classify seed via ray casting against other surface
var seedTri = tris[seed];
var cx = (seedTri.v0.x + seedTri.v1.x + seedTri.v2.x) / 3;
var cy = (seedTri.v0.y + seedTri.v1.y + seedTri.v2.y) / 3;
var cz = (seedTri.v0.z + seedTri.v1.z + seedTri.v2.z) / 3;

var seedClass = classifyPointByRayCast(
    { x: cx, y: cy, z: cz },
    otherTris, otherGrid, otherCellSize
);

// BFS: propagate to entire connected component
var queue = [seed];
while (head < queue.length) {
    var curr = queue[head++];
    for (var ni = 0; ni < neighbors[curr].length; ni++) {
        var nb = neighbors[curr][ni];
        if (!visited[nb]) {
            visited[nb] = 1;
            result[nb] = seedClass;
            queue.push(nb);
        }
    }
}

The ray-casting PIP (point-in-polygon) works in all orientations — closed pit shells, open terrain, vertical walls. Vertical faces are automatically skipped (degenerate XY projection), so the test gracefully handles steep geometry.

Step 6: Split Straddling Triangles (Constrained Delaunay)

Non-crossed triangles go directly to their inside/outside group. Crossed triangles need subdivision — but instead of splitting along the intersection segments and hoping vertices match, I re-triangulate each crossed triangle with all intersection segment endpoints as Steiner points:

  1. Build a local 2D coordinate frame on the triangle’s plane (critical for steep/vertical triangles — using world XY causes incorrect splits)

  2. Collect all segment endpoints that fall inside the triangle (barycentric validation)

  3. Run Delaunator on the original 3 vertices + Steiner points

  4. Constrain the intersection segment edges using Constrainautor ( THIS LIBRARY HAS SAVED ME SO MANY TIMES boundary edges are NOT constrained — they’re the convex hull already, and constraining them skips intermediate Steiner points on boundary edges)

  5. Filter sub-triangles: centroid must be inside the original triangle (barycentric test), area must exceed a minimum threshold

  6. Classify each sub-triangle independently via ray casting

This is the crucial difference from my old approach:

  • Both surfaces’ straddling triangles are classified using the same geometric test (ray-cast against the other surface), not by trying to align split vertices.
  • There’s no seam to close because the classification is position-based, not topology-based.

Step 7: Seam Vertex Deduplication

After splitting, near-coincident vertices along the seam are merged using a 3D spatial grid (tolerance 0.1mm). This uses a 3×3×3 neighbourhood search for O(n) performance. Degenerate triangles (where vertices collapsed to the same object) are removed.

Step 8: Normal Propagation

Enforce consistent winding order across the result mesh. For manifold meshes: BFS from a seed triangle, checking shared-edge direction — adjacent triangles should traverse their shared edge in opposite directions. For non-manifold meshes: fall back to per-triangle Z-up normals.

Step 9: Interactive Merge

The user sees 4 colour-coded groups (A-inside, A-outside, B-inside, B-outside), toggles which to keep, and clicks Apply. The merge pipeline handles vertex welding, degenerate removal, optional boundary stitching/capping, and stores the result as a new surface.

The Tool, once tested on more diverse surfaces, might get a list form for use with multi-meshes

The Merge Pipeline (Closing the Surface)

To get a watertight result, the merge includes an optional “stitch” mode with this cascade:

  1. Weld vertices — spatial grid O(n) merge within user-specified tolerance

  2. Remove degenerates/slivers — area check + minimum-altitude/max-edge ratio test

  3. Clean crossing triangles — remove over-shared edges by keeping the 2 largest triangles per edge

  4. Stitch by proximity — for each boundary edge, find the nearest boundary edge within tolerance where BOTH endpoints match, connect with a quad (2 triangles)

  5. Sequential boundary capping — extract boundary loops, cap one at a time with Constrained Delaunay, re-weld and clean non-manifold edges between each cap pass. This avoids the double-cap problem where capping all loops at once creates overlapping triangles.

  6. Safety net: force-close — operates on the indexed mesh (integer point indices, zero float precision issues), iteratively finds the nearest valid point for each remaining boundary edge

The key to reliable capping was sequential loop processing — cap one loop, clean up non-manifold artifacts, then cap the next. All-at-once capping was the source of most of my pain in the original post.

NOTE ABOUT CSG: For open surfaces, CSG produces unreliable results (normals are undefined for open meshes), which is why the Surface Boolean approach above is necessary.

What Didn’t Work (So You Don’t Have To Try)

For others attempting this, here’s what I tried and abandoned:

  • CDT + flood-fill on the split mesh: Bridge triangles leak across the seam because CDT constraints can fail on edge conflicts, and the flood fill crosses cut edges.

  • Z-interpolation classification: Comparing the centroid Z against the other surface via barycentric interpolation. Fails for vertical walls, overhangs, and any geometry that isn’t a simple heightfield.

  • Connected-component detection with cut-edge exclusion: Recording which edges were created by splitting and excluding them from adjacency. Works for terrain but fragments the pit shell into hundreds of single-triangle components.

  • Two-pass component refinement: Sub-splitting base components with cut edges, accepting only if the result isn’t fragmented. Theoretically sound, but the heuristics for “fragmented vs legitimate split” were unreliable.

  • Post-hoc stitching without classification-first: The original approach from my first post — every technique (weld, stitch, cap, curtain, force-close) was fighting the fundamental problem that the two surfaces had different vertices along the seam.

Libraries Used

  • Three.js r183

  • Delaunator — unconstrained Delaunay triangulation

  • @kninnug/constrainautor — constrained Delaunay on top of Delaunator

  • THREE-CSGMesh — solid CSG operations (for closed meshes)

  • MeshLine — fat-line rendering for intersection polyline preview

Live Demo & Source

  • Live DEV Version app: Kirra Blast & Mine Design Web Application. This is the public-facing version. This post is a future update.

  • Hope this helps anyone else wrestling with open-mesh booleans in Three.js.


Update — Fixed! (v0.2.0)

The angled wall classification issue is now resolved.

1. Multi-Axis Jittered Ray Casting

The original code cast a single ray per axis. If that ray landed exactly on a triangle edge (common with axis-aligned geometry), both adjacent triangles counted the hit → even count → wrongly classified as “outside”.

Fix:

Cast 3 deterministic jittered rays per axis (~0.00005 offset in the projection plane), majority vote
per axis. Each axis returns a 3-state result:

  • 0 = no hits (no vote — ray missed the mesh entirely)
  • 1 = inside (2+ of 3 jittered rays had odd hit count)
  • 2 = outside (2+ of 3 jittered rays had even hit count).

Then the multi-axis vote across Z, X, Y: 2+ axes say “inside” → inside. Handles any wall angle 0–90° without thresholds.

2. Free-Vertex Topology Inheritance (the critical one - Vertex-Adjacency Sub-Triangle Classification)

After CDT splitting at intersection edges, each sub-triangle needs classification. The old approach ray-cast the centroid — but centroids of sub-triangles sit right next to the intersection boundary where ray casting is least reliable.

Fix: Build a vertex→classification map from non-crossed triangles (flood-fill results).
For each sub-triangle, find a vertex that is NOT a Steiner point (not on the intersection line),
look it up in the map, and inherit that classification. Pure topology — no ray casting at the
boundary. Only falls back to ray casting when no adjacent non-crossed triangle exists.

The fix uses pure topology instead of geometry. Here’s how it works:

  1. Build a vertex→class map from all non-crossed triangles (these already have correct flood-fill classifications). Every vertex of every non-crossed triangle gets mapped to its triangle’s inside/outside class.
  2. Collect Steiner keys — the intersection segment endpoints that were inserted by CDT. These vertices sit ON the intersection line and belong to both sides, so they can’t tell us anything.
  3. For each sub-triangle, find the “free” vertex — the one that is NOT a Steiner point (not on the
    intersection line). This vertex was an original mesh vertex that also belongs to a non-crossed neighbour triangle.
  4. Look up the free vertex in the map → inherit that classification directly. No ray casting, no geometry queries. Pure topological adjacency.
  5. Fallback: Only if ALL vertices of a sub-triangle are Steiner points (rare — happens when a tiny triangle sits entirely on the intersection line) do we fall back to multi-axis ray casting.

3. No More Angle Thresholds

Removed surfaceNeedsMultiAxis — always test all 3 axes. The jitter + majority vote is cheap enough that there’s no reason to gate it.

This is the key insight: the flood-fill already classified the entire mesh correctly on both sides of the intersection. The CDT split just subdivided triangles at the boundary. Each sub-triangle shares at least one vertex with a non-crossed triangle on the correct side — so we inherit instead of re-classifying.

Result: Cube vs cube union produces a clean merged shell. Steep pit shells (80°+ walls) vs terrain work correctly. No more jagged edges at intersection boundaries.

Library updated:

https://www.npmjs.com/package/trimesh-boolean ·
trimesh-boolean — Live Demo


2 Likes

FYI… Once it was solved… it became a library. Now you all can use it. Save some time.

https://www.npmjs.com/package/trimesh-boolean

Just discovered a few issues… Angle walls don’t get classified correctly, so I’m working through that now… will update the library when I can.

3 Likes