Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions src/viewer/SceneManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' {
Expand Down Expand Up @@ -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<number>; index: ArrayLike<number>; 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. */
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand Down
83 changes: 83 additions & 0 deletions src/viewer/lod.ts
Original file line number Diff line number Diff line change
@@ -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<number, number>()
// 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<number>, 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
}
}
43 changes: 43 additions & 0 deletions tests/lod.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading