@ifc-lite/viewer 1.6.0 → 1.7.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.
Files changed (95) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/dist/assets/{Arrow.dom-BjDQoB2M.js → Arrow.dom-BGPQieQQ.js} +1 -1
  3. package/dist/assets/ifc-lite_bg-DyIN_nBM.wasm +0 -0
  4. package/dist/assets/{index-YBtrHPu3.js → index-dgdgiQ9p.js} +40212 -30008
  5. package/dist/assets/index-yTqs8kgX.css +1 -0
  6. package/dist/assets/{native-bridge-CULtTDX3.js → native-bridge-DD0SNyQ5.js} +1 -1
  7. package/dist/assets/{wasm-bridge-CjL-lSak.js → wasm-bridge-D54YMO7X.js} +1 -1
  8. package/dist/index.html +2 -2
  9. package/package.json +18 -15
  10. package/src/components/viewer/BCFPanel.tsx +7 -789
  11. package/src/components/viewer/Drawing2DCanvas.tsx +1048 -0
  12. package/src/components/viewer/DrawingSettingsPanel.tsx +3 -3
  13. package/src/components/viewer/HierarchyPanel.tsx +110 -842
  14. package/src/components/viewer/IDSExportDialog.tsx +281 -0
  15. package/src/components/viewer/IDSPanel.tsx +126 -17
  16. package/src/components/viewer/KeyboardShortcutsDialog.tsx +9 -0
  17. package/src/components/viewer/LensPanel.tsx +603 -0
  18. package/src/components/viewer/MainToolbar.tsx +188 -21
  19. package/src/components/viewer/PropertiesPanel.tsx +171 -663
  20. package/src/components/viewer/PropertyEditor.tsx +866 -77
  21. package/src/components/viewer/Section2DPanel.tsx +76 -2648
  22. package/src/components/viewer/ToolOverlays.tsx +3 -1097
  23. package/src/components/viewer/ViewerLayout.tsx +132 -45
  24. package/src/components/viewer/Viewport.tsx +237 -1659
  25. package/src/components/viewer/ViewportContainer.tsx +11 -3
  26. package/src/components/viewer/bcf/BCFCreateTopicForm.tsx +134 -0
  27. package/src/components/viewer/bcf/BCFTopicDetail.tsx +388 -0
  28. package/src/components/viewer/bcf/BCFTopicList.tsx +239 -0
  29. package/src/components/viewer/bcf/bcfHelpers.tsx +109 -0
  30. package/src/components/viewer/hierarchy/HierarchyNode.tsx +328 -0
  31. package/src/components/viewer/hierarchy/treeDataBuilder.ts +464 -0
  32. package/src/components/viewer/hierarchy/types.ts +54 -0
  33. package/src/components/viewer/hierarchy/useHierarchyTree.ts +280 -0
  34. package/src/components/viewer/lists/ListBuilder.tsx +486 -0
  35. package/src/components/viewer/lists/ListPanel.tsx +540 -0
  36. package/src/components/viewer/lists/ListResultsTable.tsx +193 -0
  37. package/src/components/viewer/properties/ClassificationCard.tsx +70 -0
  38. package/src/components/viewer/properties/CoordinateDisplay.tsx +49 -0
  39. package/src/components/viewer/properties/DocumentCard.tsx +89 -0
  40. package/src/components/viewer/properties/MaterialCard.tsx +201 -0
  41. package/src/components/viewer/properties/ModelMetadataPanel.tsx +335 -0
  42. package/src/components/viewer/properties/PropertySetCard.tsx +132 -0
  43. package/src/components/viewer/properties/QuantitySetCard.tsx +79 -0
  44. package/src/components/viewer/properties/RelationshipsCard.tsx +100 -0
  45. package/src/components/viewer/properties/encodingUtils.ts +29 -0
  46. package/src/components/viewer/tools/MeasurePanel.tsx +218 -0
  47. package/src/components/viewer/tools/MeasurementVisuals.tsx +644 -0
  48. package/src/components/viewer/tools/SectionPanel.tsx +183 -0
  49. package/src/components/viewer/tools/SectionVisualization.tsx +78 -0
  50. package/src/components/viewer/tools/formatDistance.ts +18 -0
  51. package/src/components/viewer/tools/sectionConstants.ts +14 -0
  52. package/src/components/viewer/useAnimationLoop.ts +166 -0
  53. package/src/components/viewer/useGeometryStreaming.ts +398 -0
  54. package/src/components/viewer/useKeyboardControls.ts +221 -0
  55. package/src/components/viewer/useMouseControls.ts +1009 -0
  56. package/src/components/viewer/useRenderUpdates.ts +165 -0
  57. package/src/components/viewer/useTouchControls.ts +245 -0
  58. package/src/hooks/ids/idsColorSystem.ts +125 -0
  59. package/src/hooks/ids/idsDataAccessor.ts +237 -0
  60. package/src/hooks/ids/idsExportService.ts +444 -0
  61. package/src/hooks/useBCF.ts +7 -0
  62. package/src/hooks/useDrawingExport.ts +627 -0
  63. package/src/hooks/useDrawingGeneration.ts +627 -0
  64. package/src/hooks/useFloorplanView.ts +108 -0
  65. package/src/hooks/useIDS.ts +270 -463
  66. package/src/hooks/useIfc.ts +26 -1628
  67. package/src/hooks/useIfcFederation.ts +803 -0
  68. package/src/hooks/useIfcLoader.ts +508 -0
  69. package/src/hooks/useIfcServer.ts +465 -0
  70. package/src/hooks/useKeyboardShortcuts.ts +1 -1
  71. package/src/hooks/useLens.ts +129 -0
  72. package/src/hooks/useMeasure2D.ts +365 -0
  73. package/src/hooks/useViewControls.ts +218 -0
  74. package/src/lib/ifc4-pset-definitions.test.ts +161 -0
  75. package/src/lib/ifc4-pset-definitions.ts +621 -0
  76. package/src/lib/ifc4-qto-definitions.ts +315 -0
  77. package/src/lib/lens/adapter.ts +138 -0
  78. package/src/lib/lens/index.ts +5 -0
  79. package/src/lib/lists/adapter.ts +69 -0
  80. package/src/lib/lists/index.ts +28 -0
  81. package/src/lib/lists/persistence.ts +64 -0
  82. package/src/services/fs-cache.ts +1 -1
  83. package/src/services/tauri-modules.d.ts +25 -0
  84. package/src/store/index.ts +38 -2
  85. package/src/store/slices/cameraSlice.ts +14 -1
  86. package/src/store/slices/dataSlice.ts +14 -1
  87. package/src/store/slices/lensSlice.ts +184 -0
  88. package/src/store/slices/listSlice.ts +74 -0
  89. package/src/store/slices/pinboardSlice.ts +114 -0
  90. package/src/store/types.ts +5 -0
  91. package/src/utils/ifcConfig.ts +16 -3
  92. package/src/utils/serverDataModel.ts +64 -101
  93. package/src/vite-env.d.ts +3 -0
  94. package/dist/assets/ifc-lite_bg-C6kblxf9.wasm +0 -0
  95. package/dist/assets/index-v3mcCUPN.css +0 -1
