Files
gig-line/Geo/tools/asset_metadata.py
zwitschi 386d4bee78 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.
2026-11-07 12:28:26 +01:00

133 lines
5.0 KiB
Python
Executable File

#!/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())