@ifc-lite/renderer 1.2.1 → 1.4.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
@@ -11,9 +11,11 @@ export { Scene } from './scene.js';
11
11
  export { Picker } from './picker.js';
12
12
  export { MathUtils } from './math.js';
13
13
  export { SectionPlaneRenderer } from './section-plane.js';
14
+ export { Section2DOverlayRenderer } from './section-2d-overlay.js';
14
15
  export { Raycaster } from './raycaster.js';
15
16
  export { SnapDetector, SnapType } from './snap-detector.js';
16
17
  export { BVH } from './bvh.js';
18
+ export { FederationRegistry, federationRegistry } from './federation-registry.js';
17
19
  export * from './types.js';
18
20
  // Zero-copy GPU upload (new - faster, less memory)
19
21
  export { ZeroCopyGpuUploader, createZeroCopyUploader, } from './zero-copy-uploader.js';
@@ -24,6 +26,7 @@ import { Scene } from './scene.js';
24
26
  import { Picker } from './picker.js';
25
27
  import { FrustumUtils } from '@ifc-lite/spatial';
26
28
  import { SectionPlaneRenderer } from './section-plane.js';
29
+ import { Section2DOverlayRenderer } from './section-2d-overlay.js';
27
30
  import { deduplicateMeshes } from '@ifc-lite/geometry';
28
31
  import { MathUtils } from './math.js';
29
32
  import { Raycaster } from './raycaster.js';
