@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,365 @@
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
+ /**
6
+ * Hook for 2D measurement tool logic
7
+ * Extracts pan/measure mouse handling, snapping, orthogonal constraints,
8
+ * and keyboard/global-mouseup effects from Section2DPanel.
9
+ */
10
+
11
+ import { useCallback, useEffect, useRef } from 'react';
12
+ import type { Drawing2D } from '@ifc-lite/drawing-2d';
13
+
14
+ // ─── Public interfaces ──────────────────────────────────────────────────────
15
+
16
+ export interface UseMeasure2DParams {
17
+ drawing: Drawing2D | null;
18
+ viewTransform: { x: number; y: number; scale: number };
19
+ setViewTransform: React.Dispatch<React.SetStateAction<{ x: number; y: number; scale: number }>>;
20
+ sectionAxis: 'down' | 'front' | 'side';
21
+ containerRef: React.RefObject<HTMLDivElement | null>;
22
+ // Store state
23
+ measure2DMode: boolean;
24
+ measure2DStart: { x: number; y: number } | null;
25
+ measure2DCurrent: { x: number; y: number } | null;
26
+ measure2DShiftLocked: boolean;
27
+ measure2DLockedAxis: 'x' | 'y' | null;
28
+ setMeasure2DStart: (pt: { x: number; y: number }) => void;
29
+ setMeasure2DCurrent: (pt: { x: number; y: number }) => void;
30
+ setMeasure2DShiftLocked: (locked: boolean, axis?: 'x' | 'y') => void;
31
+ setMeasure2DSnapPoint: (pt: { x: number; y: number } | null) => void;
32
+ cancelMeasure2D: () => void;
33
+ completeMeasure2D: () => void;
34
+ }
35
+
36
+ export interface UseMeasure2DResult {
37
+ handleMouseDown: (e: React.MouseEvent) => void;
38
+ handleMouseMove: (e: React.MouseEvent) => void;
39
+ handleMouseUp: () => void;
40
+ handleMouseLeave: () => void;
41
+ handleMouseEnter: (e: React.MouseEvent) => void;
42
+ }
43
+
44
+ // ─── Hook implementation ────────────────────────────────────────────────────
45
+
46
+ export function useMeasure2D({
47
+ drawing,
48
+ viewTransform,
49
+ setViewTransform,
50
+ sectionAxis,
51
+ containerRef,
52
+ measure2DMode,
53
+ measure2DStart,
54
+ measure2DCurrent,
55
+ measure2DShiftLocked,
56
+ measure2DLockedAxis,
57
+ setMeasure2DStart,
58
+ setMeasure2DCurrent,
59
+ setMeasure2DShiftLocked,
60
+ setMeasure2DSnapPoint,
61
+ cancelMeasure2D,
62
+ completeMeasure2D,
63
+ }: UseMeasure2DParams): UseMeasure2DResult {
64
+ // ── Internal refs ───────────────────────────────────────────────────────
65
+ const isPanning = useRef(false);
66
+ const lastPanPoint = useRef({ x: 0, y: 0 });
67
+ const isMouseButtonDown = useRef(false);
68
+ const isMouseInsidePanel = useRef(true);
69
+
70
+ // ═══════════════════════════════════════════════════════════════════════
71
+ // 2D MEASURE TOOL HELPER FUNCTIONS
72
+ // ═══════════════════════════════════════════════════════════════════════
73
+
74
+ // Convert screen coordinates to drawing coordinates
75
+ const screenToDrawing = useCallback((screenX: number, screenY: number): { x: number; y: number } => {
76
+ // Screen coord → drawing coord
77
+ // Apply axis-specific inverse transforms (matching canvas rendering)
78
+ const currentAxis = sectionAxis;
79
+ const flipY = currentAxis !== 'down'; // Only flip Y for front/side views
80
+ const flipX = currentAxis === 'side'; // Flip X for side view
81
+
82
+ // Inverse of: screenX = drawingX * scaleX + transform.x
83
+ // where scaleX = flipX ? -scale : scale
84
+ const scaleX = flipX ? -viewTransform.scale : viewTransform.scale;
85
+ const scaleY = flipY ? -viewTransform.scale : viewTransform.scale;
86
+
87
+ const x = (screenX - viewTransform.x) / scaleX;
88
+ const y = (screenY - viewTransform.y) / scaleY;
89
+ return { x, y };
90
+ }, [viewTransform, sectionAxis]);
91
+
92
+ // Find nearest point on a line segment
93
+ const nearestPointOnSegment = useCallback((
94
+ p: { x: number; y: number },
95
+ a: { x: number; y: number },
96
+ b: { x: number; y: number }
97
+ ): { point: { x: number; y: number }; dist: number } => {
98
+ const dx = b.x - a.x;
99
+ const dy = b.y - a.y;
100
+ const lenSq = dx * dx + dy * dy;
101
+
102
+ if (lenSq < 0.0001) {
103
+ // Degenerate segment
104
+ const d = Math.sqrt((p.x - a.x) ** 2 + (p.y - a.y) ** 2);
105
+ return { point: a, dist: d };
106
+ }
107
+
108
+ // Parameter t along segment [0,1]
109
+ let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq;
110
+ t = Math.max(0, Math.min(1, t));
111
+
112
+ const nearest = { x: a.x + t * dx, y: a.y + t * dy };
113
+ const dist = Math.sqrt((p.x - nearest.x) ** 2 + (p.y - nearest.y) ** 2);
114
+
115
+ return { point: nearest, dist };
116
+ }, []);
117
+
118
+ // Find snap point near cursor (check polygon vertices, edges, and line endpoints)
119
+ const findSnapPoint = useCallback((drawingCoord: { x: number; y: number }): { x: number; y: number } | null => {
120
+ if (!drawing) return null;
121
+
122
+ const snapThreshold = 10 / viewTransform.scale; // 10 screen pixels
123
+ let bestSnap: { x: number; y: number } | null = null;
124
+ let bestDist = snapThreshold;
125
+
126
+ // Priority 1: Check polygon vertices (endpoints are highest priority)
127
+ for (const polygon of drawing.cutPolygons) {
128
+ for (const pt of polygon.polygon.outer) {
129
+ const dist = Math.sqrt((pt.x - drawingCoord.x) ** 2 + (pt.y - drawingCoord.y) ** 2);
130
+ if (dist < bestDist * 0.7) { // Vertices get priority (70% threshold)
131
+ return { x: pt.x, y: pt.y }; // Return immediately for vertex snaps
132
+ }
133
+ }
134
+ for (const hole of polygon.polygon.holes) {
135
+ for (const pt of hole) {
136
+ const dist = Math.sqrt((pt.x - drawingCoord.x) ** 2 + (pt.y - drawingCoord.y) ** 2);
137
+ if (dist < bestDist * 0.7) {
138
+ return { x: pt.x, y: pt.y };
139
+ }
140
+ }
141
+ }
142
+ }
143
+
144
+ // Priority 2: Check line endpoints
145
+ for (const line of drawing.lines) {
146
+ const { start, end } = line.line;
147
+ for (const pt of [start, end]) {
148
+ const dist = Math.sqrt((pt.x - drawingCoord.x) ** 2 + (pt.y - drawingCoord.y) ** 2);
149
+ if (dist < bestDist * 0.7) {
150
+ return { x: pt.x, y: pt.y };
151
+ }
152
+ }
153
+ }
154
+
155
+ // Priority 3: Check polygon edges
156
+ for (const polygon of drawing.cutPolygons) {
157
+ const outer = polygon.polygon.outer;
158
+ for (let i = 0; i < outer.length; i++) {
159
+ const a = outer[i];
160
+ const b = outer[(i + 1) % outer.length];
161
+ const { point, dist } = nearestPointOnSegment(drawingCoord, a, b);
162
+ if (dist < bestDist) {
163
+ bestDist = dist;
164
+ bestSnap = point;
165
+ }
166
+ }
167
+ for (const hole of polygon.polygon.holes) {
168
+ for (let i = 0; i < hole.length; i++) {
169
+ const a = hole[i];
170
+ const b = hole[(i + 1) % hole.length];
171
+ const { point, dist } = nearestPointOnSegment(drawingCoord, a, b);
172
+ if (dist < bestDist) {
173
+ bestDist = dist;
174
+ bestSnap = point;
175
+ }
176
+ }
177
+ }
178
+ }
179
+
180
+ // Priority 4: Check drawing lines
181
+ for (const line of drawing.lines) {
182
+ const { start, end } = line.line;
183
+ const { point, dist } = nearestPointOnSegment(drawingCoord, start, end);
184
+ if (dist < bestDist) {
185
+ bestDist = dist;
186
+ bestSnap = point;
187
+ }
188
+ }
189
+
190
+ return bestSnap;
191
+ }, [drawing, viewTransform.scale, nearestPointOnSegment]);
192
+
193
+ // Apply orthogonal constraint if shift is held
194
+ const applyOrthogonalConstraint = useCallback((start: { x: number; y: number }, current: { x: number; y: number }, lockedAxis: 'x' | 'y' | null): { x: number; y: number } => {
195
+ if (!lockedAxis) return current;
196
+
197
+ if (lockedAxis === 'x') {
198
+ return { x: current.x, y: start.y };
199
+ } else {
200
+ return { x: start.x, y: current.y };
201
+ }
202
+ }, []);
203
+
204
+ // ═══════════════════════════════════════════════════════════════════════
205
+ // EFFECTS
206
+ // ═══════════════════════════════════════════════════════════════════════
207
+
208
+ // Keyboard handlers for shift key (orthogonal constraint)
209
+ useEffect(() => {
210
+ if (!measure2DMode) return;
211
+
212
+ const handleKeyDown = (e: KeyboardEvent) => {
213
+ if (e.key === 'Shift' && measure2DStart && measure2DCurrent && !measure2DShiftLocked) {
214
+ // Determine axis based on dominant direction
215
+ const dx = Math.abs(measure2DCurrent.x - measure2DStart.x);
216
+ const dy = Math.abs(measure2DCurrent.y - measure2DStart.y);
217
+ const axis = dx > dy ? 'x' : 'y';
218
+ setMeasure2DShiftLocked(true, axis);
219
+ }
220
+ if (e.key === 'Escape') {
221
+ cancelMeasure2D();
222
+ }
223
+ };
224
+
225
+ const handleKeyUp = (e: KeyboardEvent) => {
226
+ if (e.key === 'Shift') {
227
+ setMeasure2DShiftLocked(false);
228
+ }
229
+ };
230
+
231
+ window.addEventListener('keydown', handleKeyDown);
232
+ window.addEventListener('keyup', handleKeyUp);
233
+
234
+ return () => {
235
+ window.removeEventListener('keydown', handleKeyDown);
236
+ window.removeEventListener('keyup', handleKeyUp);
237
+ };
238
+ }, [measure2DMode, measure2DStart, measure2DCurrent, measure2DShiftLocked, setMeasure2DShiftLocked, cancelMeasure2D]);
239
+
240
+ // Global mouseup handler to cancel measurement if released outside panel
241
+ useEffect(() => {
242
+ if (!measure2DMode) return;
243
+
244
+ const handleGlobalMouseUp = (e: MouseEvent) => {
245
+ // If mouse button is released and we're outside the panel with a measurement started, cancel it
246
+ if (!isMouseInsidePanel.current && measure2DStart && e.button === 0) {
247
+ cancelMeasure2D();
248
+ }
249
+ isMouseButtonDown.current = false;
250
+ };
251
+
252
+ window.addEventListener('mouseup', handleGlobalMouseUp);
253
+ return () => {
254
+ window.removeEventListener('mouseup', handleGlobalMouseUp);
255
+ };
256
+ }, [measure2DMode, measure2DStart, cancelMeasure2D]);
257
+
258
+ // ═══════════════════════════════════════════════════════════════════════
259
+ // PAN / MEASURE HANDLERS
260
+ // ═══════════════════════════════════════════════════════════════════════
261
+
262
+ const handleMouseDown = useCallback((e: React.MouseEvent) => {
263
+ if (e.button !== 0) return;
264
+
265
+ isMouseButtonDown.current = true;
266
+ const rect = containerRef.current?.getBoundingClientRect();
267
+ if (!rect) return;
268
+
269
+ const screenX = e.clientX - rect.left;
270
+ const screenY = e.clientY - rect.top;
271
+
272
+ if (measure2DMode) {
273
+ // Measure mode: set start point
274
+ const drawingCoord = screenToDrawing(screenX, screenY);
275
+ const snapPoint = findSnapPoint(drawingCoord);
276
+ const startPoint = snapPoint || drawingCoord;
277
+ setMeasure2DStart(startPoint);
278
+ setMeasure2DCurrent(startPoint);
279
+ } else {
280
+ // Pan mode
281
+ isPanning.current = true;
282
+ lastPanPoint.current = { x: e.clientX, y: e.clientY };
283
+ }
284
+ }, [measure2DMode, screenToDrawing, findSnapPoint, setMeasure2DStart, setMeasure2DCurrent]);
285
+
286
+ const handleMouseMove = useCallback((e: React.MouseEvent) => {
287
+ const rect = containerRef.current?.getBoundingClientRect();
288
+ if (!rect) return;
289
+
290
+ const screenX = e.clientX - rect.left;
291
+ const screenY = e.clientY - rect.top;
292
+
293
+ if (measure2DMode) {
294
+ const drawingCoord = screenToDrawing(screenX, screenY);
295
+
296
+ // Find snap point and update
297
+ const snapPoint = findSnapPoint(drawingCoord);
298
+ setMeasure2DSnapPoint(snapPoint);
299
+
300
+ if (measure2DStart) {
301
+ // If measuring, update current point
302
+ let currentPoint = snapPoint || drawingCoord;
303
+
304
+ // Apply orthogonal constraint if shift is held
305
+ if (measure2DShiftLocked && measure2DLockedAxis) {
306
+ currentPoint = applyOrthogonalConstraint(measure2DStart, currentPoint, measure2DLockedAxis);
307
+ }
308
+
309
+ setMeasure2DCurrent(currentPoint);
310
+ }
311
+ } else if (isPanning.current) {
312
+ // Pan mode
313
+ const dx = e.clientX - lastPanPoint.current.x;
314
+ const dy = e.clientY - lastPanPoint.current.y;
315
+ lastPanPoint.current = { x: e.clientX, y: e.clientY };
316
+ setViewTransform((prev) => ({
317
+ ...prev,
318
+ x: prev.x + dx,
319
+ y: prev.y + dy,
320
+ }));
321
+ }
322
+ }, [measure2DMode, measure2DStart, measure2DShiftLocked, measure2DLockedAxis, screenToDrawing, findSnapPoint, setMeasure2DSnapPoint, setMeasure2DCurrent, applyOrthogonalConstraint]);
323
+
324
+ const handleMouseUp = useCallback(() => {
325
+ isMouseButtonDown.current = false;
326
+ if (measure2DMode && measure2DStart && measure2DCurrent) {
327
+ // Complete the measurement
328
+ completeMeasure2D();
329
+ }
330
+ isPanning.current = false;
331
+ }, [measure2DMode, measure2DStart, measure2DCurrent, completeMeasure2D]);
332
+
333
+ const handleMouseLeave = useCallback(() => {
334
+ isMouseInsidePanel.current = false;
335
+ // Don't cancel if button is still down - user might re-enter
336
+ // Cancel will happen on global mouseup if released outside
337
+ isPanning.current = false;
338
+ }, []);
339
+
340
+ const handleMouseEnter = useCallback((e: React.MouseEvent) => {
341
+ isMouseInsidePanel.current = true;
342
+ // If re-entering with button down and measurement started, resume tracking
343
+ if (isMouseButtonDown.current && measure2DMode && measure2DStart) {
344
+ const rect = containerRef.current?.getBoundingClientRect();
345
+ if (rect) {
346
+ const screenX = e.clientX - rect.left;
347
+ const screenY = e.clientY - rect.top;
348
+ const drawingCoord = screenToDrawing(screenX, screenY);
349
+ const snapPoint = findSnapPoint(drawingCoord);
350
+ const currentPoint = snapPoint || drawingCoord;
351
+ setMeasure2DCurrent(currentPoint);
352
+ }
353
+ }
354
+ }, [measure2DMode, measure2DStart, screenToDrawing, findSnapPoint, setMeasure2DCurrent]);
355
+
356
+ return {
357
+ handleMouseDown,
358
+ handleMouseMove,
359
+ handleMouseUp,
360
+ handleMouseLeave,
361
+ handleMouseEnter,
362
+ };
363
+ }
364
+
365
+ export default useMeasure2D;
@@ -0,0 +1,218 @@
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, useEffect, useRef, useState } from 'react';
6
+ import type { Drawing2D, DrawingSheet } from '@ifc-lite/drawing-2d';
7
+
8
+ interface UseViewControlsParams {
9
+ drawing: Drawing2D | null;
10
+ sectionPlane: { axis: 'down' | 'front' | 'side'; position: number; flipped: boolean };
11
+ containerRef: React.RefObject<HTMLDivElement | null>;
12
+ panelVisible: boolean;
13
+ status: string;
14
+ sheetEnabled: boolean;
15
+ activeSheet: DrawingSheet | null;
16
+ isPinned: boolean;
17
+ cachedSheetTransformRef: React.MutableRefObject<{
18
+ translateX: number;
19
+ translateY: number;
20
+ scaleFactor: number;
21
+ } | null>;
22
+ }
23
+
24
+ interface UseViewControlsResult {
25
+ viewTransform: { x: number; y: number; scale: number };
26
+ setViewTransform: React.Dispatch<React.SetStateAction<{ x: number; y: number; scale: number }>>;
27
+ zoomIn: () => void;
28
+ zoomOut: () => void;
29
+ fitToView: () => void;
30
+ }
31
+
32
+ function useViewControls({
33
+ drawing,
34
+ sectionPlane,
35
+ containerRef,
36
+ panelVisible,
37
+ status,
38
+ sheetEnabled,
39
+ activeSheet,
40
+ isPinned,
41
+ cachedSheetTransformRef,
42
+ }: UseViewControlsParams): UseViewControlsResult {
43
+ const [viewTransform, setViewTransform] = useState({ x: 0, y: 0, scale: 1 });
44
+ const [needsFit, setNeedsFit] = useState(true); // Force fit on first open and axis change
45
+ const prevAxisRef = useRef(sectionPlane.axis); // Track axis changes
46
+
47
+ // Wheel zoom handler
48
+ useEffect(() => {
49
+ // Only attach handler when panel is visible
50
+ if (!panelVisible) return;
51
+
52
+ const container = containerRef.current;
53
+ if (!container) {
54
+ // Container not ready yet, try again on next render
55
+ return;
56
+ }
57
+
58
+ const wheelHandler = (e: WheelEvent) => {
59
+ e.preventDefault();
60
+ e.stopPropagation();
61
+ const delta = e.deltaY > 0 ? 0.9 : 1.1;
62
+ const rect = container.getBoundingClientRect();
63
+
64
+ const x = e.clientX - rect.left;
65
+ const y = e.clientY - rect.top;
66
+
67
+ setViewTransform((prev) => {
68
+ const newScale = Math.max(0.01, prev.scale * delta);
69
+ const scaleRatio = newScale / prev.scale;
70
+ return {
71
+ scale: newScale,
72
+ x: x - (x - prev.x) * scaleRatio,
73
+ y: y - (y - prev.y) * scaleRatio,
74
+ };
75
+ });
76
+ };
77
+
78
+ container.addEventListener('wheel', wheelHandler, { passive: false });
79
+ return () => {
80
+ container.removeEventListener('wheel', wheelHandler);
81
+ };
82
+ }, [panelVisible, status]); // Re-run when panel visibility or status changes to ensure container is ready
83
+
84
+ // Zoom controls - unlimited zoom
85
+ const zoomIn = useCallback(() => {
86
+ setViewTransform((prev) => ({ ...prev, scale: prev.scale * 1.2 })); // No upper limit
87
+ }, []);
88
+
89
+ const zoomOut = useCallback(() => {
90
+ setViewTransform((prev) => ({ ...prev, scale: Math.max(0.01, prev.scale / 1.2) }));
91
+ }, []);
92
+
93
+ const fitToView = useCallback(() => {
94
+ if (!drawing || !containerRef.current) return;
95
+ const rect = containerRef.current.getBoundingClientRect();
96
+
97
+ // Sheet mode: fit the entire paper into view
98
+ if (sheetEnabled && activeSheet) {
99
+ const paperWidth = activeSheet.paper.widthMm;
100
+ const paperHeight = activeSheet.paper.heightMm;
101
+
102
+ // Calculate scale to fit paper with padding (10% margin on each side)
103
+ const padding = 0.1;
104
+ const availableWidth = rect.width * (1 - 2 * padding);
105
+ const availableHeight = rect.height * (1 - 2 * padding);
106
+ const scaleX = availableWidth / paperWidth;
107
+ const scaleY = availableHeight / paperHeight;
108
+ const scale = Math.min(scaleX, scaleY);
109
+
110
+ // Center the paper in the view
111
+ setViewTransform({
112
+ scale,
113
+ x: (rect.width - paperWidth * scale) / 2,
114
+ y: (rect.height - paperHeight * scale) / 2,
115
+ });
116
+ return;
117
+ }
118
+
119
+ // Non-sheet mode: fit the drawing bounds
120
+ const { bounds } = drawing;
121
+ const width = bounds.max.x - bounds.min.x;
122
+ const height = bounds.max.y - bounds.min.y;
123
+
124
+ if (width < 0.001 || height < 0.001) return;
125
+
126
+ // Calculate scale to fit with padding (15% margin on each side)
127
+ const padding = 0.15;
128
+ const availableWidth = rect.width * (1 - 2 * padding);
129
+ const availableHeight = rect.height * (1 - 2 * padding);
130
+ const scaleX = availableWidth / width;
131
+ const scaleY = availableHeight / height;
132
+ // No artificial cap - let it zoom to fit the content
133
+ const scale = Math.min(scaleX, scaleY);
134
+
135
+ // Center the drawing in the view with axis-specific transforms
136
+ // Must match the canvas rendering transforms:
137
+ // - 'down' (plan view): no Y flip
138
+ // - 'front'/'side': Y flip
139
+ // - 'side': X flip
140
+ const currentAxis = sectionPlane.axis;
141
+ const flipY = currentAxis !== 'down';
142
+ const flipX = currentAxis === 'side';
143
+
144
+ const centerX = (bounds.min.x + bounds.max.x) / 2;
145
+ const centerY = (bounds.min.y + bounds.max.y) / 2;
146
+
147
+ // Apply transforms matching canvas rendering
148
+ const adjustedCenterX = flipX ? -centerX : centerX;
149
+ const adjustedCenterY = flipY ? -centerY : centerY;
150
+
151
+ setViewTransform({
152
+ scale,
153
+ x: rect.width / 2 - adjustedCenterX * scale,
154
+ y: rect.height / 2 - adjustedCenterY * scale,
155
+ });
156
+ }, [drawing, sheetEnabled, activeSheet, sectionPlane.axis]);
157
+
158
+ // Track axis changes for forced fit-to-view
159
+ const lastFitAxisRef = useRef(sectionPlane.axis);
160
+
161
+ // Set needsFit when axis changes
162
+ useEffect(() => {
163
+ if (sectionPlane.axis !== prevAxisRef.current) {
164
+ prevAxisRef.current = sectionPlane.axis;
165
+ setNeedsFit(true); // Force fit when axis changes
166
+ cachedSheetTransformRef.current = null; // Clear cached transform for new axis
167
+ }
168
+ }, [sectionPlane.axis]);
169
+
170
+ // Track previous sheet mode to detect toggle
171
+ const prevSheetEnabledRef = useRef(sheetEnabled);
172
+ useEffect(() => {
173
+ if (sheetEnabled !== prevSheetEnabledRef.current) {
174
+ prevSheetEnabledRef.current = sheetEnabled;
175
+ cachedSheetTransformRef.current = null; // Clear cached transform
176
+ // Auto-fit when sheet mode is toggled
177
+ if (status === 'ready' && drawing && containerRef.current) {
178
+ const timeout = setTimeout(() => {
179
+ fitToView();
180
+ }, 50);
181
+ return () => clearTimeout(timeout);
182
+ }
183
+ }
184
+ }, [sheetEnabled, status, drawing, fitToView]);
185
+
186
+ // Auto-fit when: (1) needsFit is true (first open or axis change), or (2) not pinned after regenerate
187
+ // ALWAYS fit when axis changed, regardless of pin state
188
+ // Also re-run when panelVisible changes so we fit when panel opens with existing drawing
189
+ useEffect(() => {
190
+ if (status === 'ready' && drawing && containerRef.current && panelVisible) {
191
+ const axisChanged = lastFitAxisRef.current !== sectionPlane.axis;
192
+
193
+ // Fit if needsFit (first open/axis change) OR if not pinned OR if axis just changed
194
+ if (needsFit || !isPinned || axisChanged) {
195
+ // Small delay to ensure canvas is rendered
196
+ const timeout = setTimeout(() => {
197
+ fitToView();
198
+ lastFitAxisRef.current = sectionPlane.axis;
199
+ if (needsFit) {
200
+ setNeedsFit(false); // Clear the flag after fitting
201
+ }
202
+ }, 50);
203
+ return () => clearTimeout(timeout);
204
+ }
205
+ }
206
+ }, [status, drawing, fitToView, isPinned, needsFit, sectionPlane.axis, panelVisible]);
207
+
208
+ return {
209
+ viewTransform,
210
+ setViewTransform,
211
+ zoomIn,
212
+ zoomOut,
213
+ fitToView,
214
+ };
215
+ }
216
+
217
+ export { useViewControls };
218
+ export default useViewControls;