@ifc-lite/viewer 1.14.4 → 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.
@@ -1,6 +1,6 @@
1
- const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/browser-Czmf34bo.js","assets/index-DX-Qf5fA.js","assets/index-CMQ_Dgkr.css"])))=>i.map(i=>d[i]);
2
- import { _ as u, b as _, d as M, __tla as __tla_0 } from "./index-DX-Qf5fA.js";
3
- import { N as Z, m as $, __tla as __tla_1 } from "./index-DX-Qf5fA.js";
1
+ const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/browser-BMqEoJw4.js","assets/index-DZY6uD8A.js","assets/index-CJr7Itua.css"])))=>i.map(i=>d[i]);
2
+ import { _ as u, b as _, d as M, __tla as __tla_0 } from "./index-DZY6uD8A.js";
3
+ import { N as Z, m as $, __tla as __tla_1 } from "./index-DZY6uD8A.js";
4
4
  let c, y, O, V, I, C, L;
5
5
  let __tla = Promise.all([
6
6
  (()=>{
@@ -91,7 +91,7 @@ let __tla = Promise.all([
91
91
  function D() {
92
92
  return m || (m = (async ()=>{
93
93
  try {
94
- const s = await u(()=>import("./browser-Czmf34bo.js").then((i)=>i.b), __vite__mapDeps([0,1,2])), t = s.default ?? s;
94
+ const s = await u(()=>import("./browser-BMqEoJw4.js").then((i)=>i.b), __vite__mapDeps([0,1,2])), t = s.default ?? s;
95
95
  let e;
96
96
  try {
97
97
  e = (await u(()=>import("./esbuild-COv63sf-.js"), [])).default;
@@ -1,4 +1,4 @@
1
- import { _ as c, __tla as __tla_0 } from "./index-DX-Qf5fA.js";
1
+ import { _ as c, __tla as __tla_0 } from "./index-DZY6uD8A.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-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};
1
+ import{I as f,a as m}from"./index-DZY6uD8A.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-DX-Qf5fA.js"></script>
48
- <link rel="stylesheet" crossorigin href="/assets/index-CMQ_Dgkr.css">
47
+ <script type="module" crossorigin src="/assets/index-DZY6uD8A.js"></script>
48
+ <link rel="stylesheet" crossorigin href="/assets/index-CJr7Itua.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.4",
3
+ "version": "1.15.0",
4
4
  "description": "IFC-Lite viewer application",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -46,19 +46,19 @@
46
46
  "@ifc-lite/cache": "^1.14.3",
47
47
  "@ifc-lite/data": "^1.14.3",
48
48
  "@ifc-lite/drawing-2d": "^1.14.3",
49
- "@ifc-lite/encoding": "^1.14.3",
50
- "@ifc-lite/export": "^1.14.4",
49
+ "@ifc-lite/encoding": "^1.14.4",
50
+ "@ifc-lite/export": "^1.15.0",
51
51
  "@ifc-lite/geometry": "^1.14.3",
52
- "@ifc-lite/ids": "^1.14.3",
53
- "@ifc-lite/lists": "^1.14.3",
52
+ "@ifc-lite/ids": "^1.14.4",
54
53
  "@ifc-lite/lens": "^1.14.3",
54
+ "@ifc-lite/lists": "^1.14.4",
55
55
  "@ifc-lite/mutations": "^1.14.3",
56
- "@ifc-lite/parser": "^2.0.0",
56
+ "@ifc-lite/parser": "^2.1.0",
57
57
  "@ifc-lite/query": "^1.14.4",
58
58
  "@ifc-lite/renderer": "^1.14.3",
59
+ "@ifc-lite/sandbox": "^1.14.3",
59
60
  "@ifc-lite/server-client": "^1.14.3",
60
61
  "@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": {
@@ -51,7 +51,7 @@ import {
51
51
  import { useViewerStore } from '@/store';
52
52
  import { configureMutationView } from '@/utils/configureMutationView';
53
53
  import { toast } from '@/components/ui/toast';
54
- import { StepExporter, MergedExporter, Ifc5Exporter, type MergeModelInput } from '@ifc-lite/export';
54
+ import { StepExporter, MergedExporter, Ifc5Exporter, IFC5_KNOWN_PROP_NAMES, type MergeModelInput } from '@ifc-lite/export';
55
55
  import { MutablePropertyView } from '@ifc-lite/mutations';
56
56
  import type { IfcDataStore } from '@ifc-lite/parser';
57
57
 
@@ -84,6 +84,7 @@ export function ExportDialog({ trigger }: ExportDialogProps) {
84
84
  const [applyMutations, setApplyMutations] = useState(true);
85
85
  const [changesOnly, setChangesOnly] = useState(false);
86
86
  const [visibleOnly, setVisibleOnly] = useState(false);
87
+ const [onlyKnownProperties, setOnlyKnownProperties] = useState(true);
87
88
  const [isExporting, setIsExporting] = useState(false);
88
89
  const [exportResult, setExportResult] = useState<{ success: boolean; message: string } | null>(null);
89
90
 
@@ -244,6 +245,29 @@ export function ExportDialog({ trigger }: ExportDialogProps) {
244
245
  return localIds.size > 0 ? localIds : null;
245
246
  }, [models, isolatedEntities, isolatedEntitiesByModel]);
246
247
 
248
+ // Detect if the model has properties that would be filtered by onlyKnownProperties.
249
+ // Only relevant for IFC5 exports — show the toggle only when there's something to filter.
250
+ const hasFilterableProperties = useMemo(() => {
251
+ if (!isIfc5 || !selectedModel?.ifcDataStore) return false;
252
+ const mutationView = getMutationView(selectedModelId);
253
+ const propSource = mutationView || selectedModel.ifcDataStore.properties;
254
+ if (!propSource) return false;
255
+
256
+ // Sample a few entities to check for unknown property names
257
+ const entities = selectedModel.ifcDataStore.entities;
258
+ const limit = Math.min(entities.count, 50);
259
+ for (let i = 0; i < limit; i++) {
260
+ const id = entities.expressId[i];
261
+ const psets = propSource.getForEntity(id);
262
+ for (const pset of psets) {
263
+ for (const prop of pset.properties) {
264
+ if (!IFC5_KNOWN_PROP_NAMES.has(prop.name)) return true;
265
+ }
266
+ }
267
+ }
268
+ return false;
269
+ }, [isIfc5, selectedModel, selectedModelId, getMutationView]);
270
+
247
271
  // Compute output format description for UI
248
272
  const outputInfo = useMemo(() => {
249
273
  if (changesOnly) {
@@ -356,6 +380,7 @@ export function ExportDialog({ trigger }: ExportDialogProps) {
356
380
  visibleOnly: effectiveVisibleOnly,
357
381
  hiddenEntityIds: localHidden,
358
382
  isolatedEntityIds: localIsolated,
383
+ onlyKnownProperties,
359
384
  author: 'ifc-lite',
360
385
  });
361
386
 
@@ -440,7 +465,7 @@ export function ExportDialog({ trigger }: ExportDialogProps) {
440
465
  } finally {
441
466
  setIsExporting(false);
442
467
  }
443
- }, [selectedModel, selectedModelId, schema, isIfc5, exportScope, includeGeometry, applyMutations, changesOnly, visibleOnly, getMutationView, getLocalHiddenIds, getLocalIsolatedIds, modifiedCount, models]);
468
+ }, [selectedModel, selectedModelId, schema, isIfc5, exportScope, includeGeometry, applyMutations, changesOnly, visibleOnly, onlyKnownProperties, getMutationView, getLocalHiddenIds, getLocalIsolatedIds, modifiedCount, models]);
444
469
 
445
470
  return (
446
471
  <Dialog open={open} onOpenChange={setOpen}>
@@ -583,6 +608,19 @@ export function ExportDialog({ trigger }: ExportDialogProps) {
583
608
  </div>
584
609
  )}
585
610
 
611
+ {/* IFC5: strict property schema filtering */}
612
+ {isIfc5 && hasFilterableProperties && (
613
+ <div className="flex items-center justify-between">
614
+ <div>
615
+ <Label>Only Known IFC5 Properties</Label>
616
+ <p className="text-xs text-muted-foreground">
617
+ Skip properties without an official IFC5 schema (avoids viewer warnings)
618
+ </p>
619
+ </div>
620
+ <Switch checked={onlyKnownProperties} onCheckedChange={setOnlyKnownProperties} />
621
+ </div>
622
+ )}
623
+
586
624
  {/* Stats */}
587
625
  {modifiedCount > 0 && (
588
626
  <Alert>
@@ -2,7 +2,7 @@
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 { useState, useCallback, useRef, useEffect } from 'react';
5
+ import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
6
6
  import { useVirtualizer } from '@tanstack/react-virtual';
7
7
  import {
8
8
  Search,
@@ -46,6 +46,12 @@ export function HierarchyPanel() {
46
46
  const setStoreysSelection = useViewerStore((s) => s.setStoreysSelection);
47
47
  const clearStoreySelection = useViewerStore((s) => s.clearStoreySelection);
48
48
  const isolateEntities = useViewerStore((s) => s.isolateEntities);
49
+ const isolatedEntities = useViewerStore((s) => s.isolatedEntities);
50
+ const clearIsolation = useViewerStore((s) => s.clearIsolation);
51
+ const classFilter = useViewerStore((s) => s.classFilter);
52
+ const setClassFilter = useViewerStore((s) => s.setClassFilter);
53
+ const clearClassFilter = useViewerStore((s) => s.clearClassFilter);
54
+ const clearAllFilters = useViewerStore((s) => s.clearAllFilters);
49
55
  const setHierarchyBasketSelection = useViewerStore((s) => s.setHierarchyBasketSelection);
50
56
 
51
57
  const hiddenEntities = useViewerStore((s) => s.hiddenEntities);
@@ -54,6 +60,26 @@ export function HierarchyPanel() {
54
60
  const toggleEntityVisibility = useViewerStore((s) => s.toggleEntityVisibility);
55
61
  const clearSelection = useViewerStore((s) => s.clearSelection);
56
62
 
63
+ // Derive label for type isolation (from Type tab) by checking mesh ifcType
64
+ const typeIsolationLabel = useMemo(() => {
65
+ if (!isolatedEntities || isolatedEntities.size === 0) return null;
66
+ const sampleId = isolatedEntities.values().next().value!;
67
+ for (const [, model] of models) {
68
+ const gr = model.geometryResult;
69
+ if (!gr?.meshes) continue;
70
+ const offset = model.idOffset ?? 0;
71
+ const mesh = gr.meshes.find((m: { expressId: number }) => m.expressId + offset === sampleId);
72
+ if (mesh?.ifcType) return mesh.ifcType;
73
+ }
74
+ if (geometryResult?.meshes) {
75
+ const mesh = geometryResult.meshes.find((m: { expressId: number }) => m.expressId === sampleId);
76
+ if (mesh?.ifcType) return mesh.ifcType;
77
+ }
78
+ return `${isolatedEntities.size} elements`;
79
+ }, [isolatedEntities, models, geometryResult]);
80
+
81
+ const hasActiveFilters = selectedStoreys.size > 0 || isolatedEntities !== null || classFilter !== null;
82
+
57
83
  // Resizable panel split (percentage for storeys section, 0.5 = 50%)
58
84
  const [splitRatio, setSplitRatio] = useState(0.5);
59
85
  const [isDragging, setIsDragging] = useState(false);
@@ -198,15 +224,20 @@ export function HierarchyPanel() {
198
224
  }]);
199
225
  }
200
226
 
201
- // Type group nodes - click to isolate entities, expand via chevron only
227
+ // Type group nodes - click to filter/isolate entities, expand via chevron only
202
228
  if (node.type === 'type-group') {
203
229
  const elements = getNodeElements(node);
204
230
  if (elements.length > 0) {
205
- // Clear multi-selection highlight — isolate shows the class members,
206
- // but we don't want every element highlighted/selected
231
+ // Clear multi-selection highlight
207
232
  setSelectedEntityIds([]);
208
233
  setSelectedEntity(resolveEntityRef(elements[0]));
209
- isolateEntities(elements);
234
+ if (groupingMode === 'type') {
235
+ // Class tab → class filter (combinable with storey + type isolation)
236
+ setClassFilter(elements, node.ifcType || node.name);
237
+ } else {
238
+ // Type tab → type isolation (combinable with storey + class filter)
239
+ isolateEntities(elements);
240
+ }
210
241
  }
211
242
  return;
212
243
  }
@@ -357,7 +388,7 @@ export function HierarchyPanel() {
357
388
  setSelectedEntity(resolveEntityRef(globalId));
358
389
  }
359
390
  }
360
- }, [selectedStoreys, setStoreysSelection, clearStoreySelection, setSelectedEntityId, setSelectedEntityIds, setSelectedEntity, setSelectedEntities, setActiveModel, toggleExpand, unifiedStoreys, models, isolateEntities, getNodeElements, setHierarchyBasketSelection, toGlobalId]);
391
+ }, [selectedStoreys, setStoreysSelection, clearStoreySelection, setSelectedEntityId, setSelectedEntityIds, setSelectedEntity, setSelectedEntities, setActiveModel, toggleExpand, unifiedStoreys, models, isolateEntities, getNodeElements, setHierarchyBasketSelection, toGlobalId, groupingMode, setClassFilter]);
361
392
 
362
393
  // Compute selection and visibility state for a node
363
394
  const computeNodeState = useCallback((node: TreeNode): { isSelected: boolean; nodeHidden: boolean; modelVisible?: boolean } => {
@@ -551,21 +582,44 @@ export function HierarchyPanel() {
551
582
  </div>
552
583
 
553
584
  {/* Footer status */}
554
- {selectedStoreys.size > 0 ? (
585
+ {hasActiveFilters ? (
555
586
  <div className="p-2 border-t-2 border-zinc-200 dark:border-zinc-800 bg-primary text-white dark:bg-primary">
556
- <div className="flex items-center justify-between text-xs font-medium">
557
- <span className="uppercase tracking-wide">
558
- {selectedStoreys.size} {selectedStoreys.size === 1 ? 'STOREY' : 'STOREYS'} FILTERED
559
- </span>
560
- <div className="flex items-center gap-2">
587
+ <div className="flex items-center justify-between text-xs font-medium gap-2">
588
+ <div className="flex items-center gap-1.5 flex-wrap min-w-0">
589
+ {selectedStoreys.size > 0 && (
590
+ <span className="inline-flex items-center gap-1 bg-white/15 rounded px-1.5 py-0.5 text-[10px] uppercase tracking-wide">
591
+ {selectedStoreys.size} {selectedStoreys.size === 1 ? 'Storey' : 'Storeys'}
592
+ <button onClick={clearStoreySelection} className="ml-0.5 opacity-60 hover:opacity-100 text-xs leading-none" aria-label="Clear storey filter">&times;</button>
593
+ </span>
594
+ )}
595
+ {classFilter !== null && (
596
+ <>
597
+ {selectedStoreys.size > 0 && <span className="text-[10px] opacity-50">+</span>}
598
+ <span className="inline-flex items-center gap-1 bg-white/15 rounded px-1.5 py-0.5 text-[10px] uppercase tracking-wide">
599
+ {classFilter.label}
600
+ <button onClick={clearClassFilter} className="ml-0.5 opacity-60 hover:opacity-100 text-xs leading-none" aria-label="Clear class filter">&times;</button>
601
+ </span>
602
+ </>
603
+ )}
604
+ {isolatedEntities !== null && (
605
+ <>
606
+ {(selectedStoreys.size > 0 || classFilter !== null) && <span className="text-[10px] opacity-50">+</span>}
607
+ <span className="inline-flex items-center gap-1 bg-white/15 rounded px-1.5 py-0.5 text-[10px] uppercase tracking-wide">
608
+ {typeIsolationLabel}
609
+ <button onClick={clearIsolation} className="ml-0.5 opacity-60 hover:opacity-100 text-xs leading-none" aria-label="Clear type filter">&times;</button>
610
+ </span>
611
+ </>
612
+ )}
613
+ </div>
614
+ <div className="flex items-center gap-2 shrink-0">
561
615
  <span className="opacity-70 text-[10px] font-mono">ESC</span>
562
616
  <Button
563
617
  variant="ghost"
564
618
  size="sm"
565
619
  className="h-6 text-[10px] uppercase border border-white/20 hover:bg-white/20 hover:text-white rounded-none px-2"
566
- onClick={clearStoreySelection}
620
+ onClick={() => { clearStoreySelection(); clearAllFilters(); }}
567
621
  >
568
- Clear
622
+ Clear all
569
623
  </Button>
570
624
  </div>
571
625
  </div>
@@ -614,21 +668,44 @@ export function HierarchyPanel() {
614
668
  </div>
615
669
 
616
670
  {/* Footer status */}
617
- {selectedStoreys.size > 0 ? (
671
+ {hasActiveFilters ? (
618
672
  <div className="p-2 border-t-2 border-zinc-200 dark:border-zinc-800 bg-primary text-white dark:bg-primary">
619
- <div className="flex items-center justify-between text-xs font-medium">
620
- <span className="uppercase tracking-wide">
621
- {selectedStoreys.size} {selectedStoreys.size === 1 ? 'STOREY' : 'STOREYS'} FILTERED
622
- </span>
623
- <div className="flex items-center gap-2">
673
+ <div className="flex items-center justify-between text-xs font-medium gap-2">
674
+ <div className="flex items-center gap-1.5 flex-wrap min-w-0">
675
+ {selectedStoreys.size > 0 && (
676
+ <span className="inline-flex items-center gap-1 bg-white/15 rounded px-1.5 py-0.5 text-[10px] uppercase tracking-wide">
677
+ {selectedStoreys.size} {selectedStoreys.size === 1 ? 'Storey' : 'Storeys'}
678
+ <button onClick={clearStoreySelection} className="ml-0.5 opacity-60 hover:opacity-100 text-xs leading-none" aria-label="Clear storey filter">&times;</button>
679
+ </span>
680
+ )}
681
+ {classFilter !== null && (
682
+ <>
683
+ {selectedStoreys.size > 0 && <span className="text-[10px] opacity-50">+</span>}
684
+ <span className="inline-flex items-center gap-1 bg-white/15 rounded px-1.5 py-0.5 text-[10px] uppercase tracking-wide">
685
+ {classFilter.label}
686
+ <button onClick={clearClassFilter} className="ml-0.5 opacity-60 hover:opacity-100 text-xs leading-none" aria-label="Clear class filter">&times;</button>
687
+ </span>
688
+ </>
689
+ )}
690
+ {isolatedEntities !== null && (
691
+ <>
692
+ {(selectedStoreys.size > 0 || classFilter !== null) && <span className="text-[10px] opacity-50">+</span>}
693
+ <span className="inline-flex items-center gap-1 bg-white/15 rounded px-1.5 py-0.5 text-[10px] uppercase tracking-wide">
694
+ {typeIsolationLabel}
695
+ <button onClick={clearIsolation} className="ml-0.5 opacity-60 hover:opacity-100 text-xs leading-none" aria-label="Clear type filter">&times;</button>
696
+ </span>
697
+ </>
698
+ )}
699
+ </div>
700
+ <div className="flex items-center gap-2 shrink-0">
624
701
  <span className="opacity-70 text-[10px] font-mono">ESC</span>
625
702
  <Button
626
703
  variant="ghost"
627
704
  size="sm"
628
705
  className="h-6 text-[10px] uppercase border border-white/20 hover:bg-white/20 hover:text-white rounded-none px-2"
629
- onClick={clearStoreySelection}
706
+ onClick={() => { clearStoreySelection(); clearAllFilters(); }}
630
707
  >
631
- Clear
708
+ Clear all
632
709
  </Button>
633
710
  </div>
634
711
  </div>
@@ -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 = () => (
@@ -4,7 +4,84 @@
4
4
 
5
5
  import type { EntityRef, VisibilityBackendMethods } from '@ifc-lite/sdk';
6
6
  import type { StoreApi } from './types.js';
7
- import { getModelForRef } from './model-compat.js';
7
+ import { getModelForRef, type ModelLike } from './model-compat.js';
8
+ import { collectIfcBuildingStoreyElementsWithIfcSpace } from '../../store/basketVisibleSet.js';
9
+ import { IfcTypeEnum, type SpatialNode } from '@ifc-lite/data';
10
+
11
+ const SPATIAL_TYPES = new Set([
12
+ 'IfcBuildingStorey',
13
+ 'IfcBuilding',
14
+ 'IfcSite',
15
+ 'IfcProject',
16
+ ]);
17
+
18
+ function findDescendantNode(root: SpatialNode, expressId: number): SpatialNode | null {
19
+ const stack: SpatialNode[] = [root];
20
+ while (stack.length > 0) {
21
+ const node = stack.pop()!;
22
+ if (node.expressId === expressId) return node;
23
+ for (const child of node.children) {
24
+ stack.push(child);
25
+ }
26
+ }
27
+ return null;
28
+ }
29
+
30
+ function collectDescendantStoreyIds(node: SpatialNode): number[] {
31
+ const storeyIds: number[] = [];
32
+ const stack: SpatialNode[] = [node];
33
+ while (stack.length > 0) {
34
+ const current = stack.pop()!;
35
+ if (current.type === IfcTypeEnum.IfcBuildingStorey) {
36
+ storeyIds.push(current.expressId);
37
+ }
38
+ for (const child of current.children) {
39
+ stack.push(child);
40
+ }
41
+ }
42
+ return storeyIds;
43
+ }
44
+
45
+ /**
46
+ * If `ref` points to a spatial structure element (storey, building, etc.),
47
+ * expand it to the local expressIds of all contained elements.
48
+ * Otherwise return the original expressId as-is.
49
+ */
50
+ function expandSpatialRef(ref: EntityRef, model: ModelLike): number[] {
51
+ const dataStore = model.ifcDataStore;
52
+ const typeName = dataStore.entities.getTypeName(ref.expressId) || '';
53
+ if (!SPATIAL_TYPES.has(typeName)) return [ref.expressId];
54
+
55
+ const hierarchy = dataStore.spatialHierarchy;
56
+ if (!hierarchy) return [ref.expressId];
57
+
58
+ if (typeName === 'IfcBuildingStorey') {
59
+ const ids = collectIfcBuildingStoreyElementsWithIfcSpace(hierarchy, ref.expressId);
60
+ return ids && ids.length > 0 ? ids : [ref.expressId];
61
+ }
62
+
63
+ // For higher-level containers (IfcBuilding, IfcSite, IfcProject),
64
+ // walk the spatial tree from ref.expressId to find descendant storeys only
65
+ const startNode = findDescendantNode(hierarchy.project, ref.expressId);
66
+ if (!startNode) return [ref.expressId];
67
+
68
+ const descendantStoreyIds = collectDescendantStoreyIds(startNode);
69
+
70
+ const allIds: number[] = [];
71
+ const seen = new Set<number>();
72
+ for (const storeyId of descendantStoreyIds) {
73
+ const storeyIds = collectIfcBuildingStoreyElementsWithIfcSpace(hierarchy, storeyId);
74
+ if (storeyIds) {
75
+ for (const id of storeyIds) {
76
+ if (!seen.has(id)) {
77
+ seen.add(id);
78
+ allIds.push(id);
79
+ }
80
+ }
81
+ }
82
+ }
83
+ return allIds.length > 0 ? allIds : [ref.expressId];
84
+ }
8
85
 
9
86
  export function createVisibilityAdapter(store: StoreApi): VisibilityBackendMethods {
10
87
  return {
@@ -44,7 +121,10 @@ export function createVisibilityAdapter(store: StoreApi): VisibilityBackendMetho
44
121
  for (const ref of refs) {
45
122
  const model = getModelForRef(state, ref.modelId);
46
123
  if (model) {
47
- globalIds.push(ref.expressId + model.idOffset);
124
+ const expanded = expandSpatialRef(ref, model);
125
+ for (const id of expanded) {
126
+ globalIds.push(id + model.idOffset);
127
+ }
48
128
  }
49
129
  }
50
130
  if (globalIds.length > 0) {