@ifc-lite/viewer 1.14.3 → 1.15.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 +32 -0
- package/dist/assets/{Arrow.dom-BgkZDIQm.js → Arrow.dom-OVBBPqOB.js} +1 -1
- package/dist/assets/{basketViewActivator-h_M3YbMW.js → basketViewActivator-Bx6QU4ma.js} +1 -1
- package/dist/assets/{browser-CRQ0bPh1.js → browser-BMqEoJw4.js} +1 -1
- package/dist/assets/ifc-lite_bg-CyWQTvp5.wasm +0 -0
- package/dist/assets/index-CJr7Itua.css +1 -0
- package/dist/assets/index-DZY6uD8A.js +185948 -0
- package/dist/assets/{index-C4VVJRL-.js → index-DsX-NCtx.js} +4 -4
- package/dist/assets/{native-bridge-DtcJqlOi.js → native-bridge-D6tKFqGO.js} +1 -1
- package/dist/assets/{wasm-bridge-BJJVu9P2.js → wasm-bridge-D4kvZVDw.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +7 -7
- package/src/components/viewer/CommandPalette.tsx +1 -0
- package/src/components/viewer/ExportDialog.tsx +40 -2
- package/src/components/viewer/HierarchyPanel.tsx +127 -35
- package/src/components/viewer/MainToolbar.tsx +113 -95
- package/src/components/viewer/ViewportContainer.tsx +30 -25
- package/src/components/viewer/hierarchy/HierarchyNode.tsx +10 -3
- package/src/components/viewer/hierarchy/treeDataBuilder.test.ts +126 -0
- package/src/components/viewer/hierarchy/treeDataBuilder.ts +139 -38
- package/src/components/viewer/hierarchy/types.ts +6 -1
- package/src/components/viewer/hierarchy/useHierarchyTree.ts +27 -12
- package/src/sdk/adapters/visibility-adapter.ts +82 -2
- package/src/store/basketVisibleSet.ts +72 -4
- package/src/store/index.ts +11 -1
- package/src/store/slices/visibilitySlice.ts +28 -2
- package/dist/assets/ifc-lite_bg-DyBKoGgk.wasm +0 -0
- package/dist/assets/index-Be6XjVeM.js +0 -116717
- package/dist/assets/index-DdwD4c-E.css +0 -1
|
@@ -36,7 +36,9 @@ import {
|
|
|
36
36
|
ClipboardCheck,
|
|
37
37
|
Palette,
|
|
38
38
|
Orbit,
|
|
39
|
+
Layout,
|
|
39
40
|
LayoutTemplate,
|
|
41
|
+
FileCode2,
|
|
40
42
|
} from 'lucide-react';
|
|
41
43
|
import { Button } from '@/components/ui/button';
|
|
42
44
|
import { Separator } from '@/components/ui/separator';
|
|
@@ -70,6 +72,7 @@ import { ThemeSwitch } from './ThemeSwitch';
|
|
|
70
72
|
import { toast } from '@/components/ui/toast';
|
|
71
73
|
|
|
72
74
|
type Tool = 'select' | 'pan' | 'orbit' | 'walk' | 'measure' | 'section';
|
|
75
|
+
type WorkspacePanel = 'script' | 'list' | 'bcf' | 'ids' | 'lens';
|
|
73
76
|
|
|
74
77
|
// #region FIX: Move ToolButton OUTSIDE MainToolbar to prevent recreation on every render
|
|
75
78
|
// This fixes Radix UI Tooltip's asChild prop becoming stale during re-renders
|
|
@@ -176,13 +179,11 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
|
|
|
176
179
|
const toggleTypeVisibility = useViewerStore((state) => state.toggleTypeVisibility);
|
|
177
180
|
const resetViewerState = useViewerStore((state) => state.resetViewerState);
|
|
178
181
|
const bcfPanelVisible = useViewerStore((state) => state.bcfPanelVisible);
|
|
179
|
-
const toggleBcfPanel = useViewerStore((state) => state.toggleBcfPanel);
|
|
180
182
|
const setBcfPanelVisible = useViewerStore((state) => state.setBcfPanelVisible);
|
|
181
183
|
const idsPanelVisible = useViewerStore((state) => state.idsPanelVisible);
|
|
182
|
-
const toggleIdsPanel = useViewerStore((state) => state.toggleIdsPanel);
|
|
183
184
|
const setIdsPanelVisible = useViewerStore((state) => state.setIdsPanelVisible);
|
|
184
185
|
const listPanelVisible = useViewerStore((state) => state.listPanelVisible);
|
|
185
|
-
const
|
|
186
|
+
const setListPanelVisible = useViewerStore((state) => state.setListPanelVisible);
|
|
186
187
|
const setRightPanelCollapsed = useViewerStore((state) => state.setRightPanelCollapsed);
|
|
187
188
|
const projectionMode = useViewerStore((state) => state.projectionMode);
|
|
188
189
|
const toggleProjectionMode = useViewerStore((state) => state.toggleProjectionMode);
|
|
@@ -193,8 +194,9 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
|
|
|
193
194
|
const toggleBasketPresentationVisible = useViewerStore((state) => state.toggleBasketPresentationVisible);
|
|
194
195
|
// Lens state
|
|
195
196
|
const lensPanelVisible = useViewerStore((state) => state.lensPanelVisible);
|
|
196
|
-
const toggleLensPanel = useViewerStore((state) => state.toggleLensPanel);
|
|
197
197
|
const setLensPanelVisible = useViewerStore((state) => state.setLensPanelVisible);
|
|
198
|
+
const scriptPanelVisible = useViewerStore((state) => state.scriptPanelVisible);
|
|
199
|
+
const setScriptPanelVisible = useViewerStore((state) => state.setScriptPanelVisible);
|
|
198
200
|
|
|
199
201
|
// Check which type geometries exist across ALL loaded models (federation-aware).
|
|
200
202
|
// PERF: Use meshes.length as dep proxy instead of full geometryResult, and
|
|
@@ -361,6 +363,61 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
|
|
|
361
363
|
goHomeFromStore();
|
|
362
364
|
}, []);
|
|
363
365
|
|
|
366
|
+
const handleToggleBottomPanel = useCallback((panel: 'script' | 'list') => {
|
|
367
|
+
const isScriptPanel = panel === 'script';
|
|
368
|
+
const nextScriptVisible = isScriptPanel ? !scriptPanelVisible : false;
|
|
369
|
+
const nextListVisible = isScriptPanel ? false : !listPanelVisible;
|
|
370
|
+
|
|
371
|
+
setScriptPanelVisible(nextScriptVisible);
|
|
372
|
+
setListPanelVisible(nextListVisible);
|
|
373
|
+
|
|
374
|
+
if (nextScriptVisible || nextListVisible) {
|
|
375
|
+
setRightPanelCollapsed(false);
|
|
376
|
+
}
|
|
377
|
+
}, [listPanelVisible, scriptPanelVisible, setListPanelVisible, setRightPanelCollapsed, setScriptPanelVisible]);
|
|
378
|
+
|
|
379
|
+
const handleToggleRightPanel = useCallback((panel: 'bcf' | 'ids' | 'lens') => {
|
|
380
|
+
const nextBcfVisible = panel === 'bcf' ? !bcfPanelVisible : false;
|
|
381
|
+
const nextIdsVisible = panel === 'ids' ? !idsPanelVisible : false;
|
|
382
|
+
const nextLensVisible = panel === 'lens' ? !lensPanelVisible : false;
|
|
383
|
+
|
|
384
|
+
setBcfPanelVisible(nextBcfVisible);
|
|
385
|
+
setIdsPanelVisible(nextIdsVisible);
|
|
386
|
+
setLensPanelVisible(nextLensVisible);
|
|
387
|
+
|
|
388
|
+
if (nextBcfVisible || nextIdsVisible || nextLensVisible) {
|
|
389
|
+
setRightPanelCollapsed(false);
|
|
390
|
+
}
|
|
391
|
+
}, [
|
|
392
|
+
bcfPanelVisible,
|
|
393
|
+
idsPanelVisible,
|
|
394
|
+
lensPanelVisible,
|
|
395
|
+
setBcfPanelVisible,
|
|
396
|
+
setIdsPanelVisible,
|
|
397
|
+
setLensPanelVisible,
|
|
398
|
+
setRightPanelCollapsed,
|
|
399
|
+
]);
|
|
400
|
+
|
|
401
|
+
const activeWorkspacePanels = useMemo(() => {
|
|
402
|
+
const panels = new Set<WorkspacePanel>();
|
|
403
|
+
if (scriptPanelVisible) panels.add('script');
|
|
404
|
+
if (listPanelVisible) panels.add('list');
|
|
405
|
+
if (bcfPanelVisible) panels.add('bcf');
|
|
406
|
+
if (idsPanelVisible) panels.add('ids');
|
|
407
|
+
if (lensPanelVisible) panels.add('lens');
|
|
408
|
+
return panels;
|
|
409
|
+
}, [bcfPanelVisible, idsPanelVisible, lensPanelVisible, listPanelVisible, scriptPanelVisible]);
|
|
410
|
+
|
|
411
|
+
const workspacePanelLabel = useMemo(() => {
|
|
412
|
+
if (activeWorkspacePanels.size === 0) return null;
|
|
413
|
+
if (activeWorkspacePanels.size > 1) return 'Multiple Panels';
|
|
414
|
+
if (activeWorkspacePanels.has('script')) return 'Script Editor';
|
|
415
|
+
if (activeWorkspacePanels.has('list')) return 'Lists';
|
|
416
|
+
if (activeWorkspacePanels.has('bcf')) return 'BCF Issues';
|
|
417
|
+
if (activeWorkspacePanels.has('ids')) return 'IDS Validation';
|
|
418
|
+
return 'Lens Rules';
|
|
419
|
+
}, [activeWorkspacePanels]);
|
|
420
|
+
|
|
364
421
|
const handleExportGLB = useCallback(() => {
|
|
365
422
|
if (!geometryResult) return;
|
|
366
423
|
try {
|
|
@@ -624,76 +681,61 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
|
|
|
624
681
|
<ExportChangesButton />
|
|
625
682
|
|
|
626
683
|
{/* ── Panels ── */}
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
684
|
+
<DropdownMenu>
|
|
685
|
+
<Tooltip>
|
|
686
|
+
<TooltipTrigger asChild>
|
|
687
|
+
<DropdownMenuTrigger asChild>
|
|
688
|
+
<Button
|
|
689
|
+
variant={activeWorkspacePanels.size > 0 ? 'default' : 'ghost'}
|
|
690
|
+
size="icon-sm"
|
|
691
|
+
aria-label={workspacePanelLabel ? `Panels: ${workspacePanelLabel}` : 'Panels'}
|
|
692
|
+
className={cn(activeWorkspacePanels.size > 0 && 'bg-primary text-primary-foreground')}
|
|
693
|
+
>
|
|
694
|
+
<Layout className="h-4 w-4" />
|
|
695
|
+
</Button>
|
|
696
|
+
</DropdownMenuTrigger>
|
|
697
|
+
</TooltipTrigger>
|
|
698
|
+
<TooltipContent>{workspacePanelLabel ? `Panels: ${workspacePanelLabel}` : 'Panels'}</TooltipContent>
|
|
699
|
+
</Tooltip>
|
|
700
|
+
<DropdownMenuContent align="start" className="w-56">
|
|
701
|
+
<DropdownMenuCheckboxItem
|
|
702
|
+
checked={activeWorkspacePanels.has('script')}
|
|
703
|
+
onCheckedChange={() => handleToggleBottomPanel('script')}
|
|
644
704
|
>
|
|
645
|
-
<
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
{/* IDS Validation Button */}
|
|
652
|
-
<Tooltip>
|
|
653
|
-
<TooltipTrigger asChild>
|
|
654
|
-
<Button
|
|
655
|
-
variant={idsPanelVisible ? 'default' : 'ghost'}
|
|
656
|
-
size="icon-sm"
|
|
657
|
-
onClick={(e) => {
|
|
658
|
-
(e.currentTarget as HTMLButtonElement).blur();
|
|
659
|
-
if (!idsPanelVisible) {
|
|
660
|
-
// Close other right-panel content first, then expand
|
|
661
|
-
setBcfPanelVisible(false);
|
|
662
|
-
setLensPanelVisible(false);
|
|
663
|
-
setRightPanelCollapsed(false);
|
|
664
|
-
}
|
|
665
|
-
toggleIdsPanel();
|
|
666
|
-
}}
|
|
667
|
-
className={cn(idsPanelVisible && 'bg-primary text-primary-foreground')}
|
|
705
|
+
<FileCode2 className="h-4 w-4 mr-2" />
|
|
706
|
+
Script Editor
|
|
707
|
+
</DropdownMenuCheckboxItem>
|
|
708
|
+
<DropdownMenuCheckboxItem
|
|
709
|
+
checked={activeWorkspacePanels.has('list')}
|
|
710
|
+
onCheckedChange={() => handleToggleBottomPanel('list')}
|
|
668
711
|
>
|
|
669
|
-
<
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
<Tooltip>
|
|
677
|
-
<TooltipTrigger asChild>
|
|
678
|
-
<Button
|
|
679
|
-
variant={listPanelVisible ? 'default' : 'ghost'}
|
|
680
|
-
size="icon-sm"
|
|
681
|
-
onClick={(e) => {
|
|
682
|
-
(e.currentTarget as HTMLButtonElement).blur();
|
|
683
|
-
// Close other bottom panels (bottom-panel exclusivity)
|
|
684
|
-
useViewerStore.getState().setScriptPanelVisible(false);
|
|
685
|
-
if (!listPanelVisible) {
|
|
686
|
-
setRightPanelCollapsed(false);
|
|
687
|
-
}
|
|
688
|
-
toggleListPanel();
|
|
689
|
-
}}
|
|
690
|
-
className={cn(listPanelVisible && 'bg-primary text-primary-foreground')}
|
|
712
|
+
<FileSpreadsheet className="h-4 w-4 mr-2" />
|
|
713
|
+
Lists
|
|
714
|
+
</DropdownMenuCheckboxItem>
|
|
715
|
+
<DropdownMenuSeparator />
|
|
716
|
+
<DropdownMenuCheckboxItem
|
|
717
|
+
checked={activeWorkspacePanels.has('bcf')}
|
|
718
|
+
onCheckedChange={() => handleToggleRightPanel('bcf')}
|
|
691
719
|
>
|
|
692
|
-
<
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
720
|
+
<MessageSquare className="h-4 w-4 mr-2" />
|
|
721
|
+
BCF Issues
|
|
722
|
+
</DropdownMenuCheckboxItem>
|
|
723
|
+
<DropdownMenuCheckboxItem
|
|
724
|
+
checked={activeWorkspacePanels.has('ids')}
|
|
725
|
+
onCheckedChange={() => handleToggleRightPanel('ids')}
|
|
726
|
+
>
|
|
727
|
+
<ClipboardCheck className="h-4 w-4 mr-2" />
|
|
728
|
+
IDS Validation
|
|
729
|
+
</DropdownMenuCheckboxItem>
|
|
730
|
+
<DropdownMenuCheckboxItem
|
|
731
|
+
checked={activeWorkspacePanels.has('lens')}
|
|
732
|
+
onCheckedChange={() => handleToggleRightPanel('lens')}
|
|
733
|
+
>
|
|
734
|
+
<Palette className="h-4 w-4 mr-2" />
|
|
735
|
+
Lens Rules
|
|
736
|
+
</DropdownMenuCheckboxItem>
|
|
737
|
+
</DropdownMenuContent>
|
|
738
|
+
</DropdownMenu>
|
|
697
739
|
|
|
698
740
|
<Separator orientation="vertical" className="h-6 mx-1" />
|
|
699
741
|
|
|
@@ -821,30 +863,6 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
|
|
|
821
863
|
</DropdownMenuContent>
|
|
822
864
|
</DropdownMenu>
|
|
823
865
|
|
|
824
|
-
{/* Lens (rule-based filtering) */}
|
|
825
|
-
<Tooltip>
|
|
826
|
-
<TooltipTrigger asChild>
|
|
827
|
-
<Button
|
|
828
|
-
variant={lensPanelVisible ? 'default' : 'ghost'}
|
|
829
|
-
size="icon-sm"
|
|
830
|
-
onClick={(e) => {
|
|
831
|
-
(e.currentTarget as HTMLButtonElement).blur();
|
|
832
|
-
if (!lensPanelVisible) {
|
|
833
|
-
// Close other right-panel content first, then expand
|
|
834
|
-
setBcfPanelVisible(false);
|
|
835
|
-
setIdsPanelVisible(false);
|
|
836
|
-
setRightPanelCollapsed(false);
|
|
837
|
-
}
|
|
838
|
-
toggleLensPanel();
|
|
839
|
-
}}
|
|
840
|
-
className={cn(lensPanelVisible && 'bg-primary text-primary-foreground')}
|
|
841
|
-
>
|
|
842
|
-
<Palette className="h-4 w-4" />
|
|
843
|
-
</Button>
|
|
844
|
-
</TooltipTrigger>
|
|
845
|
-
<TooltipContent>Lens (Color Rules)</TooltipContent>
|
|
846
|
-
</Tooltip>
|
|
847
|
-
|
|
848
866
|
<Separator orientation="vertical" className="h-6 mx-1" />
|
|
849
867
|
|
|
850
868
|
{/* ── Camera & View ── */}
|
|
@@ -9,6 +9,7 @@ import { ToolOverlays } from './ToolOverlays';
|
|
|
9
9
|
import { Section2DPanel } from './Section2DPanel';
|
|
10
10
|
import { BasketPresentationDock } from './BasketPresentationDock';
|
|
11
11
|
import { useViewerStore } from '@/store';
|
|
12
|
+
import { collectIfcBuildingStoreyElementsWithIfcSpace } from '@/store/basketVisibleSet';
|
|
12
13
|
import { useIfc } from '@/hooks/useIfc';
|
|
13
14
|
import { useWebGPU } from '@/hooks/useWebGPU';
|
|
14
15
|
import { Upload, MousePointer, Layers, Info, Command, AlertTriangle, ChevronDown, ExternalLink, Plus } from 'lucide-react';
|
|
@@ -27,6 +28,7 @@ export function ViewportContainer() {
|
|
|
27
28
|
const selectedStoreys = useViewerStore((s) => s.selectedStoreys);
|
|
28
29
|
const typeVisibility = useViewerStore((s) => s.typeVisibility);
|
|
29
30
|
const isolatedEntities = useViewerStore((s) => s.isolatedEntities);
|
|
31
|
+
const classFilter = useViewerStore((s) => s.classFilter);
|
|
30
32
|
// Multi-model support: get all loaded models from store (for merged geometry)
|
|
31
33
|
const storeModels = useViewerStore((s) => s.models);
|
|
32
34
|
const resetViewerState = useViewerStore((s) => s.resetViewerState);
|
|
@@ -251,15 +253,8 @@ export function ViewportContainer() {
|
|
|
251
253
|
// Now supports multi-model: aggregates elements from all models for selected storeys
|
|
252
254
|
// IMPORTANT: Returns globalIds (meshes use globalIds after federation registry transformation)
|
|
253
255
|
const computedIsolatedIds = useMemo(() => {
|
|
254
|
-
//
|
|
255
|
-
|
|
256
|
-
return isolatedEntities;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// If storeys are selected, compute combined element IDs from all selected storeys
|
|
260
|
-
// across ALL models (multi-model support)
|
|
261
|
-
// NOTE: Storey hierarchy uses original expressIds, but meshes use globalIds
|
|
262
|
-
// We must transform expressIds -> globalIds using the model's offset
|
|
256
|
+
// Compute storey isolation if storeys are selected
|
|
257
|
+
let storeyIsolation: Set<number> | null = null;
|
|
263
258
|
if (selectedStoreys.size > 0) {
|
|
264
259
|
const combinedGlobalIds = new Set<number>();
|
|
265
260
|
|
|
@@ -268,46 +263,56 @@ export function ViewportContainer() {
|
|
|
268
263
|
const hierarchy = model.ifcDataStore?.spatialHierarchy;
|
|
269
264
|
if (!hierarchy) continue;
|
|
270
265
|
|
|
271
|
-
// Get this model's offset directly from the model (no need for registry)
|
|
272
266
|
const offset = model.idOffset ?? 0;
|
|
273
267
|
|
|
274
268
|
for (const storeyId of selectedStoreys) {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
// For now, try both the storeyId and storeyId - offset
|
|
278
|
-
const storeyElementIds = hierarchy.byStorey.get(storeyId) || hierarchy.byStorey.get(storeyId - offset);
|
|
269
|
+
const localStoreyId = hierarchy.byStorey.has(storeyId) ? storeyId : storeyId - offset;
|
|
270
|
+
const storeyElementIds = collectIfcBuildingStoreyElementsWithIfcSpace(hierarchy, localStoreyId);
|
|
279
271
|
if (storeyElementIds) {
|
|
280
272
|
for (const originalExpressId of storeyElementIds) {
|
|
281
|
-
|
|
282
|
-
const globalId = originalExpressId + offset;
|
|
283
|
-
combinedGlobalIds.add(globalId);
|
|
273
|
+
combinedGlobalIds.add(originalExpressId + offset);
|
|
284
274
|
}
|
|
285
275
|
}
|
|
286
276
|
}
|
|
287
277
|
}
|
|
288
278
|
|
|
289
|
-
//
|
|
290
|
-
// In this case, offset is 0, so globalId = expressId
|
|
279
|
+
// Legacy single-model mode (offset = 0)
|
|
291
280
|
if (ifcDataStore?.spatialHierarchy && storeModels.size === 0) {
|
|
292
281
|
const hierarchy = ifcDataStore.spatialHierarchy;
|
|
293
282
|
for (const storeyId of selectedStoreys) {
|
|
294
|
-
const storeyElementIds = hierarchy
|
|
283
|
+
const storeyElementIds = collectIfcBuildingStoreyElementsWithIfcSpace(hierarchy, storeyId);
|
|
295
284
|
if (storeyElementIds) {
|
|
296
285
|
for (const id of storeyElementIds) {
|
|
297
|
-
combinedGlobalIds.add(id);
|
|
286
|
+
combinedGlobalIds.add(id);
|
|
298
287
|
}
|
|
299
288
|
}
|
|
300
289
|
}
|
|
301
290
|
}
|
|
302
291
|
|
|
303
292
|
if (combinedGlobalIds.size > 0) {
|
|
304
|
-
|
|
293
|
+
storeyIsolation = combinedGlobalIds;
|
|
305
294
|
}
|
|
306
295
|
}
|
|
307
296
|
|
|
308
|
-
//
|
|
309
|
-
|
|
310
|
-
|
|
297
|
+
// Collect all active filters and intersect them
|
|
298
|
+
const filters: Set<number>[] = [];
|
|
299
|
+
if (storeyIsolation !== null) filters.push(storeyIsolation);
|
|
300
|
+
if (classFilter !== null) filters.push(classFilter.ids);
|
|
301
|
+
if (isolatedEntities !== null) filters.push(isolatedEntities);
|
|
302
|
+
|
|
303
|
+
if (filters.length === 0) return null;
|
|
304
|
+
if (filters.length === 1) return filters[0];
|
|
305
|
+
|
|
306
|
+
// Intersect all active filters — start from smallest for efficiency
|
|
307
|
+
const sorted = filters.sort((a, b) => a.size - b.size);
|
|
308
|
+
const intersection = new Set<number>();
|
|
309
|
+
for (const id of sorted[0]) {
|
|
310
|
+
if (sorted.every(s => s.has(id))) {
|
|
311
|
+
intersection.add(id);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return intersection;
|
|
315
|
+
}, [storeModels, ifcDataStore, selectedStoreys, isolatedEntities, classFilter]);
|
|
311
316
|
|
|
312
317
|
// Grid Pattern
|
|
313
318
|
const GridPattern = () => (
|
|
@@ -68,7 +68,8 @@ export function HierarchyNode({
|
|
|
68
68
|
onRemoveModel,
|
|
69
69
|
onModelHeaderClick,
|
|
70
70
|
}: HierarchyNodeProps) {
|
|
71
|
-
const
|
|
71
|
+
const resolvedType = node.ifcType || node.type;
|
|
72
|
+
const Icon = TYPE_ICONS[resolvedType] || TYPE_ICONS[node.type] || TYPE_ICONS.default;
|
|
72
73
|
|
|
73
74
|
// Model header nodes (for visibility control and expansion)
|
|
74
75
|
if (node.type === 'model-header' && node.id.startsWith('model-')) {
|
|
@@ -261,19 +262,25 @@ export function HierarchyNode({
|
|
|
261
262
|
<Icon className="h-3.5 w-3.5 shrink-0 text-zinc-500 dark:text-zinc-400" />
|
|
262
263
|
</TooltipTrigger>
|
|
263
264
|
<TooltipContent>
|
|
264
|
-
<p className="text-xs">{
|
|
265
|
+
<p className="text-xs">{resolvedType}</p>
|
|
265
266
|
</TooltipContent>
|
|
266
267
|
</Tooltip>
|
|
267
268
|
|
|
268
269
|
{/* Name */}
|
|
269
270
|
<span className={cn(
|
|
270
271
|
'flex-1 text-sm truncate ml-1.5',
|
|
271
|
-
isSpatialContainer(node.type) || node.type === 'IfcBuildingStorey' || node.type === 'unified-storey' || node.type === 'type-group'
|
|
272
|
+
isSpatialContainer(node.type) || node.type === 'IfcBuildingStorey' || node.type === 'IfcSpace' || node.type === 'unified-storey' || node.type === 'type-group'
|
|
272
273
|
? 'font-medium text-zinc-900 dark:text-zinc-100'
|
|
273
274
|
: 'text-zinc-700 dark:text-zinc-300',
|
|
274
275
|
nodeHidden && 'line-through decoration-zinc-400 dark:decoration-zinc-600'
|
|
275
276
|
)}>{node.name}</span>
|
|
276
277
|
|
|
278
|
+
{node.ifcType && node.type === 'element' && (
|
|
279
|
+
<span className="text-[10px] font-mono text-zinc-400 dark:text-zinc-500 truncate max-w-[90px]">
|
|
280
|
+
{node.ifcType}
|
|
281
|
+
</span>
|
|
282
|
+
)}
|
|
283
|
+
|
|
277
284
|
{/* Storey Elevation */}
|
|
278
285
|
{node.storeyElevation !== undefined && (
|
|
279
286
|
<Tooltip>
|
|
@@ -0,0 +1,126 @@
|
|
|
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
|
+
import { describe, it } from 'node:test';
|
|
6
|
+
import assert from 'node:assert';
|
|
7
|
+
import { IfcTypeEnum, type SpatialHierarchy, type SpatialNode } from '@ifc-lite/data';
|
|
8
|
+
import type { IfcDataStore } from '@ifc-lite/parser';
|
|
9
|
+
import { useViewerStore, type FederatedModel } from '@/store';
|
|
10
|
+
import { buildTreeData } from './treeDataBuilder';
|
|
11
|
+
|
|
12
|
+
function createSpatialNode(
|
|
13
|
+
expressId: number,
|
|
14
|
+
type: IfcTypeEnum,
|
|
15
|
+
name: string,
|
|
16
|
+
children: SpatialNode[] = [],
|
|
17
|
+
): SpatialNode {
|
|
18
|
+
return {
|
|
19
|
+
expressId,
|
|
20
|
+
type,
|
|
21
|
+
name,
|
|
22
|
+
children,
|
|
23
|
+
elements: [],
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function createDataStore(): IfcDataStore {
|
|
28
|
+
const spaceNode = createSpatialNode(5, IfcTypeEnum.IfcSpace, 'e3035b71');
|
|
29
|
+
const storeyNode = createSpatialNode(4, IfcTypeEnum.IfcBuildingStorey, 'MY_STOREY', [spaceNode]);
|
|
30
|
+
const buildingNode = createSpatialNode(3, IfcTypeEnum.IfcBuilding, 'MY_BUILDING', [storeyNode]);
|
|
31
|
+
const siteNode = createSpatialNode(2, IfcTypeEnum.IfcSite, 'MY_SITE', [buildingNode]);
|
|
32
|
+
const projectNode = createSpatialNode(1, IfcTypeEnum.IfcProject, 'MY_PROJECT', [siteNode]);
|
|
33
|
+
|
|
34
|
+
const spatialHierarchy: SpatialHierarchy = {
|
|
35
|
+
project: projectNode,
|
|
36
|
+
byStorey: new Map([[4, [6, 7]]]),
|
|
37
|
+
byBuilding: new Map(),
|
|
38
|
+
bySite: new Map(),
|
|
39
|
+
bySpace: new Map([[5, [7]]]),
|
|
40
|
+
storeyElevations: new Map(),
|
|
41
|
+
storeyHeights: new Map(),
|
|
42
|
+
elementToStorey: new Map([[6, 4], [7, 4]]),
|
|
43
|
+
getStoreyElements: () => [],
|
|
44
|
+
getStoreyByElevation: () => null,
|
|
45
|
+
getContainingSpace: (elementId: number) => (elementId === 7 ? 5 : null),
|
|
46
|
+
getPath: () => [],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
spatialHierarchy,
|
|
51
|
+
entities: {
|
|
52
|
+
count: 0,
|
|
53
|
+
getName: (id: number) => {
|
|
54
|
+
if (id === 6) return 'Wall';
|
|
55
|
+
if (id === 7) return '';
|
|
56
|
+
return '';
|
|
57
|
+
},
|
|
58
|
+
getTypeName: (id: number) => {
|
|
59
|
+
if (id === 6) return 'IfcWall';
|
|
60
|
+
if (id === 7) return 'IfcWindow';
|
|
61
|
+
if (id === 5) return 'IfcSpace';
|
|
62
|
+
return 'Unknown';
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
} as unknown as IfcDataStore;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function createModel(idOffset: number): FederatedModel {
|
|
69
|
+
return {
|
|
70
|
+
id: 'model-1',
|
|
71
|
+
name: 'Model 1',
|
|
72
|
+
ifcDataStore: createDataStore(),
|
|
73
|
+
geometryResult: { meshes: [], totalVertices: 0, totalTriangles: 0, coordinateInfo: null as never },
|
|
74
|
+
visible: true,
|
|
75
|
+
collapsed: false,
|
|
76
|
+
schemaVersion: 'IFC4',
|
|
77
|
+
loadedAt: 1,
|
|
78
|
+
fileSize: 1,
|
|
79
|
+
idOffset,
|
|
80
|
+
maxExpressId: 7,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe('buildTreeData', () => {
|
|
85
|
+
it('keeps IfcSpace as a spatial node, expands bySpace children, and avoids storey duplicates', () => {
|
|
86
|
+
useViewerStore.setState({ models: new Map() });
|
|
87
|
+
useViewerStore.getState().registerModelOffset('tree-test-padding', 99);
|
|
88
|
+
const idOffset = useViewerStore.getState().registerModelOffset('model-1', 7);
|
|
89
|
+
const model = createModel(idOffset);
|
|
90
|
+
useViewerStore.setState({ models: new Map([['model-1', model]]) });
|
|
91
|
+
|
|
92
|
+
const models = new Map<string, FederatedModel>([['model-1', model]]);
|
|
93
|
+
const expandedNodes = new Set([
|
|
94
|
+
'root-1',
|
|
95
|
+
'root-1-2',
|
|
96
|
+
'root-1-2-3',
|
|
97
|
+
'root-1-2-3-4',
|
|
98
|
+
'root-1-2-3-4-5',
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
const nodes = buildTreeData(models, null, expandedNodes, false, []);
|
|
102
|
+
|
|
103
|
+
const storeyNode = nodes.find((node) => node.id === 'root-1-2-3-4');
|
|
104
|
+
assert.ok(storeyNode);
|
|
105
|
+
assert.strictEqual(storeyNode.elementCount, 1);
|
|
106
|
+
|
|
107
|
+
const spaceNode = nodes.find((node) => node.id === 'root-1-2-3-4-5');
|
|
108
|
+
assert.ok(spaceNode);
|
|
109
|
+
assert.strictEqual(spaceNode.type, 'IfcSpace');
|
|
110
|
+
assert.deepStrictEqual(spaceNode.expressIds, [5]);
|
|
111
|
+
assert.deepStrictEqual(spaceNode.globalIds, [105]);
|
|
112
|
+
assert.strictEqual(spaceNode.elementCount, 1);
|
|
113
|
+
assert.strictEqual(spaceNode.hasChildren, true);
|
|
114
|
+
|
|
115
|
+
const windowNode = nodes.find((node) => node.id === 'element-model-1-7');
|
|
116
|
+
assert.ok(windowNode);
|
|
117
|
+
assert.strictEqual(windowNode.type, 'element');
|
|
118
|
+
assert.strictEqual(windowNode.ifcType, 'IfcWindow');
|
|
119
|
+
assert.deepStrictEqual(windowNode.expressIds, [7]);
|
|
120
|
+
assert.deepStrictEqual(windowNode.globalIds, [107]);
|
|
121
|
+
assert.strictEqual(windowNode.name, 'IfcWindow #7');
|
|
122
|
+
|
|
123
|
+
assert.strictEqual(nodes.filter((node) => node.id === 'element-model-1-6').length, 1);
|
|
124
|
+
assert.strictEqual(nodes.filter((node) => node.id === 'element-model-1-7').length, 1);
|
|
125
|
+
});
|
|
126
|
+
});
|