Files
animation/tools/md_bridge/TinqsMDBridge.py
T
jeremy 3ba86b2ea8 feat: clothing lane, character sources, and DCC bridges
Bulk import of the working lanes that were living untracked on the PC.

Content:
- characters/  Lena/male body lanes, bakes, texture work, run logs
- clothing/    garment pipeline, configs, gates, contract docs
- garments/    MD-authored garment sources (.zprj/.zpac)
- UAL-Lib/     Universal Animation Library 2 source (.blend/.fbx/.glb)
- tools/       blender_bridge, iclone_bridge, md_bridge, tailor, glm_agent
- docs/, plans/, dev/, .agents/plans/

Repo hygiene:
- .gitattributes: LFS now covers .blend, .zprj, .zpac, .obj, .npy and the
  Reallusion .iAvatar/.ccAvatar/.ccRestore containers. Without this the
  ~3.8 GB in this commit would land as raw blobs. .png/.jpg are left out
  on purpose — ~250 are already tracked raw and converting them would
  rewrite every one without shrinking history.
- .gitignore: exclude /accurig/ (~1 GB AccuRig program files, redistributable
  from Reallusion, nothing authored here) and /dev/null/ (git-lfs hook copies
  dropped by a `>/dev/null` redirect on Windows).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 15:55:43 -07:00

270 lines
9.8 KiB
Python

