@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
@@ -77,6 +77,13 @@ export function setGlobalRendererRef(ref: React.RefObject<Renderer | null>): voi
77
77
  globalRendererRef = ref;
78
78
  }
79
79
 
80
+ /**
81
+ * Get the global renderer instance (for direct rendering control, e.g., IDS snapshot capture)
82
+ */
83
+ export function getGlobalRenderer(): Renderer | null {
84
+ return globalRendererRef?.current ?? null;
85
+ }
86
+
80
87
  /**
81
88
  * Clear the global references (called on unmount to prevent memory leaks)
82
89
  */
@@ -0,0 +1,627 @@
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 { useCallback } from 'react';
6
+ import {
7
+ GraphicOverrideEngine,
8
+ renderFrame,
9
+ renderTitleBlock,
10
+ calculateDrawingTransform,
11
+ type Drawing2D,
12
+ type DrawingSheet,
13
+ type ElementData,
14
+ type TitleBlockExtras,
15
+ } from '@ifc-lite/drawing-2d';
16
+ import { getFillColorForType } from '@/components/viewer/Drawing2DCanvas';
17
+ import { formatDistance } from '@/components/viewer/tools/formatDistance';
18
+
19
+ interface UseDrawingExportParams {
20
+ drawing: Drawing2D | null;
21
+ displayOptions: { showHiddenLines: boolean; scale: number };
22
+ sectionPlane: { axis: 'down' | 'front' | 'side'; position: number; flipped: boolean };
23
+ activePresetId: string | null;
24
+ entityColorMap: Map<number, [number, number, number, number]>;
25
+ overridesEnabled: boolean;
26
+ overrideEngine: GraphicOverrideEngine;
27
+ measure2DResults: Array<{ id: string; start: { x: number; y: number }; end: { x: number; y: number }; distance: number }>;
28
+ sheetEnabled: boolean;
29
+ activeSheet: DrawingSheet | null;
30
+ }
31
+
32
+ interface UseDrawingExportResult {
33
+ formatDistance: (distance: number) => string;
34
+ handleExportSVG: () => void;
35
+ handlePrint: () => void;
36
+ }
37
+
38
+ function useDrawingExport({
39
+ drawing,
40
+ displayOptions,
41
+ sectionPlane,
42
+ activePresetId,
43
+ entityColorMap,
44
+ overridesEnabled,
45
+ overrideEngine,
46
+ measure2DResults,
47
+ sheetEnabled,
48
+ activeSheet,
49
+ }: UseDrawingExportParams): UseDrawingExportResult {
50
+
51
+ // Generate SVG that matches the canvas rendering exactly
52
+ const generateExportSVG = useCallback((): string | null => {
53
+ if (!drawing) return null;
54
+
55
+ const { bounds } = drawing;
56
+ const width = bounds.max.x - bounds.min.x;
57
+ const height = bounds.max.y - bounds.min.y;
58
+
59
+ // Add padding around the drawing
60
+ const padding = Math.max(width, height) * 0.1;
61
+ const viewMinX = bounds.min.x - padding;
62
+ const viewMinY = bounds.min.y - padding;
63
+ const viewWidth = width + padding * 2;
64
+ const viewHeight = height + padding * 2;
65
+
66
+ // SVG dimensions in mm (assuming model is in meters, scale 1:100)
67
+ const scale = displayOptions.scale || 100;
68
+ const svgWidthMm = (viewWidth * 1000) / scale;
69
+ const svgHeightMm = (viewHeight * 1000) / scale;
70
+
71
+ // Convert mm on paper to model units (meters)
72
+ // At 1:100 scale, 1mm on paper = 0.1m in model space
73
+ // Formula: modelUnits = paperMm * scale / 1000
74
+ const mmToModel = (mm: number) => mm * scale / 1000;
75
+
76
+ // Helper to escape XML
77
+ const escapeXml = (str: string): string => {
78
+ return str
79
+ .replace(/&/g, '&amp;')
80
+ .replace(/</g, '&lt;')
81
+ .replace(/>/g, '&gt;')
82
+ .replace(/"/g, '&quot;')
83
+ .replace(/'/g, '&apos;');
84
+ };
85
+
86
+ // Axis-specific flipping (matching canvas rendering)
87
+ // - 'down' (plan view): DON'T flip Y so north (Z+) is up
88
+ // - 'front' and 'side': flip Y so height (Y+) is up
89
+ // - 'side': also flip X to look from conventional direction
90
+ const currentAxis = sectionPlane.axis;
91
+ const flipY = currentAxis !== 'down';
92
+ const flipX = currentAxis === 'side';
93
+
94
+ // Helper to get polygon path with axis-specific coordinate transformation
95
+ const polygonToPath = (polygon: { outer: { x: number; y: number }[]; holes: { x: number; y: number }[][] }): string => {
96
+ const transformPt = (x: number, y: number) => ({
97
+ x: flipX ? -x : x,
98
+ y: flipY ? -y : y,
99
+ });
100
+
101
+ let path = '';
102
+ if (polygon.outer.length > 0) {
103
+ const first = transformPt(polygon.outer[0].x, polygon.outer[0].y);
104
+ path += `M ${first.x.toFixed(4)} ${first.y.toFixed(4)}`;
105
+ for (let i = 1; i < polygon.outer.length; i++) {
106
+ const pt = transformPt(polygon.outer[i].x, polygon.outer[i].y);
107
+ path += ` L ${pt.x.toFixed(4)} ${pt.y.toFixed(4)}`;
108
+ }
109
+ path += ' Z';
110
+ }
111
+ for (const hole of polygon.holes) {
112
+ if (hole.length > 0) {
113
+ const holeFirst = transformPt(hole[0].x, hole[0].y);
114
+ path += ` M ${holeFirst.x.toFixed(4)} ${holeFirst.y.toFixed(4)}`;
115
+ for (let i = 1; i < hole.length; i++) {
116
+ const pt = transformPt(hole[i].x, hole[i].y);
117
+ path += ` L ${pt.x.toFixed(4)} ${pt.y.toFixed(4)}`;
118
+ }
119
+ path += ' Z';
120
+ }
121
+ }
122
+ return path;
123
+ };
124
+
125
+ // Calculate viewBox with axis-specific flipping
126
+ const viewBoxMinX = flipX ? -viewMinX - viewWidth : viewMinX;
127
+ const viewBoxMinY = flipY ? -viewMinY - viewHeight : viewMinY;
128
+
129
+ // Start building SVG
130
+ let svg = `<?xml version="1.0" encoding="UTF-8"?>
131
+ <svg xmlns="http://www.w3.org/2000/svg"
132
+ width="${svgWidthMm.toFixed(2)}mm"
133
+ height="${svgHeightMm.toFixed(2)}mm"
134
+ viewBox="${viewBoxMinX.toFixed(4)} ${viewBoxMinY.toFixed(4)} ${viewWidth.toFixed(4)} ${viewHeight.toFixed(4)}">
135
+ <rect x="${viewBoxMinX.toFixed(4)}" y="${viewBoxMinY.toFixed(4)}" width="${viewWidth.toFixed(4)}" height="${viewHeight.toFixed(4)}" fill="#FFFFFF"/>
136
+ `;
137
+
138
+ // 1. FILL CUT POLYGONS (with color from IFC materials or override engine)
139
+ svg += ' <g id="polygon-fills">\n';
140
+ for (const polygon of drawing.cutPolygons) {
141
+ let fillColor = getFillColorForType(polygon.ifcType);
142
+ let opacity = 1;
143
+
144
+ // Use actual IFC material colors from the mesh data
145
+ if (activePresetId === 'preset-3d-colors') {
146
+ const materialColor = entityColorMap.get(polygon.entityId);
147
+ if (materialColor) {
148
+ const r = Math.round(materialColor[0] * 255);
149
+ const g = Math.round(materialColor[1] * 255);
150
+ const b = Math.round(materialColor[2] * 255);
151
+ fillColor = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
152
+ opacity = materialColor[3];
153
+ }
154
+ } else if (overridesEnabled) {
155
+ const elementData: ElementData = {
156
+ expressId: polygon.entityId,
157
+ ifcType: polygon.ifcType,
158
+ };
159
+ const result = overrideEngine.applyOverrides(elementData);
160
+ fillColor = result.style.fillColor;
161
+ opacity = result.style.opacity;
162
+ }
163
+
164
+ const pathData = polygonToPath(polygon.polygon);
165
+ svg += ` <path d="${pathData}" fill="${fillColor}" fill-opacity="${opacity.toFixed(2)}" fill-rule="evenodd" data-entity-id="${polygon.entityId}" data-ifc-type="${escapeXml(polygon.ifcType)}"/>\n`;
166
+ }
167
+ svg += ' </g>\n';
168
+
169
+ // 2. STROKE CUT POLYGON OUTLINES (with color from override engine)
170
+ svg += ' <g id="polygon-outlines">\n';
171
+ for (const polygon of drawing.cutPolygons) {
172
+ let strokeColor = '#000000';
173
+ let lineWeight = 0.5;
174
+
175
+ if (overridesEnabled) {
176
+ const elementData: ElementData = {
177
+ expressId: polygon.entityId,
178
+ ifcType: polygon.ifcType,
179
+ };
180
+ const result = overrideEngine.applyOverrides(elementData);
181
+ strokeColor = result.style.strokeColor;
182
+ lineWeight = result.style.lineWeight;
183
+ }
184
+
185
+ const pathData = polygonToPath(polygon.polygon);
186
+ // Convert line weight (mm on paper) to model units
187
+ const svgLineWeight = mmToModel(lineWeight);
188
+ svg += ` <path d="${pathData}" fill="none" stroke="${strokeColor}" stroke-width="${svgLineWeight.toFixed(4)}" data-entity-id="${polygon.entityId}"/>\n`;
189
+ }
190
+ svg += ' </g>\n';
191
+
192
+ // 3. DRAW PROJECTION/SILHOUETTE LINES
193
+ // Pre-compute bounds for line validation
194
+ const lineBounds = drawing.bounds;
195
+ const lineMargin = Math.max(lineBounds.max.x - lineBounds.min.x, lineBounds.max.y - lineBounds.min.y) * 0.5;
196
+ const lineMinX = lineBounds.min.x - lineMargin;
197
+ const lineMaxX = lineBounds.max.x + lineMargin;
198
+ const lineMinY = lineBounds.min.y - lineMargin;
199
+ const lineMaxY = lineBounds.max.y + lineMargin;
200
+
201
+ svg += ' <g id="drawing-lines">\n';
202
+ for (const line of drawing.lines) {
203
+ // Skip 'cut' lines - they're triangulation edges, already handled by polygons
204
+ if (line.category === 'cut') continue;
205
+
206
+ // Skip hidden lines if not showing
207
+ if (!displayOptions.showHiddenLines && line.visibility === 'hidden') continue;
208
+
209
+ // Skip lines with invalid coordinates
210
+ const { start, end } = line.line;
211
+ if (!isFinite(start.x) || !isFinite(start.y) || !isFinite(end.x) || !isFinite(end.y)) {
212
+ continue;
213
+ }
214
+ if (start.x < lineMinX || start.x > lineMaxX || start.y < lineMinY || start.y > lineMaxY ||
215
+ end.x < lineMinX || end.x > lineMaxX || end.y < lineMinY || end.y > lineMaxY) {
216
+ continue;
217
+ }
218
+
219
+ // Set line style based on category
220
+ let strokeColor = '#000000';
221
+ let lineWidth = 0.25;
222
+ let dashArray = '';
223
+
224
+ switch (line.category) {
225
+ case 'projection':
226
+ lineWidth = 0.25;
227
+ strokeColor = '#000000';
228
+ break;
229
+ case 'hidden':
230
+ lineWidth = 0.18;
231
+ strokeColor = '#666666';
232
+ dashArray = '2 1';
233
+ break;
234
+ case 'silhouette':
235
+ lineWidth = 0.35;
236
+ strokeColor = '#000000';
237
+ break;
238
+ case 'crease':
239
+ lineWidth = 0.18;
240
+ strokeColor = '#000000';
241
+ break;
242
+ case 'boundary':
243
+ lineWidth = 0.25;
244
+ strokeColor = '#000000';
245
+ break;
246
+ case 'annotation':
247
+ lineWidth = 0.13;
248
+ strokeColor = '#000000';
249
+ break;
250
+ }
251
+
252
+ // Hidden visibility overrides
253
+ if (line.visibility === 'hidden') {
254
+ strokeColor = '#888888';
255
+ dashArray = '2 1';
256
+ lineWidth *= 0.7;
257
+ }
258
+
259
+ // Convert line width from mm on paper to model units
260
+ const svgLineWidth = mmToModel(lineWidth);
261
+ const dashAttr = dashArray ? ` stroke-dasharray="${dashArray.split(' ').map(d => mmToModel(parseFloat(d)).toFixed(4)).join(' ')}"` : '';
262
+
263
+ // Transform line endpoints with axis-specific flipping
264
+ const startT = { x: flipX ? -start.x : start.x, y: flipY ? -start.y : start.y };
265
+ const endT = { x: flipX ? -end.x : end.x, y: flipY ? -end.y : end.y };
266
+ svg += ` <line x1="${startT.x.toFixed(4)}" y1="${startT.y.toFixed(4)}" x2="${endT.x.toFixed(4)}" y2="${endT.y.toFixed(4)}" stroke="${strokeColor}" stroke-width="${svgLineWidth.toFixed(4)}"${dashAttr}/>\n`;
267
+ }
268
+ svg += ' </g>\n';
269
+
270
+ // 4. DRAW COMPLETED MEASUREMENTS
271
+ if (measure2DResults.length > 0) {
272
+ svg += ' <g id="measurements">\n';
273
+ for (const result of measure2DResults) {
274
+ const { start, end, distance } = result;
275
+ // Transform measurement points with axis-specific flipping
276
+ const startT = { x: flipX ? -start.x : start.x, y: flipY ? -start.y : start.y };
277
+ const endT = { x: flipX ? -end.x : end.x, y: flipY ? -end.y : end.y };
278
+ const midX = (startT.x + endT.x) / 2;
279
+ const midY = (startT.y + endT.y) / 2;
280
+ const labelText = formatDistance(distance);
281
+
282
+ // Measurement styling (all in mm on paper, converted to model units)
283
+ const measureColor = '#2196F3';
284
+ const measureLineWidth = mmToModel(0.4); // 0.4mm line on paper
285
+ const endpointRadius = mmToModel(1.5); // 1.5mm radius on paper
286
+
287
+ // Draw line
288
+ svg += ` <line x1="${startT.x.toFixed(4)}" y1="${startT.y.toFixed(4)}" x2="${endT.x.toFixed(4)}" y2="${endT.y.toFixed(4)}" stroke="${measureColor}" stroke-width="${measureLineWidth.toFixed(4)}"/>\n`;
289
+
290
+ // Draw endpoints
291
+ svg += ` <circle cx="${startT.x.toFixed(4)}" cy="${startT.y.toFixed(4)}" r="${endpointRadius.toFixed(4)}" fill="${measureColor}"/>\n`;
292
+ svg += ` <circle cx="${endT.x.toFixed(4)}" cy="${endT.y.toFixed(4)}" r="${endpointRadius.toFixed(4)}" fill="${measureColor}"/>\n`;
293
+
294
+ // Draw label background and text
295
+ // Use 3mm text height on paper for readable labels
296
+ const fontSize = mmToModel(3);
297
+ const labelWidth = labelText.length * fontSize * 0.6; // Approximate text width
298
+ const labelHeight = fontSize * 1.4;
299
+ const labelStroke = mmToModel(0.2);
300
+
301
+ svg += ` <rect x="${(midX - labelWidth / 2).toFixed(4)}" y="${(midY - labelHeight / 2).toFixed(4)}" width="${labelWidth.toFixed(4)}" height="${labelHeight.toFixed(4)}" fill="rgba(255,255,255,0.95)" stroke="${measureColor}" stroke-width="${labelStroke.toFixed(4)}"/>\n`;
302
+ svg += ` <text x="${midX.toFixed(4)}" y="${midY.toFixed(4)}" font-family="Arial, sans-serif" font-size="${fontSize.toFixed(4)}" fill="#000000" text-anchor="middle" dominant-baseline="middle" font-weight="500">${escapeXml(labelText)}</text>\n`;
303
+ }
304
+ svg += ' </g>\n';
305
+ }
306
+
307
+ svg += '</svg>';
308
+ return svg;
309
+ }, [drawing, displayOptions, activePresetId, entityColorMap, overridesEnabled, overrideEngine, measure2DResults, sectionPlane.axis]);
310
+
311
+ // Generate SVG with drawing sheet (frame, title block, scale bar)
312
+ // This generates coordinates directly in paper mm space (like the canvas rendering)
313
+ const generateSheetSVG = useCallback((): string | null => {
314
+ if (!drawing || !activeSheet) return null;
315
+
316
+ const { bounds } = drawing;
317
+
318
+ // Sheet dimensions in mm
319
+ const paperWidth = activeSheet.paper.widthMm;
320
+ const paperHeight = activeSheet.paper.heightMm;
321
+ const viewport = activeSheet.viewportBounds;
322
+
323
+ // Calculate transform to fit drawing into viewport
324
+ const drawingTransform = calculateDrawingTransform(
325
+ { minX: bounds.min.x, minY: bounds.min.y, maxX: bounds.max.x, maxY: bounds.max.y },
326
+ viewport,
327
+ activeSheet.scale
328
+ );
329
+
330
+ const { translateX, translateY, scaleFactor } = drawingTransform;
331
+
332
+ // Axis-specific flipping (matching canvas rendering)
333
+ // - 'down' (plan view): DON'T flip Y so north (Z+) is up
334
+ // - 'front' and 'side': flip Y so height (Y+) is up
335
+ // - 'side': also flip X to look from conventional direction
336
+ const currentAxis = sectionPlane.axis;
337
+ const flipY = currentAxis !== 'down';
338
+ const flipX = currentAxis === 'side';
339
+
340
+ // Helper: convert model coordinates to paper mm (matching canvas rendering exactly)
341
+ const modelToPaper = (x: number, y: number): { x: number; y: number } => {
342
+ const adjustedX = flipX ? -x : x;
343
+ const adjustedY = flipY ? -y : y;
344
+ return {
345
+ x: adjustedX * scaleFactor + translateX,
346
+ y: adjustedY * scaleFactor + translateY,
347
+ };
348
+ };
349
+
350
+ // Start building SVG (paper coordinates in mm)
351
+ let svg = `<?xml version="1.0" encoding="UTF-8"?>
352
+ <svg xmlns="http://www.w3.org/2000/svg"
353
+ width="${paperWidth}mm"
354
+ height="${paperHeight}mm"
355
+ viewBox="0 0 ${paperWidth} ${paperHeight}">
356
+ <!-- Background -->
357
+ <rect x="0" y="0" width="${paperWidth}" height="${paperHeight}" fill="#FFFFFF"/>
358
+
359
+ `;
360
+
361
+ // Create clipping path for viewport FIRST (so it can be used by drawing content)
362
+ svg += ` <defs>
363
+ <clipPath id="viewport-clip">
364
+ <rect x="${viewport.x.toFixed(2)}" y="${viewport.y.toFixed(2)}" width="${viewport.width.toFixed(2)}" height="${viewport.height.toFixed(2)}"/>
365
+ </clipPath>
366
+ </defs>
367
+
368
+ `;
369
+
370
+ // Drawing content FIRST (so frame/title block render on top)
371
+ svg += ` <g id="drawing-content" clip-path="url(#viewport-clip)">
372
+ `;
373
+
374
+ // Helper to escape XML
375
+ const escapeXml = (str: string): string => {
376
+ return str
377
+ .replace(/&/g, '&amp;')
378
+ .replace(/</g, '&lt;')
379
+ .replace(/>/g, '&gt;')
380
+ .replace(/"/g, '&quot;')
381
+ .replace(/'/g, '&apos;');
382
+ };
383
+
384
+ // Helper to get polygon path in paper coordinates
385
+ const polygonToPath = (polygon: { outer: { x: number; y: number }[]; holes: { x: number; y: number }[][] }): string => {
386
+ let path = '';
387
+ if (polygon.outer.length > 0) {
388
+ const first = modelToPaper(polygon.outer[0].x, polygon.outer[0].y);
389
+ path += `M ${first.x.toFixed(4)} ${first.y.toFixed(4)}`;
390
+ for (let i = 1; i < polygon.outer.length; i++) {
391
+ const pt = modelToPaper(polygon.outer[i].x, polygon.outer[i].y);
392
+ path += ` L ${pt.x.toFixed(4)} ${pt.y.toFixed(4)}`;
393
+ }
394
+ path += ' Z';
395
+ }
396
+ for (const hole of polygon.holes) {
397
+ if (hole.length > 0) {
398
+ const holeFirst = modelToPaper(hole[0].x, hole[0].y);
399
+ path += ` M ${holeFirst.x.toFixed(4)} ${holeFirst.y.toFixed(4)}`;
400
+ for (let i = 1; i < hole.length; i++) {
401
+ const pt = modelToPaper(hole[i].x, hole[i].y);
402
+ path += ` L ${pt.x.toFixed(4)} ${pt.y.toFixed(4)}`;
403
+ }
404
+ path += ' Z';
405
+ }
406
+ }
407
+ return path;
408
+ };
409
+
410
+ // Render polygon fills
411
+ svg += ' <g id="polygon-fills">\n';
412
+ for (const polygon of drawing.cutPolygons) {
413
+ let fillColor = getFillColorForType(polygon.ifcType);
414
+ let opacity = 1;
415
+
416
+ if (activePresetId === 'preset-3d-colors') {
417
+ const materialColor = entityColorMap.get(polygon.entityId);
418
+ if (materialColor) {
419
+ const r = Math.round(materialColor[0] * 255);
420
+ const g = Math.round(materialColor[1] * 255);
421
+ const b = Math.round(materialColor[2] * 255);
422
+ fillColor = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
423
+ opacity = materialColor[3];
424
+ }
425
+ } else if (overridesEnabled) {
426
+ const elementData: ElementData = {
427
+ expressId: polygon.entityId,
428
+ ifcType: polygon.ifcType,
429
+ };
430
+ const result = overrideEngine.applyOverrides(elementData);
431
+ fillColor = result.style.fillColor;
432
+ opacity = result.style.opacity;
433
+ }
434
+
435
+ const pathData = polygonToPath(polygon.polygon);
436
+ if (pathData) {
437
+ svg += ` <path d="${pathData}" fill="${fillColor}" fill-opacity="${opacity.toFixed(2)}" fill-rule="evenodd" data-entity-id="${polygon.entityId}" data-ifc-type="${escapeXml(polygon.ifcType)}"/>\n`;
438
+ }
439
+ }
440
+ svg += ' </g>\n';
441
+
442
+ // Render polygon outlines
443
+ svg += ' <g id="polygon-outlines">\n';
444
+ for (const polygon of drawing.cutPolygons) {
445
+ let strokeColor = '#000000';
446
+ let lineWeight = 0.5;
447
+
448
+ if (overridesEnabled) {
449
+ const elementData: ElementData = {
450
+ expressId: polygon.entityId,
451
+ ifcType: polygon.ifcType,
452
+ };
453
+ const result = overrideEngine.applyOverrides(elementData);
454
+ strokeColor = result.style.strokeColor;
455
+ lineWeight = result.style.lineWeight;
456
+ }
457
+
458
+ const pathData = polygonToPath(polygon.polygon);
459
+ if (pathData) {
460
+ // lineWeight is in mm on paper
461
+ const svgLineWeight = lineWeight * 0.3; // Scale down for better appearance
462
+ svg += ` <path d="${pathData}" fill="none" stroke="${strokeColor}" stroke-width="${svgLineWeight.toFixed(4)}" data-entity-id="${polygon.entityId}"/>\n`;
463
+ }
464
+ }
465
+ svg += ' </g>\n';
466
+
467
+ // Render drawing lines
468
+ const lineBounds = drawing.bounds;
469
+ const lineMargin = Math.max(lineBounds.max.x - lineBounds.min.x, lineBounds.max.y - lineBounds.min.y) * 0.5;
470
+ const lineMinX = lineBounds.min.x - lineMargin;
471
+ const lineMaxX = lineBounds.max.x + lineMargin;
472
+ const lineMinY = lineBounds.min.y - lineMargin;
473
+ const lineMaxY = lineBounds.max.y + lineMargin;
474
+
475
+ svg += ' <g id="drawing-lines">\n';
476
+ for (const line of drawing.lines) {
477
+ if (line.category === 'cut') continue;
478
+ if (!displayOptions.showHiddenLines && line.visibility === 'hidden') continue;
479
+
480
+ const { start, end } = line.line;
481
+ if (!isFinite(start.x) || !isFinite(start.y) || !isFinite(end.x) || !isFinite(end.y)) continue;
482
+ if (start.x < lineMinX || start.x > lineMaxX || start.y < lineMinY || start.y > lineMaxY ||
483
+ end.x < lineMinX || end.x > lineMaxX || end.y < lineMinY || end.y > lineMaxY) continue;
484
+
485
+ let strokeColor = '#000000';
486
+ let lineWidth = 0.25;
487
+ let dashArray = '';
488
+
489
+ switch (line.category) {
490
+ case 'projection': lineWidth = 0.25; break;
491
+ case 'hidden': lineWidth = 0.18; strokeColor = '#666666'; dashArray = '1 0.5'; break;
492
+ case 'silhouette': lineWidth = 0.35; break;
493
+ case 'crease': lineWidth = 0.18; break;
494
+ case 'boundary': lineWidth = 0.25; break;
495
+ case 'annotation': lineWidth = 0.13; break;
496
+ }
497
+
498
+ if (line.visibility === 'hidden') {
499
+ strokeColor = '#888888';
500
+ dashArray = '1 0.5';
501
+ lineWidth *= 0.7;
502
+ }
503
+
504
+ const paperStart = modelToPaper(start.x, start.y);
505
+ const paperEnd = modelToPaper(end.x, end.y);
506
+
507
+ // lineWidth is in mm on paper
508
+ const svgLineWidth = lineWidth * 0.3;
509
+ const dashAttr = dashArray ? ` stroke-dasharray="${dashArray}"` : '';
510
+ svg += ` <line x1="${paperStart.x.toFixed(4)}" y1="${paperStart.y.toFixed(4)}" x2="${paperEnd.x.toFixed(4)}" y2="${paperEnd.y.toFixed(4)}" stroke="${strokeColor}" stroke-width="${svgLineWidth.toFixed(4)}"${dashAttr}/>\n`;
511
+ }
512
+ svg += ' </g>\n';
513
+
514
+ svg += ' </g>\n\n';
515
+
516
+ // Render frame (on top of drawing content)
517
+ const frameResult = renderFrame(activeSheet.paper, activeSheet.frame);
518
+ svg += frameResult.svgElements;
519
+ svg += '\n';
520
+
521
+ // Render title block with scale bar and north arrow inside
522
+ // Pass effectiveScaleFactor from the actual transform (not just configured scale)
523
+ // This ensures scale bar shows correct values when dynamically scaled
524
+ const titleBlockExtras: TitleBlockExtras = {
525
+ scaleBar: activeSheet.scaleBar,
526
+ northArrow: activeSheet.northArrow,
527
+ scale: activeSheet.scale,
528
+ effectiveScaleFactor: scaleFactor,
529
+ };
530
+ const titleBlockResult = renderTitleBlock(
531
+ activeSheet.titleBlock,
532
+ frameResult.innerBounds,
533
+ activeSheet.revisions,
534
+ titleBlockExtras
535
+ );
536
+ svg += titleBlockResult.svgElements;
537
+ svg += '\n';
538
+
539
+ svg += '</svg>';
540
+ return svg;
541
+ }, [drawing, activeSheet, displayOptions, activePresetId, entityColorMap, overridesEnabled, overrideEngine]);
542
+
543
+ // Export SVG
544
+ const handleExportSVG = useCallback(() => {
545
+ // Use sheet export if enabled, otherwise raw drawing export
546
+ const svg = (sheetEnabled && activeSheet) ? generateSheetSVG() : generateExportSVG();
547
+ if (!svg) return;
548
+ const blob = new Blob([svg], { type: 'image/svg+xml' });
549
+ const url = URL.createObjectURL(blob);
550
+ const a = document.createElement('a');
551
+ a.href = url;
552
+ const filename = (sheetEnabled && activeSheet)
553
+ ? `${activeSheet.name.replace(/\s+/g, '-')}-${sectionPlane.axis}-${sectionPlane.position}.svg`
554
+ : `section-${sectionPlane.axis}-${sectionPlane.position}.svg`;
555
+ a.download = filename;
556
+ a.click();
557
+ URL.revokeObjectURL(url);
558
+ }, [generateExportSVG, generateSheetSVG, sheetEnabled, activeSheet, sectionPlane]);
559
+
560
+ // Print handler
561
+ const handlePrint = useCallback(() => {
562
+ // Use sheet export if enabled, otherwise raw drawing export
563
+ const svg = (sheetEnabled && activeSheet) ? generateSheetSVG() : generateExportSVG();
564
+ if (!svg) return;
565
+
566
+ // Create a new window for printing
567
+ const printWindow = window.open('', '_blank', 'width=800,height=600');
568
+ if (!printWindow) {
569
+ alert('Please allow popups to print');
570
+ return;
571
+ }
572
+
573
+ const title = (sheetEnabled && activeSheet)
574
+ ? `${activeSheet.name} - ${sectionPlane.axis} at ${sectionPlane.position}%`
575
+ : `Section Drawing - ${sectionPlane.axis} at ${sectionPlane.position}%`;
576
+
577
+ // Write print-friendly HTML with the SVG
578
+ printWindow.document.write(`
579
+ <!DOCTYPE html>
580
+ <html>
581
+ <head>
582
+ <title>${title}</title>
583
+ <style>
584
+ @media print {
585
+ @page { margin: ${(sheetEnabled && activeSheet) ? '0' : '1cm'}; }
586
+ body { margin: 0; }
587
+ }
588
+ body {
589
+ display: flex;
590
+ justify-content: center;
591
+ align-items: center;
592
+ min-height: 100vh;
593
+ margin: 0;
594
+ padding: ${(sheetEnabled && activeSheet) ? '0' : '20px'};
595
+ box-sizing: border-box;
596
+ }
597
+ svg {
598
+ max-width: 100%;
599
+ max-height: 100vh;
600
+ width: auto;
601
+ height: auto;
602
+ }
603
+ </style>
604
+ </head>
605
+ <body>
606
+ ${svg}
607
+ <script>
608
+ window.onload = function() {
609
+ window.print();
610
+ window.onafterprint = function() { window.close(); };
611
+ };
612
+ </script>
613
+ </body>
614
+ </html>
615
+ `);
616
+ printWindow.document.close();
617
+ }, [generateExportSVG, generateSheetSVG, sheetEnabled, activeSheet, sectionPlane]);
618
+
619
+ return {
620
+ formatDistance,
621
+ handleExportSVG,
622
+ handlePrint,
623
+ };
624
+ }
625
+
626
+ export { useDrawingExport };
627
+ export default useDrawingExport;