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