@ifc-lite/viewer 1.7.0 → 1.9.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 (95) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/dist/assets/{Arrow.dom-BGPQieQQ.js → Arrow.dom-CusgkT03.js} +1 -1
  3. package/dist/assets/browser-BXNIkE8a.js +694 -0
  4. package/dist/assets/emscripten-module-BTRCZGcB.wasm +0 -0
  5. package/dist/assets/emscripten-module-CGIn_cMh.wasm +0 -0
  6. package/dist/assets/emscripten-module-DYvzWiHh.wasm +0 -0
  7. package/dist/assets/emscripten-module-NWak2PoB.wasm +0 -0
  8. package/dist/assets/emscripten-module.browser-CY5t0Vfq.js +1 -0
  9. package/dist/assets/esbuild-COv63sf-.js +1 -0
  10. package/dist/assets/esbuild-Cpd5nU_H.wasm +0 -0
  11. package/dist/assets/ffi-DlhRHxHv.js +1 -0
  12. package/dist/assets/index-6Mr3byM-.js +216 -0
  13. package/dist/assets/index-CGbokkQ9.css +1 -0
  14. package/dist/assets/index-huvR-kGC.js +98305 -0
  15. package/dist/assets/module-6F3E5H7Y-tx0BadV3.js +6 -0
  16. package/dist/assets/{native-bridge-DD0SNyQ5.js → native-bridge-DsHOKdgD.js} +1 -1
  17. package/dist/assets/{wasm-bridge-D54YMO7X.js → wasm-bridge-Bd73HXn-.js} +1 -1
  18. package/dist/index.html +12 -3
  19. package/index.html +10 -1
  20. package/package.json +30 -21
  21. package/src/App.tsx +6 -1
  22. package/src/components/ui/dialog.tsx +8 -6
  23. package/src/components/viewer/CodeEditor.tsx +309 -0
  24. package/src/components/viewer/CommandPalette.tsx +597 -0
  25. package/src/components/viewer/Drawing2DCanvas.tsx +364 -1
  26. package/src/components/viewer/EntityContextMenu.tsx +47 -20
  27. package/src/components/viewer/ExportDialog.tsx +166 -17
  28. package/src/components/viewer/HierarchyPanel.tsx +3 -1
  29. package/src/components/viewer/LensPanel.tsx +848 -85
  30. package/src/components/viewer/MainToolbar.tsx +145 -84
  31. package/src/components/viewer/ScriptPanel.tsx +416 -0
  32. package/src/components/viewer/Section2DPanel.tsx +269 -29
  33. package/src/components/viewer/TextAnnotationEditor.tsx +112 -0
  34. package/src/components/viewer/ViewerLayout.tsx +63 -11
  35. package/src/components/viewer/Viewport.tsx +58 -23
  36. package/src/components/viewer/ViewportContainer.tsx +2 -0
  37. package/src/components/viewer/hierarchy/HierarchyNode.tsx +1 -1
  38. package/src/components/viewer/hierarchy/types.ts +1 -1
  39. package/src/components/viewer/lists/ListResultsTable.tsx +53 -19
  40. package/src/components/viewer/tools/cloudPathGenerator.test.ts +118 -0
  41. package/src/components/viewer/tools/cloudPathGenerator.ts +275 -0
  42. package/src/components/viewer/tools/computePolygonArea.test.ts +165 -0
  43. package/src/components/viewer/tools/computePolygonArea.ts +72 -0
  44. package/src/components/viewer/useGeometryStreaming.ts +25 -5
  45. package/src/hooks/ids/idsExportService.ts +1 -1
  46. package/src/hooks/useAnnotation2D.ts +551 -0
  47. package/src/hooks/useDrawingExport.ts +83 -1
  48. package/src/hooks/useKeyboardShortcuts.ts +114 -14
  49. package/src/hooks/useLens.ts +40 -55
  50. package/src/hooks/useLensDiscovery.ts +46 -0
  51. package/src/hooks/useModelSelection.ts +5 -22
  52. package/src/hooks/useSandbox.ts +113 -0
  53. package/src/index.css +7 -1
  54. package/src/lib/lens/adapter.ts +127 -1
  55. package/src/lib/lists/columnToAutoColor.ts +33 -0
  56. package/src/lib/recent-files.ts +122 -0
  57. package/src/lib/scripts/persistence.ts +132 -0
  58. package/src/lib/scripts/templates/bim-globals.d.ts +111 -0
  59. package/src/lib/scripts/templates/data-quality-audit.ts +149 -0
  60. package/src/lib/scripts/templates/envelope-check.ts +164 -0
  61. package/src/lib/scripts/templates/federation-compare.ts +189 -0
  62. package/src/lib/scripts/templates/fire-safety-check.ts +161 -0
  63. package/src/lib/scripts/templates/mep-equipment-schedule.ts +175 -0
  64. package/src/lib/scripts/templates/quantity-takeoff.ts +145 -0
  65. package/src/lib/scripts/templates/reset-view.ts +6 -0
  66. package/src/lib/scripts/templates/space-validation.ts +189 -0
  67. package/src/lib/scripts/templates/tsconfig.json +13 -0
  68. package/src/lib/scripts/templates.ts +86 -0
  69. package/src/sdk/BimProvider.tsx +50 -0
  70. package/src/sdk/adapters/export-adapter.ts +283 -0
  71. package/src/sdk/adapters/lens-adapter.ts +44 -0
  72. package/src/sdk/adapters/model-adapter.ts +32 -0
  73. package/src/sdk/adapters/model-compat.ts +80 -0
  74. package/src/sdk/adapters/mutate-adapter.ts +45 -0
  75. package/src/sdk/adapters/query-adapter.ts +241 -0
  76. package/src/sdk/adapters/selection-adapter.ts +29 -0
  77. package/src/sdk/adapters/spatial-adapter.ts +37 -0
  78. package/src/sdk/adapters/types.ts +11 -0
  79. package/src/sdk/adapters/viewer-adapter.ts +103 -0
  80. package/src/sdk/adapters/visibility-adapter.ts +61 -0
  81. package/src/sdk/local-backend.ts +144 -0
  82. package/src/sdk/useBimHost.ts +69 -0
  83. package/src/store/constants.ts +10 -2
  84. package/src/store/index.ts +28 -2
  85. package/src/store/resolveEntityRef.ts +44 -0
  86. package/src/store/slices/drawing2DSlice.ts +321 -0
  87. package/src/store/slices/lensSlice.ts +46 -4
  88. package/src/store/slices/pinboardSlice.ts +171 -42
  89. package/src/store/slices/scriptSlice.ts +218 -0
  90. package/src/store/slices/uiSlice.ts +2 -0
  91. package/src/store.ts +3 -0
  92. package/tsconfig.json +5 -2
  93. package/vite.config.ts +8 -0
  94. package/dist/assets/index-dgdgiQ9p.js +0 -75456
  95. package/dist/assets/index-yTqs8kgX.css +0 -1
