@ifc-lite/renderer 2.0.0 → 2.2.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 (177) hide show
  1. package/README.md +10 -0
  2. package/dist/appearance-batch-cohorts.d.ts +33 -0
  3. package/dist/appearance-batch-cohorts.d.ts.map +1 -0
  4. package/dist/appearance-batch-cohorts.js +126 -0
  5. package/dist/appearance-batch-cohorts.js.map +1 -0
  6. package/dist/appearance-companions.d.ts +30 -0
  7. package/dist/appearance-companions.d.ts.map +1 -0
  8. package/dist/appearance-companions.js +138 -0
  9. package/dist/appearance-companions.js.map +1 -0
  10. package/dist/appearance-preview.d.ts +93 -0
  11. package/dist/appearance-preview.d.ts.map +1 -0
  12. package/dist/appearance-preview.js +246 -0
  13. package/dist/appearance-preview.js.map +1 -0
  14. package/dist/appearance-uvs.d.ts +10 -0
  15. package/dist/appearance-uvs.d.ts.map +1 -0
  16. package/dist/appearance-uvs.js +112 -0
  17. package/dist/appearance-uvs.js.map +1 -0
  18. package/dist/bvh.d.ts.map +1 -1
  19. package/dist/bvh.js +3 -0
  20. package/dist/bvh.js.map +1 -1
  21. package/dist/deviation/deviation-computer.js +1 -1
  22. package/dist/deviation/deviation-computer.js.map +1 -1
  23. package/dist/index.d.ts +60 -18
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +184 -232
  26. package/dist/index.js.map +1 -1
  27. package/dist/lod-simplify.d.ts.map +1 -1
  28. package/dist/lod-simplify.js +24 -2
  29. package/dist/lod-simplify.js.map +1 -1
  30. package/dist/merged-mesh-extract.d.ts +18 -0
  31. package/dist/merged-mesh-extract.d.ts.map +1 -0
  32. package/dist/merged-mesh-extract.js +73 -0
  33. package/dist/merged-mesh-extract.js.map +1 -0
  34. package/dist/model-bounds-tracker.d.ts +1 -1
  35. package/dist/model-bounds-tracker.d.ts.map +1 -1
  36. package/dist/model-bounds-tracker.js +3 -2
  37. package/dist/model-bounds-tracker.js.map +1 -1
  38. package/dist/model-placement-bounds.d.ts +29 -0
  39. package/dist/model-placement-bounds.d.ts.map +1 -0
  40. package/dist/model-placement-bounds.js +55 -0
  41. package/dist/model-placement-bounds.js.map +1 -0
  42. package/dist/model-translation.d.ts +57 -0
  43. package/dist/model-translation.d.ts.map +1 -0
  44. package/dist/model-translation.js +223 -0
  45. package/dist/model-translation.js.map +1 -0
  46. package/dist/partial-batch-cache.d.ts +65 -0
  47. package/dist/partial-batch-cache.d.ts.map +1 -0
  48. package/dist/partial-batch-cache.js +88 -0
  49. package/dist/partial-batch-cache.js.map +1 -0
  50. package/dist/partial-batch-requests.d.ts +77 -0
  51. package/dist/partial-batch-requests.d.ts.map +1 -0
  52. package/dist/partial-batch-requests.js +97 -0
  53. package/dist/partial-batch-requests.js.map +1 -0
  54. package/dist/picking-manager.js +2 -2
  55. package/dist/picking-manager.js.map +1 -1
  56. package/dist/point-picker.d.ts +2 -2
  57. package/dist/point-picker.d.ts.map +1 -1
  58. package/dist/point-picker.js +27 -21
  59. package/dist/point-picker.js.map +1 -1
  60. package/dist/pointcloud/point-cloud-node.d.ts +2 -0
  61. package/dist/pointcloud/point-cloud-node.d.ts.map +1 -1
  62. package/dist/pointcloud/point-cloud-node.js +12 -0
  63. package/dist/pointcloud/point-cloud-node.js.map +1 -1
  64. package/dist/pointcloud/point-cloud-placement.d.ts +20 -0
  65. package/dist/pointcloud/point-cloud-placement.d.ts.map +1 -0
  66. package/dist/pointcloud/point-cloud-placement.js +69 -0
  67. package/dist/pointcloud/point-cloud-placement.js.map +1 -0
  68. package/dist/pointcloud/point-cloud-renderer.d.ts +14 -14
  69. package/dist/pointcloud/point-cloud-renderer.d.ts.map +1 -1
  70. package/dist/pointcloud/point-cloud-renderer.js +47 -63
  71. package/dist/pointcloud/point-cloud-renderer.js.map +1 -1
  72. package/dist/raycast-engine.d.ts +9 -11
  73. package/dist/raycast-engine.d.ts.map +1 -1
  74. package/dist/raycast-engine.js +6 -11
  75. package/dist/raycast-engine.js.map +1 -1
  76. package/dist/raycaster.d.ts.map +1 -1
  77. package/dist/raycaster.js +3 -0
  78. package/dist/raycaster.js.map +1 -1
  79. package/dist/reference-image-hit.d.ts +5 -0
  80. package/dist/reference-image-hit.d.ts.map +1 -0
  81. package/dist/reference-image-hit.js +28 -0
  82. package/dist/reference-image-hit.js.map +1 -0
  83. package/dist/reference-image-host.d.ts +5 -0
  84. package/dist/reference-image-host.d.ts.map +1 -0
  85. package/dist/reference-image-host.js +26 -0
  86. package/dist/reference-image-host.js.map +1 -0
  87. package/dist/reference-image-pipeline.d.ts +15 -0
  88. package/dist/reference-image-pipeline.d.ts.map +1 -0
  89. package/dist/reference-image-pipeline.js +104 -0
  90. package/dist/reference-image-pipeline.js.map +1 -0
  91. package/dist/reference-image-types.d.ts +27 -0
  92. package/dist/reference-image-types.d.ts.map +1 -0
  93. package/dist/reference-image-types.js +5 -0
  94. package/dist/reference-image-types.js.map +1 -0
  95. package/dist/reference-images.d.ts +27 -0
  96. package/dist/reference-images.d.ts.map +1 -0
  97. package/dist/reference-images.js +134 -0
  98. package/dist/reference-images.js.map +1 -0
  99. package/dist/renderer-viewport.d.ts +3 -0
  100. package/dist/renderer-viewport.d.ts.map +1 -0
  101. package/dist/renderer-viewport.js +20 -0
  102. package/dist/renderer-viewport.js.map +1 -0
  103. package/dist/rgba-texture-pool.d.ts +12 -0
  104. package/dist/rgba-texture-pool.d.ts.map +1 -0
  105. package/dist/rgba-texture-pool.js +51 -0
  106. package/dist/rgba-texture-pool.js.map +1 -0
  107. package/dist/scene-appearance-buckets.d.ts +41 -0
  108. package/dist/scene-appearance-buckets.d.ts.map +1 -0
  109. package/dist/scene-appearance-buckets.js +164 -0
  110. package/dist/scene-appearance-buckets.js.map +1 -0
  111. package/dist/scene-appearance-instances.d.ts +42 -0
  112. package/dist/scene-appearance-instances.d.ts.map +1 -0
  113. package/dist/scene-appearance-instances.js +143 -0
  114. package/dist/scene-appearance-instances.js.map +1 -0
  115. package/dist/scene-appearance-preview.d.ts +30 -0
  116. package/dist/scene-appearance-preview.d.ts.map +1 -0
  117. package/dist/scene-appearance-preview.js +149 -0
  118. package/dist/scene-appearance-preview.js.map +1 -0
  119. package/dist/scene-appearance-resources.d.ts +16 -0
  120. package/dist/scene-appearance-resources.d.ts.map +1 -0
  121. package/dist/scene-appearance-resources.js +41 -0
  122. package/dist/scene-appearance-resources.js.map +1 -0
  123. package/dist/scene-authored-owner.d.ts +9 -0
  124. package/dist/scene-authored-owner.d.ts.map +1 -0
  125. package/dist/scene-authored-owner.js +73 -0
  126. package/dist/scene-authored-owner.js.map +1 -0
  127. package/dist/scene-batch-upload.d.ts +12 -0
  128. package/dist/scene-batch-upload.d.ts.map +1 -0
  129. package/dist/scene-batch-upload.js +147 -0
  130. package/dist/scene-batch-upload.js.map +1 -0
  131. package/dist/scene-contents.d.ts +41 -0
  132. package/dist/scene-contents.d.ts.map +1 -1
  133. package/dist/scene-geometry.d.ts +16 -0
  134. package/dist/scene-geometry.d.ts.map +1 -1
  135. package/dist/scene-geometry.js +26 -0
  136. package/dist/scene-geometry.js.map +1 -1
  137. package/dist/scene-instance-materialization.d.ts +17 -0
  138. package/dist/scene-instance-materialization.d.ts.map +1 -0
  139. package/dist/scene-instance-materialization.js +52 -0
  140. package/dist/scene-instance-materialization.js.map +1 -0
  141. package/dist/scene-instance-suppression.d.ts +22 -0
  142. package/dist/scene-instance-suppression.d.ts.map +1 -0
  143. package/dist/scene-instance-suppression.js +100 -0
  144. package/dist/scene-instance-suppression.js.map +1 -0
  145. package/dist/scene-instance-types.d.ts +55 -0
  146. package/dist/scene-instance-types.d.ts.map +1 -0
  147. package/dist/scene-instance-types.js +2 -0
  148. package/dist/scene-instance-types.js.map +1 -0
  149. package/dist/scene-model-translation.d.ts +49 -0
  150. package/dist/scene-model-translation.d.ts.map +1 -0
  151. package/dist/scene-model-translation.js +100 -0
  152. package/dist/scene-model-translation.js.map +1 -0
  153. package/dist/scene-stream-split.d.ts +4 -0
  154. package/dist/scene-stream-split.d.ts.map +1 -0
  155. package/dist/scene-stream-split.js +54 -0
  156. package/dist/scene-stream-split.js.map +1 -0
  157. package/dist/scene.d.ts +105 -85
  158. package/dist/scene.d.ts.map +1 -1
  159. package/dist/scene.js +445 -581
  160. package/dist/scene.js.map +1 -1
  161. package/dist/shadow-occluders.d.ts.map +1 -1
  162. package/dist/shadow-occluders.js +13 -9
  163. package/dist/shadow-occluders.js.map +1 -1
  164. package/dist/snap-detector.d.ts.map +1 -1
  165. package/dist/snap-detector.js +3 -0
  166. package/dist/snap-detector.js.map +1 -1
  167. package/dist/textured-vertices.d.ts +9 -0
  168. package/dist/textured-vertices.d.ts.map +1 -0
  169. package/dist/textured-vertices.js +40 -0
  170. package/dist/textured-vertices.js.map +1 -0
  171. package/dist/types.d.ts +11 -9
  172. package/dist/types.d.ts.map +1 -1
  173. package/dist/xray-alpha.d.ts +131 -0
  174. package/dist/xray-alpha.d.ts.map +1 -0
  175. package/dist/xray-alpha.js +266 -0
  176. package/dist/xray-alpha.js.map +1 -0
  177. package/package.json +4 -4
