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