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),

View File

@@ -0,0 +1,12 @@
"""Reusable pieces of the OSM → Blender/Cesium asset pipeline.
The package is split by dependency, not by feature:
- `osm` and `geom` are pure Python. They import no `bpy` and can be run and
tested with a plain interpreter (`python3 -m unittest discover blender/tests`).
- everything else may touch `bpy` and only runs inside Blender.
Keeping that line sharp is what makes the geometry testable at all; before the
split it was interleaved with scene construction and could only be exercised by
rendering a whole area.
"""

View File

@@ -0,0 +1,195 @@
"""Single source of truth for the scene's road layers and materials.
Before this module the same facts lived in several places at once: the nine
osm2streets layers had their draw order in `scripts/lib/scene-layers.js`, their
Blender heights in a `layer_z` dict, and their colours in a `road_mats` dict —
three copies across two languages, kept in sync by hand. Everything the scene
builder needs is now declared here, once.
Two deliberate non-goals:
- The colours here are NOT derived from `scene-layers.js`. That file's `fill`
values are QGIS sRGB hex for a 2D debug map; these are linear Blender base
colours for a 3D scene, and the two were tuned separately. `check_layers`
cross-checks the layer *set and order* — the part that must agree — and
leaves the palettes alone.
- Order is load-bearing. Material creation order fixes the material indices in
the exported GLB, and layer order fixes mesh creation order, so both lists
are sequences, not dicts, and appending is the only safe edit.
"""
import json
import os
# Draw order, bottom first. `z` is the Blender height in metres that keeps the
# markings above the asphalt without z-fighting; `id` matches the GeoJSON file
# stem written by the intermediates stage.
ROAD_LAYERS = [
{"id": "road_surface", "material": "Road Asphalt",
"color": (0.055, 0.065, 0.070), "z": 0.03},
{"id": "intersection_surface", "material": "Intersection Asphalt",
"color": (0.065, 0.075, 0.080), "z": 0.035},
{"id": "sidewalks", "material": "Sidewalk",
"color": (0.49, 0.51, 0.49), "z": 0.065},
{"id": "sidewalk_corners", "material": "Sidewalk Corner",
"color": (0.49, 0.51, 0.49), "z": 0.067},
{"id": "lane_separators", "material": "Lane Separator",
"color": (0.85, 0.84, 0.72), "z": 0.090},
{"id": "center_lines", "material": "Center Line",
"color": (0.94, 0.58, 0.06), "z": 0.092},
{"id": "crosswalks", "material": "Crosswalk",
"color": (0.95, 0.94, 0.82), "z": 0.094},
{"id": "vehicle_stop_lines", "material": "Stop Line",
"color": (0.95, 0.94, 0.82), "z": 0.096},
{"id": "lane_arrows_webscale", "material": "Lane Arrow",
"color": (0.95, 0.94, 0.82), "z": 0.098},
]
SCENE_STYLE_FILE = "osm2streets_scene_style.json"
# Material specs. `kind` selects the builder:
# solid — flat base colour
# textured — Poly Haven diffuse + normal, optionally tinted
# `procedural` adds noise-driven base colour and bump on top of a solid.
MATERIALS = {
"ground": {"kind": "solid", "name": "Ground", "color": (0.27, 0.32, 0.24)},
"water": {"kind": "solid", "name": "Lake Water", "color": (0.035, 0.22, 0.30),
"roughness": 0.18, "metallic": 0.05},
"grass": {"kind": "textured", "name": "Grass",
"diffuse": "leafy_grass_diff_1k.jpg",
"normal": "leafy_grass_nor_gl_1k.jpg",
"roughness": 0.92, "scale": 7.0,
"tint": (0.12, 0.48, 0.08), "tint_factor": 0.72},
"scrub": {"kind": "textured", "name": "Scrub Ground Cover",
"diffuse": "leafy_grass_diff_1k.jpg",
"normal": "leafy_grass_nor_gl_1k.jpg",
"roughness": 0.96, "scale": 15.0,
"tint": (0.085, 0.30, 0.065), "tint_factor": 0.46},
"fountain_stone": {"kind": "solid", "name": "Fountain Stone",
"color": (0.42, 0.45, 0.43), "roughness": 0.72},
"fountain_water": {"kind": "solid", "name": "Fountain Water",
"color": (0.03, 0.32, 0.42), "roughness": 0.16,
"metallic": 0.05},
"fountain_spray": {"kind": "solid", "name": "Fountain Spray",
"color": (0.20, 0.70, 0.78), "roughness": 0.12,
"metallic": 0.02},
"building_default": {"kind": "textured", "name": "Office White Plaster Facade",
"diffuse": "white_plaster_02_diff_1k.jpg",
"normal": "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},
"building_industrial": {"kind": "textured",
"name": "Industrial White Ribbed Facade",
"diffuse": "corrugated_iron_03_diff_1k.jpg",
"normal": "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},
"building_office_roof": {"kind": "textured", "name": "Office Light Flat Roof",
"diffuse": "concrete_floor_02_diff_1k.jpg",
"normal": "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},
"building_industrial_roof": {"kind": "textured",
"name": "Factory Blue Metal Roof",
"diffuse": "blue_metal_plate_diff_1k.jpg",
"normal": "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},
"building_glass": {"kind": "solid", "name": "Office Blue Gray Glass",
"color": (0.12, 0.20, 0.24), "roughness": 0.22,
"metallic": 0.10},
"building_factory_glass": {"kind": "solid", "name": "Factory Dark Windows",
"color": (0.10, 0.14, 0.15), "roughness": 0.28,
"metallic": 0.08},
"tree_trunk": {"kind": "textured", "name": "Tree Trunk",
"diffuse": "bark_brown_01_diff_1k.jpg",
"normal": "bark_brown_01_nor_gl_1k.jpg",
"roughness": 0.92, "scale": 5.0},
"tree_crown_dark": {"kind": "solid", "name": "Tree Crown Dark",
"color": (0.065, 0.25, 0.055), "roughness": 0.90,
"procedural": {"colors": ((0.035, 0.14, 0.035),
(0.12, 0.36, 0.08)),
"scale": 3.2, "detail": 3.8,
"bump_strength": 0.08},
"cesium": {"tint": ((0.06, 0.22, 0.05), 0.18)}},
"tree_crown_light": {"kind": "solid", "name": "Tree Crown Light",
"color": (0.13, 0.42, 0.09), "roughness": 0.88,
"procedural": {"colors": ((0.07, 0.25, 0.05),
(0.22, 0.56, 0.13)),
"scale": 3.6, "detail": 3.4,
"bump_strength": 0.07},
"cesium": {"tint": ((0.16, 0.42, 0.09), 0.16)}},
"tree_crown": {"kind": "solid", "name": "Tree Crown",
"color": (0.10, 0.36, 0.08), "roughness": 0.88,
"procedural": {"colors": ((0.04, 0.18, 0.04),
(0.18, 0.50, 0.12)),
"scale": 2.8, "detail": 3.2,
"bump_strength": 0.10},
"cesium": {"tint": None,
"base_color": (0.11, 0.34, 0.075),
"emission": ((0.04, 0.11, 0.035), 0.02)}},
}
# Cesium-specific overrides that don't have a home in the material system yet:
# metallic overrides (flat values, not materials) and emission overrides for
# colours that export_cesium.py hand-tuned separately.
CESIUM_EXPORT = {
"metallic_overrides": {
"Office White Plaster Facade": 0.0,
"Industrial White Ribbed Facade": 0.08,
},
"emission_overrides": {
"Office White Plaster Facade": ((0.93, 0.94, 0.91), 0.18),
"Office Light Flat Roof": ((0.88, 0.90, 0.88), 0.14),
"Industrial White Ribbed Facade": ((0.90, 0.93, 0.91), 0.18),
"Factory Blue Metal Roof": ((0.08, 0.50, 0.88), 0.12),
},
}
def road_material_specs():
"""Road layer materials as MATERIALS-shaped specs, in draw order."""
return [{"kind": "solid", "name": layer["material"], "color": layer["color"]}
for layer in ROAD_LAYERS]
def check_layers(geojson_dir):
"""Warn when the intermediates stage and this catalog disagree on layers.
The style JSON is written next to the GeoJSON by the intermediates and
reimport stages. A layer added on the JS side but not here would be
silently dropped from the 3D scene, which is exactly the kind of drift the
single-source-of-truth split is meant to make loud. Warn rather than fail:
a stale or absent output directory should not block a rebuild.
"""
style_path = os.path.join(geojson_dir or "", SCENE_STYLE_FILE)
if not geojson_dir or not os.path.exists(style_path):
return []
try:
with open(style_path, "r", encoding="utf-8") as handle:
style = json.load(handle)
except (OSError, ValueError) as error:
return ["Could not read %s: %s" % (style_path, error)]
upstream = [entry.get("id") for entry in style.get("layers", [])]
local = [layer["id"] for layer in ROAD_LAYERS]
problems = []
for missing in [i for i in upstream if i not in local]:
problems.append(
"layer '%s' exists in %s but not in catalog.ROAD_LAYERS "
"(it will not reach the 3D scene)" % (missing, SCENE_STYLE_FILE))
for extra in [i for i in local if i not in upstream]:
problems.append(
"layer '%s' is in catalog.ROAD_LAYERS but not in %s "
"(no GeoJSON will be produced for it)" % (extra, SCENE_STYLE_FILE))
if not problems and upstream != local:
problems.append(
"layer draw order differs: %s produces %s, catalog stacks %s"
% (SCENE_STYLE_FILE, upstream, local))
return problems

148
blender/osmassets/geom.py Normal file
View File

@@ -0,0 +1,148 @@
"""Planar geometry helpers for the OSM → asset pipeline.
Pure Python: no `bpy`, so this runs and tests outside Blender. All functions
work in projected metres (see `osmassets.osm.Projector`) unless the name says
otherwise; `geometry_rings` and `feature_in_bounds` take raw GeoJSON and are the
two exceptions, operating on lon/lat.
Rings are lists of (x, y) tuples. A repeated closing point is tolerated
everywhere but never required.
"""
import math
def geometry_rings(geometry):
"""Exterior rings of a GeoJSON Polygon/MultiPolygon; holes are dropped."""
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):
"""True when any coordinate of the feature falls inside the padded bounds."""
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):
"""Sutherland-Hodgman clip of a ring against an axis-aligned box."""
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 sample_tree_row(points, spacing, height):
"""Evenly space (x, y, height) samples along a polyline.
The trailing point is appended only when the last regular sample stops well
short of it, so a row does not end in a double-planted tree.
"""
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 polygon_area(ring):
"""Unsigned shoelace area; 0.0 for degenerate rings."""
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):
"""Shortest distance from a point to the ring's edges (not its interior)."""
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

View File

@@ -0,0 +1,22 @@
"""Grass feature assembly (`landuse=grass`) with optional tuft scattering."""
from osmassets.geom import clip_polygon
from osmassets.mesh import MeshBatch
def assemble(ring, way_id, scene_xmin, scene_xmax, scene_ymin, scene_ymax,
green_c, grass_mat, tuft_variants, add_grass_tufts_fn):
ring = clip_polygon(ring, scene_xmin, scene_xmax, scene_ymin, scene_ymax)
name = "Grass_" + str(way_id)
focus = list(ring)
if len(ring) < 3:
return 0, 0, focus
batch = MeshBatch(name, green_c, grass_mat)
batch.add_polygon(ring, 0.015)
obj = batch.finish()
tufts = 0
if tuft_variants:
tufts = add_grass_tufts_fn(name, ring, tuft_variants, green_c)
if obj:
obj["grass_tufts"] = tufts
return 1, tufts, focus

View File

@@ -0,0 +1,172 @@
"""Blender material construction.
Requires `bpy`; only runs inside Blender. The catalog (`osmassets.catalog`)
declares *what* a material is, this module builds it — that split is what keeps
the catalog importable by plain Python, and by anything else that wants to read
the scene's material definitions without launching Blender.
"""
import os
import bpy
TEXTURE_ROOT = os.path.abspath(os.path.join(
os.path.dirname(os.path.abspath(__file__)), "..", "..",
"assets", "textures", "polyhaven"
))
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
def from_spec(spec):
"""Build a material from a `catalog.MATERIALS` entry."""
if spec["kind"] == "textured":
return make_textured_material(
spec["name"], spec["diffuse"], spec["normal"],
roughness=spec.get("roughness", 0.8), scale=spec["scale"],
normal_is_bump=spec.get("normal_is_bump", False),
metallic=spec.get("metallic", 0.0),
tint=spec.get("tint"), tint_factor=spec.get("tint_factor", 0.0))
material = make_material(spec["name"], spec["color"],
spec.get("roughness", 0.8),
spec.get("metallic", 0.0))
procedural = spec.get("procedural")
if procedural:
add_procedural_surface(material, procedural["colors"],
scale=procedural["scale"],
detail=procedural["detail"],
bump_strength=procedural["bump_strength"],
object_space=procedural.get("object_space", False))
return material

126
blender/osmassets/mesh.py Normal file
View File

@@ -0,0 +1,126 @@
"""Mesh and collection construction for the Blender scene.
Requires `bpy`; only runs inside Blender.
`MeshBatch` is the workhorse: most of the scene is flat polygons and extruded
prisms, and batching them into one mesh datablock per logical group keeps the
object count (and the glTF node count) down. Callers accumulate geometry and
call `finish()` once.
"""
import math
import bpy
def new_collection(name):
collection = bpy.data.collections.new(name)
bpy.context.scene.collection.children.link(collection)
return collection
def link_object_to_collection(obj, collection):
for current in list(obj.users_collection):
current.objects.unlink(obj)
collection.objects.link(obj)
class MeshBatch:
"""Accumulates polygons/prisms into a single mesh object."""
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)
# Foliage reads as blobby volume, so it wants smooth normals; the built
# environment wants its facets. The name prefix is the discriminator.
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):
"""Add a thin inset slab along a facade edge, used for window bands."""
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_polyline(name, coords, projector, collection, material, width, z):
"""Bevelled curve along lon/lat coordinates; the OSM highway road fallback."""
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)

