@ifc-lite/viewer 1.6.1 → 1.8.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 (110) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/dist/assets/{Arrow.dom-Be1tgmo6.js → Arrow.dom-CwcRxist.js} +1 -1
  3. package/dist/assets/ifc-lite_bg-DyIN_nBM.wasm +0 -0
  4. package/dist/assets/index-7WoQ-qVC.css +1 -0
  5. package/dist/assets/{index-D1Du89Pa.js → index-BSANf7-H.js} +44948 -31410
  6. package/dist/assets/{native-bridge-A6zNnTfi.js → native-bridge-5LbrYh3R.js} +1 -1
  7. package/dist/assets/{wasm-bridge-DkRhgSvE.js → wasm-bridge-CgpLtj1h.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 +1411 -0
  12. package/src/components/viewer/DrawingSettingsPanel.tsx +3 -3
  13. package/src/components/viewer/EntityContextMenu.tsx +47 -20
  14. package/src/components/viewer/ExportDialog.tsx +166 -17
  15. package/src/components/viewer/HierarchyPanel.tsx +113 -843
  16. package/src/components/viewer/IDSExportDialog.tsx +281 -0
  17. package/src/components/viewer/IDSPanel.tsx +126 -17
  18. package/src/components/viewer/KeyboardShortcutsDialog.tsx +9 -0
  19. package/src/components/viewer/LensPanel.tsx +1366 -0
  20. package/src/components/viewer/MainToolbar.tsx +237 -37
  21. package/src/components/viewer/PropertiesPanel.tsx +171 -652
  22. package/src/components/viewer/PropertyEditor.tsx +866 -77
  23. package/src/components/viewer/Section2DPanel.tsx +329 -2661
  24. package/src/components/viewer/TextAnnotationEditor.tsx +112 -0
  25. package/src/components/viewer/ToolOverlays.tsx +3 -1097
  26. package/src/components/viewer/ViewerLayout.tsx +132 -45
  27. package/src/components/viewer/Viewport.tsx +290 -1678
  28. package/src/components/viewer/ViewportContainer.tsx +13 -3
  29. package/src/components/viewer/bcf/BCFCreateTopicForm.tsx +134 -0
  30. package/src/components/viewer/bcf/BCFTopicDetail.tsx +388 -0
  31. package/src/components/viewer/bcf/BCFTopicList.tsx +239 -0
  32. package/src/components/viewer/bcf/bcfHelpers.tsx +109 -0
  33. package/src/components/viewer/hierarchy/HierarchyNode.tsx +328 -0
  34. package/src/components/viewer/hierarchy/treeDataBuilder.ts +464 -0
  35. package/src/components/viewer/hierarchy/types.ts +54 -0
  36. package/src/components/viewer/hierarchy/useHierarchyTree.ts +280 -0
  37. package/src/components/viewer/lists/ListBuilder.tsx +486 -0
  38. package/src/components/viewer/lists/ListPanel.tsx +540 -0
  39. package/src/components/viewer/lists/ListResultsTable.tsx +227 -0
  40. package/src/components/viewer/properties/ClassificationCard.tsx +70 -0
  41. package/src/components/viewer/properties/CoordinateDisplay.tsx +49 -0
  42. package/src/components/viewer/properties/DocumentCard.tsx +89 -0
  43. package/src/components/viewer/properties/MaterialCard.tsx +201 -0
  44. package/src/components/viewer/properties/ModelMetadataPanel.tsx +335 -0
  45. package/src/components/viewer/properties/PropertySetCard.tsx +132 -0
  46. package/src/components/viewer/properties/QuantitySetCard.tsx +79 -0
  47. package/src/components/viewer/properties/RelationshipsCard.tsx +100 -0
  48. package/src/components/viewer/properties/encodingUtils.ts +29 -0
  49. package/src/components/viewer/tools/MeasurePanel.tsx +218 -0
  50. package/src/components/viewer/tools/MeasurementVisuals.tsx +644 -0
  51. package/src/components/viewer/tools/SectionPanel.tsx +183 -0
  52. package/src/components/viewer/tools/SectionVisualization.tsx +78 -0
  53. package/src/components/viewer/tools/cloudPathGenerator.test.ts +118 -0
  54. package/src/components/viewer/tools/cloudPathGenerator.ts +275 -0
  55. package/src/components/viewer/tools/computePolygonArea.test.ts +165 -0
  56. package/src/components/viewer/tools/computePolygonArea.ts +72 -0
  57. package/src/components/viewer/tools/formatDistance.ts +18 -0
  58. package/src/components/viewer/tools/sectionConstants.ts +14 -0
  59. package/src/components/viewer/useAnimationLoop.ts +166 -0
  60. package/src/components/viewer/useGeometryStreaming.ts +406 -0
  61. package/src/components/viewer/useKeyboardControls.ts +221 -0
  62. package/src/components/viewer/useMouseControls.ts +1009 -0
  63. package/src/components/viewer/useRenderUpdates.ts +165 -0
  64. package/src/components/viewer/useTouchControls.ts +245 -0
  65. package/src/hooks/ids/idsColorSystem.ts +125 -0
  66. package/src/hooks/ids/idsDataAccessor.ts +237 -0
  67. package/src/hooks/ids/idsExportService.ts +444 -0
  68. package/src/hooks/useAnnotation2D.ts +551 -0
  69. package/src/hooks/useBCF.ts +7 -0
  70. package/src/hooks/useDrawingExport.ts +709 -0
  71. package/src/hooks/useDrawingGeneration.ts +627 -0
  72. package/src/hooks/useFloorplanView.ts +108 -0
  73. package/src/hooks/useIDS.ts +270 -463
  74. package/src/hooks/useIfc.ts +26 -1628
  75. package/src/hooks/useIfcFederation.ts +803 -0
  76. package/src/hooks/useIfcLoader.ts +508 -0
  77. package/src/hooks/useIfcServer.ts +465 -0
  78. package/src/hooks/useKeyboardShortcuts.ts +114 -15
  79. package/src/hooks/useLens.ts +113 -0
  80. package/src/hooks/useLensDiscovery.ts +46 -0
  81. package/src/hooks/useMeasure2D.ts +365 -0
  82. package/src/hooks/useModelSelection.ts +5 -22
  83. package/src/hooks/useViewControls.ts +218 -0
  84. package/src/index.css +7 -1
  85. package/src/lib/ifc4-pset-definitions.test.ts +161 -0
  86. package/src/lib/ifc4-pset-definitions.ts +621 -0
  87. package/src/lib/ifc4-qto-definitions.ts +315 -0
  88. package/src/lib/lens/adapter.ts +264 -0
  89. package/src/lib/lens/index.ts +5 -0
  90. package/src/lib/lists/adapter.ts +69 -0
  91. package/src/lib/lists/columnToAutoColor.ts +33 -0
  92. package/src/lib/lists/index.ts +28 -0
  93. package/src/lib/lists/persistence.ts +64 -0
  94. package/src/services/fs-cache.ts +1 -1
  95. package/src/services/tauri-modules.d.ts +25 -0
  96. package/src/store/index.ts +52 -3
  97. package/src/store/resolveEntityRef.ts +44 -0
  98. package/src/store/slices/cameraSlice.ts +14 -1
  99. package/src/store/slices/dataSlice.ts +14 -1
  100. package/src/store/slices/drawing2DSlice.ts +321 -0
  101. package/src/store/slices/lensSlice.ts +226 -0
  102. package/src/store/slices/listSlice.ts +74 -0
  103. package/src/store/slices/pinboardSlice.ts +247 -0
  104. package/src/store/types.ts +5 -0
  105. package/src/store.ts +3 -0
  106. package/src/utils/ifcConfig.ts +16 -3
  107. package/src/utils/serverDataModel.ts +64 -101
  108. package/src/vite-env.d.ts +3 -0
  109. package/dist/assets/ifc-lite_bg-C6kblxf9.wasm +0 -0
  110. package/dist/assets/index-v3mcCUPN.css +0 -1
@@ -0,0 +1,709 @@
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
+ import { formatArea, computePolygonCentroid } from '@/components/viewer/tools/computePolygonArea';
19
+ import { generateCloudSVGPath } from '@/components/viewer/tools/cloudPathGenerator';
20
+ import type { PolygonArea2DResult, TextAnnotation2D, CloudAnnotation2D } from '@/store/slices/drawing2DSlice';
21
+
22
+ interface UseDrawingExportParams {
23
+ drawing: Drawing2D | null;
24
+ displayOptions: { showHiddenLines: boolean; scale: number };
25
+ sectionPlane: { axis: 'down' | 'front' | 'side'; position: number; flipped: boolean };
26
+ activePresetId: string | null;
27
+ entityColorMap: Map<number, [number, number, number, number]>;
28
+ overridesEnabled: boolean;
29
+ overrideEngine: GraphicOverrideEngine;
30
+ measure2DResults: Array<{ id: string; start: { x: number; y: number }; end: { x: number; y: number }; distance: number }>;
31
+ polygonArea2DResults: PolygonArea2DResult[];
32
+ textAnnotations2D: TextAnnotation2D[];
33
+ cloudAnnotations2D: CloudAnnotation2D[];
34
+ sheetEnabled: boolean;
35
+ activeSheet: DrawingSheet | null;
36
+ }
37
+
38
+ interface UseDrawingExportResult {
39
+ formatDistance: (distance: number) => string;
40
+ handleExportSVG: () => void;
41
+ handlePrint: () => void;
42
+ }
43
+
44
+ function useDrawingExport({
45
+ drawing,
46
+ displayOptions,
47
+ sectionPlane,
48
+ activePresetId,
49
+ entityColorMap,
50
+ overridesEnabled,
51
+ overrideEngine,
52
+ measure2DResults,
53
+ polygonArea2DResults,
54
+ textAnnotations2D,
55
+ cloudAnnotations2D,
56
+ sheetEnabled,
57
+ activeSheet,
58
+ }: UseDrawingExportParams): UseDrawingExportResult {
59
+
60
+ // Generate SVG that matches the canvas rendering exactly
61
+ const generateExportSVG = useCallback((): string | null => {
62
+ if (!drawing) return null;
63
+
64
+ const { bounds } = drawing;
65
+ const width = bounds.max.x - bounds.min.x;
66
+ const height = bounds.max.y - bounds.min.y;
67
+
68
+ // Add padding around the drawing
69
+ const padding = Math.max(width, height) * 0.1;
70
+ const viewMinX = bounds.min.x - padding;
71
+ const viewMinY = bounds.min.y - padding;
72
+ const viewWidth = width + padding * 2;
73
+ const viewHeight = height + padding * 2;
74
+
75
+ // SVG dimensions in mm (assuming model is in meters, scale 1:100)
76
+ const scale = displayOptions.scale || 100;
77
+ const svgWidthMm = (viewWidth * 1000) / scale;
78
+ const svgHeightMm = (viewHeight * 1000) / scale;
79
+
80
+ // Convert mm on paper to model units (meters)
81
+ // At 1:100 scale, 1mm on paper = 0.1m in model space
82
+ // Formula: modelUnits = paperMm * scale / 1000
83
+ const mmToModel = (mm: number) => mm * scale / 1000;
84
+
85
+ // Helper to escape XML
86
+ const escapeXml = (str: string): string => {
87
+ return str
88
+ .replace(/&/g, '&amp;')
89
+ .replace(/</g, '&lt;')
90
+ .replace(/>/g, '&gt;')
91
+ .replace(/"/g, '&quot;')
92
+ .replace(/'/g, '&apos;');
93
+ };
94
+
95
+ // Axis-specific flipping (matching canvas rendering)
96
+ // - 'down' (plan view): DON'T flip Y so north (Z+) is up
97
+ // - 'front' and 'side': flip Y so height (Y+) is up
98
+ // - 'side': also flip X to look from conventional direction
99
+ const currentAxis = sectionPlane.axis;
100
+ const flipY = currentAxis !== 'down';
101
+ const flipX = currentAxis === 'side';
102
+
103
+ // Helper to get polygon path with axis-specific coordinate transformation
104
+ const polygonToPath = (polygon: { outer: { x: number; y: number }[]; holes: { x: number; y: number }[][] }): string => {
105
+ const transformPt = (x: number, y: number) => ({
106
+ x: flipX ? -x : x,
107
+ y: flipY ? -y : y,
108
+ });
109
+
110
+ let path = '';
111
+ if (polygon.outer.length > 0) {
112
+ const first = transformPt(polygon.outer[0].x, polygon.outer[0].y);
113
+ path += `M ${first.x.toFixed(4)} ${first.y.toFixed(4)}`;
114
+ for (let i = 1; i < polygon.outer.length; i++) {
115
+ const pt = transformPt(polygon.outer[i].x, polygon.outer[i].y);
116
+ path += ` L ${pt.x.toFixed(4)} ${pt.y.toFixed(4)}`;
117
+ }
118
+ path += ' Z';
119
+ }
120
+ for (const hole of polygon.holes) {
121
+ if (hole.length > 0) {
122
+ const holeFirst = transformPt(hole[0].x, hole[0].y);
123
+ path += ` M ${holeFirst.x.toFixed(4)} ${holeFirst.y.toFixed(4)}`;
124
+ for (let i = 1; i < hole.length; i++) {
125
+ const pt = transformPt(hole[i].x, hole[i].y);
126
+ path += ` L ${pt.x.toFixed(4)} ${pt.y.toFixed(4)}`;
127
+ }
128
+ path += ' Z';
129
+ }
130
+ }
131
+ return path;
132
+ };
133
+
134
+ // Calculate viewBox with axis-specific flipping
135
+ const viewBoxMinX = flipX ? -viewMinX - viewWidth : viewMinX;
136
+ const viewBoxMinY = flipY ? -viewMinY - viewHeight : viewMinY;
137
+
138
+ // Start building SVG
139
+ let svg = `<?xml version="1.0" encoding="UTF-8"?>
140
+ <svg xmlns="http://www.w3.org/2000/svg"
141
+ width="${svgWidthMm.toFixed(2)}mm"
142
+ height="${svgHeightMm.toFixed(2)}mm"
143
+ viewBox="${viewBoxMinX.toFixed(4)} ${viewBoxMinY.toFixed(4)} ${viewWidth.toFixed(4)} ${viewHeight.toFixed(4)}">
144
+ <rect x="${viewBoxMinX.toFixed(4)}" y="${viewBoxMinY.toFixed(4)}" width="${viewWidth.toFixed(4)}" height="${viewHeight.toFixed(4)}" fill="#FFFFFF"/>
145
+ `;
146
+
147
+ // 1. FILL CUT POLYGONS (with color from IFC materials or override engine)
148
+ svg += ' <g id="polygon-fills">\n';
149
+ for (const polygon of drawing.cutPolygons) {
150
+ let fillColor = getFillColorForType(polygon.ifcType);
151
+ let opacity = 1;
152
+
153
+ // Use actual IFC material colors from the mesh data
154
+ if (activePresetId === 'preset-3d-colors') {
155
+ const materialColor = entityColorMap.get(polygon.entityId);
156
+ if (materialColor) {
157
+ const r = Math.round(materialColor[0] * 255);
158
+ const g = Math.round(materialColor[1] * 255);
159
+ const b = Math.round(materialColor[2] * 255);
160
+ fillColor = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
161
+ opacity = materialColor[3];
162
+ }
163
+ } else if (overridesEnabled) {
164
+ const elementData: ElementData = {
165
+ expressId: polygon.entityId,
166
+ ifcType: polygon.ifcType,
167
+ };
168
+ const result = overrideEngine.applyOverrides(elementData);
169
+ fillColor = result.style.fillColor;
170
+ opacity = result.style.opacity;
171
+ }
172
+
173
+ const pathData = polygonToPath(polygon.polygon);
174
+ 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`;
175
+ }
176
+ svg += ' </g>\n';
177
+
178
+ // 2. STROKE CUT POLYGON OUTLINES (with color from override engine)
179
+ svg += ' <g id="polygon-outlines">\n';
180
+ for (const polygon of drawing.cutPolygons) {
181
+ let strokeColor = '#000000';
182
+ let lineWeight = 0.5;
183
+
184
+ if (overridesEnabled) {
185
+ const elementData: ElementData = {
186
+ expressId: polygon.entityId,
187
+ ifcType: polygon.ifcType,
188
+ };
189
+ const result = overrideEngine.applyOverrides(elementData);
190
+ strokeColor = result.style.strokeColor;
191
+ lineWeight = result.style.lineWeight;
192
+ }
193
+
194
+ const pathData = polygonToPath(polygon.polygon);
195
+ // Convert line weight (mm on paper) to model units
196
+ const svgLineWeight = mmToModel(lineWeight);
197
+ svg += ` <path d="${pathData}" fill="none" stroke="${strokeColor}" stroke-width="${svgLineWeight.toFixed(4)}" data-entity-id="${polygon.entityId}"/>\n`;
198
+ }
199
+ svg += ' </g>\n';
200
+
201
+ // 3. DRAW PROJECTION/SILHOUETTE LINES
202
+ // Pre-compute bounds for line validation
203
+ const lineBounds = drawing.bounds;
204
+ const lineMargin = Math.max(lineBounds.max.x - lineBounds.min.x, lineBounds.max.y - lineBounds.min.y) * 0.5;
205
+ const lineMinX = lineBounds.min.x - lineMargin;
206
+ const lineMaxX = lineBounds.max.x + lineMargin;
207
+ const lineMinY = lineBounds.min.y - lineMargin;
208
+ const lineMaxY = lineBounds.max.y + lineMargin;
209
+
210
+ svg += ' <g id="drawing-lines">\n';
211
+ for (const line of drawing.lines) {
212
+ // Skip 'cut' lines - they're triangulation edges, already handled by polygons
213
+ if (line.category === 'cut') continue;
214
+
215
+ // Skip hidden lines if not showing
216
+ if (!displayOptions.showHiddenLines && line.visibility === 'hidden') continue;
217
+
218
+ // Skip lines with invalid coordinates
219
+ const { start, end } = line.line;
220
+ if (!isFinite(start.x) || !isFinite(start.y) || !isFinite(end.x) || !isFinite(end.y)) {
221
+ continue;
222
+ }
223
+ if (start.x < lineMinX || start.x > lineMaxX || start.y < lineMinY || start.y > lineMaxY ||
224
+ end.x < lineMinX || end.x > lineMaxX || end.y < lineMinY || end.y > lineMaxY) {
225
+ continue;
226
+ }
227
+
228
+ // Set line style based on category
229
+ let strokeColor = '#000000';
230
+ let lineWidth = 0.25;
231
+ let dashArray = '';
232
+
233
+ switch (line.category) {
234
+ case 'projection':
235
+ lineWidth = 0.25;
236
+ strokeColor = '#000000';
237
+ break;
238
+ case 'hidden':
239
+ lineWidth = 0.18;
240
+ strokeColor = '#666666';
241
+ dashArray = '2 1';
242
+ break;
243
+ case 'silhouette':
244
+ lineWidth = 0.35;
245
+ strokeColor = '#000000';
246
+ break;
247
+ case 'crease':
248
+ lineWidth = 0.18;
249
+ strokeColor = '#000000';
250
+ break;
251
+ case 'boundary':
252
+ lineWidth = 0.25;
253
+ strokeColor = '#000000';
254
+ break;
255
+ case 'annotation':
256
+ lineWidth = 0.13;
257
+ strokeColor = '#000000';
258
+ break;
259
+ }
260
+
261
+ // Hidden visibility overrides
262
+ if (line.visibility === 'hidden') {
263
+ strokeColor = '#888888';
264
+ dashArray = '2 1';
265
+ lineWidth *= 0.7;
266
+ }
267
+
268
+ // Convert line width from mm on paper to model units
269
+ const svgLineWidth = mmToModel(lineWidth);
270
+ const dashAttr = dashArray ? ` stroke-dasharray="${dashArray.split(' ').map(d => mmToModel(parseFloat(d)).toFixed(4)).join(' ')}"` : '';
271
+
272
+ // Transform line endpoints with axis-specific flipping
273
+ const startT = { x: flipX ? -start.x : start.x, y: flipY ? -start.y : start.y };
274
+ const endT = { x: flipX ? -end.x : end.x, y: flipY ? -end.y : end.y };
275
+ 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`;
276
+ }
277
+ svg += ' </g>\n';
278
+
279
+ // 4. DRAW COMPLETED MEASUREMENTS
280
+ if (measure2DResults.length > 0) {
281
+ svg += ' <g id="measurements">\n';
282
+ for (const result of measure2DResults) {
283
+ const { start, end, distance } = result;
284
+ // Transform measurement points with axis-specific flipping
285
+ const startT = { x: flipX ? -start.x : start.x, y: flipY ? -start.y : start.y };
286
+ const endT = { x: flipX ? -end.x : end.x, y: flipY ? -end.y : end.y };
287
+ const midX = (startT.x + endT.x) / 2;
288
+ const midY = (startT.y + endT.y) / 2;
289
+ const labelText = formatDistance(distance);
290
+
291
+ // Measurement styling (all in mm on paper, converted to model units)
292
+ const measureColor = '#2196F3';
293
+ const measureLineWidth = mmToModel(0.4); // 0.4mm line on paper
294
+ const endpointRadius = mmToModel(1.5); // 1.5mm radius on paper
295
+
296
+ // Draw line
297
+ 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`;
298
+
299
+ // Draw endpoints
300
+ svg += ` <circle cx="${startT.x.toFixed(4)}" cy="${startT.y.toFixed(4)}" r="${endpointRadius.toFixed(4)}" fill="${measureColor}"/>\n`;
301
+ svg += ` <circle cx="${endT.x.toFixed(4)}" cy="${endT.y.toFixed(4)}" r="${endpointRadius.toFixed(4)}" fill="${measureColor}"/>\n`;
302
+
303
+ // Draw label background and text
304
+ // Use 3mm text height on paper for readable labels
305
+ const fontSize = mmToModel(3);
306
+ const labelWidth = labelText.length * fontSize * 0.6; // Approximate text width
307
+ const labelHeight = fontSize * 1.4;
308
+ const labelStroke = mmToModel(0.2);
309
+
310
+ 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`;
311
+ 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`;
312
+ }
313
+ svg += ' </g>\n';
314
+ }
315
+
316
+ // 5. DRAW POLYGON AREA MEASUREMENTS
317
+ if (polygonArea2DResults.length > 0) {
318
+ svg += ' <g id="polygon-area-measurements">\n';
319
+ for (const result of polygonArea2DResults) {
320
+ if (result.points.length < 3) continue;
321
+ const pointsStr = result.points.map(p => {
322
+ const pt = { x: flipX ? -p.x : p.x, y: flipY ? -p.y : p.y };
323
+ return `${pt.x.toFixed(4)},${pt.y.toFixed(4)}`;
324
+ }).join(' ');
325
+
326
+ const measureColor = '#2196F3';
327
+ const lineWidth = mmToModel(0.3);
328
+
329
+ svg += ` <polygon points="${pointsStr}" fill="rgba(33,150,243,0.1)" stroke="${measureColor}" stroke-width="${lineWidth.toFixed(4)}" stroke-dasharray="${mmToModel(1).toFixed(4)} ${mmToModel(0.5).toFixed(4)}"/>\n`;
330
+
331
+ // Label at centroid
332
+ const centroid = computePolygonCentroid(result.points);
333
+ const ct = { x: flipX ? -centroid.x : centroid.x, y: flipY ? -centroid.y : centroid.y };
334
+ const areaText = formatArea(result.area);
335
+ const fontSize = mmToModel(3);
336
+
337
+ svg += ` <text x="${ct.x.toFixed(4)}" y="${ct.y.toFixed(4)}" font-family="Arial, sans-serif" font-size="${fontSize.toFixed(4)}" fill="#000000" text-anchor="middle" dominant-baseline="middle" font-weight="bold">${escapeXml(areaText)}</text>\n`;
338
+ }
339
+ svg += ' </g>\n';
340
+ }
341
+
342
+ // 6. DRAW TEXT ANNOTATIONS
343
+ if (textAnnotations2D.length > 0) {
344
+ svg += ' <g id="text-annotations">\n';
345
+ for (const annotation of textAnnotations2D) {
346
+ if (!annotation.text.trim()) continue;
347
+ const pt = { x: flipX ? -annotation.position.x : annotation.position.x, y: flipY ? -annotation.position.y : annotation.position.y };
348
+ const fontSize = mmToModel(2.5);
349
+ const padding = mmToModel(1);
350
+ const lines = annotation.text.split('\n');
351
+ const lineHeight = fontSize * 1.3;
352
+ const approxWidth = Math.max(...lines.map(l => l.length * fontSize * 0.6)) + padding * 2;
353
+ const height = lines.length * lineHeight + padding * 2;
354
+
355
+ svg += ` <rect x="${pt.x.toFixed(4)}" y="${pt.y.toFixed(4)}" width="${approxWidth.toFixed(4)}" height="${height.toFixed(4)}" fill="${annotation.backgroundColor}" stroke="${annotation.borderColor}" stroke-width="${mmToModel(0.15).toFixed(4)}"/>\n`;
356
+ for (let i = 0; i < lines.length; i++) {
357
+ svg += ` <text x="${(pt.x + padding).toFixed(4)}" y="${(pt.y + padding + fontSize * 0.8 + i * lineHeight).toFixed(4)}" font-family="Arial, sans-serif" font-size="${fontSize.toFixed(4)}" fill="${annotation.color}">${escapeXml(lines[i])}</text>\n`;
358
+ }
359
+ }
360
+ svg += ' </g>\n';
361
+ }
362
+
363
+ // 7. DRAW CLOUD ANNOTATIONS
364
+ if (cloudAnnotations2D.length > 0) {
365
+ svg += ' <g id="cloud-annotations">\n';
366
+ for (const cloud of cloudAnnotations2D) {
367
+ if (cloud.points.length < 2) continue;
368
+ const rectW = Math.abs(cloud.points[1].x - cloud.points[0].x);
369
+ const rectH = Math.abs(cloud.points[1].y - cloud.points[0].y);
370
+ const arcRadius = Math.min(rectW, rectH) * 0.15 || 0.2;
371
+
372
+ const transformX = (x: number) => flipX ? -x : x;
373
+ const transformY = (y: number) => flipY ? -y : y;
374
+ const pathData = generateCloudSVGPath(cloud.points[0], cloud.points[1], arcRadius, transformX, transformY);
375
+ const lineWidth = mmToModel(0.4);
376
+
377
+ svg += ` <path d="${pathData}" fill="rgba(229,57,53,0.05)" stroke="${cloud.color}" stroke-width="${lineWidth.toFixed(4)}"/>\n`;
378
+
379
+ if (cloud.label) {
380
+ const cx = transformX((cloud.points[0].x + cloud.points[1].x) / 2);
381
+ const cy = transformY((cloud.points[0].y + cloud.points[1].y) / 2);
382
+ const fontSize = mmToModel(3);
383
+ svg += ` <text x="${cx.toFixed(4)}" y="${cy.toFixed(4)}" font-family="Arial, sans-serif" font-size="${fontSize.toFixed(4)}" fill="${cloud.color}" text-anchor="middle" dominant-baseline="middle" font-weight="bold">${escapeXml(cloud.label)}</text>\n`;
384
+ }
385
+ }
386
+ svg += ' </g>\n';
387
+ }
388
+
389
+ svg += '</svg>';
390
+ return svg;
391
+ }, [drawing, displayOptions, activePresetId, entityColorMap, overridesEnabled, overrideEngine, measure2DResults, polygonArea2DResults, textAnnotations2D, cloudAnnotations2D, sectionPlane.axis]);
392
+
393
+ // Generate SVG with drawing sheet (frame, title block, scale bar)
394
+ // This generates coordinates directly in paper mm space (like the canvas rendering)
395
+ const generateSheetSVG = useCallback((): string | null => {
396
+ if (!drawing || !activeSheet) return null;
397
+
398
+ const { bounds } = drawing;
399
+
400
+ // Sheet dimensions in mm
401
+ const paperWidth = activeSheet.paper.widthMm;
402
+ const paperHeight = activeSheet.paper.heightMm;
403
+ const viewport = activeSheet.viewportBounds;
404
+
405
+ // Calculate transform to fit drawing into viewport
406
+ const drawingTransform = calculateDrawingTransform(
407
+ { minX: bounds.min.x, minY: bounds.min.y, maxX: bounds.max.x, maxY: bounds.max.y },
408
+ viewport,
409
+ activeSheet.scale
410
+ );
411
+
412
+ const { translateX, translateY, scaleFactor } = drawingTransform;
413
+
414
+ // Axis-specific flipping (matching canvas rendering)
415
+ // - 'down' (plan view): DON'T flip Y so north (Z+) is up
416
+ // - 'front' and 'side': flip Y so height (Y+) is up
417
+ // - 'side': also flip X to look from conventional direction
418
+ const currentAxis = sectionPlane.axis;
419
+ const flipY = currentAxis !== 'down';
420
+ const flipX = currentAxis === 'side';
421
+
422
+ // Helper: convert model coordinates to paper mm (matching canvas rendering exactly)
423
+ const modelToPaper = (x: number, y: number): { x: number; y: number } => {
424
+ const adjustedX = flipX ? -x : x;
425
+ const adjustedY = flipY ? -y : y;
426
+ return {
427
+ x: adjustedX * scaleFactor + translateX,
428
+ y: adjustedY * scaleFactor + translateY,
429
+ };
430
+ };
431
+
432
+ // Start building SVG (paper coordinates in mm)
433
+ let svg = `<?xml version="1.0" encoding="UTF-8"?>
434
+ <svg xmlns="http://www.w3.org/2000/svg"
435
+ width="${paperWidth}mm"
436
+ height="${paperHeight}mm"
437
+ viewBox="0 0 ${paperWidth} ${paperHeight}">
438
+ <!-- Background -->
439
+ <rect x="0" y="0" width="${paperWidth}" height="${paperHeight}" fill="#FFFFFF"/>
440
+
441
+ `;
442
+
443
+ // Create clipping path for viewport FIRST (so it can be used by drawing content)
444
+ svg += ` <defs>
445
+ <clipPath id="viewport-clip">
446
+ <rect x="${viewport.x.toFixed(2)}" y="${viewport.y.toFixed(2)}" width="${viewport.width.toFixed(2)}" height="${viewport.height.toFixed(2)}"/>
447
+ </clipPath>
448
+ </defs>
449
+
450
+ `;
451
+
452
+ // Drawing content FIRST (so frame/title block render on top)
453
+ svg += ` <g id="drawing-content" clip-path="url(#viewport-clip)">
454
+ `;
455
+
456
+ // Helper to escape XML
457
+ const escapeXml = (str: string): string => {
458
+ return str
459
+ .replace(/&/g, '&amp;')
460
+ .replace(/</g, '&lt;')
461
+ .replace(/>/g, '&gt;')
462
+ .replace(/"/g, '&quot;')
463
+ .replace(/'/g, '&apos;');
464
+ };
465
+
466
+ // Helper to get polygon path in paper coordinates
467
+ const polygonToPath = (polygon: { outer: { x: number; y: number }[]; holes: { x: number; y: number }[][] }): string => {
468
+ let path = '';
469
+ if (polygon.outer.length > 0) {
470
+ const first = modelToPaper(polygon.outer[0].x, polygon.outer[0].y);
471
+ path += `M ${first.x.toFixed(4)} ${first.y.toFixed(4)}`;
472
+ for (let i = 1; i < polygon.outer.length; i++) {
473
+ const pt = modelToPaper(polygon.outer[i].x, polygon.outer[i].y);
474
+ path += ` L ${pt.x.toFixed(4)} ${pt.y.toFixed(4)}`;
475
+ }
476
+ path += ' Z';
477
+ }
478
+ for (const hole of polygon.holes) {
479
+ if (hole.length > 0) {
480
+ const holeFirst = modelToPaper(hole[0].x, hole[0].y);
481
+ path += ` M ${holeFirst.x.toFixed(4)} ${holeFirst.y.toFixed(4)}`;
482
+ for (let i = 1; i < hole.length; i++) {
483
+ const pt = modelToPaper(hole[i].x, hole[i].y);
484
+ path += ` L ${pt.x.toFixed(4)} ${pt.y.toFixed(4)}`;
485
+ }
486
+ path += ' Z';
487
+ }
488
+ }
489
+ return path;
490
+ };
491
+
492
+ // Render polygon fills
493
+ svg += ' <g id="polygon-fills">\n';
494
+ for (const polygon of drawing.cutPolygons) {
495
+ let fillColor = getFillColorForType(polygon.ifcType);
496
+ let opacity = 1;
497
+
498
+ if (activePresetId === 'preset-3d-colors') {
499
+ const materialColor = entityColorMap.get(polygon.entityId);
500
+ if (materialColor) {
501
+ const r = Math.round(materialColor[0] * 255);
502
+ const g = Math.round(materialColor[1] * 255);
503
+ const b = Math.round(materialColor[2] * 255);
504
+ fillColor = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
505
+ opacity = materialColor[3];
506
+ }
507
+ } else if (overridesEnabled) {
508
+ const elementData: ElementData = {
509
+ expressId: polygon.entityId,
510
+ ifcType: polygon.ifcType,
511
+ };
512
+ const result = overrideEngine.applyOverrides(elementData);
513
+ fillColor = result.style.fillColor;
514
+ opacity = result.style.opacity;
515
+ }
516
+
517
+ const pathData = polygonToPath(polygon.polygon);
518
+ if (pathData) {
519
+ 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`;
520
+ }
521
+ }
522
+ svg += ' </g>\n';
523
+
524
+ // Render polygon outlines
525
+ svg += ' <g id="polygon-outlines">\n';
526
+ for (const polygon of drawing.cutPolygons) {
527
+ let strokeColor = '#000000';
528
+ let lineWeight = 0.5;
529
+
530
+ if (overridesEnabled) {
531
+ const elementData: ElementData = {
532
+ expressId: polygon.entityId,
533
+ ifcType: polygon.ifcType,
534
+ };
535
+ const result = overrideEngine.applyOverrides(elementData);
536
+ strokeColor = result.style.strokeColor;
537
+ lineWeight = result.style.lineWeight;
538
+ }
539
+
540
+ const pathData = polygonToPath(polygon.polygon);
541
+ if (pathData) {
542
+ // lineWeight is in mm on paper
543
+ const svgLineWeight = lineWeight * 0.3; // Scale down for better appearance
544
+ svg += ` <path d="${pathData}" fill="none" stroke="${strokeColor}" stroke-width="${svgLineWeight.toFixed(4)}" data-entity-id="${polygon.entityId}"/>\n`;
545
+ }
546
+ }
547
+ svg += ' </g>\n';
548
+
549
+ // Render drawing lines
550
+ const lineBounds = drawing.bounds;
551
+ const lineMargin = Math.max(lineBounds.max.x - lineBounds.min.x, lineBounds.max.y - lineBounds.min.y) * 0.5;
552
+ const lineMinX = lineBounds.min.x - lineMargin;
553
+ const lineMaxX = lineBounds.max.x + lineMargin;
554
+ const lineMinY = lineBounds.min.y - lineMargin;
555
+ const lineMaxY = lineBounds.max.y + lineMargin;
556
+
557
+ svg += ' <g id="drawing-lines">\n';
558
+ for (const line of drawing.lines) {
559
+ if (line.category === 'cut') continue;
560
+ if (!displayOptions.showHiddenLines && line.visibility === 'hidden') continue;
561
+
562
+ const { start, end } = line.line;
563
+ if (!isFinite(start.x) || !isFinite(start.y) || !isFinite(end.x) || !isFinite(end.y)) continue;
564
+ if (start.x < lineMinX || start.x > lineMaxX || start.y < lineMinY || start.y > lineMaxY ||
565
+ end.x < lineMinX || end.x > lineMaxX || end.y < lineMinY || end.y > lineMaxY) continue;
566
+
567
+ let strokeColor = '#000000';
568
+ let lineWidth = 0.25;
569
+ let dashArray = '';
570
+
571
+ switch (line.category) {
572
+ case 'projection': lineWidth = 0.25; break;
573
+ case 'hidden': lineWidth = 0.18; strokeColor = '#666666'; dashArray = '1 0.5'; break;
574
+ case 'silhouette': lineWidth = 0.35; break;
575
+ case 'crease': lineWidth = 0.18; break;
576
+ case 'boundary': lineWidth = 0.25; break;
577
+ case 'annotation': lineWidth = 0.13; break;
578
+ }
579
+
580
+ if (line.visibility === 'hidden') {
581
+ strokeColor = '#888888';
582
+ dashArray = '1 0.5';
583
+ lineWidth *= 0.7;
584
+ }
585
+
586
+ const paperStart = modelToPaper(start.x, start.y);
587
+ const paperEnd = modelToPaper(end.x, end.y);
588
+
589
+ // lineWidth is in mm on paper
590
+ const svgLineWidth = lineWidth * 0.3;
591
+ const dashAttr = dashArray ? ` stroke-dasharray="${dashArray}"` : '';
592
+ 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`;
593
+ }
594
+ svg += ' </g>\n';
595
+
596
+ svg += ' </g>\n\n';
597
+
598
+ // Render frame (on top of drawing content)
599
+ const frameResult = renderFrame(activeSheet.paper, activeSheet.frame);
600
+ svg += frameResult.svgElements;
601
+ svg += '\n';
602
+
603
+ // Render title block with scale bar and north arrow inside
604
+ // Pass effectiveScaleFactor from the actual transform (not just configured scale)
605
+ // This ensures scale bar shows correct values when dynamically scaled
606
+ const titleBlockExtras: TitleBlockExtras = {
607
+ scaleBar: activeSheet.scaleBar,
608
+ northArrow: activeSheet.northArrow,
609
+ scale: activeSheet.scale,
610
+ effectiveScaleFactor: scaleFactor,
611
+ };
612
+ const titleBlockResult = renderTitleBlock(
613
+ activeSheet.titleBlock,
614
+ frameResult.innerBounds,
615
+ activeSheet.revisions,
616
+ titleBlockExtras
617
+ );
618
+ svg += titleBlockResult.svgElements;
619
+ svg += '\n';
620
+
621
+ svg += '</svg>';
622
+ return svg;
623
+ }, [drawing, activeSheet, displayOptions, activePresetId, entityColorMap, overridesEnabled, overrideEngine]);
624
+
625
+ // Export SVG
626
+ const handleExportSVG = useCallback(() => {
627
+ // Use sheet export if enabled, otherwise raw drawing export
628
+ const svg = (sheetEnabled && activeSheet) ? generateSheetSVG() : generateExportSVG();
629
+ if (!svg) return;
630
+ const blob = new Blob([svg], { type: 'image/svg+xml' });
631
+ const url = URL.createObjectURL(blob);
632
+ const a = document.createElement('a');
633
+ a.href = url;
634
+ const filename = (sheetEnabled && activeSheet)
635
+ ? `${activeSheet.name.replace(/\s+/g, '-')}-${sectionPlane.axis}-${sectionPlane.position}.svg`
636
+ : `section-${sectionPlane.axis}-${sectionPlane.position}.svg`;
637
+ a.download = filename;
638
+ a.click();
639
+ URL.revokeObjectURL(url);
640
+ }, [generateExportSVG, generateSheetSVG, sheetEnabled, activeSheet, sectionPlane]);
641
+
642
+ // Print handler
643
+ const handlePrint = useCallback(() => {
644
+ // Use sheet export if enabled, otherwise raw drawing export
645
+ const svg = (sheetEnabled && activeSheet) ? generateSheetSVG() : generateExportSVG();
646
+ if (!svg) return;
647
+
648
+ // Create a new window for printing
649
+ const printWindow = window.open('', '_blank', 'width=800,height=600');
650
+ if (!printWindow) {
651
+ alert('Please allow popups to print');
652
+ return;
653
+ }
654
+
655
+ const title = (sheetEnabled && activeSheet)
656
+ ? `${activeSheet.name} - ${sectionPlane.axis} at ${sectionPlane.position}%`
657
+ : `Section Drawing - ${sectionPlane.axis} at ${sectionPlane.position}%`;
658
+
659
+ // Write print-friendly HTML with the SVG
660
+ printWindow.document.write(`
661
+ <!DOCTYPE html>
662
+ <html>
663
+ <head>
664
+ <title>${title}</title>
665
+ <style>
666
+ @media print {
667
+ @page { margin: ${(sheetEnabled && activeSheet) ? '0' : '1cm'}; }
668
+ body { margin: 0; }
669
+ }
670
+ body {
671
+ display: flex;
672
+ justify-content: center;
673
+ align-items: center;
674
+ min-height: 100vh;
675
+ margin: 0;
676
+ padding: ${(sheetEnabled && activeSheet) ? '0' : '20px'};
677
+ box-sizing: border-box;
678
+ }
679
+ svg {
680
+ max-width: 100%;
681
+ max-height: 100vh;
682
+ width: auto;
683
+ height: auto;
684
+ }
685
+ </style>
686
+ </head>
687
+ <body>
688
+ ${svg}
689
+ <script>
690
+ window.onload = function() {
691
+ window.print();
692
+ window.onafterprint = function() { window.close(); };
693
+ };
694
+ </script>
695
+ </body>
696
+ </html>
697
+ `);
698
+ printWindow.document.close();
699
+ }, [generateExportSVG, generateSheetSVG, sheetEnabled, activeSheet, sectionPlane]);
700
+
701
+ return {
702
+ formatDistance,
703
+ handleExportSVG,
704
+ handlePrint,
705
+ };
706
+ }
707
+
708
+ export { useDrawingExport };
709
+ export default useDrawingExport;