From 43c35b429f36dbc3174a62003cc88ba5193704fd Mon Sep 17 00:00:00 2001 From: Michal Dub <37038617+midub@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:21:24 +0000 Subject: [PATCH 1/6] Viewer: shared node materials, x-ray by material swap, merged component bodies One node material per source material and use for the whole board, with a normal and an x-ray variant; the state texel comes from the geometry (merged layer meshes store the global texel instead of the feature ID). setXray swaps the meshes' materials, and the other mode is compiled in idle time after loading. The asset's component bodies are merged into one static mesh per material (large repeated meshes stay instanced); runtime models stay instanced and share their materials by content. Work in progress. --- packages/viewer/src/board-model.ts | 60 +-- packages/viewer/src/bodies.ts | 416 ++++++++++++++++++-- packages/viewer/src/element.ts | 90 +++-- packages/viewer/src/feature-ranges.ts | 14 +- packages/viewer/src/materials.ts | 208 +++++++--- packages/viewer/src/picking.ts | 18 +- packages/viewer/src/renderer.ts | 8 + packages/viewer/src/state.ts | 6 + packages/viewer/test/board-model.test.ts | 12 +- packages/viewer/test/bodies.test.ts | 146 +++++++ packages/viewer/test/dense.test.ts | 7 +- packages/viewer/test/element.test.ts | 1 + packages/viewer/test/materials.test.ts | 119 ++++++ packages/viewer/test/runtime-models.test.ts | 26 +- 14 files changed, 952 insertions(+), 179 deletions(-) create mode 100644 packages/viewer/test/bodies.test.ts create mode 100644 packages/viewer/test/materials.test.ts diff --git a/packages/viewer/src/board-model.ts b/packages/viewer/src/board-model.ts index ce14153..b7b6297 100644 --- a/packages/viewer/src/board-model.ts +++ b/packages/viewer/src/board-model.ts @@ -22,7 +22,7 @@ import { DRAWING_ROLES, readBoardExtension, } from './board-extension.js'; -import { ComponentBatch, ComponentBodies } from './bodies.js'; +import { type BodyGroup, batchBodies, type ComponentBatch, ComponentBodies } from './bodies.js'; import { FeatureRanges } from './feature-ranges.js'; import { type ElementKind, featureId, idKind, parseFeatureId } from './ids.js'; import { @@ -31,6 +31,7 @@ import { parsePropertyTables, type StructuralMetadataJson, } from './metadata.js'; +import { STATE_ATTRIBUTE } from './state.js'; /** Row reference meaning "none" (spec §8.2). */ const NONE = 4294967295; @@ -66,7 +67,7 @@ export interface LayerModel { readonly color: string | undefined; } -export { ComponentBatch } from './bodies.js'; +export type { ComponentBatch } from './bodies.js'; /** An element resolved to its state texels and bounding box (spec §9). */ export interface ResolvedElement { @@ -229,7 +230,7 @@ export class BoardModel { const group = new Group(); group.name = info.id; group.visible = kind === 'drill' || (info as BoardLayerJson).visible; - const meshes = mergeLayer(info.id, node, ranges); + const meshes = mergeLayer(info.id, node, ranges, stateOffset); if (meshes.length) group.add(...meshes); const layer = { id: info.id, @@ -596,12 +597,12 @@ export class BoardModel { } /** - * Groups component meshes by geometry and material into instanced meshes (spec §6.8), and - * finds the components whose body is a placeholder: meshes on the node itself with the + * Groups component meshes by geometry and material and batches them (spec §6.8, `bodies.ts`), + * and finds the components whose body is a placeholder: meshes on the node itself with the * materials `boardui/body` and `boardui/pin1` only. */ #batchComponents(nodes: ReadonlyMap): ComponentBodies { - const groups = new Map(); + const groups = new Map(); const count = this.components.count; const matrices: (Matrix4 | null)[] = new Array(count).fill(null); const replaceable = new Uint8Array(count); @@ -620,7 +621,7 @@ export class BoardModel { const key = `${mesh.geometry.uuid}/${material.uuid}`; let group = groups.get(key); if (!group) { - group = { source: mesh, matrices: [], rows: [] }; + group = { geometry: mesh.geometry, material, matrices: [], rows: [] }; groups.set(key, group); } group.matrices.push(mesh.matrixWorld.clone()); @@ -628,16 +629,14 @@ export class BoardModel { }); replaceable[row] = placeholder && meshes > 0 ? 1 : 0; } - const batches = [...groups.values()].map( - ({ source, matrices, rows }) => - new ComponentBatch( - source.geometry, - source.material as Material, - Uint32Array.from(rows), - matrices, - ), + const batches = batchBodies([...groups.values()], this.componentOffset); + return new ComponentBodies( + this.componentGroup, + batches, + this.componentOffset, + matrices, + replaceable, ); - return new ComponentBodies(this.componentGroup, batches, matrices, replaceable); } } @@ -654,9 +653,15 @@ function sourceKey(instance: number | null, source: number): number { /** * Merges the primitives of a layer node into one mesh per material, with positions in board - * coordinates and 32-bit indices, and records the feature ranges (spec §8.1). + * coordinates, 32-bit indices and each vertex's state texel (its feature ID plus `stateOffset`, + * {@link STATE_ATTRIBUTE}), and records the feature ranges (spec §8.1). */ -function mergeLayer(id: string, node: Object3D, ranges: FeatureRanges): Mesh[] { +function mergeLayer( + id: string, + node: Object3D, + ranges: FeatureRanges, + stateOffset: number, +): Mesh[] { const byMaterial = new Map(); node.traverse((object) => { const mesh = object as Mesh; @@ -674,7 +679,7 @@ function mergeLayer(id: string, node: Object3D, ranges: FeatureRanges): Mesh[] { indexCount += part.geometry.index?.count ?? count; } const positions = new Float32Array(vertexCount * 3); - const featureIds = new Float32Array(vertexCount); + const texels = new Float32Array(vertexCount); const index = new Uint32Array(indexCount); let v = 0; let i = 0; @@ -688,25 +693,26 @@ function mergeLayer(id: string, node: Object3D, ranges: FeatureRanges): Mesh[] { point.fromBufferAttribute(position, k); if (transform) point.applyMatrix4(part.matrixWorld); point.toArray(positions, (v + k) * 3); - featureIds[v + k] = ids.getX(k); + texels[v + k] = ids.getX(k) + stateOffset; } const source = geometry.index; const count = source?.count ?? position.count; for (let k = 0; k < count; k++) { index[i + k] = v + (source ? source.getX(k) : k); } - ranges.scan(featureIds, positions, index, { - vertexStart: v, - vertexEnd: v + position.count, - indexStart: i, - indexEnd: i + count, - }); + ranges.scan( + texels, + positions, + index, + { vertexStart: v, vertexEnd: v + position.count, indexStart: i, indexEnd: i + count }, + stateOffset, + ); v += position.count; i += count; } const geometry = new BufferGeometry(); geometry.setAttribute('position', new BufferAttribute(positions, 3)); - geometry.setAttribute('_feature_id_0', new BufferAttribute(featureIds, 1)); + geometry.setAttribute(STATE_ATTRIBUTE, new BufferAttribute(texels, 1)); geometry.setIndex(new BufferAttribute(index, 1)); geometry.computeBoundingBox(); geometry.computeBoundingSphere(); diff --git a/packages/viewer/src/bodies.ts b/packages/viewer/src/bodies.ts index 0c20bbf..2471dd5 100644 --- a/packages/viewer/src/bodies.ts +++ b/packages/viewer/src/bodies.ts @@ -1,24 +1,100 @@ /** - * Component bodies: the placeholder bodies (and user models) of the asset, batched into - * instanced meshes, and the runtime models that replace placeholder bodies (spec/README.md, - * "Runtime models"). A component's instances all read its state texel, so hover, picking, - * selection and highlights work the same on both. + * Component bodies: the placeholder bodies (and user models) of the asset, and the runtime models + * that replace placeholder bodies (spec/README.md, "Runtime models"). + * + * The asset's bodies are merged into one static mesh per material, so that a board draws them + * with a few meshes however many packages it has; only large meshes that repeat stay instanced + * (see {@link MERGE_MAX_COPIED_VERTICES}). Runtime models are instanced. Every vertex (merged) or + * instance carries its component's state texel ({@link STATE_ATTRIBUTE}), so hover, picking, + * selection and highlights work the same on all bodies, and they share their materials. */ import { + BackSide, Box3, - type BufferGeometry, + BufferAttribute, + BufferGeometry, + FrontSide, Group, InstancedBufferAttribute, InstancedMesh, type Material, + Matrix3, Matrix4, + Mesh, + type Raycaster, + type Side, + Sphere, + Vector3, } from 'three'; +import { STATE_ATTRIBUTE } from './state.js'; + +/** + * The asset's meshes are merged unless the copies of a mesh would add more vertices than this + * (vertices × (instances − 1)); then they are instanced. Placeholder bodies have tens of vertices. + */ +export const MERGE_MAX_COPIED_VERTICES = 1 << 18; + +/** Receives a component instance that a ray hits. */ +export type BodyHit = (row: number, point: Vector3, distance: number) => void; + +/** Component meshes drawn as one: an instanced mesh, or a merged one. */ +export interface ComponentBatch { + /** The mesh; it has the source material until the viewer gives it its own. */ + readonly mesh: Mesh; + /** Component row of each drawn instance. */ + readonly rows: Uint32Array; + /** Draws only the instances whose component passes `keep`. */ + filter(keep: (row: number) => boolean): void; + /** Adds the bounding box of each instance to its component's, as stored (float32). */ + addBounds(bounds: Box3[]): void; + /** Reports the drawn instances hit by the ray of `raycaster`, as three's ray casts do. */ + raycast(raycaster: Raycaster, hit: BodyHit): void; + /** Releases the GPU resources of the batch. */ + dispose(): void; +} + +/** Meshes of the asset's components that share geometry and material, with their instances. */ +export interface BodyGroup { + readonly geometry: BufferGeometry; + readonly material: Material; + /** Component row of each instance. */ + readonly rows: readonly number[]; + /** World matrix of each instance. */ + readonly matrices: readonly Matrix4[]; +} + +/** + * Batches the asset's component meshes: merged per material and vertex layout, except large + * meshes that repeat, which are instanced. + * + * @param texelOffset State texel of component row 0. + */ +export function batchBodies(groups: readonly BodyGroup[], texelOffset: number): ComponentBatch[] { + const merged = new Map(); + const instanced: ComponentBatch[] = []; + for (const group of groups) { + const { geometry, material, rows, matrices } = group; + const copies = geometry.getAttribute('position').count * (rows.length - 1); + if (copies > MERGE_MAX_COPIED_VERTICES || !mergeable(geometry)) { + instanced.push( + new InstancedBatch(geometry, material, Uint32Array.from(rows), matrices, texelOffset), + ); + continue; + } + const key = `${material.uuid}|${layout(geometry)}`; + const list = merged.get(key); + if (list) list.push(group); + else merged.set(key, [group]); + } + return [...[...merged.values()].map((list) => new MergedBatch(list, texelOffset)), ...instanced]; +} /** Component meshes that share geometry and material, drawn as one instanced mesh. */ -export class ComponentBatch { +export class InstancedBatch implements ComponentBatch { readonly mesh: InstancedMesh; - /** Component row of every drawn instance, as the material reads it. */ - readonly rowAttribute: InstancedBufferAttribute; + /** State texel of every drawn instance, as the material reads it. */ + readonly #texels: InstancedBufferAttribute; + readonly #texelOffset: number; /** Rows and matrices of all instances, drawn or not. */ readonly #allRows: Uint32Array; readonly #allMatrices: Float32Array; @@ -26,13 +102,25 @@ export class ComponentBatch { /** Index into the instances of each drawn one. */ readonly #drawn: Uint32Array; + /** + * @param geometry Not modified: the batch draws a copy that shares its attributes. + * @param texelOffset State texel of component row 0. + */ constructor( geometry: BufferGeometry, material: Material, rows: Uint32Array, matrices: readonly Matrix4[], + texelOffset: number, ) { - this.mesh = new InstancedMesh(geometry, material, rows.length); + const own = shallowCopy(geometry); + this.#texelOffset = texelOffset; + this.#texels = new InstancedBufferAttribute( + Float32Array.from(rows, (row) => row + texelOffset), + 1, + ); + own.setAttribute(STATE_ATTRIBUTE, this.#texels); + this.mesh = new InstancedMesh(own, material, rows.length); matrices.forEach((matrix, i) => { this.mesh.setMatrixAt(i, matrix); }); @@ -40,19 +128,16 @@ export class ComponentBatch { this.#allMatrices = (this.mesh.instanceMatrix.array as Float32Array).slice(); this.#rows = rows.slice(); this.#drawn = Uint32Array.from(rows, (_, i) => i); - this.rowAttribute = new InstancedBufferAttribute(Float32Array.from(rows), 1); this.#update(); } - /** Component row of each drawn instance. */ get rows(): Uint32Array { return this.#rows.subarray(0, this.mesh.count); } - /** Draws only the instances whose component passes `keep`. */ filter(keep: (row: number) => boolean): void { const matrices = this.mesh.instanceMatrix.array as Float32Array; - const rows = this.rowAttribute.array as Float32Array; + const texels = this.#texels.array as Float32Array; let count = 0; let changed = false; this.#allRows.forEach((row, i) => { @@ -61,7 +146,7 @@ export class ComponentBatch { changed = true; this.#drawn[count] = i; this.#rows[count] = row; - rows[count] = row; + texels[count] = row + this.#texelOffset; matrices.set(this.#allMatrices.subarray(i * 16, i * 16 + 16), count * 16); } count++; @@ -69,11 +154,34 @@ export class ComponentBatch { if (!changed && count === this.mesh.count) return; this.mesh.count = count; this.mesh.instanceMatrix.needsUpdate = true; - this.rowAttribute.needsUpdate = true; + this.#texels.needsUpdate = true; this.#update(); } - /** Releases the GPU resources of the geometry and the instances. */ + addBounds(bounds: Box3[]): void { + const { mesh } = this; + const geometry = mesh.geometry; + geometry.computeBoundingBox(); + const box = new Box3(); + const matrix = new Matrix4(); + this.rows.forEach((row, i) => { + mesh.getMatrixAt(i, matrix); + box.copy(geometry.boundingBox as Box3).applyMatrix4(matrix); + bounds[row]?.union(box); + }); + } + + raycast(raycaster: Raycaster, hit: BodyHit): void { + for (const h of raycaster.intersectObject(this.mesh, false)) { + const row = this.rows[h.instanceId ?? -1]; + if (row !== undefined) hit(row, h.point, h.distance); + } + } + + /** + * Releases the batch's geometry and instances. three then also releases the GPU buffers of + * attributes shared with the source geometry; it uploads them again if they are drawn later. + */ dispose(): void { this.mesh.geometry.dispose(); this.mesh.dispose(); @@ -86,6 +194,213 @@ export class ComponentBatch { } } +/** + * Instances of component meshes baked into one static mesh with one material: positions (and + * normals) in board coordinates, and a state texel per vertex. Hiding instances shortens the + * index buffer. + */ +export class MergedBatch implements ComponentBatch { + readonly mesh: Mesh; + /** Component row of each instance. */ + readonly #rows: Uint32Array; + /** Component row of each drawn instance. */ + readonly #drawnRows: Uint32Array; + #drawnCount: number; + /** Whether each instance is drawn. */ + readonly #drawn: Uint8Array; + /** Indices of all instances; instance `i` owns `#indexStart[i]` up to `#indexStart[i + 1]`. */ + readonly #allIndex: Uint32Array; + readonly #indexStart: Uint32Array; + /** Box of each instance's vertices, to skip instances in ray casts. */ + readonly #boxes: Box3[]; + /** Bounding box of each instance as {@link InstancedBatch} computes it. */ + readonly #bounds: Box3[]; + readonly #positions: Float32Array; + readonly #side: Side; + + /** + * @param groups Groups with one material and the same vertex attributes. + * @param texelOffset State texel of component row 0. + */ + constructor(groups: readonly BodyGroup[], texelOffset: number) { + const first = groups[0]; + if (!first) throw new Error('MergedBatch needs a group'); + const material = first.material; + const names = Object.keys(first.geometry.attributes); + let vertexCount = 0; + let indexCount = 0; + let instances = 0; + for (const { geometry, rows } of groups) { + const count = geometry.getAttribute('position').count; + vertexCount += count * rows.length; + indexCount += (geometry.index?.count ?? count) * rows.length; + instances += rows.length; + } + const arrays = new Map(); + for (const name of names) { + const itemSize = first.geometry.getAttribute(name).itemSize; + arrays.set(name, new Float32Array(vertexCount * itemSize)); + } + const texels = new Float32Array(vertexCount); + this.#positions = arrays.get('position') as Float32Array; + this.#allIndex = new Uint32Array(indexCount); + this.#indexStart = new Uint32Array(instances + 1); + this.#rows = new Uint32Array(instances); + this.#boxes = []; + this.#bounds = []; + + const point = new Vector3(); + const matrix = new Matrix4(); + const normalMatrix = new Matrix3(); + let v = 0; + let i = 0; + let instance = 0; + for (const { geometry, rows, matrices } of groups) { + geometry.computeBoundingBox(); + const position = geometry.getAttribute('position'); + const index = geometry.index; + const count = index?.count ?? position.count; + rows.forEach((row, k) => { + // Rounded like an instance matrix, so that bounds are as an instanced batch stores them. + matrix.fromArray(Float32Array.from((matrices[k] as Matrix4).elements)); + normalMatrix.getNormalMatrix(matrix); + const box = new Box3(); + for (const name of names) { + const source = geometry.getAttribute(name); + const target = arrays.get(name) as Float32Array; + const size = source.itemSize; + for (let j = 0; j < position.count; j++) { + const at = (v + j) * size; + if (name === 'position') { + point.fromBufferAttribute(source, j).applyMatrix4(matrix).toArray(target, at); + box.expandByPoint(point); + } else if (name === 'normal') { + point.fromBufferAttribute(source, j).applyNormalMatrix(normalMatrix); + point.toArray(target, at); + } else { + for (let c = 0; c < size; c++) target[at + c] = source.getComponent(j, c); + } + } + } + texels.fill(row + texelOffset, v, v + position.count); + // A mirroring matrix turns the triangles over; restore their winding. + const mirrored = matrix.determinant() < 0; + for (let j = 0; j < count; j += 3) { + const a = index ? index.getX(j) : j; + const b = index ? index.getX(j + 1) : j + 1; + const c = index ? index.getX(j + 2) : j + 2; + this.#allIndex[i + j] = v + a; + this.#allIndex[i + j + 1] = v + (mirrored ? c : b); + this.#allIndex[i + j + 2] = v + (mirrored ? b : c); + } + this.#rows[instance] = row; + this.#boxes.push(box); + this.#bounds.push((geometry.boundingBox as Box3).clone().applyMatrix4(matrix)); + v += position.count; + i += count; + instance++; + this.#indexStart[instance] = i; + }); + } + + const merged = new BufferGeometry(); + for (const name of names) { + const itemSize = first.geometry.getAttribute(name).itemSize; + merged.setAttribute(name, new BufferAttribute(arrays.get(name) as Float32Array, itemSize)); + } + merged.setAttribute(STATE_ATTRIBUTE, new BufferAttribute(texels, 1)); + merged.setIndex(new BufferAttribute(this.#allIndex.slice(), 1)); + this.mesh = new Mesh(merged, material); + this.mesh.name = material.name; + this.#side = material.side; + this.#drawn = new Uint8Array(instances).fill(1); + this.#drawnRows = this.#rows.slice(); + this.#drawnCount = instances; + this.#update(); + } + + get rows(): Uint32Array { + return this.#drawnRows.subarray(0, this.#drawnCount); + } + + filter(keep: (row: number) => boolean): void { + let changed = false; + this.#rows.forEach((row, i) => { + const drawn = keep(row) ? 1 : 0; + if (this.#drawn[i] !== drawn) { + this.#drawn[i] = drawn; + changed = true; + } + }); + if (!changed) return; + const index = this.mesh.geometry.index as BufferAttribute; + const array = index.array as Uint32Array; + let length = 0; + let count = 0; + this.#rows.forEach((row, i) => { + if (!this.#drawn[i]) return; + const start = this.#indexStart[i] as number; + const end = this.#indexStart[i + 1] as number; + array.set(this.#allIndex.subarray(start, end), length); + length += end - start; + this.#drawnRows[count++] = row; + }); + this.#drawnCount = count; + this.mesh.geometry.setDrawRange(0, length); + index.needsUpdate = true; + this.#update(); + } + + addBounds(bounds: Box3[]): void { + this.#rows.forEach((row, i) => { + bounds[row]?.union(this.#bounds[i] as Box3); + }); + } + + raycast(raycaster: Raycaster, hit: BodyHit): void { + const { ray, near, far } = raycaster; + const positions = this.#positions; + const index = this.#allIndex; + const a = new Vector3(); + const b = new Vector3(); + const c = new Vector3(); + const point = new Vector3(); + const cull = this.#side === FrontSide; + this.#rows.forEach((row, i) => { + if (!this.#drawn[i] || !ray.intersectsBox(this.#boxes[i] as Box3)) return; + const end = this.#indexStart[i + 1] as number; + for (let j = this.#indexStart[i] as number; j < end; j += 3) { + a.fromArray(positions, (index[j] as number) * 3); + b.fromArray(positions, (index[j + 1] as number) * 3); + c.fromArray(positions, (index[j + 2] as number) * 3); + const found = + this.#side === BackSide + ? ray.intersectTriangle(c, b, a, true, point) + : ray.intersectTriangle(a, b, c, cull, point); + if (!found) continue; + const distance = ray.origin.distanceTo(point); + if (distance >= near && distance <= far) hit(row, point.clone(), distance); + } + }); + } + + dispose(): void { + this.mesh.geometry.dispose(); + } + + /** Bounds of the drawn instances, for frustum culling and the board's bounds. */ + #update(): void { + const geometry = this.mesh.geometry; + geometry.boundingBox ??= new Box3(); + const box = geometry.boundingBox.makeEmpty(); + this.#rows.forEach((_, i) => { + if (this.#drawn[i]) box.union(this.#boxes[i] as Box3); + }); + geometry.boundingSphere = box.getBoundingSphere(geometry.boundingSphere ?? new Sphere()); + this.mesh.visible = this.#drawnCount > 0; + } +} + /** The bodies of all components: the asset's batches, and runtime models replacing some. */ export class ComponentBodies { /** Holds every body mesh; its `visible` flag shows or hides all components. */ @@ -93,16 +408,18 @@ export class ComponentBodies { /** The asset's batches: placeholder bodies, pin-1 markers and user models. */ readonly placeholders: readonly ComponentBatch[]; readonly #models = new Group(); + readonly #texelOffset: number; readonly #nodeMatrices: readonly (Matrix4 | null)[]; readonly #replaceable: Uint8Array; readonly #assetBounds: Box3[]; readonly #modelBounds: Box3[]; /** Key of the runtime model of each row, if it has one. */ readonly #modelKeys: (string | undefined)[]; - readonly #modelBatches = new Map(); + readonly #modelBatches = new Map(); #shown = true; /** + * @param texelOffset State texel of component row 0. * @param nodeMatrices World matrix of each component's node. * @param replaceable Whether a row's body is a placeholder (spec §6.8) that a runtime model * may replace: it has one, and no user model. @@ -110,11 +427,13 @@ export class ComponentBodies { constructor( group: Group, placeholders: ComponentBatch[], + texelOffset: number, nodeMatrices: (Matrix4 | null)[], replaceable: Uint8Array, ) { this.group = group; this.placeholders = placeholders; + this.#texelOffset = texelOffset; this.#nodeMatrices = nodeMatrices; this.#replaceable = replaceable; const count = nodeMatrices.length; @@ -123,7 +442,7 @@ export class ComponentBodies { this.#modelKeys = new Array(count); for (const batch of placeholders) { group.add(batch.mesh); - bound(batch, this.#assetBounds); + batch.addBounds(this.#assetBounds); } this.#models.name = 'models'; group.add(this.#models); @@ -178,14 +497,15 @@ export class ComponentBodies { ): ComponentBatch[] { this.removeModel(key); const batches = parts.map( - ({ geometry, material }) => new ComponentBatch(geometry, material, rows, matrices), + ({ geometry, material }) => + new InstancedBatch(geometry, material, rows, matrices, this.#texelOffset), ); for (const row of rows) { this.#modelKeys[row] = key; this.#modelBounds[row]?.makeEmpty(); } for (const batch of batches) { - bound(batch, this.#modelBounds); + batch.addBounds(this.#modelBounds); this.#models.add(batch.mesh); } this.#modelBatches.set(key, batches); @@ -196,8 +516,8 @@ export class ComponentBodies { /** * Removes the runtime model of a key, showing the placeholders again. * - * @returns The removed batches; the caller releases their materials. Their geometry is shared - * with other loads of the model, so it is not disposed. + * @returns The removed batches, disposed; the caller releases their materials. The parts' + * geometry is shared with other loads of the model, so it is not disposed. */ removeModel(key: string): ComponentBatch[] { const batches = this.#modelBatches.get(key); @@ -205,7 +525,7 @@ export class ComponentBodies { this.#modelBatches.delete(key); for (const batch of batches) { this.#models.remove(batch.mesh); - batch.mesh.dispose(); + batch.dispose(); for (const row of batch.rows) { if (this.#modelKeys[row] === key) this.#modelKeys[row] = undefined; } @@ -228,7 +548,7 @@ export class ComponentBodies { dispose(): void { for (const batch of this.placeholders) batch.dispose(); - for (const batch of this.clearModels()) batch.mesh.dispose(); + this.clearModels(); } #filter(): void { @@ -237,16 +557,40 @@ export class ComponentBodies { } } -/** Adds the instances of a batch to their components' bounds, as stored (float32). */ -function bound(batch: ComponentBatch, bounds: Box3[]): void { - const { mesh } = batch; - const geometry = mesh.geometry; - geometry.computeBoundingBox(); - const box = new Box3(); - const matrix = new Matrix4(); - batch.rows.forEach((row, i) => { - mesh.getMatrixAt(i, matrix); - box.copy(geometry.boundingBox as Box3).applyMatrix4(matrix); - bounds[row]?.union(box); - }); +/** + * Whether a geometry's instances can be merged: no morph targets or tangents. Groups don't matter + * with one material. + */ +function mergeable(geometry: BufferGeometry): boolean { + return ( + !Object.keys(geometry.morphAttributes).length && + !geometry.hasAttribute('tangent') && + !geometry.hasAttribute(STATE_ATTRIBUTE) + ); +} + +/** The vertex attributes of a geometry, by name and size. */ +function layout(geometry: BufferGeometry): string { + return Object.entries(geometry.attributes) + .map(([name, attribute]) => `${name}:${attribute.itemSize}`) + .sort() + .join(','); +} + +/** A geometry that shares the attributes and index of `geometry`. */ +function shallowCopy(geometry: BufferGeometry): BufferGeometry { + const copy = new BufferGeometry(); + copy.setIndex(geometry.index); + for (const [name, attribute] of Object.entries(geometry.attributes)) { + copy.setAttribute(name, attribute); + } + copy.morphAttributes = geometry.morphAttributes; + copy.morphTargetsRelative = geometry.morphTargetsRelative; + for (const { start, count, materialIndex } of geometry.groups) { + copy.addGroup(start, count, materialIndex); + } + copy.boundingBox = geometry.boundingBox?.clone() ?? null; + copy.boundingSphere = geometry.boundingSphere?.clone() ?? null; + copy.name = geometry.name; + return copy; } diff --git a/packages/viewer/src/element.ts b/packages/viewer/src/element.ts index 3a25498..8e2735c 100644 --- a/packages/viewer/src/element.ts +++ b/packages/viewer/src/element.ts @@ -81,6 +81,18 @@ const MASK_DEPTH_BIAS = 4; /** Share of {@link LoadProgress.fraction} taken by the conversion; loading takes the rest. */ const CONVERT_SHARE = 0.9; +/** Runs `callback` when the browser is idle (or soon, where it can't tell). */ +function idle(callback: () => void): void { + if (globalThis.requestIdleCallback) globalThis.requestIdleCallback(() => callback()); + else setTimeout(callback, 50); +} + +/** Runs `callback` in the next animation frame, after the callbacks requested before. */ +function afterFrame(callback: () => void): void { + if (globalThis.requestAnimationFrame) globalThis.requestAnimationFrame(() => callback()); + else setTimeout(callback, 16); +} + /** Pointer travel (CSS px) below which a press and release count as a click. */ const CLICK_SLOP = 4; /** `focus(id)` leaves room around the element, as a factor of its size. */ @@ -116,6 +128,10 @@ interface Loaded { bvhs: BvhBuilder | null; /** Resolves when every pickable layer has its BVH. */ pickable: Promise; + /** Settles when three has compiled the materials of the other x-ray mode (see `#prepare`). */ + prepared: Promise; + /** Whether a `#prepare` run is scheduled. */ + prepareQueued: boolean; } /** @@ -245,25 +261,22 @@ export class BoardViewerElement extends ElementBase { } const state = new ElementState(model.stateCount); const materials = new BoardMaterials(state); + materials.setXray(this.#xray); const overlays: Loaded['overlays'] = []; for (const layer of model.layers) { const copper = !('role' in layer.info) || layer.info.role === 'COPPER'; + // The mask's bottom face lies on the dielectric and the copper (spec §6.5, ADR 0006): push + // it back a little so that the copper wins where they meet (seen from below with the + // dielectric hidden). A constant bias only: a slope-scaled one pushes the whole mask + // behind the copper (20 µm below its top) in oblique views. + const mask = 'role' in layer.info && layer.info.role === 'SOLDERMASK'; for (const mesh of layer.meshes) { const source = mesh.material as Material; const xray = copper ? XRAY_COPPER_OPACITY : XRAY_OPACITY; - mesh.material = materials.layer(source, layer.stateOffset, xray); - if ('role' in layer.info && layer.info.role === 'SOLDERMASK') { - // The mask's bottom face lies on the dielectric and the copper (spec §6.5, ADR 0006): - // push it back a little so that the copper wins where they meet (seen from below with - // the dielectric hidden). A constant bias only: a slope-scaled one pushes the whole - // mask behind the copper (20 µm below its top) in oblique views. - const material = mesh.material as Material; - material.polygonOffset = true; - material.polygonOffsetFactor = 0; - material.polygonOffsetUnits = MASK_DEPTH_BIAS; - } + materials.layer(mesh, source, xray, mask ? MASK_DEPTH_BIAS : 0); if (copper) { - const overlay = new Mesh(mesh.geometry, materials.overlay(source, layer.stateOffset)); + const overlay = new Mesh(mesh.geometry); + materials.overlay(overlay, source); overlay.renderOrder = 1; // after the soldermask layer.group.add(overlay); overlays.push({ @@ -274,14 +287,9 @@ export class BoardViewerElement extends ElementBase { } } } - for (const { mesh, rowAttribute } of model.componentBatches) { - mesh.material = materials.components( - mesh.material as Material, - rowAttribute, - model.componentOffset, - ); + for (const { mesh } of model.componentBatches) { + materials.component(mesh, mesh.material as Material); } - materials.setXray(this.#xray); model.bodies.showModels(this.#modelsShown); const pickLayers = model.layers.filter( (layer) => !('role' in layer.info) || layer.info.role !== 'SOLDERMASK', @@ -296,6 +304,8 @@ export class BoardViewerElement extends ElementBase { overlays, bvhs: null, pickable: Promise.resolve(), + prepared: Promise.resolve(), + prepareQueued: false, }; this.#unload(); this.#loaded = loaded; @@ -428,7 +438,7 @@ export class BoardViewerElement extends ElementBase { * Turns x-ray mode on or off: every layer and component becomes translucent, copper less so * than the rest, so that the copper of both sides shows. Layer visibility stays as it is: inner * copper (hidden by default) shows only when switched on, since inner planes would cover the - * view of a multilayer board. + * view of a multilayer board. Toggling swaps materials prepared after loading, so it is cheap. */ setXray(on: boolean): void { this.#xray = on; @@ -644,6 +654,26 @@ export class BoardViewerElement extends ElementBase { // Zoom limits and clipping cover the whole board, hidden layers too. renderer.setBounds(loaded.model.bounds); renderer.frame(this.#shownBounds(loaded), viewDirection('iso'), false); + this.#prepare(loaded); + } + + /** + * Has three compile the materials of the other x-ray mode in idle time after the next frame, + * so that toggling doesn't stall. three compiles the scene as it is when `compile` is called, + * so the meshes get their other variants for just that call. + */ + #prepare(loaded: Loaded): void { + if (loaded.prepareQueued) return; + loaded.prepareQueued = true; + const run = () => { + loaded.prepareQueued = false; + const renderer = this.#renderer; + if (this.#loaded !== loaded || !renderer) return; + loaded.materials.setXray(!this.#xray); + loaded.prepared = renderer.compile().catch(() => {}); + loaded.materials.setXray(this.#xray); + }; + void loaded.prepared.then(() => afterFrame(() => idle(run))); } /** @@ -654,7 +684,7 @@ export class BoardViewerElement extends ElementBase { this.#modelRun = null; const { model, materials } = loaded; const removed = model.bodies.clearModels(); - for (const batch of removed) materials.release(batch.mesh.material as Material); + for (const batch of removed) materials.release(batch.mesh); if (removed.length) this.#modelsChanged(loaded); if (!this.#modelSources.length) return; this.#modelCache ??= new ModelCache(); @@ -665,17 +695,12 @@ export class BoardViewerElement extends ElementBase { memory: this.#modelMemory, apply: (key, geometry, rows, matrices) => { if (this.#modelRun !== run) return; - for (const batch of model.bodies.removeModel(key)) { - materials.release(batch.mesh.material as Material); - } + const removed = model.bodies.removeModel(key); for (const batch of model.bodies.setModel(key, geometry.parts, rows, matrices)) { - batch.mesh.material = materials.components( - batch.mesh.material as Material, - batch.rowAttribute, - model.componentOffset, - ); + materials.component(batch.mesh, batch.mesh.material as Material); } - materials.setXray(this.#xray); + // Released after the new batches took their materials, which may be the same. + for (const batch of removed) materials.release(batch.mesh); }, report: (status) => { if (this.#modelRun !== run) return; @@ -689,12 +714,14 @@ export class BoardViewerElement extends ElementBase { } /** Bodies changed (runtime models swapped in or out): bounds, widgets and the frame follow. */ - #modelsChanged({ model }: Loaded): void { + #modelsChanged(loaded: Loaded): void { + const { model } = loaded; model.updateBounds(); this.#renderer?.setBounds(model.bounds); this.#widgetBoxes.clear(); this.#visibility++; this.#requestRender(); + this.#prepare(loaded); } /** Bounding box of what is shown; the whole board if nothing is. */ @@ -734,7 +761,6 @@ export class BoardViewerElement extends ElementBase { ); builds.push(build); } - const idle = globalThis.requestIdleCallback ?? ((callback) => setTimeout(callback, 50)); builds.push( new Promise((resolve) => { const next = () => { diff --git a/packages/viewer/src/feature-ranges.ts b/packages/viewer/src/feature-ranges.ts index 532f9a4..1a1115b 100644 --- a/packages/viewer/src/feature-ranges.ts +++ b/packages/viewer/src/feature-ranges.ts @@ -51,10 +51,11 @@ export class FeatureRanges { /** * Records the features of one primitive. * - * @param featureIds `_FEATURE_ID_0` per vertex. + * @param featureIds `_FEATURE_ID_0` per vertex, plus `offset`. * @param positions Vertex positions, three floats per vertex. * @param index Triangle indices into the same vertex buffers. * @param span Where the primitive lies in these buffers. + * @param offset Added to every feature ID in `featureIds` (the layer's first state texel). * @throws if the primitive breaks the layout rules of spec §8.1. */ scan( @@ -62,11 +63,12 @@ export class FeatureRanges { positions: ArrayLike, index: ArrayLike, span: PrimitiveSpan, + offset = 0, ): void { const { vertexStart, vertexCount, indexStart, indexCount, bounds } = this; let current = -1; for (let v = span.vertexStart; v < span.vertexEnd; v++) { - const feature = featureIds[v] as number; + const feature = (featureIds[v] as number) - offset; if (feature !== current) { this.#checkNext(feature, current, vertexCount, `vertex ${v}`); current = feature; @@ -85,13 +87,11 @@ export class FeatureRanges { } current = -1; for (let i = span.indexStart; i < span.indexEnd; i += 3) { - const feature = featureIds[index[i] as number] as number; - if ( - featureIds[index[i + 1] as number] !== feature || - featureIds[index[i + 2] as number] !== feature - ) { + const id = featureIds[index[i] as number] as number; + if (featureIds[index[i + 1] as number] !== id || featureIds[index[i + 2] as number] !== id) { throw new Error(`Triangle at index ${i} mixes features (spec §8.1)`); } + const feature = id - offset; if (feature !== current) { this.#checkNext(feature, current, indexCount, `index ${i}`); current = feature; diff --git a/packages/viewer/src/materials.ts b/packages/viewer/src/materials.ts index bc27cbf..b08466f 100644 --- a/packages/viewer/src/materials.ts +++ b/packages/viewer/src/materials.ts @@ -2,20 +2,22 @@ * Board materials: the asset's materials as node materials, plus a TSL graph that reads the * element state texture (see `state.ts`). The same graph runs on WebGPU and on the WebGL2 * fallback of `WebGPURenderer`. + * + * Meshes that render alike share their node materials: the graph reads each vertex's state + * texel from the geometry ({@link STATE_ATTRIBUTE}), so it holds nothing per mesh, and three + * builds each shader once. Every shared material has a normal and an x-ray variant; x-ray mode + * swaps the meshes' materials, so toggling it rebuilds nothing. */ -import { DataTexture, type InstancedBufferAttribute, type Material, NearestFilter } from 'three'; +import { DataTexture, type Material, type Mesh, NearestFilter } from 'three'; import { attribute, - instancedBufferAttribute, int, ivec2, materialColor, materialEmissive, materialMetalness, - materialOpacity, mix, textureLoad, - uniform, varying, vec4, } from 'three/tsl'; @@ -25,7 +27,7 @@ import { type NodeMaterial, StandardNodeLibrary, } from 'three/webgpu'; -import { type ElementState, HIDDEN_ALPHA } from './state.js'; +import { type ElementState, HIDDEN_ALPHA, STATE_ATTRIBUTE } from './state.js'; /** Opacity of board materials in x-ray mode. Tinted elements stay more opaque. */ export const XRAY_OPACITY = 0.15; @@ -34,20 +36,38 @@ export const XRAY_COPPER_OPACITY = 0.6; /** Share of the tint colour added as emission, so tints show on metal and in shadow. */ const TINT_GLOW = 0.35; -interface Entry { - readonly material: NodeMaterial; - readonly transparent: boolean; - readonly depthWrite: boolean; +/** The two variants of a shared material. */ +export interface MaterialVariants { + readonly normal: NodeMaterial; + /** Translucent, without depth writes; the same object as {@link normal} for an overlay. */ + readonly xray: NodeMaterial; } -/** Creates and owns the materials of one loaded board. */ +/** How a mesh uses a shared material. */ +interface Use { + readonly overlay: boolean; + /** Opacity of untinted elements in x-ray mode. */ + readonly xrayOpacity: number; + /** Constant depth bias (`polygonOffsetUnits`); 0 for none. */ + readonly depthBias: number; +} + +interface Shared { + readonly key: string; + readonly variants: MaterialVariants; + /** Meshes using the variants. */ + users: number; +} + +/** Creates and owns the materials of one loaded board, shared between its meshes. */ export class BoardMaterials { /** The GPU copy of {@link ElementState.data}. */ readonly texture: DataTexture; readonly #state: ElementState; readonly #library = new StandardNodeLibrary(); - readonly #xray = uniform(0); - readonly #entries: Entry[] = []; + readonly #shared = new Map(); + readonly #meshes = new Map(); + #xray = false; #uploaded = -1; constructor(state: ElementState) { @@ -58,44 +78,67 @@ export class BoardMaterials { this.texture.generateMipmaps = false; } + /** Whether the meshes have their x-ray variants. */ + get xray(): boolean { + return this.#xray; + } + + /** Number of shared materials in use (each has two variants, an overlay one). */ + get size(): number { + return this.#shared.size; + } + /** - * Material for a merged layer mesh whose feature rows start at state texel `offset`. + * Gives a merged layer mesh the shared material for `source`. * * @param xrayOpacity Opacity of untinted elements in x-ray mode. + * @param depthBias Constant depth bias (`polygonOffsetUnits`), e.g. for the soldermask. */ - layer(source: Material, offset: number, xrayOpacity = XRAY_OPACITY): NodeMaterial { - const index = attribute<'float'>('_feature_id_0', 'float').add(offset); - return this.#create(source, index, false, xrayOpacity); + layer(mesh: Mesh, source: Material, xrayOpacity = XRAY_OPACITY, depthBias = 0): void { + this.#assign(mesh, source, { overlay: false, xrayOpacity, depthBias }); } /** - * Material that draws only the tinted elements of a layer mesh, in a second pass after the - * translucent soldermask, so that tints under the mask stay visible. + * Gives a mesh the material that draws only the tinted elements of a layer mesh, in a second + * pass after the translucent soldermask, so that tints under the mask stay visible. It is the + * same in x-ray mode. */ - overlay(source: Material, offset: number): NodeMaterial { - return this.#create(source, attribute<'float'>('_feature_id_0', 'float').add(offset), true); + overlay(mesh: Mesh, source: Material): void { + this.#assign(mesh, source, { overlay: true, xrayOpacity: 1, depthBias: 0 }); } - /** Material for a component batch; `rows` holds each instance's component row. */ - components(source: Material, rows: InstancedBufferAttribute, offset: number): NodeMaterial { - return this.#create(source, instancedBufferAttribute<'float'>(rows, 'float').add(offset)); + /** Gives a component batch the shared material for `source`. */ + component(mesh: Mesh, source: Material): void { + this.#assign(mesh, source, { overlay: false, xrayOpacity: XRAY_OPACITY, depthBias: 0 }); } - /** Disposes a material made by this object, e.g. of a removed runtime model. */ - release(material: Material): void { - const index = this.#entries.findIndex((entry) => entry.material === material); - if (index < 0) return; - this.#entries.splice(index, 1); - material.dispose(); + /** The variants a mesh uses, or `undefined` if it has none from this object. */ + variants(mesh: Mesh): MaterialVariants | undefined { + return this.#meshes.get(mesh)?.variants; } - /** Makes every board material translucent, or restores it. */ + /** + * Forgets a mesh, e.g. of a removed runtime model, and disposes its materials when no other + * mesh uses them. + */ + release(mesh: Mesh): void { + const shared = this.#meshes.get(mesh); + if (!shared) return; + this.#meshes.delete(mesh); + if (--shared.users > 0) return; + this.#shared.delete(shared.key); + shared.variants.normal.dispose(); + shared.variants.xray.dispose(); + } + + /** + * Makes every board material translucent, or restores it, by swapping each mesh's material + * for its other variant. Nothing is rebuilt once three has drawn (or compiled) both. + */ setXray(on: boolean): void { - this.#xray.value = on ? 1 : 0; - for (const { material, transparent, depthWrite } of this.#entries) { - material.transparent = on || transparent; - material.depthWrite = !on && depthWrite; - material.needsUpdate = true; + this.#xray = on; + for (const [mesh, { variants }] of this.#meshes) { + mesh.material = on ? variants.xray : variants.normal; } } @@ -108,18 +151,33 @@ export class BoardMaterials { } dispose(): void { - for (const { material } of this.#entries) material.dispose(); + for (const { variants } of this.#shared.values()) { + variants.normal.dispose(); + variants.xray.dispose(); + } + this.#shared.clear(); + this.#meshes.clear(); this.texture.dispose(); } - #create( - source: Material, - stateIndex: Node<'float'>, - overlay = false, - xrayOpacity = XRAY_OPACITY, - ): NodeMaterial { + #assign(mesh: Mesh, source: Material, use: Use): void { + this.release(mesh); + const key = `${use.overlay ? 'overlay' : `${use.xrayOpacity}|${use.depthBias}`}|${materialKey(source)}`; + let shared = this.#shared.get(key); + if (!shared) { + const normal = this.#create(source, use, false); + const xray = use.overlay ? normal : this.#create(source, use, true); + shared = { key, variants: { normal, xray }, users: 0 }; + this.#shared.set(key, shared); + } + shared.users++; + this.#meshes.set(mesh, shared); + mesh.material = this.#xray ? shared.variants.xray : shared.variants.normal; + } + + #create(source: Material, use: Use, xray: boolean): NodeMaterial { const material = this.#library.fromMaterial(source.clone()) as NodeMaterial; - const index = int(stateIndex); + const index = int(attribute<'float'>(STATE_ATTRIBUTE, 'float')); const width = int(this.#state.width); // Read the texel per vertex; all vertices of a triangle belong to one element. const state = varying(textureLoad(this.texture, ivec2(index.mod(width), index.div(width)))); @@ -133,20 +191,70 @@ export class BoardMaterials { material.metalnessNode = materialMetalness.mul(strength.oneMinus()); } const visible = state.a.lessThan((HIDDEN_ALPHA - 0.5) / 255); - if (overlay) { + if (use.overlay) { material.transparent = true; material.depthWrite = false; material.opacityNode = strength; material.maskNode = visible.and(strength.greaterThan(0)); + } else if (xray) { + material.transparent = true; + material.depthWrite = false; + material.opacityNode = mix(use.xrayOpacity, 1, strength); + material.maskNode = visible; } else { - material.opacityNode = mix(materialOpacity, mix(xrayOpacity, 1, strength), this.#xray); material.maskNode = visible; } - this.#entries.push({ - material, - transparent: material.transparent, - depthWrite: material.depthWrite, - }); + if (use.depthBias) { + material.polygonOffset = true; + material.polygonOffsetFactor = 0; + material.polygonOffsetUnits = use.depthBias; + } return material; } } + +/** Material properties that don't change how it renders. */ +const IGNORED = new Set(['uuid', 'id', 'name', 'version', 'userData', '_listeners']); + +/** + * A key that is equal for materials that render alike, so that they share node materials: + * runtime models bring their own copies of a small palette. Textures count by identity, and so + * do properties of an unknown kind. + */ +export function materialKey(material: Material): string { + const parts = [material.type]; + for (const [name, value] of Object.entries(material)) { + if (IGNORED.has(name) || typeof value === 'function') continue; + parts.push(`${name}:${valueKey(value) ?? material.uuid}`); + } + return parts.join(';'); +} + +function valueKey(value: unknown): string | undefined { + if (value === null || typeof value !== 'object') return String(value); + const v = value as { + isColor?: boolean; + isTexture?: boolean; + uuid?: string; + r?: number; + g?: number; + b?: number; + toArray?: () => unknown[]; + }; + if (v.isColor) return `${v.r},${v.g},${v.b}`; + if (v.isTexture) return v.uuid; + if (Array.isArray(value)) { + const keys = value.map(valueKey); + return keys.includes(undefined) ? undefined : `[${keys.join(',')}]`; + } + // Vectors and Euler angles. + if (typeof v.toArray === 'function') return valueKey(v.toArray()); + // Plain objects, such as `defines`. + if (Object.getPrototypeOf(value) === Object.prototype) { + const entries = Object.entries(value).map(([k, x]) => [k, valueKey(x)]); + return entries.some(([, x]) => x === undefined) + ? undefined + : `{${entries.map(([k, x]) => `${k}=${x}`).join(',')}}`; + } + return undefined; +} diff --git a/packages/viewer/src/picking.ts b/packages/viewer/src/picking.ts index 1d79e58..58901b0 100644 --- a/packages/viewer/src/picking.ts +++ b/packages/viewer/src/picking.ts @@ -8,6 +8,7 @@ import { } from 'three'; import { MeshBVH } from 'three-mesh-bvh'; import type { BoardModel, LayerModel } from './board-model.js'; +import { STATE_ATTRIBUTE } from './state.js'; /** The element found by {@link Picker.pick}. */ export interface PickHit { @@ -20,7 +21,7 @@ export interface PickHit { /** * Finds the element under a ray. Layer meshes are searched with a `three-mesh-bvh` BVH each, - * built on first use; component batches use three's instanced-mesh ray cast. + * built on first use; component batches search their instances (see `bodies.ts`). */ export class Picker { readonly #model: BoardModel; @@ -55,20 +56,19 @@ export class Picker { if (!layer.group.visible) continue; for (const mesh of layer.meshes) { if (this.#pending.has(mesh.geometry)) continue; - const ids = mesh.geometry.getAttribute('_feature_id_0'); + const texels = mesh.geometry.getAttribute(STATE_ATTRIBUTE); for (const hit of this.bvh(mesh).raycast(ray, FrontSide)) { - if (hit.face) consider(layer.stateOffset + ids.getX(hit.face.a), hit.point, hit.distance); + if (hit.face) consider(texels.getX(hit.face.a), hit.point, hit.distance); } } } if (this.#model.componentGroup.visible) { this.#raycaster.ray.copy(ray); - for (const { mesh, rows } of this.#model.componentBatches) { - for (const hit of this.#raycaster.intersectObject(mesh, false)) { - const row = rows[hit.instanceId ?? -1]; - if (row !== undefined) - consider(this.#model.componentOffset + row, hit.point, hit.distance); - } + const offset = this.#model.componentOffset; + for (const batch of this.#model.componentBatches) { + batch.raycast(this.#raycaster, (row, point, distance) => + consider(offset + row, point, distance), + ); } } return best; diff --git a/packages/viewer/src/renderer.ts b/packages/viewer/src/renderer.ts index ae9d018..0bdb7e0 100644 --- a/packages/viewer/src/renderer.ts +++ b/packages/viewer/src/renderer.ts @@ -185,6 +185,14 @@ export class BoardRenderer { this.requestRender(); } + /** + * Compiles what the scene would draw now (node materials and pipelines) without drawing it, + * a little at a time; resolves when done. + */ + compile(): Promise { + return this.renderer.compileAsync(this.scene, this.camera); + } + /** Schedules a frame. */ requestRender(): void { if (!this.#frame) { diff --git a/packages/viewer/src/state.ts b/packages/viewer/src/state.ts index 8fda0d4..1837f03 100644 --- a/packages/viewer/src/state.ts +++ b/packages/viewer/src/state.ts @@ -12,6 +12,12 @@ export type Rgb = readonly [number, number, number]; /** Alpha value that marks a hidden element. */ export const HIDDEN_ALPHA = 255; +/** + * Geometry attribute (per vertex, or per instance) holding the state texel of the element drawn, + * as a float: exact up to 2²⁴, more texels than the texture can have (2048 × 8192). + */ +export const STATE_ATTRIBUTE = '_state'; + /** Tint used for the element under the pointer. */ export const HOVER_TINT = { color: [0.05, 0.45, 1] as Rgb, strength: 0.75 }; /** Tint used for the selected element. */ diff --git a/packages/viewer/test/board-model.test.ts b/packages/viewer/test/board-model.test.ts index 3edd9fd..9c9b240 100644 --- a/packages/viewer/test/board-model.test.ts +++ b/packages/viewer/test/board-model.test.ts @@ -252,20 +252,14 @@ describe('ID resolution', () => { }); describe('components', () => { - it('are batched per shared geometry and material, with instance → row mapping', () => { + it('are merged per material, with instance → row mapping', () => { const batches = model.componentBatches.map((b) => [ (b.mesh.material as Material).name, - b.mesh.count, [...b.rows].map((r) => model.components.get('refDes', r)).join(' '), ]); expect(batches).toEqual([ - ['boardui/body', 2, 'R1 R2'], - ['boardui/pin1', 2, 'R1 R2'], - ['boardui/body', 1, 'U1'], - ['boardui/pin1', 1, 'U1'], - ['boardui/body', 1, 'C1'], - ['boardui/body', 1, 'J1'], - ['boardui/pin1', 1, 'J1'], + ['boardui/body', 'R1 R2 U1 C1 J1'], + ['boardui/pin1', 'R1 R2 U1 J1'], ]); }); }); diff --git a/packages/viewer/test/bodies.test.ts b/packages/viewer/test/bodies.test.ts new file mode 100644 index 0000000..d5abc33 --- /dev/null +++ b/packages/viewer/test/bodies.test.ts @@ -0,0 +1,146 @@ +import { + Box3, + BoxGeometry, + InstancedMesh, + Matrix4, + MeshStandardMaterial, + Raycaster, + Vector3, +} from 'three'; +import { describe, expect, it } from 'vitest'; +import { + type BodyGroup, + batchBodies, + type ComponentBatch, + MERGE_MAX_COPIED_VERTICES, + MergedBatch, +} from '../src/bodies.js'; +import { STATE_ATTRIBUTE } from '../src/state.js'; + +const OFFSET = 100; +const material = new MeshStandardMaterial({ name: 'body' }); +const at = (x: number, z = 0) => new Matrix4().makeTranslation(x, 0.5, z); + +/** Rows hit by a ray straight down at `x` (off the boxes' diagonals). */ +function hits(batch: ComponentBatch, x: number): number[] { + const raycaster = new Raycaster(new Vector3(x, 10, 0.1), new Vector3(0, -1, 0)); + const rows: number[] = []; + batch.raycast(raycaster, (row) => rows.push(row)); + return rows; +} + +describe('merged component batches', () => { + const box = new BoxGeometry(1, 1, 1); + const small = new BoxGeometry(0.5, 0.5, 0.5).toNonIndexed(); + const groups: BodyGroup[] = [ + { geometry: box, material, rows: [0, 2], matrices: [at(0), at(3)] }, + { geometry: small, material, rows: [1], matrices: [at(6)] }, + ]; + + it('bake the instances in board coordinates, with a state texel per vertex', () => { + const batch = new MergedBatch(groups, OFFSET); + const geometry = batch.mesh.geometry; + const vertices = box.getAttribute('position').count; + expect(batch.mesh).not.toBeInstanceOf(InstancedMesh); + expect(geometry.getAttribute('position').count).toBe(2 * vertices + 36); + expect(geometry.getAttribute('normal').count).toBe(2 * vertices + 36); + const texels = geometry.getAttribute(STATE_ATTRIBUTE); + expect([texels.getX(0), texels.getX(vertices), texels.getX(2 * vertices)]).toEqual([ + OFFSET, + OFFSET + 2, + OFFSET + 1, + ]); + expect(geometry.index?.count).toBe(2 * (box.index?.count ?? 0) + 36); + expect([...batch.rows]).toEqual([0, 2, 1]); + geometry.computeBoundingBox(); + expect(geometry.boundingBox?.min.toArray()).toEqual([-0.5, 0, -0.5]); + expect(geometry.boundingBox?.max.toArray()).toEqual([6.25, 1, 0.5]); + }); + + it('pick and bound each instance, and leave out filtered ones', () => { + const batch = new MergedBatch(groups, OFFSET); + expect(hits(batch, 0)).toEqual([0]); // the top face only: the bottom one faces away + expect(hits(batch, 3.2)).toEqual([2]); + expect(hits(batch, 6.05)).toEqual([1]); + expect(hits(batch, 4.5)).toEqual([]); + const bounds = [new Box3(), new Box3(), new Box3()]; + batch.addBounds(bounds); + expect(bounds[2]?.min.toArray()).toEqual([2.5, 0, -0.5]); + + batch.filter((row) => row !== 2); + expect([...batch.rows]).toEqual([0, 1]); + expect(hits(batch, 3.2)).toEqual([]); + expect(batch.mesh.geometry.drawRange.count).toBe((box.index?.count ?? 0) + 36); + expect(batch.mesh.geometry.boundingBox?.max.x).toBe(6.25); + batch.filter((row) => row === 2); + expect([...batch.rows]).toEqual([2]); + expect(batch.mesh.geometry.boundingBox?.min.x).toBe(2.5); + batch.filter(() => false); + expect(batch.mesh.visible).toBe(false); + batch.filter(() => true); + expect(batch.mesh.visible).toBe(true); + expect(batch.mesh.geometry.drawRange.count).toBe(2 * (box.index?.count ?? 0) + 36); + expect(hits(batch, 3.2)).toEqual([2]); + }); + + it('keep the triangles facing out under a mirroring matrix', () => { + const mirror = at(0).multiply(new Matrix4().makeScale(1, -1, 1)); + const batch = new MergedBatch([{ geometry: box, material, rows: [0], matrices: [mirror] }], 0); + const position = batch.mesh.geometry.getAttribute('position'); + const normal = batch.mesh.geometry.getAttribute('normal'); + const index = batch.mesh.geometry.index; + const [a, b, c, n] = [new Vector3(), new Vector3(), new Vector3(), new Vector3()]; + for (let i = 0; i < (index?.count ?? 0); i += 3) { + a.fromBufferAttribute(position, index?.getX(i) ?? 0); + b.fromBufferAttribute(position, index?.getX(i + 1) ?? 0); + c.fromBufferAttribute(position, index?.getX(i + 2) ?? 0); + n.fromBufferAttribute(normal, index?.getX(i) ?? 0); + const face = b.sub(a).cross(c.sub(a)); + expect(face.dot(n), `triangle ${i / 3}`).toBeGreaterThan(0); + } + expect(hits(batch, 0)).toEqual([0]); + }); +}); + +describe('batchBodies', () => { + it('merges per material and vertex layout, and instances large repeated meshes', () => { + const other = new MeshStandardMaterial({ name: 'pin1' }); + const big = new BoxGeometry(1, 1, 1, 64, 64, 64); + const vertices = big.getAttribute('position').count; + const copies = Math.floor(MERGE_MAX_COPIED_VERTICES / vertices) + 2; + const batches = batchBodies( + [ + { geometry: new BoxGeometry(), material, rows: [0], matrices: [at(0)] }, + { geometry: new BoxGeometry(), material: other, rows: [0], matrices: [at(0)] }, + { + geometry: new BoxGeometry().deleteAttribute('uv'), + material, + rows: [1], + matrices: [at(1)], + }, + { geometry: new BoxGeometry(2, 2, 2), material, rows: [2, 3], matrices: [at(2), at(3)] }, + { + geometry: big, + material, + rows: Array.from({ length: copies }, (_, i) => i), + matrices: Array.from({ length: copies }, (_, i) => at(i)), + }, + // Used once: merged however large. + { geometry: big, material: other, rows: [4], matrices: [at(4)] }, + ], + OFFSET, + ); + expect(batches.map((b) => [b.mesh.constructor.name, b.mesh.material, [...b.rows]])).toEqual([ + ['Mesh', material, [0, 2, 3]], + ['Mesh', other, [0, 4]], + ['Mesh', material, [1]], + ['InstancedMesh', material, Array.from({ length: copies }, (_, i) => i)], + ]); + const instanced = batches[3]?.mesh as InstancedMesh; + // The instanced batch reads its texels per instance from its own copy of the geometry. + expect(instanced.geometry).not.toBe(big); + expect(big.hasAttribute(STATE_ATTRIBUTE)).toBe(false); + expect(instanced.geometry.getAttribute(STATE_ATTRIBUTE).getX(3)).toBe(OFFSET + 3); + expect(instanced.geometry.getAttribute('position')).toBe(big.getAttribute('position')); + }); +}); diff --git a/packages/viewer/test/dense.test.ts b/packages/viewer/test/dense.test.ts index dd1b3c9..f1faf30 100644 --- a/packages/viewer/test/dense.test.ts +++ b/packages/viewer/test/dense.test.ts @@ -2,7 +2,7 @@ * The dense fixture (~110k features): checks that the board stays at one mesh per layer and * logs load and picking timings. */ -import { Ray, Vector3 } from 'three'; +import { InstancedMesh, Ray, Vector3 } from 'three'; import { beforeAll, describe, expect, it } from 'vitest'; import { BoardModel } from '../src/board-model.js'; import { loadGltf } from '../src/load.js'; @@ -45,8 +45,9 @@ describe('dense fixture', () => { timings.layerTriangles = triangles; }); - it('draws all components in two instanced meshes (body, pin-1 marker)', () => { - expect(model.componentBatches.map((b) => b.mesh.count)).toEqual([GRID * GRID, GRID * GRID]); + it('draws all components in two merged meshes (body, pin-1 marker)', () => { + expect(model.componentBatches.map((b) => b.rows.length)).toEqual([GRID * GRID, GRID * GRID]); + expect(model.componentBatches.some((b) => b.mesh instanceof InstancedMesh)).toBe(false); }); it('picks with a BVH', async () => { diff --git a/packages/viewer/test/element.test.ts b/packages/viewer/test/element.test.ts index 5da47ef..1dc84d5 100644 --- a/packages/viewer/test/element.test.ts +++ b/packages/viewer/test/element.test.ts @@ -22,6 +22,7 @@ vi.mock('../src/renderer.js', async () => { setBounds() {}, resize() {}, requestRender() {}, + compile: async () => {}, dispose() {}, frame(box: Box3, direction?: Vector3) { frames.push({ box: box.clone(), direction: direction?.clone() }); diff --git a/packages/viewer/test/materials.test.ts b/packages/viewer/test/materials.test.ts new file mode 100644 index 0000000..0bd98a9 --- /dev/null +++ b/packages/viewer/test/materials.test.ts @@ -0,0 +1,119 @@ +import { + BufferGeometry, + DoubleSide, + Mesh, + MeshStandardMaterial, + type MeshStandardMaterialParameters, + Texture, +} from 'three'; +import { describe, expect, it } from 'vitest'; +import { BoardMaterials, materialKey, XRAY_COPPER_OPACITY } from '../src/materials.js'; +import { ElementState } from '../src/state.js'; + +const mesh = () => new Mesh(new BufferGeometry()); +const copper = (values: MeshStandardMaterialParameters = {}) => + new MeshStandardMaterial({ + name: 'copper', + color: 0xb87333, + metalness: 1, + roughness: 0.3, + ...values, + }); + +describe('board materials', () => { + it('share one node material per source material and use, also between equal copies', () => { + const materials = new BoardMaterials(new ElementState(10)); + const [a, b, c, d, e] = [mesh(), mesh(), mesh(), mesh(), mesh()]; + const source = copper(); + materials.layer(a, source, XRAY_COPPER_OPACITY); + materials.layer(b, source, XRAY_COPPER_OPACITY); + // Runtime models bring their own copies of a palette: equal materials share too. + materials.layer(c, Object.assign(source.clone(), { name: 'other name' }), XRAY_COPPER_OPACITY); + materials.layer(d, source); // another x-ray opacity + materials.component(e, new MeshStandardMaterial({ color: 0x2b2b2b })); + expect(a.material).toBe(b.material); + expect(a.material).toBe(c.material); + expect(a.material).not.toBe(d.material); + expect(a.material).not.toBe(e.material); + expect(a.material).not.toBe(source); + expect(materials.size).toBe(3); + }); + + it('swap meshes to their x-ray variants and back without changing a material', () => { + const materials = new BoardMaterials(new ElementState(10)); + const [layer, mask, overlay] = [mesh(), mesh(), mesh()]; + materials.layer(layer, copper(), XRAY_COPPER_OPACITY); + materials.layer(mask, new MeshStandardMaterial({ opacity: 0.8, transparent: true }), 0.15, 4); + materials.overlay(overlay, copper()); + const variants = [layer, mask, overlay].map((m) => materials.variants(m)); + const all = variants.flatMap((v) => (v ? [v.normal, v.xray] : [])); + const versions = all.map((m) => m.version); + + expect(layer.material).toBe(variants[0]?.normal); + materials.setXray(true); + expect([layer, mask, overlay].map((m) => m.material)).toEqual(variants.map((v) => v?.xray)); + materials.setXray(false); + expect([layer, mask, overlay].map((m) => m.material)).toEqual(variants.map((v) => v?.normal)); + expect(all.map((m) => m.version)).toEqual(versions); + + const [l, k, o] = variants; + // Normal: as the source; x-ray: translucent, without depth writes. + expect([l?.normal.transparent, l?.normal.depthWrite]).toEqual([false, true]); + expect([l?.xray.transparent, l?.xray.depthWrite]).toEqual([true, false]); + expect([k?.normal.transparent, k?.xray.transparent]).toEqual([true, true]); + // The mask's depth bias holds in both modes. + for (const m of [k?.normal, k?.xray]) { + expect([m?.polygonOffset, m?.polygonOffsetFactor, m?.polygonOffsetUnits]).toEqual([ + true, + 0, + 4, + ]); + } + expect(l?.normal.polygonOffset).toBe(false); + // The tint overlay is the same in both modes. + expect(o?.xray).toBe(o?.normal); + expect([o?.normal.transparent, o?.normal.depthWrite]).toEqual([true, false]); + }); + + it('give meshes assigned in x-ray mode their x-ray variant', () => { + const materials = new BoardMaterials(new ElementState(10)); + materials.setXray(true); + const m = mesh(); + materials.component(m, copper()); + expect(m.material).toBe(materials.variants(m)?.xray); + }); + + it('dispose a shared material when its last mesh is released', () => { + const materials = new BoardMaterials(new ElementState(10)); + const [a, b] = [mesh(), mesh()]; + materials.component(a, copper()); + materials.component(b, copper()); + const variants = materials.variants(a); + const disposed: string[] = []; + variants?.normal.addEventListener('dispose', () => disposed.push('normal')); + variants?.xray.addEventListener('dispose', () => disposed.push('xray')); + materials.release(a); + expect(disposed).toEqual([]); + expect(materials.variants(a)).toBeUndefined(); + materials.release(b); + expect(disposed).toEqual(['normal', 'xray']); + expect(materials.size).toBe(0); + materials.component(a, copper()); + expect(materials.variants(a)).not.toBe(variants); + }); +}); + +describe('materialKey', () => { + it('is equal for materials that render alike', () => { + expect(materialKey(copper())).toBe(materialKey(copper())); + expect(materialKey(copper())).not.toBe(materialKey(copper({ roughness: 0.4 }))); + expect(materialKey(copper())).not.toBe(materialKey(copper({ color: 0xb87334 }))); + expect(materialKey(copper())).not.toBe(materialKey(copper({ side: DoubleSide }))); + }); + + it('tells textures apart by identity', () => { + const map = new Texture(); + expect(materialKey(copper({ map }))).toBe(materialKey(copper({ map }))); + expect(materialKey(copper({ map }))).not.toBe(materialKey(copper({ map: map.clone() }))); + }); +}); diff --git a/packages/viewer/test/runtime-models.test.ts b/packages/viewer/test/runtime-models.test.ts index ff1c076..f293574 100644 --- a/packages/viewer/test/runtime-models.test.ts +++ b/packages/viewer/test/runtime-models.test.ts @@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url'; import { BoxGeometry, type BufferGeometry, + InstancedMesh, type Material, Matrix4, type Mesh, @@ -201,18 +202,28 @@ describe('runtime models', () => { it('replace placeholder bodies and pin-1 markers on the same state texels, and revert', async () => { const model = await board(); const before = model.resolve('cmp/U1')?.box?.clone(); - const placeholders = model.bodies.placeholders.map((b) => b.mesh.count); + const placeholders = model.bodies.placeholders.map((b) => refDes(model, b.rows)); + // C1 has a user model (see `board`). + expect(placeholders).toEqual(['R1 R2 U1 J1', 'R1 R2 U1 J1', 'C1']); await run(model, [source('s', (c) => (c.refDes === 'U1' ? 'big' : null), { big: '20' })]); const drawn = model.componentBatches.map((b): [string, string] => [ (b.mesh.material as Material).name, refDes(model, b.rows), ]); // U1's body and pin-1 marker are gone; its model has a batch per material. - expect(drawn.filter(([, r]) => r === 'U1')).toEqual([ + expect(drawn).toEqual([ + ['boardui/body', 'R1 R2 J1'], + ['boardui/pin1', 'R1 R2 J1'], + ['user/model', 'C1'], ['a', 'U1'], ['b', 'U1'], ]); - expect(drawn.filter(([n]) => n.startsWith('boardui/')).map(([, r]) => r)).not.toContain('U1'); + // The merged placeholders draw fewer triangles, and the model's batches are instanced. + const [body, pin1] = model.bodies.placeholders; + expect(body?.mesh.geometry.drawRange.count).toBeLessThan(body?.mesh.geometry.index?.count ?? 0); + expect(pin1?.mesh.visible).toBe(true); + const models = model.componentBatches.slice(3); + expect(models.every((b) => b.mesh instanceof InstancedMesh)).toBe(true); const box = model.resolve('cmp/U1')?.box; const node = model.bodies.nodeMatrix(model.ids('component').indexOf('cmp/U1')); expect(box?.max.y).toBeCloseTo((node?.elements[13] ?? 0) + 0.01, 6); @@ -221,7 +232,8 @@ describe('runtime models', () => { expect(model.bounds.max.y).toBeGreaterThan(0.009); model.bodies.showModels(false); - expect(model.bodies.placeholders.map((b) => b.mesh.count)).toEqual(placeholders); + expect(model.bodies.placeholders.map((b) => refDes(model, b.rows))).toEqual(placeholders); + expect(body?.mesh.geometry.drawRange.count).toBe(body?.mesh.geometry.index?.count); expect(model.componentBatches.some((b) => (b.mesh.material as Material).name === 'a')).toBe( false, ); @@ -229,7 +241,7 @@ describe('runtime models', () => { model.bodies.showModels(true); expect(model.bodies.modelCount).toBe(1); model.bodies.clearModels(); - expect(model.bodies.placeholders.map((b) => b.mesh.count)).toEqual(placeholders); + expect(model.bodies.placeholders.map((b) => refDes(model, b.rows))).toEqual(placeholders); }); it('place a model with the reference’s transform on the component’s node', async () => { @@ -250,7 +262,9 @@ describe('runtime models', () => { await run(model, [s]); const batch = model.componentBatches.find((b) => (b.mesh.material as Material).name === 'a'); const matrix = new Matrix4(); - batch?.mesh.getMatrixAt(0, matrix); + const mesh = batch?.mesh as InstancedMesh; + expect(mesh).toBeInstanceOf(InstancedMesh); + mesh.getMatrixAt(0, matrix); const row = model.ids('component').indexOf('cmp/U1'); const expected = model.bodies.nodeMatrix(row)?.clone().multiply(transformMatrix(ref)); expect(matrix.toArray().map((v) => +v.toFixed(9))).toEqual( From f33bda690ce1281a35c93985f7669b322242ad34 Mon Sep 17 00:00:00 2001 From: Michal Dub <37038617+midub@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:41:49 +0000 Subject: [PATCH 2/6] Docs and changelog: shared materials, x-ray swap, merged component bodies --- CHANGELOG.md | 6 ++++++ docs/architecture.md | 4 ++-- packages/viewer/README.md | 29 ++++++++++++++++++++--------- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b20503..d65bcfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ glTF profile has its own version (`profileVersion`, [spec §11](spec/README.md#1 ## [Unreleased] +- **Faster x-ray and first frame on big boards (viewer):** meshes that render alike share their + materials, x-ray mode swaps materials prepared after loading instead of rebuilding every + shader, and the components' placeholder bodies are drawn as a few merged meshes instead of one + instanced mesh per package. On testcase1 (1,656 components) an x-ray toggle takes 2–15 ms of + main-thread JavaScript instead of 1.5–1.6 s, loading 1.6 s instead of 3.4 s, and the first frame + has 21 draw calls instead of 223. - **Licence: AGPL-3.0-only or a commercial licence** (was MIT). boardui stays open source; closed-source products, and web applications that don't publish their source, need a commercial licence ([LICENSING.md](LICENSING.md), [ADR 0015](docs/adr/0015-agpl-plus-commercial-licence.md)). diff --git a/docs/architecture.md b/docs/architecture.md index 2be585f..95d723c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -107,14 +107,14 @@ Files kept on disk or served over the network can be large. A dense board approa Every element can be hovered, selected, recoloured or hidden, but layers stay merged meshes ([ADR 0008](adr/0008-hybrid-metadata.md), [ADR 0009](adr/0009-viewer-three-js-web-component.md)): 1. **One texel per element.** A small RGBA8 *state texture* has one texel per feature, across all layers (each layer gets an offset). It holds a highlight colour, a blend factor and a hidden flag. -2. **The shader reads it.** The shader reads `_FEATURE_ID_0` plus the layer offset, looks up the texel, then tints or discards the fragment. +2. **The shader reads it.** The shader reads the element's texel index from the geometry (when merging a layer, the viewer stores `_FEATURE_ID_0` plus the layer offset), looks up the texel, then tints or discards the fragment. The graph holds nothing per mesh, so meshes that render alike share one material, and x-ray mode swaps each mesh's material for its x-ray variant instead of changing materials. 3. **Hover.** - A BVH ray cast finds the triangle under the cursor. - The triangle's vertex gives the feature ID, and the metadata gives the element. - The viewer writes one texel; nothing is rebuilt. 4. **Net highlight.** The viewer writes every texel of the net's features. The feature → net index is built once at load time. 5. **Feature ranges.** Contiguous features (spec §8.1) let the loader compute each feature's vertex range and bounding box in one pass. These feed widget anchors and "zoom to element". -6. **Components.** Component nodes that share a mesh are batched (`BatchedMesh` / `InstancedMesh`) per material, and the instance index maps back to the component row. Runtime models (below) are batched the same way, on the same texels, so a component behaves alike whatever body it shows. +6. **Components.** The asset's component meshes are merged into one static mesh per material, each vertex carrying its component's texel, so the number of meshes doesn't grow with the number of packages; meshes that would cost too much memory as copies, and runtime models (below), are instanced, with the texel per instance. Either way a component uses the same texel, so it behaves alike whatever body it shows. ### Runtime models diff --git a/packages/viewer/README.md b/packages/viewer/README.md index ef40171..b2d622e 100644 --- a/packages/viewer/README.md +++ b/packages/viewer/README.md @@ -156,15 +156,26 @@ component) is switched off or the element is hidden with `hide`. (bottom view). The soldermask gets a polygon offset: its bottom face is coplanar with the dielectric's top and the copper's bottom (ADR 0006), which z-fought with the dielectric hidden. - **Element state.** One RGBA8 texel per feature and per component. A TSL node graph reads it per - vertex (feature ID + layer offset) and tints, or discards, the fragment. Hover, selection, - highlights and hiding are texel writes. Copper and drill layers get a second, overlay draw that - shows tinted features through the translucent soldermask; it is drawn only while the layer has a - tint. -- **Components.** Component nodes that share geometry and material become one `InstancedMesh`; an - instanced attribute carries each instance's component row into the same state-texture lookup. - Runtime models are batched the same way (`src/bodies.ts`); the placeholder batches they replace - draw only their remaining instances. -- **Picking.** `three-mesh-bvh` BVHs per layer mesh, three's instanced ray cast for components. + vertex and tints, or discards, the fragment; the texel's index comes from the geometry (merged + layer meshes store each vertex's feature ID plus the layer's offset instead of the feature ID). + Hover, selection, highlights and hiding are texel writes. Copper and drill layers get a second, + overlay draw that shows tinted features through the translucent soldermask; it is drawn only + while the layer has a tint. +- **Materials.** Meshes that render alike share one node material for the whole board + (`src/materials.ts`; runtime models by the material's content, as their loaders make copies of a + small palette), so three builds each shader once instead of once per mesh. Each has a normal and + an x-ray variant: `setXray` swaps the meshes' materials and changes none, and the other mode is + compiled (`compileAsync`) in idle time after loading and after runtime models arrive, so toggling + x-ray rebuilds nothing. +- **Components.** The asset's component meshes (placeholder bodies, pin-1 markers, embedded user + models) are merged into one static mesh per material, in board coordinates, with each vertex's + state texel, so a board draws its bodies with a few meshes however many packages it has; the + merged copies cost some memory (testcase1, 1,656 components: 67k vertices). Meshes whose copies + would add more than 2¹⁸ vertices stay one `InstancedMesh` each, with the texel per instance. + Runtime models are instanced the same way (`src/bodies.ts`); placeholders they replace drop out + of the merged index buffer. +- **Picking.** `three-mesh-bvh` BVHs per layer mesh; for components, a box test per instance, then + its triangles (three's instanced ray cast for instanced batches). Meshes with 50k triangles or more get their BVH built in Web Workers (`src/bvh.worker.ts`, a pool of up to three) from copies of their positions and indices, so loading a dense board doesn't block the main thread; smaller ones are built in idle time. Until a layer's BVH arrives, hover From 8fd333a44f468fb8defc4db24773eaa843d778aa Mon Sep 17 00:00:00 2001 From: Michal Dub <37038617+midub@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:49:15 +0000 Subject: [PATCH 3/6] Faster body merge; merged bounds as instanced meshes compute them; hook timeout --- packages/viewer/src/bodies.ts | 58 ++++++++++++++++++++++++++------ packages/viewer/vitest.config.ts | 2 ++ 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/packages/viewer/src/bodies.ts b/packages/viewer/src/bodies.ts index 2471dd5..7cc9950 100644 --- a/packages/viewer/src/bodies.ts +++ b/packages/viewer/src/bodies.ts @@ -17,6 +17,7 @@ import { Group, InstancedBufferAttribute, InstancedMesh, + type InterleavedBufferAttribute, type Material, Matrix3, Matrix4, @@ -264,17 +265,15 @@ export class MergedBatch implements ComponentBatch { // Rounded like an instance matrix, so that bounds are as an instanced batch stores them. matrix.fromArray(Float32Array.from((matrices[k] as Matrix4).elements)); normalMatrix.getNormalMatrix(matrix); - const box = new Box3(); + const box = transformPositions(position, matrix, this.#positions, v); for (const name of names) { + if (name === 'position') continue; const source = geometry.getAttribute(name); const target = arrays.get(name) as Float32Array; const size = source.itemSize; for (let j = 0; j < position.count; j++) { const at = (v + j) * size; - if (name === 'position') { - point.fromBufferAttribute(source, j).applyMatrix4(matrix).toArray(target, at); - box.expandByPoint(point); - } else if (name === 'normal') { + if (name === 'normal') { point.fromBufferAttribute(source, j).applyNormalMatrix(normalMatrix); point.toArray(target, at); } else { @@ -285,10 +284,11 @@ export class MergedBatch implements ComponentBatch { texels.fill(row + texelOffset, v, v + position.count); // A mirroring matrix turns the triangles over; restore their winding. const mirrored = matrix.determinant() < 0; + const indices = index?.array; for (let j = 0; j < count; j += 3) { - const a = index ? index.getX(j) : j; - const b = index ? index.getX(j + 1) : j + 1; - const c = index ? index.getX(j + 2) : j + 2; + const a = indices ? (indices[j] as number) : j; + const b = indices ? (indices[j + 1] as number) : j + 1; + const c = indices ? (indices[j + 2] as number) : j + 2; this.#allIndex[i + j] = v + a; this.#allIndex[i + j + 1] = v + (mirrored ? c : b); this.#allIndex[i + j + 2] = v + (mirrored ? b : c); @@ -388,13 +388,16 @@ export class MergedBatch implements ComponentBatch { this.mesh.geometry.dispose(); } - /** Bounds of the drawn instances, for frustum culling and the board's bounds. */ + /** + * Bounds of the drawn instances, for frustum culling and the board's bounds: the union of + * their bounding boxes, as an instanced mesh computes it. + */ #update(): void { const geometry = this.mesh.geometry; geometry.boundingBox ??= new Box3(); const box = geometry.boundingBox.makeEmpty(); this.#rows.forEach((_, i) => { - if (this.#drawn[i]) box.union(this.#boxes[i] as Box3); + if (this.#drawn[i]) box.union(this.#bounds[i] as Box3); }); geometry.boundingSphere = box.getBoundingSphere(geometry.boundingSphere ?? new Sphere()); this.mesh.visible = this.#drawnCount > 0; @@ -577,6 +580,41 @@ function layout(geometry: BufferGeometry): string { .join(','); } +/** + * Writes the vertices of `position` moved by an affine `matrix` into `target` from vertex `start` + * on, and returns their bounding box (of the float32 values written). + */ +function transformPositions( + position: BufferAttribute | InterleavedBufferAttribute, + matrix: Matrix4, + target: Float32Array, + start: number, +): Box3 { + const [a, d, g, , b, e, h, , c, f, i, , x0, y0, z0] = matrix.elements; + const box = new Box3(); + const { min, max } = box; + for (let j = 0; j < position.count; j++) { + const x = position.getX(j); + const y = position.getY(j); + const z = position.getZ(j); + const o = (start + j) * 3; + target[o] = a * x + b * y + c * z + x0; + target[o + 1] = d * x + e * y + f * z + y0; + target[o + 2] = g * x + h * y + i * z + z0; + min.set( + Math.min(min.x, target[o] as number), + Math.min(min.y, target[o + 1] as number), + Math.min(min.z, target[o + 2] as number), + ); + max.set( + Math.max(max.x, target[o] as number), + Math.max(max.y, target[o + 1] as number), + Math.max(max.z, target[o + 2] as number), + ); + } + return box; +} + /** A geometry that shares the attributes and index of `geometry`. */ function shallowCopy(geometry: BufferGeometry): BufferGeometry { const copy = new BufferGeometry(); diff --git a/packages/viewer/vitest.config.ts b/packages/viewer/vitest.config.ts index a5ada80..8579b6b 100644 --- a/packages/viewer/vitest.config.ts +++ b/packages/viewer/vitest.config.ts @@ -5,5 +5,7 @@ export default defineConfig({ include: ['test/**/*.test.ts'], environment: 'node', testTimeout: 60_000, + // The dense fixture takes seconds to generate, more while other packages test in parallel. + hookTimeout: 60_000, }, }); From df3467dcf1fdace0401e34d05262b17710e5d4b3 Mon Sep 17 00:00:00 2001 From: Michal Dub <37038617+midub@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:00:36 +0000 Subject: [PATCH 4/6] Prepare the other x-ray mode after a layer is shown; changelog numbers --- CHANGELOG.md | 7 ++++--- packages/viewer/README.md | 4 ++-- packages/viewer/src/element.ts | 4 +++- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d65bcfd..bea5225 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,10 @@ glTF profile has its own version (`profileVersion`, [spec §11](spec/README.md#1 - **Faster x-ray and first frame on big boards (viewer):** meshes that render alike share their materials, x-ray mode swaps materials prepared after loading instead of rebuilding every shader, and the components' placeholder bodies are drawn as a few merged meshes instead of one - instanced mesh per package. On testcase1 (1,656 components) an x-ray toggle takes 2–15 ms of - main-thread JavaScript instead of 1.5–1.6 s, loading 1.6 s instead of 3.4 s, and the first frame - has 21 draw calls instead of 223. + instanced mesh per package. On testcase1 (1,656 components) an x-ray toggle takes a few + milliseconds of main-thread JavaScript instead of 1.5–1.6 s, loading 1.6 s instead of 3.4 s, and + the first frame has 21 draw calls instead of 223; on the KiCad sample royalblue54l-feather with + its runtime models, a toggle takes 2–7 ms instead of 0.4–0.7 s. - **Licence: AGPL-3.0-only or a commercial licence** (was MIT). boardui stays open source; closed-source products, and web applications that don't publish their source, need a commercial licence ([LICENSING.md](LICENSING.md), [ADR 0015](docs/adr/0015-agpl-plus-commercial-licence.md)). diff --git a/packages/viewer/README.md b/packages/viewer/README.md index b2d622e..7a3d45d 100644 --- a/packages/viewer/README.md +++ b/packages/viewer/README.md @@ -165,8 +165,8 @@ component) is switched off or the element is hidden with `hide`. (`src/materials.ts`; runtime models by the material's content, as their loaders make copies of a small palette), so three builds each shader once instead of once per mesh. Each has a normal and an x-ray variant: `setXray` swaps the meshes' materials and changes none, and the other mode is - compiled (`compileAsync`) in idle time after loading and after runtime models arrive, so toggling - x-ray rebuilds nothing. + compiled (`compileAsync`) in idle time after loading, after runtime models arrive and after a + layer is shown, so toggling x-ray rebuilds nothing. - **Components.** The asset's component meshes (placeholder bodies, pin-1 markers, embedded user models) are merged into one static mesh per material, in board coordinates, with each vertex's state texel, so a board draws its bodies with a few meshes however many packages it has; the diff --git a/packages/viewer/src/element.ts b/packages/viewer/src/element.ts index 8e2735c..58c505a 100644 --- a/packages/viewer/src/element.ts +++ b/packages/viewer/src/element.ts @@ -385,11 +385,13 @@ export class BoardViewerElement extends ElementBase { /** Shows or hides a layer or drill layer. */ setLayerVisible(id: string, visible: boolean): void { - const layer = this.#model().layer(id); + const loaded = this.#require(); + const layer = loaded.model.layer(id); if (!layer) throw new RangeError(`Unknown layer: ${id}`); layer.group.visible = visible; this.#visibility++; this.#requestRender(); + if (visible) this.#prepare(loaded); } /** From fe8dea0abb3d260831c54d8633b1e1912d5d0005 Mon Sep 17 00:00:00 2001 From: Michal Dub <37038617+midub@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:10:03 +0000 Subject: [PATCH 5/6] Prepare x-ray per mesh in idle time, so that slow pipelines don't hold up shader builds --- packages/viewer/src/element.ts | 72 +++++++++++++++++++++++++-------- packages/viewer/src/renderer.ts | 8 ++-- 2 files changed, 59 insertions(+), 21 deletions(-) diff --git a/packages/viewer/src/element.ts b/packages/viewer/src/element.ts index 58c505a..77724be 100644 --- a/packages/viewer/src/element.ts +++ b/packages/viewer/src/element.ts @@ -81,9 +81,11 @@ const MASK_DEPTH_BIAS = 4; /** Share of {@link LoadProgress.fraction} taken by the conversion; loading takes the rest. */ const CONVERT_SHARE = 0.9; -/** Runs `callback` when the browser is idle (or soon, where it can't tell). */ -function idle(callback: () => void): void { - if (globalThis.requestIdleCallback) globalThis.requestIdleCallback(() => callback()); +/** + * Runs `callback` when the browser is idle (or soon, where it can't tell, without a deadline). + */ +function idle(callback: (deadline?: IdleDeadline) => void): void { + if (globalThis.requestIdleCallback) globalThis.requestIdleCallback(callback); else setTimeout(callback, 50); } @@ -93,6 +95,9 @@ function afterFrame(callback: () => void): void { else setTimeout(callback, 16); } +/** Idle time (ms) left in which `#preparePass` still compiles another mesh (≈ one build). */ +const PREPARE_SLICE_MS = 10; + /** Pointer travel (CSS px) below which a press and release count as a click. */ const CLICK_SLOP = 4; /** `focus(id)` leaves room around the element, as a factor of its size. */ @@ -128,9 +133,9 @@ interface Loaded { bvhs: BvhBuilder | null; /** Resolves when every pickable layer has its BVH. */ pickable: Promise; - /** Settles when three has compiled the materials of the other x-ray mode (see `#prepare`). */ + /** Resolves when the last `#prepare` pass has handed every mesh to three. */ prepared: Promise; - /** Whether a `#prepare` run is scheduled. */ + /** Whether a `#prepare` pass is scheduled. */ prepareQueued: boolean; } @@ -660,22 +665,55 @@ export class BoardViewerElement extends ElementBase { } /** - * Has three compile the materials of the other x-ray mode in idle time after the next frame, - * so that toggling doesn't stall. three compiles the scene as it is when `compile` is called, - * so the meshes get their other variants for just that call. + * Has three compile the materials of the other x-ray mode for the meshes drawn now, in idle + * time after the next frame, so that toggling doesn't stall. */ #prepare(loaded: Loaded): void { if (loaded.prepareQueued) return; loaded.prepareQueued = true; - const run = () => { - loaded.prepareQueued = false; - const renderer = this.#renderer; - if (this.#loaded !== loaded || !renderer) return; - loaded.materials.setXray(!this.#xray); - loaded.prepared = renderer.compile().catch(() => {}); - loaded.materials.setXray(this.#xray); - }; - void loaded.prepared.then(() => afterFrame(() => idle(run))); + void loaded.prepared.then(() => + afterFrame(() => + idle(() => { + loaded.prepareQueued = false; + loaded.prepared = this.#preparePass(loaded); + }), + ), + ); + } + + /** + * Compiles the other variant of each drawn mesh in idle callbacks, a mesh at a time: three + * builds its node material at once and creates its pipeline in the background, so that slow + * pipelines don't hold up the rest. three compiles a mesh as it is when `compile` is called, + * so the mesh has its other variant for just that call. + */ + #preparePass(loaded: Loaded): Promise { + const meshes: Mesh[] = []; + loaded.model.root.traverseVisible((object) => { + if ((object as Mesh).isMesh) meshes.push(object as Mesh); + }); + return new Promise((resolve) => { + const next = (deadline?: IdleDeadline) => { + do { + const renderer = this.#renderer; + const mesh = meshes.shift(); + if (!mesh || !renderer || this.#loaded !== loaded) { + resolve(); + return; + } + const variants = loaded.materials.variants(mesh); + const other = this.#xray ? variants?.normal : variants?.xray; + const current = mesh.material; + if (other && other !== current) { + mesh.material = other; + renderer.compile(mesh).catch(() => {}); + mesh.material = current; + } + } while ((deadline?.timeRemaining() ?? 0) > PREPARE_SLICE_MS); + idle(next); + }; + next(); + }); } /** diff --git a/packages/viewer/src/renderer.ts b/packages/viewer/src/renderer.ts index 0bdb7e0..6aef35f 100644 --- a/packages/viewer/src/renderer.ts +++ b/packages/viewer/src/renderer.ts @@ -186,11 +186,11 @@ export class BoardRenderer { } /** - * Compiles what the scene would draw now (node materials and pipelines) without drawing it, - * a little at a time; resolves when done. + * Compiles what the scene, or `object` in it, would draw now (node materials and pipelines) + * without drawing it; resolves when done. */ - compile(): Promise { - return this.renderer.compileAsync(this.scene, this.camera); + compile(object: Object3D = this.scene): Promise { + return this.renderer.compileAsync(object, this.camera, this.scene); } /** Schedules a frame. */ From 4f03669c6d8cf20d4af1dd5a5c6b6d57fb288760 Mon Sep 17 00:00:00 2001 From: Michal Dub <37038617+midub@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:15:52 +0000 Subject: [PATCH 6/6] Changelog: final numbers --- CHANGELOG.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bea5225..db889c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,10 @@ glTF profile has its own version (`profileVersion`, [spec §11](spec/README.md#1 materials, x-ray mode swaps materials prepared after loading instead of rebuilding every shader, and the components' placeholder bodies are drawn as a few merged meshes instead of one instanced mesh per package. On testcase1 (1,656 components) an x-ray toggle takes a few - milliseconds of main-thread JavaScript instead of 1.5–1.6 s, loading 1.6 s instead of 3.4 s, and - the first frame has 21 draw calls instead of 223; on the KiCad sample royalblue54l-feather with - its runtime models, a toggle takes 2–7 ms instead of 0.4–0.7 s. + milliseconds of main-thread JavaScript instead of 1.5–1.6 s, loading about 1.6 s instead of + 3.4 s, and the first frame has 21 draw calls instead of 223; on the KiCad sample + royalblue54l-feather with its runtime models, a toggle takes a few milliseconds instead of + 0.4–0.7 s. - **Licence: AGPL-3.0-only or a commercial licence** (was MIT). boardui stays open source; closed-source products, and web applications that don't publish their source, need a commercial licence ([LICENSING.md](LICENSING.md), [ADR 0015](docs/adr/0015-agpl-plus-commercial-licence.md)).