@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.
Files changed (29) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/assets/{Arrow.dom-BgkZDIQm.js → Arrow.dom-OVBBPqOB.js} +1 -1
  3. package/dist/assets/{basketViewActivator-h_M3YbMW.js → basketViewActivator-Bx6QU4ma.js} +1 -1
  4. package/dist/assets/{browser-CRQ0bPh1.js → browser-BMqEoJw4.js} +1 -1
  5. package/dist/assets/ifc-lite_bg-CyWQTvp5.wasm +0 -0
  6. package/dist/assets/index-CJr7Itua.css +1 -0
  7. package/dist/assets/index-DZY6uD8A.js +185948 -0
  8. package/dist/assets/{index-C4VVJRL-.js → index-DsX-NCtx.js} +4 -4
  9. package/dist/assets/{native-bridge-DtcJqlOi.js → native-bridge-D6tKFqGO.js} +1 -1
  10. package/dist/assets/{wasm-bridge-BJJVu9P2.js → wasm-bridge-D4kvZVDw.js} +1 -1
  11. package/dist/index.html +2 -2
  12. package/package.json +7 -7
  13. package/src/components/viewer/CommandPalette.tsx +1 -0
  14. package/src/components/viewer/ExportDialog.tsx +40 -2
  15. package/src/components/viewer/HierarchyPanel.tsx +127 -35
  16. package/src/components/viewer/MainToolbar.tsx +113 -95
  17. package/src/components/viewer/ViewportContainer.tsx +30 -25
  18. package/src/components/viewer/hierarchy/HierarchyNode.tsx +10 -3
  19. package/src/components/viewer/hierarchy/treeDataBuilder.test.ts +126 -0
  20. package/src/components/viewer/hierarchy/treeDataBuilder.ts +139 -38
  21. package/src/components/viewer/hierarchy/types.ts +6 -1
  22. package/src/components/viewer/hierarchy/useHierarchyTree.ts +27 -12
  23. package/src/sdk/adapters/visibility-adapter.ts +82 -2
  24. package/src/store/basketVisibleSet.ts +72 -4
  25. package/src/store/index.ts +11 -1
  26. package/src/store/slices/visibilitySlice.ts +28 -2
  27. package/dist/assets/ifc-lite_bg-DyBKoGgk.wasm +0 -0
  28. package/dist/assets/index-Be6XjVeM.js +0 -116717
  29. 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 toggleListPanel = useViewerStore((state) => state.toggleListPanel);
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
- {/* BCF Issues Button */}
628
- <Tooltip>
629
- <TooltipTrigger asChild>
630
- <Button
631
- variant={bcfPanelVisible ? 'default' : 'ghost'}
632
- size="icon-sm"
633
- onClick={(e) => {
634
- (e.currentTarget as HTMLButtonElement).blur();
635
- if (!bcfPanelVisible) {
636
- // Close other right-panel content first, then expand
637
- setIdsPanelVisible(false);
638
- setLensPanelVisible(false);
639
- setRightPanelCollapsed(false);
640
- }
641
- toggleBcfPanel();
642
- }}
643
- className={cn(bcfPanelVisible && 'bg-primary text-primary-foreground')}
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
- <MessageSquare className="h-4 w-4" />
646
- </Button>
647
- </TooltipTrigger>
648
- <TooltipContent>BCF Issues</TooltipContent>
649
- </Tooltip>
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
- <ClipboardCheck className="h-4 w-4" />
670
- </Button>
671
- </TooltipTrigger>
672
- <TooltipContent>IDS Validation</TooltipContent>
673
- </Tooltip>
674
-
675
- {/* Lists Button */}
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
- <FileSpreadsheet className="h-4 w-4" />
693
- </Button>
694
- </TooltipTrigger>
695
- <TooltipContent>Lists</TooltipContent>
696
- </Tooltip>
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
- // If manual isolation is active, use that (already contains globalIds)
255
- if (isolatedEntities !== null) {
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
- // Note: storeyId itself might be a globalId if the user selected via mesh click,
276
- // or an original ID if selected via hierarchy panel. The byStorey map uses original IDs.
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
- // Transform to globalId
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
- // Also check legacy ifcDataStore (for single-model mode without federation)
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.byStorey.get(storeyId);
283
+ const storeyElementIds = collectIfcBuildingStoreyElementsWithIfcSpace(hierarchy, storeyId);
295
284
  if (storeyElementIds) {
296
285
  for (const id of storeyElementIds) {
297
- combinedGlobalIds.add(id); // offset = 0 for legacy single-model
286
+ combinedGlobalIds.add(id);
298
287
  }
299
288
  }
300
289
  }
301
290
  }
302
291
 
303
292
  if (combinedGlobalIds.size > 0) {
304
- return combinedGlobalIds;
293
+ storeyIsolation = combinedGlobalIds;
305
294
  }
306
295
  }
307
296
 
308
- // No isolation active
309
- return null;
310
- }, [storeModels, ifcDataStore, selectedStoreys, isolatedEntities]);
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 Icon = TYPE_ICONS[node.type] || TYPE_ICONS.default;
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">{node.type}</p>
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
+ });