@ifc-lite/renderer 1.41.0 → 1.43.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 (57) hide show
  1. package/dist/camera-fit-policy.d.ts.map +1 -1
  2. package/dist/camera-fit-policy.js +18 -7
  3. package/dist/camera-fit-policy.js.map +1 -1
  4. package/dist/camera.d.ts.map +1 -1
  5. package/dist/camera.js +77 -7
  6. package/dist/camera.js.map +1 -1
  7. package/dist/deviation/deviation-pipeline.d.ts.map +1 -1
  8. package/dist/deviation/deviation-pipeline.js +21 -4
  9. package/dist/deviation/deviation-pipeline.js.map +1 -1
  10. package/dist/index.d.ts +179 -35
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +478 -672
  13. package/dist/index.js.map +1 -1
  14. package/dist/math.d.ts +10 -1
  15. package/dist/math.d.ts.map +1 -1
  16. package/dist/math.js +107 -12
  17. package/dist/math.js.map +1 -1
  18. package/dist/model-bounds-tracker.d.ts +96 -0
  19. package/dist/model-bounds-tracker.d.ts.map +1 -0
  20. package/dist/model-bounds-tracker.js +147 -0
  21. package/dist/model-bounds-tracker.js.map +1 -0
  22. package/dist/pointcloud/point-cloud-node.d.ts.map +1 -1
  23. package/dist/pointcloud/point-cloud-node.js +24 -4
  24. package/dist/pointcloud/point-cloud-node.js.map +1 -1
  25. package/dist/render-degradation.d.ts +77 -0
  26. package/dist/render-degradation.d.ts.map +1 -0
  27. package/dist/render-degradation.js +52 -0
  28. package/dist/render-degradation.js.map +1 -0
  29. package/dist/render-section-draw.d.ts +51 -0
  30. package/dist/render-section-draw.d.ts.map +1 -0
  31. package/dist/render-section-draw.js +66 -0
  32. package/dist/render-section-draw.js.map +1 -0
  33. package/dist/render-section-plane.d.ts +128 -0
  34. package/dist/render-section-plane.d.ts.map +1 -0
  35. package/dist/render-section-plane.js +298 -0
  36. package/dist/render-section-plane.js.map +1 -0
  37. package/dist/renderer-overlays.d.ts +144 -0
  38. package/dist/renderer-overlays.d.ts.map +1 -0
  39. package/dist/renderer-overlays.js +257 -0
  40. package/dist/renderer-overlays.js.map +1 -0
  41. package/dist/renderer-symbolic-overlays.d.ts +52 -0
  42. package/dist/renderer-symbolic-overlays.d.ts.map +1 -0
  43. package/dist/renderer-symbolic-overlays.js +120 -0
  44. package/dist/renderer-symbolic-overlays.js.map +1 -0
  45. package/dist/scene.d.ts +103 -1
  46. package/dist/scene.d.ts.map +1 -1
  47. package/dist/scene.js +374 -104
  48. package/dist/scene.js.map +1 -1
  49. package/dist/section-2d-overlay.d.ts +17 -0
  50. package/dist/section-2d-overlay.d.ts.map +1 -1
  51. package/dist/section-2d-overlay.js +62 -0
  52. package/dist/section-2d-overlay.js.map +1 -1
  53. package/dist/visual-enhancement.d.ts +33 -0
  54. package/dist/visual-enhancement.d.ts.map +1 -0
  55. package/dist/visual-enhancement.js +46 -0
  56. package/dist/visual-enhancement.js.map +1 -0
  57. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -13,7 +13,6 @@ export { Picker } from './picker.js';
13
13
  export { MathUtils } from './math.js';
14
14
  export { SectionPlaneRenderer } from './section-plane.js';
15
15
  export { Section2DOverlayRenderer } from './section-2d-overlay.js';
16
- import { aabbEdgeLineList } from './aabb-edges.js';
17
16
  // IfcAnnotation overlay pipelines (3D world-space). Self-contained — caller
18
17
  // passes a GPUDevice + presentation format and invokes `.render(pass, viewProj)`
19
18
  // from inside an RGBA-blended pass. See packages/renderer/src/symbolic-overlay-pipelines.ts.
@@ -50,16 +49,17 @@ import { Scene } from './scene.js';
50
49
  import { Picker } from './picker.js';
51
50
  import { MathUtils } from './math.js';
52
51
  import { FrustumUtils } from '@ifc-lite/spatial';
53
- import { SectionPlaneRenderer } from './section-plane.js';
52
+ import { VisualEnhancementResolver } from './visual-enhancement.js';
54
53
  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';
54
+ import { RendererOverlays } from './renderer-overlays.js';
55
+ import { resolveSectionPlaneFrame } from './render-section-plane.js';
58
56
  import { PickingManager } from './picking-manager.js';
59
57
  import { RaycastEngine } from './raycast-engine.js';
58
+ import { RenderDegradationMonitor } from './render-degradation.js';
60
59
  import { PostProcessor } from './post-processor.js';
61
60
  import { InteractionEffectsGovernor } from './interaction-effects-governor.js';
62
61
  import { VisibilityEpochTracker } from './visibility-epoch.js';
62
+ import { ModelBoundsTracker } from './model-bounds-tracker.js';
63
63
  import { resolveContributionThresholdPx, projectedAabbRadiusPx, projectedInstancedRadiusPx } from './contribution-cull.js';
64
64
  import { EdlPass } from './edl-pass.js';
65
65
  import { SkyPass } from './sky-pass.js';
@@ -91,6 +91,42 @@ function computeBvhFingerprint(meshes) {
91
91
  }
92
92
  return parts.join('|');
93
93
  }
94
+ /**
95
+ * Is this throw the GPU device telling us it is gone?
96
+ *
97
+ * The discriminator is the exception TYPE, not its message, because WebGPU
98
+ * draws exactly that line:
99
+ * - a call on a dead / invalid-state device throws a `DOMException`
100
+ * (`InvalidStateError` in Safari 26.5 — the whole of issue #2229);
101
+ * - a buffer allocation the host cannot back throws a plain `RangeError`
102
+ * ("createBuffer failed, size (…) is too large … when mappedAtCreation ==
103
+ * true"), which `gpu-upload-guard` documents happening on a HEALTHY device
104
+ * under memory pressure.
105
+ *
106
+ * Treating the second as a device loss is a false positive that costs the whole
107
+ * session, so only the first latches; everything else degrades one frame.
108
+ *
109
+ * There is deliberately NO consecutive-failure threshold as a middle ground.
110
+ * Not because failures necessarily arrive back-to-back — between two BCF / IDS
111
+ * capture frames the awaited `camera.frameBounds` normally does let an ordinary
112
+ * rAF frame through, which would reset a counter — but because those ordinary
113
+ * frames are not guaranteed to SUCCEED: they allocate too (`ensureMeshResources`
114
+ * creates a buffer per unresourced mesh, and the queued-mesh flush allocates),
115
+ * so under sustained host memory pressure any finite budget is still reachable.
116
+ * A latch whose safety depends on incidental animation timing is the wrong
117
+ * shape of guarantee for "never kill the viewport by mistake".
118
+ *
119
+ * Real losses on browsers that do not throw are still caught by the async
120
+ * `device.lost` promise — which the WebGPU spec makes the sole loss channel
121
+ * anyway (on a conformant engine, calls against a lost device are no-ops, not
122
+ * throws; Safari 26.5's synchronous throw is the deviation being handled here).
123
+ *
124
+ * `typeof` guarded because non-DOM hosts (Node before 17, some workers) have no
125
+ * `DOMException` global; there, no throw can be a WebGPU device signal anyway.
126
+ */
127
+ function isDeviceLossThrow(error) {
128
+ return typeof DOMException !== 'undefined' && error instanceof DOMException;
129
+ }
94
130
  /**
95
131
  * Main renderer class
96
132
  */
