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:
1 parent
5e5e5ae9b4
commit
386d4bee78
26 files changed
+471
-57
No files matched your search
@@ -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)"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
"""Headless Blosm blockout: import an OSM extract into a .blend, no GUI.
|
||||
|
||||
Run with Blender in background mode. The add-on path has to point at the folder
|
||||
that contains `blosm/` (for the Flatpak install that is
|
||||
`~/.var/app/org.blender.Blender/config/blender/5.2/scripts`):
|
||||
|
||||
B=/var/lib/flatpak/app/org.blender.Blender/x86_64/stable/active/files
|
||||
LD_LIBRARY_PATH="$B/blender/lib" \
|
||||
BLENDER_USER_SCRIPTS="$HOME/.var/app/org.blender.Blender/config/blender/5.2/scripts" \
|
||||
"$B/blender/blender" -b --factory-startup \
|
||||
--python Tools/blosm_blockout.py -- \
|
||||
--osm Geo/raw/osm-api-map-2026-11-06.osm \
|
||||
--bounds 34.1006,-118.3422,34.1020,-118.3370 \
|
||||
--out Art/blender/blockout/blockout-2026-11-06.blend
|
||||
|
||||
What it does:
|
||||
|
||||
1. enables Blosm and checks that `scene.blosm` exists;
|
||||
2. imports OSM data from a local `.osm` file (no network, no Overpass);
|
||||
3. reports what arrived, per import category and in metres, so a scale or
|
||||
missing-category failure is visible in the log rather than at first render;
|
||||
4. saves the .blend.
|
||||
|
||||
No source assets are modified in place: the output path is the only write.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import addon_utils
|
||||
import bpy
|
||||
|
||||
|
||||
def parse_args():
|
||||
argv = sys.argv
|
||||
argv = argv[argv.index("--") + 1 :] if "--" in argv else []
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--osm", required=True, help="local .osm file to import")
|
||||
ap.add_argument("--bounds", required=True, help="south,west,north,east in WGS84 degrees")
|
||||
ap.add_argument("--out", required=True, help="output .blend path")
|
||||
ap.add_argument("--level-height", type=float, default=3.2, help="metres per building level")
|
||||
ap.add_argument("--origin", default=None,
|
||||
help="lat,lon to use as the Blender origin (project origin). Default: Blosm centres on the extract")
|
||||
ap.add_argument("--no-subdivide", action="store_true", help="skip OSM area subdivision")
|
||||
ap.add_argument("--load-missing", action="store_true",
|
||||
help="let Blosm fetch relation members missing from the extract (needs network, breaks offline reproducibility)")
|
||||
ap.add_argument("--mode", default="3Dsimple", choices=("3Dsimple", "3Drealistic", "2D"),
|
||||
help="Blosm import mode; 3Drealistic needs the paid material asset pack")
|
||||
ap.add_argument("--assets-dir", default=None,
|
||||
help="directory with building_materials.blend/vegetation.blend, only needed for 3Drealistic")
|
||||
ap.add_argument("--data-dir", default="Art/blender/.cache/blosm",
|
||||
help="where Blosm may cache downloads")
|
||||
return ap.parse_args(argv)
|
||||
|
||||
|
||||
def enable_blosm():
|
||||
present = [m.__name__ for m in addon_utils.modules() if m.__name__ == "blosm"]
|
||||
if not present:
|
||||
raise SystemExit(
|
||||
"Blosm is not on Blender's script path. Set BLENDER_USER_SCRIPTS to the "
|
||||
"scripts folder that contains blosm/, e.g. "
|
||||
"$HOME/.var/app/org.blender.Blender/config/blender/5.2/scripts"
|
||||
)
|
||||
addon_utils.enable("blosm", default_set=True, persistent=False)
|
||||
if not hasattr(bpy.context.scene, "blosm"):
|
||||
raise SystemExit("Blosm enabled but scene.blosm is missing; add-on registration failed")
|
||||
|
||||
|
||||
def configure_preferences(args):
|
||||
"""Blosm refuses to import until an asset directory is set.
|
||||
|
||||
The check only requires the directory to exist; the paid asset pack
|
||||
(building_materials.blend, vegetation.blend) supplies richer materials, and
|
||||
without it the blockout still gets geometry and placeholder materials.
|
||||
"""
|
||||
import blosm
|
||||
|
||||
prefs = bpy.context.preferences.addons["blosm"].preferences
|
||||
assets = args.assets_dir or os.path.join(os.path.dirname(blosm.__file__), "assets")
|
||||
if os.path.isdir(assets):
|
||||
prefs.assetsDir = assets
|
||||
elif args.mode == "3Drealistic":
|
||||
raise SystemExit(f"3Drealistic needs an asset directory; {assets} does not exist")
|
||||
data_dir = os.path.abspath(args.data_dir)
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
prefs.dataDir = data_dir
|
||||
# In background mode the GUI never populates the default level table, and the
|
||||
# add-on then takes a code path that imports a module relative to the wrong
|
||||
# package ("from gui import addDefaultLevels") and dies. One entry avoids it.
|
||||
scene = bpy.context.scene
|
||||
levels = scene.blosm.defaultLevels
|
||||
if len(levels) == 0:
|
||||
item = levels.add()
|
||||
item.levels = 4
|
||||
item.weight = 1
|
||||
print("seeded one default level entry (headless guard)")
|
||||
print(f"blosm assets: {assets}")
|
||||
print(f"blosm data: {data_dir}")
|
||||
|
||||
|
||||
def import_osm(args):
|
||||
south, west, north, east = (float(v) for v in args.bounds.split(","))
|
||||
scene = bpy.context.scene
|
||||
osmp = scene.blosm
|
||||
|
||||
if args.origin:
|
||||
lat, lon = (float(v) for v in args.origin.split(","))
|
||||
scene["lat"] = lat
|
||||
scene["lon"] = lon
|
||||
osmp.relativeToInitialImport = True
|
||||
print(f"origin set to {lat}, {lon} (project origin)")
|
||||
|
||||
osmp.commandLineMode = True
|
||||
osmp.mode = args.mode
|
||||
assert osmp.mode == args.mode
|
||||
osmp.dataType = "osm"
|
||||
osmp.osmSource = "file"
|
||||
osmp.osmFilepath = os.path.abspath(args.osm)
|
||||
osmp.coordinatesAsFilter = True
|
||||
osmp.minLat = south
|
||||
osmp.maxLat = north
|
||||
osmp.minLon = west
|
||||
osmp.maxLon = east
|
||||
|
||||
# what to bring in: buildings and the street network are what 0.1.0 needs
|
||||
osmp.buildings = True
|
||||
osmp.highways = True
|
||||
osmp.water = False
|
||||
osmp.forests = False
|
||||
osmp.vegetation = False
|
||||
osmp.railways = False
|
||||
osmp.singleObject = False
|
||||
osmp.levelHeight = args.level_height
|
||||
# default: stay offline and reproducible from the committed extract alone
|
||||
osmp.loadMissingMembers = bool(args.load_missing)
|
||||
osmp.subdivide = not args.no_subdivide
|
||||
osmp.subdivisionSize = 1.0
|
||||
|
||||
print(f"importing {args.osm} bounds {args.bounds}")
|
||||
bpy.ops.blosm.import_data()
|
||||
print("import finished")
|
||||
|
||||
|
||||
def proj_batch(points, proj_bin):
|
||||
"""WGS84 (lat, lon) -> EPSG:26911 (E, N) in one PROJ call."""
|
||||
import subprocess
|
||||
|
||||
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", "%.5f"],
|
||||
input=payload, capture_output=True, text=True, env=env, check=True,
|
||||
).stdout.strip().splitlines()
|
||||
return [tuple(float(v) for v in line.split()) for line in out]
|
||||
|
||||
|
||||
def find_proj():
|
||||
import glob
|
||||
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
|
||||
pattern = os.path.expanduser(
|
||||
"~/.local/share/flatpak/app/org.qgis.qgis/x86_64/stable/*/files/bin/proj"
|
||||
)
|
||||
matches = sorted(glob.glob(pattern))
|
||||
if not matches:
|
||||
raise SystemExit("no PROJ binary; set PROJ_BIN")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def correct_frame(args):
|
||||
"""Map Blosm's spherical Transverse Mercator onto true EPSG:26911.
|
||||
|
||||
Blosm projects with a sphere of radius 6378137 about the origin, which is a
|
||||
conformal but ~0.3 % oversized frame relative to ellipsoidal UTM. The
|
||||
difference between the two over a 500 m block is an affine map to well under
|
||||
a centimetre, so fit a similarity transform on four spread control points and
|
||||
apply it to the imported objects. After this, the blockout uses the same
|
||||
metric frame as the DEM and the origin documented in Geo/manifests.
|
||||
"""
|
||||
import math
|
||||
|
||||
import bpy
|
||||
from mathutils import Matrix
|
||||
from util.transverse_mercator import TransverseMercator
|
||||
|
||||
lat, lon = (float(v) for v in args.origin.split(","))
|
||||
tm = TransverseMercator(lat=lat, lon=lon)
|
||||
|
||||
south, west, north, east = (float(v) for v in args.bounds.split(","))
|
||||
corners = [(south, west), (south, east), (north, west), (north, east),
|
||||
((south + north) / 2, (west + east) / 2)]
|
||||
blosm_xy = [tm.fromGeographic(la, lo)[:2] for la, lo in corners]
|
||||
utm_xy = proj_batch(corners, find_proj())
|
||||
|
||||
# full affine fit (6 parameters): absorbs the small shear the spherical
|
||||
# projection introduces across the block, which a similarity leaves as ~30 cm
|
||||
n = len(blosm_xy)
|
||||
bxy = [p for p in blosm_xy]
|
||||
uxy = [q for q in utm_xy]
|
||||
# solve [x, y, 1] * [a, b, c]^T = u for both components, least squares
|
||||
def solve(target):
|
||||
ata = [[0.0] * 3 for _ in range(3)]
|
||||
atb = [0.0] * 3
|
||||
for (x, y), t in zip(bxy, target):
|
||||
row = (x, y, 1.0)
|
||||
for i in range(3):
|
||||
for j in range(3):
|
||||
ata[i][j] += row[i] * row[j]
|
||||
atb[i] += row[i] * t
|
||||
# Gaussian elimination
|
||||
m = [ata[i][:] + [atb[i]] for i in range(3)]
|
||||
for i in range(3):
|
||||
piv = max(range(i, 3), key=lambda r: abs(m[r][i]))
|
||||
m[i], m[piv] = m[piv], m[i]
|
||||
for r in range(3):
|
||||
if r != i:
|
||||
f = m[r][i] / m[i][i]
|
||||
for c in range(i, 4):
|
||||
m[r][c] -= f * m[i][c]
|
||||
return [m[i][3] / m[i][i] for i in range(3)]
|
||||
|
||||
a, b, tx = solve([q[0] for q in uxy])
|
||||
d, e, ty = solve([q[1] for q in uxy])
|
||||
|
||||
residuals = [
|
||||
math.hypot(a * p[0] + b * p[1] + tx - q[0], d * p[0] + e * p[1] + ty - q[1])
|
||||
for p, q in zip(blosm_xy, utm_xy)
|
||||
]
|
||||
print(f"frame correction affine: X = {a:.6f}*x {b:+.6f}*y {tx:+.2f}, "
|
||||
f"Y = {d:+.6f}*x {e:.6f}*y {ty:+.2f}")
|
||||
print(f"frame correction residual: max {max(residuals)*100:.1f} cm")
|
||||
|
||||
# local origin for the UTM frame: the project origin
|
||||
origin_lat_lon = (lat, lon)
|
||||
origin_xy = proj_batch([origin_lat_lon], find_proj())[0]
|
||||
|
||||
m = Matrix(((a, b, 0.0, tx - origin_xy[0]), (d, e, 0.0, ty - origin_xy[1]),
|
||||
(0.0, 0.0, 1.0, 0.0), (0.0, 0.0, 0.0, 1.0)))
|
||||
# only transform roots: children inherit their parent's transform, and
|
||||
# transforming both would apply the correction twice
|
||||
roots = [o for o in bpy.data.objects if o.parent is None]
|
||||
for obj in roots:
|
||||
obj.matrix_world = m @ obj.matrix_world
|
||||
print(f"frame correction applied to {len(roots)} root objects "
|
||||
f"({len(bpy.data.objects) - len(roots)} children inherit)")
|
||||
|
||||
|
||||
def report():
|
||||
"""Per-object measurements in metres, so a scale bug is obvious in the log."""
|
||||
collections = {c.name: c for c in bpy.data.collections}
|
||||
meshes = [o for o in bpy.data.objects if o.type == "MESH"]
|
||||
print(f"objects: {len(bpy.data.objects)} total, {len(meshes)} mesh, "
|
||||
f"collections: {sorted(collections)}")
|
||||
if not meshes:
|
||||
print("WARNING: no mesh objects were created")
|
||||
return
|
||||
|
||||
def dimension(obj):
|
||||
return max(obj.dimensions)
|
||||
|
||||
meshes.sort(key=dimension, reverse=True)
|
||||
print("largest meshes (name, dimensions in metres):")
|
||||
for obj in meshes[:12]:
|
||||
dx, dy, dz = obj.dimensions
|
||||
print(f" {obj.name[:40]:40} {dx:7.2f} x {dy:7.2f} x {dz:7.2f}")
|
||||
|
||||
widths = [dimension(o) for o in meshes]
|
||||
print(f"mesh extent: max {max(widths):.2f} m, min {min(widths):.3f} m, "
|
||||
f"median-ish {sorted(widths)[len(widths)//2]:.2f} m")
|
||||
if max(widths) > 1000:
|
||||
print("WARNING: something is over 1 km across; check units and bounds")
|
||||
if max(widths) < 5:
|
||||
print("WARNING: nothing larger than 5 m; the filter probably matched nothing")
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
enable_blosm()
|
||||
configure_preferences(args)
|
||||
import_osm(args)
|
||||
if args.origin:
|
||||
correct_frame(args)
|
||||
report()
|
||||
|
||||
out = os.path.abspath(args.out)
|
||||
os.makedirs(os.path.dirname(out), exist_ok=True)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=out)
|
||||
print(f"saved {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user