@ifc-lite/renderer 1.43.0 → 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 (85) 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-framing.d.ts +86 -0
  14. package/dist/camera-framing.d.ts.map +1 -0
  15. package/dist/camera-framing.js +230 -0
  16. package/dist/camera-framing.js.map +1 -0
  17. package/dist/camera-guards.d.ts +148 -0
  18. package/dist/camera-guards.d.ts.map +1 -0
  19. package/dist/camera-guards.js +150 -0
  20. package/dist/camera-guards.js.map +1 -0
  21. package/dist/camera-matrices.d.ts +14 -0
  22. package/dist/camera-matrices.d.ts.map +1 -0
  23. package/dist/camera-matrices.js +146 -0
  24. package/dist/camera-matrices.js.map +1 -0
  25. package/dist/camera-preset-view.d.ts +56 -0
  26. package/dist/camera-preset-view.d.ts.map +1 -0
  27. package/dist/camera-preset-view.js +180 -0
  28. package/dist/camera-preset-view.js.map +1 -0
  29. package/dist/camera-projection.d.ts +1 -1
  30. package/dist/camera-projection.d.ts.map +1 -1
  31. package/dist/camera-projection.js +83 -30
  32. package/dist/camera-projection.js.map +1 -1
  33. package/dist/camera-state.d.ts +62 -0
  34. package/dist/camera-state.d.ts.map +1 -0
  35. package/dist/camera-state.js +5 -0
  36. package/dist/camera-state.js.map +1 -0
  37. package/dist/camera.d.ts +22 -11
  38. package/dist/camera.d.ts.map +1 -1
  39. package/dist/camera.js +75 -120
  40. package/dist/camera.js.map +1 -1
  41. package/dist/environment.d.ts.map +1 -1
  42. package/dist/environment.js +23 -1
  43. package/dist/environment.js.map +1 -1
  44. package/dist/index.d.ts +154 -8
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +294 -45
  47. package/dist/index.js.map +1 -1
  48. package/dist/math.d.ts +49 -1
  49. package/dist/math.d.ts.map +1 -1
  50. package/dist/math.js +122 -67
  51. package/dist/math.js.map +1 -1
  52. package/dist/renderer-symbolic-overlays.d.ts.map +1 -1
  53. package/dist/renderer-symbolic-overlays.js +20 -25
  54. package/dist/renderer-symbolic-overlays.js.map +1 -1
  55. package/dist/scene-geometry.d.ts +32 -0
  56. package/dist/scene-geometry.d.ts.map +1 -1
  57. package/dist/scene-geometry.js +66 -0
  58. package/dist/scene-geometry.js.map +1 -1
  59. package/dist/scene.d.ts.map +1 -1
  60. package/dist/scene.js +22 -97
  61. package/dist/scene.js.map +1 -1
  62. package/dist/section-2d-lift.d.ts +105 -0
  63. package/dist/section-2d-lift.d.ts.map +1 -0
  64. package/dist/section-2d-lift.js +162 -0
  65. package/dist/section-2d-lift.js.map +1 -0
  66. package/dist/section-2d-line-buffer.d.ts +58 -0
  67. package/dist/section-2d-line-buffer.d.ts.map +1 -0
  68. package/dist/section-2d-line-buffer.js +109 -0
  69. package/dist/section-2d-line-buffer.js.map +1 -0
  70. package/dist/section-2d-overlay.d.ts +28 -77
  71. package/dist/section-2d-overlay.d.ts.map +1 -1
  72. package/dist/section-2d-overlay.js +161 -593
  73. package/dist/section-2d-overlay.js.map +1 -1
  74. package/dist/section-plane-basis.d.ts +22 -3
  75. package/dist/section-plane-basis.d.ts.map +1 -1
  76. package/dist/section-plane-basis.js +51 -10
  77. package/dist/section-plane-basis.js.map +1 -1
  78. package/dist/section-plane.d.ts.map +1 -1
  79. package/dist/section-plane.js +10 -1
  80. package/dist/section-plane.js.map +1 -1
  81. package/dist/shaders/section-2d-overlay.wgsl.d.ts +102 -0
  82. package/dist/shaders/section-2d-overlay.wgsl.d.ts.map +1 -0
  83. package/dist/shaders/section-2d-overlay.wgsl.js +281 -0
  84. package/dist/shaders/section-2d-overlay.wgsl.js.map +1 -0
  85. package/package.json +3 -2
