@ifc-lite/viewer 1.10.0 → 1.11.1

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 (44) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/dist/assets/{Arrow.dom-Bw5JMdDs.js → Arrow.dom-p9ppgFLr.js} +1 -1
  3. package/dist/assets/{browser-DdRf3aWl.js → browser-lKzgHsnJ.js} +1 -1
  4. package/dist/assets/{ifc-lite_bg-C1-gLAHo.wasm → ifc-lite_bg-B6s-pcv0.wasm} +0 -0
  5. package/dist/assets/index-BoYyWYAu.css +1 -0
  6. package/dist/assets/{index-1ff6P0kc.js → index-CF854G-8.js} +42703 -41097
  7. package/dist/assets/{index-Bz7vHRxl.js → index-DQlpY6aJ.js} +4 -4
  8. package/dist/assets/{native-bridge-C5hD5vae.js → native-bridge-BgRWyawy.js} +1 -1
  9. package/dist/assets/{wasm-bridge-CaNKXFGM.js → wasm-bridge-BZxGtE7z.js} +1 -1
  10. package/dist/index.html +2 -2
  11. package/package.json +20 -19
  12. package/src/components/viewer/BasketPresentationDock.tsx +422 -0
  13. package/src/components/viewer/CommandPalette.tsx +29 -32
  14. package/src/components/viewer/EntityContextMenu.tsx +37 -22
  15. package/src/components/viewer/HierarchyPanel.tsx +19 -1
  16. package/src/components/viewer/MainToolbar.tsx +56 -113
  17. package/src/components/viewer/Section2DPanel.tsx +8 -1
  18. package/src/components/viewer/ThemeSwitch.tsx +55 -0
  19. package/src/components/viewer/Viewport.tsx +66 -105
  20. package/src/components/viewer/ViewportContainer.tsx +2 -0
  21. package/src/components/viewer/ViewportOverlays.tsx +9 -3
  22. package/src/components/viewer/useGeometryStreaming.ts +25 -0
  23. package/src/components/viewer/useKeyboardControls.ts +2 -2
  24. package/src/components/viewer/useRenderUpdates.ts +10 -3
  25. package/src/hooks/meshColorUpdates.test.ts +56 -0
  26. package/src/hooks/meshColorUpdates.ts +20 -0
  27. package/src/hooks/useIDS.ts +7 -8
  28. package/src/hooks/useIfcLoader.ts +25 -1
  29. package/src/hooks/useKeyboardShortcuts.ts +51 -84
  30. package/src/hooks/useViewerSelectors.ts +4 -0
  31. package/src/store/basket/basketCommands.ts +81 -0
  32. package/src/store/basket/basketViewActivator.ts +54 -0
  33. package/src/store/basketSave.ts +122 -0
  34. package/src/store/basketVisibleSet.test.ts +161 -0
  35. package/src/store/basketVisibleSet.ts +487 -0
  36. package/src/store/homeView.ts +21 -0
  37. package/src/store/index.ts +8 -0
  38. package/src/store/slices/dataSlice.test.ts +53 -4
  39. package/src/store/slices/dataSlice.ts +13 -5
  40. package/src/store/slices/drawing2DSlice.ts +5 -0
  41. package/src/store/slices/pinboardSlice.test.ts +160 -0
  42. package/src/store/slices/pinboardSlice.ts +248 -18
  43. package/src/store/types.ts +11 -0
  44. package/dist/assets/index-mvbV6NHd.css +0 -1
@@ -1,6 +1,6 @@
1
- const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/browser-DdRf3aWl.js","assets/index-1ff6P0kc.js","assets/index-mvbV6NHd.css"])))=>i.map(i=>d[i]);
2
- import { _ as u, b as S, __tla as __tla_0 } from "./index-1ff6P0kc.js";
3
- import { N as j, m as B, __tla as __tla_1 } from "./index-1ff6P0kc.js";
1
+ const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/browser-lKzgHsnJ.js","assets/index-CF854G-8.js","assets/index-BoYyWYAu.css"])))=>i.map(i=>d[i]);
2
+ import { _ as u, b as S, __tla as __tla_0 } from "./index-CF854G-8.js";
3
+ import { N as j, m as B, __tla as __tla_1 } from "./index-CF854G-8.js";
4
4
  let c, g, L, D, x, R, A;
