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>
172 lines
6.4 KiB
Python
172 lines
6.4 KiB
Python
"""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"]),
|
|
}))
|