@ifc-lite/viewer 1.16.0 → 1.17.1
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/.turbo/turbo-build.log +42 -0
- package/.turbo/turbo-typecheck.log +44 -0
- package/CHANGELOG.md +25 -0
- package/dist/assets/{Arrow.dom--gdrQd-q.js → Arrow.dom-DuPUrOxJ.js} +1 -1
- package/dist/assets/{basketViewActivator-CI3y6VYQ.js → basketViewActivator-DetjPnvt.js} +1 -1
- package/dist/assets/{browser-vWDubxDI.js → browser-BQdwnOUt.js} +1 -1
- package/dist/assets/geometry.worker-Bjm-ukng.js +1 -0
- package/dist/assets/ifc-lite_bg-DD0A7Yow.wasm +0 -0
- package/dist/assets/{index-RXIK18da.js → index-B3X21yXA.js} +4 -4
- package/dist/assets/index-Ba4eoTe7.css +1 -0
- package/dist/assets/{index-BImINgzG.js → index-BybGZJTW.js} +29281 -27174
- package/dist/assets/{native-bridge-4rLidc3f.js → native-bridge-CN0ZMR2t.js} +1 -1
- package/dist/assets/{wasm-bridge-BkfXfw8O.js → wasm-bridge-D0bALkma.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +14 -13
- package/src/components/viewer/BCFPanel.tsx +12 -0
- package/src/components/viewer/BulkPropertyEditor.tsx +315 -154
- package/src/components/viewer/CommandPalette.tsx +0 -6
- package/src/components/viewer/DataConnector.tsx +489 -284
- package/src/components/viewer/ExportDialog.tsx +66 -6
- package/src/components/viewer/KeyboardShortcutsDialog.tsx +44 -1
- package/src/components/viewer/MainToolbar.tsx +1 -5
- package/src/components/viewer/PropertiesPanel.tsx +6 -7
- package/src/components/viewer/Viewport.tsx +42 -56
- package/src/components/viewer/ViewportContainer.tsx +3 -0
- package/src/components/viewer/ViewportOverlays.tsx +12 -10
- package/src/components/viewer/bcf/BCFOverlay.tsx +254 -0
- package/src/components/viewer/hierarchy/treeDataBuilder.test.ts +70 -0
- package/src/components/viewer/hierarchy/treeDataBuilder.ts +35 -10
- package/src/components/viewer/hierarchy/types.ts +24 -2
- package/src/components/viewer/lists/ListPanel.tsx +0 -21
- package/src/components/viewer/lists/ListResultsTable.tsx +93 -5
- package/src/components/viewer/measureHandlers.ts +558 -0
- package/src/components/viewer/mouseHandlerTypes.ts +108 -0
- package/src/components/viewer/selectionHandlers.ts +86 -0
- package/src/components/viewer/useAnimationLoop.ts +116 -44
- package/src/components/viewer/useGeometryStreaming.ts +155 -367
- package/src/components/viewer/useKeyboardControls.ts +30 -46
- package/src/components/viewer/useMouseControls.ts +169 -695
- package/src/components/viewer/useRenderUpdates.ts +9 -59
- package/src/components/viewer/useTouchControls.ts +55 -40
- package/src/hooks/bcfIdLookup.ts +70 -0
- package/src/hooks/useBCF.ts +12 -31
- package/src/hooks/useIfcCache.ts +2 -20
- package/src/hooks/useIfcFederation.ts +5 -11
- package/src/hooks/useIfcLoader.ts +47 -56
- package/src/hooks/useIfcServer.ts +9 -1
- package/src/hooks/useKeyboardShortcuts.ts +0 -10
- package/src/hooks/useLatestRef.ts +24 -0
- package/src/sdk/adapters/export-adapter.ts +2 -2
- package/src/sdk/adapters/model-adapter.ts +1 -0
- package/src/sdk/adapters/visibility-adapter.ts +7 -49
- package/src/sdk/local-backend.ts +2 -0
- package/src/store/basketVisibleSet.test.ts +73 -3
- package/src/store/basketVisibleSet.ts +58 -75
- package/src/store/slices/bcfSlice.ts +9 -0
- package/src/utils/loadingUtils.ts +46 -0
- package/src/utils/serverDataModel.test.ts +90 -0
- package/src/utils/serverDataModel.ts +26 -37
- package/src/utils/spatialHierarchy.test.ts +38 -0
- package/src/utils/spatialHierarchy.ts +13 -23
- package/dist/assets/ifc-lite_bg-CyWQTvp5.wasm +0 -0
- package/dist/assets/index-ax1X2WPd.css +0 -1
|
@@ -0,0 +1,254 @@
|
|
|
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
|
+
* BCFOverlay — renders BCF topic markers as 3D-positioned overlays in the viewport.
|
|
7
|
+
*
|
|
8
|
+
* Connects:
|
|
9
|
+
* - Zustand store (BCF topics, active topic)
|
|
10
|
+
* - Renderer (camera projection, entity bounds)
|
|
11
|
+
* - BCFOverlayRenderer (pure DOM marker rendering)
|
|
12
|
+
* - BCF panel (click marker → open topic, bidirectional sync)
|
|
13
|
+
*
|
|
14
|
+
* KEY DESIGN: Bounds lookup queries the renderer Scene directly via a
|
|
15
|
+
* mutable ref (not React state). Marker computation is triggered by an
|
|
16
|
+
* `overlayReady` counter that bumps once the renderer is available AND
|
|
17
|
+
* when loading completes (ensuring bounding boxes are cached).
|
|
18
|
+
* The camera's current distance is passed as `targetDistance` so fallback
|
|
19
|
+
* markers land at the orbit center — not at hardcoded 10 units.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { useEffect, useRef, useCallback, useMemo, useState } from 'react';
|
|
23
|
+
import { useViewerStore } from '@/store';
|
|
24
|
+
import { getGlobalRenderer } from '@/hooks/useBCF';
|
|
25
|
+
import { globalIdToExpressId as globalIdToExpressIdLookup } from '@/hooks/bcfIdLookup';
|
|
26
|
+
import {
|
|
27
|
+
computeMarkerPositions,
|
|
28
|
+
BCFOverlayRenderer,
|
|
29
|
+
type BCFOverlayProjection,
|
|
30
|
+
type OverlayBBox,
|
|
31
|
+
type OverlayPoint3D,
|
|
32
|
+
type EntityBoundsLookup,
|
|
33
|
+
} from '@ifc-lite/bcf';
|
|
34
|
+
import type { Renderer } from '@ifc-lite/renderer';
|
|
35
|
+
|
|
36
|
+
// ============================================================================
|
|
37
|
+
// WebGPU projection adapter
|
|
38
|
+
// ============================================================================
|
|
39
|
+
|
|
40
|
+
function createWebGPUProjection(
|
|
41
|
+
renderer: Renderer,
|
|
42
|
+
canvas: HTMLCanvasElement,
|
|
43
|
+
): BCFOverlayProjection {
|
|
44
|
+
let prevPosX = NaN;
|
|
45
|
+
let prevPosY = NaN;
|
|
46
|
+
let prevPosZ = NaN;
|
|
47
|
+
let prevTgtX = NaN;
|
|
48
|
+
let prevTgtY = NaN;
|
|
49
|
+
let prevTgtZ = NaN;
|
|
50
|
+
let prevWidth = 0;
|
|
51
|
+
let prevHeight = 0;
|
|
52
|
+
|
|
53
|
+
const listeners = new Set<() => void>();
|
|
54
|
+
let rafId: number | null = null;
|
|
55
|
+
let listenerCount = 0;
|
|
56
|
+
|
|
57
|
+
function poll() {
|
|
58
|
+
rafId = requestAnimationFrame(poll);
|
|
59
|
+
const cam = renderer.getCamera();
|
|
60
|
+
const pos = cam.getPosition();
|
|
61
|
+
const tgt = cam.getTarget();
|
|
62
|
+
const w = canvas.clientWidth;
|
|
63
|
+
const h = canvas.clientHeight;
|
|
64
|
+
|
|
65
|
+
if (
|
|
66
|
+
pos.x !== prevPosX || pos.y !== prevPosY || pos.z !== prevPosZ ||
|
|
67
|
+
tgt.x !== prevTgtX || tgt.y !== prevTgtY || tgt.z !== prevTgtZ ||
|
|
68
|
+
w !== prevWidth || h !== prevHeight
|
|
69
|
+
) {
|
|
70
|
+
prevPosX = pos.x; prevPosY = pos.y; prevPosZ = pos.z;
|
|
71
|
+
prevTgtX = tgt.x; prevTgtY = tgt.y; prevTgtZ = tgt.z;
|
|
72
|
+
prevWidth = w; prevHeight = h;
|
|
73
|
+
for (const cb of listeners) cb();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
projectToScreen(worldPos: OverlayPoint3D) {
|
|
79
|
+
return renderer.getCamera().projectToScreen(
|
|
80
|
+
worldPos,
|
|
81
|
+
canvas.clientWidth,
|
|
82
|
+
canvas.clientHeight,
|
|
83
|
+
);
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
getEntityBounds(expressId: number): OverlayBBox | null {
|
|
87
|
+
return renderer.getScene().getEntityBoundingBox(expressId);
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
getCanvasSize() {
|
|
91
|
+
return { width: canvas.clientWidth, height: canvas.clientHeight };
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
getCameraPosition(): OverlayPoint3D {
|
|
95
|
+
return renderer.getCamera().getPosition();
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
onCameraChange(callback: () => void) {
|
|
99
|
+
listeners.add(callback);
|
|
100
|
+
listenerCount++;
|
|
101
|
+
if (listenerCount === 1) rafId = requestAnimationFrame(poll);
|
|
102
|
+
return () => {
|
|
103
|
+
listeners.delete(callback);
|
|
104
|
+
listenerCount--;
|
|
105
|
+
if (listenerCount === 0 && rafId !== null) {
|
|
106
|
+
cancelAnimationFrame(rafId);
|
|
107
|
+
rafId = null;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ============================================================================
|
|
115
|
+
// React Component
|
|
116
|
+
// ============================================================================
|
|
117
|
+
|
|
118
|
+
export function BCFOverlay() {
|
|
119
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
120
|
+
const overlayRef = useRef<BCFOverlayRenderer | null>(null);
|
|
121
|
+
const rendererRef = useRef<Renderer | null>(null);
|
|
122
|
+
|
|
123
|
+
// Bumped when overlay/renderer is ready or geometry finishes loading,
|
|
124
|
+
// triggering marker recomputation with real bounding boxes.
|
|
125
|
+
const [overlayReady, setOverlayReady] = useState(0);
|
|
126
|
+
|
|
127
|
+
// Store selectors
|
|
128
|
+
const bcfProject = useViewerStore((s) => s.bcfProject);
|
|
129
|
+
const activeTopicId = useViewerStore((s) => s.activeTopicId);
|
|
130
|
+
const setActiveTopic = useViewerStore((s) => s.setActiveTopic);
|
|
131
|
+
const setBcfPanelVisible = useViewerStore((s) => s.setBcfPanelVisible);
|
|
132
|
+
const models = useViewerStore((s) => s.models);
|
|
133
|
+
const loading = useViewerStore((s) => s.loading);
|
|
134
|
+
const ifcDataStore = useViewerStore((s) => s.ifcDataStore);
|
|
135
|
+
|
|
136
|
+
// GlobalId → expressId lookup (delegates to shared utility)
|
|
137
|
+
const globalIdToExpressId = useCallback(
|
|
138
|
+
(globalIdString: string) =>
|
|
139
|
+
globalIdToExpressIdLookup(globalIdString, models, ifcDataStore),
|
|
140
|
+
[models, ifcDataStore],
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
// Bounds lookup — queries the renderer Scene directly
|
|
144
|
+
const boundsLookup: EntityBoundsLookup = useCallback(
|
|
145
|
+
(ifcGuid: string): OverlayBBox | null => {
|
|
146
|
+
const r = rendererRef.current;
|
|
147
|
+
if (!r) return null;
|
|
148
|
+
const result = globalIdToExpressId(ifcGuid);
|
|
149
|
+
if (!result) return null;
|
|
150
|
+
return r.getScene().getEntityBoundingBox(result.expressId);
|
|
151
|
+
},
|
|
152
|
+
[globalIdToExpressId],
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
// Get current camera distance (for proper fallback marker placement)
|
|
156
|
+
const getCameraDistance = useCallback((): number => {
|
|
157
|
+
const r = rendererRef.current;
|
|
158
|
+
if (!r) return 50; // safe default
|
|
159
|
+
return r.getCamera().getDistance();
|
|
160
|
+
}, []);
|
|
161
|
+
|
|
162
|
+
// Topics list
|
|
163
|
+
const topics = (() => {
|
|
164
|
+
if (!bcfProject) return [];
|
|
165
|
+
return Array.from(bcfProject.topics.values());
|
|
166
|
+
})();
|
|
167
|
+
|
|
168
|
+
// Compute markers — recomputes when topics, bounds, loading, or readiness changes
|
|
169
|
+
const markers = useMemo(
|
|
170
|
+
() => computeMarkerPositions(topics, boundsLookup, {
|
|
171
|
+
targetDistance: getCameraDistance(),
|
|
172
|
+
}),
|
|
173
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
174
|
+
[topics, boundsLookup, overlayReady, loading],
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// Initialize overlay renderer
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
const container = containerRef.current;
|
|
180
|
+
if (!container) return;
|
|
181
|
+
|
|
182
|
+
const renderer = getGlobalRenderer();
|
|
183
|
+
if (!renderer) return;
|
|
184
|
+
|
|
185
|
+
const canvas = container.closest('[data-viewport]')?.querySelector('canvas') as HTMLCanvasElement | null;
|
|
186
|
+
if (!canvas) return;
|
|
187
|
+
|
|
188
|
+
rendererRef.current = renderer;
|
|
189
|
+
|
|
190
|
+
const projection = createWebGPUProjection(renderer, canvas);
|
|
191
|
+
const overlay = new BCFOverlayRenderer(container, projection, {
|
|
192
|
+
showConnectors: true,
|
|
193
|
+
showTooltips: true,
|
|
194
|
+
verticalOffset: 36,
|
|
195
|
+
});
|
|
196
|
+
overlayRef.current = overlay;
|
|
197
|
+
|
|
198
|
+
// Trigger marker recomputation now that renderer is available
|
|
199
|
+
setOverlayReady((n) => n + 1);
|
|
200
|
+
|
|
201
|
+
return () => {
|
|
202
|
+
overlay.dispose();
|
|
203
|
+
overlayRef.current = null;
|
|
204
|
+
rendererRef.current = null;
|
|
205
|
+
};
|
|
206
|
+
}, [models]);
|
|
207
|
+
|
|
208
|
+
// Recompute markers when loading finishes (bounding boxes get cached)
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
if (!loading && rendererRef.current) {
|
|
211
|
+
setOverlayReady((n) => n + 1);
|
|
212
|
+
}
|
|
213
|
+
}, [loading]);
|
|
214
|
+
|
|
215
|
+
// Push markers to overlay renderer
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
overlayRef.current?.setMarkers(markers);
|
|
218
|
+
}, [markers, overlayReady]);
|
|
219
|
+
|
|
220
|
+
// Sync active marker
|
|
221
|
+
useEffect(() => {
|
|
222
|
+
overlayRef.current?.setActiveMarker(activeTopicId);
|
|
223
|
+
}, [activeTopicId, overlayReady]);
|
|
224
|
+
|
|
225
|
+
// Visibility — reproject markers when becoming visible so they don't
|
|
226
|
+
// sit at stale positions until the next camera move.
|
|
227
|
+
useEffect(() => {
|
|
228
|
+
const overlay = overlayRef.current;
|
|
229
|
+
if (!overlay) return;
|
|
230
|
+
const hasTopics = bcfProject !== null && bcfProject.topics.size > 0;
|
|
231
|
+
overlay.setVisible(hasTopics);
|
|
232
|
+
if (hasTopics) overlay.updatePositions();
|
|
233
|
+
}, [bcfProject, overlayReady]);
|
|
234
|
+
|
|
235
|
+
// Click handler — read bcfPanelVisible from store inside callback to
|
|
236
|
+
// avoid re-registering the handler on every panel toggle.
|
|
237
|
+
useEffect(() => {
|
|
238
|
+
const overlay = overlayRef.current;
|
|
239
|
+
if (!overlay) return;
|
|
240
|
+
return overlay.onMarkerClick((topicGuid) => {
|
|
241
|
+
setActiveTopic(topicGuid);
|
|
242
|
+
const panelVisible = useViewerStore.getState().bcfPanelVisible;
|
|
243
|
+
if (!panelVisible) setBcfPanelVisible(true);
|
|
244
|
+
});
|
|
245
|
+
}, [overlayReady, setActiveTopic, setBcfPanelVisible]);
|
|
246
|
+
|
|
247
|
+
return (
|
|
248
|
+
<div
|
|
249
|
+
ref={containerRef}
|
|
250
|
+
className="absolute inset-0 pointer-events-none z-20"
|
|
251
|
+
data-bcf-overlay
|
|
252
|
+
/>
|
|
253
|
+
);
|
|
254
|
+
}
|
|
@@ -65,6 +65,40 @@ function createDataStore(): IfcDataStore {
|
|
|
65
65
|
} as unknown as IfcDataStore;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
function createFacilityDataStore(): IfcDataStore {
|
|
69
|
+
const partNode = createSpatialNode(3, IfcTypeEnum.IfcBridgePart, 'DECK');
|
|
70
|
+
partNode.elements = [4];
|
|
71
|
+
const bridgeNode = createSpatialNode(2, IfcTypeEnum.IfcBridge, 'BRIDGE', [partNode]);
|
|
72
|
+
const projectNode = createSpatialNode(1, IfcTypeEnum.IfcProject, 'INFRA_PROJECT', [bridgeNode]);
|
|
73
|
+
|
|
74
|
+
const spatialHierarchy: SpatialHierarchy = {
|
|
75
|
+
project: projectNode,
|
|
76
|
+
byStorey: new Map(),
|
|
77
|
+
byBuilding: new Map([[2, []]]),
|
|
78
|
+
bySite: new Map(),
|
|
79
|
+
bySpace: new Map(),
|
|
80
|
+
storeyElevations: new Map(),
|
|
81
|
+
storeyHeights: new Map(),
|
|
82
|
+
elementToStorey: new Map(),
|
|
83
|
+
getStoreyElements: () => [],
|
|
84
|
+
getStoreyByElevation: () => null,
|
|
85
|
+
getContainingSpace: () => null,
|
|
86
|
+
getPath: () => [],
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
spatialHierarchy,
|
|
91
|
+
entities: {
|
|
92
|
+
count: 0,
|
|
93
|
+
getName: (id: number) => (id === 4 ? 'Barrier' : ''),
|
|
94
|
+
getTypeName: (id: number) => {
|
|
95
|
+
if (id === 4) return 'IfcWall';
|
|
96
|
+
return 'Unknown';
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
} as unknown as IfcDataStore;
|
|
100
|
+
}
|
|
101
|
+
|
|
68
102
|
function createModel(idOffset: number): FederatedModel {
|
|
69
103
|
return {
|
|
70
104
|
id: 'model-1',
|
|
@@ -123,4 +157,40 @@ describe('buildTreeData', () => {
|
|
|
123
157
|
assert.strictEqual(nodes.filter((node) => node.id === 'element-model-1-6').length, 1);
|
|
124
158
|
assert.strictEqual(nodes.filter((node) => node.id === 'element-model-1-7').length, 1);
|
|
125
159
|
});
|
|
160
|
+
|
|
161
|
+
it('keeps IFC4.3 facility and facility-part nodes as spatial hierarchy rows', () => {
|
|
162
|
+
useViewerStore.setState({ models: new Map() });
|
|
163
|
+
useViewerStore.getState().registerModelOffset('tree-test-infra-padding', 199);
|
|
164
|
+
const idOffset = useViewerStore.getState().registerModelOffset('model-infra', 4);
|
|
165
|
+
const model = {
|
|
166
|
+
...createModel(idOffset),
|
|
167
|
+
id: 'model-infra',
|
|
168
|
+
name: 'Infra Model',
|
|
169
|
+
ifcDataStore: createFacilityDataStore(),
|
|
170
|
+
maxExpressId: 4,
|
|
171
|
+
};
|
|
172
|
+
useViewerStore.setState({ models: new Map([['model-infra', model]]) });
|
|
173
|
+
|
|
174
|
+
const nodes = buildTreeData(
|
|
175
|
+
new Map<string, FederatedModel>([['model-infra', model]]),
|
|
176
|
+
null,
|
|
177
|
+
new Set(['root-1', 'root-1-2', 'root-1-2-3']),
|
|
178
|
+
false,
|
|
179
|
+
[],
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const bridgeNode = nodes.find((node) => node.id === 'root-1-2');
|
|
183
|
+
assert.ok(bridgeNode);
|
|
184
|
+
assert.strictEqual(bridgeNode.type, 'IfcBridge');
|
|
185
|
+
|
|
186
|
+
const partNode = nodes.find((node) => node.id === 'root-1-2-3');
|
|
187
|
+
assert.ok(partNode);
|
|
188
|
+
assert.strictEqual(partNode.type, 'IfcBridgePart');
|
|
189
|
+
assert.strictEqual(partNode.elementCount, 1);
|
|
190
|
+
|
|
191
|
+
const barrierNode = nodes.find((node) => node.id === 'element-model-infra-4');
|
|
192
|
+
assert.ok(barrierNode);
|
|
193
|
+
assert.strictEqual(barrierNode.type, 'element');
|
|
194
|
+
assert.strictEqual(barrierNode.ifcType, 'IfcWall');
|
|
195
|
+
});
|
|
126
196
|
});
|
|
@@ -2,7 +2,15 @@
|
|
|
2
2
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
3
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
IfcTypeEnum,
|
|
7
|
+
EntityFlags,
|
|
8
|
+
RelationshipType,
|
|
9
|
+
isSpaceLikeSpatialType,
|
|
10
|
+
isSpatialStructureType,
|
|
11
|
+
isStoreyLikeSpatialType,
|
|
12
|
+
type SpatialNode,
|
|
13
|
+
} from '@ifc-lite/data';
|
|
6
14
|
import type { IfcDataStore } from '@ifc-lite/parser';
|
|
7
15
|
import { useViewerStore, type FederatedModel } from '@/store';
|
|
8
16
|
import type { TreeNode, NodeType, StoreyData, UnifiedStorey } from './types';
|
|
@@ -18,7 +26,16 @@ export function getNodeType(ifcType: IfcTypeEnum): NodeType {
|
|
|
18
26
|
case IfcTypeEnum.IfcProject: return 'IfcProject';
|
|
19
27
|
case IfcTypeEnum.IfcSite: return 'IfcSite';
|
|
20
28
|
case IfcTypeEnum.IfcBuilding: return 'IfcBuilding';
|
|
29
|
+
case IfcTypeEnum.IfcFacility: return 'IfcFacility';
|
|
30
|
+
case IfcTypeEnum.IfcBridge: return 'IfcBridge';
|
|
31
|
+
case IfcTypeEnum.IfcRoad: return 'IfcRoad';
|
|
32
|
+
case IfcTypeEnum.IfcRailway: return 'IfcRailway';
|
|
33
|
+
case IfcTypeEnum.IfcMarineFacility: return 'IfcMarineFacility';
|
|
21
34
|
case IfcTypeEnum.IfcBuildingStorey: return 'IfcBuildingStorey';
|
|
35
|
+
case IfcTypeEnum.IfcFacilityPart: return 'IfcFacilityPart';
|
|
36
|
+
case IfcTypeEnum.IfcBridgePart: return 'IfcBridgePart';
|
|
37
|
+
case IfcTypeEnum.IfcRoadPart: return 'IfcRoadPart';
|
|
38
|
+
case IfcTypeEnum.IfcRailwayPart: return 'IfcRailwayPart';
|
|
22
39
|
case IfcTypeEnum.IfcSpace: return 'IfcSpace';
|
|
23
40
|
default: return 'element';
|
|
24
41
|
}
|
|
@@ -68,10 +85,17 @@ function getSpatialNodeElements(
|
|
|
68
85
|
nodeType: NodeType,
|
|
69
86
|
descendantSpaceCache: Map<number, Set<number>>
|
|
70
87
|
): number[] {
|
|
71
|
-
if (
|
|
88
|
+
if (isSpaceLikeSpatialType(spatialNode.type)) {
|
|
72
89
|
return (dataStore.spatialHierarchy?.bySpace.get(spatialNode.expressId) as number[]) || [];
|
|
73
90
|
}
|
|
74
91
|
|
|
92
|
+
if (!isStoreyLikeSpatialType(spatialNode.type)) {
|
|
93
|
+
if (!isSpatialStructureType(spatialNode.type)) {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
return spatialNode.elements || [];
|
|
97
|
+
}
|
|
98
|
+
|
|
75
99
|
if (nodeType !== 'IfcBuildingStorey') {
|
|
76
100
|
return [];
|
|
77
101
|
}
|
|
@@ -177,16 +201,16 @@ function buildSpatialNodes(
|
|
|
177
201
|
}
|
|
178
202
|
|
|
179
203
|
const elements = getSpatialNodeElements(spatialNode, dataStore, nodeType, descendantSpaceCache);
|
|
204
|
+
const hasDirectElements = elements.length > 0;
|
|
180
205
|
|
|
181
206
|
// Check if has children
|
|
182
207
|
// In stopAtBuilding mode, buildings have no children (storeys shown separately)
|
|
183
208
|
const hasNonStoreyChildren = spatialNode.children?.some(
|
|
184
|
-
(c: SpatialNode) =>
|
|
209
|
+
(c: SpatialNode) => !isStoreyLikeSpatialType(c.type)
|
|
185
210
|
);
|
|
186
211
|
const hasChildren = stopAtBuilding
|
|
187
|
-
? (
|
|
188
|
-
: (spatialNode.children?.length > 0) ||
|
|
189
|
-
((nodeType === 'IfcBuildingStorey' || nodeType === 'IfcSpace') && elements.length > 0);
|
|
212
|
+
? Boolean(hasNonStoreyChildren || hasDirectElements)
|
|
213
|
+
: (spatialNode.children?.length > 0) || hasDirectElements;
|
|
190
214
|
|
|
191
215
|
nodes.push({
|
|
192
216
|
id: nodeId,
|
|
@@ -201,7 +225,7 @@ function buildSpatialNodes(
|
|
|
201
225
|
hasChildren,
|
|
202
226
|
isExpanded: isNodeExpanded,
|
|
203
227
|
isVisible: true, // Visibility computed lazily during render
|
|
204
|
-
elementCount:
|
|
228
|
+
elementCount: hasDirectElements ? elements.length : undefined,
|
|
205
229
|
storeyElevation: spatialNode.elevation,
|
|
206
230
|
// Store idOffset for lazy visibility computation
|
|
207
231
|
_idOffset: idOffset,
|
|
@@ -209,7 +233,8 @@ function buildSpatialNodes(
|
|
|
209
233
|
|
|
210
234
|
if (isNodeExpanded) {
|
|
211
235
|
// Sort storeys by elevation descending
|
|
212
|
-
const
|
|
236
|
+
const shouldSortByElevation = (spatialNode.children || []).some((child) => isStoreyLikeSpatialType(child.type));
|
|
237
|
+
const sortedChildren = shouldSortByElevation
|
|
213
238
|
? [...(spatialNode.children || [])].sort((a, b) => (b.elevation || 0) - (a.elevation || 0))
|
|
214
239
|
: spatialNode.children || [];
|
|
215
240
|
|
|
@@ -229,8 +254,8 @@ function buildSpatialNodes(
|
|
|
229
254
|
);
|
|
230
255
|
}
|
|
231
256
|
|
|
232
|
-
//
|
|
233
|
-
if (
|
|
257
|
+
// Add direct spatial children elements for expanded nodes.
|
|
258
|
+
if (hasDirectElements) {
|
|
234
259
|
for (const elementId of elements) {
|
|
235
260
|
const globalId = resolveTreeGlobalId(modelId, elementId, models);
|
|
236
261
|
const entityType = dataStore.entities?.getTypeName(elementId) || 'Unknown';
|
|
@@ -9,7 +9,16 @@ export type NodeType =
|
|
|
9
9
|
| 'IfcProject' // Project node
|
|
10
10
|
| 'IfcSite' // Site node
|
|
11
11
|
| 'IfcBuilding' // Building node
|
|
12
|
+
| 'IfcFacility' // IFC4.3 facility root
|
|
13
|
+
| 'IfcBridge' // IFC4.3 bridge root
|
|
14
|
+
| 'IfcRoad' // IFC4.3 road root
|
|
15
|
+
| 'IfcRailway' // IFC4.3 railway root
|
|
16
|
+
| 'IfcMarineFacility' // IFC4.3 marine facility root
|
|
12
17
|
| 'IfcBuildingStorey' // Storey node
|
|
18
|
+
| 'IfcFacilityPart' // IFC4.3 facility part
|
|
19
|
+
| 'IfcBridgePart' // IFC4.3 bridge part
|
|
20
|
+
| 'IfcRoadPart' // IFC4.3 road part
|
|
21
|
+
| 'IfcRailwayPart' // IFC4.3 railway part
|
|
13
22
|
| 'IfcSpace' // Space node
|
|
14
23
|
| 'type-group' // IFC class grouping header (e.g., "IfcWall (47)")
|
|
15
24
|
| 'ifc-type' // IFC type entity node (e.g., "IfcWallType/W01")
|
|
@@ -57,6 +66,19 @@ export interface UnifiedStorey {
|
|
|
57
66
|
totalElements: number;
|
|
58
67
|
}
|
|
59
68
|
|
|
60
|
-
// Spatial container types (
|
|
61
|
-
const SPATIAL_CONTAINER_TYPES: Set<NodeType> = new Set([
|
|
69
|
+
// Spatial container types (all non-leaf spatial nodes) - these don't participate in storey filters.
|
|
70
|
+
const SPATIAL_CONTAINER_TYPES: Set<NodeType> = new Set([
|
|
71
|
+
'IfcProject',
|
|
72
|
+
'IfcSite',
|
|
73
|
+
'IfcBuilding',
|
|
74
|
+
'IfcFacility',
|
|
75
|
+
'IfcBridge',
|
|
76
|
+
'IfcRoad',
|
|
77
|
+
'IfcRailway',
|
|
78
|
+
'IfcMarineFacility',
|
|
79
|
+
'IfcFacilityPart',
|
|
80
|
+
'IfcBridgePart',
|
|
81
|
+
'IfcRoadPart',
|
|
82
|
+
'IfcRailwayPart',
|
|
83
|
+
]);
|
|
62
84
|
export const isSpatialContainer = (type: NodeType): boolean => SPATIAL_CONTAINER_TYPES.has(type);
|
|
@@ -34,7 +34,6 @@ import { useViewerStore } from '@/store';
|
|
|
34
34
|
import { useIfc } from '@/hooks/useIfc';
|
|
35
35
|
import {
|
|
36
36
|
executeList,
|
|
37
|
-
listResultToCSV,
|
|
38
37
|
LIST_PRESETS,
|
|
39
38
|
importListDefinition,
|
|
40
39
|
exportListDefinition,
|
|
@@ -172,18 +171,6 @@ export function ListPanel({ onClose }: ListPanelProps) {
|
|
|
172
171
|
}
|
|
173
172
|
}, [editingList]);
|
|
174
173
|
|
|
175
|
-
const handleExportCSV = useCallback(() => {
|
|
176
|
-
if (!listResult) return;
|
|
177
|
-
const csv = listResultToCSV(listResult);
|
|
178
|
-
const blob = new Blob([csv], { type: 'text/csv' });
|
|
179
|
-
const url = URL.createObjectURL(blob);
|
|
180
|
-
const a = document.createElement('a');
|
|
181
|
-
a.href = url;
|
|
182
|
-
a.download = 'list-export.csv';
|
|
183
|
-
a.click();
|
|
184
|
-
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
185
|
-
}, [listResult]);
|
|
186
|
-
|
|
187
174
|
const handleImport = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
188
175
|
const file = e.target.files?.[0];
|
|
189
176
|
if (!file) return;
|
|
@@ -228,14 +215,6 @@ export function ListPanel({ onClose }: ListPanelProps) {
|
|
|
228
215
|
</TooltipTrigger>
|
|
229
216
|
<TooltipContent>Edit Configuration</TooltipContent>
|
|
230
217
|
</Tooltip>
|
|
231
|
-
<Tooltip>
|
|
232
|
-
<TooltipTrigger asChild>
|
|
233
|
-
<Button variant="ghost" size="icon-sm" onClick={handleExportCSV}>
|
|
234
|
-
<Download className="h-3.5 w-3.5" />
|
|
235
|
-
</Button>
|
|
236
|
-
</TooltipTrigger>
|
|
237
|
-
<TooltipContent>Export CSV</TooltipContent>
|
|
238
|
-
</Tooltip>
|
|
239
218
|
<Tooltip>
|
|
240
219
|
<TooltipTrigger asChild>
|
|
241
220
|
<Button variant="ghost" size="icon-sm" onClick={() => setView('library')}>
|
|
@@ -12,10 +12,14 @@
|
|
|
12
12
|
|
|
13
13
|
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
|
14
14
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
15
|
-
import { ArrowUp, ArrowDown, Search, Palette } from 'lucide-react';
|
|
15
|
+
import { ArrowUp, ArrowDown, Search, Palette, Eye, EyeOff, Download } from 'lucide-react';
|
|
16
16
|
import { Input } from '@/components/ui/input';
|
|
17
|
+
import { Button } from '@/components/ui/button';
|
|
18
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
|
17
19
|
import { useViewerStore } from '@/store';
|
|
20
|
+
import { getVisibleBasketEntityRefsFromStore } from '@/store/basketVisibleSet';
|
|
18
21
|
import type { ListResult, ListRow, CellValue, ColumnDefinition } from '@ifc-lite/lists';
|
|
22
|
+
import { listResultToCSV } from '@ifc-lite/lists';
|
|
19
23
|
import { cn } from '@/lib/utils';
|
|
20
24
|
import { columnToAutoColor } from '@/lib/lists/columnToAutoColor';
|
|
21
25
|
import { AUTO_COLOR_FROM_LIST_ID } from '@/store/slices/lensSlice';
|
|
@@ -29,6 +33,7 @@ export function ListResultsTable({ result }: ListResultsTableProps) {
|
|
|
29
33
|
const [searchQuery, setSearchQuery] = useState('');
|
|
30
34
|
const [sortCol, setSortCol] = useState<number | null>(null);
|
|
31
35
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
|
36
|
+
const [filterByVisibility, setFilterByVisibility] = useState(true);
|
|
32
37
|
|
|
33
38
|
const setSelectedEntityId = useViewerStore((s) => s.setSelectedEntityId);
|
|
34
39
|
const setSelectedEntity = useViewerStore((s) => s.setSelectedEntity);
|
|
@@ -37,14 +42,49 @@ export function ListResultsTable({ result }: ListResultsTableProps) {
|
|
|
37
42
|
const activeLensId = useViewerStore((s) => s.activeLensId);
|
|
38
43
|
const [colorByColIdx, setColorByColIdx] = useState<number | null>(null);
|
|
39
44
|
|
|
45
|
+
// Subscribe to visibility state so we re-filter when 3D visibility changes
|
|
46
|
+
const hiddenEntities = useViewerStore((s) => s.hiddenEntities);
|
|
47
|
+
const isolatedEntities = useViewerStore((s) => s.isolatedEntities);
|
|
48
|
+
const classFilter = useViewerStore((s) => s.classFilter);
|
|
49
|
+
const lensHiddenIds = useViewerStore((s) => s.lensHiddenIds);
|
|
50
|
+
const selectedStoreys = useViewerStore((s) => s.selectedStoreys);
|
|
51
|
+
const typeVisibility = useViewerStore((s) => s.typeVisibility);
|
|
52
|
+
const hiddenEntitiesByModel = useViewerStore((s) => s.hiddenEntitiesByModel);
|
|
53
|
+
const isolatedEntitiesByModel = useViewerStore((s) => s.isolatedEntitiesByModel);
|
|
54
|
+
const models = useViewerStore((s) => s.models);
|
|
55
|
+
const activeBasketViewId = useViewerStore((s) => s.activeBasketViewId);
|
|
56
|
+
const geometryResult = useViewerStore((s) => s.geometryResult);
|
|
57
|
+
|
|
58
|
+
// Filter rows by 3D visibility
|
|
59
|
+
const visibilityFilteredRows = useMemo(() => {
|
|
60
|
+
if (!filterByVisibility) return result.rows;
|
|
61
|
+
|
|
62
|
+
const visibleRefs = getVisibleBasketEntityRefsFromStore();
|
|
63
|
+
const visibleSet = new Set<string>();
|
|
64
|
+
for (const ref of visibleRefs) {
|
|
65
|
+
visibleSet.add(`${ref.modelId}:${ref.expressId}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return result.rows.filter(row => {
|
|
69
|
+
// List uses 'default' for single-model, visibility uses 'legacy'
|
|
70
|
+
const modelId = row.modelId === 'default' ? 'legacy' : row.modelId;
|
|
71
|
+
return visibleSet.has(`${modelId}:${row.entityId}`);
|
|
72
|
+
});
|
|
73
|
+
}, [
|
|
74
|
+
result.rows, filterByVisibility,
|
|
75
|
+
hiddenEntities, isolatedEntities, classFilter, lensHiddenIds,
|
|
76
|
+
selectedStoreys, typeVisibility, hiddenEntitiesByModel,
|
|
77
|
+
isolatedEntitiesByModel, models, activeBasketViewId, geometryResult,
|
|
78
|
+
]);
|
|
79
|
+
|
|
40
80
|
// Filter rows by search query
|
|
41
81
|
const filteredRows = useMemo(() => {
|
|
42
|
-
if (!searchQuery) return
|
|
82
|
+
if (!searchQuery) return visibilityFilteredRows;
|
|
43
83
|
const q = searchQuery.toLowerCase();
|
|
44
|
-
return
|
|
84
|
+
return visibilityFilteredRows.filter(row =>
|
|
45
85
|
row.values.some(v => v !== null && String(v).toLowerCase().includes(q))
|
|
46
86
|
);
|
|
47
|
-
}, [
|
|
87
|
+
}, [visibilityFilteredRows, searchQuery]);
|
|
48
88
|
|
|
49
89
|
// Sort rows
|
|
50
90
|
const sortedRows = useMemo(() => {
|
|
@@ -74,6 +114,23 @@ export function ListResultsTable({ result }: ListResultsTableProps) {
|
|
|
74
114
|
setColorByColIdx(colIdx);
|
|
75
115
|
}, [activateAutoColorFromColumn]);
|
|
76
116
|
|
|
117
|
+
const handleExportCSV = useCallback(() => {
|
|
118
|
+
const exportResult: ListResult = {
|
|
119
|
+
columns: result.columns,
|
|
120
|
+
rows: sortedRows,
|
|
121
|
+
totalCount: sortedRows.length,
|
|
122
|
+
executionTime: result.executionTime,
|
|
123
|
+
};
|
|
124
|
+
const csv = listResultToCSV(exportResult);
|
|
125
|
+
const blob = new Blob([csv], { type: 'text/csv' });
|
|
126
|
+
const url = URL.createObjectURL(blob);
|
|
127
|
+
const a = document.createElement('a');
|
|
128
|
+
a.href = url;
|
|
129
|
+
a.download = 'list-export.csv';
|
|
130
|
+
a.click();
|
|
131
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
132
|
+
}, [result.columns, result.executionTime, sortedRows]);
|
|
133
|
+
|
|
77
134
|
const handleRowClick = useCallback((row: ListRow) => {
|
|
78
135
|
setSelectedEntity({ modelId: row.modelId, expressId: row.entityId });
|
|
79
136
|
// For single-model, selectedEntityId is the expressId
|
|
@@ -111,8 +168,39 @@ export function ListResultsTable({ result }: ListResultsTableProps) {
|
|
|
111
168
|
className="h-7 text-xs border-0 shadow-none focus-visible:ring-0 px-0"
|
|
112
169
|
/>
|
|
113
170
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
|
114
|
-
{sortedRows.length}{searchQuery ? ` / ${result.rows.length}` : ''} rows
|
|
171
|
+
{sortedRows.length}{(searchQuery || filterByVisibility) ? ` / ${result.rows.length}` : ''} rows
|
|
115
172
|
</span>
|
|
173
|
+
<Tooltip>
|
|
174
|
+
<TooltipTrigger asChild>
|
|
175
|
+
<Button
|
|
176
|
+
variant="ghost"
|
|
177
|
+
size="icon-sm"
|
|
178
|
+
className={cn(
|
|
179
|
+
'h-6 w-6 shrink-0',
|
|
180
|
+
filterByVisibility && 'text-primary',
|
|
181
|
+
)}
|
|
182
|
+
onClick={() => setFilterByVisibility(prev => !prev)}
|
|
183
|
+
>
|
|
184
|
+
{filterByVisibility ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />}
|
|
185
|
+
</Button>
|
|
186
|
+
</TooltipTrigger>
|
|
187
|
+
<TooltipContent>
|
|
188
|
+
{filterByVisibility ? 'Showing visible objects only' : 'Showing all objects'}
|
|
189
|
+
</TooltipContent>
|
|
190
|
+
</Tooltip>
|
|
191
|
+
<Tooltip>
|
|
192
|
+
<TooltipTrigger asChild>
|
|
193
|
+
<Button
|
|
194
|
+
variant="ghost"
|
|
195
|
+
size="icon-sm"
|
|
196
|
+
className="h-6 w-6 shrink-0"
|
|
197
|
+
onClick={handleExportCSV}
|
|
198
|
+
>
|
|
199
|
+
<Download className="h-3.5 w-3.5" />
|
|
200
|
+
</Button>
|
|
201
|
+
</TooltipTrigger>
|
|
202
|
+
<TooltipContent>Export CSV</TooltipContent>
|
|
203
|
+
</Tooltip>
|
|
116
204
|
</div>
|
|
117
205
|
|
|
118
206
|
{/* Table */}
|