refactor(tools): group the sixty scripts by what they talk to

One directory with sixty scripts stopped being navigable, and the grouping is also the sandbox boundary:

  Tools/unreal/    commandlets and the two map builders, with ue_common.py and ue_placement.py as the
                   shared imports (they need the engine)
  Tools/blender/   builders, exporters, the FBX preset, the MPFB character pipeline, blender_screenshots
                   (they need the Blender Flatpak)
  Tools/review/    the visual loop: render_screenshots.sh, judge_screenshot.py, hero_compare.py,
                   build_comparison_md.py, e2e_asset_check.py, hero_iterate.sh, visual_loop.py,
                   visual_targets.json
  Tools/gen/       image and texture generation: the ComfyUI driver, deterministic texture writers,
                   reference fetching
  Tools/input/     OS-level input injection
  root             _common.py, check-lfs.sh, addon_compat_probe.py, sibling_asset_audit.py, tests/

Every moved script now resolves the repository root as ../.. and puts the tools root plus its siblings on
sys.path, so 'import ue_common' still works from unreal/. The test suite bootstrap does the same for the five
folders. render_screenshots.sh takes the folder as a third argument to run_cmdlet, and the e2e driver,
hero_iterate.sh and the comparison generator were repointed.

51 README rows now link to their new location, the README gains a Layout section naming each folder's
sandbox, and the architecture notes and Blender README follow the moves.

Verified: 108 tests pass, prettier is clean, judge_screenshot.py and hero_compare.py run from their new
homes, and the e2e run was aborted mid-way (it had been rebuilding an empty DemoMap for 15 minutes) and is
being rerun against the new layout.
This commit is contained in:
zwitschi committed 2026-11-10 12:40:30 +01:00
1 parent f681d83736
commit 4d27109668
168 files changed
+799 -407

No files matched your search

