@ifc-lite/renderer 1.41.1 → 1.44.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 (120) hide show
  1. package/dist/camera-animation.d.ts +20 -32
  2. package/dist/camera-animation.d.ts.map +1 -1
  3. package/dist/camera-animation.js +71 -358
  4. package/dist/camera-animation.js.map +1 -1
  5. package/dist/camera-controls.d.ts +11 -51
  6. package/dist/camera-controls.d.ts.map +1 -1
  7. package/dist/camera-controls.js +140 -12
  8. package/dist/camera-controls.js.map +1 -1
  9. package/dist/camera-first-person.d.ts +52 -0
  10. package/dist/camera-first-person.d.ts.map +1 -0
  11. package/dist/camera-first-person.js +119 -0
  12. package/dist/camera-first-person.js.map +1 -0
  13. package/dist/camera-fit-policy.d.ts.map +1 -1
  14. package/dist/camera-fit-policy.js +18 -7
  15. package/dist/camera-fit-policy.js.map +1 -1
  16. package/dist/camera-framing.d.ts +86 -0
  17. package/dist/camera-framing.d.ts.map +1 -0
  18. package/dist/camera-framing.js +230 -0
  19. package/dist/camera-framing.js.map +1 -0
  20. package/dist/camera-guards.d.ts +148 -0
  21. package/dist/camera-guards.d.ts.map +1 -0
  22. package/dist/camera-guards.js +150 -0
  23. package/dist/camera-guards.js.map +1 -0
  24. package/dist/camera-matrices.d.ts +14 -0
  25. package/dist/camera-matrices.d.ts.map +1 -0
  26. package/dist/camera-matrices.js +146 -0
  27. package/dist/camera-matrices.js.map +1 -0
  28. package/dist/camera-preset-view.d.ts +56 -0
  29. package/dist/camera-preset-view.d.ts.map +1 -0
  30. package/dist/camera-preset-view.js +180 -0
  31. package/dist/camera-preset-view.js.map +1 -0
  32. package/dist/camera-projection.d.ts +1 -1
  33. package/dist/camera-projection.d.ts.map +1 -1
  34. package/dist/camera-projection.js +83 -30
  35. package/dist/camera-projection.js.map +1 -1
  36. package/dist/camera-state.d.ts +62 -0
  37. package/dist/camera-state.d.ts.map +1 -0
  38. package/dist/camera-state.js +5 -0
  39. package/dist/camera-state.js.map +1 -0
  40. package/dist/camera.d.ts +22 -11
  41. package/dist/camera.d.ts.map +1 -1
  42. package/dist/camera.js +110 -85
  43. package/dist/camera.js.map +1 -1
  44. package/dist/deviation/deviation-pipeline.d.ts.map +1 -1
  45. package/dist/deviation/deviation-pipeline.js +21 -4
  46. package/dist/deviation/deviation-pipeline.js.map +1 -1
  47. package/dist/environment.d.ts.map +1 -1
  48. package/dist/environment.js +23 -1
  49. package/dist/environment.js.map +1 -1
  50. package/dist/index.d.ts +333 -43
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +753 -704
  53. package/dist/index.js.map +1 -1
  54. package/dist/math.d.ts +59 -2
  55. package/dist/math.d.ts.map +1 -1
  56. package/dist/math.js +173 -23
  57. package/dist/math.js.map +1 -1
  58. package/dist/model-bounds-tracker.d.ts +96 -0
  59. package/dist/model-bounds-tracker.d.ts.map +1 -0
  60. package/dist/model-bounds-tracker.js +147 -0
  61. package/dist/model-bounds-tracker.js.map +1 -0
  62. package/dist/pointcloud/point-cloud-node.d.ts.map +1 -1
  63. package/dist/pointcloud/point-cloud-node.js +24 -4
  64. package/dist/pointcloud/point-cloud-node.js.map +1 -1
  65. package/dist/render-degradation.d.ts +77 -0
  66. package/dist/render-degradation.d.ts.map +1 -0
  67. package/dist/render-degradation.js +52 -0
  68. package/dist/render-degradation.js.map +1 -0
  69. package/dist/render-section-draw.d.ts +51 -0
  70. package/dist/render-section-draw.d.ts.map +1 -0
  71. package/dist/render-section-draw.js +66 -0
  72. package/dist/render-section-draw.js.map +1 -0
  73. package/dist/render-section-plane.d.ts +128 -0
  74. package/dist/render-section-plane.d.ts.map +1 -0
  75. package/dist/render-section-plane.js +298 -0
  76. package/dist/render-section-plane.js.map +1 -0
  77. package/dist/renderer-overlays.d.ts +144 -0
  78. package/dist/renderer-overlays.d.ts.map +1 -0
  79. package/dist/renderer-overlays.js +257 -0
  80. package/dist/renderer-overlays.js.map +1 -0
  81. package/dist/renderer-symbolic-overlays.d.ts +52 -0
  82. package/dist/renderer-symbolic-overlays.d.ts.map +1 -0
  83. package/dist/renderer-symbolic-overlays.js +115 -0
  84. package/dist/renderer-symbolic-overlays.js.map +1 -0
  85. package/dist/scene-geometry.d.ts +32 -0
  86. package/dist/scene-geometry.d.ts.map +1 -1
  87. package/dist/scene-geometry.js +66 -0
  88. package/dist/scene-geometry.js.map +1 -1
  89. package/dist/scene.d.ts +86 -1
  90. package/dist/scene.d.ts.map +1 -1
  91. package/dist/scene.js +391 -201
  92. package/dist/scene.js.map +1 -1
  93. package/dist/section-2d-lift.d.ts +105 -0
  94. package/dist/section-2d-lift.d.ts.map +1 -0
  95. package/dist/section-2d-lift.js +162 -0
  96. package/dist/section-2d-lift.js.map +1 -0
  97. package/dist/section-2d-line-buffer.d.ts +58 -0
  98. package/dist/section-2d-line-buffer.d.ts.map +1 -0
  99. package/dist/section-2d-line-buffer.js +109 -0
  100. package/dist/section-2d-line-buffer.js.map +1 -0
  101. package/dist/section-2d-overlay.d.ts +43 -75
  102. package/dist/section-2d-overlay.d.ts.map +1 -1
  103. package/dist/section-2d-overlay.js +189 -559
  104. package/dist/section-2d-overlay.js.map +1 -1
  105. package/dist/section-plane-basis.d.ts +22 -3
  106. package/dist/section-plane-basis.d.ts.map +1 -1
  107. package/dist/section-plane-basis.js +51 -10
  108. package/dist/section-plane-basis.js.map +1 -1
  109. package/dist/section-plane.d.ts.map +1 -1
  110. package/dist/section-plane.js +10 -1
  111. package/dist/section-plane.js.map +1 -1
  112. package/dist/shaders/section-2d-overlay.wgsl.d.ts +102 -0
  113. package/dist/shaders/section-2d-overlay.wgsl.d.ts.map +1 -0
  114. package/dist/shaders/section-2d-overlay.wgsl.js +281 -0
  115. package/dist/shaders/section-2d-overlay.wgsl.js.map +1 -0
  116. package/dist/visual-enhancement.d.ts +33 -0
  117. package/dist/visual-enhancement.d.ts.map +1 -0
  118. package/dist/visual-enhancement.js +46 -0
  119. package/dist/visual-enhancement.js.map +1 -0
  120. package/package.json +4 -3
package/dist/index.js CHANGED
@@ -11,9 +11,15 @@ export { pickFitPolicy } from './camera-fit-policy.js';
11
11
  export { Scene } from './scene.js';
12
12
  export { Picker } from './picker.js';
13
13
  export { MathUtils } from './math.js';
14
+ // The orthonormal camera basis `MathUtils.lookAt` renders through, exposed so
15
+ // that a consumer which has to reconstruct the on-screen frame outside the
16
+ // renderer derives it from the same substitution the picture used, instead of
17
+ // recomputing `cross(forward, up)` and inventing its own answer for a
18
+ // degenerate `up` (#2467 made this call inside the package; the Cesium overlay
19
+ // is the same situation from outside it).
20
+ export { viewBasis } from './math.js';
14
21
  export { SectionPlaneRenderer } from './section-plane.js';
15
22
  export { Section2DOverlayRenderer } from './section-2d-overlay.js';
16
- import { aabbEdgeLineList } from './aabb-edges.js';
17
23
  // IfcAnnotation overlay pipelines (3D world-space). Self-contained — caller
18
24
  // passes a GPUDevice + presentation format and invokes `.render(pass, viewProj)`
19
25
  // from inside an RGBA-blended pass. See packages/renderer/src/symbolic-overlay-pipelines.ts.
@@ -48,18 +54,19 @@ import { RenderPipeline } from './pipeline.js';
48
54
  import { Camera } from './camera.js';
49
55
  import { Scene } from './scene.js';
50
56
  import { Picker } from './picker.js';
51
- import { MathUtils } from './math.js';
57
+ import { MathUtils, viewBasis } from './math.js';
52
58
  import { FrustumUtils } from '@ifc-lite/spatial';
53
- import { SectionPlaneRenderer } from './section-plane.js';
59
+ import { VisualEnhancementResolver } from './visual-enhancement.js';
54
60
  import { packClipBox } from './clip-box.js';
55
- import { Section2DOverlayRenderer } from './section-2d-overlay.js';
56
- import { SymbolicFillPipeline, SymbolicTextPipeline, } from './symbolic-overlay-pipelines.js';
57
- import { DEFAULT_CAP_STYLE, HATCH_PATTERN_IDS } from './section-cap-style.js';
61
+ import { RendererOverlays } from './renderer-overlays.js';
62
+ import { resolveSectionPlaneFrame } from './render-section-plane.js';
58
63
  import { PickingManager } from './picking-manager.js';
59
64
  import { RaycastEngine } from './raycast-engine.js';
65
+ import { RenderDegradationMonitor } from './render-degradation.js';
60
66
  import { PostProcessor } from './post-processor.js';
61
67
  import { InteractionEffectsGovernor } from './interaction-effects-governor.js';
62
68
  import { VisibilityEpochTracker } from './visibility-epoch.js';
69
+ import { ModelBoundsTracker } from './model-bounds-tracker.js';
63
70
  import { resolveContributionThresholdPx, projectedAabbRadiusPx, projectedInstancedRadiusPx } from './contribution-cull.js';
64
71
  import { EdlPass } from './edl-pass.js';
65
72
  import { SkyPass } from './sky-pass.js';
@@ -91,6 +98,69 @@ function computeBvhFingerprint(meshes) {
91
98
  }
92
99
  return parts.join('|');
93
100
  }
101
+ /**
102
+ * Is this throw the GPU device telling us it is gone?
103
+ *
104
+ * The discriminator is the exception TYPE, not its message, because WebGPU
105
+ * draws exactly that line:
106
+ * - a call on a dead / invalid-state device throws a `DOMException`
107
+ * (`InvalidStateError` in Safari 26.5 — the whole of issue #2229);
108
+ * - a buffer allocation the host cannot back throws a plain `RangeError`
109
+ * ("createBuffer failed, size (…) is too large … when mappedAtCreation ==
110
+ * true"), which `gpu-upload-guard` documents happening on a HEALTHY device
111
+ * under memory pressure.
112
+ *
113
+ * Treating the second as a device loss is a false positive that costs the whole
114
+ * session, so only the first latches; everything else degrades one frame.
115
+ *
116
+ * There is deliberately NO consecutive-failure threshold as a middle ground.
117
+ * Not because failures necessarily arrive back-to-back — between two BCF / IDS
118
+ * capture frames the awaited `camera.frameBounds` normally does let an ordinary
119
+ * rAF frame through, which would reset a counter — but because those ordinary
120
+ * frames are not guaranteed to SUCCEED: they allocate too (`ensureMeshResources`
121
+ * creates a buffer per unresourced mesh, and the queued-mesh flush allocates),
122
+ * so under sustained host memory pressure any finite budget is still reachable.
123
+ * A latch whose safety depends on incidental animation timing is the wrong
124
+ * shape of guarantee for "never kill the viewport by mistake".
125
+ *
126
+ * Real losses on browsers that do not throw are still caught by the async
127
+ * `device.lost` promise — which the WebGPU spec makes the sole loss channel
128
+ * anyway (on a conformant engine, calls against a lost device are no-ops, not
129
+ * throws; Safari 26.5's synchronous throw is the deviation being handled here).
130
+ *
131
+ * `typeof` guarded because non-DOM hosts (Node before 17, some workers) have no
132
+ * `DOMException` global; there, no throw can be a WebGPU device signal anyway.
133
+ */
134
+ function isDeviceLossThrow(error) {
135
+ return typeof DOMException !== 'undefined' && error instanceof DOMException;
136
+ }
137
+ /**
138
+ * The reason `whenReady()` rejects when the renderer is destroyed.
139
+ *
140
+ * A plain `Error` carrying a stable `name` rather than an exported subclass:
141
+ * consumers can discriminate it with `err.name === 'RendererDestroyedError'`
142
+ * without this package growing a new export (and without `instanceof` breaking
143
+ * across duplicated copies of the package).
144
+ */
145
+ function rendererDestroyedError() {
146
+ const error = new Error('Renderer was destroyed before it became ready');
147
+ error.name = 'RendererDestroyedError';
148
+ return error;
149
+ }
150
+ /**
151
+ * The reason `whenReady()` rejects once the GPU device has been lost.
152
+ *
153
+ * Deliberately NOT `RendererDestroyedError`: a destroyed renderer is finished,
154
+ * while a lost one is dead only until the host re-initialises it (`init()`
155
+ * clears the latch and readiness is published again). A caller that wants to
156
+ * retry needs to tell those apart, so the loss carries its own `name` — same
157
+ * plain-`Error` shape, for the same reasons.
158
+ */
159
+ function rendererDeviceLostError() {
160
+ const error = new Error('GPU device was lost before the renderer became ready');
161
+ error.name = 'RendererDeviceLostError';
162
+ return error;
163
+ }
94
164
  /**
95
165
  * Main renderer class
96
166
  */
