@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,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:]))