@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
@@ -1,6 +1,6 @@
1
- const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/browser-CRQ0bPh1.js","assets/index-Be6XjVeM.js","assets/index-DdwD4c-E.css"])))=>i.map(i=>d[i]);
2
- import { _ as u, b as _, d as M, __tla as __tla_0 } from "./index-Be6XjVeM.js";
3
- import { N as Z, m as $, __tla as __tla_1 } from "./index-Be6XjVeM.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-CRQ0bPh1.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-Be6XjVeM.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-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-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-Be6XjVeM.js"></script>
48
- <link rel="stylesheet" crossorigin href="/assets/index-DdwD4c-E.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.3",
3
+ "version": "1.15.0",
4
4
  "description": "IFC-Lite viewer application",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -46,15 +46,15 @@
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.3",
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",
52
+ "@ifc-lite/ids": "^1.14.4",
53
53
  "@ifc-lite/lens": "^1.14.3",
54
- "@ifc-lite/lists": "^1.14.3",
54
+ "@ifc-lite/lists": "^1.14.4",
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.1.0",
57
+ "@ifc-lite/query": "^1.14.4",
58
58
  "@ifc-lite/renderer": "^1.14.3",
59
59
  "@ifc-lite/sandbox": "^1.14.3",
60
60
  "@ifc-lite/server-client": "^1.14.3",
@@ -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
  }
@@ -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
  }
@@ -319,30 +350,45 @@ export function HierarchyPanel() {
319
350
  setStoreysSelection(storeyIds);
320
351
  }
321
352
  }
353
+ } else if (node.type === 'IfcSpace') {
354
+ const spaceId = node.expressIds[0];
355
+ const modelId = node.modelIds[0];
356
+ const globalId = node.globalIds[0] ?? spaceId;
357
+
358
+ setSelectedEntityIds([]);
359
+
360
+ if (modelId && modelId !== 'legacy') {
361
+ setSelectedEntityId(globalId);
362
+ setSelectedEntity({ modelId, expressId: spaceId });
363
+ setActiveModel(modelId);
364
+ } else {
365
+ setSelectedEntityId(globalId);
366
+ setSelectedEntity({ modelId: 'legacy', expressId: spaceId });
367
+ }
368
+
369
+ if (node.hasChildren) {
370
+ toggleExpand(node.id);
371
+ }
322
372
  } else if (node.type === 'element') {
323
373
  // Element click - select it
324
- const elementId = node.expressIds[0]; // Original expressId
374
+ const elementId = node.expressIds[0];
325
375
  const modelId = node.modelIds[0];
376
+ const globalId = node.globalIds[0] ?? elementId;
326
377
 
327
378
  // Clear multi-selection (e.g. from a prior type-group click) so only
328
379
  // this single element is highlighted, matching Viewport pick behavior
329
380
  setSelectedEntityIds([]);
330
381
 
331
382
  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
383
  setSelectedEntityId(globalId);
336
384
  setSelectedEntity({ modelId, expressId: elementId });
337
385
  setActiveModel(modelId);
338
386
  } 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));
387
+ setSelectedEntityId(globalId);
388
+ setSelectedEntity(resolveEntityRef(globalId));
343
389
  }
344
390
  }
345
- }, [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]);
346
392
 
347
393
  // Compute selection and visibility state for a node
