feat(facades): 0.1.6 retail frontage first pass — Lee Drug and 6806-6820

Models the two retail buildings beside the hero from their own photograph rather
than the generic kit: bronze cornice, mural row, lettering band, stepped Deco
parapet and rounded corner tower on 6800 Lee Drug; burgundy fascia, pilasters,
white-framed windows and banded parapet on 6806-6820. Reference analysis written
before any geometry (Art/blender/retail/ref-analysis.md), every unmeasured
proportion carried into the part report as an assumption.

6822 Budget is deferred, not guessed: no photograph of it exists and the 6.0 x 5.5 m
sliver cannot be read out of the neighbour shot.

Pipeline fixes found on the way:
- render_screenshots.sh called ./Engine/... for the capture step while every
  commandlet used the absolute path; set -e killed the script at 127 *after* the
  maps were rebuilt, so screenshots silently stayed a day old.
- the facade kit applies to every Frontage_*/North_* actor, so it pasted its own
  language over the new modelled facades; it now skips them, and the trim stages as
  RetailTrim_*.
- pure bay arithmetic moved to Tools/_common.py so it is unit-testable without
  Blender (4 new tests, 82 total pass).

Known gaps recorded in Screenshots/README.md, not hidden: the parapet reads as a
blank band, murals and lettering do not resolve at review distance, the tower is a
plain cylinder, and the review camera crops the east end.
This commit is contained in:
zwitschi committed 2026-11-09 09:10:49 +01:00
1 parent 4e703fcfd9
commit 2614d259aa
54 files changed
+715 -50

No files matched your search

