This commit is contained in:
0
tests/tools/nodes/__init__.py
Normal file
0
tests/tools/nodes/__init__.py
Normal file
1
tests/tools/nodes/fixtures/projected_symmetry.json
Normal file
1
tests/tools/nodes/fixtures/projected_symmetry.json
Normal file
File diff suppressed because one or more lines are too long
175
tests/tools/nodes/test_arrangement.py
Normal file
175
tests/tools/nodes/test_arrangement.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""Exercise automatic arrangement while preserving node programs and geometry."""
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
|
||||
from tools.nodes.arrangement.arrange import arrange_nodes
|
||||
from tools.nodes.arrangement.dimensions import estimate_node_dimensions
|
||||
from tools.nodes.check import ASSET_PATH, SHADER_NAMES
|
||||
from nodes.groups.image_depth_cutout import build_image_depth_cutout_group
|
||||
from nodes.groups.image_depth_layer import build_image_depth_layer_group
|
||||
from nodes.groups.image_layer import build_image_layer_group
|
||||
from nodes.groups.shadeless import build_shadeless_group
|
||||
|
||||
|
||||
BUILDERS = (
|
||||
("O Image Depth Cutout", build_image_depth_cutout_group),
|
||||
("O Image Layer", build_image_layer_group),
|
||||
)
|
||||
|
||||
SHADER_BUILDERS = {
|
||||
"O Image Layer": build_image_layer_group,
|
||||
"O Image Depth Layer": build_image_depth_layer_group,
|
||||
"O Shadeless": build_shadeless_group,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(params=[name for name, _ in BUILDERS])
|
||||
def node_group(request):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
return next(builder() for name, builder in BUILDERS if name == request.param)
|
||||
|
||||
|
||||
def _source(socket):
|
||||
while socket.node.bl_idname == "NodeReroute":
|
||||
socket = socket.node.inputs[0].links[0].from_socket
|
||||
node = socket.node
|
||||
return ("INPUT" if node.bl_idname == "NodeGroupInput" else node.name, socket.identifier)
|
||||
|
||||
|
||||
def _value(value):
|
||||
if isinstance(value, (str, int, float, bool)) or value is None:
|
||||
return value
|
||||
if isinstance(value, bpy.types.ID):
|
||||
return value.as_pointer()
|
||||
return tuple(value)
|
||||
|
||||
|
||||
def _program(node_group):
|
||||
nodes = {}
|
||||
for node in node_group.nodes:
|
||||
if node.bl_idname in {"NodeReroute", "NodeGroupInput", "NodeFrame"}:
|
||||
continue
|
||||
settings = {
|
||||
name: _value(getattr(node, name))
|
||||
for name in ("operation", "data_type", "input_type", "domain", "mode", "clamp",
|
||||
"interpolation", "interpolation_type", "extension", "pattern_mode",
|
||||
"space", "uv_map", "attribute_type", "attribute_name", "rotation_type",
|
||||
"invert", "blend_type", "clamp_factor", "clamp_result", "distribution")
|
||||
if hasattr(node, name)
|
||||
}
|
||||
if hasattr(node, "mapping"):
|
||||
mapping = node.mapping
|
||||
settings["mapping"] = (
|
||||
mapping.extend, mapping.use_clip,
|
||||
tuple((tuple(point.location), point.handle_type)
|
||||
for curve in mapping.curves for point in curve.points),
|
||||
)
|
||||
inputs = [
|
||||
(socket.identifier, _value(socket.default_value) if hasattr(socket, "default_value") else None,
|
||||
[_source(link.from_socket) for link in sorted(
|
||||
socket.links, key=lambda link: link.multi_input_sort_id, reverse=True)])
|
||||
for socket in node.inputs
|
||||
]
|
||||
paired = getattr(node, "paired_output", None)
|
||||
nodes[node.name] = (node.bl_idname, settings, inputs, paired.name if paired else None)
|
||||
interface = [
|
||||
(item.identifier, item.name, item.in_out, item.socket_type,
|
||||
_value(item.default_value) if hasattr(item, "default_value") else None)
|
||||
for item in node_group.interface.items_tree if item.item_type == "SOCKET"
|
||||
]
|
||||
return nodes, interface
|
||||
|
||||
|
||||
def _surface(node_group):
|
||||
mesh = bpy.data.meshes.new("Layout Surface")
|
||||
mesh.from_pydata(
|
||||
[(x - 2, y - 1, 0) for y in range(3) for x in range(5)], [],
|
||||
[(y * 5 + x, y * 5 + x + 1, (y + 1) * 5 + x + 1, (y + 1) * 5 + x)
|
||||
for y in range(2) for x in range(4)],
|
||||
)
|
||||
uv = mesh.uv_layers.new(name="UVMap")
|
||||
for loop in mesh.loops:
|
||||
x, y, _ = mesh.vertices[loop.vertex_index].co
|
||||
uv.data[loop.index].uv = ((x + 2) / 4, (y + 1) / 2)
|
||||
profile = mesh.attributes.new("o_balloon", "FLOAT", "POINT")
|
||||
for vertex, value in zip(mesh.vertices, profile.data):
|
||||
value.value = 0.25 * (1 - abs(vertex.co.x) / 2) * (1 - abs(vertex.co.y))
|
||||
mesh.materials.append(bpy.data.materials.new("Image"))
|
||||
obj = bpy.data.objects.new("Layout Surface", mesh)
|
||||
bpy.context.collection.objects.link(obj)
|
||||
modifier = obj.modifiers.new("Surface", "NODES")
|
||||
modifier.node_group = node_group
|
||||
image = bpy.data.images.new("Layout Depth", width=16, height=8, float_buffer=True)
|
||||
image.colorspace_settings.name = "Non-Color"
|
||||
image.alpha_mode = "CHANNEL_PACKED"
|
||||
yy, xx = np.mgrid[:8, :16]
|
||||
pixels = np.ones((8, 16, 4), dtype=np.float32)
|
||||
pixels[..., 0] = ((xx + 0.5) / 16 - 0.5) * 4
|
||||
pixels[..., 1] = -((yy + 0.5) / 8 - 0.5) * 2
|
||||
pixels[..., 2] = 2 + xx * 0.02
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
values = {"Subdivide": 1, "Thickness": 0.2, "Depth Image": image,
|
||||
"Uniform Scale": 1.0, "Reference Depth": 2.5, "Depth Scale": 0.8,
|
||||
"Edge Turn": 2, "Depth Split": 0.25,
|
||||
"Offset": 0.5}
|
||||
mode = None
|
||||
for item in node_group.interface.items_tree:
|
||||
if item.item_type != "SOCKET" or item.in_out != "INPUT":
|
||||
continue
|
||||
if item.name in values:
|
||||
modifier[item.identifier] = values[item.name]
|
||||
elif item.name == "Mode":
|
||||
mode = item.identifier
|
||||
|
||||
def evaluate():
|
||||
results = []
|
||||
for choice in range(2 if mode else 1):
|
||||
if mode:
|
||||
modifier[mode] = choice
|
||||
obj.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
evaluated = result.to_mesh()
|
||||
try:
|
||||
assert len(evaluated.vertices) > 0
|
||||
results.append((np.array([vertex.co[:] for vertex in evaluated.vertices]),
|
||||
[tuple(face.vertices) for face in evaluated.polygons]))
|
||||
finally:
|
||||
result.to_mesh_clear()
|
||||
return results
|
||||
|
||||
return evaluate
|
||||
|
||||
|
||||
def test_arrangement_preserves_program_and_evaluation(node_group):
|
||||
program = _program(node_group)
|
||||
evaluate = _surface(node_group) if node_group.bl_idname == "GeometryNodeTree" else list
|
||||
geometry = evaluate()
|
||||
arrange_nodes(node_group)
|
||||
assert _program(node_group) == program
|
||||
for (before_points, before_faces), (after_points, after_faces) in zip(geometry, evaluate()):
|
||||
np.testing.assert_allclose(after_points, before_points, atol=1e-6)
|
||||
assert after_faces == before_faces
|
||||
|
||||
display_nodes = [node for node in node_group.nodes if node.bl_idname not in {"NodeFrame", "NodeReroute"}]
|
||||
boxes = []
|
||||
for node in display_nodes:
|
||||
size = estimate_node_dimensions(node)
|
||||
x, y = node.location_absolute
|
||||
box = (x, y - size.height, x + size.width, y)
|
||||
assert all(not (box[0] < other[2] and box[2] > other[0]
|
||||
and box[1] < other[3] and box[3] > other[1]) for other in boxes), node.name
|
||||
boxes.append(box)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", sorted(SHADER_NAMES))
|
||||
def test_shader_asset_preserves_definition(name):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
definition = SHADER_BUILDERS[name]()
|
||||
expected = _program(definition)
|
||||
with bpy.data.libraries.load(str(ASSET_PATH)) as (_, target):
|
||||
target.node_groups = [name]
|
||||
assert _program(target.node_groups[0]) == expected
|
||||
92
tests/tools/nodes/test_cli.py
Normal file
92
tests/tools/nodes/test_cli.py
Normal file
@@ -0,0 +1,92 @@
|
||||
from tests.support.paths import PROJECT_ROOT
|
||||
import importlib.util
|
||||
import subprocess
|
||||
from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
|
||||
SCRIPT_PATH = PROJECT_ROOT / "tools" / "nodes" / "cli.py"
|
||||
|
||||
|
||||
def load_launcher():
|
||||
specification = importlib.util.spec_from_file_location(
|
||||
"node_asset_launcher",
|
||||
SCRIPT_PATH,
|
||||
)
|
||||
module = importlib.util.module_from_spec(specification)
|
||||
specification.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_commands_require_the_blender_group(monkeypatch):
|
||||
launcher = load_launcher()
|
||||
monkeypatch.setattr("sys.argv", ["node-group", "check"])
|
||||
with patch.object(launcher.importlib.util, "find_spec", return_value=None):
|
||||
with pytest.raises(RuntimeError, match="--group blender"):
|
||||
launcher.main()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arguments,commands", [
|
||||
(["build"], [
|
||||
("tools.nodes.build", ()),
|
||||
("tools.nodes.check", ()),
|
||||
("pytest", ("tests/nodes", "tests/tools/nodes")),
|
||||
]),
|
||||
(["check", "--skip-tests"], [("tools.nodes.check", ())]),
|
||||
(["preview", "--fragment"], [
|
||||
("tools.nodes.preview.export", "preview"),
|
||||
]),
|
||||
])
|
||||
def test_commands_dispatch_requested_work(monkeypatch, tmp_path, arguments, commands):
|
||||
launcher = load_launcher()
|
||||
output = tmp_path / "node groups.html"
|
||||
command = ["node-group", *arguments]
|
||||
if arguments[0] == "preview":
|
||||
command += ["--output", str(output)]
|
||||
monkeypatch.setattr("sys.argv", command)
|
||||
with (
|
||||
patch.object(launcher, "require_bpy"),
|
||||
patch.object(launcher, "run_module") as run_module,
|
||||
patch.object(launcher, "run") as run,
|
||||
patch.object(launcher.webbrowser, "open") as open_browser,
|
||||
):
|
||||
launcher.main()
|
||||
if arguments[0] == "preview":
|
||||
run_module.assert_called_once_with(
|
||||
"tools.nodes.preview.export",
|
||||
["--output", str(output), "--fragment"],
|
||||
)
|
||||
open_browser.assert_not_called()
|
||||
else:
|
||||
expected_modules = [item[0] for item in commands if item[0] != "pytest"]
|
||||
assert [call.args[0] for call in run_module.call_args_list] == expected_modules
|
||||
if any(item[0] == "pytest" for item in commands):
|
||||
run.assert_called_once_with(
|
||||
(
|
||||
launcher.sys.executable,
|
||||
"-m",
|
||||
"pytest",
|
||||
"tests/nodes",
|
||||
"tests/tools/nodes",
|
||||
)
|
||||
)
|
||||
else:
|
||||
run.assert_not_called()
|
||||
|
||||
|
||||
def test_module_failure_stops_later_steps(monkeypatch):
|
||||
launcher = load_launcher()
|
||||
monkeypatch.setattr("sys.argv", ["node-group", "build"])
|
||||
with (
|
||||
patch.object(launcher, "require_bpy"),
|
||||
patch.object(
|
||||
launcher,
|
||||
"run_module",
|
||||
side_effect=subprocess.CalledProcessError(1, "build"),
|
||||
) as run_module,
|
||||
patch.object(launcher, "run") as run,
|
||||
):
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
launcher.main()
|
||||
run_module.assert_called_once_with("tools.nodes.build")
|
||||
run.assert_not_called()
|
||||
109
tests/tools/nodes/test_inventory.py
Normal file
109
tests/tools/nodes/test_inventory.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""Check the saved library's public inventory and persistence."""
|
||||
import bpy
|
||||
from anyimage.common.node import node_asset_path
|
||||
from tools.nodes.check import GEOMETRY_NAMES
|
||||
|
||||
|
||||
def test_saved_assets_evaluate():
|
||||
from tools.nodes.check import check_asset_library
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
check_asset_library(node_asset_path())
|
||||
|
||||
|
||||
def test_saved_parameter_descriptions_match_definitions():
|
||||
from tools.nodes.build import build_node_groups
|
||||
|
||||
parameters = {
|
||||
"O Image Plane": {"Subdivide"},
|
||||
"O Image Depth Plane": {"Subdivide", "Thickness", "Depth Split", "Depth Mask", "Mask Threshold", "Boundary Smooth"},
|
||||
"O Image Relief Plane": {"Subdivide", "Thickness", "Depth Direction", "Depth Offset"},
|
||||
"O Image Cutout": {"Thickness"},
|
||||
"O Image Depth Cutout": {"Thickness", "Depth Split", "Boundary Smooth", "Edge Turn", "Front Inflation"},
|
||||
"O Image Cutout Symmetry": {"Direction", "Offset", "Scale", "Fill Sides", "Smooth"},
|
||||
"O Image Depth Panorama": {"Subdivide", "Depth Scale", "Depth Split", "Dome Radius", "Depth Mask", "Mask Threshold", "Boundary Smooth"},
|
||||
"O Image Layer": {"Alpha Fix", "Normal Scale", "Bump Scale", "Object Space"},
|
||||
"O Image Depth Layer": {"Bump Scale"},
|
||||
}
|
||||
|
||||
def descriptions(groups):
|
||||
result = {}
|
||||
for group in groups:
|
||||
if group.name not in parameters:
|
||||
continue
|
||||
sockets = [
|
||||
item for item in group.interface.items_tree
|
||||
if item.item_type == "SOCKET" and item.in_out == "INPUT"
|
||||
and item.name in parameters[group.name]
|
||||
]
|
||||
assert {item.name for item in sockets} == parameters[group.name]
|
||||
assert all(item.description for item in sockets), group.name
|
||||
result[group.name] = [(item.identifier, item.name, item.description) for item in sockets]
|
||||
assert result.keys() == parameters.keys()
|
||||
return result
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
expected = descriptions(build_node_groups())
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
with bpy.data.libraries.load(str(node_asset_path())) as (library, target):
|
||||
target.node_groups = list(library.node_groups)
|
||||
assert descriptions(target.node_groups) == expected
|
||||
|
||||
|
||||
def test_geometry_nodes_contribute_to_output():
|
||||
from tools.nodes.build import build_node_groups
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
groups = build_node_groups()
|
||||
for group in groups:
|
||||
if group.bl_idname != "GeometryNodeTree":
|
||||
continue
|
||||
pending = [node for node in group.nodes if node.bl_idname == "NodeGroupOutput"]
|
||||
used = set()
|
||||
while pending:
|
||||
node = pending.pop()
|
||||
if node in used:
|
||||
continue
|
||||
used.add(node)
|
||||
pending.extend(link.from_node for socket in node.inputs for link in socket.links)
|
||||
pending.extend(candidate for candidate in group.nodes
|
||||
if getattr(candidate, "paired_output", None) == node)
|
||||
unused = [node.name for node in group.nodes
|
||||
if node not in used and node.bl_idname != "NodeFrame"]
|
||||
assert not unused, (group.name, unused)
|
||||
|
||||
|
||||
def test_geometry_attribute_contract_includes_nested_groups():
|
||||
from tools.nodes.build import build_node_groups
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
pending = build_node_groups()
|
||||
seen = set()
|
||||
allowed = {
|
||||
"UVMap": ("FLOAT2", "CORNER"),
|
||||
"o_balloon": ("FLOAT", "POINT"),
|
||||
"_o_depth_cut": ("BOOLEAN", "CORNER"),
|
||||
"_o_front_normal": ("FLOAT_VECTOR", "POINT"),
|
||||
"_o_symmetry_weld": ("BOOLEAN", "FACE"),
|
||||
"o_depth_rotation": ("FLOAT_VECTOR", "FACE"),
|
||||
"o_depth_face": ("FLOAT", "FACE"),
|
||||
"o_depth_axis": ("FLOAT", "FACE"),
|
||||
}
|
||||
while pending:
|
||||
group = pending.pop()
|
||||
if group in seen or group.bl_idname != "GeometryNodeTree":
|
||||
continue
|
||||
seen.add(group)
|
||||
for node in group.nodes:
|
||||
assert node.bl_idname != "GeometryNodeCaptureAttribute", group.name
|
||||
if node.bl_idname == "GeometryNodeGroup" and node.node_tree:
|
||||
pending.append(node.node_tree)
|
||||
if node.bl_idname != "GeometryNodeStoreNamedAttribute":
|
||||
continue
|
||||
name = node.inputs["Name"].default_value
|
||||
if name == "o_normal_reduction":
|
||||
assert node.data_type == "FLOAT" and node.domain in {"POINT", "FACE"}
|
||||
else:
|
||||
assert name in allowed, (group.name, name)
|
||||
assert (node.data_type, node.domain) == allowed[name]
|
||||
assert {group.name for group in seen} == GEOMETRY_NAMES
|
||||
Reference in New Issue
Block a user