@ifc-lite/renderer 1.41.1 → 1.43.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 (57) hide show
  1. package/dist/camera-fit-policy.d.ts.map +1 -1
  2. package/dist/camera-fit-policy.js +18 -7
  3. package/dist/camera-fit-policy.js.map +1 -1
  4. package/dist/camera.d.ts.map +1 -1
  5. package/dist/camera.js +77 -7
  6. package/dist/camera.js.map +1 -1
  7. package/dist/deviation/deviation-pipeline.d.ts.map +1 -1
  8. package/dist/deviation/deviation-pipeline.js +21 -4
  9. package/dist/deviation/deviation-pipeline.js.map +1 -1
  10. package/dist/index.d.ts +179 -35
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +466 -666
  13. package/dist/index.js.map +1 -1
  14. package/dist/math.d.ts +10 -1
  15. package/dist/math.d.ts.map +1 -1
  16. package/dist/math.js +107 -12
  17. package/dist/math.js.map +1 -1
  18. package/dist/model-bounds-tracker.d.ts +96 -0
  19. package/dist/model-bounds-tracker.d.ts.map +1 -0
  20. package/dist/model-bounds-tracker.js +147 -0
  21. package/dist/model-bounds-tracker.js.map +1 -0
  22. package/dist/pointcloud/point-cloud-node.d.ts.map +1 -1
  23. package/dist/pointcloud/point-cloud-node.js +24 -4
  24. package/dist/pointcloud/point-cloud-node.js.map +1 -1
  25. package/dist/render-degradation.d.ts +77 -0
  26. package/dist/render-degradation.d.ts.map +1 -0
  27. package/dist/render-degradation.js +52 -0
  28. package/dist/render-degradation.js.map +1 -0
  29. package/dist/render-section-draw.d.ts +51 -0
  30. package/dist/render-section-draw.d.ts.map +1 -0
  31. package/dist/render-section-draw.js +66 -0
  32. package/dist/render-section-draw.js.map +1 -0
  33. package/dist/render-section-plane.d.ts +128 -0
  34. package/dist/render-section-plane.d.ts.map +1 -0
  35. package/dist/render-section-plane.js +298 -0
  36. package/dist/render-section-plane.js.map +1 -0
  37. package/dist/renderer-overlays.d.ts +144 -0
  38. package/dist/renderer-overlays.d.ts.map +1 -0
  39. package/dist/renderer-overlays.js +257 -0
  40. package/dist/renderer-overlays.js.map +1 -0
  41. package/dist/renderer-symbolic-overlays.d.ts +52 -0
  42. package/dist/renderer-symbolic-overlays.d.ts.map +1 -0
  43. package/dist/renderer-symbolic-overlays.js +120 -0
  44. package/dist/renderer-symbolic-overlays.js.map +1 -0
  45. package/dist/scene.d.ts +86 -1
  46. package/dist/scene.d.ts.map +1 -1
  47. package/dist/scene.js +369 -104
  48. package/dist/scene.js.map +1 -1
  49. package/dist/section-2d-overlay.d.ts +17 -0
  50. package/dist/section-2d-overlay.d.ts.map +1 -1
  51. package/dist/section-2d-overlay.js +62 -0
  52. package/dist/section-2d-overlay.js.map +1 -1
  53. package/dist/visual-enhancement.d.ts +33 -0
  54. package/dist/visual-enhancement.d.ts.map +1 -0
  55. package/dist/visual-enhancement.js +46 -0
  56. package/dist/visual-enhancement.js.map +1 -0
  57. package/package.json +3 -3
package/dist/scene.js CHANGED
@@ -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
+ }
1937
1962
  }
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;
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);
1946
1969
  }
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 live — dropping 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);
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
  });
@@ -2636,7 +2711,8 @@ export class Scene {
2636
2711
  partialBatches: this.partialBatchCache.values(),
2637
2712
  meshes: this.meshes,
2638
2713
  textured: this.texturedMeshes,
2639
- instanced: this.instancedTemplates,
2714
+ // Live templates only — freed slots are holes with destroyed buffers.
2715
+ instanced: this.liveInstancedTemplates,
2640
2716
  });
2641
2717
  }
