Files
gig-line/Art/blender/site/site_assembly.py
T
zwitschi bc2e421d33 gameplay: designated street crossing — zebra markings, corridor cut, up-facing street
- street_mesh.py: continental zebra markings (0.5 m bars, 0.5 m gaps, 3 m
  band) at all three signalised crossings, triangles not quads (FBX SDK
  refuses to triangulate the crown-twisted quads), stripes +6 cm above the
  DEM so they stay above the road surface; winding flipped so every street
  surface faces up (road/sidewalks/markings were 100% down-facing and
  invisible in engine once the terrain stopped covering them).
- terrain_from_dem.py: cut a 10.7 m corridor around the Hollywood Blvd
  centreline so road, kerbs and sidewalks are not buried under the terrain;
  sidewalks themselves cover the transition band, walk-check stays at 0 holes.
- Blender builders save with a writability guard + fatal failure: *.blend
  is LFS-lockable and checks out read-only; a failed save previously left
  downstream steps building from stale files.
- ue_import_site.py: Crossing_Markings imports with no collision (paint on
  the road); ue_build_demo_map.py stages it and chmods the lockable .umap
  before saving (save_map silently returned False on read-only maps).
- ue_build_review_map.py: demo-crossing camera moved to the real crossing
  (s=130.1 m -> UE -24291,-1340) and fixed the ensure_writable path (was
  missing the .umap suffix, so the chmod never fired).
2026-11-07 15:46:45 +01:00

210 lines
8.0 KiB
Python

