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:
2026-08-06 15:55:43 -07:00
parent 3363209cac
commit 3ba86b2ea8
558 changed files with 68622 additions and 8 deletions
+257
View File
@@ -0,0 +1,257 @@
"""TinqsBridge -- iClone 8 auto-load plugin.
Runs a localhost-only TCP socket server inside iClone 8 and executes Python
source sent to it on iClone's Qt main thread. RLPy is NOT thread-safe: the
socket-accept thread and per-connection handler threads never touch RLPy or
any Qt object directly. They only move bytes on/off a queue.Queue. A QTimer
created on the main thread (inside initialize_plugin()) polls that queue and
does all RLPy/exec() work.
Wire protocol: newline-delimited JSON over TCP, one request/response pair per
TCP connection (the client opens a new connection per call).
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:
- `exec(code, ns, ns)` against ONE persistent namespace dict (`_ns`), shared
across every request for the life of the iClone session, and pre-seeded
with `RLPy`. The same dict is passed as both globals and locals so nested
functions/comprehensions in submitted code can see top-level names (the
classic exec-scoping trap).
- If the code sets a variable named `result`, that becomes the response
`result` (JSON-encoded; if it isn't JSON-serializable we fall back to
`repr()`). `ns["result"]` is cleared to None before every exec so a stale
value from a previous request can never leak into a response that didn't
set one.
- stdout/stderr during exec are captured and returned as `stdout`.
- Any exception during exec is caught; the response is `ok: false` with
`traceback.format_exc()`, and the bridge keeps serving further requests.
Source of truth for this file lives in the repo at
tools/iclone_bridge/TinqsBridge/main.py. tools/install_iclone_bridge.ps1
copies this folder into iClone's OpenPlugin directory. iClone must be
restarted to pick up changes to this file (there is no live-reload of
main.py itself -- but you CAN hot-swap behavior at runtime by exec-ing new
code through the bridge once it's up).
Written for iClone 8's embedded Python. Bin64 ships both python38.dll and
python310.dll and it is not obvious ahead of time which one hosts plugins,
so this file is kept 3.8-compatible on purpose: no `match` statement, no
`X | Y` union type syntax, no walrus-heavy or 3.9+-only stdlib usage. The
--ping command reports the real embedded `sys.version` back to the client so
this can be verified after the plugin loads.
See docs/iclone-bridge.md for the client-side usage, and the threading rule
above for anyone extending this file: new RLPy calls MUST happen inside
_execute()/_drain_queue() (i.e. on the QTimer callback / main thread), never
inside _handle_conn() or _accept_loop() (the socket threads).
"""
import contextlib
import io
import json
import os
import queue
import socket
import threading
import traceback
from datetime import datetime
import RLPy
try:
from PySide2.QtCore import QTimer
except Exception:
QTimer = None # reported to the log in initialize_plugin() if this happens
DEFAULT_PORT = 18800
HOST = "127.0.0.1"
POLL_MS = 50
ACCEPT_POLL_TIMEOUT_S = 1.0
CONN_READ_TIMEOUT_S = 30.0
LOG_PATH = os.path.join(
os.environ.get("TEMP", os.environ.get("TMP", ".")),
"tinqs_iclone_bridge.log",
)
# Persistent namespace shared across every request for the life of the iClone
# session. Passed as BOTH globals and locals to exec() -- see module docstring.
_ns = {"RLPy": RLPy}
_request_queue = queue.Queue()
_server_socket = None
_accept_thread = None
_timer = None
_stop_event = threading.Event()
_stats = {"served": 0, "port": None}
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 _get_port():
raw = os.environ.get("TINQS_ICLONE_BRIDGE_PORT")
if raw:
try:
return int(raw)
except ValueError:
_log("WARN: TINQS_ICLONE_BRIDGE_PORT={!r} is not an int, using default {}".format(
raw, DEFAULT_PORT))
return DEFAULT_PORT
def _execute(code):
"""RLPy-touching work. Only ever called from _drain_queue() on the Qt main thread."""
_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 _drain_queue():
"""QTimer callback -- runs on the Qt main thread. Safe to call RLPy here."""
while True:
try:
job = _request_queue.get_nowait()
except queue.Empty:
return
req_id = job.get("id")
resp = _execute(job.get("code", ""))
resp["id"] = req_id
_stats["served"] += 1
if resp["ok"]:
_log("request id={} ok=true".format(req_id))
else:
last_line = resp["error"].strip().splitlines()[-1] if resp["error"] else "?"
_log("request id={} ok=false: {}".format(req_id, last_line))
job["response"] = resp
job["event"].set()
def _handle_conn(conn, addr):
"""Socket thread. NEVER touches RLPy -- only reads/writes bytes and the queue."""
try:
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
try:
req = json.loads(buf.decode("utf-8"))
except Exception:
error_resp = {"id": None, "ok": False, "error": "invalid JSON request", "stdout": ""}
conn.sendall((json.dumps(error_resp) + "\n").encode("utf-8"))
return
event = threading.Event()
job = {"id": req.get("id"), "code": req.get("code", ""), "event": event, "response": None}
_request_queue.put(job)
event.wait()
conn.sendall((json.dumps(job["response"]) + "\n").encode("utf-8"))
except Exception:
_log("connection handler error: {}".format(traceback.format_exc()))
finally:
try:
conn.close()
except Exception:
pass
def _accept_loop(sock):
"""Daemon thread started from initialize_plugin(). NEVER touches RLPy."""
sock.settimeout(ACCEPT_POLL_TIMEOUT_S)
while not _stop_event.is_set():
try:
conn, addr = sock.accept()
except socket.timeout:
continue
except OSError:
break
t = threading.Thread(target=_handle_conn, args=(conn, addr), daemon=True)
t.start()
_log("accept loop exiting")
def initialize_plugin():
"""Mandatory entry point -- iClone refuses to load a plugin without this.
Entire body is wrapped in try/except so a load failure is logged instead
of silently failing (plugin load failures are otherwise near-invisible).
"""
global _server_socket, _accept_thread, _timer
try:
port = _get_port()
_log("=" * 60)
_log("TinqsBridge initialize_plugin() starting, requested port {}".format(port))
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 {}:{} -- {}".format(HOST, port, traceback.format_exc()))
_log("(A stale process or a second iClone instance may already hold this port.)")
return
sock.listen(5)
_server_socket = sock
_stats["port"] = port
_log("bound OK on {}:{}".format(HOST, port))
_accept_thread = threading.Thread(target=_accept_loop, args=(sock,), daemon=True)
_accept_thread.start()
_log("accept thread started")
if QTimer is None:
_log("PySide2.QtCore.QTimer unavailable -- no executor, bridge will accept "
"connections but never answer them")
return
_timer = QTimer()
_timer.timeout.connect(_drain_queue)
_timer.start(POLL_MS)
_log("QTimer executor started ({} ms poll)".format(POLL_MS))
_log("TinqsBridge ready on {}:{}. Python: {}".format(
HOST, port, __import__("sys").version))
except Exception:
_log("initialize_plugin() FAILED:\n{}".format(traceback.format_exc()))
def dispose_plugin():
"""Called by iClone on plugin unload/app exit. Best-effort cleanup, never raises."""
try:
_stop_event.set()
if _timer is not None:
_timer.stop()
if _server_socket is not None:
try:
_server_socket.close()
except Exception:
pass
_log("dispose_plugin() -- shutting down, served {} requests total".format(
_stats["served"]))
except Exception:
_log("dispose_plugin() FAILED:\n{}".format(traceback.format_exc()))