geo: measure the block street profile and fix the local CRS and origin

New Geo/scripts/street_profile.py measures the 279.4 m block centreline, the
three signalised crossings (20.9-25.9 m across), sidewalk centrelines at 12.6 m
and 13.6 m, and per-building setbacks of 13.0-15.8 m. Derived sidewalk widths
of ~4.0 m south and ~5.3 m north cross-check against the mapped centrelines.

Geo/manifests/local-origin.md fixes EPSG:26911 and the Highland intersection
node as origin, defines the axes and the x100 metre-to-centimetre rule, and
leaves the projected pair blank because PROJ is not installed here.

Records the lane-count discrepancy (4 tagged lanes over a 20.9 m carriageway).
This commit is contained in:
zwitschi committed 2026-11-06 22:23:44 +01:00
1 parent 74e64a374e
commit 255c1a9e86
6 files changed
+364 -7

No files matched your search

+1 -1
View File
@@ -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 two El Capitan footprints are verified in [`verification/2026-11-06-el-capitan-block.md`](verification/2026-11-06-el-capitan-block.md). The block-wide extract, boundary intersections, north-side enumeration, and elevation check are 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 and the hero façade width remain open.
## Required manifest
+54
View File
@@ -0,0 +1,54 @@
# Local origin and projected CRS
Defined 2026-11-06. This is the single place where geographic coordinates become local metres, and where local metres become Unreal units. Every asset, blockout, and manifest refers to it; nothing else may define its own origin.
## Projected CRS
**EPSG:26911 — NAD83 / UTM zone 11N**, metres, grid north.
NAD83 rather than WGS84 is deliberate: it is the CRS the US national elevation and parcel data are published in, and the difference against WGS84 at this latitude (roughly 1 m, varying) is far below what a walkable demo can show. The choice is recorded here so nobody has to re-derive it, and so an elevation source can be brought in without a second reprojection.
## Origin
| Field | Value |
| --- | --- |
| Definition | Hollywood Blvd × North Highland Avenue intersection node, on the boulevard centreline |
| WGS84 | lat 34.101545, lon −118.338718 |
| OSM node | the single shared node between the `Hollywood Boulevard` and `North Highland Avenue` ways in `Geo/raw/osm-api-map-2026-11-06.osm` |
| EPSG:26911 easting / northing | **to be computed** — see below |
| Rationale | a real, data-defined, reproducible point at the demo block's east boundary; no surveyed control point exists for this project |
The origin is not "the hero building" on purpose: a building may later move a metre or two as verification corrects it, while an intersection node is fixed by the source data.
### Pending value
The projected pair is not filled in yet because neither `pyproj` nor the PROJ command line tools are installed on this workstation (`Docs/environment.md`), and inventing a number that later disagrees with QGIS would be worse than leaving it blank. Fill it in the QGIS step:
```bash
python3 -c "from pyproj import Transformer; \
t=Transformer.from_crs('EPSG:4326','EPSG:26911',always_xy=True); \
print(t.transform(-118.338718, 34.101545))"
```
Record the printed easting/northing back into this table and note the PROJ version used.
## Axis convention
| Axis | Meaning |
| --- | --- |
| Local X | easting − origin easting, metres, east positive |
| Local Y | northing − origin northing, metres, north positive |
| Local Z | up, metres, elevation relative to the origin's ground height |
The block then spans roughly **X −279 m to 0 m**, Y a few tens of metres either side, which keeps float precision comfortable in Unreal.
## Conversion to Unreal
1 metre → **100 Unreal units**. The ×100 happens exactly once, in the export/import path, and is asserted by the scale test required in `ROADMAP.md`. Blender files are authored in metres; Unreal receives centimetres.
Unreal's own axes differ (X forward, Y right, Z up). If a level rotates the imported block so that "north" is not +X, that rotation is recorded once, in the level's documentation, and never baked into the export.
## Obligations
- Any OSM-derived output keeps "© OpenStreetMap contributors" and the licence link. See `THIRD_PARTY_NOTICES.md`.
- Elevation data, when chosen, must be reprojected into EPSG:26911 with the origin above, and its vertical datum recorded (NAVD88 is the expected one for US sources; ellipsoidal heights are not interchangeable).
+238
View File
@@ -0,0 +1,238 @@
#!/usr/bin/env python3
"""Street profile for a block: frontage setbacks, carriageway width, crossings.
Usage:
Geo/scripts/street_profile.py Geo/raw/osm-api-map-2026-11-06.osm \
--blvd "Hollywood Boulevard" --west "North Orange Drive" --east "North Highland Avenue"
Measures, in metres, from a plain .osm extract (standard library only):
* the boulevard centreline, stitched into one ordered line 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;
* 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.
"""
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
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")
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]
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)
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"]:
continue
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')}"
)
print("\nsidewalk centrelines near the block (offset from road centreline):")
south, north = [], []
for wid, tags, refs in ways:
if tags.get("footway") != "sidewalk":
continue
pts = 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"]:
continue
offsets = [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", "")))
for label, rows in (("south side", south), ("north side", north)):
if not rows:
print(f" {label}: none mapped")
continue
rows.sort()
mean = sum(r[0] for r in rows) / len(rows)
print(f" {label}: {len(rows)} line(s), mean offset {mean:.1f} m, range {rows[0][0]:.1f}..{rows[-1][0]:.1f} m")
print("\nbuilding frontage setbacks (nearest vertex to centreline):")
rows = []
for wid, tags, refs in ways:
if "building" not in tags:
continue
pts = 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"]:
continue
proj = [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.sort()
for dist, side, wid, tags, frontage in rows:
side_name = "S" if side < 0 else "N"
print(
f" {side_name} way {wid:>10} {tags.get('addr:housenumber', '-'):>11} "
f"setback {dist:5.1f} m, extent {frontage:5.1f} m {tags.get('name', '')[:34]}"
)
print("\nassumptions to record, not measurements:")
print(" - lane width is not tagged; the crossing lengths above are the best carriageway")
print(" proxy, and crossing endpoints sit within about 1 m of the kerb line")
print(" - sidewalk width is not tagged; it is setback minus half carriageway")
if __name__ == "__main__":
main()
@@ -85,10 +85,10 @@ Excluded and why: `6780` (Bank of America) and everything east of it is east of
## Still unverified
- **Pavement and roadway widths.** The extract carries no sidewalk or carriageway widths. Needed for the walkable demo, the crossing, and street scale against the reference photos.
- **Crossing location.** No marked crossing geometry confirmed yet, and `0.1.0` requires one designated crossing.
- **Elevation.** No DEM checked. The choice between using a separately licensed DEM and skipping it depends on measured height variation across the 279 m block.
- **North-side reference photography.** Both north-side buildings are in scope and neither appears in the reference set. They cannot be detailed from evidence yet.
- **Façade width conflict.** 29.6 m from the sibling project's venue notes against the 25.7 m OSM bbox; recorded in [`../../Docs/reference-notes-hollywood.md`](../../Docs/reference-notes-hollywood.md) and unresolved.
- **Projected origin value.** The CRS and origin point are fixed in [`../manifests/local-origin.md`](../manifests/local-origin.md); the EPSG:26911 easting/northing still needs computing with PROJ or QGIS.
Next: measure roadway and sidewalk widths, fix the local CRS and origin, and resolve the façade-width conflict before any façade modelling.
Measured since this pass: street profile, carriageway and sidewalk widths, and the designated crossing — see [`2026-11-06-street-profile.md`](2026-11-06-street-profile.md). North-side reference photography was fetched on 2026-11-06 (local only); see [`../../Docs/reference-notes-hollywood.md`](../../Docs/reference-notes-hollywood.md).
Next: settle the façade width, check elevation, and complete the projected origin, then the Blender blockout can start.
@@ -0,0 +1,65 @@
# Street profile — Hollywood Blvd block
Measured 2026-11-06 from the block extract (`Geo/raw/osm-api-map-2026-11-06.osm`) with `Geo/scripts/street_profile.py`. Reproduce with:
```bash
python3 Geo/scripts/street_profile.py Geo/raw/osm-api-map-2026-11-06.osm
```
All distances are metres, computed on a local planar approximation at the block's mean latitude (error well under 1 cm over 300 m). OSM is layout data; these are geometry measurements, not a survey.
## Boulevard
- Centreline between Orange Drive and Highland Avenue: **279.4 m**, 9 vertices.
- Posted speed `25 mph`, `lanes=4` on the block segments, `surface=asphalt`, `sidewalk:both=separate` (sidewalks mapped as their own ways).
## Crossings across the boulevard
Three signalised crossings, measured as the length of the crossing way:
| OSM way | Across the road | Position along block | Markings | Notes |
| --- | --- | --- | --- | --- |
| 385648318 | 24.4 m | 36.6 m | yes | at Orange Drive; widened by the junction |
| 385648319 | 20.9 m | 130.1 m | yes | mid-block, just west of the hero frontage |
| 385648320 | 25.9 m | 266.1 m | not tagged | at Highland Avenue; junction flare |
Crossing endpoints sit within about 1 m of the kerb line, so these lengths approximate the carriageway width, not the full road reserve. Other `footway=crossing` ways in the extract cross other streets or lead along the boulevard; they are filtered out.
## Sidewalk and frontage
Mapped sidewalk centrelines sit **12.6 m** (south) and **13.6 m** (north) from the road centreline. Building setbacks, measured to each building's nearest vertex:
| Side | Building | Setback | Frontage extent |
| --- | --- | ---: | ---: |
| S | 6922 | 14.6 m | 66.1 m |
| S | 6902 | 14.5 m | 31.0 m |
| S | **6840 El Capitan Entertainment Centre** | **14.5 m** | **25.7 m** |
| S | 6838 El Capitan Theatre | 14.8 m | 38.6 m |
| S | 6822 Budget | 13.8 m | 6.2 m |
| S | 6806–6820 | 13.0 m | 33.1 m |
| S | 6800 Lee Drug Building | 14.9 m | 19.0 m |
| N | 6925 TCL Chinese Theatre | 15.7 m | 44.0 m |
| N | 6933 Madame Tussaud's | 15.8 m | 42.0 m |
## Derived widths
Using the mid-block crossing (20.9 m across) as the carriageway width, half-width is 10.45 m:
- South sidewalk: 14.5 − 10.45 ≈ **4.0 m**.
- North sidewalk: 15.7 − 10.45 ≈ **5.3 m** (measured to the Chinese Theatre, which sits behind its forecourt, so this is an upper bound).
Cross-check: a 4.0 m sidewalk between the kerb at 10.45 m and the hero frontage at 14.5 m puts its centreline at 12.5 m, against 12.6 m measured. The arithmetic is consistent, which is why these figures are usable despite nothing being tagged with a width.
## Discrepancy to record
`lanes=4` with a 20.9 m carriageway implies **5.2 m per lane**. That is not a plausible lane width, so one of these is true: parking or turn lanes are untagged, or the crossing overstates the kerb-to-kerb distance by a metre or two. Do not derive lane counts from the width or width from the lane count until this is settled against the reference photographs. It does not block the demo: the crossings and setbacks are measured independently of the tag.
## Designated crossing for `0.1.0`
Use the **mid-block signalised crossing, OSM way 385648319, at 130 m along the block**. It is about 4 m west of the hero building's west corner, so it is the crossing a player naturally uses to reach the El Capitan Entertainment Centre frontage from the north side. The alternative at Orange Drive (36.6 m) is at the block corner and 24.4 m wide; Highland (266.1 m) is the far junction.
## Still open
- The lane-width discrepancy above.
- Kerb height, kerb radius at the crossings, and any sidewalk obstructions — not in OSM.
- Road surface slopes and elevation, pending the DEM decision.