refactor: 抽 osmassets 包,要素注册表,材质单一定义源 (P0-P3)

generate_scene.py 从 1271 → 816 行 (-455)

P0: 纯函数搬家
- osmassets/osm.py: parse_osm / Projector / parse_height
- osmassets/geom.py: clip_polygon / point_in_polygon / distance_to_ring / sample_tree_row 等
- blender/tests/test_pure.py: 42 个 unittest (脱离 bpy 运行)

P1: 单一定义源
- osmassets/catalog.py: ROAD_LAYERS + MATERIALS (含 cesium 导出参数)
- 对接 osm2streets_scene_style.json 做图层一致性 warning
- 干掉 road_mats / layer_z / 材质参数三份副本

P2: 要素注册表
- osmassets/{water,grass,scrub}.py: 每个要素一个 assemble() 函数
- build() 中的 if/elif 链收缩为注册表调用
- 计数器集中到 counts 字典

P3: 材质契约化
- catalog.py 扩展 CESIUM_EXPORT 段 (tint/metallic/emission)
- 标记已发现的死条目 Office White Metal Facade (四表各一组)

校验:
- parity.js + scene_digest.py + glb-digest.js 三位一体
- control-1 vs p0/p1/p2a/p2b/p3-counts: 两区域全 PARITY OK
- 42 个纯 Python 测试全部通过

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-29 17:59:32 +08:00
parent 23ae63bc2a
commit 24e02e2041
16 changed files with 1924 additions and 598 deletions

View File

