diff --git a/Docs/setup.md b/Docs/setup.md index dae3083..e3de5ab 100644 --- a/Docs/setup.md +++ b/Docs/setup.md @@ -19,10 +19,12 @@ Anything marked **human step** needs credentials, licence acceptance, or a decis ## 2. Engine acquisition -- `[ ]` **Human step.** Create an Epic account, accept the Unreal license, and join the Epic Games GitHub organisation if the source route is used. Record the account email and the date of acceptance here; record no credentials. +- `[x]` **Human step.** Create an Epic account, accept the Unreal license. + Done on 2026-11-06 with the account email recorded locally; no credentials stored in the repository. - `[ ]` **Human step.** Acquire one pinned engine build. Two routes: - **Launcher build**: install the Linux Epic Games Launcher, download the pinned engine version, install to the DATA volume, and disable auto-update so the pin holds. - **Source build**: clone the pinned `release` branch from the Epic Games GitHub organisation, then `./Setup.sh`, `./GenerateProjectFiles.sh`, `make -j16`. Budget 120–200 GB and a multi-hour first build on 16 threads. + Downloaded: Linux_Unreal_Engine_5.2.0.zip, 19.59 GB, Built on 2026-11-03. - `[ ]` Record the exact engine version and build route in [toolchain.md](toolchain.md). - `[ ]` Validate that the engine editor opens and a blank project runs on Bazzite. Unreal does not support this distribution; record any workarounds. diff --git a/Geo/README.md b/Geo/README.md index 1e784b2..0bb9b64 100644 --- a/Geo/README.md +++ b/Geo/README.md @@ -13,7 +13,7 @@ OSM is layout data only. It is not authoritative for façade detail, height, or | `Geo/manifests/` | One manifest per saved extract (see below) | | `Geo/scripts/` | Repeatable fetch and conversion helpers | -Current state: the extract, both El Capitan footprints, the demo bounds, the building sets, the street profile, and the designated crossing are recorded in [`verification/`](verification/). The local CRS and origin are fixed in [`manifests/local-origin.md`](manifests/local-origin.md), with the projected easting/northing still to be computed. Elevation and the hero façade width remain open. +Current state: the extract, both El Capitan footprints, the demo bounds, the building sets, the street profile, and the designated crossing are recorded in [`verification/`](verification/). The local CRS and origin are fixed in [`manifests/local-origin.md`](manifests/local-origin.md), with the projected easting/northing still to be computed. Elevation is measured (3.94 m relief, 3DEP chosen) but the DEM tile is not downloaded; the hero façade width remains open. ## Required manifest diff --git a/Geo/raw/epqs-2026-11-06.json b/Geo/raw/epqs-2026-11-06.json new file mode 100644 index 0000000..5a9f3ff --- /dev/null +++ b/Geo/raw/epqs-2026-11-06.json @@ -0,0 +1,197 @@ +{ + "service": "https://epqs.nationalmap.gov/v1/json", + "processed_utc": "2026-11-06T21:42:50+00:00", + "source_dem": "USGS 3DEP seamless 1 m (CONUS)", + "vertical_datum": "NAVD88 orthometric (3DEP CONUS)", + "query": { + "blvd": "Hollywood Boulevard", + "west": "North Orange Drive", + "east": "North Highland Avenue", + "hero_way": 424024147, + "block_len_m": 279.37 + }, + "summary": { + "min_m": 115.646949768, + "max_m": 119.585700989, + "relief_m": 3.939, + "samples": 22 + }, + "samples": [ + { + "lat": 34.1014287, + "lon": -118.341748, + "elevation_m": 118.901107788, + "raster_id": 129943, + "resolution_m": 1, + "label": "s= 0.0 m south sidewalk" + }, + { + "lat": 34.1015419, + "lon": -118.3417487, + "elevation_m": 119.361953735, + "raster_id": 129943, + "resolution_m": 1, + "label": "s= 0.0 m centreline" + }, + { + "lat": 34.1016641, + "lon": -118.3417495, + "elevation_m": 119.585700989, + "raster_id": 129943, + "resolution_m": 1, + "label": "s= 0.0 m north sidewalk" + }, + { + "lat": 34.1014304, + "lon": -118.3409914, + "elevation_m": 118.110488892, + "raster_id": 129943, + "resolution_m": 1, + "label": "s= 69.8 m south sidewalk" + }, + { + "lat": 34.1015435, + "lon": -118.340991, + "elevation_m": 118.458061218, + "raster_id": 129943, + "resolution_m": 1, + "label": "s= 69.8 m centreline" + }, + { + "lat": 34.1016657, + "lon": -118.3409906, + "elevation_m": 118.491287231, + "raster_id": 129943, + "resolution_m": 1, + "label": "s= 69.8 m north sidewalk" + }, + { + "lat": 34.1014291, + "lon": -118.340233, + "elevation_m": 116.954948425, + "raster_id": 129943, + "resolution_m": 1, + "label": "s=139.7 m south sidewalk" + }, + { + "lat": 34.1015423, + "lon": -118.3402333, + "elevation_m": 117.407516479, + "raster_id": 129943, + "resolution_m": 1, + "label": "s=139.7 m centreline" + }, + { + "lat": 34.1016645, + "lon": -118.3402336, + "elevation_m": 117.497383118, + "raster_id": 129943, + "resolution_m": 1, + "label": "s=139.7 m north sidewalk" + }, + { + "lat": 34.1014306, + "lon": -118.3394753, + "elevation_m": 116.039543152, + "raster_id": 129943, + "resolution_m": 1, + "label": "s=209.5 m south sidewalk" + }, + { + "lat": 34.1015438, + "lon": -118.3394756, + "elevation_m": 116.266822815, + "raster_id": 129943, + "resolution_m": 1, + "label": "s=209.5 m centreline" + }, + { + "lat": 34.101666, + "lon": -118.339476, + "elevation_m": 116.471313477, + "raster_id": 129943, + "resolution_m": 1, + "label": "s=209.5 m north sidewalk" + }, + { + "lat": 34.101432, + "lon": -118.3387176, + "elevation_m": 115.646949768, + "raster_id": 129943, + "resolution_m": 1, + "label": "s=279.4 m south sidewalk" + }, + { + "lat": 34.1015452, + "lon": -118.3387179, + "elevation_m": 115.911499023, + "raster_id": 129943, + "resolution_m": 1, + "label": "s=279.4 m centreline" + }, + { + "lat": 34.1016674, + "lon": -118.3387183, + "elevation_m": 116.096321106, + "raster_id": 129943, + "resolution_m": 1, + "label": "s=279.4 m north sidewalk" + }, + { + "lat": 34.1009186, + "lon": -118.3402987, + "elevation_m": 114.977104187, + "raster_id": 129943, + "resolution_m": 1, + "label": "hero near NW" + }, + { + "lat": 34.1009186, + "lon": -118.3400194, + "elevation_m": 114.528839111, + "raster_id": 129943, + "resolution_m": 1, + "label": "hero near NE" + }, + { + "lat": 34.1014116, + "lon": -118.3402987, + "elevation_m": 117.13494873, + "raster_id": 129943, + "resolution_m": 1, + "label": "hero far SW" + }, + { + "lat": 34.1014116, + "lon": -118.3400194, + "elevation_m": 116.738006592, + "raster_id": 129943, + "resolution_m": 1, + "label": "hero far SE" + }, + { + "lat": 34.1015404, + "lon": -118.3413518, + "elevation_m": 118.956344604, + "raster_id": 129943, + "resolution_m": 1, + "label": "crossing way 385648318 at s=37 m" + }, + { + "lat": 34.1015485, + "lon": -118.3403371, + "elevation_m": 117.53289032, + "raster_id": 129943, + "resolution_m": 1, + "label": "crossing way 385648319 at s=130 m" + }, + { + "lat": 34.1015482, + "lon": -118.3388618, + "elevation_m": 115.954216003, + "raster_id": 129943, + "resolution_m": 1, + "label": "crossing way 385648320 at s=266 m" + } + ] +} \ No newline at end of file diff --git a/Geo/scripts/elevation_probe.py b/Geo/scripts/elevation_probe.py new file mode 100755 index 0000000..955ede0 --- /dev/null +++ b/Geo/scripts/elevation_probe.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""Probe ground elevation across the demo block with the USGS 3DEP point service. + +Usage: + Geo/scripts/elevation_probe.py Geo/raw/osm-api-map-2026-11-06.osm + +Samples a grid along the boulevard (centreline and both sidewalk centrelines), +the hero footprint corners, and the crossing midpoints, then reports the relief +across the demo and whether a separate elevation source is needed. + +The decision rule follows `AGENTS.md`: acquire separately licensed elevation data +unless the DEM shows less than 1 m of height variation across the demo bounds. + +Writes the raw responses to `Geo/raw/epqs-.json` so the numbers can be +re-checked without re-querying. Service: USGS Elevation Point Query Service, +3DEP 1 m seamless DEM (CONUS). Standard library only. +""" + +import argparse +import concurrent.futures +import datetime +import json +import os +import time +import urllib.parse +import urllib.request + +import osm_common as oc + +EPQS = "https://epqs.nationalmap.gov/v1/json" +UA = "gig-line-geo/0.1 (local development, elevation check)" + + +def query(lat: float, lon: float, retries: int = 5) -> dict: + params = urllib.parse.urlencode( + {"x": f"{lon:.7f}", "y": f"{lat:.7f}", "wkid": "4326", "units": "Meters", "includeDate": "false"} + ) + url = f"{EPQS}?{params}" + for attempt in range(retries): + req = urllib.request.Request(url, headers={"User-Agent": UA}) + try: + with urllib.request.urlopen(req, timeout=40) as resp: + payload = json.loads(resp.read().decode("utf-8")) + value = payload.get("value") + if value in (None, "", "No Data"): + raise ValueError(f"no elevation returned: {payload}") + return { + "lat": round(lat, 7), + "lon": round(lon, 7), + "elevation_m": float(value), + "raster_id": payload.get("rasterId"), + "resolution_m": payload.get("resolution"), + } + except Exception as exc: # noqa: BLE001 + if attempt == retries - 1: + # a single dead point must not kill the whole probe + print(f" ! no elevation at {lat:.6f},{lon:.6f}: {exc}", flush=True) + return {"lat": round(lat, 7), "lon": round(lon, 7), "elevation_m": None, + "raster_id": None, "resolution_m": None} + time.sleep(3 * (attempt + 1)) + return {} + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("osm") + ap.add_argument("--blvd", default="Hollywood Boulevard") + ap.add_argument("--west", default="North Orange Drive") + ap.add_argument("--east", default="North Highland Avenue") + ap.add_argument("--hero", type=int, default=424024147, help="hero building OSM way id") + ap.add_argument("--workers", type=int, default=4, help="parallel requests; the service is slow") + ap.add_argument("--pause", type=float, default=0.0) + args = ap.parse_args() + + nodes, ways = oc.load(args.osm) + line, lat0, lon0, lengths, block_len = oc.block_extent(nodes, ways, args.blvd, args.west, args.east) + + samples = [] + grid = [block_len * i / 4 for i in range(5)] + for s in grid: + for label, offset in (("south sidewalk", -12.6), ("centreline", 0.0), ("north sidewalk", 13.6)): + lat, lon = oc.point_at(line, lengths, s, offset, lat0, lon0) + samples.append({"label": f"s={s:5.1f} m {label}", "lat": lat, "lon": lon}) + + hero_refs = next((refs for wid, _, refs in ways if str(wid) == str(args.hero)), None) + if hero_refs: + hero_pts = oc.way_points(nodes, hero_refs) + lats = [p[0] for p in hero_pts] + lons = [p[1] for p in hero_pts] + corners = [ + ("near NW", min(lats), min(lons)), + ("near NE", min(lats), max(lons)), + ("far SW", max(lats), min(lons)), + ("far SE", max(lats), max(lons)), + ] + for label, lat, lon in corners: + samples.append({"label": f"hero {label}", "lat": lat, "lon": lon}) + + for wid, tags, refs in ways: + if tags.get("footway") != "crossing": + continue + pts = oc.way_points(nodes, refs) + if not pts: + continue + mid = (sum(p[0] for p in pts) / len(pts), sum(p[1] for p in pts) / len(pts)) + dist, s, _ = oc.project(mid, line, lat0, lon0, lengths) + # only crossings of THIS boulevard, not side-street crossings inside the window + if 0 <= s <= block_len and dist <= 4.0: + samples.append({"label": f"crossing way {wid} at s={s:.0f} m", "lat": mid[0], "lon": mid[1]}) + + def fetch(sample): + r = query(sample["lat"], sample["lon"]) + r["label"] = sample["label"] + return r + + started = time.time() + with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, args.workers)) as pool: + futures = {pool.submit(fetch, s): s for s in samples} + results = [] + for done, future in enumerate(concurrent.futures.as_completed(futures), start=1): + r = future.result() + results.append(r) + value = "no data" if r["elevation_m"] is None else f"{r['elevation_m']:7.2f} m" + print(f" [{done:02d}/{len(samples)}] {r['label']:28} {value:>10}", flush=True) + if args.pause: + time.sleep(args.pause) + order = {s["label"]: i for i, s in enumerate(samples)} + results.sort(key=lambda r: order.get(r["label"], 999)) + print(f"query time {time.time() - started:.0f} s for {len(samples)} samples") + + walkable = [r for r in results if r["elevation_m"] is not None and not r["label"].startswith("hero")] + building = [r for r in results if r["elevation_m"] is not None and r["label"].startswith("hero")] + values = [r["elevation_m"] for r in walkable] + lo, hi = min(values), max(values) + relief = hi - lo + + print("\nsamples by group (walkable surfaces only below; building interiors are diagnostics):") + for key in ("crossing", "hero"): + group = [r for r in results if r["elevation_m"] is not None and key in r["label"]] + if group: + g = [r["elevation_m"] for r in group] + print(f" {key:12} n={len(group):2} min {min(g):.2f} max {max(g):.2f} spread {max(g)-min(g):.2f} m") + + print("\ncross-slope check (south sidewalk minus north sidewalk):") + for s in [block_len * i / 4 for i in range(5)]: + south = next(r for r in results if r["label"] == f"s={s:5.1f} m south sidewalk") + north = next(r for r in results if r["label"] == f"s={s:5.1f} m north sidewalk") + drop = south["elevation_m"] - north["elevation_m"] + print(f" s={s:5.1f} m: south {south['elevation_m']:.2f} m, north {north['elevation_m']:.2f} m, " + f"difference {drop:+.2f} m over ~26 m") + + if building: + print("\nwarning: points inside the hero footprint are not ground reference; 3DEP can return") + print(" building or void values there. They are excluded from the relief figure.") + print(f"\nelevation range on walkable surfaces: {lo:.2f} to {hi:.2f} m, relief {relief:.2f} m") + first = next(r for r in walkable) + print(f"source: USGS 3DEP point service, raster {first['raster_id']}, resolution {first['resolution_m']} m") + if relief < 1.0: + print("decision: relief under 1 m, a separate elevation source is NOT required (AGENTS.md rule)") + else: + print("decision: relief at or above 1 m, a separately licensed elevation source IS required") + print("datum: 3DEP CONUS heights are orthometric NAVD88; treat absolute values as such, not ellipsoidal") + + today = datetime.date.today().isoformat() + out_path = os.path.join("Geo", "raw", f"epqs-{today}.json") + os.makedirs(os.path.dirname(out_path), exist_ok=True) + payload = { + "service": EPQS, + "processed_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"), + "source_dem": "USGS 3DEP seamless 1 m (CONUS)", + "vertical_datum": "NAVD88 orthometric (3DEP CONUS)", + "query": { + "blvd": args.blvd, + "west": args.west, + "east": args.east, + "hero_way": args.hero, + "block_len_m": round(block_len, 2), + }, + "summary": {"min_m": lo, "max_m": hi, "relief_m": round(relief, 3), "samples": len(results)}, + "samples": results, + } + with open(out_path, "w", encoding="utf-8") as handle: + json.dump(payload, handle, indent=2) + print(f"wrote {out_path}") + + +if __name__ == "__main__": + main() diff --git a/Geo/scripts/osm_common.py b/Geo/scripts/osm_common.py new file mode 100755 index 0000000..a371f94 --- /dev/null +++ b/Geo/scripts/osm_common.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Shared helpers for the Geo scripts: parse .osm, stitch a street, project points. + +Standard library only. Imported by `block_report.py`, `street_profile.py`, and +`elevation_probe.py` when run from this directory. +""" + +from __future__ import annotations + +import math +import xml.etree.ElementTree as ET + +EARTH_R = 6378137.0 + + +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] diff --git a/Geo/scripts/street_profile.py b/Geo/scripts/street_profile.py index b2fa4bc..0300c4f 100755 --- a/Geo/scripts/street_profile.py +++ b/Geo/scripts/street_profile.py @@ -7,111 +7,23 @@ Usage: Measures, in metres, from a plain .osm extract (standard library only): -* the boulevard centreline, stitched into one ordered line between the two - boundary intersections; +* the boulevard centreline, stitched and clipped between the two boundary + intersections; * each building's frontage setback: the distance from its nearest vertex to the centreline, and which side of the road it is on; -* each mapped crossing (`footway=crossing`) across the boulevard: its length in - metres, which is the carriageway width at that point, and its position along - the block; +* each mapped crossing (`footway=crossing`) across the boulevard: its length, + which is the carriageway width at that point, and its position along the block; * the offset of the mapped sidewalk centrelines from the road centreline. What it cannot measure: carriageway width where no crossing or kerb is mapped, and sidewalk width, because OSM models sidewalks here as centre lines with no -width tag. Treat those as recorded assumptions, not measurements. +width tag. Derive those from the numbers printed here and record them as +assumptions. """ import argparse -import math -import xml.etree.ElementTree as ET -EARTH_R = 6378137.0 - - -def to_xy(lat, lon, lat0, lon0): - return ( - math.radians(lon - lon0) * EARTH_R * math.cos(math.radians(lat0)), - math.radians(lat - lat0) * EARTH_R, - ) - - -def load(path): - 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 stitch(nodes, ways, name, lon_min, lon_max): - """Ordered polyline for a named street between two longitudes.""" - 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) - if not segments: - raise SystemExit(f"no geometry for {name!r} inside the block") - - 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 = 0.0 - prev = 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: returns (distance_m, s_along_m, side).""" - 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 - seg_len2 = dx * dx + dy * dy - if seg_len2 == 0: - continue - t = max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / seg_len2)) - 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) - side = 1 if cross > 0 else -1 - best = (dist, lengths[i] + t * (lengths[i + 1] - lengths[i]), side) - return best +import osm_common as oc def main(): @@ -122,59 +34,32 @@ def main(): ap.add_argument("--east", default="North Highland Avenue") args = ap.parse_args() - nodes, ways = load(args.osm) - - inter = {} - for label, street in (("west", args.west), ("east", args.east)): - refs = {r for _, tags, rs in ways if tags.get("name") == street for r in rs} - pts = way_points(nodes, refs) - blvd = {r for _, tags, rs in ways if tags.get("name") == args.blvd for r in rs} - shared = [nodes[r] for r in refs & blvd if r in nodes] - if not shared: - raise SystemExit(f"no intersection with {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, args.blvd, inter["west"], inter["east"]) - - # orient west -> east, then clip to the block between the two intersections - 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) - block_len = lengths[-1] + nodes, ways = oc.load(args.osm) + line, lat0, lon0, lengths, block_len = oc.block_extent( + nodes, ways, args.blvd, args.west, args.east + ) print(f"centreline: {len(line)} vertices, {block_len:.1f} m between the boundary intersections\n") print("crossings across the boulevard (length = carriageway width there):") for wid, tags, refs in ways: if tags.get("footway") != "crossing": continue - pts = way_points(nodes, refs) + pts = oc.way_points(nodes, refs) if not pts: continue - lats = [p[0] for p in pts] - lons = [p[1] for p in pts] - # must span the roadway: the along-road extent small, the across extent large - a = to_xy(pts[0][0], pts[0][1], lat0, lon0) - b = to_xy(pts[-1][0], pts[-1][1], lat0, lon0) - length = math.dist(a, b) - mid = (sum(lats) / len(lats), sum(lons) / len(lons)) - dist, s, side = project(mid, line, lat0, lon0, lengths) - if min(lons) > inter["east"] or max(lons) < inter["west"]: + if min(p[1] for p in pts) > line[-1][1] or max(p[1] for p in pts) < line[0][1]: continue + a = oc.to_xy(pts[0][0], pts[0][1], lat0, lon0) + b = oc.to_xy(pts[-1][0], pts[-1][1], lat0, lon0) + length = ((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2) ** 0.5 + mid = (sum(p[0] for p in pts) / len(pts), sum(p[1] for p in pts) / len(pts)) + dist, s, _ = oc.project(mid, line, lat0, lon0, lengths) if not (0.0 <= s <= block_len) or dist > 4.0: continue - marks = tags.get("crossing:markings", "?") print( f" way {wid:>10} {length:6.1f} m across, at {s:6.1f} m along the block " - f"(offset {dist:4.1f} m) markings={marks:6} signals={tags.get('crossing')}" + f"(offset {dist:4.1f} m) markings={tags.get('crossing:markings', '-'):6} " + f"signals={tags.get('crossing')}" ) print("\nsidewalk centrelines near the block (offset from road centreline):") @@ -182,20 +67,17 @@ def main(): for wid, tags, refs in ways: if tags.get("footway") != "sidewalk": continue - pts = way_points(nodes, refs) + pts = oc.way_points(nodes, refs) if not pts: continue - if min(p[1] for p in pts) > inter["east"] or max(p[1] for p in pts) < inter["west"]: + if min(p[1] for p in pts) > line[-1][1] or max(p[1] for p in pts) < line[0][1]: continue - offsets = [project(p, line, lat0, lon0, lengths) for p in pts] + offsets = [oc.project(p, line, lat0, lon0, lengths) for p in pts] mean = sum(o[0] for o in offsets) / len(offsets) - side = offsets[0][2] along = max(o[1] for o in offsets) - min(o[1] for o in offsets) - # keep only long lines that run roughly parallel within the block if not (6.0 <= mean <= 20.0) or along < 30.0: continue - target = south if side < 0 else north - target.append((mean, along, wid, tags.get("name", ""))) + (south if offsets[0][2] < 0 else north).append((mean, along, wid, tags.get("name", ""))) for label, rows in (("south side", south), ("north side", north)): if not rows: @@ -210,16 +92,15 @@ def main(): for wid, tags, refs in ways: if "building" not in tags: continue - pts = way_points(nodes, refs) + pts = oc.way_points(nodes, refs) if not pts: continue - if min(p[1] for p in pts) > inter["east"] or max(p[1] for p in pts) < inter["west"]: + if min(p[1] for p in pts) > line[-1][1] or max(p[1] for p in pts) < line[0][1]: continue - proj = [project(p, line, lat0, lon0, lengths) for p in pts] + proj = [oc.project(p, line, lat0, lon0, lengths) for p in pts] nearest = min(proj, key=lambda o: o[0]) along = [o[1] for o in proj] - frontage = max(along) - min(along) - rows.append((nearest[0], nearest[2], wid, tags, frontage)) + rows.append((nearest[0], nearest[2], wid, tags, max(along) - min(along))) rows.sort() for dist, side, wid, tags, frontage in rows: side_name = "S" if side < 0 else "N" diff --git a/Geo/verification/2026-11-06-elevation.md b/Geo/verification/2026-11-06-elevation.md new file mode 100644 index 0000000..4dc2585 --- /dev/null +++ b/Geo/verification/2026-11-06-elevation.md @@ -0,0 +1,49 @@ +# Elevation check — Hollywood Blvd block + +Measured 2026-11-06 with `Geo/scripts/elevation_probe.py` against the USGS Elevation Point Query Service (3DEP, 1 m seamless DEM, raster id 129943). Raw responses: `Geo/raw/epqs-2026-11-06.json`. Reproduce with: + +```bash +python3 Geo/scripts/elevation_probe.py Geo/raw/osm-api-map-2026-11-06.osm --workers 5 +``` + +Heights are **orthometric, NAVD88**, as published for 3DEP CONUS. They are not ellipsoidal heights and must not be mixed with GPS/ellipsoid values. + +Note: the point service answers in 6–8 s per request, so the probe runs requests in parallel and samples a grid rather than a dense sweep. + +## Grid along the boulevard + +Five positions along the 279.4 m block, at the south sidewalk (12.6 m from the centreline), the road centreline, and the north sidewalk (13.6 m). + +| Along block | South sidewalk | Centreline | North sidewalk | +| ---: | ---: | ---: | ---: | +| 0.0 m (Orange Dr) | 118.90 | 119.36 | 119.59 | +| 69.8 m | 118.11 | 118.46 | 118.49 | +| 139.7 m | 116.95 | 117.41 | 117.50 | +| 209.5 m | 116.04 | 116.27 | 116.47 | +| 279.4 m (Highland Ave) | 115.65 | 115.91 | 116.10 | + +Crossings: Orange Dr 118.96 m, mid-block 117.53 m, Highland 115.95 m. + +## What the numbers say + +- **Longitudinal grade: about 3.3 m of fall over 279 m, roughly 1.2 %, descending eastward** toward Highland. That is a real, visible slope for a walking demo, not a rounding artefact: it repeats consistently on all three lines. +- **Cross slope: the north sidewalk sits 0.4–0.7 m higher than the south** across a 26 m road reserve, which is 1.5–2.5 %. The road drains southward. +- **Total relief on walkable surfaces: 3.94 m** (115.65 m at the Highland end to 119.59 m at the Orange Drive end). + +## Decision + +`AGENTS.md` requires separately licensed elevation data unless the DEM shows less than 1 m of variation. Here the variation is **3.94 m**, so elevation data is required, and the sampled source already qualifies: **USGS 3DEP 1 m DEM is a US Government work in the public domain**, so there is no licence conflict and no third-party purchase. + +Chosen source: **USGS 3DEP 1 m (3DEP seamless, CONUS)**, reprojected to EPSG:26911 with the origin in [`../manifests/local-origin.md`](../manifests/local-origin.md), vertical datum NAVD88 recorded. + +Consequence for `0.1.0`: the demo cannot use a flat ground plane. The street and sidewalks must follow this grade, or the walkable demo will look and feel wrong at the Highland end, where the whole block drops away. + +## Caveat on building-interior samples + +Points inside the hero footprint returned 114.53–117.13 m — a 2.6 m spread within one building outline, which is not ground reference. 3DEP can carry structures and voids there. Those four diagnostic points are excluded from the relief figure; use the street and sidewalk lines, not building interiors, for any ground surface. + +## Next + +1. Download the 3DEP tile(s) covering the demo bounds, keep the source file (size is larger than the OSM extract; decide commit vs local-only with a manifest and checksum at that point). +2. Reproject to EPSG:26911, clip to the block plus margin, and record the vertical datum and the clip bounds. +3. Sample the kerb line and the crossing ramps specifically — the grid above is a street-level average, not a kerb profile. diff --git a/ROADMAP.md b/ROADMAP.md index 057fbb9..d31f1be 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -9,7 +9,7 @@ Living roadmap for this project. This file is the single source of truth for cur Status detail in [Docs/setup.md](Docs/setup.md); workstation audit in [Docs/environment.md](Docs/environment.md); pins in [Docs/toolchain.md](Docs/toolchain.md). - [x] Update Bazzite using its supported mechanism; verify NVIDIA driver and Vulkan operation. -- [ ] Register an Epic account and accept the Unreal license; obtain a pinned Linux engine build or build from Epic's authorized source repository. +- [x] Register an Epic account and accept the Unreal license; obtain a pinned Linux engine build or build from Epic's authorized source repository. - [ ] Validate Unreal runs on Bazzite before asset production (not an officially supported distribution). - [-] Install the engine-required compiler/SDK, Git, Git LFS, and an editor; use Distrobox for isolated CLI dependencies where useful. - [-] Pin Blender, MPFB, Blosm, and QGIS versions; confirm add-on compatibility; record in `docs/toolchain.md`. @@ -27,7 +27,8 @@ Status detail in [Docs/setup.md](Docs/setup.md); workstation audit in [Docs/envi ### Geographic data - [x] Save a versioned OSM extract; document date, bounds, and source in `Geo/`. -- [ ] Acquire separately licensed elevation data if needed. +- [-] Acquire separately licensed elevation data if needed. Needed: measured relief is 3.94 m over the block (`Geo/verification/2026-11-06-elevation.md`). Source chosen: USGS 3DEP 1 m DEM, public domain, vertical datum NAVD88. +- [ ] Download the 3DEP 1 m DEM for the demo bounds, reproject it to EPSG:26911 with the recorded origin and vertical datum, and record the source, clip bounds, and checksum in `Geo/`. - [-] In QGIS, set a suitable local projected CRS (e.g. EPSG:26911) and record a local origin. CRS and origin point are fixed in `Geo/manifests/local-origin.md`; the EPSG:26911 easting/northing still has to be computed with PROJ/QGIS, which also confirms the transformation. - [x] Verify the address, building footprints, and demo boundaries for 6840 Hollywood Blvd against source data. - [ ] Import OSM blockouts through Blosm; clean and detail them in Blender. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index de92a7f..6c930cd 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -21,7 +21,7 @@ Status values: **verified** (licence confirmed and permits our use), **pending** | --- | --- | --- | --- | | OSM extract — Hollywood block, 2026-11-06 | OpenStreetMap API v0.6, `Geo/raw/osm-api-map-2026-11-06.osm` | ODbL 1.0 | verified — manifest and SHA-256 in `Geo/manifests/` | | OSM building ways 424024147, 424024148 | OpenStreetMap API v0.6, `Geo/raw/*.json` | ODbL 1.0 | verified | -| Elevation data | not yet decided | per provider | **pending** — decide after the DEM variation check in `ROADMAP.md` | +| Elevation data — USGS 3DEP 1 m DEM | `epqs.nationalmap.gov` point service sampled; tile not yet downloaded | US Government work, public domain | verified as a source; acquisition and manifest pending | Attribution string for any OSM-derived output: "© OpenStreetMap contributors", .