feat(facades): 0.1.4 — module kit, textured materials, theatre trim (#30)
Merged PR #30: 0.1.4 — facade module kit, textured materials, theatre trim Co-authored-by: Zwitschi <zwitschi82@gmail.com>
This commit was merged in pull request #30.
This commit is contained in:
1 parent
d6d3d54810
commit
5e2c5e29c0
90 files changed
+2592
-413
No files matched your search
+9
-1
@@ -16,6 +16,12 @@ Geographic tools live in [`Geo/tools/`](../Geo/README.md) and the Blender-side b
|
||||
| Tool | Purpose |
|
||||
| --- | --- |
|
||||
| `check-lfs.sh` | Fails if the `lfs` filter is missing or an LFS-tracked file was committed as a plain blob |
|
||||
| `facade_kit_build.py` | Builds the reusable façade **module kit** at the measured 4.96 m bay: pilaster, sill, lintel, window bay (surround + glazing), shopfront bay (piers, glass, fascia), door bay (jambs, door, threshold), cornice and parapet + coping. Exports each piece with the shared preset plus a JSON part report (dims + evidence per piece). |
|
||||
| `ue_import_facade_kit.py` | Imports the kit into `/Game/Hollywood/Kit/*` as static meshes with complex-as-simple collision; writes `/tmp/gigline/kit-import.txt`. |
|
||||
| `theatre_trim_build.py` | El Capitan Theatre trim: projecting marquee canopy, illuminated fascia, recessed soffit, vertical blade sign with frame and bracket. Built **in world coordinates** (span and pavement height baked in) with a JSON part report, so staging is a single spawn at the origin. |
|
||||
| `ue_stage_theatre_trim.py` | Imports the trim to `/Game/Hollywood/Theatre` and stages one actor per piece in the DemoMap with site materials; clears a previous run first. |
|
||||
| `ue_apply_facade_kit.py` | Lays the kit over a building's street face from its own bounding box: bay count = round(width / 4.96), storeys from the height, shopfronts on the ground floor, windows above, pilasters on bay boundaries, cornice and parapet at the top. Clears a previous application first; unique labels per building. |
|
||||
| `generate_facade_textures.py` | Deterministic stdlib PNG generator for the facade maps (`T_StoneAshlar`, `T_Stucco`, `T_Asphalt`, `T_Concrete`, each `_BC` base colour + `_R` roughness). Pure-zlib writer, hash-based value noise, identical bytes on every run; `--size` and `--out` (`/tmp/gigline/textures` by default). `ue_build_demo_map.py` imports them into `/Game/Hollywood/Textures` and samples them world-aligned. |
|
||||
| `ue_common.py` | Shared helpers for the Unreal commandlet scripts: `make_writable()` clears the read-only bit an LFS `.umap` checks out with (otherwise `save_map()` returns False silently), and `require_saved()` makes that failure explicit. |
|
||||
| `fbx_export_preset.py` | The **single** FBX export preset (`ue_metric_v1` / `ue_character_v1`): metres→centimetres via `FBX_SCALE_NONE`, axis mapping east +X / north −Y / up +Z, `mesh_smooth_type="FACE"` (smoothing groups survive the import). Every Blender exporter imports it instead of copying the argument block; `preset_kwargs()` is bpy-free and unit-tested. |
|
||||
| `sibling_asset_audit.py` | Reads the sibling project's prop library from GLB headers into `Art/props/reuse-manifest.json`; copies nothing |
|
||||
@@ -52,7 +58,9 @@ Geographic tools live in [`Geo/tools/`](../Geo/README.md) and the Blender-side b
|
||||
| Tool | Purpose |
|
||||
| --- | --- |
|
||||
| `render_screenshots.py` / `render_screenshots.sh` | Rebuilds the review map, runs the game, captures the shot list into `Screenshots/` |
|
||||
| `judge_screenshot.py` | Vision-model pre-check: judges a screenshot against an expected-scene description (`--image` + `--expect`, or `--shots` for every shot in the README). Reads `OPENROUTER_API_KEY` from `.env` (ignored). Optional helper — a human must still approve. |
|
||||
| `judge_screenshot.py` | Vision-model pre-check: judges a screenshot against an expected-scene description (`--image` + `--expect`, or `--shots` for every shot in the README). Reads `OPENROUTER_API_KEY` from `.env` (ignored). Optional, advisory only — the model is unreliable on this content and never replaces a human verdict. |
|
||||
| `visual_loop.py` | The reference-vs-render loop. `--verify` gates each capture against its expectation in `visual_targets.json` (blank/short/wrong-size/tone-less shots fail); `--compare` measures luminance, contrast, edge density, palette and 3×3 composition against the reference photograph and turns the deltas into modelling actions naming the skill to consult (`blender-modeler`, `archviz`, `materials`, `lookdev`, `camera-cinematography`); `--iteration N` writes the artefacts under `Art/reference/iterations/N/`, which is **gitignored until the owner confirms a shot**. |
|
||||
| `visual_targets.json` | Per-shot expectation and reference photograph for `visual_loop.py`. Every entry must be a demo-map shot. |
|
||||
|
||||
## Gameplay QA
|
||||
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the reusable façade module kit (0.1.4).
|
||||
|
||||
One module, measured: the **4.96 m bay** (the hero's column rhythm, which the
|
||||
El Capitan Theatre next door shares per the reference notes). Every block building
|
||||
should assemble from the same pieces instead of being a bare extrusion, so the
|
||||
street wall gets a consistent window rhythm, shopfront line, cornice and parapet.
|
||||
|
||||
Pieces (`ue_metric_v1`, metres, origin at the module's bottom-front centre so a
|
||||
building can place them by bay index and storey index):
|
||||
|
||||
Kit_Pilaster vertical pier on a bay boundary
|
||||
Kit_Sill window sill band
|
||||
Kit_Lintel window head / lintel band
|
||||
Kit_WindowBay recessed window opening in one bay (glass + reveal)
|
||||
Kit_ShopfrontBay ground-floor shopfront: piers, glass, fascia
|
||||
Kit_DoorBay entrance bay: recessed door, jambs, threshold
|
||||
Kit_Cornice projecting cornice band, one module long
|
||||
Kit_Parapet parapet wall + coping, one module long
|
||||
|
||||
Dimensions come from the measured storey height and the reference photography
|
||||
where it resolves them (window proportions, shopfront line); everything the
|
||||
photographs do not resolve is an assumption and is recorded as one in the part
|
||||
report, so model and evidence stay separable.
|
||||
|
||||
python3 Tools/facade_kit_build.py --out /tmp/gigline/kit \
|
||||
--report /tmp/gigline/kit-report.json
|
||||
blender -b --factory-startup --python Tools/facade_kit_build.py -- --out /tmp/gigline/kit
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import fbx_export_preset as fbx_preset # noqa: E402
|
||||
|
||||
# --- module ------------------------------------------------------------------
|
||||
|
||||
BAY = 4.96 # m, measured: hero column rhythm, shared by the theatre
|
||||
STOREY = 3.10 # m, the calibrated level height (see block_height_report)
|
||||
PARAPET_H = 0.90 # m, coping above the top storey
|
||||
CORNICE_H = 0.55 # m, projecting band
|
||||
CORNICE_OUT = 0.35 # m, how far the cornice projects
|
||||
|
||||
PILASTER_W = 0.60
|
||||
PILASTER_D = 0.28
|
||||
|
||||
WINDOW_W = 1.60
|
||||
WINDOW_H = 1.85
|
||||
WINDOW_REVEAL = 0.18 # how far the glass sits behind the wall face
|
||||
SILL_H = 0.14
|
||||
LINTEL_H = 0.34
|
||||
|
||||
SHOP_GLASS_H = 2.30 # shopfront glazing height from the pavement
|
||||
SHOP_FASCIA_H = 0.55 # sign band above the glazing
|
||||
DOOR_W = 1.90
|
||||
DOOR_H = 2.45
|
||||
|
||||
STONE = None # materials are assigned in Unreal; Blender carries one grey
|
||||
PARTS: list[dict] = []
|
||||
|
||||
|
||||
def make_material(name, value):
|
||||
mat = bpy.data.materials.new(name)
|
||||
mat.use_nodes = True
|
||||
bsdf = mat.node_tree.nodes.get("Principled BSDF")
|
||||
bsdf.inputs["Base Color"].default_value = (value, value * 0.98, value * 0.94, 1.0)
|
||||
bsdf.inputs["Roughness"].default_value = 0.75
|
||||
return mat
|
||||
|
||||
|
||||
def validate(obj, tri_budget=4000):
|
||||
"""Topology check before handoff (blender-modeler cleanup checklist).
|
||||
|
||||
Reports non-manifold edges, loose geometry and the triangle count. Kit pieces
|
||||
are primitive solids, so any non-manifold edge means a modelling slip rather
|
||||
than an intentional hole.
|
||||
"""
|
||||
import bmesh
|
||||
|
||||
me = obj.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.edges.ensure_lookup_table()
|
||||
non_manifold = [e for e in bm.edges if len(e.link_faces) != 2]
|
||||
loose_verts = [v for v in bm.verts if not v.link_edges]
|
||||
tri_count = sum(len(f.verts) - 2 for f in bm.faces)
|
||||
bm.free()
|
||||
if non_manifold or loose_verts:
|
||||
print(f"[kit] WARNING {obj.name}: {len(non_manifold)} non-manifold edge(s), "
|
||||
f"{len(loose_verts)} loose vertex/vertices")
|
||||
if tri_count > tri_budget:
|
||||
print(f"[kit] WARNING {obj.name}: {tri_count} tris exceeds the {tri_budget} budget")
|
||||
return {"manifold": not non_manifold, "loose_verts": len(loose_verts),
|
||||
"tris": tri_count}
|
||||
|
||||
|
||||
def note(obj, note_text):
|
||||
PARTS.append({
|
||||
"object": obj.name,
|
||||
"asset": "SM_" + obj.name,
|
||||
"dims_m": [round(d, 3) for d in obj.dimensions],
|
||||
"location_m": [round(c, 3) for c in obj.location],
|
||||
"topology": validate(obj),
|
||||
"evidence": note_text,
|
||||
})
|
||||
print(f"[kit] {obj.name:22} {obj.dimensions.x:.2f} x {obj.dimensions.y:.2f} "
|
||||
f"x {obj.dimensions.z:.2f} m ({note_text})")
|
||||
|
||||
|
||||
def cube(name, x, y, z, dx, dy, dz, material):
|
||||
bpy.ops.mesh.primitive_cube_add(size=1.0, location=(x, y, z))
|
||||
obj = bpy.context.active_object
|
||||
obj.name = name
|
||||
obj.scale = (dx, dy, dz)
|
||||
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
|
||||
obj.data.materials.append(material)
|
||||
return obj
|
||||
|
||||
|
||||
def cylinder(name, x, y, z0, z1, radius, material, vertices=16):
|
||||
bpy.ops.mesh.primitive_cylinder_add(vertices=vertices, radius=radius,
|
||||
depth=(z1 - z0), location=(x, y, (z0 + z1) / 2.0))
|
||||
obj = bpy.context.active_object
|
||||
obj.name = name
|
||||
obj.data.materials.append(material)
|
||||
bpy.ops.object.shade_smooth_by_angle(angle=math.radians(35.0))
|
||||
return obj
|
||||
|
||||
|
||||
def clear_scene():
|
||||
"""Remove objects and orphan meshes. Materials are left alone: deleting them
|
||||
invalidates handles the previous piece still holds ("StructRNA of type
|
||||
Material has been removed")."""
|
||||
bpy.ops.object.select_all(action="SELECT")
|
||||
bpy.ops.object.delete(use_global=False)
|
||||
for mesh in list(bpy.data.meshes):
|
||||
if mesh.users == 0:
|
||||
bpy.data.meshes.remove(mesh)
|
||||
|
||||
|
||||
# --- pieces ------------------------------------------------------------------
|
||||
# Each builder returns [(object, note)] for one module. Origin: bottom-front
|
||||
# centre of the module, +Y into the wall depth, +Z up, X across the bay.
|
||||
|
||||
|
||||
def build_pilaster(mat):
|
||||
o = cube("Kit_Pilaster", 0.0, PILASTER_D / 2.0, STOREY / 2.0,
|
||||
PILASTER_W, PILASTER_D, STOREY, mat)
|
||||
return [(o, "assumption: 0.60 x 0.28 m pier on the bay boundary")]
|
||||
|
||||
|
||||
def build_sill(mat):
|
||||
o = cube("Kit_Sill", 0.0, 0.10, SILL_H / 2.0, WINDOW_W + 0.30, 0.24, SILL_H, mat)
|
||||
return [(o, "assumption: projecting stone sill under each opening")]
|
||||
|
||||
|
||||
def build_lintel(mat):
|
||||
o = cube("Kit_Lintel", 0.0, 0.10, LINTEL_H / 2.0, WINDOW_W + 0.30, 0.24, LINTEL_H, mat)
|
||||
return [(o, "assumption: lintel band over each opening")]
|
||||
|
||||
|
||||
def build_window_bay(mat):
|
||||
"""One storey of window: a surround standing proud of the wall, glazing inset.
|
||||
|
||||
The blockout wall is a solid box, so a real recess cannot be cut. The piece is
|
||||
therefore a shallow surround that projects 3 cm and a glazing plane set 6 cm
|
||||
behind its front, which reads as a window from any street angle. The first
|
||||
version modelled everything at +Y (into the wall), so the whole bay sat inside
|
||||
the solid wall and was invisible in every render.
|
||||
"""
|
||||
parts = []
|
||||
surround_d = 0.22
|
||||
surround = cube("Kit_WindowBay", 0.0, -0.03 + surround_d / 2.0, WINDOW_H / 2.0,
|
||||
WINDOW_W, surround_d, WINDOW_H, mat)
|
||||
parts.append((surround, "photographs: window proportions 1.60 x 1.85 m; surround "
|
||||
"projects 3 cm so it reads against a solid blockout wall"))
|
||||
glass = cube("Kit_WindowGlass", 0.0, 0.09, WINDOW_H / 2.0,
|
||||
WINDOW_W - 0.16, 0.04, WINDOW_H - 0.16, mat)
|
||||
parts.append((glass, "glazing plane set 6 cm behind the surround face; "
|
||||
"material assigned in Unreal"))
|
||||
return parts
|
||||
|
||||
|
||||
def build_shopfront_bay(mat):
|
||||
"""Ground floor: two piers, glazing between, fascia above."""
|
||||
parts = []
|
||||
for side in (-1, 1):
|
||||
x = side * (BAY / 2.0 - PILASTER_W / 2.0)
|
||||
o = cube(f"Kit_ShopPier_{'L' if side < 0 else 'R'}", x, PILASTER_D / 2.0,
|
||||
SHOP_GLASS_H / 2.0, PILASTER_W, PILASTER_D, SHOP_GLASS_H, mat)
|
||||
parts.append((o, "shopfront pier, same 0.60 m module as the pilaster"))
|
||||
glass = cube("Kit_ShopGlass", 0.0, 0.06, SHOP_GLASS_H / 2.0,
|
||||
BAY - 2 * PILASTER_W, 0.05, SHOP_GLASS_H - 0.25, mat)
|
||||
parts.append((glass, "photographs: continuous shopfront glazing to a "
|
||||
"2.30 m head"))
|
||||
fascia = cube("Kit_ShopFascia", 0.0, 0.14, SHOP_GLASS_H + SHOP_FASCIA_H / 2.0,
|
||||
BAY, 0.26, SHOP_FASCIA_H, mat)
|
||||
parts.append((fascia, "sign band over the shopfront; 0.55 m assumption"))
|
||||
return parts
|
||||
|
||||
|
||||
def build_door_bay(mat):
|
||||
parts = []
|
||||
for side in (-1, 1):
|
||||
x = side * (DOOR_W / 2.0 + 0.35)
|
||||
o = cube(f"Kit_DoorJamb_{'L' if side < 0 else 'R'}", x, 0.14, STOREY / 2.0,
|
||||
0.35, 0.30, STOREY, mat)
|
||||
parts.append((o, "entrance jamb standing 0.30 m proud of the wall"))
|
||||
door = cube("Kit_Door", 0.0, 0.06, DOOR_H / 2.0, DOOR_W, 0.09, DOOR_H, mat)
|
||||
parts.append((door, "assumption: 1.90 x 2.45 m recessed door"))
|
||||
threshold = cube("Kit_Threshold", 0.0, 0.22, 0.05, DOOR_W + 0.70, 0.44, 0.10, mat)
|
||||
parts.append((threshold, "threshold slab, flush with the pavement"))
|
||||
return parts
|
||||
|
||||
|
||||
def build_cornice(mat):
|
||||
o = cube("Kit_Cornice", 0.0, -CORNICE_OUT / 2.0, CORNICE_H / 2.0,
|
||||
BAY, CORNICE_OUT * 2.0, CORNICE_H, mat)
|
||||
return [(o, "projecting cornice band, one bay long; 0.35 m projection assumption")]
|
||||
|
||||
|
||||
def build_parapet(mat):
|
||||
parts = []
|
||||
wall = cube("Kit_Parapet", 0.0, -0.15, PARAPET_H / 2.0, BAY, 0.55, PARAPET_H, mat)
|
||||
parts.append((wall, "assumption: 0.90 m parapet above the top storey"))
|
||||
coping = cube("Kit_Coping", 0.0, -0.15, PARAPET_H - 0.06, BAY, 0.65, 0.12, mat)
|
||||
parts.append((coping, "coping slab over the parapet"))
|
||||
return parts
|
||||
|
||||
|
||||
BUILDERS = {
|
||||
"pilaster": build_pilaster,
|
||||
"sill": build_sill,
|
||||
"lintel": build_lintel,
|
||||
"window": build_window_bay,
|
||||
"shopfront": build_shopfront_bay,
|
||||
"door": build_door_bay,
|
||||
"cornice": build_cornice,
|
||||
"parapet": build_parapet,
|
||||
}
|
||||
|
||||
|
||||
def build_piece(name):
|
||||
"""Build one piece group in a clean scene. The material is created *after*
|
||||
the clear so its handle stays valid."""
|
||||
clear_scene()
|
||||
mat = make_material(f"M_Kit_{name}", 0.6)
|
||||
return BUILDERS[name](mat)
|
||||
|
||||
|
||||
def export_group(objects, out_dir):
|
||||
"""Export each object to its own FBX with the shared preset."""
|
||||
exported = []
|
||||
for obj in objects:
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
# Unreal naming convention for static meshes is SM_<Name>; the Blender
|
||||
# object keeps its readable name, the asset the importable one.
|
||||
path = os.path.join(out_dir, "SM_" + obj.name + ".fbx")
|
||||
fbx_preset.export_static(path, selection=True)
|
||||
exported.append(path)
|
||||
return exported
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--out", default="/tmp/gigline/kit")
|
||||
ap.add_argument("--report", default=None)
|
||||
ap.add_argument("--only", default=None, help="comma-separated piece names")
|
||||
args = ap.parse_args(argv)
|
||||
os.makedirs(args.out, exist_ok=True)
|
||||
|
||||
names = [n.strip() for n in args.only.split(",")] if args.only else list(BUILDERS)
|
||||
unknown = [n for n in names if n not in BUILDERS]
|
||||
if unknown:
|
||||
raise SystemExit(f"unknown piece(s): {unknown}; known: {sorted(BUILDERS)}")
|
||||
|
||||
total = 0
|
||||
for name in names:
|
||||
made = build_piece(name)
|
||||
if name == "window":
|
||||
made = made # window bay exports two objects (reveal + glass)
|
||||
objects = [o for o, _ in made]
|
||||
for obj, note_text in made:
|
||||
note(obj, note_text)
|
||||
exported = export_group(objects, args.out)
|
||||
total += len(exported)
|
||||
print(f"[kit] {name}: {len(exported)} object(s) exported")
|
||||
|
||||
if args.report:
|
||||
with open(args.report, "w", encoding="utf-8") as handle:
|
||||
json.dump({
|
||||
"module_m": BAY,
|
||||
"storey_m": STOREY,
|
||||
"pieces": sorted(BUILDERS),
|
||||
"parts": PARTS,
|
||||
}, handle, indent=2)
|
||||
print(f"[kit] report -> {args.report}")
|
||||
print(f"[kit] exported {total} object(s) to {args.out}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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/generate_facade_textures.py # write to /tmp/gigline/textures
|
||||
python3 Tools/generate_facade_textures.py --size 1024
|
||||
python3 Tools/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())
|
||||
+32
-34
@@ -1,56 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# Render in-game screenshots for visual review into <repo>/Screenshots/ (gitignored).
|
||||
# Render the demo-map screenshots used for visual review into <repo>/Screenshots/demo/.
|
||||
#
|
||||
# Usage: Tools/render_screenshots.sh
|
||||
# Rebuilds /Game/Test/ReviewMap and /Game/Hollywood/DemoMap (shot lists), runs
|
||||
# the game on each map (the GigLineShotActor walks the cameras, captures, quits),
|
||||
# and copies the PNGs into Screenshots/. PNGs are never committed.
|
||||
# Usage: Tools/render_screenshots.sh [--verify]
|
||||
#
|
||||
# Only the **demo map** is captured: the review staging map exists to hold the
|
||||
# hero/camera list, not to produce review screenshots. Everything here is what a
|
||||
# player actually sees when the demo runs.
|
||||
#
|
||||
# --verify run Tools/visual_loop.py --verify afterwards and fail if a shot
|
||||
# does not show the content its expectation describes
|
||||
#
|
||||
# The PNGs are committed (see Screenshots/README.md): a reviewer must see exactly
|
||||
# what the build rendered.
|
||||
set -euo pipefail
|
||||
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
ENGINE="${UE_ENGINE_DIR:-/run/media/system/DATA/Engines/UE_5.8.4}"
|
||||
PROJ="$REPO/Game/GigLine/GigLine.uproject"
|
||||
VERIFY="no"
|
||||
[ "${1:-}" = "--verify" ] && VERIFY="yes"
|
||||
|
||||
echo "== 1/4 build review + demo shot maps =="
|
||||
echo "== 1/3 stage the demo shot cameras =="
|
||||
cd "$ENGINE"
|
||||
./Engine/Binaries/Linux/UnrealEditor-Cmd "$PROJ" \
|
||||
-run=pythonscript -script="$REPO/Tools/ue_build_review_map.py" \
|
||||
-nullrhi -unattended -nopause -stdout
|
||||
cat /tmp/ue-review-map-result.txt | tail -3
|
||||
tail -3 /tmp/ue-review-map-result.txt 2>/dev/null || true
|
||||
|
||||
run_capture() {
|
||||
local map="$1" label="$2"
|
||||
echo "== capture: $label =="
|
||||
./Engine/Binaries/Linux/UnrealEditor-Cmd "$PROJ" "$map" \
|
||||
-game -windowed -ResX=1920 -ResY=1080 -nopause -nosplash -stdout
|
||||
}
|
||||
echo "== 2/3 capture the demo map =="
|
||||
./Engine/Binaries/Linux/UnrealEditor-Cmd "$PROJ" /Game/Hollywood/DemoMap \
|
||||
-game -windowed -ResX=1920 -ResY=1080 -nopause -nosplash -stdout
|
||||
|
||||
echo "== 2/4 run game on review map (hero/scene shots) =="
|
||||
run_capture "/Game/Test/ReviewMap" "review"
|
||||
|
||||
echo "== 3/4 run game on demo map (walkable-demo shots) =="
|
||||
run_capture "/Game/Hollywood/DemoMap" "demo"
|
||||
|
||||
echo "== 4/4 collect screenshots =="
|
||||
echo "== 3/3 collect the demo screenshots =="
|
||||
mkdir -p "$REPO/Screenshots/demo"
|
||||
rm -f "$REPO"/Screenshots/review-*.png "$REPO"/Screenshots/demo/review-*.png
|
||||
rm -f "$REPO"/Screenshots/demo/review-*.png
|
||||
found=0
|
||||
for src in \
|
||||
"$REPO/Game/GigLine/Saved/Screenshots"/*/review-*.png \
|
||||
"$REPO/Game/GigLine/Saved/Screenshots"/*/*/review-*.png \
|
||||
"$HOME/.config/Epic/GigLine/Saved/Screenshots"/*/review-*.png; do
|
||||
[ -e "$src" ] || continue
|
||||
name=$(basename "$src")
|
||||
if [[ "$name" == review-?("")-demo-* || "$name" == review-*-demo-* || "$name" == review-0?-demo-* ]] \
|
||||
|| [[ "$name" == *demo-* ]]; then
|
||||
cp -f "$src" "$REPO/Screenshots/demo/$name"
|
||||
else
|
||||
cp -f "$src" "$REPO/Screenshots/$name"
|
||||
fi
|
||||
found=1
|
||||
case "$(basename "$src")" in
|
||||
*demo-*) cp -f "$src" "$REPO/Screenshots/demo/"; found=1 ;;
|
||||
esac
|
||||
done
|
||||
[ "$found" = "1" ] || { echo "ERROR: no review-*.png found"; exit 1; }
|
||||
echo "--- Screenshots/ ---"
|
||||
ls -1 "$REPO/Screenshots"
|
||||
echo "--- Screenshots/demo/ ---"
|
||||
ls -1 "$REPO/Screenshots/demo"
|
||||
[ "$found" = "1" ] || { echo "ERROR: no demo screenshots found"; exit 1; }
|
||||
ls -1 "$REPO/Screenshots/demo"
|
||||
|
||||
if [ "$VERIFY" = "yes" ]; then
|
||||
echo "== verify: does each shot show what it should? =="
|
||||
cd "$REPO"
|
||||
python3 Tools/visual_loop.py --verify
|
||||
fi
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Tests for Tools/visual_loop.py — metrics, comparison and feedback rules.
|
||||
|
||||
Synthetic 8-bit images are written to a temp directory and measured, so the
|
||||
numbers can be reasoned about directly (a mid-grey flat frame has near-zero edge
|
||||
density and contrast; a checkerboard has a lot of both).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from PIL import Image
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
sys.path.insert(0, os.path.join(ROOT, "Tools"))
|
||||
|
||||
import visual_loop as vl # noqa: E402
|
||||
|
||||
|
||||
def write_image(path, kind, size=(64, 64)):
|
||||
img = Image.new("RGB", size)
|
||||
if kind == "flat":
|
||||
img.paste((128, 128, 128), (0, 0, size[0], size[1]))
|
||||
elif kind == "dark":
|
||||
img.paste((10, 10, 10), (0, 0, size[0], size[1]))
|
||||
elif kind == "checker":
|
||||
px = img.load()
|
||||
for y in range(size[1]):
|
||||
for x in range(size[0]):
|
||||
px[x, y] = (240, 240, 240) if (x // 4 + y // 4) % 2 else (20, 20, 20)
|
||||
elif kind == "sky":
|
||||
# bright top third, mid stone bottom: composition like an elevation
|
||||
px = img.load()
|
||||
for y in range(size[1]):
|
||||
for x in range(size[0]):
|
||||
px[x, y] = (150, 190, 230) if y < size[1] // 3 else (170, 165, 155)
|
||||
img.save(path)
|
||||
return path
|
||||
|
||||
|
||||
class MetricsTest(unittest.TestCase):
|
||||
def test_flat_frame_has_no_edges_and_no_contrast(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = vl.metrics(write_image(os.path.join(tmp, "a.png"), "flat"))
|
||||
self.assertEqual(m["size"], [64, 64])
|
||||
self.assertAlmostEqual(m["mean_luma"], 128.0, delta=1.0)
|
||||
self.assertLess(m["rms_contrast"], 1.0)
|
||||
self.assertLess(m["edge_density"], vl.MIN_EDGE_DENSITY)
|
||||
|
||||
def test_checker_frame_has_edges_and_contrast(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = vl.metrics(write_image(os.path.join(tmp, "b.png"), "checker"))
|
||||
self.assertGreater(m["edge_density"], 0.01)
|
||||
self.assertGreater(m["rms_contrast"], 50.0)
|
||||
|
||||
def test_composition_grid_is_nine_cells(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = vl.metrics(write_image(os.path.join(tmp, "c.png"), "sky"))
|
||||
self.assertEqual(len(m["grid_luma"]), 9)
|
||||
# sky row should be brighter than the stone rows
|
||||
self.assertGreater(m["grid_luma"][0], m["grid_luma"][8])
|
||||
|
||||
def test_dominant_palette_is_reported(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = vl.metrics(write_image(os.path.join(tmp, "d.png"), "flat"))
|
||||
self.assertTrue(m["dominant"])
|
||||
self.assertAlmostEqual(m["dominant"][0]["share"], 1.0, delta=0.05)
|
||||
|
||||
|
||||
class CompareTest(unittest.TestCase):
|
||||
def _metrics(self, kind):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
return vl.metrics(write_image(os.path.join(tmp, "x.png"), kind))
|
||||
|
||||
def test_identical_images_score_high(self):
|
||||
m = self._metrics("checker")
|
||||
delta = vl.compare(m, m)
|
||||
self.assertGreater(delta["score"], 0.9)
|
||||
self.assertEqual(delta["delta_luma"], 0.0)
|
||||
|
||||
def test_flat_against_checker_scores_low_on_edges(self):
|
||||
flat, checker = self._metrics("flat"), self._metrics("checker")
|
||||
delta = vl.compare(flat, checker)
|
||||
self.assertLess(delta["edge_ratio"], 0.5)
|
||||
self.assertLess(delta["score"], 0.9)
|
||||
|
||||
def test_dark_against_bright_reports_a_luma_delta(self):
|
||||
dark, bright = self._metrics("dark"), self._metrics("checker")
|
||||
delta = vl.compare(dark, bright)
|
||||
self.assertLess(delta["delta_luma"], -50)
|
||||
|
||||
|
||||
class FeedbackTest(unittest.TestCase):
|
||||
def test_low_edge_ratio_names_the_modelling_skills(self):
|
||||
notes = vl.feedback("x", {}, {}, {
|
||||
"delta_luma": 0, "contrast_ratio": 1.0, "edge_ratio": 0.3,
|
||||
"palette_distance": 10, "composition_correlation": 0.8, "score": 0.5})
|
||||
joined = " ".join(notes)
|
||||
self.assertIn("Relief", joined)
|
||||
self.assertIn("blender-modeler", joined)
|
||||
|
||||
def test_bright_and_compressed_flags_lighting_and_lookdev(self):
|
||||
notes = vl.feedback("x", {}, {}, {
|
||||
"delta_luma": 40, "contrast_ratio": 0.5, "edge_ratio": 1.0,
|
||||
"palette_distance": 10, "composition_correlation": 0.8, "score": 0.5})
|
||||
joined = " ".join(notes)
|
||||
self.assertIn("Too bright", joined)
|
||||
self.assertIn("lookdev", joined)
|
||||
|
||||
def test_framing_mismatch_names_the_camera_skill(self):
|
||||
notes = vl.feedback("x", {}, {}, {
|
||||
"delta_luma": 0, "contrast_ratio": 1.0, "edge_ratio": 1.0,
|
||||
"palette_distance": 10, "composition_correlation": 0.1, "score": 0.5})
|
||||
self.assertIn("camera-cinematography", " ".join(notes))
|
||||
|
||||
def test_no_delta_reports_no_action(self):
|
||||
notes = vl.feedback("x", {}, {}, {
|
||||
"delta_luma": 0, "contrast_ratio": 1.0, "edge_ratio": 1.0,
|
||||
"palette_distance": 5, "composition_correlation": 0.9, "score": 0.95})
|
||||
self.assertIn("No metric is outside its tolerance", notes[0])
|
||||
|
||||
|
||||
class TargetSpecTest(unittest.TestCase):
|
||||
def test_every_target_has_expectation_and_reference(self):
|
||||
for target in vl.load_targets():
|
||||
self.assertTrue(target["expect"].strip(), target["name"])
|
||||
self.assertTrue(target["references"], target["name"])
|
||||
self.assertTrue(target["shot"].startswith("Screenshots/demo/"),
|
||||
f"{target['name']} must be a demo-map shot")
|
||||
|
||||
def test_iteration_output_is_gitignored(self):
|
||||
# Iterations are deliberately uncommitted until the owner confirms a shot.
|
||||
ignore = open(os.path.join(ROOT, ".gitignore"), encoding="utf-8").read()
|
||||
self.assertIn("/Art/reference/iterations/", ignore)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env python3
|
||||
"""El Capitan Theatre (6838) trim: marquee canopy and blade sign.
|
||||
|
||||
The theatre blockout is a plain volume; what makes it read as *the* El Capitan
|
||||
Theatre is the projecting marquee over the pavement and the vertical blade sign on
|
||||
the corner. Both are built **in world coordinates** here, so the export carries its
|
||||
position and the Unreal staging step is a single spawn at the origin — the same
|
||||
convention as the block frontage and the north buildings.
|
||||
|
||||
The building's verified span (from `Geo/verification/2026-11-06-el-capitan-block.md`
|
||||
and the staged mesh) is X −12082..−8210, street face at Y ≈ +1001, height 20.55 m.
|
||||
|
||||
blender -b --factory-startup --python Tools/theatre_trim_build.py -- \
|
||||
--out /tmp/gigline/theatre --report /tmp/gigline/theatre-report.json
|
||||
|
||||
Everything the photographs do not resolve at this scale (exact marquee depth,
|
||||
soffit height, sign lettering) is an assumption recorded in the part report.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import fbx_export_preset as fbx_preset # noqa: E402
|
||||
|
||||
# Verified building span, metres (Unreal X, Y, Z but Blender-frame: X east,
|
||||
# Y north, Z up, metres).
|
||||
THEATRE_X0 = -120.82
|
||||
THEATRE_X1 = -82.10
|
||||
# Street face of the blockout volume, in BLENDER metres. Blender's +Y is north and
|
||||
# Unreal's is -Y, so the theatre's Unreal face at Y +1001 cm is Blender Y -10.01 m.
|
||||
# Getting this sign wrong put the whole marquee on the far side of the boulevard.
|
||||
FACE_Y = -10.01
|
||||
HEIGHT = 20.55
|
||||
# Ground level at the theatre frontage, in the shared frame (Z up, metres). The
|
||||
# pavement here is 4.56 m above the project datum, so every trim height below is
|
||||
# measured from the pavement and then offset: the first build used absolute Z and
|
||||
# put the marquee canopy 5 cm above the pavement, where the wall probe found it at
|
||||
# knee height.
|
||||
GROUND_Z = 4.56
|
||||
|
||||
# Marquee: centred on the frontage, projecting over the pavement.
|
||||
MARQUEE_W = 12.0
|
||||
MARQUEE_DEPTH = 2.60
|
||||
MARQUEE_Z = GROUND_Z + 4.60 # underside of the canopy above the pavement
|
||||
CANOPY_T = 0.30
|
||||
FASCIA_H = 1.10
|
||||
|
||||
BLADE_W = 1.30
|
||||
BLADE_H = 5.20
|
||||
BLADE_Z0 = GROUND_Z + 5.20
|
||||
BLADE_PROJECT = 2.00
|
||||
|
||||
PARTS: list[dict] = []
|
||||
|
||||
|
||||
def make_material(name, value, rough=0.6):
|
||||
mat = bpy.data.materials.new(name)
|
||||
mat.use_nodes = True
|
||||
bsdf = mat.node_tree.nodes.get("Principled BSDF")
|
||||
bsdf.inputs["Base Color"].default_value = (value, value * 0.97, value * 0.92, 1.0)
|
||||
bsdf.inputs["Roughness"].default_value = rough
|
||||
return mat
|
||||
|
||||
|
||||
def validate(obj, tri_budget=6000):
|
||||
import bmesh
|
||||
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(obj.data)
|
||||
bm.edges.ensure_lookup_table()
|
||||
bad = [e for e in bm.edges if len(e.link_faces) != 2]
|
||||
loose = [v for v in bm.verts if not v.link_edges]
|
||||
tris = sum(len(f.verts) - 2 for f in bm.faces)
|
||||
bm.free()
|
||||
if bad or loose:
|
||||
print(f"[trim] WARNING {obj.name}: {len(bad)} non-manifold, {len(loose)} loose")
|
||||
if tris > tri_budget:
|
||||
print(f"[trim] WARNING {obj.name}: {tris} tris over budget")
|
||||
return {"manifold": not bad, "loose_verts": len(loose), "tris": tris}
|
||||
|
||||
|
||||
def note(obj, text):
|
||||
PARTS.append({
|
||||
"object": obj.name,
|
||||
"asset": "SM_" + obj.name,
|
||||
"dims_m": [round(d, 3) for d in obj.dimensions],
|
||||
"location_m": [round(c, 3) for c in obj.location],
|
||||
"topology": validate(obj),
|
||||
"evidence": text,
|
||||
})
|
||||
print(f"[trim] {obj.name:22} {obj.dimensions.x:6.2f} x {obj.dimensions.y:5.2f} "
|
||||
f"x {obj.dimensions.z:5.2f} m ({text})")
|
||||
|
||||
|
||||
def cube(name, x, y, z, dx, dy, dz, material):
|
||||
bpy.ops.mesh.primitive_cube_add(size=1.0, location=(x, y, z))
|
||||
obj = bpy.context.active_object
|
||||
obj.name = name
|
||||
obj.scale = (dx, dy, dz)
|
||||
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
|
||||
obj.data.materials.append(material)
|
||||
return obj
|
||||
|
||||
|
||||
def clear_scene():
|
||||
bpy.ops.object.select_all(action="SELECT")
|
||||
bpy.ops.object.delete(use_global=False)
|
||||
for mesh in list(bpy.data.meshes):
|
||||
if mesh.users == 0:
|
||||
bpy.data.meshes.remove(mesh)
|
||||
|
||||
|
||||
def build():
|
||||
"""Marquee canopy, fascia, soffit and the blade sign, in world metres."""
|
||||
clear_scene()
|
||||
stone = make_material("M_Trim_Stone", 0.62)
|
||||
dark = make_material("M_Trim_Shadow", 0.30, rough=0.75)
|
||||
lightbox = make_material("M_Trim_Lightbox", 0.86, rough=0.35)
|
||||
|
||||
cx = (THEATRE_X0 + THEATRE_X1) / 2.0
|
||||
# The street is at smaller Unreal Y than the face, which is *larger* Blender Y,
|
||||
# so the marquee projects in +Y here.
|
||||
y_outer = FACE_Y + MARQUEE_DEPTH
|
||||
|
||||
parts = []
|
||||
canopy = cube("Trim_MarqueeCanopy", cx, FACE_Y + MARQUEE_DEPTH / 2.0,
|
||||
MARQUEE_Z + CANOPY_T / 2.0, MARQUEE_W, MARQUEE_DEPTH, CANOPY_T, stone)
|
||||
parts.append((canopy, "photographs: projecting marquee over the pavement; "
|
||||
"12.0 x 2.60 m at 4.60 m soffit — depth and height assumed"))
|
||||
|
||||
fascia = cube("Trim_MarqueeFascia", cx, y_outer - 0.12,
|
||||
MARQUEE_Z + CANOPY_T + FASCIA_H / 2.0,
|
||||
MARQUEE_W, 0.24, FASCIA_H, lightbox)
|
||||
parts.append((fascia, "photographs: illuminated fascia band around the marquee "
|
||||
"edge; 1.10 m depth assumed"))
|
||||
|
||||
soffit = cube("Trim_MarqueeSoffit", cx, FACE_Y + MARQUEE_DEPTH / 2.0,
|
||||
MARQUEE_Z - 0.05, MARQUEE_W - 0.4, MARQUEE_DEPTH - 0.4, 0.10, dark)
|
||||
parts.append((soffit, "assumption: recessed soffit panel under the canopy; "
|
||||
"reads as shadow from the pavement"))
|
||||
|
||||
# Blade sign on the west corner, projecting perpendicular to the facade.
|
||||
bx = THEATRE_X0 + 3.6
|
||||
blade = cube("Trim_BladeSign", bx, FACE_Y + BLADE_PROJECT / 2.0,
|
||||
BLADE_Z0 + BLADE_H / 2.0, BLADE_W, 0.42, BLADE_H, lightbox)
|
||||
parts.append((blade, "photographs: vertical blade sign on the corner; "
|
||||
"1.30 x 5.20 m, 2.00 m projection assumed"))
|
||||
|
||||
frame = cube("Trim_BladeSignFrame", bx, FACE_Y + BLADE_PROJECT / 2.0,
|
||||
BLADE_Z0 + BLADE_H / 2.0, BLADE_W + 0.28, 0.60, 0.35, stone)
|
||||
parts.append((frame, "assumption: stone frame band at the base of the blade"))
|
||||
|
||||
# Bracket back to the wall so the sign does not float.
|
||||
bracket = cube("Trim_BladeSignBracket", bx, FACE_Y + BLADE_PROJECT - 0.6,
|
||||
BLADE_Z0 + BLADE_H - 0.5, 0.60, 1.40, 0.30, dark)
|
||||
parts.append((bracket, "assumption: bracket tying the blade back to the facade"))
|
||||
|
||||
return parts
|
||||
|
||||
|
||||
def export_objects(parts, out_dir):
|
||||
count = 0
|
||||
for obj, _text in parts:
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
path = os.path.join(out_dir, "SM_" + obj.name + ".fbx")
|
||||
fbx_preset.export_static(path, selection=True)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--out", default="/tmp/gigline/theatre")
|
||||
ap.add_argument("--report", default=None)
|
||||
args = ap.parse_args(argv)
|
||||
os.makedirs(args.out, exist_ok=True)
|
||||
|
||||
parts = build()
|
||||
for obj, text in parts:
|
||||
note(obj, text)
|
||||
exported = export_objects(parts, args.out)
|
||||
print(f"[trim] exported {exported} object(s) to {args.out}")
|
||||
|
||||
if args.report:
|
||||
with open(args.report, "w", encoding="utf-8") as handle:
|
||||
json.dump({
|
||||
"building": "El Capitan Theatre (6838)",
|
||||
"span_m": [THEATRE_X0, THEATRE_X1, FACE_Y, HEIGHT],
|
||||
"parts": PARTS,
|
||||
}, handle, indent=2)
|
||||
print(f"[trim] report -> {args.report}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Assemble the façade kit onto the block buildings.
|
||||
|
||||
Takes the imported kit from `/Game/Hollywood/Kit` and lays it out over a frontage
|
||||
building's street face: shopfront bays on the ground floor, window bays above,
|
||||
pilasters on the bay boundaries, a cornice under the parapet and a coping over it.
|
||||
|
||||
Layout is driven by the building's own bounding box, so it follows whatever the
|
||||
blockout gives: the bay count is `round(width / BAY)` and the storey count comes
|
||||
from the height above the ground floor. Nothing is hand-placed per building.
|
||||
|
||||
UnrealEditor-Cmd <uproject> -run=pythonscript \
|
||||
-script=Tools/ue_apply_facade_kit.py -nullrhi -unattended -nopause -stdout
|
||||
|
||||
# or, as a step inside the demo builder:
|
||||
from ue_apply_facade_kit import apply_to_actor
|
||||
|
||||
Result file: /tmp/gigline/kit-apply.txt
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import unreal
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import ue_common # noqa: E402
|
||||
|
||||
KIT = "/Game/Hollywood/Kit"
|
||||
RESULT = "/tmp/gigline/kit-apply.txt"
|
||||
|
||||
# Material per kit piece. Without this the pieces keep the grey material the FBX
|
||||
# import creates (M_Kit_*), so the glazing is not glass and the shopfronts are not
|
||||
# stone - the kit reads as a plastic scaffold over the buildings.
|
||||
KIT_MATERIALS = {
|
||||
"Kit_WindowGlass": "M_FacadeGlass",
|
||||
"Kit_ShopGlass": "M_FacadeGlass",
|
||||
"Kit_Door": "M_FacadeStoneShadow",
|
||||
"Kit_WindowBay": "M_FacadeStone",
|
||||
"Kit_ShopFascia": "M_FacadeBanner",
|
||||
}
|
||||
KIT_MATERIAL_FALLBACK = "M_FacadeStone"
|
||||
|
||||
BAY_CM = 496.0 # the kit module
|
||||
STOREY_CM = 310.0
|
||||
SHOP_FLOOR_CM = 310.0 # ground floor is one storey tall
|
||||
KIT_PREFIX = "Kit_"
|
||||
|
||||
lines: list[str] = []
|
||||
|
||||
|
||||
def log(message: str) -> None:
|
||||
lines.append(message)
|
||||
print(message)
|
||||
|
||||
|
||||
def load_kit() -> dict:
|
||||
"""Load every kit mesh once, keyed by its short name."""
|
||||
kit = {}
|
||||
registry = unreal.AssetRegistryHelpers.get_asset_registry()
|
||||
filt = unreal.ARFilter(package_paths=[KIT], recursive_paths=False,
|
||||
class_paths=[unreal.TopLevelAssetPath("/Script/Engine", "StaticMesh")])
|
||||
for data in registry.get_assets(filt):
|
||||
mesh = unreal.EditorAssetLibrary.load_asset(str(data.package_name))
|
||||
if mesh:
|
||||
# Assets are SM_-prefixed (Unreal convention); the layout code refers to
|
||||
# pieces by their short name, so key on the name without the prefix.
|
||||
name = mesh.get_name()
|
||||
kit[name[3:] if name.startswith("SM_") else name] = mesh
|
||||
return kit
|
||||
|
||||
|
||||
def _bounds(mesh):
|
||||
box = mesh.get_bounding_box()
|
||||
return box.min, box.max
|
||||
|
||||
|
||||
def _spawn(mesh, location, label, material=None, parent_collection=None):
|
||||
actor = unreal.EditorLevelLibrary.spawn_actor_from_class(
|
||||
unreal.StaticMeshActor, location)
|
||||
if actor is None:
|
||||
log(f"could not spawn {label}")
|
||||
return None
|
||||
actor.set_actor_label(label)
|
||||
comp = actor.static_mesh_component
|
||||
comp.set_static_mesh(mesh)
|
||||
comp.set_mobility(unreal.ComponentMobility.STATIC)
|
||||
comp.set_collision_enabled(unreal.CollisionEnabled.QUERY_AND_PHYSICS)
|
||||
comp.set_collision_profile_name("BlockAll")
|
||||
if material:
|
||||
comp.set_material(0, material)
|
||||
return actor
|
||||
|
||||
|
||||
def apply_to_actor(actor, kit, materials=None, prefix=None):
|
||||
"""Lay the kit out over one building actor's street face.
|
||||
|
||||
Returns the number of pieces placed. The building's own mesh stays in place
|
||||
as the wall the pieces sit on.
|
||||
"""
|
||||
comp = actor.get_component_by_class(unreal.StaticMeshComponent)
|
||||
if not comp or not comp.static_mesh:
|
||||
return 0
|
||||
# Labels must be unique per building, otherwise two buildings' pieces share a
|
||||
# label and the outliner (and any label-based lookup) becomes ambiguous.
|
||||
slug = actor.get_actor_label().replace("Frontage_SM_", "").replace("North_", "").lower()
|
||||
prefix = prefix or f"Kit_{slug}_"
|
||||
# WORLD-space bounds. The imported meshes carry a building-line-local offset in
|
||||
# their own vertices and the demo builder then positions the actor, so the
|
||||
# asset's bounding box is NOT world space: using it put every kit piece about
|
||||
# 12 km east of its building, floating in empty ground where nothing could see
|
||||
# it. get_actor_bounds() includes the actor transform.
|
||||
origin_w, extent_w = actor.get_actor_bounds(False)
|
||||
mn = unreal.Vector(origin_w.x - extent_w.x, origin_w.y - extent_w.y, origin_w.z - extent_w.z)
|
||||
mx = unreal.Vector(origin_w.x + extent_w.x, origin_w.y + extent_w.y, origin_w.z + extent_w.z)
|
||||
origin = unreal.Vector(0.0, 0.0, 0.0)
|
||||
width = mx.x - mn.x
|
||||
height = mx.z - mn.z
|
||||
if width < BAY_CM * 0.75 or height < STOREY_CM:
|
||||
log(f"{actor.get_actor_label()}: too small for the kit "
|
||||
f"({width / 100:.1f} x {height / 100:.1f} m)")
|
||||
return 0
|
||||
|
||||
bays = max(1, int(round(width / BAY_CM)))
|
||||
bay_w = width / bays # actual bay width for this building
|
||||
# Street face: the frontage runs along X, and the street is at smaller Y.
|
||||
face_y = mn.y
|
||||
base_z = mn.z
|
||||
storeys = max(1, int((height - SHOP_FLOOR_CM) // STOREY_CM))
|
||||
|
||||
def kit_material(name):
|
||||
if materials is None:
|
||||
return None
|
||||
want = KIT_MATERIALS.get(name, KIT_MATERIAL_FALLBACK)
|
||||
return materials.get(want)
|
||||
|
||||
placed = 0
|
||||
for bay in range(bays):
|
||||
cx = mn.x + bay_w * (bay + 0.5)
|
||||
# ground floor: shopfront
|
||||
for piece, dx in (("Kit_ShopPier_L", -bay_w / 2 + 30.0),
|
||||
("Kit_ShopPier_R", bay_w / 2 - 30.0)):
|
||||
if piece in kit:
|
||||
if _spawn(kit[piece], unreal.Vector(cx + dx, face_y, base_z),
|
||||
f"{prefix}shop_pier_{bay}", kit_material(piece)):
|
||||
placed += 1
|
||||
if "Kit_ShopGlass" in kit:
|
||||
if _spawn(kit["Kit_ShopGlass"], unreal.Vector(cx, face_y, base_z),
|
||||
f"{prefix}shop_glass_{bay}", kit_material("Kit_ShopGlass")):
|
||||
placed += 1
|
||||
# upper storeys: window bays
|
||||
for storey in range(storeys):
|
||||
if "Kit_WindowBay" in kit:
|
||||
z = base_z + SHOP_FLOOR_CM + STOREY_CM * storey + 65.0
|
||||
if _spawn(kit["Kit_WindowBay"], unreal.Vector(cx, face_y, z),
|
||||
f"{prefix}window_{bay}_{storey}", kit_material("Kit_WindowBay")):
|
||||
placed += 1
|
||||
# pilaster on the left boundary of every bay
|
||||
if "Kit_Pilaster" in kit:
|
||||
if _spawn(kit["Kit_Pilaster"],
|
||||
unreal.Vector(mn.x + bay_w * bay, face_y, base_z),
|
||||
f"{prefix}pilaster_{bay}", kit_material("Kit_Pilaster")):
|
||||
placed += 1
|
||||
|
||||
# cornice under the parapet, coping on top
|
||||
if "Kit_Cornice" in kit:
|
||||
for bay in range(bays):
|
||||
cx = mn.x + bay_w * (bay + 0.5)
|
||||
if _spawn(kit["Kit_Cornice"],
|
||||
unreal.Vector(cx, face_y, base_z + height - 145.0),
|
||||
f"{prefix}cornice_{bay}", kit_material("Kit_Cornice")):
|
||||
placed += 1
|
||||
if "Kit_Parapet" in kit:
|
||||
for bay in range(bays):
|
||||
cx = mn.x + bay_w * (bay + 0.5)
|
||||
if _spawn(kit["Kit_Parapet"],
|
||||
unreal.Vector(cx, face_y, base_z + height - 90.0),
|
||||
f"{prefix}parapet_{bay}", kit_material("Kit_Parapet")):
|
||||
placed += 1
|
||||
|
||||
log(f"{actor.get_actor_label()}: {bays} bays x {storeys} storeys, "
|
||||
f"{width / 100:.1f} x {height / 100:.1f} m -> {placed} pieces")
|
||||
return placed
|
||||
|
||||
|
||||
def main() -> None:
|
||||
kit = load_kit()
|
||||
if not kit:
|
||||
log(f"no kit meshes under {KIT} — run Tools/ue_import_facade_kit.py first")
|
||||
return
|
||||
log(f"kit meshes: {len(kit)}")
|
||||
|
||||
# Site materials built by Tools/ue_build_demo_map.py.
|
||||
materials = {}
|
||||
for name in ("M_FacadeStone", "M_FacadeStoneShadow", "M_FacadeGlass",
|
||||
"M_FacadeBanner", "M_Stucco", "M_Concrete"):
|
||||
asset = unreal.EditorAssetLibrary.load_asset(f"/Game/Hollywood/Site/{name}")
|
||||
if asset:
|
||||
materials[name] = asset
|
||||
log(f"kit materials resolved: {len(materials)}/6")
|
||||
|
||||
level = "/Game/Hollywood/DemoMap"
|
||||
if not unreal.EditorLevelLibrary.load_level(level):
|
||||
log(f"could not load {level}")
|
||||
return
|
||||
# The map is LFS-tracked and checks out read-only.
|
||||
content = unreal.Paths.project_content_dir()
|
||||
ue_common.make_writable(os.path.join(content, "Hollywood/DemoMap.umap"))
|
||||
|
||||
# Remove a previous application so re-running does not stack duplicates.
|
||||
removed = 0
|
||||
for actor in list(unreal.EditorLevelLibrary.get_all_level_actors()):
|
||||
if actor.get_actor_label().startswith("Kit_"):
|
||||
unreal.EditorLevelLibrary.destroy_actor(actor)
|
||||
removed += 1
|
||||
log(f"cleared {removed} previous kit actors")
|
||||
|
||||
targets = [a for a in unreal.EditorLevelLibrary.get_all_level_actors()
|
||||
if a.get_actor_label().startswith(("Frontage_", "North_"))]
|
||||
log(f"frontage actors: {len(targets)}")
|
||||
total = 0
|
||||
for actor in targets:
|
||||
total += apply_to_actor(actor, kit, materials)
|
||||
log(f"placed {total} kit pieces")
|
||||
|
||||
saved = unreal.EditorLoadingAndSavingUtils.save_map(
|
||||
unreal.EditorLevelLibrary.get_editor_world(), level)
|
||||
log(f"save_map {level}: {saved}")
|
||||
|
||||
|
||||
try:
|
||||
main()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
import traceback
|
||||
log(f"FAILED: {type(exc).__name__}: {exc}")
|
||||
log(traceback.format_exc())
|
||||
|
||||
os.makedirs(os.path.dirname(RESULT), exist_ok=True)
|
||||
with open(RESULT, "w", encoding="utf-8") as handle:
|
||||
handle.write("\n".join(lines) + "\n")
|
||||
+223
-16
@@ -16,10 +16,13 @@
|
||||
# deterministically instead of stacking duplicates.
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import unreal
|
||||
|
||||
MAP = "/Game/Hollywood/DemoMap"
|
||||
# Repository root, so the texture generator can be invoked by path.
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
RESULT = "/tmp/gigline/demo-map.txt"
|
||||
lines = []
|
||||
|
||||
@@ -50,7 +53,46 @@ def spawn(cls, loc, label, rot=None):
|
||||
return actor
|
||||
|
||||
|
||||
def ensure_material(mat_name, spec):
|
||||
TEXTURE_DIR = "/Game/Hollywood/Textures"
|
||||
|
||||
|
||||
def import_texture(png_path, asset_name):
|
||||
"""Import a generated PNG as a Texture2D (idempotent: replaces the asset)."""
|
||||
if not os.path.exists(png_path):
|
||||
log(f"texture missing: {png_path}")
|
||||
return None
|
||||
task = unreal.AssetImportTask()
|
||||
task.filename = png_path
|
||||
task.destination_path = TEXTURE_DIR
|
||||
task.destination_name = asset_name
|
||||
task.automated = True
|
||||
task.save = True
|
||||
task.replace_existing = True
|
||||
ui = unreal.TextureImportUI() if hasattr(unreal, "TextureImportUI") else None
|
||||
if ui:
|
||||
task.options = ui
|
||||
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
|
||||
return unreal.EditorAssetLibrary.load_asset(f"{TEXTURE_DIR}/{asset_name}")
|
||||
|
||||
|
||||
def ensure_textures():
|
||||
"""Generate/point at the facade maps and import them. Returns {name: asset}."""
|
||||
out_dir = "/tmp/gigline/textures"
|
||||
gen = os.path.join(ROOT, "Tools", "generate_facade_textures.py")
|
||||
if os.path.exists(gen) and not os.path.exists(os.path.join(out_dir, "T_StoneAshlar_BC.png")):
|
||||
import subprocess
|
||||
subprocess.run([sys.executable, gen, "--out", out_dir, "--size", "512"], check=False)
|
||||
imported = {}
|
||||
for name in ("T_StoneAshlar_BC", "T_StoneAshlar_R", "T_Stucco_BC", "T_Stucco_R",
|
||||
"T_Asphalt_BC", "T_Asphalt_R", "T_Concrete_BC", "T_Concrete_R"):
|
||||
asset = import_texture(os.path.join(out_dir, name + ".png"), name)
|
||||
if asset:
|
||||
imported[name] = asset
|
||||
log(f"textures imported: {len(imported)}/8")
|
||||
return imported
|
||||
|
||||
|
||||
def ensure_material(mat_name, spec, textures=None):
|
||||
"""Create/reuse a look-developed material under /Game/Hollywood/Site.
|
||||
|
||||
Blender's flat diffuse colours do not survive the FBX -> Unreal import (the
|
||||
@@ -65,6 +107,13 @@ def ensure_material(mat_name, spec):
|
||||
|
||||
spec keys: dark, bright (RGB tuples), roughness, specular, noise_scale
|
||||
(world units per noise cycle; smaller = larger features).
|
||||
|
||||
Optional `courses_m` adds **masonry coursing**: horizontal joint lines at a
|
||||
given course height in world metres, plus a per-course horizontal offset so
|
||||
the vertical joints stagger like real ashlar. Optional `joint_tint` is the
|
||||
colour the joints darken toward, and `roughness_variation` lerps roughness
|
||||
between `roughness` and a rougher value using the same noise, so the surface
|
||||
does not read as one flat specular value.
|
||||
"""
|
||||
path = f"/Game/Hollywood/Site/{mat_name}"
|
||||
if unreal.EditorAssetLibrary.does_asset_exist(path):
|
||||
@@ -88,6 +137,20 @@ def ensure_material(mat_name, spec):
|
||||
n.set_editor_property("r", float(value))
|
||||
return n
|
||||
|
||||
def link(src, dst, pin=""):
|
||||
"""Connect, and say so when it fails.
|
||||
|
||||
`connect_material_expressions` returns False rather than raising, so an
|
||||
unknown pin name silently leaves an input unconnected and the material
|
||||
compiles with a default value — a coursing mask of 0 is invisible, which
|
||||
is exactly how the first attempt failed.
|
||||
"""
|
||||
ok = lib.connect_material_expressions(src, "", dst, pin)
|
||||
if not ok:
|
||||
log(f"material {mat_name}: FAILED to connect {src.__class__.__name__} "
|
||||
f"-> {dst.__class__.__name__}.{pin or '(default)'}")
|
||||
return ok
|
||||
|
||||
# world position -> noise -> lerp(dark, bright) -> base colour
|
||||
wp = node(unreal.MaterialExpressionWorldPosition)
|
||||
scale = scalar(spec.get("noise_scale", 0.004))
|
||||
@@ -108,10 +171,95 @@ def ensure_material(mat_name, spec):
|
||||
lib.connect_material_expressions(c3(spec["dark"]), "", lerp, "A")
|
||||
lib.connect_material_expressions(c3(spec["bright"]), "", lerp, "B")
|
||||
lib.connect_material_expressions(noise, "", lerp, "Alpha")
|
||||
lib.connect_material_property(lerp, "", unreal.MaterialProperty.MP_BASE_COLOR)
|
||||
|
||||
lib.connect_material_property(
|
||||
scalar(spec.get("roughness", 0.85)), "", unreal.MaterialProperty.MP_ROUGHNESS)
|
||||
# --- optional masonry coursing -----------------------------------------
|
||||
# A world-space course mask: the fractional part of Z / course height is a
|
||||
# sawtooth per course; zeroing it gives a joint line every course. The row
|
||||
# index shifts each course sideways by half a brick, so vertical joints
|
||||
# stagger. All of it is world-space, so every building shares the coursing
|
||||
# without needing its own UVs.
|
||||
base_color_expr = lerp
|
||||
if spec.get("courses_m"):
|
||||
# WorldPosition is in CENTIMETRES, so a course height given in metres must
|
||||
# be scaled by 100 here; getting this wrong makes the coursing 100x too
|
||||
# fine to see at all (the first attempt did exactly that).
|
||||
course_cm = float(spec["courses_m"]) * 100.0
|
||||
row = node(unreal.MaterialExpressionDivide)
|
||||
link(wp, row, "A")
|
||||
link(c3((course_cm, course_cm, course_cm)), row, "B")
|
||||
saw = node(unreal.MaterialExpressionFrac)
|
||||
link(row, saw)
|
||||
# Joint mask without a comparison node (UE has no Less): 1 - saw/joint is
|
||||
# 1 at the course boundary and 0 once past the joint width; clamped to 0..1
|
||||
# so it is a linear band rather than a hard step.
|
||||
div = node(unreal.MaterialExpressionDivide)
|
||||
link(saw, div, "A")
|
||||
# joint_width is a fraction of one course (0.11 ~= a 6 cm joint on a
|
||||
# 55 cm course), which is the space `saw` lives in after the divide.
|
||||
link(scalar(spec.get("joint_width", 0.11)), div, "B")
|
||||
inv = node(unreal.MaterialExpressionOneMinus)
|
||||
link(div, inv)
|
||||
joint = node(unreal.MaterialExpressionClamp)
|
||||
link(inv, joint)
|
||||
tinted = node(unreal.MaterialExpressionLinearInterpolate)
|
||||
link(lerp, tinted, "A")
|
||||
link(c3(spec.get("joint_tint", (0.34, 0.31, 0.27))), tinted, "B")
|
||||
link(joint, tinted, "Alpha")
|
||||
base_color_expr = tinted
|
||||
|
||||
# If a texture set is supplied for this material, sample it world-aligned and
|
||||
# use it instead of the graph-only coursing: the map carries the coursing and
|
||||
# the grain, so overlaying both would double the pattern.
|
||||
tex = (textures or {}).get(spec.get("texture", ""))
|
||||
if tex:
|
||||
bc = (textures or {}).get(spec.get("texture", "") + "_BC")
|
||||
# world position (cm) -> UV: one texture tile every tile_cm centimetres
|
||||
tile_cm = float(spec.get("tile_cm", 300.0))
|
||||
uv_scale = node(unreal.MaterialExpressionMultiply)
|
||||
link(wp, uv_scale, "A")
|
||||
link(scalar(1.0 / tile_cm), uv_scale, "B")
|
||||
mask_xy = node(unreal.MaterialExpressionComponentMask)
|
||||
for pin in ("R", "G"):
|
||||
try:
|
||||
mask_xy.set_editor_property(pin, True)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
try:
|
||||
mask_xy.set_editor_property("B", False)
|
||||
mask_xy.set_editor_property("A", False)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
link(uv_scale, mask_xy)
|
||||
if bc:
|
||||
sample = node(unreal.MaterialExpressionTextureSample)
|
||||
sample.set_editor_property("texture", bc)
|
||||
link(mask_xy, sample, "UVs")
|
||||
lib.connect_material_property(sample, "RGB",
|
||||
unreal.MaterialProperty.MP_BASE_COLOR)
|
||||
base_color_expr = sample
|
||||
r_map = (textures or {}).get(spec.get("texture", "") + "_R")
|
||||
if r_map:
|
||||
r_sample = node(unreal.MaterialExpressionTextureSample)
|
||||
r_sample.set_editor_property("texture", r_map)
|
||||
link(mask_xy, r_sample, "UVs")
|
||||
lib.connect_material_property(r_sample, "R",
|
||||
unreal.MaterialProperty.MP_ROUGHNESS)
|
||||
|
||||
lib.connect_material_property(base_color_expr, "", unreal.MaterialProperty.MP_BASE_COLOR)
|
||||
|
||||
# Roughness: lerp(base, base + variation, noise) so the highlight breaks up.
|
||||
variation = float(spec.get("roughness_variation", 0.0))
|
||||
if variation > 0.0:
|
||||
rough_lerp = node(unreal.MaterialExpressionLinearInterpolate)
|
||||
lib.connect_material_expressions(
|
||||
scalar(spec.get("roughness", 0.85)), "", rough_lerp, "A")
|
||||
lib.connect_material_expressions(
|
||||
scalar(min(1.0, spec.get("roughness", 0.85) + variation)), "", rough_lerp, "B")
|
||||
lib.connect_material_expressions(noise, "", rough_lerp, "Alpha")
|
||||
lib.connect_material_property(rough_lerp, "", unreal.MaterialProperty.MP_ROUGHNESS)
|
||||
else:
|
||||
lib.connect_material_property(
|
||||
scalar(spec.get("roughness", 0.85)), "", unreal.MaterialProperty.MP_ROUGHNESS)
|
||||
try:
|
||||
lib.connect_material_property(
|
||||
scalar(spec.get("specular", 0.3)), "", unreal.MaterialProperty.MP_SPECULAR)
|
||||
@@ -269,19 +417,47 @@ def build():
|
||||
# Look-developed surface materials (see ensure_material for the graph).
|
||||
MATERIAL_SPECS = {
|
||||
"M_Asphalt": dict(dark=(0.040, 0.040, 0.044), bright=(0.072, 0.072, 0.078),
|
||||
roughness=0.92, specular=0.22, noise_scale=0.006),
|
||||
roughness=0.92, specular=0.22, noise_scale=0.006,
|
||||
texture="T_Asphalt", tile_cm=900.0),
|
||||
"M_Concrete": dict(dark=(0.44, 0.42, 0.39), bright=(0.56, 0.54, 0.50),
|
||||
roughness=0.88, specular=0.28, noise_scale=0.004),
|
||||
roughness=0.88, specular=0.28, noise_scale=0.004,
|
||||
texture="T_Concrete", tile_cm=250.0),
|
||||
"M_KerbConcrete": dict(dark=(0.47, 0.45, 0.42), bright=(0.60, 0.58, 0.54),
|
||||
roughness=0.85, specular=0.30, noise_scale=0.008),
|
||||
"M_Ground": dict(dark=(0.22, 0.20, 0.17), bright=(0.33, 0.30, 0.26),
|
||||
roughness=0.98, specular=0.15, noise_scale=0.003),
|
||||
"M_RoadPaint": dict(dark=(0.74, 0.74, 0.72), bright=(0.86, 0.86, 0.84),
|
||||
roughness=0.62, specular=0.35, noise_scale=0.02),
|
||||
"M_FacadeStone": dict(dark=(0.62, 0.58, 0.50), bright=(0.74, 0.70, 0.62),
|
||||
roughness=0.72, specular=0.32, noise_scale=0.005),
|
||||
"M_Stucco": dict(dark=(0.52, 0.48, 0.42), bright=(0.66, 0.62, 0.55),
|
||||
roughness=0.86, specular=0.25, noise_scale=0.004),
|
||||
# Ashlar coursing at 0.55 m (a 0.10 m band joint), the reading the 2009
|
||||
# facade photograph gives for the hero's stone; roughness breaks up so the
|
||||
# wall is not one specular plane.
|
||||
"M_FacadeStone": dict(dark=(0.62, 0.58, 0.50), bright=(0.76, 0.72, 0.64),
|
||||
roughness=0.70, specular=0.32, noise_scale=0.005,
|
||||
courses_m=0.55, joint_width=0.11,
|
||||
joint_tint=(0.40, 0.37, 0.32), roughness_variation=0.12,
|
||||
texture="T_StoneAshlar", tile_cm=440.0),
|
||||
# Rendered/stucco frontage: no coursing, but a fine tooth and a wider
|
||||
# roughness range than before, because flat stucco is what read as plastic.
|
||||
"M_Stucco": dict(dark=(0.50, 0.46, 0.40), bright=(0.68, 0.64, 0.57),
|
||||
roughness=0.76, specular=0.25, noise_scale=0.0025,
|
||||
roughness_variation=0.20, texture="T_Stucco", tile_cm=260.0),
|
||||
# Stone in shadow: the hero's recessed wall behind the colonnade. Same
|
||||
# coursing as the face, a stop darker, so depth reads without a light.
|
||||
"M_FacadeStoneShadow": dict(dark=(0.40, 0.37, 0.32), bright=(0.52, 0.49, 0.43),
|
||||
roughness=0.78, specular=0.26, noise_scale=0.005,
|
||||
courses_m=0.55, joint_width=0.11,
|
||||
joint_tint=(0.28, 0.26, 0.23),
|
||||
roughness_variation=0.10),
|
||||
# Glazing: low roughness, strong specular. A plausible IOR for glass sits
|
||||
# near 1.5, which UE expresses through specular ~0.5-0.6 on an opaque
|
||||
# surface (there is no refraction pass at blockout fidelity).
|
||||
"M_FacadeGlass": dict(dark=(0.030, 0.045, 0.055), bright=(0.075, 0.095, 0.110),
|
||||
roughness=0.14, specular=0.62, noise_scale=0.01,
|
||||
roughness_variation=0.06),
|
||||
# The JIMMY KIMMEL banners: the one saturated surface on the elevation.
|
||||
"M_FacadeBanner": dict(dark=(0.32, 0.05, 0.07), bright=(0.55, 0.10, 0.12),
|
||||
roughness=0.72, specular=0.22, noise_scale=0.02,
|
||||
roughness_variation=0.12),
|
||||
}
|
||||
SURFACE_MATERIALS = {
|
||||
"Terrain_DEM": "M_Ground",
|
||||
@@ -292,9 +468,10 @@ def build():
|
||||
"Kerb_North": "M_KerbConcrete",
|
||||
"Crossing_Markings": "M_RoadPaint",
|
||||
}
|
||||
textures = ensure_textures()
|
||||
built = {}
|
||||
for mat_name, spec in MATERIAL_SPECS.items():
|
||||
built[mat_name] = ensure_material(mat_name, spec)
|
||||
built[mat_name] = ensure_material(mat_name, spec, textures)
|
||||
|
||||
imported = 0
|
||||
for name, mat_name in SURFACE_MATERIALS.items():
|
||||
@@ -337,15 +514,45 @@ def build():
|
||||
LABEL_MATERIALS = {"Hero_Facade": "M_FacadeStone",
|
||||
"North_TCL_Chinese": "M_Stucco",
|
||||
"North_Madame_Tussauds": "M_Stucco"}
|
||||
|
||||
# Assign per SLOT, by what the slot's imported material is called, not just
|
||||
# slot 0: the hero mesh's slot 0 is the banner, so the stone, the shadowed
|
||||
# stone and the glazing were all still wearing their flat Blender materials.
|
||||
SLOT_RULES = (
|
||||
("glass", "M_FacadeGlass"),
|
||||
("banner", "M_FacadeBanner"),
|
||||
("stoneshadow", "M_FacadeStoneShadow"),
|
||||
("stone", "M_FacadeStone"),
|
||||
("roof", "M_Concrete"),
|
||||
("wall", "M_Stucco"),
|
||||
)
|
||||
|
||||
def slot_material_name(slot_mesh_material):
|
||||
if not slot_mesh_material:
|
||||
return None
|
||||
key = slot_mesh_material.get_name().lower()
|
||||
for needle, target in SLOT_RULES:
|
||||
if needle in key:
|
||||
return target
|
||||
return None
|
||||
|
||||
assigned = 0
|
||||
for actor in unreal.EditorLevelLibrary.get_all_level_actors():
|
||||
mat_name = LABEL_MATERIALS.get(actor.get_actor_label())
|
||||
if not mat_name or mat_name not in built:
|
||||
continue
|
||||
fallback = LABEL_MATERIALS.get(actor.get_actor_label())
|
||||
comp = actor.get_component_by_class(unreal.StaticMeshComponent)
|
||||
if comp and comp.static_mesh:
|
||||
comp.set_material(0, built[mat_name])
|
||||
if not comp or not comp.static_mesh:
|
||||
continue
|
||||
slots = comp.static_mesh.get_editor_property("static_materials")
|
||||
touched = 0
|
||||
for index, slot in enumerate(slots):
|
||||
mesh_mat = slot.get_editor_property("material_interface")
|
||||
target = slot_material_name(mesh_mat) or fallback
|
||||
if target and target in built:
|
||||
comp.set_material(index, built[target])
|
||||
touched += 1
|
||||
if touched:
|
||||
assigned += 1
|
||||
log(f"materials {actor.get_actor_label()}: {touched}/{len(slots)} slots")
|
||||
log(f"assigned look materials to {assigned} facade/background actors")
|
||||
|
||||
# --- surrounding block frontage ---------------------------------------
|
||||
|
||||
@@ -240,9 +240,9 @@ try:
|
||||
# verticals stay parallel. The old shot looked up ~4 deg from 4.2 m at a
|
||||
# 7.6 m target, which converged the columns — the one thing an
|
||||
# architectural elevation must not do (archviz: "straight verticals").
|
||||
("hero-facade-front", unreal.Vector(hero_x + 200, -3200, 760), unreal.Vector(hero_x, hero_y + 400, 760)),
|
||||
("hero-facade-front", unreal.Vector(hero_x + 200, -3200, 760), unreal.Vector(hero_x, hero_y + 400, 760), 42.0),
|
||||
# oblique from the north-west, reading the colonnade in depth
|
||||
("hero-facade-oblique", unreal.Vector(hero_x - 2200, -3400, 620), unreal.Vector(hero_x + 300, hero_y, 700)),
|
||||
("hero-facade-oblique", unreal.Vector(hero_x - 2200, -3400, 620), unreal.Vector(hero_x + 300, hero_y, 700), 55.0),
|
||||
]
|
||||
# --- review shot cameras + save -------------------------------------------
|
||||
# Order matters: the review scene is staged in the default (unsaved) world.
|
||||
@@ -256,6 +256,12 @@ try:
|
||||
"shot_rotations",
|
||||
[unreal.MathLibrary.find_look_at_rotation(c[1], c[2]) for c in cams])
|
||||
shots.set_editor_property("shot_names", [c[0] for c in cams])
|
||||
# Optional 4th element: field of view in degrees. 0 keeps the default 90.
|
||||
# Elevations shot from across the street use a long lens so the building
|
||||
# fills the frame without pitching the camera (archviz: straight
|
||||
# verticals, telephoto for elevations).
|
||||
shots.set_editor_property(
|
||||
"shot_field_of_view", [float(c[3]) if len(c) > 3 else 0.0 for c in cams])
|
||||
shots.set_editor_property("quit_when_done", True)
|
||||
log(f"staged: ShotSequencer with {len(cams)} cameras: {', '.join(c[0] for c in cams)}")
|
||||
|
||||
@@ -282,7 +288,7 @@ try:
|
||||
# the colonnade's underside.
|
||||
# Level horizon for the same reason: a block elevation is read by its
|
||||
# verticals, and a pitched camera makes them lean.
|
||||
("demo-spawn-view", unreal.Vector(-13336, -3100, 620), unreal.Vector(-13336, 1300, 620)),
|
||||
("demo-spawn-view", unreal.Vector(-13336, -3100, 620), unreal.Vector(-13336, 1300, 620), 48.0),
|
||||
# Street level, from the north sidewalk: the old camera sat at Y -400,
|
||||
# inside the facade body (which spans Y -1457..-200), so it looked at
|
||||
# the unlit back face. Y -2600 is on the sidewalk in front of the lit
|
||||
@@ -309,6 +315,9 @@ try:
|
||||
seq.set_editor_property("shot_rotations",
|
||||
[unreal.MathLibrary.find_look_at_rotation(c[1], c[2]) for c in demo_shots])
|
||||
seq.set_editor_property("shot_names", [c[0] for c in demo_shots])
|
||||
seq.set_editor_property(
|
||||
"shot_field_of_view",
|
||||
[float(c[3]) if len(c) > 3 else 0.0 for c in demo_shots])
|
||||
# quit after the demo shots so a dedicated DemoMap capture run
|
||||
# terminates cleanly (render_screenshots.sh runs both maps).
|
||||
seq.set_editor_property("quit_when_done", True)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Import the façade module kit into Unreal: /Game/Hollywood/Kit/*.
|
||||
|
||||
Pairs with `Tools/facade_kit_build.py`. Each piece is imported as its own static
|
||||
mesh at the module's origin (bottom-front centre), so a building can place a
|
||||
piece by bay index and storey index with a single offset.
|
||||
|
||||
UnrealEditor-Cmd <uproject> -run=pythonscript \
|
||||
-script=Tools/ue_import_facade_kit.py -nullrhi -unattended -nopause -stdout
|
||||
|
||||
Result file: /tmp/gigline/kit-import.txt (the commandlet swallows print()).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import unreal
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import ue_common # noqa: E402
|
||||
|
||||
SRC = "/tmp/gigline/kit"
|
||||
DEST = "/Game/Hollywood/Kit"
|
||||
RESULT = "/tmp/gigline/kit-import.txt"
|
||||
|
||||
lines: list[str] = []
|
||||
|
||||
|
||||
def log(message: str) -> None:
|
||||
lines.append(message)
|
||||
print(message)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if not os.path.isdir(SRC):
|
||||
log(f"MISSING source directory {SRC} — run Tools/facade_kit_build.py first")
|
||||
return
|
||||
files = sorted(f for f in os.listdir(SRC) if f.lower().endswith(".fbx"))
|
||||
if not files:
|
||||
log(f"no FBX files in {SRC}")
|
||||
return
|
||||
|
||||
# Drop kit assets that the current build no longer produces (the pieces were
|
||||
# renamed to follow the SM_ convention), so the folder cannot accumulate two
|
||||
# generations of the same piece.
|
||||
expected = {os.path.splitext(f)[0] for f in files}
|
||||
for existing in unreal.EditorAssetLibrary.list_assets(DEST, recursive=False):
|
||||
name = existing.split("/")[-1].split(".")[0]
|
||||
if name not in expected:
|
||||
unreal.EditorAssetLibrary.delete_asset(f"{DEST}/{name}")
|
||||
log(f"removed stale kit asset {name}")
|
||||
|
||||
for filename in files:
|
||||
asset_name = os.path.splitext(filename)[0]
|
||||
task = unreal.AssetImportTask()
|
||||
task.filename = os.path.join(SRC, filename)
|
||||
task.destination_path = DEST
|
||||
task.destination_name = asset_name
|
||||
task.automated = True
|
||||
task.save = True
|
||||
task.replace_existing = True
|
||||
ui = unreal.FbxImportUI()
|
||||
ui.import_mesh = True
|
||||
ui.import_materials = True
|
||||
ui.import_textures = False
|
||||
ui.import_as_skeletal = False
|
||||
smd = ui.static_mesh_import_data
|
||||
smd.import_uniform_scale = 1.0
|
||||
smd.combine_meshes = True
|
||||
smd.convert_scene_unit = False
|
||||
smd.force_front_x_axis = False
|
||||
smd.normal_import_method = unreal.FBXNormalImportMethod.FBXNIM_IMPORT_NORMALS
|
||||
# Kit pieces are thin boxes and panes: complex-as-simple keeps the
|
||||
# collision on the real faces instead of a fat convex hull that would
|
||||
# fence off the shopfront glazing.
|
||||
smd.auto_generate_collision = False
|
||||
task.options = ui
|
||||
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
|
||||
|
||||
mesh = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{asset_name}")
|
||||
if mesh is None:
|
||||
log(f"{asset_name}: IMPORT FAILED")
|
||||
continue
|
||||
# The FBX import also creates an M_Kit_* material per piece. The demo
|
||||
# assigns real site materials at assembly time, so those are clutter.
|
||||
for slot in mesh.get_editor_property("static_materials"):
|
||||
mat = slot.get_editor_property("material_interface")
|
||||
if mat and mat.get_name().startswith("M_Kit_"):
|
||||
unreal.EditorAssetLibrary.delete_asset(mat.get_path_name().split(".")[0])
|
||||
unreal.GigLineAssetTools.set_complex_collision_as_simple(mesh, True)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(mesh)
|
||||
box = mesh.get_bounding_box()
|
||||
mn, mx = box.min, box.max
|
||||
log(f"{asset_name:20} size=({mx.x - mn.x:6.1f}, {mx.y - mn.y:6.1f}, "
|
||||
f"{mx.z - mn.z:6.1f}) cm min=({mn.x:6.1f}, {mn.y:6.1f}, {mn.z:6.1f}) "
|
||||
f"collision={unreal.GigLineAssetTools.get_collision_complexity(mesh)}")
|
||||
|
||||
log(f"imported {len(files)} kit piece(s) into {DEST}")
|
||||
|
||||
|
||||
try:
|
||||
main()
|
||||
except Exception as exc: # noqa: BLE001 - surface the failure in the result file
|
||||
import traceback
|
||||
log(f"FAILED: {type(exc).__name__}: {exc}")
|
||||
log(traceback.format_exc())
|
||||
|
||||
os.makedirs(os.path.dirname(RESULT), exist_ok=True)
|
||||
with open(RESULT, "w", encoding="utf-8") as handle:
|
||||
handle.write("\n".join(lines) + "\n")
|
||||
@@ -12,6 +12,7 @@
|
||||
# -script=<repo>/Tools/ue_import_north_buildings.py -nullrhi -unattended -nopause -stdout
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import unreal
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Import and stage the El Capitan Theatre trim (marquee + blade sign).
|
||||
|
||||
Two steps, both idempotent:
|
||||
|
||||
1. Import `/tmp/gigline/theatre/*.fbx` into `/Game/Hollywood/Theatre`.
|
||||
2. Stage one static-mesh actor per piece at the **origin**, because the meshes are
|
||||
built in world coordinates (same convention as the block frontage and the north
|
||||
buildings). Anything that has to be positioned by arithmetic is a bug waiting to
|
||||
happen — the kit was once placed 12 km away that way.
|
||||
|
||||
UnrealEditor-Cmd <uproject> -run=pythonscript \
|
||||
-script=Tools/ue_stage_theatre_trim.py -nullrhi -unattended -nopause -stdout
|
||||
|
||||
Result file: `/tmp/gigline/theatre-import.txt`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import unreal
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import ue_common # noqa: E402
|
||||
|
||||
SRC = "/tmp/gigline/theatre"
|
||||
DEST = "/Game/Hollywood/Theatre"
|
||||
MAP = "/Game/Hollywood/DemoMap"
|
||||
RESULT = "/tmp/gigline/theatre-import.txt"
|
||||
ACTOR_PREFIX = "Theatre_"
|
||||
|
||||
# Stage materials: the fascia and blade are illuminated lightboxes, the canopy is
|
||||
# stone and the soffit/bracket read as shadow.
|
||||
MATERIALS = {
|
||||
"SM_Trim_MarqueeCanopy": "M_FacadeStone",
|
||||
"SM_Trim_MarqueeFascia": "M_FacadeBanner",
|
||||
"SM_Trim_MarqueeSoffit": "M_FacadeStoneShadow",
|
||||
"SM_Trim_BladeSign": "M_FacadeBanner",
|
||||
"SM_Trim_BladeSignFrame": "M_FacadeStone",
|
||||
"SM_Trim_BladeSignBracket": "M_FacadeStoneShadow",
|
||||
}
|
||||
|
||||
lines: list[str] = []
|
||||
|
||||
|
||||
def log(message: str) -> None:
|
||||
lines.append(message)
|
||||
print(message)
|
||||
|
||||
|
||||
def import_pieces() -> list[str]:
|
||||
if not os.path.isdir(SRC):
|
||||
log(f"MISSING {SRC} — run Tools/theatre_trim_build.py first")
|
||||
return []
|
||||
files = sorted(f for f in os.listdir(SRC) if f.lower().endswith(".fbx"))
|
||||
if not files:
|
||||
log(f"no FBX in {SRC}")
|
||||
return []
|
||||
|
||||
expected = {os.path.splitext(f)[0] for f in files}
|
||||
for existing in unreal.EditorAssetLibrary.list_assets(DEST, recursive=False):
|
||||
name = existing.split("/")[-1].split(".")[0]
|
||||
if name not in expected:
|
||||
unreal.EditorAssetLibrary.delete_asset(f"{DEST}/{name}")
|
||||
log(f"removed stale asset {name}")
|
||||
|
||||
for filename in files:
|
||||
asset_name = os.path.splitext(filename)[0]
|
||||
task = unreal.AssetImportTask()
|
||||
task.filename = os.path.join(SRC, filename)
|
||||
task.destination_path = DEST
|
||||
task.destination_name = asset_name
|
||||
task.automated = True
|
||||
task.save = True
|
||||
task.replace_existing = True
|
||||
ui = unreal.FbxImportUI()
|
||||
ui.import_mesh = True
|
||||
ui.import_materials = True
|
||||
ui.import_textures = False
|
||||
ui.import_as_skeletal = False
|
||||
smd = ui.static_mesh_import_data
|
||||
smd.import_uniform_scale = 1.0
|
||||
smd.combine_meshes = True
|
||||
smd.convert_scene_unit = False
|
||||
smd.force_front_x_axis = False
|
||||
smd.normal_import_method = unreal.FBXNormalImportMethod.FBXNIM_IMPORT_NORMALS
|
||||
# A projecting canopy over the pavement must block: the player should not be
|
||||
# able to walk through the fascia or clip up into the soffit.
|
||||
smd.auto_generate_collision = False
|
||||
task.options = ui
|
||||
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
|
||||
|
||||
mesh = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{asset_name}")
|
||||
if mesh is None:
|
||||
log(f"{asset_name}: IMPORT FAILED")
|
||||
continue
|
||||
unreal.GigLineAssetTools.set_complex_collision_as_simple(mesh, True)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(mesh)
|
||||
box = mesh.get_bounding_box()
|
||||
log(f"{asset_name:26} X {box.min.x:8.0f}..{box.max.x:7.0f} "
|
||||
f"Y {box.min.y:7.0f}..{box.max.y:7.0f} Z {box.min.z:6.0f}..{box.max.z:6.0f}")
|
||||
return [os.path.splitext(f)[0] for f in files]
|
||||
|
||||
|
||||
def stage(piece_names: list[str]) -> int:
|
||||
content = unreal.Paths.project_content_dir()
|
||||
ue_common.make_writable(os.path.join(content, "Hollywood/DemoMap.umap"))
|
||||
if not unreal.EditorLevelLibrary.load_level(MAP):
|
||||
log(f"could not load {MAP}")
|
||||
return 0
|
||||
|
||||
removed = 0
|
||||
for actor in list(unreal.EditorLevelLibrary.get_all_level_actors()):
|
||||
if actor.get_actor_label().startswith(ACTOR_PREFIX):
|
||||
unreal.EditorLevelLibrary.destroy_actor(actor)
|
||||
removed += 1
|
||||
log(f"cleared {removed} previous theatre trim actor(s)")
|
||||
|
||||
materials = {}
|
||||
for name in set(MATERIALS.values()):
|
||||
asset = unreal.EditorAssetLibrary.load_asset(f"/Game/Hollywood/Site/{name}")
|
||||
if asset:
|
||||
materials[name] = asset
|
||||
|
||||
placed = 0
|
||||
for piece in piece_names:
|
||||
mesh = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{piece}")
|
||||
if mesh is None:
|
||||
continue
|
||||
actor = unreal.EditorLevelLibrary.spawn_actor_from_class(
|
||||
unreal.StaticMeshActor, unreal.Vector(0.0, 0.0, 0.0))
|
||||
if actor is None:
|
||||
log(f"could not spawn {piece}")
|
||||
continue
|
||||
actor.set_actor_label(ACTOR_PREFIX + piece.replace("SM_Trim_", ""))
|
||||
comp = actor.static_mesh_component
|
||||
comp.set_static_mesh(mesh)
|
||||
comp.set_mobility(unreal.ComponentMobility.STATIC)
|
||||
comp.set_collision_enabled(unreal.CollisionEnabled.QUERY_AND_PHYSICS)
|
||||
comp.set_collision_profile_name("BlockAll")
|
||||
want = MATERIALS.get(piece)
|
||||
if want and want in materials:
|
||||
comp.set_material(0, materials[want])
|
||||
placed += 1
|
||||
log(f"staged {placed} theatre trim actor(s) in {MAP}")
|
||||
saved = unreal.EditorLoadingAndSavingUtils.save_map(
|
||||
unreal.EditorLevelLibrary.get_editor_world(), MAP)
|
||||
log(f"save_map {MAP}: {saved}")
|
||||
return placed
|
||||
|
||||
|
||||
def main() -> None:
|
||||
names = import_pieces()
|
||||
if names:
|
||||
stage(names)
|
||||
|
||||
|
||||
try:
|
||||
main()
|
||||
except Exception as exc: # noqa: BLE001 - surface in the result file
|
||||
import traceback
|
||||
log(f"FAILED: {type(exc).__name__}: {exc}")
|
||||
log(traceback.format_exc())
|
||||
|
||||
os.makedirs(os.path.dirname(RESULT), exist_ok=True)
|
||||
with open(RESULT, "w", encoding="utf-8") as handle:
|
||||
handle.write("\n".join(lines) + "\n")
|
||||
@@ -0,0 +1,389 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Iterate a building against the reference photography and the engine render.
|
||||
|
||||
The loop this tool implements:
|
||||
|
||||
```
|
||||
reference photograph ─┐
|
||||
├─ compare ─► metrics + feedback ─► blender-skill actions
|
||||
engine screenshot ─┘ │
|
||||
└─ next iteration (uncommitted)
|
||||
```
|
||||
|
||||
It answers three questions with numbers instead of opinions:
|
||||
|
||||
1. **Does the shot show what it was supposed to?** (`--verify`) — every capture
|
||||
has an expectation in `Tools/visual_targets.json`; a shot that is blank, the
|
||||
wrong size, or wildly unlike its expectation is reported as a failure.
|
||||
2. **How far is the render from the reference?** (`--compare`) — luminance,
|
||||
contrast, edge density, palette and 3×3 composition are measured on both and
|
||||
reported as deltas with a 0–1 similarity score.
|
||||
3. **What should be modelled next?** (`--feedback`) — the deltas are translated
|
||||
into concrete actions phrased for the Blender skill packs (silhouette, relief,
|
||||
value range, hue, framing).
|
||||
|
||||
Iterations are **not committed**: every artefact goes under
|
||||
`Art/reference/iterations/<iteration>/`, which is gitignored. Promote a shot into
|
||||
`Screenshots/` only after the owner confirms it.
|
||||
|
||||
python3 Tools/visual_loop.py --verify # gate the current set
|
||||
python3 Tools/visual_loop.py --compare --iteration 3 # measure vs references
|
||||
python3 Tools/visual_loop.py --compare --iteration 3 --judge # + vision advisory
|
||||
|
||||
Metrics are computed with Pillow (already in requirements-tools.txt); no numpy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
from PIL import Image, ImageFilter, ImageStat
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
TARGETS = os.path.join(ROOT, "Tools", "visual_targets.json")
|
||||
ITER_ROOT = os.path.join(ROOT, "Art", "reference", "iterations")
|
||||
|
||||
# A shot must clear these floors or it is treated as broken, not merely ugly.
|
||||
MIN_EDGE_DENSITY = 0.010
|
||||
MIN_CONTRAST = 12.0
|
||||
MIN_MEAN_LUMA = 8.0
|
||||
|
||||
|
||||
# --- metrics ----------------------------------------------------------------
|
||||
|
||||
|
||||
def _luma_gray(image):
|
||||
return image.convert("L")
|
||||
|
||||
|
||||
def metrics(image_path):
|
||||
"""Measure one image. Everything is on the 8-bit luma/RGB 0..255 scale."""
|
||||
with Image.open(image_path) as handle:
|
||||
image = handle.convert("RGB")
|
||||
width, height = image.size
|
||||
gray = _luma_gray(image)
|
||||
stat = ImageStat.Stat(gray)
|
||||
|
||||
# Edge density: fraction of pixels above a low threshold after FIND_EDGES.
|
||||
# Stands in for "how much relief and silhouette detail is in the frame".
|
||||
# Filter, then crop: FIND_EDGES samples outside the image as zero, so it leaves
|
||||
# its own border ring at a high value (236 of 3600 px on a 60x60 frame, 6.5%),
|
||||
# which would make a perfectly flat picture look detailed. Cropping *before*
|
||||
# filtering does not help — the new border gets the same treatment.
|
||||
filtered = gray.filter(ImageFilter.FIND_EDGES)
|
||||
inner = filtered.crop((2, 2, max(3, width - 2), max(3, height - 2)))
|
||||
hist = inner.histogram()
|
||||
area = float(inner.width * inner.height)
|
||||
edge_pixels = sum(hist[24:])
|
||||
edge_density = edge_pixels / area if area else 0.0
|
||||
|
||||
# Palette: quantise and keep the dominant colours with their share.
|
||||
total = float(width * height)
|
||||
quantised = image.quantize(colors=12, method=Image.Quantize.MEDIANCUT)
|
||||
palette = quantised.getpalette() or []
|
||||
counts = sorted(quantised.getcolors() or [], reverse=True)
|
||||
dominant = []
|
||||
for count, index in counts[:5]:
|
||||
base = index * 3
|
||||
if base + 2 < len(palette):
|
||||
dominant.append({
|
||||
"rgb": palette[base:base + 3],
|
||||
"share": round(count / total, 3),
|
||||
})
|
||||
|
||||
# 3x3 composition grid of mean luma, so "too much sky" is measurable.
|
||||
grid = []
|
||||
for row in range(3):
|
||||
for col in range(3):
|
||||
box = (int(col * width / 3), int(row * height / 3),
|
||||
int((col + 1) * width / 3), int((row + 1) * height / 3))
|
||||
grid.append(round(ImageStat.Stat(gray.crop(box)).mean[0], 1))
|
||||
|
||||
return {
|
||||
"path": os.path.relpath(image_path, ROOT),
|
||||
"size": [width, height],
|
||||
"mean_luma": round(stat.mean[0], 1),
|
||||
"rms_contrast": round(stat.stddev[0], 1),
|
||||
"edge_density": round(edge_density, 4),
|
||||
"dominant": dominant,
|
||||
"grid_luma": grid,
|
||||
}
|
||||
|
||||
|
||||
def _palette_distance(a, b):
|
||||
"""Mean nearest-colour distance between two dominant palettes, 0..441."""
|
||||
if not a or not b:
|
||||
return None
|
||||
total = 0.0
|
||||
for entry in a:
|
||||
pa = entry["rgb"]
|
||||
best = min(
|
||||
sum((pa[i] - pb["rgb"][i]) ** 2 for i in range(3)) ** 0.5 for pb in b)
|
||||
total += best
|
||||
return round(total / len(a), 1)
|
||||
|
||||
|
||||
def _correlation(values_a, values_b):
|
||||
n = len(values_a)
|
||||
mean_a = sum(values_a) / n
|
||||
mean_b = sum(values_b) / n
|
||||
var_a = sum((v - mean_a) ** 2 for v in values_a)
|
||||
var_b = sum((v - mean_b) ** 2 for v in values_b)
|
||||
if var_a <= 0.0 or var_b <= 0.0:
|
||||
return 0.0
|
||||
cov = sum((a - mean_a) * (b - mean_b) for a, b in zip(values_a, values_b))
|
||||
return round(cov / (var_a * var_b) ** 0.5, 3)
|
||||
|
||||
|
||||
def compare(shot_metrics, reference_metrics):
|
||||
"""Delta between a render and a reference, plus a 0..1 similarity score."""
|
||||
luma = shot_metrics["mean_luma"] - reference_metrics["mean_luma"]
|
||||
contrast_ratio = (shot_metrics["rms_contrast"] /
|
||||
reference_metrics["rms_contrast"]) if reference_metrics["rms_contrast"] else 1.0
|
||||
edge_ratio = (shot_metrics["edge_density"] /
|
||||
reference_metrics["edge_density"]) if reference_metrics["edge_density"] else 1.0
|
||||
palette = _palette_distance(shot_metrics["dominant"], reference_metrics["dominant"])
|
||||
composition = _correlation(shot_metrics["grid_luma"], reference_metrics["grid_luma"])
|
||||
|
||||
# Score: luminance within 25, contrast and edge ratios near 1, palette near 0,
|
||||
# composition correlated. Each term is clamped to 0..1 and equally weighted.
|
||||
def near(value, target, span):
|
||||
return max(0.0, 1.0 - abs(value - target) / span)
|
||||
|
||||
score = (
|
||||
near(luma, 0.0, 25.0)
|
||||
+ near(contrast_ratio, 1.0, 1.0)
|
||||
+ near(edge_ratio, 1.0, 1.5)
|
||||
+ (1.0 if palette is None else near(palette, 0.0, 120.0))
|
||||
+ max(0.0, composition)
|
||||
) / 5.0
|
||||
|
||||
return {
|
||||
"delta_luma": round(luma, 1),
|
||||
"contrast_ratio": round(contrast_ratio, 2),
|
||||
"edge_ratio": round(edge_ratio, 2),
|
||||
"palette_distance": palette,
|
||||
"composition_correlation": composition,
|
||||
"score": round(score, 3),
|
||||
}
|
||||
|
||||
|
||||
# --- feedback ---------------------------------------------------------------
|
||||
# Each rule maps a measured delta to an action phrased for the Blender skills.
|
||||
|
||||
|
||||
def feedback(name, shot, reference, delta):
|
||||
notes = []
|
||||
if delta["edge_ratio"] < 0.6:
|
||||
notes.append(
|
||||
f"**Relief and silhouette detail** are {1 - delta['edge_ratio']:.0%} below "
|
||||
f"the reference (`blender-modeler` / `archviz`): add window rhythm, "
|
||||
f"pilasters, cornices or quoins that cast a shadow line.")
|
||||
elif delta["edge_ratio"] > 1.8:
|
||||
notes.append(
|
||||
"**Too busy** against the reference: the render has far more edge detail. "
|
||||
"Check for stray kit pieces, z-fighting or duplicated geometry.")
|
||||
|
||||
if delta["delta_luma"] <= -15:
|
||||
notes.append(
|
||||
f"**Too dark** by {abs(delta['delta_luma']):.0f} luma (`materials` / "
|
||||
f"`lighting`): raise the material albedo or the exposure.")
|
||||
elif delta["delta_luma"] >= 15:
|
||||
notes.append(
|
||||
f"**Too bright** by {delta['delta_luma']:.0f} luma (`materials` / "
|
||||
f"`lighting`): lower the albedo or the exposure.")
|
||||
|
||||
if delta["contrast_ratio"] < 0.7:
|
||||
notes.append(
|
||||
"**Value range compressed** (`lookdev`): the reference has much stronger "
|
||||
"light/shadow separation. Deepen recesses or widen the material range.")
|
||||
elif delta["contrast_ratio"] > 1.5:
|
||||
notes.append(
|
||||
"**Value range too harsh** (`lookdev`): soften the key or the shadow "
|
||||
"contrast so the surface does not clip.")
|
||||
|
||||
if delta["palette_distance"] is not None and delta["palette_distance"] > 60:
|
||||
notes.append(
|
||||
f"**Hue drift** ({delta['palette_distance']:.0f} palette distance, "
|
||||
f"`materials`): compare the dominant colours in the iteration report "
|
||||
f"against the reference and correct the base colours.")
|
||||
|
||||
if delta["composition_correlation"] < 0.3:
|
||||
notes.append(
|
||||
"**Framing differs** (`camera-cinematography`): the 3x3 luma grid does "
|
||||
"not match the reference. Check the camera height, lens and how much "
|
||||
"sky/ground is in frame before changing the model.")
|
||||
|
||||
if not notes:
|
||||
notes.append(
|
||||
"No metric is outside its tolerance. Differences left are stylistic; "
|
||||
"confirm visually rather than by number.")
|
||||
return notes
|
||||
|
||||
|
||||
# --- verification -----------------------------------------------------------
|
||||
|
||||
|
||||
def verify(targets, judge=False):
|
||||
"""Check that each shot exists, is not broken, and looks like its expectation."""
|
||||
results = []
|
||||
for target in targets:
|
||||
shot = os.path.join(ROOT, target["shot"])
|
||||
entry = {"name": target["name"], "shot": target["shot"], "expect": target["expect"]}
|
||||
if not os.path.exists(shot):
|
||||
entry["status"] = "MISSING"
|
||||
results.append(entry)
|
||||
continue
|
||||
m = metrics(shot)
|
||||
problems = []
|
||||
if m["edge_density"] < MIN_EDGE_DENSITY:
|
||||
problems.append(f"almost flat (edge density {m['edge_density']})")
|
||||
if m["rms_contrast"] < MIN_CONTRAST:
|
||||
problems.append(f"no tonal range (contrast {m['rms_contrast']})")
|
||||
if m["mean_luma"] < MIN_MEAN_LUMA:
|
||||
problems.append(f"black frame (luma {m['mean_luma']})")
|
||||
expected_size = target.get("size")
|
||||
if expected_size and m["size"] != expected_size:
|
||||
problems.append(f"size {m['size']} != {expected_size}")
|
||||
entry["metrics"] = {k: m[k] for k in
|
||||
("size", "mean_luma", "rms_contrast", "edge_density")}
|
||||
entry["status"] = "FAIL" if problems else "OK"
|
||||
entry["problems"] = problems
|
||||
|
||||
if judge and os.environ.get("OPENROUTER_API_KEY") or (
|
||||
judge and os.path.exists(os.path.join(ROOT, ".env"))):
|
||||
entry["judge"] = run_judge(shot, target["expect"])
|
||||
results.append(entry)
|
||||
return results
|
||||
|
||||
|
||||
def run_judge(shot, expect):
|
||||
"""Advisory vision check. Never a gate: the model is unreliable on this content."""
|
||||
import subprocess
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[sys.executable, os.path.join(ROOT, "Tools", "judge_screenshot.py"),
|
||||
"--image", shot, "--expect", expect],
|
||||
capture_output=True, text=True, timeout=180, cwd=ROOT)
|
||||
out = (proc.stdout or "") + (proc.stderr or "")
|
||||
for line in out.splitlines():
|
||||
if line.startswith("verdict="):
|
||||
return line.strip()
|
||||
return "unavailable"
|
||||
except Exception as exc: # noqa: BLE001 - advisory only
|
||||
return f"error: {exc}"
|
||||
|
||||
|
||||
# --- reporting --------------------------------------------------------------
|
||||
|
||||
|
||||
def write_iteration(iteration, rows, args):
|
||||
"""Write the iteration artefacts (gitignored) and return the directory."""
|
||||
out_dir = os.path.join(ITER_ROOT, str(iteration))
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
for row in rows:
|
||||
shot = os.path.join(ROOT, row["shot"])
|
||||
if os.path.exists(shot):
|
||||
shutil.copy2(shot, os.path.join(out_dir, os.path.basename(shot)))
|
||||
with open(os.path.join(out_dir, "metrics.json"), "w", encoding="utf-8") as handle:
|
||||
json.dump({"iteration": iteration, "targets": rows}, handle, indent=2)
|
||||
|
||||
lines = [f"# Iteration {iteration} — reference vs render", ""]
|
||||
for row in rows:
|
||||
lines.append(f"## {row['name']}")
|
||||
lines.append("")
|
||||
lines.append(f"- shot: `{row['shot']}`")
|
||||
if row.get("reference"):
|
||||
lines.append(f"- reference: `{row['reference']}`")
|
||||
delta = row.get("delta")
|
||||
if delta:
|
||||
lines.append(
|
||||
f"- **score {delta['score']:.2f}** · luma {delta['delta_luma']:+.0f} · "
|
||||
f"contrast ×{delta['contrast_ratio']} · edges ×{delta['edge_ratio']} · "
|
||||
f"palette {delta['palette_distance']} · composition r={delta['composition_correlation']}")
|
||||
lines.append("")
|
||||
for note in row.get("feedback", []):
|
||||
lines.append(f"- {note}")
|
||||
lines.append("")
|
||||
path = os.path.join(out_dir, "feedback.md")
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
handle.write("\n".join(lines) + "\n")
|
||||
return out_dir, path
|
||||
|
||||
|
||||
def load_targets():
|
||||
with open(TARGETS, encoding="utf-8") as handle:
|
||||
return json.load(handle)["targets"]
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--verify", action="store_true",
|
||||
help="gate the current shots against their expectations")
|
||||
ap.add_argument("--compare", action="store_true",
|
||||
help="measure each shot against its reference(s)")
|
||||
ap.add_argument("--feedback", action="store_true", help="include action notes (default with --compare)")
|
||||
ap.add_argument("--judge", action="store_true", help="add the advisory vision verdict")
|
||||
ap.add_argument("--iteration", default=None, help="iteration label for the output directory")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
if not (args.verify or args.compare):
|
||||
args.verify = args.compare = True
|
||||
|
||||
targets = load_targets()
|
||||
|
||||
if args.verify:
|
||||
print("== verification ==")
|
||||
results = verify(targets, judge=args.judge)
|
||||
failed = [r for r in results if r["status"] != "OK"]
|
||||
for r in results:
|
||||
extra = f" ({'; '.join(r['problems'])})" if r.get("problems") else ""
|
||||
judge = f" [judge: {r['judge']}]" if r.get("judge") else ""
|
||||
print(f" {r['status']:7} {r['name']:32}{extra}{judge}")
|
||||
print(f" -> {len(results) - len(failed)}/{len(results)} shots OK")
|
||||
|
||||
if args.compare:
|
||||
print("\n== comparison ==")
|
||||
rows = []
|
||||
for target in targets:
|
||||
shot_path = os.path.join(ROOT, target["shot"])
|
||||
if not os.path.exists(shot_path):
|
||||
continue
|
||||
shot_m = metrics(shot_path)
|
||||
best = None
|
||||
for ref_rel in target.get("references", []):
|
||||
ref_path = os.path.join(ROOT, ref_rel)
|
||||
if not os.path.exists(ref_path):
|
||||
continue
|
||||
ref_m = metrics(ref_path)
|
||||
delta = compare(shot_m, ref_m)
|
||||
if best is None or delta["score"] > best[1]["score"]:
|
||||
best = (ref_rel, delta, ref_m)
|
||||
if best is None:
|
||||
continue
|
||||
ref_rel, delta, ref_m = best
|
||||
rows.append({
|
||||
"name": target["name"],
|
||||
"shot": target["shot"],
|
||||
"reference": ref_rel,
|
||||
"delta": delta,
|
||||
"feedback": feedback(target["name"], shot_m, ref_m, delta),
|
||||
"expect": target["expect"],
|
||||
})
|
||||
print(f" {target['name']:32} score {delta['score']:.2f} "
|
||||
f"luma {delta['delta_luma']:+.0f} edges ×{delta['edge_ratio']}")
|
||||
if args.iteration is not None:
|
||||
out_dir, feedback_path = write_iteration(args.iteration, rows, args)
|
||||
print(f"\n iteration written to {os.path.relpath(out_dir, ROOT)}")
|
||||
print(f" feedback: {os.path.relpath(feedback_path, ROOT)}")
|
||||
print(" (gitignored: promote a shot into Screenshots/ only after owner confirmation)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"comment": "What each demo-map capture must show, and which reference photograph it is measured against. Used by Tools/visual_loop.py --verify (does the shot show the expected content?) and --compare (how far is the render from the reference?).",
|
||||
"targets": [
|
||||
{
|
||||
"name": "demo-spawn-view",
|
||||
"shot": "Screenshots/demo/review-demo-spawn-view.png",
|
||||
"size": [
|
||||
1920,
|
||||
1080
|
||||
],
|
||||
"references": [
|
||||
"Art/reference/hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg"
|
||||
],
|
||||
"expect": "The whole south block from the north sidewalk: the neoclassical hero facade between its neighbouring buildings, both sidewalks and the road in the foreground, daylight"
|
||||
},
|
||||
{
|
||||
"name": "demo-street-level",
|
||||
"shot": "Screenshots/demo/review-demo-street-level.png",
|
||||
"size": [
|
||||
1920,
|
||||
1080
|
||||
],
|
||||
"references": [
|
||||
"Art/reference/hollywood/el-capitan-theatre-blade-sign-day-wikimedia-2008-10.jpg"
|
||||
],
|
||||
"expect": "Street level from the north side: the hero colonnade with the adjacent frontage buildings either side, shopfronts at pavement level, daylight"
|
||||
},
|
||||
{
|
||||
"name": "demo-crossing",
|
||||
"shot": "Screenshots/demo/review-demo-crossing.png",
|
||||
"size": [
|
||||
1920,
|
||||
1080
|
||||
],
|
||||
"references": [
|
||||
"Art/reference/hollywood/walk-of-fame-terrazzo-ovation-day-cc0-2019-06-20.jpg"
|
||||
],
|
||||
"expect": "The designated mid-block crossing: continental zebra bars on the asphalt, the south frontage and hero colonnade behind, daylight"
|
||||
},
|
||||
{
|
||||
"name": "demo-north-buildings",
|
||||
"shot": "Screenshots/demo/review-demo-north-buildings.png",
|
||||
"size": [
|
||||
1920,
|
||||
1080
|
||||
],
|
||||
"references": [
|
||||
"Art/reference/north-chinese/01-grauman-s-chinese-theatre-by-carol-highsmith.jpg"
|
||||
],
|
||||
"expect": "The two north-side buildings (TCL Chinese Theatre, Madame Tussaud's) seen from across Hollywood Boulevard, full height in frame, daylight"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user