Add new node asset scripts for geometry and shader operations

- Implemented `build_mesh_plane.py` to create a geometry node group for mesh planes with customizable parameters.
- Created `build_shadeless.py` for a shader node group that displays color without scene lighting while preserving shadows and reflections.
- Added `validate.py` to validate the integrity of the newly created node groups and their inputs.
- Introduced `validate_cutout.py` to ensure the functionality of cutout node groups and their dependencies.
- Developed `validate_mesh_plane.py` to test the mesh plane creation and its modifier settings.
This commit is contained in:
2026-08-30 00:17:29 +08:00
parent 7d39fe8c5e
commit 32bcd45d7b
168 changed files with 7213 additions and 13121 deletions

View File

@@ -0,0 +1,49 @@
from pathlib import Path
import sys
import bpy
PROJECT_ROOT = Path(__file__).parents[2]
sys.path.insert(0, str(Path(__file__).parent))
sys.path.insert(0, str(PROJECT_ROOT / "src"))
sys.path.insert(
0,
str(next((PROJECT_ROOT / "src" / "anyimage" / "wheels").glob("blendjob-*.whl"))),
)
from build_cutout import (
CUTOUT_NODE_GROUP_NAMES,
build_cutout_modifier_group,
build_node_group,
)
from build_mesh_plane import build_mesh_plane_group
from build_shadeless import build_shadeless_group
from anyimage.operators.common.node_graph import expand_smooth
OUTPUT_PATH = (
PROJECT_ROOT
/ "src"
/ "anyimage"
/ "assets"
/ "O_AnyImage.blend"
)
def main():
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.context.preferences.filepaths.save_version = 0
build_mesh_plane_group()
build_shadeless_group()
shape_groups = {
shape: build_node_group(shape)
for shape in CUTOUT_NODE_GROUP_NAMES
}
build_cutout_modifier_group(shape_groups, expand_smooth)
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
bpy.ops.wm.save_as_mainfile(filepath=str(OUTPUT_PATH), check_existing=False)
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,253 @@
"""Build the Mesh Plane Geometry Nodes group."""
import bpy
MESH_PLANE_GROUP_NAME = "O Mesh Plane"
def new_socket(interface, name, in_out, socket_type, parent=None):
socket = interface.new_socket(
name=name,
in_out=in_out,
socket_type=socket_type,
parent=parent,
)
if in_out == "INPUT" and hasattr(socket, "structure_type"):
socket.structure_type = "SINGLE"
return socket
def new_group_input(nodes, location, visible_names):
node = nodes.new("NodeGroupInput")
node.location = location
visible = set(visible_names)
for output in node.outputs:
output.hide = output.name not in visible
return node
def build_mesh_plane_group():
group = bpy.data.node_groups.new(MESH_PLANE_GROUP_NAME, "GeometryNodeTree")
group.is_modifier = True
interface = group.interface
geometry = new_socket(interface, "Geometry", "INPUT", "NodeSocketGeometry")
geometry.structure_type = "SINGLE"
subdivide = new_socket(interface, "Subdivide", "INPUT", "NodeSocketInt")
subdivide.default_value = 0
subdivide.min_value = 0
subdivide.max_value = 10
thickness = new_socket(interface, "Thickness", "INPUT", "NodeSocketFloat")
thickness.default_value = 0.0
thickness.min_value = 0.0
thickness.max_value = 10000.0
thickness.subtype = "DISTANCE"
data_panel = interface.new_panel(name="Data", default_closed=True)
material = new_socket(
interface,
"Material",
"INPUT",
"NodeSocketMaterial",
parent=data_panel,
)
material.hide_in_modifier = True
new_socket(interface, "Geometry", "OUTPUT", "NodeSocketGeometry")
nodes = group.nodes
links = group.links
geometry_input = new_group_input(nodes, (-1800.0, 500.0), {"Geometry"})
subdivide_input = new_group_input(nodes, (-1800.0, 0.0), {"Subdivide"})
thickness_input = new_group_input(nodes, (100.0, -250.0), {"Thickness"})
material_input = new_group_input(nodes, (900.0, 0.0), {"Material"})
output = nodes.new("NodeGroupOutput")
output.location = (1350.0, 500.0)
bounds = nodes.new("GeometryNodeBoundBox")
bounds.location = (-1600.0, 500.0)
links.new(geometry_input.outputs["Geometry"], bounds.inputs["Geometry"])
dimensions = nodes.new("ShaderNodeVectorMath")
dimensions.operation = "SUBTRACT"
dimensions.location = (-1400.0, 500.0)
links.new(bounds.outputs["Max"], dimensions.inputs[0])
links.new(bounds.outputs["Min"], dimensions.inputs[1])
separate_dimensions = nodes.new("ShaderNodeSeparateXYZ")
separate_dimensions.location = (-1200.0, 500.0)
links.new(dimensions.outputs["Vector"], separate_dimensions.inputs[0])
short_segments = nodes.new("ShaderNodeMath")
short_segments.operation = "POWER"
short_segments.inputs[0].default_value = 2.0
short_segments.location = (-1400.0, 0.0)
links.new(subdivide_input.outputs["Subdivide"], short_segments.inputs[1])
vertex_counts = []
for index, (axis, other_axis) in enumerate((("X", "Y"), ("Y", "X"))):
y = 300.0 - index * 300.0
ratio = nodes.new("ShaderNodeMath")
ratio.operation = "DIVIDE"
ratio.location = (-1000.0, y)
links.new(separate_dimensions.outputs[axis], ratio.inputs[0])
links.new(separate_dimensions.outputs[other_axis], ratio.inputs[1])
at_least_one = nodes.new("ShaderNodeMath")
at_least_one.operation = "MAXIMUM"
at_least_one.inputs[1].default_value = 1.0
at_least_one.location = (-800.0, y)
links.new(ratio.outputs[0], at_least_one.inputs[0])
scaled_segments = nodes.new("ShaderNodeMath")
scaled_segments.operation = "MULTIPLY"
scaled_segments.location = (-600.0, y)
links.new(short_segments.outputs[0], scaled_segments.inputs[0])
links.new(at_least_one.outputs[0], scaled_segments.inputs[1])
rounded_segments = nodes.new("ShaderNodeMath")
rounded_segments.operation = "ROUND"
rounded_segments.location = (-400.0, y)
links.new(scaled_segments.outputs[0], rounded_segments.inputs[0])
vertices = nodes.new("ShaderNodeMath")
vertices.operation = "ADD"
vertices.inputs[1].default_value = 1.0
vertices.location = (-200.0, y)
links.new(rounded_segments.outputs[0], vertices.inputs[0])
vertex_counts.append(vertices.outputs[0])
grid = nodes.new("GeometryNodeMeshGrid")
grid.location = (50.0, 500.0)
links.new(separate_dimensions.outputs["X"], grid.inputs["Size X"])
links.new(separate_dimensions.outputs["Y"], grid.inputs["Size Y"])
links.new(vertex_counts[0], grid.inputs["Vertices X"])
links.new(vertex_counts[1], grid.inputs["Vertices Y"])
position = nodes.new("GeometryNodeInputPosition")
position.location = (-200.0, 900.0)
separate_position = nodes.new("ShaderNodeSeparateXYZ")
separate_position.location = (0.0, 900.0)
links.new(position.outputs["Position"], separate_position.inputs[0])
uv_components = []
for index, axis in enumerate(("X", "Y")):
y = 900.0 - index * 180.0
normalize = nodes.new("ShaderNodeMath")
normalize.operation = "DIVIDE"
normalize.location = (200.0, y)
links.new(separate_position.outputs[axis], normalize.inputs[0])
links.new(separate_dimensions.outputs[axis], normalize.inputs[1])
offset_uv = nodes.new("ShaderNodeMath")
offset_uv.operation = "ADD"
offset_uv.inputs[1].default_value = 0.5
offset_uv.location = (400.0, y)
links.new(normalize.outputs[0], offset_uv.inputs[0])
uv_components.append(offset_uv.outputs[0])
uv_coordinates = nodes.new("ShaderNodeCombineXYZ")
uv_coordinates.location = (600.0, 800.0)
links.new(uv_components[0], uv_coordinates.inputs["X"])
links.new(uv_components[1], uv_coordinates.inputs["Y"])
store_grid_uv = nodes.new("GeometryNodeStoreNamedAttribute")
store_grid_uv.data_type = "FLOAT2"
store_grid_uv.domain = "CORNER"
store_grid_uv.inputs["Name"].default_value = "UVMap"
store_grid_uv.location = (250.0, 500.0)
links.new(grid.outputs["Mesh"], store_grid_uv.inputs["Geometry"])
links.new(uv_coordinates.outputs["Vector"], store_grid_uv.inputs["Value"])
center_sum = nodes.new("ShaderNodeVectorMath")
center_sum.operation = "ADD"
center_sum.location = (-200.0, 1150.0)
links.new(bounds.outputs["Min"], center_sum.inputs[0])
links.new(bounds.outputs["Max"], center_sum.inputs[1])
center = nodes.new("ShaderNodeVectorMath")
center.operation = "SCALE"
center.inputs[3].default_value = 0.5
center.location = (50.0, 1150.0)
links.new(center_sum.outputs["Vector"], center.inputs[0])
place_grid = nodes.new("GeometryNodeTransform")
place_grid.location = (500.0, 500.0)
links.new(store_grid_uv.outputs["Geometry"], place_grid.inputs["Geometry"])
links.new(center.outputs["Vector"], place_grid.inputs["Translation"])
cell_sizes = []
for index, axis in enumerate(("X", "Y")):
y = 100.0 - index * 180.0
segments = nodes.new("ShaderNodeMath")
segments.operation = "SUBTRACT"
segments.inputs[1].default_value = 1.0
segments.location = (-100.0, y)
links.new(vertex_counts[index], segments.inputs[0])
cell_size = nodes.new("ShaderNodeMath")
cell_size.operation = "DIVIDE"
cell_size.location = (100.0, y)
links.new(separate_dimensions.outputs[axis], cell_size.inputs[0])
links.new(segments.outputs[0], cell_size.inputs[1])
cell_sizes.append(cell_size.outputs[0])
cell_area = nodes.new("ShaderNodeMath")
cell_area.operation = "MULTIPLY"
cell_area.location = (300.0, 100.0)
links.new(cell_sizes[0], cell_area.inputs[0])
links.new(cell_sizes[1], cell_area.inputs[1])
target_step = nodes.new("ShaderNodeMath")
target_step.operation = "SQRT"
target_step.location = (500.0, 100.0)
links.new(cell_area.outputs[0], target_step.inputs[0])
depth_ratio = nodes.new("ShaderNodeMath")
depth_ratio.operation = "DIVIDE"
depth_ratio.location = (300.0, -100.0)
links.new(thickness_input.outputs["Thickness"], depth_ratio.inputs[0])
links.new(target_step.outputs[0], depth_ratio.inputs[1])
rounded_depth = nodes.new("ShaderNodeMath")
rounded_depth.operation = "ROUND"
rounded_depth.location = (500.0, -100.0)
links.new(depth_ratio.outputs[0], rounded_depth.inputs[0])
depth_segments = nodes.new("ShaderNodeMath")
depth_segments.operation = "MAXIMUM"
depth_segments.inputs[1].default_value = 1.0
depth_segments.location = (700.0, -100.0)
links.new(rounded_depth.outputs[0], depth_segments.inputs[0])
depth_vertices = nodes.new("ShaderNodeMath")
depth_vertices.operation = "ADD"
depth_vertices.inputs[1].default_value = 1.0
depth_vertices.location = (900.0, -100.0)
links.new(depth_segments.outputs[0], depth_vertices.inputs[0])
cube_size = nodes.new("ShaderNodeCombineXYZ")
cube_size.location = (700.0, 250.0)
links.new(separate_dimensions.outputs["X"], cube_size.inputs["X"])
links.new(separate_dimensions.outputs["Y"], cube_size.inputs["Y"])
links.new(thickness_input.outputs["Thickness"], cube_size.inputs["Z"])
cube = nodes.new("GeometryNodeMeshCube")
cube.location = (900.0, 250.0)
links.new(cube_size.outputs["Vector"], cube.inputs["Size"])
links.new(vertex_counts[0], cube.inputs["Vertices X"])
links.new(vertex_counts[1], cube.inputs["Vertices Y"])
links.new(depth_vertices.outputs[0], cube.inputs["Vertices Z"])
store_cube_uv = nodes.new("GeometryNodeStoreNamedAttribute")
store_cube_uv.data_type = "FLOAT2"
store_cube_uv.domain = "CORNER"
store_cube_uv.inputs["Name"].default_value = "UVMap"
store_cube_uv.location = (1100.0, 250.0)
links.new(cube.outputs["Mesh"], store_cube_uv.inputs["Geometry"])
links.new(uv_coordinates.outputs["Vector"], store_cube_uv.inputs["Value"])
place_cube = nodes.new("GeometryNodeTransform")
place_cube.location = (1300.0, 250.0)
links.new(store_cube_uv.outputs["Geometry"], place_cube.inputs["Geometry"])
links.new(center.outputs["Vector"], place_cube.inputs["Translation"])
has_thickness = nodes.new("FunctionNodeCompare")
has_thickness.data_type = "FLOAT"
has_thickness.operation = "GREATER_THAN"
has_thickness.location = (450.0, -350.0)
links.new(thickness_input.outputs["Thickness"], has_thickness.inputs[0])
geometry_switch = nodes.new("GeometryNodeSwitch")
geometry_switch.input_type = "GEOMETRY"
geometry_switch.location = (750.0, 500.0)
links.new(has_thickness.outputs["Result"], geometry_switch.inputs["Switch"])
links.new(place_grid.outputs["Geometry"], geometry_switch.inputs["False"])
links.new(place_cube.outputs["Geometry"], geometry_switch.inputs["True"])
set_material = nodes.new("GeometryNodeSetMaterial")
set_material.location = (1050.0, 500.0)
links.new(geometry_switch.outputs["Output"], set_material.inputs["Geometry"])
links.new(material_input.outputs["Material"], set_material.inputs["Material"])
links.new(set_material.outputs["Geometry"], output.inputs["Geometry"])
group.color_tag = "GEOMETRY"
group.description = (
"Build an aspect-adaptive image plane and add optional centered thickness."
)
group.use_fake_user = True
return group

