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