@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.
- package/examples/cad/blender-box-with-hole.recipe.json +71 -0
- package/package.json +3 -1
- package/packages/identity/package.json +1 -1
- package/protocol/cad/blender-recipe.schema.json +92 -0
- package/protocol/capabilities/manifest.json +76 -0
- package/services/cad/blender/adapter.py +603 -0
- package/src/cli.js +9 -0
- package/src/commands/cad.js +138 -0
- package/src/lib/cad/blender.js +340 -0
- package/src/lib/cad/index.js +15 -0
- package/test/blender-cad.test.mjs +146 -0
|
@@ -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') {
|