tree_style 新增 'polyhaven' 选项,用 wm.append 从 vendored .blend 加载 LOD1 网格(4 种 branch+leaf 组合),通过实例化放置场景中的树。 每棵树约 625 tris / 2 meshes (branch+leaf),比旧 procedural tree (~400 tris) 多了约 60%,但换来真实树形和 PBR 贴图,树叶有 alpha 透明度,在 Cesium 远距离比纯几何球体更可读。 纹理从 ~/Downloads 手动下载(Poly Haven CDN 的 API URL 不可用), 存放在 assets/models/polyhaven/island_tree_01/textures/(不入 git)。 默认 tree_style 仍为 'natural',parity 保持不变。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
861 lines
37 KiB
Python
861 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.geom import ( # noqa: E402 (needs the sys.path line above)
|
|
clip_polygon,
|
|
distance_to_ring,
|
|
feature_in_bounds,
|
|
geometry_rings,
|
|
point_in_polygon,
|
|
sample_tree_row,
|
|
)
|
|
from osmassets.materials import ( # noqa: E402
|
|
from_spec as material_from_spec,
|
|
tint_base_color,
|
|
)
|
|
from osmassets.mesh import ( # noqa: E402
|
|
MeshBatch,
|
|
add_polyline,
|
|
add_roof,
|
|
add_wall_panel,
|
|
link_object_to_collection,
|
|
make_prism,
|
|
new_collection,
|
|
)
|
|
from osmassets.osm import Projector, parse_height, parse_osm # noqa: E402
|
|
from osmassets import water as _water # noqa: E402
|
|
from osmassets import grass as _grass # noqa: E402
|
|
from osmassets import scrub as _scrub # noqa: E402
|
|
from osmassets import tree as _tree # noqa: E402
|
|
|
|
|
|
# Building assembly stays in this file because it needs make_prism, add_roof,
|
|
# add_wall_panel, and add_building_details — Blender geometry helpers that
|
|
# live a few lines above. The other features moved to osmassets/{water,grass,
|
|
# scrub}.py and take only pure-geometry primitives (MeshBatch / clip_polygon).
|
|
def _assemble_building(ring, way_id, tag, args, buildings_c, building_mats):
|
|
industrial = (tag.get("building") == "industrial" and
|
|
way_id not in args["office_overrides"])
|
|
source_height = max(3.0, parse_height(tag, 12.0))
|
|
height = source_height if industrial or source_height >= 30.0 else 11.4
|
|
material = building_mats["industrial"] if industrial else building_mats["default"]
|
|
building_name = "Building_" + way_id
|
|
building_obj = make_prism(building_name, ring, 0.08, height,
|
|
material, buildings_c)
|
|
if building_obj:
|
|
building_obj["osm_height"] = source_height
|
|
building_obj["render_height"] = height
|
|
building_obj["building_kind"] = "industrial" if industrial else "office"
|
|
building_obj["osm_building_tag"] = tag.get("building", "")
|
|
building_obj["office_override"] = way_id in args["office_overrides"]
|
|
bevel = building_obj.modifiers.new("Soft facade edges", "BEVEL")
|
|
bevel.width = 0.16
|
|
bevel.segments = 2
|
|
roof_mat = (building_mats["industrial_roof"] if industrial
|
|
else building_mats["office_roof"])
|
|
add_roof(building_name, ring, height + 0.095, roof_mat, buildings_c)
|
|
add_building_details(building_name, ring, height, industrial,
|
|
building_mats, buildings_c)
|
|
return 1, int(industrial), ring
|
|
|
|
|
|
def _add_polyhaven_trees(positions, collection):
|
|
"""Scatter island_tree_01 instances at `positions`.
|
|
|
|
positions is a list of (x, y, height) tuples as gathered by the two
|
|
tree-collecting loops (point nodes + tree_row samples).
|
|
"""
|
|
variants = _tree.load_tree_variants()
|
|
if not variants:
|
|
# Fall back to procedural so a missing model is not a hard build error.
|
|
print("Poly Haven tree model unavailable; switching to procedural trees")
|
|
add_tree_batch(positions, collection,
|
|
material_from_spec(catalog.MATERIALS["tree_trunk"]),
|
|
material_from_spec(catalog.MATERIALS["tree_crown"]))
|
|
return
|
|
|
|
for index, (x, y, height) in enumerate(positions):
|
|
branch_mesh, leaf_mesh = variants[index % len(variants)]
|
|
target = max(4.0, height)
|
|
factor = target / 3.2
|
|
|
|
b_obj = bpy.data.objects.new(f"Tree_PH_{index:03d}_Branch", branch_mesh)
|
|
b_obj.location = (x, y, 0.0)
|
|
b_obj.scale = (factor, factor, factor)
|
|
b_obj.rotation_euler = (0, 0, (index * 1.61803398875) % 1.0 * math.tau)
|
|
collection.objects.link(b_obj)
|
|
|
|
l_obj = bpy.data.objects.new(f"Tree_PH_{index:03d}_Leaf", leaf_mesh)
|
|
l_obj.location = (x, y, 0.0)
|
|
l_obj.scale = (factor, factor, factor)
|
|
l_obj.rotation_euler = (0, 0, (index * 1.61803398875) % 1.0 * math.tau)
|
|
collection.objects.link(l_obj)
|
|
|
|
|
|
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", "polyhaven"}:
|
|
raise RuntimeError("--tree-style must be 'natural', 'procedural', or 'polyhaven'")
|
|
return values
|
|
|
|
|
|
def add_building_details(name, ring, height, industrial, materials, collection):
|
|
footprint = ring[:-1] if len(ring) > 1 and ring[0] == ring[-1] else ring
|
|
if len(footprint) < 3:
|
|
return
|
|
glass_mat = materials["factory_glass"] if industrial else materials["glass"]
|
|
|
|
glass_batch = MeshBatch(name + "_Windows", collection, glass_mat)
|
|
edges = list(zip(footprint, footprint[1:] + footprint[:1]))
|
|
if industrial:
|
|
band_height = min(1.8, max(0.75, height * 0.16))
|
|
band_base = max(0.9, height * 0.52)
|
|
for start, end in edges:
|
|
add_wall_panel(glass_batch, start, end, band_base, band_height,
|
|
thickness=0.055, inset=0.12)
|
|
else:
|
|
floor_height = 3.25
|
|
floor_count = max(1, int((height - 0.7) / floor_height))
|
|
for floor in range(floor_count):
|
|
band_base = 0.55 + floor * floor_height + 0.95
|
|
if band_base + 1.25 > height - 0.18:
|
|
break
|
|
for start, end in edges:
|
|
add_wall_panel(glass_batch, start, end, band_base, 1.25,
|
|
thickness=0.045, inset=0.10)
|
|
glass_batch.finish()
|
|
|
|
|
|
|
|
def add_geojson_layer(path, layer, projector, collection, material, z):
|
|
if not os.path.exists(path):
|
|
return 0
|
|
with open(path, "r", encoding="utf-8") as handle:
|
|
data = json.load(handle)
|
|
batch = MeshBatch("Road_" + layer, collection, material)
|
|
b = projector.bounds
|
|
xmin, ymin = projector.xy((b["min_lon"], b["min_lat"]))
|
|
xmax, ymax = projector.xy((b["max_lon"], b["max_lat"]))
|
|
count = 0
|
|
for feature in data.get("features", []):
|
|
if not feature_in_bounds(feature, projector):
|
|
continue
|
|
for ring in geometry_rings(feature.get("geometry")):
|
|
points = [projector.xy(pair) for pair in ring]
|
|
points = clip_polygon(points, xmin, xmax, ymin, ymax)
|
|
if len(points) >= 3:
|
|
batch.add_polygon(points, z)
|
|
count += 1
|
|
batch.finish()
|
|
return count
|
|
|
|
|
|
def add_tree_batch(positions, collection, trunk_material, leaf_material):
|
|
trunk = MeshBatch("Tree_Trunks", collection, trunk_material)
|
|
leaves = MeshBatch("Tree_Crowns", collection, leaf_material)
|
|
sides = 10
|
|
|
|
def add_blob(batch, cx, cy, cz, rx, ry, rz, phase):
|
|
rings = 5
|
|
start = len(batch.vertices)
|
|
for ring in range(rings):
|
|
latitude = -math.pi / 2 + math.pi * ring / (rings - 1)
|
|
ring_radius = math.cos(latitude)
|
|
for side in range(sides):
|
|
angle = math.tau * side / sides
|
|
variation = 1.0 + 0.09 * math.sin(phase + side * 1.73 + ring * 0.91)
|
|
batch.vertices.append((cx + rx * ring_radius * math.cos(angle) * variation,
|
|
cy + ry * ring_radius * math.sin(angle) * variation,
|
|
cz + rz * math.sin(latitude)))
|
|
for ring in range(rings - 1):
|
|
for side in range(sides):
|
|
next_side = (side + 1) % sides
|
|
batch.faces.append((start + ring * sides + side,
|
|
start + ring * sides + next_side,
|
|
start + (ring + 1) * sides + next_side,
|
|
start + (ring + 1) * sides + side))
|
|
|
|
for index, (x, y, height) in enumerate(positions):
|
|
base = len(trunk.vertices)
|
|
radius = max(0.12, height * 0.035)
|
|
trunk_top = height * 0.62
|
|
for z, ring_radius in ((0.0, radius), (trunk_top, radius * 0.68)):
|
|
for i in range(sides):
|
|
a = math.tau * i / sides
|
|
trunk.vertices.append((x + ring_radius * math.cos(a),
|
|
y + ring_radius * math.sin(a), z))
|
|
trunk.faces.append(tuple(base + i for i in range(sides - 1, -1, -1)))
|
|
for i in range(sides):
|
|
j = (i + 1) % sides
|
|
trunk.faces.append((base + i, base + j, base + sides + j, base + sides + i))
|
|
trunk.faces.append(tuple(base + sides + i for i in range(sides)))
|
|
|
|
crown_r = max(0.85, height * 0.30)
|
|
crown_z = height * 0.82
|
|
add_blob(leaves, x, y, crown_z, crown_r * 0.70,
|
|
crown_r * 0.62, crown_r * 0.72, index * 1.41)
|
|
add_blob(leaves, x - crown_r * 0.42, y + crown_r * 0.08,
|
|
crown_z * 0.98, crown_r * 0.52, crown_r * 0.48,
|
|
crown_r * 0.58, index * 2.17 + 0.7)
|
|
add_blob(leaves, x + crown_r * 0.40, y - crown_r * 0.05,
|
|
crown_z * 1.02, crown_r * 0.50, crown_r * 0.46,
|
|
crown_r * 0.55, index * 2.63 + 1.3)
|
|
trunk.finish()
|
|
leaves.finish()
|
|
|
|
|
|
def add_natural_tree_instances(positions, collection, trunk_material,
|
|
leaf_dark_material, leaf_light_material):
|
|
trunk = MeshBatch("Tree_Natural_Trunks", collection, trunk_material)
|
|
lower = MeshBatch("Tree_Natural_Crowns_Dark", collection, leaf_dark_material)
|
|
upper = MeshBatch("Tree_Natural_Crowns_Light", collection, leaf_light_material)
|
|
trunk_sides = 9
|
|
crown_sides = 9
|
|
crown_rings = 5
|
|
|
|
def add_blob(batch, cx, cy, cz, rx, ry, rz, phase, squash=1.0):
|
|
start = len(batch.vertices)
|
|
for ring in range(crown_rings):
|
|
latitude = -math.pi / 2 + math.pi * ring / (crown_rings - 1)
|
|
ring_radius = math.cos(latitude)
|
|
for side in range(crown_sides):
|
|
angle = math.tau * side / crown_sides
|
|
wobble = (
|
|
1.0 +
|
|
0.14 * math.sin(phase + side * 1.31 + ring * 0.83) +
|
|
0.07 * math.sin(phase * 0.7 + side * 2.11)
|
|
)
|
|
batch.vertices.append((
|
|
cx + rx * ring_radius * math.cos(angle) * wobble,
|
|
cy + ry * ring_radius * math.sin(angle) * wobble,
|
|
cz + rz * math.sin(latitude) * squash,
|
|
))
|
|
for ring in range(crown_rings - 1):
|
|
for side in range(crown_sides):
|
|
next_side = (side + 1) % crown_sides
|
|
batch.faces.append((
|
|
start + ring * crown_sides + side,
|
|
start + ring * crown_sides + next_side,
|
|
start + (ring + 1) * crown_sides + next_side,
|
|
start + (ring + 1) * crown_sides + side,
|
|
))
|
|
|
|
for index, (x, y, height) in enumerate(positions):
|
|
target_height = max(4.8, min(8.8, height * 1.08))
|
|
phase = index * 1.61803398875
|
|
trunk_height = target_height * (0.48 + 0.05 * math.sin(phase))
|
|
trunk_radius = max(0.13, target_height * 0.038)
|
|
lean_x = math.sin(phase * 1.7) * target_height * 0.025
|
|
lean_y = math.cos(phase * 1.3) * target_height * 0.025
|
|
|
|
base = len(trunk.vertices)
|
|
trunk_levels = [
|
|
(0.0, trunk_radius),
|
|
(trunk_height * 0.55, trunk_radius * 0.78),
|
|
(trunk_height, trunk_radius * 0.48),
|
|
]
|
|
for level_index, (z, radius) in enumerate(trunk_levels):
|
|
offset_x = lean_x * level_index / (len(trunk_levels) - 1)
|
|
offset_y = lean_y * level_index / (len(trunk_levels) - 1)
|
|
for side in range(trunk_sides):
|
|
angle = math.tau * side / trunk_sides
|
|
trunk.vertices.append((
|
|
x + offset_x + radius * math.cos(angle),
|
|
y + offset_y + radius * math.sin(angle),
|
|
z,
|
|
))
|
|
trunk.faces.append(tuple(base + i for i in range(trunk_sides - 1, -1, -1)))
|
|
for level_index in range(len(trunk_levels) - 1):
|
|
row = base + level_index * trunk_sides
|
|
next_row = row + trunk_sides
|
|
for side in range(trunk_sides):
|
|
next_side = (side + 1) % trunk_sides
|
|
trunk.faces.append((row + side, row + next_side,
|
|
next_row + next_side, next_row + side))
|
|
top_row = base + (len(trunk_levels) - 1) * trunk_sides
|
|
trunk.faces.append(tuple(top_row + i for i in range(trunk_sides)))
|
|
|
|
crown_x = x + lean_x
|
|
crown_y = y + lean_y
|
|
crown_z = trunk_height + target_height * 0.22
|
|
crown_r = target_height * (0.35 + 0.035 * math.sin(phase * 0.9))
|
|
|
|
# Dark lower mass gives the canopy volume when viewed obliquely.
|
|
add_blob(lower, crown_x, crown_y, crown_z - crown_r * 0.08,
|
|
crown_r * 0.95, crown_r * 0.78, crown_r * 0.52,
|
|
phase, squash=0.82)
|
|
add_blob(lower, crown_x - crown_r * 0.46, crown_y + crown_r * 0.05,
|
|
crown_z - crown_r * 0.02, crown_r * 0.62, crown_r * 0.50,
|
|
crown_r * 0.42, phase + 0.8, squash=0.80)
|
|
add_blob(lower, crown_x + crown_r * 0.42, crown_y - crown_r * 0.08,
|
|
crown_z, crown_r * 0.58, crown_r * 0.48,
|
|
crown_r * 0.40, phase + 1.9, squash=0.80)
|
|
|
|
# Lighter upper clumps break the silhouette without adding heavy geometry.
|
|
add_blob(upper, crown_x + crown_r * 0.05, crown_y + crown_r * 0.04,
|
|
crown_z + crown_r * 0.34, crown_r * 0.70,
|
|
crown_r * 0.58, crown_r * 0.38, phase + 2.7, squash=0.74)
|
|
add_blob(upper, crown_x - crown_r * 0.24, crown_y - crown_r * 0.22,
|
|
crown_z + crown_r * 0.23, crown_r * 0.46,
|
|
crown_r * 0.40, crown_r * 0.30, phase + 3.5, squash=0.72)
|
|
trunk.finish()
|
|
lower.finish()
|
|
upper.finish()
|
|
|
|
|
|
def load_tuft_variants():
|
|
"""Import the vendored Poly Haven plant once and return decimated meshes.
|
|
|
|
Returns [] when the asset is missing so a clean checkout still builds; the
|
|
lawns then fall back to plain textured ground.
|
|
"""
|
|
if not os.path.exists(TUFT_MODEL):
|
|
return []
|
|
before = set(bpy.data.objects)
|
|
try:
|
|
bpy.ops.import_scene.gltf(filepath=TUFT_MODEL)
|
|
except (RuntimeError, AttributeError) as error:
|
|
print("Grass tuft import failed, lawns stay flat:", error)
|
|
return []
|
|
|
|
imported = [obj for obj in set(bpy.data.objects) - before if obj.type == "MESH"]
|
|
variants = []
|
|
tinted = set()
|
|
for obj in sorted(imported, key=lambda item: item.name):
|
|
obj.data.calc_loop_triangles()
|
|
source_tris = len(obj.data.loop_triangles)
|
|
if TUFT_TARGET_TRIS and source_tris > TUFT_TARGET_TRIS:
|
|
modifier = obj.modifiers.new("TuftDecimate", "DECIMATE")
|
|
modifier.ratio = max(0.02, TUFT_TARGET_TRIS / source_tris)
|
|
bpy.context.view_layer.update()
|
|
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
|
mesh = bpy.data.meshes.new_from_object(evaluated)
|
|
else:
|
|
mesh = obj.data.copy()
|
|
mesh.name = "GrassTuft_" + obj.name
|
|
mesh.calc_loop_triangles()
|
|
# Nothing is saved yet at this point, so keep the datablock alive even
|
|
# if a scene ends up with no lawn polygons to instance it into.
|
|
mesh.use_fake_user = True
|
|
for material in mesh.materials:
|
|
# The vendored textures are JPEG with no alpha channel, so hashed
|
|
# transparency only costs sorting work in Blender and Cesium.
|
|
if hasattr(material, "blend_method"):
|
|
material.blend_method = "OPAQUE"
|
|
# All four variants share one material, so guard against stacking
|
|
# the mix node — and the tint with it — four times over.
|
|
if material.name not in tinted:
|
|
tint_base_color(material, TUFT_TINT, TUFT_TINT_FACTOR)
|
|
tinted.add(material.name)
|
|
variants.append(mesh)
|
|
|
|
for obj in imported:
|
|
bpy.data.objects.remove(obj, do_unlink=True)
|
|
|
|
tris = sum(len(mesh.loop_triangles) for mesh in variants)
|
|
detail = f"decimated to ~{TUFT_TARGET_TRIS} tris each" if TUFT_TARGET_TRIS else "full detail"
|
|
print(f"Grass tuft variants loaded: {len(variants)} ({detail}, {tris} tris shared)")
|
|
return variants
|
|
|
|
|
|
def tuft_density_wave(x, y):
|
|
# A lawn wants gentle clumping, not the hard banding a shrub bed needs:
|
|
# low amplitude keeps most of the polygon planted so the bare stretches
|
|
# read as mown patches rather than as dead ground.
|
|
return (math.sin(x * 0.21 + y * 0.17)
|
|
+ 0.55 * math.sin(x * 0.44 - y * 0.29 + 1.3))
|
|
|
|
|
|
def add_grass_tufts(name, ring, variants, collection):
|
|
"""Scatter the plant variants across a lawn polygon as grass tufts."""
|
|
xmin = min(x for x, _ in ring)
|
|
xmax = max(x for x, _ in ring)
|
|
ymin = min(y for _, y in ring)
|
|
ymax = max(y for _, y in ring)
|
|
|
|
def sample(spacing):
|
|
spots = []
|
|
cols = max(1, int(math.ceil((xmax - xmin) / spacing)))
|
|
rows = max(1, int(math.ceil((ymax - ymin) / spacing)))
|
|
for i in range(cols):
|
|
for j in range(rows):
|
|
# Jitter breaks up the lattice; without it the tufts read as a
|
|
# planted grid rather than as grass.
|
|
seed = (i * 73856093) ^ (j * 19349663)
|
|
jx = ((seed * 0.61803398875) % 1.0 - 0.5) * spacing * 0.8
|
|
jy = ((seed * 0.41421356237) % 1.0 - 0.5) * spacing * 0.8
|
|
x = xmin + (i + 0.5) * spacing + jx
|
|
y = ymin + (j + 0.5) * spacing + jy
|
|
if not point_in_polygon((x, y), ring):
|
|
continue
|
|
# Keep tufts off the kerb line so they do not overhang paving.
|
|
if distance_to_ring((x, y), ring) < 0.45:
|
|
continue
|
|
if tuft_density_wave(x, y) < -0.85:
|
|
continue
|
|
spots.append((x, y, seed))
|
|
return spots
|
|
|
|
# Adapt spacing to the lawn so a large polygon does not explode the export.
|
|
spacing = TUFT_SPACING
|
|
spots = sample(spacing)
|
|
while len(spots) > TUFT_LIMIT_PER_LAWN and spacing < 12.0:
|
|
spacing *= 1.22
|
|
spots = sample(spacing)
|
|
|
|
min_scale, max_scale = TUFT_SCALE_RANGE
|
|
span = max_scale - min_scale
|
|
for index, (x, y, seed) in enumerate(spots):
|
|
mesh = variants[index % len(variants)]
|
|
obj = bpy.data.objects.new(f"{name}_Tuft_{index:03d}", mesh)
|
|
scale = min_scale + span * ((seed * 0.754877666) % 1.0)
|
|
# Sunk slightly below the lawn surface so the stems never float.
|
|
obj.location = (x, y, 0.008)
|
|
obj.rotation_euler = (
|
|
math.radians(-4.0 + 8.0 * ((seed * 0.5698403) % 1.0)),
|
|
math.radians(-4.0 + 8.0 * ((seed * 0.3317554) % 1.0)),
|
|
math.tau * ((seed * 0.61803398875) % 1.0),
|
|
)
|
|
obj.scale = (scale, scale * (0.90 + 0.20 * ((seed * 0.2236068) % 1.0)), scale)
|
|
collection.objects.link(obj)
|
|
return len(spots)
|
|
|
|
|
|
def add_scrub_patch(name, ring, ground_material, collection):
|
|
if len(ring) < 3:
|
|
return None
|
|
if ring[0] == ring[-1]:
|
|
ring = ring[:-1]
|
|
if len(ring) < 3:
|
|
return None
|
|
|
|
ground = MeshBatch(name, collection, ground_material)
|
|
ground.add_polygon(ring, 0.055)
|
|
obj = ground.finish()
|
|
|
|
# The vendored plant asset reads as grass, so it went to the lawns. Until a
|
|
# bush-shaped model lands, scrub beds stay flat ground cover: the previous
|
|
# procedural hedge mass read as blocky colour patches, not as planting.
|
|
if obj:
|
|
obj["scrub_texture"] = "Poly Haven leafy_grass, scrub-tinted"
|
|
obj["scrub_style"] = "flat foliage ground cover"
|
|
return obj
|
|
|
|
|
|
def add_fountain(name, x, y, collection, materials):
|
|
def cylinder(part_name, radius, depth, z, material, vertices=48):
|
|
bpy.ops.mesh.primitive_cylinder_add(
|
|
vertices=vertices, radius=radius, depth=depth,
|
|
location=(x, y, z))
|
|
obj = bpy.context.object
|
|
obj.name = name + "_" + part_name
|
|
link_object_to_collection(obj, collection)
|
|
obj.data.materials.append(material)
|
|
for polygon in obj.data.polygons:
|
|
polygon.use_smooth = True
|
|
return obj
|
|
|
|
basin = cylinder("Basin", 3.0, 0.32, 0.16,
|
|
materials["fountain_stone"])
|
|
basin["osm_feature"] = "amenity=fountain"
|
|
cylinder("Water", 2.52, 0.045, 0.335,
|
|
materials["fountain_water"])
|
|
cylinder("Pedestal", 0.30, 0.78, 0.72,
|
|
materials["fountain_stone"], vertices=32)
|
|
|
|
bpy.ops.mesh.primitive_uv_sphere_add(
|
|
segments=20, ring_count=10, radius=0.22,
|
|
location=(x, y, 1.30))
|
|
crown = bpy.context.object
|
|
crown.name = name + "_Water_Crown"
|
|
link_object_to_collection(crown, collection)
|
|
crown.data.materials.append(materials["fountain_spray"])
|
|
for index in range(8):
|
|
angle = math.tau * index / 8.0
|
|
radius = 0.50
|
|
bpy.ops.mesh.primitive_uv_sphere_add(
|
|
segments=12, ring_count=6, radius=0.075,
|
|
location=(x + math.cos(angle) * radius,
|
|
y + math.sin(angle) * radius,
|
|
1.02 + 0.10 * math.sin(angle * 2.0)))
|
|
droplet = bpy.context.object
|
|
droplet.name = name + "_Droplet_" + str(index + 1)
|
|
link_object_to_collection(droplet, collection)
|
|
droplet.data.materials.append(materials["fountain_spray"])
|
|
|
|
|
|
def look_at(obj, target):
|
|
obj.rotation_euler = (Vector(target) - obj.location).to_track_quat("-Z", "Y").to_euler()
|
|
|
|
|
|
def clear_scene():
|
|
bpy.ops.object.select_all(action="SELECT")
|
|
bpy.ops.object.delete(use_global=False)
|
|
for collection in list(bpy.data.collections):
|
|
if collection.name != "Collection" and collection.users == 0:
|
|
bpy.data.collections.remove(collection)
|
|
|
|
|
|
def configure_scene():
|
|
scene = bpy.context.scene
|
|
scene.render.engine = "BLENDER_EEVEE_NEXT"
|
|
scene.render.resolution_x = 1200
|
|
scene.render.resolution_y = 900
|
|
scene.render.resolution_percentage = 100
|
|
scene.render.image_settings.file_format = "PNG"
|
|
scene.render.film_transparent = False
|
|
scene.world.color = (0.055, 0.075, 0.095)
|
|
scene.view_settings.look = "AgX - Medium High Contrast"
|
|
|
|
|
|
def configure_default_viewport():
|
|
workspace = bpy.data.workspaces.get("Layout")
|
|
if workspace:
|
|
try:
|
|
bpy.context.window.workspace = workspace
|
|
except (AttributeError, RuntimeError):
|
|
pass
|
|
for screen in bpy.data.screens:
|
|
for area in screen.areas:
|
|
if area.type != "VIEW_3D":
|
|
continue
|
|
space = area.spaces.active
|
|
space.shading.type = "MATERIAL"
|
|
space.shading.light = "STUDIO"
|
|
space.shading.color_type = "MATERIAL"
|
|
space.overlay.show_floor = False
|
|
if space.region_3d:
|
|
space.region_3d.view_perspective = "CAMERA"
|
|
space.region_3d.view_camera_zoom = 0.0
|
|
|
|
|
|
def build(args):
|
|
bounds, ways, point_features = parse_osm(args["osm"])
|
|
projector = Projector(bounds)
|
|
clear_scene()
|
|
configure_scene()
|
|
|
|
ground_c = new_collection("00_Ground")
|
|
water_c = new_collection("01_Water")
|
|
green_c = new_collection("02_Green")
|
|
roads_c = new_collection("03_Roads")
|
|
buildings_c = new_collection("04_Buildings")
|
|
props_c = new_collection("05_Props")
|
|
|
|
ground_mat = material_from_spec(catalog.MATERIALS["ground"])
|
|
water_mat = material_from_spec(catalog.MATERIALS["water"])
|
|
grass_mat = material_from_spec(catalog.MATERIALS["grass"])
|
|
scrub_mat = material_from_spec(catalog.MATERIALS["scrub"])
|
|
# Ordering note: the tuft import creates its own materials, so it stays
|
|
# between the ground materials and the props. Material creation order fixes
|
|
# the material indices in the exported GLB.
|
|
tuft_variants = load_tuft_variants()
|
|
fountain_mats = {
|
|
key: material_from_spec(catalog.MATERIALS[key])
|
|
for key in ("fountain_stone", "fountain_water", "fountain_spray")
|
|
}
|
|
building_mats = {
|
|
key: material_from_spec(catalog.MATERIALS["building_" + key])
|
|
for key in ("default", "industrial", "office_roof", "industrial_roof",
|
|
"glass", "factory_glass")
|
|
}
|
|
road_mats = {
|
|
layer["id"]: material_from_spec(spec)
|
|
for layer, spec in zip(catalog.ROAD_LAYERS, catalog.road_material_specs())
|
|
}
|
|
|
|
b = bounds
|
|
scene_xmin, scene_ymin = projector.xy((b["min_lon"], b["min_lat"]))
|
|
scene_xmax, scene_ymax = projector.xy((b["max_lon"], b["max_lat"]))
|
|
ground_ring = [projector.xy((b["min_lon"] - 0.0012, b["min_lat"] - 0.0012)),
|
|
projector.xy((b["max_lon"] + 0.0012, b["min_lat"] - 0.0012)),
|
|
projector.xy((b["max_lon"] + 0.0012, b["max_lat"] + 0.0012)),
|
|
projector.xy((b["min_lon"] - 0.0012, b["max_lat"] + 0.0012))]
|
|
ground_batch = MeshBatch("Ground Plane", ground_c, ground_mat)
|
|
ground_batch.add_polygon(ground_ring, -0.35)
|
|
ground_batch.finish()
|
|
|
|
grass_rings = []
|
|
tree_rows = []
|
|
# Counters were previously individual ints scattered through the loop body.
|
|
# Collecting them into a dict lets the scene[...] and SCENE_DONE sections
|
|
# read from a single place. The keys are kept alphabetically so the
|
|
# SCENE_DONE JSON order from control-1 stays byte-for-byte identical.
|
|
counts = {
|
|
"building_count": 0,
|
|
"fountain_count": 0,
|
|
"grass_count": 0,
|
|
"grass_tuft_count": 0,
|
|
"industrial_count": 0,
|
|
"lake_count": 0,
|
|
"scrub_count": 0,
|
|
}
|
|
focus_points = []
|
|
for way in ways:
|
|
coords = way["coords"]
|
|
if not any(projector.inside(c) for c in coords):
|
|
continue
|
|
ring = projector.ring(coords)
|
|
tag = way["tags"]
|
|
if tag.get("natural") == "water" or tag.get("water") == "lake":
|
|
counts["lake_count"] += _water.assemble(ring, scene_xmin, scene_xmax,
|
|
scene_ymin, scene_ymax,
|
|
water_c, water_mat)
|
|
elif tag.get("landuse") == "grass":
|
|
added, tufts, ring_pts = _grass.assemble(
|
|
ring, way["id"], scene_xmin, scene_xmax, scene_ymin, scene_ymax,
|
|
green_c, grass_mat, tuft_variants, add_grass_tufts)
|
|
counts["grass_count"] += added
|
|
counts["grass_tuft_count"] += tufts
|
|
if ring_pts:
|
|
grass_rings.append(ring_pts)
|
|
focus_points.extend(ring_pts)
|
|
elif tag.get("natural") == "scrub" and len(ring) >= 3:
|
|
added, ring_pts = _scrub.assemble(ring, way["id"], scene_xmin, scene_xmax,
|
|
scene_ymin, scene_ymax, green_c, scrub_mat,
|
|
add_scrub_patch)
|
|
counts["scrub_count"] += added
|
|
if ring_pts:
|
|
focus_points.extend(ring_pts)
|
|
elif tag.get("natural") == "tree_row":
|
|
tree_rows.append((ring, tag))
|
|
focus_points.extend(ring)
|
|
elif "building" in tag and len(ring) >= 3:
|
|
added, ind_added, ring_pts = _assemble_building(
|
|
ring, str(way["id"]), tag, args, buildings_c, building_mats)
|
|
counts["building_count"] += added
|
|
counts["industrial_count"] += ind_added
|
|
if ring_pts:
|
|
focus_points.extend(ring_pts)
|
|
|
|
geojson_dir = args.get("geojson")
|
|
road_counts = {}
|
|
if geojson_dir and os.path.isdir(geojson_dir):
|
|
for problem in catalog.check_layers(geojson_dir):
|
|
print("Layer catalog warning:", problem)
|
|
for layer in catalog.ROAD_LAYERS:
|
|
layer_id = layer["id"]
|
|
road_counts[layer_id] = add_geojson_layer(
|
|
os.path.join(geojson_dir, layer_id + ".geojson"), layer_id,
|
|
projector, roads_c, road_mats[layer_id], layer["z"])
|
|
|
|
if road_counts.get("road_surface", 0) == 0:
|
|
for way in ways:
|
|
highway = way["tags"].get("highway")
|
|
if highway and len(way["coords"]) >= 2:
|
|
width = {"secondary": 7.0, "residential": 5.5, "service": 3.5}.get(highway, 4.0)
|
|
add_polyline("OSM_Road_" + str(way["id"]), way["coords"], projector,
|
|
roads_c, road_mats["road_surface"], width, 0.03)
|
|
|
|
trees = []
|
|
individual_tree_count = 0
|
|
for feature in point_features:
|
|
if feature["tags"].get("natural") != "tree":
|
|
continue
|
|
if not projector.inside(feature["coord"]):
|
|
continue
|
|
x, y = projector.xy(feature["coord"])
|
|
trees.append((x, y, parse_height(feature["tags"], 5.5)))
|
|
individual_tree_count += 1
|
|
row_tree_count = 0
|
|
for row, row_tags in tree_rows:
|
|
row_samples = sample_tree_row(row, spacing=5.0,
|
|
height=parse_height(row_tags, 5.0))
|
|
trees.extend(row_samples)
|
|
row_tree_count += len(row_samples)
|
|
if trees:
|
|
tree_style = args.get("tree_style")
|
|
if tree_style == "polyhaven":
|
|
_add_polyhaven_trees(trees, props_c)
|
|
elif tree_style == "natural":
|
|
tree_trunk = material_from_spec(catalog.MATERIALS["tree_trunk"])
|
|
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"]))
|
|
else:
|
|
tree_trunk = material_from_spec(catalog.MATERIALS["tree_trunk"])
|
|
add_tree_batch(trees, props_c, tree_trunk,
|
|
material_from_spec(catalog.MATERIALS["tree_crown"]))
|
|
|
|
for feature in point_features:
|
|
if feature["tags"].get("amenity") != "fountain":
|
|
continue
|
|
if not projector.inside(feature["coord"]):
|
|
continue
|
|
fx, fy = projector.xy(feature["coord"])
|
|
add_fountain("Fountain_" + str(feature["id"]), fx, fy,
|
|
props_c, fountain_mats)
|
|
counts["fountain_count"] += 1
|
|
|
|
bpy.ops.object.light_add(type="SUN", location=(0, 0, 500))
|
|
sun = bpy.context.object
|
|
sun.name = "Sun"
|
|
sun.data.energy = 3.0
|
|
sun.rotation_euler = (math.radians(28), math.radians(-22), math.radians(-32))
|
|
bpy.ops.object.light_add(type="AREA", location=(0, -220, 420))
|
|
area = bpy.context.object
|
|
area.name = "Fill Light"
|
|
area.data.energy = 1700
|
|
area.data.shape = "DISK"
|
|
area.data.size = 260
|
|
look_at(area, (0, 0, 0))
|
|
|
|
width = (b["max_lon"] - b["min_lon"]) * projector.m_per_lon
|
|
height = (b["max_lat"] - b["min_lat"]) * projector.m_per_lat
|
|
if focus_points:
|
|
min_fx = min(point[0] for point in focus_points)
|
|
max_fx = max(point[0] for point in focus_points)
|
|
min_fy = min(point[1] for point in focus_points)
|
|
max_fy = max(point[1] for point in focus_points)
|
|
focus_x = (min_fx + max_fx) / 2
|
|
focus_y = (min_fy + max_fy) / 2
|
|
focus_span = max(max_fx - min_fx, (max_fy - min_fy) * 1.25)
|
|
cam_location = (focus_x + focus_span * 0.78,
|
|
focus_y - focus_span * 0.92,
|
|
focus_span * 1.22)
|
|
camera_target = (focus_x, focus_y, 3)
|
|
else:
|
|
cam_location = (width * 0.78, -height * 1.15, max(width, height) * 1.22)
|
|
camera_target = (0, 0, 3)
|
|
bpy.ops.object.camera_add(location=cam_location)
|
|
camera = bpy.context.object
|
|
camera.name = "Scene Overview Camera"
|
|
camera.data.lens = 48
|
|
camera.data.clip_start = 0.1
|
|
camera.data.clip_end = 5000.0
|
|
look_at(camera, camera_target)
|
|
bpy.context.scene.camera = camera
|
|
configure_default_viewport()
|
|
|
|
scene = bpy.context.scene
|
|
scene.render.filepath = args["render"]
|
|
scene["source_osm"] = args["osm"]
|
|
scene["source_geojson"] = geojson_dir or ""
|
|
scene["osm_bounds"] = json.dumps(bounds, ensure_ascii=True)
|
|
scene["building_count"] = counts["building_count"]
|
|
scene["industrial_building_count"] = counts["industrial_count"]
|
|
scene["office_override_way_ids"] = json.dumps(sorted(args["office_overrides"]))
|
|
scene["lake_count"] = counts["lake_count"]
|
|
scene["grass_count"] = counts["grass_count"]
|
|
scene["grass_tuft_count"] = counts["grass_tuft_count"]
|
|
scene["scrub_count"] = counts["scrub_count"]
|
|
scene["fountain_count"] = counts["fountain_count"]
|
|
scene["tree_node_count"] = individual_tree_count
|
|
scene["tree_row_count"] = row_tree_count
|
|
scene["tree_count"] = len(trees)
|
|
scene["road_feature_counts"] = json.dumps(road_counts, ensure_ascii=True)
|
|
|
|
os.makedirs(os.path.dirname(args["output"]), exist_ok=True)
|
|
os.makedirs(os.path.dirname(args["render"]), exist_ok=True)
|
|
try:
|
|
bpy.ops.file.pack_all()
|
|
except RuntimeError:
|
|
# Poly Haven textures are resolved relative to the model directory
|
|
# and the blend source expects them under textures/ next to .blend.
|
|
# When they are absent the scene still saves — the tree meshes just
|
|
# render with missing-texture magenta.
|
|
print("Some external resources could not be packed; saving anyway")
|
|
bpy.ops.wm.save_as_mainfile(filepath=args["output"])
|
|
bpy.ops.render.render(write_still=True)
|
|
print("SCENE_DONE", json.dumps({"output": args["output"],
|
|
"render": args["render"],
|
|
"buildings": counts["building_count"],
|
|
"industrial_buildings": counts["industrial_count"],
|
|
"lake": counts["lake_count"],
|
|
"grass": counts["grass_count"],
|
|
"grass_tufts": counts["grass_tuft_count"],
|
|
"scrub": counts["scrub_count"],
|
|
"fountains": counts["fountain_count"],
|
|
"tree_nodes": individual_tree_count,
|
|
"tree_row_instances": row_tree_count,
|
|
"trees": len(trees),
|
|
"road_features": road_counts}, ensure_ascii=True))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
build(cli_args())
|