@ifc-lite/renderer 3.0.1 → 3.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 (111) hide show
  1. package/README.md +22 -0
  2. package/dist/appearance-preview.d.ts +2 -0
  3. package/dist/appearance-preview.d.ts.map +1 -1
  4. package/dist/appearance-preview.js +4 -0
  5. package/dist/appearance-preview.js.map +1 -1
  6. package/dist/camera-animation-immediate.d.ts +5 -0
  7. package/dist/camera-animation-immediate.d.ts.map +1 -0
  8. package/dist/camera-animation-immediate.js +16 -0
  9. package/dist/camera-animation-immediate.js.map +1 -0
  10. package/dist/camera-animation.d.ts +3 -9
  11. package/dist/camera-animation.d.ts.map +1 -1
  12. package/dist/camera-animation.js +14 -9
  13. package/dist/camera-animation.js.map +1 -1
  14. package/dist/device-recovery.d.ts +60 -0
  15. package/dist/device-recovery.d.ts.map +1 -0
  16. package/dist/device-recovery.js +153 -0
  17. package/dist/device-recovery.js.map +1 -0
  18. package/dist/device.d.ts +2 -0
  19. package/dist/device.d.ts.map +1 -1
  20. package/dist/device.js +12 -0
  21. package/dist/device.js.map +1 -1
  22. package/dist/gpu-upload-guard.d.ts +106 -0
  23. package/dist/gpu-upload-guard.d.ts.map +1 -0
  24. package/dist/gpu-upload-guard.js +102 -0
  25. package/dist/gpu-upload-guard.js.map +1 -0
  26. package/dist/index.d.ts +44 -21
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +156 -139
  29. package/dist/index.js.map +1 -1
  30. package/dist/instanced-override-color.d.ts +8 -0
  31. package/dist/instanced-override-color.d.ts.map +1 -1
  32. package/dist/instanced-override-color.js +10 -0
  33. package/dist/instanced-override-color.js.map +1 -1
  34. package/dist/merged-mesh-extract.d.ts.map +1 -1
  35. package/dist/merged-mesh-extract.js +5 -0
  36. package/dist/merged-mesh-extract.js.map +1 -1
  37. package/dist/mesh-entity-hosting.d.ts +14 -0
  38. package/dist/mesh-entity-hosting.d.ts.map +1 -0
  39. package/dist/mesh-entity-hosting.js +25 -0
  40. package/dist/mesh-entity-hosting.js.map +1 -0
  41. package/dist/model-translation.d.ts +39 -1
  42. package/dist/model-translation.d.ts.map +1 -1
  43. package/dist/model-translation.js +103 -16
  44. package/dist/model-translation.js.map +1 -1
  45. package/dist/picking-manager.d.ts +2 -1
  46. package/dist/picking-manager.d.ts.map +1 -1
  47. package/dist/picking-manager.js +11 -2
  48. package/dist/picking-manager.js.map +1 -1
  49. package/dist/reference-images.d.ts +2 -0
  50. package/dist/reference-images.d.ts.map +1 -1
  51. package/dist/reference-images.js +2 -0
  52. package/dist/reference-images.js.map +1 -1
  53. package/dist/renderer-overlays.d.ts +3 -0
  54. package/dist/renderer-overlays.d.ts.map +1 -1
  55. package/dist/renderer-overlays.js +13 -0
  56. package/dist/renderer-overlays.js.map +1 -1
  57. package/dist/renderer-symbolic-overlays.d.ts +1 -0
  58. package/dist/renderer-symbolic-overlays.d.ts.map +1 -1
  59. package/dist/renderer-symbolic-overlays.js +3 -0
  60. package/dist/renderer-symbolic-overlays.js.map +1 -1
  61. package/dist/scene-appearance-preview.d.ts +2 -0
  62. package/dist/scene-appearance-preview.d.ts.map +1 -1
  63. package/dist/scene-appearance-preview.js +9 -0
  64. package/dist/scene-appearance-preview.js.map +1 -1
  65. package/dist/scene-authored-owner.d.ts +10 -0
  66. package/dist/scene-authored-owner.d.ts.map +1 -1
  67. package/dist/scene-authored-owner.js +44 -5
  68. package/dist/scene-authored-owner.js.map +1 -1
  69. package/dist/scene-batch-rebuild.d.ts +18 -0
  70. package/dist/scene-batch-rebuild.d.ts.map +1 -0
  71. package/dist/scene-batch-rebuild.js +49 -0
  72. package/dist/scene-batch-rebuild.js.map +1 -0
  73. package/dist/scene-batch-upload.d.ts +7 -2
  74. package/dist/scene-batch-upload.d.ts.map +1 -1
  75. package/dist/scene-batch-upload.js +26 -1
  76. package/dist/scene-batch-upload.js.map +1 -1
  77. package/dist/scene-bucket-routing.d.ts +21 -0
  78. package/dist/scene-bucket-routing.d.ts.map +1 -0
  79. package/dist/scene-bucket-routing.js +44 -0
  80. package/dist/scene-bucket-routing.js.map +1 -0
  81. package/dist/scene-contents.d.ts +8 -0
  82. package/dist/scene-contents.d.ts.map +1 -1
  83. package/dist/scene-derived-batches.d.ts +84 -0
  84. package/dist/scene-derived-batches.d.ts.map +1 -0
  85. package/dist/scene-derived-batches.js +73 -0
  86. package/dist/scene-derived-batches.js.map +1 -0
  87. package/dist/scene-derived-mesh-provenance.d.ts +13 -0
  88. package/dist/scene-derived-mesh-provenance.d.ts.map +1 -0
  89. package/dist/scene-derived-mesh-provenance.js +30 -0
  90. package/dist/scene-derived-mesh-provenance.js.map +1 -0
  91. package/dist/scene-device-recovery.d.ts +103 -0
  92. package/dist/scene-device-recovery.d.ts.map +1 -0
  93. package/dist/scene-device-recovery.js +299 -0
  94. package/dist/scene-device-recovery.js.map +1 -0
  95. package/dist/scene-geometry.d.ts +1 -0
  96. package/dist/scene-geometry.d.ts.map +1 -1
  97. package/dist/scene-geometry.js +3 -23
  98. package/dist/scene-geometry.js.map +1 -1
  99. package/dist/scene-model-translation.d.ts +11 -1
  100. package/dist/scene-model-translation.d.ts.map +1 -1
  101. package/dist/scene-model-translation.js +22 -0
  102. package/dist/scene-model-translation.js.map +1 -1
  103. package/dist/scene-precision.d.ts +16 -0
  104. package/dist/scene-precision.d.ts.map +1 -0
  105. package/dist/scene-precision.js +73 -0
  106. package/dist/scene-precision.js.map +1 -0
  107. package/dist/scene.d.ts +46 -42
  108. package/dist/scene.d.ts.map +1 -1
  109. package/dist/scene.js +211 -208
  110. package/dist/scene.js.map +1 -1
  111. package/package.json +2 -2
package/dist/scene.js CHANGED
@@ -4,29 +4,34 @@
4
4
  import { materializeInstances } from './scene-instance-materialization.js';
5
5
  import { InstanceSuppression } from './scene-instance-suppression.js';
6
6
  import { createSceneBatch } from './scene-batch-upload.js';
