@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.
- package/CHANGELOG.md +88 -0
- package/dist/assets/{Arrow.dom-BGPQieQQ.js → Arrow.dom-CusgkT03.js} +1 -1
- package/dist/assets/browser-BXNIkE8a.js +694 -0
- package/dist/assets/emscripten-module-BTRCZGcB.wasm +0 -0
- package/dist/assets/emscripten-module-CGIn_cMh.wasm +0 -0
- package/dist/assets/emscripten-module-DYvzWiHh.wasm +0 -0
- package/dist/assets/emscripten-module-NWak2PoB.wasm +0 -0
- package/dist/assets/emscripten-module.browser-CY5t0Vfq.js +1 -0
- package/dist/assets/esbuild-COv63sf-.js +1 -0
- package/dist/assets/esbuild-Cpd5nU_H.wasm +0 -0
- package/dist/assets/ffi-DlhRHxHv.js +1 -0
- package/dist/assets/index-6Mr3byM-.js +216 -0
- package/dist/assets/index-CGbokkQ9.css +1 -0
- package/dist/assets/index-huvR-kGC.js +98305 -0
- package/dist/assets/module-6F3E5H7Y-tx0BadV3.js +6 -0
- package/dist/assets/{native-bridge-DD0SNyQ5.js → native-bridge-DsHOKdgD.js} +1 -1
- package/dist/assets/{wasm-bridge-D54YMO7X.js → wasm-bridge-Bd73HXn-.js} +1 -1
- package/dist/index.html +12 -3
- package/index.html +10 -1
- package/package.json +30 -21
- package/src/App.tsx +6 -1
- package/src/components/ui/dialog.tsx +8 -6
- package/src/components/viewer/CodeEditor.tsx +309 -0
- package/src/components/viewer/CommandPalette.tsx +597 -0
- package/src/components/viewer/Drawing2DCanvas.tsx +364 -1
- package/src/components/viewer/EntityContextMenu.tsx +47 -20
- package/src/components/viewer/ExportDialog.tsx +166 -17
- package/src/components/viewer/HierarchyPanel.tsx +3 -1
- package/src/components/viewer/LensPanel.tsx +848 -85
- package/src/components/viewer/MainToolbar.tsx +145 -84
- package/src/components/viewer/ScriptPanel.tsx +416 -0
- package/src/components/viewer/Section2DPanel.tsx +269 -29
- package/src/components/viewer/TextAnnotationEditor.tsx +112 -0
- package/src/components/viewer/ViewerLayout.tsx +63 -11
- package/src/components/viewer/Viewport.tsx +58 -23
- package/src/components/viewer/ViewportContainer.tsx +2 -0
- package/src/components/viewer/hierarchy/HierarchyNode.tsx +1 -1
- package/src/components/viewer/hierarchy/types.ts +1 -1
- package/src/components/viewer/lists/ListResultsTable.tsx +53 -19
- package/src/components/viewer/tools/cloudPathGenerator.test.ts +118 -0
- package/src/components/viewer/tools/cloudPathGenerator.ts +275 -0
- package/src/components/viewer/tools/computePolygonArea.test.ts +165 -0
- package/src/components/viewer/tools/computePolygonArea.ts +72 -0
- package/src/components/viewer/useGeometryStreaming.ts +25 -5
- package/src/hooks/ids/idsExportService.ts +1 -1
- package/src/hooks/useAnnotation2D.ts +551 -0
- package/src/hooks/useDrawingExport.ts +83 -1
- package/src/hooks/useKeyboardShortcuts.ts +114 -14
- package/src/hooks/useLens.ts +40 -55
- package/src/hooks/useLensDiscovery.ts +46 -0
- package/src/hooks/useModelSelection.ts +5 -22
- package/src/hooks/useSandbox.ts +113 -0
- package/src/index.css +7 -1
- package/src/lib/lens/adapter.ts +127 -1
- package/src/lib/lists/columnToAutoColor.ts +33 -0
- package/src/lib/recent-files.ts +122 -0
- package/src/lib/scripts/persistence.ts +132 -0
- package/src/lib/scripts/templates/bim-globals.d.ts +111 -0
- package/src/lib/scripts/templates/data-quality-audit.ts +149 -0
- package/src/lib/scripts/templates/envelope-check.ts +164 -0
- package/src/lib/scripts/templates/federation-compare.ts +189 -0
- package/src/lib/scripts/templates/fire-safety-check.ts +161 -0
- package/src/lib/scripts/templates/mep-equipment-schedule.ts +175 -0
- package/src/lib/scripts/templates/quantity-takeoff.ts +145 -0
- package/src/lib/scripts/templates/reset-view.ts +6 -0
- package/src/lib/scripts/templates/space-validation.ts +189 -0
- package/src/lib/scripts/templates/tsconfig.json +13 -0
- package/src/lib/scripts/templates.ts +86 -0
- package/src/sdk/BimProvider.tsx +50 -0
- package/src/sdk/adapters/export-adapter.ts +283 -0
- package/src/sdk/adapters/lens-adapter.ts +44 -0
- package/src/sdk/adapters/model-adapter.ts +32 -0
- package/src/sdk/adapters/model-compat.ts +80 -0
- package/src/sdk/adapters/mutate-adapter.ts +45 -0
- package/src/sdk/adapters/query-adapter.ts +241 -0
- package/src/sdk/adapters/selection-adapter.ts +29 -0
- package/src/sdk/adapters/spatial-adapter.ts +37 -0
- package/src/sdk/adapters/types.ts +11 -0
- package/src/sdk/adapters/viewer-adapter.ts +103 -0
- package/src/sdk/adapters/visibility-adapter.ts +61 -0
- package/src/sdk/local-backend.ts +144 -0
- package/src/sdk/useBimHost.ts +69 -0
- package/src/store/constants.ts +10 -2
- package/src/store/index.ts +28 -2
- package/src/store/resolveEntityRef.ts +44 -0
- package/src/store/slices/drawing2DSlice.ts +321 -0
- package/src/store/slices/lensSlice.ts +46 -4
- package/src/store/slices/pinboardSlice.ts +171 -42
- package/src/store/slices/scriptSlice.ts +218 -0
- package/src/store/slices/uiSlice.ts +2 -0
- package/src/store.ts +3 -0
- package/tsconfig.json +5 -2
- package/vite.config.ts +8 -0
- package/dist/assets/index-dgdgiQ9p.js +0 -75456
- 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
|
+
}
|