@ifc-lite/viewer 1.7.0 → 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.
- package/CHANGELOG.md +35 -0
- package/dist/assets/{Arrow.dom-BGPQieQQ.js → Arrow.dom-CwcRxist.js} +1 -1
- package/dist/assets/index-7WoQ-qVC.css +1 -0
- package/dist/assets/{index-dgdgiQ9p.js → index-BSANf7-H.js} +20926 -17587
- package/dist/assets/{native-bridge-DD0SNyQ5.js → native-bridge-5LbrYh3R.js} +1 -1
- package/dist/assets/{wasm-bridge-D54YMO7X.js → wasm-bridge-CgpLtj1h.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +18 -18
- package/src/components/viewer/Drawing2DCanvas.tsx +364 -1
- package/src/components/viewer/EntityContextMenu.tsx +47 -20
- package/src/components/viewer/ExportDialog.tsx +166 -17
- package/src/components/viewer/HierarchyPanel.tsx +3 -1
- package/src/components/viewer/LensPanel.tsx +848 -85
- package/src/components/viewer/MainToolbar.tsx +114 -81
- package/src/components/viewer/Section2DPanel.tsx +269 -29
- package/src/components/viewer/TextAnnotationEditor.tsx +112 -0
- package/src/components/viewer/Viewport.tsx +57 -23
- package/src/components/viewer/ViewportContainer.tsx +2 -0
- package/src/components/viewer/hierarchy/HierarchyNode.tsx +1 -1
- package/src/components/viewer/hierarchy/types.ts +1 -1
- package/src/components/viewer/lists/ListResultsTable.tsx +53 -19
- package/src/components/viewer/tools/cloudPathGenerator.test.ts +118 -0
- package/src/components/viewer/tools/cloudPathGenerator.ts +275 -0
- package/src/components/viewer/tools/computePolygonArea.test.ts +165 -0
- package/src/components/viewer/tools/computePolygonArea.ts +72 -0
- package/src/components/viewer/useGeometryStreaming.ts +12 -4
- package/src/hooks/ids/idsExportService.ts +1 -1
- package/src/hooks/useAnnotation2D.ts +551 -0
- package/src/hooks/useDrawingExport.ts +83 -1
- package/src/hooks/useKeyboardShortcuts.ts +113 -14
- package/src/hooks/useLens.ts +39 -55
- package/src/hooks/useLensDiscovery.ts +46 -0
- package/src/hooks/useModelSelection.ts +5 -22
- package/src/index.css +7 -1
- package/src/lib/lens/adapter.ts +127 -1
- package/src/lib/lists/columnToAutoColor.ts +33 -0
- package/src/store/index.ts +14 -1
- package/src/store/resolveEntityRef.ts +44 -0
- package/src/store/slices/drawing2DSlice.ts +321 -0
- package/src/store/slices/lensSlice.ts +46 -4
- package/src/store/slices/pinboardSlice.ts +171 -38
- package/src/store.ts +3 -0
- package/dist/assets/index-yTqs8kgX.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;
|
|
@@ -15,6 +15,9 @@ import {
|
|
|
15
15
|
} from '@ifc-lite/drawing-2d';
|
|
16
16
|
import { getFillColorForType } from '@/components/viewer/Drawing2DCanvas';
|
|
17
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';
|
|
18
21
|
|
|
19
22
|
interface UseDrawingExportParams {
|
|
20
23
|
drawing: Drawing2D | null;
|
|
@@ -25,6 +28,9 @@ interface UseDrawingExportParams {
|
|
|
25
28
|
overridesEnabled: boolean;
|
|
26
29
|
overrideEngine: GraphicOverrideEngine;
|
|
27
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[];
|
|
28
34
|
sheetEnabled: boolean;
|
|
29
35
|
activeSheet: DrawingSheet | null;
|
|
30
36
|
}
|
|
@@ -44,6 +50,9 @@ function useDrawingExport({
|
|
|
44
50
|
overridesEnabled,
|
|
45
51
|
overrideEngine,
|
|
46
52
|
measure2DResults,
|
|
53
|
+
polygonArea2DResults,
|
|
54
|
+
textAnnotations2D,
|
|
55
|
+
cloudAnnotations2D,
|
|
47
56
|
sheetEnabled,
|
|
48
57
|
activeSheet,
|
|
49
58
|
}: UseDrawingExportParams): UseDrawingExportResult {
|
|
@@ -304,9 +313,82 @@ function useDrawingExport({
|
|
|
304
313
|
svg += ' </g>\n';
|
|
305
314
|
}
|
|
306
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
|
+
|
|
307
389
|
svg += '</svg>';
|
|
308
390
|
return svg;
|
|
309
|
-
}, [drawing, displayOptions, activePresetId, entityColorMap, overridesEnabled, overrideEngine, measure2DResults, sectionPlane.axis]);
|
|
391
|
+
}, [drawing, displayOptions, activePresetId, entityColorMap, overridesEnabled, overrideEngine, measure2DResults, polygonArea2DResults, textAnnotations2D, cloudAnnotations2D, sectionPlane.axis]);
|
|
310
392
|
|
|
311
393
|
// Generate SVG with drawing sheet (frame, title block, scale bar)
|
|
312
394
|
// This generates coordinates directly in paper mm space (like the canvas rendering)
|