2642
2718
  /**
@@ -2653,7 +2729,148 @@ export class Scene {
2653
2729
  getInstancedTemplates() {
2654
2730
  if (!this.instancedVisible)
2655
2731
  return EMPTY_INSTANCED_TEMPLATES;
2656
- return this.instancedTemplates;
2732
+ return this.liveInstancedTemplates;
2733
+ }
2734
+ /** Rebuild the compacted live-template view after a slot add/remove. */
2735
+ refreshLiveInstancedTemplates() {
2736
+ const live = [];
2737
+ for (const t of this.instancedTemplates) {
2738
+ if (t)
2739
+ live.push(t);
2740
+ }
2741
+ this.liveInstancedTemplates = live;
2742
+ }
2743
+ /** Model indices that currently own at least one instanced template. Unlike
2744
+ * `getInstancedTemplates()` this ignores the Types-view visibility toggle —
2745
+ * hiding the pass does not change who owns what. */
2746
+ getInstancedModelIndices() {
2747
+ const seen = new Set();
2748
+ for (const t of this.instancedTemplates) {
2749
+ if (t)
2750
+ seen.add(t.modelIndex);
2751
+ }
2752
+ return [...seen];
2753
+ }
2754
+ /**
2755
+ * Free every instanced template owned by `modelIndex`: destroy exactly its
2756
+ * own vertex/index/instance buffers, blank its slots (leaving holes, so no
2757
+ * other model's `templateIndex` shifts), prune its occurrences from
2758
+ * `instancedEntityMap`, and drop the per-id selection/hidden/override
2759
+ * bookkeeping for ids that lost their LAST occurrence. Express ids shared
2760
+ * with another model — the federated case before id offsetting — keep the
2761
+ * surviving model's occurrences.
2762
+ *
2763
+ * `boundingBoxes` bookkeeping (#2073): an id that ALSO owns flat geometry is
2764
+ * a mixed id — `removeMeshesForEntity` owns that cache, so it is left alone
2765
+ * here even when the id's last instanced occurrence is pruned. An
2766
+ * instanced-only id that loses its last occurrence has nothing left to keep
2767
+ * a cached box for, so its entry is dropped — otherwise bbox-raycast and
2768
+ * `getBounds()` (post geometry-release) keep finding/sizing an element that
2769
+ * no longer has geometry. An id that keeps SOME occurrences (the shared,
2770
+ * federated case) has its box recomputed from just the survivors, not left
2771
+ * as the stale union that also covered the freed model's occurrences.
2772
+ *
2773
+ * Returns the number of templates removed (0 for an unknown model, which is a
2774
+ * no-op).
2775
+ */
2776
+ removeInstancedTemplatesForModel(modelIndex) {
2777
+ const freed = new Set();
2778
+ for (let i = 0; i < this.instancedTemplates.length; i++) {
2779
+ const t = this.instancedTemplates[i];
2780
+ if (!t || t.modelIndex !== modelIndex)
2781
+ continue;
2782
+ t.vertexBuffer.destroy();
2783
+ t.indexBuffer.destroy();
2784
+ t.instanceBuffer.destroy();
2785
+ this.instancedTemplates[i] = undefined;
2786
+ this.instancedTemplateCpu[i] = undefined;
2787
+ freed.add(i);
2788
+ }
2789
+ if (freed.size === 0)
2790
+ return 0;
2791
+ for (const [eid, occurrences] of this.instancedEntityMap) {
2792
+ const kept = occurrences.filter((o) => !freed.has(o.templateIndex));
2793
+ if (kept.length === occurrences.length)
2794
+ continue;
2795
+ if (kept.length > 0) {
2796
+ this.instancedEntityMap.set(eid, kept);
2797
+ // Shared id: some occurrences survive in another model. The cached box
2798
+ // is a union that also covered the freed occurrences — recompute it
2799
+ // from exactly the survivors rather than leave it oversized.
2800
+ this.recomputeInstancedWorldAabb(eid, kept);
2801
+ continue;
2802
+ }
2803
+ // Last occurrence gone: the id is no longer instanced at all.
2804
+ this.instancedEntityMap.delete(eid);
2805
+ this.instancedSelected.delete(eid);
2806
+ this.instancedHidden.delete(eid);
2807
+ this.instancedOverridden.delete(eid);
2808
+ // Only drop the cached box when the id has no flat geometry either — a
2809
+ // mixed id's box is owned by the flat-removal path (removeMeshesForEntity),
2810
+ // which clears it on its own schedule.
2811
+ if (!this.meshDataMap.has(eid)) {
2812
+ this.boundingBoxes.delete(eid);
2813
+ }
2814
+ }
2815
+ this.refreshLiveInstancedTemplates();
2816
+ // Occurrence population changed → force the next visibility pass to
2817
+ // recompute rather than early-return on an unchanged id set.
2818
+ this.lastInstancedVisibilityVersion = -1;
2819
+ this.instancedVisibilityDirty = true;
2820
+ return freed.size;
2821
+ }
2822
+ /**
2823
+ * Recompute `boundingBoxes[eid]` from exactly `occurrences` (REPLACING any
2824
+ * existing entry rather than unioning into it), reading each occurrence's
2825
+ * template-local AABB + packed instance matrix the same way
2826
+ * `unionInstancedWorldAabb` does at upload time. Used when a model-level
2827
+ * template removal (`removeInstancedTemplatesForModel`) prunes some but not
2828
+ * all of a shared id's occurrences — a plain union only ever grows, so it
2829
+ * cannot shrink to reflect occurrences that no longer exist. Deletes the
2830
+ * entry outright if no surviving occurrence has a finite local box (#2073).
2831
+ */
2832
+ recomputeInstancedWorldAabb(eid, occurrences) {
2833
+ let minX = Infinity, minY = Infinity, minZ = Infinity;
2834
+ let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
2835
+ let any = false;
2836
+ for (const occ of occurrences) {
2837
+ const cpu = this.instancedTemplateCpu[occ.templateIndex];
2838
+ if (!cpu || !Number.isFinite(cpu.localMin[0]))
2839
+ continue;
2840
+ const dv = new DataView(cpu.instanceData);
2841
+ const [lmnx, lmny, lmnz] = cpu.localMin;
2842
+ const [lmxx, lmxy, lmxz] = cpu.localMax;
2843
+ const b = occ.byteOffset;
2844
+ const m0 = dv.getFloat32(b + 0, true), m1 = dv.getFloat32(b + 4, true), m2 = dv.getFloat32(b + 8, true);
2845
+ const m4 = dv.getFloat32(b + 16, true), m5 = dv.getFloat32(b + 20, true), m6 = dv.getFloat32(b + 24, true);
2846
+ const m8 = dv.getFloat32(b + 32, true), m9 = dv.getFloat32(b + 36, true), m10 = dv.getFloat32(b + 40, true);
2847
+ const m12 = dv.getFloat32(b + 48, true), m13 = dv.getFloat32(b + 52, true), m14 = dv.getFloat32(b + 56, true);
2848
+ for (let c = 0; c < 8; c++) {
2849
+ const x = (c & 1) ? lmxx : lmnx, y = (c & 2) ? lmxy : lmny, z = (c & 4) ? lmxz : lmnz;
2850
+ const wx = m0 * x + m4 * y + m8 * z + m12;
2851
+ const wy = m1 * x + m5 * y + m9 * z + m13;
2852
+ const wz = m2 * x + m6 * y + m10 * z + m14;
2853
+ if (wx < minX)
2854
+ minX = wx;
2855
+ if (wy < minY)
2856
+ minY = wy;
2857
+ if (wz < minZ)
2858
+ minZ = wz;
2859
+ if (wx > maxX)
2860
+ maxX = wx;
2861
+ if (wy > maxY)
2862
+ maxY = wy;
2863
+ if (wz > maxZ)
2864
+ maxZ = wz;
2865
+ }
2866
+ any = true;
2867
+ }
2868
+ if (any) {
2869
+ this.boundingBoxes.set(eid, { min: { x: minX, y: minY, z: minZ }, max: { x: maxX, y: maxY, z: maxZ } });
2870
+ }
2871
+ else {
2872
+ this.boundingBoxes.delete(eid);
2873
+ }
2657
2874
  }