+3
View File
@@ -17,7 +17,10 @@ Geographic tools live in [`Geo/tools/`](../Geo/README.md) and the Blender-side b
| --- | --- |
| `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_retail_detail.py` | Imports the two retail trim meshes into `/Game/Hollywood/Frontage` with complex-as-simple collision — the trim decorates a solid the player already collides with, so it must not add snag points. Staged by the demo builder as `RetailTrim_*`. |
| `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`. |
| `retail_frontage_build.py` | Retail frontage trim for **6800 Lee Drug** and **6806–6820** (0.1.6): bronze cornice, mural row, lettering band, stepped Deco parapet and rounded corner tower on Lee Drug; burgundy fascia, pilasters, white-framed windows and banded parapet on 6806–6820. Built in world coordinates from the blockout bounds, one joined mesh per building, JSON part report with the evidence behind every piece. `6822 Budget` is **not** built — no photograph exists. |
| `retail_frontage_build.py` | Retail frontage trim for **6800 Lee Drug** and **6806–6820** (0.1.6): bronze cornice, mural row, lettering band, stepped Deco parapet and rounded corner tower on Lee Drug; burgundy fascia, pilasters, white-framed windows and banded parapet on 6806–6820. Built in world coordinates from the blockout bounds, one joined mesh per building, JSON part report with the evidence behind every piece. `6822 Budget` is **not** built — no photograph exists. |
| `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. |
+27
View File
@@ -0,0 +1,27 @@
"""Shared helpers that must stay importable outside Blender and Unreal.
Blender-side builders import `bpy` at module scope, so nothing in them can be unit
tested. Pure layout arithmetic lives here instead: the builders import it, and
`Tools/tests/` imports it without a 3D application.
Standard library only.
"""
from __future__ import annotations
def bay_centres(x0, x1, margin=0.9, bay=2.2):
"""Centres of equal bays across a frontage, inset by `margin` at each end.
Used by the façade kit and the retail frontage so both use one rhythm. The count
is the span divided by the bay module and rounded, so a 33 m frontage at a 2.2 m
module gets 14 bays rather than one bay per 2.2 m of wall with a stub at the end.
Returns [] when the margins consume the whole frontage.
"""
span = (x1 - x0) - 2.0 * margin
if span <= 0:
return []
count = max(1, int(round(span / bay)))
step = span / count
return [x0 + margin + step * (i + 0.5) for i in range(count)]
+4 -1
View File
@@ -39,7 +39,10 @@ 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 "== 6/6 capture the demo map =="
./Engine/Binaries/Linux/UnrealEditor-Cmd "$PROJ" /Game/Hollywood/DemoMap \
# The engine path is absolute: this step runs from the repo, not from $ENGINE, and a
# bare ./Engine/... failed with "No such file or directory" while every commandlet
# above succeeded through run_cmdlet's cd.
"$ENGINE/Engine/Binaries/Linux/UnrealEditor-Cmd" "$PROJ" /Game/Hollywood/DemoMap \
-game -windowed -ResX=1920 -ResY=1080 -nopause -nosplash -stdout
echo "== collect the demo screenshots =="
+329
View File
@@ -0,0 +1,329 @@
#!/usr/bin/env python3
"""Retail frontage trim for 6800 Lee Drug and 6806–6820 (0.1.6).
Both buildings are bare OSM extrusions with a flat stucco material; what makes them
read as *the* retail run beside the hero is the façade layer: the bronze cornice and
stepped Deco parapet on Lee Drug, and the pilaster-and-fascia rhythm on 6806–6820.
The trim is built **in world coordinates** in the shared frame (metres, X east,
Y north, Z from the project datum), so the export carries its position and the
Unreal staging step is a single spawn at the origin — the same convention as
`Tools/theatre_trim_build.py` and `Tools/export_block_frontage.py`.
blender -b --factory-startup --python Tools/retail_frontage_build.py -- \
--out /tmp/gigline/retail --report /tmp/gigline/retail-report.json
Bounds are **measured**, not assumed: they are the blockout objects' world bounds
(`Lee Drug Building`, `element.019`), which sit at Z 0 with the volume top at 12.10 m.
Vertical proportions come from the single reference photograph and are recorded with
the assumption in `Art/blender/retail/ref-analysis.md`.
6822 Budget is deliberately **not** built: there is no photograph of it, and the TODO
item says so. Guessing its façade would be the misattribution the item warns against.
"""
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 _common # noqa: E402
import fbx_export_preset as fbx_preset # noqa: E402
# --- measured bounds, from the blockout (world metres) ------------------------
# Lee Drug 6800, OSM way 424024149. Front face is the north edge (max Y).
LD_X0, LD_X1 = -33.20, -14.37
LD_FACE_Y = -14.63
LD_H = 12.10
# 6806-6820, OSM way 424024150.
B6_X0, B6_X1 = -66.08, -33.07
B6_FACE_Y = -12.52
B6_H = 12.10
# --- proportions, read off the reference photograph ---------------------------
# Fractions of the 12.10 m height; see Art/blender/retail/ref-analysis.md for how
# they were read and why they are assumptions at ±0.03 H.
LD_SHOP_TOP = 0.36 * LD_H # 4.36 m: shopfront glazing / cornice soffit
LD_CORNICE_TOP = 0.43 * LD_H # 5.20 m: top of the bronze band
LD_MURAL_Z = (0.49 * LD_H, 0.61 * LD_H)
LD_LETTER_Z = (0.65 * LD_H, 0.76 * LD_H)
LD_PARAPET_Z = 0.82 * LD_H # 9.92 m: bottom of the stepped parapet
B6_SHOP_TOP = 0.32 * B6_H # 3.87 m
B6_FASCIA_TOP = 0.38 * B6_H # 4.60 m
B6_WINDOW_Z = (0.45 * B6_H, 0.60 * B6_H)
B6_PARAPET_Z = 0.62 * B6_H # 7.50 m
BAY = 2.20 # mural / bay rhythm on Lee Drug, assumed
PROJECT = 0.55 # bronze cornice projection, assumed
TOWER_R = 2.10 # Lee Drug corner tower radius, assumed
PARTS: list[dict] = []
def make_material(name, rgb, rough=0.6, metallic=0.0):
"""Principled material with an explicit colour; the export preset carries it as a slot."""
mat = bpy.data.materials.new(name)
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get("Principled BSDF")
bsdf.inputs["Base Color"].default_value = (rgb[0], rgb[1], rgb[2], 1.0)
bsdf.inputs["Roughness"].default_value = rough
bsdf.inputs["Metallic"].default_value = metallic
return mat
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 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, z, radius, height, material, vertices=28):
bpy.ops.mesh.primitive_cylinder_add(vertices=vertices, radius=radius, depth=height,
location=(x, y, z))
obj = bpy.context.active_object
obj.name = name
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
obj.data.materials.append(material)
return obj
def bay_centres(x0, x1, margin=0.9, bay=BAY):
"""Bay centres; the arithmetic lives in `_common` so it is testable without Blender."""
return _common.bay_centres(x0, x1, margin=margin, bay=bay)
def note(obj, evidence):
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],
"evidence": evidence,
})
print(f"[retail] {obj.name:26} {obj.dimensions.x:6.2f} x {obj.dimensions.y:5.2f} "
f"x {obj.dimensions.z:5.2f} m ({evidence})")
def build_lee_drug(mats):
"""6800 Lee Drug: bronze cornice, mural row, lettering band, stepped Deco parapet,
rounded corner tower."""
stucco, bronze, mural, glass, white = (mats["stucco"], mats["bronze"], mats["mural"],
mats["glass"], mats["white"])
parts = []
cx = (LD_X0 + LD_X1) / 2.0
width = LD_X1 - LD_X0
# Bronze cornice: the strongest horizontal line on the building.
cornice = cube("LD_Cornice", cx, LD_FACE_Y + PROJECT / 2.0,
(LD_SHOP_TOP + LD_CORNICE_TOP) / 2.0,
width + 0.4, PROJECT, LD_CORNICE_TOP - LD_SHOP_TOP, bronze)
parts.append((cornice, "photographs: projecting bronze cornice over the shopfront; "
f"{PROJECT:.2f} m projection assumed"))
# Shopfront glazing, recessed behind the face, one panel per bay.
for i, bx in enumerate(bay_centres(LD_X0, LD_X1, margin=1.2), start=1):
panel = cube(f"LD_ShopGlass_{i}", bx, LD_FACE_Y + 0.12, LD_SHOP_TOP / 2.0,
BAY - 0.30, 0.10, LD_SHOP_TOP - 0.55, glass)
parts.append((panel, "photographs: full-height shopfront glazing; bay width and "
"the 0.55 m kickplate are assumed (a bus hides this zone)"))
mullion = cube(f"LD_Mullion_{i}", bx - (BAY - 0.30) / 2.0, LD_FACE_Y - 0.02,
LD_SHOP_TOP / 2.0, 0.10, 0.16, LD_SHOP_TOP - 0.55, bronze)
parts.append((mullion, "assumption: slim mullion at each bay boundary"))
# Celebrity-portrait panels, one per bay, in the band above the cornice.
z0, z1 = LD_MURAL_Z
for i, bx in enumerate(bay_centres(LD_X0, LD_X1, margin=1.2), start=1):
panel = cube(f"LD_Mural_{i}", bx, LD_FACE_Y - 0.04, (z0 + z1) / 2.0,
BAY - 0.75, 0.08, z1 - z0, mural)
parts.append((panel, "photographs: row of black-framed portrait panels; count and "
"size assumed from the visible row"))
# Lettering band: a recessed plate, not text. Reproducing the lettering needs a
# decal, which is a materials-pass job.
lz0, lz1 = LD_LETTER_Z
letter = cube("LD_LetterBand", cx, LD_FACE_Y - 0.03, (lz0 + lz1) / 2.0,
width - 3.0, 0.06, lz1 - lz0, mural)
parts.append((letter, "photographs: large dark lettering across the upper wall; "
"modelled as a recessed plate, the text itself is a decal job"))
# Stepped Deco parapet: wall band plus four horizontal reveals.
para = cube("LD_Parapet", cx, LD_FACE_Y + 0.10, (LD_PARAPET_Z + LD_H) / 2.0,
width + 0.2, 0.30, LD_H - LD_PARAPET_Z, stucco)
parts.append((para, "photographs: parapet steps above the frontage wall; "
"thickness assumed"))
for i in range(4):
z = LD_PARAPET_Z + (LD_H - LD_PARAPET_Z) * (i + 1) / 5.0
groove = cube(f"LD_ParapetReveal_{i + 1}", cx, LD_FACE_Y - 0.03, z,
width + 0.2, 0.07, 0.10, mural)
parts.append((groove, "photographs: four horizontal reveal lines in the parapet; "
"count read off the frame"))
# Rounded corner tower at the east end, stepped twice at the top. The centre
# sits INSIDE the footprint so the tower projects 0.35 m from the face instead of
# bulging 4 m into the pavement, which the first run did.
tower_y = LD_FACE_Y - TOWER_R + 0.35
tower = cylinder("LD_CornerTower", LD_X1 - TOWER_R, tower_y,
(LD_H - 0.9) / 2.0, TOWER_R, LD_H - 0.9, stucco)
parts.append((tower, "photographs: rounded Deco corner tower rising above the "
f"frontage; {TOWER_R:.2f} m radius assumed"))
# Cap rings stack exactly to the measured 12.10 m top: 11.20 -> 11.75 -> 12.10.
cap_z = LD_H - 0.9
for i, (dh, radius) in enumerate(((0.55, TOWER_R - 0.35), (0.35, TOWER_R - 0.70)), start=1):
cap = cylinder(f"LD_TowerCap_{i}", LD_X1 - TOWER_R, tower_y,
cap_z + dh / 2.0, radius, dh, white)
parts.append((cap, "photographs: two stepped cap rings on the tower"))
cap_z += dh
return parts
def build_6806(mats):
"""6806–6820: burgundy fascia, brown pilasters, white-framed windows, banded parapet."""
stucco, bronze, glass, white, burgundy = (mats["stucco"], mats["bronze"], mats["glass"],
mats["white"], mats["burgundy"])
parts = []
cx = (B6_X0 + B6_X1) / 2.0
width = B6_X1 - B6_X0
fascia = cube("B6_Fascia", cx, B6_FACE_Y - 0.02,
(B6_SHOP_TOP + B6_FASCIA_TOP) / 2.0, width, 0.22,
B6_FASCIA_TOP - B6_SHOP_TOP, burgundy)
parts.append((fascia, "photographs: dark fascia band over the shopfront; colour "
"sampled, height assumed"))
centres = bay_centres(B6_X0, B6_X1, margin=1.0)
for i, bx in enumerate(centres, start=1):
panel = cube(f"B6_ShopGlass_{i}", bx, B6_FACE_Y + 0.12, B6_SHOP_TOP / 2.0,
BAY - 0.25, 0.10, B6_SHOP_TOP - 0.45, glass)
parts.append((panel, "photographs: framed shopfront glazing behind the fascia"))
# Brown pilaster on every second bay boundary, as the photo shows.
if i % 2 == 1:
pil = cube(f"B6_Pilaster_{i}", bx, B6_FACE_Y - 0.10,
(B6_FASCIA_TOP + B6_PARAPET_Z) / 2.0, 0.40, 0.22,
B6_PARAPET_Z - B6_FASCIA_TOP, bronze)
parts.append((pil, "photographs: brown pilaster strips dividing the frontage"))
wz0, wz1 = B6_WINDOW_Z
for i, bx in enumerate(centres, start=1):
frame = cube(f"B6_WindowFrame_{i}", bx, B6_FACE_Y - 0.06, (wz0 + wz1) / 2.0,
1.70, 0.14, wz1 - wz0, white)
parts.append((frame, "photographs: white-framed upper windows with security bars; "
"count and size assumed"))
pane = cube(f"B6_WindowGlass_{i}", bx, B6_FACE_Y + 0.02, (wz0 + wz1) / 2.0,
1.46, 0.08, wz1 - wz0 - 0.24, glass)
parts.append((pane, "photographs: dark glazing inside each frame"))
para = cube("B6_Parapet", cx, B6_FACE_Y + 0.08, (B6_PARAPET_Z + B6_H) / 2.0,
width + 0.2, 0.26, B6_H - B6_PARAPET_Z, stucco)
parts.append((para, "photographs: parapet band above the window storey; thickness assumed"))
for i in range(3):
z = B6_PARAPET_Z + (B6_H - B6_PARAPET_Z) * (i + 1) / 4.0
groove = cube(f"B6_ParapetReveal_{i + 1}", cx, B6_FACE_Y - 0.03, z,
width + 0.2, 0.07, 0.09, bronze)
parts.append((groove, "photographs: three reveal lines in the parapet; count assumed"))
return parts
def join_parts(parts, name):
"""Join one building's parts into a single mesh so Unreal imports one asset.
The per-part dimensions and evidence are recorded before this runs, so joining
loses nothing from the report. Materials survive as separate slots.
"""
bpy.ops.object.select_all(action="DESELECT")
for obj, _text in parts:
obj.select_set(True)
bpy.context.view_layer.objects.active = parts[0][0]
bpy.ops.object.join()
joined = bpy.context.active_object
joined.name = name
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
return joined
def export_objects(objects, out_dir):
count = 0
for obj in objects:
bpy.ops.object.select_all(action="DESELECT")
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
fbx_preset.export_static(os.path.join(out_dir, "SM_" + obj.name + ".fbx"),
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/retail")
ap.add_argument("--report", default=None)
args = ap.parse_args(argv)
os.makedirs(args.out, exist_ok=True)
clear_scene()
mats = {
"stucco": make_material("M_Retail_Stucco", (0.86, 0.83, 0.76), rough=0.72),
"bronze": make_material("M_Retail_Bronze", (0.20, 0.11, 0.06), rough=0.45, metallic=0.35),
"mural": make_material("M_Retail_Mural", (0.035, 0.032, 0.030), rough=0.55),
"glass": make_material("M_Retail_Glass", (0.035, 0.045, 0.055), rough=0.15),
"white": make_material("M_Retail_White", (0.90, 0.88, 0.84), rough=0.60),
"burgundy": make_material("M_Retail_Burgundy", (0.20, 0.09, 0.09), rough=0.55),
}
ld_parts = build_lee_drug(mats)
b6_parts = build_6806(mats)
for obj, text in ld_parts + b6_parts:
note(obj, text)
# One mesh per building: 85 separate actors over the frontage would cost more
# draw calls than the trim is worth, and the staging step stays two spawns.
joined = [join_parts(ld_parts, "Retail_LeeDrug"), join_parts(b6_parts, "Retail_6806")]
for obj in joined:
print(f"[retail] joined {obj.name}: {len(obj.data.vertices)} verts, "
f"{len(obj.data.materials)} material slot(s), "
f"X {min(v.co.x for v in obj.data.vertices):.2f}.."
f"{max(v.co.x for v in obj.data.vertices):.2f}")
exported = export_objects(joined, args.out)
print(f"[retail] exported {exported} object(s) to {args.out}")
if args.report:
with open(args.report, "w", encoding="utf-8") as handle:
json.dump({
"scope": ["6800 Lee Drug (way 424024149)", "6806-6820 (way 424024150)"],
"deferred": ["6822 Budget (way 424024144): no photograph, not modelled"],
"bounds_m": {
"lee_drug": [LD_X0, LD_X1, LD_FACE_Y, LD_H],
"b6806": [B6_X0, B6_X1, B6_FACE_Y, B6_H],
},
"reference": "Art/reference/retail-neighbours/01-lee-drug-and-neighbor.jpg "
"(Gb321, CC BY-SA 4.0)",
"analysis": "Art/blender/retail/ref-analysis.md",
"parts": PARTS,
}, handle, indent=2)
print(f"[retail] report -> {args.report}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+32
View File
@@ -0,0 +1,32 @@
"""Tests for the pure layout helper in Tools/retail_frontage_build.py.
The builder itself needs Blender; `bay_centres` does not, so the bay rhythm the
façade depends on is checkable without starting Blender or Unreal.
"""
import unittest
from Tools._common import bay_centres
class BayCentreTests(unittest.TestCase):
def test_centres_stay_inside_the_margins(self):
centres = bay_centres(-33.20, -14.37, margin=1.2)
self.assertGreaterEqual(min(centres), -33.20 + 1.2 - 1e-9)
self.assertLessEqual(max(centres), -14.37 - 1.2 + 1e-9)
def test_bays_are_evenly_spaced(self):
centres = bay_centres(-66.08, -33.07, margin=1.0)
steps = {round(b - a, 6) for a, b in zip(centres, centres[1:])}
self.assertEqual(len(steps), 1, f"uneven bay spacing: {steps}")
def test_bay_count_is_the_round_of_span_over_bay(self):
# 33.01 m frontage, 1.0 m margin each end -> 31.01 m -> 14 bays at 2.2 m.
self.assertEqual(len(bay_centres(-66.08, -33.07, margin=1.0)), 14)
def test_degenerate_frontage_returns_nothing(self):
self.assertEqual(bay_centres(0.0, 1.0, margin=1.0), [])
if __name__ == "__main__":
unittest.main()
+10 -2
View File
@@ -235,9 +235,17 @@ def main() -> None:
removed += 1
log(f"cleared {removed} previous kit actors")
# The kit is the fallback for buildings that have nothing but a blockout. These
# two are modelled from their own photograph in 0.1.6
# (Tools/retail_frontage_build.py); pasting the generic kit over them put a
# second, contradicting facade language on the same wall.
MODELLED = ("Frontage_SM_LeeDrug_6800", "Frontage_SM_Hollywood_6806")
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)}")
if a.get_actor_label().startswith(("Frontage_", "North_"))
and a.get_actor_label() not in MODELLED]
skipped = [a.get_actor_label() for a in unreal.EditorLevelLibrary.get_all_level_actors()
if a.get_actor_label() in MODELLED]
log(f"frontage actors: {len(targets)} (kit skipped on modelled facades: {skipped})")
total = 0
for actor in targets:
total += apply_to_actor(actor, kit, materials)
+19
View File
@@ -597,6 +597,25 @@ def build():
staged_frontage += 1
log(f"staged {staged_frontage} frontage buildings")
# --- retail frontage trim (0.1.6) -------------------------------------
# The two retail buildings beside the hero are modelled from their own
# photograph (Art/reference/retail-neighbours/); the trim is laid over the
# blockout volume, so it keeps the trim's own sampled materials rather than a
# look-dev override. 6822 Budget has no photograph and stays a bare volume.
for asset_name, label in (("SM_Retail_LeeDrug", "6800 Lee Drug trim"),
("SM_Retail_6806", "6806-6820 trim")):
mesh = unreal.EditorAssetLibrary.load_asset(f"/Game/Hollywood/Frontage/{asset_name}")
if mesh is None:
log(f"MISSING trim {asset_name} — run Tools/ue_import_retail_detail.py")
continue
# RetailTrim_ rather than Frontage_ so the kit assembler leaves it alone.
actor = spawn(unreal.StaticMeshActor, unreal.Vector(0, 0, 0), f"RetailTrim_{asset_name}")
if actor:
comp = actor.static_mesh_component
comp.set_static_mesh(mesh)
comp.set_mobility(unreal.ComponentMobility.MOVABLE)
log(f"staged: {label}")
# --- player start: north sidewalk, facing the hero frontage -------------
# Signs were measured from the imported meshes rather than assumed: the FBX
# preset flips north, so in Unreal the road spans Y -1382..+1047, the NORTH
+6
View File
@@ -300,6 +300,12 @@ try:
# on the north sidewalk looking along the crosswalk itself, which is
# the pedestrian's view and shows the zebra bars as bars.
("demo-crossing", unreal.Vector(-15250, -2500, 400), unreal.Vector(-14956, 400, 150)),
# 0.1.6 retail frontage: 6800 Lee Drug (X -3340..-1417, face Y +1467)
# and 6806-6820 (X -6618..-3297, face Y +1252). Stand on the north
# sidewalk at 2 m, look south with a level horizon so the parapets and
# the cornice line read as horizontals. 68 deg covers both frontages at
# this distance; the ends crop rather than the elevation leaning.
("demo-retail-frontage", unreal.Vector(-4700, -2000, 200), unreal.Vector(-4700, 1400, 560), 68.0),
# the two north-side background buildings (TCL, Tussaud's) seen from
# the south across Hollywood Blvd. Probed world-space (2026-11-08):
# TCL bbox X -22889..-18442, Y -9431..-1802; Tussaud's X -27071..
+94
View File
@@ -0,0 +1,94 @@
# Import the retail frontage trim (6800 Lee Drug, 6806–6820) into Unreal.
#
# blender -b --factory-startup --python Tools/retail_frontage_build.py -- \
# --out /tmp/gigline/retail --report /tmp/gigline/retail-report.json
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
# -script=<repo>/Tools/ue_import_retail_detail.py -nullrhi -unattended -nopause -stdout
#
# Two meshes arrive, one per building, each already at its world position in the
# project frame (the FBX carries the offset), so staging spawns at the origin.
# Destination: /Game/Hollywood/Frontage/<name> — same folder as the block frontage
# so the demo builder stages them together.
#
# Result file: /tmp/gigline/retail-import.txt
import glob
import os
import unreal
SRC = "/tmp/gigline/retail"
DEST = "/Game/Hollywood/Frontage"
RESULT = "/tmp/gigline/retail-import.txt"
lines = []
def log(msg):
lines.append(str(msg))
unreal.log(str(msg))
def import_fbx(path, name):
task = unreal.AssetImportTask()
task.filename = path
task.destination_path = DEST
task.destination_name = 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
task.options = ui
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
mesh = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{name}")
if mesh is None:
log(f"MISSING after import: {DEST}/{name}")
return None
# Trim is decoration on a solid the player already collides with: no collision
# of its own, otherwise the cornice and mullions become snag points.
try:
body = mesh.get_editor_property("body_setup")
body.set_editor_property("collision_trace_flag",
unreal.CollisionTraceFlag.CTF_USE_COMPLEX_AS_SIMPLE)
mesh.set_editor_property("body_setup", body)
except Exception as exc: # noqa: BLE001
log(f"{name}: collision flag not set ({exc!r})")
box = mesh.get_bounding_box()
mn, mx = box.min, box.max
log(f"{name:22} X {mn.x:8.0f}..{mx.x:8.0f} Y {mn.y:7.0f}..{mx.y:7.0f} "
f"Z {mn.z:5.0f}..{mx.z:5.0f} slots {len(mesh.get_editor_property('static_materials'))}")
return mesh
def main():
files = sorted(glob.glob(os.path.join(SRC, "*.fbx")))
if not files:
log(f"no FBX in {SRC}; run Tools/retail_frontage_build.py first")
return
for path in files:
import_fbx(path, os.path.splitext(os.path.basename(path))[0])
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
try:
main()
except Exception as exc: # noqa: BLE001
import traceback
log(f"FAILED: {type(exc).__name__}: {exc}")
log(traceback.format_exc())
with open(RESULT, "w") as f:
f.write("\n".join(lines) + "\n")
+12
View File
@@ -48,6 +48,18 @@
"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"
},
{
"name": "demo-retail-frontage",
"shot": "Screenshots/demo/review-demo-retail-frontage.png",
"size": [
1920,
1080
],
"references": [
"Art/reference/retail-neighbours/01-lee-drug-and-neighbor.jpg"
],
"expect": "The retail run beside the hero from across the street: 6800 Lee Drug's bronze cornice, mural row and stepped parapet next to 6806-6820's pilasters and window storey, daylight"
}
]
}