@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 +1 -1
- package/package.json +1 -1
- package/python/export_runner.py +60 -2
- package/python/rebuild_daemon.py +217 -0
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
|
-
`
|
|
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
package/python/export_runner.py
CHANGED
|
@@ -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())
|