@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.
- package/CHANGELOG.md +106 -0
- package/dist/assets/{Arrow.dom-Be1tgmo6.js → Arrow.dom-CwcRxist.js} +1 -1
- package/dist/assets/ifc-lite_bg-DyIN_nBM.wasm +0 -0
- package/dist/assets/index-7WoQ-qVC.css +1 -0
- package/dist/assets/{index-D1Du89Pa.js → index-BSANf7-H.js} +44948 -31410
- package/dist/assets/{native-bridge-A6zNnTfi.js → native-bridge-5LbrYh3R.js} +1 -1
- package/dist/assets/{wasm-bridge-DkRhgSvE.js → wasm-bridge-CgpLtj1h.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +18 -15
- package/src/components/viewer/BCFPanel.tsx +7 -789
- package/src/components/viewer/Drawing2DCanvas.tsx +1411 -0
- package/src/components/viewer/DrawingSettingsPanel.tsx +3 -3
- package/src/components/viewer/EntityContextMenu.tsx +47 -20
- package/src/components/viewer/ExportDialog.tsx +166 -17
- package/src/components/viewer/HierarchyPanel.tsx +113 -843
- package/src/components/viewer/IDSExportDialog.tsx +281 -0
- package/src/components/viewer/IDSPanel.tsx +126 -17
- package/src/components/viewer/KeyboardShortcutsDialog.tsx +9 -0
- package/src/components/viewer/LensPanel.tsx +1366 -0
- package/src/components/viewer/MainToolbar.tsx +237 -37
- package/src/components/viewer/PropertiesPanel.tsx +171 -652
- package/src/components/viewer/PropertyEditor.tsx +866 -77
- package/src/components/viewer/Section2DPanel.tsx +329 -2661
- package/src/components/viewer/TextAnnotationEditor.tsx +112 -0
- package/src/components/viewer/ToolOverlays.tsx +3 -1097
- package/src/components/viewer/ViewerLayout.tsx +132 -45
- package/src/components/viewer/Viewport.tsx +290 -1678
- package/src/components/viewer/ViewportContainer.tsx +13 -3
- package/src/components/viewer/bcf/BCFCreateTopicForm.tsx +134 -0
- package/src/components/viewer/bcf/BCFTopicDetail.tsx +388 -0
- package/src/components/viewer/bcf/BCFTopicList.tsx +239 -0
- package/src/components/viewer/bcf/bcfHelpers.tsx +109 -0
- package/src/components/viewer/hierarchy/HierarchyNode.tsx +328 -0
- package/src/components/viewer/hierarchy/treeDataBuilder.ts +464 -0
- package/src/components/viewer/hierarchy/types.ts +54 -0
- package/src/components/viewer/hierarchy/useHierarchyTree.ts +280 -0
- package/src/components/viewer/lists/ListBuilder.tsx +486 -0
- package/src/components/viewer/lists/ListPanel.tsx +540 -0
- package/src/components/viewer/lists/ListResultsTable.tsx +227 -0
- package/src/components/viewer/properties/ClassificationCard.tsx +70 -0
- package/src/components/viewer/properties/CoordinateDisplay.tsx +49 -0
- package/src/components/viewer/properties/DocumentCard.tsx +89 -0
- package/src/components/viewer/properties/MaterialCard.tsx +201 -0
- package/src/components/viewer/properties/ModelMetadataPanel.tsx +335 -0
- package/src/components/viewer/properties/PropertySetCard.tsx +132 -0
- package/src/components/viewer/properties/QuantitySetCard.tsx +79 -0
- package/src/components/viewer/properties/RelationshipsCard.tsx +100 -0
- package/src/components/viewer/properties/encodingUtils.ts +29 -0
- package/src/components/viewer/tools/MeasurePanel.tsx +218 -0
- package/src/components/viewer/tools/MeasurementVisuals.tsx +644 -0
- package/src/components/viewer/tools/SectionPanel.tsx +183 -0
- package/src/components/viewer/tools/SectionVisualization.tsx +78 -0
- package/src/components/viewer/tools/cloudPathGenerator.test.ts +118 -0
- package/src/components/viewer/tools/cloudPathGenerator.ts +275 -0
- package/src/components/viewer/tools/computePolygonArea.test.ts +165 -0
- package/src/components/viewer/tools/computePolygonArea.ts +72 -0
- package/src/components/viewer/tools/formatDistance.ts +18 -0
- package/src/components/viewer/tools/sectionConstants.ts +14 -0
- package/src/components/viewer/useAnimationLoop.ts +166 -0
- package/src/components/viewer/useGeometryStreaming.ts +406 -0
- package/src/components/viewer/useKeyboardControls.ts +221 -0
- package/src/components/viewer/useMouseControls.ts +1009 -0
- package/src/components/viewer/useRenderUpdates.ts +165 -0
- package/src/components/viewer/useTouchControls.ts +245 -0
- package/src/hooks/ids/idsColorSystem.ts +125 -0
- package/src/hooks/ids/idsDataAccessor.ts +237 -0
- package/src/hooks/ids/idsExportService.ts +444 -0
- package/src/hooks/useAnnotation2D.ts +551 -0
- package/src/hooks/useBCF.ts +7 -0
- package/src/hooks/useDrawingExport.ts +709 -0
- package/src/hooks/useDrawingGeneration.ts +627 -0
- package/src/hooks/useFloorplanView.ts +108 -0
- package/src/hooks/useIDS.ts +270 -463
- package/src/hooks/useIfc.ts +26 -1628
- package/src/hooks/useIfcFederation.ts +803 -0
- package/src/hooks/useIfcLoader.ts +508 -0
- package/src/hooks/useIfcServer.ts +465 -0
- package/src/hooks/useKeyboardShortcuts.ts +114 -15
- package/src/hooks/useLens.ts +113 -0
- package/src/hooks/useLensDiscovery.ts +46 -0
- package/src/hooks/useMeasure2D.ts +365 -0
- package/src/hooks/useModelSelection.ts +5 -22
- package/src/hooks/useViewControls.ts +218 -0
- package/src/index.css +7 -1
- package/src/lib/ifc4-pset-definitions.test.ts +161 -0
- package/src/lib/ifc4-pset-definitions.ts +621 -0
- package/src/lib/ifc4-qto-definitions.ts +315 -0
- package/src/lib/lens/adapter.ts +264 -0
- package/src/lib/lens/index.ts +5 -0
- package/src/lib/lists/adapter.ts +69 -0
- package/src/lib/lists/columnToAutoColor.ts +33 -0
- package/src/lib/lists/index.ts +28 -0
- package/src/lib/lists/persistence.ts +64 -0
- package/src/services/fs-cache.ts +1 -1
- package/src/services/tauri-modules.d.ts +25 -0
- package/src/store/index.ts +52 -3
- package/src/store/resolveEntityRef.ts +44 -0
- package/src/store/slices/cameraSlice.ts +14 -1
- package/src/store/slices/dataSlice.ts +14 -1
- package/src/store/slices/drawing2DSlice.ts +321 -0
- package/src/store/slices/lensSlice.ts +226 -0
- package/src/store/slices/listSlice.ts +74 -0
- package/src/store/slices/pinboardSlice.ts +247 -0
- package/src/store/types.ts +5 -0
- package/src/store.ts +3 -0
- package/src/utils/ifcConfig.ts +16 -3
- package/src/utils/serverDataModel.ts +64 -101
- package/src/vite-env.d.ts +3 -0
- package/dist/assets/ifc-lite_bg-C6kblxf9.wasm +0 -0
- package/dist/assets/index-v3mcCUPN.css +0 -1
|
@@ -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
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
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
|
}
|