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,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()
@@ -0,0 +1,3 @@
1
+ numpy>=1.26
2
+ trimesh>=4.0
3
+ manifold3d>=2.2
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
+ }