@@ -0,0 +1,597 @@
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
+ /**
6
+ * CommandPalette — Ctrl+K / Cmd+K
7
+ *
8
+ * Raycast-style command palette for the entire viewer.
9
+ * Keyboard-first, scored search, recent usage tracking.
10
+ */
11
+
12
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
13
+ import {
14
+ Dialog,
15
+ DialogContent,
16
+ } from '@/components/ui/dialog';
17
+ import {
18
+ Search,
19
+ Play,
20
+ MousePointer2,
21
+ Hand,
22
+ Rotate3d,
23
+ PersonStanding,
24
+ Ruler,
25
+ Scissors,
26
+ Home,
27
+ Maximize2,
28
+ Crosshair,
29
+ ArrowUp,
30
+ ArrowDown,
31
+ ArrowLeft,
32
+ ArrowRight,
33
+ Box,
34
+ EyeOff,
35
+ Eye,
36
+ Equal,
37
+ Plus,
38
+ Minus,
39
+ RotateCcw,
40
+ SquareX,
41
+ Building2,
42
+ Layout,
43
+ TreeDeciduous,
44
+ FileCode2,
45
+ MessageSquare,
46
+ ClipboardCheck,
47
+ FileSpreadsheet,
48
+ Palette,
49
+ Camera,
50
+ Download,
51
+ FileJson,
52
+ Sun,
53
+ Info,
54
+ Orbit,
55
+ FolderOpen,
56
+ Clock,
57
+ } from 'lucide-react';
58
+ import { cn } from '@/lib/utils';
59
+ import { useViewerStore, stringToEntityRef } from '@/store';
60
+ import type { EntityRef } from '@/store';
61
+ import { useSandbox } from '@/hooks/useSandbox';
62
+ import { SCRIPT_TEMPLATES } from '@/lib/scripts/templates';
63
+ import { GLTFExporter, CSVExporter } from '@ifc-lite/export';
64
+ import { getRecentFiles, formatFileSize, getCachedFile } from '@/lib/recent-files';
65
+ import type { RecentFileEntry } from '@/lib/recent-files';
66
+
67
+ // ── Types ──────────────────────────────────────────────────────────────
68
+
69
+ type Category =
70
+ | 'Recent'
71
+ | 'File'
72
+ | 'View'
73
+ | 'Tools'
74
+ | 'Visibility'
75
+ | 'Panels'
76
+ | 'Export'
77
+ | 'Automation'
78
+ | 'Preferences';
79
+
80
+ interface Command {
81
+ id: string;
82
+ label: string;
83
+ keywords: string; // extra search tokens (no UI display)
84
+ category: Exclude<Category, 'Recent'>;
85
+ icon: React.ElementType;
86
+ shortcut?: string;
87
+ detail?: string; // subtle secondary text (e.g. file size)
88
+ action: () => void;
89
+ }
90
+
91
+ interface FlatItem {
92
+ cmd: Command;
93
+ flatIdx: number;
94
+ }
95
+
96
+ // ── Constants ──────────────────────────────────────────────────────────
97
+
98
+ const RECENT_KEY = 'ifc-lite:cmd-palette:recent';
99
+ const MAX_RECENT = 5;
100
+ const CATEGORY_ORDER: Category[] = [
101
+ 'Recent', 'File', 'View', 'Tools', 'Visibility', 'Panels', 'Export', 'Automation', 'Preferences',
102
+ ];
103
+
104
+ // ── Search scoring ─────────────────────────────────────────────────────
105
+
106
+ /**
107
+ * Score how well `query` matches `text`.
108
+ * 0 = no match
109
+ * 100 = exact substring
110
+ * 50 = word-start initials
111
+ * 1-25 = tight fuzzy (avg gap ≤ 5)
112
+ */
113
+ function score(query: string, text: string): number {
114
+ const q = query.toLowerCase();
115
+ const t = text.toLowerCase();
116
+
117
+ // Exact substring
118
+ if (t.includes(q)) return 100;
119
+
120
+ // Word-start initials (e.g. "cs" → "Color Spaces")
121
+ const words = t.split(/[\s\-_:\/,]+/);
122
+ let wi = 0, qi = 0;
123
+ while (wi < words.length && qi < q.length) {
124
+ if (words[wi].length > 0 && words[wi][0] === q[qi]) qi++;
125
+ wi++;
126
+ }
127
+ if (qi === q.length) return 50;
128
+
129
+ // Tight fuzzy — reject if chars are scattered
130
+ let lastIdx = -1, totalGap = 0;
131
+ qi = 0;
132
+ for (let i = 0; i < t.length && qi < q.length; i++) {
133
+ if (t[i] === q[qi]) {
134
+ if (lastIdx >= 0) totalGap += i - lastIdx - 1;
135
+ lastIdx = i;
136
+ qi++;
137
+ }
138
+ }
139
+ if (qi < q.length) return 0;
140
+ const avgGap = q.length > 1 ? totalGap / (q.length - 1) : 0;
141
+ if (avgGap > 5) return 0;
142
+ return Math.max(1, 25 - Math.round(avgGap * 3));
143
+ }
144
+
145
+ /** Rank a command against the search query. Label dominates. */
146
+ function rankCommand(cmd: Command, query: string): number {
147
+ const l = score(query, cmd.label);
148
+ const k = score(query, cmd.keywords) * 0.9;
149
+ const c = score(query, cmd.category) * 0.5;
150
+ return Math.max(l, k, c);
151
+ }
152
+
153
+ // ── Recent usage ───────────────────────────────────────────────────────
154
+
155
+ function getRecentIds(): string[] {
156
+ try { return JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]'); }
157
+ catch { return []; }
158
+ }
159
+ function recordUsage(id: string) {
160
+ try {
161
+ const r = getRecentIds().filter(x => x !== id);
162
+ r.unshift(id);
163
+ localStorage.setItem(RECENT_KEY, JSON.stringify(r.slice(0, 30)));
164
+ } catch { /* noop */ }
165
+ }
166
+
167
+ // ── Utilities ──────────────────────────────────────────────────────────
168
+
169
+ function downloadBlob(data: BlobPart, name: string, mime: string) {
170
+ const url = URL.createObjectURL(new Blob([data], { type: mime }));
171
+ Object.assign(document.createElement('a'), { href: url, download: name }).click();
172
+ URL.revokeObjectURL(url);
173
+ }
174
+
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
+ /** Exclusively activate a right-panel content panel (BCF / IDS / Lens).
192
+ * Closes all others first so the if-else chain in ViewerLayout renders it.
193
+ * If the target is already active, closes it (back to Properties). */
194
+ function activateRightPanel(panel: 'bcf' | 'ids' | 'lens') {
195
+ const s = useViewerStore.getState();
196
+ const isActive =
197
+ panel === 'bcf' ? s.bcfPanelVisible :
198
+ panel === 'ids' ? s.idsPanelVisible :
199
+ s.lensPanelVisible;
200
+
201
+ // Close all content panels
202
+ s.setBcfPanelVisible(false);
203
+ s.setIdsPanelVisible(false);
204
+ s.setLensPanelVisible(false);
205
+
206
+ if (!isActive) {
207
+ // Open the target, expand right panel
208
+ s.setRightPanelCollapsed(false);
209
+ if (panel === 'bcf') s.setBcfPanelVisible(true);
210
+ else if (panel === 'ids') s.setIdsPanelVisible(true);
211
+ else s.setLensPanelVisible(true);
212
+ }
213
+ // If was active → all closed → falls back to Properties
214
+ }
215
+
216
+ /** Exclusively activate a bottom panel (Script / List).
217
+ * Closes the other first so the if-else chain in ViewerLayout renders it.
218
+ * If the target is already active, closes it. */
219
+ function activateBottomPanel(panel: 'script' | 'list') {
220
+ const s = useViewerStore.getState();
221
+ const isActive = panel === 'script' ? s.scriptPanelVisible : s.listPanelVisible;
222
+
223
+ // Close all bottom panels
224
+ s.setScriptPanelVisible(false);
225
+ s.setListPanelVisible(false);
226
+
227
+ if (!isActive) {
228
+ if (panel === 'script') s.setScriptPanelVisible(true);
229
+ else s.setListPanelVisible(true);
230
+ }
231
+ }
232
+
233
+ // ── Component ──────────────────────────────────────────────────────────
234
+
235
+ interface CommandPaletteProps {
236
+ open: boolean;
237
+ onOpenChange: (open: boolean) => void;
238
+ }
239
+
240
+ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
241
+ const [query, setQuery] = useState('');
242
+ const [selectedIndex, setSelectedIndex] = useState(0);
243
+ const [recentIds, setRecentIds] = useState<string[]>([]);
244
+ const [recentFiles, setRecentFiles] = useState<RecentFileEntry[]>([]);
245
+ const inputRef = useRef<HTMLInputElement>(null);
246
+ const listRef = useRef<HTMLDivElement>(null);
247
+ const navigatedByKeyboard = useRef(false);
248
+
249
+ const { execute } = useSandbox();
250
+
251
+ useEffect(() => {
252
+ if (open) {
253
+ setRecentIds(getRecentIds());
254
+ setRecentFiles(getRecentFiles());
255
+ setQuery('');
256
+ requestAnimationFrame(() => inputRef.current?.focus());
257
+ }
258
+ }, [open]);
259
+
260
+ // ── Command definitions ──
261
+ const commands = useMemo<Command[]>(() => {
262
+ const c: Command[] = [];
263
+
264
+ // ── File ──
265
+ c.push(
266
+ { id: 'file:open', label: 'Open File', keywords: 'ifc ifcx glb load model browse', category: 'File', icon: FolderOpen,
267
+ action: () => {
268
+ const input = document.getElementById('file-input-open') as HTMLInputElement | null;
269
+ if (input) input.click();
270
+ } },
271
+ );
272
+ for (const rf of recentFiles) {
273
+ const fileName = rf.name;
274
+ c.push({
275
+ id: `file:recent:${fileName}`, label: fileName,
276
+ keywords: `recent open ${formatFileSize(rf.size)}`,
277
+ category: 'File', icon: Clock,
278
+ detail: formatFileSize(rf.size),
279
+ action: () => {
280
+ // Try loading from IndexedDB blob cache → dispatches to MainToolbar's loadFile
281
+ getCachedFile(fileName).then(file => {
282
+ if (file) {
283
+ window.dispatchEvent(new CustomEvent('ifc-lite:load-file', { detail: file }));
284
+ } else {
285
+ // Cache miss — fall back to file picker
286
+ const input = document.getElementById('file-input-open') as HTMLInputElement | null;
287
+ if (input) input.click();
288
+ }
289
+ });
290
+ },
291
+ });
292
+ }
293
+
294
+ // ── View ──
295
+ c.push(
296
+ { id: 'view:home', label: 'Home', keywords: 'isometric reset camera', category: 'View', icon: Home, shortcut: 'H',
297
+ action: () => { useViewerStore.getState().cameraCallbacks.home?.(); } },
298
+ { id: 'view:fit', label: 'Fit All', keywords: 'zoom extents entire model', category: 'View', icon: Maximize2, shortcut: 'Z',
299
+ action: () => { useViewerStore.getState().cameraCallbacks.fitAll?.(); } },
300
+ { id: 'view:frame', label: 'Frame Selection', keywords: 'zoom focus selected', category: 'View', icon: Crosshair, shortcut: 'F',
301
+ action: () => { useViewerStore.getState().cameraCallbacks.frameSelection?.(); } },
302
+ { id: 'view:projection', label: 'Projection', keywords: 'perspective orthographic ortho toggle switch', category: 'View', icon: Orbit,
303
+ action: () => { useViewerStore.getState().toggleProjectionMode(); } },
304
+ { id: 'view:top', label: 'Top View', keywords: 'camera plan', category: 'View', icon: ArrowUp, shortcut: '1',
305
+ action: () => { useViewerStore.getState().cameraCallbacks.setPresetView?.('top'); } },
306
+ { id: 'view:bottom', label: 'Bottom View', keywords: 'camera', category: 'View', icon: ArrowDown, shortcut: '2',
307
+ action: () => { useViewerStore.getState().cameraCallbacks.setPresetView?.('bottom'); } },
308
+ { id: 'view:front', label: 'Front View', keywords: 'camera elevation', category: 'View', icon: ArrowRight, shortcut: '3',
309
+ action: () => { useViewerStore.getState().cameraCallbacks.setPresetView?.('front'); } },
310
+ { id: 'view:back', label: 'Back View', keywords: 'camera', category: 'View', icon: ArrowLeft, shortcut: '4',
311
+ action: () => { useViewerStore.getState().cameraCallbacks.setPresetView?.('back'); } },
312
+ { id: 'view:left', label: 'Left View', keywords: 'camera', category: 'View', icon: ArrowLeft, shortcut: '5',
313
+ action: () => { useViewerStore.getState().cameraCallbacks.setPresetView?.('left'); } },
314
+ { id: 'view:right', label: 'Right View', keywords: 'camera', category: 'View', icon: ArrowRight, shortcut: '6',
315
+ action: () => { useViewerStore.getState().cameraCallbacks.setPresetView?.('right'); } },
316
+ );
317
+
318
+ // ── Tools ──
319
+ c.push(
320
+ { id: 'tool:select', label: 'Select', keywords: 'pick click pointer', category: 'Tools', icon: MousePointer2, shortcut: 'V',
321
+ action: () => { useViewerStore.getState().setActiveTool('select'); } },
322
+ { id: 'tool:pan', label: 'Pan', keywords: 'move drag hand', category: 'Tools', icon: Hand, shortcut: 'P',
323
+ action: () => { useViewerStore.getState().setActiveTool('pan'); } },
324
+ { id: 'tool:orbit', label: 'Orbit', keywords: 'rotate spin', category: 'Tools', icon: Rotate3d, shortcut: 'O',
325
+ action: () => { useViewerStore.getState().setActiveTool('orbit'); } },
326
+ { id: 'tool:walk', label: 'Walk', keywords: 'first person navigate wasd', category: 'Tools', icon: PersonStanding, shortcut: 'C',
327
+ action: () => { useViewerStore.getState().setActiveTool('walk'); } },
328
+ { id: 'tool:measure', label: 'Measure', keywords: 'distance ruler dimension', category: 'Tools', icon: Ruler, shortcut: 'M',
329
+ action: () => { useViewerStore.getState().setActiveTool('measure'); } },
330
+ { id: 'tool:section', label: 'Section', keywords: 'clip cut plane', category: 'Tools', icon: Scissors, shortcut: 'X',
331
+ action: () => { useViewerStore.getState().setActiveTool('section'); } },
332
+ );
333
+
334
+ // ── Visibility ──
335
+ c.push(
336
+ { id: 'vis:hide', label: 'Hide Selection', keywords: 'hide selected invisible', category: 'Visibility', icon: EyeOff, shortcut: 'Del',
337
+ action: () => {
338
+ const s = useViewerStore.getState();
339
+ const ids = s.selectedEntityIds.size > 0 ? Array.from(s.selectedEntityIds) : s.selectedEntityId !== null ? [s.selectedEntityId] : [];
340
+ if (ids.length > 0) { s.hideEntities(ids); s.clearSelection(); }
341
+ } },
342
+ { 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(); } },
356
+ { id: 'vis:spaces', label: 'Spaces', keywords: 'IfcSpace rooms show hide', category: 'Visibility', icon: Box,
357
+ action: () => { useViewerStore.getState().toggleTypeVisibility('spaces'); } },
358
+ { id: 'vis:openings', label: 'Openings', keywords: 'IfcOpeningElement show hide', category: 'Visibility', icon: SquareX,
359
+ action: () => { useViewerStore.getState().toggleTypeVisibility('openings'); } },
360
+ { id: 'vis:site', label: 'Site', keywords: 'IfcSite terrain show hide', category: 'Visibility', icon: Building2,
361
+ action: () => { useViewerStore.getState().toggleTypeVisibility('site'); } },
362
+ { id: 'vis:reset-colors', label: 'Reset Colors', keywords: 'clear color override', category: 'Visibility', icon: Palette,
363
+ action: () => { execute('bim.viewer.resetColors()\nconsole.log("Colors reset")'); } },
364
+ );
365
+
366
+ // ── Panels ──
367
+ c.push(
368
+ { id: 'panel:properties', label: 'Properties', keywords: 'attributes panel right', category: 'Panels', icon: Layout,
369
+ action: () => { const s = useViewerStore.getState(); s.setRightPanelCollapsed(!s.rightPanelCollapsed); } },
370
+ { id: 'panel:tree', label: 'Spatial Tree', keywords: 'hierarchy left panel', category: 'Panels', icon: TreeDeciduous,
371
+ action: () => { const s = useViewerStore.getState(); s.setLeftPanelCollapsed(!s.leftPanelCollapsed); } },
372
+ { id: 'panel:script', label: 'Script Editor', keywords: 'code automation console', category: 'Panels', icon: FileCode2,
373
+ action: () => { activateBottomPanel('script'); } },
374
+ { id: 'panel:bcf', label: 'BCF Issues', keywords: 'collaboration topics comments viewpoint', category: 'Panels', icon: MessageSquare,
375
+ action: () => { activateRightPanel('bcf'); } },
376
+ { id: 'panel:ids', label: 'IDS Validation', keywords: 'information delivery specification check', category: 'Panels', icon: ClipboardCheck,
377
+ action: () => { activateRightPanel('ids'); } },
378
+ { id: 'panel:lists', label: 'Entity Lists', keywords: 'table spreadsheet schedule', category: 'Panels', icon: FileSpreadsheet,
379
+ action: () => { activateBottomPanel('list'); } },
380
+ { id: 'panel:lens', label: 'Lens Rules', keywords: 'color filter highlight', category: 'Panels', icon: Palette,
381
+ action: () => { activateRightPanel('lens'); } },
382
+ );
383
+
384
+ // ── Export ──
385
+ c.push(
386
+ { id: 'export:screenshot', label: 'Screenshot', keywords: 'capture png image viewport', category: 'Export', icon: Camera,
387
+ action: () => {
388
+ const canvas = document.querySelector('canvas');
389
+ if (!canvas) return;
390
+ try { const d = canvas.toDataURL('image/png'); Object.assign(document.createElement('a'), { href: d, download: 'screenshot.png' }).click(); }
391
+ catch (e) { console.error('Screenshot failed:', e); }
392
+ } },
393
+ { id: 'export:glb', label: 'Export GLB', keywords: '3d model gltf download', category: 'Export', icon: Download,
394
+ action: () => {
395
+ const gr = useViewerStore.getState().geometryResult; if (!gr) return;
396
+ try { const e = new GLTFExporter(gr); downloadBlob(new Uint8Array(e.exportGLB({ includeMetadata: true })), 'model.glb', 'model/gltf-binary'); }
397
+ catch (e) { console.error('GLB export failed:', e); }
398
+ } },
399
+ { id: 'export:csv-entities', label: 'Export CSV: Entities', keywords: 'spreadsheet properties download', category: 'Export', icon: FileSpreadsheet,
400
+ action: () => { const d = useViewerStore.getState().ifcDataStore; if (!d) return; try { downloadBlob(new CSVExporter(d).exportEntities(undefined, { includeProperties: true, flattenProperties: true }), 'entities.csv', 'text/csv'); } catch (e) { console.error(e); } } },
401
+ { id: 'export:csv-properties', label: 'Export CSV: Properties', keywords: 'pset spreadsheet download', category: 'Export', icon: FileSpreadsheet,
402
+ action: () => { const d = useViewerStore.getState().ifcDataStore; if (!d) return; try { downloadBlob(new CSVExporter(d).exportProperties(), 'properties.csv', 'text/csv'); } catch (e) { console.error(e); } } },
403
+ { id: 'export:csv-quantities', label: 'Export CSV: Quantities', keywords: 'qto spreadsheet download', category: 'Export', icon: FileSpreadsheet,
404
+ action: () => { const d = useViewerStore.getState().ifcDataStore; if (!d) return; try { downloadBlob(new CSVExporter(d).exportQuantities(), 'quantities.csv', 'text/csv'); } catch (e) { console.error(e); } } },
405
+ { id: 'export:csv-spatial', label: 'Export CSV: Spatial', keywords: 'hierarchy spreadsheet download', category: 'Export', icon: FileSpreadsheet,
406
+ action: () => { const d = useViewerStore.getState().ifcDataStore; if (!d) return; try { downloadBlob(new CSVExporter(d).exportSpatialHierarchy(), 'spatial-hierarchy.csv', 'text/csv'); } catch (e) { console.error(e); } } },
407
+ { id: 'export:json', label: 'Export JSON', keywords: 'data entities all download', category: 'Export', icon: FileJson,
408
+ action: () => {
409
+ const d = useViewerStore.getState().ifcDataStore; if (!d) return;
410
+ try {
411
+ const out: Record<string, unknown>[] = [];
412
+ for (let i = 0; i < d.entities.count; i++) { const id = d.entities.expressId[i]; out.push({ expressId: id, globalId: d.entities.getGlobalId(id), name: d.entities.getName(id), type: d.entities.getTypeName(id), properties: d.properties.getForEntity(id) }); }
413
+ downloadBlob(JSON.stringify({ entities: out }, null, 2), 'model-data.json', 'application/json');
414
+ } catch (e) { console.error(e); }
415
+ } },
416
+ );
417
+
418
+ // ── Automation (scripts — last, power-user feature) ──
419
+ for (const t of SCRIPT_TEMPLATES) {
420
+ c.push({
421
+ id: `auto:${t.name}`, label: t.name, keywords: `script run ${t.description}`,
422
+ category: 'Automation', icon: Play,
423
+ action: () => { const s = useViewerStore.getState(); s.setListPanelVisible(false); s.setScriptPanelVisible(true); s.setScriptEditorContent(t.code); execute(t.code); },
424
+ });
425
+ }
426
+
427
+ // ── Preferences ──
428
+ c.push(
429
+ { id: 'pref:theme', label: 'Theme', keywords: 'dark light mode appearance switch', category: 'Preferences', icon: Sun, shortcut: 'T',
430
+ action: () => { useViewerStore.getState().toggleTheme(); } },
431
+ { id: 'pref:tooltips', label: 'Hover Tooltips', keywords: 'entity info mouse hover show hide', category: 'Preferences', icon: Info,
432
+ action: () => { useViewerStore.getState().toggleHoverTooltips(); } },
433
+ );
434
+
435
+ return c;
436
+ }, [execute, recentFiles]);
437
+
438
+ // ── Search: score, filter, sort ──
439
+ // When searching, results are FLAT sorted by relevance — no category grouping.
440
+ // When browsing (no query), results are grouped by category.
441
+ const { grouped, flatItems } = useMemo(() => {
442
+ const groups: { category: string; items: FlatItem[] }[] = [];
443
+ const flat: FlatItem[] = [];
444
+ let idx = 0;
445
+
446
+ if (query) {
447
+ // ── Searching: flat ranked list, no categories ──
448
+ const scored = commands
449
+ .map(cmd => ({ cmd, s: rankCommand(cmd, query) }))
450
+ .filter(x => x.s > 0);
451
+ scored.sort((a, b) => b.s - a.s);
452
+
453
+ if (scored.length > 0) {
454
+ const items: FlatItem[] = scored.map(({ cmd }) => {
455
+ const item = { cmd, flatIdx: idx++ };
456
+ flat.push(item);
457
+ return item;
458
+ });
459
+ groups.push({ category: '', items }); // empty category = no header
460
+ }
461
+ } else {
462
+ // ── Browsing: recent on top, then categories ──
463
+ if (recentIds.length > 0) {
464
+ const items: FlatItem[] = [];
465
+ for (const id of recentIds.slice(0, MAX_RECENT)) {
466
+ const cmd = commands.find(c => c.id === id);
467
+ if (cmd) { const item = { cmd, flatIdx: idx++ }; items.push(item); flat.push(item); }
468
+ }
469
+ if (items.length > 0) groups.push({ category: 'Recent', items });
470
+ }
471
+
472
+ for (const cat of CATEGORY_ORDER) {
473
+ if (cat === 'Recent') continue;
474
+ const catCmds = commands.filter(c => c.category === cat);
475
+ if (catCmds.length > 0) {
476
+ const items: FlatItem[] = catCmds.map(cmd => {
477
+ const item = { cmd, flatIdx: idx++ };
478
+ flat.push(item);
479
+ return item;
480
+ });
481
+ groups.push({ category: cat, items });
482
+ }
483
+ }
484
+ }
485
+
486
+ return { grouped: groups, flatItems: flat };
487
+ }, [commands, query, recentIds]);
488
+
489
+ useEffect(() => { setSelectedIndex(0); }, [query, open]);
490
+
491
+ useEffect(() => {
492
+ if (!navigatedByKeyboard.current || !listRef.current) return;
493
+ navigatedByKeyboard.current = false;
494
+ const el = listRef.current.querySelector(`[data-index="${selectedIndex}"]`) as HTMLElement | null;
495
+ el?.scrollIntoView({ block: 'nearest' });
496
+ }, [selectedIndex]);
497
+
498
+ const runCommand = useCallback((cmd: Command) => {
499
+ onOpenChange(false);
500
+ recordUsage(cmd.id);
501
+ requestAnimationFrame(() => cmd.action());
502
+ }, [onOpenChange]);
503
+
504
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
505
+ if (e.key === 'ArrowDown') {
506
+ e.preventDefault(); navigatedByKeyboard.current = true;
507
+ setSelectedIndex(i => Math.min(i + 1, flatItems.length - 1));
508
+ } else if (e.key === 'ArrowUp') {
509
+ e.preventDefault(); navigatedByKeyboard.current = true;
510
+ setSelectedIndex(i => Math.max(i - 1, 0));
511
+ } else if (e.key === 'Enter') {
512
+ e.preventDefault();
513
+ const item = flatItems[selectedIndex];
514
+ if (item) runCommand(item.cmd);
515
+ }
516
+ }, [flatItems, selectedIndex, runCommand]);
517
+
518
+ return (
519
+ <Dialog open={open} onOpenChange={onOpenChange}>
520
+ <DialogContent className="p-0 gap-0 max-w-lg overflow-hidden" aria-label="Command palette" hideCloseButton>
521
+ {/* Search */}
522
+ <div className="flex items-center gap-2 px-3 py-2.5 border-b">
523
+ <Search className="h-4 w-4 text-muted-foreground shrink-0" />
524
+ <input
525
+ ref={inputRef}
526
+ value={query}
527
+ onChange={e => setQuery(e.target.value)}
528
+ onKeyDown={handleKeyDown}
529
+ placeholder="What do you need?"
530
+ className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
531
+ autoComplete="off"
532
+ spellCheck={false}
533
+ />
534
+ <kbd className="hidden sm:inline-flex h-5 items-center gap-1 rounded border bg-muted px-1.5 text-[10px] font-medium text-muted-foreground">
535
+ Esc
536
+ </kbd>
537
+ </div>
538
+
539
+ {/* Results */}
540
+ <div ref={listRef} className="max-h-[min(420px,60vh)] overflow-y-auto py-1" role="listbox">
541
+ {flatItems.length === 0 && (
542
+ <div className="px-3 py-8 text-center text-sm text-muted-foreground">
543
+ No results
544
+ </div>
545
+ )}
546
+
547
+ {grouped.map((group) => (
548
+ <div key={group.category || '__flat'}>
549
+ {group.category && (
550
+ <div className="px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground select-none">
551
+ {group.category}
552
+ </div>
553
+ )}
554
+ {group.items.map(({ cmd, flatIdx }) => {
555
+ const Icon = cmd.icon;
556
+ return (
557
+ <button
558
+ key={`${group.category}:${cmd.id}`}
559
+ role="option"
560
+ data-index={flatIdx}
561
+ aria-selected={flatIdx === selectedIndex}
562
+ className={cn(
563
+ 'flex items-center gap-3 w-full px-3 py-2 text-left text-sm',
564
+ flatIdx === selectedIndex
565
+ ? 'bg-accent text-accent-foreground'
566
+ : 'text-foreground hover:bg-accent/50',
567
+ )}
568
+ onClick={() => runCommand(cmd)}
569
+ onMouseMove={() => { if (selectedIndex !== flatIdx) setSelectedIndex(flatIdx); }}
570
+ >
571
+ <Icon className="h-4 w-4 text-muted-foreground shrink-0" />
572
+ <span className="flex-1 truncate">{cmd.label}</span>
573
+ {cmd.detail && (
574
+ <span className="text-[11px] text-muted-foreground shrink-0">{cmd.detail}</span>
575
+ )}
576
+ {cmd.shortcut && (
577
+ <kbd className="ml-auto hidden sm:inline-flex h-5 min-w-[20px] items-center justify-center rounded border bg-muted px-1.5 text-[10px] font-medium text-muted-foreground shrink-0">
578
+ {cmd.shortcut}
579
+ </kbd>
580
+ )}
581
+ </button>
582
+ );
583
+ })}
584
+ </div>
585
+ ))}
586
+ </div>
587
+
588
+ {/* Footer */}
589
+ <div className="flex items-center gap-4 px-3 py-1.5 border-t text-[10px] text-muted-foreground select-none">
590
+ <span><kbd className="font-mono">↑↓</kbd> navigate</span>
591
+ <span><kbd className="font-mono">↵</kbd> run</span>
592
+ <span><kbd className="font-mono">esc</kbd> close</span>
593
+ </div>
594
+ </DialogContent>
595
+ </Dialog>
596
+ );
597
+ }