"""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) # Sample spacing is independent of coverage: walk the whole block at # `--sample` metres, so a finer spacing measures the gradient instead of # accidentally shortening the route (previously range(int(block_len)) with a # 0.25 m spacing only covered a quarter of the block). along = int(block_len / args.sample) across = int(26.0 / args.sample) route_specs = { "south sidewalk": [(i * args.sample, -12.5) for i in range(along)], "north sidewalk": [(i * args.sample, 13.0) for i in range(along)], "crossing at 130 m": [(130.0, i * args.sample - 12.5) for i in range(across)], } 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 = [(i, abs(b - a)) for i, (a, b) in enumerate(zip(heights, heights[1:])) if a is not None and b is not None] worst_i, max_step = (max(deltas, key=lambda t: t[1]) if deltas else (0, float("nan"))) known = [h for h in heights if h is not None] grade = (max_step / args.sample) * 100 if deltas else float("nan") # Position of the worst step, as arc length / cross offset, so the cause # can be located instead of guessed. if label == "crossing at 130 m": where = f"s=130 off={(worst_i * args.sample - 12.5):+.2f} m" else: where = f"s={worst_i * args.sample:.1f} m" 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 " f"({grade:.1f} % grade) at {where}") 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()