feat(lena): v02 lane recipes + the archival full-res master

The v02 chain, which exists because bisecting the shard-eye defect proved it lives in
the hires MASTER rather than in any v02 step: the early heal chain ran whole-mesh welds
and reset custom split normals, and the eye/lash shells only read as an eye through
Tripo's authored normals. Every descendant inherits it, including the shipped body.

  48_decimate_headsafe  body/hands only, head bit-identical (feathered ramp)
  49_seams_v02          24_seams with a density-aware hip landmark — the v01 rule fired
                        at u=0.610, mid-belly, on the head-protected vert count
  50_seams_headuv       body-only unwrap; the head KEEPS its original Tripo charts.
                        SLIM collapsed the undecimated lash/brow slivers to points and
                        ANGLE_BASED packed at half v01's texel density; the face was
                        the best-mapped region of the source atlas, so it is reused
  51_reatlas_v02        31_reatlas + centroid splats for sub-texel triangles — the
                        skipped set IS the lashes, which rendered as grey glass
  52_head_transplant    the PRISTINE ORIGINAL head onto the decimated nude body
  53_rig_transfer       54_crotch_refill

55_fullres_v02.blend is pinned in .lanekeep as THE archival master: full-res nude body
+ pristine original head, crotch refill and texture despeckle applied, 883,404 v /
1,761,640 f. It supersedes 34_v04 as the lane root (34_v04's head has the shard eyes).

Also: tools/graft_hands.py, the Marvelous Designer hunter-skirt configs v1-v8, the
hunter cloth texture generator, and Mako's measurement card.

Per .agents/rules/working-files.md the per-attempt .blend files under work/lena/v02
are SCRATCH ("never committed") — the .py recipes here are the history and regenerate
any of them from the pinned master. See the ignore rule landing next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 18:53:17 -07:00
parent 23fc4b378e
commit c12c4f156c
17 changed files with 3828 additions and 0 deletions
+589
View File
@@ -0,0 +1,589 @@
# graft_hands.py — transplant a working 40-bone hand rig onto a body that was rigged without one.
#
# blender --background --python tools/graft_hands.py -- \
# --target <rigged_body.glb> --donor <Ariki_Female_QuatSkin.glb> --out <out.glb>
# blender --background --python tools/graft_hands.py -- --selftest --donor <...QuatSkin.glb>
#
# WHY THIS EXISTS
# The rig-graft lane (.agents/plans/rig-graft-lane-2026-08-04.md) cuts the hands off the AccuRig
# bait, because close-packed fingers are where every historical hand-mangling came from. That
# leaves the body rigged and the hands unrigged — but the hands do not need solving at all:
# * the game skeleton's hand chains are FIXED (40 of its 65 joints; verify_body_variant.py
# gates on "65 joints in identical order"), so there is nothing to discover, only to place;
# * the nude lane never touched the hands — measured 0.010 mm mean displacement from the Tripo
# original, p50 exactly 0.000 — so a donor's hand weights fit this mesh as-is;
# * the shipped clips articulate fingers up to 89 deg relative to each other, so a mitten
# (fingers weighted as one mass) would visibly flatten four of the six dances.
# So: take the hands from a body that already ships with working ones, aligned at the wrist.
#
# WHY glTF JSON AND NOT BLENDER
# Blender's armature import/export re-derives bone rest orientation from edit-bone head/tail,
# which silently rotates rest poses. That is precisely the failure ariki-game/tools/rig_pose_gate.py
# was written to catch (Godot animation tracks store ABSOLUTE local transforms, so a rewritten
# rest orientation diverges under a clip while a rest-pose comparison still looks fine). Editing
# the node graph directly cannot introduce it. bpy is used only for its KD-tree.
#
# WHAT IT DOES
# 1. Reads the canonical joint ORDER and the hand subtree from the donor.
# 2. Builds a similarity transform M mapping donor space to target space such that the donor's
# wrist frame lands exactly on the target's wrist frame, with bone lengths scaled by the
# ratio of forearm lengths (the local scale that matters at the wrist, not global height).
# 3. Re-parents the transformed hand chain under the target's lowerarm, baking the scale into
# translations so every joint keeps unit scale.
# 4. Copies hand skin weights donor -> target by nearest surface, remapped by joint NAME.
# 5. Rebuilds skin.joints in the donor's canonical order and recomputes inverse-bind matrices.
#
# The IBM convention is not assumed: it is recovered from the target's own body joints and
# asserted before anything is written (see check_ibm_convention).
import json, struct, sys, os, math, argparse
import numpy as np
try:
from mathutils.kdtree import KDTree
except ImportError:
KDTree = None
DT = {5120: np.int8, 5121: np.uint8, 5122: np.int16, 5123: np.uint16,
5125: np.uint32, 5126: np.float32}
CT = {v: k for k, v in DT.items()}
NC = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4, "MAT4": 16}
HAND_ROOTS = ("hand_l", "hand_r")
# --------------------------------------------------------------------------- glTF container
class Gltf:
def __init__(self, path):
data = open(path, "rb").read()
total = struct.unpack("<I", data[8:12])[0]
off, self.js, self.bin = 12, None, b""
while off < total:
ln, ty = struct.unpack("<II", data[off:off + 8]); off += 8
ch = data[off:off + ln]; off += ln
if ty == 0x4E4F534A: self.js = json.loads(ch)
elif ty == 0x004E4942: self.bin = ch
self.path = path
self.extra = bytearray() # appended payload for new accessors
# ---- reading
def read(self, i):
a = self.js["accessors"][i]
dt = np.dtype(DT[a["componentType"]]); nc = NC[a["type"]]; n = a["count"]
if "bufferView" not in a:
return np.zeros((n, nc), dtype=dt)
bv = self.js["bufferViews"][a["bufferView"]]
off = bv.get("byteOffset", 0) + a.get("byteOffset", 0)
stride = bv.get("byteStride") or nc * dt.itemsize
if stride == nc * dt.itemsize:
return np.frombuffer(self.bin, dtype=dt, count=n * nc, offset=off).reshape(n, nc)
raw = np.frombuffer(self.bin, dtype=np.uint8, count=stride * n, offset=off).reshape(n, stride)
return raw[:, :nc * dt.itemsize].copy().view(dt).reshape(n, nc)
# ---- writing (append-only: existing views are never disturbed)
def add(self, arr, type_):
arr = np.ascontiguousarray(arr)
base = len(self.bin) + len(self.extra)
pad = (-base) % 4
self.extra += b"\x00" * pad
off = base + pad
raw = arr.tobytes()
self.extra += raw
self.js["bufferViews"].append({"buffer": 0, "byteOffset": off, "byteLength": len(raw)})
acc = {"bufferView": len(self.js["bufferViews"]) - 1,
"componentType": CT[arr.dtype.type], "count": len(arr), "type": type_}
if type_ == "VEC3":
acc["min"] = [float(x) for x in arr.min(axis=0)]
acc["max"] = [float(x) for x in arr.max(axis=0)]
self.js["accessors"].append(acc)
return len(self.js["accessors"]) - 1
def save(self, out):
blob = bytes(self.bin) + bytes(self.extra)
blob += b"\x00" * ((-len(blob)) % 4)
self.js["buffers"] = [{"byteLength": len(blob)}]
js = json.dumps(self.js, separators=(",", ":")).encode("utf-8")
js += b" " * ((-len(js)) % 4)
hdr = struct.pack("<III", 0x46546C67, 2, 12 + 8 + len(js) + 8 + len(blob))
with open(out, "wb") as f:
f.write(hdr)
f.write(struct.pack("<II", len(js), 0x4E4F534A)); f.write(js)
f.write(struct.pack("<II", len(blob), 0x004E4942)); f.write(blob)
# ---- topology helpers
def parents(self):
p = {}
for i, n in enumerate(self.js["nodes"]):
for c in n.get("children", []): p[c] = i
return p
def by_name(self):
return {n.get("name"): i for i, n in enumerate(self.js["nodes"]) if n.get("name")}
def local(self, i):
n = self.js["nodes"][i]
if "matrix" in n:
return np.array(n["matrix"], dtype=np.float64).reshape(4, 4).T
T = np.eye(4); R = np.eye(4); S = np.eye(4)
T[:3, 3] = n.get("translation", [0, 0, 0])
R[:3, :3] = quat_mat(n.get("rotation", [0, 0, 0, 1]))
S[:3, :3] = np.diag(n.get("scale", [1, 1, 1]))
return T @ R @ S
def world(self, i, par=None):
par = par if par is not None else self.parents()
M = np.eye(4); j = i
chain = []
while j is not None:
chain.append(j); j = par.get(j)
for j in reversed(chain): M = M @ self.local(j)
return M
def body_prim(self):
best = None
for mi, m in enumerate(self.js["meshes"]):
for pi, pr in enumerate(m["primitives"]):
n = self.js["accessors"][pr["attributes"]["POSITION"]]["count"]
if best is None or n > best[0]: best = (n, mi, pi)
return best[1], best[2]
def skinned_node(self):
for i, n in enumerate(self.js["nodes"]):
if "skin" in n and "mesh" in n: return i
return None
def prune_nodes(g, drop):
"""Delete nodes and remap every index that referred to them. Leaving them orphaned but
present is not good enough: verify_body_variant.py compares the node-NAME SET against the
canonical body, and stray nodes fail it (they would also ship as dead scene content)."""
drop = set(drop)
keep = [i for i in range(len(g.js["nodes"])) if i not in drop]
remap = {old: new for new, old in enumerate(keep)}
g.js["nodes"] = [g.js["nodes"][i] for i in keep]
for n in g.js["nodes"]:
if "children" in n:
kids = [remap[c] for c in n["children"] if c in remap]
if kids: n["children"] = kids
else: n.pop("children")
for sc in g.js.get("scenes", []):
if "nodes" in sc:
sc["nodes"] = [remap[i] for i in sc["nodes"] if i in remap]
for sk in g.js.get("skins", []):
sk["joints"] = [remap[i] for i in sk["joints"] if i in remap]
if "skeleton" in sk:
if sk["skeleton"] in remap: sk["skeleton"] = remap[sk["skeleton"]]
else: sk.pop("skeleton")
for an in g.js.get("animations", []):
for ch in an.get("channels", []):
t = ch.get("target", {})
if "node" in t:
if t["node"] in remap: t["node"] = remap[t["node"]]
else: ch["_orphan"] = True
an["channels"] = [c for c in an.get("channels", []) if not c.pop("_orphan", False)]
return remap
def quat_mat(q):
x, y, z, w = q
return np.array([
[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
[2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
[2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)]], dtype=np.float64)
def mat_quat(R):
"""Rotation matrix -> xyzw quaternion, via the numerically stable branch."""
t = R[0, 0] + R[1, 1] + R[2, 2]
if t > 0:
s = math.sqrt(t + 1.0) * 2
w = 0.25 * s
x = (R[2, 1] - R[1, 2]) / s; y = (R[0, 2] - R[2, 0]) / s; z = (R[1, 0] - R[0, 1]) / s
elif R[0, 0] > R[1, 1] and R[0, 0] > R[2, 2]:
s = math.sqrt(1.0 + R[0, 0] - R[1, 1] - R[2, 2]) * 2
w = (R[2, 1] - R[1, 2]) / s; x = 0.25 * s
y = (R[0, 1] + R[1, 0]) / s; z = (R[0, 2] + R[2, 0]) / s
elif R[1, 1] > R[2, 2]:
s = math.sqrt(1.0 + R[1, 1] - R[0, 0] - R[2, 2]) * 2
w = (R[0, 2] - R[2, 0]) / s; x = (R[0, 1] + R[1, 0]) / s
y = 0.25 * s; z = (R[1, 2] + R[2, 1]) / s
else:
s = math.sqrt(1.0 + R[2, 2] - R[0, 0] - R[1, 1]) * 2
w = (R[1, 0] - R[0, 1]) / s; x = (R[0, 2] + R[2, 0]) / s
y = (R[1, 2] + R[2, 1]) / s; z = 0.25 * s
q = np.array([x, y, z, w]); return q / np.linalg.norm(q)
def decompose_unit(M):
"""(translation, xyzw quaternion) with scale stripped — joints stay unit-scale so a scale
factor never propagates down the finger chain."""
R = M[:3, :3].copy()
for k in range(3):
n = np.linalg.norm(R[:, k])
if n > 0: R[:, k] /= n
return M[:3, 3].copy(), mat_quat(R)
def subtree(g, root, par=None):
out, stack = [], [root]
while stack:
i = stack.pop(0); out.append(i)
stack += g.js["nodes"][i].get("children", [])
return out
def check_ibm_convention(g, tol=1e-4):
"""Recover, rather than assume, how this file relates inverse-bind matrices to rest poses.
Returns the mesh-node world matrix that makes IBM == inv(world(joint)) @ Wmesh hold."""
sk = g.js["skins"][0]
if "inverseBindMatrices" not in sk: return np.eye(4), 0.0
ibm = g.read(sk["inverseBindMatrices"]).reshape(-1, 4, 4).transpose(0, 2, 1)
par = g.parents()
node = g.skinned_node()
Wmesh = g.world(node, par) if node is not None else np.eye(4)
worst = 0.0
for k, j in enumerate(sk["joints"]):
pred = np.linalg.inv(g.world(j, par)) @ Wmesh
worst = max(worst, float(np.abs(pred - ibm[k]).max()))
return Wmesh, worst
# --------------------------------------------------------------------------- the graft
def unit_scale(M):
"""Same matrix with each basis vector normalised — scale removed, rotation kept."""
out = M.copy()
for k in range(3):
n = np.linalg.norm(out[:3, k])
if n > 0: out[:3, k] /= n
return out
def wrist_transform(gt, gd, side, tn, dn, par_t, par_d):
"""Similarity transform mapping DONOR world space to TARGET world space so the donor wrist
frame lands on the target wrist frame. Scale comes from forearm length — the length that
governs how far the fingers reach; global body height would be wrong for a differently
proportioned arm.
Both wrist frames are stripped of their own scale before composing. Without that, a target
whose nodes already carry a scale gets it applied TWICE (once inside Wt, once via the forearm
ratio, which was measured in that same scaled space) — selftest B caught exactly this, as a
0.055 u placement error that grew toward the finger tips."""
hand, fore = f"hand_{side}", f"lowerarm_{side}"
if hand not in tn:
raise SystemExit(f"target has no '{hand}' node — cannot align. AccuRig must return at "
f"least a wrist joint, or use --wrist-from-forearm (not implemented).")
Wt = gt.world(tn[hand], par_t)
Wd = gd.world(dn[hand], par_d)
s = 1.0
if fore in tn and fore in dn:
lt = np.linalg.norm(Wt[:3, 3] - gt.world(tn[fore], par_t)[:3, 3])
ld = np.linalg.norm(Wd[:3, 3] - gd.world(dn[fore], par_d)[:3, 3])
if ld > 1e-9: s = lt / ld
Sc = np.eye(4); Sc[:3, :3] *= s
return unit_scale(Wt) @ Sc @ np.linalg.inv(unit_scale(Wd)), s
def graft(target, donor, out, hand_frac=0.756, verbose=True):
gt, gd = Gltf(target), Gltf(donor)
tn, dn = gt.by_name(), gd.by_name()
par_t, par_d = gt.parents(), gd.parents()
Wmesh_t, err_t = check_ibm_convention(gt)
_, err_d = check_ibm_convention(gd)
if verbose:
print(f"[ibm] convention residual target {err_t:.2e} donor {err_d:.2e}")
if err_d > 1e-3:
raise SystemExit(f"donor IBMs do not follow inv(world(joint)) @ Wmesh (residual "
f"{err_d:.2e}); refusing to guess a different convention")
canonical = [gd.js["nodes"][j].get("name") for j in gd.js["skins"][0]["joints"]]
skin_t = gt.js["skins"][0]
tj_names = [gt.js["nodes"][j].get("name") for j in skin_t["joints"]]
# ---- 1. copy each donor hand subtree into the target, transformed to the target wrist
new_nodes = {}
dropped = []
for side in ("l", "r"):
M, s = wrist_transform(gt, gd, side, tn, dn, par_t, par_d)
chain = subtree(gd, dn[f"hand_{side}"])
if verbose:
print(f"[wrist] {side}: forearm-length scale x{s:.5f}, {len(chain)} joints")
# drop any hand chain the target already has, so this is a replacement not a duplicate
if f"hand_{side}" in tn:
old = subtree(gt, tn[f"hand_{side}"], par_t)
p = par_t.get(old[0])
if p is not None:
gt.js["nodes"][p]["children"] = [c for c in gt.js["nodes"][p].get("children", [])
if c != old[0]]
dropped.extend(old) # removed for real at the end, once indices settle
for j in chain:
Wnew = M @ gd.world(j, par_d)
t, q = decompose_unit(Wnew)
gt.js["nodes"].append({"name": gd.js["nodes"][j].get("name"),
"translation": [float(x) for x in t],
"rotation": [float(x) for x in q]})
new_nodes[gd.js["nodes"][j].get("name")] = len(gt.js["nodes"]) - 1
# re-parent: the chain root goes under the target's forearm, children under their own
for j in chain:
nm = gd.js["nodes"][j].get("name")
kids = [gd.js["nodes"][c].get("name") for c in gd.js["nodes"][j].get("children", [])]
if kids:
gt.js["nodes"][new_nodes[nm]]["children"] = [new_nodes[k] for k in kids]
root_nm = gd.js["nodes"][dn[f'hand_{side}']].get("name")
fore_i = tn.get(f"lowerarm_{side}")
if fore_i is None:
raise SystemExit(f"target has no lowerarm_{side} to parent the hand under")
gt.js["nodes"][fore_i].setdefault("children", []).append(new_nodes[root_nm])
# local transforms are currently WORLD; convert to parent-relative
par_t = gt.parents()
for j in chain:
nm = gd.js["nodes"][j].get("name"); i = new_nodes[nm]
Wnew = M @ gd.world(j, par_d)
p = par_t.get(i)
Lp = np.linalg.inv(gt.world(p, par_t)) @ Wnew if p is not None else Wnew
t, q = decompose_unit(Lp)
gt.js["nodes"][i]["translation"] = [float(x) for x in t]
gt.js["nodes"][i]["rotation"] = [float(x) for x in q]
par_t = gt.parents()
# ---- 2. rebuild skin.joints in the donor's canonical order
tn = gt.by_name(); par_t = gt.parents()
joints_new, missing = [], []
for nm in canonical:
if nm in new_nodes: joints_new.append(new_nodes[nm])
elif nm in tn: joints_new.append(tn[nm])
else: missing.append(nm)
if missing:
raise SystemExit(f"target is missing non-hand joints the donor defines: {missing[:6]}")
old_index = {nm: k for k, nm in enumerate(tj_names)}
new_index = {nm: k for k, nm in enumerate(canonical)}
# ---- 3. weights: donor hand -> target hand vertices, by nearest surface, remapped by NAME
mi_t, pi_t = gt.body_prim(); prim_t = gt.js["meshes"][mi_t]["primitives"][pi_t]
mi_d, pi_d = gd.body_prim(); prim_d = gd.js["meshes"][mi_d]["primitives"][pi_d]
Pt = np.array(gt.read(prim_t["attributes"]["POSITION"]), dtype=np.float64)
Pd = np.array(gd.read(prim_d["attributes"]["POSITION"]), dtype=np.float64)
Jd = np.array(gd.read(prim_d["attributes"]["JOINTS_0"]), dtype=np.int64)
Wd_ = np.array(gd.read(prim_d["attributes"]["WEIGHTS_0"]), dtype=np.float64)
Jt = np.array(gt.read(prim_t["attributes"]["JOINTS_0"]), dtype=np.int64)
Wt_ = np.array(gt.read(prim_t["attributes"]["WEIGHTS_0"]), dtype=np.float64)
dj_names = [gd.js["nodes"][j].get("name") for j in gd.js["skins"][0]["joints"]]
hand_joint_ids_d = {k for k, nm in enumerate(dj_names)
if nm and (nm.startswith(("index", "middle", "ring", "pinky", "thumb"))
or nm in HAND_ROOTS)}
# donor vertices that are actually skinned to the hand — the geometric definition of "hand"
is_hand_d = np.array([any(Jd[i, k] in hand_joint_ids_d and Wd_[i, k] > 0 for k in range(4))
for i in range(len(Pd))])
# target hand region by the same bbox-relative cut rigbait_decimate.py uses
half = np.abs(Pt[:, 0]).max()
is_hand_t = np.abs(Pt[:, 0]) > hand_frac * half
# Transform donor hand verts into TARGET MESH-LOCAL space, which is the space POSITION data
# lives in. M works in world space, so the round trip is:
# donor local -> donor world (Wmesh_d) -> target world (M) -> target local (inv Wmesh_t).
# Skipping the mesh-node matrices only works when both are identity; selftest B caught that
# as a 0.70 u mean nearest-donor distance where it should have been ~0.
Wmesh_d, _ = check_ibm_convention(gd)
inv_Wmesh_t = np.linalg.inv(Wmesh_t)
Md = {}
for side in ("l", "r"):
Md[side], _ = wrist_transform(gt, gd, side, gt.by_name(), dn, gt.parents(), par_d)
def donor_side(i):
"""Which hand a donor vertex belongs to, from the joint it is actually weighted to —
not from the sign of x, which assumes a convention this file need not follow."""
best, bw = None, -1.0
for k in range(4):
nm = dj_names[Jd[i, k]]
if Wd_[i, k] > bw and nm and nm.endswith(("_l", "_r")):
best, bw = nm[-1], Wd_[i, k]
return best or "l"
src_idx = np.where(is_hand_d)[0]
src_pts = np.empty((len(src_idx), 3))
for a, i in enumerate(src_idx):
w = Wmesh_d @ np.append(Pd[i], 1.0)
src_pts[a] = (inv_Wmesh_t @ (Md[donor_side(i)] @ w))[:3]
if KDTree is None:
raise SystemExit("mathutils unavailable — run this under blender --background --python")
kd = KDTree(len(src_pts))
for a, p in enumerate(src_pts.tolist()): kd.insert(p, a)
kd.balance()
Jt_new = np.zeros_like(Jt); Wt_new = np.zeros_like(Wt_)
# body vertices keep their weights, remapped to the new joint ordering
for i in range(len(Pt)):
if is_hand_t[i]: continue
for k in range(4):
nm = tj_names[Jt[i, k]] if Jt[i, k] < len(tj_names) else None
if nm and nm in new_index and Wt_[i, k] > 0:
Jt_new[i, k] = new_index[nm]; Wt_new[i, k] = Wt_[i, k]
moved = 0
dists = []
for i in np.where(is_hand_t)[0]:
a = kd.find(tuple(Pt[i]))[1]
dists.append(kd.find(tuple(Pt[i]))[2])
s = src_idx[a]
for k in range(4):
nm = dj_names[Jd[s, k]]
if Wd_[s, k] > 0 and nm in new_index:
Jt_new[i, k] = new_index[nm]; Wt_new[i, k] = Wd_[s, k]
moved += 1
sums = Wt_new.sum(axis=1, keepdims=True)
Wt_new = np.where(sums > 0, Wt_new / np.maximum(sums, 1e-12), Wt_new)
if verbose and dists:
d = np.array(dists)
print(f"[weights] {moved:,} target hand verts sourced from {len(src_idx):,} donor hand "
f"verts | nearest-donor distance mean {d.mean():.6f} p99 {np.percentile(d,99):.6f} "
f"max {d.max():.6f}")
# ---- 4. inverse-bind matrices for the whole (reordered) skin
par_t = gt.parents()
ibm = np.empty((len(joints_new), 4, 4))
for k, j in enumerate(joints_new):
ibm[k] = np.linalg.inv(gt.world(j, par_t)) @ Wmesh_t
skin_t["joints"] = joints_new
skin_t["inverseBindMatrices"] = gt.add(
ibm.transpose(0, 2, 1).reshape(-1, 16).astype(np.float32), "MAT4")
prim_t["attributes"]["JOINTS_0"] = gt.add(Jt_new.astype(np.uint16), "VEC4")
prim_t["attributes"]["WEIGHTS_0"] = gt.add(Wt_new.astype(np.float32), "VEC4")
# ---- 5. remove the hand chains we replaced. IBMs are keyed by position in skin.joints, and
# prune preserves that order, so they stay valid across the reindex.
if dropped:
prune_nodes(gt, dropped)
if verbose: print(f"[prune] removed {len(dropped)} replaced hand nodes")
gt.save(out)
if verbose:
print(f"[done] {out} ({os.path.getsize(out)/1e6:.2f} MB, {len(joints_new)} joints)")
return out
# --------------------------------------------------------------------------- self-tests
def selftest(donor, tmp):
"""Two synthetic tests, because the real input (an AccuRig FBX with no hands) does not exist
yet. Both use the donor as its own target, so the correct answer is known exactly.
A. IDENTITY — strip the hand chains, graft them back, expect the original rest poses and
weights to return. Validates ordering, re-parenting, IBMs and weight remap.
B. SIMILARITY — same, but the target is first scaled and rotated by a known amount. The
graft must land the hands on the transformed wrist, which is what the real
cross-body case needs (AccuRig output differs in scale and orientation).
"""
ok = True
ref = Gltf(donor)
ref_names = [ref.js["nodes"][j].get("name") for j in ref.js["skins"][0]["joints"]]
par = ref.parents()
ref_world = {nm: ref.world(ref.js["skins"][0]["joints"][k], par)
for k, nm in enumerate(ref_names)}
for label, scale, deg in (("A identity", 1.0, 0.0), ("B similarity", 0.55, 7.0)):
tgt = os.path.join(tmp, f"selftest_{label.split()[0]}_target.glb")
g = Gltf(donor)
# transform the whole target by a known similarity, applied at the scene roots
if scale != 1.0 or deg != 0.0:
c, s_ = math.cos(math.radians(deg)), math.sin(math.radians(deg))
R = np.array([[c, 0, s_, 0], [0, 1, 0, 0], [-s_, 0, c, 0], [0, 0, 0, 1]])
S = np.eye(4); S[:3, :3] *= scale
X = R @ S
roots = set(range(len(g.js["nodes"]))) - set(g.parents().keys())
for r in roots:
L = X @ g.local(r)
t, q = decompose_unit(L)
sc = np.linalg.norm(L[:3, 0])
g.js["nodes"][r].pop("matrix", None)
g.js["nodes"][r]["translation"] = [float(v) for v in t]
g.js["nodes"][r]["rotation"] = [float(v) for v in q]
g.js["nodes"][r]["scale"] = [float(sc)] * 3
# strip the hand chains from the target's skin (simulating the hands-off bait)
keep = [j for j, nm in zip(g.js["skins"][0]["joints"],
[g.js["nodes"][x].get("name") for x in g.js["skins"][0]["joints"]])
if not (nm.startswith(("index", "middle", "ring", "pinky", "thumb")))]
names_keep = [g.js["nodes"][j].get("name") for j in keep]
mi, pi = g.body_prim(); prim = g.js["meshes"][mi]["primitives"][pi]
J = np.array(g.read(prim["attributes"]["JOINTS_0"]), dtype=np.int64)
W = np.array(g.read(prim["attributes"]["WEIGHTS_0"]), dtype=np.float64)
old_names = [g.js["nodes"][j].get("name") for j in g.js["skins"][0]["joints"]]
ni = {nm: k for k, nm in enumerate(names_keep)}
J2 = np.zeros_like(J); W2 = np.zeros_like(W)
for i in range(len(J)):
for k in range(4):
nm = old_names[J[i, k]]
# finger weights collapse onto the wrist, as a hands-off rig would have them
nm = nm if nm in ni else ("hand_l" if nm.endswith("_l") else "hand_r")
J2[i, k] = ni[nm]; W2[i, k] = W[i, k]
ibm_old = g.read(g.js["skins"][0]["inverseBindMatrices"]).reshape(-1, 4, 4)
keepidx = [old_names.index(nm) for nm in names_keep]
g.js["skins"][0]["joints"] = keep
g.js["skins"][0]["inverseBindMatrices"] = g.add(
ibm_old[keepidx].reshape(-1, 16).astype(np.float32), "MAT4")
prim["attributes"]["JOINTS_0"] = g.add(J2.astype(np.uint16), "VEC4")
prim["attributes"]["WEIGHTS_0"] = g.add(W2.astype(np.float32), "VEC4")
g.save(tgt)
out = os.path.join(tmp, f"selftest_{label.split()[0]}_out.glb")
print(f"\n=== selftest {label} (target scaled x{scale}, rotated {deg} deg)")
graft(tgt, donor, out)
r = Gltf(out)
names = [r.js["nodes"][j].get("name") for j in r.js["skins"][0]["joints"]]
if names != ref_names:
print(f" FAIL joint order differs ({len(names)} vs {len(ref_names)})"); ok = False
else:
print(f" PASS joint order — {len(names)} joints, canonical")
# hand rest poses, compared in the target's own frame (undo the known transform)
parr = r.parents()
worst, worstn = 0.0, ""
for k, nm in enumerate(names):
if not (nm.startswith(("index", "middle", "ring", "pinky", "thumb")) or nm in HAND_ROOTS):
continue
Wg = r.world(r.js["skins"][0]["joints"][k], parr)
# expected: reference world transformed by the same similarity, scale stripped
c, s_ = math.cos(math.radians(deg)), math.sin(math.radians(deg))
R = np.array([[c, 0, s_, 0], [0, 1, 0, 0], [-s_, 0, c, 0], [0, 0, 0, 1]])
S = np.eye(4); S[:3, :3] *= scale
exp = R @ S @ ref_world[nm]
d = np.linalg.norm(Wg[:3, 3] - exp[:3, 3])
if d > worst: worst, worstn = d, nm
tol = 1e-5 * max(scale, 1e-3)
print(f" {'PASS' if worst < 1e-4 else 'FAIL'} hand joint placement — worst origin error "
f"{worst:.3e} u at {worstn}")
ok &= worst < 1e-4
# weights: every hand vertex should recover the donor's own weights
mi, pi = r.body_prim(); pr = r.js["meshes"][mi]["primitives"][pi]
Jn = np.array(r.read(pr["attributes"]["JOINTS_0"]), dtype=np.int64)
Wn = np.array(r.read(pr["attributes"]["WEIGHTS_0"]), dtype=np.float64)
mi0, pi0 = ref.body_prim(); pr0 = ref.js["meshes"][mi0]["primitives"][pi0]
J0 = np.array(ref.read(pr0["attributes"]["JOINTS_0"]), dtype=np.int64)
W0 = np.array(ref.read(pr0["attributes"]["WEIGHTS_0"]), dtype=np.float64)
P0 = np.array(ref.read(pr0["attributes"]["POSITION"]), dtype=np.float64)
half = np.abs(P0[:, 0]).max()
hand = np.abs(P0[:, 0]) > 0.756 * half
def as_dict(J, W, i):
return {J[i, k]: round(float(W[i, k]), 4) for k in range(4) if W[i, k] > 1e-6}
same = sum(1 for i in np.where(hand)[0] if as_dict(Jn, Wn, i) == as_dict(J0, W0, i))
tot = int(hand.sum())
print(f" {'PASS' if same == tot else 'WARN'} hand weights recovered exactly on "
f"{same:,}/{tot:,} hand verts ({100*same/max(tot,1):.2f}%)")
wsum = Wn.sum(axis=1)
print(f" {'PASS' if abs(wsum-1).max() < 1e-3 else 'FAIL'} weights normalised "
f"(max deviation {abs(wsum-1).max():.2e})")
ok &= abs(wsum - 1).max() < 1e-3
print(f"\nSELFTEST {'PASS' if ok else 'FAIL'}")
return 0 if ok else 2
def main():
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else sys.argv[1:]
ap = argparse.ArgumentParser()
ap.add_argument("--target"); ap.add_argument("--donor", required=True)
ap.add_argument("--out"); ap.add_argument("--selftest", action="store_true")
ap.add_argument("--tmp", default=".")
a = ap.parse_args(argv)
if a.selftest:
raise SystemExit(selftest(a.donor, a.tmp))
if not a.target or not a.out:
raise SystemExit("--target and --out are required unless --selftest")
graft(a.target, a.donor, a.out)
main()
+32
View File
@@ -0,0 +1,32 @@
{
"source": "C:\\Users\\Jeremy\\tinqs\\ariki-game\\assets\\quaternius\\derived-bodies\\Mako_Fullhead_QuatSkin_candidate.glb",
"units": "meters (glTF)",
"height_total": 1.818,
"chest_circ": 1.3592,
"chest_z": 1.2992,
"waist_circ": 0.8868,
"waist_z": 1.065,
"hip_circ": 1.0143,
"hip_z": 0.9321,
"thigh_circ": 0.6726,
"thigh_z": 0.813,
"neck_circ": 0.6052,
"neck_z": 1.5661,
"bicep_circ": 0.5922,
"shoulder_width": 0.3838,
"arm_len_shoulder_to_wrist": 0.5471,
"nape_to_pelvis": 0.5709,
"crotch_height": 0.9321,
"pelvis_height": 0.9167,
"knee_height": 0.5318,
"ankle_height": 0.1037,
"shoulder_z": 1.4579,
"notes": {
"verified": "Torso circumference profile re-run independently 2026-08-12; chest/waist/hip/thigh are real body geometry (dominant groups all spine_*/pelvis/thigh_*, no arm intrusion at T-pose).",
"neck_circ_unreliable": "neck_circ/neck_z measure the GRAFTED FULL-RES HEAD, not the neck. The scan's min landed at z=1.566 where the slice is 340+ verts of the Head group (hair/jaw), reading 60.6 cm. Do not draft a collar to this. The body mesh's own neck stump narrows to 51 cm at z=1.525 and the body geometry ENDS at z=1.545 - above that is all Head group. Treat z=1.52-1.55 as the collar ceiling.",
"body_mesh_top_z": 1.545,
"shape_vs_lena": "Inverted triangle where Lena is a pear. chest +27 cm, waist +21 cm, hip -8 cm, thigh -17 cm, shoulder_width +6 cm vs Ariki_Female_QuatSkin. Lena garment configs will bind at the chest and hang loose at the hips - redraft, do not re-drape.",
"vert_budget": "122,067 verts total: Head 81,552 (full-res, includes hair), hand_l/r 22,472 (rigid mitts), torso+limbs the remainder. Heavy avatar for MD - simulate with patience.",
"md_avatar_fbx": "tools/tailor/avatar/Mako_Fullhead_QuatSkin_Avatar.fbx (this GLB, Icosphere stray dropped, leaf bones stripped). Blender-exported so import with op.scale = 10.0."
}
}
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""
make_hunter_cloth.py -- coarse woven brown cloth for the hunter wrap skirt.
python tools/tailor/textures/make_hunter_cloth.py
Derived from the reference renders
`male-clothing-hunter-gpt-v2{,-side,-back}.png`: a dark warm-brown coarse plain
weave, roughly burlap/harakeke-sack in character, with visible thread grain and
wear mottling.
WHY THE COLOUR IS BAKED IN, NOT GREY
The clothing pipeline has NO alpha and `apply_fabric_texture()` wires the PNG
straight into Base Color, *replacing* the part's flat `color` rather than
multiplying it. So a grayscale weave renders grey in-game, not brown. Every
value below is a final albedo, not a mask.
WHY THESE RGB NUMBERS
Sampled from the reference renders: garment mean (56,35,26) with highlights to
about (92,61,45). Those are *lit* pixels from a dim studio setup, so the albedo
sits above them -- BASE is set brighter so that in-game lighting lands the
garment back on the reference's apparent tone instead of crushing it to near
black. Warm ramp throughout: r > g > b, r-b about 45.
WHY DPI AND NOT IMAGE SCALE
In MD the PNG's DPI sets the cloth's physical size, so tiling is controlled by
dpi, never by resizing the image. This tile represents CLOTH_MM of fabric:
dpi = SIZE / (CLOTH_MM / 25.4). At 100 mm it repeats about 7x across the skirt's
687 mm hem, which is what keeps the weave reading as thread rather than pattern.
DETERMINISM
No `random` and no time source -- a fixed LCG plus a fixed hash, so the file is
byte-identical run to run. This PNG is a build input; a texture that changes
under you turns a placement regression into a wild goose chase.
"""
import os
from PIL import Image
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "hunter_cloth.png")
SIZE = 512 # px, square
CLOTH_MM = 100.0 # physical span this tile represents
DPI = SIZE / (CLOTH_MM / 25.4)
BASE = (108, 80, 60) # mid warm brown albedo
THREAD_PITCH = 16 # px per thread; 512px/100mm -> ~3.1 mm threads (coarse)
OVER_LIFT = 16 # threads on top of the weave are lighter
UNDER_DROP = 20 # threads passing under are shaded
ROUND_SHADE = 14 # cross-thread rounding falloff
SLUB_RANGE = 10 # per-thread thickness/tone irregularity
MOTTLE = 16 # large-scale wear variation
MOTTLE_CELL = 64 # px per mottle cell
def lcg(seed):
"""Deterministic 0..1 sequence. Fixed constants (glibc), fixed seed."""
state = seed
while True:
state = (1103515245 * state + 12345) % (2 ** 31)
yield state / float(2 ** 31)
def thread_tones(n, seed):
"""One slub value per thread, so a thread's irregularity runs its length --
per-pixel noise would read as sand, not as spun fibre."""
g = lcg(seed)
return [int((next(g) * 2.0 - 1.0) * SLUB_RANGE) for _ in range(n)]
def value_noise(cells, seed):
"""Coarse lattice of values, bilinearly interpolated -> smooth wear blotches."""
g = lcg(seed)
grid = [[(next(g) * 2.0 - 1.0) for _ in range(cells + 1)] for _ in range(cells + 1)]
def sample(x, y):
fx, fy = x * cells / SIZE, y * cells / SIZE
x0, y0 = int(fx), int(fy)
tx, ty = fx - x0, fy - y0
# smoothstep so cell borders don't show as creases
tx, ty = tx * tx * (3 - 2 * tx), ty * ty * (3 - 2 * ty)
a = grid[y0][x0] * (1 - tx) + grid[y0][x0 + 1] * tx
b = grid[y0 + 1][x0] * (1 - tx) + grid[y0 + 1][x0 + 1] * tx
return a * (1 - ty) + b * ty
return sample
def main():
n_threads = SIZE // THREAD_PITCH
warp = thread_tones(n_threads, seed=20260812)
weft = thread_tones(n_threads, seed=90210)
mottle = value_noise(SIZE // MOTTLE_CELL, seed=5150)
img = Image.new("RGB", (SIZE, SIZE))
px = img.load()
for y in range(SIZE):
j = (y // THREAD_PITCH) % n_threads
# position across the weft thread, -1..1, for rounding
vy = ((y % THREAD_PITCH) / (THREAD_PITCH - 1.0)) * 2.0 - 1.0
for x in range(SIZE):
i = (x // THREAD_PITCH) % n_threads
vx = ((x % THREAD_PITCH) / (THREAD_PITCH - 1.0)) * 2.0 - 1.0
# plain weave: alternate which thread sits on top
warp_on_top = ((i + j) % 2) == 0
if warp_on_top:
lift = OVER_LIFT - int(ROUND_SHADE * vx * vx)
slub = warp[i]
else:
lift = -UNDER_DROP + int(ROUND_SHADE * (1.0 - vy * vy))
slub = weft[j]
wear = int(mottle(x, y) * MOTTLE)
d = lift + slub + wear
# warm ramp: brown shifts warmer as it lightens, cooler in shadow
r = BASE[0] + d
g = BASE[1] + int(d * 0.78)
b = BASE[2] + int(d * 0.62)
px[x, y] = (max(0, min(255, r)), max(0, min(255, g)), max(0, min(255, b)))
img.save(OUT, dpi=(DPI, DPI))
vals = [px[x, y] for y in range(0, SIZE, 8) for x in range(0, SIZE, 8)]
n = len(vals)
mean = tuple(sum(v[c] for v in vals) // n for c in range(3))
print("wrote %s (%dx%d, dpi %.1f -> %.0f mm of cloth)"
% (OUT, SIZE, SIZE, DPI, CLOTH_MM))
print("mean albedo %s (reference lit mean was (56,35,26))" % (mean,))
print("range r %d-%d g %d-%d b %d-%d"
% (min(v[0] for v in vals), max(v[0] for v in vals),
min(v[1] for v in vals), max(v[1] for v in vals),
min(v[2] for v in vals), max(v[2] for v in vals)))
warm = mean[0] - mean[2]
print("warmth r-b = %d (reference %d)" % (warm, 56 - 26))
if warm < 30:
print("WARNING: not warm enough -- will read as grey cloth")
if __name__ == "__main__":
main()