Advance M8-M11 parity workflows
This commit is contained in:
369
tools/web/generate-geometry-node-evaluator-golden.py
Normal file
369
tools/web/generate-geometry-node-evaluator-golden.py
Normal file
@@ -0,0 +1,369 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user