Files
osmWorkflow/blender/generate_scene.py
2026-08-03 14:02:28 +08:00

896 lines
37 KiB
Python

"""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
MODEL_ROOT = os.path.abspath(os.path.join(
os.path.dirname(__file__), "..", "assets", "models", "polyhaven"
))
CUSTOM_MODEL_ROOT = os.path.abspath(os.path.join(
os.path.dirname(__file__), "..", "assets", "models", "custom"
))
# 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
SCRUB_BUSH_MODEL = os.path.join(CUSTOM_MODEL_ROOT, "bush", "bush.glb")
SCRUB_BUSH_SPACING = 0.82
SCRUB_BUSH_INSET = 0.38
SCRUB_BUSH_LIMIT_PER_PATCH = 180
SCRUB_BUSH_HEIGHT = 1.575
SCRUB_BUSH_SCALE_JITTER = 0.16
SCRUB_BUSH_LEAF_TINT = (0.075, 0.31, 0.055)
SCRUB_BUSH_LEAF_TINT_FACTOR = 0.36
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; 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_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 _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:
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
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()
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())
}
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,
}
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):
added, ind_added, ring_pts = _building.assemble(
ring, str(way["id"]), tag, args["office_overrides"],
buildings_c, building_mats)
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")
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] = _roads.assemble_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:
_roads.assemble_osm_fallback(
ways, projector, roads_c, road_mats["road_surface"])
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["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["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"],
"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())