@ifc-lite/viewer 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/dist/assets/{Arrow.dom-BjDQoB2M.js → Arrow.dom-BGPQieQQ.js} +1 -1
  3. package/dist/assets/ifc-lite_bg-DyIN_nBM.wasm +0 -0
  4. package/dist/assets/{index-YBtrHPu3.js → index-dgdgiQ9p.js} +40212 -30008
  5. package/dist/assets/index-yTqs8kgX.css +1 -0
  6. package/dist/assets/{native-bridge-CULtTDX3.js → native-bridge-DD0SNyQ5.js} +1 -1
  7. package/dist/assets/{wasm-bridge-CjL-lSak.js → wasm-bridge-D54YMO7X.js} +1 -1
  8. package/dist/index.html +2 -2
  9. package/package.json +18 -15
  10. package/src/components/viewer/BCFPanel.tsx +7 -789
  11. package/src/components/viewer/Drawing2DCanvas.tsx +1048 -0
  12. package/src/components/viewer/DrawingSettingsPanel.tsx +3 -3
  13. package/src/components/viewer/HierarchyPanel.tsx +110 -842
  14. package/src/components/viewer/IDSExportDialog.tsx +281 -0
  15. package/src/components/viewer/IDSPanel.tsx +126 -17
  16. package/src/components/viewer/KeyboardShortcutsDialog.tsx +9 -0
  17. package/src/components/viewer/LensPanel.tsx +603 -0
  18. package/src/components/viewer/MainToolbar.tsx +188 -21
  19. package/src/components/viewer/PropertiesPanel.tsx +171 -663
  20. package/src/components/viewer/PropertyEditor.tsx +866 -77
  21. package/src/components/viewer/Section2DPanel.tsx +76 -2648
  22. package/src/components/viewer/ToolOverlays.tsx +3 -1097
  23. package/src/components/viewer/ViewerLayout.tsx +132 -45
  24. package/src/components/viewer/Viewport.tsx +237 -1659
  25. package/src/components/viewer/ViewportContainer.tsx +11 -3
  26. package/src/components/viewer/bcf/BCFCreateTopicForm.tsx +134 -0
  27. package/src/components/viewer/bcf/BCFTopicDetail.tsx +388 -0
  28. package/src/components/viewer/bcf/BCFTopicList.tsx +239 -0
  29. package/src/components/viewer/bcf/bcfHelpers.tsx +109 -0
  30. package/src/components/viewer/hierarchy/HierarchyNode.tsx +328 -0
  31. package/src/components/viewer/hierarchy/treeDataBuilder.ts +464 -0
  32. package/src/components/viewer/hierarchy/types.ts +54 -0
  33. package/src/components/viewer/hierarchy/useHierarchyTree.ts +280 -0
  34. package/src/components/viewer/lists/ListBuilder.tsx +486 -0
  35. package/src/components/viewer/lists/ListPanel.tsx +540 -0
  36. package/src/components/viewer/lists/ListResultsTable.tsx +193 -0
  37. package/src/components/viewer/properties/ClassificationCard.tsx +70 -0
  38. package/src/components/viewer/properties/CoordinateDisplay.tsx +49 -0
  39. package/src/components/viewer/properties/DocumentCard.tsx +89 -0
  40. package/src/components/viewer/properties/MaterialCard.tsx +201 -0
  41. package/src/components/viewer/properties/ModelMetadataPanel.tsx +335 -0
  42. package/src/components/viewer/properties/PropertySetCard.tsx +132 -0
  43. package/src/components/viewer/properties/QuantitySetCard.tsx +79 -0
  44. package/src/components/viewer/properties/RelationshipsCard.tsx +100 -0
  45. package/src/components/viewer/properties/encodingUtils.ts +29 -0
  46. package/src/components/viewer/tools/MeasurePanel.tsx +218 -0
  47. package/src/components/viewer/tools/MeasurementVisuals.tsx +644 -0
  48. package/src/components/viewer/tools/SectionPanel.tsx +183 -0
  49. package/src/components/viewer/tools/SectionVisualization.tsx +78 -0
  50. package/src/components/viewer/tools/formatDistance.ts +18 -0
  51. package/src/components/viewer/tools/sectionConstants.ts +14 -0
  52. package/src/components/viewer/useAnimationLoop.ts +166 -0
  53. package/src/components/viewer/useGeometryStreaming.ts +398 -0
  54. package/src/components/viewer/useKeyboardControls.ts +221 -0
  55. package/src/components/viewer/useMouseControls.ts +1009 -0
  56. package/src/components/viewer/useRenderUpdates.ts +165 -0
  57. package/src/components/viewer/useTouchControls.ts +245 -0
  58. package/src/hooks/ids/idsColorSystem.ts +125 -0
  59. package/src/hooks/ids/idsDataAccessor.ts +237 -0
  60. package/src/hooks/ids/idsExportService.ts +444 -0
  61. package/src/hooks/useBCF.ts +7 -0
  62. package/src/hooks/useDrawingExport.ts +627 -0
  63. package/src/hooks/useDrawingGeneration.ts +627 -0
  64. package/src/hooks/useFloorplanView.ts +108 -0
  65. package/src/hooks/useIDS.ts +270 -463
  66. package/src/hooks/useIfc.ts +26 -1628
  67. package/src/hooks/useIfcFederation.ts +803 -0
  68. package/src/hooks/useIfcLoader.ts +508 -0
  69. package/src/hooks/useIfcServer.ts +465 -0
  70. package/src/hooks/useKeyboardShortcuts.ts +1 -1
  71. package/src/hooks/useLens.ts +129 -0
  72. package/src/hooks/useMeasure2D.ts +365 -0
  73. package/src/hooks/useViewControls.ts +218 -0
  74. package/src/lib/ifc4-pset-definitions.test.ts +161 -0
  75. package/src/lib/ifc4-pset-definitions.ts +621 -0
  76. package/src/lib/ifc4-qto-definitions.ts +315 -0
  77. package/src/lib/lens/adapter.ts +138 -0
  78. package/src/lib/lens/index.ts +5 -0
  79. package/src/lib/lists/adapter.ts +69 -0
  80. package/src/lib/lists/index.ts +28 -0
  81. package/src/lib/lists/persistence.ts +64 -0
  82. package/src/services/fs-cache.ts +1 -1
  83. package/src/services/tauri-modules.d.ts +25 -0
  84. package/src/store/index.ts +38 -2
  85. package/src/store/slices/cameraSlice.ts +14 -1
  86. package/src/store/slices/dataSlice.ts +14 -1
  87. package/src/store/slices/lensSlice.ts +184 -0
  88. package/src/store/slices/listSlice.ts +74 -0
  89. package/src/store/slices/pinboardSlice.ts +114 -0
  90. package/src/store/types.ts +5 -0
  91. package/src/utils/ifcConfig.ts +16 -3
  92. package/src/utils/serverDataModel.ts +64 -101
  93. package/src/vite-env.d.ts +3 -0
  94. package/dist/assets/ifc-lite_bg-C6kblxf9.wasm +0 -0
  95. package/dist/assets/index-v3mcCUPN.css +0 -1
@@ -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
+ }