Spaces:
Running
Running
noahlee1234 commited on
Commit ·
c67d8f3
1
Parent(s): 16058c2
NaturalCAD: add hosted backend flow and Space fixes
Browse files- Dockerfile +1 -1
- README.md +48 -2
- apps/backend-api/.dockerignore +5 -0
- apps/backend-api/Dockerfile +17 -0
- apps/backend-api/README.md +30 -1
- apps/backend-api/app/config.py +2 -0
- apps/backend-api/app/main.py +237 -65
- apps/backend-api/app/models.py +86 -3
- apps/backend-api/app/repository.py +72 -1
- apps/backend-api/app/store.py +1 -0
- apps/backend-api/fly.toml +22 -0
- apps/backend-api/requirements.txt +1 -0
- apps/gradio-demo/README.md +28 -4
- apps/gradio-demo/app/main.py +154 -7
- docs/backend-v0.md +22 -0
- docs/compositional-spec-v1.1.md +137 -0
- docs/hf-space-deploy-checklist.md +15 -0
- package.json +4 -0
- scripts/run-local-backend.sh +15 -0
- scripts/run-local-frontend.sh +29 -0
Dockerfile
CHANGED
|
@@ -16,7 +16,7 @@ ENV HOME=/home/user \
|
|
| 16 |
WORKDIR $HOME/app
|
| 17 |
|
| 18 |
RUN conda create -n cad python=3.10 -y && \
|
| 19 |
-
conda install -n cad -c conda-forge ocp=7.8.1 -y && \
|
| 20 |
conda clean -a -y
|
| 21 |
|
| 22 |
ENV PATH=$CONDA_DIR/envs/cad/bin:$PATH
|
|
|
|
| 16 |
WORKDIR $HOME/app
|
| 17 |
|
| 18 |
RUN conda create -n cad python=3.10 -y && \
|
| 19 |
+
conda install -n cad -c conda-forge ocp=7.8.1 vtk=9.3 -y && \
|
| 20 |
conda clean -a -y
|
| 21 |
|
| 22 |
ENV PATH=$CONDA_DIR/envs/cad/bin:$PATH
|
README.md
CHANGED
|
@@ -16,6 +16,11 @@ pinned: false
|
|
| 16 |
|
| 17 |
**NaturalCAD** is a public prompt-to-CAD demo built around build123d.
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
Turn natural-language prompts into quick CAD studies, test the interaction with real users, and learn what deserves to become a bigger product.
|
| 20 |
|
| 21 |
<p align="center">
|
|
@@ -37,6 +42,26 @@ Turn natural-language prompts into quick CAD studies, test the interaction with
|
|
| 37 |
|
| 38 |
## Local run
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
```bash
|
| 41 |
pip install -r requirements.txt
|
| 42 |
python app.py
|
|
@@ -44,8 +69,29 @@ python app.py
|
|
| 44 |
|
| 45 |
## Deployment posture
|
| 46 |
|
| 47 |
-
Right now the priority is a lean Hugging Face Space MVP.
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
## Key docs
|
| 51 |
|
|
|
|
| 16 |
|
| 17 |
**NaturalCAD** is a public prompt-to-CAD demo built around build123d.
|
| 18 |
|
| 19 |
+
Current local preview posture:
|
| 20 |
+
- browser preview uses GLB when available
|
| 21 |
+
- STEP remains the main CAD handoff artifact
|
| 22 |
+
- STL remains available as a mesh export
|
| 23 |
+
|
| 24 |
Turn natural-language prompts into quick CAD studies, test the interaction with real users, and learn what deserves to become a bigger product.
|
| 25 |
|
| 26 |
<p align="center">
|
|
|
|
| 42 |
|
| 43 |
## Local run
|
| 44 |
|
| 45 |
+
Simplest path:
|
| 46 |
+
|
| 47 |
+
```bash
|
| 48 |
+
npm run backend:local
|
| 49 |
+
npm run frontend:local
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
That uses the repo helper scripts:
|
| 53 |
+
- `scripts/run-local-backend.sh`
|
| 54 |
+
- `scripts/run-local-frontend.sh`
|
| 55 |
+
|
| 56 |
+
Notes:
|
| 57 |
+
- frontend local dev needs Python 3.10-3.13 because `build123d` does not currently publish wheels for Python 3.14+
|
| 58 |
+
- by default the frontend helper uses `~/.openclaw/workspace/.venvs/cadrender312`
|
| 59 |
+
- the frontend helper defaults `NATURALCAD_BACKEND_URL` to `http://127.0.0.1:8010`
|
| 60 |
+
- if `apps/backend-api/.env` exists, the frontend helper also reuses `API_SHARED_SECRET` as `NATURALCAD_API_KEY`
|
| 61 |
+
- if you want a different frontend venv, set `NATURALCAD_FRONTEND_VENV=/path/to/venv`
|
| 62 |
+
|
| 63 |
+
Manual fallback:
|
| 64 |
+
|
| 65 |
```bash
|
| 66 |
pip install -r requirements.txt
|
| 67 |
python app.py
|
|
|
|
| 69 |
|
| 70 |
## Deployment posture
|
| 71 |
|
| 72 |
+
Right now the priority is a lean Hugging Face Space MVP with a separate hosted backend.
|
| 73 |
+
|
| 74 |
+
Current recommended shape:
|
| 75 |
+
- Hugging Face Space = public UI + local preview/runtime
|
| 76 |
+
- Fly.io backend = API, auth, rate limiting, job/spec logging, Supabase writes
|
| 77 |
+
- Supabase = Postgres + artifact storage
|
| 78 |
+
- managed inference endpoint later = swappable model layer behind the backend
|
| 79 |
+
|
| 80 |
+
If the CAD dependency stack or runtime limits become painful, the frontend can stay on Hugging Face while execution moves further toward a worker/container architecture later.
|
| 81 |
+
|
| 82 |
+
### Hosted env wiring
|
| 83 |
+
|
| 84 |
+
Hugging Face Space:
|
| 85 |
+
- variable: `NATURALCAD_BACKEND_URL`
|
| 86 |
+
- secret: `NATURALCAD_API_KEY`
|
| 87 |
+
|
| 88 |
+
Backend:
|
| 89 |
+
- `DATABASE_URL`
|
| 90 |
+
- `SUPABASE_URL`
|
| 91 |
+
- `SUPABASE_ANON_KEY`
|
| 92 |
+
- `SUPABASE_SERVICE_ROLE_KEY`
|
| 93 |
+
- `SUPABASE_BUCKET`
|
| 94 |
+
- `API_SHARED_SECRET`
|
| 95 |
|
| 96 |
## Key docs
|
| 97 |
|
apps/backend-api/.dockerignore
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fly.toml
|
| 2 |
+
.git/
|
| 3 |
+
__pycache__/
|
| 4 |
+
.envrc
|
| 5 |
+
.venv/
|
apps/backend-api/Dockerfile
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.14.0 AS builder
|
| 2 |
+
|
| 3 |
+
ENV PYTHONUNBUFFERED=1 \
|
| 4 |
+
PYTHONDONTWRITEBYTECODE=1
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
RUN python -m venv .venv
|
| 8 |
+
COPY requirements.txt ./
|
| 9 |
+
RUN .venv/bin/pip install -r requirements.txt
|
| 10 |
+
|
| 11 |
+
FROM python:3.14.0-slim
|
| 12 |
+
ENV PYTHONUNBUFFERED=1 \
|
| 13 |
+
PYTHONDONTWRITEBYTECODE=1
|
| 14 |
+
WORKDIR /app
|
| 15 |
+
COPY --from=builder /app/.venv .venv/
|
| 16 |
+
COPY . .
|
| 17 |
+
CMD ["/app/.venv/bin/uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
apps/backend-api/README.md
CHANGED
|
@@ -23,12 +23,17 @@ python3 -m venv .venv
|
|
| 23 |
- `POST /v1/jobs`
|
| 24 |
- `GET /v1/jobs/{job_id}`
|
| 25 |
- `POST /v1/generate-spec`
|
|
|
|
| 26 |
|
| 27 |
## Current integration state
|
| 28 |
- `apps/gradio-demo` now creates backend jobs through `POST /v1/jobs`
|
| 29 |
-
- the backend currently returns a validated in-memory spec
|
| 30 |
- the Gradio app still performs local build123d execution for now
|
| 31 |
- next step is moving execution from the Gradio app into a real worker
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
## Notes
|
| 34 |
This is the v0 scaffold. It currently uses in-memory storage by default, but now includes a Postgres schema and a repository layer that can switch to `DATABASE_URL` when Supabase is ready.
|
|
@@ -37,3 +42,27 @@ This is the v0 scaffold. It currently uses in-memory storage by default, but now
|
|
| 37 |
- schema file: `db/schema.sql`
|
| 38 |
- env placeholders added for `DATABASE_URL` and Supabase keys
|
| 39 |
- repository layer falls back to memory until the database is configured
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
- `POST /v1/jobs`
|
| 24 |
- `GET /v1/jobs/{job_id}`
|
| 25 |
- `POST /v1/generate-spec`
|
| 26 |
+
- `POST /v1/jobs/{job_id}/artifacts` (multipart upload: `kind` + `file`)
|
| 27 |
|
| 28 |
## Current integration state
|
| 29 |
- `apps/gradio-demo` now creates backend jobs through `POST /v1/jobs`
|
| 30 |
+
- the backend currently returns a validated compositional in-memory spec (v1.1 semantic shape)
|
| 31 |
- the Gradio app still performs local build123d execution for now
|
| 32 |
- next step is moving execution from the Gradio app into a real worker
|
| 33 |
+
- backend models now include an early **compositional spec v1.1** shape for the next generation stage, so model output is not forced into a rigid family-first schema too early
|
| 34 |
+
|
| 35 |
+
See also:
|
| 36 |
+
- `docs/compositional-spec-v1.1.md`
|
| 37 |
|
| 38 |
## Notes
|
| 39 |
This is the v0 scaffold. It currently uses in-memory storage by default, but now includes a Postgres schema and a repository layer that can switch to `DATABASE_URL` when Supabase is ready.
|
|
|
|
| 42 |
- schema file: `db/schema.sql`
|
| 43 |
- env placeholders added for `DATABASE_URL` and Supabase keys
|
| 44 |
- repository layer falls back to memory until the database is configured
|
| 45 |
+
- artifact uploads now write to Supabase Storage when these env vars are set:
|
| 46 |
+
- `SUPABASE_URL`
|
| 47 |
+
- `SUPABASE_SERVICE_ROLE_KEY`
|
| 48 |
+
- `SUPABASE_BUCKET`
|
| 49 |
+
- optional: `STORAGE_MAX_UPLOAD_BYTES`
|
| 50 |
+
|
| 51 |
+
## Fly.io deployment notes
|
| 52 |
+
- backend app can be deployed directly from `apps/backend-api`
|
| 53 |
+
- internal service port should be `8000`
|
| 54 |
+
- backend Docker image should start with `uvicorn`, not `fastapi run`
|
| 55 |
+
- recommended startup command is already baked into `apps/backend-api/Dockerfile`
|
| 56 |
+
- Hugging Face Space should call this backend via:
|
| 57 |
+
- variable: `NATURALCAD_BACKEND_URL`
|
| 58 |
+
- secret: `NATURALCAD_API_KEY`
|
| 59 |
+
- backend should keep the matching value in `API_SHARED_SECRET`
|
| 60 |
+
|
| 61 |
+
### Suggested setup order
|
| 62 |
+
1. Create Supabase project
|
| 63 |
+
2. Run `db/schema.sql` in SQL editor
|
| 64 |
+
3. Create Storage bucket (default: `naturalCAD-artifacts` if matching the current deployed config)
|
| 65 |
+
4. Add backend env vars and restart API
|
| 66 |
+
5. Deploy backend host (Fly.io is the current path)
|
| 67 |
+
6. Wire Hugging Face Space env vars and rebuild
|
| 68 |
+
7. Confirm Gradio app calls `/v1/jobs/{job_id}/artifacts` after STL/STEP export
|
apps/backend-api/app/config.py
CHANGED
|
@@ -19,6 +19,8 @@ class Settings:
|
|
| 19 |
supabase_url: str = os.getenv("SUPABASE_URL", "")
|
| 20 |
supabase_service_role_key: str = os.getenv("SUPABASE_SERVICE_ROLE_KEY", "")
|
| 21 |
supabase_anon_key: str = os.getenv("SUPABASE_ANON_KEY", "")
|
|
|
|
|
|
|
| 22 |
|
| 23 |
|
| 24 |
settings = Settings()
|
|
|
|
| 19 |
supabase_url: str = os.getenv("SUPABASE_URL", "")
|
| 20 |
supabase_service_role_key: str = os.getenv("SUPABASE_SERVICE_ROLE_KEY", "")
|
| 21 |
supabase_anon_key: str = os.getenv("SUPABASE_ANON_KEY", "")
|
| 22 |
+
supabase_bucket: str = os.getenv("SUPABASE_BUCKET", "naturalcad-artifacts")
|
| 23 |
+
storage_max_upload_bytes: int = int(os.getenv("STORAGE_MAX_UPLOAD_BYTES", "26214400"))
|
| 24 |
|
| 25 |
|
| 26 |
settings = Settings()
|
apps/backend-api/app/main.py
CHANGED
|
@@ -1,30 +1,92 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import hashlib
|
|
|
|
|
|
|
| 4 |
import re
|
| 5 |
import time
|
| 6 |
from typing import cast
|
| 7 |
|
| 8 |
-
|
|
|
|
| 9 |
|
| 10 |
from .config import settings
|
| 11 |
from .models import (
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
| 14 |
CreateJobRequest,
|
|
|
|
|
|
|
| 15 |
GenerateSpecRequest,
|
| 16 |
GenerateSpecResponse,
|
|
|
|
|
|
|
| 17 |
HealthResponse,
|
| 18 |
JobRecord,
|
| 19 |
ModeType,
|
| 20 |
OutputType,
|
|
|
|
|
|
|
|
|
|
| 21 |
)
|
|
|
|
| 22 |
from .repository import get_job as repo_get_job, save_job
|
|
|
|
| 23 |
from .store import _CACHE, _JOBS, _REQUESTS
|
| 24 |
|
| 25 |
app = FastAPI(title=settings.app_name, version="0.4.0")
|
| 26 |
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
def _check_auth(header_value: str | None) -> None:
|
| 29 |
if settings.api_shared_secret and header_value != settings.api_shared_secret:
|
| 30 |
raise HTTPException(status_code=401, detail="Invalid shared secret")
|
|
@@ -114,114 +176,191 @@ def _extract_count(prompt: str, nouns: list[str], default: int) -> int:
|
|
| 114 |
return default
|
| 115 |
|
| 116 |
|
| 117 |
-
def
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
elif any(word in prompt for word in ["smooth", "soft", "shell", "canopy"]):
|
| 130 |
-
family = "smooth"
|
| 131 |
-
elif any(word in prompt for word in ["diagram", "profile", "elevation", "line"]):
|
| 132 |
-
family = "diagrammatic"
|
| 133 |
|
| 134 |
-
return CadStyle(family=family, heaviness=heaviness)
|
| 135 |
|
| 136 |
-
|
| 137 |
-
def _infer_spec(prompt: str, mode: ModeType, output_type: OutputType) -> CadSpec:
|
| 138 |
p = prompt.lower()
|
| 139 |
|
| 140 |
if output_type == "2d_vector" or mode == "sketch":
|
| 141 |
family = "truss_elevation" if any(word in p for word in ["truss", "beam", "frame", "elevation"]) else "plate_profile"
|
| 142 |
if family == "truss_elevation":
|
| 143 |
-
|
| 144 |
"span": _extract_number(p, ["span", "length", "width"], 140),
|
| 145 |
"height": _extract_number(p, ["height", "rise"], 24),
|
| 146 |
"panel_count": _extract_count(p, ["panels", "bays", "segments"], 7),
|
| 147 |
"member_size": _extract_number(p, ["member", "thickness", "depth"], 3),
|
| 148 |
"preview_thickness": 1,
|
| 149 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
else:
|
| 151 |
-
|
| 152 |
"width": _extract_number(p, ["width", "span"], 80),
|
| 153 |
"height": _extract_number(p, ["height"], 50),
|
| 154 |
"hole_count": _extract_count(p, ["holes", "bolt holes", "openings"], 4),
|
| 155 |
"hole_diameter": _extract_number(p, ["hole diameter", "hole", "diameter"], 10),
|
| 156 |
"preview_thickness": 1,
|
| 157 |
}
|
| 158 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
output_type="2d_vector",
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
)
|
| 164 |
|
| 165 |
if output_type == "surface":
|
| 166 |
family = "canopy_surface" if any(word in p for word in ["roof", "canopy", "shell", "surface"]) else "lofted_panel"
|
| 167 |
if family == "canopy_surface":
|
| 168 |
-
|
| 169 |
"span": _extract_number(p, ["span", "width"], 160),
|
| 170 |
"depth": _extract_number(p, ["depth", "length"], 90),
|
| 171 |
"peak_height": _extract_number(p, ["peak", "height", "rise"], 38),
|
| 172 |
"thickness": _extract_number(p, ["thickness"], 2),
|
| 173 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
else:
|
| 175 |
-
|
| 176 |
"width": _extract_number(p, ["width", "span"], 80),
|
| 177 |
"depth": _extract_number(p, ["depth", "length"], 50),
|
| 178 |
"rise": _extract_number(p, ["rise", "height"], 18),
|
| 179 |
"thickness": _extract_number(p, ["thickness"], 2),
|
| 180 |
}
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
output_type="surface",
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
)
|
| 187 |
|
| 188 |
if any(word in p for word in ["truss", "beam", "frame", "girder"]):
|
| 189 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
output_type="3d_solid",
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
"
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
)
|
| 200 |
|
| 201 |
if any(word in p for word in ["tower", "block", "monolith"]):
|
| 202 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
output_type="3d_solid",
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
)
|
| 213 |
|
| 214 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
output_type="3d_solid",
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
"
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
)
|
| 226 |
|
| 227 |
|
|
@@ -257,12 +396,13 @@ def _generate_spec(payload: GenerateSpecRequest) -> GenerateSpecResponse:
|
|
| 257 |
"Using conservative defaults and a simple geometry family.",
|
| 258 |
])
|
| 259 |
|
| 260 |
-
spec =
|
| 261 |
response = GenerateSpecResponse(
|
| 262 |
prompt_hash=key,
|
| 263 |
spec=spec,
|
| 264 |
notes=notes + [
|
| 265 |
-
"Prompt mapped into a structured CAD spec.",
|
|
|
|
| 266 |
"Replace the stub router with a real HF endpoint later.",
|
| 267 |
],
|
| 268 |
suspicious_input=suspicious_input,
|
|
@@ -314,6 +454,7 @@ def create_job(payload: CreateJobRequest, request: Request, x_api_key: str | Non
|
|
| 314 |
)
|
| 315 |
job.status = cast(str, "queued")
|
| 316 |
save_job(job)
|
|
|
|
| 317 |
return job
|
| 318 |
|
| 319 |
|
|
@@ -323,7 +464,38 @@ def get_job(job_id: str, x_api_key: str | None = Header(default=None)) -> JobRec
|
|
| 323 |
job = repo_get_job(job_id)
|
| 324 |
if not job:
|
| 325 |
raise HTTPException(status_code=404, detail="Job not found")
|
| 326 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 327 |
|
| 328 |
|
| 329 |
@app.get("/")
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import hashlib
|
| 4 |
+
import os
|
| 5 |
+
import posixpath
|
| 6 |
import re
|
| 7 |
import time
|
| 8 |
from typing import cast
|
| 9 |
|
| 10 |
+
import httpx
|
| 11 |
+
from fastapi import FastAPI, File, Form, Header, HTTPException, Request, UploadFile
|
| 12 |
|
| 13 |
from .config import settings
|
| 14 |
from .models import (
|
| 15 |
+
ArtifactKind,
|
| 16 |
+
ArtifactRecord,
|
| 17 |
+
ArtifactUploadResponse,
|
| 18 |
+
ConstraintRecord,
|
| 19 |
CreateJobRequest,
|
| 20 |
+
DedupeHint,
|
| 21 |
+
FamilyHint,
|
| 22 |
GenerateSpecRequest,
|
| 23 |
GenerateSpecResponse,
|
| 24 |
+
GeometryFeature,
|
| 25 |
+
GeometryPlan,
|
| 26 |
HealthResponse,
|
| 27 |
JobRecord,
|
| 28 |
ModeType,
|
| 29 |
OutputType,
|
| 30 |
+
SemanticCadSpec,
|
| 31 |
+
SemanticPart,
|
| 32 |
+
SemanticStyle,
|
| 33 |
)
|
| 34 |
+
from .repository import create_artifact as repo_create_artifact
|
| 35 |
from .repository import get_job as repo_get_job, save_job
|
| 36 |
+
from .repository import list_artifacts as repo_list_artifacts
|
| 37 |
from .store import _CACHE, _JOBS, _REQUESTS
|
| 38 |
|
| 39 |
app = FastAPI(title=settings.app_name, version="0.4.0")
|
| 40 |
|
| 41 |
|
| 42 |
+
def _storage_ready() -> bool:
|
| 43 |
+
return bool(settings.supabase_url and settings.supabase_service_role_key and settings.supabase_bucket)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _build_public_artifact_url(storage_key: str) -> str:
|
| 47 |
+
return f"{settings.supabase_url}/storage/v1/object/public/{settings.supabase_bucket}/{storage_key}"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _upload_bytes_to_supabase_storage(storage_key: str, blob: bytes, content_type: str) -> None:
|
| 51 |
+
if not _storage_ready():
|
| 52 |
+
raise HTTPException(status_code=503, detail="Artifact storage not configured")
|
| 53 |
+
|
| 54 |
+
url = f"{settings.supabase_url}/storage/v1/object/{settings.supabase_bucket}/{storage_key}"
|
| 55 |
+
headers = {
|
| 56 |
+
"apikey": settings.supabase_service_role_key,
|
| 57 |
+
"Authorization": f"Bearer {settings.supabase_service_role_key}",
|
| 58 |
+
"x-upsert": "true",
|
| 59 |
+
"content-type": content_type,
|
| 60 |
+
}
|
| 61 |
+
with httpx.Client(timeout=30.0) as client:
|
| 62 |
+
resp = client.post(url, content=blob, headers=headers)
|
| 63 |
+
if resp.status_code >= 300:
|
| 64 |
+
raise HTTPException(status_code=502, detail=f"Supabase upload failed: {resp.text[:300]}")
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _artifact_url_for(storage_key: str) -> str | None:
|
| 68 |
+
if not settings.supabase_url:
|
| 69 |
+
return None
|
| 70 |
+
return _build_public_artifact_url(storage_key)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _artifact_to_record(row: dict) -> ArtifactRecord:
|
| 74 |
+
return ArtifactRecord(
|
| 75 |
+
id=str(row.get("id")),
|
| 76 |
+
job_id=str(row.get("job_id")),
|
| 77 |
+
kind=cast(ArtifactKind, row.get("kind", "other")),
|
| 78 |
+
storage_key=str(row.get("storage_key")),
|
| 79 |
+
size_bytes=row.get("size_bytes"),
|
| 80 |
+
url=_artifact_url_for(str(row.get("storage_key"))),
|
| 81 |
+
created_at=row.get("created_at"),
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _job_with_artifacts(job: dict) -> JobRecord:
|
| 86 |
+
artifacts = [_artifact_to_record(a) for a in repo_list_artifacts(str(job["id"]))]
|
| 87 |
+
return JobRecord(**job, artifacts=artifacts)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
def _check_auth(header_value: str | None) -> None:
|
| 91 |
if settings.api_shared_secret and header_value != settings.api_shared_secret:
|
| 92 |
raise HTTPException(status_code=401, detail="Invalid shared secret")
|
|
|
|
| 176 |
return default
|
| 177 |
|
| 178 |
|
| 179 |
+
def _style_keywords_from_prompt(prompt: str, default_keyword: str) -> list[str]:
|
| 180 |
+
keywords = [default_keyword]
|
| 181 |
+
for word in ["industrial", "structural", "smooth", "diagrammatic", "heavy-duty", "lightweight", "architectural"]:
|
| 182 |
+
if word in prompt and word not in keywords:
|
| 183 |
+
keywords.append(word)
|
| 184 |
+
if any(word in prompt for word in ["steel", "metal", "brutal"]):
|
| 185 |
+
keywords.append("industrial")
|
| 186 |
+
if any(word in prompt for word in ["truss", "frame", "girder"]):
|
| 187 |
+
keywords.append("structural")
|
| 188 |
+
if any(word in prompt for word in ["roof", "canopy", "shell"]):
|
| 189 |
+
keywords.append("smooth")
|
| 190 |
+
return list(dict.fromkeys(keywords))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
|
|
|
|
| 192 |
|
| 193 |
+
def _infer_semantic_spec(prompt: str, mode: ModeType, output_type: OutputType) -> SemanticCadSpec:
|
|
|
|
| 194 |
p = prompt.lower()
|
| 195 |
|
| 196 |
if output_type == "2d_vector" or mode == "sketch":
|
| 197 |
family = "truss_elevation" if any(word in p for word in ["truss", "beam", "frame", "elevation"]) else "plate_profile"
|
| 198 |
if family == "truss_elevation":
|
| 199 |
+
dimensions = {
|
| 200 |
"span": _extract_number(p, ["span", "length", "width"], 140),
|
| 201 |
"height": _extract_number(p, ["height", "rise"], 24),
|
| 202 |
"panel_count": _extract_count(p, ["panels", "bays", "segments"], 7),
|
| 203 |
"member_size": _extract_number(p, ["member", "thickness", "depth"], 3),
|
| 204 |
"preview_thickness": 1,
|
| 205 |
}
|
| 206 |
+
geometry = GeometryPlan(
|
| 207 |
+
primitive_strategy=["sketch", "extrude"],
|
| 208 |
+
features=[
|
| 209 |
+
GeometryFeature(name="chords", feature_type="parallel_members", count=2),
|
| 210 |
+
GeometryFeature(name="posts", feature_type="vertical_members", count=int(dimensions["panel_count"]) + 1),
|
| 211 |
+
],
|
| 212 |
+
)
|
| 213 |
+
semantic_part = SemanticPart(category="diagram", function="structural elevation", topology=["top chord", "bottom chord", "posts"], symmetry="bilateral")
|
| 214 |
else:
|
| 215 |
+
dimensions = {
|
| 216 |
"width": _extract_number(p, ["width", "span"], 80),
|
| 217 |
"height": _extract_number(p, ["height"], 50),
|
| 218 |
"hole_count": _extract_count(p, ["holes", "bolt holes", "openings"], 4),
|
| 219 |
"hole_diameter": _extract_number(p, ["hole diameter", "hole", "diameter"], 10),
|
| 220 |
"preview_thickness": 1,
|
| 221 |
}
|
| 222 |
+
geometry = GeometryPlan(
|
| 223 |
+
primitive_strategy=["sketch", "extrude"],
|
| 224 |
+
features=[
|
| 225 |
+
GeometryFeature(name="base_profile", feature_type="rectangle"),
|
| 226 |
+
GeometryFeature(name="holes", feature_type="circular_cutouts", count=int(dimensions["hole_count"])),
|
| 227 |
+
],
|
| 228 |
+
)
|
| 229 |
+
semantic_part = SemanticPart(category="profile", function="cut pattern", topology=["base plate", "openings"], symmetry="bilateral")
|
| 230 |
+
return SemanticCadSpec(
|
| 231 |
+
intent=prompt.strip(),
|
| 232 |
+
mode=mode,
|
| 233 |
output_type="2d_vector",
|
| 234 |
+
semantic_part=semantic_part,
|
| 235 |
+
family_hint=FamilyHint(name=family, generation_mode="reuse", confidence=0.75, novelty_score=0.25),
|
| 236 |
+
geometry=geometry,
|
| 237 |
+
dimensions=dimensions,
|
| 238 |
+
constraints=[],
|
| 239 |
+
style=SemanticStyle(keywords=_style_keywords_from_prompt(p, "diagrammatic"), symmetry=semantic_part.symmetry, manufacturing_bias="sheet_metal"),
|
| 240 |
+
dedupe=DedupeHint(canonical_signature=f"{family}|{sorted(dimensions.items())}"),
|
| 241 |
+
notes=["Concept-grade sketch/profile interpretation."],
|
| 242 |
)
|
| 243 |
|
| 244 |
if output_type == "surface":
|
| 245 |
family = "canopy_surface" if any(word in p for word in ["roof", "canopy", "shell", "surface"]) else "lofted_panel"
|
| 246 |
if family == "canopy_surface":
|
| 247 |
+
dimensions = {
|
| 248 |
"span": _extract_number(p, ["span", "width"], 160),
|
| 249 |
"depth": _extract_number(p, ["depth", "length"], 90),
|
| 250 |
"peak_height": _extract_number(p, ["peak", "height", "rise"], 38),
|
| 251 |
"thickness": _extract_number(p, ["thickness"], 2),
|
| 252 |
}
|
| 253 |
+
features = [
|
| 254 |
+
GeometryFeature(name="base_section", feature_type="rectangle_profile"),
|
| 255 |
+
GeometryFeature(name="top_section", feature_type="scaled_rectangle_profile"),
|
| 256 |
+
]
|
| 257 |
+
topology = ["base perimeter", "raised perimeter", "lofted shell"]
|
| 258 |
else:
|
| 259 |
+
dimensions = {
|
| 260 |
"width": _extract_number(p, ["width", "span"], 80),
|
| 261 |
"depth": _extract_number(p, ["depth", "length"], 50),
|
| 262 |
"rise": _extract_number(p, ["rise", "height"], 18),
|
| 263 |
"thickness": _extract_number(p, ["thickness"], 2),
|
| 264 |
}
|
| 265 |
+
features = [
|
| 266 |
+
GeometryFeature(name="lower_frame", feature_type="rectangle_profile"),
|
| 267 |
+
GeometryFeature(name="upper_frame", feature_type="scaled_rectangle_profile"),
|
| 268 |
+
]
|
| 269 |
+
topology = ["lower frame", "upper frame", "lofted skin"]
|
| 270 |
+
return SemanticCadSpec(
|
| 271 |
+
intent=prompt.strip(),
|
| 272 |
+
mode=mode,
|
| 273 |
output_type="surface",
|
| 274 |
+
semantic_part=SemanticPart(category="surface", function="enclosure/canopy", topology=topology, symmetry="bilateral"),
|
| 275 |
+
family_hint=FamilyHint(name=family, generation_mode="extend", confidence=0.7, novelty_score=0.45),
|
| 276 |
+
geometry=GeometryPlan(primitive_strategy=["loft", "offset"], features=features),
|
| 277 |
+
dimensions=dimensions,
|
| 278 |
+
constraints=[ConstraintRecord(kind="min", target="thickness", value=1.0)],
|
| 279 |
+
style=SemanticStyle(keywords=_style_keywords_from_prompt(p, "smooth"), symmetry="bilateral", manufacturing_bias="generic"),
|
| 280 |
+
dedupe=DedupeHint(canonical_signature=f"{family}|{sorted(dimensions.items())}"),
|
| 281 |
+
notes=["Surface interpretation remains concept-grade and may simplify shell behavior."],
|
| 282 |
)
|
| 283 |
|
| 284 |
if any(word in p for word in ["truss", "beam", "frame", "girder"]):
|
| 285 |
+
dimensions = {
|
| 286 |
+
"span": _extract_number(p, ["span", "length"], 140),
|
| 287 |
+
"height": _extract_number(p, ["height", "rise"], 24),
|
| 288 |
+
"panel_count": _extract_count(p, ["panels", "bays", "segments"], 7),
|
| 289 |
+
"member_size": _extract_number(p, ["member", "thickness", "depth"], 3),
|
| 290 |
+
}
|
| 291 |
+
return SemanticCadSpec(
|
| 292 |
+
intent=prompt.strip(),
|
| 293 |
+
mode=mode,
|
| 294 |
output_type="3d_solid",
|
| 295 |
+
semantic_part=SemanticPart(category="structure", function="spanning member", topology=["top chord", "bottom chord", "posts"], symmetry="bilateral"),
|
| 296 |
+
family_hint=FamilyHint(name="truss_beam", generation_mode="reuse", confidence=0.82, novelty_score=0.28),
|
| 297 |
+
geometry=GeometryPlan(
|
| 298 |
+
primitive_strategy=["extrude", "array", "boolean_compound"],
|
| 299 |
+
features=[
|
| 300 |
+
GeometryFeature(name="top_chord", feature_type="beam_member"),
|
| 301 |
+
GeometryFeature(name="bottom_chord", feature_type="beam_member"),
|
| 302 |
+
GeometryFeature(name="posts", feature_type="vertical_members", count=int(dimensions["panel_count"]) + 1),
|
| 303 |
+
],
|
| 304 |
+
),
|
| 305 |
+
dimensions=dimensions,
|
| 306 |
+
constraints=[ConstraintRecord(kind="min", target="panel_count", value=3)],
|
| 307 |
+
style=SemanticStyle(keywords=_style_keywords_from_prompt(p, "structural"), symmetry="bilateral", manufacturing_bias="machined"),
|
| 308 |
+
dedupe=DedupeHint(canonical_signature=f"truss_beam|{sorted(dimensions.items())}"),
|
| 309 |
+
notes=["Maps to the current truss generator for execution."],
|
| 310 |
)
|
| 311 |
|
| 312 |
if any(word in p for word in ["tower", "block", "monolith"]):
|
| 313 |
+
dimensions = {
|
| 314 |
+
"width": _extract_number(p, ["width"], 30),
|
| 315 |
+
"length": _extract_number(p, ["length", "depth"], 30),
|
| 316 |
+
"height": _extract_number(p, ["height"], 120),
|
| 317 |
+
"notch": _extract_number(p, ["notch", "cut"], 10),
|
| 318 |
+
}
|
| 319 |
+
return SemanticCadSpec(
|
| 320 |
+
intent=prompt.strip(),
|
| 321 |
+
mode=mode,
|
| 322 |
output_type="3d_solid",
|
| 323 |
+
semantic_part=SemanticPart(category="mass", function="vertical block study", topology=["primary mass", "subtractive notches"], symmetry="bilateral"),
|
| 324 |
+
family_hint=FamilyHint(name="tower_block", generation_mode="extend", confidence=0.74, novelty_score=0.52),
|
| 325 |
+
geometry=GeometryPlan(
|
| 326 |
+
primitive_strategy=["box", "boolean_subtract"],
|
| 327 |
+
features=[
|
| 328 |
+
GeometryFeature(name="main_mass", feature_type="box"),
|
| 329 |
+
GeometryFeature(name="notches", feature_type="subtractive_blocks", count=2),
|
| 330 |
+
],
|
| 331 |
+
),
|
| 332 |
+
dimensions=dimensions,
|
| 333 |
+
constraints=[],
|
| 334 |
+
style=SemanticStyle(keywords=_style_keywords_from_prompt(p, "industrial"), symmetry="bilateral", manufacturing_bias="generic"),
|
| 335 |
+
dedupe=DedupeHint(canonical_signature=f"tower_block|{sorted(dimensions.items())}"),
|
| 336 |
+
notes=["Keeps tower prompts broad while still routing to the existing massing generator."],
|
| 337 |
)
|
| 338 |
|
| 339 |
+
dimensions = {
|
| 340 |
+
"width": _extract_number(p, ["width", "span"], 80),
|
| 341 |
+
"height": _extract_number(p, ["height"], 50),
|
| 342 |
+
"thickness": _extract_number(p, ["thickness"], 6),
|
| 343 |
+
"hole_count": _extract_count(p, ["holes", "bolt holes", "openings"], 4),
|
| 344 |
+
"hole_diameter": _extract_number(p, ["hole diameter", "hole", "diameter"], 10),
|
| 345 |
+
}
|
| 346 |
+
return SemanticCadSpec(
|
| 347 |
+
intent=prompt.strip(),
|
| 348 |
+
mode=mode,
|
| 349 |
output_type="3d_solid",
|
| 350 |
+
semantic_part=SemanticPart(category="support", function="mounting/support part", topology=["plate body", "openings"], symmetry="bilateral"),
|
| 351 |
+
family_hint=FamilyHint(name="bracket_plate", generation_mode="extend", confidence=0.6, novelty_score=0.58),
|
| 352 |
+
geometry=GeometryPlan(
|
| 353 |
+
primitive_strategy=["extrude", "boolean_subtract"],
|
| 354 |
+
features=[
|
| 355 |
+
GeometryFeature(name="base_plate", feature_type="rectangular_plate"),
|
| 356 |
+
GeometryFeature(name="holes", feature_type="circular_cutouts", count=int(dimensions["hole_count"])),
|
| 357 |
+
],
|
| 358 |
+
),
|
| 359 |
+
dimensions=dimensions,
|
| 360 |
+
constraints=[ConstraintRecord(kind="min", target="thickness", value=2.0)],
|
| 361 |
+
style=SemanticStyle(keywords=_style_keywords_from_prompt(p, "industrial"), symmetry="bilateral", manufacturing_bias="machined"),
|
| 362 |
+
dedupe=DedupeHint(canonical_signature=f"bracket_plate|{sorted(dimensions.items())}"),
|
| 363 |
+
notes=["Default concept-grade support interpretation. Replace with true model output later for broader novelty."],
|
| 364 |
)
|
| 365 |
|
| 366 |
|
|
|
|
| 396 |
"Using conservative defaults and a simple geometry family.",
|
| 397 |
])
|
| 398 |
|
| 399 |
+
spec = _infer_semantic_spec(safe_prompt, payload.mode, payload.output_type)
|
| 400 |
response = GenerateSpecResponse(
|
| 401 |
prompt_hash=key,
|
| 402 |
spec=spec,
|
| 403 |
notes=notes + [
|
| 404 |
+
"Prompt mapped into a structured compositional CAD spec.",
|
| 405 |
+
"This is still a stub translator, not the final model stage.",
|
| 406 |
"Replace the stub router with a real HF endpoint later.",
|
| 407 |
],
|
| 408 |
suspicious_input=suspicious_input,
|
|
|
|
| 454 |
)
|
| 455 |
job.status = cast(str, "queued")
|
| 456 |
save_job(job)
|
| 457 |
+
job.artifacts = []
|
| 458 |
return job
|
| 459 |
|
| 460 |
|
|
|
|
| 464 |
job = repo_get_job(job_id)
|
| 465 |
if not job:
|
| 466 |
raise HTTPException(status_code=404, detail="Job not found")
|
| 467 |
+
return _job_with_artifacts(job)
|
| 468 |
+
|
| 469 |
+
|
| 470 |
+
@app.post("/v1/jobs/{job_id}/artifacts", response_model=ArtifactUploadResponse)
|
| 471 |
+
async def upload_job_artifact(
|
| 472 |
+
job_id: str,
|
| 473 |
+
kind: ArtifactKind = Form(...),
|
| 474 |
+
file: UploadFile = File(...),
|
| 475 |
+
x_api_key: str | None = Header(default=None),
|
| 476 |
+
) -> ArtifactUploadResponse:
|
| 477 |
+
_check_auth(x_api_key)
|
| 478 |
+
|
| 479 |
+
job = repo_get_job(job_id)
|
| 480 |
+
if not job:
|
| 481 |
+
raise HTTPException(status_code=404, detail="Job not found")
|
| 482 |
+
if not _storage_ready():
|
| 483 |
+
raise HTTPException(status_code=503, detail="Supabase storage not configured")
|
| 484 |
+
|
| 485 |
+
blob = await file.read()
|
| 486 |
+
if not blob:
|
| 487 |
+
raise HTTPException(status_code=400, detail="Empty file upload")
|
| 488 |
+
if len(blob) > settings.storage_max_upload_bytes:
|
| 489 |
+
raise HTTPException(status_code=413, detail="File too large for configured upload limit")
|
| 490 |
+
|
| 491 |
+
suffix = os.path.splitext(file.filename or "")[1].lower()
|
| 492 |
+
safe_suffix = suffix if suffix in {".stl", ".step", ".stp", ".png", ".jpg", ".jpeg", ".webp"} else ""
|
| 493 |
+
storage_key = posixpath.join("jobs", job_id, f"{kind}{safe_suffix}")
|
| 494 |
+
content_type = file.content_type or "application/octet-stream"
|
| 495 |
+
|
| 496 |
+
_upload_bytes_to_supabase_storage(storage_key, blob, content_type)
|
| 497 |
+
saved = repo_create_artifact(job_id, kind, storage_key, len(blob))
|
| 498 |
+
return ArtifactUploadResponse(artifact=_artifact_to_record(saved))
|
| 499 |
|
| 500 |
|
| 501 |
@app.get("/")
|
apps/backend-api/app/models.py
CHANGED
|
@@ -8,6 +8,10 @@ from pydantic import BaseModel, Field
|
|
| 8 |
ModeType = Literal["part", "assembly", "sketch"]
|
| 9 |
OutputType = Literal["2d_vector", "surface", "3d_solid"]
|
| 10 |
JobStatus = Literal["submitted", "validated", "queued", "running", "completed", "failed"]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
|
| 13 |
class GenerateSpecRequest(BaseModel):
|
|
@@ -37,17 +41,90 @@ class CadSpec(BaseModel):
|
|
| 37 |
style: CadStyle
|
| 38 |
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
class GenerateSpecResponse(BaseModel):
|
| 41 |
ok: bool = True
|
| 42 |
cached: bool = False
|
| 43 |
prompt_hash: str
|
| 44 |
-
spec: CadSpec
|
| 45 |
notes: list[str] = []
|
| 46 |
model: str = "stub/template-router"
|
| 47 |
suspicious_input: bool = False
|
| 48 |
fallback_level: str = "normal"
|
| 49 |
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
class JobRecord(BaseModel):
|
| 52 |
id: str = Field(default_factory=lambda: str(uuid4()))
|
| 53 |
status: JobStatus = "submitted"
|
|
@@ -56,9 +133,15 @@ class JobRecord(BaseModel):
|
|
| 56 |
output_type: OutputType = "3d_solid"
|
| 57 |
session_id: str | None = None
|
| 58 |
prompt_hash: str | None = None
|
| 59 |
-
spec: CadSpec | None = None
|
| 60 |
-
notes: list[str] =
|
| 61 |
error: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
|
| 64 |
class HealthResponse(BaseModel):
|
|
|
|
| 8 |
ModeType = Literal["part", "assembly", "sketch"]
|
| 9 |
OutputType = Literal["2d_vector", "surface", "3d_solid"]
|
| 10 |
JobStatus = Literal["submitted", "validated", "queued", "running", "completed", "failed"]
|
| 11 |
+
ArtifactKind = Literal["stl", "step", "preview", "other"]
|
| 12 |
+
GenerationMode = Literal["reuse", "extend", "new"]
|
| 13 |
+
SymmetryType = Literal["bilateral", "radial", "asymmetric", "none"]
|
| 14 |
+
ValueType = int | float | str | bool
|
| 15 |
|
| 16 |
|
| 17 |
class GenerateSpecRequest(BaseModel):
|
|
|
|
| 41 |
style: CadStyle
|
| 42 |
|
| 43 |
|
| 44 |
+
class SemanticPart(BaseModel):
|
| 45 |
+
category: str | None = None
|
| 46 |
+
function: str | None = None
|
| 47 |
+
topology: list[str] = Field(default_factory=list)
|
| 48 |
+
symmetry: SymmetryType = "none"
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class GeometryFeature(BaseModel):
|
| 52 |
+
name: str
|
| 53 |
+
feature_type: str
|
| 54 |
+
count: int | None = None
|
| 55 |
+
attributes: dict[str, ValueType] = Field(default_factory=dict)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class GeometryPlan(BaseModel):
|
| 59 |
+
primitive_strategy: list[str] = Field(default_factory=list)
|
| 60 |
+
features: list[GeometryFeature] = Field(default_factory=list)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class ConstraintRecord(BaseModel):
|
| 64 |
+
kind: str
|
| 65 |
+
target: str
|
| 66 |
+
value: ValueType | None = None
|
| 67 |
+
reference: str | None = None
|
| 68 |
+
notes: str | None = None
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class SemanticStyle(BaseModel):
|
| 72 |
+
keywords: list[str] = Field(default_factory=list)
|
| 73 |
+
symmetry: SymmetryType = "none"
|
| 74 |
+
manufacturing_bias: str = "generic"
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
class FamilyHint(BaseModel):
|
| 78 |
+
name: str | None = None
|
| 79 |
+
generation_mode: GenerationMode = "new"
|
| 80 |
+
parent_family: str | None = None
|
| 81 |
+
confidence: float | None = None
|
| 82 |
+
novelty_score: float | None = None
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class DedupeHint(BaseModel):
|
| 86 |
+
canonical_signature: str | None = None
|
| 87 |
+
similar_to_job_id: str | None = None
|
| 88 |
+
is_likely_duplicate: bool = False
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class SemanticCadSpec(BaseModel):
|
| 92 |
+
spec_version: str = "1.1"
|
| 93 |
+
intent: str
|
| 94 |
+
mode: ModeType = "part"
|
| 95 |
+
output_type: OutputType = "3d_solid"
|
| 96 |
+
units: str = "mm"
|
| 97 |
+
semantic_part: SemanticPart = Field(default_factory=SemanticPart)
|
| 98 |
+
family_hint: FamilyHint = Field(default_factory=FamilyHint)
|
| 99 |
+
geometry: GeometryPlan = Field(default_factory=GeometryPlan)
|
| 100 |
+
dimensions: dict[str, ValueType] = Field(default_factory=dict)
|
| 101 |
+
constraints: list[ConstraintRecord] = Field(default_factory=list)
|
| 102 |
+
style: SemanticStyle = Field(default_factory=SemanticStyle)
|
| 103 |
+
dedupe: DedupeHint = Field(default_factory=DedupeHint)
|
| 104 |
+
notes: list[str] = Field(default_factory=list)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
class GenerateSpecResponse(BaseModel):
|
| 108 |
ok: bool = True
|
| 109 |
cached: bool = False
|
| 110 |
prompt_hash: str
|
| 111 |
+
spec: CadSpec | SemanticCadSpec
|
| 112 |
notes: list[str] = []
|
| 113 |
model: str = "stub/template-router"
|
| 114 |
suspicious_input: bool = False
|
| 115 |
fallback_level: str = "normal"
|
| 116 |
|
| 117 |
|
| 118 |
+
class ArtifactRecord(BaseModel):
|
| 119 |
+
id: str = Field(default_factory=lambda: str(uuid4()))
|
| 120 |
+
job_id: str
|
| 121 |
+
kind: ArtifactKind
|
| 122 |
+
storage_key: str
|
| 123 |
+
size_bytes: int | None = None
|
| 124 |
+
url: str | None = None
|
| 125 |
+
created_at: str | None = None
|
| 126 |
+
|
| 127 |
+
|
| 128 |
class JobRecord(BaseModel):
|
| 129 |
id: str = Field(default_factory=lambda: str(uuid4()))
|
| 130 |
status: JobStatus = "submitted"
|
|
|
|
| 133 |
output_type: OutputType = "3d_solid"
|
| 134 |
session_id: str | None = None
|
| 135 |
prompt_hash: str | None = None
|
| 136 |
+
spec: CadSpec | SemanticCadSpec | None = None
|
| 137 |
+
notes: list[str] = Field(default_factory=list)
|
| 138 |
error: str | None = None
|
| 139 |
+
artifacts: list[ArtifactRecord] = Field(default_factory=list)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
class ArtifactUploadResponse(BaseModel):
|
| 143 |
+
ok: bool = True
|
| 144 |
+
artifact: ArtifactRecord
|
| 145 |
|
| 146 |
|
| 147 |
class HealthResponse(BaseModel):
|
apps/backend-api/app/repository.py
CHANGED
|
@@ -4,7 +4,7 @@ from typing import Any
|
|
| 4 |
|
| 5 |
from .db import connect, get_database_state, serialize_json
|
| 6 |
from .models import JobRecord
|
| 7 |
-
from .store import _JOBS
|
| 8 |
|
| 9 |
|
| 10 |
def save_job(job: JobRecord) -> None:
|
|
@@ -83,3 +83,74 @@ def get_job(job_id: str) -> dict[str, Any] | None:
|
|
| 83 |
"notes": notes_json or [],
|
| 84 |
"error": error_text,
|
| 85 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
from .db import connect, get_database_state, serialize_json
|
| 6 |
from .models import JobRecord
|
| 7 |
+
from .store import _ARTIFACTS, _JOBS
|
| 8 |
|
| 9 |
|
| 10 |
def save_job(job: JobRecord) -> None:
|
|
|
|
| 83 |
"notes": notes_json or [],
|
| 84 |
"error": error_text,
|
| 85 |
}
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def create_artifact(job_id: str, kind: str, storage_key: str, size_bytes: int | None) -> dict[str, Any]:
|
| 89 |
+
db_state = get_database_state()
|
| 90 |
+
if not db_state.enabled:
|
| 91 |
+
row = {
|
| 92 |
+
"id": f"{job_id}:{kind}:{len(_ARTIFACTS[job_id]) + 1}",
|
| 93 |
+
"job_id": job_id,
|
| 94 |
+
"kind": kind,
|
| 95 |
+
"storage_key": storage_key,
|
| 96 |
+
"size_bytes": size_bytes,
|
| 97 |
+
"created_at": None,
|
| 98 |
+
}
|
| 99 |
+
_ARTIFACTS[job_id].append(row)
|
| 100 |
+
return row
|
| 101 |
+
|
| 102 |
+
with connect() as conn:
|
| 103 |
+
with conn.cursor() as cur:
|
| 104 |
+
cur.execute(
|
| 105 |
+
"""
|
| 106 |
+
insert into artifacts (job_id, kind, storage_key, size_bytes)
|
| 107 |
+
values (%s, %s, %s, %s)
|
| 108 |
+
returning id, job_id, kind, storage_key, size_bytes, created_at
|
| 109 |
+
""",
|
| 110 |
+
(job_id, kind, storage_key, size_bytes),
|
| 111 |
+
)
|
| 112 |
+
row = cur.fetchone()
|
| 113 |
+
conn.commit()
|
| 114 |
+
|
| 115 |
+
artifact_id, r_job_id, r_kind, r_storage_key, r_size_bytes, r_created_at = row
|
| 116 |
+
return {
|
| 117 |
+
"id": str(artifact_id),
|
| 118 |
+
"job_id": str(r_job_id),
|
| 119 |
+
"kind": r_kind,
|
| 120 |
+
"storage_key": r_storage_key,
|
| 121 |
+
"size_bytes": r_size_bytes,
|
| 122 |
+
"created_at": r_created_at.isoformat() if r_created_at else None,
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def list_artifacts(job_id: str) -> list[dict[str, Any]]:
|
| 127 |
+
db_state = get_database_state()
|
| 128 |
+
if not db_state.enabled:
|
| 129 |
+
return list(_ARTIFACTS.get(job_id, []))
|
| 130 |
+
|
| 131 |
+
with connect() as conn:
|
| 132 |
+
with conn.cursor() as cur:
|
| 133 |
+
cur.execute(
|
| 134 |
+
"""
|
| 135 |
+
select id, job_id, kind, storage_key, size_bytes, created_at
|
| 136 |
+
from artifacts
|
| 137 |
+
where job_id = %s
|
| 138 |
+
order by created_at asc
|
| 139 |
+
""",
|
| 140 |
+
(job_id,),
|
| 141 |
+
)
|
| 142 |
+
rows = cur.fetchall() or []
|
| 143 |
+
|
| 144 |
+
out: list[dict[str, Any]] = []
|
| 145 |
+
for artifact_id, r_job_id, r_kind, r_storage_key, r_size_bytes, r_created_at in rows:
|
| 146 |
+
out.append(
|
| 147 |
+
{
|
| 148 |
+
"id": str(artifact_id),
|
| 149 |
+
"job_id": str(r_job_id),
|
| 150 |
+
"kind": r_kind,
|
| 151 |
+
"storage_key": r_storage_key,
|
| 152 |
+
"size_bytes": r_size_bytes,
|
| 153 |
+
"created_at": r_created_at.isoformat() if r_created_at else None,
|
| 154 |
+
}
|
| 155 |
+
)
|
| 156 |
+
return out
|
apps/backend-api/app/store.py
CHANGED
|
@@ -5,3 +5,4 @@ from collections import defaultdict, deque
|
|
| 5 |
_REQUESTS: dict[str, deque[float]] = defaultdict(deque)
|
| 6 |
_CACHE: dict[str, dict] = {}
|
| 7 |
_JOBS: dict[str, dict] = {}
|
|
|
|
|
|
| 5 |
_REQUESTS: dict[str, deque[float]] = defaultdict(deque)
|
| 6 |
_CACHE: dict[str, dict] = {}
|
| 7 |
_JOBS: dict[str, dict] = {}
|
| 8 |
+
_ARTIFACTS: dict[str, list[dict]] = defaultdict(list)
|
apps/backend-api/fly.toml
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# fly.toml app configuration file generated for natural-cad on 2026-04-11T20:14:32-07:00
|
| 2 |
+
#
|
| 3 |
+
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
|
| 4 |
+
#
|
| 5 |
+
|
| 6 |
+
app = 'natural-cad'
|
| 7 |
+
primary_region = 'ord'
|
| 8 |
+
|
| 9 |
+
[build]
|
| 10 |
+
|
| 11 |
+
[http_service]
|
| 12 |
+
internal_port = 8000
|
| 13 |
+
force_https = true
|
| 14 |
+
auto_stop_machines = 'stop'
|
| 15 |
+
auto_start_machines = true
|
| 16 |
+
min_machines_running = 0
|
| 17 |
+
processes = ['app']
|
| 18 |
+
|
| 19 |
+
[[vm]]
|
| 20 |
+
memory = '1gb'
|
| 21 |
+
cpus = 1
|
| 22 |
+
memory_mb = 1024
|
apps/backend-api/requirements.txt
CHANGED
|
@@ -3,4 +3,5 @@ uvicorn[standard]>=0.30.0
|
|
| 3 |
pydantic>=2.8.0
|
| 4 |
python-dotenv>=1.0.1
|
| 5 |
httpx>=0.27.0
|
|
|
|
| 6 |
psycopg[binary]>=3.2.0
|
|
|
|
| 3 |
pydantic>=2.8.0
|
| 4 |
python-dotenv>=1.0.1
|
| 5 |
httpx>=0.27.0
|
| 6 |
+
python-multipart>=0.0.9
|
| 7 |
psycopg[binary]>=3.2.0
|
apps/gradio-demo/README.md
CHANGED
|
@@ -13,13 +13,34 @@ Gradio prototype for NaturalCAD, a public natural-language CAD modeler built on
|
|
| 13 |
|
| 14 |
- Prompt-driven model generation through the NaturalCAD backend when available
|
| 15 |
- Local fallback generation if the backend is unavailable
|
| 16 |
-
- Run build123d geometry and see
|
| 17 |
- Download STL and STEP exports
|
|
|
|
| 18 |
- View backend + execution logs
|
| 19 |
- Lightweight run logging for MVP testing data (`artifacts/logs/runs.jsonl`)
|
| 20 |
|
| 21 |
## Run locally
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
Start the backend first:
|
| 24 |
|
| 25 |
```bash
|
|
@@ -41,14 +62,17 @@ Current Space-oriented dependency note:
|
|
| 41 |
- if Hugging Face Space cannot reliably support the CAD dependency stack, we can keep the UI there and offload execution to a container or VM later without changing the product direction
|
| 42 |
|
| 43 |
Optional environment variables:
|
| 44 |
-
- `NATURALCAD_BACKEND_URL` (leave unset for a pure Space-only MVP, or set it to enable backend-assisted spec generation)
|
| 45 |
- `NATURALCAD_API_KEY`
|
| 46 |
- `NATURALCAD_BACKEND_TIMEOUT` (default `4` seconds)
|
| 47 |
- `BUILD123D_PYTHON` (defaults to the current Python runtime, which is better for Hugging Face Space deployment)
|
| 48 |
|
|
|
|
|
|
|
|
|
|
| 49 |
Runtime artifacts:
|
| 50 |
-
- latest files in `artifacts/`
|
| 51 |
-
- archived runs in `artifacts/runs/`
|
| 52 |
- lightweight run logs in `artifacts/logs/runs.jsonl`
|
| 53 |
|
| 54 |
Open http://localhost:7860
|
|
|
|
| 13 |
|
| 14 |
- Prompt-driven model generation through the NaturalCAD backend when available
|
| 15 |
- Local fallback generation if the backend is unavailable
|
| 16 |
+
- Run build123d geometry and see a GLB preview in the browser
|
| 17 |
- Download STL and STEP exports
|
| 18 |
+
- Upload generated STL/STEP artifacts to backend storage when backend is configured
|
| 19 |
- View backend + execution logs
|
| 20 |
- Lightweight run logging for MVP testing data (`artifacts/logs/runs.jsonl`)
|
| 21 |
|
| 22 |
## Run locally
|
| 23 |
|
| 24 |
+
From the repo root, the easiest path is:
|
| 25 |
+
|
| 26 |
+
```bash
|
| 27 |
+
npm run backend:local
|
| 28 |
+
npm run frontend:local
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
Those commands use:
|
| 32 |
+
- `scripts/run-local-backend.sh`
|
| 33 |
+
- `scripts/run-local-frontend.sh`
|
| 34 |
+
|
| 35 |
+
Frontend notes:
|
| 36 |
+
- local Gradio dev needs Python 3.10-3.13 because `build123d` does not currently publish wheels for Python 3.14+
|
| 37 |
+
- by default the frontend helper uses `~/.openclaw/workspace/.venvs/cadrender312`
|
| 38 |
+
- it defaults `NATURALCAD_BACKEND_URL` to `http://127.0.0.1:8010`
|
| 39 |
+
- if `apps/backend-api/.env` exists, it reuses `API_SHARED_SECRET` as `NATURALCAD_API_KEY`
|
| 40 |
+
- override with `NATURALCAD_FRONTEND_VENV=/path/to/venv` if needed
|
| 41 |
+
|
| 42 |
+
Manual fallback:
|
| 43 |
+
|
| 44 |
Start the backend first:
|
| 45 |
|
| 46 |
```bash
|
|
|
|
| 62 |
- if Hugging Face Space cannot reliably support the CAD dependency stack, we can keep the UI there and offload execution to a container or VM later without changing the product direction
|
| 63 |
|
| 64 |
Optional environment variables:
|
| 65 |
+
- `NATURALCAD_BACKEND_URL` (leave unset for a pure Space-only MVP, or set it to enable backend-assisted spec generation + artifact upload)
|
| 66 |
- `NATURALCAD_API_KEY`
|
| 67 |
- `NATURALCAD_BACKEND_TIMEOUT` (default `4` seconds)
|
| 68 |
- `BUILD123D_PYTHON` (defaults to the current Python runtime, which is better for Hugging Face Space deployment)
|
| 69 |
|
| 70 |
+
When backend is enabled and returns a `job.id`, the app will POST STL/STEP files to:
|
| 71 |
+
- `POST /v1/jobs/{job_id}/artifacts`
|
| 72 |
+
|
| 73 |
Runtime artifacts:
|
| 74 |
+
- latest files in `artifacts/` (`model.glb`, `model.stl`, `model.step`)
|
| 75 |
+
- archived runs in `artifacts/runs/` (GLB preview is ephemeral and regenerated locally)
|
| 76 |
- lightweight run logs in `artifacts/logs/runs.jsonl`
|
| 77 |
|
| 78 |
Open http://localhost:7860
|
apps/gradio-demo/app/main.py
CHANGED
|
@@ -4,6 +4,7 @@
|
|
| 4 |
from __future__ import annotations
|
| 5 |
|
| 6 |
import json
|
|
|
|
| 7 |
import os
|
| 8 |
import shutil
|
| 9 |
import subprocess
|
|
@@ -17,6 +18,7 @@ from pathlib import Path
|
|
| 17 |
from urllib import error, request
|
| 18 |
|
| 19 |
import gradio as gr
|
|
|
|
| 20 |
|
| 21 |
BUILD123D_PYTHON = os.getenv("BUILD123D_PYTHON", sys.executable)
|
| 22 |
BACKEND_URL = os.getenv("NATURALCAD_BACKEND_URL", os.getenv("NL_CAD_BACKEND_URL", "")).strip()
|
|
@@ -56,7 +58,74 @@ result = bp.part
|
|
| 56 |
'''
|
| 57 |
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
def render_code_from_spec(spec: dict) -> str:
|
|
|
|
| 60 |
geometry_family = spec.get("geometry_family", "bracket_plate")
|
| 61 |
output_type = spec.get("output_type", "3d_solid")
|
| 62 |
params = spec.get("parameters", {})
|
|
@@ -258,16 +327,65 @@ def create_job(prompt: str, mode: str, output_type: str) -> tuple[dict | None, s
|
|
| 258 |
return None, json.dumps({"error": f"backend unavailable: {exc}"}, indent=2)
|
| 259 |
|
| 260 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
def _append_run_log(entry: dict) -> None:
|
| 262 |
with RUN_LOG_PATH.open("a", encoding="utf-8") as fh:
|
| 263 |
fh.write(json.dumps(entry) + "\n")
|
| 264 |
|
| 265 |
|
| 266 |
-
def run_build123d(code: str, prompt: str = "") -> tuple[str | None, str | None, str, str, str | None, float]:
|
| 267 |
if not code or not code.strip():
|
| 268 |
-
return None, None, "No code provided.", "No geometry was generated.", None, 0.0
|
| 269 |
|
| 270 |
logs: list[str] = []
|
|
|
|
| 271 |
stl_path: str | None = None
|
| 272 |
step_path: str | None = None
|
| 273 |
started_at = time.time()
|
|
@@ -278,6 +396,7 @@ def run_build123d(code: str, prompt: str = "") -> tuple[str | None, str | None,
|
|
| 278 |
source_file.write_text(code)
|
| 279 |
stl_file = RUNS_DIR / f"{run_id}.stl"
|
| 280 |
step_file = RUNS_DIR / f"{run_id}.step"
|
|
|
|
| 281 |
|
| 282 |
logs.append(f"Run ID: {run_id}")
|
| 283 |
if prompt.strip():
|
|
@@ -337,10 +456,24 @@ print("STEP exported to {step_file}")
|
|
| 337 |
if result.returncode == 0 and stl_file.exists() and step_file.exists():
|
| 338 |
latest_stl = ARTIFACTS_DIR / "model.stl"
|
| 339 |
latest_step = ARTIFACTS_DIR / "model.step"
|
|
|
|
| 340 |
shutil.copy2(stl_file, latest_stl)
|
| 341 |
shutil.copy2(step_file, latest_step)
|
| 342 |
stl_path = str(latest_stl)
|
| 343 |
step_path = str(latest_step)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
logs.append(f"Export successful. Archived artifacts at runs/{run_id}.*")
|
| 345 |
else:
|
| 346 |
logs.append(f"Runner exited with code {result.returncode}.")
|
|
@@ -352,7 +485,7 @@ print("STEP exported to {step_file}")
|
|
| 352 |
|
| 353 |
duration = time.time() - started_at
|
| 354 |
summary = f"Model ready in {duration:.2f}s."
|
| 355 |
-
return stl_path, step_path, "\n".join(logs), summary, run_id, duration
|
| 356 |
|
| 357 |
|
| 358 |
def generate_from_prompt(prompt: str, mode: str, output_type: str):
|
|
@@ -394,14 +527,14 @@ def generate_from_prompt(prompt: str, mode: str, output_type: str):
|
|
| 394 |
return None, None, None, backend_log, "Backend created no CAD spec."
|
| 395 |
|
| 396 |
code = render_code_from_spec(spec)
|
| 397 |
-
stl_path, step_path, logs, summary, run_id, execution_seconds = run_build123d(code, prompt)
|
| 398 |
combined_logs = "\n\n".join([
|
| 399 |
"Backend job created:" if backend_ok else "Backend unavailable, using local fallback:",
|
| 400 |
backend_log,
|
| 401 |
"Local execution log:",
|
| 402 |
logs,
|
| 403 |
])
|
| 404 |
-
success = bool(stl_path)
|
| 405 |
_append_run_log({
|
| 406 |
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 407 |
"run_id": run_id,
|
|
@@ -417,11 +550,25 @@ def generate_from_prompt(prompt: str, mode: str, output_type: str):
|
|
| 417 |
"execution_seconds": round(execution_seconds, 3),
|
| 418 |
"error": None if success else "Generation failed.",
|
| 419 |
})
|
| 420 |
-
if not
|
| 421 |
return None, None, None, combined_logs, "Generation failed. Try a simpler prompt or an example."
|
| 422 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 423 |
final_summary = summary if not client_notice else f"{summary}\n\n⚠️ {client_notice}"
|
| 424 |
-
|
|
|
|
| 425 |
|
| 426 |
|
| 427 |
def use_example(prompt: str, mode: str, output_type: str):
|
|
|
|
| 4 |
from __future__ import annotations
|
| 5 |
|
| 6 |
import json
|
| 7 |
+
import mimetypes
|
| 8 |
import os
|
| 9 |
import shutil
|
| 10 |
import subprocess
|
|
|
|
| 18 |
from urllib import error, request
|
| 19 |
|
| 20 |
import gradio as gr
|
| 21 |
+
import trimesh
|
| 22 |
|
| 23 |
BUILD123D_PYTHON = os.getenv("BUILD123D_PYTHON", sys.executable)
|
| 24 |
BACKEND_URL = os.getenv("NATURALCAD_BACKEND_URL", os.getenv("NL_CAD_BACKEND_URL", "")).strip()
|
|
|
|
| 58 |
'''
|
| 59 |
|
| 60 |
|
| 61 |
+
def _legacy_spec_from_semantic(spec: dict) -> dict:
|
| 62 |
+
if "geometry_family" in spec and "parameters" in spec:
|
| 63 |
+
return spec
|
| 64 |
+
|
| 65 |
+
family_hint = spec.get("family_hint") or {}
|
| 66 |
+
geometry = spec.get("geometry") or {}
|
| 67 |
+
semantic_part = spec.get("semantic_part") or {}
|
| 68 |
+
dimensions = dict(spec.get("dimensions") or {})
|
| 69 |
+
output_type = spec.get("output_type", "3d_solid")
|
| 70 |
+
|
| 71 |
+
geometry_family = family_hint.get("name")
|
| 72 |
+
if not geometry_family:
|
| 73 |
+
topology = " ".join(semantic_part.get("topology") or []).lower()
|
| 74 |
+
feature_types = " ".join((f.get("feature_type", "") for f in geometry.get("features") or [] if isinstance(f, dict))).lower()
|
| 75 |
+
if "truss" in topology or "truss" in feature_types or "span" in dimensions and "panel_count" in dimensions:
|
| 76 |
+
geometry_family = "truss_beam" if output_type != "2d_vector" else "truss_elevation"
|
| 77 |
+
elif output_type == "surface":
|
| 78 |
+
geometry_family = "canopy_surface"
|
| 79 |
+
elif "tower" in topology or "mass" in topology or "notch" in feature_types:
|
| 80 |
+
geometry_family = "tower_block"
|
| 81 |
+
else:
|
| 82 |
+
geometry_family = "bracket_plate"
|
| 83 |
+
|
| 84 |
+
params = dict(dimensions)
|
| 85 |
+
if output_type == "2d_vector":
|
| 86 |
+
params.setdefault("preview_thickness", 1)
|
| 87 |
+
if geometry_family == "bracket_plate":
|
| 88 |
+
params.setdefault("width", 80)
|
| 89 |
+
params.setdefault("height", 50)
|
| 90 |
+
params.setdefault("thickness", 6)
|
| 91 |
+
params.setdefault("hole_count", 4)
|
| 92 |
+
params.setdefault("hole_diameter", 10)
|
| 93 |
+
elif geometry_family == "truss_beam":
|
| 94 |
+
params.setdefault("span", 140)
|
| 95 |
+
params.setdefault("height", 24)
|
| 96 |
+
params.setdefault("panel_count", 7)
|
| 97 |
+
params.setdefault("member_size", 3)
|
| 98 |
+
elif geometry_family == "truss_elevation":
|
| 99 |
+
params.setdefault("span", 140)
|
| 100 |
+
params.setdefault("height", 24)
|
| 101 |
+
params.setdefault("panel_count", 7)
|
| 102 |
+
params.setdefault("member_size", 3)
|
| 103 |
+
params.setdefault("preview_thickness", 1)
|
| 104 |
+
elif geometry_family == "tower_block":
|
| 105 |
+
params.setdefault("width", 30)
|
| 106 |
+
params.setdefault("length", 30)
|
| 107 |
+
params.setdefault("height", 120)
|
| 108 |
+
params.setdefault("notch", 10)
|
| 109 |
+
elif geometry_family == "canopy_surface":
|
| 110 |
+
params.setdefault("span", 160)
|
| 111 |
+
params.setdefault("depth", 90)
|
| 112 |
+
params.setdefault("peak_height", 38)
|
| 113 |
+
params.setdefault("thickness", 2)
|
| 114 |
+
elif geometry_family == "lofted_panel":
|
| 115 |
+
params.setdefault("width", 80)
|
| 116 |
+
params.setdefault("depth", 50)
|
| 117 |
+
params.setdefault("rise", 18)
|
| 118 |
+
params.setdefault("thickness", 2)
|
| 119 |
+
|
| 120 |
+
return {
|
| 121 |
+
"geometry_family": geometry_family,
|
| 122 |
+
"output_type": output_type,
|
| 123 |
+
"parameters": params,
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
|
| 127 |
def render_code_from_spec(spec: dict) -> str:
|
| 128 |
+
spec = _legacy_spec_from_semantic(spec)
|
| 129 |
geometry_family = spec.get("geometry_family", "bracket_plate")
|
| 130 |
output_type = spec.get("output_type", "3d_solid")
|
| 131 |
params = spec.get("parameters", {})
|
|
|
|
| 327 |
return None, json.dumps({"error": f"backend unavailable: {exc}"}, indent=2)
|
| 328 |
|
| 329 |
|
| 330 |
+
def upload_job_artifact(job_id: str, kind: str, path: str) -> tuple[dict | None, str]:
|
| 331 |
+
if not BACKEND_URL or not job_id:
|
| 332 |
+
return None, ""
|
| 333 |
+
|
| 334 |
+
file_path = Path(path)
|
| 335 |
+
if not file_path.exists():
|
| 336 |
+
return None, json.dumps({"error": f"artifact path missing: {path}"}, indent=2)
|
| 337 |
+
|
| 338 |
+
boundary = f"----naturalcad-{uuid.uuid4().hex}"
|
| 339 |
+
content_type = mimetypes.guess_type(file_path.name)[0] or "application/octet-stream"
|
| 340 |
+
file_bytes = file_path.read_bytes()
|
| 341 |
+
|
| 342 |
+
body = bytearray()
|
| 343 |
+
body.extend(f"--{boundary}\r\n".encode())
|
| 344 |
+
body.extend(b'Content-Disposition: form-data; name="kind"\r\n\r\n')
|
| 345 |
+
body.extend(kind.encode())
|
| 346 |
+
body.extend(b"\r\n")
|
| 347 |
+
|
| 348 |
+
body.extend(f"--{boundary}\r\n".encode())
|
| 349 |
+
body.extend(
|
| 350 |
+
f'Content-Disposition: form-data; name="file"; filename="{file_path.name}"\r\n'.encode()
|
| 351 |
+
)
|
| 352 |
+
body.extend(f"Content-Type: {content_type}\r\n\r\n".encode())
|
| 353 |
+
body.extend(file_bytes)
|
| 354 |
+
body.extend(b"\r\n")
|
| 355 |
+
body.extend(f"--{boundary}--\r\n".encode())
|
| 356 |
+
|
| 357 |
+
headers = {"Content-Type": f"multipart/form-data; boundary={boundary}"}
|
| 358 |
+
if BACKEND_API_KEY:
|
| 359 |
+
headers["x-api-key"] = BACKEND_API_KEY
|
| 360 |
+
|
| 361 |
+
req = request.Request(
|
| 362 |
+
f"{BACKEND_URL.rstrip('/')}/v1/jobs/{job_id}/artifacts",
|
| 363 |
+
data=bytes(body),
|
| 364 |
+
headers=headers,
|
| 365 |
+
method="POST",
|
| 366 |
+
)
|
| 367 |
+
try:
|
| 368 |
+
with request.urlopen(req, timeout=BACKEND_TIMEOUT_SECONDS) as response:
|
| 369 |
+
data = json.loads(response.read().decode())
|
| 370 |
+
return data, json.dumps(data, indent=2)
|
| 371 |
+
except error.HTTPError as exc:
|
| 372 |
+
detail = exc.read().decode() if exc.fp else str(exc)
|
| 373 |
+
return None, json.dumps({"error": f"artifact upload http {exc.code}", "detail": detail}, indent=2)
|
| 374 |
+
except Exception as exc: # noqa: BLE001
|
| 375 |
+
return None, json.dumps({"error": f"artifact upload failed: {exc}"}, indent=2)
|
| 376 |
+
|
| 377 |
+
|
| 378 |
def _append_run_log(entry: dict) -> None:
|
| 379 |
with RUN_LOG_PATH.open("a", encoding="utf-8") as fh:
|
| 380 |
fh.write(json.dumps(entry) + "\n")
|
| 381 |
|
| 382 |
|
| 383 |
+
def run_build123d(code: str, prompt: str = "") -> tuple[str | None, str | None, str | None, str, str, str | None, float]:
|
| 384 |
if not code or not code.strip():
|
| 385 |
+
return None, None, None, "No code provided.", "No geometry was generated.", None, 0.0
|
| 386 |
|
| 387 |
logs: list[str] = []
|
| 388 |
+
glb_path: str | None = None
|
| 389 |
stl_path: str | None = None
|
| 390 |
step_path: str | None = None
|
| 391 |
started_at = time.time()
|
|
|
|
| 396 |
source_file.write_text(code)
|
| 397 |
stl_file = RUNS_DIR / f"{run_id}.stl"
|
| 398 |
step_file = RUNS_DIR / f"{run_id}.step"
|
| 399 |
+
glb_file = RUNS_DIR / f"{run_id}.glb"
|
| 400 |
|
| 401 |
logs.append(f"Run ID: {run_id}")
|
| 402 |
if prompt.strip():
|
|
|
|
| 456 |
if result.returncode == 0 and stl_file.exists() and step_file.exists():
|
| 457 |
latest_stl = ARTIFACTS_DIR / "model.stl"
|
| 458 |
latest_step = ARTIFACTS_DIR / "model.step"
|
| 459 |
+
latest_glb = ARTIFACTS_DIR / "model.glb"
|
| 460 |
shutil.copy2(stl_file, latest_stl)
|
| 461 |
shutil.copy2(step_file, latest_step)
|
| 462 |
stl_path = str(latest_stl)
|
| 463 |
step_path = str(latest_step)
|
| 464 |
+
|
| 465 |
+
try:
|
| 466 |
+
preview_mesh = trimesh.load_mesh(stl_file, force="mesh")
|
| 467 |
+
preview_mesh.apply_transform(
|
| 468 |
+
trimesh.transformations.rotation_matrix(-1.5707963267948966, [1, 0, 0])
|
| 469 |
+
)
|
| 470 |
+
preview_mesh.export(glb_file)
|
| 471 |
+
shutil.copy2(glb_file, latest_glb)
|
| 472 |
+
glb_path = str(latest_glb)
|
| 473 |
+
logs.append(f"GLB preview exported to {latest_glb}")
|
| 474 |
+
except Exception as exc: # noqa: BLE001
|
| 475 |
+
logs.append(f"GLB preview export failed: {exc}")
|
| 476 |
+
|
| 477 |
logs.append(f"Export successful. Archived artifacts at runs/{run_id}.*")
|
| 478 |
else:
|
| 479 |
logs.append(f"Runner exited with code {result.returncode}.")
|
|
|
|
| 485 |
|
| 486 |
duration = time.time() - started_at
|
| 487 |
summary = f"Model ready in {duration:.2f}s."
|
| 488 |
+
return glb_path, stl_path, step_path, "\n".join(logs), summary, run_id, duration
|
| 489 |
|
| 490 |
|
| 491 |
def generate_from_prompt(prompt: str, mode: str, output_type: str):
|
|
|
|
| 527 |
return None, None, None, backend_log, "Backend created no CAD spec."
|
| 528 |
|
| 529 |
code = render_code_from_spec(spec)
|
| 530 |
+
glb_path, stl_path, step_path, logs, summary, run_id, execution_seconds = run_build123d(code, prompt)
|
| 531 |
combined_logs = "\n\n".join([
|
| 532 |
"Backend job created:" if backend_ok else "Backend unavailable, using local fallback:",
|
| 533 |
backend_log,
|
| 534 |
"Local execution log:",
|
| 535 |
logs,
|
| 536 |
])
|
| 537 |
+
success = bool(step_path and (glb_path or stl_path))
|
| 538 |
_append_run_log({
|
| 539 |
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 540 |
"run_id": run_id,
|
|
|
|
| 550 |
"execution_seconds": round(execution_seconds, 3),
|
| 551 |
"error": None if success else "Generation failed.",
|
| 552 |
})
|
| 553 |
+
if not step_path:
|
| 554 |
return None, None, None, combined_logs, "Generation failed. Try a simpler prompt or an example."
|
| 555 |
|
| 556 |
+
job_id = str((job_data or {}).get("id", ""))
|
| 557 |
+
if backend_ok and job_id:
|
| 558 |
+
upload_logs: list[str] = []
|
| 559 |
+
_, stl_upload_log = upload_job_artifact(job_id, "stl", stl_path)
|
| 560 |
+
if stl_upload_log:
|
| 561 |
+
upload_logs.append("STL upload:\n" + stl_upload_log)
|
| 562 |
+
if step_path:
|
| 563 |
+
_, step_upload_log = upload_job_artifact(job_id, "step", step_path)
|
| 564 |
+
if step_upload_log:
|
| 565 |
+
upload_logs.append("STEP upload:\n" + step_upload_log)
|
| 566 |
+
if upload_logs:
|
| 567 |
+
combined_logs = "\n\n".join([combined_logs, "Artifact uploads:", *upload_logs])
|
| 568 |
+
|
| 569 |
final_summary = summary if not client_notice else f"{summary}\n\n⚠️ {client_notice}"
|
| 570 |
+
preview_path = glb_path or stl_path
|
| 571 |
+
return preview_path, stl_path, step_path, combined_logs, final_summary
|
| 572 |
|
| 573 |
|
| 574 |
def use_example(prompt: str, mode: str, output_type: str):
|
docs/backend-v0.md
CHANGED
|
@@ -235,6 +235,28 @@ Recommendation:
|
|
| 235 |
### Phase 4
|
| 236 |
- tighten retention, add auth tiers, add cancellation, add preview generation
|
| 237 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
## What not to do in v0
|
| 239 |
|
| 240 |
- no public arbitrary Python execution
|
|
|
|
| 235 |
### Phase 4
|
| 236 |
- tighten retention, add auth tiers, add cancellation, add preview generation
|
| 237 |
|
| 238 |
+
## Spec direction for the next phase
|
| 239 |
+
|
| 240 |
+
NaturalCAD should move next toward a **loose compositional / semantic JSON spec** rather than a rigid family-first schema.
|
| 241 |
+
|
| 242 |
+
Reason:
|
| 243 |
+
- rigid family routing too early will bias the model toward repetitive safe defaults
|
| 244 |
+
- concept-grade generation needs room for novelty, unexpected topology, and broader prompt coverage
|
| 245 |
+
- reuse and dedupe should exist, but as later optimization layers rather than the main creative frame
|
| 246 |
+
|
| 247 |
+
Recommended next spec target:
|
| 248 |
+
- `intent`
|
| 249 |
+
- `semantic_part`
|
| 250 |
+
- `family_hint` (optional, not dominant)
|
| 251 |
+
- `geometry`
|
| 252 |
+
- `dimensions`
|
| 253 |
+
- `constraints`
|
| 254 |
+
- `style`
|
| 255 |
+
- `dedupe`
|
| 256 |
+
|
| 257 |
+
Reference:
|
| 258 |
+
- `docs/compositional-spec-v1.1.md`
|
| 259 |
+
|
| 260 |
## What not to do in v0
|
| 261 |
|
| 262 |
- no public arbitrary Python execution
|
docs/compositional-spec-v1.1.md
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# NaturalCAD Compositional Spec v1.1
|
| 2 |
+
|
| 3 |
+
## Why this exists
|
| 4 |
+
|
| 5 |
+
NaturalCAD is currently targeting **concept-grade** generation, not fabrication-grade precision.
|
| 6 |
+
That means the next spec should stay loose enough to support novel and unexpected objects, instead of forcing every prompt into a tiny menu of safe default families.
|
| 7 |
+
|
| 8 |
+
This spec is meant to capture:
|
| 9 |
+
- intent
|
| 10 |
+
- semantic part meaning
|
| 11 |
+
- topology/composition
|
| 12 |
+
- geometry features and operations
|
| 13 |
+
- dimensions
|
| 14 |
+
- constraints
|
| 15 |
+
- style cues
|
| 16 |
+
|
| 17 |
+
It is intentionally less rigid than a true later-phase spec-to-model contract.
|
| 18 |
+
|
| 19 |
+
## Design goals
|
| 20 |
+
|
| 21 |
+
1. Preserve novelty and surprise
|
| 22 |
+
2. Avoid collapsing every prompt into the same default family
|
| 23 |
+
3. Capture enough structure to support execution and logging
|
| 24 |
+
4. Leave room for later hardening into stricter parametric generators
|
| 25 |
+
|
| 26 |
+
## Shape
|
| 27 |
+
|
| 28 |
+
```json
|
| 29 |
+
{
|
| 30 |
+
"spec_version": "1.1",
|
| 31 |
+
"intent": "Offset wall support with ribbed spine and staggered mounting tabs",
|
| 32 |
+
"mode": "part",
|
| 33 |
+
"output_type": "3d_solid",
|
| 34 |
+
"units": "mm",
|
| 35 |
+
"semantic_part": {
|
| 36 |
+
"category": "support",
|
| 37 |
+
"function": "wall-mounted load transfer",
|
| 38 |
+
"topology": ["main spine", "2 mounting tabs", "reinforcing ribs"],
|
| 39 |
+
"symmetry": "asymmetric"
|
| 40 |
+
},
|
| 41 |
+
"family_hint": {
|
| 42 |
+
"name": "support_bracket",
|
| 43 |
+
"generation_mode": "extend",
|
| 44 |
+
"parent_family": null,
|
| 45 |
+
"confidence": 0.64,
|
| 46 |
+
"novelty_score": 0.72
|
| 47 |
+
},
|
| 48 |
+
"geometry": {
|
| 49 |
+
"primitive_strategy": ["extrude", "boolean_subtract", "fillet"],
|
| 50 |
+
"features": [
|
| 51 |
+
{
|
| 52 |
+
"name": "spine",
|
| 53 |
+
"feature_type": "tapered_plate",
|
| 54 |
+
"attributes": {"taper_ratio": 0.7}
|
| 55 |
+
},
|
| 56 |
+
{
|
| 57 |
+
"name": "tabs",
|
| 58 |
+
"feature_type": "offset_mounts",
|
| 59 |
+
"count": 2,
|
| 60 |
+
"attributes": {"tab_offset": 18}
|
| 61 |
+
},
|
| 62 |
+
{
|
| 63 |
+
"name": "ribs",
|
| 64 |
+
"feature_type": "triangular_gusset",
|
| 65 |
+
"count": 3,
|
| 66 |
+
"attributes": {"rib_depth": 10}
|
| 67 |
+
}
|
| 68 |
+
]
|
| 69 |
+
},
|
| 70 |
+
"dimensions": {
|
| 71 |
+
"overall_height": 120,
|
| 72 |
+
"overall_width": 60,
|
| 73 |
+
"overall_depth": 45
|
| 74 |
+
},
|
| 75 |
+
"constraints": [
|
| 76 |
+
{
|
| 77 |
+
"kind": "min",
|
| 78 |
+
"target": "wall_thickness",
|
| 79 |
+
"value": 6
|
| 80 |
+
}
|
| 81 |
+
],
|
| 82 |
+
"style": {
|
| 83 |
+
"keywords": ["industrial", "structural", "heavy-duty"],
|
| 84 |
+
"symmetry": "asymmetric",
|
| 85 |
+
"manufacturing_bias": "machined"
|
| 86 |
+
},
|
| 87 |
+
"dedupe": {
|
| 88 |
+
"canonical_signature": null,
|
| 89 |
+
"similar_to_job_id": null,
|
| 90 |
+
"is_likely_duplicate": false
|
| 91 |
+
},
|
| 92 |
+
"notes": [
|
| 93 |
+
"Treat as a concept-grade structural support rather than a fabrication-verified design."
|
| 94 |
+
]
|
| 95 |
+
}
|
| 96 |
+
```
|
| 97 |
+
|
| 98 |
+
## Interpretation notes
|
| 99 |
+
|
| 100 |
+
### `intent`
|
| 101 |
+
A concise restatement of what the prompt is trying to produce.
|
| 102 |
+
|
| 103 |
+
### `semantic_part`
|
| 104 |
+
Describes what the object is and how it is composed, without prematurely locking it into a rigid generator family.
|
| 105 |
+
|
| 106 |
+
### `family_hint`
|
| 107 |
+
Optional. This should be treated as a backend hint, not as the main creative frame.
|
| 108 |
+
- `reuse`: known generator likely fits
|
| 109 |
+
- `extend`: known generator likely fits with variation
|
| 110 |
+
- `new`: likely needs a new generator path or a looser execution strategy
|
| 111 |
+
|
| 112 |
+
### `geometry`
|
| 113 |
+
Captures operations and features. This is closer to executable structure than pure natural language, while still leaving room for novelty.
|
| 114 |
+
|
| 115 |
+
### `dimensions`
|
| 116 |
+
Stores named scale-driving values. These should stay loose in concept-grade mode, then become more constrained in later phases.
|
| 117 |
+
|
| 118 |
+
### `constraints`
|
| 119 |
+
Carries guardrails and relationships, but should not yet be treated as full fabrication-grade tolerance logic.
|
| 120 |
+
|
| 121 |
+
### `dedupe`
|
| 122 |
+
Supports later duplicate detection and lineage without dominating the first-pass generation stage.
|
| 123 |
+
|
| 124 |
+
## Current recommendation
|
| 125 |
+
|
| 126 |
+
Use this spec as the next target for:
|
| 127 |
+
- backend model output
|
| 128 |
+
- prompt logging
|
| 129 |
+
- evaluation
|
| 130 |
+
- future prompt-to-build123d translation
|
| 131 |
+
|
| 132 |
+
Current repo status:
|
| 133 |
+
- `POST /v1/generate-spec` now emits this general semantic shape through a stub translator
|
| 134 |
+
- the Gradio app temporarily adapts the semantic spec back into the older generator families for execution
|
| 135 |
+
- this keeps the contract moving forward without breaking the current build123d demo path
|
| 136 |
+
|
| 137 |
+
Do not over-tighten it yet. NaturalCAD still needs room for new and unexpected objects.
|
docs/hf-space-deploy-checklist.md
CHANGED
|
@@ -20,6 +20,21 @@ For the lean MVP, backend use should be optional, not assumed. If `NATURALCAD_BA
|
|
| 20 |
|
| 21 |
If the Hugging Face Space runtime cannot support the CAD dependency stack cleanly, keep the Space as the frontend and offload execution to a container or VM.
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
## Data to capture
|
| 24 |
|
| 25 |
- timestamp
|
|
|
|
| 20 |
|
| 21 |
If the Hugging Face Space runtime cannot support the CAD dependency stack cleanly, keep the Space as the frontend and offload execution to a container or VM.
|
| 22 |
|
| 23 |
+
## Current hosted setup
|
| 24 |
+
|
| 25 |
+
Space env:
|
| 26 |
+
- variable: `NATURALCAD_BACKEND_URL`
|
| 27 |
+
- secret: `NATURALCAD_API_KEY`
|
| 28 |
+
|
| 29 |
+
Backend host:
|
| 30 |
+
- current recommended host: Fly.io
|
| 31 |
+
- current recommended backend port: `8000`
|
| 32 |
+
- backend should expose `GET /v1/health`, `POST /v1/generate-spec`, `POST /v1/jobs`, and `POST /v1/jobs/{job_id}/artifacts`
|
| 33 |
+
|
| 34 |
+
Runtime note:
|
| 35 |
+
- the Space Docker image must include the native stack needed by `build123d` / `OCP`
|
| 36 |
+
- current Dockerfile installs both `ocp=7.8.1` and `vtk=9.3` in the Conda env to avoid the missing `libvtkWrappingPythonCore3.10-9.3.so` runtime error
|
| 37 |
+
|
| 38 |
## Data to capture
|
| 39 |
|
| 40 |
- timestamp
|
package.json
CHANGED
|
@@ -2,6 +2,10 @@
|
|
| 2 |
"name": "live-visualizer",
|
| 3 |
"private": true,
|
| 4 |
"version": "0.1.0",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
"workspaces": [
|
| 6 |
"apps/*",
|
| 7 |
"packages/*"
|
|
|
|
| 2 |
"name": "live-visualizer",
|
| 3 |
"private": true,
|
| 4 |
"version": "0.1.0",
|
| 5 |
+
"scripts": {
|
| 6 |
+
"backend:local": "./scripts/run-local-backend.sh",
|
| 7 |
+
"frontend:local": "./scripts/run-local-frontend.sh"
|
| 8 |
+
},
|
| 9 |
"workspaces": [
|
| 10 |
"apps/*",
|
| 11 |
"packages/*"
|
scripts/run-local-backend.sh
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
| 5 |
+
BACKEND_DIR="$ROOT/apps/backend-api"
|
| 6 |
+
|
| 7 |
+
cd "$BACKEND_DIR"
|
| 8 |
+
|
| 9 |
+
if [[ ! -d .venv ]]; then
|
| 10 |
+
python3 -m venv .venv
|
| 11 |
+
fi
|
| 12 |
+
|
| 13 |
+
source .venv/bin/activate
|
| 14 |
+
pip install -r requirements.txt
|
| 15 |
+
exec uvicorn app.main:app --reload --port "${NATURALCAD_BACKEND_PORT:-8010}"
|
scripts/run-local-frontend.sh
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
| 5 |
+
VENV_DEFAULT="$HOME/.openclaw/workspace/.venvs/cadrender312"
|
| 6 |
+
VENV_PATH="${NATURALCAD_FRONTEND_VENV:-$VENV_DEFAULT}"
|
| 7 |
+
BACKEND_ENV_PATH="$ROOT/apps/backend-api/.env"
|
| 8 |
+
|
| 9 |
+
if [[ ! -x "$VENV_PATH/bin/python3" ]]; then
|
| 10 |
+
echo "NaturalCAD frontend venv not found at: $VENV_PATH" >&2
|
| 11 |
+
echo "Set NATURALCAD_FRONTEND_VENV=/path/to/venv if you want to use a different one." >&2
|
| 12 |
+
exit 1
|
| 13 |
+
fi
|
| 14 |
+
|
| 15 |
+
if [[ -z "${NATURALCAD_BACKEND_URL:-}" ]]; then
|
| 16 |
+
export NATURALCAD_BACKEND_URL="http://127.0.0.1:8010"
|
| 17 |
+
fi
|
| 18 |
+
|
| 19 |
+
if [[ -z "${NATURALCAD_API_KEY:-}" && -f "$BACKEND_ENV_PATH" ]]; then
|
| 20 |
+
backend_secret="$(grep '^API_SHARED_SECRET=' "$BACKEND_ENV_PATH" | tail -n 1 | cut -d= -f2-)"
|
| 21 |
+
if [[ -n "$backend_secret" ]]; then
|
| 22 |
+
export NATURALCAD_API_KEY="$backend_secret"
|
| 23 |
+
fi
|
| 24 |
+
fi
|
| 25 |
+
|
| 26 |
+
cd "$ROOT"
|
| 27 |
+
source "$VENV_PATH/bin/activate"
|
| 28 |
+
pip install -r requirements.txt
|
| 29 |
+
exec python3 app.py
|