@ifc-lite/renderer 3.1.0 → 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 (103) 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 +43 -21
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +155 -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/model-translation.d.ts +39 -1
  38. package/dist/model-translation.d.ts.map +1 -1
  39. package/dist/model-translation.js +103 -16
  40. package/dist/model-translation.js.map +1 -1
  41. package/dist/picking-manager.d.ts +2 -1
  42. package/dist/picking-manager.d.ts.map +1 -1
  43. package/dist/picking-manager.js +11 -2
  44. package/dist/picking-manager.js.map +1 -1
  45. package/dist/reference-images.d.ts +2 -0
  46. package/dist/reference-images.d.ts.map +1 -1
  47. package/dist/reference-images.js +2 -0
  48. package/dist/reference-images.js.map +1 -1
  49. package/dist/renderer-overlays.d.ts +3 -0
  50. package/dist/renderer-overlays.d.ts.map +1 -1
  51. package/dist/renderer-overlays.js +13 -0
  52. package/dist/renderer-overlays.js.map +1 -1
  53. package/dist/renderer-symbolic-overlays.d.ts +1 -0
  54. package/dist/renderer-symbolic-overlays.d.ts.map +1 -1
  55. package/dist/renderer-symbolic-overlays.js +3 -0
  56. package/dist/renderer-symbolic-overlays.js.map +1 -1
  57. package/dist/scene-appearance-preview.d.ts +2 -0
  58. package/dist/scene-appearance-preview.d.ts.map +1 -1
  59. package/dist/scene-appearance-preview.js +9 -0
  60. package/dist/scene-appearance-preview.js.map +1 -1
  61. package/dist/scene-authored-owner.d.ts +10 -0
  62. package/dist/scene-authored-owner.d.ts.map +1 -1
  63. package/dist/scene-authored-owner.js +44 -5
  64. package/dist/scene-authored-owner.js.map +1 -1
  65. package/dist/scene-batch-rebuild.d.ts +18 -0
  66. package/dist/scene-batch-rebuild.d.ts.map +1 -0
  67. package/dist/scene-batch-rebuild.js +49 -0
  68. package/dist/scene-batch-rebuild.js.map +1 -0
  69. package/dist/scene-batch-upload.d.ts +4 -1
  70. package/dist/scene-batch-upload.d.ts.map +1 -1
  71. package/dist/scene-batch-upload.js +17 -0
  72. package/dist/scene-batch-upload.js.map +1 -1
  73. package/dist/scene-bucket-routing.d.ts +21 -0
  74. package/dist/scene-bucket-routing.d.ts.map +1 -0
  75. package/dist/scene-bucket-routing.js +44 -0
  76. package/dist/scene-bucket-routing.js.map +1 -0
  77. package/dist/scene-contents.d.ts +8 -0
  78. package/dist/scene-contents.d.ts.map +1 -1
  79. package/dist/scene-derived-mesh-provenance.d.ts +13 -0
  80. package/dist/scene-derived-mesh-provenance.d.ts.map +1 -0
  81. package/dist/scene-derived-mesh-provenance.js +30 -0
  82. package/dist/scene-derived-mesh-provenance.js.map +1 -0
  83. package/dist/scene-device-recovery.d.ts +103 -0
  84. package/dist/scene-device-recovery.d.ts.map +1 -0
  85. package/dist/scene-device-recovery.js +299 -0
  86. package/dist/scene-device-recovery.js.map +1 -0
  87. package/dist/scene-geometry.d.ts +1 -0
  88. package/dist/scene-geometry.d.ts.map +1 -1
  89. package/dist/scene-geometry.js +3 -23
  90. package/dist/scene-geometry.js.map +1 -1
  91. package/dist/scene-model-translation.d.ts +11 -1
  92. package/dist/scene-model-translation.d.ts.map +1 -1
  93. package/dist/scene-model-translation.js +22 -0
  94. package/dist/scene-model-translation.js.map +1 -1
  95. package/dist/scene-precision.d.ts +16 -0
  96. package/dist/scene-precision.d.ts.map +1 -0
  97. package/dist/scene-precision.js +73 -0
  98. package/dist/scene-precision.js.map +1 -0
  99. package/dist/scene.d.ts +25 -20
  100. package/dist/scene.d.ts.map +1 -1
  101. package/dist/scene.js +149 -137
  102. package/dist/scene.js.map +1 -1
  103. package/package.json +2 -2
package/dist/scene.js CHANGED
@@ -4,9 +4,9 @@
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';
@@ -14,9 +14,10 @@ import { hostsOtherEntities } from './mesh-entity-hosting.js';
14
14
  import { BATCH_CONSTANTS } from './constants.js';
