@ifc-lite/renderer 1.17.0 → 1.20.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/deviation/deviation-pipeline.d.ts +48 -0
- package/dist/deviation/deviation-pipeline.d.ts.map +1 -0
- package/dist/deviation/deviation-pipeline.js +163 -0
- package/dist/deviation/deviation-pipeline.js.map +1 -0
- package/dist/deviation/deviation-shader.wgsl.d.ts +23 -0
- package/dist/deviation/deviation-shader.wgsl.d.ts.map +1 -0
- package/dist/deviation/deviation-shader.wgsl.js +237 -0
- package/dist/deviation/deviation-shader.wgsl.js.map +1 -0
- package/dist/deviation/triangle-bvh.d.ts +58 -0
- package/dist/deviation/triangle-bvh.d.ts.map +1 -0
- package/dist/deviation/triangle-bvh.js +255 -0
- package/dist/deviation/triangle-bvh.js.map +1 -0
- package/dist/device.d.ts +3 -0
- package/dist/device.d.ts.map +1 -1
- package/dist/device.js +10 -0
- package/dist/device.js.map +1 -1
- package/dist/edl-pass.d.ts +52 -0
- package/dist/edl-pass.d.ts.map +1 -0
- package/dist/edl-pass.js +204 -0
- package/dist/edl-pass.js.map +1 -0
- package/dist/index.d.ts +175 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +676 -110
- package/dist/index.js.map +1 -1
- package/dist/picker.d.ts +40 -3
- package/dist/picker.d.ts.map +1 -1
- package/dist/picker.js +211 -59
- package/dist/picker.js.map +1 -1
- package/dist/picking-manager.d.ts +32 -1
- package/dist/picking-manager.d.ts.map +1 -1
- package/dist/picking-manager.js +53 -1
- package/dist/picking-manager.js.map +1 -1
- package/dist/point-picker.d.ts +61 -0
- package/dist/point-picker.d.ts.map +1 -0
- package/dist/point-picker.js +223 -0
- package/dist/point-picker.js.map +1 -0
- package/dist/pointcloud/point-cloud-node.d.ts +63 -0
- package/dist/pointcloud/point-cloud-node.d.ts.map +1 -0
- package/dist/pointcloud/point-cloud-node.js +149 -0
- package/dist/pointcloud/point-cloud-node.js.map +1 -0
- package/dist/pointcloud/point-cloud-renderer.d.ts +166 -0
- package/dist/pointcloud/point-cloud-renderer.d.ts.map +1 -0
- package/dist/pointcloud/point-cloud-renderer.js +293 -0
- package/dist/pointcloud/point-cloud-renderer.js.map +1 -0
- package/dist/pointcloud/point-cloud-uniforms.d.ts +36 -0
- package/dist/pointcloud/point-cloud-uniforms.d.ts.map +1 -0
- package/dist/pointcloud/point-cloud-uniforms.js +89 -0
- package/dist/pointcloud/point-cloud-uniforms.js.map +1 -0
- package/dist/pointcloud/point-pipeline.d.ts +27 -0
- package/dist/pointcloud/point-pipeline.d.ts.map +1 -0
- package/dist/pointcloud/point-pipeline.js +126 -0
- package/dist/pointcloud/point-pipeline.js.map +1 -0
- package/dist/pointcloud/point-shader.wgsl.d.ts +22 -0
- package/dist/pointcloud/point-shader.wgsl.d.ts.map +1 -0
- package/dist/pointcloud/point-shader.wgsl.js +288 -0
- package/dist/pointcloud/point-shader.wgsl.js.map +1 -0
- package/dist/scene.d.ts +11 -0
- package/dist/scene.d.ts.map +1 -1
- package/dist/scene.js +21 -0
- package/dist/scene.js.map +1 -1
- package/dist/section-2d-overlay.d.ts +24 -5
- package/dist/section-2d-overlay.d.ts.map +1 -1
- package/dist/section-2d-overlay.js +42 -13
- package/dist/section-2d-overlay.js.map +1 -1
- package/dist/section-plane-basis.d.ts +64 -0
- package/dist/section-plane-basis.d.ts.map +1 -0
- package/dist/section-plane-basis.js +86 -0
- package/dist/section-plane-basis.js.map +1 -0
- package/dist/section-plane.d.ts +18 -0
- package/dist/section-plane.d.ts.map +1 -1
- package/dist/section-plane.js +89 -6
- package/dist/section-plane.js.map +1 -1
- package/dist/types.d.ts +35 -4
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -16,6 +16,7 @@ export { Section2DOverlayRenderer } from './section-2d-overlay.js';
|
|
|
16
16
|
// is now rendered by Section2DOverlayRenderer's fill pass; this module just
|
|
17
17
|
// holds the styling primitives shared with the store and UI.
|
|
18
18
|
export { DEFAULT_CAP_STYLE, HATCH_PATTERN_IDS } from './section-cap-style.js';
|
|
19
|
+
export { planeBasis, nearestCardinalAxis } from './section-plane-basis.js';
|
|
19
20
|
export { Raycaster } from './raycaster.js';
|
|
20
21
|
export { SnapDetector, SnapType } from './snap-detector.js';
|
|
21
22
|
export { BVH } from './bvh.js';
|
|
@@ -26,6 +27,10 @@ export { ZeroCopyGpuUploader, createZeroCopyUploader, } from './zero-copy-upload
|
|
|
26
27
|
// Extracted manager classes
|
|
27
28
|
export { PickingManager } from './picking-manager.js';
|
|
28
29
|
export { RaycastEngine } from './raycast-engine.js';
|
|
30
|
+
export { PointPicker, decodePickSample } from './point-picker.js';
|
|
31
|
+
// Point cloud rendering (Phase 0: IFCx inline; Phase 1+: streaming LAS/LAZ)
|
|
32
|
+
export { PointCloudRenderer } from './pointcloud/point-cloud-renderer.js';
|
|
33
|
+
export { PointRenderPipeline } from './pointcloud/point-pipeline.js';
|
|
29
34
|
import { WebGPUDevice } from './device.js';
|
|
30
35
|
import { RenderPipeline, InstancedRenderPipeline } from './pipeline.js';
|
|
31
36
|
import { Camera } from './camera.js';
|
|
@@ -40,8 +45,31 @@ import { DEFAULT_CAP_STYLE, HATCH_PATTERN_IDS } from './section-cap-style.js';
|
|
|
40
45
|
import { PickingManager } from './picking-manager.js';
|
|
41
46
|
import { RaycastEngine } from './raycast-engine.js';
|
|
42
47
|
import { PostProcessor } from './post-processor.js';
|
|
48
|
+
import { EdlPass } from './edl-pass.js';
|
|
49
|
+
import { PointCloudRenderer } from './pointcloud/point-cloud-renderer.js';
|
|
50
|
+
import { DeviationPipeline } from './deviation/deviation-pipeline.js';
|
|
51
|
+
import { buildTriangleBVH } from './deviation/triangle-bvh.js';
|
|
43
52
|
const MAX_ENCODED_ENTITY_ID = 0xFFFFFF;
|
|
44
53
|
let warnedEntityIdRange = false;
|
|
54
|
+
/**
|
|
55
|
+
* Build a deterministic fingerprint of the BVH input mesh set so
|
|
56
|
+
* `Renderer.computeDeviations` can skip the rebuild when the source
|
|
57
|
+
* geometry hasn't changed. Folds in expressId / modelIndex / position
|
|
58
|
+
* + index lengths per mesh so two distinct mesh sets that happen to
|
|
59
|
+
* share the same aggregate position-length total can't collide on the
|
|
60
|
+
* same fingerprint and reuse a stale BVH.
|
|
61
|
+
*/
|
|
62
|
+
function computeBvhFingerprint(meshes) {
|
|
63
|
+
const parts = [String(meshes.length)];
|
|
64
|
+
for (const m of meshes) {
|
|
65
|
+
const id = m.expressId ?? -1;
|
|
66
|
+
const mi = m.modelIndex ?? -1;
|
|
67
|
+
const posLen = m.positions?.length ?? 0;
|
|
68
|
+
const idxLen = m.indices?.length ?? 0;
|
|
69
|
+
parts.push(`${id}:${mi}:${posLen}:${idxLen}`);
|
|
70
|
+
}
|
|
71
|
+
return parts.join('|');
|
|
72
|
+
}
|
|
45
73
|
/**
|
|
46
74
|
* Main renderer class
|
|
47
75
|
*/
|
|
@@ -56,6 +84,23 @@ export class Renderer {
|
|
|
56
84
|
sectionPlaneRenderer = null;
|
|
57
85
|
section2DOverlayRenderer = null;
|
|
58
86
|
postProcessor = null;
|
|
87
|
+
edlPass = null;
|
|
88
|
+
edlOptions = {
|
|
89
|
+
enabled: false,
|
|
90
|
+
strength: 1,
|
|
91
|
+
radiusPx: 1,
|
|
92
|
+
highQuality: true,
|
|
93
|
+
};
|
|
94
|
+
pointCloudRenderer = null;
|
|
95
|
+
deviationPipeline = null;
|
|
96
|
+
/**
|
|
97
|
+
* Cache of which mesh-set the BVH was built from. We rebuild on
|
|
98
|
+
* `computeDeviations` only when the cached "fingerprint" misses,
|
|
99
|
+
* so re-running deviation against the same model is a fast
|
|
100
|
+
* dispatch — the BVH is multi-second on big BIMs and we don't
|
|
101
|
+
* want to pay that on every slider drag.
|
|
102
|
+
*/
|
|
103
|
+
deviationBvhFingerprint = null;
|
|
59
104
|
visualEnhancementState = {
|
|
60
105
|
enabled: true,
|
|
61
106
|
edgeContrast: { enabled: true, intensity: 1.0 },
|
|
@@ -70,6 +115,11 @@ export class Renderer {
|
|
|
70
115
|
// Error rate limiting (log at most once per second)
|
|
71
116
|
lastRenderErrorTime = 0;
|
|
72
117
|
RENDER_ERROR_THROTTLE_MS = 1000;
|
|
118
|
+
// Diagnostic counters for mobile debugging
|
|
119
|
+
_renderCallCount = 0;
|
|
120
|
+
_renderSkipCount = 0;
|
|
121
|
+
_renderErrorCount = 0;
|
|
122
|
+
_lastRenderError = '';
|
|
73
123
|
// Dirty flag: set by requestRender(), consumed by the animation loop.
|
|
74
124
|
// Centralises all render scheduling — callers never call render() directly.
|
|
75
125
|
_renderRequested = false;
|
|
@@ -108,14 +158,335 @@ export class Renderer {
|
|
|
108
158
|
this.picker = new Picker(this.device, width, height);
|
|
109
159
|
this.sectionPlaneRenderer = new SectionPlaneRenderer(this.device.getDevice(), this.device.getFormat(), this.pipeline.getSampleCount());
|
|
110
160
|
this.section2DOverlayRenderer = new Section2DOverlayRenderer(this.device.getDevice(), this.device.getFormat(), this.pipeline.getSampleCount());
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
161
|
+
// PostProcessor is optional — if it fails (e.g. mobile GPU lacking
|
|
162
|
+
// depth TEXTURE_BINDING), rendering still works without post-processing.
|
|
163
|
+
try {
|
|
164
|
+
this.postProcessor = new PostProcessor(this.device, {
|
|
165
|
+
enableContactShading: true,
|
|
166
|
+
contactRadius: 1.0,
|
|
167
|
+
contactIntensity: 0.3,
|
|
168
|
+
}, this.pipeline.getSampleCount());
|
|
169
|
+
}
|
|
170
|
+
catch (e) {
|
|
171
|
+
console.warn('[Renderer] PostProcessor init failed (post-processing disabled):', e);
|
|
172
|
+
this.postProcessor = null;
|
|
173
|
+
}
|
|
174
|
+
this.pointCloudRenderer = new PointCloudRenderer(this.device.getDevice(), this.device.getFormat(), 'depth24plus-stencil8', this.pipeline.getSampleCount());
|
|
175
|
+
// Compute pipeline for the BIM↔scan deviation heatmap. Lazily
|
|
176
|
+
// owns the per-triangle BVH GPU buffers; idle until the first
|
|
177
|
+
// `computeDeviations` call.
|
|
178
|
+
this.deviationPipeline = new DeviationPipeline(this.device.getDevice());
|
|
179
|
+
this.edlPass = new EdlPass(this.device, this.pipeline.getSampleCount());
|
|
116
180
|
this.camera.setAspect(width / height);
|
|
117
181
|
// Update picking manager with initialized picker
|
|
118
182
|
this.pickingManager.setPicker(this.picker);
|
|
183
|
+
// Provide a snapshot of pickable point nodes per pick. The
|
|
184
|
+
// sizing must mirror the live splat shader so click hit-testing
|
|
185
|
+
// matches what the user actually sees on screen.
|
|
186
|
+
this.pickingManager.setPointPickProvider(() => {
|
|
187
|
+
const pcr = this.pointCloudRenderer;
|
|
188
|
+
if (!pcr || !pcr.hasAssets())
|
|
189
|
+
return null;
|
|
190
|
+
const opts = pcr.getOptions();
|
|
191
|
+
const sizeMode = opts.sizeMode === 'fixed-px' ? 0 : opts.sizeMode === 'adaptive-world' ? 1 : 2;
|
|
192
|
+
return {
|
|
193
|
+
nodes: pcr.getPickNodes(),
|
|
194
|
+
sizing: {
|
|
195
|
+
sizeMode: sizeMode,
|
|
196
|
+
worldRadius: opts.worldRadius,
|
|
197
|
+
pointSizePx: opts.pointSize,
|
|
198
|
+
clickTolerancePx: 2,
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Replace all loaded point clouds with `assets`.
|
|
205
|
+
*
|
|
206
|
+
* Phase 0 entry point — single-chunk inline assets from IFCx
|
|
207
|
+
* (`pcd::base64`, `points::array`, `points::base64`). Future phases
|
|
208
|
+
* accept streaming sources via a different overload.
|
|
209
|
+
*/
|
|
210
|
+
setPointClouds(assets) {
|
|
211
|
+
if (!this.pointCloudRenderer) {
|
|
212
|
+
throw new Error('Renderer not initialized. Call init() first.');
|
|
213
|
+
}
|
|
214
|
+
this.pointCloudRenderer.setAssets(assets);
|
|
215
|
+
// Replace, not append — bounds may have shrunk (e.g. an IFCx
|
|
216
|
+
// reload with a smaller scan). `expandModelBoundsForPointClouds`
|
|
217
|
+
// alone only grows; recompute from scratch to keep
|
|
218
|
+
// fit-to-view + section-plane sliders accurate.
|
|
219
|
+
this.recomputeModelBounds();
|
|
220
|
+
this.camera.setSceneBounds(this.modelBounds);
|
|
221
|
+
this.requestRender();
|
|
222
|
+
}
|
|
223
|
+
/** Append additional point clouds without clearing existing ones. */
|
|
224
|
+
addPointClouds(assets) {
|
|
225
|
+
if (!this.pointCloudRenderer) {
|
|
226
|
+
throw new Error('Renderer not initialized. Call init() first.');
|
|
227
|
+
}
|
|
228
|
+
for (const asset of assets) {
|
|
229
|
+
this.pointCloudRenderer.addAsset(asset);
|
|
230
|
+
}
|
|
231
|
+
this.expandModelBoundsForPointClouds();
|
|
232
|
+
this.camera.setSceneBounds(this.modelBounds);
|
|
233
|
+
this.requestRender();
|
|
234
|
+
}
|
|
235
|
+
/** Total number of point cloud assets currently uploaded. */
|
|
236
|
+
getPointCloudAssetCount() {
|
|
237
|
+
return this.pointCloudRenderer?.getNodeCount() ?? 0;
|
|
238
|
+
}
|
|
239
|
+
/** Total number of points across all point cloud assets. */
|
|
240
|
+
getPointCloudPointCount() {
|
|
241
|
+
return this.pointCloudRenderer?.getPointCount() ?? 0;
|
|
242
|
+
}
|
|
243
|
+
/** Drop all point cloud GPU resources. */
|
|
244
|
+
clearPointClouds() {
|
|
245
|
+
this.pointCloudRenderer?.clear();
|
|
246
|
+
this.recomputeModelBounds();
|
|
247
|
+
this.camera.setSceneBounds(this.modelBounds);
|
|
248
|
+
this.requestRender();
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Streaming entry: open an empty asset that will receive chunks via
|
|
252
|
+
* `appendPointCloudChunk`. Call `endPointCloudStream` when no more
|
|
253
|
+
* chunks will arrive (currently a no-op but kept for symmetry).
|
|
254
|
+
*/
|
|
255
|
+
beginPointCloudStream(meta) {
|
|
256
|
+
if (!this.pointCloudRenderer) {
|
|
257
|
+
throw new Error('Renderer not initialized. Call init() first.');
|
|
258
|
+
}
|
|
259
|
+
return this.pointCloudRenderer.beginAsset(meta);
|
|
260
|
+
}
|
|
261
|
+
appendPointCloudChunk(handle, chunk) {
|
|
262
|
+
if (!this.pointCloudRenderer)
|
|
263
|
+
return;
|
|
264
|
+
this.pointCloudRenderer.appendChunk(handle, chunk);
|
|
265
|
+
this.expandModelBoundsForPointClouds();
|
|
266
|
+
this.camera.setSceneBounds(this.modelBounds);
|
|
267
|
+
this.requestRender();
|
|
268
|
+
}
|
|
269
|
+
endPointCloudStream(handle) {
|
|
270
|
+
this.pointCloudRenderer?.endAsset(handle);
|
|
271
|
+
this.requestRender();
|
|
272
|
+
}
|
|
273
|
+
removePointCloudAsset(handle) {
|
|
274
|
+
this.pointCloudRenderer?.removeAsset(handle);
|
|
275
|
+
// Bounds may have shrunk — recompute from scratch so fit-to-view
|
|
276
|
+
// and section-plane sliders see fresh extents.
|
|
277
|
+
this.recomputeModelBounds();
|
|
278
|
+
this.camera.setSceneBounds(this.modelBounds);
|
|
279
|
+
this.requestRender();
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Reassign a streamed point-cloud's expressId after upload. Use
|
|
283
|
+
* this when the federation registry assigns a new model offset and
|
|
284
|
+
* the renderer needs to emit the post-offset globalId in picking
|
|
285
|
+
* outputs. The change takes effect on the next render — no GPU
|
|
286
|
+
* buffer rewrite needed.
|
|
287
|
+
*/
|
|
288
|
+
relabelPointCloudAsset(handle, newExpressId) {
|
|
289
|
+
this.pointCloudRenderer?.relabelAsset(handle, newExpressId);
|
|
290
|
+
this.requestRender();
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Compute model bounds from triangle meshes + remaining point clouds.
|
|
294
|
+
* Called from removeAsset / clear paths so bounds shrink correctly.
|
|
295
|
+
* Triangle meshes still drive the bounds when present (existing
|
|
296
|
+
* Scene-driven path), so this only re-folds in the point cloud
|
|
297
|
+
* extents over whatever the mesh path left.
|
|
298
|
+
*/
|
|
299
|
+
recomputeModelBounds() {
|
|
300
|
+
// Always recompute from scratch: take mesh bounds as the
|
|
301
|
+
// baseline, then fold in the CURRENT point-cloud bounds on
|
|
302
|
+
// top. Folding only-up via expandModelBoundsForPointClouds()
|
|
303
|
+
// is correct when pc bounds grow but never shrinks them when
|
|
304
|
+
// an asset is removed, leaving stale oversized extents until
|
|
305
|
+
// every point cloud is gone.
|
|
306
|
+
const meshBounds = this.computeMeshBounds();
|
|
307
|
+
const pcBounds = this.pointCloudRenderer?.getBounds() ?? null;
|
|
308
|
+
if (!meshBounds && !pcBounds) {
|
|
309
|
+
this.modelBounds = null;
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
this.modelBounds = meshBounds ?? {
|
|
313
|
+
min: { x: pcBounds.min[0], y: pcBounds.min[1], z: pcBounds.min[2] },
|
|
314
|
+
max: { x: pcBounds.max[0], y: pcBounds.max[1], z: pcBounds.max[2] },
|
|
315
|
+
};
|
|
316
|
+
if (meshBounds && pcBounds) {
|
|
317
|
+
this.expandModelBoundsForPointClouds();
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/** Aggregate bounds across all batched + individual meshes. Returns
|
|
321
|
+
* null if the scene has no mesh geometry. */
|
|
322
|
+
computeMeshBounds() {
|
|
323
|
+
let minX = Infinity, minY = Infinity, minZ = Infinity;
|
|
324
|
+
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
|
|
325
|
+
let any = false;
|
|
326
|
+
for (const batch of this.scene.getBatchedMeshes()) {
|
|
327
|
+
if (!batch.bounds)
|
|
328
|
+
continue;
|
|
329
|
+
any = true;
|
|
330
|
+
if (batch.bounds.min[0] < minX)
|
|
331
|
+
minX = batch.bounds.min[0];
|
|
332
|
+
if (batch.bounds.min[1] < minY)
|
|
333
|
+
minY = batch.bounds.min[1];
|
|
334
|
+
if (batch.bounds.min[2] < minZ)
|
|
335
|
+
minZ = batch.bounds.min[2];
|
|
336
|
+
if (batch.bounds.max[0] > maxX)
|
|
337
|
+
maxX = batch.bounds.max[0];
|
|
338
|
+
if (batch.bounds.max[1] > maxY)
|
|
339
|
+
maxY = batch.bounds.max[1];
|
|
340
|
+
if (batch.bounds.max[2] > maxZ)
|
|
341
|
+
maxZ = batch.bounds.max[2];
|
|
342
|
+
}
|
|
343
|
+
if (!any)
|
|
344
|
+
return null;
|
|
345
|
+
return { min: { x: minX, y: minY, z: minZ }, max: { x: maxX, y: maxY, z: maxZ } };
|
|
346
|
+
}
|
|
347
|
+
/** Apply rendering options (color mode, fixed override, point size). */
|
|
348
|
+
setPointCloudOptions(opts) {
|
|
349
|
+
this.pointCloudRenderer?.setOptions(opts);
|
|
350
|
+
this.requestRender();
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Compute BIM ↔ scan deviation for every loaded point cloud asset.
|
|
354
|
+
*
|
|
355
|
+
* Walks every triangle in the scene (individual + batched meshes,
|
|
356
|
+
* regardless of which IFC ingest path produced them — STEP, IFCx,
|
|
357
|
+
* GLB, or federated combinations), builds a per-triangle BVH on
|
|
358
|
+
* the GPU, then runs a closest-point compute pass per chunk that
|
|
359
|
+
* writes signed distance into each chunk's deviation buffer.
|
|
360
|
+
*
|
|
361
|
+
* Returns metadata so the UI can populate a histogram + auto-range:
|
|
362
|
+
* the per-asset point count, the suggested ±range from the 95th
|
|
363
|
+
* percentile, and the bbox the BVH was built from.
|
|
364
|
+
*
|
|
365
|
+
* Idempotent: re-running with the same mesh set reuses the GPU
|
|
366
|
+
* BVH (the BVH build dominates wall time on big BIMs). Pass
|
|
367
|
+
* `forceRebuild: true` to invalidate.
|
|
368
|
+
*/
|
|
369
|
+
async computeDeviations(opts = {}) {
|
|
370
|
+
if (!this.deviationPipeline || !this.pointCloudRenderer) {
|
|
371
|
+
throw new Error('Renderer not initialised — call init() first.');
|
|
372
|
+
}
|
|
373
|
+
const meshes = this.collectAllSceneMeshes();
|
|
374
|
+
// Fingerprint folds in per-mesh expressId / modelIndex /
|
|
375
|
+
// positions length / triangle count, so two distinct meshes
|
|
376
|
+
// that happen to share an aggregate position-length total
|
|
377
|
+
// can't alias each other. A federation reload that swaps one
|
|
378
|
+
// model for another with the same total triangle count would
|
|
379
|
+
// otherwise reuse the previous BVH and report wrong distances.
|
|
380
|
+
const fingerprint = computeBvhFingerprint(meshes);
|
|
381
|
+
if (opts.forceRebuild || fingerprint !== this.deviationBvhFingerprint) {
|
|
382
|
+
const bvh = buildTriangleBVH(meshes);
|
|
383
|
+
this.deviationPipeline.uploadBvh(bvh);
|
|
384
|
+
this.deviationBvhFingerprint = fingerprint;
|
|
385
|
+
}
|
|
386
|
+
const stats = this.deviationPipeline.getBvhStats();
|
|
387
|
+
const maxRange = opts.maxRange ?? 1.0;
|
|
388
|
+
// Encode every chunk into a single command submit so the GPU
|
|
389
|
+
// can pipeline the dispatches without a CPU round-trip per
|
|
390
|
+
// chunk. Histogram readback is a follow-up — for v1 we emit
|
|
391
|
+
// the deviation buffers and let the splat shader visualise.
|
|
392
|
+
const encoder = this.device.getDevice().createCommandEncoder({ label: 'pointcloud-deviation' });
|
|
393
|
+
let chunksProcessed = 0;
|
|
394
|
+
let pointsProcessed = 0;
|
|
395
|
+
const nodes = this.pointCloudRenderer.getInternalNodes();
|
|
396
|
+
for (const node of nodes) {
|
|
397
|
+
for (const chunk of node.chunks) {
|
|
398
|
+
const ok = this.deviationPipeline.dispatch(encoder, {
|
|
399
|
+
positionsBuffer: chunk.vertexBuffer,
|
|
400
|
+
deviationsBuffer: chunk.deviationBuffer,
|
|
401
|
+
pointCount: chunk.pointCount,
|
|
402
|
+
maxRange,
|
|
403
|
+
});
|
|
404
|
+
if (ok) {
|
|
405
|
+
chunksProcessed++;
|
|
406
|
+
pointsProcessed += chunk.pointCount;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
this.device.getDevice().queue.submit([encoder.finish()]);
|
|
411
|
+
// Wait until the GPU finishes the dispatches before resolving.
|
|
412
|
+
// Otherwise the caller's "compute done" callback fires before
|
|
413
|
+
// the deviation buffers are actually populated.
|
|
414
|
+
await this.device.getDevice().queue.onSubmittedWorkDone();
|
|
415
|
+
this.requestRender();
|
|
416
|
+
// Suggest a default half-range = max(0.01m, max-extent / 1000).
|
|
417
|
+
// Tighter than the maxRange clip; gives the user a reasonable
|
|
418
|
+
// starting slider position without a histogram readback.
|
|
419
|
+
const bb = stats.bounds;
|
|
420
|
+
const suggestedHalfRange = bb
|
|
421
|
+
? Math.max(0.01, Math.max(bb.max[0] - bb.min[0], bb.max[1] - bb.min[1], bb.max[2] - bb.min[2]) / 1000)
|
|
422
|
+
: 0.05;
|
|
423
|
+
return {
|
|
424
|
+
bvhTriangles: stats.triangleCount,
|
|
425
|
+
bvhNodes: stats.nodeCount,
|
|
426
|
+
chunksProcessed,
|
|
427
|
+
pointsProcessed,
|
|
428
|
+
bounds: stats.bounds,
|
|
429
|
+
suggestedHalfRange,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Aggregate every triangle source the scene exposes — individual
|
|
434
|
+
* meshes (created on demand by picking / highlights) AND batched
|
|
435
|
+
* meshes (the streaming geometry path's compact GPU buffers).
|
|
436
|
+
* Both formats arrive as `MeshData`; the BVH builder doesn't care
|
|
437
|
+
* which source they came from.
|
|
438
|
+
*/
|
|
439
|
+
collectAllSceneMeshes() {
|
|
440
|
+
// The Scene keeps every CPU-side MeshData regardless of which
|
|
441
|
+
// ingest path produced it (STEP / IFCx / GLB). One iteration
|
|
442
|
+
// covers individual + batched + multi-piece + multi-model.
|
|
443
|
+
// `forEachMeshData` deduplicates by identity so a colour-merged
|
|
444
|
+
// batch is only added once even if it's indexed under multiple
|
|
445
|
+
// contributor expressIds.
|
|
446
|
+
const out = [];
|
|
447
|
+
this.scene.forEachMeshData((md) => {
|
|
448
|
+
if (md.positions && md.positions.length > 0)
|
|
449
|
+
out.push(md);
|
|
450
|
+
});
|
|
451
|
+
return out;
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Toggle Eye-Dome Lighting and tune its strength.
|
|
455
|
+
*
|
|
456
|
+
* EDL adds depth perception to point clouds (and meshes) via screen-
|
|
457
|
+
* space depth gradient — silhouette pixels get a soft black halo.
|
|
458
|
+
* Cheap: ~9 texture taps per pixel. Only runs when point clouds are
|
|
459
|
+
* loaded.
|
|
460
|
+
*/
|
|
461
|
+
setEdlOptions(opts) {
|
|
462
|
+
if (opts.enabled !== undefined)
|
|
463
|
+
this.edlOptions.enabled = opts.enabled;
|
|
464
|
+
if (opts.strength !== undefined)
|
|
465
|
+
this.edlOptions.strength = Math.max(0, Math.min(3, opts.strength));
|
|
466
|
+
if (opts.radiusPx !== undefined)
|
|
467
|
+
this.edlOptions.radiusPx = Math.max(1, Math.min(4, opts.radiusPx));
|
|
468
|
+
if (opts.highQuality !== undefined)
|
|
469
|
+
this.edlOptions.highQuality = opts.highQuality;
|
|
470
|
+
this.requestRender();
|
|
471
|
+
}
|
|
472
|
+
expandModelBoundsForPointClouds() {
|
|
473
|
+
const pcBounds = this.pointCloudRenderer?.getBounds();
|
|
474
|
+
if (!pcBounds)
|
|
475
|
+
return;
|
|
476
|
+
if (!this.modelBounds) {
|
|
477
|
+
this.modelBounds = {
|
|
478
|
+
min: { x: pcBounds.min[0], y: pcBounds.min[1], z: pcBounds.min[2] },
|
|
479
|
+
max: { x: pcBounds.max[0], y: pcBounds.max[1], z: pcBounds.max[2] },
|
|
480
|
+
};
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
const m = this.modelBounds;
|
|
484
|
+
m.min.x = Math.min(m.min.x, pcBounds.min[0]);
|
|
485
|
+
m.min.y = Math.min(m.min.y, pcBounds.min[1]);
|
|
486
|
+
m.min.z = Math.min(m.min.z, pcBounds.min[2]);
|
|
487
|
+
m.max.x = Math.max(m.max.x, pcBounds.max[0]);
|
|
488
|
+
m.max.y = Math.max(m.max.y, pcBounds.max[1]);
|
|
489
|
+
m.max.z = Math.max(m.max.z, pcBounds.max[2]);
|
|
119
490
|
}
|
|
120
491
|
/**
|
|
121
492
|
* Load geometry from GeometryResult or MeshData array
|
|
@@ -500,9 +871,32 @@ export class Renderer {
|
|
|
500
871
|
/**
|
|
501
872
|
* Render frame
|
|
502
873
|
*/
|
|
874
|
+
/** Get diagnostic info for mobile debugging */
|
|
875
|
+
getDiagnostics() {
|
|
876
|
+
const pos = this.camera.getPosition();
|
|
877
|
+
const tgt = this.camera.getTarget();
|
|
878
|
+
const b = this.modelBounds;
|
|
879
|
+
return {
|
|
880
|
+
calls: this._renderCallCount,
|
|
881
|
+
skips: this._renderSkipCount,
|
|
882
|
+
errors: this._renderErrorCount,
|
|
883
|
+
lastError: this._lastRenderError,
|
|
884
|
+
batches: this.scene.getBatchedMeshes().length,
|
|
885
|
+
meshes: this.scene.getMeshes().length,
|
|
886
|
+
contextOk: this.device.isInitialized(),
|
|
887
|
+
gpuErrors: this.device._uncapturedErrorCount,
|
|
888
|
+
lastGpuError: this.device._lastUncapturedError ?? '',
|
|
889
|
+
camPos: `${pos.x.toFixed(1)},${pos.y.toFixed(1)},${pos.z.toFixed(1)}`,
|
|
890
|
+
camTgt: `${tgt.x.toFixed(1)},${tgt.y.toFixed(1)},${tgt.z.toFixed(1)}`,
|
|
891
|
+
bounds: b ? `${b.min.x.toFixed(0)}..${b.max.x.toFixed(0)} ${b.min.y.toFixed(0)}..${b.max.y.toFixed(0)} ${b.min.z.toFixed(0)}..${b.max.z.toFixed(0)}` : 'none',
|
|
892
|
+
};
|
|
893
|
+
}
|
|
503
894
|
render(options = {}) {
|
|
504
|
-
|
|
895
|
+
this._renderCallCount++;
|
|
896
|
+
if (!this.device.isInitialized() || !this.pipeline) {
|
|
897
|
+
this._renderSkipCount++;
|
|
505
898
|
return;
|
|
899
|
+
}
|
|
506
900
|
// Validate canvas dimensions
|
|
507
901
|
// Align width to 64 pixels for WebGPU texture row alignment (256 bytes / 4 bytes per pixel)
|
|
508
902
|
const rect = this.canvas.getBoundingClientRect();
|
|
@@ -510,8 +904,10 @@ export class Renderer {
|
|
|
510
904
|
const width = Math.max(64, Math.floor(rawWidth / 64) * 64);
|
|
511
905
|
const height = Math.max(1, Math.floor(rect.height));
|
|
512
906
|
// Skip rendering if canvas is too small
|
|
513
|
-
if (width < 64 || height < 10)
|
|
907
|
+
if (width < 64 || height < 10) {
|
|
908
|
+
this._renderSkipCount++;
|
|
514
909
|
return;
|
|
910
|
+
}
|
|
515
911
|
// Update canvas pixel dimensions if needed
|
|
516
912
|
const dimensionsChanged = this.canvas.width !== width || this.canvas.height !== height;
|
|
517
913
|
if (dimensionsChanged) {
|
|
@@ -527,10 +923,13 @@ export class Renderer {
|
|
|
527
923
|
}
|
|
528
924
|
}
|
|
529
925
|
// Skip rendering if canvas is invalid
|
|
530
|
-
if (this.canvas.width === 0 || this.canvas.height === 0)
|
|
926
|
+
if (this.canvas.width === 0 || this.canvas.height === 0) {
|
|
927
|
+
this._renderSkipCount++;
|
|
531
928
|
return;
|
|
929
|
+
}
|
|
532
930
|
// Ensure context is valid before rendering (handles HMR, focus changes, etc.)
|
|
533
931
|
if (!this.device.ensureContext()) {
|
|
932
|
+
this._renderSkipCount++;
|
|
534
933
|
return; // Skip this frame, context will be ready next frame
|
|
535
934
|
}
|
|
536
935
|
const device = this.device.getDevice();
|
|
@@ -553,29 +952,66 @@ export class Renderer {
|
|
|
553
952
|
const hasHiddenFilter = options.hiddenIds && options.hiddenIds.size > 0;
|
|
554
953
|
const hasIsolatedFilter = options.isolatedIds !== null && options.isolatedIds !== undefined;
|
|
555
954
|
const hasVisibilityFiltering = hasHiddenFilter || hasIsolatedFilter;
|
|
955
|
+
// Build the selected-id set once per frame so the X-Ray override paths
|
|
956
|
+
// can keep highlighted entities at full alpha without per-site checks.
|
|
957
|
+
const selectedId = options.selectedId;
|
|
958
|
+
const selectedIds = options.selectedIds;
|
|
959
|
+
const selectedModelIndex = options.selectedModelIndex;
|
|
960
|
+
const selectedExpressIds = new Set();
|
|
961
|
+
if (selectedId !== undefined && selectedId !== null) {
|
|
962
|
+
selectedExpressIds.add(selectedId);
|
|
963
|
+
}
|
|
964
|
+
if (selectedIds) {
|
|
965
|
+
for (const id of selectedIds) {
|
|
966
|
+
selectedExpressIds.add(id);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
const hasSelected = selectedExpressIds.size > 0;
|
|
556
970
|
// Per-frame alpha overrides for X-Ray mode. See RenderOptions.transparencyOverrides.
|
|
557
|
-
//
|
|
558
|
-
|
|
559
|
-
const
|
|
971
|
+
// Snapshot the caller's map so mid-frame mutation can't desync classification
|
|
972
|
+
// and uniform-write decisions for the same batch/mesh.
|
|
973
|
+
const txOverridesSrc = options.transparencyOverrides;
|
|
974
|
+
const hasTxOverrides = txOverridesSrc != null && txOverridesSrc.size > 0;
|
|
975
|
+
const txOverrides = hasTxOverrides ? new Map(txOverridesSrc) : null;
|
|
560
976
|
const alphaForMesh = (expressId, fallback) => {
|
|
561
977
|
if (!hasTxOverrides)
|
|
562
978
|
return fallback;
|
|
979
|
+
// Selected meshes are exempt — the highlight pass renders them last,
|
|
980
|
+
// but exempting here also keeps mesh classification + uniform writes
|
|
981
|
+
// consistent so a selected mesh never enters the transparent pipeline
|
|
982
|
+
// because of its own override entry.
|
|
983
|
+
if (hasSelected && selectedExpressIds.has(expressId))
|
|
984
|
+
return fallback;
|
|
563
985
|
const a = txOverrides.get(expressId);
|
|
564
986
|
return a !== undefined ? a : fallback;
|
|
565
987
|
};
|
|
566
|
-
//
|
|
567
|
-
//
|
|
568
|
-
//
|
|
988
|
+
// Cache resolved batch alpha for the frame: classification needs it
|
|
989
|
+
// (opaque vs transparent routing) and renderBatch needs it for the
|
|
990
|
+
// uniform write. Without the cache we'd walk batch.expressIds twice
|
|
991
|
+
// per batch per frame, which becomes the dominant JS cost in X-Ray.
|
|
992
|
+
const batchAlphaCache = hasTxOverrides
|
|
993
|
+
? new WeakMap()
|
|
994
|
+
: null;
|
|
569
995
|
const alphaForBatch = (batch, fallback) => {
|
|
570
996
|
if (!hasTxOverrides)
|
|
571
997
|
return fallback;
|
|
998
|
+
const cached = batchAlphaCache.get(batch);
|
|
999
|
+
if (cached !== undefined)
|
|
1000
|
+
return cached;
|
|
572
1001
|
let minAlpha = Infinity;
|
|
573
1002
|
for (const eid of batch.expressIds) {
|
|
1003
|
+
// Selected ids never drag down a batch's alpha — the highlight
|
|
1004
|
+
// pass redraws them on top, but excluding here also means a
|
|
1005
|
+
// batch made entirely of selected entities stays opaque.
|
|
1006
|
+
if (hasSelected && selectedExpressIds.has(eid))
|
|
1007
|
+
continue;
|
|
574
1008
|
const a = txOverrides.get(eid);
|
|
575
1009
|
if (a !== undefined && a < minAlpha)
|
|
576
1010
|
minAlpha = a;
|
|
577
1011
|
}
|
|
578
|
-
|
|
1012
|
+
const resolved = minAlpha === Infinity ? fallback : minAlpha;
|
|
1013
|
+
batchAlphaCache.set(batch, resolved);
|
|
1014
|
+
return resolved;
|
|
579
1015
|
};
|
|
580
1016
|
// PERFORMANCE FIX: Use batch-level visibility filtering instead of creating individual meshes
|
|
581
1017
|
// Only create individual meshes for selected elements (for highlighting)
|
|
@@ -609,6 +1045,12 @@ export class Renderer {
|
|
|
609
1045
|
if (this.instancedPipeline?.needsResize(this.canvas.width, this.canvas.height)) {
|
|
610
1046
|
this.instancedPipeline.resize(this.canvas.width, this.canvas.height);
|
|
611
1047
|
}
|
|
1048
|
+
// Push a validation error scope to capture the EXACT error (for mobile debugging)
|
|
1049
|
+
// Only do this for the first few renders to avoid performance overhead
|
|
1050
|
+
const captureGpuError = this._renderCallCount <= 5;
|
|
1051
|
+
if (captureGpuError) {
|
|
1052
|
+
device.pushErrorScope('validation');
|
|
1053
|
+
}
|
|
612
1054
|
// Get current texture safely - may return null if context needs reconfiguration
|
|
613
1055
|
const currentTexture = this.device.getCurrentTexture();
|
|
614
1056
|
if (!currentTexture) {
|
|
@@ -645,9 +1087,6 @@ export class Renderer {
|
|
|
645
1087
|
// Write uniform data to each mesh's buffer BEFORE recording commands
|
|
646
1088
|
// This ensures each mesh has its own color data
|
|
647
1089
|
const allMeshes = [...opaqueMeshes, ...transparentMeshes];
|
|
648
|
-
const selectedId = options.selectedId;
|
|
649
|
-
const selectedIds = options.selectedIds;
|
|
650
|
-
const selectedModelIndex = options.selectedModelIndex;
|
|
651
1090
|
// Calculate section plane parameters and model bounds
|
|
652
1091
|
// Always calculate bounds when sectionPlane is provided (for preview and active mode)
|
|
653
1092
|
let sectionPlaneData;
|
|
@@ -682,6 +1121,21 @@ export class Renderer {
|
|
|
682
1121
|
boundsMax.z = Math.max(boundsMax.z, batch.bounds.max[2]);
|
|
683
1122
|
}
|
|
684
1123
|
}
|
|
1124
|
+
// Fold in point-cloud bounds too — without this, a
|
|
1125
|
+
// pure point-cloud scene falls through to the default
|
|
1126
|
+
// [-100,100], and a mixed scene clips against a
|
|
1127
|
+
// smaller mesh-only range while the point pipeline
|
|
1128
|
+
// (which honours the same sectionPlaneData) keeps
|
|
1129
|
+
// drawing points outside the slider's reach.
|
|
1130
|
+
const pcBoundsForSection = this.pointCloudRenderer?.getBounds();
|
|
1131
|
+
if (pcBoundsForSection) {
|
|
1132
|
+
boundsMin.x = Math.min(boundsMin.x, pcBoundsForSection.min[0]);
|
|
1133
|
+
boundsMin.y = Math.min(boundsMin.y, pcBoundsForSection.min[1]);
|
|
1134
|
+
boundsMin.z = Math.min(boundsMin.z, pcBoundsForSection.min[2]);
|
|
1135
|
+
boundsMax.x = Math.max(boundsMax.x, pcBoundsForSection.max[0]);
|
|
1136
|
+
boundsMax.y = Math.max(boundsMax.y, pcBoundsForSection.max[1]);
|
|
1137
|
+
boundsMax.z = Math.max(boundsMax.z, pcBoundsForSection.max[2]);
|
|
1138
|
+
}
|
|
685
1139
|
// If no batched meshes have bounds yet (streaming, degenerate
|
|
686
1140
|
// models), fall back to individual meshes so at least the
|
|
687
1141
|
// slider has a workable range.
|
|
@@ -717,82 +1171,113 @@ export class Renderer {
|
|
|
717
1171
|
};
|
|
718
1172
|
}
|
|
719
1173
|
if (options.sectionPlane.enabled) {
|
|
720
|
-
//
|
|
721
|
-
//
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
if (
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
//
|
|
735
|
-
|
|
736
|
-
const
|
|
737
|
-
const
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
normal[1]
|
|
745
|
-
|
|
1174
|
+
// Explicit normal + distance override (face-pick / arbitrary
|
|
1175
|
+
// plane, issue #243). Used verbatim: no axis mapping, no
|
|
1176
|
+
// position slider, no building rotation — the caller already
|
|
1177
|
+
// has the plane in world space.
|
|
1178
|
+
const explicitNormal = options.sectionPlane.normal;
|
|
1179
|
+
const explicitDistance = options.sectionPlane.distance;
|
|
1180
|
+
const hasExplicitPlane = explicitNormal !== undefined &&
|
|
1181
|
+
explicitDistance !== undefined &&
|
|
1182
|
+
Number.isFinite(explicitDistance);
|
|
1183
|
+
let normal;
|
|
1184
|
+
let distance;
|
|
1185
|
+
if (hasExplicitPlane) {
|
|
1186
|
+
// Defensive renormalisation in case the caller passed a
|
|
1187
|
+
// non-unit vector (e.g. mesh face normals quantised by
|
|
1188
|
+
// the geometry pipeline).
|
|
1189
|
+
const nx = explicitNormal[0];
|
|
1190
|
+
const ny = explicitNormal[1];
|
|
1191
|
+
const nz = explicitNormal[2];
|
|
1192
|
+
const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
|
|
1193
|
+
if (len > 1e-6) {
|
|
1194
|
+
normal = [nx / len, ny / len, nz / len];
|
|
1195
|
+
distance = explicitDistance / len;
|
|
1196
|
+
}
|
|
1197
|
+
else {
|
|
1198
|
+
normal = [0, 1, 0];
|
|
1199
|
+
distance = explicitDistance;
|
|
746
1200
|
}
|
|
747
1201
|
}
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
1202
|
+
else {
|
|
1203
|
+
// Cardinal-axis preset path (unchanged behaviour).
|
|
1204
|
+
// down = Y axis (horizontal cut), front = Z axis, side = X axis
|
|
1205
|
+
normal = [0, 0, 0];
|
|
1206
|
+
if (options.sectionPlane.axis === 'side')
|
|
1207
|
+
normal[0] = 1; // X axis
|
|
1208
|
+
else if (options.sectionPlane.axis === 'down')
|
|
1209
|
+
normal[1] = 1; // Y axis (horizontal)
|
|
1210
|
+
else
|
|
1211
|
+
normal[2] = 1; // Z axis (front)
|
|
1212
|
+
// Apply building rotation if present (rotate normal around Y axis)
|
|
1213
|
+
// Building rotation is in X-Y plane (Z is up in IFC, Y is up in WebGL)
|
|
1214
|
+
if (options.buildingRotation !== undefined && options.buildingRotation !== 0) {
|
|
1215
|
+
const cosR = Math.cos(options.buildingRotation);
|
|
1216
|
+
const sinR = Math.sin(options.buildingRotation);
|
|
1217
|
+
// Rotate normal vector around Y axis (vertical)
|
|
1218
|
+
// For X-Z plane rotation: x' = x*cos - z*sin, z' = x*sin + z*cos, y' = y
|
|
1219
|
+
const x = normal[0];
|
|
1220
|
+
const z = normal[2];
|
|
1221
|
+
normal[0] = x * cosR - z * sinR;
|
|
1222
|
+
normal[2] = x * sinR + z * cosR;
|
|
1223
|
+
// Normalize to maintain unit length
|
|
1224
|
+
const rlen = Math.sqrt(normal[0] * normal[0] + normal[1] * normal[1] + normal[2] * normal[2]);
|
|
1225
|
+
if (rlen > 0.0001) {
|
|
1226
|
+
normal[0] /= rlen;
|
|
1227
|
+
normal[1] /= rlen;
|
|
1228
|
+
normal[2] /= rlen;
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
// Get axis-specific range. The renderer's own `boundsMin/Max`
|
|
1232
|
+
// are computed from the GPU vertex buffers this frame, so
|
|
1233
|
+
// they are guaranteed to be in the same Y-up world space as
|
|
1234
|
+
// `input.worldPos` in the shader. `options.sectionPlane.min/max`
|
|
1235
|
+
// comes from the UI via `coordinateInfo.shiftedBounds` and can
|
|
1236
|
+
// be stale during streaming or outright wrong during model
|
|
1237
|
+
// load (initialised to {0,0,0} before the first bounds update)
|
|
1238
|
+
// — using those directly was the cause of the "slider moves
|
|
1239
|
+
// 1% and the whole model disappears" bug.
|
|
1240
|
+
//
|
|
1241
|
+
// Policy: always use the renderer's own bounds for the Y-up
|
|
1242
|
+
// range. Only honour the UI override when it is a valid,
|
|
1243
|
+
// non-degenerate range that lies INSIDE the actual mesh
|
|
1244
|
+
// bounds (e.g. storey filtering from the level picker).
|
|
1245
|
+
const axisIdx = options.sectionPlane.axis === 'side' ? 'x' : options.sectionPlane.axis === 'down' ? 'y' : 'z';
|
|
1246
|
+
let minVal = boundsMin[axisIdx];
|
|
1247
|
+
let maxVal = boundsMax[axisIdx];
|
|
1248
|
+
const uiMin = options.sectionPlane.min;
|
|
1249
|
+
const uiMax = options.sectionPlane.max;
|
|
1250
|
+
if (Number.isFinite(uiMin) &&
|
|
1251
|
+
Number.isFinite(uiMax) &&
|
|
1252
|
+
uiMax - uiMin > 1e-6 &&
|
|
1253
|
+
uiMin >= minVal - 1e-3 &&
|
|
1254
|
+
uiMax <= maxVal + 1e-3) {
|
|
1255
|
+
minVal = uiMin;
|
|
1256
|
+
maxVal = uiMax;
|
|
1257
|
+
}
|
|
1258
|
+
// Calculate plane distance from position percentage
|
|
1259
|
+
const range = maxVal - minVal;
|
|
1260
|
+
distance = minVal + (options.sectionPlane.position / 100) * range;
|
|
774
1261
|
}
|
|
775
|
-
// Calculate plane distance from position percentage
|
|
776
|
-
const range = maxVal - minVal;
|
|
777
|
-
const distance = minVal + (options.sectionPlane.position / 100) * range;
|
|
778
1262
|
sectionPlaneData = { normal, distance, enabled: true };
|
|
779
1263
|
// One-shot diagnostic: when section first becomes active,
|
|
780
|
-
// log the exact bounds +
|
|
781
|
-
//
|
|
782
|
-
// without asking the user to run a
|
|
1264
|
+
// log the exact bounds + plane the shader will use. This
|
|
1265
|
+
// is the fastest way to confirm "bounds mismatch" / "plane
|
|
1266
|
+
// off-screen" bugs without asking the user to run a
|
|
1267
|
+
// debugger. The custom-plane branch logs `mode: 'explicit'`
|
|
1268
|
+
// so reports against tilted planes are easy to spot.
|
|
783
1269
|
if (!this._loggedSectionBounds) {
|
|
784
1270
|
this._loggedSectionBounds = true;
|
|
785
1271
|
console.info('[Section] Y-up bounds used for clip:', {
|
|
1272
|
+
mode: hasExplicitPlane ? 'explicit' : 'axis-aligned',
|
|
786
1273
|
axis: options.sectionPlane.axis,
|
|
787
|
-
axisIdx,
|
|
788
1274
|
bounds: {
|
|
789
1275
|
min: { x: boundsMin.x, y: boundsMin.y, z: boundsMin.z },
|
|
790
1276
|
max: { x: boundsMax.x, y: boundsMax.y, z: boundsMax.z },
|
|
791
1277
|
},
|
|
792
|
-
|
|
793
|
-
used: { min: minVal, max: maxVal },
|
|
794
|
-
position: options.sectionPlane.position,
|
|
1278
|
+
normal,
|
|
795
1279
|
distance,
|
|
1280
|
+
position: options.sectionPlane.position,
|
|
796
1281
|
batchedMeshCount: this.scene.getBatchedMeshes().length,
|
|
797
1282
|
});
|
|
798
1283
|
}
|
|
@@ -849,24 +1334,26 @@ export class Renderer {
|
|
|
849
1334
|
// Set up MSAA rendering if enabled
|
|
850
1335
|
const msaaView = this.pipeline.getMultisampleTextureView();
|
|
851
1336
|
const useMSAA = msaaView !== null && this.pipeline.getSampleCount() > 1;
|
|
1337
|
+
// Build color attachments — skip objectId in single-target mode
|
|
1338
|
+
const colorAttachments = [
|
|
1339
|
+
{
|
|
1340
|
+
// If MSAA enabled: render to multisample texture, resolve to swap chain
|
|
1341
|
+
// If MSAA disabled: render directly to swap chain
|
|
1342
|
+
view: useMSAA ? msaaView : textureView,
|
|
1343
|
+
resolveTarget: useMSAA ? textureView : undefined,
|
|
1344
|
+
loadOp: 'clear',
|
|
1345
|
+
clearValue: clearColor,
|
|
1346
|
+
storeOp: (useMSAA ? 'discard' : 'store'),
|
|
1347
|
+
},
|
|
1348
|
+
];
|
|
1349
|
+
colorAttachments.push({
|
|
1350
|
+
view: objectIdView,
|
|
1351
|
+
loadOp: 'clear',
|
|
1352
|
+
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
|
1353
|
+
storeOp: (needsObjectIdPass ? 'store' : 'discard'),
|
|
1354
|
+
});
|
|
852
1355
|
const pass = encoder.beginRenderPass({
|
|
853
|
-
colorAttachments
|
|
854
|
-
{
|
|
855
|
-
// If MSAA enabled: render to multisample texture, resolve to swap chain
|
|
856
|
-
// If MSAA disabled: render directly to swap chain
|
|
857
|
-
view: useMSAA ? msaaView : textureView,
|
|
858
|
-
resolveTarget: useMSAA ? textureView : undefined,
|
|
859
|
-
loadOp: 'clear',
|
|
860
|
-
clearValue: clearColor,
|
|
861
|
-
storeOp: useMSAA ? 'discard' : 'store', // Discard MSAA buffer after resolve
|
|
862
|
-
},
|
|
863
|
-
{
|
|
864
|
-
view: objectIdView,
|
|
865
|
-
loadOp: 'clear',
|
|
866
|
-
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
|
867
|
-
storeOp: needsObjectIdPass ? 'store' : 'discard',
|
|
868
|
-
},
|
|
869
|
-
],
|
|
1356
|
+
colorAttachments,
|
|
870
1357
|
depthStencilAttachment: {
|
|
871
1358
|
view: this.pipeline.getDepthTextureView(),
|
|
872
1359
|
depthClearValue: 0.0, // Reverse-Z: clear to 0.0 (far plane)
|
|
@@ -961,15 +1448,6 @@ export class Renderer {
|
|
|
961
1448
|
opaqueBatches.push(batch);
|
|
962
1449
|
}
|
|
963
1450
|
}
|
|
964
|
-
const selectedExpressIds = new Set();
|
|
965
|
-
if (selectedId !== undefined && selectedId !== null) {
|
|
966
|
-
selectedExpressIds.add(selectedId);
|
|
967
|
-
}
|
|
968
|
-
if (selectedIds) {
|
|
969
|
-
for (const id of selectedIds) {
|
|
970
|
-
selectedExpressIds.add(id);
|
|
971
|
-
}
|
|
972
|
-
}
|
|
973
1451
|
// Build a uniform template ONCE per frame — shared across all batches.
|
|
974
1452
|
// Only the 4-float color (offset 32) differs per batch; everything else
|
|
975
1453
|
// (viewProj, identity model, material, section plane, flags) is identical.
|
|
@@ -1308,6 +1786,19 @@ export class Renderer {
|
|
|
1308
1786
|
}
|
|
1309
1787
|
}
|
|
1310
1788
|
}
|
|
1789
|
+
// Draw point clouds (IFCx inline + streamed LAS/LAZ).
|
|
1790
|
+
// Shares the depth buffer + section plane state with the mesh pipeline so
|
|
1791
|
+
// points occlude triangles and vice versa. The splat shader needs the
|
|
1792
|
+
// viewport size to convert pixel sizes into clip-space offsets.
|
|
1793
|
+
if (this.pointCloudRenderer && this.pointCloudRenderer.hasAssets()) {
|
|
1794
|
+
this.pointCloudRenderer.draw(pass, {
|
|
1795
|
+
viewProj,
|
|
1796
|
+
sectionPlane: sectionPlaneData
|
|
1797
|
+
? { ...sectionPlaneData, flipped: options.sectionPlane?.flipped === true }
|
|
1798
|
+
: null,
|
|
1799
|
+
viewport: { width: this.canvas.width, height: this.canvas.height },
|
|
1800
|
+
});
|
|
1801
|
+
}
|
|
1311
1802
|
// Draw section plane visual BEFORE pass.end() (within same MSAA render pass)
|
|
1312
1803
|
// Always show plane when sectionPlane options are provided (as preview or active)
|
|
1313
1804
|
const modelBounds = this.getModelBounds();
|
|
@@ -1320,6 +1811,11 @@ export class Renderer {
|
|
|
1320
1811
|
isPreview: !options.sectionPlane.enabled, // Preview mode when not enabled
|
|
1321
1812
|
min: options.sectionPlane.min,
|
|
1322
1813
|
max: options.sectionPlane.max,
|
|
1814
|
+
// Custom-plane gizmo override (issue #243). When both
|
|
1815
|
+
// are set the gizmo bypasses the cardinal path; see
|
|
1816
|
+
// SectionPlaneRenderer.calculatePlaneVerticesFromNormal.
|
|
1817
|
+
normal: options.sectionPlane.normal,
|
|
1818
|
+
distance: options.sectionPlane.distance,
|
|
1323
1819
|
});
|
|
1324
1820
|
// Draw 2D section overlay on the section plane (when section is
|
|
1325
1821
|
// active, not preview). The overlay is also the 3D SECTION CAP:
|
|
@@ -1380,9 +1876,37 @@ export class Renderer {
|
|
|
1380
1876
|
enableSeparationLines: separationEnabled,
|
|
1381
1877
|
});
|
|
1382
1878
|
}
|
|
1879
|
+
// Eye-Dome Lighting — runs AFTER contact/separation so it darkens
|
|
1880
|
+
// every layer uniformly. Cheap (~9 depth taps), only active when
|
|
1881
|
+
// there are point clouds in the scene and the user has enabled it.
|
|
1882
|
+
if (this.edlPass
|
|
1883
|
+
&& this.edlOptions.enabled
|
|
1884
|
+
&& this.pointCloudRenderer?.hasAssets()) {
|
|
1885
|
+
this.edlPass.apply(encoder, {
|
|
1886
|
+
targetView: textureView,
|
|
1887
|
+
depthView: this.pipeline.getDepthOnlyTextureView(),
|
|
1888
|
+
}, {
|
|
1889
|
+
strength: this.edlOptions.strength,
|
|
1890
|
+
radiusPx: this.edlOptions.radiusPx,
|
|
1891
|
+
highQuality: this.edlOptions.highQuality,
|
|
1892
|
+
});
|
|
1893
|
+
}
|
|
1383
1894
|
device.queue.submit([encoder.finish()]);
|
|
1895
|
+
// Pop validation error scope and capture the exact error
|
|
1896
|
+
if (captureGpuError) {
|
|
1897
|
+
device.popErrorScope().then((error) => {
|
|
1898
|
+
if (error) {
|
|
1899
|
+
const msg = error.message || String(error);
|
|
1900
|
+
console.error('[WebGPU] Validation error in render pass:', msg);
|
|
1901
|
+
this.device._lastUncapturedError = `VALIDATION: ${msg}`;
|
|
1902
|
+
this.device._uncapturedErrorCount++;
|
|
1903
|
+
}
|
|
1904
|
+
});
|
|
1905
|
+
}
|
|
1384
1906
|
}
|
|
1385
1907
|
catch (error) {
|
|
1908
|
+
this._renderErrorCount++;
|
|
1909
|
+
this._lastRenderError = error instanceof Error ? error.message : String(error);
|
|
1386
1910
|
// Handle WebGPU errors (e.g., device lost, invalid state)
|
|
1387
1911
|
// Mark context as invalid so it gets reconfigured next frame
|
|
1388
1912
|
this.device.invalidateContext();
|
|
@@ -1405,6 +1929,18 @@ export class Renderer {
|
|
|
1405
1929
|
async pick(x, y, options) {
|
|
1406
1930
|
return this.pickingManager.pick(x, y, options);
|
|
1407
1931
|
}
|
|
1932
|
+
/**
|
|
1933
|
+
* GPU-based rectangle pick. Drag-select returns the set of
|
|
1934
|
+
* `expressId`s touched by any pixel inside `[x0,y0]..[x1,y1]`
|
|
1935
|
+
* (CSS pixels, canvas-relative). Both meshes and point clouds
|
|
1936
|
+
* participate.
|
|
1937
|
+
*
|
|
1938
|
+
* See `PickingManager.pickRect` for the visibility-filter +
|
|
1939
|
+
* limitation notes.
|
|
1940
|
+
*/
|
|
1941
|
+
async pickRect(x0, y0, x1, y1, options) {
|
|
1942
|
+
return this.pickingManager.pickRect(x0, y0, x1, y1, options);
|
|
1943
|
+
}
|
|
1408
1944
|
/**
|
|
1409
1945
|
* Raycast into the scene to get precise 3D intersection point
|
|
1410
1946
|
* This is more accurate than pick() as it returns the exact surface point
|
|
@@ -1495,15 +2031,34 @@ export class Renderer {
|
|
|
1495
2031
|
return this.scene;
|
|
1496
2032
|
}
|
|
1497
2033
|
/**
|
|
1498
|
-
* Upload 2D section drawing data for 3D overlay rendering
|
|
1499
|
-
*
|
|
1500
|
-
*
|
|
2034
|
+
* Upload 2D section drawing data for 3D overlay rendering.
|
|
2035
|
+
*
|
|
2036
|
+
* Cardinal-axis call site: pass `axis` + `position` percentage and the
|
|
2037
|
+
* upload computes the plane offset along the cardinal axis using the
|
|
2038
|
+
* model bounds (or `sectionRange` override). 2D points are then lifted
|
|
2039
|
+
* to 3D via the cardinal-axis swap.
|
|
2040
|
+
*
|
|
2041
|
+
* Custom-plane call site (issue #243): pass `customPlane = { origin,
|
|
2042
|
+
* tangent, bitangent }`. The 2D points are lifted via the explicit
|
|
2043
|
+
* basis, exactly inverting the projection `SectionCutter` applied when
|
|
2044
|
+
* generating the polygons. Without this the cap silhouette lands off
|
|
2045
|
+
* the actual cutting plane (the bug PR #581 hid by suppressing the
|
|
2046
|
+
* cap entirely for non-cardinal planes).
|
|
1501
2047
|
*/
|
|
1502
2048
|
uploadSection2DOverlay(polygons, lines, axis, position, // 0-100 percentage
|
|
1503
2049
|
sectionRange, // Same storey-based range as section plane
|
|
1504
|
-
flipped = false) {
|
|
2050
|
+
flipped = false, customPlane) {
|
|
1505
2051
|
if (!this.section2DOverlayRenderer)
|
|
1506
2052
|
return;
|
|
2053
|
+
if (customPlane) {
|
|
2054
|
+
// Custom-plane path: planePosition / axis are unused — the
|
|
2055
|
+
// basis the cap shader needs travels in `customPlane`. We pass
|
|
2056
|
+
// 0 for `planePosition` and the existing `axis` so the cardinal
|
|
2057
|
+
// shader code path that callers depend on (e.g. legacy SVG
|
|
2058
|
+
// export) keeps working when customPlane is omitted.
|
|
2059
|
+
this.section2DOverlayRenderer.uploadDrawing(polygons, lines, axis, 0, flipped, customPlane);
|
|
2060
|
+
return;
|
|
2061
|
+
}
|
|
1507
2062
|
// Use EXACTLY same calculation as section plane in render() method:
|
|
1508
2063
|
// minVal = options.sectionPlane.min ?? boundsMin[axisIdx]
|
|
1509
2064
|
// maxVal = options.sectionPlane.max ?? boundsMax[axisIdx]
|
|
@@ -1601,11 +2156,22 @@ export class Renderer {
|
|
|
1601
2156
|
// Post-processor uniform buffer
|
|
1602
2157
|
this.postProcessor?.destroy();
|
|
1603
2158
|
this.postProcessor = null;
|
|
2159
|
+
this.edlPass?.destroy();
|
|
2160
|
+
this.edlPass = null;
|
|
1604
2161
|
// Section-plane renderers
|
|
1605
2162
|
this.sectionPlaneRenderer?.destroy();
|
|
1606
2163
|
this.sectionPlaneRenderer = null;
|
|
1607
2164
|
this.section2DOverlayRenderer?.dispose();
|
|
1608
2165
|
this.section2DOverlayRenderer = null;
|
|
2166
|
+
// Point cloud GPU resources
|
|
2167
|
+
this.pointCloudRenderer?.clear();
|
|
2168
|
+
this.pointCloudRenderer = null;
|
|
2169
|
+
// BIM ↔ scan deviation pipeline + cached BVH GPU buffers.
|
|
2170
|
+
// Done before queue.destroy() so the GPU calls inside
|
|
2171
|
+
// `destroy()` still have a valid device.
|
|
2172
|
+
this.deviationPipeline?.destroy();
|
|
2173
|
+
this.deviationPipeline = null;
|
|
2174
|
+
this.deviationBvhFingerprint = null;
|
|
1609
2175
|
// Snap detector geometry cache
|
|
1610
2176
|
this.raycastEngine.clearCaches();
|
|
1611
2177
|
}
|