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:
commit
974e26f592
17 changed files with 5008 additions and 0 deletions
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
node_modules/
|
||||||
|
public/
|
||||||
|
.wrangler/
|
||||||
|
.dev.vars
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
68
README.md
Normal file
68
README.md
Normal 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
4205
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
34
package.json
Normal file
34
package.json
Normal 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
209
src/client/App.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
src/client/components/Viewer.tsx
Normal file
56
src/client/components/Viewer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/client/engine/index.ts
Normal file
52
src/client/engine/index.ts
Normal 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;
|
||||||
|
}
|
||||||
54
src/client/engine/jscad.worker.ts
Normal file
54
src/client/engine/jscad.worker.ts
Normal 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
19
src/client/index.html
Normal 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
10
src/client/main.tsx
Normal 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
55
src/client/styles.css
Normal 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
55
src/worker/claude.ts
Normal 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
59
src/worker/index.ts
Normal 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
68
src/worker/prompts.ts
Normal 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
17
tsconfig.json
Normal 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
19
vite.config.ts
Normal 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
22
wrangler.jsonc
Normal 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.
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue