@forgent3d/cad-runtime 0.1.3 → 0.1.5

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/README.md CHANGED
@@ -9,7 +9,7 @@ This package is intentionally runtime-light:
9
9
  - no React
10
10
  - no database clients
11
11
 
12
- `cad-agent` and `cf-sandbox` can both depend on this package without coupling their deploy targets together.
12
+ `@forgent3d/cloud` and `cf-sandbox` can both depend on this package without coupling their deploy targets together.
13
13
 
14
14
  ## Python Runtime Assets
15
15
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgent3d/cad-runtime",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Shared CAD runtime protocol, types, and pure utilities for Forgent3D cloud services.",
5
5
  "license": "MIT",
6
6
  "author": "Forgent3D",
@@ -30,6 +30,58 @@ MODELS_DIR = os.path.join(PROJECT_ROOT, "models")
30
30
  CACHE_DIR = os.path.join(PROJECT_ROOT, ".cache")
31
31
  MODEL_KINDS = ("part",)
32
32
 
33
+ # Sentinel-prefixed line emitted on stdout when --emit-build-json is passed, so a
34
+ # caller can read the build summary (bbox/anchors/hasResult) from this single run
35
+ # instead of a separate aicad-script build pass. Keep in sync with cf-sandbox rebuild.ts.
36
+ BUILD_JSON_SENTINEL = "@@AICAD_BUILD_JSON@@"
37
+
38
+
39
+ def _emit_build_json(payload) -> None:
40
+ print(BUILD_JSON_SENTINEL + json.dumps(payload, ensure_ascii=False, separators=(",", ":")))
41
+
42
+
43
+ def _bbox_summary(shape):
44
+ if shape is None or not hasattr(shape, "bounding_box"):
45
+ return None
46
+ try:
47
+ bb = shape.bounding_box()
48
+ return {
49
+ "min": [float(bb.min.X), float(bb.min.Y), float(bb.min.Z)],
50
+ "max": [float(bb.max.X), float(bb.max.Y), float(bb.max.Z)],
51
+ "size": [
52
+ float(bb.max.X - bb.min.X),
53
+ float(bb.max.Y - bb.min.Y),
54
+ float(bb.max.Z - bb.min.Z),
55
+ ],
56
+ }
57
+ except Exception:
58
+ return None
59
+
60
+
61
+ def _build_summary_payload(model_name, part_name, source_path, ns, result):
62
+ metadata = ns.get("metadata") if isinstance(ns, dict) else None
63
+ anchors = None
64
+ metadata_keys = []
65
+ if isinstance(metadata, dict):
66
+ metadata_keys = sorted(str(k) for k in metadata.keys())
67
+ if isinstance(metadata.get("anchors"), dict):
68
+ try:
69
+ anchors = _json_safe(metadata.get("anchors"))
70
+ except Exception:
71
+ anchors = None
72
+ return {
73
+ "ok": result is not None,
74
+ "script": "build",
75
+ "model": model_name,
76
+ "part": part_name,
77
+ "source": os.path.relpath(source_path, PROJECT_ROOT).replace(os.sep, "/") if source_path else None,
78
+ "resultType": type(result).__name__ if result is not None else None,
79
+ "hasResult": result is not None,
80
+ "bbox": _bbox_summary(result),
81
+ "metadataKeys": metadata_keys,
82
+ "metadataAnchors": anchors,
83
+ }
84
+
33
85
 
34
86
  def _looks_like_build123d(obj) -> bool:
35
87
  return any(c.__module__.startswith("build123d") for c in type(obj).__mro__)
