@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
|
@@ -0,0 +1,1048 @@
|
|
|
1
|
+
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
|
+
|
|
5
|
+
import React, { useRef, useState, useEffect } from 'react';
|
|
6
|
+
import {
|
|
7
|
+
GraphicOverrideEngine,
|
|
8
|
+
calculateDrawingTransform,
|
|
9
|
+
type Drawing2D,
|
|
10
|
+
type ElementData,
|
|
11
|
+
} from '@ifc-lite/drawing-2d';
|
|
12
|
+
import { formatDistance } from './tools/formatDistance';
|
|
13
|
+
|
|
14
|
+
// Fill colors for IFC types (architectural convention)
|
|
15
|
+
const IFC_TYPE_FILL_COLORS: Record<string, string> = {
|
|
16
|
+
// Structural elements - solid gray
|
|
17
|
+
IfcWall: '#b0b0b0',
|
|
18
|
+
IfcWallStandardCase: '#b0b0b0',
|
|
19
|
+
IfcColumn: '#909090',
|
|
20
|
+
IfcBeam: '#909090',
|
|
21
|
+
IfcSlab: '#c8c8c8',
|
|
22
|
+
IfcRoof: '#d0d0d0',
|
|
23
|
+
IfcFooting: '#808080',
|
|
24
|
+
IfcPile: '#707070',
|
|
25
|
+
|
|
26
|
+
// Windows/Doors - lighter
|
|
27
|
+
IfcWindow: '#e8f4fc',
|
|
28
|
+
IfcDoor: '#f5e6d3',
|
|
29
|
+
|
|
30
|
+
// Stairs/Railings
|
|
31
|
+
IfcStair: '#d8d8d8',
|
|
32
|
+
IfcStairFlight: '#d8d8d8',
|
|
33
|
+
IfcRailing: '#c0c0c0',
|
|
34
|
+
|
|
35
|
+
// MEP - distinct colors
|
|
36
|
+
IfcPipeSegment: '#a0d0ff',
|
|
37
|
+
IfcDuctSegment: '#c0ffc0',
|
|
38
|
+
|
|
39
|
+
// Furniture
|
|
40
|
+
IfcFurnishingElement: '#ffe0c0',
|
|
41
|
+
|
|
42
|
+
// Spaces (usually not shown in section)
|
|
43
|
+
IfcSpace: '#f0f0f0',
|
|
44
|
+
|
|
45
|
+
// Default
|
|
46
|
+
default: '#d0d0d0',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export function getFillColorForType(ifcType: string): string {
|
|
50
|
+
return IFC_TYPE_FILL_COLORS[ifcType] || IFC_TYPE_FILL_COLORS.default;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Static constants to avoid creating new objects/arrays on every render
|
|
54
|
+
const CANVAS_STYLE = { imageRendering: 'crisp-edges' as const };
|
|
55
|
+
const EMPTY_MEASURE_RESULTS: Measure2DResultData[] = [];
|
|
56
|
+
|
|
57
|
+
export interface Measure2DResultData {
|
|
58
|
+
id: string;
|
|
59
|
+
start: { x: number; y: number };
|
|
60
|
+
end: { x: number; y: number };
|
|
61
|
+
distance: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface Drawing2DCanvasProps {
|
|
65
|
+
drawing: Drawing2D;
|
|
66
|
+
transform: { x: number; y: number; scale: number };
|
|
67
|
+
showHiddenLines: boolean;
|
|
68
|
+
overrideEngine: GraphicOverrideEngine;
|
|
69
|
+
overridesEnabled: boolean;
|
|
70
|
+
entityColorMap: Map<number, [number, number, number, number]>;
|
|
71
|
+
useIfcMaterials: boolean;
|
|
72
|
+
// Measure tool props
|
|
73
|
+
measureMode?: boolean;
|
|
74
|
+
measureStart?: { x: number; y: number } | null;
|
|
75
|
+
measureCurrent?: { x: number; y: number } | null;
|
|
76
|
+
measureResults?: Measure2DResultData[];
|
|
77
|
+
measureSnapPoint?: { x: number; y: number } | null;
|
|
78
|
+
// Sheet mode props
|
|
79
|
+
sheetEnabled?: boolean;
|
|
80
|
+
activeSheet?: import('@ifc-lite/drawing-2d').DrawingSheet | null;
|
|
81
|
+
// Section plane info for axis-specific rendering
|
|
82
|
+
sectionAxis: 'down' | 'front' | 'side';
|
|
83
|
+
// Pinned mode - keep model fixed in place on sheet
|
|
84
|
+
isPinned?: boolean;
|
|
85
|
+
cachedSheetTransformRef?: React.MutableRefObject<{ translateX: number; translateY: number; scaleFactor: number } | null>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function Drawing2DCanvas({
|
|
89
|
+
drawing,
|
|
90
|
+
transform,
|
|
91
|
+
showHiddenLines,
|
|
92
|
+
overrideEngine,
|
|
93
|
+
overridesEnabled,
|
|
94
|
+
entityColorMap,
|
|
95
|
+
useIfcMaterials,
|
|
96
|
+
measureMode = false,
|
|
97
|
+
measureStart = null,
|
|
98
|
+
measureCurrent = null,
|
|
99
|
+
measureResults = EMPTY_MEASURE_RESULTS,
|
|
100
|
+
measureSnapPoint = null,
|
|
101
|
+
sheetEnabled = false,
|
|
102
|
+
activeSheet = null,
|
|
103
|
+
sectionAxis,
|
|
104
|
+
isPinned = false,
|
|
105
|
+
cachedSheetTransformRef,
|
|
106
|
+
}: Drawing2DCanvasProps): React.ReactElement {
|
|
107
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
108
|
+
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
|
|
109
|
+
|
|
110
|
+
// ResizeObserver to track canvas size changes
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
const canvas = canvasRef.current;
|
|
113
|
+
if (!canvas) return;
|
|
114
|
+
|
|
115
|
+
const resizeObserver = new ResizeObserver((entries) => {
|
|
116
|
+
for (const entry of entries) {
|
|
117
|
+
const { width, height } = entry.contentRect;
|
|
118
|
+
setCanvasSize((prev) => {
|
|
119
|
+
// Only update if size actually changed to avoid render loops
|
|
120
|
+
if (prev.width !== width || prev.height !== height) {
|
|
121
|
+
return { width, height };
|
|
122
|
+
}
|
|
123
|
+
return prev;
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
resizeObserver.observe(canvas);
|
|
129
|
+
return () => resizeObserver.disconnect();
|
|
130
|
+
}, []);
|
|
131
|
+
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
const canvas = canvasRef.current;
|
|
134
|
+
if (!canvas || canvasSize.width === 0 || canvasSize.height === 0) return;
|
|
135
|
+
|
|
136
|
+
const ctx = canvas.getContext('2d');
|
|
137
|
+
if (!ctx) return;
|
|
138
|
+
|
|
139
|
+
// Set canvas size using tracked dimensions
|
|
140
|
+
const dpr = window.devicePixelRatio || 1;
|
|
141
|
+
canvas.width = canvasSize.width * dpr;
|
|
142
|
+
canvas.height = canvasSize.height * dpr;
|
|
143
|
+
ctx.scale(dpr, dpr);
|
|
144
|
+
|
|
145
|
+
// Clear with light gray background (shows paper edge when in sheet mode)
|
|
146
|
+
ctx.fillStyle = sheetEnabled && activeSheet ? '#e5e5e5' : '#ffffff';
|
|
147
|
+
ctx.fillRect(0, 0, canvasSize.width, canvasSize.height);
|
|
148
|
+
|
|
149
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
150
|
+
// SHEET MODE: Render paper, frame, title block, then drawing in viewport
|
|
151
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
152
|
+
if (sheetEnabled && activeSheet) {
|
|
153
|
+
const paper = activeSheet.paper;
|
|
154
|
+
const frame = activeSheet.frame;
|
|
155
|
+
const titleBlock = activeSheet.titleBlock;
|
|
156
|
+
const viewport = activeSheet.viewportBounds;
|
|
157
|
+
const scaleBar = activeSheet.scaleBar;
|
|
158
|
+
const northArrow = activeSheet.northArrow;
|
|
159
|
+
|
|
160
|
+
// Helper: convert sheet mm to screen pixels
|
|
161
|
+
const mmToScreen = (mm: number) => mm * transform.scale;
|
|
162
|
+
const mmToScreenX = (x: number) => x * transform.scale + transform.x;
|
|
163
|
+
const mmToScreenY = (y: number) => y * transform.scale + transform.y;
|
|
164
|
+
|
|
165
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
166
|
+
// 1. Draw paper background (white with shadow)
|
|
167
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
168
|
+
ctx.save();
|
|
169
|
+
// Paper shadow
|
|
170
|
+
ctx.shadowColor = 'rgba(0, 0, 0, 0.2)';
|
|
171
|
+
ctx.shadowBlur = 10 * (transform.scale > 0.5 ? 1 : transform.scale * 2);
|
|
172
|
+
ctx.shadowOffsetX = 3;
|
|
173
|
+
ctx.shadowOffsetY = 3;
|
|
174
|
+
ctx.fillStyle = '#ffffff';
|
|
175
|
+
ctx.fillRect(
|
|
176
|
+
mmToScreenX(0),
|
|
177
|
+
mmToScreenY(0),
|
|
178
|
+
mmToScreen(paper.widthMm),
|
|
179
|
+
mmToScreen(paper.heightMm)
|
|
180
|
+
);
|
|
181
|
+
ctx.restore();
|
|
182
|
+
|
|
183
|
+
// Paper border
|
|
184
|
+
ctx.strokeStyle = '#cccccc';
|
|
185
|
+
ctx.lineWidth = 1;
|
|
186
|
+
ctx.strokeRect(
|
|
187
|
+
mmToScreenX(0),
|
|
188
|
+
mmToScreenY(0),
|
|
189
|
+
mmToScreen(paper.widthMm),
|
|
190
|
+
mmToScreen(paper.heightMm)
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
194
|
+
// 2. Draw frame borders
|
|
195
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
196
|
+
const frameLeft = frame.margins.left + frame.margins.bindingMargin;
|
|
197
|
+
const frameTop = frame.margins.top;
|
|
198
|
+
const frameRight = paper.widthMm - frame.margins.right;
|
|
199
|
+
const frameBottom = paper.heightMm - frame.margins.bottom;
|
|
200
|
+
const frameWidth = frameRight - frameLeft;
|
|
201
|
+
const frameHeight = frameBottom - frameTop;
|
|
202
|
+
|
|
203
|
+
// Outer border
|
|
204
|
+
ctx.strokeStyle = '#000000';
|
|
205
|
+
ctx.lineWidth = Math.max(1, mmToScreen(frame.border.outerLineWeight));
|
|
206
|
+
ctx.strokeRect(
|
|
207
|
+
mmToScreenX(frameLeft),
|
|
208
|
+
mmToScreenY(frameTop),
|
|
209
|
+
mmToScreen(frameWidth),
|
|
210
|
+
mmToScreen(frameHeight)
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
// Inner border (if gap > 0)
|
|
214
|
+
if (frame.border.borderGap > 0) {
|
|
215
|
+
const innerLeft = frameLeft + frame.border.borderGap;
|
|
216
|
+
const innerTop = frameTop + frame.border.borderGap;
|
|
217
|
+
const innerWidth = frameWidth - 2 * frame.border.borderGap;
|
|
218
|
+
const innerHeight = frameHeight - 2 * frame.border.borderGap;
|
|
219
|
+
|
|
220
|
+
ctx.lineWidth = Math.max(0.5, mmToScreen(frame.border.innerLineWeight));
|
|
221
|
+
ctx.strokeRect(
|
|
222
|
+
mmToScreenX(innerLeft),
|
|
223
|
+
mmToScreenY(innerTop),
|
|
224
|
+
mmToScreen(innerWidth),
|
|
225
|
+
mmToScreen(innerHeight)
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
230
|
+
// 3. Draw title block
|
|
231
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
232
|
+
const innerLeft = frameLeft + frame.border.borderGap;
|
|
233
|
+
const innerTop = frameTop + frame.border.borderGap;
|
|
234
|
+
const innerWidth = frameWidth - 2 * frame.border.borderGap;
|
|
235
|
+
const innerHeight = frameHeight - 2 * frame.border.borderGap;
|
|
236
|
+
|
|
237
|
+
let tbX: number, tbY: number, tbW: number, tbH: number;
|
|
238
|
+
switch (titleBlock.position) {
|
|
239
|
+
case 'bottom-right':
|
|
240
|
+
tbW = titleBlock.widthMm;
|
|
241
|
+
tbH = titleBlock.heightMm;
|
|
242
|
+
tbX = innerLeft + innerWidth - tbW;
|
|
243
|
+
tbY = innerTop + innerHeight - tbH;
|
|
244
|
+
break;
|
|
245
|
+
case 'bottom-full':
|
|
246
|
+
tbW = innerWidth;
|
|
247
|
+
tbH = titleBlock.heightMm;
|
|
248
|
+
tbX = innerLeft;
|
|
249
|
+
tbY = innerTop + innerHeight - tbH;
|
|
250
|
+
break;
|
|
251
|
+
case 'right-strip':
|
|
252
|
+
tbW = titleBlock.widthMm;
|
|
253
|
+
tbH = innerHeight;
|
|
254
|
+
tbX = innerLeft + innerWidth - tbW;
|
|
255
|
+
tbY = innerTop;
|
|
256
|
+
break;
|
|
257
|
+
default:
|
|
258
|
+
tbW = titleBlock.widthMm;
|
|
259
|
+
tbH = titleBlock.heightMm;
|
|
260
|
+
tbX = innerLeft + innerWidth - tbW;
|
|
261
|
+
tbY = innerTop + innerHeight - tbH;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Title block border
|
|
265
|
+
ctx.strokeStyle = '#000000';
|
|
266
|
+
ctx.lineWidth = Math.max(1, mmToScreen(titleBlock.borderWeight));
|
|
267
|
+
ctx.strokeRect(
|
|
268
|
+
mmToScreenX(tbX),
|
|
269
|
+
mmToScreenY(tbY),
|
|
270
|
+
mmToScreen(tbW),
|
|
271
|
+
mmToScreen(tbH)
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
// Title block fields - calculate row heights based on font sizes
|
|
275
|
+
const logoSpace = titleBlock.logo ? 50 : 0;
|
|
276
|
+
const revisionSpace = titleBlock.showRevisionHistory ? 20 : 0;
|
|
277
|
+
const availableWidth = tbW - logoSpace - 5;
|
|
278
|
+
const availableHeight = tbH - revisionSpace - 4;
|
|
279
|
+
const numCols = 2;
|
|
280
|
+
|
|
281
|
+
// Group fields by row
|
|
282
|
+
const fieldsByRow = new Map<number, typeof titleBlock.fields>();
|
|
283
|
+
for (const field of titleBlock.fields) {
|
|
284
|
+
const row = field.row ?? 0;
|
|
285
|
+
if (!fieldsByRow.has(row)) fieldsByRow.set(row, []);
|
|
286
|
+
fieldsByRow.get(row)!.push(field);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Calculate minimum height needed for each row based on its largest font
|
|
290
|
+
const rowCount = Math.max(...Array.from(fieldsByRow.keys()), 0) + 1;
|
|
291
|
+
const rowHeights: number[] = [];
|
|
292
|
+
let totalMinHeight = 0;
|
|
293
|
+
|
|
294
|
+
for (let r = 0; r < rowCount; r++) {
|
|
295
|
+
const fields = fieldsByRow.get(r) || [];
|
|
296
|
+
const maxFontSize = fields.length > 0 ? Math.max(...fields.map(f => f.fontSize)) : 3;
|
|
297
|
+
const labelSize = Math.min(maxFontSize * 0.5, 2.2);
|
|
298
|
+
const minRowHeight = labelSize + 1 + maxFontSize + 2;
|
|
299
|
+
rowHeights.push(minRowHeight);
|
|
300
|
+
totalMinHeight += minRowHeight;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Scale row heights if they exceed available space
|
|
304
|
+
const rowScaleFactor = totalMinHeight > availableHeight ? availableHeight / totalMinHeight : 1;
|
|
305
|
+
const scaledRowHeights = rowHeights.map(h => h * rowScaleFactor);
|
|
306
|
+
|
|
307
|
+
const colWidth = availableWidth / numCols;
|
|
308
|
+
const gridStartX = tbX + logoSpace + 2;
|
|
309
|
+
const gridStartY = tbY + 2;
|
|
310
|
+
|
|
311
|
+
// Calculate row Y positions
|
|
312
|
+
const rowYPositions: number[] = [gridStartY];
|
|
313
|
+
for (let i = 0; i < scaledRowHeights.length - 1; i++) {
|
|
314
|
+
rowYPositions.push(rowYPositions[i] + scaledRowHeights[i]);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Draw grid lines
|
|
318
|
+
ctx.strokeStyle = '#000000';
|
|
319
|
+
ctx.lineWidth = Math.max(0.5, mmToScreen(titleBlock.gridWeight));
|
|
320
|
+
|
|
321
|
+
// Horizontal lines
|
|
322
|
+
for (let i = 1; i < rowCount; i++) {
|
|
323
|
+
const lineY = rowYPositions[i];
|
|
324
|
+
ctx.beginPath();
|
|
325
|
+
ctx.moveTo(mmToScreenX(gridStartX), mmToScreenY(lineY));
|
|
326
|
+
ctx.lineTo(mmToScreenX(gridStartX + availableWidth - 4), mmToScreenY(lineY));
|
|
327
|
+
ctx.stroke();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Vertical dividers (for rows with multiple columns)
|
|
331
|
+
for (const [row, fields] of fieldsByRow) {
|
|
332
|
+
const hasMultipleCols = fields.some(f => (f.colSpan ?? 1) < 2);
|
|
333
|
+
if (hasMultipleCols) {
|
|
334
|
+
const centerX = gridStartX + colWidth;
|
|
335
|
+
const lineY1 = rowYPositions[row];
|
|
336
|
+
const lineY2 = rowYPositions[row] + scaledRowHeights[row];
|
|
337
|
+
ctx.beginPath();
|
|
338
|
+
ctx.moveTo(mmToScreenX(centerX), mmToScreenY(lineY1));
|
|
339
|
+
ctx.lineTo(mmToScreenX(centerX), mmToScreenY(lineY2));
|
|
340
|
+
ctx.stroke();
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Render field text - scale proportionally with zoom
|
|
345
|
+
for (const [row, fields] of fieldsByRow) {
|
|
346
|
+
const rowY = rowYPositions[row];
|
|
347
|
+
if (rowY === undefined) continue;
|
|
348
|
+
|
|
349
|
+
const rowH = scaledRowHeights[row] ?? 5;
|
|
350
|
+
const screenRowH = mmToScreen(rowH);
|
|
351
|
+
|
|
352
|
+
// Skip if row is too small to be readable
|
|
353
|
+
if (screenRowH < 4) continue;
|
|
354
|
+
|
|
355
|
+
for (const field of fields) {
|
|
356
|
+
const col = field.col ?? 0;
|
|
357
|
+
const fieldX = gridStartX + col * colWidth + 1.5;
|
|
358
|
+
|
|
359
|
+
// Calculate font sizes in mm (accounting for compressed rows)
|
|
360
|
+
const effectiveScale = rowScaleFactor < 1 ? rowScaleFactor : 1;
|
|
361
|
+
const labelFontMm = Math.min(field.fontSize * 0.45, 2.2) * Math.max(effectiveScale, 0.7);
|
|
362
|
+
const valueFontMm = field.fontSize * Math.max(effectiveScale, 0.7);
|
|
363
|
+
|
|
364
|
+
// Convert to screen pixels - scales naturally with zoom
|
|
365
|
+
const screenLabelFont = mmToScreen(labelFontMm);
|
|
366
|
+
const screenValueFont = mmToScreen(valueFontMm);
|
|
367
|
+
|
|
368
|
+
// Skip if too small to read
|
|
369
|
+
if (screenLabelFont < 3) continue;
|
|
370
|
+
|
|
371
|
+
const screenRowY = mmToScreenY(rowY);
|
|
372
|
+
const screenFieldX = mmToScreenX(fieldX);
|
|
373
|
+
|
|
374
|
+
// Label
|
|
375
|
+
ctx.font = `${screenLabelFont}px Arial, sans-serif`;
|
|
376
|
+
ctx.fillStyle = '#666666';
|
|
377
|
+
ctx.textAlign = 'left';
|
|
378
|
+
ctx.textBaseline = 'top';
|
|
379
|
+
ctx.fillText(field.label, screenFieldX, screenRowY + mmToScreen(0.3));
|
|
380
|
+
|
|
381
|
+
// Value below label (spacing in mm, converted to screen)
|
|
382
|
+
const valueY = screenRowY + mmToScreen(labelFontMm + 0.5);
|
|
383
|
+
ctx.font = `${field.fontWeight === 'bold' ? 'bold ' : ''}${screenValueFont}px Arial, sans-serif`;
|
|
384
|
+
ctx.fillStyle = '#000000';
|
|
385
|
+
ctx.fillText(field.value, screenFieldX, valueY);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
390
|
+
// 4. Clip to viewport and draw model content
|
|
391
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
392
|
+
ctx.save();
|
|
393
|
+
|
|
394
|
+
// Create clip region for viewport
|
|
395
|
+
ctx.beginPath();
|
|
396
|
+
ctx.rect(
|
|
397
|
+
mmToScreenX(viewport.x),
|
|
398
|
+
mmToScreenY(viewport.y),
|
|
399
|
+
mmToScreen(viewport.width),
|
|
400
|
+
mmToScreen(viewport.height)
|
|
401
|
+
);
|
|
402
|
+
ctx.clip();
|
|
403
|
+
|
|
404
|
+
// Calculate drawing transform to fit in viewport
|
|
405
|
+
const drawingBounds = {
|
|
406
|
+
minX: drawing.bounds.min.x,
|
|
407
|
+
minY: drawing.bounds.min.y,
|
|
408
|
+
maxX: drawing.bounds.max.x,
|
|
409
|
+
maxY: drawing.bounds.max.y,
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
// Axis-specific flipping
|
|
413
|
+
const flipY = sectionAxis !== 'down';
|
|
414
|
+
const flipX = sectionAxis === 'side';
|
|
415
|
+
|
|
416
|
+
// Use cached transform when pinned, otherwise calculate new one
|
|
417
|
+
let drawingTransform: { translateX: number; translateY: number; scaleFactor: number };
|
|
418
|
+
|
|
419
|
+
if (isPinned && cachedSheetTransformRef?.current) {
|
|
420
|
+
// Use cached transform to keep model fixed in place
|
|
421
|
+
drawingTransform = cachedSheetTransformRef.current;
|
|
422
|
+
} else {
|
|
423
|
+
// Calculate new transform
|
|
424
|
+
const baseTransform = calculateDrawingTransform(drawingBounds, viewport, activeSheet.scale);
|
|
425
|
+
|
|
426
|
+
// Adjust for axis-specific flipping
|
|
427
|
+
// calculateDrawingTransform assumes Y-flip (uses maxY), but for 'down' view we don't flip Y
|
|
428
|
+
drawingTransform = {
|
|
429
|
+
...baseTransform,
|
|
430
|
+
translateY: flipY
|
|
431
|
+
? baseTransform.translateY
|
|
432
|
+
: baseTransform.translateY - (drawingBounds.maxY + drawingBounds.minY) * baseTransform.scaleFactor,
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
// Cache the transform for pinned mode
|
|
436
|
+
if (cachedSheetTransformRef) {
|
|
437
|
+
cachedSheetTransformRef.current = drawingTransform;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Apply combined transform: sheet mm -> screen, then drawing coords -> sheet mm
|
|
442
|
+
// Drawing coord (meters) * scaleFactor = sheet mm, + translateX/Y
|
|
443
|
+
// Then sheet mm -> screen via mmToScreenX/Y
|
|
444
|
+
const drawModelContent = () => {
|
|
445
|
+
// Determine flip behavior based on section axis
|
|
446
|
+
// - 'down' (plan view): DON'T flip Y so north (Z+) is up
|
|
447
|
+
// - 'front' and 'side': flip Y so height (Y+) is up
|
|
448
|
+
// - 'side': also flip X to look from conventional direction
|
|
449
|
+
|
|
450
|
+
// For each polygon/line, transform from model coords to screen coords
|
|
451
|
+
const modelToScreen = (x: number, y: number) => {
|
|
452
|
+
// Apply axis-specific flipping
|
|
453
|
+
const adjustedX = flipX ? -x : x;
|
|
454
|
+
const adjustedY = flipY ? -y : y;
|
|
455
|
+
// Model to sheet mm
|
|
456
|
+
const sheetX = adjustedX * drawingTransform.scaleFactor + drawingTransform.translateX;
|
|
457
|
+
const sheetY = adjustedY * drawingTransform.scaleFactor + drawingTransform.translateY;
|
|
458
|
+
// Sheet mm to screen
|
|
459
|
+
return { x: mmToScreenX(sheetX), y: mmToScreenY(sheetY) };
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
// Line width in screen pixels (convert mm to screen)
|
|
463
|
+
const mmLineToScreen = (mmWeight: number) => Math.max(0.5, mmToScreen(mmWeight / drawingTransform.scaleFactor * 0.001));
|
|
464
|
+
|
|
465
|
+
// Fill cut polygons
|
|
466
|
+
for (const polygon of drawing.cutPolygons) {
|
|
467
|
+
let fillColor = getFillColorForType(polygon.ifcType);
|
|
468
|
+
let opacity = 1;
|
|
469
|
+
|
|
470
|
+
if (useIfcMaterials) {
|
|
471
|
+
const materialColor = entityColorMap.get(polygon.entityId);
|
|
472
|
+
if (materialColor) {
|
|
473
|
+
const r = Math.round(materialColor[0] * 255);
|
|
474
|
+
const g = Math.round(materialColor[1] * 255);
|
|
475
|
+
const b = Math.round(materialColor[2] * 255);
|
|
476
|
+
fillColor = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
|
477
|
+
opacity = materialColor[3];
|
|
478
|
+
}
|
|
479
|
+
} else if (overridesEnabled) {
|
|
480
|
+
const elementData: ElementData = {
|
|
481
|
+
expressId: polygon.entityId,
|
|
482
|
+
ifcType: polygon.ifcType,
|
|
483
|
+
};
|
|
484
|
+
const result = overrideEngine.applyOverrides(elementData);
|
|
485
|
+
fillColor = result.style.fillColor;
|
|
486
|
+
opacity = result.style.opacity;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
ctx.globalAlpha = opacity;
|
|
490
|
+
ctx.fillStyle = fillColor;
|
|
491
|
+
ctx.beginPath();
|
|
492
|
+
|
|
493
|
+
if (polygon.polygon.outer.length > 0) {
|
|
494
|
+
const first = modelToScreen(polygon.polygon.outer[0].x, polygon.polygon.outer[0].y);
|
|
495
|
+
ctx.moveTo(first.x, first.y);
|
|
496
|
+
for (let i = 1; i < polygon.polygon.outer.length; i++) {
|
|
497
|
+
const pt = modelToScreen(polygon.polygon.outer[i].x, polygon.polygon.outer[i].y);
|
|
498
|
+
ctx.lineTo(pt.x, pt.y);
|
|
499
|
+
}
|
|
500
|
+
ctx.closePath();
|
|
501
|
+
|
|
502
|
+
for (const hole of polygon.polygon.holes) {
|
|
503
|
+
if (hole.length > 0) {
|
|
504
|
+
const holeFirst = modelToScreen(hole[0].x, hole[0].y);
|
|
505
|
+
ctx.moveTo(holeFirst.x, holeFirst.y);
|
|
506
|
+
for (let i = 1; i < hole.length; i++) {
|
|
507
|
+
const pt = modelToScreen(hole[i].x, hole[i].y);
|
|
508
|
+
ctx.lineTo(pt.x, pt.y);
|
|
509
|
+
}
|
|
510
|
+
ctx.closePath();
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
ctx.fill('evenodd');
|
|
515
|
+
ctx.globalAlpha = 1;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Stroke polygon outlines
|
|
519
|
+
for (const polygon of drawing.cutPolygons) {
|
|
520
|
+
let strokeColor = '#000000';
|
|
521
|
+
let lineWeight = 0.5;
|
|
522
|
+
|
|
523
|
+
if (overridesEnabled) {
|
|
524
|
+
const elementData: ElementData = {
|
|
525
|
+
expressId: polygon.entityId,
|
|
526
|
+
ifcType: polygon.ifcType,
|
|
527
|
+
};
|
|
528
|
+
const result = overrideEngine.applyOverrides(elementData);
|
|
529
|
+
strokeColor = result.style.strokeColor;
|
|
530
|
+
lineWeight = result.style.lineWeight;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
ctx.strokeStyle = strokeColor;
|
|
534
|
+
ctx.lineWidth = Math.max(0.5, mmToScreen(lineWeight) * 0.3);
|
|
535
|
+
ctx.beginPath();
|
|
536
|
+
|
|
537
|
+
if (polygon.polygon.outer.length > 0) {
|
|
538
|
+
const first = modelToScreen(polygon.polygon.outer[0].x, polygon.polygon.outer[0].y);
|
|
539
|
+
ctx.moveTo(first.x, first.y);
|
|
540
|
+
for (let i = 1; i < polygon.polygon.outer.length; i++) {
|
|
541
|
+
const pt = modelToScreen(polygon.polygon.outer[i].x, polygon.polygon.outer[i].y);
|
|
542
|
+
ctx.lineTo(pt.x, pt.y);
|
|
543
|
+
}
|
|
544
|
+
ctx.closePath();
|
|
545
|
+
|
|
546
|
+
for (const hole of polygon.polygon.holes) {
|
|
547
|
+
if (hole.length > 0) {
|
|
548
|
+
const holeFirst = modelToScreen(hole[0].x, hole[0].y);
|
|
549
|
+
ctx.moveTo(holeFirst.x, holeFirst.y);
|
|
550
|
+
for (let i = 1; i < hole.length; i++) {
|
|
551
|
+
const pt = modelToScreen(hole[i].x, hole[i].y);
|
|
552
|
+
ctx.lineTo(pt.x, pt.y);
|
|
553
|
+
}
|
|
554
|
+
ctx.closePath();
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
ctx.stroke();
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Draw lines (projection, silhouette, etc.)
|
|
562
|
+
const lineBounds = drawing.bounds;
|
|
563
|
+
const lineMargin = Math.max(lineBounds.max.x - lineBounds.min.x, lineBounds.max.y - lineBounds.min.y) * 0.5;
|
|
564
|
+
const lineMinX = lineBounds.min.x - lineMargin;
|
|
565
|
+
const lineMaxX = lineBounds.max.x + lineMargin;
|
|
566
|
+
const lineMinY = lineBounds.min.y - lineMargin;
|
|
567
|
+
const lineMaxY = lineBounds.max.y + lineMargin;
|
|
568
|
+
|
|
569
|
+
for (const line of drawing.lines) {
|
|
570
|
+
if (line.category === 'cut') continue;
|
|
571
|
+
if (!showHiddenLines && line.visibility === 'hidden') continue;
|
|
572
|
+
|
|
573
|
+
const { start, end } = line.line;
|
|
574
|
+
if (!isFinite(start.x) || !isFinite(start.y) || !isFinite(end.x) || !isFinite(end.y)) continue;
|
|
575
|
+
if (start.x < lineMinX || start.x > lineMaxX || start.y < lineMinY || start.y > lineMaxY ||
|
|
576
|
+
end.x < lineMinX || end.x > lineMaxX || end.y < lineMinY || end.y > lineMaxY) continue;
|
|
577
|
+
|
|
578
|
+
let strokeColor = '#000000';
|
|
579
|
+
let lineWidth = 0.25;
|
|
580
|
+
let dashPattern: number[] = [];
|
|
581
|
+
|
|
582
|
+
switch (line.category) {
|
|
583
|
+
case 'projection': lineWidth = 0.25; break;
|
|
584
|
+
case 'hidden': lineWidth = 0.18; strokeColor = '#666666'; dashPattern = [4, 2]; break;
|
|
585
|
+
case 'silhouette': lineWidth = 0.35; break;
|
|
586
|
+
case 'crease': lineWidth = 0.18; break;
|
|
587
|
+
case 'boundary': lineWidth = 0.25; break;
|
|
588
|
+
case 'annotation': lineWidth = 0.13; break;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if (line.visibility === 'hidden') {
|
|
592
|
+
strokeColor = '#888888';
|
|
593
|
+
dashPattern = [4, 2];
|
|
594
|
+
lineWidth *= 0.7;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
ctx.strokeStyle = strokeColor;
|
|
598
|
+
ctx.lineWidth = Math.max(0.5, mmToScreen(lineWidth) * 0.3);
|
|
599
|
+
ctx.setLineDash(dashPattern);
|
|
600
|
+
|
|
601
|
+
const screenStart = modelToScreen(start.x, start.y);
|
|
602
|
+
const screenEnd = modelToScreen(end.x, end.y);
|
|
603
|
+
|
|
604
|
+
ctx.beginPath();
|
|
605
|
+
ctx.moveTo(screenStart.x, screenStart.y);
|
|
606
|
+
ctx.lineTo(screenEnd.x, screenEnd.y);
|
|
607
|
+
ctx.stroke();
|
|
608
|
+
ctx.setLineDash([]);
|
|
609
|
+
}
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
drawModelContent();
|
|
613
|
+
ctx.restore();
|
|
614
|
+
|
|
615
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
616
|
+
// 6. Draw scale bar at BOTTOM LEFT of title block
|
|
617
|
+
// Uses actual drawingTransform.scaleFactor which accounts for dynamic scaling
|
|
618
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
619
|
+
if (scaleBar.visible && tbH > 10) {
|
|
620
|
+
// Position: bottom left with small margin
|
|
621
|
+
const sbX = tbX + 3;
|
|
622
|
+
const sbY = tbY + tbH - 8; // 8mm from bottom (leaves room for label)
|
|
623
|
+
|
|
624
|
+
// Calculate effective scale from the actual drawing transform
|
|
625
|
+
// scaleFactor = mm per meter, so effective scale ratio = 1000 / scaleFactor
|
|
626
|
+
const effectiveScaleFactor = drawingTransform.scaleFactor;
|
|
627
|
+
|
|
628
|
+
// Scale bar length: we want to show a nice round number of meters
|
|
629
|
+
// Calculate how many mm on paper for the desired real-world length
|
|
630
|
+
const maxBarWidth = Math.min(tbW * 0.3, 50); // Max 30% of width or 50mm
|
|
631
|
+
|
|
632
|
+
// Find a nice round length that fits
|
|
633
|
+
// Start with the configured length and adjust if needed
|
|
634
|
+
let targetLengthM = scaleBar.totalLengthM;
|
|
635
|
+
let sbLengthMm = targetLengthM * effectiveScaleFactor;
|
|
636
|
+
|
|
637
|
+
// If bar would be too long, reduce the target length
|
|
638
|
+
while (sbLengthMm > maxBarWidth && targetLengthM > 0.5) {
|
|
639
|
+
targetLengthM = targetLengthM / 2;
|
|
640
|
+
sbLengthMm = targetLengthM * effectiveScaleFactor;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// If bar would be too short, increase the target length
|
|
644
|
+
while (sbLengthMm < maxBarWidth * 0.3 && targetLengthM < 100) {
|
|
645
|
+
targetLengthM = targetLengthM * 2;
|
|
646
|
+
sbLengthMm = targetLengthM * effectiveScaleFactor;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Clamp to max width
|
|
650
|
+
sbLengthMm = Math.min(sbLengthMm, maxBarWidth);
|
|
651
|
+
|
|
652
|
+
// Actual length represented by the bar
|
|
653
|
+
const actualTotalLength = sbLengthMm / effectiveScaleFactor;
|
|
654
|
+
|
|
655
|
+
const sbHeight = Math.min(scaleBar.heightMm, 3);
|
|
656
|
+
|
|
657
|
+
// Scale bar divisions
|
|
658
|
+
const divisions = scaleBar.primaryDivisions;
|
|
659
|
+
const divWidth = sbLengthMm / divisions;
|
|
660
|
+
for (let i = 0; i < divisions; i++) {
|
|
661
|
+
ctx.fillStyle = i % 2 === 0 ? scaleBar.fillColor : '#ffffff';
|
|
662
|
+
ctx.fillRect(
|
|
663
|
+
mmToScreenX(sbX + i * divWidth),
|
|
664
|
+
mmToScreenY(sbY),
|
|
665
|
+
mmToScreen(divWidth),
|
|
666
|
+
mmToScreen(sbHeight)
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Scale bar border
|
|
671
|
+
ctx.strokeStyle = scaleBar.strokeColor;
|
|
672
|
+
ctx.lineWidth = Math.max(1, mmToScreen(scaleBar.lineWeight));
|
|
673
|
+
ctx.strokeRect(
|
|
674
|
+
mmToScreenX(sbX),
|
|
675
|
+
mmToScreenY(sbY),
|
|
676
|
+
mmToScreen(sbLengthMm),
|
|
677
|
+
mmToScreen(sbHeight)
|
|
678
|
+
);
|
|
679
|
+
|
|
680
|
+
// Distance labels - only at 0 and end
|
|
681
|
+
const labelFontSize = Math.max(7, mmToScreen(1.8));
|
|
682
|
+
ctx.font = `${labelFontSize}px Arial, sans-serif`;
|
|
683
|
+
ctx.fillStyle = '#000000';
|
|
684
|
+
ctx.textBaseline = 'top';
|
|
685
|
+
const labelScreenY = mmToScreenY(sbY + sbHeight) + 1;
|
|
686
|
+
|
|
687
|
+
ctx.textAlign = 'left';
|
|
688
|
+
ctx.fillText('0', mmToScreenX(sbX), labelScreenY);
|
|
689
|
+
|
|
690
|
+
ctx.textAlign = 'right';
|
|
691
|
+
const endLabel = actualTotalLength < 1
|
|
692
|
+
? `${(actualTotalLength * 100).toFixed(0)}cm`
|
|
693
|
+
: `${actualTotalLength.toFixed(0)}m`;
|
|
694
|
+
ctx.fillText(endLabel, mmToScreenX(sbX + sbLengthMm), labelScreenY);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
698
|
+
// 7. Draw north arrow at BOTTOM RIGHT of title block
|
|
699
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
700
|
+
if (northArrow.style !== 'none' && tbH > 10) {
|
|
701
|
+
// Position: bottom right with margin
|
|
702
|
+
const naSize = Math.min(northArrow.sizeMm, 8, tbH * 0.6);
|
|
703
|
+
const naX = tbX + tbW - naSize - 5; // Right side with margin
|
|
704
|
+
const naY = tbY + tbH - naSize / 2 - 3; // Bottom with margin
|
|
705
|
+
|
|
706
|
+
ctx.save();
|
|
707
|
+
ctx.translate(mmToScreenX(naX), mmToScreenY(naY));
|
|
708
|
+
ctx.rotate((northArrow.rotation * Math.PI) / 180);
|
|
709
|
+
|
|
710
|
+
// Draw arrow
|
|
711
|
+
const arrowLen = mmToScreen(naSize);
|
|
712
|
+
ctx.fillStyle = '#000000';
|
|
713
|
+
ctx.beginPath();
|
|
714
|
+
ctx.moveTo(0, -arrowLen / 2);
|
|
715
|
+
ctx.lineTo(-arrowLen / 6, arrowLen / 2);
|
|
716
|
+
ctx.lineTo(0, arrowLen / 3);
|
|
717
|
+
ctx.lineTo(arrowLen / 6, arrowLen / 2);
|
|
718
|
+
ctx.closePath();
|
|
719
|
+
ctx.fill();
|
|
720
|
+
|
|
721
|
+
// Draw "N" label
|
|
722
|
+
const nFontSize = Math.max(8, mmToScreen(2.5));
|
|
723
|
+
ctx.font = `bold ${nFontSize}px Arial, sans-serif`;
|
|
724
|
+
ctx.textAlign = 'center';
|
|
725
|
+
ctx.textBaseline = 'bottom';
|
|
726
|
+
ctx.fillText('N', 0, -arrowLen / 2 - 1);
|
|
727
|
+
|
|
728
|
+
ctx.restore();
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
} else {
|
|
732
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
733
|
+
// NON-SHEET MODE: Original rendering (drawing coords -> screen)
|
|
734
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
735
|
+
|
|
736
|
+
// Apply transform with axis-specific flipping
|
|
737
|
+
// - 'down' (plan view): DON'T flip Y so north (Z+) is up
|
|
738
|
+
// - 'front' and 'side': flip Y so height (Y+) is up
|
|
739
|
+
// - 'side': also flip X to look from conventional direction
|
|
740
|
+
const scaleX = sectionAxis === 'side' ? -transform.scale : transform.scale;
|
|
741
|
+
const scaleY = sectionAxis === 'down' ? transform.scale : -transform.scale;
|
|
742
|
+
|
|
743
|
+
ctx.save();
|
|
744
|
+
ctx.translate(transform.x, transform.y);
|
|
745
|
+
ctx.scale(scaleX, scaleY);
|
|
746
|
+
|
|
747
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
748
|
+
// 1. FILL CUT POLYGONS (with color from IFC materials, override engine, or type fallback)
|
|
749
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
750
|
+
for (const polygon of drawing.cutPolygons) {
|
|
751
|
+
// Get fill color - priority: IFC materials > override engine > IFC type fallback
|
|
752
|
+
let fillColor = getFillColorForType(polygon.ifcType);
|
|
753
|
+
let strokeColor = '#000000';
|
|
754
|
+
let opacity = 1;
|
|
755
|
+
|
|
756
|
+
// Use actual IFC material colors from the mesh data
|
|
757
|
+
if (useIfcMaterials) {
|
|
758
|
+
const materialColor = entityColorMap.get(polygon.entityId);
|
|
759
|
+
if (materialColor) {
|
|
760
|
+
// Convert RGBA [0-1] to hex color
|
|
761
|
+
const r = Math.round(materialColor[0] * 255);
|
|
762
|
+
const g = Math.round(materialColor[1] * 255);
|
|
763
|
+
const b = Math.round(materialColor[2] * 255);
|
|
764
|
+
fillColor = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
|
765
|
+
opacity = materialColor[3];
|
|
766
|
+
}
|
|
767
|
+
} else if (overridesEnabled) {
|
|
768
|
+
const elementData: ElementData = {
|
|
769
|
+
expressId: polygon.entityId,
|
|
770
|
+
ifcType: polygon.ifcType,
|
|
771
|
+
};
|
|
772
|
+
const result = overrideEngine.applyOverrides(elementData);
|
|
773
|
+
fillColor = result.style.fillColor;
|
|
774
|
+
strokeColor = result.style.strokeColor;
|
|
775
|
+
opacity = result.style.opacity;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
ctx.globalAlpha = opacity;
|
|
779
|
+
ctx.fillStyle = fillColor;
|
|
780
|
+
ctx.beginPath();
|
|
781
|
+
if (polygon.polygon.outer.length > 0) {
|
|
782
|
+
ctx.moveTo(polygon.polygon.outer[0].x, polygon.polygon.outer[0].y);
|
|
783
|
+
for (let i = 1; i < polygon.polygon.outer.length; i++) {
|
|
784
|
+
ctx.lineTo(polygon.polygon.outer[i].x, polygon.polygon.outer[i].y);
|
|
785
|
+
}
|
|
786
|
+
ctx.closePath();
|
|
787
|
+
|
|
788
|
+
// Draw holes (inner boundaries)
|
|
789
|
+
for (const hole of polygon.polygon.holes) {
|
|
790
|
+
if (hole.length > 0) {
|
|
791
|
+
ctx.moveTo(hole[0].x, hole[0].y);
|
|
792
|
+
for (let i = 1; i < hole.length; i++) {
|
|
793
|
+
ctx.lineTo(hole[i].x, hole[i].y);
|
|
794
|
+
}
|
|
795
|
+
ctx.closePath();
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
ctx.fill('evenodd');
|
|
800
|
+
ctx.globalAlpha = 1;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
804
|
+
// 2. STROKE CUT POLYGON OUTLINES (with color from override engine)
|
|
805
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
806
|
+
for (const polygon of drawing.cutPolygons) {
|
|
807
|
+
let strokeColor = '#000000';
|
|
808
|
+
let lineWeight = 0.5;
|
|
809
|
+
|
|
810
|
+
if (overridesEnabled) {
|
|
811
|
+
const elementData: ElementData = {
|
|
812
|
+
expressId: polygon.entityId,
|
|
813
|
+
ifcType: polygon.ifcType,
|
|
814
|
+
};
|
|
815
|
+
const result = overrideEngine.applyOverrides(elementData);
|
|
816
|
+
strokeColor = result.style.strokeColor;
|
|
817
|
+
lineWeight = result.style.lineWeight;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
ctx.strokeStyle = strokeColor;
|
|
821
|
+
ctx.lineWidth = lineWeight / transform.scale;
|
|
822
|
+
ctx.beginPath();
|
|
823
|
+
if (polygon.polygon.outer.length > 0) {
|
|
824
|
+
ctx.moveTo(polygon.polygon.outer[0].x, polygon.polygon.outer[0].y);
|
|
825
|
+
for (let i = 1; i < polygon.polygon.outer.length; i++) {
|
|
826
|
+
ctx.lineTo(polygon.polygon.outer[i].x, polygon.polygon.outer[i].y);
|
|
827
|
+
}
|
|
828
|
+
ctx.closePath();
|
|
829
|
+
|
|
830
|
+
// Stroke holes too
|
|
831
|
+
for (const hole of polygon.polygon.holes) {
|
|
832
|
+
if (hole.length > 0) {
|
|
833
|
+
ctx.moveTo(hole[0].x, hole[0].y);
|
|
834
|
+
for (let i = 1; i < hole.length; i++) {
|
|
835
|
+
ctx.lineTo(hole[i].x, hole[i].y);
|
|
836
|
+
}
|
|
837
|
+
ctx.closePath();
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
ctx.stroke();
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
845
|
+
// 3. DRAW PROJECTION/SILHOUETTE LINES (skip 'cut' - already in polygons)
|
|
846
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
847
|
+
// Pre-compute bounds for line validation
|
|
848
|
+
const lineBounds = drawing.bounds;
|
|
849
|
+
const lineMargin = Math.max(lineBounds.max.x - lineBounds.min.x, lineBounds.max.y - lineBounds.min.y) * 0.5;
|
|
850
|
+
const lineMinX = lineBounds.min.x - lineMargin;
|
|
851
|
+
const lineMaxX = lineBounds.max.x + lineMargin;
|
|
852
|
+
const lineMinY = lineBounds.min.y - lineMargin;
|
|
853
|
+
const lineMaxY = lineBounds.max.y + lineMargin;
|
|
854
|
+
|
|
855
|
+
for (const line of drawing.lines) {
|
|
856
|
+
// Skip 'cut' lines - they're triangulation edges, already handled by polygons
|
|
857
|
+
if (line.category === 'cut') continue;
|
|
858
|
+
|
|
859
|
+
// Skip hidden lines if not showing
|
|
860
|
+
if (!showHiddenLines && line.visibility === 'hidden') continue;
|
|
861
|
+
|
|
862
|
+
// Skip lines with invalid coordinates (NaN, Infinity, or far outside bounds)
|
|
863
|
+
const { start, end } = line.line;
|
|
864
|
+
if (!isFinite(start.x) || !isFinite(start.y) || !isFinite(end.x) || !isFinite(end.y)) {
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
867
|
+
if (start.x < lineMinX || start.x > lineMaxX || start.y < lineMinY || start.y > lineMaxY ||
|
|
868
|
+
end.x < lineMinX || end.x > lineMaxX || end.y < lineMinY || end.y > lineMaxY) {
|
|
869
|
+
continue;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// Set line style based on category
|
|
873
|
+
let strokeColor = '#000000';
|
|
874
|
+
let lineWidth = 0.25;
|
|
875
|
+
let dashPattern: number[] = [];
|
|
876
|
+
|
|
877
|
+
switch (line.category) {
|
|
878
|
+
case 'projection':
|
|
879
|
+
lineWidth = 0.25;
|
|
880
|
+
strokeColor = '#000000';
|
|
881
|
+
break;
|
|
882
|
+
case 'hidden':
|
|
883
|
+
lineWidth = 0.18;
|
|
884
|
+
strokeColor = '#666666';
|
|
885
|
+
dashPattern = [2, 1];
|
|
886
|
+
break;
|
|
887
|
+
case 'silhouette':
|
|
888
|
+
lineWidth = 0.35;
|
|
889
|
+
strokeColor = '#000000';
|
|
890
|
+
break;
|
|
891
|
+
case 'crease':
|
|
892
|
+
lineWidth = 0.18;
|
|
893
|
+
strokeColor = '#000000';
|
|
894
|
+
break;
|
|
895
|
+
case 'boundary':
|
|
896
|
+
lineWidth = 0.25;
|
|
897
|
+
strokeColor = '#000000';
|
|
898
|
+
break;
|
|
899
|
+
case 'annotation':
|
|
900
|
+
lineWidth = 0.13;
|
|
901
|
+
strokeColor = '#000000';
|
|
902
|
+
break;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// Hidden visibility overrides
|
|
906
|
+
if (line.visibility === 'hidden') {
|
|
907
|
+
strokeColor = '#888888';
|
|
908
|
+
dashPattern = [2, 1];
|
|
909
|
+
lineWidth *= 0.7;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
ctx.strokeStyle = strokeColor;
|
|
913
|
+
ctx.lineWidth = lineWidth / transform.scale;
|
|
914
|
+
ctx.setLineDash(dashPattern.map((d) => d / transform.scale));
|
|
915
|
+
|
|
916
|
+
ctx.beginPath();
|
|
917
|
+
ctx.moveTo(line.line.start.x, line.line.start.y);
|
|
918
|
+
ctx.lineTo(line.line.end.x, line.line.end.y);
|
|
919
|
+
ctx.stroke();
|
|
920
|
+
|
|
921
|
+
ctx.setLineDash([]);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
ctx.restore();
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
928
|
+
// 4. RENDER MEASUREMENTS (in screen space)
|
|
929
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
930
|
+
const drawMeasureLine = (
|
|
931
|
+
start: { x: number; y: number },
|
|
932
|
+
end: { x: number; y: number },
|
|
933
|
+
distance: number,
|
|
934
|
+
color: string = '#2196F3',
|
|
935
|
+
isActive: boolean = false
|
|
936
|
+
) => {
|
|
937
|
+
// Convert drawing coords to screen coords with axis-specific transforms
|
|
938
|
+
const measureScaleX = sectionAxis === 'side' ? -transform.scale : transform.scale;
|
|
939
|
+
const measureScaleY = sectionAxis === 'down' ? transform.scale : -transform.scale;
|
|
940
|
+
const screenStart = {
|
|
941
|
+
x: start.x * measureScaleX + transform.x,
|
|
942
|
+
y: start.y * measureScaleY + transform.y,
|
|
943
|
+
};
|
|
944
|
+
const screenEnd = {
|
|
945
|
+
x: end.x * measureScaleX + transform.x,
|
|
946
|
+
y: end.y * measureScaleY + transform.y,
|
|
947
|
+
};
|
|
948
|
+
|
|
949
|
+
// Draw line
|
|
950
|
+
ctx.strokeStyle = color;
|
|
951
|
+
ctx.lineWidth = isActive ? 2 : 1.5;
|
|
952
|
+
ctx.setLineDash(isActive ? [6, 3] : []);
|
|
953
|
+
ctx.beginPath();
|
|
954
|
+
ctx.moveTo(screenStart.x, screenStart.y);
|
|
955
|
+
ctx.lineTo(screenEnd.x, screenEnd.y);
|
|
956
|
+
ctx.stroke();
|
|
957
|
+
ctx.setLineDash([]);
|
|
958
|
+
|
|
959
|
+
// Draw endpoints
|
|
960
|
+
ctx.fillStyle = color;
|
|
961
|
+
const endpointRadius = isActive ? 5 : 4;
|
|
962
|
+
ctx.beginPath();
|
|
963
|
+
ctx.arc(screenStart.x, screenStart.y, endpointRadius, 0, Math.PI * 2);
|
|
964
|
+
ctx.fill();
|
|
965
|
+
ctx.beginPath();
|
|
966
|
+
ctx.arc(screenEnd.x, screenEnd.y, endpointRadius, 0, Math.PI * 2);
|
|
967
|
+
ctx.fill();
|
|
968
|
+
|
|
969
|
+
// Draw distance label
|
|
970
|
+
const midX = (screenStart.x + screenEnd.x) / 2;
|
|
971
|
+
const midY = (screenStart.y + screenEnd.y) / 2;
|
|
972
|
+
|
|
973
|
+
// Format distance using shared utility
|
|
974
|
+
const labelText = formatDistance(distance);
|
|
975
|
+
|
|
976
|
+
// Background for label
|
|
977
|
+
ctx.font = '12px system-ui, sans-serif';
|
|
978
|
+
const textMetrics = ctx.measureText(labelText);
|
|
979
|
+
const padding = 4;
|
|
980
|
+
const bgWidth = textMetrics.width + padding * 2;
|
|
981
|
+
const bgHeight = 18;
|
|
982
|
+
|
|
983
|
+
ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
|
|
984
|
+
ctx.fillRect(midX - bgWidth / 2, midY - bgHeight / 2, bgWidth, bgHeight);
|
|
985
|
+
ctx.strokeStyle = color;
|
|
986
|
+
ctx.lineWidth = 1;
|
|
987
|
+
ctx.strokeRect(midX - bgWidth / 2, midY - bgHeight / 2, bgWidth, bgHeight);
|
|
988
|
+
|
|
989
|
+
// Text
|
|
990
|
+
ctx.fillStyle = '#000000';
|
|
991
|
+
ctx.textAlign = 'center';
|
|
992
|
+
ctx.textBaseline = 'middle';
|
|
993
|
+
ctx.fillText(labelText, midX, midY);
|
|
994
|
+
};
|
|
995
|
+
|
|
996
|
+
// Draw completed measurements
|
|
997
|
+
for (const result of measureResults) {
|
|
998
|
+
drawMeasureLine(result.start, result.end, result.distance, '#2196F3', false);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// Draw active measurement
|
|
1002
|
+
if (measureStart && measureCurrent) {
|
|
1003
|
+
const dx = measureCurrent.x - measureStart.x;
|
|
1004
|
+
const dy = measureCurrent.y - measureStart.y;
|
|
1005
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
1006
|
+
drawMeasureLine(measureStart, measureCurrent, distance, '#FF5722', true);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// Draw snap indicator
|
|
1010
|
+
if (measureMode && measureSnapPoint) {
|
|
1011
|
+
// Use axis-specific transforms (matching canvas rendering)
|
|
1012
|
+
const snapScaleX = sectionAxis === 'side' ? -transform.scale : transform.scale;
|
|
1013
|
+
const snapScaleY = sectionAxis === 'down' ? transform.scale : -transform.scale;
|
|
1014
|
+
const screenSnap = {
|
|
1015
|
+
x: measureSnapPoint.x * snapScaleX + transform.x,
|
|
1016
|
+
y: measureSnapPoint.y * snapScaleY + transform.y,
|
|
1017
|
+
};
|
|
1018
|
+
|
|
1019
|
+
// Draw snap crosshair
|
|
1020
|
+
ctx.strokeStyle = '#4CAF50';
|
|
1021
|
+
ctx.lineWidth = 1.5;
|
|
1022
|
+
const snapSize = 12;
|
|
1023
|
+
|
|
1024
|
+
ctx.beginPath();
|
|
1025
|
+
ctx.moveTo(screenSnap.x - snapSize, screenSnap.y);
|
|
1026
|
+
ctx.lineTo(screenSnap.x + snapSize, screenSnap.y);
|
|
1027
|
+
ctx.stroke();
|
|
1028
|
+
|
|
1029
|
+
ctx.beginPath();
|
|
1030
|
+
ctx.moveTo(screenSnap.x, screenSnap.y - snapSize);
|
|
1031
|
+
ctx.lineTo(screenSnap.x, screenSnap.y + snapSize);
|
|
1032
|
+
ctx.stroke();
|
|
1033
|
+
|
|
1034
|
+
// Draw snap circle
|
|
1035
|
+
ctx.beginPath();
|
|
1036
|
+
ctx.arc(screenSnap.x, screenSnap.y, 6, 0, Math.PI * 2);
|
|
1037
|
+
ctx.stroke();
|
|
1038
|
+
}
|
|
1039
|
+
}, [drawing, transform, showHiddenLines, canvasSize, overrideEngine, overridesEnabled, entityColorMap, useIfcMaterials, measureMode, measureStart, measureCurrent, measureResults, measureSnapPoint, sheetEnabled, activeSheet, sectionAxis, isPinned]);
|
|
1040
|
+
|
|
1041
|
+
return (
|
|
1042
|
+
<canvas
|
|
1043
|
+
ref={canvasRef}
|
|
1044
|
+
className="w-full h-full"
|
|
1045
|
+
style={CANVAS_STYLE}
|
|
1046
|
+
/>
|
|
1047
|
+
);
|
|
1048
|
+
}
|