@ifc-lite/renderer 1.14.3 → 1.14.6

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.
Files changed (52) hide show
  1. package/dist/camera-animation.d.ts +10 -4
  2. package/dist/camera-animation.d.ts.map +1 -1
  3. package/dist/camera-animation.js +79 -39
  4. package/dist/camera-animation.js.map +1 -1
  5. package/dist/camera-controls.d.ts +63 -25
  6. package/dist/camera-controls.d.ts.map +1 -1
  7. package/dist/camera-controls.js +284 -186
  8. package/dist/camera-controls.js.map +1 -1
  9. package/dist/camera.d.ts +32 -14
  10. package/dist/camera.d.ts.map +1 -1
  11. package/dist/camera.js +74 -25
  12. package/dist/camera.js.map +1 -1
  13. package/dist/index.d.ts +68 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +412 -51
  16. package/dist/index.js.map +1 -1
  17. package/dist/picker.d.ts +7 -0
  18. package/dist/picker.d.ts.map +1 -1
  19. package/dist/picker.js +15 -0
  20. package/dist/picker.js.map +1 -1
  21. package/dist/picking-manager.d.ts +3 -3
  22. package/dist/picking-manager.d.ts.map +1 -1
  23. package/dist/picking-manager.js +4 -4
  24. package/dist/picking-manager.js.map +1 -1
  25. package/dist/pipeline.d.ts +14 -0
  26. package/dist/pipeline.d.ts.map +1 -1
  27. package/dist/pipeline.js +32 -230
  28. package/dist/pipeline.js.map +1 -1
  29. package/dist/post-processor.d.ts +7 -0
  30. package/dist/post-processor.d.ts.map +1 -1
  31. package/dist/post-processor.js +15 -0
  32. package/dist/post-processor.js.map +1 -1
  33. package/dist/scene.d.ts +65 -9
  34. package/dist/scene.d.ts.map +1 -1
  35. package/dist/scene.js +482 -127
  36. package/dist/scene.js.map +1 -1
  37. package/dist/section-plane.d.ts +6 -0
  38. package/dist/section-plane.d.ts.map +1 -1
  39. package/dist/section-plane.js +12 -0
  40. package/dist/section-plane.js.map +1 -1
  41. package/dist/shaders/main.wgsl.d.ts +7 -0
  42. package/dist/shaders/main.wgsl.d.ts.map +1 -0
  43. package/dist/shaders/main.wgsl.js +239 -0
  44. package/dist/shaders/main.wgsl.js.map +1 -0
  45. package/dist/snap-detector.d.ts.map +1 -1
  46. package/dist/snap-detector.js +20 -6
  47. package/dist/snap-detector.js.map +1 -1
  48. package/package.json +4 -4
  49. package/dist/geometry-manager.d.ts +0 -99
  50. package/dist/geometry-manager.d.ts.map +0 -1
  51. package/dist/geometry-manager.js +0 -400
  52. package/dist/geometry-manager.js.map +0 -1
