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:
zwitschi committed 2026-11-07 12:28:26 +01:00
1 parent 5e5e5ae9b4
commit 386d4bee78
26 files changed
+471 -57

No files matched your search

+1 -1
View File
@@ -39,7 +39,7 @@ The initial release is **`0.1.0`**: a walkable exterior demo around **6840 Holly
- Track `.uasset`, `.umap`, `.blend`, and large source binaries with LFS.
- Ignore `Binaries/`, `Intermediate/`, `Saved/`, and Derived Data Cache.
- Never commit Unreal Engine source, credentials, or non-redistributable assets.
- Planned remote repository: `https://git.allucanget.biz/zwitschi/gig-line.git`. Do not assume it is configured or available.
- Active remote repository (`origin`): `https://git.allucanget.biz/zwitschi/gig-line.git`. Verify connectivity before use.
## Release Policy
+31
View File
@@ -0,0 +1,31 @@
{
"schema": 1,
"asset": "blockout",
"kind": "building",
"generated_utc": "2026-11-07T11:27:43+00:00",
"geographic": {
"crs": "EPSG:26911",
"source": "Geo/raw/osm-api-map-2026-11-06.osm, demo bounds per Geo/verification/2026-11-06-el-capitan-block.md",
"centre_easting_m": 376380.0,
"centre_northing_m": 3774200.0,
"elevation_navd88_m": 117.0,
"origin": {
"easting_m": 376515.389,
"northing_m": 3774224.083,
"reference": "Geo/manifests/local-origin.md"
}
},
"unreal": {
"centre_cm": [
-13538.9,
2408.3,
108.9
],
"conversion": "100 uu per metre, applied once in osm_common.to_unreal_local",
"axes": "X east, Y south (FBX preset flips north), Z up"
},
"dims_report": null,
"notes": [
"86 meshes from Blosm; affine-corrected from the spherical TM frame into EPSG:26911 (0.0 cm residual)"
]
}
File renamed without changes.
+31
View File
@@ -0,0 +1,31 @@
{
"schema": 1,
"asset": "hero-facade",
"kind": "building",
"generated_utc": "2026-11-07T11:26:11+00:00",
"geographic": {
"crs": "EPSG:26911",
"source": "OSM way 424024147 (El Capitan Entertainment Centre), 25.75 m frontage",
"centre_easting_m": 376387.944,
"centre_northing_m": 3774185.293,
"elevation_navd88_m": 116.6,
"origin": {
"easting_m": 376515.389,
"northing_m": 3774224.083,
"reference": "Geo/manifests/local-origin.md"
}
},
"unreal": {
"centre_cm": [
-12744.5,
3879.0,
68.9
],
"conversion": "100 uu per metre, applied once in osm_common.to_unreal_local",
"axes": "X east, Y south (FBX preset flips north), Z up"
},
"dims_report": "Art/blender/hero/hero-facade-2026-11-07.json",
"notes": [
"frontage 25.75 m and height 14.5 m measured; column bay 4.96 m from the sibling notes; portal flights from the photographs; everything else assumption"
]
}
@@ -26,7 +26,7 @@ import sys
import bpy
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "Geo", "scripts"))
sys.path.insert(0, os.path.join(HERE, "..", "..", "..", "Geo", "tools"))
import osm_common as oc # noqa: E402
ORIGIN_E = 376515.389
+31
View File
@@ -0,0 +1,31 @@
{
"schema": 1,
"asset": "street",
"kind": "street",
"generated_utc": "2026-11-07T11:27:43+00:00",
"geographic": {
"crs": "EPSG:26911",
"source": "Hollywood Blvd centreline between Orange Dr and Highland Ave, Geo/verification/2026-11-06-street-profile.md",
"centre_easting_m": 376375.6,
"centre_northing_m": 3774226.0,
"elevation_navd88_m": 117.3,
"origin": {
"easting_m": 376515.389,
"northing_m": 3774224.083,
"reference": "Geo/manifests/local-origin.md"
}
},
"unreal": {
"centre_cm": [
-13978.9,
-191.7,
138.9
],
"conversion": "100 uu per metre, applied once in osm_common.to_unreal_local",
"axes": "X east, Y south (FBX preset flips north), Z up"
},
"dims_report": null,
"notes": [
"carriageway -10.5..+10.5 m, kerbs 0.15 m assumed, sidewalks 4.0 m south / 5.3 m north"
]
}
@@ -38,7 +38,7 @@ import sys
import bpy
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "Geo", "scripts"))
sys.path.insert(0, os.path.join(HERE, "..", "..", "..", "Geo", "tools"))
import dem_stats # noqa: E402
import osm_common as oc # noqa: E402
+31
View File
@@ -0,0 +1,31 @@
{
"schema": 1,
"asset": "terrain",
"kind": "terrain",
"generated_utc": "2026-11-07T11:27:43+00:00",
"geographic": {
"crs": "EPSG:26911",
"source": "3DEP 1 m DEM window, Geo/manifests/2026-11-06-3dep-dem.md",
"centre_easting_m": 376375.0,
"centre_northing_m": 3774225.0,
"elevation_navd88_m": 115.911,
"origin": {
"easting_m": 376515.389,
"northing_m": 3774224.083,
"reference": "Geo/manifests/local-origin.md"
}
},
"unreal": {
"centre_cm": [
-14038.9,
-91.7,
0.0
],
"conversion": "100 uu per metre, applied once in osm_common.to_unreal_local",
"axes": "X east, Y south (FBX preset flips north), Z up"
},
"dims_report": null,
"notes": [
"2 m grid, 191 x 106, building footprints cut out; Z relative to origin ground 115.911 m NAVD88"
]
}
@@ -31,7 +31,7 @@ import subprocess
import bpy
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "Geo", "scripts"))
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "..", "Geo", "tools"))
import dem_stats # noqa: E402
import osm_common as oc # noqa: E402
+1 -1
View File
@@ -61,7 +61,7 @@ The archive on disk is **Unreal Engine 5.2.0**, released May 2023, three and a h
- `[x]` Blender 5.2 LTS (Flatpak) and MPFB 2.0.18 recorded.
- `[x]` Install QGIS and record its version. QGIS **4.2.3**, Flatpak `stable`, runtime `org.kde.Platform 6.11`, bundled PROJ `9.9.0`. The stale `lts` 3.28.9 install was uninstalled 2026-11-07; only `stable` remains.
- `[x]` Install Blosm in Blender, record its version, and confirm it loads on Blender 5.2. Blosm **2.7.28** imports the block extract headless in `3Dsimple` mode; the exact command and the two required headless workarounds are in `Art/blender/README.md`. No second Blender version was needed.
- `[ ]` **Optional.** Confirm the geo tool Python environment (venv with `pyproj`, `GDAL`, `shapely`). Not needed so far: every geo script in `Geo/scripts/` is standard-library only, and PROJ 9.9.0 is invoked from the QGIS Flatpak (`LD_LIBRARY_PATH` trick in `Geo/manifests/local-origin.md`). Add the venv only when a step genuinely needs `pyproj` or GDAL in-process.
- `[ ]` **Optional.** Confirm the geo tool Python environment (venv with `pyproj`, `GDAL`, `shapely`). Not needed so far: every geo script in `Geo/tools/` is standard-library only, and PROJ 9.9.0 is invoked from the QGIS Flatpak (`LD_LIBRARY_PATH` trick in `Geo/manifests/local-origin.md`). Add the venv only when a step genuinely needs `pyproj` or GDAL in-process.
## 4. Project bootstrap
+26 -29
View File
@@ -1,48 +1,45 @@
# Geo
Geographic sources, manifests, and the scripts that turn them into Blender imports.
Geographic sources, manifests, and the tools that turn them into Blender and Unreal content.
OSM is layout data only. It is not authoritative for façade detail, height, or pavement, and it is never sampled as a texture or panorama source.
The OSM source files are © OpenStreetMap contributors, available under the [Open Database License 1.0](https://opendatacommons.org/licenses/odbl/1-0/); see [OpenStreetMap attribution](https://www.openstreetmap.org/copyright). Public use of a derivative database can require sharing that database or the changes needed to recreate it under ODbL. Review the applicable obligations for each distributed product.
## Layout
| Path | Contents |
| --- | --- |
| `Geo/raw/` | Verbatim source responses, checksummed. Never edited by hand. |
| `Geo/verification/` | Per-pass verification notes: address, footprint, bounds, and what remains unresolved |
| `Geo/manifests/` | One manifest per saved extract (see below) |
| `Geo/scripts/` | Repeatable fetch and conversion helpers |
| `Geo/raw/` | Verbatim source responses and downloads, checksummed. Never edited by hand. |
| `Geo/manifests/` | One manifest per source: extract, DEM, and the local origin |
| `Geo/verification/` | Per-pass notes: address, footprints, bounds, street profile, cross-sections, elevation |
| `Geo/tools/` | Every geographic tool (Python, standard library only unless noted) |
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 and the 3DEP 1 m DEM window is committed (EPSG:26911, NAVD88, 984 KB). The street cross-sections (crown ~2 %, gutters at ±10–11 m) are in `verification/`. The hero façade width is resolved at 25.75 m. The geo pipeline is ready for the Blender blockout.
Tools that build 3D content from these sources live next to their output in `Art/blender/*/` (`terrain_from_dem.py`, `street_mesh.py`, `site_assembly.py`, `blosm_blockout.py`) because they need Blender; their shared geo helpers and the unit conversion come from `Geo/tools/osm_common.py`.
## Required manifest
## Tools
Every extract gets a manifest file `Geo/manifests/<source>-<date>.md` recording:
| Field | Example |
| Tool | Purpose |
| --- | --- |
| Source | Overpass API / Geofabrik / OSM extract provider |
| Extract date | date the query ran or the file was downloaded |
| Query or file URL | the exact query text or download URL |
| Data timestamp | OSM data date reported by the source (may differ from the extract date) |
| Bounds | west, south, east, north in WGS84 decimal degrees |
| Licence | ODbL 1.0, attribution string |
| Checksum | SHA-256 of the committed file |
| CRS and local origin | projected CRS plus the chosen origin, with its WGS84 coordinates |
| Files | every file produced from this extract |
| `osm_common.py` | Shared helpers: parse `.osm`, stitch and clip a street, project to UTM through PROJ, **and the single metres→centimetres conversion** (`to_unreal_local`) |
| `block_report.py` | Streets, boundary intersections, and the building set for a block, split by side |
| `block_height_report.py` | Every block building with its height **source** — tag, levels × 3.2 m, or a placeholder guess |
| `street_profile.py` | Carriageway width from crossings, sidewalk offsets, per-building setbacks |
| `street_section.py` | Cross-sections from the DEM: crown, gutters, kerb detection |
| `elevation_probe.py` | Samples 3DEP for relief and records the baseline for the release checks |
| `dem_stats.py` | Stdlib reader for the tiled GeoTIFF the 3DEP service returns; GDAL does not run on this host |
| `asset_metadata.py` | Writes and audits `<asset>.geo.json` sidecars: position, provenance, conversion |
## Demo bounds
## Unit boundary
`0.1.0` covers 6840 Hollywood Blvd / El Capitan Entertainment Centre, buildings on the south side of Hollywood Boulevard between Highland Avenue and Orange Drive, and north-side buildings whose footprint lies within 50 m of the north frontage. Verify address, footprints, and boundaries against source data before modeling; anything unverifiable is recorded here as unresolved and is not modeled.
There is exactly one conversion in the repository:
## Coordinate convention
```
WGS84 (lat, lon) → EPSG:26911 (E, N metres) → project-local metres → Unreal centimetres
```
- Work in a local projected CRS (EPSG:26911, NAD83 / UTM zone 11N, is the default candidate) and record the local origin explicitly.
- Blender and Unreal both take metres → Unreal units at 1 m = 100 uu. The conversion is applied once, in one place, and recorded in the manifest. Never apply a second silent scale on import.
- Preserve the original geographic coordinates in metadata so any asset can be traced back to its position.
`Geo/tools/osm_common.py::to_unreal_local` is the only place that multiplies by 100. Per-asset sidecars record the result, and `asset_metadata.py audit` checks each saved sidecar against that function, so a file that drifts is caught rather than trusted.
## Unresolved items
## Current state
Record here any structure whose address, footprint, or boundary could not be verified from permitted sources, with the sources checked. It does not get modeled.
The extract, both El Capitan footprints, the demo bounds, the building sets, the street profile, cross-sections, and the 3DEP DEM window are recorded in `Geo/verification/` and `Geo/manifests/`. The local CRS and origin are fixed in `manifests/local-origin.md`.
Open items live in `ROADMAP.md` under **Geographic data**.
+1 -1
View File
@@ -35,7 +35,7 @@ Min 110.46 m, max 127.73 m, mean 117.43 m over the 550 × 350 m window — a 17.
## Reading the file
`Geo/scripts/dem_stats.py` parses it with the standard library. GDAL cannot run on the host (its flatpak libraries do not resolve outside the sandbox), and the exported TIFF is **tiled**, not strip-organised — a strip reader silently scrambles the rows. If another tool misreads it, suspect the tile layout before the data.
`Geo/tools/dem_stats.py` parses it with the standard library. GDAL cannot run on the host (its flatpak libraries do not resolve outside the sandbox), and the exported TIFF is **tiled**, not strip-organised — a strip reader silently scrambles the rows. If another tool misreads it, suspect the tile layout before the data.
## Open
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""Read, write and audit per-asset geographic metadata — the provenance sidecar.
Every exported asset gets `<asset>.geo.json` next to it carrying the boundary it
was modelled against, its position in EPSG:26911 and in Unreal centimetres, the
reasoning behind every non-measured dimension, and the unit conversion used. The
file makes three things checkable after the fact:
* where the asset belongs in the world, without re-deriving it;
* which measurements are evidence and which are assumptions;
* that the metres-to-centimetres conversion was applied once, consistently.
Geo/tools/asset_metadata.py write --asset hero-facade --kind building \\
--bounds "OSM way 424024147" --centre-easting 376381.9 --centre-northing 3774184 \\
--out Art/blender/hero
Geo/tools/asset_metadata.py audit Art/blender/hero
"""
import argparse
import datetime
import glob
import json
import os
import sys
import osm_common as oc
SCHEMA = 1
def build(asset, kind, bounds, easting, northing, elevation, report_path, extra):
x, y, z = oc.to_unreal_local(easting, northing, elevation if elevation else 0.0)
doc = {
"schema": SCHEMA,
"asset": asset,
"kind": kind,
"generated_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"),
"geographic": {
"crs": "EPSG:26911",
"source": bounds,
"centre_easting_m": easting,
"centre_northing_m": northing,
"elevation_navd88_m": elevation,
"origin": {
"easting_m": oc.ORIGIN_E,
"northing_m": oc.ORIGIN_N,
"reference": "Geo/manifests/local-origin.md",
},
},
"unreal": {
"centre_cm": [round(x, 1), round(y, 1), None if z is None else round(z, 1)],
"conversion": f"{oc.METRES_TO_UNREAL:g} uu per metre, applied once in osm_common.to_unreal_local",
"axes": "X east, Y south (FBX preset flips north), Z up",
},
"dims_report": report_path,
"notes": extra,
}
return doc
def cmd_write(args):
doc = build(args.asset, args.kind, args.bounds, args.centre_easting, args.centre_northing,
args.elevation, args.report, args.note or [])
path = os.path.join(args.out, args.asset + ".geo.json")
os.makedirs(args.out, exist_ok=True)
with open(path, "w") as f:
json.dump(doc, f, indent=2)
f.write("\n")
print(f"wrote {path}")
def cmd_audit(targets):
files = []
for t in targets:
if os.path.isdir(t):
# assets keep their sidecars either in the same folder as the .blend
# or one level down, so a directory target must recurse
files += glob.glob(os.path.join(t, "**", "*.geo.json"), recursive=True)
else:
files.append(t)
bad = 0
for path in sorted(files):
try:
doc = json.load(open(path))
except Exception as exc: # noqa: BLE001
print(f"UNREADABLE {path}: {exc}")
bad += 1
continue
missing = [k for k in ("asset", "kind", "geographic", "unreal") if k not in doc]
if missing:
print(f"MISSING {path}: {missing}")
bad += 1
continue
u = doc["unreal"]["centre_cm"]
geo = doc["geographic"]
expect_x = (geo["centre_easting_m"] - oc.ORIGIN_E) * oc.METRES_TO_UNREAL
expect_y = -(geo["centre_northing_m"] - oc.ORIGIN_N) * oc.METRES_TO_UNREAL
if abs(u[0] - expect_x) > 1.0 or abs(u[1] - expect_y) > 1.0:
print(f"STALE {path}: centre_cm {u} disagrees with the origin conversion "
f"({expect_x:.1f}, {expect_y:.1f})")
bad += 1
continue
print(f"ok {doc['asset']:24} {doc['kind']:10} centre_cm {u[0]:10.1f} {u[1]:10.1f} "
f"src {geo['source'][:40]}")
print(f"\n{len(files)} sidecars, {bad} problem(s)")
return 1 if bad else 0
def main():
ap = argparse.ArgumentParser()
sub = ap.add_subparsers(dest="cmd", required=True)
w = sub.add_parser("write")
w.add_argument("--asset", required=True)
w.add_argument("--kind", required=True, choices=("building", "terrain", "street", "prop", "character"))
w.add_argument("--bounds", required=True, help="what the asset was modelled against")
w.add_argument("--centre-easting", type=float, required=True)
w.add_argument("--centre-northing", type=float, required=True)
w.add_argument("--elevation", type=float, default=None, help="NAVD88 metres")
w.add_argument("--report", default=None, help="dimension report produced by the build script")
w.add_argument("--note", action="append")
w.add_argument("--out", required=True)
a = sub.add_parser("audit")
a.add_argument("targets", nargs="+")
args = ap.parse_args()
if args.cmd == "write":
cmd_write(args)
return 0
return cmd_audit(args.targets)
if __name__ == "__main__":
sys.exit(main())
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Report which block buildings have real height data and which are guesses.
Usage:
Geo/tools/block_height_report.py Geo/raw/osm-api-map-2026-11-06.osm
`height` and `building:levels` are the only height evidence OSM carries. Where
neither exists, Blosm falls back to `defaultLevels * levelHeight`, which is a
placeholder: the demo renders a constant-height block instead of a real skyline.
This tool lists every building in the demo bounds with its height source, so the
substitutions are visible and measurable rather than buried in the model.
Exit code is 0 either way; the count of guessed buildings is the deliverable.
"""
import argparse
import math
import sys
import osm_common as oc
LEVEL_HEIGHT = 3.2
def main():
ap = argparse.ArgumentParser()
ap.add_argument("osm")
args = ap.parse_args()
nodes, ways = oc.load(args.osm)
line, lat0, lon0, lengths, block_len = oc.block_extent(
nodes, ways, "Hollywood Boulevard", "North Orange Drive", "North Highland Avenue")
def in_block(pts):
if min(p[1] for p in pts) > line[-1][1] or max(p[1] for p in pts) < line[0][1]:
return False
return True
rows = []
for wid, tags, refs in ways:
if "building" not in tags:
continue
pts = oc.way_points(nodes, refs)
if not pts or not in_block(pts):
continue
tag_height = tags.get("height")
levels = tags.get("building:levels")
if tag_height:
height, source = float(tag_height), "height tag"
elif levels:
height, source = float(levels) * LEVEL_HEIGHT, f"levels x {LEVEL_HEIGHT} m"
else:
height, source = 2 * LEVEL_HEIGHT, f"GUESS: default 2 levels x {LEVEL_HEIGHT} m"
nearest = min(oc.project(p, line, lat0, lon0, lengths)[0] for p in pts)
side = "S" if oc.project(pts[0], line, lat0, lon0, lengths)[2] < 0 else "N"
rows.append((side, nearest, wid, tags.get("name", "") or tags.get("addr:housenumber", ""),
height, source))
rows.sort(key=lambda r: (r[0], r[1]))
guessed = [r for r in rows if r[5].startswith("GUESS")]
print(f"{len(rows)} buildings in the block, {len(guessed)} with a guessed height\n")
for side, setback, wid, name, height, source in rows:
flag = " <-- guessed" if source.startswith("GUESS") else ""
print(f"{side} way {wid:>10} setback {setback:5.1f} m height {height:5.1f} m "
f"({source:26}) {name[:34]}{flag}")
if guessed:
print("\nGuessed heights to replace from the reference photography:")
for side, setback, wid, name, height, source in guessed:
print(f" {side} way {wid} {name}")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -2,7 +2,7 @@
"""Report streets, boundary intersections and the demo building set for an OSM extract.
Usage:
Geo/scripts/block_report.py Geo/raw/osm-api-map-2026-11-06.osm \
Geo/tools/block_report.py Geo/raw/osm-api-map-2026-11-06.osm \
--blvd "Hollywood Boulevard" --west "North Orange Drive" --east "North Highland Avenue"
Reads a plain .osm XML file (the output of the OSM API `map` endpoint or an
@@ -2,8 +2,8 @@
"""Read a plain float32 GeoTIFF, report its georeferencing and elevation stats.
Usage:
Geo/scripts/dem_stats.py Geo/raw/dem-3dep-1m-26911-2026-11-06.tif
Geo/scripts/dem_stats.py <tif> --sample 376515.389,3774224.083
Geo/tools/dem_stats.py Geo/raw/dem-3dep-1m-26911-2026-11-06.tif
Geo/tools/dem_stats.py <tif> --sample 376515.389,3774224.083
Why this exists: GDAL is not runnable on the host (its flatpak libraries do not
resolve outside the sandbox), so this reads the strip-based GeoTIFF that the
@@ -2,7 +2,7 @@
"""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
Geo/tools/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
@@ -1,17 +1,92 @@
#!/usr/bin/env python3
"""Shared helpers for the Geo scripts: parse .osm, stitch a street, project points.
"""Shared geo helpers: parse .osm, stitch a street, project points, convert to Unreal.
Standard library only. Imported by `block_report.py`, `street_profile.py`, and
`elevation_probe.py` when run from this directory.
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)."""
@@ -2,7 +2,7 @@
"""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 \
Geo/tools/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):
@@ -2,7 +2,7 @@
"""Cross-sections of the street from the local 3DEP DEM.
Usage:
Geo/scripts/street_section.py Geo/raw/osm-api-map-2026-11-06.osm \
Geo/tools/street_section.py Geo/raw/osm-api-map-2026-11-06.osm \
Geo/raw/dem-3dep-1m-26911-2026-11-06.tif --step 0.5
For several positions along the block it samples the DEM on a line perpendicular
@@ -1,6 +1,6 @@
# Cross-sections — crowned road, gutters, sidewalk shelves
Sampled 2026-11-06 from the local 3DEP DEM (`Geo/raw/dem-3dep-1m-26911-2026-11-06.tif`) with `Geo/scripts/street_section.py`. Seven sections at 40 m spacing, offsets −18 m to +18 m from the boulevard centreline (negative = south), 1 m sampling step.
Sampled 2026-11-06 from the local 3DEP DEM (`Geo/raw/dem-3dep-1m-26911-2026-11-06.tif`) with `Geo/tools/street_section.py`. Seven sections at 40 m spacing, offsets −18 m to +18 m from the boulevard centreline (negative = south), 1 m sampling step.
## What the profile shows
+2 -2
View File
@@ -1,9 +1,9 @@
# 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:
Measured 2026-11-06 with `Geo/tools/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
python3 Geo/tools/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.
@@ -1,9 +1,9 @@
# 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:
Measured 2026-11-06 from the block extract (`Geo/raw/osm-api-map-2026-11-06.osm`) with `Geo/tools/street_profile.py`. Reproduce with:
```bash
python3 Geo/scripts/street_profile.py Geo/raw/osm-api-map-2026-11-06.osm
python3 Geo/tools/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.
+1 -1
View File
@@ -52,7 +52,7 @@ Scope: facts and traps that cost time to establish. Planned work lives in [ROADM
## 2026-11-06 — Geo data traps
- **The 3DEP export is a tiled TIFF, not strips.** A strip reader silently scrambles rows and produces fake 0 m / 127 m bands. `Geo/scripts/dem_stats.py` handles tiles.
- **The 3DEP export is a tiled TIFF, not strips.** A strip reader silently scrambles rows and produces fake 0 m / 127 m bands. `Geo/tools/dem_stats.py` handles tiles.
- **Sample walk routes through PROJ.** Placing samples with the OSM spherical approximation put them metres off the UTM meshes and reported 91 phantom holes. `/tmp` is not shared between tool calls here either — regenerate instead of saving intermediate files there.
- **3DEP cannot resolve kerbs.** Sharpest break near the kerb line is 0.09–0.12 m/m, a gutter transition, not a 0.15 m upstand. Kerb height stays a stated assumption.
- **Building interiors are not ground.** Points inside the hero footprint returned 114.5–117.1 m — structure, not terrain. Excluded from the relief figure.
+15 -3
View File
@@ -42,15 +42,27 @@ Status detail in [Docs/setup.md](Docs/setup.md); pins in [Docs/toolchain.md](Doc
- [x] Export one MPFB character with baked materials and deform skeleton; verify import. 2026-11-07: MPFB 2.0.18 generated human (19 158 verts, 1.66 m tall) + standard rig with weights (137 bones) + v2 skin baked to a 1024² diffuse texture (`Tools/mpfb_character_export.py`, export preset `ue_character_v1`). Imported headless (`Tools/ue_character_import.py`) into `/Game/Test/Character`: `SkeletalMesh` + 138-bone `Skeleton` + `Material` with the baked `Texture2D` assigned to slot 0. Quirks recorded: 5.8 Interchange skips materials for skeletal FBX imports (finished in script); Blender 5.2 dropped the FBX `armature_nla` option; the baked image needs `image.filepath` set for FBX to reference it.
- [x] Document initial setup in `Docs/`: engine install, project checkout, fresh-checkout build procedure. 2026-11-07: `Docs/setup.md` reconciled with reality (all engine/bootstrap items marked done with evidence); `Docs/build.md` completed with the fresh-checkout build, packaging, and headless-content-check procedures plus known engine quirks.
### Geographic data
### Geographic data — active — gig-line-geo
Tools ported 2026-11-07: every geographic script now lives in `Geo/tools/` with `Geo/README.md` listing its purpose, and the Blender-side builders moved next to their outputs in `Art/blender/*/`, importing the shared helpers and the single unit conversion from `Geo/tools/osm_common.py`.
| Tool | Purpose |
| --- | --- |
| `osm_common.py` | parse `.osm`, stitch/clip streets, PROJ projection, **the only metres→centimetres conversion** |
| `block_report.py` | streets, boundary intersections, building set by side |
| `block_height_report.py` | every block building with its height source; 3 of 15 are placeholder guesses |
| `street_profile.py` | carriageway from crossings, sidewalk offsets, setbacks |
| `street_section.py` | DEM cross-sections, crown and gutters |
| `elevation_probe.py` | 3DEP relief sampling |
| `dem_stats.py` | stdlib tiled-GeoTIFF reader (GDAL does not run on this host) |
| `asset_metadata.py` | writes and audits the `<asset>.geo.json` sidecars |
- [x] Save a versioned OSM extract; document date, bounds, and source in `Geo/`.
- [x] Acquire separately licensed elevation data if needed. Needed and acquired: USGS 3DEP 1 m DEM in EPSG:26911, public domain, NAVD88, 984 KB committed (`Geo/manifests/2026-11-06-3dep-dem.md`); measured relief 3.94 m.
- [x] 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/`.
- [x] In QGIS, set a suitable local projected CRS (e.g. EPSG:26911) and record a local origin. EPSG:26911, origin and projected easting/northing are recorded in `Geo/manifests/local-origin.md` with PROJ 9.9.0 control points; QGIS 4.2.3 is installed for the DEM reprojection step.
- [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. Imported 2026-11-06 (`Art/blender/blockout/blockout-2026-11-06.blend`, 86 meshes, scale 1:1 against OSM, re-framed into EPSG:26911 with a 0.0 cm affine residual); cleaning and detailing not started.
- [-] Preserve geographic coordinates in metadata; define explicit meter→centimeter conversion to local Unreal coordinates. The frame is defined (`Geo/manifests/local-origin.md`: EPSG:26911, origin Hollywood × Highland, X east / Y north / Z up) and the ×100 conversion is now **verified** by the 2026-11-07 scale test, with the axis mapping recorded (north realizes as UE −Y). Left: build the production export preset for the actual site meshes using these settings.
- [~] Import OSM blockouts through Blosm; clean and detail them in Blender. Imported 2026-11-06 and re-framed to EPSG:26911. Tooling for the cleaning pass is now in `Geo/tools/` (height provenance, block report) and `Art/blender/blockout/blosm_blockout.py`. **Still open:** 3 of 15 block buildings carry no height data and render at a placeholder 6.4 m, roofs and facades are untextured, and Blosm `setupScript` façade styles are unused. Earlier: (`Art/blender/blockout/blockout-2026-11-06.blend`, 86 meshes, scale 1:1 against OSM, re-framed into EPSG:26911 with a 0.0 cm affine residual); cleaning and detailing not started.
- [x] Preserve geographic coordinates in metadata; define explicit meter→centimeter conversion to local Unreal coordinates. Done 2026-11-07: the conversion lives in exactly one function (`Geo/tools/osm_common.py::to_unreal_local`) and every exporter must call it; `<asset>.geo.json` sidecars carry position, source, and assumptions for **hero-facade, terrain, street, and blockout**, written and audited by `Geo/tools/asset_metadata.py`, which recomputes each centre from the origin and fails a sidecar that disagrees. Props follow the same schema when they are imported. The frame is defined (`Geo/manifests/local-origin.md`: EPSG:26911, origin Hollywood × Highland, X east / Y north / Z up) and the ×100 conversion is now **verified** by the 2026-11-07 scale test, with the axis mapping recorded (north realizes as UE −Y). Left: build the production export preset for the actual site meshes using these settings.
### Review scene and player placement - active - gig-line-review-placement
+3 -4
View File
@@ -14,10 +14,9 @@ Repeatable import/export and processing helpers. Anything that was done twice by
- `render_screenshots.sh` — visual-review pipeline: rebuilds `/Game/Test/ReviewMap` from current content (`ue_build_review_map.py`), runs the game on it in `-game` mode, lets `AGigLineShotActor` (`Game/GigLine/Source/GigLine/`) capture the fixed camera list, and copies the PNGs into the gitignored `Screenshots/`. See `Screenshots/README.md`.
- `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.
- `site_assembly.py` — assembles terrain + street + blockout into one file and raycasts the walking routes for holes and steps.
- `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`.
- Geographic tools and their shared helpers live in `Geo/tools/` (see `Geo/README.md`).
- Blender-side builders that turn geo data into meshes live beside their output: `Art/blender/terrain/terrain_from_dem.py`, `Art/blender/street/street_mesh.py`, `Art/blender/site/site_assembly.py`, `Art/blender/blockout/blosm_blockout.py`. They import their helpers from `Geo/tools/osm_common.py`, which is also the only place that converts metres to Unreal centimetres.
- `sibling_asset_audit.py` — reads the sibling project's prop library straight from GLB headers and writes `Art/props/reuse-manifest.json`; copies nothing.
- `page_image_fetch.py` — resolves a web page's `og:image` and downloads it, with page URL and hash in a local sidecar. Works for Flickr, Commons file pages, and most photo blogs.
- `commons_fetch.py` — Wikimedia Commons reference fetcher. Search, exact `File:` titles, or a whole Commons category (`--category`), thumbnails, provenance `sidecar.json` per folder. Wikimedia throttles `upload.wikimedia.org` (HTTP 429), so keep batches to six or fewer files and pass `--delay 3`; a throttled file is skipped and recorded rather than aborting the run. Writes only into the gitignored `Art/reference/`; reference images are never committed.