perf: replace string-key maps with numeric flat arrays in hot paths

Displacement, subdivision and exclusion all used template-string keys
for vertex dedup and edge lookup maps. Replaced with a single numeric
dedup pass + flat typed arrays (Float64Array / Uint8Array), cutting
displacement time by ~2.5x and subdivision by ~2.4x on a 68k tri STL.

- displacement.js: vertex dedup → flat arrays for smooth normals,
  zone areas, masked fractions, displacement cache, excluded set
- subdivision.js: numeric edge keys (a*maxV+b) instead of template strings
- exclusion.js: numeric edge keys, BFS queue.shift() → index pointer,
  adjacency Map → Array, TypedArray.set() for overlay copy
- mapping.js: Math.pow(x,4) → x²·x², cos/sin cached per computeUV call,
  applyTransform signature changed to accept precomputed cos/sin
This commit is contained in:
Avatarsia
2026-04-06 02:38:13 +02:00
parent d99c97fb24
commit 51873fd5fc
4 changed files with 142 additions and 135 deletions
+76 -77
View File
@@ -45,8 +45,6 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
const settingsWithAspect = { ...settings, textureAspectU: aspectU, textureAspectV: aspectV }; const settingsWithAspect = { ...settings, textureAspectU: aspectU, textureAspectV: aspectV };
const QUANT = 1e4; const QUANT = 1e4;
const posKey = (x, y, z) =>
`${Math.round(x * QUANT)}_${Math.round(y * QUANT)}_${Math.round(z * QUANT)}`;
// ── WHY GAPS HAPPEN ─────────────────────────────────────────────────────── // ── WHY GAPS HAPPEN ───────────────────────────────────────────────────────
// The mesh is non-indexed (unrolled): every triangle has its own copy of // The mesh is non-indexed (unrolled): every triangle has its own copy of
@@ -64,33 +62,47 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
// underlying geometry is still faceted (the subdivision didn't change it), // underlying geometry is still faceted (the subdivision didn't change it),
// so printed edges remain sharp. // so printed edges remain sharp.
// ── Vertex dedup pass: position → numeric ID via one-time string-map pass ─
const _dedupMap = new Map();
let _nextId = 0;
const vertexId = new Uint32Array(count);
for (let i = 0; i < count; i++) {
const x = posAttr.getX(i), y = posAttr.getY(i), z = posAttr.getZ(i);
const key = `${Math.round(x * QUANT)}_${Math.round(y * QUANT)}_${Math.round(z * QUANT)}`;
let id = _dedupMap.get(key);
if (id === undefined) { id = _nextId++; _dedupMap.set(key, id); }
vertexId[i] = id;
}
const uniqueCount = _nextId;
// ── Pass 1: accumulate area-weighted smooth normals per unique position ─── // ── Pass 1: accumulate area-weighted smooth normals per unique position ───
// Map: posKey → [nx, ny, nz] (unnormalised sum) // Flat arrays indexed by vertex dedup ID (replaces Map<string, ...>)
const smoothNrmMap = new Map(); const smoothNrmX = new Float64Array(uniqueCount);
// zoneAreaMap: posKey → [xArea, yArea, zArea] (cubic mapping only) const smoothNrmY = new Float64Array(uniqueCount);
// Tracks the total adjacent face area in each cubic projection zone (X/Y/Z dominant). const smoothNrmZ = new Float64Array(uniqueCount);
// Seam-edge vertices that border two zones get a blend proportional to face area,
// eliminating the mixed-projection artefact on seam-crossing triangles. // zoneArea: per-axis face area for cubic mapping (replaces zoneAreaMap)
const zoneAreaMap = new Map(); const zoneAreaX = new Float64Array(uniqueCount);
// maskedFracMap: posKey → [maskedArea, totalArea] const zoneAreaY = new Float64Array(uniqueCount);
// Tracks the fraction of surrounding face area that is masked so boundary const zoneAreaZ = new Float64Array(uniqueCount);
// vertices get a smooth displacement blend instead of a hard on/off cutoff.
const maskedFracMap = new Map(); // maskedFrac: [maskedArea, totalArea] per unique vertex (replaces maskedFracMap)
const maskedFracMasked = new Float64Array(uniqueCount);
const maskedFracTotal = new Float64Array(uniqueCount);
// Optional per-vertex exclusion weights threaded through by subdivision.js. // Optional per-vertex exclusion weights threaded through by subdivision.js.
// A face's user-exclusion flag = average of its 3 vertex weights > 0.99. // A face's user-exclusion flag = average of its 3 vertex weights > 0.99.
const ewAttr = geometry.attributes.excludeWeight || null; const ewAttr = geometry.attributes.excludeWeight || null;
// Per-face user-exclusion flag: stored separately from maskedFracMap so that // Per-face user-exclusion flag: stored separately from maskedFrac so that
// user-excluded faces do NOT bleed reduced displacement into adjacent faces // user-excluded faces do NOT bleed reduced displacement into adjacent faces
// via shared vertices (maskedFracMap is only for angle-based blending). // via shared vertices (maskedFrac is only for angle-based blending).
const userExcludedFaces = ewAttr ? new Uint8Array(count / 3) : null; const userExcludedFaces = ewAttr ? new Uint8Array(count / 3) : null;
// Positions that belong to at least one user-excluded face. Any included-face // Positions that belong to at least one user-excluded face (replaces excludedPosSet).
// vertex whose original position is in this set sits on the seam boundary; we const excludedPos = ewAttr ? new Uint8Array(uniqueCount) : null;
// pin it to zero displacement so both sides of the seam end up at the same
// final position. Without this the mesh has an open crack at the mask // Displacement cache: one sample per unique vertex (replaces dispCache Map)
// boundary, which causes the QEM decimator to treat the excluded patch as an const dispCacheVal = new Float64Array(uniqueCount);
// isolated open-mesh island and collapse it to nothing (missing triangles). const dispCacheSet = new Uint8Array(uniqueCount);
const excludedPosSet = ewAttr ? new Set() : null;
for (let t = 0; t < count; t += 3) { for (let t = 0; t < count; t += 3) {
vA.fromBufferAttribute(posAttr, t); vA.fromBufferAttribute(posAttr, t);
@@ -141,9 +153,8 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
} }
for (let v = 0; v < 3; v++) { for (let v = 0; v < 3; v++) {
tmpPos.fromBufferAttribute(posAttr, t + v); const vid = vertexId[t + v];
const k = posKey(tmpPos.x, tmpPos.y, tmpPos.z); if (userExcluded && excludedPos) excludedPos[vid] = 1;
if (userExcluded && excludedPosSet) excludedPosSet.add(k);
// Use the buffer normal (from subdivision) weighted by face area. // Use the buffer normal (from subdivision) weighted by face area.
// The subdivision pipeline splits indexed vertices at sharp dihedral // The subdivision pipeline splits indexed vertices at sharp dihedral
// edges (>30°), so the interpolated buffer normals are smooth across // edges (>30°), so the interpolated buffer normals are smooth across
@@ -151,44 +162,33 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
// This eliminates visible faceting steps on round surfaces while still // This eliminates visible faceting steps on round surfaces while still
// preserving hard edges. // preserving hard edges.
tmpNrm.fromBufferAttribute(nrmAttr, t + v); tmpNrm.fromBufferAttribute(nrmAttr, t + v);
const existing = smoothNrmMap.get(k); smoothNrmX[vid] += tmpNrm.x * faceArea;
if (existing) { smoothNrmY[vid] += tmpNrm.y * faceArea;
existing[0] += tmpNrm.x * faceArea; smoothNrmZ[vid] += tmpNrm.z * faceArea;
existing[1] += tmpNrm.y * faceArea;
existing[2] += tmpNrm.z * faceArea;
} else {
smoothNrmMap.set(k, [tmpNrm.x * faceArea, tmpNrm.y * faceArea, tmpNrm.z * faceArea]);
}
if (czX > 1e-12 || czY > 1e-12 || czZ > 1e-12) { if (czX > 1e-12 || czY > 1e-12 || czZ > 1e-12) {
const za = zoneAreaMap.get(k); zoneAreaX[vid] += czX;
if (za) { za[0] += czX; za[1] += czY; za[2] += czZ; } zoneAreaY[vid] += czY;
else { zoneAreaMap.set(k, [czX, czY, czZ]); } zoneAreaZ[vid] += czZ;
}
const mf = maskedFracMap.get(k);
if (mf) {
if (faceMasked) mf[0] += faceArea;
mf[1] += faceArea;
} else {
maskedFracMap.set(k, [faceMasked ? faceArea : 0, faceArea]);
} }
if (faceMasked) maskedFracMasked[vid] += faceArea;
maskedFracTotal[vid] += faceArea;
} }
} }
// Normalise each accumulated normal // Normalise each accumulated normal
smoothNrmMap.forEach((n) => { for (let id = 0; id < uniqueCount; id++) {
const len = Math.sqrt(n[0]*n[0] + n[1]*n[1] + n[2]*n[2]) || 1; const len = Math.sqrt(smoothNrmX[id]*smoothNrmX[id] + smoothNrmY[id]*smoothNrmY[id] + smoothNrmZ[id]*smoothNrmZ[id]) || 1;
n[0] /= len; n[1] /= len; n[2] /= len; smoothNrmX[id] /= len; smoothNrmY[id] /= len; smoothNrmZ[id] /= len;
}); }
// ── Pass 2: sample displacement texture once per unique position ────────── // ── Pass 2: sample displacement texture once per unique position ──────────
const dispCache = new Map(); // posKey → grey [0, 1]
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
tmpPos.fromBufferAttribute(posAttr, i); const vid = vertexId[i];
const k = posKey(tmpPos.x, tmpPos.y, tmpPos.z); if (dispCacheSet[vid]) continue;
if (dispCache.has(k)) continue; dispCacheSet[vid] = 1;
const sn = smoothNrmMap.get(k); tmpPos.fromBufferAttribute(posAttr, i);
// Cubic: zone-area-weighted sampling with a stable per-face dominant axis. // Cubic: zone-area-weighted sampling with a stable per-face dominant axis.
// Non-seam vertices use their single zone purely; seam-edge vertices that // Non-seam vertices use their single zone purely; seam-edge vertices that
@@ -201,39 +201,39 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
// top (0,0,1) and bottom (0,0,-1) face normals cancel at shared edge vertices, // top (0,0,1) and bottom (0,0,-1) face normals cancel at shared edge vertices,
// leaving a horizontal smooth normal. computeUV would then pick the wrong // leaving a horizontal smooth normal. computeUV would then pick the wrong
// cubic projection axis, making those faces appear untextured. The face- // cubic projection axis, making those faces appear untextured. The face-
// normal-based zoneAreaMap is immune to this because it classifies faces by // normal-based zoneArea arrays are immune to this because they classify faces
// their geometric cross-product normal, not the averaged vertex normal. // by their geometric cross-product normal, not the averaged vertex normal.
if (settings.mappingMode === 6 /* MODE_CUBIC */) { if (settings.mappingMode === 6 /* MODE_CUBIC */) {
const za = zoneAreaMap.get(k); const zaX = zoneAreaX[vid], zaY = zoneAreaY[vid], zaZ = zoneAreaZ[vid];
const total = za ? za[0] + za[1] + za[2] : 0; const total = zaX + zaY + zaZ;
if (total > 0) { if (total > 0) {
const md = Math.max(bounds.size.x, bounds.size.y, bounds.size.z, 1e-6); const md = Math.max(bounds.size.x, bounds.size.y, bounds.size.z, 1e-6);
const rotRad = (settings.rotation ?? 0) * Math.PI / 180; const rotRad = (settings.rotation ?? 0) * Math.PI / 180;
let grey = 0; let grey = 0;
if (za[0] > 0) { // X-dominant zone → YZ projection if (zaX > 0) { // X-dominant zone → YZ projection
let rawU = (tmpPos.y-bounds.min.y)/md; let rawU = (tmpPos.y-bounds.min.y)/md;
if (sn[0] < 0) rawU = -rawU; // flip U for -X faces if (smoothNrmX[vid] < 0) rawU = -rawU; // flip U for -X faces
const uv = _cubicUV(rawU, (tmpPos.z-bounds.min.z)/md, settings, rotRad, aspectU, aspectV); const uv = _cubicUV(rawU, (tmpPos.z-bounds.min.z)/md, settings, rotRad, aspectU, aspectV);
grey += sampleBilinear(imageData.data, imgWidth, imgHeight, uv.u, uv.v) * (za[0]/total); grey += sampleBilinear(imageData.data, imgWidth, imgHeight, uv.u, uv.v) * (zaX/total);
} }
if (za[1] > 0) { // Y-dominant zone → XZ projection if (zaY > 0) { // Y-dominant zone → XZ projection
let rawU = (tmpPos.x-bounds.min.x)/md; let rawU = (tmpPos.x-bounds.min.x)/md;
if (sn[1] > 0) rawU = -rawU; // flip U for +Y faces if (smoothNrmY[vid] > 0) rawU = -rawU; // flip U for +Y faces
const uv = _cubicUV(rawU, (tmpPos.z-bounds.min.z)/md, settings, rotRad, aspectU, aspectV); const uv = _cubicUV(rawU, (tmpPos.z-bounds.min.z)/md, settings, rotRad, aspectU, aspectV);
grey += sampleBilinear(imageData.data, imgWidth, imgHeight, uv.u, uv.v) * (za[1]/total); grey += sampleBilinear(imageData.data, imgWidth, imgHeight, uv.u, uv.v) * (zaY/total);
} }
if (za[2] > 0) { // Z-dominant zone → XY projection if (zaZ > 0) { // Z-dominant zone → XY projection
let rawU = (tmpPos.x-bounds.min.x)/md; let rawU = (tmpPos.x-bounds.min.x)/md;
if (sn[2] < 0) rawU = -rawU; // flip U for -Z faces if (smoothNrmZ[vid] < 0) rawU = -rawU; // flip U for -Z faces
const uv = _cubicUV(rawU, (tmpPos.y-bounds.min.y)/md, settings, rotRad, aspectU, aspectV); const uv = _cubicUV(rawU, (tmpPos.y-bounds.min.y)/md, settings, rotRad, aspectU, aspectV);
grey += sampleBilinear(imageData.data, imgWidth, imgHeight, uv.u, uv.v) * (za[2]/total); grey += sampleBilinear(imageData.data, imgWidth, imgHeight, uv.u, uv.v) * (zaZ/total);
} }
dispCache.set(k, grey); dispCacheVal[vid] = grey;
continue; continue;
} }
} }
tmpNrm.set(sn[0], sn[1], sn[2]); tmpNrm.set(smoothNrmX[vid], smoothNrmY[vid], smoothNrmZ[vid]);
const uvResult = computeUV(tmpPos, tmpNrm, settings.mappingMode, settingsWithAspect, bounds); const uvResult = computeUV(tmpPos, tmpNrm, settings.mappingMode, settingsWithAspect, bounds);
let grey; let grey;
@@ -245,7 +245,7 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
} else { } else {
grey = sampleBilinear(imageData.data, imgWidth, imgHeight, uvResult.u, uvResult.v); grey = sampleBilinear(imageData.data, imgWidth, imgHeight, uvResult.u, uvResult.v);
} }
dispCache.set(k, grey); dispCacheVal[vid] = grey;
} }
// ── Pass 3: displace every vertex copy by the same vector ───────────────── // ── Pass 3: displace every vertex copy by the same vector ─────────────────
@@ -258,9 +258,8 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
tmpPos.fromBufferAttribute(posAttr, i); tmpPos.fromBufferAttribute(posAttr, i);
tmpNrm.fromBufferAttribute(nrmAttr, i); tmpNrm.fromBufferAttribute(nrmAttr, i);
const k = posKey(tmpPos.x, tmpPos.y, tmpPos.z); const vid = vertexId[i];
const sn = smoothNrmMap.get(k); const grey = dispCacheVal[vid];
const grey = dispCache.get(k);
// User-excluded faces get zero displacement; only angle-based masking uses // User-excluded faces get zero displacement; only angle-based masking uses
// the smooth per-vertex blend so neighbours are never unintentionally dimmed. // the smooth per-vertex blend so neighbours are never unintentionally dimmed.
@@ -268,15 +267,15 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
// Pin included-face vertices that share a position with an excluded face. // Pin included-face vertices that share a position with an excluded face.
// This seals the open crack at the mask boundary so the mesh stays watertight // This seals the open crack at the mask boundary so the mesh stays watertight
// and the decimator cannot collapse the excluded patch to zero faces. // and the decimator cannot collapse the excluded patch to zero faces.
const isSealedBoundary = !isFaceExcluded && excludedPosSet && excludedPosSet.has(k); const isSealedBoundary = !isFaceExcluded && excludedPos && excludedPos[vid] === 1;
const mf = maskedFracMap.get(k) || [0, 1]; const mfTotal = maskedFracTotal[vid];
const maskedFrac = mf[1] > 0 ? mf[0] / mf[1] : 0; const maskedFrac = mfTotal > 0 ? maskedFracMasked[vid] / mfTotal : 0;
const centeredGrey = settings.symmetricDisplacement ? (grey - 0.5) : grey; const centeredGrey = settings.symmetricDisplacement ? (grey - 0.5) : grey;
const disp = (isFaceExcluded || isSealedBoundary) ? 0 : (1 - maskedFrac) * centeredGrey * settings.amplitude; const disp = (isFaceExcluded || isSealedBoundary) ? 0 : (1 - maskedFrac) * centeredGrey * settings.amplitude;
const newX = tmpPos.x + sn[0] * disp; const newX = tmpPos.x + smoothNrmX[vid] * disp;
const newY = tmpPos.y + sn[1] * disp; const newY = tmpPos.y + smoothNrmY[vid] * disp;
let newZ = tmpPos.z + sn[2] * disp; let newZ = tmpPos.z + smoothNrmZ[vid] * disp;
// Prevent boundary vertices from poking through the masked surface in Z. // Prevent boundary vertices from poking through the masked surface in Z.
// Only triggers for vertices that are partly masked (maskedFrac > 0) and // Only triggers for vertices that are partly masked (maskedFrac > 0) and
+33 -28
View File
@@ -23,7 +23,7 @@ const quantKey = (x, y, z) =>
* *
* @param {THREE.BufferGeometry} geometry non-indexed * @param {THREE.BufferGeometry} geometry non-indexed
* @returns {{ * @returns {{
* adjacency: Map<number, Array<{neighbor:number, angle:number}>>, * adjacency: Array<Array<{neighbor:number, angle:number}>>,
* centroids: Float32Array (triCount × 3, world-space centroid per triangle) * centroids: Float32Array (triCount × 3, world-space centroid per triangle)
* }} * }}
*/ */
@@ -71,24 +71,27 @@ export function buildAdjacency(geometry) {
// Build edge → triangle list (two triangles share an edge iff they share two // Build edge → triangle list (two triangles share an edge iff they share two
// vertex positions after quantization-based deduplication). // vertex positions after quantization-based deduplication).
// Vertex-dedup pass: assign a numeric ID to each unique quantised position.
const posToId = new Map();
let nextId = 0;
const vertId = new Uint32Array(triCount * 3);
for (let i = 0; i < triCount * 3; i++) {
const x = posAttr.getX(i), y = posAttr.getY(i), z = posAttr.getZ(i);
const key = `${Math.round(x*QUANT)}_${Math.round(y*QUANT)}_${Math.round(z*QUANT)}`;
let id = posToId.get(key);
if (id === undefined) { id = nextId++; posToId.set(key, id); }
vertId[i] = id;
}
// nextId^2 < MAX_SAFE_INTEGER → safe up to ~94M unique vertices
const numEdgeKey = (a, b) => a < b ? a * nextId + b : b * nextId + a;
const edgeMap = new Map(); const edgeMap = new Map();
const makeEdgeKey = (ax, ay, az, bx, by, bz) => { const edgePairs = [0, 1, 0, 2, 1, 2]; // vertex-index pairs within triangle
const ka = quantKey(ax, ay, az);
const kb = quantKey(bx, by, bz);
return ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`;
};
for (let t = 0; t < triCount; t++) { for (let t = 0; t < triCount; t++) {
const i = t * 3; const base = t * 3;
vA.fromBufferAttribute(posAttr, i); for (let e = 0; e < 6; e += 2) {
vB.fromBufferAttribute(posAttr, i + 1); const ek = numEdgeKey(vertId[base + edgePairs[e]], vertId[base + edgePairs[e + 1]]);
vC.fromBufferAttribute(posAttr, i + 2);
const ekAB = makeEdgeKey(vA.x, vA.y, vA.z, vB.x, vB.y, vB.z);
const ekBC = makeEdgeKey(vB.x, vB.y, vB.z, vC.x, vC.y, vC.z);
const ekCA = makeEdgeKey(vC.x, vC.y, vC.z, vA.x, vA.y, vA.z);
for (const ek of [ekAB, ekBC, ekCA]) {
const entry = edgeMap.get(ek); const entry = edgeMap.get(ek);
if (entry) entry.push(t); if (entry) entry.push(t);
else edgeMap.set(ek, [t]); else edgeMap.set(ek, [t]);
@@ -96,8 +99,9 @@ export function buildAdjacency(geometry) {
} }
// Convert edge map to adjacency list with per-edge dihedral angle // Convert edge map to adjacency list with per-edge dihedral angle
const adjacency = new Map(); // Array from buildAdjacency
for (let t = 0; t < triCount; t++) adjacency.set(t, []); const adjacency = new Array(triCount);
for (let t = 0; t < triCount; t++) adjacency[t] = [];
for (const [, tris] of edgeMap) { for (const [, tris] of edgeMap) {
if (tris.length !== 2) continue; if (tris.length !== 2) continue;
@@ -106,8 +110,8 @@ export function buildAdjacency(geometry) {
const nBx = faceNormals[b * 3], nBy = faceNormals[b * 3 + 1], nBz = faceNormals[b * 3 + 2]; const nBx = faceNormals[b * 3], nBy = faceNormals[b * 3 + 1], nBz = faceNormals[b * 3 + 2];
const dot = Math.max(-1, Math.min(1, nAx * nBx + nAy * nBy + nAz * nBz)); const dot = Math.max(-1, Math.min(1, nAx * nBx + nAy * nBy + nAz * nBz));
const angleDeg = Math.acos(dot) * (180 / Math.PI); const angleDeg = Math.acos(dot) * (180 / Math.PI);
adjacency.get(a).push({ neighbor: b, angle: angleDeg }); adjacency[a].push({ neighbor: b, angle: angleDeg });
adjacency.get(b).push({ neighbor: a, angle: angleDeg }); adjacency[b].push({ neighbor: a, angle: angleDeg });
} }
return { adjacency, centroids, boundRadii }; return { adjacency, centroids, boundRadii };
@@ -120,16 +124,17 @@ export function buildAdjacency(geometry) {
* Spreads across edges whose dihedral angle ≤ thresholdDeg. * Spreads across edges whose dihedral angle ≤ thresholdDeg.
* *
* @param {number} seedTriIdx * @param {number} seedTriIdx
* @param {Map<number, Array<{neighbor:number, angle:number}>>} adjacency * @param {Array<Array<{neighbor:number, angle:number}>>} adjacency
* @param {number} thresholdDeg * @param {number} thresholdDeg
* @returns {Set<number>} set of triangle indices in the filled region * @returns {Set<number>} set of triangle indices in the filled region
*/ */
export function bucketFill(seedTriIdx, adjacency, thresholdDeg) { export function bucketFill(seedTriIdx, adjacency, thresholdDeg) {
const visited = new Set([seedTriIdx]); const visited = new Set([seedTriIdx]);
const queue = [seedTriIdx]; const queue = [seedTriIdx];
while (queue.length > 0) { let head = 0;
const cur = queue.shift(); while (head < queue.length) {
const neighbors = adjacency.get(cur); const cur = queue[head++];
const neighbors = adjacency[cur];
if (!neighbors) continue; if (!neighbors) continue;
for (const { neighbor, angle } of neighbors) { for (const { neighbor, angle } of neighbors) {
if (!visited.has(neighbor) && angle <= thresholdDeg) { if (!visited.has(neighbor) && angle <= thresholdDeg) {
@@ -163,15 +168,15 @@ export function buildExclusionOverlayGeo(geometry, faceSet, invert = false) {
for (let t = 0; t < total; t++) { for (let t = 0; t < total; t++) {
if (faceSet.has(t)) continue; if (faceSet.has(t)) continue;
const src = t * 9; const src = t * 9;
for (let i = 0; i < 9; i++) outPos[dst + i] = srcPos[src + i]; outPos.set(srcPos.subarray(src, src + 9), dst);
if (outNrm) for (let i = 0; i < 9; i++) outNrm[dst + i] = srcNrm[src + i]; if (outNrm) outNrm.set(srcNrm.subarray(src, src + 9), dst);
dst += 9; dst += 9;
} }
} else { } else {
for (const t of faceSet) { for (const t of faceSet) {
const src = t * 9; const src = t * 9;
for (let i = 0; i < 9; i++) outPos[dst + i] = srcPos[src + i]; outPos.set(srcPos.subarray(src, src + 9), dst);
if (outNrm) for (let i = 0; i < 9; i++) outNrm[dst + i] = srcNrm[src + i]; if (outNrm) outNrm.set(srcNrm.subarray(src, src + 9), dst);
dst += 9; dst += 9;
} }
} }
+23 -22
View File
@@ -112,6 +112,8 @@ export function computeUV(pos, normal, mode, settings, bounds) {
const scaleV = (settings.scaleV) / aV; const scaleV = (settings.scaleV) / aV;
const { offsetU, offsetV } = settings; const { offsetU, offsetV } = settings;
const rotRad = (settings.rotation ?? 0) * Math.PI / 180; const rotRad = (settings.rotation ?? 0) * Math.PI / 180;
const cosR = Math.cos(rotRad);
const sinR = Math.sin(rotRad);
const maxDim = Math.max(size.x, size.y, size.z); const maxDim = Math.max(size.x, size.y, size.z);
const md = Math.max(maxDim, 1e-6); const md = Math.max(maxDim, 1e-6);
@@ -161,14 +163,14 @@ export function computeUV(pos, normal, mode, settings, bounds) {
const d = uRaw < 0.5 ? uRaw : uRaw - 1.0; const d = uRaw < 0.5 ? uRaw : uRaw - 1.0;
const tRaw = (d + seamBand) / (2.0 * seamBand); const tRaw = (d + seamBand) / (2.0 * seamBand);
const t = tRaw * tRaw * (3 - 2 * tRaw); // smoothstep const t = tRaw * tRaw * (3 - 2 * tRaw); // smoothstep
const tLeft = applyTransform(1.0 + d, vSide, scaleU, scaleV, offsetU, offsetV, rotRad); const tLeft = applyTransform(1.0 + d, vSide, scaleU, scaleV, offsetU, offsetV, cosR, sinR);
const tRight = applyTransform(d, vSide, scaleU, scaleV, offsetU, offsetV, rotRad); const tRight = applyTransform(d, vSide, scaleU, scaleV, offsetU, offsetV, cosR, sinR);
sideSamples = [ sideSamples = [
{ u: tRight.u, v: tRight.v, w: t }, { u: tRight.u, v: tRight.v, w: t },
{ u: tLeft.u, v: tLeft.v, w: 1 - t }, { u: tLeft.u, v: tLeft.v, w: 1 - t },
]; ];
} else { } else {
const tSide = applyTransform(uRaw, vSide, scaleU, scaleV, offsetU, offsetV, rotRad); const tSide = applyTransform(uRaw, vSide, scaleU, scaleV, offsetU, offsetV, cosR, sinR);
sideSamples = [{ u: tSide.u, v: tSide.v, w: 1 }]; sideSamples = [{ u: tSide.u, v: tSide.v, w: 1 }];
} }
@@ -189,7 +191,7 @@ export function computeUV(pos, normal, mode, settings, bounds) {
const uCap = rx / C + 0.5; const uCap = rx / C + 0.5;
const vCap = ry / C + 0.5; const vCap = ry / C + 0.5;
const tCap = applyTransform(uCap, vCap, scaleU, scaleV, offsetU, offsetV, rotRad); const tCap = applyTransform(uCap, vCap, scaleU, scaleV, offsetU, offsetV, cosR, sinR);
if (capW >= 1) { if (capW >= 1) {
return tCap; return tCap;
@@ -218,8 +220,8 @@ export function computeUV(pos, normal, mode, settings, bounds) {
const d = uRaw < 0.5 ? uRaw : uRaw - 1.0; const d = uRaw < 0.5 ? uRaw : uRaw - 1.0;
const tRaw = (d + seamBand) / (2.0 * seamBand); const tRaw = (d + seamBand) / (2.0 * seamBand);
const t = tRaw * tRaw * (3 - 2 * tRaw); // smoothstep const t = tRaw * tRaw * (3 - 2 * tRaw); // smoothstep
const tLeft = applyTransform(1.0 + d, vRaw, scaleU, scaleV, offsetU, offsetV, rotRad); const tLeft = applyTransform(1.0 + d, vRaw, scaleU, scaleV, offsetU, offsetV, cosR, sinR);
const tRight = applyTransform(d, vRaw, scaleU, scaleV, offsetU, offsetV, rotRad); const tRight = applyTransform(d, vRaw, scaleU, scaleV, offsetU, offsetV, cosR, sinR);
return { return {
triplanar: true, triplanar: true,
samples: [ samples: [
@@ -244,9 +246,9 @@ export function computeUV(pos, normal, mode, settings, bounds) {
if (normal.y > 0) xzU = -xzU; if (normal.y > 0) xzU = -xzU;
let xyU = (pos.x - min.x) / md; let xyU = (pos.x - min.x) / md;
if (normal.z < 0) xyU = -xyU; if (normal.z < 0) xyU = -xyU;
const tYZ = applyTransform(yzU, (pos.z - min.z) / md, scaleU, scaleV, offsetU, offsetV, rotRad); const tYZ = applyTransform(yzU, (pos.z - min.z) / md, scaleU, scaleV, offsetU, offsetV, cosR, sinR);
const tXZ = applyTransform(xzU, (pos.z - min.z) / md, scaleU, scaleV, offsetU, offsetV, rotRad); const tXZ = applyTransform(xzU, (pos.z - min.z) / md, scaleU, scaleV, offsetU, offsetV, cosR, sinR);
const tXY = applyTransform(xyU, (pos.y - min.y) / md, scaleU, scaleV, offsetU, offsetV, rotRad); const tXY = applyTransform(xyU, (pos.y - min.y) / md, scaleU, scaleV, offsetU, offsetV, cosR, sinR);
if (weights.x > 0.999) return tYZ; if (weights.x > 0.999) return tYZ;
if (weights.y > 0.999) return tXZ; if (weights.y > 0.999) return tXZ;
@@ -268,10 +270,10 @@ export function computeUV(pos, normal, mode, settings, bounds) {
const ax = Math.abs(normal.x); const ax = Math.abs(normal.x);
const ay = Math.abs(normal.y); const ay = Math.abs(normal.y);
const az = Math.abs(normal.z); const az = Math.abs(normal.z);
const pw = 4.0; const ax2 = ax * ax, ay2 = ay * ay, az2 = az * az;
const bx = Math.pow(ax, pw); const bx = ax2 * ax2;
const by = Math.pow(ay, pw); const by = ay2 * ay2;
const bz = Math.pow(az, pw); const bz = az2 * az2;
const sum = bx + by + bz + 1e-6; const sum = bx + by + bz + 1e-6;
const wx = bx / sum; const wx = bx / sum;
const wy = by / sum; const wy = by / sum;
@@ -304,25 +306,24 @@ export function computeUV(pos, normal, mode, settings, bounds) {
return { return {
triplanar: true, triplanar: true,
samples: [ samples: [
{ ...applyTransform(uvXY.u, uvXY.v, scaleU, scaleV, offsetU, offsetV, rotRad), w: uvXY.w }, { ...applyTransform(uvXY.u, uvXY.v, scaleU, scaleV, offsetU, offsetV, cosR, sinR), w: uvXY.w },
{ ...applyTransform(uvXZ.u, uvXZ.v, scaleU, scaleV, offsetU, offsetV, rotRad), w: uvXZ.w }, { ...applyTransform(uvXZ.u, uvXZ.v, scaleU, scaleV, offsetU, offsetV, cosR, sinR), w: uvXZ.w },
{ ...applyTransform(uvYZ.u, uvYZ.v, scaleU, scaleV, offsetU, offsetV, rotRad), w: uvYZ.w }, { ...applyTransform(uvYZ.u, uvYZ.v, scaleU, scaleV, offsetU, offsetV, cosR, sinR), w: uvYZ.w },
], ],
}; };
} }
} }
return applyTransform(u, v, scaleU, scaleV, offsetU, offsetV, rotRad); return applyTransform(u, v, scaleU, scaleV, offsetU, offsetV, cosR, sinR);
} }
function applyTransform(u, v, scaleU, scaleV, offsetU, offsetV, rotRad) { function applyTransform(u, v, scaleU, scaleV, offsetU, offsetV, cosR, sinR) {
let uu = u / scaleU + offsetU; let uu = u / scaleU + offsetU;
let vv = v / scaleV + offsetV; let vv = v / scaleV + offsetV;
if (rotRad !== 0) { if (cosR !== 1 || sinR !== 0) {
const c = Math.cos(rotRad), s = Math.sin(rotRad);
uu -= 0.5; vv -= 0.5; uu -= 0.5; vv -= 0.5;
const ru = c * uu - s * vv; const ru = cosR * uu - sinR * vv;
const rv = s * uu + c * vv; const rv = sinR * uu + cosR * vv;
uu = ru + 0.5; vv = rv + 0.5; uu = ru + 0.5; vv = rv + 0.5;
} }
return { triplanar: false, u: fract(uu), v: fract(vv) }; return { triplanar: false, u: fract(uu), v: fract(vv) };
+10 -8
View File
@@ -119,13 +119,17 @@ export async function subdivide(geometry, maxEdgeLength, onProgress, faceWeights
function subdividePass(positions, normals, weights, indices, maxEdgeLength, safetyCap, faceExcluded = null, canonIdx = null, posCanonMap = null, faceParentId = null) { function subdividePass(positions, normals, weights, indices, maxEdgeLength, safetyCap, faceExcluded = null, canonIdx = null, posCanonMap = null, faceParentId = null) {
const maxSq = maxEdgeLength * maxEdgeLength; const maxSq = maxEdgeLength * maxEdgeLength;
const midCache = new Map(); const midCache = new Map();
midCache._maxV = positions.length / 3; // set before any getMidpoint calls
// When canonIdx is available (accurate/export mode), use position-canonical // When canonIdx is available (accurate/export mode), use position-canonical
// edge keys so split-vertex faces on both sides of a sharp edge see the same // edge keys so split-vertex faces on both sides of a sharp edge see the same
// split decision. Otherwise (fast/preview mode) use simple index-based keys. // split decision. Otherwise (fast/preview mode) use simple index-based keys.
// NOTE: _maxV is computed once at pass start. During the pass, positions grows
// via getMidpoint, but splitEdges and midCache only use indices < _maxV.
const _maxV = positions.length / 3;
const _edgeKey = canonIdx const _edgeKey = canonIdx
? (a, b) => { const ca = canonIdx[a], cb = canonIdx[b]; return ca < cb ? `${ca}:${cb}` : `${cb}:${ca}`; } ? (a, b) => { const ca = canonIdx[a], cb = canonIdx[b]; return ca < cb ? ca * _maxV + cb : cb * _maxV + ca; }
: (a, b) => a < b ? `${a}:${b}` : `${b}:${a}`; : (a, b) => a < b ? a * _maxV + b : b * _maxV + a;
// ── Step 1: globally mark edges that need splitting ───────────────────── // ── Step 1: globally mark edges that need splitting ─────────────────────
// Excluded triangles do NOT proactively mark their own edges their // Excluded triangles do NOT proactively mark their own edges their
@@ -264,11 +268,6 @@ function subdividePass(positions, normals, weights, indices, maxEdgeLength, safe
// ── Helpers ────────────────────────────────────────────────────────────────── // ── 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) { function edgeLenSq(pos, a, b) {
const dx = pos[a*3] - pos[b*3]; const dx = pos[a*3] - pos[b*3];
const dy = pos[a*3+1] - pos[b*3+1]; const dy = pos[a*3+1] - pos[b*3+1];
@@ -277,7 +276,10 @@ function edgeLenSq(pos, a, b) {
} }
function getMidpoint(positions, normals, weights, cache, a, b, canonIdx, posCanonMap) { function getMidpoint(positions, normals, weights, cache, a, b, canonIdx, posCanonMap) {
const key = a < b ? `${a}:${b}` : `${b}:${a}`; // Numeric edge key: uses _maxV set on the cache by subdividePass at pass start.
// All lookups use indices < _maxV, so this is safe even as positions grows.
const _mv = cache._maxV;
const key = a < b ? a * _mv + b : b * _mv + a;
if (cache.has(key)) return cache.get(key); if (cache.has(key)) return cache.get(key);
// Midpoint position // Midpoint position