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
+641
View File
@@ -0,0 +1,641 @@
# Bake the NUDE body albedo: paint the sculpted-in bra AND briefs out of the stock texture,
# leaving uniform skin — no tan line, no nipples, no crotch detail.
#
# blender --background --python tools/_bake_nude_body_texture.py -- [--out <png>] [--debug-mask]
#
# WHY BOTH GARMENTS ARE REDONE HERE rather than starting from Ariki_Female_QuatSkin_Bare's
# already-bra-free texture: that file's chest fill leaves visible streaks, which show up as
# smudges once the chest carries real breast volume. Doing bra and briefs in one pass with one
# technique gives a consistent result and drops the dependency on _Bare (whose geometry is
# broken anyway — see make_lena_nude_body.py).
#
# The mask comes from the MESH, not from guessed UV rectangles: faces whose vertices are painted
# fabric are rasterised into UV space, which automatically finds every scattered island (torso
# front, back straps, shoulder straps, waistband) without anyone hand-listing coordinates. The
# atlas mirrors islands left/right, so one fill covers both sides.
#
# The fill is a harmonic (Jacobi neighbour-average) diffusion from the surrounding skin. That is
# chosen for what it CANNOT do: a harmonic function has no interior extrema, so the filled area
# is smooth by construction and cannot invent a nipple, an areola, or a crotch seam.
import bpy, sys, os, json, struct, argparse
from collections import deque
import numpy as np
# This tool lives in the ANIMATION repo (moved from ariki-game 2026-08-06: it authors characters,
# and characters/REGISTRY.md governs its subjects — its output already staged here before the tool
# did). The stock body+albedo it reads still live in the game repo, so that path is RESOLVED,
# never assumed: $ARIKI_GAME wins, otherwise a sibling checkout is guessed and then verified. An
# ariki-relative default that silently fails to resolve is a known trap here — see
# .agents/wiki/ARCHITECTURE.md on the retargeters' --target.
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
GAME = os.environ.get("ARIKI_GAME") or os.path.abspath(os.path.join(REPO, os.pardir, "ariki-game"))
BODIES = os.path.join(GAME, "assets/quaternius/derived-bodies")
SRC_GLB = os.path.join(BODIES, "Ariki_Female_QuatSkin.glb")
SRC_TEX = os.path.join(BODIES, "Ariki_Female_QuatSkin_Lena_Body_Toned.png")
# Output defaults to THIS repo's staging tree, not next to the source bodies in ariki-game.
# characters/REGISTRY.md rule 8 (the boundary law) reserves PascalCase "Ariki_*" for game-repo
# ship names and forbids them upstream: the rename happens exactly once, at release. Writing an
# Ariki_*_Nude.png next to the canonical bodies would mint a ship name for something still WIP.
STAGING = os.path.join(REPO, "characters", "female", "lena_nude")
OUT_TEX = os.path.join(STAGING, "lena_nude_basecolor.png")
def game_asset(path, flag):
"""Fail loudly on an unresolved cross-repo path instead of reading the wrong file."""
if not os.path.isfile(path):
raise SystemExit(
f"[nude-tex] FATAL: cannot find {os.path.basename(path)}\n"
f" looked in: {path}\n"
f" That is an ariki-game asset. Pass {flag} explicitly, or set ARIKI_GAME\n"
f" to your ariki-game checkout (the sibling-directory guess failed).")
return path
# Colour key, measured per-face on this albedo: garment faces sit at sat 0.354 / R-B 1.55,
# plain skin at sat 0.546 / R-B 2.20. The threshold is deliberately on the garment side of that
# gap — the rim is recovered by growing a mesh ring afterwards, never by loosening the key.
SAT_STRONG, RB_STRONG = 0.42, 1.80
# Second, permissive key used ONLY to reclaim leftover garment texels lying next to the mask
# (pale rim, dark stitching). Safe because it is confined to the neighbourhood of the
# geometry-anchored mask; applied atlas-wide it would eat skin.
SAT_NEAR, RB_NEAR = 0.50, 2.05
# Third key: RELATIVE DARKNESS. The bra's painted cast-shadow band under the bust, and the
# garments' stitch lines, are dark and fairly SATURATED, so neither pale-fabric key sees them —
# they survived every earlier pass as a dark smudge across the ribcage, and a clay render proved
# it was paint and not geometry. Same trick as tools/_bake_browless_face.py: a pixel much darker
# than its own neighbourhood is ink, not skin tone. Kept on a short leash (DARK_PX) so ordinary
# anatomical shading — the navel, the ab creases — is not erased along with it.
# The band is a WIDE soft gradient, so the blur it is compared against has to be wider still —
# at radius 15 the band darkened its own reference and the test caught almost nothing (1.3k px).
# Its neighbourhood is bounded in MESH rings rather than UV pixels: "just below the bra" is a
# statement about the body, and UV distance does not respect it.
DARK_REL = 0.90
DARK_BLUR = 70
DARK_RINGS = 6
MESH_RINGS = 2
NEAR_PX = 18
Z_LO, Z_HI = 0.80, 1.42 # torso band; keeps low-saturation eyes/teeth/nails out
DILATE_PX = 8
ITERS = 400
FEATHER_PX = 8
COMP_FMT = {5120: "b", 5121: "B", 5122: "h", 5123: "H", 5125: "I", 5126: "f"}
NCOMP = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4, "MAT4": 16}
def log(m):
print(f"[nude-tex] {m}", flush=True)
class Gltf:
"""Minimal GLB reader (pattern from clothing/skirt_garment_weights.py). Used instead of the
bpy importer so the triangle list and its per-vertex UVs come through unchanged, with no
loop indirection and no stray-Icosphere ambiguity."""
def __init__(self, path):
with open(path, "rb") as f:
blob = f.read()
clen, _ = struct.unpack_from("<II", blob, 12)
self.d = json.loads(blob[20:20 + clen])
blen, _ = struct.unpack_from("<II", blob, 20 + clen)
self.buf = blob[20 + clen + 8: 20 + clen + 8 + blen]
def read(self, idx):
a = self.d["accessors"][idx]
bv = self.d["bufferViews"][a["bufferView"]]
off = bv.get("byteOffset", 0) + a.get("byteOffset", 0)
fmt = COMP_FMT[a["componentType"]]
n = NCOMP[a["type"]]
size = struct.calcsize("<" + fmt * n)
stride = bv.get("byteStride") or size
if stride == size:
arr = np.frombuffer(self.buf, dtype=np.dtype(fmt), count=a["count"] * n, offset=off)
return arr.reshape(a["count"], n).astype(np.float64 if fmt == "f" else np.int64)
return np.array([struct.unpack_from("<" + fmt * n, self.buf, off + i * stride)
for i in range(a["count"])])
def body_prim(self):
"""The primitive with the most vertices — i.e. the body, not the stray Icosphere."""
best = None
for m in self.d["meshes"]:
for p in m["primitives"]:
n = self.d["accessors"][p["attributes"]["POSITION"]]["count"]
if best is None or n > best[0]:
best = (n, p)
return best[1]
def load_image(path):
img = bpy.data.images.load(path)
w, h = img.size
buf = np.empty(w * h * 4, dtype=np.float32)
img.pixels.foreach_get(buf) # foreach_get: pixels[:] on 4096^2 is glacial
return img, buf.reshape(h, w, 4), w, h
def tri_pixels(p, w, h):
"""Indices of the texels strictly inside one UV triangle (pixel coords). Returns (ys, xs).
Sampling a triangle's bounding BOX instead — the obvious shortcut — pulls in neighbouring
islands, and on this atlas that made the underwear's own texels read as skin."""
x0, x1 = int(np.floor(p[:, 0].min())), int(np.ceil(p[:, 0].max()))
y0, y1 = int(np.floor(p[:, 1].min())), int(np.ceil(p[:, 1].max()))
if x1 - x0 > 512 or y1 - y0 > 512:
return None, None # straddles the atlas edge
x0, y0 = max(x0, 0), max(y0, 0)
x1, y1 = min(x1, w - 1), min(y1, h - 1)
if x1 < x0 or y1 < y0:
return None, None
gx, gy = np.meshgrid(np.arange(x0, x1 + 1), np.arange(y0, y1 + 1))
d = ((p[1, 1] - p[2, 1]) * (p[0, 0] - p[2, 0]) +
(p[2, 0] - p[1, 0]) * (p[0, 1] - p[2, 1]))
if abs(d) < 1e-12:
return None, None
a = ((p[1, 1] - p[2, 1]) * (gx - p[2, 0]) + (p[2, 0] - p[1, 0]) * (gy - p[2, 1])) / d
bb = ((p[2, 1] - p[0, 1]) * (gx - p[2, 0]) + (p[0, 0] - p[2, 0]) * (gy - p[2, 1])) / d
c = 1.0 - a - bb
inside = (a >= -0.002) & (bb >= -0.002) & (c >= -0.002)
if not inside.any():
return None, None
return gy[inside], gx[inside]
def uv_px(uv, w, h):
"""glTF UV -> Blender pixel coords.
THE V FLIP IS LOAD-BEARING. glTF puts the texture origin at the UPPER-left with v running
down; Blender's image buffer starts at the LOWER-left. UVs read straight out of the glTF are
therefore mirrored against a Blender-loaded image. Skipping this flip silently sampled the
wrong half of the atlas: the briefs measured as skin (sat 0.56) and plain belly skin measured
as fabric, so the mask formed in the wrong places and the fill smudged the chest.
(tools/make_lena_nude_body.py does NOT need this — it reads UVs from the bpy importer, which
has already flipped them.)"""
return np.stack([np.clip(uv[:, 0], 0, 1) * (w - 1),
(1.0 - np.clip(uv[:, 1], 0, 1)) * (h - 1)], axis=1)
def face_fabric(uv, tris, sel, rgb, w, h):
"""Classify each selected face by the MEAN colour of the texels inside its own UV triangle.
Per-face means are far steadier than the per-vertex sample this started as: a vertex sits on
an island boundary where the texel is often skin or padding, which under-selected the
garment by roughly 3x."""
PU = uv_px(uv, w, h)
idx = np.nonzero(sel)[0]
fab = np.zeros(len(tris), dtype=bool)
sats = np.zeros(len(tris))
rbs = np.zeros(len(tris))
got = np.zeros(len(tris), dtype=bool)
for fi in idx:
ys, xs = tri_pixels(PU[tris[fi]], w, h)
if ys is None:
continue
m = rgb[ys, xs].mean(axis=0)
mx, mn = m.max(), m.min()
sat = (mx - mn) / mx if mx > 1e-5 else 0.0
rb = m[0] / max(m[2], 1e-5)
sats[fi], rbs[fi], got[fi] = sat, rb, True
# STRICT threshold on purpose. The permissive pair (sat<0.55, R/B<2.20) sits exactly on
# ordinary belly skin's median (0.546 / 2.20) and flagged half of it; the strict pair
# separates cleanly — garment faces measure 0.354 / 1.55, skin 0.546 / 2.20. The rim is
# recovered afterwards by growing one mesh ring, not by loosening this.
fab[fi] = (sat < SAT_STRONG) and (rb < RB_STRONG)
return fab, sats, rbs, got
def rasterize_faces(uv, tris, keep, w, h):
"""Fill the UV triangles of the kept faces into a boolean image, barycentrically.
Blender image rows run bottom-up and so does UV v, so no flip is needed here."""
mask = np.zeros((h, w), dtype=bool)
px = uv_px(uv, w, h)
for t in tris[keep]:
p = px[t]
x0, x1 = int(np.floor(p[:, 0].min())), int(np.ceil(p[:, 0].max()))
y0, y1 = int(np.floor(p[:, 1].min())), int(np.ceil(p[:, 1].max()))
if x1 < x0 or y1 < y0:
continue
x1, y1 = min(x1, w - 1), min(y1, h - 1)
x0, y0 = max(x0, 0), max(y0, 0)
if (x1 - x0) > 512 or (y1 - y0) > 512:
continue # a face straddling the atlas edge: skip
xs = np.arange(x0, x1 + 1)
ys = np.arange(y0, y1 + 1)
gx, gy = np.meshgrid(xs, ys)
d = ((p[1, 1] - p[2, 1]) * (p[0, 0] - p[2, 0]) +
(p[2, 0] - p[1, 0]) * (p[0, 1] - p[2, 1]))
if abs(d) < 1e-12:
continue
a = ((p[1, 1] - p[2, 1]) * (gx - p[2, 0]) + (p[2, 0] - p[1, 0]) * (gy - p[2, 1])) / d
bb = ((p[2, 1] - p[0, 1]) * (gx - p[2, 0]) + (p[0, 0] - p[2, 0]) * (gy - p[2, 1])) / d
c = 1.0 - a - bb
inside = (a >= -0.002) & (bb >= -0.002) & (c >= -0.002)
mask[y0:y1 + 1, x0:x1 + 1] |= inside
return mask
def dilate(m, n):
out = m.copy()
for _ in range(n):
out[1:-1, 1:-1] |= (out[:-2, 1:-1] | out[2:, 1:-1] | out[1:-1, :-2] | out[1:-1, 2:])
return out
def components(mask, min_px):
keep = np.zeros_like(mask)
seen = np.zeros_like(mask)
ys, xs = np.nonzero(mask)
boxes = []
for sy, sx in zip(ys, xs):
if seen[sy, sx]:
continue
q = deque([(sy, sx)]); seen[sy, sx] = True; comp = []
while q:
cy, cx = q.popleft(); comp.append((cy, cx))
for dy, dx in ((-1, 0), (1, 0), (0, -1), (0, 1)):
ny, nx = cy + dy, cx + dx
if 0 <= ny < mask.shape[0] and 0 <= nx < mask.shape[1] \
and mask[ny, nx] and not seen[ny, nx]:
seen[ny, nx] = True; q.append((ny, nx))
if len(comp) >= min_px:
ca = np.array(comp)
for cy, cx in comp:
keep[cy, cx] = True
boxes.append((ca[:, 0].min(), ca[:, 0].max(), ca[:, 1].min(), ca[:, 1].max()))
return keep, boxes
def _pool2(a):
"""2x2 sum-pool, cropping any odd row/column."""
h, w = a.shape[0] // 2 * 2, a.shape[1] // 2 * 2
a = a[:h, :w]
if a.ndim == 3:
return a.reshape(h // 2, 2, w // 2, 2, a.shape[2]).sum(axis=(1, 3))
return a.reshape(h // 2, 2, w // 2, 2).sum(axis=(1, 3))
def box_blur(a, r):
"""Separable box blur via cumulative sums (pattern from tools/_bake_browless_face.py).
Multi-channel arrays are blurred PER CHANNEL. The 2D original finished with
`b1(b1(a,0).T,0).T`, and on an (h,w,3) array that transpose rotates the CHANNEL axis into
the blur: "blurred" R became roughly mean(R,G,B), so grain = a - blur turned into a
saturation booster (+red, -blue) instead of mean-zero texture — every fill region baked out
Fanta-orange, brighter than any actual skin texel in the atlas."""
if a.ndim == 3:
return np.stack([box_blur(a[:, :, c], r) for c in range(a.shape[2])], axis=2)
def b1(x, axis):
p = [(0, 0)] * x.ndim
p[axis] = (r, r)
c = np.cumsum(np.pad(x, p, mode="edge"), axis=axis)
return (np.take(c, np.arange(2 * r, c.shape[axis]), axis=axis) -
np.take(c, np.arange(0, c.shape[axis] - 2 * r), axis=axis)) / (2 * r)
return b1(b1(a, 0).T, 0).T
def box3(a):
"""3x3 box blur, edge-clamped — used to take the blockiness out of an upsampled level."""
p = np.pad(a, ((1, 1), (1, 1), (0, 0)), mode="edge")
return (p[:-2, :-2] + p[:-2, 1:-1] + p[:-2, 2:] +
p[1:-1, :-2] + p[1:-1, 1:-1] + p[1:-1, 2:] +
p[2:, :-2] + p[2:, 1:-1] + p[2:, 2:]) / 9.0
def pull_push(sub, valid, fallback):
"""Multi-scale 'pull-push' hole fill: sum-pool colour and coverage down an image pyramid,
then walk back up letting each level inherit its holes from the coarser one.
This replaces a Jacobi/harmonic relaxation, which was the original approach and was WRONG at
this scale: Jacobi propagates information one pixel per iteration, so a hole ~600 px across
needs on the order of 600^2 sweeps to converge. At 400 sweeps the interior never heard from
the boundary and simply kept its seed value — the mean of a ring that still included pale
fabric — which is exactly why the briefs showed up as a lighter panty-shaped patch. The
pyramid carries boundary information across the whole hole in log(width) steps instead.
`valid` marks the pixels allowed to act as SOURCES, which is deliberately narrower than
"not masked": the atlas packs islands close together with pale padding in the gutters, so
pooling every unmasked pixel in the bounding box averaged in gutter and neighbouring-island
texels and produced a pale, desaturated patch shaped like the briefs. Only verified torso
skin is allowed to contribute.
Still smooth by construction, so it cannot invent a nipple or a crotch seam."""
vf = valid.astype(np.float64)
cols = [sub.astype(np.float64) * vf[:, :, None]]
wts = [vf]
while min(wts[-1].shape[:2]) > 4:
cols.append(_pool2(cols[-1]))
wts.append(_pool2(wts[-1]))
# Coarsest level: any cell with NO coverage falls back to the reference skin tone. Without
# this the cell stays at zero and the pyramid carries pure black upward — which showed up as
# big black rectangles over the hips. Uncovered coarse cells are not hypothetical: some UV
# islands are almost entirely garment, so their bbox holds no skin to interpolate from at all.
w = wts[-1]
sol = np.where(w[:, :, None] > 0,
cols[-1] / np.maximum(w, 1e-9)[:, :, None],
fallback[None, None, :])
for lv in range(len(cols) - 2, -1, -1):
up = np.repeat(np.repeat(sol, 2, axis=0), 2, axis=1)
h, wd = cols[lv].shape[:2]
if up.shape[0] < h or up.shape[1] < wd: # odd dims were cropped on the way down
up = np.pad(up, ((0, max(0, h - up.shape[0])), (0, max(0, wd - up.shape[1])), (0, 0)),
mode="edge")
up = box3(up[:h, :wd])
cw = wts[lv]
own = np.where(cw[:, :, None] > 0, cols[lv] / np.maximum(cw, 1e-9)[:, :, None], up)
sol = np.where(cw[:, :, None] > 0, own, up)
return sol
def diffuse_fill(rgb, mask, boxes, fallback, source, target=None):
"""Fill each masked island from verified surrounding skin — seamlessly, and with the skin's
own grain.
Three stages beyond the raw pull-push, each answering a specific observed failure:
1. Jacobi polish — removes the pyramid's blockiness (convergence not needed; pull_push
already supplied the low frequencies).
2. SEAMLESS OFFSET (a cheap Poisson-style clone): the residual (original - fill) is
measured on a ring of real skin just outside the mask and smoothly extended over the
fill via a second pull_push. The fill then matches the surrounding skin exactly at the
boundary, killing the tonal seam an alpha feather merely narrowed — the ghost detector
still measured the old garment outline at 1.28x control on grazing views with feather
alone.
3. GRAIN REINJECTION: the fill is smooth by construction, but real skin here carries
high-frequency paint grain, so a smooth patch reads as a decal in exactly the shape of
the removed garment. The grain is harvested from the surrounding skin itself (residual
vs a box blur, sampled at fixed shifted offsets so no synthetic pattern is invented)
and added back inside the mask."""
out = rgb.copy()
for (y0, y1, x0, x1) in boxes:
pad = DILATE_PX + FEATHER_PX + 40 # reach far enough to find real skin to pool from
ya, yb = max(0, y0 - pad), min(rgb.shape[0], y1 + pad + 1)
xa, xb = max(0, x0 - pad), min(rgb.shape[1], x1 + pad + 1)
sub = out[ya:yb, xa:xb, :3]
m = mask[ya:yb, xa:xb]
if not m.any():
continue
src = source[ya:yb, xa:xb]
if target is not None:
# 3D-aware low-frequency base; pull-push only backfills texels the target missed
tgt = target[ya:yb, xa:xb]
have = ~np.isnan(tgt[:, :, 0])
filled = pull_push(sub, src, fallback)
filled = np.where(have[:, :, None], np.nan_to_num(tgt), filled)
else:
filled = pull_push(sub, src, fallback)
cur = np.where(m[:, :, None], filled, sub.astype(np.float64))
# With a 3D target the relaxation must stay LIGHT: hundreds of passes would diffuse the
# atlas-boundary tone back over the target and undo the 3D awareness.
iters = 12 if target is not None else ITERS
for _ in range(iters):
nb = np.zeros_like(cur)
nb[1:-1] += cur[:-2] + cur[2:]
nb[:, 1:-1] += cur[:, :-2] + cur[:, 2:]
cnt = np.zeros(cur.shape[:2])
cnt[1:-1] += 2
cnt[:, 1:-1] += 2
cur[m] = (nb / np.maximum(cnt, 1)[:, :, None])[m]
# seamless offset: harmonic-extend the boundary residual over the fill
ring = dilate(m, 3) & ~m & src
if ring.any():
resid = np.zeros_like(cur)
resid[ring] = sub[ring].astype(np.float64) - cur[ring]
ext = pull_push(resid, ring, np.zeros(3))
cur[m] += ext[m]
# Grain reinjection, harvested from surrounding real skin. Tile-transplanted: each 16 px
# tile of the fill copies the high-pass residual of a randomly chosen all-skin tile.
# (A first attempt sampled at three fixed pixel shifts instead; on large islands the
# shifted positions land inside the mask itself and coverage fell to 0-30%, leaving
# smooth patches in exactly the garment's shape — the thing grain exists to prevent.)
T = 16
grain = sub.astype(np.float64) - box_blur(sub.astype(np.float64), 5)
h_, w_ = m.shape
cand = []
for ty in range(0, h_ - T, T):
for tx in range(0, w_ - T, T):
if src[ty:ty + T, tx:tx + T].all():
cand.append((ty, tx))
covered = 0
if cand:
rng = np.random.RandomState(1234) # fixed seed: deterministic bake
for ty in range(0, h_ - T + 1, T):
for tx in range(0, w_ - T + 1, T):
tm = m[ty:ty + T, tx:tx + T]
if not tm.any():
continue
sy, sx = cand[rng.randint(len(cand))]
g = grain[sy:sy + T, sx:sx + T]
blk = cur[ty:ty + T, tx:tx + T]
blk[tm] += g[tm] * 0.85
covered += int(tm.sum())
log(f" island: grain tiles from {len(cand)} skin tiles -> {covered}/{int(m.sum())} px, "
f"seam ring {int(ring.sum())} px")
# narrow feather as belt-and-braces on top of the seamless offset
a = np.ones(m.shape)
edge = m.copy()
for k in range(FEATHER_PX):
nxt = dilate(edge, 1) & ~m
a[nxt] = (k + 1) / (FEATHER_PX + 1.0)
edge = edge | nxt
blend = np.where(m, 1.0, 1.0 - a)[:, :, None]
out[ya:yb, xa:xb, :3] = np.clip(cur * blend + sub * (1 - blend), 0, 1).astype(np.float32)
return out
def main():
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
ap = argparse.ArgumentParser()
ap.add_argument("--glb", default=SRC_GLB)
ap.add_argument("--tex", default=SRC_TEX)
ap.add_argument("--out", default=OUT_TEX)
ap.add_argument("--debug-mask", default="")
a = ap.parse_args(argv)
out_dir = os.path.dirname(os.path.abspath(a.out))
if not os.path.isdir(out_dir):
raise SystemExit(f"[nude-tex] FATAL: output directory does not exist: {out_dir}\n"
f" pass --out explicitly, or create the staging folder "
f"(see characters/REGISTRY.md)")
game_asset(a.glb, "--glb")
game_asset(a.tex, "--tex")
g = Gltf(a.glb)
prim = g.body_prim()
pos = g.read(prim["attributes"]["POSITION"])
uv = g.read(prim["attributes"]["TEXCOORD_0"])
tris = g.read(prim["indices"]).reshape(-1, 3)
log(f"{os.path.basename(a.glb)}: {len(pos)} verts, {len(tris)} tris")
img, px, w, h = load_image(a.tex)
log(f"{os.path.basename(a.tex)}: {w}x{h} uv u[{uv[:,0].min():.3f},{uv[:,0].max():.3f}] "
f"v[{uv[:,1].min():.3f},{uv[:,1].max():.3f}]")
rgb = px[:, :, :3]
# Geometry decides only WHICH FACES to consider — the torso band. This keeps the pale atlas
# gutters out (no face covers them) and the low-saturation eyes/teeth/nails out (wrong
# height). glTF is Y-up, so height is component 1.
cen = pos[tris].mean(axis=1)
torso = (cen[:, 1] > Z_LO) & (cen[:, 1] < Z_HI)
torso_region = rasterize_faces(uv, tris, torso, w, h)
log(f"torso faces: {torso.sum()} of {len(tris)} -> {torso_region.sum()} px")
fab, sats, rbs, got = face_fabric(uv, tris, torso, rgb, w, h)
log(f"fabric faces: {fab.sum()} of {got.sum()} classified")
# Validate the classifier against regions whose identity is known from anatomy alone, so a
# bad threshold shows up as a number here instead of as a smudge in the final render.
for nm, sel in (
("briefs front", (cen[:, 1] > 0.90) & (cen[:, 1] < 1.00) & (np.abs(cen[:, 0]) < 0.10)
& (cen[:, 2] > 0.04)),
("bra cups ", (cen[:, 1] > 1.22) & (cen[:, 1] < 1.32) & (np.abs(cen[:, 0]) < 0.12)
& (cen[:, 2] > 0.05)),
("belly SKIN ", (cen[:, 1] > 1.05) & (cen[:, 1] < 1.11) & (np.abs(cen[:, 0]) < 0.07)
& (cen[:, 2] > 0.03)),
("thigh SKIN ", (cen[:, 1] > 0.68) & (cen[:, 1] < 0.76) & (np.abs(cen[:, 0]) < 0.12)),
):
s = sel & got
if s.sum():
log(f" [{nm}] n={s.sum():4d} flagged={100*fab[s].mean():5.1f}% "
f"sat med={np.median(sats[s]):.3f} R/B med={np.median(rbs[s]):.2f}")
# Grow over the rim faces. Only 59-74% of garment faces pass the strict key — the misses are
# edge faces whose texel average is half skin — so the leftovers sit exactly ON the mask
# boundary, and a harmonic fill would then interpolate FROM pale fabric and leave a lighter
# ghost of the briefs plus dark stitch dashes. Growing outward puts the boundary on real skin.
def grow_rings(sel, n):
out = sel.copy()
for _ in range(n):
vg = np.zeros(len(pos), dtype=bool)
vg[tris[out].ravel()] = True
out = out | (vg[tris].any(axis=1) & torso)
return out
grown = grow_rings(fab, MESH_RINGS)
log(f"after {MESH_RINGS} mesh rings: {grown.sum()} faces")
raster = rasterize_faces(uv, tris, grown, w, h)
log(f"rasterised: {raster.sum()} px")
near_region = rasterize_faces(uv, tris, grow_rings(fab, DARK_RINGS), w, h)
log(f"near-garment region ({DARK_RINGS} rings): {near_region.sum()} px")
r, b = rgb[:, :, 0], rgb[:, :, 2]
mx, mn = rgb.max(axis=2), rgb.min(axis=2)
sat = np.where(mx > 1e-5, (mx - mn) / np.maximum(mx, 1e-5), 0.0)
near_fab = (sat < SAT_NEAR) & (r / np.maximum(b, 1e-5) < RB_NEAR)
mask = raster | (dilate(raster, NEAR_PX) & near_fab)
log(f"+ reclaimed pale neighbours: {mask.sum()} px")
luma = 0.2126 * rgb[:, :, 0] + 0.7152 * rgb[:, :, 1] + 0.0722 * rgb[:, :, 2]
dark = luma < DARK_REL * box_blur(luma, DARK_BLUR)
mask = mask | (dark & near_region)
log(f"+ painted shadow/stitching: {mask.sum()} px")
mask = dilate(mask, DILATE_PX)
mask, boxes = components(mask, 200)
log(f"final mask: {mask.sum()} px in {len(boxes)} islands")
if a.debug_mask:
dbg = px.copy()
dbg[:, :, 0] = np.where(mask, 1.0, dbg[:, :, 0])
dbg[:, :, 1] = np.where(mask, 0.0, dbg[:, :, 1])
dbg[:, :, 2] = np.where(mask, 0.0, dbg[:, :, 2])
save(dbg, w, h, a.debug_mask)
# Fill sources: verified torso skin only — inside the torso's own UV islands, not masked,
# not keyed as fabric even permissively, and not painted ink.
source = torso_region & ~mask & ~near_fab & ~dark
fallback = rgb[source].mean(axis=0).astype(np.float64) if source.sum() > 1000 \
else np.array([0.5, 0.35, 0.28])
log(f"skin sources: {source.sum()} texels, mean "
f"rgb({fallback[0]:.3f},{fallback[1]:.3f},{fallback[2]:.3f})")
# 3D-aware fill target (the load-bearing stage — see fill_target_3d)
target = fill_target_3d(pos, uv, tris, torso, mask, source, rgb, w, h)
out = diffuse_fill(px, mask, boxes, fallback, source, target)
save(out, w, h, a.out)
def fill_target_3d(pos, uv, tris, torso, mask, source, rgb, w, h):
"""Low-frequency fill colour per masked texel, decided by BODY proximity, not atlas
proximity.
Why: filling each UV island from its own atlas surroundings gave every island its own tone
— the big torso fills converged toward the global mean (an orange noticeably more saturated
than the pale flank skin), and islands that sit next to each other ON THE BODY but far apart
IN THE ATLAS met at visible tone seams. The atlas cannot see 3D adjacency; this stage can.
Method: per-VERTEX, so it is cheap and cross-seam consistent by construction. Every vertex
gets its albedo from its own texel; garment vertices then take an inverse-square-distance
blend of the K nearest verified-skin vertices in 3D; the per-vertex colours are rasterised
barycentrically into the mask. Grain and the seam offset go on top in diffuse_fill."""
PU = uv_px(uv, w, h)
xi = np.clip(PU[:, 0], 0, w - 1).astype(int)
yi = np.clip(PU[:, 1], 0, h - 1).astype(int)
vcol = rgb[yi, xi].astype(np.float64)
v_masked = mask[yi, xi]
v_source = source[yi, xi]
skin_v = np.nonzero(v_source & ~v_masked)[0]
need_v = np.nonzero(v_masked)[0]
log(f"3d fill: {len(need_v)} garment verts <- {len(skin_v)} skin verts")
sp = pos[skin_v]
sc = vcol[skin_v]
K = 12
fill_c = vcol.copy()
for vi in need_v:
d2 = np.sum((sp - pos[vi]) ** 2, axis=1)
near = np.argpartition(d2, K)[:K]
wgt = 1.0 / np.maximum(d2[near], 1e-6)
fill_c[vi] = (sc[near] * wgt[:, None]).sum(axis=0) / wgt.sum()
# rasterise per-vertex fill colours over the masked faces (one extra ring so the mask's
# pixel dilation stays covered), barycentric
target = np.full((h, w, 3), np.nan)
vg = np.zeros(len(pos), dtype=bool)
vg[need_v] = True
faces = (vg[tris].any(axis=1)) & torso
for fi in np.nonzero(faces)[0]:
t = tris[fi]
p = PU[t]
ys, xs = tri_pixels(p, w, h)
if ys is None:
continue
d = ((p[1, 1] - p[2, 1]) * (p[0, 0] - p[2, 0]) +
(p[2, 0] - p[1, 0]) * (p[0, 1] - p[2, 1]))
if abs(d) < 1e-12:
continue
a_ = ((p[1, 1] - p[2, 1]) * (xs - p[2, 0]) + (p[2, 0] - p[1, 0]) * (ys - p[2, 1])) / d
b_ = ((p[2, 1] - p[0, 1]) * (xs - p[2, 0]) + (p[0, 0] - p[2, 0]) * (ys - p[2, 1])) / d
c_ = 1.0 - a_ - b_
target[ys, xs] = (a_[:, None] * fill_c[t[0]] + b_[:, None] * fill_c[t[1]]
+ c_[:, None] * fill_c[t[2]])
# the mask is dilated a few px past the rasterised faces; creep the target outward to cover
have = ~np.isnan(target[:, :, 0])
for _ in range(DILATE_PX + 2):
grown_have = dilate(have, 1)
ring_ = grown_have & ~have
ys, xs = np.nonzero(ring_)
for oy, ox in ((0, 1), (0, -1), (1, 0), (-1, 0)):
ny, nx = np.clip(ys + oy, 0, h - 1), np.clip(xs + ox, 0, w - 1)
ok = have[ny, nx] & np.isnan(target[ys, xs, 0])
target[ys[ok], xs[ok]] = target[ny[ok], nx[ok]]
have = grown_have
cov = (~np.isnan(target[:, :, 0]) & mask).sum()
log(f"3d fill: target covers {cov}/{mask.sum()} masked px")
return target
def save(arr, w, h, path):
im = bpy.data.images.new("out", width=w, height=h, alpha=True)
im.pixels.foreach_set(arr.reshape(-1).astype(np.float32))
im.filepath_raw = path
im.file_format = 'PNG'
im.save()
log(f"WROTE {path} ({os.path.getsize(path)/1e6:.2f} MB)")
main()
+98
View File
@@ -0,0 +1,98 @@
# Framed closeup renders of a body GLB, for judging geometry and texture separately.
#
# blender --background --python tools/_render_body_closeup.py -- \
# --glb <a.glb> --out <a.png> [--yaw 0] [--region torso|hip|full] [--clay] [--wire]
#
# Why not tools/_glb_render.py: that one frames the whole-scene bounding box, and every body
# GLB here carries a stray 1 m-radius Icosphere at the origin, so the figure ends up small in
# frame. This one frames an explicit anatomical band on the BODY mesh only.
# --clay drops all materials for a neutral grey surface, which is the only honest way to judge
# silhouette and shading artifacts — a painted-on bra shadow otherwise reads as a mesh dent.
import bpy, sys, math, argparse
from mathutils import Vector
REGIONS = { # (z_lo, z_hi) in metres on a 1.777 m body
"torso": (1.00, 1.52),
"hip": (0.78, 1.12),
"full": (0.00, 1.80),
}
def main():
argv = sys.argv[sys.argv.index("--") + 1:]
ap = argparse.ArgumentParser()
ap.add_argument("--glb", required=True)
ap.add_argument("--out", required=True)
ap.add_argument("--yaw", type=float, default=0.0)
ap.add_argument("--region", default="torso", choices=sorted(REGIONS))
ap.add_argument("--clay", action="store_true")
ap.add_argument("--wire", action="store_true")
ap.add_argument("--no-shadow", action="store_true",
help="disable cast shadows — for measurement renders; in a T-pose the arm "
"throws sharp finger-shadow bands across the flank that edge metrics "
"misread as surface creases")
ap.add_argument("--res", type=int, default=900)
a = ap.parse_args(argv)
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=a.glb)
meshes = [o for o in bpy.data.objects if o.type == 'MESH']
body = max(meshes, key=lambda o: len(o.data.vertices))
for o in meshes: # hide the stray Icosphere and anything else
if o is not body:
o.hide_render = True
if a.clay:
body.data.materials.clear()
m = bpy.data.materials.new("Clay")
m.use_nodes = True
bsdf = m.node_tree.nodes["Principled BSDF"]
bsdf.inputs["Base Color"].default_value = (0.62, 0.60, 0.58, 1.0)
bsdf.inputs["Roughness"].default_value = 0.45
body.data.materials.append(m)
if a.wire:
body.modifiers.new("Wire", 'WIREFRAME').thickness = 0.0012
z_lo, z_hi = REGIONS[a.region]
pts = [body.matrix_world @ v.co for v in body.data.vertices]
band = [p for p in pts if z_lo <= p.z <= z_hi] or pts
ctr = Vector((0.0, 0.0, (z_lo + z_hi) / 2))
xs = [p.x for p in band]
ctr.y = sum(p.y for p in band) / len(band)
span = max(max(xs) - min(xs), z_hi - z_lo)
w = bpy.data.worlds.new("W")
w.color = (0.22, 0.22, 0.24)
bpy.context.scene.world = w
# three-point-ish lighting: a key sun plus a softer fill, so volume reads without the
# single-sun hotspot that flattens a bust into one bright blob
key = bpy.data.objects.new("Key", bpy.data.lights.new("Key", 'SUN'))
key.data.energy = 3.0
key.data.use_shadow = not a.no_shadow
key.rotation_euler = (math.radians(62), 0, math.radians(35 + a.yaw))
bpy.context.collection.objects.link(key)
fill = bpy.data.objects.new("Fill", bpy.data.lights.new("Fill", 'SUN'))
fill.data.energy = 1.1
fill.rotation_euler = (math.radians(75), 0, math.radians(a.yaw - 110))
bpy.context.collection.objects.link(fill)
cam = bpy.data.objects.new("Cam", bpy.data.cameras.new("Cam"))
cam.data.lens = 85
bpy.context.collection.objects.link(cam)
yaw = math.radians(a.yaw)
dist = span * 3.1
cam.location = ctr + Vector((math.sin(yaw) * dist, -math.cos(yaw) * dist, 0.0))
cam.rotation_euler = (ctr - cam.location).to_track_quat('-Z', 'Y').to_euler()
bpy.context.scene.camera = cam
scn = bpy.context.scene
scn.render.engine = 'BLENDER_EEVEE' if bpy.app.version >= (4, 2) else 'BLENDER_EEVEE_NEXT'
scn.render.resolution_x = scn.render.resolution_y = a.res
scn.render.filepath = a.out
bpy.ops.render.render(write_still=True)
print("CLOSEUP_OK", a.out)
main()
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env python
"""blender_bridge.py -- terminal client for the TinqsBlenderBridge server.
Talks newline-delimited JSON over a localhost TCP socket to the
TinqsBlenderBridge server running inside an open Blender session
(tools/blender_bridge/tinqs_blender_bridge.py -- started via Text Editor >
Run Script, or auto-started from scripts/startup/ after running
tools/install_blender_bridge.ps1). Unlike the MD bridge, Blender's UI stays
fully interactive while the bridge serves.
Same wire protocol and `result` convention as tools/iclone_bridge.py (18800)
and tools/md_bridge.py (18900), but on port 19000 so all three can run at
once.
Usage:
python tools/blender_bridge.py --ping
python tools/blender_bridge.py --exec "result = [o.name for o in bpy.data.objects]"
python tools/blender_bridge.py --file some_script.py
python tools/blender_bridge.py --stop
python tools/blender_bridge.py --port 19001 --timeout 120 --exec "..."
The exec namespace is persistent per Blender session and pre-seeded with
bpy, view3d_override() (a temp_override kwargs helper for viewport
operators), and a BRIDGE info dict.
"""
import argparse
import json
import socket
import sys
import time
DEFAULT_PORT = 19000
DEFAULT_TIMEOUT = 30.0
# Sent by --ping: bridge metadata seeded by the server at startup.
PING_CODE = "BRIDGE['blend_file'] = bpy.data.filepath or '<unsaved>'\nresult = BRIDGE\n"
def _send(sock, obj):
sock.sendall((json.dumps(obj) + "\n").encode("utf-8"))
def _recv_line(sock, timeout):
sock.settimeout(timeout)
buf = b""
while not buf.endswith(b"\n"):
chunk = sock.recv(4096)
if not chunk:
if buf:
break
raise ConnectionError(
"connection closed by the Blender bridge before a response was received")
buf += chunk
return buf.decode("utf-8")
def run(code, port=DEFAULT_PORT, timeout=DEFAULT_TIMEOUT):
"""Send `code` to be exec()'d inside the open Blender session.
Returns the parsed response dict. Raises OSError on transport failure;
the dict's "ok" key reports exec-level success/failure.
"""
req = {"id": int(time.time() * 1000), "code": code}
with socket.create_connection(("127.0.0.1", port), timeout=timeout) as sock:
_send(sock, req)
line = _recv_line(sock, timeout)
return json.loads(line)
def ping(port=DEFAULT_PORT, timeout=DEFAULT_TIMEOUT):
"""Convenience wrapper: run(PING_CODE, ...)."""
return run(PING_CODE, port=port, timeout=timeout)
def _read_code(args):
if args.exec is not None:
return args.exec
with open(args.file, "r", encoding="utf-8") as f:
return f.read()
def _main(argv=None):
p = argparse.ArgumentParser(
description="Client for the TinqsBlenderBridge socket server inside Blender.")
p.add_argument("--port", type=int, default=DEFAULT_PORT,
help="bridge TCP port (default: %(default)s; must match "
"TINQS_BLENDER_BRIDGE_PORT inside Blender if overridden there)")
p.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT,
help="socket timeout in seconds (default: %(default)s; raise "
"for long bakes/exports)")
group = p.add_mutually_exclusive_group(required=True)
group.add_argument("--ping", action="store_true",
help="check the bridge is alive; reports blender version, "
"open .blend, embedded python")
group.add_argument("--stop", action="store_true",
help="stop the bridge server inside Blender")
group.add_argument("--exec", metavar="CODE",
help="python source to exec() inside the open Blender")
group.add_argument("--file", metavar="PATH",
help="path to a python file to exec() inside the open Blender")
args = p.parse_args(argv)
try:
if args.ping:
resp = ping(port=args.port, timeout=args.timeout)
elif args.stop:
resp = run("__STOP__", port=args.port, timeout=args.timeout)
else:
resp = run(_read_code(args), port=args.port, timeout=args.timeout)
except ConnectionRefusedError:
print(
"error: connection refused on 127.0.0.1:{} -- is Blender running? "
"was the bridge started (Run Script or scripts/startup install)?".format(args.port),
file=sys.stderr,
)
return 1
except OSError as exc:
print("error: could not reach the Blender bridge on 127.0.0.1:{}: {}".format(
args.port, exc), file=sys.stderr)
return 1
if resp.get("stdout"):
text = resp["stdout"]
print(text, end="" if text.endswith("\n") else "\n")
if resp.get("ok"):
if args.ping:
info = resp.get("result") or {}
py = str(info.get("python", "?")).split()[0]
print("ok: Blender bridge v{} blender={} python {} file={}".format(
info.get("version", "?"), info.get("blender", "?"), py,
info.get("blend_file", "?")))
else:
print(json.dumps(resp.get("result"), indent=2))
return 0
print("error: {}".format(resp.get("error", "unknown error")), file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(_main())
@@ -0,0 +1,289 @@
"""TinqsBlenderBridge -- live-control socket server for a running Blender.
Runs a localhost-only TCP server inside Blender and executes Python source
sent to it, giving external tooling (Claude Code) live control of the open
Blender session via bpy. Counterpart to the iClone TinqsBridge (port 18800)
and TinqsMDBridge (port 18900); this one serves on port 19000.
Unlike MD, Blender has bpy.app.timers: the server polls a NON-BLOCKING
socket from a main-thread timer, so the UI stays fully interactive while
the bridge runs. Each request's exec() happens on the main thread (the only
safe place for bpy calls); the UI stalls only for the duration of that one
exec.
Install / start:
- One-off, in the open window: Scripting workspace > open this file >
Run Script. The bridge starts immediately.
- Auto-start on every launch: run tools/install_blender_bridge.ps1, which
copies this file into Blender's scripts/startup/ folder (startup modules
are imported at launch and their register() is called).
- Verify from a terminal: python tools/blender_bridge.py --ping
- Stop: python tools/blender_bridge.py --stop
Wire protocol (identical to the iClone/MD bridges): 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 other bridges):
- exec(code, ns, ns) against ONE persistent namespace, pre-seeded with
bpy, a BRIDGE info dict, and view3d_override() (see below). It survives
across requests; re-running this file re-seeds it.
- 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 bridge keeps serving.
Context caveat: timer callbacks run with NO window/area in bpy.context, so
operators that need one (most bpy.ops.view3d.*, some object ops) fail with
"context is incorrect". Prefer the data API (bpy.data, obj.location, ...).
When an operator genuinely needs a 3D viewport, the seeded helper gives you
an override:
with bpy.context.temp_override(**view3d_override()):
bpy.ops.view3d.view_selected()
Log file: %TEMP%/tinqs_blender_bridge.log
"""
import builtins
import contextlib
import io
import json
import os
import socket
import traceback
from datetime import datetime
import bpy
VERSION = 1
DEFAULT_PORT = 19000
HOST = "127.0.0.1"
POLL_INTERVAL_S = 0.1
CONN_READ_TIMEOUT_S = 10.0
SINGLETON_ATTR = "_tinqs_blender_bridge"
STOP_SENTINEL = "__STOP__"
LOG_PATH = os.path.join(
os.environ.get("TEMP", os.environ.get("TMP", ".")),
"tinqs_blender_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 _safe_blend_file():
try:
return bpy.data.filepath or "<unsaved>"
except AttributeError:
return "<unknown -- restricted context>"
def view3d_override():
"""Kwargs for bpy.context.temp_override() targeting the first 3D viewport."""
wm = bpy.data.window_managers[0]
for window in wm.windows:
for area in window.screen.areas:
if area.type == "VIEW_3D":
region = next(r for r in area.regions if r.type == "WINDOW")
return {"window": window, "area": area, "region": region}
raise RuntimeError("no VIEW_3D area found in any open window")
class _Bridge:
def __init__(self, port):
self.port = port
self.sock = None
self.served = 0
import sys
self.ns = {
"bpy": bpy,
"view3d_override": view3d_override,
}
self.ns["BRIDGE"] = {
"version": VERSION,
"mode": "timer",
"port": port,
"source": os.path.abspath(__file__) if "__file__" in globals() else "<text editor>",
"blender": bpy.app.version_string,
"python": sys.version,
# bpy.data is a restricted stub during startup-module registration
# (AttributeError on .filepath); --ping refreshes this live anyway.
"blend_file": _safe_blend_file(),
}
def start(self):
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((HOST, self.port))
sock.listen(5)
sock.setblocking(False)
self.sock = sock
bpy.app.timers.register(self._poll, persistent=True)
_log("TinqsBlenderBridge v{} serving on {}:{} blender={}".format(
VERSION, HOST, self.port, bpy.app.version_string))
print("TinqsBlenderBridge v{}: serving on {}:{} (UI stays interactive)".format(
VERSION, HOST, self.port))
print("Verify: python tools/blender_bridge.py --ping")
def stop(self, in_timer=False):
# Unregistering a timer from inside its own callback is an error in
# Blender -- when stopping from _poll, closing the socket is enough;
# the callback's `return None` retires the timer.
if not in_timer and bpy.app.timers.is_registered(self._poll):
bpy.app.timers.unregister(self._poll)
if self.sock is not None:
try:
self.sock.close()
except Exception:
pass
self.sock = None
_log("bridge stopped, served {}".format(self.served))
# -- timer callback: runs on the main thread every POLL_INTERVAL_S --
def _poll(self):
try:
conn, _addr = self.sock.accept()
except BlockingIOError:
return POLL_INTERVAL_S
except OSError:
_log("server socket died:\n{}".format(traceback.format_exc()))
self.stop(in_timer=True)
return None
try:
self._handle(conn)
except Exception:
_log("connection error:\n{}".format(traceback.format_exc()))
finally:
try:
conn.close()
except Exception:
pass
if self.sock is None: # _handle saw __STOP__
return None
return POLL_INTERVAL_S
def _handle(self, conn):
try:
req = self._read_request(conn)
except Exception:
self._respond(conn, {"id": None, "ok": False,
"error": "invalid JSON request", "stdout": ""})
return
if req is None:
return
code = req.get("code", "")
if code.strip() == STOP_SENTINEL:
self._respond(conn, {"id": req.get("id"), "ok": True,
"result": "stopped", "stdout": ""})
print("TinqsBlenderBridge: stopped by client (served {})".format(self.served))
self.stop(in_timer=True)
return
resp = self._execute(code)
resp["id"] = req.get("id")
self.served += 1
if not resp["ok"]:
last = resp["error"].strip().splitlines()[-1] if resp["error"] else "?"
_log("request id={} ok=false: {}".format(req.get("id"), last))
self._respond(conn, resp)
def _execute(self, code):
self.ns["result"] = None
out = io.StringIO()
try:
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(out):
exec(code, self.ns, self.ns)
result = self.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()}
@staticmethod
def _read_request(conn):
"""Read one newline-terminated JSON request. Returns dict or None."""
conn.setblocking(True)
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"))
@staticmethod
def _respond(conn, resp):
conn.sendall((json.dumps(resp) + "\n").encode("utf-8"))
def _start():
port = int(os.environ.get("TINQS_BLENDER_BRIDGE_PORT", DEFAULT_PORT))
# Re-running the script (Text Editor) or re-importing (startup) must not
# leak the previous socket/timer -- the singleton lives on builtins so it
# survives this module's namespace being rebuilt.
old = getattr(builtins, SINGLETON_ATTR, None)
if old is not None:
try:
old.stop()
_log("closed previous bridge instance before restart")
except Exception:
pass
setattr(builtins, SINGLETON_ATTR, None)
bridge = _Bridge(port)
try:
bridge.start()
except OSError:
_log("BIND FAILED on {}:{}:\n{}".format(HOST, port, traceback.format_exc()))
print("TinqsBlenderBridge: port {} busy -- another Blender serving? see {}".format(
port, LOG_PATH))
return
setattr(builtins, SINGLETON_ATTR, bridge)
def _deferred_start():
"""One-shot timer body: runs after startup, when the full API is live."""
try:
_start()
except Exception:
_log("deferred start CRASHED:\n{}".format(traceback.format_exc()))
return None
def register():
"""Called by Blender for modules in scripts/startup/ at launch.
Much of bpy is restricted during startup registration (bpy.data is a
_RestrictData stub), so we only schedule the real start here; the timer
fires once Blender is fully up.
"""
bpy.app.timers.register(_deferred_start, first_interval=0.1)
def unregister():
bridge = getattr(builtins, SINGLETON_ATTR, None)
if bridge is not None:
bridge.stop()
setattr(builtins, SINGLETON_ATTR, None)
if __name__ == "__main__":
_start()
+265
View File
@@ -0,0 +1,265 @@
#!/usr/bin/env python
"""glm_agent.py -- autonomous GLM (z.ai) agent loop with guarded shell execution.
Delegates a long grind to GLM so it runs unattended instead of burning Claude
context. GLM proposes one shell command at a time; this harness executes it in
the repo, feeds the output back, and repeats until GLM reports DONE or the wall
clock runs out.
python tools/glm_agent.py --task-file <brief.md> --minutes 120 \
[--model glm-4.6] [--log <path>] [--probe]
Protocol (plain text, not tool-calling -- more robust across providers). GLM
must answer with exactly one of:
COMMAND: <single-line shell command>
DONE: <summary of what was accomplished>
Safety (it runs unattended):
- cwd is pinned to the animation repo; commands run through bash.
- DENY list blocks destructive/irreversible/network-push actions outright.
- Per-command timeout, output truncation, and a hard wall-clock budget.
- Everything is logged to --log for review.
Key: Z_AI_GLM_API_KEY in tinqs-docs/.env (never printed, never committed).
NOTE: thinking must be DISABLED for this endpoint or content comes back empty.
"""
import argparse
import json
import os
import re
import subprocess
import sys
import time
import urllib.error
import urllib.request
REPO = r"C:\Users\Jeremy\tinqs\animation"
ENV_PATH = r"C:\Users\Jeremy\tinqs\tinqs-docs\.env"
ENDPOINT = "https://api.z.ai/api/paas/v4/chat/completions"
CMD_TIMEOUT_S = 900 # 15 min: a Blender stage can be slow
MAX_OUT_CHARS = 6000 # truncate tool output fed back to the model
MAX_STEPS = 2000 # effectively unlimited; the wall clock is the real budget
LOG_PATH = None # set in main(); used by the crash handler
# Blocked outright -- irreversible, or reaches outside this machine/task.
DENY = [
r"\brm\s+-[rf]", r"\brmdir\b", r"\bdel\s+/", r"Remove-Item",
r"\bgit\s+(push|commit|reset\s+--hard|clean|checkout\s+--|rebase|merge)",
r"\btinqs\s+(push|pull)", r"\bformat\b", r"\bshutdown\b", r"\breboot\b",
r"\bmkfs", r"\bdd\s+if=", r":\(\)\{", r"\bchmod\s+777\b",
r"\bcurl\b[^|]*\|\s*(ba)?sh", r"\bwget\b[^|]*\|\s*(ba)?sh",
r"\bpip\s+install", r"\bnpm\s+(install|i)\b",
r">\s*/dev/sd", r"\.env\b", r"\bZ_AI_GLM_API_KEY\b",
]
# Windows consoles/redirects default to cp1252; model replies contain unicode
# (≈, —, box drawing). Without this the whole run dies on a stray character.
for _s in (sys.stdout, sys.stderr):
try:
_s.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
def load_key():
with open(ENV_PATH, "r", encoding="utf-8") as f:
for line in f:
if line.strip().startswith("Z_AI_GLM_API_KEY="):
return line.split("=", 1)[1].strip()
raise SystemExit("Z_AI_GLM_API_KEY not found in " + ENV_PATH)
def chat(key, model, messages, timeout=180):
"""One completion. Thinking disabled -- required or content is empty."""
body = json.dumps({
"model": model,
"messages": messages,
"thinking": {"type": "disabled"},
"temperature": 0.2,
"max_tokens": 1500,
}).encode("utf-8")
req = urllib.request.Request(
ENDPOINT, data=body,
headers={"Authorization": "Bearer " + key,
"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=timeout) as r:
data = json.loads(r.read().decode("utf-8"))
return data["choices"][0]["message"]["content"]
def denied(cmd):
for pat in DENY:
if re.search(pat, cmd, re.IGNORECASE):
return pat
return None
# Absolute path: when this harness is launched detached (Start-Process / Task Scheduler) the
# child does not inherit a Git-Bash PATH, subprocess can't resolve "bash", and every command
# fails with FileNotFoundError — the agent then reports itself blocked and gives up.
BASH = r"C:\Program Files\Git\bin\bash.exe"
if not os.path.exists(BASH):
BASH = "bash"
def run_cmd(cmd):
try:
p = subprocess.run([BASH, "-lc", cmd], cwd=REPO, capture_output=True,
text=True, timeout=CMD_TIMEOUT_S)
out = (p.stdout or "") + (("\n[stderr]\n" + p.stderr) if p.stderr else "")
out = out.strip() or "(no output)"
if len(out) > MAX_OUT_CHARS:
out = out[:MAX_OUT_CHARS] + "\n...[truncated]"
return f"exit={p.returncode}\n{out}"
except subprocess.TimeoutExpired:
return f"exit=TIMEOUT after {CMD_TIMEOUT_S}s"
except Exception as exc:
return f"exit=HARNESS_ERROR {exc!r}"
SYSTEM = """You are an autonomous build agent working inside a git repo on Windows (Git Bash).
You act by emitting exactly ONE of these, and NOTHING else -- no markdown fences, no commentary:
COMMAND: <one single-line shell command>
DONE: <what you accomplished, and anything left unfinished>
Rules:
- ONE command per turn. Wait for its output before the next.
- Commands run with cwd = the animation repo root. Use relative paths.
- Prefer small, verifiable steps. Inspect before you change.
- To write files, use a heredoc on one line via printf/echo, or python -c.
- Never: git commit/push, rm -rf, install packages, touch .env or secrets.
- If a command fails, diagnose from its output and adapt. Do not repeat a
failing command unchanged.
- If you are blocked and cannot proceed, emit DONE: with a clear explanation
of the blocker and what you tried.
- Budget your steps; you have a wall-clock limit. Report DONE before you run out
if the goal is met."""
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--task-file")
ap.add_argument("--minutes", type=float, default=120.0)
ap.add_argument("--model", default="glm-4.6")
ap.add_argument("--log", default="glm_agent_run.log")
ap.add_argument("--probe", action="store_true",
help="connectivity/model check, then exit")
a = ap.parse_args()
key = load_key()
if a.probe:
for m in ("glm-4.6", "glm-4.5", "glm-5.2", "glm-4.5-air"):
try:
r = chat(key, m, [{"role": "user", "content":
"Reply with exactly: OK"}], timeout=60)
print(f"{m:12s} -> {r.strip()[:60]!r}")
except urllib.error.HTTPError as e:
print(f"{m:12s} -> HTTP {e.code}")
except Exception as e:
print(f"{m:12s} -> {type(e).__name__}")
return 0
global LOG_PATH
LOG_PATH = a.log
with open(a.task_file, "r", encoding="utf-8") as f:
task = f.read()
log = open(a.log, "a", encoding="utf-8", errors="replace", buffering=1)
def emit(s):
# never let a logging problem kill a long unattended run
try:
print(s, flush=True)
except Exception:
pass
try:
log.write(s + "\n")
except Exception:
pass
emit(f"\n===== GLM agent start {time.strftime('%Y-%m-%d %H:%M:%S')} "
f"model={a.model} budget={a.minutes}min =====")
messages = [{"role": "system", "content": SYSTEM},
{"role": "user", "content": task}]
deadline = time.time() + a.minutes * 60
api_fails = 0
for step in range(1, MAX_STEPS + 1):
left = deadline - time.time()
if left <= 0:
emit(f"\n!! wall-clock budget exhausted at step {step}")
break
try:
reply = chat(key, a.model, messages).strip()
api_fails = 0
except Exception as exc:
# Exponential backoff, capped at 10 min. A fixed 20 s retry on HTTP 429 hammers
# the rate limiter and can keep the key blocked indefinitely — one run spun for
# an hour straight without completing a single step that way.
api_fails += 1
wait = min(20 * (2 ** min(api_fails - 1, 5)), 600)
emit(f"[api error #{api_fails}] {exc!r} -- backing off {wait}s")
time.sleep(wait)
continue
emit(f"\n--- step {step} ({left/60:.0f} min left) ---\n{reply}")
messages.append({"role": "assistant", "content": reply})
if reply.upper().startswith("DONE"):
emit("\n===== agent reported DONE =====")
break
m = re.search(r"COMMAND:\s*(.+)", reply, re.DOTALL)
if not m:
messages.append({"role": "user", "content":
"Malformed. Reply with exactly 'COMMAND: <cmd>' or 'DONE: <summary>'."})
continue
cmd = m.group(1).strip().splitlines()[0].strip().strip("`")
bad = denied(cmd)
if bad:
emit(f"[DENIED by guard: {bad}]")
messages.append({"role": "user", "content":
f"BLOCKED by safety guard (pattern {bad}). "
"That action is not permitted. Choose another approach."})
continue
out = run_cmd(cmd)
emit(f"[output]\n{out}")
messages.append({"role": "user", "content": out})
# keep context bounded: drop oldest exchanges, keep system+task
if len(messages) > 40:
messages = messages[:2] + messages[-30:]
emit(f"\n===== GLM agent end {time.strftime('%Y-%m-%d %H:%M:%S')} =====")
log.close()
return 0
if __name__ == "__main__":
# A crash in an unattended run must be visible in the run log, not just on
# a stdout nobody is watching (this bit us once: UnicodeEncodeError killed
# a run silently and it looked like the agent had merely gone quiet).
try:
sys.exit(main())
except SystemExit:
raise
except BaseException:
import traceback as _tb
try:
with open(LOG_PATH or "glm_agent_run.log", "a",
encoding="utf-8", errors="replace") as _f:
_f.write("\n===== AGENT CRASHED =====\n" + _tb.format_exc() + "\n")
except Exception:
pass
raise
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env python
"""iclone_bridge.py -- terminal client for the TinqsBridge iClone 8 plugin.
Talks newline-delimited JSON over a localhost TCP socket to the TinqsBridge
plugin (tools/iclone_bridge/TinqsBridge/main.py, installed into iClone via
tools/install_iclone_bridge.ps1). iClone must be running with the plugin
loaded (i.e. restarted at least once since install) for any of this to work.
Python 3.12, stdlib only -- no third-party packages.
Usage:
python tools/iclone_bridge.py --ping
python tools/iclone_bridge.py --exec "result = 1 + 1"
python tools/iclone_bridge.py --file some_script.py
python tools/iclone_bridge.py --port 18801 --timeout 120 --exec "..."
See docs/iclone-bridge.md for the wire protocol, the `result` convention, and
the persistent-namespace / threading rules.
"""
import argparse
import json
import socket
import sys
import time
DEFAULT_PORT = 18800
DEFAULT_TIMEOUT = 30.0
# Sent by --ping. Finds product/version via RApplication, embedded Python via
# sys.version -- see RLPy.py (RApplication.GetProductName/GetProductVersion).
PING_CODE = (
"import sys, RLPy\n"
"result = {\n"
" 'product': RLPy.RApplication.GetProductName(),\n"
" 'version': RLPy.RApplication.GetProductVersion(),\n"
" 'python': sys.version,\n"
"}\n"
)
def _send(sock, obj):
sock.sendall((json.dumps(obj) + "\n").encode("utf-8"))
def _recv_line(sock, timeout):
sock.settimeout(timeout)
buf = b""
while not buf.endswith(b"\n"):
chunk = sock.recv(4096)
if not chunk:
if buf:
break
raise ConnectionError(
"connection closed by the iClone bridge before a response was received")
buf += chunk
return buf.decode("utf-8")
def run(code, port=DEFAULT_PORT, timeout=DEFAULT_TIMEOUT):
"""Send `code` to be exec()'d inside iClone. Returns the parsed response dict.
Raises OSError (e.g. ConnectionRefusedError, socket.timeout) on transport
failure; the returned dict's "ok" key reports exec-level success/failure.
"""
req = {"id": int(time.time() * 1000), "code": code}
with socket.create_connection(("127.0.0.1", port), timeout=timeout) as sock:
_send(sock, req)
line = _recv_line(sock, timeout)
return json.loads(line)
def ping(port=DEFAULT_PORT, timeout=DEFAULT_TIMEOUT):
"""Convenience wrapper: run(PING_CODE, ...)."""
return run(PING_CODE, port=port, timeout=timeout)
def _read_code(args):
if args.exec is not None:
return args.exec
with open(args.file, "r", encoding="utf-8") as f:
return f.read()
def _main(argv=None):
p = argparse.ArgumentParser(
description="Client for the TinqsBridge iClone 8 socket plugin.")
p.add_argument("--port", type=int, default=DEFAULT_PORT,
help="bridge TCP port (default: %(default)s; must match "
"TINQS_ICLONE_BRIDGE_PORT inside iClone if overridden there)")
p.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT,
help="socket timeout in seconds (default: %(default)s)")
group = p.add_mutually_exclusive_group(required=True)
group.add_argument("--ping", action="store_true",
help="check the bridge is alive; reports product/version/embedded python")
group.add_argument("--exec", metavar="CODE",
help="python source to exec() inside iClone")
group.add_argument("--file", metavar="PATH",
help="path to a python file to exec() inside iClone")
args = p.parse_args(argv)
try:
if args.ping:
resp = ping(port=args.port, timeout=args.timeout)
else:
resp = run(_read_code(args), port=args.port, timeout=args.timeout)
except ConnectionRefusedError:
print(
"error: connection refused on 127.0.0.1:{} -- is iClone running? "
"was it restarted after installing the TinqsBridge plugin?".format(args.port),
file=sys.stderr,
)
return 1
except OSError as exc:
print("error: could not reach the iClone bridge on 127.0.0.1:{}: {}".format(
args.port, exc), file=sys.stderr)
return 1
if resp.get("stdout"):
text = resp["stdout"]
print(text, end="" if text.endswith("\n") else "\n")
if resp.get("ok"):
if args.ping:
result = resp.get("result") or {}
py_ver = str(result.get("python", "?")).split()[0]
print("ok: {} {} (embedded python {})".format(
result.get("product", "?"), result.get("version", "?"), py_ver))
else:
print(json.dumps(resp.get("result"), indent=2))
return 0
print("error: {}".format(resp.get("error", "unknown error")), file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(_main())
+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()))
+50
View File
@@ -0,0 +1,50 @@
<#
.SYNOPSIS
Installs (or updates) the TinqsBlenderBridge auto-start module.
.DESCRIPTION
Copies tools/blender_bridge/tinqs_blender_bridge.py (the server's source
of truth in this repo) into Blender's user scripts/startup/ folder.
Startup modules are imported at every Blender launch and their register()
is called, so the bridge auto-starts on port 19000 from the next launch
onward. Idempotent -- safe to re-run after editing the server source.
This does NOT start the bridge in an already-open Blender. For that, in
the open window: Scripting workspace > open the repo copy > Run Script.
.EXAMPLE
powershell -File tools\install_blender_bridge.ps1
#>
$ErrorActionPreference = "Stop"
$RepoRoot = Split-Path -Parent $PSScriptRoot
$SourceFile = Join-Path $RepoRoot "tools\blender_bridge\tinqs_blender_bridge.py"
$BlenderCfg = Join-Path $env:APPDATA "Blender Foundation\Blender"
if (-not (Test-Path $SourceFile)) {
Write-Error "Bridge source not found: $SourceFile"
exit 1
}
$versions = Get-ChildItem -Directory $BlenderCfg | Where-Object { $_.Name -match '^\d+\.\d+$' }
if (-not $versions) {
Write-Error "No Blender version folders under $BlenderCfg -- has Blender been run at least once?"
exit 1
}
foreach ($v in $versions) {
$startupDir = Join-Path $v.FullName "scripts\startup"
New-Item -ItemType Directory -Force -Path $startupDir | Out-Null
Copy-Item -Path $SourceFile -Destination $startupDir -Force
Write-Host "Installed to: $(Join-Path $startupDir 'tinqs_blender_bridge.py')" -ForegroundColor Cyan
}
Write-Host ""
Write-Host "The bridge auto-starts (port 19000) on every FUTURE Blender launch." -ForegroundColor Yellow
Write-Host "For the currently open Blender: Scripting workspace > Open > " -ForegroundColor Yellow
Write-Host " $SourceFile" -ForegroundColor Yellow
Write-Host " then Run Script (the play button)." -ForegroundColor Yellow
Write-Host ""
Write-Host "Verify: python tools\blender_bridge.py --ping"
Write-Host "Log file once running: `$env:TEMP\tinqs_blender_bridge.log"
+53
View File
@@ -0,0 +1,53 @@
<#
.SYNOPSIS
Installs (or updates) the TinqsBridge iClone 8 plugin.
.DESCRIPTION
Copies tools/iclone_bridge/TinqsBridge/ (the plugin's source of truth in
this repo) into iClone 8's auto-load plugin folder, overwriting any
existing copy there. Idempotent -- safe to re-run any time after editing
the plugin source; it will not touch any other plugin under OpenPlugin.
iClone must be RESTARTED to pick up a new or changed plugin. This script
never touches the running iClone process -- it only copies files.
.EXAMPLE
powershell -File tools\install_iclone_bridge.ps1
#>
$ErrorActionPreference = "Stop"
$RepoRoot = Split-Path -Parent $PSScriptRoot
$SourceDir = Join-Path $RepoRoot "tools\iclone_bridge\TinqsBridge"
$DestRoot = "A:\Program Files (x86)\iClone 8\Bin64\OpenPlugin"
$DestDir = Join-Path $DestRoot "TinqsBridge"
if (-not (Test-Path $SourceDir)) {
Write-Error "Plugin source not found: $SourceDir"
exit 1
}
if (-not (Test-Path $DestRoot)) {
Write-Error "iClone OpenPlugin folder not found: $DestRoot (is iClone 8 installed at the expected path?)"
exit 1
}
New-Item -ItemType Directory -Force -Path $DestDir | Out-Null
$copied = Copy-Item -Path (Join-Path $SourceDir "*") -Destination $DestDir -Recurse -Force -PassThru
Write-Host ""
Write-Host "TinqsBridge plugin installed to:" -ForegroundColor Cyan
Write-Host " $DestDir"
Write-Host ""
Write-Host "Files copied:"
foreach ($item in $copied) {
if (-not $item.PSIsContainer) {
Write-Host " $($item.FullName.Substring($DestDir.Length + 1))"
}
}
Write-Host ""
Write-Host "iClone must be RESTARTED to load this plugin (or pick up changes to it)." -ForegroundColor Yellow
Write-Host "Have Jeremy save his work and restart iClone before running any smoke tests." -ForegroundColor Yellow
Write-Host ""
Write-Host "Log file once running: `$env:TEMP\tinqs_iclone_bridge.log"
+666
View File
@@ -0,0 +1,666 @@
# Build the NUDE Lena body variant from the CANONICAL body: erase the sculpted-in underwear
# (geometry AND its rim creases), sculpt anatomical breasts procedurally, swap in the nude
# albedo, export a structurally-identical GLB.
#
# blender --background --python tools/make_lena_nude_body.py -- --out <path.glb> [--size 1.0]
# [--texture <png>] [--texture-name <n>] [--probe] [--dump-mask <glb>]
# [--no-fair] [--no-breasts]
#
# WHERE THE OUTPUT GOES. While this body is work in progress it is staged in THIS repo at
# characters/female/lena_nude/lena_nude_quatskin_glb_v01.glb
# and NOT next to the canonical bodies in ariki-game. characters/REGISTRY.md rule 8 (the boundary
# law) reserves PascalCase "Ariki_*" for game-repo ship names: the rename to
# Ariki_Female_QuatSkin_Nude.glb happens exactly once, at release into
# ariki-game/assets/quaternius/derived-bodies/. --out is required so that choice is always
# deliberate.
#
# ---------------------------------------------------------------------------------------------
# WHY THIS SHAPE OF SOLUTION (findings that killed the obvious approaches — v1 AND v2)
#
# 1. THE UNDERWEAR IS THE BODY SURFACE. A ray-crossing census through the torso finds exactly
# two surface crossings at every height — thigh, belly, bra cup, briefs alike. There is no
# skin underneath the bra or briefs: they are the single body skin, modelled flush (proudness
# +0.41 mm vs +0.34 mm for plain torso skin) with a hard rim crease, and painted beige.
# DELETING garment faces opens a hole — that is the defect in Ariki_Female_QuatSkin_Bare.glb,
# which renders a black cavity across the chest even untextured.
#
# 2. THE GARMENT INTERIOR *IS* THE ANATOMY. The briefs surface is her butt and hips; a membrane
# fill of the whole footprint would flatten them. So the interior is left alone and only a
# NARROW BAND along the garment boundary is re-faired (bi-Laplacian flow, fixed outside the
# band) — that is where the visible crease lives, and a narrow band cannot erase anatomy.
# v1 instead applied a Taubin reference clamped to 2.5 mm over the whole footprint: the clamp
# protected the hips but also PRESERVED THE CREASE, which is exactly what Jeremy could still
# see. Removing proudness is not the same as removing an edge.
#
# 3. CHESTV1 IS GONE. v1 projected the chest onto the ChestV1 experiment, but that sculpt was
# made OVER the bra — the result read as "a bra with volume" (bandeau silhouette, bra
# neckline crease). v2 smooths the chest back to a plain chest wall and sculpts breasts
# PROCEDURALLY from anatomy: elliptical root on the ribcage, teardrop profile (fuller lower
# pole, long gradual upper slope), apex slightly lateral, true cleavage separation, C1 blend
# at the root by construction. No nipples by construction — the field is smooth.
#
# 4. NO VERTEX CORRESPONDENCE EXISTS between body variants (the exporter reorders verts; the UV
# atlas mirrors islands left/right with intra-key spreads to 1.77 m), so nothing here ever
# relies on matching vertices across files.
#
# 5. THE IMPORTER DOES NOT WELD UV SEAMS. Every glTF vertex stays a separate Blender vertex, so
# the mesh is topologically shattered along every seam (23,978 "boundary" edges, only ~3.2k
# real). All geometry runs over a POSITION-WELDED view; displacing seam-split copies of one
# point differently would tear the mesh open.
#
# 6. THE MESH IS NOT WATERTIGHT (3,809 boundary edges). The painted underwear was hiding open
# slits that read as a dotted line of black triangles once it is gone — they are welded shut
# in the torso band before anything else measures the mesh.
#
# Also: every one of these GLBs carries a stray unparented 42-vert "Icosphere" (radius 1.0)
# beside the body. It comes from stock, so it is kept for structural parity — but the body must
# always be picked by max vertex count, never as "the first MESH object".
import bpy, bmesh, sys, os, math, argparse
from collections import defaultdict, deque
from mathutils import Vector
from mathutils.kdtree import KDTree
import numpy as np
# This tool lives in the ANIMATION repo (moved from ariki-game 2026-08-06: it authors characters,
# and characters/REGISTRY.md governs its subjects). The canonical RIGGED bodies it reads still
# live in the game repo, so that path is RESOLVED, never assumed: $ARIKI_GAME wins, otherwise a
# sibling checkout is guessed and then verified. An ariki-relative default that silently fails to
# resolve is a known trap here — see .agents/wiki/ARCHITECTURE.md on the retargeters' --target.
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
GAME = os.environ.get("ARIKI_GAME") or os.path.abspath(os.path.join(REPO, os.pardir, "ariki-game"))
BODIES = os.path.join(GAME, "assets/quaternius/derived-bodies")
STOCK = os.path.join(BODIES, "Ariki_Female_QuatSkin.glb")
STOCK_TEX = os.path.join(BODIES, "Ariki_Female_QuatSkin_Lena_Body_Toned.png")
def game_asset(path, flag):
"""Fail loudly on an unresolved cross-repo path instead of reading the wrong file."""
if not os.path.isfile(path):
raise SystemExit(
f"[nude] FATAL: cannot find {os.path.basename(path)}\n"
f" looked in: {path}\n"
f" That is an ariki-game asset. Pass {flag} explicitly, or set ARIKI_GAME\n"
f" to your ariki-game checkout (the sibling-directory guess failed).")
return path
# --- underwear colour key (measured per-vertex against the stock albedo) -----------------
# fabric: sat ~0.35-0.37, R/B ~1.55 | skin: sat 0.55-0.66, R/B 2.2-3.0
SAT_MAX = 0.47
RB_MAX = 1.95
UW_Z_LO, UW_Z_HI = 0.80, 1.52 # torso incl. shoulder straps; keeps eyes/teeth out (head)
UW_X_MAX = 0.30 # T-pose guard: keeps low-saturation fingernails out (arms)
MASK_GROW = 2 # ring steps, to swallow the rim crease in the weight mask
# --- rim-band fairing ---------------------------------------------------------------------
BAND_RINGS = 3 # band half-width in mesh rings each side of the boundary
# --- chest wall + procedural breasts ------------------------------------------------------
CHEST_BONES = ("spine_02", "spine_03")
CHEST_W_MIN = 0.35
CHEST_Z_LO, CHEST_Z_HI = 1.11, 1.38 # bottom under the bra band's bulge (z≈1.19); top capped
CHEST_Z_FADE = 0.04 # BELOW the clavicles (z≈1.40) so their definition survives
CHEST_FRONT_MAX_Y = 0.02
CHEST_FREE_MIN = 0.25 # mask weight above which a group is a free membrane vert
# Calibrated against Lena's OWN source art: the 974k-vert Tripo sculpt has real breast anatomy
# modelled under its bra shell (apex z=1.248 on a 1.777 m body, apexes +/-6.0 cm from midline,
# ~12.7 cm base diameter, underbust line z=1.163, ~3.0 cm bare-flesh projection after
# subtracting the ~1.1 cm bra shell). Projecting from that sculpt directly is impossible — the
# bra IS its skin, same construction as the game body — so it serves as the metrics source.
BREAST = { # defaults; --size scales depth and radii together
"cx": 0.061, # root centre, metres from midline (apex measured at 0.060)
"cz": 1.262, # root centre height
"rx": 0.058, # root half-width (base ~12.7 cm across)
"rz": 0.070, # root half-height (before the teardrop asymmetry)
"depth": 0.050, # apex projection off the chest wall (wall sits ~15 mm behind
# the old bra surface, so this reads smaller than it sounds)
"upper_stretch": 1.45, # reach above centre = rz*this: long slope to z~1.35
"lower_stretch": 1.15, # reach below centre = rz*this: lower pole to z~1.167,
# matching the sculpt's underbust line
"apex_drop": 0.014, # amplitude peak sits this far below the root centre (z~1.248)
"gap": 0.008, # half-width of the cleavage dead zone at the sternum
"gap_w": 0.030, # cleavage falloff width — wide, so the forms meet softly
}
NORMAL_RING_FADE = 2 # rings of old->new normal transition outside touched area
def log(m):
print(f"[nude] {m}", flush=True)
def smoothstep(x):
x = max(0.0, min(1.0, x))
return x * x * (3.0 - 2.0 * x)
# =============================================================================================
# mesh plumbing
# =============================================================================================
def import_body(path):
before = set(bpy.data.objects)
bpy.ops.import_scene.gltf(filepath=path)
new = [o for o in bpy.data.objects if o not in before]
meshes = [o for o in new if o.type == 'MESH']
body = max(meshes, key=lambda o: len(o.data.vertices))
rig = next((o for o in new if o.type == 'ARMATURE'), None)
log(f"imported {os.path.basename(path)}: '{body.name}' {len(body.data.vertices)}v/"
f"{len(body.data.polygons)}f" + (f" + {[o.name for o in meshes if o is not body]}"
if len(meshes) > 1 else ""))
return body, rig
class Welded:
"""Position-welded view of a mesh: groups of coincident Blender vertices, their shared
position/normal, and true surface adjacency across UV seams."""
def __init__(self, me, prec=6):
self.me = me
buckets = defaultdict(list)
for v in me.vertices:
buckets[(round(v.co.x, prec), round(v.co.y, prec), round(v.co.z, prec))].append(v.index)
self.groups = list(buckets.values())
self.gov = [0] * len(me.vertices)
for gi, members in enumerate(self.groups):
for vi in members:
self.gov[vi] = gi
self.n = len(self.groups)
self.adj = [set() for _ in range(self.n)]
for e in me.edges:
a, b = self.gov[e.vertices[0]], self.gov[e.vertices[1]]
if a != b:
self.adj[a].add(b)
self.adj[b].add(a)
self.co = np.array([[*me.vertices[m[0]].co] for m in self.groups], dtype=np.float64)
nrm = np.zeros((self.n, 3))
for gi, m in enumerate(self.groups):
acc = Vector((0, 0, 0))
for vi in m:
acc += me.vertices[vi].normal
nrm[gi] = [*acc]
ln = np.linalg.norm(nrm, axis=1, keepdims=True)
self.nrm = nrm / np.maximum(ln, 1e-12)
# flat adjacency for fast numpy smoothing
idx, ptr = [], [0]
for gi in range(self.n):
idx.extend(self.adj[gi])
ptr.append(len(idx))
self.adj_idx = np.array(idx, dtype=np.int64)
self.adj_ptr = np.array(ptr, dtype=np.int64)
self.adj_cnt = np.maximum(np.diff(self.adj_ptr), 1)
def neighbour_mean(self, P):
sums = np.add.reduceat(P[self.adj_idx], self.adj_ptr[:-1], axis=0)
empty = np.diff(self.adj_ptr) == 0
sums[empty] = P[empty]
return sums / self.adj_cnt[:, None]
def laplacian(self, P):
return self.neighbour_mean(P) - P
def taubin(self, P, iters, lam=0.55, mu=-0.58, mask=None):
"""Taubin lambda/mu smoothing — shrink then inflate, so the surface sheds detail
without deflating the way plain Laplacian smoothing would over many passes."""
Q = P.copy()
w = None if mask is None else mask[:, None]
for _ in range(iters):
for f in (lam, mu):
d = self.laplacian(Q) * f
Q += d if w is None else d * w
return Q
def grow(self, sel, rings):
"""Boolean selection grown over welded adjacency."""
out = sel.copy()
for _ in range(rings):
nxt = out.copy()
for gi in np.nonzero(out)[0]:
for nb in self.adj[gi]:
nxt[nb] = True
out = nxt
return out
def group_normals(self, P):
"""Fresh area-weighted normals per welded group computed from positions P — without
touching the mesh, so intermediate stages can direct displacement off the CURRENT
surface rather than the imported one."""
acc = np.zeros((self.n, 3))
for p in self.me.polygons:
vs = [self.gov[vi] for vi in p.vertices]
n = np.cross(P[vs[1]] - P[vs[0]], P[vs[2]] - P[vs[0]])
for gi in vs:
acc[gi] += n
ln = np.linalg.norm(acc, axis=1, keepdims=True)
return acc / np.maximum(ln, 1e-12)
def write(self, P):
for gi, members in enumerate(self.groups):
c = Vector(P[gi])
for vi in members: # every seam copy moves alike -> no tearing
self.me.vertices[vi].co = c
def close_rim_slits(body, dist_mm=4.0, zband=(0.80, 1.45)):
"""Weld shut the open slits in the torso band (see header, finding 6). Welding is preferred
over hole-filling: it adds no faces, so no new UVs have to be invented. Exact-duplicate
merging first recovers true topology (UV-seam splits masquerade as boundaries); merging
position duplicates is safe — UVs live per face corner and survive the merge. The band stays
below the head so mouth/nostril/ear openings are never candidates."""
me = body.data
bm = bmesh.new()
bm.from_mesh(me)
v0 = len(bm.verts)
bmesh.ops.remove_doubles(bm, verts=list(bm.verts), dist=1e-5)
bm.verts.ensure_lookup_table()
seams = len([e for e in bm.edges if len(e.link_faces) == 1])
sel = [v for v in bm.verts
if zband[0] <= v.co.z <= zband[1]
and any(len(e.link_faces) == 1 for e in v.link_edges)]
if sel:
bmesh.ops.remove_doubles(bm, verts=sel, dist=dist_mm / 1000.0)
after = len([e for e in bm.edges if len(e.link_faces) == 1])
bm.to_mesh(me)
bm.free()
me.update()
log(f"rim slits: {v0} -> {len(me.vertices)} verts; boundary edges {seams} -> {after} "
f"({len(sel)} band verts welded at {dist_mm} mm)")
# =============================================================================================
# 1. garment mask + rim-band fairing
# =============================================================================================
def sample_albedo(me, png):
img = bpy.data.images.load(png, check_existing=True)
w, h = img.size
buf = np.empty(w * h * 4, dtype=np.float32)
img.pixels.foreach_get(buf)
px = buf.reshape(h, w, 4)[:, :, :3]
uvl = me.uv_layers.active.data
first = {}
for p in me.polygons:
for li in p.loop_indices:
vi = me.loops[li].vertex_index
if vi not in first:
first[vi] = uvl[li].uv[:]
return px, w, h, first
def garment_groups(me, wl, png):
"""Boolean per welded group: is this point painted fabric? (bpy-imported UVs — already
v-flipped by the importer, unlike raw-parsed glTF UVs; see _bake_nude_body_texture.py)."""
px, w, h, first = sample_albedo(me, png)
fabric = np.zeros(wl.n, dtype=bool)
for gi, members in enumerate(wl.groups):
x_, y_, z_ = wl.co[gi]
if not (UW_Z_LO <= z_ <= UW_Z_HI) or abs(x_) > UW_X_MAX:
continue
uv = next((first[vi] for vi in members if vi in first), None)
if uv is None:
continue
xi = int(min(max(uv[0], 0.0), 1.0) * (w - 1))
yi = int(min(max(uv[1], 0.0), 1.0) * (h - 1))
r, g, b = px[yi, xi]
mx, mn = max(r, g, b), min(r, g, b)
sat = (mx - mn) / mx if mx > 1e-5 else 0.0
if sat < SAT_MAX and r / max(b, 1e-5) < RB_MAX:
fabric[gi] = True
# component filter kills stitch-pixel speckle
keep = np.zeros_like(fabric)
seen = np.zeros_like(fabric)
for s in np.nonzero(fabric)[0]:
if seen[s]:
continue
q = deque([s]); seen[s] = True; comp = [s]
while q:
c = q.popleft()
for nb in wl.adj[c]:
if fabric[nb] and not seen[nb]:
seen[nb] = True; q.append(nb); comp.append(nb)
if len(comp) >= 40:
keep[comp] = True
log(f"garment mask: {fabric.sum()} colour-keyed -> {keep.sum()} after component filter")
return keep
def membrane_fair(wl, free, P, collar_rings=2, quiet=False):
"""Replace the surface over `free` with a bi-harmonic membrane spanning its surroundings:
solve L²x = 0 with two collar rings held fixed (position + slope boundary conditions),
as a DIRECT dense solve.
Direct rather than iterative flow on purpose: explicit bi-Laplacian flow needs on the order
of diameter^4 sweeps to converge, which is fine for a several-ring band and hopeless for the
whole chest (~40 rings across — the same class of mistake as the v1 texture fill's 400
Jacobi passes). And Taubin smoothing is the wrong tool entirely here: it removes
high-frequency detail but PRESERVES low-frequency shape by design, and the bra bulge is
low-frequency — v2's first attempt left a 42 mm cliff at the old neckline (z=1.304) to
prove it. The membrane deletes the shape and re-derives it from the ribcage boundary."""
collar = wl.grow(free, collar_rings) & ~free
S = np.nonzero(free | collar)[0]
loc = {g: i for i, g in enumerate(S)}
n = len(S)
L = np.zeros((n, n))
for i, g in enumerate(S):
L[i, i] = -1.0
nbs = wl.adj[g]
if not nbs:
L[i, i] = 0.0
continue
wnb = 1.0 / len(nbs)
for nb in nbs:
if nb in loc:
L[i, loc[nb]] += wnb
# neighbours outside S contribute to the mean as constants; fold them into the
# right-hand side by treating them as extra fixed columns below
# constant contribution from neighbours outside S
C = np.zeros((n, 3))
for i, g in enumerate(S):
nbs = wl.adj[g]
if not nbs:
continue
wnb = 1.0 / len(nbs)
for nb in nbs:
if nb not in loc:
C[i] += wnb * P[nb]
# L(x) over S = Lmat@x + C, with C the folded contribution of fixed neighbours outside S.
# Free verts sit >=2 rings inside, so L²(x)|free = (Lmat²@x + Lmat@C)|free exactly.
M = L @ L
free_rows = np.array([i for i, g in enumerate(S) if free[g]])
fixed_rows = np.array([i for i, g in enumerate(S) if not free[g]])
x_fixed = P[S[fixed_rows]]
A = M[np.ix_(free_rows, free_rows)]
rhs = -(M[np.ix_(free_rows, fixed_rows)] @ x_fixed) - (L @ C)[free_rows]
Q = P.copy()
try:
sol = np.linalg.solve(A, rhs)
except np.linalg.LinAlgError:
sol = np.linalg.lstsq(A, rhs, rcond=None)[0]
log("membrane: singular system, used lstsq")
Q[S[free_rows]] = sol
if not quiet:
moved = np.linalg.norm(Q - P, axis=1)
log(f"membrane: {free.sum()} free / {collar.sum()} collar, "
f"max moved {moved.max()*1000:.1f} mm")
return Q
def fair_rim_band(wl, garment, P, rings=BAND_RINGS):
"""Erase the garment's rim creases: exact bi-harmonic membrane over a narrow band each side
of every garment/skin boundary, everything outside the band held fixed.
The band is deliberately narrow (finding 2 in the header): the garment interior IS her
anatomy, so only the crease line is rebuilt. The band splits into separate closed loops
(neckline, armholes, bra band, straps, waistband, leg holes), and each loop is solved with
the DIRECT membrane solver independently — a few hundred verts each, so the dense solve is
trivial. The first version ran explicit bi-Laplacian flow over the whole band instead; at
400 sweeps it had merely SOFTENED the creases (the ghost detector still flagged the
racerback's neck scoop, armholes and band on the back at 1.3-1.5x control), because
explicit flow needs ~diameter^4 sweeps and never truly converges."""
boundary = np.zeros(wl.n, dtype=bool)
for gi in np.nonzero(garment)[0]:
for nb in wl.adj[gi]:
if not garment[nb]:
boundary[gi] = True
boundary[nb] = True
band = wl.grow(boundary, rings)
log(f"rim band: {boundary.sum()} boundary groups -> {band.sum()} in band (+/-{rings} rings)")
# split the band into connected loops
seen = np.zeros(wl.n, dtype=bool)
comps = []
for s in np.nonzero(band)[0]:
if seen[s]:
continue
q = deque([s]); seen[s] = True; comp = [s]
while q:
c = q.popleft()
for nb in wl.adj[c]:
if band[nb] and not seen[nb]:
seen[nb] = True; q.append(nb); comp.append(nb)
comps.append(comp)
log(f"rim band: {len(comps)} loops "
f"({', '.join(str(len(c)) for c in sorted(comps, key=len, reverse=True)[:6])}...)")
Q = P.copy()
for comp in comps:
if len(comp) < 8:
continue
free = np.zeros(wl.n, dtype=bool)
free[comp] = True
Q = membrane_fair(wl, free, Q, quiet=True)
moved = np.linalg.norm(Q - P, axis=1)
idx = np.nonzero(band)[0]
log(f"rim fairing: max {moved.max()*1000:.2f} mm, median(band) "
f"{np.median(moved[idx])*1000:.2f} mm")
return Q, band
# =============================================================================================
# 2. chest wall + procedural breasts
# =============================================================================================
def chest_mask(body, wl):
"""Anatomical bust mask: skin weight on the spine bones that carry the bust, intersected
with a Z window capped below the clavicles. A raw Z slice is useless in a T-pose — arms and
hands cross any band containing the bust."""
gi_of = {g.name: g.index for g in body.vertex_groups}
bones = {gi_of[b] for b in CHEST_BONES if b in gi_of}
if not bones:
raise SystemExit("[nude] FATAL: chest bones missing from vertex groups")
me = body.data
w = np.zeros(wl.n)
for gi, members in enumerate(wl.groups):
v = me.vertices[members[0]]
co = v.co
if co.z < CHEST_Z_LO - CHEST_Z_FADE or co.z > CHEST_Z_HI + CHEST_Z_FADE \
or co.y > CHEST_FRONT_MAX_Y:
continue
skin = sum(g.weight for g in v.groups if g.group in bones)
if skin < CHEST_W_MIN:
continue
a = smoothstep((skin - CHEST_W_MIN) / 0.25)
a *= smoothstep((co.z - (CHEST_Z_LO - CHEST_Z_FADE)) / CHEST_Z_FADE)
a *= smoothstep(((CHEST_Z_HI + CHEST_Z_FADE) - co.z) / CHEST_Z_FADE)
a *= smoothstep((CHEST_FRONT_MAX_Y - co.y) / 0.04)
w[gi] = a
log(f"chest mask: {(w > 0.01).sum()} welded groups")
return w
def breast_field(wl, P, mask, size):
"""Sculpt two anatomical breasts onto the (already smoothed) chest wall.
The shape is built from anatomy, not from any donor mesh: an elliptical root attachment on
the ribcage, a teardrop profile — the vertical coordinate is stretched above the root centre
(long gradual slope toward the collarbone, no shelf) and compressed below it (full lower
pole), the amplitude peak sits slightly below centre (apex_drop), and a smoothstep dead zone
at the sternum separates the two forms with real cleavage. Displacement runs along the local
chest-wall normal. The radial profile (1-u^2)^2 has zero slope at u=1, so the root blends C1
into the wall by construction, and the field is smooth everywhere — no nipple can exist.
Eligibility is geometric (front hemisphere, torso half-width, the lobes' own z window), NOT
the chest mask — the mask's front-Y taper fades exactly where the outer root lands, and
gating by it cut a notch into the outer-upper quadrant of each breast. The profile vanishes
at the root on its own; nothing else may taper it."""
p = dict(BREAST)
p["depth"] *= size
p["rx"] *= math.sqrt(size)
p["rz"] *= math.sqrt(size)
nrm = wl.group_normals(P)
Q = P.copy()
applied = []
max_d = 0.0
for gi in range(wl.n):
x, y, z = P[gi]
if y > 0.05 or abs(x) > 0.13:
continue
s = 1.0 if x >= 0 else -1.0
dx = x - s * p["cx"]
dz = z - (p["cz"] - p["apex_drop"])
dz_eff = dz / (p["upper_stretch"] if dz > 0 else p["lower_stretch"]) # reach = rz*stretch
u2 = (dx / p["rx"]) ** 2 + (dz_eff / p["rz"]) ** 2
if u2 >= 1.0:
continue
# (1-u^2.4)^1.6: flatter dome / rounder shoulder than (1-u^2)^2, still zero-slope at
# the root (exponent > 1) so the C1 blend survives
a = p["depth"] * (1.0 - u2 ** 1.2) ** 1.6
a *= smoothstep((abs(x) - p["gap"]) / p["gap_w"]) # cleavage separation
if a <= 1e-6:
continue
Q[gi] += nrm[gi] * a
applied.append(gi)
max_d = max(max_d, a)
log(f"breasts: size={size}, {len(applied)} groups displaced, apex {max_d*1000:.1f} mm")
lobes = np.zeros(wl.n, dtype=bool)
lobes[applied] = True
return Q, lobes
# =============================================================================================
# 3. normals / texture / export
# =============================================================================================
def rebuild_normals(body, wl, touched):
"""Fresh area-weighted smooth normals over the WELDED topology wherever geometry changed,
with a short ring-distance fade into the original custom split normals outside. v1 blended
old->new by displacement magnitude, which PRESERVED the crease shading where the crease
geometry had barely moved — the old baked normals are part of what made the underwear
visible, so inside the touched region they are fully replaced."""
me = body.data
if not me.has_custom_normals:
log("base has no custom split normals; leaving normals to Blender")
return
old = [Vector(c.vector) for c in me.corner_normals]
acc = np.zeros((wl.n, 3))
for poly in me.polygons:
n = np.array([*poly.normal]) * poly.area
for vi in poly.vertices:
acc[wl.gov[vi]] += n
ln = np.linalg.norm(acc, axis=1)
fade = touched.astype(np.float64)
zone = touched.copy()
for k in range(1, NORMAL_RING_FADE + 1):
nxt = wl.grow(zone, 1)
ring = nxt & ~zone
fade[ring] = 1.0 - k / (NORMAL_RING_FADE + 1.0)
zone = nxt
out, changed = [], 0
for li, loop in enumerate(me.loops):
gi = wl.gov[loop.vertex_index]
a = fade[gi]
if a <= 0.0 or ln[gi] < 1e-9:
out.append(old[li])
continue
fresh = Vector(acc[gi] / ln[gi])
v = old[li] * (1.0 - a) + fresh * a
out.append(v.normalized() if v.length > 1e-9 else fresh)
changed += 1
me.normals_split_custom_set(out)
log(f"normals: {changed} of {len(out)} loops rebuilt")
def swap_texture(body, png, name):
"""Point the material's base colour at a new file. The image datablock NAME is set
explicitly — it decides the sidecar the exporter writes, and a WIP asset must not bake a
PascalCase ship name inside itself (REGISTRY.md rule 8)."""
for mat in [ms.material for ms in body.material_slots if ms.material]:
for node in mat.node_tree.nodes:
if node.type == 'TEX_IMAGE' and node.image:
img = bpy.data.images.load(png, check_existing=True)
img.name = name
node.image = img
img.pack()
log(f"texture -> '{img.name}' ({os.path.basename(png)}) on material '{mat.name}'")
return
log("WARNING: no image texture node found; texture NOT swapped")
def dump_mask(body, wl, garment, band, chest_w, path):
"""Bake the selections into vertex colours (R garment, G band, B chest) for eyeballing."""
me = body.data
lay = me.color_attributes.new(name="MaskDebug", type='FLOAT_COLOR', domain='POINT')
for vi in range(len(me.vertices)):
gi = wl.gov[vi]
lay.data[vi].color = (float(garment[gi]), float(band[gi]), float(chest_w[gi]), 1.0)
for mat in [ms.material for ms in body.material_slots if ms.material]:
nt = mat.node_tree
n = nt.nodes.new("ShaderNodeVertexColor")
n.layer_name = "MaskDebug"
bsdf = next((x for x in nt.nodes if x.type == 'BSDF_PRINCIPLED'), None)
if bsdf:
nt.links.new(n.outputs["Color"], bsdf.inputs["Base Color"])
bpy.ops.export_scene.gltf(filepath=path, export_format='GLB', use_selection=False,
export_yup=True, export_skins=True, export_animations=False)
log(f"WROTE mask debug {path}")
def main():
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
ap = argparse.ArgumentParser()
ap.add_argument("--base", default=STOCK)
ap.add_argument("--mask-texture", default=STOCK_TEX,
help="albedo WITH the underwear painted, used for the colour key")
ap.add_argument("--out", required=True)
ap.add_argument("--size", type=float, default=1.0, help="breast volume multiplier")
ap.add_argument("--texture", default="", help="albedo to ship on the result")
ap.add_argument("--texture-name", default="lena_nude_basecolor",
help="image datablock name; drives the exported sidecar filename")
ap.add_argument("--dump-mask", default="")
ap.add_argument("--no-fair", action="store_true")
ap.add_argument("--no-breasts", action="store_true")
ap.add_argument("--weld-slits", type=float, default=4.0)
a = ap.parse_args(argv)
game_asset(a.base, "--base")
game_asset(a.mask_texture, "--mask-texture")
bpy.ops.wm.read_factory_settings(use_empty=True)
body, rig = import_body(a.base)
if a.weld_slits > 0:
close_rim_slits(body, a.weld_slits)
wl = Welded(body.data)
P0 = wl.co.copy()
P = P0.copy()
garment = garment_groups(body.data, wl, a.mask_texture)
band = np.zeros(wl.n, dtype=bool)
if not a.no_fair:
P, band = fair_rim_band(wl, garment, P)
chest_w = chest_mask(body, wl)
lobes = np.zeros(wl.n, dtype=bool)
if not a.no_breasts:
# erase the bra shape down to a plain chest wall, then sculpt on top of it
P = membrane_fair(wl, chest_w > CHEST_FREE_MIN, P)
P, lobes = breast_field(wl, P, chest_w, a.size)
# Grazing-angle polish: each membrane strip lands within a millimetre or two of its
# surroundings, which is invisible front-on but reads as horizontal waviness in side views
# (the ghost detector caught torso90 at 1.67x control while every other view passed). A few
# light Taubin passes over the band zone blend the strips in; the breast lobes are excluded
# so their sculpted shape stays crisp.
if not a.no_fair:
polish = wl.grow(band, 2) & ~lobes
P = wl.taubin(P, 8, mask=polish.astype(np.float64))
log(f"polish: {polish.sum()} groups, 8 light passes")
touched = (np.linalg.norm(P - P0, axis=1) > 1e-6) | band | (chest_w > 0.01)
wl.write(P)
body.data.update()
rebuild_normals(body, wl, touched)
if a.texture:
swap_texture(body, a.texture, a.texture_name)
if a.dump_mask:
dump_mask(body, wl, garment, band, chest_w, a.dump_mask)
for o in bpy.data.objects:
o.select_set(True) # keeps the stray Icosphere, for parity with stock
bpy.ops.export_scene.gltf(filepath=a.out, export_format='GLB', use_selection=True,
export_yup=True, export_skins=True, export_animations=False,
export_apply=False, export_image_format='AUTO',
export_tangents=False, export_normals=True)
log(f"WROTE {a.out} ({os.path.getsize(a.out)/1e6:.2f} MB)")
main()
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env python
"""md_bridge.py -- terminal client for the TinqsMDBridge Marvelous Designer plug-in.
Talks newline-delimited JSON over a localhost TCP socket to the TinqsMDBridge
plug-in (tools/md_bridge/TinqsMDBridge.py, registered manually via MD's
Plugin tab > Plug-in Manager > + ADD). Clicking Plugin > TinqsMDBridge starts
a BRIDGE SESSION: MD's UI freezes and the plug-in serves requests on the main
thread until --stop is sent or the idle timeout (default 300 s) expires. MD
cannot be used interactively during a session -- that's by design, see
docs/md-bridge.md.
Same wire protocol and `result` convention as tools/iclone_bridge.py, but on
port 18900 so both bridges can run at once.
Usage:
python tools/md_bridge.py --ping
python tools/md_bridge.py --exec "result = dir(pattern_api)"
python tools/md_bridge.py --file some_script.py
python tools/md_bridge.py --stop
python tools/md_bridge.py --port 18901 --timeout 120 --exec "..."
The exec namespace is persistent per MD session and pre-seeded with MD's api
modules (import_api, export_api, pattern_api, fabric_api, utility_api,
ApiTypes) plus a BRIDGE info dict. See docs/md-bridge.md.
"""
import argparse
import json
import socket
import sys
import time
DEFAULT_PORT = 18900
DEFAULT_TIMEOUT = 30.0
# Sent by --ping: bridge metadata seeded by the plug-in at startup.
PING_CODE = "result = BRIDGE\n"
def _send(sock, obj):
sock.sendall((json.dumps(obj) + "\n").encode("utf-8"))
def _recv_line(sock, timeout):
sock.settimeout(timeout)
buf = b""
while not buf.endswith(b"\n"):
chunk = sock.recv(4096)
if not chunk:
if buf:
break
raise ConnectionError(
"connection closed by the MD bridge before a response was received")
buf += chunk
return buf.decode("utf-8")
def run(code, port=DEFAULT_PORT, timeout=DEFAULT_TIMEOUT):
"""Send `code` to be exec()'d inside Marvelous Designer.
Returns the parsed response dict. Raises OSError on transport failure;
the dict's "ok" key reports exec-level success/failure.
"""
req = {"id": int(time.time() * 1000), "code": code}
with socket.create_connection(("127.0.0.1", port), timeout=timeout) as sock:
_send(sock, req)
line = _recv_line(sock, timeout)
return json.loads(line)
def ping(port=DEFAULT_PORT, timeout=DEFAULT_TIMEOUT):
"""Convenience wrapper: run(PING_CODE, ...)."""
return run(PING_CODE, port=port, timeout=timeout)
def _read_code(args):
if args.exec is not None:
return args.exec
with open(args.file, "r", encoding="utf-8") as f:
return f.read()
def _main(argv=None):
p = argparse.ArgumentParser(
description="Client for the TinqsMDBridge Marvelous Designer socket plug-in.")
p.add_argument("--port", type=int, default=DEFAULT_PORT,
help="bridge TCP port (default: %(default)s; must match "
"TINQS_MD_BRIDGE_PORT inside MD if overridden there)")
p.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT,
help="socket timeout in seconds (default: %(default)s; raise "
"for long Simulate() calls)")
group = p.add_mutually_exclusive_group(required=True)
group.add_argument("--ping", action="store_true",
help="check the bridge is alive; reports executor mode, "
"api modules, embedded python")
group.add_argument("--stop", action="store_true",
help="end the bridge session (unfreezes MD's UI)")
group.add_argument("--exec", metavar="CODE",
help="python source to exec() inside Marvelous Designer")
group.add_argument("--file", metavar="PATH",
help="path to a python file to exec() inside Marvelous Designer")
args = p.parse_args(argv)
try:
if args.ping:
resp = ping(port=args.port, timeout=args.timeout)
elif args.stop:
resp = run("__STOP__", port=args.port, timeout=args.timeout)
else:
resp = run(_read_code(args), port=args.port, timeout=args.timeout)
except ConnectionRefusedError:
print(
"error: connection refused on 127.0.0.1:{} -- is Marvelous Designer "
"running? was Plugin > TinqsMDBridge clicked this session?".format(args.port),
file=sys.stderr,
)
return 1
except OSError as exc:
print("error: could not reach the MD bridge on 127.0.0.1:{}: {}".format(
args.port, exc), file=sys.stderr)
return 1
if resp.get("stdout"):
text = resp["stdout"]
print(text, end="" if text.endswith("\n") else "\n")
if resp.get("ok"):
if args.ping:
info = resp.get("result") or {}
py = str(info.get("python", "?")).split()[0]
print("ok: MD bridge mode={} qt={} python {} api={}".format(
info.get("mode", "?"), info.get("qt_binding"), py,
",".join(info.get("api_modules", []))))
if info.get("api_missing"):
print("warning: missing api modules: {}".format(
",".join(info["api_missing"])))
else:
print(json.dumps(resp.get("result"), indent=2))
return 0
print("error: {}".format(resp.get("error", "unknown error")), file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(_main())
+269
View File
@@ -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))
+109
View File
@@ -0,0 +1,109 @@
{
"import_api.ImportAvatar": "ImportAvatar(arg0: str, arg1: Marvelous::ImportExportOption) -> bool\n",
"import_api.ImportFBX": "ImportFBX(arg0: str, arg1: Marvelous::ImportExportOption) -> bool\n",
"import_api.ImportOBJ": "ImportOBJ(arg0: str, arg1: Marvelous::ImportExportOption) -> bool\n",
"import_api.ImportFile": "ImportFile(*args, **kwargs)\nOverloaded function.\n\n1. ImportFile(arg0: str) -> bool\n\n2. ImportFile(arg0: str, arg1: Marvelous::ImportExportOption) -> bool\n",
"import_api.ImportZpac": "ImportZpac(arg0: str, arg1: Marvelous::ImportExportOption) -> bool\n",
"import_api.ImportPose": "ImportPose(*args, **kwargs)\nOverloaded function.\n\n1. ImportPose(arg0: str) -> bool\n\n2. ImportPose(arg0: str, arg1: bool, arg2: bool) -> bool\n",
"export_api.ExportSnapshot3D": "ExportSnapshot3D(*args, **kwargs)\nOverloaded function.\n\n1. ExportSnapshot3D(arg0: str) -> List[List[str]]\n\n2. ExportSnapshot3D() -> List[List[str]]\n",
"export_api.ExportCustomViewSnapshot": "ExportCustomViewSnapshot(_targetFolderPath: str, _width: int, _height: int, _outputPrefix: str = '') -> List[str]\n",
"export_api.ExportFBX": "ExportFBX(*args, **kwargs)\nOverloaded function.\n\n1. ExportFBX(arg0: Marvelous::ImportExportOption) -> List[str]\n\n2. ExportFBX(arg0: str, arg1: Marvelous::ImportExportOption) -> List[str]\n",
"export_api.ExportOBJ": "ExportOBJ(*args, **kwargs)\nOverloaded function.\n\n1. ExportOBJ() -> List[str]\n\n2. ExportOBJ(arg0: str) -> List[str]\n\n3. ExportOBJ(arg0: Marvelous::ImportExportOption) -> List[str]\n\n4. ExportOBJ(arg0: str, arg1: Marvelous::ImportExportOption) -> List[str]\n",
"export_api.ExportZPac": "ExportZPac(*args, **kwargs)\nOverloaded function.\n\n1. ExportZPac() -> str\n\n2. ExportZPac(arg0: str) -> str\n",
"export_api.ExportTurntableImages": "ExportTurntableImages(*args, **kwargs)\nOverloaded function.\n\n1. ExportTurntableImages(arg0: int) -> List[str]\n\n2. ExportTurntableImages(_filePath: str, _numberOfImages: int, _width: int = 2500, _height: int = 2500, _startIndex: int = 0) -> List[str]\n",
"export_api.GetAvatarCount": "GetAvatarCount() -> int\n",
"export_api.GetAvatarNameList": "GetAvatarNameList() -> List[str]\n",
"pattern_api.CreatePatternWithPoints": "CreatePatternWithPoints(arg0: List[Tuple[float, float, int]]) -> int\n",
"pattern_api.CreateInternalShapeWithPoints": "CreateInternalShapeWithPoints(arg0: int, arg1: List[Tuple[float, float, int]], arg2: bool) -> int\n",
"pattern_api.AddSeamlinePairGroup": "AddSeamlinePairGroup(*args, **kwargs)\nOverloaded function.\n\n1. AddSeamlinePairGroup(arg0: int, arg1: int, arg2: int, arg3: int, arg4: bool, arg5: bool) -> bool\n\n2. AddSeamlinePairGroup(arg0: int, arg1: int, arg2: int, arg3: int, arg4: int, arg5: bool, arg6: bool) -> bool\n\n3. AddSeamlinePairGroup(arg0: int, arg1: int, arg2: int, arg3: int, arg4: int, arg5: int, arg6: bool, arg7: bool) -> bool\n",
"pattern_api.GetSeamlinePairGroupCount": "GetSeamlinePairGroupCount() -> int\n",
"pattern_api.SetArrangement": "SetArrangement(arg0: int, arg1: int) -> None\n",
"pattern_api.SetArrangementPosition": "SetArrangementPosition(arg0: int, arg1: int, arg2: int, arg3: int) -> None\n",
"pattern_api.SetArrangementOrientation": "SetArrangementOrientation(arg0: int, arg1: int) -> None\n",
"pattern_api.GetArrangementList": "GetArrangementList() -> List[Dict[str, str]]\n",
"pattern_api.GetArrangementOfPattern": "GetArrangementOfPattern(*args, **kwargs)\nOverloaded function.\n\n1. GetArrangementOfPattern() -> List[Dict[str, str]]\n\n2. GetArrangementOfPattern(arg0: int) -> Dict[str, str]\n\n3. GetArrangementOfPattern(arg0: str) -> Dict[str, str]\n",
"pattern_api.SetPatternPiecePos": "SetPatternPiecePos(arg0: int, arg1: float, arg2: float) -> None\n",
"pattern_api.SetPatternPieceMove": "SetPatternPieceMove(arg0: int, arg1: float, arg2: float) -> None\n",
"pattern_api.MovePatternPoint": "MovePatternPoint(arg0: int, arg1: int, arg2: float, arg3: float) -> None\n",
"pattern_api.FlipPatternPiece": "FlipPatternPiece(arg0: int, arg1: bool, arg2: bool) -> None\n",
"pattern_api.GetPatternIndexList": "<missing>",
"pattern_api.GetPatternInformation": "GetPatternInformation(arg0: int) -> str\n",
"pattern_api.SetPatternPieceElastic": "SetPatternPieceElastic(arg0: int, arg1: int, arg2: bool) -> None\n",
"pattern_api.DeletePatternPiece": "DeletePatternPiece(arg0: int) -> None\n",
"fabric_api.GetFabricList": "<missing>",
"fabric_api.GetFabricIndexForPattern": "GetFabricIndexForPattern(arg0: int) -> int\n",
"fabric_api.SetFabricForPattern": "<missing>",
"fabric_api.AddFabric": "AddFabric(arg0: str) -> int\n",
"fabric_api.SetFabricColor": "<missing>",
"utility_api.Simulate": "Simulate(arg0: int) -> bool\n",
"utility_api.NewProject": "NewProject() -> None\n",
"utility_api.OpenProject": "<missing>",
"utility_api.SaveProjectFile": "<missing>",
"utility_api.SetColorwayIndex": "<missing>",
"utility_api.DeleteAvatar": "DeleteAvatar(arg0: List[int]) -> bool\n",
"utility_api.AlignAvatarsAndGarmentToCenter": "AlignAvatarsAndGarmentToCenter() -> None\n",
"utility_api.ResetSimulation": "<missing>",
"utility_api.SetSimulationQuality": "SetSimulationQuality(arg0: int, arg1: int) -> None\n",
"utility_api.GetCurrentProjectFilePath": "<missing>",
"ApiTypes.ImportExportOption": "<missing>",
"ApiTypes.ImportExportOption.members": [
"ImportObjectType",
"axisX",
"axisY",
"axisZ",
"bAdd",
"bAddArrangementPoints",
"bAutoCreateFittingSuit",
"bAutoTranslate",
"bAvatarUnifiedUVCoordinates",
"bClothUnifiedUVCoordinates",
"bCreateAvatarCacheAnimation",
"bCreateAvatarJointAnimation",
"bCreateCamera",
"bCreateClothCacheAnimation",
"bCreateMetallicRoughnessMap",
"bCreateUnifiedTexture",
"bDiffuseColorCombined",
"bEmbedded",
"bExcludeAmbient",
"bExportAvatar",
"bExportFabric",
"bExportGarment",
"bExportLight",
"bIncludeAvatarShape",
"bIncludeHiddenObject",
"bIncludeInnerShape",
"bInvertX",
"bInvertY",
"bInvertZ",
"bMetaData",
"bMoveGarment",
"bOpacityMap",
"bSaveColorWays",
"bSaveColorWaysSingleFile",
"bSaveInZip",
"bSingleObject",
"bSizeAndPoseFromAvatar",
"bThin",
"bTrace2DPatternsUVMap",
"bUnifiedDiffuseMap",
"bUnifiedDisplacementMap",
"bUnifiedMetalnessMap",
"bUnifiedNormalMap",
"bUnifiedOpacityMap",
"bUnifiedRoughnessMap",
"bUnifiedUVCoordinates",
"bUseInifinteSeams",
"fbxSdkVersion",
"m_AuthenticationKeyForAPI",
"scale",
"translationValueX",
"translationValueY",
"translationValueZ",
"unifiedTextureBakeMargin",
"unifiedTextureBakeRelateive",
"unifiedTextureFillSeamSize",
"unifiedTextureSize",
"weldType"
]
}
+702
View File
@@ -0,0 +1,702 @@
{
"ApiTypes": [
"AlembicUnit",
"AttachFileInfo",
"CLOAPI_ANCHOR_CENTER",
"CLOAPI_ANCHOR_DOWN",
"CLOAPI_ANCHOR_LEFT",
"CLOAPI_ANCHOR_LEFT_DOWN",
"CLOAPI_ANCHOR_LEFT_UP",
"CLOAPI_ANCHOR_RIGHT",
"CLOAPI_ANCHOR_RIGHT_DOWN",
"CLOAPI_ANCHOR_RIGHT_UP",
"CLOAPI_ANCHOR_UP",
"CLO_API_TECH_PACK",
"CLO_DUMMY",
"CLO_SET_SREST",
"CLO_SET_TECH_PACK",
"CLO_TECH_PACK",
"CloApiAnchorPoint",
"CloApiGraphicDimensions",
"CloApiGraphicPlacementPoints",
"CloApiGraphicPosition",
"CloApiRestRequest",
"CloApiRestResponse",
"CloApiRgb",
"CloApiRgba",
"CloGroundData",
"DEFAULT_WELDED",
"ExportDxfOption",
"ExportTechPackType",
"ExportTechpackOption",
"ExportUSDOption",
"FULLY_UNWELDED",
"FULLY_WELDED",
"GRAPHIC_REMOVE_BROWSER_ONLY",
"GRAPHIC_REMOVE_PATTERN_AND_BROWSER",
"GRAPHIC_REMOVE_PATTERN_ONLY",
"GraphicRemoveMode",
"ImportAlembicOption",
"ImportDxfOption",
"ImportExportOption",
"ImportZPRJOption",
"MimeData",
"OB_TYPE_BUTTON",
"OB_TYPE_BUTTON_HOLE",
"OB_TYPE_FABRIC",
"OB_TYPE_GRAPHIC",
"OB_TYPE_PUCKERING",
"OB_TYPE_TRIM",
"OB_TYPE_ZIPPER",
"ObjRegisterOptions",
"ObjectBrowserContextOptions",
"ObjectBrowserType",
"PatternSnapShotColorwayOption",
"PatternSnapShotImageOption",
"PatternSnapShotInformationOption",
"PatternSnapShotLineOption",
"PatternSnapShotLineType",
"PatternSnapShotPaperPreset",
"PatternSnapShotPaperUnit",
"PatternSnapShotPrintType",
"PatternSnapShotSizeOption",
"PropertyEditorContextOptions",
"RenderImageVideoOptions",
"RenderPropertyOptions",
"SELECTED_WELDED",
"TEXTURE_MAP_BASE_COLOR",
"TEXTURE_MAP_DISPLACEMENT",
"TEXTURE_MAP_METALNESS",
"TEXTURE_MAP_NORMAL",
"TEXTURE_MAP_OPACITY",
"TEXTURE_MAP_ROUGHNESS",
"TextureMapTarget",
"TransformOptions",
"VideoExportOption",
"WELD_TYPE",
"WindControllerOptions",
"ZIPPER_OBJECT_BOTTOM_CLOSED_STOPPER",
"ZIPPER_OBJECT_BOTTOM_OPEN_STOPPER",
"ZIPPER_OBJECT_PULLER",
"ZIPPER_OBJECT_SLIDER",
"ZIPPER_OBJECT_SLIDER_PULLER",
"ZIPPER_OBJECT_TEETH",
"ZIPPER_OBJECT_TOP_STOPPER",
"ZIPPER_SCALE_8_6_FEET",
"ZIPPER_SCALE_8_FEET",
"ZIPPER_SCALE_AUTO",
"ZIPPER_SCALE_CM",
"ZIPPER_SCALE_FEET",
"ZIPPER_SCALE_INCH",
"ZIPPER_SCALE_M",
"ZIPPER_SCALE_MM",
"ZipperObjectType",
"ZipperScaleUnit"
],
"export_api": [
"ExportAVT",
"ExportAVTW",
"ExportAlembic",
"ExportAlembicW",
"ExportAnimationVideo",
"ExportAnimationVideoW",
"ExportCustomViewSnapshot",
"ExportCustomViewSnapshotW",
"ExportFBX",
"ExportFBXW",
"ExportOBJ",
"ExportOBJW",
"ExportPose",
"ExportPoseW",
"ExportSnapshot2D",
"ExportSnapshot3D",
"ExportSnapshot3DW",
"ExportThumbnail3D",
"ExportThumbnail3DW",
"ExportTopStitchStyle",
"ExportTurntableImages",
"ExportTurntableImagesW",
"ExportTurntableVideo",
"ExportTurntableVideoW",
"ExportUSD",
"ExportUSDW",
"ExportZCMR",
"ExportZPac",
"ExportZPacW",
"ExportZPrj",
"ExportZPrjW",
"GenerateZcmrFrom3DWindow",
"GetAvatarCount",
"GetAvatarGenderList",
"GetAvatarNameList",
"GetAvatarNameListW",
"~ExportAPIInterface"
],
"fabric_api": [
"AddFabric",
"AddFabricW",
"AddTextureToPatterns",
"AssignFabricToPattern",
"AutoGenerateFabricDisplacementMap",
"AutoGenerateFabricNormalMap",
"AutoGenerateFabricOpacityMap",
"AutoGenerateFabricRoughnessMap",
"CombineZfab",
"CreateZfabFromTextures",
"DeleteFabric",
"DeleteFabricDisplacementMap",
"DeleteFabricNormalMap",
"DeleteFabricOpacityMap",
"DeleteFabricRoughnessMap",
"ExportFabric",
"ExportFabricW",
"ExportZFab",
"ExportZFabW",
"GetAPIMetaDataFromFile",
"GetAPIMetaDataFromFileW",
"GetBaseTextureMapImageFilePath",
"GetBaseTextureMapImageFilePathW",
"GetCurrentFabricIndex",
"GetDisplacementMapImageFilePath",
"GetDisplacementMapImageFilePathW",
"GetFabricCount",
"GetFabricIndex",
"GetFabricIndexForPattern",
"GetFabricIndexW",
"GetFabricInfo",
"GetFabricInfoW",
"GetFabricInformation",
"GetFabricInformationW",
"GetFabricItemNo",
"GetFabricItemNoW",
"GetFabricLength",
"GetFabricName",
"GetFabricNameW",
"GetFabricPBRMaterialBaseColor",
"GetFabricStyleNameList",
"GetFabricTextureMappingType",
"GetFirstFabricTextureName",
"GetFirstFabricTextureNameW",
"GetMaterialType",
"GetMetalness",
"GetMetalnessMapImageFilePath",
"GetMetalnessMapImageFilePathW",
"GetNormalMapImageFilePath",
"GetNormalMapImageFilePathW",
"GetNormalMapIntensity",
"GetOpacityIntensity",
"GetOpacityMapImageFilePath",
"GetOpacityMapImageFilePathW",
"GetPBRMaterialDisplacementMapValue",
"GetPrimaryFabric",
"GetReflectionIntensity",
"GetReflectionRoughness",
"GetRoughnessMapImageFilePath",
"GetRoughnessMapImageFilePathW",
"GetRoughnessType",
"GetRoughnessValueIntensity",
"GetRoughnessValueMapIntensity",
"GetUseSameColorAsFront",
"GetUseSameMaterialAsFront",
"ImportSubstanceFile",
"ImportSubstanceFileAsFaceType",
"ImportSubstanceFileAsFaceTypeW",
"ImportSubstanceFileW",
"IsRoughnessValueMapInvert",
"ReplaceFabric",
"SetBaseTextureMapImageGivenFilePath",
"SetBaseTextureMapImageGivenFilePathW",
"SetCurrentFabricIndex",
"SetCustomImage",
"SetCustomImageW",
"SetDisplacementMapImageGivenFilePath",
"SetDisplacementMapImageGivenFilePathW",
"SetFabricInformation",
"SetFabricInformationW",
"SetFabricItemNo",
"SetFabricItemNoW",
"SetFabricName",
"SetFabricNameW",
"SetFabricPBRMaterialBaseColor",
"SetMaterialType",
"SetMetalness",
"SetMetalnessMapImageGivenFilePath",
"SetMetalnessMapImageGivenFilePathW",
"SetNormalMapImageGivenFilePath",
"SetNormalMapImageGivenFilePathW",
"SetNormalMapIntensity",
"SetOpacityIntensity",
"SetOpacityMapImageGivenFilePath",
"SetOpacityMapImageGivenFilePathW",
"SetPBRMaterialDisplacementMap",
"SetPBRMaterialDisplacementMapValue",
"SetReflectionIntensity",
"SetReflectionRoughness",
"SetRoughnessMapImageGivenFilePath",
"SetRoughnessMapImageGivenFilePathW",
"SetRoughnessType",
"SetRoughnessValueIntensity",
"SetRoughnessValueMapIntensity",
"SetRoughnessValueMapInvert",
"SetSubstancePreset",
"SetSubstanceResolution",
"SetTextureMapping",
"SetUseSameColorAsFront",
"SetUseSameMaterialAsFront",
"TransformAOPOnFabric",
"~FabricAPIInterface"
],
"import_api": [
"ImportAlembic",
"ImportAsGraphic",
"ImportAsGraphicW",
"ImportAvatar",
"ImportAvatarMeasurement",
"ImportFBX",
"ImportFBXW",
"ImportFile",
"ImportFileW",
"ImportGraphicStyleFromImage",
"ImportMeasurement",
"ImportOBJ",
"ImportOBJW",
"ImportPose",
"ImportPoseW",
"ImportSMP",
"ImportSMPW",
"ImportTrim",
"ImportZpac",
"ImportZprj",
"ImportZprjW",
"~ImportAPIInterface"
],
"pattern_api": [
"AddGraphicStyleToPattern",
"AddSeamlinePairGroup",
"AddSeamlineTopstitch",
"AddSegmentTopstitch",
"ConvertToBaseLine",
"ConvertToInternalLine",
"CopyPatternPieceMove",
"CopyPatternPiecePos",
"CreateBaseShapeWithPoints",
"CreateInternalShapeWithPoints",
"CreatePatternWithPoints",
"DeleteLine",
"DeletePatternPiece",
"DeletePoint",
"DistribueInternalLinesbetweenSegments",
"ExportObjectBrowserMaterialsList",
"ExportPatternJSON",
"FitPatternUVToUDIM",
"FlipPatternPiece",
"GetAddlThicknessCollisionValue",
"GetAllStitchProperty",
"GetArrangementList",
"GetArrangementOfPattern",
"GetArrangementOfPatternW",
"GetBackUVExpansion",
"GetBoundingBoxOfPattern",
"GetBoundingBoxOfPatternW",
"GetGradingSizeQuantityMix",
"GetGradingSizeTotalQuantity",
"GetLineLength",
"GetLinkedPatternIndex",
"GetLinkedPatternLists",
"GetMeshCountByType",
"GetMeshCountByTypeW",
"GetParticleDistanceOfPattern",
"GetParticleDistanceOfPatternW",
"GetPatternArchiveState",
"GetPatternAssignedTopstitch",
"GetPatternAssignedTopstitchCount",
"GetPatternAssignedTopstitchCurvedLength",
"GetPatternAssignedTopstitchStyle",
"GetPatternAssignedTopstitchStyleIndex",
"GetPatternAssignedTopstitchZOffset",
"GetPatternCount",
"GetPatternIndex",
"GetPatternIndexFrom2DView",
"GetPatternIndexFrom3DView",
"GetPatternIndexW",
"GetPatternInformation",
"GetPatternInformationW",
"GetPatternInputInformation",
"GetPatternInputInformationW",
"GetPatternLayer",
"GetPatternPieceArea",
"GetPatternPieceCategory",
"GetPatternPieceClassification",
"GetPatternPieceFabricIndex",
"GetPatternPieceGrainDirection",
"GetPatternPieceName",
"GetPatternPiecePos",
"GetPatternPieceSolidifyStrengthen",
"GetPatternSize",
"GetPatternsAttachedToAvatarMeasures",
"GetPinListSize",
"GetSeamlinePairGroupCount",
"GetSeamlinePairGroupIndexFromName",
"GetSeamlinePairGroupIndexFromNameW",
"GetSeamlinePairGroupListInPattern",
"GetSeamlinePairGroupName",
"GetSeamlinePairGroupNameW",
"GetSelectedPattern",
"GetSelectedPatternViaIndex",
"GetShrinkagePercentage",
"GetShrinkagePercentageW",
"GetSideUVExpansion",
"GetTopstitchStyleList",
"GetTopstitchStyleModelType",
"ImportPatternJSON",
"ImportTopStitchStyle",
"InstancePatternPiece",
"InstancePatternPieceWithPatternName",
"IsPatternAssignedTopstitchCurved",
"IsPatternAssignedTopstitchCurvedRightAngled",
"IsPatternAssignedTopstitchExtendEnd",
"IsPatternAssignedTopstitchExtendStart",
"IsPatternPieceSolidify",
"LayerClonePatternPieceMove",
"LayerClonePatternPiecePos",
"MovePatternPoint",
"OffsetAsInternalLine",
"RemoveAllPins",
"RemovePin",
"SelectPatternViaIndex",
"SelectPatternViaName",
"SetAddlThicknessCollision",
"SetArrangement",
"SetArrangementOrientation",
"SetArrangementPosition",
"SetArrangementShapeStyle",
"SetArrangementShapeStyleW",
"SetBackUVExpansion",
"SetGradingSizeQuantityMix",
"SetHeightShrinkagePercentage",
"SetMeshType",
"SetMeshTypeW",
"SetParticleDistanceOfPattern",
"SetParticleDistanceOfPatterns",
"SetPatternArchiveState",
"SetPatternAssignedTopstitchCurved",
"SetPatternAssignedTopstitchCurvedLength",
"SetPatternAssignedTopstitchCurvedRightAngled",
"SetPatternAssignedTopstitchExtendEnd",
"SetPatternAssignedTopstitchExtendStart",
"SetPatternAssignedTopstitchStyle",
"SetPatternAssignedTopstitchZOffset",
"SetPatternFreeze",
"SetPatternHide3D",
"SetPatternLayer",
"SetPatternLock",
"SetPatternPieceCategory",
"SetPatternPieceClassification",
"SetPatternPieceElastic",
"SetPatternPieceElasticSegmentLength",
"SetPatternPieceElasticStrength",
"SetPatternPieceElasticStrengthRatio",
"SetPatternPieceElasticTotalLength",
"SetPatternPieceFabricIndex",
"SetPatternPieceGrainDirection",
"SetPatternPieceMove",
"SetPatternPieceName",
"SetPatternPiecePos",
"SetPatternPieceSametapingWidth",
"SetPatternPieceSeamtaping",
"SetPatternPieceShirring",
"SetPatternPieceShirringExtend",
"SetPatternPieceShirringHeight",
"SetPatternPieceShirringInterval",
"SetPatternPieceSolidify",
"SetPatternPieceSolidifyStrengthen",
"SetPatternStrengthen",
"SetSideUVExpansion",
"SetTopstitchStyleModelType",
"SetWidthShrinkagePercentage",
"SymmetryPatternPiece",
"SymmetryPatternPieceWithPatternName",
"UnfoldPatternPiece",
"UnfoldPatternPieceWithPatternName"
],
"utility_api": [
"ABPNetworkAuth",
"AddBlockTypeToStyle",
"AddColorSwatch",
"AddColorSwatchW",
"AddGraphicStyleFromImageFile",
"AddGraphicStyleToPattern",
"AddGraphicStyleToPatternV2",
"AddLibraryColorSwatchList",
"AddLineToCategory",
"AddPinsForFabricValidation",
"AddStyleToCategory",
"AddUserCustomLibraryFolder",
"AddUserCustomLibraryFolderW",
"AlignAvatarsAndGarmentToCenter",
"AutoGenerateGraphicDisplacementMap",
"AutoGenerateGraphicNormalMap",
"AutoGenerateGraphicOpacityMap",
"AutoGenerateGraphicRoughnessMap",
"AutoHang",
"BakeUVTexture",
"BakeUVTextureW",
"ChangeMetaDataValueForCurrentGarment",
"CheckZPRJForUnsavedChanges",
"CopyFromFirstClothCache",
"CopyGraphicStyle",
"CreateProgressBar",
"CreateUserCustomLibrary",
"CurrentlyThemeInCLO",
"DeleteAvatar",
"DeleteColorSwatchLibraryTabByName",
"DeleteColorSwatchListItem",
"DeleteGraphicDisplacementMap",
"DeleteGraphicNormalMap",
"DeleteGraphicOpacityMap",
"DeleteGraphicRoughnessMap",
"DeleteProgressBar",
"DeleteUserCustomLibrary",
"DeleteUserCustomLibraryFolder",
"DeleteWidgets",
"DisplayMessageBox",
"DisplayMessageBoxW",
"FitAllUV",
"GenerateZippersFromObj",
"Get3DGarmentRenderingStyle",
"GetAPIMetaData",
"GetAPIMetaDataW",
"GetAnimationLayerFrameRange",
"GetAvatarOpacityMaps",
"GetAvatarProperties",
"GetAvatarSoftBodyStiffness",
"GetAvatarSubdivisionLevel",
"GetAvatarTexureMap",
"GetButtonHeadStyleColor",
"GetButtonHeadStyleListWithIndex",
"GetButtonHoleStyleColor",
"GetClothPositions",
"GetColorSwatchLibraryTabList",
"GetColorSwatchLibraryTabListW",
"GetCurrentAnimationFrame",
"GetCustomViewInformation",
"GetCustomViewInformationW",
"GetEndAnimationFrame",
"GetGraphicDisplacementMapTexture",
"GetGraphicMetalnessMapTexture",
"GetGraphicNormalMapTexture",
"GetGraphicOpacityMapTexture",
"GetGraphicRoughnessMapTexture",
"GetGraphicStyleColor",
"GetGraphicStyleCount",
"GetGraphicStyleDimensions",
"GetGraphicStyleDimensionsOnPattern",
"GetGraphicStyleListWithIndex",
"GetGraphicStyleName",
"GetGraphicStylePatternPieceIndices",
"GetGraphicStylePlacementPoints",
"GetGraphicStylePosition",
"GetMajorVersion",
"GetMaterialTextureTransformations",
"GetMetaDataForCurrentGarment",
"GetMetaDataForCurrentGarmentW",
"GetMinorVersion",
"GetNormalBlendingMethod",
"GetPatchVersion",
"GetPatternSnapShotImageOption",
"GetPatternSnapShotInformationOption",
"GetPatternSnapShotLineOption",
"GetPatternSnapShotSizeOption",
"GetProjectFilePath",
"GetProjectFilePathW",
"GetProjectName",
"GetProjectNameW",
"GetQualityRenderStatus",
"GetRenderImageVideoProperties",
"GetRenderingProperties",
"GetSchematicRender",
"GetSimulationQuality",
"GetStartAnimationFrame",
"GetStyleSheetCodeForWidget",
"GetStyleSheetCodeForWidgetW",
"GetTopStitchColor",
"GetTopStitchCount",
"GetTopStitchDistanceValue",
"GetTopStitchIndex",
"GetTopStitchName",
"GetTopStitchNumberOfLines",
"GetTopStitchOffsetIndex",
"GetTopStitchOffsetValue",
"GetTopStitchOpacity",
"GetTopStitchWidthValue",
"GetTotalEndAnimationFrame",
"GetTotalGraphicItemQuantity",
"GetTrimMaterialProperties",
"GetTrimStyleColor",
"GetTrimStyleCount",
"GetTrimStyleIndex",
"GetTrimStyleListWithIndex",
"GetTrimStyleName",
"GetUserHeadQuarterId",
"GetUserHeadQuarterIdW",
"GetViewPoint",
"GetWindActive",
"GetWindControllerSettings",
"GetWindPosition",
"GetWindRotation",
"GetZipperStyleAssetType",
"GetZipperStyleFunctionType",
"GetZipperStyleName",
"GetZipperStyleTapeThickness",
"GetZipperStyleTeethType",
"GetZipperStyleTeethWidth",
"GetZipperStyleWeight",
"IsReadableImageFormat",
"IsReadableImageFormatW",
"IsShowAvatar",
"LoadCustomViewIn3DWindow",
"LoadLibraryColorSwatchList",
"MoveAnimationFrame",
"NewProject",
"OpenButtonHeadStyleFileByIndex",
"OpenGraphicStyleFileByIndex",
"OpenTrimStyleFileByIndex",
"ReDrape3DArrangement",
"Refresh3DWindow",
"RegisterPythonScript",
"RegisterPythonScriptFolder",
"RegisterPythonScriptFolderW",
"RegisterPythonScriptW",
"RegisterWidget",
"RemoveGraphicStyle",
"ReplaceGraphicStyleFromImage",
"ReplaceGraphicStyleFromImageW",
"RepositionGraphicByAnchor",
"ResetClothArrangement",
"ResetUVTo2DArrangement",
"ResetWidgetRegistry",
"RunAnimationRecording",
"SaveCLOFileThumbnail",
"Set3DGarmentRenderingStyle",
"Set3DWindowTitle",
"Set3DWindowTitleW",
"SetAPF",
"SetAPIMetaData",
"SetAPIMetaDataW",
"SetAnimationRecording",
"SetAvatarActivation",
"SetAvatarMeshTexture",
"SetAvatarOpacityMap",
"SetAvatarOpacityMapByIndex",
"SetAvatarProperties",
"SetAvatarSmooth",
"SetAvatarSoftBodyStiffness",
"SetAvatarTexureMap",
"SetBaseTextureMapImageDesaturation",
"SetButtonHeadStyleColor",
"SetButtonHoleStyleColor",
"SetCamViewPoint",
"SetColorSwatchLibraryTabName",
"SetColorSwatchListItemName",
"SetCropBackground",
"SetCurrentAnimationFrame",
"SetEndAnimationFrame",
"SetEnvironmentDisplayProperties",
"SetFormat3DBackground",
"SetGarmentDisplayProperties",
"SetGraphicBaseColorMapTexture",
"SetGraphicDisplacementMapTexture",
"SetGraphicMetalnessMapTexture",
"SetGraphicNormalMapTexture",
"SetGraphicOpacityMapTexture",
"SetGraphicRoughnessMapTexture",
"SetGraphicStyleBaseColorMapTextureDesaturation",
"SetGraphicStyleColor",
"SetGraphicStyleDimensions",
"SetGraphicStyleDimensionsOnPattern",
"SetGraphicStyleHeight",
"SetGraphicStyleName",
"SetGraphicStylePositionOnPattern",
"SetGraphicStyleToGraphic",
"SetGraphicStyleWidth",
"SetMaterialTextureTransformations",
"SetMetaDataForCurrentGarment",
"SetNormalBlendingMethod",
"SetPatternSnapShotImageOption",
"SetPatternSnapShotInformationOption",
"SetPatternSnapShotLineOption",
"SetPatternSnapShotSizeOption",
"SetProgress",
"SetProgressW",
"SetQualityRender",
"SetRenderImageVideoProperties",
"SetRenderingProperties",
"SetSchematicBrightness",
"SetSchematicClothColor",
"SetSchematicClothRenderType",
"SetSchematicInternalLineWidth",
"SetSchematicRender",
"SetSchematicSeamLineWidth",
"SetSchematicSilhouetteLineWidth",
"SetSchematicTopstitchLineScalePercent",
"SetShowHideAvatar",
"SetShowHideColorOptions",
"SetShowSchematicInternalLine",
"SetShowSchematicSeamLine",
"SetShowSchematicSilhouetteLine",
"SetShowSchematicTopstitchLine",
"SetSimulationAirDamping",
"SetSimulationCGFinishCondition",
"SetSimulationCGIterationCount",
"SetSimulationCGResidual",
"SetSimulationGravity",
"SetSimulationGroundCollision",
"SetSimulationGroundHeight",
"SetSimulationLayerBasedCollisionDetection",
"SetSimulationNonlinearSimulation",
"SetSimulationNumberOfCPUInUse",
"SetSimulationNumberOfSimulation",
"SetSimulationQuality",
"SetSimulationSelfCollisionAvoidanceStiffness",
"SetSimulationSelfCollisionIterationCount",
"SetSimulationTimeStep",
"SetStartAnimationFrame",
"SetTopStitchColor",
"SetTopStitchDistanceValue",
"SetTopStitchName",
"SetTopStitchNumberOfLines",
"SetTopStitchOffsetIndex",
"SetTopStitchOpacity",
"SetTopStitchWidthValue",
"SetTrimDisplaySettings",
"SetTrimMaterialProperties",
"SetTrimStyleColor",
"SetViewControlDefaults",
"SetViewPoint",
"SetWindActive",
"SetWindControllerSettings",
"SetWindPosition",
"SetWindRotation",
"SetZipperBottomStopperStyle",
"SetZipperPullerStyle",
"SetZipperSliderStyle",
"SetZipperStyleAssetType",
"SetZipperStyleFunctionType",
"SetZipperStyleName",
"SetZipperStyleTapeThickness",
"SetZipperStyleTeethType",
"SetZipperStyleTeethWidth",
"SetZipperStyleWeight",
"SetZipperTopStopperStyle",
"SetZoomView",
"Simulate",
"UVPacking",
"UnlinkGraphicStyleAllColorways",
"UpdateCloStyleForPlugIn",
"UpdatePropertyWindow",
"ValidateCLOFile",
"ValidateCLOFileW",
"stringToMD5",
"toUtf8"
]
}
+91
View File
@@ -0,0 +1,91 @@
# TinqsMDBridge smoke test -- run via:
# python tools/md_bridge.py --file tools/md_bridge/smoke_test.py --timeout 120
#
# Defensive by design: MD's api docs are incomplete and signatures unverified,
# so every step is independently try/except'd and the whole thing returns a
# step-by-step report instead of dying on the first surprise. Nothing here
# should be treated as the "right" calling convention until this has passed
# once -- it's a probe, not a recipe.
import inspect
import os
import tempfile
import traceback
report = {"steps": []}
def step(name, fn):
entry = {"step": name}
try:
entry["ok"] = True
entry["value"] = fn()
except Exception:
entry["ok"] = False
entry["error"] = traceback.format_exc().strip().splitlines()[-1]
report["steps"].append(entry)
return entry
def docs_of(mod, names):
out = {}
for n in names:
f = getattr(mod, n, None)
if f is None:
out[n] = "<missing>"
continue
try:
out[n] = str(inspect.signature(f))
except (ValueError, TypeError):
doc = (getattr(f, "__doc__", "") or "").strip()
out[n] = doc.splitlines()[0] if doc else "<no signature/doc>"
return out
# 1. what do the key functions actually look like?
step("signatures.pattern", lambda: docs_of(pattern_api, [
"CreatePatternWithPoints", "AddSeamlinePairGroup", "SetArrangementPosition",
"MovePatternPoint", "DeletePatternPiece"]))
step("signatures.utility", lambda: docs_of(utility_api, [
"NewProject", "Simulate", "GetAvatarCount", "GetAvatarNameList"]))
step("signatures.export", lambda: docs_of(export_api, [
"ExportSnapshot3D", "ExportCustomViewSnapshot", "ExportGLTF", "ExportFBX"]))
# 2. fresh project
step("NewProject", lambda: utility_api.NewProject())
# 3. default avatar present?
step("avatars", lambda: {
"count": utility_api.GetAvatarCount(),
"names": utility_api.GetAvatarNameList(),
})
# 4. create a 50cm square pattern (units unverified -- try mm first, the CLO
# convention; the snapshot will show which interpretation MD used)
SQUARE_MM = [(0.0, 0.0), (500.0, 0.0), (500.0, 500.0), (0.0, 500.0)]
def make_square():
return pattern_api.CreatePatternWithPoints(SQUARE_MM)
step("CreatePatternWithPoints(square)", make_square)
# 5. short drape
step("Simulate(30)", lambda: utility_api.Simulate(30))
# 6. snapshot -- the agent's eyes
SNAP = os.path.join(tempfile.gettempdir(), "tinqs_md_smoke.png")
def snap():
export_api.ExportSnapshot3D(SNAP)
return {"path": SNAP, "exists": os.path.exists(SNAP),
"size": os.path.getsize(SNAP) if os.path.exists(SNAP) else 0}
step("ExportSnapshot3D", snap)
report["passed"] = sum(1 for s in report["steps"] if s["ok"])
report["failed"] = sum(1 for s in report["steps"] if not s["ok"])
result = report
+104
View File
@@ -0,0 +1,104 @@
# Rig-bait decimate: make an AccuRig-loadable FBX from a raw ~1.9M-tri Tripo GLB.
# Successor to male_mesh_decimate.py (July male lane) for the rig-graft lane
# (.agents/plans/rig-graft-lane-2026-08-04.md). Differences from July, both Jeremy calls:
# * NO scale / recenter — the source GLBs stay at Tripo's native ~0.98 m, feet at Z=0.
# * Hands budget 40k (was 10k): the bait's hand density bounds how crisply per-finger
# weights transfer back onto the 170k-tri full-res hands in the graft step.
# Region cuts are bbox-relative (works at any scale): head above 86.5% of height,
# hands beyond 75.6% of half-span — same proportions the July tool used at 1.85 m.
# The bait is DISPOSABLE: it exists to carry a skeleton out of AccuRig. Never ship it.
#
# blender --background --python tools/rigbait_decimate.py -- <src.glb> <out_dir> <basename> [body head hand]
import bpy, os, sys, math
from mathutils import Vector
args = sys.argv[sys.argv.index("--") + 1:]
SRC, OUT, BASE = args[0], args[1], args[2]
T_BODY, T_HEAD, T_HAND = (int(a) for a in args[3:6]) if len(args) >= 6 else (24000, 14000, 40000)
os.makedirs(OUT, exist_ok=True)
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=SRC)
body = max((o for o in bpy.data.objects if o.type == "MESH"), key=lambda o: len(o.data.vertices))
me = body.data
bpy.ops.object.select_all(action="DESELECT")
body.select_set(True)
bpy.context.view_layer.objects.active = body
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
bb = [body.matrix_world @ Vector(c) for c in body.bound_box]
mnz, mxz = min(v.z for v in bb), max(v.z for v in bb)
half_span = max(abs(v.x) for v in bb)
NECK_Z = mnz + 0.865 * (mxz - mnz)
HAND_X = 0.756 * half_span
print(f"[bait] {BASE}: height={mxz-mnz:.3f} half_span={half_span:.3f} neck_z={NECK_Z:.3f} hand_x={HAND_X:.3f}")
# anti-facet shading fixes (shading only, geometry untouched)
try: bpy.ops.mesh.customdata_custom_splitnormals_clear()
except Exception: pass
if "sharp_edge" in me.attributes:
me.attributes.remove(me.attributes["sharp_edge"])
def is_head(v): return v.co.z > NECK_Z
def is_hand(v): return abs(v.co.x) > HAND_X
def group(name, pred):
g = body.vertex_groups.new(name=name)
g.add([v.index for v in me.vertices if pred(v)], 1.0, "REPLACE")
return g
head_tris = sum(1 for p in me.polygons if all(is_head(me.vertices[vi]) for vi in p.vertices))
hand_tris = sum(1 for p in me.polygons if all(is_hand(me.vertices[vi]) for vi in p.vertices))
print(f"[bait] regions: head_tris={head_tris} hand_tris={hand_tris} total={len(me.polygons)}")
if hand_tris < 20000 or head_tris < 20000:
raise SystemExit(f"[bait] region cut looks wrong (head={head_tris}, hands={hand_tris}) — check pose/thresholds")
group("P_BODY", lambda v: is_head(v) or is_hand(v))
group("P_HEAD", lambda v: not is_head(v))
group("P_HAND", lambda v: not is_hand(v))
targets = {"P_BODY": T_BODY, "P_HEAD": T_HEAD, "P_HAND": T_HAND}
region_now = {"P_BODY": len(me.polygons) - head_tris - hand_tris,
"P_HEAD": head_tris, "P_HAND": hand_tris}
for pg in ("P_BODY", "P_HEAD", "P_HAND"):
other_now = len(me.polygons) - region_now[pg]
dec = body.modifiers.new("dec", "DECIMATE")
dec.ratio = min(1.0, (targets[pg] + other_now) / max(1, len(me.polygons)))
dec.vertex_group = pg
dec.invert_vertex_group = True
dec.delimit = {"UV"}
bpy.ops.object.modifier_apply(modifier="dec")
me = body.data
region_now[pg] = targets[pg]
print(f"[bait] after {pg} pass: tris={len(me.polygons)}")
bpy.ops.object.shade_smooth()
# QA renders: whole body + hand closeup (the region the budgets exist to protect)
def render(name, loc, look_z):
cam = bpy.data.objects.get("cam")
if cam is None:
camd = bpy.data.cameras.new("cam"); cam = bpy.data.objects.new("cam", camd)
bpy.context.scene.collection.objects.link(cam)
bpy.context.scene.camera = cam
cam.location = loc
cam.rotation_euler = (math.radians(90), 0, 0)
cam.location.z = look_z
sc = bpy.context.scene
sc.render.engine = "BLENDER_WORKBENCH"
sc.display.shading.light = "STUDIO"
sc.display.shading.color_type = "TEXTURE"
sc.render.resolution_x = 800; sc.render.resolution_y = 800
sc.render.filepath = os.path.join(OUT, name)
bpy.ops.render.render(write_still=True)
h = mxz - mnz
render(f"{BASE}_qa_front.png", Vector((0, -2.0 * h, 0)), mnz + 0.5 * h)
render(f"{BASE}_qa_hand.png", Vector((HAND_X + 0.08 * h, -0.35 * h, 0)), mnz + 0.72 * h)
bpy.ops.object.select_all(action="DESELECT"); body.select_set(True)
for g in list(body.vertex_groups): body.vertex_groups.remove(g)
fbx_out = os.path.join(OUT, f"{BASE}.fbx")
bpy.ops.export_scene.fbx(filepath=fbx_out, use_selection=True, path_mode="COPY", embed_textures=True)
print(f"[bait] wrote {fbx_out} ({os.path.getsize(fbx_out)/1e6:.1f} MB), final tris={len(me.polygons)}")
print("[bait] DONE")
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 MiB

Binary file not shown.
@@ -0,0 +1,53 @@
# Backup — kapa haka MD work, taken 2026-07-31 before the piupiu v3 re-cut
Snapshot of the **shipped** kapa haka authoring work, taken because none of
`tools/tailor/` is tracked in git and the next step edits the piupiu pattern.
Nothing in here is referenced by any script — it exists purely to restore from.
## What's inside
| Folder | Contents |
|---|---|
| `tailor/` | Every `lena_piupiu_*`, `lena_pari_*`, `lena_kapahaka_outfit_*` (`.zprj` / `.fbx` / `.obj` / `.mtl` / `.zpac` / colorway PNG), plus `md_piupiu.py`, `md_pari.py`, `lena_measurements.json` |
| `screenshots/` | Review renders for piupiu v1v2, pari v1v3, outfit v1v3 |
| `textures/` | `taniko.png`, `piupiu.png` (generated fabric maps) |
| `configs/` | `piupiu.json`, `pari.json`, `piupiu_sb.json` as they stood |
| `game-outfit/` | Shipped `Female_Kapahaka_{Body,Legs}.{gltf,bin}` from `ariki-game/assets/quaternius/outfits/kapahaka/` |
55 files, 86 MB. Copied with timestamps preserved; SHA1-verified for the four
`.zprj`/`.fbx` sources and the shipped `Legs.bin`.
## Deliberately NOT backed up
- `clothing/work/piupiu*/` — 92102 MB each of Blender stage checkpoints. Fully
regenerable (`census..export` ran in 26 s in the acceptance test), so not worth
the disk.
- `Female_KapahakaSB_*` — owned by the concurrent skirt-bones session; not mine
to snapshot mid-write.
- tee / A-line skirt garments — unrelated to this change, untouched.
## To restore
```bash
cd C:/Users/Jeremy/tinqs/animation
B=tools/tailor/backup/2026-07-31-pre-v3
cp -p $B/tailor/* tools/tailor/
cp -p $B/screenshots/* tools/tailor/screenshots/
cp -p $B/textures/* tools/tailor/textures/
cp -p $B/configs/* clothing/configs/
cp -p $B/game-outfit/* ../ariki-game/assets/quaternius/outfits/kapahaka/
```
After restoring the game-outfit files, force a Godot reimport of the two `.gltf`
files (`ariki-game/tools/targeted_reimport.sh`) or the old `.godot/imported/`
entries will keep serving the newer mesh.
## Why v3 is being cut
The piupiu is cut tighter than the hip it passes over (waist edge 500 mm/panel =
1000 mm total vs Lena's 1095 mm hips), so it drapes as a taut pencil wrap on the
thighs with ~12 cm clearance. `fit`/G3 measures 8 mm penetration **at rest**;
G5 measures 216231 verts up to 8.5 cm deep in Idle/Walk, because
`weights: "dress"` makes the sub-hip skirt pelvis-only — the thigh swings, the
skirt doesn't. v3 re-cuts for real clearance and adds MD collision thickness.
See the session discussion and `.agents/plans/clothing-pipeline-unification-2026-07-31.md` §8.
@@ -0,0 +1,47 @@
{
"name": "pari",
"source": "C:/Users/Jeremy/tinqs/animation/tools/tailor/lena_pari_v3_garment.fbx",
"body": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/derived-bodies/Ariki_Female_QuatSkin.glb",
"weld_threshold": 0.0006,
"shell_mm": 4,
"align": {
"top_bone": "neck_01",
"scale_xy": 0.1,
"scale_z": 0.1,
"z_nudge": 0.0483,
"xy_nudge": [
0.0,
0.0
]
},
"parts": {
"Pari": {
"slot": "Body",
"islands": [
0
],
"tris": 4000,
"planar_deg": 5,
"fit": {
"target": "BODY_SHELL",
"mask": "full",
"offset_mm": 3,
"wrap_mode": "OUTSIDE"
},
"weights": "dress",
"color": [
0.5,
0.2,
0.15
],
"texture": "../tools/tailor/textures/taniko.png"
}
},
"export": {
"out_dir": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/outfits/kapahaka",
"gender": "Female",
"set": "Kapahaka",
"note": "T\u00c4\u0081niko bodice (pari) - Body slot"
},
"min_island_verts": 40
}
@@ -0,0 +1,48 @@
{
"name": "piupiu",
"source": "C:/Users/Jeremy/tinqs/animation/tools/tailor/lena_piupiu_v2_garment.fbx",
"body": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/derived-bodies/Ariki_Female_QuatSkin.glb",
"weld_threshold": 0.0006,
"shell_mm": 4,
"align": {
"top_bone": "spine_01",
"scale_xy": 0.1,
"scale_z": 0.1,
"z_nudge": 0.037,
"xy_nudge": [
0.0,
0.0
]
},
"parts": {
"Piupiu": {
"slot": "Legs",
"islands": [
0
],
"tris": 8000,
"planar_deg": 5,
"fit": {
"target": "BODY_SHELL",
"mask": "full",
"offset_mm": 3,
"wrap_mode": "OUTSIDE",
"hem_mm": 14
},
"weights": "dress",
"color": [
0.45,
0.35,
0.15
],
"texture": "../tools/tailor/textures/piupiu.png"
}
},
"export": {
"out_dir": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/outfits/kapahaka",
"gender": "Female",
"set": "Kapahaka",
"note": "Flax skirt (piupiu) - Legs slot"
},
"min_island_verts": 40
}
@@ -0,0 +1,48 @@
{
"name": "piupiu_sb",
"source": "C:/Users/Jeremy/tinqs/animation/tools/tailor/lena_piupiu_v2_garment.fbx",
"body": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/derived-bodies/Ariki_Female_QuatSkin_SkirtRig.glb",
"weld_threshold": 0.0006,
"shell_mm": 4,
"align": {
"top_bone": "spine_01",
"scale_xy": 0.1,
"scale_z": 0.1,
"z_nudge": 0.037,
"xy_nudge": [
0.0,
0.0
]
},
"parts": {
"Piupiu": {
"slot": "Legs",
"islands": [
0
],
"tris": 8000,
"planar_deg": 5,
"fit": {
"target": "BODY_SHELL",
"mask": "full",
"offset_mm": 3,
"wrap_mode": "OUTSIDE",
"hem_mm": 14
},
"weights": "skirt_bones",
"color": [
0.45,
0.35,
0.15
],
"texture": "../tools/tailor/textures/piupiu.png"
}
},
"export": {
"out_dir": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/outfits/kapahaka",
"gender": "Female",
"set": "KapahakaSB",
"note": "Flax skirt (piupiu) on the skirt-boned SkirtRig Lena copy - Legs slot. Same garment as piupiu.json; differs only in body + weights mode."
},
"min_island_verts": 40
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 502 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 512 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 510 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

@@ -0,0 +1,24 @@
{
"source": "C:\\Users\\Jeremy\\tinqs\\ariki-game\\assets\\quaternius\\derived-bodies\\Ariki_Female_QuatSkin.glb",
"units": "meters (glTF)",
"height_total": 1.777,
"chest_circ": 1.0834,
"chest_z": 1.2636,
"waist_circ": 0.6747,
"waist_z": 1.0888,
"hip_circ": 1.0947,
"hip_z": 0.9455,
"thigh_circ": 0.8421,
"thigh_z": 0.8652,
"neck_circ": 0.3923,
"neck_z": 1.4568,
"bicep_circ": 0.417,
"shoulder_width": 0.3193,
"arm_len_shoulder_to_wrist": 0.5148,
"nape_to_pelvis": 0.4808,
"crotch_height": 0.9455,
"pelvis_height": 0.9318,
"knee_height": 0.5171,
"ankle_height": 0.1063,
"shoulder_z": 1.3973
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

@@ -0,0 +1,10 @@
# MTL Exporter v1.0 by Seungwoo Oh at CLO - Virtual Fashion Inc.
newmtl (Default_for_Simulation)_FRONT_19892
Ka 0.257132 0.257132 0.257132
Kd 1.000000 1.000000 1.000000
Ks 0.075000 0.075000 0.075000
Ns 28.763239
illum 2
d 1.000000
map_Ka C:/Users/Jeremy/tinqs/animation/tools/tailor/taniko.png
map_Kd C:/Users/Jeremy/tinqs/animation/tools/tailor/taniko.png
Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

@@ -0,0 +1,10 @@
# MTL Exporter v1.0 by Seungwoo Oh at CLO - Virtual Fashion Inc.
newmtl (Default_for_Simulation)_FRONT_34682
Ka 0.257132 0.257132 0.257132
Kd 1.000000 1.000000 1.000000
Ks 0.075000 0.075000 0.075000
Ns 28.763239
illum 2
d 1.000000
map_Ka C:/Users/Jeremy/tinqs/animation/tools/tailor/taniko.png
map_Kd C:/Users/Jeremy/tinqs/animation/tools/tailor/taniko.png
Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

@@ -0,0 +1,10 @@
# MTL Exporter v1.0 by Seungwoo Oh at CLO - Virtual Fashion Inc.
newmtl (Default_for_Simulation)_FRONT_41289
Ka 0.257132 0.257132 0.257132
Kd 1.000000 1.000000 1.000000
Ks 0.075000 0.075000 0.075000
Ns 28.763239
illum 2
d 1.000000
map_Ka C:/Users/Jeremy/tinqs/animation/tools/tailor/taniko.png
map_Kd C:/Users/Jeremy/tinqs/animation/tools/tailor/taniko.png
Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

@@ -0,0 +1,10 @@
# MTL Exporter v1.0 by Seungwoo Oh at CLO - Virtual Fashion Inc.
newmtl (Default_for_Simulation)_FRONT_18617
Ka 0.257132 0.257132 0.257132
Kd 1.000000 1.000000 1.000000
Ks 0.075000 0.075000 0.075000
Ns 28.763239
illum 2
d 1.000000
map_Ka C:/Users/Jeremy/tinqs/animation/tools/tailor/piupiu.png
map_Kd C:/Users/Jeremy/tinqs/animation/tools/tailor/piupiu.png
Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

@@ -0,0 +1,10 @@
# MTL Exporter v1.0 by Seungwoo Oh at CLO - Virtual Fashion Inc.
newmtl (Default_for_Simulation)_FRONT_32185
Ka 0.257132 0.257132 0.257132
Kd 1.000000 1.000000 1.000000
Ks 0.075000 0.075000 0.075000
Ns 28.763239
illum 2
d 1.000000
map_Ka C:/Users/Jeremy/tinqs/animation/tools/tailor/piupiu.png
map_Kd C:/Users/Jeremy/tinqs/animation/tools/tailor/piupiu.png
@@ -0,0 +1,61 @@
# Pari v2 (kapa haka taniko bodice) — tube-with-straps construction.
# Straps solve the v1 slide-down (tube settled underbust) AND cover the
# avatar's baked-in bra straps. Uses the proven tee shoulder-seam recipe.
# QC targets: band top ~1.31 m (above bust), hem ~1.05 m (waist) +-3 cm.
# python tools/md_bridge.py --file tools/tailor/md_pari.py --timeout 500
import os
import tempfile
report = {}
AVATAR_FBX = r"C:\Users\Jeremy\tinqs\animation\tools\tailor\avatar\Lena_QuatSkin_Avatar.fbx"
ZFAB = r"C:\Users\Public\Documents\MarvelousDesigner\New Assets\Fabric\(Default for Simulation).zfab"
TEX = r"C:\Users\Jeremy\tinqs\animation\tools\tailor\textures\taniko.png"
utility_api.NewProject()
op = ApiTypes.ImportExportOption()
op.scale = 10.0
op.bAddArrangementPoints = True
op.bAutoTranslate = True
report["avatar"] = import_api.ImportFBX(AVATAR_FBX, op)
# Tank-style: shoulder->waist 350 tall, 550/panel (snug over 1083 bust),
# straps 90 wide, wide-deep front scoop so the taniko band reads as a tube.
# y+ UP. Lines: 0 shoulder_L | 1-4 scoop (open) | 5 shoulder_R |
# 6 armhole_R | 7 side_R | 8 hem | 9 side_L | 10 armhole_L
def panel(scoop, dx):
top = 350.0
band = 350.0 - 105.0 # scoop floor -> band top ~ z 1.30
pts = [(65.0, top - 25.0), (155.0, top),
(205.0, top - scoop * 0.75), (275.0, top - scoop),
(345.0, top - scoop * 0.75), (395.0, top), (485.0, top - 25.0),
(550.0, 185.0), (550.0, 0.0), (0.0, 0.0), (0.0, 185.0)]
return pattern_api.CreatePatternWithPoints([(x + dx, y, 0) for (x, y) in pts])
pf = panel(105.0, 0.0) # front scoop floor = band top
pb = panel(45.0, 800.0) # shallower back
report["ids"] = [pf, pb]
for line in (0, 5, 7, 9):
pattern_api.AddSeamlinePairGroup(pf, line, pb, line, False, False)
fab = fabric_api.AddFabric(ZFAB)
fabric_api.SetBaseTextureMapImageGivenFilePath(TEX, fab)
for p in (pf, pb):
pattern_api.SetPatternPieceFabricIndex(p, fab)
arr = {a["ArrangementName"]: int(a["ArrangementIndex"])
for a in pattern_api.GetArrangementList()}
pattern_api.SetArrangement(pf, arr["Body_Front_Center_1"])
pattern_api.SetArrangement(pb, arr["Body_Back_Center_1"])
pattern_api.SetArrangementPosition(pf, 50, 55, 50)
pattern_api.SetArrangementPosition(pb, 0, 55, 50)
utility_api.ResetClothArrangement()
report["sim1"] = utility_api.Simulate(300)
utility_api.Refresh3DWindow()
snap = os.path.join(tempfile.gettempdir(), "tinqs_md_pari_v2.png")
export_api.ExportSnapshot3D(snap)
report["snap"] = snap
result = report
@@ -0,0 +1,56 @@
# Piupiu v2 (kapa haka flax skirt) — placement-targeted rebuild.
# QC targets (from reference photo, tools/tailor/qc_placement.py verifies):
# waistband top 1.05 m +-3 cm, hem 0.45 m +-4 cm (just below knee)
# python tools/md_bridge.py --file tools/tailor/md_piupiu.py --timeout 500
import os
import tempfile
report = {}
AVATAR_FBX = r"C:\Users\Jeremy\tinqs\animation\tools\tailor\avatar\Lena_QuatSkin_Avatar.fbx"
ZFAB = r"C:\Users\Public\Documents\MarvelousDesigner\New Assets\Fabric\(Default for Simulation).zfab"
TEX = r"C:\Users\Jeremy\tinqs\animation\tools\tailor\textures\piupiu.png" # dpi 43.3 = 600 mm
utility_api.NewProject()
op = ApiTypes.ImportExportOption()
op.scale = 10.0
op.bAddArrangementPoints = True
op.bAutoTranslate = True
report["avatar"] = import_api.ImportFBX(AVATAR_FBX, op)
# length 600 (waist 1.05 -> hem 0.45); tension waist 500/panel; A-line hem.
def panel(dx):
pts = [(160.0, 600.0), (660.0, 600.0), (820.0, 0.0), (0.0, 0.0)]
return pattern_api.CreatePatternWithPoints([(x + dx, y, 0) for (x, y) in pts])
sf = panel(0.0)
sb = panel(1000.0)
report["ids"] = [sf, sb]
pattern_api.AddSeamlinePairGroup(sf, 1, sb, 1, False, False)
pattern_api.AddSeamlinePairGroup(sf, 3, sb, 3, False, False)
fab = fabric_api.AddFabric(ZFAB)
fabric_api.SetBaseTextureMapImageGivenFilePath(TEX, fab)
for p in (sf, sb):
pattern_api.SetPatternPieceFabricIndex(p, fab)
arr = {a["ArrangementName"]: int(a["ArrangementIndex"])
for a in pattern_api.GetArrangementList()}
pattern_api.SetArrangement(sf, arr["Body_Front_Waist"])
pattern_api.SetArrangement(sb, arr["Body_Back_Waist"])
pattern_api.SetArrangementPosition(sf, 50, 30, 50)
pattern_api.SetArrangementPosition(sb, 0, 30, 50)
pattern_api.SetPatternStrengthen(sf, True)
pattern_api.SetPatternStrengthen(sb, True)
utility_api.ResetClothArrangement()
report["sim1"] = utility_api.Simulate(250)
pattern_api.SetPatternStrengthen(sf, False)
pattern_api.SetPatternStrengthen(sb, False)
report["sim2"] = utility_api.Simulate(50)
utility_api.Refresh3DWindow()
snap = os.path.join(tempfile.gettempdir(), "tinqs_md_piupiu_v2.png")
export_api.ExportSnapshot3D(snap)
report["snap"] = snap
result = report
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

+591
View File
@@ -0,0 +1,591 @@
#!/usr/bin/env python
"""blocks.py -- parametric garment blocks: worksheet numbers in, `md` config block out.
The tailor's half of the lane, as code. You read the reference image with
`.claude/skills/marvelous-designer/references/deconstruction.md` (slots ->
anchoring -> placement targets -> ease), and this module turns those worksheet
numbers into a config skeleton for tools/tailor/draft_garment.py: panels with
computed line indices, seams with the correct parity, arrangements with the
correct per-family x values, and the proven sim recipe.
python tools/tailor/blocks.py fitted_top --name pari_v4 --band-top-z 1.31 --hem-z 1.05 --ease 17
python tools/tailor/blocks.py aline_skirt --name skirt_v2 --waist-z 1.05 --hem-z 0.45
python tools/tailor/blocks.py strand_skirt --name piupiu_v4 --band-top-z 1.05 --hem-z 0.45
python tools/tailor/blocks.py --selftest
Writes a full config skeleton (name + `md` + `expect.bands`) to stdout or -o;
the worksheet echo goes to stderr. Feed the result to draft_garment.py, or merge
it into a shipping config in clothing/configs/. `md.exports` starts EMPTY on
purpose -- guard the export until a snapshot looks right, then set all four.
WHY BLOCKS INSTEAD OF HAND-TYPED POINT LISTS -- each function bakes in a
discovery that cost a session to find:
- Panel widths come from the body circumference AT THAT HEIGHT plus ease,
interpolated from the measurement card (tools/tailor/lena_measurements.json).
The body is stylized; real-world size charts produce clothes that don't fit.
- Vertical mm are 1:1 with world metres; straps must reach shoulder_z.
- BODICES TAPER (tooling.md 3.4): a rectangle cut for the bust carries ~400 mm
of surplus to a waist hem and it can only fold. Default taper is computed
from the hem-height circumference; equal on both panels so seam lengths match.
- SEAM PARITY (tooling.md 3.3): these blocks draft front/back from the SAME
point list offset by dx (identical, NOT mirrored), so side seams take
(True, True). (False, False) -- what the original recipes shipped with --
twists the panel; a busy texture concealed exactly that for several rounds.
Shoulder seams stay (False, False) (as shipped and working; never implicated).
- ARRANGEMENT X IS PER FAMILY (tooling.md 5): Body_*_Center_1 needs the SAME x
on both panels (50/50; a 50/0 split folds one shoulder); Leg_Skirt_* needs
DIFFERENT x (50/0; same-x drops the skirt on the floor). Never harmonise.
- Skirts arrange on Leg_Skirt_Front/Back y=92 -- Body_*_Waist put a skirt
8-14 cm high. Bottoms hold by TENSION (waist cut ~0.91 x hip circ);
elastic only mid-settle, which draft_garment.py now emits.
- Strand skirts are comb outlines with symmetric half-gaps at BOTH panel edges;
an asymmetric first tooth made one band edge slanted and notched the
waistband (the 0.075 mm "rounding" that wasn't).
Exit codes: 0 ok - 2 bad arguments - 1 selftest failure.
"""
import argparse
import json
import math
import os
import sys
import tempfile
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
DEFAULT_MEASUREMENTS = os.path.join(REPO_ROOT, "tools", "tailor", "lena_measurements.json")
DEFAULT_AVATAR = os.path.join(REPO_ROOT, "tools", "tailor", "avatar", "Lena_QuatSkin_Avatar.fbx")
DEFAULT_ZFAB = (r"C:\Users\Public\Documents\MarvelousDesigner\New Assets\Fabric"
r"\(Default for Simulation).zfab")
PANEL_GAP = 250.0 # 2D spacing between front and back panel bounding boxes
class BlockError(Exception):
"""Raised when worksheet numbers can't make a sane panel -- exits 2."""
# ------------------------------------------------------------------ measurements
def load_measurements(path=DEFAULT_MEASUREMENTS):
with open(path, "r", encoding="utf-8") as fh:
m = json.load(fh)
for key in ("shoulder_z", "chest_circ", "chest_z", "waist_circ", "waist_z",
"hip_circ", "hip_z", "neck_circ", "neck_z"):
if key not in m:
raise BlockError("measurement card {} is missing {!r} -- regenerate with "
"tools/tailor/measure_body.py".format(path, key))
return m
def body_circ_at(m, z):
"""Body circumference (m) at height z, piecewise-linear between the card's
landmarks. NEVER interpolates below the hip -- the legs bifurcate there and
a slice measures nonsense -- it clamps to the hip value instead."""
knots = sorted([(m["neck_z"], m["neck_circ"]), (m["chest_z"], m["chest_circ"]),
(m["waist_z"], m["waist_circ"]), (m["hip_z"], m["hip_circ"])],
reverse=True)
if z >= knots[0][0]:
return knots[0][1]
for (z_hi, c_hi), (z_lo, c_lo) in zip(knots, knots[1:]):
if z >= z_lo:
t = (z_hi - z) / (z_hi - z_lo)
return c_hi + t * (c_lo - c_hi)
return knots[-1][1] # below the hip: clamp, don't interpolate
def _max_circ_over(m, z_lo, z_hi, samples=9):
"""Max body circumference over [z_lo, z_hi]. The landmark knots inside the
span are sampled EXPLICITLY -- a uniform grid straddles the bust peak and
undersizes the panel by ~2 cm."""
zs = [z_lo + (z_hi - z_lo) * i / (samples - 1.0) for i in range(samples)]
zs += [z for z in (m["neck_z"], m["chest_z"], m["waist_z"], m["hip_z"])
if z_lo <= z <= z_hi]
return max(body_circ_at(m, z) for z in zs)
def _mm(metres):
return round(metres * 1000.0, 1)
# ----------------------------------------------------------------------- blocks
def fitted_top(m, band_top_z, hem_z, ease_mm=100.0, back_scoop_mm=45.0,
strap_width_mm=90.0, neck_gap_mm=None, armhole_depth_mm=165.0,
taper_mm=None, shoulder_drop_mm=25.0, name="top"):
"""Tank / tee / fitted bodice / bandeau-with-straps. Proven: tee, pari.
band_top_z / hem_z: placement targets in metres (worksheet row 4). The
front scoop is derived so the visible band top lands at band_top_z while
the straps reach the shoulders (they must, or the garment settles at the
underbust -- straps beat tubes, always).
"""
if not band_top_z > hem_z:
raise BlockError("band_top_z must be above hem_z")
H = _mm(m["shoulder_z"] - hem_z) # strap top = shoulder
band_top_h = _mm(band_top_z - hem_z)
if band_top_h >= H - shoulder_drop_mm:
raise BlockError("band top {}m is at/above the shoulders".format(band_top_z))
front_scoop = H - band_top_h
circ = _max_circ_over(m, hem_z, band_top_z)
W = round((circ * 1000.0 + ease_mm) / 2.0, 1) # per-panel width
G = neck_gap_mm if neck_gap_mm is not None else min(260.0, round(0.44 * W, 1))
c = W / 2.0
tip_l = c - G / 2.0 - strap_width_mm
if tip_l < 20.0:
raise BlockError("neck gap {} + straps {} don't fit a {} panel"
.format(G, strap_width_mm, W))
tip_y = H - shoulder_drop_mm
arm_y = tip_y - armhole_depth_mm
if arm_y <= 0:
raise BlockError("armhole depth {} exceeds the panel height {}".format(
armhole_depth_mm, H))
if taper_mm is None:
# Bodices must taper: hem width from the circumference AT the hem.
hem_w = (body_circ_at(m, hem_z) * 1000.0 + ease_mm) / 2.0
taper_mm = max(0.0, min(round((W - hem_w) / 2.0, 1), round(0.2 * W, 1)))
def outline(scoop):
neck_l, neck_r = c - G / 2.0, c + G / 2.0
if scoop > 0.5:
ny = H
mids = [(c - 0.3 * G, H - 0.75 * scoop), (c, H - scoop),
(c + 0.3 * G, H - 0.75 * scoop)]
else: # straight (tee back)
ny = tip_y
mids = [(c - 0.3 * G, tip_y), (c, tip_y), (c + 0.3 * G, tip_y)]
pts = ([(tip_l, tip_y), (neck_l, ny)] + mids +
[(neck_r, ny), (c + G / 2.0 + strap_width_mm, tip_y),
(W, arm_y), (W - taper_mm, 0.0), (taper_mm, 0.0), (0.0, arm_y)])
return [(round(x, 2), round(y, 2)) for x, y in pts]
# Lines: 0 shoulder_L | 1-4 neckline (open) | 5 shoulder_R | 6 armhole_R
# 7 side_R | 8 hem | 9 side_L | 10 armhole_L (closing edge)
lines = {"shoulder_l": 0, "shoulder_r": 5, "side_r": 7, "hem": 8, "side_l": 9}
md = {
"panels": [
{"name": "front", "dx": 0.0,
"note": "fitted_top block: W {} (circ {} + ease {}), H {}, scoop {}, taper {}"
.format(W, round(circ * 1000, 1), ease_mm, H,
round(front_scoop, 1), taper_mm),
"points": outline(front_scoop)},
{"name": "back", "dx": round(W + PANEL_GAP, 1),
"note": "same block, back scoop {}; lines 0 shL | 1-4 neck | 5 shR | "
"6 armR | 7 sideR | 8 hem | 9 sideL | 10 armL".format(back_scoop_mm),
"points": outline(back_scoop_mm)},
],
"seams": [
# Shoulders: (False, False) as shipped and working -- never implicated.
{"a": "front", "b": "back",
"lines": [lines["shoulder_l"], lines["shoulder_r"]]},
# Sides: identical (not mirrored) panels need BOTH edges reversed --
# (False, False) here is the twist that a taniko print concealed.
{"a": "front", "a_line": lines["side_r"], "b": "back",
"b_line": lines["side_r"], "reverse_a": True, "reverse_b": True},
{"a": "front", "a_line": lines["side_l"], "b": "back",
"b_line": lines["side_l"], "reverse_a": True, "reverse_b": True},
],
"arrangements": [
# Body_*_Center_1 wants the SAME x on both panels (sweep-verified);
# the shipped 50/0 split folds one shoulder and exposes the reverse face.
{"panel": "front", "point": "Body_Front_Center_1", "offset": [50, 55, 50]},
{"panel": "back", "point": "Body_Back_Center_1", "offset": [50, 55, 50]},
],
# Sides fully sewn -> strengthen through the WHOLE settle, relax at the end.
# (If you cut the side seams down to partial coverage, DROP strengthen:
# it rotates an under-constrained garment.)
"sim": {"strengthen": True, "settle_frames": 250, "relax_frames": 50},
}
bands = {name: {"top_m": round(band_top_z, 3), "bottom_m": round(hem_z, 3),
"tol_m": 0.03, "from": "cover"}}
worksheet = {"block": "fitted_top", "panel_w_mm": W, "panel_h_mm": H,
"body_circ_mm": round(circ * 1000, 1), "ease_mm": ease_mm,
"front_scoop_mm": round(front_scoop, 1), "back_scoop_mm": back_scoop_mm,
"taper_mm": taper_mm, "lines": lines}
return {"md": md, "expect_bands": bands, "worksheet": worksheet}
def aline_skirt(m, waist_z, hem_z, tension=0.91, flare=1.5, waist_elastic=False,
elastic_ratio=0.9, arrange_y=92, name="skirt"):
"""A-line / straight skirt -- the most forgiving garment. Proven: skirt, piupiu v2.
Held up by TENSION: the waist edge is cut tension x hip circumference
(smaller than the hips it must pass over). flare = hem width / waist width.
"""
if not waist_z > hem_z:
raise BlockError("waist_z must be above hem_z")
if tension >= 0.97:
raise BlockError("tension {} won't grip -- the waist must be cut smaller "
"than the hips (0.90-0.92 proven)".format(tension))
H = _mm(waist_z - hem_z)
waist_w = round(m["hip_circ"] * 1000.0 * tension / 2.0, 1)
hem_w = round(waist_w * flare, 1)
inset = round((hem_w - waist_w) / 2.0, 1)
pts = [(inset, H), (inset + waist_w, H), (hem_w, 0.0), (0.0, 0.0)]
lines = {"waist": 0, "side_r": 1, "hem": 2, "side_l": 3}
md = {
"panels": [
{"name": "front", "dx": 0.0,
"note": "aline_skirt block: waist {} (= {} x {} hip), hem {}, H {}"
.format(waist_w, tension, _mm(m["hip_circ"]), hem_w, H),
"points": [(round(x, 2), round(y, 2)) for x, y in pts]},
{"name": "back", "dx": round(hem_w + PANEL_GAP, 1),
"note": "identical block; lines 0 waist | 1 side_R | 2 hem | 3 side_L",
"points": [(round(x, 2), round(y, 2)) for x, y in pts]},
],
"seams": [
# Identical (not mirrored) panels: side seams take (True, True).
{"a": "front", "a_line": lines["side_r"], "b": "back",
"b_line": lines["side_r"], "reverse_a": True, "reverse_b": True},
{"a": "front", "a_line": lines["side_l"], "b": "back",
"b_line": lines["side_l"], "reverse_a": True, "reverse_b": True},
],
"arrangements": [
# Leg_Skirt_*, NOT Body_*_Waist (which places 8-14 cm high), and the
# two x values must DIFFER -- 50/50 drops the skirt on the floor.
{"panel": "front", "point": "Leg_Skirt_Front", "offset": [50, arrange_y, 50]},
{"panel": "back", "point": "Leg_Skirt_Back", "offset": [0, arrange_y, 50]},
],
"sim": {"strengthen": True, "settle_frames": 250, "relax_frames": 50},
}
if waist_elastic:
md["sim"]["elastic"] = [
{"panel": "front", "line": lines["waist"],
"total_length": round(waist_w * elastic_ratio, 1)},
{"panel": "back", "line": lines["waist"],
"total_length": round(waist_w * elastic_ratio, 1)},
]
md["sim"]["elastic_frames"] = 80
bands = {name: {"top_m": round(waist_z, 3), "bottom_m": round(hem_z, 3),
"tol_m": 0.03, "bottom_tol_m": 0.04, "from": "cover"}}
worksheet = {"block": "aline_skirt", "waist_w_mm": waist_w, "hem_w_mm": hem_w,
"panel_h_mm": H, "tension": tension, "flare": flare, "lines": lines}
return {"md": md, "expect_bands": bands, "worksheet": worksheet}
def strand_skirt(m, band_top_z, hem_z, band_height_mm=60.0, strands_per_panel=16,
gap_fraction=0.25, tension=0.91, waist_elastic=True,
elastic_ratio=0.9, arrange_y=92, particle_distance_mm=20.0,
name="strandskirt"):
"""Strand / fringe skirt (piupiu, hula, fur trim): a waistband with teeth cut
into the outline -- NEVER partial seams. Only the two band side edges sew.
Light garments do not slide into place: arrangement height IS the placement,
so arrange_y is load-bearing. Elastic defaults ON (mid-settle) -- that is
what locked the piupiu waistband.
"""
if not band_top_z > hem_z:
raise BlockError("band_top_z must be above hem_z")
H = _mm(band_top_z - hem_z)
if band_height_mm >= H:
raise BlockError("band height {} swallows the whole {} panel".format(
band_height_mm, H))
band_bot = round(H - band_height_mm, 2) # teeth run band_bot -> 0
W = round(m["hip_circ"] * 1000.0 * tension / 2.0, 1)
pitch = W / strands_per_panel
gap = gap_fraction * pitch
tooth = pitch - gap
# Symmetric half-gaps: tooth i spans [gap/2 + i*pitch, gap/2 + i*pitch + tooth],
# so BOTH band side edges are true verticals of exactly band_height_mm. The
# asymmetric version (first tooth flush) slants one edge and notches the band.
pts = [(0.0, H), (W, H), (W, band_bot)]
for i in reversed(range(strands_per_panel)):
x_l = gap / 2.0 + i * pitch
x_r = x_l + tooth
pts += [(x_r, band_bot), (x_r, 0.0), (x_l, 0.0), (x_l, band_bot)]
pts.append((0.0, band_bot))
pts = [(round(x, 2), round(y, 2)) for x, y in pts]
side_l_line = len(pts) - 1 # closing edge back to (0, H)
lines = {"waist": 0, "side_r": 1, "side_l": side_l_line}
panel_note = ("strand_skirt block: band {} tall + {} strands of {} (gap {}) over "
"{} wide (= {} x hip); teeth are OUTLINE, not partial seams"
.format(band_height_mm, strands_per_panel, round(tooth, 1),
round(gap, 1), W, tension))
md = {
"panels": [
{"name": "front", "dx": 0.0, "note": panel_note,
"points": pts, "particle_distance": particle_distance_mm},
{"name": "back", "dx": round(W + PANEL_GAP, 1),
"note": "identical comb; only the two band side edges sew",
"points": pts, "particle_distance": particle_distance_mm},
],
"seams": [
{"a": "front", "a_line": lines["side_r"], "b": "back",
"b_line": lines["side_r"], "reverse_a": True, "reverse_b": True},
{"a": "front", "a_line": lines["side_l"], "b": "back",
"b_line": lines["side_l"], "reverse_a": True, "reverse_b": True},
],
"arrangements": [
{"panel": "front", "point": "Leg_Skirt_Front", "offset": [50, arrange_y, 50]},
{"panel": "back", "point": "Leg_Skirt_Back", "offset": [0, arrange_y, 50]},
],
"sim": {
"strengthen": True, "settle_frames": 250, "relax_frames": 50,
"elastic_frames": 80,
},
}
if waist_elastic:
md["sim"]["elastic"] = [
{"panel": "front", "line": lines["waist"],
"total_length": round(W * elastic_ratio, 1)},
{"panel": "back", "line": lines["waist"],
"total_length": round(W * elastic_ratio, 1)},
]
bands = {name: {"top_m": round(band_top_z, 3), "bottom_m": round(hem_z, 3),
"tol_m": 0.03, "bottom_tol_m": 0.04, "from": "cover"}}
worksheet = {"block": "strand_skirt", "band_w_mm": W, "panel_h_mm": H,
"band_height_mm": band_height_mm, "strands": strands_per_panel,
"tooth_mm": round(tooth, 1), "gap_mm": round(gap, 1),
"tension": tension, "lines": lines,
"qc_note": "gappy silhouette: qc_placement.py pixel classifier is "
"INVALID here -- measure the exported OBJ instead"}
return {"md": md, "expect_bands": bands, "worksheet": worksheet}
BLOCKS = {"fitted_top": fitted_top, "aline_skirt": aline_skirt,
"strand_skirt": strand_skirt}
# ------------------------------------------------------------- config skeleton
def config_skeleton(name, block_out, texture=None, texture_dpi=None):
"""Wrap a block's md fragment in a full, runnable config: scene defaults,
QC snapshot paths (front, back, AND pre-sim), and a guarded publish stage."""
tmp = tempfile.gettempdir().replace("\\", "/")
md = {
"reset": "new_project",
"avatar_fbx": DEFAULT_AVATAR.replace("\\", "/"),
"avatar_scale": 10.0,
"add_arrangement_points": True,
"auto_translate": True,
"zfab": DEFAULT_ZFAB.replace("\\", "/"),
}
if texture:
md["texture"] = texture
if texture_dpi:
md["texture_dpi"] = texture_dpi
md.update(block_out["md"])
md.update({
"cam_viewpoint": 2,
"presim_snapshot": "{}/tinqs_md_{}_presim.png".format(tmp, name),
"snapshot": "{}/tinqs_md_{}.png".format(tmp, name),
"back_snapshot": "{}/tinqs_md_{}_back.png".format(tmp, name),
"export_dir": os.path.join(REPO_ROOT, "tools", "tailor").replace("\\", "/"),
"export_basename": "lena_{}".format(name),
# Guard the export: [] until a snapshot looks right, then all four.
"exports": [],
})
return {
"name": name,
"_worksheet": block_out["worksheet"],
"md": md,
"expect": {"bands": block_out["expect_bands"]},
}
# ----------------------------------------------------------- offline validation
def _edge_len(points, i):
ax, ay = points[i]
bx, by = points[(i + 1) % len(points)]
return math.hypot(bx - ax, by - ay)
def verify_seam_symmetry(md, tol=1e-6):
"""Offline mirror of the generated script's GetLineLength check: paired seam
edges must be EQUAL, not close. Returns a list of mismatch strings."""
panels = {p["name"]: p["points"] for p in md["panels"]}
bad = []
for s in md.get("seams", []):
pairs = ([(int(n), int(n)) for n in s["lines"]] if "lines" in s
else [(int(s["a_line"]), int(s["b_line"]))])
for la, lb in pairs:
fa = _edge_len(panels[s["a"]], la)
fb = _edge_len(panels[s["b"]], lb)
if abs(fa - fb) > tol:
bad.append("{}.{}={:.4f} vs {}.{}={:.4f}".format(
s["a"], la, fa, s["b"], lb, fb))
return bad
# ---------------------------------------------------------------------- selftest
def _selftest():
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import draft_garment
m = load_measurements()
fails = []
def check(label, cond, detail=""):
print(" {} {}{}".format("ok " if cond else "FAIL", label,
" -- " + str(detail) if detail else ""))
if not cond:
fails.append(label)
print("[1/4] fitted_top reproduces the shipped tee from placement targets")
tee = fitted_top(m, band_top_z=1.257, hem_z=0.917, ease_mm=97)
ws = tee["worksheet"]
check("panel width ~590", abs(ws["panel_w_mm"] - 590) < 15, ws["panel_w_mm"])
check("panel height ~480", abs(ws["panel_h_mm"] - 480) < 10, ws["panel_h_mm"])
check("scoop ~140", abs(ws["front_scoop_mm"] - 140) < 12, ws["front_scoop_mm"])
check("no taper at hip hem", ws["taper_mm"] == 0.0, ws["taper_mm"])
print("[2/4] fitted_top reproduces the shipped pari -- WITH the taper it lacked")
pari = fitted_top(m, band_top_z=1.295, hem_z=1.05, ease_mm=17)
ws = pari["worksheet"]
check("panel width ~550", abs(ws["panel_w_mm"] - 550) < 15, ws["panel_w_mm"])
check("panel height ~350", abs(ws["panel_h_mm"] - 350) < 10, ws["panel_h_mm"])
check("scoop ~105", abs(ws["front_scoop_mm"] - 105) < 10, ws["front_scoop_mm"])
check("waist hem tapers (3.4: bodices must)", 50 < ws["taper_mm"] < 110,
ws["taper_mm"])
# Regression: a uniform sample grid can straddle the bust knot and undersize
# the panel ~2 cm; the knots must be sampled explicitly.
ws2 = fitted_top(m, band_top_z=1.31, hem_z=1.05, ease_mm=17)["worksheet"]
check("bust peak captured at any band span", abs(ws2["body_circ_mm"] - 1083.4) < 1,
ws2["body_circ_mm"])
print("[3/4] aline_skirt reproduces the shipped piupiu v2")
piu = aline_skirt(m, waist_z=1.05, hem_z=0.45, tension=0.913, flare=1.64)
ws = piu["worksheet"]
check("waist ~500/panel", abs(ws["waist_w_mm"] - 500) < 5, ws["waist_w_mm"])
check("hem ~820", abs(ws["hem_w_mm"] - 820) < 10, ws["hem_w_mm"])
check("height 600", abs(ws["panel_h_mm"] - 600) < 1, ws["panel_h_mm"])
print("[4/4] every block: symmetric seams, valid config, emitted script compiles")
strand = strand_skirt(m, band_top_z=1.05, hem_z=0.45)
n_pts = len(strand["md"]["panels"][0]["points"])
check("comb outline point count", n_pts == 4 * 16 + 4, n_pts)
tmpdir = tempfile.mkdtemp(prefix="tinqs_blocks_selftest_")
for label, out in (("tee", tee), ("pari", pari), ("piupiu", piu),
("strand", strand)):
bad = verify_seam_symmetry(out["md"])
check("{}: paired seam edges equal".format(label), not bad, "; ".join(bad))
cfg = config_skeleton("selftest_" + label, out)
cfg_path = os.path.join(tmpdir, label + ".json")
with open(cfg_path, "w", encoding="utf-8") as fh:
json.dump(cfg, fh, indent=2)
try:
spec = draft_garment.parse_md(cfg, cfg_path)
text = draft_garment.emit(spec, "all", cfg_path)
compile(text, cfg_path, "exec")
check("{}: parse+emit+compile".format(label), True)
except Exception as exc: # noqa: BLE001
check("{}: parse+emit+compile".format(label), False, exc)
print("\n{} -- artifacts in {}".format(
"ALL PASS" if not fails else "{} FAILURE(S)".format(len(fails)), tmpdir))
return 1 if fails else 0
# -------------------------------------------------------------------------- cli
def main(argv=None):
argv = list(sys.argv[1:] if argv is None else argv)
if "--selftest" in argv:
return _selftest()
ap = argparse.ArgumentParser(
description="Emit a garment config skeleton from a tailoring block. "
"Read references/deconstruction.md first: these arguments ARE "
"the worksheet.")
common = argparse.ArgumentParser(add_help=False)
common.add_argument("--measurements", default=DEFAULT_MEASUREMENTS,
help="measurement card (default: Lena's)")
common.add_argument("--name", required=True, help="garment name, e.g. pari_v4")
common.add_argument("-o", "--out", default="-", help="config path ('-' = stdout)")
common.add_argument("--texture", help="optional texture PNG (DPI sets physical size)")
common.add_argument("--texture-dpi", type=float)
sub = ap.add_subparsers(dest="block", required=True)
top = sub.add_parser("fitted_top", parents=[common], help="tank/tee/bodice (proven)")
top.add_argument("--band-top-z", type=float, required=True)
top.add_argument("--hem-z", type=float, required=True)
top.add_argument("--ease", type=float, default=100.0,
help="total ease mm: 15-20 snug, ~100 regular")
top.add_argument("--back-scoop", type=float, default=45.0)
top.add_argument("--strap-width", type=float, default=90.0)
top.add_argument("--neck-gap", type=float)
top.add_argument("--armhole-depth", type=float, default=165.0)
top.add_argument("--taper", type=float, help="per-side hem taper mm (default: computed)")
sk = sub.add_parser("aline_skirt", parents=[common],
help="A-line/straight skirt (proven)")
sk.add_argument("--waist-z", type=float, required=True)
sk.add_argument("--hem-z", type=float, required=True)
sk.add_argument("--tension", type=float, default=0.91)
sk.add_argument("--flare", type=float, default=1.5)
sk.add_argument("--waist-elastic", action="store_true")
sk.add_argument("--arrange-y", type=int, default=92)
st = sub.add_parser("strand_skirt", parents=[common],
help="strand/fringe skirt (comb outline)")
st.add_argument("--band-top-z", type=float, required=True)
st.add_argument("--hem-z", type=float, required=True)
st.add_argument("--band-height", type=float, default=60.0)
st.add_argument("--strands", type=int, default=16, help="strands per panel")
st.add_argument("--gap-fraction", type=float, default=0.25)
st.add_argument("--tension", type=float, default=0.91)
st.add_argument("--no-waist-elastic", action="store_true")
st.add_argument("--arrange-y", type=int, default=92)
args = ap.parse_args(argv)
try:
m = load_measurements(args.measurements)
if args.block == "fitted_top":
out = fitted_top(m, args.band_top_z, args.hem_z, ease_mm=args.ease,
back_scoop_mm=args.back_scoop,
strap_width_mm=args.strap_width,
neck_gap_mm=args.neck_gap,
armhole_depth_mm=args.armhole_depth,
taper_mm=args.taper, name=args.name)
elif args.block == "aline_skirt":
out = aline_skirt(m, args.waist_z, args.hem_z, tension=args.tension,
flare=args.flare, waist_elastic=args.waist_elastic,
arrange_y=args.arrange_y, name=args.name)
else:
out = strand_skirt(m, args.band_top_z, args.hem_z,
band_height_mm=args.band_height,
strands_per_panel=args.strands,
gap_fraction=args.gap_fraction, tension=args.tension,
waist_elastic=not args.no_waist_elastic,
arrange_y=args.arrange_y, name=args.name)
except BlockError as exc:
print("error: {}".format(exc), file=sys.stderr)
return 2
bad = verify_seam_symmetry(out["md"])
if bad:
print("error: block produced unequal seam pairs (bug): {}".format(bad),
file=sys.stderr)
return 2
cfg = config_skeleton(args.name, out, texture=args.texture,
texture_dpi=args.texture_dpi)
text = json.dumps(cfg, indent=2)
print("worksheet: {}".format(json.dumps(out["worksheet"])), file=sys.stderr)
if args.out == "-":
print(text)
else:
with open(args.out, "w", encoding="utf-8") as fh:
fh.write(text + "\n")
print("wrote {}".format(args.out), file=sys.stderr)
print("next: python tools/tailor/draft_garment.py --config {} --emit "
"work/{}/md_script.py".format(args.out, args.name), file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())
+821
View File
@@ -0,0 +1,821 @@
#!/usr/bin/env python
"""draft_garment.py -- ONE template that replaces the four copy-paste MD garment scripts.
python tools/tailor/draft_garment.py --config <resolved.json> --emit <out_script.py>
[--stage draft|drape|publish|all] [--timeout-hint]
GENERATION MODE ONLY. This script never talks to Marvelous Designer: it reads the
`md` block of a garment config and WRITES a self-contained bridge script that
`tools/md_bridge.py --file` executes inside MD. So it runs headless, in CI, with no
session, and the emitted script is a reviewable artifact in `work/<name>/`.
python tools/tailor/draft_garment.py --config work/pari/resolved.json \
--emit work/pari/md_script.py
python tools/md_bridge.py --file work/pari/md_script.py --timeout 500
Replaces (which stay in place until this is proven on a live session):
`md_pari.py`, `md_piupiu.py`, `md_tee_v1.py`, `md_skirt_v1.py`. Their identical
~100% shell is the template below; only panels/seams/arrangements/fabric differed.
`md_recon.py` is an API-introspection tool and does NOT fold in here.
--------------------------------------------------------------------------------
THE `md` CONFIG BLOCK
--------------------------------------------------------------------------------
Everything is optional except `panels`. Relative paths resolve against the config
file's directory, then the repo root; emitted paths are always absolute (MD needs
absolute paths).
```jsonc
"md": {
// --- scene -------------------------------------------------------------
"reset": "new_project", // "new_project" (default) | "clear_patterns" | "none"
// new_project = utility_api.NewProject() [DELETES the avatar]
// clear_patterns = delete every pattern, keep the loaded avatar
"avatar_fbx": "C:/.../Lena_QuatSkin_Avatar.fbx", // omit to keep the avatar already in the scene
"avatar_scale": 10.0, // Blender-exported FBX lands 10x small; default 10.0
"add_arrangement_points": true,// default true -- without it there is nowhere to hang cloth
"auto_translate": true, // default true
// --- fabric ------------------------------------------------------------
"zfab": "C:/Users/Public/Documents/MarvelousDesigner/New Assets/Fabric/(Default for Simulation).zfab",
"texture": "C:/.../textures/taniko.png", // optional; DPI in the PNG sets physical size
"texture_dpi": 96.012, // optional; VERIFIED against the PNG at emit time
"base_color": [0.13, 0.3, 0.75, 1.0], // optional RGBA; alternative to `texture`
// (fabric calls are emitted only when `zfab` is set)
// --- pattern -----------------------------------------------------------
"panels": [ // 2D pattern, millimetres, +y is UP in the 3D mapping
{"name": "front", "dx": 0.0, "dy": 0.0, "note": "front scoop floor = band top",
"points": [[65.0, 325.0], [155.0, 350.0], ...], // line i = point[i] -> point[i+1]
"particle_distance": 20.0, // optional; sim mesh resolution (mm) -- light/gappy panels
"thickness_collision": 3.0} // optional; stand-off from the skin (mm), set BEFORE the settle
],
"seams": [ // whole-edge pairs only
{"a": "front", "b": "back", "lines": [0, 5, 7, 9]}, // SAME index both panels (safe form)
{"a": "front", "a_line": 1, "b": "back", "b_line": 3, // explicit form
"reverse_a": false, "reverse_b": false}
],
"seam_check": true, // default true: fingerprint every seam pair with GetLineLength
// and FAIL the draft on any inequality > 0.1 mm -- paired edges
// must be EQUAL, not close (a 0.075 mm "rounding" gap was a
// slanted edge that notched a waistband)
"arrangements": [ // looked up BY NAME at run time, never by index
{"panel": "front", "point": "Body_Front_Center_1", "offset": [50, 55, 50]}
],
// --- drape -------------------------------------------------------------
"sim": {
"strengthen": true, // true = every panel | ["front"] = those panels | false/absent = none
"settle_frames": 250, // main settle, cloth still stiff
"elastic": [ // optional; applied MID-SETTLE (after settle_frames), never frame 0
{"panel": "front", "line": 0, "total_length": 450.0, "strength": null}
],
"elastic_frames": 80, // sim frames after enabling elastic (default 80 when elastic set)
"relax_frames": 50 // 0/absent = no relax pass (strengthen stays as-is)
},
"cam_viewpoint": 2, // optional utility_api.SetCamViewPoint(2) = front, before snapshots
"presim_snapshot": "C:/.../pari_presim.png", // optional; THE diagnostic (see lessons below)
"snapshot": "C:/.../tinqs_md_pari_v2.png", // ExportSnapshot3D target for the drape stage
"back_snapshot": "C:/.../pari_back.png", // optional; rear view via ExportTurntableImages(4)
// -- ExportSnapshot3D CANNOT show the back at all
// --- publish -----------------------------------------------------------
"export_dir": "C:/Users/Jeremy/tinqs/animation/tools/tailor", // default: tools/tailor/
"export_basename": "lena_pari_v3", // versioned; <base>.zprj / <base>_garment.fbx /
// <base>_garment.obj / <base>.zpac
"exports": ["zprj", "fbx", "obj", "zpac"] // default: all four
}
```
--------------------------------------------------------------------------------
STAGES (`--stage`, matching the contract's three MD stages)
--------------------------------------------------------------------------------
| stage | emits |
|-----------|------------------------------------------------------------------------|
| `draft` | reset, avatar import, panels, seams, fabric, arrangement assignment |
| | (+ `ResetClothArrangement` and a 0-frame snapshot iff `presim_snapshot`)|
| `drape` | strengthen, `ResetClothArrangement`, `Simulate`, relax, `Refresh3DWindow`, `ExportSnapshot3D` |
| `publish` | `ExportZPrj` / `ExportFBX` / `ExportOBJ` / `ExportZPac` |
| `all` | all three, in one script -- **the default, and the recommended path** |
Split stages work because MD's exec namespace persists WITHIN a session: `draft`
stores `{"garment", "patterns", "fabric"}` in a `TINQS_GARMENT` dict that `drape`
and `publish` read back, and they fail loudly if it is missing or belongs to a
different garment. It does NOT persist across sessions, so a session restart means
re-running `draft`. `--stage all` sidesteps all of that and reproduces exactly the
call order of the four originals -- prefer it unless you are iterating on a drape.
--------------------------------------------------------------------------------
HARD-WON MD FACTS (also emitted into every generated script)
--------------------------------------------------------------------------------
- 2D pattern space: units are mm, +y is UP in the 3D mapping. Straps/neck at HIGH
y, hem at y=0. (Panels built y-down drape upside-down over the head and tangle
-- it looks like a seam bug, it isn't. Seven "seam" iterations were this.)
- MD internal 3D unit is mm (gravity default -9800). Blender-exported FBX avatars
land 10x small -- import with op.scale = 10.
- ImportAvatar() is .avt ONLY and returns False on FBX; use import_api.ImportFBX.
- op.bAddArrangementPoints = True auto-generates ~98 named arrangement points.
- CreatePatternWithPoints takes (x, y, type) triples; type 0 = corner. The returned
id is the pattern index; line i = edge point[i] -> point[i+1].
- Seams: AddSeamlinePairGroup(patA, lineA, patB, lineB, False, False) with the SAME
line index on mirrored front/back panels; cross-pairing sews the garment over the
face. Whole-edge sewing only -> build neck gaps and armholes into the outline as
extra points, not as partial seams.
- SetArrangement() only ASSIGNS; utility_api.ResetClothArrangement() APPLIES it
(ReDrape3DArrangement only materializes not-yet-draped cloth).
- Arrangement INDICES REGENERATE on every avatar import -- always look up by name
from GetArrangementList(). Offsets aren't stable either: verify with a 0-frame
snapshot.
- utility_api.NewProject() DELETES the avatar -- re-import after.
- fabric_api.AddFabric() needs a .zfab FILE PATH; a name string silently fails.
Fabric index 0 is the shared default -- coloring it dyes every garment.
- SetBaseTextureMapImageGivenFilePath(path, fabricIdx) -- PATH IS ARG 0.
- AssignFabricToPattern() returns False for every arg order tried; use
pattern_api.SetPatternPieceFabricIndex(pattern, fabric).
- PNG DPI sets a texture's physical size in MD (1024 px @ 54.2 dpi = 480 mm).
Control tiling by setting dpi in PIL, not by scaling the image.
- SetViewPoint() does nothing; utility_api.SetCamViewPoint(2) = front view.
- Strengthen through the WHOLE settle, relax only at the end -- soft fabric from
frame 0 rolls into a bunch at the waist.
- Elastic mid-settle, never from the start; bottoms stay up by TENSION (waist cut
smaller than hips), not elastic.
- One garment per scene: a second garment, even frozen, grabs and inverts the new one.
- utility_api.Simulate(n) is synchronous, ~1 min per 300 frames -- raise the bridge
client --timeout accordingly.
- GetClothPositions() stays empty from Python: there is NO mesh introspection, so
every drape judgement is image-based (that is what gate G1 measures).
Exit codes: 0 emitted · 2 invalid config · 3 could not write.
"""
import argparse
import datetime
import json
import os
import sys
STAGES = ("draft", "drape", "publish", "all")
EXPORT_KINDS = ("zprj", "fbx", "obj", "zpac")
# three dirnames: this file lives at <repo>/tools/tailor/draft_garment.py
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
DEFAULT_EXPORT_DIR = os.path.join(REPO_ROOT, "tools", "tailor")
# Emitted verbatim into every generated script -- the lessons must travel with the
# code a human actually reads in work/<name>/md_script.py, not only live here.
LESSONS = """\
# HARD-WON MD FACTS (do not rediscover -- see tools/tailor/draft_garment.py):
# - 2D pattern space: units are mm, +y is UP in the 3D mapping. Straps/neck at
# HIGH y, hem at y=0. Panels built y-down drape upside-down over the head and
# tangle -- it looks like a seam bug, it isn't.
# - MD's 3D unit is mm (gravity -9800). Blender-exported FBX avatars land 10x
# small -- import with op.scale = 10. ImportAvatar() is .avt only (False on FBX).
# - op.bAddArrangementPoints = True generates the ~98 named arrangement points;
# without it there is nowhere to hang cloth.
# - CreatePatternWithPoints takes (x, y, type) triples, type 0 = corner. Returned
# id is the pattern index; line i = edge point[i] -> point[i+1].
# - Seams are WHOLE-EDGE only: AddSeamlinePairGroup(a, lineA, b, lineB, False,
# False) with the SAME line index on mirrored front/back panels. Cross-pairing
# sews the garment over the face. Neck gaps and armholes are extra points in
# the outline, not partial seams.
# - SetArrangement() only ASSIGNS; utility_api.ResetClothArrangement() APPLIES it
# (ReDrape3DArrangement only materializes not-yet-draped cloth).
# - Arrangement INDICES REGENERATE on every avatar import -- always look up by
# NAME from GetArrangementList(). Offsets aren't stable either; verify with a
# 0-frame snapshot (the diagnostic that unsticks everything).
# - utility_api.NewProject() DELETES the avatar -- re-import after.
# - AddFabric() needs a .zfab FILE PATH (a name string silently fails); fabric 0
# is the shared default, coloring it dyes every garment. Assign with
# SetPatternPieceFabricIndex -- AssignFabricToPattern() never worked.
# - SetBaseTextureMapImageGivenFilePath(PATH, fabricIdx) -- path is arg 0.
# - PNG DPI sets a texture's physical size in MD; control tiling via dpi in PIL.
# - Strengthen through the WHOLE settle, relax only at the end; elastic (if any)
# mid-settle, never from frame 0. One garment per scene.
# - Simulate(n) is synchronous, ~1 min per 300 frames -- raise the client timeout.
# - GetClothPositions() stays empty: no mesh introspection, QC is image-based.
"""
class ConfigError(Exception):
"""Raised for anything the `md` block gets wrong -- exits 2."""
# --------------------------------------------------------------------------- io
def _load_config(path):
with open(path, "r", encoding="utf-8") as fh:
return json.load(fh)
def _resolve(path, config_dir):
"""Config paths -> absolute. Try as-given, then config-dir-relative, then repo root."""
if not path:
return path
p = os.path.expanduser(str(path))
if os.path.isabs(p):
return os.path.normpath(p)
for base in (config_dir, REPO_ROOT):
cand = os.path.normpath(os.path.join(base, p))
if os.path.exists(cand):
return cand
return os.path.normpath(os.path.join(config_dir, p))
def _warn(msg):
print("warning: {}".format(msg), file=sys.stderr)
# ---------------------------------------------------------------------- schema
def _ident(name, used):
"""Panel name -> a safe, unique python identifier for the generated script."""
safe = "".join(c if (c.isalnum() or c == "_") else "_" for c in str(name))
if not safe or safe[0].isdigit():
safe = "p_" + safe
base = "p_" + safe
out, n = base, 2
while out in used:
out, n = "{}_{}".format(base, n), n + 1
used.add(out)
return out
def parse_md(config, config_path):
"""Validate + normalise the `md` block. Raises ConfigError. Returns a plain dict."""
config_dir = os.path.dirname(os.path.abspath(config_path))
md = config.get("md")
if not isinstance(md, dict):
raise ConfigError("config has no `md` block (this garment has no MD upstream stage)")
spec = {"name": config.get("name") or os.path.splitext(os.path.basename(config_path))[0]}
# --- scene
reset = md.get("reset", "new_project")
if reset not in ("new_project", "clear_patterns", "none"):
raise ConfigError("md.reset must be new_project|clear_patterns|none, got {!r}".format(reset))
spec["reset"] = reset
spec["avatar_fbx"] = _resolve(md.get("avatar_fbx"), config_dir)
if spec["avatar_fbx"] and not os.path.exists(spec["avatar_fbx"]):
_warn("md.avatar_fbx does not exist: {}".format(spec["avatar_fbx"]))
if reset == "new_project" and not spec["avatar_fbx"]:
_warn("md.reset=new_project DELETES the avatar and no md.avatar_fbx is set -- "
"the garment will drape on nothing")
spec["avatar_scale"] = float(md.get("avatar_scale", 10.0))
spec["add_arrangement_points"] = bool(md.get("add_arrangement_points", True))
spec["auto_translate"] = bool(md.get("auto_translate", True))
# --- fabric
spec["zfab"] = _resolve(md.get("zfab"), config_dir)
if spec["zfab"] and not os.path.exists(spec["zfab"]):
_warn("md.zfab does not exist: {} (AddFabric needs a real .zfab path)".format(spec["zfab"]))
spec["texture"] = _resolve(md.get("texture"), config_dir)
spec["texture_dpi"] = md.get("texture_dpi")
if spec["texture"]:
if not spec["zfab"]:
raise ConfigError("md.texture set without md.zfab -- fabric index 0 is the shared "
"default and texturing it dyes every garment in the scene")
_check_texture(spec["texture"], spec["texture_dpi"])
color = md.get("base_color")
if color is not None:
if len(color) not in (3, 4):
raise ConfigError("md.base_color must be [r,g,b] or [r,g,b,a]")
color = [float(c) for c in color]
if len(color) == 3:
color.append(1.0)
spec["base_color"] = color
# --- panels
panels = md.get("panels")
if not isinstance(panels, list) or not panels:
raise ConfigError("md.panels must be a non-empty list")
used, seen = set(), set()
spec["panels"] = []
for i, raw in enumerate(panels):
if not isinstance(raw, dict):
raise ConfigError("md.panels[{}] must be an object".format(i))
name = raw.get("name") or "panel{}".format(i)
if name in seen:
raise ConfigError("duplicate panel name {!r}".format(name))
seen.add(name)
pts = raw.get("points")
if not isinstance(pts, list) or len(pts) < 3:
raise ConfigError("panel {!r} needs at least 3 points".format(name))
clean = []
for j, pt in enumerate(pts):
if len(pt) < 2:
raise ConfigError("panel {!r} point {} must be [x, y]".format(name, j))
clean.append((float(pt[0]), float(pt[1])))
spec["panels"].append({
"name": name, "var": _ident(name, used), "points": clean,
"dx": float(raw.get("dx", 0.0)), "dy": float(raw.get("dy", 0.0)),
"point_type": int(raw.get("point_type", 0)),
"note": raw.get("note", ""),
"particle_distance": (None if raw.get("particle_distance") is None
else float(raw["particle_distance"])),
"thickness_collision": (None if raw.get("thickness_collision") is None
else float(raw["thickness_collision"])),
})
by_name = {p["name"]: p for p in spec["panels"]}
# --- seams
spec["seams"] = []
for i, raw in enumerate(md.get("seams", []) or []):
a, b = raw.get("a"), raw.get("b")
for who in (a, b):
if who not in by_name:
raise ConfigError("md.seams[{}] references unknown panel {!r}".format(i, who))
ra, rb = bool(raw.get("reverse_a", False)), bool(raw.get("reverse_b", False))
if "lines" in raw:
pairs = [(int(n), int(n)) for n in raw["lines"]]
elif "a_line" in raw and "b_line" in raw:
pairs = [(int(raw["a_line"]), int(raw["b_line"]))]
else:
raise ConfigError("md.seams[{}] needs `lines` or both `a_line`/`b_line`".format(i))
na, nb = len(by_name[a]["points"]), len(by_name[b]["points"])
for la, lb in pairs:
if not 0 <= la < na:
raise ConfigError("seam line {} out of range for panel {!r} ({} lines)"
.format(la, a, na))
if not 0 <= lb < nb:
raise ConfigError("seam line {} out of range for panel {!r} ({} lines)"
.format(lb, b, nb))
if a != b and la != lb:
_warn("seam {}.{} <-> {}.{} cross-pairs different line indices; on mirrored "
"front/back panels that sews the garment over the face"
.format(a, la, b, lb))
spec["seams"].append({"a": by_name[a], "a_line": la,
"b": by_name[b], "b_line": lb,
"reverse_a": ra, "reverse_b": rb})
spec["seam_check"] = bool(md.get("seam_check", True))
# --- arrangements
spec["arrangements"] = []
for i, raw in enumerate(md.get("arrangements", []) or []):
panel = raw.get("panel")
if panel not in by_name:
raise ConfigError("md.arrangements[{}] references unknown panel {!r}".format(i, panel))
point = raw.get("point")
if not point:
raise ConfigError("md.arrangements[{}] needs an arrangement point `point` name "
"(indices regenerate; names are the only stable handle)".format(i))
off = raw.get("offset")
if off is not None and len(off) != 3:
raise ConfigError("md.arrangements[{}].offset must be [x, y, z]".format(i))
spec["arrangements"].append({
"panel": by_name[panel], "point": str(point),
"offset": None if off is None else [_num(v) for v in off],
})
# --- sim
sim = md.get("sim") or {}
strengthen = sim.get("strengthen", False)
if strengthen is True:
targets = [p["name"] for p in spec["panels"]]
elif strengthen in (False, None):
targets = []
elif isinstance(strengthen, list):
for n in strengthen:
if n not in by_name:
raise ConfigError("md.sim.strengthen references unknown panel {!r}".format(n))
targets = list(strengthen)
else:
raise ConfigError("md.sim.strengthen must be true, false, or a list of panel names")
spec["strengthen"] = [by_name[n] for n in targets]
spec["settle_frames"] = int(sim.get("settle_frames", sim.get("frames", 250)))
spec["relax_frames"] = int(sim.get("relax_frames", 0) or 0)
if spec["relax_frames"] and not spec["strengthen"]:
_warn("md.sim.relax_frames is set but nothing is strengthened -- the relax pass only "
"makes sense as the 'stiff settle, then soften' recipe")
# --- elastic (mid-settle, NEVER frame 0 -- elastic-first drapes slide off a hip)
spec["elastic"] = []
for i, raw in enumerate(sim.get("elastic", []) or []):
panel = raw.get("panel")
if panel not in by_name:
raise ConfigError("md.sim.elastic[{}] references unknown panel {!r}".format(i, panel))
line = int(raw.get("line", -1))
if not 0 <= line < len(by_name[panel]["points"]):
raise ConfigError("md.sim.elastic[{}] line {} out of range for panel {!r}"
.format(i, line, panel))
if raw.get("total_length") is None:
raise ConfigError("md.sim.elastic[{}] needs total_length (mm) -- the target "
"cinched length of the edge".format(i))
spec["elastic"].append({
"panel": by_name[panel], "line": line,
"total_length": float(raw["total_length"]),
"strength": (None if raw.get("strength") is None else float(raw["strength"])),
})
spec["elastic_frames"] = int(sim.get("elastic_frames", 80)) if spec["elastic"] else 0
# --- snapshots / camera
spec["cam_viewpoint"] = md.get("cam_viewpoint")
spec["presim_snapshot"] = _resolve(md.get("presim_snapshot"), config_dir)
spec["snapshot"] = _resolve(md.get("snapshot"), config_dir)
spec["back_snapshot"] = _resolve(md.get("back_snapshot"), config_dir)
if not spec["snapshot"]:
_warn("md.snapshot is not set -- the drape stage will produce no image, and gate G1 "
"(drape placement) has nothing to measure")
# --- publish
spec["export_dir"] = _resolve(md.get("export_dir") or DEFAULT_EXPORT_DIR, config_dir)
spec["export_basename"] = md.get("export_basename")
kinds = md.get("exports", list(EXPORT_KINDS))
bad = [k for k in kinds if k not in EXPORT_KINDS]
if bad:
raise ConfigError("md.exports has unknown kinds {}; valid: {}"
.format(bad, list(EXPORT_KINDS)))
spec["exports"] = list(kinds)
if spec["exports"] and not spec["export_basename"]:
raise ConfigError("md.export_basename is required to publish (use a versioned name "
"like lena_pari_v3 -- never overwrite a shipped export)")
return spec
def _num(v):
f = float(v)
return int(f) if f == int(f) else f
def _check_texture(path, declared_dpi):
"""DPI is the texture's physical size in MD. Verify the PNG really carries it."""
if not os.path.exists(path):
_warn("md.texture does not exist: {}".format(path))
return
try:
from PIL import Image
except ImportError:
_warn("PIL not available -- skipping the md.texture_dpi check")
return
try:
with Image.open(path) as im:
size, dpi = im.size, im.info.get("dpi")
except Exception as exc: # noqa: BLE001
_warn("could not read md.texture {}: {}".format(path, exc))
return
if declared_dpi is None:
return
if not dpi:
_warn("md.texture {} carries no DPI but md.texture_dpi={} is declared -- MD will size "
"the cloth from the file, not from the config".format(path, declared_dpi))
return
if abs(float(dpi[0]) - float(declared_dpi)) > 0.05:
_warn("md.texture DPI mismatch: {} has {:.3f} dpi, config declares {} "
"(1024 px @ {:.3f} dpi = {:.0f} mm of cloth)"
.format(path, float(dpi[0]), declared_dpi, float(dpi[0]),
size[0] / float(dpi[0]) * 25.4))
# -------------------------------------------------------------------- emission
def _r(path):
"""Windows path -> a python raw-string literal for the generated script."""
return 'r"{}"'.format(str(path).replace('"', '\\"'))
def emit(spec, stage, config_path):
"""Render the bridge script. `stage` in draft|drape|publish|all."""
want = {"draft", "drape", "publish"} if stage == "all" else {stage}
L = []
a = L.append
a("# GENERATED by tools/tailor/draft_garment.py -- DO NOT EDIT.")
a("# garment : {}".format(spec["name"]))
a("# stage : {}".format(stage))
a("# config : {}".format(config_path))
a("# emitted : {}".format(datetime.datetime.now().replace(microsecond=0).isoformat()))
a("# Edit the config's `md` block and re-emit; edits here are lost.")
a("#")
a("# Run inside an MD bridge session (a human must click Plugin > TinqsMDBridge first):")
a("# python tools/md_bridge.py --file <this file> --timeout {}"
.format(_timeout_hint(spec)))
a("#")
a(LESSONS.rstrip())
a("")
a("import os")
a("")
a("report = {{\"garment\": {!r}, \"stage\": {!r}}}".format(spec["name"], stage))
a("")
if "draft" in want:
L.extend(_emit_draft(spec))
else:
L.extend(_emit_state_load(spec))
if "drape" in want:
L.extend(_emit_drape(spec))
if "publish" in want:
L.extend(_emit_publish(spec))
a("result = report")
return "\n".join(L) + "\n"
def _emit_state_load(spec):
"""drape/publish run in a later bridge request: recover the pattern ids draft stored."""
return [
"# The exec namespace persists WITHIN an MD session, so `draft` handed these over.",
"# It does NOT persist across sessions: if MD was restarted, re-run --stage draft.",
"if \"TINQS_GARMENT\" not in globals():",
" raise RuntimeError(\"no TINQS_GARMENT in this MD session -- run --stage draft \"",
" \"first, or emit --stage all\")",
"if TINQS_GARMENT.get(\"garment\") != {!r}:".format(spec["name"]),
" raise RuntimeError(\"this MD session holds garment %r, not {} -- re-run \"".format(
spec["name"]),
" \"--stage draft\" % TINQS_GARMENT.get(\"garment\"))",
"_pat = TINQS_GARMENT[\"patterns\"]",
] + [
"{} = _pat[{!r}]".format(p["var"], p["name"]) for p in spec["panels"]
] + [
"fab = TINQS_GARMENT.get(\"fabric\")",
"",
]
def _emit_draft(spec):
L = []
a = L.append
a("# ---- scene ---------------------------------------------------------------")
if spec["reset"] == "new_project":
a("utility_api.NewProject() # NOTE: this DELETES the avatar; re-import below")
elif spec["reset"] == "clear_patterns":
a("# keep the loaded avatar, drop every pattern (delete back-to-front: ids shift)")
a("for _i in range(pattern_api.GetPatternCount() - 1, -1, -1):")
a(" pattern_api.DeletePatternPiece(_i)")
if spec["avatar_fbx"]:
a("AVATAR_FBX = {}".format(_r(spec["avatar_fbx"])))
a("op = ApiTypes.ImportExportOption()")
a("op.scale = {} # Blender-exported FBX lands 10x small"
.format(_fmt(spec["avatar_scale"])))
if spec["add_arrangement_points"]:
a("op.bAddArrangementPoints = True # ~98 named points to hang cloth on")
if spec["auto_translate"]:
a("op.bAutoTranslate = True")
a("report[\"avatar\"] = import_api.ImportFBX(AVATAR_FBX, op) "
"# ImportAvatar() is .avt only")
a("")
a("# ---- pattern (mm; +y is UP in 3D -- hem at y=0, neck/straps at high y) ----")
for p in spec["panels"]:
if p["note"]:
a("# {}: {}".format(p["name"], p["note"]))
a("_pts_{} = [".format(p["var"]))
for chunk in _chunk(p["points"], 3):
a(" " + " ".join("({}, {}),".format(_fmt(x), _fmt(y)) for x, y in chunk))
a("]")
a("{} = pattern_api.CreatePatternWithPoints("
"[(x + {}, y + {}, {}) for (x, y) in _pts_{}])"
.format(p["var"], _fmt(p["dx"]), _fmt(p["dy"]), p["point_type"], p["var"]))
if p["particle_distance"] is not None:
a("pattern_api.SetParticleDistanceOfPattern({}, {}) # sim mesh resolution (mm)"
.format(p["var"], _fmt(p["particle_distance"])))
if p["thickness_collision"] is not None:
a("pattern_api.SetAddlThicknessCollision({}, {}) "
"# skin stand-off; the sim resolves it, set BEFORE the settle"
.format(p["var"], _fmt(p["thickness_collision"])))
a("report[\"ids\"] = [{}]".format(", ".join(p["var"] for p in spec["panels"])))
a("")
if spec["seams"]:
a("# ---- seams (WHOLE-EDGE only; same line index on mirrored panels) ---------")
for s in spec["seams"]:
a("pattern_api.AddSeamlinePairGroup({}, {}, {}, {}, {}, {})".format(
s["a"]["var"], s["a_line"], s["b"]["var"], s["b_line"],
s["reverse_a"], s["reverse_b"]))
a("")
if spec.get("seam_check", True):
a("# Paired edges must be EQUAL, not close: a 0.075 mm 'rounding' difference was")
a("# a slanted edge that notched a waistband. Fail loudly BEFORE wasting a sim.")
a("_pairs = [{}]".format(", ".join(
"({}, {}, {}, {})".format(s["a"]["var"], s["a_line"], s["b"]["var"], s["b_line"])
for s in spec["seams"])))
a("report[\"seam_lengths\"] = []")
a("_bad = []")
a("for _pa, _la, _pb, _lb in _pairs:")
a(" _fa = pattern_api.GetLineLength(_pa, _la)")
a(" _fb = pattern_api.GetLineLength(_pb, _lb)")
a(" report[\"seam_lengths\"].append((_pa, _la, round(_fa, 3), _pb, _lb, round(_fb, 3)))")
a(" if abs(_fa - _fb) > 0.1:")
a(" _bad.append(\"%s.%s=%.3f vs %s.%s=%.3f\" % (_pa, _la, _fa, _pb, _lb, _fb))")
a("if _bad:")
a(" raise RuntimeError(\"seam length mismatch (construction bug, not rounding): \"")
a(" + \"; \".join(_bad))")
a("")
if spec["zfab"]:
a("# ---- fabric (never touch index 0: it is the scene-wide default) ---------")
a("ZFAB = {}".format(_r(spec["zfab"])))
a("fab = fabric_api.AddFabric(ZFAB) # a name string silently fails")
if spec["texture"]:
dpi = " (dpi {} -> physical size)".format(spec["texture_dpi"]) \
if spec["texture_dpi"] else ""
a("TEX = {}{}".format(_r(spec["texture"]), dpi and " #" + dpi or ""))
a("fabric_api.SetBaseTextureMapImageGivenFilePath(TEX, fab) # path is arg 0")
if spec["base_color"]:
a("fabric_api.SetFabricPBRMaterialBaseColor(fab, 0, {})".format(
", ".join(_fmt(c) for c in spec["base_color"])))
a("for _p in ({},):".format(", ".join(p["var"] for p in spec["panels"])))
a(" pattern_api.SetPatternPieceFabricIndex(_p, fab) "
"# AssignFabricToPattern never worked")
a("")
else:
a("fab = None")
a("")
if spec["arrangements"]:
a("# ---- arrangement (BY NAME: indices regenerate on every avatar import) ----")
a("arr = {a[\"ArrangementName\"]: int(a[\"ArrangementIndex\"])")
a(" for a in pattern_api.GetArrangementList()}")
for ar in spec["arrangements"]:
a("pattern_api.SetArrangement({}, arr[{!r}])".format(ar["panel"]["var"], ar["point"]))
for ar in spec["arrangements"]:
if ar["offset"] is not None:
a("pattern_api.SetArrangementPosition({}, {})".format(
ar["panel"]["var"], ", ".join(_fmt(v) for v in ar["offset"])))
a("")
a("# Hand the pattern ids to a later --stage drape/publish in the SAME session.")
a("TINQS_GARMENT = {{\"garment\": {!r}, \"fabric\": fab, \"patterns\": {{{}}}}}".format(
spec["name"], ", ".join("{!r}: {}".format(p["name"], p["var"]) for p in spec["panels"])))
a("")
if spec["presim_snapshot"]:
a("# THE diagnostic: apply the arrangement and shoot with ZERO sim frames. It shows")
a("# where the panels actually start, before physics muddies it. Reach for this the")
a("# moment a drape misbehaves -- upside-down panels look exactly like seam bugs.")
a("utility_api.ResetClothArrangement()")
L.extend(_emit_snapshot(spec, spec["presim_snapshot"], "presim_snap"))
a("")
return L
def _emit_drape(spec):
L = []
a = L.append
a("# ---- drape ---------------------------------------------------------------")
if spec["strengthen"]:
a("# Stiffen through the WHOLE settle: soft fabric from frame 0 rolls into a bunch.")
for p in spec["strengthen"]:
a("pattern_api.SetPatternStrengthen({}, True)".format(p["var"]))
a("utility_api.ResetClothArrangement() # SetArrangement only assigns; THIS applies it")
a("report[\"sim1\"] = utility_api.Simulate({}) # synchronous, ~1 min / 300 frames"
.format(spec["settle_frames"]))
if spec["elastic"]:
a("# Elastic MID-SETTLE, never from frame 0 -- elastic-first drapes cinch the")
a("# garment off one hip before the cloth has wrapped.")
for e in spec["elastic"]:
a("pattern_api.SetPatternPieceElastic({}, {}, True)".format(
e["panel"]["var"], e["line"]))
a("pattern_api.SetPatternPieceElasticTotalLength({}, {}, {})".format(
e["panel"]["var"], e["line"], _fmt(e["total_length"])))
if e["strength"] is not None:
a("pattern_api.SetPatternPieceElasticStrength({}, {}, {})".format(
e["panel"]["var"], e["line"], _fmt(e["strength"])))
a("report[\"sim_elastic\"] = utility_api.Simulate({})".format(spec["elastic_frames"]))
if spec["relax_frames"]:
a("# Relax only at the end, so the settled shape falls into natural folds.")
for p in spec["strengthen"]:
a("pattern_api.SetPatternStrengthen({}, False)".format(p["var"]))
a("report[\"sim2\"] = utility_api.Simulate({})".format(spec["relax_frames"]))
a("utility_api.Refresh3DWindow()")
if spec["snapshot"]:
L.extend(_emit_snapshot(spec, spec["snapshot"], "snap"))
if spec["back_snapshot"]:
a("# ExportSnapshot3D has NO rear view (SetCamViewPoint 0-7: none is the back).")
a("# Turntable ignores any path arg and reuses the same output names -- copy now.")
a("import shutil")
a("_tt = export_api.ExportTurntableImages(4) # 0 front, 1 side, 2 BACK, 3 side")
a("if _tt and len(_tt) > 2:")
a(" back_snap = {}".format(_r(spec["back_snapshot"])))
a(" os.makedirs(os.path.dirname(back_snap), exist_ok=True)")
a(" shutil.copyfile(_tt[2], back_snap)")
a(" report[\"back_snapshot\"] = back_snap")
a("else:")
a(" report[\"back_snapshot\"] = \"TURNTABLE RETURNED NOTHING -- rear unverified\"")
a("")
return L
def _emit_snapshot(spec, path, key):
L = []
a = L.append
if spec["cam_viewpoint"] is not None:
a("utility_api.SetCamViewPoint({}) # SetViewPoint() does nothing; 2 = front"
.format(spec["cam_viewpoint"]))
a("{} = {}".format(key, _r(path)))
a("os.makedirs(os.path.dirname({}), exist_ok=True)".format(key))
a("export_api.ExportSnapshot3D({})".format(key))
a("report[{!r}] = {}".format(key, key))
return L
def _emit_publish(spec):
L = []
a = L.append
if not spec["exports"]:
return L
base = os.path.join(spec["export_dir"], spec["export_basename"])
a("# ---- publish (all scriptable; the FBX/OBJ flags are the load-bearing part) -")
a("os.makedirs({}, exist_ok=True)".format(_r(spec["export_dir"])))
a("xop = ApiTypes.ImportExportOption()")
a("xop.bExportGarment = True")
a("xop.bExportAvatar = False # the avatar is the game body; ship cloth only")
if "zprj" in spec["exports"]:
a("report[\"zprj\"] = {}".format(_r(base + ".zprj")))
a("export_api.ExportZPrj(report[\"zprj\"]) "
"# editable source of truth; no SaveProjectFile exists")
if "fbx" in spec["exports"]:
a("report[\"fbx\"] = {}".format(_r(base + "_garment.fbx")))
a("export_api.ExportFBX(report[\"fbx\"], xop) # -> clothing/garment_pipeline.py")
if "obj" in spec["exports"]:
a("report[\"obj\"] = {}".format(_r(base + "_garment.obj")))
a("export_api.ExportOBJ(report[\"obj\"], xop)")
if "zpac" in spec["exports"]:
a("report[\"zpac\"] = {}".format(_r(base + ".zpac")))
a("export_api.ExportZPac(report[\"zpac\"]) "
"# for outfit assembly (ImportZpac op.bAdd=True)")
a("")
return L
def _fmt(v):
if isinstance(v, bool):
return "True" if v else "False"
f = float(v)
return repr(int(f)) if f == int(f) and abs(f) < 1e15 and isinstance(v, int) else repr(f)
def _chunk(seq, n):
for i in range(0, len(seq), n):
yield seq[i:i + n]
def _timeout_hint(spec):
"""~1 min per 300 sim frames, plus import/export headroom."""
frames = spec["settle_frames"] + spec["relax_frames"] + spec.get("elastic_frames", 0)
return max(120, int(frames / 300.0 * 60.0 * 2.5) + 120)
# ----------------------------------------------------------------------- entry
def main(argv=None):
ap = argparse.ArgumentParser(
description="Emit a Marvelous Designer bridge script from a garment config's `md` block.")
ap.add_argument("--config", required=True,
help="resolved.json (or any garment config carrying an `md` block)")
ap.add_argument("--emit", required=True,
help="output path for the generated bridge script ('-' = stdout)")
ap.add_argument("--stage", default="all", choices=STAGES,
help="which MD stage(s) to emit (default: %(default)s)")
ap.add_argument("--timeout-hint", action="store_true",
help="print only the suggested md_bridge.py --timeout and exit")
args = ap.parse_args(argv)
try:
config = _load_config(args.config)
except (OSError, ValueError) as exc:
print("error: could not read config {}: {}".format(args.config, exc), file=sys.stderr)
return 3
try:
spec = parse_md(config, args.config)
except ConfigError as exc:
print("error: {}".format(exc), file=sys.stderr)
return 2
if args.timeout_hint:
print(_timeout_hint(spec))
return 0
text = emit(spec, args.stage, os.path.abspath(args.config))
try:
compile(text, args.emit, "exec")
except SyntaxError as exc:
print("error: generated script does not parse ({}) -- this is a template bug"
.format(exc), file=sys.stderr)
return 3
if args.emit == "-":
sys.stdout.write(text)
return 0
try:
out_dir = os.path.dirname(os.path.abspath(args.emit))
if out_dir:
os.makedirs(out_dir, exist_ok=True)
with open(args.emit, "w", encoding="utf-8") as fh:
fh.write(text)
except OSError as exc:
print("error: could not write {}: {}".format(args.emit, exc), file=sys.stderr)
return 3
print("emitted {} ({} stage, {} panels, {} seams) -> {}".format(
spec["name"], args.stage, len(spec["panels"]), len(spec["seams"]), args.emit))
print(" python tools/md_bridge.py --file {} --timeout {}".format(
args.emit, _timeout_hint(spec)))
return 0
if __name__ == "__main__":
sys.exit(main())
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
# MTL Exporter v1.0 by Seungwoo Oh at CLO - Virtual Fashion Inc.
newmtl (Default_for_Simulation)_FRONT_98912
Ka 0.257132 0.257132 0.257132
Kd 1.000000 1.000000 1.000000
Ks 0.075000 0.075000 0.075000
Ns 28.763239
illum 2
d 1.000000
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
# MTL Exporter v1.0 by Seungwoo Oh at CLO - Virtual Fashion Inc.
newmtl (Default_for_Simulation)_FRONT_125643
Ka 0.257132 0.257132 0.257132
Kd 1.000000 1.000000 1.000000
Ks 0.075000 0.075000 0.075000
Ns 28.763239
illum 2
d 1.000000
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

+24
View File
@@ -0,0 +1,24 @@
{
"source": "C:\\Users\\Jeremy\\tinqs\\ariki-game\\assets\\quaternius\\derived-bodies\\Ariki_Female_QuatSkin.glb",
"units": "meters (glTF)",
"height_total": 1.777,
"chest_circ": 1.0834,
"chest_z": 1.2636,
"waist_circ": 0.6747,
"waist_z": 1.0888,
"hip_circ": 1.0947,
"hip_z": 0.9455,
"thigh_circ": 0.8421,
"thigh_z": 0.8652,
"neck_circ": 0.3923,
"neck_z": 1.4568,
"bicep_circ": 0.417,
"shoulder_width": 0.3193,
"arm_len_shoulder_to_wrist": 0.5148,
"nape_to_pelvis": 0.4808,
"crotch_height": 0.9455,
"pelvis_height": 0.9318,
"knee_height": 0.5171,
"ankle_height": 0.1063,
"shoulder_z": 1.3973
}
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.
+10
View File
@@ -0,0 +1,10 @@
# MTL Exporter v1.0 by Seungwoo Oh at CLO - Virtual Fashion Inc.
newmtl (Default_for_Simulation)_FRONT_19892
Ka 0.257132 0.257132 0.257132
Kd 1.000000 1.000000 1.000000
Ks 0.075000 0.075000 0.075000
Ns 28.763239
illum 2
d 1.000000
map_Ka C:/Users/Jeremy/tinqs/animation/tools/tailor/taniko.png
map_Kd C:/Users/Jeremy/tinqs/animation/tools/tailor/taniko.png

Some files were not shown because too many files have changed in this diff Show More