@ifc-lite/renderer 1.14.4 → 1.14.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -20,7 +20,6 @@ export * from './types.js';
20
20
  // Zero-copy GPU upload (new - faster, less memory)
21
21
  export { ZeroCopyGpuUploader, createZeroCopyUploader, } from './zero-copy-uploader.js';
22
22
  // Extracted manager classes
23
- export { GeometryManager } from './geometry-manager.js';
24
23
  export { PickingManager } from './picking-manager.js';
25
24
  export { RaycastEngine } from './raycast-engine.js';
26
25
  import { WebGPUDevice } from './device.js';
@@ -28,13 +27,16 @@ import { RenderPipeline, InstancedRenderPipeline } from './pipeline.js';
28
27
  import { Camera } from './camera.js';
29
28
  import { Scene } from './scene.js';
30
29
  import { Picker } from './picker.js';
30
+ import { MathUtils } from './math.js';
31
31
  import { FrustumUtils } from '@ifc-lite/spatial';
32
+ import { deduplicateMeshes } from '@ifc-lite/geometry';
32
33
  import { SectionPlaneRenderer } from './section-plane.js';
33
34
  import { Section2DOverlayRenderer } from './section-2d-overlay.js';
34
- import { GeometryManager } from './geometry-manager.js';
35
35
  import { PickingManager } from './picking-manager.js';
36
36
  import { RaycastEngine } from './raycast-engine.js';
37
37
  import { PostProcessor } from './post-processor.js';
38
+ const MAX_ENCODED_ENTITY_ID = 0xFFFFFF;
39
+ let warnedEntityIdRange = false;
38
40
  /**
39
41
  * Main renderer class
40
42
  */
@@ -55,13 +57,17 @@ export class Renderer {
55
57
  contactShading: { quality: 'off', intensity: 0.3, radius: 1.0 },
56
58
  separationLines: { enabled: true, quality: 'low', intensity: 0.5, radius: 1.0 },
57
59
  };
60
+ // Model bounds for fitToView, section planes, camera
61
+ modelBounds = null;
58
62
  // Composition: delegate to extracted managers
59
- geometryManager;
60
63
  pickingManager;
61
64
  raycastEngine;
62
65
  // Error rate limiting (log at most once per second)
63
66
  lastRenderErrorTime = 0;
64
67
  RENDER_ERROR_THROTTLE_MS = 1000;
68
+ // Dirty flag: set by requestRender(), consumed by the animation loop.
69
+ // Centralises all render scheduling — callers never call render() directly.
70
+ _renderRequested = false;
65
71
  // Pooled per-frame buffers to avoid GC pressure from per-batch Float32Array allocations
66
72
  // A single 192-byte uniform buffer (48 floats) is reused for all batches/meshes within a frame
67
73
  uniformScratch = new Float32Array(48);
@@ -72,8 +78,7 @@ export class Renderer {
72
78
  this.camera = new Camera();
73
79
  this.scene = new Scene();
74
80
  // Create composition managers
75
- this.geometryManager = new GeometryManager(this.device, this.scene);
76
- this.pickingManager = new PickingManager(this.camera, this.scene, null, this.canvas, this.geometryManager);
81
+ this.pickingManager = new PickingManager(this.camera, this.scene, null, this.canvas, (meshData) => this.createMeshFromData(meshData));
77
82
  this.raycastEngine = new RaycastEngine(this.camera, this.scene, this.canvas);
78
83
  }
79
84
  /**
@@ -111,10 +116,22 @@ export class Renderer {
111
116
  * @param geometry - Either a GeometryResult from geometry.process() or an array of MeshData
112
117
  */