@@ -23,16 +23,74 @@ import json
import math
import os
import sys
import xml.etree.ElementTree as ET
from collections import defaultdict
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
# 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
TEXTURE_ROOT = os.path.abspath(os.path.join(
os.path.dirname(__file__), "..", "assets", "textures", "polyhaven"
))
MODEL_ROOT = os.path.abspath(os.path.join(
os.path.dirname(__file__), "..", "assets", "models", "polyhaven"
@@ -91,290 +149,6 @@ def cli_args():
return values
def tags(element):
return {t.attrib.get("k", ""): t.attrib.get("v", "")
for t in element.findall("tag")}
def parse_osm(path):
root = ET.parse(path).getroot()
bounds_node = root.find("bounds")
if bounds_node is None:
raise RuntimeError("OSM file does not contain a bounds element")
bounds = {"min_lon": float(bounds_node.attrib["minlon"]),
"min_lat": float(bounds_node.attrib["minlat"]),
"max_lon": float(bounds_node.attrib["maxlon"]),
"max_lat": float(bounds_node.attrib["maxlat"])}
nodes = {}
point_features = []
for node in root.findall("node"):
try:
node_id = int(node.attrib["id"])
coord = (float(node.attrib["lon"]), float(node.attrib["lat"]))
node_tags = tags(node)
nodes[node_id] = coord
if node_tags:
point_features.append({"id": node.attrib.get("id", ""),
"coord": coord, "tags": node_tags})
except (KeyError, ValueError):
continue
ways = []
for way in root.findall("way"):
if way.attrib.get("action") == "delete":
continue
refs = []
for ref in way.findall("nd"):
try:
refs.append(int(ref.attrib["ref"]))
except (KeyError, ValueError):
pass
coords = [nodes[r] for r in refs if r in nodes]
if len(coords) >= 2:
ways.append({"id": way.attrib.get("id", ""),
"coords": coords, "tags": tags(way)})
return bounds, ways, point_features
class Projector:
def __init__(self, bounds):
self.bounds = bounds
self.lon0 = (bounds["min_lon"] + bounds["max_lon"]) / 2
self.lat0 = (bounds["min_lat"] + bounds["max_lat"]) / 2
self.m_per_lat = 111320.0
self.m_per_lon = 111320.0 * math.cos(math.radians(self.lat0))
def xy(self, lon_lat):
lon, lat = lon_lat
return ((lon - self.lon0) * self.m_per_lon,
(lat - self.lat0) * self.m_per_lat)
def inside(self, lon_lat, pad=0.00035):
lon, lat = lon_lat
b = self.bounds
return (b["min_lon"] - pad <= lon <= b["max_lon"] + pad and
b["min_lat"] - pad <= lat <= b["max_lat"] + pad)
def ring(self, coords):
return [self.xy(c) for c in coords]
def new_collection(name):
collection = bpy.data.collections.new(name)
bpy.context.scene.collection.children.link(collection)
return collection
def principled_bsdf(material):
if not material.use_nodes:
return None
for node in material.node_tree.nodes:
if node.type == "BSDF_PRINCIPLED":
return node
return None
def make_material(name, color, roughness=0.8, metallic=0.0):
material = bpy.data.materials.get(name) or bpy.data.materials.new(name)
material.diffuse_color = (*color, 1.0)
material.use_nodes = True
bsdf = principled_bsdf(material)
if bsdf:
bsdf.inputs["Base Color"].default_value = (*color, 1.0)
bsdf.inputs["Roughness"].default_value = roughness
bsdf.inputs["Metallic"].default_value = metallic
return material
def add_procedural_surface(material, colors, scale=2.0, detail=2.0, bump_strength=0.08,
object_space=False):
nodes = material.node_tree.nodes
links = material.node_tree.links
bsdf = principled_bsdf(material)
if not bsdf:
return
noise = nodes.new("ShaderNodeTexNoise")
noise.inputs["Scale"].default_value = scale
noise.inputs["Detail"].default_value = detail
noise.inputs["Roughness"].default_value = 0.65
texcoord = nodes.new("ShaderNodeTexCoord")
ramp = nodes.new("ShaderNodeValToRGB")
ramp.color_ramp.elements[0].color = (*colors[0], 1.0)
ramp.color_ramp.elements[1].color = (*colors[1], 1.0)
bump = nodes.new("ShaderNodeBump")
bump.inputs["Strength"].default_value = bump_strength
bump.inputs["Distance"].default_value = 0.12
# "Generated" normalises across the object bounding box, so on a mesh that
# spans the whole scene the noise stretches to tens of metres and vanishes.
# Object space keeps the scale in metres, which is what foliage needs.
source = "Object" if object_space else "Generated"
links.new(texcoord.outputs[source], noise.inputs["Vector"])
links.new(noise.outputs["Fac"], ramp.inputs["Fac"])
links.new(ramp.outputs["Color"], bsdf.inputs["Base Color"])
links.new(noise.outputs["Fac"], bump.inputs["Height"])
links.new(bump.outputs["Normal"], bsdf.inputs["Normal"])
def tint_base_color(material, tint, factor):
"""Mix an existing material's base colour toward `tint`.
Imported assets arrive with their own diffuse texture wired up. Rather than
replacing it — which throws away the leaf detail — this splices a mix node
in front of the Base Color input so the texture survives at (1 - factor).
"""
if factor <= 0.0 or not material.use_nodes:
return
bsdf = principled_bsdf(material)
if not bsdf:
return
nodes = material.node_tree.nodes
links = material.node_tree.links
base = bsdf.inputs["Base Color"]
tint_node = nodes.new("ShaderNodeRGB")
tint_node.outputs["Color"].default_value = (*tint, 1.0)
mix = nodes.new("ShaderNodeMixRGB")
mix.blend_type = "MIX"
mix.inputs["Fac"].default_value = factor
if base.is_linked:
# Capture the upstream socket before relinking; Blender drops the old
# link as soon as the input takes a new one.
links.new(base.links[0].from_socket, mix.inputs[1])
else:
mix.inputs[1].default_value = base.default_value
links.new(tint_node.outputs["Color"], mix.inputs[2])
links.new(mix.outputs["Color"], base)
def make_textured_material(name, diffuse_file, normal_file, roughness,
scale, normal_is_bump=False, metallic=0.0,
tint=None, tint_factor=0.0):
diffuse_path = os.path.join(TEXTURE_ROOT, diffuse_file)
normal_path = os.path.join(TEXTURE_ROOT, normal_file)
if not os.path.exists(diffuse_path) or not os.path.exists(normal_path):
return make_material(name, (0.5, 0.5, 0.5), roughness, metallic)
material = make_material(name, (0.5, 0.5, 0.5), roughness, metallic)
nodes = material.node_tree.nodes
links = material.node_tree.links
bsdf = principled_bsdf(material)
if not bsdf:
return material
texcoord = nodes.new("ShaderNodeTexCoord")
mapping = nodes.new("ShaderNodeMapping")
mapping.inputs["Scale"].default_value = (scale, scale, scale)
diffuse = nodes.new("ShaderNodeTexImage")
diffuse.image = bpy.data.images.load(diffuse_path, check_existing=True)
diffuse.extension = "REPEAT"
normal = nodes.new("ShaderNodeTexImage")
normal.image = bpy.data.images.load(normal_path, check_existing=True)
normal.image.colorspace_settings.name = "Non-Color"
normal.extension = "REPEAT"
links.new(texcoord.outputs["Generated"], mapping.inputs["Vector"])
links.new(mapping.outputs["Vector"], diffuse.inputs["Vector"])
links.new(mapping.outputs["Vector"], normal.inputs["Vector"])
if tint and tint_factor > 0.0:
tint_node = nodes.new("ShaderNodeRGB")
tint_node.outputs["Color"].default_value = (*tint, 1.0)
mix = nodes.new("ShaderNodeMixRGB")
mix.blend_type = "MIX"
mix.inputs["Fac"].default_value = tint_factor
links.new(diffuse.outputs["Color"], mix.inputs[1])
links.new(tint_node.outputs["Color"], mix.inputs[2])
links.new(mix.outputs["Color"], bsdf.inputs["Base Color"])
else:
links.new(diffuse.outputs["Color"], bsdf.inputs["Base Color"])
if normal_is_bump:
bump = nodes.new("ShaderNodeBump")
bump.inputs["Strength"].default_value = 0.22
bump.inputs["Distance"].default_value = 0.12
links.new(normal.outputs["Color"], bump.inputs["Height"])
links.new(bump.outputs["Normal"], bsdf.inputs["Normal"])
else:
normal_map = nodes.new("ShaderNodeNormalMap")
normal_map.inputs["Strength"].default_value = 0.52
links.new(normal.outputs["Color"], normal_map.inputs["Color"])
links.new(normal_map.outputs["Normal"], bsdf.inputs["Normal"])
return material
class MeshBatch:
def __init__(self, name, collection, material):
self.name = name
self.collection = collection
self.material = material
self.vertices = []
self.faces = []
def add_polygon(self, ring, z):
if len(ring) < 3:
return
if ring[0] == ring[-1]:
ring = ring[:-1]
if len(ring) < 3:
return
start = len(self.vertices)
self.vertices.extend((x, y, z) for x, y in ring)
self.faces.append(tuple(range(start, start + len(ring))))
def add_prism(self, ring, base, height):
if len(ring) < 3:
return
if ring[0] == ring[-1]:
ring = ring[:-1]
if len(ring) < 3:
return
start = len(self.vertices)
self.vertices.extend((x, y, base) for x, y in ring)
self.vertices.extend((x, y, base + height) for x, y in ring)
n = len(ring)
self.faces.append(tuple(range(start, start + n)))
self.faces.append(tuple(range(start + n, start + 2 * n)))
for i in range(n):
j = (i + 1) % n
self.faces.append((start + i, start + j, start + n + j, start + n + i))
def finish(self):
if not self.vertices:
return None
mesh = bpy.data.meshes.new(self.name + "Mesh")
mesh.from_pydata(self.vertices, [], self.faces)
mesh.materials.append(self.material)
if self.name.startswith("Tree_") or self.name.startswith("Scrub_"):
for polygon in mesh.polygons:
polygon.use_smooth = True
mesh.update()
obj = bpy.data.objects.new(self.name, mesh)
self.collection.objects.link(obj)
return obj
def make_prism(name, ring, base, height, material, collection):
batch = MeshBatch(name, collection, material)
batch.add_prism(ring, base, height)
return batch.finish()
def add_roof(name, ring, z, material, collection):
batch = MeshBatch(name + "_Roof", collection, material)
batch.add_polygon(ring, z)
return batch.finish()
def add_wall_panel(batch, start, end, base, height, thickness=0.045, inset=0.08):
dx, dy = end[0] - start[0], end[1] - start[1]
length = math.hypot(dx, dy)
if length < 3.0:
return
ux, uy = dx / length, dy / length
a = (start[0] + dx * inset, start[1] + dy * inset)
b = (end[0] - dx * inset, end[1] - dy * inset)
nx, ny = -uy * thickness / 2, ux * thickness / 2
panel = [(a[0] + nx, a[1] + ny), (b[0] + nx, b[1] + ny),
(b[0] - nx, b[1] - ny), (a[0] - nx, a[1] - ny)]
batch.add_prism(panel, base, height)
def add_building_details(name, ring, height, industrial, materials, collection):
footprint = ring[:-1] if len(ring) > 1 and ring[0] == ring[-1] else ring
if len(footprint) < 3:
@@ -403,65 +177,6 @@ def add_building_details(name, ring, height, industrial, materials, collection):
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
@@ -485,53 +200,6 @@ def add_geojson_layer(path, layer, projector, collection, material, z):
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)
@@ -685,51 +353,6 @@ def add_natural_tree_instances(positions, collection, trunk_material,
upper.finish()
def polygon_area(ring):
if len(ring) < 3:
return 0.0
area = 0.0
for (x1, y1), (x2, y2) in zip(ring, ring[1:] + ring[:1]):
area += x1 * y2 - x2 * y1
return abs(area) * 0.5
def point_in_polygon(point, ring):
x, y = point
inside = False
j = len(ring) - 1
for i, (xi, yi) in enumerate(ring):
xj, yj = ring[j]
crosses = ((yi > y) != (yj > y))
if crosses:
x_at_y = (xj - xi) * (y - yi) / (yj - yi) + xi
if x < x_at_y:
inside = not inside
j = i
return inside
def distance_to_ring(point, ring):
px, py = point
best = float("inf")
count = len(ring)
for index in range(count):
ax, ay = ring[index]
bx, by = ring[(index + 1) % count]
dx = bx - ax
dy = by - ay
length_sq = dx * dx + dy * dy
if length_sq <= 1e-9:
distance = math.hypot(px - ax, py - ay)
else:
t = ((px - ax) * dx + (py - ay) * dy) / length_sq
t = max(0.0, min(1.0, t))
distance = math.hypot(px - (ax + t * dx), py - (ay + t * dy))
if distance < best:
best = distance
return best
def load_tuft_variants():
"""Import the vendored Poly Haven plant once and return decimated meshes.
@@ -869,12 +492,6 @@ def add_scrub_patch(name, ring, ground_material, collection):
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(
@@ -975,55 +592,26 @@ def build(args):
buildings_c = new_collection("04_Buildings")
props_c = new_collection("05_Props")
ground_mat = make_material("Ground", (0.27, 0.32, 0.24))
water_mat = make_material("Lake Water", (0.035, 0.22, 0.30), 0.18, 0.05)
grass_mat = make_textured_material(
"Grass", "leafy_grass_diff_1k.jpg", "leafy_grass_nor_gl_1k.jpg",
roughness=0.92, scale=7.0, tint=(0.12, 0.48, 0.08), tint_factor=0.72)
scrub_mat = make_textured_material(
"Scrub Ground Cover", "leafy_grass_diff_1k.jpg",
"leafy_grass_nor_gl_1k.jpg", roughness=0.96, scale=15.0,
tint=(0.085, 0.30, 0.065), tint_factor=0.46)
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 = {
"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),
key: material_from_spec(catalog.MATERIALS[key])
for key in ("fountain_stone", "fountain_water", "fountain_spray")
}
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),
key: material_from_spec(catalog.MATERIALS["building_" + key])
for key in ("default", "industrial", "office_roof", "industrial_roof",
"glass", "factory_glass")
}
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)),
layer["id"]: material_from_spec(spec)
for layer, spec in zip(catalog.ROAD_LAYERS, catalog.road_material_specs())
}
b = bounds
@@ -1039,13 +627,19 @@ def build(args):
grass_rings = []
tree_rows = []
lake_count = 0
grass_count = 0
grass_tuft_count = 0
scrub_count = 0
fountain_count = 0
building_count = 0
industrial_count = 0
# 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"]
@@ -1054,80 +648,46 @@ def build(args):
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
counts["lake_count"] += _water.assemble(ring, scene_xmin, scene_xmax,
scene_ymin, scene_ymax,
water_c, water_mat)
elif tag.get("landuse") == "grass":
ring = clip_polygon(ring, scene_xmin, scene_xmax,
scene_ymin, scene_ymax)
grass_rings.append(ring)
focus_points.extend(ring)
batch = MeshBatch("Grass_" + str(way["id"]), green_c, grass_mat)
if len(ring) >= 3:
batch.add_polygon(ring, 0.015)
obj = batch.finish()
grass_count += 1
if tuft_variants:
tufts = add_grass_tufts("Grass_" + str(way["id"]), ring,
tuft_variants, green_c)
grass_tuft_count += tufts
if obj:
obj["grass_tufts"] = tufts
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:
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
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:
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)
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):
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)
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:
@@ -1154,30 +714,15 @@ def build(args):
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)
tree_trunk = material_from_spec(catalog.MATERIALS["tree_trunk"])
if args.get("tree_style") == "natural":
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 = 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)
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":
@@ -1187,7 +732,7 @@ def build(args):
fx, fy = projector.xy(feature["coord"])
add_fountain("Fountain_" + str(feature["id"]), fx, fy,
props_c, fountain_mats)
fountain_count += 1
counts["fountain_count"] += 1
bpy.ops.object.light_add(type="SUN", location=(0, 0, 500))
sun = bpy.context.object
@@ -1234,14 +779,14 @@ def build(args):
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["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"] = lake_count
scene["grass_count"] = grass_count
scene["grass_tuft_count"] = grass_tuft_count
scene["scrub_count"] = scrub_count
scene["fountain_count"] = fountain_count
scene["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)
@@ -1254,13 +799,13 @@ def build(args):
bpy.ops.render.render(write_still=True)
print("SCENE_DONE", json.dumps({"output": args["output"],
"render": args["render"],
"buildings": building_count,
"industrial_buildings": industrial_count,
"lake": lake_count,
"grass": grass_count,
"grass_tufts": grass_tuft_count,
"scrub": scrub_count,
"fountains": fountain_count,
"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),