@@ -552,7 +604,7 @@ def _build_namespace(model_name: str, part_name: str, source_override: str = Non
552
604
  return ns, source_path, 0
553
605
 
554
606
 
555
- def build_one(model_name: str, part_name: str = None, export_format: str = "brep", output: str = None, source_override: str = None) -> int:
607
+ def build_one(model_name: str, part_name: str = None, export_format: str = "brep", output: str = None, source_override: str = None, emit_build_json: bool = False) -> int:
556
608
  part_name = part_name or model_name
557
609
  build_started = time.perf_counter()
558
610
  ns, source_path, err = _build_namespace(model_name, part_name, source_override)
@@ -566,6 +618,8 @@ def build_one(model_name: str, part_name: str = None, export_format: str = "brep
566
618
  if candidate is not None:
567
619
  result = candidate
568
620
  if result is None:
621
+ if emit_build_json:
622
+ _emit_build_json(_build_summary_payload(model_name, part_name, source_path, ns, None))
569
623
  print(f"[export_runner] {source_path} must define a global result (or assembly) object (build123d).",
570
624
  file=sys.stderr)
571
625
  return 4
@@ -575,6 +629,8 @@ def build_one(model_name: str, part_name: str = None, export_format: str = "brep
575
629
  except Exception as exc:
576
630
  print(f"[export_runner] Failed to write metadata.json: {exc}", file=sys.stderr)
577
631
  return 8
632
+ if emit_build_json:
633
+ _emit_build_json(_build_summary_payload(model_name, part_name, source_path, ns, result))
578
634
 
579
635
  fmt = (export_format or "brep").strip().lower()
580
636
  if fmt not in ("brep", "step", "stl", "obj", "glb", "3mf"):
@@ -625,6 +681,8 @@ def main() -> int:
625
681
  parser.add_argument("--source", default=None, help="Optional project-relative or absolute source file path (overrides default lookup)")
626
682
  parser.add_argument("--export-format", default="brep", choices=["brep", "step", "stl", "obj", "glb", "3mf"])
627
683
  parser.add_argument("--output", default=None, help="Optional absolute path for exported file")
684
+ parser.add_argument("--emit-build-json", action="store_true",
685
+ help="Emit a sentinel-prefixed build-summary JSON line on stdout (cloud rebuild fast path)")
628
686
  args = parser.parse_args()
629
687
  global PROJECT_ROOT, MODELS_DIR, CACHE_DIR
630
688
  PROJECT_ROOT = os.path.abspath(args.project) if args.project else HERE
@@ -633,7 +691,7 @@ def main() -> int:
633
691
  model_name = args.model or args.part
634
692
  if not model_name:
635
693
  parser.error("one of --model / --part is required")
636
- return build_one(model_name, args.part_name or model_name, args.export_format, args.output, args.source)
694
+ return build_one(model_name, args.part_name or model_name, args.export_format, args.output, args.source, args.emit_build_json)
637
695
 
638
696
 
639
697
  if __name__ == "__main__":
@@ -0,0 +1,217 @@
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 json
24
+ import os
25
+ import sys
26
+ import traceback
27
+ from http.server import BaseHTTPRequestHandler, HTTPServer
28
+
29
+ HERE = os.path.dirname(os.path.abspath(__file__))
30
+ if HERE not in sys.path:
31
+ sys.path.insert(0, HERE)
32
+
33
+ import export_runner as er # reuse the exact build/export/metadata logic
34
+
35
+ # Modules + sys.path present right after warmup. Anything a request adds (the user's
36
+ # part.py and its local imports) is rolled back afterwards so edits rebuild fresh and
37
+ # sys.path/sys.modules don't grow unbounded across requests.
38
+ _BASE_MODULES: set = set()
39
+ _BASE_SYS_PATH: list = []
40
+
41
+
42
+ def _warmup() -> None:
43
+ global _BASE_MODULES, _BASE_SYS_PATH
44
+ import build123d # noqa: F401
45
+ from build123d import export_gltf, Unit # noqa: F401 (load the GLB export path too)
46
+ for optional in ("aicad_select", "aicad_attach"):
47
+ try:
48
+ __import__(optional)
49
+ except Exception:
50
+ pass
51
+ _BASE_MODULES = set(sys.modules)
52
+ _BASE_SYS_PATH = list(sys.path)
53
+
54
+
55
+ def _reset_after_request() -> None:
56
+ for name in list(sys.modules):
57
+ if name not in _BASE_MODULES:
58
+ sys.modules.pop(name, None)
59
+ sys.path[:] = _BASE_SYS_PATH
60
+
61
+
62
+ def _build_export(req: dict) -> dict:
63
+ project = os.path.abspath(str(req.get("project") or os.getcwd()))
64
+ er.PROJECT_ROOT = project
65
+ er.MODELS_DIR = os.path.join(project, "models")
66
+ er.CACHE_DIR = os.path.join(project, ".cache")
67
+
68
+ model = str(req.get("model") or "").strip()
69
+ part = str(req.get("part") or model).strip()
70
+ fmt = str(req.get("format") or "glb").strip().lower()
71
+ output = req.get("output")
72
+ source = req.get("source")
73
+ if not model:
74
+ return {"ok": False, "model": "", "part": "", "hasResult": False, "error": "model is required."}
75
+
76
+ ns, source_path, err = er._build_namespace(model, part, source)
77
+ if err:
78
+ return {"ok": False, "model": model, "part": part, "hasResult": False,
79
+ "error": f"build failed (export_runner code {err}); see daemon stderr"}
80
+
81
+ result = ns.get("result", None)
82
+ if result is None:
83
+ result = ns.get("assembly", None)
84
+ payload = er._build_summary_payload(model, part, source_path, ns, result)
85
+ if result is None:
86
+ return payload # ok=False, hasResult=False -> caller emits "must define a global result"
87
+
88
+ try:
89
+ er._ensure_assembly_metadata(ns, result)
90
+ er._write_metadata(source_path, ns)
91
+ except Exception as exc:
92
+ payload["ok"] = False
93
+ payload["error"] = f"metadata write failed: {exc}"
94
+ return payload
95
+
96
+ out = os.path.abspath(output) if output else os.path.join(er.CACHE_DIR, f"{model}__{part}.{fmt}")
97
+ os.makedirs(os.path.dirname(out), exist_ok=True)
98
+ try:
99
+ if fmt == "glb":
100
+ er._write_glb(result, out, er._load_model_params(model))
101
+ elif fmt == "brep":
102
+ er._write_brep(result, out)
103
+ elif fmt == "step":
104
+ er._write_step(result, out)
105
+ elif fmt == "stl":
106
+ er._write_stl(result, out)
107
+ elif fmt == "obj":
108
+ er._write_obj(result, out, model)
109
+ elif fmt == "3mf":
110
+ er._write_3mf(result, out, er._load_model_params(model), model)
111
+ else:
112
+ payload["ok"] = False
113
+ payload["error"] = f"unsupported export format: {fmt}"
114
+ return payload
115
+ except Exception as exc:
116
+ payload["ok"] = False
117
+ payload["error"] = f"{fmt} export failed: {exc}"
118
+ return payload
119
+
120
+ size = os.path.getsize(out) if os.path.exists(out) else 0
121
+ if size <= 0:
122
+ payload["ok"] = False
123
+ payload["error"] = "exported file is empty"
124
+ return payload
125
+ payload["outputSize"] = size
126
+ return payload
127
+
128
+
129
+ class Handler(BaseHTTPRequestHandler):
130
+ protocol_version = "HTTP/1.1"
131
+
132
+ def log_message(self, *args): # silence default request logging
133
+ pass
134
+
135
+ def _send(self, code: int, obj: dict) -> None:
136
+ body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
137
+ self.send_response(code)
138
+ self.send_header("Content-Type", "application/json")
139
+ self.send_header("Content-Length", str(len(body)))
140
+ self.end_headers()
141
+ self.wfile.write(body)
142
+
143
+ def do_GET(self):
144
+ if self.path.rstrip("/") == "/health":
145
+ self._send(200, {"ok": True, "pid": os.getpid(), "ready": bool(_BASE_MODULES)})
146
+ else:
147
+ self._send(404, {"ok": False, "error": "not found"})
148
+
149
+ def do_POST(self):
150
+ if self.path.rstrip("/") != "/build_export":
151
+ self._send(404, {"ok": False, "error": "not found"})
152
+ return
153
+ try:
154
+ length = int(self.headers.get("Content-Length") or 0)
155
+ req = json.loads(self.rfile.read(length) or b"{}")
156
+ except Exception as exc:
157
+ self._send(400, {"ok": False, "error": f"bad request: {exc}"})
158
+ return
159
+ try:
160
+ result = _build_export(req)
161
+ self._send(200, result)
162
+ except Exception as exc:
163
+ print(f"[rebuild_daemon] handler error: {exc}", file=sys.stderr)
164
+ traceback.print_exc()
165
+ self._send(200, {"ok": False, "error": f"daemon error: {exc}"})
166
+ finally:
167
+ _reset_after_request()
168
+
169
+
170
+ def client_main(argv) -> int:
171
+ """`rebuild_daemon.py client <METHOD> <PATH> [<base64-json-body>] [<port>]`
172
+
173
+ A dependency-free way for cf-sandbox to reach the daemon: a bare `python3`
174
+ spawn (no build123d import) that POSTs to the localhost daemon and prints the
175
+ response JSON. On connection failure it prints {"__client_error__": ...} so the
176
+ caller can fall back to one-shot export_runner.py.
177
+ """
178
+ import base64
179
+ import urllib.request
180
+
181
+ method = argv[0] if argv else "GET"
182
+ path = argv[1] if len(argv) > 1 else "/health"
183
+ body = base64.b64decode(argv[2]) if len(argv) > 2 and argv[2] else None
184
+ port = int(argv[3]) if len(argv) > 3 and argv[3] else 8765
185
+ req = urllib.request.Request(
186
+ f"http://127.0.0.1:{port}{path}", data=body, method=method,
187
+ headers={"Content-Type": "application/json"},
188
+ )
189
+ try:
190
+ sys.stdout.write(urllib.request.urlopen(req, timeout=125).read().decode())
191
+ return 0
192
+ except Exception as exc:
193
+ sys.stdout.write(json.dumps({"__client_error__": str(exc)}))
194
+ return 3
195
+
196
+
197
+ def main() -> int:
198
+ if len(sys.argv) > 1 and sys.argv[1] == "client":
199
+ return client_main(sys.argv[2:])
200
+
201
+ parser = argparse.ArgumentParser()
202
+ parser.add_argument("--host", default="127.0.0.1")
203
+ parser.add_argument("--port", type=int, default=8765)
204
+ args = parser.parse_args()
205
+
206
+ _warmup()
207
+ server = HTTPServer((args.host, args.port), Handler)
208
+ print(f"[rebuild_daemon] ready on {args.host}:{args.port} pid={os.getpid()}", flush=True)
209
+ try:
210
+ server.serve_forever()
211
+ except KeyboardInterrupt:
212
+ pass
213
+ return 0
214
+
215
+
216
+ if __name__ == "__main__":
217
+ sys.exit(main())