@ifc-lite/viewer 1.1.6 → 1.5.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 (164) hide show
  1. package/LICENSE +373 -0
  2. package/dist/apple-touch-icon.png +0 -0
  3. package/dist/assets/Arrow.dom-B0e15b_b.js +20 -0
  4. package/dist/assets/arrow2-bb-jcVEo.js +2 -0
  5. package/dist/assets/arrow2_bg-4Y7xYo54.wasm +0 -0
  6. package/dist/assets/arrow2_bg-BlXl-cSQ.js +1 -0
  7. package/dist/assets/arrow2_bg-BoXCojjR.wasm +0 -0
  8. package/dist/assets/desktop-cache-oPzaWXYE.js +1 -0
  9. package/dist/assets/event-DIOks52T.js +1 -0
  10. package/dist/assets/ifc-cache-BAN4vcd4.js +1 -0
  11. package/dist/assets/ifc-lite_bg-C6kblxf9.wasm +0 -0
  12. package/dist/assets/index-Dgd6vzw_.js +65252 -0
  13. package/dist/assets/index-v3mcCUPN.css +1 -0
  14. package/dist/assets/native-bridge-Ci7NLjlZ.js +111 -0
  15. package/dist/assets/wasm-bridge-Dc82YpdZ.js +1 -0
  16. package/dist/favicon-16x16-cropped.png +0 -0
  17. package/dist/favicon-16x16.png +0 -0
  18. package/dist/favicon-192x192-cropped.png +0 -0
  19. package/dist/favicon-192x192.png +0 -0
  20. package/dist/favicon-32x32-cropped.png +0 -0
  21. package/dist/favicon-32x32.png +0 -0
  22. package/dist/favicon-48x48-cropped.png +0 -0
  23. package/dist/favicon-48x48.png +0 -0
  24. package/dist/favicon-512x512-cropped.png +0 -0
  25. package/dist/favicon-512x512.png +0 -0
  26. package/dist/favicon-64x64-cropped.png +0 -0
  27. package/dist/favicon-64x64.png +0 -0
  28. package/dist/favicon-96x96-cropped.png +0 -0
  29. package/dist/favicon-96x96.png +0 -0
  30. package/dist/favicon-square-512.png +0 -0
  31. package/dist/favicon.ico +0 -0
  32. package/dist/favicon.png +0 -0
  33. package/dist/favicon.svg +3 -0
  34. package/dist/index.html +44 -0
  35. package/dist/logo.png +0 -0
  36. package/dist/manifest.json +48 -0
  37. package/index.html +33 -2
  38. package/package.json +34 -17
  39. package/public/apple-touch-icon.png +0 -0
  40. package/public/favicon-16x16-cropped.png +0 -0
  41. package/public/favicon-16x16.png +0 -0
  42. package/public/favicon-192x192-cropped.png +0 -0
  43. package/public/favicon-192x192.png +0 -0
  44. package/public/favicon-32x32-cropped.png +0 -0
  45. package/public/favicon-32x32.png +0 -0
  46. package/public/favicon-48x48-cropped.png +0 -0
  47. package/public/favicon-48x48.png +0 -0
  48. package/public/favicon-512x512-cropped.png +0 -0
  49. package/public/favicon-512x512.png +0 -0
  50. package/public/favicon-64x64-cropped.png +0 -0
  51. package/public/favicon-64x64.png +0 -0
  52. package/public/favicon-96x96-cropped.png +0 -0
  53. package/public/favicon-96x96.png +0 -0
  54. package/public/favicon-square-512.png +0 -0
  55. package/public/favicon.ico +0 -0
  56. package/public/favicon.png +0 -0
  57. package/public/favicon.svg +3 -0
  58. package/public/logo.png +0 -0
  59. package/public/manifest.json +48 -0
  60. package/src/App.tsx +2 -0
  61. package/src/components/ui/alert.tsx +62 -0
  62. package/src/components/ui/badge.tsx +39 -0
  63. package/src/components/ui/dialog.tsx +120 -0
  64. package/src/components/ui/label.tsx +27 -0
  65. package/src/components/ui/select.tsx +151 -0
  66. package/src/components/ui/switch.tsx +30 -0
  67. package/src/components/ui/table.tsx +120 -0
  68. package/src/components/ui/tabs.tsx +1 -1
  69. package/src/components/viewer/BCFPanel.tsx +1164 -0
  70. package/src/components/viewer/BulkPropertyEditor.tsx +875 -0
  71. package/src/components/viewer/DataConnector.tsx +840 -0
  72. package/src/components/viewer/DrawingSettingsPanel.tsx +536 -0
  73. package/src/components/viewer/EntityContextMenu.tsx +45 -17
  74. package/src/components/viewer/ExportChangesButton.tsx +195 -0
  75. package/src/components/viewer/ExportDialog.tsx +402 -0
  76. package/src/components/viewer/HierarchyPanel.tsx +1132 -218
  77. package/src/components/viewer/IDSPanel.tsx +661 -0
  78. package/src/components/viewer/KeyboardShortcutsDialog.tsx +245 -39
  79. package/src/components/viewer/MainToolbar.tsx +418 -94
  80. package/src/components/viewer/PropertiesPanel.tsx +1355 -91
  81. package/src/components/viewer/PropertyEditor.tsx +611 -0
  82. package/src/components/viewer/Section2DPanel.tsx +3313 -0
  83. package/src/components/viewer/SheetSetupPanel.tsx +502 -0
  84. package/src/components/viewer/StatusBar.tsx +27 -16
  85. package/src/components/viewer/TitleBlockEditor.tsx +437 -0
  86. package/src/components/viewer/ToolOverlays.tsx +935 -127
  87. package/src/components/viewer/ViewerLayout.tsx +40 -11
  88. package/src/components/viewer/Viewport.tsx +1276 -336
  89. package/src/components/viewer/ViewportContainer.tsx +554 -18
  90. package/src/components/viewer/ViewportOverlays.tsx +24 -7
  91. package/src/hooks/useBCF.ts +504 -0
  92. package/src/hooks/useIDS.ts +1065 -0
  93. package/src/hooks/useIfc.ts +1534 -205
  94. package/src/hooks/useIfcCache.ts +279 -0
  95. package/src/hooks/useKeyboardShortcuts.ts +50 -8
  96. package/src/hooks/useModelSelection.ts +61 -0
  97. package/src/hooks/useViewerSelectors.ts +218 -0
  98. package/src/hooks/useWebGPU.ts +80 -0
  99. package/src/index.css +265 -27
  100. package/src/lib/platform.ts +23 -0
  101. package/src/services/cacheService.ts +142 -0
  102. package/src/services/desktop-cache.ts +143 -0
  103. package/src/services/fs-cache.ts +212 -0
  104. package/src/services/ifc-cache.ts +14 -6
  105. package/src/store/constants.ts +85 -0
  106. package/src/store/index.ts +214 -0
  107. package/src/store/slices/bcfSlice.ts +372 -0
  108. package/src/store/slices/cameraSlice.ts +63 -0
  109. package/src/store/slices/dataSlice.test.ts +226 -0
  110. package/src/store/slices/dataSlice.ts +112 -0
  111. package/src/store/slices/drawing2DSlice.ts +340 -0
  112. package/src/store/slices/hoverSlice.ts +40 -0
  113. package/src/store/slices/idsSlice.ts +310 -0
  114. package/src/store/slices/loadingSlice.ts +33 -0
  115. package/src/store/slices/measurementSlice.test.ts +217 -0
  116. package/src/store/slices/measurementSlice.ts +293 -0
  117. package/src/store/slices/modelSlice.test.ts +271 -0
  118. package/src/store/slices/modelSlice.ts +211 -0
  119. package/src/store/slices/mutationSlice.ts +502 -0
  120. package/src/store/slices/sectionSlice.test.ts +125 -0
  121. package/src/store/slices/sectionSlice.ts +58 -0
  122. package/src/store/slices/selectionSlice.test.ts +286 -0
  123. package/src/store/slices/selectionSlice.ts +263 -0
  124. package/src/store/slices/sheetSlice.ts +565 -0
  125. package/src/store/slices/uiSlice.ts +58 -0
  126. package/src/store/slices/visibilitySlice.test.ts +304 -0
  127. package/src/store/slices/visibilitySlice.ts +277 -0
  128. package/src/store/types.test.ts +135 -0
  129. package/src/store/types.ts +248 -0
  130. package/src/store.ts +40 -515
  131. package/src/utils/ifcConfig.ts +82 -0
  132. package/src/utils/localParsingUtils.ts +287 -0
  133. package/src/utils/serverDataModel.ts +783 -0
  134. package/src/utils/spatialHierarchy.ts +283 -0
  135. package/src/utils/viewportUtils.ts +334 -0
  136. package/src/vite-env.d.ts +23 -0
  137. package/src/webgpu-types.d.ts +128 -0
  138. package/src-tauri/Cargo.toml +29 -0
  139. package/src-tauri/build.rs +7 -0
  140. package/src-tauri/capabilities/default.json +18 -0
  141. package/src-tauri/icons/128x128.png +0 -0
  142. package/src-tauri/icons/128x128@2x.png +0 -0
  143. package/src-tauri/icons/32x32.png +0 -0
  144. package/src-tauri/icons/Square107x107Logo.png +0 -0
  145. package/src-tauri/icons/Square142x142Logo.png +0 -0
  146. package/src-tauri/icons/Square150x150Logo.png +0 -0
  147. package/src-tauri/icons/Square284x284Logo.png +0 -0
  148. package/src-tauri/icons/Square30x30Logo.png +0 -0
  149. package/src-tauri/icons/Square310x310Logo.png +0 -0
  150. package/src-tauri/icons/Square44x44Logo.png +0 -0
  151. package/src-tauri/icons/Square71x71Logo.png +0 -0
  152. package/src-tauri/icons/Square89x89Logo.png +0 -0
  153. package/src-tauri/icons/StoreLogo.png +0 -0
  154. package/src-tauri/icons/icon.icns +0 -0
  155. package/src-tauri/icons/icon.ico +0 -0
  156. package/src-tauri/icons/icon.png +0 -0
  157. package/src-tauri/src/lib.rs +21 -0
  158. package/src-tauri/src/main.rs +10 -0
  159. package/src-tauri/tauri.conf.json +39 -0
  160. package/vite.config.ts +174 -26
  161. package/public/ifc-lite_bg.wasm +0 -0
  162. package/public/web-ifc.wasm +0 -0
  163. package/src/components/Viewport.tsx +0 -723
  164. package/src/components/viewer/BoxSelectionOverlay.tsx +0 -53
