c12c4f156c
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>
145 lines
5.6 KiB
Python
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()
|