@ifc-lite/viewer 1.1.7 → 1.6.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 (164) hide show
  1. package/LICENSE +373 -0
  2. package/dist/apple-touch-icon.png +0 -0
  3. package/dist/assets/Arrow.dom-BjDQoB2M.js +20 -0
  4. package/dist/assets/arrow2-bb-jcVEo.js +2 -0
  5. package/dist/assets/arrow2_bg-4Y7xYo54.wasm +0 -0
  6. package/dist/assets/arrow2_bg-BlXl-cSQ.js +1 -0
  7. package/dist/assets/arrow2_bg-BoXCojjR.wasm +0 -0
  8. package/dist/assets/desktop-cache-oPzaWXYE.js +1 -0
  9. package/dist/assets/event-DIOks52T.js +1 -0
  10. package/dist/assets/ifc-cache-BAN4vcd4.js +1 -0
  11. package/dist/assets/ifc-lite_bg-C6kblxf9.wasm +0 -0
  12. package/dist/assets/index-YBtrHPu3.js +65252 -0
  13. package/dist/assets/index-v3mcCUPN.css +1 -0
  14. package/dist/assets/native-bridge-CULtTDX3.js +111 -0
  15. package/dist/assets/wasm-bridge-CjL-lSak.js +1 -0
  16. package/dist/favicon-16x16-cropped.png +0 -0
  17. package/dist/favicon-16x16.png +0 -0
  18. package/dist/favicon-192x192-cropped.png +0 -0
  19. package/dist/favicon-192x192.png +0 -0
  20. package/dist/favicon-32x32-cropped.png +0 -0
  21. package/dist/favicon-32x32.png +0 -0
  22. package/dist/favicon-48x48-cropped.png +0 -0
  23. package/dist/favicon-48x48.png +0 -0
  24. package/dist/favicon-512x512-cropped.png +0 -0
  25. package/dist/favicon-512x512.png +0 -0
  26. package/dist/favicon-64x64-cropped.png +0 -0
  27. package/dist/favicon-64x64.png +0 -0
  28. package/dist/favicon-96x96-cropped.png +0 -0
  29. package/dist/favicon-96x96.png +0 -0
  30. package/dist/favicon-square-512.png +0 -0
  31. package/dist/favicon.ico +0 -0
  32. package/dist/favicon.png +0 -0
  33. package/dist/favicon.svg +3 -0
  34. package/dist/index.html +44 -0
  35. package/dist/logo.png +0 -0
  36. package/dist/manifest.json +48 -0
  37. package/index.html +33 -2
  38. package/package.json +34 -17
  39. package/public/apple-touch-icon.png +0 -0
  40. package/public/favicon-16x16-cropped.png +0 -0
  41. package/public/favicon-16x16.png +0 -0
  42. package/public/favicon-192x192-cropped.png +0 -0
  43. package/public/favicon-192x192.png +0 -0
  44. package/public/favicon-32x32-cropped.png +0 -0
  45. package/public/favicon-32x32.png +0 -0
  46. package/public/favicon-48x48-cropped.png +0 -0
  47. package/public/favicon-48x48.png +0 -0
  48. package/public/favicon-512x512-cropped.png +0 -0
  49. package/public/favicon-512x512.png +0 -0
  50. package/public/favicon-64x64-cropped.png +0 -0
  51. package/public/favicon-64x64.png +0 -0
  52. package/public/favicon-96x96-cropped.png +0 -0
  53. package/public/favicon-96x96.png +0 -0
  54. package/public/favicon-square-512.png +0 -0
  55. package/public/favicon.ico +0 -0
  56. package/public/favicon.png +0 -0
  57. package/public/favicon.svg +3 -0
  58. package/public/logo.png +0 -0
  59. package/public/manifest.json +48 -0
  60. package/src/App.tsx +2 -0
  61. package/src/components/ui/alert.tsx +62 -0
  62. package/src/components/ui/badge.tsx +39 -0
  63. package/src/components/ui/dialog.tsx +120 -0
  64. package/src/components/ui/label.tsx +27 -0
  65. package/src/components/ui/select.tsx +151 -0
  66. package/src/components/ui/switch.tsx +30 -0
  67. package/src/components/ui/table.tsx +120 -0
  68. package/src/components/ui/tabs.tsx +1 -1
  69. package/src/components/viewer/BCFPanel.tsx +1164 -0
  70. package/src/components/viewer/BulkPropertyEditor.tsx +875 -0
  71. package/src/components/viewer/DataConnector.tsx +840 -0
  72. package/src/components/viewer/DrawingSettingsPanel.tsx +536 -0
  73. package/src/components/viewer/EntityContextMenu.tsx +45 -17
  74. package/src/components/viewer/ExportChangesButton.tsx +195 -0
  75. package/src/components/viewer/ExportDialog.tsx +402 -0
  76. package/src/components/viewer/HierarchyPanel.tsx +1132 -218
  77. package/src/components/viewer/IDSPanel.tsx +661 -0
  78. package/src/components/viewer/KeyboardShortcutsDialog.tsx +245 -39
  79. package/src/components/viewer/MainToolbar.tsx +418 -94
  80. package/src/components/viewer/PropertiesPanel.tsx +1355 -91
  81. package/src/components/viewer/PropertyEditor.tsx +611 -0
  82. package/src/components/viewer/Section2DPanel.tsx +3313 -0
  83. package/src/components/viewer/SheetSetupPanel.tsx +502 -0
  84. package/src/components/viewer/StatusBar.tsx +27 -16
  85. package/src/components/viewer/TitleBlockEditor.tsx +437 -0
  86. package/src/components/viewer/ToolOverlays.tsx +935 -127
  87. package/src/components/viewer/ViewerLayout.tsx +40 -11
  88. package/src/components/viewer/Viewport.tsx +1276 -336
  89. package/src/components/viewer/ViewportContainer.tsx +554 -18
  90. package/src/components/viewer/ViewportOverlays.tsx +24 -7
  91. package/src/hooks/useBCF.ts +504 -0
  92. package/src/hooks/useIDS.ts +1065 -0
  93. package/src/hooks/useIfc.ts +1534 -205
  94. package/src/hooks/useIfcCache.ts +279 -0
  95. package/src/hooks/useKeyboardShortcuts.ts +50 -8
  96. package/src/hooks/useModelSelection.ts +61 -0
  97. package/src/hooks/useViewerSelectors.ts +218 -0
  98. package/src/hooks/useWebGPU.ts +80 -0
  99. package/src/index.css +265 -27
  100. package/src/lib/platform.ts +23 -0
  101. package/src/services/cacheService.ts +142 -0
  102. package/src/services/desktop-cache.ts +143 -0
  103. package/src/services/fs-cache.ts +212 -0
  104. package/src/services/ifc-cache.ts +14 -6
  105. package/src/store/constants.ts +85 -0
  106. package/src/store/index.ts +214 -0
  107. package/src/store/slices/bcfSlice.ts +372 -0
  108. package/src/store/slices/cameraSlice.ts +63 -0
  109. package/src/store/slices/dataSlice.test.ts +226 -0
  110. package/src/store/slices/dataSlice.ts +112 -0
  111. package/src/store/slices/drawing2DSlice.ts +340 -0
  112. package/src/store/slices/hoverSlice.ts +40 -0
  113. package/src/store/slices/idsSlice.ts +310 -0
  114. package/src/store/slices/loadingSlice.ts +33 -0
  115. package/src/store/slices/measurementSlice.test.ts +217 -0
  116. package/src/store/slices/measurementSlice.ts +293 -0
  117. package/src/store/slices/modelSlice.test.ts +271 -0
  118. package/src/store/slices/modelSlice.ts +211 -0
  119. package/src/store/slices/mutationSlice.ts +502 -0
  120. package/src/store/slices/sectionSlice.test.ts +125 -0
  121. package/src/store/slices/sectionSlice.ts +58 -0
  122. package/src/store/slices/selectionSlice.test.ts +286 -0
  123. package/src/store/slices/selectionSlice.ts +263 -0
  124. package/src/store/slices/sheetSlice.ts +565 -0
  125. package/src/store/slices/uiSlice.ts +58 -0
  126. package/src/store/slices/visibilitySlice.test.ts +304 -0
  127. package/src/store/slices/visibilitySlice.ts +277 -0
  128. package/src/store/types.test.ts +135 -0
  129. package/src/store/types.ts +248 -0
  130. package/src/store.ts +40 -515
  131. package/src/utils/ifcConfig.ts +82 -0
  132. package/src/utils/localParsingUtils.ts +287 -0
  133. package/src/utils/serverDataModel.ts +783 -0
  134. package/src/utils/spatialHierarchy.ts +283 -0
  135. package/src/utils/viewportUtils.ts +334 -0
  136. package/src/vite-env.d.ts +23 -0
  137. package/src/webgpu-types.d.ts +128 -0
  138. package/src-tauri/Cargo.toml +29 -0
  139. package/src-tauri/build.rs +7 -0
  140. package/src-tauri/capabilities/default.json +18 -0
  141. package/src-tauri/icons/128x128.png +0 -0
  142. package/src-tauri/icons/128x128@2x.png +0 -0
  143. package/src-tauri/icons/32x32.png +0 -0
  144. package/src-tauri/icons/Square107x107Logo.png +0 -0
  145. package/src-tauri/icons/Square142x142Logo.png +0 -0
  146. package/src-tauri/icons/Square150x150Logo.png +0 -0
  147. package/src-tauri/icons/Square284x284Logo.png +0 -0
  148. package/src-tauri/icons/Square30x30Logo.png +0 -0
  149. package/src-tauri/icons/Square310x310Logo.png +0 -0
  150. package/src-tauri/icons/Square44x44Logo.png +0 -0
  151. package/src-tauri/icons/Square71x71Logo.png +0 -0
  152. package/src-tauri/icons/Square89x89Logo.png +0 -0
  153. package/src-tauri/icons/StoreLogo.png +0 -0
  154. package/src-tauri/icons/icon.icns +0 -0
  155. package/src-tauri/icons/icon.ico +0 -0
  156. package/src-tauri/icons/icon.png +0 -0
  157. package/src-tauri/src/lib.rs +21 -0
  158. package/src-tauri/src/main.rs +10 -0
  159. package/src-tauri/tauri.conf.json +39 -0
  160. package/vite.config.ts +174 -26
  161. package/public/ifc-lite_bg.wasm +0 -0
  162. package/public/web-ifc.wasm +0 -0
  163. package/src/components/Viewport.tsx +0 -723
  164. package/src/components/viewer/BoxSelectionOverlay.tsx +0 -53