View File

@@ -0,0 +1,77 @@
"""Build the Shadeless shader node group."""
import bpy
SHADELESS_GROUP_NAME = "O Shadeless"
def build_shadeless_group():
group = bpy.data.node_groups.new(SHADELESS_GROUP_NAME, "ShaderNodeTree")
group.interface.new_socket(
name="Shader", in_out="OUTPUT", socket_type="NodeSocketShader"
)
group.interface.new_socket(
name="Color", in_out="INPUT", socket_type="NodeSocketColor"
)
nodes = group.nodes
links = group.links
group_input = nodes.new("NodeGroupInput")
group_input.location = (-900.0, -350.0)
group_output = nodes.new("NodeGroupOutput")
group_output.location = (850.0, 100.0)
diffuse = nodes.new("ShaderNodeBsdfDiffuse")
diffuse.location = (250.0, -150.0)
emission = nodes.new("ShaderNodeEmission")
emission.location = (250.0, -400.0)
links.new(group_input.outputs["Color"], diffuse.inputs["Color"])
links.new(group_input.outputs["Color"], emission.inputs["Color"])
light_path = nodes.new("ShaderNodeLightPath")
light_path.location = (-900.0, 150.0)
bounce_count = nodes.new("ShaderNodeMath")
bounce_count.operation = "SUBTRACT"
bounce_count.location = (-500.0, -50.0)
links.new(light_path.outputs["Ray Depth"], bounce_count.inputs[0])
links.new(light_path.outputs["Transmission Depth"], bounce_count.inputs[1])
refracted = nodes.new("ShaderNodeMath")
refracted.operation = "SUBTRACT"
refracted.inputs[0].default_value = 1.0
refracted.location = (-250.0, -50.0)
links.new(bounce_count.outputs[0], refracted.inputs[1])
reflection_limit = nodes.new("ShaderNodeMath")
reflection_limit.operation = "SUBTRACT"
reflection_limit.inputs[0].default_value = 2.0
reflection_limit.location = (-650.0, 450.0)
links.new(light_path.outputs["Ray Depth"], reflection_limit.inputs[1])
camera_reflected = nodes.new("ShaderNodeMath")
camera_reflected.operation = "MULTIPLY"
camera_reflected.location = (-400.0, 400.0)
links.new(reflection_limit.outputs[0], camera_reflected.inputs[0])
links.new(light_path.outputs["Is Glossy Ray"], camera_reflected.inputs[1])
shadow_or_reflect = nodes.new("ShaderNodeMath")
shadow_or_reflect.operation = "MAXIMUM"
shadow_or_reflect.location = (-100.0, 350.0)
links.new(camera_reflected.outputs[0], shadow_or_reflect.inputs[0])
links.new(light_path.outputs["Is Shadow Ray"], shadow_or_reflect.inputs[1])
shader_factor = nodes.new("ShaderNodeMath")
shader_factor.operation = "MAXIMUM"
shader_factor.location = (150.0, 300.0)
links.new(shadow_or_reflect.outputs[0], shader_factor.inputs[0])
links.new(refracted.outputs[0], shader_factor.inputs[1])
mix = nodes.new("ShaderNodeMixShader")
mix.location = (550.0, 100.0)
links.new(shader_factor.outputs[0], mix.inputs["Fac"])
links.new(diffuse.outputs["BSDF"], mix.inputs[1])
links.new(emission.outputs["Emission"], mix.inputs[2])
links.new(mix.outputs["Shader"], group_output.inputs["Shader"])
group.color_tag = "SHADER"
group.description = (
"Display color without scene lighting while preserving shadows "
"and reflections."
)
group.use_fake_user = True
return group

