@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
@@ -23,69 +23,16 @@ import {
23
23
  } from '@/components/ui/dropdown-menu';
24
24
  import { useViewerStore } from '@/store';
25
25
  import { useIfc } from '@/hooks/useIfc';
26
- import {
27
- Drawing2DGenerator,
28
- createSectionConfig,
29
- GraphicOverrideEngine,
30
- renderFrame,
31
- renderTitleBlock,
32
- calculateDrawingTransform,
33
- type Drawing2D,
34
- type DrawingLine,
35
- type SectionConfig,
36
- type ElementData,
37
- type TitleBlockExtras,
38
- } from '@ifc-lite/drawing-2d';
39
- import { GeometryProcessor, type GeometryResult } from '@ifc-lite/geometry';
26
+ import { GraphicOverrideEngine } from '@ifc-lite/drawing-2d';
27
+ import { type GeometryResult } from '@ifc-lite/geometry';
40
28
  import { DrawingSettingsPanel } from './DrawingSettingsPanel';
41
29
  import { SheetSetupPanel } from './SheetSetupPanel';
42
30
  import { TitleBlockEditor } from './TitleBlockEditor';
43
-
44
- // Axis conversion from semantic (down/front/side) to geometric (x/y/z)
45
- const AXIS_MAP: Record<'down' | 'front' | 'side', 'x' | 'y' | 'z'> = {
46
- down: 'y',
47
- front: 'z',
48
- side: 'x',
49
- };
50
-
51
- // Fill colors for IFC types (architectural convention)
52
- const IFC_TYPE_FILL_COLORS: Record<string, string> = {
53
- // Structural elements - solid gray
54
- IfcWall: '#b0b0b0',
55
- IfcWallStandardCase: '#b0b0b0',
56
- IfcColumn: '#909090',
57
- IfcBeam: '#909090',
58
- IfcSlab: '#c8c8c8',
59
- IfcRoof: '#d0d0d0',
60
- IfcFooting: '#808080',
61
- IfcPile: '#707070',
62
-
63
- // Windows/Doors - lighter
64
- IfcWindow: '#e8f4fc',
65
- IfcDoor: '#f5e6d3',
66
-
67
- // Stairs/Railings
68
- IfcStair: '#d8d8d8',
69
- IfcStairFlight: '#d8d8d8',
70
- IfcRailing: '#c0c0c0',
71
-
72
- // MEP - distinct colors
73
- IfcPipeSegment: '#a0d0ff',
74
- IfcDuctSegment: '#c0ffc0',
75
-
76
- // Furniture
77
- IfcFurnishingElement: '#ffe0c0',
78
-
79
- // Spaces (usually not shown in section)
80
- IfcSpace: '#f0f0f0',
81
-
82
- // Default
83
- default: '#d0d0d0',
84
- };
85
-
86
- function getFillColorForType(ifcType: string): string {
87
- return IFC_TYPE_FILL_COLORS[ifcType] || IFC_TYPE_FILL_COLORS.default;
88
- }
31
+ import { Drawing2DCanvas } from './Drawing2DCanvas';
32
+ import { useDrawingGeneration } from '@/hooks/useDrawingGeneration';
33
+ import { useMeasure2D } from '@/hooks/useMeasure2D';
34
+ import { useViewControls } from '@/hooks/useViewControls';
35
+ import { useDrawingExport } from '@/hooks/useDrawingExport';
89
36
 