"""TinqsMDBridge -- Marvelous Designer python plug-in (v2, main-loop design).
Runs a localhost-only TCP socket server inside Marvelous Designer and
executes Python source sent to it, giving external tooling (Claude Code)
live control of MD's scripting API (pattern_api / import_api / export_api /
fabric_api / utility_api / ApiTypes).
WHY v2 BLOCKS THE UI -- verified 2026-07-30 on MD 2026 Personal (embedded
Python 3.11.8): MD's embedded interpreter does NOT schedule Python background
threads while MD is idle. A daemon-thread server binds and listens (the OS
accepts connections into the backlog) but accept()/recv() never run, so every
request times out. No Python Qt binding ships with MD either, so the iClone
QTimer trick is unavailable. The only thread Python code reliably runs on is
the one the Plug-in Manager calls into -- so the server IS the click:
- Clicking Plugin > TinqsMDBridge enters a single-threaded serve loop on
the main thread. MD's UI freezes ("Not Responding" is NORMAL) while the
bridge session is active. All api calls run on the main thread -- the
safest place for them.
- The loop exits (returning MD to interactive use) when a client sends the
literal code "__STOP__" (python tools/md_bridge.py --stop), or after
IDLE_TIMEOUT_S with no requests (default 300, env
TINQS_MD_BRIDGE_IDLE overrides) -- the escape hatch, since a frozen UI
can't be clicked. Each click is a fresh session; the socket is fully
closed on exit.
Install (one-time, manual -- MD has no auto-load plugin folder):
1. In MD: Plugin tab > Plug-in Manager > + ADD
2. Select this file, name it "TinqsMDBridge", click OK
3. Click Plugin > TinqsMDBridge to START A BRIDGE SESSION (UI freezes)
4. Verify from a terminal: python tools/md_bridge.py --ping
5. End the session: python tools/md_bridge.py --stop
NOTE: if MD copies the .py on registration rather than referencing it in
place (check --ping's "source" field), edits require remove + re-ADD.
Wire protocol (identical to the iClone TinqsBridge): newline-delimited JSON
over TCP, one request/response pair per connection.
request: {"id": <int>, "code": "<python source>"}
success: {"id": <int>, "ok": true, "result": <json|null>, "stdout": "<str>"}
failure: {"id": <int>, "ok": false, "error": "<traceback str>", "stdout": "<str>"}
Execution semantics (same `result` convention as the iClone bridge):
- exec(code, ns, ns) against ONE persistent namespace. It survives across
requests AND across serve sessions (module-level dict; the Plug-in
Manager re-execs this file per click but the namespace is re-seeded,
fresh api module refs, stale user state discarded -- keep long-lived
state on your own side).
- Setting `result` in submitted code makes it the response result
(JSON-encoded, repr() fallback). Cleared before every exec.
- stdout/stderr captured and returned; exceptions come back as ok:false
with a traceback and the session keeps serving.
Log file: %TEMP%/tinqs_md_bridge.log
"""
import contextlib
import io
import json
import os
import socket
import time
import traceback
from datetime import datetime
VERSION = 2
DEFAULT_PORT = 18900
HOST = "127.0.0.1"
ACCEPT_POLL_TIMEOUT_S = 1.0
CONN_READ_TIMEOUT_S = 30.0
DEFAULT_IDLE_TIMEOUT_S = 14400.0 # 4 h -- one click lasts a work session;
# --stop from any terminal ends it anytime
STOP_SENTINEL = "__STOP__"
LOG_PATH = os.path.join(
os.environ.get("TEMP", os.environ.get("TMP", ".")),
"tinqs_md_bridge.log",
)
def _log(msg):
"""Append a timestamped line to the log file. Must never raise."""
try:
line = "[{}] {}\n".format(datetime.now().strftime("%Y-%m-%d %H:%M:%S"), msg)
with open(LOG_PATH, "a", encoding="utf-8") as f:
f.write(line)
except Exception:
pass
def _env_float(name, default):
raw = os.environ.get(name)
if raw:
try:
return float(raw)
except ValueError:
_log("WARN: {}={!r} not a number, using {}".format(name, raw, default))
return default
def _import_md_api():
"""Import whatever MD api modules exist in this MD build; report misses."""
ns = {}
missing = []
for name in ("import_api", "export_api", "pattern_api", "fabric_api",
"utility_api", "ApiTypes"):
try:
ns[name] = __import__(name)
except Exception:
missing.append(name)
return ns, missing
def _execute(ns, code):
ns["result"] = None
out = io.StringIO()
try:
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(out):
exec(code, ns, ns)
result = ns.get("result")
try:
json.dumps(result)
except TypeError:
result = repr(result)
return {"ok": True, "result": result, "stdout": out.getvalue()}
except Exception:
return {"ok": False, "error": traceback.format_exc(), "stdout": out.getvalue()}
def _read_request(conn):
"""Read one newline-terminated JSON request. Returns dict or None."""
conn.settimeout(CONN_READ_TIMEOUT_S)
buf = b""
while not buf.endswith(b"\n"):
chunk = conn.recv(4096)
if not chunk:
break
buf += chunk
if not buf.strip():
return None
return json.loads(buf.decode("utf-8"))
def _respond(conn, resp):
conn.sendall((json.dumps(resp) + "\n").encode("utf-8"))
def _reclaim_stale_socket():
"""v1 of this plugin parked a never-serving socket on builtins. Close it
so our bind succeeds without an MD restart."""
import builtins
old = getattr(builtins, "_tinqs_md_bridge_singleton", None)
if old is not None:
try:
if getattr(old, "server_socket", None) is not None:
old.server_socket.close()
_log("closed stale v1 socket")
except Exception:
pass
setattr(builtins, "_tinqs_md_bridge_singleton", None)
def _serve():
import sys
port = int(_env_float("TINQS_MD_BRIDGE_PORT", DEFAULT_PORT))
idle_timeout = _env_float("TINQS_MD_BRIDGE_IDLE", DEFAULT_IDLE_TIMEOUT_S)
_log("=" * 60)
_log("TinqsMDBridge v{} session starting, port {}, idle timeout {}s".format(
VERSION, port, idle_timeout))
_reclaim_stale_socket()
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
sock.bind((HOST, port))
except OSError:
_log("BIND FAILED on {}:{}:\n{}".format(HOST, port, traceback.format_exc()))
print("TinqsMDBridge: port {} busy -- another session active? see {}".format(
port, LOG_PATH))
return
sock.listen(5)
sock.settimeout(ACCEPT_POLL_TIMEOUT_S)
api_ns, missing = _import_md_api()
try:
source = os.path.abspath(__file__)
except NameError:
source = "<unknown -- __file__ not set by Plug-in Manager>"
ns = dict(api_ns)
ns["BRIDGE"] = {
"version": VERSION,
"mode": "mainloop",
"port": port,
"idle_timeout_s": idle_timeout,
"source": source,
"api_modules": sorted(api_ns.keys()),
"api_missing": missing,
"python": sys.version,
}
_log("serving on {}:{} api={} missing={} python={}".format(
HOST, port, sorted(api_ns.keys()), missing, sys.version.split()[0]))
print("TinqsMDBridge v{}: serving on {}:{}".format(VERSION, HOST, port))
print("MD's UI is frozen while the bridge session runs -- this is normal.")
print("End the session with: python tools/md_bridge.py --stop")
print("(auto-ends after {:.0f}s idle)".format(idle_timeout))
served = 0
deadline = time.monotonic() + idle_timeout
try:
while True:
if time.monotonic() > deadline:
_log("idle timeout after {} requests -- session over".format(served))
print("TinqsMDBridge: idle timeout, session over (served {})".format(served))
return
try:
conn, _addr = sock.accept()
except socket.timeout:
continue
except OSError:
_log("server socket died:\n{}".format(traceback.format_exc()))
return
try:
try:
req = _read_request(conn)
except Exception:
_respond(conn, {"id": None, "ok": False,
"error": "invalid JSON request", "stdout": ""})
continue
if req is None:
continue
code = req.get("code", "")
if code.strip() == STOP_SENTINEL:
_respond(conn, {"id": req.get("id"), "ok": True,
"result": "stopped", "stdout": ""})
_log("stop command -- session over, served {}".format(served))
print("TinqsMDBridge: stopped by client (served {})".format(served))
return
resp = _execute(ns, code)
resp["id"] = req.get("id")
served += 1
deadline = time.monotonic() + idle_timeout
if not resp["ok"]:
last = resp["error"].strip().splitlines()[-1] if resp["error"] else "?"
_log("request id={} ok=false: {}".format(req.get("id"), last))
_respond(conn, resp)
except Exception:
_log("connection error:\n{}".format(traceback.format_exc()))
finally:
try:
conn.close()
except Exception:
pass
finally:
try:
sock.close()
except Exception:
pass
_log("socket closed, session ended")
try:
_serve()
except Exception:
_log("session CRASHED:\n{}".format(traceback.format_exc()))
print("TinqsMDBridge: crashed -- see {}".format(LOG_PATH))