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