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