noahlee1234 commited on
Commit
010bc6c
·
0 Parent(s):

Initial NaturalCAD MVP

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +5 -0
  2. .gitignore +16 -0
  3. README.md +49 -0
  4. app.py +28 -0
  5. apps/backend-api/README.md +39 -0
  6. apps/backend-api/app/__init__.py +1 -0
  7. apps/backend-api/app/config.py +24 -0
  8. apps/backend-api/app/db.py +38 -0
  9. apps/backend-api/app/main.py +336 -0
  10. apps/backend-api/app/models.py +69 -0
  11. apps/backend-api/app/repository.py +85 -0
  12. apps/backend-api/app/store.py +7 -0
  13. apps/backend-api/db/README.md +12 -0
  14. apps/backend-api/db/schema.sql +59 -0
  15. apps/backend-api/requirements.txt +6 -0
  16. apps/gradio-demo/.gitignore +5 -0
  17. apps/gradio-demo/HF_SPACE_NOTES.md +21 -0
  18. apps/gradio-demo/README.md +54 -0
  19. apps/gradio-demo/app/main.py +496 -0
  20. apps/gradio-demo/artifacts/.gitkeep +0 -0
  21. apps/gradio-demo/artifacts/logs/.gitkeep +0 -0
  22. apps/gradio-demo/requirements.txt +3 -0
  23. apps/viewer/README.md +8 -0
  24. apps/web-visualizer/README.md +49 -0
  25. apps/web-visualizer/artifacts/.gitkeep +0 -0
  26. apps/web-visualizer/docs/architecture.md +26 -0
  27. apps/web-visualizer/docs/milestone-01.md +34 -0
  28. apps/web-visualizer/index.html +13 -0
  29. apps/web-visualizer/package-lock.json +0 -0
  30. apps/web-visualizer/package.json +30 -0
  31. apps/web-visualizer/server/index.js +93 -0
  32. apps/web-visualizer/server/runner.py +85 -0
  33. apps/web-visualizer/src/App.tsx +270 -0
  34. apps/web-visualizer/src/main.tsx +10 -0
  35. apps/web-visualizer/src/styles.css +169 -0
  36. apps/web-visualizer/tsconfig.json +19 -0
  37. apps/web-visualizer/tsconfig.node.json +14 -0
  38. apps/web-visualizer/vite.config.ts +16 -0
  39. archive/README.md +62 -0
  40. archive/gradio-demo-backend-legacy/API_CONTRACT.md +53 -0
  41. archive/gradio-demo-backend-legacy/README.md +26 -0
  42. archive/gradio-demo-backend-legacy/app/__init__.py +1 -0
  43. archive/gradio-demo-backend-legacy/app/main.py +272 -0
  44. archive/gradio-demo-backend-legacy/requirements.txt +5 -0
  45. archive/package.json +9 -0
  46. docs/HF_SPACE_NOTES.md +21 -0
  47. docs/architecture.md +26 -0
  48. docs/backend-v0.md +254 -0
  49. docs/hf-space-deploy-checklist.md +34 -0
  50. docs/hf-space-mvp.md +96 -0
