@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.
- package/README.md +24 -0
- package/dist/index.cjs +52 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +11 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/package.json +44 -0
- package/python/aicad-script.py +621 -0
- package/python/export_runner.py +316 -0
- package/python/skill-helpers/aicad_attach.py +586 -0
- package/python/skill-helpers/aicad_select.py +989 -0
|
@@ -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())
|