5
5
  let __tla = Promise.all([
6
6
  (()=>{
@@ -87,7 +87,7 @@ let __tla = Promise.all([
87
87
  function k() {
88
88
  return m || (m = (async ()=>{
89
89
  try {
90
- const e = await u(()=>import("./browser-DdRf3aWl.js").then((i)=>i.b), __vite__mapDeps([0,1,2])), t = e.default ?? e;
90
+ const e = await u(()=>import("./browser-lKzgHsnJ.js").then((i)=>i.b), __vite__mapDeps([0,1,2])), t = e.default ?? e;
91
91
  let s;
92
92
  try {
93
93
  s = (await u(()=>import("./esbuild-COv63sf-.js"), [])).default;
@@ -1,4 +1,4 @@
1
- import { _ as c, __tla as __tla_0 } from "./index-1ff6P0kc.js";
1
+ import { _ as c, __tla as __tla_0 } from "./index-CF854G-8.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-1ff6P0kc.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-CF854G-8.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-1ff6P0kc.js"></script>
48
- <link rel="stylesheet" crossorigin href="/assets/index-mvbV6NHd.css">
47
+ <script type="module" crossorigin src="/assets/index-CF854G-8.js"></script>
48
+ <link rel="stylesheet" crossorigin href="/assets/index-BoYyWYAu.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.10.0",
3
+ "version": "1.11.1",
4
4
  "description": "IFC-Lite viewer application",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -29,6 +29,7 @@
29
29
  "@tanstack/react-virtual": "^3.13.18",
30
30
  "apache-arrow": "^14.0.2",
31
31
  "autoprefixer": "^10.4.23",
32
+ "beautiful-theme-toggle": "^1.0.1",
32
33
  "class-variance-authority": "^0.7.1",
33
34
  "clsx": "^2.1.1",
34
35
  "lucide-react": "^0.562.0",
@@ -40,24 +41,24 @@
40
41
  "tailwind-merge": "^3.4.0",
41
42
  "tailwindcss": "^4.1.18",
42
43
  "zustand": "^4.4.0",
43
- "@ifc-lite/bcf": "^1.10.0",
44
- "@ifc-lite/cache": "^1.10.0",
45
- "@ifc-lite/data": "^1.10.0",
46
- "@ifc-lite/drawing-2d": "^1.10.0",
47
- "@ifc-lite/encoding": "^1.10.0",
48
- "@ifc-lite/export": "^1.10.0",
49
- "@ifc-lite/geometry": "^1.10.0",
50
- "@ifc-lite/ids": "^1.10.0",
51
- "@ifc-lite/lens": "^1.10.0",
52
- "@ifc-lite/lists": "^1.10.0",
53
- "@ifc-lite/mutations": "^1.10.0",
54
- "@ifc-lite/parser": "^1.10.0",
55
- "@ifc-lite/query": "^1.10.0",
56
- "@ifc-lite/renderer": "^1.10.0",
57
- "@ifc-lite/sandbox": "^1.10.0",
58
- "@ifc-lite/server-client": "^1.10.0",
59
- "@ifc-lite/spatial": "^1.10.0",
60
- "@ifc-lite/wasm": "^1.10.0"
44
+ "@ifc-lite/bcf": "^1.11.1",
45
+ "@ifc-lite/cache": "^1.11.1",
46
+ "@ifc-lite/data": "^1.11.1",
47
+ "@ifc-lite/drawing-2d": "^1.11.1",
48
+ "@ifc-lite/encoding": "^1.11.1",
49
+ "@ifc-lite/export": "^1.11.1",
50
+ "@ifc-lite/geometry": "^1.11.1",
51
+ "@ifc-lite/ids": "^1.11.1",
52
+ "@ifc-lite/lens": "^1.11.1",
53
+ "@ifc-lite/lists": "^1.11.1",
54
+ "@ifc-lite/mutations": "^1.11.1",
55
+ "@ifc-lite/parser": "^1.11.1",
56
+ "@ifc-lite/query": "^1.11.1",
57
+ "@ifc-lite/renderer": "^1.11.1",
58
+ "@ifc-lite/sandbox": "^1.11.1",
59
+ "@ifc-lite/server-client": "^1.11.1",
60
+ "@ifc-lite/spatial": "^1.11.1",
61
+ "@ifc-lite/wasm": "^1.11.1"
61
62
  },
62
63
  "devDependencies": {
63
64
  "@tailwindcss/postcss": "^4.1.18",
@@ -0,0 +1,422 @@
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 { useCallback, useMemo, useRef, useState } from 'react';
6
+ import {
7
+ ChevronLeft,
8
+ ChevronRight,
9
+ Equal,
10
+ Eye,
11
+ EyeOff,
12
+ Minus,
13
+ Pencil,
14
+ Play,
15
+ Plus,
16
+ RotateCcw,
17
+ Save,
18
+ Square,
19
+ Timer,
20
+ Trash2,
21
+ } from 'lucide-react';
22
+ import { Button } from '@/components/ui/button';
23
+ import { Input } from '@/components/ui/input';
24
+ import { cn } from '@/lib/utils';
25
+ import { useViewerStore } from '@/store';
26
+ import {
27
+ executeBasketSet,
28
+ executeBasketAdd,
29
+ executeBasketRemove,
30
+ executeBasketSaveView,
31
+ executeBasketClear,
32
+ } from '@/store/basket/basketCommands';
33
+ import { activateBasketViewFromStore } from '@/store/basket/basketViewActivator';
34
+ import { getSmartBasketInputFromStore, isBasketIsolationActiveFromStore } from '@/store/basketVisibleSet';
35
+
36
+ export function BasketPresentationDock() {
37
+ const [savingThumbnail, setSavingThumbnail] = useState(false);
38
+ const [editingViewId, setEditingViewId] = useState<string | null>(null);
39
+ const [editingName, setEditingName] = useState('');
40
+ const [playingAll, setPlayingAll] = useState(false);
41
+ const stripRef = useRef<HTMLDivElement>(null);
42
+ const stopPlayRef = useRef(false);
43
+ const loopPlayRef = useRef(false);
44
+
45
+ const pinboardEntities = useViewerStore((s) => s.pinboardEntities);
46
+ const isolatedEntities = useViewerStore((s) => s.isolatedEntities);
47
+ const basketViews = useViewerStore((s) => s.basketViews);
48
+ const activeBasketViewId = useViewerStore((s) => s.activeBasketViewId);
49
+ const basketPresentationVisible = useViewerStore((s) => s.basketPresentationVisible);
50
+
51
+ const showPinboard = useViewerStore((s) => s.showPinboard);
52
+ const clearIsolation = useViewerStore((s) => s.clearIsolation);
53
+ const setBasketPresentationVisible = useViewerStore((s) => s.setBasketPresentationVisible);
54
+
55
+ const removeBasketView = useViewerStore((s) => s.removeBasketView);
56
+ const renameBasketView = useViewerStore((s) => s.renameBasketView);
57
+ const setBasketViewTransitionMs = useViewerStore((s) => s.setBasketViewTransitionMs);
58
+
59
+ const basketIsVisible = useMemo(
60
+ () => pinboardEntities.size > 0 && isolatedEntities !== null && isBasketIsolationActiveFromStore(),
61
+ [pinboardEntities, isolatedEntities],
62
+ );
63
+
64
+ const applySource = useCallback((mode: 'set' | 'add' | 'remove') => {
65
+ if (mode === 'set') executeBasketSet();
66
+ else if (mode === 'add') executeBasketAdd();
67
+ else executeBasketRemove();
68
+ }, []);
69
+
70
+ const handleSaveCurrent = useCallback(async () => {
71
+ if (pinboardEntities.size === 0 || savingThumbnail) return;
72
+
73
+ setSavingThumbnail(true);
74
+ try {
75
+ const { source } = getSmartBasketInputFromStore();
76
+ await executeBasketSaveView(source === 'empty' ? 'manual' : source);
77
+ } finally {
78
+ setSavingThumbnail(false);
79
+ }
80
+ }, [pinboardEntities, savingThumbnail]);
81
+
82
+ const startRename = useCallback((viewId: string, name: string) => {
83
+ setEditingViewId(viewId);
84
+ setEditingName(name);
85
+ }, []);
86
+
87
+ const cancelRename = useCallback(() => {
88
+ setEditingViewId(null);
89
+ setEditingName('');
90
+ }, []);
91
+
92
+ const commitRename = useCallback(() => {
93
+ if (!editingViewId) return;
94
+ const nextName = editingName.trim();
95
+ if (nextName.length > 0) {
96
+ renameBasketView(editingViewId, nextName);
97
+ }
98
+ setEditingViewId(null);
99
+ setEditingName('');
100
+ }, [editingViewId, editingName, renameBasketView]);
101
+
102
+ const scrollStrip = useCallback((delta: number) => {
103
+ stripRef.current?.scrollBy({ left: delta, behavior: 'smooth' });
104
+ }, []);
105
+
106
+ const toTransitionMs = useCallback((value: number | null | undefined) => {
107
+ if (!value || !Number.isFinite(value) || value <= 0) return 700;
108
+ return Math.max(150, Math.min(15000, Math.round(value)));
109
+ }, []);
110
+
111
+ const wait = useCallback((ms: number) => new Promise<void>((resolve) => {
112
+ window.setTimeout(resolve, ms);
113
+ }), []);
114
+
115
+ const stopPlayAll = useCallback(() => {
116
+ stopPlayRef.current = true;
117
+ loopPlayRef.current = false;
118
+ setPlayingAll(false);
119
+ }, []);
120
+
121
+ const startPlayAll = useCallback(async (loop = false) => {
122
+ if (playingAll || basketViews.length === 0) return;
123
+ stopPlayRef.current = false;
124
+ loopPlayRef.current = loop;
125
+ setPlayingAll(true);
126
+
127
+ try {
128
+ const orderedViews = [...basketViews];
129
+ do {
130
+ for (const view of orderedViews) {
131
+ if (stopPlayRef.current) break;
132
+ activateBasketViewFromStore(view.id);
133
+ const transitionMs = toTransitionMs(view.transitionMs);
134
+ await wait(transitionMs + 180);
135
+ }
136
+ } while (loopPlayRef.current && !stopPlayRef.current && orderedViews.length > 0);
137
+ } finally {
138
+ loopPlayRef.current = false;
139
+ setPlayingAll(false);
140
+ }
141
+ }, [basketViews, playingAll, toTransitionMs, wait]);
142
+
143
+ const setViewTransitionDuration = useCallback((viewId: string, currentTransitionMs: number | null) => {
144
+ const defaultSeconds = currentTransitionMs && currentTransitionMs > 0
145
+ ? (currentTransitionMs / 1000).toFixed(1)
146
+ : '';
147
+ const input = window.prompt(
148
+ 'Transition duration in seconds (optional). Leave empty for default smooth transition.',
149
+ defaultSeconds,
150
+ );
151
+ if (input === null) return;
152
+
153
+ const trimmed = input.trim();
154
+ if (!trimmed) {
155
+ setBasketViewTransitionMs(viewId, null);
156
+ return;
157
+ }
158
+
159
+ const seconds = Number(trimmed);
160
+ if (!Number.isFinite(seconds) || seconds <= 0) return;
161
+ setBasketViewTransitionMs(viewId, Math.round(seconds * 1000));
162
+ }, [setBasketViewTransitionMs]);
163
+
164
+ if (!basketPresentationVisible) {
165
+ return (
166
+ <div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-30 pointer-events-none">
167
+ <Button
168
+ type="button"
169
+ variant="secondary"
170
+ size="sm"
171
+ className="pointer-events-auto shadow-lg gap-2"
172
+ onClick={() => setBasketPresentationVisible(true)}
173
+ >
174
+ Presentation
175
+ <span className="rounded-full bg-primary/15 px-1.5 py-0.5 text-[10px] font-semibold text-primary">
176
+ {basketViews.length}
177
+ </span>
178
+ </Button>
179
+ </div>
180
+ );
181
+ }
182
+
183
+ return (
184
+ <div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-30 w-[min(980px,calc(100%-2rem))] pointer-events-none">
185
+ <div className="pointer-events-auto rounded-xl border bg-background/90 backdrop-blur-sm shadow-lg p-3 space-y-3">
186
+ <div className="flex items-center justify-between gap-3">
187
+ <div className="flex items-center gap-2 min-w-0">
188
+ <div className="text-sm font-semibold">Presentation</div>
189
+ <span className="rounded-full border px-2 py-0.5 text-[11px] text-muted-foreground">
190
+ {pinboardEntities.size} in basket
191
+ </span>
192
+ <span className="rounded-full border px-2 py-0.5 text-[11px] text-muted-foreground">
193
+ {basketViews.length} views
194
+ </span>
195
+ </div>
196
+
197
+ <div className="flex items-center gap-1.5">
198
+ <div className="flex items-center gap-1 rounded-md border bg-background/70 p-1">
199
+ <Button type="button" variant="outline" size="icon-sm" onClick={() => applySource('set')} title="Set basket from current context">
200
+ <Equal className="h-4 w-4" />
201
+ </Button>
202
+ <Button type="button" variant="outline" size="icon-sm" onClick={() => applySource('add')} title="Add current context to basket">
203
+ <Plus className="h-4 w-4" />
204
+ </Button>
205
+ <Button
206
+ type="button"
207
+ variant="outline"
208
+ size="icon-sm"
209
+ onClick={() => applySource('remove')}
210
+ disabled={pinboardEntities.size === 0}
211
+ title="Remove current context from basket"
212
+ >
213
+ <Minus className="h-4 w-4" />
214
+ </Button>
215
+ </div>
216
+ <div className="flex items-center gap-1 rounded-md border bg-background/70 p-1">
217
+ <Button
218
+ type="button"
219
+ variant="outline"
220
+ size="icon-sm"
221
+ onClick={() => {
222
+ if (basketIsVisible) clearIsolation();
223
+ else showPinboard();
224
+ }}
225
+ disabled={pinboardEntities.size === 0}
226
+ title={basketIsVisible ? 'Hide active basket' : 'Show active basket'}
227
+ >
228
+ {basketIsVisible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
229
+ </Button>
230
+ <Button
231
+ type="button"
232
+ variant="outline"
233
+ size="icon-sm"
234
+ onClick={executeBasketClear}
235
+ disabled={pinboardEntities.size === 0}
236
+ title="Clear active basket"
237
+ >
238
+ <RotateCcw className="h-4 w-4" />
239
+ </Button>
240
+ </div>
241
+ <Button
242
+ type="button"
243
+ variant="default"
244
+ size="icon-sm"
245
+ onClick={handleSaveCurrent}
246
+ disabled={pinboardEntities.size === 0 || savingThumbnail}
247
+ title="Save current basket as presentation view"
248
+ >
249
+ <Save className="h-4 w-4" />
250
+ </Button>
251
+ <Button
252
+ type="button"
253
+ variant={playingAll ? 'secondary' : 'outline'}
254
+ size="icon-sm"
255
+ onClick={playingAll ? stopPlayAll : (e) => { void startPlayAll(e.shiftKey); }}
256
+ disabled={basketViews.length === 0}
257
+ title={playingAll ? 'Stop playback' : 'Play all saved views (Shift+Click to loop)'}
258
+ >
259
+ {playingAll ? <Square className="h-4 w-4" /> : <Play className="h-4 w-4" />}
260
+ </Button>
261
+ <Button
262
+ type="button"
263
+ variant="ghost"
264
+ size="sm"
265
+ className="ml-1 text-xs"
266
+ onClick={() => setBasketPresentationVisible(false)}
267
+ >
268
+ Hide
269
+ </Button>
270
+ </div>
271
+ </div>
272
+
273
+ <div className="flex items-center gap-2">
274
+ <Button
275
+ type="button"
276
+ variant="outline"
277
+ size="icon-sm"
278
+ onClick={() => scrollStrip(-280)}
279
+ disabled={basketViews.length <= 1}
280
+ title="Scroll left"
281
+ >
282
+ <ChevronLeft className="h-4 w-4" />
283
+ </Button>
284
+
285
+ <div
286
+ ref={stripRef}
287
+ className="flex-1 min-w-0 overflow-x-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent snap-x snap-mandatory"
288
+ >
289
+ <div className="flex items-stretch gap-2 pr-1">
290
+ {basketViews.length === 0 && (
291
+ <div className="h-[102px] min-w-[340px] rounded-md border border-dashed text-xs text-muted-foreground px-3 py-2 flex items-center">
292
+ Save basket views here. Click any card to restore both visibility and viewpoint.
293
+ </div>
294
+ )}
295
+
296
+ {basketViews.map((view) => (
297
+ <div key={view.id} className="relative w-[186px] h-[102px] shrink-0 snap-start">
298
+ <button
299
+ type="button"
300
+ onClick={() => {
301
+ if (editingViewId) return;
302
+ activateBasketViewFromStore(view.id);
303
+ }}
304
+ className={cn(
305
+ 'h-full w-full rounded-md border bg-card text-left overflow-hidden transition-colors',
306
+ activeBasketViewId === view.id && 'ring-2 ring-primary border-primary',
307
+ )}
308
+ >
309
+ {view.thumbnailDataUrl ? (
310
+ <img
311
+ src={view.thumbnailDataUrl}
312
+ alt={view.name}
313
+ className="absolute inset-0 h-full w-full object-cover"
314
+ />
315
+ ) : (
316
+ <div className="absolute inset-0 bg-muted" />
317
+ )}
318
+
319
+ {activeBasketViewId === view.id && (
320
+ <div className="absolute left-1 top-1 rounded bg-primary px-1.5 py-0.5 text-[10px] font-semibold text-primary-foreground">
321
+ Active
322
+ </div>
323
+ )}
324
+
325
+ </button>
326
+
327
+ <div
328
+ className={cn(
329
+ 'absolute inset-x-0 bottom-0 bg-black/60 text-white px-2 py-1',
330
+ editingViewId !== view.id && 'pointer-events-none',
331
+ )}
332
+ onClick={(e) => e.stopPropagation()}
333
+ >
334
+ {editingViewId === view.id ? (
335
+ <Input
336
+ autoFocus
337
+ value={editingName}
338
+ onChange={(e) => setEditingName(e.target.value)}
339
+ onBlur={commitRename}
340
+ onKeyDown={(e) => {
341
+ if (e.key === 'Enter') {
342
+ e.preventDefault();
343
+ commitRename();
344
+ } else if (e.key === 'Escape') {
345
+ e.preventDefault();
346
+ cancelRename();
347
+ }
348
+ }}
349
+ className="h-6 bg-black/40 text-xs border-white/30 text-white placeholder:text-white/60"
350
+ />
351
+ ) : (
352
+ <>
353
+ <div className="text-[12px] font-medium truncate">{view.name}</div>
354
+ <div className="text-[10px] opacity-80">
355
+ {view.entityRefs.length} objects
356
+ {view.transitionMs ? ` · ${(view.transitionMs / 1000).toFixed(1)}s` : ''}
357
+ </div>
358
+ </>
359
+ )}
360
+ </div>
361
+
362
+ <Button
363
+ type="button"
364
+ variant="secondary"
365
+ size="icon-xs"
366
+ className="absolute top-1 right-7"
367
+ title="Rename view"
368
+ onClick={(e) => {
369
+ e.stopPropagation();
370
+ startRename(view.id, view.name);
371
+ }}
372
+ >
373
+ <Pencil className="h-3 w-3" />
374
+ </Button>
375
+ <Button
376
+ type="button"
377
+ variant="secondary"
378
+ size="icon-xs"
379
+ className="absolute top-1 right-[3.25rem]"
380
+ title="Set transition duration"
381
+ onClick={(e) => {
382
+ e.stopPropagation();
383
+ setViewTransitionDuration(view.id, view.transitionMs);
384
+ }}
385
+ >
386
+ <Timer className="h-3 w-3" />
387
+ </Button>
388
+ <Button
389
+ type="button"
390
+ variant="secondary"
391
+ size="icon-xs"
392
+ className="absolute top-1 right-1"
393
+ title="Delete view"
394
+ onClick={(e) => {
395
+ e.stopPropagation();
396
+ if (playingAll) stopPlayAll();
397
+ if (editingViewId === view.id) cancelRename();
398
+ removeBasketView(view.id);
399
+ }}
400
+ >
401
+ <Trash2 className="h-3 w-3" />
402
+ </Button>
403
+ </div>
404
+ ))}
405
+ </div>
406
+ </div>
407
+
408
+ <Button
409
+ type="button"
410
+ variant="outline"
411
+ size="icon-sm"
412
+ onClick={() => scrollStrip(280)}
413
+ disabled={basketViews.length <= 1}
414
+ title="Scroll right"
415
+ >
416
+ <ChevronRight className="h-4 w-4" />
417
+ </Button>
418
+ </div>
419
+ </div>
420
+ </div>
421
+ );
422
+ }
@@ -54,10 +54,19 @@ import {
54
54
  Orbit,
55
55
  FolderOpen,
56
56
  Clock,
57
+ Save,
57
58
  } from 'lucide-react';
58
59
  import { cn } from '@/lib/utils';
59
- import { useViewerStore, stringToEntityRef } from '@/store';
60
- import type { EntityRef } from '@/store';
60
+ import { useViewerStore } from '@/store';
61
+ import { goHomeFromStore, resetVisibilityForHomeFromStore } from '@/store/homeView';
62
+ import {
63
+ executeBasketSet,
64
+ executeBasketAdd,
65
+ executeBasketRemove,
66
+ executeBasketToggleVisibility,
67
+ executeBasketSaveView,
68
+ executeBasketClear,
69
+ } from '@/store/basket/basketCommands';
61
70
  import { useSandbox } from '@/hooks/useSandbox';
62
71
  import { SCRIPT_TEMPLATES } from '@/lib/scripts/templates';
63
72
  import { GLTFExporter, CSVExporter } from '@ifc-lite/export';
@@ -172,22 +181,6 @@ function downloadBlob(data: BlobPart, name: string, mime: string) {
172
181
  URL.revokeObjectURL(url);
173
182
  }
174
183
 
175
- function getSelectionRefs(): EntityRef[] {
176
- const s = useViewerStore.getState();
177
- if (s.selectedEntitiesSet.size > 0) {
178
- const refs: EntityRef[] = [];
179
- for (const str of s.selectedEntitiesSet) refs.push(stringToEntityRef(str));
180
- return refs;
181
- }
182
- return s.selectedEntity ? [s.selectedEntity] : [];
183
- }
184
-
185
- function clearMultiSelect() {
186
- const s = useViewerStore.getState();
187
- if (s.selectedEntitiesSet.size > 0)
188
- useViewerStore.setState({ selectedEntitiesSet: new Set(), selectedEntityIds: new Set() });
189
- }
190
-
191
184
  /** Exclusively activate a right-panel content panel (BCF / IDS / Lens).
192
185
  * Closes all others first so the if-else chain in ViewerLayout renders it.
193
186
  * If the target is already active, closes it (back to Properties). */
@@ -294,7 +287,7 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
294
287
  // ── View ──
295
288
  c.push(
296
289
  { id: 'view:home', label: 'Home', keywords: 'isometric reset camera', category: 'View', icon: Home, shortcut: 'H',
297
- action: () => { useViewerStore.getState().cameraCallbacks.home?.(); } },
290
+ action: () => { goHomeFromStore(); } },
298
291
  { id: 'view:fit', label: 'Fit All', keywords: 'zoom extents entire model', category: 'View', icon: Maximize2, shortcut: 'Z',
299
292
  action: () => { useViewerStore.getState().cameraCallbacks.fitAll?.(); } },
300
293
  { id: 'view:frame', label: 'Frame Selection', keywords: 'zoom focus selected', category: 'View', icon: Crosshair, shortcut: 'F',
@@ -340,19 +333,23 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
340
333
  if (ids.length > 0) { s.hideEntities(ids); s.clearSelection(); }
341
334
  } },
342
335
  { id: 'vis:show', label: 'Show All', keywords: 'unhide reset visible', category: 'Visibility', icon: Eye, shortcut: 'A',
343
- action: () => { const s = useViewerStore.getState(); s.showAll(); s.clearStoreySelection(); } },
344
- { id: 'vis:isolate', label: 'Isolate Selection', keywords: 'basket set pinboard', category: 'Visibility', icon: Equal, shortcut: 'I',
345
- action: () => {
346
- const s = useViewerStore.getState();
347
- if (s.pinboardEntities.size > 0 && s.selectedEntitiesSet.size === 0) { s.showPinboard(); }
348
- else { const r = getSelectionRefs(); if (r.length > 0) { s.setBasket(r); clearMultiSelect(); } }
349
- } },
350
- { id: 'vis:add-iso', label: 'Add to Isolation', keywords: 'basket plus', category: 'Visibility', icon: Plus, shortcut: '+',
351
- action: () => { const r = getSelectionRefs(); if (r.length > 0) { useViewerStore.getState().addToBasket(r); clearMultiSelect(); } } },
352
- { id: 'vis:remove-iso', label: 'Remove from Isolation', keywords: 'basket minus', category: 'Visibility', icon: Minus, shortcut: '−',
353
- action: () => { const r = getSelectionRefs(); if (r.length > 0) { useViewerStore.getState().removeFromBasket(r); clearMultiSelect(); } } },
354
- { id: 'vis:clear-iso', label: 'Clear Isolation', keywords: 'basket reset', category: 'Visibility', icon: RotateCcw,
355
- action: () => { useViewerStore.getState().clearBasket(); } },
336
+ action: () => { resetVisibilityForHomeFromStore(); } },
337
+ { id: 'vis:set-iso', label: 'Isolate (Set Basket)', keywords: 'basket isolate set selection hierarchy view equals', category: 'Visibility', icon: Equal, shortcut: 'I',
338
+ action: () => executeBasketSet() },
339
+ { id: 'vis:add-iso', label: 'Add to Basket', keywords: 'basket plus selection hierarchy view', category: 'Visibility', icon: Plus, shortcut: '+',
340
+ action: () => executeBasketAdd() },
341
+ { id: 'vis:remove-iso', label: 'Remove from Basket', keywords: 'basket minus selection hierarchy view', category: 'Visibility', icon: Minus, shortcut: '−',
342
+ action: () => executeBasketRemove() },
343
+ { id: 'vis:toggle-iso', label: 'Toggle Basket Visibility', keywords: 'basket show hide', category: 'Visibility', icon: Eye,
344
+ action: () => executeBasketToggleVisibility() },
345
+ { id: 'vis:save-view', label: 'Save Basket as View', keywords: 'basket presentation thumbnail', category: 'Visibility', icon: Save,
346
+ action: () => executeBasketSaveView().catch((err) => {
347
+ console.error('[CommandPalette] Failed to save basket view:', err);
348
+ }) },
349
+ { id: 'vis:toggle-presentation', label: 'Toggle Basket Presentation Dock', keywords: 'basket panel carousel thumbnails', category: 'Visibility', icon: Layout,
350
+ action: () => { useViewerStore.getState().toggleBasketPresentationVisible(); } },
351
+ { id: 'vis:clear-iso', label: 'Clear Basket', keywords: 'basket clear reset', category: 'Visibility', icon: RotateCcw,
352
+ action: () => executeBasketClear() },
356
353
  { id: 'vis:spaces', label: 'Spaces', keywords: 'IfcSpace rooms show hide', category: 'Visibility', icon: Box,
357
354
  action: () => { useViewerStore.getState().toggleTypeVisibility('spaces'); } },
358
355
  { id: 'vis:openings', label: 'Openings', keywords: 'IfcOpeningElement show hide', category: 'Visibility', icon: SquareX,