feat: unify on build123d kernel (preview == export)
Single source of truth: build123d drives BOTH the STL preview and the STEP export from the same script + params, so what you see is what you download. Verified live: generate → build123d → /api/build STL + /api/step STEP. - prompts: SYSTEM_PROMPT teaches build123d (Python) with # @param sliders - claude.ts: parse python fences - step.ts → generic geometry-service client (/tessellate + /step), GeometryError carries the Python traceback for the repair loop - worker: /api/generate returns code; /api/build tessellates; /api/step runs the same code+params (no separate Claude re-gen) - client engine: runJscad → buildModel (server STL); parseParams handles # @param - App: debounced param rebuilds + rebuilding indicator; STEP sends script+params - Viewer: Z-up→Y-up rotation; retire client-side JSCAD worker - VM service.py/runner.py: /tessellate + /step with params Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6f2a43f4d7
commit
b2a52525d6
11 changed files with 193 additions and 234 deletions
|
|
@ -5,7 +5,7 @@ import { DesignsDrawer } from "./components/DesignsDrawer";
|
|||
import { ColorPicker } from "./components/ColorPicker";
|
||||
import { 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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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() });
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
/// <reference lib="webworker" />
|
||||
// Sandboxed execution of model-generated JSCAD. Runs in a Web Worker with no DOM
|
||||
// access — the isolation boundary for untrusted code. Receives a script + params,
|
||||
// returns binary STL (transferred, zero-copy) or a structured error for the
|
||||
// repair loop.
|
||||
import * as jscadModule from "@jscad/modeling";
|
||||
// @ts-expect-error — no bundled types
|
||||
import stlSerializer from "@jscad/stl-serializer";
|
||||
|
||||
// Under ESM interop the real API lands on `.default` (Node) or the namespace
|
||||
// itself (some bundlers). Normalize so scripts always get { primitives, ... }.
|
||||
const jscad = (jscadModule as Record<string, unknown>).default ?? jscadModule;
|
||||
|
||||
(globalThis as Record<string, unknown>).jscad = jscad;
|
||||
|
||||
interface RunMsg {
|
||||
id: number;
|
||||
script: string;
|
||||
params: Record<string, number>;
|
||||
}
|
||||
|
||||
self.onmessage = (e: MessageEvent<RunMsg>) => {
|
||||
const { id, script, params } = e.data;
|
||||
try {
|
||||
// Compile the script body and pull out main(). `jscad` and `params` are the
|
||||
// only things in scope besides JS built-ins.
|
||||
const factory = new Function(
|
||||
"jscad",
|
||||
"params",
|
||||
`${script}\n;
|
||||
if (typeof main !== "function") throw new Error("Script must define a main(params) function.");
|
||||
return main(params || {});`
|
||||
);
|
||||
const geom = factory(jscad, params);
|
||||
if (!geom) throw new Error("main(params) returned nothing.");
|
||||
|
||||
const geoms = Array.isArray(geom) ? geom : [geom];
|
||||
// Binary STL comes back as chunks [header(80), count(4), body...] — concat
|
||||
// them into one contiguous ArrayBuffer.
|
||||
const chunks = stlSerializer.serialize({ binary: true }, ...geoms) as ArrayBuffer[];
|
||||
const total = chunks.reduce((n, c) => n + c.byteLength, 0);
|
||||
const merged = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const c of chunks) {
|
||||
merged.set(new Uint8Array(c as ArrayBuffer), offset);
|
||||
offset += c.byteLength;
|
||||
}
|
||||
const buf = merged.buffer;
|
||||
self.postMessage({ id, stl: buf }, [buf]);
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
self.postMessage({ id, error: e.stack || e.message || String(err) });
|
||||
}
|
||||
};
|
||||
|
|
@ -124,3 +124,5 @@ body { background: var(--bg); color: var(--text); font-family: var(--mono); }
|
|||
.step.on .dot { background: var(--red); border-color: var(--red); box-shadow: 0 0 0 4px rgba(255,45,85,.25); animation: pulse 1.1s infinite; }
|
||||
.step.on { 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; }
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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\`\`\``;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue