From b2a52525d65475ea920e14a7dbddb20554ca5082 Mon Sep 17 00:00:00 2001 From: maverick Date: Sat, 25 Jul 2026 15:36:48 +1000 Subject: [PATCH] feat: unify on build123d kernel (preview == export) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/client/App.tsx | 36 +++++++++----- src/client/components/Viewer.tsx | 2 + src/client/engine/index.ts | 52 +++++++------------- src/client/engine/jscad.worker.ts | 54 --------------------- src/client/styles.css | 2 + src/worker/claude.ts | 2 +- src/worker/index.ts | 38 +++++++++++---- src/worker/prompts.ts | 81 ++++++++++++++++--------------- src/worker/step.ts | 80 +++++++++--------------------- step-service/runner.py | 23 ++++++--- step-service/service.py | 57 ++++++++++++++-------- 11 files changed, 193 insertions(+), 234 deletions(-) delete mode 100644 src/client/engine/jscad.worker.ts diff --git a/src/client/App.tsx b/src/client/App.tsx index 1681c13..b434e81 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -5,7 +5,7 @@ import { DesignsDrawer } from "./components/DesignsDrawer"; import { ColorPicker } from "./components/ColorPicker"; import { FinishPicker } from "./components/FinishPicker"; import type { Finish } from "./components/Viewer"; -import { runJscad, parseParams, type Param } from "./engine"; +import { buildModel, parseParams, type Param } from "./engine"; interface Msg { role: "user" | "assistant"; @@ -63,6 +63,7 @@ export default function App() { } }); const [hideOverlays, setHideOverlays] = useState(false); + const [rebuilding, setRebuilding] = useState(false); // param rebuild in flight const scrollRef = useRef(null); const captureRef = useRef<(() => string | null) | null>(null); @@ -96,7 +97,7 @@ export default function App() { let current = initialScript; for (let attempt = 0; attempt <= MAX_REPAIRS; attempt++) { try { - const buf = await runJscad(current, values); + const buf = await buildModel(current, values); setStl(buf); return current; } catch (err) { @@ -228,11 +229,21 @@ export default function App() { } }, [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 | null>(null); 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(() => {}); + 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] ); @@ -261,7 +272,7 @@ export default function App() { setParams(p); setScript(d.script); setDesignId(id); - await runJscad(d.script, paramValues(p)).then(setStl); + await buildModel(d.script, paramValues(p)).then(setStl); setDesignVersion((v) => v + 1); } catch (err) { setMessages((m) => [...m, { role: "assistant", content: `⚠️ ${(err as Error).message}` }]); @@ -284,16 +295,16 @@ export default function App() { URL.revokeObjectURL(url); }, [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 () => { - if (steppy) return; + if (steppy || !script) return; setSteppy(true); try { - const brief = messages.filter((m) => m.role === "user").map((m) => m.content).join(". "); const res = await fetch("/api/step", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ request: brief || title }), + body: JSON.stringify({ script, params: paramValues(params) }), }); if (!res.ok) { const e = (await res.json().catch(() => ({}))) as { error?: string }; @@ -310,7 +321,7 @@ export default function App() { } finally { setSteppy(false); } - }, [steppy, messages, title]); + }, [steppy, script, params, paramValues, title]); const setModelColor = useCallback((c: string) => { setColor(c); @@ -398,7 +409,10 @@ export default function App() { {params.length > 0 && (
-
Parameters · drag to reshape
+
+ Parameters · drag to reshape + {rebuilding && · rebuilding…} +
{params.map((p) => { 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); @@ -470,7 +484,7 @@ export default function App() { -
diff --git a/src/client/components/Viewer.tsx b/src/client/components/Viewer.tsx index d14db0f..bdc58e6 100644 --- a/src/client/components/Viewer.tsx +++ b/src/client/components/Viewer.tsx @@ -34,6 +34,8 @@ export const FINISH_OPTIONS: { key: Finish; label: string }[] = [ function Model({ stl, color, finish }: { stl: ArrayBuffer; color: string; finish: Finish }) { const geometry = useMemo(() => { 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.computeBoundingBox(); return g; diff --git a/src/client/engine/index.ts b/src/client/engine/index.ts index 4c2e352..d03c616 100644 --- a/src/client/engine/index.ts +++ b/src/client/engine/index.ts @@ -1,5 +1,6 @@ -// Main-thread wrapper around the JSCAD Web Worker. Single long-lived worker, -// requests keyed by id so slider spam can't cross wires. +// Geometry now comes from the server: build123d runs on the VM and returns an +// 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 { name: string; @@ -7,43 +8,26 @@ export interface Param { label: string; } -let worker: Worker | null = null; -let seq = 0; -const pending = new Map 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): Promise { - 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); +// POST the build123d script + params to the Worker, get back a binary STL. +// Throws with the Python traceback on a model error (drives the repair loop). +export async function buildModel(script: string, params: Record): Promise { + const res = await fetch("/api/build", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ script, params }), }); + 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 -// can render sliders. Mirrors the contract taught in the system prompt. +// Pull `# @param name default // label` (or `// @param ...`) declarations out of a +// script so the UI can render sliders. Matches the contract in the system prompt. export function parseParams(script: string): 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; while ((m = re.exec(script))) { out.push({ name: m[1], value: parseFloat(m[2]), label: (m[3] || m[1]).trim() }); diff --git a/src/client/engine/jscad.worker.ts b/src/client/engine/jscad.worker.ts deleted file mode 100644 index ae3ee31..0000000 --- a/src/client/engine/jscad.worker.ts +++ /dev/null @@ -1,54 +0,0 @@ -/// -// 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).default ?? jscadModule; - -(globalThis as Record).jscad = jscad; - -interface RunMsg { - id: number; - script: string; - params: Record; -} - -self.onmessage = (e: MessageEvent) => { - 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) }); - } -}; diff --git a/src/client/styles.css b/src/client/styles.css index e246b12..ade6de1 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -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 { 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); } } + +.rebuilding { color: var(--red); font-style: italic; } diff --git a/src/worker/claude.ts b/src/worker/claude.ts index 48ba096..583d448 100644 --- a/src/worker/claude.ts +++ b/src/worker/claude.ts @@ -19,7 +19,7 @@ export interface ModelResult { // 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 fence = raw.match(/```(?:python|py|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 }; diff --git a/src/worker/index.ts b/src/worker/index.ts index 5215c55..a6ceef8 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -3,7 +3,7 @@ import { callClaude, verifyModel, type ChatMsg } from "./claude"; import { editContext, repairContext } from "./prompts"; import { identity } from "./auth"; import { listDesigns, getDesign, saveDesign, deleteDesign } from "./db"; -import { generateStep } from "./step"; +import { callGeometryService, GeometryError } from "./step"; interface Env { ANTHROPIC_API_KEY: string; @@ -77,17 +77,37 @@ app.post("/api/verify", async (c) => { } }); -// --- STEP export (D) ---------------------------------------------------------- -app.post("/api/step", async (c) => { - const { request } = await c.req.json<{ request: string }>(); - if (!c.env.STEP_SERVICE_URL) return c.json({ error: "STEP engine not configured yet." }, 503); - if (!c.env.ANTHROPIC_API_KEY) return c.json({ error: "ANTHROPIC_API_KEY not set" }, 500); +// --- Build the preview mesh (tessellate the build123d script) ----------------- +// Success → binary STL. Model error → 422 {error: traceback} for the repair loop. +app.post("/api/build", async (c) => { + const { script, params } = await c.req.json<{ script: string; params?: Record }>(); + if (!c.env.STEP_SERVICE_URL) return c.json({ error: "Geometry engine not configured." }, 503); try { - const step = await generateStep( - c.env.ANTHROPIC_API_KEY, + const stl = await callGeometryService( c.env.STEP_SERVICE_URL, 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 }>(); + 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, { headers: { diff --git a/src/worker/prompts.ts b/src/worker/prompts.ts index e5a65ca..029e67b 100644 --- a/src/worker/prompts.ts +++ b/src/worker/prompts.ts @@ -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. -// 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. +// build123d (Python + OpenCascade) is the single source of truth: the same script +// 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 -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: -\`\`\`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 +\`\`\`python +# @param outer_d 40 // outer diameter (mm) +# @param height 90 // total height (mm) +# @param wall 3 // wall thickness (mm) +from build123d import * - 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 }) - // ...build here... - return body -} +with BuildPart() as part: + Cylinder(radius=p["outer_d"] / 2, height=p["height"]) + # 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) -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. +Declare each tunable number with a magic comment ABOVE the code, one per line: +\`# @param name default // human label\` +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 -- 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.). +- Units are MILLIMETRES throughout. +- Prefer build123d builder mode (\`with BuildPart() as part:\`). Use \`Mode.SUBTRACT\` / \`Mode.ADD\`, \`Locations(...)\`, \`Hole(...)\`, and selectors + \`fillet\`/\`chamfer\` for real edges. +- Make it manufacturable: wall thickness >= 1mm, no zero-thickness faces, no self-intersections, watertight solids. - 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 -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)\`. +## Useful build123d API +Primitives (in BuildPart): Box(l,w,h), Cylinder(radius,height), Sphere(radius), Cone(bottom_radius,top_radius,height), Torus(major_radius,minor_radius), Wedge(...). +Operations: Hole(radius,depth), CounterBoreHole, CounterSinkHole, extrude(amount=), revolve(), loft(), sweep(). +Placement: Locations((x,y,z), ...), GridLocations, PolarLocations, Rotation, Pos, Plane. +Modifiers: fillet(edges, radius), chamfer(edges, length). Select with \`part.edges()\`, \`.faces()\`, filters like \`.filter_by(Axis.Z)\`, \`.group_by(...)[i]\`, \`.sort_by(...)\`. +2D → 3D: with BuildSketch() build a face (Rectangle, Circle, RegularPolygon, Text, ...), then extrude(amount=h) or revolve(axis=Axis.Z). +align uses Align.MIN / Align.CENTER / Align.MAX per axis. ## 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.`; +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 // 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"}`; 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 { - 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\`\`\``; } diff --git a/src/worker/step.ts b/src/worker/step.ts index 28cf5af..5de342d 100644 --- a/src/worker/step.ts +++ b/src/worker/step.ts @@ -1,70 +1,38 @@ -// STEP export engine (D). Claude authors build123d (Python + OpenCascade) code, -// which a small execution service runs to emit a true B-rep STEP file — the -// format that reopens cleanly in Fusion / SolidWorks / FreeCAD. +// Client for the build123d geometry service (on the VM behind a tunnel). +// Same code + params drives BOTH the STL preview (/tessellate) and the STEP +// 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"; -const MODEL = "claude-sonnet-4-6"; - -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. - -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 { - 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 class GeometryError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.status = status; + } } -export async function generateStep( - apiKey: string, +export async function callGeometryService( serviceUrl: string, secret: string, - request: string + path: "tessellate" | "step", + code: string, + params: Record ): Promise { - const code = await claudePython(apiKey, request); - const res = await fetch(`${serviceUrl.replace(/\/$/, "")}/run`, { + const res = await fetch(`${serviceUrl.replace(/\/$/, "")}/${path}`, { method: "POST", headers: { "content-type": "application/json", "x-chisel-secret": secret }, - body: JSON.stringify({ code }), + body: JSON.stringify({ code, params: params ?? {} }), }); if (!res.ok) { - const t = await res.text(); - throw new Error(`STEP service ${res.status}: ${t.slice(0, 300)}`); + let detail = ""; + 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(); } diff --git a/step-service/runner.py b/step-service/runner.py index 55a5b3a..7b2b97b 100644 --- a/step-service/runner.py +++ b/step-service/runner.py @@ -1,20 +1,25 @@ """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 + python runner.py -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 ) 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 -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: - 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: code = f.read() - ns: dict = {} + ns: dict = {"params": params} exec(compile(code, "", "exec"), ns, ns) # noqa: S102 — sandboxed subprocess result = ns.get("result") @@ -22,9 +27,11 @@ def main() -> int: print("ERROR: script did not assign `result`", file=sys.stderr) return 2 - # build123d objects expose .part/.solid in various wrappers; normalize. 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 diff --git a/step-service/service.py b/step-service/service.py index bda6f39..e19ad75 100644 --- a/step-service/service.py +++ b/step-service/service.py @@ -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": "" } - → 200 application/step (the file) or 4xx/5xx JSON error +POST /tessellate { code, params } → 200 model/stl (preview mesh) +POST /step { code, params } → 200 application/step (B-rep export) -Auth: requests must carry X-Chisel-Secret: . Only Chisel's -Cloudflare Worker knows it, so the arbitrary-code /run endpoint isn't public. +Both run the SAME code + params, so the preview mesh and the STEP download are +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 -malicious model can't hang or take down the service. +Each run is a subprocess (runner.py) with a hard timeout so a bad or malicious +model can't hang the service. """ +import json import os import subprocess import tempfile @@ -21,34 +22,31 @@ HERE = os.path.dirname(os.path.abspath(__file__)) PY = os.path.join(HERE, "venv", "bin", "python") RUNNER = os.path.join(HERE, "runner.py") 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): code: str + params: dict = {} -@app.get("/health") -def health(): - return {"ok": True} - - -@app.post("/run") -def run(req: RunReq, x_chisel_secret: str = Header(default="")): - if SECRET and x_chisel_secret != SECRET: +def _auth(secret: str) -> None: + if SECRET and secret != SECRET: raise HTTPException(status_code=401, detail="bad secret") + +def _run(req: RunReq, fmt: str) -> bytes: with tempfile.TemporaryDirectory() as d: 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: f.write(req.code) try: proc = subprocess.run( - [PY, RUNNER, code_file, out_file], + [PY, RUNNER, code_file, out_file, fmt, json.dumps(req.params or {})], capture_output=True, text=True, 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): 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: - 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( content=data, media_type="application/step",