Merged PR #21: 0.1.1 collision patch (flush sidewalk edge, graded ramps) Co-authored-by: Zwitschi <zwitschi82@gmail.com>
224 lines
8.8 KiB
Python
224 lines
8.8 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)
|
|
|
|
# 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()
|