"""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.geom import ( # noqa: E402 (needs the sys.path line above) clip_polygon, distance_to_ring, feature_in_bounds, geometry_rings, point_in_polygon, 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, add_polyline, add_roof, add_wall_panel, link_object_to_collection, make_prism, new_collection, ) from osmassets.osm import Projector, parse_height, parse_osm # noqa: E402 from osmassets import water as _water # noqa: E402 from osmassets import grass as _grass # noqa: E402 from osmassets import scrub as _scrub # noqa: E402 from osmassets import tree as _tree # noqa: E402 # Building assembly stays in this file because it needs make_prism, add_roof, # add_wall_panel, and add_building_details — Blender geometry helpers that # live a few lines above. The other features moved to osmassets/{water,grass, # scrub}.py and take only pure-geometry primitives (MeshBatch / clip_polygon). def _assemble_building(ring, way_id, tag, args, buildings_c, building_mats): industrial = (tag.get("building") == "industrial" and way_id not in args["office_overrides"]) source_height = max(3.0, parse_height(tag, 12.0)) height = source_height if industrial or source_height >= 30.0 else 11.4 material = building_mats["industrial"] if industrial else building_mats["default"] building_name = "Building_" + way_id building_obj = make_prism(building_name, ring, 0.08, height, material, buildings_c) if building_obj: building_obj["osm_height"] = source_height building_obj["render_height"] = height building_obj["building_kind"] = "industrial" if industrial else "office" building_obj["osm_building_tag"] = tag.get("building", "") building_obj["office_override"] = way_id in args["office_overrides"] bevel = building_obj.modifiers.new("Soft facade edges", "BEVEL") bevel.width = 0.16 bevel.segments = 2 roof_mat = (building_mats["industrial_roof"] if industrial else building_mats["office_roof"]) add_roof(building_name, ring, height + 0.095, roof_mat, buildings_c) add_building_details(building_name, ring, height, industrial, building_mats, buildings_c) return 1, int(industrial), ring MODEL_ROOT = os.path.abspath(os.path.join( os.path.dirname(__file__), "..", "assets", "models", "polyhaven" )) # Vendored under the name shrub_02, but the plant reads as a tufted grass, not # as a bush: it belongs on the lawns. Scrub beds stay flat ground cover until a # genuinely bush-shaped asset is sourced. TUFT_MODEL = os.path.join(MODEL_ROOT, "shrub_02", "shrub_02_1k.gltf") # Poly Haven ships it at ~27k triangles across four variants. The leaves are # modelled as real geometry, so decimation eats them: at ~2.2k per variant the # tufts render as bare twigs. Instancing makes the full mesh affordable anyway — # every tuft shares one of four datablocks, so the scene and the exported GLB # carry that geometry once no matter how many are scattered. 0 disables it. TUFT_TARGET_TRIS = 0 # The variants stand 1.17-1.68m tall natively, which is shrub height. Roughly a # third of that lands in the 0.3-0.8m range real lawn tufts occupy. TUFT_SCALE_RANGE = (0.26, 0.46) TUFT_SPACING = 1.7 TUFT_LIMIT_PER_LAWN = 100 # The vendored 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 the lawn tint, a shade brighter so the tufts still separate from it. TUFT_TINT = (0.15, 0.52, 0.09) TUFT_TINT_FACTOR = 0.66 # Tree styles. The two built from mesh batches live in this file; the rest are # vendored models handled by osmassets.tree, which owns that list. 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, "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_building_details(name, ring, height, industrial, materials, collection): footprint = ring[:-1] if len(ring) > 1 and ring[0] == ring[-1] else ring if len(footprint) < 3: return glass_mat = materials["factory_glass"] if industrial else materials["glass"] glass_batch = MeshBatch(name + "_Windows", collection, glass_mat) edges = list(zip(footprint, footprint[1:] + footprint[:1])) if industrial: band_height = min(1.8, max(0.75, height * 0.16)) band_base = max(0.9, height * 0.52) for start, end in edges: add_wall_panel(glass_batch, start, end, band_base, band_height, thickness=0.055, inset=0.12) else: floor_height = 3.25 floor_count = max(1, int((height - 0.7) / floor_height)) for floor in range(floor_count): band_base = 0.55 + floor * floor_height + 0.95 if band_base + 1.25 > height - 0.18: break for start, end in edges: add_wall_panel(glass_batch, start, end, band_base, 1.25, thickness=0.045, inset=0.10) glass_batch.finish() def add_geojson_layer(path, layer, projector, collection, material, z): if not os.path.exists(path): return 0 with open(path, "r", encoding="utf-8") as handle: data = json.load(handle) batch = MeshBatch("Road_" + layer, collection, material) b = projector.bounds xmin, ymin = projector.xy((b["min_lon"], b["min_lat"])) xmax, ymax = projector.xy((b["max_lon"], b["max_lat"])) count = 0 for feature in data.get("features", []): if not feature_in_bounds(feature, projector): continue for ring in geometry_rings(feature.get("geometry")): points = [projector.xy(pair) for pair in ring] points = clip_polygon(points, xmin, xmax, ymin, ymax) if len(points) >= 3: batch.add_polygon(points, z) count += 1 batch.finish() return count 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 load_tuft_variants(): """Import the vendored Poly Haven plant once and return decimated meshes. Returns [] when the asset is missing so a clean checkout still builds; the lawns then fall back to plain textured ground. """ if not os.path.exists(TUFT_MODEL): return [] before = set(bpy.data.objects) try: bpy.ops.import_scene.gltf(filepath=TUFT_MODEL) 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"] variants = [] tinted = set() 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 = obj.data.copy() mesh.name = "GrassTuft_" + 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 material in mesh.materials: # The vendored textures are JPEG with no alpha channel, so hashed # transparency only costs sorting work in Blender and Cesium. if hasattr(material, "blend_method"): material.blend_method = "OPAQUE" # All four variants share one material, so guard against stacking # the mix node — and the tint with it — four times over. if material.name not in tinted: tint_base_color(material, TUFT_TINT, TUFT_TINT_FACTOR) tinted.add(material.name) variants.append(mesh) for obj in imported: bpy.data.objects.remove(obj, do_unlink=True) 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): # A lawn wants gentle clumping, not the hard banding a shrub bed needs: # low amplitude keeps most of the polygon planted so the bare stretches # read as mown patches rather than as dead ground. 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 add_scrub_patch(name, ring, ground_material, collection): if len(ring) < 3: return None if ring[0] == ring[-1]: ring = ring[:-1] if len(ring) < 3: return None ground = MeshBatch(name, collection, ground_material) ground.add_polygon(ring, 0.055) obj = ground.finish() # The vendored plant asset reads as grass, so it went to the lawns. Until a # bush-shaped model lands, scrub beds stay flat ground cover: the previous # procedural hedge mass read as blocky colour patches, not as planting. if obj: obj["scrub_texture"] = "Poly Haven leafy_grass, scrub-tinted" obj["scrub_style"] = "flat foliage ground cover" return obj def add_fountain(name, x, y, collection, materials): def cylinder(part_name, radius, depth, z, material, vertices=48): bpy.ops.mesh.primitive_cylinder_add( vertices=vertices, radius=radius, depth=depth, location=(x, y, z)) obj = bpy.context.object obj.name = name + "_" + part_name link_object_to_collection(obj, collection) obj.data.materials.append(material) for polygon in obj.data.polygons: polygon.use_smooth = True return obj basin = cylinder("Basin", 3.0, 0.32, 0.16, materials["fountain_stone"]) basin["osm_feature"] = "amenity=fountain" cylinder("Water", 2.52, 0.045, 0.335, materials["fountain_water"]) cylinder("Pedestal", 0.30, 0.78, 0.72, materials["fountain_stone"], vertices=32) bpy.ops.mesh.primitive_uv_sphere_add( segments=20, ring_count=10, radius=0.22, location=(x, y, 1.30)) crown = bpy.context.object crown.name = name + "_Water_Crown" link_object_to_collection(crown, collection) crown.data.materials.append(materials["fountain_spray"]) for index in range(8): angle = math.tau * index / 8.0 radius = 0.50 bpy.ops.mesh.primitive_uv_sphere_add( segments=12, ring_count=6, radius=0.075, location=(x + math.cos(angle) * radius, y + math.sin(angle) * radius, 1.02 + 0.10 * math.sin(angle * 2.0))) droplet = bpy.context.object droplet.name = name + "_Droplet_" + str(index + 1) link_object_to_collection(droplet, collection) droplet.data.materials.append(materials["fountain_spray"]) 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 scene.render.engine = "BLENDER_EEVEE_NEXT" 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") 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() 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()) } 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 = [] 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_count": 0, } focus_points = [] for way in ways: coords = way["coords"] if not any(projector.inside(c) for c in coords): continue ring = projector.ring(coords) tag = way["tags"] if tag.get("natural") == "water" or tag.get("water") == "lake": counts["lake_count"] += _water.assemble(ring, scene_xmin, scene_xmax, scene_ymin, scene_ymax, water_c, water_mat) elif tag.get("landuse") == "grass": 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) elif tag.get("natural") == "scrub" and len(ring) >= 3: added, ring_pts = _scrub.assemble(ring, way["id"], scene_xmin, scene_xmax, scene_ymin, scene_ymax, green_c, scrub_mat, add_scrub_patch) counts["scrub_count"] += added if ring_pts: focus_points.extend(ring_pts) elif tag.get("natural") == "tree_row": tree_rows.append((ring, tag)) focus_points.extend(ring) elif "building" in tag and len(ring) >= 3: added, ind_added, ring_pts = _assemble_building( ring, str(way["id"]), tag, args, buildings_c, building_mats) counts["building_count"] += added counts["industrial_count"] += ind_added if ring_pts: focus_points.extend(ring_pts) geojson_dir = args.get("geojson") road_counts = {} if 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] = add_geojson_layer( os.path.join(geojson_dir, layer_id + ".geojson"), layer_id, projector, roads_c, road_mats[layer_id], layer["z"]) if road_counts.get("road_surface", 0) == 0: for way in ways: highway = way["tags"].get("highway") if highway and len(way["coords"]) >= 2: width = {"secondary": 7.0, "residential": 5.5, "service": 3.5}.get(highway, 4.0) add_polyline("OSM_Road_" + str(way["id"]), way["coords"], projector, roads_c, road_mats["road_surface"], width, 0.03) 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 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"]) add_fountain("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["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["fountain_count"] = counts["fountain_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"], "fountains": counts["fountain_count"], "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())