@ifc-lite/viewer 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/dist/assets/{Arrow.dom-BjDQoB2M.js → Arrow.dom-BGPQieQQ.js} +1 -1
  3. package/dist/assets/ifc-lite_bg-DyIN_nBM.wasm +0 -0
  4. package/dist/assets/{index-YBtrHPu3.js → index-dgdgiQ9p.js} +40212 -30008
  5. package/dist/assets/index-yTqs8kgX.css +1 -0
  6. package/dist/assets/{native-bridge-CULtTDX3.js → native-bridge-DD0SNyQ5.js} +1 -1
  7. package/dist/assets/{wasm-bridge-CjL-lSak.js → wasm-bridge-D54YMO7X.js} +1 -1
  8. package/dist/index.html +2 -2
  9. package/package.json +18 -15
  10. package/src/components/viewer/BCFPanel.tsx +7 -789
  11. package/src/components/viewer/Drawing2DCanvas.tsx +1048 -0
  12. package/src/components/viewer/DrawingSettingsPanel.tsx +3 -3
  13. package/src/components/viewer/HierarchyPanel.tsx +110 -842
  14. package/src/components/viewer/IDSExportDialog.tsx +281 -0
  15. package/src/components/viewer/IDSPanel.tsx +126 -17
  16. package/src/components/viewer/KeyboardShortcutsDialog.tsx +9 -0
  17. package/src/components/viewer/LensPanel.tsx +603 -0
  18. package/src/components/viewer/MainToolbar.tsx +188 -21
  19. package/src/components/viewer/PropertiesPanel.tsx +171 -663
  20. package/src/components/viewer/PropertyEditor.tsx +866 -77
  21. package/src/components/viewer/Section2DPanel.tsx +76 -2648
  22. package/src/components/viewer/ToolOverlays.tsx +3 -1097
  23. package/src/components/viewer/ViewerLayout.tsx +132 -45
  24. package/src/components/viewer/Viewport.tsx +237 -1659
  25. package/src/components/viewer/ViewportContainer.tsx +11 -3
  26. package/src/components/viewer/bcf/BCFCreateTopicForm.tsx +134 -0
  27. package/src/components/viewer/bcf/BCFTopicDetail.tsx +388 -0
  28. package/src/components/viewer/bcf/BCFTopicList.tsx +239 -0
  29. package/src/components/viewer/bcf/bcfHelpers.tsx +109 -0
  30. package/src/components/viewer/hierarchy/HierarchyNode.tsx +328 -0
  31. package/src/components/viewer/hierarchy/treeDataBuilder.ts +464 -0
  32. package/src/components/viewer/hierarchy/types.ts +54 -0
  33. package/src/components/viewer/hierarchy/useHierarchyTree.ts +280 -0
  34. package/src/components/viewer/lists/ListBuilder.tsx +486 -0
  35. package/src/components/viewer/lists/ListPanel.tsx +540 -0
  36. package/src/components/viewer/lists/ListResultsTable.tsx +193 -0
  37. package/src/components/viewer/properties/ClassificationCard.tsx +70 -0
  38. package/src/components/viewer/properties/CoordinateDisplay.tsx +49 -0
  39. package/src/components/viewer/properties/DocumentCard.tsx +89 -0
  40. package/src/components/viewer/properties/MaterialCard.tsx +201 -0
  41. package/src/components/viewer/properties/ModelMetadataPanel.tsx +335 -0
  42. package/src/components/viewer/properties/PropertySetCard.tsx +132 -0
  43. package/src/components/viewer/properties/QuantitySetCard.tsx +79 -0
  44. package/src/components/viewer/properties/RelationshipsCard.tsx +100 -0
  45. package/src/components/viewer/properties/encodingUtils.ts +29 -0
  46. package/src/components/viewer/tools/MeasurePanel.tsx +218 -0
  47. package/src/components/viewer/tools/MeasurementVisuals.tsx +644 -0
  48. package/src/components/viewer/tools/SectionPanel.tsx +183 -0
  49. package/src/components/viewer/tools/SectionVisualization.tsx +78 -0
  50. package/src/components/viewer/tools/formatDistance.ts +18 -0
  51. package/src/components/viewer/tools/sectionConstants.ts +14 -0
  52. package/src/components/viewer/useAnimationLoop.ts +166 -0
  53. package/src/components/viewer/useGeometryStreaming.ts +398 -0
  54. package/src/components/viewer/useKeyboardControls.ts +221 -0
  55. package/src/components/viewer/useMouseControls.ts +1009 -0
  56. package/src/components/viewer/useRenderUpdates.ts +165 -0
  57. package/src/components/viewer/useTouchControls.ts +245 -0
  58. package/src/hooks/ids/idsColorSystem.ts +125 -0
  59. package/src/hooks/ids/idsDataAccessor.ts +237 -0
  60. package/src/hooks/ids/idsExportService.ts +444 -0
  61. package/src/hooks/useBCF.ts +7 -0
  62. package/src/hooks/useDrawingExport.ts +627 -0
  63. package/src/hooks/useDrawingGeneration.ts +627 -0
  64. package/src/hooks/useFloorplanView.ts +108 -0
  65. package/src/hooks/useIDS.ts +270 -463
  66. package/src/hooks/useIfc.ts +26 -1628
  67. package/src/hooks/useIfcFederation.ts +803 -0
  68. package/src/hooks/useIfcLoader.ts +508 -0
  69. package/src/hooks/useIfcServer.ts +465 -0
  70. package/src/hooks/useKeyboardShortcuts.ts +1 -1
  71. package/src/hooks/useLens.ts +129 -0
  72. package/src/hooks/useMeasure2D.ts +365 -0
  73. package/src/hooks/useViewControls.ts +218 -0
  74. package/src/lib/ifc4-pset-definitions.test.ts +161 -0
  75. package/src/lib/ifc4-pset-definitions.ts +621 -0
  76. package/src/lib/ifc4-qto-definitions.ts +315 -0
  77. package/src/lib/lens/adapter.ts +138 -0
  78. package/src/lib/lens/index.ts +5 -0
  79. package/src/lib/lists/adapter.ts +69 -0
  80. package/src/lib/lists/index.ts +28 -0
  81. package/src/lib/lists/persistence.ts +64 -0
  82. package/src/services/fs-cache.ts +1 -1
  83. package/src/services/tauri-modules.d.ts +25 -0
  84. package/src/store/index.ts +38 -2
  85. package/src/store/slices/cameraSlice.ts +14 -1
  86. package/src/store/slices/dataSlice.ts +14 -1
  87. package/src/store/slices/lensSlice.ts +184 -0
  88. package/src/store/slices/listSlice.ts +74 -0
  89. package/src/store/slices/pinboardSlice.ts +114 -0
  90. package/src/store/types.ts +5 -0
  91. package/src/utils/ifcConfig.ts +16 -3
  92. package/src/utils/serverDataModel.ts +64 -101
  93. package/src/vite-env.d.ts +3 -0
  94. package/dist/assets/ifc-lite_bg-C6kblxf9.wasm +0 -0
  95. package/dist/assets/index-v3mcCUPN.css +0 -1
@@ -6,12 +6,9 @@
6
6
  * Tool-specific overlays for measure and section tools
7
7
  */
8
8
 
9
- import React, { useCallback, useMemo, useState, useEffect } from 'react';
10
- import { X, Trash2, Ruler, Slice, ChevronDown, FileImage } from 'lucide-react';
11
- import { Button } from '@/components/ui/button';
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
- }