@@ -41,6 +44,7 @@ export class Renderer {
41
44
  picker = null;
42
45
  canvas;
43
46
  sectionPlaneRenderer = null;
47
+ section2DOverlayRenderer = null;
44
48
  modelBounds = null;
45
49
  raycaster;
46
50
  snapDetector;
@@ -49,6 +53,9 @@ export class Renderer {
49
53
  bvhCache = null;
50
54
  // Performance constants
51
55
  BVH_THRESHOLD = 100;
56
+ // Error rate limiting (log at most once per second)
57
+ lastRenderErrorTime = 0;
58
+ RENDER_ERROR_THROTTLE_MS = 1000;
52
59
  constructor(canvas) {
53
60
  this.canvas = canvas;
54
61
  this.device = new WebGPUDevice();
@@ -77,8 +84,96 @@ export class Renderer {
77
84
  this.instancedPipeline = new InstancedRenderPipeline(this.device, width, height);
78
85
  this.picker = new Picker(this.device, width, height);
79
86
  this.sectionPlaneRenderer = new SectionPlaneRenderer(this.device.getDevice(), this.device.getFormat(), this.pipeline.getSampleCount());
87
+ this.section2DOverlayRenderer = new Section2DOverlayRenderer(this.device.getDevice(), this.device.getFormat(), this.pipeline.getSampleCount());
80
88
  this.camera.setAspect(width / height);
81
89
  }
90
+ /**
91
+ * Load geometry from GeometryResult or MeshData array
92
+ * This is the main entry point for loading IFC geometry into the renderer
93
+ *
94
+ * @param geometry - Either a GeometryResult from geometry.process() or an array of MeshData
95
+ */
96
+ loadGeometry(geometry) {
97
+ if (!this.device.isInitialized() || !this.pipeline) {
98
+ throw new Error('Renderer not initialized. Call init() first.');
99
+ }
100
+ const meshes = Array.isArray(geometry) ? geometry : geometry.meshes;
101
+ if (meshes.length === 0) {
102
+ console.warn('[Renderer] loadGeometry called with empty mesh array');
103
+ return;
104
+ }
105
+ // Use batched rendering for optimal performance
106
+ const device = this.device.getDevice();
107
+ this.scene.appendToBatches(meshes, device, this.pipeline, false);
108
+ // Calculate and store model bounds for fitToView
109
+ this.updateModelBounds(meshes);
110
+ console.log(`[Renderer] Loaded ${meshes.length} meshes`);
111
+ }
112
+ /**
113
+ * Add multiple meshes to the scene (convenience method for streaming)
114
+ *
115
+ * @param meshes - Array of MeshData to add
116
+ * @param isStreaming - If true, throttles batch rebuilding for better streaming performance
117
+ */
118
+ addMeshes(meshes, isStreaming = false) {
119
+ if (!this.device.isInitialized() || !this.pipeline) {
120
+ throw new Error('Renderer not initialized. Call init() first.');
121
+ }
122
+ if (meshes.length === 0)
123
+ return;
124
+ const device = this.device.getDevice();
125
+ this.scene.appendToBatches(meshes, device, this.pipeline, isStreaming);
126
+ // Update model bounds incrementally
127
+ this.updateModelBounds(meshes);
128
+ }
129
+ /**
130
+ * Update model bounds from mesh data
131
+ */
132
+ updateModelBounds(meshes) {
133
+ if (!this.modelBounds) {
134
+ this.modelBounds = {
135
+ min: { x: Infinity, y: Infinity, z: Infinity },
136
+ max: { x: -Infinity, y: -Infinity, z: -Infinity }
137
+ };
138
+ }
139
+ for (const mesh of meshes) {
140
+ const positions = mesh.positions;
141
+ for (let i = 0; i < positions.length; i += 3) {
142
+ const x = positions[i];
143
+ const y = positions[i + 1];
144
+ const z = positions[i + 2];
145
+ if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z)) {
146
+ this.modelBounds.min.x = Math.min(this.modelBounds.min.x, x);
147
+ this.modelBounds.min.y = Math.min(this.modelBounds.min.y, y);
148
+ this.modelBounds.min.z = Math.min(this.modelBounds.min.z, z);
149
+ this.modelBounds.max.x = Math.max(this.modelBounds.max.x, x);
150
+ this.modelBounds.max.y = Math.max(this.modelBounds.max.y, y);
151
+ this.modelBounds.max.z = Math.max(this.modelBounds.max.z, z);
152
+ }
153
+ }
154
+ }
155
+ }
156
+ /**
157
+ * Fit camera to view all loaded geometry
158
+ */
159
+ fitToView() {
160
+ if (!this.modelBounds) {
161
+ console.warn('[Renderer] fitToView called but no geometry loaded');
162
+ return;
163
+ }
164
+ const { min, max } = this.modelBounds;
165
+ // Calculate center and size
166
+ const center = {
167
+ x: (min.x + max.x) / 2,
168
+ y: (min.y + max.y) / 2,
169
+ z: (min.z + max.z) / 2
170
+ };
171
+ const size = Math.max(max.x - min.x, max.y - min.y, max.z - min.z);
172
+ // Position camera to see entire model
173
+ const distance = size * 1.5;
174
+ this.camera.setPosition(center.x + distance * 0.5, center.y + distance * 0.5, center.z + distance);
175
+ this.camera.setTarget(center.x, center.y, center.z);
176
+ }
82
177
  /**
83
178
  * Add mesh to scene with per-mesh GPU resources for unique colors
84
179
  */
@@ -308,6 +403,7 @@ export class Renderer {
308
403
  // Add to scene with identity transform (positions already in world space)
309
404
  this.scene.addMesh({
310
405
  expressId: meshData.expressId,
406
+ modelIndex: meshData.modelIndex, // Preserve modelIndex for multi-model selection
311
407
  vertexBuffer,
312
408
  indexBuffer,
313
409
  indexCount: meshData.indices.length,
@@ -356,11 +452,13 @@ export class Renderer {
356
452
  if (!this.device.isInitialized() || !this.pipeline)
357
453
  return;
358
454
  // Validate canvas dimensions
455
+ // Align width to 64 pixels for WebGPU texture row alignment (256 bytes / 4 bytes per pixel)
359
456
  const rect = this.canvas.getBoundingClientRect();
360
- const width = Math.max(1, Math.floor(rect.width));
457
+ const rawWidth = Math.max(1, Math.floor(rect.width));
458
+ const width = Math.max(64, Math.floor(rawWidth / 64) * 64);
361
459
  const height = Math.max(1, Math.floor(rect.height));
362
460
  // Skip rendering if canvas is too small
363
- if (width < 10 || height < 10)
461
+ if (width < 64 || height < 10)
364
462
  return;
365
463
  // Update canvas pixel dimensions if needed
366
464
  const dimensionsChanged = this.canvas.width !== width || this.canvas.height !== height;
@@ -459,6 +557,7 @@ export class Renderer {
459
557
  const allMeshes = [...opaqueMeshes, ...transparentMeshes];
460
558
  const selectedId = options.selectedId;
461
559
  const selectedIds = options.selectedIds;
560
+ const selectedModelIndex = options.selectedModelIndex;
462
561
  // Calculate section plane parameters and model bounds
463
562
  // Always calculate bounds when sectionPlane is provided (for preview and active mode)
464
563
  let sectionPlaneData;
@@ -526,7 +625,10 @@ export class Renderer {
526
625
  buffer.set(viewProj, 0);
527
626
  buffer.set(mesh.transform.m, 16);
528
627
  // Check if mesh is selected (single or multi-selection)
529
- const isSelected = (selectedId !== undefined && selectedId !== null && mesh.expressId === selectedId)
628
+ // For multi-model support: also check modelIndex if provided
629
+ const expressIdMatch = mesh.expressId === selectedId;
630
+ const modelIndexMatch = selectedModelIndex === undefined || mesh.modelIndex === selectedModelIndex;
631
+ const isSelected = (selectedId !== undefined && selectedId !== null && expressIdMatch && modelIndexMatch)
530
632
  || (selectedIds !== undefined && selectedIds.has(mesh.expressId));
531
633
  // Apply selection highlight effect
532
634
  if (isSelected) {
@@ -612,26 +714,33 @@ export class Renderer {
612
714
  // need individual mesh rendering to show only the visible elements
613
715
  const opaqueBatches = [];
614
716
  const transparentBatches = [];
615
- // Collect visible expressIds from partially visible batches
616
- // These need individual mesh rendering instead of batch rendering
617
- const partiallyVisibleExpressIds = new Set();
717
+ // PERFORMANCE FIX: Track partially visible batches for sub-batch rendering
718
+ // Instead of creating 10,000+ individual meshes, we create cached sub-batches
719
+ const partiallyVisibleBatches = [];
618
720
  for (const batch of allBatchedMeshes) {
619
721
  // Check visibility
620
722
  if (hasVisibilityFiltering) {
621
723
  const vis = batchVisibility.get(batch.colorKey);
622
724
  if (!vis || !vis.visible)
623
725
  continue; // Skip completely hidden batches
624
- // FIX: Skip partially visible batches - their visible elements
625
- // will be rendered individually below
726
+ // Handle partially visible batches - create sub-batches instead of individual meshes
626
727
  if (!vis.fullyVisible) {
627
728
  // Collect the visible expressIds from this batch
729
+ const visibleIds = new Set();
628
730
  for (const expressId of batch.expressIds) {
629
731
  const isHidden = options.hiddenIds?.has(expressId) ?? false;
630
732
  const isIsolated = !hasIsolatedFilter || options.isolatedIds.has(expressId);
631
733
  if (!isHidden && isIsolated) {
632
- partiallyVisibleExpressIds.add(expressId);
734
+ visibleIds.add(expressId);
633
735
  }
634
736
  }
737
+ if (visibleIds.size > 0) {
738
+ partiallyVisibleBatches.push({
739
+ colorKey: batch.colorKey,
740
+ visibleIds,
741
+ color: batch.color,
742
+ });
743
+ }
635
744
  continue; // Don't add batch to render list
636
745
  }
637
746
  }
@@ -694,77 +803,32 @@ export class Renderer {
694
803
  for (const batch of opaqueBatches) {
695
804
  renderBatch(batch);
696
805
  }
697
- // Render partially visible elements individually (from batches that had mixed visibility)
698
- // This is the FIX for hide/isolate showing wrong batches
806
+ // PERFORMANCE FIX: Render partially visible batches as sub-batches (not individual meshes!)
807
+ // This is the key optimization: instead of 10,000+ individual draw calls,
808
+ // we create cached sub-batches with only visible elements and render them as single draw calls
699
809
  const allMeshes = this.scene.getMeshes();
700
- const existingMeshIds = new Set(allMeshes.map(m => m.expressId));
701
- if (partiallyVisibleExpressIds.size > 0) {
702
- // Create GPU resources lazily for partially visible meshes
703
- for (const pvId of partiallyVisibleExpressIds) {
704
- if (!existingMeshIds.has(pvId) && this.scene.hasMeshData(pvId)) {
705
- const meshData = this.scene.getMeshData(pvId);
706
- this.createMeshFromData(meshData);
707
- existingMeshIds.add(pvId);
708
- }
709
- }
710
- // Get partially visible meshes and render them
711
- const partialMeshes = this.scene.getMeshes().filter(mesh => partiallyVisibleExpressIds.has(mesh.expressId));
712
- // Ensure partial meshes have uniform buffers and bind groups
713
- for (const mesh of partialMeshes) {
714
- if (!mesh.uniformBuffer && this.pipeline) {
715
- mesh.uniformBuffer = device.createBuffer({
716
- size: this.pipeline.getUniformBufferSize(),
717
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
718
- });
719
- mesh.bindGroup = device.createBindGroup({
720
- layout: this.pipeline.getBindGroupLayout(),
721
- entries: [
722
- {
723
- binding: 0,
724
- resource: { buffer: mesh.uniformBuffer },
725
- },
726
- ],
727
- });
728
- }
729
- }
730
- // Render partially visible meshes (not selected, normal rendering)
731
- for (const mesh of partialMeshes) {
732
- if (!mesh.bindGroup || !mesh.uniformBuffer) {
733
- continue;
734
- }
735
- const buffer = new Float32Array(48);
736
- const flagBuffer = new Uint32Array(buffer.buffer, 176, 4);
737
- buffer.set(viewProj, 0);
738
- buffer.set(mesh.transform.m, 16);
739
- buffer.set(mesh.color, 32);
740
- buffer[36] = mesh.material?.metallic ?? 0.0;
741
- buffer[37] = mesh.material?.roughness ?? 0.6;
742
- // Section plane data
743
- if (sectionPlaneData) {
744
- buffer[40] = sectionPlaneData.normal[0];
745
- buffer[41] = sectionPlaneData.normal[1];
746
- buffer[42] = sectionPlaneData.normal[2];
747
- buffer[43] = sectionPlaneData.distance;
748
- }
749
- // Flags (not selected)
750
- flagBuffer[0] = 0;
751
- flagBuffer[1] = sectionPlaneData?.enabled ? 1 : 0;
752
- flagBuffer[2] = 0;
753
- flagBuffer[3] = 0;
754
- device.queue.writeBuffer(mesh.uniformBuffer, 0, buffer);
755
- // Use opaque or transparent pipeline based on alpha
756
- const isTransparent = mesh.color[3] < 0.99;
757
- if (isTransparent) {
758
- pass.setPipeline(this.pipeline.getTransparentPipeline());
759
- }
760
- else {
761
- pass.setPipeline(this.pipeline.getPipeline());
810
+ // Track existing meshes by (expressId:modelIndex) to handle multi-model expressId collisions
811
+ // E.g., door #535 in model 0 vs beam #535 in model 1 need separate tracking
812
+ const existingMeshKeys = new Set(allMeshes.map(m => `${m.expressId}:${m.modelIndex ?? 'any'}`));
813
+ if (partiallyVisibleBatches.length > 0) {
814
+ for (const { colorKey, visibleIds, color } of partiallyVisibleBatches) {
815
+ // Get or create a cached sub-batch for this visibility state
816
+ const subBatch = this.scene.getOrCreatePartialBatch(colorKey, visibleIds, device, this.pipeline);
817
+ if (subBatch) {
818
+ // Use opaque or transparent pipeline based on alpha
819
+ const isTransparent = color[3] < 0.99;
820
+ if (isTransparent) {
821
+ pass.setPipeline(this.pipeline.getTransparentPipeline());
822
+ }
823
+ else {
824
+ pass.setPipeline(this.pipeline.getPipeline());
825
+ }
826
+ // Render the sub-batch as a single draw call
827
+ renderBatch(subBatch);
762
828
  }
763
- pass.setBindGroup(0, mesh.bindGroup);
764
- pass.setVertexBuffer(0, mesh.vertexBuffer);
765
- pass.setIndexBuffer(mesh.indexBuffer, 'uint32');
766
- pass.drawIndexed(mesh.indexCount, 1, 0, 0, 0);
767
829
  }
830
+ // Reset to opaque pipeline for subsequent rendering
831
+ pass.setPipeline(this.pipeline.getPipeline());
768
832
  }
769
833
  // Render selected meshes individually for proper highlighting
770
834
  // First, check if we have Mesh objects for selected IDs
@@ -782,15 +846,26 @@ export class Renderer {
782
846
  visibleSelectedIds.add(selId);
783
847
  }
784
848
  // Create GPU resources lazily for visible selected meshes that don't have them yet
849
+ // Pass selectedModelIndex to get mesh data from the correct model (for multi-model support)
850
+ // Use composite key to handle expressId collisions between models
785
851
  for (const selId of visibleSelectedIds) {
786
- if (!existingMeshIds.has(selId) && this.scene.hasMeshData(selId)) {
787
- const meshData = this.scene.getMeshData(selId);
852
+ const meshKey = `${selId}:${selectedModelIndex ?? 'any'}`;
853
+ if (!existingMeshKeys.has(meshKey) && this.scene.hasMeshData(selId, selectedModelIndex)) {
854
+ const meshData = this.scene.getMeshData(selId, selectedModelIndex);
788
855
  this.createMeshFromData(meshData);
789
- existingMeshIds.add(selId);
856
+ existingMeshKeys.add(meshKey);
790
857
  }
791
858
  }
792
859
  // Now get selected meshes (only visible ones)
793
- const selectedMeshes = this.scene.getMeshes().filter(mesh => visibleSelectedIds.has(mesh.expressId));
860
+ // For multi-model support: also filter by modelIndex if provided
861
+ const selectedMeshes = this.scene.getMeshes().filter(mesh => {
862
+ if (!visibleSelectedIds.has(mesh.expressId))
863
+ return false;
864
+ // If selectedModelIndex is provided, also match modelIndex
865
+ if (selectedModelIndex !== undefined && mesh.modelIndex !== selectedModelIndex)
866
+ return false;
867
+ return true;
868
+ });
794
869
  // Ensure selected meshes have uniform buffers and bind groups
795
870
  for (const mesh of selectedMeshes) {
796
871
  if (!mesh.uniformBuffer && this.pipeline) {
@@ -945,6 +1020,17 @@ export class Renderer {
945
1020
  min: options.sectionPlane.min,
946
1021
  max: options.sectionPlane.max,
947
1022
  });
1023
+ // Draw 2D section overlay on the section plane (when section is active, not preview)
1024
+ if (options.sectionPlane.enabled && this.section2DOverlayRenderer?.hasGeometry()) {
1025
+ this.section2DOverlayRenderer.draw(pass, {
1026
+ axis: options.sectionPlane.axis,
1027
+ position: options.sectionPlane.position,
1028
+ bounds: this.modelBounds,
1029
+ viewProj,
1030
+ min: options.sectionPlane.min,
1031
+ max: options.sectionPlane.max,
1032
+ });
1033
+ }
948
1034
  }
949
1035
  pass.end();
950
1036
  device.queue.submit([encoder.finish()]);
@@ -953,8 +1039,10 @@ export class Renderer {
953
1039
  // Handle WebGPU errors (e.g., device lost, invalid state)
954
1040
  // Mark context as invalid so it gets reconfigured next frame
955
1041
  this.device.invalidateContext();
956
- // Only log occasional errors to avoid spam
957
- if (Math.random() < 0.01) {
1042
+ // Rate-limit error logging to avoid spam (max once per second)
1043
+ const now = performance.now();
1044
+ if (now - this.lastRenderErrorTime > this.RENDER_ERROR_THROTTLE_MS) {
1045
+ this.lastRenderErrorTime = now;
958
1046
  console.warn('Render error (context will be reconfigured):', error);
959
1047
  }
960
1048
  }
@@ -962,6 +1050,7 @@ export class Renderer {
962
1050
  /**
963
1051
  * Pick object at screen coordinates
964
1052
  * Respects visibility filtering so users can only select visible elements
1053
+ * Returns PickResult with expressId and modelIndex for multi-model support
965
1054
  */
966
1055
  async pick(x, y, options) {
967
1056
  if (!this.picker) {
@@ -985,19 +1074,27 @@ export class Renderer {
985
1074
  expressIds.add(expressId);
986
1075
  }
987
1076
  }
988
- // Track existing expressIds to avoid duplicates (using Set for O(1) lookup)
989
- const existingExpressIds = new Set(meshes.map(m => m.expressId));
1077
+ // Track existing meshes by (expressId:modelIndex) for multi-model support
1078
+ // This handles expressId collisions (e.g., door #535 in model 0 vs beam #535 in model 1)
1079
+ const existingMeshKeys = new Set(meshes.map(m => `${m.expressId}:${m.modelIndex ?? 'any'}`));
990
1080
  // Count how many meshes we'd need to create for full GPU picking
1081
+ // For multi-model, count all pieces with unique (expressId, modelIndex) pairs
991
1082
  let toCreate = 0;
992
1083
  for (const expressId of expressIds) {
993
- if (existingExpressIds.has(expressId))
994
- continue;
995
1084
  if (options?.hiddenIds?.has(expressId))
996
1085
  continue;
997
1086
  if (options?.isolatedIds !== null && options?.isolatedIds !== undefined && !options.isolatedIds.has(expressId))
998
1087
  continue;
999
- if (this.scene.hasMeshData(expressId))
1000
- toCreate++;
1088
+ // Get all pieces for this expressId (handles multi-model)
1089
+ const pieces = this.scene.getMeshDataPieces(expressId);
1090
+ if (pieces) {
1091
+ for (const piece of pieces) {
1092
+ const meshKey = `${expressId}:${piece.modelIndex ?? 'any'}`;
1093
+ if (!existingMeshKeys.has(meshKey)) {
1094
+ toCreate++;
1095
+ }
1096
+ }
1097
+ }
1001
1098
  }
1002
1099
  // PERFORMANCE FIX: Use CPU raycasting for large models instead of creating GPU meshes
1003
1100
  // GPU picking requires individual mesh buffers; for 60K+ elements this is too slow
@@ -1007,25 +1104,34 @@ export class Renderer {
1007
1104
  // Use CPU raycasting fallback - works regardless of how many individual meshes exist
1008
1105
  const ray = this.camera.unprojectToRay(x, y, this.canvas.width, this.canvas.height);
1009
1106
  const hit = this.scene.raycast(ray.origin, ray.direction, options?.hiddenIds, options?.isolatedIds);
1010
- return hit ? hit.expressId : null;
1107
+ if (!hit)
1108
+ return null;
1109
+ // CPU raycasting returns expressId and modelIndex
1110
+ return {
1111
+ expressId: hit.expressId,
1112
+ modelIndex: hit.modelIndex,
1113
+ };
1011
1114
  }
1012
1115
  // For smaller models, create GPU meshes for picking
1013
1116
  // Only create meshes for VISIBLE elements (not hidden, and either no isolation or in isolated set)
1117
+ // For multi-model support: create meshes for ALL (expressId, modelIndex) pairs
1014
1118
  for (const expressId of expressIds) {
1015
- // Skip if already exists
1016
- if (existingExpressIds.has(expressId))
1017
- continue;
1018
1119
  // Skip if hidden
1019
1120
  if (options?.hiddenIds?.has(expressId))
1020
1121
  continue;
1021
1122
  // Skip if isolation is active and this entity is not isolated
1022
1123
  if (options?.isolatedIds !== null && options?.isolatedIds !== undefined && !options.isolatedIds.has(expressId))
1023
1124
  continue;
1024
- if (this.scene.hasMeshData(expressId)) {
1025
- const meshData = this.scene.getMeshData(expressId);
1026
- if (meshData) {
1027
- this.createMeshFromData(meshData);
1028
- existingExpressIds.add(expressId); // Track newly created mesh
1125
+ // Get all pieces for this expressId (handles multi-model)
1126
+ const pieces = this.scene.getMeshDataPieces(expressId);
1127
+ if (pieces) {
1128
+ for (const piece of pieces) {
1129
+ const meshKey = `${piece.expressId}:${piece.modelIndex ?? 'any'}`;
1130
+ // Skip if mesh already exists for this (expressId, modelIndex) pair
1131
+ if (existingMeshKeys.has(meshKey))
1132
+ continue;
1133
+ this.createMeshFromData(piece);
1134
+ existingMeshKeys.add(meshKey);
1029
1135
  }
1030
1136
  }
1031
1137
  }
@@ -1278,6 +1384,43 @@ export class Renderer {
1278
1384
  getScene() {
1279
1385
  return this.scene;
1280
1386
  }
1387
+ /**
1388
+ * Upload 2D section drawing data for 3D overlay rendering
1389
+ * Call this when a 2D drawing is generated to display it on the section plane
1390
+ * Uses same position calculation as section plane: sectionRange min/max if provided, else modelBounds
1391
+ */
1392
+ uploadSection2DOverlay(polygons, lines, axis, position, // 0-100 percentage
1393
+ sectionRange, // Same storey-based range as section plane
1394
+ flipped = false) {
1395
+ if (!this.section2DOverlayRenderer)
1396
+ return;
1397
+ // Use EXACTLY same calculation as section plane in render() method:
1398
+ // minVal = options.sectionPlane.min ?? boundsMin[axisIdx]
1399
+ // maxVal = options.sectionPlane.max ?? boundsMax[axisIdx]
1400
+ const axisIdx = axis === 'side' ? 'x' : axis === 'down' ? 'y' : 'z';
1401
+ // Allow upload if either sectionRange has both values, or modelBounds exists as fallback
1402
+ const hasFullRange = sectionRange?.min !== undefined && sectionRange?.max !== undefined;
1403
+ if (!hasFullRange && !this.modelBounds)
1404
+ return;
1405
+ const minVal = sectionRange?.min ?? this.modelBounds.min[axisIdx];
1406
+ const maxVal = sectionRange?.max ?? this.modelBounds.max[axisIdx];
1407
+ const planePosition = minVal + (position / 100) * (maxVal - minVal);
1408
+ this.section2DOverlayRenderer.uploadDrawing(polygons, lines, axis, planePosition, flipped);
1409
+ }
1410
+ /**
1411
+ * Clear the 2D section overlay
1412
+ */
1413
+ clearSection2DOverlay() {
1414
+ if (this.section2DOverlayRenderer) {
1415
+ this.section2DOverlayRenderer.clearGeometry();
1416
+ }
1417
+ }
1418
+ /**
1419
+ * Check if 2D section overlay has geometry to render
1420
+ */
1421
+ hasSection2DOverlay() {
1422
+ return this.section2DOverlayRenderer?.hasGeometry() ?? false;
1423
+ }
1281
1424
  /**
1282
1425
  * Get render pipeline (for batching)
1283
1426
  */
@@ -1299,5 +1442,35 @@ export class Renderer {
1299
1442
  }
1300
1443
  return this.device.getDevice();
1301
1444
  }
1445
+ /**
1446
+ * Capture a screenshot of the current view
1447
+ * Waits for GPU work to complete and captures exactly what's displayed
1448
+ * @returns PNG data URL or null if capture failed
1449
+ */
1450
+ async captureScreenshot() {
1451
+ if (!this.device.isInitialized()) {
1452
+ console.warn('[Renderer] Cannot capture screenshot: not initialized');
1453
+ return null;
1454
+ }
1455
+ try {
1456
+ // Wait for any pending GPU work to complete before capturing
1457
+ // This ensures we capture the fully rendered frame
1458
+ const device = this.device.getDevice();
1459
+ await device.queue.onSubmittedWorkDone();
1460
+ // Capture exactly what's displayed on the canvas
1461
+ const dataUrl = this.canvas.toDataURL('image/png');
1462
+ return dataUrl;
1463
+ }
1464
+ catch (error) {
1465
+ console.error('[Renderer] Screenshot capture failed:', error);
1466
+ return null;
1467
+ }
1468
+ }
1469
+ /**
1470
+ * Get the canvas element
1471
+ */
1472
+ getCanvas() {
1473
+ return this.canvas;
1474
+ }
1302
1475
  }
1303
1476
  //# sourceMappingURL=index.js.map