@ifc-lite/viewer 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +78 -0
- package/dist/assets/{Arrow.dom-BjDQoB2M.js → Arrow.dom-BGPQieQQ.js} +1 -1
- package/dist/assets/ifc-lite_bg-DyIN_nBM.wasm +0 -0
- package/dist/assets/{index-YBtrHPu3.js → index-dgdgiQ9p.js} +40212 -30008
- package/dist/assets/index-yTqs8kgX.css +1 -0
- package/dist/assets/{native-bridge-CULtTDX3.js → native-bridge-DD0SNyQ5.js} +1 -1
- package/dist/assets/{wasm-bridge-CjL-lSak.js → wasm-bridge-D54YMO7X.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +18 -15
- package/src/components/viewer/BCFPanel.tsx +7 -789
- package/src/components/viewer/Drawing2DCanvas.tsx +1048 -0
- package/src/components/viewer/DrawingSettingsPanel.tsx +3 -3
- package/src/components/viewer/HierarchyPanel.tsx +110 -842
- package/src/components/viewer/IDSExportDialog.tsx +281 -0
- package/src/components/viewer/IDSPanel.tsx +126 -17
- package/src/components/viewer/KeyboardShortcutsDialog.tsx +9 -0
- package/src/components/viewer/LensPanel.tsx +603 -0
- package/src/components/viewer/MainToolbar.tsx +188 -21
- package/src/components/viewer/PropertiesPanel.tsx +171 -663
- package/src/components/viewer/PropertyEditor.tsx +866 -77
- package/src/components/viewer/Section2DPanel.tsx +76 -2648
- package/src/components/viewer/ToolOverlays.tsx +3 -1097
- package/src/components/viewer/ViewerLayout.tsx +132 -45
- package/src/components/viewer/Viewport.tsx +237 -1659
- package/src/components/viewer/ViewportContainer.tsx +11 -3
- package/src/components/viewer/bcf/BCFCreateTopicForm.tsx +134 -0
- package/src/components/viewer/bcf/BCFTopicDetail.tsx +388 -0
- package/src/components/viewer/bcf/BCFTopicList.tsx +239 -0
- package/src/components/viewer/bcf/bcfHelpers.tsx +109 -0
- package/src/components/viewer/hierarchy/HierarchyNode.tsx +328 -0
- package/src/components/viewer/hierarchy/treeDataBuilder.ts +464 -0
- package/src/components/viewer/hierarchy/types.ts +54 -0
- package/src/components/viewer/hierarchy/useHierarchyTree.ts +280 -0
- package/src/components/viewer/lists/ListBuilder.tsx +486 -0
- package/src/components/viewer/lists/ListPanel.tsx +540 -0
- package/src/components/viewer/lists/ListResultsTable.tsx +193 -0
- package/src/components/viewer/properties/ClassificationCard.tsx +70 -0
- package/src/components/viewer/properties/CoordinateDisplay.tsx +49 -0
- package/src/components/viewer/properties/DocumentCard.tsx +89 -0
- package/src/components/viewer/properties/MaterialCard.tsx +201 -0
- package/src/components/viewer/properties/ModelMetadataPanel.tsx +335 -0
- package/src/components/viewer/properties/PropertySetCard.tsx +132 -0
- package/src/components/viewer/properties/QuantitySetCard.tsx +79 -0
- package/src/components/viewer/properties/RelationshipsCard.tsx +100 -0
- package/src/components/viewer/properties/encodingUtils.ts +29 -0
- package/src/components/viewer/tools/MeasurePanel.tsx +218 -0
- package/src/components/viewer/tools/MeasurementVisuals.tsx +644 -0
- package/src/components/viewer/tools/SectionPanel.tsx +183 -0
- package/src/components/viewer/tools/SectionVisualization.tsx +78 -0
- package/src/components/viewer/tools/formatDistance.ts +18 -0
- package/src/components/viewer/tools/sectionConstants.ts +14 -0
- package/src/components/viewer/useAnimationLoop.ts +166 -0
- package/src/components/viewer/useGeometryStreaming.ts +398 -0
- package/src/components/viewer/useKeyboardControls.ts +221 -0
- package/src/components/viewer/useMouseControls.ts +1009 -0
- package/src/components/viewer/useRenderUpdates.ts +165 -0
- package/src/components/viewer/useTouchControls.ts +245 -0
- package/src/hooks/ids/idsColorSystem.ts +125 -0
- package/src/hooks/ids/idsDataAccessor.ts +237 -0
- package/src/hooks/ids/idsExportService.ts +444 -0
- package/src/hooks/useBCF.ts +7 -0
- package/src/hooks/useDrawingExport.ts +627 -0
- package/src/hooks/useDrawingGeneration.ts +627 -0
- package/src/hooks/useFloorplanView.ts +108 -0
- package/src/hooks/useIDS.ts +270 -463
- package/src/hooks/useIfc.ts +26 -1628
- package/src/hooks/useIfcFederation.ts +803 -0
- package/src/hooks/useIfcLoader.ts +508 -0
- package/src/hooks/useIfcServer.ts +465 -0
- package/src/hooks/useKeyboardShortcuts.ts +1 -1
- package/src/hooks/useLens.ts +129 -0
- package/src/hooks/useMeasure2D.ts +365 -0
- package/src/hooks/useViewControls.ts +218 -0
- package/src/lib/ifc4-pset-definitions.test.ts +161 -0
- package/src/lib/ifc4-pset-definitions.ts +621 -0
- package/src/lib/ifc4-qto-definitions.ts +315 -0
- package/src/lib/lens/adapter.ts +138 -0
- package/src/lib/lens/index.ts +5 -0
- package/src/lib/lists/adapter.ts +69 -0
- package/src/lib/lists/index.ts +28 -0
- package/src/lib/lists/persistence.ts +64 -0
- package/src/services/fs-cache.ts +1 -1
- package/src/services/tauri-modules.d.ts +25 -0
- package/src/store/index.ts +38 -2
- package/src/store/slices/cameraSlice.ts +14 -1
- package/src/store/slices/dataSlice.ts +14 -1
- package/src/store/slices/lensSlice.ts +184 -0
- package/src/store/slices/listSlice.ts +74 -0
- package/src/store/slices/pinboardSlice.ts +114 -0
- package/src/store/types.ts +5 -0
- package/src/utils/ifcConfig.ts +16 -3
- package/src/utils/serverDataModel.ts +64 -101
- package/src/vite-env.d.ts +3 -0
- package/dist/assets/ifc-lite_bg-C6kblxf9.wasm +0 -0
- package/dist/assets/index-v3mcCUPN.css +0 -1
|
@@ -23,69 +23,16 @@ import {
|
|
|
23
23
|
} from '@/components/ui/dropdown-menu';
|
|
24
24
|
import { useViewerStore } from '@/store';
|
|
25
25
|
import { useIfc } from '@/hooks/useIfc';
|
|
26
|
-
import {
|
|
27
|
-
|
|
28
|
-
createSectionConfig,
|
|
29
|
-
GraphicOverrideEngine,
|
|
30
|
-
renderFrame,
|
|
31
|
-
renderTitleBlock,
|
|
32
|
-
calculateDrawingTransform,
|
|
33
|
-
type Drawing2D,
|
|
34
|
-
type DrawingLine,
|
|
35
|
-
type SectionConfig,
|
|
36
|
-
type ElementData,
|
|
37
|
-
type TitleBlockExtras,
|
|
38
|
-
} from '@ifc-lite/drawing-2d';
|
|
39
|
-
import { GeometryProcessor, type GeometryResult } from '@ifc-lite/geometry';
|
|
26
|
+
import { GraphicOverrideEngine } from '@ifc-lite/drawing-2d';
|
|
27
|
+
import { type GeometryResult } from '@ifc-lite/geometry';
|
|
40
28
|
import { DrawingSettingsPanel } from './DrawingSettingsPanel';
|
|
41
29
|
import { SheetSetupPanel } from './SheetSetupPanel';
|
|
42
30
|
import { TitleBlockEditor } from './TitleBlockEditor';
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
side: 'x',
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
// Fill colors for IFC types (architectural convention)
|
|
52
|
-
const IFC_TYPE_FILL_COLORS: Record<string, string> = {
|
|
53
|
-
// Structural elements - solid gray
|
|
54
|
-
IfcWall: '#b0b0b0',
|
|
55
|
-
IfcWallStandardCase: '#b0b0b0',
|
|
56
|
-
IfcColumn: '#909090',
|
|
57
|
-
IfcBeam: '#909090',
|
|
58
|
-
IfcSlab: '#c8c8c8',
|
|
59
|
-
IfcRoof: '#d0d0d0',
|
|
60
|
-
IfcFooting: '#808080',
|
|
61
|
-
IfcPile: '#707070',
|
|
62
|
-
|
|
63
|
-
// Windows/Doors - lighter
|
|
64
|
-
IfcWindow: '#e8f4fc',
|
|
65
|
-
IfcDoor: '#f5e6d3',
|
|
66
|
-
|
|
67
|
-
// Stairs/Railings
|
|
68
|
-
IfcStair: '#d8d8d8',
|
|
69
|
-
IfcStairFlight: '#d8d8d8',
|
|
70
|
-
IfcRailing: '#c0c0c0',
|
|
71
|
-
|
|
72
|
-
// MEP - distinct colors
|
|
73
|
-
IfcPipeSegment: '#a0d0ff',
|
|
74
|
-
IfcDuctSegment: '#c0ffc0',
|
|
75
|
-
|
|
76
|
-
// Furniture
|
|
77
|
-
IfcFurnishingElement: '#ffe0c0',
|
|
78
|
-
|
|
79
|
-
// Spaces (usually not shown in section)
|
|
80
|
-
IfcSpace: '#f0f0f0',
|
|
81
|
-
|
|
82
|
-
// Default
|
|
83
|
-
default: '#d0d0d0',
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
function getFillColorForType(ifcType: string): string {
|
|
87
|
-
return IFC_TYPE_FILL_COLORS[ifcType] || IFC_TYPE_FILL_COLORS.default;
|
|
88
|
-
}
|
|
31
|
+
import { Drawing2DCanvas } from './Drawing2DCanvas';
|
|
32
|
+
import { useDrawingGeneration } from '@/hooks/useDrawingGeneration';
|
|
33
|
+
import { useMeasure2D } from '@/hooks/useMeasure2D';
|
|
34
|
+
import { useViewControls } from '@/hooks/useViewControls';
|
|
35
|
+
import { useDrawingExport } from '@/hooks/useDrawingExport';
|
|
89
36
|
|
|
90
37
|
interface Section2DPanelProps {
|
|
91
38
|
mergedGeometry?: GeometryResult | null;
|
|
@@ -93,11 +40,14 @@ interface Section2DPanelProps {
|
|
|
93
40
|
modelIdToIndex?: Map<string, number>;
|
|
94
41
|
}
|
|
95
42
|
|
|
96
|
-
export function Section2DPanel({
|
|
97
|
-
mergedGeometry,
|
|
98
|
-
computedIsolatedIds,
|
|
99
|
-
modelIdToIndex
|
|
43
|
+
export function Section2DPanel({
|
|
44
|
+
mergedGeometry,
|
|
45
|
+
computedIsolatedIds,
|
|
46
|
+
modelIdToIndex
|
|
100
47
|
}: Section2DPanelProps = {}): React.ReactElement | null {
|
|
48
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
49
|
+
// STORE SELECTORS
|
|
50
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
101
51
|
const panelVisible = useViewerStore((s) => s.drawing2DPanelVisible);
|
|
102
52
|
const setDrawingPanelVisible = useViewerStore((s) => s.setDrawing2DPanelVisible);
|
|
103
53
|
const drawing = useViewerStore((s) => s.drawing2D);
|
|
@@ -152,11 +102,13 @@ export function Section2DPanel({
|
|
|
152
102
|
const activeTool = useViewerStore((s) => s.activeTool);
|
|
153
103
|
const models = useViewerStore((s) => s.models);
|
|
154
104
|
const { geometryResult: legacyGeometryResult, ifcDataStore } = useIfc();
|
|
155
|
-
|
|
105
|
+
|
|
156
106
|
// Use merged geometry from props if available (multi-model), otherwise fall back to legacy single-model
|
|
157
107
|
const geometryResult = mergedGeometry ?? legacyGeometryResult;
|
|
158
108
|
|
|
159
|
-
//
|
|
109
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
110
|
+
// AUTO-SHOW PANEL EFFECT
|
|
111
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
160
112
|
const prevActiveToolRef = useRef(activeTool);
|
|
161
113
|
useEffect(() => {
|
|
162
114
|
// Section tool was just activated
|
|
@@ -166,22 +118,17 @@ export function Section2DPanel({
|
|
|
166
118
|
prevActiveToolRef.current = activeTool;
|
|
167
119
|
}, [activeTool, geometryResult, setDrawingPanelVisible]);
|
|
168
120
|
|
|
169
|
-
//
|
|
170
|
-
|
|
121
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
122
|
+
// LOCAL STATE
|
|
123
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
171
124
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
172
125
|
const [panelSize, setPanelSize] = useState({ width: 400, height: 300 });
|
|
173
126
|
const [isNarrow, setIsNarrow] = useState(false); // Track if panel is too narrow for all buttons
|
|
174
127
|
const [isPinned, setIsPinned] = useState(true); // Default ON: keep position on regenerate
|
|
175
|
-
const [needsFit, setNeedsFit] = useState(true); // Force fit on first open and axis change
|
|
176
128
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
177
129
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
178
|
-
const isPanning = useRef(false);
|
|
179
|
-
const lastPanPoint = useRef({ x: 0, y: 0 });
|
|
180
130
|
const isResizing = useRef<'right' | 'top' | 'corner' | null>(null);
|
|
181
131
|
const resizeStartPos = useRef({ x: 0, y: 0, width: 0, height: 0 });
|
|
182
|
-
const prevAxisRef = useRef(sectionPlane.axis); // Track axis changes
|
|
183
|
-
const isMouseButtonDown = useRef(false); // Track if mouse button is currently pressed
|
|
184
|
-
const isMouseInsidePanel = useRef(true); // Track if mouse is inside the panel
|
|
185
132
|
// Track resize event handlers for cleanup
|
|
186
133
|
const resizeHandlersRef = useRef<{ move: ((e: MouseEvent) => void) | null; up: (() => void) | null }>({ move: null, up: null });
|
|
187
134
|
// Cache sheet drawing transform when pinned (to keep model fixed in place)
|
|
@@ -192,6 +139,10 @@ export function Section2DPanel({
|
|
|
192
139
|
setIsNarrow(panelSize.width < 480);
|
|
193
140
|
}, [panelSize.width]);
|
|
194
141
|
|
|
142
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
143
|
+
// MEMOIZED VALUES
|
|
144
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
145
|
+
|
|
195
146
|
// Create graphic override engine with active rules
|
|
196
147
|
const overrideEngine = useMemo(() => {
|
|
197
148
|
const rules = getActiveOverrideRules();
|
|
@@ -211,17 +162,21 @@ export function Section2DPanel({
|
|
|
211
162
|
return map;
|
|
212
163
|
}, [geometryResult]);
|
|
213
164
|
|
|
165
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
166
|
+
// VISIBILITY STATE
|
|
167
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
168
|
+
|
|
214
169
|
// Get visibility state from store for filtering
|
|
215
170
|
const hiddenEntities = useViewerStore((s) => s.hiddenEntities);
|
|
216
171
|
const isolatedEntities = useViewerStore((s) => s.isolatedEntities);
|
|
217
172
|
const hiddenEntitiesByModel = useViewerStore((s) => s.hiddenEntitiesByModel);
|
|
218
173
|
const isolatedEntitiesByModel = useViewerStore((s) => s.isolatedEntitiesByModel);
|
|
219
|
-
|
|
174
|
+
|
|
220
175
|
// Build combined Set of global IDs from multi-model visibility state
|
|
221
176
|
// This converts per-model local expressIds to global IDs using idOffset
|
|
222
177
|
const combinedHiddenIds = useMemo(() => {
|
|
223
178
|
const globalHiddenIds = new Set<number>(hiddenEntities); // Start with legacy hidden IDs
|
|
224
|
-
|
|
179
|
+
|
|
225
180
|
// Add hidden entities from each model (convert local expressId to global ID)
|
|
226
181
|
for (const [modelId, localHiddenIds] of hiddenEntitiesByModel) {
|
|
227
182
|
const model = models.get(modelId);
|
|
@@ -231,17 +186,17 @@ export function Section2DPanel({
|
|
|
231
186
|
}
|
|
232
187
|
}
|
|
233
188
|
}
|
|
234
|
-
|
|
189
|
+
|
|
235
190
|
return globalHiddenIds;
|
|
236
191
|
}, [hiddenEntities, hiddenEntitiesByModel, models]);
|
|
237
|
-
|
|
192
|
+
|
|
238
193
|
// Build combined Set of global IDs for isolation
|
|
239
194
|
const combinedIsolatedIds = useMemo(() => {
|
|
240
195
|
// If legacy isolation is active, use that (already contains global IDs)
|
|
241
196
|
if (isolatedEntities !== null) {
|
|
242
197
|
return isolatedEntities;
|
|
243
198
|
}
|
|
244
|
-
|
|
199
|
+
|
|
245
200
|
// Build from multi-model isolation
|
|
246
201
|
const globalIsolatedIds = new Set<number>();
|
|
247
202
|
for (const [modelId, localIsolatedIds] of isolatedEntitiesByModel) {
|
|
@@ -252,1516 +207,44 @@ export function Section2DPanel({
|
|
|
252
207
|
}
|
|
253
208
|
}
|
|
254
209
|
}
|
|
255
|
-
|
|
210
|
+
|
|
256
211
|
return globalIsolatedIds.size > 0 ? globalIsolatedIds : null;
|
|
257
212
|
}, [isolatedEntities, isolatedEntitiesByModel, models]);
|
|
258
213
|
|
|
259
|
-
// Track if this is a regeneration (vs initial generation)
|
|
260
|
-
const isRegeneratingRef = useRef(false);
|
|
261
|
-
|
|
262
|
-
// Cache for symbolic representations - these don't change with section position
|
|
263
|
-
// Only re-parse when model or display options change
|
|
264
|
-
const symbolicCacheRef = useRef<{
|
|
265
|
-
lines: DrawingLine[];
|
|
266
|
-
entities: Set<number>;
|
|
267
|
-
sourceId: string | null;
|
|
268
|
-
useSymbolic: boolean;
|
|
269
|
-
} | null>(null);
|
|
270
|
-
|
|
271
|
-
// Generate drawing when panel opens
|
|
272
|
-
const generateDrawing = useCallback(async (isRegenerate = false) => {
|
|
273
|
-
if (!geometryResult?.meshes || geometryResult.meshes.length === 0) {
|
|
274
|
-
// Clear the drawing when no geometry is available (e.g., all models hidden)
|
|
275
|
-
setDrawing(null);
|
|
276
|
-
setDrawingStatus('idle');
|
|
277
|
-
setDrawingError('No visible geometry');
|
|
278
|
-
return;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// Only show full loading overlay for initial generation, not regeneration
|
|
282
|
-
if (!isRegenerate) {
|
|
283
|
-
setDrawingStatus('generating');
|
|
284
|
-
setDrawingProgress(0, 'Initializing...');
|
|
285
|
-
}
|
|
286
|
-
isRegeneratingRef.current = isRegenerate;
|
|
287
|
-
|
|
288
|
-
// Parse symbolic representations if enabled (for hybrid mode)
|
|
289
|
-
// OPTIMIZATION: Cache symbolic data - it doesn't change with section position
|
|
290
|
-
let symbolicLines: DrawingLine[] = [];
|
|
291
|
-
let entitiesWithSymbols = new Set<number>();
|
|
292
|
-
|
|
293
|
-
// For multi-model: create cache key from model count and visible model IDs
|
|
294
|
-
// For single-model: use source byteLength as before
|
|
295
|
-
const modelCacheKey = models.size > 0
|
|
296
|
-
? `${models.size}-${[...models.values()].filter(m => m.visible).map(m => m.id).sort().join(',')}`
|
|
297
|
-
: (ifcDataStore?.source ? String(ifcDataStore.source.byteLength) : null);
|
|
298
|
-
|
|
299
|
-
const useSymbolic = displayOptions.useSymbolicRepresentations && (
|
|
300
|
-
models.size > 0 ? true : !!ifcDataStore?.source
|
|
301
|
-
);
|
|
302
|
-
|
|
303
|
-
// Check if we can use cached symbolic data
|
|
304
|
-
const cache = symbolicCacheRef.current;
|
|
305
|
-
const cacheValid = cache &&
|
|
306
|
-
cache.sourceId === modelCacheKey &&
|
|
307
|
-
cache.useSymbolic === useSymbolic;
|
|
308
|
-
|
|
309
|
-
if (useSymbolic) {
|
|
310
|
-
if (cacheValid) {
|
|
311
|
-
// Use cached data - FAST PATH
|
|
312
|
-
symbolicLines = cache.lines;
|
|
313
|
-
entitiesWithSymbols = cache.entities;
|
|
314
|
-
} else {
|
|
315
|
-
// Need to parse - only on first load or when model changes
|
|
316
|
-
try {
|
|
317
|
-
if (!isRegenerate) {
|
|
318
|
-
setDrawingProgress(5, 'Parsing symbolic representations...');
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
const processor = new GeometryProcessor();
|
|
322
|
-
try {
|
|
323
|
-
await processor.init();
|
|
324
|
-
|
|
325
|
-
const symbolicCollection = processor.parseSymbolicRepresentations(ifcDataStore!.source);
|
|
326
|
-
// For single-model (legacy) mode, model index is always 0
|
|
327
|
-
// Multi-model symbolic parsing would require iterating over each model separately
|
|
328
|
-
const symbolicModelIndex = 0;
|
|
329
|
-
|
|
330
|
-
if (symbolicCollection && !symbolicCollection.isEmpty) {
|
|
331
|
-
// Process polylines
|
|
332
|
-
for (let i = 0; i < symbolicCollection.polylineCount; i++) {
|
|
333
|
-
const poly = symbolicCollection.getPolyline(i);
|
|
334
|
-
if (!poly) continue;
|
|
335
|
-
|
|
336
|
-
entitiesWithSymbols.add(poly.expressId);
|
|
337
|
-
const points = poly.points;
|
|
338
|
-
const pointCount = poly.pointCount;
|
|
339
|
-
|
|
340
|
-
for (let j = 0; j < pointCount - 1; j++) {
|
|
341
|
-
symbolicLines.push({
|
|
342
|
-
line: {
|
|
343
|
-
start: { x: points[j * 2], y: points[j * 2 + 1] },
|
|
344
|
-
end: { x: points[(j + 1) * 2], y: points[(j + 1) * 2 + 1] }
|
|
345
|
-
},
|
|
346
|
-
category: 'silhouette',
|
|
347
|
-
visibility: 'visible',
|
|
348
|
-
entityId: poly.expressId,
|
|
349
|
-
ifcType: poly.ifcType,
|
|
350
|
-
modelIndex: symbolicModelIndex,
|
|
351
|
-
depth: 0,
|
|
352
|
-
});
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
if (poly.isClosed && pointCount > 2) {
|
|
356
|
-
symbolicLines.push({
|
|
357
|
-
line: {
|
|
358
|
-
start: { x: points[(pointCount - 1) * 2], y: points[(pointCount - 1) * 2 + 1] },
|
|
359
|
-
end: { x: points[0], y: points[1] }
|
|
360
|
-
},
|
|
361
|
-
category: 'silhouette',
|
|
362
|
-
visibility: 'visible',
|
|
363
|
-
entityId: poly.expressId,
|
|
364
|
-
ifcType: poly.ifcType,
|
|
365
|
-
modelIndex: symbolicModelIndex,
|
|
366
|
-
depth: 0,
|
|
367
|
-
});
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
// Process circles/arcs
|
|
372
|
-
for (let i = 0; i < symbolicCollection.circleCount; i++) {
|
|
373
|
-
const circle = symbolicCollection.getCircle(i);
|
|
374
|
-
if (!circle) continue;
|
|
375
|
-
|
|
376
|
-
entitiesWithSymbols.add(circle.expressId);
|
|
377
|
-
const numSegments = circle.isFullCircle ? 32 : 16;
|
|
378
|
-
|
|
379
|
-
for (let j = 0; j < numSegments; j++) {
|
|
380
|
-
const t1 = j / numSegments;
|
|
381
|
-
const t2 = (j + 1) / numSegments;
|
|
382
|
-
const a1 = circle.startAngle + t1 * (circle.endAngle - circle.startAngle);
|
|
383
|
-
const a2 = circle.startAngle + t2 * (circle.endAngle - circle.startAngle);
|
|
384
|
-
|
|
385
|
-
symbolicLines.push({
|
|
386
|
-
line: {
|
|
387
|
-
start: {
|
|
388
|
-
x: circle.centerX + circle.radius * Math.cos(a1),
|
|
389
|
-
y: circle.centerY + circle.radius * Math.sin(a1),
|
|
390
|
-
},
|
|
391
|
-
end: {
|
|
392
|
-
x: circle.centerX + circle.radius * Math.cos(a2),
|
|
393
|
-
y: circle.centerY + circle.radius * Math.sin(a2),
|
|
394
|
-
},
|
|
395
|
-
},
|
|
396
|
-
category: 'silhouette',
|
|
397
|
-
visibility: 'visible',
|
|
398
|
-
entityId: circle.expressId,
|
|
399
|
-
ifcType: circle.ifcType,
|
|
400
|
-
modelIndex: symbolicModelIndex,
|
|
401
|
-
depth: 0,
|
|
402
|
-
});
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
} finally {
|
|
407
|
-
processor.dispose();
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
// Cache the parsed data
|
|
411
|
-
symbolicCacheRef.current = {
|
|
412
|
-
lines: symbolicLines,
|
|
413
|
-
entities: entitiesWithSymbols,
|
|
414
|
-
sourceId: modelCacheKey,
|
|
415
|
-
useSymbolic,
|
|
416
|
-
};
|
|
417
|
-
} catch (error) {
|
|
418
|
-
console.warn('Symbolic parsing failed:', error);
|
|
419
|
-
symbolicLines = [];
|
|
420
|
-
entitiesWithSymbols = new Set<number>();
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
} else {
|
|
424
|
-
// Clear cache if symbolic is disabled
|
|
425
|
-
if (cache && cache.useSymbolic) {
|
|
426
|
-
symbolicCacheRef.current = null;
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
let generator: Drawing2DGenerator | null = null;
|
|
431
|
-
try {
|
|
432
|
-
generator = new Drawing2DGenerator();
|
|
433
|
-
await generator.initialize();
|
|
434
|
-
|
|
435
|
-
// Convert semantic axis to geometric
|
|
436
|
-
const axis = AXIS_MAP[sectionPlane.axis];
|
|
437
|
-
|
|
438
|
-
// Calculate section position from percentage using coordinateInfo bounds
|
|
439
|
-
const bounds = geometryResult.coordinateInfo.shiftedBounds;
|
|
440
|
-
|
|
441
|
-
const axisMin = bounds.min[axis];
|
|
442
|
-
const axisMax = bounds.max[axis];
|
|
443
|
-
const position = axisMin + (sectionPlane.position / 100) * (axisMax - axisMin);
|
|
444
|
-
|
|
445
|
-
// Calculate max depth as half the model extent
|
|
446
|
-
const maxDepth = (axisMax - axisMin) * 0.5;
|
|
447
|
-
|
|
448
|
-
// Adjust progress to account for symbolic parsing phase (0-20%)
|
|
449
|
-
const progressOffset = symbolicLines.length > 0 ? 20 : 0;
|
|
450
|
-
const progressScale = symbolicLines.length > 0 ? 0.8 : 1;
|
|
451
|
-
const progressCallback = (stage: string, prog: number) => {
|
|
452
|
-
setDrawingProgress(progressOffset + prog * 100 * progressScale, stage);
|
|
453
|
-
};
|
|
454
|
-
|
|
455
|
-
// Create section config
|
|
456
|
-
const config: SectionConfig = createSectionConfig(axis, position, {
|
|
457
|
-
projectionDepth: maxDepth,
|
|
458
|
-
includeHiddenLines: displayOptions.showHiddenLines,
|
|
459
|
-
scale: displayOptions.scale,
|
|
460
|
-
});
|
|
461
|
-
|
|
462
|
-
// Override the flipped setting
|
|
463
|
-
config.plane.flipped = sectionPlane.flipped;
|
|
464
|
-
|
|
465
|
-
// Filter meshes by visibility (respect 3D hiding/isolation)
|
|
466
|
-
let meshesToProcess = geometryResult.meshes;
|
|
467
|
-
|
|
468
|
-
// Filter out hidden entities (using combined multi-model set)
|
|
469
|
-
if (combinedHiddenIds.size > 0) {
|
|
470
|
-
meshesToProcess = meshesToProcess.filter(
|
|
471
|
-
mesh => !combinedHiddenIds.has(mesh.expressId)
|
|
472
|
-
);
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
// Filter by isolation (if active, using combined multi-model set)
|
|
476
|
-
if (combinedIsolatedIds !== null) {
|
|
477
|
-
meshesToProcess = meshesToProcess.filter(
|
|
478
|
-
mesh => combinedIsolatedIds.has(mesh.expressId)
|
|
479
|
-
);
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
// Also filter by computedIsolatedIds (storey selection)
|
|
483
|
-
if (computedIsolatedIds !== null && computedIsolatedIds !== undefined && computedIsolatedIds.size > 0) {
|
|
484
|
-
const isolatedSet = computedIsolatedIds;
|
|
485
|
-
meshesToProcess = meshesToProcess.filter(
|
|
486
|
-
mesh => isolatedSet.has(mesh.expressId)
|
|
487
|
-
);
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
// If all meshes were filtered out by visibility, clear the drawing
|
|
491
|
-
if (meshesToProcess.length === 0) {
|
|
492
|
-
setDrawing(null);
|
|
493
|
-
setDrawingStatus('idle');
|
|
494
|
-
setDrawingError(null);
|
|
495
|
-
return;
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
const result = await generator.generate(meshesToProcess, config, {
|
|
499
|
-
includeHiddenLines: false, // Disable - causes internal mesh edges
|
|
500
|
-
includeProjection: false, // Disable - causes triangulation lines
|
|
501
|
-
includeEdges: false, // Disable - causes triangulation lines
|
|
502
|
-
mergeLines: true,
|
|
503
|
-
onProgress: progressCallback,
|
|
504
|
-
});
|
|
505
|
-
|
|
506
|
-
// If we have symbolic representations, create a hybrid drawing
|
|
507
|
-
if (symbolicLines.length > 0 && entitiesWithSymbols.size > 0) {
|
|
508
|
-
// Get entity IDs that actually appear in the section cut (these are being cut by the plane)
|
|
509
|
-
const cutEntityIds = new Set<number>();
|
|
510
|
-
for (const line of result.lines) {
|
|
511
|
-
if (line.entityId !== undefined) {
|
|
512
|
-
cutEntityIds.add(line.entityId);
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
// Also check cut polygons for entity IDs
|
|
516
|
-
for (const poly of result.cutPolygons ?? []) {
|
|
517
|
-
if ((poly as { entityId?: number }).entityId !== undefined) {
|
|
518
|
-
cutEntityIds.add((poly as { entityId?: number }).entityId!);
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
// Only include symbolic lines for entities that are ACTUALLY being cut
|
|
523
|
-
// This filters out symbols from other floors/levels not intersected by the section plane
|
|
524
|
-
const relevantSymbolicLines = symbolicLines.filter(line =>
|
|
525
|
-
line.entityId !== undefined && cutEntityIds.has(line.entityId)
|
|
526
|
-
);
|
|
527
|
-
|
|
528
|
-
// Get the set of entities that have both symbols AND are being cut
|
|
529
|
-
const entitiesWithRelevantSymbols = new Set<number>();
|
|
530
|
-
for (const line of relevantSymbolicLines) {
|
|
531
|
-
if (line.entityId !== undefined) {
|
|
532
|
-
entitiesWithRelevantSymbols.add(line.entityId);
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
// Align symbolic geometry with section cut geometry using bounding box matching
|
|
537
|
-
// Plan representations often have different local origins than Body representations
|
|
538
|
-
// So we compute per-entity transforms to align Plan bbox center with section cut bbox center
|
|
539
|
-
|
|
540
|
-
// Build per-entity bounding boxes for section cut
|
|
541
|
-
const sectionCutBounds = new Map<number, { minX: number; minY: number; maxX: number; maxY: number }>();
|
|
542
|
-
const updateBounds = (entityId: number, x: number, y: number) => {
|
|
543
|
-
const bounds = sectionCutBounds.get(entityId) ?? { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity };
|
|
544
|
-
bounds.minX = Math.min(bounds.minX, x);
|
|
545
|
-
bounds.minY = Math.min(bounds.minY, y);
|
|
546
|
-
bounds.maxX = Math.max(bounds.maxX, x);
|
|
547
|
-
bounds.maxY = Math.max(bounds.maxY, y);
|
|
548
|
-
sectionCutBounds.set(entityId, bounds);
|
|
549
|
-
};
|
|
550
|
-
for (const line of result.lines) {
|
|
551
|
-
if (line.entityId === undefined) continue;
|
|
552
|
-
updateBounds(line.entityId, line.line.start.x, line.line.start.y);
|
|
553
|
-
updateBounds(line.entityId, line.line.end.x, line.line.end.y);
|
|
554
|
-
}
|
|
555
|
-
// Include cut polygon vertices in bounds computation
|
|
556
|
-
for (const poly of result.cutPolygons ?? []) {
|
|
557
|
-
const entityId = (poly as { entityId?: number }).entityId;
|
|
558
|
-
if (entityId === undefined) continue;
|
|
559
|
-
for (const pt of poly.polygon.outer) {
|
|
560
|
-
updateBounds(entityId, pt.x, pt.y);
|
|
561
|
-
}
|
|
562
|
-
for (const hole of poly.polygon.holes) {
|
|
563
|
-
for (const pt of hole) {
|
|
564
|
-
updateBounds(entityId, pt.x, pt.y);
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
// Build per-entity bounding boxes for symbolic
|
|
570
|
-
const symbolicBounds = new Map<number, { minX: number; minY: number; maxX: number; maxY: number }>();
|
|
571
|
-
for (const line of relevantSymbolicLines) {
|
|
572
|
-
if (line.entityId === undefined) continue;
|
|
573
|
-
const bounds = symbolicBounds.get(line.entityId) ?? { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity };
|
|
574
|
-
bounds.minX = Math.min(bounds.minX, line.line.start.x, line.line.end.x);
|
|
575
|
-
bounds.minY = Math.min(bounds.minY, line.line.start.y, line.line.end.y);
|
|
576
|
-
bounds.maxX = Math.max(bounds.maxX, line.line.start.x, line.line.end.x);
|
|
577
|
-
bounds.maxY = Math.max(bounds.maxY, line.line.start.y, line.line.end.y);
|
|
578
|
-
symbolicBounds.set(line.entityId, bounds);
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
// Compute per-entity alignment transforms (center-to-center offset)
|
|
582
|
-
const alignmentOffsets = new Map<number, { dx: number; dy: number }>();
|
|
583
|
-
for (const entityId of entitiesWithRelevantSymbols) {
|
|
584
|
-
const scBounds = sectionCutBounds.get(entityId);
|
|
585
|
-
const symBounds = symbolicBounds.get(entityId);
|
|
586
|
-
if (scBounds && symBounds) {
|
|
587
|
-
const scCenterX = (scBounds.minX + scBounds.maxX) / 2;
|
|
588
|
-
const scCenterY = (scBounds.minY + scBounds.maxY) / 2;
|
|
589
|
-
const symCenterX = (symBounds.minX + symBounds.maxX) / 2;
|
|
590
|
-
const symCenterY = (symBounds.minY + symBounds.maxY) / 2;
|
|
591
|
-
alignmentOffsets.set(entityId, {
|
|
592
|
-
dx: scCenterX - symCenterX,
|
|
593
|
-
dy: scCenterY - symCenterY,
|
|
594
|
-
});
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
// Apply alignment offsets to symbolic lines
|
|
599
|
-
const alignedSymbolicLines = relevantSymbolicLines.map(line => {
|
|
600
|
-
const offset = line.entityId !== undefined ? alignmentOffsets.get(line.entityId) : undefined;
|
|
601
|
-
if (offset) {
|
|
602
|
-
return {
|
|
603
|
-
...line,
|
|
604
|
-
line: {
|
|
605
|
-
start: { x: line.line.start.x + offset.dx, y: line.line.start.y + offset.dy },
|
|
606
|
-
end: { x: line.line.end.x + offset.dx, y: line.line.end.y + offset.dy },
|
|
607
|
-
},
|
|
608
|
-
};
|
|
609
|
-
}
|
|
610
|
-
return line;
|
|
611
|
-
});
|
|
612
|
-
|
|
613
|
-
// Filter out section cut lines for entities that have relevant symbolic representations
|
|
614
|
-
const filteredLines = result.lines.filter((line: DrawingLine) =>
|
|
615
|
-
line.entityId === undefined || !entitiesWithRelevantSymbols.has(line.entityId)
|
|
616
|
-
);
|
|
617
|
-
|
|
618
|
-
// Also filter cut polygons for entities with relevant symbols
|
|
619
|
-
const filteredCutPolygons = result.cutPolygons?.filter((poly: { entityId?: number }) =>
|
|
620
|
-
poly.entityId === undefined || !entitiesWithRelevantSymbols.has(poly.entityId)
|
|
621
|
-
) ?? [];
|
|
622
|
-
|
|
623
|
-
// Combine filtered section cuts with aligned symbolic lines
|
|
624
|
-
const combinedLines = [...filteredLines, ...alignedSymbolicLines];
|
|
625
|
-
|
|
626
|
-
// Recalculate bounds with combined lines and polygons
|
|
627
|
-
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
628
|
-
for (const line of combinedLines) {
|
|
629
|
-
minX = Math.min(minX, line.line.start.x, line.line.end.x);
|
|
630
|
-
minY = Math.min(minY, line.line.start.y, line.line.end.y);
|
|
631
|
-
maxX = Math.max(maxX, line.line.start.x, line.line.end.x);
|
|
632
|
-
maxY = Math.max(maxY, line.line.start.y, line.line.end.y);
|
|
633
|
-
}
|
|
634
|
-
// Include polygon vertices in bounds
|
|
635
|
-
for (const poly of filteredCutPolygons) {
|
|
636
|
-
for (const pt of poly.polygon.outer) {
|
|
637
|
-
minX = Math.min(minX, pt.x);
|
|
638
|
-
minY = Math.min(minY, pt.y);
|
|
639
|
-
maxX = Math.max(maxX, pt.x);
|
|
640
|
-
maxY = Math.max(maxY, pt.y);
|
|
641
|
-
}
|
|
642
|
-
for (const hole of poly.polygon.holes) {
|
|
643
|
-
for (const pt of hole) {
|
|
644
|
-
minX = Math.min(minX, pt.x);
|
|
645
|
-
minY = Math.min(minY, pt.y);
|
|
646
|
-
maxX = Math.max(maxX, pt.x);
|
|
647
|
-
maxY = Math.max(maxY, pt.y);
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
// Create hybrid drawing
|
|
653
|
-
const hybridDrawing: Drawing2D = {
|
|
654
|
-
...result,
|
|
655
|
-
lines: combinedLines,
|
|
656
|
-
cutPolygons: filteredCutPolygons,
|
|
657
|
-
bounds: {
|
|
658
|
-
min: { x: isFinite(minX) ? minX : result.bounds.min.x, y: isFinite(minY) ? minY : result.bounds.min.y },
|
|
659
|
-
max: { x: isFinite(maxX) ? maxX : result.bounds.max.x, y: isFinite(maxY) ? maxY : result.bounds.max.y },
|
|
660
|
-
},
|
|
661
|
-
stats: {
|
|
662
|
-
...result.stats,
|
|
663
|
-
cutLineCount: combinedLines.length,
|
|
664
|
-
},
|
|
665
|
-
};
|
|
666
|
-
|
|
667
|
-
setDrawing(hybridDrawing);
|
|
668
|
-
} else {
|
|
669
|
-
setDrawing(result);
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
// Always set status to ready (whether initial generation or regeneration)
|
|
673
|
-
setDrawingStatus('ready');
|
|
674
|
-
isRegeneratingRef.current = false;
|
|
675
|
-
} catch (error) {
|
|
676
|
-
console.error('Drawing generation failed:', error);
|
|
677
|
-
setDrawingError(error instanceof Error ? error.message : 'Generation failed');
|
|
678
|
-
} finally {
|
|
679
|
-
// Always cleanup generator to prevent resource leaks
|
|
680
|
-
generator?.dispose();
|
|
681
|
-
}
|
|
682
|
-
}, [
|
|
683
|
-
geometryResult,
|
|
684
|
-
ifcDataStore,
|
|
685
|
-
sectionPlane,
|
|
686
|
-
displayOptions,
|
|
687
|
-
combinedHiddenIds,
|
|
688
|
-
combinedIsolatedIds,
|
|
689
|
-
computedIsolatedIds,
|
|
690
|
-
setDrawing,
|
|
691
|
-
setDrawingStatus,
|
|
692
|
-
setDrawingProgress,
|
|
693
|
-
setDrawingError,
|
|
694
|
-
]);
|
|
695
|
-
|
|
696
|
-
// Track panel visibility and geometry for detecting changes
|
|
697
|
-
const prevPanelVisibleRef = useRef(false);
|
|
698
|
-
const prevOverlayEnabledRef = useRef(false);
|
|
699
|
-
const prevMeshCountRef = useRef(0);
|
|
700
|
-
|
|
701
|
-
// Auto-generate when panel opens (or 3D overlay is enabled) and no drawing exists
|
|
702
|
-
// Also regenerate when geometry changes significantly (e.g., models hidden/shown)
|
|
703
|
-
useEffect(() => {
|
|
704
|
-
const wasVisible = prevPanelVisibleRef.current;
|
|
705
|
-
const wasOverlayEnabled = prevOverlayEnabledRef.current;
|
|
706
|
-
const prevMeshCount = prevMeshCountRef.current;
|
|
707
|
-
const currentMeshCount = geometryResult?.meshes?.length ?? 0;
|
|
708
|
-
const hasGeometry = currentMeshCount > 0;
|
|
709
|
-
|
|
710
|
-
// Track panel visibility separately from overlay
|
|
711
|
-
const panelJustOpened = panelVisible && !wasVisible;
|
|
712
|
-
const overlayJustEnabled = displayOptions.show3DOverlay && !wasOverlayEnabled;
|
|
713
|
-
const isNowActive = panelVisible || displayOptions.show3DOverlay;
|
|
714
|
-
const geometryChanged = currentMeshCount !== prevMeshCount;
|
|
715
|
-
|
|
716
|
-
// Always update refs
|
|
717
|
-
prevPanelVisibleRef.current = panelVisible;
|
|
718
|
-
prevOverlayEnabledRef.current = displayOptions.show3DOverlay;
|
|
719
|
-
prevMeshCountRef.current = currentMeshCount;
|
|
720
|
-
|
|
721
|
-
if (isNowActive) {
|
|
722
|
-
if (!hasGeometry) {
|
|
723
|
-
// No geometry available - clear the drawing
|
|
724
|
-
if (drawing) {
|
|
725
|
-
setDrawing(null);
|
|
726
|
-
setDrawingStatus('idle');
|
|
727
|
-
}
|
|
728
|
-
} else if (panelJustOpened || overlayJustEnabled || !drawing || geometryChanged) {
|
|
729
|
-
// Generate if:
|
|
730
|
-
// 1. Panel just opened, OR
|
|
731
|
-
// 2. Overlay just enabled, OR
|
|
732
|
-
// 3. No drawing exists, OR
|
|
733
|
-
// 4. Geometry changed significantly (models hidden/shown)
|
|
734
|
-
generateDrawing();
|
|
735
|
-
}
|
|
736
|
-
}
|
|
737
|
-
}, [panelVisible, displayOptions.show3DOverlay, drawing, geometryResult, generateDrawing, setDrawing, setDrawingStatus]);
|
|
738
|
-
|
|
739
|
-
// Auto-regenerate when section plane changes
|
|
740
|
-
// Strategy: INSTANT - no debounce, but prevent overlapping computations
|
|
741
|
-
// The generation time itself acts as natural batching for fast slider movements
|
|
742
|
-
const sectionRef = useRef({ axis: sectionPlane.axis, position: sectionPlane.position, flipped: sectionPlane.flipped });
|
|
743
|
-
const isGeneratingRef = useRef(false);
|
|
744
|
-
const latestSectionRef = useRef({ axis: sectionPlane.axis, position: sectionPlane.position, flipped: sectionPlane.flipped });
|
|
745
|
-
const [isRegenerating, setIsRegenerating] = useState(false);
|
|
746
|
-
|
|
747
|
-
// Stable regenerate function that handles overlapping calls
|
|
748
|
-
const doRegenerate = useCallback(async () => {
|
|
749
|
-
if (isGeneratingRef.current) {
|
|
750
|
-
// Already generating - the latest position is already tracked in latestSectionRef
|
|
751
|
-
// When current generation finishes, it will check if another is needed
|
|
752
|
-
return;
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
isGeneratingRef.current = true;
|
|
756
|
-
setIsRegenerating(true);
|
|
757
|
-
|
|
758
|
-
// Capture position at start of generation
|
|
759
|
-
const targetSection = { ...latestSectionRef.current };
|
|
760
|
-
|
|
761
|
-
try {
|
|
762
|
-
await generateDrawing(true);
|
|
763
|
-
} finally {
|
|
764
|
-
isGeneratingRef.current = false;
|
|
765
|
-
setIsRegenerating(false);
|
|
766
|
-
|
|
767
|
-
// Check if section changed while we were generating
|
|
768
|
-
const current = latestSectionRef.current;
|
|
769
|
-
if (
|
|
770
|
-
current.axis !== targetSection.axis ||
|
|
771
|
-
current.position !== targetSection.position ||
|
|
772
|
-
current.flipped !== targetSection.flipped
|
|
773
|
-
) {
|
|
774
|
-
// Position changed during generation - regenerate immediately with latest
|
|
775
|
-
// Use microtask to avoid blocking
|
|
776
|
-
queueMicrotask(() => doRegenerate());
|
|
777
|
-
}
|
|
778
|
-
}
|
|
779
|
-
}, [generateDrawing]);
|
|
780
|
-
|
|
781
|
-
useEffect(() => {
|
|
782
|
-
// Always update latest section ref (even if generating)
|
|
783
|
-
latestSectionRef.current = { axis: sectionPlane.axis, position: sectionPlane.position, flipped: sectionPlane.flipped };
|
|
784
|
-
|
|
785
|
-
// Check if section plane actually changed from last processed
|
|
786
|
-
const prev = sectionRef.current;
|
|
787
|
-
if (
|
|
788
|
-
prev.axis === sectionPlane.axis &&
|
|
789
|
-
prev.position === sectionPlane.position &&
|
|
790
|
-
prev.flipped === sectionPlane.flipped
|
|
791
|
-
) {
|
|
792
|
-
return;
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
// Update processed ref
|
|
796
|
-
sectionRef.current = { axis: sectionPlane.axis, position: sectionPlane.position, flipped: sectionPlane.flipped };
|
|
797
|
-
|
|
798
|
-
// If panel is visible OR 3D overlay is enabled, and we have geometry, regenerate INSTANTLY
|
|
799
|
-
if ((panelVisible || displayOptions.show3DOverlay) && geometryResult?.meshes) {
|
|
800
|
-
// Start immediately - no debounce
|
|
801
|
-
// doRegenerate handles preventing overlaps and will auto-regenerate with latest when done
|
|
802
|
-
doRegenerate();
|
|
803
|
-
}
|
|
804
|
-
}, [panelVisible, displayOptions.show3DOverlay, sectionPlane.axis, sectionPlane.position, sectionPlane.flipped, geometryResult, combinedHiddenIds, combinedIsolatedIds, computedIsolatedIds, doRegenerate]);
|
|
805
|
-
|
|
806
214
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
807
|
-
//
|
|
215
|
+
// EXTRACTED HOOKS
|
|
808
216
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
809
217
|
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
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
|
-
}
|
|
218
|
+
const { generateDrawing, doRegenerate, isRegenerating } = useDrawingGeneration({
|
|
219
|
+
geometryResult, ifcDataStore, sectionPlane, displayOptions,
|
|
220
|
+
combinedHiddenIds, combinedIsolatedIds, computedIsolatedIds,
|
|
221
|
+
models, panelVisible, drawing,
|
|
222
|
+
setDrawing, setDrawingStatus, setDrawingProgress, setDrawingError,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const { viewTransform, setViewTransform, zoomIn, zoomOut, fitToView } = useViewControls({
|
|
226
|
+
drawing, sectionPlane, containerRef,
|
|
227
|
+
panelVisible, status, sheetEnabled, activeSheet,
|
|
228
|
+
isPinned, cachedSheetTransformRef,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const { handleMouseDown, handleMouseMove, handleMouseUp, handleMouseLeave, handleMouseEnter } = useMeasure2D({
|
|
232
|
+
drawing, viewTransform, setViewTransform, sectionAxis: sectionPlane.axis, containerRef,
|
|
233
|
+
measure2DMode, measure2DStart, measure2DCurrent,
|
|
234
|
+
measure2DShiftLocked, measure2DLockedAxis,
|
|
235
|
+
setMeasure2DStart, setMeasure2DCurrent, setMeasure2DShiftLocked,
|
|
236
|
+
setMeasure2DSnapPoint, cancelMeasure2D, completeMeasure2D,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const { formatDistance, handleExportSVG, handlePrint } = useDrawingExport({
|
|
240
|
+
drawing, displayOptions, sectionPlane, activePresetId,
|
|
241
|
+
entityColorMap, overridesEnabled, overrideEngine,
|
|
242
|
+
measure2DResults, sheetEnabled, activeSheet,
|
|
243
|
+
});
|
|
1703
244
|
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
lineWidth *= 0.7;
|
|
1708
|
-
}
|
|
1709
|
-
|
|
1710
|
-
const paperStart = modelToPaper(start.x, start.y);
|
|
1711
|
-
const paperEnd = modelToPaper(end.x, end.y);
|
|
1712
|
-
|
|
1713
|
-
// lineWidth is in mm on paper
|
|
1714
|
-
const svgLineWidth = lineWidth * 0.3;
|
|
1715
|
-
const dashAttr = dashArray ? ` stroke-dasharray="${dashArray}"` : '';
|
|
1716
|
-
svg += ` <line x1="${paperStart.x.toFixed(4)}" y1="${paperStart.y.toFixed(4)}" x2="${paperEnd.x.toFixed(4)}" y2="${paperEnd.y.toFixed(4)}" stroke="${strokeColor}" stroke-width="${svgLineWidth.toFixed(4)}"${dashAttr}/>\n`;
|
|
1717
|
-
}
|
|
1718
|
-
svg += ' </g>\n';
|
|
1719
|
-
|
|
1720
|
-
svg += ' </g>\n\n';
|
|
1721
|
-
|
|
1722
|
-
// Render frame (on top of drawing content)
|
|
1723
|
-
const frameResult = renderFrame(activeSheet.paper, activeSheet.frame);
|
|
1724
|
-
svg += frameResult.svgElements;
|
|
1725
|
-
svg += '\n';
|
|
1726
|
-
|
|
1727
|
-
// Render title block with scale bar and north arrow inside
|
|
1728
|
-
// Pass effectiveScaleFactor from the actual transform (not just configured scale)
|
|
1729
|
-
// This ensures scale bar shows correct values when dynamically scaled
|
|
1730
|
-
const titleBlockExtras: TitleBlockExtras = {
|
|
1731
|
-
scaleBar: activeSheet.scaleBar,
|
|
1732
|
-
northArrow: activeSheet.northArrow,
|
|
1733
|
-
scale: activeSheet.scale,
|
|
1734
|
-
effectiveScaleFactor: scaleFactor,
|
|
1735
|
-
};
|
|
1736
|
-
const titleBlockResult = renderTitleBlock(
|
|
1737
|
-
activeSheet.titleBlock,
|
|
1738
|
-
frameResult.innerBounds,
|
|
1739
|
-
activeSheet.revisions,
|
|
1740
|
-
titleBlockExtras
|
|
1741
|
-
);
|
|
1742
|
-
svg += titleBlockResult.svgElements;
|
|
1743
|
-
svg += '\n';
|
|
1744
|
-
|
|
1745
|
-
svg += '</svg>';
|
|
1746
|
-
return svg;
|
|
1747
|
-
}, [drawing, activeSheet, displayOptions, activePresetId, entityColorMap, overridesEnabled, overrideEngine]);
|
|
1748
|
-
|
|
1749
|
-
// Export SVG
|
|
1750
|
-
const handleExportSVG = useCallback(() => {
|
|
1751
|
-
// Use sheet export if enabled, otherwise raw drawing export
|
|
1752
|
-
const svg = (sheetEnabled && activeSheet) ? generateSheetSVG() : generateExportSVG();
|
|
1753
|
-
if (!svg) return;
|
|
1754
|
-
const blob = new Blob([svg], { type: 'image/svg+xml' });
|
|
1755
|
-
const url = URL.createObjectURL(blob);
|
|
1756
|
-
const a = document.createElement('a');
|
|
1757
|
-
a.href = url;
|
|
1758
|
-
const filename = (sheetEnabled && activeSheet)
|
|
1759
|
-
? `${activeSheet.name.replace(/\s+/g, '-')}-${sectionPlane.axis}-${sectionPlane.position}.svg`
|
|
1760
|
-
: `section-${sectionPlane.axis}-${sectionPlane.position}.svg`;
|
|
1761
|
-
a.download = filename;
|
|
1762
|
-
a.click();
|
|
1763
|
-
URL.revokeObjectURL(url);
|
|
1764
|
-
}, [generateExportSVG, generateSheetSVG, sheetEnabled, activeSheet, sectionPlane]);
|
|
245
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
246
|
+
// CALLBACKS
|
|
247
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1765
248
|
|
|
1766
249
|
// Close panel
|
|
1767
250
|
const handleClose = useCallback(() => {
|
|
@@ -1788,7 +271,10 @@ export function Section2DPanel({
|
|
|
1788
271
|
setIsPinned((prev) => !prev);
|
|
1789
272
|
}, []);
|
|
1790
273
|
|
|
1791
|
-
//
|
|
274
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
275
|
+
// RESIZE HANDLING
|
|
276
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
277
|
+
|
|
1792
278
|
const handleResizeStart = useCallback((edge: 'right' | 'top' | 'corner') => (e: React.MouseEvent) => {
|
|
1793
279
|
e.preventDefault();
|
|
1794
280
|
e.stopPropagation();
|
|
@@ -1856,64 +342,9 @@ export function Section2DPanel({
|
|
|
1856
342
|
};
|
|
1857
343
|
}, []);
|
|
1858
344
|
|
|
1859
|
-
//
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
const svg = (sheetEnabled && activeSheet) ? generateSheetSVG() : generateExportSVG();
|
|
1863
|
-
if (!svg) return;
|
|
1864
|
-
|
|
1865
|
-
// Create a new window for printing
|
|
1866
|
-
const printWindow = window.open('', '_blank', 'width=800,height=600');
|
|
1867
|
-
if (!printWindow) {
|
|
1868
|
-
alert('Please allow popups to print');
|
|
1869
|
-
return;
|
|
1870
|
-
}
|
|
1871
|
-
|
|
1872
|
-
const title = (sheetEnabled && activeSheet)
|
|
1873
|
-
? `${activeSheet.name} - ${sectionPlane.axis} at ${sectionPlane.position}%`
|
|
1874
|
-
: `Section Drawing - ${sectionPlane.axis} at ${sectionPlane.position}%`;
|
|
1875
|
-
|
|
1876
|
-
// Write print-friendly HTML with the SVG
|
|
1877
|
-
printWindow.document.write(`
|
|
1878
|
-
<!DOCTYPE html>
|
|
1879
|
-
<html>
|
|
1880
|
-
<head>
|
|
1881
|
-
<title>${title}</title>
|
|
1882
|
-
<style>
|
|
1883
|
-
@media print {
|
|
1884
|
-
@page { margin: ${(sheetEnabled && activeSheet) ? '0' : '1cm'}; }
|
|
1885
|
-
body { margin: 0; }
|
|
1886
|
-
}
|
|
1887
|
-
body {
|
|
1888
|
-
display: flex;
|
|
1889
|
-
justify-content: center;
|
|
1890
|
-
align-items: center;
|
|
1891
|
-
min-height: 100vh;
|
|
1892
|
-
margin: 0;
|
|
1893
|
-
padding: ${(sheetEnabled && activeSheet) ? '0' : '20px'};
|
|
1894
|
-
box-sizing: border-box;
|
|
1895
|
-
}
|
|
1896
|
-
svg {
|
|
1897
|
-
max-width: 100%;
|
|
1898
|
-
max-height: 100vh;
|
|
1899
|
-
width: auto;
|
|
1900
|
-
height: auto;
|
|
1901
|
-
}
|
|
1902
|
-
</style>
|
|
1903
|
-
</head>
|
|
1904
|
-
<body>
|
|
1905
|
-
${svg}
|
|
1906
|
-
<script>
|
|
1907
|
-
window.onload = function() {
|
|
1908
|
-
window.print();
|
|
1909
|
-
window.onafterprint = function() { window.close(); };
|
|
1910
|
-
};
|
|
1911
|
-
</script>
|
|
1912
|
-
</body>
|
|
1913
|
-
</html>
|
|
1914
|
-
`);
|
|
1915
|
-
printWindow.document.close();
|
|
1916
|
-
}, [generateExportSVG, generateSheetSVG, sheetEnabled, activeSheet, sectionPlane]);
|
|
345
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
346
|
+
// MEMOIZED STYLES
|
|
347
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1917
348
|
|
|
1918
349
|
// Memoize panel style to avoid creating new object on every render
|
|
1919
350
|
const panelStyle = useMemo(() => {
|
|
@@ -1925,6 +356,10 @@ export function Section2DPanel({
|
|
|
1925
356
|
// Memoize progress bar style
|
|
1926
357
|
const progressBarStyle = useMemo(() => ({ width: `${progress}%` }), [progress]);
|
|
1927
358
|
|
|
359
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
360
|
+
// RENDER
|
|
361
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
362
|
+
|
|
1928
363
|
if (!panelVisible) return null;
|
|
1929
364
|
|
|
1930
365
|
const panelClasses = isExpanded
|
|
@@ -2304,1010 +739,3 @@ export function Section2DPanel({
|
|
|
2304
739
|
</div>
|
|
2305
740
|
);
|
|
2306
741
|
}
|
|
2307
|
-
|
|
2308
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
2309
|
-
// CANVAS RENDERER
|
|
2310
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
2311
|
-
|
|
2312
|
-
// Static style constant to avoid creating new object on every render
|
|
2313
|
-
const CANVAS_STYLE = { imageRendering: 'crisp-edges' as const };
|
|
2314
|
-
|
|
2315
|
-
interface Measure2DResultData {
|
|
2316
|
-
id: string;
|
|
2317
|
-
start: { x: number; y: number };
|
|
2318
|
-
end: { x: number; y: number };
|
|
2319
|
-
distance: number;
|
|
2320
|
-
}
|
|
2321
|
-
|
|
2322
|
-
interface Drawing2DCanvasProps {
|
|
2323
|
-
drawing: Drawing2D;
|
|
2324
|
-
transform: { x: number; y: number; scale: number };
|
|
2325
|
-
showHiddenLines: boolean;
|
|
2326
|
-
overrideEngine: GraphicOverrideEngine;
|
|
2327
|
-
overridesEnabled: boolean;
|
|
2328
|
-
entityColorMap: Map<number, [number, number, number, number]>;
|
|
2329
|
-
useIfcMaterials: boolean;
|
|
2330
|
-
// Measure tool props
|
|
2331
|
-
measureMode?: boolean;
|
|
2332
|
-
measureStart?: { x: number; y: number } | null;
|
|
2333
|
-
measureCurrent?: { x: number; y: number } | null;
|
|
2334
|
-
measureResults?: Measure2DResultData[];
|
|
2335
|
-
measureSnapPoint?: { x: number; y: number } | null;
|
|
2336
|
-
// Sheet mode props
|
|
2337
|
-
sheetEnabled?: boolean;
|
|
2338
|
-
activeSheet?: import('@ifc-lite/drawing-2d').DrawingSheet | null;
|
|
2339
|
-
// Section plane info for axis-specific rendering
|
|
2340
|
-
sectionAxis: 'down' | 'front' | 'side';
|
|
2341
|
-
// Pinned mode - keep model fixed in place on sheet
|
|
2342
|
-
isPinned?: boolean;
|
|
2343
|
-
cachedSheetTransformRef?: React.MutableRefObject<{ translateX: number; translateY: number; scaleFactor: number } | null>;
|
|
2344
|
-
}
|
|
2345
|
-
|
|
2346
|
-
function Drawing2DCanvas({
|
|
2347
|
-
drawing,
|
|
2348
|
-
transform,
|
|
2349
|
-
showHiddenLines,
|
|
2350
|
-
overrideEngine,
|
|
2351
|
-
overridesEnabled,
|
|
2352
|
-
entityColorMap,
|
|
2353
|
-
useIfcMaterials,
|
|
2354
|
-
measureMode = false,
|
|
2355
|
-
measureStart = null,
|
|
2356
|
-
measureCurrent = null,
|
|
2357
|
-
measureResults = [],
|
|
2358
|
-
measureSnapPoint = null,
|
|
2359
|
-
sheetEnabled = false,
|
|
2360
|
-
activeSheet = null,
|
|
2361
|
-
sectionAxis,
|
|
2362
|
-
isPinned = false,
|
|
2363
|
-
cachedSheetTransformRef,
|
|
2364
|
-
}: Drawing2DCanvasProps): React.ReactElement {
|
|
2365
|
-
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
2366
|
-
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
|
|
2367
|
-
|
|
2368
|
-
// ResizeObserver to track canvas size changes
|
|
2369
|
-
useEffect(() => {
|
|
2370
|
-
const canvas = canvasRef.current;
|
|
2371
|
-
if (!canvas) return;
|
|
2372
|
-
|
|
2373
|
-
const resizeObserver = new ResizeObserver((entries) => {
|
|
2374
|
-
for (const entry of entries) {
|
|
2375
|
-
const { width, height } = entry.contentRect;
|
|
2376
|
-
setCanvasSize((prev) => {
|
|
2377
|
-
// Only update if size actually changed to avoid render loops
|
|
2378
|
-
if (prev.width !== width || prev.height !== height) {
|
|
2379
|
-
return { width, height };
|
|
2380
|
-
}
|
|
2381
|
-
return prev;
|
|
2382
|
-
});
|
|
2383
|
-
}
|
|
2384
|
-
});
|
|
2385
|
-
|
|
2386
|
-
resizeObserver.observe(canvas);
|
|
2387
|
-
return () => resizeObserver.disconnect();
|
|
2388
|
-
}, []);
|
|
2389
|
-
|
|
2390
|
-
useEffect(() => {
|
|
2391
|
-
const canvas = canvasRef.current;
|
|
2392
|
-
if (!canvas || canvasSize.width === 0 || canvasSize.height === 0) return;
|
|
2393
|
-
|
|
2394
|
-
const ctx = canvas.getContext('2d');
|
|
2395
|
-
if (!ctx) return;
|
|
2396
|
-
|
|
2397
|
-
// Set canvas size using tracked dimensions
|
|
2398
|
-
const dpr = window.devicePixelRatio || 1;
|
|
2399
|
-
canvas.width = canvasSize.width * dpr;
|
|
2400
|
-
canvas.height = canvasSize.height * dpr;
|
|
2401
|
-
ctx.scale(dpr, dpr);
|
|
2402
|
-
|
|
2403
|
-
// Clear with light gray background (shows paper edge when in sheet mode)
|
|
2404
|
-
ctx.fillStyle = sheetEnabled && activeSheet ? '#e5e5e5' : '#ffffff';
|
|
2405
|
-
ctx.fillRect(0, 0, canvasSize.width, canvasSize.height);
|
|
2406
|
-
|
|
2407
|
-
// ═══════════════════════════════════════════════════════════════════════
|
|
2408
|
-
// SHEET MODE: Render paper, frame, title block, then drawing in viewport
|
|
2409
|
-
// ═══════════════════════════════════════════════════════════════════════
|
|
2410
|
-
if (sheetEnabled && activeSheet) {
|
|
2411
|
-
const paper = activeSheet.paper;
|
|
2412
|
-
const frame = activeSheet.frame;
|
|
2413
|
-
const titleBlock = activeSheet.titleBlock;
|
|
2414
|
-
const viewport = activeSheet.viewportBounds;
|
|
2415
|
-
const scaleBar = activeSheet.scaleBar;
|
|
2416
|
-
const northArrow = activeSheet.northArrow;
|
|
2417
|
-
|
|
2418
|
-
// Helper: convert sheet mm to screen pixels
|
|
2419
|
-
const mmToScreen = (mm: number) => mm * transform.scale;
|
|
2420
|
-
const mmToScreenX = (x: number) => x * transform.scale + transform.x;
|
|
2421
|
-
const mmToScreenY = (y: number) => y * transform.scale + transform.y;
|
|
2422
|
-
|
|
2423
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
2424
|
-
// 1. Draw paper background (white with shadow)
|
|
2425
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
2426
|
-
ctx.save();
|
|
2427
|
-
// Paper shadow
|
|
2428
|
-
ctx.shadowColor = 'rgba(0, 0, 0, 0.2)';
|
|
2429
|
-
ctx.shadowBlur = 10 * (transform.scale > 0.5 ? 1 : transform.scale * 2);
|
|
2430
|
-
ctx.shadowOffsetX = 3;
|
|
2431
|
-
ctx.shadowOffsetY = 3;
|
|
2432
|
-
ctx.fillStyle = '#ffffff';
|
|
2433
|
-
ctx.fillRect(
|
|
2434
|
-
mmToScreenX(0),
|
|
2435
|
-
mmToScreenY(0),
|
|
2436
|
-
mmToScreen(paper.widthMm),
|
|
2437
|
-
mmToScreen(paper.heightMm)
|
|
2438
|
-
);
|
|
2439
|
-
ctx.restore();
|
|
2440
|
-
|
|
2441
|
-
// Paper border
|
|
2442
|
-
ctx.strokeStyle = '#cccccc';
|
|
2443
|
-
ctx.lineWidth = 1;
|
|
2444
|
-
ctx.strokeRect(
|
|
2445
|
-
mmToScreenX(0),
|
|
2446
|
-
mmToScreenY(0),
|
|
2447
|
-
mmToScreen(paper.widthMm),
|
|
2448
|
-
mmToScreen(paper.heightMm)
|
|
2449
|
-
);
|
|
2450
|
-
|
|
2451
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
2452
|
-
// 2. Draw frame borders
|
|
2453
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
2454
|
-
const frameLeft = frame.margins.left + frame.margins.bindingMargin;
|
|
2455
|
-
const frameTop = frame.margins.top;
|
|
2456
|
-
const frameRight = paper.widthMm - frame.margins.right;
|
|
2457
|
-
const frameBottom = paper.heightMm - frame.margins.bottom;
|
|
2458
|
-
const frameWidth = frameRight - frameLeft;
|
|
2459
|
-
const frameHeight = frameBottom - frameTop;
|
|
2460
|
-
|
|
2461
|
-
// Outer border
|
|
2462
|
-
ctx.strokeStyle = '#000000';
|
|
2463
|
-
ctx.lineWidth = Math.max(1, mmToScreen(frame.border.outerLineWeight));
|
|
2464
|
-
ctx.strokeRect(
|
|
2465
|
-
mmToScreenX(frameLeft),
|
|
2466
|
-
mmToScreenY(frameTop),
|
|
2467
|
-
mmToScreen(frameWidth),
|
|
2468
|
-
mmToScreen(frameHeight)
|
|
2469
|
-
);
|
|
2470
|
-
|
|
2471
|
-
// Inner border (if gap > 0)
|
|
2472
|
-
if (frame.border.borderGap > 0) {
|
|
2473
|
-
const innerLeft = frameLeft + frame.border.borderGap;
|
|
2474
|
-
const innerTop = frameTop + frame.border.borderGap;
|
|
2475
|
-
const innerWidth = frameWidth - 2 * frame.border.borderGap;
|
|
2476
|
-
const innerHeight = frameHeight - 2 * frame.border.borderGap;
|
|
2477
|
-
|
|
2478
|
-
ctx.lineWidth = Math.max(0.5, mmToScreen(frame.border.innerLineWeight));
|
|
2479
|
-
ctx.strokeRect(
|
|
2480
|
-
mmToScreenX(innerLeft),
|
|
2481
|
-
mmToScreenY(innerTop),
|
|
2482
|
-
mmToScreen(innerWidth),
|
|
2483
|
-
mmToScreen(innerHeight)
|
|
2484
|
-
);
|
|
2485
|
-
}
|
|
2486
|
-
|
|
2487
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
2488
|
-
// 3. Draw title block
|
|
2489
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
2490
|
-
const innerLeft = frameLeft + frame.border.borderGap;
|
|
2491
|
-
const innerTop = frameTop + frame.border.borderGap;
|
|
2492
|
-
const innerWidth = frameWidth - 2 * frame.border.borderGap;
|
|
2493
|
-
const innerHeight = frameHeight - 2 * frame.border.borderGap;
|
|
2494
|
-
|
|
2495
|
-
let tbX: number, tbY: number, tbW: number, tbH: number;
|
|
2496
|
-
switch (titleBlock.position) {
|
|
2497
|
-
case 'bottom-right':
|
|
2498
|
-
tbW = titleBlock.widthMm;
|
|
2499
|
-
tbH = titleBlock.heightMm;
|
|
2500
|
-
tbX = innerLeft + innerWidth - tbW;
|
|
2501
|
-
tbY = innerTop + innerHeight - tbH;
|
|
2502
|
-
break;
|
|
2503
|
-
case 'bottom-full':
|
|
2504
|
-
tbW = innerWidth;
|
|
2505
|
-
tbH = titleBlock.heightMm;
|
|
2506
|
-
tbX = innerLeft;
|
|
2507
|
-
tbY = innerTop + innerHeight - tbH;
|
|
2508
|
-
break;
|
|
2509
|
-
case 'right-strip':
|
|
2510
|
-
tbW = titleBlock.widthMm;
|
|
2511
|
-
tbH = innerHeight;
|
|
2512
|
-
tbX = innerLeft + innerWidth - tbW;
|
|
2513
|
-
tbY = innerTop;
|
|
2514
|
-
break;
|
|
2515
|
-
default:
|
|
2516
|
-
tbW = titleBlock.widthMm;
|
|
2517
|
-
tbH = titleBlock.heightMm;
|
|
2518
|
-
tbX = innerLeft + innerWidth - tbW;
|
|
2519
|
-
tbY = innerTop + innerHeight - tbH;
|
|
2520
|
-
}
|
|
2521
|
-
|
|
2522
|
-
// Title block border
|
|
2523
|
-
ctx.strokeStyle = '#000000';
|
|
2524
|
-
ctx.lineWidth = Math.max(1, mmToScreen(titleBlock.borderWeight));
|
|
2525
|
-
ctx.strokeRect(
|
|
2526
|
-
mmToScreenX(tbX),
|
|
2527
|
-
mmToScreenY(tbY),
|
|
2528
|
-
mmToScreen(tbW),
|
|
2529
|
-
mmToScreen(tbH)
|
|
2530
|
-
);
|
|
2531
|
-
|
|
2532
|
-
// Title block fields - calculate row heights based on font sizes
|
|
2533
|
-
const logoSpace = titleBlock.logo ? 50 : 0;
|
|
2534
|
-
const revisionSpace = titleBlock.showRevisionHistory ? 20 : 0;
|
|
2535
|
-
const availableWidth = tbW - logoSpace - 5;
|
|
2536
|
-
const availableHeight = tbH - revisionSpace - 4;
|
|
2537
|
-
const numCols = 2;
|
|
2538
|
-
|
|
2539
|
-
// Group fields by row
|
|
2540
|
-
const fieldsByRow = new Map<number, typeof titleBlock.fields>();
|
|
2541
|
-
for (const field of titleBlock.fields) {
|
|
2542
|
-
const row = field.row ?? 0;
|
|
2543
|
-
if (!fieldsByRow.has(row)) fieldsByRow.set(row, []);
|
|
2544
|
-
fieldsByRow.get(row)!.push(field);
|
|
2545
|
-
}
|
|
2546
|
-
|
|
2547
|
-
// Calculate minimum height needed for each row based on its largest font
|
|
2548
|
-
const rowCount = Math.max(...Array.from(fieldsByRow.keys()), 0) + 1;
|
|
2549
|
-
const rowHeights: number[] = [];
|
|
2550
|
-
let totalMinHeight = 0;
|
|
2551
|
-
|
|
2552
|
-
for (let r = 0; r < rowCount; r++) {
|
|
2553
|
-
const fields = fieldsByRow.get(r) || [];
|
|
2554
|
-
const maxFontSize = fields.length > 0 ? Math.max(...fields.map(f => f.fontSize)) : 3;
|
|
2555
|
-
const labelSize = Math.min(maxFontSize * 0.5, 2.2);
|
|
2556
|
-
const minRowHeight = labelSize + 1 + maxFontSize + 2;
|
|
2557
|
-
rowHeights.push(minRowHeight);
|
|
2558
|
-
totalMinHeight += minRowHeight;
|
|
2559
|
-
}
|
|
2560
|
-
|
|
2561
|
-
// Scale row heights if they exceed available space
|
|
2562
|
-
const rowScaleFactor = totalMinHeight > availableHeight ? availableHeight / totalMinHeight : 1;
|
|
2563
|
-
const scaledRowHeights = rowHeights.map(h => h * rowScaleFactor);
|
|
2564
|
-
|
|
2565
|
-
const colWidth = availableWidth / numCols;
|
|
2566
|
-
const gridStartX = tbX + logoSpace + 2;
|
|
2567
|
-
const gridStartY = tbY + 2;
|
|
2568
|
-
|
|
2569
|
-
// Calculate row Y positions
|
|
2570
|
-
const rowYPositions: number[] = [gridStartY];
|
|
2571
|
-
for (let i = 0; i < scaledRowHeights.length - 1; i++) {
|
|
2572
|
-
rowYPositions.push(rowYPositions[i] + scaledRowHeights[i]);
|
|
2573
|
-
}
|
|
2574
|
-
|
|
2575
|
-
// Draw grid lines
|
|
2576
|
-
ctx.strokeStyle = '#000000';
|
|
2577
|
-
ctx.lineWidth = Math.max(0.5, mmToScreen(titleBlock.gridWeight));
|
|
2578
|
-
|
|
2579
|
-
// Horizontal lines
|
|
2580
|
-
for (let i = 1; i < rowCount; i++) {
|
|
2581
|
-
const lineY = rowYPositions[i];
|
|
2582
|
-
ctx.beginPath();
|
|
2583
|
-
ctx.moveTo(mmToScreenX(gridStartX), mmToScreenY(lineY));
|
|
2584
|
-
ctx.lineTo(mmToScreenX(gridStartX + availableWidth - 4), mmToScreenY(lineY));
|
|
2585
|
-
ctx.stroke();
|
|
2586
|
-
}
|
|
2587
|
-
|
|
2588
|
-
// Vertical dividers (for rows with multiple columns)
|
|
2589
|
-
for (const [row, fields] of fieldsByRow) {
|
|
2590
|
-
const hasMultipleCols = fields.some(f => (f.colSpan ?? 1) < 2);
|
|
2591
|
-
if (hasMultipleCols) {
|
|
2592
|
-
const centerX = gridStartX + colWidth;
|
|
2593
|
-
const lineY1 = rowYPositions[row];
|
|
2594
|
-
const lineY2 = rowYPositions[row] + scaledRowHeights[row];
|
|
2595
|
-
ctx.beginPath();
|
|
2596
|
-
ctx.moveTo(mmToScreenX(centerX), mmToScreenY(lineY1));
|
|
2597
|
-
ctx.lineTo(mmToScreenX(centerX), mmToScreenY(lineY2));
|
|
2598
|
-
ctx.stroke();
|
|
2599
|
-
}
|
|
2600
|
-
}
|
|
2601
|
-
|
|
2602
|
-
// Render field text - scale proportionally with zoom
|
|
2603
|
-
for (const [row, fields] of fieldsByRow) {
|
|
2604
|
-
const rowY = rowYPositions[row];
|
|
2605
|
-
if (rowY === undefined) continue;
|
|
2606
|
-
|
|
2607
|
-
const rowH = scaledRowHeights[row] ?? 5;
|
|
2608
|
-
const screenRowH = mmToScreen(rowH);
|
|
2609
|
-
|
|
2610
|
-
// Skip if row is too small to be readable
|
|
2611
|
-
if (screenRowH < 4) continue;
|
|
2612
|
-
|
|
2613
|
-
for (const field of fields) {
|
|
2614
|
-
const col = field.col ?? 0;
|
|
2615
|
-
const fieldX = gridStartX + col * colWidth + 1.5;
|
|
2616
|
-
|
|
2617
|
-
// Calculate font sizes in mm (accounting for compressed rows)
|
|
2618
|
-
const effectiveScale = rowScaleFactor < 1 ? rowScaleFactor : 1;
|
|
2619
|
-
const labelFontMm = Math.min(field.fontSize * 0.45, 2.2) * Math.max(effectiveScale, 0.7);
|
|
2620
|
-
const valueFontMm = field.fontSize * Math.max(effectiveScale, 0.7);
|
|
2621
|
-
|
|
2622
|
-
// Convert to screen pixels - scales naturally with zoom
|
|
2623
|
-
const screenLabelFont = mmToScreen(labelFontMm);
|
|
2624
|
-
const screenValueFont = mmToScreen(valueFontMm);
|
|
2625
|
-
|
|
2626
|
-
// Skip if too small to read
|
|
2627
|
-
if (screenLabelFont < 3) continue;
|
|
2628
|
-
|
|
2629
|
-
const screenRowY = mmToScreenY(rowY);
|
|
2630
|
-
const screenFieldX = mmToScreenX(fieldX);
|
|
2631
|
-
|
|
2632
|
-
// Label
|
|
2633
|
-
ctx.font = `${screenLabelFont}px Arial, sans-serif`;
|
|
2634
|
-
ctx.fillStyle = '#666666';
|
|
2635
|
-
ctx.textAlign = 'left';
|
|
2636
|
-
ctx.textBaseline = 'top';
|
|
2637
|
-
ctx.fillText(field.label, screenFieldX, screenRowY + mmToScreen(0.3));
|
|
2638
|
-
|
|
2639
|
-
// Value below label (spacing in mm, converted to screen)
|
|
2640
|
-
const valueY = screenRowY + mmToScreen(labelFontMm + 0.5);
|
|
2641
|
-
ctx.font = `${field.fontWeight === 'bold' ? 'bold ' : ''}${screenValueFont}px Arial, sans-serif`;
|
|
2642
|
-
ctx.fillStyle = '#000000';
|
|
2643
|
-
ctx.fillText(field.value, screenFieldX, valueY);
|
|
2644
|
-
}
|
|
2645
|
-
}
|
|
2646
|
-
|
|
2647
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
2648
|
-
// 4. Clip to viewport and draw model content
|
|
2649
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
2650
|
-
ctx.save();
|
|
2651
|
-
|
|
2652
|
-
// Create clip region for viewport
|
|
2653
|
-
ctx.beginPath();
|
|
2654
|
-
ctx.rect(
|
|
2655
|
-
mmToScreenX(viewport.x),
|
|
2656
|
-
mmToScreenY(viewport.y),
|
|
2657
|
-
mmToScreen(viewport.width),
|
|
2658
|
-
mmToScreen(viewport.height)
|
|
2659
|
-
);
|
|
2660
|
-
ctx.clip();
|
|
2661
|
-
|
|
2662
|
-
// Calculate drawing transform to fit in viewport
|
|
2663
|
-
const drawingBounds = {
|
|
2664
|
-
minX: drawing.bounds.min.x,
|
|
2665
|
-
minY: drawing.bounds.min.y,
|
|
2666
|
-
maxX: drawing.bounds.max.x,
|
|
2667
|
-
maxY: drawing.bounds.max.y,
|
|
2668
|
-
};
|
|
2669
|
-
|
|
2670
|
-
// Axis-specific flipping
|
|
2671
|
-
const flipY = sectionAxis !== 'down';
|
|
2672
|
-
const flipX = sectionAxis === 'side';
|
|
2673
|
-
|
|
2674
|
-
// Use cached transform when pinned, otherwise calculate new one
|
|
2675
|
-
let drawingTransform: { translateX: number; translateY: number; scaleFactor: number };
|
|
2676
|
-
|
|
2677
|
-
if (isPinned && cachedSheetTransformRef?.current) {
|
|
2678
|
-
// Use cached transform to keep model fixed in place
|
|
2679
|
-
drawingTransform = cachedSheetTransformRef.current;
|
|
2680
|
-
} else {
|
|
2681
|
-
// Calculate new transform
|
|
2682
|
-
const baseTransform = calculateDrawingTransform(drawingBounds, viewport, activeSheet.scale);
|
|
2683
|
-
|
|
2684
|
-
// Adjust for axis-specific flipping
|
|
2685
|
-
// calculateDrawingTransform assumes Y-flip (uses maxY), but for 'down' view we don't flip Y
|
|
2686
|
-
drawingTransform = {
|
|
2687
|
-
...baseTransform,
|
|
2688
|
-
translateY: flipY
|
|
2689
|
-
? baseTransform.translateY
|
|
2690
|
-
: baseTransform.translateY - (drawingBounds.maxY + drawingBounds.minY) * baseTransform.scaleFactor,
|
|
2691
|
-
};
|
|
2692
|
-
|
|
2693
|
-
// Cache the transform for pinned mode
|
|
2694
|
-
if (cachedSheetTransformRef) {
|
|
2695
|
-
cachedSheetTransformRef.current = drawingTransform;
|
|
2696
|
-
}
|
|
2697
|
-
}
|
|
2698
|
-
|
|
2699
|
-
// Apply combined transform: sheet mm -> screen, then drawing coords -> sheet mm
|
|
2700
|
-
// Drawing coord (meters) * scaleFactor = sheet mm, + translateX/Y
|
|
2701
|
-
// Then sheet mm -> screen via mmToScreenX/Y
|
|
2702
|
-
const drawModelContent = () => {
|
|
2703
|
-
// Determine flip behavior based on section axis
|
|
2704
|
-
// - 'down' (plan view): DON'T flip Y so north (Z+) is up
|
|
2705
|
-
// - 'front' and 'side': flip Y so height (Y+) is up
|
|
2706
|
-
// - 'side': also flip X to look from conventional direction
|
|
2707
|
-
|
|
2708
|
-
// For each polygon/line, transform from model coords to screen coords
|
|
2709
|
-
const modelToScreen = (x: number, y: number) => {
|
|
2710
|
-
// Apply axis-specific flipping
|
|
2711
|
-
const adjustedX = flipX ? -x : x;
|
|
2712
|
-
const adjustedY = flipY ? -y : y;
|
|
2713
|
-
// Model to sheet mm
|
|
2714
|
-
const sheetX = adjustedX * drawingTransform.scaleFactor + drawingTransform.translateX;
|
|
2715
|
-
const sheetY = adjustedY * drawingTransform.scaleFactor + drawingTransform.translateY;
|
|
2716
|
-
// Sheet mm to screen
|
|
2717
|
-
return { x: mmToScreenX(sheetX), y: mmToScreenY(sheetY) };
|
|
2718
|
-
};
|
|
2719
|
-
|
|
2720
|
-
// Line width in screen pixels (convert mm to screen)
|
|
2721
|
-
const mmLineToScreen = (mmWeight: number) => Math.max(0.5, mmToScreen(mmWeight / drawingTransform.scaleFactor * 0.001));
|
|
2722
|
-
|
|
2723
|
-
// Fill cut polygons
|
|
2724
|
-
for (const polygon of drawing.cutPolygons) {
|
|
2725
|
-
let fillColor = getFillColorForType(polygon.ifcType);
|
|
2726
|
-
let opacity = 1;
|
|
2727
|
-
|
|
2728
|
-
if (useIfcMaterials) {
|
|
2729
|
-
const materialColor = entityColorMap.get(polygon.entityId);
|
|
2730
|
-
if (materialColor) {
|
|
2731
|
-
const r = Math.round(materialColor[0] * 255);
|
|
2732
|
-
const g = Math.round(materialColor[1] * 255);
|
|
2733
|
-
const b = Math.round(materialColor[2] * 255);
|
|
2734
|
-
fillColor = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
|
2735
|
-
opacity = materialColor[3];
|
|
2736
|
-
}
|
|
2737
|
-
} else if (overridesEnabled) {
|
|
2738
|
-
const elementData: ElementData = {
|
|
2739
|
-
expressId: polygon.entityId,
|
|
2740
|
-
ifcType: polygon.ifcType,
|
|
2741
|
-
};
|
|
2742
|
-
const result = overrideEngine.applyOverrides(elementData);
|
|
2743
|
-
fillColor = result.style.fillColor;
|
|
2744
|
-
opacity = result.style.opacity;
|
|
2745
|
-
}
|
|
2746
|
-
|
|
2747
|
-
ctx.globalAlpha = opacity;
|
|
2748
|
-
ctx.fillStyle = fillColor;
|
|
2749
|
-
ctx.beginPath();
|
|
2750
|
-
|
|
2751
|
-
if (polygon.polygon.outer.length > 0) {
|
|
2752
|
-
const first = modelToScreen(polygon.polygon.outer[0].x, polygon.polygon.outer[0].y);
|
|
2753
|
-
ctx.moveTo(first.x, first.y);
|
|
2754
|
-
for (let i = 1; i < polygon.polygon.outer.length; i++) {
|
|
2755
|
-
const pt = modelToScreen(polygon.polygon.outer[i].x, polygon.polygon.outer[i].y);
|
|
2756
|
-
ctx.lineTo(pt.x, pt.y);
|
|
2757
|
-
}
|
|
2758
|
-
ctx.closePath();
|
|
2759
|
-
|
|
2760
|
-
for (const hole of polygon.polygon.holes) {
|
|
2761
|
-
if (hole.length > 0) {
|
|
2762
|
-
const holeFirst = modelToScreen(hole[0].x, hole[0].y);
|
|
2763
|
-
ctx.moveTo(holeFirst.x, holeFirst.y);
|
|
2764
|
-
for (let i = 1; i < hole.length; i++) {
|
|
2765
|
-
const pt = modelToScreen(hole[i].x, hole[i].y);
|
|
2766
|
-
ctx.lineTo(pt.x, pt.y);
|
|
2767
|
-
}
|
|
2768
|
-
ctx.closePath();
|
|
2769
|
-
}
|
|
2770
|
-
}
|
|
2771
|
-
}
|
|
2772
|
-
ctx.fill('evenodd');
|
|
2773
|
-
ctx.globalAlpha = 1;
|
|
2774
|
-
}
|
|
2775
|
-
|
|
2776
|
-
// Stroke polygon outlines
|
|
2777
|
-
for (const polygon of drawing.cutPolygons) {
|
|
2778
|
-
let strokeColor = '#000000';
|
|
2779
|
-
let lineWeight = 0.5;
|
|
2780
|
-
|
|
2781
|
-
if (overridesEnabled) {
|
|
2782
|
-
const elementData: ElementData = {
|
|
2783
|
-
expressId: polygon.entityId,
|
|
2784
|
-
ifcType: polygon.ifcType,
|
|
2785
|
-
};
|
|
2786
|
-
const result = overrideEngine.applyOverrides(elementData);
|
|
2787
|
-
strokeColor = result.style.strokeColor;
|
|
2788
|
-
lineWeight = result.style.lineWeight;
|
|
2789
|
-
}
|
|
2790
|
-
|
|
2791
|
-
ctx.strokeStyle = strokeColor;
|
|
2792
|
-
ctx.lineWidth = Math.max(0.5, mmToScreen(lineWeight) * 0.3);
|
|
2793
|
-
ctx.beginPath();
|
|
2794
|
-
|
|
2795
|
-
if (polygon.polygon.outer.length > 0) {
|
|
2796
|
-
const first = modelToScreen(polygon.polygon.outer[0].x, polygon.polygon.outer[0].y);
|
|
2797
|
-
ctx.moveTo(first.x, first.y);
|
|
2798
|
-
for (let i = 1; i < polygon.polygon.outer.length; i++) {
|
|
2799
|
-
const pt = modelToScreen(polygon.polygon.outer[i].x, polygon.polygon.outer[i].y);
|
|
2800
|
-
ctx.lineTo(pt.x, pt.y);
|
|
2801
|
-
}
|
|
2802
|
-
ctx.closePath();
|
|
2803
|
-
|
|
2804
|
-
for (const hole of polygon.polygon.holes) {
|
|
2805
|
-
if (hole.length > 0) {
|
|
2806
|
-
const holeFirst = modelToScreen(hole[0].x, hole[0].y);
|
|
2807
|
-
ctx.moveTo(holeFirst.x, holeFirst.y);
|
|
2808
|
-
for (let i = 1; i < hole.length; i++) {
|
|
2809
|
-
const pt = modelToScreen(hole[i].x, hole[i].y);
|
|
2810
|
-
ctx.lineTo(pt.x, pt.y);
|
|
2811
|
-
}
|
|
2812
|
-
ctx.closePath();
|
|
2813
|
-
}
|
|
2814
|
-
}
|
|
2815
|
-
}
|
|
2816
|
-
ctx.stroke();
|
|
2817
|
-
}
|
|
2818
|
-
|
|
2819
|
-
// Draw lines (projection, silhouette, etc.)
|
|
2820
|
-
const lineBounds = drawing.bounds;
|
|
2821
|
-
const lineMargin = Math.max(lineBounds.max.x - lineBounds.min.x, lineBounds.max.y - lineBounds.min.y) * 0.5;
|
|
2822
|
-
const lineMinX = lineBounds.min.x - lineMargin;
|
|
2823
|
-
const lineMaxX = lineBounds.max.x + lineMargin;
|
|
2824
|
-
const lineMinY = lineBounds.min.y - lineMargin;
|
|
2825
|
-
const lineMaxY = lineBounds.max.y + lineMargin;
|
|
2826
|
-
|
|
2827
|
-
for (const line of drawing.lines) {
|
|
2828
|
-
if (line.category === 'cut') continue;
|
|
2829
|
-
if (!showHiddenLines && line.visibility === 'hidden') continue;
|
|
2830
|
-
|
|
2831
|
-
const { start, end } = line.line;
|
|
2832
|
-
if (!isFinite(start.x) || !isFinite(start.y) || !isFinite(end.x) || !isFinite(end.y)) continue;
|
|
2833
|
-
if (start.x < lineMinX || start.x > lineMaxX || start.y < lineMinY || start.y > lineMaxY ||
|
|
2834
|
-
end.x < lineMinX || end.x > lineMaxX || end.y < lineMinY || end.y > lineMaxY) continue;
|
|
2835
|
-
|
|
2836
|
-
let strokeColor = '#000000';
|
|
2837
|
-
let lineWidth = 0.25;
|
|
2838
|
-
let dashPattern: number[] = [];
|
|
2839
|
-
|
|
2840
|
-
switch (line.category) {
|
|
2841
|
-
case 'projection': lineWidth = 0.25; break;
|
|
2842
|
-
case 'hidden': lineWidth = 0.18; strokeColor = '#666666'; dashPattern = [4, 2]; break;
|
|
2843
|
-
case 'silhouette': lineWidth = 0.35; break;
|
|
2844
|
-
case 'crease': lineWidth = 0.18; break;
|
|
2845
|
-
case 'boundary': lineWidth = 0.25; break;
|
|
2846
|
-
case 'annotation': lineWidth = 0.13; break;
|
|
2847
|
-
}
|
|
2848
|
-
|
|
2849
|
-
if (line.visibility === 'hidden') {
|
|
2850
|
-
strokeColor = '#888888';
|
|
2851
|
-
dashPattern = [4, 2];
|
|
2852
|
-
lineWidth *= 0.7;
|
|
2853
|
-
}
|
|
2854
|
-
|
|
2855
|
-
ctx.strokeStyle = strokeColor;
|
|
2856
|
-
ctx.lineWidth = Math.max(0.5, mmToScreen(lineWidth) * 0.3);
|
|
2857
|
-
ctx.setLineDash(dashPattern);
|
|
2858
|
-
|
|
2859
|
-
const screenStart = modelToScreen(start.x, start.y);
|
|
2860
|
-
const screenEnd = modelToScreen(end.x, end.y);
|
|
2861
|
-
|
|
2862
|
-
ctx.beginPath();
|
|
2863
|
-
ctx.moveTo(screenStart.x, screenStart.y);
|
|
2864
|
-
ctx.lineTo(screenEnd.x, screenEnd.y);
|
|
2865
|
-
ctx.stroke();
|
|
2866
|
-
ctx.setLineDash([]);
|
|
2867
|
-
}
|
|
2868
|
-
};
|
|
2869
|
-
|
|
2870
|
-
drawModelContent();
|
|
2871
|
-
ctx.restore();
|
|
2872
|
-
|
|
2873
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
2874
|
-
// 6. Draw scale bar at BOTTOM LEFT of title block
|
|
2875
|
-
// Uses actual drawingTransform.scaleFactor which accounts for dynamic scaling
|
|
2876
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
2877
|
-
if (scaleBar.visible && tbH > 10) {
|
|
2878
|
-
// Position: bottom left with small margin
|
|
2879
|
-
const sbX = tbX + 3;
|
|
2880
|
-
const sbY = tbY + tbH - 8; // 8mm from bottom (leaves room for label)
|
|
2881
|
-
|
|
2882
|
-
// Calculate effective scale from the actual drawing transform
|
|
2883
|
-
// scaleFactor = mm per meter, so effective scale ratio = 1000 / scaleFactor
|
|
2884
|
-
const effectiveScaleFactor = drawingTransform.scaleFactor;
|
|
2885
|
-
|
|
2886
|
-
// Scale bar length: we want to show a nice round number of meters
|
|
2887
|
-
// Calculate how many mm on paper for the desired real-world length
|
|
2888
|
-
const maxBarWidth = Math.min(tbW * 0.3, 50); // Max 30% of width or 50mm
|
|
2889
|
-
|
|
2890
|
-
// Find a nice round length that fits
|
|
2891
|
-
// Start with the configured length and adjust if needed
|
|
2892
|
-
let targetLengthM = scaleBar.totalLengthM;
|
|
2893
|
-
let sbLengthMm = targetLengthM * effectiveScaleFactor;
|
|
2894
|
-
|
|
2895
|
-
// If bar would be too long, reduce the target length
|
|
2896
|
-
while (sbLengthMm > maxBarWidth && targetLengthM > 0.5) {
|
|
2897
|
-
targetLengthM = targetLengthM / 2;
|
|
2898
|
-
sbLengthMm = targetLengthM * effectiveScaleFactor;
|
|
2899
|
-
}
|
|
2900
|
-
|
|
2901
|
-
// If bar would be too short, increase the target length
|
|
2902
|
-
while (sbLengthMm < maxBarWidth * 0.3 && targetLengthM < 100) {
|
|
2903
|
-
targetLengthM = targetLengthM * 2;
|
|
2904
|
-
sbLengthMm = targetLengthM * effectiveScaleFactor;
|
|
2905
|
-
}
|
|
2906
|
-
|
|
2907
|
-
// Clamp to max width
|
|
2908
|
-
sbLengthMm = Math.min(sbLengthMm, maxBarWidth);
|
|
2909
|
-
|
|
2910
|
-
// Actual length represented by the bar
|
|
2911
|
-
const actualTotalLength = sbLengthMm / effectiveScaleFactor;
|
|
2912
|
-
|
|
2913
|
-
const sbHeight = Math.min(scaleBar.heightMm, 3);
|
|
2914
|
-
|
|
2915
|
-
// Scale bar divisions
|
|
2916
|
-
const divisions = scaleBar.primaryDivisions;
|
|
2917
|
-
const divWidth = sbLengthMm / divisions;
|
|
2918
|
-
for (let i = 0; i < divisions; i++) {
|
|
2919
|
-
ctx.fillStyle = i % 2 === 0 ? scaleBar.fillColor : '#ffffff';
|
|
2920
|
-
ctx.fillRect(
|
|
2921
|
-
mmToScreenX(sbX + i * divWidth),
|
|
2922
|
-
mmToScreenY(sbY),
|
|
2923
|
-
mmToScreen(divWidth),
|
|
2924
|
-
mmToScreen(sbHeight)
|
|
2925
|
-
);
|
|
2926
|
-
}
|
|
2927
|
-
|
|
2928
|
-
// Scale bar border
|
|
2929
|
-
ctx.strokeStyle = scaleBar.strokeColor;
|
|
2930
|
-
ctx.lineWidth = Math.max(1, mmToScreen(scaleBar.lineWeight));
|
|
2931
|
-
ctx.strokeRect(
|
|
2932
|
-
mmToScreenX(sbX),
|
|
2933
|
-
mmToScreenY(sbY),
|
|
2934
|
-
mmToScreen(sbLengthMm),
|
|
2935
|
-
mmToScreen(sbHeight)
|
|
2936
|
-
);
|
|
2937
|
-
|
|
2938
|
-
// Distance labels - only at 0 and end
|
|
2939
|
-
const labelFontSize = Math.max(7, mmToScreen(1.8));
|
|
2940
|
-
ctx.font = `${labelFontSize}px Arial, sans-serif`;
|
|
2941
|
-
ctx.fillStyle = '#000000';
|
|
2942
|
-
ctx.textBaseline = 'top';
|
|
2943
|
-
const labelScreenY = mmToScreenY(sbY + sbHeight) + 1;
|
|
2944
|
-
|
|
2945
|
-
ctx.textAlign = 'left';
|
|
2946
|
-
ctx.fillText('0', mmToScreenX(sbX), labelScreenY);
|
|
2947
|
-
|
|
2948
|
-
ctx.textAlign = 'right';
|
|
2949
|
-
const endLabel = actualTotalLength < 1
|
|
2950
|
-
? `${(actualTotalLength * 100).toFixed(0)}cm`
|
|
2951
|
-
: `${actualTotalLength.toFixed(0)}m`;
|
|
2952
|
-
ctx.fillText(endLabel, mmToScreenX(sbX + sbLengthMm), labelScreenY);
|
|
2953
|
-
}
|
|
2954
|
-
|
|
2955
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
2956
|
-
// 7. Draw north arrow at BOTTOM RIGHT of title block
|
|
2957
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
2958
|
-
if (northArrow.style !== 'none' && tbH > 10) {
|
|
2959
|
-
// Position: bottom right with margin
|
|
2960
|
-
const naSize = Math.min(northArrow.sizeMm, 8, tbH * 0.6);
|
|
2961
|
-
const naX = tbX + tbW - naSize - 5; // Right side with margin
|
|
2962
|
-
const naY = tbY + tbH - naSize / 2 - 3; // Bottom with margin
|
|
2963
|
-
|
|
2964
|
-
ctx.save();
|
|
2965
|
-
ctx.translate(mmToScreenX(naX), mmToScreenY(naY));
|
|
2966
|
-
ctx.rotate((northArrow.rotation * Math.PI) / 180);
|
|
2967
|
-
|
|
2968
|
-
// Draw arrow
|
|
2969
|
-
const arrowLen = mmToScreen(naSize);
|
|
2970
|
-
ctx.fillStyle = '#000000';
|
|
2971
|
-
ctx.beginPath();
|
|
2972
|
-
ctx.moveTo(0, -arrowLen / 2);
|
|
2973
|
-
ctx.lineTo(-arrowLen / 6, arrowLen / 2);
|
|
2974
|
-
ctx.lineTo(0, arrowLen / 3);
|
|
2975
|
-
ctx.lineTo(arrowLen / 6, arrowLen / 2);
|
|
2976
|
-
ctx.closePath();
|
|
2977
|
-
ctx.fill();
|
|
2978
|
-
|
|
2979
|
-
// Draw "N" label
|
|
2980
|
-
const nFontSize = Math.max(8, mmToScreen(2.5));
|
|
2981
|
-
ctx.font = `bold ${nFontSize}px Arial, sans-serif`;
|
|
2982
|
-
ctx.textAlign = 'center';
|
|
2983
|
-
ctx.textBaseline = 'bottom';
|
|
2984
|
-
ctx.fillText('N', 0, -arrowLen / 2 - 1);
|
|
2985
|
-
|
|
2986
|
-
ctx.restore();
|
|
2987
|
-
}
|
|
2988
|
-
|
|
2989
|
-
} else {
|
|
2990
|
-
// ═══════════════════════════════════════════════════════════════════════
|
|
2991
|
-
// NON-SHEET MODE: Original rendering (drawing coords -> screen)
|
|
2992
|
-
// ═══════════════════════════════════════════════════════════════════════
|
|
2993
|
-
|
|
2994
|
-
// Apply transform with axis-specific flipping
|
|
2995
|
-
// - 'down' (plan view): DON'T flip Y so north (Z+) is up
|
|
2996
|
-
// - 'front' and 'side': flip Y so height (Y+) is up
|
|
2997
|
-
// - 'side': also flip X to look from conventional direction
|
|
2998
|
-
const scaleX = sectionAxis === 'side' ? -transform.scale : transform.scale;
|
|
2999
|
-
const scaleY = sectionAxis === 'down' ? transform.scale : -transform.scale;
|
|
3000
|
-
|
|
3001
|
-
ctx.save();
|
|
3002
|
-
ctx.translate(transform.x, transform.y);
|
|
3003
|
-
ctx.scale(scaleX, scaleY);
|
|
3004
|
-
|
|
3005
|
-
// ═══════════════════════════════════════════════════════════════════════
|
|
3006
|
-
// 1. FILL CUT POLYGONS (with color from IFC materials, override engine, or type fallback)
|
|
3007
|
-
// ═══════════════════════════════════════════════════════════════════════
|
|
3008
|
-
for (const polygon of drawing.cutPolygons) {
|
|
3009
|
-
// Get fill color - priority: IFC materials > override engine > IFC type fallback
|
|
3010
|
-
let fillColor = getFillColorForType(polygon.ifcType);
|
|
3011
|
-
let strokeColor = '#000000';
|
|
3012
|
-
let opacity = 1;
|
|
3013
|
-
|
|
3014
|
-
// Use actual IFC material colors from the mesh data
|
|
3015
|
-
if (useIfcMaterials) {
|
|
3016
|
-
const materialColor = entityColorMap.get(polygon.entityId);
|
|
3017
|
-
if (materialColor) {
|
|
3018
|
-
// Convert RGBA [0-1] to hex color
|
|
3019
|
-
const r = Math.round(materialColor[0] * 255);
|
|
3020
|
-
const g = Math.round(materialColor[1] * 255);
|
|
3021
|
-
const b = Math.round(materialColor[2] * 255);
|
|
3022
|
-
fillColor = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
|
3023
|
-
opacity = materialColor[3];
|
|
3024
|
-
}
|
|
3025
|
-
} else if (overridesEnabled) {
|
|
3026
|
-
const elementData: ElementData = {
|
|
3027
|
-
expressId: polygon.entityId,
|
|
3028
|
-
ifcType: polygon.ifcType,
|
|
3029
|
-
};
|
|
3030
|
-
const result = overrideEngine.applyOverrides(elementData);
|
|
3031
|
-
fillColor = result.style.fillColor;
|
|
3032
|
-
strokeColor = result.style.strokeColor;
|
|
3033
|
-
opacity = result.style.opacity;
|
|
3034
|
-
}
|
|
3035
|
-
|
|
3036
|
-
ctx.globalAlpha = opacity;
|
|
3037
|
-
ctx.fillStyle = fillColor;
|
|
3038
|
-
ctx.beginPath();
|
|
3039
|
-
if (polygon.polygon.outer.length > 0) {
|
|
3040
|
-
ctx.moveTo(polygon.polygon.outer[0].x, polygon.polygon.outer[0].y);
|
|
3041
|
-
for (let i = 1; i < polygon.polygon.outer.length; i++) {
|
|
3042
|
-
ctx.lineTo(polygon.polygon.outer[i].x, polygon.polygon.outer[i].y);
|
|
3043
|
-
}
|
|
3044
|
-
ctx.closePath();
|
|
3045
|
-
|
|
3046
|
-
// Draw holes (inner boundaries)
|
|
3047
|
-
for (const hole of polygon.polygon.holes) {
|
|
3048
|
-
if (hole.length > 0) {
|
|
3049
|
-
ctx.moveTo(hole[0].x, hole[0].y);
|
|
3050
|
-
for (let i = 1; i < hole.length; i++) {
|
|
3051
|
-
ctx.lineTo(hole[i].x, hole[i].y);
|
|
3052
|
-
}
|
|
3053
|
-
ctx.closePath();
|
|
3054
|
-
}
|
|
3055
|
-
}
|
|
3056
|
-
}
|
|
3057
|
-
ctx.fill('evenodd');
|
|
3058
|
-
ctx.globalAlpha = 1;
|
|
3059
|
-
}
|
|
3060
|
-
|
|
3061
|
-
// ═══════════════════════════════════════════════════════════════════════
|
|
3062
|
-
// 2. STROKE CUT POLYGON OUTLINES (with color from override engine)
|
|
3063
|
-
// ═══════════════════════════════════════════════════════════════════════
|
|
3064
|
-
for (const polygon of drawing.cutPolygons) {
|
|
3065
|
-
let strokeColor = '#000000';
|
|
3066
|
-
let lineWeight = 0.5;
|
|
3067
|
-
|
|
3068
|
-
if (overridesEnabled) {
|
|
3069
|
-
const elementData: ElementData = {
|
|
3070
|
-
expressId: polygon.entityId,
|
|
3071
|
-
ifcType: polygon.ifcType,
|
|
3072
|
-
};
|
|
3073
|
-
const result = overrideEngine.applyOverrides(elementData);
|
|
3074
|
-
strokeColor = result.style.strokeColor;
|
|
3075
|
-
lineWeight = result.style.lineWeight;
|
|
3076
|
-
}
|
|
3077
|
-
|
|
3078
|
-
ctx.strokeStyle = strokeColor;
|
|
3079
|
-
ctx.lineWidth = lineWeight / transform.scale;
|
|
3080
|
-
ctx.beginPath();
|
|
3081
|
-
if (polygon.polygon.outer.length > 0) {
|
|
3082
|
-
ctx.moveTo(polygon.polygon.outer[0].x, polygon.polygon.outer[0].y);
|
|
3083
|
-
for (let i = 1; i < polygon.polygon.outer.length; i++) {
|
|
3084
|
-
ctx.lineTo(polygon.polygon.outer[i].x, polygon.polygon.outer[i].y);
|
|
3085
|
-
}
|
|
3086
|
-
ctx.closePath();
|
|
3087
|
-
|
|
3088
|
-
// Stroke holes too
|
|
3089
|
-
for (const hole of polygon.polygon.holes) {
|
|
3090
|
-
if (hole.length > 0) {
|
|
3091
|
-
ctx.moveTo(hole[0].x, hole[0].y);
|
|
3092
|
-
for (let i = 1; i < hole.length; i++) {
|
|
3093
|
-
ctx.lineTo(hole[i].x, hole[i].y);
|
|
3094
|
-
}
|
|
3095
|
-
ctx.closePath();
|
|
3096
|
-
}
|
|
3097
|
-
}
|
|
3098
|
-
}
|
|
3099
|
-
ctx.stroke();
|
|
3100
|
-
}
|
|
3101
|
-
|
|
3102
|
-
// ═══════════════════════════════════════════════════════════════════════
|
|
3103
|
-
// 3. DRAW PROJECTION/SILHOUETTE LINES (skip 'cut' - already in polygons)
|
|
3104
|
-
// ═══════════════════════════════════════════════════════════════════════
|
|
3105
|
-
// Pre-compute bounds for line validation
|
|
3106
|
-
const lineBounds = drawing.bounds;
|
|
3107
|
-
const lineMargin = Math.max(lineBounds.max.x - lineBounds.min.x, lineBounds.max.y - lineBounds.min.y) * 0.5;
|
|
3108
|
-
const lineMinX = lineBounds.min.x - lineMargin;
|
|
3109
|
-
const lineMaxX = lineBounds.max.x + lineMargin;
|
|
3110
|
-
const lineMinY = lineBounds.min.y - lineMargin;
|
|
3111
|
-
const lineMaxY = lineBounds.max.y + lineMargin;
|
|
3112
|
-
|
|
3113
|
-
for (const line of drawing.lines) {
|
|
3114
|
-
// Skip 'cut' lines - they're triangulation edges, already handled by polygons
|
|
3115
|
-
if (line.category === 'cut') continue;
|
|
3116
|
-
|
|
3117
|
-
// Skip hidden lines if not showing
|
|
3118
|
-
if (!showHiddenLines && line.visibility === 'hidden') continue;
|
|
3119
|
-
|
|
3120
|
-
// Skip lines with invalid coordinates (NaN, Infinity, or far outside bounds)
|
|
3121
|
-
const { start, end } = line.line;
|
|
3122
|
-
if (!isFinite(start.x) || !isFinite(start.y) || !isFinite(end.x) || !isFinite(end.y)) {
|
|
3123
|
-
continue;
|
|
3124
|
-
}
|
|
3125
|
-
if (start.x < lineMinX || start.x > lineMaxX || start.y < lineMinY || start.y > lineMaxY ||
|
|
3126
|
-
end.x < lineMinX || end.x > lineMaxX || end.y < lineMinY || end.y > lineMaxY) {
|
|
3127
|
-
continue;
|
|
3128
|
-
}
|
|
3129
|
-
|
|
3130
|
-
// Set line style based on category
|
|
3131
|
-
let strokeColor = '#000000';
|
|
3132
|
-
let lineWidth = 0.25;
|
|
3133
|
-
let dashPattern: number[] = [];
|
|
3134
|
-
|
|
3135
|
-
switch (line.category) {
|
|
3136
|
-
case 'projection':
|
|
3137
|
-
lineWidth = 0.25;
|
|
3138
|
-
strokeColor = '#000000';
|
|
3139
|
-
break;
|
|
3140
|
-
case 'hidden':
|
|
3141
|
-
lineWidth = 0.18;
|
|
3142
|
-
strokeColor = '#666666';
|
|
3143
|
-
dashPattern = [2, 1];
|
|
3144
|
-
break;
|
|
3145
|
-
case 'silhouette':
|
|
3146
|
-
lineWidth = 0.35;
|
|
3147
|
-
strokeColor = '#000000';
|
|
3148
|
-
break;
|
|
3149
|
-
case 'crease':
|
|
3150
|
-
lineWidth = 0.18;
|
|
3151
|
-
strokeColor = '#000000';
|
|
3152
|
-
break;
|
|
3153
|
-
case 'boundary':
|
|
3154
|
-
lineWidth = 0.25;
|
|
3155
|
-
strokeColor = '#000000';
|
|
3156
|
-
break;
|
|
3157
|
-
case 'annotation':
|
|
3158
|
-
lineWidth = 0.13;
|
|
3159
|
-
strokeColor = '#000000';
|
|
3160
|
-
break;
|
|
3161
|
-
}
|
|
3162
|
-
|
|
3163
|
-
// Hidden visibility overrides
|
|
3164
|
-
if (line.visibility === 'hidden') {
|
|
3165
|
-
strokeColor = '#888888';
|
|
3166
|
-
dashPattern = [2, 1];
|
|
3167
|
-
lineWidth *= 0.7;
|
|
3168
|
-
}
|
|
3169
|
-
|
|
3170
|
-
ctx.strokeStyle = strokeColor;
|
|
3171
|
-
ctx.lineWidth = lineWidth / transform.scale;
|
|
3172
|
-
ctx.setLineDash(dashPattern.map((d) => d / transform.scale));
|
|
3173
|
-
|
|
3174
|
-
ctx.beginPath();
|
|
3175
|
-
ctx.moveTo(line.line.start.x, line.line.start.y);
|
|
3176
|
-
ctx.lineTo(line.line.end.x, line.line.end.y);
|
|
3177
|
-
ctx.stroke();
|
|
3178
|
-
|
|
3179
|
-
ctx.setLineDash([]);
|
|
3180
|
-
}
|
|
3181
|
-
|
|
3182
|
-
ctx.restore();
|
|
3183
|
-
}
|
|
3184
|
-
|
|
3185
|
-
// ═══════════════════════════════════════════════════════════════════════
|
|
3186
|
-
// 4. RENDER MEASUREMENTS (in screen space)
|
|
3187
|
-
// ═══════════════════════════════════════════════════════════════════════
|
|
3188
|
-
const drawMeasureLine = (
|
|
3189
|
-
start: { x: number; y: number },
|
|
3190
|
-
end: { x: number; y: number },
|
|
3191
|
-
distance: number,
|
|
3192
|
-
color: string = '#2196F3',
|
|
3193
|
-
isActive: boolean = false
|
|
3194
|
-
) => {
|
|
3195
|
-
// Convert drawing coords to screen coords with axis-specific transforms
|
|
3196
|
-
const measureScaleX = sectionAxis === 'side' ? -transform.scale : transform.scale;
|
|
3197
|
-
const measureScaleY = sectionAxis === 'down' ? transform.scale : -transform.scale;
|
|
3198
|
-
const screenStart = {
|
|
3199
|
-
x: start.x * measureScaleX + transform.x,
|
|
3200
|
-
y: start.y * measureScaleY + transform.y,
|
|
3201
|
-
};
|
|
3202
|
-
const screenEnd = {
|
|
3203
|
-
x: end.x * measureScaleX + transform.x,
|
|
3204
|
-
y: end.y * measureScaleY + transform.y,
|
|
3205
|
-
};
|
|
3206
|
-
|
|
3207
|
-
// Draw line
|
|
3208
|
-
ctx.strokeStyle = color;
|
|
3209
|
-
ctx.lineWidth = isActive ? 2 : 1.5;
|
|
3210
|
-
ctx.setLineDash(isActive ? [6, 3] : []);
|
|
3211
|
-
ctx.beginPath();
|
|
3212
|
-
ctx.moveTo(screenStart.x, screenStart.y);
|
|
3213
|
-
ctx.lineTo(screenEnd.x, screenEnd.y);
|
|
3214
|
-
ctx.stroke();
|
|
3215
|
-
ctx.setLineDash([]);
|
|
3216
|
-
|
|
3217
|
-
// Draw endpoints
|
|
3218
|
-
ctx.fillStyle = color;
|
|
3219
|
-
const endpointRadius = isActive ? 5 : 4;
|
|
3220
|
-
ctx.beginPath();
|
|
3221
|
-
ctx.arc(screenStart.x, screenStart.y, endpointRadius, 0, Math.PI * 2);
|
|
3222
|
-
ctx.fill();
|
|
3223
|
-
ctx.beginPath();
|
|
3224
|
-
ctx.arc(screenEnd.x, screenEnd.y, endpointRadius, 0, Math.PI * 2);
|
|
3225
|
-
ctx.fill();
|
|
3226
|
-
|
|
3227
|
-
// Draw distance label
|
|
3228
|
-
const midX = (screenStart.x + screenEnd.x) / 2;
|
|
3229
|
-
const midY = (screenStart.y + screenEnd.y) / 2;
|
|
3230
|
-
|
|
3231
|
-
// Format distance (assuming meters, convert to readable units)
|
|
3232
|
-
let labelText: string;
|
|
3233
|
-
if (distance < 0.01) {
|
|
3234
|
-
labelText = `${(distance * 1000).toFixed(1)} mm`;
|
|
3235
|
-
} else if (distance < 1) {
|
|
3236
|
-
labelText = `${(distance * 100).toFixed(1)} cm`;
|
|
3237
|
-
} else {
|
|
3238
|
-
labelText = `${distance.toFixed(3)} m`;
|
|
3239
|
-
}
|
|
3240
|
-
|
|
3241
|
-
// Background for label
|
|
3242
|
-
ctx.font = '12px system-ui, sans-serif';
|
|
3243
|
-
const textMetrics = ctx.measureText(labelText);
|
|
3244
|
-
const padding = 4;
|
|
3245
|
-
const bgWidth = textMetrics.width + padding * 2;
|
|
3246
|
-
const bgHeight = 18;
|
|
3247
|
-
|
|
3248
|
-
ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
|
|
3249
|
-
ctx.fillRect(midX - bgWidth / 2, midY - bgHeight / 2, bgWidth, bgHeight);
|
|
3250
|
-
ctx.strokeStyle = color;
|
|
3251
|
-
ctx.lineWidth = 1;
|
|
3252
|
-
ctx.strokeRect(midX - bgWidth / 2, midY - bgHeight / 2, bgWidth, bgHeight);
|
|
3253
|
-
|
|
3254
|
-
// Text
|
|
3255
|
-
ctx.fillStyle = '#000000';
|
|
3256
|
-
ctx.textAlign = 'center';
|
|
3257
|
-
ctx.textBaseline = 'middle';
|
|
3258
|
-
ctx.fillText(labelText, midX, midY);
|
|
3259
|
-
};
|
|
3260
|
-
|
|
3261
|
-
// Draw completed measurements
|
|
3262
|
-
for (const result of measureResults) {
|
|
3263
|
-
drawMeasureLine(result.start, result.end, result.distance, '#2196F3', false);
|
|
3264
|
-
}
|
|
3265
|
-
|
|
3266
|
-
// Draw active measurement
|
|
3267
|
-
if (measureStart && measureCurrent) {
|
|
3268
|
-
const dx = measureCurrent.x - measureStart.x;
|
|
3269
|
-
const dy = measureCurrent.y - measureStart.y;
|
|
3270
|
-
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
3271
|
-
drawMeasureLine(measureStart, measureCurrent, distance, '#FF5722', true);
|
|
3272
|
-
}
|
|
3273
|
-
|
|
3274
|
-
// Draw snap indicator
|
|
3275
|
-
if (measureMode && measureSnapPoint) {
|
|
3276
|
-
// Use axis-specific transforms (matching canvas rendering)
|
|
3277
|
-
const snapScaleX = sectionAxis === 'side' ? -transform.scale : transform.scale;
|
|
3278
|
-
const snapScaleY = sectionAxis === 'down' ? transform.scale : -transform.scale;
|
|
3279
|
-
const screenSnap = {
|
|
3280
|
-
x: measureSnapPoint.x * snapScaleX + transform.x,
|
|
3281
|
-
y: measureSnapPoint.y * snapScaleY + transform.y,
|
|
3282
|
-
};
|
|
3283
|
-
|
|
3284
|
-
// Draw snap crosshair
|
|
3285
|
-
ctx.strokeStyle = '#4CAF50';
|
|
3286
|
-
ctx.lineWidth = 1.5;
|
|
3287
|
-
const snapSize = 12;
|
|
3288
|
-
|
|
3289
|
-
ctx.beginPath();
|
|
3290
|
-
ctx.moveTo(screenSnap.x - snapSize, screenSnap.y);
|
|
3291
|
-
ctx.lineTo(screenSnap.x + snapSize, screenSnap.y);
|
|
3292
|
-
ctx.stroke();
|
|
3293
|
-
|
|
3294
|
-
ctx.beginPath();
|
|
3295
|
-
ctx.moveTo(screenSnap.x, screenSnap.y - snapSize);
|
|
3296
|
-
ctx.lineTo(screenSnap.x, screenSnap.y + snapSize);
|
|
3297
|
-
ctx.stroke();
|
|
3298
|
-
|
|
3299
|
-
// Draw snap circle
|
|
3300
|
-
ctx.beginPath();
|
|
3301
|
-
ctx.arc(screenSnap.x, screenSnap.y, 6, 0, Math.PI * 2);
|
|
3302
|
-
ctx.stroke();
|
|
3303
|
-
}
|
|
3304
|
-
}, [drawing, transform, showHiddenLines, canvasSize, overrideEngine, overridesEnabled, entityColorMap, useIfcMaterials, measureMode, measureStart, measureCurrent, measureResults, measureSnapPoint, sheetEnabled, activeSheet, sectionAxis, isPinned]);
|
|
3305
|
-
|
|
3306
|
-
return (
|
|
3307
|
-
<canvas
|
|
3308
|
-
ref={canvasRef}
|
|
3309
|
-
className="w-full h-full"
|
|
3310
|
-
style={CANVAS_STYLE}
|
|
3311
|
-
/>
|
|
3312
|
-
);
|
|
3313
|
-
}
|