1110 lines
45 KiB
Python
1110 lines
45 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 xml.etree.ElementTree as ET
|
|
from collections import defaultdict
|
|
|
|
import bpy
|
|
from mathutils import Vector
|
|
|
|
|
|
TEXTURE_ROOT = os.path.abspath(os.path.join(
|
|
os.path.dirname(__file__), "..", "assets", "textures", "polyhaven"
|
|
))
|
|
|
|
|
|
def cli_args():
|
|
values = {"osm": None, "geojson": None, "output": None, "render": None,
|
|
"office_overrides": "", "tree_style": "natural"}
|
|
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
|
i = 0
|
|
while i < len(argv):
|
|
if argv[i].startswith("--") and i + 1 < len(argv):
|
|
values[argv[i][2:].replace("-", "_")] = argv[i + 1]
|
|
i += 2
|
|
else:
|
|
i += 1
|
|
if not values.get("osm"):
|
|
raise RuntimeError("--osm is required; --geojson is optional")
|
|
if not values.get("output"):
|
|
raise RuntimeError("--output is required")
|
|
if not values.get("render"):
|
|
raise RuntimeError("--render is required")
|
|
if values.get("office_overrides"):
|
|
try:
|
|
values["office_overrides"] = set(
|
|
w.strip() for w in values["office_overrides"].split(",") if w.strip()
|
|
)
|
|
except Exception:
|
|
values["office_overrides"] = set()
|
|
else:
|
|
values["office_overrides"] = set()
|
|
if values.get("tree_style") not in {"natural", "procedural"}:
|
|
raise RuntimeError("--tree-style must be 'natural' or 'procedural'")
|
|
return values
|
|
|
|
|
|
def tags(element):
|
|
return {t.attrib.get("k", ""): t.attrib.get("v", "")
|
|
for t in element.findall("tag")}
|
|
|
|
|
|
def parse_osm(path):
|
|
root = ET.parse(path).getroot()
|
|
bounds_node = root.find("bounds")
|
|
if bounds_node is None:
|
|
raise RuntimeError("OSM file does not contain a bounds element")
|
|
bounds = {"min_lon": float(bounds_node.attrib["minlon"]),
|
|
"min_lat": float(bounds_node.attrib["minlat"]),
|
|
"max_lon": float(bounds_node.attrib["maxlon"]),
|
|
"max_lat": float(bounds_node.attrib["maxlat"])}
|
|
|
|
nodes = {}
|
|
point_features = []
|
|
for node in root.findall("node"):
|
|
try:
|
|
node_id = int(node.attrib["id"])
|
|
coord = (float(node.attrib["lon"]), float(node.attrib["lat"]))
|
|
node_tags = tags(node)
|
|
nodes[node_id] = coord
|
|
if node_tags:
|
|
point_features.append({"id": node.attrib.get("id", ""),
|
|
"coord": coord, "tags": node_tags})
|
|
except (KeyError, ValueError):
|
|
continue
|
|
|
|
ways = []
|
|
for way in root.findall("way"):
|
|
if way.attrib.get("action") == "delete":
|
|
continue
|
|
refs = []
|
|
for ref in way.findall("nd"):
|
|
try:
|
|
refs.append(int(ref.attrib["ref"]))
|
|
except (KeyError, ValueError):
|
|
pass
|
|
coords = [nodes[r] for r in refs if r in nodes]
|
|
if len(coords) >= 2:
|
|
ways.append({"id": way.attrib.get("id", ""),
|
|
"coords": coords, "tags": tags(way)})
|
|
return bounds, ways, point_features
|
|
|
|
|
|
class Projector:
|
|
def __init__(self, bounds):
|
|
self.bounds = bounds
|
|
self.lon0 = (bounds["min_lon"] + bounds["max_lon"]) / 2
|
|
self.lat0 = (bounds["min_lat"] + bounds["max_lat"]) / 2
|
|
self.m_per_lat = 111320.0
|
|
self.m_per_lon = 111320.0 * math.cos(math.radians(self.lat0))
|
|
|
|
def xy(self, lon_lat):
|
|
lon, lat = lon_lat
|
|
return ((lon - self.lon0) * self.m_per_lon,
|
|
(lat - self.lat0) * self.m_per_lat)
|
|
|
|
def inside(self, lon_lat, pad=0.00035):
|
|
lon, lat = lon_lat
|
|
b = self.bounds
|
|
return (b["min_lon"] - pad <= lon <= b["max_lon"] + pad and
|
|
b["min_lat"] - pad <= lat <= b["max_lat"] + pad)
|
|
|
|
def ring(self, coords):
|
|
return [self.xy(c) for c in coords]
|
|
|
|
|
|
def new_collection(name):
|
|
collection = bpy.data.collections.new(name)
|
|
bpy.context.scene.collection.children.link(collection)
|
|
return collection
|
|
|
|
|
|
def principled_bsdf(material):
|
|
if not material.use_nodes:
|
|
return None
|
|
for node in material.node_tree.nodes:
|
|
if node.type == "BSDF_PRINCIPLED":
|
|
return node
|
|
return None
|
|
|
|
|
|
def make_material(name, color, roughness=0.8, metallic=0.0):
|
|
material = bpy.data.materials.get(name) or bpy.data.materials.new(name)
|
|
material.diffuse_color = (*color, 1.0)
|
|
material.use_nodes = True
|
|
bsdf = principled_bsdf(material)
|
|
if bsdf:
|
|
bsdf.inputs["Base Color"].default_value = (*color, 1.0)
|
|
bsdf.inputs["Roughness"].default_value = roughness
|
|
bsdf.inputs["Metallic"].default_value = metallic
|
|
return material
|
|
|
|
|
|
def add_procedural_surface(material, colors, scale=2.0, detail=2.0, bump_strength=0.08):
|
|
nodes = material.node_tree.nodes
|
|
links = material.node_tree.links
|
|
bsdf = principled_bsdf(material)
|
|
if not bsdf:
|
|
return
|
|
noise = nodes.new("ShaderNodeTexNoise")
|
|
noise.inputs["Scale"].default_value = scale
|
|
noise.inputs["Detail"].default_value = detail
|
|
noise.inputs["Roughness"].default_value = 0.65
|
|
texcoord = nodes.new("ShaderNodeTexCoord")
|
|
ramp = nodes.new("ShaderNodeValToRGB")
|
|
ramp.color_ramp.elements[0].color = (*colors[0], 1.0)
|
|
ramp.color_ramp.elements[1].color = (*colors[1], 1.0)
|
|
bump = nodes.new("ShaderNodeBump")
|
|
bump.inputs["Strength"].default_value = bump_strength
|
|
bump.inputs["Distance"].default_value = 0.12
|
|
links.new(texcoord.outputs["Generated"], noise.inputs["Vector"])
|
|
links.new(noise.outputs["Fac"], ramp.inputs["Fac"])
|
|
links.new(ramp.outputs["Color"], bsdf.inputs["Base Color"])
|
|
links.new(noise.outputs["Fac"], bump.inputs["Height"])
|
|
links.new(bump.outputs["Normal"], bsdf.inputs["Normal"])
|
|
|
|
|
|
def make_textured_material(name, diffuse_file, normal_file, roughness,
|
|
scale, normal_is_bump=False, metallic=0.0,
|
|
tint=None, tint_factor=0.0):
|
|
diffuse_path = os.path.join(TEXTURE_ROOT, diffuse_file)
|
|
normal_path = os.path.join(TEXTURE_ROOT, normal_file)
|
|
if not os.path.exists(diffuse_path) or not os.path.exists(normal_path):
|
|
return make_material(name, (0.5, 0.5, 0.5), roughness, metallic)
|
|
|
|
material = make_material(name, (0.5, 0.5, 0.5), roughness, metallic)
|
|
nodes = material.node_tree.nodes
|
|
links = material.node_tree.links
|
|
bsdf = principled_bsdf(material)
|
|
if not bsdf:
|
|
return material
|
|
texcoord = nodes.new("ShaderNodeTexCoord")
|
|
mapping = nodes.new("ShaderNodeMapping")
|
|
mapping.inputs["Scale"].default_value = (scale, scale, scale)
|
|
diffuse = nodes.new("ShaderNodeTexImage")
|
|
diffuse.image = bpy.data.images.load(diffuse_path, check_existing=True)
|
|
diffuse.extension = "REPEAT"
|
|
normal = nodes.new("ShaderNodeTexImage")
|
|
normal.image = bpy.data.images.load(normal_path, check_existing=True)
|
|
normal.image.colorspace_settings.name = "Non-Color"
|
|
normal.extension = "REPEAT"
|
|
links.new(texcoord.outputs["Generated"], mapping.inputs["Vector"])
|
|
links.new(mapping.outputs["Vector"], diffuse.inputs["Vector"])
|
|
links.new(mapping.outputs["Vector"], normal.inputs["Vector"])
|
|
if tint and tint_factor > 0.0:
|
|
tint_node = nodes.new("ShaderNodeRGB")
|
|
tint_node.outputs["Color"].default_value = (*tint, 1.0)
|
|
mix = nodes.new("ShaderNodeMixRGB")
|
|
mix.blend_type = "MIX"
|
|
mix.inputs["Fac"].default_value = tint_factor
|
|
links.new(diffuse.outputs["Color"], mix.inputs[1])
|
|
links.new(tint_node.outputs["Color"], mix.inputs[2])
|
|
links.new(mix.outputs["Color"], bsdf.inputs["Base Color"])
|
|
else:
|
|
links.new(diffuse.outputs["Color"], bsdf.inputs["Base Color"])
|
|
if normal_is_bump:
|
|
bump = nodes.new("ShaderNodeBump")
|
|
bump.inputs["Strength"].default_value = 0.22
|
|
bump.inputs["Distance"].default_value = 0.12
|
|
links.new(normal.outputs["Color"], bump.inputs["Height"])
|
|
links.new(bump.outputs["Normal"], bsdf.inputs["Normal"])
|
|
else:
|
|
normal_map = nodes.new("ShaderNodeNormalMap")
|
|
normal_map.inputs["Strength"].default_value = 0.52
|
|
links.new(normal.outputs["Color"], normal_map.inputs["Color"])
|
|
links.new(normal_map.outputs["Normal"], bsdf.inputs["Normal"])
|
|
return material
|
|
|
|
|
|
class MeshBatch:
|
|
def __init__(self, name, collection, material):
|
|
self.name = name
|
|
self.collection = collection
|
|
self.material = material
|
|
self.vertices = []
|
|
self.faces = []
|
|
|
|
def add_polygon(self, ring, z):
|
|
if len(ring) < 3:
|
|
return
|
|
if ring[0] == ring[-1]:
|
|
ring = ring[:-1]
|
|
if len(ring) < 3:
|
|
return
|
|
start = len(self.vertices)
|
|
self.vertices.extend((x, y, z) for x, y in ring)
|
|
self.faces.append(tuple(range(start, start + len(ring))))
|
|
|
|
def add_prism(self, ring, base, height):
|
|
if len(ring) < 3:
|
|
return
|
|
if ring[0] == ring[-1]:
|
|
ring = ring[:-1]
|
|
if len(ring) < 3:
|
|
return
|
|
start = len(self.vertices)
|
|
self.vertices.extend((x, y, base) for x, y in ring)
|
|
self.vertices.extend((x, y, base + height) for x, y in ring)
|
|
n = len(ring)
|
|
self.faces.append(tuple(range(start, start + n)))
|
|
self.faces.append(tuple(range(start + n, start + 2 * n)))
|
|
for i in range(n):
|
|
j = (i + 1) % n
|
|
self.faces.append((start + i, start + j, start + n + j, start + n + i))
|
|
|
|
def finish(self):
|
|
if not self.vertices:
|
|
return None
|
|
mesh = bpy.data.meshes.new(self.name + "Mesh")
|
|
mesh.from_pydata(self.vertices, [], self.faces)
|
|
mesh.materials.append(self.material)
|
|
if self.name.startswith("Tree_") or self.name.startswith("Scrub_"):
|
|
for polygon in mesh.polygons:
|
|
polygon.use_smooth = True
|
|
mesh.update()
|
|
obj = bpy.data.objects.new(self.name, mesh)
|
|
self.collection.objects.link(obj)
|
|
return obj
|
|
|
|
|
|
def make_prism(name, ring, base, height, material, collection):
|
|
batch = MeshBatch(name, collection, material)
|
|
batch.add_prism(ring, base, height)
|
|
return batch.finish()
|
|
|
|
|
|
def add_roof(name, ring, z, material, collection):
|
|
batch = MeshBatch(name + "_Roof", collection, material)
|
|
batch.add_polygon(ring, z)
|
|
return batch.finish()
|
|
|
|
|
|
def add_wall_panel(batch, start, end, base, height, thickness=0.045, inset=0.08):
|
|
dx, dy = end[0] - start[0], end[1] - start[1]
|
|
length = math.hypot(dx, dy)
|
|
if length < 3.0:
|
|
return
|
|
ux, uy = dx / length, dy / length
|
|
a = (start[0] + dx * inset, start[1] + dy * inset)
|
|
b = (end[0] - dx * inset, end[1] - dy * inset)
|
|
nx, ny = -uy * thickness / 2, ux * thickness / 2
|
|
panel = [(a[0] + nx, a[1] + ny), (b[0] + nx, b[1] + ny),
|
|
(b[0] - nx, b[1] - ny), (a[0] - nx, a[1] - ny)]
|
|
batch.add_prism(panel, base, height)
|
|
|
|
|
|
def add_building_details(name, ring, height, industrial, materials, collection):
|
|
footprint = ring[:-1] if len(ring) > 1 and ring[0] == ring[-1] else ring
|
|
if len(footprint) < 3:
|
|
return
|
|
glass_mat = materials["factory_glass"] if industrial else materials["glass"]
|
|
|
|
glass_batch = MeshBatch(name + "_Windows", collection, glass_mat)
|
|
edges = list(zip(footprint, footprint[1:] + footprint[:1]))
|
|
if industrial:
|
|
band_height = min(1.8, max(0.75, height * 0.16))
|
|
band_base = max(0.9, height * 0.52)
|
|
for start, end in edges:
|
|
add_wall_panel(glass_batch, start, end, band_base, band_height,
|
|
thickness=0.055, inset=0.12)
|
|
else:
|
|
floor_height = 3.25
|
|
floor_count = max(1, int((height - 0.7) / floor_height))
|
|
for floor in range(floor_count):
|
|
band_base = 0.55 + floor * floor_height + 0.95
|
|
if band_base + 1.25 > height - 0.18:
|
|
break
|
|
for start, end in edges:
|
|
add_wall_panel(glass_batch, start, end, band_base, 1.25,
|
|
thickness=0.045, inset=0.10)
|
|
glass_batch.finish()
|
|
|
|
|
|
|
|
def geometry_rings(geometry):
|
|
if not geometry:
|
|
return []
|
|
kind = geometry.get("type")
|
|
coordinates = geometry.get("coordinates", [])
|
|
if kind == "Polygon":
|
|
return coordinates[:1]
|
|
if kind == "MultiPolygon":
|
|
return [polygon[0] for polygon in coordinates if polygon]
|
|
return []
|
|
|
|
|
|
def feature_in_bounds(feature, projector):
|
|
def walk(value):
|
|
if isinstance(value, list) and value and isinstance(value[0], (int, float)):
|
|
return projector.inside(value)
|
|
return any(walk(v) for v in value) if isinstance(value, list) else False
|
|
return walk(feature.get("geometry", {}).get("coordinates", []))
|
|
|
|
|
|
def clip_polygon(ring, xmin, xmax, ymin, ymax):
|
|
if len(ring) < 3:
|
|
return []
|
|
|
|
def clip_edge(points, inside, intersection):
|
|
if not points:
|
|
return []
|
|
result = []
|
|
previous = points[-1]
|
|
previous_inside = inside(previous)
|
|
for current in points:
|
|
current_inside = inside(current)
|
|
if current_inside != previous_inside:
|
|
result.append(intersection(previous, current))
|
|
if current_inside:
|
|
result.append(current)
|
|
previous = current
|
|
previous_inside = current_inside
|
|
return result
|
|
|
|
ring = clip_edge(
|
|
ring, lambda p: p[0] >= xmin,
|
|
lambda a, b: (xmin, a[1] + (b[1] - a[1]) * (xmin - a[0]) /
|
|
(b[0] - a[0]) if b[0] != a[0] else a[1]))
|
|
ring = clip_edge(
|
|
ring, lambda p: p[0] <= xmax,
|
|
lambda a, b: (xmax, a[1] + (b[1] - a[1]) * (xmax - a[0]) /
|
|
(b[0] - a[0]) if b[0] != a[0] else a[1]))
|
|
ring = clip_edge(
|
|
ring, lambda p: p[1] >= ymin,
|
|
lambda a, b: (a[0] + (b[0] - a[0]) * (ymin - a[1]) /
|
|
(b[1] - a[1]) if b[1] != a[1] else a[0], ymin))
|
|
ring = clip_edge(
|
|
ring, lambda p: p[1] <= ymax,
|
|
lambda a, b: (a[0] + (b[0] - a[0]) * (ymax - a[1]) /
|
|
(b[1] - a[1]) if b[1] != a[1] else a[0], ymax))
|
|
return ring
|
|
|
|
|
|
def add_geojson_layer(path, layer, projector, collection, material, z):
|
|
if not os.path.exists(path):
|
|
return 0
|
|
with open(path, "r", encoding="utf-8") as handle:
|
|
data = json.load(handle)
|
|
batch = MeshBatch("Road_" + layer, collection, material)
|
|
b = projector.bounds
|
|
xmin, ymin = projector.xy((b["min_lon"], b["min_lat"]))
|
|
xmax, ymax = projector.xy((b["max_lon"], b["max_lat"]))
|
|
count = 0
|
|
for feature in data.get("features", []):
|
|
if not feature_in_bounds(feature, projector):
|
|
continue
|
|
for ring in geometry_rings(feature.get("geometry")):
|
|
points = [projector.xy(pair) for pair in ring]
|
|
points = clip_polygon(points, xmin, xmax, ymin, ymax)
|
|
if len(points) >= 3:
|
|
batch.add_polygon(points, z)
|
|
count += 1
|
|
batch.finish()
|
|
return count
|
|
|
|
|
|
def add_polyline(name, coords, projector, collection, material, width, z):
|
|
points = [projector.xy(c) for c in coords]
|
|
if len(points) < 2:
|
|
return
|
|
curve = bpy.data.curves.new(name, "CURVE")
|
|
curve.dimensions = "3D"
|
|
curve.resolution_u = 1
|
|
curve.bevel_depth = width / 2
|
|
curve.bevel_resolution = 1
|
|
spline = curve.splines.new("POLY")
|
|
spline.points.add(len(points) - 1)
|
|
for point, (x, y) in zip(spline.points, points):
|
|
point.co = (x, y, z, 1)
|
|
obj = bpy.data.objects.new(name, curve)
|
|
collection.objects.link(obj)
|
|
obj.data.materials.append(material)
|
|
|
|
|
|
def parse_height(feature_tags, default):
|
|
try:
|
|
return max(0.5, float(feature_tags.get("height", default)))
|
|
except ValueError:
|
|
return default
|
|
|
|
|
|
def sample_tree_row(points, spacing, height):
|
|
if len(points) < 2:
|
|
return []
|
|
samples = [(points[0][0], points[0][1], height)]
|
|
distance_until_next = spacing
|
|
for start, end in zip(points, points[1:]):
|
|
dx = end[0] - start[0]
|
|
dy = end[1] - start[1]
|
|
segment_length = math.hypot(dx, dy)
|
|
if segment_length == 0:
|
|
continue
|
|
while distance_until_next <= segment_length:
|
|
ratio = distance_until_next / segment_length
|
|
samples.append((start[0] + dx * ratio, start[1] + dy * ratio, height))
|
|
distance_until_next += spacing
|
|
distance_until_next -= segment_length
|
|
last = points[-1]
|
|
if math.hypot(samples[-1][0] - last[0], samples[-1][1] - last[1]) > spacing * 0.45:
|
|
samples.append((last[0], last[1], height))
|
|
return samples
|
|
|
|
|
|
def add_tree_batch(positions, collection, trunk_material, leaf_material):
|
|
trunk = MeshBatch("Tree_Trunks", collection, trunk_material)
|
|
leaves = MeshBatch("Tree_Crowns", collection, leaf_material)
|
|
sides = 10
|
|
|
|
def add_blob(batch, cx, cy, cz, rx, ry, rz, phase):
|
|
rings = 5
|
|
start = len(batch.vertices)
|
|
for ring in range(rings):
|
|
latitude = -math.pi / 2 + math.pi * ring / (rings - 1)
|
|
ring_radius = math.cos(latitude)
|
|
for side in range(sides):
|
|
angle = math.tau * side / sides
|
|
variation = 1.0 + 0.09 * math.sin(phase + side * 1.73 + ring * 0.91)
|
|
batch.vertices.append((cx + rx * ring_radius * math.cos(angle) * variation,
|
|
cy + ry * ring_radius * math.sin(angle) * variation,
|
|
cz + rz * math.sin(latitude)))
|
|
for ring in range(rings - 1):
|
|
for side in range(sides):
|
|
next_side = (side + 1) % sides
|
|
batch.faces.append((start + ring * sides + side,
|
|
start + ring * sides + next_side,
|
|
start + (ring + 1) * sides + next_side,
|
|
start + (ring + 1) * sides + side))
|
|
|
|
for index, (x, y, height) in enumerate(positions):
|
|
base = len(trunk.vertices)
|
|
radius = max(0.12, height * 0.035)
|
|
trunk_top = height * 0.62
|
|
for z, ring_radius in ((0.0, radius), (trunk_top, radius * 0.68)):
|
|
for i in range(sides):
|
|
a = math.tau * i / sides
|
|
trunk.vertices.append((x + ring_radius * math.cos(a),
|
|
y + ring_radius * math.sin(a), z))
|
|
trunk.faces.append(tuple(base + i for i in range(sides - 1, -1, -1)))
|
|
for i in range(sides):
|
|
j = (i + 1) % sides
|
|
trunk.faces.append((base + i, base + j, base + sides + j, base + sides + i))
|
|
trunk.faces.append(tuple(base + sides + i for i in range(sides)))
|
|
|
|
crown_r = max(0.85, height * 0.30)
|
|
crown_z = height * 0.82
|
|
add_blob(leaves, x, y, crown_z, crown_r * 0.70,
|
|
crown_r * 0.62, crown_r * 0.72, index * 1.41)
|
|
add_blob(leaves, x - crown_r * 0.42, y + crown_r * 0.08,
|
|
crown_z * 0.98, crown_r * 0.52, crown_r * 0.48,
|
|
crown_r * 0.58, index * 2.17 + 0.7)
|
|
add_blob(leaves, x + crown_r * 0.40, y - crown_r * 0.05,
|
|
crown_z * 1.02, crown_r * 0.50, crown_r * 0.46,
|
|
crown_r * 0.55, index * 2.63 + 1.3)
|
|
trunk.finish()
|
|
leaves.finish()
|
|
|
|
|
|
def add_natural_tree_instances(positions, collection, trunk_material,
|
|
leaf_dark_material, leaf_light_material):
|
|
trunk = MeshBatch("Tree_Natural_Trunks", collection, trunk_material)
|
|
lower = MeshBatch("Tree_Natural_Crowns_Dark", collection, leaf_dark_material)
|
|
upper = MeshBatch("Tree_Natural_Crowns_Light", collection, leaf_light_material)
|
|
trunk_sides = 9
|
|
crown_sides = 9
|
|
crown_rings = 5
|
|
|
|
def add_blob(batch, cx, cy, cz, rx, ry, rz, phase, squash=1.0):
|
|
start = len(batch.vertices)
|
|
for ring in range(crown_rings):
|
|
latitude = -math.pi / 2 + math.pi * ring / (crown_rings - 1)
|
|
ring_radius = math.cos(latitude)
|
|
for side in range(crown_sides):
|
|
angle = math.tau * side / crown_sides
|
|
wobble = (
|
|
1.0 +
|
|
0.14 * math.sin(phase + side * 1.31 + ring * 0.83) +
|
|
0.07 * math.sin(phase * 0.7 + side * 2.11)
|
|
)
|
|
batch.vertices.append((
|
|
cx + rx * ring_radius * math.cos(angle) * wobble,
|
|
cy + ry * ring_radius * math.sin(angle) * wobble,
|
|
cz + rz * math.sin(latitude) * squash,
|
|
))
|
|
for ring in range(crown_rings - 1):
|
|
for side in range(crown_sides):
|
|
next_side = (side + 1) % crown_sides
|
|
batch.faces.append((
|
|
start + ring * crown_sides + side,
|
|
start + ring * crown_sides + next_side,
|
|
start + (ring + 1) * crown_sides + next_side,
|
|
start + (ring + 1) * crown_sides + side,
|
|
))
|
|
|
|
for index, (x, y, height) in enumerate(positions):
|
|
target_height = max(4.8, min(8.8, height * 1.08))
|
|
phase = index * 1.61803398875
|
|
trunk_height = target_height * (0.48 + 0.05 * math.sin(phase))
|
|
trunk_radius = max(0.13, target_height * 0.038)
|
|
lean_x = math.sin(phase * 1.7) * target_height * 0.025
|
|
lean_y = math.cos(phase * 1.3) * target_height * 0.025
|
|
|
|
base = len(trunk.vertices)
|
|
trunk_levels = [
|
|
(0.0, trunk_radius),
|
|
(trunk_height * 0.55, trunk_radius * 0.78),
|
|
(trunk_height, trunk_radius * 0.48),
|
|
]
|
|
for level_index, (z, radius) in enumerate(trunk_levels):
|
|
offset_x = lean_x * level_index / (len(trunk_levels) - 1)
|
|
offset_y = lean_y * level_index / (len(trunk_levels) - 1)
|
|
for side in range(trunk_sides):
|
|
angle = math.tau * side / trunk_sides
|
|
trunk.vertices.append((
|
|
x + offset_x + radius * math.cos(angle),
|
|
y + offset_y + radius * math.sin(angle),
|
|
z,
|
|
))
|
|
trunk.faces.append(tuple(base + i for i in range(trunk_sides - 1, -1, -1)))
|
|
for level_index in range(len(trunk_levels) - 1):
|
|
row = base + level_index * trunk_sides
|
|
next_row = row + trunk_sides
|
|
for side in range(trunk_sides):
|
|
next_side = (side + 1) % trunk_sides
|
|
trunk.faces.append((row + side, row + next_side,
|
|
next_row + next_side, next_row + side))
|
|
top_row = base + (len(trunk_levels) - 1) * trunk_sides
|
|
trunk.faces.append(tuple(top_row + i for i in range(trunk_sides)))
|
|
|
|
crown_x = x + lean_x
|
|
crown_y = y + lean_y
|
|
crown_z = trunk_height + target_height * 0.22
|
|
crown_r = target_height * (0.35 + 0.035 * math.sin(phase * 0.9))
|
|
|
|
# Dark lower mass gives the canopy volume when viewed obliquely.
|
|
add_blob(lower, crown_x, crown_y, crown_z - crown_r * 0.08,
|
|
crown_r * 0.95, crown_r * 0.78, crown_r * 0.52,
|
|
phase, squash=0.82)
|
|
add_blob(lower, crown_x - crown_r * 0.46, crown_y + crown_r * 0.05,
|
|
crown_z - crown_r * 0.02, crown_r * 0.62, crown_r * 0.50,
|
|
crown_r * 0.42, phase + 0.8, squash=0.80)
|
|
add_blob(lower, crown_x + crown_r * 0.42, crown_y - crown_r * 0.08,
|
|
crown_z, crown_r * 0.58, crown_r * 0.48,
|
|
crown_r * 0.40, phase + 1.9, squash=0.80)
|
|
|
|
# Lighter upper clumps break the silhouette without adding heavy geometry.
|
|
add_blob(upper, crown_x + crown_r * 0.05, crown_y + crown_r * 0.04,
|
|
crown_z + crown_r * 0.34, crown_r * 0.70,
|
|
crown_r * 0.58, crown_r * 0.38, phase + 2.7, squash=0.74)
|
|
add_blob(upper, crown_x - crown_r * 0.24, crown_y - crown_r * 0.22,
|
|
crown_z + crown_r * 0.23, crown_r * 0.46,
|
|
crown_r * 0.40, crown_r * 0.30, phase + 3.5, squash=0.72)
|
|
trunk.finish()
|
|
lower.finish()
|
|
upper.finish()
|
|
|
|
|
|
def polygon_area(ring):
|
|
if len(ring) < 3:
|
|
return 0.0
|
|
area = 0.0
|
|
for (x1, y1), (x2, y2) in zip(ring, ring[1:] + ring[:1]):
|
|
area += x1 * y2 - x2 * y1
|
|
return abs(area) * 0.5
|
|
|
|
|
|
def point_in_polygon(point, ring):
|
|
x, y = point
|
|
inside = False
|
|
j = len(ring) - 1
|
|
for i, (xi, yi) in enumerate(ring):
|
|
xj, yj = ring[j]
|
|
crosses = ((yi > y) != (yj > y))
|
|
if crosses:
|
|
x_at_y = (xj - xi) * (y - yi) / (yj - yi) + xi
|
|
if x < x_at_y:
|
|
inside = not inside
|
|
j = i
|
|
return inside
|
|
|
|
|
|
def add_scrub_patch(name, ring, material, collection):
|
|
if len(ring) < 3:
|
|
return None
|
|
if ring[0] == ring[-1]:
|
|
ring = ring[:-1]
|
|
if len(ring) < 3:
|
|
return None
|
|
|
|
batch = MeshBatch(name, collection, material)
|
|
batch.add_polygon(ring, 0.055)
|
|
|
|
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)
|
|
width = max(0.1, xmax - xmin)
|
|
depth = max(0.1, ymax - ymin)
|
|
area = polygon_area(ring)
|
|
clump_count = max(10, min(90, int(area / 18.0) + 8))
|
|
sides = 10
|
|
rings = 4
|
|
|
|
def add_dome(cx, cy, rx, ry, height, phase):
|
|
start = len(batch.vertices)
|
|
for ring_index in range(rings):
|
|
t = ring_index / (rings - 1)
|
|
z = 0.055 + height * math.sin(t * math.pi / 2)
|
|
radius_scale = math.cos(t * math.pi / 2)
|
|
for side in range(sides):
|
|
angle = math.tau * side / sides
|
|
wobble = 1.0 + 0.12 * math.sin(phase + side * 1.37 + ring_index * 0.73)
|
|
batch.vertices.append((
|
|
cx + rx * radius_scale * math.cos(angle) * wobble,
|
|
cy + ry * radius_scale * math.sin(angle) * wobble,
|
|
z,
|
|
))
|
|
for ring_index in range(rings - 1):
|
|
for side in range(sides):
|
|
next_side = (side + 1) % sides
|
|
batch.faces.append((
|
|
start + ring_index * sides + side,
|
|
start + ring_index * sides + next_side,
|
|
start + (ring_index + 1) * sides + next_side,
|
|
start + (ring_index + 1) * sides + side,
|
|
))
|
|
|
|
added = 0
|
|
attempts = 0
|
|
while added < clump_count and attempts < clump_count * 8:
|
|
attempts += 1
|
|
u = (attempts * 0.61803398875) % 1.0
|
|
v = (attempts * 0.41421356237) % 1.0
|
|
x = xmin + u * width
|
|
y = ymin + v * depth
|
|
if not point_in_polygon((x, y), ring):
|
|
continue
|
|
scale = 0.65 + 0.55 * ((attempts * 0.754877666) % 1.0)
|
|
add_dome(x, y, 0.95 * scale, 0.72 * scale,
|
|
0.34 + 0.28 * scale, attempts * 0.91)
|
|
added += 1
|
|
|
|
obj = batch.finish()
|
|
if obj:
|
|
obj["scrub_texture"] = "Poly Haven leafy_grass, scrub-tinted"
|
|
obj["scrub_style"] = "tileable foliage ground cover with low shrub domes"
|
|
return obj
|
|
|
|
|
|
def link_object_to_collection(obj, collection):
|
|
for current in list(obj.users_collection):
|
|
current.objects.unlink(obj)
|
|
collection.objects.link(obj)
|
|
|
|
|
|
def add_fountain(name, x, y, collection, materials):
|
|
def cylinder(part_name, radius, depth, z, material, vertices=48):
|
|
bpy.ops.mesh.primitive_cylinder_add(
|
|
vertices=vertices, radius=radius, depth=depth,
|
|
location=(x, y, z))
|
|
obj = bpy.context.object
|
|
obj.name = name + "_" + part_name
|
|
link_object_to_collection(obj, collection)
|
|
obj.data.materials.append(material)
|
|
for polygon in obj.data.polygons:
|
|
polygon.use_smooth = True
|
|
return obj
|
|
|
|
basin = cylinder("Basin", 3.0, 0.32, 0.16,
|
|
materials["fountain_stone"])
|
|
basin["osm_feature"] = "amenity=fountain"
|
|
cylinder("Water", 2.52, 0.045, 0.335,
|
|
materials["fountain_water"])
|
|
cylinder("Pedestal", 0.30, 0.78, 0.72,
|
|
materials["fountain_stone"], vertices=32)
|
|
|
|
bpy.ops.mesh.primitive_uv_sphere_add(
|
|
segments=20, ring_count=10, radius=0.22,
|
|
location=(x, y, 1.30))
|
|
crown = bpy.context.object
|
|
crown.name = name + "_Water_Crown"
|
|
link_object_to_collection(crown, collection)
|
|
crown.data.materials.append(materials["fountain_spray"])
|
|
for index in range(8):
|
|
angle = math.tau * index / 8.0
|
|
radius = 0.50
|
|
bpy.ops.mesh.primitive_uv_sphere_add(
|
|
segments=12, ring_count=6, radius=0.075,
|
|
location=(x + math.cos(angle) * radius,
|
|
y + math.sin(angle) * radius,
|
|
1.02 + 0.10 * math.sin(angle * 2.0)))
|
|
droplet = bpy.context.object
|
|
droplet.name = name + "_Droplet_" + str(index + 1)
|
|
link_object_to_collection(droplet, collection)
|
|
droplet.data.materials.append(materials["fountain_spray"])
|
|
|
|
|
|
def look_at(obj, target):
|
|
obj.rotation_euler = (Vector(target) - obj.location).to_track_quat("-Z", "Y").to_euler()
|
|
|
|
|
|
def clear_scene():
|
|
bpy.ops.object.select_all(action="SELECT")
|
|
bpy.ops.object.delete(use_global=False)
|
|
for collection in list(bpy.data.collections):
|
|
if collection.name != "Collection" and collection.users == 0:
|
|
bpy.data.collections.remove(collection)
|
|
|
|
|
|
def configure_scene():
|
|
scene = bpy.context.scene
|
|
scene.render.engine = "BLENDER_EEVEE_NEXT"
|
|
scene.render.resolution_x = 1200
|
|
scene.render.resolution_y = 900
|
|
scene.render.resolution_percentage = 100
|
|
scene.render.image_settings.file_format = "PNG"
|
|
scene.render.film_transparent = False
|
|
scene.world.color = (0.055, 0.075, 0.095)
|
|
scene.view_settings.look = "AgX - Medium High Contrast"
|
|
|
|
|
|
def configure_default_viewport():
|
|
workspace = bpy.data.workspaces.get("Layout")
|
|
if workspace:
|
|
try:
|
|
bpy.context.window.workspace = workspace
|
|
except (AttributeError, RuntimeError):
|
|
pass
|
|
for screen in bpy.data.screens:
|
|
for area in screen.areas:
|
|
if area.type != "VIEW_3D":
|
|
continue
|
|
space = area.spaces.active
|
|
space.shading.type = "MATERIAL"
|
|
space.shading.light = "STUDIO"
|
|
space.shading.color_type = "MATERIAL"
|
|
space.overlay.show_floor = False
|
|
if space.region_3d:
|
|
space.region_3d.view_perspective = "CAMERA"
|
|
space.region_3d.view_camera_zoom = 0.0
|
|
|
|
|
|
def build(args):
|
|
bounds, ways, point_features = parse_osm(args["osm"])
|
|
projector = Projector(bounds)
|
|
clear_scene()
|
|
configure_scene()
|
|
|
|
ground_c = new_collection("00_Ground")
|
|
water_c = new_collection("01_Water")
|
|
green_c = new_collection("02_Green")
|
|
roads_c = new_collection("03_Roads")
|
|
buildings_c = new_collection("04_Buildings")
|
|
props_c = new_collection("05_Props")
|
|
|
|
ground_mat = make_material("Ground", (0.27, 0.32, 0.24))
|
|
water_mat = make_material("Lake Water", (0.035, 0.22, 0.30), 0.18, 0.05)
|
|
grass_mat = make_textured_material(
|
|
"Grass", "leafy_grass_diff_1k.jpg", "leafy_grass_nor_gl_1k.jpg",
|
|
roughness=0.92, scale=7.0, tint=(0.12, 0.48, 0.08), tint_factor=0.72)
|
|
scrub_mat = make_textured_material(
|
|
"Scrub Ground Cover", "leafy_grass_diff_1k.jpg",
|
|
"leafy_grass_nor_gl_1k.jpg", roughness=0.96, scale=13.0,
|
|
tint=(0.09, 0.32, 0.07), tint_factor=0.42)
|
|
fountain_mats = {
|
|
"fountain_stone": make_material("Fountain Stone", (0.42, 0.45, 0.43), 0.72),
|
|
"fountain_water": make_material("Fountain Water", (0.03, 0.32, 0.42), 0.16, 0.05),
|
|
"fountain_spray": make_material("Fountain Spray", (0.20, 0.70, 0.78), 0.12, 0.02),
|
|
}
|
|
building_mats = {
|
|
"default": make_textured_material(
|
|
"Office White Plaster Facade", "white_plaster_02_diff_1k.jpg",
|
|
"white_plaster_02_nor_gl_1k.jpg", roughness=0.82,
|
|
scale=4.2, metallic=0.0, tint=(0.92, 0.94, 0.92),
|
|
tint_factor=0.38),
|
|
"industrial": make_textured_material(
|
|
"Industrial White Ribbed Facade", "corrugated_iron_03_diff_1k.jpg",
|
|
"corrugated_iron_03_nor_gl_1k.jpg", roughness=0.56,
|
|
scale=2.4, metallic=0.16, tint=(0.86, 0.92, 0.94),
|
|
tint_factor=0.68),
|
|
"office_roof": make_textured_material(
|
|
"Office Light Flat Roof", "concrete_floor_02_diff_1k.jpg",
|
|
"concrete_floor_02_bump_1k.jpg", roughness=0.84,
|
|
scale=5.0, normal_is_bump=True, tint=(0.82, 0.86, 0.88),
|
|
tint_factor=0.35),
|
|
"industrial_roof": make_textured_material(
|
|
"Factory Blue Metal Roof", "blue_metal_plate_diff_1k.jpg",
|
|
"blue_metal_plate_nor_gl_1k.jpg", roughness=0.48,
|
|
scale=3.4, metallic=0.28, tint=(0.03, 0.42, 0.78),
|
|
tint_factor=0.45),
|
|
"glass": make_material("Office Blue Gray Glass", (0.12, 0.20, 0.24), 0.22, 0.10),
|
|
"factory_glass": make_material("Factory Dark Windows", (0.10, 0.14, 0.15), 0.28, 0.08),
|
|
}
|
|
road_mats = {
|
|
"road_surface": make_material("Road Asphalt", (0.055, 0.065, 0.070)),
|
|
"intersection_surface": make_material("Intersection Asphalt", (0.065, 0.075, 0.080)),
|
|
"sidewalks": make_material("Sidewalk", (0.49, 0.51, 0.49)),
|
|
"sidewalk_corners": make_material("Sidewalk Corner", (0.49, 0.51, 0.49)),
|
|
"lane_separators": make_material("Lane Separator", (0.85, 0.84, 0.72)),
|
|
"center_lines": make_material("Center Line", (0.94, 0.58, 0.06)),
|
|
"crosswalks": make_material("Crosswalk", (0.95, 0.94, 0.82)),
|
|
"vehicle_stop_lines": make_material("Stop Line", (0.95, 0.94, 0.82)),
|
|
"lane_arrows_webscale": make_material("Lane Arrow", (0.95, 0.94, 0.82)),
|
|
}
|
|
|
|
b = bounds
|
|
scene_xmin, scene_ymin = projector.xy((b["min_lon"], b["min_lat"]))
|
|
scene_xmax, scene_ymax = projector.xy((b["max_lon"], b["max_lat"]))
|
|
ground_ring = [projector.xy((b["min_lon"] - 0.0012, b["min_lat"] - 0.0012)),
|
|
projector.xy((b["max_lon"] + 0.0012, b["min_lat"] - 0.0012)),
|
|
projector.xy((b["max_lon"] + 0.0012, b["max_lat"] + 0.0012)),
|
|
projector.xy((b["min_lon"] - 0.0012, b["max_lat"] + 0.0012))]
|
|
ground_batch = MeshBatch("Ground Plane", ground_c, ground_mat)
|
|
ground_batch.add_polygon(ground_ring, -0.35)
|
|
ground_batch.finish()
|
|
|
|
grass_rings = []
|
|
tree_rows = []
|
|
lake_count = 0
|
|
grass_count = 0
|
|
scrub_count = 0
|
|
fountain_count = 0
|
|
building_count = 0
|
|
industrial_count = 0
|
|
focus_points = []
|
|
for way in ways:
|
|
coords = way["coords"]
|
|
if not any(projector.inside(c) for c in coords):
|
|
continue
|
|
ring = projector.ring(coords)
|
|
tag = way["tags"]
|
|
if tag.get("natural") == "water" or tag.get("water") == "lake":
|
|
ring = clip_polygon(ring, scene_xmin, scene_xmax,
|
|
scene_ymin, scene_ymax)
|
|
batch = MeshBatch("Lake Surface", water_c, water_mat)
|
|
if len(ring) >= 3:
|
|
batch.add_polygon(ring, 0.10)
|
|
batch.finish()
|
|
lake_count += 1
|
|
elif tag.get("landuse") == "grass":
|
|
ring = clip_polygon(ring, scene_xmin, scene_xmax,
|
|
scene_ymin, scene_ymax)
|
|
grass_rings.append(ring)
|
|
focus_points.extend(ring)
|
|
batch = MeshBatch("Grass_" + str(way["id"]), green_c, grass_mat)
|
|
if len(ring) >= 3:
|
|
batch.add_polygon(ring, 0.015)
|
|
batch.finish()
|
|
grass_count += 1
|
|
elif tag.get("natural") == "scrub" and len(ring) >= 3:
|
|
ring = clip_polygon(ring, scene_xmin, scene_xmax,
|
|
scene_ymin, scene_ymax)
|
|
focus_points.extend(ring)
|
|
if len(ring) >= 3:
|
|
add_scrub_patch("Scrub_" + str(way["id"]), ring,
|
|
scrub_mat, green_c)
|
|
scrub_count += 1
|
|
elif tag.get("natural") == "tree_row":
|
|
tree_rows.append((ring, tag))
|
|
focus_points.extend(ring)
|
|
elif "building" in tag and len(ring) >= 3:
|
|
way_id = str(way["id"])
|
|
industrial = (tag.get("building") == "industrial" and
|
|
way_id not in args["office_overrides"])
|
|
source_height = max(3.0, parse_height(tag, 12.0))
|
|
height = source_height if industrial or source_height >= 30.0 else 11.4
|
|
material = building_mats["industrial"] if industrial else building_mats["default"]
|
|
building_name = "Building_" + way_id
|
|
building_obj = make_prism(building_name, ring, 0.08, height,
|
|
material, buildings_c)
|
|
if building_obj:
|
|
building_obj["osm_height"] = source_height
|
|
building_obj["render_height"] = height
|
|
building_obj["building_kind"] = "industrial" if industrial else "office"
|
|
building_obj["osm_building_tag"] = tag.get("building", "")
|
|
building_obj["office_override"] = way_id in args["office_overrides"]
|
|
bevel = building_obj.modifiers.new("Soft facade edges", "BEVEL")
|
|
bevel.width = 0.16
|
|
bevel.segments = 2
|
|
roof_mat = (building_mats["industrial_roof"] if industrial
|
|
else building_mats["office_roof"])
|
|
add_roof(building_name, ring, height + 0.095, roof_mat, buildings_c)
|
|
add_building_details(building_name, ring, height, industrial,
|
|
building_mats, buildings_c)
|
|
focus_points.extend(ring)
|
|
building_count += 1
|
|
industrial_count += int(industrial)
|
|
|
|
geojson_dir = args.get("geojson")
|
|
road_counts = {}
|
|
if geojson_dir and os.path.isdir(geojson_dir):
|
|
layer_z = {"road_surface": 0.03, "intersection_surface": 0.035,
|
|
"sidewalks": 0.065, "sidewalk_corners": 0.067,
|
|
"lane_separators": 0.090, "center_lines": 0.092,
|
|
"crosswalks": 0.094, "vehicle_stop_lines": 0.096,
|
|
"lane_arrows_webscale": 0.098}
|
|
for layer, z in layer_z.items():
|
|
road_counts[layer] = add_geojson_layer(
|
|
os.path.join(geojson_dir, layer + ".geojson"), layer,
|
|
projector, roads_c, road_mats[layer], z)
|
|
|
|
if road_counts.get("road_surface", 0) == 0:
|
|
for way in ways:
|
|
highway = way["tags"].get("highway")
|
|
if highway and len(way["coords"]) >= 2:
|
|
width = {"secondary": 7.0, "residential": 5.5, "service": 3.5}.get(highway, 4.0)
|
|
add_polyline("OSM_Road_" + str(way["id"]), way["coords"], projector,
|
|
roads_c, road_mats["road_surface"], width, 0.03)
|
|
|
|
trees = []
|
|
individual_tree_count = 0
|
|
for feature in point_features:
|
|
if feature["tags"].get("natural") != "tree":
|
|
continue
|
|
if not projector.inside(feature["coord"]):
|
|
continue
|
|
x, y = projector.xy(feature["coord"])
|
|
trees.append((x, y, parse_height(feature["tags"], 5.5)))
|
|
individual_tree_count += 1
|
|
row_tree_count = 0
|
|
for row, row_tags in tree_rows:
|
|
row_samples = sample_tree_row(row, spacing=5.0,
|
|
height=parse_height(row_tags, 5.0))
|
|
trees.extend(row_samples)
|
|
row_tree_count += len(row_samples)
|
|
if trees:
|
|
tree_style = args.get("tree_style")
|
|
if tree_style == "natural":
|
|
tree_trunk = make_textured_material(
|
|
"Tree Trunk", "bark_brown_01_diff_1k.jpg",
|
|
"bark_brown_01_nor_gl_1k.jpg", roughness=0.92, scale=5.0)
|
|
leaf_dark = make_material("Tree Crown Dark", (0.065, 0.25, 0.055), 0.90)
|
|
add_procedural_surface(leaf_dark,
|
|
((0.035, 0.14, 0.035), (0.12, 0.36, 0.08)),
|
|
scale=3.2, detail=3.8, bump_strength=0.08)
|
|
leaf_light = make_material("Tree Crown Light", (0.13, 0.42, 0.09), 0.88)
|
|
add_procedural_surface(leaf_light,
|
|
((0.07, 0.25, 0.05), (0.22, 0.56, 0.13)),
|
|
scale=3.6, detail=3.4, bump_strength=0.07)
|
|
add_natural_tree_instances(trees, props_c, tree_trunk,
|
|
leaf_dark, leaf_light)
|
|
else:
|
|
tree_trunk = make_textured_material(
|
|
"Tree Trunk", "bark_brown_01_diff_1k.jpg",
|
|
"bark_brown_01_nor_gl_1k.jpg", roughness=0.92, scale=5.0)
|
|
tree_leaf = make_material("Tree Crown", (0.10, 0.36, 0.08), 0.88)
|
|
add_procedural_surface(tree_leaf,
|
|
((0.04, 0.18, 0.04), (0.18, 0.50, 0.12)),
|
|
scale=2.8, detail=3.2, bump_strength=0.10)
|
|
add_tree_batch(trees, props_c, tree_trunk, tree_leaf)
|
|
|
|
for feature in point_features:
|
|
if feature["tags"].get("amenity") != "fountain":
|
|
continue
|
|
if not projector.inside(feature["coord"]):
|
|
continue
|
|
fx, fy = projector.xy(feature["coord"])
|
|
add_fountain("Fountain_" + str(feature["id"]), fx, fy,
|
|
props_c, fountain_mats)
|
|
fountain_count += 1
|
|
|
|
bpy.ops.object.light_add(type="SUN", location=(0, 0, 500))
|
|
sun = bpy.context.object
|
|
sun.name = "Sun"
|
|
sun.data.energy = 3.0
|
|
sun.rotation_euler = (math.radians(28), math.radians(-22), math.radians(-32))
|
|
bpy.ops.object.light_add(type="AREA", location=(0, -220, 420))
|
|
area = bpy.context.object
|
|
area.name = "Fill Light"
|
|
area.data.energy = 1700
|
|
area.data.shape = "DISK"
|
|
area.data.size = 260
|
|
look_at(area, (0, 0, 0))
|
|
|
|
width = (b["max_lon"] - b["min_lon"]) * projector.m_per_lon
|
|
height = (b["max_lat"] - b["min_lat"]) * projector.m_per_lat
|
|
if focus_points:
|
|
min_fx = min(point[0] for point in focus_points)
|
|
max_fx = max(point[0] for point in focus_points)
|
|
min_fy = min(point[1] for point in focus_points)
|
|
max_fy = max(point[1] for point in focus_points)
|
|
focus_x = (min_fx + max_fx) / 2
|
|
focus_y = (min_fy + max_fy) / 2
|
|
focus_span = max(max_fx - min_fx, (max_fy - min_fy) * 1.25)
|
|
cam_location = (focus_x + focus_span * 0.78,
|
|
focus_y - focus_span * 0.92,
|
|
focus_span * 1.22)
|
|
camera_target = (focus_x, focus_y, 3)
|
|
else:
|
|
cam_location = (width * 0.78, -height * 1.15, max(width, height) * 1.22)
|
|
camera_target = (0, 0, 3)
|
|
bpy.ops.object.camera_add(location=cam_location)
|
|
camera = bpy.context.object
|
|
camera.name = "Scene Overview Camera"
|
|
camera.data.lens = 48
|
|
camera.data.clip_start = 0.1
|
|
camera.data.clip_end = 5000.0
|
|
look_at(camera, camera_target)
|
|
bpy.context.scene.camera = camera
|
|
configure_default_viewport()
|
|
|
|
scene = bpy.context.scene
|
|
scene.render.filepath = args["render"]
|
|
scene["source_osm"] = args["osm"]
|
|
scene["source_geojson"] = geojson_dir or ""
|
|
scene["osm_bounds"] = json.dumps(bounds, ensure_ascii=True)
|
|
scene["building_count"] = building_count
|
|
scene["industrial_building_count"] = industrial_count
|
|
scene["office_override_way_ids"] = json.dumps(sorted(args["office_overrides"]))
|
|
scene["lake_count"] = lake_count
|
|
scene["grass_count"] = grass_count
|
|
scene["scrub_count"] = scrub_count
|
|
scene["fountain_count"] = fountain_count
|
|
scene["tree_node_count"] = individual_tree_count
|
|
scene["tree_row_count"] = row_tree_count
|
|
scene["tree_count"] = len(trees)
|
|
scene["road_feature_counts"] = json.dumps(road_counts, ensure_ascii=True)
|
|
|
|
os.makedirs(os.path.dirname(args["output"]), exist_ok=True)
|
|
os.makedirs(os.path.dirname(args["render"]), exist_ok=True)
|
|
bpy.ops.file.pack_all()
|
|
bpy.ops.wm.save_as_mainfile(filepath=args["output"])
|
|
bpy.ops.render.render(write_still=True)
|
|
print("SCENE_DONE", json.dumps({"output": args["output"],
|
|
"render": args["render"],
|
|
"buildings": building_count,
|
|
"industrial_buildings": industrial_count,
|
|
"lake": lake_count,
|
|
"grass": grass_count,
|
|
"scrub": scrub_count,
|
|
"fountains": fountain_count,
|
|
"tree_nodes": individual_tree_count,
|
|
"tree_row_instances": row_tree_count,
|
|
"trees": len(trees),
|
|
"road_features": road_counts}, ensure_ascii=True))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
build(cli_args())
|