@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/index.js CHANGED
@@ -39,6 +39,7 @@ export { LINE_OVERLAY_CHANNELS } from './section-2d-overlay.js';
39
39
  export { Raycaster } from './raycaster.js';
40
40
  export { SnapDetector, SnapType } from './snap-detector.js';
41
41
  export { BVH } from './bvh.js';
42
+ export { hostsOtherEntities } from './mesh-entity-hosting.js';
42
43
  export { FederationRegistry, federationRegistry } from './federation-registry.js';
43
44
  export * from './types.js';
44
45
  export { resolveEnvironment, deriveSkyGradient, packEnvironmentUniforms, ENVIRONMENT_UNIFORM_SIZE, } from './environment.js';
@@ -105,44 +106,10 @@ import { PartialBatchRequests } from './partial-batch-requests.js';
105
106
  import { colorSaltByte, packEntityLane } from './scene-geometry.js';
106
107
  import { PointCloudRenderer } from './pointcloud/point-cloud-renderer.js';
107
108
  import { DeviationComputer } from './deviation/deviation-computer.js';
109
+ import { runGuardedGpuUpload, isDeviceLossThrow } from './gpu-upload-guard.js';
110
+ import { recoverRendererDevice, rendererDeviceLostError } from './device-recovery.js';
108
111
  const MAX_ENCODED_ENTITY_ID = 0xFFFFFF;
109
112
  let warnedEntityIdRange = false;
110
- /**
111
- * Is this throw the GPU device telling us it is gone?
112
- *
113
- * The discriminator is the exception TYPE, not its message, because WebGPU
114
- * draws exactly that line:
115
- * - a call on a dead / invalid-state device throws a `DOMException`
116
- * (`InvalidStateError` in Safari 26.5 — the whole of issue #2229);
117
- * - a buffer allocation the host cannot back throws a plain `RangeError`
118
- * ("createBuffer failed, size (…) is too large … when mappedAtCreation ==
119
- * true"), which `gpu-upload-guard` documents happening on a HEALTHY device
120
- * under memory pressure.
121
- *
122
- * Treating the second as a device loss is a false positive that costs the whole
123
- * session, so only the first latches; everything else degrades one frame.
124
- *
125
- * There is deliberately NO consecutive-failure threshold as a middle ground.
126
- * Not because failures necessarily arrive back-to-back — between two BCF / IDS
127
- * capture frames the awaited `camera.frameBounds` normally does let an ordinary
128
- * rAF frame through, which would reset a counter — but because those ordinary
129
- * frames are not guaranteed to SUCCEED: they allocate too (`ensureMeshResources`
130
- * creates a buffer per unresourced mesh, and the queued-mesh flush allocates),
131
- * so under sustained host memory pressure any finite budget is still reachable.
132
- * A latch whose safety depends on incidental animation timing is the wrong
133
- * shape of guarantee for "never kill the viewport by mistake".
134
- *
135
- * Real losses on browsers that do not throw are still caught by the async
136
- * `device.lost` promise — which the WebGPU spec makes the sole loss channel
137
- * anyway (on a conformant engine, calls against a lost device are no-ops, not
138
- * throws; Safari 26.5's synchronous throw is the deviation being handled here).
139
- *
140
- * `typeof` guarded because non-DOM hosts (Node before 17, some workers) have no
141
- * `DOMException` global; there, no throw can be a WebGPU device signal anyway.
142
- */
143
- function isDeviceLossThrow(error) {
144
- return typeof DOMException !== 'undefined' && error instanceof DOMException;
145
- }
146
113
  /**
147
114
  * The reason `whenReady()` rejects when the renderer is destroyed.
148
115
  *
@@ -156,20 +123,6 @@ function rendererDestroyedError() {
156
123
  error.name = 'RendererDestroyedError';
157
124
  return error;
158
125
  }
159
- /**
160
- * The reason `whenReady()` rejects once the GPU device has been lost.
161
- *
162
- * Deliberately NOT `RendererDestroyedError`: a destroyed renderer is finished,
163
- * while a lost one is dead only until the host re-initialises it (`init()`
164
- * clears the latch and readiness is published again). A caller that wants to
165
- * retry needs to tell those apart, so the loss carries its own `name` — same
166
- * plain-`Error` shape, for the same reasons.
167
- */
168
- function rendererDeviceLostError() {
169
- const error = new Error('GPU device was lost before the renderer became ready');
170
- error.name = 'RendererDeviceLostError';
171
- return error;
172
- }
173
126
  /**
174
127
  * Main renderer class
175
128
  */
@@ -218,8 +171,9 @@ export class Renderer {
218
171
  highQuality: true,
219
172
  };
220
173
  pointCloudRenderer = null;
221
- /**
222
- * Set true at the end of the LATEST `init()`; gates `whenReady()` and
174
+ pointCloudStreamEpoch = 0;
175
+ pointCloudStreamEpochs = new Map(); // handle id → epoch; cleanup rebuilds `{ id }`, cleared per epoch
176
+ /** Set true at the end of the LATEST `init()`; gates `whenReady()` and
223
177
  * `isReady()`. Revoked synchronously by `init()` and by `destroy()`, and
224
178
  * overridden (not cleared) by a device loss — see `deviceLost`, which the
225
179
  * two readiness methods consult alongside this flag because the loss can
@@ -241,19 +195,18 @@ export class Renderer {
241
195
  */
242
196
  destroyed = false;
243
197
  /**
244
- * The tail of the `init()` queue. `init()` chains onto this rather than
245
- * running immediately, so two overlapping calls cannot both walk past the
246
- * "a previous init completed" guard while the first is still awaiting its
247
- * device and both allocate a full set of GPU objects (#2448). Always
248
- * settled fulfilled — a rejected init is swallowed HERE (never for the
249
- * caller) so one failure does not deadlock every later call.
198
+ * The tail of the GPU lifecycle queue. `init()` and `recoverDevice()` chain
199
+ * onto this rather than running immediately, so overlapping calls cannot
200
+ * both mutate the shared device/pipeline fields while one is awaiting its
201
+ * adapter. Always settled fulfilled — a rejected operation is swallowed
202
+ * HERE (never for its caller) so one failure cannot deadlock later work.
250
203
  */
251
204
  initChain = Promise.resolve();
252
205
  /**
253
- * Incremented synchronously by every `init()` call AND by every public
254
- * `destroy()`. It stamps "the lifecycle event an in-flight init belongs to":
255
- * an init that no longer carries the current stamp has been superseded and
256
- * must neither allocate nor publish readiness.
206
+ * Incremented synchronously by every `init()` / `recoverDevice()` call and
207
+ * by every public `destroy()`. It stamps the lifecycle event an in-flight
208
+ * operation belongs to: stale work must neither allocate nor publish
209
+ * readiness.
257
210
  *
258
211
  * Both bumps are load-bearing, for the same reason. Because the queue above
259
212
  * defers the body, an init can finish while a later one is still waiting its
@@ -311,6 +264,10 @@ export class Renderer {
311
264
  /** Retained so a listener registered AFTER the loss still learns of it. */
312
265
  deviceLostInfo = null;
313
266
  deviceLostListeners = new Set();
267
+ deviceLossSequence = 0;
268
+ recovery = {
269
+ inFlight: null, lostReferenceImages: false, quantizedBatchesRequested: false, omissions: new Set(), pointCloudOptions: null,
270
+ };
314
271
  /** BIM ↔ scan deviation: owns the compute pipeline + its BVH cache. */
315
272
  deviationComputer = new DeviationComputer();
316
273
  visualEnhancementResolver = new VisualEnhancementResolver();
@@ -485,7 +442,7 @@ export class Renderer {
485
442
  this.initChain = run.then(() => undefined, () => undefined);
486
443
  return run;
487
444
  }
488
- async initOnce(generation) {
445
+ async initOnce(generation, options = {}) {
489
446
  // `pipeline` is the marker for "a previous init() completed": it is
490
447
  // assigned unconditionally there and nulled by destroy().
491
448
  if (this.pipeline !== null) {
@@ -503,12 +460,17 @@ export class Renderer {
503
460
  // the generation and this body running is stamped with the generation
504
461
  // that is clearing it. `deviceLostGeneration` deliberately keeps its
505
462
  // stale value — it is only ever read alongside this flag.
506
- this.deviceLost = false;
507
- // Subscribe before the device exists so a loss during the first frames
508
- // is never missed — the handler is only invoked when `device.lost`
509
- // actually resolves (a real fault), long after init in practice.
510
- this.device.onDeviceLost((info) => this.handleDeviceLost(info));
511
- await this.device.init(this.canvas);
463
+ if (options.clearDeviceLost !== false)
464
+ this.deviceLost = false;
465
+ // Subscribe before init so a loss during the first frames is not missed.
466
+ const initializingDevice = this.device;
467
+ initializingDevice.onDeviceLost((info) => {
468
+ // Ignore a delayed loss from a wrapper recovery already replaced.
469
+ if (this.device !== initializingDevice)
470
+ return;
471
+ this.handleDeviceLost(info, { fromDevicePromise: true });
472
+ });
473
+ await initializingDevice.init(this.canvas);
512
474
  // A `destroy()` (or a newer `init()`) landed while we were parked on the
513
475
  // device. Everything below allocates a full GPU stack — two pipelines,
514
476
  // the picker, the post-processor, the point-cloud and deviation
@@ -586,7 +548,8 @@ export class Renderer {
586
548
  // from the GPU click-pick provider above, since raycastScene* is
587
549
  // synchronous CPU code while pick() is an async GPU readback.
588
550
  this.raycastEngine.setPointCloudProvider(() => this.pointCloudRenderer?.getRayQuerySources() ?? []);
589
- this.markReady(generation);
551
+ if (options.publishReady !== false)
552
+ this.markReady(generation);
590
553
  }
591
554
  /**
592
555
  * Resolves once `init()` has finished and the GPU device + point-cloud
@@ -718,10 +681,33 @@ export class Renderer {
718
681
  isDeviceLost() {
719
682
  return this.deviceLost;
720
683
  }
721
- handleDeviceLost(info) {
722
- if (this.deviceLost)
684
+ recoverDevice() {
685
+ return recoverRendererDevice(this);
686
+ }
687
+ /**
688
+ * `onLossDetected` for every `runGuardedGpuUpload` call below (#4885):
689
+ * a synchronous Safari-style `DOMException` (issue #2229) caught OUTSIDE
690
+ * `render()` never otherwise reaches `handleDeviceLost` — only the async
691
+ * `device.lost` promise and `render()`'s own catch do — so without this,
692
+ * `isDeviceLost()` would keep answering false and every later upload on
693
+ * this path would keep failing against the same dead device.
694
+ */
695
+ reportUploadLoss(error) {
696
+ this.handleDeviceLost({
697
+ message: error instanceof Error ? error.message : String(error),
698
+ reason: 'gpu-upload-exception',
699
+ });
700
+ }
701
+ handleDeviceLost(info, options = {}) {
702
+ // Latched: only the current device's `device.lost` signal is a replacement loss; anything else is a duplicate report.
703
+ if (this.deviceLost) {
704
+ if (options.fromDevicePromise)
705
+ this.deviceLossSequence++;
723
706
  return;
707
+ }
708
+ this.deviceLossSequence++;
724
709
  this.deviceLost = true;
710
+ this.recovery.lostReferenceImages = this.referenceImages.hasImages();
725
711
  this.referenceImages.destroy();
726
712
  this.deviceLostGeneration = this.initGeneration;
727
713
  this.deviceLostInfo = info;
@@ -893,24 +879,34 @@ export class Renderer {
893
879
  * chunks will arrive (currently a no-op but kept for symmetry).
894
880
  */
895
881
  beginPointCloudStream(meta) {
882
+ if (this.deviceLost)
883
+ throw rendererDeviceLostError();
896
884
  if (!this.pointCloudRenderer) {
897
885
  throw new Error('Renderer not initialized. Call init() first.');
898
886
  }
899
- return this.pointCloudRenderer.beginAsset(meta);
887
+ const handle = this.pointCloudRenderer.beginAsset(meta);
888
+ this.pointCloudStreamEpochs.set(handle.id, this.pointCloudStreamEpoch);
889
+ return handle;
890
+ }
891
+ currentPointCloudStreamRenderer(handle) {
892
+ if (this.deviceLost || !this.pointCloudRenderer
893
+ || this.pointCloudStreamEpochs.get(handle.id) !== this.pointCloudStreamEpoch)
894
+ throw rendererDeviceLostError();
895
+ return this.pointCloudRenderer;
900
896
  }
901
897
  appendPointCloudChunk(handle, chunk) {
902
- if (!this.pointCloudRenderer)
903
- return;
904
- this.pointCloudRenderer.appendChunk(handle, chunk);
898
+ this.currentPointCloudStreamRenderer(handle).appendChunk(handle, chunk);
905
899
  this.modelBoundsTracker.expandForPointClouds();
906
900
  this.camera.setSceneBounds(this.modelBounds);
907
901
  this.requestRender();
908
902
  }
909
903
  endPointCloudStream(handle) {
910
- this.pointCloudRenderer?.endAsset(handle);
904
+ this.currentPointCloudStreamRenderer(handle).endAsset(handle);
911
905
  this.requestRender();
912
906
  }
913
907
  removePointCloudAsset(handle) {
908
+ if (!this.pointCloudStreamEpochs.delete(handle.id))
909
+ return;
914
910
  this.pointCloudRenderer?.removeAsset(handle);
915
911
  // Bounds may have shrunk — recompute from scratch so fit-to-view
916
912
  // and section-plane sliders see fresh extents.
@@ -947,6 +943,15 @@ export class Renderer {
947
943
  this.clearCaches();
948
944
  this.refreshPlacementBounds();
949
945
  }
946
+ /** Absolute workspace yaw about the render-frame pivot; GPU-instanced only (#4890). Skips invalidation on a no-op (#4890 review). */
947
+ setModelRotation(modelIndex, angle, pivot) {
948
+ const changed = this.scene.setModelRotation(modelIndex, angle, pivot);
949
+ if (!changed)
950
+ return false;
951
+ this.clearCaches();
952
+ this.refreshPlacementBounds();
953
+ return true;
954
+ }
950
955
  /** Streamed clouds are addressed by durable asset handle. */
951
956
  setPointCloudTranslation(handle, translation) {
952
957
  this.pointCloudRenderer?.setAssetTranslation(handle, translation);
@@ -1011,22 +1016,26 @@ export class Renderer {
1011
1016
  * @param geometry - Either a GeometryResult from geometry.process() or an array of MeshData
1012
1017
  */
1013
1018
  loadGeometry(geometry) {
1014
- if (!this.device.isInitialized() || !this.pipeline) {
1019
+ if (this.isDeviceLost())
1020
+ return { ok: false, reason: 'device-lost' }; // #4885: zombie device stays "initialized"
1021
+ if (!this.device.isInitialized() || !this.pipeline)
1015
1022
  throw new Error('Renderer not initialized. Call init() first.');
1016
- }
1017
1023
  const meshes = Array.isArray(geometry) ? geometry : geometry.meshes;
1018
1024
  if (meshes.length === 0) {
1019
1025
  console.warn('[Renderer] loadGeometry called with empty mesh array');
1020
- return;
1026
+ return { ok: true, value: undefined };
1021
1027
  }
1022
- // Use batched rendering for optimal performance
1023
- const device = this.device.getDevice();
1024
- this.scene.appendToBatches(meshes, device, this.pipeline, false);
1025
- // Calculate and store model bounds for fitToView
1026
- this.modelBoundsTracker.updateFromMeshes(meshes, (index) => this.scene.getModelTranslation(index));
1027
- console.log(`[Renderer] Loaded ${meshes.length} meshes`);
1028
- // Update camera scene bounds for tight orthographic near/far planes
1029
- this.camera.setSceneBounds(this.modelBounds);
1028
+ const pipeline = this.pipeline;
1029
+ return runGuardedGpuUpload(() => this.isDeviceLost(), () => {
1030
+ // Use batched rendering for optimal performance
1031
+ const device = this.device.getDevice();
1032
+ this.scene.appendToBatches(meshes, device, pipeline, false);
1033
+ // Calculate and store model bounds for fitToView
1034
+ this.modelBoundsTracker.updateFromMeshes(meshes, (index) => this.scene.getModelTranslation(index));
1035
+ console.log(`[Renderer] Loaded ${meshes.length} meshes`);
1036
+ // Update camera scene bounds for tight orthographic near/far planes
1037
+ this.camera.setSceneBounds(this.modelBounds);
1038
+ }, (error) => this.reportUploadLoss(error));
1030
1039
  }
1031
1040
  /**
1032
1041
  * Add multiple meshes to the scene (convenience method for streaming)
@@ -1035,17 +1044,20 @@ export class Renderer {
1035
1044
  * @param isStreaming - If true, throttles batch rebuilding for better streaming performance
1036
1045
  */
1037
1046
  addMeshes(meshes, isStreaming = false) {
1047
+ if (this.isDeviceLost())
1048
+ return { ok: false, reason: 'device-lost' };
1038
1049
  if (!this.device.isInitialized() || !this.pipeline) {
1039
1050
  throw new Error('Renderer not initialized. Call init() first.');
1040
1051
  }
1041
1052
  if (meshes.length === 0)
1042
- return;
1043
- const device = this.device.getDevice();
1044
- this.scene.appendToBatches(meshes, device, this.pipeline, isStreaming);
1045
- // Update model bounds incrementally
1046
- this.modelBoundsTracker.updateFromMeshes(meshes, (index) => this.scene.getModelTranslation(index));
1047
- // Update camera scene bounds for tight orthographic near/far planes
1048
- this.camera.setSceneBounds(this.modelBounds);
1053
+ return { ok: true, value: undefined };
1054
+ const pipeline = this.pipeline;
1055
+ return runGuardedGpuUpload(() => this.isDeviceLost(), () => {
1056
+ const device = this.device.getDevice();
1057
+ this.scene.appendToBatches(meshes, device, pipeline, isStreaming);
1058
+ this.modelBoundsTracker.updateFromMeshes(meshes, (index) => this.scene.getModelTranslation(index));
1059
+ this.camera.setSceneBounds(this.modelBounds);
1060
+ }, (error) => this.reportUploadLoss(error));
1049
1061
  }
1050
1062
  /**
1051
1063
  * Fit camera to view all loaded geometry
@@ -1073,50 +1085,46 @@ export class Renderer {
1073
1085
  */
1074
1086
  addMesh(mesh) {
1075
1087
  if (!this.pipeline)
1076
- return;
1077
- // Create per-mesh uniform buffer and bind group if not already created
1078
- if (!mesh.uniformBuffer && this.device.isInitialized()) {
1079
- const device = this.device.getDevice();
1080
- // Create uniform buffer for this mesh
1081
- mesh.uniformBuffer = device.createBuffer({
1082
- size: this.pipeline.getUniformBufferSize(),
1083
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1084
- });
1085
- // Create bind group for this mesh
1086
- mesh.bindGroup = device.createBindGroup({
1087
- layout: this.pipeline.getBindGroupLayout(),
1088
- entries: [
1089
- {
1090
- binding: 0,
1091
- resource: { buffer: mesh.uniformBuffer },
1092
- },
1093
- ],
1094
- });
1095
- }
1088
+ return { ok: true, value: undefined };
1089
+ const pipeline = this.pipeline;
1090
+ const outcome = (!mesh.uniformBuffer && !this.isDeviceLost() && this.device.isInitialized())
1091
+ ? runGuardedGpuUpload(() => this.isDeviceLost(), () => {
1092
+ const device = this.device.getDevice();
1093
+ mesh.uniformBuffer = device.createBuffer({
1094
+ size: pipeline.getUniformBufferSize(),
1095
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1096
+ });
1097
+ mesh.bindGroup = device.createBindGroup({
1098
+ layout: pipeline.getBindGroupLayout(),
1099
+ entries: [{ binding: 0, resource: { buffer: mesh.uniformBuffer } }],
1100
+ });
1101
+ }, (error) => this.reportUploadLoss(error))
1102
+ : { ok: true, value: undefined };
1096
1103
  this.scene.addMesh(mesh);
1104
+ return outcome;
1097
1105
  }
1098
1106
  /**
1099
1107
  * Ensure all meshes have GPU resources (call after adding meshes if pipeline wasn't ready)
1100
1108
  */
1101
1109
  ensureMeshResources() {
1102
1110
  if (!this.pipeline || !this.device.isInitialized())
1103
- return;
1104
- const device = this.device.getDevice();
1105
- for (const mesh of this.scene.getMeshes()) {
1106
- if (!mesh.uniformBuffer) {
1107
- mesh.uniformBuffer = device.createBuffer({
1108
- size: this.pipeline.getUniformBufferSize(),
1109
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1110
- });
1111
- mesh.bindGroup = device.createBindGroup({
1112
- layout: this.pipeline.getBindGroupLayout(),
1113
- entries: [{
1114
- binding: 0,
1115
- resource: { buffer: mesh.uniformBuffer },
1116
- }],
1117
- });
1111
+ return { ok: true, value: undefined };
1112
+ const pipeline = this.pipeline;
1113
+ return runGuardedGpuUpload(() => this.isDeviceLost(), () => {
1114
+ const device = this.device.getDevice();
1115
+ for (const mesh of this.scene.getMeshes()) {
1116
+ if (!mesh.uniformBuffer) {
1117
+ mesh.uniformBuffer = device.createBuffer({
1118
+ size: pipeline.getUniformBufferSize(),
1119
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1120
+ });
1121
+ mesh.bindGroup = device.createBindGroup({
1122
+ layout: pipeline.getBindGroupLayout(),
1123
+ entries: [{ binding: 0, resource: { buffer: mesh.uniformBuffer } }],
1124
+ });
1125
+ }
1118
1126
  }
1119
- }
1127
+ }, (error) => this.reportUploadLoss(error));
1120
1128
  }
1121
1129
  /**
1122
1130
  * Get model bounds (used for section planes, fitToView, etc.)
@@ -1209,11 +1217,15 @@ export class Renderer {
1209
1217
  this.scene.recordBatchDrawn(batch);
1210
1218
  }
1211
1219
  }
1220
+ /** Guarded entry point (#4885) for the unguarded body below. */
1221
+ createMeshFromData(meshData) {
1222
+ return runGuardedGpuUpload(() => this.isDeviceLost(), () => this.createMeshFromDataUnguarded(meshData), (error) => this.reportUploadLoss(error));
1223
+ }
1212
1224
  /**
1213
1225
  * Create a GPU Mesh from MeshData (lazy creation for selection highlighting)
1214
1226
  * This is called on-demand when a mesh is selected, avoiding 2x buffer creation during streaming
1215
1227
  */
1216
- createMeshFromData(meshData) {
1228
+ createMeshFromDataUnguarded(meshData) {
1217
1229
  if (!this.device.isInitialized())
1218
1230
  return;
1219
1231
  const device = this.device.getDevice();
@@ -1230,7 +1242,7 @@ export class Renderer {
1230
1242
  // We compute the same `world` here. When there's no shared origin yet
1231
1243
  // (legacy / pre-batch), fall back to a plain f64 fold (local + origin).
1232
1244
  const o = meshData.origin;
1233
- const so = this.scene.getSharedFrameOrigin(meshData.modelIndex);
1245
+ const so = this.scene.getSharedFrameOrigin(meshData.modelIndex, meshData);
1234
1246
  const ox = o ? o[0] : 0, oy = o ? o[1] : 0, oz = o ? o[2] : 0;
1235
1247
  const fr = Math.fround;
1236
1248
  const sox = so ? fr(so[0]) : null, soy = so ? fr(so[1]) : 0, soz = so ? fr(so[2]) : 0;
@@ -1415,6 +1427,7 @@ export class Renderer {
1415
1427
  * rejecting pipeline creation) batches stay on the f32 path.
1416
1428
  */
1417
1429
  async enableQuantizedBatches() {
1430
+ this.recovery.quantizedBatchesRequested = true;
1418
1431
  if (!this.pipeline)
1419
1432
  return false;
1420
1433
  const ok = await this.pipeline.ensureQuantizedPipelines();
@@ -3043,8 +3056,8 @@ export class Renderer {
3043
3056
  }
3044
3057
  /** Stage one new owner; borrowed mesh buffers must remain immutable until disposal. */
3045
3058
  prepareAuthoredOwner(parts) {
3046
- if (!this.device.isInitialized() || !this.pipeline)
3047
- throw new Error('Renderer is not initialized.');
3059
+ if (this.deviceLost || !this.device.isInitialized() || !this.pipeline)
3060
+ throw this.deviceLost ? rendererDeviceLostError() : new Error('Renderer is not initialized.');
3048
3061
  const prepared = this.scene.prepareAuthoredOwner(parts, this.device.getDevice(), this.pipeline);
3049
3062
  return { commit: () => { prepared.commit(); this.refreshPlacementBounds(); this.invalidateBVHCache(); this.requestRender(); }, dispose: prepared.dispose };
3050
3063
  }
@@ -3305,15 +3318,17 @@ export class Renderer {
3305
3318
  * in-flight init. Callers: the public `destroy()` (which invalidates first)
3306
3319
  * and `initOnce()`, tearing down the previous init before building its own.
3307
3320
  */
3308
- teardown() {
3321
+ teardown(clearScene = true) {
3309
3322
  // Nothing below survives this call, so `whenReady()` / `isReady()` must
3310
3323
  // go back to waiting. Set first: every release below is synchronous, but
3311
3324
  // the flag is what a caller holding a live reference actually reads.
3312
3325
  this.ready = false;
3313
3326
  // Scene mesh GPU buffers
3314
- this.scene.clear();
3315
- // Re-arm the section-bounds diagnostic log for the next model.
3316
- this._loggedSectionBounds = false;
3327
+ if (clearScene) {
3328
+ this.scene.clear();
3329
+ // Re-arm the section-bounds diagnostic log for the next model.
3330
+ this._loggedSectionBounds = false;
3331
+ }
3317
3332
  // Render pipelines (textures + uniform buffers)
3318
3333
  this.pipeline?.destroy();
3319
3334
  this.pipeline = null;
@@ -3338,6 +3353,8 @@ export class Renderer {
3338
3353
  this.overlays.destroy();
3339
3354
  this.referenceImages.destroy();
3340
3355
  // Point cloud GPU resources
3356
+ this.pointCloudStreamEpoch++;
3357
+ this.pointCloudStreamEpochs.clear();
3341
3358
  this.pointCloudRenderer?.clear();
3342
3359
  this.pointCloudRenderer = null;
3343
3360
  // BIM ↔ scan deviation pipeline + cached BVH GPU buffers.