dsh-plugin-blender-3d 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/LICENSE +21 -0
- package/README.md +59 -0
- package/cordis.patch.yml +4 -0
- package/lib/bridge-client.js +109 -0
- package/lib/index.js +117 -0
- package/lib/runtime/blender_ai_bridge/README.md +57 -0
- package/lib/runtime/blender_ai_bridge/__init__.py +159 -0
- package/lib/runtime/blender_ai_bridge/commands.py +617 -0
- package/lib/runtime/blender_ai_bridge/headless_start.py +62 -0
- package/lib/runtime/blender_ai_bridge/server.py +205 -0
- package/lib/runtime/blender_driver.js +192 -0
- package/lib/runtime/headless_bridge.py +575 -0
- package/lib/runtime/requirements.txt +3 -0
- package/package.json +61 -0
- package/skill/blender-3d-modeling/SKILL.md +118 -0
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Command handlers for the Blender AI Bridge add-on.
|
|
3
|
+
|
|
4
|
+
All handlers run on Blender's MAIN thread (the HTTP server marshals requests
|
|
5
|
+
through a timer queue, see server.py). Each handler takes an args dict and
|
|
6
|
+
returns a JSON-serializable dict. Heavy geometry is NEVER returned in the JSON
|
|
7
|
+
payload — exports write to disk and return only the file path, and scene
|
|
8
|
+
queries return lightweight summaries. This keeps the Node.js harness driver
|
|
9
|
+
from blowing the V8 heap on large meshes (OOM fix).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import bpy
|
|
13
|
+
import bmesh
|
|
14
|
+
import math
|
|
15
|
+
import os
|
|
16
|
+
import io
|
|
17
|
+
import json
|
|
18
|
+
import base64
|
|
19
|
+
import tempfile
|
|
20
|
+
import traceback
|
|
21
|
+
from mathutils import Vector, Euler, Matrix
|
|
22
|
+
|
|
23
|
+
# Blender version tuple, e.g. (4, 2, 0) or (3, 6, 4).
|
|
24
|
+
BL_VERSION = bpy.app.version
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# --------------------------------------------------------------------------- #
|
|
28
|
+
# helpers
|
|
29
|
+
# --------------------------------------------------------------------------- #
|
|
30
|
+
def _vec(v, default=(0.0, 0.0, 0.0)):
|
|
31
|
+
if v is None:
|
|
32
|
+
return Vector(default)
|
|
33
|
+
if isinstance(v, Vector):
|
|
34
|
+
return v
|
|
35
|
+
return Vector((float(v[0]), float(v[1]), float(v[2])))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _euler(rot, default=(0.0, 0.0, 0.0)):
|
|
39
|
+
"""Accept radians (list/tuple) or degrees when ``rot_is_degrees`` is set."""
|
|
40
|
+
return Euler(_vec(rot, default), 'XYZ')
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _obj(name):
|
|
44
|
+
obj = bpy.data.objects.get(name)
|
|
45
|
+
if obj is None:
|
|
46
|
+
raise KeyError("object not found: %r" % name)
|
|
47
|
+
return obj
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _select_only(names):
|
|
51
|
+
bpy.ops.object.select_all(action='DESELECT')
|
|
52
|
+
selected = []
|
|
53
|
+
for n in names:
|
|
54
|
+
obj = bpy.data.objects.get(n)
|
|
55
|
+
if obj is not None:
|
|
56
|
+
obj.select_set(True)
|
|
57
|
+
selected.append(obj)
|
|
58
|
+
if selected:
|
|
59
|
+
bpy.context.view_layer.objects.active = selected[0]
|
|
60
|
+
return selected
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _apply_active_modifiers(obj, only=None):
|
|
64
|
+
"""Apply modifiers on ``obj`` that the bridge added (names start with 'AIB_')."""
|
|
65
|
+
ctx = bpy.context.copy()
|
|
66
|
+
for mod in list(obj.modifiers):
|
|
67
|
+
if mod.name.startswith("AIB_") and (only is None or mod.name == only):
|
|
68
|
+
ctx["modifier"] = mod
|
|
69
|
+
try:
|
|
70
|
+
bpy.ops.object.modifier_apply(ctx, modifier=mod.name)
|
|
71
|
+
except Exception:
|
|
72
|
+
# Fall back: remove if apply fails so we don't leave dangling state.
|
|
73
|
+
obj.modifiers.remove(mod)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _ensure_active(obj):
|
|
77
|
+
bpy.ops.object.select_all(action='DESELECT')
|
|
78
|
+
obj.select_set(True)
|
|
79
|
+
bpy.context.view_layer.objects.active = obj
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# --------------------------------------------------------------------------- #
|
|
83
|
+
# version-tolerant export
|
|
84
|
+
# --------------------------------------------------------------------------- #
|
|
85
|
+
def _export(fmt, path, selected_only, ascii_out=False):
|
|
86
|
+
"""Call the right export operator for the running Blender version.
|
|
87
|
+
|
|
88
|
+
4.1+ moved STL/OBJ/PLY/USD to ``bpy.ops.wm.*_export``; 3.x keeps them under
|
|
89
|
+
``export_mesh`` / ``export_scene``. We try the new API first and fall back.
|
|
90
|
+
"""
|
|
91
|
+
fmt = fmt.lower().lstrip(".")
|
|
92
|
+
sel = bool(selected_only)
|
|
93
|
+
|
|
94
|
+
def call(op_path, kwargs):
|
|
95
|
+
mod, _, fn = op_path.partition(".")
|
|
96
|
+
op = getattr(getattr(bpy.ops, mod), fn)
|
|
97
|
+
op(**kwargs)
|
|
98
|
+
|
|
99
|
+
if fmt == "stl":
|
|
100
|
+
try:
|
|
101
|
+
call("wm.stl_export", {"filepath": path, "export_selected_objects": sel,
|
|
102
|
+
"use_ascii": ascii_out})
|
|
103
|
+
except (AttributeError, TypeError):
|
|
104
|
+
call("export_mesh.stl", {"filepath": path, "use_selection": sel,
|
|
105
|
+
"ascii": ascii_out})
|
|
106
|
+
elif fmt == "obj":
|
|
107
|
+
try:
|
|
108
|
+
call("wm.obj_export", {"filepath": path, "export_selected_objects": sel})
|
|
109
|
+
except (AttributeError, TypeError):
|
|
110
|
+
call("export_scene.obj", {"filepath": path, "use_selection": sel})
|
|
111
|
+
elif fmt == "ply":
|
|
112
|
+
try:
|
|
113
|
+
call("wm.ply_export", {"filepath": path, "export_selected_objects": sel})
|
|
114
|
+
except (AttributeError, TypeError):
|
|
115
|
+
call("export_mesh.ply", {"filepath": path, "use_selection": sel})
|
|
116
|
+
elif fmt == "fbx":
|
|
117
|
+
call("export_scene.fbx", {"filepath": path, "use_selection": sel})
|
|
118
|
+
elif fmt in ("gltf", "glb"):
|
|
119
|
+
ext = "glb" if fmt == "glb" else "gltf"
|
|
120
|
+
# Force the right extension on disk.
|
|
121
|
+
path = os.path.splitext(path)[0] + "." + ext
|
|
122
|
+
call("export_scene.gltf", {"filepath": path, "use_selection": sel,
|
|
123
|
+
"export_format": ('GLB' if ext == 'glb' else 'GLTF_SEPARATE')})
|
|
124
|
+
elif fmt == "blend":
|
|
125
|
+
# Save the whole .blend to path.
|
|
126
|
+
bpy.ops.wm.save_as_mainfile(filepath=path)
|
|
127
|
+
elif fmt == "ply":
|
|
128
|
+
call("export_mesh.ply", {"filepath": path, "use_selection": sel})
|
|
129
|
+
else:
|
|
130
|
+
raise ValueError("unsupported export format: %s" % fmt)
|
|
131
|
+
return path
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# --------------------------------------------------------------------------- #
|
|
135
|
+
# command dispatcher
|
|
136
|
+
# --------------------------------------------------------------------------- #
|
|
137
|
+
class BlenderCommandHandler:
|
|
138
|
+
"""Stateless command handler. Constructed once per add-on session."""
|
|
139
|
+
|
|
140
|
+
def __init__(self, tmp_dir=None):
|
|
141
|
+
self.tmp_dir = tmp_dir or tempfile.gettempdir()
|
|
142
|
+
|
|
143
|
+
def dispatch(self, payload):
|
|
144
|
+
cmd = payload.get("command")
|
|
145
|
+
args = payload.get("args", {}) or {}
|
|
146
|
+
# Allow args to be passed at top level for convenience.
|
|
147
|
+
if not args and isinstance(payload, dict):
|
|
148
|
+
args = {k: v for k, v in payload.items() if k not in ("command", "args", "id")}
|
|
149
|
+
try:
|
|
150
|
+
fn = getattr(self, "cmd_" + cmd, None) if cmd else None
|
|
151
|
+
if fn is None:
|
|
152
|
+
raise ValueError("unknown command: %r" % cmd)
|
|
153
|
+
res = fn(args)
|
|
154
|
+
if res is None:
|
|
155
|
+
res = {"ok": True}
|
|
156
|
+
if isinstance(res, dict) and "ok" not in res:
|
|
157
|
+
res = dict(res, ok=True)
|
|
158
|
+
return res
|
|
159
|
+
except Exception as e:
|
|
160
|
+
return {"ok": False, "error": str(e),
|
|
161
|
+
"trace": traceback.format_exc(limit=6)}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# --------------------------------------------------------------------------- #
|
|
165
|
+
# primitives
|
|
166
|
+
# --------------------------------------------------------------------------- #
|
|
167
|
+
def cmd_create_shape(self, a):
|
|
168
|
+
t = a.get("type", "box")
|
|
169
|
+
loc = _vec(a.get("location"))
|
|
170
|
+
rot = _euler(a.get("rotation"), (0, 0, 0))
|
|
171
|
+
if a.get("rotation_degrees"):
|
|
172
|
+
rot = Euler((math.radians(v) for v in a["rotation"]), 'XYZ')
|
|
173
|
+
name = a.get("name")
|
|
174
|
+
|
|
175
|
+
bpy.ops.object.select_all(action='DESELECT')
|
|
176
|
+
|
|
177
|
+
def add(op, **kw):
|
|
178
|
+
op(location=tuple(loc), **kw)
|
|
179
|
+
|
|
180
|
+
if t == "box":
|
|
181
|
+
dx, dy, dz = a.get("dx", 1), a.get("dy", 1), a.get("dz", 1)
|
|
182
|
+
s = a.get("size")
|
|
183
|
+
if s is not None:
|
|
184
|
+
dx = dy = dz = s
|
|
185
|
+
add(bpy.ops.mesh.primitive_cube_add, size=1.0)
|
|
186
|
+
obj = bpy.context.active_object
|
|
187
|
+
obj.scale = (dx, dy, dz)
|
|
188
|
+
elif t == "sphere":
|
|
189
|
+
add(bpy.ops.mesh.primitive_uv_sphere_add,
|
|
190
|
+
radius=a.get("radius", 1.0),
|
|
191
|
+
segments=a.get("segments", 32), ring_count=a.get("rings", 16))
|
|
192
|
+
obj = bpy.context.active_object
|
|
193
|
+
elif t == "ico_sphere":
|
|
194
|
+
add(bpy.ops.mesh.primitive_ico_sphere_add,
|
|
195
|
+
radius=a.get("radius", 1.0), subdivisions=a.get("subdivisions", 2))
|
|
196
|
+
obj = bpy.context.active_object
|
|
197
|
+
elif t == "cylinder":
|
|
198
|
+
add(bpy.ops.mesh.primitive_cylinder_add,
|
|
199
|
+
radius=a.get("radius", 1.0), depth=a.get("height", a.get("depth", 2.0)),
|
|
200
|
+
vertices=a.get("vertices", 32))
|
|
201
|
+
obj = bpy.context.active_object
|
|
202
|
+
elif t == "cone":
|
|
203
|
+
add(bpy.ops.mesh.primitive_cone_add,
|
|
204
|
+
radius1=a.get("radius", 1.0), radius2=a.get("radius2", 0.0),
|
|
205
|
+
depth=a.get("height", 2.0), vertices=a.get("vertices", 32))
|
|
206
|
+
obj = bpy.context.active_object
|
|
207
|
+
elif t == "torus":
|
|
208
|
+
add(bpy.ops.mesh.primitive_torus_add,
|
|
209
|
+
major_radius=a.get("major_radius", 1.0),
|
|
210
|
+
minor_radius=a.get("minor_radius", 0.25),
|
|
211
|
+
major_segments=a.get("major_segments", 48),
|
|
212
|
+
minor_segments=a.get("minor_segments", 12))
|
|
213
|
+
obj = bpy.context.active_object
|
|
214
|
+
elif t == "plane":
|
|
215
|
+
add(bpy.ops.mesh.primitive_plane_add, size=a.get("size", 2.0))
|
|
216
|
+
obj = bpy.context.active_object
|
|
217
|
+
elif t == "grid":
|
|
218
|
+
add(bpy.ops.mesh.primitive_grid_add, size=a.get("size", 2.0),
|
|
219
|
+
x_subdivisions=a.get("x_subdivisions", 10),
|
|
220
|
+
y_subdivisions=a.get("y_subdivisions", 10))
|
|
221
|
+
obj = bpy.context.active_object
|
|
222
|
+
elif t == "circle":
|
|
223
|
+
add(bpy.ops.mesh.primitive_circle_add, radius=a.get("radius", 1.0),
|
|
224
|
+
vertices=a.get("vertices", 32))
|
|
225
|
+
obj = bpy.context.active_object
|
|
226
|
+
elif t == "monkey":
|
|
227
|
+
add(bpy.ops.mesh.primitive_monkey_add, size=a.get("size", 2.0))
|
|
228
|
+
obj = bpy.context.active_object
|
|
229
|
+
else:
|
|
230
|
+
raise ValueError("unknown shape type: %r" % t)
|
|
231
|
+
|
|
232
|
+
obj.rotation_euler = rot
|
|
233
|
+
if name:
|
|
234
|
+
obj.name = name
|
|
235
|
+
return {"ok": True, "name": obj.name, "type": t,
|
|
236
|
+
"location": tuple(obj.location), "scale": tuple(obj.scale)}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# --------------------------------------------------------------------------- #
|
|
240
|
+
# transforms / object ops
|
|
241
|
+
# --------------------------------------------------------------------------- #
|
|
242
|
+
def cmd_transform(self, a):
|
|
243
|
+
obj = _obj(a["name"])
|
|
244
|
+
if "location" in a:
|
|
245
|
+
obj.location = _vec(a["location"])
|
|
246
|
+
if "rotation" in a:
|
|
247
|
+
r = a["rotation"]
|
|
248
|
+
obj.rotation_euler = Euler((math.radians(v) for v in r), 'XYZ') \
|
|
249
|
+
if a.get("rotation_degrees") else _euler(r)
|
|
250
|
+
if "scale" in a:
|
|
251
|
+
obj.scale = _vec(a["scale"], (1, 1, 1))
|
|
252
|
+
if "rotation_euler" in a:
|
|
253
|
+
obj.rotation_euler = _euler(a["rotation_euler"])
|
|
254
|
+
return {"ok": True, "name": obj.name,
|
|
255
|
+
"location": tuple(obj.location), "scale": tuple(obj.scale)}
|
|
256
|
+
|
|
257
|
+
def cmd_duplicate(self, a):
|
|
258
|
+
obj = _obj(a["name"])
|
|
259
|
+
count = int(a.get("count", 1))
|
|
260
|
+
offset = _vec(a.get("offset", (0, 0, 0)))
|
|
261
|
+
base = a.get("new_name") or a.get("as") or obj.name
|
|
262
|
+
names = []
|
|
263
|
+
for i in range(count):
|
|
264
|
+
_ensure_active(obj)
|
|
265
|
+
bpy.ops.object.duplicate_move(
|
|
266
|
+
TRANSFORM_OT_translate={"value": tuple(offset)})
|
|
267
|
+
dup = bpy.context.active_object
|
|
268
|
+
dup.name = base if count == 1 else "%s.%03d" % (base, i + 1)
|
|
269
|
+
names.append(dup.name)
|
|
270
|
+
return {"ok": True, "created": names}
|
|
271
|
+
|
|
272
|
+
def cmd_delete(self, a):
|
|
273
|
+
names = a.get("names") or [a["name"]]
|
|
274
|
+
removed = []
|
|
275
|
+
for n in list(names):
|
|
276
|
+
obj = bpy.data.objects.get(n)
|
|
277
|
+
if obj is not None:
|
|
278
|
+
removed.append(n)
|
|
279
|
+
_ensure_active(obj)
|
|
280
|
+
bpy.ops.object.delete()
|
|
281
|
+
return {"ok": True, "removed": removed}
|
|
282
|
+
|
|
283
|
+
def cmd_rename(self, a):
|
|
284
|
+
obj = _obj(a["name"])
|
|
285
|
+
obj.name = a["new_name"]
|
|
286
|
+
return {"ok": True, "name": obj.name}
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# --------------------------------------------------------------------------- #
|
|
290
|
+
# boolean
|
|
291
|
+
# --------------------------------------------------------------------------- #
|
|
292
|
+
def cmd_boolean(self, a):
|
|
293
|
+
op = a.get("operation", "difference").upper()
|
|
294
|
+
if op not in ("UNION", "DIFFERENCE", "INTERSECT"):
|
|
295
|
+
raise ValueError("bad operation: %s" % op)
|
|
296
|
+
target = _obj(a["target"])
|
|
297
|
+
tool_objs = []
|
|
298
|
+
for n in (a.get("tools") or ([a["tool"]] if "tool" in a else [])):
|
|
299
|
+
tool_objs.append(_obj(n))
|
|
300
|
+
if not tool_objs:
|
|
301
|
+
raise ValueError("boolean needs at least one tool object")
|
|
302
|
+
|
|
303
|
+
# Boolean modifier only supports one tool at a time; chain them.
|
|
304
|
+
for tool in tool_objs:
|
|
305
|
+
mod = target.modifiers.new(name="AIB_Boolean", type='BOOLEAN')
|
|
306
|
+
mod.operation = op
|
|
307
|
+
mod.object = tool
|
|
308
|
+
if hasattr(mod, 'solver'):
|
|
309
|
+
mod.solver = a.get("solver", 'EXACT')
|
|
310
|
+
if a.get("apply", True):
|
|
311
|
+
_apply_active_modifiers(target, only=mod.name)
|
|
312
|
+
|
|
313
|
+
if a.get("remove_tools", False):
|
|
314
|
+
for tool in tool_objs:
|
|
315
|
+
if tool.name in bpy.data.objects:
|
|
316
|
+
_ensure_active(tool)
|
|
317
|
+
bpy.ops.object.delete()
|
|
318
|
+
|
|
319
|
+
result = {"ok": True, "target": target.name, "operation": op}
|
|
320
|
+
if a.get("name"):
|
|
321
|
+
target.name = a["name"]
|
|
322
|
+
result["name"] = target.name
|
|
323
|
+
return result
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# --------------------------------------------------------------------------- #
|
|
327
|
+
# modifiers
|
|
328
|
+
# --------------------------------------------------------------------------- #
|
|
329
|
+
def cmd_modifier(self, a):
|
|
330
|
+
action = a.get("action", "add")
|
|
331
|
+
obj = _obj(a["name"])
|
|
332
|
+
if action == "add":
|
|
333
|
+
mtype = a["type"].upper()
|
|
334
|
+
mod = obj.modifiers.new(name=a.get("mod_name", "AIB_" + a["type"]),
|
|
335
|
+
type=mtype)
|
|
336
|
+
for k, v in (a.get("params") or {}).items():
|
|
337
|
+
if hasattr(mod, k):
|
|
338
|
+
setattr(mod, k, v)
|
|
339
|
+
if a.get("apply", False):
|
|
340
|
+
_apply_active_modifiers(obj, only=mod.name)
|
|
341
|
+
return {"ok": True, "modifier": mod.name, "type": mtype}
|
|
342
|
+
if action == "remove":
|
|
343
|
+
mod = obj.modifiers.get(a["mod_name"])
|
|
344
|
+
if mod:
|
|
345
|
+
obj.modifiers.remove(mod)
|
|
346
|
+
return {"ok": True}
|
|
347
|
+
if action == "list":
|
|
348
|
+
return {"ok": True, "modifiers": [
|
|
349
|
+
{"name": m.name, "type": m.type} for m in obj.modifiers]}
|
|
350
|
+
raise ValueError("bad modifier action: %s" % action)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# --------------------------------------------------------------------------- #
|
|
354
|
+
# materials
|
|
355
|
+
# --------------------------------------------------------------------------- #
|
|
356
|
+
def cmd_material(self, a):
|
|
357
|
+
action = a.get("action", "assign")
|
|
358
|
+
if action == "list":
|
|
359
|
+
return {"ok": True, "materials": [
|
|
360
|
+
{"name": m.name} for m in bpy.data.materials]}
|
|
361
|
+
if action == "create":
|
|
362
|
+
mat = bpy.data.materials.new(name=a.get("name", "Material"))
|
|
363
|
+
mat.use_nodes = True
|
|
364
|
+
bsdf = mat.node_tree.nodes.get("Principled BSDF")
|
|
365
|
+
if bsdf and "color" in a:
|
|
366
|
+
bsdf.inputs["Base Color"].default_value = _vec4(a["color"])
|
|
367
|
+
if bsdf and "metallic" in a and "Metallic" in bsdf.inputs:
|
|
368
|
+
bsdf.inputs["Metallic"].default_value = float(a["metallic"])
|
|
369
|
+
if bsdf and "roughness" in a and "Roughness" in bsdf.inputs:
|
|
370
|
+
bsdf.inputs["Roughness"].default_value = float(a["roughness"])
|
|
371
|
+
return {"ok": True, "name": mat.name}
|
|
372
|
+
# assign
|
|
373
|
+
obj = _obj(a["name"])
|
|
374
|
+
mat = bpy.data.materials.get(a["material"])
|
|
375
|
+
if mat is None:
|
|
376
|
+
raise KeyError("material not found: %r" % a["material"])
|
|
377
|
+
if obj.data.materials:
|
|
378
|
+
obj.data.materials[0] = mat
|
|
379
|
+
else:
|
|
380
|
+
obj.data.materials.append(mat)
|
|
381
|
+
return {"ok": True, "name": obj.name, "material": mat.name}
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# --------------------------------------------------------------------------- #
|
|
385
|
+
# scene / selection (lightweight — no mesh data, OOM-safe)
|
|
386
|
+
# --------------------------------------------------------------------------- #
|
|
387
|
+
def cmd_scene(self, a):
|
|
388
|
+
objs = []
|
|
389
|
+
for o in bpy.data.objects:
|
|
390
|
+
d = {"name": o.name, "type": o.type,
|
|
391
|
+
"location": tuple(o.location), "scale": tuple(o.scale),
|
|
392
|
+
"rotation": tuple(o.rotation_euler)}
|
|
393
|
+
if o.type == 'MESH':
|
|
394
|
+
d["vertices"] = len(o.data.vertices)
|
|
395
|
+
d["polygons"] = len(o.data.polygons)
|
|
396
|
+
d["materials"] = [m.name for m in o.data.materials if m]
|
|
397
|
+
objs.append(d)
|
|
398
|
+
return {"ok": True, "objects": objs,
|
|
399
|
+
"object_count": len(objs),
|
|
400
|
+
"blender_version": list(BL_VERSION)}
|
|
401
|
+
|
|
402
|
+
def cmd_selection(self, a):
|
|
403
|
+
action = a.get("action", "list")
|
|
404
|
+
if action == "list":
|
|
405
|
+
return {"ok": True, "selected": [
|
|
406
|
+
o.name for o in bpy.context.selected_objects]}
|
|
407
|
+
if action == "clear":
|
|
408
|
+
bpy.ops.object.select_all(action='DESELECT')
|
|
409
|
+
return {"ok": True}
|
|
410
|
+
if action == "set":
|
|
411
|
+
_select_only(a.get("names", []))
|
|
412
|
+
return {"ok": True, "selected": a.get("names", [])}
|
|
413
|
+
if action == "active":
|
|
414
|
+
o = bpy.context.active_object
|
|
415
|
+
return {"ok": True, "active": o.name if o else None}
|
|
416
|
+
raise ValueError("bad selection action: %s" % action)
|
|
417
|
+
|
|
418
|
+
def cmd_scene_clear(self, a):
|
|
419
|
+
keep = set(a.get("keep_types", []))
|
|
420
|
+
removed = []
|
|
421
|
+
for o in list(bpy.data.objects):
|
|
422
|
+
if o.type in keep:
|
|
423
|
+
continue
|
|
424
|
+
if o.type == 'MESH' or a.get("all", False):
|
|
425
|
+
removed.append(o.name)
|
|
426
|
+
bpy.data.objects.remove(o, do_unlink=True)
|
|
427
|
+
return {"ok": True, "removed": removed}
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# --------------------------------------------------------------------------- #
|
|
431
|
+
# camera / light / render
|
|
432
|
+
# --------------------------------------------------------------------------- #
|
|
433
|
+
def cmd_camera(self, a):
|
|
434
|
+
if a.get("action", "add") == "add":
|
|
435
|
+
bpy.ops.object.camera_add(location=tuple(_vec(a.get("location", (7, -7, 5)))))
|
|
436
|
+
cam = bpy.context.active_object
|
|
437
|
+
if a.get("name"):
|
|
438
|
+
cam.name = a["name"]
|
|
439
|
+
if "rotation" in a:
|
|
440
|
+
cam.rotation_euler = _euler(a["rotation"])
|
|
441
|
+
bpy.context.scene.camera = cam
|
|
442
|
+
return {"ok": True, "name": cam.name}
|
|
443
|
+
raise ValueError("bad camera action")
|
|
444
|
+
|
|
445
|
+
def cmd_light(self, a):
|
|
446
|
+
ltype = a.get("type", "POINT").upper()
|
|
447
|
+
energy = float(a.get("energy", 1000))
|
|
448
|
+
bpy.ops.object.light_add(type=ltype, location=tuple(_vec(a.get("location", (4, -4, 6)))))
|
|
449
|
+
light = bpy.context.active_object
|
|
450
|
+
light.data.energy = energy
|
|
451
|
+
if a.get("name"):
|
|
452
|
+
light.name = a["name"]
|
|
453
|
+
return {"ok": True, "name": light.name, "type": ltype}
|
|
454
|
+
|
|
455
|
+
def cmd_render(self, a):
|
|
456
|
+
scene = bpy.context.scene
|
|
457
|
+
if "engine" in a:
|
|
458
|
+
scene.render.engine = a["engine"] # e.g. 'CYCLES', 'BLENDER_EEVEE_NEXT'
|
|
459
|
+
if "resolution" in a:
|
|
460
|
+
scene.render.resolution_x, scene.render.resolution_y = a["resolution"]
|
|
461
|
+
if "samples" in a and scene.render.engine == 'CYCLES':
|
|
462
|
+
scene.cycles.samples = int(a["samples"])
|
|
463
|
+
path = a.get("path")
|
|
464
|
+
if not path:
|
|
465
|
+
path = os.path.join(self.tmp_dir, "aib_render.png")
|
|
466
|
+
scene.render.filepath = path
|
|
467
|
+
bpy.ops.render.render(write_still=True)
|
|
468
|
+
return {"ok": True, "file_path": path}
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
# --------------------------------------------------------------------------- #
|
|
472
|
+
# export / import (OOM-safe: returns file path, not bytes)
|
|
473
|
+
# --------------------------------------------------------------------------- #
|
|
474
|
+
def cmd_export(self, a):
|
|
475
|
+
fmt = a.get("format", "stl")
|
|
476
|
+
path = a.get("path")
|
|
477
|
+
if not path:
|
|
478
|
+
ext = fmt.lower().lstrip(".")
|
|
479
|
+
if ext == "glb":
|
|
480
|
+
ext = "glb"
|
|
481
|
+
elif ext in ("gltf",):
|
|
482
|
+
ext = "gltf"
|
|
483
|
+
path = os.path.join(self.tmp_dir, "aib_export.%s" % ext)
|
|
484
|
+
names = a.get("names")
|
|
485
|
+
selected_only = bool(a.get("selected_only", False))
|
|
486
|
+
if names:
|
|
487
|
+
_select_only(names)
|
|
488
|
+
selected_only = True
|
|
489
|
+
path = _export(fmt, path, selected_only, ascii_out=bool(a.get("ascii", False)))
|
|
490
|
+
result = {"ok": True, "file_path": path, "format": fmt,
|
|
491
|
+
"size": os.path.getsize(path) if os.path.exists(path) else 0}
|
|
492
|
+
# Optionally inline small files as base64 (only when explicitly asked
|
|
493
|
+
# and below a size cap, to avoid the Node OOM we hit before).
|
|
494
|
+
if a.get("return_data") and os.path.exists(path):
|
|
495
|
+
cap = int(a.get("max_inline_bytes", 2 * 1024 * 1024))
|
|
496
|
+
sz = os.path.getsize(path)
|
|
497
|
+
if sz <= cap:
|
|
498
|
+
with open(path, "rb") as fh:
|
|
499
|
+
result["data_b64"] = base64.b64encode(fh.read()).decode("ascii")
|
|
500
|
+
else:
|
|
501
|
+
result["data_b64"] = None
|
|
502
|
+
result["note"] = "file too large to inline; read from file_path"
|
|
503
|
+
return result
|
|
504
|
+
|
|
505
|
+
def cmd_import(self, a):
|
|
506
|
+
path = a["path"]
|
|
507
|
+
fmt = a.get("format") or os.path.splitext(path)[1].lower().lstrip(".")
|
|
508
|
+
if fmt == "obj":
|
|
509
|
+
try:
|
|
510
|
+
bpy.ops.wm.obj_import(filepath=path)
|
|
511
|
+
except (AttributeError, TypeError):
|
|
512
|
+
bpy.ops.import_scene.obj(filepath=path)
|
|
513
|
+
elif fmt == "stl":
|
|
514
|
+
try:
|
|
515
|
+
bpy.ops.wm.stl_import(filepath=path)
|
|
516
|
+
except (AttributeError, TypeError):
|
|
517
|
+
bpy.ops.import_mesh.stl(filepath=path)
|
|
518
|
+
elif fmt == "fbx":
|
|
519
|
+
bpy.ops.import_scene.fbx(filepath=path)
|
|
520
|
+
elif fmt in ("gltf", "glb"):
|
|
521
|
+
bpy.ops.import_scene.gltf(filepath=path)
|
|
522
|
+
else:
|
|
523
|
+
raise ValueError("unsupported import format: %s" % fmt)
|
|
524
|
+
return {"ok": True, "imported": [o.name for o in bpy.context.selected_objects]}
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
# --------------------------------------------------------------------------- #
|
|
528
|
+
# script / operator / document
|
|
529
|
+
# --------------------------------------------------------------------------- #
|
|
530
|
+
def cmd_script(self, a):
|
|
531
|
+
code = a["code"]
|
|
532
|
+
g = {"bpy": bpy, "bmesh": bmesh, "math": math,
|
|
533
|
+
"Vector": Vector, "Euler": Euler, "Matrix": Matrix,
|
|
534
|
+
"result": None}
|
|
535
|
+
exec(compile(code, "<bridge_script>", "exec"), g)
|
|
536
|
+
res = g.get("result")
|
|
537
|
+
try:
|
|
538
|
+
json.dumps(res)
|
|
539
|
+
except TypeError:
|
|
540
|
+
res = str(res)
|
|
541
|
+
return {"ok": True, "result": res}
|
|
542
|
+
|
|
543
|
+
def cmd_command(self, a):
|
|
544
|
+
op_path = a["op"]
|
|
545
|
+
params = a.get("params", {}) or {}
|
|
546
|
+
mod, _, fn = op_path.partition(".")
|
|
547
|
+
op = getattr(getattr(bpy.ops, mod), fn)
|
|
548
|
+
ret = op(**params)
|
|
549
|
+
return {"ok": True, "op": op_path, "return": str(ret)}
|
|
550
|
+
|
|
551
|
+
def cmd_document(self, a):
|
|
552
|
+
action = a.get("action", "info")
|
|
553
|
+
if action == "info":
|
|
554
|
+
return {"ok": True, "name": bpy.data.filepath or "untitled",
|
|
555
|
+
"blender_version": list(BL_VERSION),
|
|
556
|
+
"objects": len(bpy.data.objects)}
|
|
557
|
+
if action == "new":
|
|
558
|
+
bpy.ops.wm.read_homefile(use_empty=True)
|
|
559
|
+
return {"ok": True, "action": "new"}
|
|
560
|
+
if action == "save":
|
|
561
|
+
path = a.get("path") or bpy.data.filepath
|
|
562
|
+
if not path:
|
|
563
|
+
path = os.path.join(self.tmp_dir, "aib_scene.blend")
|
|
564
|
+
bpy.ops.wm.save_as_mainfile(filepath=path)
|
|
565
|
+
return {"ok": True, "file_path": path}
|
|
566
|
+
if action == "open":
|
|
567
|
+
bpy.ops.wm.open_mainfile(filepath=a["path"])
|
|
568
|
+
return {"ok": True, "action": "open"}
|
|
569
|
+
raise ValueError("bad document action: %s" % action)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
# --------------------------------------------------------------------------- #
|
|
573
|
+
# bmesh edit ops
|
|
574
|
+
# --------------------------------------------------------------------------- #
|
|
575
|
+
def cmd_mesh_edit(self, a):
|
|
576
|
+
obj = _obj(a["name"])
|
|
577
|
+
action = a["action"]
|
|
578
|
+
_ensure_active(obj)
|
|
579
|
+
bpy.ops.object.mode_set(mode='EDIT')
|
|
580
|
+
bm = bmesh.from_edit_mesh(obj.data)
|
|
581
|
+
try:
|
|
582
|
+
if action == "subdivide":
|
|
583
|
+
bmesh.ops.subdivide_edges(bm, edges=bm.edges[:],
|
|
584
|
+
cuts=int(a.get("cuts", 1)),
|
|
585
|
+
use_grid_fill=True)
|
|
586
|
+
elif action == "extrude":
|
|
587
|
+
geom = bmesh.ops.extrude_face_region(bm, geom=bm.faces[:])
|
|
588
|
+
if "offset" in a:
|
|
589
|
+
off = _vec(a["offset"])
|
|
590
|
+
for v in geom["geom"]:
|
|
591
|
+
if isinstance(v, bmesh.types.BMVert):
|
|
592
|
+
v.co += off
|
|
593
|
+
elif action == "inset":
|
|
594
|
+
bmesh.ops.inset_individual(bm, faces=bm.faces[:],
|
|
595
|
+
thickness=float(a.get("thickness", 0.1)))
|
|
596
|
+
elif action == "recalc_normals":
|
|
597
|
+
bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:])
|
|
598
|
+
else:
|
|
599
|
+
raise ValueError("bad mesh_edit action: %s" % action)
|
|
600
|
+
finally:
|
|
601
|
+
bmesh.update_edit_mesh(obj.data)
|
|
602
|
+
bpy.ops.object.mode_set(mode='OBJECT')
|
|
603
|
+
return {"ok": True, "name": obj.name,
|
|
604
|
+
"vertices": len(obj.data.vertices),
|
|
605
|
+
"polygons": len(obj.data.polygons)}
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _vec4(v, default=(0.8, 0.8, 0.8, 1.0)):
|
|
609
|
+
if v is None:
|
|
610
|
+
return default
|
|
611
|
+
if len(v) == 3:
|
|
612
|
+
return (float(v[0]), float(v[1]), float(v[2]), 1.0)
|
|
613
|
+
return tuple(float(x) for x in v)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
# Module-level singleton used by the HTTP server.
|
|
617
|
+
HANDLER = BlenderCommandHandler()
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
3
|
+
"""Headless launcher: start the AI bridge add-on inside background Blender.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
blender --background --python /workspace/blender_ai_bridge/headless_start.py
|
|
7
|
+
|
|
8
|
+
Optionally pre-load a .blend and set the port:
|
|
9
|
+
AIB_PORT=13080 blender --background myscene.blend --python headless_start.py
|
|
10
|
+
|
|
11
|
+
The script registers the add-on package, starts the HTTP server, and keeps the
|
|
12
|
+
process alive by polling the command queue on the main thread (background
|
|
13
|
+
Blender has no event loop pumping timers on its own after the startup script).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
import bpy
|
|
20
|
+
|
|
21
|
+
# Make the add-on importable when this file is run directly.
|
|
22
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
23
|
+
PARENT = os.path.dirname(HERE)
|
|
24
|
+
if PARENT not in sys.path:
|
|
25
|
+
sys.path.insert(0, PARENT)
|
|
26
|
+
|
|
27
|
+
import blender_ai_bridge # noqa: E402
|
|
28
|
+
from blender_ai_bridge import server as _server # noqa: E402
|
|
29
|
+
|
|
30
|
+
PORT = int(os.environ.get("AIB_PORT", "13080"))
|
|
31
|
+
HOST = os.environ.get("AIB_HOST", "127.0.0.1")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def start():
|
|
35
|
+
try:
|
|
36
|
+
blender_ai_bridge.register()
|
|
37
|
+
except Exception:
|
|
38
|
+
# Already registered or partial; ignore.
|
|
39
|
+
pass
|
|
40
|
+
srv = _server.BridgeHTTPServer(host=HOST, port=PORT)
|
|
41
|
+
srv.start()
|
|
42
|
+
_server.ensure_timer()
|
|
43
|
+
print("[AIB] bridge listening on http://%s:%d (blender %s)" % (
|
|
44
|
+
HOST, PORT, ".".join(str(v) for v in bpy.app.version)), flush=True)
|
|
45
|
+
return srv
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def keep_alive(srv):
|
|
49
|
+
# Drain the command queue on the main thread and sleep between ticks.
|
|
50
|
+
# bpy.app.timers may not pump in --background after the script returns, so
|
|
51
|
+
# we poll explicitly.
|
|
52
|
+
try:
|
|
53
|
+
while srv.running:
|
|
54
|
+
_server.drain_once()
|
|
55
|
+
time.sleep(0.01)
|
|
56
|
+
except KeyboardInterrupt:
|
|
57
|
+
srv.stop()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
if __name__ == "__main__":
|
|
61
|
+
srv = start()
|
|
62
|
+
keep_alive(srv)
|