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>
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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()))
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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))
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
After Width: | Height: | Size: 6.3 MiB |
@@ -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 v1–v2, pari v1–v3, outfit v1–v3 |
|
||||
| `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*/` — 92–102 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 ~1–2 cm clearance. `fit`/G3 measures 8 mm penetration **at rest**;
|
||||
G5 measures 216–231 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
|
||||
}
|
||||
|
After Width: | Height: | Size: 502 KiB |
|
After Width: | Height: | Size: 512 KiB |
|
After Width: | Height: | Size: 510 KiB |
|
After Width: | Height: | Size: 420 KiB |
|
After Width: | Height: | Size: 466 KiB |
|
After Width: | Height: | Size: 462 KiB |
|
After Width: | Height: | Size: 496 KiB |
|
After Width: | Height: | Size: 472 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 185 KiB |
|
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
|
||||
}
|
||||
|
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
|
||||
|
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
|
||||
|
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
|
||||
|
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
|
||||
|
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
|
||||
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 37 KiB |
@@ -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())
|
||||
@@ -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())
|
||||
|
After Width: | Height: | Size: 136 KiB |
@@ -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
|
||||
|
After Width: | Height: | Size: 146 KiB |
@@ -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
|
||||
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 185 KiB |
|
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
|
||||
}
|
||||
|
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
|
||||