OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / tools / compose_trim_sheet.py

168 lines · python ref:
#!/usr/bin/env python3
"""Compose a TRIM SHEET from tiling PBR materials we already ship (#94 P2,
docs/hotspot-trim-design.md §2B). User 2026-10-03: "can we create the trim sheet by
compositing multiple textures?" — research: yes, it is the standard Substance-Designer /
Ultimate-Trim recipe (one layout -> a mask per strip, each strip filled with a tiling
material); what makes rows read as TRIM rather than striped wallpaper is the per-strip
45° bevel in the normal map, AO in the bevel, matching texel density across rows and
gutters against mip bleed.

Layout spec (JSON):
  {"name": "industrial", "size": 1024, "px_per_m": 512, "gutter_px": 4, "seed": 1,
   "strips": [{"tag": "concrete", "h": 256, "material": "ambientcg/Concrete034",
               "bevel_px": 8}, ...]}
Strips stack top to bottom, full width, each repeating along U. Heights + gutters must fit.

Per strip: resample the material to px_per_m with its scale SNAPPED so a whole number of
repeats spans the width (keeps U seamless), tile, crop V at a seeded offset; bevel height
ramps 0 -> 1 over bevel_px at the top and bottom edges (45°: rise = run); normal = the
bevel's normal blended over the material's (whiteout); AO *= bevel cavity; height =
bevel + scaled material relief. Gutters replicate each strip's edge rows.

Writes <out>/<name>/{albedo.jpg, normal.png, orm.jpg, height.png} and prints the pack.json
entry (rects exact by construction: x, y, w, h, tile_u, inset = bevel width, tag).
  python3 tools/compose_trim_sheet.py layout.json --packs texture_packs --out texture_packs/composed
"""
import argparse
import io
import json
import os
import sys

import numpy as np
from PIL import Image

AO_BEVEL = 0.55        # AO at the foot of a bevel (0 = black, 1 = none) — design choice
BASE_RELIEF = 0.35     # share of the strip height range given to the material's own relief
RECT_INSET_PX = 1      # rect inset against mip bleed (the gutters do the rest)


def _load_tex(packs, tex_id):
    pack, name = tex_id.split("/", 1)
    meta = json.load(open(os.path.join(packs, pack, "pack.json")))
    t = next(x for x in meta["textures"] if x["name"] == name)
    base = os.path.join(packs, pack)
    rd = lambda k, mode: Image.open(os.path.join(base, t[k])).convert(mode) if t.get(k) else None
    return t, rd("albedo", "RGB"), rd("normal", "RGB"), rd("orm", "RGB"), \
        (Image.open(os.path.join(base, t["height"])) if t.get("height") else None)


def _fill(img, mode, width, height, tile_px_u, tile_px_v, v_off):
    """img resampled to one repeat = tile_px_u x tile_px_v, tiled over width x height, V offset."""
    if img is None:
        return None
    rep = img.convert(mode).resize((tile_px_u, tile_px_v), Image.LANCZOS)
    a = np.asarray(rep, np.float64)
    reps_u = width // tile_px_u
    reps_v = (height + v_off) // tile_px_v + 2
    tiled = np.tile(a, (reps_v, reps_u, 1) if a.ndim == 3 else (reps_v, reps_u))
    return tiled[v_off:v_off + height, :width]


def _bevel_profile(h, bevel):
    """0..1 height across a strip of h rows: linear ramps of `bevel` rows at both edges."""
    y = np.arange(h, dtype=np.float64)
    d = np.minimum(y + 0.5, h - y - 0.5)          # distance to the nearer edge, in rows
    return np.clip(d / max(bevel, 1e-6), 0.0, 1.0)


def _height_to_normal_dx(hgt_m, px_per_m):
    """DirectX tangent normal (+G = image down) from a height field in metres."""
    gu = (np.roll(hgt_m, -1, 1) - np.roll(hgt_m, 1, 1)) * px_per_m / 2.0
    gv = (np.roll(hgt_m, -1, 0) - np.roll(hgt_m, 1, 0)) * px_per_m / 2.0
    n = np.stack([-gu, -gv, np.ones_like(hgt_m)], -1)
    return n / np.linalg.norm(n, axis=-1, keepdims=True)