89
blender/osmassets/osm.py Normal file
View File

@@ -0,0 +1,89 @@
"""OSM XML parsing and the local metric projection.
Pure Python: no `bpy`, so this runs and tests outside Blender.
"""
import math
import xml.etree.ElementTree as ET
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
def parse_height(feature_tags, default):
try:
return max(0.5, float(feature_tags.get("height", default)))
except ValueError:
return default
class Projector:
"""Equirectangular projection about the centre of the OSM bounds.
Output is metres in a local ENU frame (X east, Y north), which is what both
the Blender scene and the Cesium GLB are authored in.
"""
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]

View File

@@ -0,0 +1,13 @@
"""Scrub feature assembly (`natural=scrub`). Flat ground cover only."""
from osmassets.geom import clip_polygon
def assemble(ring, way_id, scene_xmin, scene_xmax, scene_ymin, scene_ymax,
green_c, scrub_mat, add_scrub_patch_fn):
ring = clip_polygon(ring, scene_xmin, scene_xmax, scene_ymin, scene_ymax)
focus = list(ring)
if len(ring) < 3:
return 0, focus
add_scrub_patch_fn("Scrub_" + str(way_id), ring, scrub_mat, green_c)
return 1, focus

View File

@@ -0,0 +1,15 @@
"""Water feature assembly (`natural=water` or `water=lake`)."""
from osmassets.geom import clip_polygon
from osmassets.mesh import MeshBatch
def assemble(ring, scene_xmin, scene_xmax, scene_ymin, scene_ymax,
water_c, water_mat):
ring = clip_polygon(ring, scene_xmin, scene_xmax, scene_ymin, scene_ymax)
if len(ring) < 3:
return 0
batch = MeshBatch("Lake Surface", water_c, water_mat)
batch.add_polygon(ring, 0.10)
batch.finish()
return 1

