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:
maverick 2026-07-25 15:36:48 +10:00 committed by King Omar
parent 6f2a43f4d7
commit b2a52525d6
11 changed files with 193 additions and 234 deletions

View file

@ -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>

View file

@ -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;

View file

@ -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() });

View file

@ -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) });
}
};

View file

@ -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; }

View file

@ -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 };

View file

@ -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: {

View file

@ -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\`\`\``;
} }

View file

@ -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();
} }

View file

@ -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)
export_step(obj, out_file) # noqa: F405 if fmt == "step":
export_step(obj, out_file) # noqa: F405
else:
export_stl(obj, out_file) # noqa: F405
return 0 return 0

View file

@ -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",