From d42e39faa9765d3d25b2987694315b3cbd00105d Mon Sep 17 00:00:00 2001 From: Zwitschi Date: Fri, 6 Nov 2026 23:45:28 +0100 Subject: [PATCH] feat: road, kerbs, and sidewalks on the DEM Tools/street_mesh.py builds the corridor in the project frame from the measured profile: carriageway -10.5..+10.5 m, kerbs 0.15 m (0.13 m as built), sidewalks 4.0 m south and 5.3 m north, all elevations sampled from the DEM. Kerbs flush at the three signalised crossings. Crown at mid-block reproduces the cross-sections to the centimetre. --- Art/blender/README.md | 21 ++ Art/blender/street/street-2026-11-06.blend | 3 + Geo/verification/2026-11-06-cross-sections.md | 4 + ROADMAP.md | 2 +- Tools/README.md | 2 + Tools/street_mesh.py | 269 ++++++++++++++++++ 6 files changed, 300 insertions(+), 1 deletion(-) create mode 100644 Art/blender/street/street-2026-11-06.blend create mode 100644 Tools/street_mesh.py diff --git a/Art/blender/README.md b/Art/blender/README.md index 096c982..7fe1d57 100644 --- a/Art/blender/README.md +++ b/Art/blender/README.md @@ -61,6 +61,27 @@ Spot checks against the measured cross-sections: That reproduces both the 3.4 m eastward fall and the ~2 % crown, so the DEM, the cross-sections, and this mesh agree. +## Street corridor + +`street/street-2026-11-06.blend` — road, kerbs, and both sidewalks, built by `Tools/street_mesh.py` from the DEM and the centreline. + +```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/street_mesh.py -- \ + --osm Geo/raw/osm-api-map-2026-11-06.osm \ + --dem Geo/raw/dem-3dep-1m-26911-2026-11-06.tif \ + --out Art/blender/street/street-2026-11-06.blend +``` + +- `Road_Hollywood`: 8 120 verts, 7 812 faces, carriageway −10.5…+10.5 m, DEM elevations plus a 2 cm lift so it does not z-fight the terrain. +- `Sidewalk_South` 4.0 m wide, `Sidewalk_North` 5.3 m wide, both raised by the kerb height. +- `Kerb_South`, `Kerb_North`: vertical faces, mostly **0.13 m** — the 0.15 m assumption minus nothing on the road side — and flushed to 0 within ±2.5 m of each signalised crossing with a 1.5 m blend. Measured from the built mesh: 256 steps at 0.13 m, 15 flush, a few in transition. + +Crown check on the built mesh at mid-block: gutter −10.5 m → +0.99, −5 m → +1.38, centre → +1.52, +5 m → +1.58, +10.5 m → +1.52. The crown sits just north of the centreline, as the cross-sections said. + +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. + ## 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/street/street-2026-11-06.blend b/Art/blender/street/street-2026-11-06.blend new file mode 100644 index 0000000..47871e3 --- /dev/null +++ b/Art/blender/street/street-2026-11-06.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b675a61e3f6d2d97d77383ff2edc522886bbee9fbb84a3ebccdce2e69ba1ba08 +size 510663 diff --git a/Geo/verification/2026-11-06-cross-sections.md b/Geo/verification/2026-11-06-cross-sections.md index 6d62f79..626359f 100644 --- a/Geo/verification/2026-11-06-cross-sections.md +++ b/Geo/verification/2026-11-06-cross-sections.md @@ -44,6 +44,10 @@ Consequence: kerb height and shape are an **assumption** until measured or obser `Art/blender/terrain/terrain-2026-11-06.blend` samples the DEM on a 2 m grid and reproduces these numbers: origin Z 0.00, Orange Drive end +3.39, south gutter +0.93, road centre +1.38, north sidewalk +1.43 relative to the origin ground. The crown therefore survives from DEM to mesh. See `Art/blender/README.md`. +## Reproduced in the street mesh + +`Art/blender/street/street-2026-11-06.blend` carries the same numbers through to geometry: rule at mid-block reads +0.99 at the south gutter, +1.38 at −5 m, +1.52 at the centre, +1.58 at +5 m, +1.52 at the north gutter. Kerb faces measure 0.13 m and flush to zero at the crossings. + ## Open - Kerb height, kerb radius, and ramp slope: assumption until surveyed or measured from photographs. diff --git a/ROADMAP.md b/ROADMAP.md index 16ff4f0..07c6dd8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -42,7 +42,7 @@ Status detail in [Docs/setup.md](Docs/setup.md); workstation audit in [Docs/envi - [ ] Model hero façade for the El Capitan Entertainment Centre site. - [ ] Model surrounding block frontage. - [ ] Model simplified background buildings directly across Hollywood Boulevard. -- [-] Build sidewalks, roads, and collision meshes. Terrain mesh from the DEM exists (`Art/blender/terrain/terrain-2026-11-06.blend`); sidewalks, roads, kerbs and collision are not built. Ground must follow the DEM: 3.94 m fall along the block, ~2 % crown between the gutters at ±10–11 m from the centreline, north sidewalk higher than south. Kerb height is an unresolved assumption — the DEM does not resolve it (`Geo/verification/2026-11-06-cross-sections.md`). +- [-] Build sidewalks, roads, and collision meshes. Terrain, road, sidewalks and kerbs exist (`Art/blender/terrain/`, `street/`); collision and details (crossing markings, kerb radii, aprons) are not built. Ground must follow the DEM: 3.94 m fall along the block, ~2 % crown between the gutters at ±10–11 m from the centreline, north sidewalk higher than south. Kerb height is an unresolved assumption — the DEM does not resolve it (`Geo/verification/2026-11-06-cross-sections.md`). - [ ] Materials and look development for all exported assets. ### Gameplay diff --git a/Tools/README.md b/Tools/README.md index 8c2af35..9b8b3ac 100644 --- a/Tools/README.md +++ b/Tools/README.md @@ -13,6 +13,8 @@ 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. +- `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`. - `commons_fetch.py` — Wikimedia Commons reference fetcher. Search or exact `File:` titles, 1600 px thumbnails, provenance `sidecar.json` per folder. Writes only into the gitignored `Art/reference/`; reference images are never committed. diff --git a/Tools/street_mesh.py b/Tools/street_mesh.py new file mode 100644 index 0000000..454a9e3 --- /dev/null +++ b/Tools/street_mesh.py @@ -0,0 +1,269 @@ +"""Build the street corridor — road, kerbs, sidewalks — on the DEM. + + 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/street_mesh.py -- \ + --osm Geo/raw/osm-api-map-2026-11-06.osm \ + --dem Geo/raw/dem-3dep-1m-26911-2026-11-06.tif \ + --out Art/blender/street/street-2026-11-06.blend + +Everything lands in the project frame from `Geo/manifests/local-origin.md`: +X east, Y north, Z up, metres, origin at Hollywood × Highland. + +Geometry follows the measured profile rather than a guess: + +* carriageway between gutters at ±10.5 m from the centreline (cross-sections + put them at −10 and +10…+12; the crossing length says 20.9 m); +* kerb upstand **0.15 m**, which is an assumption — the DEM does not resolve a + kerb (see `Geo/verification/2026-11-06-cross-sections.md`); +* kerbs are flushed to 0.02 m within ±2.5 m of each signalised crossing, with a + 1.5 m blend, so the crossings are actually usable on foot; +* sidewalk widths 4.0 m south and 5.3 m north, from the DEM-derived setback + arithmetic; +* the DEM supplies every elevation, so the 3.4 m fall and the ~2 % crown come + through unchanged. + +The road surface is lifted 2 cm above the DEM so it does not z-fight the terrain +mesh. When the terrain is cut to the corridor later, that lift can go. +""" + +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 dem_stats # noqa: E402 +import osm_common as oc # noqa: E402 + +ORIGIN_E = 376515.389 +ORIGIN_N = 3774224.083 +ORIGIN_Z = 115.911 + +SOUTH_GUTTER = -10.5 +NORTH_GUTTER = 10.5 +SOUTH_SIDEWALK = -14.5 +NORTH_SIDEWALK = 15.8 +CROSSINGS = (36.6, 130.1, 266.1) + + +def parse_args(): + argv = sys.argv + argv = argv[argv.index("--") + 1:] if "--" in argv else [] + ap = argparse.ArgumentParser() + ap.add_argument("--osm", required=True) + ap.add_argument("--dem", required=True) + ap.add_argument("--out", required=True) + ap.add_argument("--along", type=float, default=1.0) + ap.add_argument("--cross", type=float, default=0.75) + ap.add_argument("--kerb", type=float, default=0.15) + ap.add_argument("--ramp-half-width", type=float, default=2.5) + ap.add_argument("--ramp-blend", type=float, default=1.5) + return ap.parse_args(argv) + + +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_utm(points, proj_bin): + 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 [tuple(float(v) for v in line.split()) for line in out.stdout.strip().splitlines()] + + +class Dem: + def __init__(self, path): + entries, w, h, values = dem_stats.read_tiff(path) + self.w, self.h, self.v = w, h, values + scale, tie = entries[33550], entries[33922] + self.x0 = tie[3] - tie[0] * scale[0] + self.y0 = tie[4] + tie[1] * scale[1] + self.sx, self.sy = scale[0], scale[1] + nodata = entries.get(42113) + self.nodata = float(nodata) if nodata not in (None, "") else None + + def at(self, e, n): + col = (e - self.x0) / self.sx - 0.5 + row = (self.y0 - n) / self.sy - 0.5 + c0, r0 = int(col // 1), int(row // 1) + if c0 < 0 or r0 < 0 or c0 + 1 >= self.w or r0 + 1 >= self.h: + return None + fx, fy = col - c0, row - r0 + p = [] + for r, c in ((r0, c0), (r0, c0 + 1), (r0 + 1, c0), (r0 + 1, c0 + 1)): + val = self.v[r * self.w + c] + if self.nodata is not None and val == self.nodata: + return None + p.append(val) + return (p[0] * (1 - fx) * (1 - fy) + p[1] * fx * (1 - fy) + + p[2] * (1 - fx) * fy + p[3] * fx * fy) + + +def kerb_height(s, base, half_width, blend): + """Full kerb, except flushed near a crossing so people can step off.""" + for crossing in CROSSINGS: + d = abs(s - crossing) + if d <= half_width: + return 0.02 + if d <= half_width + blend: + t = (d - half_width) / blend + return 0.02 + (base - 0.02) * t + return base + + +def strip(rows, offsets, z_at, name, material): + """Build a quad grid from per-row sampled vertices.""" + verts, faces = [], [] + cols = len(offsets) + for r, (_s, base, normal) in enumerate(rows): + for off in offsets: + x = base[0] + off * normal[0] + y = base[1] + off * normal[1] + verts.append((x, y, z_at(r, off, x, y))) + for r in range(len(rows) - 1): + for c in range(cols - 1): + i = r * cols + c + faces.append((i, i + 1, i + cols + 1, i + cols)) + mesh = bpy.data.meshes.new(name) + mesh.from_pydata(verts, [], faces) + mesh.update() + if material: + mesh.materials.append(material) + obj = bpy.data.objects.new(name, mesh) + bpy.context.scene.collection.objects.link(obj) + return obj + + +def main(): + args = parse_args() + nodes, ways = oc.load(args.osm) + line, _, _, _, block_len = oc.block_extent( + nodes, ways, "Hollywood Boulevard", "North Orange Drive", "North Highland Avenue") + utm = project_to_utm(line, find_proj()) + local = [(e - ORIGIN_E, n - ORIGIN_N) for e, n in utm] + + # arc length + unit normals in the local frame + acc, lengths = 0.0, [0.0] + for a, b in zip(local, local[1:]): + acc += math.hypot(b[0] - a[0], b[1] - a[1]) + lengths.append(acc) + total = lengths[-1] + print(f"centreline {total:.1f} m") + + def at(s): + for i in range(len(local) - 1): + if lengths[i] <= s <= lengths[i + 1]: + seg = lengths[i + 1] - lengths[i] + t = 0.0 if seg == 0 else (s - lengths[i]) / seg + ax, ay = local[i] + bx, by = local[i + 1] + dx, dy = bx - ax, by - ay + norm = math.hypot(dx, dy) or 1.0 + return ((ax + t * dx, ay + t * dy), (-dy / norm, dx / norm), (dx / norm, dy / norm)) + ax, ay = local[-1] + return ((ax, ay), (0.0, 1.0), (1.0, 0.0)) + + dem = Dem(args.dem) + + rows = [] + s = 0.0 + while s <= total + 1e-9: + base, normal, tangent = at(s) + rows.append((s, base, normal)) + s += args.along + + # materials + asphalt = bpy.data.materials.new("Asphalt") + asphalt.diffuse_color = (0.09, 0.09, 0.095, 1.0) + concrete = bpy.data.materials.new("Concrete") + concrete.diffuse_color = (0.55, 0.53, 0.50, 1.0) + + def dem_z(x, y): + z = dem.at(x + ORIGIN_E, y + ORIGIN_N) + return None if z is None else z - ORIGIN_Z + + road_offsets = [SOUTH_GUTTER + i * (NORTH_GUTTER - SOUTH_GUTTER) / 28 for i in range(29)] + south_offsets = [SOUTH_SIDEWALK + i * (SOUTH_GUTTER - SOUTH_SIDEWALK) / 8 for i in range(9)] + north_offsets = [NORTH_GUTTER + i * (NORTH_SIDEWALK - NORTH_GUTTER) / 10 for i in range(11)] + + def road_z(r, off, x, y): + z = dem_z(x, y) + return (z if z is not None else 0.0) + 0.02 + + def south_z(r, off, x, y): + z = dem_z(x, y) + base = z if z is not None else 0.0 + return base + kerb_height(rows[r][0], args.kerb, args.ramp_half_width, args.ramp_blend) + + def north_z(r, off, x, y): + return south_z(r, off, x, y) + + road = strip(rows, road_offsets, road_z, "Road_Hollywood", asphalt) + sw_s = strip(rows, south_offsets, south_z, "Sidewalk_South", concrete) + sw_n = strip(rows, north_offsets, north_z, "Sidewalk_North", concrete) + + # kerb faces: vertical quads joining road edge to sidewalk edge + def kerb_faces(offset, name): + verts, faces = [], [] + for idx, (s_val, base, normal) in enumerate(rows): + x = base[0] + offset * normal[0] + y = base[1] + offset * normal[1] + z_road = road_z(idx, offset, x, y) + h = kerb_height(s_val, args.kerb, args.ramp_half_width, args.ramp_blend) + z_walk = (dem_z(x, y) or 0.0) + h + verts.extend([(x, y, z_road), (x, y, z_walk)]) + for idx in range(len(rows) - 1): + i = idx * 2 + faces.append((i, i + 2, i + 3, i + 1)) + mesh = bpy.data.meshes.new(name) + mesh.from_pydata(verts, [], faces) + mesh.update() + mesh.materials.append(concrete) + obj = bpy.data.objects.new(name, mesh) + bpy.context.scene.collection.objects.link(obj) + return obj + + kerb_faces(SOUTH_GUTTER, "Kerb_South") + kerb_faces(NORTH_GUTTER, "Kerb_North") + + for obj in (road, sw_s, sw_n): + print(f"{obj.name:16} verts {len(obj.data.vertices):5} faces {len(obj.data.polygons):5}") + + # crown check on the built mesh + mid = total / 2 + for off in (SOUTH_GUTTER, -5.0, 0.0, 5.0, NORTH_GUTTER): + base, normal, _ = at(mid) + x = base[0] + off * normal[0] + y = base[1] + off * normal[1] + zr = road_z(0, off, x, y) + print(f" crown at {off:+5.1f} m: road Z {zr:+.2f} m") + + 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()