"""Assemble the site: blockout + terrain + street in one file, and pre-check walkability.
B=/var/lib/flatpak/app/org.blender.Blender/x86_64/stable/active/files
LD_LIBRARY_PATH="$B/blender/lib" "$B/blender/blender" -b --factory-startup \
--python Tools/site_assembly.py -- --out Art/blender/site/site-2026-11-06.blend
The three source files are opened/appended, not regenerated. Objects are grouped
into `Site_Blockout`, `Site_Terrain`, and `Site_Street` so the next step — collision
authoring in Unreal — can address them by category.
The walkability check is a pre-check, not the `0.1.0` release check: it raycasts
straight down on a 1 m grid over the walking route (both sidewalks and the
designated crossing) and reports holes (no hit within 5 m) and steps (height jumps
between adjacent samples). Anything that would drop the player is caught here
rather than in the engine.
"""
import argparse
import glob
import math
import os
import shutil
import subprocess
import sys
import bpy
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "..", "..", "Geo", "tools"))
import osm_common as oc # noqa: E402
ORIGIN_E = 376515.389
ORIGIN_N = 3774224.083
def find_proj():
env = os.environ.get("PROJ_BIN")
if env and os.path.exists(env):
return env
which = shutil.which("proj")
if which:
return which
matches = sorted(glob.glob(os.path.expanduser(
"~/.local/share/flatpak/app/org.qgis.qgis/x86_64/stable/*/files/bin/proj")))
if not matches:
raise SystemExit("no PROJ binary; set PROJ_BIN")
return matches[0]
def project_to_local(points, proj_bin):
"""WGS84 (lat, lon) -> project-local X/Y in one PROJ call."""
app_files = os.path.dirname(os.path.dirname(proj_bin))
env = dict(os.environ)
env["LD_LIBRARY_PATH"] = f"{app_files}/lib:{env.get('LD_LIBRARY_PATH', '')}"
payload = "\n".join(f"{lon:.9f} {lat:.9f}" for lat, lon in points) + "\n"
out = subprocess.run([proj_bin, "+proj=utm", "+zone=11", "+datum=NAD83", "-f", "%.4f"],
input=payload, capture_output=True, text=True, env=env, check=True)
return [(float(a) - ORIGIN_E, float(b) - ORIGIN_N)
for a, b in (line.split() for line in out.stdout.strip().splitlines())]
APPEND = {
"Art/blender/terrain/terrain-2026-11-06.blend": ["Terrain_DEM"],
"Art/blender/street/street-2026-11-06.blend": [
"Road_Hollywood", "Sidewalk_South", "Sidewalk_North", "Kerb_South", "Kerb_North",
"Crossing_Markings",
],
}
BLOCKOUT = "Art/blender/blockout/blockout-2026-11-06.blend"
def parse_args():
argv = sys.argv
argv = argv[argv.index("--") + 1:] if "--" in argv else []
ap = argparse.ArgumentParser()
ap.add_argument("--out", required=True)
ap.add_argument("--osm", default="Geo/raw/osm-api-map-2026-11-06.osm")
ap.add_argument("--sample", type=float, default=1.0, help="walkability sample spacing in metres")
return ap.parse_args(argv)
def collection(name):
coll = bpy.data.collections.get(name)
if coll is None:
coll = bpy.data.collections.new(name)
bpy.context.scene.collection.children.link(coll)
return coll
def move_to(obj, coll):
for parent in list(obj.users_collection):
parent.objects.unlink(obj)
coll.objects.link(obj)
def main():
args = parse_args()
bpy.ops.wm.open_mainfile(filepath=os.path.abspath(BLOCKOUT))
for path, names in APPEND.items():
blend = os.path.abspath(path)
if not os.path.exists(blend):
raise SystemExit(f"missing source file: {blend}")
for name in names:
bpy.ops.wm.append(
filepath=f"{blend}/Object/{name}",
directory=f"{blend}/Object",
filename=name,
)
terrain_coll = collection("Site_Terrain")
street_coll = collection("Site_Street")
blockout_coll = collection("Site_Blockout")
for name in APPEND["Art/blender/street/street-2026-11-06.blend"]:
obj = bpy.data.objects.get(name)
if obj and name != "Terrain_DEM":
move_to(obj, street_coll)
for name in ("Terrain_DEM",):
obj = bpy.data.objects.get(name)
if obj:
move_to(obj, terrain_coll)
for obj in list(bpy.data.objects):
if obj.users_collection and obj.users_collection[0].name.startswith("osm-api-map"):
move_to(obj, blockout_coll)
meshes = [o for o in bpy.data.objects if o.type == "MESH"]
for obj in meshes:
obj.data.calc_loop_triangles()
tris = sum(len(o.data.loop_triangles) for o in meshes)
print(f"site objects: {len(bpy.data.objects)} ({len(meshes)} mesh), "
f"triangles {tris}, collections: {sorted(c.name for c in bpy.data.collections)}")
# walkability pre-check
depsgraph = bpy.context.evaluated_depsgraph_get()
scene = bpy.context.scene
block_coll = bpy.data.collections.get("Site_Blockout")
blockout_names = {o.name for o in block_coll.all_objects} if block_coll else set()
print(f"walk check ignores {len(blockout_names)} blockout objects (marquees, canopies, roofs)")
def ground(x, y):
"""Lowest-ish walkable hit: skip building meshes such as marquees overhead."""
origin_z = 60.0
for _ in range(8):
hit, loc, _n, _i, obj, _m = scene.ray_cast(
depsgraph, (x, y, origin_z), (0.0, 0.0, -1.0), distance=200.0)
if not hit:
return None, None
if obj.name not in blockout_names:
return loc.z, obj.name
origin_z = loc.z - 0.02
return None, None
nodes, ways = oc.load(os.path.abspath(args.osm))
line, _lat0, _lon0, _lengths, block_len = oc.block_extent(
nodes, ways, "Hollywood Boulevard", "North Orange Drive", "North Highland Avenue")
# route in local metres: both sidewalks and the designated crossing. Points are
# computed in the mesh frame via PROJ — the OSM spherical approximation is
# rotated by ~0.75 deg against UTM here, which is metres over a 280 m block.
lat0 = sum(p[0] for p in line) / len(line)
lon0 = sum(p[1] for p in line) / len(line)
def lat_lon(s, offset):
return oc.point_at(line, _lengths, s, offset, lat0, lon0)
route_specs = {
"south sidewalk": [(s, -12.5) for s in [i * args.sample for i in range(int(block_len))]],
"north sidewalk": [(s, 13.0) for s in [i * args.sample for i in range(int(block_len))]],
"crossing at 130 m": [(130.0, o) for o in [i * args.sample - 12.5 for i in range(26)]],
}
flat = [(spec, lat_lon(s, off)) for spec, samples in route_specs.items() for s, off in samples]
local = project_to_local([ll for _spec, ll in flat], find_proj())
routes = {}
for (spec, _ll), xy in zip(flat, local):
routes.setdefault(spec, []).append(xy)
for label, samples in routes.items():
heights, holes = [], 0
for x, y in samples:
z, _hit = ground(x, y)
if z is None:
holes += 1
heights.append(None)
else:
heights.append(z)
deltas = [abs(b - a) for a, b in zip(heights, heights[1:])
if a is not None and b is not None]
max_step = max(deltas) if deltas else float("nan")
known = [h for h in heights if h is not None]
print(f" {label:18} samples {len(heights):3} holes {holes} "
f"Z {min(known):+.2f}..{max(known):+.2f} max step {max_step*100:.1f} cm")
out = os.path.abspath(args.out)
os.makedirs(os.path.dirname(out), exist_ok=True)
# *.blend is LFS-lockable, so a fresh checkout marks it read-only. Make it
# writable again or the save fails and downstream steps build from a stale
# file — which is worse than failing here.
if os.path.exists(out) and not os.access(out, os.W_OK):
os.chmod(out, 0o644)
result = bpy.ops.wm.save_as_mainfile(filepath=out)
if result != {"FINISHED"}:
raise RuntimeError(f"could not save {out}: {result}")
print(f"saved {out}")
if __name__ == "__main__":
main()