@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,621 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import inspect
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
import traceback
|
|
9
|
+
|
|
10
|
+
PROJECT_ROOT = os.path.abspath(os.environ.get("AICAD_PROJECT_ROOT") or os.getcwd())
|
|
11
|
+
MODELS_DIR = os.path.join(PROJECT_ROOT, "models")
|
|
12
|
+
NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
13
|
+
MODULE_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$")
|
|
14
|
+
API_LIST_LIMIT = 180
|
|
15
|
+
API_SEARCH_LIMIT = 80
|
|
16
|
+
API_MEMBER_LIMIT = 80
|
|
17
|
+
API_DOC_LIMIT = 1400
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def emit(payload):
|
|
21
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def fail(message, **extra):
|
|
25
|
+
emit({"ok": False, "error": message, **extra})
|
|
26
|
+
return 1
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def safe_name(value, label):
|
|
30
|
+
value = str(value or "").strip()
|
|
31
|
+
if not value or not NAME_RE.match(value):
|
|
32
|
+
raise ValueError(f"{label} can only contain letters, numbers, underscores, and hyphens: {value!r}")
|
|
33
|
+
return value
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def default_part_for(model):
|
|
37
|
+
flat = os.path.join(MODELS_DIR, model, "part.py")
|
|
38
|
+
if os.path.isfile(flat):
|
|
39
|
+
return model
|
|
40
|
+
assembly = os.path.join(MODELS_DIR, model, "assembly.py")
|
|
41
|
+
if os.path.isfile(assembly):
|
|
42
|
+
return model
|
|
43
|
+
parts_dir = os.path.join(MODELS_DIR, model, "parts")
|
|
44
|
+
same_name = os.path.join(parts_dir, model, "part.py")
|
|
45
|
+
if os.path.isfile(same_name):
|
|
46
|
+
return model
|
|
47
|
+
if os.path.isdir(parts_dir):
|
|
48
|
+
parts = [
|
|
49
|
+
name for name in os.listdir(parts_dir)
|
|
50
|
+
if NAME_RE.match(name) and os.path.isfile(os.path.join(parts_dir, name, "part.py"))
|
|
51
|
+
]
|
|
52
|
+
if len(parts) == 1:
|
|
53
|
+
return parts[0]
|
|
54
|
+
return model
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def split_target(raw):
|
|
58
|
+
raw = str(raw or "").strip()
|
|
59
|
+
if not raw:
|
|
60
|
+
active = os.environ.get("AICAD_ACTIVE_MODEL", "").strip()
|
|
61
|
+
if active:
|
|
62
|
+
model = safe_name(active, "model")
|
|
63
|
+
return model, default_part_for(model)
|
|
64
|
+
models = [
|
|
65
|
+
name for name in os.listdir(MODELS_DIR)
|
|
66
|
+
if os.path.isdir(os.path.join(MODELS_DIR, name))
|
|
67
|
+
] if os.path.isdir(MODELS_DIR) else []
|
|
68
|
+
if len(models) == 1:
|
|
69
|
+
model = safe_name(models[0], "model")
|
|
70
|
+
return model, default_part_for(model)
|
|
71
|
+
raise ValueError("target is required when there is no active model")
|
|
72
|
+
if "/" in raw:
|
|
73
|
+
model, part = raw.split("/", 1)
|
|
74
|
+
return safe_name(model, "model"), safe_name(part, "part")
|
|
75
|
+
name = safe_name(raw, "model")
|
|
76
|
+
return name, default_part_for(name)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def part_source(model, part):
|
|
80
|
+
if model == part:
|
|
81
|
+
flat = os.path.join(MODELS_DIR, model, "part.py")
|
|
82
|
+
if os.path.isfile(flat):
|
|
83
|
+
return flat
|
|
84
|
+
assembly = os.path.join(MODELS_DIR, model, "assembly.py")
|
|
85
|
+
if os.path.isfile(assembly):
|
|
86
|
+
return assembly
|
|
87
|
+
source = os.path.join(MODELS_DIR, model, "parts", part, "part.py")
|
|
88
|
+
if not os.path.isfile(source):
|
|
89
|
+
raise FileNotFoundError(
|
|
90
|
+
f"no source file found for {model}/{part} "
|
|
91
|
+
f"(looked for models/{model}/part.py, models/{model}/assembly.py, "
|
|
92
|
+
f"models/{model}/parts/{part}/part.py)"
|
|
93
|
+
)
|
|
94
|
+
return source
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def json_safe(value):
|
|
98
|
+
if value is None or isinstance(value, (str, int, float, bool)):
|
|
99
|
+
return value
|
|
100
|
+
if isinstance(value, (list, tuple)):
|
|
101
|
+
return [json_safe(v) for v in value]
|
|
102
|
+
if isinstance(value, dict):
|
|
103
|
+
return {str(k): json_safe(v) for k, v in value.items()}
|
|
104
|
+
if hasattr(value, "to_tuple"):
|
|
105
|
+
try:
|
|
106
|
+
return json_safe(value.to_tuple())
|
|
107
|
+
except Exception:
|
|
108
|
+
pass
|
|
109
|
+
for names in (("X", "Y", "Z"), ("x", "y", "z")):
|
|
110
|
+
if all(hasattr(value, n) for n in names):
|
|
111
|
+
try:
|
|
112
|
+
return [float(getattr(value, names[0])), float(getattr(value, names[1])), float(getattr(value, names[2]))]
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
return str(value)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def load_namespace(model, part):
|
|
119
|
+
source = part_source(model, part)
|
|
120
|
+
part_dir = os.path.dirname(source)
|
|
121
|
+
if part_dir not in sys.path:
|
|
122
|
+
sys.path.insert(0, part_dir)
|
|
123
|
+
if PROJECT_ROOT not in sys.path:
|
|
124
|
+
sys.path.insert(0, PROJECT_ROOT)
|
|
125
|
+
ns = {
|
|
126
|
+
"__name__": f"__aicad_script_{model}_{part}__",
|
|
127
|
+
"__file__": source,
|
|
128
|
+
"__package__": None,
|
|
129
|
+
"__cached__": None,
|
|
130
|
+
"__spec__": None,
|
|
131
|
+
}
|
|
132
|
+
with open(source, "r", encoding="utf-8") as f:
|
|
133
|
+
code = compile(f.read(), source, "exec")
|
|
134
|
+
exec(code, ns, ns)
|
|
135
|
+
return ns, source
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def metadata_from(ns):
|
|
139
|
+
meta = ns.get("metadata", None)
|
|
140
|
+
return meta if isinstance(meta, dict) else ({} if meta is None else {"value": meta})
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def bbox_of(shape):
|
|
144
|
+
if shape is None or not hasattr(shape, "bounding_box"):
|
|
145
|
+
return None
|
|
146
|
+
try:
|
|
147
|
+
bb = shape.bounding_box()
|
|
148
|
+
return {
|
|
149
|
+
"min": json_safe(bb.min),
|
|
150
|
+
"max": json_safe(bb.max),
|
|
151
|
+
"size": [
|
|
152
|
+
float(bb.max.X - bb.min.X),
|
|
153
|
+
float(bb.max.Y - bb.min.Y),
|
|
154
|
+
float(bb.max.Z - bb.min.Z),
|
|
155
|
+
],
|
|
156
|
+
}
|
|
157
|
+
except Exception:
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def literal_value(value, depth=0):
|
|
162
|
+
if depth > 4:
|
|
163
|
+
return repr(value)
|
|
164
|
+
if value is None or isinstance(value, (str, int, float, bool)):
|
|
165
|
+
return value
|
|
166
|
+
if isinstance(value, dict):
|
|
167
|
+
out = {}
|
|
168
|
+
for i, (key, item) in enumerate(value.items()):
|
|
169
|
+
if i >= 50:
|
|
170
|
+
out["..."] = f"+{len(value) - 50} more"
|
|
171
|
+
break
|
|
172
|
+
safe = literal_value(item, depth + 1)
|
|
173
|
+
if safe is None and item is not None:
|
|
174
|
+
return None
|
|
175
|
+
out[str(key)] = safe
|
|
176
|
+
return out
|
|
177
|
+
if isinstance(value, (list, tuple)):
|
|
178
|
+
if not value:
|
|
179
|
+
return None
|
|
180
|
+
out = []
|
|
181
|
+
for item in value[:50]:
|
|
182
|
+
safe = literal_value(item, depth + 1)
|
|
183
|
+
if safe is None and item is not None:
|
|
184
|
+
return None
|
|
185
|
+
out.append(safe)
|
|
186
|
+
if len(value) > 50:
|
|
187
|
+
out.append(f"... +{len(value) - 50} more")
|
|
188
|
+
return out
|
|
189
|
+
try:
|
|
190
|
+
return xyz(value)
|
|
191
|
+
except Exception:
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def distance(a, b):
|
|
196
|
+
point_a = xyz(a)
|
|
197
|
+
point_b = xyz(b)
|
|
198
|
+
return math.sqrt(sum((point_b[i] - point_a[i]) ** 2 for i in range(3)))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def bbox_relation(a, b):
|
|
202
|
+
bbox_a = bbox_of(a)
|
|
203
|
+
bbox_b = bbox_of(b)
|
|
204
|
+
if not bbox_a or not bbox_b:
|
|
205
|
+
raise ValueError("both values must provide bounding_box()")
|
|
206
|
+
min_a, max_a = bbox_a["min"], bbox_a["max"]
|
|
207
|
+
min_b, max_b = bbox_b["min"], bbox_b["max"]
|
|
208
|
+
center_a = [(min_a[i] + max_a[i]) / 2 for i in range(3)]
|
|
209
|
+
center_b = [(min_b[i] + max_b[i]) / 2 for i in range(3)]
|
|
210
|
+
axes = ("x", "y", "z")
|
|
211
|
+
gap = {}
|
|
212
|
+
overlap = {}
|
|
213
|
+
for i, axis in enumerate(axes):
|
|
214
|
+
if max_a[i] < min_b[i]:
|
|
215
|
+
gap[axis] = min_b[i] - max_a[i]
|
|
216
|
+
elif max_b[i] < min_a[i]:
|
|
217
|
+
gap[axis] = min_a[i] - max_b[i]
|
|
218
|
+
else:
|
|
219
|
+
gap[axis] = 0.0
|
|
220
|
+
overlap[axis] = min(max_a[i], max_b[i]) - max(min_a[i], min_b[i])
|
|
221
|
+
center_delta = [center_b[i] - center_a[i] for i in range(3)]
|
|
222
|
+
return {
|
|
223
|
+
"a_bbox": bbox_a,
|
|
224
|
+
"b_bbox": bbox_b,
|
|
225
|
+
"center_a": center_a,
|
|
226
|
+
"center_b": center_b,
|
|
227
|
+
"center_delta_b_minus_a": center_delta,
|
|
228
|
+
"center_distance": math.sqrt(sum(v * v for v in center_delta)),
|
|
229
|
+
"axis_gap": gap,
|
|
230
|
+
"axis_overlap": overlap,
|
|
231
|
+
"bbox_gap_distance": math.sqrt(sum(v * v for v in gap.values())),
|
|
232
|
+
"bbox_intersects": all(v >= 0 for v in overlap.values()),
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def summarize_one(item, index):
|
|
237
|
+
entry = {"index": index, "type": type(item).__name__}
|
|
238
|
+
geom = getattr(item, "geom_type", None)
|
|
239
|
+
if geom is not None:
|
|
240
|
+
entry["geom_type"] = str(geom() if callable(geom) else geom)
|
|
241
|
+
for attr in ("length", "area", "radius"):
|
|
242
|
+
val = getattr(item, attr, None)
|
|
243
|
+
if callable(val):
|
|
244
|
+
try:
|
|
245
|
+
val = val()
|
|
246
|
+
except Exception:
|
|
247
|
+
val = None
|
|
248
|
+
if isinstance(val, (int, float)):
|
|
249
|
+
entry[attr] = float(val)
|
|
250
|
+
try:
|
|
251
|
+
center = item.center()
|
|
252
|
+
entry["center"] = json_safe(center)
|
|
253
|
+
except Exception:
|
|
254
|
+
pass
|
|
255
|
+
bb = bbox_of(item)
|
|
256
|
+
if bb is not None:
|
|
257
|
+
entry["bbox"] = bb
|
|
258
|
+
return entry
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def summarize_value(value, per_item_limit=12):
|
|
262
|
+
report = {"py_type": type(value).__name__}
|
|
263
|
+
literal = literal_value(value)
|
|
264
|
+
if literal is not None or value is None:
|
|
265
|
+
report["value"] = literal
|
|
266
|
+
return report
|
|
267
|
+
if hasattr(value, "__iter__") and not isinstance(value, (str, bytes, dict)):
|
|
268
|
+
try:
|
|
269
|
+
items = list(value)
|
|
270
|
+
except Exception:
|
|
271
|
+
items = None
|
|
272
|
+
if items is not None:
|
|
273
|
+
report["count"] = len(items)
|
|
274
|
+
if items:
|
|
275
|
+
report["item_type"] = type(items[0]).__name__
|
|
276
|
+
total_length = 0.0
|
|
277
|
+
total_area = 0.0
|
|
278
|
+
for item in items:
|
|
279
|
+
length = getattr(item, "length", None)
|
|
280
|
+
if callable(length):
|
|
281
|
+
try:
|
|
282
|
+
length = length()
|
|
283
|
+
except Exception:
|
|
284
|
+
length = None
|
|
285
|
+
if isinstance(length, (int, float)):
|
|
286
|
+
total_length += float(length)
|
|
287
|
+
area = getattr(item, "area", None)
|
|
288
|
+
if callable(area):
|
|
289
|
+
try:
|
|
290
|
+
area = area()
|
|
291
|
+
except Exception:
|
|
292
|
+
area = None
|
|
293
|
+
if isinstance(area, (int, float)):
|
|
294
|
+
total_area += float(area)
|
|
295
|
+
if total_length > 0:
|
|
296
|
+
report["total_length"] = total_length
|
|
297
|
+
if total_area > 0:
|
|
298
|
+
report["total_area"] = total_area
|
|
299
|
+
report["items"] = [summarize_one(item, i) for i, item in enumerate(items[:per_item_limit])]
|
|
300
|
+
if len(items) > per_item_limit:
|
|
301
|
+
report["items_truncated"] = len(items) - per_item_limit
|
|
302
|
+
return report
|
|
303
|
+
report.update(summarize_one(value, 0))
|
|
304
|
+
return report
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def build_probe_namespace(ns, secondary_ns=None):
|
|
308
|
+
probe_ns = dict(ns)
|
|
309
|
+
result = ns.get("result", None)
|
|
310
|
+
probe_ns["result"] = result
|
|
311
|
+
probe_ns["part"] = result
|
|
312
|
+
probe_ns["result_a"] = result
|
|
313
|
+
probe_ns["part_a"] = result
|
|
314
|
+
probe_ns["a"] = result
|
|
315
|
+
probe_ns["metadata_a"] = metadata_from(ns)
|
|
316
|
+
if secondary_ns is not None:
|
|
317
|
+
result_b = secondary_ns.get("result", None)
|
|
318
|
+
probe_ns["result_b"] = result_b
|
|
319
|
+
probe_ns["part_b"] = result_b
|
|
320
|
+
probe_ns["b"] = result_b
|
|
321
|
+
probe_ns["metadata_b"] = metadata_from(secondary_ns)
|
|
322
|
+
probe_ns["distance"] = distance
|
|
323
|
+
probe_ns["bbox_relation"] = bbox_relation
|
|
324
|
+
try:
|
|
325
|
+
import aicad_select # type: ignore[import-not-found]
|
|
326
|
+
probe_ns["aicad_select"] = aicad_select
|
|
327
|
+
for name in getattr(aicad_select, "__all__", []):
|
|
328
|
+
probe_ns[name] = getattr(aicad_select, name)
|
|
329
|
+
except Exception as exc:
|
|
330
|
+
probe_ns["aicad_select_error"] = str(exc)
|
|
331
|
+
try:
|
|
332
|
+
import aicad_attach # type: ignore[import-not-found]
|
|
333
|
+
probe_ns["aicad_attach"] = aicad_attach
|
|
334
|
+
for name in getattr(aicad_attach, "__all__", []):
|
|
335
|
+
probe_ns[name] = getattr(aicad_attach, name)
|
|
336
|
+
except Exception as exc:
|
|
337
|
+
probe_ns["aicad_attach_error"] = str(exc)
|
|
338
|
+
try:
|
|
339
|
+
import build123d as build123d_module
|
|
340
|
+
probe_ns["build123d"] = build123d_module
|
|
341
|
+
for name in ("Axis", "Plane", "Vector", "GeomType"):
|
|
342
|
+
if hasattr(build123d_module, name):
|
|
343
|
+
probe_ns.setdefault(name, getattr(build123d_module, name))
|
|
344
|
+
except Exception:
|
|
345
|
+
pass
|
|
346
|
+
return probe_ns
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def lookup_path(root, path_text):
|
|
350
|
+
if path_text in ("", "."):
|
|
351
|
+
return root
|
|
352
|
+
current = root
|
|
353
|
+
for segment in str(path_text).split("."):
|
|
354
|
+
if isinstance(current, dict) and segment in current:
|
|
355
|
+
current = current[segment]
|
|
356
|
+
elif isinstance(current, (list, tuple)) and segment.isdigit() and int(segment) < len(current):
|
|
357
|
+
current = current[int(segment)]
|
|
358
|
+
else:
|
|
359
|
+
raise KeyError(path_text)
|
|
360
|
+
return current
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def lookup_metadata(meta, key):
|
|
364
|
+
candidates = [
|
|
365
|
+
key,
|
|
366
|
+
f"anchors.{key}",
|
|
367
|
+
f"points.{key}",
|
|
368
|
+
f"measurements.{key}",
|
|
369
|
+
]
|
|
370
|
+
last_error = None
|
|
371
|
+
for candidate in candidates:
|
|
372
|
+
try:
|
|
373
|
+
return candidate, lookup_path(meta, candidate)
|
|
374
|
+
except Exception as exc:
|
|
375
|
+
last_error = exc
|
|
376
|
+
raise KeyError(str(last_error or key))
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def xyz(value):
|
|
380
|
+
if isinstance(value, dict):
|
|
381
|
+
if all(k in value for k in ("x", "y", "z")):
|
|
382
|
+
return [float(value["x"]), float(value["y"]), float(value["z"])]
|
|
383
|
+
for key in ("point", "position", "origin", "center"):
|
|
384
|
+
if key in value:
|
|
385
|
+
return xyz(value[key])
|
|
386
|
+
if isinstance(value, (list, tuple)) and len(value) >= 3:
|
|
387
|
+
return [float(value[0]), float(value[1]), float(value[2])]
|
|
388
|
+
for names in (("X", "Y", "Z"), ("x", "y", "z")):
|
|
389
|
+
if all(hasattr(value, n) for n in names):
|
|
390
|
+
return [float(getattr(value, names[0])), float(getattr(value, names[1])), float(getattr(value, names[2]))]
|
|
391
|
+
raise TypeError(f"value is not a 3D point: {value!r}")
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def parse_flags(args):
|
|
395
|
+
flags = {}
|
|
396
|
+
positionals = []
|
|
397
|
+
i = 0
|
|
398
|
+
while i < len(args):
|
|
399
|
+
token = str(args[i])
|
|
400
|
+
if token.startswith("-"):
|
|
401
|
+
key = token.lstrip("-")
|
|
402
|
+
if not key:
|
|
403
|
+
raise ValueError("empty flag")
|
|
404
|
+
if i + 1 >= len(args) or str(args[i + 1]).startswith("-"):
|
|
405
|
+
raise ValueError(f"flag {token} requires a value")
|
|
406
|
+
flags[key] = args[i + 1]
|
|
407
|
+
i += 2
|
|
408
|
+
else:
|
|
409
|
+
positionals.append(token)
|
|
410
|
+
i += 1
|
|
411
|
+
return flags, positionals
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def flag_value(flags, *names):
|
|
415
|
+
for name in names:
|
|
416
|
+
if name in flags:
|
|
417
|
+
return flags[name]
|
|
418
|
+
return None
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def trim_text(value, limit):
|
|
422
|
+
text = str(value or "").strip()
|
|
423
|
+
if len(text) <= limit:
|
|
424
|
+
return text
|
|
425
|
+
return text[:limit].rstrip() + f"\n...[truncated {len(text) - limit} chars]"
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def public_names(obj):
|
|
429
|
+
try:
|
|
430
|
+
names = getattr(obj, "__all__", None)
|
|
431
|
+
if names:
|
|
432
|
+
return sorted(str(name) for name in names if not str(name).startswith("_"))
|
|
433
|
+
except Exception:
|
|
434
|
+
pass
|
|
435
|
+
return sorted(name for name in dir(obj) if not name.startswith("_"))
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def symbol_kind(obj):
|
|
439
|
+
if inspect.isclass(obj):
|
|
440
|
+
return "class"
|
|
441
|
+
if inspect.ismodule(obj):
|
|
442
|
+
return "module"
|
|
443
|
+
if inspect.isfunction(obj) or inspect.ismethod(obj) or inspect.isbuiltin(obj):
|
|
444
|
+
return "function"
|
|
445
|
+
if callable(obj):
|
|
446
|
+
return "callable"
|
|
447
|
+
return type(obj).__name__
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def signature_for(obj):
|
|
451
|
+
try:
|
|
452
|
+
return str(inspect.signature(obj))
|
|
453
|
+
except Exception:
|
|
454
|
+
return ""
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def import_module_checked(module_name):
|
|
458
|
+
module_name = str(module_name or "").strip()
|
|
459
|
+
if not module_name or not MODULE_RE.match(module_name):
|
|
460
|
+
raise ValueError(f"invalid module name: {module_name!r}")
|
|
461
|
+
return importlib.import_module(module_name)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def resolve_symbol(module, name):
|
|
465
|
+
current = module
|
|
466
|
+
for segment in str(name or "").split("."):
|
|
467
|
+
if not segment:
|
|
468
|
+
raise ValueError("empty symbol segment")
|
|
469
|
+
current = getattr(current, segment)
|
|
470
|
+
return current
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def command_api(args):
|
|
474
|
+
flags, positionals = parse_flags(args)
|
|
475
|
+
if positionals:
|
|
476
|
+
raise ValueError("script/api expects flags, e.g. api -module build123d -search fillet")
|
|
477
|
+
module_name = flag_value(flags, "module", "m")
|
|
478
|
+
if not module_name:
|
|
479
|
+
raise ValueError("script/api expects -module <module>")
|
|
480
|
+
module = import_module_checked(module_name)
|
|
481
|
+
name = flag_value(flags, "name", "symbol")
|
|
482
|
+
search = flag_value(flags, "search", "q")
|
|
483
|
+
|
|
484
|
+
if name:
|
|
485
|
+
obj = resolve_symbol(module, name)
|
|
486
|
+
members = []
|
|
487
|
+
if inspect.isclass(obj) or inspect.ismodule(obj):
|
|
488
|
+
for member_name in public_names(obj):
|
|
489
|
+
try:
|
|
490
|
+
member = getattr(obj, member_name)
|
|
491
|
+
except Exception:
|
|
492
|
+
continue
|
|
493
|
+
members.append({
|
|
494
|
+
"name": member_name,
|
|
495
|
+
"kind": symbol_kind(member),
|
|
496
|
+
"signature": signature_for(member),
|
|
497
|
+
})
|
|
498
|
+
if len(members) >= API_MEMBER_LIMIT:
|
|
499
|
+
break
|
|
500
|
+
return {
|
|
501
|
+
"ok": True,
|
|
502
|
+
"script": "api",
|
|
503
|
+
"module": module_name,
|
|
504
|
+
"name": name,
|
|
505
|
+
"kind": symbol_kind(obj),
|
|
506
|
+
"signature": signature_for(obj),
|
|
507
|
+
"doc": trim_text(inspect.getdoc(obj) or "", API_DOC_LIMIT),
|
|
508
|
+
"members": members,
|
|
509
|
+
"membersTruncated": max(0, len(public_names(obj)) - len(members)) if (inspect.isclass(obj) or inspect.ismodule(obj)) else 0,
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
lowered = str(search or "").lower()
|
|
513
|
+
symbols = []
|
|
514
|
+
for symbol_name in public_names(module):
|
|
515
|
+
if lowered and lowered not in symbol_name.lower():
|
|
516
|
+
continue
|
|
517
|
+
try:
|
|
518
|
+
obj = getattr(module, symbol_name)
|
|
519
|
+
except Exception:
|
|
520
|
+
continue
|
|
521
|
+
symbols.append({
|
|
522
|
+
"name": symbol_name,
|
|
523
|
+
"kind": symbol_kind(obj),
|
|
524
|
+
"signature": signature_for(obj),
|
|
525
|
+
})
|
|
526
|
+
limit = API_SEARCH_LIMIT if lowered else API_LIST_LIMIT
|
|
527
|
+
if len(symbols) >= limit:
|
|
528
|
+
break
|
|
529
|
+
total_public = len(public_names(module))
|
|
530
|
+
return {
|
|
531
|
+
"ok": True,
|
|
532
|
+
"script": "api",
|
|
533
|
+
"module": module_name,
|
|
534
|
+
"search": search or "",
|
|
535
|
+
"symbols": symbols,
|
|
536
|
+
"totalPublicSymbols": total_public,
|
|
537
|
+
"truncated": len(symbols) < total_public if not lowered else len(symbols) >= API_SEARCH_LIMIT,
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def command_build(args):
|
|
542
|
+
flags, positionals = parse_flags(args)
|
|
543
|
+
if positionals:
|
|
544
|
+
raise ValueError("script/build expects command: build -component <name>")
|
|
545
|
+
component = flag_value(flags, "component", "c")
|
|
546
|
+
if not component:
|
|
547
|
+
raise ValueError("script/build expects command: build -component <name>")
|
|
548
|
+
model, part = split_target(component)
|
|
549
|
+
ns, source = load_namespace(model, part)
|
|
550
|
+
result = ns.get("result", None)
|
|
551
|
+
meta = metadata_from(ns)
|
|
552
|
+
return {
|
|
553
|
+
"ok": True,
|
|
554
|
+
"script": "build",
|
|
555
|
+
"model": model,
|
|
556
|
+
"part": part,
|
|
557
|
+
"source": os.path.relpath(source, PROJECT_ROOT).replace(os.sep, "/"),
|
|
558
|
+
"resultType": type(result).__name__ if result is not None else None,
|
|
559
|
+
"hasResult": result is not None,
|
|
560
|
+
"bbox": bbox_of(result),
|
|
561
|
+
"metadataKeys": sorted([str(k) for k in meta.keys()]),
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def command_probe(args):
|
|
566
|
+
flags, positionals = parse_flags(args)
|
|
567
|
+
if positionals:
|
|
568
|
+
raise ValueError('script/probe expects flags, e.g. probe -component model/part -expr "top_edges(part)"')
|
|
569
|
+
component = flag_value(flags, "component", "target", "part")
|
|
570
|
+
component_b = flag_value(flags, "component-b", "componentB", "other-component", "other", "b")
|
|
571
|
+
expression = str(flag_value(flags, "expr", "expression") or "").strip()
|
|
572
|
+
if not expression:
|
|
573
|
+
expression = "bbox_relation(part_a, part_b)" if component_b else "part"
|
|
574
|
+
model, part = split_target(component or "")
|
|
575
|
+
ns, _source = load_namespace(model, part)
|
|
576
|
+
if ns.get("result", None) is None:
|
|
577
|
+
raise ValueError("part.py must define a global result before probing")
|
|
578
|
+
ns_b = None
|
|
579
|
+
model_b = None
|
|
580
|
+
part_b = None
|
|
581
|
+
if component_b:
|
|
582
|
+
model_b, part_b = split_target(component_b)
|
|
583
|
+
ns_b, _source_b = load_namespace(model_b, part_b)
|
|
584
|
+
if ns_b.get("result", None) is None:
|
|
585
|
+
raise ValueError("componentB part.py must define a global result before probing")
|
|
586
|
+
probe_ns = build_probe_namespace(ns, ns_b)
|
|
587
|
+
value = eval(expression, probe_ns, probe_ns)
|
|
588
|
+
payload = {
|
|
589
|
+
"ok": True,
|
|
590
|
+
"script": "probe",
|
|
591
|
+
"model": model,
|
|
592
|
+
"part": part,
|
|
593
|
+
"expression": expression,
|
|
594
|
+
"value": summarize_value(value),
|
|
595
|
+
}
|
|
596
|
+
if model_b and part_b:
|
|
597
|
+
payload["componentB"] = {"model": model_b, "part": part_b}
|
|
598
|
+
return payload
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def main(argv):
|
|
602
|
+
if not argv:
|
|
603
|
+
return fail("script is required")
|
|
604
|
+
script = argv[0].strip().strip("/")
|
|
605
|
+
args = argv[1:]
|
|
606
|
+
try:
|
|
607
|
+
if script == "build":
|
|
608
|
+
emit(command_build(args))
|
|
609
|
+
elif script == "probe":
|
|
610
|
+
emit(command_probe(args))
|
|
611
|
+
elif script == "api":
|
|
612
|
+
emit(command_api(args))
|
|
613
|
+
else:
|
|
614
|
+
return fail(f"unknown script: {script}", available=["build", "probe", "api"])
|
|
615
|
+
return 0
|
|
616
|
+
except Exception as exc:
|
|
617
|
+
return fail(f"{type(exc).__name__}: {exc}", traceback=traceback.format_exc(limit=8))
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
if __name__ == "__main__":
|
|
621
|
+
sys.exit(main(sys.argv[1:]))
|