@ifc-lite/renderer 1.41.1 → 1.44.0

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 (120) hide show
  1. package/dist/camera-animation.d.ts +20 -32
  2. package/dist/camera-animation.d.ts.map +1 -1
  3. package/dist/camera-animation.js +71 -358
  4. package/dist/camera-animation.js.map +1 -1
  5. package/dist/camera-controls.d.ts +11 -51
  6. package/dist/camera-controls.d.ts.map +1 -1
  7. package/dist/camera-controls.js +140 -12
  8. package/dist/camera-controls.js.map +1 -1
  9. package/dist/camera-first-person.d.ts +52 -0
  10. package/dist/camera-first-person.d.ts.map +1 -0
  11. package/dist/camera-first-person.js +119 -0
  12. package/dist/camera-first-person.js.map +1 -0
  13. package/dist/camera-fit-policy.d.ts.map +1 -1
  14. package/dist/camera-fit-policy.js +18 -7
  15. package/dist/camera-fit-policy.js.map +1 -1
  16. package/dist/camera-framing.d.ts +86 -0
  17. package/dist/camera-framing.d.ts.map +1 -0
  18. package/dist/camera-framing.js +230 -0
  19. package/dist/camera-framing.js.map +1 -0
  20. package/dist/camera-guards.d.ts +148 -0
  21. package/dist/camera-guards.d.ts.map +1 -0
  22. package/dist/camera-guards.js +150 -0
  23. package/dist/camera-guards.js.map +1 -0
  24. package/dist/camera-matrices.d.ts +14 -0
  25. package/dist/camera-matrices.d.ts.map +1 -0
  26. package/dist/camera-matrices.js +146 -0
  27. package/dist/camera-matrices.js.map +1 -0
  28. package/dist/camera-preset-view.d.ts +56 -0
  29. package/dist/camera-preset-view.d.ts.map +1 -0
  30. package/dist/camera-preset-view.js +180 -0
  31. package/dist/camera-preset-view.js.map +1 -0
  32. package/dist/camera-projection.d.ts +1 -1
  33. package/dist/camera-projection.d.ts.map +1 -1
  34. package/dist/camera-projection.js +83 -30
  35. package/dist/camera-projection.js.map +1 -1
  36. package/dist/camera-state.d.ts +62 -0
  37. package/dist/camera-state.d.ts.map +1 -0
  38. package/dist/camera-state.js +5 -0
  39. package/dist/camera-state.js.map +1 -0
  40. package/dist/camera.d.ts +22 -11
  41. package/dist/camera.d.ts.map +1 -1
  42. package/dist/camera.js +110 -85
  43. package/dist/camera.js.map +1 -1
  44. package/dist/deviation/deviation-pipeline.d.ts.map +1 -1
  45. package/dist/deviation/deviation-pipeline.js +21 -4
  46. package/dist/deviation/deviation-pipeline.js.map +1 -1
  47. package/dist/environment.d.ts.map +1 -1
  48. package/dist/environment.js +23 -1
  49. package/dist/environment.js.map +1 -1
  50. package/dist/index.d.ts +333 -43
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +753 -704
  53. package/dist/index.js.map +1 -1
  54. package/dist/math.d.ts +59 -2
  55. package/dist/math.d.ts.map +1 -1
  56. package/dist/math.js +173 -23
  57. package/dist/math.js.map +1 -1
  58. package/dist/model-bounds-tracker.d.ts +96 -0
  59. package/dist/model-bounds-tracker.d.ts.map +1 -0
  60. package/dist/model-bounds-tracker.js +147 -0
  61. package/dist/model-bounds-tracker.js.map +1 -0
  62. package/dist/pointcloud/point-cloud-node.d.ts.map +1 -1
  63. package/dist/pointcloud/point-cloud-node.js +24 -4
  64. package/dist/pointcloud/point-cloud-node.js.map +1 -1
  65. package/dist/render-degradation.d.ts +77 -0
  66. package/dist/render-degradation.d.ts.map +1 -0
  67. package/dist/render-degradation.js +52 -0
  68. package/dist/render-degradation.js.map +1 -0
  69. package/dist/render-section-draw.d.ts +51 -0
  70. package/dist/render-section-draw.d.ts.map +1 -0
  71. package/dist/render-section-draw.js +66 -0
  72. package/dist/render-section-draw.js.map +1 -0
  73. package/dist/render-section-plane.d.ts +128 -0
  74. package/dist/render-section-plane.d.ts.map +1 -0
  75. package/dist/render-section-plane.js +298 -0
  76. package/dist/render-section-plane.js.map +1 -0
  77. package/dist/renderer-overlays.d.ts +144 -0
  78. package/dist/renderer-overlays.d.ts.map +1 -0
  79. package/dist/renderer-overlays.js +257 -0
  80. package/dist/renderer-overlays.js.map +1 -0
  81. package/dist/renderer-symbolic-overlays.d.ts +52 -0
  82. package/dist/renderer-symbolic-overlays.d.ts.map +1 -0
  83. package/dist/renderer-symbolic-overlays.js +115 -0
  84. package/dist/renderer-symbolic-overlays.js.map +1 -0
  85. package/dist/scene-geometry.d.ts +32 -0
  86. package/dist/scene-geometry.d.ts.map +1 -1
  87. package/dist/scene-geometry.js +66 -0
  88. package/dist/scene-geometry.js.map +1 -1
  89. package/dist/scene.d.ts +86 -1
  90. package/dist/scene.d.ts.map +1 -1
  91. package/dist/scene.js +391 -201
  92. package/dist/scene.js.map +1 -1
  93. package/dist/section-2d-lift.d.ts +105 -0
  94. package/dist/section-2d-lift.d.ts.map +1 -0
  95. package/dist/section-2d-lift.js +162 -0
  96. package/dist/section-2d-lift.js.map +1 -0
  97. package/dist/section-2d-line-buffer.d.ts +58 -0
  98. package/dist/section-2d-line-buffer.d.ts.map +1 -0
  99. package/dist/section-2d-line-buffer.js +109 -0
  100. package/dist/section-2d-line-buffer.js.map +1 -0
  101. package/dist/section-2d-overlay.d.ts +43 -75
  102. package/dist/section-2d-overlay.d.ts.map +1 -1
  103. package/dist/section-2d-overlay.js +189 -559
  104. package/dist/section-2d-overlay.js.map +1 -1
  105. package/dist/section-plane-basis.d.ts +22 -3
  106. package/dist/section-plane-basis.d.ts.map +1 -1
  107. package/dist/section-plane-basis.js +51 -10
  108. package/dist/section-plane-basis.js.map +1 -1
  109. package/dist/section-plane.d.ts.map +1 -1
  110. package/dist/section-plane.js +10 -1
  111. package/dist/section-plane.js.map +1 -1
  112. package/dist/shaders/section-2d-overlay.wgsl.d.ts +102 -0
  113. package/dist/shaders/section-2d-overlay.wgsl.d.ts.map +1 -0
  114. package/dist/shaders/section-2d-overlay.wgsl.js +281 -0
  115. package/dist/shaders/section-2d-overlay.wgsl.js.map +1 -0
  116. package/dist/visual-enhancement.d.ts +33 -0
  117. package/dist/visual-enhancement.d.ts.map +1 -0
  118. package/dist/visual-enhancement.js +46 -0
  119. package/dist/visual-enhancement.js.map +1 -0
  120. package/package.json +4 -3
