feat: terrain mesh from the DEM, and correct the blockout frame

Tools/terrain_from_dem.py builds a 191x106 grid at 2 m over the block, Z being
NAVD88 minus the origin ground. Building footprints from the OSM extract are cut
out so rooftops never become terrain (72 footprints, 6310 cells). Spot checks
reproduce the measured profile: origin 0.00, Orange Dr +3.39, south gutter
+0.93, crown +1.38, north sidewalk +1.43.

Blosm projects with a sphere, not an ellipsoid, so its frame was ~0.4% oversized
and rotated by meridian convergence: about 2 m at the theatre. The blockout now
fits a six-parameter affine into EPSG:26911 on five control points (0.0 cm
residual) and transforms root objects only, since children inherit and double
transforming them read as a 2 m placement error.
This commit is contained in:
zwitschi committed 2026-11-06 23:40:50 +01:00
1 parent bf23a27939
commit ece48649f5
7 files changed
+391 -9

No files matched your search

+123
View File
@@ -41,6 +41,8 @@ def parse_args():
ap.add_argument("--bounds", required=True, help="south,west,north,east in WGS84 degrees")
ap.add_argument("--out", required=True, help="output .blend path")
ap.add_argument("--level-height", type=float, default=3.2, help="metres per building level")
ap.add_argument("--origin", default=None,
help="lat,lon to use as the Blender origin (project origin). Default: Blosm centres on the extract")
ap.add_argument("--no-subdivide", action="store_true", help="skip OSM area subdivision")
ap.add_argument("--load-missing", action="store_true",
help="let Blosm fetch relation members missing from the extract (needs network, breaks offline reproducibility)")
@@ -103,6 +105,13 @@ def import_osm(args):
scene = bpy.context.scene
osmp = scene.blosm
if args.origin:
lat, lon = (float(v) for v in args.origin.split(","))
scene["lat"] = lat
scene["lon"] = lon
osmp.relativeToInitialImport = True
print(f"origin set to {lat}, {lon} (project origin)")
osmp.commandLineMode = True
osmp.mode = args.mode
assert osmp.mode == args.mode
@@ -134,6 +143,118 @@ def import_osm(args):
print("import finished")
def proj_batch(points, proj_bin):
"""WGS84 (lat, lon) -> EPSG:26911 (E, N) in one PROJ call."""
import subprocess
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", "%.5f"],
input=payload, capture_output=True, text=True, env=env, check=True,
).stdout.strip().splitlines()
return [tuple(float(v) for v in line.split()) for line in out]
def find_proj():
import glob
import shutil
env = os.environ.get("PROJ_BIN")
if env and os.path.exists(env):
return env
which = shutil.which("proj")
if which:
return which
pattern = os.path.expanduser(
"~/.local/share/flatpak/app/org.qgis.qgis/x86_64/stable/*/files/bin/proj"
)
matches = sorted(glob.glob(pattern))
if not matches:
raise SystemExit("no PROJ binary; set PROJ_BIN")
return matches[0]
def correct_frame(args):
"""Map Blosm's spherical Transverse Mercator onto true EPSG:26911.
Blosm projects with a sphere of radius 6378137 about the origin, which is a
conformal but ~0.3 % oversized frame relative to ellipsoidal UTM. The
difference between the two over a 500 m block is an affine map to well under
a centimetre, so fit a similarity transform on four spread control points and
apply it to the imported objects. After this, the blockout uses the same
metric frame as the DEM and the origin documented in Geo/manifests.
"""
import math
import bpy
from mathutils import Matrix
from util.transverse_mercator import TransverseMercator
lat, lon = (float(v) for v in args.origin.split(","))
tm = TransverseMercator(lat=lat, lon=lon)
south, west, north, east = (float(v) for v in args.bounds.split(","))
corners = [(south, west), (south, east), (north, west), (north, east),
((south + north) / 2, (west + east) / 2)]
blosm_xy = [tm.fromGeographic(la, lo)[:2] for la, lo in corners]
utm_xy = proj_batch(corners, find_proj())
# full affine fit (6 parameters): absorbs the small shear the spherical
# projection introduces across the block, which a similarity leaves as ~30 cm
n = len(blosm_xy)
bxy = [p for p in blosm_xy]
uxy = [q for q in utm_xy]
# solve [x, y, 1] * [a, b, c]^T = u for both components, least squares
def solve(target):
ata = [[0.0] * 3 for _ in range(3)]
atb = [0.0] * 3
for (x, y), t in zip(bxy, target):
row = (x, y, 1.0)
for i in range(3):
for j in range(3):
ata[i][j] += row[i] * row[j]
atb[i] += row[i] * t
# Gaussian elimination
m = [ata[i][:] + [atb[i]] for i in range(3)]
for i in range(3):
piv = max(range(i, 3), key=lambda r: abs(m[r][i]))
m[i], m[piv] = m[piv], m[i]
for r in range(3):
if r != i:
f = m[r][i] / m[i][i]
for c in range(i, 4):
m[r][c] -= f * m[i][c]
return [m[i][3] / m[i][i] for i in range(3)]
a, b, tx = solve([q[0] for q in uxy])
d, e, ty = solve([q[1] for q in uxy])
residuals = [
math.hypot(a * p[0] + b * p[1] + tx - q[0], d * p[0] + e * p[1] + ty - q[1])
for p, q in zip(blosm_xy, utm_xy)
]
print(f"frame correction affine: X = {a:.6f}*x {b:+.6f}*y {tx:+.2f}, "
f"Y = {d:+.6f}*x {e:.6f}*y {ty:+.2f}")
print(f"frame correction residual: max {max(residuals)*100:.1f} cm")
# local origin for the UTM frame: the project origin
origin_lat_lon = (lat, lon)
origin_xy = proj_batch([origin_lat_lon], find_proj())[0]
m = Matrix(((a, b, 0.0, tx - origin_xy[0]), (d, e, 0.0, ty - origin_xy[1]),
(0.0, 0.0, 1.0, 0.0), (0.0, 0.0, 0.0, 1.0)))
# only transform roots: children inherit their parent's transform, and
# transforming both would apply the correction twice
roots = [o for o in bpy.data.objects if o.parent is None]
for obj in roots:
obj.matrix_world = m @ obj.matrix_world
print(f"frame correction applied to {len(roots)} root objects "
f"({len(bpy.data.objects) - len(roots)} children inherit)")
def report():
"""Per-object measurements in metres, so a scale bug is obvious in the log."""
collections = {c.name: c for c in bpy.data.collections}
@@ -167,6 +288,8 @@ def main():
enable_blosm()
configure_preferences(args)
import_osm(args)
if args.origin:
correct_frame(args)
report()
out = os.path.abspath(args.out)