7
- import { createSceneAppearancePreview } from './scene-appearance-preview.js';
7
+ import { createSceneAppearancePreview, rebindSceneAppearanceAccess } from './scene-appearance-preview.js';
8
8
  import { AppearanceBuckets } from './scene-appearance-buckets.js';
9
- import { prepareSceneAuthoredOwner } from './scene-authored-owner.js';
9
+ import { AuthoredPreparationRegistry, prepareSceneAuthoredOwner } from './scene-authored-owner.js';
10
10
  import { interleaveTexturedVertices } from './textured-vertices.js';
11
11
  import { RgbaTexturePool } from './rgba-texture-pool.js';
12
12
  import { splitMeshForStreaming } from './scene-stream-split.js';
13
+ import { hostsOtherEntities } from './mesh-entity-hosting.js';
13
14
  import { BATCH_CONSTANTS } from './constants.js';
14
15
  import { prepareRayDirInv, raycastBoundingBoxes, raycastTriangles, rayIntersectsBox, } from './scene-raycaster.js';
15
16
  import { selectBoundingBoxesInRect } from './scene-rect-select.js';
16
- import { splitMeshDataForBufferLimit, cachedWorldAabb, worldAabbFromPieces, destroyGpuResources } from './scene-geometry.js';
17
+ import { splitMeshDataForBufferLimit, cachedWorldAabb, worldAabbFromPieces, destroyGpuResources, topologySafeBatchOrigin } from './scene-geometry.js';
18
+ import { resolvePrecisionBucket } from './scene-bucket-routing.js';
17
19
  import { sumResidentGpuBytes } from './render-stats.js';
18
- import { composeInstancedOverrideColor } from './instanced-override-color.js';
20
+ import { composeInstancedOverrideColor, writeOriginalInstancedColors } from './instanced-override-color.js';
19
21
  import { bucketBaseKeyFor } from './chunk-grid.js';
22
+ import { inheritedQuantization, groupOverridePieces, cloneOverrides, overlaysInvalidatedBy } from './scene-derived-batches.js';
20
23
  import { VisibilityEpochTracker } from './visibility-epoch.js';
21
24
  import { isEntityVisible } from './entity-visibility.js';
22
25
  import { planInstancedGhosting } from './instanced-ghost-plan.js';
23
26
  import { selectEvictions } from './residency.js';
24
27
  import { OPAQUE_ALPHA_CUTOFF } from './overlay-routing.js';
25
- import { translateSceneModel, releaseInstanceVertices, refreshTexturedBounds } from './scene-model-translation.js';
28
+ import { translateSceneModel, rotateSceneModelInstances, releaseInstanceVertices, refreshTexturedBounds } from './scene-model-translation.js';
26
29
  import { ModelTranslations } from './model-translation.js';
27
- import { extractEntityFromMergedMesh } from './merged-mesh-extract.js';
30
+ import { DerivedMeshProvenance } from './scene-derived-mesh-provenance.js';
31
+ import { rebuildSceneBatches } from './scene-batch-rebuild.js';
28
32
  import { dropAllPartialCaches as dropAllPartialCachesIn, dropPartialCacheForBatch as dropPartialCacheForBatchIn, retireUnusedAlphaSlots as retireUnusedAlphaSlotsIn, } from './partial-batch-cache.js';
29
33
  import { prepareInstancedRender, foldOccurrenceWorldBox, INSTANCE_STRIDE_BYTES, INSTANCE_COLOR_OFFSET, INSTANCE_FLAGS_OFFSET, INSTANCE_FLAG_SELECTED, INSTANCE_FLAG_HIDDEN, } from './instanced-render.js';
34
+ import { discardSceneGpuResourcesForRecovery, prepareSceneDeviceRecovery, repartitionHydratedRecoveryBucket, restoreSceneGpuResourcesAfterRecovery } from './scene-device-recovery.js';
30
35
  const EMPTY_INSTANCED_TEMPLATES = [];
31
36
  /**
32
37
  * Pure helper: compute the exclusive end index of the next flushPending()
@@ -82,6 +87,7 @@ export class Scene {
82
87
  batchedMeshes = []; // flat render array (rebuilt from buckets)
83
88
  buckets = new Map(); // bucketKey -> consolidated bucket state
84
89
  meshDataBucket = new Map(); // reverse lookup: MeshData -> owning bucket
90
+ derivedMeshProvenance = new DerivedMeshProvenance();
85
91
  modelTranslations = new ModelTranslations();
86
92
  meshDataMap = new Map(); // Map expressId -> MeshData[] (for lazy buffer creation, accumulates multiple pieces)
87
93
  boundingBoxes = new Map(); // Map expressId -> bounding box (computed lazily)
@@ -93,7 +99,9 @@ export class Scene {
93
99
  rgbaTexturePool = new RgbaTexturePool();
94
100
  appearanceController;
95
101
  appearanceBuckets;
102
+ appearanceAccessState;
96
103
  authoredGeneration = 0;
104
+ authoredPreparations = new AuthoredPreparationRegistry();
97
105
  appearanceAccess(device, pipeline) {
98
106
  return {
99
107
  meshes: () => this.texturedMeshes, data: this.meshDataMap,
@@ -133,24 +141,23 @@ export class Scene {
133
141
  },
134
142
  };
135
143
  }
144
+ bindAppearanceAccess(device, pipeline) { return this.appearanceAccessState = rebindSceneAppearanceAccess(this.appearanceAccessState, this.appearanceAccess(device, pipeline)); }
136
145
  sharedAppearanceBuckets(access) {
137
146
  return this.appearanceBuckets ??= new AppearanceBuckets(access.buckets, id => this.meshDataMap.get(id));
138
147
  }
139
148
  appearancePreview(device, pipeline) {
140
- const access = this.appearanceAccess(device, pipeline);
149
+ const access = this.bindAppearanceAccess(device, pipeline);
141
150
  return this.appearanceController ??= createSceneAppearancePreview(access, this.sharedAppearanceBuckets(access));
142
151
  }
143
- /** Place canonical native appearance source geometry in this model's live frame. */
144
152
  placeAppearanceSource(mesh) { return this.modelTranslations.placeMesh(mesh); }
145
- /** Retain model-local source coordinates when publishing a placed appearance mesh. */
146
153
  appearanceSourceMesh(mesh) { return this.modelTranslations.sourceFromPlaced(mesh); }
147
154
  /** Stage one new IFC owner with all its coloured or textured geometry parts. */
148
155
  prepareAuthoredOwner(parts, device, pipeline) {
149
- const access = this.appearanceAccess(device, pipeline), generation = this.authoredGeneration;
150
- return prepareSceneAuthoredOwner(access, this.sharedAppearanceBuckets(access), parts, () => {
156
+ const access = this.bindAppearanceAccess(device, pipeline), generation = this.authoredGeneration;
157
+ return this.authoredPreparations.track(prepareSceneAuthoredOwner(access, this.sharedAppearanceBuckets(access), parts, () => {
151
158
  if (generation !== this.authoredGeneration)
152
159
  throw new Error('The scene changed while preparing the object.');
153
- });
160
+ }));
154
161
  }
155
162
  /** Compatibility entry point for an image-backed single-part owner. */
