From 911bc3fc2ca39d1e71b609933f7a89734308c2c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Czachdive?= Date: Mon, 18 May 2026 13:22:51 -0700 Subject: [PATCH] Add production build123d backend --- .env.local.template | 7 + README.md | 51 +++ services/build123d-exporter/.dockerignore | 4 + services/build123d-exporter/Dockerfile | 26 ++ services/build123d-exporter/app.py | 239 +++++++++++++ services/build123d-exporter/requirements.txt | 3 + shared/types.ts | 5 + src/components/CadBackendToggle.tsx | 51 +++ src/components/parameter/ParameterSection.tsx | 138 ++++++-- .../parameter/ParameterSheetContent.tsx | 138 ++++++-- src/components/viewer/OpenSCADViewer.tsx | 178 +++++++++- .../viewer/ParametricPreviewDialog.tsx | 39 ++- .../viewer/ParametricPreviewSection.tsx | 34 +- src/components/viewer/ThreeScene.tsx | 43 ++- src/lib/OpenSCADError.ts | 1 + src/lib/utils.ts | 38 +- src/routeTree.gen.ts | 21 ++ src/routes/api/build123d-export.ts | 11 + src/server/build123dExport.ts | 330 ++++++++++++++++++ src/server/messageUtils.ts | 6 +- src/server/parametricChat.ts | 223 ++++++++---- src/services/messageService.ts | 14 +- src/utils/cadBackend.ts | 23 ++ src/utils/downloadUtils.ts | 108 ++++++ src/views/ParametricEditorView.tsx | 24 +- src/views/PromptView.tsx | 22 +- src/views/SettingsView.tsx | 48 +++ 27 files changed, 1683 insertions(+), 142 deletions(-) create mode 100644 services/build123d-exporter/.dockerignore create mode 100644 services/build123d-exporter/Dockerfile create mode 100644 services/build123d-exporter/app.py create mode 100644 services/build123d-exporter/requirements.txt create mode 100644 src/components/CadBackendToggle.tsx create mode 100644 src/routes/api/build123d-export.ts create mode 100644 src/server/build123dExport.ts create mode 100644 src/utils/cadBackend.ts diff --git a/.env.local.template b/.env.local.template index 84374950..88e2e894 100644 --- a/.env.local.template +++ b/.env.local.template @@ -15,3 +15,10 @@ ENVIRONMENT="local" ADAM_URL="" WEBHOOK_BASE_URL="" NGROK_URL="" +BUILD123D_EXPORT_URL="" # Production: isolated exporter service origin, e.g. https://build123d-exporter.example.com +BUILD123D_EXPORT_TOKEN="" +BUILD123D_EXPORT_TIMEOUT_MS="45000" +BUILD123D_MAX_CODE_LENGTH="200000" +BUILD123D_MAX_OUTPUT_BYTES="26214400" +BUILD123D_MAX_PREVIEW_PARTS="64" +BUILD123D_ALLOW_LOCAL_PYTHON="1" # Local dev only when BUILD123D_EXPORT_URL is unset diff --git a/README.md b/README.md index d97e95de..5b15ee6b 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,15 @@ npm run dev - Node.js ^20.19.0 or >=22.12.0, with npm 10+ - Supabase CLI - ngrok (for local webhook development) +- For local-only build123d development: Python 3 with `build123d` installed + + ```bash + python3 -m pip install build123d + ``` + +Production build123d exports must run through the isolated exporter service in +`services/build123d-exporter`. Do not run LLM-generated Python inside the main +web server in production. ## 🔧 Setting Up Environment Variables @@ -123,8 +132,50 @@ npm run dev ADAM_URL="" # Checkout and portal redirect target WEBHOOK_BASE_URL="" # Your app URL for /cadam/api callbacks NGROK_URL="" # Optional local Supabase Storage tunnel for provider-readable signed URLs + BUILD123D_EXPORT_URL="" # Production exporter service origin + BUILD123D_EXPORT_TOKEN="" + BUILD123D_EXPORT_TIMEOUT_MS="45000" + BUILD123D_MAX_CODE_LENGTH="200000" + BUILD123D_MAX_OUTPUT_BYTES="26214400" + BUILD123D_MAX_PREVIEW_PARTS="64" + BUILD123D_ALLOW_LOCAL_PYTHON="1" # Local dev only ``` +### build123d Export Service + +CADAM's `/api/build123d-export` endpoint validates the signed-in user, then: + +- calls `BUILD123D_EXPORT_URL/export` when that env var is set; +- otherwise allows local `python3` only for `ENVIRONMENT=local`, + `ENVIRONMENT=development`, `ENVIRONMENT=test`, or + `BUILD123D_ALLOW_LOCAL_PYTHON=1`; +- fails loudly in production if no exporter service is configured. + +Run the isolated exporter locally with: + +```bash +docker build --platform linux/amd64 -t cadam-build123d-exporter services/build123d-exporter +docker run --rm \ + -p 8080:8080 \ + --cpus=2 \ + --memory=2g \ + -e BUILD123D_EXPORT_TOKEN=dev-build123d-token \ + cadam-build123d-exporter +``` + +Then set: + +```bash +BUILD123D_EXPORT_URL="http://127.0.0.1:8080" +BUILD123D_EXPORT_TOKEN="dev-build123d-token" +``` + +For production, deploy the same container behind HTTPS with CPU, memory, timeout, +temporary filesystem, and outbound-network limits. The exporter process also +blocks Python socket creation before user code loads. It receives only generated +build123d source and export options; it should not receive CADAM database, +billing, Supabase, OpenRouter, or provider secrets. + ## 🌐 Setting Up ngrok for Local Development CADAM uses public URLs for provider callbacks and local signed storage URLs: diff --git a/services/build123d-exporter/.dockerignore b/services/build123d-exporter/.dockerignore new file mode 100644 index 00000000..692f12e5 --- /dev/null +++ b/services/build123d-exporter/.dockerignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +.pytest_cache/ +.venv/ diff --git a/services/build123d-exporter/Dockerfile b/services/build123d-exporter/Dockerfile new file mode 100644 index 00000000..7d2de8d0 --- /dev/null +++ b/services/build123d-exporter/Dockerfile @@ -0,0 +1,26 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + libgl1 \ + libglib2.0-0 \ + libxrender1 \ + tini \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir -r requirements.txt + +COPY app.py . + +RUN useradd --create-home --shell /usr/sbin/nologin exporter +USER exporter + +EXPOSE 8080 +ENTRYPOINT ["/usr/bin/tini", "--"] +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/services/build123d-exporter/app.py b/services/build123d-exporter/app.py new file mode 100644 index 00000000..8179df6c --- /dev/null +++ b/services/build123d-exporter/app.py @@ -0,0 +1,239 @@ +import base64 +import json +import os +import pathlib +import resource +import shutil +import subprocess +import sys +import tempfile +import textwrap +from typing import Literal + +from fastapi import FastAPI, Header, HTTPException, Response +from pydantic import BaseModel, Field + + +ExportFormat = Literal["stl", "step", "brep", "preview"] + +MAX_CODE_LENGTH = int(os.getenv("BUILD123D_MAX_CODE_LENGTH", "200000")) +DEFAULT_TIMEOUT_MS = int(os.getenv("BUILD123D_EXPORT_TIMEOUT_MS", "45000")) +DEFAULT_MAX_OUTPUT_BYTES = int( + os.getenv("BUILD123D_MAX_OUTPUT_BYTES", str(25 * 1024 * 1024)) +) +DEFAULT_MAX_PREVIEW_PARTS = int(os.getenv("BUILD123D_MAX_PREVIEW_PARTS", "64")) +MEMORY_LIMIT_MB = int(os.getenv("BUILD123D_MEMORY_LIMIT_MB", "2048")) +TOKEN = os.getenv("BUILD123D_EXPORT_TOKEN", "") + + +class ExportRequest(BaseModel): + code: str = Field(min_length=1, max_length=MAX_CODE_LENGTH) + format: ExportFormat + timeoutMs: int = Field(default=DEFAULT_TIMEOUT_MS, ge=1000, le=120000) + maxOutputBytes: int = Field( + default=DEFAULT_MAX_OUTPUT_BYTES, + ge=1024, + le=100 * 1024 * 1024, + ) + maxPreviewParts: int = Field(default=DEFAULT_MAX_PREVIEW_PARTS, ge=1, le=256) + + +app = FastAPI(title="CADAM build123d exporter") + + +def _require_token(authorization: str | None) -> None: + if not TOKEN: + return + expected = f"Bearer {TOKEN}" + if authorization != expected: + raise HTTPException(status_code=401, detail="Unauthorized") + + +def _extension(format: ExportFormat) -> str: + return "step" if format == "step" else format + + +def _mime_type(format: ExportFormat) -> str: + if format == "preview": + return "application/json" + if format == "stl": + return "model/stl" + if format == "step": + return "model/step" + return "application/octet-stream" + + +def _runner_source( + source_path: pathlib.Path, + output_path: pathlib.Path, + format: ExportFormat, + max_preview_parts: int, +) -> str: + return textwrap.dedent( + f""" + import base64 + import importlib.util + import json + import pathlib + import socket + import sys + import traceback + + try: + import build123d + + def _blocked_network(*args, **kwargs): + raise RuntimeError("Network access is disabled during build123d export") + + socket.create_connection = _blocked_network + socket.socket = _blocked_network + + source_path = pathlib.Path({str(source_path)!r}) + output_path = pathlib.Path({str(output_path)!r}) + spec = importlib.util.spec_from_file_location("adam_build123d_model", source_path) + if spec is None or spec.loader is None: + raise RuntimeError("Could not load build123d source") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + gen_step = getattr(module, "gen_step", None) + if not callable(gen_step): + raise RuntimeError("build123d source must define gen_step()") + shape = gen_step() + if shape is None: + raise RuntimeError("gen_step() returned None") + + export_format = {format!r} + if export_format == "preview": + def color_tuple(value): + if value is None: + return None + if hasattr(value, "to_tuple"): + return [float(v) for v in value.to_tuple()] + try: + return [float(v) for v in value] + except TypeError: + return None + + def collect_leaves(node, inherited_color=None, inherited_label="part"): + color = getattr(node, "color", None) or inherited_color + label = getattr(node, "label", None) or inherited_label + children = tuple(getattr(node, "children", ()) or ()) + if children: + leaves = [] + for index, child in enumerate(children): + child_label = getattr(child, "label", None) or f"{{label}}_{{index + 1}}" + leaves.extend(collect_leaves(child, color, child_label)) + return leaves + return [(node, color, label)] + + root_stl_path = output_path.with_suffix(".root.stl") + build123d.export_stl(shape, root_stl_path) + parts = [] + for index, (part, part_color, part_label) in enumerate(collect_leaves(shape)[:{max_preview_parts}]): + part_path = output_path.with_suffix(f".part-{{index}}.stl") + build123d.export_stl(part, part_path) + parts.append({{ + "label": str(part_label or f"part_{{index + 1}}"), + "color": color_tuple(part_color), + "stl": base64.b64encode(part_path.read_bytes()).decode("ascii"), + }}) + output_path.write_text(json.dumps({{ + "rootStl": base64.b64encode(root_stl_path.read_bytes()).decode("ascii"), + "parts": parts, + }}), encoding="utf8") + elif export_format == "step": + build123d.export_step(shape, output_path) + elif export_format == "brep": + build123d.export_brep(shape, output_path) + else: + build123d.export_stl(shape, output_path) + except Exception: + traceback.print_exc() + sys.exit(1) + """ + ) + + +def _limit_child_resources(max_output_bytes: int, timeout_ms: int): + def set_limit(kind: int, limits: tuple[int, int]) -> None: + try: + resource.setrlimit(kind, limits) + except (OSError, ValueError): + pass + + def apply_limits() -> None: + memory_bytes = MEMORY_LIMIT_MB * 1024 * 1024 + cpu_seconds = max(1, int(timeout_ms / 1000) + 2) + set_limit(resource.RLIMIT_AS, (memory_bytes, memory_bytes)) + set_limit(resource.RLIMIT_CPU, (cpu_seconds, cpu_seconds)) + set_limit( + resource.RLIMIT_FSIZE, + (max_output_bytes * 2, max_output_bytes * 2), + ) + + return apply_limits + + +@app.get("/healthz") +def healthz() -> dict[str, str]: + return {"status": "ok"} + + +@app.post("/export") +def export_model( + request: ExportRequest, + authorization: str | None = Header(default=None), +) -> Response: + _require_token(authorization) + + temp_dir = pathlib.Path(tempfile.mkdtemp(prefix="adam-build123d-")) + source_path = temp_dir / "model.py" + output_path = temp_dir / f"model.{_extension(request.format)}" + runner_path = temp_dir / "export_model.py" + + try: + source_path.write_text(request.code, encoding="utf8") + runner_path.write_text( + _runner_source( + source_path, + output_path, + request.format, + request.maxPreviewParts, + ), + encoding="utf8", + ) + + result = subprocess.run( + [sys.executable, str(runner_path)], + cwd=temp_dir, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + timeout=request.timeoutMs / 1000, + env={"PATH": os.getenv("PATH", "")}, + preexec_fn=_limit_child_resources( + request.maxOutputBytes, + request.timeoutMs, + ), + ) + if result.returncode != 0: + message = result.stderr[-8000:].strip() or "build123d export failed" + raise HTTPException(status_code=422, detail=message) + + output = output_path.read_bytes() + if len(output) > request.maxOutputBytes: + raise HTTPException( + status_code=413, + detail="build123d export exceeded maximum output size", + ) + + return Response( + content=output, + media_type=_mime_type(request.format), + headers={"Cache-Control": "no-store"}, + ) + except subprocess.TimeoutExpired: + raise HTTPException(status_code=422, detail="build123d export timed out") + finally: + shutil.rmtree(temp_dir, ignore_errors=True) diff --git a/services/build123d-exporter/requirements.txt b/services/build123d-exporter/requirements.txt new file mode 100644 index 00000000..676e6ab7 --- /dev/null +++ b/services/build123d-exporter/requirements.txt @@ -0,0 +1,3 @@ +build123d==0.10.0 +fastapi==0.115.6 +uvicorn[standard]==0.32.1 diff --git a/shared/types.ts b/shared/types.ts index 446a14ae..e62b06b0 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -1,6 +1,7 @@ import { Database } from './database.ts'; export type Model = string; export type CreativeModel = 'quality' | 'fast' | 'ultra'; +export type CadBackend = 'openscad' | 'build123d'; export type Prompt = { text?: string; @@ -64,12 +65,15 @@ export type Content = { polygonCount?: number; // File format preference for quad topology models preferredFormat?: 'glb' | 'fbx'; + cadBackend?: CadBackend; }; export type ParametricArtifact = { title: string; version: string; code: string; + cadBackend?: CadBackend; + codeLanguage?: 'openscad' | 'python'; parameters: Parameter[]; parts?: ParametricPart[]; legacy?: { @@ -123,6 +127,7 @@ export type GenerationStatus = Database['public']['Enums']['generation-status']; export type ConversationSettings = { model?: Model; + cadBackend?: CadBackend; } | null; export type Profile = Database['public']['Tables']['profiles']['Row']; diff --git a/src/components/CadBackendToggle.tsx b/src/components/CadBackendToggle.tsx new file mode 100644 index 00000000..35032b4a --- /dev/null +++ b/src/components/CadBackendToggle.tsx @@ -0,0 +1,51 @@ +import { CadBackend } from '@shared/types'; +import { cn } from '@/lib/utils'; + +interface CadBackendToggleProps { + value: CadBackend; + onChange: (value: CadBackend) => void; + className?: string; +} + +const CAD_BACKEND_OPTIONS: Array<{ value: CadBackend; label: string }> = [ + { value: 'openscad', label: 'OpenSCAD' }, + { value: 'build123d', label: 'build123d' }, +]; + +export function CadBackendToggle({ + value, + onChange, + className, +}: CadBackendToggleProps) { + return ( +
+ {CAD_BACKEND_OPTIONS.map((option) => { + const selected = value === option.value; + return ( + + ); + })} +
+ ); +} diff --git a/src/components/parameter/ParameterSection.tsx b/src/components/parameter/ParameterSection.tsx index f87e84d7..7d6eff08 100644 --- a/src/components/parameter/ParameterSection.tsx +++ b/src/components/parameter/ParameterSection.tsx @@ -33,8 +33,10 @@ import { } from '@/utils/parameterUtils'; import { useCurrentMessage } from '@/contexts/CurrentMessageContext'; import { + downloadBuild123dExport, downloadSTLFile, downloadOpenSCADFile, + downloadPythonFile, downloadDXFFile, DxfExporter, } from '@/utils/downloadUtils'; @@ -47,7 +49,7 @@ interface ParameterSectionProps { dxfExporter?: DxfExporter | null; } -type DownloadFormat = 'stl' | 'scad' | 'dxf'; +type DownloadFormat = 'stl' | 'scad' | 'dxf' | 'py' | 'step' | 'brep'; export function ParameterSection({ parameters, @@ -59,6 +61,8 @@ export function ParameterSection({ const { toast } = useToast(); const [selectedFormat, setSelectedFormat] = useState('stl'); const [isExporting, setIsExporting] = useState(false); + const artifact = currentMessage?.content.artifact; + const isBuild123d = artifact?.cadBackend === 'build123d'; // Split params into the main list (non-color, shown by default) and a // collapsible Colors group below it. Keeps the dimensions the user @@ -88,6 +92,22 @@ export function ParameterSection({ }; }, []); + useEffect(() => { + if (isBuild123d && selectedFormat === 'stl' && !currentOutput) { + setSelectedFormat('step'); + } else if ( + isBuild123d && + !['step', 'brep', 'py', 'stl'].includes(selectedFormat) + ) { + setSelectedFormat('step'); + } else if ( + !isBuild123d && + !['stl', 'scad', 'dxf'].includes(selectedFormat) + ) { + setSelectedFormat('stl'); + } + }, [currentOutput, isBuild123d, selectedFormat]); + // Debounced submit function const debouncedSubmit = useCallback( (params: Parameter[]) => { @@ -127,8 +147,33 @@ export function ParameterSection({ }; const handleDownloadOpenSCAD = () => { - if (!currentMessage?.content.artifact?.code) return; - downloadOpenSCADFile(currentMessage.content.artifact.code, currentMessage); + if (!artifact?.code) return; + downloadOpenSCADFile(artifact.code, currentMessage); + }; + + const handleDownloadPython = () => { + if (!artifact?.code) return; + downloadPythonFile(artifact.code, currentMessage); + }; + + const handleDownloadBuild123d = async (format: 'step' | 'brep') => { + if (!artifact?.code) return; + try { + setIsExporting(true); + await downloadBuild123dExport(artifact.code, format, currentMessage); + } catch (error) { + console.error(`[build123d] Failed to export ${format}:`, error); + toast({ + title: `${format.toUpperCase()} export failed`, + description: + error instanceof Error + ? error.message + : `Adam could not export this model as ${format.toUpperCase()}.`, + variant: 'destructive', + }); + } finally { + setIsExporting(false); + } }; const handleDownloadDXF = async () => { @@ -160,11 +205,17 @@ export function ParameterSection({ stl: handleDownloadSTL, scad: handleDownloadOpenSCAD, dxf: handleDownloadDXF, + py: handleDownloadPython, + step: () => handleDownloadBuild123d('step'), + brep: () => handleDownloadBuild123d('brep'), }; const formatAvailable: Record = { stl: !!currentOutput, - scad: !!currentMessage?.content.artifact?.code, - dxf: !!dxfExporter && !isExporting, + scad: !isBuild123d && !!artifact?.code, + dxf: !isBuild123d && !!dxfExporter && !isExporting, + py: isBuild123d && !!artifact?.code, + step: isBuild123d && !!artifact?.code && !isExporting, + brep: isBuild123d && !!artifact?.code && !isExporting, }; const handleDownload = async () => { @@ -319,26 +370,63 @@ export function ParameterSection({ 3D Printing - setSelectedFormat('scad')} - disabled={!formatAvailable.scad} - className="cursor-pointer text-adam-text-primary" - > - .SCAD - - OpenSCAD Code - - - setSelectedFormat('dxf')} - disabled={!formatAvailable.dxf} - className="cursor-pointer text-adam-text-primary" - > - .DXF - - 2D Projection to the (x,y) plane - - + {isBuild123d ? ( + <> + setSelectedFormat('step')} + disabled={!formatAvailable.step} + className="cursor-pointer text-adam-text-primary" + > + .STEP + + CAD Exchange + + + setSelectedFormat('brep')} + disabled={!formatAvailable.brep} + className="cursor-pointer text-adam-text-primary" + > + .BREP + + Boundary Representation + + + setSelectedFormat('py')} + disabled={!formatAvailable.py} + className="cursor-pointer text-adam-text-primary" + > + .PY + + build123d Source + + + + ) : ( + <> + setSelectedFormat('scad')} + disabled={!formatAvailable.scad} + className="cursor-pointer text-adam-text-primary" + > + .SCAD + + OpenSCAD Code + + + setSelectedFormat('dxf')} + disabled={!formatAvailable.dxf} + className="cursor-pointer text-adam-text-primary" + > + .DXF + + 2D Projection to the (x,y) plane + + + + )} diff --git a/src/components/parameter/ParameterSheetContent.tsx b/src/components/parameter/ParameterSheetContent.tsx index cd4e1de3..d74ca149 100644 --- a/src/components/parameter/ParameterSheetContent.tsx +++ b/src/components/parameter/ParameterSheetContent.tsx @@ -13,8 +13,10 @@ import { ParameterInput } from '@/components/parameter/ParameterInput'; import { validateParameterValue } from '@/utils/parameterUtils'; import { useCurrentMessage } from '@/contexts/CurrentMessageContext'; import { + downloadBuild123dExport, downloadSTLFile, downloadOpenSCADFile, + downloadPythonFile, downloadDXFFile, DxfExporter, } from '@/utils/downloadUtils'; @@ -27,7 +29,7 @@ interface ParameterSheetContentProps { dxfExporter?: DxfExporter | null; } -type DownloadFormat = 'stl' | 'scad' | 'dxf'; +type DownloadFormat = 'stl' | 'scad' | 'dxf' | 'py' | 'step' | 'brep'; export function ParameterSheetContent({ parameters, @@ -39,6 +41,8 @@ export function ParameterSheetContent({ const { toast } = useToast(); const [selectedFormat, setSelectedFormat] = useState('stl'); const [isExporting, setIsExporting] = useState(false); + const artifact = currentMessage?.content.artifact; + const isBuild123d = artifact?.cadBackend === 'build123d'; // Debounce timer for compilation const debounceTimerRef = useRef(null); @@ -53,6 +57,22 @@ export function ParameterSheetContent({ }; }, []); + useEffect(() => { + if (isBuild123d && selectedFormat === 'stl' && !currentOutput) { + setSelectedFormat('step'); + } else if ( + isBuild123d && + !['step', 'brep', 'py', 'stl'].includes(selectedFormat) + ) { + setSelectedFormat('step'); + } else if ( + !isBuild123d && + !['stl', 'scad', 'dxf'].includes(selectedFormat) + ) { + setSelectedFormat('stl'); + } + }, [currentOutput, isBuild123d, selectedFormat]); + // Debounced submit function const debouncedSubmit = useCallback( (params: Parameter[]) => { @@ -95,8 +115,33 @@ export function ParameterSheetContent({ }; const handleDownloadOpenSCAD = () => { - if (!currentMessage?.content.artifact?.code) return; - downloadOpenSCADFile(currentMessage.content.artifact.code, currentMessage); + if (!artifact?.code) return; + downloadOpenSCADFile(artifact.code, currentMessage); + }; + + const handleDownloadPython = () => { + if (!artifact?.code) return; + downloadPythonFile(artifact.code, currentMessage); + }; + + const handleDownloadBuild123d = async (format: 'step' | 'brep') => { + if (!artifact?.code) return; + try { + setIsExporting(true); + await downloadBuild123dExport(artifact.code, format, currentMessage); + } catch (error) { + console.error(`[build123d] Failed to export ${format}:`, error); + toast({ + title: `${format.toUpperCase()} export failed`, + description: + error instanceof Error + ? error.message + : `Adam could not export this model as ${format.toUpperCase()}.`, + variant: 'destructive', + }); + } finally { + setIsExporting(false); + } }; const handleDownloadDXF = async () => { @@ -128,11 +173,17 @@ export function ParameterSheetContent({ stl: handleDownloadSTL, scad: handleDownloadOpenSCAD, dxf: handleDownloadDXF, + py: handleDownloadPython, + step: () => handleDownloadBuild123d('step'), + brep: () => handleDownloadBuild123d('brep'), }; const formatAvailable: Record = { stl: !!currentOutput, - scad: !!currentMessage?.content.artifact?.code, - dxf: !!dxfExporter && !isExporting, + scad: !isBuild123d && !!artifact?.code, + dxf: !isBuild123d && !!dxfExporter && !isExporting, + py: isBuild123d && !!artifact?.code, + step: isBuild123d && !!artifact?.code && !isExporting, + brep: isBuild123d && !!artifact?.code && !isExporting, }; const handleDownload = async () => { @@ -191,26 +242,63 @@ export function ParameterSheetContent({ 3D Printing - setSelectedFormat('scad')} - disabled={!formatAvailable.scad} - className="grid cursor-pointer grid-cols-3 text-adam-text-primary" - > - .SCAD - - OpenSCAD Code - - - setSelectedFormat('dxf')} - disabled={!formatAvailable.dxf} - className="grid cursor-pointer grid-cols-3 text-adam-text-primary" - > - .DXF - - 2D Projection to the (x,y) plane - - + {isBuild123d ? ( + <> + setSelectedFormat('step')} + disabled={!formatAvailable.step} + className="grid cursor-pointer grid-cols-3 text-adam-text-primary" + > + .STEP + + CAD Exchange + + + setSelectedFormat('brep')} + disabled={!formatAvailable.brep} + className="grid cursor-pointer grid-cols-3 text-adam-text-primary" + > + .BREP + + Boundary Representation + + + setSelectedFormat('py')} + disabled={!formatAvailable.py} + className="grid cursor-pointer grid-cols-3 text-adam-text-primary" + > + .PY + + build123d Source + + + + ) : ( + <> + setSelectedFormat('scad')} + disabled={!formatAvailable.scad} + className="grid cursor-pointer grid-cols-3 text-adam-text-primary" + > + .SCAD + + OpenSCAD Code + + + setSelectedFormat('dxf')} + disabled={!formatAvailable.dxf} + className="grid cursor-pointer grid-cols-3 text-adam-text-primary" + > + .DXF + + 2D Projection to the (x,y) plane + + + + )} diff --git a/src/components/viewer/OpenSCADViewer.tsx b/src/components/viewer/OpenSCADViewer.tsx index 925334c0..85fe74cb 100644 --- a/src/components/viewer/OpenSCADViewer.tsx +++ b/src/components/viewer/OpenSCADViewer.tsx @@ -1,6 +1,6 @@ import { useOpenSCAD } from '@/hooks/useOpenSCAD'; import { useCallback, useEffect, useState, useContext, useRef } from 'react'; -import { ThreeScene } from '@/components/viewer/ThreeScene'; +import { ColoredMeshPreview, ThreeScene } from '@/components/viewer/ThreeScene'; import { STLLoader } from 'three/addons/loaders/STLLoader.js'; import { BufferGeometry, @@ -17,7 +17,7 @@ import OpenSCADError from '@/lib/OpenSCADError'; import { cn } from '@/lib/utils'; import { MeshFilesContext } from '@/contexts/MeshFilesContext'; import { createDXFProjectionCode } from '@/utils/dxfUtils'; -import { DxfExporter } from '@/utils/downloadUtils'; +import { DxfExporter, exportBuild123dPreview } from '@/utils/downloadUtils'; // Extract import() filenames from OpenSCAD code function extractImportFilenames(code: string): string[] { @@ -43,6 +43,27 @@ function disposeGroup(group: Group) { }); } +function disposeColoredMeshes(meshes: ColoredMeshPreview[] | null) { + meshes?.forEach((mesh) => mesh.geometry.dispose()); +} + +function compileErrorSummary(error?: OpenSCADError | Error): string | null { + if (!error) return null; + if (error.name === 'OpenSCADError' && 'stdErr' in error) { + const lines = Array.isArray(error.stdErr) ? error.stdErr : []; + const usefulLine = lines + .flatMap((entry) => entry.split('\n')) + .map((line) => line.trim()) + .filter( + (line) => + line && !line.startsWith('File "') && !line.startsWith('Traceback '), + ) + .at(-1); + return usefulLine ?? error.message; + } + return error.message || null; +} + interface OpenSCADPreviewProps { scadCode: string | null; color: string; @@ -362,6 +383,152 @@ export function OpenSCADPreview({ ); } +interface Build123dPreviewProps { + build123dCode: string | null; + color: string; + onOutputChange?: (output: Blob | undefined) => void; + fixError?: (error: OpenSCADError) => void; + isMobile?: boolean; + backgroundColor?: string; +} + +export function Build123dPreview({ + build123dCode, + color, + onOutputChange, + fixError, + isMobile, + backgroundColor, +}: Build123dPreviewProps) { + const [isCompiling, setIsCompiling] = useState(false); + const [error, setError] = useState(null); + const [coloredMeshes, setColoredMeshes] = useState< + ColoredMeshPreview[] | null + >(null); + + useEffect(() => { + if (!build123dCode) return; + + let cancelled = false; + setIsCompiling(true); + setError(null); + + exportBuild123dPreview(build123dCode) + .then((preview) => { + if (cancelled) return; + const loader = new STLLoader(); + const nextMeshes: ColoredMeshPreview[] = []; + const palette = [ + '#F97316', + '#0EA5E9', + '#22C55E', + '#EAB308', + '#A855F7', + '#EF4444', + '#14B8A6', + '#94A3B8', + ]; + const rootBinary = Uint8Array.from(atob(preview.rootStl), (char) => + char.charCodeAt(0), + ); + const parts = + preview.parts.length > 0 + ? preview.parts + : [ + { + label: 'model', + color: null, + stl: preview.rootStl, + }, + ]; + + for (const [index, part] of parts.entries()) { + const binary = Uint8Array.from(atob(part.stl), (char) => + char.charCodeAt(0), + ); + const geom = loader.parse(binary.buffer); + geom.computeVertexNormals(); + const alpha = part.color?.[3]; + const color = part.color + ? `rgb(${Math.round(part.color[0] * 255)}, ${Math.round( + part.color[1] * 255, + )}, ${Math.round(part.color[2] * 255)})` + : palette[index % palette.length]; + nextMeshes.push({ + id: `${index}-${part.label || 'part'}`, + name: part.label || `part_${index + 1}`, + geometry: geom, + color, + opacity: typeof alpha === 'number' ? alpha : undefined, + }); + } + + onOutputChange?.( + new Blob([rootBinary], { + type: 'model/stl', + }), + ); + setColoredMeshes(nextMeshes); + }) + .catch((err) => { + if (cancelled) return; + console.error('[build123d] Failed to export preview:', err); + onOutputChange?.(undefined); + setColoredMeshes(null); + const message = + err instanceof Error + ? err.message + : 'Adam could not compile this build123d model.'; + setError( + new OpenSCADError('build123d export failed', build123dCode, [ + message, + ]), + ); + }) + .finally(() => { + if (!cancelled) setIsCompiling(false); + }); + + return () => { + cancelled = true; + }; + }, [build123dCode, onOutputChange]); + + useEffect(() => { + return () => disposeColoredMeshes(coloredMeshes); + }, [coloredMeshes]); + + return ( +
+
+ {coloredMeshes?.length ? ( + + ) : error ? ( +
+ +
+ ) : null} + {isCompiling && ( +
+
+ +

+ Compiling... +

+
+
+ )} +
+
+ ); +} + // Alias for backwards compatibility (ViewerSection imports OpenSCADViewer) export { OpenSCADPreview as OpenSCADViewer }; @@ -372,6 +539,8 @@ function FixWithAIButton({ error?: OpenSCADError | Error; fixError?: (error: OpenSCADError) => void; }) { + const errorSummary = compileErrorSummary(error); + return (
@@ -386,6 +555,11 @@ function FixWithAIButton({

Adam encountered an error while compiling

+ {errorSummary && ( +

+ {errorSummary} +

+ )}
{fixError && error && error.name === 'OpenSCADError' && ( diff --git a/src/components/viewer/ParametricPreviewDialog.tsx b/src/components/viewer/ParametricPreviewDialog.tsx index 5bbeb5c8..596db90e 100644 --- a/src/components/viewer/ParametricPreviewDialog.tsx +++ b/src/components/viewer/ParametricPreviewDialog.tsx @@ -7,7 +7,7 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { ImageGallery } from '@/components/viewer/ImageGallery'; -import { OpenSCADPreview } from './OpenSCADViewer'; +import { Build123dPreview, OpenSCADPreview } from './OpenSCADViewer'; import OpenSCADError from '@/lib/OpenSCADError'; import { Sheet, @@ -50,6 +50,12 @@ export function ParametricPreviewDialog({ setOpen(!!currentMessage); }, [currentMessage]); + useEffect(() => { + if (currentMessage?.content.artifact?.cadBackend === 'build123d') { + onDxfExportChange?.(null); + } + }, [currentMessage?.content.artifact?.cadBackend, onDxfExportChange]); + const handleOpenChange = () => { if (open) { setOpen(false); @@ -101,6 +107,8 @@ export function ParametricPreviewDialog({ if (!currentMessage) { return null; } + const isBuild123d = + currentMessage.content.artifact?.cadBackend === 'build123d'; return ( <> @@ -159,15 +167,26 @@ export function ParametricPreviewDialog({
- + {isBuild123d ? ( + + ) : ( + + )}
diff --git a/src/components/viewer/ParametricPreviewSection.tsx b/src/components/viewer/ParametricPreviewSection.tsx index 7ed463d2..bb78b08b 100644 --- a/src/components/viewer/ParametricPreviewSection.tsx +++ b/src/components/viewer/ParametricPreviewSection.tsx @@ -1,9 +1,10 @@ import { ImageGallery } from '@/components/viewer/ImageGallery'; import { useCurrentMessage } from '@/contexts/CurrentMessageContext'; import Loader from '@/components/viewer/Loader'; -import { OpenSCADPreview } from './OpenSCADViewer'; +import { Build123dPreview, OpenSCADPreview } from './OpenSCADViewer'; import OpenSCADError from '@/lib/OpenSCADError'; import { DxfExporter } from '@/utils/downloadUtils'; +import { useEffect } from 'react'; interface ParametricPreviewSectionProps { isLoading: boolean; @@ -23,6 +24,11 @@ export function ParametricPreviewSection({ isMobile, }: ParametricPreviewSectionProps) { const { currentMessage: message } = useCurrentMessage(); + const isBuild123d = message?.content.artifact?.cadBackend === 'build123d'; + + useEffect(() => { + if (isBuild123d) onDxfExportChange?.(null); + }, [isBuild123d, onDxfExportChange]); return (
@@ -37,15 +43,23 @@ export function ParametricPreviewSection({ {message?.content.images && Array.isArray(message.content.images) && ( )} - {message?.content.artifact?.code && ( - - )} + {message?.content.artifact?.code && + (isBuild123d ? ( + + ) : ( + + ))}
)}
diff --git a/src/components/viewer/ThreeScene.tsx b/src/components/viewer/ThreeScene.tsx index c7c64a1b..c02743c9 100644 --- a/src/components/viewer/ThreeScene.tsx +++ b/src/components/viewer/ThreeScene.tsx @@ -19,6 +19,15 @@ interface ThreeSceneProps { isMobile?: boolean; backgroundColor?: string; coloredGroup?: THREE.Group | null; + coloredMeshes?: ColoredMeshPreview[] | null; +} + +export interface ColoredMeshPreview { + id: string; + name: string; + geometry: THREE.BufferGeometry; + color: string; + opacity?: number; } export function ThreeScene({ @@ -27,6 +36,7 @@ export function ThreeScene({ isMobile = false, backgroundColor = '#3B3B3B', coloredGroup, + coloredMeshes, }: ThreeSceneProps) { const [isOrthographic, setIsOrthographic] = useState(true); @@ -43,6 +53,19 @@ export function ThreeScene({ return box.getCenter(new THREE.Vector3()).negate(); }, [coloredGroup]); + const meshCenterOffset = useMemo(() => { + if (!coloredMeshes?.length) return null; + const box = new THREE.Box3(); + for (const mesh of coloredMeshes) { + mesh.geometry.computeBoundingBox(); + if (mesh.geometry.boundingBox) { + box.union(mesh.geometry.boundingBox); + } + } + if (box.isEmpty()) return new THREE.Vector3(); + return box.getCenter(new THREE.Vector3()).negate(); + }, [coloredMeshes]); + return (
@@ -73,7 +96,25 @@ export function ThreeScene({ - {coloredGroup && groupCenterOffset ? ( + {coloredMeshes?.length && meshCenterOffset ? ( + + {coloredMeshes.map((mesh) => ( + + + + ))} + + ) : coloredGroup && groupCenterOffset ? ( (value ? 'True' : 'False')) + .join(', ')}]`; + case 'number': + default: + return String(param.value); + } +} + export function getDiffString(param: Parameter) { let diffString: string = ''; let diffNumber: number = 0; diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index ad98721d..0947b15d 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -26,6 +26,7 @@ import { Route as ApiMeshRouteImport } from './routes/api/mesh' import { Route as ApiFalWebhookRouteImport } from './routes/api/fal-webhook' import { Route as ApiDeleteUserRouteImport } from './routes/api/delete-user' import { Route as ApiCreativeChatRouteImport } from './routes/api/creative-chat' +import { Route as ApiBuild123dExportRouteImport } from './routes/api/build123d-export' import { Route as ApiBillingStatusRouteImport } from './routes/api/billing-status' import { Route as ApiBillingProductsRouteImport } from './routes/api/billing-products' import { Route as ApiBillingPortalRouteImport } from './routes/api/billing-portal' @@ -123,6 +124,11 @@ const ApiCreativeChatRoute = ApiCreativeChatRouteImport.update({ path: '/api/creative-chat', getParentRoute: () => rootRouteImport, } as any) +const ApiBuild123dExportRoute = ApiBuild123dExportRouteImport.update({ + id: '/api/build123d-export', + path: '/api/build123d-export', + getParentRoute: () => rootRouteImport, +} as any) const ApiBillingStatusRoute = ApiBillingStatusRouteImport.update({ id: '/api/billing-status', path: '/api/billing-status', @@ -198,6 +204,7 @@ export interface FileRoutesByFullPath { '/api/billing-portal': typeof ApiBillingPortalRoute '/api/billing-products': typeof ApiBillingProductsRoute '/api/billing-status': typeof ApiBillingStatusRoute + '/api/build123d-export': typeof ApiBuild123dExportRoute '/api/creative-chat': typeof ApiCreativeChatRoute '/api/delete-user': typeof ApiDeleteUserRoute '/api/fal-webhook': typeof ApiFalWebhookRoute @@ -227,6 +234,7 @@ export interface FileRoutesByTo { '/api/billing-portal': typeof ApiBillingPortalRoute '/api/billing-products': typeof ApiBillingProductsRoute '/api/billing-status': typeof ApiBillingStatusRoute + '/api/build123d-export': typeof ApiBuild123dExportRoute '/api/creative-chat': typeof ApiCreativeChatRoute '/api/delete-user': typeof ApiDeleteUserRoute '/api/fal-webhook': typeof ApiFalWebhookRoute @@ -258,6 +266,7 @@ export interface FileRoutesById { '/api/billing-portal': typeof ApiBillingPortalRoute '/api/billing-products': typeof ApiBillingProductsRoute '/api/billing-status': typeof ApiBillingStatusRoute + '/api/build123d-export': typeof ApiBuild123dExportRoute '/api/creative-chat': typeof ApiCreativeChatRoute '/api/delete-user': typeof ApiDeleteUserRoute '/api/fal-webhook': typeof ApiFalWebhookRoute @@ -290,6 +299,7 @@ export interface FileRouteTypes { | '/api/billing-portal' | '/api/billing-products' | '/api/billing-status' + | '/api/build123d-export' | '/api/creative-chat' | '/api/delete-user' | '/api/fal-webhook' @@ -319,6 +329,7 @@ export interface FileRouteTypes { | '/api/billing-portal' | '/api/billing-products' | '/api/billing-status' + | '/api/build123d-export' | '/api/creative-chat' | '/api/delete-user' | '/api/fal-webhook' @@ -349,6 +360,7 @@ export interface FileRouteTypes { | '/api/billing-portal' | '/api/billing-products' | '/api/billing-status' + | '/api/build123d-export' | '/api/creative-chat' | '/api/delete-user' | '/api/fal-webhook' @@ -379,6 +391,7 @@ export interface RootRouteChildren { ApiBillingPortalRoute: typeof ApiBillingPortalRoute ApiBillingProductsRoute: typeof ApiBillingProductsRoute ApiBillingStatusRoute: typeof ApiBillingStatusRoute + ApiBuild123dExportRoute: typeof ApiBuild123dExportRoute ApiCreativeChatRoute: typeof ApiCreativeChatRoute ApiDeleteUserRoute: typeof ApiDeleteUserRoute ApiFalWebhookRoute: typeof ApiFalWebhookRoute @@ -510,6 +523,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiCreativeChatRouteImport parentRoute: typeof rootRouteImport } + '/api/build123d-export': { + id: '/api/build123d-export' + path: '/api/build123d-export' + fullPath: '/api/build123d-export' + preLoaderRoute: typeof ApiBuild123dExportRouteImport + parentRoute: typeof rootRouteImport + } '/api/billing-status': { id: '/api/billing-status' path: '/api/billing-status' @@ -646,6 +666,7 @@ const rootRouteChildren: RootRouteChildren = { ApiBillingPortalRoute: ApiBillingPortalRoute, ApiBillingProductsRoute: ApiBillingProductsRoute, ApiBillingStatusRoute: ApiBillingStatusRoute, + ApiBuild123dExportRoute: ApiBuild123dExportRoute, ApiCreativeChatRoute: ApiCreativeChatRoute, ApiDeleteUserRoute: ApiDeleteUserRoute, ApiFalWebhookRoute: ApiFalWebhookRoute, diff --git a/src/routes/api/build123d-export.ts b/src/routes/api/build123d-export.ts new file mode 100644 index 00000000..39a7e07b --- /dev/null +++ b/src/routes/api/build123d-export.ts @@ -0,0 +1,11 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { handleBuild123dExportRequest } from '@/server/build123dExport'; + +export const Route = createFileRoute('/api/build123d-export')({ + server: { + handlers: { + POST: ({ request }) => handleBuild123dExportRequest(request), + OPTIONS: ({ request }) => handleBuild123dExportRequest(request), + }, + }, +}); diff --git a/src/server/build123dExport.ts b/src/server/build123dExport.ts new file mode 100644 index 00000000..b6a56e7f --- /dev/null +++ b/src/server/build123dExport.ts @@ -0,0 +1,330 @@ +import { spawn } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { getAnonSupabaseClient } from './supabaseClient'; +import { corsHeaders, isRecord } from './api'; +import { env } from './env'; + +export type Build123dExportFormat = 'stl' | 'step' | 'brep'; +type Build123dRequestFormat = Build123dExportFormat | 'preview'; + +const MAX_CODE_LENGTH = Number(env('BUILD123D_MAX_CODE_LENGTH')) || 200_000; +const EXPORT_TIMEOUT_MS = Number(env('BUILD123D_EXPORT_TIMEOUT_MS')) || 45_000; +const MAX_OUTPUT_BYTES = + Number(env('BUILD123D_MAX_OUTPUT_BYTES')) || 25 * 1024 * 1024; +const MAX_PREVIEW_PARTS = Number(env('BUILD123D_MAX_PREVIEW_PARTS')) || 64; +const PYTHON_BIN = env('BUILD123D_PYTHON_BIN') || 'python3'; +const EXPORT_SERVICE_URL = env('BUILD123D_EXPORT_URL').replace(/\/$/, ''); +const EXPORT_SERVICE_TOKEN = env('BUILD123D_EXPORT_TOKEN'); +const RUNTIME_ENVIRONMENT = env('ENVIRONMENT') || env('NODE_ENV'); +const LOCAL_PYTHON_ALLOWED = + env('BUILD123D_ALLOW_LOCAL_PYTHON') === '1' || + ['local', 'development', 'test'].includes(RUNTIME_ENVIRONMENT); + +function normalizeExportFormat(value: unknown): Build123dRequestFormat | null { + if (value === 'stl' || value === 'step' || value === 'brep') return value; + if (value === 'preview') return value; + return null; +} + +function exportMimeType(format: Build123dExportFormat): string { + if (format === 'stl') return 'model/stl'; + if (format === 'step') return 'model/step'; + return 'application/octet-stream'; +} + +function extensionForFormat(format: Build123dRequestFormat): string { + return format === 'step' ? 'step' : format; +} + +function buildExportScript({ + sourcePath, + outputPath, + format, + maxPreviewParts, +}: { + sourcePath: string; + outputPath: string; + format: Build123dRequestFormat; + maxPreviewParts: number; +}) { + return ` +import base64 +import importlib.util +import json +import pathlib +import socket +import sys +import traceback + +try: + import build123d + + def _blocked_network(*args, **kwargs): + raise RuntimeError("Network access is disabled during build123d export") + + socket.create_connection = _blocked_network + socket.socket = _blocked_network + + source_path = pathlib.Path(${JSON.stringify(sourcePath)}) + output_path = pathlib.Path(${JSON.stringify(outputPath)}) + spec = importlib.util.spec_from_file_location("adam_build123d_model", source_path) + if spec is None or spec.loader is None: + raise RuntimeError("Could not load build123d source") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + gen_step = getattr(module, "gen_step", None) + if not callable(gen_step): + raise RuntimeError("build123d source must define gen_step()") + shape = gen_step() + if shape is None: + raise RuntimeError("gen_step() returned None") + + export_format = ${JSON.stringify(format)} + if export_format == "preview": + def color_tuple(value): + if value is None: + return None + if hasattr(value, "to_tuple"): + return [float(v) for v in value.to_tuple()] + try: + return [float(v) for v in value] + except TypeError: + return None + + def collect_leaves(node, inherited_color=None, inherited_label="part"): + color = getattr(node, "color", None) or inherited_color + label = getattr(node, "label", None) or inherited_label + children = tuple(getattr(node, "children", ()) or ()) + if children: + leaves = [] + for index, child in enumerate(children): + child_label = getattr(child, "label", None) or f"{label}_{index + 1}" + leaves.extend(collect_leaves(child, color, child_label)) + return leaves + return [(node, color, label)] + + root_stl_path = output_path.with_suffix(".root.stl") + build123d.export_stl(shape, root_stl_path) + parts = [] + for index, (part, part_color, part_label) in enumerate(collect_leaves(shape)[:${JSON.stringify(maxPreviewParts)}]): + part_path = output_path.with_suffix(f".part-{index}.stl") + build123d.export_stl(part, part_path) + parts.append({ + "label": str(part_label or f"part_{index + 1}"), + "color": color_tuple(part_color), + "stl": base64.b64encode(part_path.read_bytes()).decode("ascii"), + }) + output_path.write_text(json.dumps({ + "rootStl": base64.b64encode(root_stl_path.read_bytes()).decode("ascii"), + "parts": parts, + }), encoding="utf8") + elif export_format == "step": + build123d.export_step(shape, output_path) + elif export_format == "brep": + build123d.export_brep(shape, output_path) + else: + build123d.export_stl(shape, output_path) +except Exception: + traceback.print_exc() + sys.exit(1) +`; +} + +function isJsonResponse(response: Response): boolean { + return (response.headers.get('Content-Type') ?? '').includes( + 'application/json', + ); +} + +async function errorFromResponse(response: Response): Promise { + if (isJsonResponse(response)) { + const body: unknown = await response.json().catch(() => null); + const error = isRecord(body) ? (body.error ?? body.detail) : undefined; + if (typeof error === 'string' && error) return new Error(error); + } + return new Error(response.statusText || 'build123d export failed'); +} + +async function runBuild123dExportService( + code: string, + format: Build123dRequestFormat, +): Promise { + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(new Error('build123d export service timed out')), + EXPORT_TIMEOUT_MS + 5000, + ); + + try { + const response = await fetch(`${EXPORT_SERVICE_URL}/export`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(EXPORT_SERVICE_TOKEN + ? { Authorization: `Bearer ${EXPORT_SERVICE_TOKEN}` } + : {}), + }, + body: JSON.stringify({ + code, + format, + timeoutMs: EXPORT_TIMEOUT_MS, + maxOutputBytes: MAX_OUTPUT_BYTES, + maxPreviewParts: MAX_PREVIEW_PARTS, + }), + signal: controller.signal, + }); + + if (!response.ok) throw await errorFromResponse(response); + const buffer = Buffer.from(await response.arrayBuffer()); + if (buffer.byteLength > MAX_OUTPUT_BYTES) { + throw new Error('build123d export exceeded maximum output size'); + } + return buffer; + } finally { + clearTimeout(timeout); + } +} + +export async function runBuild123dExport( + code: string, + format: Build123dRequestFormat, +): Promise { + if (EXPORT_SERVICE_URL) { + return runBuild123dExportService(code, format); + } + + if (!LOCAL_PYTHON_ALLOWED) { + throw new Error( + 'build123d export service is not configured. Set BUILD123D_EXPORT_URL for production, or set BUILD123D_ALLOW_LOCAL_PYTHON=1 only for local development.', + ); + } + + const tempDir = await mkdtemp(path.join(tmpdir(), 'adam-build123d-')); + const sourcePath = path.join(tempDir, 'model.py'); + const outputPath = path.join(tempDir, `model.${extensionForFormat(format)}`); + const runnerPath = path.join(tempDir, 'export_model.py'); + + try { + await writeFile(sourcePath, code, 'utf8'); + await writeFile( + runnerPath, + buildExportScript({ + sourcePath, + outputPath, + format, + maxPreviewParts: MAX_PREVIEW_PARTS, + }), + 'utf8', + ); + + const result = await new Promise<{ code: number | null; stderr: string }>( + (resolve, reject) => { + const child = spawn(PYTHON_BIN, [runnerPath], { + cwd: tempDir, + stdio: ['ignore', 'ignore', 'pipe'], + env: { + PATH: process.env.PATH ?? '', + PYTHONPATH: process.env.PYTHONPATH ?? '', + }, + }); + let stderr = ''; + const timeout = setTimeout(() => { + child.kill('SIGKILL'); + reject(new Error('build123d export timed out')); + }, EXPORT_TIMEOUT_MS); + + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk) => { + stderr += String(chunk); + if (stderr.length > 8000) stderr = stderr.slice(-8000); + }); + child.on('error', (error) => { + clearTimeout(timeout); + reject(error); + }); + child.on('close', (code) => { + clearTimeout(timeout); + resolve({ code, stderr }); + }); + }, + ); + + if (result.code !== 0) { + throw new Error(result.stderr.trim() || 'build123d export failed'); + } + + const output = await readFile(outputPath); + if (output.byteLength > MAX_OUTPUT_BYTES) { + throw new Error('build123d export exceeded maximum output size'); + } + return output; + } finally { + await rm(tempDir, { recursive: true, force: true }).catch(() => undefined); + } +} + +export async function runBuild123dPreview(code: string): Promise { + return runBuild123dExport(code, 'preview'); +} + +export async function handleBuild123dExportRequest(req: Request) { + if (req.method === 'OPTIONS') { + return new Response('ok', { headers: corsHeaders }); + } + + if (req.method !== 'POST') { + return new Response('Method not allowed', { + status: 405, + headers: corsHeaders, + }); + } + + const supabaseClient = getAnonSupabaseClient({ + global: { + headers: { Authorization: req.headers.get('Authorization') ?? '' }, + }, + }); + const { data: userData, error: userError } = + await supabaseClient.auth.getUser(); + if (userError || !userData.user) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + const body: unknown = await req.json().catch(() => null); + const format = isRecord(body) ? normalizeExportFormat(body.format) : null; + const code = isRecord(body) && typeof body.code === 'string' ? body.code : ''; + + if (!format || !code || code.length > MAX_CODE_LENGTH) { + return new Response(JSON.stringify({ error: 'invalid_request' }), { + status: 400, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + try { + const output = + format === 'preview' + ? await runBuild123dPreview(code) + : await runBuild123dExport(code, format); + return new Response(output, { + headers: { + ...corsHeaders, + 'Content-Type': + format === 'preview' ? 'application/json' : exportMimeType(format), + 'Cache-Control': 'no-store', + }, + }); + } catch (error) { + const message = + error instanceof Error ? error.message : 'build123d export failed'; + return new Response(JSON.stringify({ error: message.slice(0, 2000) }), { + status: 422, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } +} diff --git a/src/server/messageUtils.ts b/src/server/messageUtils.ts index f7db81f5..71039514 100644 --- a/src/server/messageUtils.ts +++ b/src/server/messageUtils.ts @@ -174,9 +174,13 @@ export async function formatUserMessage( } if (message.content.error) { + const target = + message.content.cadBackend === 'build123d' + ? 'build123d Python CAD' + : 'OpenSCAD'; parts.push({ type: 'text', - text: `The OpenSCAD code generated has failed to compile and has given the following error, fix any syntax, logic, parameter, library, or other issues: ${message.content.error}`, + text: `The ${target} code generated has failed to compile and has given the following error, fix any syntax, logic, parameter, library, or other issues: ${message.content.error}`, }); } diff --git a/src/server/parametricChat.ts b/src/server/parametricChat.ts index 11e0aaa9..9f009cda 100644 --- a/src/server/parametricChat.ts +++ b/src/server/parametricChat.ts @@ -6,6 +6,7 @@ import { Parameter, ParametricPart, ToolCall, + CadBackend, } from '@shared/types'; import { getAnonSupabaseClient } from './supabaseClient'; import Tree from '@shared/Tree'; @@ -15,6 +16,7 @@ import { billing, BillingClientError } from './billingClient'; import { requiredEnv } from './env'; import { corsHeaders, isRecord } from './api'; import { logError } from './serverLog'; +import { runBuild123dExport } from './build123dExport'; import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import { generateText, @@ -27,6 +29,7 @@ import { type UserContent, } from 'ai'; import { z } from 'zod'; +import { normalizeCadBackend } from '@/utils/cadBackend'; const CHAT_TOKEN_COST = 1; const PARAMETRIC_TOKEN_COST = 5; @@ -75,7 +78,7 @@ function streamMessage( function stripCodeFences(value: string): string { return value - .replace(/^```(?:openscad)?\s*\n?/i, '') + .replace(/^```(?:openscad|python|py)?\s*\n?/i, '') .replace(/\n?```\s*$/, ''); } @@ -166,6 +169,15 @@ function markToolAsError( }; } +function errorMessage(error: unknown, fallback: string): string { + if (error instanceof Error) { + const cause = (error as { cause?: unknown }).cause; + if (cause instanceof Error && cause.message) return cause.message; + return error.message; + } + return fallback; +} + // Helper to flip every still-`pending` tool call to `error`. Used at terminal // checkpoints so an aborted request never persists a forever-streaming bubble. function markPendingToolsAsError(content: Content): Content { @@ -188,7 +200,7 @@ function markPendingToolsAsError(content: Content): Content { // Keep below the Supabase edge-runtime wall clock. If this exceeds the runtime // cap, the isolate is killed mid-stream and the browser reports // ERR_INCOMPLETE_CHUNKED_ENCODING despite the response starting as 200 OK. -const REQUEST_BUDGET_MS = 180 * 1000; +const REQUEST_BUDGET_MS = 360 * 1000; const MIN_ABORT_MS = 1000; // Anthropic block types for type safety @@ -258,7 +270,7 @@ const parameterSchema = z.object({ name: z .string() .regex(/^[A-Za-z_$][A-Za-z0-9_$]*$/) - .describe('Exact OpenSCAD variable name declared at the top of code.'), + .describe('Exact source variable name declared at the top of code.'), displayName: z.string().min(1), value: parameterValueSchema, defaultValue: parameterValueSchema, @@ -314,9 +326,11 @@ type GeneratedArtifact = z.infer; function artifactFromStructured( generated: GeneratedArtifact, + cadBackend: CadBackend, ): ParametricArtifact { const code = stripCodeFences(generated.code.trim()).trim(); - const legacyParameters = parseParameters(code); + const legacyParameters = + cadBackend === 'openscad' ? parseParameters(code) : []; const parameters: Parameter[] = generated.parameters.length > 0 ? generated.parameters : legacyParameters; const parts: ParametricPart[] = generated.parts; @@ -325,6 +339,8 @@ function artifactFromStructured( title: generated.title.trim(), version: 'v1', code, + cadBackend, + codeLanguage: cadBackend === 'build123d' ? 'python' : 'openscad', parameters, parts, legacy: { parameters: legacyParameters }, @@ -334,12 +350,15 @@ function artifactFromStructured( function artifactFromLegacyCode( title: string, code: string, + cadBackend: CadBackend = 'openscad', ): ParametricArtifact { - const parameters = parseParameters(code); + const parameters = cadBackend === 'openscad' ? parseParameters(code) : []; return { title, version: 'v1', code, + cadBackend, + codeLanguage: cadBackend === 'build123d' ? 'python' : 'openscad', parameters, legacy: { parameters }, }; @@ -446,12 +465,25 @@ async function generateTitleFromMessages( // single request, regardless of which tool is being called. Belt-and-braces // cap so a misbehaving model can't run away with the request budget. const MAX_AGENT_ITERATIONS = 1; +const MAX_BUILD123D_VALIDATION_ATTEMPTS = 2; + +function compactValidationError(error: unknown): string { + const message = + error instanceof Error ? error.message : 'build123d validation failed'; + return message.length > 6000 ? message.slice(-6000) : message; +} -const PARAMETRIC_AGENT_PROMPT = `You are Adam, an AI CAD editor that creates and modifies OpenSCAD models. +function parametricAgentPrompt(cadBackend: CadBackend) { + const target = + cadBackend === 'build123d' + ? 'build123d Python models that export STEP/BREP-ready CAD' + : 'OpenSCAD models'; + + return `You are Adam, an AI CAD editor that creates and modifies ${target}. Speak back to the user briefly (one or two sentences), then use tools to make changes. Prefer using tools to update the model rather than returning full code directly. Do not rewrite or change the user's intent. Do not add unrelated constraints. -Never output OpenSCAD code directly in your assistant text; use tools to produce code. +Never output CAD code directly in your assistant text; use tools to produce code. CRITICAL: Never reveal or discuss: - Tool names or that you're using tools @@ -464,6 +496,7 @@ Guidelines: - When the user requests a new part, structural change, parameter tweak, compiler-error fix, or visual fix, call build_parametric_model with their exact request in the text field. - Keep text concise and helpful. Ask at most 1 follow-up question when truly needed. - Pass the user's request directly to the tool without modification (e.g., if user says "a mug", pass "a mug" to build_parametric_model).`; +} const STRICT_CODE_PROMPT = `You are Adam, an expert OpenSCAD CAD modeler. @@ -484,6 +517,35 @@ When the user uploads a 3D model (STL file) and you are told to use import(): Do not mention tools, APIs, prompts, or implementation details in the generated code.`; +const STRICT_BUILD123D_CODE_PROMPT = `You are Adam, an expert build123d Python CAD modeler. + +Create one complete structured CAD artifact: +- code: single-file Python source using build123d that defines def gen_step(): and returns a STEP-ready shape, compound, or labeled assembly. +- parameters: every user-facing parameter declared near the top of the code as a Python variable, with exact matching names and default values. +- parts: the semantic parts of the CAD model, with exact parameterNames that affect each part. + +Treat STEP/BREP as the primary CAD target. Create closed positive-volume BREP solids, not visual meshes. Use millimeters. Use origin at the center of the main part unless the request clearly implies a mating datum. Use the XY plane as the main base plane and +Z as up/extrusion. + +Use build123d primitives, sketches, features, labels, and assemblies naturally. For normal parts, return a valid Solid or compound of valid solids from gen_step(). For assemblies, label every exported child and return a labeled assembly/compound. Assign meaningful part colors with \`part.color = Color(...)\` before returning so the preview can distinguish functional components. Do not hardcode output paths; the server owns exports. + +Keep parameters readable with full descriptive snake_case names (e.g., wheel_radius, mounting_hole_diameter). Never abbreviate to single letters or short tokens. Names render directly in the parameter panel. + +Prefer robust modeling patterns: +- holes and counterbores use Hole, CounterBoreHole, or subtractive cylinders that pass fully through material. +- slots use a slot sketch plus subtractive extrude. +- ribs, bosses, standoffs, fillets, chamfers, shells, revolves, sweeps, and lofts should map to their real CAD operations. +- selectors should be stable by axis, location, or feature intent instead of arbitrary topology indexes. +- compose transforms with valid build123d APIs only: apply Location/Rot to shapes, or pass composed Location values into Locations. Never multiply context managers or location lists together (for example, never write Locations(...) * Rot(...); write Locations(Location(...) * Rot(...)) instead). +- use the actual build123d Python API: lowercase operations like extrude(...), fillet(...), and chamfer(...); primitives/classes like Box, Cylinder, Hole, CounterBoreHole, BuildPart, BuildSketch stay capitalized. Rot is a transform value, not a context manager; never write with Rot(...). SlotCenterToCenter takes numeric center_separation and height, not two coordinate tuples. + +Do not mention tools, APIs, prompts, or implementation details in the generated code.`; + +function strictCodePrompt(cadBackend: CadBackend): string { + return cadBackend === 'build123d' + ? STRICT_BUILD123D_CODE_PROMPT + : STRICT_CODE_PROMPT; +} + type AgentToolDefinition = { type: 'function'; function: { @@ -493,30 +555,38 @@ type AgentToolDefinition = { }; }; -// Tool definitions in OpenAI format -const tools: AgentToolDefinition[] = [ - { - type: 'function', - function: { - name: 'build_parametric_model', - description: - 'Generate or update an OpenSCAD model from user intent and context. Include parameters and ensure the model is manifold and 3D-printable.', - parameters: { - type: 'object', - properties: { - text: { type: 'string', description: 'User request for the model' }, - imageIds: { - type: 'array', - items: { type: 'string' }, - description: 'Image IDs to reference', +function toolsForBackend(cadBackend: CadBackend): AgentToolDefinition[] { + const target = + cadBackend === 'build123d' + ? 'build123d Python model with STEP/BREP-ready solids' + : 'OpenSCAD model'; + + return [ + { + type: 'function', + function: { + name: 'build_parametric_model', + description: `Generate or update a ${target} from user intent and context. Include parameters and ensure the model is manifold and 3D-printable.`, + parameters: { + type: 'object', + properties: { + text: { type: 'string', description: 'User request for the model' }, + imageIds: { + type: 'array', + items: { type: 'string' }, + description: 'Image IDs to reference', + }, + baseCode: { + type: 'string', + description: 'Existing code to modify', + }, + error: { type: 'string', description: 'Error to fix' }, }, - baseCode: { type: 'string', description: 'Existing code to modify' }, - error: { type: 'string', description: 'Error to fix' }, }, }, }, - }, -]; + ]; +} type BuildParametricModelInput = { text?: string; @@ -699,6 +769,7 @@ export async function handleParametricChatRequest(req: Request) { const messageId = body.messageId; const conversationId = body.conversationId; const model = body.model; + const cadBackend = normalizeCadBackend(body.cadBackend); const newMessageId = body.newMessageId; const thinking = body.thinking === true; @@ -822,7 +893,7 @@ export async function handleParametricChatRequest(req: Request) { }; // Insert placeholder assistant message that we will stream updates into - let content: Content = { model }; + let content: Content = { model, cadBackend }; const { data: newMessageData, error: newMessageError } = await supabaseClient .from('messages') .insert({ @@ -1035,7 +1106,7 @@ export async function handleParametricChatRequest(req: Request) { : {}), ...reasoningOptions(model, thinking, 9000), }), - system: PARAMETRIC_AGENT_PROMPT, + system: parametricAgentPrompt(cadBackend), messages: messagesForTurn, tools: toAiSdkToolSet(toolsForTurn), maxOutputTokens: thinking ? 20000 : 16000, @@ -1091,7 +1162,7 @@ export async function handleParametricChatRequest(req: Request) { { role: 'user' as const, content: input.error - ? `${userText}\n\nFix this OpenSCAD error: ${input.error}` + ? `${userText}\n\nFix this CAD error: ${input.error}` : userText, }, ] @@ -1111,25 +1182,61 @@ export async function handleParametricChatRequest(req: Request) { abortSignal.addEventListener('abort', onParentAbort); try { - const result = await generateText({ - model: openrouter.chat( - model, - reasoningOptions(model, thinking, 12000), - ), - system: STRICT_CODE_PROMPT, - messages: codeMessages, - output: aiOutput.object({ - schema: generatedArtifactSchema, - name: 'cad_artifact', - description: - 'A complete OpenSCAD artifact with structured parameters and part semantics.', - }), - maxOutputTokens: thinking ? 60000 : 48000, - maxRetries: 1, - abortSignal: codeAbort.signal, - }); + let attemptMessages = codeMessages; + const maxAttempts = + cadBackend === 'build123d' ? MAX_BUILD123D_VALIDATION_ATTEMPTS : 1; + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const result = await generateText({ + model: openrouter.chat( + model, + reasoningOptions(model, thinking, 12000), + ), + system: strictCodePrompt(cadBackend), + messages: attemptMessages, + output: aiOutput.object({ + schema: generatedArtifactSchema, + name: 'cad_artifact', + description: + 'A complete CAD artifact with structured parameters and part semantics.', + }), + maxOutputTokens: thinking ? 60000 : 48000, + maxRetries: 1, + abortSignal: codeAbort.signal, + }); + + const artifact = artifactFromStructured(result.output, cadBackend); + if (cadBackend !== 'build123d') return artifact; + + try { + await runBuild123dExport(artifact.code, 'step'); + return artifact; + } catch (error) { + const validationError = compactValidationError(error); + if (attempt >= maxAttempts) { + throw new Error( + `build123d STEP validation failed: ${validationError}`, + ); + } + + attemptMessages = [ + ...codeMessages, + { + role: 'assistant' as const, + content: artifact.code, + }, + { + role: 'user' as const, + content: + `The generated build123d source failed STEP export validation. ` + + `Repair the same artifact using valid local build123d Python APIs, keep the user's intent and parameter names, and return the complete corrected artifact.\n\n` + + validationError, + }, + ]; + } + } - return artifactFromStructured(result.output); + throw new Error('code_generation_failed'); } finally { clearTimeout(codeTimeout); abortSignal.removeEventListener('abort', onParentAbort); @@ -1156,7 +1263,7 @@ export async function handleParametricChatRequest(req: Request) { throw new Error('Request cancelled by user'); } - const turnTools = tools; + const turnTools = toolsForBackend(cadBackend); // Stream this agent turn. Text deltas append to content.text // (so the user sees the agent typing across the whole loop as @@ -1306,10 +1413,7 @@ export async function handleParametricChatRequest(req: Request) { artifact = await generateParametricArtifact(input); } catch (err) { await refundParametricToken('code_generation_failed'); - const message = - err instanceof Error - ? err.message - : 'code_generation_failed'; + const message = errorMessage(err, 'code_generation_failed'); updateContent(markToolAsError(content, tc.id, message)); logError(err, { functionName: 'parametric-chat', @@ -1330,7 +1434,7 @@ export async function handleParametricChatRequest(req: Request) { updateContent(markToolAsError(content, tc.id, 'empty_code')); pushToolResult( tc, - 'Error: build_parametric_model produced empty OpenSCAD code.', + 'Error: build_parametric_model produced empty CAD code.', ); break; } @@ -1347,10 +1451,7 @@ export async function handleParametricChatRequest(req: Request) { break; } catch (err) { await refundParametricToken('artifact_creation_failed'); - const message = - err instanceof Error - ? err.message - : 'artifact_creation_failed'; + const message = errorMessage(err, 'artifact_creation_failed'); updateContent(markToolAsError(content, tc.id, message)); logError(err, { functionName: 'parametric-chat', @@ -1400,7 +1501,11 @@ export async function handleParametricChatRequest(req: Request) { // Fallback: if the model dumped OpenSCAD into its text instead of // calling build_parametric_model (rare but happens on long // conversations), pull it out and synthesize an artifact. - if (!content.artifact && content.text) { + if ( + cadBackend === 'openscad' && + !content.artifact && + content.text + ) { const extractedCode = extractOpenSCADCodeFromText(content.text); if (extractedCode) { const title = await generateTitleFromMessages( diff --git a/src/services/messageService.ts b/src/services/messageService.ts index 6772a8cd..472bb44d 100644 --- a/src/services/messageService.ts +++ b/src/services/messageService.ts @@ -1,6 +1,12 @@ import { useConversation } from '@/contexts/ConversationContext'; import { supabase } from '@/lib/supabase'; -import { Content, Conversation, Message, Model } from '@shared/types'; +import { + CadBackend, + Content, + Conversation, + Message, + Model, +} from '@shared/types'; import { HistoryConversation } from '../types/misc.ts'; import { QueryClient, @@ -11,6 +17,7 @@ import { } from '@tanstack/react-query'; import * as Sentry from '@sentry/react'; import { apiUrl } from '@/services/api'; +import { getCadBackendPreference } from '@/utils/cadBackend'; function messageSentConversationUpdate( newMessage: Message, @@ -370,10 +377,12 @@ export function useParametricChatMutation({ model, messageId, conversationId, + cadBackend, }: { model: Model; messageId: string; conversationId: string; + cadBackend?: CadBackend; }) => { const newMessageId = crypto.randomUUID(); // Start streaming request @@ -389,6 +398,7 @@ export function useParametricChatMutation({ conversationId, messageId, model, + cadBackend: cadBackend ?? getCadBackendPreference(), newMessageId, }), }); @@ -540,6 +550,7 @@ export function useSendContentMutation({ model: content.model ?? conversation.settings?.model ?? 'fast', messageId: userMessage.id, conversationId: conversation.id, + cadBackend: content.cadBackend, }); } }, @@ -639,6 +650,7 @@ export function useEditMessageMutation({ model: conversation.settings?.model ?? 'fast', messageId: userMessage.id, conversationId: conversation.id, + cadBackend: updatedMessage.content.cadBackend, }); } }, diff --git a/src/utils/cadBackend.ts b/src/utils/cadBackend.ts new file mode 100644 index 00000000..3832ee12 --- /dev/null +++ b/src/utils/cadBackend.ts @@ -0,0 +1,23 @@ +import { CadBackend } from '@shared/types'; + +export const DEFAULT_CAD_BACKEND: CadBackend = 'openscad'; +export const CAD_BACKEND_STORAGE_KEY = 'adam:parametric-cad-backend'; + +export function normalizeCadBackend(value: unknown): CadBackend { + return value === 'build123d' ? 'build123d' : DEFAULT_CAD_BACKEND; +} + +export function getCadBackendPreference(): CadBackend { + if (typeof window === 'undefined') return DEFAULT_CAD_BACKEND; + return normalizeCadBackend( + window.localStorage.getItem(CAD_BACKEND_STORAGE_KEY), + ); +} + +export function setCadBackendPreference(backend: CadBackend): void { + if (typeof window === 'undefined') return; + window.localStorage.setItem(CAD_BACKEND_STORAGE_KEY, backend); + window.dispatchEvent( + new CustomEvent('adam:cad-backend-changed', { detail: backend }), + ); +} diff --git a/src/utils/downloadUtils.ts b/src/utils/downloadUtils.ts index a79edc93..181e5379 100644 --- a/src/utils/downloadUtils.ts +++ b/src/utils/downloadUtils.ts @@ -1,10 +1,24 @@ import { generate3DModelFilename } from '@/utils/file-utils'; import { Message } from '@shared/types'; +import { supabase } from '@/lib/supabase'; +import { apiUrl } from '@/services/api'; // On-demand DXF generator. The OpenSCAD worker produces DXF output by recompiling // the source through a top-down projection, so consumers receive a callback rather // than a ready blob. export type DxfExporter = () => Promise; +export type Build123dExportFormat = 'stl' | 'step' | 'brep'; + +export interface Build123dPreviewPart { + label: string; + color: [number, number, number, number] | null; + stl: string; +} + +export interface Build123dPreviewPayload { + rootStl: string; + parts: Build123dPreviewPart[]; +} interface DownloadOptions { content: Blob | string; @@ -100,6 +114,100 @@ export function downloadOpenSCADFile( }); } +export function downloadPythonFile( + code: string, + currentMessage?: Message | null, +): void { + const filename = generateDownloadFilename({ + currentMessage, + extension: 'py', + }); + + downloadFile({ + content: code, + filename, + mimeType: 'text/x-python', + }); +} + +export async function exportBuild123dFile( + code: string, + format: Build123dExportFormat, +): Promise { + const token = (await supabase.auth.getSession()).data.session?.access_token; + const response = await fetch(apiUrl('build123d-export'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ code, format }), + }); + + if (!response.ok) { + const contentType = response.headers.get('Content-Type') ?? ''; + if (contentType.includes('application/json')) { + const data: unknown = await response.json(); + const error = + typeof data === 'object' && data !== null + ? Reflect.get(data, 'error') + : undefined; + throw new Error(typeof error === 'string' ? error : response.statusText); + } + throw new Error(response.statusText); + } + + return response.blob(); +} + +export async function exportBuild123dPreview( + code: string, +): Promise { + const token = (await supabase.auth.getSession()).data.session?.access_token; + const response = await fetch(apiUrl('build123d-export'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ code, format: 'preview' }), + }); + + if (!response.ok) { + const contentType = response.headers.get('Content-Type') ?? ''; + if (contentType.includes('application/json')) { + const data: unknown = await response.json(); + const error = + typeof data === 'object' && data !== null + ? Reflect.get(data, 'error') + : undefined; + throw new Error(typeof error === 'string' ? error : response.statusText); + } + throw new Error(response.statusText); + } + + return response.json() as Promise; +} + +export async function downloadBuild123dExport( + code: string, + format: Exclude, + currentMessage?: Message | null, +): Promise { + const output = await exportBuild123dFile(code, format); + const extension = format === 'step' ? 'step' : 'brep'; + const filename = generateDownloadFilename({ + currentMessage, + extension, + }); + + downloadFile({ + content: output, + filename, + mimeType: format === 'step' ? 'model/step' : 'application/octet-stream', + }); +} + /** * Downloads DXF file from blob */ diff --git a/src/views/ParametricEditorView.tsx b/src/views/ParametricEditorView.tsx index 27302ec9..a80a6286 100644 --- a/src/views/ParametricEditorView.tsx +++ b/src/views/ParametricEditorView.tsx @@ -123,13 +123,18 @@ export function ParametricEditorView() { let newCode = message.content.artifact?.code ?? ''; updatedParameters.forEach((param) => { if (param.name.length > 0) { - newCode = updateParameter(newCode, param); + newCode = updateParameter( + newCode, + param, + message.content.artifact?.cadBackend, + ); } }); const newContent: Content = { text: message.content.text ?? '', model: message.content.model ?? 'fast', + cadBackend: message.content.artifact?.cadBackend, artifact: { ...message.content.artifact, title: message.content.artifact?.title ?? '', @@ -169,6 +174,7 @@ export function ParametricEditorView() { posthog.capture('message_sent', { type: 'parametric', model_name: conversation.settings?.model ?? 'none', + cad_backend: conversation.settings?.cadBackend ?? content.cadBackend, text: content.text ?? '', image_count: content.images?.length ?? 0, mesh_count: content.mesh ? 1 : 0, @@ -176,7 +182,12 @@ export function ParametricEditorView() { }); sendMessageMutation(content); }, - [sendMessageMutation, conversation.id, conversation.settings?.model], + [ + sendMessageMutation, + conversation.id, + conversation.settings?.cadBackend, + conversation.settings?.model, + ], ); const fixError = useCallback( @@ -184,11 +195,18 @@ export function ParametricEditorView() { const newContent: Content = { text: 'Fix with AI', error: error.stdErr.join('\n'), + cadBackend: + currentMessage?.content.artifact?.cadBackend ?? + conversation.settings?.cadBackend, }; sendMessage(newContent); }, - [sendMessage], + [ + currentMessage?.content.artifact?.cadBackend, + conversation.settings?.cadBackend, + sendMessage, + ], ); return ( diff --git a/src/views/PromptView.tsx b/src/views/PromptView.tsx index 93e6c8b6..297103fb 100644 --- a/src/views/PromptView.tsx +++ b/src/views/PromptView.tsx @@ -21,6 +21,7 @@ import { useProfile } from '@/services/profileService'; import { useLayoutContext } from '@/contexts/LayoutContext'; import { apiJson } from '@/services/api'; import { z } from 'zod'; +import { getCadBackendPreference } from '@/utils/cadBackend'; const titleResponseSchema = z.object({ title: z.string().optional() }); @@ -58,6 +59,7 @@ export function PromptView() { const [type, setType] = useState<'parametric' | 'creative'>('parametric'); const [model, setModel] = useState('google/gemini-3.1-pro-preview'); + const [cadBackend, setCadBackend] = useState(getCadBackendPreference); const handleTypeChange = (newType: 'parametric' | 'creative') => { setType(newType); @@ -93,7 +95,7 @@ export function PromptView() { id: newConversationId, user_id: user?.id ?? '', type: type, - settings: { model: model }, + settings: { model: model, cadBackend }, current_message_leaf_id: null, }, }); @@ -107,6 +109,16 @@ export function PromptView() { return () => cancelAnimationFrame(frame); }, []); + useEffect(() => { + const updateCadBackend = () => setCadBackend(getCadBackendPreference()); + window.addEventListener('storage', updateCadBackend); + window.addEventListener('adam:cad-backend-changed', updateCadBackend); + return () => { + window.removeEventListener('storage', updateCadBackend); + window.removeEventListener('adam:cad-backend-changed', updateCadBackend); + }; + }, []); + // Helper function to get time-based greeting (memoized for performance) const getTimeBasedGreeting = useMemo(() => { const hour = new Date().getHours(); @@ -121,9 +133,12 @@ export function PromptView() { const { mutate: handleGenerate } = useMutation({ mutationFn: async (content: Content) => { + const contentWithBackend: Content = + type === 'parametric' ? { ...content, cadBackend } : content; posthog.capture('new_conversation', { type: type, model_name: model, + cad_backend: type === 'parametric' ? cadBackend : undefined, text: (content.text ?? '').trim().slice(0, 100), image_count: content.images?.length ?? 0, mesh_count: content.mesh ? 1 : 0, @@ -142,6 +157,7 @@ export function PromptView() { type: type, settings: { model: model, + ...(type === 'parametric' ? { cadBackend } : {}), }, }, ]) @@ -150,11 +166,11 @@ export function PromptView() { if (conversationError) throw conversationError; - sendMessage(content); + sendMessage(contentWithBackend); return { conversationId: conversation.id, - content: content, + content: contentWithBackend, }; }, onSuccess: (data) => { diff --git a/src/views/SettingsView.tsx b/src/views/SettingsView.tsx index 67ce98c6..564ac4f7 100644 --- a/src/views/SettingsView.tsx +++ b/src/views/SettingsView.tsx @@ -18,6 +18,12 @@ import { useProfile, useUpdateProfile } from '@/services/profileService'; import { AvatarUpdateDialog } from '@/components/auth/AvatarUpdateDialog'; import { useTokenPacks } from '@/hooks/useTokenPacks'; import { PLAN_DISPLAY_NAMES } from '@/config/plan-features'; +import { + getCadBackendPreference, + setCadBackendPreference, +} from '@/utils/cadBackend'; +import { CadBackendToggle } from '@/components/CadBackendToggle'; +import { CadBackend } from '@shared/types'; function formatPeriodEnd(iso: string | null | undefined): string | null { if (!iso) return null; @@ -46,6 +52,7 @@ export default function SettingsView() { const { toast } = useToast(); const [newName, setNewName] = useState(profile?.full_name || ''); const [editingName, setEditingName] = useState(false); + const [cadBackend, setCadBackend] = useState(getCadBackendPreference); const nameInputRef = useRef(null); const { data: tokenPacks = [] } = useTokenPacks(); const { @@ -64,6 +71,16 @@ export default function SettingsView() { setNewName(profile?.full_name || ''); }, [profile?.full_name]); + useEffect(() => { + const updateCadBackend = () => setCadBackend(getCadBackendPreference()); + window.addEventListener('storage', updateCadBackend); + window.addEventListener('adam:cad-backend-changed', updateCadBackend); + return () => { + window.removeEventListener('storage', updateCadBackend); + window.removeEventListener('adam:cad-backend-changed', updateCadBackend); + }; + }, []); + const { mutate: handleManageSubscription, isPending: isManageLoading } = useManageSubscription(); @@ -115,6 +132,17 @@ export default function SettingsView() { ); }; + const handleCadBackendChange = (nextBackend: CadBackend) => { + setCadBackend(nextBackend); + setCadBackendPreference(nextBackend); + toast({ + title: 'Success', + description: `CAD generation set to ${ + nextBackend === 'build123d' ? 'build123d' : 'OpenSCAD' + }`, + }); + }; + const { mutate: handleResetPassword, isPending: isResetLoading } = useMutation({ mutationFn: async () => { @@ -275,6 +303,26 @@ export default function SettingsView() {
+ {/* CAD generation */} +
+

+ CAD generation +

+ +
+
+
Backend
+
+ Choose OpenSCAD or build123d for new parametric generations. +
+
+ +
+
+ {/* Billing */}