@ifc-lite/viewer 1.14.4 → 1.16.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 (32) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/assets/{Arrow.dom-_vGzMMKs.js → Arrow.dom--gdrQd-q.js} +1 -1
  3. package/dist/assets/{basketViewActivator-BZcoCL3V.js → basketViewActivator-CI3y6VYQ.js} +1 -1
  4. package/dist/assets/{browser-Czmf34bo.js → browser-vWDubxDI.js} +1 -1
  5. package/dist/assets/ifc-lite_bg-CyWQTvp5.wasm +0 -0
  6. package/dist/assets/index-BImINgzG.js +187371 -0
  7. package/dist/assets/{index-D7nEDctQ.js → index-RXIK18da.js} +4 -4
  8. package/dist/assets/index-ax1X2WPd.css +1 -0
  9. package/dist/assets/{native-bridge-DAOWftxE.js → native-bridge-4rLidc3f.js} +1 -1
  10. package/dist/assets/{wasm-bridge-D7jYpn8a.js → wasm-bridge-BkfXfw8O.js} +1 -1
  11. package/dist/index.html +7 -2
  12. package/index.html +5 -0
  13. package/package.json +9 -9
  14. package/src/components/viewer/ExportDialog.tsx +40 -2
  15. package/src/components/viewer/HierarchyPanel.tsx +99 -22
  16. package/src/components/viewer/KeyboardShortcutsDialog.tsx +184 -82
  17. package/src/components/viewer/ViewportContainer.tsx +30 -25
  18. package/src/components/viewer/hierarchy/HierarchyNode.tsx +26 -20
  19. package/src/components/viewer/hierarchy/ifc-icons.ts +90 -0
  20. package/src/hooks/useIfcCache.ts +9 -9
  21. package/src/hooks/useKeyboardShortcuts.ts +28 -2
  22. package/src/sdk/adapters/visibility-adapter.ts +82 -2
  23. package/src/store/basketVisibleSet.ts +72 -4
  24. package/src/store/index.ts +11 -1
  25. package/src/store/slices/pinboardSlice.ts +46 -45
  26. package/src/store/slices/visibilitySlice.ts +28 -2
  27. package/src/utils/spatialHierarchy.ts +1 -1
  28. package/src/vite-env.d.ts +6 -2
  29. package/vite.config.ts +75 -23
  30. package/dist/assets/ifc-lite_bg-DyBKoGgk.wasm +0 -0
  31. package/dist/assets/index-CMQ_Dgkr.css +0 -1
  32. package/dist/assets/index-DX-Qf5fA.js +0 -116950
@@ -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>
@@ -3,13 +3,12 @@
3
3
  * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
4
 
5
5
  import { useState, useEffect, useCallback, useMemo } from 'react';
6
- import { X, Info, Keyboard, Github, ExternalLink, Sparkles, ChevronDown, Zap, Wrench, Plus } from 'lucide-react';
6
+ import { X, Info, Keyboard, Github, ExternalLink, Sparkles, ChevronDown, ChevronRight, Zap, Wrench, Plus, Package } from 'lucide-react';
7
7
  import { Button } from '@/components/ui/button';
8
8
  import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
9
9
  import { KEYBOARD_SHORTCUTS } from '@/hooks/useKeyboardShortcuts';
10
10
 
11
11
  const GITHUB_URL = 'https://github.com/louistrue/ifc-lite';
12
- const INITIAL_RELEASE_COUNT = 5;
13
12
 
