refactor: 抽 osmassets 包,要素注册表,材质单一定义源 (P0-P3)

generate_scene.py 从 1271 → 816 行 (-455)

P0: 纯函数搬家
- osmassets/osm.py: parse_osm / Projector / parse_height
- osmassets/geom.py: clip_polygon / point_in_polygon / distance_to_ring / sample_tree_row 等
- blender/tests/test_pure.py: 42 个 unittest (脱离 bpy 运行)

P1: 单一定义源
- osmassets/catalog.py: ROAD_LAYERS + MATERIALS (含 cesium 导出参数)
- 对接 osm2streets_scene_style.json 做图层一致性 warning
- 干掉 road_mats / layer_z / 材质参数三份副本

P2: 要素注册表
- osmassets/{water,grass,scrub}.py: 每个要素一个 assemble() 函数
- build() 中的 if/elif 链收缩为注册表调用
- 计数器集中到 counts 字典

P3: 材质契约化
- catalog.py 扩展 CESIUM_EXPORT 段 (tint/metallic/emission)
- 标记已发现的死条目 Office White Metal Facade (四表各一组)

校验:
- parity.js + scene_digest.py + glb-digest.js 三位一体
- control-1 vs p0/p1/p2a/p2b/p3-counts: 两区域全 PARITY OK
- 42 个纯 Python 测试全部通过

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-29 17:59:32 +08:00
parent 23ae63bc2a
commit 24e02e2041
16 changed files with 1924 additions and 598 deletions

View File

@@ -0,0 +1,171 @@
"""Dump a stable structural digest of a .blend built by generate_scene.py.
Run inside Blender:
Blender --background --factory-startup \
--python blender/tools/scene_digest.py -- \
--blend /path/to/scene.blend --out /path/to/digest.json
The digest is the parity contract for the osmassets refactor: it must stay
byte-identical across a pure restructuring. Fields that a control run (same
code, run twice) proves unstable belong in UNSTABLE_* below rather than in the
digest, otherwise the check is noise and gets ignored.
Floats are rounded to 6 decimals: Blender round-trips them through single
precision, so the last digits of a repr are not a meaningful signal.
"""
import json
import os
import sys
import bpy
# Object-level custom properties Blender adds on its own; not ours to compare.
IGNORED_PROP_KEYS = {"_RNA_UI", "cycles"}
def cli_args():
values = {"blend": None, "out": None}
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
i = 0
while i < len(argv):
if argv[i].startswith("--") and i + 1 < len(argv):
values[argv[i][2:]] = argv[i + 1]
i += 2
else:
i += 1
for key in ("blend", "out"):
if not values.get(key):
raise RuntimeError("--%s is required" % key)
return values
def rounded(value):
"""Normalise Blender's float/vector/array soup into plain JSON."""
if isinstance(value, float):
return round(value, 6)
if isinstance(value, (int, str, bool)) or value is None:
return value
if hasattr(value, "__len__") and not isinstance(value, (str, bytes)):
return [rounded(item) for item in value]
return str(value)
def custom_props(datablock):
out = {}
for key in sorted(datablock.keys()):
if key in IGNORED_PROP_KEYS:
continue
try:
out[key] = rounded(datablock[key])
except (TypeError, ValueError):
out[key] = "<unreadable>"
return out
def material_digest(material):
node_types = {}
if material.use_nodes and material.node_tree:
for node in material.node_tree.nodes:
node_types[node.type] = node_types.get(node.type, 0) + 1
entry = {
"name": material.name,
"diffuse_color": rounded(material.diffuse_color),
"use_nodes": material.use_nodes,
# Node identity is unstable (Blender names them Mix.001, Mix.002 …
# depending on creation order across datablocks), so compare the
# type histogram and the link count instead of the graph itself.
"node_types": dict(sorted(node_types.items())),
"link_count": (len(material.node_tree.links)
if material.use_nodes and material.node_tree else 0),
"props": custom_props(material),
}
if material.use_nodes and material.node_tree:
for node in material.node_tree.nodes:
if node.type != "BSDF_PRINCIPLED":
continue
for socket in ("Base Color", "Roughness", "Metallic"):
if socket in node.inputs:
entry["bsdf_" + socket.replace(" ", "_").lower()] = rounded(
node.inputs[socket].default_value)
break
return entry
def object_digest(obj):
entry = {
"name": obj.name,
"type": obj.type,
"collections": sorted(c.name for c in obj.users_collection),
"location": rounded(obj.location),
"rotation_euler": rounded(obj.rotation_euler),
"scale": rounded(obj.scale),
"data": obj.data.name if obj.data else None,
"materials": [slot.material.name if slot.material else None
for slot in obj.material_slots],
"modifiers": [(m.name, m.type) for m in obj.modifiers],
"props": custom_props(obj),
}
if obj.type == "MESH":
mesh = obj.data
entry["vertices"] = len(mesh.vertices)
entry["polygons"] = len(mesh.polygons)
entry["uv_layers"] = [layer.name for layer in mesh.uv_layers]
entry["smooth_polygons"] = sum(1 for p in mesh.polygons if p.use_smooth)
# Bounding box catches geometry that moved without changing topology;
# a vertex-by-vertex hash would be exact but too brittle to act on.
entry["bound_box"] = [rounded(corner) for corner in obj.bound_box]
elif obj.type == "CURVE":
entry["splines"] = len(obj.data.splines)
entry["points"] = sum(len(s.points) for s in obj.data.splines)
entry["bevel_depth"] = rounded(obj.data.bevel_depth)
elif obj.type == "LIGHT":
entry["light_type"] = obj.data.type
entry["energy"] = rounded(obj.data.energy)
elif obj.type == "CAMERA":
entry["lens"] = rounded(obj.data.lens)
entry["clip"] = [rounded(obj.data.clip_start), rounded(obj.data.clip_end)]
return entry
def digest(blend_path):
bpy.ops.wm.open_mainfile(filepath=blend_path)
scene = bpy.context.scene
return {
"scene": {
"name": scene.name,
"engine": scene.render.engine,
"resolution": [scene.render.resolution_x, scene.render.resolution_y],
"world_color": rounded(scene.world.color) if scene.world else None,
"camera": scene.camera.name if scene.camera else None,
"props": custom_props(scene),
},
"collections": sorted(c.name for c in bpy.data.collections),
"counts": {
"objects": len(bpy.data.objects),
"meshes": len(bpy.data.meshes),
"materials": len(bpy.data.materials),
"images": len(bpy.data.images),
},
"objects": [object_digest(obj)
for obj in sorted(bpy.data.objects, key=lambda o: o.name)],
"materials": [material_digest(mat)
for mat in sorted(bpy.data.materials, key=lambda m: m.name)],
"images": sorted(image.name for image in bpy.data.images),
}
if __name__ == "__main__":
args = cli_args()
result = digest(args["blend"])
os.makedirs(os.path.dirname(os.path.abspath(args["out"])), exist_ok=True)
with open(args["out"], "w", encoding="utf-8") as handle:
json.dump(result, handle, ensure_ascii=False, indent=2, sort_keys=True)
handle.write("\n")
print("DIGEST_DONE", json.dumps({
"blend": args["blend"], "out": args["out"],
"objects": len(result["objects"]),
"materials": len(result["materials"]),
}))