#!/usr/bin/env python3 """Read, write and audit per-asset geographic metadata — the provenance sidecar. Every exported asset gets `.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())