@@ -6,10 +6,12 @@
6
6
  * Tool-specific overlays for measure and section tools
7
7
  */
8
8
 
9
- import { useCallback } from 'react';
10
- import { X, Trash2, Ruler, Slice } from 'lucide-react';
9
+ import React, { useCallback, useMemo, useState, useEffect } from 'react';
10
+ import { X, Trash2, Ruler, Slice, ChevronDown, FileImage } from 'lucide-react';
11
11
  import { Button } from '@/components/ui/button';
12
- import { useViewerStore, type Measurement } from '@/store';
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';
13
15
 
14
16
  export function ToolOverlays() {
15
17
  const activeTool = useViewerStore((s) => s.activeTool);
@@ -28,81 +30,180 @@ export function ToolOverlays() {
28
30
  function MeasureOverlay() {
29
31
  const measurements = useViewerStore((s) => s.measurements);
30
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);
31
39
  const deleteMeasurement = useViewerStore((s) => s.deleteMeasurement);
32
40
  const clearMeasurements = useViewerStore((s) => s.clearMeasurements);
33
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]);
34
82
 
35
83
  const handleClear = useCallback(() => {
36
84
  clearMeasurements();
37
85
  }, [clearMeasurements]);
38
86
 
39
- const handleClose = useCallback(() => {
40
- setActiveTool('select');
41
- }, [setActiveTool]);
42
-
43
87
  const handleDeleteMeasurement = useCallback((id: string) => {
44
88
  deleteMeasurement(id);
45
89
  }, [deleteMeasurement]);
