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.
133 lines
5.0 KiB
Python
Executable File
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())
|