Tools/blosm_blockout.py imports the committed extract in 3Dsimple mode and reports per-building dimensions so a units failure is visible in the log. The result (87 meshes) is committed as the blockout baseline: hero 25.68 x 54.88 x 14.50 m against 25.75 x 54.88 m of OSM geometry, so the import is 1:1 metres. Two headless defects needed working around: the asset-directory check that fires before mode is honoured, and an empty default-level table sending the add-on down a broken relative import. Member fetching stays off so the import does not depend on Overpass.
180 lines
6.9 KiB
Python
180 lines
6.9 KiB
Python
"""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()
|