@ifc-lite/renderer 1.28.3 → 1.28.5

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 (46) hide show
  1. package/dist/index.d.ts.map +1 -1
  2. package/dist/index.js +70 -2
  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-geometry.d.ts +21 -0
  32. package/dist/scene-geometry.d.ts.map +1 -1
  33. package/dist/scene-geometry.js +38 -1
  34. package/dist/scene-geometry.js.map +1 -1
  35. package/dist/scene.d.ts +116 -0
  36. package/dist/scene.d.ts.map +1 -1
  37. package/dist/scene.js +565 -6
  38. package/dist/scene.js.map +1 -1
  39. package/dist/shaders/main.wgsl.d.ts +1 -1
  40. package/dist/shaders/main.wgsl.d.ts.map +1 -1
  41. package/dist/shaders/main.wgsl.js +102 -16
  42. package/dist/shaders/main.wgsl.js.map +1 -1
  43. package/dist/shaders/textured.wgsl.d.ts.map +1 -1
  44. package/dist/shaders/textured.wgsl.js +17 -6
  45. package/dist/shaders/textured.wgsl.js.map +1 -1
  46. 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';
6
- import { mergeGeometry, splitMeshDataForBufferLimit } from './scene-geometry.js';
5
+ import { prepareRayDirInv, raycastBoundingBoxes, raycastTriangles, rayIntersectsBox, } from './scene-raycaster.js';
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"
@@ -1071,6 +1127,9 @@ export class Scene {
1071
1127
  this.buckets.clear();
1072
1128
  this.meshDataBucket = new Map();
1073
1129
  this.meshDataMap.clear();
1130
+ // Free the compact instanced template geometry too; the per-occurrence world
1131
+ // AABBs already live in boundingBoxes, so bbox-raycast still finds instanced ids.
1132
+ this.instancedTemplateCpu = [];
1074
1133
  this.activeBucketKey.clear();
1075
1134
  this.pendingBatchKeys.clear();
1076
1135
  for (const batch of this.partialBatchCache.values())
@@ -1142,6 +1201,11 @@ export class Scene {
1142
1201
  }
1143
1202
  // 2. Clear the heavy data structures — typed arrays become GC-eligible
1144
1203
  this.meshDataMap.clear();
1204
+ // Free the compact instanced template geometry; per-occurrence world AABBs are
1205
+ // already cached in boundingBoxes, so the released-path bbox-raycast still
1206
+ // resolves instanced ids (exact-triangle measure/section is unavailable post-
1207
+ // release, same as the flat path).
1208
+ this.instancedTemplateCpu = [];
1145
1209
  // Clear meshData arrays in each bucket (typed arrays become GC-eligible)
1146
1210
  // but keep the bucket shells so batchedMesh references remain valid
1147
1211
  for (const bucket of this.buckets.values()) {
@@ -1468,10 +1532,12 @@ export class Scene {
1468
1532
  if (this.geometryReleased) {
1469
1533
  console.warn('[Scene] setColorOverrides called after geometry data was released — skipping.');
1470
1534
  this.colorOverrides = null;
1535
+ this.setInstancedColorOverrides(null);
1471
1536
  return;
1472
1537
  }
1473
1538
  if (overrides.size === 0) {
1474
1539
  this.colorOverrides = null;
1540
+ this.setInstancedColorOverrides(null);
1475
1541
  return;
1476
1542
  }
1477
1543
  // Defensive copy so external callers can mutate or reuse `overrides`
@@ -1479,6 +1545,10 @@ export class Scene {
1479
1545
  // frozen by the readonly type — we don't deep-clone the inner arrays
1480
1546
  // because they're treated as immutable by every consumer.
1481
1547
  this.colorOverrides = new Map(overrides);
1548
+ // Mirror the overlay onto the GPU-instanced occurrences: patch their colour
1549
+ // bytes in place (no separate overlay pass — the instanced records carry the
1550
+ // override colour directly). No-op when no instanced data is loaded.
1551
+ this.setInstancedColorOverrides(overrides);
1482
1552
  // Group expressIds by override color
1483
1553
  const colorGroups = new Map();
1484
1554
  for (const [expressId, color] of overrides) {
@@ -1513,6 +1583,7 @@ export class Scene {
1513
1583
  clearColorOverrides() {
1514
1584
  this.destroyOverrideBatches();
1515
1585
  this.colorOverrides = null;
1586
+ this.setInstancedColorOverrides(null);
1516
1587
  }
1517
1588
  /** Get overlay batches for rendering */
1518
1589
  getOverrideBatches() {
@@ -1552,6 +1623,438 @@ export class Scene {
1552
1623
  getTexturedMeshes() {
1553
1624
  return this.texturedMeshes;
1554
1625
  }
1626
+ /**
1627
+ * Toggle the instanced draw pass. Instanced geometry is class-0 occurrences
1628
+ * (the Model view); hide it in the Types view mode, where the flat path shows
1629
+ * the class-1/2 type library instead. Buffers stay uploaded — just not drawn —
1630
+ * so toggling back is free.
1631
+ */
1632
+ setInstancedVisible(visible) {
1633
+ this.instancedVisible = visible;
1634
+ }
1635
+ /** GPU-instancing templates for the renderer's instanced draw pass. Empty when
1636
+ * hidden (Types view mode) so the draw loop skips it. */
1637
+ getInstancedTemplates() {
1638
+ if (!this.instancedVisible)
1639
+ return EMPTY_INSTANCED_TEMPLATES;
1640
+ return this.instancedTemplates;
1641
+ }
1642
+ /**
1643
+ * Decode a per-batch IFNS instancing shard (`processGeometryBatchInstanced`)
1644
+ * and upload its templates for GPU-instanced drawing. Each unique geometry
1645
+ * becomes a slot-0 vertex buffer (28-byte pos+norm+entityId, matching the flat
1646
+ * layout — the per-vertex entityId is a 0 placeholder; vs_instanced reads the
1647
+ * per-occurrence id) + index buffer, plus a slot-1 per-instance buffer (mat4 +
1648
+ * entityId + rgba) already composed in the WebGL Y-up frame
1649
+ * (`prepareInstancedRender` folds the Z-up→Y-up swap). No-op on a non-shard or
1650
+ * empty payload. Idempotent only in the sense of "append" — call clear() to
1651
+ * reset on a new model.
1652
+ *
1653
+ * Takes an ALREADY-DECODED shard (the worker/main layer that receives the raw
1654
+ * IFNS bytes owns `decodeInstancedShard`), so this module keeps only a
1655
+ * type-only dependency on @ifc-lite/geometry and the Scene stays focused on
1656
+ * GPU upload.
1657
+ */
1658
+ addInstancedShard(device, shard) {
1659
+ this.instancedDevice = device; // cached for per-instance selection/overlay writeBuffer
1660
+ const prepared = prepareInstancedRender(shard);
1661
+ for (const t of prepared) {
1662
+ const vcount = Math.floor(t.positions.length / 3);
1663
+ if (vcount === 0 || t.indices.length === 0 || t.instanceCount === 0)
1664
+ continue;
1665
+ // Interleave the template's local positions + normals into the 28-byte
1666
+ // (pos3f + norm3f + entityId u32) vertex layout the instanced pipeline's
1667
+ // slot 0 expects. entityId is a 0 placeholder (vs_instanced ignores it).
1668
+ const vtx = new ArrayBuffer(vcount * 28);
1669
+ const vf = new Float32Array(vtx);
1670
+ for (let i = 0; i < vcount; i++) {
1671
+ const o = i * 7;
1672
+ vf[o + 0] = t.positions[i * 3 + 0];
1673
+ vf[o + 1] = t.positions[i * 3 + 1];
1674
+ vf[o + 2] = t.positions[i * 3 + 2];
1675
+ // normals may be shorter/absent on degenerate meshes — default to 0.
1676
+ vf[o + 3] = i * 3 + 0 < t.normals.length ? t.normals[i * 3 + 0] : 0;
1677
+ vf[o + 4] = i * 3 + 1 < t.normals.length ? t.normals[i * 3 + 1] : 0;
1678
+ vf[o + 5] = i * 3 + 2 < t.normals.length ? t.normals[i * 3 + 2] : 0;
1679
+ // vf[o + 6] (entityId lane) stays 0.
1680
+ }
1681
+ const vertexBuffer = device.createBuffer({
1682
+ size: vtx.byteLength,
1683
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
1684
+ mappedAtCreation: true,
1685
+ });
1686
+ new Uint8Array(vertexBuffer.getMappedRange()).set(new Uint8Array(vtx));
1687
+ vertexBuffer.unmap();
1688
+ const indexBuffer = device.createBuffer({
1689
+ size: t.indices.byteLength,
1690
+ usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
1691
+ mappedAtCreation: true,
1692
+ });
1693
+ new Uint32Array(indexBuffer.getMappedRange()).set(t.indices);
1694
+ indexBuffer.unmap();
1695
+ // instanceBuffer is already the interleaved mat4 + entityId + rgba block
1696
+ // (INSTANCE_STRIDE_BYTES per occurrence) from prepareInstancedRender.
1697
+ const instSize = t.instanceCount * INSTANCE_STRIDE_BYTES;
1698
+ const instanceBuffer = device.createBuffer({
1699
+ size: instSize,
1700
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
1701
+ mappedAtCreation: true,
1702
+ });
1703
+ new Uint8Array(instanceBuffer.getMappedRange()).set(new Uint8Array(t.instanceBuffer, 0, instSize));
1704
+ instanceBuffer.unmap();
1705
+ const templateIndex = this.instancedTemplates.length;
1706
+ this.instancedTemplates.push({
1707
+ vertexBuffer,
1708
+ indexBuffer,
1709
+ indexCount: t.indices.length,
1710
+ instanceBuffer,
1711
+ instanceCount: t.instanceCount,
1712
+ });
1713
+ // Template-local AABB (used to derive per-occurrence world AABBs cheaply).
1714
+ let lmnx = Infinity, lmny = Infinity, lmnz = Infinity;
1715
+ let lmxx = -Infinity, lmxy = -Infinity, lmxz = -Infinity;
1716
+ for (let i = 0; i < t.positions.length; i += 3) {
1717
+ const x = t.positions[i], y = t.positions[i + 1], z = t.positions[i + 2];
1718
+ if (x < lmnx)
1719
+ lmnx = x;
1720
+ if (y < lmny)
1721
+ lmny = y;
1722
+ if (z < lmnz)
1723
+ lmnz = z;
1724
+ if (x > lmxx)
1725
+ lmxx = x;
1726
+ if (y > lmxy)
1727
+ lmxy = y;
1728
+ if (z > lmxz)
1729
+ lmxz = z;
1730
+ }
1731
+ // Retain the compact CPU geometry + the packed instance records (mat4 per
1732
+ // occurrence) so CPU consumers can reach instanced geometry without a full
1733
+ // per-occurrence MeshData each. These are references into the decoded shard.
1734
+ this.instancedTemplateCpu.push({
1735
+ positions: t.positions,
1736
+ normals: t.normals,
1737
+ indices: t.indices,
1738
+ instanceData: t.instanceBuffer,
1739
+ localMin: [lmnx, lmny, lmnz],
1740
+ localMax: [lmxx, lmxy, lmxz],
1741
+ });
1742
+ // Map each occurrence's express_id -> (template, byte offset, original
1743
+ // colour) so selection (flag byte) + lens/IDS overlays (colour bytes) can
1744
+ // patch individual occurrences via writeBuffer without a re-upload. Also fold
1745
+ // each occurrence's world AABB (template local box × its mat4) into
1746
+ // boundingBoxes so getEntityBoundingBox + the CPU raycast-bounds path
1747
+ // (BCF anchors, large/released-model picking, frame-selection) see instanced
1748
+ // geometry. (Templates with no finite local box — empty geometry — are skipped.)
1749
+ const cdv = new DataView(t.instanceBuffer);
1750
+ const haveBox = Number.isFinite(lmnx);
1751
+ for (let i = 0; i < t.instanceCount; i++) {
1752
+ const byteOffset = i * INSTANCE_STRIDE_BYTES;
1753
+ const eid = t.entityIds[i];
1754
+ const originalColor = [
1755
+ cdv.getFloat32(byteOffset + INSTANCE_COLOR_OFFSET, true),
1756
+ cdv.getFloat32(byteOffset + INSTANCE_COLOR_OFFSET + 4, true),
1757
+ cdv.getFloat32(byteOffset + INSTANCE_COLOR_OFFSET + 8, true),
1758
+ cdv.getFloat32(byteOffset + INSTANCE_COLOR_OFFSET + 12, true),
1759
+ ];
1760
+ let arr = this.instancedEntityMap.get(eid);
1761
+ if (!arr) {
1762
+ arr = [];
1763
+ this.instancedEntityMap.set(eid, arr);
1764
+ }
1765
+ arr.push({ templateIndex, byteOffset, originalColor });
1766
+ if (haveBox) {
1767
+ this.unionInstancedWorldAabb(eid, cdv, byteOffset, lmnx, lmny, lmnz, lmxx, lmxy, lmxz);
1768
+ }
1769
+ }
1770
+ }
1771
+ // New occurrences default to flags=0 (visible). Force the next setInstancedVisibility
1772
+ // to recompute so an already-active isolate/hide also applies to geometry that
1773
+ // streamed in after the visibility was set.
1774
+ this.instancedVisibilityDirty = true;
1775
+ }
1776
+ /** Transform a template's local AABB by an occurrence's column-major mat4 (read
1777
+ * from the packed instance record at `matOffset`) and union the world box into
1778
+ * boundingBoxes[eid]. */
1779
+ unionInstancedWorldAabb(eid, dv, matOffset, lmnx, lmny, lmnz, lmxx, lmxy, lmxz) {
1780
+ const m0 = dv.getFloat32(matOffset + 0, true), m1 = dv.getFloat32(matOffset + 4, true), m2 = dv.getFloat32(matOffset + 8, true);
1781
+ const m4 = dv.getFloat32(matOffset + 16, true), m5 = dv.getFloat32(matOffset + 20, true), m6 = dv.getFloat32(matOffset + 24, true);
1782
+ const m8 = dv.getFloat32(matOffset + 32, true), m9 = dv.getFloat32(matOffset + 36, true), m10 = dv.getFloat32(matOffset + 40, true);
1783
+ const m12 = dv.getFloat32(matOffset + 48, true), m13 = dv.getFloat32(matOffset + 52, true), m14 = dv.getFloat32(matOffset + 56, true);
1784
+ let minX = Infinity, minY = Infinity, minZ = Infinity, maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
1785
+ for (let c = 0; c < 8; c++) {
1786
+ const x = (c & 1) ? lmxx : lmnx, y = (c & 2) ? lmxy : lmny, z = (c & 4) ? lmxz : lmnz;
1787
+ const wx = m0 * x + m4 * y + m8 * z + m12;
1788
+ const wy = m1 * x + m5 * y + m9 * z + m13;
1789
+ const wz = m2 * x + m6 * y + m10 * z + m14;
1790
+ if (wx < minX)
1791
+ minX = wx;
1792
+ if (wy < minY)
1793
+ minY = wy;
1794
+ if (wz < minZ)
1795
+ minZ = wz;
1796
+ if (wx > maxX)
1797
+ maxX = wx;
1798
+ if (wy > maxY)
1799
+ maxY = wy;
1800
+ if (wz > maxZ)
1801
+ maxZ = wz;
1802
+ }
1803
+ const existing = this.boundingBoxes.get(eid);
1804
+ if (existing) {
1805
+ existing.min.x = Math.min(existing.min.x, minX);
1806
+ existing.min.y = Math.min(existing.min.y, minY);
1807
+ existing.min.z = Math.min(existing.min.z, minZ);
1808
+ existing.max.x = Math.max(existing.max.x, maxX);
1809
+ existing.max.y = Math.max(existing.max.y, maxY);
1810
+ existing.max.z = Math.max(existing.max.z, maxZ);
1811
+ }
1812
+ else {
1813
+ this.boundingBoxes.set(eid, { min: { x: minX, y: minY, z: minZ }, max: { x: maxX, y: maxY, z: maxZ } });
1814
+ }
1815
+ }
1816
+ /** True if `expressId` is a GPU-instanced occurrence (lives only in the instanced
1817
+ * shard, not the flat meshDataMap). CPU consumers use this to decide whether to
1818
+ * fall back to the instanced accessors below. */
1819
+ isInstancedEntity(expressId) {
1820
+ return this.instancedEntityMap.has(expressId);
1821
+ }
1822
+ /** All instanced occurrence express_ids (for CPU consumers that enumerate geometry,
1823
+ * e.g. the raycast-engine and exporters). */
1824
+ getInstancedEntityIds() {
1825
+ return this.instancedEntityMap.keys();
1826
+ }
1827
+ /** Materialize EVERY instanced occurrence as world-space MeshData. Transient + not
1828
+ * retained — for one-shot full-geometry consumers (glTF / IFC5 export) that must
1829
+ * include the instanced occurrences absent from geometryResult.meshes. Returns []
1830
+ * when no instanced data is loaded or after geometry release (templates freed). */
1831
+ getAllInstancedMeshData() {
1832
+ const out = [];
1833
+ for (const eid of this.instancedEntityMap.keys()) {
1834
+ const pieces = this.getInstancedMeshDataPieces(eid);
1835
+ if (pieces)
1836
+ out.push(...pieces);
1837
+ }
1838
+ return out;
1839
+ }
1840
+ /** World-space AABB for an instanced occurrence (union over its occurrences),
1841
+ * or null if not instanced. Populated at upload time, so this is O(1). */
1842
+ getInstancedEntityBounds(expressId) {
1843
+ if (!this.instancedEntityMap.has(expressId))
1844
+ return null;
1845
+ return this.boundingBoxes.get(expressId) ?? null;
1846
+ }
1847
+ /** Lazily materialize per-occurrence world-space MeshData for an instanced entity
1848
+ * (template geometry × each occurrence's matrix). NOT retained — built on demand
1849
+ * for CPU consumers that need triangles (exact raycast / measure / section-face /
1850
+ * export). Returns undefined if the id is not instanced. */
1851
+ getInstancedMeshDataPieces(expressId) {
1852
+ const occ = this.instancedEntityMap.get(expressId);
1853
+ if (!occ || occ.length === 0)
1854
+ return undefined;
1855
+ const out = [];
1856
+ for (const o of occ) {
1857
+ const tpl = this.instancedTemplateCpu[o.templateIndex];
1858
+ if (!tpl || tpl.positions.length === 0)
1859
+ continue;
1860
+ const dv = new DataView(tpl.instanceData);
1861
+ const b = o.byteOffset;
1862
+ const m0 = dv.getFloat32(b + 0, true), m1 = dv.getFloat32(b + 4, true), m2 = dv.getFloat32(b + 8, true);
1863
+ const m4 = dv.getFloat32(b + 16, true), m5 = dv.getFloat32(b + 20, true), m6 = dv.getFloat32(b + 24, true);
1864
+ const m8 = dv.getFloat32(b + 32, true), m9 = dv.getFloat32(b + 36, true), m10 = dv.getFloat32(b + 40, true);
1865
+ const m12 = dv.getFloat32(b + 48, true), m13 = dv.getFloat32(b + 52, true), m14 = dv.getFloat32(b + 56, true);
1866
+ const n = tpl.positions.length;
1867
+ const positions = new Float32Array(n);
1868
+ const normals = new Float32Array(tpl.normals.length);
1869
+ for (let i = 0; i < n; i += 3) {
1870
+ const x = tpl.positions[i], y = tpl.positions[i + 1], z = tpl.positions[i + 2];
1871
+ positions[i] = m0 * x + m4 * y + m8 * z + m12;
1872
+ positions[i + 1] = m1 * x + m5 * y + m9 * z + m13;
1873
+ positions[i + 2] = m2 * x + m6 * y + m10 * z + m14;
1874
+ if (i + 2 < tpl.normals.length) {
1875
+ // Rotate normals by the upper-3×3 (instancing transforms are rigid +
1876
+ // uniform scale, so this is correct up to a renormalize).
1877
+ const nx = tpl.normals[i], ny = tpl.normals[i + 1], nz = tpl.normals[i + 2];
1878
+ let rx = m0 * nx + m4 * ny + m8 * nz;
1879
+ let ry = m1 * nx + m5 * ny + m9 * nz;
1880
+ let rz = m2 * nx + m6 * ny + m10 * nz;
1881
+ const len = Math.hypot(rx, ry, rz) || 1;
1882
+ rx /= len;
1883
+ ry /= len;
1884
+ rz /= len;
1885
+ normals[i] = rx;
1886
+ normals[i + 1] = ry;
1887
+ normals[i + 2] = rz;
1888
+ }
1889
+ }
1890
+ const color = [...o.originalColor];
1891
+ out.push({ expressId, positions, normals, indices: tpl.indices, color });
1892
+ }
1893
+ return out.length > 0 ? out : undefined;
1894
+ }
1895
+ /**
1896
+ * Per-instance SELECTION: highlight the occurrences of `expressIds` by setting
1897
+ * their flag byte (bit 0) and clearing the previously-selected ones. The shader
1898
+ * (vs_instanced -> fs_main) applies the blue highlight per occurrence, so no
1899
+ * re-draw is needed. No-op until a shard has been uploaded.
1900
+ */
1901
+ setInstancedSelection(expressIds) {
1902
+ const device = this.instancedDevice;
1903
+ if (!device || this.instancedTemplates.length === 0)
1904
+ return;
1905
+ // Called every render frame from the renderer. Fast-path an UNCHANGED selection
1906
+ // (the common orbit case, especially the empty set) so we skip both the per-frame
1907
+ // writeBuffer loops AND the `new Set(...)` allocation — equal sizes + full
1908
+ // containment ⇒ set equality.
1909
+ let changed = expressIds.size !== this.instancedSelected.size;
1910
+ if (!changed) {
1911
+ for (const eid of expressIds) {
1912
+ if (!this.instancedSelected.has(eid)) {
1913
+ changed = true;
1914
+ break;
1915
+ }
1916
+ }
1917
+ }
1918
+ if (!changed)
1919
+ return;
1920
+ // Re-derive the combined flag lane (selected | hidden) for every occurrence whose
1921
+ // selected-membership flips, so we never clobber the hidden bit.
1922
+ const prev = this.instancedSelected;
1923
+ this.instancedSelected = new Set(expressIds);
1924
+ for (const eid of prev) {
1925
+ if (!expressIds.has(eid))
1926
+ this.writeInstanceFlags(device, eid);
1927
+ }
1928
+ for (const eid of expressIds) {
1929
+ if (!prev.has(eid))
1930
+ this.writeInstanceFlags(device, eid);
1931
+ }
1932
+ }
1933
+ /**
1934
+ * Per-instance VISIBILITY (hide / isolate): set the hidden flag bit on occurrences
1935
+ * that should not render, mirroring the flat path's hiddenIds/isolatedIds filter.
1936
+ * The shader discards hidden occurrences in BOTH the render and pick passes, so they
1937
+ * neither draw nor are pickable. `isolatedIds != null` means "show only these"; any
1938
+ * occurrence not in the set is hidden. Diffed so an unchanged visibility set is a
1939
+ * no-op (no writeBuffer). No-op until a shard has been uploaded.
1940
+ */
1941
+ setInstancedVisibility(hiddenIds, isolatedIds) {
1942
+ const device = this.instancedDevice;
1943
+ if (!device || this.instancedTemplates.length === 0)
1944
+ return;
1945
+ // Called every render frame. The viewer passes stable Set references that only
1946
+ // change when visibility changes, so a reference-equality guard skips the O(N)
1947
+ // set rebuild + allocation during orbit (the common, unchanged case). The dirty
1948
+ // flag forces a recompute after a new shard adds occurrences mid-stream, so an
1949
+ // active isolate/hide also applies to geometry that streams in afterwards.
1950
+ if (!this.instancedVisibilityDirty &&
1951
+ hiddenIds === this.lastInstancedHiddenIds &&
1952
+ isolatedIds === this.lastInstancedIsolatedIds) {
1953
+ return;
1954
+ }
1955
+ this.instancedVisibilityDirty = false;
1956
+ this.lastInstancedHiddenIds = hiddenIds ?? null;
1957
+ this.lastInstancedIsolatedIds = isolatedIds ?? null;
1958
+ const isHidden = (eid) => (hiddenIds != null && hiddenIds.has(eid)) ||
1959
+ (isolatedIds != null && !isolatedIds.has(eid));
1960
+ // Recompute the effective hidden set over all instanced occurrences and diff vs
1961
+ // the current one; only flips touch the GPU buffer.
1962
+ const next = new Set();
1963
+ for (const eid of this.instancedEntityMap.keys()) {
1964
+ if (isHidden(eid))
1965
+ next.add(eid);
1966
+ }
1967
+ // Fast-path: unchanged hidden set → nothing to write.
1968
+ let changed = next.size !== this.instancedHidden.size;
1969
+ if (!changed) {
1970
+ for (const eid of next) {
1971
+ if (!this.instancedHidden.has(eid)) {
1972
+ changed = true;
1973
+ break;
1974
+ }
1975
+ }
1976
+ }
1977
+ if (!changed)
1978
+ return;
1979
+ const prev = this.instancedHidden;
1980
+ this.instancedHidden = next;
1981
+ for (const eid of prev) {
1982
+ if (!next.has(eid))
1983
+ this.writeInstanceFlags(device, eid);
1984
+ }
1985
+ for (const eid of next) {
1986
+ if (!prev.has(eid))
1987
+ this.writeInstanceFlags(device, eid);
1988
+ }
1989
+ }
1990
+ /**
1991
+ * Per-instance COLOUR OVERRIDE (lens / IDS / compare / 4D): patch the colour
1992
+ * bytes of the affected occurrences in place; occurrences dropped from the map
1993
+ * are restored to their original colour. Pass null/empty to clear all.
1994
+ */
1995
+ setInstancedColorOverrides(overrides) {
1996
+ const device = this.instancedDevice;
1997
+ if (!device || this.instancedTemplates.length === 0)
1998
+ return;
1999
+ const next = overrides ?? new Map();
2000
+ for (const eid of this.instancedOverridden) {
2001
+ if (!next.has(eid))
2002
+ this.restoreInstanceColor(device, eid);
2003
+ }
2004
+ let hasTransparent = false;
2005
+ for (const [eid, rgba] of next) {
2006
+ this.writeInstanceColor(device, eid, rgba);
2007
+ // Instanced occurrences are opaque by partition; only an override can drop alpha
2008
+ // below the cutoff (lens-ghost / x-ray / compare). Track it so the renderer runs
2009
+ // the transparent instanced sub-pass only when something is actually translucent.
2010
+ if (rgba[3] < OPAQUE_ALPHA_CUTOFF)
2011
+ hasTransparent = true;
2012
+ }
2013
+ this.instancedOverridden = new Set(next.keys());
2014
+ this.instancedHasTransparent = hasTransparent;
2015
+ }
2016
+ /** True when an active colour override made some instanced occurrence translucent,
2017
+ * so the renderer should run the transparent instanced sub-pass. */
2018
+ hasTransparentInstances() {
2019
+ return this.instancedHasTransparent;
2020
+ }
2021
+ /** Write the combined flag lane (selected | hidden) for every occurrence of `eid`.
2022
+ * Folding both bits here means selection and visibility updates never clobber each
2023
+ * other (they share the one u32 flags lane at INSTANCE_FLAGS_OFFSET). */
2024
+ writeInstanceFlags(device, eid) {
2025
+ const locs = this.instancedEntityMap.get(eid);
2026
+ if (!locs)
2027
+ return;
2028
+ const flags = (this.instancedSelected.has(eid) ? INSTANCE_FLAG_SELECTED : 0) |
2029
+ (this.instancedHidden.has(eid) ? INSTANCE_FLAG_HIDDEN : 0);
2030
+ const data = new Uint32Array([flags >>> 0]);
2031
+ for (const loc of locs) {
2032
+ const buf = this.instancedTemplates[loc.templateIndex]?.instanceBuffer;
2033
+ if (buf)
2034
+ device.queue.writeBuffer(buf, loc.byteOffset + INSTANCE_FLAGS_OFFSET, data);
2035
+ }
2036
+ }
2037
+ writeInstanceColor(device, eid, rgba) {
2038
+ const locs = this.instancedEntityMap.get(eid);
2039
+ if (!locs)
2040
+ return;
2041
+ const data = new Float32Array([rgba[0], rgba[1], rgba[2], rgba[3]]);
2042
+ for (const loc of locs) {
2043
+ const buf = this.instancedTemplates[loc.templateIndex]?.instanceBuffer;
2044
+ if (buf)
2045
+ device.queue.writeBuffer(buf, loc.byteOffset + INSTANCE_COLOR_OFFSET, data);
2046
+ }
2047
+ }
2048
+ restoreInstanceColor(device, eid) {
2049
+ const locs = this.instancedEntityMap.get(eid);
2050
+ if (!locs)
2051
+ return;
2052
+ for (const loc of locs) {
2053
+ const buf = this.instancedTemplates[loc.templateIndex]?.instanceBuffer;
2054
+ if (buf)
2055
+ device.queue.writeBuffer(buf, loc.byteOffset + INSTANCE_COLOR_OFFSET, new Float32Array(loc.originalColor));
2056
+ }
2057
+ }
1555
2058
  /**
1556
2059
  * Build a textured mesh (#961): interleave position+normal+entityId+uv into one
1557
2060
  * vertex buffer, upload the decoded RGBA8 texture, create a sampler honouring
@@ -1578,6 +2081,10 @@ export class Scene {
1578
2081
  const f = new Float32Array(interleaved);
1579
2082
  const u = new Uint32Array(interleaved);
1580
2083
  const entityIds = meshData.entityIds;
2084
+ // Match mergeGeometry's entityId-lane packing so an overlay (lens/IDS/...)
2085
+ // drawn over a textured mesh computes the same z-nudge → depthCompare:'equal'
2086
+ // matches. High 8 bits = colour salt, low 24 = picking id.
2087
+ const saltByte = colorSaltByte(meshData.color);
1581
2088
  for (let i = 0; i < vertexCount; i++) {
1582
2089
  const o = i * 9;
1583
2090
  f[o] = positions[i * 3];
@@ -1586,7 +2093,7 @@ export class Scene {
1586
2093
  f[o + 3] = normals[i * 3] ?? 0;
1587
2094
  f[o + 4] = normals[i * 3 + 1] ?? 0;
1588
2095
  f[o + 5] = normals[i * 3 + 2] ?? 0;
1589
- u[o + 6] = entityIds ? entityIds[i] : meshData.expressId;
2096
+ u[o + 6] = packEntityLane(entityIds ? entityIds[i] : meshData.expressId, saltByte);
1590
2097
  f[o + 7] = uvs[i * 2] ?? 0;
1591
2098
  f[o + 8] = uvs[i * 2 + 1] ?? 0;
1592
2099
  }
@@ -1652,6 +2159,23 @@ export class Scene {
1652
2159
  tm.texture.destroy();
1653
2160
  }
1654
2161
  this.texturedMeshes = [];
2162
+ // GPU-instancing templates own their vertex/index/instance buffers.
2163
+ for (const it of this.instancedTemplates) {
2164
+ it.vertexBuffer.destroy();
2165
+ it.indexBuffer.destroy();
2166
+ it.instanceBuffer.destroy();
2167
+ }
2168
+ this.instancedTemplates = [];
2169
+ this.instancedTemplateCpu = [];
2170
+ this.instancedEntityMap.clear();
2171
+ this.instancedSelected.clear();
2172
+ this.instancedHidden.clear();
2173
+ this.instancedOverridden.clear();
2174
+ this.instancedHasTransparent = false;
2175
+ this.lastInstancedHiddenIds = null;
2176
+ this.lastInstancedIsolatedIds = null;
2177
+ this.instancedVisibilityDirty = false;
2178
+ this.instancedDevice = undefined;
1655
2179
  // Clear partial batch cache
1656
2180
  for (const batch of this.partialBatchCache.values())
1657
2181
  destroyGpuResources(batch);
@@ -1754,9 +2278,17 @@ export class Scene {
1754
2278
  */
1755
2279
  getAllMeshDataExpressIds() {
1756
2280
  if (this.geometryReleased) {
2281
+ // boundingBoxes already includes instanced occurrences (their AABBs are
2282
+ // stored there at upload), so the released path needs no extra union.
1757
2283
  return Array.from(this.boundingBoxes.keys());
1758
2284
  }
1759
- return Array.from(this.meshDataMap.keys());
2285
+ // Union instanced-only occurrences (absent from meshDataMap) so CPU consumers
2286
+ // enumerating geometry see them too. IDs only — no geometry materialized.
2287
+ // (#1238 review)
2288
+ const ids = new Set(this.meshDataMap.keys());
2289
+ for (const eid of this.instancedEntityMap.keys())
2290
+ ids.add(eid);
2291
+ return Array.from(ids);
1760
2292
  }
1761
2293
  /**
1762
2294
  * Get or compute bounding box for an entity from its mesh vertex data.
@@ -1819,7 +2351,34 @@ export class Scene {
1819
2351
  return raycastBoundingBoxes(rayOrigin, rayDirInv, rayDirSign, this.boundingBoxes, hiddenIds, isolatedIds);
1820
2352
  }
1821
2353
  // Full triangle-level raycast with bounding-box pre-filter
1822
- return raycastTriangles(rayOrigin, rayDir, rayDirInv, rayDirSign, this.meshDataMap, (id) => this.getEntityBoundingBox(id), hiddenIds, isolatedIds);
2354
+ const flatHit = raycastTriangles(rayOrigin, rayDir, rayDirInv, rayDirSign, this.meshDataMap, (id) => this.getEntityBoundingBox(id), hiddenIds, isolatedIds);
2355
+ // Instanced-only occurrences live in the shard, not meshDataMap, so the CPU
2356
+ // pick fallback would miss them. Materialize triangles lazily ONLY for
2357
+ // entities whose world AABB the ray actually hits (never the whole instanced
2358
+ // population), then return whichever hit is closer. (#1238 review)
2359
+ let instancedHit = null;
2360
+ if (this.instancedEntityMap.size > 0) {
2361
+ const instancedMap = new Map();
2362
+ for (const eid of this.instancedEntityMap.keys()) {
2363
+ if (hiddenIds?.has(eid))
2364
+ continue;
2365
+ if (isolatedIds != null && !isolatedIds.has(eid))
2366
+ continue;
2367
+ const bounds = this.getInstancedEntityBounds(eid);
2368
+ if (!bounds || !rayIntersectsBox(rayOrigin, rayDirInv, rayDirSign, bounds))
2369
+ continue;
2370
+ const pieces = this.getInstancedMeshDataPieces(eid);
2371
+ if (pieces && pieces.length > 0)
2372
+ instancedMap.set(eid, pieces);
2373
+ }
2374
+ if (instancedMap.size > 0) {
2375
+ instancedHit = raycastTriangles(rayOrigin, rayDir, rayDirInv, rayDirSign, instancedMap, (id) => this.getInstancedEntityBounds(id), hiddenIds, isolatedIds);
2376
+ }
2377
+ }
2378
+ if (flatHit && instancedHit) {
2379
+ return instancedHit.distance < flatHit.distance ? instancedHit : flatHit;
2380
+ }
2381
+ return flatHit ?? instancedHit;
1823
2382
  }
1824
2383
  }
1825
2384
  //# sourceMappingURL=scene.js.map