2658
2875
  /**
2659
2876
  * Decode a per-batch IFNS instancing shard (`processGeometryBatchInstanced`)
@@ -2671,7 +2888,7 @@ export class Scene {
2671
2888
  * type-only dependency on @ifc-lite/geometry and the Scene stays focused on
2672
2889
  * GPU upload.
2673
2890
  */
2674
- addInstancedShard(device, shard) {
2891
+ addInstancedShard(device, shard, modelIndex = 0) {
2675
2892
  this.instancedDevice = device; // cached for per-instance selection/overlay writeBuffer
2676
2893
  const prepared = prepareInstancedRender(shard);
2677
2894
  // Selected ids whose occurrences arrived in THIS shard (selection recorded
@@ -2721,8 +2938,10 @@ export class Scene {
2721
2938
  });
2722
2939
  new Uint8Array(instanceBuffer.getMappedRange()).set(new Uint8Array(t.instanceBuffer, 0, instSize));
2723
2940
  instanceBuffer.unmap();
2941
+ // Always append: slots are stable identities, never recycled.
2724
2942
  const templateIndex = this.instancedTemplates.length;
2725
2943
  const template = {
2944
+ modelIndex,
2726
2945
  vertexBuffer,
2727
2946
  indexBuffer,
2728
2947
  indexCount: t.indices.length,
@@ -2732,7 +2951,7 @@ export class Scene {
2732
2951
  maxOccRadius: 0,
2733
2952
  selectedCount: 0,
2734
2953
  };
2735
- this.instancedTemplates.push(template);
2954
+ this.instancedTemplates[templateIndex] = template;
2736
2955
  // Template-local AABB (used to derive per-occurrence world AABBs cheaply).
2737
2956
  let lmnx = Infinity, lmny = Infinity, lmnz = Infinity;
2738
2957
  let lmxx = -Infinity, lmxy = -Infinity, lmxz = -Infinity;
@@ -2754,14 +2973,16 @@ export class Scene {
2754
2973
  // Retain the compact CPU geometry + the packed instance records (mat4 per
2755
2974
  // occurrence) so CPU consumers can reach instanced geometry without a full
2756
2975
  // per-occurrence MeshData each. These are references into the decoded shard.
2757
- this.instancedTemplateCpu.push({
2976
+ // Slot-assigned, not pushed: the CPU array is emptied on geometry release
2977
+ // while the GPU slots live on, so `push` would silently misalign the two.
2978
+ this.instancedTemplateCpu[templateIndex] = {
2758
2979
  positions: t.positions,
2759
2980
  normals: t.normals,
2760
2981
  indices: t.indices,
2761
2982
  instanceData: t.instanceBuffer,
2762
2983
  localMin: [lmnx, lmny, lmnz],
2763
2984
  localMax: [lmxx, lmxy, lmxz],
2764
- });
2985
+ };
2765
2986
  // Map each occurrence's express_id -> (template, byte offset, original
2766
2987
  // colour) so selection (flag byte) + lens/IDS overlays (colour bytes) can
2767
2988
  // patch individual occurrences via writeBuffer without a re-upload. Also fold
@@ -2805,6 +3026,7 @@ export class Scene {
2805
3026
  }
2806
3027
  }
2807
3028
  }
3029
+ this.refreshLiveInstancedTemplates();
2808
3030
  // Write the selected flag for ids whose occurrences arrived after the
2809
3031
  // selection was recorded (idempotent for their pre-existing occurrences),
2810
3032
  // so the highlight shows on late-streamed geometry too.
@@ -3287,7 +3509,60 @@ export class Scene {
3287
3509
  this.sharedTextures.delete(tm.sharedTextureKey);
3288
3510
  }
3289
3511
  }
