@inneranimalmedia/agentsam-sdk 2.3.0 → 2.4.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,603 @@
1
+ #!/usr/bin/env python3
2
+ """Fixed Blender-side adapter for AgentSam typed CAD capabilities.
3
+
4
+ This file is executed by Blender, not by the host Python runtime. It accepts a
5
+ bounded JSON request and exposes an allowlisted modeling vocabulary. It never
6
+ evaluates user-provided Python or arbitrary Blender expressions.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import math
14
+ import sys
15
+ import traceback
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ import bpy
20
+ from mathutils import Vector
21
+
22
+ RESULT_PREFIX = "AGENTSAM_RESULT="
23
+ MAX_OPERATIONS = 256
24
+ MAX_NAME = 160
25
+ PRIMITIVES = {"cube", "uv_sphere", "ico_sphere", "cylinder", "cone", "plane", "torus"}
26
+ BOOLEAN_OPERATIONS = {"UNION", "DIFFERENCE", "INTERSECT"}
27
+ RENDER_ENGINES = {"BLENDER_EEVEE", "BLENDER_EEVEE_NEXT", "BLENDER_WORKBENCH", "CYCLES"}
28
+
29
+
30
+ def _result(value: dict[str, Any]) -> None:
31
+ print(RESULT_PREFIX + json.dumps(value, separators=(",", ":"), sort_keys=True), flush=True)
32
+
33
+
34
+ def _args() -> argparse.Namespace:
35
+ argv = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else []
36
+ parser = argparse.ArgumentParser(add_help=False)
37
+ parser.add_argument("--operation", required=True, choices=("inspect", "build", "render_preview", "export"))
38
+ parser.add_argument("--request", required=True)
39
+ return parser.parse_args(argv)
40
+
41
+
42
+ def _load_request(path: str) -> dict[str, Any]:
43
+ value = json.loads(Path(path).read_text(encoding="utf-8"))
44
+ if not isinstance(value, dict) or value.get("schema_version") != 1:
45
+ raise ValueError("request schema_version must be 1")
46
+ return value
47
+
48
+
49
+ def _name(value: Any, label: str = "name") -> str:
50
+ text = str(value or "").strip()
51
+ if not text or len(text) > MAX_NAME or "\x00" in text:
52
+ raise ValueError(f"{label} must be 1..{MAX_NAME} characters")
53
+ return text
54
+
55
+
56
+ def _number(value: Any, label: str, *, minimum: float | None = None, maximum: float | None = None) -> float:
57
+ if isinstance(value, bool):
58
+ raise ValueError(f"{label} must be a finite number")
59
+ try:
60
+ result = float(value)
61
+ except (TypeError, ValueError) as exc:
62
+ raise ValueError(f"{label} must be a finite number") from exc
63
+ if not math.isfinite(result):
64
+ raise ValueError(f"{label} must be a finite number")
65
+ if minimum is not None and result < minimum:
66
+ raise ValueError(f"{label} must be >= {minimum}")
67
+ if maximum is not None and result > maximum:
68
+ raise ValueError(f"{label} must be <= {maximum}")
69
+ return result
70
+
71
+
72
+ def _integer(value: Any, label: str, *, minimum: int, maximum: int) -> int:
73
+ try:
74
+ result = int(value)
75
+ except (TypeError, ValueError) as exc:
76
+ raise ValueError(f"{label} must be an integer") from exc
77
+ if result < minimum or result > maximum:
78
+ raise ValueError(f"{label} must be {minimum}..{maximum}")
79
+ return result
80
+
81
+
82
+ def _vec(value: Any, label: str, size: int = 3, default: list[float] | None = None) -> list[float]:
83
+ if value is None and default is not None:
84
+ return list(default)
85
+ if not isinstance(value, (list, tuple)) or len(value) != size:
86
+ raise ValueError(f"{label} must contain {size} numbers")
87
+ return [_number(item, f"{label}[{index}]") for index, item in enumerate(value)]
88
+
89
+
90
+ def _object(name: Any) -> bpy.types.Object:
91
+ key = _name(name, "object")
92
+ obj = bpy.data.objects.get(key)
93
+ if obj is None:
94
+ raise ValueError(f"object not found: {key}")
95
+ return obj
96
+
97
+
98
+ def _activate(obj: bpy.types.Object) -> None:
99
+ if bpy.context.mode != "OBJECT":
100
+ try:
101
+ bpy.ops.object.mode_set(mode="OBJECT")
102
+ except RuntimeError:
103
+ pass
104
+ bpy.ops.object.select_all(action="DESELECT")
105
+ obj.select_set(True)
106
+ bpy.context.view_layer.objects.active = obj
107
+
108
+
109
+ def _apply_modifier(obj: bpy.types.Object, modifier: bpy.types.Modifier) -> None:
110
+ _activate(obj)
111
+ bpy.ops.object.modifier_apply(modifier=modifier.name)
112
+
113
+
114
+ def _set_transform(obj: bpy.types.Object, op: dict[str, Any]) -> None:
115
+ if "location" in op:
116
+ obj.location = _vec(op["location"], "location")
117
+ if "rotation" in op:
118
+ obj.rotation_mode = "XYZ"
119
+ obj.rotation_euler = _vec(op["rotation"], "rotation")
120
+ if "scale" in op:
121
+ obj.scale = _vec(op["scale"], "scale")
122
+
123
+
124
+ def _add_primitive(op: dict[str, Any]) -> bpy.types.Object:
125
+ primitive = str(op.get("primitive") or "").strip().lower()
126
+ if primitive not in PRIMITIVES:
127
+ raise ValueError(f"unsupported primitive: {primitive or '<empty>'}")
128
+ location = _vec(op.get("location"), "location", default=[0, 0, 0])
129
+ rotation = _vec(op.get("rotation"), "rotation", default=[0, 0, 0])
130
+
131
+ if primitive == "cube":
132
+ bpy.ops.mesh.primitive_cube_add(size=_number(op.get("size", 2), "size", minimum=1e-9), location=location, rotation=rotation)
133
+ elif primitive == "uv_sphere":
134
+ bpy.ops.mesh.primitive_uv_sphere_add(
135
+ segments=_integer(op.get("segments", 32), "segments", minimum=3, maximum=512),
136
+ ring_count=_integer(op.get("rings", 16), "rings", minimum=3, maximum=256),
137
+ radius=_number(op.get("radius", 1), "radius", minimum=1e-9),
138
+ location=location,
139
+ rotation=rotation,
140
+ )
141
+ elif primitive == "ico_sphere":
142
+ bpy.ops.mesh.primitive_ico_sphere_add(
143
+ subdivisions=_integer(op.get("subdivisions", 2), "subdivisions", minimum=1, maximum=8),
144
+ radius=_number(op.get("radius", 1), "radius", minimum=1e-9),
145
+ location=location,
146
+ rotation=rotation,
147
+ )
148
+ elif primitive == "cylinder":
149
+ bpy.ops.mesh.primitive_cylinder_add(
150
+ vertices=_integer(op.get("vertices", 32), "vertices", minimum=3, maximum=512),
151
+ radius=_number(op.get("radius", 1), "radius", minimum=1e-9),
152
+ depth=_number(op.get("depth", 2), "depth", minimum=1e-9),
153
+ location=location,
154
+ rotation=rotation,
155
+ )
156
+ elif primitive == "cone":
157
+ bpy.ops.mesh.primitive_cone_add(
158
+ vertices=_integer(op.get("vertices", 32), "vertices", minimum=3, maximum=512),
159
+ radius1=_number(op.get("radius1", 1), "radius1", minimum=0),
160
+ radius2=_number(op.get("radius2", 0), "radius2", minimum=0),
161
+ depth=_number(op.get("depth", 2), "depth", minimum=1e-9),
162
+ location=location,
163
+ rotation=rotation,
164
+ )
165
+ elif primitive == "plane":
166
+ bpy.ops.mesh.primitive_plane_add(size=_number(op.get("size", 2), "size", minimum=1e-9), location=location, rotation=rotation)
167
+ elif primitive == "torus":
168
+ bpy.ops.mesh.primitive_torus_add(
169
+ major_segments=_integer(op.get("major_segments", 48), "major_segments", minimum=3, maximum=512),
170
+ minor_segments=_integer(op.get("minor_segments", 12), "minor_segments", minimum=3, maximum=256),
171
+ major_radius=_number(op.get("major_radius", 1), "major_radius", minimum=1e-9),
172
+ minor_radius=_number(op.get("minor_radius", 0.25), "minor_radius", minimum=1e-9),
173
+ location=location,
174
+ rotation=rotation,
175
+ )
176
+ else: # pragma: no cover - guarded above
177
+ raise ValueError("unreachable primitive")
178
+
179
+ obj = bpy.context.active_object
180
+ obj.name = _name(op.get("name") or primitive, "name")
181
+ if "scale" in op:
182
+ obj.scale = _vec(op["scale"], "scale")
183
+ return obj
184
+
185
+
186
+ def _op_clear_scene(op: dict[str, Any]) -> None:
187
+ if op.get("op") != "clear_scene":
188
+ raise ValueError("invalid clear operation")
189
+ bpy.ops.object.select_all(action="SELECT")
190
+ bpy.ops.object.delete(use_global=False)
191
+
192
+
193
+ def _op_duplicate(op: dict[str, Any]) -> None:
194
+ source = _object(op.get("object"))
195
+ duplicate = source.copy()
196
+ duplicate.data = source.data.copy() if source.data else None
197
+ duplicate.name = _name(op.get("name") or f"{source.name}_copy", "name")
198
+ bpy.context.collection.objects.link(duplicate)
199
+ _set_transform(duplicate, op)
200
+
201
+
202
+ def _op_delete(op: dict[str, Any]) -> None:
203
+ obj = _object(op.get("object"))
204
+ bpy.data.objects.remove(obj, do_unlink=True)
205
+
206
+
207
+ def _op_join(op: dict[str, Any]) -> None:
208
+ names = op.get("objects")
209
+ if not isinstance(names, list) or len(names) < 2 or len(names) > 128:
210
+ raise ValueError("join.objects must contain 2..128 object names")
211
+ objects = [_object(value) for value in names]
212
+ bpy.ops.object.select_all(action="DESELECT")
213
+ for obj in objects:
214
+ obj.select_set(True)
215
+ bpy.context.view_layer.objects.active = objects[0]
216
+ bpy.ops.object.join()
217
+ if op.get("name"):
218
+ objects[0].name = _name(op["name"])
219
+
220
+
221
+ def _op_bevel(op: dict[str, Any]) -> None:
222
+ obj = _object(op.get("object"))
223
+ modifier = obj.modifiers.new(name="AgentSam Bevel", type="BEVEL")
224
+ modifier.width = _number(op.get("width", 0.1), "width", minimum=0)
225
+ modifier.segments = _integer(op.get("segments", 2), "segments", minimum=1, maximum=64)
226
+ if hasattr(modifier, "limit_method"):
227
+ method = str(op.get("limit_method", "ANGLE")).upper()
228
+ if method not in {"NONE", "ANGLE", "WEIGHT", "VGROUP"}:
229
+ raise ValueError("bevel limit_method must be NONE, ANGLE, WEIGHT, or VGROUP")
230
+ modifier.limit_method = method
231
+ if op.get("apply", True):
232
+ _apply_modifier(obj, modifier)
233
+
234
+
235
+ def _op_solidify(op: dict[str, Any]) -> None:
236
+ obj = _object(op.get("object"))
237
+ modifier = obj.modifiers.new(name="AgentSam Solidify", type="SOLIDIFY")
238
+ modifier.thickness = _number(op.get("thickness", 0.1), "thickness")
239
+ modifier.offset = _number(op.get("offset", 0), "offset", minimum=-1, maximum=1)
240
+ if op.get("apply", True):
241
+ _apply_modifier(obj, modifier)
242
+
243
+
244
+ def _op_array(op: dict[str, Any]) -> None:
245
+ obj = _object(op.get("object"))
246
+ modifier = obj.modifiers.new(name="AgentSam Array", type="ARRAY")
247
+ modifier.count = _integer(op.get("count", 2), "count", minimum=1, maximum=10000)
248
+ modifier.use_relative_offset = True
249
+ modifier.relative_offset_displace = _vec(op.get("relative_offset"), "relative_offset", default=[1, 0, 0])
250
+ if op.get("apply", True):
251
+ _apply_modifier(obj, modifier)
252
+
253
+
254
+ def _op_mirror(op: dict[str, Any]) -> None:
255
+ obj = _object(op.get("object"))
256
+ axes = str(op.get("axes", "X")).upper()
257
+ if not axes or any(axis not in "XYZ" for axis in axes):
258
+ raise ValueError("mirror axes must contain only X, Y, Z")
259
+ modifier = obj.modifiers.new(name="AgentSam Mirror", type="MIRROR")
260
+ modifier.use_axis = tuple(axis in axes for axis in "XYZ")
261
+ if op.get("apply", True):
262
+ _apply_modifier(obj, modifier)
263
+
264
+
265
+ def _op_boolean(op: dict[str, Any]) -> None:
266
+ target = _object(op.get("object"))
267
+ operand = _object(op.get("with"))
268
+ if target == operand:
269
+ raise ValueError("boolean target and operand must differ")
270
+ operation = str(op.get("operation", "DIFFERENCE")).upper()
271
+ if operation not in BOOLEAN_OPERATIONS:
272
+ raise ValueError("boolean operation must be UNION, DIFFERENCE, or INTERSECT")
273
+ modifier = target.modifiers.new(name="AgentSam Boolean", type="BOOLEAN")
274
+ modifier.operation = operation
275
+ modifier.object = operand
276
+ if hasattr(modifier, "solver"):
277
+ modifier.solver = "EXACT"
278
+ if op.get("apply", True):
279
+ _apply_modifier(target, modifier)
280
+ if op.get("delete_operand", False):
281
+ bpy.data.objects.remove(operand, do_unlink=True)
282
+
283
+
284
+ def _op_material(op: dict[str, Any]) -> None:
285
+ name = _name(op.get("name"), "material name")
286
+ material = bpy.data.materials.get(name) or bpy.data.materials.new(name=name)
287
+ if "base_color" in op:
288
+ material.diffuse_color = _vec(op["base_color"], "base_color", size=4)
289
+ if "metallic" in op:
290
+ material.metallic = _number(op["metallic"], "metallic", minimum=0, maximum=1)
291
+ if "roughness" in op:
292
+ material.roughness = _number(op["roughness"], "roughness", minimum=0, maximum=1)
293
+
294
+
295
+ def _op_assign_material(op: dict[str, Any]) -> None:
296
+ obj = _object(op.get("object"))
297
+ name = _name(op.get("material"), "material")
298
+ material = bpy.data.materials.get(name)
299
+ if material is None:
300
+ raise ValueError(f"material not found: {name}")
301
+ if not hasattr(obj.data, "materials"):
302
+ raise ValueError(f"object does not support materials: {obj.name}")
303
+ if op.get("clear", False):
304
+ obj.data.materials.clear()
305
+ if material.name not in [item.name for item in obj.data.materials if item]:
306
+ obj.data.materials.append(material)
307
+
308
+
309
+ def _op_add_camera(op: dict[str, Any]) -> None:
310
+ bpy.ops.object.camera_add(
311
+ location=_vec(op.get("location"), "location", default=[0, -10, 5]),
312
+ rotation=_vec(op.get("rotation"), "rotation", default=[math.radians(67), 0, 0]),
313
+ )
314
+ camera = bpy.context.active_object
315
+ camera.name = _name(op.get("name") or "Camera")
316
+ camera.data.lens = _number(op.get("lens", 50), "lens", minimum=1, maximum=1000)
317
+ if op.get("active", True):
318
+ bpy.context.scene.camera = camera
319
+
320
+
321
+ def _op_add_light(op: dict[str, Any]) -> None:
322
+ kind = str(op.get("type", "AREA")).upper()
323
+ if kind not in {"POINT", "SUN", "SPOT", "AREA"}:
324
+ raise ValueError("light type must be POINT, SUN, SPOT, or AREA")
325
+ bpy.ops.object.light_add(
326
+ type=kind,
327
+ location=_vec(op.get("location"), "location", default=[4, -4, 6]),
328
+ rotation=_vec(op.get("rotation"), "rotation", default=[0, 0, 0]),
329
+ )
330
+ light = bpy.context.active_object
331
+ light.name = _name(op.get("name") or f"{kind.title()} Light")
332
+ light.data.energy = _number(op.get("energy", 1000), "energy", minimum=0, maximum=1e9)
333
+ if "color" in op:
334
+ light.data.color = _vec(op["color"], "color", size=3)
335
+ if kind == "AREA" and "size" in op:
336
+ light.data.size = _number(op["size"], "size", minimum=1e-9)
337
+
338
+
339
+ def _configure_units(units: Any) -> None:
340
+ if units is None:
341
+ return
342
+ if not isinstance(units, dict):
343
+ raise ValueError("recipe.units must be an object")
344
+ settings = bpy.context.scene.unit_settings
345
+ if "system" in units:
346
+ system = str(units["system"]).upper()
347
+ if system not in {"NONE", "METRIC", "IMPERIAL"}:
348
+ raise ValueError("units.system must be NONE, METRIC, or IMPERIAL")
349
+ settings.system = system
350
+ if "scale_length" in units:
351
+ settings.scale_length = _number(units["scale_length"], "units.scale_length", minimum=1e-12, maximum=1e12)
352
+ if "length_unit" in units:
353
+ value = str(units["length_unit"]).upper()
354
+ try:
355
+ settings.length_unit = value
356
+ except (TypeError, ValueError) as exc:
357
+ raise ValueError(f"unsupported length_unit: {value}") from exc
358
+
359
+
360
+ def _build(request: dict[str, Any]) -> dict[str, Any]:
361
+ recipe = request.get("recipe")
362
+ if not isinstance(recipe, dict) or recipe.get("schema_version") != 1:
363
+ raise ValueError("recipe schema_version must be 1")
364
+ operations = recipe.get("operations")
365
+ if not isinstance(operations, list) or not operations or len(operations) > MAX_OPERATIONS:
366
+ raise ValueError(f"recipe operations must contain 1..{MAX_OPERATIONS} entries")
367
+ output = Path(str(request.get("output") or "")).expanduser().resolve()
368
+ if output.suffix.lower() != ".blend":
369
+ raise ValueError("build output must be a .blend file")
370
+
371
+ _configure_units(recipe.get("units"))
372
+ handlers = {
373
+ "clear_scene": _op_clear_scene,
374
+ "transform": lambda op: _set_transform(_object(op.get("object")), op),
375
+ "duplicate": _op_duplicate,
376
+ "delete": _op_delete,
377
+ "join": _op_join,
378
+ "bevel": _op_bevel,
379
+ "solidify": _op_solidify,
380
+ "array": _op_array,
381
+ "mirror": _op_mirror,
382
+ "boolean": _op_boolean,
383
+ "material": _op_material,
384
+ "assign_material": _op_assign_material,
385
+ "add_camera": _op_add_camera,
386
+ "add_light": _op_add_light,
387
+ }
388
+ for index, operation in enumerate(operations):
389
+ if not isinstance(operation, dict):
390
+ raise ValueError(f"operation {index} must be an object")
391
+ op = str(operation.get("op") or "").strip()
392
+ if op == "add":
393
+ _add_primitive(operation)
394
+ elif op in handlers:
395
+ handlers[op](operation)
396
+ else:
397
+ raise ValueError(f"unsupported operation {index}: {op or '<empty>'}")
398
+
399
+ output.parent.mkdir(parents=True, exist_ok=True)
400
+ bpy.ops.wm.save_as_mainfile(filepath=str(output), check_existing=False)
401
+ return {
402
+ "ok": True,
403
+ "blender_version": bpy.app.version_string,
404
+ "operations_applied": len(operations),
405
+ "objects": sorted(obj.name for obj in bpy.data.objects),
406
+ "warnings": [],
407
+ }
408
+
409
+
410
+ def _round_vec(values: Any) -> list[float]:
411
+ return [round(float(value), 9) for value in values]
412
+
413
+
414
+ def _inspect() -> dict[str, Any]:
415
+ scene = bpy.context.scene
416
+ objects: list[dict[str, Any]] = []
417
+ for obj in sorted(bpy.data.objects, key=lambda item: item.name):
418
+ world_bounds = []
419
+ if obj.bound_box:
420
+ world_bounds = [_round_vec(obj.matrix_world @ Vector(corner)) for corner in obj.bound_box]
421
+ objects.append({
422
+ "name": obj.name,
423
+ "type": obj.type,
424
+ "location": _round_vec(obj.location),
425
+ "rotation": _round_vec(obj.rotation_euler),
426
+ "scale": _round_vec(obj.scale),
427
+ "dimensions": _round_vec(obj.dimensions),
428
+ "world_bounds": world_bounds,
429
+ "materials": sorted(slot.material.name for slot in obj.material_slots if slot.material),
430
+ "modifiers": [{"name": modifier.name, "type": modifier.type} for modifier in obj.modifiers],
431
+ })
432
+ return {
433
+ "ok": True,
434
+ "blender_version": bpy.app.version_string,
435
+ "scene": {
436
+ "active": scene.name,
437
+ "scenes": sorted(item.name for item in bpy.data.scenes),
438
+ "collections": sorted(item.name for item in bpy.data.collections),
439
+ "materials": sorted(item.name for item in bpy.data.materials),
440
+ "cameras": sorted(item.name for item in bpy.data.objects if item.type == "CAMERA"),
441
+ "active_camera": scene.camera.name if scene.camera else None,
442
+ "render_engine": scene.render.engine,
443
+ "units": {
444
+ "system": scene.unit_settings.system,
445
+ "scale_length": scene.unit_settings.scale_length,
446
+ "length_unit": scene.unit_settings.length_unit,
447
+ },
448
+ "objects": objects,
449
+ },
450
+ "warnings": [],
451
+ }
452
+
453
+
454
+ def _select_scene(name: Any) -> bpy.types.Scene:
455
+ if not name:
456
+ return bpy.context.scene
457
+ key = _name(name, "scene")
458
+ scene = bpy.data.scenes.get(key)
459
+ if scene is None:
460
+ raise ValueError(f"scene not found: {key}")
461
+ bpy.context.window.scene = scene
462
+ return scene
463
+
464
+
465
+ def _render_preview(request: dict[str, Any]) -> dict[str, Any]:
466
+ scene = _select_scene(request.get("scene"))
467
+ camera_name = request.get("camera")
468
+ if camera_name:
469
+ camera = _object(camera_name)
470
+ if camera.type != "CAMERA":
471
+ raise ValueError(f"object is not a camera: {camera.name}")
472
+ scene.camera = camera
473
+ if scene.camera is None:
474
+ raise ValueError("scene has no active camera; add/select one before render_preview")
475
+
476
+ width = _integer(request.get("width", 1024), "width", minimum=64, maximum=4096)
477
+ height = _integer(request.get("height", 1024), "height", minimum=64, maximum=4096)
478
+ engine = request.get("engine")
479
+ if engine:
480
+ engine = str(engine).upper()
481
+ if engine not in RENDER_ENGINES:
482
+ raise ValueError(f"unsupported render engine: {engine}")
483
+ try:
484
+ scene.render.engine = engine
485
+ except TypeError as exc:
486
+ raise ValueError(f"render engine unavailable in this Blender build: {engine}") from exc
487
+
488
+ output = Path(str(request.get("output") or "")).expanduser().resolve()
489
+ if output.suffix.lower() != ".png":
490
+ raise ValueError("render output must be a .png file")
491
+ output.parent.mkdir(parents=True, exist_ok=True)
492
+ scene.render.resolution_x = width
493
+ scene.render.resolution_y = height
494
+ scene.render.resolution_percentage = 100
495
+ scene.render.image_settings.file_format = "PNG"
496
+ scene.render.filepath = str(output)
497
+ bpy.ops.render.render(write_still=True)
498
+ return {
499
+ "ok": True,
500
+ "blender_version": bpy.app.version_string,
501
+ "scene": scene.name,
502
+ "camera": scene.camera.name,
503
+ "warnings": [],
504
+ }
505
+
506
+
507
+ def _selection(request: dict[str, Any]) -> list[str]:
508
+ names = request.get("objects") or []
509
+ if not isinstance(names, list) or len(names) > 256:
510
+ raise ValueError("objects must be an array with at most 256 names")
511
+ selected = {_name(value, "object") for value in names}
512
+ collection_name = request.get("collection")
513
+ if collection_name:
514
+ collection = bpy.data.collections.get(_name(collection_name, "collection"))
515
+ if collection is None:
516
+ raise ValueError(f"collection not found: {collection_name}")
517
+ selected.update(obj.name for obj in collection.all_objects)
518
+ if selected:
519
+ missing = sorted(name for name in selected if bpy.data.objects.get(name) is None)
520
+ if missing:
521
+ raise ValueError("objects not found: " + ", ".join(missing))
522
+ bpy.ops.object.select_all(action="DESELECT")
523
+ for name in sorted(selected):
524
+ bpy.data.objects[name].select_set(True)
525
+ first = bpy.data.objects[sorted(selected)[0]]
526
+ bpy.context.view_layer.objects.active = first
527
+ return sorted(selected)
528
+
529
+
530
+ def _export(request: dict[str, Any]) -> dict[str, Any]:
531
+ _select_scene(request.get("scene"))
532
+ output_format = str(request.get("format") or "").lower().lstrip(".")
533
+ if output_format not in {"glb", "stl", "obj"}:
534
+ raise ValueError("export format must be glb, stl, or obj")
535
+ output = Path(str(request.get("output") or "")).expanduser().resolve()
536
+ if output.suffix.lower() != f".{output_format}":
537
+ raise ValueError(f"export output must end in .{output_format}")
538
+ output.parent.mkdir(parents=True, exist_ok=True)
539
+ selected = _selection(request)
540
+ use_selection = bool(selected)
541
+ apply_modifiers = bool(request.get("apply_modifiers", True))
542
+
543
+ if output_format == "glb":
544
+ kwargs = {"filepath": str(output), "export_format": "GLB", "use_selection": use_selection}
545
+ if apply_modifiers:
546
+ kwargs["export_apply"] = True
547
+ try:
548
+ bpy.ops.export_scene.gltf(**kwargs)
549
+ except TypeError:
550
+ kwargs.pop("export_apply", None)
551
+ bpy.ops.export_scene.gltf(**kwargs)
552
+ elif output_format == "stl":
553
+ if hasattr(bpy.ops.wm, "stl_export"):
554
+ bpy.ops.wm.stl_export(filepath=str(output), export_selected_objects=use_selection, apply_modifiers=apply_modifiers)
555
+ elif hasattr(bpy.ops.export_mesh, "stl"):
556
+ bpy.ops.export_mesh.stl(filepath=str(output), use_selection=use_selection, use_mesh_modifiers=apply_modifiers)
557
+ else:
558
+ raise RuntimeError("this Blender build does not provide an STL exporter")
559
+ elif output_format == "obj":
560
+ if hasattr(bpy.ops.wm, "obj_export"):
561
+ bpy.ops.wm.obj_export(filepath=str(output), export_selected_objects=use_selection, apply_modifiers=apply_modifiers)
562
+ elif hasattr(bpy.ops.export_scene, "obj"):
563
+ bpy.ops.export_scene.obj(filepath=str(output), use_selection=use_selection, use_mesh_modifiers=apply_modifiers)
564
+ else:
565
+ raise RuntimeError("this Blender build does not provide an OBJ exporter")
566
+
567
+ return {
568
+ "ok": True,
569
+ "blender_version": bpy.app.version_string,
570
+ "selected_objects": selected,
571
+ "warnings": [],
572
+ }
573
+
574
+
575
+ def main() -> int:
576
+ args = _args()
577
+ try:
578
+ request = _load_request(args.request)
579
+ if args.operation == "inspect":
580
+ value = _inspect()
581
+ elif args.operation == "build":
582
+ value = _build(request)
583
+ elif args.operation == "render_preview":
584
+ value = _render_preview(request)
585
+ elif args.operation == "export":
586
+ value = _export(request)
587
+ else: # pragma: no cover - argparse guards this
588
+ raise ValueError("unsupported operation")
589
+ _result(value)
590
+ return 0
591
+ except Exception as exc: # Blender must return machine-readable failure evidence
592
+ _result({
593
+ "ok": False,
594
+ "blender_version": bpy.app.version_string,
595
+ "error": str(exc),
596
+ "error_type": type(exc).__name__,
597
+ })
598
+ traceback.print_exc(file=sys.stderr)
599
+ return 2
600
+
601
+
602
+ if __name__ == "__main__":
603
+ raise SystemExit(main())
package/src/cli.js CHANGED
@@ -25,6 +25,7 @@ import { runMerkle } from './commands/merkle.js';
25
25
  import { runDeployReceipt } from './commands/deploy-receipt.js';
26
26
  import { runSecurity } from './commands/security.js';
27
27
  import { runRecon } from './commands/recon.js';
28
+ import { runCad } from './commands/cad.js';
28
29
  import { applyPresetSelection, runAdd, runCapabilities, runDev, runInspect } from './commands/product.js';
29
30
  import { listPresets, resolvePreset } from './presets/index.js';
30
31
  import fs from 'node:fs';
@@ -60,6 +61,7 @@ function printHelp() {
60
61
  agentsam index Plan/run incremental AST and optional embeddings (--help)
61
62
  agentsam search "query" Retrieve indexed code/text; --semantic enables embeddings
62
63
  agentsam repo snapshot Git composition/churn; --save retains observations
64
+ agentsam cad blender Programmatic Blender inspect/build/render/export (--help)
63
65
  agentsam mini <name> Create and preview a small local gadget (--help for options)
64
66
  agentsam merkle File integrity, snapshots, comparisons, and TUI (--help)
65
67
  agentsam deploy-receipt Merkle deploy/checkpoint capture + promote/failure receipts (--help)
@@ -346,6 +348,13 @@ if (command === '--version' || command === '-v') {
346
348
  console.error(`\n ✗ ${e?.message || e}\n`);
347
349
  process.exit(1);
348
350
  }
351
+ } else if (command === 'cad') {
352
+ try {
353
+ await runCad(rest);
354
+ } catch (e) {
355
+ console.error(`\n ✗ ${e?.message || e}\n`);
356
+ process.exitCode = 1;
357
+ }
349
358
  } else if (command === 'security' || command === 'sca') {
350
359
  await runSecurity(rest);
351
360
  } else if (command === 'merkle') {