@forgent3d/cad-runtime 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgent3d/cad-runtime",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Shared CAD runtime protocol, types, and pure utilities for Forgent3D cloud services.",
5
5
  "license": "MIT",
6
6
  "author": "Forgent3D",
@@ -0,0 +1,341 @@
1
+ """
2
+ rebuild_daemon.py - warm build123d/OCP worker for cf-sandbox rebuilds.
3
+
4
+ A plain `python3 export_runner.py` pays ~2.2s to `import build123d` (CPU-bound OCP
5
+ type registration, not just .so paging) on *every* call. This long-lived daemon
6
+ imports build123d/OCP once and then serves build+export requests over a localhost
7
+ HTTP port, so each rebuild only pays the actual geometry cost (single-digit to low
8
+ hundreds of ms for typical parts).
9
+
10
+ It is a single-process server (build123d/OCP is not thread-safe and a session's
11
+ rebuilds are sequential). The caller (cf-sandbox) treats it as a best-effort fast
12
+ path and falls back to one-shot export_runner.py on any failure, so this never has
13
+ to be bulletproof — if it crashes, the next request relaunches and re-warms it.
14
+
15
+ Protocol (newline JSON bodies):
16
+ GET /health -> {"ok": true, "pid": <int>, "ready": <bool>}
17
+ POST /build_export -> body {project, model, part?, output, format?, source?}
18
+ returns the export_runner build-summary dict
19
+ {ok, model, part, source, resultType, hasResult, bbox,
20
+ metadataKeys, metadataAnchors, error?}, GLB written to `output`.
21
+ """
22
+ import argparse
23
+ import importlib.util
24
+ import json
25
+ import os
26
+ import sys
27
+ import traceback
28
+ from collections import OrderedDict
29
+ from http.server import BaseHTTPRequestHandler, HTTPServer
30
+
31
+ HERE = os.path.dirname(os.path.abspath(__file__))
32
+ if HERE not in sys.path:
33
+ sys.path.insert(0, HERE)
34
+
35
+ import export_runner as er # reuse the exact build/export/metadata logic
36
+
37
+
38
+ def _load_module(filename: str, modname: str):
39
+ spec = importlib.util.spec_from_file_location(modname, os.path.join(HERE, filename))
40
+ mod = importlib.util.module_from_spec(spec)
41
+ spec.loader.exec_module(mod)
42
+ return mod
43
+
44
+
45
+ # aicad-script.py has a hyphen -> load it by path under an importable name so we can
46
+ # reuse its probe helpers (split_target, load_namespace, build_probe_namespace, ...).
47
+ aicad_script = _load_module("aicad-script.py", "aicad_script")
48
+
49
+ # Modules + sys.path present right after warmup. Anything a request adds (the user's
50
+ # part.py and its local imports) is rolled back afterwards so edits rebuild fresh and
51
+ # sys.path/sys.modules don't grow unbounded across requests.
52
+ _BASE_MODULES: set = set()
53
+ _BASE_SYS_PATH: list = []
54
+
55
+ # Built namespaces cached for probe, keyed by (project, model, part). Probes are bursty
56
+ # on the same unchanged model, so this lets the 2nd..Nth probe skip the rebuild entirely
57
+ # (not just the import). Invalidated when part.py or any project-local import it pulled in
58
+ # changes (mtime+size), so editing code still probes fresh.
59
+ _PROBE_CACHE: "OrderedDict[tuple, dict]" = OrderedDict()
60
+ _PROBE_CACHE_MAX = 8
61
+
62
+
63
+ def _warmup() -> None:
64
+ global _BASE_MODULES, _BASE_SYS_PATH
65
+ import build123d # noqa: F401
66
+ from build123d import export_gltf, Unit # noqa: F401 (load the GLB export path too)
67
+ for optional in ("aicad_select", "aicad_attach"):
68
+ try:
69
+ __import__(optional)
70
+ except Exception:
71
+ pass
72
+ _BASE_MODULES = set(sys.modules)
73
+ _BASE_SYS_PATH = list(sys.path)
74
+
75
+
76
+ def _reset_after_request() -> None:
77
+ for name in list(sys.modules):
78
+ if name not in _BASE_MODULES:
79
+ sys.modules.pop(name, None)
80
+ sys.path[:] = _BASE_SYS_PATH
81
+
82
+
83
+ def _build_export(req: dict) -> dict:
84
+ project = os.path.abspath(str(req.get("project") or os.getcwd()))
85
+ er.PROJECT_ROOT = project
86
+ er.MODELS_DIR = os.path.join(project, "models")
87
+ er.CACHE_DIR = os.path.join(project, ".cache")
88
+
89
+ model = str(req.get("model") or "").strip()
90
+ part = str(req.get("part") or model).strip()
91
+ fmt = str(req.get("format") or "glb").strip().lower()
92
+ output = req.get("output")
93
+ source = req.get("source")
94
+ if not model:
95
+ return {"ok": False, "model": "", "part": "", "hasResult": False, "error": "model is required."}
96
+
97
+ ns, source_path, err = er._build_namespace(model, part, source)
98
+ if err:
99
+ return {"ok": False, "model": model, "part": part, "hasResult": False,
100
+ "error": f"build failed (export_runner code {err}); see daemon stderr"}
101
+
102
+ result = ns.get("result", None)
103
+ if result is None:
104
+ result = ns.get("assembly", None)
105
+ payload = er._build_summary_payload(model, part, source_path, ns, result)
106
+ if result is None:
107
+ return payload # ok=False, hasResult=False -> caller emits "must define a global result"
108
+
109
+ try:
110
+ er._ensure_assembly_metadata(ns, result)
111
+ er._write_metadata(source_path, ns)
112
+ except Exception as exc:
113
+ payload["ok"] = False
114
+ payload["error"] = f"metadata write failed: {exc}"
115
+ return payload
116
+
117
+ out = os.path.abspath(output) if output else os.path.join(er.CACHE_DIR, f"{model}__{part}.{fmt}")
118
+ os.makedirs(os.path.dirname(out), exist_ok=True)
119
+ try:
120
+ if fmt == "glb":
121
+ er._write_glb(result, out, er._load_model_params(model))
122
+ elif fmt == "brep":
123
+ er._write_brep(result, out)
124
+ elif fmt == "step":
125
+ er._write_step(result, out)
126
+ elif fmt == "stl":
127
+ er._write_stl(result, out)
128
+ elif fmt == "obj":
129
+ er._write_obj(result, out, model)
130
+ elif fmt == "3mf":
131
+ er._write_3mf(result, out, er._load_model_params(model), model)
132
+ else:
133
+ payload["ok"] = False
134
+ payload["error"] = f"unsupported export format: {fmt}"
135
+ return payload
136
+ except Exception as exc:
137
+ payload["ok"] = False
138
+ payload["error"] = f"{fmt} export failed: {exc}"
139
+ return payload
140
+
141
+ size = os.path.getsize(out) if os.path.exists(out) else 0
142
+ if size <= 0:
143
+ payload["ok"] = False
144
+ payload["error"] = "exported file is empty"
145
+ return payload
146
+ payload["outputSize"] = size
147
+ return payload
148
+
149
+
150
+ def _file_sig(path):
151
+ try:
152
+ st = os.stat(path)
153
+ return (st.st_mtime_ns, st.st_size)
154
+ except OSError:
155
+ return None
156
+
157
+
158
+ def _deps_fresh(deps: dict) -> bool:
159
+ return all(_file_sig(f) == sig for f, sig in deps.items())
160
+
161
+
162
+ def _load_ns_with_deps(model: str, part: str):
163
+ """exec the model source and record the project-local files it pulled in, so the
164
+ cached namespace can be invalidated when part.py or any local helper changes."""
165
+ before = set(sys.modules)
166
+ ns, source = aicad_script.load_namespace(model, part)
167
+ deps = {source: _file_sig(source)}
168
+ # params.json is read at build time (dims/materials) but isn't a Python import, so
169
+ # track it explicitly; otherwise a params edit would serve a stale cached namespace.
170
+ params = os.path.join(aicad_script.MODELS_DIR, model, "params.json")
171
+ if os.path.isfile(params):
172
+ deps[params] = _file_sig(params)
173
+ root = os.path.abspath(aicad_script.PROJECT_ROOT) + os.sep
174
+ for name in set(sys.modules) - before:
175
+ mod = sys.modules.get(name)
176
+ f = getattr(mod, "__file__", None)
177
+ if f and os.path.abspath(f).startswith(root):
178
+ deps[f] = _file_sig(f)
179
+ return ns, deps
180
+
181
+
182
+ def _cached_ns(model: str, part: str):
183
+ key = (aicad_script.PROJECT_ROOT, model, part)
184
+ entry = _PROBE_CACHE.get(key)
185
+ if entry and _deps_fresh(entry["deps"]):
186
+ _PROBE_CACHE.move_to_end(key)
187
+ return entry["ns"], True
188
+ ns, deps = _load_ns_with_deps(model, part)
189
+ _PROBE_CACHE[key] = {"ns": ns, "deps": deps}
190
+ _PROBE_CACHE.move_to_end(key)
191
+ while len(_PROBE_CACHE) > _PROBE_CACHE_MAX:
192
+ _PROBE_CACHE.popitem(last=False)
193
+ return ns, False
194
+
195
+
196
+ def _probe(req: dict) -> dict:
197
+ """Mirror aicad-script.py command_probe, but reuse the warm interpreter and the
198
+ cached built namespace. Returns the same payload shape command_probe does."""
199
+ project = os.path.abspath(str(req.get("project") or os.getcwd()))
200
+ aicad_script.PROJECT_ROOT = project
201
+ aicad_script.MODELS_DIR = os.path.join(project, "models")
202
+
203
+ argv = req.get("argv") or []
204
+ try:
205
+ flags, positionals = aicad_script.parse_flags(argv)
206
+ if positionals:
207
+ raise ValueError('probe expects flags, e.g. -component model/part -expr "top_edges(part)"')
208
+ component = aicad_script.flag_value(flags, "component", "target", "part")
209
+ component_b = aicad_script.flag_value(flags, "component-b", "componentB", "other-component", "other", "b")
210
+ expression = str(aicad_script.flag_value(flags, "expr", "expression") or "").strip()
211
+ if not expression:
212
+ expression = "bbox_relation(part_a, part_b)" if component_b else "part"
213
+
214
+ model, part = aicad_script.split_target(component or "")
215
+ ns, hit_a = _cached_ns(model, part)
216
+ if ns.get("result", None) is None:
217
+ raise ValueError("part.py must define a global result before probing")
218
+
219
+ ns_b = None
220
+ model_b = part_b = None
221
+ cache_hits = [hit_a]
222
+ if component_b:
223
+ model_b, part_b = aicad_script.split_target(component_b)
224
+ ns_b, hit_b = _cached_ns(model_b, part_b)
225
+ cache_hits.append(hit_b)
226
+ if ns_b.get("result", None) is None:
227
+ raise ValueError("componentB part.py must define a global result before probing")
228
+
229
+ probe_ns = aicad_script.build_probe_namespace(ns, ns_b)
230
+ value = eval(expression, probe_ns, probe_ns)
231
+ payload = {
232
+ "ok": True,
233
+ "script": "probe",
234
+ "model": model,
235
+ "part": part,
236
+ "expression": expression,
237
+ "value": aicad_script.summarize_value(value),
238
+ "cached": all(cache_hits),
239
+ }
240
+ if model_b and part_b:
241
+ payload["componentB"] = {"model": model_b, "part": part_b}
242
+ return payload
243
+ except Exception as exc:
244
+ # Deterministic error (bad expression / missing result / model build error): return
245
+ # it with the traceback so the agent gets the same info the cold path would give —
246
+ # re-running cold would only repeat it after paying the import.
247
+ return {"ok": False, "script": "probe", "error": f"{type(exc).__name__}: {exc}",
248
+ "traceback": traceback.format_exc(limit=8)}
249
+
250
+
251
+ class Handler(BaseHTTPRequestHandler):
252
+ protocol_version = "HTTP/1.1"
253
+
254
+ def log_message(self, *args): # silence default request logging
255
+ pass
256
+
257
+ def _send(self, code: int, obj: dict) -> None:
258
+ body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
259
+ self.send_response(code)
260
+ self.send_header("Content-Type", "application/json")
261
+ self.send_header("Content-Length", str(len(body)))
262
+ self.end_headers()
263
+ self.wfile.write(body)
264
+
265
+ def do_GET(self):
266
+ if self.path.rstrip("/") == "/health":
267
+ self._send(200, {"ok": True, "pid": os.getpid(), "ready": bool(_BASE_MODULES)})
268
+ else:
269
+ self._send(404, {"ok": False, "error": "not found"})
270
+
271
+ def do_POST(self):
272
+ route = self.path.rstrip("/")
273
+ handlers = {"/build_export": _build_export, "/probe": _probe}
274
+ handler = handlers.get(route)
275
+ if handler is None:
276
+ self._send(404, {"ok": False, "error": "not found"})
277
+ return
278
+ try:
279
+ length = int(self.headers.get("Content-Length") or 0)
280
+ req = json.loads(self.rfile.read(length) or b"{}")
281
+ except Exception as exc:
282
+ self._send(400, {"ok": False, "error": f"bad request: {exc}"})
283
+ return
284
+ try:
285
+ self._send(200, handler(req))
286
+ except Exception as exc:
287
+ print(f"[rebuild_daemon] handler error: {exc}", file=sys.stderr)
288
+ traceback.print_exc()
289
+ self._send(200, {"ok": False, "error": f"daemon error: {exc}"})
290
+ finally:
291
+ _reset_after_request()
292
+
293
+
294
+ def client_main(argv) -> int:
295
+ """`rebuild_daemon.py client <METHOD> <PATH> [<base64-json-body>] [<port>]`
296
+
297
+ A dependency-free way for cf-sandbox to reach the daemon: a bare `python3`
298
+ spawn (no build123d import) that POSTs to the localhost daemon and prints the
299
+ response JSON. On connection failure it prints {"__client_error__": ...} so the
300
+ caller can fall back to one-shot export_runner.py.
301
+ """
302
+ import base64
303
+ import urllib.request
304
+
305
+ method = argv[0] if argv else "GET"
306
+ path = argv[1] if len(argv) > 1 else "/health"
307
+ body = base64.b64decode(argv[2]) if len(argv) > 2 and argv[2] else None
308
+ port = int(argv[3]) if len(argv) > 3 and argv[3] else 8765
309
+ req = urllib.request.Request(
310
+ f"http://127.0.0.1:{port}{path}", data=body, method=method,
311
+ headers={"Content-Type": "application/json"},
312
+ )
313
+ try:
314
+ sys.stdout.write(urllib.request.urlopen(req, timeout=125).read().decode())
315
+ return 0
316
+ except Exception as exc:
317
+ sys.stdout.write(json.dumps({"__client_error__": str(exc)}))
318
+ return 3
319
+
320
+
321
+ def main() -> int:
322
+ if len(sys.argv) > 1 and sys.argv[1] == "client":
323
+ return client_main(sys.argv[2:])
324
+
325
+ parser = argparse.ArgumentParser()
326
+ parser.add_argument("--host", default="127.0.0.1")
327
+ parser.add_argument("--port", type=int, default=8765)
328
+ args = parser.parse_args()
329
+
330
+ _warmup()
331
+ server = HTTPServer((args.host, args.port), Handler)
332
+ print(f"[rebuild_daemon] ready on {args.host}:{args.port} pid={os.getpid()}", flush=True)
333
+ try:
334
+ server.serve_forever()
335
+ except KeyboardInterrupt:
336
+ pass
337
+ return 0
338
+
339
+
340
+ if __name__ == "__main__":
341
+ sys.exit(main())