"""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("--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 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 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) 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()