46
90
 
91
+ const togglePanel = useCallback(() => {
92
+ setIsPanelCollapsed(prev => !prev);
93
+ }, []);
94
+
95
+ const handleClose = useCallback(() => {
96
+ setActiveTool('select');
97
+ }, [setActiveTool]);
98
+
47
99
  // Calculate total distance
48
100
  const totalDistance = measurements.reduce((sum, m) => sum + m.distance, 0);
49
101
 
50
102
  return (
51
103
  <>
52
- {/* Measure Tool Panel */}
53
- <div className="absolute top-4 left-1/2 -translate-x-1/2 bg-background/95 backdrop-blur-sm rounded-lg border shadow-lg p-3 min-w-64 z-30">
54
- <div className="flex items-center justify-between gap-4 mb-3">
55
- <div className="flex items-center gap-2">
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
+ >
56
115
  <Ruler className="h-4 w-4 text-primary" />
57
- <span className="font-medium text-sm">Measure Tool</span>
58
- </div>
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>
59
122
  <div className="flex items-center gap-1">
60
- <Button variant="ghost" size="icon-sm" onClick={handleClear} title="Clear all">
61
- <Trash2 className="h-4 w-4" />
62
- </Button>
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
+ )}
63
128
  <Button variant="ghost" size="icon-sm" onClick={handleClose} title="Close">
