@ifc-lite/renderer 1.2.0 → 1.3.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/constants.d.ts +125 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +124 -0
- package/dist/constants.js.map +1 -0
- package/dist/device.d.ts.map +1 -1
- package/dist/device.js +2 -1
- package/dist/device.js.map +1 -1
- package/dist/federation-registry.d.ts +98 -0
- package/dist/federation-registry.d.ts.map +1 -0
- package/dist/federation-registry.js +197 -0
- package/dist/federation-registry.js.map +1 -0
- package/dist/index.d.ts +40 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +343 -134
- package/dist/index.js.map +1 -1
- package/dist/picker.d.ts +3 -2
- package/dist/picker.d.ts.map +1 -1
- package/dist/picker.js +19 -10
- package/dist/picker.js.map +1 -1
- package/dist/pipeline.d.ts.map +1 -1
- package/dist/pipeline.js +5 -3
- package/dist/pipeline.js.map +1 -1
- package/dist/scene.d.ts +32 -8
- package/dist/scene.d.ts.map +1 -1
- package/dist/scene.js +167 -16
- package/dist/scene.js.map +1 -1
- package/dist/section-plane.d.ts +14 -4
- package/dist/section-plane.d.ts.map +1 -1
- package/dist/section-plane.js +129 -53
- package/dist/section-plane.js.map +1 -1
- package/dist/snap-detector.d.ts +47 -1
- package/dist/snap-detector.d.ts.map +1 -1
- package/dist/snap-detector.js +443 -28
- package/dist/snap-detector.js.map +1 -1
- package/dist/types.d.ts +15 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -14,6 +14,7 @@ export { SectionPlaneRenderer } from './section-plane.js';
|
|
|
14
14
|
export { Raycaster } from './raycaster.js';
|
|
15
15
|
export { SnapDetector, SnapType } from './snap-detector.js';
|
|
16
16
|
export { BVH } from './bvh.js';
|
|
17
|
+
export { FederationRegistry, federationRegistry } from './federation-registry.js';
|
|
17
18
|
export * from './types.js';
|
|
18
19
|
// Zero-copy GPU upload (new - faster, less memory)
|
|
19
20
|
export { ZeroCopyGpuUploader, createZeroCopyUploader, } from './zero-copy-uploader.js';
|
|
@@ -49,6 +50,9 @@ export class Renderer {
|
|
|
49
50
|
bvhCache = null;
|
|
50
51
|
// Performance constants
|
|
51
52
|
BVH_THRESHOLD = 100;
|
|
53
|
+
// Error rate limiting (log at most once per second)
|
|
54
|
+
lastRenderErrorTime = 0;
|
|
55
|
+
RENDER_ERROR_THROTTLE_MS = 1000;
|
|
52
56
|
constructor(canvas) {
|
|
53
57
|
this.canvas = canvas;
|
|
54
58
|
this.device = new WebGPUDevice();
|
|
@@ -76,9 +80,96 @@ export class Renderer {
|
|
|
76
80
|
this.pipeline = new RenderPipeline(this.device, width, height);
|
|
77
81
|
this.instancedPipeline = new InstancedRenderPipeline(this.device, width, height);
|
|
78
82
|
this.picker = new Picker(this.device, width, height);
|
|
79
|
-
this.sectionPlaneRenderer = new SectionPlaneRenderer(this.device.getDevice(), this.device.getFormat());
|
|
83
|
+
this.sectionPlaneRenderer = new SectionPlaneRenderer(this.device.getDevice(), this.device.getFormat(), this.pipeline.getSampleCount());
|
|
80
84
|
this.camera.setAspect(width / height);
|
|
81
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Load geometry from GeometryResult or MeshData array
|
|
88
|
+
* This is the main entry point for loading IFC geometry into the renderer
|
|
89
|
+
*
|
|
90
|
+
* @param geometry - Either a GeometryResult from geometry.process() or an array of MeshData
|
|
91
|
+
*/
|
|
92
|
+
loadGeometry(geometry) {
|
|
93
|
+
if (!this.device.isInitialized() || !this.pipeline) {
|
|
94
|
+
throw new Error('Renderer not initialized. Call init() first.');
|
|
95
|
+
}
|
|
96
|
+
const meshes = Array.isArray(geometry) ? geometry : geometry.meshes;
|
|
97
|
+
if (meshes.length === 0) {
|
|
98
|
+
console.warn('[Renderer] loadGeometry called with empty mesh array');
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
// Use batched rendering for optimal performance
|
|
102
|
+
const device = this.device.getDevice();
|
|
103
|
+
this.scene.appendToBatches(meshes, device, this.pipeline, false);
|
|
104
|
+
// Calculate and store model bounds for fitToView
|
|
105
|
+
this.updateModelBounds(meshes);
|
|
106
|
+
console.log(`[Renderer] Loaded ${meshes.length} meshes`);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Add multiple meshes to the scene (convenience method for streaming)
|
|
110
|
+
*
|
|
111
|
+
* @param meshes - Array of MeshData to add
|
|
112
|
+
* @param isStreaming - If true, throttles batch rebuilding for better streaming performance
|
|
113
|
+
*/
|
|
114
|
+
addMeshes(meshes, isStreaming = false) {
|
|
115
|
+
if (!this.device.isInitialized() || !this.pipeline) {
|
|
116
|
+
throw new Error('Renderer not initialized. Call init() first.');
|
|
117
|
+
}
|
|
118
|
+
if (meshes.length === 0)
|
|
119
|
+
return;
|
|
120
|
+
const device = this.device.getDevice();
|
|
121
|
+
this.scene.appendToBatches(meshes, device, this.pipeline, isStreaming);
|
|
122
|
+
// Update model bounds incrementally
|
|
123
|
+
this.updateModelBounds(meshes);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Update model bounds from mesh data
|
|
127
|
+
*/
|
|
128
|
+
updateModelBounds(meshes) {
|
|
129
|
+
if (!this.modelBounds) {
|
|
130
|
+
this.modelBounds = {
|
|
131
|
+
min: { x: Infinity, y: Infinity, z: Infinity },
|
|
132
|
+
max: { x: -Infinity, y: -Infinity, z: -Infinity }
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
for (const mesh of meshes) {
|
|
136
|
+
const positions = mesh.positions;
|
|
137
|
+
for (let i = 0; i < positions.length; i += 3) {
|
|
138
|
+
const x = positions[i];
|
|
139
|
+
const y = positions[i + 1];
|
|
140
|
+
const z = positions[i + 2];
|
|
141
|
+
if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z)) {
|
|
142
|
+
this.modelBounds.min.x = Math.min(this.modelBounds.min.x, x);
|
|
143
|
+
this.modelBounds.min.y = Math.min(this.modelBounds.min.y, y);
|
|
144
|
+
this.modelBounds.min.z = Math.min(this.modelBounds.min.z, z);
|
|
145
|
+
this.modelBounds.max.x = Math.max(this.modelBounds.max.x, x);
|
|
146
|
+
this.modelBounds.max.y = Math.max(this.modelBounds.max.y, y);
|
|
147
|
+
this.modelBounds.max.z = Math.max(this.modelBounds.max.z, z);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Fit camera to view all loaded geometry
|
|
154
|
+
*/
|
|
155
|
+
fitToView() {
|
|
156
|
+
if (!this.modelBounds) {
|
|
157
|
+
console.warn('[Renderer] fitToView called but no geometry loaded');
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const { min, max } = this.modelBounds;
|
|
161
|
+
// Calculate center and size
|
|
162
|
+
const center = {
|
|
163
|
+
x: (min.x + max.x) / 2,
|
|
164
|
+
y: (min.y + max.y) / 2,
|
|
165
|
+
z: (min.z + max.z) / 2
|
|
166
|
+
};
|
|
167
|
+
const size = Math.max(max.x - min.x, max.y - min.y, max.z - min.z);
|
|
168
|
+
// Position camera to see entire model
|
|
169
|
+
const distance = size * 1.5;
|
|
170
|
+
this.camera.setPosition(center.x + distance * 0.5, center.y + distance * 0.5, center.z + distance);
|
|
171
|
+
this.camera.setTarget(center.x, center.y, center.z);
|
|
172
|
+
}
|
|
82
173
|
/**
|
|
83
174
|
* Add mesh to scene with per-mesh GPU resources for unique colors
|
|
84
175
|
*/
|
|
@@ -308,6 +399,7 @@ export class Renderer {
|
|
|
308
399
|
// Add to scene with identity transform (positions already in world space)
|
|
309
400
|
this.scene.addMesh({
|
|
310
401
|
expressId: meshData.expressId,
|
|
402
|
+
modelIndex: meshData.modelIndex, // Preserve modelIndex for multi-model selection
|
|
311
403
|
vertexBuffer,
|
|
312
404
|
indexBuffer,
|
|
313
405
|
indexCount: meshData.indices.length,
|
|
@@ -459,50 +551,65 @@ export class Renderer {
|
|
|
459
551
|
const allMeshes = [...opaqueMeshes, ...transparentMeshes];
|
|
460
552
|
const selectedId = options.selectedId;
|
|
461
553
|
const selectedIds = options.selectedIds;
|
|
462
|
-
|
|
554
|
+
const selectedModelIndex = options.selectedModelIndex;
|
|
555
|
+
// Calculate section plane parameters and model bounds
|
|
556
|
+
// Always calculate bounds when sectionPlane is provided (for preview and active mode)
|
|
463
557
|
let sectionPlaneData;
|
|
464
|
-
if (options.sectionPlane
|
|
465
|
-
//
|
|
466
|
-
const normal = [0, 0, 0];
|
|
467
|
-
if (options.sectionPlane.axis === 'x')
|
|
468
|
-
normal[0] = 1;
|
|
469
|
-
else if (options.sectionPlane.axis === 'y')
|
|
470
|
-
normal[1] = 1;
|
|
471
|
-
else
|
|
472
|
-
normal[2] = 1;
|
|
473
|
-
// Get model bounds for calculating plane position and visual
|
|
558
|
+
if (options.sectionPlane) {
|
|
559
|
+
// Get model bounds from ALL geometry sources: individual meshes AND batched meshes
|
|
474
560
|
const boundsMin = { x: Infinity, y: Infinity, z: Infinity };
|
|
475
561
|
const boundsMax = { x: -Infinity, y: -Infinity, z: -Infinity };
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
}
|
|
562
|
+
// Check individual meshes
|
|
563
|
+
for (const mesh of meshes) {
|
|
564
|
+
if (mesh.bounds) {
|
|
565
|
+
boundsMin.x = Math.min(boundsMin.x, mesh.bounds.min[0]);
|
|
566
|
+
boundsMin.y = Math.min(boundsMin.y, mesh.bounds.min[1]);
|
|
567
|
+
boundsMin.z = Math.min(boundsMin.z, mesh.bounds.min[2]);
|
|
568
|
+
boundsMax.x = Math.max(boundsMax.x, mesh.bounds.max[0]);
|
|
569
|
+
boundsMax.y = Math.max(boundsMax.y, mesh.bounds.max[1]);
|
|
570
|
+
boundsMax.z = Math.max(boundsMax.z, mesh.bounds.max[2]);
|
|
486
571
|
}
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
572
|
+
}
|
|
573
|
+
// Check batched meshes (most geometry is here!)
|
|
574
|
+
const batchedMeshes = this.scene.getBatchedMeshes();
|
|
575
|
+
for (const batch of batchedMeshes) {
|
|
576
|
+
if (batch.bounds) {
|
|
577
|
+
boundsMin.x = Math.min(boundsMin.x, batch.bounds.min[0]);
|
|
578
|
+
boundsMin.y = Math.min(boundsMin.y, batch.bounds.min[1]);
|
|
579
|
+
boundsMin.z = Math.min(boundsMin.z, batch.bounds.min[2]);
|
|
580
|
+
boundsMax.x = Math.max(boundsMax.x, batch.bounds.max[0]);
|
|
581
|
+
boundsMax.y = Math.max(boundsMax.y, batch.bounds.max[1]);
|
|
582
|
+
boundsMax.z = Math.max(boundsMax.z, batch.bounds.max[2]);
|
|
490
583
|
}
|
|
491
584
|
}
|
|
492
|
-
|
|
585
|
+
// Fallback if no bounds found
|
|
586
|
+
if (!Number.isFinite(boundsMin.x)) {
|
|
493
587
|
boundsMin.x = boundsMin.y = boundsMin.z = -100;
|
|
494
588
|
boundsMax.x = boundsMax.y = boundsMax.z = 100;
|
|
495
589
|
}
|
|
496
590
|
// Store bounds for section plane visual
|
|
497
591
|
this.modelBounds = { min: boundsMin, max: boundsMax };
|
|
498
|
-
//
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
592
|
+
// Only calculate clipping data if section is enabled
|
|
593
|
+
if (options.sectionPlane.enabled) {
|
|
594
|
+
// Calculate plane normal based on semantic axis
|
|
595
|
+
// down = Y axis (horizontal cut), front = Z axis, side = X axis
|
|
596
|
+
const normal = [0, 0, 0];
|
|
597
|
+
if (options.sectionPlane.axis === 'side')
|
|
598
|
+
normal[0] = 1; // X axis
|
|
599
|
+
else if (options.sectionPlane.axis === 'down')
|
|
600
|
+
normal[1] = 1; // Y axis (horizontal)
|
|
601
|
+
else
|
|
602
|
+
normal[2] = 1; // Z axis (front)
|
|
603
|
+
// Get axis-specific range based on semantic axis
|
|
604
|
+
// Use min/max overrides from sectionPlane if provided (storey-based range)
|
|
605
|
+
const axisIdx = options.sectionPlane.axis === 'side' ? 'x' : options.sectionPlane.axis === 'down' ? 'y' : 'z';
|
|
606
|
+
const minVal = options.sectionPlane.min ?? boundsMin[axisIdx];
|
|
607
|
+
const maxVal = options.sectionPlane.max ?? boundsMax[axisIdx];
|
|
608
|
+
// Calculate plane distance from position percentage
|
|
609
|
+
const range = maxVal - minVal;
|
|
610
|
+
const distance = minVal + (options.sectionPlane.position / 100) * range;
|
|
611
|
+
sectionPlaneData = { normal, distance, enabled: true };
|
|
612
|
+
}
|
|
506
613
|
}
|
|
507
614
|
for (const mesh of allMeshes) {
|
|
508
615
|
if (mesh.uniformBuffer) {
|
|
@@ -512,7 +619,10 @@ export class Renderer {
|
|
|
512
619
|
buffer.set(viewProj, 0);
|
|
513
620
|
buffer.set(mesh.transform.m, 16);
|
|
514
621
|
// Check if mesh is selected (single or multi-selection)
|
|
515
|
-
|
|
622
|
+
// For multi-model support: also check modelIndex if provided
|
|
623
|
+
const expressIdMatch = mesh.expressId === selectedId;
|
|
624
|
+
const modelIndexMatch = selectedModelIndex === undefined || mesh.modelIndex === selectedModelIndex;
|
|
625
|
+
const isSelected = (selectedId !== undefined && selectedId !== null && expressIdMatch && modelIndexMatch)
|
|
516
626
|
|| (selectedIds !== undefined && selectedIds.has(mesh.expressId));
|
|
517
627
|
// Apply selection highlight effect
|
|
518
628
|
if (isSelected) {
|
|
@@ -598,26 +708,33 @@ export class Renderer {
|
|
|
598
708
|
// need individual mesh rendering to show only the visible elements
|
|
599
709
|
const opaqueBatches = [];
|
|
600
710
|
const transparentBatches = [];
|
|
601
|
-
//
|
|
602
|
-
//
|
|
603
|
-
const
|
|
711
|
+
// PERFORMANCE FIX: Track partially visible batches for sub-batch rendering
|
|
712
|
+
// Instead of creating 10,000+ individual meshes, we create cached sub-batches
|
|
713
|
+
const partiallyVisibleBatches = [];
|
|
604
714
|
for (const batch of allBatchedMeshes) {
|
|
605
715
|
// Check visibility
|
|
606
716
|
if (hasVisibilityFiltering) {
|
|
607
717
|
const vis = batchVisibility.get(batch.colorKey);
|
|
608
718
|
if (!vis || !vis.visible)
|
|
609
719
|
continue; // Skip completely hidden batches
|
|
610
|
-
//
|
|
611
|
-
// will be rendered individually below
|
|
720
|
+
// Handle partially visible batches - create sub-batches instead of individual meshes
|
|
612
721
|
if (!vis.fullyVisible) {
|
|
613
722
|
// Collect the visible expressIds from this batch
|
|
723
|
+
const visibleIds = new Set();
|
|
614
724
|
for (const expressId of batch.expressIds) {
|
|
615
725
|
const isHidden = options.hiddenIds?.has(expressId) ?? false;
|
|
616
726
|
const isIsolated = !hasIsolatedFilter || options.isolatedIds.has(expressId);
|
|
617
727
|
if (!isHidden && isIsolated) {
|
|
618
|
-
|
|
728
|
+
visibleIds.add(expressId);
|
|
619
729
|
}
|
|
620
730
|
}
|
|
731
|
+
if (visibleIds.size > 0) {
|
|
732
|
+
partiallyVisibleBatches.push({
|
|
733
|
+
colorKey: batch.colorKey,
|
|
734
|
+
visibleIds,
|
|
735
|
+
color: batch.color,
|
|
736
|
+
});
|
|
737
|
+
}
|
|
621
738
|
continue; // Don't add batch to render list
|
|
622
739
|
}
|
|
623
740
|
}
|
|
@@ -680,77 +797,32 @@ export class Renderer {
|
|
|
680
797
|
for (const batch of opaqueBatches) {
|
|
681
798
|
renderBatch(batch);
|
|
682
799
|
}
|
|
683
|
-
// Render partially visible
|
|
684
|
-
// This is the
|
|
800
|
+
// PERFORMANCE FIX: Render partially visible batches as sub-batches (not individual meshes!)
|
|
801
|
+
// This is the key optimization: instead of 10,000+ individual draw calls,
|
|
802
|
+
// we create cached sub-batches with only visible elements and render them as single draw calls
|
|
685
803
|
const allMeshes = this.scene.getMeshes();
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
});
|
|
705
|
-
mesh.bindGroup = device.createBindGroup({
|
|
706
|
-
layout: this.pipeline.getBindGroupLayout(),
|
|
707
|
-
entries: [
|
|
708
|
-
{
|
|
709
|
-
binding: 0,
|
|
710
|
-
resource: { buffer: mesh.uniformBuffer },
|
|
711
|
-
},
|
|
712
|
-
],
|
|
713
|
-
});
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
// Render partially visible meshes (not selected, normal rendering)
|
|
717
|
-
for (const mesh of partialMeshes) {
|
|
718
|
-
if (!mesh.bindGroup || !mesh.uniformBuffer) {
|
|
719
|
-
continue;
|
|
720
|
-
}
|
|
721
|
-
const buffer = new Float32Array(48);
|
|
722
|
-
const flagBuffer = new Uint32Array(buffer.buffer, 176, 4);
|
|
723
|
-
buffer.set(viewProj, 0);
|
|
724
|
-
buffer.set(mesh.transform.m, 16);
|
|
725
|
-
buffer.set(mesh.color, 32);
|
|
726
|
-
buffer[36] = mesh.material?.metallic ?? 0.0;
|
|
727
|
-
buffer[37] = mesh.material?.roughness ?? 0.6;
|
|
728
|
-
// Section plane data
|
|
729
|
-
if (sectionPlaneData) {
|
|
730
|
-
buffer[40] = sectionPlaneData.normal[0];
|
|
731
|
-
buffer[41] = sectionPlaneData.normal[1];
|
|
732
|
-
buffer[42] = sectionPlaneData.normal[2];
|
|
733
|
-
buffer[43] = sectionPlaneData.distance;
|
|
734
|
-
}
|
|
735
|
-
// Flags (not selected)
|
|
736
|
-
flagBuffer[0] = 0;
|
|
737
|
-
flagBuffer[1] = sectionPlaneData?.enabled ? 1 : 0;
|
|
738
|
-
flagBuffer[2] = 0;
|
|
739
|
-
flagBuffer[3] = 0;
|
|
740
|
-
device.queue.writeBuffer(mesh.uniformBuffer, 0, buffer);
|
|
741
|
-
// Use opaque or transparent pipeline based on alpha
|
|
742
|
-
const isTransparent = mesh.color[3] < 0.99;
|
|
743
|
-
if (isTransparent) {
|
|
744
|
-
pass.setPipeline(this.pipeline.getTransparentPipeline());
|
|
745
|
-
}
|
|
746
|
-
else {
|
|
747
|
-
pass.setPipeline(this.pipeline.getPipeline());
|
|
804
|
+
// Track existing meshes by (expressId:modelIndex) to handle multi-model expressId collisions
|
|
805
|
+
// E.g., door #535 in model 0 vs beam #535 in model 1 need separate tracking
|
|
806
|
+
const existingMeshKeys = new Set(allMeshes.map(m => `${m.expressId}:${m.modelIndex ?? 'any'}`));
|
|
807
|
+
if (partiallyVisibleBatches.length > 0) {
|
|
808
|
+
for (const { colorKey, visibleIds, color } of partiallyVisibleBatches) {
|
|
809
|
+
// Get or create a cached sub-batch for this visibility state
|
|
810
|
+
const subBatch = this.scene.getOrCreatePartialBatch(colorKey, visibleIds, device, this.pipeline);
|
|
811
|
+
if (subBatch) {
|
|
812
|
+
// Use opaque or transparent pipeline based on alpha
|
|
813
|
+
const isTransparent = color[3] < 0.99;
|
|
814
|
+
if (isTransparent) {
|
|
815
|
+
pass.setPipeline(this.pipeline.getTransparentPipeline());
|
|
816
|
+
}
|
|
817
|
+
else {
|
|
818
|
+
pass.setPipeline(this.pipeline.getPipeline());
|
|
819
|
+
}
|
|
820
|
+
// Render the sub-batch as a single draw call
|
|
821
|
+
renderBatch(subBatch);
|
|
748
822
|
}
|
|
749
|
-
pass.setBindGroup(0, mesh.bindGroup);
|
|
750
|
-
pass.setVertexBuffer(0, mesh.vertexBuffer);
|
|
751
|
-
pass.setIndexBuffer(mesh.indexBuffer, 'uint32');
|
|
752
|
-
pass.drawIndexed(mesh.indexCount, 1, 0, 0, 0);
|
|
753
823
|
}
|
|
824
|
+
// Reset to opaque pipeline for subsequent rendering
|
|
825
|
+
pass.setPipeline(this.pipeline.getPipeline());
|
|
754
826
|
}
|
|
755
827
|
// Render selected meshes individually for proper highlighting
|
|
756
828
|
// First, check if we have Mesh objects for selected IDs
|
|
@@ -768,15 +840,26 @@ export class Renderer {
|
|
|
768
840
|
visibleSelectedIds.add(selId);
|
|
769
841
|
}
|
|
770
842
|
// Create GPU resources lazily for visible selected meshes that don't have them yet
|
|
843
|
+
// Pass selectedModelIndex to get mesh data from the correct model (for multi-model support)
|
|
844
|
+
// Use composite key to handle expressId collisions between models
|
|
771
845
|
for (const selId of visibleSelectedIds) {
|
|
772
|
-
|
|
773
|
-
|
|
846
|
+
const meshKey = `${selId}:${selectedModelIndex ?? 'any'}`;
|
|
847
|
+
if (!existingMeshKeys.has(meshKey) && this.scene.hasMeshData(selId, selectedModelIndex)) {
|
|
848
|
+
const meshData = this.scene.getMeshData(selId, selectedModelIndex);
|
|
774
849
|
this.createMeshFromData(meshData);
|
|
775
|
-
|
|
850
|
+
existingMeshKeys.add(meshKey);
|
|
776
851
|
}
|
|
777
852
|
}
|
|
778
853
|
// Now get selected meshes (only visible ones)
|
|
779
|
-
|
|
854
|
+
// For multi-model support: also filter by modelIndex if provided
|
|
855
|
+
const selectedMeshes = this.scene.getMeshes().filter(mesh => {
|
|
856
|
+
if (!visibleSelectedIds.has(mesh.expressId))
|
|
857
|
+
return false;
|
|
858
|
+
// If selectedModelIndex is provided, also match modelIndex
|
|
859
|
+
if (selectedModelIndex !== undefined && mesh.modelIndex !== selectedModelIndex)
|
|
860
|
+
return false;
|
|
861
|
+
return true;
|
|
862
|
+
});
|
|
780
863
|
// Ensure selected meshes have uniform buffers and bind groups
|
|
781
864
|
for (const mesh of selectedMeshes) {
|
|
782
865
|
if (!mesh.uniformBuffer && this.pipeline) {
|
|
@@ -919,24 +1002,30 @@ export class Renderer {
|
|
|
919
1002
|
}
|
|
920
1003
|
}
|
|
921
1004
|
}
|
|
922
|
-
pass.end()
|
|
923
|
-
//
|
|
924
|
-
if (
|
|
925
|
-
this.sectionPlaneRenderer.
|
|
1005
|
+
// Draw section plane visual BEFORE pass.end() (within same MSAA render pass)
|
|
1006
|
+
// Always show plane when sectionPlane options are provided (as preview or active)
|
|
1007
|
+
if (options.sectionPlane && this.sectionPlaneRenderer && this.modelBounds) {
|
|
1008
|
+
this.sectionPlaneRenderer.draw(pass, {
|
|
926
1009
|
axis: options.sectionPlane.axis,
|
|
927
1010
|
position: options.sectionPlane.position,
|
|
928
1011
|
bounds: this.modelBounds,
|
|
929
1012
|
viewProj,
|
|
1013
|
+
isPreview: !options.sectionPlane.enabled, // Preview mode when not enabled
|
|
1014
|
+
min: options.sectionPlane.min,
|
|
1015
|
+
max: options.sectionPlane.max,
|
|
930
1016
|
});
|
|
931
1017
|
}
|
|
1018
|
+
pass.end();
|
|
932
1019
|
device.queue.submit([encoder.finish()]);
|
|
933
1020
|
}
|
|
934
1021
|
catch (error) {
|
|
935
1022
|
// Handle WebGPU errors (e.g., device lost, invalid state)
|
|
936
1023
|
// Mark context as invalid so it gets reconfigured next frame
|
|
937
1024
|
this.device.invalidateContext();
|
|
938
|
-
//
|
|
939
|
-
|
|
1025
|
+
// Rate-limit error logging to avoid spam (max once per second)
|
|
1026
|
+
const now = performance.now();
|
|
1027
|
+
if (now - this.lastRenderErrorTime > this.RENDER_ERROR_THROTTLE_MS) {
|
|
1028
|
+
this.lastRenderErrorTime = now;
|
|
940
1029
|
console.warn('Render error (context will be reconfigured):', error);
|
|
941
1030
|
}
|
|
942
1031
|
}
|
|
@@ -944,6 +1033,7 @@ export class Renderer {
|
|
|
944
1033
|
/**
|
|
945
1034
|
* Pick object at screen coordinates
|
|
946
1035
|
* Respects visibility filtering so users can only select visible elements
|
|
1036
|
+
* Returns PickResult with expressId and modelIndex for multi-model support
|
|
947
1037
|
*/
|
|
948
1038
|
async pick(x, y, options) {
|
|
949
1039
|
if (!this.picker) {
|
|
@@ -967,19 +1057,27 @@ export class Renderer {
|
|
|
967
1057
|
expressIds.add(expressId);
|
|
968
1058
|
}
|
|
969
1059
|
}
|
|
970
|
-
// Track existing
|
|
971
|
-
|
|
1060
|
+
// Track existing meshes by (expressId:modelIndex) for multi-model support
|
|
1061
|
+
// This handles expressId collisions (e.g., door #535 in model 0 vs beam #535 in model 1)
|
|
1062
|
+
const existingMeshKeys = new Set(meshes.map(m => `${m.expressId}:${m.modelIndex ?? 'any'}`));
|
|
972
1063
|
// Count how many meshes we'd need to create for full GPU picking
|
|
1064
|
+
// For multi-model, count all pieces with unique (expressId, modelIndex) pairs
|
|
973
1065
|
let toCreate = 0;
|
|
974
1066
|
for (const expressId of expressIds) {
|
|
975
|
-
if (existingExpressIds.has(expressId))
|
|
976
|
-
continue;
|
|
977
1067
|
if (options?.hiddenIds?.has(expressId))
|
|
978
1068
|
continue;
|
|
979
1069
|
if (options?.isolatedIds !== null && options?.isolatedIds !== undefined && !options.isolatedIds.has(expressId))
|
|
980
1070
|
continue;
|
|
981
|
-
|
|
982
|
-
|
|
1071
|
+
// Get all pieces for this expressId (handles multi-model)
|
|
1072
|
+
const pieces = this.scene.getMeshDataPieces(expressId);
|
|
1073
|
+
if (pieces) {
|
|
1074
|
+
for (const piece of pieces) {
|
|
1075
|
+
const meshKey = `${expressId}:${piece.modelIndex ?? 'any'}`;
|
|
1076
|
+
if (!existingMeshKeys.has(meshKey)) {
|
|
1077
|
+
toCreate++;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
983
1081
|
}
|
|
984
1082
|
// PERFORMANCE FIX: Use CPU raycasting for large models instead of creating GPU meshes
|
|
985
1083
|
// GPU picking requires individual mesh buffers; for 60K+ elements this is too slow
|
|
@@ -989,25 +1087,34 @@ export class Renderer {
|
|
|
989
1087
|
// Use CPU raycasting fallback - works regardless of how many individual meshes exist
|
|
990
1088
|
const ray = this.camera.unprojectToRay(x, y, this.canvas.width, this.canvas.height);
|
|
991
1089
|
const hit = this.scene.raycast(ray.origin, ray.direction, options?.hiddenIds, options?.isolatedIds);
|
|
992
|
-
|
|
1090
|
+
if (!hit)
|
|
1091
|
+
return null;
|
|
1092
|
+
// CPU raycasting returns expressId and modelIndex
|
|
1093
|
+
return {
|
|
1094
|
+
expressId: hit.expressId,
|
|
1095
|
+
modelIndex: hit.modelIndex,
|
|
1096
|
+
};
|
|
993
1097
|
}
|
|
994
1098
|
// For smaller models, create GPU meshes for picking
|
|
995
1099
|
// Only create meshes for VISIBLE elements (not hidden, and either no isolation or in isolated set)
|
|
1100
|
+
// For multi-model support: create meshes for ALL (expressId, modelIndex) pairs
|
|
996
1101
|
for (const expressId of expressIds) {
|
|
997
|
-
// Skip if already exists
|
|
998
|
-
if (existingExpressIds.has(expressId))
|
|
999
|
-
continue;
|
|
1000
1102
|
// Skip if hidden
|
|
1001
1103
|
if (options?.hiddenIds?.has(expressId))
|
|
1002
1104
|
continue;
|
|
1003
1105
|
// Skip if isolation is active and this entity is not isolated
|
|
1004
1106
|
if (options?.isolatedIds !== null && options?.isolatedIds !== undefined && !options.isolatedIds.has(expressId))
|
|
1005
1107
|
continue;
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1108
|
+
// Get all pieces for this expressId (handles multi-model)
|
|
1109
|
+
const pieces = this.scene.getMeshDataPieces(expressId);
|
|
1110
|
+
if (pieces) {
|
|
1111
|
+
for (const piece of pieces) {
|
|
1112
|
+
const meshKey = `${piece.expressId}:${piece.modelIndex ?? 'any'}`;
|
|
1113
|
+
// Skip if mesh already exists for this (expressId, modelIndex) pair
|
|
1114
|
+
if (existingMeshKeys.has(meshKey))
|
|
1115
|
+
continue;
|
|
1116
|
+
this.createMeshFromData(piece);
|
|
1117
|
+
existingMeshKeys.add(meshKey);
|
|
1011
1118
|
}
|
|
1012
1119
|
}
|
|
1013
1120
|
}
|
|
@@ -1119,6 +1226,108 @@ export class Renderer {
|
|
|
1119
1226
|
return null;
|
|
1120
1227
|
}
|
|
1121
1228
|
}
|
|
1229
|
+
/**
|
|
1230
|
+
* Raycast with magnetic edge snapping behavior
|
|
1231
|
+
* This provides the "stick and slide along edges" experience
|
|
1232
|
+
*/
|
|
1233
|
+
raycastSceneMagnetic(x, y, currentEdgeLock, options) {
|
|
1234
|
+
try {
|
|
1235
|
+
// Create ray from screen coordinates
|
|
1236
|
+
const ray = this.camera.unprojectToRay(x, y, this.canvas.width, this.canvas.height);
|
|
1237
|
+
// Get all mesh data from scene
|
|
1238
|
+
const allMeshData = [];
|
|
1239
|
+
const meshes = this.scene.getMeshes();
|
|
1240
|
+
const batchedMeshes = this.scene.getBatchedMeshes();
|
|
1241
|
+
// Collect mesh data from regular meshes
|
|
1242
|
+
for (const mesh of meshes) {
|
|
1243
|
+
const meshData = this.scene.getMeshData(mesh.expressId);
|
|
1244
|
+
if (meshData) {
|
|
1245
|
+
if (options?.hiddenIds?.has(meshData.expressId))
|
|
1246
|
+
continue;
|
|
1247
|
+
if (options?.isolatedIds !== null &&
|
|
1248
|
+
options?.isolatedIds !== undefined &&
|
|
1249
|
+
!options.isolatedIds.has(meshData.expressId)) {
|
|
1250
|
+
continue;
|
|
1251
|
+
}
|
|
1252
|
+
allMeshData.push(meshData);
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
// Collect mesh data from batched meshes
|
|
1256
|
+
for (const batch of batchedMeshes) {
|
|
1257
|
+
for (const expressId of batch.expressIds) {
|
|
1258
|
+
const meshData = this.scene.getMeshData(expressId);
|
|
1259
|
+
if (meshData) {
|
|
1260
|
+
if (options?.hiddenIds?.has(meshData.expressId))
|
|
1261
|
+
continue;
|
|
1262
|
+
if (options?.isolatedIds !== null &&
|
|
1263
|
+
options?.isolatedIds !== undefined &&
|
|
1264
|
+
!options.isolatedIds.has(meshData.expressId)) {
|
|
1265
|
+
continue;
|
|
1266
|
+
}
|
|
1267
|
+
allMeshData.push(meshData);
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
if (allMeshData.length === 0) {
|
|
1272
|
+
return {
|
|
1273
|
+
intersection: null,
|
|
1274
|
+
snapTarget: null,
|
|
1275
|
+
edgeLock: {
|
|
1276
|
+
edge: null,
|
|
1277
|
+
meshExpressId: null,
|
|
1278
|
+
edgeT: 0,
|
|
1279
|
+
shouldLock: false,
|
|
1280
|
+
shouldRelease: true,
|
|
1281
|
+
isCorner: false,
|
|
1282
|
+
cornerValence: 0,
|
|
1283
|
+
},
|
|
1284
|
+
};
|
|
1285
|
+
}
|
|
1286
|
+
// Use BVH for performance if we have many meshes
|
|
1287
|
+
let meshesToTest = allMeshData;
|
|
1288
|
+
if (allMeshData.length > this.BVH_THRESHOLD) {
|
|
1289
|
+
const needsRebuild = !this.bvhCache ||
|
|
1290
|
+
!this.bvhCache.isBuilt ||
|
|
1291
|
+
this.bvhCache.meshCount !== allMeshData.length;
|
|
1292
|
+
if (needsRebuild) {
|
|
1293
|
+
this.bvh.build(allMeshData);
|
|
1294
|
+
this.bvhCache = {
|
|
1295
|
+
meshCount: allMeshData.length,
|
|
1296
|
+
meshData: allMeshData,
|
|
1297
|
+
isBuilt: true,
|
|
1298
|
+
};
|
|
1299
|
+
}
|
|
1300
|
+
const meshIndices = this.bvh.getMeshesForRay(ray, allMeshData);
|
|
1301
|
+
meshesToTest = meshIndices.map(i => allMeshData[i]);
|
|
1302
|
+
}
|
|
1303
|
+
// Perform raycasting
|
|
1304
|
+
const intersection = this.raycaster.raycast(ray, meshesToTest);
|
|
1305
|
+
// Use magnetic snap detection
|
|
1306
|
+
const cameraPos = this.camera.getPosition();
|
|
1307
|
+
const cameraFov = this.camera.getFOV();
|
|
1308
|
+
const magneticResult = this.snapDetector.detectMagneticSnap(ray, meshesToTest, intersection, { position: cameraPos, fov: cameraFov }, this.canvas.height, currentEdgeLock, options?.snapOptions || {});
|
|
1309
|
+
return {
|
|
1310
|
+
intersection,
|
|
1311
|
+
...magneticResult,
|
|
1312
|
+
};
|
|
1313
|
+
}
|
|
1314
|
+
catch (error) {
|
|
1315
|
+
console.error('Magnetic raycast error:', error);
|
|
1316
|
+
return {
|
|
1317
|
+
intersection: null,
|
|
1318
|
+
snapTarget: null,
|
|
1319
|
+
edgeLock: {
|
|
1320
|
+
edge: null,
|
|
1321
|
+
meshExpressId: null,
|
|
1322
|
+
edgeT: 0,
|
|
1323
|
+
shouldLock: false,
|
|
1324
|
+
shouldRelease: true,
|
|
1325
|
+
isCorner: false,
|
|
1326
|
+
cornerValence: 0,
|
|
1327
|
+
},
|
|
1328
|
+
};
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1122
1331
|
/**
|
|
1123
1332
|
* Invalidate BVH cache (call when geometry changes)
|
|
1124
1333
|
*/
|