From 51873fd5fce90e8042a059657c0bb0b96b76ddf2 Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Mon, 6 Apr 2026 02:38:13 +0200 Subject: [PATCH] perf: replace string-key maps with numeric flat arrays in hot paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- js/displacement.js | 153 ++++++++++++++++++++++----------------------- js/exclusion.js | 61 +++++++++--------- js/mapping.js | 45 ++++++------- js/subdivision.js | 18 +++--- 4 files changed, 142 insertions(+), 135 deletions(-) diff --git a/js/displacement.js b/js/displacement.js index 8eb46b0..6821716 100644 --- a/js/displacement.js +++ b/js/displacement.js @@ -45,8 +45,6 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett const settingsWithAspect = { ...settings, textureAspectU: aspectU, textureAspectV: aspectV }; const QUANT = 1e4; - const posKey = (x, y, z) => - `${Math.round(x * QUANT)}_${Math.round(y * QUANT)}_${Math.round(z * QUANT)}`; // ── WHY GAPS HAPPEN ─────────────────────────────────────────────────────── // 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), // 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 ─── - // Map: posKey → [nx, ny, nz] (unnormalised sum) - const smoothNrmMap = new Map(); - // zoneAreaMap: posKey → [xArea, yArea, zArea] (cubic mapping only) - // Tracks the total adjacent face area in each cubic projection zone (X/Y/Z dominant). - // Seam-edge vertices that border two zones get a blend proportional to face area, - // eliminating the mixed-projection artefact on seam-crossing triangles. - const zoneAreaMap = new Map(); - // maskedFracMap: posKey → [maskedArea, totalArea] - // Tracks the fraction of surrounding face area that is masked so boundary - // vertices get a smooth displacement blend instead of a hard on/off cutoff. - const maskedFracMap = new Map(); + // Flat arrays indexed by vertex dedup ID (replaces Map) + const smoothNrmX = new Float64Array(uniqueCount); + const smoothNrmY = new Float64Array(uniqueCount); + const smoothNrmZ = new Float64Array(uniqueCount); + + // zoneArea: per-axis face area for cubic mapping (replaces zoneAreaMap) + const zoneAreaX = new Float64Array(uniqueCount); + const zoneAreaY = new Float64Array(uniqueCount); + const zoneAreaZ = new Float64Array(uniqueCount); + + // 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. // A face's user-exclusion flag = average of its 3 vertex weights > 0.99. 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 - // 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; - // Positions that belong to at least one user-excluded face. Any included-face - // vertex whose original position is in this set sits on the seam boundary; we - // 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 - // boundary, which causes the QEM decimator to treat the excluded patch as an - // isolated open-mesh island and collapse it to nothing (missing triangles). - const excludedPosSet = ewAttr ? new Set() : null; + // Positions that belong to at least one user-excluded face (replaces excludedPosSet). + const excludedPos = ewAttr ? new Uint8Array(uniqueCount) : null; + + // Displacement cache: one sample per unique vertex (replaces dispCache Map) + const dispCacheVal = new Float64Array(uniqueCount); + const dispCacheSet = new Uint8Array(uniqueCount); for (let t = 0; t < count; t += 3) { vA.fromBufferAttribute(posAttr, t); @@ -141,9 +153,8 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett } for (let v = 0; v < 3; v++) { - tmpPos.fromBufferAttribute(posAttr, t + v); - const k = posKey(tmpPos.x, tmpPos.y, tmpPos.z); - if (userExcluded && excludedPosSet) excludedPosSet.add(k); + const vid = vertexId[t + v]; + if (userExcluded && excludedPos) excludedPos[vid] = 1; // Use the buffer normal (from subdivision) weighted by face area. // The subdivision pipeline splits indexed vertices at sharp dihedral // 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 // preserving hard edges. tmpNrm.fromBufferAttribute(nrmAttr, t + v); - const existing = smoothNrmMap.get(k); - if (existing) { - existing[0] += tmpNrm.x * faceArea; - existing[1] += tmpNrm.y * faceArea; - existing[2] += tmpNrm.z * faceArea; - } else { - smoothNrmMap.set(k, [tmpNrm.x * faceArea, tmpNrm.y * faceArea, tmpNrm.z * faceArea]); - } + smoothNrmX[vid] += tmpNrm.x * faceArea; + smoothNrmY[vid] += tmpNrm.y * faceArea; + smoothNrmZ[vid] += tmpNrm.z * faceArea; if (czX > 1e-12 || czY > 1e-12 || czZ > 1e-12) { - const za = zoneAreaMap.get(k); - if (za) { za[0] += czX; za[1] += czY; za[2] += czZ; } - else { zoneAreaMap.set(k, [czX, czY, czZ]); } - } - const mf = maskedFracMap.get(k); - if (mf) { - if (faceMasked) mf[0] += faceArea; - mf[1] += faceArea; - } else { - maskedFracMap.set(k, [faceMasked ? faceArea : 0, faceArea]); + zoneAreaX[vid] += czX; + zoneAreaY[vid] += czY; + zoneAreaZ[vid] += czZ; } + if (faceMasked) maskedFracMasked[vid] += faceArea; + maskedFracTotal[vid] += faceArea; } } // Normalise each accumulated normal - smoothNrmMap.forEach((n) => { - const len = Math.sqrt(n[0]*n[0] + n[1]*n[1] + n[2]*n[2]) || 1; - n[0] /= len; n[1] /= len; n[2] /= len; - }); + for (let id = 0; id < uniqueCount; id++) { + const len = Math.sqrt(smoothNrmX[id]*smoothNrmX[id] + smoothNrmY[id]*smoothNrmY[id] + smoothNrmZ[id]*smoothNrmZ[id]) || 1; + smoothNrmX[id] /= len; smoothNrmY[id] /= len; smoothNrmZ[id] /= len; + } // ── Pass 2: sample displacement texture once per unique position ────────── - const dispCache = new Map(); // posKey → grey [0, 1] for (let i = 0; i < count; i++) { - tmpPos.fromBufferAttribute(posAttr, i); - const k = posKey(tmpPos.x, tmpPos.y, tmpPos.z); - if (dispCache.has(k)) continue; + const vid = vertexId[i]; + if (dispCacheSet[vid]) 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. // 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, // leaving a horizontal smooth normal. computeUV would then pick the wrong // cubic projection axis, making those faces appear untextured. The face- - // normal-based zoneAreaMap is immune to this because it classifies faces by - // their geometric cross-product normal, not the averaged vertex normal. + // normal-based zoneArea arrays are immune to this because they classify faces + // by their geometric cross-product normal, not the averaged vertex normal. if (settings.mappingMode === 6 /* MODE_CUBIC */) { - const za = zoneAreaMap.get(k); - const total = za ? za[0] + za[1] + za[2] : 0; + const zaX = zoneAreaX[vid], zaY = zoneAreaY[vid], zaZ = zoneAreaZ[vid]; + const total = zaX + zaY + zaZ; if (total > 0) { const md = Math.max(bounds.size.x, bounds.size.y, bounds.size.z, 1e-6); const rotRad = (settings.rotation ?? 0) * Math.PI / 180; 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; - 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); - 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; - 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); - 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; - 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); - 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; } } - 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); let grey; @@ -245,7 +245,7 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett } else { 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 ───────────────── @@ -258,9 +258,8 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett tmpPos.fromBufferAttribute(posAttr, i); tmpNrm.fromBufferAttribute(nrmAttr, i); - const k = posKey(tmpPos.x, tmpPos.y, tmpPos.z); - const sn = smoothNrmMap.get(k); - const grey = dispCache.get(k); + const vid = vertexId[i]; + const grey = dispCacheVal[vid]; // User-excluded faces get zero displacement; only angle-based masking uses // 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. // 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. - const isSealedBoundary = !isFaceExcluded && excludedPosSet && excludedPosSet.has(k); - const mf = maskedFracMap.get(k) || [0, 1]; - const maskedFrac = mf[1] > 0 ? mf[0] / mf[1] : 0; + const isSealedBoundary = !isFaceExcluded && excludedPos && excludedPos[vid] === 1; + const mfTotal = maskedFracTotal[vid]; + const maskedFrac = mfTotal > 0 ? maskedFracMasked[vid] / mfTotal : 0; const centeredGrey = settings.symmetricDisplacement ? (grey - 0.5) : grey; const disp = (isFaceExcluded || isSealedBoundary) ? 0 : (1 - maskedFrac) * centeredGrey * settings.amplitude; - const newX = tmpPos.x + sn[0] * disp; - const newY = tmpPos.y + sn[1] * disp; - let newZ = tmpPos.z + sn[2] * disp; + const newX = tmpPos.x + smoothNrmX[vid] * disp; + const newY = tmpPos.y + smoothNrmY[vid] * disp; + let newZ = tmpPos.z + smoothNrmZ[vid] * disp; // Prevent boundary vertices from poking through the masked surface in Z. // Only triggers for vertices that are partly masked (maskedFrac > 0) and diff --git a/js/exclusion.js b/js/exclusion.js index b379d30..f67aecf 100644 --- a/js/exclusion.js +++ b/js/exclusion.js @@ -23,7 +23,7 @@ const quantKey = (x, y, z) => * * @param {THREE.BufferGeometry} geometry – non-indexed * @returns {{ - * adjacency: Map>, + * adjacency: Array>, * 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 // 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 makeEdgeKey = (ax, ay, az, bx, by, bz) => { - const ka = quantKey(ax, ay, az); - const kb = quantKey(bx, by, bz); - return ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`; - }; + const edgePairs = [0, 1, 0, 2, 1, 2]; // vertex-index pairs within triangle for (let t = 0; t < triCount; t++) { - const i = t * 3; - vA.fromBufferAttribute(posAttr, i); - vB.fromBufferAttribute(posAttr, i + 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 base = t * 3; + for (let e = 0; e < 6; e += 2) { + const ek = numEdgeKey(vertId[base + edgePairs[e]], vertId[base + edgePairs[e + 1]]); const entry = edgeMap.get(ek); if (entry) entry.push(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 - const adjacency = new Map(); - for (let t = 0; t < triCount; t++) adjacency.set(t, []); + // Array from buildAdjacency + const adjacency = new Array(triCount); + for (let t = 0; t < triCount; t++) adjacency[t] = []; for (const [, tris] of edgeMap) { 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 dot = Math.max(-1, Math.min(1, nAx * nBx + nAy * nBy + nAz * nBz)); const angleDeg = Math.acos(dot) * (180 / Math.PI); - adjacency.get(a).push({ neighbor: b, angle: angleDeg }); - adjacency.get(b).push({ neighbor: a, angle: angleDeg }); + adjacency[a].push({ neighbor: b, angle: angleDeg }); + adjacency[b].push({ neighbor: a, angle: angleDeg }); } return { adjacency, centroids, boundRadii }; @@ -120,16 +124,17 @@ export function buildAdjacency(geometry) { * Spreads across edges whose dihedral angle ≤ thresholdDeg. * * @param {number} seedTriIdx - * @param {Map>} adjacency + * @param {Array>} adjacency * @param {number} thresholdDeg * @returns {Set} set of triangle indices in the filled region */ export function bucketFill(seedTriIdx, adjacency, thresholdDeg) { const visited = new Set([seedTriIdx]); const queue = [seedTriIdx]; - while (queue.length > 0) { - const cur = queue.shift(); - const neighbors = adjacency.get(cur); + let head = 0; + while (head < queue.length) { + const cur = queue[head++]; + const neighbors = adjacency[cur]; if (!neighbors) continue; for (const { neighbor, angle } of neighbors) { if (!visited.has(neighbor) && angle <= thresholdDeg) { @@ -163,15 +168,15 @@ export function buildExclusionOverlayGeo(geometry, faceSet, invert = false) { for (let t = 0; t < total; t++) { if (faceSet.has(t)) continue; const src = t * 9; - for (let i = 0; i < 9; i++) outPos[dst + i] = srcPos[src + i]; - if (outNrm) for (let i = 0; i < 9; i++) outNrm[dst + i] = srcNrm[src + i]; + outPos.set(srcPos.subarray(src, src + 9), dst); + if (outNrm) outNrm.set(srcNrm.subarray(src, src + 9), dst); dst += 9; } } else { for (const t of faceSet) { const src = t * 9; - for (let i = 0; i < 9; i++) outPos[dst + i] = srcPos[src + i]; - if (outNrm) for (let i = 0; i < 9; i++) outNrm[dst + i] = srcNrm[src + i]; + outPos.set(srcPos.subarray(src, src + 9), dst); + if (outNrm) outNrm.set(srcNrm.subarray(src, src + 9), dst); dst += 9; } } diff --git a/js/mapping.js b/js/mapping.js index 4b56b72..356ea57 100644 --- a/js/mapping.js +++ b/js/mapping.js @@ -112,6 +112,8 @@ export function computeUV(pos, normal, mode, settings, bounds) { const scaleV = (settings.scaleV) / aV; const { offsetU, offsetV } = settings; 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 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 tRaw = (d + seamBand) / (2.0 * seamBand); const t = tRaw * tRaw * (3 - 2 * tRaw); // smoothstep - const tLeft = applyTransform(1.0 + d, vSide, scaleU, scaleV, offsetU, offsetV, rotRad); - const tRight = applyTransform(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, cosR, sinR); sideSamples = [ { u: tRight.u, v: tRight.v, w: t }, { u: tLeft.u, v: tLeft.v, w: 1 - t }, ]; } 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 }]; } @@ -189,7 +191,7 @@ export function computeUV(pos, normal, mode, settings, bounds) { const uCap = rx / 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) { return tCap; @@ -218,8 +220,8 @@ export function computeUV(pos, normal, mode, settings, bounds) { const d = uRaw < 0.5 ? uRaw : uRaw - 1.0; const tRaw = (d + seamBand) / (2.0 * seamBand); const t = tRaw * tRaw * (3 - 2 * tRaw); // smoothstep - const tLeft = applyTransform(1.0 + d, vRaw, scaleU, scaleV, offsetU, offsetV, rotRad); - const tRight = applyTransform(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, cosR, sinR); return { triplanar: true, samples: [ @@ -244,9 +246,9 @@ export function computeUV(pos, normal, mode, settings, bounds) { if (normal.y > 0) xzU = -xzU; let xyU = (pos.x - min.x) / md; if (normal.z < 0) xyU = -xyU; - const tYZ = applyTransform(yzU, (pos.z - min.z) / md, scaleU, scaleV, offsetU, offsetV, rotRad); - const tXZ = applyTransform(xzU, (pos.z - min.z) / md, scaleU, scaleV, offsetU, offsetV, rotRad); - const tXY = applyTransform(xyU, (pos.y - min.y) / 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, cosR, sinR); + const tXY = applyTransform(xyU, (pos.y - min.y) / md, scaleU, scaleV, offsetU, offsetV, cosR, sinR); if (weights.x > 0.999) return tYZ; 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 ay = Math.abs(normal.y); const az = Math.abs(normal.z); - const pw = 4.0; - const bx = Math.pow(ax, pw); - const by = Math.pow(ay, pw); - const bz = Math.pow(az, pw); + const ax2 = ax * ax, ay2 = ay * ay, az2 = az * az; + const bx = ax2 * ax2; + const by = ay2 * ay2; + const bz = az2 * az2; const sum = bx + by + bz + 1e-6; const wx = bx / sum; const wy = by / sum; @@ -304,25 +306,24 @@ export function computeUV(pos, normal, mode, settings, bounds) { return { triplanar: true, samples: [ - { ...applyTransform(uvXY.u, uvXY.v, scaleU, scaleV, offsetU, offsetV, rotRad), w: uvXY.w }, - { ...applyTransform(uvXZ.u, uvXZ.v, scaleU, scaleV, offsetU, offsetV, rotRad), w: uvXZ.w }, - { ...applyTransform(uvYZ.u, uvYZ.v, scaleU, scaleV, offsetU, offsetV, rotRad), w: uvYZ.w }, + { ...applyTransform(uvXY.u, uvXY.v, scaleU, scaleV, offsetU, offsetV, cosR, sinR), w: uvXY.w }, + { ...applyTransform(uvXZ.u, uvXZ.v, scaleU, scaleV, offsetU, offsetV, cosR, sinR), w: uvXZ.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 vv = v / scaleV + offsetV; - if (rotRad !== 0) { - const c = Math.cos(rotRad), s = Math.sin(rotRad); + if (cosR !== 1 || sinR !== 0) { uu -= 0.5; vv -= 0.5; - const ru = c * uu - s * vv; - const rv = s * uu + c * vv; + const ru = cosR * uu - sinR * vv; + const rv = sinR * uu + cosR * vv; uu = ru + 0.5; vv = rv + 0.5; } return { triplanar: false, u: fract(uu), v: fract(vv) }; diff --git a/js/subdivision.js b/js/subdivision.js index 37b6ad7..1889c33 100644 --- a/js/subdivision.js +++ b/js/subdivision.js @@ -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) { const maxSq = maxEdgeLength * maxEdgeLength; const midCache = new Map(); + midCache._maxV = positions.length / 3; // set before any getMidpoint calls // 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 // 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 - ? (a, b) => { const ca = canonIdx[a], cb = canonIdx[b]; return ca < cb ? `${ca}:${cb}` : `${cb}:${ca}`; } - : (a, b) => a < b ? `${a}:${b}` : `${b}:${a}`; + ? (a, b) => { const ca = canonIdx[a], cb = canonIdx[b]; return ca < cb ? ca * _maxV + cb : cb * _maxV + ca; } + : (a, b) => a < b ? a * _maxV + b : b * _maxV + a; // ── Step 1: globally mark edges that need splitting ───────────────────── // Excluded triangles do NOT proactively mark their own edges – their @@ -264,11 +268,6 @@ function subdividePass(positions, normals, weights, indices, maxEdgeLength, safe // ── 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]; @@ -277,7 +276,10 @@ function edgeLenSq(pos, a, b) { } 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); // Midpoint position