package/dist/scene.js CHANGED
@@ -8,19 +8,16 @@ let warnedEntityIdRange = false;
8
8
  export class Scene {
9
9
  meshes = [];
10
10
  instancedMeshes = [];
11
- batchedMeshes = [];
12
- batchedMeshMap = new Map(); // Map bucketKey -> BatchedMesh
13
- batchedMeshData = new Map(); // Map bucketKey -> accumulated MeshData[]
14
- batchedMeshIndex = new Map(); // Map bucketKey -> index in batchedMeshes array (O(1) lookup)
11
+ batchedMeshes = []; // flat render array (rebuilt from buckets)
12
+ buckets = new Map(); // bucketKey -> consolidated bucket state
13
+ meshDataBucket = new Map(); // reverse lookup: MeshData -> owning bucket
15
14
  meshDataMap = new Map(); // Map expressId -> MeshData[] (for lazy buffer creation, accumulates multiple pieces)
16
- meshDataBatchKey = new Map(); // Reverse lookup: MeshData -> bucketKey (O(1) removal in updateMeshColors)
17
15
  boundingBoxes = new Map(); // Map expressId -> bounding box (computed lazily)
18
16
  // Buffer-size-aware bucket splitting: when a single color group's geometry
19
17
  // would exceed the GPU maxBufferSize, overflow is directed to a new
20
18
  // sub-bucket with a suffixed key (e.g. "500|500|500|1000#1"). This keeps
21
19
  // all downstream maps single-valued and the rendering code unchanged.
22
20
  activeBucketKey = new Map(); // base colorKey -> current active bucket key
23
- bucketVertexBytes = new Map(); // bucket key -> accumulated vertex buffer bytes
24
21
  nextSplitId = 0; // Monotonic counter for sub-bucket keys
25
22
  cachedMaxBufferSize = 0; // device.limits.maxBufferSize * safety factor (set on first use)
26
23
  // Sub-batch cache for partially visible batches (PERFORMANCE FIX)
@@ -38,6 +35,16 @@ export class Scene {
38
35
  // Temporary fragment batches created during streaming for immediate rendering.
39
36
  // Destroyed and replaced by proper merged batches in finalizeStreaming().
40
37
  streamingFragments = [];
38
+ // ─── Mesh command queue ────────────────────────────────────────────
39
+ // Decouples React state updates from GPU work. Callers push meshes
40
+ // via queueMeshes() (instant, no GPU), and the animation loop drains
41
+ // the queue via flushPending() with a per-frame time budget.
42
+ meshQueue = [];
43
+ // ─── GPU-resident mode ──────────────────────────────────────────────
44
+ // After releaseGeometryData(), JS-side typed arrays are freed.
45
+ // Only lightweight metadata is retained for operations that don't need
46
+ // raw vertex data (bounding boxes, color key lookups, expressId sets).
47
+ geometryReleased = false;
41
48
  /**
42
49
  * Add mesh to scene
43
50
  */
@@ -216,14 +223,14 @@ export class Scene {
216
223
  const baseKey = this.colorKey(meshData.color);
217
224
  const bucketKey = this.resolveActiveBucket(baseKey, meshData);
218
225
  // Accumulate mesh data in the bucket (always — needed for final merge)
219
- let bucket = this.batchedMeshData.get(bucketKey);
226
+ let bucket = this.buckets.get(bucketKey);
220
227
  if (!bucket) {
221
- bucket = [];
222
- this.batchedMeshData.set(bucketKey, bucket);
228
+ bucket = { key: bucketKey, meshData: [], batchedMesh: null, vertexBytes: 0 };
229
+ this.buckets.set(bucketKey, bucket);
223
230
  }
224
- bucket.push(meshData);
225
- // Track reverse mapping for O(1) batch removal in updateMeshColors
226
- this.meshDataBatchKey.set(meshData, bucketKey);
231
+ bucket.meshData.push(meshData);
232
+ // Track reverse mapping for O(1) bucket lookup in updateMeshColors
233
+ this.meshDataBucket.set(meshData, bucket);
227
234
  // Also store individual mesh data for visibility filtering
228
235
  this.addMeshData(meshData);
229
236
  // Track pending keys for non-streaming rebuild only
@@ -252,48 +259,31 @@ export class Scene {
252
259
  if (this.pendingBatchKeys.size === 0)
253
260
  return;
254
261
  for (const key of this.pendingBatchKeys) {
255
- const meshDataForKey = this.batchedMeshData.get(key);
256
- const existingBatch = this.batchedMeshMap.get(key);
257
- if (existingBatch) {
258
- // Destroy old batch buffers
259
- existingBatch.vertexBuffer.destroy();
260
- existingBatch.indexBuffer.destroy();
261
- if (existingBatch.uniformBuffer) {
262
- existingBatch.uniformBuffer.destroy();
262
+ const bucket = this.buckets.get(key);
263
+ // Destroy old GPU batch if it exists
264
+ if (bucket?.batchedMesh) {
265
+ bucket.batchedMesh.vertexBuffer.destroy();
266
+ bucket.batchedMesh.indexBuffer.destroy();
267
+ if (bucket.batchedMesh.uniformBuffer) {
268
+ bucket.batchedMesh.uniformBuffer.destroy();
263
269
  }
270
+ bucket.batchedMesh = null;
264
271
  }
265
- if (!meshDataForKey || meshDataForKey.length === 0) {
272
+ if (!bucket || bucket.meshData.length === 0) {
266
273
  // Bucket is empty — clean up
267
- this.batchedMeshMap.delete(key);
268
- this.batchedMeshData.delete(key);
269
- this.bucketVertexBytes.delete(key);
270
- // Swap-remove from flat array using O(1) index lookup
271
- const arrayIdx = this.batchedMeshIndex.get(key);
272
- if (arrayIdx !== undefined) {
273
- const lastIdx = this.batchedMeshes.length - 1;
274
- if (arrayIdx !== lastIdx) {
275
- const lastBatch = this.batchedMeshes[lastIdx];
276
- this.batchedMeshes[arrayIdx] = lastBatch;
277
- this.batchedMeshIndex.set(lastBatch.colorKey, arrayIdx);
278
- }
279
- this.batchedMeshes.pop();
280
- this.batchedMeshIndex.delete(key);
281
- }
274
+ this.buckets.delete(key);
282
275
  continue;
283
276
  }
284
277
  // Create new batch with all accumulated meshes for this bucket
285
- const color = meshDataForKey[0].color;
286
- const batchedMesh = this.createBatchedMesh(meshDataForKey, color, device, pipeline, key);
287
- this.batchedMeshMap.set(key, batchedMesh);
288
- // Update array using O(1) index lookup instead of O(N) findIndex
289
- const existingIndex = this.batchedMeshIndex.get(key);
290
- if (existingIndex !== undefined) {
291
- this.batchedMeshes[existingIndex] = batchedMesh;
292
- }
293
- else {
294
- const newIndex = this.batchedMeshes.length;
295
- this.batchedMeshes.push(batchedMesh);
296
- this.batchedMeshIndex.set(key, newIndex);
278
+ const color = bucket.meshData[0].color;
279
+ const batchedMesh = this.createBatchedMesh(bucket.meshData, color, device, pipeline, key);
280
+ bucket.batchedMesh = batchedMesh;
281
+ }
282
+ // Rebuild the flat render array from all buckets (148 max batches — not perf critical)
283
+ this.batchedMeshes = [];
284
+ for (const bucket of this.buckets.values()) {
285
+ if (bucket.batchedMesh) {
286
+ this.batchedMeshes.push(bucket.batchedMesh);
297
287
  }
298
288
  }
299
289
  this.pendingBatchKeys.clear();
@@ -304,6 +294,39 @@ export class Scene {
304
294
  hasPendingBatches() {
305
295
  return this.pendingBatchKeys.size > 0;
306
296
  }
297
+ // ─── Mesh command queue ──────────────────────────────────────────────
298
+ /**
299
+ * Queue meshes for deferred GPU upload.
300
+ * Instant (no GPU work) — safe to call from React effects.
301
+ * The animation loop calls flushPending() each frame to drain the queue.
302
+ */
303
+ queueMeshes(meshes) {
304
+ for (let i = 0; i < meshes.length; i++) {
305
+ this.meshQueue.push(meshes[i]);
306
+ }
307
+ }
308
+ /** True if the mesh queue has pending work. */
309
+ hasQueuedMeshes() {
310
+ return this.meshQueue.length > 0;
311
+ }
312
+ /**
313
+ * Drain the mesh queue with a per-frame time budget.
314
+ * Processes queued meshes through appendToBatches in streaming mode
315
+ * (creates lightweight fragment batches for immediate rendering).
316
+ *
317
+ * @returns true if any meshes were processed (caller should render)
318
+ */
319
+ flushPending(device, pipeline) {
320
+ if (this.meshQueue.length === 0)
321
+ return false;
322
+ // Drain the entire queue in one appendToBatches call.
323
+ // The queue coalesces multiple React batches into a single GPU upload,
324
+ // which is already bounded by the WASM→JS batch interval (~50-200ms).
325
+ const meshes = this.meshQueue;
326
+ this.meshQueue = [];
327
+ this.appendToBatches(meshes, device, pipeline, true);
328
+ return true;
329
+ }
307
330
  /**
308
331
  * Create lightweight fragment batches from a single streaming batch.
309
332
  * Fragments are grouped by color and added to batchedMeshes for immediate
@@ -348,18 +371,63 @@ export class Scene {
348
371
  finalizeStreaming(device, pipeline) {
349
372
  if (this.streamingFragments.length === 0)
350
373
  return;
351
- // 1. Destroy all fragment GPU resources
352
- const fragmentSet = new Set(this.streamingFragments);
353
- for (const fragment of this.streamingFragments) {
374
+ // Save references to old fragments/batches — keep them rendering
375
+ // until the new proper batches are fully built (no visual gap).
376
+ const oldFragments = this.streamingFragments;
377
+ const oldBatches = this.batchedMeshes;
378
+ const fragmentSet = new Set(oldFragments);
379
+ this.streamingFragments = [];
380
+ // 1. Collect ALL accumulated meshData before clearing state
381
+ const allMeshData = [];
382
+ for (const bucket of this.buckets.values()) {
383
+ for (const md of bucket.meshData)
384
+ allMeshData.push(md);
385
+ }
386
+ // 2. Clear all bucket/batch state for a clean rebuild
387
+ // NOTE: batchedMeshes keeps the OLD array reference — the renderer
388
+ // continues to draw from it until we swap in the new array below.
389
+ this.buckets.clear();
390
+ this.meshDataBucket = new Map();
391
+ this.activeBucketKey.clear();
392
+ this.pendingBatchKeys.clear();
393
+ // Destroy cached partial batches — their colorKeys are now stale
394
+ for (const batch of this.partialBatchCache.values()) {
395
+ batch.vertexBuffer.destroy();
396
+ batch.indexBuffer.destroy();
397
+ if (batch.uniformBuffer) {
398
+ batch.uniformBuffer.destroy();
399
+ }
400
+ }
401
+ this.partialBatchCache.clear();
402
+ this.partialBatchCacheKeys.clear();
403
+ // 3. Re-group ALL meshData by their CURRENT color.
404
+ // meshData.color may have been mutated in-place since the mesh was
405
+ // first bucketed, so the original bucket key is stale. Re-grouping
406
+ // by current color ensures batches render with correct colors.
407
+ for (const meshData of allMeshData) {
408
+ const baseKey = this.colorKey(meshData.color);
409
+ const bucketKey = this.resolveActiveBucket(baseKey, meshData);
410
+ let bucket = this.buckets.get(bucketKey);
411
+ if (!bucket) {
412
+ bucket = { key: bucketKey, meshData: [], batchedMesh: null, vertexBytes: 0 };
413
+ this.buckets.set(bucketKey, bucket);
414
+ }
415
+ bucket.meshData.push(meshData);
416
+ this.meshDataBucket.set(meshData, bucket);
417
+ this.pendingBatchKeys.add(bucketKey);
418
+ }
419
+ // 4. Build new proper batches into a fresh array
420
+ this.batchedMeshes = [];
421
+ this.rebuildPendingBatches(device, pipeline);
422
+ // 5. NOW destroy old fragment/batch GPU resources (new batches are live)
423
+ for (const fragment of oldFragments) {
354
424
  fragment.vertexBuffer.destroy();
355
425
  fragment.indexBuffer.destroy();
356
426
  if (fragment.uniformBuffer) {
357
427
  fragment.uniformBuffer.destroy();
358
428
  }
359
429
  }
360
- this.streamingFragments = [];
361
- // 2. Destroy any pre-existing proper batches (non-fragments)
362
- for (const batch of this.batchedMeshes) {
430
+ for (const batch of oldBatches) {
363
431
  if (!fragmentSet.has(batch)) {
364
432
  batch.vertexBuffer.destroy();
365
433
  batch.indexBuffer.destroy();
@@ -368,60 +436,207 @@ export class Scene {
368
436
  }
369
437
  }
370
438
  }
371
- // 3. Collect ALL accumulated meshData before clearing state
439
+ }
440
+ /**
441
+ * Time-sliced version of finalizeStreaming.
442
+ * Re-groups mesh data and rebuilds GPU batches in small chunks,
443
+ * yielding to the event loop between chunks so orbit/pan stays responsive.
444
+ * Streaming fragments continue rendering until each new batch replaces them.
445
+ *
446
+ * @param device GPU device
447
+ * @param pipeline Render pipeline
448
+ * @param budgetMs Max milliseconds per chunk (default 8 — half a 60fps frame)
449
+ * @returns Promise that resolves when all batches are rebuilt
450
+ */
451
+ finalizeStreamingAsync(device, pipeline, budgetMs = 8) {
452
+ if (this.streamingFragments.length === 0)
453
+ return Promise.resolve();
454
+ // --- Synchronous preamble (fast O(N) bookkeeping) ---
455
+ const oldFragments = this.streamingFragments;
456
+ const oldBatches = this.batchedMeshes;
457
+ const fragmentSet = new Set(oldFragments);
458
+ this.streamingFragments = [];
459
+ // 1. Collect ALL accumulated meshData
372
460
  const allMeshData = [];
373
- for (const data of this.batchedMeshData.values()) {
374
- for (const md of data)
461
+ for (const bucket of this.buckets.values()) {
462
+ for (const md of bucket.meshData)
375
463
  allMeshData.push(md);
376
464
  }
377
- // 4. Clear all bucket/batch state for a clean rebuild
378
- this.batchedMeshes = [];
379
- this.batchedMeshMap.clear();
380
- this.batchedMeshIndex.clear();
381
- this.batchedMeshData.clear();
382
- this.meshDataBatchKey.clear();
465
+ // 2. Clear bucket/batch state
466
+ this.buckets.clear();
467
+ this.meshDataBucket = new Map();
383
468
  this.activeBucketKey.clear();
384
- this.bucketVertexBytes.clear();
385
469
  this.pendingBatchKeys.clear();
386
- // Destroy cached partial batches — their colorKeys are now stale
387
470
  for (const batch of this.partialBatchCache.values()) {
388
471
  batch.vertexBuffer.destroy();
389
472
  batch.indexBuffer.destroy();
390
- if (batch.uniformBuffer) {
473
+ if (batch.uniformBuffer)
391
474
  batch.uniformBuffer.destroy();
392
- }
393
475
  }
394
476
  this.partialBatchCache.clear();
395
477
  this.partialBatchCacheKeys.clear();
396
- // 5. Re-group ALL meshData by their CURRENT color.
397
- // meshData.color may have been mutated in-place since the mesh was
398
- // first bucketed, so the original bucket key is stale. Re-grouping
399
- // by current color ensures batches render with correct colors.
478
+ // 3. Re-group meshData by current color (fast)
400
479
  for (const meshData of allMeshData) {
401
480
  const baseKey = this.colorKey(meshData.color);
402
481
  const bucketKey = this.resolveActiveBucket(baseKey, meshData);
403
- let bucket = this.batchedMeshData.get(bucketKey);
482
+ let bucket = this.buckets.get(bucketKey);
404
483
  if (!bucket) {
405
- bucket = [];
406
- this.batchedMeshData.set(bucketKey, bucket);
484
+ bucket = { key: bucketKey, meshData: [], batchedMesh: null, vertexBytes: 0 };
485
+ this.buckets.set(bucketKey, bucket);
407
486
  }
408
- bucket.push(meshData);
409
- this.meshDataBatchKey.set(meshData, bucketKey);
487
+ bucket.meshData.push(meshData);
488
+ this.meshDataBucket.set(meshData, bucket);
410
489
  this.pendingBatchKeys.add(bucketKey);
411
490
  }
412
- // 6. One O(N) full rebuild from correctly-grouped data
413
- this.rebuildPendingBatches(device, pipeline);
491
+ // Build new batches into a temporary array so the old batchedMeshes
492
+ // (streaming fragments) keep rendering until the swap is complete.
493
+ const newBatches = [];
494
+ const pendingKeys = Array.from(this.pendingBatchKeys);
495
+ this.pendingBatchKeys.clear();
496
+ // --- Async: rebuild batches in time-sliced chunks ---
497
+ let keyIdx = 0;
498
+ const scene = this;
499
+ return new Promise((resolve) => {
500
+ function processChunk() {
501
+ const chunkStart = performance.now();
502
+ while (keyIdx < pendingKeys.length) {
503
+ const key = pendingKeys[keyIdx++];
504
+ const bucket = scene.buckets.get(key);
505
+ if (!bucket || bucket.meshData.length === 0) {
506
+ scene.buckets.delete(key);
507
+ continue;
508
+ }
509
+ const color = bucket.meshData[0].color;
510
+ const batchedMesh = scene.createBatchedMesh(bucket.meshData, color, device, pipeline, key);
511
+ bucket.batchedMesh = batchedMesh;
512
+ newBatches.push(batchedMesh);
513
+ // Check time budget — yield if exceeded
514
+ if (performance.now() - chunkStart >= budgetMs) {
515
+ setTimeout(processChunk, 0);
516
+ return;
517
+ }
518
+ }
519
+ // All batches built — atomic swap so renderer never sees an empty array
520
+ scene.batchedMeshes = newBatches;
521
+ // Destroy old fragment/batch GPU resources
522
+ for (const fragment of oldFragments) {
523
+ fragment.vertexBuffer.destroy();
524
+ fragment.indexBuffer.destroy();
525
+ if (fragment.uniformBuffer)
526
+ fragment.uniformBuffer.destroy();
527
+ }
528
+ for (const batch of oldBatches) {
529
+ if (!fragmentSet.has(batch)) {
530
+ batch.vertexBuffer.destroy();
531
+ batch.indexBuffer.destroy();
532
+ if (batch.uniformBuffer)
533
+ batch.uniformBuffer.destroy();
534
+ }
535
+ }
536
+ resolve();
537
+ }
538
+ // Start first chunk immediately (no setTimeout delay)
539
+ processChunk();
540
+ });
541
+ }
542
+ /**
543
+ * Release JS-side mesh geometry data (positions, normals, indices) after
544
+ * GPU batches have been built. This frees the ~1.9GB of typed arrays that
545
+ * duplicate data already resident in GPU vertex/index buffers.
546
+ *
547
+ * After calling this method:
548
+ * - Bounding boxes are precomputed and cached for all entities
549
+ * - meshDataMap and bucket meshData arrays are cleared (typed arrays become GC-eligible)
550
+ * - Color updates (updateMeshColors) are no longer available
551
+ * - Partial batch creation and color overlays are no longer available
552
+ * - CPU raycasting falls back to bounding-box-only (no triangle intersection)
553
+ * - Selection highlighting must use GPU picking instead of CPU mesh reconstruction
554
+ *
555
+ * Call this after finalizeStreaming() when all color updates have been applied.
556
+ */
557
+ releaseGeometryData() {
558
+ if (this.geometryReleased)
559
+ return;
560
+ // Guard: releasing while async batch work is in-flight would corrupt GPU state
561
+ if (this.pendingBatchKeys.size > 0 || this.streamingFragments.length > 0) {
562
+ console.warn(`[Scene] releaseGeometryData() called with ${this.pendingBatchKeys.size} pending batches ` +
563
+ `and ${this.streamingFragments.length} streaming fragments still in-flight. ` +
564
+ `Call finalizeStreaming()/rebuildPendingBatches() first.`);
565
+ return;
566
+ }
567
+ // 1. Precompute and cache ALL entity bounding boxes before releasing data
568
+ for (const [expressId, pieces] of this.meshDataMap) {
569
+ if (this.boundingBoxes.has(expressId))
570
+ continue;
571
+ let minX = Infinity, minY = Infinity, minZ = Infinity;
572
+ let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
573
+ for (const piece of pieces) {
574
+ const positions = piece.positions;
575
+ for (let i = 0; i < positions.length; i += 3) {
576
+ const x = positions[i];
577
+ const y = positions[i + 1];
578
+ const z = positions[i + 2];
579
+ if (x < minX)
580
+ minX = x;
581
+ if (y < minY)
582
+ minY = y;
583
+ if (z < minZ)
584
+ minZ = z;
585
+ if (x > maxX)
586
+ maxX = x;
587
+ if (y > maxY)
588
+ maxY = y;
589
+ if (z > maxZ)
590
+ maxZ = z;
591
+ }
592
+ }
593
+ this.boundingBoxes.set(expressId, {
594
+ min: { x: minX, y: minY, z: minZ },
595
+ max: { x: maxX, y: maxY, z: maxZ },
596
+ });
597
+ }
598
+ // 2. Clear the heavy data structures — typed arrays become GC-eligible
599
+ this.meshDataMap.clear();
600
+ // Clear meshData arrays in each bucket (typed arrays become GC-eligible)
601
+ // but keep the bucket shells so batchedMesh references remain valid
602
+ for (const bucket of this.buckets.values()) {
603
+ bucket.meshData = [];
604
+ }
605
+ this.meshDataBucket = new Map();
606
+ this.activeBucketKey.clear();
607
+ // 3. Clear partial batch cache (would need mesh data to rebuild)
608
+ for (const batch of this.partialBatchCache.values()) {
609
+ batch.vertexBuffer.destroy();
610
+ batch.indexBuffer.destroy();
611
+ if (batch.uniformBuffer)
612
+ batch.uniformBuffer.destroy();
613
+ }
614
+ this.partialBatchCache.clear();
615
+ this.partialBatchCacheKeys.clear();
616
+ this.geometryReleased = true;
617
+ console.log(`[Scene] Released JS geometry data. ${this.boundingBoxes.size} bounding boxes cached. ` +
618
+ `${this.batchedMeshes.length} GPU batches retained.`);
619
+ }
620
+ /**
621
+ * Whether JS geometry data has been released (GPU-resident mode).
622
+ */
623
+ isGeometryDataReleased() {
624
+ return this.geometryReleased;
414
625
  }
415
626
  /**
416
627
  * Update colors for existing meshes and rebuild affected batches
417
628
  * Call this when deferred color parsing completes
418
629
  *
419
- * OPTIMIZATION: Uses meshDataBatchKey reverse-map for O(1) batch removal
630
+ * OPTIMIZATION: Uses meshDataBucket reverse-map for O(1) batch lookup
420
631
  * instead of O(N) indexOf scan per mesh. Critical for bulk IDS validation updates.
421
632
  */
422
633
  updateMeshColors(updates, device, pipeline) {
423
634
  if (updates.size === 0)
424
635
  return;
636
+ if (this.geometryReleased) {
637
+ console.warn('[Scene] updateMeshColors called after geometry data was released — skipping.');
638
+ return;
639
+ }
425
640
  // Cache max buffer size if not yet set
426
641
  if (this.cachedMaxBufferSize === 0) {
427
642
  this.cachedMaxBufferSize = this.getMaxBufferSize(device);
@@ -435,8 +650,9 @@ export class Scene {
435
650
  continue;
436
651
  const newBaseKey = this.colorKey(newColor);
437
652
  for (const meshData of meshDataList) {
438
- // Use reverse-map for O(1) old bucket key lookup
439
- const oldBucketKey = this.meshDataBatchKey.get(meshData) ?? this.colorKey(meshData.color);
653
+ // Use reverse-map for O(1) old bucket lookup
654
+ const oldBucket = this.meshDataBucket.get(meshData);
655
+ const oldBucketKey = oldBucket?.key ?? this.colorKey(meshData.color);
440
656
  // Derive old color from bucket key, NOT meshData.color.
441
657
  // meshData.color may have been mutated in-place by external code
442
658
  // (applyColorUpdatesToMeshes), making it unreliable for change detection.
@@ -446,37 +662,37 @@ export class Scene {
446
662
  const newBucketKey = this.resolveActiveBucket(newBaseKey, meshData);
447
663
  affectedOldKeys.add(oldBucketKey);
448
664
  affectedNewKeys.add(newBucketKey);
449
- // Remove from old bucket data using swap-remove for O(1)
450
- const oldBatchData = this.batchedMeshData.get(oldBucketKey);
451
- if (oldBatchData) {
452
- const idx = oldBatchData.indexOf(meshData);
665
+ // Remove from old bucket data using indexOf (O(N) within one color bucket, typically <100 items)
666
+ if (oldBucket) {
667
+ const idx = oldBucket.meshData.indexOf(meshData);
453
668
  if (idx >= 0) {
454
- // Swap with last element and pop — O(1) instead of O(N) splice
455
- const last = oldBatchData.length - 1;
669
+ // Swap-remove for O(1)
670
+ const last = oldBucket.meshData.length - 1;
456
671
  if (idx !== last) {
457
- oldBatchData[idx] = oldBatchData[last];
672
+ oldBucket.meshData[idx] = oldBucket.meshData[last];
458
673
  }
459
- oldBatchData.pop();
674
+ oldBucket.meshData.pop();
460
675
  }
461
- if (oldBatchData.length === 0) {
462
- this.batchedMeshData.delete(oldBucketKey);
676
+ if (oldBucket.meshData.length === 0) {
677
+ this.buckets.delete(oldBucketKey);
463
678
  }
464
679
  }
465
680
  // Decrease old bucket size tracking
466
681
  const meshBytes = (meshData.positions.length / 3) * BATCH_CONSTANTS.BYTES_PER_VERTEX;
467
- const oldSize = this.bucketVertexBytes.get(oldBucketKey) ?? 0;
468
- this.bucketVertexBytes.set(oldBucketKey, Math.max(0, oldSize - meshBytes));
682
+ if (oldBucket) {
683
+ oldBucket.vertexBytes = Math.max(0, oldBucket.vertexBytes - meshBytes);
684
+ }
469
685
  // Update mesh color
470
686
  meshData.color = newColor;
471
687
  // Add to new bucket data (resolveActiveBucket already updated size tracking)
472
- let newBucket = this.batchedMeshData.get(newBucketKey);
688
+ let newBucket = this.buckets.get(newBucketKey);
473
689
  if (!newBucket) {
474
- newBucket = [];
475
- this.batchedMeshData.set(newBucketKey, newBucket);
690
+ newBucket = { key: newBucketKey, meshData: [], batchedMesh: null, vertexBytes: 0 };
691
+ this.buckets.set(newBucketKey, newBucket);
476
692
  }
477
- newBucket.push(meshData);
693
+ newBucket.meshData.push(meshData);
478
694
  // Update reverse mapping
479
- this.meshDataBatchKey.set(meshData, newBucketKey);
695
+ this.meshDataBucket.set(meshData, newBucket);
480
696
  }
481
697
  }
482
698
  }
@@ -497,7 +713,7 @@ export class Scene {
497
713
  * Create a new batched mesh from mesh data array.
498
714
  * @param bucketKey - Optional unique key for this batch. When omitted the
499
715
  * base color key is used (fine for overlay / partial batches that don't
500
- * participate in the main batchedMeshMap).
716
+ * participate in the main buckets map).
501
717
  */
502
718
  createBatchedMesh(meshDataArray, color, device, pipeline, bucketKey) {
503
719
  const merged = this.mergeGeometry(meshDataArray);
@@ -685,15 +901,21 @@ export class Scene {
685
901
  */
686
902
  resolveActiveBucket(baseColorKey, meshData) {
687
903
  let bucketKey = this.activeBucketKey.get(baseColorKey) ?? baseColorKey;
688
- const currentBytes = this.bucketVertexBytes.get(bucketKey) ?? 0;
904
+ const bucket = this.buckets.get(bucketKey);
905
+ const currentBytes = bucket?.vertexBytes ?? 0;
689
906
  const meshBytes = (meshData.positions.length / 3) * BATCH_CONSTANTS.BYTES_PER_VERTEX;
690
907
  if (currentBytes > 0 && currentBytes + meshBytes > this.cachedMaxBufferSize) {
691
908
  // Overflow — create a new sub-bucket
692
909
  bucketKey = `${baseColorKey}#${this.nextSplitId++}`;
693
910
  this.activeBucketKey.set(baseColorKey, bucketKey);
694
911
  }
695
- // Update size tracking
696
- this.bucketVertexBytes.set(bucketKey, (this.bucketVertexBytes.get(bucketKey) ?? 0) + meshBytes);
912
+ // Update size tracking on the bucket (create if needed)
913
+ let targetBucket = this.buckets.get(bucketKey);
914
+ if (!targetBucket) {
915
+ targetBucket = { key: bucketKey, meshData: [], batchedMesh: null, vertexBytes: 0 };
916
+ this.buckets.set(bucketKey, targetBucket);
917
+ }
918
+ targetBucket.vertexBytes += meshBytes;
697
919
  return bucketKey;
698
920
  }
699
921
  /**
@@ -717,6 +939,9 @@ export class Scene {
717
939
  * @returns BatchedMesh containing only visible elements, or undefined if no visible elements
718
940
  */
719
941
  getOrCreatePartialBatch(colorKey, visibleIds, device, pipeline) {
942
+ // Cannot create partial batches after geometry data has been released
943
+ if (this.geometryReleased)
944
+ return undefined;
720
945
  // Create cache key from colorKey + deterministic hash of all visible IDs
721
946
  // Using a proper hash over all IDs to avoid collisions when middle IDs differ
722
947
  const sortedIds = Array.from(visibleIds).sort((a, b) => a - b);
@@ -792,6 +1017,11 @@ export class Scene {
792
1017
  setColorOverrides(overrides, device, pipeline) {
793
1018
  // Destroy previous overlay batches
794
1019
  this.destroyOverrideBatches();
1020
+ if (this.geometryReleased) {
1021
+ console.warn('[Scene] setColorOverrides called after geometry data was released — skipping.');
1022
+ this.colorOverrides = null;
1023
+ return;
1024
+ }
795
1025
  if (overrides.size === 0) {
796
1026
  this.colorOverrides = null;
797
1027
  return;
@@ -904,23 +1134,47 @@ export class Scene {
904
1134
  this.meshes = [];
905
1135
  this.instancedMeshes = [];
906
1136
  this.batchedMeshes = [];
907
- this.batchedMeshMap.clear();
908
- this.batchedMeshData.clear();
909
- this.batchedMeshIndex.clear();
1137
+ this.buckets.clear();
1138
+ this.meshDataBucket = new Map();
910
1139
  this.meshDataMap.clear();
911
- this.meshDataBatchKey.clear();
912
1140
  this.boundingBoxes.clear();
913
1141
  this.activeBucketKey.clear();
914
- this.bucketVertexBytes.clear();
915
1142
  this.cachedMaxBufferSize = 0;
916
1143
  this.pendingBatchKeys.clear();
917
1144
  this.partialBatchCache.clear();
918
1145
  this.partialBatchCacheKeys.clear();
1146
+ this.meshQueue = [];
1147
+ this.geometryReleased = false;
919
1148
  }
920
1149
  /**
921
1150
  * Calculate bounding box from actual mesh vertex data
922
1151
  */
923
1152
  getBounds() {
1153
+ // When geometry data is released, compute bounds from cached bounding boxes
1154
+ if (this.geometryReleased) {
1155
+ if (this.boundingBoxes.size === 0)
1156
+ return null;
1157
+ let minX = Infinity, minY = Infinity, minZ = Infinity;
1158
+ let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
1159
+ for (const bbox of this.boundingBoxes.values()) {
1160
+ if (bbox.min.x < minX)
1161
+ minX = bbox.min.x;
1162
+ if (bbox.min.y < minY)
1163
+ minY = bbox.min.y;
1164
+ if (bbox.min.z < minZ)
1165
+ minZ = bbox.min.z;
1166
+ if (bbox.max.x > maxX)
1167
+ maxX = bbox.max.x;
1168
+ if (bbox.max.y > maxY)
1169
+ maxY = bbox.max.y;
1170
+ if (bbox.max.z > maxZ)
1171
+ maxZ = bbox.max.z;
1172
+ }
1173
+ return {
1174
+ min: { x: minX, y: minY, z: minZ },
1175
+ max: { x: maxX, y: maxY, z: maxZ },
1176
+ };
1177
+ }
924
1178
  if (this.meshDataMap.size === 0)
925
1179
  return null;
926
1180
  let minX = Infinity, minY = Infinity, minZ = Infinity;
@@ -960,9 +1214,13 @@ export class Scene {
960
1214
  };
961
1215
  }
962
1216
  /**
963
- * Get all expressIds that have mesh data (for CPU raycasting)
1217
+ * Get all expressIds that have mesh data (for CPU raycasting).
1218
+ * After geometry release, returns expressIds from the cached bounding boxes.
964
1219
  */
965
1220
  getAllMeshDataExpressIds() {
1221
+ if (this.geometryReleased) {
1222
+ return Array.from(this.boundingBoxes.keys());
1223
+ }
966
1224
  return Array.from(this.meshDataMap.keys());
967
1225
  }
968
1226
  /**
@@ -1010,31 +1268,111 @@ export class Scene {
1010
1268
  return bbox;
1011
1269
  }
1012
1270
  /**
1013
- * Ray-box intersection test (slab method)
1271
+ * Ray-box intersection test (slab method).
1272
+ * Handles zero ray direction components (axis-aligned rays) safely.
1014
1273
  */
1015
1274
  rayIntersectsBox(rayOrigin, rayDirInv, // 1/rayDir for efficiency
1016
1275
  rayDirSign, box) {
1017
1276
  const bounds = [box.min, box.max];
1018
- let tmin = (bounds[rayDirSign[0]].x - rayOrigin.x) * rayDirInv.x;
1019
- let tmax = (bounds[1 - rayDirSign[0]].x - rayOrigin.x) * rayDirInv.x;
1020
- const tymin = (bounds[rayDirSign[1]].y - rayOrigin.y) * rayDirInv.y;
1021
- const tymax = (bounds[1 - rayDirSign[1]].y - rayOrigin.y) * rayDirInv.y;
1022
- if (tmin > tymax || tymin > tmax)
1023
- return false;
1024
- if (tymin > tmin)
1025
- tmin = tymin;
1026
- if (tymax < tmax)
1027
- tmax = tymax;
1028
- const tzmin = (bounds[rayDirSign[2]].z - rayOrigin.z) * rayDirInv.z;
1029
- const tzmax = (bounds[1 - rayDirSign[2]].z - rayOrigin.z) * rayDirInv.z;
1030
- if (tmin > tzmax || tzmin > tmax)
1031
- return false;
1032
- if (tzmin > tmin)
1033
- tmin = tzmin;
1034
- if (tzmax < tmax)
1035
- tmax = tzmax;
1277
+ let tmin = -Infinity;
1278
+ let tmax = Infinity;
1279
+ // X axis
1280
+ if (!isFinite(rayDirInv.x)) {
1281
+ if (rayOrigin.x < box.min.x || rayOrigin.x > box.max.x)
1282
+ return false;
1283
+ }
1284
+ else {
1285
+ tmin = (bounds[rayDirSign[0]].x - rayOrigin.x) * rayDirInv.x;
1286
+ tmax = (bounds[1 - rayDirSign[0]].x - rayOrigin.x) * rayDirInv.x;
1287
+ }
1288
+ // Y axis
1289
+ if (!isFinite(rayDirInv.y)) {
1290
+ if (rayOrigin.y < box.min.y || rayOrigin.y > box.max.y)
1291
+ return false;
1292
+ }
1293
+ else {
1294
+ const tymin = (bounds[rayDirSign[1]].y - rayOrigin.y) * rayDirInv.y;
1295
+ const tymax = (bounds[1 - rayDirSign[1]].y - rayOrigin.y) * rayDirInv.y;
1296
+ if (tmin > tymax || tymin > tmax)
1297
+ return false;
1298
+ if (tymin > tmin)
1299
+ tmin = tymin;
1300
+ if (tymax < tmax)
1301
+ tmax = tymax;
1302
+ }
1303
+ // Z axis
1304
+ if (!isFinite(rayDirInv.z)) {
1305
+ if (rayOrigin.z < box.min.z || rayOrigin.z > box.max.z)
1306
+ return false;
1307
+ }
1308
+ else {
1309
+ const tzmin = (bounds[rayDirSign[2]].z - rayOrigin.z) * rayDirInv.z;
1310
+ const tzmax = (bounds[1 - rayDirSign[2]].z - rayOrigin.z) * rayDirInv.z;
1311
+ if (tmin > tzmax || tzmin > tmax)
1312
+ return false;
1313
+ if (tzmin > tmin)
1314
+ tmin = tzmin;
1315
+ if (tzmax < tmax)
1316
+ tmax = tzmax;
1317
+ }
1036
1318
  return tmax >= 0;
1037
1319
  }
1320
+ /**
1321
+ * Ray-box intersection returning entry distance (tNear).
1322
+ * Returns null if no intersection, otherwise the distance along the ray
1323
+ * to the entry point (clamped to 0 if the ray originates inside the box).
1324
+ * Handles zero ray direction components (axis-aligned rays) safely.
1325
+ */
1326
+ rayBoxDistance(rayOrigin, rayDirInv, rayDirSign, box) {
1327
+ const bounds = [box.min, box.max];
1328
+ let tmin = -Infinity;
1329
+ let tmax = Infinity;
1330
+ // X axis
1331
+ if (!isFinite(rayDirInv.x)) {
1332
+ // Ray parallel to X: miss if origin outside X slab
1333
+ if (rayOrigin.x < box.min.x || rayOrigin.x > box.max.x)
1334
+ return null;
1335
+ }
1336
+ else {
1337
+ const t1 = (bounds[rayDirSign[0]].x - rayOrigin.x) * rayDirInv.x;
1338
+ const t2 = (bounds[1 - rayDirSign[0]].x - rayOrigin.x) * rayDirInv.x;
1339
+ tmin = t1;
1340
+ tmax = t2;
1341
+ }
1342
+ // Y axis
1343
+ if (!isFinite(rayDirInv.y)) {
1344
+ if (rayOrigin.y < box.min.y || rayOrigin.y > box.max.y)
1345
+ return null;
1346
+ }
1347
+ else {
1348
+ const tymin = (bounds[rayDirSign[1]].y - rayOrigin.y) * rayDirInv.y;
1349
+ const tymax = (bounds[1 - rayDirSign[1]].y - rayOrigin.y) * rayDirInv.y;
1350
+ if (tmin > tymax || tymin > tmax)
1351
+ return null;
1352
+ if (tymin > tmin)
1353
+ tmin = tymin;
1354
+ if (tymax < tmax)
1355
+ tmax = tymax;
1356
+ }
1357
+ // Z axis
1358
+ if (!isFinite(rayDirInv.z)) {
1359
+ if (rayOrigin.z < box.min.z || rayOrigin.z > box.max.z)
1360
+ return null;
1361
+ }
1362
+ else {
1363
+ const tzmin = (bounds[rayDirSign[2]].z - rayOrigin.z) * rayDirInv.z;
1364
+ const tzmax = (bounds[1 - rayDirSign[2]].z - rayOrigin.z) * rayDirInv.z;
1365
+ if (tmin > tzmax || tzmin > tmax)
1366
+ return null;
1367
+ if (tzmin > tmin)
1368
+ tmin = tzmin;
1369
+ if (tzmax < tmax)
1370
+ tmax = tzmax;
1371
+ }
1372
+ if (tmax < 0)
1373
+ return null;
1374
+ return tmin < 0 ? 0 : tmin;
1375
+ }
1038
1376
  /**
1039
1377
  * Möller–Trumbore ray-triangle intersection
1040
1378
  * Returns distance to intersection or null if no hit
@@ -1080,6 +1418,23 @@ export class Scene {
1080
1418
  ];
1081
1419
  let closestHit = null;
1082
1420
  let closestDistance = Infinity;
1421
+ // When geometry data has been released, use bounding-box-only raycast.
1422
+ // This is less accurate but uses only the precomputed bounding boxes.
1423
+ // For precise picking, callers should use GPU picking instead.
1424
+ if (this.geometryReleased) {
1425
+ for (const [expressId, bbox] of this.boundingBoxes) {
1426
+ if (hiddenIds?.has(expressId))
1427
+ continue;
1428
+ if (isolatedIds !== null && isolatedIds !== undefined && !isolatedIds.has(expressId))
1429
+ continue;
1430
+ const tNear = this.rayBoxDistance(rayOrigin, rayDirInv, rayDirSign, bbox);
1431
+ if (tNear !== null && tNear < closestDistance) {
1432
+ closestDistance = tNear;
1433
+ closestHit = { expressId, distance: tNear };
1434
+ }
1435
+ }
1436
+ return closestHit;
1437
+ }
1083
1438
  // First pass: filter by bounding box (fast)
1084
1439
  const candidates = [];
1085
1440
  for (const expressId of this.meshDataMap.keys()) {