64
- <X className="h-4 w-4" />
129
+ <X className="h-3 w-3" />
65
130
  </Button>
66
131
  </div>
67
132
  </div>
68
133
 
69
- <div className="text-xs text-muted-foreground mb-3">
70
- Click on the model to place measurement points
71
- </div>
72
-
73
- {measurements.length > 0 ? (
74
- <div className="space-y-2">
75
- {measurements.map((m, i) => (
76
- <MeasurementItem
77
- key={m.id}
78
- measurement={m}
79
- index={i}
80
- onDelete={handleDeleteMeasurement}
81
- />
82
- ))}
83
- {measurements.length > 1 && (
84
- <div className="flex items-center justify-between border-t pt-2 mt-2 text-sm font-medium">
85
- <span>Total</span>
86
- <span className="font-mono">{formatDistance(totalDistance)}</span>
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
87
157
  </div>
88
158
  )}
89
159
  </div>
90
- ) : (
91
- <div className="text-center py-4 text-muted-foreground text-sm">
92
- No measurements yet
93
- </div>
94
160
  )}
95
161
  </div>
96
162
 
97
- {/* Instruction hint */}
98
- <div className="absolute bottom-20 left-1/2 -translate-x-1/2 bg-primary text-primary-foreground px-4 py-2 rounded-full text-sm shadow-lg z-30">
99
- {pendingMeasurePoint
100
- ? 'Click to set end point'
101
- : 'Click on model to set start point'}
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>
102
194
  </div>
103
195
 
104
- {/* Render measurement lines and labels */}
105
- <MeasurementOverlays measurements={measurements} pending={pendingMeasurePoint} />
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
+ />
106
207
  </>
107
208
  );
108
209
  }
