feat(kit): façade module kit at the 4.96 m bay, assembled onto eight buildings
- Tools/facade_kit_build.py: the reusable kit (pilaster, sill, lintel, window bay, shopfront bay, door bay, cornice, parapet + coping) at the measured 4.96 m bay and 3.10 m storey; 16 meshes exported per object through the shared preset with a JSON part report recording dims and evidence. - Tools/ue_import_facade_kit.py: imports to /Game/Hollywood/Kit with complex-as-simple collision, so thin panes keep their real faces. - Tools/ue_apply_facade_kit.py: assembles from each building's own bounding box (bays = round(width/4.96), storeys above the ground floor) with shopfronts at street level, windows above, pilasters on bay boundaries, cornice and parapet at the top. Clears a previous application; unique labels per building. 638 pieces across six frontage buildings and both north buildings. Two bugs fixed en route: the window piece was modelled entirely at +Y so it sat inside the solid wall and was invisible (now projects 3 cm proud with glazing 6 cm inset), and clearing materials mid-build invalidated handles. Screenshots re-rendered; window rhythm now reads (edge transitions per row rose from flat to ~197 on the spawn view).
This commit is contained in:
1 parent
73e535a24b
commit
c9d603f754
46 files changed
+721
-32
No files matched your search
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -568,3 +568,26 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO.
|
||||
- The script needs `ROOT` in `ue_build_demo_map.py` and `sys` imported; forgetting
|
||||
either fails with `NameError` and an exit code of 0 from the commandlet (always
|
||||
read the result file under /tmp/gigline).
|
||||
|
||||
## 2026-11-09 — façade module kit (0.1.4)
|
||||
|
||||
- `Tools/facade_kit_build.py` builds the kit at the measured **4.96 m bay** and
|
||||
**3.10 m storey**: pilaster, sill, lintel, window bay, shopfront bay, door bay,
|
||||
cornice, parapet + coping = 16 meshes, exported per-object with
|
||||
`fbx_export_preset` plus a JSON part report (dims + evidence per piece).
|
||||
- `Tools/ue_import_facade_kit.py` → `/Game/Hollywood/Kit/*`, complex-as-simple
|
||||
collision (a convex hull would fence off the shopfront glazing).
|
||||
- `Tools/ue_apply_facade_kit.py` lays the kit out from each building's own bbox:
|
||||
`bays = round(width / 496 cm)`, storeys from height minus the 310 cm ground
|
||||
floor. **638 pieces** over six frontage buildings + both north buildings.
|
||||
- **Bug worth remembering:** the first window piece was modelled entirely at +Y
|
||||
(into the wall), so every window sat *inside* the solid blockout wall and was
|
||||
invisible in every render while the placement maths looked right. The kit now
|
||||
projects the surround 3 cm proud with the glazing 6 cm behind its face — a
|
||||
solid-wall blockout cannot carry a real recess, so the piece has to project.
|
||||
- Blender gotcha: clearing the scene *including materials* invalidates handles
|
||||
already taken ("StructRNA of type Material has been removed"); clear objects and
|
||||
orphan meshes only, and create the material after the clear.
|
||||
- The kit meshes are spawned at the world origin with their position baked into
|
||||
the mesh (like the frontage exports), so placement uses the bbox directly and
|
||||
must not add the actor location.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -16,7 +16,7 @@ Suggested blender skills: **procedural-modeling** + **geometry-nodes** (the faç
|
||||
### Patch tasks - active - 01a1b26b-3c94-707f-9665-2f6b4a79b564 (branch `facades-0.1.4`)
|
||||
|
||||
- [ ] **Finish the Blosm blockout cleaning/detailing (moved from the deferred `0.1.0` item).** Import state: `Art/blender/blockout/blockout-2026-11-06.blend` (86 meshes, EPSG:26911, 0.0 cm affine residual) with cleaning tooling in `Geo/tools/` and `Art/blender/blockout/blosm_blockout.py`. To do here: (a) untextured roofs/facades → **Replace placeholder materials** below; (b) Blosm `setupScript` façade styles → **Give OSM buildings a façade style** below; (c) the 3 of 15 buildings lacking height data are the `0.1.2` placeholder-heights item.
|
||||
- [ ] **Define the façade module.** One set of reusable pieces (bay, pilaster, cornice, parapet, window, shopfront, door) at a fixed module, exported with the `ue_metric_v1` preset so every building assembles from the same kit.
|
||||
- [x] **Define the façade module.** **Done 2026-11-09.** `Tools/facade_kit_build.py` builds the kit at the measured **4.96 m bay**: pilaster, sill, lintel, window bay (surround + inset glazing), shopfront bay (piers, glass, fascia), door bay (jambs, door, threshold), cornice and parapet + coping — 16 meshes, each exported through the shared `ue_metric_v1` preset with a JSON part report recording dims and evidence. `Tools/ue_import_facade_kit.py` imports to `/Game/Hollywood/Kit`. `Tools/ue_apply_facade_kit.py` assembles it from each building's own bounding box: **638 pieces placed across the six frontage buildings and both north buildings** (6922 = 13 bays × 11 storeys, theatre 8 × 5, TCL and Tussaud's 9 × 4, etc.). First pass put the window bays inside the solid wall (invisible) — the kit now projects the surround 3 cm proud with glazing 6 cm behind it.
|
||||
- [A] **Replace placeholder materials.** **In progress 2026-11-09.** `ensure_material()` now builds masonry coursing (world-space course height + joint band + tint, corrected for UE's centimetre WorldPosition) and roughness variation; new `M_FacadeStoneShadow`, `M_FacadeGlass` (specular 0.62 ≈ IOR 1.5 without a refraction pass) and `M_FacadeBanner`; assignment is per **slot** keyed on the imported slot name (it was slot-0 only, which on the hero is the banner, so stone/glass/shadow were still flat). Hero: 4/4 slots, frontage and north buildings 2/2. Remaining: bake real texture maps (brick/stone/asphalt generators) rather than graph-only detail, and give the frontage shopfront line its own material.
|
||||
- [ ] **Give OSM buildings a façade style.** Blosm exposes `scene.blosm.setupScript` (`setup/neoclassical.py` is the example) so a building can carry a real elevation instead of a plain extrusion. Build one style script for the south frontage and one for the north.
|
||||
- [ ] **Hero façade detail pass.** Ionic capitals and fluting, the parapet motto band and medallions as geometry or decal, window rhythm behind the colonnade, and the three portal flights at their photographed proportions.
|
||||
|
||||
@@ -16,6 +16,9 @@ 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`. |
|
||||
| `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. |
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
#!/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 note(obj, note_text):
|
||||
PARTS.append({
|
||||
"object": obj.name,
|
||||
"dims_m": [round(d, 3) for d in obj.dimensions],
|
||||
"location_m": [round(c, 3) for c in obj.location],
|
||||
"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
|
||||
path = os.path.join(out_dir, 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,210 @@
|
||||
#!/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"
|
||||
|
||||
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:
|
||||
kit[mesh.get_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}_"
|
||||
mn, mx = _bounds(comp.static_mesh)
|
||||
# The frontage meshes are spawned at the world origin and carry their position
|
||||
# in the mesh, so the bounding box IS world space (actor location is 0,0,0).
|
||||
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):
|
||||
return (materials or {}).get(name)
|
||||
|
||||
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)}")
|
||||
|
||||
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)
|
||||
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")
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/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
|
||||
|
||||
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
|
||||
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")
|
||||
Reference in new issue
Block a user