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,575 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
3
|
+
"""Headless 3D-modeling bridge — pure Python, no Blender required.
|
|
4
|
+
|
|
5
|
+
Implements the SAME JSON command API as the `blender_ai_bridge` Blender add-on,
|
|
6
|
+
so the DeepSeek harness driver talks to either runtime identically. Uses
|
|
7
|
+
`trimesh` + `manifold3d` for real CSG booleans and STL/OBJ/PLY/GLTF export.
|
|
8
|
+
|
|
9
|
+
Run:
|
|
10
|
+
python3 /workspace/blender_harness/headless_bridge.py --port 13080
|
|
11
|
+
GET http://127.0.0.1:13080/health
|
|
12
|
+
|
|
13
|
+
Protocol (identical to the Blender add-on):
|
|
14
|
+
POST / {"id":1,"command":"create_shape","args":{"type":"box","dx":2}}
|
|
15
|
+
-> {"id":1,"result":{"ok":true,...}} | {"id":1,"error":"..."}
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import base64
|
|
20
|
+
import json
|
|
21
|
+
import math
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
import tempfile
|
|
25
|
+
import threading
|
|
26
|
+
import traceback
|
|
27
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
28
|
+
|
|
29
|
+
import numpy as np
|
|
30
|
+
import trimesh
|
|
31
|
+
import trimesh.transformations as tf
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
import manifold3d # noqa: F401
|
|
35
|
+
_HAVE_MANIFOLD = True
|
|
36
|
+
except Exception:
|
|
37
|
+
_HAVE_MANIFOLD = False
|
|
38
|
+
|
|
39
|
+
# Prefer the manifold backend for booleans when available.
|
|
40
|
+
_BOOL_ENGINE = "manifold" if _HAVE_MANIFOLD else None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# --------------------------------------------------------------------------- #
|
|
44
|
+
# scene model
|
|
45
|
+
# --------------------------------------------------------------------------- #
|
|
46
|
+
class Object:
|
|
47
|
+
def __init__(self, name, mesh, kind="mesh"):
|
|
48
|
+
self.name = name
|
|
49
|
+
self.mesh = mesh # trimesh.Trimesh in world space (transforms baked)
|
|
50
|
+
self.kind = kind
|
|
51
|
+
self.material = None # dict of material props
|
|
52
|
+
self.selected = False
|
|
53
|
+
|
|
54
|
+
def summary(self):
|
|
55
|
+
d = {"name": self.name, "type": self.kind,
|
|
56
|
+
"location": list(self.mesh.centroid.astype(float)) if self.mesh is not None else [0, 0, 0],
|
|
57
|
+
"vertices": int(len(self.mesh.vertices)) if self.mesh is not None else 0,
|
|
58
|
+
"polygons": int(len(self.mesh.faces)) if self.mesh is not None else 0,
|
|
59
|
+
"scale": [1.0, 1.0, 1.0],
|
|
60
|
+
"rotation": [0.0, 0.0, 0.0]}
|
|
61
|
+
if self.material:
|
|
62
|
+
d["material"] = self.material
|
|
63
|
+
return d
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Scene:
|
|
67
|
+
def __init__(self):
|
|
68
|
+
self.objects = {} # name -> Object
|
|
69
|
+
self._counter = 0
|
|
70
|
+
self.tmp_dir = tempfile.gettempdir()
|
|
71
|
+
self.active = None
|
|
72
|
+
|
|
73
|
+
def _unique(self, base):
|
|
74
|
+
self._counter += 1
|
|
75
|
+
return "%s.%03d" % (base, self._counter)
|
|
76
|
+
|
|
77
|
+
def add(self, name, mesh, kind="mesh"):
|
|
78
|
+
if name is None or name in self.objects:
|
|
79
|
+
name = self._unique(name or kind)
|
|
80
|
+
obj = Object(name, mesh, kind)
|
|
81
|
+
self.objects[name] = obj
|
|
82
|
+
self.active = name
|
|
83
|
+
return obj
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# --------------------------------------------------------------------------- #
|
|
87
|
+
# command handler
|
|
88
|
+
# --------------------------------------------------------------------------- #
|
|
89
|
+
class HeadlessHandler:
|
|
90
|
+
def __init__(self):
|
|
91
|
+
self.scene = Scene()
|
|
92
|
+
|
|
93
|
+
def dispatch(self, payload):
|
|
94
|
+
cmd = payload.get("command")
|
|
95
|
+
args = payload.get("args") or {k: v for k, v in payload.items()
|
|
96
|
+
if k not in ("command", "args", "id")}
|
|
97
|
+
fn = getattr(self, "cmd_" + cmd, None) if cmd else None
|
|
98
|
+
if fn is None:
|
|
99
|
+
return {"ok": False, "error": "unknown command: %r" % cmd}
|
|
100
|
+
try:
|
|
101
|
+
res = fn(args)
|
|
102
|
+
if res is None:
|
|
103
|
+
res = {"ok": True}
|
|
104
|
+
if isinstance(res, dict) and "ok" not in res:
|
|
105
|
+
res = dict(res, ok=True)
|
|
106
|
+
return res
|
|
107
|
+
except Exception as e:
|
|
108
|
+
return {"ok": False, "error": str(e),
|
|
109
|
+
"trace": traceback.format_exc(limit=6)}
|
|
110
|
+
|
|
111
|
+
# ---- helpers ----
|
|
112
|
+
def _vec(self, v, d=(0.0, 0.0, 0.0)):
|
|
113
|
+
if v is None:
|
|
114
|
+
return np.array(d, dtype=float)
|
|
115
|
+
return np.array([float(v[0]), float(v[1]), float(v[2])], dtype=float)
|
|
116
|
+
|
|
117
|
+
def _obj(self, name):
|
|
118
|
+
o = self.scene.objects.get(name)
|
|
119
|
+
if o is None:
|
|
120
|
+
raise KeyError("object not found: %r" % name)
|
|
121
|
+
return o
|
|
122
|
+
|
|
123
|
+
def _apply_transform(self, mesh, location=None, rotation=None, scale=None,
|
|
124
|
+
rotation_degrees=False):
|
|
125
|
+
if scale is not None:
|
|
126
|
+
s = self._vec(scale, (1, 1, 1))
|
|
127
|
+
mesh.apply_transform(tf.scale_matrix(s[0], [1, 0, 0]) @
|
|
128
|
+
tf.scale_matrix(s[1], [0, 1, 0]) @
|
|
129
|
+
tf.scale_matrix(s[2], [0, 0, 1]))
|
|
130
|
+
if rotation is not None:
|
|
131
|
+
r = [float(x) for x in rotation]
|
|
132
|
+
if rotation_degrees:
|
|
133
|
+
r = [math.radians(x) for x in r]
|
|
134
|
+
mat = (tf.rotation_matrix(r[0], [1, 0, 0]) @
|
|
135
|
+
tf.rotation_matrix(r[1], [0, 1, 0]) @
|
|
136
|
+
tf.rotation_matrix(r[2], [0, 0, 1]))
|
|
137
|
+
mesh.apply_transform(mat)
|
|
138
|
+
if location is not None:
|
|
139
|
+
mesh.apply_translation(self._vec(location))
|
|
140
|
+
return mesh
|
|
141
|
+
|
|
142
|
+
# ---- primitives ----
|
|
143
|
+
def cmd_create_shape(self, a):
|
|
144
|
+
t = a.get("type", "box")
|
|
145
|
+
if t == "box":
|
|
146
|
+
s = a.get("size")
|
|
147
|
+
if s is None:
|
|
148
|
+
dx, dy, dz = a.get("dx", 1), a.get("dy", 1), a.get("dz", 1)
|
|
149
|
+
elif isinstance(s, (list, tuple)):
|
|
150
|
+
dx, dy, dz = float(s[0]), float(s[1]), float(s[2])
|
|
151
|
+
else:
|
|
152
|
+
dx = dy = dz = float(s)
|
|
153
|
+
mesh = trimesh.creation.box(extents=(dx, dy, dz))
|
|
154
|
+
elif t == "sphere":
|
|
155
|
+
mesh = trimesh.creation.uv_sphere(
|
|
156
|
+
radius=a.get("radius", 1.0),
|
|
157
|
+
count=(a.get("segments", 32), a.get("rings", 16)))
|
|
158
|
+
elif t == "ico_sphere":
|
|
159
|
+
mesh = trimesh.creation.icosphere(
|
|
160
|
+
radius=a.get("radius", 1.0),
|
|
161
|
+
subdivisions=a.get("subdivisions", 2))
|
|
162
|
+
elif t == "cylinder":
|
|
163
|
+
mesh = trimesh.creation.cylinder(
|
|
164
|
+
radius=a.get("radius", 1.0),
|
|
165
|
+
height=a.get("height", a.get("depth", 2.0)),
|
|
166
|
+
sections=a.get("vertices", 32))
|
|
167
|
+
elif t == "cone":
|
|
168
|
+
mesh = trimesh.creation.cone(
|
|
169
|
+
radius=a.get("radius", 1.0),
|
|
170
|
+
height=a.get("height", 2.0),
|
|
171
|
+
sections=a.get("vertices", 32))
|
|
172
|
+
elif t == "torus":
|
|
173
|
+
mesh = trimesh.creation.torus(
|
|
174
|
+
major_radius=a.get("major_radius", 1.0),
|
|
175
|
+
minor_radius=a.get("minor_radius", 0.25),
|
|
176
|
+
major_sections=a.get("major_segments", 48),
|
|
177
|
+
minor_sections=a.get("minor_segments", 16))
|
|
178
|
+
elif t == "plane":
|
|
179
|
+
s = a.get("size", 2.0)
|
|
180
|
+
mesh = trimesh.creation.box(extents=(s, s, 0.0))
|
|
181
|
+
# collapse to a single quad plane
|
|
182
|
+
mesh = trimesh.Trimesh(
|
|
183
|
+
vertices=np.array([[-s/2, -s/2, 0], [s/2, -s/2, 0],
|
|
184
|
+
[s/2, s/2, 0], [-s/2, s/2, 0]],
|
|
185
|
+
dtype=float),
|
|
186
|
+
faces=np.array([[0, 1, 2], [0, 2, 3]]))
|
|
187
|
+
elif t == "grid":
|
|
188
|
+
s = a.get("size", 2.0)
|
|
189
|
+
nx = a.get("x_subdivisions", 10)
|
|
190
|
+
ny = a.get("y_subdivisions", 10)
|
|
191
|
+
xs = np.linspace(-s/2, s/2, nx + 1)
|
|
192
|
+
ys = np.linspace(-s/2, s/2, ny + 1)
|
|
193
|
+
gv, gf = [], []
|
|
194
|
+
for i, x in enumerate(xs):
|
|
195
|
+
for j, y in enumerate(ys):
|
|
196
|
+
gv.append([x, y, 0.0])
|
|
197
|
+
idx = lambda i, j: i * (ny + 1) + j
|
|
198
|
+
for i in range(nx):
|
|
199
|
+
for j in range(ny):
|
|
200
|
+
a0, b0 = idx(i, j), idx(i + 1, j)
|
|
201
|
+
c0, d0 = idx(i + 1, j + 1), idx(i, j + 1)
|
|
202
|
+
gf.append([a0, b0, c0]); gf.append([a0, c0, d0])
|
|
203
|
+
mesh = trimesh.Trimesh(vertices=np.array(gv, dtype=float),
|
|
204
|
+
faces=np.array(gf))
|
|
205
|
+
elif t == "circle":
|
|
206
|
+
r = a.get("radius", 1.0)
|
|
207
|
+
n = a.get("vertices", 32)
|
|
208
|
+
ang = np.linspace(0, 2 * math.pi, n, endpoint=False)
|
|
209
|
+
verts = np.column_stack([r * np.cos(ang), r * np.sin(ang),
|
|
210
|
+
np.zeros(n)])
|
|
211
|
+
verts = np.vstack([verts, [0, 0, 0]])
|
|
212
|
+
faces = [[n, i, (i + 1) % n] for i in range(n)]
|
|
213
|
+
mesh = trimesh.Trimesh(vertices=verts, faces=faces)
|
|
214
|
+
elif t == "monkey":
|
|
215
|
+
raise ValueError("Suzanne not available headless; use Blender add-on")
|
|
216
|
+
else:
|
|
217
|
+
raise ValueError("unknown shape type: %r" % t)
|
|
218
|
+
|
|
219
|
+
self._apply_transform(mesh, a.get("location"), a.get("rotation"),
|
|
220
|
+
a.get("scale"), a.get("rotation_degrees", False))
|
|
221
|
+
obj = self.scene.add(a.get("name"), mesh, kind=t)
|
|
222
|
+
return {"ok": True, "name": obj.name, "type": t,
|
|
223
|
+
"location": list(mesh.centroid.astype(float)),
|
|
224
|
+
"vertices": int(len(mesh.vertices))}
|
|
225
|
+
|
|
226
|
+
# ---- transforms ----
|
|
227
|
+
def cmd_transform(self, a):
|
|
228
|
+
o = self._obj(a["name"])
|
|
229
|
+
if "location" in a:
|
|
230
|
+
o.mesh.apply_translation(self._vec(a["location"]) - o.mesh.centroid)
|
|
231
|
+
if "scale" in a:
|
|
232
|
+
s = self._vec(a["scale"], (1, 1, 1))
|
|
233
|
+
o.mesh.apply_transform(
|
|
234
|
+
tf.scale_matrix(s[0], [1, 0, 0]) @
|
|
235
|
+
tf.scale_matrix(s[1], [0, 1, 0]) @
|
|
236
|
+
tf.scale_matrix(s[2], [0, 0, 1]))
|
|
237
|
+
if "rotation" in a:
|
|
238
|
+
r = [float(x) for x in a["rotation"]]
|
|
239
|
+
if a.get("rotation_degrees"):
|
|
240
|
+
r = [math.radians(x) for x in r]
|
|
241
|
+
mat = (tf.rotation_matrix(r[0], [1, 0, 0]) @
|
|
242
|
+
tf.rotation_matrix(r[1], [0, 1, 0]) @
|
|
243
|
+
tf.rotation_matrix(r[2], [0, 0, 1]))
|
|
244
|
+
o.mesh.apply_transform(mat)
|
|
245
|
+
return {"ok": True, "name": o.name,
|
|
246
|
+
"location": list(o.mesh.centroid.astype(float))}
|
|
247
|
+
|
|
248
|
+
def cmd_duplicate(self, a):
|
|
249
|
+
o = self._obj(a["name"])
|
|
250
|
+
count = int(a.get("count", 1))
|
|
251
|
+
off = self._vec(a.get("offset", (0, 0, 0)))
|
|
252
|
+
base = a.get("new_name") or a.get("as") or o.name
|
|
253
|
+
created = []
|
|
254
|
+
for i in range(count):
|
|
255
|
+
m = o.mesh.copy()
|
|
256
|
+
m.apply_translation(off * (i + 1))
|
|
257
|
+
nm = base if count == 1 else "%s.%03d" % (base, i + 1)
|
|
258
|
+
no = self.scene.add(nm, m, kind=o.kind)
|
|
259
|
+
no.material = o.material
|
|
260
|
+
created.append(no.name)
|
|
261
|
+
return {"ok": True, "created": created}
|
|
262
|
+
|
|
263
|
+
def cmd_delete(self, a):
|
|
264
|
+
names = a.get("names") or [a["name"]]
|
|
265
|
+
removed = [n for n in names if n in self.scene.objects]
|
|
266
|
+
for n in removed:
|
|
267
|
+
del self.scene.objects[n]
|
|
268
|
+
return {"ok": True, "removed": removed}
|
|
269
|
+
|
|
270
|
+
def cmd_rename(self, a):
|
|
271
|
+
o = self._obj(a["name"])
|
|
272
|
+
old = o.name
|
|
273
|
+
del self.scene.objects[old]
|
|
274
|
+
o.name = a["new_name"]
|
|
275
|
+
self.scene.objects[o.name] = o
|
|
276
|
+
return {"ok": True, "name": o.name}
|
|
277
|
+
|
|
278
|
+
# ---- boolean ----
|
|
279
|
+
def cmd_boolean(self, a):
|
|
280
|
+
op = a.get("operation", "difference").lower()
|
|
281
|
+
target = self._obj(a["target"])
|
|
282
|
+
tools = [self._obj(n) for n in (a.get("tools") or
|
|
283
|
+
([a["tool"]] if "tool" in a else []))]
|
|
284
|
+
if not tools:
|
|
285
|
+
raise ValueError("boolean needs >=1 tool")
|
|
286
|
+
result = target.mesh
|
|
287
|
+
for tool in tools:
|
|
288
|
+
if op == "union":
|
|
289
|
+
result = trimesh.boolean.union([result, tool.mesh], engine=_BOOL_ENGINE)
|
|
290
|
+
elif op == "difference":
|
|
291
|
+
result = trimesh.boolean.difference([result, tool.mesh], engine=_BOOL_ENGINE)
|
|
292
|
+
elif op == "intersect":
|
|
293
|
+
result = trimesh.boolean.intersection([result, tool.mesh], engine=_BOOL_ENGINE)
|
|
294
|
+
else:
|
|
295
|
+
raise ValueError("bad operation: %s" % op)
|
|
296
|
+
if not isinstance(result, trimesh.Trimesh) or len(result.faces) == 0:
|
|
297
|
+
raise ValueError("boolean produced empty mesh")
|
|
298
|
+
name = a.get("name", target.name)
|
|
299
|
+
if a.get("remove_tools", False):
|
|
300
|
+
for tool in tools:
|
|
301
|
+
if tool.name in self.scene.objects and tool.name != target.name:
|
|
302
|
+
del self.scene.objects[tool.name]
|
|
303
|
+
if not a.get("remove_tools", False) and target.name in self.scene.objects and name == target.name:
|
|
304
|
+
target.mesh = result
|
|
305
|
+
return {"ok": True, "name": name, "operation": op,
|
|
306
|
+
"vertices": int(len(result.vertices))}
|
|
307
|
+
obj = self.scene.add(name, result, kind="boolean")
|
|
308
|
+
if a.get("remove_tools", False) and target.name in self.scene.objects and target.name != obj.name:
|
|
309
|
+
del self.scene.objects[target.name]
|
|
310
|
+
return {"ok": True, "name": obj.name, "operation": op,
|
|
311
|
+
"vertices": int(len(result.vertices))}
|
|
312
|
+
|
|
313
|
+
# ---- material ----
|
|
314
|
+
def cmd_material(self, a):
|
|
315
|
+
action = a.get("action", "assign")
|
|
316
|
+
if action == "list":
|
|
317
|
+
return {"ok": True, "materials": sorted({o.material["name"]
|
|
318
|
+
for o in self.scene.objects.values() if o.material})}
|
|
319
|
+
if action == "create":
|
|
320
|
+
mat = {"name": a.get("name", "Material"),
|
|
321
|
+
"color": a.get("color", [0.8, 0.8, 0.8, 1.0]),
|
|
322
|
+
"metallic": a.get("metallic", 0.0),
|
|
323
|
+
"roughness": a.get("roughness", 0.5)}
|
|
324
|
+
return {"ok": True, "name": mat["name"], "_mat": mat}
|
|
325
|
+
o = self._obj(a["name"])
|
|
326
|
+
o.material = {"name": a.get("material", "Material"),
|
|
327
|
+
"color": a.get("color", [0.8, 0.8, 0.8, 1.0]),
|
|
328
|
+
"metallic": a.get("metallic", 0.0),
|
|
329
|
+
"roughness": a.get("roughness", 0.5)}
|
|
330
|
+
return {"ok": True, "name": o.name, "material": o.material["name"]}
|
|
331
|
+
|
|
332
|
+
# ---- modifiers (subset) ----
|
|
333
|
+
def cmd_modifier(self, a):
|
|
334
|
+
action = a.get("action", "add")
|
|
335
|
+
o = self._obj(a["name"])
|
|
336
|
+
if action == "list":
|
|
337
|
+
return {"ok": True, "modifiers": []}
|
|
338
|
+
if action == "remove":
|
|
339
|
+
return {"ok": True}
|
|
340
|
+
mtype = a.get("type", "").lower()
|
|
341
|
+
p = a.get("params", {}) or {}
|
|
342
|
+
if mtype in ("subsurf", "subdivision", "subdivide"):
|
|
343
|
+
o.mesh = o.mesh.subdivide(iterations=int(p.get("levels", p.get("iterations", 1))))
|
|
344
|
+
elif mtype == "array":
|
|
345
|
+
axis = p.get("axis", "X").upper()
|
|
346
|
+
cnt = int(p.get("count", 2))
|
|
347
|
+
off = float(p.get("offset", 1.0))
|
|
348
|
+
ai = {"X": 0, "Y": 1, "Z": 2}[axis]
|
|
349
|
+
meshes = [o.mesh.copy()]
|
|
350
|
+
for i in range(1, cnt):
|
|
351
|
+
m = o.mesh.copy()
|
|
352
|
+
t = np.zeros(3); t[ai] = off * i
|
|
353
|
+
m.apply_translation(t)
|
|
354
|
+
meshes.append(m)
|
|
355
|
+
o.mesh = trimesh.util.concatenate(meshes)
|
|
356
|
+
elif mtype == "mirror":
|
|
357
|
+
axis = p.get("axis", "X").upper()
|
|
358
|
+
ai = {"X": 0, "Y": 1, "Z": 2}[axis]
|
|
359
|
+
m = o.mesh.copy()
|
|
360
|
+
s = np.eye(4); s[ai, ai] = -1
|
|
361
|
+
m.apply_transform(s)
|
|
362
|
+
o.mesh = trimesh.util.concatenate([o.mesh, m])
|
|
363
|
+
else:
|
|
364
|
+
# bevel/solidify not implemented headless; no-op with note.
|
|
365
|
+
return {"ok": True, "modifier": mtype,
|
|
366
|
+
"note": "modifier '%s' is a no-op in headless bridge" % mtype}
|
|
367
|
+
return {"ok": True, "modifier": mtype,
|
|
368
|
+
"vertices": int(len(o.mesh.vertices))}
|
|
369
|
+
|
|
370
|
+
# ---- mesh edit (subset) ----
|
|
371
|
+
def cmd_mesh_edit(self, a):
|
|
372
|
+
o = self._obj(a["name"])
|
|
373
|
+
action = a["action"]
|
|
374
|
+
if action == "subdivide":
|
|
375
|
+
o.mesh = o.mesh.subdivide(iterations=int(a.get("cuts", 1)))
|
|
376
|
+
elif action == "recalc_normals":
|
|
377
|
+
o.mesh.fix_normals()
|
|
378
|
+
elif action in ("extrude", "inset"):
|
|
379
|
+
return {"ok": True, "note": "%s not implemented headless" % action}
|
|
380
|
+
return {"ok": True, "name": o.name,
|
|
381
|
+
"vertices": int(len(o.mesh.vertices)),
|
|
382
|
+
"polygons": int(len(o.mesh.faces))}
|
|
383
|
+
|
|
384
|
+
# ---- scene / selection ----
|
|
385
|
+
def cmd_scene(self, a):
|
|
386
|
+
return {"ok": True,
|
|
387
|
+
"objects": [o.summary() for o in self.scene.objects.values()],
|
|
388
|
+
"object_count": len(self.scene.objects),
|
|
389
|
+
"blender_version": [0, 0, 0],
|
|
390
|
+
"runtime": "headless-python",
|
|
391
|
+
"csg_backend": "manifold3d" if _HAVE_MANIFOLD else "none"}
|
|
392
|
+
|
|
393
|
+
def cmd_selection(self, a):
|
|
394
|
+
action = a.get("action", "list")
|
|
395
|
+
if action == "list":
|
|
396
|
+
return {"ok": True, "selected": [n for n, o in self.scene.objects.items() if o.selected]}
|
|
397
|
+
if action == "clear":
|
|
398
|
+
for o in self.scene.objects.values():
|
|
399
|
+
o.selected = False
|
|
400
|
+
return {"ok": True}
|
|
401
|
+
if action == "set":
|
|
402
|
+
for o in self.scene.objects.values():
|
|
403
|
+
o.selected = o.name in (a.get("names") or [])
|
|
404
|
+
return {"ok": True, "selected": a.get("names", [])}
|
|
405
|
+
raise ValueError("bad selection action")
|
|
406
|
+
|
|
407
|
+
def cmd_scene_clear(self, a):
|
|
408
|
+
n = len(self.scene.objects)
|
|
409
|
+
self.scene.objects.clear()
|
|
410
|
+
return {"ok": True, "removed": n}
|
|
411
|
+
|
|
412
|
+
# ---- camera / light / render (no renderer headless) ----
|
|
413
|
+
def cmd_camera(self, a):
|
|
414
|
+
return {"ok": True, "note": "headless bridge has no renderer; use export"}
|
|
415
|
+
|
|
416
|
+
def cmd_light(self, a):
|
|
417
|
+
return {"ok": True, "note": "headless bridge has no renderer; use export"}
|
|
418
|
+
|
|
419
|
+
def cmd_render(self, a):
|
|
420
|
+
return {"ok": True, "note": "headless bridge has no renderer; use export to STL/OBJ/PLY/GLTF"}
|
|
421
|
+
|
|
422
|
+
# ---- export / import ----
|
|
423
|
+
def cmd_export(self, a):
|
|
424
|
+
fmt = a.get("format", "stl").lower().lstrip(".")
|
|
425
|
+
path = a.get("path") or os.path.join(self.scene.tmp_dir, "aib_export.%s" % fmt)
|
|
426
|
+
names = a.get("names")
|
|
427
|
+
meshes = []
|
|
428
|
+
if names:
|
|
429
|
+
meshes = [self._obj(n).mesh for n in names]
|
|
430
|
+
else:
|
|
431
|
+
meshes = [o.mesh for o in self.scene.objects.values() if o.mesh is not None]
|
|
432
|
+
if not meshes:
|
|
433
|
+
raise ValueError("no meshes to export")
|
|
434
|
+
scene = trimesh.Scene(meshes) if len(meshes) > 1 else meshes[0]
|
|
435
|
+
if fmt in ("gltf", "glb"):
|
|
436
|
+
ext = "glb" if fmt == "glb" else "gltf"
|
|
437
|
+
path = os.path.splitext(path)[0] + "." + ext
|
|
438
|
+
data = scene.export(file_type=ext)
|
|
439
|
+
elif fmt == "fbx":
|
|
440
|
+
raise ValueError("FBX export not supported headless; use STL/OBJ/PLY/GLTF")
|
|
441
|
+
else:
|
|
442
|
+
data = scene.export(file_type=fmt)
|
|
443
|
+
if isinstance(data, (bytes, bytearray)):
|
|
444
|
+
with open(path, "wb") as fh:
|
|
445
|
+
fh.write(data)
|
|
446
|
+
else:
|
|
447
|
+
with open(path, "w") as fh:
|
|
448
|
+
fh.write(data)
|
|
449
|
+
result = {"ok": True, "file_path": path, "format": fmt,
|
|
450
|
+
"size": os.path.getsize(path),
|
|
451
|
+
"objects": len(meshes)}
|
|
452
|
+
if a.get("return_data"):
|
|
453
|
+
cap = int(a.get("max_inline_bytes", 2 * 1024 * 1024))
|
|
454
|
+
if os.path.getsize(path) <= cap:
|
|
455
|
+
with open(path, "rb") as fh:
|
|
456
|
+
result["data_b64"] = base64.b64encode(fh.read()).decode("ascii")
|
|
457
|
+
return result
|
|
458
|
+
|
|
459
|
+
def cmd_import(self, a):
|
|
460
|
+
path = a["path"]
|
|
461
|
+
mesh = trimesh.load(path, force="mesh")
|
|
462
|
+
obj = self.scene.add(a.get("name") or os.path.splitext(os.path.basename(path))[0], mesh)
|
|
463
|
+
return {"ok": True, "imported": [obj.name],
|
|
464
|
+
"vertices": int(len(mesh.vertices))}
|
|
465
|
+
|
|
466
|
+
# ---- script ----
|
|
467
|
+
def cmd_script(self, a):
|
|
468
|
+
code = a["code"]
|
|
469
|
+
g = {"scene": self.scene, "trimesh": trimesh, "np": np, "numpy": np,
|
|
470
|
+
"math": math, "result": None,
|
|
471
|
+
"objects": self.scene.objects}
|
|
472
|
+
exec(compile(code, "<bridge_script>", "exec"), g)
|
|
473
|
+
res = g.get("result")
|
|
474
|
+
try:
|
|
475
|
+
json.dumps(res)
|
|
476
|
+
except TypeError:
|
|
477
|
+
res = str(res)
|
|
478
|
+
return {"ok": True, "result": res}
|
|
479
|
+
|
|
480
|
+
def cmd_command(self, a):
|
|
481
|
+
return {"ok": False, "error": "raw bpy operators only available in the Blender add-on"}
|
|
482
|
+
|
|
483
|
+
def cmd_document(self, a):
|
|
484
|
+
action = a.get("action", "info")
|
|
485
|
+
if action == "info":
|
|
486
|
+
return {"ok": True, "name": "headless", "runtime": "headless-python",
|
|
487
|
+
"objects": len(self.scene.objects),
|
|
488
|
+
"csg_backend": "manifold3d" if _HAVE_MANIFOLD else "none"}
|
|
489
|
+
if action == "new":
|
|
490
|
+
self.scene.objects.clear()
|
|
491
|
+
return {"ok": True, "action": "new"}
|
|
492
|
+
raise ValueError("bad document action: %s" % action)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
HANDLER = HeadlessHandler()
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
# --------------------------------------------------------------------------- #
|
|
499
|
+
# HTTP server
|
|
500
|
+
# --------------------------------------------------------------------------- #
|
|
501
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
502
|
+
def log_message(self, *args):
|
|
503
|
+
pass
|
|
504
|
+
|
|
505
|
+
def _cors(self):
|
|
506
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
507
|
+
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
508
|
+
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
|
509
|
+
|
|
510
|
+
def _send(self, code, obj):
|
|
511
|
+
body = json.dumps(obj, default=_json_default).encode("utf-8")
|
|
512
|
+
self.send_response(code)
|
|
513
|
+
self.send_header("Content-Type", "application/json")
|
|
514
|
+
self.send_header("Content-Length", str(len(body)))
|
|
515
|
+
self._cors()
|
|
516
|
+
self.end_headers()
|
|
517
|
+
self.wfile.write(body)
|
|
518
|
+
|
|
519
|
+
def do_OPTIONS(self):
|
|
520
|
+
self.send_response(204); self._cors(); self.end_headers()
|
|
521
|
+
|
|
522
|
+
def do_GET(self):
|
|
523
|
+
if self.path == "/health":
|
|
524
|
+
self._send(200, {"ok": True, "service": "blender-ai-bridge",
|
|
525
|
+
"runtime": "headless-python",
|
|
526
|
+
"csg_backend": "manifold3d" if _HAVE_MANIFOLD else "none"})
|
|
527
|
+
elif self.path == "/scene":
|
|
528
|
+
self._send(200, {"id": 0, "result": HANDLER.cmd_scene({})})
|
|
529
|
+
elif self.path in ("/", "/info"):
|
|
530
|
+
self._send(200, {"service": "blender-ai-bridge",
|
|
531
|
+
"runtime": "headless-python",
|
|
532
|
+
"endpoints": ["/", "/health", "/scene", "POST /"]})
|
|
533
|
+
else:
|
|
534
|
+
self._send(404, {"error": "not found"})
|
|
535
|
+
|
|
536
|
+
def do_POST(self):
|
|
537
|
+
length = int(self.headers.get("Content-Length", 0) or 0)
|
|
538
|
+
if length <= 0 or length > 64 * 1024 * 1024:
|
|
539
|
+
self._send(400, {"error": "bad content length"}); return
|
|
540
|
+
raw = self.rfile.read(length)
|
|
541
|
+
try:
|
|
542
|
+
payload = json.loads(raw)
|
|
543
|
+
except Exception as exc:
|
|
544
|
+
self._send(400, {"error": "invalid json: %s" % exc}); return
|
|
545
|
+
self._send(200, {"id": payload.get("id"),
|
|
546
|
+
"result": HANDLER.dispatch(payload)})
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _json_default(o):
|
|
550
|
+
if isinstance(o, (np.integer,)):
|
|
551
|
+
return int(o)
|
|
552
|
+
if isinstance(o, (np.floating,)):
|
|
553
|
+
return float(o)
|
|
554
|
+
if isinstance(o, np.ndarray):
|
|
555
|
+
return o.tolist()
|
|
556
|
+
return str(o)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def main():
|
|
560
|
+
ap = argparse.ArgumentParser()
|
|
561
|
+
ap.add_argument("--host", default="127.0.0.1")
|
|
562
|
+
ap.add_argument("--port", type=int, default=13080)
|
|
563
|
+
args = ap.parse_args()
|
|
564
|
+
httpd = ThreadingHTTPServer((args.host, args.port), _Handler)
|
|
565
|
+
httpd.daemon_threads = True
|
|
566
|
+
print("[AIB-headless] listening on http://%s:%d (csg=%s)" % (
|
|
567
|
+
args.host, args.port, "manifold3d" if _HAVE_MANIFOLD else "none"), flush=True)
|
|
568
|
+
try:
|
|
569
|
+
httpd.serve_forever()
|
|
570
|
+
except KeyboardInterrupt:
|
|
571
|
+
httpd.shutdown()
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
if __name__ == "__main__":
|
|
575
|
+
main()
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-plugin-blender-3d",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DeepSeek Harness plugin: drive Blender 3D modeling (primitives, boolean CSG, transform, material, modifier, scene, export STL/OBJ/PLY/GLTF) via a JSON bridge. Bundles a headless trimesh+manifold3d runtime and a Blender bpy add-on. · DSH 插件:通过 JSON 桥接驱动 Blender 3D 建模。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"default": "./lib/index.js"
|
|
10
|
+
},
|
|
11
|
+
"./bridge-client": {
|
|
12
|
+
"default": "./lib/bridge-client.js"
|
|
13
|
+
},
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"lib/index.js",
|
|
18
|
+
"lib/bridge-client.js",
|
|
19
|
+
"lib/runtime/**/*",
|
|
20
|
+
"skill/**/*",
|
|
21
|
+
"cordis.patch.yml",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"keywords": [
|
|
25
|
+
"deepseek",
|
|
26
|
+
"harness",
|
|
27
|
+
"dsh",
|
|
28
|
+
"dsh-plugin",
|
|
29
|
+
"blender",
|
|
30
|
+
"3d",
|
|
31
|
+
"modeling",
|
|
32
|
+
"csg",
|
|
33
|
+
"trimesh",
|
|
34
|
+
"manifold"
|
|
35
|
+
],
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"@deepseek-ai/cordis": "^4.0.0"
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=18"
|
|
45
|
+
},
|
|
46
|
+
"dsh": {
|
|
47
|
+
"bundle": {
|
|
48
|
+
"patch": "./cordis.patch.yml"
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"repository": {
|
|
52
|
+
"type": "git",
|
|
53
|
+
"url": "git+https://gitcode.com/LOOYIABC/blender.git",
|
|
54
|
+
"directory": "dsh-plugin-blender-3d"
|
|
55
|
+
},
|
|
56
|
+
"homepage": "https://gitcode.com/LOOYIABC/blender",
|
|
57
|
+
"scripts": {
|
|
58
|
+
"test": "node tests/smoke.mjs",
|
|
59
|
+
"example": "node examples/carve_block.mjs"
|
|
60
|
+
}
|
|
61
|
+
}
|