"""Build a lightweight 3D scene from an OSM export and optional osm2streets GeoJSON. Run from Blender 4.x: blender --background --factory-startup --python blender/generate_scene.py -- \ --osm "/path/to/input.osm" \ --output "/path/to/output.blend" \ --render "/path/to/preview.png" The OSM bounds element is used deliberately. OSM exports may contain distant relation members outside the requested area, so using every node for extent would produce a misleadingly large model. Optional osm2streets GeoJSON directory provides detailed road surfaces, sidewalks, lane markings, and crosswalks. When omitted, roads fall back to simple OSM highway polylines. Vegetation: natural=tree nodes become individual trees, natural=tree_row ways become evenly spaced rows, landuse=grass becomes green ground, natural=scrub becomes low shrub volumes. amenity=fountain becomes low-poly fountain basins. """ import json import math import os import sys import bpy from mathutils import Vector # --factory-startup does not put the script's own directory on sys.path, so the # osmassets package next to this file is not importable without this. _HERE = os.path.dirname(os.path.abspath(__file__)) if _HERE not in sys.path: sys.path.insert(0, _HERE) from osmassets import catalog # noqa: E402 from osmassets import features as _features # noqa: E402 from osmassets.geom import ( # noqa: E402 (needs the sys.path line above) distance_to_ring, point_in_polygon, polygon_area, sample_polygon_interior, sample_ring_boundary, sample_tree_row, ) from osmassets.materials import ( # noqa: E402 from_spec as material_from_spec, tint_base_color, ) from osmassets.mesh import ( # noqa: E402 MeshBatch, new_collection, ) from osmassets.osm import Projector, parse_height, parse_osm # noqa: E402 from osmassets import building as _building # noqa: E402 from osmassets import fountain as _fountain # noqa: E402 from osmassets import water as _water # noqa: E402 from osmassets import grass as _grass # noqa: E402 from osmassets import roads as _roads # noqa: E402 from osmassets import scrub as _scrub # noqa: E402 from osmassets import tree as _tree # noqa: E402 from osmassets import traffic_signals as _traffic_signals # noqa: E402 CUSTOM_MODEL_ROOT = os.path.abspath(os.path.join( os.path.dirname(__file__), "..", "assets", "models", "custom" )) # Shapespark grass variants replace the former high-detail lawn tuft asset. # They are alpha-cut cards at about 10 tris each, so the shared geometry is # tiny and each lawn instance links one of these datablocks. TUFT_MODELS = ( os.path.join(CUSTOM_MODEL_ROOT, "shapespark_plants", "grass-01", "model.gltf"), os.path.join(CUSTOM_MODEL_ROOT, "shapespark_plants", "grass-02", "model.gltf"), os.path.join(CUSTOM_MODEL_ROOT, "shapespark_plants", "grass-03", "model.gltf"), ) TUFT_TARGET_TRIS = 0 # The variants stand 1.2-1.34m tall natively. Roughly a third of that lands in # the 0.3-0.6m range real lawn tufts occupy. TUFT_SCALE_RANGE = (0.26, 0.46) TUFT_SPACING = 1.7 TUFT_LIMIT_PER_LAWN = 100 # The Shapespark diffuse is a grey-green leaf over brown stems. Dropped onto the # saturated lawn as-is it reads as dead weeds, so the base colour is mixed # toward a cooler lawn tint without making the cards glow. TUFT_TINT = (0.08, 0.30, 0.055) TUFT_TINT_FACTOR = 0.22 SCRUB_BUSH_MODEL = os.path.join( CUSTOM_MODEL_ROOT, "shapespark_plants", "bush-03", "model.gltf") SCRUB_BUSH_SPACING = 1.8 SCRUB_BUSH_INSET = 0.38 SCRUB_BUSH_LIMIT_PER_PATCH = 60 SCRUB_BUSH_HEIGHT = 1.575 SCRUB_BUSH_SCALE_JITTER = 0.16 SCRUB_BUSH_LEAF_TINT = (0.055, 0.23, 0.045) SCRUB_BUSH_LEAF_TINT_FACTOR = 0.18 SCRUB_BUSH_TRUNK_TINT = (0.13, 0.095, 0.055) SCRUB_BUSH_TRUNK_TINT_FACTOR = 0.18 SCRUB_TREE_MIN_AREA = 45.0 SCRUB_TREE_SPACING = 8.0 SCRUB_TREE_EDGE_CLEARANCE = 2.8 SCRUB_TREE_LIMIT_PER_PATCH = 5 SCRUB_TREE_HEIGHT_RANGE = (4.6, 6.2) # Tree styles. The two built from mesh batches live in this file; Shapespark # model trees are handled by osmassets.tree. A model style whose asset is # missing falls back to "natural" rather than planting nothing. TREE_STYLES = frozenset(("natural", "procedural")) | frozenset(_tree.MODEL_STYLES) def cli_args(): values = {"osm": None, "geojson": None, "native_road": None, "output": None, "render": None, "office_overrides": "", "tree_style": "natural"} argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] i = 0 while i < len(argv): if argv[i].startswith("--") and i + 1 < len(argv): values[argv[i][2:].replace("-", "_")] = argv[i + 1] i += 2 else: i += 1 if not values.get("osm"): raise RuntimeError("--osm is required; --geojson is optional") if not values.get("output"): raise RuntimeError("--output is required") if not values.get("render"): raise RuntimeError("--render is required") if values.get("office_overrides"): try: values["office_overrides"] = set( w.strip() for w in values["office_overrides"].split(",") if w.strip() ) except Exception: values["office_overrides"] = set() else: values["office_overrides"] = set() if values.get("tree_style") not in TREE_STYLES: raise RuntimeError("--tree-style must be one of: " + ", ".join(sorted(TREE_STYLES))) return values def add_tree_batch(positions, collection, trunk_material, leaf_material): trunk = MeshBatch("Tree_Trunks", collection, trunk_material) leaves = MeshBatch("Tree_Crowns", collection, leaf_material) sides = 10 def add_blob(batch, cx, cy, cz, rx, ry, rz, phase): rings = 5 start = len(batch.vertices) for ring in range(rings): latitude = -math.pi / 2 + math.pi * ring / (rings - 1) ring_radius = math.cos(latitude) for side in range(sides): angle = math.tau * side / sides variation = 1.0 + 0.09 * math.sin(phase + side * 1.73 + ring * 0.91) batch.vertices.append((cx + rx * ring_radius * math.cos(angle) * variation, cy + ry * ring_radius * math.sin(angle) * variation, cz + rz * math.sin(latitude))) for ring in range(rings - 1): for side in range(sides): next_side = (side + 1) % sides batch.faces.append((start + ring * sides + side, start + ring * sides + next_side, start + (ring + 1) * sides + next_side, start + (ring + 1) * sides + side)) for index, (x, y, height) in enumerate(positions): base = len(trunk.vertices) radius = max(0.12, height * 0.035) trunk_top = height * 0.62 for z, ring_radius in ((0.0, radius), (trunk_top, radius * 0.68)): for i in range(sides): a = math.tau * i / sides trunk.vertices.append((x + ring_radius * math.cos(a), y + ring_radius * math.sin(a), z)) trunk.faces.append(tuple(base + i for i in range(sides - 1, -1, -1))) for i in range(sides): j = (i + 1) % sides trunk.faces.append((base + i, base + j, base + sides + j, base + sides + i)) trunk.faces.append(tuple(base + sides + i for i in range(sides))) crown_r = max(0.85, height * 0.30) crown_z = height * 0.82 add_blob(leaves, x, y, crown_z, crown_r * 0.70, crown_r * 0.62, crown_r * 0.72, index * 1.41) add_blob(leaves, x - crown_r * 0.42, y + crown_r * 0.08, crown_z * 0.98, crown_r * 0.52, crown_r * 0.48, crown_r * 0.58, index * 2.17 + 0.7) add_blob(leaves, x + crown_r * 0.40, y - crown_r * 0.05, crown_z * 1.02, crown_r * 0.50, crown_r * 0.46, crown_r * 0.55, index * 2.63 + 1.3) trunk.finish() leaves.finish() def add_natural_tree_instances(positions, collection, trunk_material, leaf_dark_material, leaf_light_material): trunk = MeshBatch("Tree_Natural_Trunks", collection, trunk_material) lower = MeshBatch("Tree_Natural_Crowns_Dark", collection, leaf_dark_material) upper = MeshBatch("Tree_Natural_Crowns_Light", collection, leaf_light_material) trunk_sides = 9 crown_sides = 9 crown_rings = 5 def add_blob(batch, cx, cy, cz, rx, ry, rz, phase, squash=1.0): start = len(batch.vertices) for ring in range(crown_rings): latitude = -math.pi / 2 + math.pi * ring / (crown_rings - 1) ring_radius = math.cos(latitude) for side in range(crown_sides): angle = math.tau * side / crown_sides wobble = ( 1.0 + 0.14 * math.sin(phase + side * 1.31 + ring * 0.83) + 0.07 * math.sin(phase * 0.7 + side * 2.11) ) batch.vertices.append(( cx + rx * ring_radius * math.cos(angle) * wobble, cy + ry * ring_radius * math.sin(angle) * wobble, cz + rz * math.sin(latitude) * squash, )) for ring in range(crown_rings - 1): for side in range(crown_sides): next_side = (side + 1) % crown_sides batch.faces.append(( start + ring * crown_sides + side, start + ring * crown_sides + next_side, start + (ring + 1) * crown_sides + next_side, start + (ring + 1) * crown_sides + side, )) for index, (x, y, height) in enumerate(positions): target_height = max(4.8, min(8.8, height * 1.08)) phase = index * 1.61803398875 trunk_height = target_height * (0.48 + 0.05 * math.sin(phase)) trunk_radius = max(0.13, target_height * 0.038) lean_x = math.sin(phase * 1.7) * target_height * 0.025 lean_y = math.cos(phase * 1.3) * target_height * 0.025 base = len(trunk.vertices) trunk_levels = [ (0.0, trunk_radius), (trunk_height * 0.55, trunk_radius * 0.78), (trunk_height, trunk_radius * 0.48), ] for level_index, (z, radius) in enumerate(trunk_levels): offset_x = lean_x * level_index / (len(trunk_levels) - 1) offset_y = lean_y * level_index / (len(trunk_levels) - 1) for side in range(trunk_sides): angle = math.tau * side / trunk_sides trunk.vertices.append(( x + offset_x + radius * math.cos(angle), y + offset_y + radius * math.sin(angle), z, )) trunk.faces.append(tuple(base + i for i in range(trunk_sides - 1, -1, -1))) for level_index in range(len(trunk_levels) - 1): row = base + level_index * trunk_sides next_row = row + trunk_sides for side in range(trunk_sides): next_side = (side + 1) % trunk_sides trunk.faces.append((row + side, row + next_side, next_row + next_side, next_row + side)) top_row = base + (len(trunk_levels) - 1) * trunk_sides trunk.faces.append(tuple(top_row + i for i in range(trunk_sides))) crown_x = x + lean_x crown_y = y + lean_y crown_z = trunk_height + target_height * 0.22 crown_r = target_height * (0.35 + 0.035 * math.sin(phase * 0.9)) # Dark lower mass gives the canopy volume when viewed obliquely. add_blob(lower, crown_x, crown_y, crown_z - crown_r * 0.08, crown_r * 0.95, crown_r * 0.78, crown_r * 0.52, phase, squash=0.82) add_blob(lower, crown_x - crown_r * 0.46, crown_y + crown_r * 0.05, crown_z - crown_r * 0.02, crown_r * 0.62, crown_r * 0.50, crown_r * 0.42, phase + 0.8, squash=0.80) add_blob(lower, crown_x + crown_r * 0.42, crown_y - crown_r * 0.08, crown_z, crown_r * 0.58, crown_r * 0.48, crown_r * 0.40, phase + 1.9, squash=0.80) # Lighter upper clumps break the silhouette without adding heavy geometry. add_blob(upper, crown_x + crown_r * 0.05, crown_y + crown_r * 0.04, crown_z + crown_r * 0.34, crown_r * 0.70, crown_r * 0.58, crown_r * 0.38, phase + 2.7, squash=0.74) add_blob(upper, crown_x - crown_r * 0.24, crown_y - crown_r * 0.22, crown_z + crown_r * 0.23, crown_r * 0.46, crown_r * 0.40, crown_r * 0.30, phase + 3.5, squash=0.72) trunk.finish() lower.finish() upper.finish() def _numbered_base_name(name): if len(name) > 4 and name[-4] == "." and name[-3:].isdigit(): return name[:-4] return name def _dedupe_imported_material(material, tinted, tint_factor): name = _numbered_base_name(material.name) existing = bpy.data.materials.get(name) if existing and existing is not material: return existing material.name = name if getattr(material, "blend_method", "OPAQUE") == "BLEND": material.blend_method = "HASHED" material.alpha_threshold = 0.45 material.show_transparent_back = False if material.name not in tinted: tint_base_color(material, TUFT_TINT, tint_factor) tinted.add(material.name) return material def load_tuft_variants(): """Import Shapespark grass variants and return shared tuft meshes. Returns [] when the asset is missing so a clean checkout still builds; the lawns then fall back to plain textured ground. """ if not all(os.path.exists(path) for path in TUFT_MODELS): return [] before_materials = set(bpy.data.materials) before_images = set(bpy.data.images) variants = [] tinted = set() for model_path in TUFT_MODELS: before = set(bpy.data.objects) try: bpy.ops.import_scene.gltf(filepath=model_path) except (RuntimeError, AttributeError) as error: print("Grass tuft import failed, lawns stay flat:", error) return [] imported = [obj for obj in set(bpy.data.objects) - before if obj.type == "MESH"] for obj in sorted(imported, key=lambda item: item.name): obj.data.calc_loop_triangles() source_tris = len(obj.data.loop_triangles) if TUFT_TARGET_TRIS and source_tris > TUFT_TARGET_TRIS: modifier = obj.modifiers.new("TuftDecimate", "DECIMATE") modifier.ratio = max(0.02, TUFT_TARGET_TRIS / source_tris) bpy.context.view_layer.update() evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) mesh = bpy.data.meshes.new_from_object(evaluated) else: mesh = _bake_imported_mesh(obj, "GrassTuft_" + obj.name) mesh.name = "GrassTuft_" + _numbered_base_name(obj.name) mesh.calc_loop_triangles() # Nothing is saved yet at this point, so keep the datablock alive # even if a scene ends up with no lawn polygons to instance it into. mesh.use_fake_user = True for slot, material in enumerate(mesh.materials): if material is not None: mesh.materials[slot] = _dedupe_imported_material( material, tinted, TUFT_TINT_FACTOR) variants.append(mesh) for obj in imported: mesh = obj.data if obj.type == "MESH" else None bpy.data.objects.remove(obj, do_unlink=True) if mesh is not None and mesh.users == 0: bpy.data.meshes.remove(mesh) for material in set(bpy.data.materials) - before_materials: if material.users == 0: bpy.data.materials.remove(material) for image in set(bpy.data.images) - before_images: if image.users == 0: bpy.data.images.remove(image) tris = sum(len(mesh.loop_triangles) for mesh in variants) detail = f"decimated to ~{TUFT_TARGET_TRIS} tris each" if TUFT_TARGET_TRIS else "full detail" print(f"Grass tuft variants loaded: {len(variants)} ({detail}, {tris} tris shared)") return variants def tuft_density_wave(x, y): # Low-amplitude deterministic waves leave occasional open patches while # keeping most of the lawn planted, so the tufts read as uneven grass # rather than a regular grid. return (math.sin(x * 0.21 + y * 0.17) + 0.55 * math.sin(x * 0.44 - y * 0.29 + 1.3)) def add_grass_tufts(name, ring, variants, collection): """Scatter the plant variants across a lawn polygon as grass tufts.""" xmin = min(x for x, _ in ring) xmax = max(x for x, _ in ring) ymin = min(y for _, y in ring) ymax = max(y for _, y in ring) def sample(spacing): spots = [] cols = max(1, int(math.ceil((xmax - xmin) / spacing))) rows = max(1, int(math.ceil((ymax - ymin) / spacing))) for i in range(cols): for j in range(rows): # Jitter breaks up the lattice; without it the tufts read as a # planted grid rather than as grass. seed = (i * 73856093) ^ (j * 19349663) jx = ((seed * 0.61803398875) % 1.0 - 0.5) * spacing * 0.8 jy = ((seed * 0.41421356237) % 1.0 - 0.5) * spacing * 0.8 x = xmin + (i + 0.5) * spacing + jx y = ymin + (j + 0.5) * spacing + jy if not point_in_polygon((x, y), ring): continue # Keep tufts off the kerb line so they do not overhang paving. if distance_to_ring((x, y), ring) < 0.45: continue if tuft_density_wave(x, y) < -0.85: continue spots.append((x, y, seed)) return spots # Adapt spacing to the lawn so a large polygon does not explode the export. spacing = TUFT_SPACING spots = sample(spacing) while len(spots) > TUFT_LIMIT_PER_LAWN and spacing < 12.0: spacing *= 1.22 spots = sample(spacing) min_scale, max_scale = TUFT_SCALE_RANGE span = max_scale - min_scale for index, (x, y, seed) in enumerate(spots): mesh = variants[index % len(variants)] obj = bpy.data.objects.new(f"{name}_Tuft_{index:03d}", mesh) scale = min_scale + span * ((seed * 0.754877666) % 1.0) # Sunk slightly below the lawn surface so the stems never float. obj.location = (x, y, 0.008) obj.rotation_euler = ( math.radians(-4.0 + 8.0 * ((seed * 0.5698403) % 1.0)), math.radians(-4.0 + 8.0 * ((seed * 0.3317554) % 1.0)), math.tau * ((seed * 0.61803398875) % 1.0), ) obj.scale = (scale, scale * (0.90 + 0.20 * ((seed * 0.2236068) % 1.0)), scale) collection.objects.link(obj) return len(spots) def _bake_imported_mesh(obj, name): mesh = obj.data.copy() mesh.transform(obj.matrix_world.to_3x3().to_4x4()) mesh.name = name mesh.use_fake_user = True return mesh def _measure_meshes(meshes): zs = [vertex.co.z for mesh in meshes for vertex in mesh.vertices] if not zs: return 1.0, 0.0 low, high = min(zs), max(zs) return max(high - low, 1e-6), low def _discard_imported_mesh_objects(objects): for obj in objects: mesh = obj.data if obj.type == "MESH" else None bpy.data.objects.remove(obj, do_unlink=True) if mesh is not None and mesh.users == 0: bpy.data.meshes.remove(mesh) def load_scrub_bush_variant(): """Import the optional custom bush once for edge instancing.""" if not os.path.exists(SCRUB_BUSH_MODEL): return None before = set(bpy.data.objects) try: bpy.ops.import_scene.gltf(filepath=SCRUB_BUSH_MODEL) except (RuntimeError, AttributeError) as error: print("Scrub bush import failed, scrub stays flat:", error) return None imported = [obj for obj in set(bpy.data.objects) - before if obj.type == "MESH"] if not imported: print("Scrub bush import produced no mesh objects, scrub stays flat") return None meshes = [] for index, obj in enumerate(sorted(imported, key=lambda item: item.name)): mesh = _bake_imported_mesh(obj, f"ScrubBush_{index:02d}_{obj.name}") mesh.calc_loop_triangles() for material in mesh.materials: if material is None: continue material_name = material.name.lower() if ("leaf" in material_name or "branch" in material_name or "shrubbery" in material_name or "hedge" in material_name): tint_base_color( material, SCRUB_BUSH_LEAF_TINT, SCRUB_BUSH_LEAF_TINT_FACTOR) elif "trunk" in material_name or "twig" in material_name: tint_base_color( material, SCRUB_BUSH_TRUNK_TINT, SCRUB_BUSH_TRUNK_TINT_FACTOR) if getattr(material, "blend_method", "OPAQUE") == "BLEND": material.blend_method = "HASHED" material.alpha_threshold = 0.45 material.show_transparent_back = False meshes.append(mesh) height, base_z = _measure_meshes(meshes) _discard_imported_mesh_objects(imported) tris = sum(len(mesh.loop_triangles) for mesh in meshes) print(f"Scrub bush loaded: {len(meshes)} mesh(es), {tris} tris shared") return {"meshes": meshes, "height": height, "base_z": base_z} def add_scrub_edge_bushes(name, ring, variant, collection): if not variant: return 0 samples = sample_ring_boundary( ring, SCRUB_BUSH_SPACING, inset=SCRUB_BUSH_INSET, max_samples=SCRUB_BUSH_LIMIT_PER_PATCH) if not samples: return 0 base_scale = SCRUB_BUSH_HEIGHT / variant["height"] for index, (x, y, angle, seed) in enumerate(samples): jitter = 1.0 + SCRUB_BUSH_SCALE_JITTER * ( 2.0 * ((seed * 0.61803398875) % 1.0) - 1.0) scale = base_scale * jitter z = 0.065 - variant["base_z"] * scale rotation = angle + math.tau * 0.08 * ((seed * 0.41421356237) % 1.0 - 0.5) for mesh_index, mesh in enumerate(variant["meshes"]): obj = bpy.data.objects.new( f"{name}_Bush_{index:03d}_{mesh_index:02d}", mesh) obj.location = (x, y, z) obj.rotation_euler = (0.0, 0.0, rotation) obj.scale = (scale, scale, scale) collection.objects.link(obj) return len(samples) def add_scrub_patch(name, ring, ground_material, collection, bush_variant=None): if len(ring) < 3: return None, 0 if ring[0] == ring[-1]: ring = ring[:-1] if len(ring) < 3: return None, 0 ground = MeshBatch(name, collection, ground_material) ground.add_polygon(ring, 0.055) obj = ground.finish() bush_count = add_scrub_edge_bushes(name, ring, bush_variant, collection) if obj: obj["scrub_texture"] = "Poly Haven leafy_grass, scrub-tinted" obj["scrub_style"] = ( "edge bush instances" if bush_count else "flat foliage ground cover" ) obj["scrub_edge_bushes"] = bush_count return obj, bush_count def sample_scrub_interior_trees(ring, way_id): if polygon_area(ring) < SCRUB_TREE_MIN_AREA: return [] try: seed = int(way_id) except (TypeError, ValueError): seed = sum(ord(char) for char in str(way_id)) samples = sample_polygon_interior( ring, SCRUB_TREE_SPACING, edge_clearance=SCRUB_TREE_EDGE_CLEARANCE, max_samples=SCRUB_TREE_LIMIT_PER_PATCH, seed=seed) min_height, max_height = SCRUB_TREE_HEIGHT_RANGE span = max_height - min_height trees = [] for x, y, sample_seed in samples: height = min_height + span * ((sample_seed * 0.754877666) % 1.0) trees.append((x, y, height)) return trees def look_at(obj, target): obj.rotation_euler = (Vector(target) - obj.location).to_track_quat("-Z", "Y").to_euler() def clear_scene(): bpy.ops.object.select_all(action="SELECT") bpy.ops.object.delete(use_global=False) for collection in list(bpy.data.collections): if collection.name != "Collection" and collection.users == 0: bpy.data.collections.remove(collection) def configure_scene(): scene = bpy.context.scene engines = scene.render.bl_rna.properties["engine"].enum_items.keys() scene.render.engine = "BLENDER_EEVEE_NEXT" if "BLENDER_EEVEE_NEXT" in engines else "BLENDER_EEVEE" scene.render.resolution_x = 1200 scene.render.resolution_y = 900 scene.render.resolution_percentage = 100 scene.render.image_settings.file_format = "PNG" scene.render.film_transparent = False scene.world.color = (0.055, 0.075, 0.095) scene.view_settings.look = "AgX - Medium High Contrast" def configure_default_viewport(): workspace = bpy.data.workspaces.get("Layout") if workspace: try: bpy.context.window.workspace = workspace except (AttributeError, RuntimeError): pass for screen in bpy.data.screens: for area in screen.areas: if area.type != "VIEW_3D": continue space = area.spaces.active space.shading.type = "MATERIAL" space.shading.light = "STUDIO" space.shading.color_type = "MATERIAL" space.overlay.show_floor = False if space.region_3d: space.region_3d.view_perspective = "CAMERA" space.region_3d.view_camera_zoom = 0.0 def build(args): bounds, ways, point_features = parse_osm(args["osm"]) projector = Projector(bounds) clear_scene() configure_scene() ground_c = new_collection("00_Ground") water_c = new_collection("01_Water") green_c = new_collection("02_Green") roads_c = new_collection("03_Roads") buildings_c = new_collection("04_Buildings") props_c = new_collection("05_Props") traffic_dynamic_c = new_collection("06_TrafficSignalsDynamic") ground_mat = material_from_spec(catalog.MATERIALS["ground"]) water_mat = material_from_spec(catalog.MATERIALS["water"]) grass_mat = material_from_spec(catalog.MATERIALS["grass"]) scrub_mat = material_from_spec(catalog.MATERIALS["scrub"]) # Ordering note: the tuft import creates its own materials, so it stays # between the ground materials and the props. Material creation order fixes # the material indices in the exported GLB. tuft_variants = load_tuft_variants() scrub_bush_variant = load_scrub_bush_variant() fountain_mats = { key: material_from_spec(catalog.MATERIALS[key]) for key in ("fountain_stone", "fountain_water", "fountain_spray") } building_mats = { key: material_from_spec(catalog.MATERIALS["building_" + key]) for key in ("default", "industrial", "office_roof", "industrial_roof", "glass", "factory_glass") } road_mats = { layer["id"]: material_from_spec(spec) for layer, spec in zip(catalog.ROAD_LAYERS, catalog.road_material_specs()) } road_mats["native_center_line_white"] = material_from_spec( catalog.MATERIALS["native_center_line_white"]) traffic_signal_mats = { "metal": material_from_spec(catalog.MATERIALS["traffic_signal_metal"]), "housing": material_from_spec(catalog.MATERIALS["traffic_signal_housing"]), "lenses": { "red": material_from_spec(catalog.MATERIALS["traffic_signal_red"]), "yellow": material_from_spec(catalog.MATERIALS["traffic_signal_yellow"]), "green": material_from_spec(catalog.MATERIALS["traffic_signal_green"]), }, "active": material_from_spec(catalog.MATERIALS["traffic_signal_active_green"]), "dynamic": { state: material_from_spec(catalog.MATERIALS["traffic_signal_active_" + state]) for state in ("red", "yellow", "green") }, } traffic_signal_mats["dynamic"]["countdown"] = {} for phase_group in (0, 1): material = traffic_signal_mats["dynamic"]["green"].copy() material.name = "Traffic Signal Countdown Group %d" % phase_group traffic_signal_mats["dynamic"]["countdown"][phase_group] = material b = bounds scene_xmin, scene_ymin = projector.xy((b["min_lon"], b["min_lat"])) scene_xmax, scene_ymax = projector.xy((b["max_lon"], b["max_lat"])) ground_ring = [projector.xy((b["min_lon"] - 0.0012, b["min_lat"] - 0.0012)), projector.xy((b["max_lon"] + 0.0012, b["min_lat"] - 0.0012)), projector.xy((b["max_lon"] + 0.0012, b["max_lat"] + 0.0012)), projector.xy((b["min_lon"] - 0.0012, b["max_lat"] + 0.0012))] ground_batch = MeshBatch("Ground Plane", ground_c, ground_mat) ground_batch.add_polygon(ground_ring, -0.35) ground_batch.finish() grass_rings = [] scrub_trees = [] tree_rows = [] # Counters were previously individual ints scattered through the loop body. # Collecting them into a dict lets the scene[...] and SCENE_DONE sections # read from a single place. The keys are kept alphabetically so the # SCENE_DONE JSON order from control-1 stays byte-for-byte identical. counts = { "building_count": 0, "fountain_count": 0, "grass_count": 0, "grass_tuft_count": 0, "industrial_count": 0, "lake_count": 0, "scrub_bush_count": 0, "scrub_count": 0, "scrub_tree_count": 0, "traffic_signal_count": 0, } def add_scrub_patch_with_bushes(name, ring, ground_material, collection): obj, bush_count = add_scrub_patch( name, ring, ground_material, collection, scrub_bush_variant) counts["scrub_bush_count"] += bush_count return obj focus_points = [] def handle_water(way, tag, ring): counts["lake_count"] += _water.assemble(ring, scene_xmin, scene_xmax, scene_ymin, scene_ymax, water_c, water_mat) def handle_grass(way, tag, ring): added, tufts, ring_pts = _grass.assemble( ring, way["id"], scene_xmin, scene_xmax, scene_ymin, scene_ymax, green_c, grass_mat, tuft_variants, add_grass_tufts) counts["grass_count"] += added counts["grass_tuft_count"] += tufts if ring_pts: grass_rings.append(ring_pts) focus_points.extend(ring_pts) def handle_scrub(way, tag, ring): added, ring_pts = _scrub.assemble(ring, way["id"], scene_xmin, scene_xmax, scene_ymin, scene_ymax, green_c, scrub_mat, add_scrub_patch_with_bushes) counts["scrub_count"] += added if ring_pts: focus_points.extend(ring_pts) new_scrub_trees = sample_scrub_interior_trees(ring_pts, way["id"]) scrub_trees.extend(new_scrub_trees) counts["scrub_tree_count"] += len(new_scrub_trees) def handle_tree_row(way, tag, ring): tree_rows.append((ring, tag)) focus_points.extend(ring) def handle_building(way, tag, ring): inner_rings = [projector.ring(coords) for coords in way.get("inner_coords", [])] added, ind_added, ring_pts = _building.assemble( ring, str(way["id"]), tag, args["office_overrides"], buildings_c, building_mats, inner_rings=inner_rings) counts["building_count"] += added counts["industrial_count"] += ind_added if ring_pts: focus_points.extend(ring_pts) way_handlers = ( _features.FeatureHandler( "water", lambda way, tag, ring: tag.get("natural") == "water" or tag.get("water") == "lake", handle_water), _features.FeatureHandler( "grass", lambda way, tag, ring: tag.get("landuse") == "grass", handle_grass), _features.FeatureHandler( "scrub", lambda way, tag, ring: tag.get("natural") == "scrub" and len(ring) >= 3, handle_scrub), _features.FeatureHandler( "tree_row", lambda way, tag, ring: tag.get("natural") == "tree_row", handle_tree_row), _features.FeatureHandler( "building", lambda way, tag, ring: "building" in tag and len(ring) >= 3, handle_building), ) _features.dispatch_ways(ways, projector, way_handlers) geojson_dir = args.get("geojson") native_road_dir = args.get("native_road") road_counts = {} if native_road_dir: if not os.path.isdir(native_road_dir): raise RuntimeError("--native-road directory does not exist: " + native_road_dir) material_layers = {layer["id"]: layer for layer in catalog.ROAD_LAYERS} for source in catalog.NATIVE_ROAD_LAYERS: target = source["material_layer"] layer = material_layers[target] source_path = os.path.join(native_road_dir, "layers", source["source"] + ".geojson") if not os.path.isfile(source_path): raise RuntimeError("Native road layer is missing: " + source_path) if source["source"] == "center_lines": count = _roads.assemble_geojson_layer( source_path, source["source"], projector, roads_c, road_mats[target], layer["z"], lambda props: props.get("color") != "white") count += _roads.assemble_geojson_layer( source_path, source["source"] + "_white", projector, roads_c, road_mats["native_center_line_white"], layer["z"], lambda props: props.get("color") == "white") else: count = _roads.assemble_geojson_layer( source_path, source["source"], projector, roads_c, road_mats[target], layer["z"]) road_counts[source["source"]] = count elif geojson_dir and os.path.isdir(geojson_dir): for problem in catalog.check_layers(geojson_dir): print("Layer catalog warning:", problem) for layer in catalog.ROAD_LAYERS: layer_id = layer["id"] road_counts[layer_id] = _roads.assemble_geojson_layer( os.path.join(geojson_dir, layer_id + ".geojson"), layer_id, projector, roads_c, road_mats[layer_id], layer["z"]) if not native_road_dir and road_counts.get("road_surface", 0) == 0: _roads.assemble_osm_fallback( ways, projector, roads_c, road_mats["road_surface"]) traffic_signal_path = os.path.join(geojson_dir or "", "traffic_signals.json") dynamic_signal_objects = 0 if os.path.exists(traffic_signal_path): try: with open(traffic_signal_path, "r", encoding="utf-8") as handle: signal_data = json.load(handle) counts["traffic_signal_count"] = _traffic_signals.assemble( signal_data, projector, props_c, traffic_signal_mats) dynamic_signal_objects = len(_traffic_signals.assemble_dynamic( signal_data, projector, traffic_dynamic_c, traffic_signal_mats["dynamic"])) except (OSError, ValueError, TypeError) as error: print("Traffic signal warning:", error) if dynamic_signal_objects == 0: raise RuntimeError("Traffic signal dynamic geometry failed") from error trees = [] individual_tree_count = 0 for feature in point_features: if feature["tags"].get("natural") != "tree": continue if not projector.inside(feature["coord"]): continue x, y = projector.xy(feature["coord"]) trees.append((x, y, parse_height(feature["tags"], 5.5))) individual_tree_count += 1 trees.extend(scrub_trees) row_tree_count = 0 for row, row_tags in tree_rows: row_samples = sample_tree_row(row, spacing=5.0, height=parse_height(row_tags, 5.0)) trees.extend(row_samples) row_tree_count += len(row_samples) tree_style = args.get("tree_style") tree_style_used = tree_style if trees: # A model style returns 0 when its vendored asset is missing; that drops # through to "natural" so a clean checkout still gets trees. placed = (_tree.assemble(trees, props_c, tree_style) if tree_style in _tree.MODEL_STYLES else 0) if not placed: tree_style_used = "procedural" if tree_style == "procedural" else "natural" tree_trunk = material_from_spec(catalog.MATERIALS["tree_trunk"]) if tree_style_used == "procedural": add_tree_batch( trees, props_c, tree_trunk, material_from_spec(catalog.MATERIALS["tree_crown"])) else: add_natural_tree_instances( trees, props_c, tree_trunk, material_from_spec(catalog.MATERIALS["tree_crown_dark"]), material_from_spec(catalog.MATERIALS["tree_crown_light"])) for feature in point_features: if feature["tags"].get("amenity") != "fountain": continue if not projector.inside(feature["coord"]): continue fx, fy = projector.xy(feature["coord"]) _fountain.assemble("Fountain_" + str(feature["id"]), fx, fy, props_c, fountain_mats) counts["fountain_count"] += 1 bpy.ops.object.light_add(type="SUN", location=(0, 0, 500)) sun = bpy.context.object sun.name = "Sun" sun.data.energy = 3.0 sun.rotation_euler = (math.radians(28), math.radians(-22), math.radians(-32)) bpy.ops.object.light_add(type="AREA", location=(0, -220, 420)) area = bpy.context.object area.name = "Fill Light" area.data.energy = 1700 area.data.shape = "DISK" area.data.size = 260 look_at(area, (0, 0, 0)) width = (b["max_lon"] - b["min_lon"]) * projector.m_per_lon height = (b["max_lat"] - b["min_lat"]) * projector.m_per_lat if focus_points: min_fx = min(point[0] for point in focus_points) max_fx = max(point[0] for point in focus_points) min_fy = min(point[1] for point in focus_points) max_fy = max(point[1] for point in focus_points) focus_x = (min_fx + max_fx) / 2 focus_y = (min_fy + max_fy) / 2 focus_span = max(max_fx - min_fx, (max_fy - min_fy) * 1.25) cam_location = (focus_x + focus_span * 0.78, focus_y - focus_span * 0.92, focus_span * 1.22) camera_target = (focus_x, focus_y, 3) else: cam_location = (width * 0.78, -height * 1.15, max(width, height) * 1.22) camera_target = (0, 0, 3) bpy.ops.object.camera_add(location=cam_location) camera = bpy.context.object camera.name = "Scene Overview Camera" camera.data.lens = 48 camera.data.clip_start = 0.1 camera.data.clip_end = 5000.0 look_at(camera, camera_target) bpy.context.scene.camera = camera configure_default_viewport() scene = bpy.context.scene scene.render.filepath = args["render"] scene["source_osm"] = args["osm"] scene["source_geojson"] = geojson_dir or "" scene["source_native_road"] = native_road_dir or "" scene["osm_bounds"] = json.dumps(bounds, ensure_ascii=True) scene["building_count"] = counts["building_count"] scene["industrial_building_count"] = counts["industrial_count"] scene["office_override_way_ids"] = json.dumps(sorted(args["office_overrides"])) scene["lake_count"] = counts["lake_count"] scene["grass_count"] = counts["grass_count"] scene["grass_tuft_count"] = counts["grass_tuft_count"] scene["scrub_count"] = counts["scrub_count"] scene["scrub_bush_count"] = counts["scrub_bush_count"] scene["scrub_tree_count"] = counts["scrub_tree_count"] scene["fountain_count"] = counts["fountain_count"] scene["traffic_signal_count"] = counts["traffic_signal_count"] scene["tree_node_count"] = individual_tree_count scene["tree_row_count"] = row_tree_count scene["tree_count"] = len(trees) scene["tree_style"] = tree_style # Differs from tree_style when a model style's asset was missing. scene["tree_style_used"] = tree_style_used scene["road_feature_counts"] = json.dumps(road_counts, ensure_ascii=True) os.makedirs(os.path.dirname(args["output"]), exist_ok=True) os.makedirs(os.path.dirname(args["render"]), exist_ok=True) try: bpy.ops.file.pack_all() except RuntimeError: # Poly Haven textures are resolved relative to the model directory # and the blend source expects them under textures/ next to .blend. # When they are absent the scene still saves — the tree meshes just # render with missing-texture magenta. print("Some external resources could not be packed; saving anyway") bpy.ops.wm.save_as_mainfile(filepath=args["output"]) bpy.ops.render.render(write_still=True) print("SCENE_DONE", json.dumps({"output": args["output"], "render": args["render"], "buildings": counts["building_count"], "industrial_buildings": counts["industrial_count"], "lake": counts["lake_count"], "grass": counts["grass_count"], "grass_tufts": counts["grass_tuft_count"], "scrub": counts["scrub_count"], "scrub_bushes": counts["scrub_bush_count"], "scrub_trees": counts["scrub_tree_count"], "fountains": counts["fountain_count"], "traffic_signals": counts["traffic_signal_count"], "traffic_signal_dynamic_objects": dynamic_signal_objects, "tree_nodes": individual_tree_count, "tree_row_instances": row_tree_count, "trees": len(trees), "tree_style": tree_style_used, "road_features": road_counts}, ensure_ascii=True)) if __name__ == "__main__": build(cli_args())