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.
@@ -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)