import hashlib import json import pathlib import struct import sys import bpy ROOT_OBJECT = "M12 Append Object" MESH_NAME = "M12 Append Mesh" MATERIAL_NAME = "M12 Append Material" IMAGE_NAME = "M12 Append Image" IMAGE_NODE_NAME = "M12 Append Image Node" def sha256_file(path: pathlib.Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() def pixel_sha256(image: bpy.types.Image) -> str: values = list(image.pixels) return hashlib.sha256(struct.pack(f"<{len(values)}f", *values)).hexdigest() def reset() -> None: bpy.ops.wm.read_factory_settings(use_empty=True) def create_source(path: pathlib.Path) -> None: reset() image = bpy.data.images.new(IMAGE_NAME, width=2, height=2, alpha=True, float_buffer=False) image.colorspace_settings.name = "sRGB" image.pixels = [ 1.0, 0.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.5, ] image.pack() material = bpy.data.materials.new(MATERIAL_NAME) material.use_nodes = True node_tree = material.node_tree principled = node_tree.nodes.get("Principled BSDF") image_node = node_tree.nodes.new("ShaderNodeTexImage") image_node.name = IMAGE_NODE_NAME image_node.label = IMAGE_NODE_NAME image_node.image = image image_node.interpolation = "Closest" image_node.extension = "REPEAT" node_tree.links.new(image_node.outputs["Color"], principled.inputs["Base Color"]) mesh = bpy.data.meshes.new(MESH_NAME) mesh.from_pydata( [(-1.0, -1.0, 0.0), (1.0, -1.0, 0.0), (1.0, 1.0, 0.0), (-1.0, 1.0, 0.0)], [], [(0, 1, 2, 3)], ) mesh.materials.append(material) uv_layer = mesh.uv_layers.new(name="UVMap") for loop, uv in zip(uv_layer.data, [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]): loop.uv = uv mesh.update() obj = bpy.data.objects.new(ROOT_OBJECT, mesh) obj["m12_source_marker"] = "M12-03C" bpy.context.scene.collection.objects.link(obj) bpy.ops.wm.save_as_mainfile(filepath=str(path), check_existing=False) def data_block(id_type: str, value: object) -> dict: return { "idType": id_type, "name": value.name, "nameFull": value.name_full, "library": None if value.library is None else value.library.filepath, "isLibraryOverride": value.override_library is not None, } def inspect_local_graph() -> dict: obj = bpy.data.objects[ROOT_OBJECT] mesh = obj.data material = mesh.materials[0] image_node = material.node_tree.nodes[IMAGE_NODE_NAME] image = image_node.image ids = { "OBJECT": data_block("OBJECT", obj), "MESH": data_block("MESH", mesh), "MATERIAL": data_block("MATERIAL", material), "IMAGE": data_block("IMAGE", image), } return { "root": ids["OBJECT"], "ids": ids, "edges": [ {"from": "Object/M12 Append Object", "relation": "OBJECT_DATA", "to": "Mesh/M12 Append Mesh"}, {"from": "Mesh/M12 Append Mesh", "relation": "MATERIAL_SLOT[0]", "to": "Material/M12 Append Material"}, {"from": "Material/M12 Append Material", "relation": f"NODE_IMAGE[{IMAGE_NODE_NAME}]", "to": "Image/M12 Append Image"}, ], "geometry": { "vertices": len(mesh.vertices), "edges": len(mesh.edges), "polygons": len(mesh.polygons), "loops": len(mesh.loops), "uvLayers": [layer.name for layer in mesh.uv_layers], "materialSlots": [item.name for item in mesh.materials], }, "image": { "size": list(image.size), "channels": image.channels, "colorspace": image.colorspace_settings.name, "packed": image.packed_file is not None, "pixelFloat32Sha256": pixel_sha256(image), }, "sourceMarker": obj["m12_source_marker"], } def append_object(source: pathlib.Path, target: pathlib.Path) -> dict: reset() with bpy.data.libraries.load(str(source), link=False) as (data_from, data_to): if ROOT_OBJECT not in data_from.objects: raise RuntimeError("source root object is missing") data_to.objects = [ROOT_OBJECT] if len(data_to.objects) != 1 or data_to.objects[0] is None: raise RuntimeError("desktop append did not return one object") bpy.context.scene.collection.objects.link(data_to.objects[0]) before_save = inspect_local_graph() if any(item["library"] is not None or item["isLibraryOverride"] for item in before_save["ids"].values()): raise RuntimeError("appended dependency closure is not fully local") bpy.ops.wm.save_as_mainfile(filepath=str(target), check_existing=False) bpy.ops.wm.open_mainfile(filepath=str(target), load_ui=False) reopened = inspect_local_graph() if reopened != before_save: raise RuntimeError("appended dependency mapping drifted after save/reopen") return reopened def main() -> None: if "--" not in sys.argv or len(sys.argv[sys.argv.index("--") + 1 :]) != 2: raise SystemExit("usage: blender --background --factory-startup --python generate-library-append-fixture.py -- OUTPUT_DIR REPORT") output_arg, report_arg = sys.argv[sys.argv.index("--") + 1 :] output_dir = pathlib.Path(output_arg).resolve() report_path = pathlib.Path(report_arg).resolve() output_dir.mkdir(parents=True, exist_ok=True) report_path.parent.mkdir(parents=True, exist_ok=True) source = output_dir / "m12_append_source.blend" target = output_dir / "m12_append_target.blend" create_source(source) source_graph = inspect_local_graph() appended_graph = append_object(source, target) report = { "schemaVersion": 1, "task": "M12-03C", "operation": "APPEND", "blenderVersion": "5.2.0", "source": {"file": source.name, "sha256": sha256_file(source)}, "target": {"file": target.name, "sha256": sha256_file(target)}, "selectedRoots": ["Object/M12 Append Object"], "sourceGraph": source_graph, "appendedGraph": appended_graph, "stableMapping": [ {"source": "Object/M12 Append Object", "local": "Object/M12 Append Object", "owner": "LOCAL_MAIN", "readOnly": False}, {"source": "Mesh/M12 Append Mesh", "local": "Mesh/M12 Append Mesh", "owner": "LOCAL_MAIN", "readOnly": False}, {"source": "Material/M12 Append Material", "local": "Material/M12 Append Material", "owner": "LOCAL_MAIN", "readOnly": False}, {"source": "Image/M12 Append Image", "local": "Image/M12 Append Image", "owner": "LOCAL_MAIN", "readOnly": False}, ], "nextTask": "M12-03D", } report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") print( f"library-append-fixture-ok roots={len(report['selectedRoots'])} " f"mapping={len(report['stableMapping'])} source={report['source']['sha256']} " f"target={report['target']['sha256']} next={report['nextTask']}" ) main()