package/dist/index.js CHANGED
@@ -11,6 +11,13 @@ 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
23
  // IfcAnnotation overlay pipelines (3D world-space). Self-contained — caller
@@ -47,7 +54,7 @@ import { RenderPipeline } from './pipeline.js';
47
54
  import { Camera } from './camera.js';
48
55
  import { Scene } from './scene.js';
49
56
  import { Picker } from './picker.js';
50
- import { MathUtils } from './math.js';
57
+ import { MathUtils, viewBasis } from './math.js';
51
58
  import { FrustumUtils } from '@ifc-lite/spatial';
52
59
  import { VisualEnhancementResolver } from './visual-enhancement.js';
53
60
  import { packClipBox } from './clip-box.js';
@@ -127,6 +134,33 @@ function computeBvhFingerprint(meshes) {
127
134
  function isDeviceLossThrow(error) {
128
135
  return typeof DOMException !== 'undefined' && error instanceof DOMException;
129
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
+ }
130
164
  /**
131
165
  * Main renderer class
132
166
  */
@@ -165,9 +199,28 @@ export class Renderer {
165
199
  highQuality: true,
166
200
  };
167
201
  pointCloudRenderer = null;
168
- /** 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
+ */
169
209
  ready = false;
170
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;
171
224
  /**
172
225
  * The tail of the `init()` queue. `init()` chains onto this rather than
173
226
  * running immediately, so two overlapping calls cannot both walk past the
@@ -177,12 +230,33 @@ export class Renderer {
177
230
  * caller) so one failure does not deadlock every later call.
178
231
  */
179
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;
180
253
  /**
181
254
  * Set once the GPU device is lost for a non-intentional reason (driver
182
255
  * reset / VRAM exhaustion — see `WebGPUDevice`). Every GPU resource is then
183
256
  * dead, so `render()` becomes a no-op (it would only spew validation errors)
184
- * until the host re-initialises the renderer. Consumers learn of this via
185
- * `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.
186
260
  *
187
261
  * Two signals set it: the async `device.lost` promise (Chromium), and a
188
262
  * frame throwing a `DOMException` out of `render()` (Safari 26.5, which
@@ -192,6 +266,29 @@ export class Renderer {
192
266
  * device, and it must cost one frame, not the session.
193
267
  */
194
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;
195
292
  /** Retained so a listener registered AFTER the loss still learns of it. */
196
293
  deviceLostInfo = null;
197
294
  deviceLostListeners = new Set();
@@ -353,31 +450,62 @@ export class Renderer {
353
450
  * rather than adding a second, differently-shaped rule.
354
451
  */
355
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;
356
465
  // A previous init that REJECTED must not block the next one, so the
357
466
  // stored link swallows the outcome. The caller still receives `run`, so
358
467
  // rejections continue to surface exactly as before.
359
- const run = this.initChain.then(() => this.initOnce(), () => this.initOnce());
468
+ const run = this.initChain.then(() => this.initOnce(generation), () => this.initOnce(generation));
360
469
  this.initChain = run.then(() => undefined, () => undefined);
361
470
  return run;
362
471
  }
363
- async initOnce() {
472
+ async initOnce(generation) {
364
473
  // `pipeline` is the marker for "a previous init() completed": it is
365
474
  // assigned unconditionally there and nulled by destroy().
366
475
  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;
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();
372
482
  }
373
483
  // Clear the lost flag so a re-init (destroy()+init() on the same instance)
374
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.
375
490
  this.deviceLost = false;
376
491
  // Subscribe before the device exists so a loss during the first frames
377
492
  // is never missed — the handler is only invoked when `device.lost`
378
493
  // actually resolves (a real fault), long after init in practice.
379
494
  this.device.onDeviceLost((info) => this.handleDeviceLost(info));
380
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
+ }
381
509
  // Get canvas dimensions (use pixel dimensions if set, otherwise use CSS dimensions)
382
510
  // and clamp to the GPU's max 2D texture dimension so the initial pipeline allocations
383
511
  // can't overflow on tall/wide layouts (see render() for the per-frame clamp).
@@ -441,7 +569,7 @@ export class Renderer {
441
569
  // from the GPU click-pick provider above, since raycastScene* is
442
570
  // synchronous CPU code while pick() is an async GPU readback.
443
571
  this.raycastEngine.setPointCloudProvider(() => this.pointCloudRenderer?.getRayQuerySources() ?? []);
444
- this.markReady();
572
+ this.markReady(generation);
445
573
  }
446
574
  /**
447
575
  * Resolves once `init()` has finished and the GPU device + point-cloud
@@ -450,25 +578,80 @@ export class Renderer {
450
578
  * the async WebGPU init resolves — should `await renderer.whenReady()`
451
579
  * before `beginPointCloudStream`, which otherwise throws
452
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.
453
607
  */
454
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
+ }
455
616
  if (this.ready)