15
15
  import { prepareRayDirInv, raycastBoundingBoxes, raycastTriangles, rayIntersectsBox, } from './scene-raycaster.js';
16
16
  import { selectBoundingBoxesInRect } from './scene-rect-select.js';
17
- 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';
18
19
  import { sumResidentGpuBytes } from './render-stats.js';
19
- import { composeInstancedOverrideColor } from './instanced-override-color.js';
20
+ import { composeInstancedOverrideColor, writeOriginalInstancedColors } from './instanced-override-color.js';
20
21
  import { bucketBaseKeyFor } from './chunk-grid.js';
21
22
  import { inheritedQuantization, groupOverridePieces, cloneOverrides, overlaysInvalidatedBy } from './scene-derived-batches.js';
22
23
  import { VisibilityEpochTracker } from './visibility-epoch.js';
@@ -24,11 +25,13 @@ import { isEntityVisible } from './entity-visibility.js';
24
25
  import { planInstancedGhosting } from './instanced-ghost-plan.js';
25
26
  import { selectEvictions } from './residency.js';
26
27
  import { OPAQUE_ALPHA_CUTOFF } from './overlay-routing.js';
27
- import { translateSceneModel, releaseInstanceVertices, refreshTexturedBounds } from './scene-model-translation.js';
28
+ import { translateSceneModel, rotateSceneModelInstances, releaseInstanceVertices, refreshTexturedBounds } from './scene-model-translation.js';
28
29
  import { ModelTranslations } from './model-translation.js';
29
- import { extractEntityFromMergedMesh } from './merged-mesh-extract.js';
30
+ import { DerivedMeshProvenance } from './scene-derived-mesh-provenance.js';
31
+ import { rebuildSceneBatches } from './scene-batch-rebuild.js';
30
32
  import { dropAllPartialCaches as dropAllPartialCachesIn, dropPartialCacheForBatch as dropPartialCacheForBatchIn, retireUnusedAlphaSlots as retireUnusedAlphaSlotsIn, } from './partial-batch-cache.js';
31
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';
32
35
  const EMPTY_INSTANCED_TEMPLATES = [];
33
36
  /**
34
37
  * Pure helper: compute the exclusive end index of the next flushPending()
@@ -84,6 +87,7 @@ export class Scene {
84
87
  batchedMeshes = []; // flat render array (rebuilt from buckets)
85
88
  buckets = new Map(); // bucketKey -> consolidated bucket state
86
89
  meshDataBucket = new Map(); // reverse lookup: MeshData -> owning bucket
90
+ derivedMeshProvenance = new DerivedMeshProvenance();
87
91
  modelTranslations = new ModelTranslations();
88
92
  meshDataMap = new Map(); // Map expressId -> MeshData[] (for lazy buffer creation, accumulates multiple pieces)
89
93
  boundingBoxes = new Map(); // Map expressId -> bounding box (computed lazily)
@@ -95,7 +99,9 @@ export class Scene {
95
99
  rgbaTexturePool = new RgbaTexturePool();
96
100
  appearanceController;
97
101
  appearanceBuckets;
102
+ appearanceAccessState;
98
103
  authoredGeneration = 0;
104
+ authoredPreparations = new AuthoredPreparationRegistry();
99
105
  appearanceAccess(device, pipeline) {
100
106
  return {
101
107
  meshes: () => this.texturedMeshes, data: this.meshDataMap,
@@ -135,24 +141,23 @@ export class Scene {
135
141
  },
136
142
  };
137
143
  }
144
+ bindAppearanceAccess(device, pipeline) { return this.appearanceAccessState = rebindSceneAppearanceAccess(this.appearanceAccessState, this.appearanceAccess(device, pipeline)); }
138
145
  sharedAppearanceBuckets(access) {
139
146
  return this.appearanceBuckets ??= new AppearanceBuckets(access.buckets, id => this.meshDataMap.get(id));
140
147
  }
141
148
  appearancePreview(device, pipeline) {
142
- const access = this.appearanceAccess(device, pipeline);
149
+ const access = this.bindAppearanceAccess(device, pipeline);
143
150
  return this.appearanceController ??= createSceneAppearancePreview(access, this.sharedAppearanceBuckets(access));
144
151
  }
145
- /** Place canonical native appearance source geometry in this model's live frame. */
146
152
  placeAppearanceSource(mesh) { return this.modelTranslations.placeMesh(mesh); }
