noahlee1234 commited on
Commit
c67d8f3
·
1 Parent(s): 16058c2

NaturalCAD: add hosted backend flow and Space fixes

Browse files
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
- If the CAD dependency stack or runtime limits become painful, the frontend can stay on Hugging Face while execution moves to a container or VM later.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- from fastapi import FastAPI, Header, HTTPException, Request
 
9
 
10
  from .config import settings
11
  from .models import (
12
- CadSpec,
13
- CadStyle,
 
 
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 _style_from_prompt(prompt: str, default_family: str) -> CadStyle:
118
- heaviness = 0.6
119
- family = default_family
120
- if any(word in prompt for word in ["heavy", "massive", "thick", "brutal"]):
121
- heaviness = 0.85
122
- elif any(word in prompt for word in ["light", "slim", "thin", "delicate"]):
123
- heaviness = 0.35
124
-
125
- if any(word in prompt for word in ["industrial", "steel", "metal"]):
126
- family = "industrial"
127
- elif any(word in prompt for word in ["structural", "truss", "frame"]):
128
- family = "structural"
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
- params = {
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
- params = {
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
- return CadSpec(
 
 
 
 
 
 
 
 
 
 
159
  output_type="2d_vector",
160
- geometry_family=family,
161
- parameters=params,
162
- style=_style_from_prompt(p, "diagrammatic"),
 
 
 
 
 
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
- params = {
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
- params = {
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
- return CadSpec(
 
 
 
 
 
 
 
182
  output_type="surface",
183
- geometry_family=family,
184
- parameters=params,
185
- style=_style_from_prompt(p, "smooth"),
 
 
 
 
 
186
  )
187
 
188
  if any(word in p for word in ["truss", "beam", "frame", "girder"]):
189
- return CadSpec(
 
 
 
 
 
 
 
 
190
  output_type="3d_solid",
191
- geometry_family="truss_beam",
192
- parameters={
193
- "span": _extract_number(p, ["span", "length"], 140),
194
- "height": _extract_number(p, ["height", "rise"], 24),
195
- "panel_count": _extract_count(p, ["panels", "bays", "segments"], 7),
196
- "member_size": _extract_number(p, ["member", "thickness", "depth"], 3),
197
- },
198
- style=_style_from_prompt(p, "structural"),
 
 
 
 
 
 
 
199
  )
200
 
201
  if any(word in p for word in ["tower", "block", "monolith"]):
202
- return CadSpec(
 
 
 
 
 
 
 
 
203
  output_type="3d_solid",
204
- geometry_family="tower_block",
205
- parameters={
206
- "width": _extract_number(p, ["width"], 30),
207
- "length": _extract_number(p, ["length", "depth"], 30),
208
- "height": _extract_number(p, ["height"], 120),
209
- "notch": _extract_number(p, ["notch", "cut"], 10),
210
- },
211
- style=_style_from_prompt(p, "industrial"),
 
 
 
 
 
 
212
  )
213
 
214
- return CadSpec(
 
 
 
 
 
 
 
 
 
215
  output_type="3d_solid",
216
- geometry_family="bracket_plate",
217
- parameters={
218
- "width": _extract_number(p, ["width", "span"], 80),
219
- "height": _extract_number(p, ["height"], 50),
220
- "thickness": _extract_number(p, ["thickness"], 6),
221
- "hole_count": _extract_count(p, ["holes", "bolt holes", "openings"], 4),
222
- "hole_diameter": _extract_number(p, ["hole diameter", "hole", "diameter"], 10),
223
- },
224
- style=_style_from_prompt(p, "industrial"),
 
 
 
 
 
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 = _infer_spec(safe_prompt, payload.mode, payload.output_type)
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 JobRecord(**job)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 STL preview
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 stl_path:
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
- return stl_path, stl_path, step_path, combined_logs, final_summary
 
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