@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.
- package/LICENSE +373 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/Arrow.dom-BjDQoB2M.js +20 -0
- package/dist/assets/arrow2-bb-jcVEo.js +2 -0
- package/dist/assets/arrow2_bg-4Y7xYo54.wasm +0 -0
- package/dist/assets/arrow2_bg-BlXl-cSQ.js +1 -0
- package/dist/assets/arrow2_bg-BoXCojjR.wasm +0 -0
- package/dist/assets/desktop-cache-oPzaWXYE.js +1 -0
- package/dist/assets/event-DIOks52T.js +1 -0
- package/dist/assets/ifc-cache-BAN4vcd4.js +1 -0
- package/dist/assets/ifc-lite_bg-C6kblxf9.wasm +0 -0
- package/dist/assets/index-YBtrHPu3.js +65252 -0
- package/dist/assets/index-v3mcCUPN.css +1 -0
- package/dist/assets/native-bridge-CULtTDX3.js +111 -0
- package/dist/assets/wasm-bridge-CjL-lSak.js +1 -0
- package/dist/favicon-16x16-cropped.png +0 -0
- package/dist/favicon-16x16.png +0 -0
- package/dist/favicon-192x192-cropped.png +0 -0
- package/dist/favicon-192x192.png +0 -0
- package/dist/favicon-32x32-cropped.png +0 -0
- package/dist/favicon-32x32.png +0 -0
- package/dist/favicon-48x48-cropped.png +0 -0
- package/dist/favicon-48x48.png +0 -0
- package/dist/favicon-512x512-cropped.png +0 -0
- package/dist/favicon-512x512.png +0 -0
- package/dist/favicon-64x64-cropped.png +0 -0
- package/dist/favicon-64x64.png +0 -0
- package/dist/favicon-96x96-cropped.png +0 -0
- package/dist/favicon-96x96.png +0 -0
- package/dist/favicon-square-512.png +0 -0
- package/dist/favicon.ico +0 -0
- package/dist/favicon.png +0 -0
- package/dist/favicon.svg +3 -0
- package/dist/index.html +44 -0
- package/dist/logo.png +0 -0
- package/dist/manifest.json +48 -0
- package/index.html +33 -2
- package/package.json +34 -17
- package/public/apple-touch-icon.png +0 -0
- package/public/favicon-16x16-cropped.png +0 -0
- package/public/favicon-16x16.png +0 -0
- package/public/favicon-192x192-cropped.png +0 -0
- package/public/favicon-192x192.png +0 -0
- package/public/favicon-32x32-cropped.png +0 -0
- package/public/favicon-32x32.png +0 -0
- package/public/favicon-48x48-cropped.png +0 -0
- package/public/favicon-48x48.png +0 -0
- package/public/favicon-512x512-cropped.png +0 -0
- package/public/favicon-512x512.png +0 -0
- package/public/favicon-64x64-cropped.png +0 -0
- package/public/favicon-64x64.png +0 -0
- package/public/favicon-96x96-cropped.png +0 -0
- package/public/favicon-96x96.png +0 -0
- package/public/favicon-square-512.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.png +0 -0
- package/public/favicon.svg +3 -0
- package/public/logo.png +0 -0
- package/public/manifest.json +48 -0
- package/src/App.tsx +2 -0
- package/src/components/ui/alert.tsx +62 -0
- package/src/components/ui/badge.tsx +39 -0
- package/src/components/ui/dialog.tsx +120 -0
- package/src/components/ui/label.tsx +27 -0
- package/src/components/ui/select.tsx +151 -0
- package/src/components/ui/switch.tsx +30 -0
- package/src/components/ui/table.tsx +120 -0
- package/src/components/ui/tabs.tsx +1 -1
- package/src/components/viewer/BCFPanel.tsx +1164 -0
- package/src/components/viewer/BulkPropertyEditor.tsx +875 -0
- package/src/components/viewer/DataConnector.tsx +840 -0
- package/src/components/viewer/DrawingSettingsPanel.tsx +536 -0
- package/src/components/viewer/EntityContextMenu.tsx +45 -17
- package/src/components/viewer/ExportChangesButton.tsx +195 -0
- package/src/components/viewer/ExportDialog.tsx +402 -0
- package/src/components/viewer/HierarchyPanel.tsx +1132 -218
- package/src/components/viewer/IDSPanel.tsx +661 -0
- package/src/components/viewer/KeyboardShortcutsDialog.tsx +245 -39
- package/src/components/viewer/MainToolbar.tsx +418 -94
- package/src/components/viewer/PropertiesPanel.tsx +1355 -91
- package/src/components/viewer/PropertyEditor.tsx +611 -0
- package/src/components/viewer/Section2DPanel.tsx +3313 -0
- package/src/components/viewer/SheetSetupPanel.tsx +502 -0
- package/src/components/viewer/StatusBar.tsx +27 -16
- package/src/components/viewer/TitleBlockEditor.tsx +437 -0
- package/src/components/viewer/ToolOverlays.tsx +935 -127
- package/src/components/viewer/ViewerLayout.tsx +40 -11
- package/src/components/viewer/Viewport.tsx +1276 -336
- package/src/components/viewer/ViewportContainer.tsx +554 -18
- package/src/components/viewer/ViewportOverlays.tsx +24 -7
- package/src/hooks/useBCF.ts +504 -0
- package/src/hooks/useIDS.ts +1065 -0
- package/src/hooks/useIfc.ts +1534 -205
- package/src/hooks/useIfcCache.ts +279 -0
- package/src/hooks/useKeyboardShortcuts.ts +50 -8
- package/src/hooks/useModelSelection.ts +61 -0
- package/src/hooks/useViewerSelectors.ts +218 -0
- package/src/hooks/useWebGPU.ts +80 -0
- package/src/index.css +265 -27
- package/src/lib/platform.ts +23 -0
- package/src/services/cacheService.ts +142 -0
- package/src/services/desktop-cache.ts +143 -0
- package/src/services/fs-cache.ts +212 -0
- package/src/services/ifc-cache.ts +14 -6
- package/src/store/constants.ts +85 -0
- package/src/store/index.ts +214 -0
- package/src/store/slices/bcfSlice.ts +372 -0
- package/src/store/slices/cameraSlice.ts +63 -0
- package/src/store/slices/dataSlice.test.ts +226 -0
- package/src/store/slices/dataSlice.ts +112 -0
- package/src/store/slices/drawing2DSlice.ts +340 -0
- package/src/store/slices/hoverSlice.ts +40 -0
- package/src/store/slices/idsSlice.ts +310 -0
- package/src/store/slices/loadingSlice.ts +33 -0
- package/src/store/slices/measurementSlice.test.ts +217 -0
- package/src/store/slices/measurementSlice.ts +293 -0
- package/src/store/slices/modelSlice.test.ts +271 -0
- package/src/store/slices/modelSlice.ts +211 -0
- package/src/store/slices/mutationSlice.ts +502 -0
- package/src/store/slices/sectionSlice.test.ts +125 -0
- package/src/store/slices/sectionSlice.ts +58 -0
- package/src/store/slices/selectionSlice.test.ts +286 -0
- package/src/store/slices/selectionSlice.ts +263 -0
- package/src/store/slices/sheetSlice.ts +565 -0
- package/src/store/slices/uiSlice.ts +58 -0
- package/src/store/slices/visibilitySlice.test.ts +304 -0
- package/src/store/slices/visibilitySlice.ts +277 -0
- package/src/store/types.test.ts +135 -0
- package/src/store/types.ts +248 -0
- package/src/store.ts +40 -515
- package/src/utils/ifcConfig.ts +82 -0
- package/src/utils/localParsingUtils.ts +287 -0
- package/src/utils/serverDataModel.ts +783 -0
- package/src/utils/spatialHierarchy.ts +283 -0
- package/src/utils/viewportUtils.ts +334 -0
- package/src/vite-env.d.ts +23 -0
- package/src/webgpu-types.d.ts +128 -0
- package/src-tauri/Cargo.toml +29 -0
- package/src-tauri/build.rs +7 -0
- package/src-tauri/capabilities/default.json +18 -0
- package/src-tauri/icons/128x128.png +0 -0
- package/src-tauri/icons/128x128@2x.png +0 -0
- package/src-tauri/icons/32x32.png +0 -0
- package/src-tauri/icons/Square107x107Logo.png +0 -0
- package/src-tauri/icons/Square142x142Logo.png +0 -0
- package/src-tauri/icons/Square150x150Logo.png +0 -0
- package/src-tauri/icons/Square284x284Logo.png +0 -0
- package/src-tauri/icons/Square30x30Logo.png +0 -0
- package/src-tauri/icons/Square310x310Logo.png +0 -0
- package/src-tauri/icons/Square44x44Logo.png +0 -0
- package/src-tauri/icons/Square71x71Logo.png +0 -0
- package/src-tauri/icons/Square89x89Logo.png +0 -0
- package/src-tauri/icons/StoreLogo.png +0 -0
- package/src-tauri/icons/icon.icns +0 -0
- package/src-tauri/icons/icon.ico +0 -0
- package/src-tauri/icons/icon.png +0 -0
- package/src-tauri/src/lib.rs +21 -0
- package/src-tauri/src/main.rs +10 -0
- package/src-tauri/tauri.conf.json +39 -0
- package/vite.config.ts +174 -26
- package/public/ifc-lite_bg.wasm +0 -0
- package/public/web-ifc.wasm +0 -0
- package/src/components/Viewport.tsx +0 -723
- 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, '&')
|
|
1286
|
+
.replace(/</g, '<')
|
|
1287
|
+
.replace(/>/g, '>')
|
|
1288
|
+
.replace(/"/g, '"')
|
|
1289
|
+
.replace(/'/g, ''');
|
|
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, '&')
|
|
1584
|
+
.replace(/</g, '<')
|
|
1585
|
+
.replace(/>/g, '>')
|
|
1586
|
+
.replace(/"/g, '"')
|
|
1587
|
+
.replace(/'/g, ''');
|
|
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
|
+
}
|