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,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 };