@ifc-lite/viewer 1.6.1 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/dist/assets/{Arrow.dom-Be1tgmo6.js → Arrow.dom-CwcRxist.js} +1 -1
  3. package/dist/assets/ifc-lite_bg-DyIN_nBM.wasm +0 -0
  4. package/dist/assets/index-7WoQ-qVC.css +1 -0
  5. package/dist/assets/{index-D1Du89Pa.js → index-BSANf7-H.js} +44948 -31410
  6. package/dist/assets/{native-bridge-A6zNnTfi.js → native-bridge-5LbrYh3R.js} +1 -1
  7. package/dist/assets/{wasm-bridge-DkRhgSvE.js → wasm-bridge-CgpLtj1h.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 +1411 -0
  12. package/src/components/viewer/DrawingSettingsPanel.tsx +3 -3
  13. package/src/components/viewer/EntityContextMenu.tsx +47 -20
  14. package/src/components/viewer/ExportDialog.tsx +166 -17
  15. package/src/components/viewer/HierarchyPanel.tsx +113 -843
  16. package/src/components/viewer/IDSExportDialog.tsx +281 -0
  17. package/src/components/viewer/IDSPanel.tsx +126 -17
  18. package/src/components/viewer/KeyboardShortcutsDialog.tsx +9 -0
  19. package/src/components/viewer/LensPanel.tsx +1366 -0
  20. package/src/components/viewer/MainToolbar.tsx +237 -37
  21. package/src/components/viewer/PropertiesPanel.tsx +171 -652
  22. package/src/components/viewer/PropertyEditor.tsx +866 -77
  23. package/src/components/viewer/Section2DPanel.tsx +329 -2661
  24. package/src/components/viewer/TextAnnotationEditor.tsx +112 -0
  25. package/src/components/viewer/ToolOverlays.tsx +3 -1097
  26. package/src/components/viewer/ViewerLayout.tsx +132 -45
  27. package/src/components/viewer/Viewport.tsx +290 -1678
  28. package/src/components/viewer/ViewportContainer.tsx +13 -3
  29. package/src/components/viewer/bcf/BCFCreateTopicForm.tsx +134 -0
  30. package/src/components/viewer/bcf/BCFTopicDetail.tsx +388 -0
  31. package/src/components/viewer/bcf/BCFTopicList.tsx +239 -0
  32. package/src/components/viewer/bcf/bcfHelpers.tsx +109 -0
  33. package/src/components/viewer/hierarchy/HierarchyNode.tsx +328 -0
  34. package/src/components/viewer/hierarchy/treeDataBuilder.ts +464 -0
  35. package/src/components/viewer/hierarchy/types.ts +54 -0
  36. package/src/components/viewer/hierarchy/useHierarchyTree.ts +280 -0
  37. package/src/components/viewer/lists/ListBuilder.tsx +486 -0
  38. package/src/components/viewer/lists/ListPanel.tsx +540 -0
  39. package/src/components/viewer/lists/ListResultsTable.tsx +227 -0
  40. package/src/components/viewer/properties/ClassificationCard.tsx +70 -0
  41. package/src/components/viewer/properties/CoordinateDisplay.tsx +49 -0
  42. package/src/components/viewer/properties/DocumentCard.tsx +89 -0
  43. package/src/components/viewer/properties/MaterialCard.tsx +201 -0
  44. package/src/components/viewer/properties/ModelMetadataPanel.tsx +335 -0
  45. package/src/components/viewer/properties/PropertySetCard.tsx +132 -0
  46. package/src/components/viewer/properties/QuantitySetCard.tsx +79 -0
  47. package/src/components/viewer/properties/RelationshipsCard.tsx +100 -0
  48. package/src/components/viewer/properties/encodingUtils.ts +29 -0
  49. package/src/components/viewer/tools/MeasurePanel.tsx +218 -0
  50. package/src/components/viewer/tools/MeasurementVisuals.tsx +644 -0
  51. package/src/components/viewer/tools/SectionPanel.tsx +183 -0
  52. package/src/components/viewer/tools/SectionVisualization.tsx +78 -0
  53. package/src/components/viewer/tools/cloudPathGenerator.test.ts +118 -0
  54. package/src/components/viewer/tools/cloudPathGenerator.ts +275 -0
  55. package/src/components/viewer/tools/computePolygonArea.test.ts +165 -0
  56. package/src/components/viewer/tools/computePolygonArea.ts +72 -0
  57. package/src/components/viewer/tools/formatDistance.ts +18 -0
  58. package/src/components/viewer/tools/sectionConstants.ts +14 -0
  59. package/src/components/viewer/useAnimationLoop.ts +166 -0
  60. package/src/components/viewer/useGeometryStreaming.ts +406 -0
  61. package/src/components/viewer/useKeyboardControls.ts +221 -0
  62. package/src/components/viewer/useMouseControls.ts +1009 -0
  63. package/src/components/viewer/useRenderUpdates.ts +165 -0
  64. package/src/components/viewer/useTouchControls.ts +245 -0
  65. package/src/hooks/ids/idsColorSystem.ts +125 -0
  66. package/src/hooks/ids/idsDataAccessor.ts +237 -0
  67. package/src/hooks/ids/idsExportService.ts +444 -0
  68. package/src/hooks/useAnnotation2D.ts +551 -0
  69. package/src/hooks/useBCF.ts +7 -0
  70. package/src/hooks/useDrawingExport.ts +709 -0
  71. package/src/hooks/useDrawingGeneration.ts +627 -0
  72. package/src/hooks/useFloorplanView.ts +108 -0
  73. package/src/hooks/useIDS.ts +270 -463
  74. package/src/hooks/useIfc.ts +26 -1628
  75. package/src/hooks/useIfcFederation.ts +803 -0
  76. package/src/hooks/useIfcLoader.ts +508 -0
  77. package/src/hooks/useIfcServer.ts +465 -0
  78. package/src/hooks/useKeyboardShortcuts.ts +114 -15
  79. package/src/hooks/useLens.ts +113 -0
  80. package/src/hooks/useLensDiscovery.ts +46 -0
  81. package/src/hooks/useMeasure2D.ts +365 -0
  82. package/src/hooks/useModelSelection.ts +5 -22
  83. package/src/hooks/useViewControls.ts +218 -0
  84. package/src/index.css +7 -1
  85. package/src/lib/ifc4-pset-definitions.test.ts +161 -0
  86. package/src/lib/ifc4-pset-definitions.ts +621 -0
  87. package/src/lib/ifc4-qto-definitions.ts +315 -0
  88. package/src/lib/lens/adapter.ts +264 -0
  89. package/src/lib/lens/index.ts +5 -0
  90. package/src/lib/lists/adapter.ts +69 -0
  91. package/src/lib/lists/columnToAutoColor.ts +33 -0
  92. package/src/lib/lists/index.ts +28 -0
  93. package/src/lib/lists/persistence.ts +64 -0
  94. package/src/services/fs-cache.ts +1 -1
  95. package/src/services/tauri-modules.d.ts +25 -0
  96. package/src/store/index.ts +52 -3
  97. package/src/store/resolveEntityRef.ts +44 -0
  98. package/src/store/slices/cameraSlice.ts +14 -1
  99. package/src/store/slices/dataSlice.ts +14 -1
  100. package/src/store/slices/drawing2DSlice.ts +321 -0
  101. package/src/store/slices/lensSlice.ts +226 -0
  102. package/src/store/slices/listSlice.ts +74 -0
  103. package/src/store/slices/pinboardSlice.ts +247 -0
  104. package/src/store/types.ts +5 -0
  105. package/src/store.ts +3 -0
  106. package/src/utils/ifcConfig.ts +16 -3
  107. package/src/utils/serverDataModel.ts +64 -101
  108. package/src/vite-env.d.ts +3 -0
  109. package/dist/assets/ifc-lite_bg-C6kblxf9.wasm +0 -0
  110. package/dist/assets/index-v3mcCUPN.css +0 -1
@@ -0,0 +1,113 @@
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
+ * Lens evaluation hook
7
+ *
8
+ * Evaluates active lens rules against all entities across all models,
9
+ * producing a color map and hidden IDs set that are applied to the renderer.
10
+ * Unmatched entities with geometry are ghosted (semi-transparent).
11
+ *
12
+ * The pure evaluation logic lives in @ifc-lite/lens — this hook handles
13
+ * React lifecycle and Zustand integration.
14
+ *
15
+ * Performance notes:
16
+ * - Does NOT subscribe to `models` or `ifcDataStore` — reads them from
17
+ * getState() only when the active lens changes. This prevents re-evaluation
18
+ * during model loading.
19
+ * - Uses color overlay system: pendingColorUpdates triggers
20
+ * scene.setColorOverrides() which builds overlay batches rendered on top
21
+ * of original geometry. Original batches are NEVER modified — clearing
22
+ * lens is instant (no batch rebuild).
23
+ */
24
+
25
+ import { useEffect, useRef, useMemo } from 'react';
26
+ import { evaluateLens, evaluateAutoColorLens, rgbaToHex, isGhostColor } from '@ifc-lite/lens';
27
+ import { useViewerStore } from '@/store';
28
+ import { createLensDataProvider } from '@/lib/lens';
29
+ import { useLensDiscovery } from './useLensDiscovery';
30
+
31
+ export function useLens() {
32
+ const activeLensId = useViewerStore((s) => s.activeLensId);
33
+ const savedLenses = useViewerStore((s) => s.savedLenses);
34
+
35
+ // Derive the active lens object — only re-evaluates when activeLensId or
36
+ // the active lens entry itself changes, not when unrelated lenses are edited.
37
+ const activeLens = useMemo(
38
+ () => savedLenses.find(l => l.id === activeLensId) ?? null,
39
+ [activeLensId, savedLenses],
40
+ );
41
+
42
+ // Run data discovery when models change (populates discoveredLensData in store)
43
+ useLensDiscovery();
44
+
45
+ // Track the previously active lens to detect deactivation
46
+ const prevLensIdRef = useRef<string | null>(null);
47
+
48
+ useEffect(() => {
49
+
50
+ // Lens deactivated — clear overlay (instant, no batch rebuild)
51
+ if (!activeLens && prevLensIdRef.current !== null) {
52
+ prevLensIdRef.current = null;
53
+ useViewerStore.getState().setLensColorMap(new Map());
54
+ useViewerStore.getState().setLensHiddenIds(new Set());
55
+ useViewerStore.getState().setLensRuleCounts(new Map());
56
+ useViewerStore.getState().setLensRuleEntityIds(new Map());
57
+ useViewerStore.getState().setLensAutoColorLegend([]);
58
+
59
+ // Send empty map to signal "clear overlays" to useGeometryStreaming
60
+ useViewerStore.getState().setPendingColorUpdates(new Map());
61
+ return;
62
+ }
63
+
64
+ if (!activeLens) return;
65
+
66
+ // Read data sources from getState() — NOT subscribed, so model loading
67
+ // doesn't trigger re-evaluation
68
+ const { models, ifcDataStore } = useViewerStore.getState();
69
+ if (models.size === 0 && !ifcDataStore) return;
70
+
71
+ prevLensIdRef.current = activeLensId;
72
+
73
+ // Create data provider and evaluate lens using @ifc-lite/lens package
74
+ const provider = createLensDataProvider(models, ifcDataStore);
75
+
76
+ // Dispatch: auto-color mode vs. rule-based mode
77
+ const isAutoColor = !!activeLens.autoColor;
78
+ const result = isAutoColor
79
+ ? evaluateAutoColorLens(activeLens.autoColor!, provider)
80
+ : evaluateLens(activeLens, provider);
81
+
82
+ const { colorMap, hiddenIds, ruleCounts, ruleEntityIds } = result;
83
+
84
+ // Build hex color map for UI legend (exclude ghost entries)
85
+ const hexColorMap = new Map<number, string>();
86
+ for (const [id, rgba] of colorMap) {
87
+ if (!isGhostColor(rgba)) {
88
+ hexColorMap.set(id, rgbaToHex(rgba));
89
+ }
90
+ }
91
+ useViewerStore.getState().setLensColorMap(hexColorMap);
92
+ useViewerStore.getState().setLensHiddenIds(hiddenIds);
93
+ useViewerStore.getState().setLensRuleCounts(ruleCounts);
94
+ useViewerStore.getState().setLensRuleEntityIds(ruleEntityIds);
95
+
96
+ // Store auto-color legend entries for UI display
97
+ if (isAutoColor && 'legend' in result) {
98
+ useViewerStore.getState().setLensAutoColorLegend(result.legend);
99
+ } else {
100
+ useViewerStore.getState().setLensAutoColorLegend([]);
101
+ }
102
+
103
+ // Apply colors via overlay system — original batches are never modified
104
+ if (colorMap.size > 0) {
105
+ useViewerStore.getState().setPendingColorUpdates(colorMap);
106
+ }
107
+ }, [activeLensId, activeLens]);
108
+
109
+ return {
110
+ activeLensId,
111
+ savedLenses,
112
+ };
113
+ }
@@ -0,0 +1,46 @@
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
+ * Lens class discovery hook — INSTANT, zero loading impact.
7
+ *
8
+ * Only discovers IFC class names from the entity table (O(n) array scan,
9
+ * no STEP buffer parsing). Property sets, quantities, materials, and
10
+ * classifications are discovered lazily on-demand when the user opens
11
+ * a dropdown that needs them — see `useLazyDiscovery` in LensPanel.
12
+ */
13
+
14
+ import { useEffect } from 'react';
15
+ import { discoverClasses } from '@ifc-lite/lens';
16
+ import { useViewerStore } from '@/store';
17
+ import { createLensDataProvider } from '@/lib/lens';
18
+
19
+ /**
20
+ * Discover IFC classes when models change (instant).
21
+ * Stores result in `discoveredLensData.classes` on the lens slice.
22
+ */
23
+ export function useLensDiscovery(): void {
24
+ const modelCount = useViewerStore((s) => s.models.size);
25
+ const ifcDataStore = useViewerStore((s) => s.ifcDataStore);
26
+ const setDiscoveredLensData = useViewerStore((s) => s.setDiscoveredLensData);
27
+
28
+ useEffect(() => {
29
+ const { models, ifcDataStore: ds } = useViewerStore.getState();
30
+ if (models.size === 0 && !ds) {
31
+ setDiscoveredLensData(null);
32
+ return;
33
+ }
34
+
35
+ // Instant: just reads type names from entity arrays, no STEP parsing
36
+ const provider = createLensDataProvider(models, ds);
37
+ const classes = discoverClasses(provider);
38
+ setDiscoveredLensData({
39
+ classes,
40
+ propertySets: null, // lazy — discovered on-demand
41
+ quantitySets: null, // lazy — discovered on-demand
42
+ classificationSystems: null, // lazy — discovered on-demand
43
+ materials: null, // lazy — discovered on-demand
44
+ });
45
+ }, [modelCount, ifcDataStore, setDiscoveredLensData]);
46
+ }
@@ -0,0 +1,365 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * Hook for 2D measurement tool logic
7
+ * Extracts pan/measure mouse handling, snapping, orthogonal constraints,
8
+ * and keyboard/global-mouseup effects from Section2DPanel.
9
+ */
10
+
11
+ import { useCallback, useEffect, useRef } from 'react';
12
+ import type { Drawing2D } from '@ifc-lite/drawing-2d';
13
+
14
+ // ─── Public interfaces ──────────────────────────────────────────────────────
15
+
16
+ export interface UseMeasure2DParams {
17
+ drawing: Drawing2D | null;
18
+ viewTransform: { x: number; y: number; scale: number };
19
+ setViewTransform: React.Dispatch<React.SetStateAction<{ x: number; y: number; scale: number }>>;
20
+ sectionAxis: 'down' | 'front' | 'side';
21
+ containerRef: React.RefObject<HTMLDivElement | null>;
22
+ // Store state
23
+ measure2DMode: boolean;
24
+ measure2DStart: { x: number; y: number } | null;
25
+ measure2DCurrent: { x: number; y: number } | null;
26
+ measure2DShiftLocked: boolean;
27
+ measure2DLockedAxis: 'x' | 'y' | null;
28
+ setMeasure2DStart: (pt: { x: number; y: number }) => void;
29
+ setMeasure2DCurrent: (pt: { x: number; y: number }) => void;
30
+ setMeasure2DShiftLocked: (locked: boolean, axis?: 'x' | 'y') => void;
31
+ setMeasure2DSnapPoint: (pt: { x: number; y: number } | null) => void;
32
+ cancelMeasure2D: () => void;
33
+ completeMeasure2D: () => void;
34
+ }
35
+
36
+ export interface UseMeasure2DResult {
37
+ handleMouseDown: (e: React.MouseEvent) => void;
38
+ handleMouseMove: (e: React.MouseEvent) => void;
39
+ handleMouseUp: () => void;
40
+ handleMouseLeave: () => void;
41
+ handleMouseEnter: (e: React.MouseEvent) => void;
42
+ }
43
+
44
+ // ─── Hook implementation ────────────────────────────────────────────────────
45
+
46
+ export function useMeasure2D({
47
+ drawing,
48
+ viewTransform,
49
+ setViewTransform,
50
+ sectionAxis,
51
+ containerRef,
52
+ measure2DMode,
53
+ measure2DStart,
54
+ measure2DCurrent,
55
+ measure2DShiftLocked,
56
+ measure2DLockedAxis,
57
+ setMeasure2DStart,
58
+ setMeasure2DCurrent,
59
+ setMeasure2DShiftLocked,
60
+ setMeasure2DSnapPoint,
61
+ cancelMeasure2D,
62
+ completeMeasure2D,
63
+ }: UseMeasure2DParams): UseMeasure2DResult {
64
+ // ── Internal refs ───────────────────────────────────────────────────────
65
+ const isPanning = useRef(false);
66
+ const lastPanPoint = useRef({ x: 0, y: 0 });
67
+ const isMouseButtonDown = useRef(false);
68
+ const isMouseInsidePanel = useRef(true);
69
+
70
+ // ═══════════════════════════════════════════════════════════════════════
71
+ // 2D MEASURE TOOL HELPER FUNCTIONS
72
+ // ═══════════════════════════════════════════════════════════════════════
73
+
74
+ // Convert screen coordinates to drawing coordinates
75
+ const screenToDrawing = useCallback((screenX: number, screenY: number): { x: number; y: number } => {
76
+ // Screen coord → drawing coord
77
+ // Apply axis-specific inverse transforms (matching canvas rendering)
78
+ const currentAxis = sectionAxis;
79
+ const flipY = currentAxis !== 'down'; // Only flip Y for front/side views
80
+ const flipX = currentAxis === 'side'; // Flip X for side view
81
+
82
+ // Inverse of: screenX = drawingX * scaleX + transform.x
83
+ // where scaleX = flipX ? -scale : scale
84
+ const scaleX = flipX ? -viewTransform.scale : viewTransform.scale;
85
+ const scaleY = flipY ? -viewTransform.scale : viewTransform.scale;
86
+
87
+ const x = (screenX - viewTransform.x) / scaleX;
88
+ const y = (screenY - viewTransform.y) / scaleY;
89
+ return { x, y };
90
+ }, [viewTransform, sectionAxis]);
91
+
92
+ // Find nearest point on a line segment
93
+ const nearestPointOnSegment = useCallback((
94
+ p: { x: number; y: number },
95
+ a: { x: number; y: number },
96
+ b: { x: number; y: number }
97
+ ): { point: { x: number; y: number }; dist: number } => {
98
+ const dx = b.x - a.x;
99
+ const dy = b.y - a.y;
100
+ const lenSq = dx * dx + dy * dy;
101
+
102
+ if (lenSq < 0.0001) {
103
+ // Degenerate segment
104
+ const d = Math.sqrt((p.x - a.x) ** 2 + (p.y - a.y) ** 2);
105
+ return { point: a, dist: d };
106
+ }
107
+
108
+ // Parameter t along segment [0,1]
109
+ let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq;
110
+ t = Math.max(0, Math.min(1, t));
111
+
112
+ const nearest = { x: a.x + t * dx, y: a.y + t * dy };
113
+ const dist = Math.sqrt((p.x - nearest.x) ** 2 + (p.y - nearest.y) ** 2);
114
+
115
+ return { point: nearest, dist };
116
+ }, []);
117
+
118
+ // Find snap point near cursor (check polygon vertices, edges, and line endpoints)
119
+ const findSnapPoint = useCallback((drawingCoord: { x: number; y: number }): { x: number; y: number } | null => {
120
+ if (!drawing) return null;
121
+
122
+ const snapThreshold = 10 / viewTransform.scale; // 10 screen pixels
123
+ let bestSnap: { x: number; y: number } | null = null;
124
+ let bestDist = snapThreshold;
125
+
126
+ // Priority 1: Check polygon vertices (endpoints are highest priority)
127
+ for (const polygon of drawing.cutPolygons) {
128
+ for (const pt of polygon.polygon.outer) {
129
+ const dist = Math.sqrt((pt.x - drawingCoord.x) ** 2 + (pt.y - drawingCoord.y) ** 2);
130
+ if (dist < bestDist * 0.7) { // Vertices get priority (70% threshold)
131
+ return { x: pt.x, y: pt.y }; // Return immediately for vertex snaps
132
+ }
133
+ }
134
+ for (const hole of polygon.polygon.holes) {
135
+ for (const pt of hole) {
136
+ const dist = Math.sqrt((pt.x - drawingCoord.x) ** 2 + (pt.y - drawingCoord.y) ** 2);
137
+ if (dist < bestDist * 0.7) {
138
+ return { x: pt.x, y: pt.y };
139
+ }
140
+ }
141
+ }
142
+ }
143
+
144
+ // Priority 2: Check line endpoints
145
+ for (const line of drawing.lines) {
146
+ const { start, end } = line.line;
147
+ for (const pt of [start, end]) {
148
+ const dist = Math.sqrt((pt.x - drawingCoord.x) ** 2 + (pt.y - drawingCoord.y) ** 2);
149
+ if (dist < bestDist * 0.7) {
150
+ return { x: pt.x, y: pt.y };
151
+ }
152
+ }
153
+ }
154
+
155
+ // Priority 3: Check polygon edges
156
+ for (const polygon of drawing.cutPolygons) {
157
+ const outer = polygon.polygon.outer;
158
+ for (let i = 0; i < outer.length; i++) {
159
+ const a = outer[i];
160
+ const b = outer[(i + 1) % outer.length];
161
+ const { point, dist } = nearestPointOnSegment(drawingCoord, a, b);
162
+ if (dist < bestDist) {
163
+ bestDist = dist;
164
+ bestSnap = point;
165
+ }
166
+ }
167
+ for (const hole of polygon.polygon.holes) {
168
+ for (let i = 0; i < hole.length; i++) {
169
+ const a = hole[i];
170
+ const b = hole[(i + 1) % hole.length];
171
+ const { point, dist } = nearestPointOnSegment(drawingCoord, a, b);
172
+ if (dist < bestDist) {
173
+ bestDist = dist;
174
+ bestSnap = point;
175
+ }
176
+ }
177
+ }
178
+ }
179
+
180
+ // Priority 4: Check drawing lines
181
+ for (const line of drawing.lines) {
182
+ const { start, end } = line.line;
183
+ const { point, dist } = nearestPointOnSegment(drawingCoord, start, end);
184
+ if (dist < bestDist) {
185
+ bestDist = dist;
186
+ bestSnap = point;
187
+ }
188
+ }
189
+
190
+ return bestSnap;
191
+ }, [drawing, viewTransform.scale, nearestPointOnSegment]);
192
+
193
+ // Apply orthogonal constraint if shift is held
194
+ const applyOrthogonalConstraint = useCallback((start: { x: number; y: number }, current: { x: number; y: number }, lockedAxis: 'x' | 'y' | null): { x: number; y: number } => {
195
+ if (!lockedAxis) return current;
196
+
197
+ if (lockedAxis === 'x') {
198
+ return { x: current.x, y: start.y };
199
+ } else {
200
+ return { x: start.x, y: current.y };
201
+ }
202
+ }, []);
203
+
204
+ // ═══════════════════════════════════════════════════════════════════════
205
+ // EFFECTS
206
+ // ═══════════════════════════════════════════════════════════════════════
207
+
208
+ // Keyboard handlers for shift key (orthogonal constraint)
209
+ useEffect(() => {
210
+ if (!measure2DMode) return;
211
+
212
+ const handleKeyDown = (e: KeyboardEvent) => {
213
+ if (e.key === 'Shift' && measure2DStart && measure2DCurrent && !measure2DShiftLocked) {
214
+ // Determine axis based on dominant direction
215
+ const dx = Math.abs(measure2DCurrent.x - measure2DStart.x);
216
+ const dy = Math.abs(measure2DCurrent.y - measure2DStart.y);
217
+ const axis = dx > dy ? 'x' : 'y';
218
+ setMeasure2DShiftLocked(true, axis);
219
+ }
220
+ if (e.key === 'Escape') {
221
+ cancelMeasure2D();
222
+ }
223
+ };
224
+
225
+ const handleKeyUp = (e: KeyboardEvent) => {
226
+ if (e.key === 'Shift') {
227
+ setMeasure2DShiftLocked(false);
228
+ }
229
+ };
230
+
231
+ window.addEventListener('keydown', handleKeyDown);
232
+ window.addEventListener('keyup', handleKeyUp);
233
+
234
+ return () => {
235
+ window.removeEventListener('keydown', handleKeyDown);
236
+ window.removeEventListener('keyup', handleKeyUp);
237
+ };
238
+ }, [measure2DMode, measure2DStart, measure2DCurrent, measure2DShiftLocked, setMeasure2DShiftLocked, cancelMeasure2D]);
239
+
240
+ // Global mouseup handler to cancel measurement if released outside panel
241
+ useEffect(() => {
242
+ if (!measure2DMode) return;
243
+
244
+ const handleGlobalMouseUp = (e: MouseEvent) => {
245
+ // If mouse button is released and we're outside the panel with a measurement started, cancel it
246
+ if (!isMouseInsidePanel.current && measure2DStart && e.button === 0) {
247
+ cancelMeasure2D();
248
+ }
249
+ isMouseButtonDown.current = false;
250
+ };
251
+
252
+ window.addEventListener('mouseup', handleGlobalMouseUp);
253
+ return () => {
254
+ window.removeEventListener('mouseup', handleGlobalMouseUp);
255
+ };
256
+ }, [measure2DMode, measure2DStart, cancelMeasure2D]);
257
+
258
+ // ═══════════════════════════════════════════════════════════════════════
259
+ // PAN / MEASURE HANDLERS
260
+ // ═══════════════════════════════════════════════════════════════════════
261
+
262
+ const handleMouseDown = useCallback((e: React.MouseEvent) => {
263
+ if (e.button !== 0) return;
264
+
265
+ isMouseButtonDown.current = true;
266
+ const rect = containerRef.current?.getBoundingClientRect();
267
+ if (!rect) return;
268
+
269
+ const screenX = e.clientX - rect.left;
270
+ const screenY = e.clientY - rect.top;
271
+
272
+ if (measure2DMode) {
273
+ // Measure mode: set start point
274
+ const drawingCoord = screenToDrawing(screenX, screenY);
275
+ const snapPoint = findSnapPoint(drawingCoord);
276
+ const startPoint = snapPoint || drawingCoord;
277
+ setMeasure2DStart(startPoint);
278
+ setMeasure2DCurrent(startPoint);
279
+ } else {
280
+ // Pan mode
281
+ isPanning.current = true;
282
+ lastPanPoint.current = { x: e.clientX, y: e.clientY };
283
+ }
284
+ }, [measure2DMode, screenToDrawing, findSnapPoint, setMeasure2DStart, setMeasure2DCurrent]);
285
+
286
+ const handleMouseMove = useCallback((e: React.MouseEvent) => {
287
+ const rect = containerRef.current?.getBoundingClientRect();
288
+ if (!rect) return;
289
+
290
+ const screenX = e.clientX - rect.left;
291
+ const screenY = e.clientY - rect.top;
292
+
293
+ if (measure2DMode) {
294
+ const drawingCoord = screenToDrawing(screenX, screenY);
295
+
296
+ // Find snap point and update
297
+ const snapPoint = findSnapPoint(drawingCoord);
298
+ setMeasure2DSnapPoint(snapPoint);
299
+
300
+ if (measure2DStart) {
301
+ // If measuring, update current point
302
+ let currentPoint = snapPoint || drawingCoord;
303
+
304
+ // Apply orthogonal constraint if shift is held
305
+ if (measure2DShiftLocked && measure2DLockedAxis) {
306
+ currentPoint = applyOrthogonalConstraint(measure2DStart, currentPoint, measure2DLockedAxis);
307
+ }
308
+
309
+ setMeasure2DCurrent(currentPoint);
310
+ }
311
+ } else if (isPanning.current) {
312
+ // Pan mode
313
+ const dx = e.clientX - lastPanPoint.current.x;
314
+ const dy = e.clientY - lastPanPoint.current.y;
315
+ lastPanPoint.current = { x: e.clientX, y: e.clientY };
316
+ setViewTransform((prev) => ({
317
+ ...prev,
318
+ x: prev.x + dx,
319
+ y: prev.y + dy,
320
+ }));
321
+ }
322
+ }, [measure2DMode, measure2DStart, measure2DShiftLocked, measure2DLockedAxis, screenToDrawing, findSnapPoint, setMeasure2DSnapPoint, setMeasure2DCurrent, applyOrthogonalConstraint]);
323
+
324
+ const handleMouseUp = useCallback(() => {
325
+ isMouseButtonDown.current = false;
326
+ if (measure2DMode && measure2DStart && measure2DCurrent) {
327
+ // Complete the measurement
328
+ completeMeasure2D();
329
+ }
330
+ isPanning.current = false;
331
+ }, [measure2DMode, measure2DStart, measure2DCurrent, completeMeasure2D]);
332
+
333
+ const handleMouseLeave = useCallback(() => {
334
+ isMouseInsidePanel.current = false;
335
+ // Don't cancel if button is still down - user might re-enter
336
+ // Cancel will happen on global mouseup if released outside
337
+ isPanning.current = false;
338
+ }, []);
339
+
340
+ const handleMouseEnter = useCallback((e: React.MouseEvent) => {
341
+ isMouseInsidePanel.current = true;
342
+ // If re-entering with button down and measurement started, resume tracking
343
+ if (isMouseButtonDown.current && measure2DMode && measure2DStart) {
344
+ const rect = containerRef.current?.getBoundingClientRect();
345
+ if (rect) {
346
+ const screenX = e.clientX - rect.left;
347
+ const screenY = e.clientY - rect.top;
348
+ const drawingCoord = screenToDrawing(screenX, screenY);
349
+ const snapPoint = findSnapPoint(drawingCoord);
350
+ const currentPoint = snapPoint || drawingCoord;
351
+ setMeasure2DCurrent(currentPoint);
352
+ }
353
+ }
354
+ }, [measure2DMode, measure2DStart, screenToDrawing, findSnapPoint, setMeasure2DCurrent]);
355
+
356
+ return {
357
+ handleMouseDown,
358
+ handleMouseMove,
359
+ handleMouseUp,
360
+ handleMouseLeave,
361
+ handleMouseEnter,
362
+ };
363
+ }
364
+
365
+ export default useMeasure2D;
@@ -20,14 +20,13 @@
20
20
 
21
21
  import { useEffect } from 'react';
22
22
  import { useViewerStore } from '../store.js';
23
+ import { resolveEntityRef } from '../store/resolveEntityRef.js';
23
24
 
24
25
  export function useModelSelection() {
25
26
  const selectedEntityId = useViewerStore((s) => s.selectedEntityId);
26
27
  const setSelectedEntity = useViewerStore((s) => s.setSelectedEntity);
27
28
  // Subscribe to models for reactivity (when models are added/removed)
28
29
  const models = useViewerStore((s) => s.models);
29
- // Use the bulletproof store-based resolver
30
- const resolveGlobalIdFromModels = useViewerStore((s) => s.resolveGlobalIdFromModels);
31
30
 
32
31
  useEffect(() => {
33
32
  if (selectedEntityId === null) {
@@ -38,24 +37,8 @@ export function useModelSelection() {
38
37
  return;
39
38
  }
40
39
 
41
- // selectedEntityId is now a globalId
42
- // Resolve it back to (modelId, originalExpressId) using the store-based resolver
43
- // This is more reliable than the singleton registry which might have bundling issues
44
- const resolved = resolveGlobalIdFromModels(selectedEntityId);
45
- if (resolved) {
46
- // Set EntityRef with ORIGINAL expressId (for property lookup in IfcDataStore)
47
- setSelectedEntity({ modelId: resolved.modelId, expressId: resolved.expressId });
48
- } else {
49
- // Fallback for single-model mode (offset = 0, globalId = expressId)
50
- // In this case, try to find the first model and use the globalId as expressId
51
- if (models.size > 0) {
52
- const firstModelId = Array.from(models.keys())[0];
53
- setSelectedEntity({ modelId: firstModelId, expressId: selectedEntityId });
54
- } else {
55
- // Legacy single-model mode: use 'legacy' as modelId
56
- // This allows PropertiesPanel to fall back to the legacy query
57
- setSelectedEntity({ modelId: 'legacy', expressId: selectedEntityId });
58
- }
59
- }
60
- }, [selectedEntityId, setSelectedEntity, models, resolveGlobalIdFromModels]);
40
+ // Single source of truth: resolveEntityRef handles globalId → EntityRef
41
+ // including fallback for single-model mode (offset 0). Always returns an EntityRef.
42
+ setSelectedEntity(resolveEntityRef(selectedEntityId));
43
+ }, [selectedEntityId, setSelectedEntity, models]);
61
44
  }