"""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 xml.etree.ElementTree as ET from collections import defaultdict import bpy from mathutils import Vector TEXTURE_ROOT = os.path.abspath(os.path.join( os.path.dirname(__file__), "..", "assets", "textures", "polyhaven" )) 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 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 {"natural", "procedural"}: raise RuntimeError("--tree-style must be 'natural' or 'procedural'") return values def tags(element): return {t.attrib.get("k", ""): t.attrib.get("v", "") for t in element.findall("tag")} def parse_osm(path): root = ET.parse(path).getroot() bounds_node = root.find("bounds") if bounds_node is None: raise RuntimeError("OSM file does not contain a bounds element") bounds = {"min_lon": float(bounds_node.attrib["minlon"]), "min_lat": float(bounds_node.attrib["minlat"]), "max_lon": float(bounds_node.attrib["maxlon"]), "max_lat": float(bounds_node.attrib["maxlat"])} nodes = {} point_features = [] for node in root.findall("node"): try: node_id = int(node.attrib["id"]) coord = (float(node.attrib["lon"]), float(node.attrib["lat"])) node_tags = tags(node) nodes[node_id] = coord if node_tags: point_features.append({"id": node.attrib.get("id", ""), "coord": coord, "tags": node_tags}) except (KeyError, ValueError): continue ways = [] for way in root.findall("way"): if way.attrib.get("action") == "delete": continue refs = [] for ref in way.findall("nd"): try: refs.append(int(ref.attrib["ref"])) except (KeyError, ValueError): pass coords = [nodes[r] for r in refs if r in nodes] if len(coords) >= 2: ways.append({"id": way.attrib.get("id", ""), "coords": coords, "tags": tags(way)}) return bounds, ways, point_features class Projector: def __init__(self, bounds): self.bounds = bounds self.lon0 = (bounds["min_lon"] + bounds["max_lon"]) / 2 self.lat0 = (bounds["min_lat"] + bounds["max_lat"]) / 2 self.m_per_lat = 111320.0 self.m_per_lon = 111320.0 * math.cos(math.radians(self.lat0)) def xy(self, lon_lat): lon, lat = lon_lat return ((lon - self.lon0) * self.m_per_lon, (lat - self.lat0) * self.m_per_lat) def inside(self, lon_lat, pad=0.00035): lon, lat = lon_lat b = self.bounds return (b["min_lon"] - pad <= lon <= b["max_lon"] + pad and b["min_lat"] - pad <= lat <= b["max_lat"] + pad) def ring(self, coords): return [self.xy(c) for c in coords] def new_collection(name): collection = bpy.data.collections.new(name) bpy.context.scene.collection.children.link(collection) return collection def principled_bsdf(material): if not material.use_nodes: return None for node in material.node_tree.nodes: if node.type == "BSDF_PRINCIPLED": return node return None def make_material(name, color, roughness=0.8, metallic=0.0): material = bpy.data.materials.get(name) or bpy.data.materials.new(name) material.diffuse_color = (*color, 1.0) material.use_nodes = True bsdf = principled_bsdf(material) if bsdf: bsdf.inputs["Base Color"].default_value = (*color, 1.0) bsdf.inputs["Roughness"].default_value = roughness bsdf.inputs["Metallic"].default_value = metallic return material def add_procedural_surface(material, colors, scale=2.0, detail=2.0, bump_strength=0.08, object_space=False): nodes = material.node_tree.nodes links = material.node_tree.links bsdf = principled_bsdf(material) if not bsdf: return noise = nodes.new("ShaderNodeTexNoise") noise.inputs["Scale"].default_value = scale noise.inputs["Detail"].default_value = detail noise.inputs["Roughness"].default_value = 0.65 texcoord = nodes.new("ShaderNodeTexCoord") ramp = nodes.new("ShaderNodeValToRGB") ramp.color_ramp.elements[0].color = (*colors[0], 1.0) ramp.color_ramp.elements[1].color = (*colors[1], 1.0) bump = nodes.new("ShaderNodeBump") bump.inputs["Strength"].default_value = bump_strength bump.inputs["Distance"].default_value = 0.12 # "Generated" normalises across the object bounding box, so on a mesh that # spans the whole scene the noise stretches to tens of metres and vanishes. # Object space keeps the scale in metres, which is what foliage needs. source = "Object" if object_space else "Generated" links.new(texcoord.outputs[source], noise.inputs["Vector"]) links.new(noise.outputs["Fac"], ramp.inputs["Fac"]) links.new(ramp.outputs["Color"], bsdf.inputs["Base Color"]) links.new(noise.outputs["Fac"], bump.inputs["Height"]) links.new(bump.outputs["Normal"], bsdf.inputs["Normal"]) def tint_base_color(material, tint, factor): """Mix an existing material's base colour toward `tint`. Imported assets arrive with their own diffuse texture wired up. Rather than replacing it — which throws away the leaf detail — this splices a mix node in front of the Base Color input so the texture survives at (1 - factor). """ if factor <= 0.0 or not material.use_nodes: return bsdf = principled_bsdf(material) if not bsdf: return nodes = material.node_tree.nodes links = material.node_tree.links base = bsdf.inputs["Base Color"] tint_node = nodes.new("ShaderNodeRGB") tint_node.outputs["Color"].default_value = (*tint, 1.0) mix = nodes.new("ShaderNodeMixRGB") mix.blend_type = "MIX" mix.inputs["Fac"].default_value = factor if base.is_linked: # Capture the upstream socket before relinking; Blender drops the old # link as soon as the input takes a new one. links.new(base.links[0].from_socket, mix.inputs[1]) else: mix.inputs[1].default_value = base.default_value links.new(tint_node.outputs["Color"], mix.inputs[2]) links.new(mix.outputs["Color"], base) def make_textured_material(name, diffuse_file, normal_file, roughness, scale, normal_is_bump=False, metallic=0.0, tint=None, tint_factor=0.0): diffuse_path = os.path.join(TEXTURE_ROOT, diffuse_file) normal_path = os.path.join(TEXTURE_ROOT, normal_file) if not os.path.exists(diffuse_path) or not os.path.exists(normal_path): return make_material(name, (0.5, 0.5, 0.5), roughness, metallic) material = make_material(name, (0.5, 0.5, 0.5), roughness, metallic) nodes = material.node_tree.nodes links = material.node_tree.links bsdf = principled_bsdf(material) if not bsdf: return material texcoord = nodes.new("ShaderNodeTexCoord") mapping = nodes.new("ShaderNodeMapping") mapping.inputs["Scale"].default_value = (scale, scale, scale) diffuse = nodes.new("ShaderNodeTexImage") diffuse.image = bpy.data.images.load(diffuse_path, check_existing=True) diffuse.extension = "REPEAT" normal = nodes.new("ShaderNodeTexImage") normal.image = bpy.data.images.load(normal_path, check_existing=True) normal.image.colorspace_settings.name = "Non-Color" normal.extension = "REPEAT" links.new(texcoord.outputs["Generated"], mapping.inputs["Vector"]) links.new(mapping.outputs["Vector"], diffuse.inputs["Vector"]) links.new(mapping.outputs["Vector"], normal.inputs["Vector"]) if tint and tint_factor > 0.0: tint_node = nodes.new("ShaderNodeRGB") tint_node.outputs["Color"].default_value = (*tint, 1.0) mix = nodes.new("ShaderNodeMixRGB") mix.blend_type = "MIX" mix.inputs["Fac"].default_value = tint_factor links.new(diffuse.outputs["Color"], mix.inputs[1]) links.new(tint_node.outputs["Color"], mix.inputs[2]) links.new(mix.outputs["Color"], bsdf.inputs["Base Color"]) else: links.new(diffuse.outputs["Color"], bsdf.inputs["Base Color"]) if normal_is_bump: bump = nodes.new("ShaderNodeBump") bump.inputs["Strength"].default_value = 0.22 bump.inputs["Distance"].default_value = 0.12 links.new(normal.outputs["Color"], bump.inputs["Height"]) links.new(bump.outputs["Normal"], bsdf.inputs["Normal"]) else: normal_map = nodes.new("ShaderNodeNormalMap") normal_map.inputs["Strength"].default_value = 0.52 links.new(normal.outputs["Color"], normal_map.inputs["Color"]) links.new(normal_map.outputs["Normal"], bsdf.inputs["Normal"]) return material class MeshBatch: def __init__(self, name, collection, material): self.name = name self.collection = collection self.material = material self.vertices = [] self.faces = [] def add_polygon(self, ring, z): if len(ring) < 3: return if ring[0] == ring[-1]: ring = ring[:-1] if len(ring) < 3: return start = len(self.vertices) self.vertices.extend((x, y, z) for x, y in ring) self.faces.append(tuple(range(start, start + len(ring)))) def add_prism(self, ring, base, height): if len(ring) < 3: return if ring[0] == ring[-1]: ring = ring[:-1] if len(ring) < 3: return start = len(self.vertices) self.vertices.extend((x, y, base) for x, y in ring) self.vertices.extend((x, y, base + height) for x, y in ring) n = len(ring) self.faces.append(tuple(range(start, start + n))) self.faces.append(tuple(range(start + n, start + 2 * n))) for i in range(n): j = (i + 1) % n self.faces.append((start + i, start + j, start + n + j, start + n + i)) def finish(self): if not self.vertices: return None mesh = bpy.data.meshes.new(self.name + "Mesh") mesh.from_pydata(self.vertices, [], self.faces) mesh.materials.append(self.material) if self.name.startswith("Tree_") or self.name.startswith("Scrub_"): for polygon in mesh.polygons: polygon.use_smooth = True mesh.update() obj = bpy.data.objects.new(self.name, mesh) self.collection.objects.link(obj) return obj def make_prism(name, ring, base, height, material, collection): batch = MeshBatch(name, collection, material) batch.add_prism(ring, base, height) return batch.finish() def add_roof(name, ring, z, material, collection): batch = MeshBatch(name + "_Roof", collection, material) batch.add_polygon(ring, z) return batch.finish() def add_wall_panel(batch, start, end, base, height, thickness=0.045, inset=0.08): dx, dy = end[0] - start[0], end[1] - start[1] length = math.hypot(dx, dy) if length < 3.0: return ux, uy = dx / length, dy / length a = (start[0] + dx * inset, start[1] + dy * inset) b = (end[0] - dx * inset, end[1] - dy * inset) nx, ny = -uy * thickness / 2, ux * thickness / 2 panel = [(a[0] + nx, a[1] + ny), (b[0] + nx, b[1] + ny), (b[0] - nx, b[1] - ny), (a[0] - nx, a[1] - ny)] batch.add_prism(panel, base, height) 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 geometry_rings(geometry): if not geometry: return [] kind = geometry.get("type") coordinates = geometry.get("coordinates", []) if kind == "Polygon": return coordinates[:1] if kind == "MultiPolygon": return [polygon[0] for polygon in coordinates if polygon] return [] def feature_in_bounds(feature, projector): def walk(value): if isinstance(value, list) and value and isinstance(value[0], (int, float)): return projector.inside(value) return any(walk(v) for v in value) if isinstance(value, list) else False return walk(feature.get("geometry", {}).get("coordinates", [])) def clip_polygon(ring, xmin, xmax, ymin, ymax): if len(ring) < 3: return [] def clip_edge(points, inside, intersection): if not points: return [] result = [] previous = points[-1] previous_inside = inside(previous) for current in points: current_inside = inside(current) if current_inside != previous_inside: result.append(intersection(previous, current)) if current_inside: result.append(current) previous = current previous_inside = current_inside return result ring = clip_edge( ring, lambda p: p[0] >= xmin, lambda a, b: (xmin, a[1] + (b[1] - a[1]) * (xmin - a[0]) / (b[0] - a[0]) if b[0] != a[0] else a[1])) ring = clip_edge( ring, lambda p: p[0] <= xmax, lambda a, b: (xmax, a[1] + (b[1] - a[1]) * (xmax - a[0]) / (b[0] - a[0]) if b[0] != a[0] else a[1])) ring = clip_edge( ring, lambda p: p[1] >= ymin, lambda a, b: (a[0] + (b[0] - a[0]) * (ymin - a[1]) / (b[1] - a[1]) if b[1] != a[1] else a[0], ymin)) ring = clip_edge( ring, lambda p: p[1] <= ymax, lambda a, b: (a[0] + (b[0] - a[0]) * (ymax - a[1]) / (b[1] - a[1]) if b[1] != a[1] else a[0], ymax)) return ring 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_polyline(name, coords, projector, collection, material, width, z): points = [projector.xy(c) for c in coords] if len(points) < 2: return curve = bpy.data.curves.new(name, "CURVE") curve.dimensions = "3D" curve.resolution_u = 1 curve.bevel_depth = width / 2 curve.bevel_resolution = 1 spline = curve.splines.new("POLY") spline.points.add(len(points) - 1) for point, (x, y) in zip(spline.points, points): point.co = (x, y, z, 1) obj = bpy.data.objects.new(name, curve) collection.objects.link(obj) obj.data.materials.append(material) def parse_height(feature_tags, default): try: return max(0.5, float(feature_tags.get("height", default))) except ValueError: return default def sample_tree_row(points, spacing, height): if len(points) < 2: return [] samples = [(points[0][0], points[0][1], height)] distance_until_next = spacing for start, end in zip(points, points[1:]): dx = end[0] - start[0] dy = end[1] - start[1] segment_length = math.hypot(dx, dy) if segment_length == 0: continue while distance_until_next <= segment_length: ratio = distance_until_next / segment_length samples.append((start[0] + dx * ratio, start[1] + dy * ratio, height)) distance_until_next += spacing distance_until_next -= segment_length last = points[-1] if math.hypot(samples[-1][0] - last[0], samples[-1][1] - last[1]) > spacing * 0.45: samples.append((last[0], last[1], height)) return samples 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 polygon_area(ring): if len(ring) < 3: return 0.0 area = 0.0 for (x1, y1), (x2, y2) in zip(ring, ring[1:] + ring[:1]): area += x1 * y2 - x2 * y1 return abs(area) * 0.5 def point_in_polygon(point, ring): x, y = point inside = False j = len(ring) - 1 for i, (xi, yi) in enumerate(ring): xj, yj = ring[j] crosses = ((yi > y) != (yj > y)) if crosses: x_at_y = (xj - xi) * (y - yi) / (yj - yi) + xi if x < x_at_y: inside = not inside j = i return inside def distance_to_ring(point, ring): px, py = point best = float("inf") count = len(ring) for index in range(count): ax, ay = ring[index] bx, by = ring[(index + 1) % count] dx = bx - ax dy = by - ay length_sq = dx * dx + dy * dy if length_sq <= 1e-9: distance = math.hypot(px - ax, py - ay) else: t = ((px - ax) * dx + (py - ay) * dy) / length_sq t = max(0.0, min(1.0, t)) distance = math.hypot(px - (ax + t * dx), py - (ay + t * dy)) if distance < best: best = distance return best 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 link_object_to_collection(obj, collection): for current in list(obj.users_collection): current.objects.unlink(obj) collection.objects.link(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 = make_material("Ground", (0.27, 0.32, 0.24)) water_mat = make_material("Lake Water", (0.035, 0.22, 0.30), 0.18, 0.05) grass_mat = make_textured_material( "Grass", "leafy_grass_diff_1k.jpg", "leafy_grass_nor_gl_1k.jpg", roughness=0.92, scale=7.0, tint=(0.12, 0.48, 0.08), tint_factor=0.72) scrub_mat = make_textured_material( "Scrub Ground Cover", "leafy_grass_diff_1k.jpg", "leafy_grass_nor_gl_1k.jpg", roughness=0.96, scale=15.0, tint=(0.085, 0.30, 0.065), tint_factor=0.46) tuft_variants = load_tuft_variants() fountain_mats = { "fountain_stone": make_material("Fountain Stone", (0.42, 0.45, 0.43), 0.72), "fountain_water": make_material("Fountain Water", (0.03, 0.32, 0.42), 0.16, 0.05), "fountain_spray": make_material("Fountain Spray", (0.20, 0.70, 0.78), 0.12, 0.02), } building_mats = { "default": make_textured_material( "Office White Plaster Facade", "white_plaster_02_diff_1k.jpg", "white_plaster_02_nor_gl_1k.jpg", roughness=0.82, scale=4.2, metallic=0.0, tint=(0.92, 0.94, 0.92), tint_factor=0.38), "industrial": make_textured_material( "Industrial White Ribbed Facade", "corrugated_iron_03_diff_1k.jpg", "corrugated_iron_03_nor_gl_1k.jpg", roughness=0.56, scale=2.4, metallic=0.16, tint=(0.86, 0.92, 0.94), tint_factor=0.68), "office_roof": make_textured_material( "Office Light Flat Roof", "concrete_floor_02_diff_1k.jpg", "concrete_floor_02_bump_1k.jpg", roughness=0.84, scale=5.0, normal_is_bump=True, tint=(0.82, 0.86, 0.88), tint_factor=0.35), "industrial_roof": make_textured_material( "Factory Blue Metal Roof", "blue_metal_plate_diff_1k.jpg", "blue_metal_plate_nor_gl_1k.jpg", roughness=0.48, scale=3.4, metallic=0.28, tint=(0.03, 0.42, 0.78), tint_factor=0.45), "glass": make_material("Office Blue Gray Glass", (0.12, 0.20, 0.24), 0.22, 0.10), "factory_glass": make_material("Factory Dark Windows", (0.10, 0.14, 0.15), 0.28, 0.08), } road_mats = { "road_surface": make_material("Road Asphalt", (0.055, 0.065, 0.070)), "intersection_surface": make_material("Intersection Asphalt", (0.065, 0.075, 0.080)), "sidewalks": make_material("Sidewalk", (0.49, 0.51, 0.49)), "sidewalk_corners": make_material("Sidewalk Corner", (0.49, 0.51, 0.49)), "lane_separators": make_material("Lane Separator", (0.85, 0.84, 0.72)), "center_lines": make_material("Center Line", (0.94, 0.58, 0.06)), "crosswalks": make_material("Crosswalk", (0.95, 0.94, 0.82)), "vehicle_stop_lines": make_material("Stop Line", (0.95, 0.94, 0.82)), "lane_arrows_webscale": make_material("Lane Arrow", (0.95, 0.94, 0.82)), } 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 = [] lake_count = 0 grass_count = 0 grass_tuft_count = 0 scrub_count = 0 fountain_count = 0 building_count = 0 industrial_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": ring = clip_polygon(ring, scene_xmin, scene_xmax, scene_ymin, scene_ymax) batch = MeshBatch("Lake Surface", water_c, water_mat) if len(ring) >= 3: batch.add_polygon(ring, 0.10) batch.finish() lake_count += 1 elif tag.get("landuse") == "grass": ring = clip_polygon(ring, scene_xmin, scene_xmax, scene_ymin, scene_ymax) grass_rings.append(ring) focus_points.extend(ring) batch = MeshBatch("Grass_" + str(way["id"]), green_c, grass_mat) if len(ring) >= 3: batch.add_polygon(ring, 0.015) obj = batch.finish() grass_count += 1 if tuft_variants: tufts = add_grass_tufts("Grass_" + str(way["id"]), ring, tuft_variants, green_c) grass_tuft_count += tufts if obj: obj["grass_tufts"] = tufts elif tag.get("natural") == "scrub" and len(ring) >= 3: ring = clip_polygon(ring, scene_xmin, scene_xmax, scene_ymin, scene_ymax) focus_points.extend(ring) if len(ring) >= 3: add_scrub_patch("Scrub_" + str(way["id"]), ring, scrub_mat, green_c) scrub_count += 1 elif tag.get("natural") == "tree_row": tree_rows.append((ring, tag)) focus_points.extend(ring) elif "building" in tag and len(ring) >= 3: way_id = str(way["id"]) 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) focus_points.extend(ring) building_count += 1 industrial_count += int(industrial) geojson_dir = args.get("geojson") road_counts = {} if geojson_dir and os.path.isdir(geojson_dir): layer_z = {"road_surface": 0.03, "intersection_surface": 0.035, "sidewalks": 0.065, "sidewalk_corners": 0.067, "lane_separators": 0.090, "center_lines": 0.092, "crosswalks": 0.094, "vehicle_stop_lines": 0.096, "lane_arrows_webscale": 0.098} for layer, z in layer_z.items(): road_counts[layer] = add_geojson_layer( os.path.join(geojson_dir, layer + ".geojson"), layer, projector, roads_c, road_mats[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) if trees: tree_style = args.get("tree_style") if tree_style == "natural": tree_trunk = make_textured_material( "Tree Trunk", "bark_brown_01_diff_1k.jpg", "bark_brown_01_nor_gl_1k.jpg", roughness=0.92, scale=5.0) leaf_dark = make_material("Tree Crown Dark", (0.065, 0.25, 0.055), 0.90) add_procedural_surface(leaf_dark, ((0.035, 0.14, 0.035), (0.12, 0.36, 0.08)), scale=3.2, detail=3.8, bump_strength=0.08) leaf_light = make_material("Tree Crown Light", (0.13, 0.42, 0.09), 0.88) add_procedural_surface(leaf_light, ((0.07, 0.25, 0.05), (0.22, 0.56, 0.13)), scale=3.6, detail=3.4, bump_strength=0.07) add_natural_tree_instances(trees, props_c, tree_trunk, leaf_dark, leaf_light) else: tree_trunk = make_textured_material( "Tree Trunk", "bark_brown_01_diff_1k.jpg", "bark_brown_01_nor_gl_1k.jpg", roughness=0.92, scale=5.0) tree_leaf = make_material("Tree Crown", (0.10, 0.36, 0.08), 0.88) add_procedural_surface(tree_leaf, ((0.04, 0.18, 0.04), (0.18, 0.50, 0.12)), scale=2.8, detail=3.2, bump_strength=0.10) add_tree_batch(trees, props_c, tree_trunk, tree_leaf) 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) 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"] = building_count scene["industrial_building_count"] = industrial_count scene["office_override_way_ids"] = json.dumps(sorted(args["office_overrides"])) scene["lake_count"] = lake_count scene["grass_count"] = grass_count scene["grass_tuft_count"] = grass_tuft_count scene["scrub_count"] = scrub_count scene["fountain_count"] = fountain_count scene["tree_node_count"] = individual_tree_count scene["tree_row_count"] = row_tree_count scene["tree_count"] = len(trees) 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) bpy.ops.file.pack_all() 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": building_count, "industrial_buildings": industrial_count, "lake": lake_count, "grass": grass_count, "grass_tufts": grass_tuft_count, "scrub": scrub_count, "fountains": fountain_count, "tree_nodes": individual_tree_count, "tree_row_instances": row_tree_count, "trees": len(trees), "road_features": road_counts}, ensure_ascii=True)) if __name__ == "__main__": build(cli_args())