314
blender/tests/test_pure.py Normal file
View File

@@ -0,0 +1,314 @@
"""Tests for the bpy-free half of the pipeline.
python3 -m unittest discover blender/tests
These run without Blender, which is the point of the osmassets split: before
it, the only way to exercise clip_polygon or sample_tree_row was to render a
whole area and look at the picture.
The expected values are derived from the geometry, not captured from the
implementation — a test that just records current output would ratify a bug.
"""
import math
import os
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
from osmassets.geom import (
clip_polygon,
distance_to_ring,
feature_in_bounds,
geometry_rings,
point_in_polygon,
polygon_area,
sample_tree_row,
)
from osmassets.osm import Projector, parse_height, parse_osm, tags
SQUARE = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]
class GeometryRingsTest(unittest.TestCase):
def test_polygon_keeps_only_the_exterior_ring(self):
geometry = {"type": "Polygon", "coordinates": [["outer"], ["hole"]]}
self.assertEqual(geometry_rings(geometry), [["outer"]])
def test_multipolygon_takes_each_exterior_ring(self):
geometry = {"type": "MultiPolygon",
"coordinates": [[["a"], ["a hole"]], [["b"]]]}
self.assertEqual(geometry_rings(geometry), [["a"], ["b"]])
def test_unsupported_and_empty_geometry(self):
self.assertEqual(geometry_rings(None), [])
self.assertEqual(geometry_rings({}), [])
self.assertEqual(geometry_rings({"type": "LineString",
"coordinates": [[0, 0], [1, 1]]}), [])
self.assertEqual(geometry_rings({"type": "MultiPolygon",
"coordinates": [[], [["b"]]]}), [["b"]])
class ClipPolygonTest(unittest.TestCase):
def test_polygon_inside_the_box_is_unchanged(self):
clipped = clip_polygon(SQUARE, -1.0, 11.0, -1.0, 11.0)
self.assertEqual([(round(x, 6), round(y, 6)) for x, y in clipped], SQUARE)
def test_half_outside_polygon_is_cut_at_the_boundary(self):
clipped = clip_polygon(SQUARE, 0.0, 5.0, 0.0, 10.0)
self.assertTrue(all(x <= 5.0 + 1e-9 for x, _ in clipped))
# A 10x10 square clipped to half its width is a 5x10 rectangle.
self.assertAlmostEqual(polygon_area(clipped), 50.0, places=6)
def test_polygon_fully_outside_collapses(self):
self.assertEqual(clip_polygon(SQUARE, 20.0, 30.0, 20.0, 30.0), [])
def test_degenerate_input(self):
self.assertEqual(clip_polygon([], 0, 1, 0, 1), [])
self.assertEqual(clip_polygon([(0.0, 0.0), (1.0, 1.0)], 0, 1, 0, 1), [])
def test_axis_aligned_edge_does_not_divide_by_zero(self):
# A vertical edge crossing the x clip plane exercises the b[0] == a[0]
# guard in the intersection lambdas.
ring = [(5.0, -5.0), (5.0, 5.0), (-5.0, 5.0), (-5.0, -5.0)]
clipped = clip_polygon(ring, 0.0, 10.0, 0.0, 10.0)
self.assertAlmostEqual(polygon_area(clipped), 25.0, places=6)
class PolygonAreaTest(unittest.TestCase):
def test_square(self):
self.assertAlmostEqual(polygon_area(SQUARE), 100.0)
def test_winding_does_not_change_the_sign(self):
self.assertAlmostEqual(polygon_area(list(reversed(SQUARE))), 100.0)
def test_degenerate(self):
self.assertEqual(polygon_area([(0.0, 0.0), (1.0, 1.0)]), 0.0)
class PointInPolygonTest(unittest.TestCase):
def test_inside_and_outside(self):
self.assertTrue(point_in_polygon((5.0, 5.0), SQUARE))
self.assertFalse(point_in_polygon((15.0, 5.0), SQUARE))
self.assertFalse(point_in_polygon((5.0, -0.5), SQUARE))
def test_concave_notch_is_excluded(self):
# An L shape: the notch at (8, 8) is outside even though it sits inside
# the bounding box.
shape = [(0.0, 0.0), (10.0, 0.0), (10.0, 5.0),
(5.0, 5.0), (5.0, 10.0), (0.0, 10.0)]
self.assertTrue(point_in_polygon((2.0, 8.0), shape))
self.assertFalse(point_in_polygon((8.0, 8.0), shape))
class DistanceToRingTest(unittest.TestCase):
def test_distance_is_to_the_edge_not_the_interior(self):
# Centre of the square: 5m from every edge, even though it is inside.
self.assertAlmostEqual(distance_to_ring((5.0, 5.0), SQUARE), 5.0)
self.assertAlmostEqual(distance_to_ring((1.0, 5.0), SQUARE), 1.0)
def test_outside_point(self):
self.assertAlmostEqual(distance_to_ring((-3.0, 5.0), SQUARE), 3.0)
def test_closes_the_ring(self):
# Nearest edge is the implicit closing segment from (0,10) back to (0,0).
self.assertAlmostEqual(distance_to_ring((-2.0, 9.0), SQUARE), 2.0)
def test_repeated_vertex_does_not_divide_by_zero(self):
ring = [(0.0, 0.0), (0.0, 0.0), (4.0, 0.0)]
self.assertAlmostEqual(distance_to_ring((2.0, 3.0), ring), 3.0)
class SampleTreeRowTest(unittest.TestCase):
def test_even_spacing_along_a_straight_line(self):
samples = sample_tree_row([(0.0, 0.0), (10.0, 0.0)], spacing=5.0, height=6.0)
self.assertEqual([(round(x, 6), round(y, 6)) for x, y, _ in samples],
[(0.0, 0.0), (5.0, 0.0), (10.0, 0.0)])
self.assertTrue(all(h == 6.0 for _, _, h in samples))
def test_spacing_carries_across_segment_joins(self):
# Two 3m segments with 4m spacing: the second sample must land 1m into
# the second segment, not restart at its origin.
samples = sample_tree_row([(0.0, 0.0), (3.0, 0.0), (6.0, 0.0)],
spacing=4.0, height=5.0)
xs = [round(x, 6) for x, _, _ in samples]
self.assertEqual(xs, [0.0, 4.0, 6.0])
def test_trailing_point_is_skipped_when_it_would_double_plant(self):
# Endpoint sits 0.2m past the last sample, well under spacing * 0.45.
samples = sample_tree_row([(0.0, 0.0), (5.2, 0.0)], spacing=5.0, height=5.0)
self.assertEqual([round(x, 6) for x, _, _ in samples], [0.0, 5.0])
def test_zero_length_segment_is_skipped(self):
samples = sample_tree_row([(0.0, 0.0), (0.0, 0.0), (10.0, 0.0)],
spacing=5.0, height=5.0)
self.assertEqual([round(x, 6) for x, _, _ in samples], [0.0, 5.0, 10.0])
def test_too_few_points(self):
self.assertEqual(sample_tree_row([(0.0, 0.0)], spacing=5.0, height=5.0), [])
BOUNDS = {"min_lon": 114.0, "min_lat": 30.0, "max_lon": 114.01, "max_lat": 30.01}
class ProjectorTest(unittest.TestCase):
def setUp(self):
self.projector = Projector(BOUNDS)
def test_centre_of_bounds_is_the_origin(self):
x, y = self.projector.xy((114.005, 30.005))
self.assertAlmostEqual(x, 0.0, places=6)
self.assertAlmostEqual(y, 0.0, places=6)
def test_axes_point_east_and_north(self):
east, _ = self.projector.xy((114.006, 30.005))
_, north = self.projector.xy((114.005, 30.006))
self.assertGreater(east, 0.0)
self.assertGreater(north, 0.0)
def test_longitude_metres_shrink_with_latitude(self):
self.assertAlmostEqual(
self.projector.m_per_lon,
111320.0 * math.cos(math.radians(30.005)),
places=6,
)
self.assertLess(self.projector.m_per_lon, self.projector.m_per_lat)
def test_inside_honours_the_pad(self):
self.assertTrue(self.projector.inside((114.005, 30.005)))
# Default pad is 0.00035 degrees, so just outside the box still counts.
self.assertTrue(self.projector.inside((114.0102, 30.005)))
self.assertFalse(self.projector.inside((114.02, 30.005)))
self.assertFalse(self.projector.inside((114.0102, 30.005), pad=0.0))
def test_ring_projects_every_coordinate(self):
ring = self.projector.ring([(114.0, 30.0), (114.01, 30.01)])
self.assertEqual(len(ring), 2)
self.assertLess(ring[0][0], 0.0)
self.assertGreater(ring[1][0], 0.0)
class FeatureInBoundsTest(unittest.TestCase):
def setUp(self):
self.projector = Projector(BOUNDS)
def test_polygon_with_one_inside_vertex_counts(self):
feature = {"geometry": {"type": "Polygon", "coordinates": [[
[120.0, 40.0], [114.005, 30.005], [120.0, 40.0]]]}}
self.assertTrue(feature_in_bounds(feature, self.projector))
def test_feature_fully_outside(self):
feature = {"geometry": {"type": "Polygon", "coordinates": [[
[120.0, 40.0], [120.1, 40.1], [120.0, 40.0]]]}}
self.assertFalse(feature_in_bounds(feature, self.projector))
def test_missing_geometry(self):
self.assertFalse(feature_in_bounds({}, self.projector))
class ParseHeightTest(unittest.TestCase):
def test_reads_the_tag(self):
self.assertEqual(parse_height({"height": "24"}, 12.0), 24.0)
def test_missing_tag_falls_back(self):
self.assertEqual(parse_height({}, 12.0), 12.0)
def test_unparsable_tag_falls_back(self):
self.assertEqual(parse_height({"height": "about 20m"}, 12.0), 12.0)
def test_clamped_to_half_a_metre(self):
self.assertEqual(parse_height({"height": "0.1"}, 12.0), 0.5)
self.assertEqual(parse_height({"height": "-5"}, 12.0), 0.5)
OSM_SAMPLE = """<?xml version='1.0' encoding='UTF-8'?>
<osm version='0.6'>
<bounds minlon='114.0' minlat='30.0' maxlon='114.01' maxlat='30.01'/>
<node id='1' lon='114.001' lat='30.001'/>
<node id='2' lon='114.002' lat='30.002'/>
<node id='3' lon='114.003' lat='30.003'/>
<node id='4' lon='114.004' lat='30.004'>
<tag k='natural' v='tree'/>
<tag k='height' v='7'/>
</node>
<node id='bad' lon='oops' lat='30.0'/>
<way id='10'>
<nd ref='1'/><nd ref='2'/><nd ref='3'/>
<tag k='building' v='yes'/>
</way>
<way id='11' action='delete'>
<nd ref='1'/><nd ref='2'/>
<tag k='building' v='yes'/>
</way>
<way id='12'>
<nd ref='1'/><nd ref='999'/>
</way>
</osm>
"""
class ParseOsmTest(unittest.TestCase):
def setUp(self):
handle = tempfile.NamedTemporaryFile("w", suffix=".osm", delete=False,
encoding="utf-8")
handle.write(OSM_SAMPLE)
handle.close()
self.path = handle.name
def tearDown(self):
os.unlink(self.path)
def test_bounds(self):
bounds, _, _ = parse_osm(self.path)
self.assertEqual(bounds, {"min_lon": 114.0, "min_lat": 30.0,
"max_lon": 114.01, "max_lat": 30.01})
def test_only_tagged_nodes_become_point_features(self):
_, _, points = parse_osm(self.path)
self.assertEqual([p["id"] for p in points], ["4"])
self.assertEqual(points[0]["tags"], {"natural": "tree", "height": "7"})
def test_deleted_ways_are_dropped(self):
_, ways, _ = parse_osm(self.path)
self.assertNotIn("11", [w["id"] for w in ways])
def test_way_below_two_resolvable_nodes_is_dropped(self):
# Way 12 references a node that does not exist, leaving one coordinate.
_, ways, _ = parse_osm(self.path)
self.assertEqual([w["id"] for w in ways], ["10"])
self.assertEqual(len(ways[0]["coords"]), 3)
self.assertEqual(ways[0]["tags"], {"building": "yes"})
def test_unparsable_node_is_skipped_not_fatal(self):
_, _, points = parse_osm(self.path)
self.assertNotIn("bad", [p["id"] for p in points])
def test_missing_bounds_is_an_error(self):
handle = tempfile.NamedTemporaryFile("w", suffix=".osm", delete=False,
encoding="utf-8")
handle.write("<osm version='0.6'></osm>")
handle.close()
try:
with self.assertRaises(RuntimeError):
parse_osm(handle.name)
finally:
os.unlink(handle.name)
class TagsTest(unittest.TestCase):
def test_reads_key_value_children(self):
import xml.etree.ElementTree as ET
element = ET.fromstring(
"<way><tag k='building' v='yes'/><tag k='height' v='9'/></way>")
self.assertEqual(tags(element), {"building": "yes", "height": "9"})
def test_untagged_element(self):
import xml.etree.ElementTree as ET
self.assertEqual(tags(ET.fromstring("<way/>")), {})
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,171 @@
"""Dump a stable structural digest of a .blend built by generate_scene.py.
Run inside Blender:
Blender --background --factory-startup \
--python blender/tools/scene_digest.py -- \
--blend /path/to/scene.blend --out /path/to/digest.json
The digest is the parity contract for the osmassets refactor: it must stay
byte-identical across a pure restructuring. Fields that a control run (same
code, run twice) proves unstable belong in UNSTABLE_* below rather than in the
digest, otherwise the check is noise and gets ignored.
Floats are rounded to 6 decimals: Blender round-trips them through single
precision, so the last digits of a repr are not a meaningful signal.
"""
import json
import os
import sys
import bpy
# Object-level custom properties Blender adds on its own; not ours to compare.
IGNORED_PROP_KEYS = {"_RNA_UI", "cycles"}
def cli_args():
values = {"blend": None, "out": None}
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:]] = argv[i + 1]
i += 2
else:
i += 1
for key in ("blend", "out"):
if not values.get(key):
raise RuntimeError("--%s is required" % key)
return values
def rounded(value):
"""Normalise Blender's float/vector/array soup into plain JSON."""
if isinstance(value, float):
return round(value, 6)
if isinstance(value, (int, str, bool)) or value is None:
return value
if hasattr(value, "__len__") and not isinstance(value, (str, bytes)):
return [rounded(item) for item in value]
return str(value)
def custom_props(datablock):
out = {}
for key in sorted(datablock.keys()):
if key in IGNORED_PROP_KEYS:
continue
try:
out[key] = rounded(datablock[key])
except (TypeError, ValueError):
out[key] = "<unreadable>"
return out
def material_digest(material):
node_types = {}
if material.use_nodes and material.node_tree:
for node in material.node_tree.nodes:
node_types[node.type] = node_types.get(node.type, 0) + 1
entry = {
"name": material.name,
"diffuse_color": rounded(material.diffuse_color),
"use_nodes": material.use_nodes,
# Node identity is unstable (Blender names them Mix.001, Mix.002 …
# depending on creation order across datablocks), so compare the
# type histogram and the link count instead of the graph itself.
"node_types": dict(sorted(node_types.items())),
"link_count": (len(material.node_tree.links)
if material.use_nodes and material.node_tree else 0),
"props": custom_props(material),
}
if material.use_nodes and material.node_tree:
for node in material.node_tree.nodes:
if node.type != "BSDF_PRINCIPLED":
continue
for socket in ("Base Color", "Roughness", "Metallic"):
if socket in node.inputs:
entry["bsdf_" + socket.replace(" ", "_").lower()] = rounded(
node.inputs[socket].default_value)
break
return entry
def object_digest(obj):
entry = {
"name": obj.name,
"type": obj.type,
"collections": sorted(c.name for c in obj.users_collection),
"location": rounded(obj.location),
"rotation_euler": rounded(obj.rotation_euler),
"scale": rounded(obj.scale),
"data": obj.data.name if obj.data else None,
"materials": [slot.material.name if slot.material else None
for slot in obj.material_slots],
"modifiers": [(m.name, m.type) for m in obj.modifiers],
"props": custom_props(obj),
}
if obj.type == "MESH":
mesh = obj.data
entry["vertices"] = len(mesh.vertices)
entry["polygons"] = len(mesh.polygons)
entry["uv_layers"] = [layer.name for layer in mesh.uv_layers]
entry["smooth_polygons"] = sum(1 for p in mesh.polygons if p.use_smooth)
# Bounding box catches geometry that moved without changing topology;
# a vertex-by-vertex hash would be exact but too brittle to act on.
entry["bound_box"] = [rounded(corner) for corner in obj.bound_box]
elif obj.type == "CURVE":
entry["splines"] = len(obj.data.splines)
entry["points"] = sum(len(s.points) for s in obj.data.splines)
entry["bevel_depth"] = rounded(obj.data.bevel_depth)
elif obj.type == "LIGHT":
entry["light_type"] = obj.data.type
entry["energy"] = rounded(obj.data.energy)
elif obj.type == "CAMERA":
entry["lens"] = rounded(obj.data.lens)
entry["clip"] = [rounded(obj.data.clip_start), rounded(obj.data.clip_end)]
return entry
def digest(blend_path):
bpy.ops.wm.open_mainfile(filepath=blend_path)
scene = bpy.context.scene
return {
"scene": {
"name": scene.name,
"engine": scene.render.engine,
"resolution": [scene.render.resolution_x, scene.render.resolution_y],
"world_color": rounded(scene.world.color) if scene.world else None,
"camera": scene.camera.name if scene.camera else None,
"props": custom_props(scene),
},
"collections": sorted(c.name for c in bpy.data.collections),
"counts": {
"objects": len(bpy.data.objects),
"meshes": len(bpy.data.meshes),
"materials": len(bpy.data.materials),
"images": len(bpy.data.images),
},
"objects": [object_digest(obj)
for obj in sorted(bpy.data.objects, key=lambda o: o.name)],
"materials": [material_digest(mat)
for mat in sorted(bpy.data.materials, key=lambda m: m.name)],
"images": sorted(image.name for image in bpy.data.images),
}
if __name__ == "__main__":
args = cli_args()
result = digest(args["blend"])
os.makedirs(os.path.dirname(os.path.abspath(args["out"])), exist_ok=True)
with open(args["out"], "w", encoding="utf-8") as handle:
json.dump(result, handle, ensure_ascii=False, indent=2, sort_keys=True)
handle.write("\n")
print("DIGEST_DONE", json.dumps({
"blend": args["blend"], "out": args["out"],
"objects": len(result["objects"]),
"materials": len(result["materials"]),
}))