From 0e20de00dc9ba4f870767d93d2e7d0ffad952f5f Mon Sep 17 00:00:00 2001 From: CNCKitchen Date: Tue, 17 Mar 2026 11:08:00 +0100 Subject: [PATCH] Enhance subdivision algorithm to eliminate T-junctions with a two-step edge splitting approach --- js/decimation.js | 44 ++++++++-------- js/subdivision.js | 132 +++++++++++++++++++++++++++++++++++++--------- 2 files changed, 129 insertions(+), 47 deletions(-) diff --git a/js/decimation.js b/js/decimation.js index 391f4ca..f931573 100644 --- a/js/decimation.js +++ b/js/decimation.js @@ -138,38 +138,36 @@ function isBoundaryEdge(faces, vertFaces, v1, v2) { return shared < 2; } -// ── Guard 2: Link condition (non-manifold / pinch prevention) ──────────────── -// The set of common neighbours of v1 and v2 must equal exactly the apex -// vertices of their shared faces. Extra common neighbours mean collapsing -// would fuse disconnected regions → non-manifold edge. +// ── Guard 2: Duplicate-face / pinch prevention ─────────────────────────────── +// After collapsing v2 into v1, every face of v2 that survives (i.e. does not +// share v1) gets v2 replaced by v1. If any such remapped face is identical to +// a face already incident to v1, the collapse would create a duplicate → reject. +// This is the actual harm the link condition guards against, without the +// false-positives that the strict set-equality test produces on interior edges. function hasLinkViolation(faces, vertFaces, v1, v2) { - // Collect apex vertices of the shared faces - const apices = new Set(); + // Build a set of face "signatures" already incident to v1 (excluding shared faces). + // A signature is the sorted triple of vertex indices, encoded as a string. + const v1Sigs = new Set(); for (const f of vertFaces[v1]) { if (faces[f * 3] < 0) continue; const fa = faces[f * 3], fb = faces[f * 3 + 1], fc = faces[f * 3 + 2]; - if (fa === v2 || fb === v2 || fc === v2) { - if (fa !== v1 && fa !== v2) apices.add(fa); - if (fb !== v1 && fb !== v2) apices.add(fb); - if (fc !== v1 && fc !== v2) apices.add(fc); - } + if (fa === v2 || fb === v2 || fc === v2) continue; // shared face, will be deleted + const arr = [fa, fb, fc].sort((a, b) => a - b); + v1Sigs.add(`${arr[0]},${arr[1]},${arr[2]}`); } - // Build neighbour sets for v1 and v2 from active faces - const nb1 = new Set(), nb2 = new Set(); - for (const f of vertFaces[v1]) { - if (faces[f * 3] < 0) continue; - for (let k = 0; k < 3; k++) { const nb = faces[f * 3 + k]; if (nb !== v1) nb1.add(nb); } - } + // Check every surviving face of v2 (after remapping v2→v1) for duplicates. for (const f of vertFaces[v2]) { if (faces[f * 3] < 0) continue; - for (let k = 0; k < 3; k++) { const nb = faces[f * 3 + k]; if (nb !== v2) nb2.add(nb); } - } - - // Check common neighbours match apices exactly - for (const nb of nb1) { - if (nb !== v2 && nb2.has(nb) && !apices.has(nb)) return true; + const fa = faces[f * 3], fb = faces[f * 3 + 1], fc = faces[f * 3 + 2]; + if (fa === v1 || fb === v1 || fc === v1) continue; // shared face, will be deleted + // Remap v2 → v1 + const ra = fa === v2 ? v1 : fa; + const rb = fb === v2 ? v1 : fb; + const rc = fc === v2 ? v1 : fc; + const arr = [ra, rb, rc].sort((a, b) => a - b); + if (v1Sigs.has(`${arr[0]},${arr[1]},${arr[2]}`)) return true; } return false; } diff --git a/js/subdivision.js b/js/subdivision.js index f5fe435..6a6e1e1 100644 --- a/js/subdivision.js +++ b/js/subdivision.js @@ -48,12 +48,43 @@ export function subdivide(geometry, maxEdgeLength, onProgress) { } // ── One subdivision pass ────────────────────────────────────────────────────── +// +// Uses a two-step approach to eliminate T-junctions: +// +// Step 1 – scan ALL triangles and mark every edge whose squared length +// exceeds maxSq. Because this is global, both triangles that +// share an edge always agree on whether to split it. +// +// Step 2 – rebuild the index list. Each triangle is handled according to +// how many of its three edges are marked: +// +// 0 edges → keep as-is +// 1 edge → 2 sub-triangles (bisect the one long edge) +// 2 edges → 3 sub-triangles (fan from the vertex opposite the short edge) +// 3 edges → 4 sub-triangles (classic 1→4 midpoint subdivision – most regular) +// +// The 2- and 3-edge cases are new compared to the old single-edge split and +// produce significantly more regular results. Thin slivers with one very +// long edge still produce chains of thin children (unavoidable without moving +// vertices off the surface), but the mesh is now crack-free in all cases. function subdividePass(positions, normals, indices, maxEdgeLength, safetyCap) { const maxSq = maxEdgeLength * maxEdgeLength; const midCache = new Map(); + + // ── Step 1: globally mark edges that need splitting ───────────────────── + const splitEdges = new Set(); + for (let t = 0; t < indices.length; t += 3) { + const a = indices[t], b = indices[t + 1], c = indices[t + 2]; + if (edgeLenSq(positions, a, b) > maxSq) splitEdges.add(edgeKey(a, b)); + if (edgeLenSq(positions, b, c) > maxSq) splitEdges.add(edgeKey(b, c)); + if (edgeLenSq(positions, c, a) > maxSq) splitEdges.add(edgeKey(c, a)); + } + + if (splitEdges.size === 0) return { newIndices: indices, changed: false }; + + // ── Step 2: rebuild index list ─────────────────────────────────────────── const nextIndices = []; - let changed = false; for (let t = 0; t < indices.length; t += 3) { // Safety cap: stop splitting, carry remaining triangles as-is @@ -62,41 +93,94 @@ function subdividePass(positions, normals, indices, maxEdgeLength, safetyCap) { break; } - const a = indices[t]; - const b = indices[t + 1]; - const c = indices[t + 2]; + const a = indices[t], b = indices[t + 1], c = indices[t + 2]; + const sAB = splitEdges.has(edgeKey(a, b)); + const sBC = splitEdges.has(edgeKey(b, c)); + const sCA = splitEdges.has(edgeKey(c, a)); + const n = (sAB ? 1 : 0) + (sBC ? 1 : 0) + (sCA ? 1 : 0); - const ab = edgeLenSq(positions, a, b); - const bc = edgeLenSq(positions, b, c); - const ca = edgeLenSq(positions, c, a); - - const longest = Math.max(ab, bc, ca); - if (longest <= maxSq) { - // Triangle is fine – keep as is + if (n === 0) { + // ── 0-split: keep triangle ───────────────────────────────────────── nextIndices.push(a, b, c); - continue; - } - changed = true; + } else if (n === 3) { + // ── 3-split: 1→4 regular midpoint subdivision ────────────────────── + // + // a + // / \ + // mCA─mAB + // / \ / \ + // c─mBC───b + // + const mAB = getMidpoint(positions, normals, midCache, a, b); + const mBC = getMidpoint(positions, normals, midCache, b, c); + const mCA = getMidpoint(positions, normals, midCache, c, a); + nextIndices.push( + a, mAB, mCA, + mAB, b, mBC, + mCA, mBC, c, + mAB, mBC, mCA, + ); + + } else if (n === 1) { + // ── 1-split: bisect the one marked edge → 2 sub-triangles ────────── + if (sAB) { + const m = getMidpoint(positions, normals, midCache, a, b); + nextIndices.push(a, m, c, m, b, c); + } else if (sBC) { + const m = getMidpoint(positions, normals, midCache, b, c); + nextIndices.push(a, b, m, a, m, c); + } else { // sCA + const m = getMidpoint(positions, normals, midCache, c, a); + nextIndices.push(a, b, m, m, b, c); + } - // Split the longest edge - if (longest === ab) { - const m = getMidpoint(positions, normals, midCache, a, b); - nextIndices.push(a, m, c, m, b, c); - } else if (longest === bc) { - const m = getMidpoint(positions, normals, midCache, b, c); - nextIndices.push(a, b, m, a, m, c); } else { - const m = getMidpoint(positions, normals, midCache, c, a); - nextIndices.push(a, b, m, m, b, c); + // ── 2-split: 3 sub-triangles, fan from the untouched-edge vertex ─── + // + // For each case the unsplit-edge vertex forms a small corner triangle + // with its two adjacent midpoints; the remaining quadrilateral is + // split along the diagonal that connects those two midpoints to the + // opposite vertices, preserving consistent CCW winding throughout. + + if (!sAB) { // sBC + sCA: fan from C + const mBC = getMidpoint(positions, normals, midCache, b, c); + const mCA = getMidpoint(positions, normals, midCache, c, a); + nextIndices.push( + a, b, mBC, + a, mBC, mCA, + c, mCA, mBC, + ); + } else if (!sBC) { // sAB + sCA: fan from A + const mAB = getMidpoint(positions, normals, midCache, a, b); + const mCA = getMidpoint(positions, normals, midCache, c, a); + nextIndices.push( + a, mAB, mCA, + mAB, b, c, + mAB, c, mCA, + ); + } else { // sAB + sBC: fan from B + const mAB = getMidpoint(positions, normals, midCache, a, b); + const mBC = getMidpoint(positions, normals, midCache, b, c); + nextIndices.push( + b, mBC, mAB, + a, mAB, mBC, + a, mBC, c, + ); + } } } - return { newIndices: nextIndices, changed }; + return { newIndices: nextIndices, changed: true }; } // ── Helpers ────────────────────────────────────────────────────────────────── +/** Canonical order key for an undirected edge – matches the getMidpoint cache key. */ +function edgeKey(a, b) { + return a < b ? `${a}:${b}` : `${b}:${a}`; +} + function edgeLenSq(pos, a, b) { const dx = pos[a*3] - pos[b*3]; const dy = pos[a*3+1] - pos[b*3+1];