Spaces:
Running
Running
| """ | |
| 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 | |
| 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 | |
| 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 | |
| # --------------------------------------------------------------------------- | |
| 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 | |
| """ | |
| 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 | |
| # --------------------------------------------------------------------------- | |
| 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) | |