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