feat: assemble the site and pre-check walkability
site-2026-11-06.blend gathers terrain, street and blockout into categorised collections: 281 objects, ~58k triangles. A downward raycast on a 1 m grid along both sidewalks and the designated crossing finds no holes, so nothing drops the player on either side. Two steps are flagged: 15.8 cm at the sidewalk's outer terrain seam and 10-13 cm ramps at the crossing flush transitions, steeper than a real 8% ramp. Sampling is projected through PROJ: placing points with the OSM spherical approximation put them metres off the UTM meshes and reported 91 phantom holes.
This commit is contained in:
1 parent
d42e39faa9
commit
cc209cde01
5 files changed
+239
-1
No files matched your search
@@ -0,0 +1,201 @@
|
||||
"""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", "scripts"))
|
||||
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",
|
||||
],
|
||||
}
|
||||
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)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=out)
|
||||
print(f"saved {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user