Files
animation/tools/tailor/textures/make_hunter_cloth.py
T
jeremy c12c4f156c 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>
2026-08-12 18:53:17 -07:00

145 lines
5.6 KiB
Python

#!/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()