@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/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
- // Calculate section plane parameters if enabled
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?.enabled) {
465
- // Calculate plane normal based on axis
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
- if (meshes.length > 0) {
477
- for (const mesh of meshes) {
478
- if (mesh.bounds) {
479
- boundsMin.x = Math.min(boundsMin.x, mesh.bounds.min[0]);
480
- boundsMin.y = Math.min(boundsMin.y, mesh.bounds.min[1]);
481
- boundsMin.z = Math.min(boundsMin.z, mesh.bounds.min[2]);
482
- boundsMax.x = Math.max(boundsMax.x, mesh.bounds.max[0]);
483
- boundsMax.y = Math.max(boundsMax.y, mesh.bounds.max[1]);
484
- boundsMax.z = Math.max(boundsMax.z, mesh.bounds.max[2]);
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
- if (!Number.isFinite(boundsMin.x)) {
488
- boundsMin.x = boundsMin.y = boundsMin.z = -100;
489
- boundsMax.x = boundsMax.y = boundsMax.z = 100;
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
- else {
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
- // Get axis-specific range
499
- const axisIdx = options.sectionPlane.axis === 'x' ? 'x' : options.sectionPlane.axis === 'y' ? 'y' : 'z';
500
- const minVal = boundsMin[axisIdx];
501
- const maxVal = boundsMax[axisIdx];
502
- // Calculate plane distance from position percentage
503
- const range = maxVal - minVal;
504
- const distance = minVal + (options.sectionPlane.position / 100) * range;
505
- sectionPlaneData = { normal, distance, enabled: true };
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
- const isSelected = (selectedId !== undefined && selectedId !== null && mesh.expressId === selectedId)
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
- // Collect visible expressIds from partially visible batches
602
- // These need individual mesh rendering instead of batch rendering
603
- const partiallyVisibleExpressIds = new Set();
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
- // FIX: Skip partially visible batches - their visible elements
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
- partiallyVisibleExpressIds.add(expressId);
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 elements individually (from batches that had mixed visibility)
684
- // This is the FIX for hide/isolate showing wrong batches
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
- const existingMeshIds = new Set(allMeshes.map(m => m.expressId));
687
- if (partiallyVisibleExpressIds.size > 0) {
688
- // Create GPU resources lazily for partially visible meshes
689
- for (const pvId of partiallyVisibleExpressIds) {
690
- if (!existingMeshIds.has(pvId) && this.scene.hasMeshData(pvId)) {
691
- const meshData = this.scene.getMeshData(pvId);
692
- this.createMeshFromData(meshData);
693
- existingMeshIds.add(pvId);
694
- }
695
- }
696
- // Get partially visible meshes and render them
697
- const partialMeshes = this.scene.getMeshes().filter(mesh => partiallyVisibleExpressIds.has(mesh.expressId));
698
- // Ensure partial meshes have uniform buffers and bind groups
699
- for (const mesh of partialMeshes) {
700
- if (!mesh.uniformBuffer && this.pipeline) {
701
- mesh.uniformBuffer = device.createBuffer({
702
- size: this.pipeline.getUniformBufferSize(),
703
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
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
- if (!existingMeshIds.has(selId) && this.scene.hasMeshData(selId)) {
773
- const meshData = this.scene.getMeshData(selId);
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
- existingMeshIds.add(selId);
850
+ existingMeshKeys.add(meshKey);
776
851
  }
777
852
  }
778
853
  // Now get selected meshes (only visible ones)
779
- const selectedMeshes = this.scene.getMeshes().filter(mesh => visibleSelectedIds.has(mesh.expressId));
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
- // Render section plane visual if enabled
924
- if (sectionPlaneData?.enabled && this.sectionPlaneRenderer && this.modelBounds) {
925
- this.sectionPlaneRenderer.render(encoder, textureView, this.pipeline.getDepthTextureView(), {
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
- // Only log occasional errors to avoid spam
939
- if (Math.random() < 0.01) {
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 expressIds to avoid duplicates (using Set for O(1) lookup)
971
- const existingExpressIds = new Set(meshes.map(m => m.expressId));
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
- if (this.scene.hasMeshData(expressId))
982
- toCreate++;
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
- return hit ? hit.expressId : null;
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
- if (this.scene.hasMeshData(expressId)) {
1007
- const meshData = this.scene.getMeshData(expressId);
1008
- if (meshData) {
1009
- this.createMeshFromData(meshData);
1010
- existingExpressIds.add(expressId); // Track newly created mesh
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
  */