chisel/src/client/App.tsx
maverick 974e26f592 Chisel v0 — AI CAD single-shot generator
Chat → Claude writes a JSCAD script → sandboxed Web Worker executes it →
live three.js preview + STL export. Parametric // @param comments become
live sliders. Server-side repair loop fixes geometry errors before the user
sees them.

- src/worker: Hono API, Claude orchestration (prompt-cached system prompt),
  /api/generate + /api/repair
- src/client: React + react-three-fiber viewport, JSCAD engine Web Worker
- Radical-branded (red #FF2D55 on #0B0B09, Syne + IBM Plex Mono)

Engine validated end-to-end: mug model → 3256-triangle valid binary STL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 00:03:38 +10:00

209 lines
6.9 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import { Viewer } from "./components/Viewer";
import { runJscad, parseParams, type Param } from "./engine";
interface Msg {
role: "user" | "assistant";
content: string;
}
const MAX_REPAIRS = 3;
interface GenResponse {
script?: string;
summary?: string;
error?: string;
}
export default function App() {
const [messages, setMessages] = useState<Msg[]>([]);
const [input, setInput] = useState("");
const [script, setScript] = useState("");
const [params, setParams] = useState<Param[]>([]);
const [stl, setStl] = useState<ArrayBuffer | null>(null);
const [status, setStatus] = useState<string>("");
const [busy, setBusy] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight);
}, [messages, status]);
const paramValues = useCallback(
(list: Param[]) => Object.fromEntries(list.map((p) => [p.name, p.value])),
[]
);
// Run a script through the worker; on failure, ask the server to repair and
// retry up to MAX_REPAIRS. Returns the script that actually executed.
const executeWithRepair = useCallback(
async (initialScript: string, values: Record<string, number>): Promise<string> => {
let current = initialScript;
for (let attempt = 0; attempt <= MAX_REPAIRS; attempt++) {
try {
const buf = await runJscad(current, values);
setStl(buf);
return current;
} catch (err) {
if (attempt === MAX_REPAIRS) throw err;
setStatus(`Fixing a geometry error (attempt ${attempt + 1}/${MAX_REPAIRS})…`);
const res = await fetch("/api/repair", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ script: current, error: (err as Error).message }),
});
const data = (await res.json()) as GenResponse;
if (data.error) throw new Error(data.error);
current = data.script!;
}
}
return current;
},
[]
);
const send = useCallback(async () => {
const text = input.trim();
if (!text || busy) return;
setInput("");
setBusy(true);
const nextMessages = [...messages, { role: "user" as const, content: text }];
setMessages(nextMessages);
setStatus("Designing…");
try {
const res = await fetch("/api/generate", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ messages: nextMessages, currentScript: script }),
});
const data = (await res.json()) as GenResponse;
if (data.error) throw new Error(data.error);
const newParams = parseParams(data.script!);
setStatus("Building geometry…");
const finalScript = await executeWithRepair(data.script!, paramValues(newParams));
setScript(finalScript);
setParams(parseParams(finalScript));
setMessages((m) => [...m, { role: "assistant", content: data.summary! }]);
setStatus("");
} catch (err) {
setMessages((m) => [...m, { role: "assistant", content: `⚠️ ${(err as Error).message}` }]);
setStatus("");
} finally {
setBusy(false);
}
}, [input, busy, messages, script, executeWithRepair, paramValues]);
// Live slider edits: re-run locally, no API call.
const onParam = useCallback(
(name: string, value: number) => {
const updated = params.map((p) => (p.name === name ? { ...p, value } : p));
setParams(updated);
runJscad(script, paramValues(updated)).then(setStl).catch(() => {});
},
[params, script, paramValues]
);
const download = useCallback(() => {
if (!stl) return;
const blob = new Blob([stl], { type: "model/stl" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "chisel-model.stl";
a.click();
URL.revokeObjectURL(url);
}, [stl]);
return (
<div className="app">
<aside className="chat">
<header className="brand">
<span className="logo"> CHISEL</span>
<span className="tagline">say it. shape it.</span>
</header>
<div className="messages" ref={scrollRef}>
{messages.length === 0 && (
<div className="empty">
<p>Describe a part and I'll model it.</p>
<ul>
<li onClick={() => setInput("a coffee mug, 350ml, with a rounded handle")}>
a coffee mug, 350ml, with a rounded handle
</li>
<li onClick={() => setInput("a hex phone stand angled at 60 degrees")}>
a hex phone stand angled at 60°
</li>
<li onClick={() => setInput("an M4 bolt, 20mm long, with a hex head")}>
an M4 bolt, 20mm long, hex head
</li>
</ul>
</div>
)}
{messages.map((m, i) => (
<div key={i} className={`msg ${m.role}`}>
{m.content}
</div>
))}
{status && <div className="msg status">{status}</div>}
</div>
{params.length > 0 && (
<div className="params">
<div className="params-title">Parameters</div>
{params.map((p) => (
<label key={p.name} className="param">
<span>{p.label}</span>
<input
type="range"
min={Math.min(p.value * 0.2, 0)}
max={Math.max(p.value * 2.5, p.value + 10)}
step={Math.max(Math.abs(p.value) / 100, 0.1)}
value={p.value}
onChange={(e) => onParam(p.name, parseFloat(e.target.value))}
/>
<b>{p.value.toFixed(1)}</b>
</label>
))}
</div>
)}
<div className="composer">
<textarea
value={input}
placeholder={script ? "Refine it… (e.g. make the walls thinner)" : "What should I build?"}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
}}
disabled={busy}
/>
<button onClick={send} disabled={busy || !input.trim()}>
{busy ? "…" : "↑"}
</button>
</div>
</aside>
<main className="stage">
<Viewer stl={stl} />
{stl && (
<div className="toolbar">
<button onClick={download}>Download STL</button>
<button
className="ghost"
onClick={() => navigator.clipboard.writeText(script)}
title="Copy the JSCAD source"
>
Copy script
</button>
</div>
)}
</main>
</div>
);
}