feat(facades): 0.1.5 — hero detail, scene audit, material orphaning fixed (#32)
Merged PR #32: 0.1.5 — hero detail, scene audit, material orphaning fixed Co-authored-by: Zwitschi <zwitschi82@gmail.com>
This commit was merged in pull request #32.
This commit is contained in:
1 parent
743a290ac9
commit
73aca9db80
50 files changed
+1326
-88
No files matched your search
@@ -22,6 +22,8 @@ Geographic tools live in [`Geo/tools/`](../Geo/README.md) and the Blender-side b
|
||||
| `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_audit_scene.py` | Gate on the DemoMap: no unmaterialed actor, every verified frontage span within tolerance (`ue_placement`), no duplicate actor labels. Writes `/tmp/gigline/scene-audit.txt`; `render_screenshots.sh --verify` fails on a non-zero failure count. Catches the class of defect that still renders plausibly. |
|
||||
| `ue_placement.py` | Single source of truth for where staged actors belong in the DemoMap (hero position, verified frontage spans) plus `span_error`/`describe_span_failure` validators. Exists because the hero position was written twice and drifted — the importer kept the pre-`0.1.0` value and moved the hero 13 m east onto the wrong side of the boulevard. No `unreal` import, so it is unit-tested. |
|
||||
| `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 |
|
||||
|
||||
@@ -174,6 +174,20 @@ def build():
|
||||
cap_z0 = HEIGHT - ENTABLATURE_H - PARAPET_H - CAPITAL_H
|
||||
cylinder(f"Hero_Column_{i + 1}", x, -0.55, shaft_z0, cap_z0,
|
||||
COLUMN_D / 2.0, stone)
|
||||
|
||||
# Fluting: 20 shallow grooves around the shaft, as the photographs show on
|
||||
# the colonnade. Each flute is a thin cylinder set into the shaft surface;
|
||||
# modelling them as grooves would need booleans on 6 columns for detail the
|
||||
# eye reads only in silhouette and grazing light, so they are added mass in
|
||||
# the *shadow* direction instead — cheap, and it breaks the flat highlight.
|
||||
flutes = 20
|
||||
flute_r = 0.032
|
||||
for f in range(flutes):
|
||||
ang = (2.0 * math.pi * f) / flutes
|
||||
fx = x + (COLUMN_D / 2.0 - flute_r * 0.4) * math.cos(ang)
|
||||
fy = -0.55 + (COLUMN_D / 2.0 - flute_r * 0.4) * math.sin(ang)
|
||||
cylinder(f"Hero_Flute_{i + 1}_{f + 1}", fx, fy, shaft_z0 + 0.05,
|
||||
cap_z0 - 0.05, flute_r, stone_dark, vertices=6)
|
||||
# Capital block, then the square abacus that carries the architrave.
|
||||
# The abacus takes the top 0.14 m of the capital zone so the colonnade
|
||||
# still ends at HEIGHT - ENTABLATURE_H - PARAPET_H (proportion unchanged).
|
||||
@@ -185,6 +199,9 @@ def build():
|
||||
1.30, 1.30, abacus_h, stone)
|
||||
note("Hero_Column_x6", (COLUMN_D, COLUMN_D, HEIGHT - ENTABLATURE_H - PARAPET_H - PEDESTAL_H),
|
||||
f"6 columns at {COLUMN_BAY} m centres (sibling notes x measured frontage)")
|
||||
note("Hero_Flute_x120", (0.064, 0.064, cap_z0 - shaft_z0),
|
||||
"photographs: fluted shafts; 20 grooves per column modelled as shallow "
|
||||
"cylinders in the shadow tone rather than boolean cut grooves")
|
||||
note("Hero_ColumnBase_x6", (1.36, 1.36, 0.22),
|
||||
"assumption: Ionic base moulding; photographs show a wider base at the shaft foot")
|
||||
note("Hero_Abacus_x6", (1.30, 1.30, 0.14),
|
||||
@@ -223,10 +240,22 @@ def build():
|
||||
FRONTAGE_W, 1.2, PARAPET_H, stone)
|
||||
note("Hero_Parapet", (FRONTAGE_W, 1.2, PARAPET_H), "assumption: parapet carries the motto band")
|
||||
|
||||
# Motto band: a recessed panel across the parapet, as the photographs show.
|
||||
cube("Hero_MottoBand", 0.0, -1.32, HEIGHT - PARAPET_H / 2.0,
|
||||
FRONTAGE_W - 3.0, 0.08, 0.85, stone_dark)
|
||||
note("Hero_MottoBand", (FRONTAGE_W - 3.0, 0.08, 0.85), "photographs: motto panel above the colonnade")
|
||||
# Motto band: a recessed panel across the parapet with a moulded surround, as
|
||||
# the photographs show. The panel was a 8 cm stripe of dark stone; it is now a
|
||||
# real recess — surround frame standing 6 cm proud, panel set 12 cm behind it —
|
||||
# so it reads as an inscription panel from the street instead of a paint line.
|
||||
motto_z = HEIGHT - PARAPET_H / 2.0
|
||||
motto_w = FRONTAGE_W - 3.0
|
||||
for x_off in (-(motto_w / 2.0 + 0.16), (motto_w / 2.0 + 0.16)):
|
||||
cube(f"Hero_MottoSurround_v{int(x_off > 0)}", x_off, -1.13, motto_z,
|
||||
0.32, 0.26, 1.15, stone)
|
||||
for z_off in (-0.575, 0.575):
|
||||
cube(f"Hero_MottoSurround_h{int(z_off > 0)}", 0.0, -1.13, motto_z + z_off,
|
||||
motto_w + 0.64, 0.26, 0.32, stone)
|
||||
cube("Hero_MottoBand", 0.0, -1.06, motto_z, motto_w, 0.10, 0.85, stone_dark)
|
||||
note("Hero_MottoBand", (motto_w, 0.10, 0.85),
|
||||
"photographs: motto panel above the colonnade, now an inset panel with a "
|
||||
"moulded surround rather than a flat stripe")
|
||||
|
||||
# Medallions either side of the inscription: discs on the parapet face, not
|
||||
# pancakes on the coping (see disc_on_wall).
|
||||
|
||||
@@ -20,18 +20,29 @@ PROJ="$REPO/Game/GigLine/GigLine.uproject"
|
||||
VERIFY="no"
|
||||
[ "${1:-}" = "--verify" ] && VERIFY="yes"
|
||||
|
||||
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
|
||||
run_cmdlet() {
|
||||
local script="$1" label="$2"
|
||||
echo "== $label =="
|
||||
( cd "$ENGINE" && ./Engine/Binaries/Linux/UnrealEditor-Cmd "$PROJ" \
|
||||
-run=pythonscript -script="$REPO/Tools/$script" \
|
||||
-nullrhi -unattended -nopause -stdout )
|
||||
}
|
||||
|
||||
# Order matters. The demo builder rebuilds the site materials, so any actor staged
|
||||
# by another tool must be (re)staged after it or it keeps a reference to a material
|
||||
# that no longer exists - that is how 604 actors ended up untextured once.
|
||||
run_cmdlet ue_build_demo_map.py "1/5 rebuild the demo map (materials, site, frontage)"
|
||||
run_cmdlet ue_apply_facade_kit.py "2/5 assemble the facade kit"
|
||||
run_cmdlet ue_stage_theatre_trim.py "3/5 stage the theatre trim"
|
||||
run_cmdlet ue_audit_scene.py "4/6 audit the scene (materials, placement, labels)"
|
||||
run_cmdlet ue_build_review_map.py "5/6 stage the demo shot cameras"
|
||||
tail -3 /tmp/ue-review-map-result.txt 2>/dev/null || true
|
||||
|
||||
echo "== 2/3 capture the demo map =="
|
||||
echo "== 6/6 capture the demo map =="
|
||||
./Engine/Binaries/Linux/UnrealEditor-Cmd "$PROJ" /Game/Hollywood/DemoMap \
|
||||
-game -windowed -ResX=1920 -ResY=1080 -nopause -nosplash -stdout
|
||||
|
||||
echo "== 3/3 collect the demo screenshots =="
|
||||
echo "== collect the demo screenshots =="
|
||||
mkdir -p "$REPO/Screenshots/demo"
|
||||
rm -f "$REPO"/Screenshots/demo/review-*.png
|
||||
found=0
|
||||
@@ -47,6 +58,14 @@ done
|
||||
[ "$found" = "1" ] || { echo "ERROR: no demo screenshots found"; exit 1; }
|
||||
ls -1 "$REPO/Screenshots/demo"
|
||||
|
||||
# The scene audit is a gate: a wrong-material or misplaced actor is exactly the
|
||||
# kind of failure that still renders plausibly.
|
||||
if grep -q "failure(s) ==" /tmp/gigline/scene-audit.txt 2>/dev/null; then
|
||||
grep -E "FAIL|failure\(s\)" /tmp/gigline/scene-audit.txt | tail -5
|
||||
grep -q "== 0 failure(s) ==" /tmp/gigline/scene-audit.txt || {
|
||||
echo "ERROR: scene audit failed — see /tmp/gigline/scene-audit.txt"; exit 1; }
|
||||
fi
|
||||
|
||||
if [ "$VERIFY" = "yes" ]; then
|
||||
echo "== verify: does each shot show what it should? =="
|
||||
cd "$REPO"
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Tests for Tools/ue_placement.py — the single source of truth for placement.
|
||||
|
||||
A regression test for a real bug: `ue_hero_facade_import.py` carried its own hero
|
||||
position (the pre-0.1.0 one) while `ue_build_demo_map.py` staged the verified one.
|
||||
Running the importer moved the hero 13 m east and onto the north side of the
|
||||
boulevard, and nothing complained because a displaced building still renders.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
sys.path.insert(0, os.path.join(ROOT, "Tools"))
|
||||
|
||||
import ue_placement # noqa: E402
|
||||
|
||||
|
||||
class HeroPlacementTest(unittest.TestCase):
|
||||
def test_hero_actor_name(self):
|
||||
self.assertEqual(ue_placement.HERO_ACTOR, "Hero_Facade")
|
||||
|
||||
def test_hero_location_is_the_verified_building_line(self):
|
||||
x, y, z = ue_placement.HERO_LOCATION_CM
|
||||
self.assertEqual((x, y, z), (-13336.0, 1307.0, 0.0))
|
||||
# North is -Y in Unreal, so the hero on the south side must have Y > 0.
|
||||
self.assertGreater(y, 0.0, "hero must be on the south side (Y > 0)")
|
||||
|
||||
def test_hero_span_is_verified(self):
|
||||
# The mesh spans -1308..+1308 cm in X about its origin.
|
||||
lo = ue_placement.HERO_LOCATION_CM[0] - 1308.0
|
||||
hi = ue_placement.HERO_LOCATION_CM[0] + 1308.0
|
||||
exp_min, exp_max, ok = ue_placement.span_error("hero", lo, hi)
|
||||
self.assertTrue(ok)
|
||||
# The mesh spans exactly the verified footprint, so the two agree.
|
||||
self.assertAlmostEqual(exp_min, lo, delta=ue_placement.SPAN_TOLERANCE_CM)
|
||||
self.assertAlmostEqual(exp_max, hi, delta=ue_placement.SPAN_TOLERANCE_CM)
|
||||
|
||||
|
||||
class SpanValidatorTest(unittest.TestCase):
|
||||
def test_misplaced_hero_is_reported(self):
|
||||
# The exact failure observed: the stale import position put the hero here.
|
||||
lo, hi = -13332.0, -10716.0
|
||||
exp_min, exp_max, ok = ue_placement.span_error("hero", lo, hi)
|
||||
self.assertFalse(ok)
|
||||
message = ue_placement.describe_span_failure("hero", lo, hi)
|
||||
self.assertIn("does not match the verified", message)
|
||||
self.assertIn(f"{exp_min:.0f}", message)
|
||||
|
||||
def test_correct_span_reports_nothing(self):
|
||||
self.assertEqual(ue_placement.describe_span_failure("hero", -14644, -12028), "")
|
||||
|
||||
def test_within_tolerance_passes(self):
|
||||
# 99 cm off is inside the ±100 cm tolerance.
|
||||
self.assertTrue(ue_placement.span_error("6922", -24276 + 99, -17700 - 99)[2])
|
||||
|
||||
def test_outside_tolerance_fails(self):
|
||||
self.assertFalse(ue_placement.span_error("6922", -24276 + 101, -17700)[2])
|
||||
|
||||
def test_unknown_building_raises(self):
|
||||
with self.assertRaises(KeyError):
|
||||
ue_placement.span_error("no-such-building", 0, 0)
|
||||
|
||||
def test_every_frontage_span_is_ordered(self):
|
||||
for name, (lo, hi) in ue_placement.FRONTAGE_SPANS_CM.items():
|
||||
self.assertLess(lo, hi, f"{name} span is inverted")
|
||||
|
||||
|
||||
class SingleSourceOfTruthTest(unittest.TestCase):
|
||||
"""The import script and the demo builder must not carry their own literals."""
|
||||
|
||||
def test_import_script_uses_the_shared_constant(self):
|
||||
src = open(os.path.join(ROOT, "Tools", "ue_hero_facade_import.py"),
|
||||
encoding="utf-8").read()
|
||||
self.assertIn("ue_placement.HERO_LOCATION_CM", src)
|
||||
self.assertNotIn("(-12024.5, -1450.0, 0.0)", src,
|
||||
"the stale pre-0.1.0 hero position must not come back")
|
||||
|
||||
def test_demo_builder_uses_the_shared_constant(self):
|
||||
src = open(os.path.join(ROOT, "Tools", "ue_build_demo_map.py"),
|
||||
encoding="utf-8").read()
|
||||
self.assertIn("ue_placement.HERO_LOCATION_CM", src)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -123,12 +123,28 @@ def apply_to_actor(actor, kit, materials=None, prefix=None):
|
||||
f"({width / 100:.1f} x {height / 100:.1f} m)")
|
||||
return 0
|
||||
|
||||
# Bays: the kit module is one window bay (4.96 m). A very wide building gets a
|
||||
# bay count that keeps the rhythm readable rather than one window per 4.96 m of
|
||||
# frontage — 6922 is 65.8 m wide, which is 13 bays, giving a window every 4.96 m
|
||||
# across a 37 m tall block: the rhythm reads as a wall of glass, not a building.
|
||||
# Above MAX_BAYS the module is widened instead, so the bay count stays legible.
|
||||
MAX_BAYS = 9
|
||||
bays = max(1, int(round(width / BAY_CM)))
|
||||
if bays > MAX_BAYS:
|
||||
bays = MAX_BAYS
|
||||
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))
|
||||
# Storeys never overrun the volume: cap the count so the top band (cornice +
|
||||
# parapet, 145 cm) always lands inside the building.
|
||||
storeys = max(1, int((height - SHOP_FLOOR_CM - 145.0) // STOREY_CM) + 1)
|
||||
top_z = base_z + height
|
||||
# Windows above the shopfront, spread over the available height rather than
|
||||
# assumed to be exactly STOREY_CM apart, so a 37 m block does not leave a gap
|
||||
# of bare wall under the cornice.
|
||||
usable = max(0.0, (top_z - 145.0) - (base_z + SHOP_FLOOR_CM))
|
||||
storey_step = (usable / storeys) if storeys else STOREY_CM
|
||||
|
||||
def kit_material(name):
|
||||
if materials is None:
|
||||
@@ -140,11 +156,13 @@ def apply_to_actor(actor, kit, materials=None, prefix=None):
|
||||
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)):
|
||||
for piece, dx, side in (("Kit_ShopPier_L", -bay_w / 2 + 30.0, "L"),
|
||||
("Kit_ShopPier_R", bay_w / 2 - 30.0, "R")):
|
||||
if piece in kit:
|
||||
# The side belongs in the label: both piers of a bay shared one
|
||||
# label, which left 53 ambiguous pairs in the outliner.
|
||||
if _spawn(kit[piece], unreal.Vector(cx + dx, face_y, base_z),
|
||||
f"{prefix}shop_pier_{bay}", kit_material(piece)):
|
||||
f"{prefix}shop_pier_{side}_{bay}", kit_material(piece)):
|
||||
placed += 1
|
||||
if "Kit_ShopGlass" in kit:
|
||||
if _spawn(kit["Kit_ShopGlass"], unreal.Vector(cx, face_y, base_z),
|
||||
@@ -153,7 +171,7 @@ def apply_to_actor(actor, kit, materials=None, prefix=None):
|
||||
# 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
|
||||
z = base_z + SHOP_FLOOR_CM + storey_step * 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
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Audit the DemoMap for staged actors that would render wrong.
|
||||
|
||||
Checks that are cheap and catch the failures this project actually had:
|
||||
|
||||
1. **No unmaterialed actors.** 604 actors once rendered with the default material
|
||||
because the demo builder deleted and recreated the site materials, orphaning
|
||||
every reference; the map looked merely "a bit flat".
|
||||
2. **Placement spans.** Each verified frontage building is probed against
|
||||
`Tools/ue_placement.py`; a building 13 m off its footprint still renders fine,
|
||||
which is exactly why it needs a number, not an eye.
|
||||
3. **No duplicate actor labels.** Two buildings' kit pieces sharing a label made
|
||||
the outliner ambiguous and hid one of them.
|
||||
|
||||
UnrealEditor-Cmd <uproject> -run=pythonscript \
|
||||
-script=Tools/ue_audit_scene.py -nullrhi -unattended -nopause -stdout
|
||||
|
||||
Result file: `/tmp/gigline/scene-audit.txt`. Exit code from the commandlet is
|
||||
always 0, so **read the file** — `visual_loop.py --verify` does not depend on it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import os
|
||||
import sys
|
||||
|
||||
import unreal
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import ue_placement # noqa: E402
|
||||
|
||||
MAP = "/Game/Hollywood/DemoMap"
|
||||
RESULT = "/tmp/gigline/scene-audit.txt"
|
||||
|
||||
lines: list[str] = []
|
||||
failures = 0
|
||||
|
||||
|
||||
def log(message: str) -> None:
|
||||
lines.append(message)
|
||||
print(message)
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
global failures
|
||||
failures += 1
|
||||
log("FAIL " + message)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if not unreal.EditorLevelLibrary.load_level(MAP):
|
||||
log(f"could not load {MAP}")
|
||||
return
|
||||
|
||||
actors = unreal.EditorLevelLibrary.get_all_level_actors()
|
||||
|
||||
# 1. materials
|
||||
unmaterialed = []
|
||||
for actor in actors:
|
||||
comp = actor.get_component_by_class(unreal.StaticMeshComponent)
|
||||
if not comp or not comp.static_mesh:
|
||||
continue
|
||||
if comp.get_material(0) is None:
|
||||
unmaterialed.append(actor.get_actor_label())
|
||||
log(f"mesh actors: {sum(1 for a in actors if a.get_component_by_class(unreal.StaticMeshComponent))}")
|
||||
if unmaterialed:
|
||||
fail(f"{len(unmaterialed)} actor(s) with no material, e.g. {unmaterialed[:6]}")
|
||||
else:
|
||||
log("OK every staged mesh has a material")
|
||||
|
||||
# 2. placement spans
|
||||
for actor in actors:
|
||||
label = actor.get_actor_label()
|
||||
key = None
|
||||
if label.startswith("Frontage_SM_"):
|
||||
name = label[len("Frontage_SM_"):]
|
||||
key = {"Hollywood_6922": "6922", "Hollywood_6902": "6902",
|
||||
"ElCapitanTheatre": "6838", "Budget_6822": "6822",
|
||||
"Hollywood_6806": "6806", "LeeDrug_6800": "6800"}.get(name)
|
||||
elif label == ue_placement.HERO_ACTOR:
|
||||
key = "hero"
|
||||
if not key:
|
||||
continue
|
||||
origin, extent = actor.get_actor_bounds(False)
|
||||
lo, hi = origin.x - extent.x, origin.x + extent.x
|
||||
problem = ue_placement.describe_span_failure(key, lo, hi)
|
||||
if problem:
|
||||
fail(problem)
|
||||
else:
|
||||
log(f"OK {key}: X {lo:.0f}..{hi:.0f}")
|
||||
|
||||
# 3. duplicate labels
|
||||
counts = collections.Counter(a.get_actor_label() for a in actors)
|
||||
duplicates = {k: v for k, v in counts.items() if v > 1}
|
||||
if duplicates:
|
||||
sample = list(duplicates.items())[:6]
|
||||
fail(f"{len(duplicates)} duplicated actor label(s), e.g. {sample}")
|
||||
else:
|
||||
log("OK no duplicate actor labels")
|
||||
|
||||
log(f"== {failures} failure(s) ==")
|
||||
|
||||
|
||||
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")
|
||||
@@ -23,6 +23,8 @@ 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__), ".."))
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import ue_placement # noqa: E402
|
||||
RESULT = "/tmp/gigline/demo-map.txt"
|
||||
lines = []
|
||||
|
||||
@@ -116,14 +118,23 @@ def ensure_material(mat_name, spec, textures=None):
|
||||
does not read as one flat specular value.
|
||||
"""
|
||||
path = f"/Game/Hollywood/Site/{mat_name}"
|
||||
if unreal.EditorAssetLibrary.does_asset_exist(path):
|
||||
# Rebuilt every run so a look change cannot be masked by a stale asset.
|
||||
unreal.EditorAssetLibrary.delete_asset(path)
|
||||
|
||||
mat = unreal.AssetToolsHelpers.get_asset_tools().create_asset(
|
||||
mat_name, "/Game/Hollywood/Site", unreal.Material, unreal.MaterialFactoryNew())
|
||||
lib = unreal.MaterialEditingLibrary
|
||||
|
||||
# Update the existing material IN PLACE rather than deleting and recreating it.
|
||||
# Deleting the asset to force a look rebuild silently orphaned every reference:
|
||||
# after one demo-builder run, all 598 kit actors and the theatre trim pointed at
|
||||
# deleted materials and rendered with the default one (604 actors with no
|
||||
# material), while the map itself looked merely "a bit flat".
|
||||
mat = unreal.EditorAssetLibrary.load_asset(path)
|
||||
if mat is None:
|
||||
mat = unreal.AssetToolsHelpers.get_asset_tools().create_asset(
|
||||
mat_name, "/Game/Hollywood/Site", unreal.Material, unreal.MaterialFactoryNew())
|
||||
else:
|
||||
# Clear the old graph so a removed node cannot survive a look change.
|
||||
# `Material.expressions` is protected from Python, so use the editing
|
||||
# library's own deleter rather than reading the array.
|
||||
lib.delete_all_material_expressions(mat)
|
||||
|
||||
def node(cls):
|
||||
return lib.create_material_expression(mat, cls)
|
||||
|
||||
@@ -401,7 +412,7 @@ def build():
|
||||
# the NORTH side of the street, which only looked right while it stood alone.
|
||||
hero = unreal.EditorAssetLibrary.load_asset("/Game/Hollywood/ElCapitan/SM_HeroFacade")
|
||||
if hero:
|
||||
actor = spawn(unreal.StaticMeshActor, unreal.Vector(-13336.0, 1307.0, 0.0), "Hero_Facade")
|
||||
actor = spawn(unreal.StaticMeshActor, unreal.Vector(*ue_placement.HERO_LOCATION_CM), ue_placement.HERO_ACTOR)
|
||||
if actor:
|
||||
actor.static_mesh_component.set_static_mesh(hero)
|
||||
actor.static_mesh_component.set_mobility(unreal.ComponentMobility.STATIC)
|
||||
|
||||
@@ -19,14 +19,19 @@ import unreal
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import ue_common # noqa: E402
|
||||
import ue_placement # noqa: E402
|
||||
|
||||
FBX = "/tmp/gigline/hero-facade.fbx"
|
||||
DEST = "/Game/Hollywood/ElCapitan"
|
||||
ACTOR_LABEL = "Hero_Facade"
|
||||
ACTOR_LABEL = ue_placement.HERO_ACTOR
|
||||
LEVEL = "/Game/Hollywood/DemoMap"
|
||||
|
||||
# Unreal centimetres: south-east corner of the façade, on the building line.
|
||||
PLACEMENT_CM = (-12024.5, -1450.0, 0.0)
|
||||
# Placement lives in Tools/ue_placement.py. It used to be a literal here, and it
|
||||
# drifted from ue_build_demo_map.py: this file kept the pre-0.1.0 position
|
||||
# (-12024.5, -1450) while the builder staged (-13336, 1307), so running the importer
|
||||
# on its own moved the hero 13 m east and onto the north side of the boulevard.
|
||||
PLACEMENT_CM = ue_placement.HERO_LOCATION_CM
|
||||
|
||||
RESULT = "/tmp/gigline/hero-facade-import.txt"
|
||||
lines = []
|
||||
@@ -118,6 +123,17 @@ def measure_and_place():
|
||||
comp.set_static_mesh(asset)
|
||||
comp.set_mobility(unreal.ComponentMobility.STATIC)
|
||||
log(f"placed {ACTOR_LABEL} at {PLACEMENT_CM} (cm) on {LEVEL}")
|
||||
|
||||
# Prove it landed on its verified footprint, not merely that it spawned.
|
||||
box = asset.get_bounding_box() if asset else None
|
||||
if box:
|
||||
lo = box.min.x + PLACEMENT_CM[0]
|
||||
hi = box.max.x + PLACEMENT_CM[0]
|
||||
problem = ue_placement.describe_span_failure("hero", lo, hi)
|
||||
if problem:
|
||||
log(f"PLACEMENT ERROR: {problem}")
|
||||
else:
|
||||
log(f"span ok: X {lo:.0f}..{hi:.0f}")
|
||||
_make_map_writable(LEVEL)
|
||||
saved = unreal.EditorLoadingAndSavingUtils.save_map(
|
||||
unreal.EditorLevelLibrary.get_editor_world(), LEVEL)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Single source of truth for where staged actors belong in the DemoMap.
|
||||
|
||||
This exists because the same value was written in two places and they drifted:
|
||||
`ue_hero_facade_import.py` kept the pre-`0.1.0` position `(-12024.5, -1450)` while
|
||||
`ue_build_demo_map.py` staged the hero at `(-13336, 1307)`. Running the importer on
|
||||
its own therefore moved the hero **13 m east and onto the north side of the
|
||||
boulevard** — and nothing complained, because a displaced building still renders.
|
||||
|
||||
The values are Unreal centimetres, absolute world space. Every one of them is
|
||||
verified against `Geo/verification/2026-11-06-el-capitan-block.md` and probed by
|
||||
`Tools/visual_loop.py --verify` (via the demo screenshot expectations) and by the
|
||||
world-span probes recorded in `MEMORY.md`.
|
||||
|
||||
Keep this file boring: constants and one validator, no `unreal` import, so it can
|
||||
be unit-tested on the host.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# The hero façade sits INSIDE the frontage gap between 6902 (ends at -17749) and
|
||||
# the El Capitan Theatre (starts at -12082), so its actor origin is the measured
|
||||
# building line. The mesh is authored with its front plane 7 cm ahead of the actor
|
||||
# origin, which is why the X here is the frontage centre and Y is the building line.
|
||||
HERO_ACTOR = "Hero_Facade"
|
||||
HERO_LOCATION_CM = (-13336.0, 1307.0, 0.0)
|
||||
|
||||
# Tolerance for the span probe: the verified footprints are within ~1 m.
|
||||
SPAN_TOLERANCE_CM = 100.0
|
||||
|
||||
# Expected world span per frontage actor (min X, max X), from the block
|
||||
# verification notes. Used to prove a staged building is on its own footprint
|
||||
# rather than merely present.
|
||||
FRONTAGE_SPANS_CM = {
|
||||
"6922": (-24276.0, -17700.0),
|
||||
"6902": (-17749.0, -14591.0),
|
||||
"hero": (-14644.0, -12028.0),
|
||||
"6838": (-12082.0, -8210.0),
|
||||
"6822": (-7854.0, -7252.0),
|
||||
"6806": (-6608.0, -3307.0),
|
||||
"6800": (-3320.0, -1437.0),
|
||||
}
|
||||
|
||||
|
||||
def span_error(name: str, observed_min: float, observed_max: float):
|
||||
"""Return (expected_min, expected_max, ok) for an observed building span.
|
||||
|
||||
`name` is a key of `FRONTAGE_SPANS_CM`; `ok` is False when either edge is more
|
||||
than `SPAN_TOLERANCE_CM` from the verified value.
|
||||
"""
|
||||
if name not in FRONTAGE_SPANS_CM:
|
||||
raise KeyError(f"unknown building {name!r}; known: {sorted(FRONTAGE_SPANS_CM)}")
|
||||
exp_min, exp_max = FRONTAGE_SPANS_CM[name]
|
||||
ok = (abs(observed_min - exp_min) <= SPAN_TOLERANCE_CM
|
||||
and abs(observed_max - exp_max) <= SPAN_TOLERANCE_CM)
|
||||
return exp_min, exp_max, ok
|
||||
|
||||
|
||||
def describe_span_failure(name: str, observed_min: float, observed_max: float) -> str:
|
||||
"""A one-line message for a misplaced building, or "" when it is correct."""
|
||||
exp_min, exp_max, ok = span_error(name, observed_min, observed_max)
|
||||
if ok:
|
||||
return ""
|
||||
return (f"{name}: span X {observed_min:.0f}..{observed_max:.0f} does not match the "
|
||||
f"verified {exp_min:.0f}..{exp_max:.0f} (±{SPAN_TOLERANCE_CM:.0f} cm)")
|
||||
Reference in new issue
Block a user