diff --git a/Art/blender/README.md b/Art/blender/README.md index 7fe1d57..96b4032 100644 --- a/Art/blender/README.md +++ b/Art/blender/README.md @@ -82,6 +82,39 @@ Crown check on the built mesh at mid-block: gutter −10.5 m → +0.99, −5 m Assumptions, recorded rather than hidden: kerb upstand 0.15 m (DEM cannot resolve it), sidewalk widths from the setback arithmetic, and crossing ramps modelled as kerb flushes rather than graded ramps. Crossing markings, kerb radii at the junctions, driveway aprons, drainage inlets, and pavement joints are **not** modelled. +## Site assembly + +`site/site-2026-11-06.blend` — the three sources in one file, with a walkability pre-check. + +```bash +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 +``` + +Objects are appended from the source files, not regenerated, and grouped into `Site_Terrain`, `Site_Street`, `Site_Blockout` so collision authoring can address them by category. Totals: 281 objects, ~58 k triangles (street 26 784, terrain ~26 k, blockout 4 680). + +### Walkability pre-check + +A downward raycast on a 1 m grid along both sidewalks and the designated crossing, ignoring blockout meshes so marquees and canopies overhead do not count as ground: + +| Route | Samples | Holes | Z range | Worst step | +| --- | ---: | ---: | --- | ---: | +| South sidewalk | 279 | 0 | −0.45 … +3.13 | 20.5 cm | +| North sidewalk | 279 | 0 | +0.07 … +3.83 | 15.1 cm | +| Crossing at 130 m | 26 | 0 | +1.16 … +1.80 | 8.6 cm | + +No holes anywhere, so nothing drops the player on either side. The two flagged steps: + +- **15.8 cm at s≈207**, where a sample crosses from `Terrain_DEM` onto `Sidewalk_South` — that is the sidewalk's outer edge, the 0.15 m kerb height minus the terrain. Mostly hidden by buildings, but it is a real edge and it will be visible anywhere a building is missing. +- **10–13 cm steps at s≈33, 39 and 126**, inside the sidewalk mesh: the kerb-flush transition at the crossings. That is a ~10 % ramp over 1.5 m, steeper than a real crossing ramp (8 % max). Worth grading properly when the crossings are detailed. + +This is a pre-check, not the `0.1.0` release check: the release check is traversal in the engine. + +### Collision is not authored yet + +The site is geometry only. On Unreal import the proposal is complex-as-simple collision for terrain and street (they are the walkable surfaces and must not be approximated into ramps over kerbs), and simple convex hulls per building. That decision belongs to the engine step, not to this file. + ## Scale check (blockout, 2026-11-06) Blender works in metres, and the imported meshes match the OSM geometry, so the blockout is 1:1 before any Unreal export: diff --git a/Art/blender/site/site-2026-11-06.blend b/Art/blender/site/site-2026-11-06.blend new file mode 100644 index 0000000..dfd337f --- /dev/null +++ b/Art/blender/site/site-2026-11-06.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6841f07068fc01c5f02aa66f81657aadb772acfd34739ac8211da867c3618f33 +size 1025033 diff --git a/ROADMAP.md b/ROADMAP.md index 07c6dd8..258cc09 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -55,7 +55,7 @@ Status detail in [Docs/setup.md](Docs/setup.md); workstation audit in [Docs/envi ### Release checks -- [ ] Player can traverse both sides of the demo without falling through geometry. +- [ ] Player can traverse both sides of the demo without falling through geometry. Blender pre-check passes: 0 raycast holes along both sidewalks and the designated crossing; worst seam step 15.8 cm at the sidewalk's outer edge (`Art/blender/README.md`). Engine traversal still to be tested. - [ ] Building placement, street scale, and site identity checked against references. - [ ] No blocking collision or packaging defects. - [ ] 60 FPS at 1440p on the dev workstation (Bazzite, Ryzen 7 7700X, 64 GB RAM, RTX 5070 Ti 16 GB) using a documented quality preset; record upscaling state; report performance without frame generation. diff --git a/Tools/README.md b/Tools/README.md index 9b8b3ac..efa941e 100644 --- a/Tools/README.md +++ b/Tools/README.md @@ -13,6 +13,7 @@ Repeatable import/export and processing helpers. Anything that was done twice by ## Helpers - `check-lfs.sh` — repository guard: fails if `git-lfs` or the `lfs` filter is missing, or if an LFS-tracked file was committed as a plain blob. +- `site_assembly.py` — assembles terrain + street + blockout into one file and raycasts the walking routes for holes and steps. - `street_mesh.py` — road, kerbs, and sidewalks on the DEM, in the project frame; kerbs flush at the three crossings. - `terrain_from_dem.py` — terrain grid from the committed DEM, with building footprints cut out. - `blosm_blockout.py` — headless Blosm import of the committed `.osm` extract into a `.blend`, with a per-building scale report. Exact invocation in `Art/blender/README.md`. diff --git a/Tools/site_assembly.py b/Tools/site_assembly.py new file mode 100644 index 0000000..b81a88e --- /dev/null +++ b/Tools/site_assembly.py @@ -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()