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:
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]
|
||||
Reference in New Issue
Block a user