diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b20503..db889c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ 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 a few + 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)). 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..7a3d45d 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, 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 + 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 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..7cc9950 100644 --- a/packages/viewer/src/bodies.ts +++ b/packages/viewer/src/bodies.ts @@ -1,24 +1,101 @@ /** - * 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 InterleavedBufferAttribute, 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 +103,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 +129,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 +147,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 +155,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 +195,215 @@ 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 = 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 === '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; + const indices = index?.array; + for (let j = 0; j < count; j += 3) { + 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); + } + 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: 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.#bounds[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 +411,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 +430,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 +445,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 +500,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 +519,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 +528,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 +551,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 +560,75 @@ 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(); +/** + * 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(','); +} + +/** + * 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 matrix = new Matrix4(); - batch.rows.forEach((row, i) => { - mesh.getMatrixAt(i, matrix); - box.copy(geometry.boundingBox as Box3).applyMatrix4(matrix); - bounds[row]?.union(box); - }); + 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(); + 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..77724be 100644 --- a/packages/viewer/src/element.ts +++ b/packages/viewer/src/element.ts @@ -81,6 +81,23 @@ 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, without a deadline). + */ +function idle(callback: (deadline?: IdleDeadline) => 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); +} + +/** 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. */ @@ -116,6 +133,10 @@ interface Loaded { bvhs: BvhBuilder | null; /** Resolves when every pickable layer has its BVH. */ pickable: Promise; + /** Resolves when the last `#prepare` pass has handed every mesh to three. */ + prepared: Promise; + /** Whether a `#prepare` pass is scheduled. */ + prepareQueued: boolean; } /** @@ -245,25 +266,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 +292,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 +309,8 @@ export class BoardViewerElement extends ElementBase { overlays, bvhs: null, pickable: Promise.resolve(), + prepared: Promise.resolve(), + prepareQueued: false, }; this.#unload(); this.#loaded = loaded; @@ -375,11 +390,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); } /** @@ -428,7 +445,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 +661,59 @@ 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 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; + 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(); + }); } /** @@ -654,7 +724,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 +735,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 +754,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 +801,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..6aef35f 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, or `object` in it, would draw now (node materials and pipelines) + * without drawing it; resolves when done. + */ + compile(object: Object3D = this.scene): Promise { + return this.renderer.compileAsync(object, this.camera, this.scene); + } + /** 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( 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, }, });