package/dist/scene.js CHANGED
@@ -1,30 +1,32 @@
1
1
  /* This Source Code Form is subject to the terms of the Mozilla Public
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
+ import { materializeInstances } from './scene-instance-materialization.js';
5
+ import { InstanceSuppression } from './scene-instance-suppression.js';
6
+ import { createSceneBatch } from './scene-batch-upload.js';
7
+ import { createSceneAppearancePreview } from './scene-appearance-preview.js';
8
+ import { AppearanceBuckets } from './scene-appearance-buckets.js';
9
+ import { prepareSceneAuthoredOwner } from './scene-authored-owner.js';
10
+ import { interleaveTexturedVertices } from './textured-vertices.js';
11
+ import { RgbaTexturePool } from './rgba-texture-pool.js';
12
+ import { splitMeshForStreaming } from './scene-stream-split.js';
4
13
  import { BATCH_CONSTANTS } from './constants.js';
5
14
  import { prepareRayDirInv, raycastBoundingBoxes, raycastTriangles, rayIntersectsBox, } from './scene-raycaster.js';
6
15
  import { selectBoundingBoxesInRect } from './scene-rect-select.js';
7
- import { mergeGeometry, splitMeshDataForBufferLimit, colorSaltByte, packEntityLane, worldAabbFromPieces } from './scene-geometry.js';
16
+ import { splitMeshDataForBufferLimit, cachedWorldAabb, worldAabbFromPieces, destroyGpuResources } from './scene-geometry.js';
8
17
  import { sumResidentGpuBytes } from './render-stats.js';
9
18
  import { composeInstancedOverrideColor } from './instanced-override-color.js';
10
- import { simplifyIndicesByClustering, lodCellSizeForBounds, LOD_MIN_TRIANGLES } from './lod-simplify.js';
11
- import { quantizeInterleaved } from './quantize.js';
12
19
  import { bucketBaseKeyFor } from './chunk-grid.js';
13
20
  import { VisibilityEpochTracker } from './visibility-epoch.js';
14
21
  import { isEntityVisible } from './entity-visibility.js';
15
22
  import { planInstancedGhosting } from './instanced-ghost-plan.js';
16
23
  import { selectEvictions } from './residency.js';
17
24
  import { OPAQUE_ALPHA_CUTOFF } from './overlay-routing.js';
25
+ import { translateSceneModel, releaseInstanceVertices, refreshTexturedBounds } from './scene-model-translation.js';
26
+ import { ModelTranslations } from './model-translation.js';
27
+ import { extractEntityFromMergedMesh } from './merged-mesh-extract.js';
28
+ import { dropAllPartialCaches as dropAllPartialCachesIn, dropPartialCacheForBatch as dropPartialCacheForBatchIn, retireUnusedAlphaSlots as retireUnusedAlphaSlotsIn, } from './partial-batch-cache.js';
18
29
  import { prepareInstancedRender, foldOccurrenceWorldBox, INSTANCE_STRIDE_BYTES, INSTANCE_COLOR_OFFSET, INSTANCE_FLAGS_OFFSET, INSTANCE_FLAG_SELECTED, INSTANCE_FLAG_HIDDEN, } from './instanced-render.js';
19
- function destroyGpuResources(m) {
20
- m.vertexBuffer.destroy();
21
- m.indexBuffer.destroy();
22
- if (m.uniformBuffer)
23
- m.uniformBuffer.destroy();
24
- if (m.lod1IndexBuffer)
25
- m.lod1IndexBuffer.destroy();
26
- }
27
- /** Shared empty result for getInstancedTemplates() when the instanced pass is hidden. */
28
30
  const EMPTY_INSTANCED_TEMPLATES = [];
29
31
  /**
30
32
  * Pure helper: compute the exclusive end index of the next flushPending()
@@ -80,6 +82,7 @@ export class Scene {
80
82
  batchedMeshes = []; // flat render array (rebuilt from buckets)
81
83
  buckets = new Map(); // bucketKey -> consolidated bucket state
82
84
  meshDataBucket = new Map(); // reverse lookup: MeshData -> owning bucket
85
+ modelTranslations = new ModelTranslations();
83
86
  meshDataMap = new Map(); // Map expressId -> MeshData[] (for lazy buffer creation, accumulates multiple pieces)
84
87
  boundingBoxes = new Map(); // Map expressId -> bounding box (computed lazily)
85
88
  texturedMeshes = []; // #961: IFC surface-textured meshes (own buffers/texture/bindGroup)
@@ -87,6 +90,74 @@ export class Scene {
87
90
  * (one `IfcImageTexture` → one upload, sampled by every face set mapping it).
88
91
  * Refcounted: entries die when the last referencing mesh is removed / on clear(). */
89
92
  sharedTextures = new Map();
93
+ rgbaTexturePool = new RgbaTexturePool();
94
+ appearanceController;
95
+ appearanceBuckets;
96
+ authoredGeneration = 0;
97
+ appearanceAccess(device, pipeline) {
98
+ return {
99
+ meshes: () => this.texturedMeshes, data: this.meshDataMap,
100
+ instances: {
101
+ has: id => this.instancedEntityMap.has(id),
102
+ acquire: owner => this.retainInstancedOccurrence(owner.expressId, owner.modelIndex),
103
+ pieces: id => this.getInstancedMeshDataPieces(id), remove: id => { this.removeInstancedEntity(id); },
104
+ capacity: id => {
105
+ const occurrences = this.instancedEntityMap.get(id) ?? [];
106
+ return { parts: occurrences.length,
107
+ vertices: occurrences.reduce((sum, entry) => sum + (this.instancedTemplateCpu[entry.templateIndex]?.positions.length ?? 0) / 3, 0),
108
+ corners: occurrences.reduce((sum, entry) => sum + (this.instancedTemplateCpu[entry.templateIndex]?.indices.length ?? 0), 0) };
109
+ },
110
+ },
111
+ source: part => this.modelTranslations.sourceFromPlaced(part),
112
+ ready: () => !this.geometryReleased && !this.finalizeInProgress && !this.pendingBatchKeys.size && !this.streamingFragments.length,
113
+ buckets: {
114
+ buckets: this.buckets, reverse: () => this.meshDataBucket,
115
+ create: (parts, key) => this.createBatchedMesh(parts, parts[0].color, device, pipeline, key),
116
+ release: batch => { this.dropPartialCacheForBatch(batch); this.lastDrawnFrame.delete(batch.id); destroyGpuResources(batch); },
117
+ changed: key => this.markBucketDirty(key),
118
+ refresh: () => { this.batchedMeshes = [...this.buckets.values()].flatMap(b => b.batchedMesh ? [b.batchedMesh] : []); },
119
+ },
120
+ adopt: part => this.modelTranslations.placeMesh(this.modelTranslations.sourceFromPlaced(part)),
121
+ upload: part => this.createTexturedMesh(part, device, pipeline),
122
+ release: mesh => {
123
+ mesh.vertexBuffer.destroy();
124
+ mesh.indexBuffer.destroy();
125
+ mesh.uniformBuffer.destroy();
126
+ this.releaseTexturedMeshTexture(mesh);
127
+ },
128
+ invalidate: id => {
129
+ this.boundingBoxes.delete(id);
130
+ this.evictHighlightMeshes(id);
131
+ if (!this.instanceSuppression.has(id))
132
+ this.recomputeInstancedBounds(id);
133
+ },
134
+ };
135
+ }
136
+ sharedAppearanceBuckets(access) {
137
+ return this.appearanceBuckets ??= new AppearanceBuckets(access.buckets, id => this.meshDataMap.get(id));
138
+ }
139
+ appearancePreview(device, pipeline) {
140
+ const access = this.appearanceAccess(device, pipeline);
141
+ return this.appearanceController ??= createSceneAppearancePreview(access, this.sharedAppearanceBuckets(access));
142
+ }
143
+ /** Place canonical native appearance source geometry in this model's live frame. */
144
+ placeAppearanceSource(mesh) { return this.modelTranslations.placeMesh(mesh); }
145
+ /** Retain model-local source coordinates when publishing a placed appearance mesh. */
146
+ appearanceSourceMesh(mesh) { return this.modelTranslations.sourceFromPlaced(mesh); }
147
+ /** Stage one new IFC owner with all its coloured or textured geometry parts. */
148
+ prepareAuthoredOwner(parts, device, pipeline) {
149
+ const access = this.appearanceAccess(device, pipeline), generation = this.authoredGeneration;
150
+ return prepareSceneAuthoredOwner(access, this.sharedAppearanceBuckets(access), parts, () => {
151
+ if (generation !== this.authoredGeneration)
152
+ throw new Error('The scene changed while preparing the object.');
153
+ });
154
+ }
155
+ /** Compatibility entry point for an image-backed single-part owner. */
156
+ prepareTexturedOwner(mesh, device, pipeline) {
157
+ if (!Scene.hasRenderableTexture(mesh))
158
+ throw new Error('A new textured owner requires an image and UVs.');
159
+ return this.prepareAuthoredOwner([mesh], device, pipeline);
160
+ }
90
161
  texturedDevice; // #961: cached for textured-mesh re-upload on translate
91
162
  /** GPU-instancing: unique templates + per-occurrence buffers (fed by
92
163
  * addInstancedShard). SLOT-STABLE and therefore SPARSE: a per-model removal
@@ -107,6 +178,16 @@ export class Scene {
107
178
  instancedTemplateCpu = [];
108
179
  instancedDevice; // cached for per-instance flag/colour writeBuffer updates
109
180
  instancedSelected = new Set(); // currently flag-selected instanced express_ids
181
+ instancedSelectedItemExpressId;
182
+ instancedSelectedItemId; // #4382: RenderOptions.selectedItemId
183
+ instanceSuppression = new InstanceSuppression((id) => {
184
+ if (this.instancedDevice)
185
+ this.writeInstanceFlags(this.instancedDevice, id);
186
+ this.boundingBoxes.delete(id);
187
+ if (!this.instanceSuppression.has(id))
188
+ this.recomputeInstancedBounds(id);
189
+ this.evictHighlightMeshes(id);
190
+ });
110
191
  instancedHidden = new Set(); // currently hidden instanced express_ids (hide/isolate)
111
192
  instancedOverridden = new Set(); // currently colour-overridden instanced express_ids
112
193
  instancedGhosted = new Set(); // currently X-Ray ghosted instanced express_ids
@@ -170,16 +251,14 @@ export class Scene {
170
251
  finalizeInProgress = false;
171
252
  nextSplitId = 0; // Monotonic counter for sub-bucket keys
172
253
  nextBatchId = 0; // Monotonic counter for unique batch identifiers
173
- // Shared local-frame origin for ALL batches (set from the first batch's world
174
- // bbox centre). Every batch stores positions relative to it and draws with
175
- // model = translate(sharedFrameOrigin), so coincident faces across batches
176
- // stay bit-coincident (no seam z-fight) while f32 vertex coords stay small.
177
- sharedFrameOrigin = null;
254
+ // Per-model shared origins keep all colours and highlights bit-coincident
255
+ // without narrowing the distance between federated models into f32 vertices.
256
+ sharedFrameOrigins = new Map();
178
257
  cachedMaxBufferSize = 0; // device.limits.maxBufferSize * safety factor (set on first use)
179
258
  static STREAMING_FRAGMENT_MAX_INDICES = 180_000;
180
259
  static STREAMING_FRAGMENT_MAX_VERTEX_BYTES = 8 * 1024 * 1024;
181
260
  // Sub-batch cache for partially visible batches (PERFORMANCE FIX)
182
- // Key = colorKey + ":" + sorted visible expressIds hash
261
+ // Key = requesting slot's sourceBatchKey + ":" + sorted visible expressIds hash
183
262
  // This allows rendering partially visible batches as single draw calls instead of 10,000+ individual draws
184
263
  partialBatchCache = new Map();
185
264
  partialBatchCacheKeys = new Map(); // sourceBatchKey -> current cache key (for invalidation)
@@ -188,6 +267,14 @@ export class Scene {
188
267
  // re-sorting + re-hashing every visible id each frame while the epoch holds
189
268
  // (issue: O(elements) per-frame work under hide/isolate). See render loop.
190
269
  partialBatchCacheVersions = new Map();
270
+ /** The three maps above as one record, so the eviction paths in
271
+ * `partial-batch-cache.ts` can keep them consistent together. Same Map
272
+ * objects, not copies — they are only ever cleared, never reassigned. */
273
+ partialCaches = {
274
+ batches: this.partialBatchCache,
275
+ keys: this.partialBatchCacheKeys,
276
+ versions: this.partialBatchCacheVersions,
277
+ };
191
278
  // Color overlay system for lens coloring — NEVER modifies original batches.
192
279
  // Overlay batches render on top using depthCompare 'equal', so they only
193
280
  // paint where original geometry already wrote depth. Clearing is instant.
