feat: unify on build123d kernel (preview == export)
Single source of truth: build123d drives BOTH the STL preview and the STEP export from the same script + params, so what you see is what you download. Verified live: generate → build123d → /api/build STL + /api/step STEP. - prompts: SYSTEM_PROMPT teaches build123d (Python) with # @param sliders - claude.ts: parse python fences - step.ts → generic geometry-service client (/tessellate + /step), GeometryError carries the Python traceback for the repair loop - worker: /api/generate returns code; /api/build tessellates; /api/step runs the same code+params (no separate Claude re-gen) - client engine: runJscad → buildModel (server STL); parseParams handles # @param - App: debounced param rebuilds + rebuilding indicator; STEP sends script+params - Viewer: Z-up→Y-up rotation; retire client-side JSCAD worker - VM service.py/runner.py: /tessellate + /step with params Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6f2a43f4d7
commit
b2a52525d6
11 changed files with 193 additions and 234 deletions
|
|
@ -5,7 +5,7 @@ import { DesignsDrawer } from "./components/DesignsDrawer";
|
||||||
import { ColorPicker } from "./components/ColorPicker";
|
import { ColorPicker } from "./components/ColorPicker";
|
||||||
import { FinishPicker } from "./components/FinishPicker";
|
import { FinishPicker } from "./components/FinishPicker";
|
||||||
import type { Finish } from "./components/Viewer";
|
import type { Finish } from "./components/Viewer";
|
||||||
import { runJscad, parseParams, type Param } from "./engine";
|
import { buildModel, parseParams, type Param } from "./engine";
|
||||||
|
|
||||||
interface Msg {
|
interface Msg {
|
||||||
role: "user" | "assistant";
|
role: "user" | "assistant";
|
||||||
|
|
@ -63,6 +63,7 @@ export default function App() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const [hideOverlays, setHideOverlays] = useState(false);
|
const [hideOverlays, setHideOverlays] = useState(false);
|
||||||
|
const [rebuilding, setRebuilding] = useState(false); // param rebuild in flight
|
||||||
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const captureRef = useRef<(() => string | null) | null>(null);
|
const captureRef = useRef<(() => string | null) | null>(null);
|
||||||
|
|
@ -96,7 +97,7 @@ export default function App() {
|
||||||
let current = initialScript;
|
let current = initialScript;
|
||||||
for (let attempt = 0; attempt <= MAX_REPAIRS; attempt++) {
|
for (let attempt = 0; attempt <= MAX_REPAIRS; attempt++) {
|
||||||
try {
|
try {
|
||||||
const buf = await runJscad(current, values);
|
const buf = await buildModel(current, values);
|
||||||
setStl(buf);
|
setStl(buf);
|
||||||
return current;
|
return current;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -228,11 +229,21 @@ export default function App() {
|
||||||
}
|
}
|
||||||
}, [input, busy, messages, title, script, runTurn, autosave]);
|
}, [input, busy, messages, title, script, runTurn, autosave]);
|
||||||
|
|
||||||
|
// Param drags now hit the server (build123d), so debounce: update the slider
|
||||||
|
// instantly, rebuild the mesh shortly after the user stops moving.
|
||||||
|
const rebuildTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const onParam = useCallback(
|
const onParam = useCallback(
|
||||||
(name: string, value: number) => {
|
(name: string, value: number) => {
|
||||||
const updated = params.map((p) => (p.name === name ? { ...p, value } : p));
|
const updated = params.map((p) => (p.name === name ? { ...p, value } : p));
|
||||||
setParams(updated);
|
setParams(updated);
|
||||||
runJscad(script, paramValues(updated)).then(setStl).catch(() => {});
|
if (rebuildTimer.current) clearTimeout(rebuildTimer.current);
|
||||||
|
rebuildTimer.current = setTimeout(() => {
|
||||||
|
setRebuilding(true);
|
||||||
|
buildModel(script, paramValues(updated))
|
||||||
|
.then(setStl)
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setRebuilding(false));
|
||||||
|
}, 350);
|
||||||
},
|
},
|
||||||
[params, script, paramValues]
|
[params, script, paramValues]
|
||||||
);
|
);
|
||||||
|
|
@ -261,7 +272,7 @@ export default function App() {
|
||||||
setParams(p);
|
setParams(p);
|
||||||
setScript(d.script);
|
setScript(d.script);
|
||||||
setDesignId(id);
|
setDesignId(id);
|
||||||
await runJscad(d.script, paramValues(p)).then(setStl);
|
await buildModel(d.script, paramValues(p)).then(setStl);
|
||||||
setDesignVersion((v) => v + 1);
|
setDesignVersion((v) => v + 1);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMessages((m) => [...m, { role: "assistant", content: `⚠️ ${(err as Error).message}` }]);
|
setMessages((m) => [...m, { role: "assistant", content: `⚠️ ${(err as Error).message}` }]);
|
||||||
|
|
@ -284,16 +295,16 @@ export default function App() {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}, [stl, title]);
|
}, [stl, title]);
|
||||||
|
|
||||||
// STEP export (D) — re-authors the design in build123d for a true B-rep file.
|
// STEP export — the SAME build123d script + params as the preview, so the
|
||||||
|
// downloaded B-rep is exactly what's on screen.
|
||||||
const downloadStep = useCallback(async () => {
|
const downloadStep = useCallback(async () => {
|
||||||
if (steppy) return;
|
if (steppy || !script) return;
|
||||||
setSteppy(true);
|
setSteppy(true);
|
||||||
try {
|
try {
|
||||||
const brief = messages.filter((m) => m.role === "user").map((m) => m.content).join(". ");
|
|
||||||
const res = await fetch("/api/step", {
|
const res = await fetch("/api/step", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ request: brief || title }),
|
body: JSON.stringify({ script, params: paramValues(params) }),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const e = (await res.json().catch(() => ({}))) as { error?: string };
|
const e = (await res.json().catch(() => ({}))) as { error?: string };
|
||||||
|
|
@ -310,7 +321,7 @@ export default function App() {
|
||||||
} finally {
|
} finally {
|
||||||
setSteppy(false);
|
setSteppy(false);
|
||||||
}
|
}
|
||||||
}, [steppy, messages, title]);
|
}, [steppy, script, params, paramValues, title]);
|
||||||
|
|
||||||
const setModelColor = useCallback((c: string) => {
|
const setModelColor = useCallback((c: string) => {
|
||||||
setColor(c);
|
setColor(c);
|
||||||
|
|
@ -398,7 +409,10 @@ export default function App() {
|
||||||
|
|
||||||
{params.length > 0 && (
|
{params.length > 0 && (
|
||||||
<div className="params">
|
<div className="params">
|
||||||
<div className="params-title">Parameters · drag to reshape</div>
|
<div className="params-title">
|
||||||
|
Parameters · drag to reshape
|
||||||
|
{rebuilding && <span className="rebuilding"> · rebuilding…</span>}
|
||||||
|
</div>
|
||||||
{params.map((p) => {
|
{params.map((p) => {
|
||||||
const min = p.value > 0 ? Math.max(p.value * 0.25, 0.5) : p.value * 2;
|
const min = p.value > 0 ? Math.max(p.value * 0.25, 0.5) : p.value * 2;
|
||||||
const max = p.value > 0 ? p.value * 2.5 + 5 : Math.max(p.value * 0.25, 5);
|
const max = p.value > 0 ? p.value * 2.5 + 5 : Math.max(p.value * 0.25, 5);
|
||||||
|
|
@ -470,7 +484,7 @@ export default function App() {
|
||||||
<button onClick={downloadStep} disabled={steppy} title="True B-rep for Fusion/SolidWorks/FreeCAD">
|
<button onClick={downloadStep} disabled={steppy} title="True B-rep for Fusion/SolidWorks/FreeCAD">
|
||||||
{steppy ? "Building STEP…" : "Download STEP"}
|
{steppy ? "Building STEP…" : "Download STEP"}
|
||||||
</button>
|
</button>
|
||||||
<button className="ghost" onClick={() => navigator.clipboard.writeText(script)} title="Copy the JSCAD source">
|
<button className="ghost" onClick={() => navigator.clipboard.writeText(script)} title="Copy the build123d source">
|
||||||
Copy script
|
Copy script
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ export const FINISH_OPTIONS: { key: Finish; label: string }[] = [
|
||||||
function Model({ stl, color, finish }: { stl: ArrayBuffer; color: string; finish: Finish }) {
|
function Model({ stl, color, finish }: { stl: ArrayBuffer; color: string; finish: Finish }) {
|
||||||
const geometry = useMemo<BufferGeometry>(() => {
|
const geometry = useMemo<BufferGeometry>(() => {
|
||||||
const g = new STLLoader().parse(stl.slice(0));
|
const g = new STLLoader().parse(stl.slice(0));
|
||||||
|
// build123d/CAD is Z-up; three.js is Y-up. Rotate so models stand upright.
|
||||||
|
g.rotateX(-Math.PI / 2);
|
||||||
g.computeVertexNormals();
|
g.computeVertexNormals();
|
||||||
g.computeBoundingBox();
|
g.computeBoundingBox();
|
||||||
return g;
|
return g;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
// Main-thread wrapper around the JSCAD Web Worker. Single long-lived worker,
|
// Geometry now comes from the server: build123d runs on the VM and returns an
|
||||||
// requests keyed by id so slider spam can't cross wires.
|
// STL mesh. Same script + params also produces the STEP export, so the preview
|
||||||
|
// matches the download. (The old client-side JSCAD worker is retired.)
|
||||||
|
|
||||||
export interface Param {
|
export interface Param {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -7,43 +8,26 @@ export interface Param {
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let worker: Worker | null = null;
|
// POST the build123d script + params to the Worker, get back a binary STL.
|
||||||
let seq = 0;
|
// Throws with the Python traceback on a model error (drives the repair loop).
|
||||||
const pending = new Map<number, { resolve: (b: ArrayBuffer) => void; reject: (e: Error) => void }>();
|
export async function buildModel(script: string, params: Record<string, number>): Promise<ArrayBuffer> {
|
||||||
|
const res = await fetch("/api/build", {
|
||||||
function getWorker(): Worker {
|
method: "POST",
|
||||||
if (worker) return worker;
|
headers: { "content-type": "application/json" },
|
||||||
worker = new Worker(new URL("./jscad.worker.ts", import.meta.url), { type: "module" });
|
body: JSON.stringify({ script, params }),
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
if (res.ok && (res.headers.get("content-type") || "").includes("stl")) {
|
||||||
|
return res.arrayBuffer();
|
||||||
|
}
|
||||||
|
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||||
|
throw new Error(data.error || `build failed (${res.status})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pull `// @param name default // label` declarations out of a script so the UI
|
// Pull `# @param name default // label` (or `// @param ...`) declarations out of a
|
||||||
// can render sliders. Mirrors the contract taught in the system prompt.
|
// script so the UI can render sliders. Matches the contract in the system prompt.
|
||||||
export function parseParams(script: string): Param[] {
|
export function parseParams(script: string): Param[] {
|
||||||
const out: Param[] = [];
|
const out: Param[] = [];
|
||||||
const re = /\/\/\s*@param\s+(\w+)\s+(-?[\d.]+)\s*(?:\/\/\s*(.*))?/g;
|
const re = /(?:#|\/\/)\s*@param\s+(\w+)\s+(-?[\d.]+)\s*(?:\/\/\s*(.*))?/g;
|
||||||
let m: RegExpExecArray | null;
|
let m: RegExpExecArray | null;
|
||||||
while ((m = re.exec(script))) {
|
while ((m = re.exec(script))) {
|
||||||
out.push({ name: m[1], value: parseFloat(m[2]), label: (m[3] || m[1]).trim() });
|
out.push({ name: m[1], value: parseFloat(m[2]), label: (m[3] || m[1]).trim() });
|
||||||
|
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
/// <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) });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -124,3 +124,5 @@ body { background: var(--bg); color: var(--text); font-family: var(--mono); }
|
||||||
.step.on .dot { background: var(--red); border-color: var(--red); box-shadow: 0 0 0 4px rgba(255,45,85,.25); animation: pulse 1.1s infinite; }
|
.step.on .dot { background: var(--red); border-color: var(--red); box-shadow: 0 0 0 4px rgba(255,45,85,.25); animation: pulse 1.1s infinite; }
|
||||||
.step.on { color: var(--text); }
|
.step.on { color: var(--text); }
|
||||||
@keyframes pulse { 0%,100% { box-shadow: 0 0 0 3px rgba(255,45,85,.25); } 50% { box-shadow: 0 0 0 7px rgba(255,45,85,.05); } }
|
@keyframes pulse { 0%,100% { box-shadow: 0 0 0 3px rgba(255,45,85,.25); } 50% { box-shadow: 0 0 0 7px rgba(255,45,85,.05); } }
|
||||||
|
|
||||||
|
.rebuilding { color: var(--red); font-style: italic; }
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ export interface ModelResult {
|
||||||
// Extract the single ```javascript block Chisel's contract requires, plus any
|
// Extract the single ```javascript block Chisel's contract requires, plus any
|
||||||
// one-line prose the model put before it (used as the chat summary).
|
// one-line prose the model put before it (used as the chat summary).
|
||||||
function parse(raw: string): ModelResult {
|
function parse(raw: string): ModelResult {
|
||||||
const fence = raw.match(/```(?:javascript|js)?\s*\n([\s\S]*?)```/);
|
const fence = raw.match(/```(?:python|py|javascript|js)?\s*\n([\s\S]*?)```/);
|
||||||
const script = fence ? fence[1].trim() : "";
|
const script = fence ? fence[1].trim() : "";
|
||||||
const summary = raw.split("```")[0].trim() || "Here's your model.";
|
const summary = raw.split("```")[0].trim() || "Here's your model.";
|
||||||
return { script, summary, raw };
|
return { script, summary, raw };
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { callClaude, verifyModel, type ChatMsg } from "./claude";
|
||||||
import { editContext, repairContext } from "./prompts";
|
import { editContext, repairContext } from "./prompts";
|
||||||
import { identity } from "./auth";
|
import { identity } from "./auth";
|
||||||
import { listDesigns, getDesign, saveDesign, deleteDesign } from "./db";
|
import { listDesigns, getDesign, saveDesign, deleteDesign } from "./db";
|
||||||
import { generateStep } from "./step";
|
import { callGeometryService, GeometryError } from "./step";
|
||||||
|
|
||||||
interface Env {
|
interface Env {
|
||||||
ANTHROPIC_API_KEY: string;
|
ANTHROPIC_API_KEY: string;
|
||||||
|
|
@ -77,17 +77,37 @@ app.post("/api/verify", async (c) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- STEP export (D) ----------------------------------------------------------
|
// --- Build the preview mesh (tessellate the build123d script) -----------------
|
||||||
app.post("/api/step", async (c) => {
|
// Success → binary STL. Model error → 422 {error: traceback} for the repair loop.
|
||||||
const { request } = await c.req.json<{ request: string }>();
|
app.post("/api/build", async (c) => {
|
||||||
if (!c.env.STEP_SERVICE_URL) return c.json({ error: "STEP engine not configured yet." }, 503);
|
const { script, params } = await c.req.json<{ script: string; params?: Record<string, number> }>();
|
||||||
if (!c.env.ANTHROPIC_API_KEY) return c.json({ error: "ANTHROPIC_API_KEY not set" }, 500);
|
if (!c.env.STEP_SERVICE_URL) return c.json({ error: "Geometry engine not configured." }, 503);
|
||||||
try {
|
try {
|
||||||
const step = await generateStep(
|
const stl = await callGeometryService(
|
||||||
c.env.ANTHROPIC_API_KEY,
|
|
||||||
c.env.STEP_SERVICE_URL,
|
c.env.STEP_SERVICE_URL,
|
||||||
c.env.STEP_SHARED_SECRET ?? "",
|
c.env.STEP_SHARED_SECRET ?? "",
|
||||||
request
|
"tessellate",
|
||||||
|
script,
|
||||||
|
params ?? {}
|
||||||
|
);
|
||||||
|
return new Response(stl, { headers: { "content-type": "model/stl", "cache-control": "no-store" } });
|
||||||
|
} catch (e) {
|
||||||
|
const status = e instanceof GeometryError ? e.status : 500;
|
||||||
|
return c.json({ error: (e as Error).message }, status === 422 ? 422 : 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- STEP export (same script + params as the preview) ------------------------
|
||||||
|
app.post("/api/step", async (c) => {
|
||||||
|
const { script, params } = await c.req.json<{ script: string; params?: Record<string, number> }>();
|
||||||
|
if (!c.env.STEP_SERVICE_URL) return c.json({ error: "Geometry engine not configured." }, 503);
|
||||||
|
try {
|
||||||
|
const step = await callGeometryService(
|
||||||
|
c.env.STEP_SERVICE_URL,
|
||||||
|
c.env.STEP_SHARED_SECRET ?? "",
|
||||||
|
"step",
|
||||||
|
script,
|
||||||
|
params ?? {}
|
||||||
);
|
);
|
||||||
return new Response(step, {
|
return new Response(step, {
|
||||||
headers: {
|
headers: {
|
||||||
|
|
|
||||||
|
|
@ -1,63 +1,64 @@
|
||||||
// The system prompt is the product's brain. It teaches Claude the exact JSCAD
|
// The system prompt is the product's brain. It teaches Claude the exact build123d
|
||||||
// contract Chisel expects and the house rules for good, manufacturable geometry.
|
// 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
|
// build123d (Python + OpenCascade) is the single source of truth: the same script
|
||||||
// every request in a session — the design conversation reuses it verbatim.
|
// produces the STL preview AND the STEP export, so what you see is what you export.
|
||||||
|
// Kept as one large string so it can be prompt-cached across a session.
|
||||||
|
|
||||||
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.
|
export const SYSTEM_PROMPT = `You are Chisel, an AI CAD engineer. You turn plain-language requests into precise, parametric, manufacturable 3D models by writing build123d (Python) scripts. build123d runs on an OpenCascade kernel, so your output becomes a real B-rep solid — previewed as a mesh and exported as STEP.
|
||||||
|
|
||||||
## Your output contract — READ CAREFULLY
|
## 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.
|
Respond with EXACTLY ONE fenced \`\`\`python code block, and nothing else 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.
|
The script MUST:
|
||||||
|
- Assign the final solid to a variable named \`result\` (a Part / Compound / Solid).
|
||||||
|
- NOT import or read anything except build123d. NEVER write files, and NEVER call export_step / export_stl — the runner exports \`result\` for you.
|
||||||
|
- Read tunable numbers from an injected \`params\` dict (see Parameters).
|
||||||
|
|
||||||
Template:
|
Template:
|
||||||
\`\`\`javascript
|
\`\`\`python
|
||||||
// @param handleLength 40 // length of the handle in mm
|
# @param outer_d 40 // outer diameter (mm)
|
||||||
function main(params) {
|
# @param height 90 // total height (mm)
|
||||||
const { primitives, booleans, transforms, extrusions, hulls, expansions } = jscad
|
# @param wall 3 // wall thickness (mm)
|
||||||
const { cuboid, cylinder, sphere, roundedCuboid, roundedCylinder, torus } = primitives
|
from build123d import *
|
||||||
const { union, subtract, intersect } = booleans
|
|
||||||
const { translate, rotate, scale, mirror } = transforms
|
|
||||||
|
|
||||||
const p = { handleLength: 40, ...params } // apply overrides from sliders
|
p = {"outer_d": 40, "height": 90, "wall": 3}
|
||||||
|
p.update(params) # slider overrides
|
||||||
|
|
||||||
const body = cylinder({ radius: 40, height: 90 })
|
with BuildPart() as part:
|
||||||
// ...build here...
|
Cylinder(radius=p["outer_d"] / 2, height=p["height"])
|
||||||
return body
|
# hollow it out from the top
|
||||||
}
|
Cylinder(radius=p["outer_d"] / 2 - p["wall"], height=p["height"] - p["wall"],
|
||||||
|
align=(Align.CENTER, Align.CENTER, Align.MAX), mode=Mode.SUBTRACT)
|
||||||
|
|
||||||
|
result = part.part
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
## Parameters (this powers the sliders)
|
## Parameters (this powers the sliders)
|
||||||
Declare tunable numbers with a magic comment ABOVE main(), one per line:
|
Declare each tunable number with a magic comment ABOVE the code, one per line:
|
||||||
\`// @param name defaultValue // human label\`
|
\`# @param name default // 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.
|
Then \`p = {"name": default, ...}\` and \`p.update(params)\`. Users drag sliders → params override → the script re-runs. Compute derived dimensions FROM parameters; never hardcode what a param should drive.
|
||||||
|
|
||||||
## House rules for good CAD
|
## House rules for good CAD
|
||||||
- Units are MILLIMETRES. Model at realistic scale.
|
- Units are MILLIMETRES throughout.
|
||||||
- The model sits on/near the origin. Center it or rest it on the Z=0 plane sensibly.
|
- Prefer build123d builder mode (\`with BuildPart() as part:\`). Use \`Mode.SUBTRACT\` / \`Mode.ADD\`, \`Locations(...)\`, \`Hole(...)\`, and selectors + \`fillet\`/\`chamfer\` for real edges.
|
||||||
- Prefer clean boolean operations. Ensure solids actually overlap before subtract/union (no coplanar-face artifacts — add tiny overlaps like 0.01mm).
|
- Make it manufacturable: wall thickness >= 1mm, no zero-thickness faces, no self-intersections, watertight solids.
|
||||||
- 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.
|
- Keep it to ONE coherent object matching the request. Don't add unrequested extras.
|
||||||
|
- Angles are DEGREES in build123d rotation helpers unless you use radians math explicitly.
|
||||||
|
|
||||||
## JSCAD API you may use
|
## Useful build123d API
|
||||||
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.
|
Primitives (in BuildPart): Box(l,w,h), Cylinder(radius,height), Sphere(radius), Cone(bottom_radius,top_radius,height), Torus(major_radius,minor_radius), Wedge(...).
|
||||||
2D: circle, ellipse, rectangle, roundedRectangle, star, polygon.
|
Operations: Hole(radius,depth), CounterBoreHole, CounterSinkHole, extrude(amount=), revolve(), loft(), sweep().
|
||||||
extrusions: extrudeLinear({height}, shape2d), extrudeRotate({segments,angle}, shape2d).
|
Placement: Locations((x,y,z), ...), GridLocations, PolarLocations, Rotation, Pos, Plane.
|
||||||
booleans: union, subtract, intersect.
|
Modifiers: fillet(edges, radius), chamfer(edges, length). Select with \`part.edges()\`, \`.faces()\`, filters like \`.filter_by(Axis.Z)\`, \`.group_by(...)[i]\`, \`.sort_by(...)\`.
|
||||||
transforms: translate([x,y,z], g), rotate([rx,ry,rz], g) (radians), scale([x,y,z], g), mirror, center, align.
|
2D → 3D: with BuildSketch() build a face (Rectangle, Circle, RegularPolygon, Text, ...), then extrude(amount=h) or revolve(axis=Axis.Z).
|
||||||
hulls: hull(...), hullChain(...). expansions: expand({delta,corners}, g), offset.
|
align uses Align.MIN / Align.CENTER / Align.MAX per axis.
|
||||||
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
|
## 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.
|
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
|
## 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.`;
|
You'll be given a script and the Python traceback it threw. Fix the bug and return the complete corrected script. Common causes: wrong argument name/shape for a build123d class, an empty selector (\`.edges()\` matched nothing before fillet), a non-manifold boolean, a fillet/chamfer radius too large for the edge, or a missing \`result\` assignment.`;
|
||||||
|
|
||||||
// Vision judge. Sees a render of the model and the request; decides if it's a
|
// Vision judge. Sees a render of the model and the request; decides if it's a
|
||||||
// faithful match. Deliberately lenient about style/colour/detail — only flags
|
// faithful match. Deliberately lenient about style/colour/detail — only flags
|
||||||
|
|
@ -73,9 +74,9 @@ Respond with ONLY a JSON object, no prose, no code fence:
|
||||||
{"matches": true|false, "critique": "if matches:false, one sentence with the single most important concrete geometry fix, phrased as an instruction"}`;
|
{"matches": true|false, "critique": "if matches:false, one sentence with the single most important concrete geometry fix, phrased as an instruction"}`;
|
||||||
|
|
||||||
export function editContext(previousScript: string): string {
|
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\`\`\``;
|
return `Here is the current design script. Edit it per my next message and return the full updated script.\n\n\`\`\`python\n${previousScript}\n\`\`\``;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function repairContext(script: string, error: string): string {
|
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\`\`\``;
|
return `This script threw an error when executed. Fix it and return the complete corrected script.\n\nERROR:\n${error}\n\nSCRIPT:\n\`\`\`python\n${script}\n\`\`\``;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,70 +1,38 @@
|
||||||
// STEP export engine (D). Claude authors build123d (Python + OpenCascade) code,
|
// Client for the build123d geometry service (on the VM behind a tunnel).
|
||||||
// which a small execution service runs to emit a true B-rep STEP file — the
|
// Same code + params drives BOTH the STL preview (/tessellate) and the STEP
|
||||||
// format that reopens cleanly in Fusion / SolidWorks / FreeCAD.
|
// export (/step), so what the user sees is exactly what they download.
|
||||||
//
|
//
|
||||||
// The service (see step-service/) exposes POST /run {code} -> STEP bytes.
|
// A 422 carries the Python traceback for a bad model — surfaced to the repair loop.
|
||||||
|
|
||||||
const API = "https://api.anthropic.com/v1/messages";
|
export class GeometryError extends Error {
|
||||||
const MODEL = "claude-sonnet-4-6";
|
status: number;
|
||||||
|
constructor(status: number, message: string) {
|
||||||
const BUILD123D_SYSTEM = `You are Chisel's STEP engineer. Turn the request into a build123d (Python) script that builds ONE solid and exports it to STEP.
|
super(message);
|
||||||
|
this.status = status;
|
||||||
Output EXACTLY one \`\`\`python code block, nothing else.
|
}
|
||||||
|
|
||||||
Rules:
|
|
||||||
- Use build123d's builder API. Units are millimetres.
|
|
||||||
- Assign the final solid to a variable named \`result\` (a Part/Compound/Solid).
|
|
||||||
- Do NOT write any file yourself and do NOT call export_step — the runner assigns \`result\` and exports it.
|
|
||||||
- Keep it manufacturable: real wall thickness, no self-intersections, clean booleans.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
\`\`\`python
|
|
||||||
from build123d import *
|
|
||||||
with BuildPart() as part:
|
|
||||||
Box(40, 40, 20)
|
|
||||||
with Locations((0, 0, 0)):
|
|
||||||
Hole(radius=5)
|
|
||||||
result = part.part
|
|
||||||
\`\`\``;
|
|
||||||
|
|
||||||
async function claudePython(apiKey: string, request: string): Promise<string> {
|
|
||||||
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: 4000,
|
|
||||||
system: [{ type: "text", text: BUILD123D_SYSTEM, cache_control: { type: "ephemeral" } }],
|
|
||||||
messages: [{ role: "user", content: request }],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`Claude API ${res.status}`);
|
|
||||||
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 fence = raw.match(/```(?:python|py)?\s*\n([\s\S]*?)```/);
|
|
||||||
if (!fence) throw new Error("STEP engineer returned no code.");
|
|
||||||
return fence[1].trim();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateStep(
|
export async function callGeometryService(
|
||||||
apiKey: string,
|
|
||||||
serviceUrl: string,
|
serviceUrl: string,
|
||||||
secret: string,
|
secret: string,
|
||||||
request: string
|
path: "tessellate" | "step",
|
||||||
|
code: string,
|
||||||
|
params: Record<string, number>
|
||||||
): Promise<ArrayBuffer> {
|
): Promise<ArrayBuffer> {
|
||||||
const code = await claudePython(apiKey, request);
|
const res = await fetch(`${serviceUrl.replace(/\/$/, "")}/${path}`, {
|
||||||
const res = await fetch(`${serviceUrl.replace(/\/$/, "")}/run`, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json", "x-chisel-secret": secret },
|
headers: { "content-type": "application/json", "x-chisel-secret": secret },
|
||||||
body: JSON.stringify({ code }),
|
body: JSON.stringify({ code, params: params ?? {} }),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const t = await res.text();
|
let detail = "";
|
||||||
throw new Error(`STEP service ${res.status}: ${t.slice(0, 300)}`);
|
try {
|
||||||
|
const j = (await res.json()) as { detail?: string };
|
||||||
|
detail = j.detail ?? "";
|
||||||
|
} catch {
|
||||||
|
detail = await res.text().catch(() => "");
|
||||||
|
}
|
||||||
|
throw new GeometryError(res.status, detail.slice(0, 900) || `service ${res.status}`);
|
||||||
}
|
}
|
||||||
return res.arrayBuffer();
|
return res.arrayBuffer();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,25 @@
|
||||||
"""Isolated executor for Claude-authored build123d code.
|
"""Isolated executor for Claude-authored build123d code.
|
||||||
|
|
||||||
Run as a subprocess so a bad script can't hang or crash the service:
|
python runner.py <code_file> <out_file> <fmt: stl|step> <params_json>
|
||||||
python runner.py <code_file> <out_step_file>
|
|
||||||
|
|
||||||
The script must assign the final solid to `result`. We export it to STEP.
|
The model code must assign the final solid to `result`. A `params` dict (parsed
|
||||||
|
from <params_json>) is injected into its namespace so parametric sliders work —
|
||||||
|
the same code powers both the STL preview and the STEP export, guaranteeing the
|
||||||
|
preview matches the download.
|
||||||
"""
|
"""
|
||||||
|
import json
|
||||||
import sys
|
import sys
|
||||||
from build123d import * # noqa: F401,F403 — the model code relies on star import
|
from build123d import * # noqa: F401,F403 — runner's own export_* helpers
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
code_file, out_file = sys.argv[1], sys.argv[2]
|
code_file, out_file, fmt = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||||
|
params = json.loads(sys.argv[4]) if len(sys.argv) > 4 and sys.argv[4] else {}
|
||||||
|
|
||||||
with open(code_file) as f:
|
with open(code_file) as f:
|
||||||
code = f.read()
|
code = f.read()
|
||||||
|
|
||||||
ns: dict = {}
|
ns: dict = {"params": params}
|
||||||
exec(compile(code, "<model>", "exec"), ns, ns) # noqa: S102 — sandboxed subprocess
|
exec(compile(code, "<model>", "exec"), ns, ns) # noqa: S102 — sandboxed subprocess
|
||||||
|
|
||||||
result = ns.get("result")
|
result = ns.get("result")
|
||||||
|
|
@ -22,9 +27,11 @@ def main() -> int:
|
||||||
print("ERROR: script did not assign `result`", file=sys.stderr)
|
print("ERROR: script did not assign `result`", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
# build123d objects expose .part/.solid in various wrappers; normalize.
|
|
||||||
obj = getattr(result, "part", result)
|
obj = getattr(result, "part", result)
|
||||||
|
if fmt == "step":
|
||||||
export_step(obj, out_file) # noqa: F405
|
export_step(obj, out_file) # noqa: F405
|
||||||
|
else:
|
||||||
|
export_stl(obj, out_file) # noqa: F405
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
"""Chisel STEP engine — executes build123d code and returns a STEP file.
|
"""Chisel geometry engine — runs build123d code and returns STL or STEP.
|
||||||
|
|
||||||
POST /run { "code": "<build123d python assigning `result`>" }
|
POST /tessellate { code, params } → 200 model/stl (preview mesh)
|
||||||
→ 200 application/step (the file) or 4xx/5xx JSON error
|
POST /step { code, params } → 200 application/step (B-rep export)
|
||||||
|
|
||||||
Auth: requests must carry X-Chisel-Secret: <STEP_SHARED_SECRET>. Only Chisel's
|
Both run the SAME code + params, so the preview mesh and the STEP download are
|
||||||
Cloudflare Worker knows it, so the arbitrary-code /run endpoint isn't public.
|
the same geometry. Auth: X-Chisel-Secret must equal STEP_SHARED_SECRET.
|
||||||
|
|
||||||
Runs each script in a subprocess (runner.py) with a hard timeout so a bad or
|
Each run is a subprocess (runner.py) with a hard timeout so a bad or malicious
|
||||||
malicious model can't hang or take down the service.
|
model can't hang the service.
|
||||||
"""
|
"""
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
@ -21,34 +22,31 @@ HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
PY = os.path.join(HERE, "venv", "bin", "python")
|
PY = os.path.join(HERE, "venv", "bin", "python")
|
||||||
RUNNER = os.path.join(HERE, "runner.py")
|
RUNNER = os.path.join(HERE, "runner.py")
|
||||||
SECRET = os.environ.get("STEP_SHARED_SECRET", "")
|
SECRET = os.environ.get("STEP_SHARED_SECRET", "")
|
||||||
TIMEOUT = 40
|
TIMEOUT = 45
|
||||||
|
|
||||||
app = FastAPI(title="Chisel STEP engine")
|
app = FastAPI(title="Chisel geometry engine")
|
||||||
|
|
||||||
|
|
||||||
class RunReq(BaseModel):
|
class RunReq(BaseModel):
|
||||||
code: str
|
code: str
|
||||||
|
params: dict = {}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
def _auth(secret: str) -> None:
|
||||||
def health():
|
if SECRET and secret != SECRET:
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/run")
|
|
||||||
def run(req: RunReq, x_chisel_secret: str = Header(default="")):
|
|
||||||
if SECRET and x_chisel_secret != SECRET:
|
|
||||||
raise HTTPException(status_code=401, detail="bad secret")
|
raise HTTPException(status_code=401, detail="bad secret")
|
||||||
|
|
||||||
|
|
||||||
|
def _run(req: RunReq, fmt: str) -> bytes:
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
code_file = os.path.join(d, "model.py")
|
code_file = os.path.join(d, "model.py")
|
||||||
out_file = os.path.join(d, "out.step")
|
out_file = os.path.join(d, f"out.{fmt}")
|
||||||
with open(code_file, "w") as f:
|
with open(code_file, "w") as f:
|
||||||
f.write(req.code)
|
f.write(req.code)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
[PY, RUNNER, code_file, out_file],
|
[PY, RUNNER, code_file, out_file, fmt, json.dumps(req.params or {})],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=TIMEOUT,
|
timeout=TIMEOUT,
|
||||||
|
|
@ -58,11 +56,28 @@ def run(req: RunReq, x_chisel_secret: str = Header(default="")):
|
||||||
|
|
||||||
if proc.returncode != 0 or not os.path.exists(out_file):
|
if proc.returncode != 0 or not os.path.exists(out_file):
|
||||||
msg = (proc.stderr or proc.stdout or "unknown error").strip()
|
msg = (proc.stderr or proc.stdout or "unknown error").strip()
|
||||||
raise HTTPException(status_code=422, detail=msg[-800:])
|
raise HTTPException(status_code=422, detail=msg[-900:])
|
||||||
|
|
||||||
with open(out_file, "rb") as f:
|
with open(out_file, "rb") as f:
|
||||||
data = f.read()
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/tessellate")
|
||||||
|
def tessellate(req: RunReq, x_chisel_secret: str = Header(default="")):
|
||||||
|
_auth(x_chisel_secret)
|
||||||
|
data = _run(req, "stl")
|
||||||
|
return Response(content=data, media_type="model/stl")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/step")
|
||||||
|
def step(req: RunReq, x_chisel_secret: str = Header(default="")):
|
||||||
|
_auth(x_chisel_secret)
|
||||||
|
data = _run(req, "step")
|
||||||
return Response(
|
return Response(
|
||||||
content=data,
|
content=data,
|
||||||
media_type="application/step",
|
media_type="application/step",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue