Enhance subdivision algorithm to eliminate T-junctions with a two-step edge splitting approach

This commit is contained in:
CNCKitchen
2026-03-17 11:08:00 +01:00
parent 1a23d7d7fa
commit 0e20de00dc
2 changed files with 129 additions and 47 deletions
+21 -23
View File
@@ -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;
}
+108 -24
View File
@@ -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];