@ifc-lite/renderer 1.18.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/index.d.ts +98 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +362 -91
- package/dist/index.js.map +1 -1
- package/dist/picker.d.ts +18 -0
- package/dist/picker.d.ts.map +1 -1
- package/dist/picker.js +182 -70
- package/dist/picker.js.map +1 -1
- package/dist/picking-manager.d.ts +18 -0
- package/dist/picking-manager.d.ts.map +1 -1
- package/dist/picking-manager.js +40 -0
- package/dist/picking-manager.js.map +1 -1
- package/dist/pointcloud/point-cloud-node.d.ts +7 -0
- package/dist/pointcloud/point-cloud-node.d.ts.map +1 -1
- package/dist/pointcloud/point-cloud-node.js +38 -1
- package/dist/pointcloud/point-cloud-node.js.map +1 -1
- package/dist/pointcloud/point-cloud-renderer.d.ts +35 -4
- package/dist/pointcloud/point-cloud-renderer.d.ts.map +1 -1
- package/dist/pointcloud/point-cloud-renderer.js +52 -55
- package/dist/pointcloud/point-cloud-renderer.js.map +1 -1
- 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 +4 -2
- package/dist/pointcloud/point-pipeline.d.ts.map +1 -1
- package/dist/pointcloud/point-pipeline.js +17 -2
- package/dist/pointcloud/point-pipeline.js.map +1 -1
- package/dist/pointcloud/point-shader.wgsl.d.ts +1 -1
- package/dist/pointcloud/point-shader.wgsl.d.ts.map +1 -1
- package/dist/pointcloud/point-shader.wgsl.js +78 -2
- package/dist/pointcloud/point-shader.wgsl.js.map +1 -1
- 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 +20 -0
- 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';
|
|
@@ -46,8 +47,29 @@ import { RaycastEngine } from './raycast-engine.js';
|
|
|
46
47
|
import { PostProcessor } from './post-processor.js';
|
|
47
48
|
import { EdlPass } from './edl-pass.js';
|
|
48
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';
|
|
49
52
|
const MAX_ENCODED_ENTITY_ID = 0xFFFFFF;
|
|
50
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
|
+
}
|
|
51
73
|
/**
|
|
52
74
|
* Main renderer class
|
|
53
75
|
*/
|
|
@@ -70,6 +92,15 @@ export class Renderer {
|
|
|
70
92
|
highQuality: true,
|
|
71
93
|
};
|
|
72
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;
|
|
73
104
|
visualEnhancementState = {
|
|
74
105
|
enabled: true,
|
|
75
106
|
edgeContrast: { enabled: true, intensity: 1.0 },
|
|
@@ -84,6 +115,11 @@ export class Renderer {
|
|
|
84
115
|
// Error rate limiting (log at most once per second)
|
|
85
116
|
lastRenderErrorTime = 0;
|
|
86
117
|
RENDER_ERROR_THROTTLE_MS = 1000;
|
|
118
|
+
// Diagnostic counters for mobile debugging
|
|
119
|
+
_renderCallCount = 0;
|
|
120
|
+
_renderSkipCount = 0;
|
|
121
|
+
_renderErrorCount = 0;
|
|
122
|
+
_lastRenderError = '';
|
|
87
123
|
// Dirty flag: set by requestRender(), consumed by the animation loop.
|
|
88
124
|
// Centralises all render scheduling — callers never call render() directly.
|
|
89
125
|
_renderRequested = false;
|
|
@@ -122,12 +158,24 @@ export class Renderer {
|
|
|
122
158
|
this.picker = new Picker(this.device, width, height);
|
|
123
159
|
this.sectionPlaneRenderer = new SectionPlaneRenderer(this.device.getDevice(), this.device.getFormat(), this.pipeline.getSampleCount());
|
|
124
160
|
this.section2DOverlayRenderer = new Section2DOverlayRenderer(this.device.getDevice(), this.device.getFormat(), this.pipeline.getSampleCount());
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
+
}
|
|
130
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());
|
|
131
179
|
this.edlPass = new EdlPass(this.device, this.pipeline.getSampleCount());
|
|
132
180
|
this.camera.setAspect(width / height);
|
|
133
181
|
// Update picking manager with initialized picker
|
|
@@ -301,6 +349,107 @@ export class Renderer {
|
|
|
301
349
|
this.pointCloudRenderer?.setOptions(opts);
|
|
302
350
|
this.requestRender();
|
|
303
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
|
+
}
|
|
304
453
|
/**
|
|
305
454
|
* Toggle Eye-Dome Lighting and tune its strength.
|
|
306
455
|
*
|
|
@@ -722,9 +871,32 @@ export class Renderer {
|
|
|
722
871
|
/**
|
|
723
872
|
* Render frame
|
|
724
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
|
+
}
|
|
725
894
|
render(options = {}) {
|
|
726
|
-
|
|
895
|
+
this._renderCallCount++;
|
|
896
|
+
if (!this.device.isInitialized() || !this.pipeline) {
|
|
897
|
+
this._renderSkipCount++;
|
|
727
898
|
return;
|
|
899
|
+
}
|
|
728
900
|
// Validate canvas dimensions
|
|
729
901
|
// Align width to 64 pixels for WebGPU texture row alignment (256 bytes / 4 bytes per pixel)
|
|
730
902
|
const rect = this.canvas.getBoundingClientRect();
|
|
@@ -732,8 +904,10 @@ export class Renderer {
|
|
|
732
904
|
const width = Math.max(64, Math.floor(rawWidth / 64) * 64);
|
|
733
905
|
const height = Math.max(1, Math.floor(rect.height));
|
|
734
906
|
// Skip rendering if canvas is too small
|
|
735
|
-
if (width < 64 || height < 10)
|
|
907
|
+
if (width < 64 || height < 10) {
|
|
908
|
+
this._renderSkipCount++;
|
|
736
909
|
return;
|
|
910
|
+
}
|
|
737
911
|
// Update canvas pixel dimensions if needed
|
|
738
912
|
const dimensionsChanged = this.canvas.width !== width || this.canvas.height !== height;
|
|
739
913
|
if (dimensionsChanged) {
|
|
@@ -749,10 +923,13 @@ export class Renderer {
|
|
|
749
923
|
}
|
|
750
924
|
}
|
|
751
925
|
// Skip rendering if canvas is invalid
|
|
752
|
-
if (this.canvas.width === 0 || this.canvas.height === 0)
|
|
926
|
+
if (this.canvas.width === 0 || this.canvas.height === 0) {
|
|
927
|
+
this._renderSkipCount++;
|
|
753
928
|
return;
|
|
929
|
+
}
|
|
754
930
|
// Ensure context is valid before rendering (handles HMR, focus changes, etc.)
|
|
755
931
|
if (!this.device.ensureContext()) {
|
|
932
|
+
this._renderSkipCount++;
|
|
756
933
|
return; // Skip this frame, context will be ready next frame
|
|
757
934
|
}
|
|
758
935
|
const device = this.device.getDevice();
|
|
@@ -868,6 +1045,12 @@ export class Renderer {
|
|
|
868
1045
|
if (this.instancedPipeline?.needsResize(this.canvas.width, this.canvas.height)) {
|
|
869
1046
|
this.instancedPipeline.resize(this.canvas.width, this.canvas.height);
|
|
870
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
|
+
}
|
|
871
1054
|
// Get current texture safely - may return null if context needs reconfiguration
|
|
872
1055
|
const currentTexture = this.device.getCurrentTexture();
|
|
873
1056
|
if (!currentTexture) {
|
|
@@ -988,82 +1171,113 @@ export class Renderer {
|
|
|
988
1171
|
};
|
|
989
1172
|
}
|
|
990
1173
|
if (options.sectionPlane.enabled) {
|
|
991
|
-
//
|
|
992
|
-
//
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
if (
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
//
|
|
1006
|
-
|
|
1007
|
-
const
|
|
1008
|
-
const
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
normal[1]
|
|
1016
|
-
|
|
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;
|
|
1017
1200
|
}
|
|
1018
1201
|
}
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
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;
|
|
1045
1261
|
}
|
|
1046
|
-
// Calculate plane distance from position percentage
|
|
1047
|
-
const range = maxVal - minVal;
|
|
1048
|
-
const distance = minVal + (options.sectionPlane.position / 100) * range;
|
|
1049
1262
|
sectionPlaneData = { normal, distance, enabled: true };
|
|
1050
1263
|
// One-shot diagnostic: when section first becomes active,
|
|
1051
|
-
// log the exact bounds +
|
|
1052
|
-
//
|
|
1053
|
-
// 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.
|
|
1054
1269
|
if (!this._loggedSectionBounds) {
|
|
1055
1270
|
this._loggedSectionBounds = true;
|
|
1056
1271
|
console.info('[Section] Y-up bounds used for clip:', {
|
|
1272
|
+
mode: hasExplicitPlane ? 'explicit' : 'axis-aligned',
|
|
1057
1273
|
axis: options.sectionPlane.axis,
|
|
1058
|
-
axisIdx,
|
|
1059
1274
|
bounds: {
|
|
1060
1275
|
min: { x: boundsMin.x, y: boundsMin.y, z: boundsMin.z },
|
|
1061
1276
|
max: { x: boundsMax.x, y: boundsMax.y, z: boundsMax.z },
|
|
1062
1277
|
},
|
|
1063
|
-
|
|
1064
|
-
used: { min: minVal, max: maxVal },
|
|
1065
|
-
position: options.sectionPlane.position,
|
|
1278
|
+
normal,
|
|
1066
1279
|
distance,
|
|
1280
|
+
position: options.sectionPlane.position,
|
|
1067
1281
|
batchedMeshCount: this.scene.getBatchedMeshes().length,
|
|
1068
1282
|
});
|
|
1069
1283
|
}
|
|
@@ -1120,24 +1334,26 @@ export class Renderer {
|
|
|
1120
1334
|
// Set up MSAA rendering if enabled
|
|
1121
1335
|
const msaaView = this.pipeline.getMultisampleTextureView();
|
|
1122
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
|
+
});
|
|
1123
1355
|
const pass = encoder.beginRenderPass({
|
|
1124
|
-
colorAttachments
|
|
1125
|
-
{
|
|
1126
|
-
// If MSAA enabled: render to multisample texture, resolve to swap chain
|
|
1127
|
-
// If MSAA disabled: render directly to swap chain
|
|
1128
|
-
view: useMSAA ? msaaView : textureView,
|
|
1129
|
-
resolveTarget: useMSAA ? textureView : undefined,
|
|
1130
|
-
loadOp: 'clear',
|
|
1131
|
-
clearValue: clearColor,
|
|
1132
|
-
storeOp: useMSAA ? 'discard' : 'store', // Discard MSAA buffer after resolve
|
|
1133
|
-
},
|
|
1134
|
-
{
|
|
1135
|
-
view: objectIdView,
|
|
1136
|
-
loadOp: 'clear',
|
|
1137
|
-
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
|
1138
|
-
storeOp: needsObjectIdPass ? 'store' : 'discard',
|
|
1139
|
-
},
|
|
1140
|
-
],
|
|
1356
|
+
colorAttachments,
|
|
1141
1357
|
depthStencilAttachment: {
|
|
1142
1358
|
view: this.pipeline.getDepthTextureView(),
|
|
1143
1359
|
depthClearValue: 0.0, // Reverse-Z: clear to 0.0 (far plane)
|
|
@@ -1595,6 +1811,11 @@ export class Renderer {
|
|
|
1595
1811
|
isPreview: !options.sectionPlane.enabled, // Preview mode when not enabled
|
|
1596
1812
|
min: options.sectionPlane.min,
|
|
1597
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,
|
|
1598
1819
|
});
|
|
1599
1820
|
// Draw 2D section overlay on the section plane (when section is
|
|
1600
1821
|
// active, not preview). The overlay is also the 3D SECTION CAP:
|
|
@@ -1671,8 +1892,21 @@ export class Renderer {
|
|
|
1671
1892
|
});
|
|
1672
1893
|
}
|
|
1673
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
|
+
}
|
|
1674
1906
|
}
|
|
1675
1907
|
catch (error) {
|
|
1908
|
+
this._renderErrorCount++;
|
|
1909
|
+
this._lastRenderError = error instanceof Error ? error.message : String(error);
|
|
1676
1910
|
// Handle WebGPU errors (e.g., device lost, invalid state)
|
|
1677
1911
|
// Mark context as invalid so it gets reconfigured next frame
|
|
1678
1912
|
this.device.invalidateContext();
|
|
@@ -1695,6 +1929,18 @@ export class Renderer {
|
|
|
1695
1929
|
async pick(x, y, options) {
|
|
1696
1930
|
return this.pickingManager.pick(x, y, options);
|
|
1697
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
|
+
}
|
|
1698
1944
|
/**
|
|
1699
1945
|
* Raycast into the scene to get precise 3D intersection point
|
|
1700
1946
|
* This is more accurate than pick() as it returns the exact surface point
|
|
@@ -1785,15 +2031,34 @@ export class Renderer {
|
|
|
1785
2031
|
return this.scene;
|
|
1786
2032
|
}
|
|
1787
2033
|
/**
|
|
1788
|
-
* Upload 2D section drawing data for 3D overlay rendering
|
|
1789
|
-
*
|
|
1790
|
-
*
|
|
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).
|
|
1791
2047
|
*/
|
|
1792
2048
|
uploadSection2DOverlay(polygons, lines, axis, position, // 0-100 percentage
|
|
1793
2049
|
sectionRange, // Same storey-based range as section plane
|
|
1794
|
-
flipped = false) {
|
|
2050
|
+
flipped = false, customPlane) {
|
|
1795
2051
|
if (!this.section2DOverlayRenderer)
|
|
1796
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
|
+
}
|
|
1797
2062
|
// Use EXACTLY same calculation as section plane in render() method:
|
|
1798
2063
|
// minVal = options.sectionPlane.min ?? boundsMin[axisIdx]
|
|
1799
2064
|
// maxVal = options.sectionPlane.max ?? boundsMax[axisIdx]
|
|
@@ -1901,6 +2166,12 @@ export class Renderer {
|
|
|
1901
2166
|
// Point cloud GPU resources
|
|
1902
2167
|
this.pointCloudRenderer?.clear();
|
|
1903
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;
|
|
1904
2175
|
// Snap detector geometry cache
|
|
1905
2176
|
this.raycastEngine.clearCaches();
|
|
1906
2177
|
}
|