@forgent3d/cad-runtime 0.1.0

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.
@@ -0,0 +1,316 @@
1
+ """
2
+ export_runner.py - Auto-managed by AI CAD Companion Viewer (do not edit manually)
3
+ ----------------------------------------------------------------------------
4
+ Responsibilities:
5
+ * Accept --model <name> (and legacy --part <name>);
6
+ * Optionally support on-demand exports via --export-format/--output (step/stl/brep);
7
+ * Load models/<model>/parts/<part-name>/part.py and read a geometry object named result;
8
+ * Export build123d geometry to .cache/<name>.brep (and STEP/STL) via OCCT APIs;
9
+ * Let the frontend parse BREP via occt-import-js for geometry inspection.
10
+ """
11
+ import os
12
+ import sys
13
+
14
+ # Must run before other imports / open() on Windows (locale default is often GBK).
15
+ if sys.platform == "win32":
16
+ os.environ["PYTHONUTF8"] = "1"
17
+ os.environ["PYTHONIOENCODING"] = "utf-8"
18
+
19
+ import argparse
20
+ import json
21
+ import time
22
+ import traceback
23
+
24
+ HERE = os.path.dirname(os.path.abspath(__file__))
25
+ PROJECT_ROOT = HERE
26
+ MODELS_DIR = os.path.join(PROJECT_ROOT, "models")
27
+ CACHE_DIR = os.path.join(PROJECT_ROOT, ".cache")
28
+ MODEL_KINDS = ("part",)
29
+
30
+
31
+ def _looks_like_build123d(obj) -> bool:
32
+ return any(c.__module__.startswith("build123d") for c in type(obj).__mro__)
33
+
34
+
35
+ def _write_brep(shape, path_out):
36
+ if _looks_like_build123d(shape):
37
+ try:
38
+ from build123d import export_brep # type: ignore
39
+ export_brep(shape, path_out)
40
+ return "build123d.export_brep"
41
+ except Exception:
42
+ pass
43
+
44
+ try:
45
+ from OCP.BRepTools import BRepTools # type: ignore
46
+ inner = getattr(shape, "wrapped", shape)
47
+ BRepTools.Write_s(inner, path_out)
48
+ return "OCP.BRepTools.Write_s"
49
+ except Exception as exc:
50
+ raise RuntimeError(f"Unable to export BREP: {exc}")
51
+
52
+
53
+ def _write_step(shape, path_out):
54
+ if _looks_like_build123d(shape):
55
+ try:
56
+ from build123d import export_step # type: ignore
57
+ export_step(shape, path_out)
58
+ return "build123d.export_step"
59
+ except Exception:
60
+ pass
61
+
62
+ try:
63
+ from OCP.STEPControl import STEPControl_Writer, STEPControl_AsIs # type: ignore
64
+ from OCP.IFSelect import IFSelect_RetDone # type: ignore
65
+ inner = getattr(shape, "wrapped", shape)
66
+ writer = STEPControl_Writer()
67
+ writer.Transfer(inner, STEPControl_AsIs)
68
+ status = writer.Write(path_out)
69
+ if status != IFSelect_RetDone:
70
+ raise RuntimeError(f"STEP write failed with status: {status}")
71
+ return "OCP.STEPControl_Writer"
72
+ except Exception as exc:
73
+ raise RuntimeError(f"Unable to export STEP: {exc}")
74
+
75
+
76
+ def _write_stl(shape, path_out):
77
+ if _looks_like_build123d(shape):
78
+ try:
79
+ from build123d import export_stl # type: ignore
80
+ export_stl(shape, path_out)
81
+ return "build123d.export_stl"
82
+ except Exception:
83
+ pass
84
+
85
+ try:
86
+ from OCP.BRepMesh import BRepMesh_IncrementalMesh # type: ignore
87
+ from OCP.StlAPI import StlAPI_Writer # type: ignore
88
+ inner = getattr(shape, "wrapped", shape)
89
+ BRepMesh_IncrementalMesh(inner, 0.1, False, 0.5, True)
90
+ writer = StlAPI_Writer()
91
+ writer.Write(inner, path_out)
92
+ return "OCP.StlAPI_Writer"
93
+ except Exception as exc:
94
+ raise RuntimeError(f"Unable to export STL: {exc}")
95
+
96
+
97
+ def _resolve_model_source(model_name: str, part_name: str, source_override: str = None):
98
+ if source_override:
99
+ candidate = source_override
100
+ if not os.path.isabs(candidate):
101
+ candidate = os.path.join(PROJECT_ROOT, candidate)
102
+ return candidate if os.path.isfile(candidate) else None
103
+ part_sub_path = os.path.join(MODELS_DIR, model_name, "parts", part_name, "part.py")
104
+ # Sub-part builds must not fall back to assembly.py (would export the whole assembly for every mesh).
105
+ if part_name != model_name:
106
+ return part_sub_path if os.path.isfile(part_sub_path) else None
107
+ candidates = [
108
+ os.path.join(MODELS_DIR, model_name, "assembly.py"),
109
+ os.path.join(MODELS_DIR, model_name, "part.py"),
110
+ part_sub_path,
111
+ ]
112
+ for candidate in candidates:
113
+ if os.path.isfile(candidate):
114
+ return candidate
115
+ return None
116
+
117
+
118
+ def _json_safe(value):
119
+ if value is None or isinstance(value, (str, int, float, bool)):
120
+ return value
121
+ if isinstance(value, (list, tuple)):
122
+ return [_json_safe(v) for v in value]
123
+ if isinstance(value, dict):
124
+ return {str(k): _json_safe(v) for k, v in value.items()}
125
+ if hasattr(value, "to_tuple"):
126
+ try:
127
+ return _json_safe(value.to_tuple())
128
+ except Exception:
129
+ pass
130
+ if all(hasattr(value, attr) for attr in ("X", "Y", "Z")):
131
+ try:
132
+ return [float(value.X), float(value.Y), float(value.Z)]
133
+ except Exception:
134
+ pass
135
+ if all(hasattr(value, attr) for attr in ("x", "y", "z")):
136
+ try:
137
+ return [float(value.x), float(value.y), float(value.z)]
138
+ except Exception:
139
+ pass
140
+ raise TypeError(f"metadata contains non-JSON value of type {type(value).__name__}")
141
+
142
+
143
+ def _collect_compound_labels(shape, ns=None):
144
+ labels = []
145
+ children = getattr(shape, "children", None)
146
+ if children is not None:
147
+ try:
148
+ iterable = list(children)
149
+ except TypeError:
150
+ iterable = []
151
+ for child in iterable:
152
+ label = getattr(child, "label", None)
153
+ if label is None and hasattr(child, "part"):
154
+ label = getattr(child.part, "label", None)
155
+ text = str(label).strip() if label is not None else ""
156
+ if text:
157
+ labels.append(text)
158
+ if labels:
159
+ return labels
160
+ if isinstance(ns, dict):
161
+ parts_var = ns.get("parts")
162
+ if isinstance(parts_var, (list, tuple)):
163
+ for item in parts_var:
164
+ text = str(getattr(item, "label", "") or "").strip()
165
+ if text:
166
+ labels.append(text)
167
+ return labels
168
+
169
+
170
+ def _ensure_assembly_metadata(ns: dict, result) -> None:
171
+ labels = _collect_compound_labels(result, ns)
172
+ if not labels:
173
+ return
174
+ metadata = ns.get("metadata")
175
+ if metadata is None:
176
+ metadata = {}
177
+ ns["metadata"] = metadata
178
+ if not isinstance(metadata, dict):
179
+ return
180
+ if not metadata.get("assembly_parts"):
181
+ metadata["assembly_parts"] = labels
182
+
183
+
184
+ def _write_metadata(source_path: str, ns: dict):
185
+ metadata = ns.get("metadata", None)
186
+ if metadata is None:
187
+ return
188
+ try:
189
+ payload = _json_safe(metadata)
190
+ except Exception as exc:
191
+ raise RuntimeError(f"Invalid metadata: {exc}")
192
+ target_dir = os.path.dirname(source_path)
193
+ os.makedirs(target_dir, exist_ok=True)
194
+ metadata_path = os.path.join(target_dir, "metadata.json")
195
+ tmp_path = metadata_path + ".tmp"
196
+ with open(tmp_path, "w", encoding="utf-8") as f:
197
+ json.dump(payload, f, indent=2)
198
+ f.write("\n")
199
+ os.replace(tmp_path, metadata_path)
200
+
201
+
202
+ def _build_namespace(model_name: str, part_name: str, source_override: str = None):
203
+ source_path = _resolve_model_source(model_name, part_name, source_override)
204
+ if not source_path:
205
+ print(
206
+ f"[export_runner] no source file found for model {model_name!r} "
207
+ f"(looked for models/{model_name}/assembly.py, models/{model_name}/part.py, "
208
+ f"models/{model_name}/parts/{part_name}/part.py)",
209
+ file=sys.stderr
210
+ )
211
+ return None, None, 2
212
+
213
+ model_dir = os.path.dirname(source_path)
214
+ if model_dir not in sys.path:
215
+ sys.path.insert(0, model_dir)
216
+
217
+ run_name = f"__aicad_model_{model_name}__"
218
+ try:
219
+ with open(source_path, "r", encoding="utf-8") as f:
220
+ source = f.read()
221
+ code = compile(source, source_path, "exec")
222
+ ns = {
223
+ "__name__": run_name,
224
+ "__file__": source_path,
225
+ "__package__": None,
226
+ "__cached__": None,
227
+ "__spec__": None,
228
+ }
229
+ exec(code, ns, ns)
230
+ except Exception as exc:
231
+ print(f"[export_runner] Failed to execute {source_path}: {type(exc).__name__}: {exc}", file=sys.stderr)
232
+ print(traceback.format_exc(limit=12), file=sys.stderr)
233
+ return None, None, 3
234
+ return ns, source_path, 0
235
+
236
+
237
+ def build_one(model_name: str, part_name: str = None, export_format: str = "brep", output: str = None, source_override: str = None) -> int:
238
+ part_name = part_name or model_name
239
+ build_started = time.perf_counter()
240
+ ns, source_path, err = _build_namespace(model_name, part_name, source_override)
241
+ build_elapsed = time.perf_counter() - build_started
242
+ if err:
243
+ return err
244
+ print(f"[export_runner] {model_name} build_model time: {build_elapsed:.3f}s")
245
+ result = ns.get("result", None)
246
+ if result is None:
247
+ candidate = ns.get("assembly", None)
248
+ if candidate is not None:
249
+ result = candidate
250
+ if result is None:
251
+ print(f"[export_runner] {source_path} must define a global result (or assembly) object (build123d).",
252
+ file=sys.stderr)
253
+ return 4
254
+ try:
255
+ _ensure_assembly_metadata(ns, result)
256
+ _write_metadata(source_path, ns)
257
+ except Exception as exc:
258
+ print(f"[export_runner] Failed to write metadata.json: {exc}", file=sys.stderr)
259
+ return 8
260
+
261
+ fmt = (export_format or "brep").strip().lower()
262
+ if fmt not in ("brep", "step", "stl"):
263
+ print(f"[export_runner] Unsupported export format: {fmt}", file=sys.stderr)
264
+ return 7
265
+
266
+ if output:
267
+ out = os.path.abspath(output)
268
+ os.makedirs(os.path.dirname(out), exist_ok=True)
269
+ else:
270
+ os.makedirs(CACHE_DIR, exist_ok=True)
271
+ out = os.path.join(CACHE_DIR, f"{model_name}__{part_name}.{fmt}")
272
+
273
+ try:
274
+ export_started = time.perf_counter()
275
+ if fmt == "brep":
276
+ method = _write_brep(result, out)
277
+ elif fmt == "step":
278
+ method = _write_step(result, out)
279
+ else:
280
+ method = _write_stl(result, out)
281
+ export_elapsed = time.perf_counter() - export_started
282
+ except Exception as exc:
283
+ print(f"[export_runner] Failed to export {fmt.upper()}: {exc}", file=sys.stderr)
284
+ return 5
285
+
286
+ size = os.path.getsize(out) if os.path.exists(out) else 0
287
+ if size <= 0:
288
+ print("[export_runner] Generated output file is empty", file=sys.stderr)
289
+ return 6
290
+ print(f"[export_runner] {model_name}/{part_name} {fmt.upper()} export time: {export_elapsed:.3f}s")
291
+ print(f"[export_runner] {model_name}/{part_name} export succeeded [{fmt.upper()}] ({method}): {out} ({size} bytes)")
292
+ return 0
293
+
294
+
295
+ def main() -> int:
296
+ parser = argparse.ArgumentParser()
297
+ parser.add_argument("--project", default=None, help="Project root path (contains models/ and .cache/)")
298
+ parser.add_argument("--model", default=None, help="Model directory name")
299
+ parser.add_argument("--part", default=None, help="Legacy alias for --model")
300
+ parser.add_argument("--part-name", default=None, help="Part directory name inside models/<model>/parts/")
301
+ parser.add_argument("--source", default=None, help="Optional project-relative or absolute source file path (overrides default lookup)")
302
+ parser.add_argument("--export-format", default="brep", choices=["brep", "step", "stl"])
303
+ parser.add_argument("--output", default=None, help="Optional absolute path for exported file")
304
+ args = parser.parse_args()
305
+ global PROJECT_ROOT, MODELS_DIR, CACHE_DIR
306
+ PROJECT_ROOT = os.path.abspath(args.project) if args.project else HERE
307
+ MODELS_DIR = os.path.join(PROJECT_ROOT, "models")
308
+ CACHE_DIR = os.path.join(PROJECT_ROOT, ".cache")
309
+ model_name = args.model or args.part
310
+ if not model_name:
311
+ parser.error("one of --model / --part is required")
312
+ return build_one(model_name, args.part_name or model_name, args.export_format, args.output, args.source)
313
+
314
+
315
+ if __name__ == "__main__":
316
+ sys.exit(main())