refactor: 抽 osmassets 包,要素注册表,材质单一定义源 (P0-P3)
generate_scene.py 从 1271 → 816 行 (-455)
P0: 纯函数搬家
- osmassets/osm.py: parse_osm / Projector / parse_height
- osmassets/geom.py: clip_polygon / point_in_polygon / distance_to_ring / sample_tree_row 等
- blender/tests/test_pure.py: 42 个 unittest (脱离 bpy 运行)
P1: 单一定义源
- osmassets/catalog.py: ROAD_LAYERS + MATERIALS (含 cesium 导出参数)
- 对接 osm2streets_scene_style.json 做图层一致性 warning
- 干掉 road_mats / layer_z / 材质参数三份副本
P2: 要素注册表
- osmassets/{water,grass,scrub}.py: 每个要素一个 assemble() 函数
- build() 中的 if/elif 链收缩为注册表调用
- 计数器集中到 counts 字典
P3: 材质契约化
- catalog.py 扩展 CESIUM_EXPORT 段 (tint/metallic/emission)
- 标记已发现的死条目 Office White Metal Facade (四表各一组)
校验:
- parity.js + scene_digest.py + glb-digest.js 三位一体
- control-1 vs p0/p1/p2a/p2b/p3-counts: 两区域全 PARITY OK
- 42 个纯 Python 测试全部通过
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
node_modules/
|
node_modules/
|
||||||
outputs/
|
outputs/
|
||||||
|
__pycache__/
|
||||||
|
|||||||
@@ -23,16 +23,74 @@ import json
|
|||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import xml.etree.ElementTree as ET
|
|
||||||
from collections import defaultdict
|
|
||||||
|
|
||||||
import bpy
|
import bpy
|
||||||
from mathutils import Vector
|
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(
|
MODEL_ROOT = os.path.abspath(os.path.join(
|
||||||
os.path.dirname(__file__), "..", "assets", "models", "polyhaven"
|
os.path.dirname(__file__), "..", "assets", "models", "polyhaven"
|
||||||
@@ -91,290 +149,6 @@ def cli_args():
|
|||||||
return values
|
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):
|
def add_building_details(name, ring, height, industrial, materials, collection):
|
||||||
footprint = ring[:-1] if len(ring) > 1 and ring[0] == ring[-1] else ring
|
footprint = ring[:-1] if len(ring) > 1 and ring[0] == ring[-1] else ring
|
||||||
if len(footprint) < 3:
|
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):
|
def add_geojson_layer(path, layer, projector, collection, material, z):
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
return 0
|
return 0
|
||||||
@@ -485,53 +200,6 @@ def add_geojson_layer(path, layer, projector, collection, material, z):
|
|||||||
return count
|
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):
|
def add_tree_batch(positions, collection, trunk_material, leaf_material):
|
||||||
trunk = MeshBatch("Tree_Trunks", collection, trunk_material)
|
trunk = MeshBatch("Tree_Trunks", collection, trunk_material)
|
||||||
leaves = MeshBatch("Tree_Crowns", collection, leaf_material)
|
leaves = MeshBatch("Tree_Crowns", collection, leaf_material)
|
||||||
@@ -685,51 +353,6 @@ def add_natural_tree_instances(positions, collection, trunk_material,
|
|||||||
upper.finish()
|
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():
|
def load_tuft_variants():
|
||||||
"""Import the vendored Poly Haven plant once and return decimated meshes.
|
"""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
|
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 add_fountain(name, x, y, collection, materials):
|
||||||
def cylinder(part_name, radius, depth, z, material, vertices=48):
|
def cylinder(part_name, radius, depth, z, material, vertices=48):
|
||||||
bpy.ops.mesh.primitive_cylinder_add(
|
bpy.ops.mesh.primitive_cylinder_add(
|
||||||
@@ -975,55 +592,26 @@ def build(args):
|
|||||||
buildings_c = new_collection("04_Buildings")
|
buildings_c = new_collection("04_Buildings")
|
||||||
props_c = new_collection("05_Props")
|
props_c = new_collection("05_Props")
|
||||||
|
|
||||||
ground_mat = make_material("Ground", (0.27, 0.32, 0.24))
|
ground_mat = material_from_spec(catalog.MATERIALS["ground"])
|
||||||
water_mat = make_material("Lake Water", (0.035, 0.22, 0.30), 0.18, 0.05)
|
water_mat = material_from_spec(catalog.MATERIALS["water"])
|
||||||
grass_mat = make_textured_material(
|
grass_mat = material_from_spec(catalog.MATERIALS["grass"])
|
||||||
"Grass", "leafy_grass_diff_1k.jpg", "leafy_grass_nor_gl_1k.jpg",
|
scrub_mat = material_from_spec(catalog.MATERIALS["scrub"])
|
||||||
roughness=0.92, scale=7.0, tint=(0.12, 0.48, 0.08), tint_factor=0.72)
|
# Ordering note: the tuft import creates its own materials, so it stays
|
||||||
scrub_mat = make_textured_material(
|
# between the ground materials and the props. Material creation order fixes
|
||||||
"Scrub Ground Cover", "leafy_grass_diff_1k.jpg",
|
# the material indices in the exported GLB.
|
||||||
"leafy_grass_nor_gl_1k.jpg", roughness=0.96, scale=15.0,
|
|
||||||
tint=(0.085, 0.30, 0.065), tint_factor=0.46)
|
|
||||||
tuft_variants = load_tuft_variants()
|
tuft_variants = load_tuft_variants()
|
||||||
fountain_mats = {
|
fountain_mats = {
|
||||||
"fountain_stone": make_material("Fountain Stone", (0.42, 0.45, 0.43), 0.72),
|
key: material_from_spec(catalog.MATERIALS[key])
|
||||||
"fountain_water": make_material("Fountain Water", (0.03, 0.32, 0.42), 0.16, 0.05),
|
for key in ("fountain_stone", "fountain_water", "fountain_spray")
|
||||||
"fountain_spray": make_material("Fountain Spray", (0.20, 0.70, 0.78), 0.12, 0.02),
|
|
||||||
}
|
}
|
||||||
building_mats = {
|
building_mats = {
|
||||||
"default": make_textured_material(
|
key: material_from_spec(catalog.MATERIALS["building_" + key])
|
||||||
"Office White Plaster Facade", "white_plaster_02_diff_1k.jpg",
|
for key in ("default", "industrial", "office_roof", "industrial_roof",
|
||||||
"white_plaster_02_nor_gl_1k.jpg", roughness=0.82,
|
"glass", "factory_glass")
|
||||||
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),
|
|
||||||
}
|
}
|
||||||
road_mats = {
|
road_mats = {
|
||||||
"road_surface": make_material("Road Asphalt", (0.055, 0.065, 0.070)),
|
layer["id"]: material_from_spec(spec)
|
||||||
"intersection_surface": make_material("Intersection Asphalt", (0.065, 0.075, 0.080)),
|
for layer, spec in zip(catalog.ROAD_LAYERS, catalog.road_material_specs())
|
||||||
"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)),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
b = bounds
|
b = bounds
|
||||||
@@ -1039,13 +627,19 @@ def build(args):
|
|||||||
|
|
||||||
grass_rings = []
|
grass_rings = []
|
||||||
tree_rows = []
|
tree_rows = []
|
||||||
lake_count = 0
|
# Counters were previously individual ints scattered through the loop body.
|
||||||
grass_count = 0
|
# Collecting them into a dict lets the scene[...] and SCENE_DONE sections
|
||||||
grass_tuft_count = 0
|
# read from a single place. The keys are kept alphabetically so the
|
||||||
scrub_count = 0
|
# SCENE_DONE JSON order from control-1 stays byte-for-byte identical.
|
||||||
fountain_count = 0
|
counts = {
|
||||||
building_count = 0
|
"building_count": 0,
|
||||||
industrial_count = 0
|
"fountain_count": 0,
|
||||||
|
"grass_count": 0,
|
||||||
|
"grass_tuft_count": 0,
|
||||||
|
"industrial_count": 0,
|
||||||
|
"lake_count": 0,
|
||||||
|
"scrub_count": 0,
|
||||||
|
}
|
||||||
focus_points = []
|
focus_points = []
|
||||||
for way in ways:
|
for way in ways:
|
||||||
coords = way["coords"]
|
coords = way["coords"]
|
||||||
@@ -1054,80 +648,46 @@ def build(args):
|
|||||||
ring = projector.ring(coords)
|
ring = projector.ring(coords)
|
||||||
tag = way["tags"]
|
tag = way["tags"]
|
||||||
if tag.get("natural") == "water" or tag.get("water") == "lake":
|
if tag.get("natural") == "water" or tag.get("water") == "lake":
|
||||||
ring = clip_polygon(ring, scene_xmin, scene_xmax,
|
counts["lake_count"] += _water.assemble(ring, scene_xmin, scene_xmax,
|
||||||
scene_ymin, scene_ymax)
|
scene_ymin, scene_ymax,
|
||||||
batch = MeshBatch("Lake Surface", water_c, water_mat)
|
water_c, water_mat)
|
||||||
if len(ring) >= 3:
|
|
||||||
batch.add_polygon(ring, 0.10)
|
|
||||||
batch.finish()
|
|
||||||
lake_count += 1
|
|
||||||
elif tag.get("landuse") == "grass":
|
elif tag.get("landuse") == "grass":
|
||||||
ring = clip_polygon(ring, scene_xmin, scene_xmax,
|
added, tufts, ring_pts = _grass.assemble(
|
||||||
scene_ymin, scene_ymax)
|
ring, way["id"], scene_xmin, scene_xmax, scene_ymin, scene_ymax,
|
||||||
grass_rings.append(ring)
|
green_c, grass_mat, tuft_variants, add_grass_tufts)
|
||||||
focus_points.extend(ring)
|
counts["grass_count"] += added
|
||||||
batch = MeshBatch("Grass_" + str(way["id"]), green_c, grass_mat)
|
counts["grass_tuft_count"] += tufts
|
||||||
if len(ring) >= 3:
|
if ring_pts:
|
||||||
batch.add_polygon(ring, 0.015)
|
grass_rings.append(ring_pts)
|
||||||
obj = batch.finish()
|
focus_points.extend(ring_pts)
|
||||||
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
|
|
||||||
elif tag.get("natural") == "scrub" and len(ring) >= 3:
|
elif tag.get("natural") == "scrub" and len(ring) >= 3:
|
||||||
ring = clip_polygon(ring, scene_xmin, scene_xmax,
|
added, ring_pts = _scrub.assemble(ring, way["id"], scene_xmin, scene_xmax,
|
||||||
scene_ymin, scene_ymax)
|
scene_ymin, scene_ymax, green_c, scrub_mat,
|
||||||
focus_points.extend(ring)
|
add_scrub_patch)
|
||||||
if len(ring) >= 3:
|
counts["scrub_count"] += added
|
||||||
add_scrub_patch("Scrub_" + str(way["id"]), ring,
|
if ring_pts:
|
||||||
scrub_mat, green_c)
|
focus_points.extend(ring_pts)
|
||||||
scrub_count += 1
|
|
||||||
elif tag.get("natural") == "tree_row":
|
elif tag.get("natural") == "tree_row":
|
||||||
tree_rows.append((ring, tag))
|
tree_rows.append((ring, tag))
|
||||||
focus_points.extend(ring)
|
focus_points.extend(ring)
|
||||||
elif "building" in tag and len(ring) >= 3:
|
elif "building" in tag and len(ring) >= 3:
|
||||||
way_id = str(way["id"])
|
added, ind_added, ring_pts = _assemble_building(
|
||||||
industrial = (tag.get("building") == "industrial" and
|
ring, str(way["id"]), tag, args, buildings_c, building_mats)
|
||||||
way_id not in args["office_overrides"])
|
counts["building_count"] += added
|
||||||
source_height = max(3.0, parse_height(tag, 12.0))
|
counts["industrial_count"] += ind_added
|
||||||
height = source_height if industrial or source_height >= 30.0 else 11.4
|
if ring_pts:
|
||||||
material = building_mats["industrial"] if industrial else building_mats["default"]
|
focus_points.extend(ring_pts)
|
||||||
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)
|
|
||||||
|
|
||||||
geojson_dir = args.get("geojson")
|
geojson_dir = args.get("geojson")
|
||||||
road_counts = {}
|
road_counts = {}
|
||||||
if geojson_dir and os.path.isdir(geojson_dir):
|
if geojson_dir and os.path.isdir(geojson_dir):
|
||||||
layer_z = {"road_surface": 0.03, "intersection_surface": 0.035,
|
for problem in catalog.check_layers(geojson_dir):
|
||||||
"sidewalks": 0.065, "sidewalk_corners": 0.067,
|
print("Layer catalog warning:", problem)
|
||||||
"lane_separators": 0.090, "center_lines": 0.092,
|
for layer in catalog.ROAD_LAYERS:
|
||||||
"crosswalks": 0.094, "vehicle_stop_lines": 0.096,
|
layer_id = layer["id"]
|
||||||
"lane_arrows_webscale": 0.098}
|
road_counts[layer_id] = add_geojson_layer(
|
||||||
for layer, z in layer_z.items():
|
os.path.join(geojson_dir, layer_id + ".geojson"), layer_id,
|
||||||
road_counts[layer] = add_geojson_layer(
|
projector, roads_c, road_mats[layer_id], layer["z"])
|
||||||
os.path.join(geojson_dir, layer + ".geojson"), layer,
|
|
||||||
projector, roads_c, road_mats[layer], z)
|
|
||||||
|
|
||||||
if road_counts.get("road_surface", 0) == 0:
|
if road_counts.get("road_surface", 0) == 0:
|
||||||
for way in ways:
|
for way in ways:
|
||||||
@@ -1154,30 +714,15 @@ def build(args):
|
|||||||
trees.extend(row_samples)
|
trees.extend(row_samples)
|
||||||
row_tree_count += len(row_samples)
|
row_tree_count += len(row_samples)
|
||||||
if trees:
|
if trees:
|
||||||
tree_style = args.get("tree_style")
|
tree_trunk = material_from_spec(catalog.MATERIALS["tree_trunk"])
|
||||||
if tree_style == "natural":
|
if args.get("tree_style") == "natural":
|
||||||
tree_trunk = make_textured_material(
|
add_natural_tree_instances(
|
||||||
"Tree Trunk", "bark_brown_01_diff_1k.jpg",
|
trees, props_c, tree_trunk,
|
||||||
"bark_brown_01_nor_gl_1k.jpg", roughness=0.92, scale=5.0)
|
material_from_spec(catalog.MATERIALS["tree_crown_dark"]),
|
||||||
leaf_dark = make_material("Tree Crown Dark", (0.065, 0.25, 0.055), 0.90)
|
material_from_spec(catalog.MATERIALS["tree_crown_light"]))
|
||||||
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)
|
|
||||||
else:
|
else:
|
||||||
tree_trunk = make_textured_material(
|
add_tree_batch(trees, props_c, tree_trunk,
|
||||||
"Tree Trunk", "bark_brown_01_diff_1k.jpg",
|
material_from_spec(catalog.MATERIALS["tree_crown"]))
|
||||||
"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)
|
|
||||||
|
|
||||||
for feature in point_features:
|
for feature in point_features:
|
||||||
if feature["tags"].get("amenity") != "fountain":
|
if feature["tags"].get("amenity") != "fountain":
|
||||||
@@ -1187,7 +732,7 @@ def build(args):
|
|||||||
fx, fy = projector.xy(feature["coord"])
|
fx, fy = projector.xy(feature["coord"])
|
||||||
add_fountain("Fountain_" + str(feature["id"]), fx, fy,
|
add_fountain("Fountain_" + str(feature["id"]), fx, fy,
|
||||||
props_c, fountain_mats)
|
props_c, fountain_mats)
|
||||||
fountain_count += 1
|
counts["fountain_count"] += 1
|
||||||
|
|
||||||
bpy.ops.object.light_add(type="SUN", location=(0, 0, 500))
|
bpy.ops.object.light_add(type="SUN", location=(0, 0, 500))
|
||||||
sun = bpy.context.object
|
sun = bpy.context.object
|
||||||
@@ -1234,14 +779,14 @@ def build(args):
|
|||||||
scene["source_osm"] = args["osm"]
|
scene["source_osm"] = args["osm"]
|
||||||
scene["source_geojson"] = geojson_dir or ""
|
scene["source_geojson"] = geojson_dir or ""
|
||||||
scene["osm_bounds"] = json.dumps(bounds, ensure_ascii=True)
|
scene["osm_bounds"] = json.dumps(bounds, ensure_ascii=True)
|
||||||
scene["building_count"] = building_count
|
scene["building_count"] = counts["building_count"]
|
||||||
scene["industrial_building_count"] = industrial_count
|
scene["industrial_building_count"] = counts["industrial_count"]
|
||||||
scene["office_override_way_ids"] = json.dumps(sorted(args["office_overrides"]))
|
scene["office_override_way_ids"] = json.dumps(sorted(args["office_overrides"]))
|
||||||
scene["lake_count"] = lake_count
|
scene["lake_count"] = counts["lake_count"]
|
||||||
scene["grass_count"] = grass_count
|
scene["grass_count"] = counts["grass_count"]
|
||||||
scene["grass_tuft_count"] = grass_tuft_count
|
scene["grass_tuft_count"] = counts["grass_tuft_count"]
|
||||||
scene["scrub_count"] = scrub_count
|
scene["scrub_count"] = counts["scrub_count"]
|
||||||
scene["fountain_count"] = fountain_count
|
scene["fountain_count"] = counts["fountain_count"]
|
||||||
scene["tree_node_count"] = individual_tree_count
|
scene["tree_node_count"] = individual_tree_count
|
||||||
scene["tree_row_count"] = row_tree_count
|
scene["tree_row_count"] = row_tree_count
|
||||||
scene["tree_count"] = len(trees)
|
scene["tree_count"] = len(trees)
|
||||||
@@ -1254,13 +799,13 @@ def build(args):
|
|||||||
bpy.ops.render.render(write_still=True)
|
bpy.ops.render.render(write_still=True)
|
||||||
print("SCENE_DONE", json.dumps({"output": args["output"],
|
print("SCENE_DONE", json.dumps({"output": args["output"],
|
||||||
"render": args["render"],
|
"render": args["render"],
|
||||||
"buildings": building_count,
|
"buildings": counts["building_count"],
|
||||||
"industrial_buildings": industrial_count,
|
"industrial_buildings": counts["industrial_count"],
|
||||||
"lake": lake_count,
|
"lake": counts["lake_count"],
|
||||||
"grass": grass_count,
|
"grass": counts["grass_count"],
|
||||||
"grass_tufts": grass_tuft_count,
|
"grass_tufts": counts["grass_tuft_count"],
|
||||||
"scrub": scrub_count,
|
"scrub": counts["scrub_count"],
|
||||||
"fountains": fountain_count,
|
"fountains": counts["fountain_count"],
|
||||||
"tree_nodes": individual_tree_count,
|
"tree_nodes": individual_tree_count,
|
||||||
"tree_row_instances": row_tree_count,
|
"tree_row_instances": row_tree_count,
|
||||||
"trees": len(trees),
|
"trees": len(trees),
|
||||||
|
|||||||
12
blender/osmassets/__init__.py
Normal file
12
blender/osmassets/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
"""Reusable pieces of the OSM → Blender/Cesium asset pipeline.
|
||||||
|
|
||||||
|
The package is split by dependency, not by feature:
|
||||||
|
|
||||||
|
- `osm` and `geom` are pure Python. They import no `bpy` and can be run and
|
||||||
|
tested with a plain interpreter (`python3 -m unittest discover blender/tests`).
|
||||||
|
- everything else may touch `bpy` and only runs inside Blender.
|
||||||
|
|
||||||
|
Keeping that line sharp is what makes the geometry testable at all; before the
|
||||||
|
split it was interleaved with scene construction and could only be exercised by
|
||||||
|
rendering a whole area.
|
||||||
|
"""
|
||||||
195
blender/osmassets/catalog.py
Normal file
195
blender/osmassets/catalog.py
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
"""Single source of truth for the scene's road layers and materials.
|
||||||
|
|
||||||
|
Before this module the same facts lived in several places at once: the nine
|
||||||
|
osm2streets layers had their draw order in `scripts/lib/scene-layers.js`, their
|
||||||
|
Blender heights in a `layer_z` dict, and their colours in a `road_mats` dict —
|
||||||
|
three copies across two languages, kept in sync by hand. Everything the scene
|
||||||
|
builder needs is now declared here, once.
|
||||||
|
|
||||||
|
Two deliberate non-goals:
|
||||||
|
|
||||||
|
- The colours here are NOT derived from `scene-layers.js`. That file's `fill`
|
||||||
|
values are QGIS sRGB hex for a 2D debug map; these are linear Blender base
|
||||||
|
colours for a 3D scene, and the two were tuned separately. `check_layers`
|
||||||
|
cross-checks the layer *set and order* — the part that must agree — and
|
||||||
|
leaves the palettes alone.
|
||||||
|
- Order is load-bearing. Material creation order fixes the material indices in
|
||||||
|
the exported GLB, and layer order fixes mesh creation order, so both lists
|
||||||
|
are sequences, not dicts, and appending is the only safe edit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
# Draw order, bottom first. `z` is the Blender height in metres that keeps the
|
||||||
|
# markings above the asphalt without z-fighting; `id` matches the GeoJSON file
|
||||||
|
# stem written by the intermediates stage.
|
||||||
|
ROAD_LAYERS = [
|
||||||
|
{"id": "road_surface", "material": "Road Asphalt",
|
||||||
|
"color": (0.055, 0.065, 0.070), "z": 0.03},
|
||||||
|
{"id": "intersection_surface", "material": "Intersection Asphalt",
|
||||||
|
"color": (0.065, 0.075, 0.080), "z": 0.035},
|
||||||
|
{"id": "sidewalks", "material": "Sidewalk",
|
||||||
|
"color": (0.49, 0.51, 0.49), "z": 0.065},
|
||||||
|
{"id": "sidewalk_corners", "material": "Sidewalk Corner",
|
||||||
|
"color": (0.49, 0.51, 0.49), "z": 0.067},
|
||||||
|
{"id": "lane_separators", "material": "Lane Separator",
|
||||||
|
"color": (0.85, 0.84, 0.72), "z": 0.090},
|
||||||
|
{"id": "center_lines", "material": "Center Line",
|
||||||
|
"color": (0.94, 0.58, 0.06), "z": 0.092},
|
||||||
|
{"id": "crosswalks", "material": "Crosswalk",
|
||||||
|
"color": (0.95, 0.94, 0.82), "z": 0.094},
|
||||||
|
{"id": "vehicle_stop_lines", "material": "Stop Line",
|
||||||
|
"color": (0.95, 0.94, 0.82), "z": 0.096},
|
||||||
|
{"id": "lane_arrows_webscale", "material": "Lane Arrow",
|
||||||
|
"color": (0.95, 0.94, 0.82), "z": 0.098},
|
||||||
|
]
|
||||||
|
|
||||||
|
SCENE_STYLE_FILE = "osm2streets_scene_style.json"
|
||||||
|
|
||||||
|
|
||||||
|
# Material specs. `kind` selects the builder:
|
||||||
|
# solid — flat base colour
|
||||||
|
# textured — Poly Haven diffuse + normal, optionally tinted
|
||||||
|
# `procedural` adds noise-driven base colour and bump on top of a solid.
|
||||||
|
MATERIALS = {
|
||||||
|
"ground": {"kind": "solid", "name": "Ground", "color": (0.27, 0.32, 0.24)},
|
||||||
|
"water": {"kind": "solid", "name": "Lake Water", "color": (0.035, 0.22, 0.30),
|
||||||
|
"roughness": 0.18, "metallic": 0.05},
|
||||||
|
"grass": {"kind": "textured", "name": "Grass",
|
||||||
|
"diffuse": "leafy_grass_diff_1k.jpg",
|
||||||
|
"normal": "leafy_grass_nor_gl_1k.jpg",
|
||||||
|
"roughness": 0.92, "scale": 7.0,
|
||||||
|
"tint": (0.12, 0.48, 0.08), "tint_factor": 0.72},
|
||||||
|
"scrub": {"kind": "textured", "name": "Scrub Ground Cover",
|
||||||
|
"diffuse": "leafy_grass_diff_1k.jpg",
|
||||||
|
"normal": "leafy_grass_nor_gl_1k.jpg",
|
||||||
|
"roughness": 0.96, "scale": 15.0,
|
||||||
|
"tint": (0.085, 0.30, 0.065), "tint_factor": 0.46},
|
||||||
|
|
||||||
|
"fountain_stone": {"kind": "solid", "name": "Fountain Stone",
|
||||||
|
"color": (0.42, 0.45, 0.43), "roughness": 0.72},
|
||||||
|
"fountain_water": {"kind": "solid", "name": "Fountain Water",
|
||||||
|
"color": (0.03, 0.32, 0.42), "roughness": 0.16,
|
||||||
|
"metallic": 0.05},
|
||||||
|
"fountain_spray": {"kind": "solid", "name": "Fountain Spray",
|
||||||
|
"color": (0.20, 0.70, 0.78), "roughness": 0.12,
|
||||||
|
"metallic": 0.02},
|
||||||
|
|
||||||
|
"building_default": {"kind": "textured", "name": "Office White Plaster Facade",
|
||||||
|
"diffuse": "white_plaster_02_diff_1k.jpg",
|
||||||
|
"normal": "white_plaster_02_nor_gl_1k.jpg",
|
||||||
|
"roughness": 0.82, "scale": 4.2, "metallic": 0.0,
|
||||||
|
"tint": (0.92, 0.94, 0.92), "tint_factor": 0.38},
|
||||||
|
"building_industrial": {"kind": "textured",
|
||||||
|
"name": "Industrial White Ribbed Facade",
|
||||||
|
"diffuse": "corrugated_iron_03_diff_1k.jpg",
|
||||||
|
"normal": "corrugated_iron_03_nor_gl_1k.jpg",
|
||||||
|
"roughness": 0.56, "scale": 2.4, "metallic": 0.16,
|
||||||
|
"tint": (0.86, 0.92, 0.94), "tint_factor": 0.68},
|
||||||
|
"building_office_roof": {"kind": "textured", "name": "Office Light Flat Roof",
|
||||||
|
"diffuse": "concrete_floor_02_diff_1k.jpg",
|
||||||
|
"normal": "concrete_floor_02_bump_1k.jpg",
|
||||||
|
"roughness": 0.84, "scale": 5.0,
|
||||||
|
"normal_is_bump": True,
|
||||||
|
"tint": (0.82, 0.86, 0.88), "tint_factor": 0.35},
|
||||||
|
"building_industrial_roof": {"kind": "textured",
|
||||||
|
"name": "Factory Blue Metal Roof",
|
||||||
|
"diffuse": "blue_metal_plate_diff_1k.jpg",
|
||||||
|
"normal": "blue_metal_plate_nor_gl_1k.jpg",
|
||||||
|
"roughness": 0.48, "scale": 3.4, "metallic": 0.28,
|
||||||
|
"tint": (0.03, 0.42, 0.78), "tint_factor": 0.45},
|
||||||
|
"building_glass": {"kind": "solid", "name": "Office Blue Gray Glass",
|
||||||
|
"color": (0.12, 0.20, 0.24), "roughness": 0.22,
|
||||||
|
"metallic": 0.10},
|
||||||
|
"building_factory_glass": {"kind": "solid", "name": "Factory Dark Windows",
|
||||||
|
"color": (0.10, 0.14, 0.15), "roughness": 0.28,
|
||||||
|
"metallic": 0.08},
|
||||||
|
|
||||||
|
"tree_trunk": {"kind": "textured", "name": "Tree Trunk",
|
||||||
|
"diffuse": "bark_brown_01_diff_1k.jpg",
|
||||||
|
"normal": "bark_brown_01_nor_gl_1k.jpg",
|
||||||
|
"roughness": 0.92, "scale": 5.0},
|
||||||
|
"tree_crown_dark": {"kind": "solid", "name": "Tree Crown Dark",
|
||||||
|
"color": (0.065, 0.25, 0.055), "roughness": 0.90,
|
||||||
|
"procedural": {"colors": ((0.035, 0.14, 0.035),
|
||||||
|
(0.12, 0.36, 0.08)),
|
||||||
|
"scale": 3.2, "detail": 3.8,
|
||||||
|
"bump_strength": 0.08},
|
||||||
|
"cesium": {"tint": ((0.06, 0.22, 0.05), 0.18)}},
|
||||||
|
"tree_crown_light": {"kind": "solid", "name": "Tree Crown Light",
|
||||||
|
"color": (0.13, 0.42, 0.09), "roughness": 0.88,
|
||||||
|
"procedural": {"colors": ((0.07, 0.25, 0.05),
|
||||||
|
(0.22, 0.56, 0.13)),
|
||||||
|
"scale": 3.6, "detail": 3.4,
|
||||||
|
"bump_strength": 0.07},
|
||||||
|
"cesium": {"tint": ((0.16, 0.42, 0.09), 0.16)}},
|
||||||
|
"tree_crown": {"kind": "solid", "name": "Tree Crown",
|
||||||
|
"color": (0.10, 0.36, 0.08), "roughness": 0.88,
|
||||||
|
"procedural": {"colors": ((0.04, 0.18, 0.04),
|
||||||
|
(0.18, 0.50, 0.12)),
|
||||||
|
"scale": 2.8, "detail": 3.2,
|
||||||
|
"bump_strength": 0.10},
|
||||||
|
"cesium": {"tint": None,
|
||||||
|
"base_color": (0.11, 0.34, 0.075),
|
||||||
|
"emission": ((0.04, 0.11, 0.035), 0.02)}},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cesium-specific overrides that don't have a home in the material system yet:
|
||||||
|
# metallic overrides (flat values, not materials) and emission overrides for
|
||||||
|
# colours that export_cesium.py hand-tuned separately.
|
||||||
|
CESIUM_EXPORT = {
|
||||||
|
"metallic_overrides": {
|
||||||
|
"Office White Plaster Facade": 0.0,
|
||||||
|
"Industrial White Ribbed Facade": 0.08,
|
||||||
|
},
|
||||||
|
"emission_overrides": {
|
||||||
|
"Office White Plaster Facade": ((0.93, 0.94, 0.91), 0.18),
|
||||||
|
"Office Light Flat Roof": ((0.88, 0.90, 0.88), 0.14),
|
||||||
|
"Industrial White Ribbed Facade": ((0.90, 0.93, 0.91), 0.18),
|
||||||
|
"Factory Blue Metal Roof": ((0.08, 0.50, 0.88), 0.12),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def road_material_specs():
|
||||||
|
"""Road layer materials as MATERIALS-shaped specs, in draw order."""
|
||||||
|
return [{"kind": "solid", "name": layer["material"], "color": layer["color"]}
|
||||||
|
for layer in ROAD_LAYERS]
|
||||||
|
|
||||||
|
|
||||||
|
def check_layers(geojson_dir):
|
||||||
|
"""Warn when the intermediates stage and this catalog disagree on layers.
|
||||||
|
|
||||||
|
The style JSON is written next to the GeoJSON by the intermediates and
|
||||||
|
reimport stages. A layer added on the JS side but not here would be
|
||||||
|
silently dropped from the 3D scene, which is exactly the kind of drift the
|
||||||
|
single-source-of-truth split is meant to make loud. Warn rather than fail:
|
||||||
|
a stale or absent output directory should not block a rebuild.
|
||||||
|
"""
|
||||||
|
style_path = os.path.join(geojson_dir or "", SCENE_STYLE_FILE)
|
||||||
|
if not geojson_dir or not os.path.exists(style_path):
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
with open(style_path, "r", encoding="utf-8") as handle:
|
||||||
|
style = json.load(handle)
|
||||||
|
except (OSError, ValueError) as error:
|
||||||
|
return ["Could not read %s: %s" % (style_path, error)]
|
||||||
|
|
||||||
|
upstream = [entry.get("id") for entry in style.get("layers", [])]
|
||||||
|
local = [layer["id"] for layer in ROAD_LAYERS]
|
||||||
|
problems = []
|
||||||
|
for missing in [i for i in upstream if i not in local]:
|
||||||
|
problems.append(
|
||||||
|
"layer '%s' exists in %s but not in catalog.ROAD_LAYERS "
|
||||||
|
"(it will not reach the 3D scene)" % (missing, SCENE_STYLE_FILE))
|
||||||
|
for extra in [i for i in local if i not in upstream]:
|
||||||
|
problems.append(
|
||||||
|
"layer '%s' is in catalog.ROAD_LAYERS but not in %s "
|
||||||
|
"(no GeoJSON will be produced for it)" % (extra, SCENE_STYLE_FILE))
|
||||||
|
if not problems and upstream != local:
|
||||||
|
problems.append(
|
||||||
|
"layer draw order differs: %s produces %s, catalog stacks %s"
|
||||||
|
% (SCENE_STYLE_FILE, upstream, local))
|
||||||
|
return problems
|
||||||
148
blender/osmassets/geom.py
Normal file
148
blender/osmassets/geom.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
"""Planar geometry helpers for the OSM → asset pipeline.
|
||||||
|
|
||||||
|
Pure Python: no `bpy`, so this runs and tests outside Blender. All functions
|
||||||
|
work in projected metres (see `osmassets.osm.Projector`) unless the name says
|
||||||
|
otherwise; `geometry_rings` and `feature_in_bounds` take raw GeoJSON and are the
|
||||||
|
two exceptions, operating on lon/lat.
|
||||||
|
|
||||||
|
Rings are lists of (x, y) tuples. A repeated closing point is tolerated
|
||||||
|
everywhere but never required.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
|
||||||
|
def geometry_rings(geometry):
|
||||||
|
"""Exterior rings of a GeoJSON Polygon/MultiPolygon; holes are dropped."""
|
||||||
|
if not geometry:
|
||||||
|
return []
|
||||||
|
kind = geometry.get("type")
|
||||||
|
coordinates = geometry.get("coordinates", [])
|
||||||
|
if kind == "Polygon":
|
||||||
|
return coordinates[:1]
|
||||||
|
if kind == "MultiPolygon":
|
||||||
|
return [polygon[0] for polygon in coordinates if polygon]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def feature_in_bounds(feature, projector):
|
||||||
|
"""True when any coordinate of the feature falls inside the padded bounds."""
|
||||||
|
def walk(value):
|
||||||
|
if isinstance(value, list) and value and isinstance(value[0], (int, float)):
|
||||||
|
return projector.inside(value)
|
||||||
|
return any(walk(v) for v in value) if isinstance(value, list) else False
|
||||||
|
return walk(feature.get("geometry", {}).get("coordinates", []))
|
||||||
|
|
||||||
|
|
||||||
|
def clip_polygon(ring, xmin, xmax, ymin, ymax):
|
||||||
|
"""Sutherland-Hodgman clip of a ring against an axis-aligned box."""
|
||||||
|
if len(ring) < 3:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def clip_edge(points, inside, intersection):
|
||||||
|
if not points:
|
||||||
|
return []
|
||||||
|
result = []
|
||||||
|
previous = points[-1]
|
||||||
|
previous_inside = inside(previous)
|
||||||
|
for current in points:
|
||||||
|
current_inside = inside(current)
|
||||||
|
if current_inside != previous_inside:
|
||||||
|
result.append(intersection(previous, current))
|
||||||
|
if current_inside:
|
||||||
|
result.append(current)
|
||||||
|
previous = current
|
||||||
|
previous_inside = current_inside
|
||||||
|
return result
|
||||||
|
|
||||||
|
ring = clip_edge(
|
||||||
|
ring, lambda p: p[0] >= xmin,
|
||||||
|
lambda a, b: (xmin, a[1] + (b[1] - a[1]) * (xmin - a[0]) /
|
||||||
|
(b[0] - a[0]) if b[0] != a[0] else a[1]))
|
||||||
|
ring = clip_edge(
|
||||||
|
ring, lambda p: p[0] <= xmax,
|
||||||
|
lambda a, b: (xmax, a[1] + (b[1] - a[1]) * (xmax - a[0]) /
|
||||||
|
(b[0] - a[0]) if b[0] != a[0] else a[1]))
|
||||||
|
ring = clip_edge(
|
||||||
|
ring, lambda p: p[1] >= ymin,
|
||||||
|
lambda a, b: (a[0] + (b[0] - a[0]) * (ymin - a[1]) /
|
||||||
|
(b[1] - a[1]) if b[1] != a[1] else a[0], ymin))
|
||||||
|
ring = clip_edge(
|
||||||
|
ring, lambda p: p[1] <= ymax,
|
||||||
|
lambda a, b: (a[0] + (b[0] - a[0]) * (ymax - a[1]) /
|
||||||
|
(b[1] - a[1]) if b[1] != a[1] else a[0], ymax))
|
||||||
|
return ring
|
||||||
|
|
||||||
|
|
||||||
|
def sample_tree_row(points, spacing, height):
|
||||||
|
"""Evenly space (x, y, height) samples along a polyline.
|
||||||
|
|
||||||
|
The trailing point is appended only when the last regular sample stops well
|
||||||
|
short of it, so a row does not end in a double-planted tree.
|
||||||
|
"""
|
||||||
|
if len(points) < 2:
|
||||||
|
return []
|
||||||
|
samples = [(points[0][0], points[0][1], height)]
|
||||||
|
distance_until_next = spacing
|
||||||
|
for start, end in zip(points, points[1:]):
|
||||||
|
dx = end[0] - start[0]
|
||||||
|
dy = end[1] - start[1]
|
||||||
|
segment_length = math.hypot(dx, dy)
|
||||||
|
if segment_length == 0:
|
||||||
|
continue
|
||||||
|
while distance_until_next <= segment_length:
|
||||||
|
ratio = distance_until_next / segment_length
|
||||||
|
samples.append((start[0] + dx * ratio, start[1] + dy * ratio, height))
|
||||||
|
distance_until_next += spacing
|
||||||
|
distance_until_next -= segment_length
|
||||||
|
last = points[-1]
|
||||||
|
if math.hypot(samples[-1][0] - last[0], samples[-1][1] - last[1]) > spacing * 0.45:
|
||||||
|
samples.append((last[0], last[1], height))
|
||||||
|
return samples
|
||||||
|
|
||||||
|
|
||||||
|
def polygon_area(ring):
|
||||||
|
"""Unsigned shoelace area; 0.0 for degenerate rings."""
|
||||||
|
if len(ring) < 3:
|
||||||
|
return 0.0
|
||||||
|
area = 0.0
|
||||||
|
for (x1, y1), (x2, y2) in zip(ring, ring[1:] + ring[:1]):
|
||||||
|
area += x1 * y2 - x2 * y1
|
||||||
|
return abs(area) * 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def point_in_polygon(point, ring):
|
||||||
|
x, y = point
|
||||||
|
inside = False
|
||||||
|
j = len(ring) - 1
|
||||||
|
for i, (xi, yi) in enumerate(ring):
|
||||||
|
xj, yj = ring[j]
|
||||||
|
crosses = ((yi > y) != (yj > y))
|
||||||
|
if crosses:
|
||||||
|
x_at_y = (xj - xi) * (y - yi) / (yj - yi) + xi
|
||||||
|
if x < x_at_y:
|
||||||
|
inside = not inside
|
||||||
|
j = i
|
||||||
|
return inside
|
||||||
|
|
||||||
|
|
||||||
|
def distance_to_ring(point, ring):
|
||||||
|
"""Shortest distance from a point to the ring's edges (not its interior)."""
|
||||||
|
px, py = point
|
||||||
|
best = float("inf")
|
||||||
|
count = len(ring)
|
||||||
|
for index in range(count):
|
||||||
|
ax, ay = ring[index]
|
||||||
|
bx, by = ring[(index + 1) % count]
|
||||||
|
dx = bx - ax
|
||||||
|
dy = by - ay
|
||||||
|
length_sq = dx * dx + dy * dy
|
||||||
|
if length_sq <= 1e-9:
|
||||||
|
distance = math.hypot(px - ax, py - ay)
|
||||||
|
else:
|
||||||
|
t = ((px - ax) * dx + (py - ay) * dy) / length_sq
|
||||||
|
t = max(0.0, min(1.0, t))
|
||||||
|
distance = math.hypot(px - (ax + t * dx), py - (ay + t * dy))
|
||||||
|
if distance < best:
|
||||||
|
best = distance
|
||||||
|
return best
|
||||||
22
blender/osmassets/grass.py
Normal file
22
blender/osmassets/grass.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"""Grass feature assembly (`landuse=grass`) with optional tuft scattering."""
|
||||||
|
|
||||||
|
from osmassets.geom import clip_polygon
|
||||||
|
from osmassets.mesh import MeshBatch
|
||||||
|
|
||||||
|
|
||||||
|
def assemble(ring, way_id, scene_xmin, scene_xmax, scene_ymin, scene_ymax,
|
||||||
|
green_c, grass_mat, tuft_variants, add_grass_tufts_fn):
|
||||||
|
ring = clip_polygon(ring, scene_xmin, scene_xmax, scene_ymin, scene_ymax)
|
||||||
|
name = "Grass_" + str(way_id)
|
||||||
|
focus = list(ring)
|
||||||
|
if len(ring) < 3:
|
||||||
|
return 0, 0, focus
|
||||||
|
batch = MeshBatch(name, green_c, grass_mat)
|
||||||
|
batch.add_polygon(ring, 0.015)
|
||||||
|
obj = batch.finish()
|
||||||
|
tufts = 0
|
||||||
|
if tuft_variants:
|
||||||
|
tufts = add_grass_tufts_fn(name, ring, tuft_variants, green_c)
|
||||||
|
if obj:
|
||||||
|
obj["grass_tufts"] = tufts
|
||||||
|
return 1, tufts, focus
|
||||||
172
blender/osmassets/materials.py
Normal file
172
blender/osmassets/materials.py
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
"""Blender material construction.
|
||||||
|
|
||||||
|
Requires `bpy`; only runs inside Blender. The catalog (`osmassets.catalog`)
|
||||||
|
declares *what* a material is, this module builds it — that split is what keeps
|
||||||
|
the catalog importable by plain Python, and by anything else that wants to read
|
||||||
|
the scene's material definitions without launching Blender.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
|
||||||
|
|
||||||
|
TEXTURE_ROOT = os.path.abspath(os.path.join(
|
||||||
|
os.path.dirname(os.path.abspath(__file__)), "..", "..",
|
||||||
|
"assets", "textures", "polyhaven"
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def principled_bsdf(material):
|
||||||
|
if not material.use_nodes:
|
||||||
|
return None
|
||||||
|
for node in material.node_tree.nodes:
|
||||||
|
if node.type == "BSDF_PRINCIPLED":
|
||||||
|
return node
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def make_material(name, color, roughness=0.8, metallic=0.0):
|
||||||
|
material = bpy.data.materials.get(name) or bpy.data.materials.new(name)
|
||||||
|
material.diffuse_color = (*color, 1.0)
|
||||||
|
material.use_nodes = True
|
||||||
|
bsdf = principled_bsdf(material)
|
||||||
|
if bsdf:
|
||||||
|
bsdf.inputs["Base Color"].default_value = (*color, 1.0)
|
||||||
|
bsdf.inputs["Roughness"].default_value = roughness
|
||||||
|
bsdf.inputs["Metallic"].default_value = metallic
|
||||||
|
return material
|
||||||
|
|
||||||
|
|
||||||
|
def add_procedural_surface(material, colors, scale=2.0, detail=2.0, bump_strength=0.08,
|
||||||
|
object_space=False):
|
||||||
|
nodes = material.node_tree.nodes
|
||||||
|
links = material.node_tree.links
|
||||||
|
bsdf = principled_bsdf(material)
|
||||||
|
if not bsdf:
|
||||||
|
return
|
||||||
|
noise = nodes.new("ShaderNodeTexNoise")
|
||||||
|
noise.inputs["Scale"].default_value = scale
|
||||||
|
noise.inputs["Detail"].default_value = detail
|
||||||
|
noise.inputs["Roughness"].default_value = 0.65
|
||||||
|
texcoord = nodes.new("ShaderNodeTexCoord")
|
||||||
|
ramp = nodes.new("ShaderNodeValToRGB")
|
||||||
|
ramp.color_ramp.elements[0].color = (*colors[0], 1.0)
|
||||||
|
ramp.color_ramp.elements[1].color = (*colors[1], 1.0)
|
||||||
|
bump = nodes.new("ShaderNodeBump")
|
||||||
|
bump.inputs["Strength"].default_value = bump_strength
|
||||||
|
bump.inputs["Distance"].default_value = 0.12
|
||||||
|
# "Generated" normalises across the object bounding box, so on a mesh that
|
||||||
|
# spans the whole scene the noise stretches to tens of metres and vanishes.
|
||||||
|
# Object space keeps the scale in metres, which is what foliage needs.
|
||||||
|
source = "Object" if object_space else "Generated"
|
||||||
|
links.new(texcoord.outputs[source], noise.inputs["Vector"])
|
||||||
|
links.new(noise.outputs["Fac"], ramp.inputs["Fac"])
|
||||||
|
links.new(ramp.outputs["Color"], bsdf.inputs["Base Color"])
|
||||||
|
links.new(noise.outputs["Fac"], bump.inputs["Height"])
|
||||||
|
links.new(bump.outputs["Normal"], bsdf.inputs["Normal"])
|
||||||
|
|
||||||
|
|
||||||
|
def tint_base_color(material, tint, factor):
|
||||||
|
"""Mix an existing material's base colour toward `tint`.
|
||||||
|
|
||||||
|
Imported assets arrive with their own diffuse texture wired up. Rather than
|
||||||
|
replacing it — which throws away the leaf detail — this splices a mix node
|
||||||
|
in front of the Base Color input so the texture survives at (1 - factor).
|
||||||
|
"""
|
||||||
|
if factor <= 0.0 or not material.use_nodes:
|
||||||
|
return
|
||||||
|
bsdf = principled_bsdf(material)
|
||||||
|
if not bsdf:
|
||||||
|
return
|
||||||
|
nodes = material.node_tree.nodes
|
||||||
|
links = material.node_tree.links
|
||||||
|
base = bsdf.inputs["Base Color"]
|
||||||
|
tint_node = nodes.new("ShaderNodeRGB")
|
||||||
|
tint_node.outputs["Color"].default_value = (*tint, 1.0)
|
||||||
|
mix = nodes.new("ShaderNodeMixRGB")
|
||||||
|
mix.blend_type = "MIX"
|
||||||
|
mix.inputs["Fac"].default_value = factor
|
||||||
|
if base.is_linked:
|
||||||
|
# Capture the upstream socket before relinking; Blender drops the old
|
||||||
|
# link as soon as the input takes a new one.
|
||||||
|
links.new(base.links[0].from_socket, mix.inputs[1])
|
||||||
|
else:
|
||||||
|
mix.inputs[1].default_value = base.default_value
|
||||||
|
links.new(tint_node.outputs["Color"], mix.inputs[2])
|
||||||
|
links.new(mix.outputs["Color"], base)
|
||||||
|
|
||||||
|
|
||||||
|
def make_textured_material(name, diffuse_file, normal_file, roughness,
|
||||||
|
scale, normal_is_bump=False, metallic=0.0,
|
||||||
|
tint=None, tint_factor=0.0):
|
||||||
|
diffuse_path = os.path.join(TEXTURE_ROOT, diffuse_file)
|
||||||
|
normal_path = os.path.join(TEXTURE_ROOT, normal_file)
|
||||||
|
if not os.path.exists(diffuse_path) or not os.path.exists(normal_path):
|
||||||
|
return make_material(name, (0.5, 0.5, 0.5), roughness, metallic)
|
||||||
|
|
||||||
|
material = make_material(name, (0.5, 0.5, 0.5), roughness, metallic)
|
||||||
|
nodes = material.node_tree.nodes
|
||||||
|
links = material.node_tree.links
|
||||||
|
bsdf = principled_bsdf(material)
|
||||||
|
if not bsdf:
|
||||||
|
return material
|
||||||
|
texcoord = nodes.new("ShaderNodeTexCoord")
|
||||||
|
mapping = nodes.new("ShaderNodeMapping")
|
||||||
|
mapping.inputs["Scale"].default_value = (scale, scale, scale)
|
||||||
|
diffuse = nodes.new("ShaderNodeTexImage")
|
||||||
|
diffuse.image = bpy.data.images.load(diffuse_path, check_existing=True)
|
||||||
|
diffuse.extension = "REPEAT"
|
||||||
|
normal = nodes.new("ShaderNodeTexImage")
|
||||||
|
normal.image = bpy.data.images.load(normal_path, check_existing=True)
|
||||||
|
normal.image.colorspace_settings.name = "Non-Color"
|
||||||
|
normal.extension = "REPEAT"
|
||||||
|
links.new(texcoord.outputs["Generated"], mapping.inputs["Vector"])
|
||||||
|
links.new(mapping.outputs["Vector"], diffuse.inputs["Vector"])
|
||||||
|
links.new(mapping.outputs["Vector"], normal.inputs["Vector"])
|
||||||
|
if tint and tint_factor > 0.0:
|
||||||
|
tint_node = nodes.new("ShaderNodeRGB")
|
||||||
|
tint_node.outputs["Color"].default_value = (*tint, 1.0)
|
||||||
|
mix = nodes.new("ShaderNodeMixRGB")
|
||||||
|
mix.blend_type = "MIX"
|
||||||
|
mix.inputs["Fac"].default_value = tint_factor
|
||||||
|
links.new(diffuse.outputs["Color"], mix.inputs[1])
|
||||||
|
links.new(tint_node.outputs["Color"], mix.inputs[2])
|
||||||
|
links.new(mix.outputs["Color"], bsdf.inputs["Base Color"])
|
||||||
|
else:
|
||||||
|
links.new(diffuse.outputs["Color"], bsdf.inputs["Base Color"])
|
||||||
|
if normal_is_bump:
|
||||||
|
bump = nodes.new("ShaderNodeBump")
|
||||||
|
bump.inputs["Strength"].default_value = 0.22
|
||||||
|
bump.inputs["Distance"].default_value = 0.12
|
||||||
|
links.new(normal.outputs["Color"], bump.inputs["Height"])
|
||||||
|
links.new(bump.outputs["Normal"], bsdf.inputs["Normal"])
|
||||||
|
else:
|
||||||
|
normal_map = nodes.new("ShaderNodeNormalMap")
|
||||||
|
normal_map.inputs["Strength"].default_value = 0.52
|
||||||
|
links.new(normal.outputs["Color"], normal_map.inputs["Color"])
|
||||||
|
links.new(normal_map.outputs["Normal"], bsdf.inputs["Normal"])
|
||||||
|
return material
|
||||||
|
|
||||||
|
|
||||||
|
def from_spec(spec):
|
||||||
|
"""Build a material from a `catalog.MATERIALS` entry."""
|
||||||
|
if spec["kind"] == "textured":
|
||||||
|
return make_textured_material(
|
||||||
|
spec["name"], spec["diffuse"], spec["normal"],
|
||||||
|
roughness=spec.get("roughness", 0.8), scale=spec["scale"],
|
||||||
|
normal_is_bump=spec.get("normal_is_bump", False),
|
||||||
|
metallic=spec.get("metallic", 0.0),
|
||||||
|
tint=spec.get("tint"), tint_factor=spec.get("tint_factor", 0.0))
|
||||||
|
|
||||||
|
material = make_material(spec["name"], spec["color"],
|
||||||
|
spec.get("roughness", 0.8),
|
||||||
|
spec.get("metallic", 0.0))
|
||||||
|
procedural = spec.get("procedural")
|
||||||
|
if procedural:
|
||||||
|
add_procedural_surface(material, procedural["colors"],
|
||||||
|
scale=procedural["scale"],
|
||||||
|
detail=procedural["detail"],
|
||||||
|
bump_strength=procedural["bump_strength"],
|
||||||
|
object_space=procedural.get("object_space", False))
|
||||||
|
return material
|
||||||
126
blender/osmassets/mesh.py
Normal file
126
blender/osmassets/mesh.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
"""Mesh and collection construction for the Blender scene.
|
||||||
|
|
||||||
|
Requires `bpy`; only runs inside Blender.
|
||||||
|
|
||||||
|
`MeshBatch` is the workhorse: most of the scene is flat polygons and extruded
|
||||||
|
prisms, and batching them into one mesh datablock per logical group keeps the
|
||||||
|
object count (and the glTF node count) down. Callers accumulate geometry and
|
||||||
|
call `finish()` once.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
|
||||||
|
|
||||||
|
def new_collection(name):
|
||||||
|
collection = bpy.data.collections.new(name)
|
||||||
|
bpy.context.scene.collection.children.link(collection)
|
||||||
|
return collection
|
||||||
|
|
||||||
|
|
||||||
|
def link_object_to_collection(obj, collection):
|
||||||
|
for current in list(obj.users_collection):
|
||||||
|
current.objects.unlink(obj)
|
||||||
|
collection.objects.link(obj)
|
||||||
|
|
||||||
|
|
||||||
|
class MeshBatch:
|
||||||
|
"""Accumulates polygons/prisms into a single mesh object."""
|
||||||
|
|
||||||
|
def __init__(self, name, collection, material):
|
||||||
|
self.name = name
|
||||||
|
self.collection = collection
|
||||||
|
self.material = material
|
||||||
|
self.vertices = []
|
||||||
|
self.faces = []
|
||||||
|
|
||||||
|
def add_polygon(self, ring, z):
|
||||||
|
if len(ring) < 3:
|
||||||
|
return
|
||||||
|
if ring[0] == ring[-1]:
|
||||||
|
ring = ring[:-1]
|
||||||
|
if len(ring) < 3:
|
||||||
|
return
|
||||||
|
start = len(self.vertices)
|
||||||
|
self.vertices.extend((x, y, z) for x, y in ring)
|
||||||
|
self.faces.append(tuple(range(start, start + len(ring))))
|
||||||
|
|
||||||
|
def add_prism(self, ring, base, height):
|
||||||
|
if len(ring) < 3:
|
||||||
|
return
|
||||||
|
if ring[0] == ring[-1]:
|
||||||
|
ring = ring[:-1]
|
||||||
|
if len(ring) < 3:
|
||||||
|
return
|
||||||
|
start = len(self.vertices)
|
||||||
|
self.vertices.extend((x, y, base) for x, y in ring)
|
||||||
|
self.vertices.extend((x, y, base + height) for x, y in ring)
|
||||||
|
n = len(ring)
|
||||||
|
self.faces.append(tuple(range(start, start + n)))
|
||||||
|
self.faces.append(tuple(range(start + n, start + 2 * n)))
|
||||||
|
for i in range(n):
|
||||||
|
j = (i + 1) % n
|
||||||
|
self.faces.append((start + i, start + j, start + n + j, start + n + i))
|
||||||
|
|
||||||
|
def finish(self):
|
||||||
|
if not self.vertices:
|
||||||
|
return None
|
||||||
|
mesh = bpy.data.meshes.new(self.name + "Mesh")
|
||||||
|
mesh.from_pydata(self.vertices, [], self.faces)
|
||||||
|
mesh.materials.append(self.material)
|
||||||
|
# Foliage reads as blobby volume, so it wants smooth normals; the built
|
||||||
|
# environment wants its facets. The name prefix is the discriminator.
|
||||||
|
if self.name.startswith("Tree_") or self.name.startswith("Scrub_"):
|
||||||
|
for polygon in mesh.polygons:
|
||||||
|
polygon.use_smooth = True
|
||||||
|
mesh.update()
|
||||||
|
obj = bpy.data.objects.new(self.name, mesh)
|
||||||
|
self.collection.objects.link(obj)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def make_prism(name, ring, base, height, material, collection):
|
||||||
|
batch = MeshBatch(name, collection, material)
|
||||||
|
batch.add_prism(ring, base, height)
|
||||||
|
return batch.finish()
|
||||||
|
|
||||||
|
|
||||||
|
def add_roof(name, ring, z, material, collection):
|
||||||
|
batch = MeshBatch(name + "_Roof", collection, material)
|
||||||
|
batch.add_polygon(ring, z)
|
||||||
|
return batch.finish()
|
||||||
|
|
||||||
|
|
||||||
|
def add_wall_panel(batch, start, end, base, height, thickness=0.045, inset=0.08):
|
||||||
|
"""Add a thin inset slab along a facade edge, used for window bands."""
|
||||||
|
dx, dy = end[0] - start[0], end[1] - start[1]
|
||||||
|
length = math.hypot(dx, dy)
|
||||||
|
if length < 3.0:
|
||||||
|
return
|
||||||
|
ux, uy = dx / length, dy / length
|
||||||
|
a = (start[0] + dx * inset, start[1] + dy * inset)
|
||||||
|
b = (end[0] - dx * inset, end[1] - dy * inset)
|
||||||
|
nx, ny = -uy * thickness / 2, ux * thickness / 2
|
||||||
|
panel = [(a[0] + nx, a[1] + ny), (b[0] + nx, b[1] + ny),
|
||||||
|
(b[0] - nx, b[1] - ny), (a[0] - nx, a[1] - ny)]
|
||||||
|
batch.add_prism(panel, base, height)
|
||||||
|
|
||||||
|
|
||||||
|
def add_polyline(name, coords, projector, collection, material, width, z):
|
||||||
|
"""Bevelled curve along lon/lat coordinates; the OSM highway road fallback."""
|
||||||
|
points = [projector.xy(c) for c in coords]
|
||||||
|
if len(points) < 2:
|
||||||
|
return
|
||||||
|
curve = bpy.data.curves.new(name, "CURVE")
|
||||||
|
curve.dimensions = "3D"
|
||||||
|
curve.resolution_u = 1
|
||||||
|
curve.bevel_depth = width / 2
|
||||||
|
curve.bevel_resolution = 1
|
||||||
|
spline = curve.splines.new("POLY")
|
||||||
|
spline.points.add(len(points) - 1)
|
||||||
|
for point, (x, y) in zip(spline.points, points):
|
||||||
|
point.co = (x, y, z, 1)
|
||||||
|
obj = bpy.data.objects.new(name, curve)
|
||||||
|
collection.objects.link(obj)
|
||||||
|
obj.data.materials.append(material)
|
||||||
89
blender/osmassets/osm.py
Normal file
89
blender/osmassets/osm.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"""OSM XML parsing and the local metric projection.
|
||||||
|
|
||||||
|
Pure Python: no `bpy`, so this runs and tests outside Blender.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
|
||||||
|
def tags(element):
|
||||||
|
return {t.attrib.get("k", ""): t.attrib.get("v", "")
|
||||||
|
for t in element.findall("tag")}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_osm(path):
|
||||||
|
root = ET.parse(path).getroot()
|
||||||
|
bounds_node = root.find("bounds")
|
||||||
|
if bounds_node is None:
|
||||||
|
raise RuntimeError("OSM file does not contain a bounds element")
|
||||||
|
bounds = {"min_lon": float(bounds_node.attrib["minlon"]),
|
||||||
|
"min_lat": float(bounds_node.attrib["minlat"]),
|
||||||
|
"max_lon": float(bounds_node.attrib["maxlon"]),
|
||||||
|
"max_lat": float(bounds_node.attrib["maxlat"])}
|
||||||
|
|
||||||
|
nodes = {}
|
||||||
|
point_features = []
|
||||||
|
for node in root.findall("node"):
|
||||||
|
try:
|
||||||
|
node_id = int(node.attrib["id"])
|
||||||
|
coord = (float(node.attrib["lon"]), float(node.attrib["lat"]))
|
||||||
|
node_tags = tags(node)
|
||||||
|
nodes[node_id] = coord
|
||||||
|
if node_tags:
|
||||||
|
point_features.append({"id": node.attrib.get("id", ""),
|
||||||
|
"coord": coord, "tags": node_tags})
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
ways = []
|
||||||
|
for way in root.findall("way"):
|
||||||
|
if way.attrib.get("action") == "delete":
|
||||||
|
continue
|
||||||
|
refs = []
|
||||||
|
for ref in way.findall("nd"):
|
||||||
|
try:
|
||||||
|
refs.append(int(ref.attrib["ref"]))
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
pass
|
||||||
|
coords = [nodes[r] for r in refs if r in nodes]
|
||||||
|
if len(coords) >= 2:
|
||||||
|
ways.append({"id": way.attrib.get("id", ""),
|
||||||
|
"coords": coords, "tags": tags(way)})
|
||||||
|
return bounds, ways, point_features
|
||||||
|
|
||||||
|
|
||||||
|
def parse_height(feature_tags, default):
|
||||||
|
try:
|
||||||
|
return max(0.5, float(feature_tags.get("height", default)))
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
class Projector:
|
||||||
|
"""Equirectangular projection about the centre of the OSM bounds.
|
||||||
|
|
||||||
|
Output is metres in a local ENU frame (X east, Y north), which is what both
|
||||||
|
the Blender scene and the Cesium GLB are authored in.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, bounds):
|
||||||
|
self.bounds = bounds
|
||||||
|
self.lon0 = (bounds["min_lon"] + bounds["max_lon"]) / 2
|
||||||
|
self.lat0 = (bounds["min_lat"] + bounds["max_lat"]) / 2
|
||||||
|
self.m_per_lat = 111320.0
|
||||||
|
self.m_per_lon = 111320.0 * math.cos(math.radians(self.lat0))
|
||||||
|
|
||||||
|
def xy(self, lon_lat):
|
||||||
|
lon, lat = lon_lat
|
||||||
|
return ((lon - self.lon0) * self.m_per_lon,
|
||||||
|
(lat - self.lat0) * self.m_per_lat)
|
||||||
|
|
||||||
|
def inside(self, lon_lat, pad=0.00035):
|
||||||
|
lon, lat = lon_lat
|
||||||
|
b = self.bounds
|
||||||
|
return (b["min_lon"] - pad <= lon <= b["max_lon"] + pad and
|
||||||
|
b["min_lat"] - pad <= lat <= b["max_lat"] + pad)
|
||||||
|
|
||||||
|
def ring(self, coords):
|
||||||
|
return [self.xy(c) for c in coords]
|
||||||
13
blender/osmassets/scrub.py
Normal file
13
blender/osmassets/scrub.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
"""Scrub feature assembly (`natural=scrub`). Flat ground cover only."""
|
||||||
|
|
||||||
|
from osmassets.geom import clip_polygon
|
||||||
|
|
||||||
|
|
||||||
|
def assemble(ring, way_id, scene_xmin, scene_xmax, scene_ymin, scene_ymax,
|
||||||
|
green_c, scrub_mat, add_scrub_patch_fn):
|
||||||
|
ring = clip_polygon(ring, scene_xmin, scene_xmax, scene_ymin, scene_ymax)
|
||||||
|
focus = list(ring)
|
||||||
|
if len(ring) < 3:
|
||||||
|
return 0, focus
|
||||||
|
add_scrub_patch_fn("Scrub_" + str(way_id), ring, scrub_mat, green_c)
|
||||||
|
return 1, focus
|
||||||
15
blender/osmassets/water.py
Normal file
15
blender/osmassets/water.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
"""Water feature assembly (`natural=water` or `water=lake`)."""
|
||||||
|
|
||||||
|
from osmassets.geom import clip_polygon
|
||||||
|
from osmassets.mesh import MeshBatch
|
||||||
|
|
||||||
|
|
||||||
|
def assemble(ring, scene_xmin, scene_xmax, scene_ymin, scene_ymax,
|
||||||
|
water_c, water_mat):
|
||||||
|
ring = clip_polygon(ring, scene_xmin, scene_xmax, scene_ymin, scene_ymax)
|
||||||
|
if len(ring) < 3:
|
||||||
|
return 0
|
||||||
|
batch = MeshBatch("Lake Surface", water_c, water_mat)
|
||||||
|
batch.add_polygon(ring, 0.10)
|
||||||
|
batch.finish()
|
||||||
|
return 1
|
||||||
314
blender/tests/test_pure.py
Normal file
314
blender/tests/test_pure.py
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
"""Tests for the bpy-free half of the pipeline.
|
||||||
|
|
||||||
|
python3 -m unittest discover blender/tests
|
||||||
|
|
||||||
|
These run without Blender, which is the point of the osmassets split: before
|
||||||
|
it, the only way to exercise clip_polygon or sample_tree_row was to render a
|
||||||
|
whole area and look at the picture.
|
||||||
|
|
||||||
|
The expected values are derived from the geometry, not captured from the
|
||||||
|
implementation — a test that just records current output would ratify a bug.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
|
||||||
|
|
||||||
|
from osmassets.geom import (
|
||||||
|
clip_polygon,
|
||||||
|
distance_to_ring,
|
||||||
|
feature_in_bounds,
|
||||||
|
geometry_rings,
|
||||||
|
point_in_polygon,
|
||||||
|
polygon_area,
|
||||||
|
sample_tree_row,
|
||||||
|
)
|
||||||
|
from osmassets.osm import Projector, parse_height, parse_osm, tags
|
||||||
|
|
||||||
|
|
||||||
|
SQUARE = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]
|
||||||
|
|
||||||
|
|
||||||
|
class GeometryRingsTest(unittest.TestCase):
|
||||||
|
def test_polygon_keeps_only_the_exterior_ring(self):
|
||||||
|
geometry = {"type": "Polygon", "coordinates": [["outer"], ["hole"]]}
|
||||||
|
self.assertEqual(geometry_rings(geometry), [["outer"]])
|
||||||
|
|
||||||
|
def test_multipolygon_takes_each_exterior_ring(self):
|
||||||
|
geometry = {"type": "MultiPolygon",
|
||||||
|
"coordinates": [[["a"], ["a hole"]], [["b"]]]}
|
||||||
|
self.assertEqual(geometry_rings(geometry), [["a"], ["b"]])
|
||||||
|
|
||||||
|
def test_unsupported_and_empty_geometry(self):
|
||||||
|
self.assertEqual(geometry_rings(None), [])
|
||||||
|
self.assertEqual(geometry_rings({}), [])
|
||||||
|
self.assertEqual(geometry_rings({"type": "LineString",
|
||||||
|
"coordinates": [[0, 0], [1, 1]]}), [])
|
||||||
|
self.assertEqual(geometry_rings({"type": "MultiPolygon",
|
||||||
|
"coordinates": [[], [["b"]]]}), [["b"]])
|
||||||
|
|
||||||
|
|
||||||
|
class ClipPolygonTest(unittest.TestCase):
|
||||||
|
def test_polygon_inside_the_box_is_unchanged(self):
|
||||||
|
clipped = clip_polygon(SQUARE, -1.0, 11.0, -1.0, 11.0)
|
||||||
|
self.assertEqual([(round(x, 6), round(y, 6)) for x, y in clipped], SQUARE)
|
||||||
|
|
||||||
|
def test_half_outside_polygon_is_cut_at_the_boundary(self):
|
||||||
|
clipped = clip_polygon(SQUARE, 0.0, 5.0, 0.0, 10.0)
|
||||||
|
self.assertTrue(all(x <= 5.0 + 1e-9 for x, _ in clipped))
|
||||||
|
# A 10x10 square clipped to half its width is a 5x10 rectangle.
|
||||||
|
self.assertAlmostEqual(polygon_area(clipped), 50.0, places=6)
|
||||||
|
|
||||||
|
def test_polygon_fully_outside_collapses(self):
|
||||||
|
self.assertEqual(clip_polygon(SQUARE, 20.0, 30.0, 20.0, 30.0), [])
|
||||||
|
|
||||||
|
def test_degenerate_input(self):
|
||||||
|
self.assertEqual(clip_polygon([], 0, 1, 0, 1), [])
|
||||||
|
self.assertEqual(clip_polygon([(0.0, 0.0), (1.0, 1.0)], 0, 1, 0, 1), [])
|
||||||
|
|
||||||
|
def test_axis_aligned_edge_does_not_divide_by_zero(self):
|
||||||
|
# A vertical edge crossing the x clip plane exercises the b[0] == a[0]
|
||||||
|
# guard in the intersection lambdas.
|
||||||
|
ring = [(5.0, -5.0), (5.0, 5.0), (-5.0, 5.0), (-5.0, -5.0)]
|
||||||
|
clipped = clip_polygon(ring, 0.0, 10.0, 0.0, 10.0)
|
||||||
|
self.assertAlmostEqual(polygon_area(clipped), 25.0, places=6)
|
||||||
|
|
||||||
|
|
||||||
|
class PolygonAreaTest(unittest.TestCase):
|
||||||
|
def test_square(self):
|
||||||
|
self.assertAlmostEqual(polygon_area(SQUARE), 100.0)
|
||||||
|
|
||||||
|
def test_winding_does_not_change_the_sign(self):
|
||||||
|
self.assertAlmostEqual(polygon_area(list(reversed(SQUARE))), 100.0)
|
||||||
|
|
||||||
|
def test_degenerate(self):
|
||||||
|
self.assertEqual(polygon_area([(0.0, 0.0), (1.0, 1.0)]), 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
class PointInPolygonTest(unittest.TestCase):
|
||||||
|
def test_inside_and_outside(self):
|
||||||
|
self.assertTrue(point_in_polygon((5.0, 5.0), SQUARE))
|
||||||
|
self.assertFalse(point_in_polygon((15.0, 5.0), SQUARE))
|
||||||
|
self.assertFalse(point_in_polygon((5.0, -0.5), SQUARE))
|
||||||
|
|
||||||
|
def test_concave_notch_is_excluded(self):
|
||||||
|
# An L shape: the notch at (8, 8) is outside even though it sits inside
|
||||||
|
# the bounding box.
|
||||||
|
shape = [(0.0, 0.0), (10.0, 0.0), (10.0, 5.0),
|
||||||
|
(5.0, 5.0), (5.0, 10.0), (0.0, 10.0)]
|
||||||
|
self.assertTrue(point_in_polygon((2.0, 8.0), shape))
|
||||||
|
self.assertFalse(point_in_polygon((8.0, 8.0), shape))
|
||||||
|
|
||||||
|
|
||||||
|
class DistanceToRingTest(unittest.TestCase):
|
||||||
|
def test_distance_is_to_the_edge_not_the_interior(self):
|
||||||
|
# Centre of the square: 5m from every edge, even though it is inside.
|
||||||
|
self.assertAlmostEqual(distance_to_ring((5.0, 5.0), SQUARE), 5.0)
|
||||||
|
self.assertAlmostEqual(distance_to_ring((1.0, 5.0), SQUARE), 1.0)
|
||||||
|
|
||||||
|
def test_outside_point(self):
|
||||||
|
self.assertAlmostEqual(distance_to_ring((-3.0, 5.0), SQUARE), 3.0)
|
||||||
|
|
||||||
|
def test_closes_the_ring(self):
|
||||||
|
# Nearest edge is the implicit closing segment from (0,10) back to (0,0).
|
||||||
|
self.assertAlmostEqual(distance_to_ring((-2.0, 9.0), SQUARE), 2.0)
|
||||||
|
|
||||||
|
def test_repeated_vertex_does_not_divide_by_zero(self):
|
||||||
|
ring = [(0.0, 0.0), (0.0, 0.0), (4.0, 0.0)]
|
||||||
|
self.assertAlmostEqual(distance_to_ring((2.0, 3.0), ring), 3.0)
|
||||||
|
|
||||||
|
|
||||||
|
class SampleTreeRowTest(unittest.TestCase):
|
||||||
|
def test_even_spacing_along_a_straight_line(self):
|
||||||
|
samples = sample_tree_row([(0.0, 0.0), (10.0, 0.0)], spacing=5.0, height=6.0)
|
||||||
|
self.assertEqual([(round(x, 6), round(y, 6)) for x, y, _ in samples],
|
||||||
|
[(0.0, 0.0), (5.0, 0.0), (10.0, 0.0)])
|
||||||
|
self.assertTrue(all(h == 6.0 for _, _, h in samples))
|
||||||
|
|
||||||
|
def test_spacing_carries_across_segment_joins(self):
|
||||||
|
# Two 3m segments with 4m spacing: the second sample must land 1m into
|
||||||
|
# the second segment, not restart at its origin.
|
||||||
|
samples = sample_tree_row([(0.0, 0.0), (3.0, 0.0), (6.0, 0.0)],
|
||||||
|
spacing=4.0, height=5.0)
|
||||||
|
xs = [round(x, 6) for x, _, _ in samples]
|
||||||
|
self.assertEqual(xs, [0.0, 4.0, 6.0])
|
||||||
|
|
||||||
|
def test_trailing_point_is_skipped_when_it_would_double_plant(self):
|
||||||
|
# Endpoint sits 0.2m past the last sample, well under spacing * 0.45.
|
||||||
|
samples = sample_tree_row([(0.0, 0.0), (5.2, 0.0)], spacing=5.0, height=5.0)
|
||||||
|
self.assertEqual([round(x, 6) for x, _, _ in samples], [0.0, 5.0])
|
||||||
|
|
||||||
|
def test_zero_length_segment_is_skipped(self):
|
||||||
|
samples = sample_tree_row([(0.0, 0.0), (0.0, 0.0), (10.0, 0.0)],
|
||||||
|
spacing=5.0, height=5.0)
|
||||||
|
self.assertEqual([round(x, 6) for x, _, _ in samples], [0.0, 5.0, 10.0])
|
||||||
|
|
||||||
|
def test_too_few_points(self):
|
||||||
|
self.assertEqual(sample_tree_row([(0.0, 0.0)], spacing=5.0, height=5.0), [])
|
||||||
|
|
||||||
|
|
||||||
|
BOUNDS = {"min_lon": 114.0, "min_lat": 30.0, "max_lon": 114.01, "max_lat": 30.01}
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectorTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.projector = Projector(BOUNDS)
|
||||||
|
|
||||||
|
def test_centre_of_bounds_is_the_origin(self):
|
||||||
|
x, y = self.projector.xy((114.005, 30.005))
|
||||||
|
self.assertAlmostEqual(x, 0.0, places=6)
|
||||||
|
self.assertAlmostEqual(y, 0.0, places=6)
|
||||||
|
|
||||||
|
def test_axes_point_east_and_north(self):
|
||||||
|
east, _ = self.projector.xy((114.006, 30.005))
|
||||||
|
_, north = self.projector.xy((114.005, 30.006))
|
||||||
|
self.assertGreater(east, 0.0)
|
||||||
|
self.assertGreater(north, 0.0)
|
||||||
|
|
||||||
|
def test_longitude_metres_shrink_with_latitude(self):
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
self.projector.m_per_lon,
|
||||||
|
111320.0 * math.cos(math.radians(30.005)),
|
||||||
|
places=6,
|
||||||
|
)
|
||||||
|
self.assertLess(self.projector.m_per_lon, self.projector.m_per_lat)
|
||||||
|
|
||||||
|
def test_inside_honours_the_pad(self):
|
||||||
|
self.assertTrue(self.projector.inside((114.005, 30.005)))
|
||||||
|
# Default pad is 0.00035 degrees, so just outside the box still counts.
|
||||||
|
self.assertTrue(self.projector.inside((114.0102, 30.005)))
|
||||||
|
self.assertFalse(self.projector.inside((114.02, 30.005)))
|
||||||
|
self.assertFalse(self.projector.inside((114.0102, 30.005), pad=0.0))
|
||||||
|
|
||||||
|
def test_ring_projects_every_coordinate(self):
|
||||||
|
ring = self.projector.ring([(114.0, 30.0), (114.01, 30.01)])
|
||||||
|
self.assertEqual(len(ring), 2)
|
||||||
|
self.assertLess(ring[0][0], 0.0)
|
||||||
|
self.assertGreater(ring[1][0], 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureInBoundsTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.projector = Projector(BOUNDS)
|
||||||
|
|
||||||
|
def test_polygon_with_one_inside_vertex_counts(self):
|
||||||
|
feature = {"geometry": {"type": "Polygon", "coordinates": [[
|
||||||
|
[120.0, 40.0], [114.005, 30.005], [120.0, 40.0]]]}}
|
||||||
|
self.assertTrue(feature_in_bounds(feature, self.projector))
|
||||||
|
|
||||||
|
def test_feature_fully_outside(self):
|
||||||
|
feature = {"geometry": {"type": "Polygon", "coordinates": [[
|
||||||
|
[120.0, 40.0], [120.1, 40.1], [120.0, 40.0]]]}}
|
||||||
|
self.assertFalse(feature_in_bounds(feature, self.projector))
|
||||||
|
|
||||||
|
def test_missing_geometry(self):
|
||||||
|
self.assertFalse(feature_in_bounds({}, self.projector))
|
||||||
|
|
||||||
|
|
||||||
|
class ParseHeightTest(unittest.TestCase):
|
||||||
|
def test_reads_the_tag(self):
|
||||||
|
self.assertEqual(parse_height({"height": "24"}, 12.0), 24.0)
|
||||||
|
|
||||||
|
def test_missing_tag_falls_back(self):
|
||||||
|
self.assertEqual(parse_height({}, 12.0), 12.0)
|
||||||
|
|
||||||
|
def test_unparsable_tag_falls_back(self):
|
||||||
|
self.assertEqual(parse_height({"height": "about 20m"}, 12.0), 12.0)
|
||||||
|
|
||||||
|
def test_clamped_to_half_a_metre(self):
|
||||||
|
self.assertEqual(parse_height({"height": "0.1"}, 12.0), 0.5)
|
||||||
|
self.assertEqual(parse_height({"height": "-5"}, 12.0), 0.5)
|
||||||
|
|
||||||
|
|
||||||
|
OSM_SAMPLE = """<?xml version='1.0' encoding='UTF-8'?>
|
||||||
|
<osm version='0.6'>
|
||||||
|
<bounds minlon='114.0' minlat='30.0' maxlon='114.01' maxlat='30.01'/>
|
||||||
|
<node id='1' lon='114.001' lat='30.001'/>
|
||||||
|
<node id='2' lon='114.002' lat='30.002'/>
|
||||||
|
<node id='3' lon='114.003' lat='30.003'/>
|
||||||
|
<node id='4' lon='114.004' lat='30.004'>
|
||||||
|
<tag k='natural' v='tree'/>
|
||||||
|
<tag k='height' v='7'/>
|
||||||
|
</node>
|
||||||
|
<node id='bad' lon='oops' lat='30.0'/>
|
||||||
|
<way id='10'>
|
||||||
|
<nd ref='1'/><nd ref='2'/><nd ref='3'/>
|
||||||
|
<tag k='building' v='yes'/>
|
||||||
|
</way>
|
||||||
|
<way id='11' action='delete'>
|
||||||
|
<nd ref='1'/><nd ref='2'/>
|
||||||
|
<tag k='building' v='yes'/>
|
||||||
|
</way>
|
||||||
|
<way id='12'>
|
||||||
|
<nd ref='1'/><nd ref='999'/>
|
||||||
|
</way>
|
||||||
|
</osm>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class ParseOsmTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
handle = tempfile.NamedTemporaryFile("w", suffix=".osm", delete=False,
|
||||||
|
encoding="utf-8")
|
||||||
|
handle.write(OSM_SAMPLE)
|
||||||
|
handle.close()
|
||||||
|
self.path = handle.name
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
os.unlink(self.path)
|
||||||
|
|
||||||
|
def test_bounds(self):
|
||||||
|
bounds, _, _ = parse_osm(self.path)
|
||||||
|
self.assertEqual(bounds, {"min_lon": 114.0, "min_lat": 30.0,
|
||||||
|
"max_lon": 114.01, "max_lat": 30.01})
|
||||||
|
|
||||||
|
def test_only_tagged_nodes_become_point_features(self):
|
||||||
|
_, _, points = parse_osm(self.path)
|
||||||
|
self.assertEqual([p["id"] for p in points], ["4"])
|
||||||
|
self.assertEqual(points[0]["tags"], {"natural": "tree", "height": "7"})
|
||||||
|
|
||||||
|
def test_deleted_ways_are_dropped(self):
|
||||||
|
_, ways, _ = parse_osm(self.path)
|
||||||
|
self.assertNotIn("11", [w["id"] for w in ways])
|
||||||
|
|
||||||
|
def test_way_below_two_resolvable_nodes_is_dropped(self):
|
||||||
|
# Way 12 references a node that does not exist, leaving one coordinate.
|
||||||
|
_, ways, _ = parse_osm(self.path)
|
||||||
|
self.assertEqual([w["id"] for w in ways], ["10"])
|
||||||
|
self.assertEqual(len(ways[0]["coords"]), 3)
|
||||||
|
self.assertEqual(ways[0]["tags"], {"building": "yes"})
|
||||||
|
|
||||||
|
def test_unparsable_node_is_skipped_not_fatal(self):
|
||||||
|
_, _, points = parse_osm(self.path)
|
||||||
|
self.assertNotIn("bad", [p["id"] for p in points])
|
||||||
|
|
||||||
|
def test_missing_bounds_is_an_error(self):
|
||||||
|
handle = tempfile.NamedTemporaryFile("w", suffix=".osm", delete=False,
|
||||||
|
encoding="utf-8")
|
||||||
|
handle.write("<osm version='0.6'></osm>")
|
||||||
|
handle.close()
|
||||||
|
try:
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
parse_osm(handle.name)
|
||||||
|
finally:
|
||||||
|
os.unlink(handle.name)
|
||||||
|
|
||||||
|
|
||||||
|
class TagsTest(unittest.TestCase):
|
||||||
|
def test_reads_key_value_children(self):
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
element = ET.fromstring(
|
||||||
|
"<way><tag k='building' v='yes'/><tag k='height' v='9'/></way>")
|
||||||
|
self.assertEqual(tags(element), {"building": "yes", "height": "9"})
|
||||||
|
|
||||||
|
def test_untagged_element(self):
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
self.assertEqual(tags(ET.fromstring("<way/>")), {})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
171
blender/tools/scene_digest.py
Normal file
171
blender/tools/scene_digest.py
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
"""Dump a stable structural digest of a .blend built by generate_scene.py.
|
||||||
|
|
||||||
|
Run inside Blender:
|
||||||
|
|
||||||
|
Blender --background --factory-startup \
|
||||||
|
--python blender/tools/scene_digest.py -- \
|
||||||
|
--blend /path/to/scene.blend --out /path/to/digest.json
|
||||||
|
|
||||||
|
The digest is the parity contract for the osmassets refactor: it must stay
|
||||||
|
byte-identical across a pure restructuring. Fields that a control run (same
|
||||||
|
code, run twice) proves unstable belong in UNSTABLE_* below rather than in the
|
||||||
|
digest, otherwise the check is noise and gets ignored.
|
||||||
|
|
||||||
|
Floats are rounded to 6 decimals: Blender round-trips them through single
|
||||||
|
precision, so the last digits of a repr are not a meaningful signal.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
|
||||||
|
|
||||||
|
# Object-level custom properties Blender adds on its own; not ours to compare.
|
||||||
|
IGNORED_PROP_KEYS = {"_RNA_UI", "cycles"}
|
||||||
|
|
||||||
|
|
||||||
|
def cli_args():
|
||||||
|
values = {"blend": None, "out": None}
|
||||||
|
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||||
|
i = 0
|
||||||
|
while i < len(argv):
|
||||||
|
if argv[i].startswith("--") and i + 1 < len(argv):
|
||||||
|
values[argv[i][2:]] = argv[i + 1]
|
||||||
|
i += 2
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
for key in ("blend", "out"):
|
||||||
|
if not values.get(key):
|
||||||
|
raise RuntimeError("--%s is required" % key)
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def rounded(value):
|
||||||
|
"""Normalise Blender's float/vector/array soup into plain JSON."""
|
||||||
|
if isinstance(value, float):
|
||||||
|
return round(value, 6)
|
||||||
|
if isinstance(value, (int, str, bool)) or value is None:
|
||||||
|
return value
|
||||||
|
if hasattr(value, "__len__") and not isinstance(value, (str, bytes)):
|
||||||
|
return [rounded(item) for item in value]
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def custom_props(datablock):
|
||||||
|
out = {}
|
||||||
|
for key in sorted(datablock.keys()):
|
||||||
|
if key in IGNORED_PROP_KEYS:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
out[key] = rounded(datablock[key])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
out[key] = "<unreadable>"
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def material_digest(material):
|
||||||
|
node_types = {}
|
||||||
|
if material.use_nodes and material.node_tree:
|
||||||
|
for node in material.node_tree.nodes:
|
||||||
|
node_types[node.type] = node_types.get(node.type, 0) + 1
|
||||||
|
entry = {
|
||||||
|
"name": material.name,
|
||||||
|
"diffuse_color": rounded(material.diffuse_color),
|
||||||
|
"use_nodes": material.use_nodes,
|
||||||
|
# Node identity is unstable (Blender names them Mix.001, Mix.002 …
|
||||||
|
# depending on creation order across datablocks), so compare the
|
||||||
|
# type histogram and the link count instead of the graph itself.
|
||||||
|
"node_types": dict(sorted(node_types.items())),
|
||||||
|
"link_count": (len(material.node_tree.links)
|
||||||
|
if material.use_nodes and material.node_tree else 0),
|
||||||
|
"props": custom_props(material),
|
||||||
|
}
|
||||||
|
if material.use_nodes and material.node_tree:
|
||||||
|
for node in material.node_tree.nodes:
|
||||||
|
if node.type != "BSDF_PRINCIPLED":
|
||||||
|
continue
|
||||||
|
for socket in ("Base Color", "Roughness", "Metallic"):
|
||||||
|
if socket in node.inputs:
|
||||||
|
entry["bsdf_" + socket.replace(" ", "_").lower()] = rounded(
|
||||||
|
node.inputs[socket].default_value)
|
||||||
|
break
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def object_digest(obj):
|
||||||
|
entry = {
|
||||||
|
"name": obj.name,
|
||||||
|
"type": obj.type,
|
||||||
|
"collections": sorted(c.name for c in obj.users_collection),
|
||||||
|
"location": rounded(obj.location),
|
||||||
|
"rotation_euler": rounded(obj.rotation_euler),
|
||||||
|
"scale": rounded(obj.scale),
|
||||||
|
"data": obj.data.name if obj.data else None,
|
||||||
|
"materials": [slot.material.name if slot.material else None
|
||||||
|
for slot in obj.material_slots],
|
||||||
|
"modifiers": [(m.name, m.type) for m in obj.modifiers],
|
||||||
|
"props": custom_props(obj),
|
||||||
|
}
|
||||||
|
if obj.type == "MESH":
|
||||||
|
mesh = obj.data
|
||||||
|
entry["vertices"] = len(mesh.vertices)
|
||||||
|
entry["polygons"] = len(mesh.polygons)
|
||||||
|
entry["uv_layers"] = [layer.name for layer in mesh.uv_layers]
|
||||||
|
entry["smooth_polygons"] = sum(1 for p in mesh.polygons if p.use_smooth)
|
||||||
|
# Bounding box catches geometry that moved without changing topology;
|
||||||
|
# a vertex-by-vertex hash would be exact but too brittle to act on.
|
||||||
|
entry["bound_box"] = [rounded(corner) for corner in obj.bound_box]
|
||||||
|
elif obj.type == "CURVE":
|
||||||
|
entry["splines"] = len(obj.data.splines)
|
||||||
|
entry["points"] = sum(len(s.points) for s in obj.data.splines)
|
||||||
|
entry["bevel_depth"] = rounded(obj.data.bevel_depth)
|
||||||
|
elif obj.type == "LIGHT":
|
||||||
|
entry["light_type"] = obj.data.type
|
||||||
|
entry["energy"] = rounded(obj.data.energy)
|
||||||
|
elif obj.type == "CAMERA":
|
||||||
|
entry["lens"] = rounded(obj.data.lens)
|
||||||
|
entry["clip"] = [rounded(obj.data.clip_start), rounded(obj.data.clip_end)]
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def digest(blend_path):
|
||||||
|
bpy.ops.wm.open_mainfile(filepath=blend_path)
|
||||||
|
scene = bpy.context.scene
|
||||||
|
return {
|
||||||
|
"scene": {
|
||||||
|
"name": scene.name,
|
||||||
|
"engine": scene.render.engine,
|
||||||
|
"resolution": [scene.render.resolution_x, scene.render.resolution_y],
|
||||||
|
"world_color": rounded(scene.world.color) if scene.world else None,
|
||||||
|
"camera": scene.camera.name if scene.camera else None,
|
||||||
|
"props": custom_props(scene),
|
||||||
|
},
|
||||||
|
"collections": sorted(c.name for c in bpy.data.collections),
|
||||||
|
"counts": {
|
||||||
|
"objects": len(bpy.data.objects),
|
||||||
|
"meshes": len(bpy.data.meshes),
|
||||||
|
"materials": len(bpy.data.materials),
|
||||||
|
"images": len(bpy.data.images),
|
||||||
|
},
|
||||||
|
"objects": [object_digest(obj)
|
||||||
|
for obj in sorted(bpy.data.objects, key=lambda o: o.name)],
|
||||||
|
"materials": [material_digest(mat)
|
||||||
|
for mat in sorted(bpy.data.materials, key=lambda m: m.name)],
|
||||||
|
"images": sorted(image.name for image in bpy.data.images),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
args = cli_args()
|
||||||
|
result = digest(args["blend"])
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(args["out"])), exist_ok=True)
|
||||||
|
with open(args["out"], "w", encoding="utf-8") as handle:
|
||||||
|
json.dump(result, handle, ensure_ascii=False, indent=2, sort_keys=True)
|
||||||
|
handle.write("\n")
|
||||||
|
print("DIGEST_DONE", json.dumps({
|
||||||
|
"blend": args["blend"], "out": args["out"],
|
||||||
|
"objects": len(result["objects"]),
|
||||||
|
"materials": len(result["materials"]),
|
||||||
|
}))
|
||||||
112
docs/refactor-plan.md
Normal file
112
docs/refactor-plan.md
Normal file
@@ -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 <label>` | 跑 blender+cesium 阶段 → 收集 `SCENE_DONE` / `CESIUM_EXPORT_DONE` / 两份摘要 / 渲染 PNG 到 `outputs/_refactor-baseline/<label>/` |
|
||||||
|
|
||||||
|
**先做对照实验(control)**:用未改动的代码连跑两次,diff 两份摘要。这一步确定哪些字段天然不确定,把这些字段列入忽略名单。没做这步的 parity 校验是假的。
|
||||||
|
|
||||||
|
已完成,结论如下(`control-1` vs `control-2`,两区域):
|
||||||
|
|
||||||
|
- `.blend` **结构摘要两次完全一致** —— 这是主校验信号,可信
|
||||||
|
- `.blend` 文件 sha256 不一致:内嵌绝对路径 + 图片打包顺序随哈希表走
|
||||||
|
- 渲染 PNG sha256 不一致:EEVEE 非位级可复现
|
||||||
|
- GLB 结构(node / mesh / primitive / material / image)两次完全一致,但 accessor 数 399 vs 398、buffer 差 720 字节:glTF 导出器会去重相同 accessor,而 `smart_project` 的 UV 带浮点噪声,一次能去重一次不能
|
||||||
|
|
||||||
|
忽略名单(写在 `scripts/parity.js:IGNORED_PATHS`,附原因):`files.{blend,glb,render}.sha256`、`files.{glb,render}.bytes`、`glbDigest.{fileBytes,buffers,counts.accessors}`。
|
||||||
|
|
||||||
|
保留比对的即真正的契约:`SCENE_DONE` / `CESIUM_EXPORT_DONE` 标记、`.blend` 全量结构摘要、GLB 的 node/mesh/material/image 结构、`<area>.json` 元数据。加上 `control-1`、`control-2` 两份基线已落盘。
|
||||||
|
|
||||||
|
样本区域:
|
||||||
|
|
||||||
|
- `nantaizi-lake-innovation-valley` — 主样本,OSM + osm2streets GeoJSON 齐全
|
||||||
|
- `hanyang-block` — 次样本,只有 `intermediates` 产物,需先补跑一次 blender 阶段生成基线
|
||||||
|
|
||||||
|
## 分期
|
||||||
|
|
||||||
|
### P0 — 抽纯函数(行为零变化)
|
||||||
|
|
||||||
|
新建 `blender/osmassets/`,只搬运、不改逻辑:
|
||||||
|
|
||||||
|
| 目标文件 | 从 `generate_scene.py` 搬入 | 依赖 |
|
||||||
|
|---|---|---|
|
||||||
|
| `osm.py` | `tags` (94)、`parse_osm` (99)、`Projector` (140)、`parse_height` (506) | 无 bpy |
|
||||||
|
| `geom.py` | `geometry_rings` (406)、`feature_in_bounds` (418)、`clip_polygon` (426)、`sample_tree_row` (513)、`polygon_area` (688)、`point_in_polygon` (697)、`distance_to_ring` (712) | 无 bpy |
|
||||||
|
|
||||||
|
- `generate_scene.py` 顶部加 `sys.path` 引导(`--factory-startup` 下 `blender/` 不在 `sys.path`),改为 `from osmassets import ...`
|
||||||
|
- 新增 `blender/tests/test_geom.py`、`test_osm.py`,`unittest` 标准库,系统 `python3` 直接跑(本机 3.9,避免 3.10+ 语法)
|
||||||
|
- 验收:`python3 -m unittest discover blender/tests` 通过 + parity 全绿
|
||||||
|
|
||||||
|
### P1 — 单一定义源
|
||||||
|
|
||||||
|
新建 `blender/osmassets/catalog.py`:
|
||||||
|
|
||||||
|
- `ROAD_LAYERS`:`id` / `blender_z` / `material_name` / `color`,替换 `generate_scene.py:1017` 的 `road_mats` 与 `1122` 的 `layer_z` 两份副本
|
||||||
|
- `MATERIAL_SPECS`:目前散在 `build()` 里的全部 `make_material` / `make_textured_material` 调用参数
|
||||||
|
- 新增一致性检查:读输出目录里已存在的 `osm2streets_scene_style.json`,比对图层 id 集合与顺序,不一致则打 warning(**不**改颜色,改了就破坏 parity → 见 D2)
|
||||||
|
|
||||||
|
验收:parity 全绿;手动删一个图层 id 验证 warning 生效。
|
||||||
|
|
||||||
|
### P2 — 要素注册表
|
||||||
|
|
||||||
|
新建 `blender/osmassets/features/`,每种要素一个模块,导出 `SPEC`:
|
||||||
|
|
||||||
|
```
|
||||||
|
water.py natural=water / water=lake
|
||||||
|
grass.py landuse=grass(含 tuft 散布)
|
||||||
|
scrub.py natural=scrub
|
||||||
|
tree.py natural=tree 节点 + natural=tree_row + 两种树风格
|
||||||
|
building.py building=*(含 roof / windows)
|
||||||
|
fountain.py amenity=fountain
|
||||||
|
roads.py osm2streets GeoJSON 图层 + highway 折线回退
|
||||||
|
```
|
||||||
|
|
||||||
|
- `scene.py::assemble()` 遍历注册表;`build()` 收缩为「解析 → assemble → 灯光相机 → 存盘渲染」
|
||||||
|
- 计数器改由注册表汇总,但 `SCENE_DONE` 与 `scene[...]` 的键名、顺序保持逐字不变
|
||||||
|
- if/elif 的**匹配顺序**是语义的一部分(`building` 分支在最后),注册表必须保序
|
||||||
|
|
||||||
|
验收:parity 全绿 —— 这期风险最高,逐要素分次提交,每次单独跑 parity。
|
||||||
|
|
||||||
|
### P3 — 材质契约化
|
||||||
|
|
||||||
|
- `catalog.py` 的材质规格扩展出 cesium 段:`tint` / `metallic` / `base_color` / `emission`
|
||||||
|
- `generate_scene.py` 把规格写进材质自定义属性 `material["cesium_export"] = json.dumps(spec)`
|
||||||
|
- `export_cesium.py` 优先读自定义属性;读不到时回落到现有四张名字表(**原样保留,含 D1 死条目**),保证旧 `.blend` 仍能导出且 parity 成立
|
||||||
|
- `Tree Crown` 的程序化贴图特例保持不变
|
||||||
|
|
||||||
|
验收:parity 全绿;另外用重构前生成的旧 `.blend` 跑一次导出,确认回落路径可用。
|
||||||
|
|
||||||
|
## 不在本轮范围
|
||||||
|
|
||||||
|
- 输出目标可插拔(整场景 / 每要素单独 GLB)——原 P4
|
||||||
|
- `build-area.js` 里 390 行内联 HTML 与手写 glTF 的拆分——原 P4
|
||||||
|
- 上表 D1–D3 的修复
|
||||||
121
scripts/glb-digest.js
Normal file
121
scripts/glb-digest.js
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
// Structural digest of a GLB, for the osmassets refactor parity check.
|
||||||
|
//
|
||||||
|
// Byte-comparing the GLB is too strict: Blender packs images in hash-map order
|
||||||
|
// and the buffer padding shifts with it, so two runs of identical code can
|
||||||
|
// differ. This reads the glTF JSON chunk instead and reports the parts that
|
||||||
|
// carry meaning downstream in Cesium — node/mesh/material identity and PBR
|
||||||
|
// values — plus buffer lengths as a coarse size check.
|
||||||
|
//
|
||||||
|
// node scripts/glb-digest.js <file.glb> [--out digest.json]
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
function readGlbJson(file) {
|
||||||
|
const buffer = fs.readFileSync(file);
|
||||||
|
if (buffer.length < 12 || buffer.readUInt32LE(0) !== 0x46546c67) {
|
||||||
|
throw new Error(`Not a GLB (bad magic): ${file}`);
|
||||||
|
}
|
||||||
|
const total = buffer.readUInt32LE(8);
|
||||||
|
let offset = 12;
|
||||||
|
while (offset + 8 <= Math.min(total, buffer.length)) {
|
||||||
|
const chunkLength = buffer.readUInt32LE(offset);
|
||||||
|
const chunkType = buffer.readUInt32LE(offset + 4);
|
||||||
|
const start = offset + 8;
|
||||||
|
if (chunkType === 0x4e4f534a) {
|
||||||
|
return JSON.parse(buffer.slice(start, start + chunkLength).toString("utf8"));
|
||||||
|
}
|
||||||
|
offset = start + chunkLength;
|
||||||
|
}
|
||||||
|
throw new Error(`No JSON chunk found in ${file}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function round(value) {
|
||||||
|
if (typeof value === "number") return Number(value.toFixed(6));
|
||||||
|
if (Array.isArray(value)) return value.map(round);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function materialDigest(material) {
|
||||||
|
const pbr = material.pbrMetallicRoughness || {};
|
||||||
|
return {
|
||||||
|
name: material.name || null,
|
||||||
|
baseColorFactor: round(pbr.baseColorFactor || null),
|
||||||
|
metallicFactor: round(pbr.metallicFactor ?? null),
|
||||||
|
roughnessFactor: round(pbr.roughnessFactor ?? null),
|
||||||
|
hasBaseColorTexture: Boolean(pbr.baseColorTexture),
|
||||||
|
hasNormalTexture: Boolean(material.normalTexture),
|
||||||
|
emissiveFactor: round(material.emissiveFactor || null),
|
||||||
|
emissiveStrength: round(
|
||||||
|
material.extensions?.KHR_materials_emissive_strength?.emissiveStrength ?? null,
|
||||||
|
),
|
||||||
|
alphaMode: material.alphaMode || null,
|
||||||
|
doubleSided: material.doubleSided ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function digest(file) {
|
||||||
|
const gltf = readGlbJson(file);
|
||||||
|
const meshes = (gltf.meshes || []).map((mesh) => ({
|
||||||
|
name: mesh.name || null,
|
||||||
|
primitives: (mesh.primitives || []).map((primitive) => ({
|
||||||
|
material: primitive.material ?? null,
|
||||||
|
attributes: Object.keys(primitive.attributes || {}).sort(),
|
||||||
|
// Vertex/index counts live on the accessors; they are the real geometry
|
||||||
|
// fingerprint and stay stable regardless of buffer layout.
|
||||||
|
count: gltf.accessors?.[primitive.attributes?.POSITION]?.count ?? null,
|
||||||
|
indices: gltf.accessors?.[primitive.indices]?.count ?? null,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
file: path.basename(file),
|
||||||
|
fileBytes: fs.statSync(file).size,
|
||||||
|
counts: {
|
||||||
|
nodes: (gltf.nodes || []).length,
|
||||||
|
meshes: meshes.length,
|
||||||
|
materials: (gltf.materials || []).length,
|
||||||
|
images: (gltf.images || []).length,
|
||||||
|
accessors: (gltf.accessors || []).length,
|
||||||
|
},
|
||||||
|
extensionsUsed: (gltf.extensionsUsed || []).slice().sort(),
|
||||||
|
buffers: (gltf.buffers || []).map((buffer) => buffer.byteLength),
|
||||||
|
nodes: (gltf.nodes || [])
|
||||||
|
.map((node) => ({
|
||||||
|
name: node.name || null,
|
||||||
|
mesh: node.mesh ?? null,
|
||||||
|
translation: round(node.translation || null),
|
||||||
|
rotation: round(node.rotation || null),
|
||||||
|
scale: round(node.scale || null),
|
||||||
|
extras: node.extras ?? null,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => String(a.name).localeCompare(String(b.name))),
|
||||||
|
meshes: meshes.sort((a, b) => String(a.name).localeCompare(String(b.name))),
|
||||||
|
materials: (gltf.materials || [])
|
||||||
|
.map(materialDigest)
|
||||||
|
.sort((a, b) => String(a.name).localeCompare(String(b.name))),
|
||||||
|
images: (gltf.images || [])
|
||||||
|
.map((image) => ({ name: image.name || null, mimeType: image.mimeType || null }))
|
||||||
|
.sort((a, b) => String(a.name).localeCompare(String(b.name))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const argv = process.argv.slice(2);
|
||||||
|
const file = argv.find((arg) => !arg.startsWith("--"));
|
||||||
|
if (!file) {
|
||||||
|
console.error("usage: node scripts/glb-digest.js <file.glb> [--out digest.json]");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const outIndex = argv.indexOf("--out");
|
||||||
|
const result = digest(path.resolve(file));
|
||||||
|
const text = `${JSON.stringify(result, null, 2)}\n`;
|
||||||
|
if (outIndex >= 0 && argv[outIndex + 1]) {
|
||||||
|
const out = path.resolve(argv[outIndex + 1]);
|
||||||
|
fs.mkdirSync(path.dirname(out), { recursive: true });
|
||||||
|
fs.writeFileSync(out, text);
|
||||||
|
console.log(`GLB digest: ${out}`);
|
||||||
|
} else {
|
||||||
|
process.stdout.write(text);
|
||||||
|
}
|
||||||
270
scripts/parity.js
Normal file
270
scripts/parity.js
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
// Parity harness for the osmassets refactor (docs/refactor-plan.md).
|
||||||
|
//
|
||||||
|
// node scripts/parity.js capture <label> [--areas a,b] [--stages blender,cesium]
|
||||||
|
// node scripts/parity.js compare <labelA> <labelB>
|
||||||
|
//
|
||||||
|
// capture runs the pipeline and snapshots everything that must not change:
|
||||||
|
// the stage stdout markers, a structural digest of the .blend, a structural
|
||||||
|
// digest of the .glb, and a hash of the render PNG. compare diffs two
|
||||||
|
// snapshots field by field.
|
||||||
|
//
|
||||||
|
// Snapshots live under outputs/_refactor-baseline/<label>/, which is inside
|
||||||
|
// the gitignored outputs/ tree — baselines are local scratch, not artifacts.
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const crypto = require("crypto");
|
||||||
|
const { spawnSync } = require("child_process");
|
||||||
|
|
||||||
|
const repoRoot = path.resolve(__dirname, "..");
|
||||||
|
const baselineRoot = path.join(repoRoot, "outputs", "_refactor-baseline");
|
||||||
|
const DEFAULT_AREAS = ["nantaizi-lake-innovation-valley", "hanyang-block"];
|
||||||
|
|
||||||
|
// Fields a control run (identical code, run twice) proved unstable. They are
|
||||||
|
// still recorded — a human reading a snapshot wants them — but comparing them
|
||||||
|
// would bury real regressions under noise.
|
||||||
|
//
|
||||||
|
// files.*.sha256 / .bytes for blend, glb, render
|
||||||
|
// .blend embeds absolute paths and packs images in hash-map order, so the
|
||||||
|
// file hash moves while the structural digest stays put. The EEVEE render
|
||||||
|
// is likewise not bit-reproducible.
|
||||||
|
// glbDigest.fileBytes / .buffers / .counts.accessors
|
||||||
|
// The glTF exporter deduplicates identical accessors. smart_project UVs
|
||||||
|
// carry float noise, so two runs can differ by one shared UV accessor
|
||||||
|
// (observed: 399 vs 398 accessors, 720 bytes) with identical nodes,
|
||||||
|
// meshes, primitives, materials and images.
|
||||||
|
//
|
||||||
|
// What remains compared is the real contract: the SCENE_DONE / CESIUM markers,
|
||||||
|
// the full .blend structural digest (objects, meshes, materials, custom
|
||||||
|
// properties), and the GLB node/mesh/material/image structure.
|
||||||
|
const IGNORED_PATHS = new Set([
|
||||||
|
"capturedAt",
|
||||||
|
"durationMs",
|
||||||
|
"label",
|
||||||
|
"files.blend.sha256",
|
||||||
|
"files.glb.sha256",
|
||||||
|
"files.glb.bytes",
|
||||||
|
"files.render.sha256",
|
||||||
|
"files.render.bytes",
|
||||||
|
"glbDigest.fileBytes",
|
||||||
|
"glbDigest.buffers",
|
||||||
|
"glbDigest.counts.accessors",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const [command, ...rest] = process.argv.slice(2);
|
||||||
|
if (command === "capture") return capture(rest);
|
||||||
|
if (command === "compare") return compare(rest);
|
||||||
|
console.error("usage: parity.js capture <label> [--areas a,b] [--stages s]");
|
||||||
|
console.error(" parity.js compare <labelA> <labelB>");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFlags(argv) {
|
||||||
|
const flags = {};
|
||||||
|
const positional = [];
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
if (argv[i].startsWith("--") && i + 1 < argv.length) {
|
||||||
|
flags[argv[i].slice(2)] = argv[i + 1];
|
||||||
|
i += 1;
|
||||||
|
} else {
|
||||||
|
positional.push(argv[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { flags, positional };
|
||||||
|
}
|
||||||
|
|
||||||
|
function capture(argv) {
|
||||||
|
const { flags, positional } = parseFlags(argv);
|
||||||
|
const label = positional[0];
|
||||||
|
if (!label) throw new Error("capture needs a label");
|
||||||
|
const areas = (flags.areas ? flags.areas.split(",") : DEFAULT_AREAS)
|
||||||
|
.map((a) => a.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const stages = flags.stages || "blender,cesium";
|
||||||
|
|
||||||
|
for (const area of areas) {
|
||||||
|
const configPath = path.join(repoRoot, "config", "areas", `${area}.json`);
|
||||||
|
if (!fs.existsSync(configPath)) throw new Error(`No config for area: ${area}`);
|
||||||
|
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||||
|
const areaDir = path.join(repoRoot, "outputs", area);
|
||||||
|
const stem = area;
|
||||||
|
const outDir = path.join(baselineRoot, label, area);
|
||||||
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
|
||||||
|
console.log(`\n=== parity capture [${label}] ${area} (stages: ${stages}) ===`);
|
||||||
|
const started = Date.now();
|
||||||
|
const run = spawnSync(process.execPath, [
|
||||||
|
path.join(repoRoot, "scripts", "build-area.js"),
|
||||||
|
"--config", configPath,
|
||||||
|
"--stages", stages,
|
||||||
|
], { cwd: repoRoot, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
||||||
|
const stdout = `${run.stdout || ""}`;
|
||||||
|
const stderr = `${run.stderr || ""}`;
|
||||||
|
process.stdout.write(stdout);
|
||||||
|
if (run.status !== 0) {
|
||||||
|
process.stderr.write(stderr);
|
||||||
|
throw new Error(`build-area failed for ${area} (exit ${run.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = {
|
||||||
|
label,
|
||||||
|
area,
|
||||||
|
stages,
|
||||||
|
capturedAt: new Date().toISOString(),
|
||||||
|
durationMs: Date.now() - started,
|
||||||
|
markers: {
|
||||||
|
scene: parseMarker(stdout, "SCENE_DONE"),
|
||||||
|
cesium: parseMarker(stdout, "CESIUM_EXPORT_DONE"),
|
||||||
|
},
|
||||||
|
files: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const blend = path.join(areaDir, `${stem}.blend`);
|
||||||
|
if (fs.existsSync(blend)) {
|
||||||
|
snapshot.files.blend = fileStat(blend);
|
||||||
|
snapshot.blendDigest = blendDigest(config, blend, path.join(outDir, "blend-digest.json"));
|
||||||
|
}
|
||||||
|
const glb = path.join(areaDir, `${stem}.glb`);
|
||||||
|
if (fs.existsSync(glb)) {
|
||||||
|
snapshot.files.glb = fileStat(glb);
|
||||||
|
snapshot.glbDigest = glbDigest(glb, path.join(outDir, "glb-digest.json"));
|
||||||
|
}
|
||||||
|
for (const [key, file] of [
|
||||||
|
["render", path.join(areaDir, `${stem}.png`)],
|
||||||
|
["metadata", path.join(areaDir, `${stem}.json`)],
|
||||||
|
]) {
|
||||||
|
if (fs.existsSync(file)) snapshot.files[key] = fileStat(file);
|
||||||
|
}
|
||||||
|
if (fs.existsSync(path.join(areaDir, `${stem}.json`))) {
|
||||||
|
snapshot.metadata = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(areaDir, `${stem}.json`), "utf8"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJson(path.join(outDir, "snapshot.json"), snapshot);
|
||||||
|
console.log(`Snapshot: ${path.join(outDir, "snapshot.json")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMarker(stdout, marker) {
|
||||||
|
const line = stdout.split("\n").find((l) => l.startsWith(`${marker} `));
|
||||||
|
if (!line) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(line.slice(marker.length + 1));
|
||||||
|
} catch (error) {
|
||||||
|
return { unparsed: line };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileStat(file) {
|
||||||
|
const buffer = fs.readFileSync(file);
|
||||||
|
return {
|
||||||
|
bytes: buffer.length,
|
||||||
|
sha256: crypto.createHash("sha256").update(buffer).digest("hex"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function blendDigest(config, blend, outFile) {
|
||||||
|
const blenderApp = config.blenderApp || "/Applications/Blender.app";
|
||||||
|
const blender = path.join(blenderApp, "Contents", "MacOS", "Blender");
|
||||||
|
const run = spawnSync(blender, [
|
||||||
|
"--background", "--factory-startup",
|
||||||
|
"--python", path.join(repoRoot, "blender", "tools", "scene_digest.py"),
|
||||||
|
"--", "--blend", blend, "--out", outFile,
|
||||||
|
], { cwd: repoRoot, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
||||||
|
if (run.status !== 0) {
|
||||||
|
process.stderr.write(`${run.stdout || ""}${run.stderr || ""}`);
|
||||||
|
throw new Error(`scene_digest failed for ${blend}`);
|
||||||
|
}
|
||||||
|
return JSON.parse(fs.readFileSync(outFile, "utf8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function glbDigest(glb, outFile) {
|
||||||
|
const run = spawnSync(process.execPath, [
|
||||||
|
path.join(repoRoot, "scripts", "glb-digest.js"), glb, "--out", outFile,
|
||||||
|
], { cwd: repoRoot, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
||||||
|
if (run.status !== 0) {
|
||||||
|
process.stderr.write(`${run.stdout || ""}${run.stderr || ""}`);
|
||||||
|
throw new Error(`glb-digest failed for ${glb}`);
|
||||||
|
}
|
||||||
|
return JSON.parse(fs.readFileSync(outFile, "utf8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeJson(file, value) {
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compare(argv) {
|
||||||
|
const [a, b] = argv;
|
||||||
|
if (!a || !b) throw new Error("compare needs two labels");
|
||||||
|
const areas = fs.readdirSync(path.join(baselineRoot, a))
|
||||||
|
.filter((entry) => fs.existsSync(path.join(baselineRoot, a, entry, "snapshot.json")));
|
||||||
|
|
||||||
|
let differences = 0;
|
||||||
|
for (const area of areas) {
|
||||||
|
const left = readSnapshot(a, area);
|
||||||
|
const right = readSnapshot(b, area);
|
||||||
|
if (!right) {
|
||||||
|
console.log(`\n[${area}] missing in ${b} — skipped`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const diffs = [];
|
||||||
|
diffValues("", left, right, diffs);
|
||||||
|
console.log(`\n=== ${area}: ${a} vs ${b} ===`);
|
||||||
|
if (!diffs.length) {
|
||||||
|
console.log("identical");
|
||||||
|
} else {
|
||||||
|
differences += diffs.length;
|
||||||
|
for (const line of diffs.slice(0, 200)) console.log(line);
|
||||||
|
if (diffs.length > 200) console.log(`… ${diffs.length - 200} more`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`\n${differences === 0 ? "PARITY OK" : `PARITY DIFF (${differences})`}`);
|
||||||
|
process.exitCode = differences === 0 ? 0 : 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSnapshot(label, area) {
|
||||||
|
const file = path.join(baselineRoot, label, area, "snapshot.json");
|
||||||
|
return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function diffValues(pathKey, left, right, out) {
|
||||||
|
if (IGNORED_PATHS.has(pathKey)) return;
|
||||||
|
if (left === right) return;
|
||||||
|
const bothObjects = left && right && typeof left === "object" && typeof right === "object";
|
||||||
|
if (!bothObjects) {
|
||||||
|
out.push(` ${pathKey || "<root>"}: ${format(left)} -> ${format(right)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(left) !== Array.isArray(right)) {
|
||||||
|
out.push(` ${pathKey}: array/object mismatch`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(left)) {
|
||||||
|
if (left.length !== right.length) {
|
||||||
|
out.push(` ${pathKey}.length: ${left.length} -> ${right.length}`);
|
||||||
|
}
|
||||||
|
const limit = Math.min(left.length, right.length);
|
||||||
|
for (let i = 0; i < limit; i += 1) {
|
||||||
|
diffValues(`${pathKey}[${i}]`, left[i], right[i], out);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
|
||||||
|
for (const key of [...keys].sort()) {
|
||||||
|
diffValues(pathKey ? `${pathKey}.${key}` : key, left[key], right[key], out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function format(value) {
|
||||||
|
if (value === undefined) return "<missing>";
|
||||||
|
const text = JSON.stringify(value);
|
||||||
|
return text && text.length > 120 ? `${text.slice(0, 117)}…` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
Reference in New Issue
Block a user