noahlee1234
feat: add 1d_path mode and DXF export/download support
e5a7f86
"""
NaturalCAD Modal Worker — CAD generation endpoint.
API CONTRACT
------------
POST / (Modal fastapi_endpoint)
Request JSON:
prompt str required — natural-language description of the model
mode str optional — "part" (default) | "assembly" | "sketch"
output_type str optional — "3d_solid" (default) | "surface" | "2d_vector"
output_format str optional — legacy alias for output_type; ignored if output_type is present
Response JSON (success):
job_id str — full UUID for this run (matches Supabase row and storage key prefix)
generated_code str — the build123d Python script that was executed
urls dict — keys: "glb", "stl", "step" (any subset may be absent on export error)
prompt str — echoed input prompt
success bool — always True on this path
Response JSON (error):
error str — human-readable failure reason
code str — last generated Python script (present only on execution failure)
Auth: x-api-key header must match NATURALCAD_API_KEY secret when that secret is set.
"""
import modal
import ast
import secrets
import signal
import threading
import time
from collections import defaultdict, deque
from contextlib import contextmanager
from pathlib import Path
import tempfile
import os
import httpx
from fastapi import Request, HTTPException
from pydantic import BaseModel, model_validator
app = modal.App("naturalcad")
# Container image — Python 3.10 + OpenCASCADE graphics libs
image = (
modal.Image.from_registry("python:3.10-slim")
.apt_install(
"libgl1",
"libglib2.0-0",
"libxrender1",
"libxext6",
"libxkbcommon0",
)
.pip_install("build123d==0.10.0", "trimesh", "httpx", "fastapi", "pydantic")
)
# ---------------------------------------------------------------------------
# Request model
# ---------------------------------------------------------------------------
_VALID_MODES = {"part", "assembly", "sketch"}
_VALID_OUTPUTS = {"3d_solid", "surface", "2d_vector", "1d_path"}
_MAX_PROMPT_CHARS = int(os.environ.get("NATURALCAD_MAX_PROMPT_CHARS", "1200"))
_RATE_WINDOW_SECONDS = int(os.environ.get("NATURALCAD_RATE_WINDOW_SECONDS", "60"))
_RATE_LIMIT_PER_IP = int(os.environ.get("NATURALCAD_RATE_LIMIT_PER_IP", "20"))
_RATE_LIMIT_PER_KEY = int(os.environ.get("NATURALCAD_RATE_LIMIT_PER_KEY", "60"))
_MAX_CONCURRENT_RUNS = max(1, int(os.environ.get("NATURALCAD_MAX_CONCURRENT_RUNS", "2")))
_MAX_QUEUE_DEPTH = max(0, int(os.environ.get("NATURALCAD_MAX_QUEUE_DEPTH", "4")))
_QUEUE_WAIT_SECONDS = max(0, int(os.environ.get("NATURALCAD_QUEUE_WAIT_SECONDS", "15")))
_RUN_SLOT_SEMAPHORE = threading.BoundedSemaphore(_MAX_CONCURRENT_RUNS)
_STATE_LOCK = threading.Lock()
_ACTIVE_RUNS = 0
_QUEUED_RUNS = 0
_REQUESTS_BY_IP = defaultdict(deque)
_REQUESTS_BY_KEY = defaultdict(deque)
_BLOCKED_NAMES = {
"open", "exec", "eval", "compile", "__import__", "input", "breakpoint",
"globals", "locals", "vars", "getattr", "setattr", "delattr", "help",
"os", "sys", "subprocess", "socket", "httpx", "requests", "urllib",
"pathlib", "shutil", "tempfile", "ctypes", "multiprocessing", "threading",
"asyncio", "importlib", "builtins",
}
_BLOCKED_ATTRS = {
"system", "popen", "run", "Popen", "call", "check_output", "check_call",
"urlopen", "request", "get", "post", "put", "delete", "patch", "connect",
"remove", "unlink", "rmdir", "rmtree", "rename", "replace",
}
_SAFE_BUILTINS = {
"abs": abs,
"all": all,
"any": any,
"bool": bool,
"dict": dict,
"enumerate": enumerate,
"float": float,
"int": int,
"len": len,
"list": list,
"max": max,
"min": min,
"print": print,
"range": range,
"round": round,
"set": set,
"str": str,
"sum": sum,
"tuple": tuple,
"zip": zip,
"Exception": Exception,
"ValueError": ValueError,
}
_VERBOSE_LOGS = os.environ.get("NATURALCAD_VERBOSE_LOGS", "false").strip().lower() in {"1", "true", "yes", "on"}
def _log_info(message: str) -> None:
if _VERBOSE_LOGS:
print(message)
def _log_error(message: str) -> None:
print(message)
def _client_ip(request: Request) -> str:
xff = request.headers.get("x-forwarded-for", "").strip()
if xff:
return xff.split(",")[0].strip()
if request.client and request.client.host:
return request.client.host
return "unknown"
def _allow_request(bucket: dict, key: str, limit: int, window_seconds: int) -> bool:
now = time.time()
cutoff = now - window_seconds
with _STATE_LOCK:
q = bucket[key]
while q and q[0] < cutoff:
q.popleft()
if len(q) >= limit:
return False
q.append(now)
return True
@contextmanager
def _acquire_run_slot():
global _ACTIVE_RUNS, _QUEUED_RUNS
joined_queue = False
with _STATE_LOCK:
if _ACTIVE_RUNS >= _MAX_CONCURRENT_RUNS:
if _QUEUED_RUNS >= _MAX_QUEUE_DEPTH:
raise HTTPException(status_code=429, detail={"error": "Server busy, please retry."})
_QUEUED_RUNS += 1
joined_queue = True
acquired = _RUN_SLOT_SEMAPHORE.acquire(timeout=_QUEUE_WAIT_SECONDS if joined_queue else 1)
if joined_queue:
with _STATE_LOCK:
_QUEUED_RUNS = max(0, _QUEUED_RUNS - 1)
if not acquired:
raise HTTPException(status_code=429, detail={"error": "Server busy, please retry."})
with _STATE_LOCK:
_ACTIVE_RUNS += 1
try:
yield
finally:
with _STATE_LOCK:
_ACTIVE_RUNS = max(0, _ACTIVE_RUNS - 1)
_RUN_SLOT_SEMAPHORE.release()
def _strip_build123d_imports(code: str) -> str:
lines = []
for line in code.splitlines():
if line.strip() == "from build123d import *":
continue
lines.append(line)
return "\n".join(lines).strip() + "\n"
def _validate_generated_code(code: str) -> tuple[bool, str | None]:
try:
tree = ast.parse(code)
except SyntaxError as exc:
return False, f"SyntaxError: {exc}"
for node in ast.walk(tree):
if isinstance(node, (ast.Import, ast.ImportFrom)):
return False, "Import statements are not allowed in generated code."
if isinstance(node, ast.Name) and (node.id in _BLOCKED_NAMES or node.id.startswith("__")):
return False, f"Blocked identifier: {node.id}"
if isinstance(node, ast.Attribute) and node.attr in _BLOCKED_ATTRS:
return False, f"Blocked attribute access: {node.attr}"
if isinstance(node, ast.Call):
if isinstance(node.func, ast.Name) and node.func.id in _BLOCKED_NAMES:
return False, f"Blocked function call: {node.func.id}"
if isinstance(node.func, ast.Attribute) and node.func.attr in _BLOCKED_ATTRS:
return False, f"Blocked function call: {node.func.attr}"
return True, None
def _exec_with_timeout(code: str, script_path: Path, exec_globals: dict) -> None:
timeout_seconds = max(1, int(os.environ.get("NATURALCAD_EXEC_TIMEOUT_SECONDS", "60")))
# SIGALRM only works on the main thread. Modal may invoke this handler on
# a worker thread, so fall back to direct exec in that case.
if threading.current_thread() is not threading.main_thread():
exec(compile(code, str(script_path), "exec"), exec_globals)
return
def _timeout_handler(signum, frame):
raise TimeoutError(f"Execution exceeded {timeout_seconds}s")
old_handler = signal.getsignal(signal.SIGALRM)
signal.signal(signal.SIGALRM, _timeout_handler)
signal.alarm(timeout_seconds)
try:
exec(compile(code, str(script_path), "exec"), exec_globals)
finally:
signal.alarm(0)
signal.signal(signal.SIGALRM, old_handler)
class GenerateRequest(BaseModel):
prompt: str
mode: str = "part"
output_type: str = "3d_solid"
# Legacy alias — accepted silently, mapped below
output_format: str | None = None
@model_validator(mode="after")
def _resolve_aliases_and_validate(self) -> "GenerateRequest":
# Map legacy output_format → output_type when output_type was not supplied
if self.output_type == "3d_solid" and self.output_format and self.output_format != "3d_solid":
self.output_type = self.output_format
if self.mode not in _VALID_MODES:
raise ValueError(f"mode must be one of {sorted(_VALID_MODES)}")
if self.output_type not in _VALID_OUTPUTS:
raise ValueError(f"output_type must be one of {sorted(_VALID_OUTPUTS)}")
prompt_text = self.prompt.strip()
if not prompt_text:
raise ValueError("prompt must not be empty")
if len(prompt_text) > _MAX_PROMPT_CHARS:
raise ValueError(f"prompt too long (max {_MAX_PROMPT_CHARS} chars)")
return self
# ---------------------------------------------------------------------------
# Supabase helpers
# ---------------------------------------------------------------------------
def _upload_to_supabase(storage_key: str, file_data: bytes, content_type: str = "application/octet-stream") -> str:
import urllib.parse
url = os.environ.get("SUPABASE_URL", "").rstrip("/")
key = os.environ.get("SUPABASE_SERVICE_ROLE_KEY", "")
bucket = os.environ.get("SUPABASE_BUCKET", "naturalCAD-artifacts")
if not url or not key:
raise ValueError("Missing Supabase credentials in environment")
encoded_key = urllib.parse.quote(storage_key, safe="/")
endpoint = f"{url}/storage/v1/object/{bucket}/{encoded_key}"
headers = {
"Authorization": f"Bearer {key}",
"Content-Type": content_type,
"x-upsert": "true",
}
with httpx.Client() as client:
resp = client.post(endpoint, content=file_data, headers=headers)
if resp.status_code >= 400:
raise Exception(f"Supabase upload failed {resp.status_code}: {resp.text}")
return f"{url}/storage/v1/object/public/{bucket}/{encoded_key}"
def _log_job_to_supabase(
job_id: str,
prompt: str,
mode: str,
output_type: str,
generated_code: str,
status: str,
error: str = None,
) -> None:
"""Write a job row to the Supabase jobs table (best-effort; never raises)."""
url = os.environ.get("SUPABASE_URL", "").rstrip("/")
key = os.environ.get("SUPABASE_SERVICE_ROLE_KEY", "")
if not url or not key:
_log_info("Skipping DB logging: SUPABASE_URL or key not set")
return
endpoint = f"{url}/rest/v1/jobs"
headers = {
"apikey": key,
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
"Prefer": "resolution=merge-duplicates",
}
payload = {
"id": job_id,
"prompt": prompt,
"status": status,
"mode": mode,
"output_type": output_type,
}
store_code = os.environ.get("NATURALCAD_STORE_CODE", "true").strip().lower() in {"1", "true", "yes", "on"}
if store_code and generated_code:
payload["generated_code"] = generated_code
if error:
payload["error_text"] = error
try:
with httpx.Client() as client:
resp = client.post(endpoint, json=payload, headers=headers)
if resp.status_code >= 400 and "generated_code" in payload:
# Backward-compat fallback for schemas that do not yet have generated_code.
payload.pop("generated_code", None)
resp = client.post(endpoint, json=payload, headers=headers)
if resp.status_code >= 400:
_log_error(f"DB log failed for job {job_id}: {resp.text}")
else:
_log_info(f"DB log OK for job {job_id} (status={status})")
except Exception as e:
_log_error(f"DB log error for job {job_id}: {e}")
# ---------------------------------------------------------------------------
# Endpoint
# ---------------------------------------------------------------------------
@app.function(
image=image,
gpu="T4",
timeout=300,
secrets=[
modal.Secret.from_name("openrouter-secret"),
modal.Secret.from_name("supabase-secret"),
modal.Secret.from_name("naturalcad-api-key"),
],
)
@modal.fastapi_endpoint(method="POST")
def generate_cad_endpoint(payload: dict, request: Request):
import os
# Auth
expected_key = os.environ.get("NATURALCAD_API_KEY")
if not expected_key:
raise HTTPException(status_code=503, detail={"error": "Service auth is not configured."})
provided_key = request.headers.get("x-api-key", "")
if not provided_key or not secrets.compare_digest(provided_key, expected_key):
raise HTTPException(status_code=401, detail={"error": "Unauthorized"})
client_ip = _client_ip(request)
if not _allow_request(_REQUESTS_BY_IP, client_ip, _RATE_LIMIT_PER_IP, _RATE_WINDOW_SECONDS):
raise HTTPException(status_code=429, detail={"error": "Rate limit exceeded for IP."})
if not _allow_request(_REQUESTS_BY_KEY, provided_key, _RATE_LIMIT_PER_KEY, _RATE_WINDOW_SECONDS):
raise HTTPException(status_code=429, detail={"error": "Rate limit exceeded for API key."})
# Validate and normalise
try:
req = GenerateRequest(**payload)
except Exception as exc:
raise HTTPException(status_code=400, detail={"error": str(exc)})
with _acquire_run_slot():
return generate_cad.local(req.prompt, req.mode, req.output_type)
# ---------------------------------------------------------------------------
# Core generation function
# ---------------------------------------------------------------------------
_OUTPUT_RULES = {
"3d_solid": (
"Output goal: a solid 3D part. Use BuildPart, extrusions, and solid boolean operations. "
"result must be a solid Shape (e.g. bp.part)."
),
"surface": (
"Output goal: a thin surface or shell, not a chunky solid. Prefer thin extrusions (1–2 mm) "
"or surface constructs over solid primitives. result must be a valid exportable Shape."
),
"2d_vector": (
"Output goal: a 2D sketch profile (e.g. for laser cutting or DXF export). Use BuildSketch on Plane.XY. "
"Extrude with a minimal thickness of 1 mm so the geometry exports as STL/STEP. "
"result must be a Part (bp.part)."
),
"1d_path": (
"Output goal: a 1D path-style layout (linework/centerlines). Build the geometry from lines/arcs on Plane.XY. "
"For compatibility with STL/STEP preview, give the path a minimal thickness (about 1 mm) by using a thin profile. "
"result must be a Part (bp.part)."
),
}
_MODE_HINTS = {
"part": "Mode: single continuous solid part.",
"assembly": "Mode: assembly of multiple sub-parts. Combine with add() or position using Locations.",
"sketch": "Mode: sketch/profile. Focus on the 2D outline; extrude minimally (1 mm) if a 3D export is needed.",
}
# Static system rules + knowledge base (build123d 0.10.0)
_SYSTEM_RULES = """\
Rules:
1. ONLY return valid Python code. No markdown formatting, no explanations.
2. ALWAYS import build123d using: from build123d import *
3. ALWAYS store the final Shape/Part in a variable named result.
4. ALWAYS specify the plane explicitly: with BuildSketch(Plane.XY):
5. Use the modern builder API: with BuildPart() as bp:
6. Do NOT use points= in Polygon(). Use positional args: Polygon([(0,0), (10,0), (5,8)]).
7. PolarLocations and GridLocations ARE context managers: with PolarLocations(radius, count):
Do NOT wrap them inside Locations().
8. NEVER use standalone rotate() or translate(). Use with Locations((x, y, z)): or obj.rotate(Axis.Z, angle).
9. extrude() takes amount= (e.g. extrude(amount=10)) or both=True. Do NOT use start= or distance=.
10. extrude() must be called inside a BuildPart context, immediately after a BuildSketch block.
11. Keep geometry complexity bounded. Prefer a simplified form over many tiny repeated features.
Canonical skeleton (adapt dimensions and features to the request):
from build123d import *
with BuildPart() as bp:
with BuildSketch(Plane.XY):
Rectangle(60, 40)
extrude(amount=10)
with BuildSketch(bp.faces().sort_by(Axis.Z)[-1]):
with PolarLocations(12, 4):
Circle(4)
extrude(amount=-8, mode=Mode.SUBTRACT)
result = bp.part
# KNOWLEDGE BASE — build123d 0.10.0 patterns:
# PATTERN 1: Simple Box
with BuildPart() as p:
Box(80, 60, 10)
result = p.part
# PATTERN 2: Box with Hole
with BuildPart() as p:
Box(80, 60, 10)
Cylinder(radius=11, height=10, mode=Mode.SUBTRACT)
result = p.part
# PATTERN 3: Extruded Sketch with Hole
with BuildPart() as p:
with BuildSketch(Plane.XY):
Circle(60)
Rectangle(20, 20, mode=Mode.SUBTRACT)
extrude(amount=10)
result = p.part
# PATTERN 4: Multiple Holes using Locations
with BuildPart() as p:
with BuildSketch(Plane.XY):
Circle(80)
extrude(amount=10)
with BuildSketch(p.faces().sort_by(Axis.Z)[-1]):
with Locations((20, 0), (-20, 0), (0, 20), (0, -20)):
Cylinder(radius=5, height=10, mode=Mode.SUBTRACT)
result = p.part
# PATTERN 5: PolarLocations for holes in a circle
with BuildPart() as p:
with BuildSketch(Plane.XY):
Circle(50)
extrude(amount=10)
with BuildSketch(p.faces().sort_by(Axis.Z)[-1]):
with PolarLocations(20, 6):
Cylinder(radius=3, height=10, mode=Mode.SUBTRACT)
result = p.part
# PATTERN 6: Fillet edges
with BuildPart() as p:
Box(60, 40, 10)
fillet(p.edges(), radius=2)
result = p.part
# PATTERN 7: Chamfer
with BuildPart() as p:
Box(60, 40, 10)
chamfer(p.edges(), radius=1)
result = p.part
# PATTERN 8: Cylinder
with BuildPart() as p:
Cylinder(radius=20, height=50)
result = p.part
# PATTERN 9: Rounded Rectangle
with BuildPart() as p:
with BuildSketch(Plane.XY):
RectangleRounded(60, 40, 5)
extrude(amount=10)
result = p.part
# PATTERN 10: Pyramid (using Cone)
with BuildPart() as p:
Cone(radius=50, height=100)
result = p.part
# PATTERN 11: Lofting two sketches
with BuildPart() as p:
with BuildSketch(Plane.XY.offset(0)) as s1:
Circle(30)
with BuildSketch(Plane.XY.offset(50)) as s2:
Rectangle(20, 20)
loft(s1.sketch, s2.sketch)
result = p.part
# PATTERN 12: Mirroring a part
with BuildPart() as p:
Box(30, 20, 10)
mirror(p.part, Plane.YZ)
result = p.part
# PATTERN 13: Union of two shapes
with BuildPart() as p:
Box(30, 30, 30)
with Locations((20, 0, 0)):
Sphere(15)
add()
result = p.part
# PATTERN 14: Difference (Subtract) of two shapes
with BuildPart() as p:
Box(30, 30, 30)
with Locations((10, 0, 0)):
Cylinder(radius=5, height=40)
subtract()
result = p.part
# PATTERN 15: Intersection of two shapes
with BuildPart() as p:
Box(30, 30, 30)
with Locations((15, 0, 0)):
Sphere(20)
intersect()
result = p.part
"""
@app.function(
image=image,
gpu="T4",
timeout=300,
secrets=[
modal.Secret.from_name("openrouter-secret"),
modal.Secret.from_name("supabase-secret"),
],
)
def generate_cad(prompt: str, mode: str = "part", output_type: str = "3d_solid"):
"""
Core generation: prompt + mode + output_type -> LLM -> build123d exec -> Supabase upload.
Returns a dict matching the API contract in the module docstring.
"""
import os
import uuid
openrouter_api_key = os.environ.get("OPENROUTER_API_KEY")
if not openrouter_api_key:
return {"error": "OPENROUTER_API_KEY not found in environment secrets"}
openrouter_api_url = os.environ.get("OPENROUTER_API_URL", "https://openrouter.ai/api/v1/chat/completions")
openrouter_model = os.environ.get("OPENROUTER_MODEL", "anthropic/claude-opus-4.7")
log_generated_code = os.environ.get("NATURALCAD_LOG_CODE", "false").strip().lower() in {"1", "true", "yes", "on"}
include_code_in_response = os.environ.get("NATURALCAD_INCLUDE_CODE_IN_RESPONSE", "false").strip().lower() in {"1", "true", "yes", "on"}
store_glb = os.environ.get("NATURALCAD_STORE_GLB", "false").strip().lower() in {"1", "true", "yes", "on"}
mode_hint = _MODE_HINTS.get(mode, _MODE_HINTS["part"])
output_rule = _OUTPUT_RULES.get(output_type, _OUTPUT_RULES["3d_solid"])
system_prompt = (
"You are an expert Python developer for CAD code generation using the build123d library (version 0.10.0).\n"
"Write Python code to create the 3D model requested by the user.\n\n"
f"{mode_hint}\n"
f"{output_rule}\n\n"
+ _SYSTEM_RULES
)
# First user turn: structured context block + raw request
user_message = f"Mode: {mode}\nOutput: {output_type}\n\nUser request:\n{prompt}"
run_id = str(uuid.uuid4())
max_attempts = 3
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message},
]
for attempt in range(max_attempts):
_log_info(f"LLM call {attempt + 1}/{max_attempts} | mode={mode} output_type={output_type}")
try:
headers = {
"Authorization": f"Bearer {openrouter_api_key}",
"Content-Type": "application/json",
}
referer = os.environ.get("OPENROUTER_REFERER", "")
title = os.environ.get("OPENROUTER_TITLE", "NaturalCAD")
if referer:
headers["HTTP-Referer"] = referer
if title:
headers["X-Title"] = title
payload = {
"model": openrouter_model,
"messages": messages,
"max_tokens": 2048, # 1024 could truncate assemblies or multi-step parts
"temperature": 0.2,
}
with httpx.Client(timeout=180.0) as client:
response = client.post(openrouter_api_url, headers=headers, json=payload)
if response.status_code >= 400:
_log_error(f"OpenRouter error {response.status_code}: {response.text[:500]}")
return {"error": f"LLM provider unavailable ({response.status_code}). Please retry."}
data = response.json()
generated_code = (data.get("choices", [{}])[0].get("message", {}).get("content") or "").strip()
if not generated_code:
_log_error(f"OpenRouter empty content response: {str(data)[:500]}")
return {"error": "LLM returned empty output. Please retry."}
# Strip markdown fences (model sometimes ignores rule 1)
if generated_code.startswith("```python"):
generated_code = generated_code[9:]
elif generated_code.startswith("```"):
generated_code = generated_code[3:]
if generated_code.endswith("```"):
generated_code = generated_code[:-3]
generated_code = generated_code.strip()
except Exception as e:
_log_error(f"LLM call failed: {e}")
return {"error": "LLM call failed. Please retry."}
if log_generated_code:
_log_info(f"Generated code:\n{generated_code}")
from build123d import Axis, ExportDXF, Unit, export_step, export_stl
with tempfile.TemporaryDirectory() as tmpdir:
script_path = Path(tmpdir) / "script.py"
sanitized_code = _strip_build123d_imports(generated_code)
script_path.write_text(sanitized_code)
is_safe, safety_error = _validate_generated_code(sanitized_code)
if not is_safe:
err_short = f"Rejected by AST guard: {safety_error}"
_log_error(err_short)
if attempt < max_attempts - 1:
messages.append({"role": "assistant", "content": generated_code})
messages.append({
"role": "user",
"content": (
f"That code was blocked by safety guard ({safety_error}).\n"
"Return a safe build123d-only script with no imports and no filesystem/network/system calls."
),
})
continue
_log_job_to_supabase(run_id, prompt, mode, output_type, generated_code, "failed", err_short)
return {"error": "Generated code was unsafe and was blocked."}
exec_globals = {"__builtins__": _SAFE_BUILTINS.copy()}
import build123d as _b3d
for _name in dir(_b3d):
if not _name.startswith("_"):
exec_globals[_name] = getattr(_b3d, _name)
# Scrub secrets before exec so generated code cannot read them
original_env = os.environ.copy()
os.environ.pop("OPENROUTER_API_KEY", None)
os.environ.pop("SUPABASE_URL", None)
os.environ.pop("SUPABASE_SERVICE_ROLE_KEY", None)
os.environ.pop("NATURALCAD_API_KEY", None)
exec_success = False
err_short = ""
err_trace = ""
try:
_exec_with_timeout(sanitized_code, script_path, exec_globals)
exec_success = True
except Exception as e:
import traceback as _tb
err_short = f"{type(e).__name__}: {e}"
err_trace = _tb.format_exc()
_log_error(f"Execution failed: {err_short}")
finally:
os.environ.clear()
os.environ.update(original_env)
if exec_success:
result_shape = exec_globals.get("result")
if not result_shape:
err_short = "No 'result' variable found in generated code."
err_trace = err_short
exec_success = False
if not exec_success:
if attempt < max_attempts - 1:
_log_info("Retrying with error context...")
# Cap traceback to avoid blowing the context window
trace_snippet = err_trace[-2000:] if len(err_trace) > 2000 else err_trace
messages.append({"role": "assistant", "content": generated_code})
messages.append({
"role": "user",
"content": (
f"That code failed with the following error:\n{trace_snippet}\n\n"
"Fix the code and return only the corrected Python script, no markdown."
),
})
continue
else:
_log_job_to_supabase(run_id, prompt, mode, output_type, generated_code, "failed", err_short)
return {
"error": "Generation failed during CAD execution. Please refine your prompt and retry.",
"code": generated_code,
}
# ----------------------------------------------------------------
# Export: STL, STEP, GLB, DXF
# ----------------------------------------------------------------
shape = result_shape
urls = {}
stl_path = Path(tmpdir) / "output.stl"
step_path = Path(tmpdir) / "output.step"
glb_path = Path(tmpdir) / "output.glb"
dxf_path = Path(tmpdir) / "output.dxf"
try:
export_stl(shape, str(stl_path))
_log_info(f"STL exported: {stl_path.stat().st_size} bytes")
except Exception as e:
_log_error(f"STL export failed: {e}")
stl_path = None
try:
export_step(shape, str(step_path))
_log_info(f"STEP exported: {step_path.exists()}")
except Exception as e:
_log_error(f"STEP export failed: {e}")
step_path = None
try:
if stl_path and stl_path.exists():
from trimesh import load_mesh
import trimesh.transformations as tf
import math
mesh = load_mesh(str(stl_path), force="mesh")
# Rotate to glTF Y-up convention
mesh.apply_transform(tf.rotation_matrix(-math.pi / 2, [1, 0, 0]))
mesh.export(str(glb_path))
_log_info(f"GLB exported: {glb_path.exists()}")
else:
_log_info("Skipping GLB: no STL file")
except Exception as e:
_log_error(f"GLB export failed: {e}")
try:
if output_type in {"2d_vector", "1d_path"}:
exporter = ExportDXF(unit=Unit.MM)
if output_type == "1d_path":
exporter.add_shape(shape.edges())
else:
faces = shape.faces()
if faces:
top_face = faces.sort_by(Axis.Z)[-1]
wires = [top_face.outer_wire(), *list(top_face.inner_wires())]
exporter.add_shape(wires)
else:
exporter.add_shape(shape.edges())
exporter.write(str(dxf_path))
_log_info(f"DXF exported: {dxf_path.exists()}")
except Exception as e:
_log_error(f"DXF export failed: {e}")
# ----------------------------------------------------------------
# Upload to Supabase storage
# ----------------------------------------------------------------
file_pairs = [
("stl", stl_path, "model/stl"),
("step", step_path, "application/octet-stream"),
]
if dxf_path.exists():
file_pairs.append(("dxf", dxf_path, "application/dxf"))
if store_glb:
file_pairs.append(("glb", glb_path, "model/gltf-binary"))
for fmt, file_path, content_type in file_pairs:
if not file_path or not file_path.exists():
continue
storage_key = f"runs/{run_id}/model.{fmt}"
file_bytes = file_path.read_bytes()
_log_info(f"Uploading {fmt}: {len(file_bytes)} bytes")
try:
urls[fmt] = _upload_to_supabase(storage_key, file_bytes, content_type)
except Exception as e:
_log_error(f"Upload error for {fmt}: {e}")
_log_job_to_supabase(run_id, prompt, mode, output_type, generated_code, "completed")
return {
"job_id": run_id,
"success": True,
"model": openrouter_model,
"urls": urls,
"prompt": prompt,
"generated_code": generated_code if include_code_in_response else "",
}
# ---------------------------------------------------------------------------
# Health check
# ---------------------------------------------------------------------------
@app.function(image=image)
def health_check():
"""Verify build123d imports correctly."""
from build123d import Box
return {"status": "ok", "build123d": "0.10.0"}
if __name__ == "__main__":
result = generate_cad.call("a simple bracket plate")
print(result)