@@ -101,15 +171,21 @@ export class Renderer {
101
171
  scene;
102
172
  picker = null;
103
173
  canvas;
104
- sectionPlaneRenderer = null;
105
- section2DOverlayRenderer = null;
106
- // Overlay/section-cut line colour, kept on the Renderer so it survives a
107
- // pre-init call and a section2DOverlayRenderer re-creation (re-applied below).
108
- overlayLineColor = [0, 0, 0, 1];
109
- // IfcAnnotation overlay pipelines (issue #653). Created on `init()` once
110
- // the device exists; nulled until then.
111
- symbolicFillPipeline = null;
112
- symbolicTextPipeline = null;
174
+ /**
175
+ * Section-plane gizmo, 2D section drawing/cap, and the standalone 3D line
176
+ * + symbolic annotation overlays (issue #2425). Created here rather than in
177
+ * `init()` so a pre-init `setOverlayLineColor` still lands the GPU
178
+ * objects inside stay null until `init()` calls `overlays.init()`.
179
+ */
180
+ overlays = new RendererOverlays({
181
+ getModelBounds: () => this.getModelBounds(),
182
+ expandModelBoundsWithFlatVertices: (positions, stride) => this.modelBoundsTracker.expandWithFlatVertices(positions, stride),
183
+ syncCameraSceneBounds: () => {
184
+ if (this.modelBounds)
185
+ this.camera.setSceneBounds(this.modelBounds);
186
+ },
187
+ requestRender: () => this.requestRender(),
188
+ });
113
189
  postProcessor = null;
114
190
  interactionEffects = new InteractionEffectsGovernor();
115
191
  edlPass = null;
@@ -123,17 +199,98 @@ export class Renderer {
123
199
  highQuality: true,
124
200
  };
125
201
  pointCloudRenderer = null;
126
- /** Set true at the end of `init()`; gates `whenReady()`. */
202
+ /**
203
+ * Set true at the end of the LATEST `init()`; gates `whenReady()` and
204
+ * `isReady()`. Revoked synchronously by `init()` and by `destroy()`, and
205
+ * overridden (not cleared) by a device loss — see `deviceLost`, which the
206
+ * two readiness methods consult alongside this flag because the loss can
207
+ * land in the middle of the init that is about to set it.
208
+ */
127
209
  ready = false;
128
210
  readyWaiters = [];
211
+ /**
212
+ * Set by the public `destroy()`, cleared synchronously by `init()`. It is
213
+ * the difference between "not ready YET" and "never going to be ready":
214
+ * `whenReady()` parks for the first and rejects for the second.
215
+ *
216
+ * Without it a caller parked across the teardown waits forever, because
217
+ * nothing after `destroy()` will ever reach `markReady()` — the host's
218
+ * remount builds a NEW Renderer rather than re-initialising this one. The
219
+ * private `teardown()` deliberately does NOT set it: the teardown
220
+ * `initOnce()` runs on the previous init's objects is part of an init that
221
+ * IS going to publish readiness, and its waiters must survive to be flushed.
222
+ */
223
+ destroyed = false;
224
+ /**
225
+ * The tail of the `init()` queue. `init()` chains onto this rather than
226
+ * running immediately, so two overlapping calls cannot both walk past the
227
+ * "a previous init completed" guard while the first is still awaiting its
228
+ * device and both allocate a full set of GPU objects (#2448). Always
229
+ * settled fulfilled — a rejected init is swallowed HERE (never for the
230
+ * caller) so one failure does not deadlock every later call.
231
+ */
232
+ initChain = Promise.resolve();
233
+ /**
234
+ * Incremented synchronously by every `init()` call AND by every public
235
+ * `destroy()`. It stamps "the lifecycle event an in-flight init belongs to":
236
+ * an init that no longer carries the current stamp has been superseded and
237
+ * must neither allocate nor publish readiness.
238
+ *
239
+ * Both bumps are load-bearing, for the same reason. Because the queue above
240
+ * defers the body, an init can finish while a later one is still waiting its
241
+ * turn; that later call is about to tear down everything the earlier one
242
+ * built, so the earlier one must not publish readiness. And a host that calls
243
+ * `destroy()` while an init is parked on `await device.init(...)` gets the
244
+ * same hazard from the other direction: without the bump that init resumes,
245
+ * allocates a full replacement GPU stack nothing references, and re-publishes
246
+ * `ready` against a renderer that has already been torn down (#2465).
247
+ *
248
+ * The teardown `initOnce()` runs as part of its OWN re-init deliberately does
249
+ * NOT bump it — see `destroy()` vs `teardown()`. Bumping there would make
250
+ * every re-init invalidate itself, and nothing would ever become ready again.
251
+ */
252
+ initGeneration = 0;
129
253
  /**
130
254
  * Set once the GPU device is lost for a non-intentional reason (driver
131
255
  * reset / VRAM exhaustion — see `WebGPUDevice`). Every GPU resource is then
132
256
  * dead, so `render()` becomes a no-op (it would only spew validation errors)
133
- * until the host re-initialises the renderer. Consumers learn of this via
134
- * `onDeviceLost` and typically respond by reloading the model.
257
+ * and the renderer stops reporting itself ready (`isReady()` goes false,
258
+ * `whenReady()` rejects) until the host re-initialises it. Consumers learn
259
+ * of this via `onDeviceLost` and typically respond by reloading the model.
260
+ *
261
+ * Two signals set it: the async `device.lost` promise (Chromium), and a
262
+ * frame throwing a `DOMException` out of `render()` (Safari 26.5, which
263
+ * reports the loss synchronously — issue #2229). Whichever arrives first
264
+ * latches. A frame throwing anything else does NOT latch (see
265
+ * `isDeviceLossThrow`) — that class is host memory pressure on a live
266
+ * device, and it must cost one frame, not the session.
135
267
  */
136
268
  deviceLost = false;
269
+ /**
270
+ * The lifecycle generation the latched loss belongs to (see
271
+ * `initGeneration`); null until the first loss, and never cleared
272
+ * afterwards. It is only ever read next to `deviceLost`, which is what
273
+ * makes a stale stamp harmless — and that pairing is required, not
274
+ * cosmetic: a loss that latched between `init()` bumping the generation and
275
+ * its queued body running is stamped with the CURRENT generation, and only
276
+ * the flag that body clears says the renderer has moved on.
277
+ *
278
+ * `whenReady()` rejects only while this still equals the CURRENT generation,
279
+ * which is what re-arms the wait the instant a host calls `init()` — before
280
+ * the queued body has had a chance to clear `deviceLost` itself. Without
281
+ * that, `renderer.init(); await renderer.whenReady();` — the recovery shape
282
+ * `init()` already revokes readiness synchronously for — would reject inside
283
+ * the microtask window on a renderer that is being brought back up.
284
+ *
285
+ * Scoping it here rather than clearing `deviceLost` in `init()` keeps the
286
+ * flag meaning exactly one thing everywhere else: `render()`, the pick path
287
+ * and `getGPUDevice()` must stay shut for the OLD device across that same
288
+ * window, and clearing early would let frames run against dead GPU objects
289
+ * (and, on Safari, re-latch and re-notify the loss they already reported).
290
+ */
291
+ deviceLostGeneration = null;
292
+ /** Retained so a listener registered AFTER the loss still learns of it. */
293
+ deviceLostInfo = null;
137
294
  deviceLostListeners = new Set();
138
295
  deviationPipeline = null;
139
296
  /**
@@ -144,14 +301,20 @@ export class Renderer {
144
301
  * want to pay that on every slider drag.
145
302
  */
146
303
  deviationBvhFingerprint = null;
147
- visualEnhancementState = {
148
- enabled: true,
149
- edgeContrast: { enabled: true, intensity: 1.0 },
150
- contactShading: { quality: 'off', intensity: 0.3, radius: 1.0 },
151
- separationLines: { enabled: true, quality: 'low', intensity: 0.5, radius: 1.0 },
152
- };
153
- // Model bounds for fitToView, section planes, camera
154
- modelBounds = null;
304
+ visualEnhancementResolver = new VisualEnhancementResolver();
305
+ // Model bounds for fitToView, section planes, camera. The value itself
306
+ // lives in ModelBoundsTracker (issue #2425) so the four writers — point
307
+ // cloud upload, mesh load, overlay upload, and the public setModelBounds —
308
+ // share one owner instead of a private field. Camera notification stays at
309
+ // the call sites: they do not all push under the same policy.
310
+ modelBoundsTracker = new ModelBoundsTracker({
311
+ meshBounds: () => this.computeMeshBounds(),
312
+ pointCloudBounds: () => this.pointCloudRenderer?.getBounds() ?? null,
313
+ });
314
+ /** Read-only view of the tracked scene AABB (live reference, not a copy). */
315
+ get modelBounds() {
316
+ return this.modelBoundsTracker.get();
317
+ }
155
318
  // Composition: delegate to extracted managers
156
319
  pickingManager;
157
320
  raycastEngine;
@@ -161,6 +324,48 @@ export class Renderer {
161
324
  // exactly the evidence worth keeping.
162
325
  lastRenderErrorTime = -Infinity;
163
326
  RENDER_ERROR_THROTTLE_MS = 1000;
327
+ /**
328
+ * Consecutive frames that threw a non-device error and were degraded.
329
+ * Reset by any frame that completes. Gates the self-retry in `render()`'s
330
+ * catch — see there for why it is a retry budget and not a latch — and,
331
+ * since #2417, the persistent-degradation report as well. Both readings
332
+ * depend on the reset: this is the length of the CURRENT unbroken run of
333
+ * failures, never a session total (`_renderErrorCount` is that, and using
334
+ * it for either purpose would count failures the viewport recovered from).
335
+ */
336
+ consecutiveDegradedFrames = 0;
337
+ /**
338
+ * How many consecutive degraded frames may re-request themselves. Three is
339
+ * a blink at 60 Hz — enough for a transient host-memory spike to clear
340
+ * without the user touching anything, far too few to matter as wasted work
341
+ * if it does not. Beyond it the viewport goes quiet rather than spinning,
342
+ * and the next interaction/stream/animation drives it as normal.
343
+ */
344
+ MAX_DEGRADED_SELF_RETRIES = 3;
345
+ /**
346
+ * Decides when degrading has stopped being transient (issue #2417). The
347
+ * non-latching branch is correct per occurrence and blind in aggregate: a
348
+ * failure that never clears leaves a wedged viewport that looks, from
349
+ * outside, exactly like one that recovered. Fires once per session.
350
+ */
351
+ renderDegradation = new RenderDegradationMonitor();
352
+ persistentDegradationListeners = new Set();
353
+ /**
354
+ * Set by `containFrameThrow` for the frame currently in flight, cleared by
355
+ * `render()` before each one.
356
+ *
357
+ * Needed because the encode region's catch is INSIDE `renderFrame()`, and
358
+ * it swallows its throw: a frame that failed there returns to `render()`
359
+ * perfectly normally, so "did not throw" is not the same question as "did
360
+ * not fail". Without this flag `render()` reads it as a completed frame and
361
+ * resets `consecutiveDegradedFrames` on the very next line — which makes
362
+ * `++count <= MAX_DEGRADED_SELF_RETRIES` true on EVERY encode failure, so
363
+ * the retry budget never exhausts and a persistently failing encode path
364
+ * re-requests one throwing frame per rAF forever. It also caps the run
365
+ * length at 1, so no persistent-degradation report could ever fire for the
366
+ * region this PR exists to cover.
367
+ */
368
+ frameContainedThrow = false;
164
369
  // Diagnostic counters for mobile debugging
165
370
  _renderCallCount = 0;
166
371
  _renderSkipCount = 0;
@@ -225,17 +430,82 @@ export class Renderer {
225
430
  this.raycastEngine = new RaycastEngine(this.camera, this.scene, this.canvas);
226
431
  }
227
432
  /**
228
- * Initialize renderer
433
+ * Initialize renderer.
434
+ *
435
+ * Safe to call on an already-initialised instance: the previous GPU objects
436
+ * are released first. The comment below advertises a `destroy()` + `init()`
437
+ * re-init flow, and the obvious device-loss auto-recovery is to call
438
+ * `init()` on the live instance — which, without this, silently orphaned
439
+ * two render pipelines, the picker, the post-processor, the point-cloud and
440
+ * deviation pipelines, the EDL pass and the overlay layer's glyph atlas, per
441
+ * recovery (#2448). Making the method self-safe is cheaper than trusting
442
+ * every future caller to remember.
443
+ *
444
+ * Concurrent calls are SERIALISED, not coalesced: the second waits for the
445
+ * first to settle and then runs in full. Without that, `pipeline` — which
446
+ * only ever marks a COMPLETED init — is still null while the first call is
447
+ * awaiting `device.init()`, so both calls sail past the guard above and both
448
+ * allocate a full set of GPU objects, orphaning the first. Queueing turns
449
+ * the concurrent case into the sequential one the guard already handles,
450
+ * rather than adding a second, differently-shaped rule.
229
451
  */
230
452
  async init() {
453
+ // Revoke readiness SYNCHRONOUSLY, before the body is queued. Everything
454
+ // below runs in a later microtask (or, for a queued call, only after the
455
+ // one ahead of it settles), so leaving `ready` set would let
456
+ // `renderer.init(); await renderer.whenReady();` resolve immediately
457
+ // against the GPU objects this init is about to destroy — the very
458
+ // hazard the re-arm inside `initOnce()` exists to prevent. On a first
459
+ // init there is nothing to invalidate and this is a no-op.
460
+ const generation = ++this.initGeneration;
461
+ this.ready = false;
462
+ // Re-arm `whenReady()`: this instance is being brought back up, so a
463
+ // wait requested from here on is "not ready yet" again, not "destroyed".
464
+ this.destroyed = false;
465
+ // A previous init that REJECTED must not block the next one, so the
466
+ // stored link swallows the outcome. The caller still receives `run`, so
467
+ // rejections continue to surface exactly as before.
468
+ const run = this.initChain.then(() => this.initOnce(generation), () => this.initOnce(generation));
469
+ this.initChain = run.then(() => undefined, () => undefined);
470
+ return run;
471
+ }
472
+ async initOnce(generation) {
473
+ // `pipeline` is the marker for "a previous init() completed": it is
474
+ // assigned unconditionally there and nulled by destroy().
475
+ if (this.pipeline !== null) {
476
+ // Release the device the previous init() resolved on, and re-arm
477
+ // `whenReady()` so it cannot resolve against GPU objects that no
478
+ // longer exist. `teardown()`, not the public `destroy()`: this
479
+ // teardown is part of THIS init, so it must not invalidate this
480
+ // init's own generation.
481
+ this.teardown();
482
+ }
231
483
  // Clear the lost flag so a re-init (destroy()+init() on the same instance)
232
484
  // resumes rendering instead of staying a permanent no-op from an earlier loss.
485
+ // This also releases `whenReady()`'s rejection for the one case the
486
+ // generation stamp cannot: a loss that latched between `init()` bumping
487
+ // the generation and this body running is stamped with the generation
488
+ // that is clearing it. `deviceLostGeneration` deliberately keeps its
489
+ // stale value — it is only ever read alongside this flag.
233
490
  this.deviceLost = false;
234
491
  // Subscribe before the device exists so a loss during the first frames
235
492
  // is never missed — the handler is only invoked when `device.lost`
236
493
  // actually resolves (a real fault), long after init in practice.
237
494
  this.device.onDeviceLost((info) => this.handleDeviceLost(info));
238
495
  await this.device.init(this.canvas);
496
+ // A `destroy()` (or a newer `init()`) landed while we were parked on the
497
+ // device. Everything below allocates a full GPU stack — two pipelines,
498
+ // the picker, the post-processor, the point-cloud and deviation
499
+ // pipelines, the EDL pass, the overlay glyph atlas — and this aborted
500
+ // path runs no second teardown, so all of it would be orphaned outright
501
+ // (#2465). `markReady()`'s generation check is not enough on its own: it
502
+ // withholds the readiness PUBLICATION, not the allocation. Release the
503
+ // device we just brought up and stop here; a queued init will bring up
504
+ // its own.
505
+ if (generation !== this.initGeneration) {
506
+ this.device.destroy();
507
+ return;
508
+ }
239
509
  // Get canvas dimensions (use pixel dimensions if set, otherwise use CSS dimensions)
240
510
  // and clamp to the GPU's max 2D texture dimension so the initial pipeline allocations
241
511
  // can't overflow on tall/wide layouts (see render() for the per-frame clamp).
@@ -252,16 +522,7 @@ export class Renderer {
252
522
  }
253
523
  this.pipeline = new RenderPipeline(this.device, width, height);
254
524
  this.picker = new Picker(this.device, width, height);
255
- this.sectionPlaneRenderer = new SectionPlaneRenderer(this.device.getDevice(), this.device.getFormat(), this.pipeline.getSampleCount());
256
- this.section2DOverlayRenderer = new Section2DOverlayRenderer(this.device.getDevice(), this.device.getFormat(), this.pipeline.getSampleCount());
257
- // Re-apply any colour set before this (re)creation so it isn't lost.
258
- this.section2DOverlayRenderer.setOverlayLineColor(this.overlayLineColor);
259
- // IfcAnnotation overlay pipelines (issue #653). Share the device +
260
- // presentation format AND the MSAA sample count + objectId attachment
261
- // shape with the rest of the renderer so they composite into the same
262
- // RGBA pass without WebGPU pass-compatibility validation errors.
263
- this.symbolicFillPipeline = new SymbolicFillPipeline(this.device.getDevice(), this.device.getFormat(), this.pipeline.getSampleCount());
264
- this.symbolicTextPipeline = new SymbolicTextPipeline(this.device.getDevice(), this.device.getFormat(), this.pipeline.getSampleCount());
525
+ this.overlays.init(this.device.getDevice(), this.device.getFormat(), this.pipeline.getSampleCount());
265
526
  // PostProcessor is optional — if it fails (e.g. mobile GPU lacking
266
527
  // depth TEXTURE_BINDING), rendering still works without post-processing.
267
528
  try {
@@ -308,7 +569,7 @@ export class Renderer {
308
569
  // from the GPU click-pick provider above, since raycastScene* is
309
570
  // synchronous CPU code while pick() is an async GPU readback.
310
571
  this.raycastEngine.setPointCloudProvider(() => this.pointCloudRenderer?.getRayQuerySources() ?? []);
311
- this.markReady();
572
+ this.markReady(generation);
312
573
  }
313
574
  /**
314
575
  * Resolves once `init()` has finished and the GPU device + point-cloud
@@ -317,25 +578,80 @@ export class Renderer {
317
578
  * the async WebGPU init resolves — should `await renderer.whenReady()`
318
579
  * before `beginPointCloudStream`, which otherwise throws
319
580
  * "Renderer not initialized".
581
+ *
582
+ * REJECTS (with an `Error` whose `name` is `RendererDestroyedError`) if
583
+ * `destroy()` runs while the caller is waiting, or if it already ran and no
584
+ * `init()` has been started since. It never resolves against a destroyed
585
+ * renderer — that is what this method exists to prevent — so the only
586
+ * alternative would be a promise that never settles, which suspends the
587
+ * caller's async frame permanently and takes everything the frame captured
588
+ * with it. The viewer reaches that state on an ordinary path: `Viewport`
589
+ * builds a NEW `Renderer` per mount and destroys the old one in its effect
590
+ * cleanup, so a `destroy()` there is FINAL for the instance a consumer
591
+ * captured — a point-cloud drop that straddles a layout swap or a
592
+ * StrictMode remount would otherwise hang mid-load, with no error, forever.
593
+ * Callers should handle the rejection as "the target went away", not as a
594
+ * load failure.
595
+ *
596
+ * It also REJECTS (`RendererDeviceLostError`) while the GPU device is lost.
597
+ * The device is what this method promises, and a lost one cannot serve the
598
+ * call the caller is waiting to make — `getGPUDevice()` returns null, so
599
+ * `beginPointCloudStream` throws "Renderer not initialized" the moment the
600
+ * wait resolves. The third outcome is the one `destroy()` already ruled out:
601
+ * parking a waiter that only a host-initiated `init()` could ever settle,
602
+ * and that the viewer's usual response to a loss (drop this renderer, build
603
+ * a new one) guarantees will never come. Unlike the destroyed case this is
604
+ * NOT final — a later `init()` on the same instance re-arms the wait
605
+ * synchronously, so "retry after re-init" is a contract callers can act on,
606
+ * which is why the two rejections carry different names.
320
607
  */
321
608
  whenReady() {
609
+ // Checked before `ready`, which an init that completed after the loss
610
+ // latched may well have published (`init()` subscribes to the device's
611
+ // loss signal before awaiting it, so a loss DURING init leaves both
612
+ // flags set). Readiness is about the device, and the device is gone.
613
+ if (this.deviceLost && this.deviceLostGeneration === this.initGeneration) {
614
+ return Promise.reject(rendererDeviceLostError());
615
+ }
322
616
  if (this.ready)
323
617
  return Promise.resolve();
324
- return new Promise((resolve) => { this.readyWaiters.push(resolve); });
325
- }
326
- markReady() {
618
+ if (this.destroyed)
619
+ return Promise.reject(rendererDestroyedError());
620
+ return new Promise((resolve, reject) => { this.readyWaiters.push({ resolve, reject }); });
621
+ }
622
+ markReady(generation) {
623
+ // A newer init() is already queued: it will tear all of this down before
624
+ // building its own, so publishing readiness here would hand callers a
625
+ // device with a demolition order on it.
626
+ if (generation !== this.initGeneration)
627
+ return;
327
628
  this.ready = true;
328
629
  const waiters = this.readyWaiters;
329
630
  this.readyWaiters = [];
330
631
  for (const w of waiters)
331
- w();
632
+ w.resolve();
633
+ }
634
+ /**
635
+ * Fail every parked `whenReady()` waiter with `error`. Called by `destroy()`
636
+ * and by `handleDeviceLost()` — the two events after which nothing this
637
+ * instance does on its own can make the wait true. NOT by `teardown()`,
638
+ * whose waiters belong to the re-init running it and must survive to be
639
+ * flushed by it.
640
+ */
641
+ rejectReadyWaiters(error) {
642
+ const waiters = this.readyWaiters;
643
+ this.readyWaiters = [];
644
+ for (const w of waiters)
645
+ w.reject(error);
332
646
  }
333
647
  /**
334
648
  * Subscribe to non-intentional GPU device loss (driver reset / VRAM
335
649
  * exhaustion — NOT an intentional `destroy()`). Fired at most once per
336
- * device. After it fires, `render()` is a no-op until the renderer is
337
- * re-initialised, so the typical response is to dispose this renderer and
338
- * reload the model. Returns an unsubscribe function.
650
+ * device. After it fires, `render()` is a no-op and the renderer reports
651
+ * itself un-ready (`isReady()` false, `whenReady()` rejecting with
652
+ * `RendererDeviceLostError`) until it is re-initialised, so the typical
653
+ * response is to dispose this renderer and reload the model. Returns an
654
+ * unsubscribe function.
339
655
  *
340
656
  * Camera and model state live on the CPU (JS) and survive device loss, so a
341
657
  * reload restores the model at its current orientation — the loss is a GPU
@@ -343,8 +659,44 @@ export class Renderer {
343
659
  */
344
660
  onDeviceLost(listener) {
345
661
  this.deviceLostListeners.add(listener);
662
+ // Replay a loss that already happened. `init()` subscribes to the
663
+ // device's own loss signal BEFORE awaiting `device.init()`, so a loss
664
+ // during initialisation latches while `deviceLostListeners` is still
665
+ // empty — and the viewer's subscriber cannot register any earlier,
666
+ // because it needs init() to have resolved. Without this replay that
667
+ // loss reaches nobody: the renderer correctly goes quiet and the user
668
+ // sees a viewer that simply stopped, with no toast and no capture.
669
+ if (this.deviceLost && this.deviceLostInfo !== null) {
670
+ try {
671
+ listener(this.deviceLostInfo);
672
+ }
673
+ catch (e) {
674
+ console.error('[Renderer] onDeviceLost listener threw:', e);
675
+ }
676
+ }
346
677
  return () => this.deviceLostListeners.delete(listener);
347
678
  }
679
+ /**
680
+ * Subscribe to the renderer having degraded frame after frame without
681
+ * recovering (issue #2417). Distinct from `onDeviceLost`: the device is
682
+ * still alive by every signal available, which is exactly why `render()`
683
+ * refuses to latch on these throws — but the user is looking at a viewport
684
+ * that has stopped updating, and until this callback existed nothing said
685
+ * so. Fired at most once per renderer, once `PERSISTENT_DEGRADATION_FRAMES`
686
+ * frames have degraded CONSECUTIVELY — any frame that completes resets the
687
+ * run, so a session that failed occasionally and recovered every time never
688
+ * reports. Returns an unsubscribe function.
689
+ *
690
+ * No replay for a late subscriber, unlike `onDeviceLost` — a loss can latch
691
+ * during `init()`, before any subscriber can exist, but a degraded frame
692
+ * cannot: `renderFrame()` returns early while `pipeline` is null, so the
693
+ * count only moves once the host is driving frames, which is strictly after
694
+ * `init()` resolved and the host subscribed.
695
+ */
696
+ onPersistentRenderDegradation(listener) {
697
+ this.persistentDegradationListeners.add(listener);
698
+ return () => this.persistentDegradationListeners.delete(listener);
699
+ }
348
700
  /** True once the GPU device has been lost for a non-intentional reason. */
349
701
  isDeviceLost() {
350
702
  return this.deviceLost;
@@ -353,7 +705,17 @@ export class Renderer {
353
705
  if (this.deviceLost)
354
706
  return;
355
707
  this.deviceLost = true;
708
+ this.deviceLostGeneration = this.initGeneration;
709
+ this.deviceLostInfo = info;
356
710
  console.warn('[Renderer] GPU device lost — halting rendering until re-init:', info.message);
711
+ // Readiness describes the GPU objects, and every one of them just died:
712
+ // `isReady()` reports it from here on, and anyone parked in
713
+ // `whenReady()` is failed rather than left to be resolved by the init
714
+ // this loss may have landed in the middle of. Done BEFORE the listeners
715
+ // run, so a listener that recovers by calling `init()` synchronously
716
+ // finds the waiters already settled and re-arms the wait for the next
717
+ // caller rather than racing the flush.
718
+ this.rejectReadyWaiters(rendererDeviceLostError());
357
719
  for (const listener of this.deviceLostListeners) {
358
720
  try {
359
721
  listener(info);
@@ -363,6 +725,108 @@ export class Renderer {
363
725
  }
364
726
  }
365
727
  }
728
+ /**
729
+ * Contain a throw that escaped part of a frame, and decide what it meant.
730
+ *
731
+ * ONE body for both of `render()`'s catches (issue #2417). They used to
732
+ * differ in the only way that matters: the outer one discriminated on
733
+ * `isDeviceLossThrow`, the encode-region one did not, so a device that died
734
+ * after `getCurrentTexture()` succeeded degraded quietly forever — no latch,
735
+ * no toast, no `onDeviceLost`. Sharing the body is what stops the two
736
+ * halves of one policy drifting apart again.
737
+ *
738
+ * Callers keep only what is genuinely theirs: the outer catch counts the
739
+ * frame as a skip, the encode catch balances the validation error scope
740
+ * first. `origin` distinguishes them in logs and in the degradation report.
741
+ */
742
+ containFrameThrow(error, origin) {
743
+ // Recorded for BOTH branches, before either is chosen: the caller in
744
+ // the encode region is about to return normally either way, and
745
+ // `render()` must not mistake that for a frame that succeeded.
746
+ this.frameContainedThrow = true;
747
+ this._renderErrorCount++;
748
+ const message = error instanceof Error ? error.message : String(error);
749
+ this._lastRenderError = message;
750
+ if (isDeviceLossThrow(error)) {
751
+ // Reached at most once per device: the `deviceLost` early return in
752
+ // render() short-circuits every later frame. Logged with the
753
+ // original error to keep the stack.
754
+ console.error(`[Renderer] Frame threw a DOMException (${origin}) — treating as device loss:`, error);
755
+ this.handleDeviceLost({
756
+ message,
757
+ reason: origin === 'encode' ? 'render-encode-exception' : 'render-exception',
758
+ });
759
+ return;
760
+ }
761
+ // Not a device signal — cost this FRAME, never the session. Both
762
+ // regions really do have such a source on a HEALTHY device: the outer
763
+ // one runs `scene.restoreAllEvicted()` for capture frames, the encode
764
+ // one builds visibility sub-batches through
765
+ // `scene.getOrCreatePartialBatch()`, and both allocate via
766
+ // `createBuffer({ mappedAtCreation: true })`, which throws a plain
767
+ // `RangeError` under host memory pressure — the failure
768
+ // `gpu-upload-guard` documents verbatim. Latching there would kill the
769
+ // viewport for a failure whose blast radius should be one frame, and
770
+ // would raise a false "graphics device was lost" toast plus false
771
+ // `device_lost` telemetry on top.
772
+ //
773
+ // Invalidate the swap-chain configuration so the next frame
774
+ // reconfigures.
775
+ this.device.invalidateContext();
776
+ // ...and ask for that next frame. The host loop CONSUMES the dirty flag
777
+ // before calling render(), so a frame that fails has already spent its
778
+ // request: on an idle viewer (no animation, no streaming, no
779
+ // interaction) nothing would re-dirty it and the failed frame would be
780
+ // the last one drawn until the user happened to touch something.
781
+ // "Degrade and continue" has to mean the next frame actually comes, or
782
+ // it is only "degrade and hope".
783
+ //
784
+ // Bounded, and reset by any successful frame, so a persistently failing
785
+ // path cannot self-perpetuate one throwing frame per rAF forever. NOTE
786
+ // this is a RETRY budget, not a latch threshold: exhausting it stops us
787
+ // re-requesting, leaving the app's own dirty signals (interaction,
788
+ // streaming, animation) to drive — it never disables the renderer.
789
+ // Worst case is a stale viewport that any interaction revives, not a
790
+ // dead session.
791
+ if (++this.consecutiveDegradedFrames <= this.MAX_DEGRADED_SELF_RETRIES) {
792
+ this.requestRender();
793
+ }
794
+ // Per-frame degradation is the right call and an aggregate blind spot:
795
+ // report the session once it is clear the failure is not clearing.
796
+ this.notePersistentDegradation(message, origin);
797
+ const now = performance.now();
798
+ if (now - this.lastRenderErrorTime > this.RENDER_ERROR_THROTTLE_MS) {
799
+ this.lastRenderErrorTime = now;
800
+ console.warn(`[Renderer] Frame threw in ${origin} (device assumed alive; context will be reconfigured):`, error);
801
+ }
802
+ }
803
+ /**
804
+ * Fan out the once-per-session "this viewport is not recovering" report.
805
+ * The renderer files no telemetry itself (it is host-agnostic and must stay
806
+ * PostHog-free); the host subscribes and routes it through whatever it
807
+ * already uses for device loss.
808
+ */
809
+ notePersistentDegradation(detail, origin) {
810
+ // `consecutiveDegradedFrames`, NOT `_renderErrorCount`. The latter is a
811
+ // renderer-LIFETIME total that no successful frame ever resets, so it
812
+ // would turn the threshold into "the 16th failure ever" — reached by a
813
+ // long healthy session that hit four isolated spikes an hour apart and
814
+ // recovered from every one of them. The signal is meant to mean "this
815
+ // viewport has stopped", and only an unbroken run means that. The
816
+ // reset lives in `render()`, on the path where a frame completes.
817
+ const info = this.renderDegradation.note(this.consecutiveDegradedFrames, detail, origin);
818
+ if (!info)
819
+ return;
820
+ console.warn(`[Renderer] ${info.consecutiveDegradedFrames} consecutive frames degraded without one completing — the viewport is not updating.`);
821
+ for (const listener of this.persistentDegradationListeners) {
822
+ try {
823
+ listener(info);
824
+ }
825
+ catch (e) {
826
+ console.error('[Renderer] onPersistentRenderDegradation listener threw:', e);
827
+ }
828
+ }
829
+ }
366
830
  /**
367
831
  * Replace all loaded point clouds with `assets`.
368
832
  *
@@ -376,10 +840,10 @@ export class Renderer {
376
840
  }
377
841
  this.pointCloudRenderer.setAssets(assets);
378
842
  // Replace, not append — bounds may have shrunk (e.g. an IFCx
379
- // reload with a smaller scan). `expandModelBoundsForPointClouds`
843
+ // reload with a smaller scan). `expandForPointClouds`
380
844
  // alone only grows; recompute from scratch to keep
381
845
  // fit-to-view + section-plane sliders accurate.
382
- this.recomputeModelBounds();
846
+ this.modelBoundsTracker.recompute();
383
847
  this.camera.setSceneBounds(this.modelBounds);
384
848
  this.requestRender();
385
849
  }
@@ -391,7 +855,7 @@ export class Renderer {
391
855
  for (const asset of assets) {
392
856
  this.pointCloudRenderer.addAsset(asset);
393
857
  }
394
- this.expandModelBoundsForPointClouds();
858
+ this.modelBoundsTracker.expandForPointClouds();
395
859
  this.camera.setSceneBounds(this.modelBounds);
396
860
  this.requestRender();
397
861
  }
@@ -406,7 +870,7 @@ export class Renderer {
406
870
  /** Drop all point cloud GPU resources. */
407
871
  clearPointClouds() {
408
872
  this.pointCloudRenderer?.clear();
409
- this.recomputeModelBounds();
873
+ this.modelBoundsTracker.recompute();
410
874
  this.camera.setSceneBounds(this.modelBounds);
411
875
  this.requestRender();
412
876
  }
@@ -425,7 +889,7 @@ export class Renderer {
425
889
  if (!this.pointCloudRenderer)
426
890
  return;
427
891
  this.pointCloudRenderer.appendChunk(handle, chunk);
428
- this.expandModelBoundsForPointClouds();
892
+ this.modelBoundsTracker.expandForPointClouds();
429
893
  this.camera.setSceneBounds(this.modelBounds);
430
894
  this.requestRender();
431
895
  }
@@ -437,7 +901,7 @@ export class Renderer {
437
901
  this.pointCloudRenderer?.removeAsset(handle);
438
902
  // Bounds may have shrunk — recompute from scratch so fit-to-view
439
903
  // and section-plane sliders see fresh extents.
440
- this.recomputeModelBounds();
904
+ this.modelBoundsTracker.recompute();
441
905
  this.camera.setSceneBounds(this.modelBounds);
442
906
  this.requestRender();
443
907
  }
@@ -452,34 +916,6 @@ export class Renderer {
452
916
  this.pointCloudRenderer?.relabelAsset(handle, newExpressId);
453
917
  this.requestRender();
454
918
  }
455
- /**
456
- * Compute model bounds from triangle meshes + remaining point clouds.
457
- * Called from removeAsset / clear paths so bounds shrink correctly.
458
- * Triangle meshes still drive the bounds when present (existing
459
- * Scene-driven path), so this only re-folds in the point cloud
460
- * extents over whatever the mesh path left.
461
- */
462
- recomputeModelBounds() {
463
- // Always recompute from scratch: take mesh bounds as the
464
- // baseline, then fold in the CURRENT point-cloud bounds on
465
- // top. Folding only-up via expandModelBoundsForPointClouds()
466
- // is correct when pc bounds grow but never shrinks them when
467
- // an asset is removed, leaving stale oversized extents until
468
- // every point cloud is gone.
469
- const meshBounds = this.computeMeshBounds();
470
- const pcBounds = this.pointCloudRenderer?.getBounds() ?? null;
471
- if (!meshBounds && !pcBounds) {
472
- this.modelBounds = null;
473
- return;
474
- }
475
- this.modelBounds = meshBounds ?? {
476
- min: { x: pcBounds.min[0], y: pcBounds.min[1], z: pcBounds.min[2] },
477
- max: { x: pcBounds.max[0], y: pcBounds.max[1], z: pcBounds.max[2] },
478
- };
479
- if (meshBounds && pcBounds) {
480
- this.expandModelBoundsForPointClouds();
481
- }
482
- }
483
919
  /** Aggregate bounds across all batched + individual meshes. Returns
484
920
  * null if the scene has no mesh geometry. */
485
921
  computeMeshBounds() {
@@ -525,7 +961,7 @@ export class Renderer {
525
961
  // them to the camera (matching every other bounds-mutating
526
962
  // point-cloud method) so framing / zoom-to-fit targets where the
527
963
  // points actually render.
528
- this.recomputeModelBounds();
964
+ this.modelBoundsTracker.recompute();
529
965
  this.camera.setSceneBounds(this.modelBounds);
530
966
  this.requestRender();
531
967
  }
@@ -657,25 +1093,6 @@ export class Renderer {
657
1093
  this.edlOptions.highQuality = opts.highQuality;
658
1094
  this.requestRender();
659
1095
  }
660
- expandModelBoundsForPointClouds() {
661
- const pcBounds = this.pointCloudRenderer?.getBounds();
662
- if (!pcBounds)
663
- return;
664
- if (!this.modelBounds) {
665
- this.modelBounds = {
666
- min: { x: pcBounds.min[0], y: pcBounds.min[1], z: pcBounds.min[2] },
667
- max: { x: pcBounds.max[0], y: pcBounds.max[1], z: pcBounds.max[2] },
668
- };
669
- return;
670
- }
671
- const m = this.modelBounds;
672
- m.min.x = Math.min(m.min.x, pcBounds.min[0]);
673
- m.min.y = Math.min(m.min.y, pcBounds.min[1]);
674
- m.min.z = Math.min(m.min.z, pcBounds.min[2]);
675
- m.max.x = Math.max(m.max.x, pcBounds.max[0]);
676
- m.max.y = Math.max(m.max.y, pcBounds.max[1]);
677
- m.max.z = Math.max(m.max.z, pcBounds.max[2]);
678
- }
679
1096
  /**
680
1097
  * Load geometry from GeometryResult or MeshData array
681
1098
  * This is the main entry point for loading IFC geometry into the renderer
@@ -695,7 +1112,7 @@ export class Renderer {
695
1112
  const device = this.device.getDevice();
696
1113
  this.scene.appendToBatches(meshes, device, this.pipeline, false);
697
1114
  // Calculate and store model bounds for fitToView
698
- this.updateModelBounds(meshes);
1115
+ this.modelBoundsTracker.updateFromMeshes(meshes);
699
1116
  console.log(`[Renderer] Loaded ${meshes.length} meshes`);
700
1117
  // Update camera scene bounds for tight orthographic near/far planes
701
1118
  this.camera.setSceneBounds(this.modelBounds);
@@ -715,7 +1132,7 @@ export class Renderer {
715
1132
  const device = this.device.getDevice();
716
1133
  this.scene.appendToBatches(meshes, device, this.pipeline, isStreaming);
717
1134
  // Update model bounds incrementally
718
- this.updateModelBounds(meshes);
1135
+ this.modelBoundsTracker.updateFromMeshes(meshes);
719
1136
  // Update camera scene bounds for tight orthographic near/far planes
720
1137
  this.camera.setSceneBounds(this.modelBounds);
721
1138
  }
@@ -800,39 +1217,7 @@ export class Renderer {
800
1217
  * Set model bounds (used when computing bounds from batches)
801
1218
  */
802
1219
  setModelBounds(bounds) {
803
- this.modelBounds = bounds;
804
- }
805
- /**
806
- * Update model bounds from mesh data
807
- */
808
- updateModelBounds(meshes) {
809
- if (!this.modelBounds) {
810
- this.modelBounds = {
811
- min: { x: Infinity, y: Infinity, z: Infinity },
812
- max: { x: -Infinity, y: -Infinity, z: -Infinity }
813
- };
814
- }
815
- for (const mesh of meshes) {
816
- const positions = mesh.positions;
817
- // Positions are in the element's local frame (world = origin + position).
818
- // Model bounds are world-space, so fold the per-mesh origin. No-op when
819
- // origin is absent/[0,0,0]. Mirrors coordinate-handler.ts.
820
- const o = mesh.origin;
821
- const ox = o ? o[0] : 0, oy = o ? o[1] : 0, oz = o ? o[2] : 0;
822
- for (let i = 0; i < positions.length; i += 3) {
823
- const x = positions[i] + ox;
824
- const y = positions[i + 1] + oy;
825
- const z = positions[i + 2] + oz;
826
- if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z)) {
827
- this.modelBounds.min.x = Math.min(this.modelBounds.min.x, x);
828
- this.modelBounds.min.y = Math.min(this.modelBounds.min.y, y);
829
- this.modelBounds.min.z = Math.min(this.modelBounds.min.z, z);
830
- this.modelBounds.max.x = Math.max(this.modelBounds.max.x, x);
831
- this.modelBounds.max.y = Math.max(this.modelBounds.max.y, y);
832
- this.modelBounds.max.z = Math.max(this.modelBounds.max.z, z);
833
- }
834
- }
835
- }
1220
+ this.modelBoundsTracker.set(bounds);
836
1221
  }
837
1222
  /**
838
1223
  * Create a GPU Mesh from MeshData (lazy creation for selection highlighting)
@@ -985,31 +1370,6 @@ export class Renderer {
985
1370
  }
986
1371
  });
987
1372
  }
988
- resolveVisualEnhancement(options) {
989
- if (!options) {
990
- return this.visualEnhancementState;
991
- }
992
- const merged = {
993
- enabled: options.enabled ?? this.visualEnhancementState.enabled,
994
- edgeContrast: {
995
- enabled: options.edgeContrast?.enabled ?? this.visualEnhancementState.edgeContrast.enabled,
996
- intensity: options.edgeContrast?.intensity ?? this.visualEnhancementState.edgeContrast.intensity,
997
- },
998
- contactShading: {
999
- quality: options.contactShading?.quality ?? this.visualEnhancementState.contactShading.quality,
1000
- intensity: options.contactShading?.intensity ?? this.visualEnhancementState.contactShading.intensity,
1001
- radius: options.contactShading?.radius ?? this.visualEnhancementState.contactShading.radius,
1002
- },
1003
- separationLines: {
1004
- enabled: options.separationLines?.enabled ?? this.visualEnhancementState.separationLines.enabled,
1005
- quality: options.separationLines?.quality ?? this.visualEnhancementState.separationLines.quality,
1006
- intensity: options.separationLines?.intensity ?? this.visualEnhancementState.separationLines.intensity,
1007
- radius: options.separationLines?.radius ?? this.visualEnhancementState.separationLines.radius,
1008
- },
1009
- };
1010
- this.visualEnhancementState = merged;
1011
- return merged;
1012
- }
1013
1373
  /**
1014
1374
  * Render frame
1015
1375
  */
@@ -1056,6 +1416,34 @@ export class Renderer {
1056
1416
  this.scene.setQuantizedBatches(true);
1057
1417
  return ok;
1058
1418
  }
1419
+ /**
1420
+ * Draw one frame.
1421
+ *
1422
+ * Never throws, so callers never need to guard this call to keep their
1423
+ * animation loop alive.
1424
+ *
1425
+ * What a throw MEANS depends on its type (`isDeviceLossThrow`), and since
1426
+ * issue #2417 that holds for the WHOLE frame — both this catch and the
1427
+ * encode region's own, which share `containFrameThrow`:
1428
+ * - a `DOMException` is the device reporting its own death synchronously
1429
+ * (Safari 26.5, issue #2229). It latches the same `deviceLost` state the
1430
+ * async `device.lost` promise would: later frames become quiet skips and
1431
+ * `onDeviceLost` listeners fire exactly once.
1432
+ * - anything else (a `RangeError` from a buffer the host cannot allocate,
1433
+ * say) costs only this frame: the swap-chain config is invalidated so
1434
+ * the next frame reconfigures, a frame is re-requested within a bounded
1435
+ * budget, the failure is counted in `getDiagnostics()`, and rendering
1436
+ * carries on. Once enough such frames have degraded without recovering,
1437
+ * `onPersistentRenderDegradation` fires once.
1438
+ *
1439
+ * SCOPE: `renderFrame()` has two try/catch regions — this outer one (canvas
1440
+ * resize, context setup, evicted-batch restore) and an inner one opened
1441
+ * after the swap-chain texture is acquired, covering encoder work through
1442
+ * `submit`. Until #2417 only the outer one discriminated, so a device that
1443
+ * died after `getCurrentTexture()` succeeded degraded quietly forever with
1444
+ * no latch and no toast. Both now run the same policy; the encode catch
1445
+ * additionally balances the frame's validation error scope before doing so.
1446
+ */
1059
1447
  render(options = {}) {
1060
1448
  this._renderCallCount++;
1061
1449
  // A lost device leaves every pipeline/buffer dead; rendering would only
@@ -1064,6 +1452,37 @@ export class Renderer {
1064
1452
  this._renderSkipCount++;
1065
1453
  return;
1066
1454
  }
1455
+ try {
1456
+ this.frameContainedThrow = false;
1457
+ this.renderFrame(options);
1458
+ // Only a frame that actually got through resets the run. A frame
1459
+ // the ENCODE catch contained returns here normally (that catch is
1460
+ // inside renderFrame), so "did not throw" is not the same question
1461
+ // as "did not fail" — see `frameContainedThrow`.
1462
+ if (!this.frameContainedThrow)
1463
+ this.consecutiveDegradedFrames = 0;
1464
+ }
1465
+ catch (error) {
1466
+ // Safari (26.5) reports device loss SYNCHRONOUSLY: a call against a
1467
+ // dead device throws `InvalidStateError` instead of — or long
1468
+ // before — resolving `device.lost` (issue #2229). Without this
1469
+ // catch the throw escapes render(), the caller's rAF loop never
1470
+ // re-arms, and the viewer freezes for good with nothing on screen
1471
+ // and nothing subscribed to onDeviceLost ever told.
1472
+ //
1473
+ // Deliberately NOT rethrown either way: the frame is already lost,
1474
+ // and the established contract is "degrade" (see `pickPathAlive()`
1475
+ // and the rAF loop's own upload/residency guards), not "take the
1476
+ // host down with us".
1477
+ this._renderSkipCount++;
1478
+ this.containFrameThrow(error, 'frame');
1479
+ }
1480
+ }
1481
+ /**
1482
+ * The frame body. Throws on a synchronously-dead GPU device; `render()`
1483
+ * owns the containment. Private for that reason — call `render()`.
1484
+ */
1485
+ renderFrame(options) {
1067
1486
  if (!this.device.isInitialized() || !this.pipeline) {
1068
1487
  this._renderSkipCount++;
1069
1488
  return;
@@ -1126,7 +1545,7 @@ export class Renderer {
1126
1545
  if (options.restoreEvictedForCapture && this.pipeline) {
1127
1546
  this.scene.restoreAllEvicted(device, this.pipeline);
1128
1547
  }
1129
- const visualEnhancement = this.resolveVisualEnhancement(options.visualEnhancement);
1548
+ const visualEnhancement = this.visualEnhancementResolver.resolve(options.visualEnhancement);
1130
1549
  // Post effects during interaction (orbit/pan/zoom) are governed
1131
1550
  // adaptively: they stay on while the interactive frame cadence holds
1132
1551
  // (the pass costs well under a ms on discrete/Apple GPUs at CSS
@@ -1370,201 +1789,27 @@ export class Renderer {
1370
1789
  // Write uniform data to each mesh's buffer BEFORE recording commands
1371
1790
  // This ensures each mesh has its own color data
1372
1791
  const allMeshes = [...opaqueMeshes, ...transparentMeshes];
1373
- // Calculate section plane parameters and model bounds
1374
- // Always calculate bounds when sectionPlane is provided (for preview and active mode)
1375
- let sectionPlaneData;
1376
- // Terrain clip: when Cesium overlay is active, clip model below terrain.
1377
- // Normal (0,-1,0) + distance (-clipY) clips where worldPos.y < clipY.
1378
- if (options.terrainClipY !== undefined && !options.sectionPlane?.enabled) {
1379
- sectionPlaneData = {
1380
- normal: [0, -1, 0],
1381
- distance: -options.terrainClipY,
1382
- enabled: true,
1383
- };
1384
- }
1385
- if (options.sectionPlane) {
1386
- // Get model bounds from batched meshes. We deliberately EXCLUDE
1387
- // individual meshes (`this.scene.getMeshes()`) here: those are
1388
- // created lazily for selection highlighting and can live at
1389
- // unexpected world positions (e.g. legacy transforms, overlay
1390
- // helpers), which would inflate the bounds range and make
1391
- // "1% of the slider" span the entire real model — producing
1392
- // the reported symptom where the model pops from fully visible
1393
- // to fully invisible across a tiny slider range.
1394
- const boundsMin = { x: Infinity, y: Infinity, z: Infinity };
1395
- const boundsMax = { x: -Infinity, y: -Infinity, z: -Infinity };
1396
- const batchedMeshes = this.scene.getBatchedMeshes();
1397
- for (const batch of batchedMeshes) {
1398
- if (batch.bounds) {
1399
- boundsMin.x = Math.min(boundsMin.x, batch.bounds.min[0]);
1400
- boundsMin.y = Math.min(boundsMin.y, batch.bounds.min[1]);
1401
- boundsMin.z = Math.min(boundsMin.z, batch.bounds.min[2]);
1402
- boundsMax.x = Math.max(boundsMax.x, batch.bounds.max[0]);
1403
- boundsMax.y = Math.max(boundsMax.y, batch.bounds.max[1]);
1404
- boundsMax.z = Math.max(boundsMax.z, batch.bounds.max[2]);
1405
- }
1406
- }
1407
- // Fold in point-cloud bounds too — without this, a
1408
- // pure point-cloud scene falls through to the default
1409
- // [-100,100], and a mixed scene clips against a
1410
- // smaller mesh-only range while the point pipeline
1411
- // (which honours the same sectionPlaneData) keeps
1412
- // drawing points outside the slider's reach.
1413
- const pcBoundsForSection = this.pointCloudRenderer?.getBounds();
1414
- if (pcBoundsForSection) {
1415
- boundsMin.x = Math.min(boundsMin.x, pcBoundsForSection.min[0]);
1416
- boundsMin.y = Math.min(boundsMin.y, pcBoundsForSection.min[1]);
1417
- boundsMin.z = Math.min(boundsMin.z, pcBoundsForSection.min[2]);
1418
- boundsMax.x = Math.max(boundsMax.x, pcBoundsForSection.max[0]);
1419
- boundsMax.y = Math.max(boundsMax.y, pcBoundsForSection.max[1]);
1420
- boundsMax.z = Math.max(boundsMax.z, pcBoundsForSection.max[2]);
1421
- }
1422
- // If no batched meshes have bounds yet (streaming, degenerate
1423
- // models), fall back to individual meshes so at least the
1424
- // slider has a workable range.
1425
- if (!Number.isFinite(boundsMin.x)) {
1426
- for (const mesh of meshes) {
1427
- if (mesh.bounds) {
1428
- boundsMin.x = Math.min(boundsMin.x, mesh.bounds.min[0]);
1429
- boundsMin.y = Math.min(boundsMin.y, mesh.bounds.min[1]);
1430
- boundsMin.z = Math.min(boundsMin.z, mesh.bounds.min[2]);
1431
- boundsMax.x = Math.max(boundsMax.x, mesh.bounds.max[0]);
1432
- boundsMax.y = Math.max(boundsMax.y, mesh.bounds.max[1]);
1433
- boundsMax.z = Math.max(boundsMax.z, mesh.bounds.max[2]);
1434
- }
1435
- }
1436
- }
1437
- // Fallback if no bounds found
1438
- if (!Number.isFinite(boundsMin.x)) {
1439
- boundsMin.x = boundsMin.y = boundsMin.z = -100;
1440
- boundsMax.x = boundsMax.y = boundsMax.z = 100;
1441
- }
1442
- // Store bounds for section plane visual and camera near/far
1792
+ // This frame's clip plane and the bounds the section slider is
1793
+ // expressed in resolved in render-section-plane.ts, which owns
1794
+ // the bounds aggregation, the terrain-clip and explicit-plane
1795
+ // branches, and the one-shot diagnostic log (issue #2425).
1796
+ const sectionFrame = resolveSectionPlaneFrame({
1797
+ options,
1798
+ batchedMeshes: this.scene.getBatchedMeshes(),
1799
+ meshes,
1800
+ pointCloudBounds: this.pointCloudRenderer?.getBounds() ?? null,
1801
+ logSectionBounds: !this._loggedSectionBounds,
1802
+ spendLogLatch: () => { this._loggedSectionBounds = true; },
1803
+ });
1804
+ const sectionPlaneData = sectionFrame.sectionPlaneData;
1805
+ if (sectionFrame.bounds) {
1806
+ // Store bounds for section plane visual and camera near/far.
1807
+ // Two wrappers over the same min/max, exactly as before the
1808
+ // extraction the renderer's copy is replaced wholesale by the
1809
+ // bounds helpers, the camera's is not.
1810
+ const { min: boundsMin, max: boundsMax } = sectionFrame.bounds;
1443
1811
  this.setModelBounds({ min: boundsMin, max: boundsMax });
1444
1812
  this.camera.setSceneBounds({ min: boundsMin, max: boundsMax });
1445
- // Only calculate clipping data if section is enabled
1446
- // Terrain clip: when no section plane is active, use terrainClipY
1447
- // to clip fragments below terrain height. Normal (0,-1,0) with
1448
- // distance = -clipY clips worldPos.y < clipY.
1449
- if (!options.sectionPlane?.enabled && options.terrainClipY !== undefined) {
1450
- sectionPlaneData = {
1451
- normal: [0, -1, 0],
1452
- distance: -options.terrainClipY,
1453
- enabled: true,
1454
- };
1455
- }
1456
- if (options.sectionPlane.enabled) {
1457
- // Explicit normal + distance override (face-pick / arbitrary
1458
- // plane, issue #243). Used verbatim: no axis mapping, no
1459
- // position slider, no building rotation — the caller already
1460
- // has the plane in world space.
1461
- const explicitNormal = options.sectionPlane.normal;
1462
- const explicitDistance = options.sectionPlane.distance;
1463
- const hasExplicitPlane = explicitNormal !== undefined &&
1464
- explicitDistance !== undefined &&
1465
- Number.isFinite(explicitDistance);
1466
- let normal;
1467
- let distance;
1468
- if (hasExplicitPlane) {
1469
- // Defensive renormalisation in case the caller passed a
1470
- // non-unit vector (e.g. mesh face normals quantised by
1471
- // the geometry pipeline).
1472
- const nx = explicitNormal[0];
1473
- const ny = explicitNormal[1];
1474
- const nz = explicitNormal[2];
1475
- const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
1476
- if (len > 1e-6) {
1477
- normal = [nx / len, ny / len, nz / len];
1478
- distance = explicitDistance / len;
1479
- }
1480
- else {
1481
- normal = [0, 1, 0];
1482
- distance = explicitDistance;
1483
- }
1484
- }
1485
- else {
1486
- // Cardinal-axis preset path (unchanged behaviour).
1487
- // down = Y axis (horizontal cut), front = Z axis, side = X axis
1488
- normal = [0, 0, 0];
1489
- if (options.sectionPlane.axis === 'side')
1490
- normal[0] = 1; // X axis
1491
- else if (options.sectionPlane.axis === 'down')
1492
- normal[1] = 1; // Y axis (horizontal)
1493
- else
1494
- normal[2] = 1; // Z axis (front)
1495
- // Apply building rotation if present (rotate normal around Y axis)
1496
- // Building rotation is in X-Y plane (Z is up in IFC, Y is up in WebGL)
1497
- if (options.buildingRotation !== undefined && options.buildingRotation !== 0) {
1498
- const cosR = Math.cos(options.buildingRotation);
1499
- const sinR = Math.sin(options.buildingRotation);
1500
- // Rotate normal vector around Y axis (vertical)
1501
- // For X-Z plane rotation: x' = x*cos - z*sin, z' = x*sin + z*cos, y' = y
1502
- const x = normal[0];
1503
- const z = normal[2];
1504
- normal[0] = x * cosR - z * sinR;
1505
- normal[2] = x * sinR + z * cosR;
1506
- // Normalize to maintain unit length
1507
- const rlen = Math.sqrt(normal[0] * normal[0] + normal[1] * normal[1] + normal[2] * normal[2]);
1508
- if (rlen > 0.0001) {
1509
- normal[0] /= rlen;
1510
- normal[1] /= rlen;
1511
- normal[2] /= rlen;
1512
- }
1513
- }
1514
- // Get axis-specific range. The renderer's own `boundsMin/Max`
1515
- // are computed from the GPU vertex buffers this frame, so
1516
- // they are guaranteed to be in the same Y-up world space as
1517
- // `input.worldPos` in the shader. `options.sectionPlane.min/max`
1518
- // comes from the UI via `coordinateInfo.shiftedBounds` and can
1519
- // be stale during streaming or outright wrong during model
1520
- // load (initialised to {0,0,0} before the first bounds update)
1521
- // — using those directly was the cause of the "slider moves
1522
- // 1% and the whole model disappears" bug.
1523
- //
1524
- // Policy: always use the renderer's own bounds for the Y-up
1525
- // range. Only honour the UI override when it is a valid,
1526
- // non-degenerate range that lies INSIDE the actual mesh
1527
- // bounds (e.g. storey filtering from the level picker).
1528
- const axisIdx = options.sectionPlane.axis === 'side' ? 'x' : options.sectionPlane.axis === 'down' ? 'y' : 'z';
1529
- let minVal = boundsMin[axisIdx];
1530
- let maxVal = boundsMax[axisIdx];
1531
- const uiMin = options.sectionPlane.min;
1532
- const uiMax = options.sectionPlane.max;
1533
- if (Number.isFinite(uiMin) &&
1534
- Number.isFinite(uiMax) &&
1535
- uiMax - uiMin > 1e-6 &&
1536
- uiMin >= minVal - 1e-3 &&
1537
- uiMax <= maxVal + 1e-3) {
1538
- minVal = uiMin;
1539
- maxVal = uiMax;
1540
- }
1541
- // Calculate plane distance from position percentage
1542
- const range = maxVal - minVal;
1543
- distance = minVal + (options.sectionPlane.position / 100) * range;
1544
- }
1545
- sectionPlaneData = { normal, distance, enabled: true };
1546
- // One-shot diagnostic: when section first becomes active,
1547
- // log the exact bounds + plane the shader will use. This
1548
- // is the fastest way to confirm "bounds mismatch" / "plane
1549
- // off-screen" bugs without asking the user to run a
1550
- // debugger. The custom-plane branch logs `mode: 'explicit'`
1551
- // so reports against tilted planes are easy to spot.
1552
- if (!this._loggedSectionBounds) {
1553
- this._loggedSectionBounds = true;
1554
- console.info('[Section] Y-up bounds used for clip:', {
1555
- mode: hasExplicitPlane ? 'explicit' : 'axis-aligned',
1556
- axis: options.sectionPlane.axis,
1557
- bounds: {
1558
- min: { x: boundsMin.x, y: boundsMin.y, z: boundsMin.z },
1559
- max: { x: boundsMax.x, y: boundsMax.y, z: boundsMax.z },
1560
- },
1561
- normal,
1562
- distance,
1563
- position: options.sectionPlane.position,
1564
- batchedMeshCount: this.scene.getBatchedMeshes().length,
1565
- });
1566
- }
1567
- }
1568
1813
  }
1569
1814
  // Stash what we actually clipped this frame so the GPU picker mirrors
1570
1815
  // it (section/crop-clipped geometry must be unpickable, not just hidden).
@@ -1693,31 +1938,22 @@ export class Renderer {
1693
1938
  sampleCount: this.pipeline.getSampleCount(),
1694
1939
  }, skyShaderSource);
1695
1940
  }
1696
- const camPos = this.camera.getPosition();
1697
- const camTgt = this.camera.getTarget();
1698
- const camUp = this.camera.getUp();
1699
- let fx = camTgt.x - camPos.x;
1700
- let fy = camTgt.y - camPos.y;
1701
- let fz = camTgt.z - camPos.z;
1702
- const flen = Math.hypot(fx, fy, fz) || 1;
1703
- fx /= flen;
1704
- fy /= flen;
1705
- fz /= flen;
1706
- // Right = normalize(cross(forward, up)); true up = cross(right, forward).
1707
- let rx = fy * camUp.z - fz * camUp.y;
1708
- let ry = fz * camUp.x - fx * camUp.z;
1709
- let rz = fx * camUp.y - fy * camUp.x;
1710
- const rlen = Math.hypot(rx, ry, rz) || 1;
1711
- rx /= rlen;
1712
- ry /= rlen;
1713
- rz /= rlen;
1714
- const ux = ry * fz - rz * fy;
1715
- const uy = rz * fx - rx * fz;
1716
- const uz = rx * fy - ry * fx;
1941
+ // The sky shader rebuilds a per-pixel view ray from this
1942
+ // basis, so it must be the basis the frame's view matrix was
1943
+ // built from — `viewBasis`, not a local re-derivation
1944
+ // (#2489). The copy that used to live here guarded its two
1945
+ // divisors with `|| 1` and neither numerator, so a non-finite
1946
+ // camera coordinate made every axis NaN and the sky drew as a
1947
+ // flat undefined colour over the whole viewport; and for a
1948
+ // plan pose (`up` parallel to the view direction) it returned
1949
+ // zero-length axes, which is the same picture. Reading the
1950
+ // shared basis also keeps the horizon in the sky aligned with
1951
+ // the horizon in the geometry for free.
1952
+ const camBasis = viewBasis(this.camera.getPosition(), this.camera.getTarget(), this.camera.getUp());
1717
1953
  this.skyPass.draw(pass, {
1718
- forward: [fx, fy, fz],
1719
- right: [rx, ry, rz],
1720
- up: [ux, uy, uz],
1954
+ forward: [camBasis.forward.x, camBasis.forward.y, camBasis.forward.z],
1955
+ right: [camBasis.right.x, camBasis.right.y, camBasis.right.z],
1956
+ up: [camBasis.up.x, camBasis.up.y, camBasis.up.z],
1721
1957
  fovY: this.camera.getFOV(),
1722
1958
  aspect: this.canvas.height > 0 ? this.canvas.width / this.canvas.height : 1,
1723
1959
  }, environment);
@@ -2473,122 +2709,17 @@ export class Renderer {
2473
2709
  viewport: { width: this.canvas.width, height: this.canvas.height },
2474
2710
  });
2475
2711
  }
2476
- // Draw section plane visual BEFORE pass.end() (within same MSAA render pass)
2477
- // Always show plane when sectionPlane options are provided (as preview or active)
2478
- const modelBounds = this.getModelBounds();
2479
- if (options.sectionPlane && this.sectionPlaneRenderer && modelBounds) {
2480
- this.sectionPlaneRenderer.draw(pass, {
2481
- axis: options.sectionPlane.axis,
2482
- position: options.sectionPlane.position,
2483
- bounds: modelBounds,
2484
- viewProj,
2485
- isPreview: !options.sectionPlane.enabled, // Preview mode when not enabled
2486
- min: options.sectionPlane.min,
2487
- max: options.sectionPlane.max,
2488
- // Custom-plane gizmo override (issue #243). When both
2489
- // are set the gizmo bypasses the cardinal path; see
2490
- // SectionPlaneRenderer.calculatePlaneVerticesFromNormal.
2491
- normal: options.sectionPlane.normal,
2492
- distance: options.sectionPlane.distance,
2493
- });
2494
- // Draw 2D section overlay on the section plane (when section is
2495
- // active, not preview). The overlay is also the 3D SECTION CAP:
2496
- // its polygon fills come from `SectionCutter` (exact triangle-
2497
- // plane intersection), and the new fill shader applies the
2498
- // user's screen-space hatch + colour directly on those
2499
- // polygons. This replaces the old stencil-parity cap, which
2500
- // bled hatch into empty sky on non-manifold IFC geometry —
2501
- // the polygons here are mathematically correct, so the cap
2502
- // silhouette matches the 2D drawing exactly.
2503
- if (options.sectionPlane.enabled && this.section2DOverlayRenderer?.hasGeometry()) {
2504
- const o = options.sectionPlane;
2505
- const showFills = o.showCap !== false;
2506
- const showOutlines = o.showOutlines !== false;
2507
- const style = { ...DEFAULT_CAP_STYLE, ...(o.capStyle ?? {}) };
2508
- this.section2DOverlayRenderer.draw(pass, {
2509
- axis: o.axis,
2510
- position: o.position,
2511
- bounds: modelBounds,
2512
- viewProj,
2513
- min: o.min,
2514
- max: o.max,
2515
- showFills,
2516
- showOutlines,
2517
- capStyle: showFills ? {
2518
- fillColor: style.fillColor,
2519
- strokeColor: style.strokeColor,
2520
- patternId: HATCH_PATTERN_IDS[style.pattern],
2521
- spacingPx: style.spacingPx,
2522
- angleRad: style.angleRad,
2523
- widthPx: style.widthPx,
2524
- secondaryAngleRad: style.secondaryAngleRad,
2525
- } : undefined,
2526
- });
2527
- }
2528
- }
2529
- // Standalone IFC annotation overlay (issue #653). The line
2530
- // vertices were pre-lifted to world space at upload time, so
2531
- // this draw happens regardless of whether a section plane is
2532
- // active — annotations are a free-floating "drawing layer"
2533
- // that sits at each annotation's storey elevation.
2534
- //
2535
- // This block was previously nested inside the `if (options.sectionPlane && ...)`
2536
- // guard above, contradicting its own comment. Loading an
2537
- // annotation-only model with no section plane meant the entire
2538
- // overlay was skipped at draw time even though 9000+ vertices
2539
- // had been uploaded successfully. Pulled out to its own block.
2540
- //
2541
- // Order: fills (background) → lines (outlines on top) →
2542
- // texts (labels above everything).
2543
- if (this.symbolicFillPipeline?.hasGeometry()) {
2544
- this.symbolicFillPipeline.render(pass, viewProj);
2545
- }
2546
- if (this.section2DOverlayRenderer?.hasAnnotationLines3D()) {
2547
- this.section2DOverlayRenderer.drawAnnotationLines3D(pass, viewProj);
2548
- }
2549
- if (this.section2DOverlayRenderer?.hasAlignmentLines3D()) {
2550
- this.section2DOverlayRenderer.drawAlignmentLines3D(pass, viewProj);
2551
- }
2552
- if (this.section2DOverlayRenderer?.hasGridLines3D()) {
2553
- this.section2DOverlayRenderer.drawGridLines3D(pass, viewProj);
2554
- }
2555
- if (this.section2DOverlayRenderer?.hasClashBoxLines3D()) {
2556
- this.section2DOverlayRenderer.drawClashBoxLines3D(pass, viewProj);
2557
- }
2558
- if (this.symbolicTextPipeline?.hasGeometry()) {
2559
- // Pass viewport pixel dimensions so the shader can scale glyphs
2560
- // to a constant on-screen size (BIMvision-style annotations)
2561
- // regardless of camera distance or authored text height.
2562
- //
2563
- // Also pass the screen-aligned camera basis (right, up) so
2564
- // billboarded glyphs (grid bubble tags) can face the camera
2565
- // in any orientation — top-down, eye-level, oblique alike.
2566
- const camPos = this.camera.getPosition();
2567
- const camTgt = this.camera.getTarget();
2568
- const camUpVec = this.camera.getUp();
2569
- // Forward = normalize(target - position).
2570
- let fx = camTgt.x - camPos.x;
2571
- let fy = camTgt.y - camPos.y;
2572
- let fz = camTgt.z - camPos.z;
2573
- let flen = Math.hypot(fx, fy, fz) || 1;
2574
- fx /= flen;
2575
- fy /= flen;
2576
- fz /= flen;
2577
- // Right = normalize(cross(forward, world-up)).
2578
- let rx = fy * camUpVec.z - fz * camUpVec.y;
2579
- let ry = fz * camUpVec.x - fx * camUpVec.z;
2580
- let rz = fx * camUpVec.y - fy * camUpVec.x;
2581
- let rlen = Math.hypot(rx, ry, rz) || 1;
2582
- rx /= rlen;
2583
- ry /= rlen;
2584
- rz /= rlen;
2585
- // True up = normalize(cross(right, forward)) — guaranteed
2586
- // perpendicular to both, defines screen-space vertical.
2587
- const ux = ry * fz - rz * fy;
2588
- const uy = rz * fx - rx * fz;
2589
- const uz = rx * fy - ry * fx;
2590
- this.symbolicTextPipeline.render(pass, viewProj, this.canvas.width, this.canvas.height, [rx, ry, rz], [ux, uy, uz]);
2591
- }
2712
+ // Section-plane gizmo, 2D section cap and every standalone 3D
2713
+ // overlay (annotation / alignment / grid / DXF / clash / symbolic
2714
+ // text). One draw call into the pass — see RendererOverlays.draw().
2715
+ this.overlays.draw(pass, {
2716
+ options,
2717
+ viewProj,
2718
+ modelBounds: this.getModelBounds(),
2719
+ camera: this.camera,
2720
+ canvasWidth: this.canvas.width,
2721
+ canvasHeight: this.canvas.height,
2722
+ });
2592
2723
  pass.end();
2593
2724
  const canRunPostPass = (contactEnabled || separationEnabled)
2594
2725
  && this.postProcessor !== null;
@@ -2660,17 +2791,30 @@ export class Renderer {
2660
2791
  errorScopePushed = false;
2661
2792
  this.drainErrorScope(device);
2662
2793
  }
2663
- this._renderErrorCount++;
2664
- this._lastRenderError = error instanceof Error ? error.message : String(error);
2665
- // Handle WebGPU errors (e.g., device lost, invalid state)
2666
- // Mark context as invalid so it gets reconfigured next frame
2667
- this.device.invalidateContext();
2668
- // Rate-limit error logging to avoid spam (max once per second)
2669
- const now = performance.now();
2670
- if (now - this.lastRenderErrorTime > this.RENDER_ERROR_THROTTLE_MS) {
2671
- this.lastRenderErrorTime = now;
2672
- console.warn('Render error (context will be reconfigured):', error);
2673
- }
2794
+ // Same policy as the outer catch since issue #2417 — a `DOMException`
2795
+ // from here is a device that died mid-frame, after
2796
+ // `getCurrentTexture()` had already succeeded, and it must latch
2797
+ // rather than degrade forever in silence.
2798
+ //
2799
+ // Safe to discriminate here because the encode region has no
2800
+ // healthy-device `DOMException` source (swept for #2417): its
2801
+ // `queue.writeBuffer` calls all use the 3-argument form over whole
2802
+ // typed-array views — plus one 5-argument call in
2803
+ // `point-cloud-uniforms.ts` whose offset and size are compile-time
2804
+ // constants matching its scratch array — so the spec's
2805
+ // `OperationError` preconditions are unreachable; the one
2806
+ // `copyExternalImageToTexture` copies the glyph atlas's own
2807
+ // never-externally-drawn canvas at its full fixed size, so neither
2808
+ // `SecurityError` nor a zero-size `OperationError` can arise; and
2809
+ // every other WebGPU call in the region (`createView`,
2810
+ // `createCommandEncoder`, `beginRenderPass`, the pass setters and
2811
+ // draws, `finish`, `submit`, `createBindGroup`) reports failure as
2812
+ // an asynchronous `GPUValidationError` through the error scope, not
2813
+ // as a throw. The region's real healthy-device failure is
2814
+ // `getOrCreatePartialBatch`'s `createBuffer({ mappedAtCreation:
2815
+ // true })`, and that throws a `RangeError` — which is exactly why
2816
+ // the discriminator keys on the TYPE and not on "a frame threw".
2817
+ this.containFrameThrow(error, 'encode');
2674
2818
  }
2675
2819
  }
2676
2820
  /**
@@ -2811,6 +2955,17 @@ export class Renderer {
2811
2955
  * Resize canvas
2812
2956
  */
2813
2957
  resize(width, height) {
2958
+ // `canvas.width` is an IDL `unsigned long`, so it silently coerces a
2959
+ // non-finite or negative argument to **0** — a zero drawing buffer
2960
+ // that every pick guard in this package misses, because they all
2961
+ // check the bounding rect rather than the buffer. `unprojectToRay`
2962
+ // then divides by it. This is documented public API of a published
2963
+ // package (`docs/api/typescript.md`), so an external caller wiring a
2964
+ // ResizeObserver to it is the reachable route; both in-repo callers
2965
+ // already floor their own values. Keep the last usable size, the same
2966
+ // policy `setAspect` uses for the ratio it derives (#2473).
2967
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0)
2968
+ return;
2814
2969
  this.canvas.width = width;
2815
2970
  this.canvas.height = height;
2816
2971
  this.camera.setAspect(width / height);
@@ -2821,6 +2976,10 @@ export class Renderer {
2821
2976
  getScene() {
2822
2977
  return this.scene;
2823
2978
  }
2979
+ // ─── Overlay facade ──────────────────────────────────────────────────
2980
+ // The section-plane gizmo, the 2D section drawing/cap and the symbolic
2981
+ // annotation overlays live in `RendererOverlays` (issue #2425). These
2982
+ // methods are the published surface; the bodies moved with the state.
2824
2983
  /**
2825
2984
  * Upload 2D section drawing data for 3D overlay rendering.
2826
2985
  *
@@ -2839,38 +2998,13 @@ export class Renderer {
2839
2998
  uploadSection2DOverlay(polygons, lines, axis, position, // 0-100 percentage
2840
2999
  sectionRange, // Same storey-based range as section plane
2841
3000
  flipped = false, customPlane) {
2842
- if (!this.section2DOverlayRenderer)
2843
- return;
2844
- if (customPlane) {
2845
- // Custom-plane path: planePosition / axis are unused — the
2846
- // basis the cap shader needs travels in `customPlane`. We pass
2847
- // 0 for `planePosition` and the existing `axis` so the cardinal
2848
- // shader code path that callers depend on (e.g. legacy SVG
2849
- // export) keeps working when customPlane is omitted.
2850
- this.section2DOverlayRenderer.uploadDrawing(polygons, lines, axis, 0, flipped, customPlane);
2851
- return;
2852
- }
2853
- // Use EXACTLY same calculation as section plane in render() method:
2854
- // minVal = options.sectionPlane.min ?? boundsMin[axisIdx]
2855
- // maxVal = options.sectionPlane.max ?? boundsMax[axisIdx]
2856
- const axisIdx = axis === 'side' ? 'x' : axis === 'down' ? 'y' : 'z';
2857
- const modelBounds = this.getModelBounds();
2858
- // Allow upload if either sectionRange has both values, or modelBounds exists as fallback
2859
- const hasFullRange = sectionRange?.min !== undefined && sectionRange?.max !== undefined;
2860
- if (!hasFullRange && !modelBounds)
2861
- return;
2862
- const minVal = sectionRange?.min ?? modelBounds.min[axisIdx];
2863
- const maxVal = sectionRange?.max ?? modelBounds.max[axisIdx];
2864
- const planePosition = minVal + (position / 100) * (maxVal - minVal);
2865
- this.section2DOverlayRenderer.uploadDrawing(polygons, lines, axis, planePosition, flipped);
3001
+ this.overlays.uploadSection2DOverlay(polygons, lines, axis, position, sectionRange, flipped, customPlane);
2866
3002
  }
2867
3003
  /**
2868
3004
  * Clear the 2D section overlay
2869
3005
  */
2870
3006
  clearSection2DOverlay() {
2871
- if (this.section2DOverlayRenderer) {
2872
- this.section2DOverlayRenderer.clearGeometry();
2873
- }
3007
+ this.overlays.clearSection2DOverlay();
2874
3008
  }
2875
3009
  /**
2876
3010
  * Set the colour of the overlay lines (annotation / alignment / grid) and the
@@ -2879,11 +3013,7 @@ export class Renderer {
2879
3013
  * `SymbolicTextInput.color` on `uploadAnnotationTexts3D`.
2880
3014
  */
2881
3015
  setOverlayLineColor(color) {
2882
- // Persist on the Renderer so a pre-init call (and any later overlay
2883
- // re-creation) keeps the colour — init() re-applies this.overlayLineColor.
2884
- this.overlayLineColor = color;
2885
- this.section2DOverlayRenderer?.setOverlayLineColor(color);
2886
- this.requestRender();
3016
+ this.overlays.setOverlayLineColor(color);
2887
3017
  }
2888
3018
  /**
2889
3019
  * Upload pre-lifted 3D line-list vertices for the standalone annotation
@@ -2892,84 +3022,13 @@ export class Renderer {
2892
3022
  * Pass an empty Float32Array to clear.
2893
3023
  */
2894
3024
  uploadAnnotationLines3D(vertices) {
2895
- if (!this.section2DOverlayRenderer)
2896
- return;
2897
- this.section2DOverlayRenderer.uploadAnnotationLines3D(vertices);
2898
- // Contribute annotation extents to modelBounds + camera sceneBounds
2899
- // so an annotation-only model (no IfcProduct meshes — common for
2900
- // separate "annotation sheets") gets framed by Home / fit-to-view
2901
- // AND has correct near/far clipping. Without sceneBounds the camera
2902
- // frustum doesn't include the annotation cluster and they're clipped
2903
- // away even when the camera is pointed at them. Mirror the
2904
- // point-cloud upload path (`addPointClouds`, `setPointClouds`) which
2905
- // does the same thing.
2906
- this.expandModelBoundsWithFlatVertices(vertices, 3);
2907
- if (this.modelBounds)
2908
- this.camera.setSceneBounds(this.modelBounds);
2909
- this.requestRender();
2910
- }
2911
- /** Walks a flat `[x,y,z,x,y,z,...]` vertex buffer and either initialises
2912
- * or expands the cached `modelBounds` AABB. Used by the annotation
2913
- * overlay upload paths so symbolic-only models can still be framed.
2914
- *
2915
- * The geometry pipeline pre-seeds a placeholder `[-100, 100]` cube on
2916
- * every render when there are 0 meshes (so the section-plane slider
2917
- * always has a workable range). For an annotation-only model that
2918
- * fallback drowns out the much-smaller annotation cluster and a plain
2919
- * "expand" would no-op. We detect the placeholder by its exact symmetric
2920
- * signature and replace it with the actual annotation AABB instead. */
2921
- expandModelBoundsWithFlatVertices(positions, stride) {
2922
- if (positions.length === 0)
2923
- return;
2924
- const isPlaceholderCube = (b) => b.min.x === -100 && b.min.y === -100 && b.min.z === -100
2925
- && b.max.x === 100 && b.max.y === 100 && b.max.z === 100;
2926
- if (!this.modelBounds || isPlaceholderCube(this.modelBounds)) {
2927
- this.modelBounds = {
2928
- min: { x: Infinity, y: Infinity, z: Infinity },
2929
- max: { x: -Infinity, y: -Infinity, z: -Infinity },
2930
- };
2931
- }
2932
- let expanded = false;
2933
- for (let i = 0; i + 2 < positions.length; i += stride) {
2934
- const x = positions[i];
2935
- const y = positions[i + 1];
2936
- const z = positions[i + 2];
2937
- if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z))
2938
- continue;
2939
- if (x < this.modelBounds.min.x)
2940
- this.modelBounds.min.x = x;
2941
- if (y < this.modelBounds.min.y)
2942
- this.modelBounds.min.y = y;
2943
- if (z < this.modelBounds.min.z)
2944
- this.modelBounds.min.z = z;
2945
- if (x > this.modelBounds.max.x)
2946
- this.modelBounds.max.x = x;
2947
- if (y > this.modelBounds.max.y)
2948
- this.modelBounds.max.y = y;
2949
- if (z > this.modelBounds.max.z)
2950
- this.modelBounds.max.z = z;
2951
- expanded = true;
2952
- }
2953
- if (!expanded)
2954
- return;
2955
- // Guarantee non-degenerate extent on every axis so camera frustums
2956
- // don't collapse. 0.5 m margin matches what the section-plane fallback
2957
- // uses elsewhere in this file.
2958
- for (const axis of ['x', 'y', 'z']) {
2959
- if (this.modelBounds.max[axis] - this.modelBounds.min[axis] < 1e-3) {
2960
- this.modelBounds.max[axis] += 0.5;
2961
- this.modelBounds.min[axis] -= 0.5;
2962
- }
2963
- }
3025
+ this.overlays.uploadAnnotationLines3D(vertices);
2964
3026
  }
2965
3027
  /**
2966
3028
  * Clear the standalone annotation line overlay.
2967
3029
  */
2968
3030
  clearAnnotationLines3D() {
2969
- if (this.section2DOverlayRenderer) {
2970
- this.section2DOverlayRenderer.clearAnnotationLines3D();
2971
- this.requestRender();
2972
- }
3031
+ this.overlays.clearAnnotationLines3D();
2973
3032
  }
2974
3033
  /**
2975
3034
  * Upload IfcAlignment centerline segments as a flat [x,y,z,x,y,z,...]
@@ -2977,22 +3036,11 @@ export class Renderer {
2977
3036
  * to match IfcGrid / IfcAnnotation. Pass an empty Float32Array to clear.
2978
3037
  */
2979
3038
  uploadAlignmentLines3D(vertices) {
2980
- if (!this.section2DOverlayRenderer)
2981
- return;
2982
- this.section2DOverlayRenderer.uploadAlignmentLines3D(vertices);
2983
- // Frame alignment-only files the same way annotation overlays are
2984
- // framed (see uploadAnnotationLines3D).
2985
- this.expandModelBoundsWithFlatVertices(vertices, 3);
2986
- if (this.modelBounds)
2987
- this.camera.setSceneBounds(this.modelBounds);
2988
- this.requestRender();
3039
+ this.overlays.uploadAlignmentLines3D(vertices);
2989
3040
  }
2990
3041
  /** Clear the alignment centerline overlay. */
2991
3042
  clearAlignmentLines3D() {
2992
- if (this.section2DOverlayRenderer) {
2993
- this.section2DOverlayRenderer.clearAlignmentLines3D();
2994
- this.requestRender();
2995
- }
3043
+ this.overlays.clearAlignmentLines3D();
2996
3044
  }
2997
3045
  /**
2998
3046
  * Upload structural-grid (IfcGridAxis) segments as a flat [x,y,z,x,y,z,...]
@@ -3004,17 +3052,27 @@ export class Renderer {
3004
3052
  * grid axes routinely extend past the model envelope).
3005
3053
  */
3006
3054
  uploadGridLines3D(vertices) {
3007
- if (!this.section2DOverlayRenderer)
3008
- return;
3009
- this.section2DOverlayRenderer.uploadGridLines3D(vertices);
3010
- this.requestRender();
3055
+ this.overlays.uploadGridLines3D(vertices);
3011
3056
  }
3012
3057
  /** Clear the structural-grid overlay. */
3013
3058
  clearGridLines3D() {
3014
- if (this.section2DOverlayRenderer) {
3015
- this.section2DOverlayRenderer.clearGridLines3D();
3016
- this.requestRender();
3017
- }
3059
+ this.overlays.clearGridLines3D();
3060
+ }
3061
+ /**
3062
+ * Upload the DXF reference-layer's line paths as a flat
3063
+ * [x,y,z,x,y,z,...] line-list in world space (issue #2043, follow-up to
3064
+ * the 2D-only DXF underlay from #1782/#1929). Mirrors
3065
+ * `uploadGridLines3D`: a dedicated buffer so 3D DXF visibility is
3066
+ * independent of the 2D underlay's own toggle, and does NOT expand
3067
+ * model bounds/reframe the camera on upload — it's behind its own
3068
+ * visibility toggle, like grid axes. Pass an empty Float32Array to clear.
3069
+ */
3070
+ uploadDxfLines3D(vertices) {
3071
+ this.overlays.uploadDxfLines3D(vertices);
3072
+ }
3073
+ /** Clear the 3D DXF reference-layer overlay. */
3074
+ clearDxfLines3D() {
3075
+ this.overlays.clearDxfLines3D();
3018
3076
  }
3019
3077
  /**
3020
3078
  * Show (or clear) the clash-overlap box: the wireframe AABB of a focused
@@ -3023,16 +3081,7 @@ export class Renderer {
3023
3081
  * clear. `min`/`max` are world-space corners (clash works in world frame).
3024
3082
  */
3025
3083
  setClashOverlapBox(box) {
3026
- if (!this.section2DOverlayRenderer)
3027
- return;
3028
- if (!box) {
3029
- this.section2DOverlayRenderer.clearClashBoxLines3D();
3030
- this.requestRender();
3031
- return;
3032
- }
3033
- this.section2DOverlayRenderer.setClashBoxLineColor(box.color);
3034
- this.section2DOverlayRenderer.uploadClashBoxLines3D(aabbEdgeLineList(box.min, box.max));
3035
- this.requestRender();
3084
+ this.overlays.setClashOverlapBox(box);
3036
3085
  }
3037
3086
  /**
3038
3087
  * Draw the focused clash's CONTACT geometry as 3D line segments — the real
@@ -3042,73 +3091,27 @@ export class Renderer {
3042
3091
  * buffer, so only one of this / setClashOverlapBox is shown at a time.
3043
3092
  */
3044
3093
  setClashContactLines(lines) {
3045
- if (!this.section2DOverlayRenderer)
3046
- return;
3047
- if (!lines || lines.vertices.length === 0) {
3048
- this.section2DOverlayRenderer.clearClashBoxLines3D();
3049
- this.requestRender();
3050
- return;
3051
- }
3052
- this.section2DOverlayRenderer.setClashBoxLineColor(lines.color);
3053
- this.section2DOverlayRenderer.uploadClashBoxLines3D(lines.vertices);
3054
- this.requestRender();
3094
+ this.overlays.setClashContactLines(lines);
3055
3095
  }
3056
3096
  /**
3057
3097
  * Upload filled IfcAnnotation regions for the symbolic overlay
3058
3098
  * (issue #653). Pass an empty array to clear.
3059
3099
  */
3060
3100
  uploadAnnotationFills3D(fills) {
3061
- if (!this.symbolicFillPipeline)
3062
- return;
3063
- this.symbolicFillPipeline.upload(fills);
3064
- // Contribute fill extents to modelBounds — see uploadAnnotationLines3D.
3065
- for (const fill of fills) {
3066
- const pts = fill.points;
3067
- if (pts.length === 0)
3068
- continue;
3069
- // points are flat [x,z,x,z,...]; lift to (x, fill.worldY, z) per
3070
- // vertex so we expand bounds in the same world space the renderer draws in.
3071
- const lifted = new Float32Array((pts.length / 2) * 3);
3072
- for (let i = 0, j = 0; i < pts.length; i += 2, j += 3) {
3073
- lifted[j] = pts[i];
3074
- lifted[j + 1] = fill.worldY;
3075
- lifted[j + 2] = pts[i + 1];
3076
- }
3077
- this.expandModelBoundsWithFlatVertices(lifted, 3);
3078
- }
3079
- if (this.modelBounds)
3080
- this.camera.setSceneBounds(this.modelBounds);
3081
- this.requestRender();
3101
+ this.overlays.uploadAnnotationFills3D(fills);
3082
3102
  }
3083
3103
  /**
3084
3104
  * Upload IfcAnnotation text labels for the symbolic overlay
3085
3105
  * (issue #653). Pass an empty array to clear.
3086
3106
  */
3087
3107
  uploadAnnotationTexts3D(texts) {
3088
- if (!this.symbolicTextPipeline)
3089
- return;
3090
- this.symbolicTextPipeline.upload(texts);
3091
- // Text origins are single points; pack them into a flat buffer and
3092
- // expand bounds. Glyph extents are small enough that origin-only
3093
- // suffices for framing.
3094
- if (texts.length > 0) {
3095
- const buf = new Float32Array(texts.length * 3);
3096
- for (let i = 0; i < texts.length; i++) {
3097
- buf[i * 3 + 0] = texts[i].worldPos[0];
3098
- buf[i * 3 + 1] = texts[i].worldPos[1];
3099
- buf[i * 3 + 2] = texts[i].worldPos[2];
3100
- }
3101
- this.expandModelBoundsWithFlatVertices(buf, 3);
3102
- if (this.modelBounds)
3103
- this.camera.setSceneBounds(this.modelBounds);
3104
- }
3105
- this.requestRender();
3108
+ this.overlays.uploadAnnotationTexts3D(texts);
3106
3109
  }
3107
3110
  /**
3108
3111
  * Check if 2D section overlay has geometry to render
3109
3112
  */
3110
3113
  hasSection2DOverlay() {
3111
- return this.section2DOverlayRenderer?.hasGeometry() ?? false;
3114
+ return this.overlays.hasSection2DOverlay();
3112
3115
  }
3113
3116
  /**
3114
3117
  * Get render pipeline (for batching)
@@ -3117,10 +3120,27 @@ export class Renderer {
3117
3120
  return this.pipeline;
3118
3121
  }
3119
3122
  /**
3120
- * Check if renderer is fully initialized and ready to use
3123
+ * Check if renderer is fully initialized and ready to use.
3124
+ *
3125
+ * `ready` is part of the test, not decoration: between `init()` being called
3126
+ * and its queued body running, the device and pipeline still belong to the
3127
+ * PREVIOUS init and are about to be destroyed, so the other two conditions
3128
+ * alone would report a renderer that is on its way out as usable.
3129
+ *
3130
+ * So is the device-loss check. A lost device is never torn down —
3131
+ * `WebGPUDevice.destroy()` is the only thing that nulls the handle and an
3132
+ * involuntary loss (driver reset / VRAM exhaustion / GPU-process crash)
3133
+ * never calls it — so `isInitialized()` stays true, the pipeline stays
3134
+ * non-null, and `ready` stays set from the init that completed before the
3135
+ * loss. All three conditions therefore still hold while `render()` is a
3136
+ * no-op and `getGPUDevice()` returns null: the renderer would report itself
3137
+ * usable through this third door alone. Unlike the two revocations above
3138
+ * this one needs no generation scoping — an `init()` clears `ready`
3139
+ * synchronously, so a latch left standing until the queued body clears it
3140
+ * cannot make this method spuriously false in the meantime.
3121
3141
  */
3122
3142
  isReady() {
3123
- return this.device.isInitialized() && this.pipeline !== null;
3143
+ return this.ready && !this.deviceLost && this.device.isInitialized() && this.pipeline !== null;
3124
3144
  }
3125
3145
  /**
3126
3146
  * Get the GPU device (returns null if not initialized, or if the device
@@ -3176,8 +3196,46 @@ export class Renderer {
3176
3196
  * post-processing buffers, section-plane renderers, and snap caches.
3177
3197
  * After calling this method the renderer is no longer usable.
3178
3198
  * Safe to call multiple times (idempotent).
3199
+ *
3200
+ * An `init()` still in flight is invalidated too. It is parked on
3201
+ * `await device.init(...)`, and without the generation bump below it resumes
3202
+ * after this returns, allocates a complete replacement GPU stack that nothing
3203
+ * references, and re-publishes `ready` — resolving `whenReady()` waiters
3204
+ * against a renderer the host has already torn down (#2465). The bump is what
3205
+ * `initOnce()` re-checks after its await, and what makes `markReady()` refuse
3206
+ * the stale completion.
3207
+ *
3208
+ * This is why the teardown itself lives in `teardown()`: `initOnce()` runs it
3209
+ * on the PREVIOUS init's objects as part of its own re-init, and routing that
3210
+ * through here would have every init invalidate its own generation, leaving
3211
+ * `whenReady()` pending forever.
3212
+ *
3213
+ * Anyone parked in `whenReady()` is FAILED rather than left pending. Nothing
3214
+ * after this call can make the wait true — the invalidation above is exactly
3215
+ * what stops the in-flight init from publishing readiness, and a host that
3216
+ * remounts builds a new `Renderer` rather than re-initialising this one — so
3217
+ * leaving the promise unsettled suspends the caller's async frame for the
3218
+ * lifetime of the page. `apps/viewer`'s point-cloud drop is one of those
3219
+ * frames: it captured this instance before the teardown, and would stop
3220
+ * mid-load with the spinner still up and no error to report. See
3221
+ * `whenReady()` for the rejection contract.
3179
3222
  */
3180
3223
  destroy() {
3224
+ this.initGeneration++;
3225
+ this.destroyed = true;
3226
+ this.teardown();
3227
+ this.rejectReadyWaiters(rendererDestroyedError());
3228
+ }
3229
+ /**
3230
+ * Release every GPU object this renderer owns, WITHOUT invalidating an
3231
+ * in-flight init. Callers: the public `destroy()` (which invalidates first)
3232
+ * and `initOnce()`, tearing down the previous init before building its own.
3233
+ */
3234
+ teardown() {
3235
+ // Nothing below survives this call, so `whenReady()` / `isReady()` must
3236
+ // go back to waiting. Set first: every release below is synchronous, but
3237
+ // the flag is what a caller holding a live reference actually reads.
3238
+ this.ready = false;
3181
3239
  // Scene mesh GPU buffers
3182
3240
  this.scene.clear();
3183
3241
  // Re-arm the section-bounds diagnostic log for the next model.
@@ -3199,18 +3257,9 @@ export class Renderer {
3199
3257
  this.edlPass = null;
3200
3258
  this.skyPass?.destroy();
3201
3259
  this.skyPass = null;
3202
- // Section-plane renderers
3203
- this.sectionPlaneRenderer?.destroy();
3204
- this.sectionPlaneRenderer = null;
3205
- this.section2DOverlayRenderer?.dispose();
3206
- this.section2DOverlayRenderer = null;
3207
- // Symbolic annotation overlay pipelines own their own GPU buffers,
3208
- // sampler, and atlas texture — recreating the viewer without
3209
- // releasing them leaks resources on every reload.
3210
- this.symbolicFillPipeline?.destroy();
3211
- this.symbolicFillPipeline = null;
3212
- this.symbolicTextPipeline?.destroy();
3213
- this.symbolicTextPipeline = null;
3260
+ // Section-plane gizmo, 2D section overlay and the symbolic annotation
3261
+ // pipelines — see RendererOverlays.destroy().
3262
+ this.overlays.destroy();
3214
3263
  // Point cloud GPU resources
3215
3264
  this.pointCloudRenderer?.clear();
3216
3265
  this.pointCloudRenderer = null;