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 { 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<HTMLDivElement>(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<ReturnType<typeof setTimeout> | 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 && (
<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) => {
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() {
<button onClick={downloadStep} disabled={steppy} title="True B-rep for Fusion/SolidWorks/FreeCAD">
{steppy ? "Building STEP…" : "Download STEP"}
</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
</button>
</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 }) {
const geometry = useMemo<BufferGeometry>(() => {
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;

View file

@ -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<number, { resolve: (b: ArrayBuffer) => void; reject: (e: Error) => void }>();
function getWorker(): Worker {
if (worker) return worker;
worker = new Worker(new URL("./jscad.worker.ts", import.meta.url), { type: "module" });
worker.onmessage = (e: MessageEvent<{ id: number; stl?: ArrayBuffer; error?: string }>) => {
const { id, stl, error } = e.data;
const p = pending.get(id);
if (!p) return;
pending.delete(id);
if (error) p.reject(new Error(error));
else p.resolve(stl!);
};
return worker;
}
export function runJscad(script: string, params: Record<string, number>): Promise<ArrayBuffer> {
const id = ++seq;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
getWorker().postMessage({ id, script, params });
setTimeout(() => {
if (pending.has(id)) {
pending.delete(id);
reject(new Error("Model execution timed out (10s)."));
}
}, 10_000);
// 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<string, number>): Promise<ArrayBuffer> {
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() });

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

View file

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

View file

@ -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<string, number> }>();
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<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, {
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.
// 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\`\`\``;
}

View file

@ -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<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 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<string, number>
): Promise<ArrayBuffer> {
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();
}

View file

@ -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 <code_file> <out_step_file>
python runner.py <code_file> <out_file> <fmt: stl|step> <params_json>
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
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, "<model>", "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)
if fmt == "step":
export_step(obj, out_file) # noqa: F405
else:
export_stl(obj, out_file) # noqa: F405
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`>" }
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: <STEP_SHARED_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",