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
@@ -0,0 +1,160 @@
# Stage 48 (v02 candidate): decimate the body, KEEP THE HEAD — Jeremy 2026-08-11.
#
# blender --background --python 48_decimate_headsafe.py -- <in.blend> <out.blend> [body_target_v]
#
# The v01 decimation was one global ratio (30_decimate.py, 0.037728): it kept 4% of her head
# triangles, and the face is where that shows — Tripo modelled her brows, lash lines and lips as
# raised GEOMETRY, so the 197,306-tri head collapsed to 7,836 and the brows went faceted.
#
# Here the head is excluded outright and the reduction is spent on the body alone:
# - vertex group 'decim': weight 1.0 below u=0.80 (shoulders), feathered to 0.0 at u=0.86
# (chin). The feather ramps triangle density through the neck instead of snapping at a line.
# - Decimate COLLAPSE with that group; the ratio is BISECTED on the evaluated depsgraph until
# the BODY-region vertex count lands on target (the same measure-don't-guess pattern as
# 30_decimate — the ratio is faces-global, so the body count is what must converge).
# - The head is then ASSERTED untouched: exact vertex count and positional checksum over
# u > 0.87, not hoped for. If the modifier nibbled it, this aborts rather than ships.
#
# Budget consequence, stated up front: head 101,136 v rides along whole, so the result is
# ~128k v / ~250k tris — 4x the v01 game budget. That is the point: quality over budget,
# Jeremy's call. The head budget can be dialled later; the body work is not redone.
import bpy, sys, os, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT = argv[0], argv[1]
BODY_TARGET = int(argv[2]) if len(argv) > 2 else 27500 # v01's body-region density
# Feather narrowed 0.80-0.86 -> 0.84-0.87 after the first run: the wide band held 26,405
# partially-protected verts, and because the bisect counted everything below u=0.87 as "body",
# the band soaked up nearly the whole 27,500 budget — the true body came out at ~4k verts.
# The bisect now counts ONLY u <= FEATHER_LO, so the budget lands where it was meant to.
FEATHER_LO, FEATHER_HI = 0.84, 0.87
HEAD_CHECK = 0.875
t0 = time.time()
def log(m):
print(f"[dec48 {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
bpy.context.view_layer.objects.active = ob
for o in bpy.data.objects:
o.select_set(o is ob)
me = ob.data
n0 = len(me.vertices)
co = np.empty(n0 * 3); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3)
zlo, zhi = co[:, 2].min(), co[:, 2].max()
u = (co[:, 2] - zlo) / (zhi - zlo)
head0 = u > HEAD_CHECK
head_n0 = int(head0.sum())
head_sum0 = co[head0].sum(axis=0) # positional checksum of the protected region
log(f"in: {n0} v / {len(me.polygons)} f head(u>{HEAD_CHECK}): {head_n0} v")
# feathered protection weights
w = np.clip((FEATHER_HI - u) / (FEATHER_HI - FEATHER_LO), 0.0, 1.0)
vg = ob.vertex_groups.get("decim") or ob.vertex_groups.new(name="decim")
for band in (0.0, 0.25, 0.5, 0.75, 1.0):
idx = np.nonzero(np.isclose(np.round(w * 4) / 4, band))[0]
if len(idx):
vg.add(idx.tolist(), float(band), 'REPLACE')
log(f"vertex group: {int((w >= 0.999).sum())} full-weight, "
f"{int(((w > 0) & (w < 1)).sum())} feathered, {int((w <= 0).sum())} protected")
# Applying Decimate discards CUSTOM SPLIT NORMALS for the entire mesh — measured consequence:
# the head's lash and eye shells (dense sliver geometry that depends on Tripo's authored normals)
# rendered as faceted glass. Keep an untouched duplicate as a normal donor; after the decimate is
# applied, the head's normals are transferred back. Positions up there are identical by
# construction, so POLYINTERP_NEAREST is an exact restore, feathered off through the neck.
donor = ob.copy()
donor.data = ob.data.copy()
donor.name = "nrm_donor"
bpy.context.scene.collection.objects.link(donor)
mod = ob.modifiers.new("dec", 'DECIMATE')
mod.decimate_type = 'COLLAPSE'
mod.vertex_group = "decim"
mod.vertex_group_factor = 10.0
def body_count(ratio):
mod.ratio = ratio
dg = bpy.context.evaluated_depsgraph_get()
ev = ob.evaluated_get(dg)
m = ev.to_mesh()
n = len(m.vertices)
a = np.empty(n * 3); m.vertices.foreach_get("co", a); a = a.reshape(-1, 3)
uu = (a[:, 2] - zlo) / (zhi - zlo)
body = int((uu <= FEATHER_LO).sum()) # TRUE body only — the feather band is excluded
head = int((uu > HEAD_CHECK).sum())
ev.to_mesh_clear()
return body, head, n
# analytic first guess: protected faces survive, so the global ratio must budget for them
face_head = 0
lv = np.empty(len(me.loops), dtype=np.int32); me.loops.foreach_get("vertex_index", lv)
ls = np.empty(len(me.polygons), dtype=np.int32); me.polygons.foreach_get("loop_start", ls)
prot = u[lv[ls]] > FEATHER_HI # cheap: classify face by first corner
face_head = int(prot.sum())
guess = (face_head + BODY_TARGET * 2.05) / len(me.polygons)
lo_r, hi_r = guess * 0.4, min(1.0, guess * 2.5)
log(f"protected faces ~{face_head}; first guess ratio {guess:.5f}")
best = None
for it in range(9):
r = 0.5 * (lo_r + hi_r)
b, h, n = body_count(r)
log(f" it{it:02d} ratio {r:.5f} -> body {b} v, head {h} v, total {n}")
if abs(b - BODY_TARGET) / BODY_TARGET < 0.02:
best = r
break
if b < BODY_TARGET:
lo_r = r
else:
hi_r = r
best = r
mod.ratio = best
bpy.ops.object.modifier_apply(modifier=mod.name)
log(f"applied ratio {best:.5f}")
# ---- assert the head did not move ----
me = ob.data
n1 = len(me.vertices)
co1 = np.empty(n1 * 3); me.vertices.foreach_get("co", co1); co1 = co1.reshape(-1, 3)
u1 = (co1[:, 2] - zlo) / (zhi - zlo)
head1 = u1 > HEAD_CHECK
head_n1 = int(head1.sum())
head_sum1 = co1[head1].sum(axis=0)
drift = np.abs(head_sum1 - head_sum0).max()
print(f"\nHEAD CHECK: {head_n0} -> {head_n1} verts, positional checksum drift {drift:.9f}")
assert head_n1 == head_n0 and drift < 1e-4, \
"the decimation touched the protected head — do not ship this"
print(f"RESULT: {n0} -> {n1} v ({len(me.polygons)} f); body {n1 - head_n1} v, head {head_n1} v")
print(f"height unchanged: {co1[:,2].max()-co1[:,2].min():.5f} vs {zhi-zlo:.5f}")
# restore the head's authored normals from the donor
vgn = ob.vertex_groups.new(name="nrm_keep")
co2 = np.empty(len(me.vertices) * 3); me.vertices.foreach_get("co", co2); co2 = co2.reshape(-1, 3)
u2 = (co2[:, 2] - zlo) / (zhi - zlo)
wn = np.clip((u2 - 0.82) / (0.86 - 0.82), 0.0, 1.0)
for band in (0.25, 0.5, 0.75, 1.0):
idx = np.nonzero(np.isclose(np.round(wn * 4) / 4, band))[0]
if len(idx):
vgn.add(idx.tolist(), float(band), 'REPLACE')
dt = ob.modifiers.new("nrm", 'DATA_TRANSFER')
dt.object = donor
dt.use_loop_data = True
dt.data_types_loops = {'CUSTOM_NORMAL'}
dt.loop_mapping = 'POLYINTERP_NEAREST'
dt.vertex_group = "nrm_keep"
bpy.context.view_layer.objects.active = ob
bpy.ops.object.modifier_apply(modifier=dt.name)
log("head custom normals restored from donor (feathered 0.82-0.86)")
bpy.data.objects.remove(donor, do_unlink=True)
ob.vertex_groups.remove(ob.vertex_groups["nrm_keep"])
ob.vertex_groups.remove(ob.vertex_groups["decim"])
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("DEC48_DONE")
+442
View File
@@ -0,0 +1,442 @@
# Stage 49 (v02): 24_seams.py with ONE fix — a density-aware hip landmark.
#
# The v01 hip rule walked 0.01-thick midline slices and declared the crotch at the first slice
# holding <3 verts. On the head-protected v02 body (27.5k body verts vs 32k) that fired at
# u=0.610 — mid-belly. A hip ring above the real crotch turns the leg region into two joined
# tubes with too few cuts, and the minimum-stretch solver blows one chart up and packs the rest
# to nothing (coverage 0.0%). Slices are 2x thicker here and the threshold scales with the
# body's actual vertex density. Copied rather than edited: 24_seams.py is the recipe that
# regenerates the SHIPPED v01, and its behaviour must not drift.
# Stage 24: a proper human UV atlas — ~10 anatomical charts instead of Tripo's 5,870 blobs.
#
# blender --background --python 24_seams.py -- <mesh.glb|blend> <out_dir> [decimate_ratio]
#
# Every seam is placed off a MEASURED landmark, not a guessed threshold, so the same rules port
# between the hires sculpt and the decimated game body:
# neck / wrist / ankle = local minimum of cross-section radius (the narrow part)
# shoulder = smallest |s| whose slice is short in u (arm, not torso)
# hip = lowest slice that still has vertices on the midline (the crotch)
# Lengthwise cuts open each tube flat, hidden where nobody looks:
# torso + head -> back midline; arms -> back of the arm; legs -> inner side.
# Hands / feet / head need no lengthwise cut: a tube with one closed end is already a disc.
import bpy, bmesh, sys, os, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
SRC = argv[0]
OUTDIR = os.path.abspath(argv[1])
RATIO = float(argv[2]) if len(argv) > 2 else 0.0
os.makedirs(OUTDIR, exist_ok=True)
t0 = time.time()
def log(m):
print(f"[seam {time.time()-t0:6.1f}s] {m}", flush=True)
if SRC.lower().endswith(".glb"):
bpy.ops.wm.read_homefile(use_empty=True)
bpy.ops.import_scene.gltf(filepath=SRC)
else:
bpy.ops.wm.open_mainfile(filepath=SRC)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
bpy.context.view_layer.objects.active = ob
for o in bpy.data.objects:
o.select_set(o is ob)
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
log(f"body '{ob.name}' {len(ob.data.vertices)}v {len(ob.data.polygons)}f")
if 0 < RATIO < 1:
m = ob.modifiers.new("dec", 'DECIMATE')
m.ratio = RATIO
bpy.ops.object.modifier_apply(modifier=m.name)
log(f"decimated -> {len(ob.data.vertices)}v {len(ob.data.polygons)}f")
# The source mesh is non-manifold (1,649 edges with >2 faces) and decimation turns that into a
# scatter of degenerate/duplicate triangles. Each one becomes its own UV island — that is where
# the confetti comes from, not the seam rules. Clean it here, before any of it reaches the unwrap.
def clean_mesh(tag):
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.remove_doubles(threshold=1e-5)
bpy.ops.mesh.dissolve_degenerate(threshold=1e-6)
bpy.ops.mesh.delete_loose(use_verts=True, use_edges=True, use_faces=False)
# (Splitting the 1,649 non-manifold edges was tried here and rejected: it cost +3,512 verts
# and made the collapsed-face count worse, because the collapse is a solver problem, not a
# topology one — see the unwrap method below.)
bpy.ops.mesh.normals_make_consistent(inside=False)
bpy.ops.object.mode_set(mode='OBJECT')
bm_ = bmesh.new(); bm_.from_mesh(ob.data)
nm = sum(1 for e in bm_.edges if len(e.link_faces) > 2)
dg = sum(1 for f in bm_.faces if f.calc_area() < 1e-12)
bm_.free()
log(f"clean[{tag}]: {len(ob.data.vertices)}v {len(ob.data.polygons)}f "
f"nonmanifold_edges={nm} degenerate_faces={dg}")
clean_mesh("after decimate")
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3)
lo, hi = co.min(axis=0), co.max(axis=0)
span = hi - lo
UP = int(np.argmax(span))
LR = int(np.argmax(np.where(np.arange(3) == UP, -1, span)))
FB = 3 - UP - LR
u = (co[:, UP] - lo[UP]) / span[UP]
s = co[:, LR] - 0.5 * (lo[LR] + hi[LR])
d = co[:, FB] - 0.5 * (lo[FB] + hi[FB])
HALF = 0.5 * span[LR]
sn = s / HALF # left-right, normalised to +-1
print(f"axes up={'xyz'[UP]} lr={'xyz'[LR]} fb={'xyz'[FB]} height {span[UP]:.4f} units")
# ---------------------------------------------------------------- landmarks
def radius_profile(mask, coord, lo_c, hi_c, nb):
"""mean in-slice radius vs coord, over `nb` bins — the narrow parts are the joints."""
ed = np.linspace(lo_c, hi_c, nb + 1)
mid = 0.5 * (ed[:-1] + ed[1:])
out = np.full(nb, np.nan)
for i in range(nb):
m = mask & (coord >= ed[i]) & (coord < ed[i + 1])
if m.sum() < 30:
continue
A = np.stack([s[m], d[m], u[m]], axis=1)
A = np.delete(A, 0 if coord is sn else 2, axis=1) if False else A
# radius measured in the two axes perpendicular to `coord`
if coord is u:
P = np.stack([s[m], d[m]], axis=1)
else:
P = np.stack([d[m], (u[m] - u[m].mean()) * span[UP]], axis=1)
out[i] = np.linalg.norm(P - P.mean(axis=0), axis=1).mean()
return mid, out
def local_min(mid, prof, lo_c, hi_c):
m = (mid >= lo_c) & (mid <= hi_c) & np.isfinite(prof)
if not m.any():
return 0.5 * (lo_c + hi_c)
return float(mid[m][np.argmin(prof[m])])
def taper_end(mid, prof, lo_c, hi_c, from_high, tol=0.08):
"""Where the limb stops tapering, taken from the EXTREMITY side.
The global minimum of the radius profile is not the joint: on the leg it sits up the shin,
well above where the foot ends. The joint is the end of the taper nearest the extremity —
the first bin (walking in from that side) that reaches within `tol` of the minimum.
"""
m = (mid >= lo_c) & (mid <= hi_c) & np.isfinite(prof)
if not m.any():
return 0.5 * (lo_c + hi_c)
mm, pp = mid[m], prof[m]
close = np.nonzero(pp <= pp.min() * (1.0 + tol))[0]
return float(mm[close[-1] if from_high else close[0]])
torso_side = np.abs(sn) < 0.30
mid_u, prof_u = radius_profile(torso_side, u, 0.0, 1.0, 60)
NECK_U = local_min(mid_u, prof_u, 0.80, 0.93)
# The ankle must be measured on ONE leg. Over both, the "radius" is really the gap between
# them, which falls monotonically from the feet up and has no minimum to find.
one_leg = (sn > 0.05) & (u < 0.35)
mid_l, prof_l = radius_profile(one_leg, u, 0.0, 0.35, 35)
print(" single-leg radius profile (u -> radius), the ankle is the narrow point:")
print(" " + " ".join(f"{m:.2f}:{r*1000:.0f}" for m, r in zip(mid_l, prof_l)
if np.isfinite(r)))
ANKLE_U = taper_end(mid_l, prof_l, 0.02, 0.14, from_high=False)
# shoulder: the smallest |s| whose slice is SHORT in u (an arm), scanning outward
absn = np.abs(sn)
ARM_IN = 0.35
for cut in np.arange(0.15, 0.60, 0.01):
m = (absn >= cut) & (absn < cut + 0.03)
if m.sum() < 30:
continue
if (u[m].max() - u[m].min()) < 0.13:
ARM_IN = float(cut)
break
mid_a, prof_a = radius_profile(absn > ARM_IN, absn, ARM_IN, 1.0, 40)
print(" arm radius profile (|s| -> radius), the wrist is the narrow point before the hand:")
print(" " + " ".join(f"{m:.2f}:{r*1000:.0f}" for m, r in zip(mid_a, prof_a)
if np.isfinite(r)))
# The profile reads: radius falls to the wrist, bulges again over the palm, then tapers down
# the fingers. Search below the palm bulge or the "wrist" lands among the fingers.
WRIST = taper_end(mid_a, prof_a, 0.60, 0.80, from_high=True)
# hip: the crotch is the HIGHEST strictly-empty midline slice. Measured on this mesh: the
# |sn|<0.025 strip is zero below u=0.42 and populated above — except one freak slice at
# 0.50-0.52 holding a single vertex, which is what tripped both threshold-based rules (v01's
# "<3 verts" and the density-scaled version) into calling mid-belly the crotch. No stray
# vertex can fake a STRICTLY empty slice, and there is no surface between the legs to put
# one there.
HIP_U = 0.45
for lv in np.arange(0.60, 0.20, -0.005):
m = (u >= lv) & (u < lv + 0.02) & (absn < 0.025)
if m.sum() == 0:
HIP_U = float(lv + 0.02)
break
print(f"LANDMARKS neck_u {NECK_U:.3f} hip_u {HIP_U:.3f} ankle_u {ANKLE_U:.3f} "
f"arm_in {ARM_IN:.3f} wrist {WRIST:.3f} (fractions of height / half-span)")
is_arm = absn > ARM_IN
is_hand = absn > WRIST
is_head = u > NECK_U
is_leg = (u < HIP_U) & ~is_arm
is_foot = u < ANKLE_U
# centre lines for the lengthwise cuts
# A constant centre works only for a perfectly axis-aligned limb. Hers droop, so a level set of
# u wanders off the arm and cuts it twice. Use a measured centre LINE: median u per |s| bin for
# the arms, median |s| per u bin for each leg, linearly interpolated.
def centre_line(mask, along, of, nb, lo_a, hi_a):
ed = np.linspace(lo_a, hi_a, nb + 1)
mid = 0.5 * (ed[:-1] + ed[1:])
val = np.full(nb, np.nan)
for i in range(nb):
m = mask & (along >= ed[i]) & (along < ed[i + 1])
if m.sum() >= 20:
val[i] = np.median(of[m])
ok = np.isfinite(val)
if ok.sum() < 2:
return lambda q: np.full_like(q, np.nanmedian(of[mask]) if mask.any() else 0.0)
mid_o, val_o = mid[ok], val[ok]
return lambda q: np.interp(np.asarray(q), mid_o, val_o)
arm_u_of = centre_line(is_arm & ~is_hand, absn, u, 24, ARM_IN, WRIST)
leg_s_of = {}
for sg in (-1, 1):
m = is_leg & ~is_foot & (np.sign(sn) == sg)
leg_s_of[sg] = centre_line(m, u, absn, 20, ANKLE_U, HIP_U)
# A foot cut at the ankle is an L (ankle-heel-toes) and a hand is a flat paddle; neither
# flattens as one chart. Split each along its silhouette, exactly where an artist would:
# the foot into upper/sole at its mid-height, the hand into back/palm at its mid-thickness.
U_SOLE = float(np.median(u[is_foot])) if is_foot.sum() else ANKLE_U * 0.5
D_PALM = float(np.median(d[is_hand])) if is_hand.sum() else 0.0
print(f" foot split at u {U_SOLE:.3f} (upper|sole) hand split at d {D_PALM:+.4f} (back|palm)")
qa = np.linspace(ARM_IN, WRIST, 5)
print(f" arm centre line u at |s|={np.round(qa,2).tolist()}: "
f"{np.round(arm_u_of(qa), 3).tolist()}")
ql = np.linspace(ANKLE_U, HIP_U, 5)
print(f" leg(+) centre line |s| at u={np.round(ql,2).tolist()}: "
f"{np.round(leg_s_of[1](ql), 3).tolist()}")
# The torso is a tube with FOUR holes (neck, two armholes, hip). The back midline joins neck to
# hip; two more cuts are needed or the chart stays multiply-connected and the unwrap stretches it
# badly. Run a relief cut across the BACK at armpit height, from each armhole in to the midline.
# upper body only — at |s| = ARM_IN the feet also splay out that far, and they win a p5
m_pit = (np.abs(absn - ARM_IN) < 0.03) & (u > 0.5)
U_PIT = float(np.percentile(u[m_pit], 5)) if m_pit.sum() > 30 else 0.62
print(f" armpit u {U_PIT:.3f} -> relief cut across the back at that height")
# ---------------------------------------------------------------- mark seams
bm = bmesh.new()
bm.from_mesh(me)
tally = {}
def hit(k):
tally[k] = tally.get(k, 0) + 1
return True
for e in bm.edges:
e.seam = False
for e in bm.edges:
a, b = e.verts[0].index, e.verts[1].index
# rings, in order of priority
if is_head[a] != is_head[b]:
e.seam = hit("neck ring"); continue
if is_hand[a] != is_hand[b]:
e.seam = hit("wrist rings"); continue
if is_foot[a] != is_foot[b]:
e.seam = hit("ankle rings"); continue
if is_arm[a] != is_arm[b]:
e.seam = hit("shoulder rings"); continue
if (u[a] < HIP_U) != (u[b] < HIP_U):
e.seam = hit("hip ring"); continue
# lengthwise cuts
if is_foot[a] and is_foot[b]:
if (u[a] > U_SOLE) != (u[b] > U_SOLE):
e.seam = hit("foot sole line"); continue
elif is_hand[a] and is_hand[b]:
if (d[a] > D_PALM) != (d[b] > D_PALM):
e.seam = hit("hand palm line"); continue
elif is_arm[a] and is_arm[b] and not (is_hand[a] or is_hand[b]):
ca, cb = arm_u_of(absn[a]), arm_u_of(absn[b])
if d[a] > 0 and d[b] > 0 and (u[a] > ca) != (u[b] > cb):
e.seam = hit("arm back line"); continue
elif is_leg[a] and is_leg[b] and not (is_foot[a] or is_foot[b]):
sg = int(1 if sn[a] + sn[b] >= 0 else -1)
if absn[a] < leg_s_of[sg](u[a]) and absn[b] < leg_s_of[sg](u[b]) \
and (d[a] > 0) != (d[b] > 0):
e.seam = hit("leg inner line"); continue
elif not (is_arm[a] or is_arm[b] or is_foot[a] or is_foot[b]):
# torso and head share one continuous back midline
if d[a] > 0 and d[b] > 0 and (sn[a] > 0) != (sn[b] > 0):
e.seam = hit("back midline"); continue
# armpit relief: back only, from each armhole inward to the midline
if d[a] > 0 and d[b] > 0 and not is_head[a] and not is_head[b] \
and (u[a] > U_PIT) != (u[b] > U_PIT):
e.seam = hit("armpit relief"); continue
for k in sorted(tally):
print(f" seam '{k}': {tally[k]} edges")
bm.to_mesh(me)
bm.free()
log(f"marked {sum(tally.values())} seam edges")
# ---------------------------------------------------------------- unwrap
UNWRAP_METHOD = os.environ.get("UNWRAP_METHOD", "MINIMUM_STRETCH")
def do_unwrap(pack):
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
# Angle-based (ABF) is conformal: it preserves angles and is free to crush area. At her folds
# and flaps that means whole patches land under one texel and come out untextured. The
# minimum-stretch (SLIM) solver optimises area distortion instead, which is what a texture
# transfer actually needs.
try:
bpy.ops.uv.unwrap(method=UNWRAP_METHOD, margin=0.0)
except TypeError:
log(f"unwrap method {UNWRAP_METHOD} unavailable — falling back to ANGLE_BASED")
bpy.ops.uv.unwrap(method='ANGLE_BASED', margin=0.0)
bpy.ops.uv.average_islands_scale()
if pack:
try:
bpy.ops.uv.pack_islands(rotate=True, margin=0.003, scale=True)
except TypeError:
bpy.ops.uv.pack_islands(margin=0.003)
bpy.ops.object.mode_set(mode='OBJECT')
def get_islands():
m_ = ob.data
n_l_, n_f_ = len(m_.loops), len(m_.polygons)
lv = np.empty(n_l_, dtype=np.int32); m_.loops.foreach_get("vertex_index", lv)
uv_ = np.empty(n_l_ * 2); m_.uv_layers.active.data.foreach_get("uv", uv_)
uv_ = uv_.reshape(-1, 2)
ls = np.empty(n_f_, dtype=np.int32); m_.polygons.foreach_get("loop_start", ls)
lt = np.empty(n_f_, dtype=np.int32); m_.polygons.foreach_get("loop_total", lt)
li_ = ls[lt == 3]
Q = 1 << 20
k = (lv.astype(np.int64) * Q * Q
+ np.round(np.clip(uv_[:, 0], 0, 1) * (Q - 1)).astype(np.int64) * Q
+ np.round(np.clip(uv_[:, 1], 0, 1) * (Q - 1)).astype(np.int64))
_, uvv_ = np.unique(k, return_inverse=True)
n_uvv_ = uvv_.max() + 1
T_ = np.stack([uvv_[li_], uvv_[li_ + 1], uvv_[li_ + 2]], axis=1)
par = np.arange(n_uvv_, dtype=np.int64)
def find(x):
r = x
while par[r] != r:
r = par[r]
while par[x] != r:
par[x], x = r, par[x]
return r
for a_, b_, c_ in T_:
ra, rb, rc = find(a_), find(b_), find(c_)
if ra != rb:
par[rb] = ra
if ra != rc:
par[rc] = ra
_, isl_ = np.unique(np.array([find(i) for i in range(n_uvv_)]), return_inverse=True)
return isl_, isl_[T_[:, 0]], li_, lv, uv_, isl_.max() + 1
# Keep the source UVs on a second layer. Stage 31 needs them to resample the existing maps into
# the new layout — unwrapping over them in place would throw the textures away.
src_layer = ob.data.uv_layers.active
src_layer.name = "UVMap_tripo"
new_layer = ob.data.uv_layers.new(name="UVMap_atlas", do_init=True)
ob.data.uv_layers.active = new_layer
for i, l in enumerate(ob.data.uv_layers):
if l is new_layer:
ob.data.uv_layers.active_index = i
log(f"uv layers: {[l.name for l in ob.data.uv_layers]} active="
f"{ob.data.uv_layers.active.name}")
do_unwrap(pack=True)
isl, fisl, li, loops_v, uv, n_isl = get_islands()
log("unwrapped + packed")
tuv = np.stack([np.clip(uv, 0, 1)[li], np.clip(uv, 0, 1)[li + 1], np.clip(uv, 0, 1)[li + 2]], 1)
auv = 0.5 * np.abs((tuv[:, 1, 0] - tuv[:, 0, 0]) * (tuv[:, 2, 1] - tuv[:, 0, 1])
- (tuv[:, 2, 0] - tuv[:, 0, 0]) * (tuv[:, 1, 1] - tuv[:, 0, 1]))
me = ob.data
co = np.empty(len(me.vertices) * 3); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3)
P3 = co[np.stack([loops_v[li], loops_v[li + 1], loops_v[li + 2]], axis=1)]
a3 = 0.5 * np.linalg.norm(np.cross(P3[:, 1] - P3[:, 0], P3[:, 2] - P3[:, 0]), axis=1)
isl_auv = np.bincount(fisl, weights=auv, minlength=n_isl)
isl_a3 = np.bincount(fisl, weights=a3, minlength=n_isl)
isl_nf = np.bincount(fisl, minlength=n_isl)
UNITM = 1.777 / span[UP]
W = 4096
dens = np.where(isl_a3 > 0, np.sqrt(np.maximum(isl_auv, 0) / np.maximum(isl_a3, 1e-12))
* W / (UNITM * 1000.0), np.nan)
o = np.argsort(-isl_auv)
cum = np.cumsum(isl_auv[o]) / max(isl_auv.sum(), 1e-12)
n99 = int(np.searchsorted(cum, 0.99)) + 1
print(f"\n=== NEW ATLAS (Tripo baseline in brackets) ===")
print(f"islands {n_isl} [5870] 99%-of-area islands {n99} [84]")
print(f"coverage {isl_auv.sum()*100:.1f}% [62.0] confetti <20 faces {int((isl_nf<20).sum())} [5763]")
db = dens[o[:n99]]; db = db[np.isfinite(db)]
print(f"texel density {db.min():.2f}..{db.max():.2f} px/mm -> {db.max()/max(db.min(),1e-9):.2f}x "
f"spread [1.8x]")
# distortion, measured only over the real charts — the confetti islands are degenerate
# triangles whose ratios are meaningless and would own the tail
real = np.isin(fisl, o[:n99])
ok = real & (a3 > 1e-12) & (auv > 1e-14)
sc = np.sqrt(auv[ok] / a3[ok]); sc /= np.median(sc)
print(f"per-triangle area-scale vs median, real charts only: p05 {np.percentile(sc,5):.2f} "
f"p95 {np.percentile(sc,95):.2f} p99 {np.percentile(sc,99):.2f} (1.0 = undistorted)")
print("\nthe charts: # faces uv_area% 3D cm2 stretch_p95 where")
for i in o[:16]:
if isl_auv[i] * 100 < 0.05:
break
m = fisl == i
mo = m & (a3 > 1e-12) & (auv > 1e-14)
st = np.sqrt(auv[mo] / a3[mo])
st = st / np.median(st) if len(st) else np.array([1.0])
vs = np.unique(np.stack([loops_v[li], loops_v[li + 1], loops_v[li + 2]], 1)[m].ravel())
lab = []
for nm, msk in (("head", is_head), ("hand", is_hand), ("foot", is_foot),
("arm", is_arm & ~is_hand), ("leg", is_leg & ~is_foot)):
if msk[vs].mean() > 0.6:
lab.append(nm)
side = "L" if sn[vs].mean() > 0.15 else ("R" if sn[vs].mean() < -0.15 else "mid")
print(f" {i:6d} {isl_nf[i]:8d} {isl_auv[i]*100:9.3f} {isl_a3[i]*UNITM*UNITM*1e4:9.1f} "
f"{np.percentile(st,95):11.2f} {'+'.join(lab) or 'torso'} {side}")
# picture
R = 1024
rng = np.random.RandomState(3)
pal = rng.rand(n_isl, 3) * 0.7 + 0.25
IS = np.zeros((R, R, 3))
tp = tuv * (R - 1)
for fi in range(len(tp)):
P = tp[fi]
x0, x1 = int(P[:, 0].min()), int(np.ceil(P[:, 0].max()))
y0, y1 = int(P[:, 1].min()), int(np.ceil(P[:, 1].max()))
if x1 < x0 or y1 < y0 or x1 - x0 > 64 or y1 - y0 > 64:
continue
dt = ((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(dt) < 1e-12:
continue
gx, gy = np.meshgrid(np.arange(x0, min(x1, R - 1) + 1), np.arange(y0, min(y1, R - 1) + 1))
aa = ((P[1, 1] - P[2, 1]) * (gx - P[2, 0]) + (P[2, 0] - P[1, 0]) * (gy - P[2, 1])) / dt
bb = ((P[2, 1] - P[0, 1]) * (gx - P[2, 0]) + (P[0, 0] - P[2, 0]) * (gy - P[2, 1])) / dt
ins = (aa >= 0) & (bb >= 0) & (1 - aa - bb >= 0)
if ins.any():
IS[gy[ins], gx[ins]] = pal[fisl[fi]]
img = bpy.data.images.new("isl", R, R, alpha=False)
A = np.ones((R, R, 4), dtype=np.float32); A[:, :, :3] = IS
img.pixels.foreach_set(A.reshape(-1))
p = os.path.join(OUTDIR, "charts.png")
img.file_format = 'PNG'; img.filepath_raw = p; img.save(filepath=p)
bpy.ops.wm.save_as_mainfile(filepath=os.path.join(OUTDIR, "seamed.blend"))
log(f"wrote {p} + seamed.blend")
print("SEAMS_DONE")
+531
View File
@@ -0,0 +1,531 @@
# Stage 50 (v02): body-only unwrap; the head KEEPS its original Tripo UVs.
#
# Chain of failures that led here, all measured on v02/48_headsafe.blend:
# - MINIMUM_STRETCH over everything: the undecimated head carries Tripo's eyelash and brow
# shells — thousands of needle slivers — and SLIM COLLAPSED those charts to points
# (half of all loops at one UV coordinate, coverage 0.0%). v01 never saw this because
# decimation had already crushed the lashes before any unwrap ran.
# - ANGLE_BASED over everything: survived the slivers but packed at 17.3% coverage,
# 1.15 px/mm — half of v01's texel density, on the version whose whole point is quality.
#
# The escape: the head was deliberately never edited, so its ORIGINAL Tripo UV charts are
# still valid — and they are artist-grade (the face was the best-mapped region of the source
# atlas, ~2.6 px/mm). So: SLIM on the body only (proven at this density in v01),
# average-island-scale to equalise everything, boost the head charts by HEAD_BOOST for the
# quality this version exists for, then pack the lot together.
# Stage 49 (v02): 24_seams.py with ONE fix — a density-aware hip landmark.
#
# The v01 hip rule walked 0.01-thick midline slices and declared the crotch at the first slice
# holding <3 verts. On the head-protected v02 body (27.5k body verts vs 32k) that fired at
# u=0.610 — mid-belly. A hip ring above the real crotch turns the leg region into two joined
# tubes with too few cuts, and the minimum-stretch solver blows one chart up and packs the rest
# to nothing (coverage 0.0%). Slices are 2x thicker here and the threshold scales with the
# body's actual vertex density. Copied rather than edited: 24_seams.py is the recipe that
# regenerates the SHIPPED v01, and its behaviour must not drift.
# Stage 24: a proper human UV atlas — ~10 anatomical charts instead of Tripo's 5,870 blobs.
#
# blender --background --python 24_seams.py -- <mesh.glb|blend> <out_dir> [decimate_ratio]
#
# Every seam is placed off a MEASURED landmark, not a guessed threshold, so the same rules port
# between the hires sculpt and the decimated game body:
# neck / wrist / ankle = local minimum of cross-section radius (the narrow part)
# shoulder = smallest |s| whose slice is short in u (arm, not torso)
# hip = lowest slice that still has vertices on the midline (the crotch)
# Lengthwise cuts open each tube flat, hidden where nobody looks:
# torso + head -> back midline; arms -> back of the arm; legs -> inner side.
# Hands / feet / head need no lengthwise cut: a tube with one closed end is already a disc.
import bpy, bmesh, sys, os, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
SRC = argv[0]
OUTDIR = os.path.abspath(argv[1])
RATIO = float(argv[2]) if len(argv) > 2 else 0.0
os.makedirs(OUTDIR, exist_ok=True)
t0 = time.time()
def log(m):
print(f"[seam {time.time()-t0:6.1f}s] {m}", flush=True)
if SRC.lower().endswith(".glb"):
bpy.ops.wm.read_homefile(use_empty=True)
bpy.ops.import_scene.gltf(filepath=SRC)
else:
bpy.ops.wm.open_mainfile(filepath=SRC)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
bpy.context.view_layer.objects.active = ob
for o in bpy.data.objects:
o.select_set(o is ob)
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
log(f"body '{ob.name}' {len(ob.data.vertices)}v {len(ob.data.polygons)}f")
if 0 < RATIO < 1:
m = ob.modifiers.new("dec", 'DECIMATE')
m.ratio = RATIO
bpy.ops.object.modifier_apply(modifier=m.name)
log(f"decimated -> {len(ob.data.vertices)}v {len(ob.data.polygons)}f")
# The source mesh is non-manifold (1,649 edges with >2 faces) and decimation turns that into a
# scatter of degenerate/duplicate triangles. Each one becomes its own UV island — that is where
# the confetti comes from, not the seam rules. Clean it here, before any of it reaches the unwrap.
def clean_mesh(tag):
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.remove_doubles(threshold=1e-5)
bpy.ops.mesh.dissolve_degenerate(threshold=1e-6)
bpy.ops.mesh.delete_loose(use_verts=True, use_edges=True, use_faces=False)
# (Splitting the 1,649 non-manifold edges was tried here and rejected: it cost +3,512 verts
# and made the collapsed-face count worse, because the collapse is a solver problem, not a
# topology one — see the unwrap method below.)
bpy.ops.mesh.normals_make_consistent(inside=False)
bpy.ops.object.mode_set(mode='OBJECT')
bm_ = bmesh.new(); bm_.from_mesh(ob.data)
nm = sum(1 for e in bm_.edges if len(e.link_faces) > 2)
dg = sum(1 for f in bm_.faces if f.calc_area() < 1e-12)
bm_.free()
log(f"clean[{tag}]: {len(ob.data.vertices)}v {len(ob.data.polygons)}f "
f"nonmanifold_edges={nm} degenerate_faces={dg}")
# SKIP_CLEAN=1: the transplanted head's pristine look depends on its authored custom normals,
# and clean_mesh's normals_make_consistent re-winds nonmanifold shells — exactly the lash/eye
# geometry — which is how the master's eyes got broken in the first place. The cost of skipping
# is only cosmetic confetti islands from the body's 1,649 nonmanifold edges.
if os.environ.get("SKIP_CLEAN") != "1":
clean_mesh("after decimate")
else:
log("clean_mesh SKIPPED (SKIP_CLEAN=1) — protecting authored head normals")
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3)
lo, hi = co.min(axis=0), co.max(axis=0)
span = hi - lo
UP = int(np.argmax(span))
LR = int(np.argmax(np.where(np.arange(3) == UP, -1, span)))
FB = 3 - UP - LR
u = (co[:, UP] - lo[UP]) / span[UP]
s = co[:, LR] - 0.5 * (lo[LR] + hi[LR])
d = co[:, FB] - 0.5 * (lo[FB] + hi[FB])
HALF = 0.5 * span[LR]
sn = s / HALF # left-right, normalised to +-1
print(f"axes up={'xyz'[UP]} lr={'xyz'[LR]} fb={'xyz'[FB]} height {span[UP]:.4f} units")
# ---------------------------------------------------------------- landmarks
def radius_profile(mask, coord, lo_c, hi_c, nb):
"""mean in-slice radius vs coord, over `nb` bins — the narrow parts are the joints."""
ed = np.linspace(lo_c, hi_c, nb + 1)
mid = 0.5 * (ed[:-1] + ed[1:])
out = np.full(nb, np.nan)
for i in range(nb):
m = mask & (coord >= ed[i]) & (coord < ed[i + 1])
if m.sum() < 30:
continue
A = np.stack([s[m], d[m], u[m]], axis=1)
A = np.delete(A, 0 if coord is sn else 2, axis=1) if False else A
# radius measured in the two axes perpendicular to `coord`
if coord is u:
P = np.stack([s[m], d[m]], axis=1)
else:
P = np.stack([d[m], (u[m] - u[m].mean()) * span[UP]], axis=1)
out[i] = np.linalg.norm(P - P.mean(axis=0), axis=1).mean()
return mid, out
def local_min(mid, prof, lo_c, hi_c):
m = (mid >= lo_c) & (mid <= hi_c) & np.isfinite(prof)
if not m.any():
return 0.5 * (lo_c + hi_c)
return float(mid[m][np.argmin(prof[m])])
def taper_end(mid, prof, lo_c, hi_c, from_high, tol=0.08):
"""Where the limb stops tapering, taken from the EXTREMITY side.
The global minimum of the radius profile is not the joint: on the leg it sits up the shin,
well above where the foot ends. The joint is the end of the taper nearest the extremity —
the first bin (walking in from that side) that reaches within `tol` of the minimum.
"""
m = (mid >= lo_c) & (mid <= hi_c) & np.isfinite(prof)
if not m.any():
return 0.5 * (lo_c + hi_c)
mm, pp = mid[m], prof[m]
close = np.nonzero(pp <= pp.min() * (1.0 + tol))[0]
return float(mm[close[-1] if from_high else close[0]])
torso_side = np.abs(sn) < 0.30
mid_u, prof_u = radius_profile(torso_side, u, 0.0, 1.0, 60)
NECK_U = local_min(mid_u, prof_u, 0.80, 0.93)
# The ankle must be measured on ONE leg. Over both, the "radius" is really the gap between
# them, which falls monotonically from the feet up and has no minimum to find.
one_leg = (sn > 0.05) & (u < 0.35)
mid_l, prof_l = radius_profile(one_leg, u, 0.0, 0.35, 35)
print(" single-leg radius profile (u -> radius), the ankle is the narrow point:")
print(" " + " ".join(f"{m:.2f}:{r*1000:.0f}" for m, r in zip(mid_l, prof_l)
if np.isfinite(r)))
ANKLE_U = taper_end(mid_l, prof_l, 0.02, 0.14, from_high=False)
# shoulder: the smallest |s| whose slice is SHORT in u (an arm), scanning outward
absn = np.abs(sn)
ARM_IN = 0.35
for cut in np.arange(0.15, 0.60, 0.01):
m = (absn >= cut) & (absn < cut + 0.03)
if m.sum() < 30:
continue
if (u[m].max() - u[m].min()) < 0.13:
ARM_IN = float(cut)
break
mid_a, prof_a = radius_profile(absn > ARM_IN, absn, ARM_IN, 1.0, 40)
print(" arm radius profile (|s| -> radius), the wrist is the narrow point before the hand:")
print(" " + " ".join(f"{m:.2f}:{r*1000:.0f}" for m, r in zip(mid_a, prof_a)
if np.isfinite(r)))
# The profile reads: radius falls to the wrist, bulges again over the palm, then tapers down
# the fingers. Search below the palm bulge or the "wrist" lands among the fingers.
WRIST = taper_end(mid_a, prof_a, 0.60, 0.80, from_high=True)
# hip: the crotch is the HIGHEST strictly-empty midline slice. Measured on this mesh: the
# |sn|<0.025 strip is zero below u=0.42 and populated above — except one freak slice at
# 0.50-0.52 holding a single vertex, which is what tripped both threshold-based rules (v01's
# "<3 verts" and the density-scaled version) into calling mid-belly the crotch. No stray
# vertex can fake a STRICTLY empty slice, and there is no surface between the legs to put
# one there.
HIP_U = 0.45
for lv in np.arange(0.60, 0.20, -0.005):
m = (u >= lv) & (u < lv + 0.02) & (absn < 0.025)
if m.sum() == 0:
HIP_U = float(lv + 0.02)
break
print(f"LANDMARKS neck_u {NECK_U:.3f} hip_u {HIP_U:.3f} ankle_u {ANKLE_U:.3f} "
f"arm_in {ARM_IN:.3f} wrist {WRIST:.3f} (fractions of height / half-span)")
is_arm = absn > ARM_IN
is_hand = absn > WRIST
is_head = u > NECK_U
is_leg = (u < HIP_U) & ~is_arm
is_foot = u < ANKLE_U
# centre lines for the lengthwise cuts
# A constant centre works only for a perfectly axis-aligned limb. Hers droop, so a level set of
# u wanders off the arm and cuts it twice. Use a measured centre LINE: median u per |s| bin for
# the arms, median |s| per u bin for each leg, linearly interpolated.
def centre_line(mask, along, of, nb, lo_a, hi_a):
ed = np.linspace(lo_a, hi_a, nb + 1)
mid = 0.5 * (ed[:-1] + ed[1:])
val = np.full(nb, np.nan)
for i in range(nb):
m = mask & (along >= ed[i]) & (along < ed[i + 1])
if m.sum() >= 20:
val[i] = np.median(of[m])
ok = np.isfinite(val)
if ok.sum() < 2:
return lambda q: np.full_like(q, np.nanmedian(of[mask]) if mask.any() else 0.0)
mid_o, val_o = mid[ok], val[ok]
return lambda q: np.interp(np.asarray(q), mid_o, val_o)
arm_u_of = centre_line(is_arm & ~is_hand, absn, u, 24, ARM_IN, WRIST)
leg_s_of = {}
for sg in (-1, 1):
m = is_leg & ~is_foot & (np.sign(sn) == sg)
leg_s_of[sg] = centre_line(m, u, absn, 20, ANKLE_U, HIP_U)
# A foot cut at the ankle is an L (ankle-heel-toes) and a hand is a flat paddle; neither
# flattens as one chart. Split each along its silhouette, exactly where an artist would:
# the foot into upper/sole at its mid-height, the hand into back/palm at its mid-thickness.
U_SOLE = float(np.median(u[is_foot])) if is_foot.sum() else ANKLE_U * 0.5
D_PALM = float(np.median(d[is_hand])) if is_hand.sum() else 0.0
print(f" foot split at u {U_SOLE:.3f} (upper|sole) hand split at d {D_PALM:+.4f} (back|palm)")
qa = np.linspace(ARM_IN, WRIST, 5)
print(f" arm centre line u at |s|={np.round(qa,2).tolist()}: "
f"{np.round(arm_u_of(qa), 3).tolist()}")
ql = np.linspace(ANKLE_U, HIP_U, 5)
print(f" leg(+) centre line |s| at u={np.round(ql,2).tolist()}: "
f"{np.round(leg_s_of[1](ql), 3).tolist()}")
# The torso is a tube with FOUR holes (neck, two armholes, hip). The back midline joins neck to
# hip; two more cuts are needed or the chart stays multiply-connected and the unwrap stretches it
# badly. Run a relief cut across the BACK at armpit height, from each armhole in to the midline.
# upper body only — at |s| = ARM_IN the feet also splay out that far, and they win a p5
m_pit = (np.abs(absn - ARM_IN) < 0.03) & (u > 0.5)
U_PIT = float(np.percentile(u[m_pit], 5)) if m_pit.sum() > 30 else 0.62
print(f" armpit u {U_PIT:.3f} -> relief cut across the back at that height")
# ---------------------------------------------------------------- mark seams
bm = bmesh.new()
bm.from_mesh(me)
tally = {}
def hit(k):
tally[k] = tally.get(k, 0) + 1
return True
for e in bm.edges:
e.seam = False
for e in bm.edges:
a, b = e.verts[0].index, e.verts[1].index
# rings, in order of priority
if is_head[a] != is_head[b]:
e.seam = hit("neck ring"); continue
if is_hand[a] != is_hand[b]:
e.seam = hit("wrist rings"); continue
if is_foot[a] != is_foot[b]:
e.seam = hit("ankle rings"); continue
if is_arm[a] != is_arm[b]:
e.seam = hit("shoulder rings"); continue
if (u[a] < HIP_U) != (u[b] < HIP_U):
e.seam = hit("hip ring"); continue
# lengthwise cuts
if is_foot[a] and is_foot[b]:
if (u[a] > U_SOLE) != (u[b] > U_SOLE):
e.seam = hit("foot sole line"); continue
elif is_hand[a] and is_hand[b]:
if (d[a] > D_PALM) != (d[b] > D_PALM):
e.seam = hit("hand palm line"); continue
elif is_arm[a] and is_arm[b] and not (is_hand[a] or is_hand[b]):
ca, cb = arm_u_of(absn[a]), arm_u_of(absn[b])
if d[a] > 0 and d[b] > 0 and (u[a] > ca) != (u[b] > cb):
e.seam = hit("arm back line"); continue
elif is_leg[a] and is_leg[b] and not (is_foot[a] or is_foot[b]):
sg = int(1 if sn[a] + sn[b] >= 0 else -1)
if absn[a] < leg_s_of[sg](u[a]) and absn[b] < leg_s_of[sg](u[b]) \
and (d[a] > 0) != (d[b] > 0):
e.seam = hit("leg inner line"); continue
elif not (is_arm[a] or is_arm[b] or is_foot[a] or is_foot[b]):
# torso and head share one continuous back midline
if d[a] > 0 and d[b] > 0 and (sn[a] > 0) != (sn[b] > 0):
e.seam = hit("back midline"); continue
# armpit relief: back only, from each armhole inward to the midline
if d[a] > 0 and d[b] > 0 and not is_head[a] and not is_head[b] \
and (u[a] > U_PIT) != (u[b] > U_PIT):
e.seam = hit("armpit relief"); continue
for k in sorted(tally):
print(f" seam '{k}': {tally[k]} edges")
bm.to_mesh(me)
bm.free()
log(f"marked {sum(tally.values())} seam edges")
# ---------------------------------------------------------------- unwrap
UNWRAP_METHOD = os.environ.get("UNWRAP_METHOD", "MINIMUM_STRETCH")
def do_unwrap(pack):
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
# Angle-based (ABF) is conformal: it preserves angles and is free to crush area. At her folds
# and flaps that means whole patches land under one texel and come out untextured. The
# minimum-stretch (SLIM) solver optimises area distortion instead, which is what a texture
# transfer actually needs.
try:
bpy.ops.uv.unwrap(method=UNWRAP_METHOD, margin=0.0)
except TypeError:
log(f"unwrap method {UNWRAP_METHOD} unavailable — falling back to ANGLE_BASED")
bpy.ops.uv.unwrap(method='ANGLE_BASED', margin=0.0)
bpy.ops.uv.average_islands_scale()
if pack:
try:
bpy.ops.uv.pack_islands(rotate=True, margin=0.003, scale=True)
except TypeError:
bpy.ops.uv.pack_islands(margin=0.003)
bpy.ops.object.mode_set(mode='OBJECT')
def get_islands():
m_ = ob.data
n_l_, n_f_ = len(m_.loops), len(m_.polygons)
lv = np.empty(n_l_, dtype=np.int32); m_.loops.foreach_get("vertex_index", lv)
uv_ = np.empty(n_l_ * 2); m_.uv_layers.active.data.foreach_get("uv", uv_)
uv_ = uv_.reshape(-1, 2)
ls = np.empty(n_f_, dtype=np.int32); m_.polygons.foreach_get("loop_start", ls)
lt = np.empty(n_f_, dtype=np.int32); m_.polygons.foreach_get("loop_total", lt)
li_ = ls[lt == 3]
Q = 1 << 20
k = (lv.astype(np.int64) * Q * Q
+ np.round(np.clip(uv_[:, 0], 0, 1) * (Q - 1)).astype(np.int64) * Q
+ np.round(np.clip(uv_[:, 1], 0, 1) * (Q - 1)).astype(np.int64))
_, uvv_ = np.unique(k, return_inverse=True)
n_uvv_ = uvv_.max() + 1
T_ = np.stack([uvv_[li_], uvv_[li_ + 1], uvv_[li_ + 2]], axis=1)
par = np.arange(n_uvv_, dtype=np.int64)
def find(x):
r = x
while par[r] != r:
r = par[r]
while par[x] != r:
par[x], x = r, par[x]
return r
for a_, b_, c_ in T_:
ra, rb, rc = find(a_), find(b_), find(c_)
if ra != rb:
par[rb] = ra
if ra != rc:
par[rc] = ra
_, isl_ = np.unique(np.array([find(i) for i in range(n_uvv_)]), return_inverse=True)
return isl_, isl_[T_[:, 0]], li_, lv, uv_, isl_.max() + 1
# Keep the source UVs on a second layer. Stage 31 needs them to resample the existing maps into
# the new layout — unwrapping over them in place would throw the textures away.
src_layer = ob.data.uv_layers.active
src_layer.name = "UVMap_tripo"
new_layer = ob.data.uv_layers.new(name="UVMap_atlas", do_init=True)
ob.data.uv_layers.active = new_layer
for i, l in enumerate(ob.data.uv_layers):
if l is new_layer:
ob.data.uv_layers.active_index = i
log(f"uv layers: {[l.name for l in ob.data.uv_layers]} active="
f"{ob.data.uv_layers.active.name}")
HEAD_BOOST = float(os.environ.get("HEAD_BOOST", "1.5"))
# EXACTLY the seam threshold: with any offset, faces between the neck seam and the selection
# line end up in islands that are half re-unwrapped and half Tripo — incoherent charts.
NECK_FACE = NECK_U
# UVMap_atlas was created with do_init=True, i.e. a copy of the Tripo layer — so faces we do
# NOT unwrap keep their original mapping. Select body faces only, by every-corner test.
import bmesh as _bm
bpy.ops.object.mode_set(mode='EDIT')
# FACE select mode, or per-face .select is flushed away by vertex-mode sync and the unwrap
# silently runs on a leftover fraction of the body (measured: 9,757 of ~56k faces).
bpy.ops.mesh.select_mode(type='FACE')
bpy.ops.mesh.select_all(action='DESELECT')
bmm = _bm.from_edit_mesh(ob.data)
bmm.verts.ensure_lookup_table()
zs = [v.co.z for v in bmm.verts]
zlo_, zhi_ = min(zs), max(zs)
vhead = [((v.co.z - zlo_) / (zhi_ - zlo_)) > NECK_FACE for v in bmm.verts]
nbody = 0
for f in bmm.faces:
sel = not any(vhead[v.index] for v in f.verts)
f.select = sel
nbody += int(sel)
_bm.update_edit_mesh(ob.data)
log(f"body-only unwrap: {nbody} faces selected, head keeps Tripo UVs")
try:
bpy.ops.uv.unwrap(method='MINIMUM_STRETCH', margin=0.0)
except TypeError:
bpy.ops.uv.unwrap(method='ANGLE_BASED', margin=0.0)
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.uv.average_islands_scale()
bpy.ops.object.mode_set(mode='OBJECT')
# boost the head charts before packing: scale each majority-head island about its centroid
isl, fisl, li, loops_v, uv, n_isl = get_islands()
me2 = ob.data
co2 = np.empty(len(me2.vertices) * 3); me2.vertices.foreach_get("co", co2); co2 = co2.reshape(-1, 3)
u2 = (co2[:, 2] - co2[:, 2].min()) / (co2[:, 2].max() - co2[:, 2].min())
tri_head = u2[loops_v[li]] > NECK_FACE
head_isl = set()
for i in range(n_isl):
m = fisl == i
if m.sum() and tri_head[m].mean() > 0.5:
head_isl.add(i)
uvl2 = me2.uv_layers["UVMap_atlas"]
buf = np.empty(len(me2.loops) * 2); uvl2.data.foreach_get("uv", buf)
buf = buf.reshape(-1, 2)
# island id per loop: quantized (vertex,uv) identity -> island, same construction as get_islands
loop_isl = np.full(len(me2.loops), -1, dtype=np.int64)
loop_isl[li] = fisl
loop_isl[li + 1] = fisl
loop_isl[li + 2] = fisl
boosted = 0
for i in head_isl:
m = loop_isl == i
c = buf[m].mean(axis=0)
buf[m] = c + (buf[m] - c) * HEAD_BOOST
boosted += 1
uvl2.data.foreach_set("uv", buf.reshape(-1))
log(f"boosted {boosted} head islands x{HEAD_BOOST}")
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
try:
bpy.ops.uv.pack_islands(rotate=True, margin=0.003, scale=True)
except TypeError:
bpy.ops.uv.pack_islands(margin=0.003)
bpy.ops.object.mode_set(mode='OBJECT')
isl, fisl, li, loops_v, uv, n_isl = get_islands()
log("unwrapped + packed (body SLIM, head Tripo)")
tuv = np.stack([np.clip(uv, 0, 1)[li], np.clip(uv, 0, 1)[li + 1], np.clip(uv, 0, 1)[li + 2]], 1)
auv = 0.5 * np.abs((tuv[:, 1, 0] - tuv[:, 0, 0]) * (tuv[:, 2, 1] - tuv[:, 0, 1])
- (tuv[:, 2, 0] - tuv[:, 0, 0]) * (tuv[:, 1, 1] - tuv[:, 0, 1]))
me = ob.data
co = np.empty(len(me.vertices) * 3); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3)
P3 = co[np.stack([loops_v[li], loops_v[li + 1], loops_v[li + 2]], axis=1)]
a3 = 0.5 * np.linalg.norm(np.cross(P3[:, 1] - P3[:, 0], P3[:, 2] - P3[:, 0]), axis=1)
isl_auv = np.bincount(fisl, weights=auv, minlength=n_isl)
isl_a3 = np.bincount(fisl, weights=a3, minlength=n_isl)
isl_nf = np.bincount(fisl, minlength=n_isl)
UNITM = 1.777 / span[UP]
W = 4096
dens = np.where(isl_a3 > 0, np.sqrt(np.maximum(isl_auv, 0) / np.maximum(isl_a3, 1e-12))
* W / (UNITM * 1000.0), np.nan)
o = np.argsort(-isl_auv)
cum = np.cumsum(isl_auv[o]) / max(isl_auv.sum(), 1e-12)
n99 = int(np.searchsorted(cum, 0.99)) + 1
print(f"\n=== NEW ATLAS (Tripo baseline in brackets) ===")
print(f"islands {n_isl} [5870] 99%-of-area islands {n99} [84]")
print(f"coverage {isl_auv.sum()*100:.1f}% [62.0] confetti <20 faces {int((isl_nf<20).sum())} [5763]")
db = dens[o[:n99]]; db = db[np.isfinite(db)]
print(f"texel density {db.min():.2f}..{db.max():.2f} px/mm -> {db.max()/max(db.min(),1e-9):.2f}x "
f"spread [1.8x]")
# distortion, measured only over the real charts — the confetti islands are degenerate
# triangles whose ratios are meaningless and would own the tail
real = np.isin(fisl, o[:n99])
ok = real & (a3 > 1e-12) & (auv > 1e-14)
sc = np.sqrt(auv[ok] / a3[ok]); sc /= np.median(sc)
print(f"per-triangle area-scale vs median, real charts only: p05 {np.percentile(sc,5):.2f} "
f"p95 {np.percentile(sc,95):.2f} p99 {np.percentile(sc,99):.2f} (1.0 = undistorted)")
print("\nthe charts: # faces uv_area% 3D cm2 stretch_p95 where")
for i in o[:16]:
if isl_auv[i] * 100 < 0.05:
break
m = fisl == i
mo = m & (a3 > 1e-12) & (auv > 1e-14)
st = np.sqrt(auv[mo] / a3[mo])
st = st / np.median(st) if len(st) else np.array([1.0])
vs = np.unique(np.stack([loops_v[li], loops_v[li + 1], loops_v[li + 2]], 1)[m].ravel())
lab = []
for nm, msk in (("head", is_head), ("hand", is_hand), ("foot", is_foot),
("arm", is_arm & ~is_hand), ("leg", is_leg & ~is_foot)):
if msk[vs].mean() > 0.6:
lab.append(nm)
side = "L" if sn[vs].mean() > 0.15 else ("R" if sn[vs].mean() < -0.15 else "mid")
print(f" {i:6d} {isl_nf[i]:8d} {isl_auv[i]*100:9.3f} {isl_a3[i]*UNITM*UNITM*1e4:9.1f} "
f"{np.percentile(st,95):11.2f} {'+'.join(lab) or 'torso'} {side}")
# picture
R = 1024
rng = np.random.RandomState(3)
pal = rng.rand(n_isl, 3) * 0.7 + 0.25
IS = np.zeros((R, R, 3))
tp = tuv * (R - 1)
for fi in range(len(tp)):
P = tp[fi]
x0, x1 = int(P[:, 0].min()), int(np.ceil(P[:, 0].max()))
y0, y1 = int(P[:, 1].min()), int(np.ceil(P[:, 1].max()))
if x1 < x0 or y1 < y0 or x1 - x0 > 64 or y1 - y0 > 64:
continue
dt = ((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(dt) < 1e-12:
continue
gx, gy = np.meshgrid(np.arange(x0, min(x1, R - 1) + 1), np.arange(y0, min(y1, R - 1) + 1))
aa = ((P[1, 1] - P[2, 1]) * (gx - P[2, 0]) + (P[2, 0] - P[1, 0]) * (gy - P[2, 1])) / dt
bb = ((P[2, 1] - P[0, 1]) * (gx - P[2, 0]) + (P[0, 0] - P[2, 0]) * (gy - P[2, 1])) / dt
ins = (aa >= 0) & (bb >= 0) & (1 - aa - bb >= 0)
if ins.any():
IS[gy[ins], gx[ins]] = pal[fisl[fi]]
img = bpy.data.images.new("isl", R, R, alpha=False)
A = np.ones((R, R, 4), dtype=np.float32); A[:, :, :3] = IS
img.pixels.foreach_set(A.reshape(-1))
p = os.path.join(OUTDIR, "charts.png")
img.file_format = 'PNG'; img.filepath_raw = p; img.save(filepath=p)
bpy.ops.wm.save_as_mainfile(filepath=os.path.join(OUTDIR, "seamed.blend"))
log(f"wrote {p} + seamed.blend")
print("SEAMS_DONE")
+310
View File
@@ -0,0 +1,310 @@
# Stage 51 (v02): 31_reatlas.py + centroid splats for sub-texel triangles.
#
# The head-protected v02 body keeps Tripo's eyelash/eye/lip SHELLS at full density — thousands of
# triangles smaller than one texel. The v01 transfer just skipped those (0.58% of surface) and let
# the padding pass cover them, which was invisible when the skipped area was random slivers. Here
# the skipped set IS the lashes: they rendered as grey glass because their texels held padding
# smear instead of lash. Every sub-texel triangle now SPLATS its centroid: one point-sample of the
# source maps written to its one destination texel — so a lash texel holds lash.
# Stage 31: resample the existing maps into the new anatomical atlas, then export the re-atlased
# body.
#
# blender --background --python 31_reatlas.py -- <seamed.blend> <out_dir> <out.glb>
#
# Input is 24_seams.py's output, which carries TWO uv layers: 'UVMap_tripo' (the source 5,870-chart
# atlas, still holding the textures) and 'UVMap_atlas' (the new ~14-chart layout, empty). This walks
# every triangle in NEW atlas space, barycentrically recovers the OLD uv per texel, and samples the
# source maps there. Same mesh, same triangles, so the transfer is exact — no projection, no BVH,
# no Cycles bake.
#
# The normal map cannot simply be copied. It is tangent-space, and re-atlasing rotates (and
# sometimes mirrors) every chart, so its frame moves. Per face this computes the old and new
# tangent frames about the shared geometric normal and rotates the stored vector between them;
# skipping that would light her detail from the wrong direction, subtly and everywhere.
import bpy, sys, os, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUTDIR, OUTGLB = argv[0], os.path.abspath(argv[1]), os.path.abspath(argv[2])
os.makedirs(OUTDIR, exist_ok=True)
RES = int(argv[3]) if len(argv) > 3 else 4096
PAD = 16
t0 = time.time()
def log(m):
print(f"[atlas {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
me = ob.data
names = [l.name for l in me.uv_layers]
assert "UVMap_tripo" in names and "UVMap_atlas" in names, f"expected both uv layers, got {names}"
n_v, n_l, n_f = len(me.vertices), len(me.loops), len(me.polygons)
log(f"mesh {n_v}v {n_f}f uv layers {names}")
# ---- source maps, resolved through the material graph (the file holds stale duplicates) ----
src = {}
for slot in ob.material_slots:
mat = slot.material
if not mat or not mat.node_tree:
continue
for node in mat.node_tree.nodes:
if node.type != 'BSDF_PRINCIPLED':
continue
for sock, key in (("Base Color", "base"), ("Normal", "normal"), ("Roughness", "rm")):
if sock not in node.inputs or not node.inputs[sock].links:
continue
nd = node.inputs[sock].links[0].from_node
seen = set()
while nd and nd.type != 'TEX_IMAGE' and id(nd) not in seen:
seen.add(id(nd))
nxt = None
for i in nd.inputs:
if i.links:
nxt = i.links[0].from_node
break
nd = nxt
if nd and nd.type == 'TEX_IMAGE' and nd.image:
src[key] = nd.image
for k, im in src.items():
log(f"source {k}: '{im.name}' {im.size[0]}x{im.size[1]}")
assert "base" in src, "no basecolor found"
def px_of(img):
w, h = img.size
b = np.empty(w * h * 4, dtype=np.float32)
img.pixels.foreach_get(b)
return b.reshape(h, w, 4)[:, :, :3].astype(np.float32), w, h
# Colour management is not cosmetic here. `image.pixels` hands back linear values for an sRGB
# image and raw values for a Non-Color one, and re-encodes the same way on save. Write raw
# normal/rm data into a default (sRGB) image and saving gamma-encodes it: 0.5 comes back as 0.21,
# so every stored normal becomes a large false perturbation and the body shades dark and glossy.
# Each new map must therefore inherit its source's colorspace exactly.
for k, im in src.items():
log(f" {k} colorspace: {im.colorspace_settings.name}")
# ---- geometry + both uv sets ----
loops_v = np.empty(n_l, dtype=np.int32); me.loops.foreach_get("vertex_index", loops_v)
co = np.empty(n_v * 3); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3)
l_start = np.empty(n_f, dtype=np.int32); me.polygons.foreach_get("loop_start", l_start)
l_tot = np.empty(n_f, dtype=np.int32); me.polygons.foreach_get("loop_total", l_tot)
li = l_start[l_tot == 3]
nT = len(li)
def uv_of(name):
a = np.empty(n_l * 2)
me.uv_layers[name].data.foreach_get("uv", a)
return a.reshape(-1, 2)
UO = uv_of("UVMap_tripo")
UN = uv_of("UVMap_atlas")
IDX = np.stack([li, li + 1, li + 2], axis=1)
VI = loops_v[IDX] # (T,3) vertex index
Qo = np.clip(UO[IDX], 0.0, 1.0) # (T,3,2) source uv
Pn = np.clip(UN[IDX], 0.0, 1.0) # (T,3,2) new uv
P3 = co[VI] # (T,3,3)
log(f"{nT} triangles")
# ---- per-face tangent frames, for the normal-map rotation ----
e1 = P3[:, 1] - P3[:, 0]
e2 = P3[:, 2] - P3[:, 0]
N = np.cross(e1, e2)
N /= np.maximum(np.linalg.norm(N, axis=1, keepdims=True), 1e-20)
def tangent(Q):
d1 = Q[:, 1] - Q[:, 0]
d2 = Q[:, 2] - Q[:, 0]
det = d1[:, 0] * d2[:, 1] - d2[:, 0] * d1[:, 1]
r = np.where(np.abs(det) < 1e-20, 0.0, 1.0 / np.where(det == 0, 1.0, det))
T = (e1 * d2[:, 1:2] - e2 * d1[:, 1:2]) * r[:, None]
B = (e2 * d1[:, 0:1] - e1 * d2[:, 0:1]) * r[:, None]
# orthonormalise against the shared geometric normal
T = T - N * (N * T).sum(axis=1, keepdims=True)
ln = np.linalg.norm(T, axis=1, keepdims=True)
bad = (ln[:, 0] < 1e-12)
T = np.where(bad[:, None], np.cross(N, [0.0, 0.0, 1.0]), T / np.maximum(ln, 1e-20))
ln = np.maximum(np.linalg.norm(T, axis=1, keepdims=True), 1e-20)
T = T / ln
w = np.sign((np.cross(N, T) * B).sum(axis=1))
w = np.where(w == 0, 1.0, w)
return T, np.cross(N, T) * w[:, None]
To, Bo = tangent(Qo)
Tn, Bn = tangent(Pn)
# (nx',ny') = M . (nx,ny) — both frames share N, so nz is unchanged
M00 = (To * Tn).sum(axis=1); M01 = (Bo * Tn).sum(axis=1)
M10 = (To * Bn).sum(axis=1); M11 = (Bo * Bn).sum(axis=1)
rot = np.degrees(np.arctan2(M10, M00))
log(f"tangent frame rotation between atlases: p50 {np.percentile(np.abs(rot),50):.1f} deg, "
f"p95 {np.percentile(np.abs(rot),95):.1f} deg, max {np.abs(rot).max():.1f} deg")
# ---- rasterise the new atlas ----
W = H = RES
out = {k: np.zeros((H, W, 3), dtype=np.float32) for k in src}
srcpx = {k: px_of(v) for k, v in src.items()}
mask = np.zeros((H, W), dtype=bool)
def bilinear(A, w, h, uu, vv):
x = np.clip(uu, 0, 1) * (w - 1)
y = np.clip(vv, 0, 1) * (h - 1)
x0 = np.floor(x).astype(np.int32); y0 = np.floor(y).astype(np.int32)
x1 = np.minimum(x0 + 1, w - 1); y1 = np.minimum(y0 + 1, h - 1)
fx = (x - x0)[:, None]; fy = (y - y0)[:, None]
return (A[y0, x0] * (1 - fx) * (1 - fy) + A[y0, x1] * fx * (1 - fy)
+ A[y1, x0] * (1 - fx) * fy + A[y1, x1] * fx * fy)
Ppx = Pn * (W - 1)
TOL = -0.02 # slight over-rasterisation so charts have no interior gaps
done = 0
hitlist = []
for f in range(nT):
P = Ppx[f]
x0 = int(P[:, 0].min()); x1 = int(np.ceil(P[:, 0].max()))
y0 = int(P[:, 1].min()); y1 = int(np.ceil(P[:, 1].max()))
if x1 < x0 or y1 < y0 or (x1 - x0) > 512 or (y1 - y0) > 512:
continue
det = ((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(det) < 1e-12:
continue
gx, gy = np.meshgrid(np.arange(max(x0, 0), min(x1, W - 1) + 1),
np.arange(max(y0, 0), min(y1, H - 1) + 1))
if gx.size == 0:
continue
a = ((P[1, 1] - P[2, 1]) * (gx - P[2, 0]) + (P[2, 0] - P[1, 0]) * (gy - P[2, 1])) / det
b = ((P[2, 1] - P[0, 1]) * (gx - P[2, 0]) + (P[0, 0] - P[2, 0]) * (gy - P[2, 1])) / det
c = 1.0 - a - b
ins = (a >= TOL) & (b >= TOL) & (c >= TOL)
if not ins.any():
# sub-texel triangle: splat its centroid instead of abandoning it to the padding pass
cx = int(np.clip(round(float(P[:, 0].mean())), 0, W - 1))
cy = int(np.clip(round(float(P[:, 1].mean())), 0, H - 1))
ou = np.array([Qo[f, :, 0].mean()])
ov = np.array([Qo[f, :, 1].mean()])
for k, (A, w, h) in srcpx.items():
val = bilinear(A, w, h, ou, ov)
if k == "normal":
nx = val[:, 0] * 2.0 - 1.0
ny = val[:, 1] * 2.0 - 1.0
val = np.stack([(nx * M00[f] + ny * M01[f]) * 0.5 + 0.5,
(nx * M10[f] + ny * M11[f]) * 0.5 + 0.5,
val[:, 2]], axis=1)
out[k][cy, cx] = val[0]
mask[cy, cx] = True
hitlist.append(f)
done += 1
continue
# Write the slightly-outside rim pixels (TOL) but SAMPLE from strictly inside the source
# triangle — the old atlas is 38% empty, and extrapolating past a source triangle's edge
# reads black gap and leaves a dark fringe on every chart border.
aa = np.clip(a[ins], 0.0, 1.0); bb = np.clip(b[ins], 0.0, 1.0); cc = np.clip(c[ins], 0.0, 1.0)
tot = np.maximum(aa + bb + cc, 1e-12)
aa, bb, cc = aa / tot, bb / tot, cc / tot
ou = aa * Qo[f, 0, 0] + bb * Qo[f, 1, 0] + cc * Qo[f, 2, 0]
ov = aa * Qo[f, 0, 1] + bb * Qo[f, 1, 1] + cc * Qo[f, 2, 1]
yy, xx = gy[ins], gx[ins]
for k, (A, w, h) in srcpx.items():
val = bilinear(A, w, h, ou, ov)
if k == "normal":
nx = val[:, 0] * 2.0 - 1.0
ny = val[:, 1] * 2.0 - 1.0
val = np.stack([(nx * M00[f] + ny * M01[f]) * 0.5 + 0.5,
(nx * M10[f] + ny * M11[f]) * 0.5 + 0.5,
val[:, 2]], axis=1)
out[k][yy, xx] = val
mask[yy, xx] = True
hitlist.append(f)
done += 1
skipped = np.ones(nT, dtype=bool)
skipped[hitlist] = False
a3 = 0.5 * np.linalg.norm(np.cross(e1, e2), axis=1)
UNITM = 1.777 / (co[:, 2].max() - co[:, 2].min())
log(f"rasterised {done}/{nT} triangles -> {int(mask.sum())} texels "
f"({100.0*mask.sum()/(W*H):.1f}% of the atlas)")
log(f"skipped {int(skipped.sum())} triangles holding "
f"{a3[skipped].sum()*UNITM*UNITM*1e4:.2f} cm2 of "
f"{a3.sum()*UNITM*UNITM*1e4:.0f} cm2 total ({100.0*a3[skipped].sum()/a3.sum():.3f}%) "
f"— they are sub-texel slivers, covered by the padding pass")
# ---- pad outward so filtering and mip generation never pull in empty space ----
have = mask.copy()
for k in out:
C = out[k]
hv = mask.copy()
for _ in range(PAD):
acc = np.zeros_like(C)
wac = np.zeros((H, W), dtype=np.float32)
Wf = hv.astype(np.float32)
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
acc += np.roll(C * Wf[:, :, None], (dy, dx), axis=(0, 1))
wac += np.roll(Wf, (dy, dx), axis=(0, 1))
new = (~hv) & (wac > 0)
if not new.any():
break
C[new] = acc[new] / wac[new, None]
hv |= new
have = hv
log(f"padded to {int(have.sum())} texels ({100.0*have.sum()/(W*H):.1f}%)")
# ---- write the maps, rewire the material, drop the source uv layer ----
newimg = {}
for k in out:
im = bpy.data.images.new(f"lena_nude_atlas_{k}", W, H, alpha=False,
is_data=(src[k].colorspace_settings.name != 'sRGB'))
im.colorspace_settings.name = src[k].colorspace_settings.name
buf = np.ones((H, W, 4), dtype=np.float32)
buf[:, :, :3] = np.clip(out[k], 0, 1)
im.pixels.foreach_set(buf.reshape(-1))
im.file_format = 'JPEG' # the source maps are JPEG; match them so the GLB stays small
p = os.path.join(OUTDIR, f"lena_nude_atlas_{k}.jpg")
im.filepath_raw = p
im.save(filepath=p)
im.pack()
newimg[k] = im
log(f"wrote {p}")
for slot in ob.material_slots:
mat = slot.material
if not mat or not mat.node_tree:
continue
for node in mat.node_tree.nodes:
if node.type == 'TEX_IMAGE' and node.image:
for k, old in src.items():
if node.image == old:
node.image = newimg[k]
# Ship the body double-sided. The glTF material arrives single-sided, so Blender culls backfaces —
# and this mesh has patches wound inward (locally CONSISTENT, so `normals_make_consistent` cannot
# see them and settles at 25 stray triangles). Culled, those patches read as grey holes across her
# face and underbust in every render. Disabling culling here exports doubleSided=true and they
# disappear. Backface culling buys a character body essentially nothing.
for slot in ob.material_slots:
if slot.material:
slot.material.use_backface_culling = False
log(f"material '{slot.material.name}' -> doubleSided")
me.uv_layers.active = me.uv_layers["UVMap_atlas"]
me.uv_layers.remove(me.uv_layers["UVMap_tripo"])
me.uv_layers["UVMap_atlas"].name = "UVMap"
log(f"uv layers now {[l.name for l in me.uv_layers]}")
bpy.ops.wm.save_as_mainfile(filepath=os.path.join(OUTDIR, "reatlased.blend"))
for o in bpy.data.objects:
o.select_set(o is ob)
bpy.context.view_layer.objects.active = ob
bpy.ops.export_scene.gltf(filepath=OUTGLB, export_format='GLB', use_selection=True,
export_image_format='AUTO', export_jpeg_quality=95,
export_yup=True, export_apply=False)
log(f"EXPORTED {OUTGLB} ({os.path.getsize(OUTGLB)/1e6:.1f} MB)")
print("REATLAS_DONE")
+176
View File
@@ -0,0 +1,176 @@
# Stage 52 (v02): transplant the PRISTINE ORIGINAL head onto the decimated nude body.
#
# blender --background --python 52_head_transplant.py -- <body.blend> <out.blend>
#
# Why: bisecting the shard-eye defect (head renders per stage) proved it is in the hires MASTER,
# not 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 currently shipped game body. The one place the eyes are right is the
# untouched original, so the head comes from there — geometry, normals, UVs and all.
#
# Why a cut works HERE and not at the underwear: the bra had no skin beneath it (ray census,
# _Bare.glb's black cavity). The throat is ordinary skin on both sides of a plane, so both meshes
# take a clean planar bisect and the two rings bridge.
#
# The two lineages keep their own materials: body faces sample the master's maps (with the nude
# fill), head faces sample the ORIGINAL maps — the transfer stage resolves images per material.
# Bridge faces are new geometry with no source UVs; each takes a single nearby body-ring UV
# (all three corners the same texel), i.e. a flat splat of throat skin, invisible on a uniform
# throat and never smearing across two different atlases.
import bpy, bmesh, sys, os, time
import numpy as np
from mathutils import Vector
argv = sys.argv[sys.argv.index("--") + 1:]
BODY_BLEND, OUT = argv[0], argv[1]
ORIG_GLB = r"C:\Users\Jeremy\tinqs\animation\characters\originals\female\female_lena_tripo.glb"
# TX_ZCUT env overrides the plane. The default 0.775 is what the SHIPPED hybrid used and must
# keep reproducing it — but that plane clips the T-POSE ARMS, giving five boundary rings, and at
# full density bridge_loops paired the wrong ones (a geometry shelf flared out of the shoulders).
# For full-res builds use 0.80: pure throat, exactly one ring per side.
Z_CUT = float(os.environ.get("TX_ZCUT", "0.775"))
t0 = time.time()
def log(m):
print(f"[tx {time.time()-t0:6.1f}s] {m}", flush=True)
def bisect_keep(ob, keep_above):
bpy.ops.object.select_all(action='DESELECT')
ob.select_set(True)
bpy.context.view_layer.objects.active = ob
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
# plane normal is +z, so OUTER = above the cut. clear_outer removes the ABOVE side —
# the first run had these inverted and stitched the shard head onto the clothed body.
bpy.ops.mesh.bisect(plane_co=(0, 0, Z_CUT), plane_no=(0, 0, 1),
clear_inner=keep_above, clear_outer=not keep_above, use_fill=False)
bpy.ops.object.mode_set(mode='OBJECT')
bpy.ops.wm.open_mainfile(filepath=BODY_BLEND)
body = max([o for o in bpy.data.objects if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
log(f"body in: {len(body.data.vertices)} v")
bisect_keep(body, keep_above=False)
log(f"body below z={Z_CUT}: {len(body.data.vertices)} v")
# give the body a custom-normals layer BEFORE joining, or join drops the head's authored ones
body.data.shade_smooth()
try:
ln = np.empty(len(body.data.loops) * 3, dtype=np.float32)
body.data.corner_normals.foreach_get("vector", ln)
body.data.normals_split_custom_set(ln.reshape(-1, 3))
except Exception as e:
log(f" custom-normal seed on body: {e}")
before = {o.name for o in bpy.data.objects}
bpy.ops.import_scene.gltf(filepath=ORIG_GLB)
new = [o for o in bpy.data.objects if o.name not in before]
# drop importer widgets (glTF_not_exported) and non-meshes
for o in list(new):
if o.type != 'MESH' or any(c.name.startswith("glTF_not_exported") for c in o.users_collection):
if o.type == 'MESH' or o.type == 'ARMATURE' or o.type == 'EMPTY':
bpy.data.objects.remove(o, do_unlink=True)
new.remove(o)
head = max([o for o in new if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
bpy.ops.object.select_all(action='DESELECT')
head.select_set(True)
bpy.context.view_layer.objects.active = head
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
log(f"original in: {len(head.data.vertices)} v")
bisect_keep(head, keep_above=True)
log(f"original above z={Z_CUT}: {len(head.data.vertices)} v")
# BOTH sides must share one UV layer name or join makes two half-empty layers and the head
# samples texel (0,0) — measured: the whole head rendered as clay. The body blend's layer is
# 'UVMap' (34_v04 lineage); rename both to UVMap_tripo so join merges them into one.
for l in head.data.uv_layers:
l.name = "UVMap_tripo"
for l in body.data.uv_layers:
l.name = "UVMap_tripo"
# remember a body-side ring UV for the bridge splat
bm = bmesh.new(); bm.from_mesh(body.data)
uvl = bm.loops.layers.uv.get("UVMap_tripo") or bm.loops.layers.uv.active
ring_uv = None
for v in bm.verts:
if abs(v.co.z - Z_CUT) < 1e-4 and v.link_loops:
# take the uv of a loop one step AWAY from the ring (interior throat texel)
for l_ in v.link_loops:
ring_uv = tuple(l_[uvl].uv)
break
if ring_uv:
break
bm.free()
log(f"bridge splat uv: {ring_uv}")
# join: body is the target so its material stays slot 0; head material appends as slot 1
n_faces_before_join = len(body.data.polygons)
bpy.ops.object.select_all(action='DESELECT')
body.select_set(True)
head.select_set(True)
bpy.context.view_layer.objects.active = body
bpy.ops.object.join()
me = body.data
log(f"joined: {len(me.vertices)} v, {len(me.polygons)} f, materials {[m.name for m in me.materials]}")
# bridge the two rings
n_faces_before_bridge = len(me.polygons)
bm = bmesh.new(); bm.from_mesh(me)
bm.edges.ensure_lookup_table()
ring_edges = [e for e in bm.edges
if len(e.link_faces) == 1
and abs(e.verts[0].co.z - Z_CUT) < 1e-4 and abs(e.verts[1].co.z - Z_CUT) < 1e-4]
# how many separate rings? more than 2 means the plane clipped limbs and the bridge will pair wrong
_par = {}
def _find(a):
while _par.get(a, a) != a:
_par[a] = _par.get(_par[a], _par[a]); a = _par[a]
return a
for e in ring_edges:
a, b_ = _find(e.verts[0].index), _find(e.verts[1].index)
if a != b_: _par[a] = b_
_roots = {_find(e.verts[0].index) for e in ring_edges}
log(f"ring boundary edges: {len(ring_edges)} in {len(_roots)} loops"
+ (" <-- WARNING: >2 loops, plane clips limbs" if len(_roots) > 2 else ""))
try:
res = bmesh.ops.bridge_loops(bm, edges=ring_edges)
new_faces = res.get("faces", [])
except Exception as e:
log(f"bridge_loops failed: {e}")
new_faces = []
log(f"bridge created {len(new_faces)} faces")
uvl = bm.loops.layers.uv.get("UVMap_tripo") or bm.loops.layers.uv.active
for f in new_faces:
f.material_index = 0 # body material: throat skin
f.smooth = True
for l_ in f.loops:
l_[uvl].uv = ring_uv # flat splat of one throat texel
bm.to_mesh(me)
bm.free()
me.update()
# One material for the whole mesh. The transfer stage resamples every map into ONE new atlas,
# so a second material would leave head faces pointing at untransferred images with replaced
# UVs. Sampling the head from the MASTER's maps is exact: every texture pass in the lane masked
# the head out, so its texels are the original's bit-for-bit.
mi = np.zeros(len(me.polygons), dtype=np.int32)
me.polygons.foreach_set("material_index", mi)
while len(me.materials) > 1:
me.materials.pop(index=1)
log(f"unified material: {[m.name for m in me.materials]}")
# verify
co = np.empty(len(me.vertices) * 3); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3)
bm = bmesh.new(); bm.from_mesh(me)
open_ring = [e for e in bm.edges if len(e.link_faces) == 1
and abs(e.verts[0].co.z - Z_CUT) < 1e-3]
bm.free()
print(f"\nVERIFY: {len(me.vertices)} v / {len(me.polygons)} f")
print(f" height {co[:,2].max()-co[:,2].min():.5f} (feet {co[:,2].min():+.5f})")
print(f" open boundary edges left at the cut: {len(open_ring)} (0 = ring fully bridged)")
print(f" materials: {[m.name for m in me.materials]}")
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("TRANSPLANT_DONE")
+98
View File
@@ -0,0 +1,98 @@
# Stage 53 (v02): rig the transplant body by transferring weights from the rigged v01.
#
# blender --background --python 53_rig_transfer.py -- <reatlased.blend> <rig_v01.blend> <out.blend> <out.glb>
#
# The v02 topology is new (head transplant + re-decimation), so the index-exact graft that rigged
# v01 is off the table. Nearest-surface transfer is safe HERE because source and target are the
# SAME body in the SAME frame (v01 was decimated from the same master this body's torso came
# from): every target vertex sits on or microns from the source surface. The July finger-mangling
# happened transferring decimated->FULL-RES fingers; v02's hands are at v01's own density, and
# the full-res head takes trivially rigid weights (Head + face bones).
#
# Verification is the same standard as the graft: weights must sum to 1 with zero unweighted
# verts, and the DEFORMED rest pose must sit exactly on the undeformed mesh.
import bpy, sys, os, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
TARGET, RIGSRC, OUTB, OUTG = argv[0], argv[1], os.path.abspath(argv[2]), os.path.abspath(argv[3])
t0 = time.time()
def log(m):
print(f"[rig53 {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=TARGET)
body = max([o for o in bpy.data.objects if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
me = body.data
n = len(me.vertices)
V_ref = np.empty(n * 3); me.vertices.foreach_get("co", V_ref); V_ref = V_ref.reshape(-1, 3)
UNITM = 1.777 / (V_ref[:, 2].max() - V_ref[:, 2].min())
log(f"target: {n} v")
with bpy.data.libraries.load(RIGSRC, link=False) as (src, dst):
dst.objects = src.objects
donor, arm = None, None
for o in dst.objects:
if o is None:
continue
bpy.context.scene.collection.objects.link(o)
if o.type == 'MESH':
donor = o
if o.type == 'ARMATURE':
arm = o
log(f"donor: {len(donor.data.vertices)} v, {len(donor.vertex_groups)} groups; "
f"armature: {len(arm.data.bones)} bones")
# transfer weights nearest-surface
for vg in list(body.vertex_groups):
body.vertex_groups.remove(vg)
bpy.context.view_layer.objects.active = body
dt = body.modifiers.new("wts", 'DATA_TRANSFER')
dt.object = donor
dt.use_vert_data = True
dt.data_types_verts = {'VGROUP_WEIGHTS'}
dt.vert_mapping = 'POLYINTERP_NEAREST'
dt.layers_vgroup_select_src = 'ALL'
bpy.ops.object.datalayout_transfer(modifier=dt.name)
bpy.ops.object.modifier_apply(modifier=dt.name)
log(f"weights transferred into {len(body.vertex_groups)} groups")
bpy.ops.object.vertex_group_limit_total(group_select_mode='ALL', limit=4)
bpy.ops.object.vertex_group_normalize_all(group_select_mode='ALL', lock_active=False)
# bind
for m_ in list(body.modifiers):
if m_.type == 'ARMATURE':
body.modifiers.remove(m_)
mod = body.modifiers.new("Armature", 'ARMATURE')
mod.object = arm
body.parent = arm
body.matrix_parent_inverse = arm.matrix_world.inverted()
bpy.data.objects.remove(donor, do_unlink=True)
# verify
tot = np.zeros(n)
for v in me.vertices:
tot[v.index] = sum(g.weight for g in v.groups)
dg = bpy.context.evaluated_depsgraph_get()
evo = body.evaluated_get(dg)
tmp = evo.to_mesh()
Dv = np.empty(len(tmp.vertices) * 3); tmp.vertices.foreach_get("co", Dv); Dv = Dv.reshape(-1, 3)
evo.to_mesh_clear()
drift = np.linalg.norm(Dv - V_ref, axis=1).max() * UNITM * 1000
print("\n=== VERIFY ===")
print(f" weight sums: min {tot.min():.4f} mean {tot.mean():.4f} max {tot.max():.4f}")
print(f" unweighted vertices: {int((tot < 1e-6).sum())}")
print(f" DEFORMED at rest: z {Dv[:,2].min():.5f}..{Dv[:,2].max():.5f} drift {drift:.4f} mm")
assert tot.min() > 0.99 and drift < 0.01, "rig transfer failed verification"
bpy.ops.wm.save_as_mainfile(filepath=OUTB)
bpy.ops.object.select_all(action='DESELECT')
arm.select_set(True); body.select_set(True)
bpy.context.view_layer.objects.active = arm
bpy.ops.export_scene.gltf(filepath=OUTG, export_format='GLB', use_selection=True,
export_image_format='AUTO', export_jpeg_quality=95,
export_yup=True, export_apply=False, export_skins=True)
log(f"EXPORTED {OUTG} ({os.path.getsize(OUTG)/1e6:.2f} MB)")
print("RIG53_DONE")
+259
View File
@@ -0,0 +1,259 @@
# Stage 54 (v02): kill the rusty crotch patch on the v02 atlas — the focused re-run of what
# 36_refill.py did for v01.
#
# blender --background --python 54_crotch_refill.py -- <in.blend> <out.blend>
#
# Why not just re-run 36: it rebuilds its mask from masks.npz + 00_welded.blend, and the welded
# reference didn't survive the prune. It's also not needed here — the v02 body texture comes from
# the 34_v04 master whose garment regions were already patch-filled clean; the ONLY colour
# regression is the donor-crotch rust, and that region is a geometric box (the same box 36 used:
# |x| < 0.075, 0.340 < z < 0.480, in the 0.98-unit body frame).
#
# Method is 36's, unchanged in the ways that mattered:
# - the fill tone is a HARMONIC solve on the mesh with Dirichlet boundaries (CG, not Jacobi) —
# equal to her real skin at the mask edge by construction, and seam-proof across the
# torso/leg chart border the crotch straddles;
# - grain transplanted from clean skin tiles so it is not a decal;
# - normal flattened and rm set to surrounding-skin median over the same texels, which is what
# keeps the region featureless.
# Texture-only: works directly on the RIGGED master, no re-rig needed.
import bpy, sys, os, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT = argv[0], argv[1]
GROW = 3
GRAIN_T = 16
FEATHER = 4
t0 = time.time()
def log(m):
print(f"[cr54 {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
me = ob.data
n_v, n_l, n_f = len(me.vertices), len(me.loops), len(me.polygons)
co = np.empty(n_v * 3); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3)
z = co[:, 2] - co[:, 2].min()
log(f"{n_v}v {n_f}f")
# ---- the mask: 36's crotch box, slightly extended down the inner thigh, + grow rings ----
mask = (np.abs(co[:, 0]) < 0.075) & (z > 0.320) & (z < 0.480)
ev = np.empty(len(me.edges) * 2, dtype=np.int32); me.edges.foreach_get("vertices", ev)
ev = ev.reshape(-1, 2)
for _ in range(GROW):
hit = mask[ev[:, 0]] | mask[ev[:, 1]]
mask[ev[hit, 0]] = True
mask[ev[hit, 1]] = True
log(f"crotch mask: {int(mask.sum())} verts")
# ---- images through the material graph ----
src = {}
for slot in ob.material_slots:
mat = slot.material
if not mat or not mat.node_tree:
continue
for node in mat.node_tree.nodes:
if node.type != 'BSDF_PRINCIPLED':
continue
for sock, key in (("Base Color", "base"), ("Normal", "normal"), ("Roughness", "rm")):
if sock not in node.inputs or not node.inputs[sock].links:
continue
nd = node.inputs[sock].links[0].from_node
seen = set()
while nd and nd.type != 'TEX_IMAGE' and id(nd) not in seen:
seen.add(id(nd))
nxt = None
for i in nd.inputs:
if i.links:
nxt = i.links[0].from_node
break
nd = nxt
if nd and nd.type == 'TEX_IMAGE' and nd.image:
src[key] = nd.image
base = src["base"]
W, H = base.size
buf = np.empty(W * H * 4, dtype=np.float32)
base.pixels.foreach_get(buf)
tex = buf.reshape(H, W, 4)
rgb = tex[:, :, :3].astype(np.float64)
log(f"atlas '{base.name}' {W}x{H}")
loops_v = np.empty(n_l, dtype=np.int32); me.loops.foreach_get("vertex_index", loops_v)
uv = np.empty(n_l * 2); me.uv_layers.active.data.foreach_get("uv", uv); uv = uv.reshape(-1, 2)
l_start = np.empty(n_f, dtype=np.int32); me.polygons.foreach_get("loop_start", l_start)
l_tot = np.empty(n_f, dtype=np.int32); me.polygons.foreach_get("loop_total", l_tot)
li = l_start[l_tot == 3]
px = np.clip(np.round(uv[:, 0] * (W - 1)).astype(np.int32), 0, W - 1)
py = np.clip(np.round(uv[:, 1] * (H - 1)).astype(np.int32), 0, H - 1)
lc = rgb[py, px]
vc = np.zeros((n_v, 3))
for c in range(3):
vc[:, c] = np.bincount(loops_v, weights=lc[:, c], minlength=n_v)
vn = np.maximum(np.bincount(loops_v, minlength=n_v), 1)
vc /= vn[:, None]
# ---- harmonic fill (CG on the graph Laplacian, Dirichlet boundary = her real skin) ----
o_ = np.concatenate([ev[:, 0], ev[:, 1]])
n_ = np.concatenate([ev[:, 1], ev[:, 0]])
deg = np.maximum(np.bincount(o_, minlength=n_v).astype(np.float64), 1.0)
mf = mask.astype(np.float64)
def A_mul(x):
xm = x * mf
return (deg * xm - np.bincount(o_, weights=xm[n_], minlength=n_v)) * mf
fill = vc.copy()
for c in range(3):
known = vc[:, c] * (1.0 - mf)
b = np.bincount(o_, weights=known[n_], minlength=n_v) * mf
x = np.zeros(n_v)
r = b - A_mul(x)
p = r.copy()
rs = float(r @ r); r0 = rs
for it in range(4000):
if rs <= max(r0 * 1e-12, 1e-20):
break
Ap = A_mul(p)
d_ = float(p @ Ap)
if abs(d_) < 1e-30:
break
al = rs / d_
x += al * p; r -= al * Ap
rs2 = float(r @ r)
p = r + (rs2 / rs) * p
rs = rs2
fill[mask, c] = x[mask]
log(f" ch{c}: CG {it+1} iters, residual {np.sqrt(rs/max(r0,1e-30)):.2e}")
bnd = mask & (np.bincount(o_, weights=(1.0 - mf)[n_], minlength=n_v) > 0)
step = np.abs(fill[bnd] - vc[bnd]).max(axis=1)
log(f"boundary agreement: mean {step.mean():.4f} p99 {np.percentile(step,99):.4f}")
# ---- rasterise masked faces ----
IDX = np.stack([li, li + 1, li + 2], axis=1)
V = loops_v[IDX]
face_any = mask[V].any(axis=1)
P = np.stack([uv[IDX][:, :, 0] * (W - 1), uv[IDX][:, :, 1] * (H - 1)], axis=2)
out = rgb.copy()
paint = np.zeros((H, W), dtype=bool)
for f in np.nonzero(face_any)[0]:
p3 = P[f]
x0, x1 = int(p3[:, 0].min()), int(np.ceil(p3[:, 0].max()))
y0, y1 = int(p3[:, 1].min()), int(np.ceil(p3[:, 1].max()))
if x1 < x0 or y1 < y0 or x1 - x0 > 512 or y1 - y0 > 512:
continue
det = ((p3[1, 1] - p3[2, 1]) * (p3[0, 0] - p3[2, 0])
+ (p3[2, 0] - p3[1, 0]) * (p3[0, 1] - p3[2, 1]))
if abs(det) < 1e-12:
continue
gx, gy = np.meshgrid(np.arange(max(x0, 0), min(x1, W - 1) + 1),
np.arange(max(y0, 0), min(y1, H - 1) + 1))
if gx.size == 0:
continue
a = ((p3[1, 1] - p3[2, 1]) * (gx - p3[2, 0]) + (p3[2, 0] - p3[1, 0]) * (gy - p3[2, 1])) / det
b_ = ((p3[2, 1] - p3[0, 1]) * (gx - p3[2, 0]) + (p3[0, 0] - p3[2, 0]) * (gy - p3[2, 1])) / det
c_ = 1.0 - a - b_
ins = (a >= -0.02) & (b_ >= -0.02) & (c_ >= -0.02)
if not ins.any():
continue
aa, bb, cc = a[ins], b_[ins], c_[ins]
w = aa * mf[V[f, 0]] + bb * mf[V[f, 1]] + cc * mf[V[f, 2]]
col = (aa[:, None] * fill[V[f, 0]] + bb[:, None] * fill[V[f, 1]] + cc[:, None] * fill[V[f, 2]])
yy, xx = gy[ins], gx[ins]
hard = w > 0.5
if hard.any():
out[yy[hard], xx[hard]] = col[hard]
paint[yy[hard], xx[hard]] = True
log(f"repainted {int(paint.sum())} texels ({100.0*paint.mean():.3f}%)")
def box(a, r):
def b1(v, ax):
pad = [(0, 0)] * v.ndim
pad[ax] = (r, r)
cs = np.cumsum(np.pad(v, pad, mode="edge"), axis=ax)
return (np.take(cs, np.arange(2 * r, cs.shape[ax]), axis=ax)
- np.take(cs, np.arange(0, cs.shape[ax] - 2 * r), axis=ax)) / (2 * r)
return b1(b1(a, 0), 1)
# grain transplant
grain = np.stack([rgb[:, :, c] - box(rgb[:, :, c], 5) for c in range(3)], axis=2)
tone = np.median(out[paint], axis=0)
cand = []
for ty in range(0, H - GRAIN_T, GRAIN_T):
for tx in range(0, W - GRAIN_T, GRAIN_T):
if paint[ty:ty + GRAIN_T, tx:tx + GRAIN_T].any():
continue
t = rgb[ty:ty + GRAIN_T, tx:tx + GRAIN_T].reshape(-1, 3)
if t.min() < 0.02:
continue
if np.abs(t.mean(axis=0) - tone).max() < 0.10:
cand.append((ty, tx))
rng = np.random.RandomState(11)
if cand:
for ty in range(0, H - GRAIN_T + 1, GRAIN_T):
for tx in range(0, W - GRAIN_T + 1, GRAIN_T):
tm = paint[ty:ty + GRAIN_T, tx:tx + GRAIN_T]
if not tm.any():
continue
sy, sx = cand[rng.randint(len(cand))]
out[ty:ty + GRAIN_T, tx:tx + GRAIN_T][tm] += \
grain[sy:sy + GRAIN_T, sx:sx + GRAIN_T][tm] * 0.85
log(f"grain from {len(cand)} tiles")
# feather rim
a_ = np.ones((H, W))
edge = paint.copy()
for k in range(FEATHER):
grown = edge.copy()
grown[1:-1, 1:-1] |= (edge[:-2, 1:-1] | edge[2:, 1:-1] | edge[1:-1, :-2] | edge[1:-1, 2:])
ring = grown & ~edge
a_[ring] = (k + 1) / (FEATHER + 1.0)
edge = grown
blend = np.where(paint, 1.0, 1.0 - a_)[:, :, None]
final = np.clip(out * blend + rgb * (1 - blend), 0, 1)
b4 = tex.copy()
b4[:, :, :3] = final.astype(np.float32)
base.pixels.foreach_set(b4.reshape(-1))
base.pack()
# normal flat + rm to surrounding median over the same texels
def dil(m, k):
g = m.copy()
for _ in range(k):
n2 = g.copy()
n2[1:, :] |= g[:-1, :]; n2[:-1, :] |= g[1:, :]
n2[:, 1:] |= g[:, :-1]; n2[:, :-1] |= g[:, 1:]
g = n2
return g
soft = dil(paint, 2)
for key in ("normal", "rm"):
if key not in src:
continue
im = src[key]
if tuple(im.size) != (W, H):
continue
a2 = np.empty(W * H * 4, dtype=np.float32)
im.pixels.foreach_get(a2)
arr = a2.reshape(H, W, 4)
if key == "normal":
arr[soft, 0] = 0.5; arr[soft, 1] = 0.5; arr[soft, 2] = 1.0
else:
med = np.median(arr[~dil(paint, 8)][:, :3], axis=0)
arr[soft, 0] = med[0]; arr[soft, 1] = med[1]; arr[soft, 2] = med[2]
im.pixels.foreach_set(arr.reshape(-1))
im.pack()
log(f" {key}: {int(soft.sum())} texels neutralised")
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("CR54_DONE")
+6
View File
@@ -19,3 +19,9 @@ lena_nude_accurig_glb_v01.blend # rigged head: athletic_v04 + AccuRig skeleton
# The athletic_v04_*.jpg beside them are loose copies of the shipped maps. The .blend
# files pack their own textures, so the jpgs are for re-shipping a texture without
# opening Blender — deleting them costs nothing but convenience.
55_fullres_v02.blend # THE archival master (2026-08-12): full-res nude body + the PRISTINE
# ORIGINAL head (52_head_transplant.py, TX_ZCUT=0.80 — 0.775 clips the
# T-pose arms), crotch refill (54) + texture despeckle (40 x2) applied.
# 883,404 v / 1,761,640 f. The only Lena with nothing broken anywhere —
# supersedes 34_v04 as the root (34_v04's head has the shard eyes).
Binary file not shown.
+136
View File
@@ -0,0 +1,136 @@
{
"name": "hunter_skirt_v1",
"_worksheet": {
"block": "aline_skirt",
"waist_w_mm": 498.1,
"hem_w_mm": 687.4,
"panel_h_mm": 390.0,
"tension": 0.91,
"flare": 1.38,
"lines": {
"waist": 0,
"side_r": 1,
"hem": 2,
"side_l": 3
}
},
"md": {
"reset": "new_project",
"avatar_fbx": "C:/Users/Jeremy/tinqs/animation/tools/tailor/avatar/Lena_QuatSkin_Avatar.fbx",
"avatar_scale": 10.0,
"add_arrangement_points": true,
"auto_translate": true,
"zfab": "C:/Users/Public/Documents/MarvelousDesigner/New Assets/Fabric/(Default for Simulation).zfab",
"texture": "tools/tailor/textures/hunter_cloth.png",
"texture_dpi": 130.0,
"panels": [
{
"name": "front",
"dx": 0.0,
"note": "aline_skirt block: waist 498.1 (= 0.91 x 1094.7 hip), hem 687.4, H 390.0",
"points": [
[
94.6,
390.0
],
[
592.7,
390.0
],
[
687.4,
0.0
],
[
0.0,
0.0
]
]
},
{
"name": "back",
"dx": 937.4,
"note": "identical block; lines 0 waist | 1 side_R | 2 hem | 3 side_L",
"points": [
[
94.6,
390.0
],
[
592.7,
390.0
],
[
687.4,
0.0
],
[
0.0,
0.0
]
]
}
],
"seams": [
{
"a": "front",
"a_line": 1,
"b": "back",
"b_line": 1,
"reverse_a": true,
"reverse_b": true
},
{
"a": "front",
"a_line": 3,
"b": "back",
"b_line": 3,
"reverse_a": true,
"reverse_b": true
}
],
"arrangements": [
{
"panel": "front",
"point": "Leg_Skirt_Front",
"offset": [
50,
92,
50
]
},
{
"panel": "back",
"point": "Leg_Skirt_Back",
"offset": [
0,
92,
50
]
}
],
"sim": {
"strengthen": true,
"settle_frames": 250,
"relax_frames": 50
},
"cam_viewpoint": 2,
"presim_snapshot": "C:/Users/Jeremy/AppData/Local/Temp/tinqs_md_hunter_skirt_v1_presim.png",
"snapshot": "C:/Users/Jeremy/AppData/Local/Temp/tinqs_md_hunter_skirt_v1.png",
"back_snapshot": "C:/Users/Jeremy/AppData/Local/Temp/tinqs_md_hunter_skirt_v1_back.png",
"export_dir": "C:/Users/Jeremy/tinqs/animation/tools/tailor",
"export_basename": "lena_hunter_skirt_v1",
"exports": []
},
"expect": {
"bands": {
"hunter_skirt_v1": {
"top_m": 1.075,
"bottom_m": 0.685,
"tol_m": 0.03,
"bottom_tol_m": 0.04,
"from": "cover"
}
}
}
}
+160
View File
@@ -0,0 +1,160 @@
{
"name": "hunter_skirt_v2",
"_worksheet": {
"block": "aline_skirt",
"waist_w_mm": 498.1,
"hem_w_mm": 722.2,
"panel_h_mm": 390.0,
"tension": 0.91,
"flare": 1.45,
"lines": {
"waist": 0,
"side_r": 1,
"hem": 2,
"side_l": 3
},
"elastic_note": "elastic total_length overridden 448.3 -> 329.0 mm per panel. The block's default (0.9 x waist cut) is derived from HIP circumference, which leaves 28 cm of slack at z=1.075 where Lena measures only 71.5 cm -- v1 slid to the crotch and folded inside out. 329 mm = 0.92 x 71.5 cm / 2, i.e. cinched against the body it actually sits on.",
"reference": "male-clothing-hunter-gpt-v2{,-side,-back}.png",
"measured_targets": {
"waist_top_m": 1.075,
"solid_hem_m": 0.685,
"fray_tips_m": 0.65,
"method": "garment band located by warm-dark mask in all 3 reference views, expressed as fraction of figure height (back 0.598/0.373, side 0.610/0.396), averaged and projected onto Lena's 1.777 m",
"flare_measured": 1.38,
"flare_used": 1.45,
"flare_note": "reference flares 1.38 (widths 98->134 px back, 102->142 px front); raised to 1.45 because 1.38 clears Lena's 109.5 cm hip by only 2.7 cm and a binding hem rides up"
}
},
"md": {
"reset": "new_project",
"avatar_fbx": "C:/Users/Jeremy/tinqs/animation/tools/tailor/avatar/Lena_QuatSkin_Avatar.fbx",
"avatar_scale": 10.0,
"add_arrangement_points": true,
"auto_translate": true,
"zfab": "C:/Users/Public/Documents/MarvelousDesigner/New Assets/Fabric/(Default for Simulation).zfab",
"texture": "tools/tailor/textures/hunter_cloth.png",
"texture_dpi": 130.0,
"panels": [
{
"name": "front",
"dx": 0.0,
"note": "aline_skirt block: waist 498.1 (= 0.91 x 1094.7 hip), hem 722.2, H 390.0",
"points": [
[
112.1,
390.0
],
[
610.2,
390.0
],
[
722.2,
0.0
],
[
0.0,
0.0
]
]
},
{
"name": "back",
"dx": 972.2,
"note": "identical block; lines 0 waist | 1 side_R | 2 hem | 3 side_L",
"points": [
[
112.1,
390.0
],
[
610.2,
390.0
],
[
722.2,
0.0
],
[
0.0,
0.0
]
]
}
],
"seams": [
{
"a": "front",
"a_line": 1,
"b": "back",
"b_line": 1,
"reverse_a": true,
"reverse_b": true
},
{
"a": "front",
"a_line": 3,
"b": "back",
"b_line": 3,
"reverse_a": true,
"reverse_b": true
}
],
"arrangements": [
{
"panel": "front",
"point": "Leg_Skirt_Front",
"offset": [
50,
92,
50
]
},
{
"panel": "back",
"point": "Leg_Skirt_Back",
"offset": [
0,
92,
50
]
}
],
"sim": {
"strengthen": true,
"settle_frames": 250,
"relax_frames": 50,
"elastic": [
{
"panel": "front",
"line": 0,
"total_length": 329.0
},
{
"panel": "back",
"line": 0,
"total_length": 329.0
}
],
"elastic_frames": 80
},
"cam_viewpoint": 2,
"presim_snapshot": "C:/Users/Jeremy/AppData/Local/Temp/tinqs_md_hunter_skirt_v2_presim.png",
"snapshot": "C:/Users/Jeremy/AppData/Local/Temp/tinqs_md_hunter_skirt_v2.png",
"back_snapshot": "C:/Users/Jeremy/AppData/Local/Temp/tinqs_md_hunter_skirt_v2_back.png",
"export_dir": "C:/Users/Jeremy/tinqs/animation/tools/tailor",
"export_basename": "lena_hunter_skirt_v2",
"exports": []
},
"expect": {
"bands": {
"hunter_skirt_v2": {
"top_m": 1.075,
"bottom_m": 0.685,
"tol_m": 0.03,
"bottom_tol_m": 0.04,
"from": "cover"
}
}
}
}
+220
View File
@@ -0,0 +1,220 @@
{
"name": "hunter_skirt_v3",
"_worksheet": {
"block": "aline_skirt (generated), panel outline then hand-shaped",
"reference": "male-clothing-hunter-gpt-v2{,-side,-back}.png",
"measured_targets": {
"waist_top_m": 1.075,
"solid_hem_m": 0.685,
"fray_tips_m": 0.65,
"method": "garment band located by warm-dark mask in all 3 reference views, expressed as fraction of figure height (back 0.598/0.373, side 0.610/0.396), averaged and projected onto Lena's 1.777 m",
"flare_measured": 1.38,
"flare_used": 1.45,
"flare_note": "reference flares 1.38 (widths 98->134 px back, 102->142 px front); raised to 1.45 because 1.38 clears Lena's 109.5 cm hip by only 2.7 cm and a binding hem rides up"
},
"why_shaped": "v1 and v2 both failed in SIMULATION while their pre-sim snapshots were clean, which is what proved the pattern and arrangement were right. The straight-sided trapezoid takes its waist from 0.91 x HIP (99.6 cm), but at z=1.075 Lena measures only 71.5 cm -- 28 cm of surplus. v1 (no elastic) slid to the crotch and folded inside out; v2 cinched it with elastic but only at frame 250, by which time the surplus had already buckled into a flap that flipped through itself. Lena's 42 cm hip-to-waist drop over 13 cm of height is the root cause: no straight-sided cone can both grip a 71.5 cm waist and clear a 109.5 cm hip (it would need a 209 cm hem). A curved side seam solves it the way real tailoring does.",
"ease_profile_cm": {
"waist_z1.075": -3.5,
"z1.010": 5.0,
"hip_z0.945": 6.1
},
"hem_circ_cm": 126.0,
"silhouette_note": "hem/waist ratio is 1.85 vs the reference's measured 1.38. The extra fullness is forced by Lena's hips, not a drafting choice -- the same garment on the male reference body hangs nearly straight."
},
"md": {
"reset": "new_project",
"avatar_fbx": "C:/Users/Jeremy/tinqs/animation/tools/tailor/avatar/Lena_QuatSkin_Avatar.fbx",
"avatar_scale": 10.0,
"add_arrangement_points": true,
"auto_translate": true,
"zfab": "C:/Users/Public/Documents/MarvelousDesigner/New Assets/Fabric/(Default for Simulation).zfab",
"texture": "tools/tailor/textures/hunter_cloth.png",
"texture_dpi": 130.0,
"panels": [
{
"name": "front",
"dx": 0.0,
"note": "SHAPED skirt panel, not the aline_skirt trapezoid. Curved side seam: waist half 340 (grip, -3.5 cm vs body), out to 478 at y=325 (+5.0 cm), 578 at the hip y=260 (+6.1 cm), 630 at the hem. Lines: 0 waist | 1-3 side_R | 4 hem | 5-7 side_L.",
"points": [
[
145.0,
390.0
],
[
485.0,
390.0
],
[
554.0,
325.0
],
[
604.0,
260.0
],
[
630.0,
0.0
],
[
0.0,
0.0
],
[
26.0,
260.0
],
[
76.0,
325.0
]
]
},
{
"name": "back",
"dx": 880.0,
"note": "identical panel offset by dx (NOT mirrored) -> side seams take (True, True)",
"points": [
[
145.0,
390.0
],
[
485.0,
390.0
],
[
554.0,
325.0
],
[
604.0,
260.0
],
[
630.0,
0.0
],
[
0.0,
0.0
],
[
26.0,
260.0
],
[
76.0,
325.0
]
]
}
],
"seams": [
{
"a": "front",
"a_line": 1,
"b": "back",
"b_line": 1,
"reverse_a": true,
"reverse_b": true
},
{
"a": "front",
"a_line": 2,
"b": "back",
"b_line": 2,
"reverse_a": true,
"reverse_b": true
},
{
"a": "front",
"a_line": 3,
"b": "back",
"b_line": 3,
"reverse_a": true,
"reverse_b": true
},
{
"a": "front",
"a_line": 5,
"b": "back",
"b_line": 5,
"reverse_a": true,
"reverse_b": true
},
{
"a": "front",
"a_line": 6,
"b": "back",
"b_line": 6,
"reverse_a": true,
"reverse_b": true
},
{
"a": "front",
"a_line": 7,
"b": "back",
"b_line": 7,
"reverse_a": true,
"reverse_b": true
}
],
"arrangements": [
{
"panel": "front",
"point": "Leg_Skirt_Front",
"offset": [
50,
92,
50
]
},
{
"panel": "back",
"point": "Leg_Skirt_Back",
"offset": [
0,
92,
50
]
}
],
"sim": {
"strengthen": true,
"settle_frames": 250,
"relax_frames": 50,
"elastic": [
{
"panel": "front",
"line": 0,
"total_length": 335.0
},
{
"panel": "back",
"line": 0,
"total_length": 335.0
}
],
"elastic_frames": 80
},
"cam_viewpoint": 2,
"presim_snapshot": "C:/Users/Jeremy/AppData/Local/Temp/tinqs_md_hunter_skirt_v3_presim.png",
"snapshot": "C:/Users/Jeremy/AppData/Local/Temp/tinqs_md_hunter_skirt_v3.png",
"back_snapshot": "C:/Users/Jeremy/AppData/Local/Temp/tinqs_md_hunter_skirt_v3_back.png",
"export_dir": "C:/Users/Jeremy/tinqs/animation/tools/tailor",
"export_basename": "lena_hunter_skirt_v3",
"exports": []
},
"expect": {
"bands": {
"hunter_skirt_v2": {
"top_m": 1.075,
"bottom_m": 0.685,
"tol_m": 0.03,
"bottom_tol_m": 0.04,
"from": "cover"
}
}
}
}
+222
View File
@@ -0,0 +1,222 @@
{
"name": "hunter_skirt_v5",
"_worksheet": {
"block": "aline_skirt (generated), panel outline then hand-shaped",
"reference": "male-clothing-hunter-gpt-v2{,-side,-back}.png",
"measured_targets": {
"waist_top_m": 1.075,
"solid_hem_m": 0.685,
"fray_tips_m": 0.65,
"method": "garment band located by warm-dark mask in all 3 reference views, expressed as fraction of figure height (back 0.598/0.373, side 0.610/0.396), averaged and projected onto Lena's 1.777 m",
"flare_measured": 1.38,
"flare_used": 1.45,
"flare_note": "reference flares 1.38 (widths 98->134 px back, 102->142 px front); raised to 1.45 because 1.38 clears Lena's 109.5 cm hip by only 2.7 cm and a binding hem rides up"
},
"why_shaped": "v1 and v2 both failed in SIMULATION while their pre-sim snapshots were clean, which is what proved the pattern and arrangement were right. The straight-sided trapezoid takes its waist from 0.91 x HIP (99.6 cm), but at z=1.075 Lena measures only 71.5 cm -- 28 cm of surplus. v1 (no elastic) slid to the crotch and folded inside out; v2 cinched it with elastic but only at frame 250, by which time the surplus had already buckled into a flap that flipped through itself. Lena's 42 cm hip-to-waist drop over 13 cm of height is the root cause: no straight-sided cone can both grip a 71.5 cm waist and clear a 109.5 cm hip (it would need a 209 cm hem). A curved side seam solves it the way real tailoring does.",
"ease_profile_cm": {
"waist_z1.075": -3.5,
"z1.010": 5.0,
"hip_z0.945": 6.1
},
"hem_circ_cm": 126.0,
"silhouette_note": "hem/waist ratio is 1.85 vs the reference's measured 1.38. The extra fullness is forced by Lena's hips, not a drafting choice -- the same garment on the male reference body hangs nearly straight.",
"seam_pairing_discovery": "CROSS-PAIRED sides, front RIGHT <-> back LEFT: [(1,7),(2,6),(3,5),(7,1),(6,2),(5,3)] with (False, False). Established by a 4-cell sweep with the failing case as control (scratchpad/seams/seam_sheet.png): same-index (True,True) -- the rule in the marvelous-designer skill and what blocks.py emits -- COLLAPSES this panel, and so does same-index (False,False). Both cross-paired variants hold. The skill's rule ('identical panels need (True,True) on same-index sides') is derived from a trapezoid whose side is a SINGLE edge; reversal emulates mirroring there. With a 3-segment shaped side seam it does not, and the unrolled-cylinder truth takes over: front spans 0-180 deg and back 180-360, so front's right edge meets back's LEFT.",
"failure_history": "v1 no elastic: slid to the crotch, folded inside out. v2 elastic at frame 250: too late, surplus had already buckled. v3 shaped panel but negative waist clearance: solver ejected the penetrating cloth, blew up by frame 20 in BOTH stiff and soft (that symmetry is what ruled out fabric stiffness). v4 clearances fixed but still collapsed -> isolated to seam pairing. Every failure had a CLEAN pre-sim snapshot, which is what kept pointing away from the pattern and at the sim."
},
"md": {
"reset": "new_project",
"avatar_fbx": "C:/Users/Jeremy/tinqs/animation/tools/tailor/avatar/Lena_QuatSkin_Avatar.fbx",
"avatar_scale": 10.0,
"add_arrangement_points": true,
"auto_translate": true,
"zfab": "C:/Users/Public/Documents/MarvelousDesigner/New Assets/Fabric/(Default for Simulation).zfab",
"texture": "tools/tailor/textures/hunter_cloth.png",
"texture_dpi": 130.0,
"panels": [
{
"name": "front",
"dx": 0.0,
"note": "SHAPED skirt panel with POSITIVE clearance everywhere above the hip. Waist half 370 (+2.5 cm ease -- v4's -3.5 cm started the cloth INSIDE the body and MD ejected it), 468 at y=325 (+3.0), 575 at the hip y=260 (+5.5), 625 at the hem. Lines: 0 waist | 1-3 side_R | 4 hem | 5-7 side_L.",
"points": [
[
127.5,
390.0
],
[
497.5,
390.0
],
[
546.5,
325.0
],
[
600.0,
260.0
],
[
625.0,
0.0
],
[
0.0,
0.0
],
[
25.0,
260.0
],
[
78.5,
325.0
]
]
},
{
"name": "back",
"dx": 875.0,
"note": "identical panel offset by dx (NOT mirrored) -> sides must CROSS-pair",
"points": [
[
127.5,
390.0
],
[
497.5,
390.0
],
[
546.5,
325.0
],
[
600.0,
260.0
],
[
625.0,
0.0
],
[
0.0,
0.0
],
[
25.0,
260.0
],
[
78.5,
325.0
]
]
}
],
"seams": [
{
"a": "front",
"a_line": 1,
"b": "back",
"b_line": 7,
"reverse_a": false,
"reverse_b": false
},
{
"a": "front",
"a_line": 2,
"b": "back",
"b_line": 6,
"reverse_a": false,
"reverse_b": false
},
{
"a": "front",
"a_line": 3,
"b": "back",
"b_line": 5,
"reverse_a": false,
"reverse_b": false
},
{
"a": "front",
"a_line": 7,
"b": "back",
"b_line": 1,
"reverse_a": false,
"reverse_b": false
},
{
"a": "front",
"a_line": 6,
"b": "back",
"b_line": 2,
"reverse_a": false,
"reverse_b": false
},
{
"a": "front",
"a_line": 5,
"b": "back",
"b_line": 3,
"reverse_a": false,
"reverse_b": false
}
],
"arrangements": [
{
"panel": "front",
"point": "Leg_Skirt_Front",
"offset": [
50,
92,
50
]
},
{
"panel": "back",
"point": "Leg_Skirt_Back",
"offset": [
0,
92,
50
]
}
],
"sim": {
"strengthen": true,
"settle_frames": 150,
"relax_frames": 50,
"elastic": [
{
"panel": "front",
"line": 0,
"total_length": 350.0
},
{
"panel": "back",
"line": 0,
"total_length": 350.0
}
],
"elastic_frames": 60
},
"cam_viewpoint": 2,
"presim_snapshot": "C:/Users/Jeremy/AppData/Local/Temp/tinqs_md_hunter_skirt_v5_presim.png",
"snapshot": "C:/Users/Jeremy/AppData/Local/Temp/tinqs_md_hunter_skirt_v5.png",
"back_snapshot": "C:/Users/Jeremy/AppData/Local/Temp/tinqs_md_hunter_skirt_v5_back.png",
"export_dir": "C:/Users/Jeremy/tinqs/animation/tools/tailor",
"export_basename": "lena_hunter_skirt_v5",
"exports": []
},
"expect": {
"bands": {
"hunter_skirt_v2": {
"top_m": 1.075,
"bottom_m": 0.685,
"tol_m": 0.03,
"bottom_tol_m": 0.04,
"from": "cover"
}
}
}
}
+340
View File
@@ -0,0 +1,340 @@
{
"name": "hunter_skirt_v8",
"_worksheet": {
"block": "aline_skirt (generated), panel outline then hand-shaped",
"reference": "male-clothing-hunter-gpt-v2{,-side,-back}.png",
"measured_targets": {
"waist_top_m": 1.075,
"solid_hem_m": 0.685,
"fray_tips_m": 0.65,
"method": "garment band located by warm-dark mask in all 3 reference views, expressed as fraction of figure height (back 0.598/0.373, side 0.610/0.396), averaged and projected onto Lena's 1.777 m",
"flare_measured": 1.38,
"flare_used": 1.45,
"flare_note": "reference flares 1.38 (widths 98->134 px back, 102->142 px front); raised to 1.45 because 1.38 clears Lena's 109.5 cm hip by only 2.7 cm and a binding hem rides up"
},
"why_shaped": "v1 and v2 both failed in SIMULATION while their pre-sim snapshots were clean, which is what proved the pattern and arrangement were right. The straight-sided trapezoid takes its waist from 0.91 x HIP (99.6 cm), but at z=1.075 Lena measures only 71.5 cm -- 28 cm of surplus. v1 (no elastic) slid to the crotch and folded inside out; v2 cinched it with elastic but only at frame 250, by which time the surplus had already buckled into a flap that flipped through itself. Lena's 42 cm hip-to-waist drop over 13 cm of height is the root cause: no straight-sided cone can both grip a 71.5 cm waist and clear a 109.5 cm hip (it would need a 209 cm hem). A curved side seam solves it the way real tailoring does.",
"ease_profile_cm": {
"waist_z1.075": -3.5,
"z1.010": 5.0,
"hip_z0.945": 6.1
},
"hem_circ_cm": 126.0,
"silhouette_note": "hem/waist ratio is 1.85 vs the reference's measured 1.38. The extra fullness is forced by Lena's hips, not a drafting choice -- the same garment on the male reference body hangs nearly straight.",
"seam_pairing_discovery": "CROSS-PAIRED sides, front RIGHT <-> back LEFT: [(1,7),(2,6),(3,5),(7,1),(6,2),(5,3)] with (False, False). Established by a 4-cell sweep with the failing case as control (scratchpad/seams/seam_sheet.png): same-index (True,True) -- the rule in the marvelous-designer skill and what blocks.py emits -- COLLAPSES this panel, and so does same-index (False,False). Both cross-paired variants hold. The skill's rule ('identical panels need (True,True) on same-index sides') is derived from a trapezoid whose side is a SINGLE edge; reversal emulates mirroring there. With a 3-segment shaped side seam it does not, and the unrolled-cylinder truth takes over: front spans 0-180 deg and back 180-360, so front's right edge meets back's LEFT.",
"failure_history": "v1 no elastic: slid to the crotch, folded inside out. v2 elastic at frame 250: too late, surplus had already buckled. v3 shaped panel but negative waist clearance: solver ejected the penetrating cloth, blew up by frame 20 in BOTH stiff and soft (that symmetry is what ruled out fabric stiffness). v4 clearances fixed but still collapsed -> isolated to seam pairing. Every failure had a CLEAN pre-sim snapshot, which is what kept pointing away from the pattern and at the sim.",
"seam_pairing_SOLVED": "CROSS-paired sides with EXACTLY ONE SIDE REVERSED: reverse_a=False, reverse_b=True on [(1,11),(2,10),(3,9),(4,8),(5,7),(11,1),(10,2),(9,3),(8,4),(7,5)]. Two things had to be right and they are independent. (1) WHICH edges pair: the panels are identical, not mirrored, so front spans 0-180 deg and back 180-360 -- front's RIGHT edge meets back's LEFT. Same-index pairing (what blocks.py emits, and what the skill states for identical panels) collapses this garment in BOTH parities. (2) WHICH DIRECTION: front's right segments run downward (waist->hem), back's left segments run upward, so (False,False) sews every segment's top to its partner's bottom and (True,True) flips both and is EQUIVALENT -- that is exactly why sweep cells C and D were indistinguishable. Reversing one side makes waist-end meet waist-end. Verified stable frames 0-260 with no roll-up, twist or inversion.",
"strengthen_note": "strengthen=False. On this garment SetPatternStrengthen appears to INFLATE rather than merely stiffen: the strengthened cell balloons, its hem curls into a roll and it loses ~24 cm of length, converging to that shape by frame 40 and holding it. The soft cell reads correctly. The skill's 'strengthen through the whole settle' rule is for garments that must wrap on from a loose arrangement; this one is drafted to fit and materialises already in place, so it does not need it.",
"elastic_note": "No elastic. The waist is 72 cm against a 68.8 cm body (+3.2 cm) and 48.5 cm smaller than the widest body point (120.5 cm at the thighs), so it physically cannot slide down -- tension holds it, which is the doctrine's actual intent. Elastic was tried and was not the fix for any failure mode.",
"qc_measured": {
"command": "python tools/tailor/qc_placement.py <front.png> --json --colors 92,69,54 110,82,64 82,62,48 --tol 20",
"mask_note": "The garment renders at b/r ~0.59 and skin at ~0.42, which is what separates them; a single colour at tol 46 catches skin and reports phantom bands up at the shoulders.",
"band_top_m": 1.062,
"band_bottom_m": 0.669,
"band_span_m": 0.393,
"band_px": 12435,
"verdict": "PASS. top -13 mm vs target 1.075 (tol +/-30), hem -16 mm vs 0.685 (tol +/-40). Measured span 0.393 m against a 0.390 m drafted panel, so the cloth is hanging at full length, not gathered."
},
"not_yet_built": [
"Frayed/ragged hem: the reference hem is torn, ~3.5 cm of teeth below the solid hem at 0.685. Must be GEOMETRY (outline teeth, piupiu technique) because the clothing pipeline carries no alpha channel.",
"Diagonal wrap overlap across the front-left of the reference.",
"Back waist tie: knot plus two short hanging ends."
]
},
"md": {
"reset": "new_project",
"avatar_fbx": "C:/Users/Jeremy/tinqs/animation/tools/tailor/avatar/Lena_QuatSkin_Avatar.fbx",
"avatar_scale": 10.0,
"add_arrangement_points": true,
"auto_translate": true,
"zfab": "C:/Users/Public/Documents/MarvelousDesigner/New Assets/Fabric/(Default for Simulation).zfab",
"texture": "tools/tailor/textures/hunter_cloth.png",
"texture_dpi": 130.0,
"panels": [
{
"name": "front",
"dx": 0.0,
"note": "Width profile drafted against the MEASURED BODY HULL (both legs), not the measurement card: the card's body_circ_at CLAMPS to hip_circ below the hip, hiding that Lena's two thighs together are 120.5 cm at z=0.82 vs 105.6 cm at the hip. Levels (y, half-mm): 390/360, 325/465, 260.5/560, 215/615, 135/645, 0/665. >=3.2 cm ease everywhere. Lines: 0 waist | 1-5 side_R (top->bottom) | 6 hem | 7-11 side_L (bottom->top).",
"points": [
[
152.5,
390.0
],
[
512.5,
390.0
],
[
565.0,
325.0
],
[
612.5,
260.5
],
[
640.0,
215.0
],
[
655.0,
135.0
],
[
665.0,
0.0
],
[
0.0,
0.0
],
[
10.0,
135.0
],
[
25.0,
215.0
],
[
52.5,
260.5
],
[
100.0,
325.0
]
]
},
{
"name": "back",
"dx": 915.0,
"note": "identical panel offset by dx (NOT mirrored) -> sides CROSS-pair, ONE reversed",
"points": [
[
152.5,
390.0
],
[
512.5,
390.0
],
[
565.0,
325.0
],
[
612.5,
260.5
],
[
640.0,
215.0
],
[
655.0,
135.0
],
[
665.0,
0.0
],
[
0.0,
0.0
],
[
10.0,
135.0
],
[
25.0,
215.0
],
[
52.5,
260.5
],
[
100.0,
325.0
]
]
}
],
"seams": [
{
"a": "front",
"a_line": 1,
"b": "back",
"b_line": 11,
"reverse_a": false,
"reverse_b": true
},
{
"a": "front",
"a_line": 2,
"b": "back",
"b_line": 10,
"reverse_a": false,
"reverse_b": true
},
{
"a": "front",
"a_line": 3,
"b": "back",
"b_line": 9,
"reverse_a": false,
"reverse_b": true
},
{
"a": "front",
"a_line": 4,
"b": "back",
"b_line": 8,
"reverse_a": false,
"reverse_b": true
},
{
"a": "front",
"a_line": 5,
"b": "back",
"b_line": 7,
"reverse_a": false,
"reverse_b": true
},
{
"a": "front",
"a_line": 11,
"b": "back",
"b_line": 1,
"reverse_a": false,
"reverse_b": true
},
{
"a": "front",
"a_line": 10,
"b": "back",
"b_line": 2,
"reverse_a": false,
"reverse_b": true
},
{
"a": "front",
"a_line": 9,
"b": "back",
"b_line": 3,
"reverse_a": false,
"reverse_b": true
},
{
"a": "front",
"a_line": 8,
"b": "back",
"b_line": 4,
"reverse_a": false,
"reverse_b": true
},
{
"a": "front",
"a_line": 7,
"b": "back",
"b_line": 5,
"reverse_a": false,
"reverse_b": true
}
],
"arrangements": [
{
"panel": "front",
"point": "Leg_Skirt_Front",
"offset": [
50,
92,
50
]
},
{
"panel": "back",
"point": "Leg_Skirt_Back",
"offset": [
0,
92,
50
]
}
],
"sim": {
"strengthen": false,
"settle_frames": 200,
"relax_frames": 60
},
"cam_viewpoint": 2,
"presim_snapshot": "C:/Users/Jeremy/AppData/Local/Temp/tinqs_md_hunter_skirt_v8_presim.png",
"snapshot": "C:/Users/Jeremy/AppData/Local/Temp/tinqs_md_hunter_skirt_v8.png",
"back_snapshot": "C:/Users/Jeremy/AppData/Local/Temp/tinqs_md_hunter_skirt_v8_back.png",
"export_dir": "C:/Users/Jeremy/tinqs/animation/tools/tailor",
"export_basename": "lena_hunter_skirt_v8",
"exports": [
"zprj",
"fbx",
"obj",
"zpac"
]
},
"expect": {
"bands": {
"hunter_skirt_v8": {
"top_m": 1.075,
"bottom_m": 0.685,
"tol_m": 0.03,
"bottom_tol_m": 0.04,
"from": "cover"
}
}
},
"source": "C:/Users/Jeremy/tinqs/animation/tools/tailor/lena_hunter_skirt_v8_garment.fbx",
"body": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/derived-bodies/Ariki_Female_QuatSkin.glb",
"weld_threshold": 0.0006,
"shell_mm": 4,
"min_island_verts": 40,
"align": {
"top_bone": "spine_01",
"scale_xy": 0.1,
"scale_z": 0.1,
"z_nudge": 0.0,
"xy_nudge": [
0.0,
0.0
]
},
"parts": {
"HunterSkirt": {
"slot": "Legs",
"islands": [
0
],
"tris": 4000,
"planar_deg": 5,
"fit": {
"target": "BODY_SHELL",
"mask": "full",
"offset_mm": 3,
"wrap_mode": "OUTSIDE",
"hem_mm": 14
},
"weights": "dress",
"color": [
0.42,
0.31,
0.23
],
"texture": "../tools/tailor/textures/hunter_cloth.png"
}
},
"export": {
"out_dir": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/outfits/hunter",
"gender": "Female",
"set": "Hunter",
"note": "Hunter wrap skirt (brown coarse cloth) - Legs slot"
}
}
+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()