diff --git a/.gitignore b/.gitignore
index e73d995..5a8c279 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
.DS_Store
node_modules/
outputs/
+__pycache__/
diff --git a/blender/generate_scene.py b/blender/generate_scene.py
index 31bdfdf..f8b8903 100644
--- a/blender/generate_scene.py
+++ b/blender/generate_scene.py
@@ -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),
diff --git a/blender/osmassets/__init__.py b/blender/osmassets/__init__.py
new file mode 100644
index 0000000..2ef40dc
--- /dev/null
+++ b/blender/osmassets/__init__.py
@@ -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.
+"""
diff --git a/blender/osmassets/catalog.py b/blender/osmassets/catalog.py
new file mode 100644
index 0000000..050ca6c
--- /dev/null
+++ b/blender/osmassets/catalog.py
@@ -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
diff --git a/blender/osmassets/geom.py b/blender/osmassets/geom.py
new file mode 100644
index 0000000..4dae0e2
--- /dev/null
+++ b/blender/osmassets/geom.py
@@ -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
diff --git a/blender/osmassets/grass.py b/blender/osmassets/grass.py
new file mode 100644
index 0000000..ee530e4
--- /dev/null
+++ b/blender/osmassets/grass.py
@@ -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
diff --git a/blender/osmassets/materials.py b/blender/osmassets/materials.py
new file mode 100644
index 0000000..a099040
--- /dev/null
+++ b/blender/osmassets/materials.py
@@ -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
diff --git a/blender/osmassets/mesh.py b/blender/osmassets/mesh.py
new file mode 100644
index 0000000..244cb7e
--- /dev/null
+++ b/blender/osmassets/mesh.py
@@ -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)
diff --git a/blender/osmassets/osm.py b/blender/osmassets/osm.py
new file mode 100644
index 0000000..7385a19
--- /dev/null
+++ b/blender/osmassets/osm.py
@@ -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]
diff --git a/blender/osmassets/scrub.py b/blender/osmassets/scrub.py
new file mode 100644
index 0000000..26d1350
--- /dev/null
+++ b/blender/osmassets/scrub.py
@@ -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
diff --git a/blender/osmassets/water.py b/blender/osmassets/water.py
new file mode 100644
index 0000000..3dcf078
--- /dev/null
+++ b/blender/osmassets/water.py
@@ -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
diff --git a/blender/tests/test_pure.py b/blender/tests/test_pure.py
new file mode 100644
index 0000000..86d829f
--- /dev/null
+++ b/blender/tests/test_pure.py
@@ -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 = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"""
+
+
+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("")
+ 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(
+ "")
+ self.assertEqual(tags(element), {"building": "yes", "height": "9"})
+
+ def test_untagged_element(self):
+ import xml.etree.ElementTree as ET
+ self.assertEqual(tags(ET.fromstring("")), {})
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/blender/tools/scene_digest.py b/blender/tools/scene_digest.py
new file mode 100644
index 0000000..6b88fe1
--- /dev/null
+++ b/blender/tools/scene_digest.py
@@ -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] = ""
+ 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"]),
+ }))
diff --git a/docs/refactor-plan.md b/docs/refactor-plan.md
new file mode 100644
index 0000000..35e86b3
--- /dev/null
+++ b/docs/refactor-plan.md
@@ -0,0 +1,112 @@
+# 重构施工计划:`osmassets` 包化(P0–P3)
+
+> 临时工作文档。P3 收尾后把结论并入 `docs/changelog.md`,本文件删除。
+
+## 目标
+
+把「从 OSM 生成 Blender / Cesium 资产」的逻辑从两个单体脚本里拆成可复用的库,使得:
+
+- 新增一种 OSM 要素 = 新增一个 `features/*.py` + 注册一行,不改 `build()`
+- 道路图层表、材质规格只有一份定义,JS 侧与 Python 侧不再各存一份
+- `export_cesium.py` 不再靠材质名字符串跟 `generate_scene.py` 对接
+- 纯几何 / 解析逻辑脱离 `bpy`,可用系统 python 直接测
+
+## 硬约束:严格产物一致
+
+P0–P3 全程 **不改变任何输出**。每期结束必须通过 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