package/dist/scene.js CHANGED
@@ -4,7 +4,7 @@
4
4
  import { BATCH_CONSTANTS } from './constants.js';
5
5
  import { prepareRayDirInv, raycastBoundingBoxes, raycastTriangles, rayIntersectsBox, } from './scene-raycaster.js';
6
6
  import { selectBoundingBoxesInRect } from './scene-rect-select.js';
7
- import { mergeGeometry, splitMeshDataForBufferLimit, colorSaltByte, packEntityLane } from './scene-geometry.js';
7
+ import { mergeGeometry, splitMeshDataForBufferLimit, colorSaltByte, packEntityLane, worldAabbFromPieces } from './scene-geometry.js';
8
8
  import { sumResidentGpuBytes } from './render-stats.js';
9
9
  import { simplifyIndicesByClustering, lodCellSizeForBounds, LOD_MIN_TRIANGLES } from './lod-simplify.js';
10
10
  import { quantizeInterleaved } from './quantize.js';
@@ -36,10 +36,23 @@ export class Scene {
36
36
  * Refcounted: entries die when the last referencing mesh is removed / on clear(). */
37
37
  sharedTextures = new Map();
38
38
  texturedDevice; // #961: cached for textured-mesh re-upload on translate
39
- instancedTemplates = []; // GPU-instancing: unique templates + per-occurrence buffers (fed by addInstancedShard)
39
+ /** GPU-instancing: unique templates + per-occurrence buffers (fed by
40
+ * addInstancedShard). SLOT-STABLE and therefore SPARSE: a per-model removal
41
+ * (`removeInstancedTemplatesForModel`) leaves `undefined` holes instead of
42
+ * splicing, because `InstancedOccurrence.templateIndex` holds the slot by
43
+ * value and a splice would silently repoint every later occurrence at the
44
+ * wrong template. New templates always append at `length`; freed slots are
45
+ * never recycled. Iterate `getInstancedTemplates()` (compacted, cached) for
46
+ * the draw/pick/accounting walks — never this array directly. */
47
+ instancedTemplates = [];
48
+ /** Compacted live view of `instancedTemplates`, rebuilt on mutation. */
49
+ liveInstancedTemplates = [];
40
50
  instancedVisible = true; // GPU-instancing: hidden in Types view mode (instanced geometry is class-0 occurrences)
41
51
  instancedEntityMap = new Map(); // express_id -> occurrences, for per-instance selection/overlay patching
42
- instancedTemplateCpu = []; // compact CPU geometry per template (index-aligned with instancedTemplates) for CPU consumers
52
+ /** Compact CPU geometry per template, slot-aligned with `instancedTemplates`
53
+ * (so equally sparse) for CPU consumers. Also emptied wholesale on geometry
54
+ * release — every reader already tolerates a missing entry. */
55
+ instancedTemplateCpu = [];
43
56
  instancedDevice; // cached for per-instance flag/colour writeBuffer updates
44
57
  instancedSelected = new Set(); // currently flag-selected instanced express_ids
45
58
  instancedHidden = new Set(); // currently hidden instanced express_ids (hide/isolate)
@@ -1876,94 +1889,156 @@ export class Scene {
1876
1889
  // synchronously, so settle-sensitive consumers need this flag until the
1877
1890
  // time-sliced rebuild swaps the new batch array in.
1878
1891
  this.finalizeInProgress = true;
1879
- // --- Synchronous preamble (fast O(N) bookkeeping) ---
1892
+ const scene = this;
1880
1893
  const oldFragments = this.streamingFragments;
1881
1894
  const oldBatches = this.batchedMeshes;
1882
1895
  const fragmentSet = new Set(oldFragments);
1883
- this.streamingFragments = [];
1884
- // 1. Collect ALL accumulated meshData (cold buckets carried as sealed
1885
- // shells see the sync finalize for the rationale)
1886
- const allMeshData = [];
1887
- const carriedCold = [];
1888
- for (const [key, bucket] of this.buckets) {
1889
- if (this.coldBuckets.has(key) && bucket.meshData.length === 0 && bucket.batchedMesh) {
1890
- carriedCold.push([key, bucket]);
1891
- continue;
1896
+ const oldBatchSet = new Set(oldBatches);
1897
+ // The chunked rebuild spans multiple event-loop turns via setTimeout, so a
1898
+ // throw inside a LATER chunk is a distinct macrotask — it does NOT reject
1899
+ // the promise below just because that code sits inside its executor (only
1900
+ // a SYNCHRONOUS throw during the executor's own call frame does that per
1901
+ // spec). Every entry point that can fail — the synchronous preamble AND
1902
+ // each chunked continuation must therefore run under its own try/catch
1903
+ // that explicitly calls `reject`, mirroring finalizeStreamingInner's
1904
+ // try/finally contract: restore oldFragments/oldBatches, free only what
1905
+ // this attempt created, defer dropAllPartialCaches() until success, and
1906
+ // always clear finalizeInProgress.
1907
+ return new Promise((resolve, reject) => {
1908
+ const newBatches = [];
1909
+ // Every batch this attempt creates, paired with the bucket that now owns
1910
+ // it and the value it displaced. `newBatches` alone is not enough to roll
1911
+ // back: processChunk publishes each batch into `bucket.batchedMesh`, so
1912
+ // freeing it without repairing the owner leaves the bucket map pointing
1913
+ // at destroyed GPU resources (use-after-free on the next bucket-driven
1914
+ // access). `previous` is null for the freshly built buckets and, for a
1915
+ // carried COLD bucket a re-grouped meshData landed in, the shell that the
1916
+ // restored `batchedMeshes` still holds — which must be put back, not
1917
+ // nulled.
1918
+ const createdOwned = [];
1919
+ let carriedCold = [];
1920
+ let pendingKeys = [];
1921
+ let keyIdx = 0;
1922
+ function rollback() {
1923
+ // Free ONLY what this attempt created — carried cold shells and
1924
+ // anything already live before the rebuild must be left alone (they
1925
+ // are what the restored arrays point back at). Iterating the owned
1926
+ // pairs rather than `newBatches` also skips the carried cold shells
1927
+ // appended just before the swap, which this attempt did not create.
1928
+ for (const { bucket, previous, batch } of createdOwned) {
1929
+ // Repair the owner BEFORE the free, so no bucket is ever observable
1930
+ // holding a destroyed batch.
1931
+ if (bucket.batchedMesh === batch)
1932
+ bucket.batchedMesh = previous;
1933
+ if (!oldBatchSet.has(batch) && !fragmentSet.has(batch)) {
1934
+ destroyGpuResources(batch);
1935
+ }
1936
+ }
1937
+ scene.streamingFragments = oldFragments;
1938
+ scene.batchedMeshes = oldBatches;
1939
+ scene.finalizeInProgress = false;
1892
1940
  }
1893
- for (const md of bucket.meshData)
1894
- allMeshData.push(md);
1895
- }
1896
- // 2. Clear bucket/batch state
1897
- this.buckets.clear();
1898
- this.meshDataBucket = new Map();
1899
- this.activeBucketKey.clear();
1900
- this.lastDrawnFrame.clear();
1901
- this.residencyRestoreQueue.clear();
1902
- this.pendingBatchKeys.clear();
1903
- this.dropAllPartialCaches();
1904
- // Re-seat the carried cold shells in the fresh bucket map.
1905
- for (const [key, bucket] of carriedCold)
1906
- this.buckets.set(key, bucket);
1907
- // 3. Re-group meshData by current color (and grid cell) — fast
1908
- for (const meshData of allMeshData) {
1909
- const baseKey = this.bucketBaseKey(meshData);
1910
- const bucketKey = this.resolveActiveBucket(baseKey, meshData);
1911
- let bucket = this.buckets.get(bucketKey);
1912
- if (!bucket) {
1913
- bucket = { key: bucketKey, meshData: [], batchedMesh: null, vertexBytes: 0 };
1914
- this.buckets.set(bucketKey, bucket);
1915
- }
1916
- bucket.meshData.push(meshData);
1917
- this.meshDataBucket.set(meshData, bucket);
1918
- this.pendingBatchKeys.add(bucketKey);
1919
- }
1920
- // Build new batches into a temporary array so the old batchedMeshes
1921
- // (streaming fragments) keep rendering until the swap is complete.
1922
- const newBatches = [];
1923
- const pendingKeys = Array.from(this.pendingBatchKeys);
1924
- this.pendingBatchKeys.clear();
1925
- // --- Async: rebuild batches in time-sliced chunks ---
1926
- let keyIdx = 0;
1927
- const scene = this;
1928
- return new Promise((resolve) => {
1929
1941
  function processChunk() {
1930
- const chunkStart = performance.now();
1931
- while (keyIdx < pendingKeys.length) {
1932
- const key = pendingKeys[keyIdx++];
1933
- const bucket = scene.buckets.get(key);
1934
- if (!bucket || bucket.meshData.length === 0) {
1935
- scene.buckets.delete(key);
1936
- continue;
1942
+ try {
1943
+ const chunkStart = performance.now();
1944
+ while (keyIdx < pendingKeys.length) {
1945
+ const key = pendingKeys[keyIdx++];
1946
+ const bucket = scene.buckets.get(key);
1947
+ if (!bucket || bucket.meshData.length === 0) {
1948
+ scene.buckets.delete(key);
1949
+ continue;
1950
+ }
1951
+ const color = bucket.meshData[0].color;
1952
+ const previous = bucket.batchedMesh;
1953
+ const batchedMesh = scene.createBatchedMesh(bucket.meshData, color, device, pipeline, key);
1954
+ bucket.batchedMesh = batchedMesh;
1955
+ createdOwned.push({ bucket, previous, batch: batchedMesh });
1956
+ newBatches.push(batchedMesh);
1957
+ // Check time budget — yield if exceeded
1958
+ if (performance.now() - chunkStart >= budgetMs) {
1959
+ setTimeout(processChunk, 0);
1960
+ return;
1961
+ }
1962
+ }
1963
+ // Carried cold shells stay drawable-when-restored: keep them in the
1964
+ // flat array (their buffers are already destroyed; the draw loop
1965
+ // skips gpuResident === false and the restore path revives them).
1966
+ for (const [, bucket] of carriedCold) {
1967
+ if (bucket.batchedMesh)
1968
+ newBatches.push(bucket.batchedMesh);
1937
1969
  }
1938
- const color = bucket.meshData[0].color;
1939
- const batchedMesh = scene.createBatchedMesh(bucket.meshData, color, device, pipeline, key);
1940
- bucket.batchedMesh = batchedMesh;
1941
- newBatches.push(batchedMesh);
1942
- // Check time budget yield if exceeded
1943
- if (performance.now() - chunkStart >= budgetMs) {
1944
- setTimeout(processChunk, 0);
1945
- return;
1970
+ // All batches built — atomic swap so renderer never sees an empty array
1971
+ scene.batchedMeshes = newBatches;
1972
+ // Cached partial (filtered-visibility) batches are keyed by their
1973
+ // SOURCE batch, so they only go stale once the replacement batches
1974
+ // are livedropping them any earlier destroys GPU resources a
1975
+ // mid-rebuild failure could never get back (same rationale as
1976
+ // finalizeStreamingInner).
1977
+ scene.dropAllPartialCaches();
1978
+ // Destroy old fragment/batch GPU resources
1979
+ for (const fragment of oldFragments)
1980
+ destroyGpuResources(fragment);
1981
+ for (const batch of oldBatches) {
1982
+ if (!fragmentSet.has(batch))
1983
+ destroyGpuResources(batch);
1946
1984
  }
1985
+ scene.finalizeInProgress = false;
1986
+ resolve();
1947
1987
  }
1948
- // Carried cold shells stay drawable-when-restored: keep them in the
1949
- // flat array (their buffers are already destroyed; the draw loop
1950
- // skips gpuResident === false and the restore path revives them).
1951
- for (const [, bucket] of carriedCold) {
1952
- if (bucket.batchedMesh)
1953
- newBatches.push(bucket.batchedMesh);
1988
+ catch (err) {
1989
+ rollback();
1990
+ reject(err);
1954
1991
  }
1955
- // All batches built — atomic swap so renderer never sees an empty array
1956
- scene.batchedMeshes = newBatches;
1957
- // Destroy old fragment/batch GPU resources
1958
- for (const fragment of oldFragments)
1959
- destroyGpuResources(fragment);
1960
- for (const batch of oldBatches) {
1961
- if (!fragmentSet.has(batch))
1962
- destroyGpuResources(batch);
1992
+ }
1993
+ try {
1994
+ // --- Synchronous preamble (fast O(N) bookkeeping) ---
1995
+ scene.streamingFragments = [];
1996
+ // 1. Collect ALL accumulated meshData (cold buckets carried as sealed
1997
+ // shells see the sync finalize for the rationale)
1998
+ const allMeshData = [];
1999
+ for (const [key, bucket] of scene.buckets) {
2000
+ if (scene.coldBuckets.has(key) && bucket.meshData.length === 0 && bucket.batchedMesh) {
2001
+ carriedCold.push([key, bucket]);
2002
+ continue;
2003
+ }
2004
+ for (const md of bucket.meshData)
2005
+ allMeshData.push(md);
1963
2006
  }
1964
- scene.finalizeInProgress = false;
1965
- resolve();
2007
+ // 2. Clear bucket/batch state. dropAllPartialCaches() is deliberately
2008
+ // NOT called here — see the success path above.
2009
+ scene.buckets.clear();
2010
+ scene.meshDataBucket = new Map();
2011
+ scene.activeBucketKey.clear();
2012
+ scene.lastDrawnFrame.clear();
2013
+ scene.residencyRestoreQueue.clear();
2014
+ scene.pendingBatchKeys.clear();
2015
+ // Re-seat the carried cold shells in the fresh bucket map.
2016
+ for (const [key, bucket] of carriedCold)
2017
+ scene.buckets.set(key, bucket);
2018
+ // 3. Re-group meshData by current color (and grid cell) — fast
2019
+ for (const meshData of allMeshData) {
2020
+ const baseKey = scene.bucketBaseKey(meshData);
2021
+ const bucketKey = scene.resolveActiveBucket(baseKey, meshData);
2022
+ let bucket = scene.buckets.get(bucketKey);
2023
+ if (!bucket) {
2024
+ bucket = { key: bucketKey, meshData: [], batchedMesh: null, vertexBytes: 0 };
2025
+ scene.buckets.set(bucketKey, bucket);
2026
+ }
2027
+ bucket.meshData.push(meshData);
2028
+ scene.meshDataBucket.set(meshData, bucket);
2029
+ scene.pendingBatchKeys.add(bucketKey);
2030
+ }
2031
+ // Build new batches into a temporary array so the old batchedMeshes
2032
+ // (streaming fragments) keep rendering until the swap is complete.
2033
+ pendingKeys = Array.from(scene.pendingBatchKeys);
2034
+ scene.pendingBatchKeys.clear();
2035
+ }
2036
+ catch (err) {
2037
+ rollback();
2038
+ reject(err);
2039
+ return;
1966
2040
  }
2041
+ // --- Async: rebuild batches in time-sliced chunks ---
1967
2042
  // Start first chunk immediately (no setTimeout delay)
1968
2043
  processChunk();
1969
2044
  });
@@ -1974,40 +2049,17 @@ export class Scene {
1974
2049
  return;
1975
2050
  }
1976
2051
  // Preserve lightweight per-entity bounds so large-model picking and
1977
- // selection can continue to work after we discard CPU mesh arrays.
2052
+ // selection can continue to work after we discard CPU mesh arrays. An
2053
+ // entity with no usable vertex gets NO entry: after release, the keys of
2054
+ // `boundingBoxes` become the authoritative id set (`getAllMeshDataExpressIds`),
2055
+ // so caching the inverted-empty sentinel here would publish a geometry-less
2056
+ // entity to every CPU consumer with a garbage box (#2480).
1978
2057
  for (const [expressId, pieces] of this.meshDataMap) {
1979
2058
  if (this.boundingBoxes.has(expressId))
1980
2059
  continue;
1981
- let minX = Infinity, minY = Infinity, minZ = Infinity;
1982
- let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
1983
- for (const piece of pieces) {
1984
- const positions = piece.positions;
1985
- // world = origin + position (per-element local frame); bake WORLD bbox.
1986
- const ox = piece.origin ? piece.origin[0] : 0;
1987
- const oy = piece.origin ? piece.origin[1] : 0;
1988
- const oz = piece.origin ? piece.origin[2] : 0;
1989
- for (let i = 0; i < positions.length; i += 3) {
1990
- const x = positions[i] + ox;
1991
- const y = positions[i + 1] + oy;
1992
- const z = positions[i + 2] + oz;
1993
- if (x < minX)
1994
- minX = x;
1995
- if (y < minY)
1996
- minY = y;
1997
- if (z < minZ)
1998
- minZ = z;
1999
- if (x > maxX)
2000
- maxX = x;
2001
- if (y > maxY)
2002
- maxY = y;
2003
- if (z > maxZ)
2004
- maxZ = z;
2005
- }
2006
- }
2007
- this.boundingBoxes.set(expressId, {
2008
- min: { x: minX, y: minY, z: minZ },
2009
- max: { x: maxX, y: maxY, z: maxZ },
2010
- });
2060
+ const bbox = worldAabbFromPieces(pieces);
2061
+ if (bbox)
2062
+ this.boundingBoxes.set(expressId, bbox);
2011
2063
  }
2012
2064
  this.streamingFragments = [];
2013
2065
  this.buckets.clear();
@@ -2051,40 +2103,15 @@ export class Scene {
2051
2103
  `Call finalizeStreaming()/rebuildPendingBatches() first.`);
2052
2104
  return;
2053
2105
  }
2054
- // 1. Precompute and cache ALL entity bounding boxes before releasing data
2106
+ // 1. Precompute and cache ALL entity bounding boxes before releasing data.
2107
+ // Same rule as `finishEphemeralStreaming`: an entity with no usable vertex
2108
+ // gets no entry rather than the inverted-empty sentinel (#2480).
2055
2109
  for (const [expressId, pieces] of this.meshDataMap) {
2056
2110
  if (this.boundingBoxes.has(expressId))
2057
2111
  continue;
2058
- let minX = Infinity, minY = Infinity, minZ = Infinity;
2059
- let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
2060
- for (const piece of pieces) {
2061
- const positions = piece.positions;
2062
- // world = origin + position (per-element local frame); bake WORLD bbox.
2063
- const ox = piece.origin ? piece.origin[0] : 0;
2064
- const oy = piece.origin ? piece.origin[1] : 0;
2065
- const oz = piece.origin ? piece.origin[2] : 0;
2066
- for (let i = 0; i < positions.length; i += 3) {
2067
- const x = positions[i] + ox;
2068
- const y = positions[i + 1] + oy;
2069
- const z = positions[i + 2] + oz;
2070
- if (x < minX)
2071
- minX = x;
2072
- if (y < minY)
2073
- minY = y;
2074
- if (z < minZ)
2075
- minZ = z;
2076
- if (x > maxX)
2077
- maxX = x;
2078
- if (y > maxY)
2079
- maxY = y;
2080
- if (z > maxZ)
2081
- maxZ = z;
2082
- }
2083
- }
2084
- this.boundingBoxes.set(expressId, {
2085
- min: { x: minX, y: minY, z: minZ },
2086
- max: { x: maxX, y: maxY, z: maxZ },
2087
- });
2112
+ const bbox = worldAabbFromPieces(pieces);
2113
+ if (bbox)
2114
+ this.boundingBoxes.set(expressId, bbox);
2088
2115
  }
2089
2116
  // 2. Clear the heavy data structures — typed arrays become GC-eligible
2090
2117
  this.meshDataMap.clear();
@@ -2636,7 +2663,8 @@ export class Scene {
2636
2663
  partialBatches: this.partialBatchCache.values(),
2637
2664
  meshes: this.meshes,
2638
2665
  textured: this.texturedMeshes,
2639
- instanced: this.instancedTemplates,
2666
+ // Live templates only — freed slots are holes with destroyed buffers.
2667
+ instanced: this.liveInstancedTemplates,
2640
2668
  });
2641
2669
  }
2642
2670
  /**
@@ -2653,7 +2681,148 @@ export class Scene {
2653
2681
  getInstancedTemplates() {
2654
2682
  if (!this.instancedVisible)
2655
2683
  return EMPTY_INSTANCED_TEMPLATES;
2656
- return this.instancedTemplates;
2684
+ return this.liveInstancedTemplates;
2685
+ }
2686
+ /** Rebuild the compacted live-template view after a slot add/remove. */
2687
+ refreshLiveInstancedTemplates() {
2688
+ const live = [];
2689
+ for (const t of this.instancedTemplates) {
2690
+ if (t)
2691
+ live.push(t);
2692
+ }
2693
+ this.liveInstancedTemplates = live;
2694
+ }
2695
+ /** Model indices that currently own at least one instanced template. Unlike
2696
+ * `getInstancedTemplates()` this ignores the Types-view visibility toggle —
2697
+ * hiding the pass does not change who owns what. */
2698
+ getInstancedModelIndices() {
2699
+ const seen = new Set();
2700
+ for (const t of this.instancedTemplates) {
2701
+ if (t)
2702
+ seen.add(t.modelIndex);
2703
+ }
2704
+ return [...seen];
2705
+ }
2706
+ /**
2707
+ * Free every instanced template owned by `modelIndex`: destroy exactly its
2708
+ * own vertex/index/instance buffers, blank its slots (leaving holes, so no
2709
+ * other model's `templateIndex` shifts), prune its occurrences from
2710
+ * `instancedEntityMap`, and drop the per-id selection/hidden/override
2711
+ * bookkeeping for ids that lost their LAST occurrence. Express ids shared
2712
+ * with another model — the federated case before id offsetting — keep the
2713
+ * surviving model's occurrences.
2714
+ *
2715
+ * `boundingBoxes` bookkeeping (#2073): an id that ALSO owns flat geometry is
2716
+ * a mixed id — `removeMeshesForEntity` owns that cache, so it is left alone
2717
+ * here even when the id's last instanced occurrence is pruned. An
2718
+ * instanced-only id that loses its last occurrence has nothing left to keep
2719
+ * a cached box for, so its entry is dropped — otherwise bbox-raycast and
2720
+ * `getBounds()` (post geometry-release) keep finding/sizing an element that
2721
+ * no longer has geometry. An id that keeps SOME occurrences (the shared,
2722
+ * federated case) has its box recomputed from just the survivors, not left
2723
+ * as the stale union that also covered the freed model's occurrences.
2724
+ *
2725
+ * Returns the number of templates removed (0 for an unknown model, which is a
2726
+ * no-op).
2727
+ */
2728
+ removeInstancedTemplatesForModel(modelIndex) {
2729
+ const freed = new Set();
2730
+ for (let i = 0; i < this.instancedTemplates.length; i++) {
2731
+ const t = this.instancedTemplates[i];
2732
+ if (!t || t.modelIndex !== modelIndex)
2733
+ continue;
2734
+ t.vertexBuffer.destroy();
2735
+ t.indexBuffer.destroy();
2736
+ t.instanceBuffer.destroy();
2737
+ this.instancedTemplates[i] = undefined;
2738
+ this.instancedTemplateCpu[i] = undefined;
2739
+ freed.add(i);
2740
+ }
2741
+ if (freed.size === 0)
2742
+ return 0;
2743
+ for (const [eid, occurrences] of this.instancedEntityMap) {
2744
+ const kept = occurrences.filter((o) => !freed.has(o.templateIndex));
2745
+ if (kept.length === occurrences.length)
2746
+ continue;
2747
+ if (kept.length > 0) {
2748
+ this.instancedEntityMap.set(eid, kept);
2749
+ // Shared id: some occurrences survive in another model. The cached box
2750
+ // is a union that also covered the freed occurrences — recompute it
2751
+ // from exactly the survivors rather than leave it oversized.
2752
+ this.recomputeInstancedWorldAabb(eid, kept);
2753
+ continue;
2754
+ }
2755
+ // Last occurrence gone: the id is no longer instanced at all.
2756
+ this.instancedEntityMap.delete(eid);
2757
+ this.instancedSelected.delete(eid);
2758
+ this.instancedHidden.delete(eid);
2759
+ this.instancedOverridden.delete(eid);
2760
+ // Only drop the cached box when the id has no flat geometry either — a
2761
+ // mixed id's box is owned by the flat-removal path (removeMeshesForEntity),
2762
+ // which clears it on its own schedule.
2763
+ if (!this.meshDataMap.has(eid)) {
2764
+ this.boundingBoxes.delete(eid);
2765
+ }
2766
+ }
2767
+ this.refreshLiveInstancedTemplates();
2768
+ // Occurrence population changed → force the next visibility pass to
2769
+ // recompute rather than early-return on an unchanged id set.
2770
+ this.lastInstancedVisibilityVersion = -1;
2771
+ this.instancedVisibilityDirty = true;
2772
+ return freed.size;
2773
+ }
2774
+ /**
2775
+ * Recompute `boundingBoxes[eid]` from exactly `occurrences` (REPLACING any
2776
+ * existing entry rather than unioning into it), reading each occurrence's
2777
+ * template-local AABB + packed instance matrix the same way
2778
+ * `unionInstancedWorldAabb` does at upload time. Used when a model-level
2779
+ * template removal (`removeInstancedTemplatesForModel`) prunes some but not
2780
+ * all of a shared id's occurrences — a plain union only ever grows, so it
2781
+ * cannot shrink to reflect occurrences that no longer exist. Deletes the
2782
+ * entry outright if no surviving occurrence has a finite local box (#2073).
2783
+ */
2784
+ recomputeInstancedWorldAabb(eid, occurrences) {
2785
+ let minX = Infinity, minY = Infinity, minZ = Infinity;
2786
+ let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
2787
+ let any = false;
2788
+ for (const occ of occurrences) {
2789
+ const cpu = this.instancedTemplateCpu[occ.templateIndex];
2790
+ if (!cpu || !Number.isFinite(cpu.localMin[0]))
2791
+ continue;
2792
+ const dv = new DataView(cpu.instanceData);
2793
+ const [lmnx, lmny, lmnz] = cpu.localMin;
2794
+ const [lmxx, lmxy, lmxz] = cpu.localMax;
2795
+ const b = occ.byteOffset;
2796
+ const m0 = dv.getFloat32(b + 0, true), m1 = dv.getFloat32(b + 4, true), m2 = dv.getFloat32(b + 8, true);
2797
+ const m4 = dv.getFloat32(b + 16, true), m5 = dv.getFloat32(b + 20, true), m6 = dv.getFloat32(b + 24, true);
2798
+ const m8 = dv.getFloat32(b + 32, true), m9 = dv.getFloat32(b + 36, true), m10 = dv.getFloat32(b + 40, true);
2799
+ const m12 = dv.getFloat32(b + 48, true), m13 = dv.getFloat32(b + 52, true), m14 = dv.getFloat32(b + 56, true);
2800
+ for (let c = 0; c < 8; c++) {
2801
+ const x = (c & 1) ? lmxx : lmnx, y = (c & 2) ? lmxy : lmny, z = (c & 4) ? lmxz : lmnz;
2802
+ const wx = m0 * x + m4 * y + m8 * z + m12;
2803
+ const wy = m1 * x + m5 * y + m9 * z + m13;
2804
+ const wz = m2 * x + m6 * y + m10 * z + m14;
2805
+ if (wx < minX)
2806
+ minX = wx;
2807
+ if (wy < minY)
2808
+ minY = wy;
2809
+ if (wz < minZ)
2810
+ minZ = wz;
2811
+ if (wx > maxX)
2812
+ maxX = wx;
2813
+ if (wy > maxY)
2814
+ maxY = wy;
2815
+ if (wz > maxZ)
2816
+ maxZ = wz;
2817
+ }
2818
+ any = true;
2819
+ }
2820
+ if (any) {
2821
+ this.boundingBoxes.set(eid, { min: { x: minX, y: minY, z: minZ }, max: { x: maxX, y: maxY, z: maxZ } });
2822
+ }
2823
+ else {
2824
+ this.boundingBoxes.delete(eid);
2825
+ }
2657
2826
  }
2658
2827
  /**
2659
2828
  * Decode a per-batch IFNS instancing shard (`processGeometryBatchInstanced`)
@@ -2671,7 +2840,7 @@ export class Scene {
2671
2840
  * type-only dependency on @ifc-lite/geometry and the Scene stays focused on
2672
2841
  * GPU upload.
2673
2842
  */
2674
- addInstancedShard(device, shard) {
2843
+ addInstancedShard(device, shard, modelIndex = 0) {
2675
2844
  this.instancedDevice = device; // cached for per-instance selection/overlay writeBuffer
2676
2845
  const prepared = prepareInstancedRender(shard);
2677
2846
  // Selected ids whose occurrences arrived in THIS shard (selection recorded
@@ -2721,8 +2890,10 @@ export class Scene {
2721
2890
  });
2722
2891
  new Uint8Array(instanceBuffer.getMappedRange()).set(new Uint8Array(t.instanceBuffer, 0, instSize));
2723
2892
  instanceBuffer.unmap();
2893
+ // Always append: slots are stable identities, never recycled.
2724
2894
  const templateIndex = this.instancedTemplates.length;
2725
2895
  const template = {
2896
+ modelIndex,
2726
2897
  vertexBuffer,
2727
2898
  indexBuffer,
2728
2899
  indexCount: t.indices.length,
@@ -2732,7 +2903,7 @@ export class Scene {
2732
2903
  maxOccRadius: 0,
2733
2904
  selectedCount: 0,
2734
2905
  };
2735
- this.instancedTemplates.push(template);
2906
+ this.instancedTemplates[templateIndex] = template;
2736
2907
  // Template-local AABB (used to derive per-occurrence world AABBs cheaply).
2737
2908
  let lmnx = Infinity, lmny = Infinity, lmnz = Infinity;
2738
2909
  let lmxx = -Infinity, lmxy = -Infinity, lmxz = -Infinity;
@@ -2754,14 +2925,16 @@ export class Scene {
2754
2925
  // Retain the compact CPU geometry + the packed instance records (mat4 per
2755
2926
  // occurrence) so CPU consumers can reach instanced geometry without a full
2756
2927
  // per-occurrence MeshData each. These are references into the decoded shard.
2757
- this.instancedTemplateCpu.push({
2928
+ // Slot-assigned, not pushed: the CPU array is emptied on geometry release
2929
+ // while the GPU slots live on, so `push` would silently misalign the two.
2930
+ this.instancedTemplateCpu[templateIndex] = {
2758
2931
  positions: t.positions,
2759
2932
  normals: t.normals,
2760
2933
  indices: t.indices,
2761
2934
  instanceData: t.instanceBuffer,
2762
2935
  localMin: [lmnx, lmny, lmnz],
2763
2936
  localMax: [lmxx, lmxy, lmxz],
2764
- });
2937
+ };
2765
2938
  // Map each occurrence's express_id -> (template, byte offset, original
2766
2939
  // colour) so selection (flag byte) + lens/IDS overlays (colour bytes) can
2767
2940
  // patch individual occurrences via writeBuffer without a re-upload. Also fold
@@ -2805,6 +2978,7 @@ export class Scene {
2805
2978
  }
2806
2979
  }
2807
2980
  }
2981
+ this.refreshLiveInstancedTemplates();
2808
2982
  // Write the selected flag for ids whose occurrences arrived after the
2809
2983
  // selection was recorded (idempotent for their pre-existing occurrences),
2810
2984
  // so the highlight shows on late-streamed geometry too.
@@ -3287,7 +3461,60 @@ export class Scene {
3287
3461
  this.sharedTextures.delete(tm.sharedTextureKey);
3288
3462
  }
3289
3463
  }
3464
+ /**
3465
+ * Destroy every GPU-instanced template's buffers and reset all instanced
3466
+ * bookkeeping, regardless of owning model. Shared by `clear()` (full reset)
3467
+ * — `clearFlatGeometry()` deliberately does NOT call this, so a reshape
3468
+ * that still has models present can retain their instanced geometry
3469
+ * (#2073).
3470
+ */
3471
+ destroyAllInstancedTemplates() {
3472
+ for (const it of this.instancedTemplates) {
3473
+ if (!it)
3474
+ continue;
3475
+ it.vertexBuffer.destroy();
3476
+ it.indexBuffer.destroy();
3477
+ it.instanceBuffer.destroy();
3478
+ }
3479
+ this.instancedTemplates = [];
3480
+ this.liveInstancedTemplates = [];
3481
+ this.instancedTemplateCpu = [];
3482
+ this.instancedEntityMap.clear();
3483
+ this.instancedSelected.clear();
3484
+ this.instancedHidden.clear();
3485
+ this.instancedOverridden.clear();
3486
+ this.instancedHasTransparent = false;
3487
+ // Force the next setInstancedVisibility to recompute against fresh state.
3488
+ this.lastInstancedVisibilityVersion = -1;
3489
+ this.instancedVisibilityDirty = false;
3490
+ this.instancedDevice = undefined;
3491
+ }
3290
3492
  clear() {
3493
+ // GPU-instancing templates own their vertex/index/instance buffers.
3494
+ // (Freed slots are holes whose buffers are already destroyed — skip them so
3495
+ // a per-model removal followed by clear() can't double-destroy.)
3496
+ this.destroyAllInstancedTemplates();
3497
+ this.clearFlatGeometry();
3498
+ }
3499
+ /**
3500
+ * Clear flat/batched geometry (meshes, batches, buckets, textured meshes,
3501
+ * colour overlays, streaming state, residency bookkeeping) WITHOUT
3502
+ * touching GPU-instanced templates (#2073). A reshape that still has at
3503
+ * least one model present should call this instead of `clear()`, then
3504
+ * reconcile instanced ownership with `removeInstancedTemplatesForModel`
3505
+ * for any model that did NOT survive — that way a still-loaded model's
3506
+ * repeated geometry (windows, doors, bolts, ...) stays resident across a
3507
+ * visibility toggle / in-place content mutation / federated model add
3508
+ * instead of silently vanishing (nothing re-uploads instanced shard bytes
3509
+ * after their one-time drain).
3510
+ *
3511
+ * Bounding boxes are only dropped for ids with NO surviving instanced
3512
+ * occurrence — an instanced-only id's box must outlive this call so
3513
+ * raycast / measure / section keep working for the geometry that was
3514
+ * just retained; a flat-only id's box is stale the moment its mesh data
3515
+ * is gone, so it is dropped like everything else here.
3516
+ */
3517
+ clearFlatGeometry() {
3291
3518
  for (const mesh of this.meshes)
3292
3519
  destroyGpuResources(mesh);
3293
3520
  for (const batch of this.batchedMeshes)
@@ -3304,23 +3531,6 @@ export class Scene {
3304
3531
  for (const entry of this.sharedTextures.values())
3305
3532
  entry.texture.destroy();
3306
3533
  this.sharedTextures.clear();
3307
- // GPU-instancing templates own their vertex/index/instance buffers.
3308
- for (const it of this.instancedTemplates) {
3309
- it.vertexBuffer.destroy();
3310
- it.indexBuffer.destroy();
3311
- it.instanceBuffer.destroy();
3312
- }
3313
- this.instancedTemplates = [];
3314
- this.instancedTemplateCpu = [];
3315
- this.instancedEntityMap.clear();
3316
- this.instancedSelected.clear();
3317
- this.instancedHidden.clear();
3318
- this.instancedOverridden.clear();
3319
- this.instancedHasTransparent = false;
3320
- // Force the next setInstancedVisibility to recompute against fresh state.
3321
- this.lastInstancedVisibilityVersion = -1;
3322
- this.instancedVisibilityDirty = false;
3323
- this.instancedDevice = undefined;
3324
3534
  // Clear partial batch cache (destroys buffers + drops all cache maps)
3325
3535
  this.dropAllPartialCaches();
3326
3536
  this.colorOverrideGeneration++;
@@ -3328,14 +3538,21 @@ export class Scene {
3328
3538
  this.streamingFragments = [];
3329
3539
  this.destroyOverrideBatches();
3330
3540
  this.colorOverrides = null;
3331
- // Reset the shared frame origin so the next model picks its own.
3541
+ // Reset the shared frame origin so the next model picks its own. Retained
3542
+ // instanced templates are unaffected — their per-occurrence transforms are
3543
+ // already baked to absolute world coordinates at upload time, not relative
3544
+ // to this origin.
3332
3545
  this.sharedFrameOrigin = null;
3333
3546
  this.meshes = [];
3334
3547
  this.batchedMeshes = [];
3335
3548
  this.buckets.clear();
3336
3549
  this.meshDataBucket = new Map();
3337
3550
  this.meshDataMap.clear();
3338
- this.boundingBoxes.clear();
3551
+ for (const eid of [...this.boundingBoxes.keys()]) {
3552
+ if (!this.instancedEntityMap.has(eid)) {
3553
+ this.boundingBoxes.delete(eid);
3554
+ }
3555
+ }
3339
3556
  this.activeBucketKey.clear();
3340
3557
  this.lastDrawnFrame.clear();
3341
3558
  this.residencyRestoreQueue.clear();
@@ -3448,41 +3665,14 @@ export class Scene {
3448
3665
  const cached = this.boundingBoxes.get(expressId);
3449
3666
  if (cached)
3450
3667
  return cached;
3451
- // Compute from mesh data
3452
- const pieces = this.meshDataMap.get(expressId);
3453
- if (!pieces || pieces.length === 0)
3668
+ // Compute from mesh data. `null` covers both "no pieces at all" and
3669
+ // "pieces with no vertex a box can be built from" — and, critically, is
3670
+ // NOT cached (#2480): a transient empty piece must not poison the entry
3671
+ // for an entity that later gains real geometry, and this cache has no
3672
+ // invalidation tied to that.
3673
+ const bbox = worldAabbFromPieces(this.meshDataMap.get(expressId));
3674
+ if (!bbox)
3454
3675
  return null;
3455
- let minX = Infinity, minY = Infinity, minZ = Infinity;
3456
- let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
3457
- for (const piece of pieces) {
3458
- const positions = piece.positions;
3459
- // world = origin + position (per-element local frame); origin absent/[0,0,0]
3460
- // for legacy absolute meshes.
3461
- const ox = piece.origin ? piece.origin[0] : 0;
3462
- const oy = piece.origin ? piece.origin[1] : 0;
3463
- const oz = piece.origin ? piece.origin[2] : 0;
3464
- for (let i = 0; i < positions.length; i += 3) {
3465
- const x = positions[i] + ox;
3466
- const y = positions[i + 1] + oy;
3467
- const z = positions[i + 2] + oz;
3468
- if (x < minX)
3469
- minX = x;
3470
- if (y < minY)
3471
- minY = y;
3472
- if (z < minZ)
3473
- minZ = z;
3474
- if (x > maxX)
3475
- maxX = x;
3476
- if (y > maxY)
3477
- maxY = y;
3478
- if (z > maxZ)
3479
- maxZ = z;
3480
- }
3481
- }
3482
- const bbox = {
3483
- min: { x: minX, y: minY, z: minZ },
3484
- max: { x: maxX, y: maxY, z: maxZ },
3485
- };
3486
3676
  this.boundingBoxes.set(expressId, bbox);
3487
3677
  return bbox;
3488
3678
  }