@@ -0,0 +1,3313 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * Section2DPanel - 2D architectural drawing viewer panel
7
+ *
8
+ * Displays generated 2D drawings (floor plans, sections) with:
9
+ * - Canvas-based rendering with pan/zoom
10
+ * - Toggle controls for hidden lines
11
+ * - Export to SVG functionality
12
+ */
13
+
14
+ import React, { useCallback, useRef, useState, useEffect, useMemo } from 'react';
15
+ import { X, Download, Eye, EyeOff, Maximize2, ZoomIn, ZoomOut, Loader2, Printer, GripVertical, MoreHorizontal, RefreshCw, Pin, PinOff, Palette, Ruler, Trash2, FileText, Shapes, Box } from 'lucide-react';
16
+ import { Button } from '@/components/ui/button';
17
+ import {
18
+ DropdownMenu,
19
+ DropdownMenuContent,
20
+ DropdownMenuItem,
21
+ DropdownMenuSeparator,
22
+ DropdownMenuTrigger,
23
+ } from '@/components/ui/dropdown-menu';
24
+ import { useViewerStore } from '@/store';
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';
40
+ import { DrawingSettingsPanel } from './DrawingSettingsPanel';
41
+ import { SheetSetupPanel } from './SheetSetupPanel';
42
+ 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
+ }
89
+
90
+ interface Section2DPanelProps {
91
+ mergedGeometry?: GeometryResult | null;
92
+ computedIsolatedIds?: Set<number> | null;
93
+ modelIdToIndex?: Map<string, number>;
94
+ }
95
+
96
+ export function Section2DPanel({
97
+ mergedGeometry,
98
+ computedIsolatedIds,
99
+ modelIdToIndex
100
+ }: Section2DPanelProps = {}): React.ReactElement | null {
101
+ const panelVisible = useViewerStore((s) => s.drawing2DPanelVisible);
102
+ const setDrawingPanelVisible = useViewerStore((s) => s.setDrawing2DPanelVisible);
103
+ const drawing = useViewerStore((s) => s.drawing2D);
104
+ const setDrawing = useViewerStore((s) => s.setDrawing2D);
105
+ const status = useViewerStore((s) => s.drawing2DStatus);
106
+ const setDrawingStatus = useViewerStore((s) => s.setDrawing2DStatus);
107
+ const progress = useViewerStore((s) => s.drawing2DProgress);
108
+ const progressPhase = useViewerStore((s) => s.drawing2DPhase);
109
+ const setDrawingProgress = useViewerStore((s) => s.setDrawing2DProgress);
110
+ const drawingError = useViewerStore((s) => s.drawing2DError);
111
+ const setDrawingError = useViewerStore((s) => s.setDrawing2DError);
112
+ const displayOptions = useViewerStore((s) => s.drawing2DDisplayOptions);
113
+ const updateDisplayOptions = useViewerStore((s) => s.updateDrawing2DDisplayOptions);
114
+ // Graphic overrides
115
+ const graphicOverridePresets = useViewerStore((s) => s.graphicOverridePresets);
116
+ const activePresetId = useViewerStore((s) => s.activePresetId);
117
+ const setActivePreset = useViewerStore((s) => s.setActivePreset);
118
+ const overridesEnabled = useViewerStore((s) => s.overridesEnabled);
119
+ const toggleOverridesEnabled = useViewerStore((s) => s.toggleOverridesEnabled);
120
+ const getActiveOverrideRules = useViewerStore((s) => s.getActiveOverrideRules);
121
+ const customOverrideRules = useViewerStore((s) => s.customOverrideRules);
122
+
123
+ // Settings panel visibility
124
+ const [settingsPanelOpen, setSettingsPanelOpen] = useState(false);
125
+
126
+ // Sheet state
127
+ const activeSheet = useViewerStore((s) => s.activeSheet);
128
+ const sheetEnabled = useViewerStore((s) => s.sheetEnabled);
129
+ const sheetPanelVisible = useViewerStore((s) => s.sheetPanelVisible);
130
+ const setSheetPanelVisible = useViewerStore((s) => s.setSheetPanelVisible);
131
+ const titleBlockEditorVisible = useViewerStore((s) => s.titleBlockEditorVisible);
132
+ const setTitleBlockEditorVisible = useViewerStore((s) => s.setTitleBlockEditorVisible);
133
+
134
+ // 2D Measure tool state
135
+ const measure2DMode = useViewerStore((s) => s.measure2DMode);
136
+ const toggleMeasure2DMode = useViewerStore((s) => s.toggleMeasure2DMode);
137
+ const measure2DStart = useViewerStore((s) => s.measure2DStart);
138
+ const measure2DCurrent = useViewerStore((s) => s.measure2DCurrent);
139
+ const setMeasure2DStart = useViewerStore((s) => s.setMeasure2DStart);
140
+ const setMeasure2DCurrent = useViewerStore((s) => s.setMeasure2DCurrent);
141
+ const setMeasure2DShiftLocked = useViewerStore((s) => s.setMeasure2DShiftLocked);
142
+ const measure2DShiftLocked = useViewerStore((s) => s.measure2DShiftLocked);
143
+ const measure2DLockedAxis = useViewerStore((s) => s.measure2DLockedAxis);
144
+ const measure2DResults = useViewerStore((s) => s.measure2DResults);
145
+ const completeMeasure2D = useViewerStore((s) => s.completeMeasure2D);
146
+ const cancelMeasure2D = useViewerStore((s) => s.cancelMeasure2D);
147
+ const clearMeasure2DResults = useViewerStore((s) => s.clearMeasure2DResults);
148
+ const measure2DSnapPoint = useViewerStore((s) => s.measure2DSnapPoint);
149
+ const setMeasure2DSnapPoint = useViewerStore((s) => s.setMeasure2DSnapPoint);
150
+
151
+ const sectionPlane = useViewerStore((s) => s.sectionPlane);
152
+ const activeTool = useViewerStore((s) => s.activeTool);
153
+ const models = useViewerStore((s) => s.models);
154
+ const { geometryResult: legacyGeometryResult, ifcDataStore } = useIfc();
155
+
156
+ // Use merged geometry from props if available (multi-model), otherwise fall back to legacy single-model
157
+ const geometryResult = mergedGeometry ?? legacyGeometryResult;
158
+
159
+ // Auto-show panel when section tool is active
160
+ const prevActiveToolRef = useRef(activeTool);
161
+ useEffect(() => {
162
+ // Section tool was just activated
163
+ if (activeTool === 'section' && prevActiveToolRef.current !== 'section' && geometryResult?.meshes) {
164
+ setDrawingPanelVisible(true);
165
+ }
166
+ prevActiveToolRef.current = activeTool;
167
+ }, [activeTool, geometryResult, setDrawingPanelVisible]);
168
+
169
+ // Local state for pan/zoom and expanded mode
170
+ const [viewTransform, setViewTransform] = useState({ x: 0, y: 0, scale: 1 });
171
+ const [isExpanded, setIsExpanded] = useState(false);
172
+ const [panelSize, setPanelSize] = useState({ width: 400, height: 300 });
173
+ const [isNarrow, setIsNarrow] = useState(false); // Track if panel is too narrow for all buttons
174
+ 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
+ const containerRef = useRef<HTMLDivElement>(null);
177
+ const panelRef = useRef<HTMLDivElement>(null);
178
+ const isPanning = useRef(false);
179
+ const lastPanPoint = useRef({ x: 0, y: 0 });
180
+ const isResizing = useRef<'right' | 'top' | 'corner' | null>(null);
181
+ 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
+ // Track resize event handlers for cleanup
186
+ const resizeHandlersRef = useRef<{ move: ((e: MouseEvent) => void) | null; up: (() => void) | null }>({ move: null, up: null });
187
+ // Cache sheet drawing transform when pinned (to keep model fixed in place)
188
+ const cachedSheetTransformRef = useRef<{ translateX: number; translateY: number; scaleFactor: number } | null>(null);
189
+
190
+ // Track panel width for responsive header
191
+ useEffect(() => {
192
+ setIsNarrow(panelSize.width < 480);
193
+ }, [panelSize.width]);
194
+
195
+ // Create graphic override engine with active rules
196
+ const overrideEngine = useMemo(() => {
197
+ const rules = getActiveOverrideRules();
198
+ return new GraphicOverrideEngine(rules);
199
+ }, [getActiveOverrideRules, activePresetId, customOverrideRules, overridesEnabled]);
200
+
201
+ // Build entity color map from mesh material colors (for "Use IFC Materials" mode)
202
+ const entityColorMap = useMemo(() => {
203
+ const map = new Map<number, [number, number, number, number]>();
204
+ if (geometryResult?.meshes) {
205
+ for (const mesh of geometryResult.meshes) {
206
+ if (mesh.expressId && mesh.color) {
207
+ map.set(mesh.expressId, mesh.color);
208
+ }
209
+ }
210
+ }
211
+ return map;
212
+ }, [geometryResult]);
213
+
214
+ // Get visibility state from store for filtering
215
+ const hiddenEntities = useViewerStore((s) => s.hiddenEntities);
216
+ const isolatedEntities = useViewerStore((s) => s.isolatedEntities);
217
+ const hiddenEntitiesByModel = useViewerStore((s) => s.hiddenEntitiesByModel);
218
+ const isolatedEntitiesByModel = useViewerStore((s) => s.isolatedEntitiesByModel);
219
+
220
+ // Build combined Set of global IDs from multi-model visibility state
221
+ // This converts per-model local expressIds to global IDs using idOffset
222
+ const combinedHiddenIds = useMemo(() => {
223
+ const globalHiddenIds = new Set<number>(hiddenEntities); // Start with legacy hidden IDs
224
+
225
+ // Add hidden entities from each model (convert local expressId to global ID)
226
+ for (const [modelId, localHiddenIds] of hiddenEntitiesByModel) {
227
+ const model = models.get(modelId);
228
+ if (model && model.idOffset !== undefined) {
229
+ for (const localId of localHiddenIds) {
230
+ globalHiddenIds.add(localId + model.idOffset);
231
+ }
232
+ }
233
+ }
234
+
235
+ return globalHiddenIds;
236
+ }, [hiddenEntities, hiddenEntitiesByModel, models]);
237
+
238
+ // Build combined Set of global IDs for isolation
239
+ const combinedIsolatedIds = useMemo(() => {
240
+ // If legacy isolation is active, use that (already contains global IDs)
241
+ if (isolatedEntities !== null) {
242
+ return isolatedEntities;
243
+ }
244
+
245
+ // Build from multi-model isolation
246
+ const globalIsolatedIds = new Set<number>();
247
+ for (const [modelId, localIsolatedIds] of isolatedEntitiesByModel) {
248
+ const model = models.get(modelId);
249
+ if (model && model.idOffset !== undefined) {
250
+ for (const localId of localIsolatedIds) {
251
+ globalIsolatedIds.add(localId + model.idOffset);
252
+ }
253
+ }
254
+ }
255
+
256
+ return globalIsolatedIds.size > 0 ? globalIsolatedIds : null;
257
+ }, [isolatedEntities, isolatedEntitiesByModel, models]);
258
+
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
+ // ═══════════════════════════════════════════════════════════════════════════
807
+ // 2D MEASURE TOOL HELPER FUNCTIONS
808
+ // ═══════════════════════════════════════════════════════════════════════════
809
+
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
+ }
1703
+
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]);
1765
+
1766
+ // Close panel
1767
+ const handleClose = useCallback(() => {
1768
+ setDrawingPanelVisible(false);
1769
+ }, [setDrawingPanelVisible]);
1770
+
1771
+ // Toggle options
1772
+ const toggle3DOverlay = useCallback(() => {
1773
+ updateDisplayOptions({ show3DOverlay: !displayOptions.show3DOverlay });
1774
+ }, [displayOptions.show3DOverlay, updateDisplayOptions]);
1775
+
1776
+ const toggleSymbolicRepresentations = useCallback(() => {
1777
+ updateDisplayOptions({ useSymbolicRepresentations: !displayOptions.useSymbolicRepresentations });
1778
+ // Clear current drawing to trigger regeneration with new mode
1779
+ setDrawing(null);
1780
+ setDrawingStatus('idle');
1781
+ }, [displayOptions.useSymbolicRepresentations, updateDisplayOptions, setDrawing, setDrawingStatus]);
1782
+
1783
+ const toggleExpanded = useCallback(() => {
1784
+ setIsExpanded((prev) => !prev);
1785
+ }, []);
1786
+
1787
+ const togglePinned = useCallback(() => {
1788
+ setIsPinned((prev) => !prev);
1789
+ }, []);
1790
+
1791
+ // Resize handlers
1792
+ const handleResizeStart = useCallback((edge: 'right' | 'top' | 'corner') => (e: React.MouseEvent) => {
1793
+ e.preventDefault();
1794
+ e.stopPropagation();
1795
+ isResizing.current = edge;
1796
+ resizeStartPos.current = {
1797
+ x: e.clientX,
1798
+ y: e.clientY,
1799
+ width: panelSize.width,
1800
+ height: panelSize.height,
1801
+ };
1802
+
1803
+ // Remove any existing listeners first
1804
+ if (resizeHandlersRef.current.move) {
1805
+ window.removeEventListener('mousemove', resizeHandlersRef.current.move);
1806
+ }
1807
+ if (resizeHandlersRef.current.up) {
1808
+ window.removeEventListener('mouseup', resizeHandlersRef.current.up);
1809
+ }
1810
+
1811
+ const handleMouseMove = (e: MouseEvent) => {
1812
+ if (!isResizing.current) return;
1813
+
1814
+ const dx = e.clientX - resizeStartPos.current.x;
1815
+ const dy = e.clientY - resizeStartPos.current.y;
1816
+
1817
+ setPanelSize((prev) => {
1818
+ let newWidth = prev.width;
1819
+ let newHeight = prev.height;
1820
+
1821
+ if (isResizing.current === 'right' || isResizing.current === 'corner') {
1822
+ newWidth = Math.max(300, Math.min(1200, resizeStartPos.current.width + dx));
1823
+ }
1824
+ // Top resize: dragging up (negative dy) increases height
1825
+ if (isResizing.current === 'top' || isResizing.current === 'corner') {
1826
+ newHeight = Math.max(200, Math.min(800, resizeStartPos.current.height - dy));
1827
+ }
1828
+
1829
+ return { width: newWidth, height: newHeight };
1830
+ });
1831
+ };
1832
+
1833
+ const handleMouseUp = () => {
1834
+ isResizing.current = null;
1835
+ window.removeEventListener('mousemove', handleMouseMove);
1836
+ window.removeEventListener('mouseup', handleMouseUp);
1837
+ resizeHandlersRef.current = { move: null, up: null };
1838
+ };
1839
+
1840
+ // Store refs for cleanup
1841
+ resizeHandlersRef.current = { move: handleMouseMove, up: handleMouseUp };
1842
+
1843
+ window.addEventListener('mousemove', handleMouseMove);
1844
+ window.addEventListener('mouseup', handleMouseUp);
1845
+ }, [panelSize]);
1846
+
1847
+ // Cleanup resize listeners on unmount
1848
+ useEffect(() => {
1849
+ return () => {
1850
+ if (resizeHandlersRef.current.move) {
1851
+ window.removeEventListener('mousemove', resizeHandlersRef.current.move);
1852
+ }
1853
+ if (resizeHandlersRef.current.up) {
1854
+ window.removeEventListener('mouseup', resizeHandlersRef.current.up);
1855
+ }
1856
+ };
1857
+ }, []);
1858
+
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]);
1917
+
1918
+ // Memoize panel style to avoid creating new object on every render
1919
+ const panelStyle = useMemo(() => {
1920
+ return isExpanded
1921
+ ? {} // Expanded uses CSS classes for full sizing
1922
+ : { width: panelSize.width, height: panelSize.height };
1923
+ }, [isExpanded, panelSize.width, panelSize.height]);
1924
+
1925
+ // Memoize progress bar style
1926
+ const progressBarStyle = useMemo(() => ({ width: `${progress}%` }), [progress]);
1927
+
1928
+ if (!panelVisible) return null;
1929
+
1930
+ const panelClasses = isExpanded
1931
+ ? 'absolute inset-4 z-40'
1932
+ : 'absolute bottom-4 left-4 z-40';
1933
+
1934
+ return (
1935
+ <div
1936
+ ref={panelRef}
1937
+ className={`${panelClasses} bg-background rounded-lg border shadow-xl flex flex-col overflow-hidden`}
1938
+ style={panelStyle}
1939
+ >
1940
+ {/* Header */}
1941
+ <div className="flex items-center justify-between px-3 py-1.5 border-b bg-muted/50 rounded-t-lg min-w-0">
1942
+ <h2 className="font-semibold text-xs shrink-0">2D Section</h2>
1943
+
1944
+ <div className="flex items-center gap-1 min-w-0">
1945
+ {/* When panel is wide enough, show all buttons */}
1946
+ {!isNarrow && (
1947
+ <>
1948
+ {/* Display toggles */}
1949
+ <Button
1950
+ variant={displayOptions.show3DOverlay ? 'default' : 'ghost'}
1951
+ size="icon-sm"
1952
+ onClick={toggle3DOverlay}
1953
+ title="Toggle 3D overlay"
1954
+ >
1955
+ {displayOptions.show3DOverlay ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
1956
+ </Button>
1957
+
1958
+ {/* Symbolic vs Section Cut toggle */}
1959
+ <Button
1960
+ variant={displayOptions.useSymbolicRepresentations ? 'default' : 'ghost'}
1961
+ size="icon-sm"
1962
+ onClick={toggleSymbolicRepresentations}
1963
+ title={displayOptions.useSymbolicRepresentations ? 'Symbolic representations (Plan)' : 'Section cut (Body)'}
1964
+ >
1965
+ {displayOptions.useSymbolicRepresentations ? <Shapes className="h-4 w-4" /> : <Box className="h-4 w-4" />}
1966
+ </Button>
1967
+
1968
+ {/* 2D Measure Tool */}
1969
+ <Button
1970
+ variant={measure2DMode ? 'default' : 'ghost'}
1971
+ size="icon-sm"
1972
+ onClick={toggleMeasure2DMode}
1973
+ title={measure2DMode ? 'Exit measure mode' : 'Measure distance'}
1974
+ >
1975
+ <Ruler className="h-4 w-4" />
1976
+ </Button>
1977
+ {measure2DResults.length > 0 && (
1978
+ <Button
1979
+ variant="ghost"
1980
+ size="icon-sm"
1981
+ onClick={clearMeasure2DResults}
1982
+ title="Clear measurements"
1983
+ >
1984
+ <Trash2 className="h-4 w-4" />
1985
+ </Button>
1986
+ )}
1987
+
1988
+ {/* Graphic Override Settings */}
1989
+ <Button
1990
+ variant={settingsPanelOpen || activePresetId ? 'default' : 'ghost'}
1991
+ size="icon-sm"
1992
+ onClick={() => setSettingsPanelOpen((prev) => !prev)}
1993
+ title="Drawing settings"
1994
+ className="relative"
1995
+ >
1996
+ <Palette className="h-4 w-4" />
1997
+ {activePresetId && !settingsPanelOpen && (
1998
+ <span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-primary rounded-full" />
1999
+ )}
2000
+ </Button>
2001
+
2002
+ {/* Drawing Sheet Setup */}
2003
+ <Button
2004
+ variant={sheetPanelVisible || sheetEnabled ? 'default' : 'ghost'}
2005
+ size="icon-sm"
2006
+ onClick={() => setSheetPanelVisible(!sheetPanelVisible)}
2007
+ title="Drawing sheet setup"
2008
+ className="relative"
2009
+ >
2010
+ <FileText className="h-4 w-4" />
2011
+ {sheetEnabled && !sheetPanelVisible && (
2012
+ <span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-primary rounded-full" />
2013
+ )}
2014
+ </Button>
2015
+
2016
+ <div className="w-px h-4 bg-border mx-1" />
2017
+
2018
+ {/* Zoom controls */}
2019
+ <Button variant="ghost" size="icon-sm" onClick={zoomOut} title="Zoom out">
2020
+ <ZoomOut className="h-4 w-4" />
2021
+ </Button>
2022
+ <span className="text-xs font-mono w-10 text-center">
2023
+ {Math.round(viewTransform.scale * 100)}%
2024
+ </span>
2025
+ <Button variant="ghost" size="icon-sm" onClick={zoomIn} title="Zoom in">
2026
+ <ZoomIn className="h-4 w-4" />
2027
+ </Button>
2028
+ <Button variant="ghost" size="icon-sm" onClick={fitToView} title="Fit to view">
2029
+ <Maximize2 className="h-4 w-4" />
2030
+ </Button>
2031
+ <Button
2032
+ variant={isPinned ? 'default' : 'ghost'}
2033
+ size="icon-sm"
2034
+ onClick={togglePinned}
2035
+ title={isPinned ? 'Unpin view (auto-fit on regenerate)' : 'Pin view (keep position on regenerate)'}
2036
+ >
2037
+ {isPinned ? <Pin className="h-4 w-4" /> : <PinOff className="h-4 w-4" />}
2038
+ </Button>
2039
+
2040
+ <div className="w-px h-4 bg-border mx-1" />
2041
+
2042
+ {/* Export/Print */}
2043
+ <Button
2044
+ variant="ghost"
2045
+ size="icon-sm"
2046
+ onClick={handleExportSVG}
2047
+ disabled={!drawing}
2048
+ title="Download SVG"
2049
+ >
2050
+ <Download className="h-4 w-4" />
2051
+ </Button>
2052
+ <Button
2053
+ variant="ghost"
2054
+ size="icon-sm"
2055
+ onClick={handlePrint}
2056
+ disabled={!drawing}
2057
+ title="Print"
2058
+ >
2059
+ <Printer className="h-4 w-4" />
2060
+ </Button>
2061
+
2062
+ <div className="w-px h-4 bg-border mx-1" />
2063
+
2064
+ {/* Regenerate */}
2065
+ <Button
2066
+ variant="ghost"
2067
+ size="icon-sm"
2068
+ onClick={() => generateDrawing(false)}
2069
+ disabled={status === 'generating'}
2070
+ title="Regenerate"
2071
+ >
2072
+ {status === 'generating' ? (
2073
+ <Loader2 className="h-4 w-4 animate-spin" />
2074
+ ) : (
2075
+ <RefreshCw className="h-4 w-4" />
2076
+ )}
2077
+ </Button>
2078
+ </>
2079
+ )}
2080
+
2081
+ {/* When narrow, show minimal controls + dropdown menu */}
2082
+ {isNarrow && (
2083
+ <>
2084
+ {/* Essential zoom controls */}
2085
+ <Button variant="ghost" size="icon-sm" onClick={fitToView} title="Fit to view">
2086
+ <Maximize2 className="h-4 w-4" />
2087
+ </Button>
2088
+
2089
+ {/* Overflow menu */}
2090
+ <DropdownMenu>
2091
+ <DropdownMenuTrigger asChild>
2092
+ <Button variant="ghost" size="icon-sm" title="More options">
2093
+ <MoreHorizontal className="h-4 w-4" />
2094
+ </Button>
2095
+ </DropdownMenuTrigger>
2096
+ <DropdownMenuContent align="end" className="w-56">
2097
+ <DropdownMenuItem onClick={toggle3DOverlay}>
2098
+ {displayOptions.show3DOverlay ? <Eye className="h-4 w-4 mr-2" /> : <EyeOff className="h-4 w-4 mr-2" />}
2099
+ 3D Overlay {displayOptions.show3DOverlay ? 'On' : 'Off'}
2100
+ </DropdownMenuItem>
2101
+ <DropdownMenuItem onClick={toggleSymbolicRepresentations}>
2102
+ {displayOptions.useSymbolicRepresentations ? <Shapes className="h-4 w-4 mr-2" /> : <Box className="h-4 w-4 mr-2" />}
2103
+ {displayOptions.useSymbolicRepresentations ? 'Symbolic (Plan)' : 'Section Cut (Body)'}
2104
+ </DropdownMenuItem>
2105
+ <DropdownMenuItem onClick={toggleMeasure2DMode}>
2106
+ <Ruler className="h-4 w-4 mr-2" />
2107
+ Measure {measure2DMode ? 'On' : 'Off'}
2108
+ </DropdownMenuItem>
2109
+ {measure2DResults.length > 0 && (
2110
+ <DropdownMenuItem onClick={clearMeasure2DResults}>
2111
+ <Trash2 className="h-4 w-4 mr-2" />
2112
+ Clear Measurements
2113
+ </DropdownMenuItem>
2114
+ )}
2115
+ <DropdownMenuSeparator />
2116
+ <DropdownMenuItem onClick={() => setSettingsPanelOpen(true)}>
2117
+ <Palette className="h-4 w-4 mr-2" />
2118
+ Drawing Settings...
2119
+ </DropdownMenuItem>
2120
+ <DropdownMenuItem onClick={() => setSheetPanelVisible(true)}>
2121
+ <FileText className="h-4 w-4 mr-2" />
2122
+ Sheet Setup {sheetEnabled ? '(On)' : ''}
2123
+ </DropdownMenuItem>
2124
+ <DropdownMenuSeparator />
2125
+ <DropdownMenuItem onClick={zoomIn}>
2126
+ <ZoomIn className="h-4 w-4 mr-2" />
2127
+ Zoom In
2128
+ </DropdownMenuItem>
2129
+ <DropdownMenuItem onClick={zoomOut}>
2130
+ <ZoomOut className="h-4 w-4 mr-2" />
2131
+ Zoom Out
2132
+ </DropdownMenuItem>
2133
+ <DropdownMenuItem onClick={togglePinned}>
2134
+ {isPinned ? <Pin className="h-4 w-4 mr-2" /> : <PinOff className="h-4 w-4 mr-2" />}
2135
+ Pin View {isPinned ? 'On' : 'Off'}
2136
+ </DropdownMenuItem>
2137
+ <DropdownMenuSeparator />
2138
+ <DropdownMenuItem onClick={handleExportSVG} disabled={!drawing}>
2139
+ <Download className="h-4 w-4 mr-2" />
2140
+ Download SVG
2141
+ </DropdownMenuItem>
2142
+ <DropdownMenuItem onClick={handlePrint} disabled={!drawing}>
2143
+ <Printer className="h-4 w-4 mr-2" />
2144
+ Print
2145
+ </DropdownMenuItem>
2146
+ <DropdownMenuSeparator />
2147
+ <DropdownMenuItem onClick={() => generateDrawing(false)} disabled={status === 'generating'}>
2148
+ {status === 'generating' ? (
2149
+ <Loader2 className="h-4 w-4 mr-2 animate-spin" />
2150
+ ) : (
2151
+ <RefreshCw className="h-4 w-4 mr-2" />
2152
+ )}
2153
+ Regenerate
2154
+ </DropdownMenuItem>
2155
+ </DropdownMenuContent>
2156
+ </DropdownMenu>
2157
+ </>
2158
+ )}
2159
+
2160
+ {/* Close button always visible */}
2161
+ <Button variant="ghost" size="icon-sm" onClick={handleClose} title="Close">
2162
+ <X className="h-4 w-4" />
2163
+ </Button>
2164
+ </div>
2165
+ </div>
2166
+
2167
+ {/* Drawing Canvas */}
2168
+ <div
2169
+ ref={containerRef}
2170
+ className={`relative flex-1 overflow-hidden bg-white dark:bg-zinc-950 rounded-b-lg ${measure2DMode ? 'cursor-crosshair' : 'cursor-grab active:cursor-grabbing'
2171
+ }`}
2172
+ onMouseDown={handleMouseDown}
2173
+ onMouseMove={handleMouseMove}
2174
+ onMouseUp={handleMouseUp}
2175
+ onMouseEnter={handleMouseEnter}
2176
+ onMouseLeave={handleMouseLeave}
2177
+ >
2178
+ {status === 'generating' && (
2179
+ <div className="absolute inset-0 flex flex-col items-center justify-center bg-background/80">
2180
+ <Loader2 className="h-8 w-8 animate-spin mb-4 text-primary" />
2181
+ <div className="text-sm font-medium">{progressPhase}</div>
2182
+ <div className="w-48 h-2 bg-muted rounded-full mt-2 overflow-hidden">
2183
+ <div
2184
+ className="h-full bg-primary transition-all duration-200"
2185
+ style={progressBarStyle}
2186
+ />
2187
+ </div>
2188
+ <div className="text-xs text-muted-foreground mt-1">{Math.round(progress)}%</div>
2189
+ </div>
2190
+ )}
2191
+
2192
+ {status === 'error' && (
2193
+ <div className="absolute inset-0 flex items-center justify-center">
2194
+ <div className="text-destructive text-center">
2195
+ <p className="font-medium">Generation failed</p>
2196
+ <p className="text-sm text-muted-foreground">
2197
+ {drawingError}
2198
+ </p>
2199
+ <Button variant="outline" size="sm" className="mt-4" onClick={() => generateDrawing(false)}>
2200
+ Retry
2201
+ </Button>
2202
+ </div>
2203
+ </div>
2204
+ )}
2205
+
2206
+ {status === 'ready' && drawing && (drawing.cutPolygons.length > 0 || drawing.lines?.length > 0) && (
2207
+ <>
2208
+ <Drawing2DCanvas
2209
+ drawing={drawing}
2210
+ transform={viewTransform}
2211
+ showHiddenLines={displayOptions.showHiddenLines}
2212
+ overrideEngine={overrideEngine}
2213
+ overridesEnabled={overridesEnabled}
2214
+ entityColorMap={entityColorMap}
2215
+ useIfcMaterials={activePresetId === 'preset-3d-colors'}
2216
+ measureMode={measure2DMode}
2217
+ measureStart={measure2DStart}
2218
+ measureCurrent={measure2DCurrent}
2219
+ measureResults={measure2DResults}
2220
+ measureSnapPoint={measure2DSnapPoint}
2221
+ sheetEnabled={sheetEnabled}
2222
+ activeSheet={activeSheet}
2223
+ sectionAxis={sectionPlane.axis}
2224
+ isPinned={isPinned}
2225
+ cachedSheetTransformRef={cachedSheetTransformRef}
2226
+ />
2227
+ {/* Subtle updating indicator - shows while regenerating without hiding the drawing */}
2228
+ {isRegenerating && (
2229
+ <div className="absolute top-2 right-2 flex items-center gap-1.5 bg-background/80 backdrop-blur-sm px-2 py-1 rounded text-xs text-muted-foreground">
2230
+ <Loader2 className="h-3 w-3 animate-spin" />
2231
+ <span>Updating...</span>
2232
+ </div>
2233
+ )}
2234
+ </>
2235
+ )}
2236
+
2237
+ {/* Measure mode tip - bottom right */}
2238
+ {measure2DMode && measure2DStart && (
2239
+ <div className="absolute bottom-2 right-2 pointer-events-none z-10">
2240
+ <div className="flex items-center gap-1.5 text-[10px] text-black">
2241
+ <kbd className={`px-1 py-0.5 text-[9px] font-mono font-semibold ${measure2DShiftLocked ? 'text-primary' : 'text-black'}`}>Shift</kbd>
2242
+ <span className="text-black">perpendicular</span>
2243
+ </div>
2244
+ </div>
2245
+ )}
2246
+
2247
+ {status === 'ready' && drawing && drawing.cutPolygons.length === 0 && (!drawing.lines || drawing.lines.length === 0) && (
2248
+ <div className="absolute inset-0 flex items-center justify-center">
2249
+ <div className="text-center text-muted-foreground">
2250
+ <p className="font-medium">No geometry at this level</p>
2251
+ <p className="text-sm mt-1">Move the section plane to cut through geometry</p>
2252
+ </div>
2253
+ </div>
2254
+ )}
2255
+
2256
+ {/* Empty state - just show blank canvas, no message */}
2257
+ </div>
2258
+
2259
+ {/* Resize handles - only show when not expanded */}
2260
+ {!isExpanded && (
2261
+ <>
2262
+ {/* Right edge */}
2263
+ <div
2264
+ className="absolute top-0 right-0 w-2 h-full cursor-ew-resize hover:bg-primary/20 transition-colors"
2265
+ onMouseDown={handleResizeStart('right')}
2266
+ />
2267
+ {/* Top edge */}
2268
+ <div
2269
+ className="absolute top-0 left-0 w-full h-2 cursor-ns-resize hover:bg-primary/20 transition-colors"
2270
+ onMouseDown={handleResizeStart('top')}
2271
+ />
2272
+ {/* Top-right corner */}
2273
+ <div
2274
+ className="absolute top-0 right-0 w-4 h-4 cursor-nesw-resize flex items-center justify-center hover:bg-primary/20 transition-colors"
2275
+ onMouseDown={handleResizeStart('corner')}
2276
+ >
2277
+ <GripVertical className="h-3 w-3 text-muted-foreground rotate-[45deg]" />
2278
+ </div>
2279
+ </>
2280
+ )}
2281
+
2282
+ {/* Settings Panel - slides in from right */}
2283
+ {settingsPanelOpen && (
2284
+ <div className="absolute top-0 right-0 bottom-0 w-72 z-50 shadow-xl">
2285
+ <DrawingSettingsPanel onClose={() => setSettingsPanelOpen(false)} />
2286
+ </div>
2287
+ )}
2288
+
2289
+ {/* Sheet Setup Panel - slides in from right */}
2290
+ {sheetPanelVisible && (
2291
+ <div className="absolute top-0 right-0 bottom-0 w-72 z-50 shadow-xl">
2292
+ <SheetSetupPanel
2293
+ onClose={() => setSheetPanelVisible(false)}
2294
+ onOpenTitleBlockEditor={() => setTitleBlockEditorVisible(true)}
2295
+ />
2296
+ </div>
2297
+ )}
2298
+
2299
+ {/* Title Block Editor Modal */}
2300
+ <TitleBlockEditor
2301
+ open={titleBlockEditorVisible}
2302
+ onOpenChange={setTitleBlockEditorVisible}
2303
+ />
2304
+ </div>
2305
+ );
2306
+ }
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
+ }