From 24e02e20416b8a2cff92eff4482a1d36b58dafb6 Mon Sep 17 00:00:00 2001 From: que01 Date: Wed, 29 Jul 2026 17:59:32 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=8A=BD=20osmassets=20=E5=8C=85?= =?UTF-8?q?=EF=BC=8C=E8=A6=81=E7=B4=A0=E6=B3=A8=E5=86=8C=E8=A1=A8=EF=BC=8C?= =?UTF-8?q?=E6=9D=90=E8=B4=A8=E5=8D=95=E4=B8=80=E5=AE=9A=E4=B9=89=E6=BA=90?= =?UTF-8?q?=20(P0-P3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 1 + blender/generate_scene.py | 741 +++++++-------------------------- blender/osmassets/__init__.py | 12 + blender/osmassets/catalog.py | 195 +++++++++ blender/osmassets/geom.py | 148 +++++++ blender/osmassets/grass.py | 22 + blender/osmassets/materials.py | 172 ++++++++ blender/osmassets/mesh.py | 126 ++++++ blender/osmassets/osm.py | 89 ++++ blender/osmassets/scrub.py | 13 + blender/osmassets/water.py | 15 + blender/tests/test_pure.py | 314 ++++++++++++++ blender/tools/scene_digest.py | 171 ++++++++ docs/refactor-plan.md | 112 +++++ scripts/glb-digest.js | 121 ++++++ scripts/parity.js | 270 ++++++++++++ 16 files changed, 1924 insertions(+), 598 deletions(-) create mode 100644 blender/osmassets/__init__.py create mode 100644 blender/osmassets/catalog.py create mode 100644 blender/osmassets/geom.py create mode 100644 blender/osmassets/grass.py create mode 100644 blender/osmassets/materials.py create mode 100644 blender/osmassets/mesh.py create mode 100644 blender/osmassets/osm.py create mode 100644 blender/osmassets/scrub.py create mode 100644 blender/osmassets/water.py create mode 100644 blender/tests/test_pure.py create mode 100644 blender/tools/scene_digest.py create mode 100644 docs/refactor-plan.md create mode 100644 scripts/glb-digest.js create mode 100644 scripts/parity.js 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