456
617
  return Promise.resolve();
457
- return new Promise((resolve) => { this.readyWaiters.push(resolve); });
458
- }
459
- 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;
460
628
  this.ready = true;
461
629
  const waiters = this.readyWaiters;
462
630
  this.readyWaiters = [];
463
631
  for (const w of waiters)
464
- 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);
465
646
  }
466
647
  /**
467
648
  * Subscribe to non-intentional GPU device loss (driver reset / VRAM
468
649
  * exhaustion — NOT an intentional `destroy()`). Fired at most once per
469
- * device. After it fires, `render()` is a no-op until the renderer is
470
- * re-initialised, so the typical response is to dispose this renderer and
471
- * 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.
472
655
  *
473
656
  * Camera and model state live on the CPU (JS) and survive device loss, so a
474
657
  * reload restores the model at its current orientation — the loss is a GPU
@@ -522,8 +705,17 @@ export class Renderer {
522
705
  if (this.deviceLost)
523
706
  return;
524
707
  this.deviceLost = true;
708
+ this.deviceLostGeneration = this.initGeneration;
525
709
  this.deviceLostInfo = info;
526
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());
527
719
  for (const listener of this.deviceLostListeners) {
528
720
  try {
529
721
  listener(info);
@@ -1746,31 +1938,22 @@ export class Renderer {
1746
1938
  sampleCount: this.pipeline.getSampleCount(),
1747
1939
  }, skyShaderSource);
1748
1940
  }
1749
- const camPos = this.camera.getPosition();
1750
- const camTgt = this.camera.getTarget();
1751
- const camUp = this.camera.getUp();
1752
- let fx = camTgt.x - camPos.x;
1753
- let fy = camTgt.y - camPos.y;
1754
- let fz = camTgt.z - camPos.z;
1755
- const flen = Math.hypot(fx, fy, fz) || 1;
1756
- fx /= flen;
1757
- fy /= flen;
1758
- fz /= flen;
1759
- // Right = normalize(cross(forward, up)); true up = cross(right, forward).
1760
- let rx = fy * camUp.z - fz * camUp.y;
1761
- let ry = fz * camUp.x - fx * camUp.z;
1762
- let rz = fx * camUp.y - fy * camUp.x;
1763
- const rlen = Math.hypot(rx, ry, rz) || 1;
1764
- rx /= rlen;
1765
- ry /= rlen;
1766
- rz /= rlen;
1767
- const ux = ry * fz - rz * fy;
1768
- const uy = rz * fx - rx * fz;
1769
- 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());
1770
1953
  this.skyPass.draw(pass, {
1771
- forward: [fx, fy, fz],
1772
- right: [rx, ry, rz],
1773
- 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],
1774
1957
  fovY: this.camera.getFOV(),
1775
1958
  aspect: this.canvas.height > 0 ? this.canvas.width / this.canvas.height : 1,
1776
1959
  }, environment);
@@ -2772,6 +2955,17 @@ export class Renderer {
2772
2955
  * Resize canvas
2773
2956
  */
2774
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;
2775
2969
  this.canvas.width = width;
2776
2970
  this.canvas.height = height;
2777
2971
  this.camera.setAspect(width / height);
@@ -2926,10 +3120,27 @@ export class Renderer {
2926
3120
  return this.pipeline;
2927
3121
  }
2928
3122
  /**
2929
- * 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.
2930
3141
  */
2931
3142
  isReady() {
2932
- return this.device.isInitialized() && this.pipeline !== null;
3143
+ return this.ready && !this.deviceLost && this.device.isInitialized() && this.pipeline !== null;
2933
3144
  }
2934
3145
  /**
2935
3146
  * Get the GPU device (returns null if not initialized, or if the device
@@ -2985,8 +3196,46 @@ export class Renderer {
2985
3196
  * post-processing buffers, section-plane renderers, and snap caches.
2986
3197
  * After calling this method the renderer is no longer usable.
2987
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.
2988
3222
  */
2989
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;
2990
3239
  // Scene mesh GPU buffers
2991
3240
  this.scene.clear();
2992
3241
  // Re-arm the section-bounds diagnostic log for the next model.