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,205 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""HTTP bridge server for the Blender AI Bridge add-on.
|
|
3
|
+
|
|
4
|
+
Blender's bpy/bpy.ops API is **not thread-safe**: every operator and most data
|
|
5
|
+
access must happen on the main thread. The HTTP server therefore runs in a
|
|
6
|
+
background thread but never touches bpy directly. Each incoming request is
|
|
7
|
+
pushed onto a queue; a ``bpy.app.timers`` callback (registered on the main
|
|
8
|
+
thread when the server starts) drains the queue, runs the command through
|
|
9
|
+
``commands.HANDLER`` on the main thread, and fulfils a per-request event so the
|
|
10
|
+
HTTP thread can write the JSON response.
|
|
11
|
+
|
|
12
|
+
Protocol (mirrors the chili3d bridge):
|
|
13
|
+
POST / {"id": <int>, "command": "...", "args": {...}}
|
|
14
|
+
-> {"id": <int>, "result": {...}} | {"id": <int>, "error": "..."}
|
|
15
|
+
GET /health -> {"ok": true, "blender": [...]}
|
|
16
|
+
GET /scene -> runs the `scene` command and returns its result
|
|
17
|
+
GET / -> service info
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import threading
|
|
22
|
+
import queue
|
|
23
|
+
import time
|
|
24
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
25
|
+
|
|
26
|
+
import bpy
|
|
27
|
+
|
|
28
|
+
from . import commands
|
|
29
|
+
|
|
30
|
+
# Polling interval for the main-thread timer (seconds).
|
|
31
|
+
_TIMER_INTERVAL = 0.005
|
|
32
|
+
|
|
33
|
+
# Bounded queue so a flood of requests can't exhaust memory.
|
|
34
|
+
_pending = queue.Queue(maxsize=1024)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _Task:
|
|
38
|
+
__slots__ = ("payload", "event", "response")
|
|
39
|
+
|
|
40
|
+
def __init__(self, payload):
|
|
41
|
+
self.payload = payload
|
|
42
|
+
self.event = threading.Event()
|
|
43
|
+
self.response = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _drain_queue():
|
|
47
|
+
"""Main-thread timer callback: run all queued commands, then reschedule."""
|
|
48
|
+
drained = 0
|
|
49
|
+
while drained < 64: # cap work per tick so Blender stays responsive
|
|
50
|
+
try:
|
|
51
|
+
task = _pending.get_nowait()
|
|
52
|
+
except queue.Empty:
|
|
53
|
+
break
|
|
54
|
+
try:
|
|
55
|
+
result = commands.HANDLER.dispatch(task.payload)
|
|
56
|
+
task.response = {"id": task.payload.get("id"),
|
|
57
|
+
"result": result}
|
|
58
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
59
|
+
task.response = {"id": task.payload.get("id"),
|
|
60
|
+
"error": str(exc)}
|
|
61
|
+
task.event.set()
|
|
62
|
+
drained += 1
|
|
63
|
+
return _TIMER_INTERVAL # reschedule; return None to stop.
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def ensure_timer():
|
|
67
|
+
"""Register the main-thread drain timer (idempotent)."""
|
|
68
|
+
try:
|
|
69
|
+
if not bpy.app.timers.is_registered(_drain_queue):
|
|
70
|
+
bpy.app.timers.register(_drain_queue, first_interval=_TIMER_INTERVAL)
|
|
71
|
+
except Exception:
|
|
72
|
+
# In some headless contexts timers may not be available; the queue is
|
|
73
|
+
# still drained by `drain_once()` polled from the launcher instead.
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def drain_once(max_tasks=64):
|
|
78
|
+
"""Synchronous drain for headless launchers without a timer loop."""
|
|
79
|
+
drained = 0
|
|
80
|
+
while drained < max_tasks:
|
|
81
|
+
try:
|
|
82
|
+
task = _pending.get_nowait()
|
|
83
|
+
except queue.Empty:
|
|
84
|
+
return drained
|
|
85
|
+
try:
|
|
86
|
+
result = commands.HANDLER.dispatch(task.payload)
|
|
87
|
+
task.response = {"id": task.payload.get("id"), "result": result}
|
|
88
|
+
except Exception as exc:
|
|
89
|
+
task.response = {"id": task.payload.get("id"), "error": str(exc)}
|
|
90
|
+
task.event.set()
|
|
91
|
+
drained += 1
|
|
92
|
+
return drained
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class BridgeHTTPServer:
|
|
96
|
+
"""Owns the ThreadingHTTPServer lifecycle."""
|
|
97
|
+
|
|
98
|
+
def __init__(self, host="127.0.0.1", port=13080, timeout=60.0):
|
|
99
|
+
self.host = host
|
|
100
|
+
self.port = port
|
|
101
|
+
self.timeout = timeout
|
|
102
|
+
self._httpd = None
|
|
103
|
+
self._thread = None
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def running(self):
|
|
107
|
+
return self._httpd is not None
|
|
108
|
+
|
|
109
|
+
def start(self):
|
|
110
|
+
if self.running:
|
|
111
|
+
return
|
|
112
|
+
ensure_timer()
|
|
113
|
+
server = ThreadingHTTPServer((self.host, self.port), _Handler)
|
|
114
|
+
server.daemon_threads = True
|
|
115
|
+
server.timeout = 1
|
|
116
|
+
self._httpd = server
|
|
117
|
+
self._thread = threading.Thread(
|
|
118
|
+
target=server.serve_forever, name="AIB-HTTP", daemon=True)
|
|
119
|
+
self._thread.start()
|
|
120
|
+
|
|
121
|
+
def stop(self):
|
|
122
|
+
if not self.running:
|
|
123
|
+
return
|
|
124
|
+
self._httpd.shutdown()
|
|
125
|
+
self._httpd.server_close()
|
|
126
|
+
self._httpd = None
|
|
127
|
+
self._thread = None
|
|
128
|
+
|
|
129
|
+
def url(self):
|
|
130
|
+
return "http://%s:%d" % (self.host, self.port)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
134
|
+
# Quiet logging.
|
|
135
|
+
def log_message(self, *args):
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
def _cors(self):
|
|
139
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
140
|
+
self.send_header("Access-Control-Allow-Methods",
|
|
141
|
+
"GET, POST, OPTIONS")
|
|
142
|
+
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
|
143
|
+
|
|
144
|
+
def _send(self, code, obj):
|
|
145
|
+
body = json.dumps(obj).encode("utf-8")
|
|
146
|
+
self.send_response(code)
|
|
147
|
+
self.send_header("Content-Type", "application/json")
|
|
148
|
+
self.send_header("Content-Length", str(len(body)))
|
|
149
|
+
self._cors()
|
|
150
|
+
self.end_headers()
|
|
151
|
+
self.wfile.write(body)
|
|
152
|
+
|
|
153
|
+
def do_OPTIONS(self):
|
|
154
|
+
self.send_response(204)
|
|
155
|
+
self._cors()
|
|
156
|
+
self.end_headers()
|
|
157
|
+
|
|
158
|
+
def do_GET(self):
|
|
159
|
+
if self.path == "/health":
|
|
160
|
+
self._send(200, {"ok": True,
|
|
161
|
+
"blender": list(bpy.app.version),
|
|
162
|
+
"service": "blender-ai-bridge"})
|
|
163
|
+
elif self.path == "/scene":
|
|
164
|
+
self._send(200, self._run({"id": 0, "command": "scene", "args": {}}))
|
|
165
|
+
elif self.path in ("/", "/info"):
|
|
166
|
+
self._send(200, {"service": "blender-ai-bridge",
|
|
167
|
+
"blender": list(bpy.app.version),
|
|
168
|
+
"endpoints": ["/", "/health", "/scene", "POST /"]})
|
|
169
|
+
else:
|
|
170
|
+
self._send(404, {"error": "not found"})
|
|
171
|
+
|
|
172
|
+
def do_POST(self):
|
|
173
|
+
length = int(self.headers.get("Content-Length", 0) or 0)
|
|
174
|
+
if length <= 0 or length > 64 * 1024 * 1024:
|
|
175
|
+
self._send(400, {"error": "bad content length"})
|
|
176
|
+
return
|
|
177
|
+
raw = self.rfile.read(length)
|
|
178
|
+
try:
|
|
179
|
+
payload = json.loads(raw)
|
|
180
|
+
except Exception as exc:
|
|
181
|
+
self._send(400, {"error": "invalid json: %s" % exc})
|
|
182
|
+
return
|
|
183
|
+
self._send(200, self._run(payload))
|
|
184
|
+
|
|
185
|
+
def _run(self, payload):
|
|
186
|
+
task = _Task(payload)
|
|
187
|
+
_pending.put(task, block=True, timeout=30)
|
|
188
|
+
if not task.event.wait(timeout=self.server.owner_timeout):
|
|
189
|
+
return {"id": payload.get("id"),
|
|
190
|
+
"error": "timeout waiting for main thread"}
|
|
191
|
+
return task.response
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# Stash the per-request timeout on the server so the handler can read it.
|
|
195
|
+
def _patch():
|
|
196
|
+
orig_init = ThreadingHTTPServer.__init__
|
|
197
|
+
|
|
198
|
+
def init(self, addr, handler, *a, **k):
|
|
199
|
+
orig_init(self, addr, handler, *a, **k)
|
|
200
|
+
self.owner_timeout = 60.0
|
|
201
|
+
|
|
202
|
+
ThreadingHTTPServer.__init__ = init
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
_patch()
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Blender AI Bridge — harness driver.
|
|
4
|
+
*
|
|
5
|
+
* Talks to the JSON command API exposed by EITHER runtime:
|
|
6
|
+
* - the `blender_ai_bridge` Blender add-on (bpy, desktop or `--background`)
|
|
7
|
+
* - the pure-Python `headless_bridge.py` (trimesh + manifold3d, no Blender)
|
|
8
|
+
*
|
|
9
|
+
* Both speak the same protocol:
|
|
10
|
+
* POST / {"id","command","args"} -> {"id","result"|"error"}
|
|
11
|
+
*
|
|
12
|
+
* OOM-safe export: the bridge writes geometry to disk and returns only
|
|
13
|
+
* `file_path`; this driver reads the file from disk when bytes are needed,
|
|
14
|
+
* never expecting large meshes inline in JSON.
|
|
15
|
+
*
|
|
16
|
+
* Usage as a module:
|
|
17
|
+
* const { BlenderBridge } = require('./blender_driver.js');
|
|
18
|
+
* const b = new BlenderBridge('http://127.0.0.1:13082');
|
|
19
|
+
* await b.createShape({type:'box', dx:2, dy:3, dz:4, name:'mybox'});
|
|
20
|
+
* const stl = await b.export({format:'stl', path:'/tmp/x.stl', names:['mybox']});
|
|
21
|
+
* const bytes = await b.readFile(stl.file_path);
|
|
22
|
+
*
|
|
23
|
+
* Usage as a CLI (built-in demo / one-shot commands):
|
|
24
|
+
* node blender_driver.js --url http://127.0.0.1:13082 demo
|
|
25
|
+
* node blender_driver.js --url http://127.0.0.1:13082 cmd scene '{}'
|
|
26
|
+
* node blender_driver.js --url http://127.0.0.1:13082 cmd create_shape '{"type":"sphere","radius":2}'
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const http = require('http');
|
|
30
|
+
const fs = require('fs');
|
|
31
|
+
const path = require('path');
|
|
32
|
+
const { URL } = require('url');
|
|
33
|
+
|
|
34
|
+
class BlenderBridge {
|
|
35
|
+
constructor(url = 'http://127.0.0.1:13080', { timeout = 60000 } = {}) {
|
|
36
|
+
this.base = new URL(url);
|
|
37
|
+
this.timeout = timeout;
|
|
38
|
+
this._id = 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
_request(method, pth, body) {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const payload = body == null ? null : Buffer.from(JSON.stringify(body));
|
|
44
|
+
const req = http.request(
|
|
45
|
+
{
|
|
46
|
+
method,
|
|
47
|
+
hostname: this.base.hostname,
|
|
48
|
+
port: this.base.port,
|
|
49
|
+
path: pth,
|
|
50
|
+
headers: payload
|
|
51
|
+
? { 'Content-Type': 'application/json', 'Content-Length': payload.length }
|
|
52
|
+
: {},
|
|
53
|
+
},
|
|
54
|
+
(res) => {
|
|
55
|
+
const chunks = [];
|
|
56
|
+
res.on('data', (c) => chunks.push(c));
|
|
57
|
+
res.on('end', () => {
|
|
58
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
59
|
+
if (res.statusCode >= 400) {
|
|
60
|
+
reject(new Error(`HTTP ${res.statusCode}: ${text.slice(0, 500)}`));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (!text) return resolve(null);
|
|
64
|
+
try { resolve(JSON.parse(text)); } catch (e) { resolve({ raw: text }); }
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
req.on('error', reject);
|
|
69
|
+
req.setTimeout(this.timeout, () => req.destroy(new Error('timeout')));
|
|
70
|
+
if (payload) req.write(payload);
|
|
71
|
+
req.end();
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Low-level: send one command, return the `result` (throws on error). */
|
|
76
|
+
async send(command, args = {}) {
|
|
77
|
+
const id = ++this._id;
|
|
78
|
+
const resp = await this._request('POST', '/', { id, command, args });
|
|
79
|
+
if (!resp) throw new Error('empty response');
|
|
80
|
+
if (resp.error) throw new Error(resp.error);
|
|
81
|
+
if (resp.result && resp.result.ok === false) {
|
|
82
|
+
throw new Error(resp.result.error || 'command failed');
|
|
83
|
+
}
|
|
84
|
+
return resp.result;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async health() { return this._request('GET', '/health'); }
|
|
88
|
+
async scene() {
|
|
89
|
+
const r = await this._request('GET', '/scene');
|
|
90
|
+
return r && r.result ? r.result : r;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---- typed helpers (mirror the command catalog) ----
|
|
94
|
+
createShape(args) { return this.send('create_shape', args); }
|
|
95
|
+
boolean(args) { return this.send('boolean', args); }
|
|
96
|
+
transform(args) { return this.send('transform', args); }
|
|
97
|
+
duplicate(args) { return this.send('duplicate', args); }
|
|
98
|
+
delete(args) { return this.send('delete', args); }
|
|
99
|
+
rename(args) { return this.send('rename', args); }
|
|
100
|
+
material(args) { return this.send('material', args); }
|
|
101
|
+
modifier(args) { return this.send('modifier', args); }
|
|
102
|
+
meshEdit(args) { return this.send('mesh_edit', args); }
|
|
103
|
+
selection(args) { return this.send('selection', args); }
|
|
104
|
+
sceneClear(args) { return this.send('scene_clear', args); }
|
|
105
|
+
camera(args) { return this.send('camera', args); }
|
|
106
|
+
light(args) { return this.send('light', args); }
|
|
107
|
+
render(args) { return this.send('render', args); }
|
|
108
|
+
exportModel(args) { return this.send('export', args); }
|
|
109
|
+
importModel(args) { return this.send('import', args); }
|
|
110
|
+
script(args) { return this.send('script', args); }
|
|
111
|
+
command(args) { return this.send('command', args); }
|
|
112
|
+
document(args) { return this.send('document', args); }
|
|
113
|
+
|
|
114
|
+
/** Read an exported model file from disk (OOM-safe: bytes never in JSON). */
|
|
115
|
+
readFile(file_path) { return fs.promises.readFile(file_path); }
|
|
116
|
+
readFileB64(file_path) {
|
|
117
|
+
return fs.promises.readFile(file_path).then((b) => b.toString('base64'));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---- convenience: build a named primitive ----
|
|
121
|
+
box(name, dx = 1, dy = 1, dz = 1, location) {
|
|
122
|
+
return this.createShape({ type: 'box', name, dx, dy, dz, location });
|
|
123
|
+
}
|
|
124
|
+
sphere(name, radius = 1, location) {
|
|
125
|
+
return this.createShape({ type: 'sphere', name, radius, location });
|
|
126
|
+
}
|
|
127
|
+
cylinder(name, radius = 1, height = 2, location) {
|
|
128
|
+
return this.createShape({ type: 'cylinder', name, radius, height, location });
|
|
129
|
+
}
|
|
130
|
+
/** Boolean difference: target minus tools, returns result object name. */
|
|
131
|
+
cut(name, target, tool, remove = false) {
|
|
132
|
+
return this.boolean({ name, target, tool, operation: 'difference', remove_tools: remove });
|
|
133
|
+
}
|
|
134
|
+
union(name, target, tool) {
|
|
135
|
+
return this.boolean({ name, target, tool, operation: 'union' });
|
|
136
|
+
}
|
|
137
|
+
intersect(name, target, tool) {
|
|
138
|
+
return this.boolean({ name, target, tool, operation: 'intersect' });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// --------------------------------------------------------------------------- //
|
|
143
|
+
// CLI
|
|
144
|
+
// --------------------------------------------------------------------------- //
|
|
145
|
+
async function demo(url) {
|
|
146
|
+
const b = new BlenderBridge(url);
|
|
147
|
+
console.log('health:', await b.health());
|
|
148
|
+
await b.sceneClear({ all: true });
|
|
149
|
+
const box = await b.box('body', 4, 4, 4);
|
|
150
|
+
console.log('created', box.name, box.vertices, 'verts');
|
|
151
|
+
const sph = await b.sphere('hole', 1.5);
|
|
152
|
+
console.log('created', sph.name, sph.vertices, 'verts');
|
|
153
|
+
const cyl = await b.cylinder('slot', 0.8, 6);
|
|
154
|
+
await b.transform({ name: 'slot', location: [0, 0, 0] });
|
|
155
|
+
const carved = await b.cut('carved', 'body', 'hole', true);
|
|
156
|
+
console.log('boolean cut ->', carved.name, carved.vertices, 'verts');
|
|
157
|
+
const cut2 = await b.cut('carved2', 'carved', 'slot', true);
|
|
158
|
+
console.log('boolean cut2 ->', cut2.name, cut2.vertices, 'verts');
|
|
159
|
+
const stl = await b.exportModel({ format: 'stl', path: '/workspace/demo_cli.stl', names: ['carved2'] });
|
|
160
|
+
console.log('export STL ->', stl.file_path, stl.size, 'bytes');
|
|
161
|
+
const obj = await b.exportModel({ format: 'obj', path: '/workspace/demo_cli.obj', names: ['carved2'] });
|
|
162
|
+
console.log('export OBJ ->', obj.file_path, obj.size, 'bytes');
|
|
163
|
+
const sc = await b.scene();
|
|
164
|
+
console.log('scene objects:', sc.objects.map((o) => `${o.name}[${o.type}] v=${o.vertices} f=${o.polygons}`));
|
|
165
|
+
console.log('DEMO OK');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function main() {
|
|
169
|
+
const argv = process.argv.slice(2);
|
|
170
|
+
let url = 'http://127.0.0.1:13080';
|
|
171
|
+
const filtered = [];
|
|
172
|
+
for (let i = 0; i < argv.length; i++) {
|
|
173
|
+
if (argv[i] === '--url') { url = argv[++i]; } else filtered.push(argv[i]);
|
|
174
|
+
}
|
|
175
|
+
const [sub, ...rest] = filtered;
|
|
176
|
+
if (sub === 'demo') return demo(url);
|
|
177
|
+
if (sub === 'health') { console.log(await new BlenderBridge(url).health()); return; }
|
|
178
|
+
if (sub === 'scene') { console.log(JSON.stringify(await new BlenderBridge(url).scene(), null, 2)); return; }
|
|
179
|
+
if (sub === 'cmd') {
|
|
180
|
+
const [command, argsJson] = rest;
|
|
181
|
+
const r = await new BlenderBridge(url).send(command, JSON.parse(argsJson || '{}'));
|
|
182
|
+
console.log(JSON.stringify(r, null, 2));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
console.log('Usage: blender_driver.js --url URL [demo|health|scene|cmd <command> <argsJson>]');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (require.main === module) {
|
|
189
|
+
main().catch((e) => { console.error('Error:', e.message); process.exit(1); });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
module.exports = { BlenderBridge };
|