+226
View File
@@ -0,0 +1,226 @@
#!/usr/bin/env python3
"""Generate the facade texture maps the demo materials sample.
The materials started as procedural graphs only (noise + coursing), which reads
as tinted stone but has no grain. These maps add the grain: an ashlar stone
course pattern, a rendered stucco tooth, asphalt aggregate and concrete pores,
each as base colour plus a roughness map so the specular breaks up.
Standard library only — the PNG encoder is the same pure-zlib writer used for the
MPFB skin, so this runs on the host without numpy or Pillow.
python3 Tools/gen/generate_facade_textures.py # write to /tmp/gigline/textures
python3 Tools/gen/generate_facade_textures.py --size 1024
python3 Tools/gen/generate_facade_textures.py --out Art/blender/textures
Output names match the Unreal import destination `/Game/Hollywood/Textures`:
T_StoneAshlar_BC.png T_StoneAshlar_R.png
T_Stucco_BC.png T_Stucco_R.png
T_Asphalt_BC.png T_Asphalt_R.png
T_Concrete_BC.png T_Concrete_R.png
Determinism: every pattern is a deterministic hash of the pixel coordinate, so a
re-run reproduces the same files byte for byte (no RNG state, no seam drift).
"""
from __future__ import annotations
import argparse
import math
import os
import struct
import zlib
# --- PNG -------------------------------------------------------------------
def write_png(path, width, height, rgb_rows):
"""Write 8-bit RGB rows (bytes) to a PNG. `rgb_rows` is one bytes per row."""
raw = bytearray()
for y in range(height):
raw.append(0) # filter type 0
raw += rgb_rows[y]
def chunk(tag, data):
c = struct.pack(">I", len(data)) + tag + data
c += struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
return c
ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) # colour type 2 = RGB
png = b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr)
png += chunk(b"IDAT", zlib.compress(bytes(raw), 6)) + chunk(b"IEND", b"")
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
with open(path, "wb") as handle:
handle.write(png)
return path
# --- deterministic value noise --------------------------------------------
def _hash2(x, y, seed=0):
"""Deterministic 0..1 hash of an integer cell. No RNG, fully reproducible."""
n = (x * 374761393 + y * 668265263 + seed * 1442695040888963407) & 0xFFFFFFFF
n = (n ^ (n >> 13)) * 1274126177 & 0xFFFFFFFF
return ((n ^ (n >> 16)) & 0xFFFF) / 65535.0
def value_noise(x, y, seed=0):
"""Smooth bilinear value noise at continuous (x, y) in cell units."""
xi, yi = math.floor(x), math.floor(y)
fx, fy = x - xi, y - yi
# smoothstep weights so cells blend without visible lattice lines
sx = fx * fx * (3.0 - 2.0 * fx)
sy = fy * fy * (3.0 - 2.0 * fy)
n00 = _hash2(xi, yi, seed)
n10 = _hash2(xi + 1, yi, seed)
n01 = _hash2(xi, yi + 1, seed)
n11 = _hash2(xi + 1, yi + 1, seed)
return (n00 * (1 - sx) + n10 * sx) * (1 - sy) + (n01 * (1 - sx) + n11 * sx) * sy
def fractal(x, y, octaves=4, seed=0):
"""Fractal sum of value noise, normalised to 0..1."""
total, amp, freq, norm = 0.0, 1.0, 1.0, 0.0
for _ in range(octaves):
total += value_noise(x * freq, y * freq, seed) * amp
norm += amp
amp *= 0.5
freq *= 2.0
return total / norm
def clamp8(value):
return max(0, min(255, int(value * 255.0 + 0.5)))
# --- patterns --------------------------------------------------------------
# Each returns (base_rgb_function, roughness_function) over u, v in 0..1.
def stone_ashlar(courses=8, per_course=4):
"""Coursed ashlar: horizontal beds, staggered vertical joints, stone grain."""
course_h = 1.0 / courses
def block_uv(u, v):
row = int(v / course_h)
shift = 0.5 * (row % 2) / per_course # stagger alternate courses
col = int(((u + shift) % 1.0) * per_course)
return row, col, (v % course_h) / course_h, ((u + shift) % 1.0) * per_course - col
def base(u, v, px, py):
row, col, fj, fi = block_uv(u, v)
tone = 0.62 + 0.10 * _hash2(row, col, 11) # each block a little different
grain = 0.06 * (fractal(px * 0.15, py * 0.15, 5, 3) - 0.5) * 2.0
# joint: a dark band along the bed and the head of each block
joint = 0.0
if fj < 0.055 or fi < 0.055:
joint = -0.22
value = tone + grain + joint
return (value * 1.02, value, value * 0.94) # faintly warm stone
def rough(u, v, px, py):
row, col, fj, fi = block_uv(u, v)
base_r = 0.66 + 0.10 * _hash2(row, col, 29)
if fj < 0.055 or fi < 0.055:
base_r = 0.86 # joints are matte
return base_r + 0.06 * fractal(px * 0.2, py * 0.2, 3, 7)
return base, rough
def stucco():
def base(u, v, px, py):
blot = fractal(px * 0.03, py * 0.03, 4, 21)
tooth = fractal(px * 0.9, py * 0.9, 3, 22)
value = 0.58 + 0.12 * (blot - 0.5) + 0.05 * (tooth - 0.5)
return (value * 1.03, value, value * 0.96)
def rough(u, v, px, py):
return 0.74 + 0.18 * fractal(px * 0.06, py * 0.06, 3, 31)
return base, rough
def asphalt():
def base(u, v, px, py):
agg = fractal(px * 1.4, py * 1.4, 3, 41) # fine aggregate
patch = fractal(px * 0.02, py * 0.02, 3, 42) # repair patches
value = 0.055 + 0.05 * (patch - 0.5) + 0.06 * (agg - 0.5)
return (value, value, value * 1.05) # cool bitumen
def rough(u, v, px, py):
return 0.90 + 0.08 * fractal(px * 0.5, py * 0.5, 2, 43)
return base, rough
def concrete():
def base(u, v, px, py):
pores = fractal(px * 0.8, py * 0.8, 3, 51)
tone = fractal(px * 0.04, py * 0.04, 4, 52)
value = 0.50 + 0.10 * (tone - 0.5) + 0.05 * (pores - 0.5)
return (value * 1.01, value, value * 0.97)
def rough(u, v, px, py):
return 0.84 + 0.10 * fractal(px * 0.15, py * 0.15, 3, 53)
return base, rough
TEXTURES = {
"T_StoneAshlar": stone_ashlar(),
"T_Stucco": stucco(),
"T_Asphalt": asphalt(),
"T_Concrete": concrete(),
}
def render(name, pattern, size):
"""Render one texture; returns {suffix: path} for _BC and _R."""
base_fn, rough_fn = pattern
paths = {}
for suffix, fn in (("BC", base_fn), ("R", rough_fn)):
# Wrap in a function so the inner loop stays tight.
rows = []
for y in range(size):
v = y / size
row = bytearray()
for x in range(size):
u = x / size
value = fn(u, v, x / size * size / 64.0, y / size * size / 64.0)
if isinstance(value, tuple):
row += bytes((clamp8(value[0]), clamp8(value[1]), clamp8(value[2])))
else:
grey = clamp8(value)
row += bytes((grey, grey, grey))
rows.append(bytes(row))
path = os.path.join(OUT_DIR, f"{name}_{suffix}.png")
write_png(path, size, size, rows)
paths[suffix] = path
return paths
def main(argv=None) -> int:
global OUT_DIR
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--out", default="/tmp/gigline/textures",
help="output directory (default /tmp/gigline/textures)")
ap.add_argument("--size", type=int, default=512, help="edge length in pixels")
args = ap.parse_args(argv)
OUT_DIR = args.out
print(f"writing {len(TEXTURES) * 2} maps at {args.size}² to {OUT_DIR}")
for name, pattern in TEXTURES.items():
paths = render(name, pattern, args.size)
for suffix, path in paths.items():
print(f" {os.path.basename(path):28} {os.path.getsize(path):>8} bytes")
return 0
OUT_DIR = "/tmp/gigline/textures"
if __name__ == "__main__":
raise SystemExit(main())