924 lines
38 KiB
Python
924 lines
38 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
|
|
|
|
|
|
CUSTOM_MODEL_ROOT = os.path.abspath(os.path.join(
|
|
os.path.dirname(__file__), "..", "assets", "models", "custom"
|
|
))
|
|
|
|
# Shapespark grass variants replace the former high-detail lawn tuft asset.
|
|
# They are alpha-cut cards at about 10 tris each, so the shared geometry is
|
|
# tiny and each lawn instance links one of these datablocks.
|
|
TUFT_MODELS = (
|
|
os.path.join(CUSTOM_MODEL_ROOT, "shapespark_plants", "grass-01", "model.gltf"),
|
|
os.path.join(CUSTOM_MODEL_ROOT, "shapespark_plants", "grass-02", "model.gltf"),
|
|
os.path.join(CUSTOM_MODEL_ROOT, "shapespark_plants", "grass-03", "model.gltf"),
|
|
)
|
|
TUFT_TARGET_TRIS = 0
|
|
# The variants stand 1.2-1.34m tall natively. Roughly a third of that lands in
|
|
# the 0.3-0.6m range real lawn tufts occupy.
|
|
TUFT_SCALE_RANGE = (0.26, 0.46)
|
|
TUFT_SPACING = 1.7
|
|
TUFT_LIMIT_PER_LAWN = 100
|
|
# The Shapespark diffuse is a grey-green leaf over brown stems. Dropped onto the
|
|
# saturated lawn as-is it reads as dead weeds, so the base colour is mixed
|
|
# toward a cooler lawn tint without making the cards glow.
|
|
TUFT_TINT = (0.08, 0.30, 0.055)
|
|
TUFT_TINT_FACTOR = 0.22
|
|
|
|
SCRUB_BUSH_MODEL = os.path.join(
|
|
CUSTOM_MODEL_ROOT, "shapespark_plants", "bush-03", "model.gltf")
|
|
SCRUB_BUSH_SPACING = 1.8
|
|
SCRUB_BUSH_INSET = 0.38
|
|
SCRUB_BUSH_LIMIT_PER_PATCH = 60
|
|
SCRUB_BUSH_HEIGHT = 1.575
|
|
SCRUB_BUSH_SCALE_JITTER = 0.16
|
|
SCRUB_BUSH_LEAF_TINT = (0.055, 0.23, 0.045)
|
|
SCRUB_BUSH_LEAF_TINT_FACTOR = 0.18
|
|
SCRUB_BUSH_TRUNK_TINT = (0.13, 0.095, 0.055)
|
|
SCRUB_BUSH_TRUNK_TINT_FACTOR = 0.18
|
|
SCRUB_TREE_MIN_AREA = 45.0
|
|
SCRUB_TREE_SPACING = 8.0
|
|
SCRUB_TREE_EDGE_CLEARANCE = 2.8
|
|
SCRUB_TREE_LIMIT_PER_PATCH = 5
|
|
SCRUB_TREE_HEIGHT_RANGE = (4.6, 6.2)
|
|
|
|
# Tree styles. The two built from mesh batches live in this file; Shapespark
|
|
# model trees are handled by osmassets.tree. A model style whose asset is
|
|
# missing falls back to "natural" rather than planting nothing.
|
|
TREE_STYLES = frozenset(("natural", "procedural")) | frozenset(_tree.MODEL_STYLES)
|
|
|
|
|
|
def cli_args():
|
|
values = {"osm": None, "geojson": None, "output": None, "render": None,
|
|
"office_overrides": "", "tree_style": "natural"}
|
|
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
|
i = 0
|
|
while i < len(argv):
|
|
if argv[i].startswith("--") and i + 1 < len(argv):
|
|
values[argv[i][2:].replace("-", "_")] = argv[i + 1]
|
|
i += 2
|
|
else:
|
|
i += 1
|
|
if not values.get("osm"):
|
|
raise RuntimeError("--osm is required; --geojson is optional")
|
|
if not values.get("output"):
|
|
raise RuntimeError("--output is required")
|
|
if not values.get("render"):
|
|
raise RuntimeError("--render is required")
|
|
if values.get("office_overrides"):
|
|
try:
|
|
values["office_overrides"] = set(
|
|
w.strip() for w in values["office_overrides"].split(",") if w.strip()
|
|
)
|
|
except Exception:
|
|
values["office_overrides"] = set()
|
|
else:
|
|
values["office_overrides"] = set()
|
|
if values.get("tree_style") not in TREE_STYLES:
|
|
raise RuntimeError("--tree-style must be one of: "
|
|
+ ", ".join(sorted(TREE_STYLES)))
|
|
return values
|
|
|
|
|
|
def add_tree_batch(positions, collection, trunk_material, leaf_material):
|
|
trunk = MeshBatch("Tree_Trunks", collection, trunk_material)
|
|
leaves = MeshBatch("Tree_Crowns", collection, leaf_material)
|
|
sides = 10
|
|
|
|
def add_blob(batch, cx, cy, cz, rx, ry, rz, phase):
|
|
rings = 5
|
|
start = len(batch.vertices)
|
|
for ring in range(rings):
|
|
latitude = -math.pi / 2 + math.pi * ring / (rings - 1)
|
|
ring_radius = math.cos(latitude)
|
|
for side in range(sides):
|
|
angle = math.tau * side / sides
|
|
variation = 1.0 + 0.09 * math.sin(phase + side * 1.73 + ring * 0.91)
|
|
batch.vertices.append((cx + rx * ring_radius * math.cos(angle) * variation,
|
|
cy + ry * ring_radius * math.sin(angle) * variation,
|
|
cz + rz * math.sin(latitude)))
|
|
for ring in range(rings - 1):
|
|
for side in range(sides):
|
|
next_side = (side + 1) % sides
|
|
batch.faces.append((start + ring * sides + side,
|
|
start + ring * sides + next_side,
|
|
start + (ring + 1) * sides + next_side,
|
|
start + (ring + 1) * sides + side))
|
|
|
|
for index, (x, y, height) in enumerate(positions):
|
|
base = len(trunk.vertices)
|
|
radius = max(0.12, height * 0.035)
|
|
trunk_top = height * 0.62
|
|
for z, ring_radius in ((0.0, radius), (trunk_top, radius * 0.68)):
|
|
for i in range(sides):
|
|
a = math.tau * i / sides
|
|
trunk.vertices.append((x + ring_radius * math.cos(a),
|
|
y + ring_radius * math.sin(a), z))
|
|
trunk.faces.append(tuple(base + i for i in range(sides - 1, -1, -1)))
|
|
for i in range(sides):
|
|
j = (i + 1) % sides
|
|
trunk.faces.append((base + i, base + j, base + sides + j, base + sides + i))
|
|
trunk.faces.append(tuple(base + sides + i for i in range(sides)))
|
|
|
|
crown_r = max(0.85, height * 0.30)
|
|
crown_z = height * 0.82
|
|
add_blob(leaves, x, y, crown_z, crown_r * 0.70,
|
|
crown_r * 0.62, crown_r * 0.72, index * 1.41)
|
|
add_blob(leaves, x - crown_r * 0.42, y + crown_r * 0.08,
|
|
crown_z * 0.98, crown_r * 0.52, crown_r * 0.48,
|
|
crown_r * 0.58, index * 2.17 + 0.7)
|
|
add_blob(leaves, x + crown_r * 0.40, y - crown_r * 0.05,
|
|
crown_z * 1.02, crown_r * 0.50, crown_r * 0.46,
|
|
crown_r * 0.55, index * 2.63 + 1.3)
|
|
trunk.finish()
|
|
leaves.finish()
|
|
|
|
|
|
def add_natural_tree_instances(positions, collection, trunk_material,
|
|
leaf_dark_material, leaf_light_material):
|
|
trunk = MeshBatch("Tree_Natural_Trunks", collection, trunk_material)
|
|
lower = MeshBatch("Tree_Natural_Crowns_Dark", collection, leaf_dark_material)
|
|
upper = MeshBatch("Tree_Natural_Crowns_Light", collection, leaf_light_material)
|
|
trunk_sides = 9
|
|
crown_sides = 9
|
|
crown_rings = 5
|
|
|
|
def add_blob(batch, cx, cy, cz, rx, ry, rz, phase, squash=1.0):
|
|
start = len(batch.vertices)
|
|
for ring in range(crown_rings):
|
|
latitude = -math.pi / 2 + math.pi * ring / (crown_rings - 1)
|
|
ring_radius = math.cos(latitude)
|
|
for side in range(crown_sides):
|
|
angle = math.tau * side / crown_sides
|
|
wobble = (
|
|
1.0 +
|
|
0.14 * math.sin(phase + side * 1.31 + ring * 0.83) +
|
|
0.07 * math.sin(phase * 0.7 + side * 2.11)
|
|
)
|
|
batch.vertices.append((
|
|
cx + rx * ring_radius * math.cos(angle) * wobble,
|
|
cy + ry * ring_radius * math.sin(angle) * wobble,
|
|
cz + rz * math.sin(latitude) * squash,
|
|
))
|
|
for ring in range(crown_rings - 1):
|
|
for side in range(crown_sides):
|
|
next_side = (side + 1) % crown_sides
|
|
batch.faces.append((
|
|
start + ring * crown_sides + side,
|
|
start + ring * crown_sides + next_side,
|
|
start + (ring + 1) * crown_sides + next_side,
|
|
start + (ring + 1) * crown_sides + side,
|
|
))
|
|
|
|
for index, (x, y, height) in enumerate(positions):
|
|
target_height = max(4.8, min(8.8, height * 1.08))
|
|
phase = index * 1.61803398875
|
|
trunk_height = target_height * (0.48 + 0.05 * math.sin(phase))
|
|
trunk_radius = max(0.13, target_height * 0.038)
|
|
lean_x = math.sin(phase * 1.7) * target_height * 0.025
|
|
lean_y = math.cos(phase * 1.3) * target_height * 0.025
|
|
|
|
base = len(trunk.vertices)
|
|
trunk_levels = [
|
|
(0.0, trunk_radius),
|
|
(trunk_height * 0.55, trunk_radius * 0.78),
|
|
(trunk_height, trunk_radius * 0.48),
|
|
]
|
|
for level_index, (z, radius) in enumerate(trunk_levels):
|
|
offset_x = lean_x * level_index / (len(trunk_levels) - 1)
|
|
offset_y = lean_y * level_index / (len(trunk_levels) - 1)
|
|
for side in range(trunk_sides):
|
|
angle = math.tau * side / trunk_sides
|
|
trunk.vertices.append((
|
|
x + offset_x + radius * math.cos(angle),
|
|
y + offset_y + radius * math.sin(angle),
|
|
z,
|
|
))
|
|
trunk.faces.append(tuple(base + i for i in range(trunk_sides - 1, -1, -1)))
|
|
for level_index in range(len(trunk_levels) - 1):
|
|
row = base + level_index * trunk_sides
|
|
next_row = row + trunk_sides
|
|
for side in range(trunk_sides):
|
|
next_side = (side + 1) % trunk_sides
|
|
trunk.faces.append((row + side, row + next_side,
|
|
next_row + next_side, next_row + side))
|
|
top_row = base + (len(trunk_levels) - 1) * trunk_sides
|
|
trunk.faces.append(tuple(top_row + i for i in range(trunk_sides)))
|
|
|
|
crown_x = x + lean_x
|
|
crown_y = y + lean_y
|
|
crown_z = trunk_height + target_height * 0.22
|
|
crown_r = target_height * (0.35 + 0.035 * math.sin(phase * 0.9))
|
|
|
|
# Dark lower mass gives the canopy volume when viewed obliquely.
|
|
add_blob(lower, crown_x, crown_y, crown_z - crown_r * 0.08,
|
|
crown_r * 0.95, crown_r * 0.78, crown_r * 0.52,
|
|
phase, squash=0.82)
|
|
add_blob(lower, crown_x - crown_r * 0.46, crown_y + crown_r * 0.05,
|
|
crown_z - crown_r * 0.02, crown_r * 0.62, crown_r * 0.50,
|
|
crown_r * 0.42, phase + 0.8, squash=0.80)
|
|
add_blob(lower, crown_x + crown_r * 0.42, crown_y - crown_r * 0.08,
|
|
crown_z, crown_r * 0.58, crown_r * 0.48,
|
|
crown_r * 0.40, phase + 1.9, squash=0.80)
|
|
|
|
# Lighter upper clumps break the silhouette without adding heavy geometry.
|
|
add_blob(upper, crown_x + crown_r * 0.05, crown_y + crown_r * 0.04,
|
|
crown_z + crown_r * 0.34, crown_r * 0.70,
|
|
crown_r * 0.58, crown_r * 0.38, phase + 2.7, squash=0.74)
|
|
add_blob(upper, crown_x - crown_r * 0.24, crown_y - crown_r * 0.22,
|
|
crown_z + crown_r * 0.23, crown_r * 0.46,
|
|
crown_r * 0.40, crown_r * 0.30, phase + 3.5, squash=0.72)
|
|
trunk.finish()
|
|
lower.finish()
|
|
upper.finish()
|
|
|
|
|
|
def _numbered_base_name(name):
|
|
if len(name) > 4 and name[-4] == "." and name[-3:].isdigit():
|
|
return name[:-4]
|
|
return name
|
|
|
|
|
|
def _dedupe_imported_material(material, tinted, tint_factor):
|
|
name = _numbered_base_name(material.name)
|
|
existing = bpy.data.materials.get(name)
|
|
if existing and existing is not material:
|
|
return existing
|
|
material.name = name
|
|
if getattr(material, "blend_method", "OPAQUE") == "BLEND":
|
|
material.blend_method = "HASHED"
|
|
material.alpha_threshold = 0.45
|
|
material.show_transparent_back = False
|
|
if material.name not in tinted:
|
|
tint_base_color(material, TUFT_TINT, tint_factor)
|
|
tinted.add(material.name)
|
|
return material
|
|
|
|
|
|
def load_tuft_variants():
|
|
"""Import Shapespark grass variants and return shared tuft meshes.
|
|
|
|
Returns [] when the asset is missing so a clean checkout still builds; the
|
|
lawns then fall back to plain textured ground.
|
|
"""
|
|
if not all(os.path.exists(path) for path in TUFT_MODELS):
|
|
return []
|
|
|
|
before_materials = set(bpy.data.materials)
|
|
before_images = set(bpy.data.images)
|
|
variants = []
|
|
tinted = set()
|
|
for model_path in TUFT_MODELS:
|
|
before = set(bpy.data.objects)
|
|
try:
|
|
bpy.ops.import_scene.gltf(filepath=model_path)
|
|
except (RuntimeError, AttributeError) as error:
|
|
print("Grass tuft import failed, lawns stay flat:", error)
|
|
return []
|
|
|
|
imported = [obj for obj in set(bpy.data.objects) - before if obj.type == "MESH"]
|
|
for obj in sorted(imported, key=lambda item: item.name):
|
|
obj.data.calc_loop_triangles()
|
|
source_tris = len(obj.data.loop_triangles)
|
|
if TUFT_TARGET_TRIS and source_tris > TUFT_TARGET_TRIS:
|
|
modifier = obj.modifiers.new("TuftDecimate", "DECIMATE")
|
|
modifier.ratio = max(0.02, TUFT_TARGET_TRIS / source_tris)
|
|
bpy.context.view_layer.update()
|
|
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
|
mesh = bpy.data.meshes.new_from_object(evaluated)
|
|
else:
|
|
mesh = _bake_imported_mesh(obj, "GrassTuft_" + obj.name)
|
|
mesh.name = "GrassTuft_" + _numbered_base_name(obj.name)
|
|
mesh.calc_loop_triangles()
|
|
# Nothing is saved yet at this point, so keep the datablock alive
|
|
# even if a scene ends up with no lawn polygons to instance it into.
|
|
mesh.use_fake_user = True
|
|
for slot, material in enumerate(mesh.materials):
|
|
if material is not None:
|
|
mesh.materials[slot] = _dedupe_imported_material(
|
|
material, tinted, TUFT_TINT_FACTOR)
|
|
variants.append(mesh)
|
|
|
|
for obj in imported:
|
|
mesh = obj.data if obj.type == "MESH" else None
|
|
bpy.data.objects.remove(obj, do_unlink=True)
|
|
if mesh is not None and mesh.users == 0:
|
|
bpy.data.meshes.remove(mesh)
|
|
|
|
for material in set(bpy.data.materials) - before_materials:
|
|
if material.users == 0:
|
|
bpy.data.materials.remove(material)
|
|
for image in set(bpy.data.images) - before_images:
|
|
if image.users == 0:
|
|
bpy.data.images.remove(image)
|
|
|
|
tris = sum(len(mesh.loop_triangles) for mesh in variants)
|
|
detail = f"decimated to ~{TUFT_TARGET_TRIS} tris each" if TUFT_TARGET_TRIS else "full detail"
|
|
print(f"Grass tuft variants loaded: {len(variants)} ({detail}, {tris} tris shared)")
|
|
return variants
|
|
|
|
|
|
def tuft_density_wave(x, y):
|
|
# Low-amplitude deterministic waves leave occasional open patches while
|
|
# keeping most of the lawn planted, so the tufts read as uneven grass
|
|
# rather than a regular grid.
|
|
return (math.sin(x * 0.21 + y * 0.17)
|
|
+ 0.55 * math.sin(x * 0.44 - y * 0.29 + 1.3))
|
|
|
|
|
|
def add_grass_tufts(name, ring, variants, collection):
|
|
"""Scatter the plant variants across a lawn polygon as grass tufts."""
|
|
xmin = min(x for x, _ in ring)
|
|
xmax = max(x for x, _ in ring)
|
|
ymin = min(y for _, y in ring)
|
|
ymax = max(y for _, y in ring)
|
|
|
|
def sample(spacing):
|
|
spots = []
|
|
cols = max(1, int(math.ceil((xmax - xmin) / spacing)))
|
|
rows = max(1, int(math.ceil((ymax - ymin) / spacing)))
|
|
for i in range(cols):
|
|
for j in range(rows):
|
|
# Jitter breaks up the lattice; without it the tufts read as a
|
|
# planted grid rather than as grass.
|
|
seed = (i * 73856093) ^ (j * 19349663)
|
|
jx = ((seed * 0.61803398875) % 1.0 - 0.5) * spacing * 0.8
|
|
jy = ((seed * 0.41421356237) % 1.0 - 0.5) * spacing * 0.8
|
|
x = xmin + (i + 0.5) * spacing + jx
|
|
y = ymin + (j + 0.5) * spacing + jy
|
|
if not point_in_polygon((x, y), ring):
|
|
continue
|
|
# Keep tufts off the kerb line so they do not overhang paving.
|
|
if distance_to_ring((x, y), ring) < 0.45:
|
|
continue
|
|
if tuft_density_wave(x, y) < -0.85:
|
|
continue
|
|
spots.append((x, y, seed))
|
|
return spots
|
|
|
|
# Adapt spacing to the lawn so a large polygon does not explode the export.
|
|
spacing = TUFT_SPACING
|
|
spots = sample(spacing)
|
|
while len(spots) > TUFT_LIMIT_PER_LAWN and spacing < 12.0:
|
|
spacing *= 1.22
|
|
spots = sample(spacing)
|
|
|
|
min_scale, max_scale = TUFT_SCALE_RANGE
|
|
span = max_scale - min_scale
|
|
for index, (x, y, seed) in enumerate(spots):
|
|
mesh = variants[index % len(variants)]
|
|
obj = bpy.data.objects.new(f"{name}_Tuft_{index:03d}", mesh)
|
|
scale = min_scale + span * ((seed * 0.754877666) % 1.0)
|
|
# Sunk slightly below the lawn surface so the stems never float.
|
|
obj.location = (x, y, 0.008)
|
|
obj.rotation_euler = (
|
|
math.radians(-4.0 + 8.0 * ((seed * 0.5698403) % 1.0)),
|
|
math.radians(-4.0 + 8.0 * ((seed * 0.3317554) % 1.0)),
|
|
math.tau * ((seed * 0.61803398875) % 1.0),
|
|
)
|
|
obj.scale = (scale, scale * (0.90 + 0.20 * ((seed * 0.2236068) % 1.0)), scale)
|
|
collection.objects.link(obj)
|
|
return len(spots)
|
|
|
|
|
|
def _bake_imported_mesh(obj, name):
|
|
mesh = obj.data.copy()
|
|
mesh.transform(obj.matrix_world.to_3x3().to_4x4())
|
|
mesh.name = name
|
|
mesh.use_fake_user = True
|
|
return mesh
|
|
|
|
|
|
def _measure_meshes(meshes):
|
|
zs = [vertex.co.z for mesh in meshes for vertex in mesh.vertices]
|
|
if not zs:
|
|
return 1.0, 0.0
|
|
low, high = min(zs), max(zs)
|
|
return max(high - low, 1e-6), low
|
|
|
|
|
|
def _discard_imported_mesh_objects(objects):
|
|
for obj in objects:
|
|
mesh = obj.data if obj.type == "MESH" else None
|
|
bpy.data.objects.remove(obj, do_unlink=True)
|
|
if mesh is not None and mesh.users == 0:
|
|
bpy.data.meshes.remove(mesh)
|
|
|
|
|
|
def load_scrub_bush_variant():
|
|
"""Import the optional custom bush once for edge instancing."""
|
|
if not os.path.exists(SCRUB_BUSH_MODEL):
|
|
return None
|
|
before = set(bpy.data.objects)
|
|
try:
|
|
bpy.ops.import_scene.gltf(filepath=SCRUB_BUSH_MODEL)
|
|
except (RuntimeError, AttributeError) as error:
|
|
print("Scrub bush import failed, scrub stays flat:", error)
|
|
return None
|
|
|
|
imported = [obj for obj in set(bpy.data.objects) - before if obj.type == "MESH"]
|
|
if not imported:
|
|
print("Scrub bush import produced no mesh objects, scrub stays flat")
|
|
return None
|
|
|
|
meshes = []
|
|
for index, obj in enumerate(sorted(imported, key=lambda item: item.name)):
|
|
mesh = _bake_imported_mesh(obj, f"ScrubBush_{index:02d}_{obj.name}")
|
|
mesh.calc_loop_triangles()
|
|
for material in mesh.materials:
|
|
if material is None:
|
|
continue
|
|
material_name = material.name.lower()
|
|
if ("leaf" in material_name or "branch" in material_name or
|
|
"shrubbery" in material_name or "hedge" in material_name):
|
|
tint_base_color(
|
|
material, SCRUB_BUSH_LEAF_TINT,
|
|
SCRUB_BUSH_LEAF_TINT_FACTOR)
|
|
elif "trunk" in material_name or "twig" in material_name:
|
|
tint_base_color(
|
|
material, SCRUB_BUSH_TRUNK_TINT,
|
|
SCRUB_BUSH_TRUNK_TINT_FACTOR)
|
|
if getattr(material, "blend_method", "OPAQUE") == "BLEND":
|
|
material.blend_method = "HASHED"
|
|
material.alpha_threshold = 0.45
|
|
material.show_transparent_back = False
|
|
meshes.append(mesh)
|
|
|
|
height, base_z = _measure_meshes(meshes)
|
|
_discard_imported_mesh_objects(imported)
|
|
tris = sum(len(mesh.loop_triangles) for mesh in meshes)
|
|
print(f"Scrub bush loaded: {len(meshes)} mesh(es), {tris} tris shared")
|
|
return {"meshes": meshes, "height": height, "base_z": base_z}
|
|
|
|
|
|
def add_scrub_edge_bushes(name, ring, variant, collection):
|
|
if not variant:
|
|
return 0
|
|
samples = sample_ring_boundary(
|
|
ring, SCRUB_BUSH_SPACING, inset=SCRUB_BUSH_INSET,
|
|
max_samples=SCRUB_BUSH_LIMIT_PER_PATCH)
|
|
if not samples:
|
|
return 0
|
|
|
|
base_scale = SCRUB_BUSH_HEIGHT / variant["height"]
|
|
for index, (x, y, angle, seed) in enumerate(samples):
|
|
jitter = 1.0 + SCRUB_BUSH_SCALE_JITTER * (
|
|
2.0 * ((seed * 0.61803398875) % 1.0) - 1.0)
|
|
scale = base_scale * jitter
|
|
z = 0.065 - variant["base_z"] * scale
|
|
rotation = angle + math.tau * 0.08 * ((seed * 0.41421356237) % 1.0 - 0.5)
|
|
for mesh_index, mesh in enumerate(variant["meshes"]):
|
|
obj = bpy.data.objects.new(
|
|
f"{name}_Bush_{index:03d}_{mesh_index:02d}", mesh)
|
|
obj.location = (x, y, z)
|
|
obj.rotation_euler = (0.0, 0.0, rotation)
|
|
obj.scale = (scale, scale, scale)
|
|
collection.objects.link(obj)
|
|
return len(samples)
|
|
|
|
|
|
def add_scrub_patch(name, ring, ground_material, collection, bush_variant=None):
|
|
if len(ring) < 3:
|
|
return None, 0
|
|
if ring[0] == ring[-1]:
|
|
ring = ring[:-1]
|
|
if len(ring) < 3:
|
|
return None, 0
|
|
|
|
ground = MeshBatch(name, collection, ground_material)
|
|
ground.add_polygon(ring, 0.055)
|
|
obj = ground.finish()
|
|
bush_count = add_scrub_edge_bushes(name, ring, bush_variant, collection)
|
|
|
|
if obj:
|
|
obj["scrub_texture"] = "Poly Haven leafy_grass, scrub-tinted"
|
|
obj["scrub_style"] = (
|
|
"edge bush instances" if bush_count else "flat foliage ground cover"
|
|
)
|
|
obj["scrub_edge_bushes"] = bush_count
|
|
return obj, bush_count
|
|
|
|
|
|
def sample_scrub_interior_trees(ring, way_id):
|
|
if polygon_area(ring) < SCRUB_TREE_MIN_AREA:
|
|
return []
|
|
try:
|
|
seed = int(way_id)
|
|
except (TypeError, ValueError):
|
|
seed = sum(ord(char) for char in str(way_id))
|
|
samples = sample_polygon_interior(
|
|
ring, SCRUB_TREE_SPACING,
|
|
edge_clearance=SCRUB_TREE_EDGE_CLEARANCE,
|
|
max_samples=SCRUB_TREE_LIMIT_PER_PATCH,
|
|
seed=seed)
|
|
min_height, max_height = SCRUB_TREE_HEIGHT_RANGE
|
|
span = max_height - min_height
|
|
trees = []
|
|
for x, y, sample_seed in samples:
|
|
height = min_height + span * ((sample_seed * 0.754877666) % 1.0)
|
|
trees.append((x, y, height))
|
|
return trees
|
|
|
|
|
|
def look_at(obj, target):
|
|
obj.rotation_euler = (Vector(target) - obj.location).to_track_quat("-Z", "Y").to_euler()
|
|
|
|
|
|
def clear_scene():
|
|
bpy.ops.object.select_all(action="SELECT")
|
|
bpy.ops.object.delete(use_global=False)
|
|
for collection in list(bpy.data.collections):
|
|
if collection.name != "Collection" and collection.users == 0:
|
|
bpy.data.collections.remove(collection)
|
|
|
|
|
|
def configure_scene():
|
|
scene = bpy.context.scene
|
|
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())
|