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>
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
"""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))
|
||||
Reference in New Issue
Block a user