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.
This commit is contained in:
zwitschi committed 2026-11-06 23:45:28 +01:00
1 parent ece48649f5
commit d42e39faa9
6 files changed
+300 -1

No files matched your search

+21
View File
@@ -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:
Binary file not shown.
@@ -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.
+1 -1
View File
@@ -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
+2
View File
@@ -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.
+269
View File
@@ -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()