#!/usr/bin/env python3 """ make_kilt_cloth.py -- beige woven cloth with black triangle border band for the male feather-cape kilt (mako_kilt_v1). python tools/tailor/textures/make_kilt_cloth.py Derived from the reference renders `male-clothing-feather-cape-gpt-piece-kilt{,-front,-back}.png`: a light beige coarse plain weave (harakeke/tapa in character) with a black triangle border band above a short fringe at the hem. UNLIKE hunter_cloth THIS TILE IS POSITIONED, NOT GENERIC. It spans the kilt panel's full 390 mm height exactly once (dpi = SIZE / (390 / 25.4)), so the band and fringe land at fixed garment heights: garment y (mm, hem=0) zone 0-30 fringe strands (drawn, not geometry -- v1) 30-90 black band, beige triangles (6 cm, per flat pattern) 90-390 plain beige weave Horizontally the same 390 mm tiles ~1.7x across the 650 mm panel; the band is horizontal and the triangles repeat 8/tile, so the wrap is seamless. IF THE BAND RENDERS AT THE WAIST INSTEAD OF THE HEM, MD anchored the texture the other way up -- regenerate with FLIP=True rather than touching the pattern. Same doctrine as make_hunter_cloth.py: albedo baked in (pipeline has no alpha, texture replaces base color), warm ramp r>g>b, deterministic LCG noise. """ import os from PIL import Image HERE = os.path.dirname(os.path.abspath(__file__)) OUT = os.path.join(HERE, "kilt_cloth.png") SIZE = 1024 # px, square CLOTH_MM = 390.0 # spans the kilt panel height exactly once DPI = SIZE / (CLOTH_MM / 25.4) FLIP = False # set True if MD anchors the texture upside-down BASE = (206, 188, 152) # light beige albedo (reference lit mean ~(196,180,148)) BAND = (44, 38, 30) # near-black band, warm THREAD_PITCH = 8 # px per thread; 1024px/390mm -> ~3 mm threads OVER_LIFT = 12 UNDER_DROP = 16 ROUND_SHADE = 10 SLUB_RANGE = 8 MOTTLE = 12 MOTTLE_CELL = 64 # zone boundaries in garment mm (hem = 0) # v2: fringe is CUT GEOMETRY now (comb teeth, 0-30 mm) -- the texture paints the # teeth as strand-beige instead of drawing fake strands. Band triangles refined # 8 -> 12 per tile (32.5 mm) to match the reference's tighter zigzag. FRINGE_TOP_MM = 30.0 BAND_TOP_MM = 90.0 TRIANGLES_PER_TILE = 12 def lcg(seed): state = seed while True: state = (1103515245 * state + 12345) % (2 ** 31) yield state / float(2 ** 31) def thread_tones(n, seed): g = lcg(seed) return [int((next(g) * 2.0 - 1.0) * SLUB_RANGE) for _ in range(n)] def value_noise(cells, seed): 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 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 mm_from_row(y): """Garment height in mm for an image row. ANCHOR DISCOVERED BY PROBE (2026-08-18, striped texture on the kilt panels, scratchpad probe_front/back.png): MD maps pattern y=0 (hem) to image v=0.5 and WRAPS -- v = (0.5 + (390 - y)/390) mod 1. Both panels map identically. Inverted here: row -> garment mm.""" v = (y + 0.5) / SIZE return CLOTH_MM - CLOTH_MM * ((v - 0.5) % 1.0) def in_triangle(x, y): """Beige zigzag triangles inside the band: alternating up/down, classic taniko-style row. Returns True where the pixel is beige (triangle), False where it stays black band.""" band_lo = FRINGE_TOP_MM + 4.0 # thinner black margins (v2, per reference) band_hi = BAND_TOP_MM - 4.0 mm = mm_from_row(y) if not (band_lo <= mm <= band_hi): return False v = (mm - band_lo) / (band_hi - band_lo) # 0 bottom .. 1 top tri_w = SIZE / float(TRIANGLES_PER_TILE) u = (x % tri_w) / tri_w # 0..1 across one triangle k = int(x / tri_w) % 2 if k == 0: # upward triangle: wide at bottom half = (1.0 - v) * 0.5 else: # downward: wide at top half = v * 0.5 return abs(u - 0.5) < half * 0.88 # slim gutters (v2) def fringe_strand(x, y, gap_noise): """Fringe zone: vertical beige strands over shadow, ragged tips.""" mm = mm_from_row(y) strand_w = 6 u = x % strand_w # per-strand ragged length: strands end 0..10 mm above the tile bottom tip = gap_noise[(x // strand_w) % len(gap_noise)] * 10.0 if mm < tip: return None # past the tip: shadow return u >= 1 # 1px gap between strands def main(): n_threads = SIZE // THREAD_PITCH warp = thread_tones(n_threads, seed=20260818) weft = thread_tones(n_threads, seed=31337) mottle = value_noise(SIZE // MOTTLE_CELL, seed=777) g = lcg(424242) gap_noise = [next(g) for _ in range(64)] img = Image.new("RGB", (SIZE, SIZE)) px = img.load() for y in range(SIZE): j = (y // THREAD_PITCH) % n_threads vy = ((y % THREAD_PITCH) / (THREAD_PITCH - 1.0)) * 2.0 - 1.0 mm = mm_from_row(y) for x in range(SIZE): i = (x // THREAD_PITCH) % n_threads vx = ((x % THREAD_PITCH) / (THREAD_PITCH - 1.0)) * 2.0 - 1.0 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 if mm < FRINGE_TOP_MM: # v2: teeth are cut geometry; paint them as strand fibre -- # duller beige with vertical grain, no fake shadow gaps. u = x % 6 base = (192, 174, 140) if u >= 1 else (160, 143, 112) elif mm < BAND_TOP_MM: base = BASE if in_triangle(x, y) else BAND else: base = BASE r = base[0] + d gg = base[1] + int(d * 0.82) b = base[2] + int(d * 0.66) px[x, y] = (max(0, min(255, r)), max(0, min(255, gg)), max(0, min(255, b))) if FLIP: img = img.transpose(Image.FLIP_TOP_BOTTOM) img.save(OUT, dpi=(DPI, DPI)) print("wrote %s (%dx%d, dpi %.2f -> %.0f mm of cloth, band %d-%d mm, fringe 0-%d mm)" % (OUT, SIZE, SIZE, DPI, CLOTH_MM, FRINGE_TOP_MM, BAND_TOP_MM, FRINGE_TOP_MM)) if __name__ == "__main__": main()