3512
+ /**
3513
+ * Destroy every GPU-instanced template's buffers and reset all instanced
3514
+ * bookkeeping, regardless of owning model. Shared by `clear()` (full reset)
3515
+ * — `clearFlatGeometry()` deliberately does NOT call this, so a reshape
3516
+ * that still has models present can retain their instanced geometry
3517
+ * (#2073).
3518
+ */
3519
+ destroyAllInstancedTemplates() {
3520
+ for (const it of this.instancedTemplates) {
3521
+ if (!it)
3522
+ continue;
3523
+ it.vertexBuffer.destroy();
3524
+ it.indexBuffer.destroy();
3525
+ it.instanceBuffer.destroy();
3526
+ }
3527
+ this.instancedTemplates = [];
3528
+ this.liveInstancedTemplates = [];
3529
+ this.instancedTemplateCpu = [];
3530
+ this.instancedEntityMap.clear();
3531
+ this.instancedSelected.clear();
3532
+ this.instancedHidden.clear();
3533
+ this.instancedOverridden.clear();
3534
+ this.instancedHasTransparent = false;
3535
+ // Force the next setInstancedVisibility to recompute against fresh state.
3536
+ this.lastInstancedVisibilityVersion = -1;
3537
+ this.instancedVisibilityDirty = false;
3538
+ this.instancedDevice = undefined;
3539
+ }
3290
3540
  clear() {
3541
+ // GPU-instancing templates own their vertex/index/instance buffers.
3542
+ // (Freed slots are holes whose buffers are already destroyed — skip them so
3543
+ // a per-model removal followed by clear() can't double-destroy.)
3544
+ this.destroyAllInstancedTemplates();
3545
+ this.clearFlatGeometry();
3546
+ }
3547
+ /**
3548
+ * Clear flat/batched geometry (meshes, batches, buckets, textured meshes,
3549
+ * colour overlays, streaming state, residency bookkeeping) WITHOUT
3550
+ * touching GPU-instanced templates (#2073). A reshape that still has at
3551
+ * least one model present should call this instead of `clear()`, then
3552
+ * reconcile instanced ownership with `removeInstancedTemplatesForModel`
3553
+ * for any model that did NOT survive — that way a still-loaded model's
3554
+ * repeated geometry (windows, doors, bolts, ...) stays resident across a
3555
+ * visibility toggle / in-place content mutation / federated model add
3556
+ * instead of silently vanishing (nothing re-uploads instanced shard bytes
3557
+ * after their one-time drain).
3558
+ *
3559
+ * Bounding boxes are only dropped for ids with NO surviving instanced
3560
+ * occurrence — an instanced-only id's box must outlive this call so
3561
+ * raycast / measure / section keep working for the geometry that was
3562
+ * just retained; a flat-only id's box is stale the moment its mesh data
3563
+ * is gone, so it is dropped like everything else here.
3564
+ */
3565
+ clearFlatGeometry() {
3291
3566
  for (const mesh of this.meshes)
3292
3567
  destroyGpuResources(mesh);
3293
3568
  for (const batch of this.batchedMeshes)
@@ -3304,23 +3579,6 @@ export class Scene {
3304
3579
  for (const entry of this.sharedTextures.values())
3305
3580
  entry.texture.destroy();
3306
3581
  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
3582
  // Clear partial batch cache (destroys buffers + drops all cache maps)
3325
3583
  this.dropAllPartialCaches();
3326
3584
  this.colorOverrideGeneration++;
@@ -3328,14 +3586,21 @@ export class Scene {
3328
3586
  this.streamingFragments = [];
3329
3587
  this.destroyOverrideBatches();
3330
3588
  this.colorOverrides = null;
3331
- // Reset the shared frame origin so the next model picks its own.
3589
+ // Reset the shared frame origin so the next model picks its own. Retained
3590
+ // instanced templates are unaffected — their per-occurrence transforms are
3591
+ // already baked to absolute world coordinates at upload time, not relative
3592
+ // to this origin.
3332
3593
  this.sharedFrameOrigin = null;
3333
3594
  this.meshes = [];
3334
3595
  this.batchedMeshes = [];
3335
3596
  this.buckets.clear();
3336
3597
  this.meshDataBucket = new Map();
3337
3598
  this.meshDataMap.clear();
3338
- this.boundingBoxes.clear();
3599
+ for (const eid of [...this.boundingBoxes.keys()]) {
3600
+ if (!this.instancedEntityMap.has(eid)) {
3601
+ this.boundingBoxes.delete(eid);
3602
+ }
3603
+ }
3339
3604
  this.activeBucketKey.clear();
3340
3605
  this.lastDrawnFrame.clear();
3341
3606
  this.residencyRestoreQueue.clear();