From 51873fd5fce90e8042a059657c0bb0b96b76ddf2 Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Mon, 6 Apr 2026 02:38:13 +0200 Subject: [PATCH 1/8] 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 From d92296754f5013b41234fda0ab315287a85073cf Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Mon, 6 Apr 2026 02:38:30 +0200 Subject: [PATCH 2/8] perf: on-demand rendering, dispose leaks, reduce GC pressure - Render loop now only calls renderer.render() when the scene actually changed (needsRender flag + requestRender export). Idle GPU usage drops to near zero. - Disabled shadow map (no receiver in scene, wasted a full render pass). - Reuse overlay materials instead of creating new ones every paint frame. - Dispose CanvasTexture in getEffectiveMapEntry (VRAM leak on every slider change). - Dispose axes/dimension geometry on model reload. - Reuse Vector3/Quaternion temp objects in pointer/touch/wheel handlers instead of allocating ~10 objects per mouse event. - RAF-batch mousemove for hover/cursor, keep paint events immediate. - Reuse faceMask buffer attribute when size matches. - Cache getEffectiveMapEntry result (skip canvas tiling+blur when texture and smoothing haven't changed). - addSmoothNormals: same dedup+flat-array approach as displacement. --- js/main.js | 168 +++++++++++++++++++++++++++++++++------------------ js/viewer.js | 142 ++++++++++++++++++++++++++++--------------- 2 files changed, 203 insertions(+), 107 deletions(-) diff --git a/js/main.js b/js/main.js index 93fc802..44ff2b3 100644 --- a/js/main.js +++ b/js/main.js @@ -1,7 +1,8 @@ import * as THREE from 'three'; import { initViewer, loadGeometry, setMeshMaterial, setMeshGeometry, setWireframe, getControls, getCamera, getCurrentMesh, - setExclusionOverlay, setHoverPreview, setViewerTheme } from './viewer.js'; + setExclusionOverlay, setHoverPreview, setViewerTheme, + requestRender } from './viewer.js'; import { loadModelFile, computeBounds, getTriangleCount } from './stlLoader.js'; import { loadPresets, loadCustomTexture } from './presetTextures.js'; import { createPreviewMaterial, updateMaterial } from './previewMaterial.js'; @@ -25,7 +26,7 @@ let previewDebounce = null; // ── Exclusion state ─────────────────────────────────────────────────────────── let excludedFaces = new Set(); // triangle indices in currentGeometry -let triangleAdjacency = null; // Map from buildAdjacency +let triangleAdjacency = null; // Array from buildAdjacency let triangleCentroids = null; // Float32Array from buildAdjacency let triangleBoundRadii = null; // Float32Array — max vertex-to-centroid dist per tri let exclusionTool = null; // 'brush' | 'bucket' | null @@ -38,6 +39,9 @@ let selectionMode = false; // false = exclude painted faces; true = i let _lastHoverTriIdx = -1; // last triangle index used for hover preview let placeOnFaceActive = false; // true while "Place on Face" mode is active const _raycaster = new THREE.Raycaster(); +let _lastEffectiveTexture = null; +let _effectiveMapCache = null; +let _effectiveMapCacheKey = null; const settings = { mappingMode: 5, // Triplanar default @@ -139,7 +143,7 @@ let precisionEdgeLength = null; // edge length used for current refinement let precisionBusy = false; // true while async subdivision is running let precisionCentroids = null; // Float32Array from buildAdjacency on refined mesh let precisionBoundRadii = null; // Float32Array — max vertex-to-centroid per refined tri -let precisionAdjacency = null; // Map from buildAdjacency on refined mesh +let precisionAdjacency = null; // Array from buildAdjacency on refined mesh let precisionExcludedFaces = new Set(); // precision face indices excluded while precision is active // ── Displacement preview state ──────────────────────────────────────────────── @@ -648,23 +652,41 @@ function wireEvents() { } }); + // RAF-Batching: paint events fire immediately, hover/cursor batched per frame + let _pendingHoverEvent = null; + let _hoverRafId = 0; + canvas.addEventListener('mousemove', (e) => { - if (placeOnFaceActive && currentGeometry) { - updatePlaceOnFaceHover(e); - return; - } - if (exclusionTool === 'brush' && brushIsRadius) { - updateBrushCursor(e); - } + // Paint-Events sofort verarbeiten (jeder Event zaehlt fuer lueckenloses Malen) if (isPainting && exclusionTool === 'brush') { paintAt(e); + // Cursor-Update kann warten + _pendingHoverEvent = e; + if (!_hoverRafId) { + _hoverRafId = requestAnimationFrame(() => { + _hoverRafId = 0; + if (_pendingHoverEvent) updateBrushCursor(_pendingHoverEvent); + _pendingHoverEvent = null; + }); + } return; } - if (!isPainting && exclusionTool === 'brush' && currentGeometry) { - updateBrushHover(e); - } - if (!isPainting && exclusionTool === 'bucket' && currentGeometry) { - updateBucketHover(e); + // Alle anderen Hover-Pfade: RAF-Batching OK + _pendingHoverEvent = e; + if (!_hoverRafId) { + _hoverRafId = requestAnimationFrame(() => { + _hoverRafId = 0; + const ev = _pendingHoverEvent; + if (!ev) return; + _pendingHoverEvent = null; + if (placeOnFaceActive && currentGeometry) { updatePlaceOnFaceHover(ev); return; } + if (exclusionTool === 'brush') { + updateBrushCursor(ev); + if (brushIsRadius && !isPainting && currentGeometry) updateBrushHover(ev); + } else if (exclusionTool === 'bucket' && !isPainting && currentGeometry) { + updateBucketHover(ev); + } + }); } }); @@ -748,12 +770,14 @@ function setExclusionTool(tool) { } } +const _ndcResult = new THREE.Vector2(); function _canvasNDC(e) { const rect = canvas.getBoundingClientRect(); - return new THREE.Vector2( + _ndcResult.set( ((e.clientX - rect.left) / rect.width) * 2 - 1, ((e.clientY - rect.top) / rect.height) * -2 + 1, ); + return _ndcResult; } // The preview material uses THREE.DoubleSide, so the raycaster can return @@ -1437,7 +1461,11 @@ function updateFaceMask(geometry) { if (!geometry) return; const posCount = geometry.attributes.position.count; const triCount = posCount / 3; - const maskArr = new Float32Array(posCount); + + // Reuse existing buffer if length matches exactly, otherwise allocate new + const existing = geometry.getAttribute('faceMask'); + const reuseBuffer = existing && existing.array.length === posCount; + const maskArr = reuseBuffer ? existing.array : new Float32Array(posCount); // Determine which face set to check const isPrecision = (geometry === precisionGeometry && precisionMaskingEnabled); @@ -1461,7 +1489,11 @@ function updateFaceMask(geometry) { } } - geometry.setAttribute('faceMask', new THREE.Float32BufferAttribute(maskArr, 1)); + if (reuseBuffer) { + existing.needsUpdate = true; + } else { + geometry.setAttribute('faceMask', new THREE.Float32BufferAttribute(maskArr, 1)); + } // Ensure faceNormal attribute exists (needed by shader for angle masking). // For the original geometry normal == faceNormal; for subdivided geometry @@ -1470,6 +1502,7 @@ function updateFaceMask(geometry) { if (!geometry.attributes.faceNormal) { addFaceNormals(geometry); } + requestRender(); } /** @@ -1602,8 +1635,16 @@ function buildParentFaceMap(subdivGeo) { } function getEffectiveMapEntry() { - if (!activeMapEntry || settings.textureSmoothing === 0) return activeMapEntry; - const { fullCanvas, width, height } = activeMapEntry; + if (!activeMapEntry || settings.textureSmoothing === 0) { + _effectiveMapCache = null; + _effectiveMapCacheKey = null; + return activeMapEntry; + } + const { fullCanvas, width, height, name } = activeMapEntry; + const cacheKey = `${name}_${width}_${height}_${settings.textureSmoothing}`; + if (_effectiveMapCacheKey === cacheKey && _effectiveMapCache) { + return _effectiveMapCache; + } // Tile the source 3×3 before blurring so edge pixels have correct // neighbours and the blurred centre tile is seamlessly tileable. const tiled = document.createElement('canvas'); @@ -1628,7 +1669,11 @@ function getEffectiveMapEntry() { const imageData = offscreen.getContext('2d').getImageData(0, 0, width, height); const texture = new THREE.CanvasTexture(offscreen); texture.wrapS = texture.wrapT = THREE.RepeatWrapping; - return { ...activeMapEntry, imageData, texture }; + if (_lastEffectiveTexture) _lastEffectiveTexture.dispose(); + _lastEffectiveTexture = texture; + _effectiveMapCache = { ...activeMapEntry, imageData, texture }; + _effectiveMapCacheKey = cacheKey; + return _effectiveMapCache; } function updatePreview() { @@ -1717,19 +1762,28 @@ function addFaceNormals(geometry) { function addSmoothNormals(geometry) { const pos = geometry.attributes.position.array; const count = geometry.attributes.position.count; + const nrm = geometry.attributes.normal.array; + // Vertex-dedup pass: assign a numeric ID to each unique quantised position. const QUANT = 1e4; - const key = (x, y, z) => - `${Math.round(x * QUANT)}_${Math.round(y * QUANT)}_${Math.round(z * QUANT)}`; + const dedupMap = new Map(); + let nextId = 0; + const vertId = new Uint32Array(count); + for (let i = 0; i < count; i++) { + const key = `${Math.round(pos[i*3]*QUANT)}_${Math.round(pos[i*3+1]*QUANT)}_${Math.round(pos[i*3+2]*QUANT)}`; + let id = dedupMap.get(key); + if (id === undefined) { id = nextId++; dedupMap.set(key, id); } + vertId[i] = id; + } - // Accumulate area-weighted buffer normals per unique position. + // Accumulate area-weighted buffer normals per unique position into flat arrays. // The subdivision pipeline splits indexed vertices at sharp dihedral edges - // (>30°) so the interpolated buffer normals are smooth across soft edges + // (>30 deg) so the interpolated buffer normals are smooth across soft edges // (cylinder, sphere) but sharp across hard edges (cube). Using these buffer // normals instead of geometric face normals eliminates visible faceting steps // on round surfaces while still preserving hard edges. - const nrmMap = new Map(); - const nrm = geometry.attributes.normal.array; + const uc = nextId; + const snx = new Float64Array(uc), sny = new Float64Array(uc), snz = new Float64Array(uc); const vA = new THREE.Vector3(), vB = new THREE.Vector3(), vC = new THREE.Vector3(); const e1 = new THREE.Vector3(), e2 = new THREE.Vector3(), fn = new THREE.Vector3(); @@ -1744,32 +1798,24 @@ function addSmoothNormals(geometry) { if (area < 1e-12) continue; for (let v = 0; v < 3; v++) { const vi = i + v; - const nx = nrm[vi * 3], ny = nrm[vi * 3 + 1], nz = nrm[vi * 3 + 2]; - const k = key(pos[vi * 3], pos[vi * 3 + 1], pos[vi * 3 + 2]); - const prev = nrmMap.get(k); - if (prev) { - prev[0] += nx * area; - prev[1] += ny * area; - prev[2] += nz * area; - } else { - nrmMap.set(k, [nx * area, ny * area, nz * area]); - } + const id = vertId[vi]; + snx[id] += nrm[vi * 3] * area; + sny[id] += nrm[vi * 3 + 1] * area; + snz[id] += nrm[vi * 3 + 2] * area; } } // Normalize accumulated normals - for (const n of nrmMap.values()) { - const len = Math.sqrt(n[0] * n[0] + n[1] * n[1] + n[2] * n[2]); - if (len > 1e-12) { n[0] /= len; n[1] /= len; n[2] /= len; } + for (let id = 0; id < uc; id++) { + const len = Math.sqrt(snx[id] * snx[id] + sny[id] * sny[id] + snz[id] * snz[id]) || 1; + snx[id] /= len; sny[id] /= len; snz[id] /= len; } - // Write smoothNormal attribute + // Write smoothNormal attribute via vertId lookup const sn = new Float32Array(count * 3); for (let i = 0; i < count; i++) { - const k = key(pos[i * 3], pos[i * 3 + 1], pos[i * 3 + 2]); - const n = nrmMap.get(k); - if (n) { sn[i * 3] = n[0]; sn[i * 3 + 1] = n[1]; sn[i * 3 + 2] = n[2]; } - else { sn[i * 3] = 0; sn[i * 3 + 1] = 0; sn[i * 3 + 2] = 1; } + const id = vertId[i]; + sn[i * 3] = snx[id]; sn[i * 3 + 1] = sny[id]; sn[i * 3 + 2] = snz[id]; } geometry.setAttribute('smoothNormal', new THREE.Float32BufferAttribute(sn, 3)); } @@ -2193,26 +2239,30 @@ async function handleExport() { // Flat-bottom clamp: when bottom faces are masked (bottomAngleLimit > 0), // any vertex that ended up below the original model's bottom layer gets - // snapped back up to that Z. Only the Z-value is changed. + // snapped back up to that Z. Single pass with selective normal recomputation. if (settings.bottomAngleLimit > 0) { const bottomZ = currentBounds.min.z; - const posArr = finalGeometry.attributes.position.array; - for (let i = 2; i < posArr.length; i += 3) { - if (posArr[i] < bottomZ) posArr[i] = bottomZ; - } - finalGeometry.attributes.position.needsUpdate = true; - // Recompute normals via cross product so they always match winding order. const pa = finalGeometry.attributes.position.array; const na = finalGeometry.attributes.normal ? finalGeometry.attributes.normal.array : new Float32Array(pa.length); + for (let i = 0; i < pa.length; i += 9) { - const ux = pa[i+3]-pa[i], uy = pa[i+4]-pa[i+1], uz = pa[i+5]-pa[i+2]; - const vx = pa[i+6]-pa[i], vy = pa[i+7]-pa[i+1], vz = pa[i+8]-pa[i+2]; - const nx = uy*vz-uz*vy, ny = uz*vx-ux*vz, nz = ux*vy-uy*vx; - const len = Math.sqrt(nx*nx+ny*ny+nz*nz) || 1; - na[i] = na[i+3] = na[i+6] = nx/len; - na[i+1] = na[i+4] = na[i+7] = ny/len; - na[i+2] = na[i+5] = na[i+8] = nz/len; + let dirty = false; + if (pa[i+2] < bottomZ) { pa[i+2] = bottomZ; dirty = true; } + if (pa[i+5] < bottomZ) { pa[i+5] = bottomZ; dirty = true; } + if (pa[i+8] < bottomZ) { pa[i+8] = bottomZ; dirty = true; } + + if (dirty) { + const ux = pa[i+3]-pa[i], uy = pa[i+4]-pa[i+1], uz = pa[i+5]-pa[i+2]; + const vx = pa[i+6]-pa[i], vy = pa[i+7]-pa[i+1], vz = pa[i+8]-pa[i+2]; + const nx = uy*vz-uz*vy, ny = uz*vx-ux*vz, nz = ux*vy-uy*vx; + const len = Math.sqrt(nx*nx+ny*ny+nz*nz) || 1; + na[i] = na[i+3] = na[i+6] = nx/len; + na[i+1] = na[i+4] = na[i+7] = ny/len; + na[i+2] = na[i+5] = na[i+8] = nz/len; + } } + + finalGeometry.attributes.position.needsUpdate = true; if (!finalGeometry.attributes.normal) finalGeometry.setAttribute('normal', new THREE.Float32BufferAttribute(na, 3)); else finalGeometry.attributes.normal.needsUpdate = true; } diff --git a/js/viewer.js b/js/viewer.js index 646b66a..6a6fc34 100644 --- a/js/viewer.js +++ b/js/viewer.js @@ -4,6 +4,14 @@ import { LineSegments2 } from 'three/addons/lines/LineSegments2.js'; import { LineSegmentsGeometry } from 'three/addons/lines/LineSegmentsGeometry.js'; import { LineMaterial } from 'three/addons/lines/LineMaterial.js'; +// Pre-allocated temp objects for hot-path event handlers (avoid GC pressure) +const _tmpQ1 = new THREE.Quaternion(); +const _tmpQ2 = new THREE.Quaternion(); +const _tmpV1 = new THREE.Vector3(); +const _tmpV2 = new THREE.Vector3(); +const _tmpV3 = new THREE.Vector3(); +const _tmpV4 = new THREE.Vector3(); + let renderer, camera, scene, controls, meshGroup, ambientLight, dirLight1, dirLight2, grid; let currentMesh = null; let axesGroup = null; @@ -12,6 +20,9 @@ let wireframeLines = null; // LineSegments overlay, or null when hidden let wireframeVisible = false; let exclusionMesh = null; // flat orange overlay for user-excluded faces let hoverMesh = null; // semi-transparent yellow bucket-fill preview +let _exclMaterial = null; +let _hoverMaterial = null; +let _needsRender = true; // Build a labelled coordinate axes indicator scaled to `size`. // X = red, Y = green, Z = blue (up). @@ -141,8 +152,7 @@ export function initViewer(canvas) { renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.1; - renderer.shadowMap.enabled = true; - renderer.shadowMap.type = THREE.PCFSoftShadowMap; + renderer.shadowMap.enabled = false; // Scene scene = new THREE.Scene(); @@ -166,8 +176,7 @@ export function initViewer(canvas) { dirLight1 = new THREE.DirectionalLight(0xffffff, 1.2); dirLight1.position.set(80, 120, 60); - dirLight1.castShadow = true; - dirLight1.shadow.mapSize.set(1024, 1024); + dirLight1.castShadow = false; scene.add(dirLight1); dirLight2 = new THREE.DirectionalLight(0x8899ff, 0.4); @@ -229,6 +238,7 @@ export function initViewer(canvas) { const markerScale = (camera.top / camera.zoom) * 0.015; _pivotMarker.scale.setScalar(markerScale); _pivotMarker.visible = true; + _needsRender = true; }); document.addEventListener('pointermove', (e) => { @@ -240,24 +250,24 @@ export function initViewer(canvas) { const rotSpeed = 0.005; // Horizontal: rotate around world Z (up) - const qH = new THREE.Quaternion().setFromAxisAngle( - new THREE.Vector3(0, 0, 1), -dx * rotSpeed); + _tmpQ1.setFromAxisAngle(_tmpV1.set(0, 0, 1), -dx * rotSpeed); // Vertical: rotate around camera's local X (right vector) - const right = new THREE.Vector3().setFromMatrixColumn(camera.matrixWorld, 0).normalize(); - const qV = new THREE.Quaternion().setFromAxisAngle(right, -dy * rotSpeed); - const qTotal = new THREE.Quaternion().multiplyQuaternions(qV, qH); + _tmpV2.setFromMatrixColumn(camera.matrixWorld, 0).normalize(); + _tmpQ2.setFromAxisAngle(_tmpV2, -dy * rotSpeed); + _tmpQ1.premultiply(_tmpQ2); // _tmpQ1 = qV * qH (total rotation) // Rotate camera position around the pivot - const camOff = camera.position.clone().sub(_customPivot); - camOff.applyQuaternion(qTotal); - camera.position.copy(_customPivot).add(camOff); + _tmpV3.copy(camera.position).sub(_customPivot); + _tmpV3.applyQuaternion(_tmpQ1); + camera.position.copy(_customPivot).add(_tmpV3); // Rotate orbit target around the same pivot so OrbitControls stays in sync - const tgtOff = controls.target.clone().sub(_customPivot); - tgtOff.applyQuaternion(qTotal); - controls.target.copy(_customPivot).add(tgtOff); + _tmpV4.copy(controls.target).sub(_customPivot); + _tmpV4.applyQuaternion(_tmpQ1); + controls.target.copy(_customPivot).add(_tmpV4); camera.lookAt(controls.target); + _needsRender = true; }); document.addEventListener('pointerup', () => { @@ -266,6 +276,7 @@ export function initViewer(canvas) { _lastPointer = null; controls.enableRotate = true; _pivotMarker.visible = false; + _needsRender = true; } }); @@ -300,26 +311,27 @@ export function initViewer(canvas) { const curNdcX = ((midX - rect.left) / rect.width) * 2 - 1; const curNdcY = -((midY - rect.top) / rect.height) * 2 + 1; - const prevWorld = new THREE.Vector3(prevNdcX, prevNdcY, 0).unproject(camera); - const curWorld = new THREE.Vector3(curNdcX, curNdcY, 0).unproject(camera); - const panDelta = prevWorld.sub(curWorld); - camera.position.add(panDelta); - controls.target.add(panDelta); + _tmpV1.set(prevNdcX, prevNdcY, 0).unproject(camera); + _tmpV2.set(curNdcX, curNdcY, 0).unproject(camera); + _tmpV1.sub(_tmpV2); // panDelta + camera.position.add(_tmpV1); + controls.target.add(_tmpV1); // ── Zoom: zoom toward the current midpoint ──────────────────────── const factor = newDist / _pinchDist; - const before = new THREE.Vector3(curNdcX, curNdcY, 0).unproject(camera); + _tmpV3.set(curNdcX, curNdcY, 0).unproject(camera); camera.zoom = Math.max(0.05, Math.min(200, camera.zoom * factor)); camera.updateProjectionMatrix(); - const after = new THREE.Vector3(curNdcX, curNdcY, 0).unproject(camera); + _tmpV4.set(curNdcX, curNdcY, 0).unproject(camera); - const zoomDelta = before.clone().sub(after); - camera.position.add(zoomDelta); - controls.target.add(zoomDelta); + _tmpV3.sub(_tmpV4); // zoomDelta + camera.position.add(_tmpV3); + controls.target.add(_tmpV3); _pinchDist = newDist; _pinchMid = { x: midX, y: midY }; controls.update(); + _needsRender = true; }, { passive: false }); renderer.domElement.addEventListener('touchend', (e) => { @@ -338,7 +350,7 @@ export function initViewer(canvas) { const ndcY = -((e.clientY - rect.top) / rect.height) * 2 + 1; // World position under cursor before zoom - const before = new THREE.Vector3(ndcX, ndcY, 0).unproject(camera); + _tmpV1.set(ndcX, ndcY, 0).unproject(camera); // Apply zoom const factor = e.deltaY > 0 ? 1 / 1.1 : 1.1; @@ -346,12 +358,12 @@ export function initViewer(canvas) { camera.updateProjectionMatrix(); // World position under cursor after zoom - const after = new THREE.Vector3(ndcX, ndcY, 0).unproject(camera); + _tmpV2.set(ndcX, ndcY, 0).unproject(camera); // Shift camera + target so the world point stays under the cursor - const delta = before.clone().sub(after); - camera.position.add(delta); - controls.target.add(delta); + _tmpV1.sub(_tmpV2); // delta = before - after + camera.position.add(_tmpV1); + controls.target.add(_tmpV1); controls.update(); }, { passive: false }); @@ -360,12 +372,17 @@ export function initViewer(canvas) { resizeObserver.observe(canvas.parentElement); onResize(); + // Damping needs controls.update() every frame; re-render only when needed + controls.addEventListener('change', () => { _needsRender = true; }); + // Render loop (function animate() { requestAnimationFrame(animate); controls.update(); - - renderer.render(scene, camera); + if (_needsRender) { + _needsRender = false; + renderer.render(scene, camera); + } })(); } @@ -387,6 +404,21 @@ function onResize() { h * renderer.getPixelRatio(), ); } + requestRender(); +} + +function disposeGroup(group) { + group.traverse(obj => { + if (obj.geometry) obj.geometry.dispose(); + if (obj.material) { + if (Array.isArray(obj.material)) { + obj.material.forEach(m => { if (m.map) m.map.dispose(); m.dispose(); }); + } else { + if (obj.material.map) obj.material.map.dispose(); + obj.material.dispose(); + } + } + }); } /** @@ -435,7 +467,7 @@ export function loadGeometry(geometry, material) { fitCamera(sphere); // Place coordinate axes away from the part corner - if (axesGroup) scene.remove(axesGroup); + if (axesGroup) { disposeGroup(axesGroup); scene.remove(axesGroup); } const axisSize = sphere.radius * 0.30; axesGroup = buildAxesIndicator(axisSize); // Offset from the bounding box corner by ~1 axis-length so it doesn't overlap the mesh @@ -444,9 +476,10 @@ export function loadGeometry(geometry, material) { scene.add(axesGroup); // Bounding-box dimension annotations on the ground plane - if (dimensionGroup) scene.remove(dimensionGroup); + if (dimensionGroup) { disposeGroup(dimensionGroup); scene.remove(dimensionGroup); } dimensionGroup = buildDimensions(box, groundZ, sphere.radius); scene.add(dimensionGroup); + requestRender(); } /** @@ -464,6 +497,7 @@ export function setMeshMaterial(material) { metalness: 0.1, side: THREE.DoubleSide, }); + requestRender(); } /** @@ -484,6 +518,7 @@ export function setMeshGeometry(geometry) { wireframeLines = null; } if (wireframeVisible) _buildWireframe(geometry); + requestRender(); } /** @@ -514,6 +549,8 @@ function fitCamera(sphere) { controls.update(); } +export function requestRender() { _needsRender = true; } + export function getRenderer() { return renderer; } export function getCamera() { return camera; } export function getScene() { return scene; } @@ -522,6 +559,7 @@ export function getCurrentMesh() { return currentMesh; } export function setSceneBackground(hexColor) { if (scene) scene.background = new THREE.Color(hexColor); + requestRender(); } export function setViewerTheme(isLight) { @@ -541,6 +579,7 @@ export function setViewerTheme(isLight) { grid.rotation.x = Math.PI / 2; grid.position.z = savedZ; scene.add(grid); + requestRender(); } /** @@ -556,13 +595,11 @@ export function setExclusionOverlay(overlayGeo, color = 0xff6600, opacity = 1.0) if (exclusionMesh) { scene.remove(exclusionMesh); exclusionMesh.geometry.dispose(); - exclusionMesh.material.dispose(); exclusionMesh = null; } - if (!overlayGeo || overlayGeo.attributes.position.count === 0) return; - exclusionMesh = new THREE.Mesh( - overlayGeo, - new THREE.MeshLambertMaterial({ + if (!overlayGeo || overlayGeo.attributes.position.count === 0) { requestRender(); return; } + if (!_exclMaterial) { + _exclMaterial = new THREE.MeshLambertMaterial({ color, side: THREE.DoubleSide, transparent: opacity < 1.0, @@ -570,10 +607,16 @@ export function setExclusionOverlay(overlayGeo, color = 0xff6600, opacity = 1.0) polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1, - }), - ); + }); + } else { + _exclMaterial.color.set(color); + _exclMaterial.opacity = opacity; + _exclMaterial.transparent = opacity < 1.0; + } + exclusionMesh = new THREE.Mesh(overlayGeo, _exclMaterial); exclusionMesh.renderOrder = 1; scene.add(exclusionMesh); + requestRender(); } /** @@ -586,13 +629,11 @@ export function setHoverPreview(overlayGeo, color = 0xffee00) { if (hoverMesh) { scene.remove(hoverMesh); hoverMesh.geometry.dispose(); - hoverMesh.material.dispose(); hoverMesh = null; } - if (!overlayGeo || overlayGeo.attributes.position.count === 0) return; - hoverMesh = new THREE.Mesh( - overlayGeo, - new THREE.MeshBasicMaterial({ + if (!overlayGeo || overlayGeo.attributes.position.count === 0) { requestRender(); return; } + if (!_hoverMaterial) { + _hoverMaterial = new THREE.MeshBasicMaterial({ color, side: THREE.DoubleSide, transparent: true, @@ -600,10 +641,14 @@ export function setHoverPreview(overlayGeo, color = 0xffee00) { polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2, - }), - ); + }); + } else { + _hoverMaterial.color.set(color); + } + hoverMesh = new THREE.Mesh(overlayGeo, _hoverMaterial); hoverMesh.renderOrder = 2; scene.add(hoverMesh); + requestRender(); } /** @@ -618,6 +663,7 @@ export function setWireframe(enabled) { } else { if (wireframeLines) wireframeLines.visible = false; } + requestRender(); } function _buildWireframe(geometry) { From 9b2fb68c4784f8afbe784dd7349d3372705c9c1f Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Mon, 6 Apr 2026 02:38:46 +0200 Subject: [PATCH 3/8] perf: optimise QEM decimation heap and init phase - SoAHeap: hole method for bubbleUp/sinkDown (half the typed-array writes per heap level vs the old swap approach). - Seed dedup: Number keys instead of BigInt when vertex count < 94M (10-50x faster per key). BigInt fallback for extreme meshes. - addCreaseQuadrics: encode face pairs as negative numbers instead of allocating small arrays per edge. - Guard 3 (checkFlipped): single branch for corner index instead of 9 ternary operations per face. - Yield interval kept at 4096 with setTimeout(0) for reliable UI updates. --- js/decimation.js | 118 +++++++++++++++++++++++++++++------------------ 1 file changed, 73 insertions(+), 45 deletions(-) diff --git a/js/decimation.js b/js/decimation.js index eda0b12..2a41f27 100644 --- a/js/decimation.js +++ b/js/decimation.js @@ -28,8 +28,8 @@ * - Struct-of-arrays typed-array heap avoids per-entry object allocation. * - Numeric edge keys (v_lo * MAX_V + v_hi) replace template strings. * - Vertex deduplication uses a numeric spatial-grid Map instead of strings. - * - Link-violation check packs sorted face triple into two Numbers to avoid - * string allocation. + * - Link-violation check uses a module-level Set with packed keys for O(1) + * duplicate-face lookup. * - Progress callback fires at most every 512 collapses. * * @param {THREE.BufferGeometry} geometry non-indexed input @@ -46,8 +46,14 @@ const FLIP_DOT_SQ = FLIP_DOT * FLIP_DOT; const CREASE_COS = 0.5; // cos 60° — edges sharper than this are treated as creases const CREASE_WEIGHT = 1e4; // quadric penalty weight for crease edges +// Yield to browser for UI responsiveness during long-running decimation. +// setTimeout(0) guarantees a real rendering frame between iterations. +function _yieldFrame() { + return new Promise(r => setTimeout(r, 0)); +} + +// Module-level Set for hasLinkViolation — avoids per-call heap allocation. // Module-level scratch arrays for hasLinkViolation — avoids new Map() per call. -// Size 128 exceeds the maximum practical vertex valence in any STL mesh. const _hlvHi = new Float64Array(512); const _hlvLo = new Int32Array(512); @@ -78,17 +84,17 @@ export async function decimate(geometry, targetTriangles, onProgress) { let activeFaces = faceCount; // Seed min-heap with one entry per unique edge. - // Use BigInt keys to handle any vertex count without integer overflow. + // Use Number keys when vertCount < 94M (safe integer range), BigInt otherwise. const heap = new SoAHeap(Math.min(faceCount * 3, 1 << 24)); const seedSeen = new Set(); - const _vc = BigInt(vertCount); + const _useNumericSeed = vertCount < 94_000_000; for (let f = 0; f < faceCount; f++) { if (faces[f * 3] < 0) continue; for (let e = 0; e < 3; e++) { const va = faces[f * 3 + e]; const vb = faces[f * 3 + ((e + 1) % 3)]; const lo = va < vb ? va : vb, hi = va < vb ? vb : va; - const ek = BigInt(lo) * _vc + BigInt(hi); + const ek = _useNumericSeed ? lo * vertCount + hi : BigInt(lo) * BigInt(vertCount) + BigInt(hi); if (!seedSeen.has(ek)) { seedSeen.add(ek); pushEdge(heap, quadrics, positions, version, va, vb); } } } @@ -107,7 +113,7 @@ export async function decimate(geometry, targetTriangles, onProgress) { // to keep the UI responsive. Critical for flat / low-displacement // surfaces where most collapses are rejected by the safety guards. if ((++iterations & 4095) === 0) { - await new Promise(r => setTimeout(r, 0)); + await _yieldFrame(); if (onProgress) { const p = Math.min(1, (initFaces - activeFaces) / toRemove); if (p - lastProg > 0.005) { onProgress(p); lastProg = p; } @@ -258,8 +264,8 @@ function sharedFaceCount(faces, vfHead, slotFace, slotNext, v1, v2) { } // ── Guard 2: Duplicate-face / pinch prevention ─────────────────────────────── -// Uses module-level scratch arrays (_hlvHi, _hlvLo) instead of new Map() -// to avoid per-call heap allocation. +// Uses module-level scratch arrays (_hlvHi, _hlvLo) — zero allocation per call. +// Linear scan is faster than Set for typical STL vertex valence (5-8). function hasLinkViolation(faces, vfHead, slotFace, slotNext, v1, v2) { let n = 0; @@ -316,9 +322,10 @@ function checkFlipped(positions, vfHead, slotFace, slotNext, faces, vc, vo, npx, const ony = ouz*ovx - oux*ovz; const onz = oux*ovy - ouy*ovx; // New positions (vc replaced by np) - const nax = fa===vc ? npx : oax, nay = fa===vc ? npy : oay, naz = fa===vc ? npz : oaz; - const nbx = fb===vc ? npx : obx, nby = fb===vc ? npy : oby, nbz = fb===vc ? npz : obz; - const ncx = fc===vc ? npx : ocx, ncy = fc===vc ? npy : ocy, ncz = fc===vc ? npz : ocz; + let nax, nay, naz, nbx, nby, nbz, ncx, ncy, ncz; + if (fa === vc) { nax = npx; nay = npy; naz = npz; nbx = obx; nby = oby; nbz = obz; ncx = ocx; ncy = ocy; ncz = ocz; } + else if (fb === vc) { nax = oax; nay = oay; naz = oaz; nbx = npx; nby = npy; nbz = npz; ncx = ocx; ncy = ocy; ncz = ocz; } + else { nax = oax; nay = oay; naz = oaz; nbx = obx; nby = oby; nbz = obz; ncx = npx; ncy = npy; ncz = npz; } // Unnormalized new normal const nux = nbx-nax, nuy = nby-nay, nuz = nbz-naz; const nvx = ncx-nax, nvy = ncy-nay, nvz = ncz-naz; @@ -368,18 +375,24 @@ function addCreaseQuadrics(quadrics, positions, faces, faceCount) { const va = faces[f * 3 + e]; const vb = faces[f * 3 + ((e + 1) % 3)]; const key = va < vb ? va * N + vb : vb * N + va; - let arr = edgeToFaces.get(key); - if (!arr) { arr = []; edgeToFaces.set(key, arr); } - arr.push(f); + const existing = edgeToFaces.get(key); + if (existing === undefined) { + edgeToFaces.set(key, f); + } else if (existing >= 0) { + edgeToFaces.set(key, -(existing * faceCount + f + 1)); + } else { + edgeToFaces.set(key, 0); + } } } const sqrtW = Math.sqrt(CREASE_WEIGHT); - for (const [key, flist] of edgeToFaces) { - if (flist.length !== 2) continue; // open boundary or non-manifold — skip - - const f0 = flist[0], f1 = flist[1]; + for (const [key, val] of edgeToFaces) { + if (val >= 0 || val === 0) continue; // nur 1 Face oder >2 Faces -> skip + const encoded = -(val + 1); + const f0 = Math.floor(encoded / faceCount); + const f1 = encoded - f0 * faceCount; const v0a = faces[f0*3], v0b = faces[f0*3+1], v0c = faces[f0*3+2]; const v1a = faces[f1*3], v1b = faces[f1*3+1], v1c = faces[f1*3+2]; @@ -665,38 +678,53 @@ class SoAHeap { this._px[dst] = this._px[src]; this._py[dst] = this._py[src]; this._pz[dst] = this._pz[src]; } - _swap(a, b) { - const tc = this._cost[a], tv1 = this._v1[a], tv2 = this._v2[a]; - const te1 = this._ver1[a], te2 = this._ver2[a]; - const tpx = this._px[a], tpy = this._py[a], tpz = this._pz[a]; - this._cost[a] = this._cost[b]; this._v1[a] = this._v1[b]; this._v2[a] = this._v2[b]; - this._ver1[a] = this._ver1[b]; this._ver2[a] = this._ver2[b]; - this._px[a] = this._px[b]; this._py[a] = this._py[b]; this._pz[a] = this._pz[b]; - this._cost[b] = tc; this._v1[b] = tv1; this._v2[b] = tv2; - this._ver1[b] = te1; this._ver2[b] = te2; - this._px[b] = tpx; this._py[b] = tpy; this._pz[b] = tpz; - } + _bubbleUp(idx) { + const cost = this._cost[idx]; + const v1 = this._v1[idx], v2 = this._v2[idx]; + const ver1 = this._ver1[idx], ver2 = this._ver2[idx]; + const px = this._px[idx], py = this._py[idx], pz = this._pz[idx]; - _bubbleUp(i) { - const cost = this._cost; - while (i > 1) { - const p = i >> 1; - if (cost[p] <= cost[i]) break; - this._swap(p, i); i = p; + while (idx > 1) { + const parent = idx >> 1; + if (this._cost[parent] <= cost) break; + this._cost[idx] = this._cost[parent]; + this._v1[idx] = this._v1[parent]; this._v2[idx] = this._v2[parent]; + this._ver1[idx] = this._ver1[parent]; this._ver2[idx] = this._ver2[parent]; + this._px[idx] = this._px[parent]; this._py[idx] = this._py[parent]; this._pz[idx] = this._pz[parent]; + idx = parent; } + this._cost[idx] = cost; + this._v1[idx] = v1; this._v2[idx] = v2; + this._ver1[idx] = ver1; this._ver2[idx] = ver2; + this._px[idx] = px; this._py[idx] = py; this._pz[idx] = pz; } - _sinkDown(i) { - const cost = this._cost; + _sinkDown(idx) { const n = this._len; - for (;;) { - let s = i; - const l = i << 1, r = l | 1; - if (l <= n && cost[l] < cost[s]) s = l; - if (r <= n && cost[r] < cost[s]) s = r; - if (s === i) break; - this._swap(s, i); i = s; + const cost = this._cost[idx]; + const v1 = this._v1[idx], v2 = this._v2[idx]; + const ver1 = this._ver1[idx], ver2 = this._ver2[idx]; + const px = this._px[idx], py = this._py[idx], pz = this._pz[idx]; + + while (true) { + const l = idx << 1, r = l | 1; + let child = -1; + // Find smallest child that is cheaper than saved element + if (l <= n && this._cost[l] < cost) child = l; + if (r <= n && this._cost[r] < (child >= 0 ? this._cost[child] : cost)) child = r; + if (child < 0) break; + // Move child up into hole + this._cost[idx] = this._cost[child]; + this._v1[idx] = this._v1[child]; this._v2[idx] = this._v2[child]; + this._ver1[idx] = this._ver1[child]; this._ver2[idx] = this._ver2[child]; + this._px[idx] = this._px[child]; this._py[idx] = this._py[child]; this._pz[idx] = this._pz[child]; + idx = child; } + // Place saved element in final hole + this._cost[idx] = cost; + this._v1[idx] = v1; this._v2[idx] = v2; + this._ver1[idx] = ver1; this._ver2[idx] = ver2; + this._px[idx] = px; this._py[idx] = py; this._pz[idx] = pz; } _grow() { From 3c9bcfd75caa1a144bd076d1caf1b56ef872de47 Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Mon, 6 Apr 2026 02:38:58 +0200 Subject: [PATCH 4/8] perf: replace Three.js STLExporter with direct binary writer Write binary STL directly from BufferGeometry typed arrays using Uint8Array.set() bulk copies. Eliminates the Three.js STLExporter overhead: no Mesh/Material creation, no identity matrix multiplication, no redundant normal recomputation, no per-float DataView calls. --- js/exporter.js | 73 ++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 56 insertions(+), 17 deletions(-) diff --git a/js/exporter.js b/js/exporter.js index 6e881b7..403aa47 100644 --- a/js/exporter.js +++ b/js/exporter.js @@ -1,32 +1,71 @@ -import * as THREE from 'three'; -import { STLExporter } from 'three/addons/exporters/STLExporter.js'; - -const exporter = new STLExporter(); - /** - * Export a BufferGeometry as a binary STL file download. + * Fast binary STL exporter — writes directly from BufferGeometry arrays. * - * @param {THREE.BufferGeometry} geometry + * Eliminates Three.js STLExporter overhead: + * - No Mesh/Material creation + * - No identity matrix multiplication per vertex + * - No redundant normal recomputation + * - Bulk Uint8Array.set() instead of per-float DataView calls + * + * @param {THREE.BufferGeometry} geometry – non-indexed with position + normal * @param {string} [filename] */ export function exportSTL(geometry, filename = 'textured.stl') { - // Geometry is already in the original Z-up orientation (the loader never rotates it; - // the viewer uses a Z-up camera instead). Export as-is so slicers receive the correct pose. - const mesh = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial()); - const result = exporter.parse(mesh, { binary: true }); + const posArr = geometry.attributes.position.array; + const norArr = geometry.attributes.normal + ? geometry.attributes.normal.array + : null; + const triCount = (posArr.length / 9) | 0; - // result is an ArrayBuffer in binary mode - const blob = new Blob([result], { type: 'application/octet-stream' }); + // Binary STL: 80-byte header + 4-byte tri count + 50 bytes per triangle + const bufLen = 84 + 50 * triCount; + const buffer = new ArrayBuffer(bufLen); + const bytes = new Uint8Array(buffer); + const view = new DataView(buffer); + + // Header: 80 bytes (already zero-filled) + view.setUint32(80, triCount, true); + + // Reinterpret source arrays as raw bytes for bulk copy + const posSrc = new Uint8Array(posArr.buffer, posArr.byteOffset, posArr.byteLength); + const norSrc = norArr + ? new Uint8Array(norArr.buffer, norArr.byteOffset, norArr.byteLength) + : null; + + for (let i = 0; i < triCount; i++) { + const dst = 84 + i * 50; + const srcOff = i * 36; // 9 floats * 4 bytes + + if (norSrc) { + // Normal: copy first vertex normal (12 bytes) — flat shading, all 3 identical + bytes.set(norSrc.subarray(srcOff, srcOff + 12), dst); + } else { + // Compute face normal from cross product + const b = i * 9; + const ux = posArr[b+3]-posArr[b], uy = posArr[b+4]-posArr[b+1], uz = posArr[b+5]-posArr[b+2]; + const vx = posArr[b+6]-posArr[b], vy = posArr[b+7]-posArr[b+1], vz = posArr[b+8]-posArr[b+2]; + const nx = uy*vz-uz*vy, ny = uz*vx-ux*vz, nz = ux*vy-uy*vx; + const len = Math.sqrt(nx*nx + ny*ny + nz*nz) || 1; + view.setFloat32(dst, nx/len, true); + view.setFloat32(dst + 4, ny/len, true); + view.setFloat32(dst + 8, nz/len, true); + } + + // Vertices: 36 bytes (3 vertices * 3 floats * 4 bytes) + bytes.set(posSrc.subarray(srcOff, srcOff + 36), dst + 12); + + // Attribute byte count: 0 (already zero-filled) + } + + // Download + const blob = new Blob([buffer], { type: 'application/octet-stream' }); const url = URL.createObjectURL(blob); - - const a = document.createElement('a'); + const a = document.createElement('a'); a.href = url; a.download = filename; a.style.display = 'none'; document.body.appendChild(a); a.click(); document.body.removeChild(a); - - // Revoke after a short delay so the download has time to start setTimeout(() => URL.revokeObjectURL(url), 10000); } From 4811b55d5c57e3cd6329a74cf52e0d7f52cf1286 Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Mon, 6 Apr 2026 02:39:12 +0200 Subject: [PATCH 5/8] perf: add CDN preconnect, improve canvas accessibility - Preconnect hint for jsdelivr CDN (parallelize DNS+TLS handshake). - Add role="img" and aria-label to viewport canvas for screen readers. --- index.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/index.html b/index.html index 0c2e32b..e6667d8 100644 --- a/index.html +++ b/index.html @@ -27,6 +27,7 @@ } })(); +