@@ -0,0 +1,627 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * useDrawingGeneration - Custom hook for 2D drawing generation logic
7
+ *
8
+ * Extracts the drawing generation pipeline from Section2DPanel, including:
9
+ * - Section cut generation via Drawing2DGenerator
10
+ * - Symbolic representation parsing and caching
11
+ * - Hybrid drawing creation (symbolic + section cut)
12
+ * - Bounding box alignment for symbolic lines
13
+ * - Auto-generation effects (panel open, overlay enable, geometry change)
14
+ * - Section plane change detection with overlap protection
15
+ */
16
+
17
+ import { useCallback, useEffect, useRef, useState } from 'react';
18
+ import {
19
+ Drawing2DGenerator,
20
+ createSectionConfig,
21
+ type Drawing2D,
22
+ type DrawingLine,
23
+ type SectionConfig,
24
+ } from '@ifc-lite/drawing-2d';
25
+ import { GeometryProcessor, type GeometryResult } from '@ifc-lite/geometry';
26
+
27
+ // Axis conversion from semantic (down/front/side) to geometric (x/y/z)
28
+ export const AXIS_MAP: Record<'down' | 'front' | 'side', 'x' | 'y' | 'z'> = {
29
+ down: 'y',
30
+ front: 'z',
31
+ side: 'x',
32
+ };
33
+
34
+ interface UseDrawingGenerationParams {
35
+ geometryResult: GeometryResult | null | undefined;
36
+ ifcDataStore: { source: Uint8Array } | null;
37
+ sectionPlane: { axis: 'down' | 'front' | 'side'; position: number; flipped: boolean };
38
+ displayOptions: { showHiddenLines: boolean; useSymbolicRepresentations: boolean; show3DOverlay: boolean; scale: number };
39
+ combinedHiddenIds: Set<number>;
40
+ combinedIsolatedIds: Set<number> | null;
41
+ computedIsolatedIds?: Set<number> | null;
42
+ models: Map<string, { id: string; visible: boolean; idOffset?: number }>;
43
+ panelVisible: boolean;
44
+ drawing: Drawing2D | null;
45
+ // Store actions
46
+ setDrawing: (d: Drawing2D | null) => void;
47
+ setDrawingStatus: (s: 'idle' | 'generating' | 'ready' | 'error') => void;
48
+ setDrawingProgress: (p: number, phase: string) => void;
49
+ setDrawingError: (e: string | null) => void;
50
+ }
51
+
52
+ interface UseDrawingGenerationResult {
53
+ generateDrawing: (isRegenerate?: boolean) => Promise<void>;
54
+ doRegenerate: () => Promise<void>;
55
+ isRegenerating: boolean;
56
+ }
57
+
58
+ export function useDrawingGeneration({
59
+ geometryResult,
60
+ ifcDataStore,
61
+ sectionPlane,
62
+ displayOptions,
63
+ combinedHiddenIds,
64
+ combinedIsolatedIds,
65
+ computedIsolatedIds,
66
+ models,
67
+ panelVisible,
68
+ drawing,
69
+ setDrawing,
70
+ setDrawingStatus,
71
+ setDrawingProgress,
72
+ setDrawingError,
73
+ }: UseDrawingGenerationParams): UseDrawingGenerationResult {
74
+ // Track if this is a regeneration (vs initial generation)
75
+ const isRegeneratingRef = useRef(false);
76
+
77
+ // Cache for symbolic representations - these don't change with section position
78
+ // Only re-parse when model or display options change
79
+ const symbolicCacheRef = useRef<{
80
+ lines: DrawingLine[];
81
+ entities: Set<number>;
82
+ sourceId: string | null;
83
+ useSymbolic: boolean;
84
+ } | null>(null);
85
+
86
+ // Generate drawing when panel opens
87
+ const generateDrawing = useCallback(async (isRegenerate = false) => {
88
+ if (!geometryResult?.meshes || geometryResult.meshes.length === 0) {
89
+ // Clear the drawing when no geometry is available (e.g., all models hidden)
90
+ setDrawing(null);
91
+ setDrawingStatus('idle');
92
+ setDrawingError('No visible geometry');
93
+ return;
94
+ }
95
+
96
+ // Only show full loading overlay for initial generation, not regeneration
97
+ if (!isRegenerate) {
98
+ setDrawingStatus('generating');
99
+ setDrawingProgress(0, 'Initializing...');
100
+ }
101
+ isRegeneratingRef.current = isRegenerate;
102
+
103
+ // Parse symbolic representations if enabled (for hybrid mode)
104
+ // OPTIMIZATION: Cache symbolic data - it doesn't change with section position
105
+ let symbolicLines: DrawingLine[] = [];
106
+ let entitiesWithSymbols = new Set<number>();
107
+
108
+ // For multi-model: create cache key from model count and visible model IDs
109
+ // For single-model: use source byteLength as before
110
+ const modelCacheKey = models.size > 0
111
+ ? `${models.size}-${[...models.values()].filter(m => m.visible).map(m => m.id).sort().join(',')}`
112
+ : (ifcDataStore?.source ? String(ifcDataStore.source.byteLength) : null);
113
+
114
+ const useSymbolic = displayOptions.useSymbolicRepresentations && !!ifcDataStore?.source;
115
+
116
+ // Check if we can use cached symbolic data
117
+ const cache = symbolicCacheRef.current;
118
+ const cacheValid = cache &&
119
+ cache.sourceId === modelCacheKey &&
120
+ cache.useSymbolic === useSymbolic;
121
+
122
+ if (useSymbolic) {
123
+ if (cacheValid) {
124
+ // Use cached data - FAST PATH
125
+ symbolicLines = cache.lines;
126
+ entitiesWithSymbols = cache.entities;
127
+ } else {
128
+ // Need to parse - only on first load or when model changes
129
+ try {
130
+ if (!isRegenerate) {
131
+ setDrawingProgress(5, 'Parsing symbolic representations...');
132
+ }
133
+
134
+ const processor = new GeometryProcessor();
135
+ try {
136
+ await processor.init();
137
+
138
+ const symbolicCollection = processor.parseSymbolicRepresentations(ifcDataStore!.source);
139
+ // For single-model (legacy) mode, model index is always 0
140
+ // Multi-model symbolic parsing would require iterating over each model separately
141
+ const symbolicModelIndex = 0;
142
+
143
+ if (symbolicCollection && !symbolicCollection.isEmpty) {
144
+ // Process polylines
145
+ for (let i = 0; i < symbolicCollection.polylineCount; i++) {
146
+ const poly = symbolicCollection.getPolyline(i);
147
+ if (!poly) continue;
148
+
149
+ entitiesWithSymbols.add(poly.expressId);
150
+ const points = poly.points;
151
+ const pointCount = poly.pointCount;
152
+
153
+ for (let j = 0; j < pointCount - 1; j++) {
154
+ symbolicLines.push({
155
+ line: {
156
+ start: { x: points[j * 2], y: points[j * 2 + 1] },
157
+ end: { x: points[(j + 1) * 2], y: points[(j + 1) * 2 + 1] }
158
+ },
159
+ category: 'silhouette',
160
+ visibility: 'visible',
161
+ entityId: poly.expressId,
162
+ ifcType: poly.ifcType,
163
+ modelIndex: symbolicModelIndex,
164
+ depth: 0,
165
+ });
166
+ }
167
+
168
+ if (poly.isClosed && pointCount > 2) {
169
+ symbolicLines.push({
170
+ line: {
171
+ start: { x: points[(pointCount - 1) * 2], y: points[(pointCount - 1) * 2 + 1] },
172
+ end: { x: points[0], y: points[1] }
173
+ },
174
+ category: 'silhouette',
175
+ visibility: 'visible',
176
+ entityId: poly.expressId,
177
+ ifcType: poly.ifcType,
178
+ modelIndex: symbolicModelIndex,
179
+ depth: 0,
180
+ });
181
+ }
182
+ }
183
+
184
+ // Process circles/arcs
185
+ for (let i = 0; i < symbolicCollection.circleCount; i++) {
186
+ const circle = symbolicCollection.getCircle(i);
187
+ if (!circle) continue;
188
+
189
+ entitiesWithSymbols.add(circle.expressId);
190
+ const numSegments = circle.isFullCircle ? 32 : 16;
191
+
192
+ for (let j = 0; j < numSegments; j++) {
193
+ const t1 = j / numSegments;
194
+ const t2 = (j + 1) / numSegments;
195
+ const a1 = circle.startAngle + t1 * (circle.endAngle - circle.startAngle);
196
+ const a2 = circle.startAngle + t2 * (circle.endAngle - circle.startAngle);
197
+
198
+ symbolicLines.push({
199
+ line: {
200
+ start: {
201
+ x: circle.centerX + circle.radius * Math.cos(a1),
202
+ y: circle.centerY + circle.radius * Math.sin(a1),
203
+ },
204
+ end: {
205
+ x: circle.centerX + circle.radius * Math.cos(a2),
206
+ y: circle.centerY + circle.radius * Math.sin(a2),
207
+ },
208
+ },
209
+ category: 'silhouette',
210
+ visibility: 'visible',
211
+ entityId: circle.expressId,
212
+ ifcType: circle.ifcType,
213
+ modelIndex: symbolicModelIndex,
214
+ depth: 0,
215
+ });
216
+ }
217
+ }
218
+ }
219
+ } finally {
220
+ processor.dispose();
221
+ }
222
+
223
+ // Cache the parsed data
224
+ symbolicCacheRef.current = {
225
+ lines: symbolicLines,
226
+ entities: entitiesWithSymbols,
227
+ sourceId: modelCacheKey,
228
+ useSymbolic,
229
+ };
230
+ } catch (error) {
231
+ console.warn('Symbolic parsing failed:', error);
232
+ symbolicLines = [];
233
+ entitiesWithSymbols = new Set<number>();
234
+ }
235
+ }
236
+ } else {
237
+ // Clear cache if symbolic is disabled
238
+ if (cache && cache.useSymbolic) {
239
+ symbolicCacheRef.current = null;
240
+ }
241
+ }
242
+
243
+ let generator: Drawing2DGenerator | null = null;
244
+ try {
245
+ generator = new Drawing2DGenerator();
246
+ await generator.initialize();
247
+
248
+ // Convert semantic axis to geometric
249
+ const axis = AXIS_MAP[sectionPlane.axis];
250
+
251
+ // Calculate section position from percentage using coordinateInfo bounds
252
+ const bounds = geometryResult.coordinateInfo.shiftedBounds;
253
+
254
+ const axisMin = bounds.min[axis];
255
+ const axisMax = bounds.max[axis];
256
+ const position = axisMin + (sectionPlane.position / 100) * (axisMax - axisMin);
257
+
258
+ // Calculate max depth as half the model extent
259
+ const maxDepth = (axisMax - axisMin) * 0.5;
260
+
261
+ // Adjust progress to account for symbolic parsing phase (0-20%)
262
+ const progressOffset = symbolicLines.length > 0 ? 20 : 0;
263
+ const progressScale = symbolicLines.length > 0 ? 0.8 : 1;
264
+ const progressCallback = (stage: string, prog: number) => {
265
+ setDrawingProgress(progressOffset + prog * 100 * progressScale, stage);
266
+ };
267
+
268
+ // Create section config
269
+ const config: SectionConfig = createSectionConfig(axis, position, {
270
+ projectionDepth: maxDepth,
271
+ includeHiddenLines: displayOptions.showHiddenLines,
272
+ scale: displayOptions.scale,
273
+ });
274
+
275
+ // Override the flipped setting
276
+ config.plane.flipped = sectionPlane.flipped;
277
+
278
+ // Filter meshes by visibility (respect 3D hiding/isolation)
279
+ let meshesToProcess = geometryResult.meshes;
280
+
281
+ // Filter out hidden entities (using combined multi-model set)
282
+ if (combinedHiddenIds.size > 0) {
283
+ meshesToProcess = meshesToProcess.filter(
284
+ mesh => !combinedHiddenIds.has(mesh.expressId)
285
+ );
286
+ }
287
+
288
+ // Filter by isolation (if active, using combined multi-model set)
289
+ if (combinedIsolatedIds !== null) {
290
+ meshesToProcess = meshesToProcess.filter(
291
+ mesh => combinedIsolatedIds.has(mesh.expressId)
292
+ );
293
+ }
294
+
295
+ // Also filter by computedIsolatedIds (storey selection)
296
+ if (computedIsolatedIds !== null && computedIsolatedIds !== undefined && computedIsolatedIds.size > 0) {
297
+ const isolatedSet = computedIsolatedIds;
298
+ meshesToProcess = meshesToProcess.filter(
299
+ mesh => isolatedSet.has(mesh.expressId)
300
+ );
301
+ }
302
+
303
+ // If all meshes were filtered out by visibility, clear the drawing
304
+ if (meshesToProcess.length === 0) {
305
+ setDrawing(null);
306
+ setDrawingStatus('idle');
307
+ setDrawingError(null);
308
+ return;
309
+ }
310
+
311
+ const result = await generator.generate(meshesToProcess, config, {
312
+ includeHiddenLines: false, // Disable - causes internal mesh edges
313
+ includeProjection: false, // Disable - causes triangulation lines
314
+ includeEdges: false, // Disable - causes triangulation lines
315
+ mergeLines: true,
316
+ onProgress: progressCallback,
317
+ });
318
+
319
+ // If we have symbolic representations, create a hybrid drawing
320
+ if (symbolicLines.length > 0 && entitiesWithSymbols.size > 0) {
321
+ // Get entity IDs that actually appear in the section cut (these are being cut by the plane)
322
+ const cutEntityIds = new Set<number>();
323
+ for (const line of result.lines) {
324
+ if (line.entityId !== undefined) {
325
+ cutEntityIds.add(line.entityId);
326
+ }
327
+ }
328
+ // Also check cut polygons for entity IDs
329
+ for (const poly of result.cutPolygons ?? []) {
330
+ if ((poly as { entityId?: number }).entityId !== undefined) {
331
+ cutEntityIds.add((poly as { entityId?: number }).entityId!);
332
+ }
333
+ }
334
+
335
+ // Only include symbolic lines for entities that are ACTUALLY being cut
336
+ // This filters out symbols from other floors/levels not intersected by the section plane
337
+ const relevantSymbolicLines = symbolicLines.filter(line =>
338
+ line.entityId !== undefined && cutEntityIds.has(line.entityId)
339
+ );
340
+
341
+ // Get the set of entities that have both symbols AND are being cut
342
+ const entitiesWithRelevantSymbols = new Set<number>();
343
+ for (const line of relevantSymbolicLines) {
344
+ if (line.entityId !== undefined) {
345
+ entitiesWithRelevantSymbols.add(line.entityId);
346
+ }
347
+ }
348
+
349
+ // Align symbolic geometry with section cut geometry using bounding box matching
350
+ // Plan representations often have different local origins than Body representations
351
+ // So we compute per-entity transforms to align Plan bbox center with section cut bbox center
352
+
353
+ // Build per-entity bounding boxes for section cut
354
+ const sectionCutBounds = new Map<number, { minX: number; minY: number; maxX: number; maxY: number }>();
355
+ const updateBounds = (entityId: number, x: number, y: number) => {
356
+ const bounds = sectionCutBounds.get(entityId) ?? { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity };
357
+ bounds.minX = Math.min(bounds.minX, x);
358
+ bounds.minY = Math.min(bounds.minY, y);
359
+ bounds.maxX = Math.max(bounds.maxX, x);
360
+ bounds.maxY = Math.max(bounds.maxY, y);
361
+ sectionCutBounds.set(entityId, bounds);
362
+ };
363
+ for (const line of result.lines) {
364
+ if (line.entityId === undefined) continue;
365
+ updateBounds(line.entityId, line.line.start.x, line.line.start.y);
366
+ updateBounds(line.entityId, line.line.end.x, line.line.end.y);
367
+ }
368
+ // Include cut polygon vertices in bounds computation
369
+ for (const poly of result.cutPolygons ?? []) {
370
+ const entityId = (poly as { entityId?: number }).entityId;
371
+ if (entityId === undefined) continue;
372
+ for (const pt of poly.polygon.outer) {
373
+ updateBounds(entityId, pt.x, pt.y);
374
+ }
375
+ for (const hole of poly.polygon.holes) {
376
+ for (const pt of hole) {
377
+ updateBounds(entityId, pt.x, pt.y);
378
+ }
379
+ }
380
+ }
381
+
382
+ // Build per-entity bounding boxes for symbolic
383
+ const symbolicBounds = new Map<number, { minX: number; minY: number; maxX: number; maxY: number }>();
384
+ for (const line of relevantSymbolicLines) {
385
+ if (line.entityId === undefined) continue;
386
+ const bounds = symbolicBounds.get(line.entityId) ?? { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity };
387
+ bounds.minX = Math.min(bounds.minX, line.line.start.x, line.line.end.x);
388
+ bounds.minY = Math.min(bounds.minY, line.line.start.y, line.line.end.y);
389
+ bounds.maxX = Math.max(bounds.maxX, line.line.start.x, line.line.end.x);
390
+ bounds.maxY = Math.max(bounds.maxY, line.line.start.y, line.line.end.y);
391
+ symbolicBounds.set(line.entityId, bounds);
392
+ }
393
+
394
+ // Compute per-entity alignment transforms (center-to-center offset)
395
+ const alignmentOffsets = new Map<number, { dx: number; dy: number }>();
396
+ for (const entityId of entitiesWithRelevantSymbols) {
397
+ const scBounds = sectionCutBounds.get(entityId);
398
+ const symBounds = symbolicBounds.get(entityId);
399
+ if (scBounds && symBounds) {
400
+ const scCenterX = (scBounds.minX + scBounds.maxX) / 2;
401
+ const scCenterY = (scBounds.minY + scBounds.maxY) / 2;
402
+ const symCenterX = (symBounds.minX + symBounds.maxX) / 2;
403
+ const symCenterY = (symBounds.minY + symBounds.maxY) / 2;
404
+ alignmentOffsets.set(entityId, {
405
+ dx: scCenterX - symCenterX,
406
+ dy: scCenterY - symCenterY,
407
+ });
408
+ }
409
+ }
410
+
411
+ // Apply alignment offsets to symbolic lines
412
+ const alignedSymbolicLines = relevantSymbolicLines.map(line => {
413
+ const offset = line.entityId !== undefined ? alignmentOffsets.get(line.entityId) : undefined;
414
+ if (offset) {
415
+ return {
416
+ ...line,
417
+ line: {
418
+ start: { x: line.line.start.x + offset.dx, y: line.line.start.y + offset.dy },
419
+ end: { x: line.line.end.x + offset.dx, y: line.line.end.y + offset.dy },
420
+ },
421
+ };
422
+ }
423
+ return line;
424
+ });
425
+
426
+ // Filter out section cut lines for entities that have relevant symbolic representations
427
+ const filteredLines = result.lines.filter((line: DrawingLine) =>
428
+ line.entityId === undefined || !entitiesWithRelevantSymbols.has(line.entityId)
429
+ );
430
+
431
+ // Also filter cut polygons for entities with relevant symbols
432
+ const filteredCutPolygons = result.cutPolygons?.filter((poly: { entityId?: number }) =>
433
+ poly.entityId === undefined || !entitiesWithRelevantSymbols.has(poly.entityId)
434
+ ) ?? [];
435
+
436
+ // Combine filtered section cuts with aligned symbolic lines
437
+ const combinedLines = [...filteredLines, ...alignedSymbolicLines];
438
+
439
+ // Recalculate bounds with combined lines and polygons
440
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
441
+ for (const line of combinedLines) {
442
+ minX = Math.min(minX, line.line.start.x, line.line.end.x);
443
+ minY = Math.min(minY, line.line.start.y, line.line.end.y);
444
+ maxX = Math.max(maxX, line.line.start.x, line.line.end.x);
445
+ maxY = Math.max(maxY, line.line.start.y, line.line.end.y);
446
+ }
447
+ // Include polygon vertices in bounds
448
+ for (const poly of filteredCutPolygons) {
449
+ for (const pt of poly.polygon.outer) {
450
+ minX = Math.min(minX, pt.x);
451
+ minY = Math.min(minY, pt.y);
452
+ maxX = Math.max(maxX, pt.x);
453
+ maxY = Math.max(maxY, pt.y);
454
+ }
455
+ for (const hole of poly.polygon.holes) {
456
+ for (const pt of hole) {
457
+ minX = Math.min(minX, pt.x);
458
+ minY = Math.min(minY, pt.y);
459
+ maxX = Math.max(maxX, pt.x);
460
+ maxY = Math.max(maxY, pt.y);
461
+ }
462
+ }
463
+ }
464
+
465
+ // Create hybrid drawing
466
+ const hybridDrawing: Drawing2D = {
467
+ ...result,
468
+ lines: combinedLines,
469
+ cutPolygons: filteredCutPolygons,
470
+ bounds: {
471
+ min: { x: isFinite(minX) ? minX : result.bounds.min.x, y: isFinite(minY) ? minY : result.bounds.min.y },
472
+ max: { x: isFinite(maxX) ? maxX : result.bounds.max.x, y: isFinite(maxY) ? maxY : result.bounds.max.y },
473
+ },
474
+ stats: {
475
+ ...result.stats,
476
+ cutLineCount: combinedLines.length,
477
+ },
478
+ };
479
+
480
+ setDrawing(hybridDrawing);
481
+ } else {
482
+ setDrawing(result);
483
+ }
484
+
485
+ // Always set status to ready (whether initial generation or regeneration)
486
+ setDrawingStatus('ready');
487
+ isRegeneratingRef.current = false;
488
+ } catch (error) {
489
+ console.error('Drawing generation failed:', error);
490
+ setDrawingError(error instanceof Error ? error.message : 'Generation failed');
491
+ } finally {
492
+ // Always cleanup generator to prevent resource leaks
493
+ generator?.dispose();
494
+ }
495
+ }, [
496
+ geometryResult,
497
+ ifcDataStore,
498
+ sectionPlane,
499
+ displayOptions,
500
+ combinedHiddenIds,
501
+ combinedIsolatedIds,
502
+ computedIsolatedIds,
503
+ models,
504
+ setDrawing,
505
+ setDrawingStatus,
506
+ setDrawingProgress,
507
+ setDrawingError,
508
+ ]);
509
+
510
+ // Track panel visibility and geometry for detecting changes
511
+ const prevPanelVisibleRef = useRef(false);
512
+ const prevOverlayEnabledRef = useRef(false);
513
+ const prevMeshCountRef = useRef(0);
514
+
515
+ // Auto-generate when panel opens (or 3D overlay is enabled) and no drawing exists
516
+ // Also regenerate when geometry changes significantly (e.g., models hidden/shown)
517
+ useEffect(() => {
518
+ const wasVisible = prevPanelVisibleRef.current;
519
+ const wasOverlayEnabled = prevOverlayEnabledRef.current;
520
+ const prevMeshCount = prevMeshCountRef.current;
521
+ const currentMeshCount = geometryResult?.meshes?.length ?? 0;
522
+ const hasGeometry = currentMeshCount > 0;
523
+
524
+ // Track panel visibility separately from overlay
525
+ const panelJustOpened = panelVisible && !wasVisible;
526
+ const overlayJustEnabled = displayOptions.show3DOverlay && !wasOverlayEnabled;
527
+ const isNowActive = panelVisible || displayOptions.show3DOverlay;
528
+ const geometryChanged = currentMeshCount !== prevMeshCount;
529
+
530
+ // Always update refs
531
+ prevPanelVisibleRef.current = panelVisible;
532
+ prevOverlayEnabledRef.current = displayOptions.show3DOverlay;
533
+ prevMeshCountRef.current = currentMeshCount;
534
+
535
+ if (isNowActive) {
536
+ if (!hasGeometry) {
537
+ // No geometry available - clear the drawing
538
+ if (drawing) {
539
+ setDrawing(null);
540
+ setDrawingStatus('idle');
541
+ }
542
+ } else if (panelJustOpened || overlayJustEnabled || !drawing || geometryChanged) {
543
+ // Generate if:
544
+ // 1. Panel just opened, OR
545
+ // 2. Overlay just enabled, OR
546
+ // 3. No drawing exists, OR
547
+ // 4. Geometry changed significantly (models hidden/shown)
548
+ generateDrawing();
549
+ }
550
+ }
551
+ }, [panelVisible, displayOptions.show3DOverlay, drawing, geometryResult, generateDrawing, setDrawing, setDrawingStatus]);
552
+
553
+ // Auto-regenerate when section plane changes
554
+ // Strategy: INSTANT - no debounce, but prevent overlapping computations
555
+ // The generation time itself acts as natural batching for fast slider movements
556
+ const sectionRef = useRef({ axis: sectionPlane.axis, position: sectionPlane.position, flipped: sectionPlane.flipped });
557
+ const isGeneratingRef = useRef(false);
558
+ const latestSectionRef = useRef({ axis: sectionPlane.axis, position: sectionPlane.position, flipped: sectionPlane.flipped });
559
+ const [isRegenerating, setIsRegenerating] = useState(false);
560
+
561
+ // Stable regenerate function that handles overlapping calls
562
+ const doRegenerate = useCallback(async () => {
563
+ if (isGeneratingRef.current) {
564
+ // Already generating - the latest position is already tracked in latestSectionRef
565
+ // When current generation finishes, it will check if another is needed
566
+ return;
567
+ }
568
+
569
+ isGeneratingRef.current = true;
570
+ setIsRegenerating(true);
571
+
572
+ // Capture position at start of generation
573
+ const targetSection = { ...latestSectionRef.current };
574
+
575
+ try {
576
+ await generateDrawing(true);
577
+ } finally {
578
+ isGeneratingRef.current = false;
579
+ setIsRegenerating(false);
580
+
581
+ // Check if section changed while we were generating
582
+ const current = latestSectionRef.current;
583
+ if (
584
+ current.axis !== targetSection.axis ||
585
+ current.position !== targetSection.position ||
586
+ current.flipped !== targetSection.flipped
587
+ ) {
588
+ // Position changed during generation - regenerate immediately with latest
589
+ // Use microtask to avoid blocking
590
+ queueMicrotask(() => doRegenerate());
591
+ }
592
+ }
593
+ }, [generateDrawing]);
594
+
595
+ useEffect(() => {
596
+ // Always update latest section ref (even if generating)
597
+ latestSectionRef.current = { axis: sectionPlane.axis, position: sectionPlane.position, flipped: sectionPlane.flipped };
598
+
599
+ // Check if section plane actually changed from last processed
600
+ const prev = sectionRef.current;
601
+ if (
602
+ prev.axis === sectionPlane.axis &&
603
+ prev.position === sectionPlane.position &&
604
+ prev.flipped === sectionPlane.flipped
605
+ ) {
606
+ return;
607
+ }
608
+
609
+ // Update processed ref
610
+ sectionRef.current = { axis: sectionPlane.axis, position: sectionPlane.position, flipped: sectionPlane.flipped };
611
+
612
+ // If panel is visible OR 3D overlay is enabled, and we have geometry, regenerate INSTANTLY
613
+ if ((panelVisible || displayOptions.show3DOverlay) && geometryResult?.meshes) {
614
+ // Start immediately - no debounce
615
+ // doRegenerate handles preventing overlaps and will auto-regenerate with latest when done
616
+ doRegenerate();
617
+ }
618
+ }, [panelVisible, displayOptions.show3DOverlay, sectionPlane.axis, sectionPlane.position, sectionPlane.flipped, geometryResult, combinedHiddenIds, combinedIsolatedIds, computedIsolatedIds, doRegenerate]);
619
+
620
+ return {
621
+ generateDrawing,
622
+ doRegenerate,
623
+ isRegenerating,
624
+ };
625
+ }
626
+
627
+ export default useDrawingGeneration;