156
163
  prepareTexturedOwner(mesh, device, pipeline) {
@@ -279,6 +286,7 @@ export class Scene {
279
286
  // Overlay batches render on top using depthCompare 'equal', so they only
280
287
  // paint where original geometry already wrote depth. Clearing is instant.
281
288
  overrideBatches = [];
289
+ overlaySources = new Map(); // overridden piece -> bucket key its overlay inherited from (#4832)
282
290
  // Defensively-typed: the renderer is the sole writer (via setColorOverrides),
283
291
  // external readers go through getColorOverrides() and get a ReadonlyMap.
284
292
  colorOverrides = null;
@@ -298,12 +306,11 @@ export class Scene {
298
306
  // the queue via flushPending() with a per-frame time budget.
299
307
  meshQueue = [];
300
308
  meshQueueReadIndex = 0;
301
- // ─── GPU-resident mode ──────────────────────────────────────────────
302
- // After releaseGeometryData(), JS-side typed arrays are freed.
303
- // Only lightweight metadata is retained for operations that don't need
304
- // raw vertex data (bounding boxes, color key lookups, expressId sets).
305
309
  geometryReleased = false;
306
310
  ephemeralStreamingMode = false;
311
+ prepareDeviceRecovery() { return prepareSceneDeviceRecovery(this); }
312
+ discardGpuResourcesForRecovery() { discardSceneGpuResourcesForRecovery(this); }
313
+ restoreGpuResourcesAfterRecovery(device, pipeline) { restoreSceneGpuResourcesAfterRecovery(this, device, pipeline); }
307
314
  /**
308
315
  * Add mesh to scene
309
316
  */
@@ -326,8 +333,10 @@ export class Scene {
326
333
  /** The shared local-frame origin all batches relativize against (null until
327
334
  * the first batch is built). Per-mesh highlight/picker VBOs replicate the
328
335
  * batch's exact f32 path against this so they render bit-coincident. */
329
- getSharedFrameOrigin(modelIndex = 0) {
330
- return this.modelTranslations.frameOrigin(this.sharedFrameOrigins.get(modelIndex) ?? null, modelIndex) ?? null;
336
+ getSharedFrameOrigin(modelIndex = 0, meshData) {
337
+ const placed = meshData ? this.derivedMeshProvenance.placedSourceFor(meshData, this.modelTranslations) : undefined;
338
+ const bucket = placed ? this.meshDataBucket.get(placed) : undefined;
339
+ return bucket?.batchedMesh?.origin ?? bucket?.frameOrigin ?? this.modelTranslations.frameOrigin(this.sharedFrameOrigins.get(modelIndex) ?? null, modelIndex) ?? null;
331
340
  }
332
341
  /**
333
342
  * Enable/disable spatial chunk bucketing (issue #1682 phase 2). When set,
@@ -425,6 +434,7 @@ export class Scene {
425
434
  }
426
435
  const rebuilt = this.createBatchedMesh(bucket.meshData, bucket.meshData[0].color, device, pipeline, key);
427
436
  bucket.batchedMesh = rebuilt;
437
+ bucket.frameOrigin = rebuilt.origin;
428
438
  const idx = this.batchedMeshes.indexOf(old);
429
439
  if (idx >= 0)
430
440
  this.batchedMeshes[idx] = rebuilt;
@@ -458,28 +468,21 @@ export class Scene {
458
468
  * Enable 12-byte lattice-quantized batch vertices (issue #1682 phase 6).
459
469
  * ONLY call after the renderer verified its quantized pipeline variants
460
470
  * exist (see Renderer.enableQuantizedBatches) — quantized buffers are
461
- * undrawable without them. Applies to batches built from now on; every
462
- * createBatchedMesh output (buckets, fragments, partial + override
463
- * batches) quantizes onto the SAME 2^-10 lattice, so depth-equal overlay
464
- * matching and cross-batch coincidence are preserved bit-exactly. Batches
465
- * whose extent exceeds the u16 lattice range fall back to f32 silently.
471
+ * undrawable without them. Applies to batches built from now on: bucket
472
+ * batches and fragments quantize onto the SAME 2^-10 lattice when their
473
+ * extent fits the u16 range (else f32); partial + override batches INHERIT
474
+ * their source batch's decision (#4832, `scene-derived-batches.ts`), so
475
+ * depth-equal overlay matching and cross-batch coincidence stay bit-exact.
466
476
  */
467
477
  setQuantizedBatches(enabled) {
468
478
  this.quantizedBatchesEnabled = enabled;
469
479
  }
470
- /**
471
- * Whether THIS mesh's source batch renders quantized — drives the
472
- * hydrated-mesh lattice snap in createMeshFromData (a mesh whose batch
473
- * fell back to f32, e.g. >64m extent, must NOT snap). Falls back to the
474
- * global flag when the mesh isn't bucketed (mid-stream hydration).
475
- */
480
+ /** Whether THIS mesh's source batch renders quantized — drives the hydrated-mesh
481
+ * lattice snap in createMeshFromData (an f32 batch must NOT snap). Same rule
482
+ * as overlay/partial batches; global flag while unbucketed. */
476
483
  isMeshQuantized(meshData) {
477
- if (!this.quantizedBatchesEnabled)
478
- return false;
479
- const bucket = this.meshDataBucket.get(meshData);
480
- if (bucket?.batchedMesh)
481
- return bucket.batchedMesh.quantized !== undefined;
482
- return true;
484
+ const source = this.derivedMeshProvenance.placedSourceFor(meshData, this.modelTranslations);
485
+ return inheritedQuantization(this.quantizedBatchesEnabled, this.meshDataBucket.get(source)?.batchedMesh) !== 'off';
483
486
  }
484
487
  /** Set (or clear) the HOST budget in bytes for bucket CPU geometry. */
485
488
  setHostResidencyBudget(bytes) {
@@ -634,10 +637,10 @@ export class Scene {
634
637
  this.meshDataBucket.set(m, bucket);
635
638
  this.addMeshData(m);
636
639
  }
637
- this.coldBuckets.delete(key);
638
640
  if (members.length > 0) {
639
- // Warm now — re-queue so the next restore tick rebuilds the GPU batch.
640
- this.residencyRestoreQueue.add(key);
641
+ this.coldBuckets.delete(key);
642
+ for (const restoredKey of repartitionHydratedRecoveryBucket(this, key, bucket, shell))
643
+ this.residencyRestoreQueue.add(restoredKey);
641
644
  }
642
645
  else {
643
646
  console.warn(`[Scene] cold restore for ${key} found no members — bucket stays a shell`);
@@ -669,6 +672,7 @@ export class Scene {
669
672
  continue;
670
673
  const rebuilt = this.createBatchedMesh(bucket.meshData, bucket.meshData[0].color, device, pipeline, bucket.key);
671
674
  bucket.batchedMesh = rebuilt;
675
+ bucket.frameOrigin = rebuilt.origin;
672
676
  const idx = this.batchedMeshes.indexOf(old);
673
677
  if (idx >= 0)
674
678
  this.batchedMeshes[idx] = rebuilt;
@@ -868,7 +872,7 @@ export class Scene {
868
872
  // this expressId so selection highlighting is per-entity, not the
869
873
  // entire merged batch.
870
874
  if (single.entityIds) {
871
- return extractEntityFromMergedMesh(single, expressId);
875
+ return this.derivedMeshProvenance.extract(single, expressId);
872
876
  }
873
877
  return single;
874
878
  }
@@ -878,7 +882,7 @@ export class Scene {
878
882
  const extracted = [];
879
883
  for (const piece of pieces) {
880
884
  if (piece.entityIds) {
881
- const ex = extractEntityFromMergedMesh(piece, expressId);
885
+ const ex = this.derivedMeshProvenance.extract(piece, expressId);
882
886
  if (ex)
883
887
  extracted.push(ex);
884
888
  }
@@ -893,6 +897,18 @@ export class Scene {
893
897
  pieces = extracted;
894
898
  // Fall through to the normal multi-piece merge below
895
899
  }
900
+ // A cross-bucket singular result keeps the historical representative merge
901
+ // (all triangles, no provenance); precise callers use the pieces accessor.
902
+ const firstOrigin = pieces[0].origin;
903
+ const firstSource = this.derivedMeshProvenance.placedSourceFor(pieces[0], this.modelTranslations);
904
+ const firstBucket = this.meshDataBucket.get(firstSource);
905
+ const sameBucket = pieces.every(piece => this.meshDataBucket.get(this.derivedMeshProvenance.placedSourceFor(piece, this.modelTranslations)) === firstBucket);
906
+ const sameFrame = pieces.every(piece => piece.origin?.[0] === firstOrigin?.[0]
907
+ && piece.origin?.[1] === firstOrigin?.[1] && piece.origin?.[2] === firstOrigin?.[2]);
908
+ const precisionPlaceable = sameBucket || (!firstBucket && sameFrame);
909
+ const mergedOrigin = precisionPlaceable
910
+ ? firstBucket?.batchedMesh?.origin ?? firstBucket?.frameOrigin ?? firstOrigin ?? [0, 0, 0]
911
+ : undefined;
896
912
  // Check if all pieces have the same color (within tolerance)
897
913
  // This handles multi-material elements like windows (frame vs glass)
898
914
  const firstColor = pieces[0].color;
@@ -926,8 +942,17 @@ export class Scene {
926
942
  let idxOffset = 0;
927
943
  let vertexOffset = 0;
928
944
  for (const piece of pieces) {
929
- // Copy positions and normals
930
- mergedPositions.set(piece.positions, posOffset);
945
+ if (mergedOrigin) {
946
+ const ox = (piece.origin?.[0] ?? 0) - mergedOrigin[0], oy = (piece.origin?.[1] ?? 0) - mergedOrigin[1], oz = (piece.origin?.[2] ?? 0) - mergedOrigin[2];
947
+ for (let i = 0; i < piece.positions.length; i += 3) {
948
+ mergedPositions[posOffset + i] = piece.positions[i] + ox;
949
+ mergedPositions[posOffset + i + 1] = piece.positions[i + 1] + oy;
950
+ mergedPositions[posOffset + i + 2] = piece.positions[i + 2] + oz;
951
+ }
952
+ }
953
+ else {
954
+ mergedPositions.set(piece.positions, posOffset);
955
+ }
931
956
  mergedNormals.set(piece.normals, posOffset);
932
957
  // Copy indices with offset
933
958
  for (let i = 0; i < piece.indices.length; i++) {
@@ -938,7 +963,7 @@ export class Scene {
938
963
  vertexOffset += piece.positions.length / 3;
939
964
  }
940
965
  // Return merged MeshData (all pieces have same color)
941
- return {
966
+ const merged = {
942
967
  expressId,
943
968
  modelIndex: pieces[0].modelIndex, // Preserve modelIndex for multi-model support
944
969
  positions: mergedPositions,
@@ -946,7 +971,15 @@ export class Scene {
946
971
  indices: mergedIndices,
947
972
  color: firstColor,
948
973
  ifcType: pieces[0].ifcType,
974
+ ...(mergedOrigin ? { origin: mergedOrigin } : {}),
949
975
  };
976
+ // The common frame is explicit above. Keep a source only when every part
977
+ // belongs to the same live bucket; an arbitrary source would give a merged
978
+ // result the wrong quantization/frame after a re-batch.
979
+ if (firstBucket && precisionPlaceable) {
980
+ this.derivedMeshProvenance.remember(merged, firstSource);
981
+ }
982
+ return merged;
950
983
  }
951
984
  /**
952
985
  * Check if MeshData exists for an expressId
@@ -1018,7 +1051,7 @@ export class Scene {
1018
1051
  const extracted = [];
1019
1052
  for (const piece of pieces) {
1020
1053
  if (piece.entityIds) {
1021
- const ex = extractEntityFromMergedMesh(piece, expressId);
1054
+ const ex = this.derivedMeshProvenance.extract(piece, expressId);
1022
1055
  if (ex)
1023
1056
  extracted.push(ex);
1024
1057
  }
@@ -1054,10 +1087,20 @@ export class Scene {
1054
1087
  * when streaming completes to do one O(N) full merge.
1055
1088
  */
1056
1089
  appendToBatches(meshDataArray, device, pipeline, isStreaming = false) {
1090
+ meshDataArray = meshDataArray.map((mesh) => this.modelTranslations.placeMesh(mesh));
1091
+ // Validate every input before cancelling appearance work, publishing a
1092
+ // bucket, or touching GPU state. A single impossible f32 frame must leave
1093
+ // an already-valid scene usable and allow the next safe append.
1094
+ for (const meshData of meshDataArray) {
1095
+ const modelIndex = meshData.modelIndex ?? 0;
1096
+ const shared = this.modelTranslations.frameOrigin(this.sharedFrameOrigins.get(modelIndex) ?? null, modelIndex);
1097
+ if (!topologySafeBatchOrigin([meshData], undefined, shared)) {
1098
+ throw new Error('Unable to resolve a topology-safe GPU frame for mesh geometry.');
1099
+ }
1100
+ }
1057
1101
  if (this.appearanceController)
1058
1102
  for (const part of meshDataArray)
1059
1103
  this.appearanceController.cancelFor(part.expressId);
1060
- meshDataArray = meshDataArray.map((mesh) => this.modelTranslations.placeMesh(mesh));
1061
1104
  // Cache max buffer size on first call
1062
1105
  if (this.cachedMaxBufferSize === 0) {
1063
1106
  this.cachedMaxBufferSize = this.getMaxBufferSize(device);
@@ -1086,6 +1129,9 @@ export class Scene {
1086
1129
  for (const meshData of renderable) {
1087
1130
  const baseKey = this.bucketBaseKey(meshData);
1088
1131
  const bucketKey = this.resolveActiveBucket(baseKey, meshData);
1132
+ const resolvedBucket = this.buckets.get(bucketKey);
1133
+ if (resolvedBucket)
1134
+ this.meshDataBucket.set(meshData, resolvedBucket);
1089
1135
  if (retainStreamingGeometry || !isStreaming) {
1090
1136
  // Accumulate mesh data in the bucket when we need later rebatching or
1091
1137
  // CPU-side lookups. Huge-file mode intentionally skips this to keep JS
@@ -1117,46 +1163,20 @@ export class Scene {
1117
1163
  // NON-STREAMING: Rebuild full batches immediately
1118
1164
  this.rebuildPendingBatches(device, pipeline);
1119
1165
  }
1120
- /**
1121
- * Rebuild all pending batches (call this after streaming completes)
1122
- *
1123
- * Each bucket key already maps to data that fits within the GPU buffer
1124
- * limit (enforced at accumulation time by resolveActiveBucket), so no
1125
- * splitting is needed here — just create one batch per key.
1126
- */
1166
+ /** Rebuild pending buckets after streaming or a geometry mutation. */
1127
1167
  rebuildPendingBatches(device, pipeline) {
1128
1168
  if (this.pendingBatchKeys.size === 0)
1129
1169
  return;
1130
- for (const key of this.pendingBatchKeys) {
1131
- const bucket = this.buckets.get(key);
1132
- // Destroy old GPU batch if it exists
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);
1139
- destroyGpuResources(bucket.batchedMesh);
1140
- bucket.batchedMesh = null;
1141
- }
1142
- if (!bucket || bucket.meshData.length === 0) {
1143
- // Bucket is empty — clean up
1144
- this.buckets.delete(key);
1145
- continue;
1146
- }
1147
- // Create new batch with all accumulated meshes for this bucket
1148
- const color = bucket.meshData[0].color;
1149
- const batchedMesh = this.createBatchedMesh(bucket.meshData, color, device, pipeline, key);
1150
- bucket.batchedMesh = batchedMesh;
1151
- }
1152
- // Rebuild the flat render array from all buckets (148 max batches — not perf critical)
1153
- this.batchedMeshes = [];
1154
- for (const bucket of this.buckets.values()) {
1155
- if (bucket.batchedMesh) {
1156
- this.batchedMeshes.push(bucket.batchedMesh);
1157
- }
1158
- }
1170
+ const rebuilt = rebuildSceneBatches({ pendingKeys: this.pendingBatchKeys, buckets: this.buckets,
1171
+ create: (meshes, color, target, renderPipeline, key) => this.createBatchedMesh(meshes, color, target, renderPipeline, key),
1172
+ dropPartial: batch => this.dropPartialCacheForBatch(batch),
1173
+ }, device, pipeline);
1174
+ this.batchedMeshes = [...this.buckets.values()].flatMap(bucket => bucket.batchedMesh ? [bucket.batchedMesh] : []);
1159
1175
  this.pendingBatchKeys.clear();
1176
+ // Overlays follow their depth writers only when one actually changed under
1177
+ // them (flip / bucket move, #4832); finalize re-applies once itself.
1178
+ if (!this.finalizeInProgress && overlaysInvalidatedBy(rebuilt, this.overlaySources))
1179
+ this.reapplyColorOverrides(device, pipeline);
1160
1180
  }
1161
1181
  /**
1162
1182
  * Check if there are pending batch rebuilds
@@ -1177,8 +1197,8 @@ export class Scene {
1177
1197
  * raycast bounds. This is the proper removal path.
1178
1198
  *
1179
1199
  * Notes:
1180
- * - For color-merged meshes (the `entityIds` per-vertex case)
1181
- * a single MeshData often hosts many entities. We do NOT
1200
+ * - For color-merged meshes (`entityIds` naming other entities,
1201
+ * `hostsOtherEntities`) a single MeshData hosts many entities. We do NOT
1182
1202
  * drop the whole mesh in that case — that would also remove
1183
1203
  * the other entities — but we DO clear the bbox + meshDataMap
1184
1204
  * for the requested expressId, so picking and selection stop
@@ -1207,10 +1227,9 @@ export class Scene {
1207
1227
  // caller's bulk-count contract.
1208
1228
  let removedDedicated = false;
1209
1229
  for (const meshData of meshDataList) {
1210
- // Color-merged path: shared mesh, keep it but drop our entry.
1211
- if (meshData.entityIds && meshData.entityIds.length > 0) {
1230
+ // Color-merged path (entityIds naming OTHER entities): keep the shared mesh, drop our entry.
1231
+ if (hostsOtherEntities(meshData))
1212
1232
  continue;
1213
- }
1214
1233
  removedDedicated = true;
1215
1234
  // Dedicated mesh — drop from its bucket and decrement the
1216
1235
  // bucket's vertexBytes counter so subsequent
@@ -1558,21 +1577,40 @@ export class Scene {
1558
1577
  }
1559
1578
  this.meshes = kept;
1560
1579
  }
1561
- /** Absolute workspace translation, renderer Y-up metres (#4226). */
1562
- getModelTranslation(modelIndex) { return this.modelTranslations.get(modelIndex); }
1563
- setModelTranslation(modelIndex, translation) {
1580
+ /** Shared `TranslationScene` view for `setModelTranslation`/`setModelRotation`
1581
+ * (#4890) — the placement helpers in scene-model-translation.ts read scene
1582
+ * state through this narrow interface instead of the full `Scene`. */
1583
+ translationScope() {
1564
1584
  const batches = this.finalizeInProgress ? [...new Set([...this.batchedMeshes,
1565
1585
  ...[...this.buckets.values()].flatMap((bucket) => bucket.batchedMesh ? [bucket.batchedMesh] : [])])] : this.batchedMeshes;
1566
- const changed = translateSceneModel({ translations: this.modelTranslations, pieces: this.meshDataMap,
1586
+ return { translations: this.modelTranslations, pieces: this.meshDataMap,
1567
1587
  bounds: this.boundingBoxes, batches, meshes: this.meshes, overrides: this.overrideBatches, textured: this.texturedMeshes,
1568
1588
  templates: this.instancedTemplates, cpu: this.instancedTemplateCpu, occurrences: this.instancedEntityMap,
1569
1589
  device: this.instancedDevice, evictHighlight: (id) => this.evictHighlightMeshes(id, true),
1570
1590
  clearPartial: () => this.dropAllPartialCaches(),
1571
- unionBounds: (id, view, offset, min, max) => this.unionInstancedWorldAabb(id, view, offset, ...min, ...max),
1572
- }, modelIndex, translation);
1591
+ unionBounds: (id, view, offset, min, max) => this.unionInstancedWorldAabb(id, view, offset, ...min, ...max) };
1592
+ }
1593
+ dropOrphanedSuppressedBounds() {
1573
1594
  for (const id of this.instanceSuppression.suppressedIds())
1574
1595
  if (!this.meshDataMap.has(id))
1575
1596
  this.boundingBoxes.delete(id);
1597
+ }
1598
+ /** Absolute workspace translation, renderer Y-up metres (#4226). */
1599
+ getModelTranslation(modelIndex) { return this.modelTranslations.get(modelIndex); }
1600
+ setModelTranslation(modelIndex, translation) {
1601
+ const changed = translateSceneModel(this.translationScope(), modelIndex, translation);
1602
+ this.dropOrphanedSuppressedBounds();
1603
+ return changed;
1604
+ }
1605
+ /** Absolute workspace yaw, renderer Y-up radians about the render-frame
1606
+ * pivot `(pivot[0], *, pivot[2])` — the GPU-instanced half of a whole-model
1607
+ * rotation (#4890); `angle === 0` clears it. Flat/authored/batched geometry
1608
+ * is rotated by the viewer's bake, not here. */
1609
+ getModelRotation(modelIndex) { return this.modelTranslations.getYaw(modelIndex); }
1610
+ setModelRotation(modelIndex, angle, pivot) {
1611
+ const yaw = angle === 0 ? null : { angle, px: pivot[0], pz: pivot[2] };
1612
+ const changed = rotateSceneModelInstances(this.translationScope(), modelIndex, yaw);
1613
+ this.dropOrphanedSuppressedBounds();
1576
1614
  return changed;
1577
1615
  }
1578
1616
  /** Bulk variant of `translateMeshesForEntity`. */
@@ -1800,7 +1838,7 @@ export class Scene {
1800
1838
  // applies to cells exactly like it does to buckets.
1801
1839
  const colorGroups = new Map();
1802
1840
  for (const meshData of meshDataArray) {
1803
- const key = this.bucketBaseKey(meshData);
1841
+ const key = this.meshDataBucket.get(meshData)?.key ?? this.bucketBaseKey(meshData);
1804
1842
  for (const fragment of this.splitMeshForStreaming(meshData)) {
1805
1843
  let group = colorGroups.get(key);
1806
1844
  if (!group) {
@@ -1811,11 +1849,11 @@ export class Scene {
1811
1849
  }
1812
1850
  }
1813
1851
  // Create one fragment batch per color group (with buffer limit splitting)
1814
- for (const [, group] of colorGroups) {
1852
+ for (const [key, group] of colorGroups) {
1815
1853
  const chunks = this.splitMeshDataForBufferLimit(group, this.cachedMaxBufferSize);
1816
1854
  for (const chunk of chunks) {
1817
1855
  const color = chunk[0].color;
1818
- const fragment = this.createBatchedMesh(chunk, color, device, pipeline);
1856
+ const fragment = this.createBatchedMesh(chunk, color, device, pipeline, undefined, undefined, this.buckets.get(key)?.frameOrigin);
1819
1857
  this.batchedMeshes.push(fragment);
1820
1858
  this.streamingFragments.push(fragment);
1821
1859
  }
@@ -1943,6 +1981,7 @@ export class Scene {
1943
1981
  if (!fragmentSet.has(batch))
1944
1982
  destroyGpuResources(batch);
1945
1983
  }
1984
+ this.reapplyColorOverrides(device, pipeline);
1946
1985
  }
1947
1986
  /**
1948
1987
  * Time-sliced version of finalizeStreaming.
@@ -1985,15 +2024,6 @@ export class Scene {
1985
2024
  // always clear finalizeInProgress.
1986
2025
  return new Promise((resolve, reject) => {
1987
2026
  const newBatches = [];
1988
- // Every batch this attempt creates, paired with the bucket that now owns
1989
- // it and the value it displaced. `newBatches` alone is not enough to roll
1990
- // back: processChunk publishes each batch into `bucket.batchedMesh`, so
1991
- // freeing it without repairing the owner leaves the bucket map pointing
1992
- // at destroyed GPU resources (use-after-free on the next bucket-driven
1993
- // access). `previous` is null for the freshly built buckets and, for a
1994
- // carried COLD bucket a re-grouped meshData landed in, the shell that the
1995
- // restored `batchedMeshes` still holds — which must be put back, not
1996
- // nulled.
1997
2027
  const createdOwned = [];
1998
2028
  let carriedCold = [];
1999
2029
  let pendingKeys = [];
@@ -2004,11 +2034,14 @@ export class Scene {
2004
2034
  // are what the restored arrays point back at). Iterating the owned
2005
2035
  // pairs rather than `newBatches` also skips the carried cold shells
2006
2036
  // appended just before the swap, which this attempt did not create.
2007
- for (const { bucket, previous, batch } of createdOwned) {
2037
+ for (const { bucket, previous, previousFrameOrigin, batch } of createdOwned) {
2008
2038
  // Repair the owner BEFORE the free, so no bucket is ever observable
2009
- // holding a destroyed batch.
2010
- if (bucket.batchedMesh === batch)
2039
+ // holding a destroyed batch; its frame origin (what a later
2040
+ // createBatchedMesh seeds from) must describe the restored batch.
2041
+ if (bucket.batchedMesh === batch) {
2011
2042
  bucket.batchedMesh = previous;
2043
+ bucket.frameOrigin = previousFrameOrigin;
2044
+ }
2012
2045
  if (!oldBatchSet.has(batch) && !fragmentSet.has(batch)) {
2013
2046
  destroyGpuResources(batch);
2014
2047
  }
@@ -2029,9 +2062,11 @@ export class Scene {
2029
2062
  }
2030
2063
  const color = bucket.meshData[0].color;
2031
2064
  const previous = bucket.batchedMesh;
2065
+ const previousFrameOrigin = bucket.frameOrigin;
2032
2066
  const batchedMesh = scene.createBatchedMesh(bucket.meshData, color, device, pipeline, key);
2033
2067
  bucket.batchedMesh = batchedMesh;
2034
- createdOwned.push({ bucket, previous, batch: batchedMesh });
2068
+ bucket.frameOrigin = batchedMesh.origin;
2069
+ createdOwned.push({ bucket, previous, previousFrameOrigin, batch: batchedMesh });
2035
2070
  newBatches.push(batchedMesh);
2036
2071
  // Check time budget — yield if exceeded
2037
2072
  if (performance.now() - chunkStart >= budgetMs) {
@@ -2062,6 +2097,7 @@ export class Scene {
2062
2097
  destroyGpuResources(batch);
2063
2098
  }
2064
2099
  scene.finalizeInProgress = false;
2100
+ scene.reapplyColorOverrides(device, pipeline); // never throws: the swap above is committed
2065
2101
  resolve();
2066
2102
  }
2067
2103
  catch (err) {
@@ -2169,7 +2205,6 @@ export class Scene {
2169
2205
  * Call this after finalizeStreaming() when all color updates have been applied.
2170
2206
  */
2171
2207
  releaseGeometryData() {
2172
- this.authoredGeneration++;
2173
2208
  if (this.geometryReleased)
2174
2209
  return;
2175
2210
  if (this.instanceSuppression.retained) {
@@ -2183,6 +2218,8 @@ export class Scene {
2183
2218
  `Call finalizeStreaming()/rebuildPendingBatches() first.`);
2184
2219
  return;
2185
2220
  }
2221
+ this.authoredGeneration++;
2222
+ this.authoredPreparations.invalidate();
2186
2223
  this.appearanceController?.forget();
2187
2224
  // 1. Precompute and cache ALL entity bounding boxes before releasing data.
2188
2225
  // Same rule as `finishEphemeralStreaming`: an entity with no usable vertex
@@ -2323,16 +2360,20 @@ export class Scene {
2323
2360
  * @param bucketKey - Optional unique key for this batch. When omitted the
2324
2361
  * base color key is used (fine for overlay / partial batches that don't
2325
2362
  * participate in the main buckets map).
2363
+ * @param quantization - 'auto' for bucket batches; derived batches pass `inheritedQuantization(source)` (#4832).
2326
2364
  */
2327
- createBatchedMesh(meshes, color, device, pipeline, bucketKey) {
2365
+ createBatchedMesh(meshes, color, device, pipeline, bucketKey, quantization = this.quantizedBatchesEnabled ? 'auto' : 'off', frameOrigin) {
2328
2366
  // Keep main's model-local frame and translation registration while staging
2329
2367
  // every GPU allocation before publishing Scene state.
2330
2368
  const modelIndex = meshes[0]?.modelIndex ?? 0;
2331
2369
  const offset = this.modelTranslations.get(modelIndex);
2370
+ const origin = topologySafeBatchOrigin(meshes, frameOrigin ?? this.meshDataBucket.get(meshes[0])?.batchedMesh?.origin, this.modelTranslations.frameOrigin(this.sharedFrameOrigins.get(modelIndex) ?? null, modelIndex), this.meshDataBucket.get(meshes[0])?.frameOrigin);
2371
+ if (!origin)
2372
+ throw new Error('Unable to resolve a topology-safe GPU frame for mesh geometry.');
2332
2373
  const result = createSceneBatch(meshes, color, device, pipeline, {
2333
2374
  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,
2375
+ origin,
2376
+ quantized: quantization, lod: this.lodBuildsEnabled,
2336
2377
  }, bucketKey);
2337
2378
  this.nextBatchId++;
2338
2379
  if (!this.sharedFrameOrigins.has(modelIndex) && result.origin) {
@@ -2361,38 +2402,15 @@ export class Scene {
2361
2402
  * Returns the bucket key to use (may be the base key or a suffixed key).
2362
2403
  */
2363
2404
  resolveActiveBucket(baseColorKey, meshData) {
2364
- let bucketKey = this.activeBucketKey.get(baseColorKey) ?? baseColorKey;
2365
- const bucket = this.buckets.get(bucketKey);
2366
- const currentBytes = bucket?.vertexBytes ?? 0;
2367
- const meshBytes = (meshData.positions.length / 3) * BATCH_CONSTANTS.BYTES_PER_VERTEX;
2368
- // A COLD bucket is sealed (its content lives on disk and its shell's
2369
- // expressIds are the restore contract) — route new arrivals (e.g. a
2370
- // federated add landing in the same cell+colour) to an overflow
2371
- // sub-bucket instead of corrupting the sealed one.
2372
- if (bucket && this.coldBuckets.has(bucketKey)) {
2373
- bucketKey = `${baseColorKey}#${this.nextSplitId++}`;
2374
- this.activeBucketKey.set(baseColorKey, bucketKey);
2375
- let target = this.buckets.get(bucketKey);
2376
- if (!target) {
2377
- target = { key: bucketKey, meshData: [], batchedMesh: null, vertexBytes: 0 };
2378
- this.buckets.set(bucketKey, target);
2379
- }
2380
- target.vertexBytes += meshBytes;
2381
- return bucketKey;
2382
- }
2383
- if (currentBytes > 0 && currentBytes + meshBytes > this.cachedMaxBufferSize) {
2384
- // Overflow — create a new sub-bucket
2385
- bucketKey = `${baseColorKey}#${this.nextSplitId++}`;
2386
- this.activeBucketKey.set(baseColorKey, bucketKey);
2387
- }
2388
- // Update size tracking on the bucket (create if needed)
2389
- let targetBucket = this.buckets.get(bucketKey);
2390
- if (!targetBucket) {
2391
- targetBucket = { key: bucketKey, meshData: [], batchedMesh: null, vertexBytes: 0 };
2392
- this.buckets.set(bucketKey, targetBucket);
2393
- }
2394
- targetBucket.vertexBytes += meshBytes;
2395
- return bucketKey;
2405
+ const modelIndex = meshData.modelIndex ?? 0;
2406
+ return resolvePrecisionBucket({
2407
+ buckets: this.buckets,
2408
+ activeKeys: this.activeBucketKey,
2409
+ coldKeys: this.coldBuckets,
2410
+ maxBufferSize: this.cachedMaxBufferSize,
2411
+ sharedOrigin: this.modelTranslations.frameOrigin(this.sharedFrameOrigins.get(modelIndex) ?? null, modelIndex),
2412
+ nextSplitKey: () => `${baseColorKey}#${this.nextSplitId++}`,
2413
+ }, baseColorKey, meshData);
2396
2414
  }
2397
2415
  /**
2398
2416
  * Extract the base color key from a bucket key (strips "#N" suffix if present).
@@ -2500,32 +2518,27 @@ export class Scene {
2500
2518
  this.partialBatchCache.delete(currentCacheKey);
2501
2519
  }
2502
2520
  }
2503
- // Collect MeshData for visible elements
2504
- // Use the base key (strip "#N" bucket suffix) for piece filtering, since
2505
- // meshData stores the original color, not the bucket key. Pieces are
2506
- // matched through bucketBaseKey so the comparison stays correct with
2507
- // spatial chunking on (base key = "cell~colour" then, and a piece only
2508
- // belongs to this batch when BOTH its cell and colour match).
2521
+ // `colorKey` is the bucket key: match each piece's OWNING bucket exactly.
2522
+ // Matching the base key (suffix "#N" stripped) pulled sibling overflow
2523
+ // buckets' pieces in — drawn twice, wrong quantization inherited (#4832).
2524
+ // Only a batch with no bucket (a streaming fragment) uses the base key.
2525
+ const bucket = this.buckets.get(colorKey);
2509
2526
  const baseKey = this.baseColorKey(colorKey);
2510
2527
  const visibleMeshData = [];
2511
2528
  for (const expressId of visibleIds) {
2512
- const pieces = this.meshDataMap.get(expressId);
2513
- if (pieces) {
2514
- // Add all pieces for this element
2515
- for (const piece of pieces) {
2516
- // Only include pieces that match this batch's cell + color
2517
- if (this.bucketBaseKey(piece) === baseKey) {
2518
- visibleMeshData.push(piece);
2519
- }
2529
+ for (const piece of this.meshDataMap.get(expressId) ?? []) {
2530
+ if (bucket ? this.meshDataBucket.get(piece) === bucket : this.bucketBaseKey(piece) === baseKey) {
2531
+ visibleMeshData.push(piece);
2520
2532
  }
2521
2533
  }
2522
2534
  }
2523
2535
  if (visibleMeshData.length === 0) {
2524
2536
  return undefined;
2525
2537
  }
2526
- // Create the partial batch
2538
+ // Drawn INSTEAD of its source batch, so it inherits the source's f32/quantized
2539
+ // decision (#4832): the overlay built from the same source must match its depth.
2527
2540
  const color = visibleMeshData[0].color;
2528
- const partialBatch = this.createBatchedMesh(visibleMeshData, color, device, pipeline);
2541
+ const partialBatch = this.createBatchedMesh(visibleMeshData, color, device, pipeline, undefined, inheritedQuantization(this.quantizedBatchesEnabled, bucket?.batchedMesh), bucket?.batchedMesh?.origin);
2529
2542
  // Cache it
2530
2543
  this.partialBatchCache.set(cacheKey, partialBatch);
2531
2544
  this.partialBatchCacheKeys.set(sourceBatchKey, cacheKey);
@@ -2540,12 +2553,10 @@ export class Scene {
2540
2553
  // override colors. They are rendered on top of existing depth via the overlay
2541
2554
  // pipeline (depthCompare 'equal'), so hidden entities never leak through.
2542
2555
  /**
2543
- * Set color overrides for lens coloring.
2544
- * Builds overlay batches grouped by override color.
2545
- * Original batches are NEVER modified — clearing is instant.
2546
- *
2547
- * Applies the same buffer-size splitting as regular batches to prevent
2548
- * GPU buffer overflow on large models.
2556
+ * Set color overrides for lens / chart / IDS / compare / 4D coloring. Overlay
2557
+ * batches are grouped by SOURCE bucket + override color and inherit the
2558
+ * source batch's quantization (#4832, `scene-derived-batches.ts`). Original
2559
+ * batches are NEVER modified — clearing is instant.
2549
2560
  */
2550
2561
  setColorOverrides(overrides, device, pipeline) {
2551
2562
  // Destroy previous overlay batches
@@ -2564,44 +2575,37 @@ export class Scene {
2564
2575
  this.setInstancedColorOverrides(null);
2565
2576
  return;
2566
2577
  }
2567
- // Defensive copy so external callers can mutate or reuse `overrides`
2568
- // without aliasing the renderer's pipeline-routing state. Tuples are
2569
- // frozen by the readonly type — we don't deep-clone the inner arrays
2570
- // because they're treated as immutable by every consumer.
2578
+ // Defensive copy: callers may mutate/reuse `overrides`; the tuples are treated as immutable by every consumer.
2571
2579
  this.colorOverrides = new Map(overrides);
2572
- // Mirror the overlay onto the GPU-instanced occurrences: patch their colour
2573
- // bytes in place (no separate overlay pass — the instanced records carry the
2574
- // override colour directly). No-op when no instanced data is loaded.
2580
+ // Instanced occurrences carry the override colour in their records (no overlay pass); no-op without instanced data.
2575
2581
  this.setInstancedColorOverrides(overrides);
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.
2578
- const colorGroups = new Map();
2579
- for (const [expressId, color] of overrides) {
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);
2586
- }
2587
- group.meshData.push(piece);
2588
- }
2589
- }
2590
- // Build overlay batches per override color, splitting if buffers would exceed GPU limit
2582
+ // Bucket keys carry cell + colour + model: an overlay batch is a subset of ONE base batch, in one model.
2583
+ const colorGroups = groupOverridePieces(overrides, (id) => this.meshDataMap.get(id), (piece) => this.meshDataBucket.get(piece), (piece) => this.bucketBaseKey(piece), (c) => this.colorKey(c));
2591
2584
  const maxBufferSize = this.getMaxBufferSize(device);
2592
- for (const [, { color, meshData }] of colorGroups) {
2593
- if (meshData.length === 0)
2594
- continue;
2595
- const chunks = this.splitMeshDataForBufferLimit(meshData, maxBufferSize);
2596
- for (const chunk of chunks) {
2597
- const batch = this.createBatchedMesh(chunk, color, device, pipeline);
2598
- this.overrideBatches.push(batch);
2585
+ for (const { color, meshData, sourceBatch, sourceKey } of colorGroups.values()) {
2586
+ if (sourceKey !== null)
2587
+ for (const piece of meshData)
2588
+ this.overlaySources.set(piece, sourceKey);
2589
+ const quantization = inheritedQuantization(this.quantizedBatchesEnabled, sourceBatch);
2590
+ for (const chunk of this.splitMeshDataForBufferLimit(meshData, maxBufferSize)) {
2591
+ this.overrideBatches.push(this.createBatchedMesh(chunk, color, device, pipeline, undefined, quantization, sourceBatch?.origin));
2599
2592
  }
2600
2593
  }
2601
2594
  }
2602
- /**
2603
- * Clear all color overrides — instant, no batch rebuild needed.
2604
- */
2595
+ /** Rebuild the installed overlays against the batches that write depth NOW
2596
+ * (#4832). Best-effort — a committed finalize/rebuild must not be undone by
2597
+ * an overlay allocation failure, so that is reported, not thrown. */
2598
+ reapplyColorOverrides(device, pipeline) {
2599
+ if (!this.colorOverrides)
2600
+ return;
2601
+ try {
2602
+ this.setColorOverrides(cloneOverrides(this.colorOverrides), device, pipeline);
2603
+ }
2604
+ catch (err) {
2605
+ console.warn('[Scene] overlay rebuild after finalize failed — colours may be missing until the next repaint', err);
2606
+ }
2607
+ }
2608
+ /** Clear all color overrides — instant, no batch rebuild needed. */
2605
2609
  clearColorOverrides() {
2606
2610
  this.destroyOverrideBatches();
2607
2611
  this.colorOverrideGeneration++;
@@ -2644,6 +2648,7 @@ export class Scene {
2644
2648
  for (const batch of this.overrideBatches)
2645
2649
  destroyGpuResources(batch);
2646
2650
  this.overrideBatches = [];
2651
+ this.overlaySources.clear();
2647
2652
  }
2648
2653
  /**
2649
2654
  * Clear scene
@@ -3326,22 +3331,17 @@ export class Scene {
3326
3331
  this.restoreInstanceColor(device, eid);
3327
3332
  }
3328
3333
  for (const eid of toFade) {
3329
- const base = this.instancedOverrideColors?.get(eid) ?? this.originalInstanceColor(eid);
3330
- if (!base)
3331
- continue;
3332
- this.writeInstanceColor(device, eid, [base[0], base[1], base[2], ghostAlpha]);
3334
+ const override = this.instancedOverrideColors?.get(eid);
3335
+ if (override)
3336
+ this.writeInstanceColor(device, eid, [override[0], override[1], override[2], ghostAlpha]);
3337
+ else
3338
+ this.writeOriginalInstanceColors(device, eid, ghostAlpha);
3333
3339
  }
3334
3340
  this.instancedGhosted = next;
3335
3341
  this.lastGhostAlpha = ghostAlpha;
3336
3342
  this.instancedGhostDirty = false;
3337
3343
  this.instancedGhostTransparent = next.size > 0 && ghostAlpha < OPAQUE_ALPHA_CUTOFF;
3338
3344
  }
3339
- /** The colour an occurrence was uploaded with, before any override or ghost. */
3340
- originalInstanceColor(eid) {
3341
- const locs = this.instancedEntityMap.get(eid);
3342
- const first = locs?.[0];
3343
- return first ? first.originalColor : null;
3344
- }
3345
3345
  /** Write the combined flag lane (selected | hidden) for every occurrence of `eid`
3346
3346
  * (shares one u32 lane at INSTANCE_FLAGS_OFFSET, so both bits are folded here).
3347
3347
  * #4382: for the item-filtered eid, selected narrows per-occurrence to
@@ -3383,6 +3383,7 @@ export class Scene {
3383
3383
  device.queue.writeBuffer(buf, loc.byteOffset + INSTANCE_COLOR_OFFSET, new Float32Array(loc.originalColor));
3384
3384
  }
3385
3385
  }
3386
+ writeOriginalInstanceColors(device, eid, alpha) { writeOriginalInstancedColors(device, this.instancedEntityMap.get(eid) ?? [], this.instancedTemplates, INSTANCE_COLOR_OFFSET, alpha); }
3386
3387
  /**
3387
3388
  * Build a textured mesh (#961): interleave position+normal+entityId+uv into one
3388
3389
  * vertex buffer, upload the decoded RGBA8 texture, create a sampler honouring
@@ -3567,6 +3568,7 @@ export class Scene {
3567
3568
  */
3568
3569
  clearFlatGeometry() {
3569
3570
  this.authoredGeneration++;
3571
+ this.authoredPreparations.invalidate();
3570
3572
  this.instanceSuppression.restore();
3571
3573
  this.appearanceController?.forget();
3572
3574
  this.clearFlatBuffers();
@@ -3575,6 +3577,7 @@ export class Scene {
3575
3577
  * owners keep their original-instance history. Full reset remains separate. */
3576
3578
  clearFlatGeometryForRebuild(geometry, models, sourceGeometry = geometry) {
3577
3579
  this.authoredGeneration++;
3580
+ this.authoredPreparations.invalidate();
3578
3581
  const retained = this.appearanceController?.prepareRebuild(sourceGeometry, models) ?? new Set();
3579
3582
  const discarded = this.appearanceController?.discardedForRebuild(retained) ?? [];
3580
3583
  // A discarded converted owner must not resurrect its obsolete type instance.