@ifc-lite/viewer 1.19.0 → 1.19.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.
- package/.turbo/turbo-build.log +15 -14
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +8 -0
- package/dist/assets/basketViewActivator-CA2CTcVo.js +71 -0
- package/dist/assets/{bcf-DOG9_WPX.js → bcf-4K724hw0.js} +18 -18
- package/dist/assets/{exporters-BraHBeoi.js → exporters-xbXqEDlO.js} +53 -46
- package/dist/assets/ids-2WdONLlu.js +2033 -0
- package/dist/assets/index-BXeEKqJG.css +1 -0
- package/dist/assets/{index-BOi3BuUI.js → index-D8Epw-e7.js} +48072 -30928
- package/dist/assets/{native-bridge-CpBeOPQa.js → native-bridge-DKmx1z95.js} +2 -2
- package/dist/assets/{sandbox-Baez7n-t.js → sandbox-tccwm5Bo.js} +547 -529
- package/dist/assets/{server-client-BB6cMAXE.js → server-client-LoWPK1N2.js} +1 -1
- package/dist/assets/three-CDRZThFA.js +4057 -0
- package/dist/assets/{wasm-bridge-CAYCUHbE.js → wasm-bridge-BsJGgPMs.js} +1 -1
- package/dist/index.html +8 -7
- package/dist/samples/building-architecture.ifc +453 -0
- package/dist/samples/hello-wall.ifc +1054 -0
- package/dist/samples/infra-bridge.ifc +962 -0
- package/package.json +7 -2
- package/public/samples/building-architecture.ifc +453 -0
- package/public/samples/hello-wall.ifc +1054 -0
- package/public/samples/infra-bridge.ifc +962 -0
- package/src/App.tsx +37 -3
- package/src/components/mcp/HeroScene.tsx +876 -0
- package/src/components/mcp/McpLanding.tsx +1318 -0
- package/src/components/mcp/McpPlayground.tsx +524 -0
- package/src/components/mcp/PlaygroundChat.tsx +1097 -0
- package/src/components/mcp/PlaygroundViewer.tsx +815 -0
- package/src/components/mcp/README.md +171 -0
- package/src/components/mcp/data.ts +659 -0
- package/src/components/mcp/playground-dispatcher.ts +1649 -0
- package/src/components/mcp/playground-files.ts +107 -0
- package/src/components/mcp/playground-uploads.ts +122 -0
- package/src/components/mcp/types.ts +65 -0
- package/src/components/mcp/use-mcp-page.ts +109 -0
- package/src/components/viewer/MainToolbar.tsx +19 -0
- package/src/components/viewer/ViewportContainer.tsx +35 -4
- package/src/generated/mcp-catalog.json +82 -0
- package/vite.config.ts +6 -0
- package/dist/assets/basketViewActivator-RZy5c3Td.js +0 -1
- package/dist/assets/ids-DQ5jY0E8.js +0 -1
- package/dist/assets/index-0XpVr_S5.css +0 -1
|
@@ -0,0 +1,524 @@
|
|
|
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
|
+
* /mcp/playground — interactive surface for the @ifc-lite/mcp tool catalogue.
|
|
7
|
+
*
|
|
8
|
+
* Layout: a 3-column resizable workspace
|
|
9
|
+
* • Left — sample picker + parsed model summary (entity counts, types,
|
|
10
|
+
* materials, units), file drop zone for ad-hoc uploads.
|
|
11
|
+
* • Centre — agent transcript with inline tool-call rendering.
|
|
12
|
+
* • Right (collapsible later) — selected tool spotlight from the catalogue.
|
|
13
|
+
*
|
|
14
|
+
* The model parses entirely in-browser via @ifc-lite/parser; the agent runs
|
|
15
|
+
* on Anthropic via BYOK; tool calls dispatch through `playground-dispatcher`
|
|
16
|
+
* against the local `BimContext`. No IFC ever leaves the browser.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
type CSSProperties,
|
|
21
|
+
type ReactNode,
|
|
22
|
+
useCallback,
|
|
23
|
+
useEffect,
|
|
24
|
+
useMemo,
|
|
25
|
+
useRef,
|
|
26
|
+
useState,
|
|
27
|
+
} from 'react';
|
|
28
|
+
import { ArrowLeft, Box, ChevronDown, ChevronRight, Download, Loader2, Upload, FileText, AlertTriangle, Trash2 } from 'lucide-react';
|
|
29
|
+
import { cn } from '@/lib/utils';
|
|
30
|
+
import { useDocumentMeta, useFonts } from './use-mcp-page';
|
|
31
|
+
import {
|
|
32
|
+
parsePlaygroundModel,
|
|
33
|
+
supportedToolNames,
|
|
34
|
+
type DispatchContext,
|
|
35
|
+
type LoadedPlaygroundModel,
|
|
36
|
+
} from './playground-dispatcher';
|
|
37
|
+
import { PlaygroundChat } from './PlaygroundChat';
|
|
38
|
+
import { PlaygroundViewer, type ViewerController } from './PlaygroundViewer';
|
|
39
|
+
import { playgroundFiles, usePlaygroundFiles, formatBytes as formatFileBytes } from './playground-files';
|
|
40
|
+
|
|
41
|
+
const NIGHT = '#0a0a0c';
|
|
42
|
+
const PANEL = '#101014';
|
|
43
|
+
const RULE = 'rgba(237, 228, 211, 0.08)';
|
|
44
|
+
const PAPER = '#ede4d3';
|
|
45
|
+
const PAPER_DIM = 'rgba(237, 228, 211, 0.55)';
|
|
46
|
+
const ACCENT = '#d6ff3f';
|
|
47
|
+
|
|
48
|
+
const stage: CSSProperties = {
|
|
49
|
+
background: NIGHT,
|
|
50
|
+
color: PAPER,
|
|
51
|
+
fontFamily: '"Bricolage Grotesque", system-ui, sans-serif',
|
|
52
|
+
};
|
|
53
|
+
const display: CSSProperties = {
|
|
54
|
+
fontFamily: '"Instrument Serif", serif',
|
|
55
|
+
fontStyle: 'normal',
|
|
56
|
+
};
|
|
57
|
+
const mono: CSSProperties = {
|
|
58
|
+
fontFamily: '"JetBrains Mono", ui-monospace, monospace',
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
interface SampleEntry {
|
|
62
|
+
id: string;
|
|
63
|
+
label: string;
|
|
64
|
+
blurb: string;
|
|
65
|
+
url: string;
|
|
66
|
+
approxBytes: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const SAMPLES: SampleEntry[] = [
|
|
70
|
+
{ id: 'hello-wall', label: 'Hello Wall', blurb: 'IFC5 minimal · 1 wall, 1 storey', url: '/samples/hello-wall.ifc', approxBytes: 78_000 },
|
|
71
|
+
{ id: 'building-architecture', label: 'Building / Architecture', blurb: 'buildingSMART sample · 444 entities, IFC4', url: '/samples/building-architecture.ifc', approxBytes: 220_000 },
|
|
72
|
+
{ id: 'infra-bridge', label: 'Infra Bridge', blurb: 'Infrastructure · IFC4.3 bridge sample', url: '/samples/infra-bridge.ifc', approxBytes: 1_800_000 },
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
export function McpPlayground(): ReactNode {
|
|
76
|
+
useFonts(
|
|
77
|
+
'https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Bricolage+Grotesque:opsz,wght@12..96,300;12..96,400;12..96,500;12..96,600;12..96,700&family=JetBrains+Mono:wght@400;500;600&display=swap',
|
|
78
|
+
);
|
|
79
|
+
useDocumentMeta('@ifc-lite/mcp · playground', NIGHT);
|
|
80
|
+
|
|
81
|
+
const [model, setModel] = useState<LoadedPlaygroundModel | null>(null);
|
|
82
|
+
const [loadingId, setLoadingId] = useState<string | null>(null);
|
|
83
|
+
const [error, setError] = useState<string | null>(null);
|
|
84
|
+
const [viewerOpen, setViewerOpen] = useState(false);
|
|
85
|
+
const viewerRef = useRef<ViewerController | null>(null);
|
|
86
|
+
|
|
87
|
+
// Stable context getter — keeps the chat panel from re-running its
|
|
88
|
+
// dispatch closure every render while still letting the viewer ref
|
|
89
|
+
// attach late (the viewer component isn't mounted until the user
|
|
90
|
+
// expands the panel).
|
|
91
|
+
const getDispatchContext = useCallback<() => DispatchContext>(
|
|
92
|
+
() => ({
|
|
93
|
+
viewer: viewerRef.current ?? null,
|
|
94
|
+
openViewerPanel: () => setViewerOpen(true),
|
|
95
|
+
}),
|
|
96
|
+
[],
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const loadFromUrl = useCallback(async (entry: SampleEntry) => {
|
|
100
|
+
setLoadingId(entry.id);
|
|
101
|
+
setError(null);
|
|
102
|
+
try {
|
|
103
|
+
const res = await fetch(entry.url);
|
|
104
|
+
if (!res.ok) throw new Error(`Failed to fetch ${entry.label}: HTTP ${res.status}`);
|
|
105
|
+
const buf = await res.arrayBuffer();
|
|
106
|
+
const m = await parsePlaygroundModel(buf, `${entry.id}.ifc`);
|
|
107
|
+
setModel(m);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
110
|
+
} finally {
|
|
111
|
+
setLoadingId(null);
|
|
112
|
+
}
|
|
113
|
+
}, []);
|
|
114
|
+
|
|
115
|
+
const loadFromFile = useCallback(async (file: File) => {
|
|
116
|
+
setLoadingId('upload');
|
|
117
|
+
setError(null);
|
|
118
|
+
try {
|
|
119
|
+
const buf = await file.arrayBuffer();
|
|
120
|
+
const m = await parsePlaygroundModel(buf, file.name);
|
|
121
|
+
setModel(m);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
124
|
+
} finally {
|
|
125
|
+
setLoadingId(null);
|
|
126
|
+
}
|
|
127
|
+
}, []);
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<main style={stage} className="flex h-screen min-h-screen flex-col antialiased">
|
|
131
|
+
<TopBar onClose={() => setModel(null)} hasModel={!!model} />
|
|
132
|
+
|
|
133
|
+
<div className="grid flex-1 min-h-0 grid-cols-1 md:grid-cols-[340px_1fr]">
|
|
134
|
+
{/* Sidebar */}
|
|
135
|
+
<aside
|
|
136
|
+
className="flex flex-col gap-4 overflow-y-auto border-b border-white/10 px-5 py-5 md:border-b-0 md:border-r"
|
|
137
|
+
style={{ background: PANEL }}
|
|
138
|
+
>
|
|
139
|
+
<div>
|
|
140
|
+
<h2
|
|
141
|
+
className="text-[26px] leading-none tracking-tight"
|
|
142
|
+
style={{ ...display, fontStyle: 'italic' }}
|
|
143
|
+
>
|
|
144
|
+
Playground.
|
|
145
|
+
</h2>
|
|
146
|
+
<p className="mt-1.5 text-[12.5px] leading-snug" style={{ color: PAPER_DIM }}>
|
|
147
|
+
Pick a sample IFC. Then chat. The agent drives the same {supportedToolNames().length} tools the stdio MCP exposes — query, mutate, validate, BCF, export. Models stay in your browser.
|
|
148
|
+
</p>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<SampleList samples={SAMPLES} loadingId={loadingId} activeId={model && SAMPLES.find((s) => s.id === modelIdFor(model)) ? modelIdFor(model) : null} onPick={loadFromUrl} />
|
|
152
|
+
|
|
153
|
+
<DropZone disabled={loadingId !== null} onFile={loadFromFile} />
|
|
154
|
+
|
|
155
|
+
{error && (
|
|
156
|
+
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-[12px] text-red-200">
|
|
157
|
+
<AlertTriangle size={12} className="mr-1 inline" />
|
|
158
|
+
{error}
|
|
159
|
+
</div>
|
|
160
|
+
)}
|
|
161
|
+
|
|
162
|
+
{model && <ModelSummary model={model} />}
|
|
163
|
+
|
|
164
|
+
<DownloadsPanel />
|
|
165
|
+
|
|
166
|
+
<FooterLinks />
|
|
167
|
+
</aside>
|
|
168
|
+
|
|
169
|
+
{/* Right column: collapsible 3D viewer above the chat. The viewer
|
|
170
|
+
component is unmounted while collapsed so we don’t hold a WebGL
|
|
171
|
+
context for nothing. The dispatcher's `openViewerPanel()` flips
|
|
172
|
+
`viewerOpen` so the agent can request the panel programmatically. */}
|
|
173
|
+
<section className="flex min-h-0 flex-col">
|
|
174
|
+
<ViewerPanel
|
|
175
|
+
model={model}
|
|
176
|
+
open={viewerOpen}
|
|
177
|
+
onToggle={() => setViewerOpen((o) => !o)}
|
|
178
|
+
controllerRef={viewerRef}
|
|
179
|
+
/>
|
|
180
|
+
<div className="flex min-h-0 flex-1 flex-col">
|
|
181
|
+
<PlaygroundChat model={model} dispatchContext={getDispatchContext} />
|
|
182
|
+
</div>
|
|
183
|
+
</section>
|
|
184
|
+
</div>
|
|
185
|
+
</main>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── top bar ────────────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
function TopBar({ onClose, hasModel }: { onClose: () => void; hasModel: boolean }): ReactNode {
|
|
192
|
+
return (
|
|
193
|
+
<div className="flex items-center justify-between border-b border-white/10 px-5 py-3">
|
|
194
|
+
<a href="/mcp" className="flex items-center gap-2 text-[13px] text-white/70 hover:text-white">
|
|
195
|
+
<ArrowLeft size={14} />
|
|
196
|
+
<span>back to /mcp</span>
|
|
197
|
+
</a>
|
|
198
|
+
<div className="flex items-center gap-3">
|
|
199
|
+
<a
|
|
200
|
+
href="/"
|
|
201
|
+
className="hidden items-center gap-1 text-[10.5px] uppercase tracking-[0.22em] text-white/40 hover:text-white sm:inline-flex"
|
|
202
|
+
style={mono}
|
|
203
|
+
>
|
|
204
|
+
viewer
|
|
205
|
+
</a>
|
|
206
|
+
<span style={{ ...mono, color: PAPER_DIM }} className="text-[10px] uppercase tracking-[0.22em]">
|
|
207
|
+
/mcp/playground
|
|
208
|
+
</span>
|
|
209
|
+
{hasModel && (
|
|
210
|
+
<button
|
|
211
|
+
onClick={onClose}
|
|
212
|
+
className="inline-flex items-center gap-1 rounded border border-white/15 px-2 py-1 text-[10.5px] hover:bg-white/5"
|
|
213
|
+
style={{ ...mono, color: PAPER_DIM }}
|
|
214
|
+
>
|
|
215
|
+
<Trash2 size={11} /> unload
|
|
216
|
+
</button>
|
|
217
|
+
)}
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── samples ────────────────────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
function SampleList({
|
|
226
|
+
samples,
|
|
227
|
+
loadingId,
|
|
228
|
+
activeId,
|
|
229
|
+
onPick,
|
|
230
|
+
}: {
|
|
231
|
+
samples: SampleEntry[];
|
|
232
|
+
loadingId: string | null;
|
|
233
|
+
activeId: string | null;
|
|
234
|
+
onPick: (s: SampleEntry) => void;
|
|
235
|
+
}): ReactNode {
|
|
236
|
+
return (
|
|
237
|
+
<div className="flex flex-col gap-2">
|
|
238
|
+
<span style={{ ...mono, color: PAPER_DIM }} className="text-[10px] uppercase tracking-[0.22em]">
|
|
239
|
+
sample models
|
|
240
|
+
</span>
|
|
241
|
+
<ul className="flex flex-col gap-1.5">
|
|
242
|
+
{samples.map((s) => {
|
|
243
|
+
const isActive = activeId === s.id;
|
|
244
|
+
const isLoading = loadingId === s.id;
|
|
245
|
+
return (
|
|
246
|
+
<li key={s.id}>
|
|
247
|
+
<button
|
|
248
|
+
onClick={() => onPick(s)}
|
|
249
|
+
disabled={loadingId !== null}
|
|
250
|
+
className={cn(
|
|
251
|
+
'group flex w-full items-start justify-between gap-3 rounded-md border px-3 py-2.5 text-left transition-colors',
|
|
252
|
+
isActive
|
|
253
|
+
? 'border-[#d6ff3f]/60 bg-[#d6ff3f]/10'
|
|
254
|
+
: 'border-white/10 hover:bg-white/5',
|
|
255
|
+
loadingId !== null && !isLoading && 'opacity-50',
|
|
256
|
+
)}
|
|
257
|
+
>
|
|
258
|
+
<div className="min-w-0 flex-1">
|
|
259
|
+
<div className="flex items-center gap-2">
|
|
260
|
+
<span className="text-[13.5px] font-medium" style={{ color: PAPER }}>
|
|
261
|
+
{s.label}
|
|
262
|
+
</span>
|
|
263
|
+
{isLoading && <Loader2 size={11} className="animate-spin" style={{ color: ACCENT }} />}
|
|
264
|
+
</div>
|
|
265
|
+
<p className="mt-0.5 text-[11px]" style={{ color: PAPER_DIM }}>
|
|
266
|
+
{s.blurb}
|
|
267
|
+
</p>
|
|
268
|
+
</div>
|
|
269
|
+
<span style={{ ...mono, color: PAPER_DIM }} className="shrink-0 text-[10px]">
|
|
270
|
+
{formatBytes(s.approxBytes)}
|
|
271
|
+
</span>
|
|
272
|
+
</button>
|
|
273
|
+
</li>
|
|
274
|
+
);
|
|
275
|
+
})}
|
|
276
|
+
</ul>
|
|
277
|
+
</div>
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function DropZone({
|
|
282
|
+
onFile,
|
|
283
|
+
disabled,
|
|
284
|
+
}: {
|
|
285
|
+
onFile: (file: File) => void;
|
|
286
|
+
disabled: boolean;
|
|
287
|
+
}): ReactNode {
|
|
288
|
+
const [hover, setHover] = useState(false);
|
|
289
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
290
|
+
return (
|
|
291
|
+
<label
|
|
292
|
+
onDragOver={(e) => { e.preventDefault(); setHover(true); }}
|
|
293
|
+
onDragLeave={() => setHover(false)}
|
|
294
|
+
onDrop={(e) => {
|
|
295
|
+
e.preventDefault();
|
|
296
|
+
setHover(false);
|
|
297
|
+
const f = e.dataTransfer.files[0];
|
|
298
|
+
if (f && /\.ifc$/i.test(f.name)) onFile(f);
|
|
299
|
+
}}
|
|
300
|
+
className={cn(
|
|
301
|
+
'flex cursor-pointer flex-col items-center gap-1 rounded-md border-2 border-dashed px-3 py-4 text-center transition-colors',
|
|
302
|
+
hover ? 'border-[#d6ff3f]/60 bg-[#d6ff3f]/10' : 'border-white/15 hover:border-white/25',
|
|
303
|
+
disabled && 'pointer-events-none opacity-40',
|
|
304
|
+
)}
|
|
305
|
+
style={{ color: PAPER_DIM }}
|
|
306
|
+
>
|
|
307
|
+
<Upload size={14} />
|
|
308
|
+
<span className="text-[11.5px]">drop an .ifc, or click to pick</span>
|
|
309
|
+
<input
|
|
310
|
+
ref={inputRef}
|
|
311
|
+
type="file"
|
|
312
|
+
accept=".ifc"
|
|
313
|
+
className="hidden"
|
|
314
|
+
onChange={(e) => {
|
|
315
|
+
const f = e.target.files?.[0];
|
|
316
|
+
if (f) onFile(f);
|
|
317
|
+
}}
|
|
318
|
+
/>
|
|
319
|
+
</label>
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ── model summary ─────────────────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
function ModelSummary({ model }: { model: LoadedPlaygroundModel }): ReactNode {
|
|
326
|
+
const top = useMemo(() => {
|
|
327
|
+
const counts: Array<{ type: string; count: number }> = [];
|
|
328
|
+
for (const [type, ids] of model.store.entityIndex.byType) counts.push({ type, count: ids.length });
|
|
329
|
+
counts.sort((a, b) => b.count - a.count);
|
|
330
|
+
return counts.slice(0, 8);
|
|
331
|
+
}, [model]);
|
|
332
|
+
|
|
333
|
+
return (
|
|
334
|
+
<div className="flex flex-col gap-2 rounded-md border border-white/10 bg-white/[0.02] p-3">
|
|
335
|
+
<div className="flex items-center gap-2">
|
|
336
|
+
<FileText size={12} style={{ color: ACCENT }} />
|
|
337
|
+
<span className="text-[11.5px]" style={{ color: PAPER }}>
|
|
338
|
+
{model.name}
|
|
339
|
+
</span>
|
|
340
|
+
</div>
|
|
341
|
+
<dl className="grid grid-cols-3 gap-2 text-[11px]" style={{ ...mono, color: PAPER_DIM }}>
|
|
342
|
+
<div>
|
|
343
|
+
<dt className="text-[9px] uppercase tracking-[0.2em]">schema</dt>
|
|
344
|
+
<dd style={{ color: PAPER }}>{model.store.schemaVersion}</dd>
|
|
345
|
+
</div>
|
|
346
|
+
<div>
|
|
347
|
+
<dt className="text-[9px] uppercase tracking-[0.2em]">entities</dt>
|
|
348
|
+
<dd style={{ color: PAPER }}>{model.store.entityCount.toLocaleString()}</dd>
|
|
349
|
+
</div>
|
|
350
|
+
<div>
|
|
351
|
+
<dt className="text-[9px] uppercase tracking-[0.2em]">file</dt>
|
|
352
|
+
<dd style={{ color: PAPER }}>{formatBytes(model.fileSize)}</dd>
|
|
353
|
+
</div>
|
|
354
|
+
</dl>
|
|
355
|
+
|
|
356
|
+
<div className="mt-1 border-t border-white/10 pt-2">
|
|
357
|
+
<div className="mb-1 text-[9px] uppercase tracking-[0.22em]" style={{ ...mono, color: PAPER_DIM }}>
|
|
358
|
+
top entity types
|
|
359
|
+
</div>
|
|
360
|
+
<ul className="flex flex-col gap-0.5">
|
|
361
|
+
{top.map((row) => (
|
|
362
|
+
<li key={row.type} className="flex items-baseline justify-between gap-2 text-[11.5px]">
|
|
363
|
+
<span className="truncate" style={{ ...mono, color: PAPER_DIM }}>
|
|
364
|
+
{row.type}
|
|
365
|
+
</span>
|
|
366
|
+
<span style={{ ...mono, color: PAPER }}>{row.count}</span>
|
|
367
|
+
</li>
|
|
368
|
+
))}
|
|
369
|
+
</ul>
|
|
370
|
+
</div>
|
|
371
|
+
</div>
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ── footer ─────────────────────────────────────────────────────────────────
|
|
376
|
+
|
|
377
|
+
function FooterLinks(): ReactNode {
|
|
378
|
+
return (
|
|
379
|
+
<div className="mt-auto flex items-center justify-between gap-2 border-t border-white/10 pt-3 text-[10.5px]" style={{ ...mono, color: PAPER_DIM }}>
|
|
380
|
+
<a href="/mcp" className="hover:text-white">tools</a>
|
|
381
|
+
<a href="https://github.com/louistrue/ifc-lite" className="hover:text-white">github</a>
|
|
382
|
+
<a href="https://www.npmjs.com/package/@ifc-lite/mcp" className="hover:text-white">npm</a>
|
|
383
|
+
<a href="/" className="hover:text-white">viewer</a>
|
|
384
|
+
</div>
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ── helpers ───────────────────────────────────────────────────────────────
|
|
389
|
+
|
|
390
|
+
function formatBytes(bytes: number): string {
|
|
391
|
+
if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
392
|
+
if (bytes >= 1024) return (bytes / 1024).toFixed(0) + ' KB';
|
|
393
|
+
return bytes + ' B';
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function modelIdFor(model: LoadedPlaygroundModel): string {
|
|
397
|
+
// The dispatcher derives ids from the filename; we encode SAMPLES with the
|
|
398
|
+
// same prefix so we can spot the active sample in the picker.
|
|
399
|
+
return model.id;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ── downloads panel ───────────────────────────────────────────────────────
|
|
403
|
+
//
|
|
404
|
+
// Tools that "write a file" (bcf_export, model_save, export_*) push their
|
|
405
|
+
// artifact into `playgroundFiles` instead of triggering a browser download.
|
|
406
|
+
// This panel renders one row per file with an explicit Download button —
|
|
407
|
+
// the actual <a download> click only happens when the USER presses it,
|
|
408
|
+
// never auto-triggered.
|
|
409
|
+
|
|
410
|
+
function DownloadsPanel(): ReactNode {
|
|
411
|
+
const files = usePlaygroundFiles();
|
|
412
|
+
if (files.length === 0) return null;
|
|
413
|
+
return (
|
|
414
|
+
<div className="flex flex-col gap-2">
|
|
415
|
+
<div className="flex items-baseline justify-between">
|
|
416
|
+
<span style={{ ...mono, color: PAPER_DIM }} className="text-[10px] uppercase tracking-[0.22em]">
|
|
417
|
+
downloads · {files.length}
|
|
418
|
+
</span>
|
|
419
|
+
<button
|
|
420
|
+
onClick={() => playgroundFiles.clear()}
|
|
421
|
+
style={{ ...mono, color: PAPER_DIM }}
|
|
422
|
+
className="text-[10px] uppercase tracking-[0.18em] hover:text-white"
|
|
423
|
+
>
|
|
424
|
+
clear
|
|
425
|
+
</button>
|
|
426
|
+
</div>
|
|
427
|
+
<ul className="flex flex-col gap-1.5">
|
|
428
|
+
{files.map((f) => (
|
|
429
|
+
<li
|
|
430
|
+
key={f.id}
|
|
431
|
+
className="flex flex-col gap-1.5 rounded-md border border-white/10 bg-white/[0.02] px-3 py-2"
|
|
432
|
+
>
|
|
433
|
+
<div className="flex items-baseline justify-between gap-2">
|
|
434
|
+
<span className="truncate text-[12.5px]" style={{ color: PAPER }} title={f.filename}>
|
|
435
|
+
{f.filename}
|
|
436
|
+
</span>
|
|
437
|
+
<span style={{ ...mono, color: PAPER_DIM }} className="shrink-0 text-[10px]">
|
|
438
|
+
{formatFileBytes(f.size)}
|
|
439
|
+
</span>
|
|
440
|
+
</div>
|
|
441
|
+
{f.description && (
|
|
442
|
+
<span className="text-[10.5px] leading-snug" style={{ color: PAPER_DIM }}>
|
|
443
|
+
{f.description}
|
|
444
|
+
</span>
|
|
445
|
+
)}
|
|
446
|
+
<div className="flex items-center justify-between gap-2 pt-0.5">
|
|
447
|
+
<span style={{ ...mono, color: PAPER_DIM }} className="text-[9.5px] uppercase tracking-[0.18em]">
|
|
448
|
+
from {f.source}
|
|
449
|
+
</span>
|
|
450
|
+
<div className="flex items-center gap-1">
|
|
451
|
+
<button
|
|
452
|
+
onClick={() => playgroundFiles.remove(f.id)}
|
|
453
|
+
className="rounded p-1 text-white/40 hover:bg-white/5 hover:text-white"
|
|
454
|
+
aria-label="Remove"
|
|
455
|
+
title="Remove from list"
|
|
456
|
+
>
|
|
457
|
+
<Trash2 size={12} />
|
|
458
|
+
</button>
|
|
459
|
+
<button
|
|
460
|
+
onClick={() => playgroundFiles.download(f.id)}
|
|
461
|
+
className="inline-flex items-center gap-1 rounded px-2 py-1 text-[10.5px] font-semibold"
|
|
462
|
+
style={{ background: ACCENT, color: NIGHT, ...mono }}
|
|
463
|
+
>
|
|
464
|
+
<Download size={11} />
|
|
465
|
+
download
|
|
466
|
+
</button>
|
|
467
|
+
</div>
|
|
468
|
+
</div>
|
|
469
|
+
</li>
|
|
470
|
+
))}
|
|
471
|
+
</ul>
|
|
472
|
+
</div>
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// ── inline viewer panel ───────────────────────────────────────────────────
|
|
477
|
+
|
|
478
|
+
function ViewerPanel({
|
|
479
|
+
model,
|
|
480
|
+
open,
|
|
481
|
+
onToggle,
|
|
482
|
+
controllerRef,
|
|
483
|
+
}: {
|
|
484
|
+
model: LoadedPlaygroundModel | null;
|
|
485
|
+
open: boolean;
|
|
486
|
+
onToggle: () => void;
|
|
487
|
+
controllerRef: React.MutableRefObject<ViewerController | null>;
|
|
488
|
+
}): ReactNode {
|
|
489
|
+
return (
|
|
490
|
+
<div className={cn('border-b border-white/10 transition-[height]')}>
|
|
491
|
+
<button
|
|
492
|
+
onClick={onToggle}
|
|
493
|
+
className="flex w-full items-center justify-between gap-3 px-4 py-2.5 hover:bg-white/[0.025]"
|
|
494
|
+
>
|
|
495
|
+
<span className="flex items-center gap-2">
|
|
496
|
+
<Box size={13} style={{ color: ACCENT }} />
|
|
497
|
+
<span className="text-[12px]" style={{ color: PAPER }}>
|
|
498
|
+
3D viewer
|
|
499
|
+
</span>
|
|
500
|
+
<span style={{ ...mono, color: PAPER_DIM }} className="text-[10px] uppercase tracking-[0.22em]">
|
|
501
|
+
{open ? 'on' : 'off'} · inline · agent-driven
|
|
502
|
+
</span>
|
|
503
|
+
</span>
|
|
504
|
+
<span className="flex items-center gap-2">
|
|
505
|
+
{!model && (
|
|
506
|
+
<span style={{ ...mono, color: PAPER_DIM }} className="text-[10px]">
|
|
507
|
+
load a model first
|
|
508
|
+
</span>
|
|
509
|
+
)}
|
|
510
|
+
{open ? <ChevronDown size={14} style={{ color: PAPER_DIM }} /> : <ChevronRight size={14} style={{ color: PAPER_DIM }} />}
|
|
511
|
+
</span>
|
|
512
|
+
</button>
|
|
513
|
+
{open && (
|
|
514
|
+
<div className="relative h-[360px] w-full border-t border-white/10">
|
|
515
|
+
<PlaygroundViewer
|
|
516
|
+
ref={controllerRef}
|
|
517
|
+
model={model}
|
|
518
|
+
className="absolute inset-0"
|
|
519
|
+
/>
|
|
520
|
+
</div>
|
|
521
|
+
)}
|
|
522
|
+
</div>
|
|
523
|
+
);
|
|
524
|
+
}
|