View File

@@ -0,0 +1,79 @@
from pathlib import Path
import sys
import bpy
PROJECT_ROOT = Path(__file__).parents[2]
sys.path.insert(0, str(PROJECT_ROOT / "src"))
sys.path.insert(
0,
str(next((PROJECT_ROOT / "src" / "anyimage" / "wheels").glob("blendjob-*.whl"))),
)
from anyimage.operators.common.node_asset import (
load_asset_node_group,
load_mesh_plane_node_group,
load_shadeless_node_group,
)
from anyimage.operators.cutout_tool.shape_types import (
CUTOUT_NODE_GROUP_NAME,
CUTOUT_NODE_GROUP_NAMES,
)
def input_names(group):
return [
item.name
for item in group.interface.items_tree
if item.item_type == "SOCKET" and item.in_out == "INPUT"
]
def main():
bpy.ops.wm.read_factory_settings(use_empty=True)
mesh_plane = load_mesh_plane_node_group()
shadeless = load_shadeless_node_group()
cutout = load_asset_node_group(CUTOUT_NODE_GROUP_NAME)
cutout_groups = {
name: bpy.data.node_groups[name]
for name in CUTOUT_NODE_GROUP_NAMES.values()
}
assert mesh_plane.name == "O Mesh Plane"
assert mesh_plane.bl_idname == "GeometryNodeTree"
assert mesh_plane.is_modifier
assert input_names(mesh_plane) == [
"Geometry",
"Subdivide",
"Thickness",
"Material",
]
assert shadeless.name == "O Shadeless"
assert shadeless.bl_idname == "ShaderNodeTree"
assert input_names(shadeless) == ["Color"]
assert cutout.name == CUTOUT_NODE_GROUP_NAME
assert cutout.bl_idname == "GeometryNodeTree"
assert cutout.is_modifier
assert input_names(cutout)[:3] == ["Geometry", "Shape", "Thickness"]
assert set(cutout_groups) == set(CUTOUT_NODE_GROUP_NAMES.values())
for group in cutout_groups.values():
assert group.bl_idname == "GeometryNodeTree"
assert not group.is_modifier
assert input_names(group)[0] == "Geometry"
public_groups = {mesh_plane, shadeless, cutout}
loaded = {*public_groups, *cutout_groups.values()}
assert all(group.use_fake_user for group in public_groups)
assert all(group.users > 0 for group in cutout_groups.values())
if bpy.app.version >= (5, 0, 0):
assert all(group.library is not None for group in loaded)
print(
f"Validated Blender {bpy.app.version_string}: "
+ ", ".join(sorted(group.name for group in loaded))
)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,430 @@
from pathlib import Path
import sys
import bpy
PROJECT_ROOT = Path(__file__).parents[2]
sys.path.insert(0, str(PROJECT_ROOT / "src"))
sys.path.insert(
0,
str(next((PROJECT_ROOT / "src" / "anyimage" / "wheels").glob("blendjob-*.whl"))),
)
from anyimage.operators.cutout_tool import node_assets as cutout_nodes
from anyimage.operators.cutout_tool import shape_types as shapes
from anyimage.operators.common.node_asset import (
modifier_input_identifier,
set_modifier_input,
)
def evaluated_mesh(obj):
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
result = evaluated.to_mesh()
return evaluated, result
def input_names(group):
return [
item.name
for item in group.interface.items_tree
if item.item_type == "SOCKET" and item.in_out == "INPUT"
]
def grid_object(name):
vertices = [
(0.0, float(y), float(z))
for z in range(3)
for y in range(3)
]
faces = []
for z in range(2):
for y in range(2):
a = z * 3 + y
faces.extend(((a, a + 4, a + 1), (a, a + 3, a + 4)))
data = bpy.data.meshes.new(name)
data.from_pydata(vertices, (), faces)
uv_layer = data.uv_layers.new(name="UVMap")
for loop in data.loops:
coordinate = data.vertices[loop.vertex_index].co
uv_layer.data[loop.index].uv = (coordinate.y * 0.5, coordinate.z * 0.5)
obj = bpy.data.objects.new(name, data)
bpy.context.scene.collection.objects.link(obj)
return obj
def add_modifier(obj, shape, thickness):
group = cutout_nodes.cutout_node_group(shape)
modifier = obj.modifiers.new(group.name, "NODES")
modifier.node_group = group
for name, value in (
("Shape", shapes.CUTOUT_SHAPE_INDICES[shape]),
("Cleanup", 0.0),
("Smooth", 0.0),
("Smooth Iterations", 0),
("Thickness", thickness),
):
set_modifier_input(
modifier,
modifier_input_identifier(group, name),
value,
)
return modifier
def main():
bpy.ops.wm.read_factory_settings(use_empty=True)
wrapper = cutout_nodes.cutout_node_group("CUTOUT")
assert all(
cutout_nodes.cutout_node_group(shape) == wrapper
for shape in shapes.CUTOUT_SHAPES
)
assert wrapper.name == shapes.CUTOUT_NODE_GROUP_NAME
assert wrapper.is_modifier
groups = {
shape: bpy.data.node_groups[name]
for shape, name in shapes.CUTOUT_NODE_GROUP_NAMES.items()
}
assert all(not group.is_modifier for group in groups.values())
menu_switch = next(
node for node in wrapper.nodes
if node.bl_idname == "GeometryNodeMenuSwitch"
)
assert [item.name for item in menu_switch.enum_items] == list(
shapes.CUTOUT_SHAPE_LABELS.values()
)
assert any(
link.from_socket.name == "Shape"
and link.to_node == menu_switch
and link.to_socket.name == "Menu"
for link in wrapper.links
)
child_nodes = {
node.node_tree: node
for node in wrapper.nodes
if node.bl_idname == "GeometryNodeGroup"
}
assert set(child_nodes) == set(groups.values())
for shape, group in groups.items():
assert any(
link.from_node == child_nodes[group]
and link.to_node == menu_switch
and link.to_socket.name == shapes.CUTOUT_SHAPE_LABELS[shape]
for link in wrapper.links
)
assert input_names(wrapper) == [
"Geometry",
"Shape",
"Thickness",
"Depth Scale",
"Double Sided",
"Smooth Iterations",
"Cleanup",
"Smooth",
"Reference Depth",
"Depth Direction",
"Uniform Scale",
"Depth Image",
]
expected_inputs = {
"CUTOUT": ["Geometry", "Thickness"],
"BALLOON": ["Geometry", "Thickness"],
"DEPTH_BALLOON": [
"Geometry",
"Thickness",
"Depth Scale",
"Double Sided",
"Reference Depth",
"Depth Direction",
"Uniform Scale",
"Depth Image",
],
"DEPTH_SURFACE": [
"Geometry",
"Thickness",
"Depth Scale",
"Reference Depth",
"Uniform Scale",
"Depth Image",
],
}
for shape, group in groups.items():
assert input_names(group) == expected_inputs[shape]
dependencies = {}
for link in wrapper.links:
if link.from_node.bl_idname != "NodeGroupInput":
continue
if link.to_node.bl_idname != "GeometryNodeGroup":
continue
shape = next(
shape
for shape, group in groups.items()
if group == link.to_node.node_tree
)
dependencies.setdefault(link.from_socket.name, set()).add(shape)
assert dependencies == {
"Thickness": set(shapes.CUTOUT_SHAPES),
"Depth Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
"Double Sided": {"DEPTH_BALLOON"},
"Reference Depth": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
"Depth Direction": {"DEPTH_BALLOON"},
"Uniform Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
"Depth Image": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
}
repeat_counts = {
shape: sum(
node.bl_idname == "GeometryNodeRepeatInput"
for node in group.nodes
)
for shape, group in groups.items()
}
assert repeat_counts == {
"CUTOUT": 1,
"BALLOON": 0,
"DEPTH_BALLOON": 0,
"DEPTH_SURFACE": 1,
}
assert sum(
node.bl_idname == "GeometryNodeRepeatInput"
for node in wrapper.nodes
) == 1
assert sum(
node.bl_idname == "GeometryNodeInputMeshIsland"
for node in wrapper.nodes
) == 1
assert not any(
node.bl_idname == "GeometryNodeAttributeStatistic"
for node in wrapper.nodes
)
assert all(
not any(
node.bl_idname == "GeometryNodeInputMeshIsland"
for node in group.nodes
)
for group in groups.values()
)
assert any(
node.bl_idname == "GeometryNodeBlurAttribute"
for node in wrapper.nodes
)
smooth = next(
item
for item in wrapper.interface.items_tree
if item.item_type == "SOCKET" and item.name == "Smooth"
)
iterations = next(
item
for item in wrapper.interface.items_tree
if item.item_type == "SOCKET" and item.name == "Smooth Iterations"
)
cleanup = next(
item
for item in wrapper.interface.items_tree
if item.item_type == "SOCKET" and item.name == "Cleanup"
)
assert cleanup.default_value == 0.0
assert cleanup.subtype == "NONE"
assert smooth.default_value == 1.0
assert iterations.default_value == 0
assert iterations.min_value == 0
assert getattr(iterations.parent, "name", "") == ""
assert all(
not any(
node.bl_idname == "GeometryNodeBlurAttribute"
for node in group.nodes
)
for group in groups.values()
)
for group in (wrapper, *groups.values()):
assert all(not node.label for node in group.nodes)
assert all(
link.from_node.location.x <= link.to_node.location.x
for link in group.links
)
group_nodes = list(group.nodes)
assert len({tuple(node.location) for node in group_nodes}) == len(group_nodes)
for index, node in enumerate(group_nodes):
for other in group_nodes[index + 1:]:
assert not (
abs(node.location.x - other.location.x) < 150.0
and abs(node.location.y - other.location.y) < 100.0
)
for group_input in (
node for node in group_nodes if node.bl_idname == "NodeGroupInput"
):
assert all(
output.is_linked or output.hide
for output in group_input.outputs
if output.name
)
for item in group.interface.items_tree:
if item.item_type == "SOCKET" and item.in_out == "INPUT":
assert item.structure_type == "SINGLE"
surface = grid_object("Surface Validation")
add_modifier(surface, "CUTOUT", 2.4)
evaluated, result = evaluated_mesh(surface)
try:
assert len(result.vertices) == 34
assert len(result.polygons) == 40
side_faces = [
polygon for polygon in result.polygons
if abs(polygon.normal.x) < 0.1
]
assert len(side_faces) == 24
finally:
evaluated.to_mesh_clear()
balloon = grid_object("Balloon Validation")
profile = balloon.data.attributes.new(
shapes.BALLOON_ATTRIBUTE_NAME, "FLOAT", "POINT"
)
profile.data.foreach_set(
"value",
(0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0),
)
modifier = add_modifier(balloon, "BALLOON", 1.0)
evaluated, result = evaluated_mesh(balloon)
try:
initial_faces = len(result.polygons)
initial_extent = max(abs(vertex.co.x) for vertex in result.vertices)
finally:
evaluated.to_mesh_clear()
set_modifier_input(
modifier,
modifier_input_identifier(modifier.node_group, "Thickness"),
2.0,
)
balloon.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
evaluated, result = evaluated_mesh(balloon)
try:
assert len(result.polygons) == initial_faces
extent = max(abs(vertex.co.x) for vertex in result.vertices)
assert abs(extent - initial_extent * 2.0) < 1e-5
finally:
evaluated.to_mesh_clear()
depth_balloon = grid_object("Depth Balloon Metric Validation")
profile = depth_balloon.data.attributes.new(
shapes.BALLOON_ATTRIBUTE_NAME, "FLOAT", "POINT"
)
profile.data.foreach_set("value", (1.0,) * 9)
modifier = add_modifier(depth_balloon, "DEPTH_BALLOON", 0.0)
point_image = bpy.data.images.new(
"Depth Balloon Points",
width=3,
height=3,
alpha=True,
float_buffer=True,
)
point_image.colorspace_settings.name = "Non-Color"
point_image.alpha_mode = "CHANNEL_PACKED"
pixels = []
for z in range(3):
for y in range(3):
if (y, z) == (1, 1):
depth = 1.5
elif (y, z) == (0, 0):
depth = 2.5
else:
depth = 2.0
pixels.extend((0.0, 0.0, depth, 0.0))
point_image.pixels.foreach_set(pixels)
for name, value in (
("Depth Image", point_image),
("Depth Scale", 1.0),
("Uniform Scale", 1.0),
("Reference Depth", 2.0),
("Depth Direction", (0.0, 0.0, 1.0)),
):
set_modifier_input(
modifier,
modifier_input_identifier(modifier.node_group, name),
value,
)
depth_balloon.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
evaluated, result = evaluated_mesh(depth_balloon)
try:
x_values = [vertex.co.x for vertex in result.vertices]
assert abs(min(x_values) + 0.5) < 1e-5
assert abs(max(x_values)) < 1e-5
finally:
evaluated.to_mesh_clear()
pixels[(1 * 3 + 1) * 4 + 3] = -1.0
point_image.pixels.foreach_set(pixels)
point_image.update()
depth_balloon.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
evaluated, result = evaluated_mesh(depth_balloon)
try:
x_values = [vertex.co.x for vertex in result.vertices]
assert abs(min(x_values) + 0.5) < 1e-5
assert abs(max(x_values)) < 1e-5
finally:
evaluated.to_mesh_clear()
depth_surface = grid_object("Depth Surface Projection Validation")
modifier = add_modifier(depth_surface, "DEPTH_SURFACE", 0.0)
point_image = bpy.data.images.new(
"Depth Surface Points",
width=3,
height=3,
alpha=True,
float_buffer=True,
)
point_image.colorspace_settings.name = "Non-Color"
point_image.alpha_mode = "CHANNEL_PACKED"
pixels = []
for z in range(3):
for y in range(3):
pixels.extend((-y * 0.5, -z * 0.5, 2.0 + y * 0.25, 0.0))
point_image.pixels.foreach_set(pixels)
for name, value in (
("Depth Image", point_image),
("Depth Scale", 1.0),
("Uniform Scale", 1.0),
("Reference Depth", 2.0),
("Smooth Iterations", 0),
):
set_modifier_input(
modifier,
modifier_input_identifier(modifier.node_group, name),
value,
)
depth_surface.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
evaluated, result = evaluated_mesh(depth_surface)
try:
coordinates = [tuple(vertex.co) for vertex in result.vertices]
assert all(
all(abs(actual - expected) < 1e-5 for actual, expected in zip(point, target))
for point, target in zip(
coordinates,
(
(y * 0.25, y * 0.5, z * 0.5)
for z in range(3)
for y in range(3)
),
)
)
finally:
evaluated.to_mesh_clear()
print(
f"Validated Blender {bpy.app.version_string}: "
f"{wrapper.name} wrapping "
f"{', '.join(group.name for group in groups.values())}"
)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,107 @@
from pathlib import Path
import sys
import bpy
PROJECT_ROOT = Path(__file__).parents[2]
sys.path.insert(0, str(PROJECT_ROOT / "src"))
sys.path.insert(
0,
str(next((PROJECT_ROOT / "src" / "anyimage" / "wheels").glob("blendjob-*.whl"))),
)
from anyimage.operators.common.node_asset import (
get_modifier_input,
modifier_input_identifier,
set_modifier_input,
)
from anyimage.operators.convert_to_plane.mesh_plane import (
add_mesh_plane_processing,
create_mesh_plane_mesh,
)
def evaluated_mesh(obj):
bpy.context.view_layer.update()
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
return evaluated, evaluated.to_mesh()
def main():
bpy.ops.wm.read_factory_settings(use_empty=True)
mesh = create_mesh_plane_mesh("Mesh Plane", (-1.0, 1.0, -0.5, 0.5))
assert len(mesh.polygons) == 1
material = bpy.data.materials.new("Mesh Plane Material")
obj = bpy.data.objects.new("Mesh Plane", mesh)
bpy.context.scene.collection.objects.link(obj)
modifier = add_mesh_plane_processing(obj, material)
group = modifier.node_group
assert group.name == "O Mesh Plane"
assert group.is_modifier
inputs = {
item.name: item
for item in group.interface.items_tree
if item.item_type == "SOCKET" and item.in_out == "INPUT"
}
assert list(inputs) == ["Geometry", "Subdivide", "Thickness", "Material"]
assert inputs["Subdivide"].default_value == 0
assert inputs["Subdivide"].max_value == 10
assert inputs["Thickness"].default_value == 0.0
assert inputs["Material"].hide_in_modifier
assert get_modifier_input(modifier, inputs["Material"].identifier) == material
evaluated, result = evaluated_mesh(obj)
try:
assert len(result.polygons) == 2
assert len(result.vertices) == 6
assert "UVMap" in result.attributes
uv_values = {
tuple(round(value, 6) for value in loop.uv)
for loop in result.uv_layers["UVMap"].data
}
assert uv_values == {
(0.0, 0.0),
(0.5, 0.0),
(1.0, 0.0),
(0.0, 1.0),
(0.5, 1.0),
(1.0, 1.0),
}
assert all(abs(vertex.co.z) < 1e-6 for vertex in result.vertices)
finally:
evaluated.to_mesh_clear()
set_modifier_input(modifier, modifier_input_identifier(group, "Subdivide"), 3)
obj.update_tag(refresh={"DATA"})
evaluated, result = evaluated_mesh(obj)
try:
assert len(result.polygons) == 128
assert len(result.vertices) == 153
finally:
evaluated.to_mesh_clear()
set_modifier_input(modifier, modifier_input_identifier(group, "Subdivide"), 2)
set_modifier_input(modifier, modifier_input_identifier(group, "Thickness"), 1.2)
obj.update_tag(refresh={"DATA"})
evaluated, result = evaluated_mesh(obj)
try:
assert len(result.polygons) == 184
z_values = [vertex.co.z for vertex in result.vertices]
assert abs(min(z_values) + 0.6) < 1e-6
assert abs(max(z_values) - 0.6) < 1e-6
assert len({round(value, 6) for value in z_values}) == 6
edge_users = {edge.key: 0 for edge in result.edges}
for polygon in result.polygons:
for edge_key in polygon.edge_keys:
edge_users[edge_key] += 1
assert all(users == 2 for users in edge_users.values())
finally:
evaluated.to_mesh_clear()
if bpy.app.version >= (5, 0, 0):
assert group.library is not None
if __name__ == "__main__":
main()