def compose(spec, packs):
    size = int(spec["size"])
    ppm = float(spec["px_per_m"])
    gutter = int(spec.get("gutter_px", 4))
    rng = np.random.default_rng(int(spec.get("seed", 1)))
    alb = np.zeros((size, size, 3))
    nrm = np.zeros((size, size, 3))
    nrm[..., 2] = 1.0
    orm = np.zeros((size, size, 3))
    hgt = np.zeros((size, size))
    rects = []
    y = 0
    for s in spec["strips"]:
        h = int(s["h"])
        bevel = int(s.get("bevel_px", 8))
        if y + h > size:
            sys.exit(f"strip {s['tag']} does not fit: y {y} + h {h} > {size}")
        t, a_img, n_img, o_img, h_img = _load_tex(packs, s["material"])
        # SNAP the repeat so a whole number spans the width: U stays seamless.
        reps = max(1, round(size / (t["tile_u_m"] * ppm)))
        tu = size // reps
        tv = max(1, round(tu * t["tile_v_m"] / t["tile_u_m"]))
        voff = int(rng.integers(0, tv))
        a = _fill(a_img, "RGB", size, h, tu, tv, voff)
        n = _fill(n_img, "RGB", size, h, tu, tv, voff) if n_img else None
        o = _fill(o_img, "RGB", size, h, tu, tv, voff) if o_img else None
        bh = _fill(h_img, "F", size, h, tu, tv, voff) if h_img else None
        prof = _bevel_profile(h, bevel)[:, None] * np.ones((1, size))
        # 45° bevel: the profile rises bevel_px / ppm metres over bevel_px pixels.
        bevel_m = prof * (bevel / ppm)
        nb = _height_to_normal_dx(bevel_m, ppm)
        nm = (n / 127.5 - 1.0) if n is not None else np.dstack([np.zeros((h, size)), np.zeros((h, size)), np.ones((h, size))])
        # Whiteout blend: the material's detail rides on the bevel's slope.
        nn = np.dstack([nm[..., 0] + nb[..., 0], nm[..., 1] + nb[..., 1], nm[..., 2] * nb[..., 2]])
        nn /= np.linalg.norm(nn, axis=-1, keepdims=True)
        ormv = o if o is not None else np.dstack([np.full((h, size), 255.0), np.full((h, size), 204.0), np.zeros((h, size))])
        ormv = ormv.copy()
        ormv[..., 0] *= AO_BEVEL + (1.0 - AO_BEVEL) * prof
        # COLOUR UNIFICATION (research: clashing brightness / saturation between rows is
        # what makes a composed sheet look cheap). spec "unify": {"brightness": 0..1 target
        # mean luminance, "saturation": factor}; a strip can opt out with "keep_colour".
        u = spec.get("unify")
        if u and not s.get("keep_colour"):
            lum = a @ np.array([0.2126, 0.7152, 0.0722])
            if "brightness" in u:
                a = a * min(4.0, (float(u["brightness"]) * 255.0) / max(lum.mean(), 1e-6))
                lum = a @ np.array([0.2126, 0.7152, 0.0722])
            if "saturation" in u:
                a = lum[..., None] + (a - lum[..., None]) * float(u["saturation"])
            a = np.clip(a, 0, 255)
        base = bh / max(bh.max(), 1e-6) if bh is not None else np.full((h, size), 0.5)
        hh = (1.0 - BASE_RELIEF) * prof + BASE_RELIEF * base * prof
        alb[y:y + h], nrm[y:y + h], orm[y:y + h], hgt[y:y + h] = a, nn, ormv, hh
        # INSET = a small mip-bleed margin, NOT the bevel: the bevel is the point of a trim —
        # a face fitted to this rect must include it so plain geometry reads bevelled (Valve
        # hotspots). Insetting by the bevel cut it off (seen in _probe_hotspot_shot).
        rects.append({"x": 0, "y": y, "w": size, "h": h, "tile_u": True, "inset": RECT_INSET_PX,
                      "bevel": bevel, "tag": s["tag"], "material": s["material"]})
        # Gutter below: replicate the strip's last row (edge padding, against mip bleed).
        g0, g1 = y + h, min(size, y + h + gutter)
        for arr in (alb, nrm, orm, hgt):
            arr[g0:g1] = arr[y + h - 1]
        y = g1
    return alb, nrm, orm, hgt, rects


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("layout")
    ap.add_argument("--packs", default="texture_packs")
    ap.add_argument("--out", default="texture_packs/composed")
    a = ap.parse_args()
    spec = json.load(open(a.layout))
    alb, nrm, orm, hgt, rects = compose(spec, a.packs)
    d = os.path.join(a.out, spec["name"])
    os.makedirs(d, exist_ok=True)
    Image.fromarray(np.clip(alb, 0, 255).astype(np.uint8)).save(os.path.join(d, "albedo.jpg"), quality=92)
    Image.fromarray(np.clip(nrm * 127.5 + 127.5 + 0.5, 0, 255).astype(np.uint8)).save(os.path.join(d, "normal.png"))
    Image.fromarray(np.clip(orm, 0, 255).astype(np.uint8)).save(os.path.join(d, "orm.jpg"), quality=92)
    Image.fromarray((np.clip(hgt, 0, 1) * 65535 + 0.5).astype(np.uint16)).save(os.path.join(d, "height.png"))
    size, ppm = int(spec["size"]), float(spec["px_per_m"])
    entry = {"name": spec["name"], "albedo": f"{spec['name']}/albedo.jpg", "normal": f"{spec['name']}/normal.png",
             "orm": f"{spec['name']}/orm.jpg", "height": f"{spec['name']}/height.png", "size": [size, size],
             "tile_u_m": round(size / ppm, 4), "tile_v_m": round(size / ppm, 4), "kind": "trim",
             "rects": rects, "height_m": round(max(s.get("bevel_px", 8) for s in spec["strips"]) / ppm, 5),
             "composed_from": sorted({s["material"] for s in spec["strips"]})}
    print(json.dumps(entry, indent=1))


if __name__ == "__main__":
    main()