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>
This commit is contained in:
maverick 2026-07-25 00:03:38 +10:00
commit 974e26f592
17 changed files with 5008 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
node_modules/
public/
.wrangler/
.dev.vars
*.log
.DS_Store

68
README.md Normal file
View file

@ -0,0 +1,68 @@
# ◢ Chisel — AI CAD
**Say it. Shape it.** Message what you want built; Claude writes a parametric CAD
script, it runs in a sandbox, and it appears in a live 3D viewport — exportable
to real CAD.
The core idea: **the design *is* a parametric script.** Chat edits patch the
script; sliders tune its parameters; export emits the geometry. Everything —
editing, versioning, parametrics — falls out of that one choice.
## Architecture (v0 — MVP)
```
React + react-three-fiber (chat + 3D viewport)
│ POST /api/generate | /api/repair
Hono Worker ── Claude (claude-sonnet-4-6, prompt-cached system prompt)
▼ returns a JSCAD script
Sandboxed Web Worker (src/client/engine) ── runs script → binary STL
three.js viewport + Download STL
```
- **Kernel:** JSCAD (`@jscad/modeling`), pure JS — runs client-side in a Web
Worker isolated from the DOM. Exports STL now.
- **Agent loop:** generate → execute → on error, `/api/repair` (up to 3×) before
the user sees anything.
- **Parameters:** `// @param name default // label` comments in the script become
live sliders that re-run locally with no API call.
## Develop
```bash
npm install
wrangler secret put ANTHROPIC_API_KEY # or add to .dev.vars for local dev
# terminal 1 — Worker API on :8787
npm run dev
# terminal 2 — Vite client on :5173 (proxies /api → :8787)
npm run dev:client
```
Open http://localhost:5173.
## Deploy
```bash
npm run deploy # builds client → ./public, then wrangler deploy
```
Serves at `chisel.theradicalparty.com`.
## Roadmap
- **v1** — conversational edits as source-of-truth, vision verification (render →
"does this match?"), auth + saved projects (D1/R2), 3MF/OBJ export.
- **v2****STEP B-rep engine** via build123d/OpenCascade in a Cloudflare
Container/Sandbox, so exports reopen cleanly in Fusion/SolidWorks/FreeCAD.
Version history & branching.
- **v3** — assemblies & mates, dimension queries ("how tall is it?"),
manufacturing hints (3D print / CNC).
## Layout
```
src/worker/ Hono API — Claude orchestration, prompts, repair
src/client/ React app, 3D viewer, JSCAD Web Worker engine
```

4205
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

34
package.json Normal file
View file

@ -0,0 +1,34 @@
{
"name": "chisel",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Chisel — AI CAD. Message what you want built; it appears in 3D, exportable to real CAD.",
"scripts": {
"dev:client": "vite",
"dev": "wrangler dev",
"build:client": "vite build",
"deploy": "npm run build:client && wrangler deploy",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@jscad/modeling": "^2.12.5",
"@jscad/stl-serializer": "^2.1.13",
"hono": "^4.6.14"
},
"devDependencies": {
"@cloudflare/workers-types": "^5.20260724.1",
"@react-three/drei": "^9.114.0",
"@react-three/fiber": "^8.17.10",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/three": "^0.169.0",
"@vitejs/plugin-react": "^4.3.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"three": "^0.169.0",
"typescript": "^5.7.2",
"vite": "^5.4.11",
"wrangler": "^4.114.0"
}
}

209
src/client/App.tsx Normal file
View file

@ -0,0 +1,209 @@
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>
);
}

View file

