@ifc-lite/viewer 1.14.3 → 1.14.4

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.
@@ -1,4 +1,4 @@
1
- import { _ as c, __tla as __tla_0 } from "./index-Be6XjVeM.js";
1
+ import { _ as c, __tla as __tla_0 } from "./index-DX-Qf5fA.js";
2
2
  let m;
3
3
  let __tla = Promise.all([
4
4
  (()=>{
@@ -1 +1 @@
1
- import{I as f,a as m}from"./index-Be6XjVeM.js";class u{bridge;initialized=!1;constructor(){this.bridge=new f}async init(){this.initialized||(await this.bridge.init(),this.initialized=!0)}isInitialized(){return this.initialized}async processGeometry(s){this.initialized||await this.init(),performance.now();const i=new m(this.bridge.getApi(),s),n=i.collectMeshes(),r=i.getBuildingRotation();performance.now();let e=0,o=0;for(const c of n)e+=c.positions.length/3,o+=c.indices.length/3;return{meshes:n,totalVertices:e,totalTriangles:o,coordinateInfo:{originShift:{x:0,y:0,z:0},originalBounds:{min:{x:0,y:0,z:0},max:{x:0,y:0,z:0}},shiftedBounds:{min:{x:0,y:0,z:0},max:{x:0,y:0,z:0}},hasLargeCoordinates:!1,buildingRotation:r}}}async processGeometryStreaming(s,i){this.initialized||await this.init();const n=performance.now(),r=new m(this.bridge.getApi(),s);let e=0,o=0,a=0;try{for await(const t of r.collectMeshesStreaming(50)){if(t&&typeof t=="object"&&"type"in t&&t.type==="colorUpdate")continue;const l=t;e+=l.length;for(const d of l)o+=d.positions.length/3,a+=d.indices.length/3;i.onBatch?.({meshes:l,progress:{processed:e,total:e,currentType:"processing"}})}}catch(t){throw i.onError?.(t instanceof Error?t:new Error(String(t))),t}const h=performance.now()-n,g={totalMeshes:e,totalVertices:o,totalTriangles:a,parseTimeMs:h*.3,geometryTimeMs:h*.7};return i.onComplete?.(g),g}getApi(){return this.bridge.getApi()}}export{u as WasmBridge};
1
+ import{I as f,a as m}from"./index-DX-Qf5fA.js";class u{bridge;initialized=!1;constructor(){this.bridge=new f}async init(){this.initialized||(await this.bridge.init(),this.initialized=!0)}isInitialized(){return this.initialized}async processGeometry(s){this.initialized||await this.init(),performance.now();const i=new m(this.bridge.getApi(),s),n=i.collectMeshes(),r=i.getBuildingRotation();performance.now();let e=0,o=0;for(const c of n)e+=c.positions.length/3,o+=c.indices.length/3;return{meshes:n,totalVertices:e,totalTriangles:o,coordinateInfo:{originShift:{x:0,y:0,z:0},originalBounds:{min:{x:0,y:0,z:0},max:{x:0,y:0,z:0}},shiftedBounds:{min:{x:0,y:0,z:0},max:{x:0,y:0,z:0}},hasLargeCoordinates:!1,buildingRotation:r}}}async processGeometryStreaming(s,i){this.initialized||await this.init();const n=performance.now(),r=new m(this.bridge.getApi(),s);let e=0,o=0,a=0;try{for await(const t of r.collectMeshesStreaming(50)){if(t&&typeof t=="object"&&"type"in t&&t.type==="colorUpdate")continue;const l=t;e+=l.length;for(const d of l)o+=d.positions.length/3,a+=d.indices.length/3;i.onBatch?.({meshes:l,progress:{processed:e,total:e,currentType:"processing"}})}}catch(t){throw i.onError?.(t instanceof Error?t:new Error(String(t))),t}const h=performance.now()-n,g={totalMeshes:e,totalVertices:o,totalTriangles:a,parseTimeMs:h*.3,geometryTimeMs:h*.7};return i.onComplete?.(g),g}getApi(){return this.bridge.getApi()}}export{u as WasmBridge};
package/dist/index.html CHANGED
@@ -44,8 +44,8 @@
44
44
  <meta name="theme-color" content="#7aa2f7">
45
45
  <meta name="msapplication-TileColor" content="#1a1b26">
46
46
  <meta name="msapplication-TileImage" content="/favicon-192x192-cropped.png">
47
- <script type="module" crossorigin src="/assets/index-Be6XjVeM.js"></script>
48
- <link rel="stylesheet" crossorigin href="/assets/index-DdwD4c-E.css">
47
+ <script type="module" crossorigin src="/assets/index-DX-Qf5fA.js"></script>
48
+ <link rel="stylesheet" crossorigin href="/assets/index-CMQ_Dgkr.css">
49
49
  </head>
50
50
  <body>
51
51
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ifc-lite/viewer",
3
- "version": "1.14.3",
3
+ "version": "1.14.4",
4
4
  "description": "IFC-Lite viewer application",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -47,18 +47,18 @@
47
47
  "@ifc-lite/data": "^1.14.3",
48
48
  "@ifc-lite/drawing-2d": "^1.14.3",
49
49
  "@ifc-lite/encoding": "^1.14.3",
50
- "@ifc-lite/export": "^1.14.3",
50
+ "@ifc-lite/export": "^1.14.4",
51
51
  "@ifc-lite/geometry": "^1.14.3",
52
52
  "@ifc-lite/ids": "^1.14.3",
53
- "@ifc-lite/lens": "^1.14.3",
54
53
  "@ifc-lite/lists": "^1.14.3",
54
+ "@ifc-lite/lens": "^1.14.3",
55
55
  "@ifc-lite/mutations": "^1.14.3",
56
- "@ifc-lite/parser": "^1.14.3",
57
- "@ifc-lite/query": "^1.14.3",
56
+ "@ifc-lite/parser": "^2.0.0",
57
+ "@ifc-lite/query": "^1.14.4",
58
58
  "@ifc-lite/renderer": "^1.14.3",
59
- "@ifc-lite/sandbox": "^1.14.3",
60
59
  "@ifc-lite/server-client": "^1.14.3",
61
60
  "@ifc-lite/spatial": "^1.14.3",
61
+ "@ifc-lite/sandbox": "^1.14.3",
62
62
  "@ifc-lite/wasm": "^1.14.3"
63
63
  },
64
64
  "devDependencies": {
@@ -218,6 +218,7 @@ function activateBottomPanel(panel: 'script' | 'list') {
218
218
  s.setListPanelVisible(false);
219
219
 
220
220
  if (!isActive) {
221
+ s.setRightPanelCollapsed(false);
221
222
  if (panel === 'script') s.setScriptPanelVisible(true);
222
223
  else s.setListPanelVisible(true);
223
224
  }
@@ -319,27 +319,42 @@ export function HierarchyPanel() {
319
319
  setStoreysSelection(storeyIds);
320
320
  }
321
321
  }
322
+ } else if (node.type === 'IfcSpace') {
323
+ const spaceId = node.expressIds[0];
324
+ const modelId = node.modelIds[0];
325
+ const globalId = node.globalIds[0] ?? spaceId;
326
+
327
+ setSelectedEntityIds([]);
328
+
329
+ if (modelId && modelId !== 'legacy') {
330
+ setSelectedEntityId(globalId);
331
+ setSelectedEntity({ modelId, expressId: spaceId });
332
+ setActiveModel(modelId);
333
+ } else {
334
+ setSelectedEntityId(globalId);
335
+ setSelectedEntity({ modelId: 'legacy', expressId: spaceId });
336
+ }
337
+
338
+ if (node.hasChildren) {
339
+ toggleExpand(node.id);
340
+ }
322
341
  } else if (node.type === 'element') {
323
342
  // Element click - select it
324
- const elementId = node.expressIds[0]; // Original expressId
343
+ const elementId = node.expressIds[0];
325
344
  const modelId = node.modelIds[0];
345
+ const globalId = node.globalIds[0] ?? elementId;
326
346
 
327
347
  // Clear multi-selection (e.g. from a prior type-group click) so only
328
348
  // this single element is highlighted, matching Viewport pick behavior
329
349
  setSelectedEntityIds([]);
330
350
 
331
351
  if (modelId !== 'legacy') {
332
- // Multi-model: need to convert to globalId for renderer
333
- const model = models.get(modelId);
334
- const globalId = elementId + (model?.idOffset ?? 0);
335
352
  setSelectedEntityId(globalId);
336
353
  setSelectedEntity({ modelId, expressId: elementId });
337
354
  setActiveModel(modelId);
338
355
  } else {
339
- // Legacy single-model: expressId = globalId (offset is 0)
340
- setSelectedEntityId(elementId);
341
- // Also set selectedEntity for property panel (was missing, causing blank panel)
342
- setSelectedEntity(resolveEntityRef(elementId));
356
+ setSelectedEntityId(globalId);
357
+ setSelectedEntity(resolveEntityRef(globalId));
343
358
  }
344
359
  }
345
360
  }, [selectedStoreys, setStoreysSelection, clearStoreySelection, setSelectedEntityId, setSelectedEntityIds, setSelectedEntity, setSelectedEntities, setActiveModel, toggleExpand, unifiedStoreys, models, isolateEntities, getNodeElements, setHierarchyBasketSelection, toGlobalId]);
@@ -352,8 +367,8 @@ export function HierarchyPanel() {
352
367
  ? node.expressIds.some(id => selectedStoreys.has(id))
353
368
  : node.type === 'IfcBuildingStorey'
354
369
  ? selectedStoreys.has(node.expressIds[0])
355
- : node.type === 'element'
356
- ? selectedEntityId === node.expressIds[0]
370
+ : node.type === 'IfcSpace' || node.type === 'element'
371
+ ? selectedEntityId === (node.globalIds[0] ?? node.expressIds[0])
357
372
  : node.type === 'ifc-type'
358
373
  ? (() => {
359
374
  const typeExpressId = node.entityExpressId;
@@ -369,8 +384,8 @@ export function HierarchyPanel() {
369
384
  // Compute visibility inline - for elements check directly, for storeys use getNodeElements
370
385
  let nodeHidden = false;
371
386
  if (node.type === 'element') {
372
- nodeHidden = hiddenEntities.has(node.expressIds[0]);
373
- } else if (node.type === 'IfcBuildingStorey' || node.type === 'unified-storey' ||
387
+ nodeHidden = hiddenEntities.has(node.globalIds[0] ?? node.expressIds[0]);
388
+ } else if (node.type === 'IfcBuildingStorey' || node.type === 'IfcSpace' || node.type === 'unified-storey' ||
374
389
  node.type === 'type-group' || node.type === 'ifc-type' ||
375
390
  (node.type === 'model-header' && node.id.startsWith('contrib-'))) {
376
391
  const elements = getNodeElements(node);
@@ -385,7 +400,7 @@ export function HierarchyPanel() {
385
400
  }
386
401
 
387
402
  return { isSelected, nodeHidden, modelVisible };
388
- }, [selectedStoreys, selectedEntityId, hiddenEntities, getNodeElements, models]);
403
+ }, [selectedStoreys, selectedEntityId, hiddenEntities, getNodeElements, models, toGlobalId]);
389
404
 
390
405
  if (!ifcDataStore && models.size === 0) {
391
406
  return (
@@ -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 ── */}
@@ -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
+ });