.gitattributes ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ *.jpg filter=lfs diff=lfs merge=lfs -text
2
+ *.jpeg filter=lfs diff=lfs merge=lfs -text
3
+ *.png filter=lfs diff=lfs merge=lfs -text
4
+ *.gif filter=lfs diff=lfs merge=lfs -text
5
+ *.webp filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .DS_Store
2
+ node_modules/
3
+ dist/
4
+ .venv/
5
+ __pycache__/
6
+ *.pyc
7
+ *.pyo
8
+ **/artifacts/*
9
+ !**/artifacts/.gitkeep
10
+ !**/artifacts/runs/
11
+ !**/artifacts/runs/.gitkeep
12
+ !**/artifacts/logs/
13
+ !**/artifacts/logs/.gitkeep
14
+ .env
15
+ .env.*
16
+ .vite/
README.md ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: NaturalCAD
3
+ emoji: 🧱
4
+ colorFrom: slate
5
+ colorTo: blue
6
+ sdk: gradio
7
+ sdk_version: 4.44.1
8
+ app_file: app.py
9
+ pinned: false
10
+ ---
11
+
12
+ # NaturalCAD
13
+
14
+ NaturalCAD is a public prompt-to-CAD demo built around build123d.
15
+
16
+ The immediate goal is simple: get it in front of people fast, learn from real prompts, and improve from actual usage instead of guessing in a vacuum.
17
+
18
+ ## Current app path
19
+
20
+ - `app.py` - Hugging Face Space entrypoint
21
+ - `requirements.txt` - Space runtime dependencies
22
+ - `apps/gradio-demo` - primary MVP app
23
+
24
+ ## Other repo areas
25
+
26
+ - `apps/backend-api` - later-phase backend scaffold if we outgrow a Space-only MVP
27
+ - `apps/web-visualizer` - earlier React/Vite prototype
28
+ - `docs/` - product and deployment planning
29
+ - `archive/` - older or superseded material kept for reference
30
+
31
+ ## Local run
32
+
33
+ ```bash
34
+ pip install -r requirements.txt
35
+ python app.py
36
+ ```
37
+
38
+ ## Deployment posture
39
+
40
+ Right now the priority is a lean Hugging Face Space MVP.
41
+ 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.
42
+
43
+ ## Key docs
44
+
45
+ - `docs/hf-space-mvp.md`
46
+ - `docs/hf-space-deploy-checklist.md`
47
+ - `docs/publish-checklist.md`
48
+ - `docs/backend-v0.md`
49
+ - `docs/security-policy-v0.md`
app.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import importlib.util
4
+ from pathlib import Path
5
+
6
+ ROOT = Path(__file__).resolve().parent
7
+ SOURCE = ROOT / 'apps' / 'gradio-demo' / 'app' / 'main.py'
8
+
9
+ spec = importlib.util.spec_from_file_location('naturalcad_gradio_main', SOURCE)
10
+ if spec is None or spec.loader is None:
11
+ raise RuntimeError(f'Could not load NaturalCAD app from {SOURCE}')
12
+
13
+ module = importlib.util.module_from_spec(spec)
14
+ spec.loader.exec_module(module)
15
+
16
+ demo = module.build_ui()
17
+
18
+ if __name__ == '__main__':
19
+ demo.launch(
20
+ server_name='0.0.0.0',
21
+ server_port=7860,
22
+ css="""
23
+ #model-viewer {height: 620px !important; border-radius: 18px; overflow: hidden;}
24
+ .log-box textarea {font-family: 'JetBrains Mono', monospace; font-size: 13px;}
25
+ .gradio-container {max-width: 1380px !important;}
26
+ button.primary {font-weight: 700;}
27
+ """,
28
+ )
apps/backend-api/README.md ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # NaturalCAD Backend API
2
+
3
+ FastAPI backend for NaturalCAD.
4
+
5
+ ## Purpose
6
+ - keep secrets off the Hugging Face Space
7
+ - validate and rate-limit public requests
8
+ - create jobs and track status
9
+ - generate structured CAD specs
10
+ - provide a clean place for worker, DB, and storage integration
11
+
12
+ ## Run locally
13
+
14
+ ```bash
15
+ cd apps/backend-api
16
+ python3 -m venv .venv
17
+ .venv/bin/pip install -r requirements.txt
18
+ .venv/bin/uvicorn app.main:app --reload --port 8010
19
+ ```
20
+
21
+ ## Initial endpoints
22
+ - `GET /v1/health`
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.
35
+
36
+ ## Supabase readiness
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
apps/backend-api/app/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # NaturalCAD backend package
apps/backend-api/app/config.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+
6
+ from dotenv import load_dotenv
7
+
8
+ load_dotenv()
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Settings:
13
+ app_name: str = "NaturalCAD Backend"
14
+ app_env: str = os.getenv("APP_ENV", "development")
15
+ api_shared_secret: str = os.getenv("API_SHARED_SECRET", "")
16
+ rate_limit_per_hour: int = int(os.getenv("RATE_LIMIT_PER_HOUR", "20"))
17
+ max_prompt_length: int = int(os.getenv("MAX_PROMPT_LENGTH", "1000"))
18
+ database_url: str = os.getenv("DATABASE_URL", "")
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()
apps/backend-api/app/db.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ from .config import settings
8
+
9
+ try:
10
+ import psycopg
11
+ except Exception: # noqa: BLE001
12
+ psycopg = None
13
+
14
+
15
+ @dataclass
16
+ class DatabaseState:
17
+ enabled: bool
18
+ reason: str | None = None
19
+
20
+
21
+ def get_database_state() -> DatabaseState:
22
+ if not settings.database_url:
23
+ return DatabaseState(enabled=False, reason="DATABASE_URL not configured")
24
+ if psycopg is None:
25
+ return DatabaseState(enabled=False, reason="psycopg not installed")
26
+ return DatabaseState(enabled=True)
27
+
28
+
29
+ def connect():
30
+ state = get_database_state()
31
+ if not state.enabled:
32
+ raise RuntimeError(state.reason or "Database unavailable")
33
+ assert psycopg is not None
34
+ return psycopg.connect(settings.database_url)
35
+
36
+
37
+ def serialize_json(value: Any) -> str:
38
+ return json.dumps(value)
apps/backend-api/app/main.py ADDED
@@ -0,0 +1,336 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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")
31
+
32
+
33
+ def _rate_limit_key(request: Request, session_id: str | None) -> str:
34
+ client_ip = request.client.host if request.client else "unknown"
35
+ return session_id or client_ip
36
+
37
+
38
+ def _enforce_rate_limit(key: str) -> None:
39
+ now = time.time()
40
+ cutoff = now - 3600
41
+ bucket = _REQUESTS[key]
42
+ while bucket and bucket[0] < cutoff:
43
+ bucket.popleft()
44
+ if len(bucket) >= settings.rate_limit_per_hour:
45
+ raise HTTPException(status_code=429, detail="Rate limit exceeded")
46
+ bucket.append(now)
47
+
48
+
49
+ def _normalize_prompt(prompt: str) -> str:
50
+ return " ".join(prompt.lower().strip().split())
51
+
52
+
53
+ def _assess_prompt(prompt: str) -> tuple[bool, list[str]]:
54
+ reasons: list[str] = []
55
+ suspicious_patterns = [
56
+ r"```",
57
+ r"\bimport\b",
58
+ r"\bexec\b",
59
+ r"\beval\b",
60
+ r"__import__",
61
+ r"subprocess",
62
+ r"os\.system",
63
+ r"rm\s+-rf",
64
+ r"curl\s+",
65
+ r"wget\s+",
66
+ ]
67
+ for pattern in suspicious_patterns:
68
+ if re.search(pattern, prompt):
69
+ reasons.append(f"matched:{pattern}")
70
+
71
+ if len(prompt) > settings.max_prompt_length:
72
+ reasons.append("too_long")
73
+
74
+ if prompt.count("\n") > 20:
75
+ reasons.append("too_many_lines")
76
+
77
+ return bool(reasons), reasons
78
+
79
+
80
+ def _prompt_hash(prompt: str, mode: str, output_type: str) -> str:
81
+ digest = hashlib.sha256(f"{mode}|{output_type}|{prompt}".encode()).hexdigest()
82
+ return digest[:16]
83
+
84
+
85
+ def _extract_number(prompt: str, keywords: list[str], default: float) -> float:
86
+ for keyword in keywords:
87
+ pattern = rf"{keyword}\s*(?:of|=|:)?\s*(\d+(?:\.\d+)?)"
88
+ match = re.search(pattern, prompt)
89
+ if match:
90
+ return float(match.group(1))
91
+ return default
92
+
93
+
94
+ def _extract_count(prompt: str, nouns: list[str], default: int) -> int:
95
+ word_map = {
96
+ "one": 1,
97
+ "two": 2,
98
+ "three": 3,
99
+ "four": 4,
100
+ "five": 5,
101
+ "six": 6,
102
+ "seven": 7,
103
+ "eight": 8,
104
+ "nine": 9,
105
+ "ten": 10,
106
+ }
107
+ for noun in nouns:
108
+ digit_match = re.search(rf"(\d+)\s+{noun}", prompt)
109
+ if digit_match:
110
+ return int(digit_match.group(1))
111
+ for word, value in word_map.items():
112
+ if re.search(rf"{word}\s+{noun}", prompt):
113
+ return value
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
+
228
+ def _generate_spec(payload: GenerateSpecRequest) -> GenerateSpecResponse:
229
+ normalized = _normalize_prompt(payload.prompt)
230
+ suspicious_input, suspicious_reasons = _assess_prompt(payload.prompt)
231
+ key = _prompt_hash(normalized, payload.mode, payload.output_type)
232
+
233
+ if key in _CACHE:
234
+ cached = dict(_CACHE[key])
235
+ cached["cached"] = True
236
+ return GenerateSpecResponse(**cached)
237
+
238
+ safe_prompt = normalized
239
+ fallback_level = "normal"
240
+ notes = [
241
+ f"Mode: {payload.mode}",
242
+ f"Output type: {payload.output_type}",
243
+ ]
244
+
245
+ if suspicious_input:
246
+ safe_prompt = "simple industrial bracket plate with 4 holes"
247
+ fallback_level = "guardrailed"
248
+ notes.extend([
249
+ "Input looked more like code or hostile instructions than a CAD prompt.",
250
+ "Using a safe fallback prompt for MVP robustness.",
251
+ *[f"Guardrail: {reason}" for reason in suspicious_reasons],
252
+ ])
253
+ elif len(normalized.split()) < 3:
254
+ fallback_level = "underspecified"
255
+ notes.extend([
256
+ "Prompt was underspecified.",
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,
269
+ fallback_level=fallback_level,
270
+ )
271
+ _CACHE[key] = response.model_dump()
272
+ return response
273
+
274
+
275
+ @app.get("/v1/health", response_model=HealthResponse)
276
+ def health() -> HealthResponse:
277
+ return HealthResponse(
278
+ status="ok",
279
+ environment=settings.app_env,
280
+ rate_limit_per_hour=settings.rate_limit_per_hour,
281
+ cache_entries=len(_CACHE),
282
+ jobs_in_memory=len(_JOBS),
283
+ )
284
+
285
+
286
+ @app.post("/v1/generate-spec", response_model=GenerateSpecResponse)
287
+ def generate_spec(payload: GenerateSpecRequest, request: Request, x_api_key: str | None = Header(default=None)) -> GenerateSpecResponse:
288
+ _check_auth(x_api_key)
289
+ _enforce_rate_limit(_rate_limit_key(request, payload.session_id))
290
+ return _generate_spec(payload)
291
+
292
+
293
+ @app.post("/v1/jobs", response_model=JobRecord)
294
+ def create_job(payload: CreateJobRequest, request: Request, x_api_key: str | None = Header(default=None)) -> JobRecord:
295
+ _check_auth(x_api_key)
296
+ _enforce_rate_limit(_rate_limit_key(request, payload.session_id))
297
+
298
+ if len(payload.prompt.strip()) > settings.max_prompt_length:
299
+ raise HTTPException(status_code=400, detail="Prompt too long")
300
+
301
+ spec_response = _generate_spec(GenerateSpecRequest(**payload.model_dump()))
302
+ job = JobRecord(
303
+ status="validated",
304
+ prompt=payload.prompt,
305
+ mode=payload.mode,
306
+ output_type=payload.output_type,
307
+ session_id=payload.session_id,
308
+ prompt_hash=spec_response.prompt_hash,
309
+ spec=spec_response.spec,
310
+ notes=[
311
+ "Job created in backend scaffold.",
312
+ "Next step: persist to Supabase and enqueue worker execution.",
313
+ ],
314
+ )
315
+ job.status = cast(str, "queued")
316
+ save_job(job)
317
+ return job
318
+
319
+
320
+ @app.get("/v1/jobs/{job_id}", response_model=JobRecord)
321
+ def get_job(job_id: str, x_api_key: str | None = Header(default=None)) -> JobRecord:
322
+ _check_auth(x_api_key)
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("/")
330
+ def root() -> dict[str, str]:
331
+ return {
332
+ "message": settings.app_name,
333
+ "docs": "/docs",
334
+ "health": "/v1/health",
335
+ "jobs": "/v1/jobs",
336
+ }
apps/backend-api/app/models.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal
4
+ from uuid import uuid4
5
+
6
+ from pydantic import BaseModel, Field
7
+
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):
14
+ prompt: str = Field(min_length=3, max_length=1000)
15
+ mode: ModeType = "part"
16
+ output_type: OutputType = "3d_solid"
17
+ session_id: str | None = None
18
+
19
+
20
+ class CreateJobRequest(BaseModel):
21
+ prompt: str = Field(min_length=3, max_length=1000)
22
+ mode: ModeType = "part"
23
+ output_type: OutputType = "3d_solid"
24
+ session_id: str | None = None
25
+
26
+
27
+ class CadStyle(BaseModel):
28
+ family: str = "industrial"
29
+ heaviness: float = 0.6
30
+
31
+
32
+ class CadSpec(BaseModel):
33
+ output_type: OutputType
34
+ geometry_family: str
35
+ units: str = "mm"
36
+ parameters: dict[str, int | float | str]
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"
54
+ prompt: str
55
+ mode: ModeType = "part"
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):
65
+ status: str
66
+ environment: str
67
+ rate_limit_per_hour: int
68
+ cache_entries: int
69
+ jobs_in_memory: int
apps/backend-api/app/repository.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ 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:
11
+ db_state = get_database_state()
12
+ if not db_state.enabled:
13
+ _JOBS[job.id] = job.model_dump()
14
+ return
15
+
16
+ with connect() as conn:
17
+ with conn.cursor() as cur:
18
+ cur.execute(
19
+ """
20
+ insert into jobs (
21
+ id, status, prompt, mode, output_type, client_session_id,
22
+ prompt_hash, spec_json, notes_json, error_text
23
+ )
24
+ values (%s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s::jsonb, %s)
25
+ on conflict (id) do update set
26
+ status = excluded.status,
27
+ prompt = excluded.prompt,
28
+ mode = excluded.mode,
29
+ output_type = excluded.output_type,
30
+ client_session_id = excluded.client_session_id,
31
+ prompt_hash = excluded.prompt_hash,
32
+ spec_json = excluded.spec_json,
33
+ notes_json = excluded.notes_json,
34
+ error_text = excluded.error_text,
35
+ updated_at = now()
36
+ """,
37
+ (
38
+ job.id,
39
+ job.status,
40
+ job.prompt,
41
+ job.mode,
42
+ job.output_type,
43
+ job.session_id,
44
+ job.prompt_hash,
45
+ serialize_json(job.spec.model_dump() if job.spec else None),
46
+ serialize_json(job.notes),
47
+ job.error,
48
+ ),
49
+ )
50
+ conn.commit()
51
+
52
+
53
+ def get_job(job_id: str) -> dict[str, Any] | None:
54
+ db_state = get_database_state()
55
+ if not db_state.enabled:
56
+ return _JOBS.get(job_id)
57
+
58
+ with connect() as conn:
59
+ with conn.cursor() as cur:
60
+ cur.execute(
61
+ """
62
+ select id, status, prompt, mode, output_type, client_session_id,
63
+ prompt_hash, spec_json, notes_json, error_text
64
+ from jobs
65
+ where id = %s
66
+ """,
67
+ (job_id,),
68
+ )
69
+ row = cur.fetchone()
70
+ if not row:
71
+ return None
72
+
73
+ id_, status, prompt, mode, output_type, client_session_id, prompt_hash, spec_json, notes_json, error_text = row
74
+ return {
75
+ "id": str(id_),
76
+ "status": status,
77
+ "prompt": prompt,
78
+ "mode": mode,
79
+ "output_type": output_type,
80
+ "session_id": client_session_id,
81
+ "prompt_hash": prompt_hash,
82
+ "spec": spec_json,
83
+ "notes": notes_json or [],
84
+ "error": error_text,
85
+ }
apps/backend-api/app/store.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from collections import defaultdict, deque
4
+
5
+ _REQUESTS: dict[str, deque[float]] = defaultdict(deque)
6
+ _CACHE: dict[str, dict] = {}
7
+ _JOBS: dict[str, dict] = {}
apps/backend-api/db/README.md ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # NaturalCAD backend database
2
+
3
+ This folder holds the first Postgres schema for NaturalCAD v0.
4
+
5
+ ## Target
6
+ - Supabase Postgres
7
+
8
+ ## Files
9
+ - `schema.sql` - initial jobs, artifacts, audit_events, and rate_limits tables
10
+
11
+ ## Notes
12
+ This is the first persistence layer replacing the in-memory backend store. Apply this schema to Supabase once the project/account is ready.
apps/backend-api/db/schema.sql ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- NaturalCAD Backend v0 schema
2
+ -- Target: Supabase Postgres
3
+
4
+ create extension if not exists pgcrypto;
5
+
6
+ create table if not exists jobs (
7
+ id uuid primary key default gen_random_uuid(),
8
+ created_at timestamptz not null default now(),
9
+ updated_at timestamptz not null default now(),
10
+ status text not null check (status in ('submitted', 'validated', 'queued', 'running', 'completed', 'failed')),
11
+ prompt text not null,
12
+ normalized_prompt text,
13
+ mode text not null,
14
+ output_type text not null,
15
+ client_session_id text,
16
+ prompt_hash text,
17
+ spec_json jsonb,
18
+ error_text text,
19
+ model_info_json jsonb,
20
+ notes_json jsonb not null default '[]'::jsonb
21
+ );
22
+
23
+ create index if not exists idx_jobs_status on jobs (status);
24
+ create index if not exists idx_jobs_created_at on jobs (created_at desc);
25
+ create index if not exists idx_jobs_prompt_hash on jobs (prompt_hash);
26
+
27
+ create table if not exists artifacts (
28
+ id uuid primary key default gen_random_uuid(),
29
+ created_at timestamptz not null default now(),
30
+ job_id uuid not null references jobs(id) on delete cascade,
31
+ kind text not null check (kind in ('stl', 'step', 'preview', 'log')),
32
+ storage_key text not null,
33
+ size_bytes bigint,
34
+ expires_at timestamptz
35
+ );
36
+
37
+ create index if not exists idx_artifacts_job_id on artifacts (job_id);
38
+ create index if not exists idx_artifacts_kind on artifacts (kind);
39
+
40
+ create table if not exists audit_events (
41
+ id uuid primary key default gen_random_uuid(),
42
+ created_at timestamptz not null default now(),
43
+ job_id uuid references jobs(id) on delete cascade,
44
+ event_type text not null,
45
+ details_json jsonb not null default '{}'::jsonb
46
+ );
47
+
48
+ create index if not exists idx_audit_events_job_id on audit_events (job_id);
49
+ create index if not exists idx_audit_events_event_type on audit_events (event_type);
50
+
51
+ create table if not exists rate_limits (
52
+ id uuid primary key default gen_random_uuid(),
53
+ created_at timestamptz not null default now(),
54
+ key text not null,
55
+ window_start timestamptz not null,
56
+ request_count integer not null default 0
57
+ );
58
+
59
+ create index if not exists idx_rate_limits_key_window on rate_limits (key, window_start desc);
apps/backend-api/requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ fastapi>=0.115.0
2
+ 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
apps/gradio-demo/.gitignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ .venv/
2
+ __pycache__/
3
+ artifacts/model.stl
4
+ artifacts/model.step
5
+ artifacts/runs/
apps/gradio-demo/HF_SPACE_NOTES.md ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # NaturalCAD HF Space Notes
2
+
3
+ ## Current intent
4
+ - Public-facing NaturalCAD app
5
+ - build123d-backed execution loop
6
+ - Noah will wire a service endpoint for LLM generation later
7
+
8
+ ## Current prototype state
9
+ - Gradio UI
10
+ - real build123d execution
11
+ - STL preview
12
+ - STL + STEP downloads
13
+ - starter sample picker
14
+ - prompt note field for future LLM integration
15
+ - archived per-run artifacts under `artifacts/runs/`
16
+
17
+ ## Next likely steps
18
+ - add endpoint config pattern for external LLM service
19
+ - convert prompt note into real prompt-to-code flow
20
+ - improve public-facing examples
21
+ - add safe execution constraints for Spaces
apps/gradio-demo/README.md ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # NaturalCAD
2
+
3
+ Gradio prototype for NaturalCAD, a public natural-language CAD modeler built on build123d.
4
+
5
+ ## Purpose
6
+
7
+ - Fast Hugging Face Spaces deployment
8
+ - Test prompt → spec → CAD loop
9
+ - Validate interaction model before deeper productization
10
+ - Keep the MVP portable enough to offload execution later if Space limits become a problem
11
+
12
+ ## Features
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
26
+ cd ../backend-api
27
+ python3 -m venv .venv
28
+ .venv/bin/pip install -r requirements.txt
29
+ .venv/bin/uvicorn app.main:app --reload --port 8010
30
+ ```
31
+
32
+ Then run the Gradio app:
33
+
34
+ ```bash
35
+ pip install -r requirements.txt
36
+ python app/main.py
37
+ ```
38
+
39
+ Current Space-oriented dependency note:
40
+ - `build123d==0.10.0` is now declared directly in `requirements.txt`
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
apps/gradio-demo/app/main.py ADDED
@@ -0,0 +1,496 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Gradio app for live build123d geometry execution and export."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+ import time
13
+ import traceback
14
+ import uuid
15
+ from datetime import datetime, timezone
16
+ 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()
23
+ BACKEND_API_KEY = os.getenv("NATURALCAD_API_KEY", os.getenv("NL_CAD_API_KEY", ""))
24
+ BACKEND_TIMEOUT_SECONDS = float(os.getenv("NATURALCAD_BACKEND_TIMEOUT", "4"))
25
+ ARTIFACTS_DIR = Path(__file__).parent.parent / "artifacts"
26
+ RUNS_DIR = ARTIFACTS_DIR / "runs"
27
+ LOGS_DIR = ARTIFACTS_DIR / "logs"
28
+ RUN_LOG_PATH = LOGS_DIR / "runs.jsonl"
29
+ ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True)
30
+ RUNS_DIR.mkdir(parents=True, exist_ok=True)
31
+ LOGS_DIR.mkdir(parents=True, exist_ok=True)
32
+
33
+ EXAMPLE_PROMPTS = [
34
+ ["Heavy steel bracket with 4 bolt holes, 90 mm wide, 8 mm thick", "part", "3d_solid"],
35
+ ["Light structural truss beam with 9 panels and a 180 mm span", "part", "3d_solid"],
36
+ ["Industrial notched tower block, 140 mm tall", "part", "3d_solid"],
37
+ ["Smooth roof canopy surface, 200 mm span, shallow rise", "part", "surface"],
38
+ ["Bracket plate profile with 6 holes for a laser-cut sketch", "sketch", "2d_vector"],
39
+ ]
40
+
41
+ DEFAULT_CODE = '''from build123d import *
42
+
43
+ width = 80
44
+ height = 50
45
+ thickness = 6
46
+ hole_diameter = 10
47
+
48
+ with BuildPart() as bp:
49
+ with BuildSketch(Plane.XY) as base:
50
+ Rectangle(width, height)
51
+ with GridLocations(width * 0.6, height * 0.6, 2, 2):
52
+ Circle(hole_diameter / 2)
53
+ extrude(amount=thickness)
54
+
55
+ 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", {})
63
+
64
+ if geometry_family == "tower_block":
65
+ width = params.get("width", 30)
66
+ length = params.get("length", 30)
67
+ height = params.get("height", 120)
68
+ notch = params.get("notch", 10)
69
+ return f'''from build123d import *
70
+
71
+ width = {width}
72
+ length = {length}
73
+ height = {height}
74
+ notch = {notch}
75
+
76
+ with BuildPart() as bp:
77
+ Box(width, length, height)
78
+ with Locations((0, 0, height / 4), (0, 0, -height / 4)):
79
+ Box(width + 2, notch, notch, mode=Mode.SUBTRACT)
80
+ Box(notch, length + 2, notch, mode=Mode.SUBTRACT)
81
+
82
+ result = bp.part
83
+ '''
84
+
85
+ if geometry_family == "truss_beam":
86
+ span = params.get("span", 140)
87
+ height = params.get("height", 24)
88
+ panel_count = max(3, int(params.get("panel_count", 7)))
89
+ member_size = params.get("member_size", 3)
90
+ return f'''from build123d import *
91
+
92
+ span = {span}
93
+ height = {height}
94
+ chord = {member_size}
95
+ post = {member_size}
96
+ panel_count = {panel_count}
97
+
98
+ with BuildPart() as bp:
99
+ with Locations((0, 0, chord / 2)):
100
+ Box(span, chord, chord)
101
+ with Locations((0, 0, height - chord / 2)):
102
+ Box(span, chord, chord)
103
+
104
+ panel = span / panel_count
105
+ post_locations = [(-span / 2 + i * panel, 0, height / 2) for i in range(panel_count + 1)]
106
+ with Locations(*post_locations):
107
+ Box(post, chord, height)
108
+
109
+ result = bp.part
110
+ '''
111
+
112
+ if geometry_family == "truss_elevation":
113
+ span = params.get("span", 140)
114
+ height = params.get("height", 24)
115
+ panel_count = max(3, int(params.get("panel_count", 7)))
116
+ member_size = params.get("member_size", 3)
117
+ preview_thickness = params.get("preview_thickness", 1)
118
+ return f'''from build123d import *
119
+
120
+ span = {span}
121
+ height = {height}
122
+ panel_count = {panel_count}
123
+ member_size = {member_size}
124
+ preview_thickness = {preview_thickness}
125
+
126
+ with BuildPart() as bp:
127
+ with BuildSketch(Plane.XY) as sk:
128
+ Rectangle(span, member_size, align=(Align.CENTER, Align.CENTER))
129
+ with Locations((0, height)):
130
+ Rectangle(span, member_size, align=(Align.CENTER, Align.CENTER))
131
+ panel = span / panel_count
132
+ for i in range(panel_count + 1):
133
+ x = -span / 2 + i * panel
134
+ with Locations((x, height / 2)):
135
+ Rectangle(member_size, height, align=(Align.CENTER, Align.CENTER))
136
+ extrude(amount=preview_thickness)
137
+
138
+ result = bp.part
139
+ '''
140
+
141
+ if geometry_family in {"canopy_surface", "lofted_panel"} or output_type == "surface":
142
+ if geometry_family == "canopy_surface":
143
+ span = params.get("span", 160)
144
+ depth = params.get("depth", 90)
145
+ peak_height = params.get("peak_height", 38)
146
+ thickness = params.get("thickness", 2)
147
+ return f'''from build123d import *
148
+
149
+ span = {span}
150
+ depth = {depth}
151
+ peak_height = {peak_height}
152
+ thickness = {thickness}
153
+
154
+ with BuildPart() as bp:
155
+ with BuildSketch(Plane.XY.offset(0)) as s1:
156
+ Rectangle(span, depth)
157
+ with BuildSketch(Plane.XY.offset(peak_height)) as s2:
158
+ Rectangle(span * 0.65, depth * 0.65)
159
+ loft()
160
+ offset(amount=thickness)
161
+
162
+ result = bp.part
163
+ '''
164
+ width = params.get("width", 80)
165
+ depth = params.get("depth", 50)
166
+ rise = params.get("rise", 18)
167
+ thickness = params.get("thickness", 2)
168
+ return f'''from build123d import *
169
+
170
+ width = {width}
171
+ depth = {depth}
172
+ rise = {rise}
173
+ thickness = {thickness}
174
+
175
+ with BuildPart() as bp:
176
+ with BuildSketch(Plane.XY.offset(0)) as s1:
177
+ Rectangle(width, depth)
178
+ with BuildSketch(Plane.XY.offset(rise)) as s2:
179
+ Rectangle(width * 0.55, depth * 0.55)
180
+ loft()
181
+ offset(amount=thickness)
182
+
183
+ result = bp.part
184
+ '''
185
+
186
+ width = params.get("width", 80)
187
+ height = params.get("height", 50)
188
+ hole_count = max(1, int(params.get("hole_count", 4)))
189
+ hole_diameter = params.get("hole_diameter", 10)
190
+ x_count = max(1, round(hole_count ** 0.5))
191
+ y_count = max(1, (hole_count + x_count - 1) // x_count)
192
+
193
+ if output_type == "2d_vector":
194
+ preview_thickness = params.get("preview_thickness", 1)
195
+ return f'''from build123d import *
196
+
197
+ width = {width}
198
+ height = {height}
199
+ hole_diameter = {hole_diameter}
200
+ preview_thickness = {preview_thickness}
201
+
202
+ with BuildPart() as bp:
203
+ with BuildSketch(Plane.XY) as base:
204
+ Rectangle(width, height)
205
+ with GridLocations(width * 0.6, height * 0.6, {x_count}, {y_count}):
206
+ Circle(hole_diameter / 2, mode=Mode.SUBTRACT)
207
+ extrude(amount=preview_thickness)
208
+
209
+ result = bp.part
210
+ '''
211
+
212
+ thickness = params.get("thickness", 6)
213
+ return f'''from build123d import *
214
+
215
+ width = {width}
216
+ height = {height}
217
+ thickness = {thickness}
218
+ hole_diameter = {hole_diameter}
219
+
220
+ with BuildPart() as bp:
221
+ with BuildSketch(Plane.XY) as base:
222
+ Rectangle(width, height)
223
+ with GridLocations(width * 0.6, height * 0.6, {x_count}, {y_count}):
224
+ Circle(hole_diameter / 2, mode=Mode.SUBTRACT)
225
+ extrude(amount=thickness)
226
+
227
+ result = bp.part
228
+ '''
229
+
230
+
231
+ def create_job(prompt: str, mode: str, output_type: str) -> tuple[dict | None, str]:
232
+ if not prompt.strip():
233
+ return None, ""
234
+
235
+ if not BACKEND_URL:
236
+ return None, json.dumps({"info": "backend disabled", "detail": "No NATURALCAD_BACKEND_URL configured."}, indent=2)
237
+
238
+ payload = json.dumps({"prompt": prompt, "mode": mode, "output_type": output_type}).encode()
239
+ headers = {"Content-Type": "application/json"}
240
+ if BACKEND_API_KEY:
241
+ headers["x-api-key"] = BACKEND_API_KEY
242
+
243
+ req = request.Request(
244
+ f"{BACKEND_URL.rstrip('/')}/v1/jobs",
245
+ data=payload,
246
+ headers=headers,
247
+ method="POST",
248
+ )
249
+
250
+ try:
251
+ with request.urlopen(req, timeout=BACKEND_TIMEOUT_SECONDS) as response:
252
+ data = json.loads(response.read().decode())
253
+ return data, json.dumps(data, indent=2)
254
+ except error.HTTPError as exc:
255
+ detail = exc.read().decode() if exc.fp else str(exc)
256
+ return None, json.dumps({"error": f"backend http {exc.code}", "detail": detail}, indent=2)
257
+ except Exception as exc: # noqa: BLE001
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()
274
+ run_id = uuid.uuid4().hex[:8]
275
+
276
+ with tempfile.TemporaryDirectory() as tmpdir:
277
+ source_file = Path(tmpdir) / "user_script.py"
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():
284
+ logs.append(f"Prompt: {prompt.strip()}")
285
+ logs.append("Running build123d script...")
286
+
287
+ runner_code = f'''
288
+ import sys
289
+ from pathlib import Path
290
+ from build123d import export_stl, export_step
291
+
292
+ source_path = Path(r"{source_file}")
293
+ user_globals = {{}}
294
+ exec(compile(source_path.read_text(), str(source_path), "exec"), user_globals)
295
+
296
+ candidate = user_globals.get("result")
297
+ if candidate is None:
298
+ sys.exit("No `result` geometry found after execution.")
299
+
300
+ def coerce_shape(obj):
301
+ if obj is None:
302
+ return None
303
+ if hasattr(obj, "wrapped"):
304
+ return obj
305
+ for attr in ("part", "shape", "solid", "obj"):
306
+ value = getattr(obj, attr, None)
307
+ if value is not None and not callable(value):
308
+ obj = value
309
+ if hasattr(obj, "wrapped"):
310
+ return obj
311
+ return obj
312
+
313
+ shape = coerce_shape(candidate)
314
+ if shape is None:
315
+ sys.exit("Could not extract exportable shape from `result`.")
316
+
317
+ export_stl(shape, r"{stl_file}")
318
+ export_step(shape, r"{step_file}")
319
+ print("STL exported to {stl_file}")
320
+ print("STEP exported to {step_file}")
321
+ '''
322
+
323
+ runner_file = Path(tmpdir) / "_runner.py"
324
+ runner_file.write_text(runner_code)
325
+
326
+ try:
327
+ result = subprocess.run(
328
+ [BUILD123D_PYTHON, str(runner_file)],
329
+ capture_output=True,
330
+ text=True,
331
+ timeout=60,
332
+ )
333
+ if result.stdout:
334
+ logs.append(result.stdout.strip())
335
+ if result.stderr:
336
+ logs.append(f"[stderr] {result.stderr.strip()}")
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}.")
347
+ except subprocess.TimeoutExpired:
348
+ logs.append("Execution timed out after 60 seconds.")
349
+ except Exception as exc: # noqa: BLE001
350
+ logs.append(f"Execution error: {exc}")
351
+ logs.append(traceback.format_exc())
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):
359
+ started_at = time.time()
360
+ backend_ok = True
361
+ client_notice = None
362
+ fallback_level = "normal"
363
+ suspicious_input = False
364
+ job_data, backend_log = create_job(prompt, mode, output_type)
365
+ if job_data is None:
366
+ backend_ok = False
367
+ backend_log = backend_log or "Backend request failed."
368
+ spec = {
369
+ "output_type": output_type,
370
+ "geometry_family": "bracket_plate",
371
+ "parameters": {},
372
+ }
373
+ client_notice = "Backend was unavailable or disabled, so NaturalCAD used a simple local fallback."
374
+ fallback_level = "backend_unavailable"
375
+ else:
376
+ spec = job_data.get("spec")
377
+ suspicious_input = bool(job_data.get("suspicious_input", False))
378
+ fallback_level = job_data.get("fallback_level", "normal")
379
+ if suspicious_input:
380
+ client_notice = "Your prompt looked partly like code or unsafe instructions, so NaturalCAD used a safer interpretation for this run."
381
+ elif fallback_level == "underspecified":
382
+ client_notice = "Your prompt was pretty open-ended, so NaturalCAD filled in conservative defaults for this run."
383
+
384
+ if not spec:
385
+ _append_run_log({
386
+ "timestamp": datetime.now(timezone.utc).isoformat(),
387
+ "prompt": prompt,
388
+ "mode": mode,
389
+ "output_type": output_type,
390
+ "backend_ok": backend_ok,
391
+ "success": False,
392
+ "error": "Backend created no CAD spec.",
393
+ })
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,
408
+ "prompt": prompt,
409
+ "mode": mode,
410
+ "output_type": output_type,
411
+ "geometry_family": spec.get("geometry_family"),
412
+ "backend_ok": backend_ok,
413
+ "suspicious_input": suspicious_input,
414
+ "fallback_level": fallback_level,
415
+ "success": success,
416
+ "runtime_seconds": round(time.time() - started_at, 3),
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):
428
+ return prompt, mode, output_type
429
+
430
+
431
+ def build_ui() -> gr.Blocks:
432
+ with gr.Blocks(title="NaturalCAD", theme=gr.themes.Base()) as demo:
433
+ gr.Markdown(
434
+ "# NaturalCAD\n"
435
+ "Turn a natural-language prompt into a downloadable CAD result."
436
+ )
437
+ gr.Markdown(
438
+ "**Best for demo:** one-shot parts, frames, blocks, canopies, and simple profiles."
439
+ )
440
+
441
+ with gr.Row(equal_height=True):
442
+ with gr.Column(scale=1, min_width=360):
443
+ prompt_input = gr.Textbox(
444
+ label="Describe what you want",
445
+ placeholder="A heavy steel bracket with 4 bolt holes, 90 mm wide and 8 mm thick",
446
+ lines=6,
447
+ )
448
+ with gr.Row():
449
+ mode_picker = gr.Dropdown(choices=["part", "assembly", "sketch"], value="part", label="Mode")
450
+ output_picker = gr.Dropdown(choices=["3d_solid", "surface", "2d_vector"], value="3d_solid", label="Output")
451
+ generate_btn = gr.Button("Generate Model", variant="primary")
452
+ gr.Markdown("### Try one of these")
453
+ gr.Examples(
454
+ examples=EXAMPLE_PROMPTS,
455
+ inputs=[prompt_input, mode_picker, output_picker],
456
+ fn=use_example,
457
+ outputs=[prompt_input, mode_picker, output_picker],
458
+ cache_examples=False,
459
+ )
460
+
461
+ with gr.Column(scale=2, min_width=520):
462
+ model_viewer = gr.Model3D(label="Preview", elem_id="model-viewer", display_mode="solid")
463
+ with gr.Row():
464
+ stl_download = gr.File(label="Download STL")
465
+ step_download = gr.File(label="Download STEP")
466
+ status_output = gr.Markdown("Ready. Use the mouse to orbit, pan, and zoom the model.")
467
+
468
+ log_output = gr.Textbox(
469
+ label="Run log",
470
+ lines=7,
471
+ max_lines=20,
472
+ interactive=False,
473
+ elem_classes=["log-box"],
474
+ )
475
+
476
+ generate_btn.click(
477
+ fn=generate_from_prompt,
478
+ inputs=[prompt_input, mode_picker, output_picker],
479
+ outputs=[model_viewer, stl_download, step_download, log_output, status_output],
480
+ )
481
+
482
+ return demo
483
+
484
+
485
+ if __name__ == "__main__":
486
+ app = build_ui()
487
+ app.launch(
488
+ server_name="0.0.0.0",
489
+ server_port=7860,
490
+ css="""
491
+ #model-viewer {height: 620px !important; border-radius: 18px; overflow: hidden;}
492
+ .log-box textarea {font-family: 'JetBrains Mono', monospace; font-size: 13px;}
493
+ .gradio-container {max-width: 1380px !important;}
494
+ button.primary {font-weight: 700;}
495
+ """,
496
+ )
apps/gradio-demo/artifacts/.gitkeep ADDED
File without changes
apps/gradio-demo/artifacts/logs/.gitkeep ADDED
File without changes
apps/gradio-demo/requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ gradio>=4.0.0
2
+ trimesh
3
+ build123d==0.10.0
apps/viewer/README.md ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # Viewer App
2
+
3
+ Main application shell for the live visualizer.
4
+
5
+ Planned regions:
6
+ - left: prompt/editor/parameters
7
+ - center: viewport
8
+ - bottom or right: terminal/logs
apps/web-visualizer/README.md ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # build123d Live Visualizer Prototype
2
+
3
+ This prototype pairs a lightweight Express runner with a Vite + React front-end that streams runner logs and renders generated STL files inside a browser-based viewport.
4
+
5
+ ## Prerequisites
6
+
7
+ - Node.js 18+
8
+ - Access to the existing `build123d` Python environment at `/Users/noahk/.openclaw/workspace/skills/build123d-cad/.venv/bin/python` (automatically used by the server).
9
+
10
+ ## Getting Started
11
+
12
+ ```bash
13
+ npm install
14
+ npm run dev
15
+ ```
16
+
17
+ The shortcut above launches both the Express runner (`http://localhost:4000`) and the Vite dev server (`http://localhost:5173`).
18
+
19
+ ### Manual split
20
+
21
+ ```bash
22
+ npm run dev:server # terminal 1
23
+ npm run dev:client # terminal 2
24
+ ```
25
+
26
+ The front-end proxies `/api` and `/artifacts` calls to the Express server when running in dev mode.
27
+
28
+ ## Using the Prototype
29
+
30
+ 1. Paste or edit build123d code inside the left panel. Ensure your geometry is assigned to a variable called `result`.
31
+ 2. Click **Run & Stream**. The server writes your code to a scratch file, executes it inside the configured `build123d` virtualenv, and exports STL and STEP artifacts into `./artifacts`.
32
+ 3. Logs and errors stream into the right-hand panel. When the export succeeds, the STL is loaded into the three.js viewport and download links become available for both STL and STEP.
33
+
34
+ ### Sample Snippet
35
+
36
+ ```python
37
+ from build123d import *
38
+
39
+ with BuildPart() as bp:
40
+ with BuildSketch(Plane.XY) as base:
41
+ Rectangle(40, 20)
42
+ Locations((0, 0))
43
+ Circle(6)
44
+ extrude(amount=12)
45
+
46
+ result = bp.part
47
+ ```
48
+
49
+ All exported artifacts live inside the `artifacts/` folder, which is served statically for browser fetching.
apps/web-visualizer/artifacts/.gitkeep ADDED
File without changes
apps/web-visualizer/docs/architecture.md ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # build123d Live Visualizer — Architecture Notes
2
+
3
+ ## Live Update Loop
4
+ - **Front-end trigger** – `Run & Stream` button encodes the textarea contents into an SSE request (`/api/run?code=...`).
5
+ - **Server orchestration** – Express writes the snippet to `artifacts/<job>.py`, spawns the dedicated build123d Python interpreter, and relays stdout/stderr as Server-Sent Events (`log` events).
6
+ - **Completion signal** – When the Python runner finishes, Express emits a `complete` event containing the STL path (or an error message). The client loads the STL via `three.js` and refreshes the viewer.
7
+ - **Resilience** – Each run is isolated via UUIDs, making the log stream and artifacts easy to correlate while enabling multiple sequential runs without restarts.
8
+
9
+ ## Artifact Flow
10
+ 1. Client sends code → Express persists under `artifacts/<job>.py`.
11
+ 2. Python runner executes snippet, requiring a `result` variable and exporting to `artifacts/<job>.stl`.
12
+ 3. Express serves `/artifacts` statically so the browser can fetch STL files immediately.
13
+ 4. Front-end STL loader retrieves the file, renders it, and exposes a download link; artifacts remain on disk for inspection or later cleanup.
14
+
15
+ ## LLM Integration Options
16
+ - **OpenClaw Orchestrator** – Keep the current human-in-the-loop workflow where OpenClaw agents call the `/api/run` endpoint, enabling prompt-to-geometry iteration without exposing provider keys to the prototype.
17
+ - **Direct Provider Calls** – Embed provider SDK (OpenAI, Anthropic, etc.) within the server. The Express layer would accept natural-language prompts, forward them to an LLM, and pipe the generated build123d script straight into the runner before streaming results back.
18
+ - **Local Coding Agent** – Bundle a lightweight model (e.g., `llama.cpp`) or a deterministic templating agent that runs locally, translating UI prompts to build123d code without external network usage—aligned with offline or air-gapped deployments.
19
+
20
+ ## Next-Step Roadmap
21
+ - Add job queueing plus cancellation support per run id (currently a single in-memory stream).
22
+ - Persist structured job metadata (prompt, status, artifact path) for replay and auditing.
23
+ - Harden sandboxing by running the Python process inside a constrained container or Firejail profile.
24
+ - Expand the viewer with assembly overlays (multiple STL layers, color coding, exploded views).
25
+ - Wire optional LLM prompt templates + history so designers can iterate conversationally.
26
+ - Author smoke tests covering the SSE endpoint and sample runner invocation.
apps/web-visualizer/docs/milestone-01.md ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Milestone 01 - Stable Live Modeling Loop
2
+
3
+ ## Goal
4
+
5
+ Turn the prototype into a dependable first product loop.
6
+
7
+ ## Scope
8
+
9
+ - edit build123d code in-app
10
+ - run geometry through the local build123d runtime
11
+ - stream logs/errors live
12
+ - preview geometry in a live viewport via STL
13
+ - export both STL and STEP from the same run
14
+
15
+ ## Why this first
16
+
17
+ This locks the core modeling runtime before deeper LLM integration.
18
+ If the live loop is weak, the AI layer becomes brittle and frustrating.
19
+
20
+ ## Definition of done
21
+
22
+ - code changes run reliably from the UI
23
+ - viewport updates consistently after successful runs
24
+ - STL download works
25
+ - STEP download works
26
+ - errors are readable in the log pane
27
+ - repo is clean enough to keep building on
28
+
29
+ ## Next after this
30
+
31
+ - parameter controls and editable variables
32
+ - document model and adapter contract
33
+ - LLM edit/apply flow
34
+ - vector and graphic-mass display modes
apps/web-visualizer/index.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>build123d Live Visualizer</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.tsx"></script>
12
+ </body>
13
+ </html>
apps/web-visualizer/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
apps/web-visualizer/package.json ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "build123d-live-visualizer",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "concurrently \"npm:dev:server\" \"npm:dev:client\"",
7
+ "dev:server": "node server/index.js",
8
+ "dev:client": "vite",
9
+ "build": "vite build",
10
+ "preview": "vite preview",
11
+ "lint": "echo 'Add linting as needed'"
12
+ },
13
+ "dependencies": {
14
+ "cors": "^2.8.5",
15
+ "express": "^4.18.2",
16
+ "react": "^18.2.0",
17
+ "react-dom": "^18.2.0",
18
+ "three": "^0.161.0"
19
+ },
20
+ "devDependencies": {
21
+ "@types/express": "^4.17.21",
22
+ "@types/node": "^20.10.5",
23
+ "@types/react": "^18.2.48",
24
+ "@types/react-dom": "^18.2.18",
25
+ "@vitejs/plugin-react": "^4.2.0",
26
+ "concurrently": "^8.2.1",
27
+ "typescript": "^5.3.3",
28
+ "vite": "^5.0.0"
29
+ }
30
+ }
apps/web-visualizer/server/index.js ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require('express');
2
+ const cors = require('cors');
3
+ const { spawn } = require('child_process');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { randomUUID } = require('crypto');
7
+
8
+ const PYTHON_BIN = process.env.BUILD123D_PYTHON || '/Users/noahk/.openclaw/workspace/skills/build123d-cad/.venv/bin/python';
9
+ const PORT = process.env.PORT || 4000;
10
+ const ARTIFACT_DIR = path.join(__dirname, '..', 'artifacts');
11
+ const RUNNER_PATH = path.join(__dirname, 'runner.py');
12
+ const DIST_PATH = path.join(__dirname, '..', 'dist');
13
+
14
+ fs.mkdirSync(ARTIFACT_DIR, { recursive: true });
15
+
16
+ const app = express();
17
+ app.use(cors());
18
+ app.use(express.json({ limit: '1mb' }));
19
+ app.use('/artifacts', express.static(ARTIFACT_DIR));
20
+
21
+ if (fs.existsSync(DIST_PATH)) {
22
+ app.use(express.static(DIST_PATH));
23
+ }
24
+
25
+ const sendSse = (res, event, data) => {
26
+ res.write(`event: ${event}\n`);
27
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
28
+ };
29
+
30
+ app.get('/api/run', (req, res) => {
31
+ const code = req.query.code;
32
+ if (typeof code !== 'string' || !code.trim()) {
33
+ res.status(400).json({ error: 'Missing code parameter.' });
34
+ return;
35
+ }
36
+
37
+ res.setHeader('Content-Type', 'text/event-stream');
38
+ res.setHeader('Cache-Control', 'no-cache');
39
+ res.setHeader('Connection', 'keep-alive');
40
+
41
+ const jobId = randomUUID();
42
+ const codeFile = path.join(ARTIFACT_DIR, `${jobId}.py`);
43
+ const stlFile = path.join(ARTIFACT_DIR, `${jobId}.stl`);
44
+ const stepFile = path.join(ARTIFACT_DIR, `${jobId}.step`);
45
+
46
+ fs.writeFileSync(codeFile, code);
47
+ sendSse(res, 'log', { message: `Job ${jobId} accepted.` });
48
+
49
+ const pythonArgs = [RUNNER_PATH, '--source', codeFile, '--stl-output', stlFile, '--step-output', stepFile];
50
+ const child = spawn(PYTHON_BIN, pythonArgs, { env: process.env });
51
+
52
+ req.on('close', () => {
53
+ if (!child.killed) {
54
+ child.kill('SIGINT');
55
+ }
56
+ });
57
+
58
+ child.stdout.on('data', (chunk) => {
59
+ sendSse(res, 'log', { message: chunk.toString().trim() });
60
+ });
61
+
62
+ child.stderr.on('data', (chunk) => {
63
+ sendSse(res, 'log', { message: chunk.toString().trim(), level: 'error' });
64
+ });
65
+
66
+ child.on('error', (error) => {
67
+ sendSse(res, 'log', { message: `Runner error: ${error.message}`, level: 'error' });
68
+ sendSse(res, 'complete', { success: false, error: error.message });
69
+ res.end();
70
+ });
71
+
72
+ child.on('close', (code) => {
73
+ const success = code === 0;
74
+ if (success) {
75
+ sendSse(res, 'complete', {
76
+ success: true,
77
+ stlPath: `/artifacts/${path.basename(stlFile)}`,
78
+ stepPath: `/artifacts/${path.basename(stepFile)}`
79
+ });
80
+ } else {
81
+ sendSse(res, 'complete', { success: false, error: `Runner exited with ${code}` });
82
+ }
83
+ res.end();
84
+ });
85
+ });
86
+
87
+ app.get('/api/health', (_req, res) => {
88
+ res.json({ status: 'ok', python: PYTHON_BIN });
89
+ });
90
+
91
+ app.listen(PORT, () => {
92
+ console.log(`build123d live runner listening on http://localhost:${PORT}`);
93
+ });
apps/web-visualizer/server/runner.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Simple build123d runner with STL and STEP export."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import pathlib
8
+ import sys
9
+ import traceback
10
+
11
+
12
+ def coerce_shape(candidate):
13
+ """Try to pull an exportable shape from the user namespace."""
14
+ if candidate is None:
15
+ return None
16
+
17
+ if hasattr(candidate, "wrapped"):
18
+ return candidate
19
+
20
+ for attr in ("part", "shape", "solid", "obj"):
21
+ value = getattr(candidate, attr, None)
22
+ if value is not None and not callable(value):
23
+ candidate = value
24
+ if hasattr(candidate, "wrapped"):
25
+ return candidate
26
+ return candidate
27
+
28
+
29
+ def main() -> int:
30
+ parser = argparse.ArgumentParser(description="Run build123d code and export artifacts")
31
+ parser.add_argument("--source", required=True, help="Path to user code file")
32
+ parser.add_argument("--stl-output", required=True, help="Path for the STL output")
33
+ parser.add_argument("--step-output", required=False, help="Path for the STEP output")
34
+ args = parser.parse_args()
35
+
36
+ source_path = pathlib.Path(args.source)
37
+ stl_output_path = pathlib.Path(args.stl_output)
38
+ step_output_path = pathlib.Path(args.step_output) if args.step_output else None
39
+ stl_output_path.parent.mkdir(parents=True, exist_ok=True)
40
+ if step_output_path:
41
+ step_output_path.parent.mkdir(parents=True, exist_ok=True)
42
+
43
+ try:
44
+ code = source_path.read_text()
45
+ except OSError as exc:
46
+ print(f"Failed to read code: {exc}", file=sys.stderr)
47
+ return 1
48
+
49
+ user_globals: dict[str, object] = {}
50
+ try:
51
+ exec(compile(code, str(source_path), "exec"), user_globals)
52
+ except Exception as exec_error: # noqa: BLE001
53
+ print("Execution error:", file=sys.stderr)
54
+ traceback.print_exception(exec_error, file=sys.stderr)
55
+ return 1
56
+
57
+ candidate = user_globals.get("result")
58
+ candidate = coerce_shape(candidate)
59
+
60
+ if candidate is None:
61
+ print("No `result` geometry found after execution.", file=sys.stderr)
62
+ return 2
63
+
64
+ try:
65
+ from build123d import export_stl, export_step
66
+ except Exception as exc: # noqa: BLE001
67
+ print(f"Unable to import build123d exporters: {exc}", file=sys.stderr)
68
+ return 3
69
+
70
+ try:
71
+ export_stl(candidate, str(stl_output_path))
72
+ print(f"STL exported to {stl_output_path}")
73
+ if step_output_path:
74
+ export_step(candidate, str(step_output_path))
75
+ print(f"STEP exported to {step_output_path}")
76
+ except Exception as export_error: # noqa: BLE001
77
+ print("Export failed:", file=sys.stderr)
78
+ traceback.print_exception(export_error, file=sys.stderr)
79
+ return 4
80
+
81
+ return 0
82
+
83
+
84
+ if __name__ == "__main__":
85
+ raise SystemExit(main())
apps/web-visualizer/src/App.tsx ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import * as THREE from 'three';
3
+ import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
4
+ import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
5
+
6
+ const SAMPLE_SNIPPET = `from build123d import *
7
+
8
+ # Simple parametric puck
9
+ radius = 15
10
+ height = 8
11
+
12
+ with BuildPart() as bp:
13
+ with BuildSketch(Plane.XY) as base:
14
+ Circle(radius)
15
+ PolarLocations(radius / 2, 6)
16
+ Circle(radius / 6)
17
+ extrude(amount=height)
18
+
19
+ result = bp.part`;
20
+
21
+ type LogEntry = {
22
+ id: string;
23
+ message: string;
24
+ level: 'info' | 'error';
25
+ };
26
+
27
+ const loader = new STLLoader();
28
+
29
+ export default function App() {
30
+ const [code, setCode] = useState(SAMPLE_SNIPPET);
31
+ const [logs, setLogs] = useState<LogEntry[]>([]);
32
+ const [status, setStatus] = useState<'idle' | 'running' | 'done' | 'error'>('idle');
33
+ const [artifactUrl, setArtifactUrl] = useState<string | null>(null);
34
+ const [stepUrl, setStepUrl] = useState<string | null>(null);
35
+ const viewerRef = useRef<HTMLDivElement | null>(null);
36
+ const eventSourceRef = useRef<EventSource | null>(null);
37
+ const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
38
+ const sceneRef = useRef<THREE.Scene | null>(null);
39
+ const meshRef = useRef<THREE.Mesh | null>(null);
40
+ const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
41
+ const controlsRef = useRef<OrbitControls | null>(null);
42
+
43
+ const appendLog = useCallback((message: string, level: 'info' | 'error' = 'info') => {
44
+ setLogs((prev) => [...prev, { id: crypto.randomUUID(), message, level }]);
45
+ }, []);
46
+
47
+ useEffect(() => {
48
+ if (!viewerRef.current) return;
49
+
50
+ const width = viewerRef.current.clientWidth;
51
+ const height = viewerRef.current.clientHeight;
52
+ const scene = new THREE.Scene();
53
+ scene.background = new THREE.Color(0x020617);
54
+
55
+ const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000);
56
+ camera.position.set(60, 45, 60);
57
+
58
+ const renderer = new THREE.WebGLRenderer({ antialias: true });
59
+ renderer.setSize(width, height);
60
+ renderer.setPixelRatio(window.devicePixelRatio);
61
+
62
+ const controls = new OrbitControls(camera, renderer.domElement);
63
+ controls.enableDamping = true;
64
+
65
+ const ambient = new THREE.AmbientLight(0xffffff, 0.6);
66
+ const dir = new THREE.DirectionalLight(0xffffff, 0.8);
67
+ dir.position.set(50, 80, 30);
68
+
69
+ const grid = new THREE.GridHelper(120, 20, 0x172554, 0x1e293b);
70
+
71
+ scene.add(ambient);
72
+ scene.add(dir);
73
+ scene.add(grid);
74
+
75
+ viewerRef.current.appendChild(renderer.domElement);
76
+
77
+ sceneRef.current = scene;
78
+ rendererRef.current = renderer;
79
+ cameraRef.current = camera;
80
+ controlsRef.current = controls;
81
+
82
+ const animate = () => {
83
+ controls.update();
84
+ renderer.render(scene, camera);
85
+ requestAnimationFrame(animate);
86
+ };
87
+ animate();
88
+
89
+ const handleResize = () => {
90
+ if (!viewerRef.current || !rendererRef.current || !cameraRef.current) return;
91
+ const newWidth = viewerRef.current.clientWidth;
92
+ const newHeight = viewerRef.current.clientHeight;
93
+ rendererRef.current.setSize(newWidth, newHeight);
94
+ cameraRef.current.aspect = newWidth / newHeight;
95
+ cameraRef.current.updateProjectionMatrix();
96
+ };
97
+
98
+ window.addEventListener('resize', handleResize);
99
+
100
+ return () => {
101
+ window.removeEventListener('resize', handleResize);
102
+ renderer.dispose();
103
+ controls.dispose();
104
+ };
105
+ }, []);
106
+
107
+ useEffect(() => {
108
+ if (!artifactUrl || !sceneRef.current) return;
109
+
110
+ loader.load(
111
+ artifactUrl,
112
+ (geometry) => {
113
+ if (meshRef.current) {
114
+ sceneRef.current!.remove(meshRef.current);
115
+ meshRef.current.geometry.dispose();
116
+ }
117
+ geometry.center();
118
+ geometry.computeVertexNormals();
119
+ const material = new THREE.MeshStandardMaterial({
120
+ color: 0x38bdf8,
121
+ metalness: 0.1,
122
+ roughness: 0.4
123
+ });
124
+ const mesh = new THREE.Mesh(geometry, material);
125
+ meshRef.current = mesh;
126
+ sceneRef.current!.add(mesh);
127
+ appendLog('STL loaded in viewer.');
128
+ setStatus('done');
129
+ },
130
+ undefined,
131
+ (error) => {
132
+ appendLog(`Viewer load error: ${error.message}`, 'error');
133
+ setStatus('error');
134
+ }
135
+ );
136
+ }, [artifactUrl, appendLog]);
137
+
138
+ const handleRun = () => {
139
+ if (!code.trim()) {
140
+ appendLog('Add some build123d code before running.', 'error');
141
+ return;
142
+ }
143
+
144
+ if (eventSourceRef.current) {
145
+ eventSourceRef.current.close();
146
+ }
147
+
148
+ setLogs([]);
149
+ setStatus('running');
150
+ setArtifactUrl(null);
151
+ setStepUrl(null);
152
+
153
+ const url = `/api/run?ts=${Date.now()}&code=${encodeURIComponent(code)}`;
154
+ const source = new EventSource(url);
155
+ eventSourceRef.current = source;
156
+
157
+ source.addEventListener('log', (event) => {
158
+ const payload = JSON.parse((event as MessageEvent).data) as { message: string; level?: 'info' | 'error' };
159
+ appendLog(payload.message, payload.level ?? 'info');
160
+ });
161
+
162
+ source.addEventListener('complete', (event) => {
163
+ const payload = JSON.parse((event as MessageEvent).data) as { success: boolean; stlPath?: string; stepPath?: string; error?: string };
164
+ if (payload.success && payload.stlPath) {
165
+ const finalUrl = `${payload.stlPath}?v=${Date.now()}`;
166
+ appendLog('Runner completed; STL ready.');
167
+ if (payload.stepPath) {
168
+ appendLog('STEP export ready.');
169
+ setStepUrl(`${payload.stepPath}?v=${Date.now()}`);
170
+ }
171
+ setArtifactUrl(finalUrl);
172
+ } else {
173
+ appendLog(payload.error ?? 'Runner failed.', 'error');
174
+ setStatus('error');
175
+ }
176
+ source.close();
177
+ });
178
+
179
+ source.onerror = () => {
180
+ appendLog('Connection interrupted.', 'error');
181
+ setStatus('error');
182
+ source.close();
183
+ };
184
+ };
185
+
186
+ const handleStop = () => {
187
+ if (eventSourceRef.current) {
188
+ eventSourceRef.current.close();
189
+ eventSourceRef.current = null;
190
+ appendLog('Stream closed by user.');
191
+ setStatus('idle');
192
+ }
193
+ };
194
+
195
+ useEffect(() => {
196
+ return () => {
197
+ if (eventSourceRef.current) {
198
+ eventSourceRef.current.close();
199
+ }
200
+ if (rendererRef.current) {
201
+ rendererRef.current.dispose();
202
+ }
203
+ if (controlsRef.current) {
204
+ controlsRef.current.dispose();
205
+ }
206
+ };
207
+ }, []);
208
+
209
+ return (
210
+ <div className="app-shell layout-landscape">
211
+ <section className="panel panel-editor">
212
+ <h2>build123d Prompt</h2>
213
+ <div className="controls">
214
+ <button onClick={handleRun} disabled={status === 'running'}>
215
+ {status === 'running' ? 'Running…' : 'Run & Stream'}
216
+ </button>
217
+ <button onClick={handleStop}>Stop</button>
218
+ </div>
219
+ <textarea
220
+ className="editor"
221
+ value={code}
222
+ onChange={(e) => setCode(e.target.value)}
223
+ spellCheck={false}
224
+ />
225
+ <p style={{ fontSize: '0.8rem', color: '#94a3b8', marginTop: '0.5rem' }}>
226
+ Tip: assign your geometry to a variable named <code>result</code> so the runner can export it.
227
+ </p>
228
+ </section>
229
+
230
+ <section className="panel panel-viewer">
231
+ <div className="panel-header-row">
232
+ <h2>Live Model</h2>
233
+ <div className="status-inline">
234
+ <span>Status: {status}</span>
235
+ </div>
236
+ </div>
237
+ <div className="viewer-container viewer-container-large">
238
+ <div className="viewer-canvas" ref={viewerRef} />
239
+ </div>
240
+ {(artifactUrl || stepUrl) && (
241
+ <div className="status-row">
242
+ <span>Exports Ready</span>
243
+ <div style={{ display: 'flex', gap: '0.75rem' }}>
244
+ {artifactUrl && (
245
+ <a href={artifactUrl} download="model.stl" style={{ color: '#38bdf8' }}>
246
+ Download STL
247
+ </a>
248
+ )}
249
+ {stepUrl && (
250
+ <a href={stepUrl} download="model.step" style={{ color: '#f59e0b' }}>
251
+ Download STEP
252
+ </a>
253
+ )}
254
+ </div>
255
+ </div>
256
+ )}
257
+ </section>
258
+
259
+ <section className="panel panel-logs">
260
+ <div className="panel-header-row">
261
+ <h2>Runner Logs</h2>
262
+ <span className="log-count">{logs.length} entries</span>
263
+ </div>
264
+ <div className="logs">
265
+ {logs.length === 0 ? 'Awaiting output…' : logs.map((log) => `${log.level === 'error' ? '✖' : '•'} ${log.message}`).join('\n')}
266
+ </div>
267
+ </section>
268
+ </div>
269
+ );
270
+ }
apps/web-visualizer/src/main.tsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import ReactDOM from 'react-dom/client';
3
+ import App from './App';
4
+ import './styles.css';
5
+
6
+ ReactDOM.createRoot(document.getElementById('root')!).render(
7
+ <React.StrictMode>
8
+ <App />
9
+ </React.StrictMode>
10
+ );
apps/web-visualizer/src/styles.css ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
3
+ color: #e5e5e5;
4
+ background: #0f172a;
5
+ }
6
+
7
+ * {
8
+ box-sizing: border-box;
9
+ }
10
+
11
+ body,
12
+ html,
13
+ #root {
14
+ margin: 0;
15
+ padding: 0;
16
+ width: 100%;
17
+ height: 100%;
18
+ }
19
+
20
+ body {
21
+ background: #0f172a;
22
+ }
23
+
24
+ .app-shell {
25
+ display: grid;
26
+ gap: 0.75rem;
27
+ height: 100%;
28
+ padding: 0.75rem;
29
+ background: radial-gradient(circle at top, rgba(15, 23, 42, 0.95), rgba(2, 6, 23, 0.98));
30
+ }
31
+
32
+ .layout-landscape {
33
+ grid-template-columns: minmax(320px, 0.8fr) minmax(720px, 1.8fr);
34
+ grid-template-rows: minmax(0, 1fr) 180px;
35
+ grid-template-areas:
36
+ 'editor viewer'
37
+ 'editor logs';
38
+ }
39
+
40
+ .panel {
41
+ border: 1px solid rgba(148, 163, 184, 0.15);
42
+ background: rgba(15, 23, 42, 0.8);
43
+ border-radius: 0.75rem;
44
+ padding: 0.75rem;
45
+ display: flex;
46
+ flex-direction: column;
47
+ min-height: 0;
48
+ }
49
+
50
+ .panel-editor {
51
+ grid-area: editor;
52
+ }
53
+
54
+ .panel-viewer {
55
+ grid-area: viewer;
56
+ }
57
+
58
+ .panel-logs {
59
+ grid-area: logs;
60
+ }
61
+
62
+ .panel h2 {
63
+ margin: 0 0 0.5rem 0;
64
+ font-size: 1rem;
65
+ letter-spacing: 0.03em;
66
+ text-transform: uppercase;
67
+ color: #94a3b8;
68
+ }
69
+
70
+ .editor {
71
+ flex: 1;
72
+ resize: none;
73
+ background: rgba(15, 23, 42, 0.9);
74
+ color: #f8fafc;
75
+ border: 1px solid rgba(148, 163, 184, 0.2);
76
+ border-radius: 0.5rem;
77
+ font-family: 'JetBrains Mono', 'SFMono-Regular', ui-monospace, monospace;
78
+ font-size: 0.9rem;
79
+ padding: 0.75rem;
80
+ line-height: 1.4;
81
+ }
82
+
83
+ .controls {
84
+ display: flex;
85
+ gap: 0.5rem;
86
+ margin-bottom: 0.5rem;
87
+ }
88
+
89
+ button {
90
+ border: none;
91
+ background: linear-gradient(135deg, #2563eb, #7c3aed);
92
+ color: white;
93
+ border-radius: 0.5rem;
94
+ padding: 0.5rem 0.75rem;
95
+ cursor: pointer;
96
+ font-weight: 600;
97
+ font-size: 0.85rem;
98
+ transition: opacity 0.2s ease;
99
+ }
100
+
101
+ button:disabled {
102
+ opacity: 0.5;
103
+ cursor: not-allowed;
104
+ }
105
+
106
+ .viewer-container {
107
+ flex: 1;
108
+ position: relative;
109
+ min-height: 0;
110
+ }
111
+
112
+ .viewer-container-large {
113
+ min-height: 520px;
114
+ }
115
+
116
+ .viewer-canvas {
117
+ width: 100%;
118
+ height: 100%;
119
+ border-radius: 0.5rem;
120
+ background: #020617;
121
+ }
122
+
123
+ .logs {
124
+ flex: 1;
125
+ background: rgba(2, 6, 23, 0.8);
126
+ border-radius: 0.5rem;
127
+ padding: 0.5rem;
128
+ font-family: 'JetBrains Mono', 'SFMono-Regular', ui-monospace, monospace;
129
+ font-size: 0.8rem;
130
+ overflow-y: auto;
131
+ border: 1px solid rgba(148, 163, 184, 0.2);
132
+ white-space: pre-wrap;
133
+ }
134
+
135
+ .panel-header-row {
136
+ display: flex;
137
+ align-items: center;
138
+ justify-content: space-between;
139
+ gap: 1rem;
140
+ }
141
+
142
+ .status-inline,
143
+ .log-count {
144
+ font-size: 0.8rem;
145
+ color: #93c5fd;
146
+ }
147
+
148
+ .status-row {
149
+ display: flex;
150
+ justify-content: space-between;
151
+ font-size: 0.8rem;
152
+ color: #93c5fd;
153
+ margin-bottom: 0.5rem;
154
+ }
155
+
156
+ @media (max-width: 1200px) {
157
+ .layout-landscape {
158
+ grid-template-columns: 1fr;
159
+ grid-template-rows: auto minmax(420px, 1fr) 200px;
160
+ grid-template-areas:
161
+ 'editor'
162
+ 'viewer'
163
+ 'logs';
164
+ }
165
+
166
+ .viewer-container-large {
167
+ min-height: 420px;
168
+ }
169
+ }
apps/web-visualizer/tsconfig.json ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
6
+ "module": "ESNext",
7
+ "skipLibCheck": true,
8
+ "moduleResolution": "Bundler",
9
+ "allowImportingTsExtensions": false,
10
+ "resolveJsonModule": true,
11
+ "isolatedModules": true,
12
+ "noEmit": true,
13
+ "jsx": "react-jsx",
14
+ "strict": true,
15
+ "types": ["vite/client"]
16
+ },
17
+ "include": ["src"],
18
+ "references": [{ "path": "./tsconfig.node.json" }]
19
+ }
apps/web-visualizer/tsconfig.node.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "composite": true,
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "allowSyntheticDefaultImports": true,
7
+ "esModuleInterop": true,
8
+ "strict": true,
9
+ "skipLibCheck": true,
10
+ "forceConsistentCasingInFileNames": true,
11
+ "types": ["node"]
12
+ },
13
+ "include": ["vite.config.ts"]
14
+ }
apps/web-visualizer/vite.config.ts ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite';
2
+ import react from '@vitejs/plugin-react';
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ server: {
7
+ port: 5173,
8
+ proxy: {
9
+ '/api': 'http://localhost:4000',
10
+ '/artifacts': 'http://localhost:4000'
11
+ }
12
+ },
13
+ build: {
14
+ outDir: 'dist'
15
+ }
16
+ });
archive/README.md ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Live Visualizer
2
+
3
+ A custom live modeling environment for LLM-assisted geometry workflows.
4
+
5
+ ## Core idea
6
+
7
+ This project is for a modeling tool that combines:
8
+ - live model viewing
9
+ - editable variables and code
10
+ - terminal/log feedback
11
+ - LLM-assisted model generation and editing
12
+ - multiple visual modes from the same scene state
13
+
14
+ ## Position on build123d
15
+
16
+ `build123d` should be treated as an important geometry backend, but not baked so deeply into the app that the entire product becomes impossible to evolve.
17
+
18
+ So the architecture should be:
19
+ - custom app shell
20
+ - custom scene/state/visualization pipeline
21
+ - pluggable geometry adapters
22
+ - build123d as the first and best-supported adapter
23
+
24
+ That keeps the project flexible while still embracing the current best geometry engine for this workflow.
25
+
26
+ ## Initial product shape
27
+
28
+ Three-pane interface:
29
+ - editor / prompt / parameter control
30
+ - live viewport
31
+ - terminal / logs / agent output
32
+
33
+ ## Near-term goals
34
+
35
+ 1. Create a custom app shell
36
+ 2. Support build123d as the first geometry adapter
37
+ 3. Enable live regeneration from script or parameter changes
38
+ 4. Separate display modes:
39
+ - viewport shaded
40
+ - graphic mass
41
+ - technical/vector
42
+ 5. Define LLM integration layer:
43
+ - OpenClaw-driven
44
+ - provider-agnostic fallback
45
+
46
+ ## Proposed repo layout
47
+
48
+ - `apps/viewer` - main app shell
49
+ - `packages/core` - scene graph, document model, orchestration types
50
+ - `packages/renderer` - viewport and render mode logic
51
+ - `packages/llm` - LLM session/provider integration
52
+ - `packages/session` - terminal/process/runtime session management
53
+ - `packages/ui` - reusable interface components
54
+ - `examples/build123d` - test scripts and adapter examples
55
+
56
+ ## Principles
57
+
58
+ - model first, render second
59
+ - one source of geometric truth
60
+ - multiple display modes from the same scene
61
+ - user can steer with language, numbers, and code
62
+ - terminal remains a first-class part of the workflow
archive/gradio-demo-backend-legacy/API_CONTRACT.md ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # API Contract Sketch
2
+
3
+ ## POST `/v1/generate-spec`
4
+
5
+ Request:
6
+
7
+ ```json
8
+ {
9
+ "prompt": "make a heavy steel bracket with 4 bolt holes",
10
+ "mode": "part",
11
+ "output_type": "3d_solid",
12
+ "session_id": "optional-client-session"
13
+ }
14
+ ```
15
+
16
+ Response:
17
+
18
+ ```json
19
+ {
20
+ "ok": true,
21
+ "cached": false,
22
+ "prompt_hash": "8a41b50d23f1b3de",
23
+ "spec": {
24
+ "output_type": "3d_solid",
25
+ "geometry_family": "bracket_plate",
26
+ "units": "mm",
27
+ "parameters": {
28
+ "width": 80,
29
+ "height": 50,
30
+ "thickness": 6,
31
+ "hole_count": 4,
32
+ "hole_diameter": 10
33
+ },
34
+ "style": {
35
+ "family": "industrial",
36
+ "heaviness": 0.8
37
+ }
38
+ },
39
+ "notes": [
40
+ "This is a scaffolded template/spec response.",
41
+ "Next step: route this through an actual HF model endpoint."
42
+ ],
43
+ "model": "stub/template-router"
44
+ }
45
+ ```
46
+
47
+ ## GET `/v1/health`
48
+ Simple health check for the Space.
49
+
50
+ ## Intended evolution
51
+ - add actual HF endpoint call inside `generate_spec`
52
+ - keep prompt normalization, caching, and rate limiting in this backend
53
+ - convert returned JSON spec into build123d code inside the Gradio app
archive/gradio-demo-backend-legacy/README.md ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # NaturalCAD Backend Scaffold
2
+
3
+ Thin private backend for the NaturalCAD Hugging Face Space.
4
+
5
+ ## Purpose
6
+ - keep secrets off the Space
7
+ - rate limit and cache requests
8
+ - normalize prompts
9
+ - return structured CAD specs
10
+ - leave room for future provider routing
11
+
12
+ ## Run
13
+
14
+ ```bash
15
+ cd backend
16
+ python3 -m venv .venv
17
+ .venv/bin/pip install -r requirements.txt
18
+ .venv/bin/uvicorn app.main:app --reload --port 8010
19
+ ```
20
+
21
+ ## Planned flow
22
+ 1. Space sends prompt to `/v1/generate-spec`
23
+ 2. Backend validates + rate limits
24
+ 3. Backend returns structured JSON spec
25
+ 4. Gradio app converts spec into build123d code
26
+ 5. Space executes build123d and returns STL/STEP
archive/gradio-demo-backend-legacy/app/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # backend package
archive/gradio-demo-backend-legacy/app/main.py ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import os
5
+ import re
6
+ import time
7
+ from collections import defaultdict, deque
8
+ from typing import Literal
9
+
10
+ from dotenv import load_dotenv
11
+ from fastapi import FastAPI, Header, HTTPException, Request
12
+ from pydantic import BaseModel, Field
13
+
14
+ load_dotenv()
15
+
16
+ APP_NAME = "NaturalCAD Backend"
17
+ API_SHARED_SECRET = os.getenv("API_SHARED_SECRET", "")
18
+ RATE_LIMIT_PER_HOUR = int(os.getenv("RATE_LIMIT_PER_HOUR", "20"))
19
+
20
+ app = FastAPI(title=APP_NAME, version="0.3.0")
21
+
22
+ _REQUESTS: dict[str, deque[float]] = defaultdict(deque)
23
+ _CACHE: dict[str, dict] = {}
24
+
25
+ ModeType = Literal["part", "assembly", "sketch"]
26
+ OutputType = Literal["2d_vector", "surface", "3d_solid"]
27
+
28
+
29
+ class GenerateSpecRequest(BaseModel):
30
+ prompt: str = Field(min_length=3, max_length=1000)
31
+ mode: ModeType = "part"
32
+ output_type: OutputType = "3d_solid"
33
+ session_id: str | None = None
34
+
35
+
36
+ class CadStyle(BaseModel):
37
+ family: str = "industrial"
38
+ heaviness: float = 0.6
39
+
40
+
41
+ class CadSpec(BaseModel):
42
+ output_type: OutputType
43
+ geometry_family: str
44
+ units: str = "mm"
45
+ parameters: dict[str, int | float | str]
46
+ style: CadStyle
47
+
48
+
49
+ class GenerateSpecResponse(BaseModel):
50
+ ok: bool = True
51
+ cached: bool = False
52
+ prompt_hash: str
53
+ spec: CadSpec
54
+ notes: list[str] = []
55
+ model: str = "stub/template-router"
56
+
57
+
58
+ class HealthResponse(BaseModel):
59
+ status: str
60
+ rate_limit_per_hour: int
61
+ cache_entries: int
62
+
63
+
64
+ def _check_auth(header_value: str | None) -> None:
65
+ if API_SHARED_SECRET and header_value != API_SHARED_SECRET:
66
+ raise HTTPException(status_code=401, detail="Invalid shared secret")
67
+
68
+
69
+ def _rate_limit_key(request: Request, session_id: str | None) -> str:
70
+ client_ip = request.client.host if request.client else "unknown"
71
+ return session_id or client_ip
72
+
73
+
74
+ def _enforce_rate_limit(key: str) -> None:
75
+ now = time.time()
76
+ cutoff = now - 3600
77
+ bucket = _REQUESTS[key]
78
+ while bucket and bucket[0] < cutoff:
79
+ bucket.popleft()
80
+ if len(bucket) >= RATE_LIMIT_PER_HOUR:
81
+ raise HTTPException(status_code=429, detail="Rate limit exceeded")
82
+ bucket.append(now)
83
+
84
+
85
+ def _normalize_prompt(prompt: str) -> str:
86
+ return " ".join(prompt.lower().strip().split())
87
+
88
+
89
+ def _prompt_hash(prompt: str, mode: str, output_type: str) -> str:
90
+ digest = hashlib.sha256(f"{mode}|{output_type}|{prompt}".encode()).hexdigest()
91
+ return digest[:16]
92
+
93
+
94
+ def _extract_number(prompt: str, keywords: list[str], default: float) -> float:
95
+ for keyword in keywords:
96
+ pattern = rf"{keyword}\s*(?:of|=|:)?\s*(\d+(?:\.\d+)?)"
97
+ match = re.search(pattern, prompt)
98
+ if match:
99
+ return float(match.group(1))
100
+ return default
101
+
102
+
103
+ def _extract_count(prompt: str, nouns: list[str], default: int) -> int:
104
+ word_map = {
105
+ "one": 1,
106
+ "two": 2,
107
+ "three": 3,
108
+ "four": 4,
109
+ "five": 5,
110
+ "six": 6,
111
+ "seven": 7,
112
+ "eight": 8,
113
+ "nine": 9,
114
+ "ten": 10,
115
+ }
116
+ for noun in nouns:
117
+ digit_match = re.search(rf"(\d+)\s+{noun}", prompt)
118
+ if digit_match:
119
+ return int(digit_match.group(1))
120
+ for word, value in word_map.items():
121
+ if re.search(rf"{word}\s+{noun}", prompt):
122
+ return value
123
+ return default
124
+
125
+
126
+ def _style_from_prompt(prompt: str, default_family: str) -> CadStyle:
127
+ heaviness = 0.6
128
+ family = default_family
129
+ if any(word in prompt for word in ["heavy", "massive", "thick", "brutal"]):
130
+ heaviness = 0.85
131
+ elif any(word in prompt for word in ["light", "slim", "thin", "delicate"]):
132
+ heaviness = 0.35
133
+
134
+ if any(word in prompt for word in ["industrial", "steel", "metal"]):
135
+ family = "industrial"
136
+ elif any(word in prompt for word in ["structural", "truss", "frame"]):
137
+ family = "structural"
138
+ elif any(word in prompt for word in ["smooth", "soft", "shell", "canopy"]):
139
+ family = "smooth"
140
+ elif any(word in prompt for word in ["diagram", "profile", "elevation", "line"]):
141
+ family = "diagrammatic"
142
+
143
+ return CadStyle(family=family, heaviness=heaviness)
144
+
145
+
146
+ def _infer_spec(prompt: str, mode: ModeType, output_type: OutputType) -> CadSpec:
147
+ p = prompt.lower()
148
+
149
+ if output_type == "2d_vector" or mode == "sketch":
150
+ family = "truss_elevation" if any(word in p for word in ["truss", "beam", "frame", "elevation"]) else "plate_profile"
151
+ if family == "truss_elevation":
152
+ params = {
153
+ "span": _extract_number(p, ["span", "length", "width"], 140),
154
+ "height": _extract_number(p, ["height", "rise"], 24),
155
+ "panel_count": _extract_count(p, ["panels", "bays", "segments"], 7),
156
+ "member_size": _extract_number(p, ["member", "thickness", "depth"], 3),
157
+ "preview_thickness": 1,
158
+ }
159
+ else:
160
+ params = {
161
+ "width": _extract_number(p, ["width", "span"], 80),
162
+ "height": _extract_number(p, ["height"], 50),
163
+ "hole_count": _extract_count(p, ["holes", "bolt holes", "openings"], 4),
164
+ "hole_diameter": _extract_number(p, ["hole diameter", "hole", "diameter"], 10),
165
+ "preview_thickness": 1,
166
+ }
167
+ return CadSpec(
168
+ output_type="2d_vector",
169
+ geometry_family=family,
170
+ parameters=params,
171
+ style=_style_from_prompt(p, "diagrammatic"),
172
+ )
173
+
174
+ if output_type == "surface":
175
+ family = "canopy_surface" if any(word in p for word in ["roof", "canopy", "shell", "surface"]) else "lofted_panel"
176
+ if family == "canopy_surface":
177
+ params = {
178
+ "span": _extract_number(p, ["span", "width"], 160),
179
+ "depth": _extract_number(p, ["depth", "length"], 90),
180
+ "peak_height": _extract_number(p, ["peak", "height", "rise"], 38),
181
+ "thickness": _extract_number(p, ["thickness"], 2),
182
+ }
183
+ else:
184
+ params = {
185
+ "width": _extract_number(p, ["width", "span"], 80),
186
+ "depth": _extract_number(p, ["depth", "length"], 50),
187
+ "rise": _extract_number(p, ["rise", "height"], 18),
188
+ "thickness": _extract_number(p, ["thickness"], 2),
189
+ }
190
+ return CadSpec(
191
+ output_type="surface",
192
+ geometry_family=family,
193
+ parameters=params,
194
+ style=_style_from_prompt(p, "smooth"),
195
+ )
196
+
197
+ if any(word in p for word in ["truss", "beam", "frame", "girder"]):
198
+ return CadSpec(
199
+ output_type="3d_solid",
200
+ geometry_family="truss_beam",
201
+ parameters={
202
+ "span": _extract_number(p, ["span", "length"], 140),
203
+ "height": _extract_number(p, ["height", "rise"], 24),
204
+ "panel_count": _extract_count(p, ["panels", "bays", "segments"], 7),
205
+ "member_size": _extract_number(p, ["member", "thickness", "depth"], 3),
206
+ },
207
+ style=_style_from_prompt(p, "structural"),
208
+ )
209
+
210
+ if any(word in p for word in ["tower", "block", "monolith"]):
211
+ return CadSpec(
212
+ output_type="3d_solid",
213
+ geometry_family="tower_block",
214
+ parameters={
215
+ "width": _extract_number(p, ["width"], 30),
216
+ "length": _extract_number(p, ["length", "depth"], 30),
217
+ "height": _extract_number(p, ["height"], 120),
218
+ "notch": _extract_number(p, ["notch", "cut"], 10),
219
+ },
220
+ style=_style_from_prompt(p, "industrial"),
221
+ )
222
+
223
+ return CadSpec(
224
+ output_type="3d_solid",
225
+ geometry_family="bracket_plate",
226
+ parameters={
227
+ "width": _extract_number(p, ["width", "span"], 80),
228
+ "height": _extract_number(p, ["height"], 50),
229
+ "thickness": _extract_number(p, ["thickness"], 6),
230
+ "hole_count": _extract_count(p, ["holes", "bolt holes", "openings"], 4),
231
+ "hole_diameter": _extract_number(p, ["hole diameter", "hole", "diameter"], 10),
232
+ },
233
+ style=_style_from_prompt(p, "industrial"),
234
+ )
235
+
236
+
237
+ @app.get("/v1/health", response_model=HealthResponse)
238
+ def health() -> HealthResponse:
239
+ return HealthResponse(status="ok", rate_limit_per_hour=RATE_LIMIT_PER_HOUR, cache_entries=len(_CACHE))
240
+
241
+
242
+ @app.post("/v1/generate-spec", response_model=GenerateSpecResponse)
243
+ def generate_spec(payload: GenerateSpecRequest, request: Request, x_api_key: str | None = Header(default=None)) -> GenerateSpecResponse:
244
+ _check_auth(x_api_key)
245
+ _enforce_rate_limit(_rate_limit_key(request, payload.session_id))
246
+
247
+ normalized = _normalize_prompt(payload.prompt)
248
+ key = _prompt_hash(normalized, payload.mode, payload.output_type)
249
+
250
+ if key in _CACHE:
251
+ cached = dict(_CACHE[key])
252
+ cached["cached"] = True
253
+ return GenerateSpecResponse(**cached)
254
+
255
+ spec = _infer_spec(normalized, payload.mode, payload.output_type)
256
+ response = GenerateSpecResponse(
257
+ prompt_hash=key,
258
+ spec=spec,
259
+ notes=[
260
+ f"Mode: {payload.mode}",
261
+ f"Output type: {payload.output_type}",
262
+ "Prompt mapped into a structured CAD spec.",
263
+ "Replace the stub router with a real HF endpoint later.",
264
+ ],
265
+ )
266
+ _CACHE[key] = response.model_dump()
267
+ return response
268
+
269
+
270
+ @app.get("/")
271
+ def root() -> dict[str, str]:
272
+ return {"message": APP_NAME, "docs": "/docs", "health": "/v1/health"}
archive/gradio-demo-backend-legacy/requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ fastapi>=0.115.0
2
+ uvicorn[standard]>=0.30.0
3
+ pydantic>=2.8.0
4
+ python-dotenv>=1.0.1
5
+ httpx>=0.27.0
archive/package.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "live-visualizer",
3
+ "private": true,
4
+ "version": "0.1.0",
5
+ "workspaces": [
6
+ "apps/*",
7
+ "packages/*"
8
+ ]
9
+ }
docs/HF_SPACE_NOTES.md ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # NaturalCAD HF Space Notes
2
+
3
+ ## Current intent
4
+ - Public-facing NaturalCAD app
5
+ - build123d-backed execution loop
6
+ - Noah will wire a service endpoint for LLM generation later
7
+
8
+ ## Current prototype state
9
+ - Gradio UI
10
+ - real build123d execution
11
+ - STL preview
12
+ - STL + STEP downloads
13
+ - starter sample picker
14
+ - prompt note field for future LLM integration
15
+ - archived per-run artifacts under `artifacts/runs/`
16
+
17
+ ## Next likely steps
18
+ - add endpoint config pattern for external LLM service
19
+ - convert prompt note into real prompt-to-code flow
20
+ - improve public-facing examples
21
+ - add safe execution constraints for Spaces
docs/architecture.md ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # build123d Live Visualizer — Architecture Notes
2
+
3
+ ## Live Update Loop
4
+ - **Front-end trigger** – `Run & Stream` button encodes the textarea contents into an SSE request (`/api/run?code=...`).
5
+ - **Server orchestration** – Express writes the snippet to `artifacts/<job>.py`, spawns the dedicated build123d Python interpreter, and relays stdout/stderr as Server-Sent Events (`log` events).
6
+ - **Completion signal** – When the Python runner finishes, Express emits a `complete` event containing the STL path (or an error message). The client loads the STL via `three.js` and refreshes the viewer.
7
+ - **Resilience** – Each run is isolated via UUIDs, making the log stream and artifacts easy to correlate while enabling multiple sequential runs without restarts.
8
+
9
+ ## Artifact Flow
10
+ 1. Client sends code → Express persists under `artifacts/<job>.py`.
11
+ 2. Python runner executes snippet, requiring a `result` variable and exporting to `artifacts/<job>.stl`.
12
+ 3. Express serves `/artifacts` statically so the browser can fetch STL files immediately.
13
+ 4. Front-end STL loader retrieves the file, renders it, and exposes a download link; artifacts remain on disk for inspection or later cleanup.
14
+
15
+ ## LLM Integration Options
16
+ - **OpenClaw Orchestrator** – Keep the current human-in-the-loop workflow where OpenClaw agents call the `/api/run` endpoint, enabling prompt-to-geometry iteration without exposing provider keys to the prototype.
17
+ - **Direct Provider Calls** – Embed provider SDK (OpenAI, Anthropic, etc.) within the server. The Express layer would accept natural-language prompts, forward them to an LLM, and pipe the generated build123d script straight into the runner before streaming results back.
18
+ - **Local Coding Agent** – Bundle a lightweight model (e.g., `llama.cpp`) or a deterministic templating agent that runs locally, translating UI prompts to build123d code without external network usage—aligned with offline or air-gapped deployments.
19
+
20
+ ## Next-Step Roadmap
21
+ - Add job queueing plus cancellation support per run id (currently a single in-memory stream).
22
+ - Persist structured job metadata (prompt, status, artifact path) for replay and auditing.
23
+ - Harden sandboxing by running the Python process inside a constrained container or Firejail profile.
24
+ - Expand the viewer with assembly overlays (multiple STL layers, color coding, exploded views).
25
+ - Wire optional LLM prompt templates + history so designers can iterate conversationally.
26
+ - Author smoke tests covering the SSE endpoint and sample runner invocation.
docs/backend-v0.md ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # NaturalCAD Backend v0
2
+
3
+ ## Goal
4
+
5
+ Build a low-cost backend for NaturalCAD that is safe enough for an MVP:
6
+ - public UI on Hugging Face Spaces
7
+ - hosted inference on Hugging Face
8
+ - no important execution on Noah's laptop
9
+ - no public arbitrary code execution
10
+ - logs, metadata, and artifacts stored off-machine
11
+
12
+ ## Guiding priorities
13
+
14
+ 1. Keep costs low
15
+ 2. Prevent spam and abuse
16
+ 3. Keep secrets off the frontend
17
+ 4. Avoid exposing raw Python execution to the public
18
+ 5. Keep the system simple enough to actually ship
19
+
20
+ ## Recommended stack
21
+
22
+ ### Frontend
23
+ - Hugging Face Space
24
+ - current Gradio app is the fastest path
25
+
26
+ ### Backend API
27
+ - FastAPI deployed on Fly.io
28
+ - reason: build123d and worker logic are already Python-adjacent, so this reduces stack complexity while giving us a sturdier app host for API + worker processes
29
+
30
+ ### Inference
31
+ - Hugging Face Inference Endpoint or hosted HF model endpoint
32
+ - prefer free or low-cost model path for MVP
33
+
34
+ ### Database
35
+ - Supabase Postgres
36
+ - reason: structured job records, artifact metadata, and status transitions fit naturally in Postgres, and Supabase gives a good hosted dashboard with low MVP friction
37
+
38
+ ### Object storage
39
+ - hosted object storage, not local disk
40
+ - S3-compatible storage is preferred
41
+
42
+ ### Worker
43
+ - isolated Python worker for geometry generation
44
+ - build123d execution should happen here, not in the public frontend tier
45
+
46
+ ## Trust boundaries
47
+
48
+ ### Hugging Face Space
49
+ Allowed:
50
+ - collect prompts
51
+ - submit jobs to backend
52
+ - display status and results
53
+
54
+ Not allowed:
55
+ - store backend secrets
56
+ - directly execute build123d jobs
57
+ - write directly to database with privileged credentials
58
+ - be the source of truth for rate limiting or audit policy
59
+
60
+ ### Backend API
61
+ Responsible for:
62
+ - request validation
63
+ - rate limiting
64
+ - job creation
65
+ - inference calls
66
+ - schema validation
67
+ - queue handoff
68
+ - database writes
69
+ - artifact metadata
70
+ - audit logs
71
+
72
+ ### Worker
73
+ Responsible for:
74
+ - consuming approved jobs
75
+ - generating structured CAD outputs
76
+ - optionally translating internal spec to build123d code
77
+ - exporting STL/STEP
78
+ - uploading artifacts to hosted storage
79
+ - updating job status
80
+
81
+ ### Database
82
+ Store:
83
+ - job records
84
+ - prompt text
85
+ - derived structured spec
86
+ - status transitions
87
+ - artifact metadata
88
+ - session or user metadata
89
+ - audit events
90
+ - rate-limit counters if needed
91
+
92
+ ### Object storage
93
+ Store:
94
+ - STL files
95
+ - STEP files
96
+ - previews
97
+ - log blobs if needed
98
+
99
+ ## Public input model
100
+
101
+ ### Public API rule
102
+ Public users submit prompts, not arbitrary code.
103
+
104
+ That means:
105
+ - user sends prompt text
106
+ - backend calls model
107
+ - model returns structured data or a constrained internal representation
108
+ - worker generates geometry from approved internal data
109
+
110
+ ### Internal flexibility
111
+ Internally, NaturalCAD may still generate build123d code if that helps implementation.
112
+ But code generation should stay behind the backend/worker boundary, not exposed as a public execution surface.
113
+
114
+ ## Job lifecycle
115
+
116
+ Use these statuses:
117
+ - `submitted`
118
+ - `validated`
119
+ - `queued`
120
+ - `running`
121
+ - `completed`
122
+ - `failed`
123
+
124
+ Optional later:
125
+ - `blocked`
126
+ - `expired`
127
+ - `canceled`
128
+
129
+ ## Minimal API shape
130
+
131
+ ### `POST /jobs`
132
+ Create a job.
133
+
134
+ Input:
135
+ - prompt
136
+ - optional session id
137
+ - optional client metadata
138
+
139
+ Server actions:
140
+ - validate payload
141
+ - apply rate limit
142
+ - create job record
143
+ - call inference or enqueue pre-inference flow
144
+
145
+ Returns:
146
+ - job id
147
+ - status
148
+
149
+ ### `GET /jobs/{job_id}`
150
+ Fetch job status.
151
+
152
+ Returns:
153
+ - current status
154
+ - error info if failed
155
+ - artifact metadata if completed
156
+
157
+ ### `GET /jobs/{job_id}/artifacts`
158
+ Return artifact metadata and signed URLs if applicable.
159
+
160
+ ### `GET /health`
161
+ Basic health check.
162
+
163
+ ## Suggested Postgres tables
164
+
165
+ ### `jobs`
166
+ Columns:
167
+ - `id`
168
+ - `created_at`
169
+ - `updated_at`
170
+ - `status`
171
+ - `prompt`
172
+ - `normalized_prompt`
173
+ - `spec_json`
174
+ - `error_text`
175
+ - `client_session_id`
176
+ - `ip_hash`
177
+ - `model_info_json`
178
+
179
+ ### `artifacts`
180
+ Columns:
181
+ - `id`
182
+ - `job_id`
183
+ - `kind` (`stl`, `step`, `preview`, `log`)
184
+ - `storage_key`
185
+ - `size_bytes`
186
+ - `created_at`
187
+ - `expires_at`
188
+
189
+ ### `audit_events`
190
+ Columns:
191
+ - `id`
192
+ - `job_id`
193
+ - `event_type`
194
+ - `created_at`
195
+ - `details_json`
196
+
197
+ ### `rate_limits`
198
+ Optional if not handled elsewhere.
199
+
200
+ ## Queue strategy
201
+
202
+ For MVP, keep it simple.
203
+
204
+ Options:
205
+ 1. DB-backed queue with polling
206
+ 2. lightweight Redis queue later if needed
207
+
208
+ Recommendation:
209
+ - start with a DB-backed queue and one worker
210
+ - upgrade only when traffic justifies it
211
+
212
+ ## Low-cost implementation order
213
+
214
+ ### Phase 0
215
+ - keep Gradio frontend
216
+ - do not deploy public raw code execution
217
+
218
+ ### Phase 1
219
+ - create FastAPI service on Fly.io
220
+ - add `POST /jobs` and `GET /jobs/{job_id}`
221
+ - connect Supabase Postgres
222
+ - add simple rate limiting
223
+
224
+ ### Phase 2
225
+ - connect HF inference endpoint
226
+ - store prompt, status, and response metadata
227
+ - validate returned structured output
228
+
229
+ ### Phase 3
230
+ - add Python worker
231
+ - generate artifacts
232
+ - upload artifacts to hosted storage
233
+ - return signed artifact links
234
+
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
241
+ - no local laptop as production backend
242
+ - no secrets in frontend code
243
+ - no unlimited artifact retention
244
+ - no complicated microservice split
245
+
246
+ ## Default recommendation
247
+
248
+ NaturalCAD v0 should ship as:
249
+ - Hugging Face Space frontend
250
+ - FastAPI backend on Fly.io
251
+ - Supabase Postgres
252
+ - hosted object storage
253
+ - one Python worker
254
+ - strict prompt-only public interface
docs/hf-space-deploy-checklist.md ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # NaturalCAD Hugging Face Space Deploy Checklist
2
+
3
+ ## Minimum checklist
4
+
5
+ - [ ] Gradio app runs cleanly from `apps/gradio-demo/app/main.py`
6
+ - [ ] `requirements.txt` contains everything needed for Space runtime, including `build123d`
7
+ - [ ] prompt-to-model flow works without requiring local-only paths that break in Space
8
+ - [ ] example prompts produce valid outputs
9
+ - [ ] timeouts are in place
10
+ - [ ] artifacts are bounded and not unbounded temp junk
11
+ - [ ] lightweight run logging is enabled
12
+ - [ ] README explains local run and Space intent clearly
13
+
14
+ ## MVP notes
15
+
16
+ For public testing, the demo should degrade gracefully.
17
+ If the backend is unavailable, the app should still be able to produce a simple local fallback result rather than fully dying.
18
+
19
+ For the lean MVP, backend use should be optional, not assumed. If `NATURALCAD_BACKEND_URL` is unset, the Space should stay usable without waiting on a dead localhost request.
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
26
+ - run id
27
+ - prompt
28
+ - mode
29
+ - output type
30
+ - geometry family
31
+ - backend available or not
32
+ - success or failure
33
+ - runtime seconds
34
+ - error string if any
docs/hf-space-mvp.md ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # NaturalCAD Hugging Face Space MVP
2
+
3
+ ## Goal
4
+
5
+ Ship NaturalCAD quickly as a public testable demo.
6
+
7
+ Primary goals:
8
+ - get real users testing the interaction
9
+ - collect prompt/result data
10
+ - learn what people actually want
11
+ - defer heavier backend infrastructure until the product proves itself
12
+
13
+ ## Product strategy
14
+
15
+ This MVP is intentionally simple.
16
+
17
+ NaturalCAD v0 should be:
18
+ - a Hugging Face Space
19
+ - a Gradio app
20
+ - a prompt-to-geometry demo
21
+ - a data-gathering surface
22
+
23
+ It should not yet be:
24
+ - a full distributed backend system
25
+ - a polished production SaaS
26
+ - a heavily abstracted multi-service platform
27
+
28
+ ## What we keep now
29
+
30
+ - `apps/gradio-demo`
31
+ - prompt input
32
+ - stub/backend-assisted spec generation if useful
33
+ - build123d execution path
34
+ - STL preview
35
+ - STL and STEP downloads
36
+ - lightweight run logging
37
+ - examples and clean presentation
38
+
39
+ ## What we defer
40
+
41
+ - Fly.io deployment
42
+ - Supabase integration
43
+ - hosted object storage
44
+ - worker separation
45
+ - full job queue
46
+ - advanced auth system
47
+ - fine-grained persistence and audit pipeline
48
+
49
+ ## Minimal requirements before public testing
50
+
51
+ 1. The Space must run reliably
52
+ 2. The demo must generate models from prompts
53
+ 3. The UI must be clear and attractive enough for strangers to try
54
+ 4. We should capture lightweight feedback and run metadata
55
+ 5. We should avoid obvious abuse vectors where possible
56
+
57
+ ## Recommended lightweight data capture
58
+
59
+ For each run, capture only what helps learning:
60
+ - timestamp
61
+ - prompt
62
+ - mode
63
+ - output type
64
+ - inferred geometry family
65
+ - success or failure
66
+ - runtime duration
67
+ - optional notes or error string
68
+
69
+ This can begin as flat-file logging inside the Space or another lightweight mechanism, then migrate later.
70
+
71
+ ## Security posture for MVP
72
+
73
+ Keep it simple:
74
+ - no public arbitrary code input
75
+ - prompt input only
76
+ - keep prompts length-limited
77
+ - keep timeouts in place
78
+ - keep generated artifacts bounded
79
+ - do not expose secrets in frontend code
80
+
81
+ This is not full production hardening. It is enough to ship a controlled public demo.
82
+
83
+ ## Phase model
84
+
85
+ ### Phase 1
86
+ Ship the Hugging Face Space and gather usage.
87
+
88
+ ### Phase 2
89
+ If traction appears, add external persistence and better job tracking.
90
+
91
+ ### Phase 3
92
+ If usage justifies it, move to hosted backend, storage, scaling, and eventually model fine-tuning.
93
+
94
+ ## Decision
95
+
96
+ NaturalCAD should optimize for public testing first, and infrastructure maturity second.