chisel/step-service/service.py
maverick b2a52525d6 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>
2026-07-25 16:02:18 +10:00

85 lines
2.5 KiB
Python

"""Chisel geometry engine — runs build123d code and returns STL or STEP.
POST /tessellate { code, params } → 200 model/stl (preview mesh)
POST /step { code, params } → 200 application/step (B-rep export)
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.
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
from fastapi import FastAPI, Header, HTTPException
from fastapi.responses import Response
from pydantic import BaseModel
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 = 45
app = FastAPI(title="Chisel geometry engine")
class RunReq(BaseModel):
code: str
params: dict = {}
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, f"out.{fmt}")
with open(code_file, "w") as f:
f.write(req.code)
try:
proc = subprocess.run(
[PY, RUNNER, code_file, out_file, fmt, json.dumps(req.params or {})],
capture_output=True,
text=True,
timeout=TIMEOUT,
)
except subprocess.TimeoutExpired:
raise HTTPException(status_code=504, detail="model execution timed out")
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[-900:])
with open(out_file, "rb") as f:
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",
headers={"content-disposition": 'attachment; filename="chisel-model.step"'},
)