@ifc-lite/renderer 1.28.4 → 1.29.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 (44) hide show
  1. package/dist/index.d.ts.map +1 -1
  2. package/dist/index.js +89 -8
  3. package/dist/index.js.map +1 -1
  4. package/dist/instanced-render.d.ts +73 -0
  5. package/dist/instanced-render.d.ts.map +1 -0
  6. package/dist/instanced-render.js +163 -0
  7. package/dist/instanced-render.js.map +1 -0
  8. package/dist/overlay-routing.d.ts +1 -0
  9. package/dist/overlay-routing.d.ts.map +1 -1
  10. package/dist/overlay-routing.js +1 -1
  11. package/dist/overlay-routing.js.map +1 -1
  12. package/dist/picker.d.ts +5 -2
  13. package/dist/picker.d.ts.map +1 -1
  14. package/dist/picker.js +123 -5
  15. package/dist/picker.js.map +1 -1
  16. package/dist/picking-manager.d.ts.map +1 -1
  17. package/dist/picking-manager.js +2 -2
  18. package/dist/picking-manager.js.map +1 -1
  19. package/dist/pipeline.d.ts +15 -0
  20. package/dist/pipeline.d.ts.map +1 -1
  21. package/dist/pipeline.js +119 -1
  22. package/dist/pipeline.js.map +1 -1
  23. package/dist/point-picker.d.ts +10 -2
  24. package/dist/point-picker.d.ts.map +1 -1
  25. package/dist/point-picker.js +17 -2
  26. package/dist/point-picker.js.map +1 -1
  27. package/dist/raycast-engine.d.ts +3 -0
  28. package/dist/raycast-engine.d.ts.map +1 -1
  29. package/dist/raycast-engine.js +92 -5
  30. package/dist/raycast-engine.js.map +1 -1
  31. package/dist/scene.d.ts +143 -0
  32. package/dist/scene.d.ts.map +1 -1
  33. package/dist/scene.js +653 -4
  34. package/dist/scene.js.map +1 -1
  35. package/dist/shaders/main.wgsl.d.ts +1 -1
  36. package/dist/shaders/main.wgsl.d.ts.map +1 -1
  37. package/dist/shaders/main.wgsl.js +87 -3
  38. package/dist/shaders/main.wgsl.js.map +1 -1
  39. package/dist/shaders/textured.wgsl.d.ts.map +1 -1
  40. package/dist/shaders/textured.wgsl.js +17 -6
  41. package/dist/shaders/textured.wgsl.js.map +1 -1
  42. package/dist/types.d.ts +19 -0
  43. package/dist/types.d.ts.map +1 -1
  44. package/package.json +2 -2
package/dist/scene.js CHANGED
@@ -2,14 +2,18 @@
2
2
  * License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
4
  import { BATCH_CONSTANTS } from './constants.js';
5
- import { prepareRayDirInv, raycastBoundingBoxes, raycastTriangles, } from './scene-raycaster.js';
5
+ import { prepareRayDirInv, raycastBoundingBoxes, raycastTriangles, rayIntersectsBox, } from './scene-raycaster.js';
6
6
  import { mergeGeometry, splitMeshDataForBufferLimit, colorSaltByte, packEntityLane } from './scene-geometry.js';
7
+ import { OPAQUE_ALPHA_CUTOFF } from './overlay-routing.js';
8
+ import { prepareInstancedRender, INSTANCE_STRIDE_BYTES, INSTANCE_COLOR_OFFSET, INSTANCE_FLAGS_OFFSET, INSTANCE_FLAG_SELECTED, INSTANCE_FLAG_HIDDEN, } from './instanced-render.js';
7
9
  function destroyGpuResources(m) {
8
10
  m.vertexBuffer.destroy();
9
11
  m.indexBuffer.destroy();
10
12
  if (m.uniformBuffer)
11
13
  m.uniformBuffer.destroy();
12
14
  }
15
+ /** Shared empty result for getInstancedTemplates() when the instanced pass is hidden. */
16
+ const EMPTY_INSTANCED_TEMPLATES = [];
13
17
  export class Scene {
14
18
  meshes = [];
15
19
  batchedMeshes = []; // flat render array (rebuilt from buckets)
@@ -19,6 +23,18 @@ export class Scene {
19
23
  boundingBoxes = new Map(); // Map expressId -> bounding box (computed lazily)
20
24
  texturedMeshes = []; // #961: IFC surface-textured meshes (own buffers/texture/bindGroup)
21
25
  texturedDevice; // #961: cached for textured-mesh re-upload on translate
26
+ instancedTemplates = []; // GPU-instancing: unique templates + per-occurrence buffers (fed by addInstancedShard)
27
+ instancedVisible = true; // GPU-instancing: hidden in Types view mode (instanced geometry is class-0 occurrences)
28
+ instancedEntityMap = new Map(); // express_id -> occurrences, for per-instance selection/overlay patching
29
+ instancedTemplateCpu = []; // compact CPU geometry per template (index-aligned with instancedTemplates) for CPU consumers
30
+ instancedDevice; // cached for per-instance flag/colour writeBuffer updates
31
+ instancedSelected = new Set(); // currently flag-selected instanced express_ids
32
+ instancedHidden = new Set(); // currently hidden instanced express_ids (hide/isolate)
33
+ instancedOverridden = new Set(); // currently colour-overridden instanced express_ids
34
+ instancedHasTransparent = false; // an override made some instanced occurrence translucent
35
+ lastInstancedHiddenIds = null; // ref-equality guard for setInstancedVisibility
36
+ lastInstancedIsolatedIds = null;
37
+ instancedVisibilityDirty = false; // set when a new shard adds occurrences → re-apply visibility
22
38
  // Buffer-size-aware bucket splitting: when a single color group's geometry
23
39
  // would exceed the GPU maxBufferSize, overflow is directed to a new
24
40
  // sub-bucket with a suffixed key (e.g. "500|500|500|1000#1"). This keeps
@@ -329,6 +345,16 @@ export class Scene {
329
345
  visit(piece);
330
346
  }
331
347
  }
348
+ // Instanced-only occurrences live in the shard, not meshDataMap, so full-
349
+ // geometry CPU consumers (e.g. the deviation BVH) would miss them. Materialize
350
+ // them lazily here — these copies are transient (the caller builds its BVH and
351
+ // discards them), so this does NOT retain the N full copies instancing avoids.
352
+ // Skipped after geometry release (templates freed). (#1238 review)
353
+ if (!this.geometryReleased) {
354
+ for (const piece of this.getAllInstancedMeshData()) {
355
+ visit(piece);
356
+ }
357
+ }
332
358
  }
333
359
  getMeshDataPieces(expressId, modelIndex) {
334
360
  let pieces = this.meshDataMap.get(expressId);
@@ -508,7 +534,10 @@ export class Scene {
508
534
  const meshDataList = this.meshDataMap.get(expressId);
509
535
  if (!meshDataList || meshDataList.length === 0) {
510
536
  this.boundingBoxes.delete(expressId);
511
- return false;
537
+ // Instanced-only entity (lives in the shard, not meshDataMap): without this
538
+ // the GPU occurrence keeps rendering AND picking after delete/split. Tombstone
539
+ // it on the GPU + drop its instanced state. (#1238 review)
540
+ return this.removeInstancedEntity(expressId);
512
541
  }
513
542
  // Track which buckets need re-batching so we don't repeatedly
514
543
  // mark the same key.
@@ -568,10 +597,37 @@ export class Scene {
568
597
  // in the buckets and would otherwise linger after a delete/split (same ghost
569
598
  // class as a move).
570
599
  this.evictHighlightMeshes(expressId);
600
+ // An entity can have BOTH flat meshes and instanced occurrences; clean up the
601
+ // instanced side here too so a mixed entity doesn't keep ghost instances.
602
+ if (this.removeInstancedEntity(expressId))
603
+ removedDedicated = true;
571
604
  // True when at least one dedicated mesh was removed — covers
572
605
  // the case where a mesh was queued but not yet bucketed.
573
606
  return removedDedicated;
574
607
  }
608
+ /**
609
+ * Tombstone a GPU-instanced entity on delete/split: set its HIDDEN flag on the
610
+ * GPU (both render + pick shaders discard it) before forgetting its occurrence
611
+ * locations, then drop all instanced state for it. The occurrence's buffer slots
612
+ * are not reclaimed (delete/split is rare) — hiding them is sufficient and never
613
+ * touches other entities' slots. Returns true if the id was instanced. (#1238)
614
+ */
615
+ removeInstancedEntity(expressId) {
616
+ if (!this.instancedEntityMap.has(expressId))
617
+ return false;
618
+ const device = this.instancedDevice;
619
+ if (device) {
620
+ // Must set the flag while the occurrence locations are still in the map.
621
+ this.instancedHidden.add(expressId);
622
+ this.writeInstanceFlags(device, expressId);
623
+ }
624
+ this.instancedEntityMap.delete(expressId);
625
+ this.instancedSelected.delete(expressId);
626
+ this.instancedHidden.delete(expressId);
627
+ this.instancedOverridden.delete(expressId);
628
+ this.boundingBoxes.delete(expressId);
629
+ return true;
630
+ }
575
631
  /**
576
632
  * Bulk variant of `removeMeshesForEntity`. Avoids re-marking the
577
633
  * same bucket key once per entity in the common "split N walls"
@@ -608,6 +664,24 @@ export class Scene {
608
664
  * to a full reload if needed.
609
665
  */
610
666
  translateMeshesForEntity(expressId, delta) {
667
+ // An entity can have flat meshes, GPU-instanced occurrences, or both. The
668
+ // instanced occurrences live in the per-template instance buffers, NOT in
669
+ // meshDataMap, so the flat path below can't reach them — without this they
670
+ // are "left behind" when a storey lifts in Exploded mode (#1289).
671
+ //
672
+ // Flat runs FIRST because it deletes the entity's cached world AABB; the
673
+ // instanced pass runs last and rebuilds that AABB from the moved occurrence
674
+ // matrices, so a mixed flat+instanced entity never ends up with stranded
675
+ // (null) instanced bounds.
676
+ const flatMoved = this.translateFlatMeshesForEntity(expressId, delta);
677
+ const instancedMoved = this.translateInstancedEntity(expressId, delta);
678
+ return flatMoved || instancedMoved;
679
+ }
680
+ /**
681
+ * Translate every flat (non-instanced) mesh for `expressId` by `delta`. See
682
+ * {@link translateMeshesForEntity} for the full contract; this is the flat half.
683
+ */
684
+ translateFlatMeshesForEntity(expressId, delta) {
611
685
  const meshDataList = this.meshDataMap.get(expressId);
612
686
  if (!meshDataList || meshDataList.length === 0)
613
687
  return false;
@@ -676,6 +750,82 @@ export class Scene {
676
750
  }
677
751
  return true;
678
752
  }
753
+ /**
754
+ * Translate every GPU-instanced occurrence of `expressId` by `delta` in the
755
+ * renderer world frame. Instanced occurrences live in the per-template instance
756
+ * buffers (NOT meshDataMap), so the flat translate path can't reach them — this
757
+ * is what keeps repeated geometry (e.g. windows / mullions emitted via
758
+ * IfcMappedItem) lifting with its storey in Exploded mode (#1289).
759
+ *
760
+ * Mutates BOTH halves so every consumer stays consistent:
761
+ * - the CPU instance record (so getInstancedMeshDataPieces / bounds / raycast
762
+ * / measure / section / export see the new position), and
763
+ * - the GPU instance buffer (so the occurrence renders at the new position).
764
+ * The cached world AABB is shifted by the same delta — every occurrence of the
765
+ * entity moves identically, so a recompute is unnecessary.
766
+ *
767
+ * Returns `true` when at least one occurrence moved. No-op (returns false) for
768
+ * a non-instanced id or a zero delta.
769
+ */
770
+ translateInstancedEntity(expressId, delta) {
771
+ const occurrences = this.instancedEntityMap.get(expressId);
772
+ if (!occurrences || occurrences.length === 0)
773
+ return false;
774
+ const [dx, dy, dz] = delta;
775
+ if (dx === 0 && dy === 0 && dz === 0)
776
+ return false;
777
+ const device = this.instancedDevice;
778
+ let moved = false;
779
+ for (const occ of occurrences) {
780
+ const cpu = this.instancedTemplateCpu[occ.templateIndex];
781
+ if (!cpu)
782
+ continue;
783
+ // Column-major mat4: the translation column is floats 12,13,14, i.e. bytes
784
+ // +48/+52/+56 of the occurrence's record (matches unionInstancedWorldAabb).
785
+ const dv = new DataView(cpu.instanceData);
786
+ const b = occ.byteOffset;
787
+ const tx = dv.getFloat32(b + 48, true) + dx;
788
+ const ty = dv.getFloat32(b + 52, true) + dy;
789
+ const tz = dv.getFloat32(b + 56, true) + dz;
790
+ dv.setFloat32(b + 48, tx, true);
791
+ dv.setFloat32(b + 52, ty, true);
792
+ dv.setFloat32(b + 56, tz, true);
793
+ // Push only the 12 translation bytes to the GPU buffer (in place). Guarded
794
+ // on the cached device so CPU-only tests still exercise the matrix math.
795
+ if (device) {
796
+ const gpu = this.instancedTemplates[occ.templateIndex]?.instanceBuffer;
797
+ if (gpu)
798
+ device.queue.writeBuffer(gpu, b + 48, new Float32Array([tx, ty, tz]));
799
+ }
800
+ moved = true;
801
+ }
802
+ if (!moved)
803
+ return false;
804
+ // Rebuild the cached world AABB from the moved occurrence matrices so pick /
805
+ // measure / section bounds stay correct. A simple in-place shift is unsafe for
806
+ // an entity that has BOTH flat meshes and instanced occurrences: the flat
807
+ // translate path deletes this same cache entry, which would strand the shift
808
+ // (a later getInstancedEntityBounds would return null). Recomputing fresh is
809
+ // robust regardless of the flat path and the upload-time bounds.
810
+ this.recomputeInstancedBounds(expressId);
811
+ return true;
812
+ }
813
+ /** Recompute an instanced entity's cached world AABB by unioning the transformed
814
+ * template AABB across its occurrences (using their CURRENT matrices). Used after
815
+ * a translate so bounds reflect the moved geometry. No-op for a non-instanced id. */
816
+ recomputeInstancedBounds(expressId) {
817
+ const occurrences = this.instancedEntityMap.get(expressId);
818
+ if (!occurrences || occurrences.length === 0)
819
+ return;
820
+ this.boundingBoxes.delete(expressId);
821
+ for (const occ of occurrences) {
822
+ const cpu = this.instancedTemplateCpu[occ.templateIndex];
823
+ if (!cpu)
824
+ continue;
825
+ const dv = new DataView(cpu.instanceData);
826
+ this.unionInstancedWorldAabb(expressId, dv, occ.byteOffset, cpu.localMin[0], cpu.localMin[1], cpu.localMin[2], cpu.localMax[0], cpu.localMax[1], cpu.localMax[2]);
827
+ }
828
+ }
679
829
  /** Drop the per-entity selection-highlight meshes for `expressId` (frozen
680
830
  * copies in `this.meshes`) + free their GPU buffers, so the highlight is
681
831
  * rebuilt from the entity's current geometry on the next render. Used after a
@@ -1071,6 +1221,9 @@ export class Scene {
1071
1221
  this.buckets.clear();
1072
1222
  this.meshDataBucket = new Map();
1073
1223
  this.meshDataMap.clear();
1224
+ // Free the compact instanced template geometry too; the per-occurrence world
1225
+ // AABBs already live in boundingBoxes, so bbox-raycast still finds instanced ids.
1226
+ this.instancedTemplateCpu = [];
1074
1227
  this.activeBucketKey.clear();
1075
1228
  this.pendingBatchKeys.clear();
1076
1229
  for (const batch of this.partialBatchCache.values())
@@ -1142,6 +1295,11 @@ export class Scene {
1142
1295
  }
1143
1296
  // 2. Clear the heavy data structures — typed arrays become GC-eligible
1144
1297
  this.meshDataMap.clear();
1298
+ // Free the compact instanced template geometry; per-occurrence world AABBs are
1299
+ // already cached in boundingBoxes, so the released-path bbox-raycast still
1300
+ // resolves instanced ids (exact-triangle measure/section is unavailable post-
1301
+ // release, same as the flat path).
1302
+ this.instancedTemplateCpu = [];
1145
1303
  // Clear meshData arrays in each bucket (typed arrays become GC-eligible)
1146
1304
  // but keep the bucket shells so batchedMesh references remain valid
1147
1305
  for (const bucket of this.buckets.values()) {
@@ -1468,10 +1626,12 @@ export class Scene {
1468
1626
  if (this.geometryReleased) {
1469
1627
  console.warn('[Scene] setColorOverrides called after geometry data was released — skipping.');
1470
1628
  this.colorOverrides = null;
1629
+ this.setInstancedColorOverrides(null);
1471
1630
  return;
1472
1631
  }
1473
1632
  if (overrides.size === 0) {
1474
1633
  this.colorOverrides = null;
1634
+ this.setInstancedColorOverrides(null);
1475
1635
  return;
1476
1636
  }
1477
1637
  // Defensive copy so external callers can mutate or reuse `overrides`
@@ -1479,6 +1639,10 @@ export class Scene {
1479
1639
  // frozen by the readonly type — we don't deep-clone the inner arrays
1480
1640
  // because they're treated as immutable by every consumer.
1481
1641
  this.colorOverrides = new Map(overrides);
1642
+ // Mirror the overlay onto the GPU-instanced occurrences: patch their colour
1643
+ // bytes in place (no separate overlay pass — the instanced records carry the
1644
+ // override colour directly). No-op when no instanced data is loaded.
1645
+ this.setInstancedColorOverrides(overrides);
1482
1646
  // Group expressIds by override color
1483
1647
  const colorGroups = new Map();
1484
1648
  for (const [expressId, color] of overrides) {
@@ -1513,6 +1677,7 @@ export class Scene {
1513
1677
  clearColorOverrides() {
1514
1678
  this.destroyOverrideBatches();
1515
1679
  this.colorOverrides = null;
1680
+ this.setInstancedColorOverrides(null);
1516
1681
  }
1517
1682
  /** Get overlay batches for rendering */
1518
1683
  getOverrideBatches() {
@@ -1552,6 +1717,438 @@ export class Scene {
1552
1717
  getTexturedMeshes() {
1553
1718
  return this.texturedMeshes;
1554
1719
  }
1720
+ /**
1721
+ * Toggle the instanced draw pass. Instanced geometry is class-0 occurrences
1722
+ * (the Model view); hide it in the Types view mode, where the flat path shows
1723
+ * the class-1/2 type library instead. Buffers stay uploaded — just not drawn —
1724
+ * so toggling back is free.
1725
+ */
1726
+ setInstancedVisible(visible) {
1727
+ this.instancedVisible = visible;
1728
+ }
1729
+ /** GPU-instancing templates for the renderer's instanced draw pass. Empty when
1730
+ * hidden (Types view mode) so the draw loop skips it. */
1731
+ getInstancedTemplates() {
1732
+ if (!this.instancedVisible)
1733
+ return EMPTY_INSTANCED_TEMPLATES;
1734
+ return this.instancedTemplates;
1735
+ }
1736
+ /**
1737
+ * Decode a per-batch IFNS instancing shard (`processGeometryBatchInstanced`)
1738
+ * and upload its templates for GPU-instanced drawing. Each unique geometry
1739
+ * becomes a slot-0 vertex buffer (28-byte pos+norm+entityId, matching the flat
1740
+ * layout — the per-vertex entityId is a 0 placeholder; vs_instanced reads the
1741
+ * per-occurrence id) + index buffer, plus a slot-1 per-instance buffer (mat4 +
1742
+ * entityId + rgba) already composed in the WebGL Y-up frame
1743
+ * (`prepareInstancedRender` folds the Z-up→Y-up swap). No-op on a non-shard or
1744
+ * empty payload. Idempotent only in the sense of "append" — call clear() to
1745
+ * reset on a new model.
1746
+ *
1747
+ * Takes an ALREADY-DECODED shard (the worker/main layer that receives the raw
1748
+ * IFNS bytes owns `decodeInstancedShard`), so this module keeps only a
1749
+ * type-only dependency on @ifc-lite/geometry and the Scene stays focused on
1750
+ * GPU upload.
1751
+ */
1752
+ addInstancedShard(device, shard) {
1753
+ this.instancedDevice = device; // cached for per-instance selection/overlay writeBuffer
1754
+ const prepared = prepareInstancedRender(shard);
1755
+ for (const t of prepared) {
1756
+ const vcount = Math.floor(t.positions.length / 3);
1757
+ if (vcount === 0 || t.indices.length === 0 || t.instanceCount === 0)
1758
+ continue;
1759
+ // Interleave the template's local positions + normals into the 28-byte
1760
+ // (pos3f + norm3f + entityId u32) vertex layout the instanced pipeline's
1761
+ // slot 0 expects. entityId is a 0 placeholder (vs_instanced ignores it).
1762
+ const vtx = new ArrayBuffer(vcount * 28);
1763
+ const vf = new Float32Array(vtx);
1764
+ for (let i = 0; i < vcount; i++) {
1765
+ const o = i * 7;
1766
+ vf[o + 0] = t.positions[i * 3 + 0];
1767
+ vf[o + 1] = t.positions[i * 3 + 1];
1768
+ vf[o + 2] = t.positions[i * 3 + 2];
1769
+ // normals may be shorter/absent on degenerate meshes — default to 0.
1770
+ vf[o + 3] = i * 3 + 0 < t.normals.length ? t.normals[i * 3 + 0] : 0;
1771
+ vf[o + 4] = i * 3 + 1 < t.normals.length ? t.normals[i * 3 + 1] : 0;
1772
+ vf[o + 5] = i * 3 + 2 < t.normals.length ? t.normals[i * 3 + 2] : 0;
1773
+ // vf[o + 6] (entityId lane) stays 0.
1774
+ }
1775
+ const vertexBuffer = device.createBuffer({
1776
+ size: vtx.byteLength,
1777
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
1778
+ mappedAtCreation: true,
1779
+ });
1780
+ new Uint8Array(vertexBuffer.getMappedRange()).set(new Uint8Array(vtx));
1781
+ vertexBuffer.unmap();
1782
+ const indexBuffer = device.createBuffer({
1783
+ size: t.indices.byteLength,
1784
+ usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
1785
+ mappedAtCreation: true,
1786
+ });
1787
+ new Uint32Array(indexBuffer.getMappedRange()).set(t.indices);
1788
+ indexBuffer.unmap();
1789
+ // instanceBuffer is already the interleaved mat4 + entityId + rgba block
1790
+ // (INSTANCE_STRIDE_BYTES per occurrence) from prepareInstancedRender.
1791
+ const instSize = t.instanceCount * INSTANCE_STRIDE_BYTES;
1792
+ const instanceBuffer = device.createBuffer({
1793
+ size: instSize,
1794
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
1795
+ mappedAtCreation: true,
1796
+ });
1797
+ new Uint8Array(instanceBuffer.getMappedRange()).set(new Uint8Array(t.instanceBuffer, 0, instSize));
1798
+ instanceBuffer.unmap();
1799
+ const templateIndex = this.instancedTemplates.length;
1800
+ this.instancedTemplates.push({
1801
+ vertexBuffer,
1802
+ indexBuffer,
1803
+ indexCount: t.indices.length,
1804
+ instanceBuffer,
1805
+ instanceCount: t.instanceCount,
1806
+ });
1807
+ // Template-local AABB (used to derive per-occurrence world AABBs cheaply).
1808
+ let lmnx = Infinity, lmny = Infinity, lmnz = Infinity;
1809
+ let lmxx = -Infinity, lmxy = -Infinity, lmxz = -Infinity;
1810
+ for (let i = 0; i < t.positions.length; i += 3) {
1811
+ const x = t.positions[i], y = t.positions[i + 1], z = t.positions[i + 2];
1812
+ if (x < lmnx)
1813
+ lmnx = x;
1814
+ if (y < lmny)
1815
+ lmny = y;
1816
+ if (z < lmnz)
1817
+ lmnz = z;
1818
+ if (x > lmxx)
1819
+ lmxx = x;
1820
+ if (y > lmxy)
1821
+ lmxy = y;
1822
+ if (z > lmxz)
1823
+ lmxz = z;
1824
+ }
1825
+ // Retain the compact CPU geometry + the packed instance records (mat4 per
1826
+ // occurrence) so CPU consumers can reach instanced geometry without a full
1827
+ // per-occurrence MeshData each. These are references into the decoded shard.
1828
+ this.instancedTemplateCpu.push({
1829
+ positions: t.positions,
1830
+ normals: t.normals,
1831
+ indices: t.indices,
1832
+ instanceData: t.instanceBuffer,
1833
+ localMin: [lmnx, lmny, lmnz],
1834
+ localMax: [lmxx, lmxy, lmxz],
1835
+ });
1836
+ // Map each occurrence's express_id -> (template, byte offset, original
1837
+ // colour) so selection (flag byte) + lens/IDS overlays (colour bytes) can
1838
+ // patch individual occurrences via writeBuffer without a re-upload. Also fold
1839
+ // each occurrence's world AABB (template local box × its mat4) into
1840
+ // boundingBoxes so getEntityBoundingBox + the CPU raycast-bounds path
1841
+ // (BCF anchors, large/released-model picking, frame-selection) see instanced
1842
+ // geometry. (Templates with no finite local box — empty geometry — are skipped.)
1843
+ const cdv = new DataView(t.instanceBuffer);
1844
+ const haveBox = Number.isFinite(lmnx);
1845
+ for (let i = 0; i < t.instanceCount; i++) {
1846
+ const byteOffset = i * INSTANCE_STRIDE_BYTES;
1847
+ const eid = t.entityIds[i];
1848
+ const originalColor = [
1849
+ cdv.getFloat32(byteOffset + INSTANCE_COLOR_OFFSET, true),
1850
+ cdv.getFloat32(byteOffset + INSTANCE_COLOR_OFFSET + 4, true),
1851
+ cdv.getFloat32(byteOffset + INSTANCE_COLOR_OFFSET + 8, true),
1852
+ cdv.getFloat32(byteOffset + INSTANCE_COLOR_OFFSET + 12, true),
1853
+ ];
1854
+ let arr = this.instancedEntityMap.get(eid);
1855
+ if (!arr) {
1856
+ arr = [];
1857
+ this.instancedEntityMap.set(eid, arr);
1858
+ }
1859
+ arr.push({ templateIndex, byteOffset, originalColor });
1860
+ if (haveBox) {
1861
+ this.unionInstancedWorldAabb(eid, cdv, byteOffset, lmnx, lmny, lmnz, lmxx, lmxy, lmxz);
1862
+ }
1863
+ }
1864
+ }
1865
+ // New occurrences default to flags=0 (visible). Force the next setInstancedVisibility
1866
+ // to recompute so an already-active isolate/hide also applies to geometry that
1867
+ // streamed in after the visibility was set.
1868
+ this.instancedVisibilityDirty = true;
1869
+ }
1870
+ /** Transform a template's local AABB by an occurrence's column-major mat4 (read
1871
+ * from the packed instance record at `matOffset`) and union the world box into
1872
+ * boundingBoxes[eid]. */
1873
+ unionInstancedWorldAabb(eid, dv, matOffset, lmnx, lmny, lmnz, lmxx, lmxy, lmxz) {
1874
+ const m0 = dv.getFloat32(matOffset + 0, true), m1 = dv.getFloat32(matOffset + 4, true), m2 = dv.getFloat32(matOffset + 8, true);
1875
+ const m4 = dv.getFloat32(matOffset + 16, true), m5 = dv.getFloat32(matOffset + 20, true), m6 = dv.getFloat32(matOffset + 24, true);
1876
+ const m8 = dv.getFloat32(matOffset + 32, true), m9 = dv.getFloat32(matOffset + 36, true), m10 = dv.getFloat32(matOffset + 40, true);
1877
+ const m12 = dv.getFloat32(matOffset + 48, true), m13 = dv.getFloat32(matOffset + 52, true), m14 = dv.getFloat32(matOffset + 56, true);
1878
+ let minX = Infinity, minY = Infinity, minZ = Infinity, maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
1879
+ for (let c = 0; c < 8; c++) {
1880
+ const x = (c & 1) ? lmxx : lmnx, y = (c & 2) ? lmxy : lmny, z = (c & 4) ? lmxz : lmnz;
1881
+ const wx = m0 * x + m4 * y + m8 * z + m12;
1882
+ const wy = m1 * x + m5 * y + m9 * z + m13;
1883
+ const wz = m2 * x + m6 * y + m10 * z + m14;
1884
+ if (wx < minX)
1885
+ minX = wx;
1886
+ if (wy < minY)
1887
+ minY = wy;
1888
+ if (wz < minZ)
1889
+ minZ = wz;
1890
+ if (wx > maxX)
1891
+ maxX = wx;
1892
+ if (wy > maxY)
1893
+ maxY = wy;
1894
+ if (wz > maxZ)
1895
+ maxZ = wz;
1896
+ }
1897
+ const existing = this.boundingBoxes.get(eid);
1898
+ if (existing) {
1899
+ existing.min.x = Math.min(existing.min.x, minX);
1900
+ existing.min.y = Math.min(existing.min.y, minY);
1901
+ existing.min.z = Math.min(existing.min.z, minZ);
1902
+ existing.max.x = Math.max(existing.max.x, maxX);
1903
+ existing.max.y = Math.max(existing.max.y, maxY);
1904
+ existing.max.z = Math.max(existing.max.z, maxZ);
1905
+ }
1906
+ else {
1907
+ this.boundingBoxes.set(eid, { min: { x: minX, y: minY, z: minZ }, max: { x: maxX, y: maxY, z: maxZ } });
1908
+ }
1909
+ }
1910
+ /** True if `expressId` is a GPU-instanced occurrence (lives only in the instanced
1911
+ * shard, not the flat meshDataMap). CPU consumers use this to decide whether to
1912
+ * fall back to the instanced accessors below. */
1913
+ isInstancedEntity(expressId) {
1914
+ return this.instancedEntityMap.has(expressId);
1915
+ }
1916
+ /** All instanced occurrence express_ids (for CPU consumers that enumerate geometry,
1917
+ * e.g. the raycast-engine and exporters). */
1918
+ getInstancedEntityIds() {
1919
+ return this.instancedEntityMap.keys();
1920
+ }
1921
+ /** Materialize EVERY instanced occurrence as world-space MeshData. Transient + not
1922
+ * retained — for one-shot full-geometry consumers (glTF / IFC5 export) that must
1923
+ * include the instanced occurrences absent from geometryResult.meshes. Returns []
1924
+ * when no instanced data is loaded or after geometry release (templates freed). */
1925
+ getAllInstancedMeshData() {
1926
+ const out = [];
1927
+ for (const eid of this.instancedEntityMap.keys()) {
1928
+ const pieces = this.getInstancedMeshDataPieces(eid);
1929
+ if (pieces)
1930
+ out.push(...pieces);
1931
+ }
1932
+ return out;
1933
+ }
1934
+ /** World-space AABB for an instanced occurrence (union over its occurrences),
1935
+ * or null if not instanced. Populated at upload time, so this is O(1). */
1936
+ getInstancedEntityBounds(expressId) {
1937
+ if (!this.instancedEntityMap.has(expressId))
1938
+ return null;
1939
+ return this.boundingBoxes.get(expressId) ?? null;
1940
+ }
1941
+ /** Lazily materialize per-occurrence world-space MeshData for an instanced entity
1942
+ * (template geometry × each occurrence's matrix). NOT retained — built on demand
1943
+ * for CPU consumers that need triangles (exact raycast / measure / section-face /
1944
+ * export). Returns undefined if the id is not instanced. */
1945
+ getInstancedMeshDataPieces(expressId) {
1946
+ const occ = this.instancedEntityMap.get(expressId);
1947
+ if (!occ || occ.length === 0)
1948
+ return undefined;
1949
+ const out = [];
1950
+ for (const o of occ) {
1951
+ const tpl = this.instancedTemplateCpu[o.templateIndex];
1952
+ if (!tpl || tpl.positions.length === 0)
1953
+ continue;
1954
+ const dv = new DataView(tpl.instanceData);
1955
+ const b = o.byteOffset;
1956
+ const m0 = dv.getFloat32(b + 0, true), m1 = dv.getFloat32(b + 4, true), m2 = dv.getFloat32(b + 8, true);
1957
+ const m4 = dv.getFloat32(b + 16, true), m5 = dv.getFloat32(b + 20, true), m6 = dv.getFloat32(b + 24, true);
1958
+ const m8 = dv.getFloat32(b + 32, true), m9 = dv.getFloat32(b + 36, true), m10 = dv.getFloat32(b + 40, true);
1959
+ const m12 = dv.getFloat32(b + 48, true), m13 = dv.getFloat32(b + 52, true), m14 = dv.getFloat32(b + 56, true);
1960
+ const n = tpl.positions.length;
1961
+ const positions = new Float32Array(n);
1962
+ const normals = new Float32Array(tpl.normals.length);
1963
+ for (let i = 0; i < n; i += 3) {
1964
+ const x = tpl.positions[i], y = tpl.positions[i + 1], z = tpl.positions[i + 2];
1965
+ positions[i] = m0 * x + m4 * y + m8 * z + m12;
1966
+ positions[i + 1] = m1 * x + m5 * y + m9 * z + m13;
1967
+ positions[i + 2] = m2 * x + m6 * y + m10 * z + m14;
1968
+ if (i + 2 < tpl.normals.length) {
1969
+ // Rotate normals by the upper-3×3 (instancing transforms are rigid +
1970
+ // uniform scale, so this is correct up to a renormalize).
1971
+ const nx = tpl.normals[i], ny = tpl.normals[i + 1], nz = tpl.normals[i + 2];
1972
+ let rx = m0 * nx + m4 * ny + m8 * nz;
1973
+ let ry = m1 * nx + m5 * ny + m9 * nz;
1974
+ let rz = m2 * nx + m6 * ny + m10 * nz;
1975
+ const len = Math.hypot(rx, ry, rz) || 1;
1976
+ rx /= len;
1977
+ ry /= len;
1978
+ rz /= len;
1979
+ normals[i] = rx;
1980
+ normals[i + 1] = ry;
1981
+ normals[i + 2] = rz;
1982
+ }
1983
+ }
1984
+ const color = [...o.originalColor];
1985
+ out.push({ expressId, positions, normals, indices: tpl.indices, color });
1986
+ }
1987
+ return out.length > 0 ? out : undefined;
1988
+ }
1989
+ /**
1990
+ * Per-instance SELECTION: highlight the occurrences of `expressIds` by setting
1991
+ * their flag byte (bit 0) and clearing the previously-selected ones. The shader
1992
+ * (vs_instanced -> fs_main) applies the blue highlight per occurrence, so no
1993
+ * re-draw is needed. No-op until a shard has been uploaded.
1994
+ */
1995
+ setInstancedSelection(expressIds) {
1996
+ const device = this.instancedDevice;
1997
+ if (!device || this.instancedTemplates.length === 0)
1998
+ return;
1999
+ // Called every render frame from the renderer. Fast-path an UNCHANGED selection
2000
+ // (the common orbit case, especially the empty set) so we skip both the per-frame
2001
+ // writeBuffer loops AND the `new Set(...)` allocation — equal sizes + full
2002
+ // containment ⇒ set equality.
2003
+ let changed = expressIds.size !== this.instancedSelected.size;
2004
+ if (!changed) {
2005
+ for (const eid of expressIds) {
2006
+ if (!this.instancedSelected.has(eid)) {
2007
+ changed = true;
2008
+ break;
2009
+ }
2010
+ }
2011
+ }
2012
+ if (!changed)
2013
+ return;
2014
+ // Re-derive the combined flag lane (selected | hidden) for every occurrence whose
2015
+ // selected-membership flips, so we never clobber the hidden bit.
2016
+ const prev = this.instancedSelected;
2017
+ this.instancedSelected = new Set(expressIds);
2018
+ for (const eid of prev) {
2019
+ if (!expressIds.has(eid))
2020
+ this.writeInstanceFlags(device, eid);
2021
+ }
2022
+ for (const eid of expressIds) {
2023
+ if (!prev.has(eid))
2024
+ this.writeInstanceFlags(device, eid);
2025
+ }
2026
+ }
2027
+ /**
2028
+ * Per-instance VISIBILITY (hide / isolate): set the hidden flag bit on occurrences
2029
+ * that should not render, mirroring the flat path's hiddenIds/isolatedIds filter.
2030
+ * The shader discards hidden occurrences in BOTH the render and pick passes, so they
2031
+ * neither draw nor are pickable. `isolatedIds != null` means "show only these"; any
2032
+ * occurrence not in the set is hidden. Diffed so an unchanged visibility set is a
2033
+ * no-op (no writeBuffer). No-op until a shard has been uploaded.
2034
+ */
2035
+ setInstancedVisibility(hiddenIds, isolatedIds) {
2036
+ const device = this.instancedDevice;
2037
+ if (!device || this.instancedTemplates.length === 0)
2038
+ return;
2039
+ // Called every render frame. The viewer passes stable Set references that only
2040
+ // change when visibility changes, so a reference-equality guard skips the O(N)
2041
+ // set rebuild + allocation during orbit (the common, unchanged case). The dirty
2042
+ // flag forces a recompute after a new shard adds occurrences mid-stream, so an
2043
+ // active isolate/hide also applies to geometry that streams in afterwards.
2044
+ if (!this.instancedVisibilityDirty &&
2045
+ hiddenIds === this.lastInstancedHiddenIds &&
2046
+ isolatedIds === this.lastInstancedIsolatedIds) {
2047
+ return;
2048
+ }
2049
+ this.instancedVisibilityDirty = false;
2050
+ this.lastInstancedHiddenIds = hiddenIds ?? null;
2051
+ this.lastInstancedIsolatedIds = isolatedIds ?? null;
2052
+ const isHidden = (eid) => (hiddenIds != null && hiddenIds.has(eid)) ||
2053
+ (isolatedIds != null && !isolatedIds.has(eid));
2054
+ // Recompute the effective hidden set over all instanced occurrences and diff vs
2055
+ // the current one; only flips touch the GPU buffer.
2056
+ const next = new Set();
2057
+ for (const eid of this.instancedEntityMap.keys()) {
2058
+ if (isHidden(eid))
2059
+ next.add(eid);
2060
+ }
2061
+ // Fast-path: unchanged hidden set → nothing to write.
2062
+ let changed = next.size !== this.instancedHidden.size;
2063
+ if (!changed) {
2064
+ for (const eid of next) {
2065
+ if (!this.instancedHidden.has(eid)) {
2066
+ changed = true;
2067
+ break;
2068
+ }
2069
+ }
2070
+ }
2071
+ if (!changed)
2072
+ return;
2073
+ const prev = this.instancedHidden;
2074
+ this.instancedHidden = next;
2075
+ for (const eid of prev) {
2076
+ if (!next.has(eid))
2077
+ this.writeInstanceFlags(device, eid);
2078
+ }
2079
+ for (const eid of next) {
2080
+ if (!prev.has(eid))
2081
+ this.writeInstanceFlags(device, eid);
2082
+ }
2083
+ }
2084
+ /**
2085
+ * Per-instance COLOUR OVERRIDE (lens / IDS / compare / 4D): patch the colour
2086
+ * bytes of the affected occurrences in place; occurrences dropped from the map
2087
+ * are restored to their original colour. Pass null/empty to clear all.
2088
+ */
2089
+ setInstancedColorOverrides(overrides) {
2090
+ const device = this.instancedDevice;
2091
+ if (!device || this.instancedTemplates.length === 0)
2092
+ return;
2093
+ const next = overrides ?? new Map();
2094
+ for (const eid of this.instancedOverridden) {
2095
+ if (!next.has(eid))
2096
+ this.restoreInstanceColor(device, eid);
2097
+ }
2098
+ let hasTransparent = false;
2099
+ for (const [eid, rgba] of next) {
2100
+ this.writeInstanceColor(device, eid, rgba);
2101
+ // Instanced occurrences are opaque by partition; only an override can drop alpha
2102
+ // below the cutoff (lens-ghost / x-ray / compare). Track it so the renderer runs
2103
+ // the transparent instanced sub-pass only when something is actually translucent.
2104
+ if (rgba[3] < OPAQUE_ALPHA_CUTOFF)
2105
+ hasTransparent = true;
2106
+ }
2107
+ this.instancedOverridden = new Set(next.keys());
2108
+ this.instancedHasTransparent = hasTransparent;
2109
+ }
2110
+ /** True when an active colour override made some instanced occurrence translucent,
2111
+ * so the renderer should run the transparent instanced sub-pass. */
2112
+ hasTransparentInstances() {
2113
+ return this.instancedHasTransparent;
2114
+ }
2115
+ /** Write the combined flag lane (selected | hidden) for every occurrence of `eid`.
2116
+ * Folding both bits here means selection and visibility updates never clobber each
2117
+ * other (they share the one u32 flags lane at INSTANCE_FLAGS_OFFSET). */
2118
+ writeInstanceFlags(device, eid) {
2119
+ const locs = this.instancedEntityMap.get(eid);
2120
+ if (!locs)
2121
+ return;
2122
+ const flags = (this.instancedSelected.has(eid) ? INSTANCE_FLAG_SELECTED : 0) |
2123
+ (this.instancedHidden.has(eid) ? INSTANCE_FLAG_HIDDEN : 0);
2124
+ const data = new Uint32Array([flags >>> 0]);
2125
+ for (const loc of locs) {
2126
+ const buf = this.instancedTemplates[loc.templateIndex]?.instanceBuffer;
2127
+ if (buf)
2128
+ device.queue.writeBuffer(buf, loc.byteOffset + INSTANCE_FLAGS_OFFSET, data);
2129
+ }
2130
+ }
2131
+ writeInstanceColor(device, eid, rgba) {
2132
+ const locs = this.instancedEntityMap.get(eid);
2133
+ if (!locs)
2134
+ return;
2135
+ const data = new Float32Array([rgba[0], rgba[1], rgba[2], rgba[3]]);
2136
+ for (const loc of locs) {
2137
+ const buf = this.instancedTemplates[loc.templateIndex]?.instanceBuffer;
2138
+ if (buf)
2139
+ device.queue.writeBuffer(buf, loc.byteOffset + INSTANCE_COLOR_OFFSET, data);
2140
+ }
2141
+ }
2142
+ restoreInstanceColor(device, eid) {
2143
+ const locs = this.instancedEntityMap.get(eid);
2144
+ if (!locs)
2145
+ return;
2146
+ for (const loc of locs) {
2147
+ const buf = this.instancedTemplates[loc.templateIndex]?.instanceBuffer;
2148
+ if (buf)
2149
+ device.queue.writeBuffer(buf, loc.byteOffset + INSTANCE_COLOR_OFFSET, new Float32Array(loc.originalColor));
2150
+ }
2151
+ }
1555
2152
  /**
1556
2153
  * Build a textured mesh (#961): interleave position+normal+entityId+uv into one
1557
2154
  * vertex buffer, upload the decoded RGBA8 texture, create a sampler honouring
@@ -1656,6 +2253,23 @@ export class Scene {
1656
2253
  tm.texture.destroy();
1657
2254
  }
1658
2255
  this.texturedMeshes = [];
2256
+ // GPU-instancing templates own their vertex/index/instance buffers.
2257
+ for (const it of this.instancedTemplates) {
2258
+ it.vertexBuffer.destroy();
2259
+ it.indexBuffer.destroy();
2260
+ it.instanceBuffer.destroy();
2261
+ }
2262
+ this.instancedTemplates = [];
2263
+ this.instancedTemplateCpu = [];
2264
+ this.instancedEntityMap.clear();
2265
+ this.instancedSelected.clear();
2266
+ this.instancedHidden.clear();
2267
+ this.instancedOverridden.clear();
2268
+ this.instancedHasTransparent = false;
2269
+ this.lastInstancedHiddenIds = null;
2270
+ this.lastInstancedIsolatedIds = null;
2271
+ this.instancedVisibilityDirty = false;
2272
+ this.instancedDevice = undefined;
1659
2273
  // Clear partial batch cache
1660
2274
  for (const batch of this.partialBatchCache.values())
1661
2275
  destroyGpuResources(batch);
@@ -1758,9 +2372,17 @@ export class Scene {
1758
2372
  */
1759
2373
  getAllMeshDataExpressIds() {
1760
2374
  if (this.geometryReleased) {
2375
+ // boundingBoxes already includes instanced occurrences (their AABBs are
2376
+ // stored there at upload), so the released path needs no extra union.
1761
2377
  return Array.from(this.boundingBoxes.keys());
1762
2378
  }
1763
- return Array.from(this.meshDataMap.keys());
2379
+ // Union instanced-only occurrences (absent from meshDataMap) so CPU consumers
2380
+ // enumerating geometry see them too. IDs only — no geometry materialized.
2381
+ // (#1238 review)
2382
+ const ids = new Set(this.meshDataMap.keys());
2383
+ for (const eid of this.instancedEntityMap.keys())
2384
+ ids.add(eid);
2385
+ return Array.from(ids);
1764
2386
  }
1765
2387
  /**
1766
2388
  * Get or compute bounding box for an entity from its mesh vertex data.
@@ -1823,7 +2445,34 @@ export class Scene {
1823
2445
  return raycastBoundingBoxes(rayOrigin, rayDirInv, rayDirSign, this.boundingBoxes, hiddenIds, isolatedIds);
1824
2446
  }
1825
2447
  // Full triangle-level raycast with bounding-box pre-filter
1826
- return raycastTriangles(rayOrigin, rayDir, rayDirInv, rayDirSign, this.meshDataMap, (id) => this.getEntityBoundingBox(id), hiddenIds, isolatedIds);
2448
+ const flatHit = raycastTriangles(rayOrigin, rayDir, rayDirInv, rayDirSign, this.meshDataMap, (id) => this.getEntityBoundingBox(id), hiddenIds, isolatedIds);
2449
+ // Instanced-only occurrences live in the shard, not meshDataMap, so the CPU
2450
+ // pick fallback would miss them. Materialize triangles lazily ONLY for
2451
+ // entities whose world AABB the ray actually hits (never the whole instanced
2452
+ // population), then return whichever hit is closer. (#1238 review)
2453
+ let instancedHit = null;
2454
+ if (this.instancedEntityMap.size > 0) {
2455
+ const instancedMap = new Map();
2456
+ for (const eid of this.instancedEntityMap.keys()) {
2457
+ if (hiddenIds?.has(eid))
2458
+ continue;
2459
+ if (isolatedIds != null && !isolatedIds.has(eid))
2460
+ continue;
2461
+ const bounds = this.getInstancedEntityBounds(eid);
2462
+ if (!bounds || !rayIntersectsBox(rayOrigin, rayDirInv, rayDirSign, bounds))
2463
+ continue;
2464
+ const pieces = this.getInstancedMeshDataPieces(eid);
2465
+ if (pieces && pieces.length > 0)
2466
+ instancedMap.set(eid, pieces);
2467
+ }
2468
+ if (instancedMap.size > 0) {
2469
+ instancedHit = raycastTriangles(rayOrigin, rayDir, rayDirInv, rayDirSign, instancedMap, (id) => this.getInstancedEntityBounds(id), hiddenIds, isolatedIds);
2470
+ }
2471
+ }
2472
+ if (flatHit && instancedHit) {
2473
+ return instancedHit.distance < flatHit.distance ? instancedHit : flatHit;
2474
+ }
2475
+ return flatHit ?? instancedHit;
1827
2476
  }
1828
2477
  }
1829
2478
  //# sourceMappingURL=scene.js.map