@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,551 @@
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 annotation tools (polygon area, text, cloud) and
7
+ * annotation selection/drag/delete.
8
+ *
9
+ * The existing useMeasure2D hook continues to handle linear distance
10
+ * measurements and panning.
11
+ */
12
+
13
+ import { useCallback, useEffect, useRef } from 'react';
14
+ import type { Drawing2D } from '@ifc-lite/drawing-2d';
15
+ import type {
16
+ Annotation2DTool, Point2D, TextAnnotation2D,
17
+ SelectedAnnotation2D, Measure2DResult, PolygonArea2DResult, CloudAnnotation2D,
18
+ } from '@/store/slices/drawing2DSlice';
19
+ import { computePolygonArea, computePolygonPerimeter, computePolygonCentroid } from '@/components/viewer/tools/computePolygonArea';
20
+
21
+ // ─── Public interfaces ──────────────────────────────────────────────────────
22
+
23
+ export interface UseAnnotation2DParams {
24
+ drawing: Drawing2D | null;
25
+ viewTransform: { x: number; y: number; scale: number };
26
+ sectionAxis: 'down' | 'front' | 'side';
27
+ containerRef: React.RefObject<HTMLDivElement | null>;
28
+ activeTool: Annotation2DTool;
29
+ setActiveTool: (tool: Annotation2DTool) => void;
30
+ // Polygon area state
31
+ polygonArea2DPoints: Point2D[];
32
+ addPolygonArea2DPoint: (pt: Point2D) => void;
33
+ completePolygonArea2D: (area: number, perimeter: number) => void;
34
+ cancelPolygonArea2D: () => void;
35
+ // Text state
36
+ textAnnotations2D: TextAnnotation2D[];
37
+ addTextAnnotation2D: (annotation: TextAnnotation2D) => void;
38
+ setTextAnnotation2DEditing: (id: string | null) => void;
39
+ // Cloud state
40
+ cloudAnnotation2DPoints: Point2D[];
41
+ cloudAnnotations2D: CloudAnnotation2D[];
42
+ addCloudAnnotation2DPoint: (pt: Point2D) => void;
43
+ completeCloudAnnotation2D: (label?: string) => void;
44
+ cancelCloudAnnotation2D: () => void;
45
+ // Completed results (for hit testing)
46
+ measure2DResults: Measure2DResult[];
47
+ polygonArea2DResults: PolygonArea2DResult[];
48
+ // Selection
49
+ selectedAnnotation2D: SelectedAnnotation2D | null;
50
+ setSelectedAnnotation2D: (sel: SelectedAnnotation2D | null) => void;
51
+ deleteSelectedAnnotation2D: () => void;
52
+ moveAnnotation2D: (sel: SelectedAnnotation2D, newOrigin: Point2D) => void;
53
+ // Cursor and snap
54
+ setAnnotation2DCursorPos: (pos: Point2D | null) => void;
55
+ setMeasure2DSnapPoint: (pt: Point2D | null) => void;
56
+ }
57
+
58
+ export interface UseAnnotation2DResult {
59
+ /** Returns true if the click hit an annotation (consumed the event). */
60
+ handleMouseDown: (e: React.MouseEvent) => boolean;
61
+ handleMouseMove: (e: React.MouseEvent) => void;
62
+ handleMouseUp: (e: React.MouseEvent) => void;
63
+ handleDoubleClick: (e: React.MouseEvent) => void;
64
+ /** Ref that is true while an annotation drag is in progress (read at call time). */
65
+ isDraggingRef: React.RefObject<boolean>;
66
+ }
67
+
68
+ // ─── Constants ──────────────────────────────────────────────────────────────
69
+
70
+ const CLOSE_POLYGON_THRESHOLD_PX = 12;
71
+ const HIT_TEST_RADIUS_PX = 10;
72
+
73
+ // ─── Hook implementation ────────────────────────────────────────────────────
74
+
75
+ export function useAnnotation2D({
76
+ drawing,
77
+ viewTransform,
78
+ sectionAxis,
79
+ containerRef,
80
+ activeTool,
81
+ setActiveTool,
82
+ polygonArea2DPoints,
83
+ addPolygonArea2DPoint,
84
+ completePolygonArea2D,
85
+ cancelPolygonArea2D,
86
+ textAnnotations2D,
87
+ addTextAnnotation2D,
88
+ setTextAnnotation2DEditing,
89
+ cloudAnnotation2DPoints,
90
+ cloudAnnotations2D,
91
+ addCloudAnnotation2DPoint,
92
+ completeCloudAnnotation2D,
93
+ cancelCloudAnnotation2D,
94
+ measure2DResults,
95
+ polygonArea2DResults,
96
+ selectedAnnotation2D,
97
+ setSelectedAnnotation2D,
98
+ deleteSelectedAnnotation2D,
99
+ moveAnnotation2D,
100
+ setAnnotation2DCursorPos,
101
+ setMeasure2DSnapPoint,
102
+ }: UseAnnotation2DParams): UseAnnotation2DResult {
103
+
104
+ const shiftHeldRef = useRef(false);
105
+
106
+ // ── Ephemeral drag state as refs (no store churn during drag) ──────────
107
+ const isDraggingRef = useRef(false);
108
+ const dragOffsetRef = useRef<Point2D | null>(null);
109
+ // Keep a stable ref to the latest store mutators to avoid stale closures
110
+ const storeRef = useRef({
111
+ measure2DResults,
112
+ polygonArea2DResults,
113
+ textAnnotations2D,
114
+ cloudAnnotations2D,
115
+ selectedAnnotation2D,
116
+ });
117
+ storeRef.current = {
118
+ measure2DResults,
119
+ polygonArea2DResults,
120
+ textAnnotations2D,
121
+ cloudAnnotations2D,
122
+ selectedAnnotation2D,
123
+ };
124
+
125
+ // ── Coordinate conversion (depends on individual primitives, not object) ─
126
+
127
+ const scaleRef = useRef(viewTransform.scale);
128
+ const txRef = useRef(viewTransform.x);
129
+ const tyRef = useRef(viewTransform.y);
130
+ const axisRef = useRef(sectionAxis);
131
+ scaleRef.current = viewTransform.scale;
132
+ txRef.current = viewTransform.x;
133
+ tyRef.current = viewTransform.y;
134
+ axisRef.current = sectionAxis;
135
+
136
+ /** Convert screen px to drawing coords. Uses refs so it never goes stale. */
137
+ const screenToDrawing = useCallback((screenX: number, screenY: number): Point2D => {
138
+ const axis = axisRef.current;
139
+ const scaleX = axis === 'side' ? -scaleRef.current : scaleRef.current;
140
+ const scaleY = axis !== 'down' ? -scaleRef.current : scaleRef.current;
141
+ return {
142
+ x: (screenX - txRef.current) / scaleX,
143
+ y: (screenY - tyRef.current) / scaleY,
144
+ };
145
+ }, []); // stable — reads from refs
146
+
147
+ /** Convert drawing coords to screen px. */
148
+ const drawingToScreen = useCallback((pt: Point2D): { x: number; y: number } => {
149
+ const axis = axisRef.current;
150
+ const scaleX = axis === 'side' ? -scaleRef.current : scaleRef.current;
151
+ const scaleY = axis === 'down' ? scaleRef.current : -scaleRef.current;
152
+ return {
153
+ x: pt.x * scaleX + txRef.current,
154
+ y: pt.y * scaleY + tyRef.current,
155
+ };
156
+ }, []); // stable
157
+
158
+ // ── Orthogonal constraint (shift held) ────────────────────────────────
159
+
160
+ const applyShiftConstraint = useCallback((anchor: Point2D, point: Point2D): Point2D => {
161
+ const dx = Math.abs(point.x - anchor.x);
162
+ const dy = Math.abs(point.y - anchor.y);
163
+ return dx > dy ? { x: point.x, y: anchor.y } : { x: anchor.x, y: point.y };
164
+ }, []);
165
+
166
+ // ── Snap point detection ──────────────────────────────────────────────
167
+
168
+ const findSnapPoint = useCallback((drawingCoord: Point2D): Point2D | null => {
169
+ if (!drawing) return null;
170
+ const snapThreshold = 10 / scaleRef.current;
171
+ let bestSnap: Point2D | null = null;
172
+ let bestDist = snapThreshold;
173
+
174
+ // Check vertices first (early return on close match)
175
+ for (const polygon of drawing.cutPolygons) {
176
+ for (const pt of polygon.polygon.outer) {
177
+ const dist = Math.sqrt((pt.x - drawingCoord.x) ** 2 + (pt.y - drawingCoord.y) ** 2);
178
+ if (dist < bestDist * 0.7) return { x: pt.x, y: pt.y };
179
+ }
180
+ for (const hole of polygon.polygon.holes) {
181
+ for (const pt of hole) {
182
+ const dist = Math.sqrt((pt.x - drawingCoord.x) ** 2 + (pt.y - drawingCoord.y) ** 2);
183
+ if (dist < bestDist * 0.7) return { x: pt.x, y: pt.y };
184
+ }
185
+ }
186
+ }
187
+ for (const line of drawing.lines) {
188
+ for (const pt of [line.line.start, line.line.end]) {
189
+ const dist = Math.sqrt((pt.x - drawingCoord.x) ** 2 + (pt.y - drawingCoord.y) ** 2);
190
+ if (dist < bestDist * 0.7) return { x: pt.x, y: pt.y };
191
+ }
192
+ }
193
+
194
+ // Then check edge proximity
195
+ for (const polygon of drawing.cutPolygons) {
196
+ const outer = polygon.polygon.outer;
197
+ for (let i = 0; i < outer.length; i++) {
198
+ const { point, dist } = nearestPointOnSegment(drawingCoord, outer[i], outer[(i + 1) % outer.length]);
199
+ if (dist < bestDist) { bestDist = dist; bestSnap = point; }
200
+ }
201
+ for (const hole of polygon.polygon.holes) {
202
+ for (let i = 0; i < hole.length; i++) {
203
+ const { point, dist } = nearestPointOnSegment(drawingCoord, hole[i], hole[(i + 1) % hole.length]);
204
+ if (dist < bestDist) { bestDist = dist; bestSnap = point; }
205
+ }
206
+ }
207
+ }
208
+ for (const line of drawing.lines) {
209
+ const { point, dist } = nearestPointOnSegment(drawingCoord, line.line.start, line.line.end);
210
+ if (dist < bestDist) { bestDist = dist; bestSnap = point; }
211
+ }
212
+
213
+ return bestSnap;
214
+ }, [drawing]); // only recreated when the drawing changes
215
+
216
+ // ── Hit-testing for annotation selection ──────────────────────────────
217
+
218
+ const hitTestAnnotations = useCallback((screenX: number, screenY: number): SelectedAnnotation2D | null => {
219
+ const threshold = HIT_TEST_RADIUS_PX;
220
+ const { textAnnotations2D: texts, cloudAnnotations2D: clouds,
221
+ polygonArea2DResults: polys, measure2DResults: measures } = storeRef.current;
222
+
223
+ // Text annotations (highest priority — small precise targets)
224
+ for (const annotation of texts) {
225
+ if (!annotation.text.trim()) continue;
226
+ const sp = drawingToScreen(annotation.position);
227
+ const fontSize = annotation.fontSize;
228
+ const lines = annotation.text.split('\n');
229
+ const lineHeight = fontSize * 1.3;
230
+ const padding = 6;
231
+ const approxCharWidth = fontSize * 0.6;
232
+ const maxLineLen = Math.max(...lines.map((l) => l.length));
233
+ const w = maxLineLen * approxCharWidth + padding * 2;
234
+ const h = lines.length * lineHeight + padding * 2;
235
+ if (screenX >= sp.x - 2 && screenX <= sp.x + w + 2 &&
236
+ screenY >= sp.y - 2 && screenY <= sp.y + h + 2) {
237
+ return { type: 'text', id: annotation.id };
238
+ }
239
+ }
240
+
241
+ // Cloud annotations
242
+ for (const cloud of clouds) {
243
+ if (cloud.points.length < 2) continue;
244
+ const sp1 = drawingToScreen(cloud.points[0]);
245
+ const sp2 = drawingToScreen(cloud.points[1]);
246
+ const minX = Math.min(sp1.x, sp2.x);
247
+ const maxX = Math.max(sp1.x, sp2.x);
248
+ const minY = Math.min(sp1.y, sp2.y);
249
+ const maxY = Math.max(sp1.y, sp2.y);
250
+ if (screenX >= minX - threshold && screenX <= maxX + threshold &&
251
+ screenY >= minY - threshold && screenY <= maxY + threshold) {
252
+ return { type: 'cloud', id: cloud.id };
253
+ }
254
+ }
255
+
256
+ // Polygon area results (edge proximity + centroid label)
257
+ for (const result of polys) {
258
+ if (result.points.length < 3) continue;
259
+ for (let i = 0; i < result.points.length; i++) {
260
+ const a = drawingToScreen(result.points[i]);
261
+ const b = drawingToScreen(result.points[(i + 1) % result.points.length]);
262
+ if (nearestPointOnScreenSegment({ x: screenX, y: screenY }, a, b).dist < threshold) {
263
+ return { type: 'polygon', id: result.id };
264
+ }
265
+ }
266
+ const centroid = computePolygonCentroid(result.points);
267
+ const sc = drawingToScreen(centroid);
268
+ if (Math.abs(screenX - sc.x) < 40 && Math.abs(screenY - sc.y) < 20) {
269
+ return { type: 'polygon', id: result.id };
270
+ }
271
+ }
272
+
273
+ // Measure results (line proximity)
274
+ for (const result of measures) {
275
+ const sa = drawingToScreen(result.start);
276
+ const sb = drawingToScreen(result.end);
277
+ if (nearestPointOnScreenSegment({ x: screenX, y: screenY }, sa, sb).dist < threshold) {
278
+ return { type: 'measure', id: result.id };
279
+ }
280
+ }
281
+
282
+ return null;
283
+ }, [drawingToScreen]); // stable — reads annotation data from storeRef
284
+
285
+ // ── Get annotation origin (reads latest data from refs) ───────────────
286
+
287
+ const getAnnotationOrigin = useCallback((sel: SelectedAnnotation2D): Point2D | null => {
288
+ const s = storeRef.current;
289
+ switch (sel.type) {
290
+ case 'measure': { const r = s.measure2DResults.find((m) => m.id === sel.id); return r ? r.start : null; }
291
+ case 'polygon': { const r = s.polygonArea2DResults.find((p) => p.id === sel.id); return r?.points[0] ?? null; }
292
+ case 'text': { const a = s.textAnnotations2D.find((t) => t.id === sel.id); return a ? a.position : null; }
293
+ case 'cloud': { const c = s.cloudAnnotations2D.find((cl) => cl.id === sel.id); return c?.points[0] ?? null; }
294
+ }
295
+ return null;
296
+ }, []);
297
+
298
+ // ── Commit drag position to store (stable via ref) ─────────────────
299
+
300
+ const moveAnnotationRef = useRef(moveAnnotation2D);
301
+ moveAnnotationRef.current = moveAnnotation2D;
302
+
303
+ const commitDragPosition = useCallback((sel: SelectedAnnotation2D, newOrigin: Point2D) => {
304
+ moveAnnotationRef.current(sel, newOrigin);
305
+ }, []);
306
+
307
+ const isNearFirstVertex = useCallback((drawingCoord: Point2D): boolean => {
308
+ if (polygonArea2DPoints.length < 3) return false;
309
+ const first = polygonArea2DPoints[0];
310
+ const threshold = CLOSE_POLYGON_THRESHOLD_PX / scaleRef.current;
311
+ const dx = drawingCoord.x - first.x;
312
+ const dy = drawingCoord.y - first.y;
313
+ return Math.sqrt(dx * dx + dy * dy) < threshold;
314
+ }, [polygonArea2DPoints]);
315
+
316
+ // ── Keyboard shortcuts ────────────────────────────────────────────────
317
+
318
+ useEffect(() => {
319
+ const handleKeyDown = (e: KeyboardEvent) => {
320
+ if (e.key === 'Shift') {
321
+ shiftHeldRef.current = true;
322
+ }
323
+ if (e.key === 'Escape') {
324
+ // 1. Cancel in-progress work
325
+ if (activeTool === 'polygon-area') cancelPolygonArea2D();
326
+ else if (activeTool === 'cloud') cancelCloudAnnotation2D();
327
+ else if (activeTool === 'text') setTextAnnotation2DEditing(null);
328
+ // 2. Exit any creation tool back to select/pan
329
+ if (activeTool !== 'none') {
330
+ setActiveTool('none');
331
+ }
332
+ // 3. Deselect
333
+ if (storeRef.current.selectedAnnotation2D) setSelectedAnnotation2D(null);
334
+ }
335
+ if ((e.key === 'Delete' || e.key === 'Backspace') && storeRef.current.selectedAnnotation2D) {
336
+ const activeEl = document.activeElement;
337
+ if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA')) return;
338
+ e.preventDefault();
339
+ deleteSelectedAnnotation2D();
340
+ }
341
+ };
342
+
343
+ const handleKeyUp = (e: KeyboardEvent) => {
344
+ if (e.key === 'Shift') shiftHeldRef.current = false;
345
+ };
346
+
347
+ window.addEventListener('keydown', handleKeyDown);
348
+ window.addEventListener('keyup', handleKeyUp);
349
+ return () => {
350
+ window.removeEventListener('keydown', handleKeyDown);
351
+ window.removeEventListener('keyup', handleKeyUp);
352
+ };
353
+ }, [activeTool, setActiveTool, cancelPolygonArea2D, cancelCloudAnnotation2D,
354
+ setTextAnnotation2DEditing, setSelectedAnnotation2D, deleteSelectedAnnotation2D]);
355
+
356
+ // ── Mouse handlers ────────────────────────────────────────────────────
357
+
358
+ const handleMouseDown = useCallback((e: React.MouseEvent): boolean => {
359
+ if (e.button !== 0) return false;
360
+
361
+ const rect = containerRef.current?.getBoundingClientRect();
362
+ if (!rect) return false;
363
+
364
+ const screenX = e.clientX - rect.left;
365
+ const screenY = e.clientY - rect.top;
366
+ const drawingCoord = screenToDrawing(screenX, screenY);
367
+
368
+ // ── Tool-specific placement ─────────────────────────────────────────
369
+ if (activeTool !== 'none' && activeTool !== 'measure') {
370
+ const snapPoint = findSnapPoint(drawingCoord);
371
+ let point = snapPoint || drawingCoord;
372
+
373
+ switch (activeTool) {
374
+ case 'polygon-area': {
375
+ if (shiftHeldRef.current && polygonArea2DPoints.length > 0) {
376
+ point = applyShiftConstraint(polygonArea2DPoints[polygonArea2DPoints.length - 1], point);
377
+ }
378
+ if (isNearFirstVertex(point)) {
379
+ completePolygonArea2D(computePolygonArea(polygonArea2DPoints), computePolygonPerimeter(polygonArea2DPoints));
380
+ } else {
381
+ addPolygonArea2DPoint(point);
382
+ }
383
+ return true;
384
+ }
385
+ case 'text': {
386
+ const annotation: TextAnnotation2D = {
387
+ id: `text-${Date.now()}`,
388
+ position: point,
389
+ text: '',
390
+ fontSize: 14,
391
+ color: '#000000',
392
+ backgroundColor: 'rgba(255,255,255,0.9)',
393
+ borderColor: '#333333',
394
+ };
395
+ addTextAnnotation2D(annotation);
396
+ setTextAnnotation2DEditing(annotation.id);
397
+ return true;
398
+ }
399
+ case 'cloud': {
400
+ if (shiftHeldRef.current && cloudAnnotation2DPoints.length === 1) {
401
+ const firstPt = cloudAnnotation2DPoints[0];
402
+ const dx = point.x - firstPt.x;
403
+ const dy = point.y - firstPt.y;
404
+ const maxDelta = Math.max(Math.abs(dx), Math.abs(dy));
405
+ point = { x: firstPt.x + Math.sign(dx) * maxDelta, y: firstPt.y + Math.sign(dy) * maxDelta };
406
+ }
407
+ addCloudAnnotation2DPoint(point);
408
+ if (cloudAnnotation2DPoints.length === 1) {
409
+ setTimeout(() => completeCloudAnnotation2D(''), 0);
410
+ }
411
+ return true;
412
+ }
413
+ }
414
+ }
415
+
416
+ // ── Selection / drag (tool is 'none' or 'measure') ──────────────────
417
+ const hit = hitTestAnnotations(screenX, screenY);
418
+ if (hit) {
419
+ setSelectedAnnotation2D(hit);
420
+ const origin = getAnnotationOrigin(hit);
421
+ if (origin) {
422
+ isDraggingRef.current = true;
423
+ dragOffsetRef.current = { x: drawingCoord.x - origin.x, y: drawingCoord.y - origin.y };
424
+ }
425
+ return true; // consumed — don't start panning
426
+ }
427
+
428
+ // Clicked empty space — deselect
429
+ if (storeRef.current.selectedAnnotation2D) {
430
+ setSelectedAnnotation2D(null);
431
+ }
432
+ return false; // not consumed — let panning proceed
433
+ }, [activeTool, containerRef, screenToDrawing, findSnapPoint, isNearFirstVertex,
434
+ applyShiftConstraint, polygonArea2DPoints, addPolygonArea2DPoint, completePolygonArea2D,
435
+ addTextAnnotation2D, setTextAnnotation2DEditing,
436
+ cloudAnnotation2DPoints, addCloudAnnotation2DPoint, completeCloudAnnotation2D,
437
+ hitTestAnnotations, getAnnotationOrigin, setSelectedAnnotation2D]);
438
+
439
+ const handleMouseMove = useCallback((e: React.MouseEvent) => {
440
+ const rect = containerRef.current?.getBoundingClientRect();
441
+ if (!rect) return;
442
+
443
+ const screenX = e.clientX - rect.left;
444
+ const screenY = e.clientY - rect.top;
445
+ const drawingCoord = screenToDrawing(screenX, screenY);
446
+
447
+ // ── Dragging: commit position directly to store ─────────────────────
448
+ if (isDraggingRef.current && dragOffsetRef.current) {
449
+ // Throttle: Zustand set() is synchronous, but we skip if position
450
+ // hasn't changed meaningfully (< 0.5 screen px)
451
+ const newDrawingPos: Point2D = {
452
+ x: drawingCoord.x - dragOffsetRef.current.x,
453
+ y: drawingCoord.y - dragOffsetRef.current.y,
454
+ };
455
+ // We call the store action directly — it's already optimized for single-item updates
456
+ const sel = storeRef.current.selectedAnnotation2D;
457
+ if (sel) {
458
+ commitDragPosition(sel, newDrawingPos);
459
+ }
460
+ return;
461
+ }
462
+
463
+ // ── Tool preview ────────────────────────────────────────────────────
464
+ if (activeTool === 'none' || activeTool === 'measure') return;
465
+
466
+ const snapPoint = findSnapPoint(drawingCoord);
467
+ setMeasure2DSnapPoint(snapPoint);
468
+ let point = snapPoint || drawingCoord;
469
+
470
+ if (shiftHeldRef.current && activeTool === 'polygon-area' && polygonArea2DPoints.length > 0) {
471
+ point = applyShiftConstraint(polygonArea2DPoints[polygonArea2DPoints.length - 1], point);
472
+ }
473
+ if (shiftHeldRef.current && activeTool === 'cloud' && cloudAnnotation2DPoints.length === 1) {
474
+ const firstPt = cloudAnnotation2DPoints[0];
475
+ const dx = point.x - firstPt.x;
476
+ const dy = point.y - firstPt.y;
477
+ const maxDelta = Math.max(Math.abs(dx), Math.abs(dy));
478
+ point = { x: firstPt.x + Math.sign(dx) * maxDelta, y: firstPt.y + Math.sign(dy) * maxDelta };
479
+ }
480
+
481
+ setAnnotation2DCursorPos(point);
482
+ }, [activeTool, containerRef, screenToDrawing, findSnapPoint, setMeasure2DSnapPoint,
483
+ setAnnotation2DCursorPos, applyShiftConstraint, polygonArea2DPoints, cloudAnnotation2DPoints,
484
+ commitDragPosition]);
485
+
486
+ const handleMouseUp = useCallback((_e: React.MouseEvent) => {
487
+ isDraggingRef.current = false;
488
+ dragOffsetRef.current = null;
489
+ }, []);
490
+
491
+ const handleDoubleClick = useCallback((e: React.MouseEvent) => {
492
+ if (activeTool === 'polygon-area' && polygonArea2DPoints.length >= 3) {
493
+ e.preventDefault();
494
+ completePolygonArea2D(computePolygonArea(polygonArea2DPoints), computePolygonPerimeter(polygonArea2DPoints));
495
+ return;
496
+ }
497
+
498
+ const rect = containerRef.current?.getBoundingClientRect();
499
+ if (!rect) return;
500
+ const hit = hitTestAnnotations(e.clientX - rect.left, e.clientY - rect.top);
501
+ if (hit && hit.type === 'text') {
502
+ e.preventDefault();
503
+ setSelectedAnnotation2D(hit);
504
+ setTextAnnotation2DEditing(hit.id);
505
+ }
506
+ }, [activeTool, polygonArea2DPoints, completePolygonArea2D, containerRef,
507
+ hitTestAnnotations, setSelectedAnnotation2D, setTextAnnotation2DEditing]);
508
+
509
+ return {
510
+ handleMouseDown,
511
+ handleMouseMove,
512
+ handleMouseUp,
513
+ handleDoubleClick,
514
+ isDraggingRef,
515
+ };
516
+ }
517
+
518
+ // ─── Helpers (module-level, zero allocation) ────────────────────────────────
519
+
520
+ function nearestPointOnSegment(
521
+ p: Point2D, a: Point2D, b: Point2D
522
+ ): { point: Point2D; dist: number } {
523
+ const dx = b.x - a.x;
524
+ const dy = b.y - a.y;
525
+ const lenSq = dx * dx + dy * dy;
526
+ if (lenSq < 0.0001) {
527
+ return { point: a, dist: Math.sqrt((p.x - a.x) ** 2 + (p.y - a.y) ** 2) };
528
+ }
529
+ const t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq));
530
+ const nearest = { x: a.x + t * dx, y: a.y + t * dy };
531
+ return { point: nearest, dist: Math.sqrt((p.x - nearest.x) ** 2 + (p.y - nearest.y) ** 2) };
532
+ }
533
+
534
+ function nearestPointOnScreenSegment(
535
+ p: { x: number; y: number },
536
+ a: { x: number; y: number },
537
+ b: { x: number; y: number }
538
+ ): { dist: number } {
539
+ const dx = b.x - a.x;
540
+ const dy = b.y - a.y;
541
+ const lenSq = dx * dx + dy * dy;
542
+ if (lenSq < 0.01) {
543
+ return { dist: Math.sqrt((p.x - a.x) ** 2 + (p.y - a.y) ** 2) };
544
+ }
545
+ const t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq));
546
+ const nx = a.x + t * dx;
547
+ const ny = a.y + t * dy;
548
+ return { dist: Math.sqrt((p.x - nx) ** 2 + (p.y - ny) ** 2) };
549
+ }
550
+
551
+ export default useAnnotation2D;
@@ -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
  */