diff --git a/src/viewer/SceneManager.ts b/src/viewer/SceneManager.ts index 8b7e0fb7..057fa8c9 100644 --- a/src/viewer/SceneManager.ts +++ b/src/viewer/SceneManager.ts @@ -68,6 +68,7 @@ import { clippedRaycast } from './clipRaycast' import { clipTree } from './clipTree' import { CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js' import { VolumeLayer } from './volumeLayer' +import { clusterIndices, MotionLatch } from './lod' import type { VolumeDeviation } from '../core/deviation/volume' declare module 'three' { @@ -404,6 +405,11 @@ export class SceneManager { * A ghost until the workspace says otherwise, which is how it starts. */ private nominalGhost = true + /** The scan's coarse stand-in while the camera moves — see lod.ts. */ + private lodLatch = new MotionLatch() + private lodCache: { geometry: THREE.BufferGeometry; position: ArrayLike; index: ArrayLike; proxy: THREE.Mesh | null } | null = null + private lodShown: { proxy: THREE.Mesh; geometry: THREE.BufferGeometry } | null = null + /** Scratch for picking, which runs every frame the cursor moves: the * barycentric corners and difference vectors, and (in D) the hit point * carried into the part's frame. */ @@ -467,6 +473,7 @@ export class SceneManager { this.grips.putAwayWhile(this.marking.gestureArmed()) this.overlays.setPreviewBrushing(this.marking.gestureArmed()) this.updateHover() + this.stepLod() // Anything held at a fixed size on screen — the picked-point markers — // has to be re-scaled when the zoom changes. A no-op on the frames it // has not. @@ -2944,7 +2951,73 @@ export class SceneManager { this.nominalMesh = null } + /** Swap the scan for its coarse proxy while the camera moves, and back + * once it settles — see lod.ts. The full mesh is kept, only not drawn: + * its draw range is emptied, so picks still go through it. */ + private stepLod(): void { + const moving = this.lodLatch.update(this.camera.matrixWorld.elements, performance.now()) + const want = moving && this.mesh ? this.lodProxyFor(this.mesh) : null + if (want === this.lodShown?.proxy) return + this.hideLod() + if (want && this.mesh) { + const geometry = this.mesh.geometry as THREE.BufferGeometry + this.mesh.add(want) + geometry.setDrawRange(0, 0) + this.lodShown = { proxy: want, geometry } + } + this.invalidate() + } + + private hideLod(): void { + if (!this.lodShown) return + this.lodShown.geometry.setDrawRange(0, Infinity) + this.lodShown.proxy.removeFromParent() + this.lodShown = null + } + + /** The proxy of the scan as it stands, made on first need and kept until + * its arrays change hands; null for a scan small enough to draw whole. */ + private lodProxyFor(mesh: THREE.Mesh): THREE.Mesh | null { + const geometry = mesh.geometry as THREE.BufferGeometry + const position = geometry.getAttribute('position') + const index = geometry.index + if (!index || !position) return null + const c = this.lodCache + if (c && c.geometry === geometry && c.position === position.array && c.index === index.array) return c.proxy + this.dropLod() + const cut = clusterIndices(position.array as Float32Array, index.array as Uint32Array) + let proxy: THREE.Mesh | null = null + if (cut) { + const shell = new THREE.BufferGeometry() + shell.setIndex(new THREE.BufferAttribute(cut, 1)) + proxy = new THREE.Mesh(shell, mesh.material) + proxy.frustumCulled = false + proxy.raycast = () => {} + // The scan's attributes are borrowed as drawn, so colours, deviation + // and paint are whatever the full mesh carries this frame. + const p = proxy + p.onBeforeRender = (...args) => { + const full = mesh.geometry as THREE.BufferGeometry + for (const name of Object.keys(shell.attributes)) if (!full.getAttribute(name)) shell.deleteAttribute(name) + for (const [name, attr] of Object.entries(full.attributes)) { + if (shell.getAttribute(name) !== attr) shell.setAttribute(name, attr) + } + p.material = mesh.material + mesh.onBeforeRender(...args) + } + } + this.lodCache = { geometry, position: position.array, index: index.array, proxy } + return proxy + } + + private dropLod(): void { + this.hideLod() + this.lodCache?.proxy?.geometry.dispose() + this.lodCache = null + } + private disposeMesh(): void { + this.dropLod() this.setEditedMesh(null) this.regions.detach() this.marking.meshDisposed() diff --git a/src/viewer/lod.ts b/src/viewer/lod.ts new file mode 100644 index 00000000..e7c4d3a3 --- /dev/null +++ b/src/viewer/lod.ts @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Level of detail for the scan while the camera moves. A scan of a few million +// triangles orbits at a crawl, so while the view is in motion a coarse proxy +// is drawn in its place and the full mesh comes back once the view settles. +// The proxy is only an index buffer over the scan's own vertices — colours, +// deviation, paint and texture ride along unchanged — and it is never +// raycast: picking and measuring always see full detail. + +/** Scans above this many triangles get a proxy, cut to about this many. */ +export const LOD_TRIANGLES = 400_000 + +/** How long the view has to hold still before full detail comes back. */ +export const LOD_SETTLE_MS = 180 + +/** + * Triangles of a coarser mesh by vertex clustering: the bounding box is cut + * into a grid, every vertex stands in for the first one met in its cell, and + * triangles that collapse are dropped. The grid is resized until the result + * is near `target` triangles. Null when the mesh is small enough already. + */ +export function clusterIndices(positions: Float32Array, indices: Uint32Array, target = LOD_TRIANGLES): Uint32Array | null { + const tris = indices.length / 3 + if (tris <= target) return null + const n = positions.length / 3 + let minX = Infinity, minY = Infinity, minZ = Infinity + let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity + for (let i = 0; i < n; i++) { + const x = positions[i * 3], y = positions[i * 3 + 1], z = positions[i * 3 + 2] + if (x < minX) minX = x + if (x > maxX) maxX = x + if (y < minY) minY = y + if (y > maxY) maxY = y + if (z < minZ) minZ = z + if (z > maxZ) maxZ = z + } + const extent = Math.max(maxX - minX, maxY - minY, maxZ - minZ, 1e-9) + const rep = new Int32Array(n) + const cells = new Map() + // A surface meets about as many cells as the grid's side squared, and a + // mesh has about twice as many triangles as vertices. + let side = Math.max(2, Math.sqrt(target / 2)) + let out: Uint32Array = new Uint32Array(0) + for (let pass = 0; pass < 3; pass++) { + const k = Math.min(Math.floor(side), 2 ** 20 - 1) + const s = k / extent + cells.clear() + for (let i = 0; i < n; i++) { + const cx = Math.min(k, Math.floor((positions[i * 3] - minX) * s)) + const cy = Math.min(k, Math.floor((positions[i * 3 + 1] - minY) * s)) + const cz = Math.min(k, Math.floor((positions[i * 3 + 2] - minZ) * s)) + const key = (cx * (k + 1) + cy) * (k + 1) + cz + let r = cells.get(key) + if (r === undefined) cells.set(key, (r = i)) + rep[i] = r + } + const kept: number[] = [] + for (let t = 0; t < indices.length; t += 3) { + const a = rep[indices[t]], b = rep[indices[t + 1]], c = rep[indices[t + 2]] + if (a !== b && b !== c && a !== c) kept.push(a, b, c) + } + out = Uint32Array.from(kept) + const got = out.length / 3 + if (got > target * 0.7 && got <= target * 1.1) break + side *= Math.sqrt(target / Math.max(got, 1)) + } + return out.length / 3 < tris ? out : null +} + +/** Whether the camera is on the move: true from the frame its matrix changes + * until it has held still for `settleMs`. */ +export class MotionLatch { + private last: number[] | null = null + private movedAt = -Infinity + + constructor(private settleMs = LOD_SETTLE_MS) {} + + update(matrix: ArrayLike, now: number): boolean { + const m = Array.from(matrix) + if (this.last && m.some((v, i) => v !== this.last![i])) this.movedAt = now + this.last = m + return now - this.movedAt < this.settleMs + } +} diff --git a/tests/lod.test.ts b/tests/lod.test.ts new file mode 100644 index 00000000..ae613e61 --- /dev/null +++ b/tests/lod.test.ts @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { describe, expect, it } from 'vitest' +import { clusterIndices, MotionLatch } from '../src/viewer/lod' + +/** A flat grid of side `n` quads, two triangles each. */ +function grid(n: number): { positions: Float32Array; indices: Uint32Array } { + const positions = new Float32Array((n + 1) * (n + 1) * 3) + for (let y = 0; y <= n; y++) for (let x = 0; x <= n; x++) positions.set([x, y, 0], (y * (n + 1) + x) * 3) + const indices: number[] = [] + for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) { + const a = y * (n + 1) + x + indices.push(a, a + 1, a + n + 1, a + 1, a + n + 2, a + n + 1) + } + return { positions, indices: Uint32Array.from(indices) } +} + +describe('level of detail', () => { + it('leaves a small mesh alone', () => { + const g = grid(10) + expect(clusterIndices(g.positions, g.indices, 1000)).toBeNull() + }) + + it('cuts a big mesh to near the budget over its own vertices', () => { + const g = grid(200) + const cut = clusterIndices(g.positions, g.indices, 5000)! + const tris = cut.length / 3 + expect(tris).toBeLessThan(g.indices.length / 3) + expect(tris).toBeGreaterThan(2000) + expect(tris).toBeLessThan(8000) + const vertices = g.positions.length / 3 + for (let t = 0; t < cut.length; t += 3) { + expect(cut[t] < vertices && cut[t] !== cut[t + 1] && cut[t + 1] !== cut[t + 2]).toBe(true) + } + }) + + it('holds motion until the camera settles', () => { + const latch = new MotionLatch(100) + expect(latch.update([1, 0], 0)).toBe(false) + expect(latch.update([2, 0], 10)).toBe(true) + expect(latch.update([2, 0], 90)).toBe(true) + expect(latch.update([2, 0], 111)).toBe(false) + }) +})