113
118
  loadGeometry(geometry) {
114
- if (!this.pipeline) {
119
+ if (!this.device.isInitialized() || !this.pipeline) {
115
120
  throw new Error('Renderer not initialized. Call init() first.');
116
121
  }
117
- this.geometryManager.loadGeometry(geometry, this.pipeline);
122
+ const meshes = Array.isArray(geometry) ? geometry : geometry.meshes;
123
+ if (meshes.length === 0) {
124
+ console.warn('[Renderer] loadGeometry called with empty mesh array');
125
+ return;
126
+ }
127
+ // Use batched rendering for optimal performance
128
+ const device = this.device.getDevice();
129
+ this.scene.appendToBatches(meshes, device, this.pipeline, false);
130
+ // Calculate and store model bounds for fitToView
131
+ this.updateModelBounds(meshes);
132
+ console.log(`[Renderer] Loaded ${meshes.length} meshes`);
133
+ // Update camera scene bounds for tight orthographic near/far planes
134
+ this.camera.setSceneBounds(this.modelBounds);
118
135
  }
119
136
  /**
120
137
  * Add multiple meshes to the scene (convenience method for streaming)
@@ -123,16 +140,38 @@ export class Renderer {
123
140
  * @param isStreaming - If true, throttles batch rebuilding for better streaming performance
124
141
  */
125
142
  addMeshes(meshes, isStreaming = false) {
126
- if (!this.pipeline) {
143
+ if (!this.device.isInitialized() || !this.pipeline) {
127
144
  throw new Error('Renderer not initialized. Call init() first.');
128
145
  }
129
- this.geometryManager.addMeshes(meshes, this.pipeline, isStreaming);
146
+ if (meshes.length === 0)
147
+ return;
148
+ const device = this.device.getDevice();
149
+ this.scene.appendToBatches(meshes, device, this.pipeline, isStreaming);
150
+ // Update model bounds incrementally
151
+ this.updateModelBounds(meshes);
152
+ // Update camera scene bounds for tight orthographic near/far planes
153
+ this.camera.setSceneBounds(this.modelBounds);
130
154
  }
131
155
  /**
132
156
  * Fit camera to view all loaded geometry
133
157
  */
134
158
  fitToView() {
135
- this.geometryManager.fitToView(this.camera);
159
+ if (!this.modelBounds) {
160
+ console.warn('[Renderer] fitToView called but no geometry loaded');
161
+ return;
162
+ }
163
+ const { min, max } = this.modelBounds;
164
+ // Calculate center and size
165
+ const center = {
166
+ x: (min.x + max.x) / 2,
167
+ y: (min.y + max.y) / 2,
168
+ z: (min.z + max.z) / 2
169
+ };
170
+ const size = Math.max(max.x - min.x, max.y - min.y, max.z - min.z);
171
+ // Position camera to see entire model
172
+ const distance = size * 1.5;
173
+ this.camera.setPosition(center.x + distance * 0.5, center.y + distance * 0.5, center.z + distance);
174
+ this.camera.setTarget(center.x, center.y, center.z);
136
175
  }
137
176
  /**
138
177
  * Add mesh to scene with per-mesh GPU resources for unique colors
@@ -140,17 +179,96 @@ export class Renderer {
140
179
  addMesh(mesh) {
141
180
  if (!this.pipeline)
142
181
  return;
143
- this.geometryManager.addMesh(mesh, this.pipeline);
182
+ // Create per-mesh uniform buffer and bind group if not already created
183
+ if (!mesh.uniformBuffer && this.device.isInitialized()) {
184
+ const device = this.device.getDevice();
185
+ // Create uniform buffer for this mesh
186
+ mesh.uniformBuffer = device.createBuffer({
187
+ size: this.pipeline.getUniformBufferSize(),
188
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
189
+ });
190
+ // Create bind group for this mesh
191
+ mesh.bindGroup = device.createBindGroup({
192
+ layout: this.pipeline.getBindGroupLayout(),
193
+ entries: [
194
+ {
195
+ binding: 0,
196
+ resource: { buffer: mesh.uniformBuffer },
197
+ },
198
+ ],
199
+ });
200
+ }
201
+ this.scene.addMesh(mesh);
144
202
  }
145
203
  /**
146
204
  * Add instanced geometry to scene
147
205
  * Converts InstancedGeometry from geometry package to InstancedMesh for rendering
148
206
  */
149
207
  addInstancedGeometry(geometry) {
150
- if (!this.instancedPipeline) {
208
+ if (!this.instancedPipeline || !this.device.isInitialized()) {
151
209
  throw new Error('Renderer not initialized. Call init() first.');
152
210
  }
153
- this.geometryManager.addInstancedGeometry(geometry, this.instancedPipeline);
211
+ const device = this.device.getDevice();
212
+ // Upload positions and normals interleaved
213
+ const vertexCount = geometry.positions.length / 3;
214
+ const vertexData = new Float32Array(vertexCount * 6);
215
+ for (let i = 0; i < vertexCount; i++) {
216
+ vertexData[i * 6 + 0] = geometry.positions[i * 3 + 0];
217
+ vertexData[i * 6 + 1] = geometry.positions[i * 3 + 1];
218
+ vertexData[i * 6 + 2] = geometry.positions[i * 3 + 2];
219
+ vertexData[i * 6 + 3] = geometry.normals[i * 3 + 0];
220
+ vertexData[i * 6 + 4] = geometry.normals[i * 3 + 1];
221
+ vertexData[i * 6 + 5] = geometry.normals[i * 3 + 2];
222
+ }
223
+ // Create vertex buffer with exact size needed (ensure it matches data size)
224
+ const vertexBufferSize = vertexData.byteLength;
225
+ const vertexBuffer = device.createBuffer({
226
+ size: vertexBufferSize,
227
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
228
+ });
229
+ device.queue.writeBuffer(vertexBuffer, 0, vertexData);
230
+ // Create index buffer
231
+ const indexBuffer = device.createBuffer({
232
+ size: geometry.indices.byteLength,
233
+ usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
234
+ });
235
+ device.queue.writeBuffer(indexBuffer, 0, geometry.indices);
236
+ // Create instance buffer: each instance is 80 bytes (20 floats: 16 for transform + 4 for color)
237
+ const instanceCount = geometry.instance_count;
238
+ const instanceData = new Float32Array(instanceCount * 20);
239
+ const expressIdToInstanceIndex = new Map();
240
+ for (let i = 0; i < instanceCount; i++) {
241
+ const instance = geometry.get_instance(i);
242
+ if (!instance)
243
+ continue;
244
+ const baseIdx = i * 20;
245
+ // Copy transform (16 floats)
246
+ instanceData.set(instance.transform, baseIdx);
247
+ // Copy color (4 floats)
248
+ instanceData[baseIdx + 16] = instance.color[0];
249
+ instanceData[baseIdx + 17] = instance.color[1];
250
+ instanceData[baseIdx + 18] = instance.color[2];
251
+ instanceData[baseIdx + 19] = instance.color[3];
252
+ expressIdToInstanceIndex.set(instance.expressId, i);
253
+ }
254
+ const instanceBuffer = device.createBuffer({
255
+ size: instanceData.byteLength,
256
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
257
+ });
258
+ device.queue.writeBuffer(instanceBuffer, 0, instanceData);
259
+ // Create and cache bind group to avoid per-frame allocation
260
+ const bindGroup = this.instancedPipeline.createInstanceBindGroup(instanceBuffer);
261
+ const instancedMesh = {
262
+ geometryId: Number(geometry.geometryId),
263
+ vertexBuffer,
264
+ indexBuffer,
265
+ indexCount: geometry.indices.length,
266
+ instanceBuffer,
267
+ instanceCount: instanceCount,
268
+ expressIdToInstanceIndex,
269
+ bindGroup,
270
+ };
271
+ this.scene.addInstancedMesh(instancedMesh);
154
272
  }
155
273
  /**
156
274
  * Convert MeshData array to instanced meshes for optimized rendering
@@ -158,19 +276,192 @@ export class Renderer {
158
276
  * Call this in background after initial streaming completes
159
277
  */
160
278
  convertToInstanced(meshDataArray) {
161
- if (!this.instancedPipeline) {
279
+ if (!this.instancedPipeline || !this.device.isInitialized()) {
162
280
  console.warn('[Renderer] Cannot convert to instanced: renderer not initialized');
163
281
  return;
164
282
  }
165
- this.geometryManager.convertToInstanced(meshDataArray, this.instancedPipeline);
283
+ const instancedData = deduplicateMeshes(meshDataArray);
284
+ const device = this.device.getDevice();
285
+ let totalInstances = 0;
286
+ for (const group of instancedData) {
287
+ const vertexCount = group.positions.length / 3;
288
+ const vertexData = new Float32Array(vertexCount * 6);
289
+ for (let i = 0; i < vertexCount; i++) {
290
+ vertexData[i * 6 + 0] = group.positions[i * 3 + 0];
291
+ vertexData[i * 6 + 1] = group.positions[i * 3 + 1];
292
+ vertexData[i * 6 + 2] = group.positions[i * 3 + 2];
293
+ vertexData[i * 6 + 3] = group.normals[i * 3 + 0];
294
+ vertexData[i * 6 + 4] = group.normals[i * 3 + 1];
295
+ vertexData[i * 6 + 5] = group.normals[i * 3 + 2];
296
+ }
297
+ const vertexBuffer = device.createBuffer({
298
+ size: vertexData.byteLength,
299
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
300
+ });
301
+ device.queue.writeBuffer(vertexBuffer, 0, vertexData);
302
+ const indexBuffer = device.createBuffer({
303
+ size: group.indices.byteLength,
304
+ usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
305
+ });
306
+ device.queue.writeBuffer(indexBuffer, 0, group.indices);
307
+ const instanceCount = group.instances.length;
308
+ const instanceData = new Float32Array(instanceCount * 20);
309
+ const expressIdToInstanceIndex = new Map();
310
+ const identityTransform = new Float32Array([
311
+ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1,
312
+ ]);
313
+ for (let i = 0; i < instanceCount; i++) {
314
+ const instance = group.instances[i];
315
+ const baseIdx = i * 20;
316
+ instanceData.set(identityTransform, baseIdx);
317
+ instanceData[baseIdx + 16] = instance.color[0];
318
+ instanceData[baseIdx + 17] = instance.color[1];
319
+ instanceData[baseIdx + 18] = instance.color[2];
320
+ instanceData[baseIdx + 19] = instance.color[3];
321
+ expressIdToInstanceIndex.set(instance.expressId, i);
322
+ }
323
+ const instanceBuffer = device.createBuffer({
324
+ size: instanceData.byteLength,
325
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
326
+ });
327
+ device.queue.writeBuffer(instanceBuffer, 0, instanceData);
328
+ const bindGroup = this.instancedPipeline.createInstanceBindGroup(instanceBuffer);
329
+ let geometryHash = 0;
330
+ for (let i = 0; i < group.geometryHash.length; i++) {
331
+ geometryHash = ((geometryHash << 5) - geometryHash) + group.geometryHash.charCodeAt(i);
332
+ geometryHash = geometryHash & geometryHash;
333
+ }
334
+ this.scene.addInstancedMesh({
335
+ geometryId: Math.abs(geometryHash),
336
+ vertexBuffer,
337
+ indexBuffer,
338
+ indexCount: group.indices.length,
339
+ instanceBuffer,
340
+ instanceCount,
341
+ expressIdToInstanceIndex,
342
+ bindGroup,
343
+ });
344
+ totalInstances += instanceCount;
345
+ }
346
+ const regularMeshCount = this.scene.getMeshes().length;
347
+ this.scene.clearRegularMeshes();
348
+ console.log(`[Renderer] Converted ${meshDataArray.length} meshes to ${instancedData.length} instanced geometries ` +
349
+ `(${totalInstances} total instances, ${(totalInstances / instancedData.length).toFixed(1)}x deduplication). ` +
350
+ `Cleared ${regularMeshCount} regular meshes.`);
166
351
  }
167
352
  /**
168
353
  * Ensure all meshes have GPU resources (call after adding meshes if pipeline wasn't ready)
169
354
  */
170
355
  ensureMeshResources() {
171
- if (!this.pipeline)
356
+ if (!this.pipeline || !this.device.isInitialized())
172
357
  return;
173
- this.geometryManager.ensureMeshResources(this.pipeline);
358
+ const device = this.device.getDevice();
359
+ for (const mesh of this.scene.getMeshes()) {
360
+ if (!mesh.uniformBuffer) {
361
+ mesh.uniformBuffer = device.createBuffer({
362
+ size: this.pipeline.getUniformBufferSize(),
363
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
364
+ });
365
+ mesh.bindGroup = device.createBindGroup({
366
+ layout: this.pipeline.getBindGroupLayout(),
367
+ entries: [{
368
+ binding: 0,
369
+ resource: { buffer: mesh.uniformBuffer },
370
+ }],
371
+ });
372
+ }
373
+ }
374
+ }
375
+ /**
376
+ * Get model bounds (used for section planes, fitToView, etc.)
377
+ */
378
+ getModelBounds() {
379
+ return this.modelBounds;
380
+ }
381
+ /**
382
+ * Set model bounds (used when computing bounds from batches)
383
+ */
384
+ setModelBounds(bounds) {
385
+ this.modelBounds = bounds;
386
+ }
387
+ /**
388
+ * Update model bounds from mesh data
389
+ */
390
+ updateModelBounds(meshes) {
391
+ if (!this.modelBounds) {
392
+ this.modelBounds = {
393
+ min: { x: Infinity, y: Infinity, z: Infinity },
394
+ max: { x: -Infinity, y: -Infinity, z: -Infinity }
395
+ };
396
+ }
397
+ for (const mesh of meshes) {
398
+ const positions = mesh.positions;
399
+ for (let i = 0; i < positions.length; i += 3) {
400
+ const x = positions[i];
401
+ const y = positions[i + 1];
402
+ const z = positions[i + 2];
403
+ if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z)) {
404
+ this.modelBounds.min.x = Math.min(this.modelBounds.min.x, x);
405
+ this.modelBounds.min.y = Math.min(this.modelBounds.min.y, y);
406
+ this.modelBounds.min.z = Math.min(this.modelBounds.min.z, z);
407
+ this.modelBounds.max.x = Math.max(this.modelBounds.max.x, x);
408
+ this.modelBounds.max.y = Math.max(this.modelBounds.max.y, y);
409
+ this.modelBounds.max.z = Math.max(this.modelBounds.max.z, z);
410
+ }
411
+ }
412
+ }
413
+ }
414
+ /**
415
+ * Create a GPU Mesh from MeshData (lazy creation for selection highlighting)
416
+ * This is called on-demand when a mesh is selected, avoiding 2x buffer creation during streaming
417
+ */
418
+ createMeshFromData(meshData) {
419
+ if (!this.device.isInitialized())
420
+ return;
421
+ const device = this.device.getDevice();
422
+ const vertexCount = meshData.positions.length / 3;
423
+ const interleavedRaw = new ArrayBuffer(vertexCount * 7 * 4);
424
+ const interleaved = new Float32Array(interleavedRaw);
425
+ const interleavedU32 = new Uint32Array(interleavedRaw);
426
+ for (let i = 0; i < vertexCount; i++) {
427
+ const base = i * 7;
428
+ const posBase = i * 3;
429
+ interleaved[base] = meshData.positions[posBase];
430
+ interleaved[base + 1] = meshData.positions[posBase + 1];
431
+ interleaved[base + 2] = meshData.positions[posBase + 2];
432
+ interleaved[base + 3] = meshData.normals[posBase];
433
+ interleaved[base + 4] = meshData.normals[posBase + 1];
434
+ interleaved[base + 5] = meshData.normals[posBase + 2];
435
+ let encodedId = meshData.expressId >>> 0;
436
+ if (encodedId > MAX_ENCODED_ENTITY_ID) {
437
+ if (!warnedEntityIdRange) {
438
+ warnedEntityIdRange = true;
439
+ console.warn('[Renderer] expressId exceeds 24-bit seam-ID encoding range; seam lines may collide.');
440
+ }
441
+ encodedId = encodedId & MAX_ENCODED_ENTITY_ID;
442
+ }
443
+ interleavedU32[base + 6] = encodedId;
444
+ }
445
+ const vertexBuffer = device.createBuffer({
446
+ size: interleaved.byteLength,
447
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
448
+ });
449
+ device.queue.writeBuffer(vertexBuffer, 0, interleaved);
450
+ const indexBuffer = device.createBuffer({
451
+ size: meshData.indices.byteLength,
452
+ usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
453
+ });
454
+ device.queue.writeBuffer(indexBuffer, 0, meshData.indices);
455
+ // Add to scene with identity transform (positions already in world space)
456
+ this.scene.addMesh({
457
+ expressId: meshData.expressId,
458
+ modelIndex: meshData.modelIndex, // Preserve modelIndex for multi-model selection
459
+ vertexBuffer,
460
+ indexBuffer,
461
+ indexCount: meshData.indices.length,
462
+ transform: MathUtils.identity(),
463
+ color: meshData.color,
464
+ });
174
465
  }
175
466
  resolveVisualEnhancement(options) {
176
467
  if (!options) {
@@ -236,12 +527,15 @@ export class Renderer {
236
527
  const device = this.device.getDevice();
237
528
  const viewProj = this.camera.getViewProjMatrix().m;
238
529
  const visualEnhancement = this.resolveVisualEnhancement(options.visualEnhancement);
239
- const edgeEnabled = visualEnhancement.enabled && visualEnhancement.edgeContrast.enabled;
530
+ // Skip expensive visual effects during interaction (orbit/pan/zoom)
531
+ // to keep frame times low on integrated GPUs (MacBook Air etc.)
532
+ const interacting = options.isInteracting === true;
533
+ const edgeEnabled = !interacting && visualEnhancement.enabled && visualEnhancement.edgeContrast.enabled;
240
534
  const edgeIntensity = Math.min(3.0, Math.max(0.0, visualEnhancement.edgeContrast.intensity));
241
535
  const edgeEnabledU32 = edgeEnabled ? 1 : 0;
242
536
  const edgeIntensityMilliU32 = Math.round(edgeIntensity * 1000);
243
- const contactEnabled = visualEnhancement.enabled && visualEnhancement.contactShading.quality !== 'off';
244
- const separationEnabled = visualEnhancement.enabled
537
+ const contactEnabled = !interacting && visualEnhancement.enabled && visualEnhancement.contactShading.quality !== 'off';
538
+ const separationEnabled = !interacting && visualEnhancement.enabled
245
539
  && visualEnhancement.separationLines.enabled
246
540
  && visualEnhancement.separationLines.quality !== 'off';
247
541
  const needsObjectIdPass = contactEnabled || separationEnabled;
@@ -356,8 +650,9 @@ export class Renderer {
356
650
  boundsMin.x = boundsMin.y = boundsMin.z = -100;
357
651
  boundsMax.x = boundsMax.y = boundsMax.z = 100;
358
652
  }
359
- // Store bounds for section plane visual
360
- this.geometryManager.setModelBounds({ min: boundsMin, max: boundsMax });
653
+ // Store bounds for section plane visual and camera near/far
654
+ this.setModelBounds({ min: boundsMin, max: boundsMax });
655
+ this.camera.setSceneBounds({ min: boundsMin, max: boundsMax });
361
656
  // Only calculate clipping data if section is enabled
362
657
  if (options.sectionPlane.enabled) {
363
658
  // Calculate plane normal based on semantic axis
@@ -630,13 +925,6 @@ export class Renderer {
630
925
  // PERFORMANCE FIX: Render partially visible batches as sub-batches (not individual meshes!)
631
926
  // This is the key optimization: instead of 10,000+ individual draw calls,
632
927
  // we create cached sub-batches with only visible elements and render them as single draw calls
633
- const allMeshesFromScene = this.scene.getMeshes();
634
- // Track existing mesh piece counts by (expressId:modelIndex) for multi-piece elements.
635
- const existingPieceCounts = new Map();
636
- for (const mesh of allMeshesFromScene) {
637
- const key = `${mesh.expressId}:${mesh.modelIndex ?? 'any'}`;
638
- existingPieceCounts.set(key, (existingPieceCounts.get(key) ?? 0) + 1);
639
- }
640
928
  if (partiallyVisibleBatches.length > 0) {
641
929
  for (const { colorKey, visibleIds, color } of partiallyVisibleBatches) {
642
930
  // Get or create a cached sub-batch for this visibility state
@@ -679,29 +967,41 @@ export class Renderer {
679
967
  continue;
680
968
  visibleSelectedIds.add(selId);
681
969
  }
682
- const baselineExistingCounts = new Map(existingPieceCounts);
683
- for (const selId of visibleSelectedIds) {
684
- const pieces = this.scene.getMeshDataPieces(selId, selectedModelIndex);
685
- if (!pieces || pieces.length === 0)
686
- continue;
687
- const seenOrdinalsByKey = new Map();
688
- for (const piece of pieces) {
689
- const meshKey = `${piece.expressId}:${piece.modelIndex ?? 'any'}`;
690
- const ordinal = seenOrdinalsByKey.get(meshKey) ?? 0;
691
- seenOrdinalsByKey.set(meshKey, ordinal + 1);
692
- const baselineExisting = baselineExistingCounts.get(meshKey) ?? 0;
693
- if (ordinal < baselineExisting)
970
+ // Only build per-mesh piece counts when we actually have selected
971
+ // elements that need individual mesh rendering. This avoids iterating
972
+ // 200K+ meshes every frame when nothing is selected.
973
+ if (visibleSelectedIds.size > 0) {
974
+ const allMeshesFromScene = this.scene.getMeshes();
975
+ const existingPieceCounts = new Map();
976
+ for (const mesh of allMeshesFromScene) {
977
+ const key = `${mesh.expressId}:${mesh.modelIndex ?? 'any'}`;
978
+ existingPieceCounts.set(key, (existingPieceCounts.get(key) ?? 0) + 1);
979
+ }
980
+ for (const selId of visibleSelectedIds) {
981
+ const pieces = this.scene.getMeshDataPieces(selId, selectedModelIndex);
982
+ if (!pieces || pieces.length === 0)
694
983
  continue;
695
- this.geometryManager.createMeshFromData(piece);
984
+ const seenOrdinalsByKey = new Map();
985
+ for (const piece of pieces) {
986
+ const meshKey = `${piece.expressId}:${piece.modelIndex ?? 'any'}`;
987
+ const ordinal = seenOrdinalsByKey.get(meshKey) ?? 0;
988
+ seenOrdinalsByKey.set(meshKey, ordinal + 1);
989
+ const baselineExisting = existingPieceCounts.get(meshKey) ?? 0;
990
+ if (ordinal < baselineExisting)
991
+ continue;
992
+ this.createMeshFromData(piece);
993
+ }
696
994
  }
697
995
  }
698
- const selectedMeshes = this.scene.getMeshes().filter(mesh => {
699
- if (!visibleSelectedIds.has(mesh.expressId))
700
- return false;
701
- if (selectedModelIndex !== undefined && mesh.modelIndex !== selectedModelIndex)
702
- return false;
703
- return true;
704
- });
996
+ const selectedMeshes = visibleSelectedIds.size > 0
997
+ ? this.scene.getMeshes().filter(mesh => {
998
+ if (!visibleSelectedIds.has(mesh.expressId))
999
+ return false;
1000
+ if (selectedModelIndex !== undefined && mesh.modelIndex !== selectedModelIndex)
1001
+ return false;
1002
+ return true;
1003
+ })
1004
+ : [];
705
1005
  // Render transparent BATCHED meshes with transparent pipeline (after opaque batches and selections)
706
1006
  if (transparentBatches.length > 0) {
707
1007
  pass.setPipeline(this.pipeline.getTransparentPipeline());
@@ -859,7 +1159,7 @@ export class Renderer {
859
1159
  }
860
1160
  // Draw section plane visual BEFORE pass.end() (within same MSAA render pass)
861
1161
  // Always show plane when sectionPlane options are provided (as preview or active)
862
- const modelBounds = this.geometryManager.getModelBounds();
1162
+ const modelBounds = this.getModelBounds();
863
1163
  if (options.sectionPlane && this.sectionPlaneRenderer && modelBounds) {
864
1164
  this.sectionPlaneRenderer.draw(pass, {
865
1165
  axis: options.sectionPlane.axis,
@@ -973,6 +1273,37 @@ export class Renderer {
973
1273
  clearCaches() {
974
1274
  this.raycastEngine.clearCaches();
975
1275
  }
1276
+ // ─── Dirty-flag render scheduling ────────────────────────────────────
1277
+ // The animation loop is THE render path. Everything else (mouse, touch,
1278
+ // keyboard, streaming, visibility changes, theme changes) calls
1279
+ // requestRender() to set the dirty flag. The loop drains scene queues,
1280
+ // resolves render options via refs, and issues a single render() call.
1281
+ /**
1282
+ * Request a render on the next animation frame.
1283
+ * Safe to call many times per frame — only one render will happen.
1284
+ */
1285
+ requestRender() {
1286
+ this._renderRequested = true;
1287
+ }
1288
+ /**
1289
+ * Check whether a render has been requested without clearing the flag.
1290
+ * Used by the animation loop to test the dirty flag before committing
1291
+ * to render (e.g. when throttling may skip the frame).
1292
+ */
1293
+ peekRenderRequest() {
1294
+ return this._renderRequested;
1295
+ }
1296
+ /**
1297
+ * Consume the render request flag. Returns true (and resets the flag)
1298
+ * if a render was requested since the last call. Used by the animation
1299
+ * loop to decide whether to render.
1300
+ */
1301
+ consumeRenderRequest() {
1302
+ if (!this._renderRequested)
1303
+ return false;
1304
+ this._renderRequested = false;
1305
+ return true;
1306
+ }
976
1307
  /**
977
1308
  * Resize canvas
978
1309
  */
@@ -1001,7 +1332,7 @@ export class Renderer {
1001
1332
  // minVal = options.sectionPlane.min ?? boundsMin[axisIdx]
1002
1333
  // maxVal = options.sectionPlane.max ?? boundsMax[axisIdx]
1003
1334
  const axisIdx = axis === 'side' ? 'x' : axis === 'down' ? 'y' : 'z';
1004
- const modelBounds = this.geometryManager.getModelBounds();
1335
+ const modelBounds = this.getModelBounds();
1005
1336
  // Allow upload if either sectionRange has both values, or modelBounds exists as fallback
1006
1337
  const hasFullRange = sectionRange?.min !== undefined && sectionRange?.max !== undefined;
1007
1338
  if (!hasFullRange && !modelBounds)