import json import hashlib import math import os import sys import bpy ALLOWLIST = [ "NodeGroupInput", "NodeGroupOutput", "GeometryNodeTransform", "GeometryNodeSetPosition", "GeometryNodeJoinGeometry", "GeometryNodeSeparateGeometry", "GeometryNodeRealizeInstances", "GeometryNodeStoreNamedAttribute", "FunctionNodeInputInt", "FunctionNodeInputVector", "FunctionNodeCompare", "ShaderNodeValue", "ShaderNodeMath", "GeometryNodeObjectInfo", "GeometryNodeCollectionInfo", "GeometryNodeImageInfo", ] def reset_scene(): bpy.ops.wm.read_factory_settings(use_empty=True) scene = bpy.context.scene scene.frame_start = 1 scene.frame_end = 24 scene.frame_set(1) return scene def mesh_object(name, vertices, faces, location=(0.0, 0.0, 0.0), collection=None): mesh = bpy.data.meshes.new(f"{name}Mesh") mesh.from_pydata(vertices, [], faces) mesh.update() obj = bpy.data.objects.new(name, mesh) (collection or bpy.context.collection).objects.link(obj) obj.location = location return obj def cube_object(name, location=(0.0, 0.0, 0.0), collection=None): return mesh_object( name, [ (-1, -1, -1), (1, -1, -1), (1, 1, -1), (-1, 1, -1), (-1, -1, 1), (1, -1, 1), (1, 1, 1), (-1, 1, 1), ], [ (0, 1, 2, 3), (4, 7, 6, 5), (0, 4, 5, 1), (1, 5, 6, 2), (2, 6, 7, 3), (4, 0, 3, 7), ], location, collection, ) def tetra_object(name, location=(0.0, 0.0, 0.0), collection=None): return mesh_object( name, [(0, 0, 1.5), (-1, -1, 0), (1, -1, 0), (0, 1, 0)], [(0, 1, 2), (0, 2, 3), (0, 3, 1), (1, 3, 2)], location, collection, ) def geometry_group(name): group = bpy.data.node_groups.new(name, "GeometryNodeTree") group.interface.new_socket(name="Geometry", in_out="INPUT", socket_type="NodeSocketGeometry") group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") group_input = group.nodes.new("NodeGroupInput") group_output = group.nodes.new("NodeGroupOutput") group_input.name = "Group Input" group_output.name = "Group Output" return group, group_input, group_output def add_case(name, x, configure): obj = cube_object(name, (x, 0, 0)) group, group_input, group_output = geometry_group(f"{name}Graph") configure(group, group_input, group_output) modifier = obj.modifiers.new(f"{name} Nodes", "NODES") modifier.node_group = group return obj, group def link_geometry(group, source, target): group.links.new(source.outputs["Geometry"], target.inputs["Geometry"]) def build_fixture(blend_path): scene = reset_scene() cases = [] def passthrough(group, group_input, group_output): group.links.new(group_input.outputs["Geometry"], group_output.inputs["Geometry"]) cases.append(add_case("M10GN_Passthrough", 0, passthrough)) def transform_case(group, group_input, group_output): node = group.nodes.new("GeometryNodeTransform") node.name = "Transform" node.inputs["Translation"].default_value = (0.25, -0.5, 1.0) node.inputs["Rotation"].default_value = (0.0, 0.0, math.radians(15.0)) node.inputs["Scale"].default_value = (1.25, 0.75, 1.5) link_geometry(group, group_input, node) group.links.new(node.outputs["Geometry"], group_output.inputs["Geometry"]) cases.append(add_case("M10GN_Transform", 4, transform_case)) def set_position_case(group, group_input, group_output): node = group.nodes.new("GeometryNodeSetPosition") node.name = "Set Position" node.inputs["Offset"].default_value = (0.5, 0.25, -0.75) link_geometry(group, group_input, node) group.links.new(node.outputs["Geometry"], group_output.inputs["Geometry"]) cases.append(add_case("M10GN_SetPosition", 8, set_position_case)) def join_case(group, group_input, group_output): node = group.nodes.new("GeometryNodeJoinGeometry") node.name = "Join Geometry" translated = group.nodes.new("GeometryNodeTransform") translated.name = "Join Branch Transform" translated.inputs["Translation"].default_value = (3.0, 0.0, 0.0) group.links.new(group_input.outputs["Geometry"], node.inputs["Geometry"]) group.links.new(group_input.outputs["Geometry"], translated.inputs["Geometry"]) group.links.new(translated.outputs["Geometry"], node.inputs["Geometry"]) group.links.new(node.outputs["Geometry"], group_output.inputs["Geometry"]) cases.append(add_case("M10GN_Join", 12, join_case)) def separate_case(group, group_input, group_output): separate = group.nodes.new("GeometryNodeSeparateGeometry") separate.name = "Separate Geometry" separate.domain = "POINT" integer_a = group.nodes.new("FunctionNodeInputInt") integer_a.name = "Integer A" integer_a.integer = 2 integer_b = group.nodes.new("FunctionNodeInputInt") integer_b.name = "Integer B" integer_b.integer = 1 compare = group.nodes.new("FunctionNodeCompare") compare.name = "Compare" compare.data_type = "INT" compare.operation = "GREATER_THAN" link_geometry(group, group_input, separate) group.links.new(integer_a.outputs["Integer"], compare.inputs["A"]) group.links.new(integer_b.outputs["Integer"], compare.inputs["B"]) group.links.new(compare.outputs["Result"], separate.inputs["Selection"]) group.links.new(separate.outputs["Selection"], group_output.inputs["Geometry"]) cases.append(add_case("M10GN_SeparateIntCompare", 16, separate_case)) external_collection = bpy.data.collections.new("M10GN_ExternalCollection") scene.collection.children.link(external_collection) tetra_object("M10GN_CollectionTetra", (-1.5, 0, 0), external_collection) tetra_object("M10GN_CollectionTetraOffset", (1.5, 0, 0), external_collection) def collection_case(group, _group_input, group_output): collection_info = group.nodes.new("GeometryNodeCollectionInfo") collection_info.name = "Collection Info" collection_info.inputs["Collection"].default_value = external_collection collection_info.inputs["Separate Children"].default_value = True realize = group.nodes.new("GeometryNodeRealizeInstances") realize.name = "Realize Instances" group.links.new(collection_info.outputs["Instances"], realize.inputs["Geometry"]) group.links.new(realize.outputs["Geometry"], group_output.inputs["Geometry"]) cases.append(add_case("M10GN_CollectionRealize", 20, collection_case)) def store_attribute_case(group, group_input, group_output): store = group.nodes.new("GeometryNodeStoreNamedAttribute") store.name = "Store Named Attribute" store.data_type = "FLOAT" store.domain = "POINT" store.inputs["Name"].default_value = "m10_value" store.inputs["Value"].default_value = 0.375 link_geometry(group, group_input, store) group.links.new(store.outputs["Geometry"], group_output.inputs["Geometry"]) cases.append(add_case("M10GN_StoreAttribute", 24, store_attribute_case)) def vector_case(group, group_input, group_output): vector = group.nodes.new("FunctionNodeInputVector") vector.name = "Vector" vector.vector = (0.125, 0.5, 1.25) set_position = group.nodes.new("GeometryNodeSetPosition") set_position.name = "Set Position" link_geometry(group, group_input, set_position) group.links.new(vector.outputs["Vector"], set_position.inputs["Offset"]) group.links.new(set_position.outputs["Geometry"], group_output.inputs["Geometry"]) cases.append(add_case("M10GN_InputVector", 28, vector_case)) def value_math_case(group, group_input, group_output): first = group.nodes.new("ShaderNodeValue") first.name = "Value A" first.outputs["Value"].default_value = 0.25 second = group.nodes.new("ShaderNodeValue") second.name = "Value B" second.outputs["Value"].default_value = 0.5 math_node = group.nodes.new("ShaderNodeMath") math_node.name = "Math Add" math_node.operation = "ADD" compare = group.nodes.new("FunctionNodeCompare") compare.name = "Compare" compare.data_type = "FLOAT" compare.operation = "GREATER_THAN" compare.inputs["B"].default_value = 0.5 set_position = group.nodes.new("GeometryNodeSetPosition") set_position.name = "Set Position" set_position.inputs["Offset"].default_value = (0.0, 0.0, 0.625) link_geometry(group, group_input, set_position) group.links.new(first.outputs["Value"], math_node.inputs[0]) group.links.new(second.outputs["Value"], math_node.inputs[1]) group.links.new(math_node.outputs["Value"], compare.inputs["A"]) group.links.new(compare.outputs["Result"], set_position.inputs["Selection"]) group.links.new(set_position.outputs["Geometry"], group_output.inputs["Geometry"]) cases.append(add_case("M10GN_ValueMath", 32, value_math_case)) object_target = tetra_object("M10GN_ObjectInfoTarget", (0.5, 0.25, 1.5)) def object_info_case(group, group_input, group_output): object_info = group.nodes.new("GeometryNodeObjectInfo") object_info.name = "Object Info" object_info.inputs["Object"].default_value = object_target set_position = group.nodes.new("GeometryNodeSetPosition") set_position.name = "Set Position" link_geometry(group, group_input, set_position) group.links.new(object_info.outputs["Location"], set_position.inputs["Offset"]) group.links.new(set_position.outputs["Geometry"], group_output.inputs["Geometry"]) cases.append(add_case("M10GN_ObjectInfo", 36, object_info_case)) image = bpy.data.images.new("M10GN_Image", width=4, height=2, alpha=True) def image_info_case(group, group_input, group_output): image_info = group.nodes.new("GeometryNodeImageInfo") image_info.name = "Image Info" image_info.inputs["Image"].default_value = image compare = group.nodes.new("FunctionNodeCompare") compare.name = "Compare" compare.data_type = "INT" compare.operation = "GREATER_THAN" compare.inputs["B"].default_value = 3 set_position = group.nodes.new("GeometryNodeSetPosition") set_position.name = "Set Position" set_position.inputs["Offset"].default_value = (0.0, -0.75, 0.8) link_geometry(group, group_input, set_position) group.links.new(image_info.outputs["Width"], compare.inputs["A"]) group.links.new(compare.outputs["Result"], set_position.inputs["Selection"]) group.links.new(set_position.outputs["Geometry"], group_output.inputs["Geometry"]) cases.append(add_case("M10GN_ImageInfo", 40, image_info_case)) bpy.ops.wm.save_as_mainfile(filepath=blend_path, compress=True) return [obj.name for obj, _group in cases] def evaluated_mesh_record(obj, depsgraph): evaluated = obj.evaluated_get(depsgraph) mesh = evaluated.to_mesh(preserve_all_data_layers=True, depsgraph=depsgraph) try: mesh.calc_loop_triangles() positions = [component for vertex in mesh.vertices for component in vertex.co] record = { "object": obj.name, "vertexCount": len(mesh.vertices), "triangleCount": len(mesh.loop_triangles), "positions": positions, "indices": [index for triangle in mesh.loop_triangles for index in triangle.vertices], "bounds": { "min": [min(positions[axis::3]) for axis in range(3)] if positions else [0, 0, 0], "max": [max(positions[axis::3]) for axis in range(3)] if positions else [0, 0, 0], }, "attributes": {}, } attribute = mesh.attributes.get("m10_value") if attribute is not None: record["attributes"]["m10_value"] = { "domain": attribute.domain, "dataType": attribute.data_type, "values": [item.value for item in attribute.data], } return record finally: evaluated.to_mesh_clear() def write_golden(blend_path, golden_path, case_names): depsgraph = bpy.context.evaluated_depsgraph_get() node_coverage = {} cases = [] for case_name in case_names: obj = bpy.data.objects[case_name] group = obj.modifiers[0].node_group node_types = [node.bl_idname for node in group.nodes] cases.append({ "name": case_name, "graph": group.name, "nodeTypes": node_types, "mesh": evaluated_mesh_record(obj, depsgraph), }) for node_type in node_types: node_coverage.setdefault(node_type, []).append(case_name) missing = sorted(set(ALLOWLIST) - set(node_coverage)) unexpected = sorted(set(node_coverage) - set(ALLOWLIST)) if missing or unexpected: raise RuntimeError(f"allowlist coverage mismatch missing={missing} unexpected={unexpected}") output = { "schemaVersion": 1, "blenderVersion": bpy.app.version_string, "fixture": os.path.relpath( blend_path, os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(golden_path)))), ), "fixtureSha256": hashlib.sha256(open(blend_path, "rb").read()).hexdigest(), "allowlist": ALLOWLIST, "nodeCoverage": node_coverage, "tolerance": { "maxPositionError": 1e-5, "rmsPositionError": 1e-6, "maxAttributeError": 1e-6, "boundsError": 1e-5, }, "cases": cases, } os.makedirs(os.path.dirname(golden_path), exist_ok=True) with open(golden_path, "w", encoding="utf-8") as handle: json.dump(output, handle, indent=2, sort_keys=True) handle.write("\n") def main(): if "--" not in sys.argv or len(sys.argv[sys.argv.index("--") + 1:]) != 2: raise SystemExit( "usage: blender -b --python generate-geometry-node-evaluator-golden.py -- fixture.blend golden.json" ) blend_path, golden_path = [os.path.abspath(path) for path in sys.argv[sys.argv.index("--") + 1:]] os.makedirs(os.path.dirname(blend_path), exist_ok=True) case_names = build_fixture(blend_path) write_golden(blend_path, golden_path, case_names) print(f"geometry-node-evaluator-generated fixture={blend_path} golden={golden_path} cases={len(case_names)}") if __name__ == "__main__": main()