@@ -221,6 +308,7 @@ export class Scene {
221
308
  * Add mesh to scene
222
309
  */
223
310
  addMesh(mesh) {
311
+ this.modelTranslations.placeAuthoredMesh(mesh);
224
312
  this.meshes.push(mesh);
225
313
  }
226
314
  /**
@@ -238,8 +326,8 @@ export class Scene {
238
326
  /** The shared local-frame origin all batches relativize against (null until
239
327
  * the first batch is built). Per-mesh highlight/picker VBOs replicate the
240
328
  * batch's exact f32 path against this so they render bit-coincident. */
241
- getSharedFrameOrigin() {
242
- return this.sharedFrameOrigin;
329
+ getSharedFrameOrigin(modelIndex = 0) {
330
+ return this.modelTranslations.frameOrigin(this.sharedFrameOrigins.get(modelIndex) ?? null, modelIndex) ?? null;
243
331
  }
244
332
  /**
245
333
  * Enable/disable spatial chunk bucketing (issue #1682 phase 2). When set,
@@ -528,8 +616,9 @@ export class Scene {
528
616
  const provider = this.coldProvider;
529
617
  if (!bucket || !shell || !shell.bounds || !provider)
530
618
  return;
619
+ const sourceBounds = this.modelTranslations.sourceDrawableBounds(shell);
531
620
  const promise = provider
532
- .loadMeshesInBounds(shell.bounds.min, shell.bounds.max)
621
+ .loadMeshesInBounds(sourceBounds.min, sourceBounds.max)
533
622
  .then((meshes) => {
534
623
  // Re-validate: a clear()/finalize may have replaced the world.
535
624
  const current = this.buckets.get(key);
@@ -538,7 +627,8 @@ export class Scene {
538
627
  const baseKey = this.baseColorKey(key);
539
628
  const idSet = new Set(shell.expressIds);
540
629
  const members = meshes.filter((m) => idSet.has(m.expressId) && this.bucketBaseKey(m) === baseKey);
541
- for (const m of members) {
630
+ for (const source of members) {
631
+ const m = this.modelTranslations.placeMesh(source);
542
632
  bucket.meshData.push(m);
543
633
  bucket.vertexBytes += (m.positions.length / 3) * BATCH_CONSTANTS.BYTES_PER_VERTEX;
544
634
  this.meshDataBucket.set(m, bucket);
@@ -629,6 +719,8 @@ export class Scene {
629
719
  continue; // drawn this frame: not evictable
630
720
  if (bucket.meshData.length === 0)
631
721
  continue; // no rebuild source: keep resident
722
+ if (bucket.meshData.some(part => this.appearanceController?.owns(part.expressId)))
723
+ continue;
632
724
  shells.push({ key: bucket.key, bytes, lastDrawnFrame: lastDrawn });
633
725
  }
634
726
  if (residentBytes <= budget)
@@ -658,38 +750,19 @@ export class Scene {
658
750
  * sourceBatchKeys embed the batch id, so they are stale once it is
659
751
  * evicted/replaced). */
660
752
  dropPartialCacheForBatch(batch) {
661
- const prefix = `${batch.colorKey}:${batch.id}`;
662
- for (const [sourceBatchKey, cacheKey] of this.partialBatchCacheKeys) {
663
- if (!sourceBatchKey.startsWith(prefix))
664
- continue;
665
- const cached = this.partialBatchCache.get(cacheKey);
666
- if (cached) {
667
- destroyGpuResources(cached);
668
- this.partialBatchCache.delete(cacheKey);
669
- }
670
- this.partialBatchCacheKeys.delete(sourceBatchKey);
671
- this.partialBatchCacheVersions.delete(sourceBatchKey);
672
- }
753
+ dropPartialCacheForBatchIn(this.partialCaches, batch);
673
754
  }
674
- /** Destroy + drop EVERY cached partial sub-batch. The clones built during
675
- * hide/isolate are deliberately excluded from the GPU residency budget and
676
- * are otherwise only freed on clear()/finalize/evict — never when filtering
677
- * ends. The render loop calls this on the transition back to fully-visible so
678
- * the ~model-sized clone VRAM is not pinned until the next model reload. Uses
679
- * the same destroy-then-clear idiom as clear(); safe to call between frames
680
- * because the previous frame is already submitted (WebGPU defers the free
681
- * past in-flight work). */
755
+ /** Destroy + drop EVERY cached partial sub-batch called on the transition
756
+ * back to "no filtering, no X-Ray" so their VRAM is not pinned until the
757
+ * next model reload. See `partial-batch-cache.ts`. */
682
758
  dropAllPartialCaches() {
683
- if (this.partialBatchCache.size === 0
684
- && this.partialBatchCacheKeys.size === 0
685
- && this.partialBatchCacheVersions.size === 0) {
686
- return;
687
- }
688
- for (const batch of this.partialBatchCache.values())
689
- destroyGpuResources(batch);
690
- this.partialBatchCache.clear();
691
- this.partialBatchCacheKeys.clear();
692
- this.partialBatchCacheVersions.clear();
759
+ dropAllPartialCachesIn(this.partialCaches);
760
+ }
761
+ /** Free the X-Ray alpha-split slots this frame did not request, so an X-Ray
762
+ * edit that un-splits a batch cannot pin its clones for the session (#4129
763
+ * review). See `partial-batch-cache.ts` for why the sweep is scoped. */
764
+ retireUnusedAlphaSlots(inUse) {
765
+ retireUnusedAlphaSlotsIn(this.partialCaches, inUse);
693
766
  }
694
767
  /** Free the hydrated (pick / selection-highlight) individual meshes that are
695
768
  * no longer selected, destroying their GPU buffers and dropping them from
@@ -700,15 +773,19 @@ export class Scene {
700
773
  * express ids, and an id-only check would strand the OTHER model's hydrated
701
774
  * mesh resident and drawing when selection moves across models. Only meshes
702
775
  * flagged `hydrated` are touched — authored geometry added via addMesh()
703
- * and batch geometry are left untouched. Returns how many were freed. */
704
- disposeHydratedMeshesExcept(keep, keepModelIndex) {
776
+ * and batch geometry are left untouched. Returns how many were freed.
777
+ * #4382: a hydrated mesh of `itemFilterExpressId` must ALSO match
778
+ * `itemFilterItemId`'s `geometryItemId` to survive (frees a stale item on
779
+ * an in-product item switch); other kept expressIds are unaffected. */
780
+ disposeHydratedMeshesExcept(keep, keepModelIndex, itemFilterExpressId, itemFilterItemId) {
705
781
  if (this.meshes.length === 0)
706
782
  return 0;
707
783
  const kept = [];
708
784
  let disposed = 0;
709
785
  for (const mesh of this.meshes) {
710
786
  const keepMesh = keep.has(mesh.expressId)
711
- && (keepModelIndex === undefined || mesh.modelIndex === keepModelIndex);
787
+ && (keepModelIndex === undefined || mesh.modelIndex === keepModelIndex)
788
+ && (itemFilterExpressId === undefined || mesh.expressId !== itemFilterExpressId || mesh.geometryItemId === itemFilterItemId);
712
789
  if (mesh.hydrated && !keepMesh) {
713
790
  destroyGpuResources(mesh);
714
791
  disposed++;
@@ -730,7 +807,9 @@ export class Scene {
730
807
  * recolour routing.
731
808
  */
732
809
  bucketBaseKey(meshData, color) {
733
- return bucketBaseKeyFor(meshData, this.colorKey(color ?? meshData.color), this.spatialChunking);
810
+ const source = this.modelTranslations.sourceMesh(meshData);
811
+ const key = bucketBaseKeyFor(source, this.colorKey(color ?? meshData.color), this.spatialChunking);
812
+ return source.modelIndex ? `model${source.modelIndex}~${key}` : key;
734
813
  }
735
814
  /**
736
815
  * Store MeshData for lazy GPU buffer creation (used for selection highlighting)
@@ -738,6 +817,7 @@ export class Scene {
738
817
  * Accumulates multiple mesh pieces per expressId (elements can have multiple geometry pieces)
739
818
  */
740
819
  addMeshData(meshData) {
820
+ meshData = this.modelTranslations.placeMesh(meshData);
741
821
  // For color-merged batches with per-vertex entityIds, register the mesh
742
822
  // under EVERY unique entity so picking/visibility/selection can find it.
743
823
  if (meshData.entityIds && meshData.entityIds.length > 0) {
@@ -788,7 +868,7 @@ export class Scene {
788
868
  // this expressId so selection highlighting is per-entity, not the
789
869
  // entire merged batch.
790
870
  if (single.entityIds) {
791
- return this.extractEntityFromMergedMesh(single, expressId);
871
+ return extractEntityFromMergedMesh(single, expressId);
792
872
  }
793
873
  return single;
794
874
  }
@@ -798,7 +878,7 @@ export class Scene {
798
878
  const extracted = [];
799
879
  for (const piece of pieces) {
800
880
  if (piece.entityIds) {
801
- const ex = this.extractEntityFromMergedMesh(piece, expressId);
881
+ const ex = extractEntityFromMergedMesh(piece, expressId);
802
882
  if (ex)
803
883
  extracted.push(ex);
804
884
  }
@@ -873,74 +953,6 @@ export class Scene {
873
953
  * @param expressId - The expressId to look up
874
954
  * @param modelIndex - Optional modelIndex to filter by (for multi-model support)
875
955
  */
876
- /**
877
- * Extract only the vertices/triangles belonging to `targetId` from a
878
- * color-merged MeshData that contains many entities. Returns a new
879
- * lightweight MeshData suitable for selection highlighting.
880
- */
881
- extractEntityFromMergedMesh(merged, targetId) {
882
- const entityIds = merged.entityIds;
883
- const positions = merged.positions;
884
- const normals = merged.normals;
885
- const indices = merged.indices;
886
- // Build a vertex mask and remap table
887
- const vertexCount = entityIds.length;
888
- const keep = new Uint8Array(vertexCount);
889
- let keptCount = 0;
890
- for (let i = 0; i < vertexCount; i++) {
891
- if (entityIds[i] === targetId) {
892
- keep[i] = 1;
893
- keptCount++;
894
- }
895
- }
896
- if (keptCount === 0)
897
- return undefined;
898
- // Remap old vertex index → new compacted index
899
- const remap = new Uint32Array(vertexCount);
900
- let newIdx = 0;
901
- for (let i = 0; i < vertexCount; i++) {
902
- if (keep[i]) {
903
- remap[i] = newIdx++;
904
- }
905
- }
906
- // Compact positions & normals
907
- const outPos = new Float32Array(keptCount * 3);
908
- const outNorm = new Float32Array(keptCount * 3);
909
- let outOff = 0;
910
- for (let i = 0; i < vertexCount; i++) {
911
- if (!keep[i])
912
- continue;
913
- const src = i * 3;
914
- outPos[outOff] = positions[src];
915
- outPos[outOff + 1] = positions[src + 1];
916
- outPos[outOff + 2] = positions[src + 2];
917
- outNorm[outOff] = normals[src];
918
- outNorm[outOff + 1] = normals[src + 1];
919
- outNorm[outOff + 2] = normals[src + 2];
920
- outOff += 3;
921
- }
922
- // Compact indices (only triangles where ALL 3 vertices belong to target)
923
- const tmpIdx = [];
924
- for (let i = 0; i < indices.length; i += 3) {
925
- const a = indices[i], b = indices[i + 1], c = indices[i + 2];
926
- if (keep[a] && keep[b] && keep[c]) {
927
- tmpIdx.push(remap[a], remap[b], remap[c]);
928
- }
929
- }
930
- if (tmpIdx.length === 0)
931
- return undefined;
932
- return {
933
- expressId: targetId,
934
- positions: outPos,
935
- normals: outNorm,
936
- indices: new Uint32Array(tmpIdx),
937
- color: merged.color,
938
- // Extracted vertices are copied verbatim from the merged mesh's local
939
- // frame, so carry its origin forward (world = origin + position) — else
940
- // raycast/highlight/snap would treat these local coords as world.
941
- origin: merged.origin,
942
- };
943
- }
944
956
  hasMeshData(expressId, modelIndex) {
945
957
  const pieces = this.meshDataMap.get(expressId);
946
958
  if (!pieces || pieces.length === 0)
@@ -985,7 +997,9 @@ export class Scene {
985
997
  }
986
998
  }
987
999
  }
988
- getMeshDataPieces(expressId, modelIndex) {
1000
+ /** #4382: `itemId`, when given, narrows the returned pieces to those whose
1001
+ * `geometryItemId` matches (undefined never matches, so no silent fallback). */
1002
+ getMeshDataPieces(expressId, modelIndex, itemId) {
989
1003
  let pieces = this.meshDataMap.get(expressId);
990
1004
  if (!pieces || pieces.length === 0)
991
1005
  return undefined;
@@ -994,13 +1008,17 @@ export class Scene {
994
1008
  if (pieces.length === 0)
995
1009
  return undefined;
996
1010
  }
1011
+ if (itemId !== undefined)
1012
+ pieces = pieces.filter((p) => p.geometryItemId === itemId);
1013
+ if (pieces.length === 0)
1014
+ return undefined;
997
1015
  // For color-merged batches, extract only this entity's vertices so
998
1016
  // selection highlighting is per-entity, not the entire merged batch.
999
1017
  if (pieces.some(p => p.entityIds)) {
1000
1018
  const extracted = [];
1001
1019
  for (const piece of pieces) {
1002
1020
  if (piece.entityIds) {
1003
- const ex = this.extractEntityFromMergedMesh(piece, expressId);
1021
+ const ex = extractEntityFromMergedMesh(piece, expressId);
1004
1022
  if (ex)
1005
1023
  extracted.push(ex);
1006
1024
  }
@@ -1036,6 +1054,10 @@ export class Scene {
1036
1054
  * when streaming completes to do one O(N) full merge.
1037
1055
  */
1038
1056
  appendToBatches(meshDataArray, device, pipeline, isStreaming = false) {
1057
+ if (this.appearanceController)
1058
+ for (const part of meshDataArray)
1059
+ this.appearanceController.cancelFor(part.expressId);
1060
+ meshDataArray = meshDataArray.map((mesh) => this.modelTranslations.placeMesh(mesh));
1039
1061
  // Cache max buffer size on first call
1040
1062
  if (this.cachedMaxBufferSize === 0) {
1041
1063
  this.cachedMaxBufferSize = this.getMaxBufferSize(device);
@@ -1109,6 +1131,11 @@ export class Scene {
1109
1131
  const bucket = this.buckets.get(key);
1110
1132
  // Destroy old GPU batch if it exists
1111
1133
  if (bucket?.batchedMesh) {
1134
+ // Slot keys embed the batch id and the replacement gets a fresh one, so
1135
+ // drop this batch's cached sub-batch clones first or they are stranded
1136
+ // with live GPU buffers. Every other batch-destroying path already
1137
+ // clears the cache (eviction per batch; finalize/release/clear wholesale).
1138
+ this.dropPartialCacheForBatch(bucket.batchedMesh);
1112
1139
  destroyGpuResources(bucket.batchedMesh);
1113
1140
  bucket.batchedMesh = null;
1114
1141
  }
@@ -1161,6 +1188,8 @@ export class Scene {
1161
1188
  * the IFC tombstone just means we ignore it for queries.
1162
1189
  */
1163
1190
  removeMeshesForEntity(expressId) {
1191
+ this.appearanceController?.forget(expressId);
1192
+ this.modelTranslations.forgetEntityBounds(expressId);
1164
1193
  const meshDataList = this.meshDataMap.get(expressId);
1165
1194
  if (!meshDataList || meshDataList.length === 0) {
1166
1195
  this.boundingBoxes.delete(expressId);
@@ -1248,7 +1277,7 @@ export class Scene {
1248
1277
  if (!this.instancedEntityMap.has(expressId))
1249
1278
  return false;
1250
1279
  const device = this.instancedDevice;
1251
- if (device) {
1280
+ if (device && !this.instanceSuppression.has(expressId)) {
1252
1281
  // Must set the flag while the occurrence locations are still in the map.
1253
1282
  this.instancedHidden.add(expressId);
1254
1283
  this.writeInstanceFlags(device, expressId);
@@ -1259,6 +1288,7 @@ export class Scene {
1259
1288
  if (this.instancedSelected.has(expressId)) {
1260
1289
  this.bumpTemplateSelectedCount(expressId, -1);
1261
1290
  }
1291
+ this.instanceSuppression.forget(expressId);
1262
1292
  this.instancedEntityMap.delete(expressId);
1263
1293
  this.instancedSelected.delete(expressId);
1264
1294
  this.instancedHidden.delete(expressId);
@@ -1302,6 +1332,7 @@ export class Scene {
1302
1332
  * to a full reload if needed.
1303
1333
  */
1304
1334
  translateMeshesForEntity(expressId, delta) {
1335
+ this.appearanceController?.cancelFor(expressId);
1305
1336
  // An entity can have flat meshes, GPU-instanced occurrences, or both. The
1306
1337
  // instanced occurrences live in the per-template instance buffers, NOT in
1307
1338
  // meshDataMap, so the flat path below can't reach them — without this they
@@ -1315,10 +1346,6 @@ export class Scene {
1315
1346
  const instancedMoved = this.translateInstancedEntity(expressId, delta);
1316
1347
  return flatMoved || instancedMoved;
1317
1348
  }
1318
- /**
1319
- * Translate every flat (non-instanced) mesh for `expressId` by `delta`. See
1320
- * {@link translateMeshesForEntity} for the full contract; this is the flat half.
1321
- */
1322
1349
  /**
1323
1350
  * Mark a mesh's bucket for rebuild after its positions were mutated in
1324
1351
  * place (move/rotate), migrating it to a new bucket when spatial chunking
@@ -1408,9 +1435,10 @@ export class Scene {
1408
1435
  if (texturedData.length > 0) {
1409
1436
  const entries = this.texturedMeshes.filter((tm) => tm.expressId === expressId);
1410
1437
  for (let i = 0; i < entries.length && i < texturedData.length; i++) {
1411
- const interleaved = this.interleaveTexturedVertices(texturedData[i]);
1438
+ const interleaved = interleaveTexturedVertices(texturedData[i]);
1412
1439
  if (interleaved) {
1413
1440
  this.texturedDevice.queue.writeBuffer(entries[i].vertexBuffer, 0, interleaved);
1441
+ refreshTexturedBounds(this.modelTranslations, entries[i], texturedData[i]);
1414
1442
  }
1415
1443
  }
1416
1444
  }
@@ -1510,24 +1538,43 @@ export class Scene {
1510
1538
  if (template)
1511
1539
  foldOccurrenceWorldBox(template, w);
1512
1540
  }
1541
+ if (this.instanceSuppression.has(expressId))
1542
+ this.boundingBoxes.delete(expressId);
1513
1543
  }
1514
1544
  /** Drop the per-entity selection-highlight meshes for `expressId` (frozen
1515
1545
  * copies in `this.meshes`) + free their GPU buffers, so the highlight is
1516
1546
  * rebuilt from the entity's current geometry on the next render. Used after a
1517
1547
  * translate or removal, which mutate the underlying geometry but don't touch
1518
1548
  * these standalone highlight meshes. */
1519
- evictHighlightMeshes(expressId) {
1549
+ evictHighlightMeshes(expressId, hydratedOnly = false) {
1520
1550
  if (this.meshes.length === 0)
1521
1551
  return;
1522
1552
  const kept = [];
1523
1553
  for (const mesh of this.meshes) {
1524
- if (mesh.expressId === expressId)
1554
+ if (mesh.expressId === expressId && (!hydratedOnly || mesh.hydrated))
1525
1555
  destroyGpuResources(mesh);
1526
1556
  else
1527
1557
  kept.push(mesh);
1528
1558
  }
1529
1559
  this.meshes = kept;
1530
1560
  }
1561
+ /** Absolute workspace translation, renderer Y-up metres (#4226). */
1562
+ getModelTranslation(modelIndex) { return this.modelTranslations.get(modelIndex); }
1563
+ setModelTranslation(modelIndex, translation) {
1564
+ const batches = this.finalizeInProgress ? [...new Set([...this.batchedMeshes,
1565
+ ...[...this.buckets.values()].flatMap((bucket) => bucket.batchedMesh ? [bucket.batchedMesh] : [])])] : this.batchedMeshes;
1566
+ const changed = translateSceneModel({ translations: this.modelTranslations, pieces: this.meshDataMap,
1567
+ bounds: this.boundingBoxes, batches, meshes: this.meshes, overrides: this.overrideBatches, textured: this.texturedMeshes,
1568
+ templates: this.instancedTemplates, cpu: this.instancedTemplateCpu, occurrences: this.instancedEntityMap,
1569
+ device: this.instancedDevice, evictHighlight: (id) => this.evictHighlightMeshes(id, true),
1570
+ clearPartial: () => this.dropAllPartialCaches(),
1571
+ unionBounds: (id, view, offset, min, max) => this.unionInstancedWorldAabb(id, view, offset, ...min, ...max),
1572
+ }, modelIndex, translation);
1573
+ for (const id of this.instanceSuppression.suppressedIds())
1574
+ if (!this.meshDataMap.has(id))
1575
+ this.boundingBoxes.delete(id);
1576
+ return changed;
1577
+ }
1531
1578
  /** Bulk variant of `translateMeshesForEntity`. */
1532
1579
  translateMeshesForEntities(updates) {
1533
1580
  let count = 0;
@@ -1553,6 +1600,7 @@ export class Scene {
1553
1600
  * Returns true when a mesh was modified.
1554
1601
  */
1555
1602
  rotateMeshesForEntity(expressId, angleRad, pivot) {
1603
+ this.appearanceController?.cancelFor(expressId);
1556
1604
  const meshDataList = this.meshDataMap.get(expressId);
1557
1605
  if (!meshDataList || meshDataList.length === 0)
1558
1606
  return false;
@@ -1606,9 +1654,10 @@ export class Scene {
1606
1654
  if (texturedData.length > 0) {
1607
1655
  const entries = this.texturedMeshes.filter((tm) => tm.expressId === expressId);
1608
1656
  for (let i = 0; i < entries.length && i < texturedData.length; i++) {
1609
- const interleaved = this.interleaveTexturedVertices(texturedData[i]);
1657
+ const interleaved = interleaveTexturedVertices(texturedData[i]);
1610
1658
  if (interleaved) {
1611
1659
  this.texturedDevice.queue.writeBuffer(entries[i].vertexBuffer, 0, interleaved);
1660
+ refreshTexturedBounds(this.modelTranslations, entries[i], texturedData[i]);
1612
1661
  }
1613
1662
  }
1614
1663
  }
@@ -1773,52 +1822,7 @@ export class Scene {
1773
1822
  }
1774
1823
  }
1775
1824
  splitMeshForStreaming(meshData) {
1776
- const vertexBytes = meshData.positions.byteLength + meshData.normals.byteLength;
1777
- if (meshData.indices.length <= Scene.STREAMING_FRAGMENT_MAX_INDICES &&
1778
- vertexBytes <= Scene.STREAMING_FRAGMENT_MAX_VERTEX_BYTES) {
1779
- return [meshData];
1780
- }
1781
- const maxIndexCount = Math.max(3, Math.floor(Scene.STREAMING_FRAGMENT_MAX_INDICES / 3) * 3);
1782
- const fragments = [];
1783
- for (let start = 0; start < meshData.indices.length; start += maxIndexCount) {
1784
- const end = Math.min(start + maxIndexCount, meshData.indices.length);
1785
- const sourceIndices = meshData.indices.subarray(start, end);
1786
- const remap = new Map();
1787
- const positions = [];
1788
- const normals = [];
1789
- const indices = new Uint32Array(sourceIndices.length);
1790
- for (let i = 0; i < sourceIndices.length; i++) {
1791
- const sourceIndex = sourceIndices[i];
1792
- let nextIndex = remap.get(sourceIndex);
1793
- if (nextIndex === undefined) {
1794
- nextIndex = remap.size;
1795
- remap.set(sourceIndex, nextIndex);
1796
- const base = sourceIndex * 3;
1797
- positions.push(meshData.positions[base], meshData.positions[base + 1], meshData.positions[base + 2]);
1798
- normals.push(meshData.normals[base], meshData.normals[base + 1], meshData.normals[base + 2]);
1799
- }
1800
- indices[i] = nextIndex;
1801
- }
1802
- fragments.push({
1803
- expressId: meshData.expressId,
1804
- ifcType: meshData.ifcType,
1805
- positions: new Float32Array(positions),
1806
- normals: new Float32Array(normals),
1807
- indices,
1808
- color: meshData.color,
1809
- // Fragments are subsets of the same source mesh → same local frame.
1810
- // Preserve origin so each fragment relativizes/renders in world space.
1811
- ...(meshData.origin ? { origin: meshData.origin } : {}),
1812
- // Each fragment is a vertex SUBSET of the same source mesh, so the
1813
- // parent's localBounds/localToWorld (issue #1474) still apply
1814
- // unchanged: localBounds is a safe (if loose) superset — the caller
1815
- // unions across an entity's pieces anyway — and localToWorld is the
1816
- // one placement shared by the whole (pre-split) mesh.
1817
- ...(meshData.localBounds ? { localBounds: meshData.localBounds } : {}),
1818
- ...(meshData.localToWorld ? { localToWorld: meshData.localToWorld } : {}),
1819
- });
1820
- }
1821
- return fragments;
1825
+ return splitMeshForStreaming(meshData, Scene.STREAMING_FRAGMENT_MAX_INDICES, Scene.STREAMING_FRAGMENT_MAX_VERTEX_BYTES);
1822
1826
  }
1823
1827
  /**
1824
1828
  * Finalize streaming: destroy temporary fragment batches and do one full
@@ -1962,6 +1966,8 @@ export class Scene {
1962
1966
  // synchronously, so settle-sensitive consumers need this flag until the
1963
1967
  // time-sliced rebuild swaps the new batch array in.
1964
1968
  this.finalizeInProgress = true;
1969
+ // Hoisted rollback/processChunk callbacks retain this scene across turns.
1970
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
1965
1971
  const scene = this;
1966
1972
  const oldFragments = this.streamingFragments;
1967
1973
  const oldBatches = this.batchedMeshes;
@@ -2128,19 +2134,15 @@ export class Scene {
2128
2134
  // so caching the inverted-empty sentinel here would publish a geometry-less
2129
2135
  // entity to every CPU consumer with a garbage box (#2480).
2130
2136
  for (const [expressId, pieces] of this.meshDataMap) {
2131
- if (this.boundingBoxes.has(expressId))
2132
- continue;
2133
- const bbox = worldAabbFromPieces(pieces);
2134
- if (bbox)
2135
- this.boundingBoxes.set(expressId, bbox);
2137
+ cachedWorldAabb(expressId, pieces, this.boundingBoxes);
2136
2138
  }
2139
+ this.modelTranslations.retainEntityBounds(this.meshDataMap);
2137
2140
  this.streamingFragments = [];
2138
2141
  this.buckets.clear();
2139
2142
  this.meshDataBucket = new Map();
2140
2143
  this.meshDataMap.clear();
2141
- // Free the compact instanced template geometry too; the per-occurrence world
2142
- // AABBs already live in boundingBoxes, so bbox-raycast still finds instanced ids.
2143
- this.instancedTemplateCpu = [];
2144
+ // Keep occurrence transforms for placement; release heavy template vertices.
2145
+ releaseInstanceVertices(this.instancedTemplateCpu);
2144
2146
  this.activeBucketKey.clear();
2145
2147
  this.lastDrawnFrame.clear();
2146
2148
  this.residencyRestoreQueue.clear();
@@ -2167,8 +2169,13 @@ export class Scene {
2167
2169
  * Call this after finalizeStreaming() when all color updates have been applied.
2168
2170
  */
2169
2171
  releaseGeometryData() {
2172
+ this.authoredGeneration++;
2170
2173
  if (this.geometryReleased)
2171
2174
  return;
2175
+ if (this.instanceSuppression.retained) {
2176
+ console.warn('[Appearance] Retained occurrence history still needs CPU geometry');
2177
+ return;
2178
+ }
2172
2179
  // Guard: releasing while async batch work is in-flight would corrupt GPU state
2173
2180
  if (this.pendingBatchKeys.size > 0 || this.streamingFragments.length > 0) {
2174
2181
  console.warn(`[Scene] releaseGeometryData() called with ${this.pendingBatchKeys.size} pending batches ` +
@@ -2176,23 +2183,18 @@ export class Scene {
2176
2183
  `Call finalizeStreaming()/rebuildPendingBatches() first.`);
2177
2184
  return;
2178
2185
  }
2186
+ this.appearanceController?.forget();
2179
2187
  // 1. Precompute and cache ALL entity bounding boxes before releasing data.
2180
2188
  // Same rule as `finishEphemeralStreaming`: an entity with no usable vertex
2181
2189
  // gets no entry rather than the inverted-empty sentinel (#2480).
2182
2190
  for (const [expressId, pieces] of this.meshDataMap) {
2183
- if (this.boundingBoxes.has(expressId))
2184
- continue;
2185
- const bbox = worldAabbFromPieces(pieces);
2186
- if (bbox)
2187
- this.boundingBoxes.set(expressId, bbox);
2191
+ cachedWorldAabb(expressId, pieces, this.boundingBoxes);
2188
2192
  }
2193
+ this.modelTranslations.retainEntityBounds(this.meshDataMap);
2189
2194
  // 2. Clear the heavy data structures — typed arrays become GC-eligible
2190
2195
  this.meshDataMap.clear();
2191
- // Free the compact instanced template geometry; per-occurrence world AABBs are
2192
- // already cached in boundingBoxes, so the released-path bbox-raycast still
2193
- // resolves instanced ids (exact-triangle measure/section is unavailable post-
2194
- // release, same as the flat path).
2195
- this.instancedTemplateCpu = [];
2196
+ // Keep occurrence transforms for placement; release heavy template vertices.
2197
+ releaseInstanceVertices(this.instancedTemplateCpu);
2196
2198
  // Clear meshData arrays in each bucket (typed arrays become GC-eligible)
2197
2199
  // but keep the bucket shells so batchedMesh references remain valid
2198
2200
  for (const bucket of this.buckets.values()) {
@@ -2240,6 +2242,7 @@ export class Scene {
2240
2242
  const affectedNewKeys = new Set();
2241
2243
  // Update colors in meshDataMap and track affected batches
2242
2244
  for (const [expressId, newColor] of updates) {
2245
+ this.appearanceController?.cancelFor(expressId);
2243
2246
  const meshDataList = this.meshDataMap.get(expressId);
2244
2247
  if (!meshDataList)
2245
2248
  continue;
@@ -2321,160 +2324,21 @@ export class Scene {
2321
2324
  * base color key is used (fine for overlay / partial batches that don't
2322
2325
  * participate in the main buckets map).
2323
2326
  */
2324
- createBatchedMesh(meshDataArray, color, device, pipeline, bucketKey) {
2325
- // Use ONE shared scene origin for every batch (set from the first batch's
2326
- // world bbox centre). A per-batch origin would make abutting elements in
2327
- // different colour batches diverge by a few f32 ULP at building-scale world
2328
- // coords seam/end-cap z-fighting. A shared origin makes every coincident
2329
- // world point relativize identically no seam z-fight, and the model
2330
- // sits at most ±(model extent) from it (f32-precise at building scale).
2331
- const merged = this.mergeGeometry(meshDataArray, this.sharedFrameOrigin ?? undefined);
2332
- if (!this.sharedFrameOrigin && (merged.origin[0] || merged.origin[1] || merged.origin[2])) {
2333
- this.sharedFrameOrigin = merged.origin;
2334
- }
2335
- const expressIds = meshDataArray.map(m => m.expressId);
2336
- // Parallel to `expressIds` (same index = same source piece) so picking
2337
- // can scope each batch ENTRY to its own model — batches group by colour,
2338
- // not by model, so distinct models sharing an expressId+colour can be
2339
- // co-batched (see BatchedMesh.modelIndices doc).
2340
- const modelIndices = meshDataArray.map(m => m.modelIndex);
2341
- // Create vertex buffer (interleaved positions + normals)
2342
- // Use mappedAtCreation to avoid a separate writeBuffer IPC round-trip
2343
- // (significant win on Chrome/Dawn where each writeBuffer is a Mojo IPC call)
2344
- // Quantized path (issue #1682 phase 6): 12-byte lattice records instead
2345
- // of the 28-byte f32 layout. Falls back to f32 when the batch exceeds
2346
- // the u16 lattice range. Order note: the LOD build further down reads
2347
- // merged.vertexData (the CPU f32 copy) and produces INDICES only, which
2348
- // are valid for either vertex format.
2349
- // This function allocates a RUN of GPU buffers (vertex, index, uniform,
2350
- // and — when LOD1 qualifies — a second index buffer). `device.createBuffer`
2351
- // genuinely throws in production (scene.ts:2057 / index.ts:168 document a
2352
- // real "createBuffer failed, size (...) is too large" RangeError), so every
2353
- // buffer created earlier in the run must be destroyed before a later throw
2354
- // propagates — otherwise it is orphaned: allocated, never referenced again,
2355
- // never freed. Same paired-allocation idiom as `appendChunkToNode` /
2356
- // `DeviationPipeline.uploadBvh` (see paired-buffer-leak.test.ts), generalised
2357
- // to a run of N instead of a pair.
2358
- const allocated = [];
2359
- const createTracked = (desc) => {
2360
- let buf;
2361
- try {
2362
- buf = device.createBuffer(desc);
2363
- }
2364
- catch (err) {
2365
- for (const b of allocated) {
2366
- try {
2367
- b.destroy();
2368
- }
2369
- catch (destroyErr) {
2370
- // Non-fatal: surfaced rather than swallowed, per the no-silent-catch
2371
- // house rule — this firing would mean a real teardown bug.
2372
- console.warn('[Scene] failed to release a batch buffer after a paired allocation failure', destroyErr);
2373
- }
2374
- }
2375
- throw err;
2376
- }
2377
- allocated.push(buf);
2378
- return buf;
2379
- };
2380
- let quantized;
2381
- let vertexBuffer;
2382
- const quantizedData = this.quantizedBatchesEnabled
2383
- ? quantizeInterleaved(merged.vertexData, BATCH_CONSTANTS.BYTES_PER_VERTEX / 4)
2384
- : null;
2385
- if (quantizedData) {
2386
- vertexBuffer = createTracked({
2387
- size: Math.max(4, quantizedData.vertexData.byteLength),
2388
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
2389
- mappedAtCreation: true,
2390
- });
2391
- new Uint8Array(vertexBuffer.getMappedRange())
2392
- .set(new Uint8Array(quantizedData.vertexData));
2393
- vertexBuffer.unmap();
2394
- quantized = { min: quantizedData.quantMin, step: quantizedData.step };
2327
+ createBatchedMesh(meshes, color, device, pipeline, bucketKey) {
2328
+ // Keep main's model-local frame and translation registration while staging
2329
+ // every GPU allocation before publishing Scene state.
2330
+ const modelIndex = meshes[0]?.modelIndex ?? 0;
2331
+ const offset = this.modelTranslations.get(modelIndex);
2332
+ const result = createSceneBatch(meshes, color, device, pipeline, {
2333
+ id: this.nextBatchId, colorKey: bucketKey ?? this.colorKey(color),
2334
+ origin: this.modelTranslations.frameOrigin(this.sharedFrameOrigins.get(modelIndex) ?? null, modelIndex),
2335
+ quantized: this.quantizedBatchesEnabled, lod: this.lodBuildsEnabled,
2336
+ }, bucketKey);
2337
+ this.nextBatchId++;
2338
+ if (!this.sharedFrameOrigins.has(modelIndex) && result.origin) {
2339
+ this.sharedFrameOrigins.set(modelIndex, [result.origin[0] - offset[0], result.origin[1] - offset[1], result.origin[2] - offset[2]]);
2395
2340
  }
2396
- else {
2397
- vertexBuffer = createTracked({
2398
- size: merged.vertexData.byteLength,
2399
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
2400
- mappedAtCreation: true,
2401
- });
2402
- new Float32Array(vertexBuffer.getMappedRange()).set(merged.vertexData);
2403
- vertexBuffer.unmap();
2404
- }
2405
- // Create index buffer
2406
- const indexBuffer = createTracked({
2407
- size: merged.indices.byteLength,
2408
- usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
2409
- mappedAtCreation: true,
2410
- });
2411
- new Uint32Array(indexBuffer.getMappedRange()).set(merged.indices);
2412
- indexBuffer.unmap();
2413
- // Create uniform buffer for this batch
2414
- const uniformBuffer = createTracked({
2415
- size: pipeline.getUniformBufferSize(),
2416
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
2417
- });
2418
- // Create bind group
2419
- const bindGroup = device.createBindGroup({
2420
- layout: pipeline.getBindGroupLayout(),
2421
- entries: [
2422
- {
2423
- binding: 0,
2424
- resource: { buffer: uniformBuffer },
2425
- },
2426
- ],
2427
- });
2428
- // LOD1 (issue #1682 phase 5): simplified second index range over the SAME
2429
- // vertex buffer. Bucket-owned batches only (`bucketKey` present) — the
2430
- // transient streaming fragments and partial/overlay sub-batches never pay
2431
- // the build. Positions in `merged.vertexData` are relative to the batch
2432
- // origin, which is fine: clustering is translation-invariant as long as
2433
- // the cell size comes from the same-space bounds extent.
2434
- let lod1IndexBuffer;
2435
- let lod1IndexCount;
2436
- if (this.lodBuildsEnabled &&
2437
- bucketKey !== undefined &&
2438
- merged.bounds &&
2439
- merged.indices.length >= LOD_MIN_TRIANGLES * 3) {
2440
- const cellSize = lodCellSizeForBounds(merged.bounds.min, merged.bounds.max);
2441
- const lodIndices = simplifyIndicesByClustering(merged.vertexData, BATCH_CONSTANTS.BYTES_PER_VERTEX / 4, merged.indices, cellSize);
2442
- if (lodIndices) {
2443
- lod1IndexBuffer = createTracked({
2444
- size: lodIndices.byteLength,
2445
- usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
2446
- mappedAtCreation: true,
2447
- });
2448
- new Uint32Array(lod1IndexBuffer.getMappedRange()).set(lodIndices);
2449
- lod1IndexBuffer.unmap();
2450
- lod1IndexCount = lodIndices.length;
2451
- }
2452
- }
2453
- return {
2454
- id: this.nextBatchId++,
2455
- colorKey: bucketKey ?? this.colorKey(color),
2456
- vertexBuffer,
2457
- indexBuffer,
2458
- indexCount: merged.indices.length,
2459
- color,
2460
- expressIds,
2461
- bindGroup,
2462
- uniformBuffer,
2463
- bounds: merged.bounds,
2464
- modelIndices,
2465
- // Per-batch local frame: positions are stored relative to this; the draw
2466
- // loop applies model = translate(origin) so they land in world space.
2467
- origin: merged.origin,
2468
- ...(lod1IndexBuffer ? { lod1IndexBuffer, lod1IndexCount } : {}),
2469
- ...(quantized ? { quantized } : {}),
2470
- };
2471
- }
2472
- /**
2473
- * Merge multiple mesh geometries into single vertex/index buffers.
2474
- * Delegates to the extracted mergeGeometry() utility.
2475
- */
2476
- mergeGeometry(meshDataArray, forcedOrigin) {
2477
- return mergeGeometry(meshDataArray, forcedOrigin);
2341
+ return this.modelTranslations.registerDrawable(result, modelIndex);
2478
2342
  }
2479
2343
  /**
2480
2344
  * Get the effective max buffer size for this GPU device, with a safety margin.
@@ -2537,6 +2401,36 @@ export class Scene {
2537
2401
  const hashIdx = bucketKey.lastIndexOf('#');
2538
2402
  return hashIdx >= 0 ? bucketKey.substring(0, hashIdx) : bucketKey;
2539
2403
  }
2404
+ /**
2405
+ * Whether this batch's entities can be drawn as separate sub-batches right
2406
+ * now — the precondition for splitting a batch by per-entity X-Ray alpha
2407
+ * (#4129) or by any other per-entity property.
2408
+ *
2409
+ * Three ways a batch is indivisible:
2410
+ * - its CPU geometry was released (GPU-resident mode) — nothing to re-merge;
2411
+ * - it is not the live batch of a warm bucket: an evicted (cold) bucket has
2412
+ * had its `meshData` dropped, and non-bucket batches (streaming fragments,
2413
+ * sub-batches) have no piece list keyed the way the partial builder looks
2414
+ * pieces up, so it would silently come back empty;
2415
+ * - it holds a colour-merged piece, where many entities share ONE MeshData
2416
+ * tagged per vertex. Such a piece is registered under every contained id,
2417
+ * so it would land whole in more than one subset — the same geometry drawn
2418
+ * twice, at two different alphas.
2419
+ *
2420
+ * The caller must fall back to drawing the batch whole when this is false.
2421
+ */
2422
+ canPartitionBatch(batch) {
2423
+ if (this.geometryReleased)
2424
+ return false;
2425
+ const bucket = this.buckets.get(batch.colorKey);
2426
+ if (!bucket || bucket.batchedMesh !== batch || bucket.meshData.length === 0)
2427
+ return false;
2428
+ for (const md of bucket.meshData) {
2429
+ if (md.entityIds && md.entityIds.length > 0)
2430
+ return false;
2431
+ }
2432
+ return true;
2433
+ }
2540
2434
  /**
2541
2435
  * Get or create a partial batch for a subset of visible elements from a batch
2542
2436
  *
@@ -2580,7 +2474,12 @@ export class Scene {
2580
2474
  hash = hash >>> 0; // Convert to unsigned 32-bit
2581
2475
  }
2582
2476
  const idsHash = `${sortedIds.length}:${hash.toString(16)}`;
2583
- const cacheKey = `${colorKey}:${idsHash}`;
2477
+ // Scoped to the REQUESTING slot, not just the colour: a parent batch can
2478
+ // own several slots at once (`:promoted`/`:remaining`, and one per X-Ray
2479
+ // alpha group), and two slots trading id sets between frames would other-
2480
+ // wise land on each other's cache entry — the second slot's invalidation
2481
+ // then destroys the clone the first one just built and is drawing from.
2482
+ const cacheKey = `${sourceBatchKey}:${idsHash}`;
2584
2483
  // Check if we already have this exact partial batch cached
2585
2484
  const currentCacheKey = this.partialBatchCacheKeys.get(sourceBatchKey);
2586
2485
  if (currentCacheKey === cacheKey) {
@@ -2674,20 +2573,18 @@ export class Scene {
2674
2573
  // bytes in place (no separate overlay pass — the instanced records carry the
2675
2574
  // override colour directly). No-op when no instanced data is loaded.
2676
2575
  this.setInstancedColorOverrides(overrides);
2677
- // Group expressIds by override color
2576
+ // An overlay batch must belong to one model so placement cannot move a
2577
+ // different model's highlighted geometry along with its first mesh.
2678
2578
  const colorGroups = new Map();
2679
2579
  for (const [expressId, color] of overrides) {
2680
- const key = this.colorKey(color);
2681
- let group = colorGroups.get(key);
2682
- if (!group) {
2683
- group = { color, meshData: [] };
2684
- colorGroups.set(key, group);
2685
- }
2686
- const pieces = this.meshDataMap.get(expressId);
2687
- if (pieces) {
2688
- for (const piece of pieces) {
2689
- group.meshData.push(piece);
2580
+ for (const piece of this.meshDataMap.get(expressId) ?? []) {
2581
+ const key = `${piece.modelIndex ?? 0}:${this.colorKey(color)}`;
2582
+ let group = colorGroups.get(key);
2583
+ if (!group) {
2584
+ group = { color, meshData: [] };
2585
+ colorGroups.set(key, group);
2690
2586
  }
2587
+ group.meshData.push(piece);
2691
2588
  }
2692
2589
  }
2693
2590
  // Build overlay batches per override color, splitting if buffers would exceed GPU limit
@@ -2836,6 +2733,7 @@ export class Scene {
2836
2733
  * no-op).
2837
2734
  */
2838
2735
  removeInstancedTemplatesForModel(modelIndex) {
2736
+ this.instanceSuppression.forgetModel(modelIndex);
2839
2737
  const freed = new Set();
2840
2738
  for (let i = 0; i < this.instancedTemplates.length; i++) {
2841
2739
  const t = this.instancedTemplates[i];
@@ -2951,6 +2849,9 @@ export class Scene {
2951
2849
  * GPU upload.
2952
2850
  */
2953
2851
  addInstancedShard(device, shard, modelIndex = 0) {
2852
+ if (shard.instances.some(instance => this.instanceSuppression.owns(instance.entityId))) {
2853
+ throw new Error('Cannot append geometry to a retained appearance occurrence');
2854
+ }
2954
2855
  this.instancedDevice = device; // cached for per-instance selection/overlay writeBuffer
2955
2856
  const prepared = prepareInstancedRender(shard);
2956
2857
  // Selected ids whose occurrences arrived in THIS shard (selection recorded
@@ -2997,6 +2898,7 @@ export class Scene {
2997
2898
  indexBuffer.unmap();
2998
2899
  // instanceBuffer is already the interleaved mat4 + entityId + rgba block
2999
2900
  // (INSTANCE_STRIDE_BYTES per occurrence) from prepareInstancedRender.
2901
+ this.modelTranslations.placeInstances(t.instanceBuffer, modelIndex, INSTANCE_STRIDE_BYTES);
3000
2902
  const instSize = t.instanceCount * INSTANCE_STRIDE_BYTES;
3001
2903
  const instanceBuffer = device.createBuffer({
3002
2904
  size: instSize,
@@ -3165,10 +3067,23 @@ export class Scene {
3165
3067
  isInstancedEntity(expressId) {
3166
3068
  return this.instancedEntityMap.has(expressId);
3167
3069
  }
3070
+ /** Retain one model-owned occurrence for reversible appearance replacement. */
3071
+ retainInstancedOccurrence(expressId, modelIndex) {
3072
+ const occurrences = this.instancedEntityMap.get(expressId);
3073
+ if (this.geometryReleased || this.finalizeInProgress || this.streamingFragments.length
3074
+ || this.pendingBatchKeys.size || !occurrences?.length
3075
+ || occurrences.some(o => this.instancedTemplates[o.templateIndex]?.modelIndex !== modelIndex
3076
+ || !this.instancedTemplateCpu[o.templateIndex]?.positions.length)) {
3077
+ throw new Error('Appearance requires resident instances owned by the specified model');
3078
+ }
3079
+ return this.instanceSuppression.acquire(expressId, modelIndex);
3080
+ }
3168
3081
  /** All instanced occurrence express_ids (for CPU consumers that enumerate geometry,
3169
3082
  * e.g. the raycast-engine and exporters). */
3170
- getInstancedEntityIds() {
3171
- return this.instancedEntityMap.keys();
3083
+ *getInstancedEntityIds() {
3084
+ for (const id of this.instancedEntityMap.keys())
3085
+ if (!this.instanceSuppression.has(id))
3086
+ yield id;
3172
3087
  }
3173
3088
  /** Number of distinct GPU-instanced entities. O(1) — for size heuristics
3174
3089
  * (e.g. the orbit-pivot raycast skip) that must not miss instanced-heavy
@@ -3192,7 +3107,7 @@ export class Scene {
3192
3107
  /** World-space AABB for an instanced occurrence (union over its occurrences),
3193
3108
  * or null if not instanced. Populated at upload time, so this is O(1). */
3194
3109
  getInstancedEntityBounds(expressId) {
3195
- if (!this.instancedEntityMap.has(expressId))
3110
+ if (!this.instancedEntityMap.has(expressId) || this.instanceSuppression.has(expressId))
3196
3111
  return null;
3197
3112
  return this.boundingBoxes.get(expressId) ?? null;
3198
3113
  }
@@ -3202,71 +3117,25 @@ export class Scene {
3202
3117
  * export). Returns undefined if the id is not instanced. */
3203
3118
  getInstancedMeshDataPieces(expressId) {
3204
3119
  const occ = this.instancedEntityMap.get(expressId);
3205
- if (!occ || occ.length === 0)
3120
+ if (!occ || occ.length === 0 || this.instanceSuppression.has(expressId))
3206
3121
  return undefined;
3207
- const out = [];
3208
- for (const o of occ) {
3209
- const tpl = this.instancedTemplateCpu[o.templateIndex];
3210
- if (!tpl || tpl.positions.length === 0)
3211
- continue;
3212
- const dv = new DataView(tpl.instanceData);
3213
- const b = o.byteOffset;
3214
- const m0 = dv.getFloat32(b + 0, true), m1 = dv.getFloat32(b + 4, true), m2 = dv.getFloat32(b + 8, true);
3215
- const m4 = dv.getFloat32(b + 16, true), m5 = dv.getFloat32(b + 20, true), m6 = dv.getFloat32(b + 24, true);
3216
- const m8 = dv.getFloat32(b + 32, true), m9 = dv.getFloat32(b + 36, true), m10 = dv.getFloat32(b + 40, true);
3217
- const m12 = dv.getFloat32(b + 48, true), m13 = dv.getFloat32(b + 52, true), m14 = dv.getFloat32(b + 56, true);
3218
- const n = tpl.positions.length;
3219
- const positions = new Float32Array(n);
3220
- const normals = new Float32Array(tpl.normals.length);
3221
- for (let i = 0; i < n; i += 3) {
3222
- const x = tpl.positions[i], y = tpl.positions[i + 1], z = tpl.positions[i + 2];
3223
- positions[i] = m0 * x + m4 * y + m8 * z + m12;
3224
- positions[i + 1] = m1 * x + m5 * y + m9 * z + m13;
3225
- positions[i + 2] = m2 * x + m6 * y + m10 * z + m14;
3226
- if (i + 2 < tpl.normals.length) {
3227
- // Rotate normals by the upper-3×3 (instancing transforms are rigid +
3228
- // uniform scale, so this is correct up to a renormalize).
3229
- const nx = tpl.normals[i], ny = tpl.normals[i + 1], nz = tpl.normals[i + 2];
3230
- let rx = m0 * nx + m4 * ny + m8 * nz;
3231
- let ry = m1 * nx + m5 * ny + m9 * nz;
3232
- let rz = m2 * nx + m6 * ny + m10 * nz;
3233
- const len = Math.hypot(rx, ry, rz) || 1;
3234
- rx /= len;
3235
- ry /= len;
3236
- rz /= len;
3237
- normals[i] = rx;
3238
- normals[i + 1] = ry;
3239
- normals[i + 2] = rz;
3240
- }
3241
- }
3242
- const color = [...o.originalColor];
3243
- // Per-occurrence key so CPU caches that would otherwise key on `expressId`
3244
- // alone (measure-snap geometry cache) don't collide across occurrences of
3245
- // this instanced entity, which share `expressId` but hold distinct
3246
- // world-space positions (issue #1405). templateIndex+byteOffset uniquely
3247
- // and stably identifies an occurrence within the instance buffers.
3248
- const occurrenceKey = `${expressId}:inst:${o.templateIndex}:${o.byteOffset}`;
3249
- // #2985: the same drill-to-source id a flat mesh carries, so a consumer of
3250
- // these pieces is not worse off for the geometry having been instanced.
3251
- const item = o.itemId !== undefined ? { geometryItemId: o.itemId } : {};
3252
- out.push({ expressId, positions, normals, indices: tpl.indices, color, occurrenceKey, ...item });
3253
- }
3254
- return out.length > 0 ? out : undefined;
3122
+ return materializeInstances(expressId, occ, this.instancedTemplateCpu);
3255
3123
  }
3256
3124
  /**
3257
3125
  * Per-instance SELECTION: highlight the occurrences of `expressIds` by setting
3258
3126
  * their flag byte (bit 0) and clearing the previously-selected ones. The shader
3259
3127
  * (vs_instanced -> fs_main) applies the blue highlight per occurrence, so no
3260
3128
  * re-draw is needed. No-op until a shard has been uploaded.
3261
- */
3262
- setInstancedSelection(expressIds) {
3129
+ * #4382: when the item filter is set, only that expressId's matching-itemId
3130
+ * occurrences get the selected bit; every other selected id stays whole-product. */
3131
+ setInstancedSelection(expressIds, itemFilterExpressId, itemFilterItemId) {
3263
3132
  const device = this.instancedDevice;
3264
3133
  if (!device || this.instancedTemplates.length === 0)
3265
3134
  return;
3266
3135
  // Called every render frame from the renderer. Fast-path an UNCHANGED selection
3267
3136
  // (the common orbit case, especially the empty set) so we skip both the per-frame
3268
3137
  // writeBuffer loops AND the `new Set(...)` allocation — equal sizes + full
3269
- // containment ⇒ set equality.
3138
+ // containment ⇒ set equality. The item filter is cheap to compare directly.
3270
3139
  let changed = expressIds.size !== this.instancedSelected.size;
3271
3140
  if (!changed) {
3272
3141
  for (const eid of expressIds) {
@@ -3276,12 +3145,16 @@ export class Scene {
3276
3145
  }
3277
3146
  }
3278
3147
  }
3279
- if (!changed)
3148
+ const itemFilterChanged = itemFilterExpressId !== this.instancedSelectedItemExpressId || itemFilterItemId !== this.instancedSelectedItemId;
3149
+ if (!changed && !itemFilterChanged)
3280
3150
  return;
3281
3151
  // Re-derive the combined flag lane (selected | hidden) for every occurrence whose
3282
3152
  // selected-membership flips, so we never clobber the hidden bit.
3283
3153
  const prev = this.instancedSelected;
3284
3154
  this.instancedSelected = new Set(expressIds);
3155
+ const prevItemFilterExpressId = this.instancedSelectedItemExpressId;
3156
+ this.instancedSelectedItemExpressId = itemFilterExpressId;
3157
+ this.instancedSelectedItemId = itemFilterItemId;
3285
3158
  for (const eid of prev) {
3286
3159
  if (!expressIds.has(eid)) {
3287
3160
  this.writeInstanceFlags(device, eid);
@@ -3294,6 +3167,15 @@ export class Scene {
3294
3167
  this.bumpTemplateSelectedCount(eid, +1);
3295
3168
  }
3296
3169
  }
3170
+ // Item filter moved without a membership change (in-product item switch) —
3171
+ // the diffs above never touch that eid; membership-flip eids are done already.
3172
+ if (itemFilterChanged) {
3173
+ const affected = [prevItemFilterExpressId, itemFilterExpressId].filter((e) => e !== undefined);
3174
+ for (const eid of new Set(affected)) {
3175
+ if (prev.has(eid) === expressIds.has(eid) && expressIds.has(eid))
3176
+ this.writeInstanceFlags(device, eid);
3177
+ }
3178
+ }
3297
3179
  }
3298
3180
  /** Keep each template's selectedCount in sync with selection flips so the
3299
3181
  * render loop can exempt templates with selected occurrences from
@@ -3460,20 +3342,24 @@ export class Scene {
3460
3342
  const first = locs?.[0];
3461
3343
  return first ? first.originalColor : null;
3462
3344
  }
3463
- /** Write the combined flag lane (selected | hidden) for every occurrence of `eid`.
3464
- * Folding both bits here means selection and visibility updates never clobber each
3465
- * other (they share the one u32 flags lane at INSTANCE_FLAGS_OFFSET). */
3345
+ /** Write the combined flag lane (selected | hidden) for every occurrence of `eid`
3346
+ * (shares one u32 lane at INSTANCE_FLAGS_OFFSET, so both bits are folded here).
3347
+ * #4382: for the item-filtered eid, selected narrows per-occurrence to
3348
+ * `loc.itemId === instancedSelectedItemId` instead of one shared word. */
3466
3349
  writeInstanceFlags(device, eid) {
3467
3350
  const locs = this.instancedEntityMap.get(eid);
3468
3351
  if (!locs)
3469
3352
  return;
3470
- const flags = (this.instancedSelected.has(eid) ? INSTANCE_FLAG_SELECTED : 0) |
3471
- (this.instancedHidden.has(eid) ? INSTANCE_FLAG_HIDDEN : 0);
3472
- const data = new Uint32Array([flags >>> 0]);
3353
+ const hiddenBit = this.instancedHidden.has(eid) || this.instanceSuppression.has(eid) ? INSTANCE_FLAG_HIDDEN : 0;
3354
+ const eidSelected = this.instancedSelected.has(eid);
3355
+ const itemRestricted = eidSelected && eid === this.instancedSelectedItemExpressId;
3473
3356
  for (const loc of locs) {
3357
+ const selectedBit = itemRestricted
3358
+ ? (loc.itemId === this.instancedSelectedItemId ? INSTANCE_FLAG_SELECTED : 0)
3359
+ : (eidSelected ? INSTANCE_FLAG_SELECTED : 0);
3474
3360
  const buf = this.instancedTemplates[loc.templateIndex]?.instanceBuffer;
3475
3361
  if (buf)
3476
- device.queue.writeBuffer(buf, loc.byteOffset + INSTANCE_FLAGS_OFFSET, data);
3362
+ device.queue.writeBuffer(buf, loc.byteOffset + INSTANCE_FLAGS_OFFSET, new Uint32Array([(selectedBit | hiddenBit) >>> 0]));
3477
3363
  }
3478
3364
  }
3479
3365
  writeInstanceColor(device, eid, rgba) {
@@ -3513,136 +3399,111 @@ export class Scene {
3513
3399
  return Boolean(meshData.uvs) &&
3514
3400
  Boolean(meshData.texture || (meshData.textureRef && meshData.textureBitmap));
3515
3401
  }
3516
- /**
3517
- * Interleave a textured mesh's vertices into the stride-36 layout
3518
- * `[px,py,pz, nx,ny,nz, entityId(u32), u,v]`. Shared by initial upload and
3519
- * the translate re-upload so the two can't drift. Returns null when the mesh
3520
- * has no texture/uvs/geometry.
3521
- */
3522
- interleaveTexturedVertices(meshData) {
3523
- const uvs = meshData.uvs;
3524
- if (!Scene.hasRenderableTexture(meshData) || !uvs)
3525
- return null;
3526
- const positions = meshData.positions;
3527
- const normals = meshData.normals;
3528
- const vertexCount = positions.length / 3;
3529
- if (vertexCount === 0 || meshData.indices.length === 0)
3530
- return null;
3531
- const interleaved = new ArrayBuffer(vertexCount * 36);
3532
- const f = new Float32Array(interleaved);
3533
- const u = new Uint32Array(interleaved);
3534
- const entityIds = meshData.entityIds;
3535
- // Match mergeGeometry's entityId-lane packing so an overlay (lens/IDS/...)
3536
- // drawn over a textured mesh computes the same z-nudge → depthCompare:'equal'
3537
- // matches. High 8 bits = colour salt, low 24 = picking id.
3538
- const saltByte = colorSaltByte(meshData.color);
3539
- for (let i = 0; i < vertexCount; i++) {
3540
- const o = i * 9;
3541
- f[o] = positions[i * 3];
3542
- f[o + 1] = positions[i * 3 + 1];
3543
- f[o + 2] = positions[i * 3 + 2];
3544
- f[o + 3] = normals[i * 3] ?? 0;
3545
- f[o + 4] = normals[i * 3 + 1] ?? 0;
3546
- f[o + 5] = normals[i * 3 + 2] ?? 0;
3547
- u[o + 6] = packEntityLane(entityIds ? entityIds[i] : meshData.expressId, saltByte);
3548
- f[o + 7] = uvs[i * 2] ?? 0;
3549
- f[o + 8] = uvs[i * 2 + 1] ?? 0;
3550
- }
3551
- return interleaved;
3552
- }
3553
3402
  createTexturedMesh(meshData, device, pipeline) {
3554
3403
  const tex = meshData.texture;
3555
3404
  const ref = meshData.textureRef;
3556
3405
  const bitmap = meshData.textureBitmap;
3557
- const interleaved = this.interleaveTexturedVertices(meshData);
3406
+ const interleaved = interleaveTexturedVertices(meshData);
3558
3407
  if (!interleaved || !(tex || (ref && bitmap)))
3559
3408
  return;
3560
3409
  this.texturedDevice = device; // reused by translateMeshesForEntity re-upload
3561
- const vertexBuffer = device.createBuffer({
3562
- size: interleaved.byteLength,
3563
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
3564
- });
3565
- device.queue.writeBuffer(vertexBuffer, 0, interleaved);
3566
- const indexBuffer = device.createBuffer({
3567
- size: meshData.indices.byteLength,
3568
- usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
3569
- });
3570
- device.queue.writeBuffer(indexBuffer, 0, meshData.indices);
3410
+ let vertexBuffer;
3411
+ let indexBuffer;
3412
+ let uniformBuffer;
3571
3413
  let texture;
3572
3414
  let sharedTextureKey;
3573
- if (tex) {
3574
- // #961: upload the Rust-decoded RGBA8 verbatim — no image decoding in JS.
3575
- texture = device.createTexture({
3576
- size: { width: tex.width, height: tex.height },
3577
- format: 'rgba8unorm',
3578
- usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
3415
+ try {
3416
+ vertexBuffer = device.createBuffer({
3417
+ size: interleaved.byteLength,
3418
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
3579
3419
  });
3580
- device.queue.writeTexture({ texture }, tex.rgba, { bytesPerRow: tex.width * 4, rowsPerImage: tex.height }, { width: tex.width, height: tex.height });
3420
+ device.queue.writeBuffer(vertexBuffer, 0, interleaved);
3421
+ indexBuffer = device.createBuffer({
3422
+ size: meshData.indices.byteLength,
3423
+ usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
3424
+ });
3425
+ device.queue.writeBuffer(indexBuffer, 0, meshData.indices);
3426
+ if (tex) {
3427
+ // Share room-decoded pixels across surfaces and streaming fragments (#4232).
3428
+ texture = this.rgbaTexturePool.acquire(tex, device);
3429
+ }
3430
+ else {
3431
+ // #1781: external image texture — the viewer decoded the `.ifcZIP`
3432
+ // sibling to an ImageBitmap once per textureId; upload it ONCE and share
3433
+ // the GPU texture across every mesh sampling it (real files map one
3434
+ // 4096² image from dozens of face sets — per-mesh copies would be GBs).
3435
+ const refKey = ref.textureId;
3436
+ const bmp = bitmap;
3437
+ let entry = this.sharedTextures.get(refKey);
3438
+ if (!entry) {
3439
+ const gpuTex = device.createTexture({
3440
+ size: { width: bmp.width, height: bmp.height },
3441
+ format: 'rgba8unorm',
3442
+ // RENDER_ATTACHMENT is required by copyExternalImageToTexture.
3443
+ usage: GPUTextureUsage.TEXTURE_BINDING |
3444
+ GPUTextureUsage.COPY_DST |
3445
+ GPUTextureUsage.RENDER_ATTACHMENT,
3446
+ });
3447
+ texture = gpuTex; // Owned locally until upload and registry insertion succeed.
3448
+ device.queue.copyExternalImageToTexture({ source: bmp }, { texture: gpuTex }, { width: bmp.width, height: bmp.height });
3449
+ entry = { texture: gpuTex, refs: 0 };
3450
+ this.sharedTextures.set(refKey, entry);
3451
+ }
3452
+ entry.refs++;
3453
+ texture = entry.texture;
3454
+ sharedTextureKey = refKey;
3455
+ }
3456
+ const repeatS = tex ? tex.repeatS : ref.repeatS;
3457
+ const repeatT = tex ? tex.repeatT : ref.repeatT;
3458
+ const wrap = (repeat) => (repeat ? 'repeat' : 'clamp-to-edge');
3459
+ const sampler = device.createSampler({
3460
+ addressModeU: wrap(repeatS),
3461
+ addressModeV: wrap(repeatT),
3462
+ magFilter: 'linear',
3463
+ minFilter: 'linear',
3464
+ mipmapFilter: 'linear',
3465
+ });
3466
+ uniformBuffer = device.createBuffer({
3467
+ size: pipeline.getUniformBufferSize(),
3468
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
3469
+ });
3470
+ const bindGroup = pipeline.createTexturedBindGroup(uniformBuffer, texture.createView(), sampler);
3471
+ const box = worldAabbFromPieces([meshData]);
3472
+ this.texturedMeshes.push(this.modelTranslations.registerDrawable({
3473
+ expressId: meshData.expressId, modelIndex: meshData.modelIndex,
3474
+ bounds: box ? { min: [box.min.x, box.min.y, box.min.z], max: [box.max.x, box.max.y, box.max.z] } : undefined,
3475
+ vertexBuffer,
3476
+ indexBuffer,
3477
+ indexCount: meshData.indices.length,
3478
+ uniformBuffer,
3479
+ texture,
3480
+ sampler,
3481
+ bindGroup,
3482
+ color: meshData.color,
3483
+ // `world = origin + position` (#1973). Absent on the orphan
3484
+ // type-geometry path, whose positions are already absolute.
3485
+ origin: meshData.origin
3486
+ ? [meshData.origin[0], meshData.origin[1], meshData.origin[2]]
3487
+ : [0, 0, 0],
3488
+ ...(sharedTextureKey !== undefined ? { sharedTextureKey } : {}),
3489
+ }, meshData.modelIndex ?? 0));
3490
+ }
3491
+ catch (error) {
3492
+ vertexBuffer?.destroy();
3493
+ indexBuffer?.destroy();
3494
+ uniformBuffer?.destroy();
3495
+ if (texture)
3496
+ this.releaseTexturedMeshTexture({ texture, sharedTextureKey });
3497
+ throw error;
3581
3498
  }
3582
- else {
3583
- // #1781: external image texture — the viewer decoded the `.ifcZIP`
3584
- // sibling to an ImageBitmap once per textureId; upload it ONCE and share
3585
- // the GPU texture across every mesh sampling it (real files map one
3586
- // 4096² image from dozens of face sets — per-mesh copies would be GBs).
3587
- const refKey = ref.textureId;
3588
- const bmp = bitmap;
3589
- let entry = this.sharedTextures.get(refKey);
3590
- if (!entry) {
3591
- const gpuTex = device.createTexture({
3592
- size: { width: bmp.width, height: bmp.height },
3593
- format: 'rgba8unorm',
3594
- // RENDER_ATTACHMENT is required by copyExternalImageToTexture.
3595
- usage: GPUTextureUsage.TEXTURE_BINDING |
3596
- GPUTextureUsage.COPY_DST |
3597
- GPUTextureUsage.RENDER_ATTACHMENT,
3598
- });
3599
- device.queue.copyExternalImageToTexture({ source: bmp }, { texture: gpuTex }, { width: bmp.width, height: bmp.height });
3600
- entry = { texture: gpuTex, refs: 0 };
3601
- this.sharedTextures.set(refKey, entry);
3602
- }
3603
- entry.refs++;
3604
- texture = entry.texture;
3605
- sharedTextureKey = refKey;
3606
- }
3607
- const repeatS = tex ? tex.repeatS : ref.repeatS;
3608
- const repeatT = tex ? tex.repeatT : ref.repeatT;
3609
- const wrap = (repeat) => (repeat ? 'repeat' : 'clamp-to-edge');
3610
- const sampler = device.createSampler({
3611
- addressModeU: wrap(repeatS),
3612
- addressModeV: wrap(repeatT),
3613
- magFilter: 'linear',
3614
- minFilter: 'linear',
3615
- mipmapFilter: 'linear',
3616
- });
3617
- const uniformBuffer = device.createBuffer({
3618
- size: pipeline.getUniformBufferSize(),
3619
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
3620
- });
3621
- const bindGroup = pipeline.createTexturedBindGroup(uniformBuffer, texture.createView(), sampler);
3622
- this.texturedMeshes.push({
3623
- expressId: meshData.expressId,
3624
- vertexBuffer,
3625
- indexBuffer,
3626
- indexCount: meshData.indices.length,
3627
- uniformBuffer,
3628
- texture,
3629
- sampler,
3630
- bindGroup,
3631
- color: meshData.color,
3632
- // `world = origin + position` (#1973). Absent on the orphan
3633
- // type-geometry path, whose positions are already absolute.
3634
- origin: meshData.origin
3635
- ? [meshData.origin[0], meshData.origin[1], meshData.origin[2]]
3636
- : [0, 0, 0],
3637
- ...(sharedTextureKey !== undefined ? { sharedTextureKey } : {}),
3638
- });
3639
3499
  }
3640
3500
  /** Release a textured mesh's GPU texture: shared (#1781) entries decrement
3641
- * the registry refcount and die with their LAST reference; per-mesh (#961)
3642
- * uploads are destroyed outright. */
3501
+ * the registry refcount and die with their LAST reference. Decoded RGBA
3502
+ * textures use the pixel pool; unregistered partial uploads are destroyed. */
3643
3503
  releaseTexturedMeshTexture(tm) {
3644
3504
  if (tm.sharedTextureKey === undefined) {
3645
- tm.texture.destroy();
3505
+ if (!this.rgbaTexturePool.release(tm.texture))
3506
+ tm.texture.destroy();
3646
3507
  return;
3647
3508
  }
3648
3509
  const entry = this.sharedTextures.get(tm.sharedTextureKey);
@@ -3650,8 +3511,8 @@ export class Scene {
3650
3511
  return;
3651
3512
  entry.refs--;
3652
3513
  if (entry.refs <= 0) {
3653
- entry.texture.destroy();
3654
3514
  this.sharedTextures.delete(tm.sharedTextureKey);
3515
+ entry.texture.destroy();
3655
3516
  }
3656
3517
  }
3657
3518
  /**
@@ -3672,8 +3533,10 @@ export class Scene {
3672
3533
  this.instancedTemplates = [];
3673
3534
  this.liveInstancedTemplates = [];
3674
3535
  this.instancedTemplateCpu = [];
3536
+ this.instanceSuppression.forget();
3675
3537
  this.instancedEntityMap.clear();
3676
3538
  this.instancedSelected.clear();
3539
+ this.instancedSelectedItemExpressId = this.instancedSelectedItemId = undefined;
3677
3540
  this.instancedHidden.clear();
3678
3541
  this.instancedOverridden.clear();
3679
3542
  this.instancedGhosted.clear();
@@ -3687,6 +3550,7 @@ export class Scene {
3687
3550
  this.instancedDevice = undefined;
3688
3551
  }
3689
3552
  clear() {
3553
+ this.modelTranslations.clear();
3690
3554
  // GPU-instancing templates own their vertex/index/instance buffers.
3691
3555
  // (Freed slots are holes whose buffers are already destroyed — skip them so
3692
3556
  // a per-model removal followed by clear() can't double-destroy.)
@@ -3694,24 +3558,35 @@ export class Scene {
3694
3558
  this.clearFlatGeometry();
3695
3559
  }
3696
3560
  /**
3697
- * Clear flat/batched geometry (meshes, batches, buckets, textured meshes,
3698
- * colour overlays, streaming state, residency bookkeeping) WITHOUT
3699
- * touching GPU-instanced templates (#2073). A reshape that still has at
3700
- * least one model present should call this instead of `clear()`, then
3701
- * reconcile instanced ownership with `removeInstancedTemplatesForModel`
3702
- * for any model that did NOT survive that way a still-loaded model's
3703
- * repeated geometry (windows, doors, bolts, ...) stays resident across a
3704
- * visibility toggle / in-place content mutation / federated model add
3705
- * instead of silently vanishing (nothing re-uploads instanced shard bytes
3706
- * after their one-time drain).
3707
- *
3708
- * Bounding boxes are only dropped for ids with NO surviving instanced
3709
- * occurrence — an instanced-only id's box must outlive this call so
3710
- * raycast / measure / section keep working for the geometry that was
3711
- * just retained; a flat-only id's box is stale the moment its mesh data
3712
- * is gone, so it is dropped like everything else here.
3561
+ * Clear flat/batched geometry, textures, overlays and streaming/residency
3562
+ * state while preserving GPU-instanced templates (#2073). Reshapes use this
3563
+ * instead of clear(), then removeInstancedTemplatesForModel for departed
3564
+ * models: surviving shards are not uploaded again after their initial drain.
3565
+ * Drop retained flat bounds and rebuild boxes from surviving instances so
3566
+ * picking and sections cannot see a removed flat contribution (#4226).
3713
3567
  */
3714
3568
  clearFlatGeometry() {
3569
+ this.authoredGeneration++;
3570
+ this.instanceSuppression.restore();
3571
+ this.appearanceController?.forget();
3572
+ this.clearFlatBuffers();
3573
+ }
3574
+ /** Reconcile an ordinary source-geometry rebuild; exact surviving appearance
3575
+ * owners keep their original-instance history. Full reset remains separate. */
3576
+ clearFlatGeometryForRebuild(geometry, models, sourceGeometry = geometry) {
3577
+ this.authoredGeneration++;
3578
+ const retained = this.appearanceController?.prepareRebuild(sourceGeometry, models) ?? new Set();
3579
+ const discarded = this.appearanceController?.discardedForRebuild(retained) ?? [];
3580
+ // A discarded converted owner must not resurrect its obsolete type instance.
3581
+ // Its GPU slots are already hidden, so tombstoning after this atomic restore
3582
+ // performs no GPU writes and cannot leave a partially restored rebuild.
3583
+ this.instanceSuppression.restore(new Set([...retained, ...discarded]));
3584
+ for (const id of discarded)
3585
+ this.removeInstancedEntity(id);
3586
+ this.appearanceController?.finishRebuild(retained);
3587
+ this.clearFlatBuffers();
3588
+ }
3589
+ clearFlatBuffers() {
3715
3590
  for (const mesh of this.meshes)
3716
3591
  destroyGpuResources(mesh);
3717
3592
  for (const batch of this.batchedMeshes)
@@ -3728,6 +3603,7 @@ export class Scene {
3728
3603
  for (const entry of this.sharedTextures.values())
3729
3604
  entry.texture.destroy();
3730
3605
  this.sharedTextures.clear();
3606
+ this.rgbaTexturePool.clear();
3731
3607
  // Clear partial batch cache (destroys buffers + drops all cache maps)
3732
3608
  this.dropAllPartialCaches();
3733
3609
  this.colorOverrideGeneration++;
@@ -3739,17 +3615,16 @@ export class Scene {
3739
3615
  // instanced templates are unaffected — their per-occurrence transforms are
3740
3616
  // already baked to absolute world coordinates at upload time, not relative
3741
3617
  // to this origin.
3742
- this.sharedFrameOrigin = null;
3618
+ this.sharedFrameOrigins.clear();
3743
3619
  this.meshes = [];
3744
3620
  this.batchedMeshes = [];
3745
3621
  this.buckets.clear();
3746
3622
  this.meshDataBucket = new Map();
3747
3623
  this.meshDataMap.clear();
3748
- for (const eid of [...this.boundingBoxes.keys()]) {
3749
- if (!this.instancedEntityMap.has(eid)) {
3750
- this.boundingBoxes.delete(eid);
3751
- }
3752
- }
3624
+ this.modelTranslations.clearFlatBounds();
3625
+ this.boundingBoxes.clear();
3626
+ for (const eid of this.instancedEntityMap.keys())
3627
+ this.recomputeInstancedBounds(eid);
3753
3628
  this.activeBucketKey.clear();
3754
3629
  this.lastDrawnFrame.clear();
3755
3630
  this.residencyRestoreQueue.clear();
@@ -3847,7 +3722,7 @@ export class Scene {
3847
3722
  // enumerating geometry see them too. IDs only — no geometry materialized.
3848
3723
  // (#1238 review)
3849
3724
  const ids = new Set(this.meshDataMap.keys());
3850
- for (const eid of this.instancedEntityMap.keys())
3725
+ for (const eid of this.getInstancedEntityIds())
3851
3726
  ids.add(eid);
3852
3727
  return Array.from(ids);
3853
3728
  }
@@ -3858,20 +3733,9 @@ export class Scene {
3858
3733
  * @returns Bounding box with min/max corners, or null if no mesh data exists
3859
3734
  */
3860
3735
  getEntityBoundingBox(expressId) {
3861
- // Check cache first
3862
- const cached = this.boundingBoxes.get(expressId);
3863
- if (cached)
3864
- return cached;
3865
- // Compute from mesh data. `null` covers both "no pieces at all" and
3866
- // "pieces with no vertex a box can be built from" — and, critically, is
3867
- // NOT cached (#2480): a transient empty piece must not poison the entry
3868
- // for an entity that later gains real geometry, and this cache has no
3869
- // invalidation tied to that.
3870
- const bbox = worldAabbFromPieces(this.meshDataMap.get(expressId));
3871
- if (!bbox)
3736
+ if (this.instanceSuppression.has(expressId) && !this.meshDataMap.has(expressId))
3872
3737
  return null;
3873
- this.boundingBoxes.set(expressId, bbox);
3874
- return bbox;
3738
+ return cachedWorldAabb(expressId, this.meshDataMap.get(expressId), this.boundingBoxes);
3875
3739
  }
3876
3740
  /**
3877
3741
  * Local (pre-placement, object-space) AABB for an entity (issue #1474) — the