@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
@@ -0,0 +1,644 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * SVG measurement overlay visualizations (lines, labels, snap indicators)
7
+ */
8
+
9
+ import React, { useMemo } from 'react';
10
+ import type { Measurement, SnapVisualization } from '@/store';
11
+ import type { MeasurementConstraintEdge } from '@/store/types';
12
+ import { SnapType, type SnapTarget } from '@ifc-lite/renderer';
13
+ import { formatDistance } from './formatDistance';
14
+
15
+ export interface MeasurementOverlaysProps {
16
+ measurements: Measurement[];
17
+ pending: { screenX: number; screenY: number } | null;
18
+ activeMeasurement: { start: { screenX: number; screenY: number; x: number; y: number; z: number }; current: { screenX: number; screenY: number }; distance: number } | null;
19
+ snapTarget: SnapTarget | null;
20
+ snapVisualization: SnapVisualization | null;
21
+ hoverPosition?: { x: number; y: number } | null;
22
+ projectToScreen?: (worldPos: { x: number; y: number; z: number }) => { x: number; y: number } | null;
23
+ constraintEdge?: MeasurementConstraintEdge | null;
24
+ }
25
+
26
+ export const MeasurementOverlays = React.memo(function MeasurementOverlays({ measurements, pending, activeMeasurement, snapTarget, snapVisualization, hoverPosition, projectToScreen, constraintEdge }: MeasurementOverlaysProps) {
27
+ // Determine snap indicator position
28
+ // Priority: activeMeasurement.current > snapTarget projected position > hoverPosition (fallback)
29
+ const snapIndicatorPos = useMemo(() => {
30
+ // During active measurement, use the measurement's current position
31
+ if (activeMeasurement) {
32
+ return { x: activeMeasurement.current.screenX, y: activeMeasurement.current.screenY };
33
+ }
34
+ // During hover, project the snap target's world position to screen
35
+ // This ensures the indicator is at the actual snap point, not the cursor
36
+ if (snapTarget && projectToScreen) {
37
+ const projected = projectToScreen(snapTarget.position);
38
+ if (projected) {
39
+ return projected;
40
+ }
41
+ }
42
+ // Fallback to hover position (cursor position)
43
+ return hoverPosition ?? null;
44
+ }, [
45
+ activeMeasurement?.current?.screenX,
46
+ activeMeasurement?.current?.screenY,
47
+ snapTarget?.position?.x,
48
+ snapTarget?.position?.y,
49
+ snapTarget?.position?.z,
50
+ projectToScreen,
51
+ hoverPosition?.x,
52
+ hoverPosition?.y,
53
+ ]);
54
+
55
+ return (
56
+ <>
57
+ {/* SVG filter definitions for glow effect */}
58
+ <svg className="absolute w-0 h-0 pointer-events-none" style={{ pointerEvents: 'none' }}>
59
+ <defs>
60
+ <filter id="glow">
61
+ <feGaussianBlur stdDeviation="3" result="coloredBlur"/>
62
+ <feMerge>
63
+ <feMergeNode in="coloredBlur"/>
64
+ <feMergeNode in="SourceGraphic"/>
65
+ </feMerge>
66
+ </filter>
67
+ <filter id="snap-glow">
68
+ <feGaussianBlur stdDeviation="4" result="coloredBlur"/>
69
+ <feMerge>
70
+ <feMergeNode in="coloredBlur"/>
71
+ <feMergeNode in="SourceGraphic"/>
72
+ </feMerge>
73
+ </filter>
74
+ </defs>
75
+ </svg>
76
+
77
+ {/* Completed measurements */}
78
+ {measurements.map((m) => (
79
+ <div key={m.id} className="pointer-events-none">
80
+ {/* Line connecting start and end */}
81
+ <svg
82
+ className="absolute inset-0 pointer-events-none z-20"
83
+ style={{ overflow: 'visible', pointerEvents: 'none' }}
84
+ >
85
+ <line
86
+ x1={m.start.screenX}
87
+ y1={m.start.screenY}
88
+ x2={m.end.screenX}
89
+ y2={m.end.screenY}
90
+ stroke="hsl(var(--primary))"
91
+ strokeWidth="2"
92
+ strokeDasharray="6,3"
93
+ filter="url(#glow)"
94
+ />
95
+ {/* Start point */}
96
+ <circle
97
+ cx={m.start.screenX}
98
+ cy={m.start.screenY}
99
+ r="5"
100
+ fill="white"
101
+ stroke="hsl(var(--primary))"
102
+ strokeWidth="2"
103
+ />
104
+ {/* End point */}
105
+ <circle
106
+ cx={m.end.screenX}
107
+ cy={m.end.screenY}
108
+ r="5"
109
+ fill="white"
110
+ stroke="hsl(var(--primary))"
111
+ strokeWidth="2"
112
+ />
113
+ </svg>
114
+
115
+ {/* Distance label at midpoint - brutalist style */}
116
+ <div
117
+ 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)]"
118
+ style={{
119
+ left: (m.start.screenX + m.end.screenX) / 2,
120
+ top: (m.start.screenY + m.end.screenY) / 2,
121
+ }}
122
+ >
123
+ {formatDistance(m.distance)}
124
+ </div>
125
+ </div>
126
+ ))}
127
+
128
+ {/* Active measurement (live preview while dragging) */}
129
+ {activeMeasurement && (
130
+ <div className="pointer-events-none">
131
+ <svg
132
+ className="absolute inset-0 pointer-events-none z-20"
133
+ style={{ overflow: 'visible', pointerEvents: 'none' }}
134
+ >
135
+ {/* Animated dashed line (marching ants effect) */}
136
+ <line
137
+ x1={activeMeasurement.start.screenX}
138
+ y1={activeMeasurement.start.screenY}
139
+ x2={activeMeasurement.current.screenX}
140
+ y2={activeMeasurement.current.screenY}
141
+ stroke="hsl(var(--primary))"
142
+ strokeWidth="2"
143
+ strokeDasharray="6,3"
144
+ strokeOpacity="0.7"
145
+ filter="url(#glow)"
146
+ />
147
+ {/* Start point */}
148
+ <circle
149
+ cx={activeMeasurement.start.screenX}
150
+ cy={activeMeasurement.start.screenY}
151
+ r="6"
152
+ fill="white"
153
+ stroke="hsl(var(--primary))"
154
+ strokeWidth="2"
155
+ filter="url(#glow)"
156
+ />
157
+ {/* Current point (slightly larger, pulsing) */}
158
+ <circle
159
+ cx={activeMeasurement.current.screenX}
160
+ cy={activeMeasurement.current.screenY}
161
+ r="7"
162
+ fill="white"
163
+ stroke="hsl(var(--primary))"
164
+ strokeWidth="2"
165
+ filter="url(#glow)"
166
+ className="animate-pulse"
167
+ />
168
+ </svg>
169
+
170
+ {/* Live distance label - brutalist style */}
171
+ <div
172
+ 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)]"
173
+ style={{
174
+ left: (activeMeasurement.start.screenX + activeMeasurement.current.screenX) / 2,
175
+ top: (activeMeasurement.start.screenY + activeMeasurement.current.screenY) / 2,
176
+ }}
177
+ >
178
+ {formatDistance(activeMeasurement.distance)}
179
+ </div>
180
+ </div>
181
+ )}
182
+
183
+ {/* Orthogonal constraint axes visualization */}
184
+ {activeMeasurement && constraintEdge?.activeAxis && projectToScreen && (() => {
185
+ const startWorld = activeMeasurement.start;
186
+ const startScreen = { x: startWorld.screenX, y: startWorld.screenY };
187
+
188
+ // Project axis endpoints to screen space
189
+ const axisLength = 2.0; // 2 meters in world space
190
+
191
+ const { axis1, axis2, axis3 } = constraintEdge.axes;
192
+ const colors = constraintEdge.colors;
193
+
194
+ // Calculate endpoints along each axis (positive and negative)
195
+ const axis1End = projectToScreen({
196
+ x: startWorld.x + axis1.x * axisLength,
197
+ y: startWorld.y + axis1.y * axisLength,
198
+ z: startWorld.z + axis1.z * axisLength,
199
+ });
200
+ const axis1Neg = projectToScreen({
201
+ x: startWorld.x - axis1.x * axisLength,
202
+ y: startWorld.y - axis1.y * axisLength,
203
+ z: startWorld.z - axis1.z * axisLength,
204
+ });
205
+ const axis2End = projectToScreen({
206
+ x: startWorld.x + axis2.x * axisLength,
207
+ y: startWorld.y + axis2.y * axisLength,
208
+ z: startWorld.z + axis2.z * axisLength,
209
+ });
210
+ const axis2Neg = projectToScreen({
211
+ x: startWorld.x - axis2.x * axisLength,
212
+ y: startWorld.y - axis2.y * axisLength,
213
+ z: startWorld.z - axis2.z * axisLength,
214
+ });
215
+ const axis3End = projectToScreen({
216
+ x: startWorld.x + axis3.x * axisLength,
217
+ y: startWorld.y + axis3.y * axisLength,
218
+ z: startWorld.z + axis3.z * axisLength,
219
+ });
220
+ const axis3Neg = projectToScreen({
221
+ x: startWorld.x - axis3.x * axisLength,
222
+ y: startWorld.y - axis3.y * axisLength,
223
+ z: startWorld.z - axis3.z * axisLength,
224
+ });
225
+
226
+ if (!axis1End || !axis1Neg || !axis2End || !axis2Neg || !axis3End || !axis3Neg) return null;
227
+
228
+ const activeAxis = constraintEdge.activeAxis;
229
+
230
+ return (
231
+ <svg
232
+ className="absolute inset-0 pointer-events-none z-25"
233
+ style={{ overflow: 'visible', pointerEvents: 'none' }}
234
+ >
235
+ {/* Axis 1 */}
236
+ <line
237
+ x1={axis1Neg.x}
238
+ y1={axis1Neg.y}
239
+ x2={axis1End.x}
240
+ y2={axis1End.y}
241
+ stroke={colors.axis1}
242
+ strokeWidth={activeAxis === 'axis1' ? 3 : 1.5}
243
+ strokeOpacity={activeAxis === 'axis1' ? 0.9 : 0.3}
244
+ strokeDasharray={activeAxis === 'axis1' ? 'none' : '4,4'}
245
+ strokeLinecap="round"
246
+ />
247
+ {/* Axis 2 */}
248
+ <line
249
+ x1={axis2Neg.x}
250
+ y1={axis2Neg.y}
251
+ x2={axis2End.x}
252
+ y2={axis2End.y}
253
+ stroke={colors.axis2}
254
+ strokeWidth={activeAxis === 'axis2' ? 3 : 1.5}
255
+ strokeOpacity={activeAxis === 'axis2' ? 0.9 : 0.3}
256
+ strokeDasharray={activeAxis === 'axis2' ? 'none' : '4,4'}
257
+ strokeLinecap="round"
258
+ />
259
+ {/* Axis 3 */}
260
+ <line
261
+ x1={axis3Neg.x}
262
+ y1={axis3Neg.y}
263
+ x2={axis3End.x}
264
+ y2={axis3End.y}
265
+ stroke={colors.axis3}
266
+ strokeWidth={activeAxis === 'axis3' ? 3 : 1.5}
267
+ strokeOpacity={activeAxis === 'axis3' ? 0.9 : 0.3}
268
+ strokeDasharray={activeAxis === 'axis3' ? 'none' : '4,4'}
269
+ strokeLinecap="round"
270
+ />
271
+ {/* Center origin dot */}
272
+ <circle
273
+ cx={startScreen.x}
274
+ cy={startScreen.y}
275
+ r="4"
276
+ fill="white"
277
+ stroke={colors[activeAxis]}
278
+ strokeWidth="2"
279
+ />
280
+ </svg>
281
+ );
282
+ })()}
283
+
284
+ {/* Edge highlight - draw full edge in 3D-projected screen space */}
285
+ {snapVisualization?.edgeLine3D && projectToScreen && (() => {
286
+ const start = projectToScreen(snapVisualization.edgeLine3D.v0);
287
+ const end = projectToScreen(snapVisualization.edgeLine3D.v1);
288
+ if (!start || !end) return null;
289
+
290
+ // Corner position (at v0 or v1)
291
+ const cornerPos = snapVisualization.cornerRings
292
+ ? (snapVisualization.cornerRings.atStart ? start : end)
293
+ : null;
294
+
295
+ return (
296
+ <svg
297
+ className="absolute inset-0 pointer-events-none z-30"
298
+ style={{ overflow: 'visible', pointerEvents: 'none' }}
299
+ >
300
+ {/* Edge line with snap color (orange for edges) */}
301
+ <line
302
+ x1={start.x}
303
+ y1={start.y}
304
+ x2={end.x}
305
+ y2={end.y}
306
+ stroke="#FF9800"
307
+ strokeWidth="4"
308
+ strokeOpacity="0.9"
309
+ strokeLinecap="round"
310
+ filter="url(#snap-glow)"
311
+ />
312
+ {/* Outer glow line for better visibility */}
313
+ <line
314
+ x1={start.x}
315
+ y1={start.y}
316
+ x2={end.x}
317
+ y2={end.y}
318
+ stroke="#FF9800"
319
+ strokeWidth="8"
320
+ strokeOpacity="0.3"
321
+ strokeLinecap="round"
322
+ />
323
+ {/* Edge endpoints */}
324
+ <circle cx={start.x} cy={start.y} r="4" fill="#FF9800" fillOpacity="0.6" />
325
+ <circle cx={end.x} cy={end.y} r="4" fill="#FF9800" fillOpacity="0.6" />
326
+
327
+ {/* Corner rings - shows strong attraction at corners */}
328
+ {cornerPos && snapVisualization.cornerRings && (
329
+ <>
330
+ {/* Outer pulsing ring */}
331
+ <circle
332
+ cx={cornerPos.x}
333
+ cy={cornerPos.y}
334
+ r="18"
335
+ fill="none"
336
+ stroke="#FFEB3B"
337
+ strokeWidth="2"
338
+ strokeOpacity="0.4"
339
+ className="animate-pulse"
340
+ />
341
+ {/* Middle ring */}
342
+ <circle
343
+ cx={cornerPos.x}
344
+ cy={cornerPos.y}
345
+ r="12"
346
+ fill="none"
347
+ stroke="#FFEB3B"
348
+ strokeWidth="2"
349
+ strokeOpacity="0.6"
350
+ />
351
+ {/* Inner ring */}
352
+ <circle
353
+ cx={cornerPos.x}
354
+ cy={cornerPos.y}
355
+ r="6"
356
+ fill="#FFEB3B"
357
+ fillOpacity="0.8"
358
+ stroke="white"
359
+ strokeWidth="1"
360
+ />
361
+ {/* Center dot */}
362
+ <circle
363
+ cx={cornerPos.x}
364
+ cy={cornerPos.y}
365
+ r="2"
366
+ fill="white"
367
+ />
368
+ {/* Valence indicators (small dots around corner) */}
369
+ {snapVisualization.cornerRings.valence >= 3 && (
370
+ <>
371
+ <circle cx={cornerPos.x - 10} cy={cornerPos.y} r="2" fill="#FFEB3B" fillOpacity="0.7" />
372
+ <circle cx={cornerPos.x + 10} cy={cornerPos.y} r="2" fill="#FFEB3B" fillOpacity="0.7" />
373
+ <circle cx={cornerPos.x} cy={cornerPos.y - 10} r="2" fill="#FFEB3B" fillOpacity="0.7" />
374
+ </>
375
+ )}
376
+ </>
377
+ )}
378
+ </svg>
379
+ );
380
+ })()}
381
+
382
+ {/* Plane indicator - subtle grid/cross for face snaps */}
383
+ {snapVisualization?.planeIndicator && (
384
+ <svg
385
+ className="absolute inset-0 pointer-events-none z-25"
386
+ style={{ overflow: 'visible', pointerEvents: 'none' }}
387
+ >
388
+ {/* Cross indicator */}
389
+ <line
390
+ x1={snapVisualization.planeIndicator.x - 20}
391
+ y1={snapVisualization.planeIndicator.y}
392
+ x2={snapVisualization.planeIndicator.x + 20}
393
+ y2={snapVisualization.planeIndicator.y}
394
+ stroke="hsl(var(--primary))"
395
+ strokeWidth="2"
396
+ strokeOpacity="0.4"
397
+ />
398
+ <line
399
+ x1={snapVisualization.planeIndicator.x}
400
+ y1={snapVisualization.planeIndicator.y - 20}
401
+ x2={snapVisualization.planeIndicator.x}
402
+ y2={snapVisualization.planeIndicator.y + 20}
403
+ stroke="hsl(var(--primary))"
404
+ strokeWidth="2"
405
+ strokeOpacity="0.4"
406
+ />
407
+ {/* Small circle at center */}
408
+ <circle
409
+ cx={snapVisualization.planeIndicator.x}
410
+ cy={snapVisualization.planeIndicator.y}
411
+ r="4"
412
+ fill="hsl(var(--primary))"
413
+ fillOpacity="0.6"
414
+ />
415
+ </svg>
416
+ )}
417
+
418
+ {/* Snap indicator */}
419
+ {snapTarget && snapIndicatorPos && (
420
+ <SnapIndicator
421
+ screenX={snapIndicatorPos.x}
422
+ screenY={snapIndicatorPos.y}
423
+ snapType={snapTarget.type}
424
+ />
425
+ )}
426
+
427
+ {/* Pending point (legacy - keep for backward compatibility) */}
428
+ {pending && !activeMeasurement && (
429
+ <svg
430
+ className="absolute inset-0 pointer-events-none z-20"
431
+ style={{ overflow: 'visible', pointerEvents: 'none' }}
432
+ >
433
+ <circle
434
+ cx={pending.screenX}
435
+ cy={pending.screenY}
436
+ r="5"
437
+ fill="none"
438
+ stroke="hsl(var(--primary))"
439
+ strokeWidth="1.5"
440
+ />
441
+ <circle
442
+ cx={pending.screenX}
443
+ cy={pending.screenY}
444
+ r="2.5"
445
+ fill="hsl(var(--primary))"
446
+ />
447
+ </svg>
448
+ )}
449
+ </>
450
+ );
451
+ }, (prevProps, nextProps) => {
452
+ // Custom comparison to prevent unnecessary re-renders
453
+ // Return true if props are equal (skip re-render), false if different (re-render)
454
+
455
+ // Compare measurements - check both IDs AND screen coordinates
456
+ if (prevProps.measurements.length !== nextProps.measurements.length) return false;
457
+ for (let i = 0; i < prevProps.measurements.length; i++) {
458
+ const prev = prevProps.measurements[i];
459
+ const next = nextProps.measurements[i];
460
+ if (!next || prev.id !== next.id) return false;
461
+ // Check screen coordinates for zoom/camera changes
462
+ if (prev.start.screenX !== next.start.screenX || prev.start.screenY !== next.start.screenY) return false;
463
+ if (prev.end.screenX !== next.end.screenX || prev.end.screenY !== next.end.screenY) return false;
464
+ }
465
+
466
+ // Compare activeMeasurement - check if it exists and if position changed
467
+ if (!!prevProps.activeMeasurement !== !!nextProps.activeMeasurement) return false;
468
+ if (prevProps.activeMeasurement && nextProps.activeMeasurement) {
469
+ if (
470
+ prevProps.activeMeasurement.current.screenX !== nextProps.activeMeasurement.current.screenX ||
471
+ prevProps.activeMeasurement.current.screenY !== nextProps.activeMeasurement.current.screenY ||
472
+ prevProps.activeMeasurement.start.screenX !== nextProps.activeMeasurement.start.screenX ||
473
+ prevProps.activeMeasurement.start.screenY !== nextProps.activeMeasurement.start.screenY
474
+ ) return false;
475
+ }
476
+
477
+ // Compare snapTarget - check type and position
478
+ if (!!prevProps.snapTarget !== !!nextProps.snapTarget) return false;
479
+ if (prevProps.snapTarget && nextProps.snapTarget) {
480
+ if (
481
+ prevProps.snapTarget.type !== nextProps.snapTarget.type ||
482
+ prevProps.snapTarget.position.x !== nextProps.snapTarget.position.x ||
483
+ prevProps.snapTarget.position.y !== nextProps.snapTarget.position.y ||
484
+ prevProps.snapTarget.position.z !== nextProps.snapTarget.position.z
485
+ ) return false;
486
+ }
487
+
488
+ // Compare snapVisualization
489
+ if (!!prevProps.snapVisualization !== !!nextProps.snapVisualization) return false;
490
+ if (prevProps.snapVisualization && nextProps.snapVisualization) {
491
+ // Compare edgeLine3D (3D world coordinates)
492
+ const prevEdge = prevProps.snapVisualization.edgeLine3D;
493
+ const nextEdge = nextProps.snapVisualization.edgeLine3D;
494
+ if (!!prevEdge !== !!nextEdge) return false;
495
+ if (prevEdge && nextEdge) {
496
+ if (
497
+ prevEdge.v0.x !== nextEdge.v0.x ||
498
+ prevEdge.v0.y !== nextEdge.v0.y ||
499
+ prevEdge.v0.z !== nextEdge.v0.z ||
500
+ prevEdge.v1.x !== nextEdge.v1.x ||
501
+ prevEdge.v1.y !== nextEdge.v1.y ||
502
+ prevEdge.v1.z !== nextEdge.v1.z
503
+ ) return false;
504
+ }
505
+ // Compare slidingDot (t parameter only)
506
+ const prevDot = prevProps.snapVisualization.slidingDot;
507
+ const nextDot = nextProps.snapVisualization.slidingDot;
508
+ if (!!prevDot !== !!nextDot) return false;
509
+ if (prevDot && nextDot) {
510
+ if (prevDot.t !== nextDot.t) return false;
511
+ }
512
+ // Compare cornerRings (atStart + valence)
513
+ const prevCorner = prevProps.snapVisualization.cornerRings;
514
+ const nextCorner = nextProps.snapVisualization.cornerRings;
515
+ if (!!prevCorner !== !!nextCorner) return false;
516
+ if (prevCorner && nextCorner) {
517
+ if (
518
+ prevCorner.atStart !== nextCorner.atStart ||
519
+ prevCorner.valence !== nextCorner.valence
520
+ ) return false;
521
+ }
522
+ const prevPlane = prevProps.snapVisualization.planeIndicator;
523
+ const nextPlane = nextProps.snapVisualization.planeIndicator;
524
+ if (!!prevPlane !== !!nextPlane) return false;
525
+ if (prevPlane && nextPlane) {
526
+ if (
527
+ prevPlane.x !== nextPlane.x ||
528
+ prevPlane.y !== nextPlane.y
529
+ ) return false;
530
+ }
531
+ }
532
+
533
+ // Compare projectToScreen (always re-render if it changes as we need it for projection)
534
+ if (prevProps.projectToScreen !== nextProps.projectToScreen) return false;
535
+
536
+ // Compare hoverPosition
537
+ if (prevProps.hoverPosition?.x !== nextProps.hoverPosition?.x ||
538
+ prevProps.hoverPosition?.y !== nextProps.hoverPosition?.y) return false;
539
+
540
+ // Compare pending
541
+ if (prevProps.pending?.screenX !== nextProps.pending?.screenX ||
542
+ prevProps.pending?.screenY !== nextProps.pending?.screenY) return false;
543
+
544
+ // Compare constraintEdge
545
+ if (!!prevProps.constraintEdge !== !!nextProps.constraintEdge) return false;
546
+ if (prevProps.constraintEdge && nextProps.constraintEdge) {
547
+ if (prevProps.constraintEdge.activeAxis !== nextProps.constraintEdge.activeAxis) return false;
548
+ }
549
+
550
+ return true; // All props are equal, skip re-render
551
+ });
552
+
553
+ interface SnapIndicatorProps {
554
+ screenX: number;
555
+ screenY: number;
556
+ snapType: SnapType;
557
+ }
558
+
559
+ function SnapIndicator({ screenX, screenY, snapType }: SnapIndicatorProps) {
560
+ // Distinct colors for each snap type - no labels needed, shapes are self-explanatory
561
+ const snapColors = {
562
+ [SnapType.VERTEX]: '#FFEB3B', // Yellow - circle = point
563
+ [SnapType.EDGE]: '#FF9800', // Orange - line = edge
564
+ [SnapType.FACE]: '#03A9F4', // Light Blue - square = face
565
+ [SnapType.FACE_CENTER]: '#00BCD4', // Cyan - square with dot = center
566
+ };
567
+
568
+ const color = snapColors[snapType];
569
+
570
+ return (
571
+ <svg
572
+ className="absolute inset-0 pointer-events-none z-25"
573
+ style={{ overflow: 'visible', pointerEvents: 'none' }}
574
+ >
575
+ {/* Outer glow ring - subtle pulsing indicator */}
576
+ <circle
577
+ cx={screenX}
578
+ cy={screenY}
579
+ r="10"
580
+ fill="none"
581
+ stroke={color}
582
+ strokeWidth="1.5"
583
+ strokeOpacity="0.4"
584
+ filter="url(#snap-glow)"
585
+ />
586
+
587
+ {/* Vertex: filled circle (point) */}
588
+ {snapType === SnapType.VERTEX && (
589
+ <>
590
+ <circle cx={screenX} cy={screenY} r="5" fill={color} opacity="0.3" />
591
+ <circle cx={screenX} cy={screenY} r="2.5" fill={color} />
592
+ </>
593
+ )}
594
+
595
+ {/* Edge: horizontal line with center dot */}
596
+ {snapType === SnapType.EDGE && (
597
+ <>
598
+ <line
599
+ x1={screenX - 8}
600
+ y1={screenY}
601
+ x2={screenX + 8}
602
+ y2={screenY}
603
+ stroke={color}
604
+ strokeWidth="2"
605
+ strokeLinecap="round"
606
+ />
607
+ <circle cx={screenX} cy={screenY} r="2" fill={color} />
608
+ </>
609
+ )}
610
+
611
+ {/* Face: square outline */}
612
+ {snapType === SnapType.FACE && (
613
+ <>
614
+ <rect
615
+ x={screenX - 5}
616
+ y={screenY - 5}
617
+ width="10"
618
+ height="10"
619
+ fill={color}
620
+ fillOpacity="0.2"
621
+ stroke={color}
622
+ strokeWidth="1.5"
623
+ />
624
+ </>
625
+ )}
626
+
627
+ {/* Face Center: square with center dot */}
628
+ {snapType === SnapType.FACE_CENTER && (
629
+ <>
630
+ <rect
631
+ x={screenX - 5}
632
+ y={screenY - 5}
633
+ width="10"
634
+ height="10"
635
+ fill="none"
636
+ stroke={color}
637
+ strokeWidth="1.5"
638
+ />
639
+ <circle cx={screenX} cy={screenY} r="2" fill={color} />
640
+ </>
641
+ )}
642
+ </svg>
643
+ );
644
+ }