@@ -115,16 +216,16 @@ interface MeasurementItemProps {
115
216
 
116
217
  function MeasurementItem({ measurement, index, onDelete }: MeasurementItemProps) {
117
218
  return (
118
- <div className="flex items-center justify-between bg-muted/50 rounded px-2 py-1 text-sm">
119
- <span className="text-muted-foreground">#{index + 1}</span>
120
- <span className="font-mono">{formatDistance(measurement.distance)}</span>
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>
121
222
  <Button
122
223
  variant="ghost"
123
224
  size="icon-sm"
124
- className="h-5 w-5"
225
+ className="h-4 w-4 hover:bg-destructive/20"
125
226
  onClick={() => onDelete(measurement.id)}
126
227
  >
127
- <X className="h-3 w-3" />
228
+ <X className="h-2.5 w-2.5" />
128
229
  </Button>
129
230
  </div>
130
231
  );
@@ -133,18 +234,79 @@ function MeasurementItem({ measurement, index, onDelete }: MeasurementItemProps)
133
234
  interface MeasurementOverlaysProps {
134
235
  measurements: Measurement[];
135
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;
136
243
  }
137
244
 
138
- function MeasurementOverlays({ measurements, pending }: MeasurementOverlaysProps) {
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
+
139
281
  return (
140
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
+
141
303
  {/* Completed measurements */}
142
304
  {measurements.map((m) => (
143
- <div key={m.id}>
305
+ <div key={m.id} className="pointer-events-none">
144
306
  {/* Line connecting start and end */}
145
307
  <svg
146
308
  className="absolute inset-0 pointer-events-none z-20"
147
- style={{ overflow: 'visible' }}
309
+ style={{ overflow: 'visible', pointerEvents: 'none' }}
148
310
  >
149
311
  <line
150
312
  x1={m.start.screenX}
@@ -153,25 +315,32 @@ function MeasurementOverlays({ measurements, pending }: MeasurementOverlaysProps
153
315
  y2={m.end.screenY}
154
316
  stroke="hsl(var(--primary))"
155
317
  strokeWidth="2"
156
- strokeDasharray="5,5"
318
+ strokeDasharray="6,3"
319
+ filter="url(#glow)"
157
320
  />
321
+ {/* Start point */}
158
322
  <circle
159
323
  cx={m.start.screenX}
160
324
  cy={m.start.screenY}
161
- r="4"
162
- fill="hsl(var(--primary))"
325
+ r="5"
326
+ fill="white"
327
+ stroke="hsl(var(--primary))"
328
+ strokeWidth="2"
163
329
  />
330
+ {/* End point */}
164
331
  <circle
165
332
  cx={m.end.screenX}
166
333
  cy={m.end.screenY}
167
- r="4"
168
- fill="hsl(var(--primary))"
334
+ r="5"
335
+ fill="white"
336
+ stroke="hsl(var(--primary))"
337
+ strokeWidth="2"
169
338
  />
170
339
  </svg>
171
340
 
172
- {/* Distance label at midpoint */}
341
+ {/* Distance label at midpoint - brutalist style */}
173
342
  <div
174
- className="absolute pointer-events-none z-20 bg-primary text-primary-foreground px-2 py-0.5 rounded text-xs font-mono -translate-x-1/2 -translate-y-1/2"
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)]"
175
344
  style={{
176
345
  left: (m.start.screenX + m.end.screenX) / 2,
177
346
  top: (m.start.screenY + m.end.screenY) / 2,
@@ -182,45 +351,547 @@ function MeasurementOverlays({ measurements, pending }: MeasurementOverlaysProps
182
351
  </div>
183
352
  ))}
184
353
 
185
- {/* Pending point */}
186
- {pending && (
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 && (
187
655
  <svg
188
656
  className="absolute inset-0 pointer-events-none z-20"
189
- style={{ overflow: 'visible' }}
657
+ style={{ overflow: 'visible', pointerEvents: 'none' }}
190
658
  >
191
659
  <circle
192
660
  cx={pending.screenX}
193
661
  cy={pending.screenY}
194
- r="6"
662
+ r="5"
195
663
  fill="none"
196
664
  stroke="hsl(var(--primary))"
197
- strokeWidth="2"
665
+ strokeWidth="1.5"
198
666
  />
199
667
  <circle
200
668
  cx={pending.screenX}
201
669
  cy={pending.screenY}
202
- r="3"
670
+ r="2.5"
203
671
  fill="hsl(var(--primary))"
204
672
  />
205
673
  </svg>
206
674
  )}
207
675
  </>
208
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;
209
783
  }
210
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
+
211
879
  function SectionOverlay() {
212
880
  const sectionPlane = useViewerStore((s) => s.sectionPlane);
213
881
  const setSectionPlaneAxis = useViewerStore((s) => s.setSectionPlaneAxis);
214
882
  const setSectionPlanePosition = useViewerStore((s) => s.setSectionPlanePosition);
215
883
  const toggleSectionPlane = useViewerStore((s) => s.toggleSectionPlane);
216
- const flipSectionPlane = useViewerStore((s) => s.flipSectionPlane);
217
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);
218
889
 
219
890
  const handleClose = useCallback(() => {
220
891
  setActiveTool('select');
221
892
  }, [setActiveTool]);
222
893
 
223
- const handleAxisChange = useCallback((axis: 'x' | 'y' | 'z') => {
894
+ const handleAxisChange = useCallback((axis: 'down' | 'front' | 'side') => {
224
895
  setSectionPlaneAxis(axis);
225
896
  }, [setSectionPlaneAxis]);
226
897
 
@@ -228,73 +899,210 @@ function SectionOverlay() {
228
899
  setSectionPlanePosition(Number(e.target.value));
229
900
  }, [setSectionPlanePosition]);
230
901
 
231
- return (
232
- <div className="absolute top-4 left-1/2 -translate-x-1/2 bg-background/95 backdrop-blur-sm rounded-lg border shadow-lg p-3 min-w-72 z-30">
233
- <div className="flex items-center justify-between gap-4 mb-3">
234
- <div className="flex items-center gap-2">
235
- <Slice className="h-4 w-4 text-primary" />
236
- <span className="font-medium text-sm">Section Plane</span>
237
- </div>
238
- <Button variant="ghost" size="icon-sm" onClick={handleClose} title="Close">
239
- <X className="h-4 w-4" />
240
- </Button>
241
- </div>
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]);
242
911
 
243
- <div className="space-y-4">
244
- {/* Axis Selection */}
245
- <div>
246
- <label className="text-xs text-muted-foreground mb-2 block">Axis</label>
247
- <div className="flex gap-1">
248
- {(['x', 'y', 'z'] as const).map((axis) => (
249
- <Button
250
- key={axis}
251
- variant={sectionPlane.axis === axis ? 'default' : 'outline'}
252
- size="sm"
253
- className="flex-1"
254
- onClick={() => handleAxisChange(axis)}
255
- >
256
- {axis.toUpperCase()}
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" />
257
936
  </Button>
258
- ))}
937
+ )}
938
+ <Button variant="ghost" size="icon-sm" onClick={handleClose} title="Close">
939
+ <X className="h-3 w-3" />
940
+ </Button>
259
941
  </div>
260
942
  </div>
261
943
 
262
- {/* Position Slider */}
263
- <div>
264
- <div className="flex items-center justify-between mb-2">
265
- <label className="text-xs text-muted-foreground">Position</label>
266
- <span className="text-xs font-mono">{sectionPlane.position}%</span>
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
+ )}
267
1004
  </div>
268
- <input
269
- type="range"
270
- min="0"
271
- max="100"
272
- value={sectionPlane.position}
273
- onChange={handlePositionChange}
274
- className="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-primary"
275
- />
276
- </div>
1005
+ )}
1006
+ </div>
277
1007
 
278
- {/* Actions */}
279
- <div className="flex gap-2">
280
- <Button
281
- variant={sectionPlane.enabled ? 'default' : 'outline'}
282
- size="sm"
283
- className="flex-1"
284
- onClick={toggleSectionPlane}
285
- >
286
- {sectionPlane.enabled ? 'Disable' : 'Enable'}
287
- </Button>
288
- <Button variant="outline" size="sm" className="flex-1" onClick={flipSectionPlane}>
289
- Flip
290
- </Button>
291
- </div>
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>
292
1023
 
293
- <div className="text-xs text-muted-foreground text-center">
294
- Section plane cuts the model along the selected axis
295
- </div>
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>
296
1037
  </div>
297
- </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>
298
1106
  );
299
1107
  }
300
1108