geo: one tool directory, one unit boundary, per-asset provenance
Geo/tools/ is now every geographic script, with Geo/README.md documenting each one; the Blender-side builders moved beside their outputs in Art/blender/*/ and import their helpers from Geo/tools/osm_common.py. That module gains to_unreal_local() and to_blender_local(): the only place in the repository that multiplies metres by 100, so a second silent scale cannot be introduced. block_height_report.py adds height provenance for the block — 3 of 15 buildings have no height data and render at a placeholder 6.4 m. asset_metadata.py writes and audits <asset>.geo.json sidecars; four now exist (hero-facade, terrain, street, blockout) and the audit recomputes each centre from the origin, failing anything that disagrees.
This commit is contained in:
1 parent
5e5e5ae9b4
commit
386d4bee78
26 files changed
+471
-57
No files matched your search
Executable
+226
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Shared geo helpers: parse .osm, stitch a street, project points, convert to Unreal.
|
||||
|
||||
Standard library only. Imported by every script in `Geo/tools/`.
|
||||
|
||||
The unit boundary is deliberate and lives here, once:
|
||||
|
||||
WGS84 (lat, lon) -> EPSG:26911 (E, N metres) -> project local metres
|
||||
-> Unreal centimetres
|
||||
|
||||
`to_unreal` is the only place that multiplies by 100. Nothing else in the
|
||||
repository may convert metres to centimetres, so a second silent scale cannot be
|
||||
introduced by accident.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
EARTH_R = 6378137.0
|
||||
|
||||
# Project origin, EPSG:26911. See Geo/manifests/local-origin.md.
|
||||
ORIGIN_E = 376515.389
|
||||
ORIGIN_N = 3774224.083
|
||||
ORIGIN_Z_NAVD88 = 115.911
|
||||
METRES_TO_UNREAL = 100.0
|
||||
|
||||
|
||||
def to_unreal_local(easting, northing, elevation=None):
|
||||
"""EPSG:26911 metres -> Unreal centimetres in the project frame.
|
||||
|
||||
X = east of the origin, Y = south of the origin (the FBX preset maps Blender
|
||||
+Y/north to Unreal -Y), Z = height above the origin ground. This is the single
|
||||
meters->centimetres conversion for the repository.
|
||||
"""
|
||||
x_cm = (easting - ORIGIN_E) * METRES_TO_UNREAL
|
||||
y_cm = -(northing - ORIGIN_N) * METRES_TO_UNREAL
|
||||
if elevation is None:
|
||||
return x_cm, y_cm, None
|
||||
return x_cm, y_cm, (elevation - ORIGIN_Z_NAVD88) * METRES_TO_UNREAL
|
||||
|
||||
|
||||
def to_blender_local(easting, northing, elevation=None):
|
||||
"""EPSG:26911 metres -> Blender metres in the project frame (X east, Y north, Z up)."""
|
||||
x = easting - ORIGIN_E
|
||||
y = northing - ORIGIN_N
|
||||
z = None if elevation is None else elevation - ORIGIN_Z_NAVD88
|
||||
return x, y, z
|
||||
|
||||
|
||||
def find_proj():
|
||||
"""The PROJ binary: environment override, PATH, then the QGIS Flatpak."""
|
||||
import glob
|
||||
import os
|
||||
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
|
||||
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 found; set PROJ_BIN or install pyproj")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def project_wgs84_to_utm(points, proj_bin=None):
|
||||
"""WGS84 (lat, lon) -> EPSG:26911 (E, N) for a list, in one PROJ call."""
|
||||
import subprocess
|
||||
|
||||
proj_bin = proj_bin or find_proj()
|
||||
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", "%.3f"],
|
||||
input=payload, capture_output=True, text=True, env=env, check=True,
|
||||
).stdout.strip().splitlines()
|
||||
result = [tuple(float(v) for v in line.split()) for line in out]
|
||||
if len(result) != len(points):
|
||||
raise SystemExit(f"PROJ returned {len(result)} points for {len(points)} inputs")
|
||||
return result
|
||||
|
||||
|
||||
def to_xy(lat: float, lon: float, lat0: float, lon0: float) -> tuple[float, float]:
|
||||
"""Local planar metres, east/north, centred on (lat0, lon0)."""
|
||||
return (
|
||||
math.radians(lon - lon0) * EARTH_R * math.cos(math.radians(lat0)),
|
||||
math.radians(lat - lat0) * EARTH_R,
|
||||
)
|
||||
|
||||
|
||||
def from_xy(x: float, y: float, lat0: float, lon0: float) -> tuple[float, float]:
|
||||
lat = lat0 + math.degrees(y / EARTH_R)
|
||||
lon = lon0 + math.degrees(x / (EARTH_R * math.cos(math.radians(lat0))))
|
||||
return lat, lon
|
||||
|
||||
|
||||
def load(path: str):
|
||||
root = ET.parse(path).getroot()
|
||||
nodes = {n.get("id"): (float(n.get("lat")), float(n.get("lon"))) for n in root.findall("node")}
|
||||
ways = []
|
||||
for w in root.findall("way"):
|
||||
tags = {c.get("k"): c.get("v") for c in w.findall("tag")}
|
||||
ways.append((w.get("id"), tags, [nd.get("ref") for nd in w.findall("nd")]))
|
||||
return nodes, ways
|
||||
|
||||
|
||||
def way_points(nodes, refs):
|
||||
return [nodes[r] for r in refs if r in nodes]
|
||||
|
||||
|
||||
def refs_by_name(ways, name):
|
||||
return {r for _, tags, refs in ways if tags.get("name") == name for r in refs}
|
||||
|
||||
|
||||
def stitch(nodes, ways, name, lon_min, lon_max):
|
||||
"""Ordered polyline for a named street, restricted to a longitude window."""
|
||||
segments = []
|
||||
for _, tags, refs in ways:
|
||||
if tags.get("name") != name:
|
||||
continue
|
||||
pts = way_points(nodes, refs)
|
||||
if not pts:
|
||||
continue
|
||||
if max(p[1] for p in pts) < lon_min - 1e-6 or min(p[1] for p in pts) > lon_max + 1e-6:
|
||||
continue
|
||||
segments.append(pts)
|
||||
|
||||
merged = []
|
||||
for pts in segments:
|
||||
if not merged:
|
||||
merged = list(pts)
|
||||
continue
|
||||
if pts[0] == merged[-1]:
|
||||
merged.extend(pts[1:])
|
||||
elif pts[-1] == merged[-1]:
|
||||
merged.extend(reversed(pts[:-1]))
|
||||
elif pts[0] == merged[0]:
|
||||
merged = list(reversed(pts)) + merged[1:]
|
||||
elif pts[-1] == merged[0]:
|
||||
merged = pts + merged[1:]
|
||||
return merged
|
||||
|
||||
|
||||
def cumulative_lengths(points, lat0, lon0):
|
||||
xs, total, prev = [], 0.0, None
|
||||
for lat, lon in points:
|
||||
p = to_xy(lat, lon, lat0, lon0)
|
||||
if prev is not None:
|
||||
total += math.dist(prev, p)
|
||||
xs.append(total)
|
||||
prev = p
|
||||
return xs
|
||||
|
||||
|
||||
def project(point, line, lat0, lon0, lengths):
|
||||
"""Nearest point on the polyline: (distance_m, s_along_m, side) with side +1 = north."""
|
||||
px, py = to_xy(point[0], point[1], lat0, lon0)
|
||||
best = (float("inf"), 0.0, 0)
|
||||
for i in range(len(line) - 1):
|
||||
ax, ay = to_xy(line[i][0], line[i][1], lat0, lon0)
|
||||
bx, by = to_xy(line[i + 1][0], line[i + 1][1], lat0, lon0)
|
||||
dx, dy = bx - ax, by - ay
|
||||
seg2 = dx * dx + dy * dy
|
||||
if seg2 == 0:
|
||||
continue
|
||||
t = max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / seg2))
|
||||
cx, cy = ax + t * dx, ay + t * dy
|
||||
dist = math.hypot(px - cx, py - cy)
|
||||
if dist < best[0]:
|
||||
cross = dx * (py - ay) - dy * (px - ax)
|
||||
best = (dist, lengths[i] + t * (lengths[i + 1] - lengths[i]), 1 if cross > 0 else -1)
|
||||
return best
|
||||
|
||||
|
||||
def point_at(line, lengths, s, offset, lat0, lon0):
|
||||
"""Point on the polyline at arc length s, pushed offset metres to the north side."""
|
||||
for i in range(len(line) - 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 = to_xy(line[i][0], line[i][1], lat0, lon0)
|
||||
bx, by = to_xy(line[i + 1][0], line[i + 1][1], lat0, lon0)
|
||||
dx, dy = bx - ax, by - ay
|
||||
norm = math.hypot(dx, dy) or 1.0
|
||||
nx, ny = -dy / norm, dx / norm
|
||||
x = ax + t * dx + offset * nx
|
||||
y = ay + t * dy + offset * ny
|
||||
return from_xy(x, y, lat0, lon0)
|
||||
return line[-1]
|
||||
|
||||
|
||||
def block_extent(nodes, ways, blvd, west, east):
|
||||
"""Stitch and clip the boulevard between two cross streets.
|
||||
|
||||
Returns (line, lat0, lon0, lengths, block_len) with `line` running west to east.
|
||||
"""
|
||||
inter = {}
|
||||
blvd_refs = refs_by_name(ways, blvd)
|
||||
for label, street in (("west", west), ("east", east)):
|
||||
shared = [nodes[r] for r in refs_by_name(ways, street) & blvd_refs if r in nodes]
|
||||
if not shared:
|
||||
raise SystemExit(f"no intersection between {blvd!r} and {street!r}")
|
||||
inter[label] = min(p[1] for p in shared) if label == "west" else max(p[1] for p in shared)
|
||||
|
||||
line = stitch(nodes, ways, blvd, inter["west"], inter["east"])
|
||||
if line[0][1] > line[-1][1]:
|
||||
line = list(reversed(line))
|
||||
west_pt = min(line, key=lambda p: abs(p[1] - inter["west"]))
|
||||
east_pt = min(line, key=lambda p: abs(p[1] - inter["east"]))
|
||||
i0, i1 = line.index(west_pt), line.index(east_pt)
|
||||
if i0 > i1:
|
||||
i0, i1 = i1, i0
|
||||
line = line[i0 : i1 + 1]
|
||||
|
||||
lat0 = sum(p[0] for p in line) / len(line)
|
||||
lon0 = sum(p[1] for p in line) / len(line)
|
||||
lengths = cumulative_lengths(line, lat0, lon0)
|
||||
return line, lat0, lon0, lengths, lengths[-1]
|
||||
Reference in new issue
Block a user