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.
This commit is contained in:
Avatarsia
2026-04-06 02:38:30 +02:00
parent 51873fd5fc
commit d92296754f
2 changed files with 203 additions and 107 deletions
+109 -59
View File
@@ -1,7 +1,8 @@
import * as THREE from 'three'; import * as THREE from 'three';
import { initViewer, loadGeometry, setMeshMaterial, setMeshGeometry, setWireframe, import { initViewer, loadGeometry, setMeshMaterial, setMeshGeometry, setWireframe,
getControls, getCamera, getCurrentMesh, getControls, getCamera, getCurrentMesh,
setExclusionOverlay, setHoverPreview, setViewerTheme } from './viewer.js'; setExclusionOverlay, setHoverPreview, setViewerTheme,
requestRender } from './viewer.js';
import { loadModelFile, computeBounds, getTriangleCount } from './stlLoader.js'; import { loadModelFile, computeBounds, getTriangleCount } from './stlLoader.js';
import { loadPresets, loadCustomTexture } from './presetTextures.js'; import { loadPresets, loadCustomTexture } from './presetTextures.js';
import { createPreviewMaterial, updateMaterial } from './previewMaterial.js'; import { createPreviewMaterial, updateMaterial } from './previewMaterial.js';
@@ -25,7 +26,7 @@ let previewDebounce = null;
// ── Exclusion state ─────────────────────────────────────────────────────────── // ── Exclusion state ───────────────────────────────────────────────────────────
let excludedFaces = new Set(); // triangle indices in currentGeometry 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 triangleCentroids = null; // Float32Array from buildAdjacency
let triangleBoundRadii = null; // Float32Array — max vertex-to-centroid dist per tri let triangleBoundRadii = null; // Float32Array — max vertex-to-centroid dist per tri
let exclusionTool = null; // 'brush' | 'bucket' | null 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 _lastHoverTriIdx = -1; // last triangle index used for hover preview
let placeOnFaceActive = false; // true while "Place on Face" mode is active let placeOnFaceActive = false; // true while "Place on Face" mode is active
const _raycaster = new THREE.Raycaster(); const _raycaster = new THREE.Raycaster();
let _lastEffectiveTexture = null;
let _effectiveMapCache = null;
let _effectiveMapCacheKey = null;
const settings = { const settings = {
mappingMode: 5, // Triplanar default 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 precisionBusy = false; // true while async subdivision is running
let precisionCentroids = null; // Float32Array from buildAdjacency on refined mesh let precisionCentroids = null; // Float32Array from buildAdjacency on refined mesh
let precisionBoundRadii = null; // Float32Array — max vertex-to-centroid per refined tri 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 let precisionExcludedFaces = new Set(); // precision face indices excluded while precision is active
// ── Displacement preview state ──────────────────────────────────────────────── // ── 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) => { canvas.addEventListener('mousemove', (e) => {
if (placeOnFaceActive && currentGeometry) { // Paint-Events sofort verarbeiten (jeder Event zaehlt fuer lueckenloses Malen)
updatePlaceOnFaceHover(e);
return;
}
if (exclusionTool === 'brush' && brushIsRadius) {
updateBrushCursor(e);
}
if (isPainting && exclusionTool === 'brush') { if (isPainting && exclusionTool === 'brush') {
paintAt(e); paintAt(e);
// Cursor-Update kann warten
_pendingHoverEvent = e;
if (!_hoverRafId) {
_hoverRafId = requestAnimationFrame(() => {
_hoverRafId = 0;
if (_pendingHoverEvent) updateBrushCursor(_pendingHoverEvent);
_pendingHoverEvent = null;
});
}
return; return;
} }
if (!isPainting && exclusionTool === 'brush' && currentGeometry) { // Alle anderen Hover-Pfade: RAF-Batching OK
updateBrushHover(e); _pendingHoverEvent = e;
} if (!_hoverRafId) {
if (!isPainting && exclusionTool === 'bucket' && currentGeometry) { _hoverRafId = requestAnimationFrame(() => {
updateBucketHover(e); _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) { function _canvasNDC(e) {
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
return new THREE.Vector2( _ndcResult.set(
((e.clientX - rect.left) / rect.width) * 2 - 1, ((e.clientX - rect.left) / rect.width) * 2 - 1,
((e.clientY - rect.top) / rect.height) * -2 + 1, ((e.clientY - rect.top) / rect.height) * -2 + 1,
); );
return _ndcResult;
} }
// The preview material uses THREE.DoubleSide, so the raycaster can return // The preview material uses THREE.DoubleSide, so the raycaster can return
@@ -1437,7 +1461,11 @@ function updateFaceMask(geometry) {
if (!geometry) return; if (!geometry) return;
const posCount = geometry.attributes.position.count; const posCount = geometry.attributes.position.count;
const triCount = posCount / 3; 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 // Determine which face set to check
const isPrecision = (geometry === precisionGeometry && precisionMaskingEnabled); 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). // Ensure faceNormal attribute exists (needed by shader for angle masking).
// For the original geometry normal == faceNormal; for subdivided geometry // For the original geometry normal == faceNormal; for subdivided geometry
@@ -1470,6 +1502,7 @@ function updateFaceMask(geometry) {
if (!geometry.attributes.faceNormal) { if (!geometry.attributes.faceNormal) {
addFaceNormals(geometry); addFaceNormals(geometry);
} }
requestRender();
} }
/** /**
@@ -1602,8 +1635,16 @@ function buildParentFaceMap(subdivGeo) {
} }
function getEffectiveMapEntry() { function getEffectiveMapEntry() {
if (!activeMapEntry || settings.textureSmoothing === 0) return activeMapEntry; if (!activeMapEntry || settings.textureSmoothing === 0) {
const { fullCanvas, width, height } = activeMapEntry; _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 // Tile the source 3×3 before blurring so edge pixels have correct
// neighbours and the blurred centre tile is seamlessly tileable. // neighbours and the blurred centre tile is seamlessly tileable.
const tiled = document.createElement('canvas'); const tiled = document.createElement('canvas');
@@ -1628,7 +1669,11 @@ function getEffectiveMapEntry() {
const imageData = offscreen.getContext('2d').getImageData(0, 0, width, height); const imageData = offscreen.getContext('2d').getImageData(0, 0, width, height);
const texture = new THREE.CanvasTexture(offscreen); const texture = new THREE.CanvasTexture(offscreen);
texture.wrapS = texture.wrapT = THREE.RepeatWrapping; 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() { function updatePreview() {
@@ -1717,19 +1762,28 @@ function addFaceNormals(geometry) {
function addSmoothNormals(geometry) { function addSmoothNormals(geometry) {
const pos = geometry.attributes.position.array; const pos = geometry.attributes.position.array;
const count = geometry.attributes.position.count; 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 QUANT = 1e4;
const key = (x, y, z) => const dedupMap = new Map();
`${Math.round(x * QUANT)}_${Math.round(y * QUANT)}_${Math.round(z * QUANT)}`; 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 // 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 // (cylinder, sphere) but sharp across hard edges (cube). Using these buffer
// normals instead of geometric face normals eliminates visible faceting steps // normals instead of geometric face normals eliminates visible faceting steps
// on round surfaces while still preserving hard edges. // on round surfaces while still preserving hard edges.
const nrmMap = new Map(); const uc = nextId;
const nrm = geometry.attributes.normal.array; 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 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(); 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; if (area < 1e-12) continue;
for (let v = 0; v < 3; v++) { for (let v = 0; v < 3; v++) {
const vi = i + v; const vi = i + v;
const nx = nrm[vi * 3], ny = nrm[vi * 3 + 1], nz = nrm[vi * 3 + 2]; const id = vertId[vi];
const k = key(pos[vi * 3], pos[vi * 3 + 1], pos[vi * 3 + 2]); snx[id] += nrm[vi * 3] * area;
const prev = nrmMap.get(k); sny[id] += nrm[vi * 3 + 1] * area;
if (prev) { snz[id] += nrm[vi * 3 + 2] * area;
prev[0] += nx * area;
prev[1] += ny * area;
prev[2] += nz * area;
} else {
nrmMap.set(k, [nx * area, ny * area, nz * area]);
}
} }
} }
// Normalize accumulated normals // Normalize accumulated normals
for (const n of nrmMap.values()) { for (let id = 0; id < uc; id++) {
const len = Math.sqrt(n[0] * n[0] + n[1] * n[1] + n[2] * n[2]); const len = Math.sqrt(snx[id] * snx[id] + sny[id] * sny[id] + snz[id] * snz[id]) || 1;
if (len > 1e-12) { n[0] /= len; n[1] /= len; n[2] /= len; } snx[id] /= len; sny[id] /= len; snz[id] /= len;
} }
// Write smoothNormal attribute // Write smoothNormal attribute via vertId lookup
const sn = new Float32Array(count * 3); const sn = new Float32Array(count * 3);
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const k = key(pos[i * 3], pos[i * 3 + 1], pos[i * 3 + 2]); const id = vertId[i];
const n = nrmMap.get(k); sn[i * 3] = snx[id]; sn[i * 3 + 1] = sny[id]; sn[i * 3 + 2] = snz[id];
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; }
} }
geometry.setAttribute('smoothNormal', new THREE.Float32BufferAttribute(sn, 3)); 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), // Flat-bottom clamp: when bottom faces are masked (bottomAngleLimit > 0),
// any vertex that ended up below the original model's bottom layer gets // 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) { if (settings.bottomAngleLimit > 0) {
const bottomZ = currentBounds.min.z; 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 pa = finalGeometry.attributes.position.array;
const na = finalGeometry.attributes.normal ? finalGeometry.attributes.normal.array : new Float32Array(pa.length); const na = finalGeometry.attributes.normal ? finalGeometry.attributes.normal.array : new Float32Array(pa.length);
for (let i = 0; i < pa.length; i += 9) { 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]; let dirty = false;
const vx = pa[i+6]-pa[i], vy = pa[i+7]-pa[i+1], vz = pa[i+8]-pa[i+2]; if (pa[i+2] < bottomZ) { pa[i+2] = bottomZ; dirty = true; }
const nx = uy*vz-uz*vy, ny = uz*vx-ux*vz, nz = ux*vy-uy*vx; if (pa[i+5] < bottomZ) { pa[i+5] = bottomZ; dirty = true; }
const len = Math.sqrt(nx*nx+ny*ny+nz*nz) || 1; if (pa[i+8] < bottomZ) { pa[i+8] = bottomZ; dirty = true; }
na[i] = na[i+3] = na[i+6] = nx/len;
na[i+1] = na[i+4] = na[i+7] = ny/len; if (dirty) {
na[i+2] = na[i+5] = na[i+8] = nz/len; 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)); if (!finalGeometry.attributes.normal) finalGeometry.setAttribute('normal', new THREE.Float32BufferAttribute(na, 3));
else finalGeometry.attributes.normal.needsUpdate = true; else finalGeometry.attributes.normal.needsUpdate = true;
} }
+94 -48
View File
@@ -4,6 +4,14 @@ import { LineSegments2 } from 'three/addons/lines/LineSegments2.js';
import { LineSegmentsGeometry } from 'three/addons/lines/LineSegmentsGeometry.js'; import { LineSegmentsGeometry } from 'three/addons/lines/LineSegmentsGeometry.js';
import { LineMaterial } from 'three/addons/lines/LineMaterial.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 renderer, camera, scene, controls, meshGroup, ambientLight, dirLight1, dirLight2, grid;
let currentMesh = null; let currentMesh = null;
let axesGroup = null; let axesGroup = null;
@@ -12,6 +20,9 @@ let wireframeLines = null; // LineSegments overlay, or null when hidden
let wireframeVisible = false; let wireframeVisible = false;
let exclusionMesh = null; // flat orange overlay for user-excluded faces let exclusionMesh = null; // flat orange overlay for user-excluded faces
let hoverMesh = null; // semi-transparent yellow bucket-fill preview 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`. // Build a labelled coordinate axes indicator scaled to `size`.
// X = red, Y = green, Z = blue (up). // X = red, Y = green, Z = blue (up).
@@ -141,8 +152,7 @@ export function initViewer(canvas) {
renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1; renderer.toneMappingExposure = 1.1;
renderer.shadowMap.enabled = true; renderer.shadowMap.enabled = false;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
// Scene // Scene
scene = new THREE.Scene(); scene = new THREE.Scene();
@@ -166,8 +176,7 @@ export function initViewer(canvas) {
dirLight1 = new THREE.DirectionalLight(0xffffff, 1.2); dirLight1 = new THREE.DirectionalLight(0xffffff, 1.2);
dirLight1.position.set(80, 120, 60); dirLight1.position.set(80, 120, 60);
dirLight1.castShadow = true; dirLight1.castShadow = false;
dirLight1.shadow.mapSize.set(1024, 1024);
scene.add(dirLight1); scene.add(dirLight1);
dirLight2 = new THREE.DirectionalLight(0x8899ff, 0.4); dirLight2 = new THREE.DirectionalLight(0x8899ff, 0.4);
@@ -229,6 +238,7 @@ export function initViewer(canvas) {
const markerScale = (camera.top / camera.zoom) * 0.015; const markerScale = (camera.top / camera.zoom) * 0.015;
_pivotMarker.scale.setScalar(markerScale); _pivotMarker.scale.setScalar(markerScale);
_pivotMarker.visible = true; _pivotMarker.visible = true;
_needsRender = true;
}); });
document.addEventListener('pointermove', (e) => { document.addEventListener('pointermove', (e) => {
@@ -240,24 +250,24 @@ export function initViewer(canvas) {
const rotSpeed = 0.005; const rotSpeed = 0.005;
// Horizontal: rotate around world Z (up) // Horizontal: rotate around world Z (up)
const qH = new THREE.Quaternion().setFromAxisAngle( _tmpQ1.setFromAxisAngle(_tmpV1.set(0, 0, 1), -dx * rotSpeed);
new THREE.Vector3(0, 0, 1), -dx * rotSpeed);
// Vertical: rotate around camera's local X (right vector) // Vertical: rotate around camera's local X (right vector)
const right = new THREE.Vector3().setFromMatrixColumn(camera.matrixWorld, 0).normalize(); _tmpV2.setFromMatrixColumn(camera.matrixWorld, 0).normalize();
const qV = new THREE.Quaternion().setFromAxisAngle(right, -dy * rotSpeed); _tmpQ2.setFromAxisAngle(_tmpV2, -dy * rotSpeed);
const qTotal = new THREE.Quaternion().multiplyQuaternions(qV, qH); _tmpQ1.premultiply(_tmpQ2); // _tmpQ1 = qV * qH (total rotation)
// Rotate camera position around the pivot // Rotate camera position around the pivot
const camOff = camera.position.clone().sub(_customPivot); _tmpV3.copy(camera.position).sub(_customPivot);
camOff.applyQuaternion(qTotal); _tmpV3.applyQuaternion(_tmpQ1);
camera.position.copy(_customPivot).add(camOff); camera.position.copy(_customPivot).add(_tmpV3);
// Rotate orbit target around the same pivot so OrbitControls stays in sync // Rotate orbit target around the same pivot so OrbitControls stays in sync
const tgtOff = controls.target.clone().sub(_customPivot); _tmpV4.copy(controls.target).sub(_customPivot);
tgtOff.applyQuaternion(qTotal); _tmpV4.applyQuaternion(_tmpQ1);
controls.target.copy(_customPivot).add(tgtOff); controls.target.copy(_customPivot).add(_tmpV4);
camera.lookAt(controls.target); camera.lookAt(controls.target);
_needsRender = true;
}); });
document.addEventListener('pointerup', () => { document.addEventListener('pointerup', () => {
@@ -266,6 +276,7 @@ export function initViewer(canvas) {
_lastPointer = null; _lastPointer = null;
controls.enableRotate = true; controls.enableRotate = true;
_pivotMarker.visible = false; _pivotMarker.visible = false;
_needsRender = true;
} }
}); });
@@ -300,26 +311,27 @@ export function initViewer(canvas) {
const curNdcX = ((midX - rect.left) / rect.width) * 2 - 1; const curNdcX = ((midX - rect.left) / rect.width) * 2 - 1;
const curNdcY = -((midY - rect.top) / rect.height) * 2 + 1; const curNdcY = -((midY - rect.top) / rect.height) * 2 + 1;
const prevWorld = new THREE.Vector3(prevNdcX, prevNdcY, 0).unproject(camera); _tmpV1.set(prevNdcX, prevNdcY, 0).unproject(camera);
const curWorld = new THREE.Vector3(curNdcX, curNdcY, 0).unproject(camera); _tmpV2.set(curNdcX, curNdcY, 0).unproject(camera);
const panDelta = prevWorld.sub(curWorld); _tmpV1.sub(_tmpV2); // panDelta
camera.position.add(panDelta); camera.position.add(_tmpV1);
controls.target.add(panDelta); controls.target.add(_tmpV1);
// ── Zoom: zoom toward the current midpoint ──────────────────────── // ── Zoom: zoom toward the current midpoint ────────────────────────
const factor = newDist / _pinchDist; 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.zoom = Math.max(0.05, Math.min(200, camera.zoom * factor));
camera.updateProjectionMatrix(); 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); _tmpV3.sub(_tmpV4); // zoomDelta
camera.position.add(zoomDelta); camera.position.add(_tmpV3);
controls.target.add(zoomDelta); controls.target.add(_tmpV3);
_pinchDist = newDist; _pinchDist = newDist;
_pinchMid = { x: midX, y: midY }; _pinchMid = { x: midX, y: midY };
controls.update(); controls.update();
_needsRender = true;
}, { passive: false }); }, { passive: false });
renderer.domElement.addEventListener('touchend', (e) => { renderer.domElement.addEventListener('touchend', (e) => {
@@ -338,7 +350,7 @@ export function initViewer(canvas) {
const ndcY = -((e.clientY - rect.top) / rect.height) * 2 + 1; const ndcY = -((e.clientY - rect.top) / rect.height) * 2 + 1;
// World position under cursor before zoom // 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 // Apply zoom
const factor = e.deltaY > 0 ? 1 / 1.1 : 1.1; const factor = e.deltaY > 0 ? 1 / 1.1 : 1.1;
@@ -346,12 +358,12 @@ export function initViewer(canvas) {
camera.updateProjectionMatrix(); camera.updateProjectionMatrix();
// World position under cursor after zoom // 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 // Shift camera + target so the world point stays under the cursor
const delta = before.clone().sub(after); _tmpV1.sub(_tmpV2); // delta = before - after
camera.position.add(delta); camera.position.add(_tmpV1);
controls.target.add(delta); controls.target.add(_tmpV1);
controls.update(); controls.update();
}, { passive: false }); }, { passive: false });
@@ -360,12 +372,17 @@ export function initViewer(canvas) {
resizeObserver.observe(canvas.parentElement); resizeObserver.observe(canvas.parentElement);
onResize(); onResize();
// Damping needs controls.update() every frame; re-render only when needed
controls.addEventListener('change', () => { _needsRender = true; });
// Render loop // Render loop
(function animate() { (function animate() {
requestAnimationFrame(animate); requestAnimationFrame(animate);
controls.update(); controls.update();
if (_needsRender) {
renderer.render(scene, camera); _needsRender = false;
renderer.render(scene, camera);
}
})(); })();
} }
@@ -387,6 +404,21 @@ function onResize() {
h * renderer.getPixelRatio(), 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); fitCamera(sphere);
// Place coordinate axes away from the part corner // 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; const axisSize = sphere.radius * 0.30;
axesGroup = buildAxesIndicator(axisSize); axesGroup = buildAxesIndicator(axisSize);
// Offset from the bounding box corner by ~1 axis-length so it doesn't overlap the mesh // 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); scene.add(axesGroup);
// Bounding-box dimension annotations on the ground plane // 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); dimensionGroup = buildDimensions(box, groundZ, sphere.radius);
scene.add(dimensionGroup); scene.add(dimensionGroup);
requestRender();
} }
/** /**
@@ -464,6 +497,7 @@ export function setMeshMaterial(material) {
metalness: 0.1, metalness: 0.1,
side: THREE.DoubleSide, side: THREE.DoubleSide,
}); });
requestRender();
} }
/** /**
@@ -484,6 +518,7 @@ export function setMeshGeometry(geometry) {
wireframeLines = null; wireframeLines = null;
} }
if (wireframeVisible) _buildWireframe(geometry); if (wireframeVisible) _buildWireframe(geometry);
requestRender();
} }
/** /**
@@ -514,6 +549,8 @@ function fitCamera(sphere) {
controls.update(); controls.update();
} }
export function requestRender() { _needsRender = true; }
export function getRenderer() { return renderer; } export function getRenderer() { return renderer; }
export function getCamera() { return camera; } export function getCamera() { return camera; }
export function getScene() { return scene; } export function getScene() { return scene; }
@@ -522,6 +559,7 @@ export function getCurrentMesh() { return currentMesh; }
export function setSceneBackground(hexColor) { export function setSceneBackground(hexColor) {
if (scene) scene.background = new THREE.Color(hexColor); if (scene) scene.background = new THREE.Color(hexColor);
requestRender();
} }
export function setViewerTheme(isLight) { export function setViewerTheme(isLight) {
@@ -541,6 +579,7 @@ export function setViewerTheme(isLight) {
grid.rotation.x = Math.PI / 2; grid.rotation.x = Math.PI / 2;
grid.position.z = savedZ; grid.position.z = savedZ;
scene.add(grid); scene.add(grid);
requestRender();
} }
/** /**
@@ -556,13 +595,11 @@ export function setExclusionOverlay(overlayGeo, color = 0xff6600, opacity = 1.0)
if (exclusionMesh) { if (exclusionMesh) {
scene.remove(exclusionMesh); scene.remove(exclusionMesh);
exclusionMesh.geometry.dispose(); exclusionMesh.geometry.dispose();
exclusionMesh.material.dispose();
exclusionMesh = null; exclusionMesh = null;
} }
if (!overlayGeo || overlayGeo.attributes.position.count === 0) return; if (!overlayGeo || overlayGeo.attributes.position.count === 0) { requestRender(); return; }
exclusionMesh = new THREE.Mesh( if (!_exclMaterial) {
overlayGeo, _exclMaterial = new THREE.MeshLambertMaterial({
new THREE.MeshLambertMaterial({
color, color,
side: THREE.DoubleSide, side: THREE.DoubleSide,
transparent: opacity < 1.0, transparent: opacity < 1.0,
@@ -570,10 +607,16 @@ export function setExclusionOverlay(overlayGeo, color = 0xff6600, opacity = 1.0)
polygonOffset: true, polygonOffset: true,
polygonOffsetFactor: -1, polygonOffsetFactor: -1,
polygonOffsetUnits: -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; exclusionMesh.renderOrder = 1;
scene.add(exclusionMesh); scene.add(exclusionMesh);
requestRender();
} }
/** /**
@@ -586,13 +629,11 @@ export function setHoverPreview(overlayGeo, color = 0xffee00) {
if (hoverMesh) { if (hoverMesh) {
scene.remove(hoverMesh); scene.remove(hoverMesh);
hoverMesh.geometry.dispose(); hoverMesh.geometry.dispose();
hoverMesh.material.dispose();
hoverMesh = null; hoverMesh = null;
} }
if (!overlayGeo || overlayGeo.attributes.position.count === 0) return; if (!overlayGeo || overlayGeo.attributes.position.count === 0) { requestRender(); return; }
hoverMesh = new THREE.Mesh( if (!_hoverMaterial) {
overlayGeo, _hoverMaterial = new THREE.MeshBasicMaterial({
new THREE.MeshBasicMaterial({
color, color,
side: THREE.DoubleSide, side: THREE.DoubleSide,
transparent: true, transparent: true,
@@ -600,10 +641,14 @@ export function setHoverPreview(overlayGeo, color = 0xffee00) {
polygonOffset: true, polygonOffset: true,
polygonOffsetFactor: -2, polygonOffsetFactor: -2,
polygonOffsetUnits: -2, polygonOffsetUnits: -2,
}), });
); } else {
_hoverMaterial.color.set(color);
}
hoverMesh = new THREE.Mesh(overlayGeo, _hoverMaterial);
hoverMesh.renderOrder = 2; hoverMesh.renderOrder = 2;
scene.add(hoverMesh); scene.add(hoverMesh);
requestRender();
} }
/** /**
@@ -618,6 +663,7 @@ export function setWireframe(enabled) {
} else { } else {
if (wireframeLines) wireframeLines.visible = false; if (wireframeLines) wireframeLines.visible = false;
} }
requestRender();
} }
function _buildWireframe(geometry) { function _buildWireframe(geometry) {