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

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
.DS_Store
node_modules/
outputs/
__pycache__/

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"]),
}))

112
docs/refactor-plan.md Normal file
View File

@@ -0,0 +1,112 @@
# 重构施工计划:`osmassets` 包化P0P3
> 临时工作文档。P3 收尾后把结论并入 `docs/changelog.md`,本文件删除。
## 目标
把「从 OSM 生成 Blender / Cesium 资产」的逻辑从两个单体脚本里拆成可复用的库,使得:
- 新增一种 OSM 要素 = 新增一个 `features/*.py` + 注册一行,不改 `build()`
- 道路图层表、材质规格只有一份定义JS 侧与 Python 侧不再各存一份
- `export_cesium.py` 不再靠材质名字符串跟 `generate_scene.py` 对接
- 纯几何 / 解析逻辑脱离 `bpy`,可用系统 python 直接测
## 硬约束:严格产物一致
P0P3 全程 **不改变任何输出**。每期结束必须通过 parity 校验,任何差异都要么消除、要么在本文件里逐条记录原因。
已知缺陷(本轮**只记录、不修**
| # | 位置 | 现象 |
|---|---|---|
| D1 | `export_cesium.py:26,34,40,52` | `"Office White Metal Facade"` 四张表里都有,`generate_scene.py` 里已无此材质——死条目 |
| D2 | `scene-layers.js:15` vs `generate_scene.py:1017` | 同一批图层的颜色两侧各自手调,无一致性保证 |
| D3 | `generate_scene.py:788` | `tuft_density_wave` 注释仍在跟已删除的 hedge banding 作对比 |
## Parity 工具与基线
`outputs/` 已在 `.gitignore` 中,基线快照放 `outputs/_refactor-baseline/`,不入库。
| 工具 | 位置 | 作用 |
|---|---|---|
| 场景摘要 | `blender/tools/scene_digest.py` | 在 Blender 内打开 `.blend`,输出稳定 JSON对象名/顶点数/面数/材质槽/自定义属性、材质参数、场景属性 |
| GLB 摘要 | `scripts/glb-digest.js` | 纯 Node 读 GLB 的 JSON chunk输出 node/mesh/material 清单与 PBR 参数,附 buffer 字节长度 |
| 驱动 | `scripts/parity.sh <label>` | 跑 blender+cesium 阶段 → 收集 `SCENE_DONE` / `CESIUM_EXPORT_DONE` / 两份摘要 / 渲染 PNG 到 `outputs/_refactor-baseline/<label>/` |
**先做对照实验control**用未改动的代码连跑两次diff 两份摘要。这一步确定哪些字段天然不确定,把这些字段列入忽略名单。没做这步的 parity 校验是假的。
已完成,结论如下(`control-1` vs `control-2`,两区域):
- `.blend` **结构摘要两次完全一致** —— 这是主校验信号,可信
- `.blend` 文件 sha256 不一致:内嵌绝对路径 + 图片打包顺序随哈希表走
- 渲染 PNG sha256 不一致EEVEE 非位级可复现
- GLB 结构node / mesh / primitive / material / image两次完全一致但 accessor 数 399 vs 398、buffer 差 720 字节glTF 导出器会去重相同 accessor`smart_project` 的 UV 带浮点噪声,一次能去重一次不能
忽略名单(写在 `scripts/parity.js:IGNORED_PATHS`,附原因):`files.{blend,glb,render}.sha256``files.{glb,render}.bytes``glbDigest.{fileBytes,buffers,counts.accessors}`
保留比对的即真正的契约:`SCENE_DONE` / `CESIUM_EXPORT_DONE` 标记、`.blend` 全量结构摘要、GLB 的 node/mesh/material/image 结构、`<area>.json` 元数据。加上 `control-1``control-2` 两份基线已落盘。
样本区域:
- `nantaizi-lake-innovation-valley` — 主样本OSM + osm2streets GeoJSON 齐全
- `hanyang-block` — 次样本,只有 `intermediates` 产物,需先补跑一次 blender 阶段生成基线
## 分期
### P0 — 抽纯函数(行为零变化)
新建 `blender/osmassets/`,只搬运、不改逻辑:
| 目标文件 | 从 `generate_scene.py` 搬入 | 依赖 |
|---|---|---|
| `osm.py` | `tags` (94)、`parse_osm` (99)、`Projector` (140)、`parse_height` (506) | 无 bpy |
| `geom.py` | `geometry_rings` (406)、`feature_in_bounds` (418)、`clip_polygon` (426)、`sample_tree_row` (513)、`polygon_area` (688)、`point_in_polygon` (697)、`distance_to_ring` (712) | 无 bpy |
- `generate_scene.py` 顶部加 `sys.path` 引导(`--factory-startup``blender/` 不在 `sys.path`),改为 `from osmassets import ...`
- 新增 `blender/tests/test_geom.py``test_osm.py``unittest` 标准库,系统 `python3` 直接跑(本机 3.9,避免 3.10+ 语法)
- 验收:`python3 -m unittest discover blender/tests` 通过 + parity 全绿
### P1 — 单一定义源
新建 `blender/osmassets/catalog.py`
- `ROAD_LAYERS``id` / `blender_z` / `material_name` / `color`,替换 `generate_scene.py:1017``road_mats``1122``layer_z` 两份副本
- `MATERIAL_SPECS`:目前散在 `build()` 里的全部 `make_material` / `make_textured_material` 调用参数
- 新增一致性检查:读输出目录里已存在的 `osm2streets_scene_style.json`,比对图层 id 集合与顺序,不一致则打 warning**不**改颜色,改了就破坏 parity → 见 D2
验收parity 全绿;手动删一个图层 id 验证 warning 生效。
### P2 — 要素注册表
新建 `blender/osmassets/features/`,每种要素一个模块,导出 `SPEC`
```
water.py natural=water / water=lake
grass.py landuse=grass含 tuft 散布)
scrub.py natural=scrub
tree.py natural=tree 节点 + natural=tree_row + 两种树风格
building.py building=*(含 roof / windows
fountain.py amenity=fountain
roads.py osm2streets GeoJSON 图层 + highway 折线回退
```
- `scene.py::assemble()` 遍历注册表;`build()` 收缩为「解析 → assemble → 灯光相机 → 存盘渲染」
- 计数器改由注册表汇总,但 `SCENE_DONE``scene[...]` 的键名、顺序保持逐字不变
- if/elif 的**匹配顺序**是语义的一部分(`building` 分支在最后),注册表必须保序
验收parity 全绿 —— 这期风险最高,逐要素分次提交,每次单独跑 parity。
### P3 — 材质契约化
- `catalog.py` 的材质规格扩展出 cesium 段:`tint` / `metallic` / `base_color` / `emission`
- `generate_scene.py` 把规格写进材质自定义属性 `material["cesium_export"] = json.dumps(spec)`
- `export_cesium.py` 优先读自定义属性;读不到时回落到现有四张名字表(**原样保留,含 D1 死条目**),保证旧 `.blend` 仍能导出且 parity 成立
- `Tree Crown` 的程序化贴图特例保持不变
验收parity 全绿;另外用重构前生成的旧 `.blend` 跑一次导出,确认回落路径可用。
## 不在本轮范围
- 输出目标可插拔(整场景 / 每要素单独 GLB——原 P4
- `build-area.js` 里 390 行内联 HTML 与手写 glTF 的拆分——原 P4
- 上表 D1D3 的修复

121
scripts/glb-digest.js Normal file
View File

@@ -0,0 +1,121 @@
#!/usr/bin/env node
"use strict";
// Structural digest of a GLB, for the osmassets refactor parity check.
//
// Byte-comparing the GLB is too strict: Blender packs images in hash-map order
// and the buffer padding shifts with it, so two runs of identical code can
// differ. This reads the glTF JSON chunk instead and reports the parts that
// carry meaning downstream in Cesium — node/mesh/material identity and PBR
// values — plus buffer lengths as a coarse size check.
//
// node scripts/glb-digest.js <file.glb> [--out digest.json]
const fs = require("fs");
const path = require("path");
function readGlbJson(file) {
const buffer = fs.readFileSync(file);
if (buffer.length < 12 || buffer.readUInt32LE(0) !== 0x46546c67) {
throw new Error(`Not a GLB (bad magic): ${file}`);
}
const total = buffer.readUInt32LE(8);
let offset = 12;
while (offset + 8 <= Math.min(total, buffer.length)) {
const chunkLength = buffer.readUInt32LE(offset);
const chunkType = buffer.readUInt32LE(offset + 4);
const start = offset + 8;
if (chunkType === 0x4e4f534a) {
return JSON.parse(buffer.slice(start, start + chunkLength).toString("utf8"));
}
offset = start + chunkLength;
}
throw new Error(`No JSON chunk found in ${file}`);
}
function round(value) {
if (typeof value === "number") return Number(value.toFixed(6));
if (Array.isArray(value)) return value.map(round);
return value;
}
function materialDigest(material) {
const pbr = material.pbrMetallicRoughness || {};
return {
name: material.name || null,
baseColorFactor: round(pbr.baseColorFactor || null),
metallicFactor: round(pbr.metallicFactor ?? null),
roughnessFactor: round(pbr.roughnessFactor ?? null),
hasBaseColorTexture: Boolean(pbr.baseColorTexture),
hasNormalTexture: Boolean(material.normalTexture),
emissiveFactor: round(material.emissiveFactor || null),
emissiveStrength: round(
material.extensions?.KHR_materials_emissive_strength?.emissiveStrength ?? null,
),
alphaMode: material.alphaMode || null,
doubleSided: material.doubleSided ?? null,
};
}
function digest(file) {
const gltf = readGlbJson(file);
const meshes = (gltf.meshes || []).map((mesh) => ({
name: mesh.name || null,
primitives: (mesh.primitives || []).map((primitive) => ({
material: primitive.material ?? null,
attributes: Object.keys(primitive.attributes || {}).sort(),
// Vertex/index counts live on the accessors; they are the real geometry
// fingerprint and stay stable regardless of buffer layout.
count: gltf.accessors?.[primitive.attributes?.POSITION]?.count ?? null,
indices: gltf.accessors?.[primitive.indices]?.count ?? null,
})),
}));
return {
file: path.basename(file),
fileBytes: fs.statSync(file).size,
counts: {
nodes: (gltf.nodes || []).length,
meshes: meshes.length,
materials: (gltf.materials || []).length,
images: (gltf.images || []).length,
accessors: (gltf.accessors || []).length,
},
extensionsUsed: (gltf.extensionsUsed || []).slice().sort(),
buffers: (gltf.buffers || []).map((buffer) => buffer.byteLength),
nodes: (gltf.nodes || [])
.map((node) => ({
name: node.name || null,
mesh: node.mesh ?? null,
translation: round(node.translation || null),
rotation: round(node.rotation || null),
scale: round(node.scale || null),
extras: node.extras ?? null,
}))
.sort((a, b) => String(a.name).localeCompare(String(b.name))),
meshes: meshes.sort((a, b) => String(a.name).localeCompare(String(b.name))),
materials: (gltf.materials || [])
.map(materialDigest)
.sort((a, b) => String(a.name).localeCompare(String(b.name))),
images: (gltf.images || [])
.map((image) => ({ name: image.name || null, mimeType: image.mimeType || null }))
.sort((a, b) => String(a.name).localeCompare(String(b.name))),
};
}
const argv = process.argv.slice(2);
const file = argv.find((arg) => !arg.startsWith("--"));
if (!file) {
console.error("usage: node scripts/glb-digest.js <file.glb> [--out digest.json]");
process.exit(1);
}
const outIndex = argv.indexOf("--out");
const result = digest(path.resolve(file));
const text = `${JSON.stringify(result, null, 2)}\n`;
if (outIndex >= 0 && argv[outIndex + 1]) {
const out = path.resolve(argv[outIndex + 1]);
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, text);
console.log(`GLB digest: ${out}`);
} else {
process.stdout.write(text);
}

270
scripts/parity.js Normal file
View File

@@ -0,0 +1,270 @@
#!/usr/bin/env node
"use strict";
// Parity harness for the osmassets refactor (docs/refactor-plan.md).
//
// node scripts/parity.js capture <label> [--areas a,b] [--stages blender,cesium]
// node scripts/parity.js compare <labelA> <labelB>
//
// capture runs the pipeline and snapshots everything that must not change:
// the stage stdout markers, a structural digest of the .blend, a structural
// digest of the .glb, and a hash of the render PNG. compare diffs two
// snapshots field by field.
//
// Snapshots live under outputs/_refactor-baseline/<label>/, which is inside
// the gitignored outputs/ tree — baselines are local scratch, not artifacts.
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const { spawnSync } = require("child_process");
const repoRoot = path.resolve(__dirname, "..");
const baselineRoot = path.join(repoRoot, "outputs", "_refactor-baseline");
const DEFAULT_AREAS = ["nantaizi-lake-innovation-valley", "hanyang-block"];
// Fields a control run (identical code, run twice) proved unstable. They are
// still recorded — a human reading a snapshot wants them — but comparing them
// would bury real regressions under noise.
//
// files.*.sha256 / .bytes for blend, glb, render
// .blend embeds absolute paths and packs images in hash-map order, so the
// file hash moves while the structural digest stays put. The EEVEE render
// is likewise not bit-reproducible.
// glbDigest.fileBytes / .buffers / .counts.accessors
// The glTF exporter deduplicates identical accessors. smart_project UVs
// carry float noise, so two runs can differ by one shared UV accessor
// (observed: 399 vs 398 accessors, 720 bytes) with identical nodes,
// meshes, primitives, materials and images.
//
// What remains compared is the real contract: the SCENE_DONE / CESIUM markers,
// the full .blend structural digest (objects, meshes, materials, custom
// properties), and the GLB node/mesh/material/image structure.
const IGNORED_PATHS = new Set([
"capturedAt",
"durationMs",
"label",
"files.blend.sha256",
"files.glb.sha256",
"files.glb.bytes",
"files.render.sha256",
"files.render.bytes",
"glbDigest.fileBytes",
"glbDigest.buffers",
"glbDigest.counts.accessors",
]);
function main() {
const [command, ...rest] = process.argv.slice(2);
if (command === "capture") return capture(rest);
if (command === "compare") return compare(rest);
console.error("usage: parity.js capture <label> [--areas a,b] [--stages s]");
console.error(" parity.js compare <labelA> <labelB>");
process.exit(1);
}
function parseFlags(argv) {
const flags = {};
const positional = [];
for (let i = 0; i < argv.length; i += 1) {
if (argv[i].startsWith("--") && i + 1 < argv.length) {
flags[argv[i].slice(2)] = argv[i + 1];
i += 1;
} else {
positional.push(argv[i]);
}
}
return { flags, positional };
}
function capture(argv) {
const { flags, positional } = parseFlags(argv);
const label = positional[0];
if (!label) throw new Error("capture needs a label");
const areas = (flags.areas ? flags.areas.split(",") : DEFAULT_AREAS)
.map((a) => a.trim())
.filter(Boolean);
const stages = flags.stages || "blender,cesium";
for (const area of areas) {
const configPath = path.join(repoRoot, "config", "areas", `${area}.json`);
if (!fs.existsSync(configPath)) throw new Error(`No config for area: ${area}`);
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
const areaDir = path.join(repoRoot, "outputs", area);
const stem = area;
const outDir = path.join(baselineRoot, label, area);
fs.mkdirSync(outDir, { recursive: true });
console.log(`\n=== parity capture [${label}] ${area} (stages: ${stages}) ===`);
const started = Date.now();
const run = spawnSync(process.execPath, [
path.join(repoRoot, "scripts", "build-area.js"),
"--config", configPath,
"--stages", stages,
], { cwd: repoRoot, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
const stdout = `${run.stdout || ""}`;
const stderr = `${run.stderr || ""}`;
process.stdout.write(stdout);
if (run.status !== 0) {
process.stderr.write(stderr);
throw new Error(`build-area failed for ${area} (exit ${run.status})`);
}
const snapshot = {
label,
area,
stages,
capturedAt: new Date().toISOString(),
durationMs: Date.now() - started,
markers: {
scene: parseMarker(stdout, "SCENE_DONE"),
cesium: parseMarker(stdout, "CESIUM_EXPORT_DONE"),
},
files: {},
};
const blend = path.join(areaDir, `${stem}.blend`);
if (fs.existsSync(blend)) {
snapshot.files.blend = fileStat(blend);
snapshot.blendDigest = blendDigest(config, blend, path.join(outDir, "blend-digest.json"));
}
const glb = path.join(areaDir, `${stem}.glb`);
if (fs.existsSync(glb)) {
snapshot.files.glb = fileStat(glb);
snapshot.glbDigest = glbDigest(glb, path.join(outDir, "glb-digest.json"));
}
for (const [key, file] of [
["render", path.join(areaDir, `${stem}.png`)],
["metadata", path.join(areaDir, `${stem}.json`)],
]) {
if (fs.existsSync(file)) snapshot.files[key] = fileStat(file);
}
if (fs.existsSync(path.join(areaDir, `${stem}.json`))) {
snapshot.metadata = JSON.parse(
fs.readFileSync(path.join(areaDir, `${stem}.json`), "utf8"),
);
}
writeJson(path.join(outDir, "snapshot.json"), snapshot);
console.log(`Snapshot: ${path.join(outDir, "snapshot.json")}`);
}
}
function parseMarker(stdout, marker) {
const line = stdout.split("\n").find((l) => l.startsWith(`${marker} `));
if (!line) return null;
try {
return JSON.parse(line.slice(marker.length + 1));
} catch (error) {
return { unparsed: line };
}
}
function fileStat(file) {
const buffer = fs.readFileSync(file);
return {
bytes: buffer.length,
sha256: crypto.createHash("sha256").update(buffer).digest("hex"),
};
}
function blendDigest(config, blend, outFile) {
const blenderApp = config.blenderApp || "/Applications/Blender.app";
const blender = path.join(blenderApp, "Contents", "MacOS", "Blender");
const run = spawnSync(blender, [
"--background", "--factory-startup",
"--python", path.join(repoRoot, "blender", "tools", "scene_digest.py"),
"--", "--blend", blend, "--out", outFile,
], { cwd: repoRoot, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
if (run.status !== 0) {
process.stderr.write(`${run.stdout || ""}${run.stderr || ""}`);
throw new Error(`scene_digest failed for ${blend}`);
}
return JSON.parse(fs.readFileSync(outFile, "utf8"));
}
function glbDigest(glb, outFile) {
const run = spawnSync(process.execPath, [
path.join(repoRoot, "scripts", "glb-digest.js"), glb, "--out", outFile,
], { cwd: repoRoot, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
if (run.status !== 0) {
process.stderr.write(`${run.stdout || ""}${run.stderr || ""}`);
throw new Error(`glb-digest failed for ${glb}`);
}
return JSON.parse(fs.readFileSync(outFile, "utf8"));
}
function writeJson(file, value) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
}
function compare(argv) {
const [a, b] = argv;
if (!a || !b) throw new Error("compare needs two labels");
const areas = fs.readdirSync(path.join(baselineRoot, a))
.filter((entry) => fs.existsSync(path.join(baselineRoot, a, entry, "snapshot.json")));
let differences = 0;
for (const area of areas) {
const left = readSnapshot(a, area);
const right = readSnapshot(b, area);
if (!right) {
console.log(`\n[${area}] missing in ${b} — skipped`);
continue;
}
const diffs = [];
diffValues("", left, right, diffs);
console.log(`\n=== ${area}: ${a} vs ${b} ===`);
if (!diffs.length) {
console.log("identical");
} else {
differences += diffs.length;
for (const line of diffs.slice(0, 200)) console.log(line);
if (diffs.length > 200) console.log(`${diffs.length - 200} more`);
}
}
console.log(`\n${differences === 0 ? "PARITY OK" : `PARITY DIFF (${differences})`}`);
process.exitCode = differences === 0 ? 0 : 2;
}
function readSnapshot(label, area) {
const file = path.join(baselineRoot, label, area, "snapshot.json");
return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : null;
}
function diffValues(pathKey, left, right, out) {
if (IGNORED_PATHS.has(pathKey)) return;
if (left === right) return;
const bothObjects = left && right && typeof left === "object" && typeof right === "object";
if (!bothObjects) {
out.push(` ${pathKey || "<root>"}: ${format(left)} -> ${format(right)}`);
return;
}
if (Array.isArray(left) !== Array.isArray(right)) {
out.push(` ${pathKey}: array/object mismatch`);
return;
}
if (Array.isArray(left)) {
if (left.length !== right.length) {
out.push(` ${pathKey}.length: ${left.length} -> ${right.length}`);
}
const limit = Math.min(left.length, right.length);
for (let i = 0; i < limit; i += 1) {
diffValues(`${pathKey}[${i}]`, left[i], right[i], out);
}
return;
}
const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
for (const key of [...keys].sort()) {
diffValues(pathKey ? `${pathKey}.${key}` : key, left[key], right[key], out);
}
}
function format(value) {
if (value === undefined) return "<missing>";
const text = JSON.stringify(value);
return text && text.length > 120 ? `${text.slice(0, 117)}` : text;
}
main();