@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.
- package/CHANGELOG.md +78 -0
- package/dist/assets/{Arrow.dom-BjDQoB2M.js → Arrow.dom-BGPQieQQ.js} +1 -1
- package/dist/assets/ifc-lite_bg-DyIN_nBM.wasm +0 -0
- package/dist/assets/{index-YBtrHPu3.js → index-dgdgiQ9p.js} +40212 -30008
- package/dist/assets/index-yTqs8kgX.css +1 -0
- package/dist/assets/{native-bridge-CULtTDX3.js → native-bridge-DD0SNyQ5.js} +1 -1
- package/dist/assets/{wasm-bridge-CjL-lSak.js → wasm-bridge-D54YMO7X.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +18 -15
- package/src/components/viewer/BCFPanel.tsx +7 -789
- package/src/components/viewer/Drawing2DCanvas.tsx +1048 -0
- package/src/components/viewer/DrawingSettingsPanel.tsx +3 -3
- package/src/components/viewer/HierarchyPanel.tsx +110 -842
- package/src/components/viewer/IDSExportDialog.tsx +281 -0
- package/src/components/viewer/IDSPanel.tsx +126 -17
- package/src/components/viewer/KeyboardShortcutsDialog.tsx +9 -0
- package/src/components/viewer/LensPanel.tsx +603 -0
- package/src/components/viewer/MainToolbar.tsx +188 -21
- package/src/components/viewer/PropertiesPanel.tsx +171 -663
- package/src/components/viewer/PropertyEditor.tsx +866 -77
- package/src/components/viewer/Section2DPanel.tsx +76 -2648
- package/src/components/viewer/ToolOverlays.tsx +3 -1097
- package/src/components/viewer/ViewerLayout.tsx +132 -45
- package/src/components/viewer/Viewport.tsx +237 -1659
- package/src/components/viewer/ViewportContainer.tsx +11 -3
- package/src/components/viewer/bcf/BCFCreateTopicForm.tsx +134 -0
- package/src/components/viewer/bcf/BCFTopicDetail.tsx +388 -0
- package/src/components/viewer/bcf/BCFTopicList.tsx +239 -0
- package/src/components/viewer/bcf/bcfHelpers.tsx +109 -0
- package/src/components/viewer/hierarchy/HierarchyNode.tsx +328 -0
- package/src/components/viewer/hierarchy/treeDataBuilder.ts +464 -0
- package/src/components/viewer/hierarchy/types.ts +54 -0
- package/src/components/viewer/hierarchy/useHierarchyTree.ts +280 -0
- package/src/components/viewer/lists/ListBuilder.tsx +486 -0
- package/src/components/viewer/lists/ListPanel.tsx +540 -0
- package/src/components/viewer/lists/ListResultsTable.tsx +193 -0
- package/src/components/viewer/properties/ClassificationCard.tsx +70 -0
- package/src/components/viewer/properties/CoordinateDisplay.tsx +49 -0
- package/src/components/viewer/properties/DocumentCard.tsx +89 -0
- package/src/components/viewer/properties/MaterialCard.tsx +201 -0
- package/src/components/viewer/properties/ModelMetadataPanel.tsx +335 -0
- package/src/components/viewer/properties/PropertySetCard.tsx +132 -0
- package/src/components/viewer/properties/QuantitySetCard.tsx +79 -0
- package/src/components/viewer/properties/RelationshipsCard.tsx +100 -0
- package/src/components/viewer/properties/encodingUtils.ts +29 -0
- package/src/components/viewer/tools/MeasurePanel.tsx +218 -0
- package/src/components/viewer/tools/MeasurementVisuals.tsx +644 -0
- package/src/components/viewer/tools/SectionPanel.tsx +183 -0
- package/src/components/viewer/tools/SectionVisualization.tsx +78 -0
- package/src/components/viewer/tools/formatDistance.ts +18 -0
- package/src/components/viewer/tools/sectionConstants.ts +14 -0
- package/src/components/viewer/useAnimationLoop.ts +166 -0
- package/src/components/viewer/useGeometryStreaming.ts +398 -0
- package/src/components/viewer/useKeyboardControls.ts +221 -0
- package/src/components/viewer/useMouseControls.ts +1009 -0
- package/src/components/viewer/useRenderUpdates.ts +165 -0
- package/src/components/viewer/useTouchControls.ts +245 -0
- package/src/hooks/ids/idsColorSystem.ts +125 -0
- package/src/hooks/ids/idsDataAccessor.ts +237 -0
- package/src/hooks/ids/idsExportService.ts +444 -0
- package/src/hooks/useBCF.ts +7 -0
- package/src/hooks/useDrawingExport.ts +627 -0
- package/src/hooks/useDrawingGeneration.ts +627 -0
- package/src/hooks/useFloorplanView.ts +108 -0
- package/src/hooks/useIDS.ts +270 -463
- package/src/hooks/useIfc.ts +26 -1628
- package/src/hooks/useIfcFederation.ts +803 -0
- package/src/hooks/useIfcLoader.ts +508 -0
- package/src/hooks/useIfcServer.ts +465 -0
- package/src/hooks/useKeyboardShortcuts.ts +1 -1
- package/src/hooks/useLens.ts +129 -0
- package/src/hooks/useMeasure2D.ts +365 -0
- package/src/hooks/useViewControls.ts +218 -0
- package/src/lib/ifc4-pset-definitions.test.ts +161 -0
- package/src/lib/ifc4-pset-definitions.ts +621 -0
- package/src/lib/ifc4-qto-definitions.ts +315 -0
- package/src/lib/lens/adapter.ts +138 -0
- package/src/lib/lens/index.ts +5 -0
- package/src/lib/lists/adapter.ts +69 -0
- package/src/lib/lists/index.ts +28 -0
- package/src/lib/lists/persistence.ts +64 -0
- package/src/services/fs-cache.ts +1 -1
- package/src/services/tauri-modules.d.ts +25 -0
- package/src/store/index.ts +38 -2
- package/src/store/slices/cameraSlice.ts +14 -1
- package/src/store/slices/dataSlice.ts +14 -1
- package/src/store/slices/lensSlice.ts +184 -0
- package/src/store/slices/listSlice.ts +74 -0
- package/src/store/slices/pinboardSlice.ts +114 -0
- package/src/store/types.ts +5 -0
- package/src/utils/ifcConfig.ts +16 -3
- package/src/utils/serverDataModel.ts +64 -101
- package/src/vite-env.d.ts +3 -0
- package/dist/assets/ifc-lite_bg-C6kblxf9.wasm +0 -0
- package/dist/assets/index-v3mcCUPN.css +0 -1
|
@@ -6,12 +6,9 @@
|
|
|
6
6
|
* Tool-specific overlays for measure and section tools
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import { useViewerStore, type Measurement, type SnapVisualization } from '@/store';
|
|
13
|
-
import type { MeasurementConstraintEdge } from '@/store/types';
|
|
14
|
-
import { SnapType, type SnapTarget } from '@ifc-lite/renderer';
|
|
9
|
+
import { useViewerStore } from '@/store';
|
|
10
|
+
import { MeasureOverlay } from './tools/MeasurePanel';
|
|
11
|
+
import { SectionOverlay } from './tools/SectionPanel';
|
|
15
12
|
|
|
16
13
|
export function ToolOverlays() {
|
|
17
14
|
const activeTool = useViewerStore((s) => s.activeTool);
|
|
@@ -26,1094 +23,3 @@ export function ToolOverlays() {
|
|
|
26
23
|
|
|
27
24
|
return null;
|
|
28
25
|
}
|
|
29
|
-
|
|
30
|
-
function MeasureOverlay() {
|
|
31
|
-
const measurements = useViewerStore((s) => s.measurements);
|
|
32
|
-
const pendingMeasurePoint = useViewerStore((s) => s.pendingMeasurePoint);
|
|
33
|
-
const activeMeasurement = useViewerStore((s) => s.activeMeasurement);
|
|
34
|
-
const snapTarget = useViewerStore((s) => s.snapTarget);
|
|
35
|
-
const snapVisualization = useViewerStore((s) => s.snapVisualization);
|
|
36
|
-
const snapEnabled = useViewerStore((s) => s.snapEnabled);
|
|
37
|
-
const measurementConstraintEdge = useViewerStore((s) => s.measurementConstraintEdge);
|
|
38
|
-
const toggleSnap = useViewerStore((s) => s.toggleSnap);
|
|
39
|
-
const deleteMeasurement = useViewerStore((s) => s.deleteMeasurement);
|
|
40
|
-
const clearMeasurements = useViewerStore((s) => s.clearMeasurements);
|
|
41
|
-
const setActiveTool = useViewerStore((s) => s.setActiveTool);
|
|
42
|
-
const projectToScreen = useViewerStore((s) => s.cameraCallbacks.projectToScreen);
|
|
43
|
-
|
|
44
|
-
// Track cursor position in ref (no re-renders on mouse move)
|
|
45
|
-
const cursorPosRef = React.useRef<{ x: number; y: number } | null>(null);
|
|
46
|
-
// Only update snap indicator position when snap target changes (not on every cursor move)
|
|
47
|
-
const [snapIndicatorPos, setSnapIndicatorPos] = useState<{ x: number; y: number } | null>(null);
|
|
48
|
-
// Panel collapsed by default for minimal UI
|
|
49
|
-
const [isPanelCollapsed, setIsPanelCollapsed] = useState(true);
|
|
50
|
-
// Ref to the overlay container for coordinate conversion
|
|
51
|
-
const overlayRef = React.useRef<HTMLDivElement>(null);
|
|
52
|
-
|
|
53
|
-
// Update cursor position in ref (no re-renders)
|
|
54
|
-
useEffect(() => {
|
|
55
|
-
const handleMouseMove = (e: MouseEvent) => {
|
|
56
|
-
// Convert page coords to overlay-relative coords for consistent SVG positioning
|
|
57
|
-
const container = overlayRef.current?.parentElement;
|
|
58
|
-
if (container) {
|
|
59
|
-
const rect = container.getBoundingClientRect();
|
|
60
|
-
cursorPosRef.current = { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
|
61
|
-
} else {
|
|
62
|
-
cursorPosRef.current = { x: e.clientX, y: e.clientY };
|
|
63
|
-
}
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
window.addEventListener('mousemove', handleMouseMove);
|
|
67
|
-
return () => {
|
|
68
|
-
window.removeEventListener('mousemove', handleMouseMove);
|
|
69
|
-
};
|
|
70
|
-
}, []);
|
|
71
|
-
|
|
72
|
-
// Update snap indicator position when snap target changes
|
|
73
|
-
// Cursor position is stored in ref (no re-renders on mouse move)
|
|
74
|
-
// Snap target changes already trigger re-renders, so indicator will update frequently enough
|
|
75
|
-
useEffect(() => {
|
|
76
|
-
if (snapTarget && cursorPosRef.current) {
|
|
77
|
-
setSnapIndicatorPos(cursorPosRef.current);
|
|
78
|
-
} else {
|
|
79
|
-
setSnapIndicatorPos(null);
|
|
80
|
-
}
|
|
81
|
-
}, [snapTarget]);
|
|
82
|
-
|
|
83
|
-
const handleClear = useCallback(() => {
|
|
84
|
-
clearMeasurements();
|
|
85
|
-
}, [clearMeasurements]);
|
|
86
|
-
|
|
87
|
-
const handleDeleteMeasurement = useCallback((id: string) => {
|
|
88
|
-
deleteMeasurement(id);
|
|
89
|
-
}, [deleteMeasurement]);
|
|
90
|
-
|
|
91
|
-
const togglePanel = useCallback(() => {
|
|
92
|
-
setIsPanelCollapsed(prev => !prev);
|
|
93
|
-
}, []);
|
|
94
|
-
|
|
95
|
-
const handleClose = useCallback(() => {
|
|
96
|
-
setActiveTool('select');
|
|
97
|
-
}, [setActiveTool]);
|
|
98
|
-
|
|
99
|
-
// Calculate total distance
|
|
100
|
-
const totalDistance = measurements.reduce((sum, m) => sum + m.distance, 0);
|
|
101
|
-
|
|
102
|
-
return (
|
|
103
|
-
<>
|
|
104
|
-
{/* Hidden ref element for coordinate calculation */}
|
|
105
|
-
<div ref={overlayRef} className="absolute top-0 left-0 w-0 h-0" />
|
|
106
|
-
|
|
107
|
-
{/* Compact Measure Tool Panel */}
|
|
108
|
-
<div className="pointer-events-auto absolute top-4 left-1/2 -translate-x-1/2 bg-background/95 backdrop-blur-sm rounded-lg border shadow-lg z-30">
|
|
109
|
-
{/* Header - always visible */}
|
|
110
|
-
<div className="flex items-center justify-between gap-2 p-2">
|
|
111
|
-
<button
|
|
112
|
-
onClick={togglePanel}
|
|
113
|
-
className="flex items-center gap-2 hover:bg-accent/50 rounded px-2 py-1 transition-colors"
|
|
114
|
-
>
|
|
115
|
-
<Ruler className="h-4 w-4 text-primary" />
|
|
116
|
-
<span className="font-medium text-sm">Measure</span>
|
|
117
|
-
{measurements.length > 0 && !isPanelCollapsed && (
|
|
118
|
-
<span className="text-xs text-muted-foreground">({measurements.length})</span>
|
|
119
|
-
)}
|
|
120
|
-
<ChevronDown className={`h-3 w-3 transition-transform ${isPanelCollapsed ? '-rotate-90' : ''}`} />
|
|
121
|
-
</button>
|
|
122
|
-
<div className="flex items-center gap-1">
|
|
123
|
-
{measurements.length > 0 && (
|
|
124
|
-
<Button variant="ghost" size="icon-sm" onClick={handleClear} title="Clear all">
|
|
125
|
-
<Trash2 className="h-3 w-3" />
|
|
126
|
-
</Button>
|
|
127
|
-
)}
|
|
128
|
-
<Button variant="ghost" size="icon-sm" onClick={handleClose} title="Close">
|
|
129
|
-
<X className="h-3 w-3" />
|
|
130
|
-
</Button>
|
|
131
|
-
</div>
|
|
132
|
-
</div>
|
|
133
|
-
|
|
134
|
-
{/* Expandable content */}
|
|
135
|
-
{!isPanelCollapsed && (
|
|
136
|
-
<div className="border-t px-2 pb-2 min-w-56">
|
|
137
|
-
{measurements.length > 0 ? (
|
|
138
|
-
<div className="space-y-1 mt-2">
|
|
139
|
-
{measurements.map((m, i) => (
|
|
140
|
-
<MeasurementItem
|
|
141
|
-
key={m.id}
|
|
142
|
-
measurement={m}
|
|
143
|
-
index={i}
|
|
144
|
-
onDelete={handleDeleteMeasurement}
|
|
145
|
-
/>
|
|
146
|
-
))}
|
|
147
|
-
{measurements.length > 1 && (
|
|
148
|
-
<div className="flex items-center justify-between border-t pt-1 mt-1 text-xs font-medium">
|
|
149
|
-
<span>Total</span>
|
|
150
|
-
<span className="font-mono">{formatDistance(totalDistance)}</span>
|
|
151
|
-
</div>
|
|
152
|
-
)}
|
|
153
|
-
</div>
|
|
154
|
-
) : (
|
|
155
|
-
<div className="text-center py-2 text-muted-foreground text-xs">
|
|
156
|
-
No measurements
|
|
157
|
-
</div>
|
|
158
|
-
)}
|
|
159
|
-
</div>
|
|
160
|
-
)}
|
|
161
|
-
</div>
|
|
162
|
-
|
|
163
|
-
{/* Instruction hint - brutalist style with snap-colored shadow */}
|
|
164
|
-
<div
|
|
165
|
-
className="pointer-events-auto absolute bottom-16 left-1/2 -translate-x-1/2 z-30 bg-zinc-900 dark:bg-zinc-100 text-zinc-100 dark:text-zinc-900 px-3 py-1.5 border-2 border-zinc-900 dark:border-zinc-100 transition-shadow duration-150"
|
|
166
|
-
style={{
|
|
167
|
-
boxShadow: snapTarget
|
|
168
|
-
? `4px 4px 0px 0px ${
|
|
169
|
-
snapTarget.type === 'vertex' ? '#FFEB3B' :
|
|
170
|
-
snapTarget.type === 'edge' ? '#FF9800' :
|
|
171
|
-
snapTarget.type === 'face' ? '#03A9F4' : '#00BCD4'
|
|
172
|
-
}`
|
|
173
|
-
: '3px 3px 0px 0px rgba(0,0,0,0.3)'
|
|
174
|
-
}}
|
|
175
|
-
>
|
|
176
|
-
<span className="font-mono text-xs uppercase tracking-wide">
|
|
177
|
-
{activeMeasurement ? 'Release to complete' : 'Drag to measure'}
|
|
178
|
-
</span>
|
|
179
|
-
</div>
|
|
180
|
-
|
|
181
|
-
{/* Snap toggle - brutalist style */}
|
|
182
|
-
<div className="pointer-events-auto absolute bottom-4 left-1/2 -translate-x-1/2 z-30">
|
|
183
|
-
<button
|
|
184
|
-
onClick={toggleSnap}
|
|
185
|
-
className={`px-2 py-1 font-mono text-[10px] uppercase tracking-wider border-2 transition-colors ${
|
|
186
|
-
snapEnabled
|
|
187
|
-
? 'bg-primary text-primary-foreground border-primary'
|
|
188
|
-
: 'bg-zinc-100 dark:bg-zinc-900 text-zinc-500 border-zinc-300 dark:border-zinc-700'
|
|
189
|
-
}`}
|
|
190
|
-
title="Toggle snap (S key)"
|
|
191
|
-
>
|
|
192
|
-
Snap {snapEnabled ? 'On' : 'Off'}
|
|
193
|
-
</button>
|
|
194
|
-
</div>
|
|
195
|
-
|
|
196
|
-
{/* Render measurement lines, labels, and snap indicators */}
|
|
197
|
-
<MeasurementOverlays
|
|
198
|
-
measurements={measurements}
|
|
199
|
-
pending={pendingMeasurePoint}
|
|
200
|
-
activeMeasurement={activeMeasurement}
|
|
201
|
-
snapTarget={snapTarget}
|
|
202
|
-
snapVisualization={snapVisualization}
|
|
203
|
-
hoverPosition={snapIndicatorPos}
|
|
204
|
-
projectToScreen={projectToScreen}
|
|
205
|
-
constraintEdge={measurementConstraintEdge}
|
|
206
|
-
/>
|
|
207
|
-
</>
|
|
208
|
-
);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
interface MeasurementItemProps {
|
|
212
|
-
measurement: Measurement;
|
|
213
|
-
index: number;
|
|
214
|
-
onDelete: (id: string) => void;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function MeasurementItem({ measurement, index, onDelete }: MeasurementItemProps) {
|
|
218
|
-
return (
|
|
219
|
-
<div className="flex items-center justify-between bg-muted/50 rounded px-2 py-0.5 text-xs">
|
|
220
|
-
<span className="text-muted-foreground text-xs">#{index + 1}</span>
|
|
221
|
-
<span className="font-mono font-medium">{formatDistance(measurement.distance)}</span>
|
|
222
|
-
<Button
|
|
223
|
-
variant="ghost"
|
|
224
|
-
size="icon-sm"
|
|
225
|
-
className="h-4 w-4 hover:bg-destructive/20"
|
|
226
|
-
onClick={() => onDelete(measurement.id)}
|
|
227
|
-
>
|
|
228
|
-
<X className="h-2.5 w-2.5" />
|
|
229
|
-
</Button>
|
|
230
|
-
</div>
|
|
231
|
-
);
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
interface MeasurementOverlaysProps {
|
|
235
|
-
measurements: Measurement[];
|
|
236
|
-
pending: { screenX: number; screenY: number } | null;
|
|
237
|
-
activeMeasurement: { start: { screenX: number; screenY: number; x: number; y: number; z: number }; current: { screenX: number; screenY: number }; distance: number } | null;
|
|
238
|
-
snapTarget: SnapTarget | null;
|
|
239
|
-
snapVisualization: SnapVisualization | null;
|
|
240
|
-
hoverPosition?: { x: number; y: number } | null;
|
|
241
|
-
projectToScreen?: (worldPos: { x: number; y: number; z: number }) => { x: number; y: number } | null;
|
|
242
|
-
constraintEdge?: MeasurementConstraintEdge | null;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
const MeasurementOverlays = React.memo(function MeasurementOverlays({ measurements, pending, activeMeasurement, snapTarget, snapVisualization, hoverPosition, projectToScreen, constraintEdge }: MeasurementOverlaysProps) {
|
|
246
|
-
// Determine snap indicator position
|
|
247
|
-
// Priority: activeMeasurement.current > snapTarget projected position > hoverPosition (fallback)
|
|
248
|
-
const snapIndicatorPos = useMemo(() => {
|
|
249
|
-
// During active measurement, use the measurement's current position
|
|
250
|
-
if (activeMeasurement) {
|
|
251
|
-
return { x: activeMeasurement.current.screenX, y: activeMeasurement.current.screenY };
|
|
252
|
-
}
|
|
253
|
-
// During hover, project the snap target's world position to screen
|
|
254
|
-
// This ensures the indicator is at the actual snap point, not the cursor
|
|
255
|
-
if (snapTarget && projectToScreen) {
|
|
256
|
-
const projected = projectToScreen(snapTarget.position);
|
|
257
|
-
if (projected) {
|
|
258
|
-
return projected;
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
// Fallback to hover position (cursor position)
|
|
262
|
-
return hoverPosition ?? null;
|
|
263
|
-
}, [
|
|
264
|
-
activeMeasurement?.current?.screenX,
|
|
265
|
-
activeMeasurement?.current?.screenY,
|
|
266
|
-
snapTarget?.position?.x,
|
|
267
|
-
snapTarget?.position?.y,
|
|
268
|
-
snapTarget?.position?.z,
|
|
269
|
-
projectToScreen,
|
|
270
|
-
hoverPosition?.x,
|
|
271
|
-
hoverPosition?.y,
|
|
272
|
-
]);
|
|
273
|
-
|
|
274
|
-
// Stable values for effect dependencies
|
|
275
|
-
const measurementsCount = measurements.length;
|
|
276
|
-
const hasActiveMeasurement = !!activeMeasurement;
|
|
277
|
-
const hasSnapTarget = !!snapTarget;
|
|
278
|
-
const hasSnapVisualization = !!snapVisualization;
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
return (
|
|
282
|
-
<>
|
|
283
|
-
{/* SVG filter definitions for glow effect */}
|
|
284
|
-
<svg className="absolute w-0 h-0 pointer-events-none" style={{ pointerEvents: 'none' }}>
|
|
285
|
-
<defs>
|
|
286
|
-
<filter id="glow">
|
|
287
|
-
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
|
|
288
|
-
<feMerge>
|
|
289
|
-
<feMergeNode in="coloredBlur"/>
|
|
290
|
-
<feMergeNode in="SourceGraphic"/>
|
|
291
|
-
</feMerge>
|
|
292
|
-
</filter>
|
|
293
|
-
<filter id="snap-glow">
|
|
294
|
-
<feGaussianBlur stdDeviation="4" result="coloredBlur"/>
|
|
295
|
-
<feMerge>
|
|
296
|
-
<feMergeNode in="coloredBlur"/>
|
|
297
|
-
<feMergeNode in="SourceGraphic"/>
|
|
298
|
-
</feMerge>
|
|
299
|
-
</filter>
|
|
300
|
-
</defs>
|
|
301
|
-
</svg>
|
|
302
|
-
|
|
303
|
-
{/* Completed measurements */}
|
|
304
|
-
{measurements.map((m) => (
|
|
305
|
-
<div key={m.id} className="pointer-events-none">
|
|
306
|
-
{/* Line connecting start and end */}
|
|
307
|
-
<svg
|
|
308
|
-
className="absolute inset-0 pointer-events-none z-20"
|
|
309
|
-
style={{ overflow: 'visible', pointerEvents: 'none' }}
|
|
310
|
-
>
|
|
311
|
-
<line
|
|
312
|
-
x1={m.start.screenX}
|
|
313
|
-
y1={m.start.screenY}
|
|
314
|
-
x2={m.end.screenX}
|
|
315
|
-
y2={m.end.screenY}
|
|
316
|
-
stroke="hsl(var(--primary))"
|
|
317
|
-
strokeWidth="2"
|
|
318
|
-
strokeDasharray="6,3"
|
|
319
|
-
filter="url(#glow)"
|
|
320
|
-
/>
|
|
321
|
-
{/* Start point */}
|
|
322
|
-
<circle
|
|
323
|
-
cx={m.start.screenX}
|
|
324
|
-
cy={m.start.screenY}
|
|
325
|
-
r="5"
|
|
326
|
-
fill="white"
|
|
327
|
-
stroke="hsl(var(--primary))"
|
|
328
|
-
strokeWidth="2"
|
|
329
|
-
/>
|
|
330
|
-
{/* End point */}
|
|
331
|
-
<circle
|
|
332
|
-
cx={m.end.screenX}
|
|
333
|
-
cy={m.end.screenY}
|
|
334
|
-
r="5"
|
|
335
|
-
fill="white"
|
|
336
|
-
stroke="hsl(var(--primary))"
|
|
337
|
-
strokeWidth="2"
|
|
338
|
-
/>
|
|
339
|
-
</svg>
|
|
340
|
-
|
|
341
|
-
{/* Distance label at midpoint - brutalist style */}
|
|
342
|
-
<div
|
|
343
|
-
className="absolute pointer-events-none z-20 bg-zinc-900 dark:bg-zinc-100 text-zinc-100 dark:text-zinc-900 px-2 py-1 font-mono text-xs font-bold -translate-x-1/2 -translate-y-1/2 border-2 border-zinc-900 dark:border-zinc-100 shadow-[2px_2px_0px_0px_rgba(0,0,0,0.3)]"
|
|
344
|
-
style={{
|
|
345
|
-
left: (m.start.screenX + m.end.screenX) / 2,
|
|
346
|
-
top: (m.start.screenY + m.end.screenY) / 2,
|
|
347
|
-
}}
|
|
348
|
-
>
|
|
349
|
-
{formatDistance(m.distance)}
|
|
350
|
-
</div>
|
|
351
|
-
</div>
|
|
352
|
-
))}
|
|
353
|
-
|
|
354
|
-
{/* Active measurement (live preview while dragging) */}
|
|
355
|
-
{activeMeasurement && (
|
|
356
|
-
<div className="pointer-events-none">
|
|
357
|
-
<svg
|
|
358
|
-
className="absolute inset-0 pointer-events-none z-20"
|
|
359
|
-
style={{ overflow: 'visible', pointerEvents: 'none' }}
|
|
360
|
-
>
|
|
361
|
-
{/* Animated dashed line (marching ants effect) */}
|
|
362
|
-
<line
|
|
363
|
-
x1={activeMeasurement.start.screenX}
|
|
364
|
-
y1={activeMeasurement.start.screenY}
|
|
365
|
-
x2={activeMeasurement.current.screenX}
|
|
366
|
-
y2={activeMeasurement.current.screenY}
|
|
367
|
-
stroke="hsl(var(--primary))"
|
|
368
|
-
strokeWidth="2"
|
|
369
|
-
strokeDasharray="6,3"
|
|
370
|
-
strokeOpacity="0.7"
|
|
371
|
-
filter="url(#glow)"
|
|
372
|
-
/>
|
|
373
|
-
{/* Start point */}
|
|
374
|
-
<circle
|
|
375
|
-
cx={activeMeasurement.start.screenX}
|
|
376
|
-
cy={activeMeasurement.start.screenY}
|
|
377
|
-
r="6"
|
|
378
|
-
fill="white"
|
|
379
|
-
stroke="hsl(var(--primary))"
|
|
380
|
-
strokeWidth="2"
|
|
381
|
-
filter="url(#glow)"
|
|
382
|
-
/>
|
|
383
|
-
{/* Current point (slightly larger, pulsing) */}
|
|
384
|
-
<circle
|
|
385
|
-
cx={activeMeasurement.current.screenX}
|
|
386
|
-
cy={activeMeasurement.current.screenY}
|
|
387
|
-
r="7"
|
|
388
|
-
fill="white"
|
|
389
|
-
stroke="hsl(var(--primary))"
|
|
390
|
-
strokeWidth="2"
|
|
391
|
-
filter="url(#glow)"
|
|
392
|
-
className="animate-pulse"
|
|
393
|
-
/>
|
|
394
|
-
</svg>
|
|
395
|
-
|
|
396
|
-
{/* Live distance label - brutalist style */}
|
|
397
|
-
<div
|
|
398
|
-
className="absolute pointer-events-none z-20 bg-primary text-primary-foreground px-2.5 py-1 font-mono text-sm font-bold -translate-x-1/2 -translate-y-1/2 border-2 border-primary shadow-[3px_3px_0px_0px_rgba(0,0,0,0.2)]"
|
|
399
|
-
style={{
|
|
400
|
-
left: (activeMeasurement.start.screenX + activeMeasurement.current.screenX) / 2,
|
|
401
|
-
top: (activeMeasurement.start.screenY + activeMeasurement.current.screenY) / 2,
|
|
402
|
-
}}
|
|
403
|
-
>
|
|
404
|
-
{formatDistance(activeMeasurement.distance)}
|
|
405
|
-
</div>
|
|
406
|
-
</div>
|
|
407
|
-
)}
|
|
408
|
-
|
|
409
|
-
{/* Orthogonal constraint axes visualization */}
|
|
410
|
-
{activeMeasurement && constraintEdge?.activeAxis && projectToScreen && (() => {
|
|
411
|
-
const startWorld = activeMeasurement.start;
|
|
412
|
-
const startScreen = { x: startWorld.screenX, y: startWorld.screenY };
|
|
413
|
-
|
|
414
|
-
// Project axis endpoints to screen space
|
|
415
|
-
const axisLength = 2.0; // 2 meters in world space
|
|
416
|
-
|
|
417
|
-
const { axis1, axis2, axis3 } = constraintEdge.axes;
|
|
418
|
-
const colors = constraintEdge.colors;
|
|
419
|
-
|
|
420
|
-
// Calculate endpoints along each axis (positive and negative)
|
|
421
|
-
const axis1End = projectToScreen({
|
|
422
|
-
x: startWorld.x + axis1.x * axisLength,
|
|
423
|
-
y: startWorld.y + axis1.y * axisLength,
|
|
424
|
-
z: startWorld.z + axis1.z * axisLength,
|
|
425
|
-
});
|
|
426
|
-
const axis1Neg = projectToScreen({
|
|
427
|
-
x: startWorld.x - axis1.x * axisLength,
|
|
428
|
-
y: startWorld.y - axis1.y * axisLength,
|
|
429
|
-
z: startWorld.z - axis1.z * axisLength,
|
|
430
|
-
});
|
|
431
|
-
const axis2End = projectToScreen({
|
|
432
|
-
x: startWorld.x + axis2.x * axisLength,
|
|
433
|
-
y: startWorld.y + axis2.y * axisLength,
|
|
434
|
-
z: startWorld.z + axis2.z * axisLength,
|
|
435
|
-
});
|
|
436
|
-
const axis2Neg = projectToScreen({
|
|
437
|
-
x: startWorld.x - axis2.x * axisLength,
|
|
438
|
-
y: startWorld.y - axis2.y * axisLength,
|
|
439
|
-
z: startWorld.z - axis2.z * axisLength,
|
|
440
|
-
});
|
|
441
|
-
const axis3End = projectToScreen({
|
|
442
|
-
x: startWorld.x + axis3.x * axisLength,
|
|
443
|
-
y: startWorld.y + axis3.y * axisLength,
|
|
444
|
-
z: startWorld.z + axis3.z * axisLength,
|
|
445
|
-
});
|
|
446
|
-
const axis3Neg = projectToScreen({
|
|
447
|
-
x: startWorld.x - axis3.x * axisLength,
|
|
448
|
-
y: startWorld.y - axis3.y * axisLength,
|
|
449
|
-
z: startWorld.z - axis3.z * axisLength,
|
|
450
|
-
});
|
|
451
|
-
|
|
452
|
-
if (!axis1End || !axis1Neg || !axis2End || !axis2Neg || !axis3End || !axis3Neg) return null;
|
|
453
|
-
|
|
454
|
-
const activeAxis = constraintEdge.activeAxis;
|
|
455
|
-
|
|
456
|
-
return (
|
|
457
|
-
<svg
|
|
458
|
-
className="absolute inset-0 pointer-events-none z-25"
|
|
459
|
-
style={{ overflow: 'visible', pointerEvents: 'none' }}
|
|
460
|
-
>
|
|
461
|
-
{/* Axis 1 */}
|
|
462
|
-
<line
|
|
463
|
-
x1={axis1Neg.x}
|
|
464
|
-
y1={axis1Neg.y}
|
|
465
|
-
x2={axis1End.x}
|
|
466
|
-
y2={axis1End.y}
|
|
467
|
-
stroke={colors.axis1}
|
|
468
|
-
strokeWidth={activeAxis === 'axis1' ? 3 : 1.5}
|
|
469
|
-
strokeOpacity={activeAxis === 'axis1' ? 0.9 : 0.3}
|
|
470
|
-
strokeDasharray={activeAxis === 'axis1' ? 'none' : '4,4'}
|
|
471
|
-
strokeLinecap="round"
|
|
472
|
-
/>
|
|
473
|
-
{/* Axis 2 */}
|
|
474
|
-
<line
|
|
475
|
-
x1={axis2Neg.x}
|
|
476
|
-
y1={axis2Neg.y}
|
|
477
|
-
x2={axis2End.x}
|
|
478
|
-
y2={axis2End.y}
|
|
479
|
-
stroke={colors.axis2}
|
|
480
|
-
strokeWidth={activeAxis === 'axis2' ? 3 : 1.5}
|
|
481
|
-
strokeOpacity={activeAxis === 'axis2' ? 0.9 : 0.3}
|
|
482
|
-
strokeDasharray={activeAxis === 'axis2' ? 'none' : '4,4'}
|
|
483
|
-
strokeLinecap="round"
|
|
484
|
-
/>
|
|
485
|
-
{/* Axis 3 */}
|
|
486
|
-
<line
|
|
487
|
-
x1={axis3Neg.x}
|
|
488
|
-
y1={axis3Neg.y}
|
|
489
|
-
x2={axis3End.x}
|
|
490
|
-
y2={axis3End.y}
|
|
491
|
-
stroke={colors.axis3}
|
|
492
|
-
strokeWidth={activeAxis === 'axis3' ? 3 : 1.5}
|
|
493
|
-
strokeOpacity={activeAxis === 'axis3' ? 0.9 : 0.3}
|
|
494
|
-
strokeDasharray={activeAxis === 'axis3' ? 'none' : '4,4'}
|
|
495
|
-
strokeLinecap="round"
|
|
496
|
-
/>
|
|
497
|
-
{/* Center origin dot */}
|
|
498
|
-
<circle
|
|
499
|
-
cx={startScreen.x}
|
|
500
|
-
cy={startScreen.y}
|
|
501
|
-
r="4"
|
|
502
|
-
fill="white"
|
|
503
|
-
stroke={colors[activeAxis]}
|
|
504
|
-
strokeWidth="2"
|
|
505
|
-
/>
|
|
506
|
-
</svg>
|
|
507
|
-
);
|
|
508
|
-
})()}
|
|
509
|
-
|
|
510
|
-
{/* Edge highlight - draw full edge in 3D-projected screen space */}
|
|
511
|
-
{snapVisualization?.edgeLine3D && projectToScreen && (() => {
|
|
512
|
-
const start = projectToScreen(snapVisualization.edgeLine3D.v0);
|
|
513
|
-
const end = projectToScreen(snapVisualization.edgeLine3D.v1);
|
|
514
|
-
if (!start || !end) return null;
|
|
515
|
-
|
|
516
|
-
// Corner position (at v0 or v1)
|
|
517
|
-
const cornerPos = snapVisualization.cornerRings
|
|
518
|
-
? (snapVisualization.cornerRings.atStart ? start : end)
|
|
519
|
-
: null;
|
|
520
|
-
|
|
521
|
-
return (
|
|
522
|
-
<svg
|
|
523
|
-
className="absolute inset-0 pointer-events-none z-30"
|
|
524
|
-
style={{ overflow: 'visible', pointerEvents: 'none' }}
|
|
525
|
-
>
|
|
526
|
-
{/* Edge line with snap color (orange for edges) */}
|
|
527
|
-
<line
|
|
528
|
-
x1={start.x}
|
|
529
|
-
y1={start.y}
|
|
530
|
-
x2={end.x}
|
|
531
|
-
y2={end.y}
|
|
532
|
-
stroke="#FF9800"
|
|
533
|
-
strokeWidth="4"
|
|
534
|
-
strokeOpacity="0.9"
|
|
535
|
-
strokeLinecap="round"
|
|
536
|
-
filter="url(#snap-glow)"
|
|
537
|
-
/>
|
|
538
|
-
{/* Outer glow line for better visibility */}
|
|
539
|
-
<line
|
|
540
|
-
x1={start.x}
|
|
541
|
-
y1={start.y}
|
|
542
|
-
x2={end.x}
|
|
543
|
-
y2={end.y}
|
|
544
|
-
stroke="#FF9800"
|
|
545
|
-
strokeWidth="8"
|
|
546
|
-
strokeOpacity="0.3"
|
|
547
|
-
strokeLinecap="round"
|
|
548
|
-
/>
|
|
549
|
-
{/* Edge endpoints */}
|
|
550
|
-
<circle cx={start.x} cy={start.y} r="4" fill="#FF9800" fillOpacity="0.6" />
|
|
551
|
-
<circle cx={end.x} cy={end.y} r="4" fill="#FF9800" fillOpacity="0.6" />
|
|
552
|
-
|
|
553
|
-
{/* Corner rings - shows strong attraction at corners */}
|
|
554
|
-
{cornerPos && snapVisualization.cornerRings && (
|
|
555
|
-
<>
|
|
556
|
-
{/* Outer pulsing ring */}
|
|
557
|
-
<circle
|
|
558
|
-
cx={cornerPos.x}
|
|
559
|
-
cy={cornerPos.y}
|
|
560
|
-
r="18"
|
|
561
|
-
fill="none"
|
|
562
|
-
stroke="#FFEB3B"
|
|
563
|
-
strokeWidth="2"
|
|
564
|
-
strokeOpacity="0.4"
|
|
565
|
-
className="animate-pulse"
|
|
566
|
-
/>
|
|
567
|
-
{/* Middle ring */}
|
|
568
|
-
<circle
|
|
569
|
-
cx={cornerPos.x}
|
|
570
|
-
cy={cornerPos.y}
|
|
571
|
-
r="12"
|
|
572
|
-
fill="none"
|
|
573
|
-
stroke="#FFEB3B"
|
|
574
|
-
strokeWidth="2"
|
|
575
|
-
strokeOpacity="0.6"
|
|
576
|
-
/>
|
|
577
|
-
{/* Inner ring */}
|
|
578
|
-
<circle
|
|
579
|
-
cx={cornerPos.x}
|
|
580
|
-
cy={cornerPos.y}
|
|
581
|
-
r="6"
|
|
582
|
-
fill="#FFEB3B"
|
|
583
|
-
fillOpacity="0.8"
|
|
584
|
-
stroke="white"
|
|
585
|
-
strokeWidth="1"
|
|
586
|
-
/>
|
|
587
|
-
{/* Center dot */}
|
|
588
|
-
<circle
|
|
589
|
-
cx={cornerPos.x}
|
|
590
|
-
cy={cornerPos.y}
|
|
591
|
-
r="2"
|
|
592
|
-
fill="white"
|
|
593
|
-
/>
|
|
594
|
-
{/* Valence indicators (small dots around corner) */}
|
|
595
|
-
{snapVisualization.cornerRings.valence >= 3 && (
|
|
596
|
-
<>
|
|
597
|
-
<circle cx={cornerPos.x - 10} cy={cornerPos.y} r="2" fill="#FFEB3B" fillOpacity="0.7" />
|
|
598
|
-
<circle cx={cornerPos.x + 10} cy={cornerPos.y} r="2" fill="#FFEB3B" fillOpacity="0.7" />
|
|
599
|
-
<circle cx={cornerPos.x} cy={cornerPos.y - 10} r="2" fill="#FFEB3B" fillOpacity="0.7" />
|
|
600
|
-
</>
|
|
601
|
-
)}
|
|
602
|
-
</>
|
|
603
|
-
)}
|
|
604
|
-
</svg>
|
|
605
|
-
);
|
|
606
|
-
})()}
|
|
607
|
-
|
|
608
|
-
{/* Plane indicator - subtle grid/cross for face snaps */}
|
|
609
|
-
{snapVisualization?.planeIndicator && (
|
|
610
|
-
<svg
|
|
611
|
-
className="absolute inset-0 pointer-events-none z-25"
|
|
612
|
-
style={{ overflow: 'visible', pointerEvents: 'none' }}
|
|
613
|
-
>
|
|
614
|
-
{/* Cross indicator */}
|
|
615
|
-
<line
|
|
616
|
-
x1={snapVisualization.planeIndicator.x - 20}
|
|
617
|
-
y1={snapVisualization.planeIndicator.y}
|
|
618
|
-
x2={snapVisualization.planeIndicator.x + 20}
|
|
619
|
-
y2={snapVisualization.planeIndicator.y}
|
|
620
|
-
stroke="hsl(var(--primary))"
|
|
621
|
-
strokeWidth="2"
|
|
622
|
-
strokeOpacity="0.4"
|
|
623
|
-
/>
|
|
624
|
-
<line
|
|
625
|
-
x1={snapVisualization.planeIndicator.x}
|
|
626
|
-
y1={snapVisualization.planeIndicator.y - 20}
|
|
627
|
-
x2={snapVisualization.planeIndicator.x}
|
|
628
|
-
y2={snapVisualization.planeIndicator.y + 20}
|
|
629
|
-
stroke="hsl(var(--primary))"
|
|
630
|
-
strokeWidth="2"
|
|
631
|
-
strokeOpacity="0.4"
|
|
632
|
-
/>
|
|
633
|
-
{/* Small circle at center */}
|
|
634
|
-
<circle
|
|
635
|
-
cx={snapVisualization.planeIndicator.x}
|
|
636
|
-
cy={snapVisualization.planeIndicator.y}
|
|
637
|
-
r="4"
|
|
638
|
-
fill="hsl(var(--primary))"
|
|
639
|
-
fillOpacity="0.6"
|
|
640
|
-
/>
|
|
641
|
-
</svg>
|
|
642
|
-
)}
|
|
643
|
-
|
|
644
|
-
{/* Snap indicator */}
|
|
645
|
-
{snapTarget && snapIndicatorPos && (
|
|
646
|
-
<SnapIndicator
|
|
647
|
-
screenX={snapIndicatorPos.x}
|
|
648
|
-
screenY={snapIndicatorPos.y}
|
|
649
|
-
snapType={snapTarget.type}
|
|
650
|
-
/>
|
|
651
|
-
)}
|
|
652
|
-
|
|
653
|
-
{/* Pending point (legacy - keep for backward compatibility) */}
|
|
654
|
-
{pending && !activeMeasurement && (
|
|
655
|
-
<svg
|
|
656
|
-
className="absolute inset-0 pointer-events-none z-20"
|
|
657
|
-
style={{ overflow: 'visible', pointerEvents: 'none' }}
|
|
658
|
-
>
|
|
659
|
-
<circle
|
|
660
|
-
cx={pending.screenX}
|
|
661
|
-
cy={pending.screenY}
|
|
662
|
-
r="5"
|
|
663
|
-
fill="none"
|
|
664
|
-
stroke="hsl(var(--primary))"
|
|
665
|
-
strokeWidth="1.5"
|
|
666
|
-
/>
|
|
667
|
-
<circle
|
|
668
|
-
cx={pending.screenX}
|
|
669
|
-
cy={pending.screenY}
|
|
670
|
-
r="2.5"
|
|
671
|
-
fill="hsl(var(--primary))"
|
|
672
|
-
/>
|
|
673
|
-
</svg>
|
|
674
|
-
)}
|
|
675
|
-
</>
|
|
676
|
-
);
|
|
677
|
-
}, (prevProps, nextProps) => {
|
|
678
|
-
// Custom comparison to prevent unnecessary re-renders
|
|
679
|
-
// Return true if props are equal (skip re-render), false if different (re-render)
|
|
680
|
-
|
|
681
|
-
// Compare measurements - check both IDs AND screen coordinates
|
|
682
|
-
if (prevProps.measurements.length !== nextProps.measurements.length) return false;
|
|
683
|
-
for (let i = 0; i < prevProps.measurements.length; i++) {
|
|
684
|
-
const prev = prevProps.measurements[i];
|
|
685
|
-
const next = nextProps.measurements[i];
|
|
686
|
-
if (!next || prev.id !== next.id) return false;
|
|
687
|
-
// Check screen coordinates for zoom/camera changes
|
|
688
|
-
if (prev.start.screenX !== next.start.screenX || prev.start.screenY !== next.start.screenY) return false;
|
|
689
|
-
if (prev.end.screenX !== next.end.screenX || prev.end.screenY !== next.end.screenY) return false;
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
// Compare activeMeasurement - check if it exists and if position changed
|
|
693
|
-
if (!!prevProps.activeMeasurement !== !!nextProps.activeMeasurement) return false;
|
|
694
|
-
if (prevProps.activeMeasurement && nextProps.activeMeasurement) {
|
|
695
|
-
if (
|
|
696
|
-
prevProps.activeMeasurement.current.screenX !== nextProps.activeMeasurement.current.screenX ||
|
|
697
|
-
prevProps.activeMeasurement.current.screenY !== nextProps.activeMeasurement.current.screenY ||
|
|
698
|
-
prevProps.activeMeasurement.start.screenX !== nextProps.activeMeasurement.start.screenX ||
|
|
699
|
-
prevProps.activeMeasurement.start.screenY !== nextProps.activeMeasurement.start.screenY
|
|
700
|
-
) return false;
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
// Compare snapTarget - check type and position
|
|
704
|
-
if (!!prevProps.snapTarget !== !!nextProps.snapTarget) return false;
|
|
705
|
-
if (prevProps.snapTarget && nextProps.snapTarget) {
|
|
706
|
-
if (
|
|
707
|
-
prevProps.snapTarget.type !== nextProps.snapTarget.type ||
|
|
708
|
-
prevProps.snapTarget.position.x !== nextProps.snapTarget.position.x ||
|
|
709
|
-
prevProps.snapTarget.position.y !== nextProps.snapTarget.position.y ||
|
|
710
|
-
prevProps.snapTarget.position.z !== nextProps.snapTarget.position.z
|
|
711
|
-
) return false;
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
// Compare snapVisualization
|
|
715
|
-
if (!!prevProps.snapVisualization !== !!nextProps.snapVisualization) return false;
|
|
716
|
-
if (prevProps.snapVisualization && nextProps.snapVisualization) {
|
|
717
|
-
// Compare edgeLine3D (3D world coordinates)
|
|
718
|
-
const prevEdge = prevProps.snapVisualization.edgeLine3D;
|
|
719
|
-
const nextEdge = nextProps.snapVisualization.edgeLine3D;
|
|
720
|
-
if (!!prevEdge !== !!nextEdge) return false;
|
|
721
|
-
if (prevEdge && nextEdge) {
|
|
722
|
-
if (
|
|
723
|
-
prevEdge.v0.x !== nextEdge.v0.x ||
|
|
724
|
-
prevEdge.v0.y !== nextEdge.v0.y ||
|
|
725
|
-
prevEdge.v0.z !== nextEdge.v0.z ||
|
|
726
|
-
prevEdge.v1.x !== nextEdge.v1.x ||
|
|
727
|
-
prevEdge.v1.y !== nextEdge.v1.y ||
|
|
728
|
-
prevEdge.v1.z !== nextEdge.v1.z
|
|
729
|
-
) return false;
|
|
730
|
-
}
|
|
731
|
-
// Compare slidingDot (t parameter only)
|
|
732
|
-
const prevDot = prevProps.snapVisualization.slidingDot;
|
|
733
|
-
const nextDot = nextProps.snapVisualization.slidingDot;
|
|
734
|
-
if (!!prevDot !== !!nextDot) return false;
|
|
735
|
-
if (prevDot && nextDot) {
|
|
736
|
-
if (prevDot.t !== nextDot.t) return false;
|
|
737
|
-
}
|
|
738
|
-
// Compare cornerRings (atStart + valence)
|
|
739
|
-
const prevCorner = prevProps.snapVisualization.cornerRings;
|
|
740
|
-
const nextCorner = nextProps.snapVisualization.cornerRings;
|
|
741
|
-
if (!!prevCorner !== !!nextCorner) return false;
|
|
742
|
-
if (prevCorner && nextCorner) {
|
|
743
|
-
if (
|
|
744
|
-
prevCorner.atStart !== nextCorner.atStart ||
|
|
745
|
-
prevCorner.valence !== nextCorner.valence
|
|
746
|
-
) return false;
|
|
747
|
-
}
|
|
748
|
-
const prevPlane = prevProps.snapVisualization.planeIndicator;
|
|
749
|
-
const nextPlane = nextProps.snapVisualization.planeIndicator;
|
|
750
|
-
if (!!prevPlane !== !!nextPlane) return false;
|
|
751
|
-
if (prevPlane && nextPlane) {
|
|
752
|
-
if (
|
|
753
|
-
prevPlane.x !== nextPlane.x ||
|
|
754
|
-
prevPlane.y !== nextPlane.y
|
|
755
|
-
) return false;
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
// Compare projectToScreen (always re-render if it changes as we need it for projection)
|
|
760
|
-
if (prevProps.projectToScreen !== nextProps.projectToScreen) return false;
|
|
761
|
-
|
|
762
|
-
// Compare hoverPosition
|
|
763
|
-
if (prevProps.hoverPosition?.x !== nextProps.hoverPosition?.x ||
|
|
764
|
-
prevProps.hoverPosition?.y !== nextProps.hoverPosition?.y) return false;
|
|
765
|
-
|
|
766
|
-
// Compare pending
|
|
767
|
-
if (prevProps.pending?.screenX !== nextProps.pending?.screenX ||
|
|
768
|
-
prevProps.pending?.screenY !== nextProps.pending?.screenY) return false;
|
|
769
|
-
|
|
770
|
-
// Compare constraintEdge
|
|
771
|
-
if (!!prevProps.constraintEdge !== !!nextProps.constraintEdge) return false;
|
|
772
|
-
if (prevProps.constraintEdge && nextProps.constraintEdge) {
|
|
773
|
-
if (prevProps.constraintEdge.activeAxis !== nextProps.constraintEdge.activeAxis) return false;
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
return true; // All props are equal, skip re-render
|
|
777
|
-
});
|
|
778
|
-
|
|
779
|
-
interface SnapIndicatorProps {
|
|
780
|
-
screenX: number;
|
|
781
|
-
screenY: number;
|
|
782
|
-
snapType: SnapType;
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
function SnapIndicator({ screenX, screenY, snapType }: SnapIndicatorProps) {
|
|
786
|
-
// Distinct colors for each snap type - no labels needed, shapes are self-explanatory
|
|
787
|
-
const snapColors = {
|
|
788
|
-
[SnapType.VERTEX]: '#FFEB3B', // Yellow - circle = point
|
|
789
|
-
[SnapType.EDGE]: '#FF9800', // Orange - line = edge
|
|
790
|
-
[SnapType.FACE]: '#03A9F4', // Light Blue - square = face
|
|
791
|
-
[SnapType.FACE_CENTER]: '#00BCD4', // Cyan - square with dot = center
|
|
792
|
-
};
|
|
793
|
-
|
|
794
|
-
const color = snapColors[snapType];
|
|
795
|
-
|
|
796
|
-
return (
|
|
797
|
-
<svg
|
|
798
|
-
className="absolute inset-0 pointer-events-none z-25"
|
|
799
|
-
style={{ overflow: 'visible', pointerEvents: 'none' }}
|
|
800
|
-
>
|
|
801
|
-
{/* Outer glow ring - subtle pulsing indicator */}
|
|
802
|
-
<circle
|
|
803
|
-
cx={screenX}
|
|
804
|
-
cy={screenY}
|
|
805
|
-
r="10"
|
|
806
|
-
fill="none"
|
|
807
|
-
stroke={color}
|
|
808
|
-
strokeWidth="1.5"
|
|
809
|
-
strokeOpacity="0.4"
|
|
810
|
-
filter="url(#snap-glow)"
|
|
811
|
-
/>
|
|
812
|
-
|
|
813
|
-
{/* Vertex: filled circle (point) */}
|
|
814
|
-
{snapType === SnapType.VERTEX && (
|
|
815
|
-
<>
|
|
816
|
-
<circle cx={screenX} cy={screenY} r="5" fill={color} opacity="0.3" />
|
|
817
|
-
<circle cx={screenX} cy={screenY} r="2.5" fill={color} />
|
|
818
|
-
</>
|
|
819
|
-
)}
|
|
820
|
-
|
|
821
|
-
{/* Edge: horizontal line with center dot */}
|
|
822
|
-
{snapType === SnapType.EDGE && (
|
|
823
|
-
<>
|
|
824
|
-
<line
|
|
825
|
-
x1={screenX - 8}
|
|
826
|
-
y1={screenY}
|
|
827
|
-
x2={screenX + 8}
|
|
828
|
-
y2={screenY}
|
|
829
|
-
stroke={color}
|
|
830
|
-
strokeWidth="2"
|
|
831
|
-
strokeLinecap="round"
|
|
832
|
-
/>
|
|
833
|
-
<circle cx={screenX} cy={screenY} r="2" fill={color} />
|
|
834
|
-
</>
|
|
835
|
-
)}
|
|
836
|
-
|
|
837
|
-
{/* Face: square outline */}
|
|
838
|
-
{snapType === SnapType.FACE && (
|
|
839
|
-
<>
|
|
840
|
-
<rect
|
|
841
|
-
x={screenX - 5}
|
|
842
|
-
y={screenY - 5}
|
|
843
|
-
width="10"
|
|
844
|
-
height="10"
|
|
845
|
-
fill={color}
|
|
846
|
-
fillOpacity="0.2"
|
|
847
|
-
stroke={color}
|
|
848
|
-
strokeWidth="1.5"
|
|
849
|
-
/>
|
|
850
|
-
</>
|
|
851
|
-
)}
|
|
852
|
-
|
|
853
|
-
{/* Face Center: square with center dot */}
|
|
854
|
-
{snapType === SnapType.FACE_CENTER && (
|
|
855
|
-
<>
|
|
856
|
-
<rect
|
|
857
|
-
x={screenX - 5}
|
|
858
|
-
y={screenY - 5}
|
|
859
|
-
width="10"
|
|
860
|
-
height="10"
|
|
861
|
-
fill="none"
|
|
862
|
-
stroke={color}
|
|
863
|
-
strokeWidth="1.5"
|
|
864
|
-
/>
|
|
865
|
-
<circle cx={screenX} cy={screenY} r="2" fill={color} />
|
|
866
|
-
</>
|
|
867
|
-
)}
|
|
868
|
-
</svg>
|
|
869
|
-
);
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
// Axis display info for semantic names
|
|
873
|
-
const AXIS_INFO = {
|
|
874
|
-
down: { label: 'Down', description: 'Horizontal cut (floor plan view)', icon: '↓' },
|
|
875
|
-
front: { label: 'Front', description: 'Vertical cut (elevation view)', icon: '→' },
|
|
876
|
-
side: { label: 'Side', description: 'Vertical cut (side elevation)', icon: '⊙' },
|
|
877
|
-
} as const;
|
|
878
|
-
|
|
879
|
-
function SectionOverlay() {
|
|
880
|
-
const sectionPlane = useViewerStore((s) => s.sectionPlane);
|
|
881
|
-
const setSectionPlaneAxis = useViewerStore((s) => s.setSectionPlaneAxis);
|
|
882
|
-
const setSectionPlanePosition = useViewerStore((s) => s.setSectionPlanePosition);
|
|
883
|
-
const toggleSectionPlane = useViewerStore((s) => s.toggleSectionPlane);
|
|
884
|
-
const setActiveTool = useViewerStore((s) => s.setActiveTool);
|
|
885
|
-
const setDrawingPanelVisible = useViewerStore((s) => s.setDrawing2DPanelVisible);
|
|
886
|
-
const drawingPanelVisible = useViewerStore((s) => s.drawing2DPanelVisible);
|
|
887
|
-
const clearDrawing = useViewerStore((s) => s.clearDrawing2D);
|
|
888
|
-
const [isPanelCollapsed, setIsPanelCollapsed] = useState(true);
|
|
889
|
-
|
|
890
|
-
const handleClose = useCallback(() => {
|
|
891
|
-
setActiveTool('select');
|
|
892
|
-
}, [setActiveTool]);
|
|
893
|
-
|
|
894
|
-
const handleAxisChange = useCallback((axis: 'down' | 'front' | 'side') => {
|
|
895
|
-
setSectionPlaneAxis(axis);
|
|
896
|
-
}, [setSectionPlaneAxis]);
|
|
897
|
-
|
|
898
|
-
const handlePositionChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
899
|
-
setSectionPlanePosition(Number(e.target.value));
|
|
900
|
-
}, [setSectionPlanePosition]);
|
|
901
|
-
|
|
902
|
-
const togglePanel = useCallback(() => {
|
|
903
|
-
setIsPanelCollapsed(prev => !prev);
|
|
904
|
-
}, []);
|
|
905
|
-
|
|
906
|
-
const handleView2D = useCallback(() => {
|
|
907
|
-
// Clear existing drawing to force regeneration with current settings
|
|
908
|
-
clearDrawing();
|
|
909
|
-
setDrawingPanelVisible(true);
|
|
910
|
-
}, [clearDrawing, setDrawingPanelVisible]);
|
|
911
|
-
|
|
912
|
-
return (
|
|
913
|
-
<>
|
|
914
|
-
{/* Compact Section Tool Panel - matches Measure tool style */}
|
|
915
|
-
<div className="pointer-events-auto absolute top-4 left-1/2 -translate-x-1/2 bg-background/95 backdrop-blur-sm rounded-lg border shadow-lg z-30">
|
|
916
|
-
{/* Header - always visible */}
|
|
917
|
-
<div className="flex items-center justify-between gap-2 p-2">
|
|
918
|
-
<button
|
|
919
|
-
onClick={togglePanel}
|
|
920
|
-
className="flex items-center gap-2 hover:bg-accent/50 rounded px-2 py-1 transition-colors"
|
|
921
|
-
>
|
|
922
|
-
<Slice className="h-4 w-4 text-primary" />
|
|
923
|
-
<span className="font-medium text-sm">Section</span>
|
|
924
|
-
{sectionPlane.enabled && (
|
|
925
|
-
<span className="text-xs text-primary font-mono">
|
|
926
|
-
{AXIS_INFO[sectionPlane.axis].label} <span className="inline-block w-12 text-right tabular-nums">{sectionPlane.position.toFixed(1)}%</span>
|
|
927
|
-
</span>
|
|
928
|
-
)}
|
|
929
|
-
<ChevronDown className={`h-3 w-3 transition-transform ${isPanelCollapsed ? '-rotate-90' : ''}`} />
|
|
930
|
-
</button>
|
|
931
|
-
<div className="flex items-center gap-1">
|
|
932
|
-
{/* Only show 2D button when panel is closed */}
|
|
933
|
-
{!drawingPanelVisible && (
|
|
934
|
-
<Button variant="ghost" size="icon-sm" onClick={handleView2D} title="Open 2D Drawing Panel">
|
|
935
|
-
<FileImage className="h-3 w-3" />
|
|
936
|
-
</Button>
|
|
937
|
-
)}
|
|
938
|
-
<Button variant="ghost" size="icon-sm" onClick={handleClose} title="Close">
|
|
939
|
-
<X className="h-3 w-3" />
|
|
940
|
-
</Button>
|
|
941
|
-
</div>
|
|
942
|
-
</div>
|
|
943
|
-
|
|
944
|
-
{/* Expandable content */}
|
|
945
|
-
{!isPanelCollapsed && (
|
|
946
|
-
<div className="border-t px-3 pb-3 min-w-64">
|
|
947
|
-
{/* Direction Selection */}
|
|
948
|
-
<div className="mt-3">
|
|
949
|
-
<label className="text-xs text-muted-foreground mb-2 block">Direction</label>
|
|
950
|
-
<div className="flex gap-1">
|
|
951
|
-
{(['down', 'front', 'side'] as const).map((axis) => (
|
|
952
|
-
<Button
|
|
953
|
-
key={axis}
|
|
954
|
-
variant={sectionPlane.axis === axis ? 'default' : 'outline'}
|
|
955
|
-
size="sm"
|
|
956
|
-
className="flex-1 flex-col h-auto py-1.5"
|
|
957
|
-
onClick={() => handleAxisChange(axis)}
|
|
958
|
-
>
|
|
959
|
-
<span className="text-xs font-medium">{AXIS_INFO[axis].label}</span>
|
|
960
|
-
</Button>
|
|
961
|
-
))}
|
|
962
|
-
</div>
|
|
963
|
-
</div>
|
|
964
|
-
|
|
965
|
-
{/* Position Slider */}
|
|
966
|
-
<div className="mt-3">
|
|
967
|
-
<div className="flex items-center justify-between mb-1">
|
|
968
|
-
<label className="text-xs text-muted-foreground">Position</label>
|
|
969
|
-
<input
|
|
970
|
-
type="number"
|
|
971
|
-
min="0"
|
|
972
|
-
max="100"
|
|
973
|
-
step="0.1"
|
|
974
|
-
value={sectionPlane.position}
|
|
975
|
-
onChange={handlePositionChange}
|
|
976
|
-
className="w-16 text-xs font-mono bg-muted px-1.5 py-0.5 rounded border-none text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
|
977
|
-
/>
|
|
978
|
-
</div>
|
|
979
|
-
<input
|
|
980
|
-
type="range"
|
|
981
|
-
min="0"
|
|
982
|
-
max="100"
|
|
983
|
-
step="0.1"
|
|
984
|
-
value={sectionPlane.position}
|
|
985
|
-
onChange={handlePositionChange}
|
|
986
|
-
className="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-primary"
|
|
987
|
-
/>
|
|
988
|
-
</div>
|
|
989
|
-
|
|
990
|
-
{/* Show 2D panel button - only when panel is closed */}
|
|
991
|
-
{!drawingPanelVisible && (
|
|
992
|
-
<div className="mt-3 pt-3 border-t">
|
|
993
|
-
<Button
|
|
994
|
-
variant="outline"
|
|
995
|
-
size="sm"
|
|
996
|
-
className="w-full"
|
|
997
|
-
onClick={handleView2D}
|
|
998
|
-
>
|
|
999
|
-
<FileImage className="h-4 w-4 mr-2" />
|
|
1000
|
-
Open 2D Drawing
|
|
1001
|
-
</Button>
|
|
1002
|
-
</div>
|
|
1003
|
-
)}
|
|
1004
|
-
</div>
|
|
1005
|
-
)}
|
|
1006
|
-
</div>
|
|
1007
|
-
|
|
1008
|
-
{/* Instruction hint - brutalist style matching Measure tool */}
|
|
1009
|
-
<div
|
|
1010
|
-
className="pointer-events-auto absolute bottom-16 left-1/2 -translate-x-1/2 z-30 bg-zinc-900 dark:bg-zinc-100 text-zinc-100 dark:text-zinc-900 px-3 py-1.5 border-2 border-zinc-900 dark:border-zinc-100 transition-shadow duration-150"
|
|
1011
|
-
style={{
|
|
1012
|
-
boxShadow: sectionPlane.enabled
|
|
1013
|
-
? '4px 4px 0px 0px #03A9F4' // Light blue shadow when active
|
|
1014
|
-
: '3px 3px 0px 0px rgba(0,0,0,0.3)'
|
|
1015
|
-
}}
|
|
1016
|
-
>
|
|
1017
|
-
<span className="font-mono text-xs uppercase tracking-wide">
|
|
1018
|
-
{sectionPlane.enabled
|
|
1019
|
-
? `Cutting ${AXIS_INFO[sectionPlane.axis].label.toLowerCase()} at ${sectionPlane.position.toFixed(1)}%`
|
|
1020
|
-
: 'Preview mode'}
|
|
1021
|
-
</span>
|
|
1022
|
-
</div>
|
|
1023
|
-
|
|
1024
|
-
{/* Enable toggle - brutalist style matching Measure tool */}
|
|
1025
|
-
<div className="pointer-events-auto absolute bottom-4 left-1/2 -translate-x-1/2 z-30">
|
|
1026
|
-
<button
|
|
1027
|
-
onClick={toggleSectionPlane}
|
|
1028
|
-
className={`px-2 py-1 font-mono text-[10px] uppercase tracking-wider border-2 transition-colors ${
|
|
1029
|
-
sectionPlane.enabled
|
|
1030
|
-
? 'bg-primary text-primary-foreground border-primary'
|
|
1031
|
-
: 'bg-zinc-100 dark:bg-zinc-900 text-zinc-500 border-zinc-300 dark:border-zinc-700'
|
|
1032
|
-
}`}
|
|
1033
|
-
title="Toggle section plane"
|
|
1034
|
-
>
|
|
1035
|
-
{sectionPlane.enabled ? 'Cutting' : 'Preview'}
|
|
1036
|
-
</button>
|
|
1037
|
-
</div>
|
|
1038
|
-
|
|
1039
|
-
{/* Section plane visualization overlay */}
|
|
1040
|
-
<SectionPlaneVisualization axis={sectionPlane.axis} enabled={sectionPlane.enabled} />
|
|
1041
|
-
</>
|
|
1042
|
-
);
|
|
1043
|
-
}
|
|
1044
|
-
|
|
1045
|
-
// Section plane visual indicator component
|
|
1046
|
-
function SectionPlaneVisualization({ axis, enabled }: { axis: 'down' | 'front' | 'side'; enabled: boolean }) {
|
|
1047
|
-
// Get the axis color
|
|
1048
|
-
const axisColors = {
|
|
1049
|
-
down: '#03A9F4', // Light blue for horizontal cuts
|
|
1050
|
-
front: '#4CAF50', // Green for front cuts
|
|
1051
|
-
side: '#FF9800', // Orange for side cuts
|
|
1052
|
-
};
|
|
1053
|
-
|
|
1054
|
-
const color = axisColors[axis];
|
|
1055
|
-
|
|
1056
|
-
return (
|
|
1057
|
-
<svg
|
|
1058
|
-
className="absolute inset-0 pointer-events-none z-20"
|
|
1059
|
-
style={{ overflow: 'visible', pointerEvents: 'none' }}
|
|
1060
|
-
>
|
|
1061
|
-
<defs>
|
|
1062
|
-
<filter id="section-glow">
|
|
1063
|
-
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
|
|
1064
|
-
<feMerge>
|
|
1065
|
-
<feMergeNode in="coloredBlur"/>
|
|
1066
|
-
<feMergeNode in="SourceGraphic"/>
|
|
1067
|
-
</feMerge>
|
|
1068
|
-
</filter>
|
|
1069
|
-
{/* Animated dash pattern */}
|
|
1070
|
-
<pattern id="section-pattern" patternUnits="userSpaceOnUse" width="10" height="10">
|
|
1071
|
-
<line x1="0" y1="0" x2="10" y2="10" stroke={color} strokeWidth="1" strokeOpacity="0.5"/>
|
|
1072
|
-
</pattern>
|
|
1073
|
-
</defs>
|
|
1074
|
-
|
|
1075
|
-
{/* Axis indicator in corner */}
|
|
1076
|
-
<g transform="translate(24, 24)">
|
|
1077
|
-
<circle cx="20" cy="20" r="18" fill={color} fillOpacity={enabled ? 0.2 : 0.1} stroke={color} strokeWidth={enabled ? 3 : 2} filter="url(#section-glow)"/>
|
|
1078
|
-
<text
|
|
1079
|
-
x="20"
|
|
1080
|
-
y="20"
|
|
1081
|
-
textAnchor="middle"
|
|
1082
|
-
dominantBaseline="central"
|
|
1083
|
-
fill={color}
|
|
1084
|
-
fontFamily="monospace"
|
|
1085
|
-
fontSize="11"
|
|
1086
|
-
fontWeight="bold"
|
|
1087
|
-
>
|
|
1088
|
-
{AXIS_INFO[axis].label.toUpperCase()}
|
|
1089
|
-
</text>
|
|
1090
|
-
{/* Active indicator */}
|
|
1091
|
-
{enabled && (
|
|
1092
|
-
<text
|
|
1093
|
-
x="20"
|
|
1094
|
-
y="32"
|
|
1095
|
-
textAnchor="middle"
|
|
1096
|
-
fill={color}
|
|
1097
|
-
fontFamily="monospace"
|
|
1098
|
-
fontSize="7"
|
|
1099
|
-
fontWeight="bold"
|
|
1100
|
-
>
|
|
1101
|
-
CUT
|
|
1102
|
-
</text>
|
|
1103
|
-
)}
|
|
1104
|
-
</g>
|
|
1105
|
-
</svg>
|
|
1106
|
-
);
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
function formatDistance(meters: number): string {
|
|
1110
|
-
if (meters < 0.01) {
|
|
1111
|
-
return `${(meters * 1000).toFixed(1)} mm`;
|
|
1112
|
-
} else if (meters < 1) {
|
|
1113
|
-
return `${(meters * 100).toFixed(1)} cm`;
|
|
1114
|
-
} else if (meters < 1000) {
|
|
1115
|
-
return `${meters.toFixed(2)} m`;
|
|
1116
|
-
} else {
|
|
1117
|
-
return `${(meters / 1000).toFixed(2)} km`;
|
|
1118
|
-
}
|
|
1119
|
-
}
|