@ -0,0 +1,56 @@
import { useEffect, useMemo, useRef } from "react";
import { Canvas } from "@react-three/fiber";
import { OrbitControls, GizmoHelper, GizmoViewport, Grid, Center, Bounds } from "@react-three/drei";
import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js";
import type { BufferGeometry } from "three";
function Model({ stl }: { stl: ArrayBuffer }) {
// Parse once per STL buffer. STLLoader.parse wants an ArrayBuffer.
const geometry = useMemo<BufferGeometry>(() => {
const g = new STLLoader().parse(stl.slice(0));
g.computeVertexNormals();
return g;
}, [stl]);
const ref = useRef<BufferGeometry>(null);
useEffect(() => () => geometry.dispose(), [geometry]);
return (
<Bounds fit clip observe margin={1.3}>
<Center>
<mesh geometry={geometry} castShadow receiveShadow>
<primitive object={geometry} attach="geometry" ref={ref} />
<meshStandardMaterial color="#FF2D55" metalness={0.15} roughness={0.55} />
</mesh>
</Center>
</Bounds>
);
}
export function Viewer({ stl }: { stl: ArrayBuffer | null }) {
return (
<Canvas
shadows
camera={{ position: [80, 60, 80], fov: 45, near: 0.1, far: 5000 }}
style={{ background: "#0B0B09" }}
>
<ambientLight intensity={0.6} />
<directionalLight position={[50, 80, 40]} intensity={1.4} castShadow />
<directionalLight position={[-40, -20, -50]} intensity={0.4} />
<Grid
infiniteGrid
cellSize={5}
sectionSize={50}
fadeDistance={600}
cellColor="#2a2a26"
sectionColor="#3a2a2e"
position={[0, -0.01, 0]}
/>
{stl && <Model stl={stl} />}
<OrbitControls makeDefault enableDamping />
<GizmoHelper alignment="bottom-right" margin={[70, 70]}>
<GizmoViewport axisColors={["#FF2D55", "#3ddc84", "#4a9eff"]} labelColor="#eee" />
</GizmoHelper>
</Canvas>
);
}

View file

@ -0,0 +1,52 @@
// Main-thread wrapper around the JSCAD Web Worker. Single long-lived worker,
// requests keyed by id so slider spam can't cross wires.
export interface Param {
name: string;
value: number;
label: string;
}
let worker: Worker | null = null;
let seq = 0;
const pending = new Map<number, { resolve: (b: ArrayBuffer) => void; reject: (e: Error) => void }>();
function getWorker(): Worker {
if (worker) return worker;
worker = new Worker(new URL("./jscad.worker.ts", import.meta.url), { type: "module" });
worker.onmessage = (e: MessageEvent<{ id: number; stl?: ArrayBuffer; error?: string }>) => {
const { id, stl, error } = e.data;
const p = pending.get(id);
if (!p) return;
pending.delete(id);
if (error) p.reject(new Error(error));
else p.resolve(stl!);
};
return worker;
}
export function runJscad(script: string, params: Record<string, number>): Promise<ArrayBuffer> {
const id = ++seq;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
getWorker().postMessage({ id, script, params });
setTimeout(() => {
if (pending.has(id)) {
pending.delete(id);
reject(new Error("Model execution timed out (10s)."));
}
}, 10_000);
});
}
// Pull `// @param name default // label` declarations out of a script so the UI
// can render sliders. Mirrors the contract taught in the system prompt.
export function parseParams(script: string): Param[] {
const out: Param[] = [];
const re = /\/\/\s*@param\s+(\w+)\s+(-?[\d.]+)\s*(?:\/\/\s*(.*))?/g;
let m: RegExpExecArray | null;
while ((m = re.exec(script))) {
out.push({ name: m[1], value: parseFloat(m[2]), label: (m[3] || m[1]).trim() });
}
return out;
}

View file

@ -0,0 +1,54 @@
/// <reference lib="webworker" />
// Sandboxed execution of model-generated JSCAD. Runs in a Web Worker with no DOM
// access — the isolation boundary for untrusted code. Receives a script + params,
// returns binary STL (transferred, zero-copy) or a structured error for the
// repair loop.
import * as jscadModule from "@jscad/modeling";
// @ts-expect-error — no bundled types
import stlSerializer from "@jscad/stl-serializer";
// Under ESM interop the real API lands on `.default` (Node) or the namespace
// itself (some bundlers). Normalize so scripts always get { primitives, ... }.
const jscad = (jscadModule as Record<string, unknown>).default ?? jscadModule;
(globalThis as Record<string, unknown>).jscad = jscad;
interface RunMsg {
id: number;
script: string;
params: Record<string, number>;
}
self.onmessage = (e: MessageEvent<RunMsg>) => {
const { id, script, params } = e.data;
try {
// Compile the script body and pull out main(). `jscad` and `params` are the
// only things in scope besides JS built-ins.
const factory = new Function(
"jscad",
"params",
`${script}\n;
if (typeof main !== "function") throw new Error("Script must define a main(params) function.");
return main(params || {});`
);
const geom = factory(jscad, params);
if (!geom) throw new Error("main(params) returned nothing.");
const geoms = Array.isArray(geom) ? geom : [geom];
// Binary STL comes back as chunks [header(80), count(4), body...] — concat
// them into one contiguous ArrayBuffer.
const chunks = stlSerializer.serialize({ binary: true }, ...geoms) as ArrayBuffer[];
const total = chunks.reduce((n, c) => n + c.byteLength, 0);
const merged = new Uint8Array(total);
let offset = 0;
for (const c of chunks) {
merged.set(new Uint8Array(c as ArrayBuffer), offset);
offset += c.byteLength;
}
const buf = merged.buffer;
self.postMessage({ id, stl: buf }, [buf]);
} catch (err) {
const e = err as Error;
self.postMessage({ id, error: e.stack || e.message || String(err) });
}
};