147
- /** Retain model-local source coordinates when publishing a placed appearance mesh. */
148
153
  appearanceSourceMesh(mesh) { return this.modelTranslations.sourceFromPlaced(mesh); }
149
154
  /** Stage one new IFC owner with all its coloured or textured geometry parts. */
150
155
  prepareAuthoredOwner(parts, device, pipeline) {
151
- const access = this.appearanceAccess(device, pipeline), generation = this.authoredGeneration;
152
- 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, () => {
153
158
  if (generation !== this.authoredGeneration)
154
159
  throw new Error('The scene changed while preparing the object.');
155
- });
160
+ }));
156
161
  }
157
162
  /** Compatibility entry point for an image-backed single-part owner. */
158
163
  prepareTexturedOwner(mesh, device, pipeline) {
@@ -301,12 +306,11 @@ export class Scene {
301
306
  // the queue via flushPending() with a per-frame time budget.
302
307
  meshQueue = [];
303
308
  meshQueueReadIndex = 0;
304
- // ─── GPU-resident mode ──────────────────────────────────────────────
305
- // After releaseGeometryData(), JS-side typed arrays are freed.
306
- // Only lightweight metadata is retained for operations that don't need
307
- // raw vertex data (bounding boxes, color key lookups, expressId sets).
308
309
  geometryReleased = false;
309
310
  ephemeralStreamingMode = false;
311
+ prepareDeviceRecovery() { return prepareSceneDeviceRecovery(this); }
312
+ discardGpuResourcesForRecovery() { discardSceneGpuResourcesForRecovery(this); }
313
+ restoreGpuResourcesAfterRecovery(device, pipeline) { restoreSceneGpuResourcesAfterRecovery(this, device, pipeline); }
310
314
  /**
311
315
  * Add mesh to scene
312
316
  */
@@ -329,8 +333,10 @@ export class Scene {
329
333
  /** The shared local-frame origin all batches relativize against (null until
330
334
  * the first batch is built). Per-mesh highlight/picker VBOs replicate the
331
335
  * batch's exact f32 path against this so they render bit-coincident. */
332
- getSharedFrameOrigin(modelIndex = 0) {
333
- 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;
334
340
  }
335
341
  /**
336
342
  * Enable/disable spatial chunk bucketing (issue #1682 phase 2). When set,
@@ -428,6 +434,7 @@ export class Scene {
428
434
  }
429
435
  const rebuilt = this.createBatchedMesh(bucket.meshData, bucket.meshData[0].color, device, pipeline, key);
430
436
  bucket.batchedMesh = rebuilt;
437
+ bucket.frameOrigin = rebuilt.origin;
431
438
  const idx = this.batchedMeshes.indexOf(old);
432
439
  if (idx >= 0)
433
440
  this.batchedMeshes[idx] = rebuilt;
@@ -474,7 +481,8 @@ export class Scene {
474
481
  * lattice snap in createMeshFromData (an f32 batch must NOT snap). Same rule
475
482
  * as overlay/partial batches; global flag while unbucketed. */
476
483
  isMeshQuantized(meshData) {
477
- return inheritedQuantization(this.quantizedBatchesEnabled, this.meshDataBucket.get(meshData)?.batchedMesh) !== 'off';
484
+ const source = this.derivedMeshProvenance.placedSourceFor(meshData, this.modelTranslations);
485
+ return inheritedQuantization(this.quantizedBatchesEnabled, this.meshDataBucket.get(source)?.batchedMesh) !== 'off';
478
486
  }
479
487
  /** Set (or clear) the HOST budget in bytes for bucket CPU geometry. */
480
488
  setHostResidencyBudget(bytes) {
@@ -629,10 +637,10 @@ export class Scene {
629
637
  this.meshDataBucket.set(m, bucket);
630
638
  this.addMeshData(m);
631
639
  }
632
- this.coldBuckets.delete(key);
633
640
  if (members.length > 0) {
634
- // Warm now — re-queue so the next restore tick rebuilds the GPU batch.
635
- this.residencyRestoreQueue.add(key);
641
+ this.coldBuckets.delete(key);
642
+ for (const restoredKey of repartitionHydratedRecoveryBucket(this, key, bucket, shell))
643
+ this.residencyRestoreQueue.add(restoredKey);
636
644
  }
637
645
  else {
638
646
  console.warn(`[Scene] cold restore for ${key} found no members — bucket stays a shell`);
@@ -664,6 +672,7 @@ export class Scene {
664
672
  continue;
665
673
  const rebuilt = this.createBatchedMesh(bucket.meshData, bucket.meshData[0].color, device, pipeline, bucket.key);
666
674
  bucket.batchedMesh = rebuilt;
675
+ bucket.frameOrigin = rebuilt.origin;
667
676
  const idx = this.batchedMeshes.indexOf(old);
668
677
  if (idx >= 0)
669
678
  this.batchedMeshes[idx] = rebuilt;
@@ -863,7 +872,7 @@ export class Scene {
863
872
  // this expressId so selection highlighting is per-entity, not the
864
873
  // entire merged batch.
865
874
  if (single.entityIds) {
866
- return extractEntityFromMergedMesh(single, expressId);
875
+ return this.derivedMeshProvenance.extract(single, expressId);
867
876
  }
868
877
  return single;
869
878
  }
@@ -873,7 +882,7 @@ export class Scene {
873
882
  const extracted = [];
874
883
  for (const piece of pieces) {
875
884
  if (piece.entityIds) {
876
- const ex = extractEntityFromMergedMesh(piece, expressId);
885
+ const ex = this.derivedMeshProvenance.extract(piece, expressId);
877
886
  if (ex)
878
887
  extracted.push(ex);
879
888
  }
@@ -888,6 +897,18 @@ export class Scene {
888
897
  pieces = extracted;
889
898
  // Fall through to the normal multi-piece merge below
890
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;
891
912
  // Check if all pieces have the same color (within tolerance)
892
913
  // This handles multi-material elements like windows (frame vs glass)
893
914
  const firstColor = pieces[0].color;
@@ -921,8 +942,17 @@ export class Scene {
921
942
  let idxOffset = 0;
922
943
  let vertexOffset = 0;
923
944
  for (const piece of pieces) {
924
- // Copy positions and normals
925
- 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
+ }
926
956
  mergedNormals.set(piece.normals, posOffset);
927
957
  // Copy indices with offset
928
958
  for (let i = 0; i < piece.indices.length; i++) {
@@ -933,7 +963,7 @@ export class Scene {
933
963
  vertexOffset += piece.positions.length / 3;
934
964
  }
935
965
  // Return merged MeshData (all pieces have same color)
936
- return {
966
+ const merged = {
937
967
  expressId,
938
968
  modelIndex: pieces[0].modelIndex, // Preserve modelIndex for multi-model support
939
969
  positions: mergedPositions,
@@ -941,7 +971,15 @@ export class Scene {
941
971
  indices: mergedIndices,
942
972
  color: firstColor,
943
973
  ifcType: pieces[0].ifcType,
974
+ ...(mergedOrigin ? { origin: mergedOrigin } : {}),
944
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;
945
983
  }
946
984
  /**
947
985
  * Check if MeshData exists for an expressId
@@ -1013,7 +1051,7 @@ export class Scene {
1013
1051
  const extracted = [];
1014
1052
  for (const piece of pieces) {
1015
1053
  if (piece.entityIds) {
1016
- const ex = extractEntityFromMergedMesh(piece, expressId);
1054
+ const ex = this.derivedMeshProvenance.extract(piece, expressId);
1017
1055
  if (ex)
1018
1056
  extracted.push(ex);
1019
1057
  }
@@ -1049,10 +1087,20 @@ export class Scene {
1049
1087
  * when streaming completes to do one O(N) full merge.
1050
1088
  */
1051
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
+ }
1052
1101
  if (this.appearanceController)
1053
1102
  for (const part of meshDataArray)
1054
1103
  this.appearanceController.cancelFor(part.expressId);
1055
- meshDataArray = meshDataArray.map((mesh) => this.modelTranslations.placeMesh(mesh));
1056
1104
  // Cache max buffer size on first call
1057
1105
  if (this.cachedMaxBufferSize === 0) {
1058
1106
  this.cachedMaxBufferSize = this.getMaxBufferSize(device);
@@ -1081,6 +1129,9 @@ export class Scene {
1081
1129
  for (const meshData of renderable) {
1082
1130
  const baseKey = this.bucketBaseKey(meshData);
1083
1131
  const bucketKey = this.resolveActiveBucket(baseKey, meshData);
1132
+ const resolvedBucket = this.buckets.get(bucketKey);
1133
+ if (resolvedBucket)
1134
+ this.meshDataBucket.set(meshData, resolvedBucket);
1084
1135
  if (retainStreamingGeometry || !isStreaming) {
1085
1136
  // Accumulate mesh data in the bucket when we need later rebatching or
1086
1137
  // CPU-side lookups. Huge-file mode intentionally skips this to keep JS
@@ -1112,48 +1163,15 @@ export class Scene {
1112
1163
  // NON-STREAMING: Rebuild full batches immediately
1113
1164
  this.rebuildPendingBatches(device, pipeline);
1114
1165
  }
1115
- /**
1116
- * Rebuild all pending batches (call this after streaming completes)
1117
- *
1118
- * Each bucket key already maps to data that fits within the GPU buffer
1119
- * limit (enforced at accumulation time by resolveActiveBucket), so no
1120
- * splitting is needed here — just create one batch per key.
1121
- */
1166
+ /** Rebuild pending buckets after streaming or a geometry mutation. */
1122
1167
  rebuildPendingBatches(device, pipeline) {
1123
1168
  if (this.pendingBatchKeys.size === 0)
1124
1169
  return;
1125
- const rebuilt = [];
1126
- for (const key of this.pendingBatchKeys) {
1127
- const bucket = this.buckets.get(key);
1128
- const previousQuantized = bucket?.batchedMesh?.quantized !== undefined;
1129
- // Destroy old GPU batch if it exists
1130
- if (bucket?.batchedMesh) {
1131
- // Slot keys embed the batch id and the replacement gets a fresh one, so
1132
- // drop this batch's cached sub-batch clones first or they are stranded
1133
- // with live GPU buffers. Every other batch-destroying path already
1134
- // clears the cache (eviction per batch; finalize/release/clear wholesale).
1135
- this.dropPartialCacheForBatch(bucket.batchedMesh);
1136
- destroyGpuResources(bucket.batchedMesh);
1137
- bucket.batchedMesh = null;
1138
- }
1139
- if (!bucket || bucket.meshData.length === 0) {
1140
- // Bucket is empty — clean up
1141
- this.buckets.delete(key);
1142
- continue;
1143
- }
1144
- // Create new batch with all accumulated meshes for this bucket
1145
- const color = bucket.meshData[0].color;
1146
- const batchedMesh = this.createBatchedMesh(bucket.meshData, color, device, pipeline, key);
1147
- bucket.batchedMesh = batchedMesh;
1148
- rebuilt.push({ bucket, previousQuantized });
1149
- }
1150
- // Rebuild the flat render array from all buckets (148 max batches — not perf critical)
1151
- this.batchedMeshes = [];
1152
- for (const bucket of this.buckets.values()) {
1153
- if (bucket.batchedMesh) {
1154
- this.batchedMeshes.push(bucket.batchedMesh);
1155
- }
1156
- }
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] : []);
1157
1175
  this.pendingBatchKeys.clear();
1158
1176
  // Overlays follow their depth writers only when one actually changed under
1159
1177
  // them (flip / bucket move, #4832); finalize re-applies once itself.
@@ -1559,21 +1577,40 @@ export class Scene {
1559
1577
  }
1560
1578
  this.meshes = kept;
1561
1579
  }
1562
- /** Absolute workspace translation, renderer Y-up metres (#4226). */
1563
- getModelTranslation(modelIndex) { return this.modelTranslations.get(modelIndex); }
1564
- 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() {
1565
1584
  const batches = this.finalizeInProgress ? [...new Set([...this.batchedMeshes,
1566
1585
  ...[...this.buckets.values()].flatMap((bucket) => bucket.batchedMesh ? [bucket.batchedMesh] : [])])] : this.batchedMeshes;
1567
- const changed = translateSceneModel({ translations: this.modelTranslations, pieces: this.meshDataMap,
1586
+ return { translations: this.modelTranslations, pieces: this.meshDataMap,
1568
1587
  bounds: this.boundingBoxes, batches, meshes: this.meshes, overrides: this.overrideBatches, textured: this.texturedMeshes,
1569
1588
  templates: this.instancedTemplates, cpu: this.instancedTemplateCpu, occurrences: this.instancedEntityMap,
1570
1589
  device: this.instancedDevice, evictHighlight: (id) => this.evictHighlightMeshes(id, true),
1571
1590
  clearPartial: () => this.dropAllPartialCaches(),
1572
- unionBounds: (id, view, offset, min, max) => this.unionInstancedWorldAabb(id, view, offset, ...min, ...max),
1573
- }, modelIndex, translation);
1591
+ unionBounds: (id, view, offset, min, max) => this.unionInstancedWorldAabb(id, view, offset, ...min, ...max) };
1592
+ }
1593
+ dropOrphanedSuppressedBounds() {
1574
1594
  for (const id of this.instanceSuppression.suppressedIds())
1575
1595
  if (!this.meshDataMap.has(id))
1576
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();
1577
1614
  return changed;
1578
1615
  }
1579
1616
  /** Bulk variant of `translateMeshesForEntity`. */
@@ -1801,7 +1838,7 @@ export class Scene {
1801
1838
  // applies to cells exactly like it does to buckets.
1802
1839
  const colorGroups = new Map();
1803
1840
  for (const meshData of meshDataArray) {
1804
- const key = this.bucketBaseKey(meshData);
1841
+ const key = this.meshDataBucket.get(meshData)?.key ?? this.bucketBaseKey(meshData);
1805
1842
  for (const fragment of this.splitMeshForStreaming(meshData)) {
1806
1843
  let group = colorGroups.get(key);
1807
1844
  if (!group) {
@@ -1812,11 +1849,11 @@ export class Scene {
1812
1849
  }
1813
1850
  }
1814
1851
  // Create one fragment batch per color group (with buffer limit splitting)
1815
- for (const [, group] of colorGroups) {
1852
+ for (const [key, group] of colorGroups) {
1816
1853
  const chunks = this.splitMeshDataForBufferLimit(group, this.cachedMaxBufferSize);
1817
1854
  for (const chunk of chunks) {
1818
1855
  const color = chunk[0].color;
1819
- const fragment = this.createBatchedMesh(chunk, color, device, pipeline);
1856
+ const fragment = this.createBatchedMesh(chunk, color, device, pipeline, undefined, undefined, this.buckets.get(key)?.frameOrigin);
1820
1857
  this.batchedMeshes.push(fragment);
1821
1858
  this.streamingFragments.push(fragment);
1822
1859
  }
@@ -1987,15 +2024,6 @@ export class Scene {
1987
2024
  // always clear finalizeInProgress.
1988
2025
  return new Promise((resolve, reject) => {
1989
2026
  const newBatches = [];
1990
- // Every batch this attempt creates, paired with the bucket that now owns
1991
- // it and the value it displaced. `newBatches` alone is not enough to roll
1992
- // back: processChunk publishes each batch into `bucket.batchedMesh`, so
1993
- // freeing it without repairing the owner leaves the bucket map pointing
1994
- // at destroyed GPU resources (use-after-free on the next bucket-driven
1995
- // access). `previous` is null for the freshly built buckets and, for a
1996
- // carried COLD bucket a re-grouped meshData landed in, the shell that the
1997
- // restored `batchedMeshes` still holds — which must be put back, not
1998
- // nulled.
1999
2027
  const createdOwned = [];
2000
2028
  let carriedCold = [];
2001
2029
  let pendingKeys = [];
@@ -2006,11 +2034,14 @@ export class Scene {
2006
2034
  // are what the restored arrays point back at). Iterating the owned
2007
2035
  // pairs rather than `newBatches` also skips the carried cold shells
2008
2036
  // appended just before the swap, which this attempt did not create.
2009
- for (const { bucket, previous, batch } of createdOwned) {
2037
+ for (const { bucket, previous, previousFrameOrigin, batch } of createdOwned) {
2010
2038
  // Repair the owner BEFORE the free, so no bucket is ever observable
2011
- // holding a destroyed batch.
2012
- 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) {
2013
2042
  bucket.batchedMesh = previous;
2043
+ bucket.frameOrigin = previousFrameOrigin;
2044
+ }
2014
2045
  if (!oldBatchSet.has(batch) && !fragmentSet.has(batch)) {
2015
2046
  destroyGpuResources(batch);
2016
2047
  }
@@ -2031,9 +2062,11 @@ export class Scene {
2031
2062
  }
2032
2063
  const color = bucket.meshData[0].color;
2033
2064
  const previous = bucket.batchedMesh;
2065
+ const previousFrameOrigin = bucket.frameOrigin;
2034
2066
  const batchedMesh = scene.createBatchedMesh(bucket.meshData, color, device, pipeline, key);
2035
2067
  bucket.batchedMesh = batchedMesh;
2036
- createdOwned.push({ bucket, previous, batch: batchedMesh });
2068
+ bucket.frameOrigin = batchedMesh.origin;
2069
+ createdOwned.push({ bucket, previous, previousFrameOrigin, batch: batchedMesh });
2037
2070
  newBatches.push(batchedMesh);
2038
2071
  // Check time budget — yield if exceeded
2039
2072
  if (performance.now() - chunkStart >= budgetMs) {
@@ -2172,7 +2205,6 @@ export class Scene {
2172
2205
  * Call this after finalizeStreaming() when all color updates have been applied.
2173
2206
  */
2174
2207
  releaseGeometryData() {
2175
- this.authoredGeneration++;
2176
2208
  if (this.geometryReleased)
2177
2209
  return;
2178
2210
  if (this.instanceSuppression.retained) {
@@ -2186,6 +2218,8 @@ export class Scene {
2186
2218
  `Call finalizeStreaming()/rebuildPendingBatches() first.`);
2187
2219
  return;
2188
2220
  }
2221
+ this.authoredGeneration++;
2222
+ this.authoredPreparations.invalidate();
2189
2223
  this.appearanceController?.forget();
2190
2224
  // 1. Precompute and cache ALL entity bounding boxes before releasing data.
2191
2225
  // Same rule as `finishEphemeralStreaming`: an entity with no usable vertex
@@ -2328,14 +2362,17 @@ export class Scene {
2328
2362
  * participate in the main buckets map).
2329
2363
  * @param quantization - 'auto' for bucket batches; derived batches pass `inheritedQuantization(source)` (#4832).
2330
2364
  */
2331
- createBatchedMesh(meshes, color, device, pipeline, bucketKey, quantization = this.quantizedBatchesEnabled ? 'auto' : 'off') {
2365
+ createBatchedMesh(meshes, color, device, pipeline, bucketKey, quantization = this.quantizedBatchesEnabled ? 'auto' : 'off', frameOrigin) {
2332
2366
  // Keep main's model-local frame and translation registration while staging
2333
2367
  // every GPU allocation before publishing Scene state.
2334
2368
  const modelIndex = meshes[0]?.modelIndex ?? 0;
2335
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.');
2336
2373
  const result = createSceneBatch(meshes, color, device, pipeline, {
2337
2374
  id: this.nextBatchId, colorKey: bucketKey ?? this.colorKey(color),
2338
- origin: this.modelTranslations.frameOrigin(this.sharedFrameOrigins.get(modelIndex) ?? null, modelIndex),
2375
+ origin,
2339
2376
  quantized: quantization, lod: this.lodBuildsEnabled,
2340
2377
  }, bucketKey);
2341
2378
  this.nextBatchId++;
@@ -2365,38 +2402,15 @@ export class Scene {
2365
2402
  * Returns the bucket key to use (may be the base key or a suffixed key).
2366
2403
  */
2367
2404
  resolveActiveBucket(baseColorKey, meshData) {
2368
- let bucketKey = this.activeBucketKey.get(baseColorKey) ?? baseColorKey;
2369
- const bucket = this.buckets.get(bucketKey);
2370
- const currentBytes = bucket?.vertexBytes ?? 0;
2371
- const meshBytes = (meshData.positions.length / 3) * BATCH_CONSTANTS.BYTES_PER_VERTEX;
2372
- // A COLD bucket is sealed (its content lives on disk and its shell's
2373
- // expressIds are the restore contract) — route new arrivals (e.g. a
2374
- // federated add landing in the same cell+colour) to an overflow
2375
- // sub-bucket instead of corrupting the sealed one.
2376
- if (bucket && this.coldBuckets.has(bucketKey)) {
2377
- bucketKey = `${baseColorKey}#${this.nextSplitId++}`;
2378
- this.activeBucketKey.set(baseColorKey, bucketKey);
2379
- let target = this.buckets.get(bucketKey);
2380
- if (!target) {
2381
- target = { key: bucketKey, meshData: [], batchedMesh: null, vertexBytes: 0 };
2382
- this.buckets.set(bucketKey, target);
2383
- }
2384
- target.vertexBytes += meshBytes;
2385
- return bucketKey;
2386
- }
2387
- if (currentBytes > 0 && currentBytes + meshBytes > this.cachedMaxBufferSize) {
2388
- // Overflow — create a new sub-bucket
2389
- bucketKey = `${baseColorKey}#${this.nextSplitId++}`;
2390
- this.activeBucketKey.set(baseColorKey, bucketKey);
2391
- }
2392
- // Update size tracking on the bucket (create if needed)
2393
- let targetBucket = this.buckets.get(bucketKey);
2394
- if (!targetBucket) {
2395
- targetBucket = { key: bucketKey, meshData: [], batchedMesh: null, vertexBytes: 0 };
2396
- this.buckets.set(bucketKey, targetBucket);
2397
- }
2398
- targetBucket.vertexBytes += meshBytes;
2399
- 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);
2400
2414
  }
2401
2415
  /**
2402
2416
  * Extract the base color key from a bucket key (strips "#N" suffix if present).
@@ -2524,7 +2538,7 @@ export class Scene {
2524
2538
  // Drawn INSTEAD of its source batch, so it inherits the source's f32/quantized
2525
2539
  // decision (#4832): the overlay built from the same source must match its depth.
2526
2540
  const color = visibleMeshData[0].color;
2527
- const partialBatch = this.createBatchedMesh(visibleMeshData, color, device, pipeline, undefined, inheritedQuantization(this.quantizedBatchesEnabled, bucket?.batchedMesh));
2541
+ const partialBatch = this.createBatchedMesh(visibleMeshData, color, device, pipeline, undefined, inheritedQuantization(this.quantizedBatchesEnabled, bucket?.batchedMesh), bucket?.batchedMesh?.origin);
2528
2542
  // Cache it
2529
2543
  this.partialBatchCache.set(cacheKey, partialBatch);
2530
2544
  this.partialBatchCacheKeys.set(sourceBatchKey, cacheKey);
@@ -2574,7 +2588,7 @@ export class Scene {
2574
2588
  this.overlaySources.set(piece, sourceKey);
2575
2589
  const quantization = inheritedQuantization(this.quantizedBatchesEnabled, sourceBatch);
2576
2590
  for (const chunk of this.splitMeshDataForBufferLimit(meshData, maxBufferSize)) {
2577
- this.overrideBatches.push(this.createBatchedMesh(chunk, color, device, pipeline, undefined, quantization));
2591
+ this.overrideBatches.push(this.createBatchedMesh(chunk, color, device, pipeline, undefined, quantization, sourceBatch?.origin));
2578
2592
  }
2579
2593
  }
2580
2594
  }
@@ -3317,22 +3331,17 @@ export class Scene {
3317
3331
  this.restoreInstanceColor(device, eid);
3318
3332
  }
3319
3333
  for (const eid of toFade) {
3320
- const base = this.instancedOverrideColors?.get(eid) ?? this.originalInstanceColor(eid);
3321
- if (!base)
3322
- continue;
3323
- 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);
3324
3339
  }
3325
3340
  this.instancedGhosted = next;
3326
3341
  this.lastGhostAlpha = ghostAlpha;
3327
3342
  this.instancedGhostDirty = false;
3328
3343
  this.instancedGhostTransparent = next.size > 0 && ghostAlpha < OPAQUE_ALPHA_CUTOFF;
3329
3344
  }
3330
- /** The colour an occurrence was uploaded with, before any override or ghost. */
3331
- originalInstanceColor(eid) {
3332
- const locs = this.instancedEntityMap.get(eid);
3333
- const first = locs?.[0];
3334
- return first ? first.originalColor : null;
3335
- }
3336
3345
  /** Write the combined flag lane (selected | hidden) for every occurrence of `eid`
3337
3346
  * (shares one u32 lane at INSTANCE_FLAGS_OFFSET, so both bits are folded here).
3338
3347
  * #4382: for the item-filtered eid, selected narrows per-occurrence to
@@ -3374,6 +3383,7 @@ export class Scene {
3374
3383
  device.queue.writeBuffer(buf, loc.byteOffset + INSTANCE_COLOR_OFFSET, new Float32Array(loc.originalColor));
3375
3384
  }
3376
3385
  }
3386
+ writeOriginalInstanceColors(device, eid, alpha) { writeOriginalInstancedColors(device, this.instancedEntityMap.get(eid) ?? [], this.instancedTemplates, INSTANCE_COLOR_OFFSET, alpha); }
3377
3387
  /**
3378
3388
  * Build a textured mesh (#961): interleave position+normal+entityId+uv into one
3379
3389
  * vertex buffer, upload the decoded RGBA8 texture, create a sampler honouring
@@ -3558,6 +3568,7 @@ export class Scene {
3558
3568
  */
3559
3569
  clearFlatGeometry() {
3560
3570
  this.authoredGeneration++;
3571
+ this.authoredPreparations.invalidate();
3561
3572
  this.instanceSuppression.restore();
3562
3573
  this.appearanceController?.forget();
3563
3574
  this.clearFlatBuffers();
@@ -3566,6 +3577,7 @@ export class Scene {
3566
3577
  * owners keep their original-instance history. Full reset remains separate. */
3567
3578
  clearFlatGeometryForRebuild(geometry, models, sourceGeometry = geometry) {
3568
3579
  this.authoredGeneration++;
3580
+ this.authoredPreparations.invalidate();
3569
3581
  const retained = this.appearanceController?.prepareRebuild(sourceGeometry, models) ?? new Set();
3570
3582
  const discarded = this.appearanceController?.discardedForRebuild(retained) ?? [];
3571
3583
  // A discarded converted owner must not resurrect its obsolete type instance.