348
394
  const computeNodeState = useCallback((node: TreeNode): { isSelected: boolean; nodeHidden: boolean; modelVisible?: boolean } => {
@@ -352,8 +398,8 @@ export function HierarchyPanel() {
352
398
  ? node.expressIds.some(id => selectedStoreys.has(id))
353
399
  : node.type === 'IfcBuildingStorey'
354
400
  ? selectedStoreys.has(node.expressIds[0])
355
- : node.type === 'element'
356
- ? selectedEntityId === node.expressIds[0]
401
+ : node.type === 'IfcSpace' || node.type === 'element'
402
+ ? selectedEntityId === (node.globalIds[0] ?? node.expressIds[0])
357
403
  : node.type === 'ifc-type'
358
404
  ? (() => {
359
405
  const typeExpressId = node.entityExpressId;
@@ -369,8 +415,8 @@ export function HierarchyPanel() {
369
415
  // Compute visibility inline - for elements check directly, for storeys use getNodeElements
370
416
  let nodeHidden = false;
371
417
  if (node.type === 'element') {
372
- nodeHidden = hiddenEntities.has(node.expressIds[0]);
373
- } else if (node.type === 'IfcBuildingStorey' || node.type === 'unified-storey' ||
418
+ nodeHidden = hiddenEntities.has(node.globalIds[0] ?? node.expressIds[0]);
419
+ } else if (node.type === 'IfcBuildingStorey' || node.type === 'IfcSpace' || node.type === 'unified-storey' ||
374
420
  node.type === 'type-group' || node.type === 'ifc-type' ||
375
421
  (node.type === 'model-header' && node.id.startsWith('contrib-'))) {
376
422
  const elements = getNodeElements(node);
@@ -385,7 +431,7 @@ export function HierarchyPanel() {
385
431
  }
386
432
 
387
433
  return { isSelected, nodeHidden, modelVisible };
388
- }, [selectedStoreys, selectedEntityId, hiddenEntities, getNodeElements, models]);
434
+ }, [selectedStoreys, selectedEntityId, hiddenEntities, getNodeElements, models, toGlobalId]);
389
435
 
390
436
  if (!ifcDataStore && models.size === 0) {
391
437
  return (
@@ -536,21 +582,44 @@ export function HierarchyPanel() {
536
582
  </div>
537
583
 
538
584
  {/* Footer status */}
539
- {selectedStoreys.size > 0 ? (
585
+ {hasActiveFilters ? (
540
586
  <div className="p-2 border-t-2 border-zinc-200 dark:border-zinc-800 bg-primary text-white dark:bg-primary">
541
- <div className="flex items-center justify-between text-xs font-medium">
542
- <span className="uppercase tracking-wide">
543
- {selectedStoreys.size} {selectedStoreys.size === 1 ? 'STOREY' : 'STOREYS'} FILTERED
544
- </span>
545
- <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">
546
615
  <span className="opacity-70 text-[10px] font-mono">ESC</span>
547
616
  <Button
548
617
  variant="ghost"
549
618
  size="sm"
550
619
  className="h-6 text-[10px] uppercase border border-white/20 hover:bg-white/20 hover:text-white rounded-none px-2"
551
- onClick={clearStoreySelection}
620
+ onClick={() => { clearStoreySelection(); clearAllFilters(); }}
552
621
  >
553
- Clear
622
+ Clear all
554
623
  </Button>
555
624
  </div>
556
625
  </div>
@@ -599,21 +668,44 @@ export function HierarchyPanel() {
599
668
  </div>
600
669
 
601
670
  {/* Footer status */}
602
- {selectedStoreys.size > 0 ? (
671
+ {hasActiveFilters ? (
603
672
  <div className="p-2 border-t-2 border-zinc-200 dark:border-zinc-800 bg-primary text-white dark:bg-primary">
604
- <div className="flex items-center justify-between text-xs font-medium">
605
- <span className="uppercase tracking-wide">
606
- {selectedStoreys.size} {selectedStoreys.size === 1 ? 'STOREY' : 'STOREYS'} FILTERED
607
- </span>
608
- <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">
609
701
  <span className="opacity-70 text-[10px] font-mono">ESC</span>
610
702
  <Button
611
703
  variant="ghost"
612
704
  size="sm"
613
705
  className="h-6 text-[10px] uppercase border border-white/20 hover:bg-white/20 hover:text-white rounded-none px-2"
614
- onClick={clearStoreySelection}
706
+ onClick={() => { clearStoreySelection(); clearAllFilters(); }}
615
707
  >
616
- Clear
708
+ Clear all
617
709
  </Button>
618
710
  </div>
619
711
  </div>