14
13
  interface InfoDialogProps {
15
14
  open: boolean;
@@ -35,85 +34,172 @@ const TYPE_CONFIG = {
35
34
  } as const;
36
35
 
37
36
  function AboutTab() {
37
+ const [showPackages, setShowPackages] = useState(false);
38
+ const packageVersions = __PACKAGE_VERSIONS__;
39
+
38
40
  return (
39
- <div className="space-y-4">
41
+ <div className="space-y-3">
40
42
  {/* Header */}
41
- <div className="text-center pb-4 border-b">
43
+ <div className="text-center pb-2 border-b">
42
44
  <h3 className="text-xl font-bold">ifc-lite</h3>
43
- <p className="text-sm text-muted-foreground mt-1">
44
- Version {__APP_VERSION__}
45
- </p>
46
45
  <p className="text-xs text-muted-foreground mt-0.5">
47
- Built {formatBuildDate(__BUILD_DATE__)}
48
- </p>
49
- </div>
50
-
51
- {/* Description */}
52
- <div className="space-y-2">
53
- <p className="text-sm">
54
- A high-performance IFC viewer for BIM models, built with WebGPU.
46
+ v{__APP_VERSION__} &middot; {formatBuildDate(__BUILD_DATE__)}
55
47
  </p>
56
48
  </div>
57
49
 
58
- {/* Features */}
59
- <div className="space-y-2">
60
- <h4 className="text-sm font-medium">Features</h4>
61
- <ul className="text-sm text-muted-foreground space-y-1 list-disc list-inside">
62
- <li>WebGPU-accelerated 3D rendering</li>
63
- <li>IFC4 and IFC5/IFCX format support</li>
64
- <li>Multi-model federation</li>
65
- <li>Spatial hierarchy navigation</li>
66
- <li>Section planes and measurements</li>
67
- <li>Property inspection</li>
68
- </ul>
69
- </div>
70
-
71
50
  {/* Links */}
72
- <div className="pt-4 border-t space-y-2">
51
+ <div className="flex items-center justify-center gap-4 text-xs">
73
52
  <a
74
53
  href={GITHUB_URL}
75
54
  target="_blank"
76
55
  rel="noopener noreferrer"
77
- className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
56
+ className="flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-colors"
78
57
  >
79
- <Github className="h-4 w-4" />
80
- <span>View on GitHub</span>
81
- <ExternalLink className="h-3 w-3" />
58
+ <Github className="h-3.5 w-3.5" />
59
+ GitHub
82
60
  </a>
83
61
  <a
84
62
  href={`${GITHUB_URL}/issues`}
85
63
  target="_blank"
86
64
  rel="noopener noreferrer"
87
- className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
65
+ className="flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-colors"
88
66
  >
89
- <span className="w-4 text-center">🐛</span>
90
- <span>Report an issue</span>
67
+ Report issue
91
68
  <ExternalLink className="h-3 w-3" />
92
69
  </a>
70
+ <span className="text-muted-foreground">MPL-2.0</span>
93
71
  </div>
94
72
 
95
- {/* License */}
96
- <div className="pt-4 border-t">
97
- <p className="text-xs text-muted-foreground text-center">
98
- Licensed under Mozilla Public License 2.0
99
- </p>
73
+ {/* Feature chips */}
74
+ <div className="flex flex-wrap gap-1 justify-center pt-2 border-t">
75
+ {[
76
+ 'WebGPU', 'IFC2x3', 'IFC4', 'IFC4X3', 'IFC5/IFCX',
77
+ 'Federation', 'Measurements', 'Sections',
78
+ 'Properties', 'Data tables', 'Lens rules', 'IDS',
79
+ '2D drawings', 'BCF', 'Scripting', 'AI assistant',
80
+ 'glTF export', 'CSV', 'Parquet',
81
+ ].map((tag) => (
82
+ <span
83
+ key={tag}
84
+ className="px-2 py-0.5 text-[11px] rounded-full bg-muted/60 text-muted-foreground"
85
+ >
86
+ {tag}
87
+ </span>
88
+ ))}
100
89
  </div>
90
+
91
+ {/* Package Versions */}
92
+ {packageVersions.length > 0 && (
93
+ <div className="pt-2 border-t">
94
+ <button
95
+ onClick={() => setShowPackages(!showPackages)}
96
+ className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
97
+ >
98
+ {showPackages ? (
99
+ <ChevronDown className="h-3 w-3" />
100
+ ) : (
101
+ <ChevronRight className="h-3 w-3" />
102
+ )}
103
+ <Package className="h-3 w-3" />
104
+ {packageVersions.length} packages
105
+ </button>
106
+ {showPackages && (
107
+ <div className="rounded-md border bg-muted/30 p-2 mt-1.5 max-h-48 overflow-y-auto">
108
+ <div className="grid grid-cols-2 gap-x-4 gap-y-0.5">
109
+ {packageVersions.map((pkg) => (
110
+ <div
111
+ key={pkg.name}
112
+ className="flex items-center justify-between text-xs py-0.5 px-1 min-w-0"
113
+ >
114
+ <span className="text-muted-foreground font-mono truncate mr-2">
115
+ {pkg.name.replace('@ifc-lite/', '')}
116
+ </span>
117
+ <span className="font-mono shrink-0 tabular-nums">{pkg.version}</span>
118
+ </div>
119
+ ))}
120
+ </div>
121
+ </div>
122
+ )}
123
+ </div>
124
+ )}
101
125
  </div>
102
126
  );
103
127
  }
104
128
 
129
+ function formatPkgName(name: string): string {
130
+ return name.replace('@ifc-lite/', '');
131
+ }
132
+
133
+ type TimelineEntry = {
134
+ version: string;
135
+ isViewerVersion: boolean;
136
+ entries: Array<{ pkg: string; highlights: typeof __RELEASE_HISTORY__[0]['releases'][0]['highlights'] }>;
137
+ };
138
+
139
+ const compareSemver = (a: string, b: string) => {
140
+ const pa = a.split('.').map(Number);
141
+ const pb = b.split('.').map(Number);
142
+ for (let i = 0; i < 3; i++) {
143
+ if ((pa[i] || 0) !== (pb[i] || 0)) return (pb[i] || 0) - (pa[i] || 0);
144
+ }
145
+ return 0;
146
+ };
147
+
148
+ /** Merge all per-package changelogs into a unified timeline grouped by version. */
149
+ function buildTimeline(
150
+ packageChangelogs: typeof __RELEASE_HISTORY__,
151
+ viewerVersion: string
152
+ ): TimelineEntry[] {
153
+ type Highlights = typeof __RELEASE_HISTORY__[0]['releases'][0]['highlights'];
154
+ const versionMap = new Map<string, Map<string, Highlights>>();
155
+
156
+ for (const pkg of packageChangelogs) {
157
+ for (const release of pkg.releases) {
158
+ if (!versionMap.has(release.version)) {
159
+ versionMap.set(release.version, new Map());
160
+ }
161
+ versionMap.get(release.version)!.set(pkg.name, release.highlights);
162
+ }
163
+ }
164
+
165
+ return Array.from(versionMap.entries())
166
+ .sort(([a], [b]) => compareSemver(a, b))
167
+ .map(([version, pkgMap]) => ({
168
+ version,
169
+ isViewerVersion: version === viewerVersion,
170
+ entries: Array.from(pkgMap.entries())
171
+ .sort(([a], [b]) => a.localeCompare(b))
172
+ .map(([pkg, highlights]) => ({ pkg, highlights })),
173
+ }));
174
+ }
175
+
105
176
  function WhatsNewTab() {
106
- const [showAll, setShowAll] = useState(false);
107
- const releases = __RELEASE_HISTORY__;
177
+ const packageChangelogs = __RELEASE_HISTORY__;
178
+ const viewerVersion = __APP_VERSION__;
179
+ const [expandedVersions, setExpandedVersions] = useState<Set<string>>(() => new Set());
108
180
 
109
- const visibleReleases = useMemo(
110
- () => (showAll ? releases : releases.slice(0, INITIAL_RELEASE_COUNT)),
111
- [releases, showAll]
181
+ const timeline = useMemo(
182
+ () => buildTimeline(packageChangelogs, viewerVersion),
183
+ [packageChangelogs, viewerVersion]
112
184
  );
113
185
 
114
- const hasMore = releases.length > INITIAL_RELEASE_COUNT;
186
+ // Auto-expand the first version with actual changes
187
+ useEffect(() => {
188
+ if (timeline.length > 0 && expandedVersions.size === 0) {
189
+ setExpandedVersions(new Set([timeline[0].version]));
190
+ }
191
+ }, [timeline]);
115
192
 
116
- if (releases.length === 0) {
193
+ const toggleVersion = useCallback((version: string) => {
194
+ setExpandedVersions((prev) => {
195
+ const next = new Set(prev);
196
+ if (next.has(version)) next.delete(version);
197
+ else next.add(version);
198
+ return next;
199
+ });
200
+ }, []);
201
+
202
+ if (timeline.length === 0) {
117
203
  return (
118
204
  <div className="text-center py-8 text-sm text-muted-foreground">
119
205
  No release history available.
@@ -122,43 +208,59 @@ function WhatsNewTab() {
122
208
  }
123
209
 
124
210
  return (
125
- <div className="space-y-4">
126
- {visibleReleases.map((release, i) => (
127
- <div key={release.version}>
128
- <div className="flex items-center gap-2 mb-1.5">
129
- <span className="text-sm font-semibold">v{release.version}</span>
130
- {i === 0 && (
131
- <span className="px-1.5 py-0.5 text-[10px] font-medium bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 rounded">
132
- latest
211
+ <div className="space-y-1">
212
+ {timeline.map((release) => {
213
+ const isExpanded = expandedVersions.has(release.version);
214
+ const totalHighlights = release.entries.reduce((s, e) => s + e.highlights.length, 0);
215
+ return (
216
+ <div key={release.version}>
217
+ <button
218
+ onClick={() => toggleVersion(release.version)}
219
+ className="flex items-center gap-2 w-full py-1.5 px-1 text-left hover:bg-muted/40 transition-colors rounded"
220
+ >
221
+ {isExpanded ? (
222
+ <ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" />
223
+ ) : (
224
+ <ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />
225
+ )}
226
+ <span className="text-sm font-semibold">v{release.version}</span>
227
+ {release.isViewerVersion && (
228
+ <span className="px-1.5 py-0.5 text-[10px] font-medium bg-sky-500/15 text-sky-600 dark:text-sky-400 rounded">
229
+ viewer
230
+ </span>
231
+ )}
232
+ <span className="text-xs text-muted-foreground ml-auto">
233
+ {totalHighlights} change{totalHighlights !== 1 ? 's' : ''}
133
234
  </span>
235
+ </button>
236
+ {isExpanded && (
237
+ <div className="ml-5 pb-2 space-y-2">
238
+ {release.entries.map(({ pkg, highlights }) => (
239
+ <div key={pkg}>
240
+ <span className="text-xs font-medium font-mono text-muted-foreground">
241
+ {formatPkgName(pkg)}
242
+ </span>
243
+ <ul className="space-y-0.5 mt-0.5">
244
+ {highlights.map((h) => {
245
+ const { icon: Icon, className } = TYPE_CONFIG[h.type];
246
+ return (
247
+ <li
248
+ key={h.text}
249
+ className="flex items-start gap-1.5 text-sm text-muted-foreground"
250
+ >
251
+ <Icon className={`h-3 w-3 mt-0.5 shrink-0 ${className}`} />
252
+ <span>{h.text}</span>
253
+ </li>
254
+ );
255
+ })}
256
+ </ul>
257
+ </div>
258
+ ))}
259
+ </div>
134
260
  )}
135
261
  </div>
136
- <ul className="space-y-1 ml-0.5">
137
- {release.highlights.map((h) => {
138
- const { icon: Icon, className } = TYPE_CONFIG[h.type];
139
- return (
140
- <li key={h.text} className="flex items-start gap-2 text-sm text-muted-foreground">
141
- <Icon className={`h-3.5 w-3.5 mt-0.5 shrink-0 ${className}`} />
142
- <span>{h.text}</span>
143
- </li>
144
- );
145
- })}
146
- </ul>
147
- {i < visibleReleases.length - 1 && (
148
- <div className="border-b mt-3" />
149
- )}
150
- </div>
151
- ))}
152
-
153
- {hasMore && !showAll && (
154
- <button
155
- onClick={() => setShowAll(true)}
156
- className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors mx-auto"
157
- >
158
- <ChevronDown className="h-3.5 w-3.5" />
159
- Show all {releases.length} releases
160
- </button>
161
- )}
262
+ );
263
+ })}
162
264
 
163
265
  {/* Legend */}
164
266
  <div className="pt-3 border-t flex items-center justify-center gap-4 text-[11px] text-muted-foreground">
@@ -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 = () => (