@@ -101,15 +137,21 @@ export class Renderer {
101
137
  scene;
102
138
  picker = null;
103
139
  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;
140
+ /**
141
+ * Section-plane gizmo, 2D section drawing/cap, and the standalone 3D line
142
+ * + symbolic annotation overlays (issue #2425). Created here rather than in
143
+ * `init()` so a pre-init `setOverlayLineColor` still lands the GPU
144
+ * objects inside stay null until `init()` calls `overlays.init()`.
145
+ */
146
+ overlays = new RendererOverlays({
147
+ getModelBounds: () => this.getModelBounds(),
148
+ expandModelBoundsWithFlatVertices: (positions, stride) => this.modelBoundsTracker.expandWithFlatVertices(positions, stride),
149
+ syncCameraSceneBounds: () => {
150
+ if (this.modelBounds)
151
+ this.camera.setSceneBounds(this.modelBounds);
152
+ },
153
+ requestRender: () => this.requestRender(),
154
+ });
113
155
  postProcessor = null;
114
156
  interactionEffects = new InteractionEffectsGovernor();
115
157
  edlPass = null;
@@ -126,14 +168,32 @@ export class Renderer {
126
168
  /** Set true at the end of `init()`; gates `whenReady()`. */
127
169
  ready = false;
128
170
  readyWaiters = [];
171
+ /**
172
+ * The tail of the `init()` queue. `init()` chains onto this rather than
173
+ * running immediately, so two overlapping calls cannot both walk past the
174
+ * "a previous init completed" guard while the first is still awaiting its
175
+ * device and both allocate a full set of GPU objects (#2448). Always
176
+ * settled fulfilled — a rejected init is swallowed HERE (never for the
177
+ * caller) so one failure does not deadlock every later call.
178
+ */
179
+ initChain = Promise.resolve();
129
180
  /**
130
181
  * Set once the GPU device is lost for a non-intentional reason (driver
131
182
  * reset / VRAM exhaustion — see `WebGPUDevice`). Every GPU resource is then
132
183
  * dead, so `render()` becomes a no-op (it would only spew validation errors)
133
184
  * until the host re-initialises the renderer. Consumers learn of this via
134
185
  * `onDeviceLost` and typically respond by reloading the model.
186
+ *
187
+ * Two signals set it: the async `device.lost` promise (Chromium), and a
188
+ * frame throwing a `DOMException` out of `render()` (Safari 26.5, which
189
+ * reports the loss synchronously — issue #2229). Whichever arrives first
190
+ * latches. A frame throwing anything else does NOT latch (see
191
+ * `isDeviceLossThrow`) — that class is host memory pressure on a live
192
+ * device, and it must cost one frame, not the session.
135
193
  */
136
194
  deviceLost = false;
195
+ /** Retained so a listener registered AFTER the loss still learns of it. */
196
+ deviceLostInfo = null;
137
197
  deviceLostListeners = new Set();
138
198
  deviationPipeline = null;
139
199
  /**
@@ -144,14 +204,20 @@ export class Renderer {
144
204
  * want to pay that on every slider drag.
145
205
  */
146
206
  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;
207
+ visualEnhancementResolver = new VisualEnhancementResolver();
208
+ // Model bounds for fitToView, section planes, camera. The value itself
209
+ // lives in ModelBoundsTracker (issue #2425) so the four writers — point
210
+ // cloud upload, mesh load, overlay upload, and the public setModelBounds —
211
+ // share one owner instead of a private field. Camera notification stays at
212
+ // the call sites: they do not all push under the same policy.
213
+ modelBoundsTracker = new ModelBoundsTracker({
214
+ meshBounds: () => this.computeMeshBounds(),
215
+ pointCloudBounds: () => this.pointCloudRenderer?.getBounds() ?? null,
216
+ });
217
+ /** Read-only view of the tracked scene AABB (live reference, not a copy). */
218
+ get modelBounds() {
219
+ return this.modelBoundsTracker.get();
220
+ }
155
221
  // Composition: delegate to extracted managers
156
222
  pickingManager;
157
223
  raycastEngine;
@@ -161,6 +227,48 @@ export class Renderer {
161
227
  // exactly the evidence worth keeping.
162
228
  lastRenderErrorTime = -Infinity;
163
229
  RENDER_ERROR_THROTTLE_MS = 1000;
230
+ /**
231
+ * Consecutive frames that threw a non-device error and were degraded.
232
+ * Reset by any frame that completes. Gates the self-retry in `render()`'s
233
+ * catch — see there for why it is a retry budget and not a latch — and,
234
+ * since #2417, the persistent-degradation report as well. Both readings
235
+ * depend on the reset: this is the length of the CURRENT unbroken run of
236
+ * failures, never a session total (`_renderErrorCount` is that, and using
237
+ * it for either purpose would count failures the viewport recovered from).
238
+ */
239
+ consecutiveDegradedFrames = 0;
240
+ /**
241
+ * How many consecutive degraded frames may re-request themselves. Three is
242
+ * a blink at 60 Hz — enough for a transient host-memory spike to clear
243
+ * without the user touching anything, far too few to matter as wasted work
244
+ * if it does not. Beyond it the viewport goes quiet rather than spinning,
245
+ * and the next interaction/stream/animation drives it as normal.
246
+ */
247
+ MAX_DEGRADED_SELF_RETRIES = 3;
248
+ /**
249
+ * Decides when degrading has stopped being transient (issue #2417). The
250
+ * non-latching branch is correct per occurrence and blind in aggregate: a
251
+ * failure that never clears leaves a wedged viewport that looks, from
252
+ * outside, exactly like one that recovered. Fires once per session.
253
+ */
254
+ renderDegradation = new RenderDegradationMonitor();
255
+ persistentDegradationListeners = new Set();
256
+ /**
257
+ * Set by `containFrameThrow` for the frame currently in flight, cleared by
258
+ * `render()` before each one.
259
+ *
260
+ * Needed because the encode region's catch is INSIDE `renderFrame()`, and
261
+ * it swallows its throw: a frame that failed there returns to `render()`
262
+ * perfectly normally, so "did not throw" is not the same question as "did
263
+ * not fail". Without this flag `render()` reads it as a completed frame and
264
+ * resets `consecutiveDegradedFrames` on the very next line — which makes
265
+ * `++count <= MAX_DEGRADED_SELF_RETRIES` true on EVERY encode failure, so
266
+ * the retry budget never exhausts and a persistently failing encode path
267
+ * re-requests one throwing frame per rAF forever. It also caps the run
268
+ * length at 1, so no persistent-degradation report could ever fire for the
269
+ * region this PR exists to cover.
270
+ */
271
+ frameContainedThrow = false;
164
272
  // Diagnostic counters for mobile debugging
165
273
  _renderCallCount = 0;
166
274
  _renderSkipCount = 0;
@@ -225,9 +333,43 @@ export class Renderer {
225
333
  this.raycastEngine = new RaycastEngine(this.camera, this.scene, this.canvas);
226
334
  }
227
335
  /**
228
- * Initialize renderer
336
+ * Initialize renderer.
337
+ *
338
+ * Safe to call on an already-initialised instance: the previous GPU objects
339
+ * are released first. The comment below advertises a `destroy()` + `init()`
340
+ * re-init flow, and the obvious device-loss auto-recovery is to call
341
+ * `init()` on the live instance — which, without this, silently orphaned
342
+ * two render pipelines, the picker, the post-processor, the point-cloud and
343
+ * deviation pipelines, the EDL pass and the overlay layer's glyph atlas, per
344
+ * recovery (#2448). Making the method self-safe is cheaper than trusting
345
+ * every future caller to remember.
346
+ *
347
+ * Concurrent calls are SERIALISED, not coalesced: the second waits for the
348
+ * first to settle and then runs in full. Without that, `pipeline` — which
349
+ * only ever marks a COMPLETED init — is still null while the first call is
350
+ * awaiting `device.init()`, so both calls sail past the guard above and both
351
+ * allocate a full set of GPU objects, orphaning the first. Queueing turns
352
+ * the concurrent case into the sequential one the guard already handles,
353
+ * rather than adding a second, differently-shaped rule.
229
354
  */
230
355
  async init() {
356
+ // A previous init that REJECTED must not block the next one, so the
357
+ // stored link swallows the outcome. The caller still receives `run`, so
358
+ // rejections continue to surface exactly as before.
359
+ const run = this.initChain.then(() => this.initOnce(), () => this.initOnce());
360
+ this.initChain = run.then(() => undefined, () => undefined);
361
+ return run;
362
+ }
363
+ async initOnce() {
364
+ // `pipeline` is the marker for "a previous init() completed": it is
365
+ // assigned unconditionally there and nulled by destroy().
366
+ if (this.pipeline !== null) {
367
+ this.destroy();
368
+ // destroy() releases the device the previous init() resolved on, so
369
+ // `whenReady()` must go back to waiting rather than resolve against
370
+ // GPU objects that no longer exist.
371
+ this.ready = false;
372
+ }
231
373
  // Clear the lost flag so a re-init (destroy()+init() on the same instance)
232
374
  // resumes rendering instead of staying a permanent no-op from an earlier loss.
233
375
  this.deviceLost = false;
@@ -252,16 +394,7 @@ export class Renderer {
252
394
  }
253
395
  this.pipeline = new RenderPipeline(this.device, width, height);
254
396
  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());
397
+ this.overlays.init(this.device.getDevice(), this.device.getFormat(), this.pipeline.getSampleCount());
265
398
  // PostProcessor is optional — if it fails (e.g. mobile GPU lacking
266
399
  // depth TEXTURE_BINDING), rendering still works without post-processing.
267
400
  try {
@@ -343,8 +476,44 @@ export class Renderer {
343
476
  */
344
477
  onDeviceLost(listener) {
345
478
  this.deviceLostListeners.add(listener);
479
+ // Replay a loss that already happened. `init()` subscribes to the
480
+ // device's own loss signal BEFORE awaiting `device.init()`, so a loss
481
+ // during initialisation latches while `deviceLostListeners` is still
482
+ // empty — and the viewer's subscriber cannot register any earlier,
483
+ // because it needs init() to have resolved. Without this replay that
484
+ // loss reaches nobody: the renderer correctly goes quiet and the user
485
+ // sees a viewer that simply stopped, with no toast and no capture.
486
+ if (this.deviceLost && this.deviceLostInfo !== null) {
487
+ try {
488
+ listener(this.deviceLostInfo);
489
+ }
490
+ catch (e) {
491
+ console.error('[Renderer] onDeviceLost listener threw:', e);
492
+ }
493
+ }
346
494
  return () => this.deviceLostListeners.delete(listener);
347
495
  }
496
+ /**
497
+ * Subscribe to the renderer having degraded frame after frame without
498
+ * recovering (issue #2417). Distinct from `onDeviceLost`: the device is
499
+ * still alive by every signal available, which is exactly why `render()`
500
+ * refuses to latch on these throws — but the user is looking at a viewport
501
+ * that has stopped updating, and until this callback existed nothing said
502
+ * so. Fired at most once per renderer, once `PERSISTENT_DEGRADATION_FRAMES`
503
+ * frames have degraded CONSECUTIVELY — any frame that completes resets the
504
+ * run, so a session that failed occasionally and recovered every time never
505
+ * reports. Returns an unsubscribe function.
506
+ *
507
+ * No replay for a late subscriber, unlike `onDeviceLost` — a loss can latch
508
+ * during `init()`, before any subscriber can exist, but a degraded frame
509
+ * cannot: `renderFrame()` returns early while `pipeline` is null, so the
510
+ * count only moves once the host is driving frames, which is strictly after
511
+ * `init()` resolved and the host subscribed.
512
+ */
513
+ onPersistentRenderDegradation(listener) {
514
+ this.persistentDegradationListeners.add(listener);
515
+ return () => this.persistentDegradationListeners.delete(listener);
516
+ }
348
517
  /** True once the GPU device has been lost for a non-intentional reason. */
349
518
  isDeviceLost() {
350
519
  return this.deviceLost;
@@ -353,6 +522,7 @@ export class Renderer {
353
522
  if (this.deviceLost)
354
523
  return;
355
524
  this.deviceLost = true;
525
+ this.deviceLostInfo = info;
356
526
  console.warn('[Renderer] GPU device lost — halting rendering until re-init:', info.message);
357
527
  for (const listener of this.deviceLostListeners) {
358
528
  try {
@@ -363,6 +533,108 @@ export class Renderer {
363
533
  }
364
534
  }
365
535
  }
536
+ /**
537
+ * Contain a throw that escaped part of a frame, and decide what it meant.
538
+ *
539
+ * ONE body for both of `render()`'s catches (issue #2417). They used to
540
+ * differ in the only way that matters: the outer one discriminated on
541
+ * `isDeviceLossThrow`, the encode-region one did not, so a device that died
542
+ * after `getCurrentTexture()` succeeded degraded quietly forever — no latch,
543
+ * no toast, no `onDeviceLost`. Sharing the body is what stops the two
544
+ * halves of one policy drifting apart again.
545
+ *
546
+ * Callers keep only what is genuinely theirs: the outer catch counts the
547
+ * frame as a skip, the encode catch balances the validation error scope
548
+ * first. `origin` distinguishes them in logs and in the degradation report.
549
+ */
550
+ containFrameThrow(error, origin) {
551
+ // Recorded for BOTH branches, before either is chosen: the caller in
552
+ // the encode region is about to return normally either way, and
553
+ // `render()` must not mistake that for a frame that succeeded.
554
+ this.frameContainedThrow = true;
555
+ this._renderErrorCount++;
556
+ const message = error instanceof Error ? error.message : String(error);
557
+ this._lastRenderError = message;
558
+ if (isDeviceLossThrow(error)) {
559
+ // Reached at most once per device: the `deviceLost` early return in
560
+ // render() short-circuits every later frame. Logged with the
561
+ // original error to keep the stack.
562
+ console.error(`[Renderer] Frame threw a DOMException (${origin}) — treating as device loss:`, error);
563
+ this.handleDeviceLost({
564
+ message,
565
+ reason: origin === 'encode' ? 'render-encode-exception' : 'render-exception',
566
+ });
567
+ return;
568
+ }
569
+ // Not a device signal — cost this FRAME, never the session. Both
570
+ // regions really do have such a source on a HEALTHY device: the outer
571
+ // one runs `scene.restoreAllEvicted()` for capture frames, the encode
572
+ // one builds visibility sub-batches through
573
+ // `scene.getOrCreatePartialBatch()`, and both allocate via
574
+ // `createBuffer({ mappedAtCreation: true })`, which throws a plain
575
+ // `RangeError` under host memory pressure — the failure
576
+ // `gpu-upload-guard` documents verbatim. Latching there would kill the
577
+ // viewport for a failure whose blast radius should be one frame, and
578
+ // would raise a false "graphics device was lost" toast plus false
579
+ // `device_lost` telemetry on top.
580
+ //
581
+ // Invalidate the swap-chain configuration so the next frame
582
+ // reconfigures.
583
+ this.device.invalidateContext();
584
+ // ...and ask for that next frame. The host loop CONSUMES the dirty flag
585
+ // before calling render(), so a frame that fails has already spent its
586
+ // request: on an idle viewer (no animation, no streaming, no
587
+ // interaction) nothing would re-dirty it and the failed frame would be
588
+ // the last one drawn until the user happened to touch something.
589
+ // "Degrade and continue" has to mean the next frame actually comes, or
590
+ // it is only "degrade and hope".
591
+ //
592
+ // Bounded, and reset by any successful frame, so a persistently failing
593
+ // path cannot self-perpetuate one throwing frame per rAF forever. NOTE
594
+ // this is a RETRY budget, not a latch threshold: exhausting it stops us
595
+ // re-requesting, leaving the app's own dirty signals (interaction,
596
+ // streaming, animation) to drive — it never disables the renderer.
597
+ // Worst case is a stale viewport that any interaction revives, not a
598
+ // dead session.
599
+ if (++this.consecutiveDegradedFrames <= this.MAX_DEGRADED_SELF_RETRIES) {
600
+ this.requestRender();
601
+ }
602
+ // Per-frame degradation is the right call and an aggregate blind spot:
603
+ // report the session once it is clear the failure is not clearing.
604
+ this.notePersistentDegradation(message, origin);
605
+ const now = performance.now();
606
+ if (now - this.lastRenderErrorTime > this.RENDER_ERROR_THROTTLE_MS) {
607
+ this.lastRenderErrorTime = now;
608
+ console.warn(`[Renderer] Frame threw in ${origin} (device assumed alive; context will be reconfigured):`, error);
609
+ }
610
+ }
611
+ /**
612
+ * Fan out the once-per-session "this viewport is not recovering" report.
613
+ * The renderer files no telemetry itself (it is host-agnostic and must stay
614
+ * PostHog-free); the host subscribes and routes it through whatever it
615
+ * already uses for device loss.
616
+ */
617
+ notePersistentDegradation(detail, origin) {
618
+ // `consecutiveDegradedFrames`, NOT `_renderErrorCount`. The latter is a
619
+ // renderer-LIFETIME total that no successful frame ever resets, so it
620
+ // would turn the threshold into "the 16th failure ever" — reached by a
621
+ // long healthy session that hit four isolated spikes an hour apart and
622
+ // recovered from every one of them. The signal is meant to mean "this
623
+ // viewport has stopped", and only an unbroken run means that. The
624
+ // reset lives in `render()`, on the path where a frame completes.
625
+ const info = this.renderDegradation.note(this.consecutiveDegradedFrames, detail, origin);
626
+ if (!info)
627
+ return;
628
+ console.warn(`[Renderer] ${info.consecutiveDegradedFrames} consecutive frames degraded without one completing — the viewport is not updating.`);
629
+ for (const listener of this.persistentDegradationListeners) {
630
+ try {
631
+ listener(info);
632
+ }
633
+ catch (e) {
634
+ console.error('[Renderer] onPersistentRenderDegradation listener threw:', e);
635
+ }
636
+ }
637
+ }
366
638
  /**
367
639
  * Replace all loaded point clouds with `assets`.
368
640
  *
@@ -376,10 +648,10 @@ export class Renderer {
376
648
  }
377
649
  this.pointCloudRenderer.setAssets(assets);
378
650
  // Replace, not append — bounds may have shrunk (e.g. an IFCx
379
- // reload with a smaller scan). `expandModelBoundsForPointClouds`
651
+ // reload with a smaller scan). `expandForPointClouds`
380
652
  // alone only grows; recompute from scratch to keep
381
653
  // fit-to-view + section-plane sliders accurate.
382
- this.recomputeModelBounds();
654
+ this.modelBoundsTracker.recompute();
383
655
  this.camera.setSceneBounds(this.modelBounds);
384
656
  this.requestRender();
385
657
  }
@@ -391,7 +663,7 @@ export class Renderer {
391
663
  for (const asset of assets) {
392
664
  this.pointCloudRenderer.addAsset(asset);
393
665
  }
394
- this.expandModelBoundsForPointClouds();
666
+ this.modelBoundsTracker.expandForPointClouds();
395
667
  this.camera.setSceneBounds(this.modelBounds);
396
668
  this.requestRender();
397
669
  }
@@ -406,7 +678,7 @@ export class Renderer {
406
678
  /** Drop all point cloud GPU resources. */
407
679
  clearPointClouds() {
408
680
  this.pointCloudRenderer?.clear();
409
- this.recomputeModelBounds();
681
+ this.modelBoundsTracker.recompute();
410
682
  this.camera.setSceneBounds(this.modelBounds);
411
683
  this.requestRender();
412
684
  }
@@ -425,7 +697,7 @@ export class Renderer {
425
697
  if (!this.pointCloudRenderer)
426
698
  return;
427
699
  this.pointCloudRenderer.appendChunk(handle, chunk);
428
- this.expandModelBoundsForPointClouds();
700
+ this.modelBoundsTracker.expandForPointClouds();
429
701
  this.camera.setSceneBounds(this.modelBounds);
430
702
  this.requestRender();
431
703
  }
@@ -437,7 +709,7 @@ export class Renderer {
437
709
  this.pointCloudRenderer?.removeAsset(handle);
438
710
  // Bounds may have shrunk — recompute from scratch so fit-to-view
439
711
  // and section-plane sliders see fresh extents.
440
- this.recomputeModelBounds();
712
+ this.modelBoundsTracker.recompute();
441
713
  this.camera.setSceneBounds(this.modelBounds);
442
714
  this.requestRender();
443
715
  }
@@ -452,34 +724,6 @@ export class Renderer {
452
724
  this.pointCloudRenderer?.relabelAsset(handle, newExpressId);
453
725
  this.requestRender();
454
726
  }
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
727
  /** Aggregate bounds across all batched + individual meshes. Returns
484
728
  * null if the scene has no mesh geometry. */
485
729
  computeMeshBounds() {
@@ -525,7 +769,7 @@ export class Renderer {
525
769
  // them to the camera (matching every other bounds-mutating
526
770
  // point-cloud method) so framing / zoom-to-fit targets where the
527
771
  // points actually render.
528
- this.recomputeModelBounds();
772
+ this.modelBoundsTracker.recompute();
529
773
  this.camera.setSceneBounds(this.modelBounds);
530
774
  this.requestRender();
531
775
  }
@@ -657,25 +901,6 @@ export class Renderer {
657
901
  this.edlOptions.highQuality = opts.highQuality;
658
902
  this.requestRender();
659
903
  }
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
904
  /**
680
905
  * Load geometry from GeometryResult or MeshData array
681
906
  * This is the main entry point for loading IFC geometry into the renderer
@@ -695,7 +920,7 @@ export class Renderer {
695
920
  const device = this.device.getDevice();
696
921
  this.scene.appendToBatches(meshes, device, this.pipeline, false);
697
922
  // Calculate and store model bounds for fitToView
698
- this.updateModelBounds(meshes);
923
+ this.modelBoundsTracker.updateFromMeshes(meshes);
699
924
  console.log(`[Renderer] Loaded ${meshes.length} meshes`);
700
925
  // Update camera scene bounds for tight orthographic near/far planes
701
926
  this.camera.setSceneBounds(this.modelBounds);
@@ -715,7 +940,7 @@ export class Renderer {
715
940
  const device = this.device.getDevice();
716
941
  this.scene.appendToBatches(meshes, device, this.pipeline, isStreaming);
717
942
  // Update model bounds incrementally
718
- this.updateModelBounds(meshes);
943
+ this.modelBoundsTracker.updateFromMeshes(meshes);
719
944
  // Update camera scene bounds for tight orthographic near/far planes
720
945
  this.camera.setSceneBounds(this.modelBounds);
721
946
  }
@@ -800,39 +1025,7 @@ export class Renderer {
800
1025
  * Set model bounds (used when computing bounds from batches)
801
1026
  */
802
1027
  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
- }
1028
+ this.modelBoundsTracker.set(bounds);
836
1029
  }
837
1030
  /**
838
1031
  * Create a GPU Mesh from MeshData (lazy creation for selection highlighting)
@@ -985,31 +1178,6 @@ export class Renderer {
985
1178
  }
986
1179
  });
987
1180
  }
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
1181
  /**
1014
1182
  * Render frame
1015
1183
  */
@@ -1056,6 +1224,34 @@ export class Renderer {
1056
1224
  this.scene.setQuantizedBatches(true);
1057
1225
  return ok;
1058
1226
  }
1227
+ /**
1228
+ * Draw one frame.
1229
+ *
1230
+ * Never throws, so callers never need to guard this call to keep their
1231
+ * animation loop alive.
1232
+ *
1233
+ * What a throw MEANS depends on its type (`isDeviceLossThrow`), and since
1234
+ * issue #2417 that holds for the WHOLE frame — both this catch and the
1235
+ * encode region's own, which share `containFrameThrow`:
1236
+ * - a `DOMException` is the device reporting its own death synchronously
1237
+ * (Safari 26.5, issue #2229). It latches the same `deviceLost` state the
1238
+ * async `device.lost` promise would: later frames become quiet skips and
1239
+ * `onDeviceLost` listeners fire exactly once.
1240
+ * - anything else (a `RangeError` from a buffer the host cannot allocate,
1241
+ * say) costs only this frame: the swap-chain config is invalidated so
1242
+ * the next frame reconfigures, a frame is re-requested within a bounded
1243
+ * budget, the failure is counted in `getDiagnostics()`, and rendering
1244
+ * carries on. Once enough such frames have degraded without recovering,
1245
+ * `onPersistentRenderDegradation` fires once.
1246
+ *
1247
+ * SCOPE: `renderFrame()` has two try/catch regions — this outer one (canvas
1248
+ * resize, context setup, evicted-batch restore) and an inner one opened
1249
+ * after the swap-chain texture is acquired, covering encoder work through
1250
+ * `submit`. Until #2417 only the outer one discriminated, so a device that
1251
+ * died after `getCurrentTexture()` succeeded degraded quietly forever with
1252
+ * no latch and no toast. Both now run the same policy; the encode catch
1253
+ * additionally balances the frame's validation error scope before doing so.
1254
+ */
1059
1255
  render(options = {}) {
1060
1256
  this._renderCallCount++;
1061
1257
  // A lost device leaves every pipeline/buffer dead; rendering would only
@@ -1064,6 +1260,37 @@ export class Renderer {
1064
1260
  this._renderSkipCount++;
1065
1261
  return;
1066
1262
  }
1263
+ try {
1264
+ this.frameContainedThrow = false;
1265
+ this.renderFrame(options);
1266
+ // Only a frame that actually got through resets the run. A frame
1267
+ // the ENCODE catch contained returns here normally (that catch is
1268
+ // inside renderFrame), so "did not throw" is not the same question
1269
+ // as "did not fail" — see `frameContainedThrow`.
1270
+ if (!this.frameContainedThrow)
1271
+ this.consecutiveDegradedFrames = 0;
1272
+ }
1273
+ catch (error) {
1274
+ // Safari (26.5) reports device loss SYNCHRONOUSLY: a call against a
1275
+ // dead device throws `InvalidStateError` instead of — or long
1276
+ // before — resolving `device.lost` (issue #2229). Without this
1277
+ // catch the throw escapes render(), the caller's rAF loop never
1278
+ // re-arms, and the viewer freezes for good with nothing on screen
1279
+ // and nothing subscribed to onDeviceLost ever told.
1280
+ //
1281
+ // Deliberately NOT rethrown either way: the frame is already lost,
1282
+ // and the established contract is "degrade" (see `pickPathAlive()`
1283
+ // and the rAF loop's own upload/residency guards), not "take the
1284
+ // host down with us".
1285
+ this._renderSkipCount++;
1286
+ this.containFrameThrow(error, 'frame');
1287
+ }
1288
+ }
1289
+ /**
1290
+ * The frame body. Throws on a synchronously-dead GPU device; `render()`
1291
+ * owns the containment. Private for that reason — call `render()`.
1292
+ */
1293
+ renderFrame(options) {
1067
1294
  if (!this.device.isInitialized() || !this.pipeline) {
1068
1295
  this._renderSkipCount++;
1069
1296
  return;
@@ -1126,7 +1353,7 @@ export class Renderer {
1126
1353
  if (options.restoreEvictedForCapture && this.pipeline) {
1127
1354
  this.scene.restoreAllEvicted(device, this.pipeline);
1128
1355
  }
1129
- const visualEnhancement = this.resolveVisualEnhancement(options.visualEnhancement);
1356
+ const visualEnhancement = this.visualEnhancementResolver.resolve(options.visualEnhancement);
1130
1357
  // Post effects during interaction (orbit/pan/zoom) are governed
1131
1358
  // adaptively: they stay on while the interactive frame cadence holds
1132
1359
  // (the pass costs well under a ms on discrete/Apple GPUs at CSS
@@ -1370,201 +1597,27 @@ export class Renderer {
1370
1597
  // Write uniform data to each mesh's buffer BEFORE recording commands
1371
1598
  // This ensures each mesh has its own color data
1372
1599
  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
1600
+ // This frame's clip plane and the bounds the section slider is
1601
+ // expressed in resolved in render-section-plane.ts, which owns
1602
+ // the bounds aggregation, the terrain-clip and explicit-plane
1603
+ // branches, and the one-shot diagnostic log (issue #2425).
1604
+ const sectionFrame = resolveSectionPlaneFrame({
1605
+ options,
1606
+ batchedMeshes: this.scene.getBatchedMeshes(),
1607
+ meshes,
1608
+ pointCloudBounds: this.pointCloudRenderer?.getBounds() ?? null,
1609
+ logSectionBounds: !this._loggedSectionBounds,
1610
+ spendLogLatch: () => { this._loggedSectionBounds = true; },
1611
+ });
1612
+ const sectionPlaneData = sectionFrame.sectionPlaneData;
1613
+ if (sectionFrame.bounds) {
1614
+ // Store bounds for section plane visual and camera near/far.
1615
+ // Two wrappers over the same min/max, exactly as before the
1616
+ // extraction the renderer's copy is replaced wholesale by the
1617
+ // bounds helpers, the camera's is not.
1618
+ const { min: boundsMin, max: boundsMax } = sectionFrame.bounds;
1443
1619
  this.setModelBounds({ min: boundsMin, max: boundsMax });
1444
1620
  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
1621
  }
1569
1622
  // Stash what we actually clipped this frame so the GPU picker mirrors
1570
1623
  // it (section/crop-clipped geometry must be unpickable, not just hidden).
@@ -2131,12 +2184,6 @@ export class Renderer {
2131
2184
  const texturedMeshes = this.scene.getTexturedMeshes();
2132
2185
  if (texturedMeshes.length > 0) {
2133
2186
  pass.setPipeline(this.pipeline.getTexturedPipeline());
2134
- // Textured meshes carry absolute (origin-0) positions, so the
2135
- // model translation must be identity here — reset the column
2136
- // that renderBatch left set to the last opaque batch's origin.
2137
- tpl[28] = 0;
2138
- tpl[29] = 0;
2139
- tpl[30] = 0;
2140
2187
  for (const tm of texturedMeshes) {
2141
2188
  // Honour hide/isolate — textured meshes bypass the batch
2142
2189
  // visibility filtering above, so apply it per-mesh here or
@@ -2161,6 +2208,18 @@ export class Renderer {
2161
2208
  // batch overlay paint pass doesn't iterate textured meshes,
2162
2209
  // so applying the override here is what recolours them.
2163
2210
  const txOverride = colorOverrides?.get(tm.expressId);
2211
+ // `world = origin + position`: the vertex buffer stores
2212
+ // positions in this mesh's per-element local frame, so the
2213
+ // model translation carries the world magnitude (#1973).
2214
+ // Per mesh, which also overwrites the column renderBatch
2215
+ // left set to the last opaque batch's origin. This used to
2216
+ // be hoisted out of the loop and hard-zeroed — right only
2217
+ // for the orphan type-geometry path (origin == 0), and it
2218
+ // drew every textured occurrence collapsed toward the
2219
+ // world origin.
2220
+ tpl[28] = tm.origin[0];
2221
+ tpl[29] = tm.origin[1];
2222
+ tpl[30] = tm.origin[2];
2164
2223
  tpl[32] = txOverride ? txOverride[0] : tm.color[0];
2165
2224
  tpl[33] = txOverride ? txOverride[1] : tm.color[1];
2166
2225
  tpl[34] = txOverride ? txOverride[2] : tm.color[2];
@@ -2467,122 +2526,17 @@ export class Renderer {
2467
2526
  viewport: { width: this.canvas.width, height: this.canvas.height },
2468
2527
  });
2469
2528
  }
2470
- // Draw section plane visual BEFORE pass.end() (within same MSAA render pass)
2471
- // Always show plane when sectionPlane options are provided (as preview or active)
2472
- const modelBounds = this.getModelBounds();
2473
- if (options.sectionPlane && this.sectionPlaneRenderer && modelBounds) {
2474
- this.sectionPlaneRenderer.draw(pass, {
2475
- axis: options.sectionPlane.axis,
2476
- position: options.sectionPlane.position,
2477
- bounds: modelBounds,
2478
- viewProj,
2479
- isPreview: !options.sectionPlane.enabled, // Preview mode when not enabled
2480
- min: options.sectionPlane.min,
2481
- max: options.sectionPlane.max,
2482
- // Custom-plane gizmo override (issue #243). When both
2483
- // are set the gizmo bypasses the cardinal path; see
2484
- // SectionPlaneRenderer.calculatePlaneVerticesFromNormal.
2485
- normal: options.sectionPlane.normal,
2486
- distance: options.sectionPlane.distance,
2487
- });
2488
- // Draw 2D section overlay on the section plane (when section is
2489
- // active, not preview). The overlay is also the 3D SECTION CAP:
2490
- // its polygon fills come from `SectionCutter` (exact triangle-
2491
- // plane intersection), and the new fill shader applies the
2492
- // user's screen-space hatch + colour directly on those
2493
- // polygons. This replaces the old stencil-parity cap, which
2494
- // bled hatch into empty sky on non-manifold IFC geometry —
2495
- // the polygons here are mathematically correct, so the cap
2496
- // silhouette matches the 2D drawing exactly.
2497
- if (options.sectionPlane.enabled && this.section2DOverlayRenderer?.hasGeometry()) {
2498
- const o = options.sectionPlane;
2499
- const showFills = o.showCap !== false;
2500
- const showOutlines = o.showOutlines !== false;
2501
- const style = { ...DEFAULT_CAP_STYLE, ...(o.capStyle ?? {}) };
2502
- this.section2DOverlayRenderer.draw(pass, {
2503
- axis: o.axis,
2504
- position: o.position,
2505
- bounds: modelBounds,
2506
- viewProj,
2507
- min: o.min,
2508
- max: o.max,
2509
- showFills,
2510
- showOutlines,
2511
- capStyle: showFills ? {
2512
- fillColor: style.fillColor,
2513
- strokeColor: style.strokeColor,
2514
- patternId: HATCH_PATTERN_IDS[style.pattern],
2515
- spacingPx: style.spacingPx,
2516
- angleRad: style.angleRad,
2517
- widthPx: style.widthPx,
2518
- secondaryAngleRad: style.secondaryAngleRad,
2519
- } : undefined,
2520
- });
2521
- }
2522
- }
2523
- // Standalone IFC annotation overlay (issue #653). The line
2524
- // vertices were pre-lifted to world space at upload time, so
2525
- // this draw happens regardless of whether a section plane is
2526
- // active — annotations are a free-floating "drawing layer"
2527
- // that sits at each annotation's storey elevation.
2528
- //
2529
- // This block was previously nested inside the `if (options.sectionPlane && ...)`
2530
- // guard above, contradicting its own comment. Loading an
2531
- // annotation-only model with no section plane meant the entire
2532
- // overlay was skipped at draw time even though 9000+ vertices
2533
- // had been uploaded successfully. Pulled out to its own block.
2534
- //
2535
- // Order: fills (background) → lines (outlines on top) →
2536
- // texts (labels above everything).
2537
- if (this.symbolicFillPipeline?.hasGeometry()) {
2538
- this.symbolicFillPipeline.render(pass, viewProj);
2539
- }
2540
- if (this.section2DOverlayRenderer?.hasAnnotationLines3D()) {
2541
- this.section2DOverlayRenderer.drawAnnotationLines3D(pass, viewProj);
2542
- }
2543
- if (this.section2DOverlayRenderer?.hasAlignmentLines3D()) {
2544
- this.section2DOverlayRenderer.drawAlignmentLines3D(pass, viewProj);
2545
- }
2546
- if (this.section2DOverlayRenderer?.hasGridLines3D()) {
2547
- this.section2DOverlayRenderer.drawGridLines3D(pass, viewProj);
2548
- }
2549
- if (this.section2DOverlayRenderer?.hasClashBoxLines3D()) {
2550
- this.section2DOverlayRenderer.drawClashBoxLines3D(pass, viewProj);
2551
- }
2552
- if (this.symbolicTextPipeline?.hasGeometry()) {
2553
- // Pass viewport pixel dimensions so the shader can scale glyphs
2554
- // to a constant on-screen size (BIMvision-style annotations)
2555
- // regardless of camera distance or authored text height.
2556
- //
2557
- // Also pass the screen-aligned camera basis (right, up) so
2558
- // billboarded glyphs (grid bubble tags) can face the camera
2559
- // in any orientation — top-down, eye-level, oblique alike.
2560
- const camPos = this.camera.getPosition();
2561
- const camTgt = this.camera.getTarget();
2562
- const camUpVec = this.camera.getUp();
2563
- // Forward = normalize(target - position).
2564
- let fx = camTgt.x - camPos.x;
2565
- let fy = camTgt.y - camPos.y;
2566
- let fz = camTgt.z - camPos.z;
2567
- let flen = Math.hypot(fx, fy, fz) || 1;
2568
- fx /= flen;
2569
- fy /= flen;
2570
- fz /= flen;
2571
- // Right = normalize(cross(forward, world-up)).
2572
- let rx = fy * camUpVec.z - fz * camUpVec.y;
2573
- let ry = fz * camUpVec.x - fx * camUpVec.z;
2574
- let rz = fx * camUpVec.y - fy * camUpVec.x;
2575
- let rlen = Math.hypot(rx, ry, rz) || 1;
2576
- rx /= rlen;
2577
- ry /= rlen;
2578
- rz /= rlen;
2579
- // True up = normalize(cross(right, forward)) — guaranteed
2580
- // perpendicular to both, defines screen-space vertical.
2581
- const ux = ry * fz - rz * fy;
2582
- const uy = rz * fx - rx * fz;
2583
- const uz = rx * fy - ry * fx;
2584
- this.symbolicTextPipeline.render(pass, viewProj, this.canvas.width, this.canvas.height, [rx, ry, rz], [ux, uy, uz]);
2585
- }
2529
+ // Section-plane gizmo, 2D section cap and every standalone 3D
2530
+ // overlay (annotation / alignment / grid / DXF / clash / symbolic
2531
+ // text). One draw call into the pass — see RendererOverlays.draw().
2532
+ this.overlays.draw(pass, {
2533
+ options,
2534
+ viewProj,
2535
+ modelBounds: this.getModelBounds(),
2536
+ camera: this.camera,
2537
+ canvasWidth: this.canvas.width,
2538
+ canvasHeight: this.canvas.height,
2539
+ });
2586
2540
  pass.end();
2587
2541
  const canRunPostPass = (contactEnabled || separationEnabled)
2588
2542
  && this.postProcessor !== null;
@@ -2654,17 +2608,30 @@ export class Renderer {
2654
2608
  errorScopePushed = false;
2655
2609
  this.drainErrorScope(device);
2656
2610
  }
2657
- this._renderErrorCount++;
2658
- this._lastRenderError = error instanceof Error ? error.message : String(error);
2659
- // Handle WebGPU errors (e.g., device lost, invalid state)
2660
- // Mark context as invalid so it gets reconfigured next frame
2661
- this.device.invalidateContext();
2662
- // Rate-limit error logging to avoid spam (max once per second)
2663
- const now = performance.now();
2664
- if (now - this.lastRenderErrorTime > this.RENDER_ERROR_THROTTLE_MS) {
2665
- this.lastRenderErrorTime = now;
2666
- console.warn('Render error (context will be reconfigured):', error);
2667
- }
2611
+ // Same policy as the outer catch since issue #2417 — a `DOMException`
2612
+ // from here is a device that died mid-frame, after
2613
+ // `getCurrentTexture()` had already succeeded, and it must latch
2614
+ // rather than degrade forever in silence.
2615
+ //
2616
+ // Safe to discriminate here because the encode region has no
2617
+ // healthy-device `DOMException` source (swept for #2417): its
2618
+ // `queue.writeBuffer` calls all use the 3-argument form over whole
2619
+ // typed-array views — plus one 5-argument call in
2620
+ // `point-cloud-uniforms.ts` whose offset and size are compile-time
2621
+ // constants matching its scratch array — so the spec's
2622
+ // `OperationError` preconditions are unreachable; the one
2623
+ // `copyExternalImageToTexture` copies the glyph atlas's own
2624
+ // never-externally-drawn canvas at its full fixed size, so neither
2625
+ // `SecurityError` nor a zero-size `OperationError` can arise; and
2626
+ // every other WebGPU call in the region (`createView`,
2627
+ // `createCommandEncoder`, `beginRenderPass`, the pass setters and
2628
+ // draws, `finish`, `submit`, `createBindGroup`) reports failure as
2629
+ // an asynchronous `GPUValidationError` through the error scope, not
2630
+ // as a throw. The region's real healthy-device failure is
2631
+ // `getOrCreatePartialBatch`'s `createBuffer({ mappedAtCreation:
2632
+ // true })`, and that throws a `RangeError` — which is exactly why
2633
+ // the discriminator keys on the TYPE and not on "a frame threw".
2634
+ this.containFrameThrow(error, 'encode');
2668
2635
  }
2669
2636
  }
2670
2637
  /**
@@ -2815,6 +2782,10 @@ export class Renderer {
2815
2782
  getScene() {
2816
2783
  return this.scene;
2817
2784
  }
2785
+ // ─── Overlay facade ──────────────────────────────────────────────────
2786
+ // The section-plane gizmo, the 2D section drawing/cap and the symbolic
2787
+ // annotation overlays live in `RendererOverlays` (issue #2425). These
2788
+ // methods are the published surface; the bodies moved with the state.
2818
2789
  /**
2819
2790
  * Upload 2D section drawing data for 3D overlay rendering.
2820
2791
  *
@@ -2833,38 +2804,13 @@ export class Renderer {
2833
2804
  uploadSection2DOverlay(polygons, lines, axis, position, // 0-100 percentage
2834
2805
  sectionRange, // Same storey-based range as section plane
2835
2806
  flipped = false, customPlane) {
2836
- if (!this.section2DOverlayRenderer)
2837
- return;
2838
- if (customPlane) {
2839
- // Custom-plane path: planePosition / axis are unused — the
2840
- // basis the cap shader needs travels in `customPlane`. We pass
2841
- // 0 for `planePosition` and the existing `axis` so the cardinal
2842
- // shader code path that callers depend on (e.g. legacy SVG
2843
- // export) keeps working when customPlane is omitted.
2844
- this.section2DOverlayRenderer.uploadDrawing(polygons, lines, axis, 0, flipped, customPlane);
2845
- return;
2846
- }
2847
- // Use EXACTLY same calculation as section plane in render() method:
2848
- // minVal = options.sectionPlane.min ?? boundsMin[axisIdx]
2849
- // maxVal = options.sectionPlane.max ?? boundsMax[axisIdx]
2850
- const axisIdx = axis === 'side' ? 'x' : axis === 'down' ? 'y' : 'z';
2851
- const modelBounds = this.getModelBounds();
2852
- // Allow upload if either sectionRange has both values, or modelBounds exists as fallback
2853
- const hasFullRange = sectionRange?.min !== undefined && sectionRange?.max !== undefined;
2854
- if (!hasFullRange && !modelBounds)
2855
- return;
2856
- const minVal = sectionRange?.min ?? modelBounds.min[axisIdx];
2857
- const maxVal = sectionRange?.max ?? modelBounds.max[axisIdx];
2858
- const planePosition = minVal + (position / 100) * (maxVal - minVal);
2859
- this.section2DOverlayRenderer.uploadDrawing(polygons, lines, axis, planePosition, flipped);
2807
+ this.overlays.uploadSection2DOverlay(polygons, lines, axis, position, sectionRange, flipped, customPlane);
2860
2808
  }
2861
2809
  /**
2862
2810
  * Clear the 2D section overlay
2863
2811
  */
2864
2812
  clearSection2DOverlay() {
2865
- if (this.section2DOverlayRenderer) {
2866
- this.section2DOverlayRenderer.clearGeometry();
2867
- }
2813
+ this.overlays.clearSection2DOverlay();
2868
2814
  }
2869
2815
  /**
2870
2816
  * Set the colour of the overlay lines (annotation / alignment / grid) and the
@@ -2873,11 +2819,7 @@ export class Renderer {
2873
2819
  * `SymbolicTextInput.color` on `uploadAnnotationTexts3D`.
2874
2820
  */
2875
2821
  setOverlayLineColor(color) {
2876
- // Persist on the Renderer so a pre-init call (and any later overlay
2877
- // re-creation) keeps the colour — init() re-applies this.overlayLineColor.
2878
- this.overlayLineColor = color;
2879
- this.section2DOverlayRenderer?.setOverlayLineColor(color);
2880
- this.requestRender();
2822
+ this.overlays.setOverlayLineColor(color);
2881
2823
  }
2882
2824
  /**
2883
2825
  * Upload pre-lifted 3D line-list vertices for the standalone annotation
@@ -2886,84 +2828,13 @@ export class Renderer {
2886
2828
  * Pass an empty Float32Array to clear.
2887
2829
  */
2888
2830
  uploadAnnotationLines3D(vertices) {
2889
- if (!this.section2DOverlayRenderer)
2890
- return;
2891
- this.section2DOverlayRenderer.uploadAnnotationLines3D(vertices);
2892
- // Contribute annotation extents to modelBounds + camera sceneBounds
2893
- // so an annotation-only model (no IfcProduct meshes — common for
2894
- // separate "annotation sheets") gets framed by Home / fit-to-view
2895
- // AND has correct near/far clipping. Without sceneBounds the camera
2896
- // frustum doesn't include the annotation cluster and they're clipped
2897
- // away even when the camera is pointed at them. Mirror the
2898
- // point-cloud upload path (`addPointClouds`, `setPointClouds`) which
2899
- // does the same thing.
2900
- this.expandModelBoundsWithFlatVertices(vertices, 3);
2901
- if (this.modelBounds)
2902
- this.camera.setSceneBounds(this.modelBounds);
2903
- this.requestRender();
2904
- }
2905
- /** Walks a flat `[x,y,z,x,y,z,...]` vertex buffer and either initialises
2906
- * or expands the cached `modelBounds` AABB. Used by the annotation
2907
- * overlay upload paths so symbolic-only models can still be framed.
2908
- *
2909
- * The geometry pipeline pre-seeds a placeholder `[-100, 100]` cube on
2910
- * every render when there are 0 meshes (so the section-plane slider
2911
- * always has a workable range). For an annotation-only model that
2912
- * fallback drowns out the much-smaller annotation cluster and a plain
2913
- * "expand" would no-op. We detect the placeholder by its exact symmetric
2914
- * signature and replace it with the actual annotation AABB instead. */
2915
- expandModelBoundsWithFlatVertices(positions, stride) {
2916
- if (positions.length === 0)
2917
- return;
2918
- const isPlaceholderCube = (b) => b.min.x === -100 && b.min.y === -100 && b.min.z === -100
2919
- && b.max.x === 100 && b.max.y === 100 && b.max.z === 100;
2920
- if (!this.modelBounds || isPlaceholderCube(this.modelBounds)) {
2921
- this.modelBounds = {
2922
- min: { x: Infinity, y: Infinity, z: Infinity },
2923
- max: { x: -Infinity, y: -Infinity, z: -Infinity },
2924
- };
2925
- }
2926
- let expanded = false;
2927
- for (let i = 0; i + 2 < positions.length; i += stride) {
2928
- const x = positions[i];
2929
- const y = positions[i + 1];
2930
- const z = positions[i + 2];
2931
- if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z))
2932
- continue;
2933
- if (x < this.modelBounds.min.x)
2934
- this.modelBounds.min.x = x;
2935
- if (y < this.modelBounds.min.y)
2936
- this.modelBounds.min.y = y;
2937
- if (z < this.modelBounds.min.z)
2938
- this.modelBounds.min.z = z;
2939
- if (x > this.modelBounds.max.x)
2940
- this.modelBounds.max.x = x;
2941
- if (y > this.modelBounds.max.y)
2942
- this.modelBounds.max.y = y;
2943
- if (z > this.modelBounds.max.z)
2944
- this.modelBounds.max.z = z;
2945
- expanded = true;
2946
- }
2947
- if (!expanded)
2948
- return;
2949
- // Guarantee non-degenerate extent on every axis so camera frustums
2950
- // don't collapse. 0.5 m margin matches what the section-plane fallback
2951
- // uses elsewhere in this file.
2952
- for (const axis of ['x', 'y', 'z']) {
2953
- if (this.modelBounds.max[axis] - this.modelBounds.min[axis] < 1e-3) {
2954
- this.modelBounds.max[axis] += 0.5;
2955
- this.modelBounds.min[axis] -= 0.5;
2956
- }
2957
- }
2831
+ this.overlays.uploadAnnotationLines3D(vertices);
2958
2832
  }
2959
2833
  /**
2960
2834
  * Clear the standalone annotation line overlay.
2961
2835
  */
2962
2836
  clearAnnotationLines3D() {
2963
- if (this.section2DOverlayRenderer) {
2964
- this.section2DOverlayRenderer.clearAnnotationLines3D();
2965
- this.requestRender();
2966
- }
2837
+ this.overlays.clearAnnotationLines3D();
2967
2838
  }
2968
2839
  /**
2969
2840
  * Upload IfcAlignment centerline segments as a flat [x,y,z,x,y,z,...]
@@ -2971,22 +2842,11 @@ export class Renderer {
2971
2842
  * to match IfcGrid / IfcAnnotation. Pass an empty Float32Array to clear.
2972
2843
  */
2973
2844
  uploadAlignmentLines3D(vertices) {
2974
- if (!this.section2DOverlayRenderer)
2975
- return;
2976
- this.section2DOverlayRenderer.uploadAlignmentLines3D(vertices);
2977
- // Frame alignment-only files the same way annotation overlays are
2978
- // framed (see uploadAnnotationLines3D).
2979
- this.expandModelBoundsWithFlatVertices(vertices, 3);
2980
- if (this.modelBounds)
2981
- this.camera.setSceneBounds(this.modelBounds);
2982
- this.requestRender();
2845
+ this.overlays.uploadAlignmentLines3D(vertices);
2983
2846
  }
2984
2847
  /** Clear the alignment centerline overlay. */
2985
2848
  clearAlignmentLines3D() {
2986
- if (this.section2DOverlayRenderer) {
2987
- this.section2DOverlayRenderer.clearAlignmentLines3D();
2988
- this.requestRender();
2989
- }
2849
+ this.overlays.clearAlignmentLines3D();
2990
2850
  }
2991
2851
  /**
2992
2852
  * Upload structural-grid (IfcGridAxis) segments as a flat [x,y,z,x,y,z,...]
@@ -2998,17 +2858,27 @@ export class Renderer {
2998
2858
  * grid axes routinely extend past the model envelope).
2999
2859
  */
3000
2860
  uploadGridLines3D(vertices) {
3001
- if (!this.section2DOverlayRenderer)
3002
- return;
3003
- this.section2DOverlayRenderer.uploadGridLines3D(vertices);
3004
- this.requestRender();
2861
+ this.overlays.uploadGridLines3D(vertices);
3005
2862
  }
3006
2863
  /** Clear the structural-grid overlay. */
3007
2864
  clearGridLines3D() {
3008
- if (this.section2DOverlayRenderer) {
3009
- this.section2DOverlayRenderer.clearGridLines3D();
3010
- this.requestRender();
3011
- }
2865
+ this.overlays.clearGridLines3D();
2866
+ }
2867
+ /**
2868
+ * Upload the DXF reference-layer's line paths as a flat
2869
+ * [x,y,z,x,y,z,...] line-list in world space (issue #2043, follow-up to
2870
+ * the 2D-only DXF underlay from #1782/#1929). Mirrors
2871
+ * `uploadGridLines3D`: a dedicated buffer so 3D DXF visibility is
2872
+ * independent of the 2D underlay's own toggle, and does NOT expand
2873
+ * model bounds/reframe the camera on upload — it's behind its own
2874
+ * visibility toggle, like grid axes. Pass an empty Float32Array to clear.
2875
+ */
2876
+ uploadDxfLines3D(vertices) {
2877
+ this.overlays.uploadDxfLines3D(vertices);
2878
+ }
2879
+ /** Clear the 3D DXF reference-layer overlay. */
2880
+ clearDxfLines3D() {
2881
+ this.overlays.clearDxfLines3D();
3012
2882
  }
3013
2883
  /**
3014
2884
  * Show (or clear) the clash-overlap box: the wireframe AABB of a focused
@@ -3017,16 +2887,7 @@ export class Renderer {
3017
2887
  * clear. `min`/`max` are world-space corners (clash works in world frame).
3018
2888
  */
3019
2889
  setClashOverlapBox(box) {
3020
- if (!this.section2DOverlayRenderer)
3021
- return;
3022
- if (!box) {
3023
- this.section2DOverlayRenderer.clearClashBoxLines3D();
3024
- this.requestRender();
3025
- return;
3026
- }
3027
- this.section2DOverlayRenderer.setClashBoxLineColor(box.color);
3028
- this.section2DOverlayRenderer.uploadClashBoxLines3D(aabbEdgeLineList(box.min, box.max));
3029
- this.requestRender();
2890
+ this.overlays.setClashOverlapBox(box);
3030
2891
  }
3031
2892
  /**
3032
2893
  * Draw the focused clash's CONTACT geometry as 3D line segments — the real
@@ -3036,73 +2897,27 @@ export class Renderer {
3036
2897
  * buffer, so only one of this / setClashOverlapBox is shown at a time.
3037
2898
  */
3038
2899
  setClashContactLines(lines) {
3039
- if (!this.section2DOverlayRenderer)
3040
- return;
3041
- if (!lines || lines.vertices.length === 0) {
3042
- this.section2DOverlayRenderer.clearClashBoxLines3D();
3043
- this.requestRender();
3044
- return;
3045
- }
3046
- this.section2DOverlayRenderer.setClashBoxLineColor(lines.color);
3047
- this.section2DOverlayRenderer.uploadClashBoxLines3D(lines.vertices);
3048
- this.requestRender();
2900
+ this.overlays.setClashContactLines(lines);
3049
2901
  }
3050
2902
  /**
3051
2903
  * Upload filled IfcAnnotation regions for the symbolic overlay
3052
2904
  * (issue #653). Pass an empty array to clear.
3053
2905
  */
3054
2906
  uploadAnnotationFills3D(fills) {
3055
- if (!this.symbolicFillPipeline)
3056
- return;
3057
- this.symbolicFillPipeline.upload(fills);
3058
- // Contribute fill extents to modelBounds — see uploadAnnotationLines3D.
3059
- for (const fill of fills) {
3060
- const pts = fill.points;
3061
- if (pts.length === 0)
3062
- continue;
3063
- // points are flat [x,z,x,z,...]; lift to (x, fill.worldY, z) per
3064
- // vertex so we expand bounds in the same world space the renderer draws in.
3065
- const lifted = new Float32Array((pts.length / 2) * 3);
3066
- for (let i = 0, j = 0; i < pts.length; i += 2, j += 3) {
3067
- lifted[j] = pts[i];
3068
- lifted[j + 1] = fill.worldY;
3069
- lifted[j + 2] = pts[i + 1];
3070
- }
3071
- this.expandModelBoundsWithFlatVertices(lifted, 3);
3072
- }
3073
- if (this.modelBounds)
3074
- this.camera.setSceneBounds(this.modelBounds);
3075
- this.requestRender();
2907
+ this.overlays.uploadAnnotationFills3D(fills);
3076
2908
  }
3077
2909
  /**
3078
2910
  * Upload IfcAnnotation text labels for the symbolic overlay
3079
2911
  * (issue #653). Pass an empty array to clear.
3080
2912
  */
3081
2913
  uploadAnnotationTexts3D(texts) {
3082
- if (!this.symbolicTextPipeline)
3083
- return;
3084
- this.symbolicTextPipeline.upload(texts);
3085
- // Text origins are single points; pack them into a flat buffer and
3086
- // expand bounds. Glyph extents are small enough that origin-only
3087
- // suffices for framing.
3088
- if (texts.length > 0) {
3089
- const buf = new Float32Array(texts.length * 3);
3090
- for (let i = 0; i < texts.length; i++) {
3091
- buf[i * 3 + 0] = texts[i].worldPos[0];
3092
- buf[i * 3 + 1] = texts[i].worldPos[1];
3093
- buf[i * 3 + 2] = texts[i].worldPos[2];
3094
- }
3095
- this.expandModelBoundsWithFlatVertices(buf, 3);
3096
- if (this.modelBounds)
3097
- this.camera.setSceneBounds(this.modelBounds);
3098
- }
3099
- this.requestRender();
2914
+ this.overlays.uploadAnnotationTexts3D(texts);
3100
2915
  }
3101
2916
  /**
3102
2917
  * Check if 2D section overlay has geometry to render
3103
2918
  */
3104
2919
  hasSection2DOverlay() {
3105
- return this.section2DOverlayRenderer?.hasGeometry() ?? false;
2920
+ return this.overlays.hasSection2DOverlay();
3106
2921
  }
3107
2922
  /**
3108
2923
  * Get render pipeline (for batching)
@@ -3193,18 +3008,9 @@ export class Renderer {
3193
3008
  this.edlPass = null;
3194
3009
  this.skyPass?.destroy();
3195
3010
  this.skyPass = null;
3196
- // Section-plane renderers
3197
- this.sectionPlaneRenderer?.destroy();
3198
- this.sectionPlaneRenderer = null;
3199
- this.section2DOverlayRenderer?.dispose();
3200
- this.section2DOverlayRenderer = null;
3201
- // Symbolic annotation overlay pipelines own their own GPU buffers,
3202
- // sampler, and atlas texture — recreating the viewer without
3203
- // releasing them leaks resources on every reload.
3204
- this.symbolicFillPipeline?.destroy();
3205
- this.symbolicFillPipeline = null;
3206
- this.symbolicTextPipeline?.destroy();
3207
- this.symbolicTextPipeline = null;
3011
+ // Section-plane gizmo, 2D section overlay and the symbolic annotation
3012
+ // pipelines — see RendererOverlays.destroy().
3013
+ this.overlays.destroy();
3208
3014
  // Point cloud GPU resources
3209
3015
  this.pointCloudRenderer?.clear();
3210
3016
  this.pointCloudRenderer = null;