@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.
Files changed (42) hide show
  1. package/.turbo/turbo-build.log +15 -14
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +8 -0
  4. package/dist/assets/basketViewActivator-CA2CTcVo.js +71 -0
  5. package/dist/assets/{bcf-DOG9_WPX.js → bcf-4K724hw0.js} +18 -18
  6. package/dist/assets/{exporters-BraHBeoi.js → exporters-xbXqEDlO.js} +53 -46
  7. package/dist/assets/ids-2WdONLlu.js +2033 -0
  8. package/dist/assets/index-BXeEKqJG.css +1 -0
  9. package/dist/assets/{index-BOi3BuUI.js → index-D8Epw-e7.js} +48072 -30928
  10. package/dist/assets/{native-bridge-CpBeOPQa.js → native-bridge-DKmx1z95.js} +2 -2
  11. package/dist/assets/{sandbox-Baez7n-t.js → sandbox-tccwm5Bo.js} +547 -529
  12. package/dist/assets/{server-client-BB6cMAXE.js → server-client-LoWPK1N2.js} +1 -1
  13. package/dist/assets/three-CDRZThFA.js +4057 -0
  14. package/dist/assets/{wasm-bridge-CAYCUHbE.js → wasm-bridge-BsJGgPMs.js} +1 -1
  15. package/dist/index.html +8 -7
  16. package/dist/samples/building-architecture.ifc +453 -0
  17. package/dist/samples/hello-wall.ifc +1054 -0
  18. package/dist/samples/infra-bridge.ifc +962 -0
  19. package/package.json +7 -2
  20. package/public/samples/building-architecture.ifc +453 -0
  21. package/public/samples/hello-wall.ifc +1054 -0
  22. package/public/samples/infra-bridge.ifc +962 -0
  23. package/src/App.tsx +37 -3
  24. package/src/components/mcp/HeroScene.tsx +876 -0
  25. package/src/components/mcp/McpLanding.tsx +1318 -0
  26. package/src/components/mcp/McpPlayground.tsx +524 -0
  27. package/src/components/mcp/PlaygroundChat.tsx +1097 -0
  28. package/src/components/mcp/PlaygroundViewer.tsx +815 -0
  29. package/src/components/mcp/README.md +171 -0
  30. package/src/components/mcp/data.ts +659 -0
  31. package/src/components/mcp/playground-dispatcher.ts +1649 -0
  32. package/src/components/mcp/playground-files.ts +107 -0
  33. package/src/components/mcp/playground-uploads.ts +122 -0
  34. package/src/components/mcp/types.ts +65 -0
  35. package/src/components/mcp/use-mcp-page.ts +109 -0
  36. package/src/components/viewer/MainToolbar.tsx +19 -0
  37. package/src/components/viewer/ViewportContainer.tsx +35 -4
  38. package/src/generated/mcp-catalog.json +82 -0
  39. package/vite.config.ts +6 -0
  40. package/dist/assets/basketViewActivator-RZy5c3Td.js +0 -1
  41. package/dist/assets/ids-DQ5jY0E8.js +0 -1
  42. 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
+ }