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

+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()