19
src/client/index.html Normal file
View file

@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Chisel — AI CAD</title>
<meta name="description" content="Say it. Shape it. Message what you want built and watch it appear in 3D — exportable to real CAD." />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Syne:wght@600;800&family=IBM+Plex+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

10
src/client/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);

55
src/client/styles.css Normal file
View file

@ -0,0 +1,55 @@
:root {
--red: #ff2d55;
--bg: #0b0b09;
--panel: #121210;
--line: #242420;
--text: #ece9e2;
--muted: #8a877e;
--mono: "IBM Plex Mono", ui-monospace, monospace;
--display: "Syne", system-ui, sans-serif;
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; }
body { background: var(--bg); color: var(--text); font-family: var(--mono); }
.app { display: grid; grid-template-columns: 380px 1fr; height: 100vh; }
/* --- Chat panel --- */
.chat { display: flex; flex-direction: column; background: var(--panel); border-right: 1px solid var(--line); min-height: 0; }
.brand { padding: 18px 20px; border-bottom: 1px solid var(--line); display: flex; align-items: baseline; gap: 10px; }
.logo { font-family: var(--display); font-weight: 800; letter-spacing: 1px; color: var(--red); font-size: 20px; }
.tagline { color: var(--muted); font-size: 12px; }
.messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 10px; min-height: 0; }
.empty { color: var(--muted); font-size: 13px; }
.empty ul { list-style: none; padding: 0; margin: 14px 0 0; display: flex; flex-direction: column; gap: 8px; }
.empty li { border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; cursor: pointer; transition: border-color .15s, color .15s; }
.empty li:hover { border-color: var(--red); color: var(--text); }
.msg { padding: 10px 13px; border-radius: 10px; font-size: 13px; line-height: 1.5; white-space: pre-wrap; max-width: 92%; }
.msg.user { background: var(--red); color: #fff; align-self: flex-end; border-bottom-right-radius: 3px; }
.msg.assistant { background: #1b1b18; align-self: flex-start; border-bottom-left-radius: 3px; }
.msg.status { color: var(--muted); font-style: italic; background: none; padding: 4px 2px; }
/* --- Parameters --- */
.params { border-top: 1px solid var(--line); padding: 12px 16px; max-height: 32vh; overflow-y: auto; }
.params-title { font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: var(--muted); margin-bottom: 8px; }
.param { display: grid; grid-template-columns: 90px 1fr 42px; align-items: center; gap: 8px; font-size: 12px; margin: 6px 0; }
.param span { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.param b { text-align: right; color: var(--red); font-weight: 500; }
.param input[type="range"] { accent-color: var(--red); }
/* --- Composer --- */
.composer { display: flex; gap: 8px; padding: 14px 16px; border-top: 1px solid var(--line); }
.composer textarea { flex: 1; resize: none; height: 44px; background: #0d0d0b; border: 1px solid var(--line); border-radius: 10px; color: var(--text); font-family: var(--mono); font-size: 13px; padding: 12px; outline: none; }
.composer textarea:focus { border-color: var(--red); }
.composer button { width: 44px; border: none; border-radius: 10px; background: var(--red); color: #fff; font-size: 18px; cursor: pointer; }
.composer button:disabled { opacity: .4; cursor: default; }
/* --- Stage --- */
.stage { position: relative; }
.toolbar { position: absolute; top: 16px; right: 16px; display: flex; gap: 8px; }
.toolbar button { font-family: var(--mono); font-size: 12px; padding: 9px 14px; border-radius: 8px; border: 1px solid var(--red); background: var(--red); color: #fff; cursor: pointer; }
.toolbar button.ghost { background: transparent; border-color: var(--line); color: var(--text); }
.toolbar button.ghost:hover { border-color: var(--red); }

55
src/worker/claude.ts Normal file
View file

@ -0,0 +1,55 @@
import { SYSTEM_PROMPT } from "./prompts";
// Default to Sonnet 4.6 for snappy chat latency. Opus 4.8 gives noticeably better
// geometric reasoning for hard parts — flip MODEL if quality matters more than speed.
const MODEL = "claude-sonnet-4-6";
const API = "https://api.anthropic.com/v1/messages";
export interface ChatMsg {
role: "user" | "assistant";
content: string;
}
export interface ModelResult {
script: string;
summary: string;
raw: string;
}
// Extract the single ```javascript block Chisel's contract requires, plus any
// one-line prose the model put before it (used as the chat summary).
function parse(raw: string): ModelResult {
const fence = raw.match(/```(?:javascript|js)?\s*\n([\s\S]*?)```/);
const script = fence ? fence[1].trim() : "";
const summary = raw.split("```")[0].trim() || "Here's your model.";
return { script, summary, raw };
}
export async function callClaude(apiKey: string, messages: ChatMsg[]): Promise<ModelResult> {
const res = await fetch(API, {
method: "POST",
headers: {
"content-type": "application/json",
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: MODEL,
max_tokens: 8000,
// Prompt-cache the big system prompt — it's identical every turn.
system: [{ type: "text", text: SYSTEM_PROMPT, cache_control: { type: "ephemeral" } }],
messages: messages.map((m) => ({ role: m.role, content: m.content })),
}),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`Claude API ${res.status}: ${body.slice(0, 500)}`);
}
const data = (await res.json()) as { content: Array<{ type: string; text?: string }> };
const raw = data.content.filter((b) => b.type === "text").map((b) => b.text).join("");
const parsed = parse(raw);
if (!parsed.script) throw new Error("Model returned no code block.");
return parsed;
}

59
src/worker/index.ts Normal file
View file

@ -0,0 +1,59 @@
import { Hono } from "hono";
import { callClaude, type ChatMsg } from "./claude";
import { editContext, repairContext } from "./prompts";
interface Env {
ANTHROPIC_API_KEY: string;
ASSETS: { fetch: typeof fetch };
}
const app = new Hono<{ Bindings: Env }>();
// --- Generate / edit a design -------------------------------------------------
// The client sends the running chat plus (optionally) the current script. If a
// script exists we prepend an edit-context turn so the model patches it rather
// than starting over. The JSCAD is executed client-side in a sandboxed Web
// Worker; the repair loop lives at /api/repair.
app.post("/api/generate", async (c) => {
const { messages, currentScript } = await c.req.json<{
messages: ChatMsg[];
currentScript?: string;
}>();
if (!c.env.ANTHROPIC_API_KEY) return c.json({ error: "ANTHROPIC_API_KEY not set" }, 500);
if (!messages?.length) return c.json({ error: "no messages" }, 400);
const convo: ChatMsg[] = [];
if (currentScript?.trim()) {
convo.push({ role: "user", content: editContext(currentScript) });
convo.push({ role: "assistant", content: "Understood — I have the current design. What should I change?" });
}
convo.push(...messages);
try {
const result = await callClaude(c.env.ANTHROPIC_API_KEY, convo);
return c.json({ script: result.script, summary: result.summary });
} catch (e) {
return c.json({ error: (e as Error).message }, 500);
}
});
// --- Repair a script that failed to execute -----------------------------------
app.post("/api/repair", async (c) => {
const { script, error } = await c.req.json<{ script: string; error: string }>();
if (!c.env.ANTHROPIC_API_KEY) return c.json({ error: "ANTHROPIC_API_KEY not set" }, 500);
try {
const result = await callClaude(c.env.ANTHROPIC_API_KEY, [
{ role: "user", content: repairContext(script, error) },
]);
return c.json({ script: result.script, summary: result.summary });
} catch (e) {
return c.json({ error: (e as Error).message }, 500);
}
});
// Everything else → static assets / SPA fallback.
app.all("*", (c) => c.env.ASSETS.fetch(c.req.raw));
export default app;

68
src/worker/prompts.ts Normal file
View file

@ -0,0 +1,68 @@
// The system prompt is the product's brain. It teaches Claude the exact JSCAD
// contract Chisel expects and the house rules for good, manufacturable geometry.
// Kept as a single large string so it can be prompt-cached (cache_control) across
// every request in a session — the design conversation reuses it verbatim.
export const SYSTEM_PROMPT = `You are Chisel, an AI CAD engineer. You turn plain-language requests into precise, parametric 3D models by writing JSCAD (@jscad/modeling) scripts.
## Your output contract READ CAREFULLY
Respond with EXACTLY ONE fenced \`\`\`javascript code block and nothing else outside it except, optionally, ONE short sentence before it describing what you made.
The code block MUST define a function \`main(params)\` that returns a single JSCAD geometry (or an array of geometries for an assembly). \`jscad\` is injected as a global — do NOT import or require anything.
Template:
\`\`\`javascript
// @param handleLength 40 // length of the handle in mm
function main(params) {
const { primitives, booleans, transforms, extrusions, hulls, expansions } = jscad
const { cuboid, cylinder, sphere, roundedCuboid, roundedCylinder, torus } = primitives
const { union, subtract, intersect } = booleans
const { translate, rotate, scale, mirror } = transforms
const p = { handleLength: 40, ...params } // apply overrides from sliders
const body = cylinder({ radius: 40, height: 90 })
// ...build here...
return body
}
\`\`\`
## Parameters (this powers the sliders)
Declare tunable numbers with a magic comment ABOVE main(), one per line:
\`// @param name defaultValue // human label\`
Then read them via \`const p = { name: default, ...params }\`. Users drag sliders → params override defaults → main() re-runs. Design AROUND parameters: derived dimensions should be computed from them, not hardcoded.
## House rules for good CAD
- Units are MILLIMETRES. Model at realistic scale.
- The model sits on/near the origin. Center it or rest it on the Z=0 plane sensibly.
- Prefer clean boolean operations. Ensure solids actually overlap before subtract/union (no coplanar-face artifacts add tiny overlaps like 0.01mm).
- Use \`segments: 64\` on cylinders/spheres for smooth curves when detail matters.
- No self-intersections, no zero-thickness walls, no non-manifold results.
- Real, printable proportions (wall thickness >= 1mm, etc.).
- Keep it to ONE coherent object matching the request. Don't add unrequested extras.
## JSCAD API you may use
primitives: cuboid({size:[x,y,z]}), roundedCuboid({size,roundRadius}), cylinder({radius,height,segments}), roundedCylinder({radius,height,roundRadius,segments}), cylinderElliptic, sphere({radius,segments}), geodesicSphere, torus({innerRadius,outerRadius,segments}), ellipsoid, polygon, polyhedron.
2D: circle, ellipse, rectangle, roundedRectangle, star, polygon.
extrusions: extrudeLinear({height}, shape2d), extrudeRotate({segments,angle}, shape2d).
booleans: union, subtract, intersect.
transforms: translate([x,y,z], g), rotate([rx,ry,rz], g) (radians), scale([x,y,z], g), mirror, center, align.
hulls: hull(...), hullChain(...). expansions: expand({delta,corners}, g), offset.
maths: jscad.maths (vec3 etc). utils: jscad.utils.degToRad.
Angles are RADIANS use \`Math.PI\` or \`jscad.utils.degToRad(deg)\`.
## When editing an existing design
You'll be given the previous script. Make the SMALLEST change that satisfies the request and return the COMPLETE updated script (never a diff). Preserve existing parameters and structure unless the request requires changing them.
## When repairing an error
You'll be given a script and the runtime error it threw. Fix the bug and return the complete corrected script. Common causes: wrong argument shape, radians vs degrees, non-overlapping booleans, undefined variable, using an API that doesn't exist.`;
export function editContext(previousScript: string): string {
return `Here is the current design script. Edit it per my next message and return the full updated script.\n\n\`\`\`javascript\n${previousScript}\n\`\`\``;
}
export function repairContext(script: string, error: string): string {
return `This script threw an error when executed. Fix it and return the complete corrected script.\n\nERROR:\n${error}\n\nSCRIPT:\n\`\`\`javascript\n${script}\n\`\`\``;
}

17
tsconfig.json Normal file
View file

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"],
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"types": ["@cloudflare/workers-types", "vite/client"]
},
"include": ["src"]
}

19
vite.config.ts Normal file
View file

@ -0,0 +1,19 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// Client builds into ./public, which the Worker serves via the ASSETS binding.
// /api/* is handled by the Worker (see src/worker/index.ts); everything else is
// the SPA. In dev, `vite` proxies /api to `wrangler dev` on :8787.
export default defineConfig({
root: "src/client",
publicDir: false,
build: {
outDir: "../../public",
emptyOutDir: true,
},
server: {
proxy: {
"/api": "http://localhost:8787",
},
},
});

22
wrangler.jsonc Normal file
View file

@ -0,0 +1,22 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "chisel",
"main": "src/worker/index.ts",
"compatibility_date": "2025-07-01",
"compatibility_flags": ["nodejs_compat"],
"observability": { "enabled": true },
"build": {
"command": "npm run build:client"
},
"routes": [
{ "pattern": "chisel.theradicalparty.com", "custom_domain": true }
],
"assets": {
"directory": "./public",
"binding": "ASSETS",
"not_found_handling": "single-page-application",
"run_worker_first": ["/api/*"]
}
// ANTHROPIC_API_KEY is set via: wrangler secret put ANTHROPIC_API_KEY
// v2: add a Container binding here for the build123d / STEP engine.
}