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