370 lines
15 KiB
Python
370 lines
15 KiB
Python
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()
|