109 lines
5.3 KiB
Markdown
109 lines
5.3 KiB
Markdown
|
|
# Blender Bridge (TinqsBlenderBridge)
|
||
|
|
|
||
|
|
Live control of a **running, interactive** Blender session from the terminal —
|
||
|
|
third sibling of the iClone (`docs/iclone-bridge.md`) and MD
|
||
|
|
(`docs/md-bridge.md`) bridges: a socket server inside the app + a thin
|
||
|
|
JSON-over-TCP client, same wire protocol and `result` convention.
|
||
|
|
|
||
|
|
Distinct from the headless pipeline scripts (`tools/cc_retarget.py`,
|
||
|
|
`tools/tailor/*`), which spawn `blender --background` and exit. Those never
|
||
|
|
touch the open GUI. This bridge is for driving the window Jeremy is
|
||
|
|
*looking at* — inspecting a selection, nudging a mesh, checking a drape.
|
||
|
|
|
||
|
|
**The good news vs. MD:** Blender has `bpy.app.timers`, so the server polls a
|
||
|
|
non-blocking socket from a main-thread timer. **The UI stays fully
|
||
|
|
interactive** — no frozen-window session like MD. The UI only stalls for the
|
||
|
|
duration of a single `exec`.
|
||
|
|
|
||
|
|
| | iClone bridge | MD bridge | Blender bridge |
|
||
|
|
|---|---|---|---|
|
||
|
|
| Server source | `tools/iclone_bridge/TinqsBridge/main.py` | `tools/md_bridge/TinqsMDBridge.py` | `tools/blender_bridge/tinqs_blender_bridge.py` |
|
||
|
|
| Client | `tools/iclone_bridge.py` | `tools/md_bridge.py` | `tools/blender_bridge.py` |
|
||
|
|
| Port | 18800 | 18900 | **19000** (`TINQS_BLENDER_BRIDGE_PORT` overrides) |
|
||
|
|
| Install | ps1 → OpenPlugin, auto-loads | manual register, click = session | ps1 → `scripts/startup/`, auto-starts |
|
||
|
|
| Executor | QTimer on Qt main thread | main-thread loop, **UI frozen** | `bpy.app.timers`, **UI stays live** |
|
||
|
|
| Session end | app exit | `--stop` / 4 h idle | `--stop`, or app exit (no idle timeout) |
|
||
|
|
| Embedded Python | 3.8 | 3.11.8 | 3.13.9 (Blender 5.1.2) |
|
||
|
|
| Log | `%TEMP%\tinqs_iclone_bridge.log` | `%TEMP%\tinqs_md_bridge.log` | `%TEMP%\tinqs_blender_bridge.log` |
|
||
|
|
|
||
|
|
## Install (one-time)
|
||
|
|
|
||
|
|
```powershell
|
||
|
|
powershell -File tools\install_blender_bridge.ps1
|
||
|
|
```
|
||
|
|
|
||
|
|
Copies the server into every `%APPDATA%\Blender Foundation\Blender\<ver>\scripts\startup\`.
|
||
|
|
Blender imports startup modules at launch and calls their `register()`, so
|
||
|
|
**the bridge auto-starts on port 19000 from the next launch onward** — zero
|
||
|
|
clicks, forever.
|
||
|
|
|
||
|
|
**To start it in an ALREADY-OPEN Blender** (a session that predates the
|
||
|
|
install — the common case, and one that matters when the window has unsaved
|
||
|
|
work you don't want to lose to a restart):
|
||
|
|
|
||
|
|
> Scripting workspace → **Open** → `tools\blender_bridge\tinqs_blender_bridge.py` → **Run Script** (▶)
|
||
|
|
|
||
|
|
Re-running is safe: a singleton on `builtins` closes the previous socket and
|
||
|
|
retires its timer before rebinding, so no leak and no "port busy" on reload.
|
||
|
|
|
||
|
|
## Workflow
|
||
|
|
|
||
|
|
```bash
|
||
|
|
python tools/blender_bridge.py --ping # blender ver, open .blend
|
||
|
|
python tools/blender_bridge.py --exec "result = [o.name for o in bpy.data.objects]"
|
||
|
|
python tools/blender_bridge.py --file edit_mesh.py
|
||
|
|
python tools/blender_bridge.py --timeout 300 --file long_bake.py
|
||
|
|
python tools/blender_bridge.py --stop # server off, Blender lives on
|
||
|
|
```
|
||
|
|
|
||
|
|
⚠ **Use `--file`, not `--exec`, for anything with quotes.** PowerShell strips
|
||
|
|
inner double quotes from native-command args, so
|
||
|
|
`--exec "bpy.data.objects[\"Cube\"]"` arrives as `bpy.data.objects[Cube]` →
|
||
|
|
`NameError: name 'Cube' is not defined`. Write a scratchpad .py and use
|
||
|
|
`--file`.
|
||
|
|
|
||
|
|
The exec namespace persists across requests within a Blender session and is
|
||
|
|
pre-seeded with `bpy`, `view3d_override()`, and a `BRIDGE` info dict.
|
||
|
|
|
||
|
|
## The context trap (read this before using bpy.ops)
|
||
|
|
|
||
|
|
Timer callbacks run with **no window/area in `bpy.context`**, so operators
|
||
|
|
that need one fail with *"context is incorrect"*. Two ways out:
|
||
|
|
|
||
|
|
1. **Prefer the data API** — `bpy.data`, `obj.location`, `mesh.vertices[i].co`,
|
||
|
|
or `bmesh` for topology. Context-free, and the right tool anyway.
|
||
|
|
2. **Override when an operator truly needs a viewport** — the seeded helper
|
||
|
|
returns the kwargs:
|
||
|
|
|
||
|
|
```python
|
||
|
|
with bpy.context.temp_override(**view3d_override()):
|
||
|
|
bpy.ops.object.shade_smooth()
|
||
|
|
```
|
||
|
|
|
||
|
|
## Verified (2026-08-04, Blender 5.1.2, Python 3.13.9)
|
||
|
|
|
||
|
|
Smoke-tested end to end in a throwaway GUI instance: socket up ~3 s after
|
||
|
|
launch; `--ping`; direct vertex edit; `bmesh` subdivide (8 → 26 verts);
|
||
|
|
`shade_smooth` via `temp_override`; `--stop` closed the port cleanly and
|
||
|
|
**Blender stayed alive and responsive**.
|
||
|
|
|
||
|
|
## Two bugs found and fixed during that verification
|
||
|
|
|
||
|
|
Both are the kind that silently produce "connection refused", so they're
|
||
|
|
worth remembering if the bridge is ever ported or rewritten:
|
||
|
|
|
||
|
|
1. **`bpy.data` is restricted during startup registration.** Modules in
|
||
|
|
`scripts/startup/` register while `bpy.data` is still a `_RestrictData`
|
||
|
|
stub — reading `bpy.data.filepath` there raises
|
||
|
|
`AttributeError: '_RestrictData' object has no attribute 'filepath'`,
|
||
|
|
which aborts `register()` and the socket never binds, leaving *no log
|
||
|
|
file at all*. Fix: `register()` only schedules a one-shot
|
||
|
|
`bpy.app.timers` callback; the real start runs after boot, when the full
|
||
|
|
API is live. Diagnose this class of failure with
|
||
|
|
`blender --background --python-expr "print('x')"` — startup tracebacks
|
||
|
|
print to stdout. (Do **not** pass `--factory-startup`; it disables user
|
||
|
|
scripts and hides the very thing you're testing.)
|
||
|
|
2. **Unregistering a timer from inside its own callback is an error.** The
|
||
|
|
`__STOP__` path called `stop()` from within `_poll`. Fix: `stop(in_timer=True)`
|
||
|
|
just closes the socket and lets the callback's `return None` retire the timer.
|