90
37
  interface Section2DPanelProps {
91
38
  mergedGeometry?: GeometryResult | null;
@@ -93,11 +40,14 @@ interface Section2DPanelProps {
93
40
  modelIdToIndex?: Map<string, number>;
94
41
  }
95
42
 
96
- export function Section2DPanel({
97
- mergedGeometry,
98
- computedIsolatedIds,
99
- modelIdToIndex
43
+ export function Section2DPanel({
44
+ mergedGeometry,
45
+ computedIsolatedIds,
46
+ modelIdToIndex
100
47
  }: Section2DPanelProps = {}): React.ReactElement | null {
48
+ // ═══════════════════════════════════════════════════════════════════════════
49
+ // STORE SELECTORS
50
+ // ═══════════════════════════════════════════════════════════════════════════
101
51
  const panelVisible = useViewerStore((s) => s.drawing2DPanelVisible);
102
52
  const setDrawingPanelVisible = useViewerStore((s) => s.setDrawing2DPanelVisible);
103
53
  const drawing = useViewerStore((s) => s.drawing2D);
@@ -152,11 +102,13 @@ export function Section2DPanel({
152
102
  const activeTool = useViewerStore((s) => s.activeTool);
153
103
  const models = useViewerStore((s) => s.models);
154
104
  const { geometryResult: legacyGeometryResult, ifcDataStore } = useIfc();
155
-
105
+
156
106
  // Use merged geometry from props if available (multi-model), otherwise fall back to legacy single-model
157
107
  const geometryResult = mergedGeometry ?? legacyGeometryResult;
158
108
 
159
- // Auto-show panel when section tool is active
109
+ // ═══════════════════════════════════════════════════════════════════════════
110
+ // AUTO-SHOW PANEL EFFECT
111
+ // ═══════════════════════════════════════════════════════════════════════════
160
112
  const prevActiveToolRef = useRef(activeTool);
161
113
  useEffect(() => {
162
114
  // Section tool was just activated
@@ -166,22 +118,17 @@ export function Section2DPanel({
166
118
  prevActiveToolRef.current = activeTool;
167
119
  }, [activeTool, geometryResult, setDrawingPanelVisible]);
168
120
 
169
- // Local state for pan/zoom and expanded mode
170
- const [viewTransform, setViewTransform] = useState({ x: 0, y: 0, scale: 1 });
121
+ // ═══════════════════════════════════════════════════════════════════════════
122
+ // LOCAL STATE
123
+ // ═══════════════════════════════════════════════════════════════════════════
171
124
  const [isExpanded, setIsExpanded] = useState(false);
172
125
  const [panelSize, setPanelSize] = useState({ width: 400, height: 300 });
173
126
  const [isNarrow, setIsNarrow] = useState(false); // Track if panel is too narrow for all buttons
174
127
  const [isPinned, setIsPinned] = useState(true); // Default ON: keep position on regenerate
175
- const [needsFit, setNeedsFit] = useState(true); // Force fit on first open and axis change
176
128
  const containerRef = useRef<HTMLDivElement>(null);
177
129
  const panelRef = useRef<HTMLDivElement>(null);
178
- const isPanning = useRef(false);
179
- const lastPanPoint = useRef({ x: 0, y: 0 });
180
130
  const isResizing = useRef<'right' | 'top' | 'corner' | null>(null);
181
131
  const resizeStartPos = useRef({ x: 0, y: 0, width: 0, height: 0 });
182
- const prevAxisRef = useRef(sectionPlane.axis); // Track axis changes
183
- const isMouseButtonDown = useRef(false); // Track if mouse button is currently pressed
184
- const isMouseInsidePanel = useRef(true); // Track if mouse is inside the panel
185
132
  // Track resize event handlers for cleanup
186
133
  const resizeHandlersRef = useRef<{ move: ((e: MouseEvent) => void) | null; up: (() => void) | null }>({ move: null, up: null });
187
134
  // Cache sheet drawing transform when pinned (to keep model fixed in place)
@@ -192,6 +139,10 @@ export function Section2DPanel({
192
139
  setIsNarrow(panelSize.width < 480);
193
140
  }, [panelSize.width]);
194
141
 
142
+ // ═══════════════════════════════════════════════════════════════════════════
143
+ // MEMOIZED VALUES
144
+ // ═══════════════════════════════════════════════════════════════════════════
145
+
195
146
  // Create graphic override engine with active rules
196
147
  const overrideEngine = useMemo(() => {
197
148
  const rules = getActiveOverrideRules();
@@ -211,17 +162,21 @@ export function Section2DPanel({
211
162
  return map;
212
163
  }, [geometryResult]);
213
164
 
165
+ // ═══════════════════════════════════════════════════════════════════════════
166
+ // VISIBILITY STATE
167
+ // ═══════════════════════════════════════════════════════════════════════════
168
+
214
169
  // Get visibility state from store for filtering
215
170
  const hiddenEntities = useViewerStore((s) => s.hiddenEntities);
216
171
  const isolatedEntities = useViewerStore((s) => s.isolatedEntities);
217
172
  const hiddenEntitiesByModel = useViewerStore((s) => s.hiddenEntitiesByModel);
218
173
  const isolatedEntitiesByModel = useViewerStore((s) => s.isolatedEntitiesByModel);
219
-
174
+
220
175
  // Build combined Set of global IDs from multi-model visibility state
221
176
  // This converts per-model local expressIds to global IDs using idOffset
222
177
  const combinedHiddenIds = useMemo(() => {
223
178
  const globalHiddenIds = new Set<number>(hiddenEntities); // Start with legacy hidden IDs
224
-
179
+
225
180
  // Add hidden entities from each model (convert local expressId to global ID)
226
181
  for (const [modelId, localHiddenIds] of hiddenEntitiesByModel) {
227
182
  const model = models.get(modelId);
@@ -231,17 +186,17 @@ export function Section2DPanel({
231
186
  }
232
187
  }
233
188
  }
234
-
189
+
235
190
  return globalHiddenIds;
236
191
  }, [hiddenEntities, hiddenEntitiesByModel, models]);
237
-
192
+
238
193
  // Build combined Set of global IDs for isolation
239
194
  const combinedIsolatedIds = useMemo(() => {
240
195
  // If legacy isolation is active, use that (already contains global IDs)
241
196
  if (isolatedEntities !== null) {
242
197
  return isolatedEntities;
243
198
  }
244
-
199
+
245
200
  // Build from multi-model isolation
246
201
  const globalIsolatedIds = new Set<number>();
247
202
  for (const [modelId, localIsolatedIds] of isolatedEntitiesByModel) {
@@ -252,1516 +207,44 @@ export function Section2DPanel({
252
207
  }
253
208
  }
254
209
  }
255
-
210
+
256
211
  return globalIsolatedIds.size > 0 ? globalIsolatedIds : null;
257
212
  }, [isolatedEntities, isolatedEntitiesByModel, models]);
258
213
 
259
- // Track if this is a regeneration (vs initial generation)
260
- const isRegeneratingRef = useRef(false);
261
-
262
- // Cache for symbolic representations - these don't change with section position
263
- // Only re-parse when model or display options change
264
- const symbolicCacheRef = useRef<{
265
- lines: DrawingLine[];
266
- entities: Set<number>;
267
- sourceId: string | null;
268
- useSymbolic: boolean;
269
- } | null>(null);
270
-
271
- // Generate drawing when panel opens
272
- const generateDrawing = useCallback(async (isRegenerate = false) => {
273
- if (!geometryResult?.meshes || geometryResult.meshes.length === 0) {
274
- // Clear the drawing when no geometry is available (e.g., all models hidden)
275
- setDrawing(null);
276
- setDrawingStatus('idle');
277
- setDrawingError('No visible geometry');
278
- return;
279
- }
280
-
281
- // Only show full loading overlay for initial generation, not regeneration
282
- if (!isRegenerate) {
283
- setDrawingStatus('generating');
284
- setDrawingProgress(0, 'Initializing...');
285
- }
286
- isRegeneratingRef.current = isRegenerate;
287
-
288
- // Parse symbolic representations if enabled (for hybrid mode)
289
- // OPTIMIZATION: Cache symbolic data - it doesn't change with section position
290
- let symbolicLines: DrawingLine[] = [];
291
- let entitiesWithSymbols = new Set<number>();
292
-
293
- // For multi-model: create cache key from model count and visible model IDs
294
- // For single-model: use source byteLength as before
295
- const modelCacheKey = models.size > 0
296
- ? `${models.size}-${[...models.values()].filter(m => m.visible).map(m => m.id).sort().join(',')}`
297
- : (ifcDataStore?.source ? String(ifcDataStore.source.byteLength) : null);
298
-
299
- const useSymbolic = displayOptions.useSymbolicRepresentations && (
300
- models.size > 0 ? true : !!ifcDataStore?.source
301
- );
302
-
303
- // Check if we can use cached symbolic data
304
- const cache = symbolicCacheRef.current;
305
- const cacheValid = cache &&
306
- cache.sourceId === modelCacheKey &&
307
- cache.useSymbolic === useSymbolic;
308
-
309
- if (useSymbolic) {
310
- if (cacheValid) {
311
- // Use cached data - FAST PATH
312
- symbolicLines = cache.lines;
313
- entitiesWithSymbols = cache.entities;
314
- } else {
315
- // Need to parse - only on first load or when model changes
316
- try {
317
- if (!isRegenerate) {
318
- setDrawingProgress(5, 'Parsing symbolic representations...');
319
- }
320
-
321
- const processor = new GeometryProcessor();
322
- try {
323
- await processor.init();
324
-
325
- const symbolicCollection = processor.parseSymbolicRepresentations(ifcDataStore!.source);
326
- // For single-model (legacy) mode, model index is always 0
327
- // Multi-model symbolic parsing would require iterating over each model separately
328
- const symbolicModelIndex = 0;
329
-
330
- if (symbolicCollection && !symbolicCollection.isEmpty) {
331
- // Process polylines
332
- for (let i = 0; i < symbolicCollection.polylineCount; i++) {
333
- const poly = symbolicCollection.getPolyline(i);
334
- if (!poly) continue;
335
-
336
- entitiesWithSymbols.add(poly.expressId);
337
- const points = poly.points;
338
- const pointCount = poly.pointCount;
339
-
340
- for (let j = 0; j < pointCount - 1; j++) {
341
- symbolicLines.push({
342
- line: {
343
- start: { x: points[j * 2], y: points[j * 2 + 1] },
344
- end: { x: points[(j + 1) * 2], y: points[(j + 1) * 2 + 1] }
345
- },
346
- category: 'silhouette',
347
- visibility: 'visible',
348
- entityId: poly.expressId,
349
- ifcType: poly.ifcType,
350
- modelIndex: symbolicModelIndex,
351
- depth: 0,
352
- });
353
- }
354
-
355
- if (poly.isClosed && pointCount > 2) {
356
- symbolicLines.push({
357
- line: {
358
- start: { x: points[(pointCount - 1) * 2], y: points[(pointCount - 1) * 2 + 1] },
359
- end: { x: points[0], y: points[1] }
360
- },
361
- category: 'silhouette',
362
- visibility: 'visible',
363
- entityId: poly.expressId,
364
- ifcType: poly.ifcType,
365
- modelIndex: symbolicModelIndex,
366
- depth: 0,
367
- });
368
- }
369
- }
370
-
371
- // Process circles/arcs
372
- for (let i = 0; i < symbolicCollection.circleCount; i++) {
373
- const circle = symbolicCollection.getCircle(i);
374
- if (!circle) continue;
375
-
376
- entitiesWithSymbols.add(circle.expressId);
377
- const numSegments = circle.isFullCircle ? 32 : 16;
378
-
379
- for (let j = 0; j < numSegments; j++) {
380
- const t1 = j / numSegments;
381
- const t2 = (j + 1) / numSegments;
382
- const a1 = circle.startAngle + t1 * (circle.endAngle - circle.startAngle);
383
- const a2 = circle.startAngle + t2 * (circle.endAngle - circle.startAngle);
384
-
385
- symbolicLines.push({
386
- line: {
387
- start: {
388
- x: circle.centerX + circle.radius * Math.cos(a1),
389
- y: circle.centerY + circle.radius * Math.sin(a1),
390
- },
391
- end: {
392
- x: circle.centerX + circle.radius * Math.cos(a2),
393
- y: circle.centerY + circle.radius * Math.sin(a2),
394
- },
395
- },
396
- category: 'silhouette',
397
- visibility: 'visible',
398
- entityId: circle.expressId,
399
- ifcType: circle.ifcType,
400
- modelIndex: symbolicModelIndex,
401
- depth: 0,
402
- });
403
- }
404
- }
405
- }
406
- } finally {
407
- processor.dispose();
408
- }
409
-
410
- // Cache the parsed data
411
- symbolicCacheRef.current = {
412
- lines: symbolicLines,
413
- entities: entitiesWithSymbols,
414
- sourceId: modelCacheKey,
415
- useSymbolic,
416
- };
417
- } catch (error) {
418
- console.warn('Symbolic parsing failed:', error);
419
- symbolicLines = [];
420
- entitiesWithSymbols = new Set<number>();
421
- }
422
- }
423
- } else {
424
- // Clear cache if symbolic is disabled
425
- if (cache && cache.useSymbolic) {
426
- symbolicCacheRef.current = null;
427
- }
428
- }
429
-
430
- let generator: Drawing2DGenerator | null = null;
431
- try {
432
- generator = new Drawing2DGenerator();
433
- await generator.initialize();
434
-
435
- // Convert semantic axis to geometric
436
- const axis = AXIS_MAP[sectionPlane.axis];
437
-
438
- // Calculate section position from percentage using coordinateInfo bounds
439
- const bounds = geometryResult.coordinateInfo.shiftedBounds;
440
-
441
- const axisMin = bounds.min[axis];
442
- const axisMax = bounds.max[axis];
443
- const position = axisMin + (sectionPlane.position / 100) * (axisMax - axisMin);
444
-
445
- // Calculate max depth as half the model extent
446
- const maxDepth = (axisMax - axisMin) * 0.5;
447
-
448
- // Adjust progress to account for symbolic parsing phase (0-20%)
449
- const progressOffset = symbolicLines.length > 0 ? 20 : 0;
450
- const progressScale = symbolicLines.length > 0 ? 0.8 : 1;
451
- const progressCallback = (stage: string, prog: number) => {
452
- setDrawingProgress(progressOffset + prog * 100 * progressScale, stage);
453
- };
454
-
455
- // Create section config
456
- const config: SectionConfig = createSectionConfig(axis, position, {
457
- projectionDepth: maxDepth,
458
- includeHiddenLines: displayOptions.showHiddenLines,
459
- scale: displayOptions.scale,
460
- });
461
-
462
- // Override the flipped setting
463
- config.plane.flipped = sectionPlane.flipped;
464
-
465
- // Filter meshes by visibility (respect 3D hiding/isolation)
466
- let meshesToProcess = geometryResult.meshes;
467
-
468
- // Filter out hidden entities (using combined multi-model set)
469
- if (combinedHiddenIds.size > 0) {
470
- meshesToProcess = meshesToProcess.filter(
471
- mesh => !combinedHiddenIds.has(mesh.expressId)
472
- );
473
- }
474
-
475
- // Filter by isolation (if active, using combined multi-model set)
476
- if (combinedIsolatedIds !== null) {
477
- meshesToProcess = meshesToProcess.filter(
478
- mesh => combinedIsolatedIds.has(mesh.expressId)
479
- );
480
- }
481
-
482
- // Also filter by computedIsolatedIds (storey selection)
483
- if (computedIsolatedIds !== null && computedIsolatedIds !== undefined && computedIsolatedIds.size > 0) {
484
- const isolatedSet = computedIsolatedIds;
485
- meshesToProcess = meshesToProcess.filter(
486
- mesh => isolatedSet.has(mesh.expressId)
487
- );
488
- }
489
-
490
- // If all meshes were filtered out by visibility, clear the drawing
491
- if (meshesToProcess.length === 0) {
492
- setDrawing(null);
493
- setDrawingStatus('idle');
494
- setDrawingError(null);
495
- return;
496
- }
497
-
498
- const result = await generator.generate(meshesToProcess, config, {
499
- includeHiddenLines: false, // Disable - causes internal mesh edges
500
- includeProjection: false, // Disable - causes triangulation lines
501
- includeEdges: false, // Disable - causes triangulation lines
502
- mergeLines: true,
503
- onProgress: progressCallback,
504
- });
505
-
506
- // If we have symbolic representations, create a hybrid drawing
507
- if (symbolicLines.length > 0 && entitiesWithSymbols.size > 0) {
508
- // Get entity IDs that actually appear in the section cut (these are being cut by the plane)
509
- const cutEntityIds = new Set<number>();
510
- for (const line of result.lines) {
511
- if (line.entityId !== undefined) {
512
- cutEntityIds.add(line.entityId);
513
- }
514
- }
515
- // Also check cut polygons for entity IDs
516
- for (const poly of result.cutPolygons ?? []) {
517
- if ((poly as { entityId?: number }).entityId !== undefined) {
518
- cutEntityIds.add((poly as { entityId?: number }).entityId!);
519
- }
520
- }
521
-
522
- // Only include symbolic lines for entities that are ACTUALLY being cut
523
- // This filters out symbols from other floors/levels not intersected by the section plane
524
- const relevantSymbolicLines = symbolicLines.filter(line =>
525
- line.entityId !== undefined && cutEntityIds.has(line.entityId)
526
- );
527
-
528
- // Get the set of entities that have both symbols AND are being cut
529
- const entitiesWithRelevantSymbols = new Set<number>();
530
- for (const line of relevantSymbolicLines) {
531
- if (line.entityId !== undefined) {
532
- entitiesWithRelevantSymbols.add(line.entityId);
533
- }
534
- }
535
-
536
- // Align symbolic geometry with section cut geometry using bounding box matching
537
- // Plan representations often have different local origins than Body representations
538
- // So we compute per-entity transforms to align Plan bbox center with section cut bbox center
539
-
540
- // Build per-entity bounding boxes for section cut
541
- const sectionCutBounds = new Map<number, { minX: number; minY: number; maxX: number; maxY: number }>();
542
- const updateBounds = (entityId: number, x: number, y: number) => {
543
- const bounds = sectionCutBounds.get(entityId) ?? { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity };
544
- bounds.minX = Math.min(bounds.minX, x);
545
- bounds.minY = Math.min(bounds.minY, y);
546
- bounds.maxX = Math.max(bounds.maxX, x);
547
- bounds.maxY = Math.max(bounds.maxY, y);
548
- sectionCutBounds.set(entityId, bounds);
549
- };
550
- for (const line of result.lines) {
551
- if (line.entityId === undefined) continue;
552
- updateBounds(line.entityId, line.line.start.x, line.line.start.y);
553
- updateBounds(line.entityId, line.line.end.x, line.line.end.y);
554
- }
555
- // Include cut polygon vertices in bounds computation
556
- for (const poly of result.cutPolygons ?? []) {
557
- const entityId = (poly as { entityId?: number }).entityId;
558
- if (entityId === undefined) continue;
559
- for (const pt of poly.polygon.outer) {
560
- updateBounds(entityId, pt.x, pt.y);
561
- }
562
- for (const hole of poly.polygon.holes) {
563
- for (const pt of hole) {
564
- updateBounds(entityId, pt.x, pt.y);
565
- }
566
- }
567
- }
568
-
569
- // Build per-entity bounding boxes for symbolic
570
- const symbolicBounds = new Map<number, { minX: number; minY: number; maxX: number; maxY: number }>();
571
- for (const line of relevantSymbolicLines) {
572
- if (line.entityId === undefined) continue;
573
- const bounds = symbolicBounds.get(line.entityId) ?? { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity };
574
- bounds.minX = Math.min(bounds.minX, line.line.start.x, line.line.end.x);
575
- bounds.minY = Math.min(bounds.minY, line.line.start.y, line.line.end.y);
576
- bounds.maxX = Math.max(bounds.maxX, line.line.start.x, line.line.end.x);
577
- bounds.maxY = Math.max(bounds.maxY, line.line.start.y, line.line.end.y);
578
- symbolicBounds.set(line.entityId, bounds);
579
- }
580
-
581
- // Compute per-entity alignment transforms (center-to-center offset)
582
- const alignmentOffsets = new Map<number, { dx: number; dy: number }>();
583
- for (const entityId of entitiesWithRelevantSymbols) {
584
- const scBounds = sectionCutBounds.get(entityId);
585
- const symBounds = symbolicBounds.get(entityId);
586
- if (scBounds && symBounds) {
587
- const scCenterX = (scBounds.minX + scBounds.maxX) / 2;
588
- const scCenterY = (scBounds.minY + scBounds.maxY) / 2;
589
- const symCenterX = (symBounds.minX + symBounds.maxX) / 2;
590
- const symCenterY = (symBounds.minY + symBounds.maxY) / 2;
591
- alignmentOffsets.set(entityId, {
592
- dx: scCenterX - symCenterX,
593
- dy: scCenterY - symCenterY,
594
- });
595
- }
596
- }
597
-
598
- // Apply alignment offsets to symbolic lines
599
- const alignedSymbolicLines = relevantSymbolicLines.map(line => {
600
- const offset = line.entityId !== undefined ? alignmentOffsets.get(line.entityId) : undefined;
601
- if (offset) {
602
- return {
603
- ...line,
604
- line: {
605
- start: { x: line.line.start.x + offset.dx, y: line.line.start.y + offset.dy },
606
- end: { x: line.line.end.x + offset.dx, y: line.line.end.y + offset.dy },
607
- },
608
- };
609
- }
610
- return line;
611
- });
612
-
613
- // Filter out section cut lines for entities that have relevant symbolic representations
614
- const filteredLines = result.lines.filter((line: DrawingLine) =>
615
- line.entityId === undefined || !entitiesWithRelevantSymbols.has(line.entityId)
616
- );
617
-
618
- // Also filter cut polygons for entities with relevant symbols
619
- const filteredCutPolygons = result.cutPolygons?.filter((poly: { entityId?: number }) =>
620
- poly.entityId === undefined || !entitiesWithRelevantSymbols.has(poly.entityId)
621
- ) ?? [];
622
-
623
- // Combine filtered section cuts with aligned symbolic lines
624
- const combinedLines = [...filteredLines, ...alignedSymbolicLines];
625
-
626
- // Recalculate bounds with combined lines and polygons
627
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
628
- for (const line of combinedLines) {
629
- minX = Math.min(minX, line.line.start.x, line.line.end.x);
630
- minY = Math.min(minY, line.line.start.y, line.line.end.y);
631
- maxX = Math.max(maxX, line.line.start.x, line.line.end.x);
632
- maxY = Math.max(maxY, line.line.start.y, line.line.end.y);
633
- }
634
- // Include polygon vertices in bounds
635
- for (const poly of filteredCutPolygons) {
636
- for (const pt of poly.polygon.outer) {
637
- minX = Math.min(minX, pt.x);
638
- minY = Math.min(minY, pt.y);
639
- maxX = Math.max(maxX, pt.x);
640
- maxY = Math.max(maxY, pt.y);
641
- }
642
- for (const hole of poly.polygon.holes) {
643
- for (const pt of hole) {
644
- minX = Math.min(minX, pt.x);
645
- minY = Math.min(minY, pt.y);
646
- maxX = Math.max(maxX, pt.x);
647
- maxY = Math.max(maxY, pt.y);
648
- }
649
- }
650
- }
651
-
652
- // Create hybrid drawing
653
- const hybridDrawing: Drawing2D = {
654
- ...result,
655
- lines: combinedLines,
656
- cutPolygons: filteredCutPolygons,
657
- bounds: {
658
- min: { x: isFinite(minX) ? minX : result.bounds.min.x, y: isFinite(minY) ? minY : result.bounds.min.y },
659
- max: { x: isFinite(maxX) ? maxX : result.bounds.max.x, y: isFinite(maxY) ? maxY : result.bounds.max.y },
660
- },
661
- stats: {
662
- ...result.stats,
663
- cutLineCount: combinedLines.length,
664
- },
665
- };
666
-
667
- setDrawing(hybridDrawing);
668
- } else {
669
- setDrawing(result);
670
- }
671
-
672
- // Always set status to ready (whether initial generation or regeneration)
673
- setDrawingStatus('ready');
674
- isRegeneratingRef.current = false;
675
- } catch (error) {
676
- console.error('Drawing generation failed:', error);
677
- setDrawingError(error instanceof Error ? error.message : 'Generation failed');
678
- } finally {
679
- // Always cleanup generator to prevent resource leaks
680
- generator?.dispose();
681
- }
682
- }, [
683
- geometryResult,
684
- ifcDataStore,
685
- sectionPlane,
686
- displayOptions,
687
- combinedHiddenIds,
688
- combinedIsolatedIds,
689
- computedIsolatedIds,
690
- setDrawing,
691
- setDrawingStatus,
692
- setDrawingProgress,
693
- setDrawingError,
694
- ]);
695
-
696
- // Track panel visibility and geometry for detecting changes
697
- const prevPanelVisibleRef = useRef(false);
698
- const prevOverlayEnabledRef = useRef(false);
699
- const prevMeshCountRef = useRef(0);
700
-
701
- // Auto-generate when panel opens (or 3D overlay is enabled) and no drawing exists
702
- // Also regenerate when geometry changes significantly (e.g., models hidden/shown)
703
- useEffect(() => {
704
- const wasVisible = prevPanelVisibleRef.current;
705
- const wasOverlayEnabled = prevOverlayEnabledRef.current;
706
- const prevMeshCount = prevMeshCountRef.current;
707
- const currentMeshCount = geometryResult?.meshes?.length ?? 0;
708
- const hasGeometry = currentMeshCount > 0;
709
-
710
- // Track panel visibility separately from overlay
711
- const panelJustOpened = panelVisible && !wasVisible;
712
- const overlayJustEnabled = displayOptions.show3DOverlay && !wasOverlayEnabled;
713
- const isNowActive = panelVisible || displayOptions.show3DOverlay;
714
- const geometryChanged = currentMeshCount !== prevMeshCount;
715
-
716
- // Always update refs
717
- prevPanelVisibleRef.current = panelVisible;
718
- prevOverlayEnabledRef.current = displayOptions.show3DOverlay;
719
- prevMeshCountRef.current = currentMeshCount;
720
-
721
- if (isNowActive) {
722
- if (!hasGeometry) {
723
- // No geometry available - clear the drawing
724
- if (drawing) {
725
- setDrawing(null);
726
- setDrawingStatus('idle');
727
- }
728
- } else if (panelJustOpened || overlayJustEnabled || !drawing || geometryChanged) {
729
- // Generate if:
730
- // 1. Panel just opened, OR
731
- // 2. Overlay just enabled, OR
732
- // 3. No drawing exists, OR
733
- // 4. Geometry changed significantly (models hidden/shown)
734
- generateDrawing();
735
- }
736
- }
737
- }, [panelVisible, displayOptions.show3DOverlay, drawing, geometryResult, generateDrawing, setDrawing, setDrawingStatus]);
738
-
739
- // Auto-regenerate when section plane changes
740
- // Strategy: INSTANT - no debounce, but prevent overlapping computations
741
- // The generation time itself acts as natural batching for fast slider movements
742
- const sectionRef = useRef({ axis: sectionPlane.axis, position: sectionPlane.position, flipped: sectionPlane.flipped });
743
- const isGeneratingRef = useRef(false);
744
- const latestSectionRef = useRef({ axis: sectionPlane.axis, position: sectionPlane.position, flipped: sectionPlane.flipped });
745
- const [isRegenerating, setIsRegenerating] = useState(false);
746
-
747
- // Stable regenerate function that handles overlapping calls
748
- const doRegenerate = useCallback(async () => {
749
- if (isGeneratingRef.current) {
750
- // Already generating - the latest position is already tracked in latestSectionRef
751
- // When current generation finishes, it will check if another is needed
752
- return;
753
- }
754
-
755
- isGeneratingRef.current = true;
756
- setIsRegenerating(true);
757
-
758
- // Capture position at start of generation
759
- const targetSection = { ...latestSectionRef.current };
760
-
761
- try {
762
- await generateDrawing(true);
763
- } finally {
764
- isGeneratingRef.current = false;
765
- setIsRegenerating(false);
766
-
767
- // Check if section changed while we were generating
768
- const current = latestSectionRef.current;
769
- if (
770
- current.axis !== targetSection.axis ||
771
- current.position !== targetSection.position ||
772
- current.flipped !== targetSection.flipped
773
- ) {
774
- // Position changed during generation - regenerate immediately with latest
775
- // Use microtask to avoid blocking
776
- queueMicrotask(() => doRegenerate());
777
- }
778
- }
779
- }, [generateDrawing]);
780
-
781
- useEffect(() => {
782
- // Always update latest section ref (even if generating)
783
- latestSectionRef.current = { axis: sectionPlane.axis, position: sectionPlane.position, flipped: sectionPlane.flipped };
784
-
785
- // Check if section plane actually changed from last processed
786
- const prev = sectionRef.current;
787
- if (
788
- prev.axis === sectionPlane.axis &&
789
- prev.position === sectionPlane.position &&
790
- prev.flipped === sectionPlane.flipped
791
- ) {
792
- return;
793
- }
794
-
795
- // Update processed ref
796
- sectionRef.current = { axis: sectionPlane.axis, position: sectionPlane.position, flipped: sectionPlane.flipped };
797
-
798
- // If panel is visible OR 3D overlay is enabled, and we have geometry, regenerate INSTANTLY
799
- if ((panelVisible || displayOptions.show3DOverlay) && geometryResult?.meshes) {
800
- // Start immediately - no debounce
801
- // doRegenerate handles preventing overlaps and will auto-regenerate with latest when done
802
- doRegenerate();
803
- }
804
- }, [panelVisible, displayOptions.show3DOverlay, sectionPlane.axis, sectionPlane.position, sectionPlane.flipped, geometryResult, combinedHiddenIds, combinedIsolatedIds, computedIsolatedIds, doRegenerate]);
805
-
806
214
  // ═══════════════════════════════════════════════════════════════════════════
807
- // 2D MEASURE TOOL HELPER FUNCTIONS
215
+ // EXTRACTED HOOKS
808
216
  // ═══════════════════════════════════════════════════════════════════════════
809
217
 
810
- // Convert screen coordinates to drawing coordinates
811
- const screenToDrawing = useCallback((screenX: number, screenY: number): { x: number; y: number } => {
812
- // Screen coord → drawing coord
813
- // Apply axis-specific inverse transforms (matching canvas rendering)
814
- const currentAxis = sectionPlane.axis;
815
- const flipY = currentAxis !== 'down'; // Only flip Y for front/side views
816
- const flipX = currentAxis === 'side'; // Flip X for side view
817
-
818
- // Inverse of: screenX = drawingX * scaleX + transform.x
819
- // where scaleX = flipX ? -scale : scale
820
- const scaleX = flipX ? -viewTransform.scale : viewTransform.scale;
821
- const scaleY = flipY ? -viewTransform.scale : viewTransform.scale;
822
-
823
- const x = (screenX - viewTransform.x) / scaleX;
824
- const y = (screenY - viewTransform.y) / scaleY;
825
- return { x, y };
826
- }, [viewTransform, sectionPlane.axis]);
827
-
828
- // Find nearest point on a line segment
829
- const nearestPointOnSegment = useCallback((
830
- p: { x: number; y: number },
831
- a: { x: number; y: number },
832
- b: { x: number; y: number }
833
- ): { point: { x: number; y: number }; dist: number } => {
834
- const dx = b.x - a.x;
835
- const dy = b.y - a.y;
836
- const lenSq = dx * dx + dy * dy;
837
-
838
- if (lenSq < 0.0001) {
839
- // Degenerate segment
840
- const d = Math.sqrt((p.x - a.x) ** 2 + (p.y - a.y) ** 2);
841
- return { point: a, dist: d };
842
- }
843
-
844
- // Parameter t along segment [0,1]
845
- let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq;
846
- t = Math.max(0, Math.min(1, t));
847
-
848
- const nearest = { x: a.x + t * dx, y: a.y + t * dy };
849
- const dist = Math.sqrt((p.x - nearest.x) ** 2 + (p.y - nearest.y) ** 2);
850
-
851
- return { point: nearest, dist };
852
- }, []);
853
-
854
- // Find snap point near cursor (check polygon vertices, edges, and line endpoints)
855
- const findSnapPoint = useCallback((drawingCoord: { x: number; y: number }): { x: number; y: number } | null => {
856
- if (!drawing) return null;
857
-
858
- const snapThreshold = 10 / viewTransform.scale; // 10 screen pixels
859
- let bestSnap: { x: number; y: number } | null = null;
860
- let bestDist = snapThreshold;
861
-
862
- // Priority 1: Check polygon vertices (endpoints are highest priority)
863
- for (const polygon of drawing.cutPolygons) {
864
- for (const pt of polygon.polygon.outer) {
865
- const dist = Math.sqrt((pt.x - drawingCoord.x) ** 2 + (pt.y - drawingCoord.y) ** 2);
866
- if (dist < bestDist * 0.7) { // Vertices get priority (70% threshold)
867
- return { x: pt.x, y: pt.y }; // Return immediately for vertex snaps
868
- }
869
- }
870
- for (const hole of polygon.polygon.holes) {
871
- for (const pt of hole) {
872
- const dist = Math.sqrt((pt.x - drawingCoord.x) ** 2 + (pt.y - drawingCoord.y) ** 2);
873
- if (dist < bestDist * 0.7) {
874
- return { x: pt.x, y: pt.y };
875
- }
876
- }
877
- }
878
- }
879
-
880
- // Priority 2: Check line endpoints
881
- for (const line of drawing.lines) {
882
- const { start, end } = line.line;
883
- for (const pt of [start, end]) {
884
- const dist = Math.sqrt((pt.x - drawingCoord.x) ** 2 + (pt.y - drawingCoord.y) ** 2);
885
- if (dist < bestDist * 0.7) {
886
- return { x: pt.x, y: pt.y };
887
- }
888
- }
889
- }
890
-
891
- // Priority 3: Check polygon edges
892
- for (const polygon of drawing.cutPolygons) {
893
- const outer = polygon.polygon.outer;
894
- for (let i = 0; i < outer.length; i++) {
895
- const a = outer[i];
896
- const b = outer[(i + 1) % outer.length];
897
- const { point, dist } = nearestPointOnSegment(drawingCoord, a, b);
898
- if (dist < bestDist) {
899
- bestDist = dist;
900
- bestSnap = point;
901
- }
902
- }
903
- for (const hole of polygon.polygon.holes) {
904
- for (let i = 0; i < hole.length; i++) {
905
- const a = hole[i];
906
- const b = hole[(i + 1) % hole.length];
907
- const { point, dist } = nearestPointOnSegment(drawingCoord, a, b);
908
- if (dist < bestDist) {
909
- bestDist = dist;
910
- bestSnap = point;
911
- }
912
- }
913
- }
914
- }
915
-
916
- // Priority 4: Check drawing lines
917
- for (const line of drawing.lines) {
918
- const { start, end } = line.line;
919
- const { point, dist } = nearestPointOnSegment(drawingCoord, start, end);
920
- if (dist < bestDist) {
921
- bestDist = dist;
922
- bestSnap = point;
923
- }
924
- }
925
-
926
- return bestSnap;
927
- }, [drawing, viewTransform.scale, nearestPointOnSegment]);
928
-
929
- // Apply orthogonal constraint if shift is held
930
- const applyOrthogonalConstraint = useCallback((start: { x: number; y: number }, current: { x: number; y: number }, lockedAxis: 'x' | 'y' | null): { x: number; y: number } => {
931
- if (!lockedAxis) return current;
932
-
933
- if (lockedAxis === 'x') {
934
- return { x: current.x, y: start.y };
935
- } else {
936
- return { x: start.x, y: current.y };
937
- }
938
- }, []);
939
-
940
- // Keyboard handlers for shift key (orthogonal constraint)
941
- useEffect(() => {
942
- if (!measure2DMode) return;
943
-
944
- const handleKeyDown = (e: KeyboardEvent) => {
945
- if (e.key === 'Shift' && measure2DStart && measure2DCurrent && !measure2DShiftLocked) {
946
- // Determine axis based on dominant direction
947
- const dx = Math.abs(measure2DCurrent.x - measure2DStart.x);
948
- const dy = Math.abs(measure2DCurrent.y - measure2DStart.y);
949
- const axis = dx > dy ? 'x' : 'y';
950
- setMeasure2DShiftLocked(true, axis);
951
- }
952
- if (e.key === 'Escape') {
953
- cancelMeasure2D();
954
- }
955
- };
956
-
957
- const handleKeyUp = (e: KeyboardEvent) => {
958
- if (e.key === 'Shift') {
959
- setMeasure2DShiftLocked(false);
960
- }
961
- };
962
-
963
- window.addEventListener('keydown', handleKeyDown);
964
- window.addEventListener('keyup', handleKeyUp);
965
-
966
- return () => {
967
- window.removeEventListener('keydown', handleKeyDown);
968
- window.removeEventListener('keyup', handleKeyUp);
969
- };
970
- }, [measure2DMode, measure2DStart, measure2DCurrent, measure2DShiftLocked, setMeasure2DShiftLocked, cancelMeasure2D]);
971
-
972
- // Global mouseup handler to cancel measurement if released outside panel
973
- useEffect(() => {
974
- if (!measure2DMode) return;
975
-
976
- const handleGlobalMouseUp = (e: MouseEvent) => {
977
- // If mouse button is released and we're outside the panel with a measurement started, cancel it
978
- if (!isMouseInsidePanel.current && measure2DStart && e.button === 0) {
979
- cancelMeasure2D();
980
- }
981
- isMouseButtonDown.current = false;
982
- };
983
-
984
- window.addEventListener('mouseup', handleGlobalMouseUp);
985
- return () => {
986
- window.removeEventListener('mouseup', handleGlobalMouseUp);
987
- };
988
- }, [measure2DMode, measure2DStart, cancelMeasure2D]);
989
-
990
- // Pan/Measure handlers
991
- const handleMouseDown = useCallback((e: React.MouseEvent) => {
992
- if (e.button !== 0) return;
993
-
994
- isMouseButtonDown.current = true;
995
- const rect = containerRef.current?.getBoundingClientRect();
996
- if (!rect) return;
997
-
998
- const screenX = e.clientX - rect.left;
999
- const screenY = e.clientY - rect.top;
1000
-
1001
- if (measure2DMode) {
1002
- // Measure mode: set start point
1003
- const drawingCoord = screenToDrawing(screenX, screenY);
1004
- const snapPoint = findSnapPoint(drawingCoord);
1005
- const startPoint = snapPoint || drawingCoord;
1006
- setMeasure2DStart(startPoint);
1007
- setMeasure2DCurrent(startPoint);
1008
- } else {
1009
- // Pan mode
1010
- isPanning.current = true;
1011
- lastPanPoint.current = { x: e.clientX, y: e.clientY };
1012
- }
1013
- }, [measure2DMode, screenToDrawing, findSnapPoint, setMeasure2DStart, setMeasure2DCurrent]);
1014
-
1015
- const handleMouseMove = useCallback((e: React.MouseEvent) => {
1016
- const rect = containerRef.current?.getBoundingClientRect();
1017
- if (!rect) return;
1018
-
1019
- const screenX = e.clientX - rect.left;
1020
- const screenY = e.clientY - rect.top;
1021
-
1022
- if (measure2DMode) {
1023
- const drawingCoord = screenToDrawing(screenX, screenY);
1024
-
1025
- // Find snap point and update
1026
- const snapPoint = findSnapPoint(drawingCoord);
1027
- setMeasure2DSnapPoint(snapPoint);
1028
-
1029
- if (measure2DStart) {
1030
- // If measuring, update current point
1031
- let currentPoint = snapPoint || drawingCoord;
1032
-
1033
- // Apply orthogonal constraint if shift is held
1034
- if (measure2DShiftLocked && measure2DLockedAxis) {
1035
- currentPoint = applyOrthogonalConstraint(measure2DStart, currentPoint, measure2DLockedAxis);
1036
- }
1037
-
1038
- setMeasure2DCurrent(currentPoint);
1039
- }
1040
- } else if (isPanning.current) {
1041
- // Pan mode
1042
- const dx = e.clientX - lastPanPoint.current.x;
1043
- const dy = e.clientY - lastPanPoint.current.y;
1044
- lastPanPoint.current = { x: e.clientX, y: e.clientY };
1045
- setViewTransform((prev) => ({
1046
- ...prev,
1047
- x: prev.x + dx,
1048
- y: prev.y + dy,
1049
- }));
1050
- }
1051
- }, [measure2DMode, measure2DStart, measure2DShiftLocked, measure2DLockedAxis, screenToDrawing, findSnapPoint, setMeasure2DSnapPoint, setMeasure2DCurrent, applyOrthogonalConstraint]);
1052
-
1053
- const handleMouseUp = useCallback(() => {
1054
- isMouseButtonDown.current = false;
1055
- if (measure2DMode && measure2DStart && measure2DCurrent) {
1056
- // Complete the measurement
1057
- completeMeasure2D();
1058
- }
1059
- isPanning.current = false;
1060
- }, [measure2DMode, measure2DStart, measure2DCurrent, completeMeasure2D]);
1061
-
1062
- const handleMouseLeave = useCallback(() => {
1063
- isMouseInsidePanel.current = false;
1064
- // Don't cancel if button is still down - user might re-enter
1065
- // Cancel will happen on global mouseup if released outside
1066
- isPanning.current = false;
1067
- }, []);
1068
-
1069
- const handleMouseEnter = useCallback((e: React.MouseEvent) => {
1070
- isMouseInsidePanel.current = true;
1071
- // If re-entering with button down and measurement started, resume tracking
1072
- if (isMouseButtonDown.current && measure2DMode && measure2DStart) {
1073
- const rect = containerRef.current?.getBoundingClientRect();
1074
- if (rect) {
1075
- const screenX = e.clientX - rect.left;
1076
- const screenY = e.clientY - rect.top;
1077
- const drawingCoord = screenToDrawing(screenX, screenY);
1078
- const snapPoint = findSnapPoint(drawingCoord);
1079
- const currentPoint = snapPoint || drawingCoord;
1080
- setMeasure2DCurrent(currentPoint);
1081
- }
1082
- }
1083
- }, [measure2DMode, measure2DStart, screenToDrawing, findSnapPoint, setMeasure2DCurrent]);
1084
-
1085
- // Wheel handler - attached with passive: false to allow preventDefault
1086
- useEffect(() => {
1087
- // Only attach handler when panel is visible
1088
- if (!panelVisible) return;
1089
-
1090
- const container = containerRef.current;
1091
- if (!container) {
1092
- // Container not ready yet, try again on next render
1093
- return;
1094
- }
1095
-
1096
- const wheelHandler = (e: WheelEvent) => {
1097
- e.preventDefault();
1098
- e.stopPropagation();
1099
- const delta = e.deltaY > 0 ? 0.9 : 1.1;
1100
- const rect = container.getBoundingClientRect();
1101
-
1102
- const x = e.clientX - rect.left;
1103
- const y = e.clientY - rect.top;
1104
-
1105
- setViewTransform((prev) => {
1106
- const newScale = Math.max(0.01, prev.scale * delta);
1107
- const scaleRatio = newScale / prev.scale;
1108
- return {
1109
- scale: newScale,
1110
- x: x - (x - prev.x) * scaleRatio,
1111
- y: y - (y - prev.y) * scaleRatio,
1112
- };
1113
- });
1114
- };
1115
-
1116
- container.addEventListener('wheel', wheelHandler, { passive: false });
1117
- return () => {
1118
- container.removeEventListener('wheel', wheelHandler);
1119
- };
1120
- }, [panelVisible, status]); // Re-run when panel visibility or status changes to ensure container is ready
1121
-
1122
- // Zoom controls - unlimited zoom
1123
- const zoomIn = useCallback(() => {
1124
- setViewTransform((prev) => ({ ...prev, scale: prev.scale * 1.2 })); // No upper limit
1125
- }, []);
1126
-
1127
- const zoomOut = useCallback(() => {
1128
- setViewTransform((prev) => ({ ...prev, scale: Math.max(0.01, prev.scale / 1.2) }));
1129
- }, []);
1130
-
1131
- const fitToView = useCallback(() => {
1132
- if (!drawing || !containerRef.current) return;
1133
- const rect = containerRef.current.getBoundingClientRect();
1134
-
1135
- // Sheet mode: fit the entire paper into view
1136
- if (sheetEnabled && activeSheet) {
1137
- const paperWidth = activeSheet.paper.widthMm;
1138
- const paperHeight = activeSheet.paper.heightMm;
1139
-
1140
- // Calculate scale to fit paper with padding (10% margin on each side)
1141
- const padding = 0.1;
1142
- const availableWidth = rect.width * (1 - 2 * padding);
1143
- const availableHeight = rect.height * (1 - 2 * padding);
1144
- const scaleX = availableWidth / paperWidth;
1145
- const scaleY = availableHeight / paperHeight;
1146
- const scale = Math.min(scaleX, scaleY);
1147
-
1148
- // Center the paper in the view
1149
- setViewTransform({
1150
- scale,
1151
- x: (rect.width - paperWidth * scale) / 2,
1152
- y: (rect.height - paperHeight * scale) / 2,
1153
- });
1154
- return;
1155
- }
1156
-
1157
- // Non-sheet mode: fit the drawing bounds
1158
- const { bounds } = drawing;
1159
- const width = bounds.max.x - bounds.min.x;
1160
- const height = bounds.max.y - bounds.min.y;
1161
-
1162
- if (width < 0.001 || height < 0.001) return;
1163
-
1164
- // Calculate scale to fit with padding (15% margin on each side)
1165
- const padding = 0.15;
1166
- const availableWidth = rect.width * (1 - 2 * padding);
1167
- const availableHeight = rect.height * (1 - 2 * padding);
1168
- const scaleX = availableWidth / width;
1169
- const scaleY = availableHeight / height;
1170
- // No artificial cap - let it zoom to fit the content
1171
- const scale = Math.min(scaleX, scaleY);
1172
-
1173
- // Center the drawing in the view with axis-specific transforms
1174
- // Must match the canvas rendering transforms:
1175
- // - 'down' (plan view): no Y flip
1176
- // - 'front'/'side': Y flip
1177
- // - 'side': X flip
1178
- const currentAxis = sectionPlane.axis;
1179
- const flipY = currentAxis !== 'down';
1180
- const flipX = currentAxis === 'side';
1181
-
1182
- const centerX = (bounds.min.x + bounds.max.x) / 2;
1183
- const centerY = (bounds.min.y + bounds.max.y) / 2;
1184
-
1185
- // Apply transforms matching canvas rendering
1186
- const adjustedCenterX = flipX ? -centerX : centerX;
1187
- const adjustedCenterY = flipY ? -centerY : centerY;
1188
-
1189
- setViewTransform({
1190
- scale,
1191
- x: rect.width / 2 - adjustedCenterX * scale,
1192
- y: rect.height / 2 - adjustedCenterY * scale,
1193
- });
1194
- }, [drawing, sheetEnabled, activeSheet, sectionPlane.axis]);
1195
-
1196
- // Track axis changes for forced fit-to-view
1197
- const lastFitAxisRef = useRef(sectionPlane.axis);
1198
-
1199
- // Set needsFit when axis changes
1200
- useEffect(() => {
1201
- if (sectionPlane.axis !== prevAxisRef.current) {
1202
- prevAxisRef.current = sectionPlane.axis;
1203
- setNeedsFit(true); // Force fit when axis changes
1204
- cachedSheetTransformRef.current = null; // Clear cached transform for new axis
1205
- }
1206
- }, [sectionPlane.axis]);
1207
-
1208
- // Track previous sheet mode to detect toggle
1209
- const prevSheetEnabledRef = useRef(sheetEnabled);
1210
- useEffect(() => {
1211
- if (sheetEnabled !== prevSheetEnabledRef.current) {
1212
- prevSheetEnabledRef.current = sheetEnabled;
1213
- cachedSheetTransformRef.current = null; // Clear cached transform
1214
- // Auto-fit when sheet mode is toggled
1215
- if (status === 'ready' && drawing && containerRef.current) {
1216
- const timeout = setTimeout(() => {
1217
- fitToView();
1218
- }, 50);
1219
- return () => clearTimeout(timeout);
1220
- }
1221
- }
1222
- }, [sheetEnabled, status, drawing, fitToView]);
1223
-
1224
- // Auto-fit when: (1) needsFit is true (first open or axis change), or (2) not pinned after regenerate
1225
- // ALWAYS fit when axis changed, regardless of pin state
1226
- // Also re-run when panelVisible changes so we fit when panel opens with existing drawing
1227
- useEffect(() => {
1228
- if (status === 'ready' && drawing && containerRef.current && panelVisible) {
1229
- const axisChanged = lastFitAxisRef.current !== sectionPlane.axis;
1230
-
1231
- // Fit if needsFit (first open/axis change) OR if not pinned OR if axis just changed
1232
- if (needsFit || !isPinned || axisChanged) {
1233
- // Small delay to ensure canvas is rendered
1234
- const timeout = setTimeout(() => {
1235
- fitToView();
1236
- lastFitAxisRef.current = sectionPlane.axis;
1237
- if (needsFit) {
1238
- setNeedsFit(false); // Clear the flag after fitting
1239
- }
1240
- }, 50);
1241
- return () => clearTimeout(timeout);
1242
- }
1243
- }
1244
- }, [status, drawing, fitToView, isPinned, needsFit, sectionPlane.axis, panelVisible]);
1245
-
1246
- // Format distance for display (same logic as canvas)
1247
- const formatDistance = useCallback((distance: number): string => {
1248
- if (distance < 0.01) {
1249
- return `${(distance * 1000).toFixed(1)} mm`;
1250
- } else if (distance < 1) {
1251
- return `${(distance * 100).toFixed(1)} cm`;
1252
- } else {
1253
- return `${distance.toFixed(3)} m`;
1254
- }
1255
- }, []);
1256
-
1257
- // Generate SVG that matches the canvas rendering exactly
1258
- const generateExportSVG = useCallback((): string | null => {
1259
- if (!drawing) return null;
1260
-
1261
- const { bounds } = drawing;
1262
- const width = bounds.max.x - bounds.min.x;
1263
- const height = bounds.max.y - bounds.min.y;
1264
-
1265
- // Add padding around the drawing
1266
- const padding = Math.max(width, height) * 0.1;
1267
- const viewMinX = bounds.min.x - padding;
1268
- const viewMinY = bounds.min.y - padding;
1269
- const viewWidth = width + padding * 2;
1270
- const viewHeight = height + padding * 2;
1271
-
1272
- // SVG dimensions in mm (assuming model is in meters, scale 1:100)
1273
- const scale = displayOptions.scale || 100;
1274
- const svgWidthMm = (viewWidth * 1000) / scale;
1275
- const svgHeightMm = (viewHeight * 1000) / scale;
1276
-
1277
- // Convert mm on paper to model units (meters)
1278
- // At 1:100 scale, 1mm on paper = 0.1m in model space
1279
- // Formula: modelUnits = paperMm * scale / 1000
1280
- const mmToModel = (mm: number) => mm * scale / 1000;
1281
-
1282
- // Helper to escape XML
1283
- const escapeXml = (str: string): string => {
1284
- return str
1285
- .replace(/&/g, '&amp;')
1286
- .replace(/</g, '&lt;')
1287
- .replace(/>/g, '&gt;')
1288
- .replace(/"/g, '&quot;')
1289
- .replace(/'/g, '&apos;');
1290
- };
1291
-
1292
- // Axis-specific flipping (matching canvas rendering)
1293
- // - 'down' (plan view): DON'T flip Y so north (Z+) is up
1294
- // - 'front' and 'side': flip Y so height (Y+) is up
1295
- // - 'side': also flip X to look from conventional direction
1296
- const currentAxis = sectionPlane.axis;
1297
- const flipY = currentAxis !== 'down';
1298
- const flipX = currentAxis === 'side';
1299
-
1300
- // Helper to get polygon path with axis-specific coordinate transformation
1301
- const polygonToPath = (polygon: { outer: { x: number; y: number }[]; holes: { x: number; y: number }[][] }): string => {
1302
- const transformPt = (x: number, y: number) => ({
1303
- x: flipX ? -x : x,
1304
- y: flipY ? -y : y,
1305
- });
1306
-
1307
- let path = '';
1308
- if (polygon.outer.length > 0) {
1309
- const first = transformPt(polygon.outer[0].x, polygon.outer[0].y);
1310
- path += `M ${first.x.toFixed(4)} ${first.y.toFixed(4)}`;
1311
- for (let i = 1; i < polygon.outer.length; i++) {
1312
- const pt = transformPt(polygon.outer[i].x, polygon.outer[i].y);
1313
- path += ` L ${pt.x.toFixed(4)} ${pt.y.toFixed(4)}`;
1314
- }
1315
- path += ' Z';
1316
- }
1317
- for (const hole of polygon.holes) {
1318
- if (hole.length > 0) {
1319
- const holeFirst = transformPt(hole[0].x, hole[0].y);
1320
- path += ` M ${holeFirst.x.toFixed(4)} ${holeFirst.y.toFixed(4)}`;
1321
- for (let i = 1; i < hole.length; i++) {
1322
- const pt = transformPt(hole[i].x, hole[i].y);
1323
- path += ` L ${pt.x.toFixed(4)} ${pt.y.toFixed(4)}`;
1324
- }
1325
- path += ' Z';
1326
- }
1327
- }
1328
- return path;
1329
- };
1330
-
1331
- // Calculate viewBox with axis-specific flipping
1332
- const viewBoxMinX = flipX ? -viewMinX - viewWidth : viewMinX;
1333
- const viewBoxMinY = flipY ? -viewMinY - viewHeight : viewMinY;
1334
-
1335
- // Start building SVG
1336
- let svg = `<?xml version="1.0" encoding="UTF-8"?>
1337
- <svg xmlns="http://www.w3.org/2000/svg"
1338
- width="${svgWidthMm.toFixed(2)}mm"
1339
- height="${svgHeightMm.toFixed(2)}mm"
1340
- viewBox="${viewBoxMinX.toFixed(4)} ${viewBoxMinY.toFixed(4)} ${viewWidth.toFixed(4)} ${viewHeight.toFixed(4)}">
1341
- <rect x="${viewBoxMinX.toFixed(4)}" y="${viewBoxMinY.toFixed(4)}" width="${viewWidth.toFixed(4)}" height="${viewHeight.toFixed(4)}" fill="#FFFFFF"/>
1342
- `;
1343
-
1344
- // 1. FILL CUT POLYGONS (with color from IFC materials or override engine)
1345
- svg += ' <g id="polygon-fills">\n';
1346
- for (const polygon of drawing.cutPolygons) {
1347
- let fillColor = getFillColorForType(polygon.ifcType);
1348
- let opacity = 1;
1349
-
1350
- // Use actual IFC material colors from the mesh data
1351
- if (activePresetId === 'preset-3d-colors') {
1352
- const materialColor = entityColorMap.get(polygon.entityId);
1353
- if (materialColor) {
1354
- const r = Math.round(materialColor[0] * 255);
1355
- const g = Math.round(materialColor[1] * 255);
1356
- const b = Math.round(materialColor[2] * 255);
1357
- fillColor = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
1358
- opacity = materialColor[3];
1359
- }
1360
- } else if (overridesEnabled) {
1361
- const elementData: ElementData = {
1362
- expressId: polygon.entityId,
1363
- ifcType: polygon.ifcType,
1364
- };
1365
- const result = overrideEngine.applyOverrides(elementData);
1366
- fillColor = result.style.fillColor;
1367
- opacity = result.style.opacity;
1368
- }
1369
-
1370
- const pathData = polygonToPath(polygon.polygon);
1371
- svg += ` <path d="${pathData}" fill="${fillColor}" fill-opacity="${opacity.toFixed(2)}" fill-rule="evenodd" data-entity-id="${polygon.entityId}" data-ifc-type="${escapeXml(polygon.ifcType)}"/>\n`;
1372
- }
1373
- svg += ' </g>\n';
1374
-
1375
- // 2. STROKE CUT POLYGON OUTLINES (with color from override engine)
1376
- svg += ' <g id="polygon-outlines">\n';
1377
- for (const polygon of drawing.cutPolygons) {
1378
- let strokeColor = '#000000';
1379
- let lineWeight = 0.5;
1380
-
1381
- if (overridesEnabled) {
1382
- const elementData: ElementData = {
1383
- expressId: polygon.entityId,
1384
- ifcType: polygon.ifcType,
1385
- };
1386
- const result = overrideEngine.applyOverrides(elementData);
1387
- strokeColor = result.style.strokeColor;
1388
- lineWeight = result.style.lineWeight;
1389
- }
1390
-
1391
- const pathData = polygonToPath(polygon.polygon);
1392
- // Convert line weight (mm on paper) to model units
1393
- const svgLineWeight = mmToModel(lineWeight);
1394
- svg += ` <path d="${pathData}" fill="none" stroke="${strokeColor}" stroke-width="${svgLineWeight.toFixed(4)}" data-entity-id="${polygon.entityId}"/>\n`;
1395
- }
1396
- svg += ' </g>\n';
1397
-
1398
- // 3. DRAW PROJECTION/SILHOUETTE LINES
1399
- // Pre-compute bounds for line validation
1400
- const lineBounds = drawing.bounds;
1401
- const lineMargin = Math.max(lineBounds.max.x - lineBounds.min.x, lineBounds.max.y - lineBounds.min.y) * 0.5;
1402
- const lineMinX = lineBounds.min.x - lineMargin;
1403
- const lineMaxX = lineBounds.max.x + lineMargin;
1404
- const lineMinY = lineBounds.min.y - lineMargin;
1405
- const lineMaxY = lineBounds.max.y + lineMargin;
1406
-
1407
- svg += ' <g id="drawing-lines">\n';
1408
- for (const line of drawing.lines) {
1409
- // Skip 'cut' lines - they're triangulation edges, already handled by polygons
1410
- if (line.category === 'cut') continue;
1411
-
1412
- // Skip hidden lines if not showing
1413
- if (!displayOptions.showHiddenLines && line.visibility === 'hidden') continue;
1414
-
1415
- // Skip lines with invalid coordinates
1416
- const { start, end } = line.line;
1417
- if (!isFinite(start.x) || !isFinite(start.y) || !isFinite(end.x) || !isFinite(end.y)) {
1418
- continue;
1419
- }
1420
- if (start.x < lineMinX || start.x > lineMaxX || start.y < lineMinY || start.y > lineMaxY ||
1421
- end.x < lineMinX || end.x > lineMaxX || end.y < lineMinY || end.y > lineMaxY) {
1422
- continue;
1423
- }
1424
-
1425
- // Set line style based on category
1426
- let strokeColor = '#000000';
1427
- let lineWidth = 0.25;
1428
- let dashArray = '';
1429
-
1430
- switch (line.category) {
1431
- case 'projection':
1432
- lineWidth = 0.25;
1433
- strokeColor = '#000000';
1434
- break;
1435
- case 'hidden':
1436
- lineWidth = 0.18;
1437
- strokeColor = '#666666';
1438
- dashArray = '2 1';
1439
- break;
1440
- case 'silhouette':
1441
- lineWidth = 0.35;
1442
- strokeColor = '#000000';
1443
- break;
1444
- case 'crease':
1445
- lineWidth = 0.18;
1446
- strokeColor = '#000000';
1447
- break;
1448
- case 'boundary':
1449
- lineWidth = 0.25;
1450
- strokeColor = '#000000';
1451
- break;
1452
- case 'annotation':
1453
- lineWidth = 0.13;
1454
- strokeColor = '#000000';
1455
- break;
1456
- }
1457
-
1458
- // Hidden visibility overrides
1459
- if (line.visibility === 'hidden') {
1460
- strokeColor = '#888888';
1461
- dashArray = '2 1';
1462
- lineWidth *= 0.7;
1463
- }
1464
-
1465
- // Convert line width from mm on paper to model units
1466
- const svgLineWidth = mmToModel(lineWidth);
1467
- const dashAttr = dashArray ? ` stroke-dasharray="${dashArray.split(' ').map(d => mmToModel(parseFloat(d)).toFixed(4)).join(' ')}"` : '';
1468
-
1469
- // Transform line endpoints with axis-specific flipping
1470
- const startT = { x: flipX ? -start.x : start.x, y: flipY ? -start.y : start.y };
1471
- const endT = { x: flipX ? -end.x : end.x, y: flipY ? -end.y : end.y };
1472
- svg += ` <line x1="${startT.x.toFixed(4)}" y1="${startT.y.toFixed(4)}" x2="${endT.x.toFixed(4)}" y2="${endT.y.toFixed(4)}" stroke="${strokeColor}" stroke-width="${svgLineWidth.toFixed(4)}"${dashAttr}/>\n`;
1473
- }
1474
- svg += ' </g>\n';
1475
-
1476
- // 4. DRAW COMPLETED MEASUREMENTS
1477
- if (measure2DResults.length > 0) {
1478
- svg += ' <g id="measurements">\n';
1479
- for (const result of measure2DResults) {
1480
- const { start, end, distance } = result;
1481
- // Transform measurement points with axis-specific flipping
1482
- const startT = { x: flipX ? -start.x : start.x, y: flipY ? -start.y : start.y };
1483
- const endT = { x: flipX ? -end.x : end.x, y: flipY ? -end.y : end.y };
1484
- const midX = (startT.x + endT.x) / 2;
1485
- const midY = (startT.y + endT.y) / 2;
1486
- const labelText = formatDistance(distance);
1487
-
1488
- // Measurement styling (all in mm on paper, converted to model units)
1489
- const measureColor = '#2196F3';
1490
- const measureLineWidth = mmToModel(0.4); // 0.4mm line on paper
1491
- const endpointRadius = mmToModel(1.5); // 1.5mm radius on paper
1492
-
1493
- // Draw line
1494
- svg += ` <line x1="${startT.x.toFixed(4)}" y1="${startT.y.toFixed(4)}" x2="${endT.x.toFixed(4)}" y2="${endT.y.toFixed(4)}" stroke="${measureColor}" stroke-width="${measureLineWidth.toFixed(4)}"/>\n`;
1495
-
1496
- // Draw endpoints
1497
- svg += ` <circle cx="${startT.x.toFixed(4)}" cy="${startT.y.toFixed(4)}" r="${endpointRadius.toFixed(4)}" fill="${measureColor}"/>\n`;
1498
- svg += ` <circle cx="${endT.x.toFixed(4)}" cy="${endT.y.toFixed(4)}" r="${endpointRadius.toFixed(4)}" fill="${measureColor}"/>\n`;
1499
-
1500
- // Draw label background and text
1501
- // Use 3mm text height on paper for readable labels
1502
- const fontSize = mmToModel(3);
1503
- const labelWidth = labelText.length * fontSize * 0.6; // Approximate text width
1504
- const labelHeight = fontSize * 1.4;
1505
- const labelStroke = mmToModel(0.2);
1506
-
1507
- svg += ` <rect x="${(midX - labelWidth / 2).toFixed(4)}" y="${(midY - labelHeight / 2).toFixed(4)}" width="${labelWidth.toFixed(4)}" height="${labelHeight.toFixed(4)}" fill="rgba(255,255,255,0.95)" stroke="${measureColor}" stroke-width="${labelStroke.toFixed(4)}"/>\n`;
1508
- svg += ` <text x="${midX.toFixed(4)}" y="${midY.toFixed(4)}" font-family="Arial, sans-serif" font-size="${fontSize.toFixed(4)}" fill="#000000" text-anchor="middle" dominant-baseline="middle" font-weight="500">${escapeXml(labelText)}</text>\n`;
1509
- }
1510
- svg += ' </g>\n';
1511
- }
1512
-
1513
- svg += '</svg>';
1514
- return svg;
1515
- }, [drawing, displayOptions, activePresetId, entityColorMap, overridesEnabled, overrideEngine, measure2DResults, formatDistance, sectionPlane.axis]);
1516
-
1517
- // Generate SVG with drawing sheet (frame, title block, scale bar)
1518
- // This generates coordinates directly in paper mm space (like the canvas rendering)
1519
- const generateSheetSVG = useCallback((): string | null => {
1520
- if (!drawing || !activeSheet) return null;
1521
-
1522
- const { bounds } = drawing;
1523
-
1524
- // Sheet dimensions in mm
1525
- const paperWidth = activeSheet.paper.widthMm;
1526
- const paperHeight = activeSheet.paper.heightMm;
1527
- const viewport = activeSheet.viewportBounds;
1528
-
1529
- // Calculate transform to fit drawing into viewport
1530
- const drawingTransform = calculateDrawingTransform(
1531
- { minX: bounds.min.x, minY: bounds.min.y, maxX: bounds.max.x, maxY: bounds.max.y },
1532
- viewport,
1533
- activeSheet.scale
1534
- );
1535
-
1536
- const { translateX, translateY, scaleFactor } = drawingTransform;
1537
-
1538
- // Axis-specific flipping (matching canvas rendering)
1539
- // - 'down' (plan view): DON'T flip Y so north (Z+) is up
1540
- // - 'front' and 'side': flip Y so height (Y+) is up
1541
- // - 'side': also flip X to look from conventional direction
1542
- const currentAxis = sectionPlane.axis;
1543
- const flipY = currentAxis !== 'down';
1544
- const flipX = currentAxis === 'side';
1545
-
1546
- // Helper: convert model coordinates to paper mm (matching canvas rendering exactly)
1547
- const modelToPaper = (x: number, y: number): { x: number; y: number } => {
1548
- const adjustedX = flipX ? -x : x;
1549
- const adjustedY = flipY ? -y : y;
1550
- return {
1551
- x: adjustedX * scaleFactor + translateX,
1552
- y: adjustedY * scaleFactor + translateY,
1553
- };
1554
- };
1555
-
1556
- // Start building SVG (paper coordinates in mm)
1557
- let svg = `<?xml version="1.0" encoding="UTF-8"?>
1558
- <svg xmlns="http://www.w3.org/2000/svg"
1559
- width="${paperWidth}mm"
1560
- height="${paperHeight}mm"
1561
- viewBox="0 0 ${paperWidth} ${paperHeight}">
1562
- <!-- Background -->
1563
- <rect x="0" y="0" width="${paperWidth}" height="${paperHeight}" fill="#FFFFFF"/>
1564
-
1565
- `;
1566
-
1567
- // Create clipping path for viewport FIRST (so it can be used by drawing content)
1568
- svg += ` <defs>
1569
- <clipPath id="viewport-clip">
1570
- <rect x="${viewport.x.toFixed(2)}" y="${viewport.y.toFixed(2)}" width="${viewport.width.toFixed(2)}" height="${viewport.height.toFixed(2)}"/>
1571
- </clipPath>
1572
- </defs>
1573
-
1574
- `;
1575
-
1576
- // Drawing content FIRST (so frame/title block render on top)
1577
- svg += ` <g id="drawing-content" clip-path="url(#viewport-clip)">
1578
- `;
1579
-
1580
- // Helper to escape XML
1581
- const escapeXml = (str: string): string => {
1582
- return str
1583
- .replace(/&/g, '&amp;')
1584
- .replace(/</g, '&lt;')
1585
- .replace(/>/g, '&gt;')
1586
- .replace(/"/g, '&quot;')
1587
- .replace(/'/g, '&apos;');
1588
- };
1589
-
1590
- // Helper to get polygon path in paper coordinates
1591
- const polygonToPath = (polygon: { outer: { x: number; y: number }[]; holes: { x: number; y: number }[][] }): string => {
1592
- let path = '';
1593
- if (polygon.outer.length > 0) {
1594
- const first = modelToPaper(polygon.outer[0].x, polygon.outer[0].y);
1595
- path += `M ${first.x.toFixed(4)} ${first.y.toFixed(4)}`;
1596
- for (let i = 1; i < polygon.outer.length; i++) {
1597
- const pt = modelToPaper(polygon.outer[i].x, polygon.outer[i].y);
1598
- path += ` L ${pt.x.toFixed(4)} ${pt.y.toFixed(4)}`;
1599
- }
1600
- path += ' Z';
1601
- }
1602
- for (const hole of polygon.holes) {
1603
- if (hole.length > 0) {
1604
- const holeFirst = modelToPaper(hole[0].x, hole[0].y);
1605
- path += ` M ${holeFirst.x.toFixed(4)} ${holeFirst.y.toFixed(4)}`;
1606
- for (let i = 1; i < hole.length; i++) {
1607
- const pt = modelToPaper(hole[i].x, hole[i].y);
1608
- path += ` L ${pt.x.toFixed(4)} ${pt.y.toFixed(4)}`;
1609
- }
1610
- path += ' Z';
1611
- }
1612
- }
1613
- return path;
1614
- };
1615
-
1616
- // Render polygon fills
1617
- svg += ' <g id="polygon-fills">\n';
1618
- for (const polygon of drawing.cutPolygons) {
1619
- let fillColor = getFillColorForType(polygon.ifcType);
1620
- let opacity = 1;
1621
-
1622
- if (activePresetId === 'preset-3d-colors') {
1623
- const materialColor = entityColorMap.get(polygon.entityId);
1624
- if (materialColor) {
1625
- const r = Math.round(materialColor[0] * 255);
1626
- const g = Math.round(materialColor[1] * 255);
1627
- const b = Math.round(materialColor[2] * 255);
1628
- fillColor = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
1629
- opacity = materialColor[3];
1630
- }
1631
- } else if (overridesEnabled) {
1632
- const elementData: ElementData = {
1633
- expressId: polygon.entityId,
1634
- ifcType: polygon.ifcType,
1635
- };
1636
- const result = overrideEngine.applyOverrides(elementData);
1637
- fillColor = result.style.fillColor;
1638
- opacity = result.style.opacity;
1639
- }
1640
-
1641
- const pathData = polygonToPath(polygon.polygon);
1642
- if (pathData) {
1643
- svg += ` <path d="${pathData}" fill="${fillColor}" fill-opacity="${opacity.toFixed(2)}" fill-rule="evenodd" data-entity-id="${polygon.entityId}" data-ifc-type="${escapeXml(polygon.ifcType)}"/>\n`;
1644
- }
1645
- }
1646
- svg += ' </g>\n';
1647
-
1648
- // Render polygon outlines
1649
- svg += ' <g id="polygon-outlines">\n';
1650
- for (const polygon of drawing.cutPolygons) {
1651
- let strokeColor = '#000000';
1652
- let lineWeight = 0.5;
1653
-
1654
- if (overridesEnabled) {
1655
- const elementData: ElementData = {
1656
- expressId: polygon.entityId,
1657
- ifcType: polygon.ifcType,
1658
- };
1659
- const result = overrideEngine.applyOverrides(elementData);
1660
- strokeColor = result.style.strokeColor;
1661
- lineWeight = result.style.lineWeight;
1662
- }
1663
-
1664
- const pathData = polygonToPath(polygon.polygon);
1665
- if (pathData) {
1666
- // lineWeight is in mm on paper
1667
- const svgLineWeight = lineWeight * 0.3; // Scale down for better appearance
1668
- svg += ` <path d="${pathData}" fill="none" stroke="${strokeColor}" stroke-width="${svgLineWeight.toFixed(4)}" data-entity-id="${polygon.entityId}"/>\n`;
1669
- }
1670
- }
1671
- svg += ' </g>\n';
1672
-
1673
- // Render drawing lines
1674
- const lineBounds = drawing.bounds;
1675
- const lineMargin = Math.max(lineBounds.max.x - lineBounds.min.x, lineBounds.max.y - lineBounds.min.y) * 0.5;
1676
- const lineMinX = lineBounds.min.x - lineMargin;
1677
- const lineMaxX = lineBounds.max.x + lineMargin;
1678
- const lineMinY = lineBounds.min.y - lineMargin;
1679
- const lineMaxY = lineBounds.max.y + lineMargin;
1680
-
1681
- svg += ' <g id="drawing-lines">\n';
1682
- for (const line of drawing.lines) {
1683
- if (line.category === 'cut') continue;
1684
- if (!displayOptions.showHiddenLines && line.visibility === 'hidden') continue;
1685
-
1686
- const { start, end } = line.line;
1687
- if (!isFinite(start.x) || !isFinite(start.y) || !isFinite(end.x) || !isFinite(end.y)) continue;
1688
- if (start.x < lineMinX || start.x > lineMaxX || start.y < lineMinY || start.y > lineMaxY ||
1689
- end.x < lineMinX || end.x > lineMaxX || end.y < lineMinY || end.y > lineMaxY) continue;
1690
-
1691
- let strokeColor = '#000000';
1692
- let lineWidth = 0.25;
1693
- let dashArray = '';
1694
-
1695
- switch (line.category) {
1696
- case 'projection': lineWidth = 0.25; break;
1697
- case 'hidden': lineWidth = 0.18; strokeColor = '#666666'; dashArray = '1 0.5'; break;
1698
- case 'silhouette': lineWidth = 0.35; break;
1699
- case 'crease': lineWidth = 0.18; break;
1700
- case 'boundary': lineWidth = 0.25; break;
1701
- case 'annotation': lineWidth = 0.13; break;
1702
- }
218
+ const { generateDrawing, doRegenerate, isRegenerating } = useDrawingGeneration({
219
+ geometryResult, ifcDataStore, sectionPlane, displayOptions,
220
+ combinedHiddenIds, combinedIsolatedIds, computedIsolatedIds,
221
+ models, panelVisible, drawing,
222
+ setDrawing, setDrawingStatus, setDrawingProgress, setDrawingError,
223
+ });
224
+
225
+ const { viewTransform, setViewTransform, zoomIn, zoomOut, fitToView } = useViewControls({
226
+ drawing, sectionPlane, containerRef,
227
+ panelVisible, status, sheetEnabled, activeSheet,
228
+ isPinned, cachedSheetTransformRef,
229
+ });
230
+
231
+ const { handleMouseDown, handleMouseMove, handleMouseUp, handleMouseLeave, handleMouseEnter } = useMeasure2D({
232
+ drawing, viewTransform, setViewTransform, sectionAxis: sectionPlane.axis, containerRef,
233
+ measure2DMode, measure2DStart, measure2DCurrent,
234
+ measure2DShiftLocked, measure2DLockedAxis,
235
+ setMeasure2DStart, setMeasure2DCurrent, setMeasure2DShiftLocked,
236
+ setMeasure2DSnapPoint, cancelMeasure2D, completeMeasure2D,
237
+ });
238
+
239
+ const { formatDistance, handleExportSVG, handlePrint } = useDrawingExport({
240
+ drawing, displayOptions, sectionPlane, activePresetId,
241
+ entityColorMap, overridesEnabled, overrideEngine,
242
+ measure2DResults, sheetEnabled, activeSheet,
243
+ });
1703
244
 
1704
- if (line.visibility === 'hidden') {
1705
- strokeColor = '#888888';
1706
- dashArray = '1 0.5';
1707
- lineWidth *= 0.7;
1708
- }
1709
-
1710
- const paperStart = modelToPaper(start.x, start.y);
1711
- const paperEnd = modelToPaper(end.x, end.y);
1712
-
1713
- // lineWidth is in mm on paper
1714
- const svgLineWidth = lineWidth * 0.3;
1715
- const dashAttr = dashArray ? ` stroke-dasharray="${dashArray}"` : '';
1716
- svg += ` <line x1="${paperStart.x.toFixed(4)}" y1="${paperStart.y.toFixed(4)}" x2="${paperEnd.x.toFixed(4)}" y2="${paperEnd.y.toFixed(4)}" stroke="${strokeColor}" stroke-width="${svgLineWidth.toFixed(4)}"${dashAttr}/>\n`;
1717
- }
1718
- svg += ' </g>\n';
1719
-
1720
- svg += ' </g>\n\n';
1721
-
1722
- // Render frame (on top of drawing content)
1723
- const frameResult = renderFrame(activeSheet.paper, activeSheet.frame);
1724
- svg += frameResult.svgElements;
1725
- svg += '\n';
1726
-
1727
- // Render title block with scale bar and north arrow inside
1728
- // Pass effectiveScaleFactor from the actual transform (not just configured scale)
1729
- // This ensures scale bar shows correct values when dynamically scaled
1730
- const titleBlockExtras: TitleBlockExtras = {
1731
- scaleBar: activeSheet.scaleBar,
1732
- northArrow: activeSheet.northArrow,
1733
- scale: activeSheet.scale,
1734
- effectiveScaleFactor: scaleFactor,
1735
- };
1736
- const titleBlockResult = renderTitleBlock(
1737
- activeSheet.titleBlock,
1738
- frameResult.innerBounds,
1739
- activeSheet.revisions,
1740
- titleBlockExtras
1741
- );
1742
- svg += titleBlockResult.svgElements;
1743
- svg += '\n';
1744
-
1745
- svg += '</svg>';
1746
- return svg;
1747
- }, [drawing, activeSheet, displayOptions, activePresetId, entityColorMap, overridesEnabled, overrideEngine]);
1748
-
1749
- // Export SVG
1750
- const handleExportSVG = useCallback(() => {
1751
- // Use sheet export if enabled, otherwise raw drawing export
1752
- const svg = (sheetEnabled && activeSheet) ? generateSheetSVG() : generateExportSVG();
1753
- if (!svg) return;
1754
- const blob = new Blob([svg], { type: 'image/svg+xml' });
1755
- const url = URL.createObjectURL(blob);
1756
- const a = document.createElement('a');
1757
- a.href = url;
1758
- const filename = (sheetEnabled && activeSheet)
1759
- ? `${activeSheet.name.replace(/\s+/g, '-')}-${sectionPlane.axis}-${sectionPlane.position}.svg`
1760
- : `section-${sectionPlane.axis}-${sectionPlane.position}.svg`;
1761
- a.download = filename;
1762
- a.click();
1763
- URL.revokeObjectURL(url);
1764
- }, [generateExportSVG, generateSheetSVG, sheetEnabled, activeSheet, sectionPlane]);
245
+ // ═══════════════════════════════════════════════════════════════════════════
246
+ // CALLBACKS
247
+ // ═══════════════════════════════════════════════════════════════════════════
1765
248
 
1766
249
  // Close panel
1767
250
  const handleClose = useCallback(() => {
@@ -1788,7 +271,10 @@ export function Section2DPanel({
1788
271
  setIsPinned((prev) => !prev);
1789
272
  }, []);
1790
273
 
1791
- // Resize handlers
274
+ // ═══════════════════════════════════════════════════════════════════════════
275
+ // RESIZE HANDLING
276
+ // ═══════════════════════════════════════════════════════════════════════════
277
+
1792
278
  const handleResizeStart = useCallback((edge: 'right' | 'top' | 'corner') => (e: React.MouseEvent) => {
1793
279
  e.preventDefault();
1794
280
  e.stopPropagation();
@@ -1856,64 +342,9 @@ export function Section2DPanel({
1856
342
  };
1857
343
  }, []);
1858
344
 
1859
- // Print handler
1860
- const handlePrint = useCallback(() => {
1861
- // Use sheet export if enabled, otherwise raw drawing export
1862
- const svg = (sheetEnabled && activeSheet) ? generateSheetSVG() : generateExportSVG();
1863
- if (!svg) return;
1864
-
1865
- // Create a new window for printing
1866
- const printWindow = window.open('', '_blank', 'width=800,height=600');
1867
- if (!printWindow) {
1868
- alert('Please allow popups to print');
1869
- return;
1870
- }
1871
-
1872
- const title = (sheetEnabled && activeSheet)
1873
- ? `${activeSheet.name} - ${sectionPlane.axis} at ${sectionPlane.position}%`
1874
- : `Section Drawing - ${sectionPlane.axis} at ${sectionPlane.position}%`;
1875
-
1876
- // Write print-friendly HTML with the SVG
1877
- printWindow.document.write(`
1878
- <!DOCTYPE html>
1879
- <html>
1880
- <head>
1881
- <title>${title}</title>
1882
- <style>
1883
- @media print {
1884
- @page { margin: ${(sheetEnabled && activeSheet) ? '0' : '1cm'}; }
1885
- body { margin: 0; }
1886
- }
1887
- body {
1888
- display: flex;
1889
- justify-content: center;
1890
- align-items: center;
1891
- min-height: 100vh;
1892
- margin: 0;
1893
- padding: ${(sheetEnabled && activeSheet) ? '0' : '20px'};
1894
- box-sizing: border-box;
1895
- }
1896
- svg {
1897
- max-width: 100%;
1898
- max-height: 100vh;
1899
- width: auto;
1900
- height: auto;
1901
- }
1902
- </style>
1903
- </head>
1904
- <body>
1905
- ${svg}
1906
- <script>
1907
- window.onload = function() {
1908
- window.print();
1909
- window.onafterprint = function() { window.close(); };
1910
- };
1911
- </script>
1912
- </body>
1913
- </html>
1914
- `);
1915
- printWindow.document.close();
1916
- }, [generateExportSVG, generateSheetSVG, sheetEnabled, activeSheet, sectionPlane]);
345
+ // ═══════════════════════════════════════════════════════════════════════════
346
+ // MEMOIZED STYLES
347
+ // ═══════════════════════════════════════════════════════════════════════════
1917
348
 
1918
349
  // Memoize panel style to avoid creating new object on every render
1919
350
  const panelStyle = useMemo(() => {
@@ -1925,6 +356,10 @@ export function Section2DPanel({
1925
356
  // Memoize progress bar style
1926
357
  const progressBarStyle = useMemo(() => ({ width: `${progress}%` }), [progress]);
1927
358
 
359
+ // ═══════════════════════════════════════════════════════════════════════════
360
+ // RENDER
361
+ // ═══════════════════════════════════════════════════════════════════════════
362
+
1928
363
  if (!panelVisible) return null;
1929
364
 
1930
365
  const panelClasses = isExpanded
@@ -2304,1010 +739,3 @@ export function Section2DPanel({
2304
739
  </div>
2305
740
  );
2306
741
  }
2307
-
2308
- // ═══════════════════════════════════════════════════════════════════════════
2309
- // CANVAS RENDERER
2310
- // ═══════════════════════════════════════════════════════════════════════════
2311
-
2312
- // Static style constant to avoid creating new object on every render
2313
- const CANVAS_STYLE = { imageRendering: 'crisp-edges' as const };
2314
-
2315
- interface Measure2DResultData {
2316
- id: string;
2317
- start: { x: number; y: number };
2318
- end: { x: number; y: number };
2319
- distance: number;
2320
- }
2321
-
2322
- interface Drawing2DCanvasProps {
2323
- drawing: Drawing2D;
2324
- transform: { x: number; y: number; scale: number };
2325
- showHiddenLines: boolean;
2326
- overrideEngine: GraphicOverrideEngine;
2327
- overridesEnabled: boolean;
2328
- entityColorMap: Map<number, [number, number, number, number]>;
2329
- useIfcMaterials: boolean;
2330
- // Measure tool props
2331
- measureMode?: boolean;
2332
- measureStart?: { x: number; y: number } | null;
2333
- measureCurrent?: { x: number; y: number } | null;
2334
- measureResults?: Measure2DResultData[];
2335
- measureSnapPoint?: { x: number; y: number } | null;
2336
- // Sheet mode props
2337
- sheetEnabled?: boolean;
2338
- activeSheet?: import('@ifc-lite/drawing-2d').DrawingSheet | null;
2339
- // Section plane info for axis-specific rendering
2340
- sectionAxis: 'down' | 'front' | 'side';
2341
- // Pinned mode - keep model fixed in place on sheet
2342
- isPinned?: boolean;
2343
- cachedSheetTransformRef?: React.MutableRefObject<{ translateX: number; translateY: number; scaleFactor: number } | null>;
2344
- }
2345
-
2346
- function Drawing2DCanvas({
2347
- drawing,
2348
- transform,
2349
- showHiddenLines,
2350
- overrideEngine,
2351
- overridesEnabled,
2352
- entityColorMap,
2353
- useIfcMaterials,
2354
- measureMode = false,
2355
- measureStart = null,
2356
- measureCurrent = null,
2357
- measureResults = [],
2358
- measureSnapPoint = null,
2359
- sheetEnabled = false,
2360
- activeSheet = null,
2361
- sectionAxis,
2362
- isPinned = false,
2363
- cachedSheetTransformRef,
2364
- }: Drawing2DCanvasProps): React.ReactElement {
2365
- const canvasRef = useRef<HTMLCanvasElement>(null);
2366
- const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
2367
-
2368
- // ResizeObserver to track canvas size changes
2369
- useEffect(() => {
2370
- const canvas = canvasRef.current;
2371
- if (!canvas) return;
2372
-
2373
- const resizeObserver = new ResizeObserver((entries) => {
2374
- for (const entry of entries) {
2375
- const { width, height } = entry.contentRect;
2376
- setCanvasSize((prev) => {
2377
- // Only update if size actually changed to avoid render loops
2378
- if (prev.width !== width || prev.height !== height) {
2379
- return { width, height };
2380
- }
2381
- return prev;
2382
- });
2383
- }
2384
- });
2385
-
2386
- resizeObserver.observe(canvas);
2387
- return () => resizeObserver.disconnect();
2388
- }, []);
2389
-
2390
- useEffect(() => {
2391
- const canvas = canvasRef.current;
2392
- if (!canvas || canvasSize.width === 0 || canvasSize.height === 0) return;
2393
-
2394
- const ctx = canvas.getContext('2d');
2395
- if (!ctx) return;
2396
-
2397
- // Set canvas size using tracked dimensions
2398
- const dpr = window.devicePixelRatio || 1;
2399
- canvas.width = canvasSize.width * dpr;
2400
- canvas.height = canvasSize.height * dpr;
2401
- ctx.scale(dpr, dpr);
2402
-
2403
- // Clear with light gray background (shows paper edge when in sheet mode)
2404
- ctx.fillStyle = sheetEnabled && activeSheet ? '#e5e5e5' : '#ffffff';
2405
- ctx.fillRect(0, 0, canvasSize.width, canvasSize.height);
2406
-
2407
- // ═══════════════════════════════════════════════════════════════════════
2408
- // SHEET MODE: Render paper, frame, title block, then drawing in viewport
2409
- // ═══════════════════════════════════════════════════════════════════════
2410
- if (sheetEnabled && activeSheet) {
2411
- const paper = activeSheet.paper;
2412
- const frame = activeSheet.frame;
2413
- const titleBlock = activeSheet.titleBlock;
2414
- const viewport = activeSheet.viewportBounds;
2415
- const scaleBar = activeSheet.scaleBar;
2416
- const northArrow = activeSheet.northArrow;
2417
-
2418
- // Helper: convert sheet mm to screen pixels
2419
- const mmToScreen = (mm: number) => mm * transform.scale;
2420
- const mmToScreenX = (x: number) => x * transform.scale + transform.x;
2421
- const mmToScreenY = (y: number) => y * transform.scale + transform.y;
2422
-
2423
- // ─────────────────────────────────────────────────────────────────────
2424
- // 1. Draw paper background (white with shadow)
2425
- // ─────────────────────────────────────────────────────────────────────
2426
- ctx.save();
2427
- // Paper shadow
2428
- ctx.shadowColor = 'rgba(0, 0, 0, 0.2)';
2429
- ctx.shadowBlur = 10 * (transform.scale > 0.5 ? 1 : transform.scale * 2);
2430
- ctx.shadowOffsetX = 3;
2431
- ctx.shadowOffsetY = 3;
2432
- ctx.fillStyle = '#ffffff';
2433
- ctx.fillRect(
2434
- mmToScreenX(0),
2435
- mmToScreenY(0),
2436
- mmToScreen(paper.widthMm),
2437
- mmToScreen(paper.heightMm)
2438
- );
2439
- ctx.restore();
2440
-
2441
- // Paper border
2442
- ctx.strokeStyle = '#cccccc';
2443
- ctx.lineWidth = 1;
2444
- ctx.strokeRect(
2445
- mmToScreenX(0),
2446
- mmToScreenY(0),
2447
- mmToScreen(paper.widthMm),
2448
- mmToScreen(paper.heightMm)
2449
- );
2450
-
2451
- // ─────────────────────────────────────────────────────────────────────
2452
- // 2. Draw frame borders
2453
- // ─────────────────────────────────────────────────────────────────────
2454
- const frameLeft = frame.margins.left + frame.margins.bindingMargin;
2455
- const frameTop = frame.margins.top;
2456
- const frameRight = paper.widthMm - frame.margins.right;
2457
- const frameBottom = paper.heightMm - frame.margins.bottom;
2458
- const frameWidth = frameRight - frameLeft;
2459
- const frameHeight = frameBottom - frameTop;
2460
-
2461
- // Outer border
2462
- ctx.strokeStyle = '#000000';
2463
- ctx.lineWidth = Math.max(1, mmToScreen(frame.border.outerLineWeight));
2464
- ctx.strokeRect(
2465
- mmToScreenX(frameLeft),
2466
- mmToScreenY(frameTop),
2467
- mmToScreen(frameWidth),
2468
- mmToScreen(frameHeight)
2469
- );
2470
-
2471
- // Inner border (if gap > 0)
2472
- if (frame.border.borderGap > 0) {
2473
- const innerLeft = frameLeft + frame.border.borderGap;
2474
- const innerTop = frameTop + frame.border.borderGap;
2475
- const innerWidth = frameWidth - 2 * frame.border.borderGap;
2476
- const innerHeight = frameHeight - 2 * frame.border.borderGap;
2477
-
2478
- ctx.lineWidth = Math.max(0.5, mmToScreen(frame.border.innerLineWeight));
2479
- ctx.strokeRect(
2480
- mmToScreenX(innerLeft),
2481
- mmToScreenY(innerTop),
2482
- mmToScreen(innerWidth),
2483
- mmToScreen(innerHeight)
2484
- );
2485
- }
2486
-
2487
- // ─────────────────────────────────────────────────────────────────────
2488
- // 3. Draw title block
2489
- // ─────────────────────────────────────────────────────────────────────
2490
- const innerLeft = frameLeft + frame.border.borderGap;
2491
- const innerTop = frameTop + frame.border.borderGap;
2492
- const innerWidth = frameWidth - 2 * frame.border.borderGap;
2493
- const innerHeight = frameHeight - 2 * frame.border.borderGap;
2494
-
2495
- let tbX: number, tbY: number, tbW: number, tbH: number;
2496
- switch (titleBlock.position) {
2497
- case 'bottom-right':
2498
- tbW = titleBlock.widthMm;
2499
- tbH = titleBlock.heightMm;
2500
- tbX = innerLeft + innerWidth - tbW;
2501
- tbY = innerTop + innerHeight - tbH;
2502
- break;
2503
- case 'bottom-full':
2504
- tbW = innerWidth;
2505
- tbH = titleBlock.heightMm;
2506
- tbX = innerLeft;
2507
- tbY = innerTop + innerHeight - tbH;
2508
- break;
2509
- case 'right-strip':
2510
- tbW = titleBlock.widthMm;
2511
- tbH = innerHeight;
2512
- tbX = innerLeft + innerWidth - tbW;
2513
- tbY = innerTop;
2514
- break;
2515
- default:
2516
- tbW = titleBlock.widthMm;
2517
- tbH = titleBlock.heightMm;
2518
- tbX = innerLeft + innerWidth - tbW;
2519
- tbY = innerTop + innerHeight - tbH;
2520
- }
2521
-
2522
- // Title block border
2523
- ctx.strokeStyle = '#000000';
2524
- ctx.lineWidth = Math.max(1, mmToScreen(titleBlock.borderWeight));
2525
- ctx.strokeRect(
2526
- mmToScreenX(tbX),
2527
- mmToScreenY(tbY),
2528
- mmToScreen(tbW),
2529
- mmToScreen(tbH)
2530
- );
2531
-
2532
- // Title block fields - calculate row heights based on font sizes
2533
- const logoSpace = titleBlock.logo ? 50 : 0;
2534
- const revisionSpace = titleBlock.showRevisionHistory ? 20 : 0;
2535
- const availableWidth = tbW - logoSpace - 5;
2536
- const availableHeight = tbH - revisionSpace - 4;
2537
- const numCols = 2;
2538
-
2539
- // Group fields by row
2540
- const fieldsByRow = new Map<number, typeof titleBlock.fields>();
2541
- for (const field of titleBlock.fields) {
2542
- const row = field.row ?? 0;
2543
- if (!fieldsByRow.has(row)) fieldsByRow.set(row, []);
2544
- fieldsByRow.get(row)!.push(field);
2545
- }
2546
-
2547
- // Calculate minimum height needed for each row based on its largest font
2548
- const rowCount = Math.max(...Array.from(fieldsByRow.keys()), 0) + 1;
2549
- const rowHeights: number[] = [];
2550
- let totalMinHeight = 0;
2551
-
2552
- for (let r = 0; r < rowCount; r++) {
2553
- const fields = fieldsByRow.get(r) || [];
2554
- const maxFontSize = fields.length > 0 ? Math.max(...fields.map(f => f.fontSize)) : 3;
2555
- const labelSize = Math.min(maxFontSize * 0.5, 2.2);
2556
- const minRowHeight = labelSize + 1 + maxFontSize + 2;
2557
- rowHeights.push(minRowHeight);
2558
- totalMinHeight += minRowHeight;
2559
- }
2560
-
2561
- // Scale row heights if they exceed available space
2562
- const rowScaleFactor = totalMinHeight > availableHeight ? availableHeight / totalMinHeight : 1;
2563
- const scaledRowHeights = rowHeights.map(h => h * rowScaleFactor);
2564
-
2565
- const colWidth = availableWidth / numCols;
2566
- const gridStartX = tbX + logoSpace + 2;
2567
- const gridStartY = tbY + 2;
2568
-
2569
- // Calculate row Y positions
2570
- const rowYPositions: number[] = [gridStartY];
2571
- for (let i = 0; i < scaledRowHeights.length - 1; i++) {
2572
- rowYPositions.push(rowYPositions[i] + scaledRowHeights[i]);
2573
- }
2574
-
2575
- // Draw grid lines
2576
- ctx.strokeStyle = '#000000';
2577
- ctx.lineWidth = Math.max(0.5, mmToScreen(titleBlock.gridWeight));
2578
-
2579
- // Horizontal lines
2580
- for (let i = 1; i < rowCount; i++) {
2581
- const lineY = rowYPositions[i];
2582
- ctx.beginPath();
2583
- ctx.moveTo(mmToScreenX(gridStartX), mmToScreenY(lineY));
2584
- ctx.lineTo(mmToScreenX(gridStartX + availableWidth - 4), mmToScreenY(lineY));
2585
- ctx.stroke();
2586
- }
2587
-
2588
- // Vertical dividers (for rows with multiple columns)
2589
- for (const [row, fields] of fieldsByRow) {
2590
- const hasMultipleCols = fields.some(f => (f.colSpan ?? 1) < 2);
2591
- if (hasMultipleCols) {
2592
- const centerX = gridStartX + colWidth;
2593
- const lineY1 = rowYPositions[row];
2594
- const lineY2 = rowYPositions[row] + scaledRowHeights[row];
2595
- ctx.beginPath();
2596
- ctx.moveTo(mmToScreenX(centerX), mmToScreenY(lineY1));
2597
- ctx.lineTo(mmToScreenX(centerX), mmToScreenY(lineY2));
2598
- ctx.stroke();
2599
- }
2600
- }
2601
-
2602
- // Render field text - scale proportionally with zoom
2603
- for (const [row, fields] of fieldsByRow) {
2604
- const rowY = rowYPositions[row];
2605
- if (rowY === undefined) continue;
2606
-
2607
- const rowH = scaledRowHeights[row] ?? 5;
2608
- const screenRowH = mmToScreen(rowH);
2609
-
2610
- // Skip if row is too small to be readable
2611
- if (screenRowH < 4) continue;
2612
-
2613
- for (const field of fields) {
2614
- const col = field.col ?? 0;
2615
- const fieldX = gridStartX + col * colWidth + 1.5;
2616
-
2617
- // Calculate font sizes in mm (accounting for compressed rows)
2618
- const effectiveScale = rowScaleFactor < 1 ? rowScaleFactor : 1;
2619
- const labelFontMm = Math.min(field.fontSize * 0.45, 2.2) * Math.max(effectiveScale, 0.7);
2620
- const valueFontMm = field.fontSize * Math.max(effectiveScale, 0.7);
2621
-
2622
- // Convert to screen pixels - scales naturally with zoom
2623
- const screenLabelFont = mmToScreen(labelFontMm);
2624
- const screenValueFont = mmToScreen(valueFontMm);
2625
-
2626
- // Skip if too small to read
2627
- if (screenLabelFont < 3) continue;
2628
-
2629
- const screenRowY = mmToScreenY(rowY);
2630
- const screenFieldX = mmToScreenX(fieldX);
2631
-
2632
- // Label
2633
- ctx.font = `${screenLabelFont}px Arial, sans-serif`;
2634
- ctx.fillStyle = '#666666';
2635
- ctx.textAlign = 'left';
2636
- ctx.textBaseline = 'top';
2637
- ctx.fillText(field.label, screenFieldX, screenRowY + mmToScreen(0.3));
2638
-
2639
- // Value below label (spacing in mm, converted to screen)
2640
- const valueY = screenRowY + mmToScreen(labelFontMm + 0.5);
2641
- ctx.font = `${field.fontWeight === 'bold' ? 'bold ' : ''}${screenValueFont}px Arial, sans-serif`;
2642
- ctx.fillStyle = '#000000';
2643
- ctx.fillText(field.value, screenFieldX, valueY);
2644
- }
2645
- }
2646
-
2647
- // ─────────────────────────────────────────────────────────────────────
2648
- // 4. Clip to viewport and draw model content
2649
- // ─────────────────────────────────────────────────────────────────────
2650
- ctx.save();
2651
-
2652
- // Create clip region for viewport
2653
- ctx.beginPath();
2654
- ctx.rect(
2655
- mmToScreenX(viewport.x),
2656
- mmToScreenY(viewport.y),
2657
- mmToScreen(viewport.width),
2658
- mmToScreen(viewport.height)
2659
- );
2660
- ctx.clip();
2661
-
2662
- // Calculate drawing transform to fit in viewport
2663
- const drawingBounds = {
2664
- minX: drawing.bounds.min.x,
2665
- minY: drawing.bounds.min.y,
2666
- maxX: drawing.bounds.max.x,
2667
- maxY: drawing.bounds.max.y,
2668
- };
2669
-
2670
- // Axis-specific flipping
2671
- const flipY = sectionAxis !== 'down';
2672
- const flipX = sectionAxis === 'side';
2673
-
2674
- // Use cached transform when pinned, otherwise calculate new one
2675
- let drawingTransform: { translateX: number; translateY: number; scaleFactor: number };
2676
-
2677
- if (isPinned && cachedSheetTransformRef?.current) {
2678
- // Use cached transform to keep model fixed in place
2679
- drawingTransform = cachedSheetTransformRef.current;
2680
- } else {
2681
- // Calculate new transform
2682
- const baseTransform = calculateDrawingTransform(drawingBounds, viewport, activeSheet.scale);
2683
-
2684
- // Adjust for axis-specific flipping
2685
- // calculateDrawingTransform assumes Y-flip (uses maxY), but for 'down' view we don't flip Y
2686
- drawingTransform = {
2687
- ...baseTransform,
2688
- translateY: flipY
2689
- ? baseTransform.translateY
2690
- : baseTransform.translateY - (drawingBounds.maxY + drawingBounds.minY) * baseTransform.scaleFactor,
2691
- };
2692
-
2693
- // Cache the transform for pinned mode
2694
- if (cachedSheetTransformRef) {
2695
- cachedSheetTransformRef.current = drawingTransform;
2696
- }
2697
- }
2698
-
2699
- // Apply combined transform: sheet mm -> screen, then drawing coords -> sheet mm
2700
- // Drawing coord (meters) * scaleFactor = sheet mm, + translateX/Y
2701
- // Then sheet mm -> screen via mmToScreenX/Y
2702
- const drawModelContent = () => {
2703
- // Determine flip behavior based on section axis
2704
- // - 'down' (plan view): DON'T flip Y so north (Z+) is up
2705
- // - 'front' and 'side': flip Y so height (Y+) is up
2706
- // - 'side': also flip X to look from conventional direction
2707
-
2708
- // For each polygon/line, transform from model coords to screen coords
2709
- const modelToScreen = (x: number, y: number) => {
2710
- // Apply axis-specific flipping
2711
- const adjustedX = flipX ? -x : x;
2712
- const adjustedY = flipY ? -y : y;
2713
- // Model to sheet mm
2714
- const sheetX = adjustedX * drawingTransform.scaleFactor + drawingTransform.translateX;
2715
- const sheetY = adjustedY * drawingTransform.scaleFactor + drawingTransform.translateY;
2716
- // Sheet mm to screen
2717
- return { x: mmToScreenX(sheetX), y: mmToScreenY(sheetY) };
2718
- };
2719
-
2720
- // Line width in screen pixels (convert mm to screen)
2721
- const mmLineToScreen = (mmWeight: number) => Math.max(0.5, mmToScreen(mmWeight / drawingTransform.scaleFactor * 0.001));
2722
-
2723
- // Fill cut polygons
2724
- for (const polygon of drawing.cutPolygons) {
2725
- let fillColor = getFillColorForType(polygon.ifcType);
2726
- let opacity = 1;
2727
-
2728
- if (useIfcMaterials) {
2729
- const materialColor = entityColorMap.get(polygon.entityId);
2730
- if (materialColor) {
2731
- const r = Math.round(materialColor[0] * 255);
2732
- const g = Math.round(materialColor[1] * 255);
2733
- const b = Math.round(materialColor[2] * 255);
2734
- fillColor = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
2735
- opacity = materialColor[3];
2736
- }
2737
- } else if (overridesEnabled) {
2738
- const elementData: ElementData = {
2739
- expressId: polygon.entityId,
2740
- ifcType: polygon.ifcType,
2741
- };
2742
- const result = overrideEngine.applyOverrides(elementData);
2743
- fillColor = result.style.fillColor;
2744
- opacity = result.style.opacity;
2745
- }
2746
-
2747
- ctx.globalAlpha = opacity;
2748
- ctx.fillStyle = fillColor;
2749
- ctx.beginPath();
2750
-
2751
- if (polygon.polygon.outer.length > 0) {
2752
- const first = modelToScreen(polygon.polygon.outer[0].x, polygon.polygon.outer[0].y);
2753
- ctx.moveTo(first.x, first.y);
2754
- for (let i = 1; i < polygon.polygon.outer.length; i++) {
2755
- const pt = modelToScreen(polygon.polygon.outer[i].x, polygon.polygon.outer[i].y);
2756
- ctx.lineTo(pt.x, pt.y);
2757
- }
2758
- ctx.closePath();
2759
-
2760
- for (const hole of polygon.polygon.holes) {
2761
- if (hole.length > 0) {
2762
- const holeFirst = modelToScreen(hole[0].x, hole[0].y);
2763
- ctx.moveTo(holeFirst.x, holeFirst.y);
2764
- for (let i = 1; i < hole.length; i++) {
2765
- const pt = modelToScreen(hole[i].x, hole[i].y);
2766
- ctx.lineTo(pt.x, pt.y);
2767
- }
2768
- ctx.closePath();
2769
- }
2770
- }
2771
- }
2772
- ctx.fill('evenodd');
2773
- ctx.globalAlpha = 1;
2774
- }
2775
-
2776
- // Stroke polygon outlines
2777
- for (const polygon of drawing.cutPolygons) {
2778
- let strokeColor = '#000000';
2779
- let lineWeight = 0.5;
2780
-
2781
- if (overridesEnabled) {
2782
- const elementData: ElementData = {
2783
- expressId: polygon.entityId,
2784
- ifcType: polygon.ifcType,
2785
- };
2786
- const result = overrideEngine.applyOverrides(elementData);
2787
- strokeColor = result.style.strokeColor;
2788
- lineWeight = result.style.lineWeight;
2789
- }
2790
-
2791
- ctx.strokeStyle = strokeColor;
2792
- ctx.lineWidth = Math.max(0.5, mmToScreen(lineWeight) * 0.3);
2793
- ctx.beginPath();
2794
-
2795
- if (polygon.polygon.outer.length > 0) {
2796
- const first = modelToScreen(polygon.polygon.outer[0].x, polygon.polygon.outer[0].y);
2797
- ctx.moveTo(first.x, first.y);
2798
- for (let i = 1; i < polygon.polygon.outer.length; i++) {
2799
- const pt = modelToScreen(polygon.polygon.outer[i].x, polygon.polygon.outer[i].y);
2800
- ctx.lineTo(pt.x, pt.y);
2801
- }
2802
- ctx.closePath();
2803
-
2804
- for (const hole of polygon.polygon.holes) {
2805
- if (hole.length > 0) {
2806
- const holeFirst = modelToScreen(hole[0].x, hole[0].y);
2807
- ctx.moveTo(holeFirst.x, holeFirst.y);
2808
- for (let i = 1; i < hole.length; i++) {
2809
- const pt = modelToScreen(hole[i].x, hole[i].y);
2810
- ctx.lineTo(pt.x, pt.y);
2811
- }
2812
- ctx.closePath();
2813
- }
2814
- }
2815
- }
2816
- ctx.stroke();
2817
- }
2818
-
2819
- // Draw lines (projection, silhouette, etc.)
2820
- const lineBounds = drawing.bounds;
2821
- const lineMargin = Math.max(lineBounds.max.x - lineBounds.min.x, lineBounds.max.y - lineBounds.min.y) * 0.5;
2822
- const lineMinX = lineBounds.min.x - lineMargin;
2823
- const lineMaxX = lineBounds.max.x + lineMargin;
2824
- const lineMinY = lineBounds.min.y - lineMargin;
2825
- const lineMaxY = lineBounds.max.y + lineMargin;
2826
-
2827
- for (const line of drawing.lines) {
2828
- if (line.category === 'cut') continue;
2829
- if (!showHiddenLines && line.visibility === 'hidden') continue;
2830
-
2831
- const { start, end } = line.line;
2832
- if (!isFinite(start.x) || !isFinite(start.y) || !isFinite(end.x) || !isFinite(end.y)) continue;
2833
- if (start.x < lineMinX || start.x > lineMaxX || start.y < lineMinY || start.y > lineMaxY ||
2834
- end.x < lineMinX || end.x > lineMaxX || end.y < lineMinY || end.y > lineMaxY) continue;
2835
-
2836
- let strokeColor = '#000000';
2837
- let lineWidth = 0.25;
2838
- let dashPattern: number[] = [];
2839
-
2840
- switch (line.category) {
2841
- case 'projection': lineWidth = 0.25; break;
2842
- case 'hidden': lineWidth = 0.18; strokeColor = '#666666'; dashPattern = [4, 2]; break;
2843
- case 'silhouette': lineWidth = 0.35; break;
2844
- case 'crease': lineWidth = 0.18; break;
2845
- case 'boundary': lineWidth = 0.25; break;
2846
- case 'annotation': lineWidth = 0.13; break;
2847
- }
2848
-
2849
- if (line.visibility === 'hidden') {
2850
- strokeColor = '#888888';
2851
- dashPattern = [4, 2];
2852
- lineWidth *= 0.7;
2853
- }
2854
-
2855
- ctx.strokeStyle = strokeColor;
2856
- ctx.lineWidth = Math.max(0.5, mmToScreen(lineWidth) * 0.3);
2857
- ctx.setLineDash(dashPattern);
2858
-
2859
- const screenStart = modelToScreen(start.x, start.y);
2860
- const screenEnd = modelToScreen(end.x, end.y);
2861
-
2862
- ctx.beginPath();
2863
- ctx.moveTo(screenStart.x, screenStart.y);
2864
- ctx.lineTo(screenEnd.x, screenEnd.y);
2865
- ctx.stroke();
2866
- ctx.setLineDash([]);
2867
- }
2868
- };
2869
-
2870
- drawModelContent();
2871
- ctx.restore();
2872
-
2873
- // ─────────────────────────────────────────────────────────────────────
2874
- // 6. Draw scale bar at BOTTOM LEFT of title block
2875
- // Uses actual drawingTransform.scaleFactor which accounts for dynamic scaling
2876
- // ─────────────────────────────────────────────────────────────────────
2877
- if (scaleBar.visible && tbH > 10) {
2878
- // Position: bottom left with small margin
2879
- const sbX = tbX + 3;
2880
- const sbY = tbY + tbH - 8; // 8mm from bottom (leaves room for label)
2881
-
2882
- // Calculate effective scale from the actual drawing transform
2883
- // scaleFactor = mm per meter, so effective scale ratio = 1000 / scaleFactor
2884
- const effectiveScaleFactor = drawingTransform.scaleFactor;
2885
-
2886
- // Scale bar length: we want to show a nice round number of meters
2887
- // Calculate how many mm on paper for the desired real-world length
2888
- const maxBarWidth = Math.min(tbW * 0.3, 50); // Max 30% of width or 50mm
2889
-
2890
- // Find a nice round length that fits
2891
- // Start with the configured length and adjust if needed
2892
- let targetLengthM = scaleBar.totalLengthM;
2893
- let sbLengthMm = targetLengthM * effectiveScaleFactor;
2894
-
2895
- // If bar would be too long, reduce the target length
2896
- while (sbLengthMm > maxBarWidth && targetLengthM > 0.5) {
2897
- targetLengthM = targetLengthM / 2;
2898
- sbLengthMm = targetLengthM * effectiveScaleFactor;
2899
- }
2900
-
2901
- // If bar would be too short, increase the target length
2902
- while (sbLengthMm < maxBarWidth * 0.3 && targetLengthM < 100) {
2903
- targetLengthM = targetLengthM * 2;
2904
- sbLengthMm = targetLengthM * effectiveScaleFactor;
2905
- }
2906
-
2907
- // Clamp to max width
2908
- sbLengthMm = Math.min(sbLengthMm, maxBarWidth);
2909
-
2910
- // Actual length represented by the bar
2911
- const actualTotalLength = sbLengthMm / effectiveScaleFactor;
2912
-
2913
- const sbHeight = Math.min(scaleBar.heightMm, 3);
2914
-
2915
- // Scale bar divisions
2916
- const divisions = scaleBar.primaryDivisions;
2917
- const divWidth = sbLengthMm / divisions;
2918
- for (let i = 0; i < divisions; i++) {
2919
- ctx.fillStyle = i % 2 === 0 ? scaleBar.fillColor : '#ffffff';
2920
- ctx.fillRect(
2921
- mmToScreenX(sbX + i * divWidth),
2922
- mmToScreenY(sbY),
2923
- mmToScreen(divWidth),
2924
- mmToScreen(sbHeight)
2925
- );
2926
- }
2927
-
2928
- // Scale bar border
2929
- ctx.strokeStyle = scaleBar.strokeColor;
2930
- ctx.lineWidth = Math.max(1, mmToScreen(scaleBar.lineWeight));
2931
- ctx.strokeRect(
2932
- mmToScreenX(sbX),
2933
- mmToScreenY(sbY),
2934
- mmToScreen(sbLengthMm),
2935
- mmToScreen(sbHeight)
2936
- );
2937
-
2938
- // Distance labels - only at 0 and end
2939
- const labelFontSize = Math.max(7, mmToScreen(1.8));
2940
- ctx.font = `${labelFontSize}px Arial, sans-serif`;
2941
- ctx.fillStyle = '#000000';
2942
- ctx.textBaseline = 'top';
2943
- const labelScreenY = mmToScreenY(sbY + sbHeight) + 1;
2944
-
2945
- ctx.textAlign = 'left';
2946
- ctx.fillText('0', mmToScreenX(sbX), labelScreenY);
2947
-
2948
- ctx.textAlign = 'right';
2949
- const endLabel = actualTotalLength < 1
2950
- ? `${(actualTotalLength * 100).toFixed(0)}cm`
2951
- : `${actualTotalLength.toFixed(0)}m`;
2952
- ctx.fillText(endLabel, mmToScreenX(sbX + sbLengthMm), labelScreenY);
2953
- }
2954
-
2955
- // ─────────────────────────────────────────────────────────────────────
2956
- // 7. Draw north arrow at BOTTOM RIGHT of title block
2957
- // ─────────────────────────────────────────────────────────────────────
2958
- if (northArrow.style !== 'none' && tbH > 10) {
2959
- // Position: bottom right with margin
2960
- const naSize = Math.min(northArrow.sizeMm, 8, tbH * 0.6);
2961
- const naX = tbX + tbW - naSize - 5; // Right side with margin
2962
- const naY = tbY + tbH - naSize / 2 - 3; // Bottom with margin
2963
-
2964
- ctx.save();
2965
- ctx.translate(mmToScreenX(naX), mmToScreenY(naY));
2966
- ctx.rotate((northArrow.rotation * Math.PI) / 180);
2967
-
2968
- // Draw arrow
2969
- const arrowLen = mmToScreen(naSize);
2970
- ctx.fillStyle = '#000000';
2971
- ctx.beginPath();
2972
- ctx.moveTo(0, -arrowLen / 2);
2973
- ctx.lineTo(-arrowLen / 6, arrowLen / 2);
2974
- ctx.lineTo(0, arrowLen / 3);
2975
- ctx.lineTo(arrowLen / 6, arrowLen / 2);
2976
- ctx.closePath();
2977
- ctx.fill();
2978
-
2979
- // Draw "N" label
2980
- const nFontSize = Math.max(8, mmToScreen(2.5));
2981
- ctx.font = `bold ${nFontSize}px Arial, sans-serif`;
2982
- ctx.textAlign = 'center';
2983
- ctx.textBaseline = 'bottom';
2984
- ctx.fillText('N', 0, -arrowLen / 2 - 1);
2985
-
2986
- ctx.restore();
2987
- }
2988
-
2989
- } else {
2990
- // ═══════════════════════════════════════════════════════════════════════
2991
- // NON-SHEET MODE: Original rendering (drawing coords -> screen)
2992
- // ═══════════════════════════════════════════════════════════════════════
2993
-
2994
- // Apply transform with axis-specific flipping
2995
- // - 'down' (plan view): DON'T flip Y so north (Z+) is up
2996
- // - 'front' and 'side': flip Y so height (Y+) is up
2997
- // - 'side': also flip X to look from conventional direction
2998
- const scaleX = sectionAxis === 'side' ? -transform.scale : transform.scale;
2999
- const scaleY = sectionAxis === 'down' ? transform.scale : -transform.scale;
3000
-
3001
- ctx.save();
3002
- ctx.translate(transform.x, transform.y);
3003
- ctx.scale(scaleX, scaleY);
3004
-
3005
- // ═══════════════════════════════════════════════════════════════════════
3006
- // 1. FILL CUT POLYGONS (with color from IFC materials, override engine, or type fallback)
3007
- // ═══════════════════════════════════════════════════════════════════════
3008
- for (const polygon of drawing.cutPolygons) {
3009
- // Get fill color - priority: IFC materials > override engine > IFC type fallback
3010
- let fillColor = getFillColorForType(polygon.ifcType);
3011
- let strokeColor = '#000000';
3012
- let opacity = 1;
3013
-
3014
- // Use actual IFC material colors from the mesh data
3015
- if (useIfcMaterials) {
3016
- const materialColor = entityColorMap.get(polygon.entityId);
3017
- if (materialColor) {
3018
- // Convert RGBA [0-1] to hex color
3019
- const r = Math.round(materialColor[0] * 255);
3020
- const g = Math.round(materialColor[1] * 255);
3021
- const b = Math.round(materialColor[2] * 255);
3022
- fillColor = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
3023
- opacity = materialColor[3];
3024
- }
3025
- } else if (overridesEnabled) {
3026
- const elementData: ElementData = {
3027
- expressId: polygon.entityId,
3028
- ifcType: polygon.ifcType,
3029
- };
3030
- const result = overrideEngine.applyOverrides(elementData);
3031
- fillColor = result.style.fillColor;
3032
- strokeColor = result.style.strokeColor;
3033
- opacity = result.style.opacity;
3034
- }
3035
-
3036
- ctx.globalAlpha = opacity;
3037
- ctx.fillStyle = fillColor;
3038
- ctx.beginPath();
3039
- if (polygon.polygon.outer.length > 0) {
3040
- ctx.moveTo(polygon.polygon.outer[0].x, polygon.polygon.outer[0].y);
3041
- for (let i = 1; i < polygon.polygon.outer.length; i++) {
3042
- ctx.lineTo(polygon.polygon.outer[i].x, polygon.polygon.outer[i].y);
3043
- }
3044
- ctx.closePath();
3045
-
3046
- // Draw holes (inner boundaries)
3047
- for (const hole of polygon.polygon.holes) {
3048
- if (hole.length > 0) {
3049
- ctx.moveTo(hole[0].x, hole[0].y);
3050
- for (let i = 1; i < hole.length; i++) {
3051
- ctx.lineTo(hole[i].x, hole[i].y);
3052
- }
3053
- ctx.closePath();
3054
- }
3055
- }
3056
- }
3057
- ctx.fill('evenodd');
3058
- ctx.globalAlpha = 1;
3059
- }
3060
-
3061
- // ═══════════════════════════════════════════════════════════════════════
3062
- // 2. STROKE CUT POLYGON OUTLINES (with color from override engine)
3063
- // ═══════════════════════════════════════════════════════════════════════
3064
- for (const polygon of drawing.cutPolygons) {
3065
- let strokeColor = '#000000';
3066
- let lineWeight = 0.5;
3067
-
3068
- if (overridesEnabled) {
3069
- const elementData: ElementData = {
3070
- expressId: polygon.entityId,
3071
- ifcType: polygon.ifcType,
3072
- };
3073
- const result = overrideEngine.applyOverrides(elementData);
3074
- strokeColor = result.style.strokeColor;
3075
- lineWeight = result.style.lineWeight;
3076
- }
3077
-
3078
- ctx.strokeStyle = strokeColor;
3079
- ctx.lineWidth = lineWeight / transform.scale;
3080
- ctx.beginPath();
3081
- if (polygon.polygon.outer.length > 0) {
3082
- ctx.moveTo(polygon.polygon.outer[0].x, polygon.polygon.outer[0].y);
3083
- for (let i = 1; i < polygon.polygon.outer.length; i++) {
3084
- ctx.lineTo(polygon.polygon.outer[i].x, polygon.polygon.outer[i].y);
3085
- }
3086
- ctx.closePath();
3087
-
3088
- // Stroke holes too
3089
- for (const hole of polygon.polygon.holes) {
3090
- if (hole.length > 0) {
3091
- ctx.moveTo(hole[0].x, hole[0].y);
3092
- for (let i = 1; i < hole.length; i++) {
3093
- ctx.lineTo(hole[i].x, hole[i].y);
3094
- }
3095
- ctx.closePath();
3096
- }
3097
- }
3098
- }
3099
- ctx.stroke();
3100
- }
3101
-
3102
- // ═══════════════════════════════════════════════════════════════════════
3103
- // 3. DRAW PROJECTION/SILHOUETTE LINES (skip 'cut' - already in polygons)
3104
- // ═══════════════════════════════════════════════════════════════════════
3105
- // Pre-compute bounds for line validation
3106
- const lineBounds = drawing.bounds;
3107
- const lineMargin = Math.max(lineBounds.max.x - lineBounds.min.x, lineBounds.max.y - lineBounds.min.y) * 0.5;
3108
- const lineMinX = lineBounds.min.x - lineMargin;
3109
- const lineMaxX = lineBounds.max.x + lineMargin;
3110
- const lineMinY = lineBounds.min.y - lineMargin;
3111
- const lineMaxY = lineBounds.max.y + lineMargin;
3112
-
3113
- for (const line of drawing.lines) {
3114
- // Skip 'cut' lines - they're triangulation edges, already handled by polygons
3115
- if (line.category === 'cut') continue;
3116
-
3117
- // Skip hidden lines if not showing
3118
- if (!showHiddenLines && line.visibility === 'hidden') continue;
3119
-
3120
- // Skip lines with invalid coordinates (NaN, Infinity, or far outside bounds)
3121
- const { start, end } = line.line;
3122
- if (!isFinite(start.x) || !isFinite(start.y) || !isFinite(end.x) || !isFinite(end.y)) {
3123
- continue;
3124
- }
3125
- if (start.x < lineMinX || start.x > lineMaxX || start.y < lineMinY || start.y > lineMaxY ||
3126
- end.x < lineMinX || end.x > lineMaxX || end.y < lineMinY || end.y > lineMaxY) {
3127
- continue;
3128
- }
3129
-
3130
- // Set line style based on category
3131
- let strokeColor = '#000000';
3132
- let lineWidth = 0.25;
3133
- let dashPattern: number[] = [];
3134
-
3135
- switch (line.category) {
3136
- case 'projection':
3137
- lineWidth = 0.25;
3138
- strokeColor = '#000000';
3139
- break;
3140
- case 'hidden':
3141
- lineWidth = 0.18;
3142
- strokeColor = '#666666';
3143
- dashPattern = [2, 1];
3144
- break;
3145
- case 'silhouette':
3146
- lineWidth = 0.35;
3147
- strokeColor = '#000000';
3148
- break;
3149
- case 'crease':
3150
- lineWidth = 0.18;
3151
- strokeColor = '#000000';
3152
- break;
3153
- case 'boundary':
3154
- lineWidth = 0.25;
3155
- strokeColor = '#000000';
3156
- break;
3157
- case 'annotation':
3158
- lineWidth = 0.13;
3159
- strokeColor = '#000000';
3160
- break;
3161
- }
3162
-
3163
- // Hidden visibility overrides
3164
- if (line.visibility === 'hidden') {
3165
- strokeColor = '#888888';
3166
- dashPattern = [2, 1];
3167
- lineWidth *= 0.7;
3168
- }
3169
-
3170
- ctx.strokeStyle = strokeColor;
3171
- ctx.lineWidth = lineWidth / transform.scale;
3172
- ctx.setLineDash(dashPattern.map((d) => d / transform.scale));
3173
-
3174
- ctx.beginPath();
3175
- ctx.moveTo(line.line.start.x, line.line.start.y);
3176
- ctx.lineTo(line.line.end.x, line.line.end.y);
3177
- ctx.stroke();
3178
-
3179
- ctx.setLineDash([]);
3180
- }
3181
-
3182
- ctx.restore();
3183
- }
3184
-
3185
- // ═══════════════════════════════════════════════════════════════════════
3186
- // 4. RENDER MEASUREMENTS (in screen space)
3187
- // ═══════════════════════════════════════════════════════════════════════
3188
- const drawMeasureLine = (
3189
- start: { x: number; y: number },
3190
- end: { x: number; y: number },
3191
- distance: number,
3192
- color: string = '#2196F3',
3193
- isActive: boolean = false
3194
- ) => {
3195
- // Convert drawing coords to screen coords with axis-specific transforms
3196
- const measureScaleX = sectionAxis === 'side' ? -transform.scale : transform.scale;
3197
- const measureScaleY = sectionAxis === 'down' ? transform.scale : -transform.scale;
3198
- const screenStart = {
3199
- x: start.x * measureScaleX + transform.x,
3200
- y: start.y * measureScaleY + transform.y,
3201
- };
3202
- const screenEnd = {
3203
- x: end.x * measureScaleX + transform.x,
3204
- y: end.y * measureScaleY + transform.y,
3205
- };
3206
-
3207
- // Draw line
3208
- ctx.strokeStyle = color;
3209
- ctx.lineWidth = isActive ? 2 : 1.5;
3210
- ctx.setLineDash(isActive ? [6, 3] : []);
3211
- ctx.beginPath();
3212
- ctx.moveTo(screenStart.x, screenStart.y);
3213
- ctx.lineTo(screenEnd.x, screenEnd.y);
3214
- ctx.stroke();
3215
- ctx.setLineDash([]);
3216
-
3217
- // Draw endpoints
3218
- ctx.fillStyle = color;
3219
- const endpointRadius = isActive ? 5 : 4;
3220
- ctx.beginPath();
3221
- ctx.arc(screenStart.x, screenStart.y, endpointRadius, 0, Math.PI * 2);
3222
- ctx.fill();
3223
- ctx.beginPath();
3224
- ctx.arc(screenEnd.x, screenEnd.y, endpointRadius, 0, Math.PI * 2);
3225
- ctx.fill();
3226
-
3227
- // Draw distance label
3228
- const midX = (screenStart.x + screenEnd.x) / 2;
3229
- const midY = (screenStart.y + screenEnd.y) / 2;
3230
-
3231
- // Format distance (assuming meters, convert to readable units)
3232
- let labelText: string;
3233
- if (distance < 0.01) {
3234
- labelText = `${(distance * 1000).toFixed(1)} mm`;
3235
- } else if (distance < 1) {
3236
- labelText = `${(distance * 100).toFixed(1)} cm`;
3237
- } else {
3238
- labelText = `${distance.toFixed(3)} m`;
3239
- }
3240
-
3241
- // Background for label
3242
- ctx.font = '12px system-ui, sans-serif';
3243
- const textMetrics = ctx.measureText(labelText);
3244
- const padding = 4;
3245
- const bgWidth = textMetrics.width + padding * 2;
3246
- const bgHeight = 18;
3247
-
3248
- ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
3249
- ctx.fillRect(midX - bgWidth / 2, midY - bgHeight / 2, bgWidth, bgHeight);
3250
- ctx.strokeStyle = color;
3251
- ctx.lineWidth = 1;
3252
- ctx.strokeRect(midX - bgWidth / 2, midY - bgHeight / 2, bgWidth, bgHeight);
3253
-
3254
- // Text
3255
- ctx.fillStyle = '#000000';
3256
- ctx.textAlign = 'center';
3257
- ctx.textBaseline = 'middle';
3258
- ctx.fillText(labelText, midX, midY);
3259
- };
3260
-
3261
- // Draw completed measurements
3262
- for (const result of measureResults) {
3263
- drawMeasureLine(result.start, result.end, result.distance, '#2196F3', false);
3264
- }
3265
-
3266
- // Draw active measurement
3267
- if (measureStart && measureCurrent) {
3268
- const dx = measureCurrent.x - measureStart.x;
3269
- const dy = measureCurrent.y - measureStart.y;
3270
- const distance = Math.sqrt(dx * dx + dy * dy);
3271
- drawMeasureLine(measureStart, measureCurrent, distance, '#FF5722', true);
3272
- }
3273
-
3274
- // Draw snap indicator
3275
- if (measureMode && measureSnapPoint) {
3276
- // Use axis-specific transforms (matching canvas rendering)
3277
- const snapScaleX = sectionAxis === 'side' ? -transform.scale : transform.scale;
3278
- const snapScaleY = sectionAxis === 'down' ? transform.scale : -transform.scale;
3279
- const screenSnap = {
3280
- x: measureSnapPoint.x * snapScaleX + transform.x,
3281
- y: measureSnapPoint.y * snapScaleY + transform.y,
3282
- };
3283
-
3284
- // Draw snap crosshair
3285
- ctx.strokeStyle = '#4CAF50';
3286
- ctx.lineWidth = 1.5;
3287
- const snapSize = 12;
3288
-
3289
- ctx.beginPath();
3290
- ctx.moveTo(screenSnap.x - snapSize, screenSnap.y);
3291
- ctx.lineTo(screenSnap.x + snapSize, screenSnap.y);
3292
- ctx.stroke();
3293
-
3294
- ctx.beginPath();
3295
- ctx.moveTo(screenSnap.x, screenSnap.y - snapSize);
3296
- ctx.lineTo(screenSnap.x, screenSnap.y + snapSize);
3297
- ctx.stroke();
3298
-
3299
- // Draw snap circle
3300
- ctx.beginPath();
3301
- ctx.arc(screenSnap.x, screenSnap.y, 6, 0, Math.PI * 2);
3302
- ctx.stroke();
3303
- }
3304
- }, [drawing, transform, showHiddenLines, canvasSize, overrideEngine, overridesEnabled, entityColorMap, useIfcMaterials, measureMode, measureStart, measureCurrent, measureResults, measureSnapPoint, sheetEnabled, activeSheet, sectionAxis, isPinned]);
3305
-
3306
- return (
3307
- <canvas
3308
- ref={canvasRef}
3309
- className="w-full h-full"
3310
- style={CANVAS_STYLE}
3311
- />
3312
- );
3313
- }