Add tests for image layer, image plane, inventory, saved layout, commands, documentation, manifest, and packaging
- Implement tests for image layer functionality including alpha curve and depth layer behavior. - Create tests for image plane evaluation and modifier input handling. - Add tests to verify the saved asset inventory and its persistence. - Introduce tests for the documentation command to ensure correct project version deployment. - Validate the extension manifest structure and required permissions. - Develop tests for the packaging process, ensuring correct platform handling and dependency management. - Enhance image depth plane group functionality with new split threshold input and surface processing.
This commit is contained in:
0
tests/tools/__init__.py
Normal file
0
tests/tools/__init__.py
Normal file
0
tests/tools/models/__init__.py
Normal file
0
tests/tools/models/__init__.py
Normal file
96
tests/tools/models/test_cli.py
Normal file
96
tests/tools/models/test_cli.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from tests.support.paths import PROJECT_ROOT
|
||||
import importlib.util
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def load_sync_models():
|
||||
path = PROJECT_ROOT / "tools" / "models" / "cli.py"
|
||||
spec = importlib.util.spec_from_file_location("sync_models", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class SyncModelsTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.sync = load_sync_models()
|
||||
|
||||
def test_r2_is_only_an_upload_destination(self):
|
||||
for model in self.sync.MODEL_FILES:
|
||||
self.assertNotIn("models.omoolab.xyz", model.source_url)
|
||||
if model.build:
|
||||
self.assertFalse(model.source_url)
|
||||
|
||||
def test_local_exports_use_official_pytorch_weights(self):
|
||||
builds = self.sync.MODEL_BUILDS
|
||||
self.assertEqual(
|
||||
set(builds),
|
||||
{"REALESRGAN_GENERAL_WDN_X4V3", "SAFMN_REAL_X4"},
|
||||
)
|
||||
for build in builds.values():
|
||||
self.assertTrue(build.source_url.startswith("https://github.com/"))
|
||||
self.assertTrue(build.source_filename.endswith(".pth"))
|
||||
self.assertEqual(len(build.source_sha256), 64)
|
||||
|
||||
def test_prepare_model_builds_missing_local_export(self):
|
||||
model = next(model for model in self.sync.MODEL_FILES if model.build)
|
||||
with (
|
||||
patch.object(self.sync, "verify_file", return_value=False),
|
||||
patch.object(self.sync, "build_model") as build,
|
||||
patch.object(self.sync, "download_file") as download,
|
||||
):
|
||||
self.sync.prepare_model(model)
|
||||
build.assert_called_once_with(model)
|
||||
download.assert_not_called()
|
||||
|
||||
def test_prepare_model_downloads_upstream_onnx(self):
|
||||
model = next(
|
||||
model
|
||||
for model in self.sync.MODEL_FILES
|
||||
if model.source_url and not model.build
|
||||
)
|
||||
with (
|
||||
patch.object(self.sync, "verify_file", return_value=False),
|
||||
patch.object(self.sync, "download_file") as download,
|
||||
):
|
||||
self.sync.prepare_model(model)
|
||||
download.assert_called_once_with(
|
||||
model.source_url,
|
||||
model.destination,
|
||||
model.size,
|
||||
model.sha256,
|
||||
)
|
||||
|
||||
def test_upload_copies_declared_files_without_remote_deletion(self):
|
||||
model = self.sync.ModelFile(
|
||||
key="TEST",
|
||||
folder="folder",
|
||||
filename="model.onnx",
|
||||
size=1,
|
||||
sha256="hash",
|
||||
)
|
||||
with (
|
||||
patch.object(self.sync.shutil, "which", return_value="rclone"),
|
||||
patch.object(self.sync.subprocess, "run") as run,
|
||||
):
|
||||
self.sync.upload_models((model,))
|
||||
command = run.call_args.args[0]
|
||||
self.assertEqual(command[1], "copyto")
|
||||
self.assertNotIn("sync", command)
|
||||
self.assertEqual(command[-2], model.remote)
|
||||
|
||||
def test_verify_file_checks_size_and_sha256(self):
|
||||
path = PROJECT_ROOT / "tools" / "models" / "cli.py"
|
||||
size = path.stat().st_size
|
||||
digest = self.sync.file_sha256(path)
|
||||
self.assertTrue(self.sync.verify_file(path, size, digest))
|
||||
with self.assertRaisesRegex(RuntimeError, "unexpected size"):
|
||||
self.sync.verify_file(path, size - 1, digest)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
0
tests/tools/nodes/__init__.py
Normal file
0
tests/tools/nodes/__init__.py
Normal file
683
tests/tools/nodes/test_arrangement.py
Normal file
683
tests/tools/nodes/test_arrangement.py
Normal file
@@ -0,0 +1,683 @@
|
||||
"""Exercise automatic arrangement without changing geometry or publishing assets."""
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
|
||||
from tools.nodes.groups.image_plane import build_image_plane_group
|
||||
from tools.nodes.groups.image_depth_plane import build_image_depth_plane_group
|
||||
from tools.nodes.groups.image_relief_plane import build_image_relief_plane_group
|
||||
from tests.support.nodes import CUTOUT_BUILDERS
|
||||
from tools.nodes.arrangement.arrange import BRANCH_GAP, FIELD_HEIGHT, ROUTE_RUN, arrange_nodes
|
||||
from tools.nodes.arrangement.flow import geometry_paths, origin_socket, peer_links
|
||||
from tools.nodes.arrangement.dimensions import RULES, estimate_node_dimensions
|
||||
|
||||
|
||||
@pytest.fixture(params=("plane", "depth_plane", "relief_plane", "SOLID", "DEPTH_SOLID", "DEPTH_BALLOON"))
|
||||
def node_group(request):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
if request.param == "plane":
|
||||
return build_image_plane_group()
|
||||
if request.param == "depth_plane":
|
||||
return build_image_depth_plane_group(build_image_plane_group())
|
||||
if request.param == "relief_plane":
|
||||
return build_image_relief_plane_group(build_image_plane_group())
|
||||
return CUTOUT_BUILDERS[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"}:
|
||||
continue
|
||||
settings = {
|
||||
name: _value(getattr(node, name))
|
||||
for name in ("operation", "data_type", "input_type", "domain", "mode", "clamp",
|
||||
"interpolation", "interpolation_type", "extension", "pattern_mode")
|
||||
if hasattr(node, name)
|
||||
}
|
||||
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.0, "Depth Scale": 0.8,
|
||||
"Smooth": 1, "Normal Smooth": 2, "Split Threshold": 0.25}
|
||||
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_each_asset_program(node_group):
|
||||
program = _program(node_group)
|
||||
evaluate = _surface(node_group)
|
||||
geometry = evaluate()
|
||||
arrange_nodes(node_group)
|
||||
assert _program(node_group) == program
|
||||
|
||||
for node in node_group.nodes:
|
||||
if node.bl_idname == "NodeGroupInput":
|
||||
targets = {link.to_node for socket in node.outputs for link in socket.links}
|
||||
assert len(targets) == 1
|
||||
assert all(socket.hide for socket in node.outputs if not socket.is_linked)
|
||||
|
||||
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
|
||||
positions = {
|
||||
node.name: tuple(node.location) for node in node_group.nodes
|
||||
if node.bl_idname not in {"NodeReroute", "NodeGroupInput"}
|
||||
}
|
||||
arrange_nodes(node_group)
|
||||
assert _program(node_group) == program
|
||||
assert {name: tuple(node_group.nodes[name].location) for name in positions} == positions
|
||||
for node in node_group.nodes:
|
||||
sources = [link.from_socket for link in peer_links(node)]
|
||||
if len(sources) < 2:
|
||||
continue
|
||||
levels = [socket.node.location.y - estimate_node_dimensions(socket.node).sockets[socket] for socket in sources]
|
||||
size = estimate_node_dimensions(node)
|
||||
output = next(socket for socket in node.outputs if socket in size.sockets)
|
||||
assert node.location.y - size.sockets[output] == pytest.approx((min(levels) + max(levels)) / 2, abs=0.001), node.name
|
||||
assert all(first >= second - 0.001 for first, second in zip(levels, levels[1:])), node.name
|
||||
|
||||
|
||||
def test_dimensions_include_unlinked_vector_controls_and_collapsed_sockets():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
node_group = bpy.data.node_groups.new("Dimensions", "GeometryNodeTree")
|
||||
source = node_group.nodes.new("GeometryNodeInputPosition")
|
||||
vector = node_group.nodes.new("ShaderNodeVectorMath")
|
||||
vector.operation = "ADD"
|
||||
expanded = estimate_node_dimensions(vector)
|
||||
node_group.links.new(source.outputs[0], vector.inputs[0])
|
||||
linked = estimate_node_dimensions(vector)
|
||||
assert expanded.height - linked.height == 3 * RULES["row"]
|
||||
vector.hide = True
|
||||
collapsed = estimate_node_dimensions(vector)
|
||||
assert collapsed.height < linked.height
|
||||
assert all(0 < offset < collapsed.height for offset in collapsed.sockets.values())
|
||||
|
||||
|
||||
def test_local_inputs_preserve_identifiers_with_duplicate_display_names():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
node_group = bpy.data.node_groups.new("Controls", "GeometryNodeTree")
|
||||
first = node_group.interface.new_socket(name="Thickness", in_out="INPUT", socket_type="NodeSocketFloat")
|
||||
second = node_group.interface.new_socket(name="Thickness", in_out="INPUT", socket_type="NodeSocketFloat")
|
||||
first.default_value, second.default_value = 0.25, 0.75
|
||||
node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
|
||||
controls = node_group.nodes.new("NodeGroupInput")
|
||||
grid = node_group.nodes.new("GeometryNodeMeshGrid")
|
||||
output = node_group.nodes.new("NodeGroupOutput")
|
||||
node_group.links.new(controls.outputs[first.identifier], grid.inputs["Size X"])
|
||||
node_group.links.new(controls.outputs[second.identifier], grid.inputs["Size Y"])
|
||||
node_group.links.new(grid.outputs["Mesh"], output.inputs["Geometry"])
|
||||
program = _program(node_group)
|
||||
arrange_nodes(node_group)
|
||||
assert _program(node_group) == program
|
||||
|
||||
|
||||
@pytest.mark.parametrize("branch_lengths", [(1, 1), (1, 3), (3, 1), (1, 2, 3)])
|
||||
def test_geometry_paths_order_join_branches_and_separate_their_shared_input(branch_lengths):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
node_group = bpy.data.node_groups.new("Parallel Geometry", "GeometryNodeTree")
|
||||
node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
|
||||
source = node_group.nodes.new("GeometryNodeMeshGrid")
|
||||
join = node_group.nodes.new("GeometryNodeJoinGeometry")
|
||||
output = node_group.nodes.new("NodeGroupOutput")
|
||||
branches = []
|
||||
for length in branch_lengths:
|
||||
geometry = source.outputs["Mesh"]
|
||||
branch = []
|
||||
for _ in range(length):
|
||||
position = node_group.nodes.new("GeometryNodeSetPosition")
|
||||
node_group.links.new(geometry, position.inputs["Geometry"])
|
||||
geometry = position.outputs["Geometry"]
|
||||
branch.append(position)
|
||||
node_group.links.new(geometry, join.inputs["Geometry"])
|
||||
branches.append(branch)
|
||||
node_group.links.new(source.outputs["Mesh"], join.inputs["Geometry"])
|
||||
node_group.links.new(join.outputs["Geometry"], output.inputs["Geometry"])
|
||||
|
||||
geometry_parents, paths = geometry_paths(node_group)
|
||||
assert set(geometry_parents) == {join, output, *(node for branch in branches for node in branch)}
|
||||
assert [join, output] in paths
|
||||
assert all(branch in paths for branch in branches)
|
||||
assert source not in geometry_parents
|
||||
assert sum(len(path) for path in paths) == len(geometry_parents)
|
||||
program = _program(node_group)
|
||||
arrange_nodes(node_group)
|
||||
assert _program(node_group) == program
|
||||
levels = [link.from_node.location.y - estimate_node_dimensions(link.from_node).sockets[link.from_socket]
|
||||
for link in peer_links(join)]
|
||||
assert levels == sorted(levels, reverse=True)
|
||||
|
||||
if branch_lengths == (1, 2, 3):
|
||||
heights = {node: node.location.y for node in geometry_parents}
|
||||
field = node_group.nodes.new("GeometryNodeInputPosition").outputs["Position"]
|
||||
calculations = [field.node]
|
||||
for _ in range(30):
|
||||
operation = node_group.nodes.new("ShaderNodeVectorMath")
|
||||
operation.operation = "ADD"
|
||||
node_group.links.new(field, operation.inputs[0])
|
||||
field = operation.outputs["Vector"]
|
||||
calculations.append(operation)
|
||||
node_group.links.new(field, branches[0][0].inputs["Offset"])
|
||||
program = _program(node_group)
|
||||
arrange_nodes(node_group)
|
||||
assert _program(node_group) == program
|
||||
assert {node: node.location.y for node in geometry_parents} == heights
|
||||
assert max(node.location.y for node in calculations) - min(
|
||||
node.location.y - estimate_node_dimensions(node).height for node in calculations
|
||||
) <= FIELD_HEIGHT
|
||||
|
||||
|
||||
def test_geometry_used_for_field_sampling_stays_with_the_field_branch():
|
||||
from tools.nodes.preview.export import _node_group_data
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
node_group = bpy.data.node_groups.new("Sampled Geometry", "GeometryNodeTree")
|
||||
node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
|
||||
source = node_group.nodes.new("GeometryNodeMeshGrid")
|
||||
separate = node_group.nodes.new("GeometryNodeSeparateGeometry")
|
||||
sample = node_group.nodes.new("GeometryNodeSampleIndex")
|
||||
sample.data_type = "FLOAT_VECTOR"
|
||||
position = node_group.nodes.new("GeometryNodeSetPosition")
|
||||
output = node_group.nodes.new("NodeGroupOutput")
|
||||
node_group.links.new(source.outputs["Mesh"], separate.inputs["Geometry"])
|
||||
node_group.links.new(separate.outputs["Selection"], sample.inputs["Geometry"])
|
||||
node_group.links.new(sample.outputs["Value"], position.inputs["Position"])
|
||||
node_group.links.new(source.outputs["Mesh"], position.inputs["Geometry"])
|
||||
node_group.links.new(position.outputs["Geometry"], output.inputs["Geometry"])
|
||||
|
||||
geometry_parents, paths = geometry_paths(node_group)
|
||||
assert set(geometry_parents) == {source, position, output}
|
||||
assert paths == [[source, position, output]]
|
||||
program = _program(node_group)
|
||||
arrange_nodes(node_group)
|
||||
assert _program(node_group) == program
|
||||
roles = {node["name"]: node["role"] for node in _node_group_data(node_group)["nodes"]}
|
||||
assert roles[separate.name] == roles[sample.name] == "field"
|
||||
assert roles[position.name] == "geometry"
|
||||
node_group.links.new(separate.outputs["Selection"], output.inputs["Geometry"])
|
||||
roles = {node["name"]: node["role"] for node in _node_group_data(node_group)["nodes"]}
|
||||
assert roles[separate.name] == "geometry"
|
||||
|
||||
|
||||
def test_shared_field_references_branch_near_their_consumers():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
node_group = bpy.data.node_groups.new("Shared Field", "GeometryNodeTree")
|
||||
node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
|
||||
source = node_group.nodes.new("GeometryNodeInputPosition")
|
||||
geometry = node_group.nodes.new("GeometryNodeMeshGrid").outputs["Mesh"]
|
||||
consumers = []
|
||||
for index in range(8):
|
||||
node = node_group.nodes.new("GeometryNodeSetPosition")
|
||||
node_group.links.new(geometry, node.inputs["Geometry"])
|
||||
if index in (0, 4, 7):
|
||||
node_group.links.new(source.outputs["Position"], node.inputs["Offset"])
|
||||
consumers.append(node)
|
||||
geometry = node.outputs["Geometry"]
|
||||
output = node_group.nodes.new("NodeGroupOutput")
|
||||
node_group.links.new(geometry, output.inputs["Geometry"])
|
||||
program = _program(node_group)
|
||||
arrange_nodes(node_group)
|
||||
assert _program(node_group) == program
|
||||
for node in consumers[1:]:
|
||||
branch = node.inputs["Offset"].links[0].from_node
|
||||
assert branch.bl_idname == "NodeReroute"
|
||||
assert node.location.x - branch.location.x >= ROUTE_RUN
|
||||
assert any(node.bl_idname == "NodeReroute" and len(node.outputs[0].links) > 1
|
||||
for node in node_group.nodes)
|
||||
assert all(link.to_node.location.x > link.from_node.location.x
|
||||
for link in node_group.links
|
||||
if link.from_node.bl_idname == link.to_node.bl_idname == "NodeReroute")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("side", ["upper", "lower"])
|
||||
def test_field_modules_reserve_lane_and_exit_clearance(side):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
node_group = bpy.data.node_groups.new("Branch Rectangles", "GeometryNodeTree")
|
||||
node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
|
||||
source = node_group.nodes.new("GeometryNodeMeshGrid")
|
||||
join = node_group.nodes.new("GeometryNodeJoinGeometry")
|
||||
output = node_group.nodes.new("NodeGroupOutput")
|
||||
branches = []
|
||||
for _ in range(2):
|
||||
geometry, branch = source.outputs["Mesh"], []
|
||||
for _ in range(5):
|
||||
node = node_group.nodes.new("GeometryNodeSetPosition")
|
||||
node_group.links.new(geometry, node.inputs["Geometry"])
|
||||
geometry = node.outputs["Geometry"]
|
||||
branch.append(node)
|
||||
branches.append(branch)
|
||||
node_group.links.new(geometry, join.inputs["Geometry"])
|
||||
node_group.links.new(join.outputs["Geometry"], output.inputs["Geometry"])
|
||||
arrange_nodes(node_group)
|
||||
branches.sort(key=lambda branch: branch[0].location.y, reverse=True)
|
||||
lane = branches[0 if side == "upper" else -1]
|
||||
heights = [node.location.y for branch in branches for node in branch]
|
||||
fields = []
|
||||
for target in (lane[1], lane[3]):
|
||||
value = node_group.nodes.new("GeometryNodeInputPosition").outputs[0]
|
||||
branch = [value.node]
|
||||
for _ in range(8):
|
||||
node = node_group.nodes.new("ShaderNodeVectorMath")
|
||||
node.operation = "ADD"
|
||||
node_group.links.new(value, node.inputs[0])
|
||||
value = node.outputs["Vector"]
|
||||
branch.append(node)
|
||||
node_group.links.new(value, target.inputs["Offset"])
|
||||
fields.append(branch)
|
||||
program = _program(node_group)
|
||||
arrange_nodes(node_group)
|
||||
assert _program(node_group) == program
|
||||
assert [node.location.y for branch in branches for node in branch] == heights
|
||||
boxes = [(min(node.location.x for node in branch),
|
||||
max(node.location.x + estimate_node_dimensions(node).width for node in branch),
|
||||
max(node.location.y for node in branch),
|
||||
min(node.location.y - estimate_node_dimensions(node).height for node in branch)) for branch in fields]
|
||||
core, _ = geometry_paths(node_group)
|
||||
core_left = min(node.location.x for node in core)
|
||||
for _, right, top, bottom in boxes:
|
||||
if right + BRANCH_GAP <= core_left + 0.001:
|
||||
continue
|
||||
if side == "upper":
|
||||
assert bottom >= lane[0].location.y + BRANCH_GAP - 0.001
|
||||
else:
|
||||
assert top <= lane[0].location.y - estimate_node_dimensions(lane[0]).height - BRANCH_GAP + 0.001
|
||||
first, second = boxes
|
||||
assert (first[1] + BRANCH_GAP <= second[0] + 0.001 or second[1] + BRANCH_GAP <= first[0] + 0.001
|
||||
or first[3] >= second[2] + BRANCH_GAP - 0.001 or second[3] >= first[2] + BRANCH_GAP - 0.001)
|
||||
if side == "lower":
|
||||
assert any(right + BRANCH_GAP <= core_left + 0.001 for _, right, _, _ in boxes)
|
||||
for branch, target, other in zip(fields, (lane[1], lane[3]), reversed(boxes)):
|
||||
terminal = branch[-1]
|
||||
assert terminal.location.x + terminal.width < target.location.x
|
||||
socket = terminal.outputs["Vector"]
|
||||
x1 = terminal.location.x + terminal.width
|
||||
y1 = terminal.location.y - estimate_node_dimensions(terminal).sockets[socket]
|
||||
x2 = target.location.x
|
||||
y2 = target.location.y - estimate_node_dimensions(target).sockets[target.inputs["Offset"]]
|
||||
bend = max(40, (x2 - x1) * 0.42)
|
||||
t = np.linspace(0, 1, 200)
|
||||
x = (1-t)**3*x1 + 3*(1-t)**2*t*(x1+bend) + 3*(1-t)*t**2*(x2-bend) + t**3*x2
|
||||
y = (1-t)**3*y1 + 3*(1-t)**2*t*y1 + 3*(1-t)*t**2*y2 + t**3*y2
|
||||
left, right, top, bottom = other
|
||||
assert not np.any((left < x) & (x < right) & (bottom < y) & (y < top))
|
||||
|
||||
|
||||
def test_boolean_operands_follow_socket_order_and_center_the_junction():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
node_group = bpy.data.node_groups.new("Boolean Operands", "GeometryNodeTree")
|
||||
node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
|
||||
comparisons = [node_group.nodes.new("FunctionNodeCompare") for _ in range(2)]
|
||||
operation = node_group.nodes.new("FunctionNodeBooleanMath")
|
||||
operation.operation = "OR"
|
||||
grid = node_group.nodes.new("GeometryNodeMeshGrid")
|
||||
output = node_group.nodes.new("NodeGroupOutput")
|
||||
for source, socket in zip(comparisons, operation.inputs):
|
||||
node_group.links.new(source.outputs[0], socket)
|
||||
node_group.links.new(operation.outputs[0], grid.inputs["Size X"])
|
||||
node_group.links.new(grid.outputs["Mesh"], output.inputs["Geometry"])
|
||||
program = _program(node_group)
|
||||
arrange_nodes(node_group)
|
||||
assert _program(node_group) == program
|
||||
levels = [node.location.y - estimate_node_dimensions(node).sockets[node.outputs[0]] for node in comparisons]
|
||||
assert levels[0] > levels[1]
|
||||
assert comparisons[0].location.x + comparisons[0].width == comparisons[1].location.x + comparisons[1].width
|
||||
assert operation.location.y - estimate_node_dimensions(operation).sockets[operation.outputs[0]] == pytest.approx(sum(levels) / 2)
|
||||
|
||||
|
||||
def test_preview_is_read_only_and_escapes_node_names(tmp_path):
|
||||
from tools.nodes.preview.export import write_node_preview
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
node_group = build_image_plane_group()
|
||||
arrange_nodes(node_group)
|
||||
node_group.nodes[0].name = '</script><script>alert("node")</script>'
|
||||
program = _program(node_group)
|
||||
positions = {node.name: tuple(node.location) for node in node_group.nodes}
|
||||
destination = write_node_preview([node_group], tmp_path / "nodes.html")
|
||||
markup = destination.read_text(encoding="utf-8")
|
||||
assert markup.startswith("<!doctype html>")
|
||||
assert '__NODE_DATA__' not in markup
|
||||
assert '<script>alert("node")</script>' not in markup
|
||||
assert '\\u003c/script>' in markup
|
||||
assert _program(node_group) == program
|
||||
assert {node.name: tuple(node.location) for node in node_group.nodes} == positions
|
||||
|
||||
|
||||
def test_geometry_bypass_has_two_level_corners_next_to_processing():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
node_group = bpy.data.node_groups.new("Geometry Bypass", "GeometryNodeTree")
|
||||
node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
|
||||
source = node_group.nodes.new("GeometryNodeMeshGrid")
|
||||
transform = node_group.nodes.new("GeometryNodeTransform")
|
||||
switch = node_group.nodes.new("GeometryNodeSwitch")
|
||||
switch.input_type = "GEOMETRY"
|
||||
output = node_group.nodes.new("NodeGroupOutput")
|
||||
node_group.links.new(source.outputs["Mesh"], transform.inputs["Geometry"])
|
||||
node_group.links.new(source.outputs["Mesh"], switch.inputs["False"])
|
||||
node_group.links.new(transform.outputs["Geometry"], switch.inputs["True"])
|
||||
node_group.links.new(switch.outputs["Output"], output.inputs["Geometry"])
|
||||
program = _program(node_group)
|
||||
arrange_nodes(node_group)
|
||||
exit_port = switch.inputs["False"].links[0].from_node
|
||||
entry_port = exit_port.inputs[0].links[0].from_node
|
||||
assert entry_port.bl_idname == exit_port.bl_idname == "NodeReroute"
|
||||
assert entry_port.location.y == exit_port.location.y
|
||||
assert source.location.x + source.width < entry_port.location.x < exit_port.location.x < switch.location.x
|
||||
assert exit_port.location.x + 16 == pytest.approx(transform.location.x + transform.width)
|
||||
assert _program(node_group) == program
|
||||
|
||||
|
||||
@pytest.mark.parametrize("zone_type", ["Repeat", "Simulation", "ForeachGeometryElement"])
|
||||
def test_preview_zone_contains_body_and_excludes_shared_external_sources(zone_type):
|
||||
from tools.nodes.preview.export import _node_group_data
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
node_group = bpy.data.node_groups.new("Zone Preview", "GeometryNodeTree")
|
||||
source = node_group.nodes.new("GeometryNodeMeshGrid")
|
||||
entry = node_group.nodes.new(f"GeometryNode{zone_type}Input")
|
||||
end = node_group.nodes.new(f"GeometryNode{zone_type}Output")
|
||||
entry.pair_with_output(end)
|
||||
body = node_group.nodes.new("GeometryNodeSetPosition")
|
||||
node_group.links.new(source.outputs["Mesh"], entry.inputs["Geometry"])
|
||||
geometry = next(socket for socket in entry.outputs if socket.type == "GEOMETRY")
|
||||
node_group.links.new(geometry, body.inputs["Geometry"])
|
||||
geometry_input = next(socket for socket in end.inputs if socket.type == "GEOMETRY")
|
||||
node_group.links.new(body.outputs["Geometry"], geometry_input)
|
||||
entry.location, body.location, end.location = (0, 0), (300, 0), (700, 0)
|
||||
data = _node_group_data(node_group)
|
||||
zone = data["zones"][0]
|
||||
names = {data["nodes"][index]["name"] for index in zone["nodes"]}
|
||||
assert names == {entry.name, body.name, end.name}
|
||||
for index in zone["nodes"]:
|
||||
node = data["nodes"][index]
|
||||
assert zone["x"] <= node["x"] and node["x"] + node["w"] <= zone["x"] + zone["w"]
|
||||
assert zone["y"] <= node["y"] and node["y"] + node["h"] <= zone["y"] + zone["h"]
|
||||
|
||||
|
||||
def test_repeat_geometry_collector_stays_in_the_local_body():
|
||||
from tools.nodes.arrangement.zones import node_zones
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
node_group = CUTOUT_BUILDERS["SOLID"]()
|
||||
arrange_nodes(node_group)
|
||||
entry = node_group.nodes["Repeat Input"]
|
||||
end = entry.paired_output
|
||||
collector = node_group.nodes["Join Geometry.001"]
|
||||
assert collector in next(members for start, _, members in node_zones(node_group) if start == entry)
|
||||
assert entry.location.x < collector.location.x < end.location.x
|
||||
assert abs(collector.location.y - entry.location.y) <= FIELD_HEIGHT + BRANCH_GAP
|
||||
assert entry.location.y == end.location.y
|
||||
|
||||
|
||||
def test_shared_geometry_uses_one_forward_trunk_with_late_branches():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
node_group = bpy.data.node_groups.new("Shared Geometry References", "GeometryNodeTree")
|
||||
node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
|
||||
shared = node_group.nodes.new("GeometryNodeMeshGrid")
|
||||
geometry = node_group.nodes.new("GeometryNodeMeshGrid").outputs["Mesh"]
|
||||
samplers = []
|
||||
for index in range(10):
|
||||
node = node_group.nodes.new("GeometryNodeSetPosition")
|
||||
node_group.links.new(geometry, node.inputs["Geometry"])
|
||||
geometry = node.outputs["Geometry"]
|
||||
if index in (0, 5, 9):
|
||||
sample = node_group.nodes.new("GeometryNodeSampleIndex")
|
||||
sample.data_type = "FLOAT_VECTOR"
|
||||
node_group.links.new(shared.outputs["Mesh"], sample.inputs["Geometry"])
|
||||
node_group.links.new(sample.outputs["Value"], node.inputs["Offset"])
|
||||
samplers.append(sample)
|
||||
output = node_group.nodes.new("NodeGroupOutput")
|
||||
node_group.links.new(geometry, output.inputs["Geometry"])
|
||||
program = _program(node_group)
|
||||
arrange_nodes(node_group)
|
||||
for sample in samplers[1:]:
|
||||
branch = sample.inputs["Geometry"].links[0].from_node
|
||||
assert branch.bl_idname == "NodeReroute"
|
||||
assert branch.location.x > shared.location.x + (sample.location.x - shared.location.x) / 2
|
||||
assert any(node.bl_idname == "NodeReroute" and len(node.outputs[0].links) > 1
|
||||
for node in node_group.nodes if node.inputs and node.inputs[0].is_linked
|
||||
and origin_socket(node.inputs[0].links[0].from_socket).node == shared)
|
||||
assert _program(node_group) == program
|
||||
|
||||
|
||||
def test_reused_geometry_processing_keeps_its_upstream_result_path():
|
||||
from tools.nodes.arrangement.flow import node_roles
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
node_group = bpy.data.node_groups.new("Reused Processing", "GeometryNodeTree")
|
||||
node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
|
||||
grid = node_group.nodes.new("GeometryNodeMeshGrid")
|
||||
transform = node_group.nodes.new("GeometryNodeTransform")
|
||||
position = node_group.nodes.new("GeometryNodeSetPosition")
|
||||
output = node_group.nodes.new("NodeGroupOutput")
|
||||
node_group.links.new(grid.outputs["Mesh"], transform.inputs["Geometry"])
|
||||
node_group.links.new(transform.outputs["Geometry"], position.inputs["Geometry"])
|
||||
node_group.links.new(position.outputs["Geometry"], output.inputs["Geometry"])
|
||||
for name in ("Selection", "Offset"):
|
||||
separate = node_group.nodes.new("GeometryNodeSeparateGeometry")
|
||||
sample = node_group.nodes.new("GeometryNodeSampleIndex")
|
||||
node_group.links.new(transform.outputs["Geometry"], separate.inputs["Geometry"])
|
||||
node_group.links.new(separate.outputs["Selection"], sample.inputs["Geometry"])
|
||||
node_group.links.new(sample.outputs["Value"], position.inputs[name])
|
||||
arrange_nodes(node_group)
|
||||
roles = node_roles(node_group)
|
||||
assert all(roles[node] == "geometry" for node in (grid, transform, position, output))
|
||||
assert grid.location.y == transform.location.y == position.location.y == output.location.y
|
||||
assert all(roles[node] == "field" for node in node_group.nodes
|
||||
if node.bl_idname in {"GeometryNodeSampleIndex", "GeometryNodeSeparateGeometry"})
|
||||
|
||||
|
||||
def test_geometry_preparation_shared_by_independent_paths_becomes_a_public_input():
|
||||
from tools.nodes.arrangement.flow import node_roles
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
node_group = bpy.data.node_groups.new("Public Prepared Geometry", "GeometryNodeTree")
|
||||
node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
|
||||
grid = node_group.nodes.new("GeometryNodeMeshGrid")
|
||||
prepare = node_group.nodes.new("GeometryNodeTransform")
|
||||
join = node_group.nodes.new("GeometryNodeJoinGeometry")
|
||||
output = node_group.nodes.new("NodeGroupOutput")
|
||||
node_group.links.new(grid.outputs["Mesh"], prepare.inputs["Geometry"])
|
||||
branches = []
|
||||
for _ in range(3):
|
||||
node = node_group.nodes.new("GeometryNodeSetPosition")
|
||||
node_group.links.new(prepare.outputs["Geometry"], node.inputs["Geometry"])
|
||||
node_group.links.new(node.outputs["Geometry"], join.inputs["Geometry"])
|
||||
branches.append(node)
|
||||
node_group.links.new(join.outputs["Geometry"], output.inputs["Geometry"])
|
||||
program = _program(node_group)
|
||||
arrange_nodes(node_group)
|
||||
assert _program(node_group) == program
|
||||
roles = node_roles(node_group)
|
||||
assert roles[grid] == roles[prepare] == "field"
|
||||
assert all(roles[node] == "geometry" for node in branches)
|
||||
assert prepare.location.x + prepare.width < min(node.location.x for node in branches)
|
||||
|
||||
|
||||
def test_repeat_field_connectors_reserve_horizontal_run_at_both_ends():
|
||||
from tools.nodes.arrangement.connections import link_endpoints
|
||||
from tools.nodes.arrangement.zones import node_zones
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
node_group = CUTOUT_BUILDERS["SOLID"]()
|
||||
arrange_nodes(node_group)
|
||||
core, _ = geometry_paths(node_group)
|
||||
sizes = {node: estimate_node_dimensions(node) for node in node_group.nodes}
|
||||
positions = {node: tuple(node.location) for node in node_group.nodes}
|
||||
checked = 0
|
||||
for _, _, members in node_zones(node_group):
|
||||
for link in node_group.links:
|
||||
if link.from_node not in members or link.to_node not in members:
|
||||
continue
|
||||
if (link.from_node in core) == (link.to_node in core):
|
||||
continue
|
||||
start, end = link_endpoints(link, positions, sizes)
|
||||
assert end[0] - start[0] >= abs(end[1] - start[1]) * 0.8 - 0.01, (link.from_node.name, link.to_node.name)
|
||||
checked += 1
|
||||
assert checked > 0
|
||||
entry = node_group.nodes["Repeat Input.001"]
|
||||
end = entry.paired_output
|
||||
assert end.location.x - entry.location.x < 6500
|
||||
source = entry.outputs["Geometry"]
|
||||
assert len(source.links) == 1
|
||||
branch = source.links[0].to_node
|
||||
while branch.bl_idname == "NodeReroute" and len(branch.outputs[0].links) == 1:
|
||||
branch = branch.outputs[0].links[0].to_node
|
||||
assert branch.bl_idname == "NodeReroute"
|
||||
assert len(branch.outputs[0].links) > 1
|
||||
deliveries = [link for link in branch.outputs[0].links
|
||||
if link.to_node not in core and link.to_node.bl_idname != "NodeReroute"]
|
||||
assert deliveries
|
||||
for link in deliveries:
|
||||
start, target = link_endpoints(link, positions, sizes)
|
||||
assert target[0] - start[0] == pytest.approx(max(ROUTE_RUN, abs(target[1] - start[1]) * 0.8), abs=0.01)
|
||||
for port in core:
|
||||
if not port.get("arrangement_input") or port.outputs[0].type != "GEOMETRY":
|
||||
continue
|
||||
source = origin_socket(port.outputs[0]).node
|
||||
if source not in core:
|
||||
continue
|
||||
left = source.location.x + sizes[source].width
|
||||
right, level = port.location.x + 8, port.location.y - 8
|
||||
for node in node_group.nodes:
|
||||
if node in core or node.bl_idname == "NodeReroute":
|
||||
continue
|
||||
if left < node.location.x + sizes[node].width and node.location.x < right:
|
||||
assert not node.location.y - sizes[node].height < level < node.location.y, node.name
|
||||
for link in node_group.links:
|
||||
if link.from_socket.type != "GEOMETRY":
|
||||
continue
|
||||
start, target = link_endpoints(link, positions, sizes)
|
||||
if abs(start[1] - target[1]) > 0.01:
|
||||
continue
|
||||
for node in node_group.nodes:
|
||||
if node in core or node.bl_idname == "NodeReroute" or node in {link.from_node, link.to_node}:
|
||||
continue
|
||||
if start[0] < node.location.x + sizes[node].width and node.location.x < target[0]:
|
||||
assert not node.location.y - sizes[node].height < start[1] < node.location.y, node.name
|
||||
|
||||
|
||||
def test_expression_skip_links_have_passages_between_intermediate_nodes():
|
||||
from tools.nodes.arrangement.connections import link_endpoints
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
node_group = bpy.data.node_groups.new("Expression Passages", "GeometryNodeTree")
|
||||
node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
|
||||
grid = node_group.nodes.new("GeometryNodeMeshGrid")
|
||||
position = node_group.nodes.new("GeometryNodeSetPosition")
|
||||
output = node_group.nodes.new("NodeGroupOutput")
|
||||
seed = node_group.nodes.new("GeometryNodeInputIndex")
|
||||
previous = node_group.nodes.new("GeometryNodeInputMeshFaceArea").outputs[0]
|
||||
fields = [seed, previous.node]
|
||||
for _ in range(7):
|
||||
operation = node_group.nodes.new("ShaderNodeMath")
|
||||
operation.operation = "ADD"
|
||||
node_group.links.new(previous, operation.inputs[0])
|
||||
node_group.links.new(seed.outputs[0], operation.inputs[1])
|
||||
previous = operation.outputs[0]
|
||||
fields.append(operation)
|
||||
node_group.links.new(grid.outputs["Mesh"], position.inputs["Geometry"])
|
||||
node_group.links.new(previous, position.inputs["Offset"])
|
||||
node_group.links.new(position.outputs["Geometry"], output.inputs["Geometry"])
|
||||
program = _program(node_group)
|
||||
arrange_nodes(node_group)
|
||||
assert _program(node_group) == program
|
||||
sizes = {node: estimate_node_dimensions(node) for node in node_group.nodes}
|
||||
positions = {node: tuple(node.location) for node in node_group.nodes}
|
||||
assert any(node.get("arrangement_branch") for node in node_group.nodes)
|
||||
for link in node_group.links:
|
||||
if origin_socket(link.from_socket).node not in fields:
|
||||
continue
|
||||
(x1, y1), (x2, y2) = link_endpoints(link, positions, sizes)
|
||||
bend = max(40, abs(x2 - x1) * 0.42)
|
||||
for t in np.linspace(0, 1, 100):
|
||||
x = (1-t)**3*x1 + 3*(1-t)**2*t*(x1+bend) + 3*(1-t)*t*t*(x2-bend) + t**3*x2
|
||||
y = (1-t)**3*y1 + 3*(1-t)**2*t*y1 + 3*(1-t)*t*t*y2 + t**3*y2
|
||||
for node in fields:
|
||||
if node in {link.from_node, link.to_node}:
|
||||
continue
|
||||
left, top = positions[node]
|
||||
assert not (left < x < left + sizes[node].width and top - sizes[node].height < y < top), node.name
|
||||
88
tests/tools/nodes/test_cli.py
Normal file
88
tests/tools/nodes/test_cli.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from tests.support.paths import PROJECT_ROOT
|
||||
import importlib.util
|
||||
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_blender_path_prefers_explicit_path(tmp_path):
|
||||
launcher = load_launcher()
|
||||
executable = tmp_path / "blender"
|
||||
executable.touch()
|
||||
|
||||
with patch.dict("os.environ", {"BLENDER_BIN": "ignored"}):
|
||||
assert launcher.blender_path(executable) == executable
|
||||
|
||||
|
||||
def test_blender_path_reads_environment(tmp_path):
|
||||
launcher = load_launcher()
|
||||
executable = tmp_path / "blender"
|
||||
executable.touch()
|
||||
|
||||
with patch.dict("os.environ", {"BLENDER_BIN": str(executable)}):
|
||||
assert launcher.blender_path() == executable
|
||||
|
||||
|
||||
def test_blender_path_reads_project_dotenv(tmp_path):
|
||||
launcher = load_launcher()
|
||||
executable = tmp_path / "Blender 4.5" / "blender.exe"
|
||||
executable.parent.mkdir()
|
||||
executable.touch()
|
||||
env_path = tmp_path / ".env"
|
||||
env_path.write_text(
|
||||
f'BLENDER_BIN="{executable}"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with patch.dict("os.environ", {}, clear=True), patch.object(
|
||||
launcher,
|
||||
"PROJECT_ROOT",
|
||||
tmp_path,
|
||||
):
|
||||
assert launcher.blender_path() == executable
|
||||
|
||||
|
||||
def test_blender_path_reports_missing_configuration(tmp_path):
|
||||
launcher = load_launcher()
|
||||
|
||||
with patch.dict("os.environ", {}, clear=True), patch.object(
|
||||
launcher.shutil,
|
||||
"which",
|
||||
return_value=None,
|
||||
), patch.object(
|
||||
launcher,
|
||||
"PROJECT_ROOT",
|
||||
tmp_path,
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="Blender not found"):
|
||||
launcher.blender_path()
|
||||
|
||||
|
||||
def test_launcher_runs_asset_build_and_validations_before_tests(monkeypatch, tmp_path):
|
||||
launcher = load_launcher()
|
||||
executable = tmp_path / "blender"
|
||||
executable.touch()
|
||||
calls = []
|
||||
monkeypatch.setattr(launcher, "blender_path", lambda _: executable)
|
||||
monkeypatch.setattr(launcher, "run", calls.append)
|
||||
monkeypatch.setattr("sys.argv", ["build_nodes"])
|
||||
|
||||
launcher.main()
|
||||
|
||||
assert len(calls) == 3
|
||||
assert "runpy.run_module('tools.nodes.build', run_name='__main__')" in calls[0][-1]
|
||||
assert "runpy.run_module('tools.nodes.check', run_name='__main__')" in calls[1][-1]
|
||||
assert all(command[1:6] == ("--factory-startup", "--background", "--python-exit-code", "1", "--python-expr") for command in calls[:-1])
|
||||
assert calls[-1][1:] == ("-m", "pytest")
|
||||
85
tests/tools/nodes/test_cutouts.py
Normal file
85
tests/tools/nodes/test_cutouts.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from collections import Counter
|
||||
|
||||
import bpy
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from tests.support.nodes import CUTOUT_BUILDERS
|
||||
|
||||
from anyimage.operators.cutout_tool.object import load_cutout_node_group
|
||||
|
||||
from anyimage.common.object import modifier_input_identifier, set_modifier_input
|
||||
|
||||
from tests.support.nodes import validate_node_structure
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["definition", "asset"])
|
||||
def test_cutout_interfaces_and_thickness_modes(source):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
groups = {shape: (CUTOUT_BUILDERS[shape]() if source == "definition" else load_cutout_node_group(shape)) for shape in ("FLAT", "SOLID", "DEPTH_SOLID", "DEPTH_BALLOON")}
|
||||
assert groups["FLAT"] == groups["SOLID"]
|
||||
assert len(set(groups.values())) == 3
|
||||
for group in set(groups.values()):
|
||||
validate_node_structure(group)
|
||||
assert group.is_modifier and group.get("anyimage_asset_revision") == 11
|
||||
assert not any(node.bl_idname == "GeometryNodeGroup" for node in group.nodes)
|
||||
inputs = {item.name: item for item in group.interface.items_tree if item.item_type == "SOCKET" and item.in_out == "INPUT"}
|
||||
assert "Shape" not in inputs and "Rim Thickness" not in inputs
|
||||
assert next(s for s in group.interface.items_tree if s.name == "Thickness").default_value == 1
|
||||
assert all(item.structure_type == "SINGLE" for item in inputs.values())
|
||||
if "Mode" in inputs:
|
||||
assert inputs["Thickness"].subtype == "DISTANCE"
|
||||
assert inputs["Mode"].default_value == "Balloon"
|
||||
if "Double Sided" in inputs:
|
||||
assert inputs["Double Sided"].default_value
|
||||
if group == groups["DEPTH_SOLID"]:
|
||||
assert np.isclose(inputs["Edge Round"].default_value, 0.2)
|
||||
assert inputs["Edge Round"].parent.name == "Options"
|
||||
assert list(inputs)[list(inputs).index("Front Inflation") - 1] == "Edge Round"
|
||||
assert inputs["Normal Smooth"].default_value == 50
|
||||
assert inputs["Front Inflation"].default_value == 1
|
||||
assert inputs["Front Inflation"].parent.name == "Options"
|
||||
assert not set(inputs) & {"Edge Round Range", "Back Rim Range", "Normal Influence", "Edge Round Amount", "Back Rim Fullness"}
|
||||
options = [s.name for s in inputs.values() if s.parent and s.parent.name == "Options"]
|
||||
expected = ["Cleanup Threshold"]
|
||||
if group == groups["DEPTH_SOLID"]:
|
||||
expected += ["Split Threshold", "Reference Depth", "Edge Round", "Front Inflation", "Normal Smooth"]
|
||||
elif group == groups["DEPTH_BALLOON"]:
|
||||
expected += ["Reference Depth"]
|
||||
expected += ["Smooth Weight"]
|
||||
if group == groups["DEPTH_BALLOON"]:
|
||||
expected += ["Depth Direction"]
|
||||
assert options == expected, (group.name, options)
|
||||
assert inputs["Smooth"].socket_type == "NodeSocketInt" and inputs["Smooth"].default_value == 0
|
||||
assert inputs["Smooth Weight"].default_value == 1
|
||||
incoming = [link for link in group.links if link.from_node.bl_idname == "NodeGroupInput" and link.from_socket.name == "Geometry"]
|
||||
assert len(incoming) == 1 and incoming[0].to_node.bl_idname == "GeometryNodeDeleteGeometry"
|
||||
mesh = bpy.data.meshes.new("Profile")
|
||||
mesh.from_pydata([(-1,-1,0), (1,-1,0), (1,1,0), (-1,1,0), (0,0,0)], [], [(0,1,4), (1,2,4), (2,3,4), (3,0,4)])
|
||||
profile = mesh.attributes.new("o_balloon", "FLOAT", "POINT")
|
||||
profile.data.foreach_set("value", [0,0,0,0,0.6])
|
||||
obj = bpy.data.objects.new("Profile", mesh)
|
||||
bpy.context.collection.objects.link(obj)
|
||||
modifier = obj.modifiers.new("Cutout", "NODES")
|
||||
modifier.node_group = groups["SOLID"]
|
||||
for mode, thickness in ((0, 0), (0, 1), (1, 0), (1, 0.4)):
|
||||
for name, value in (("Mode", mode), ("Thickness", thickness)):
|
||||
subtype = ("DISTANCE" if mode else "NONE") if name == "Thickness" else None
|
||||
set_modifier_input(modifier, modifier_input_identifier(modifier.node_group, name, subtype=subtype), value)
|
||||
obj.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
result = evaluated.to_mesh()
|
||||
try:
|
||||
z = np.array([vertex.co.z for vertex in result.vertices])
|
||||
assert np.isfinite(z).all() and len(z)
|
||||
if thickness == 0:
|
||||
assert len(result.polygons) == 4 and np.max(abs(z)) < 1e-7
|
||||
assert all(abs(item.value - 1) < 1e-7 for item in result.attributes["o_normal_scale"].data)
|
||||
else:
|
||||
radius = 0.6 if mode == 0 else 0.2
|
||||
assert np.isclose(z.min(), -radius) and np.isclose(z.max(), radius)
|
||||
uses = Counter(tuple(sorted((a,b))) for face in result.polygons for a,b in zip(face.vertices, (*face.vertices[1:],face.vertices[0])))
|
||||
assert set(uses.values()) == {2}
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
246
tests/tools/nodes/test_depth_cutout.py
Normal file
246
tests/tools/nodes/test_depth_cutout.py
Normal file
@@ -0,0 +1,246 @@
|
||||
"""Contracts for smooth depth volume, editable presets and normal masks."""
|
||||
import bpy
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from tools.nodes.groups.image_depth_cutout import build_volume_fields
|
||||
from tools.nodes.groups.image_depth_cutout import build_volume_rounding
|
||||
from anyimage.common.object import modifier_input_identifier, set_modifier_input
|
||||
|
||||
|
||||
@pytest.mark.parametrize("iterations", [0, 50])
|
||||
def test_uniform_thickness_follows_the_tilted_front_normal(iterations):
|
||||
from scipy.spatial import cKDTree
|
||||
from tests.support.depth_surface import surface, evaluated, assert_closed
|
||||
obj, set_value = surface(step=0, resolution=4)
|
||||
image = bpy.data.images["Camera"]
|
||||
pixels = np.array(image.pixels[:], dtype=np.float32).reshape(32, 64, 4)
|
||||
pixels[..., 2] = 1 + 0.4 * pixels[..., 0]
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.update()
|
||||
set_value("Normal Smooth", iterations)
|
||||
front, _ = evaluated(obj)
|
||||
set_value("Thickness", 0.2)
|
||||
points, faces = evaluated(obj)
|
||||
expected = front - 0.2 * np.array((0.4, 0, 1)) / np.sqrt(1.16)
|
||||
assert cKDTree(points).query(expected)[0].max() < 1e-5
|
||||
assert cKDTree(points).query(front)[0].max() < 1e-5
|
||||
assert_closed(faces)
|
||||
|
||||
|
||||
def test_balloon_shell_converges_to_front_with_thickness():
|
||||
from scipy.spatial import cKDTree
|
||||
from tests.support.depth_surface import surface, evaluated, assert_closed
|
||||
obj, set_value = surface(step=1, resolution=10)
|
||||
xy = np.array([v.co[:2] for v in obj.data.vertices])
|
||||
profile = 0.3 * np.sin(np.pi * xy[:, 0] / 2) * np.sin(np.pi * xy[:, 1])
|
||||
profile[abs(profile) < 1e-7] = 0
|
||||
obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile)
|
||||
set_value("Mode", 0)
|
||||
set_value("Edge Round", 0.0)
|
||||
set_value("Normal Smooth", 0)
|
||||
modifier = obj.modifiers[0]
|
||||
thickness = modifier_input_identifier(modifier.node_group, "Thickness", subtype="NONE")
|
||||
def amount(value):
|
||||
set_modifier_input(modifier, thickness, value)
|
||||
obj.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
return evaluated(obj)
|
||||
front, _ = amount(0)
|
||||
tree = cKDTree(front)
|
||||
gaps = []
|
||||
for value in (0.01, 0.001, 0.0001):
|
||||
points, faces = amount(value)
|
||||
assert_closed(faces)
|
||||
gaps.append(tree.query(points)[0].max())
|
||||
np.testing.assert_allclose(np.array(gaps[1:]) / gaps[:-1], 0.1, rtol=0.02)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("amount", [0.0, 1.0])
|
||||
def test_rounding_shares_front_and_back_falloff(amount):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
n = 15
|
||||
xy = np.array([(x, y) for y in np.linspace(-1, 1, n) for x in np.linspace(-1, 1, n)])
|
||||
balloon = 0.4 * np.maximum(0, (1 - xy[:, 0]**2) * (1 - xy[:, 1]**2))
|
||||
depth = 0.2 * xy[:, 0] + 0.3 * xy[:, 1] + 1.4 * balloon
|
||||
faces = [(y*n+x, y*n+x+1, (y+1)*n+x+1, (y+1)*n+x) for y in range(n-1) for x in range(n-1)]
|
||||
mesh = bpy.data.meshes.new("Round profile")
|
||||
mesh.from_pydata(np.column_stack((xy, depth)), [], faces)
|
||||
mesh.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", balloon)
|
||||
obj = bpy.data.objects.new("Round profile", mesh)
|
||||
bpy.context.collection.objects.link(obj)
|
||||
group = bpy.data.node_groups.new("Round field test", "GeometryNodeTree")
|
||||
for name, kind, default in (("Geometry", "NodeSocketGeometry", None), ("Thickness", "NodeSocketFloat", 1), ("Edge Round", "NodeSocketFloat", amount), ("Normal Smooth", "NodeSocketInt", 20)):
|
||||
socket = group.interface.new_socket(name=name, in_out="INPUT", socket_type=kind)
|
||||
if default is not None:
|
||||
socket.default_value = default
|
||||
group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
|
||||
source = group.nodes.new("NodeGroupInput")
|
||||
result = build_volume_fields(group, source.outputs["Geometry"])
|
||||
result = build_volume_rounding(group, result)
|
||||
group.links.new(result, group.nodes.new("NodeGroupOutput").inputs["Geometry"])
|
||||
obj.modifiers.new("Round", "NODES").node_group = group
|
||||
bpy.context.view_layer.update()
|
||||
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
result = evaluated.to_mesh()
|
||||
try:
|
||||
def values(name):
|
||||
return np.array([v.value for v in result.attributes["o_balloon_surface_" + name].data])
|
||||
center = values("center")
|
||||
u = balloon / balloon.max()
|
||||
weight = amount * (1 - u**3 * (u * (6*u - 15) + 10))
|
||||
delta = (center + balloon - depth) * weight
|
||||
original = np.maximum(depth - center + balloon, 0.05 * balloon)
|
||||
np.testing.assert_allclose(values("edge_weight"), weight, atol=1e-6)
|
||||
np.testing.assert_allclose(values("thickness"), original + delta + balloon * weight, atol=1e-6)
|
||||
np.testing.assert_allclose([v.co.z for v in result.vertices], depth + delta, atol=1e-6)
|
||||
normals = np.array([v.vector[:] for v in result.attributes["o_balloon_surface_normal"].data])
|
||||
np.testing.assert_allclose(np.linalg.norm(normals, axis=1), 1, atol=1e-6)
|
||||
assert np.max(abs(normals[:, :2])) > 0.1
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
|
||||
def test_smooth_back_rejects_front_noise_and_preserves_center_depth():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
n = 41
|
||||
points = np.array([(x, y) for y in np.linspace(-1, 1, n) for x in np.linspace(-1, 1, n)])
|
||||
profile = np.sqrt(np.maximum(0, np.cos(points[:, 0]*np.pi/2)*np.cos(points[:, 1]*np.pi/2))) * 0.6
|
||||
boundary = np.max(abs(points), axis=1) > 0.999
|
||||
profile[boundary] = 0
|
||||
center = 0.3*points[:, 0] + 0.6*points[:, 1]
|
||||
noise = 0.08*np.sin(points[:, 0]*23)*np.sin(points[:, 1]*19)
|
||||
noise[boundary] = 0
|
||||
faces = [(y*n+x, y*n+x+1, (y+1)*n+x+1) for y in range(n-1) for x in range(n-1)]
|
||||
faces += [(y*n+x, (y+1)*n+x+1, (y+1)*n+x) for y in range(n-1) for x in range(n-1)]
|
||||
mesh = bpy.data.meshes.new("Noisy front")
|
||||
mesh.from_pydata(np.column_stack((points, center + profile + noise)), [], faces)
|
||||
mesh.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile.astype(np.float32))
|
||||
obj = bpy.data.objects.new("Noisy front", mesh)
|
||||
bpy.context.collection.objects.link(obj)
|
||||
group = bpy.data.node_groups.new("Volume field test", "GeometryNodeTree")
|
||||
for name, kind in (("Geometry", "NodeSocketGeometry"), ("Thickness", "NodeSocketFloat")):
|
||||
socket = group.interface.new_socket(name=name, in_out="INPUT", socket_type=kind)
|
||||
if name == "Thickness":
|
||||
socket.default_value = 1
|
||||
group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
|
||||
source = group.nodes.new("NodeGroupInput")
|
||||
result = build_volume_fields(group, source.outputs["Geometry"])
|
||||
group.links.new(result, group.nodes.new("NodeGroupOutput").inputs["Geometry"])
|
||||
obj.modifiers.new("Volume", "NODES").node_group = group
|
||||
bpy.context.view_layer.update()
|
||||
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
result = evaluated.to_mesh()
|
||||
try:
|
||||
thickness = np.array([item.value for item in result.attributes["o_balloon_surface_thickness"].data])
|
||||
rear = center + profile + noise - thickness
|
||||
error = rear - (center - profile)
|
||||
assert max(abs(error)) < 1e-5
|
||||
assert np.sqrt(np.mean(error**2)) < np.sqrt(np.mean(noise**2))*0.01
|
||||
assert max(abs(thickness[boundary])) == 0
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
|
||||
def test_depth_cutout_normal_map_only_affects_front_faces():
|
||||
from tests.support.depth_surface import surface
|
||||
obj, set_value = surface()
|
||||
set_value("Thickness", 0.3)
|
||||
bpy.context.view_layer.update()
|
||||
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
mesh = evaluated.to_mesh()
|
||||
try:
|
||||
attribute = mesh.attributes["o_normal_scale"]
|
||||
assert attribute.domain == "FACE"
|
||||
weights = [item.value for item in attribute.data]
|
||||
assert weights.count(1) == len(obj.data.polygons)
|
||||
assert weights.count(0) == len(mesh.polygons) - len(obj.data.polygons)
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("structure", [0, 1, 4])
|
||||
@pytest.mark.parametrize("thickness", [0.0, 0.02, 0.1])
|
||||
def test_uniform_front_inflation_scales_with_distance(thickness, structure):
|
||||
from tests.support.depth_surface import surface, evaluated, assert_closed
|
||||
obj, set_value = surface(step=0, resolution=10)
|
||||
xy = np.array([v.co[:2] for v in obj.data.vertices])
|
||||
profile = 0.3 * np.sin(np.pi * xy[:, 0] / 2) * np.sin(np.pi * xy[:, 1])
|
||||
profile[abs(profile) < 1e-7] = 0
|
||||
obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile)
|
||||
group = obj.modifiers[0].node_group
|
||||
store = next(n for n in group.nodes if n.bl_idname == "GeometryNodeStoreNamedAttribute" and n.inputs["Name"].default_value == "o_balloon_surface_structure")
|
||||
for link in list(store.inputs["Value"].links):
|
||||
group.links.remove(link)
|
||||
store.inputs["Value"].default_value = structure
|
||||
set_value("Thickness", thickness)
|
||||
set_value("Front Inflation", 0.0)
|
||||
baseline, faces = evaluated(obj)
|
||||
set_value("Front Inflation", 1.0)
|
||||
inflated, inflated_faces = evaluated(obj)
|
||||
assert faces == inflated_faces
|
||||
delta = np.linalg.norm(inflated - baseline, axis=1)
|
||||
front = np.isclose(baseline[:, 2], baseline[:, 2].max(), atol=1e-6)
|
||||
np.testing.assert_allclose(delta[front], thickness, atol=2e-6)
|
||||
np.testing.assert_allclose(delta[~front], 0, atol=2e-6)
|
||||
if thickness:
|
||||
assert_closed(inflated_faces)
|
||||
else:
|
||||
np.testing.assert_array_equal(inflated, baseline)
|
||||
|
||||
|
||||
def test_normal_smooth_controls_front_and_rear_directions():
|
||||
from tests.support.depth_surface import surface, evaluated
|
||||
obj, set_value = surface(step=1, resolution=10)
|
||||
set_value("Front Inflation", 1.0)
|
||||
xy = np.array([v.co[:2] for v in obj.data.vertices])
|
||||
profile = 0.3 * np.sin(np.pi * xy[:, 0] / 2) * np.sin(np.pi * xy[:, 1])
|
||||
profile[abs(profile) < 1e-7] = 0
|
||||
obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile)
|
||||
group = obj.modifiers[0].node_group
|
||||
set_value("Mode", 0)
|
||||
set_modifier_input(obj.modifiers[0], modifier_input_identifier(group, "Thickness", subtype="NONE"), 1.0)
|
||||
masks = []
|
||||
coordinates = []
|
||||
for iterations in (0, 50):
|
||||
set_value("Normal Smooth", iterations)
|
||||
points, faces = evaluated(obj)
|
||||
ev = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
mesh = ev.to_mesh()
|
||||
mask = [v.value for v in mesh.attributes["o_normal_scale"].data]
|
||||
front = {v for face, weight in zip(faces, mask) if weight > 0.5 for v in face}
|
||||
rear = set(range(len(points))) - front
|
||||
ev.to_mesh_clear()
|
||||
coordinates.append(points)
|
||||
masks.append((front, rear))
|
||||
assert masks[0] == masks[1]
|
||||
for ids in masks[0]:
|
||||
assert np.max(abs(coordinates[1][list(ids)] - coordinates[0][list(ids)])) > 1e-5
|
||||
|
||||
|
||||
def test_balloon_suppression_is_stronger_without_a_startup_threshold():
|
||||
from tests.support.depth_surface import surface, evaluated
|
||||
obj, set_value = surface(step=0, resolution=10)
|
||||
xy = np.array([v.co[:2] for v in obj.data.vertices])
|
||||
profile = 0.3 * np.sin(np.pi * xy[:, 0] / 2) * np.sin(np.pi * xy[:, 1])
|
||||
profile[abs(profile) < 1e-7] = 0
|
||||
obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile)
|
||||
group = obj.modifiers[0].node_group
|
||||
store = next(n for n in group.nodes if n.bl_idname == "GeometryNodeStoreNamedAttribute" and n.inputs["Name"].default_value == "o_balloon_surface_structure")
|
||||
for link in list(store.inputs["Value"].links):
|
||||
group.links.remove(link)
|
||||
store.inputs["Value"].default_value = 1
|
||||
set_value("Mode", 0)
|
||||
set_value("Edge Round", 0)
|
||||
thickness_id = modifier_input_identifier(group, "Thickness", subtype="NONE")
|
||||
previous = 0
|
||||
for thickness in (0.001, 0.01, 0.1, 0.5, 1):
|
||||
set_modifier_input(obj.modifiers[0], thickness_id, thickness)
|
||||
set_value("Front Inflation", 0)
|
||||
baseline, _ = evaluated(obj)
|
||||
set_value("Front Inflation", 1)
|
||||
inflated, _ = evaluated(obj)
|
||||
growth = np.linalg.norm(inflated - baseline, axis=1).max() / profile.max()
|
||||
assert growth > previous
|
||||
previous = growth
|
||||
assert growth == pytest.approx(0.323, abs=0.001)
|
||||
347
tests/tools/nodes/test_depth_relief_planes.py
Normal file
347
tests/tools/nodes/test_depth_relief_planes.py
Normal file
@@ -0,0 +1,347 @@
|
||||
"""Evaluate complete-image projection and rectangular surface topology."""
|
||||
|
||||
from collections import Counter
|
||||
import bpy
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from tests.support.planes import surface, relief, evaluated
|
||||
from tests.support.nodes import validate_node_structure
|
||||
from tools.nodes.arrangement.flow import origin_socket
|
||||
|
||||
|
||||
@pytest.mark.parametrize("subdivide", [0, 1, 3])
|
||||
def test_zero_depth_matches_plane_geometry_and_uv(surface, subdivide):
|
||||
obj, set_value, plane, inputs, _ = surface
|
||||
set_value("Subdivide", subdivide)
|
||||
projected = evaluated(obj)
|
||||
modifier = obj.modifiers[0]
|
||||
modifier.node_group = plane
|
||||
for item in plane.interface.items_tree:
|
||||
if item.item_type == "SOCKET" and item.in_out == "INPUT":
|
||||
if item.name == "Subdivide":
|
||||
modifier[item.identifier] = subdivide
|
||||
elif item.name == "Thickness":
|
||||
modifier[item.identifier] = 0.0
|
||||
obj.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
regular = evaluated(obj)
|
||||
np.testing.assert_allclose(projected[0], regular[0], atol=1e-6)
|
||||
assert projected[1] == regular[1]
|
||||
np.testing.assert_allclose(projected[2], regular[2], atol=1e-6)
|
||||
assert projected[3:5] == regular[3:5]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("strength", [0, 0.5, 1, 2])
|
||||
def test_camera_projection_and_reference_depth(surface, strength):
|
||||
obj, set_value, _, _, _ = surface
|
||||
base = evaluated(obj)
|
||||
uv = (base[0][:, :2] + (2, 1)) / (4, 2)
|
||||
texel = np.clip(uv * (16, 8), (0.5, 0.5), (15.5, 7.5))
|
||||
camera = np.column_stack(((texel[:, 0] - 5) / 8,
|
||||
(texel[:, 1] - 3) / 8,
|
||||
1.2 - (2 + texel[:, 0] / 16) * 0.5))
|
||||
expected = base[0] * (1 - strength) + camera * strength
|
||||
set_value("Depth Scale", float(strength))
|
||||
projected = evaluated(obj)
|
||||
np.testing.assert_allclose(projected[0], expected, atol=1e-6)
|
||||
assert projected[1] == base[1]
|
||||
np.testing.assert_allclose(projected[2], base[2])
|
||||
set_value("Reference Depth", 1.7)
|
||||
shifted = evaluated(obj)[0]
|
||||
np.testing.assert_allclose(shifted - projected[0],
|
||||
np.tile((0, 0, 0.5 * strength), (len(shifted), 1)), atol=1e-6)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("thickness", [0.000001, 0.2, 2.0])
|
||||
def test_shell_keeps_front_and_closes_rectangular_boundary(surface, thickness):
|
||||
obj, set_value, _, _, _ = surface
|
||||
set_value("Depth Scale", 1.0)
|
||||
front = evaluated(obj)[0]
|
||||
set_value("Thickness", thickness)
|
||||
points, faces, uv, materials, indices, attributes = evaluated(obj)
|
||||
assert len(points) == 2 * len(front)
|
||||
edges = Counter(tuple(sorted((a, b))) for f in faces for a, b in zip(f, (*f[1:], f[0])))
|
||||
assert set(edges.values()) == {2}
|
||||
normal = np.array((0.25, 0, 1.0))
|
||||
normal /= np.linalg.norm(normal)
|
||||
expected = np.vstack((front, front - normal * thickness))
|
||||
from scipy.spatial import cKDTree
|
||||
assert cKDTree(points).query(expected)[0].max() < 1e-6
|
||||
assert np.isfinite(points).all() and np.isfinite(uv).all()
|
||||
assert materials[0] == "Image" and set(indices) == {0}
|
||||
assert not any(name.startswith("o_depth_") for name in attributes)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("valid_only", [False, True])
|
||||
def test_depth_split_preserves_faces_and_closes_each_shell(surface, valid_only):
|
||||
from tests.support.depth_surface import component_count
|
||||
|
||||
obj, set_value, _, _, image = surface
|
||||
pixels = np.asarray(image.pixels[:], dtype=np.float32).reshape(8, 16, 4)
|
||||
pixels[:, 8:, 2] += 10
|
||||
pixels[:2, :, 3] = 0
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.update()
|
||||
set_value("Depth Scale", 1.0)
|
||||
set_value("Valid Only", valid_only)
|
||||
base = evaluated(obj)
|
||||
set_value("Split Threshold", 1.0)
|
||||
points, faces, uv, materials, indices, attributes = evaluated(obj)
|
||||
assert len(points) > len(base[0])
|
||||
assert len(faces) == len(base[1])
|
||||
assert component_count(faces) > component_count(base[1])
|
||||
np.testing.assert_allclose(uv, base[2])
|
||||
assert (materials, indices) == base[3:5]
|
||||
assert not any(name.startswith("o_depth_") for name in attributes)
|
||||
set_value("Thickness", 0.2)
|
||||
shell_points, shell_faces, shell_uv, *_ = evaluated(obj)
|
||||
assert len(shell_points) == 2 * len(points)
|
||||
edges = Counter(tuple(sorted((a, b))) for face in shell_faces
|
||||
for a, b in zip(face, (*face[1:], face[0])))
|
||||
assert set(edges.values()) == {2}
|
||||
assert np.isfinite(shell_points).all() and np.isfinite(shell_uv).all()
|
||||
set_value("Thickness", 0.0)
|
||||
set_value("Split Threshold", 0.0)
|
||||
restored = evaluated(obj)
|
||||
np.testing.assert_allclose(restored[0], base[0])
|
||||
assert restored[1] == base[1]
|
||||
|
||||
|
||||
def test_depth_jump_keeps_rectangular_faces(surface):
|
||||
obj, set_value, _, _, image = surface
|
||||
base = evaluated(obj)
|
||||
pixels = np.ones((8, 16, 4), dtype=np.float32)
|
||||
pixels[:, 8:, 2] = 10
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.update()
|
||||
set_value("Depth Scale", 1.0)
|
||||
result = evaluated(obj)
|
||||
assert result[1] == base[1]
|
||||
assert len(result[0]) == len(base[0])
|
||||
assert np.ptp(result[0][:, 2]) > 4
|
||||
|
||||
|
||||
@pytest.mark.parametrize("thickness", [0.0, 0.4])
|
||||
def test_relief_reads_z_and_keeps_the_base(relief, thickness):
|
||||
obj, set_value, _, inputs, image = relief
|
||||
set_value("Thickness", thickness)
|
||||
set_value("Depth Scale", 1.0)
|
||||
set_value("Reference Depth", 2.0)
|
||||
pixels = np.ones((8, 16, 4), dtype=np.float32)
|
||||
pixels[..., 0], pixels[..., 1], pixels[..., 2] = -9, 13, 2
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.update()
|
||||
set_value("Uniform Scale", 0.5)
|
||||
first = evaluated(obj)
|
||||
assert first[0][:, 2].max() == pytest.approx(thickness + 1)
|
||||
assert first[0][:, 2].min() == pytest.approx(0 if thickness else 1)
|
||||
pixels[..., 0], pixels[..., 1] = 31, -22
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.update()
|
||||
set_value("Uniform Scale", 0.5)
|
||||
np.testing.assert_allclose(evaluated(obj)[0], first[0])
|
||||
assert obj.modifiers[0][inputs["Thickness"].identifier] == pytest.approx(thickness)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("normal_smooth", [0, 2, 50])
|
||||
def test_curved_shell_smooths_normals_without_shrinking_thickness(surface, normal_smooth):
|
||||
from scipy.spatial import cKDTree
|
||||
|
||||
obj, set_value, _, _, image = surface
|
||||
yy, xx = np.mgrid[:8, :16]
|
||||
pixels = np.ones((8, 16, 4), dtype=np.float32)
|
||||
pixels[..., 0] = (xx + 0.5 - 8) / 4
|
||||
pixels[..., 1] = (4 - yy - 0.5) / 4
|
||||
pixels[..., 2] = 2 + ((xx + 0.5 - 8) / 8) ** 2 + 0.12 * (xx % 2)
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.update()
|
||||
set_value("Subdivide", 3)
|
||||
set_value("Depth Scale", 1.0)
|
||||
front, faces, *_ = evaluated(obj)
|
||||
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
mesh = result.to_mesh()
|
||||
try:
|
||||
raw_normals = np.array([v.normal[:] for v in mesh.vertices])
|
||||
finally:
|
||||
result.to_mesh_clear()
|
||||
set_value("Normal Smooth", normal_smooth)
|
||||
thickness = 0.001
|
||||
set_value("Thickness", thickness)
|
||||
points = evaluated(obj)[0]
|
||||
distance, source = cKDTree(front).query(points)
|
||||
rear = distance > thickness * 0.5
|
||||
assert rear.sum() == len(front)
|
||||
assert len(set(source[rear])) == len(front)
|
||||
np.testing.assert_allclose(distance[rear], thickness, atol=1e-6)
|
||||
normals = np.empty_like(front)
|
||||
normals[source[rear]] = (front[source[rear]] - points[rear]) / thickness
|
||||
assert np.all(np.sum(normals * raw_normals, axis=1) > 0)
|
||||
edges = np.array(list({tuple(sorted((a, b))) for f in faces
|
||||
for a, b in zip(f, (*f[1:], f[0]))}))
|
||||
roughness = lambda n: np.mean(np.linalg.norm(n[edges[:, 0]] - n[edges[:, 1]], axis=1))
|
||||
if normal_smooth == 0:
|
||||
np.testing.assert_allclose(normals, raw_normals, atol=2e-4)
|
||||
else:
|
||||
assert roughness(normals) < roughness(raw_normals)
|
||||
|
||||
|
||||
def test_surface_interface_and_node_structure(surface):
|
||||
obj, _, _, inputs, _ = surface
|
||||
assert list(inputs) == ["Geometry", "Subdivide", "Thickness", "Depth Scale", "Smooth", "Valid Only",
|
||||
"Reference Depth", "Normal Smooth", "Smooth Weight", "Validity Threshold", "Split Threshold", "Uniform Scale", "Depth Image"]
|
||||
assert inputs["Split Threshold"].default_value == 0
|
||||
assert inputs["Split Threshold"].min_value == 0
|
||||
assert inputs["Split Threshold"].max_value == 1
|
||||
assert inputs["Split Threshold"].subtype == "FACTOR"
|
||||
assert inputs["Split Threshold"].parent.name == "Options"
|
||||
assert inputs["Validity Threshold"].default_value == pytest.approx(0.9)
|
||||
assert inputs["Validity Threshold"].parent.name == "Options"
|
||||
assert inputs["Validity Threshold"].subtype == "FACTOR"
|
||||
assert inputs["Valid Only"].default_value is True
|
||||
assert inputs["Smooth"].default_value == 0
|
||||
assert inputs["Smooth"].min_value == 0 and inputs["Smooth"].max_value == 20
|
||||
assert inputs["Smooth Weight"].default_value == 1
|
||||
assert inputs["Smooth Weight"].subtype == "FACTOR"
|
||||
assert inputs["Smooth Weight"].parent.name == "Options"
|
||||
assert inputs["Normal Smooth"].default_value == 50
|
||||
assert inputs["Normal Smooth"].min_value == 0
|
||||
assert inputs["Normal Smooth"].max_value == 50
|
||||
assert inputs["Normal Smooth"].parent.name == "Options"
|
||||
assert inputs["Thickness"].default_value == 0
|
||||
assert inputs["Subdivide"].default_value == 6
|
||||
assert inputs["Depth Scale"].default_value == 1
|
||||
assert all(item.structure_type == "SINGLE" for item in inputs.values())
|
||||
assert inputs["Thickness"].subtype == inputs["Reference Depth"].subtype == "DISTANCE"
|
||||
assert inputs["Uniform Scale"].hide_in_modifier and inputs["Depth Image"].hide_in_modifier
|
||||
validate_node_structure(obj.modifiers[0].node_group)
|
||||
|
||||
|
||||
def test_relief_interface_and_node_structure(relief):
|
||||
obj, _, _, inputs, _ = relief
|
||||
assert list(inputs) == ["Geometry", "Subdivide", "Thickness", "Depth Scale",
|
||||
"Reference Depth", "Uniform Scale", "Depth Image"]
|
||||
assert inputs["Thickness"].default_value == 0
|
||||
assert inputs["Subdivide"].default_value == 6
|
||||
assert inputs["Depth Scale"].default_value == 1
|
||||
assert all(item.structure_type == "SINGLE" for item in inputs.values())
|
||||
assert inputs["Thickness"].subtype == inputs["Reference Depth"].subtype == "DISTANCE"
|
||||
assert inputs["Uniform Scale"].hide_in_modifier and inputs["Depth Image"].hide_in_modifier
|
||||
validate_node_structure(obj.modifiers[0].node_group)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("threshold", [0.0, 0.4, 0.9, 1.0])
|
||||
@pytest.mark.parametrize("pattern", ["valid", "invalid", "partial", "half", "stripe"])
|
||||
def test_valid_only_recomputes_per_point_and_preserves_boundary_faces(surface, pattern, threshold):
|
||||
obj, set_value, _, inputs, _ = surface
|
||||
set_value("Validity Threshold", threshold)
|
||||
depth_image = obj.modifiers[0][inputs["Depth Image"].identifier]
|
||||
pixels = np.ones((8, 16, 4), dtype=np.float32)
|
||||
alpha = np.ones(16, dtype=np.float32)
|
||||
if pattern == "invalid":
|
||||
alpha[:] = 0
|
||||
elif pattern == "partial":
|
||||
alpha[:] = 0.5
|
||||
elif pattern == "half":
|
||||
alpha[:8] = 0
|
||||
elif pattern == "stripe":
|
||||
alpha[:] = 0
|
||||
alpha[8] = 1
|
||||
pixels[..., 3] = alpha
|
||||
depth_image.pixels.foreach_set(pixels.ravel())
|
||||
depth_image.update()
|
||||
for subdivisions in (0, 2, 5):
|
||||
set_value("Subdivide", subdivisions)
|
||||
set_value("Valid Only", False)
|
||||
base = evaluated(obj)
|
||||
u = (base[0][:, 0] + 2) / 4
|
||||
sampled = np.interp(u * 16 - 0.5, np.arange(16), alpha)
|
||||
expected = [face for face in base[1] if any(sampled[index] >= threshold for index in face)]
|
||||
set_value("Valid Only", True)
|
||||
culled = evaluated(obj)
|
||||
assert len(culled[1]) == len(expected)
|
||||
expected_points = {tuple(base[0][index]) for face in expected for index in face}
|
||||
assert {tuple(point) for point in culled[0]} == expected_points
|
||||
assert np.isfinite(culled[2]).all()
|
||||
if pattern == "half" and subdivisions >= 2 and threshold == 1.0:
|
||||
assert 0 <= min(point[0] for point in culled[0]) < 0.125
|
||||
set_value("Valid Only", False)
|
||||
np.testing.assert_allclose(evaluated(obj)[0], base[0])
|
||||
|
||||
|
||||
def test_valid_only_precedes_closed_shell_generation(surface):
|
||||
obj, set_value, _, inputs, _ = surface
|
||||
depth_image = obj.modifiers[0][inputs["Depth Image"].identifier]
|
||||
pixels = np.asarray(depth_image.pixels[:], dtype=np.float32).reshape(8, 16, 4)
|
||||
pixels[:, :8, 3] = 0
|
||||
depth_image.pixels.foreach_set(pixels.ravel())
|
||||
depth_image.update()
|
||||
set_value("Depth Scale", 1.0)
|
||||
front = evaluated(obj)[0]
|
||||
set_value("Thickness", 0.2)
|
||||
points, faces, uv, *_ = evaluated(obj)
|
||||
assert len(points) == 2 * len(front)
|
||||
edges = Counter(tuple(sorted((a, b))) for face in faces for a, b in zip(face, (*face[1:], face[0])))
|
||||
assert set(edges.values()) == {2}
|
||||
assert np.isfinite(points).all() and np.isfinite(uv).all()
|
||||
|
||||
|
||||
def test_validity_graph_matches_reference_domains_and_connections(surface):
|
||||
obj, _, _, _, _ = surface
|
||||
group = obj.modifiers[0].node_group
|
||||
delete = next(node for node in group.nodes if node.bl_idname == "GeometryNodeDeleteGeometry")
|
||||
assert (delete.domain, delete.mode) == ("FACE", "ALL")
|
||||
points = origin_socket(delete.inputs["Selection"].links[0].from_socket).node
|
||||
assert (points.bl_idname, points.data_type, points.domain) == ("GeometryNodeFieldOnDomain", "BOOLEAN", "POINT")
|
||||
compare = origin_socket(points.inputs["Value"].links[0].from_socket).node
|
||||
assert (compare.bl_idname, compare.data_type, compare.operation) == ("FunctionNodeCompare", "FLOAT", "LESS_THAN")
|
||||
assert origin_socket(compare.inputs["B"].links[0].from_socket).name == "Validity Threshold"
|
||||
alpha = origin_socket(compare.inputs["A"].links[0].from_socket)
|
||||
assert alpha.name == "Alpha" and alpha.node.bl_idname == "FunctionNodeSeparateColor"
|
||||
color = origin_socket(alpha.node.inputs["Color"].links[0].from_socket).node
|
||||
assert origin_socket(color.inputs["Image"].links[0].from_socket).name == "Depth Image"
|
||||
consumers = [link for link in group.links if link.to_node.bl_idname != "NodeReroute"
|
||||
and origin_socket(link.from_socket) == color.outputs["Color"]]
|
||||
assert any(link.to_node == alpha.node for link in consumers)
|
||||
assert origin_socket(delete.inputs["Geometry"].links[0].from_socket).node.node_tree.name == "O Image Plane"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("thickness", [0.0, 0.2])
|
||||
def test_depth_smooth_changes_positions_and_preserves_topology(surface, thickness):
|
||||
obj, set_value, _, _, depth_image = surface
|
||||
pixels = np.asarray(depth_image.pixels[:], dtype=np.float32).reshape(8, 16, 4)
|
||||
pixels[..., 2] += (np.arange(16) % 2) * 0.3
|
||||
pixels[:, :4, 3] = 0
|
||||
depth_image.pixels.foreach_set(pixels.ravel())
|
||||
depth_image.update()
|
||||
set_value("Depth Scale", 1.0)
|
||||
set_value("Thickness", thickness)
|
||||
base = evaluated(obj)
|
||||
set_value("Smooth", 3)
|
||||
smoothed = evaluated(obj)
|
||||
assert smoothed[1] == base[1]
|
||||
np.testing.assert_allclose(smoothed[2], base[2])
|
||||
assert smoothed[3:5] == base[3:5]
|
||||
assert np.isfinite(smoothed[0]).all()
|
||||
assert not np.allclose(smoothed[0], base[0])
|
||||
set_value("Smooth Weight", 0.0)
|
||||
np.testing.assert_allclose(evaluated(obj)[0], base[0], atol=1e-6)
|
||||
set_value("Smooth Weight", 1.0)
|
||||
set_value("Smooth", 0)
|
||||
np.testing.assert_allclose(evaluated(obj)[0], base[0], atol=1e-6)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["DEPTH", "RELIEF"])
|
||||
@pytest.mark.parametrize("source", ["definition", "asset"])
|
||||
def test_plane_interface_panels(source, kind):
|
||||
from tests.support.planes import create_surface
|
||||
obj, _, _, _, _ = create_surface(source, kind)
|
||||
node_group = obj.modifiers[0].node_group
|
||||
panels = [item for item in node_group.interface.items_tree if item.item_type == "PANEL"]
|
||||
assert [item.name for item in panels] == ["Options", "Data"]
|
||||
controls = ["Reference Depth"]
|
||||
if kind == "DEPTH":
|
||||
controls += ["Normal Smooth", "Smooth Weight", "Validity Threshold", "Split Threshold"]
|
||||
assert [item.name for item in panels[0].interface_items] == controls
|
||||
assert [item.name for item in panels[1].interface_items] == ["Uniform Scale", "Depth Image"]
|
||||
assert all(panel.default_closed for panel in panels)
|
||||
269
tests/tools/nodes/test_depth_surface_split.py
Normal file
269
tests/tools/nodes/test_depth_surface_split.py
Normal file
@@ -0,0 +1,269 @@
|
||||
"""Behavior checks for face separation and connected Depth Surface shells."""
|
||||
|
||||
from collections import Counter
|
||||
import bpy
|
||||
import bmesh
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
|
||||
from tests.support.nodes import CUTOUT_BUILDERS
|
||||
from tests.support.depth_surface import assert_closed, component_count, evaluated, surface
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [0, 1])
|
||||
@pytest.mark.parametrize("split", [0.0, 0.5])
|
||||
def test_shell_walls_directly_connect_caps(mode, split):
|
||||
from anyimage.common.object import modifier_input_identifier, set_modifier_input
|
||||
|
||||
obj, set_value = surface(resolution=4)
|
||||
obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set(
|
||||
"value", np.full(len(obj.data.vertices), 0.3)
|
||||
)
|
||||
set_value("Split Threshold", split)
|
||||
front, faces = evaluated(obj)
|
||||
edges = Counter(tuple(sorted((a, b))) for face in faces for a, b in zip(face, (*face[1:], face[0])))
|
||||
boundary_count = sum(count == 1 for count in edges.values())
|
||||
set_value("Mode", mode)
|
||||
modifier = obj.modifiers[0]
|
||||
thickness = modifier_input_identifier(modifier.node_group, "Thickness", subtype="NONE" if mode == 0 else "DISTANCE")
|
||||
for amount in (0.01, 2.0):
|
||||
set_modifier_input(modifier, thickness, amount)
|
||||
obj.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
points, shell_faces = evaluated(obj)
|
||||
assert len(points) == 2 * len(front)
|
||||
assert len(shell_faces) == 2 * len(faces) + boundary_count
|
||||
assert all(len(face) == 4 for face in shell_faces)
|
||||
assert np.isfinite(points).all()
|
||||
assert_closed(shell_faces)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("thickness", [0.1, 1.4])
|
||||
@pytest.mark.parametrize("split", [0.0, 0.5])
|
||||
def test_surface_caps_and_segmented_walls_are_connected(thickness, split):
|
||||
obj, set_value = surface()
|
||||
set_value("Split Threshold", split)
|
||||
set_value("Thickness", thickness)
|
||||
coords, faces = evaluated(obj)
|
||||
assert np.isfinite(coords).all()
|
||||
assert_closed(faces)
|
||||
set_value("Thickness", 0.0)
|
||||
front, _ = evaluated(obj)
|
||||
from scipy.spatial import cKDTree
|
||||
assert cKDTree(front).query(coords)[0].max() <= thickness + 1e-5
|
||||
assert cKDTree(coords).query(front)[0].max() < 1e-5
|
||||
|
||||
|
||||
@pytest.mark.parametrize("size", [0.01, 1.0, 100.0])
|
||||
def test_split_mapping_and_uniform_scale(size):
|
||||
obj, set_value = surface(step=2.0, size=size)
|
||||
# Face centers are size/2 apart: gradient is 4.
|
||||
counts = []
|
||||
for split in (0.0, 0.25, 0.5, 1.0):
|
||||
set_value("Split Threshold", split)
|
||||
coords, faces = evaluated(obj)
|
||||
counts.append(len(coords))
|
||||
assert len(faces) == 8
|
||||
assert counts[:3] == [15] * 3
|
||||
assert counts[3] == 18
|
||||
set_value("Depth Scale", 0.0)
|
||||
coords, faces = evaluated(obj)
|
||||
assert len(coords) == 18 and np.abs(coords[:, 2]).max() < 1e-6
|
||||
|
||||
|
||||
def test_midpoint_selects_gradient_six_and_preserves_faces():
|
||||
obj, set_value = surface(step=3.0)
|
||||
set_value("Split Threshold", 0.5)
|
||||
coords, faces = evaluated(obj)
|
||||
assert len(coords) == 18 and len(faces) == 8
|
||||
assert set(np.round(coords[:, 2], 4)) == {0.0, -3.0}
|
||||
|
||||
|
||||
def test_cleanup_keeps_split_parts_of_a_large_source_island():
|
||||
obj, set_value = surface(wrapper=True)
|
||||
set_value("Cleanup Threshold", 110.0)
|
||||
assert len(evaluated(obj)[1]) == 8
|
||||
set_value("Split Threshold", 0.5)
|
||||
assert len(evaluated(obj)[1]) == 8
|
||||
set_value("Thickness", 0.2)
|
||||
assert_closed(evaluated(obj)[1])
|
||||
set_value("Cleanup Threshold", 210.0)
|
||||
assert len(evaluated(obj)[1]) == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("depth_scale", [0.0, 0.25, 1.0, 2.0, 4.0])
|
||||
def test_split_topology_uses_depth_before_display_scaling(depth_scale):
|
||||
obj, set_value = surface(step=3.0)
|
||||
set_value("Split Threshold", 0.5)
|
||||
original, original_faces = evaluated(obj)
|
||||
set_value("Depth Scale", depth_scale)
|
||||
coords, faces = evaluated(obj)
|
||||
assert len(coords) == len(original) == 18
|
||||
assert faces == original_faces
|
||||
np.testing.assert_allclose(coords[:, 2], original[:, 2] * depth_scale, atol=1e-6)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shape", ["SOLID", "DEPTH_SOLID", "DEPTH_BALLOON"])
|
||||
def test_each_asset_cleans_the_original_geometry_before_shaping(shape):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
group = CUTOUT_BUILDERS[shape]()
|
||||
links = [link for link in group.links if link.from_node.bl_idname == "NodeGroupInput" and link.from_socket.name == "Geometry"]
|
||||
assert len(links) == 1
|
||||
assert links[0].to_node.bl_idname == "GeometryNodeDeleteGeometry"
|
||||
|
||||
|
||||
def test_full_wrapper_smooth_keeps_closed_shell():
|
||||
obj, set_value = surface(wrapper=True)
|
||||
set_value("Split Threshold", 0.5)
|
||||
set_value("Thickness", 0.2)
|
||||
set_value("Smooth", 2)
|
||||
coords, faces = evaluated(obj)
|
||||
assert np.isfinite(coords).all()
|
||||
assert_closed(faces)
|
||||
|
||||
|
||||
def test_coincident_independent_sheets_do_not_weld_together():
|
||||
obj, set_value = surface(step=0.0)
|
||||
mesh = obj.data
|
||||
vertices = [tuple(v.co) for v in mesh.vertices]
|
||||
faces = [tuple(f.vertices) for f in mesh.polygons]
|
||||
n = len(vertices)
|
||||
duplicate = bpy.data.meshes.new("Close sheets")
|
||||
duplicate.from_pydata(
|
||||
vertices + [(x, y, z + 1e-8) for x, y, z in vertices],
|
||||
[],
|
||||
faces + [tuple(v + n for v in face) for face in faces],
|
||||
)
|
||||
uv = duplicate.uv_layers.new(name="UVMap")
|
||||
for loop in duplicate.loops:
|
||||
x, y, z = duplicate.vertices[loop.vertex_index].co
|
||||
uv.data[loop.index].uv = (x / 2, y)
|
||||
obj.data = duplicate
|
||||
set_value("Thickness", 0.1)
|
||||
coords, faces = evaluated(obj)
|
||||
assert_closed(faces)
|
||||
assert component_count(faces) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("resolution", [1, 2, 4])
|
||||
def test_planar_depth_has_no_split_at_any_density(resolution):
|
||||
obj, set_value = surface(step=0.0, resolution=resolution)
|
||||
original, original_faces = evaluated(obj)
|
||||
set_value("Split Threshold", 1.0)
|
||||
coords, faces = evaluated(obj)
|
||||
assert len(coords) == len(obj.data.vertices)
|
||||
assert len(faces) == len(obj.data.polygons)
|
||||
np.testing.assert_allclose(coords, original, atol=1e-6)
|
||||
|
||||
|
||||
def test_small_positive_split_preserves_unsplit_projection():
|
||||
obj, set_value = surface(step=2.0)
|
||||
original, original_faces = evaluated(obj)
|
||||
set_value("Split Threshold", 0.01)
|
||||
coords, faces = evaluated(obj)
|
||||
np.testing.assert_allclose(coords, original, atol=1e-6)
|
||||
assert faces == original_faces
|
||||
|
||||
|
||||
def test_cut_vertices_preserve_camera_rays_and_other_vertices():
|
||||
obj, set_value = surface(step=3.0)
|
||||
original, original_faces = evaluated(obj)
|
||||
set_value("Split Threshold", 0.5)
|
||||
coords, faces = evaluated(obj)
|
||||
for source_face, result_face in zip(original_faces, faces):
|
||||
for source_index, result_index in zip(source_face, result_face):
|
||||
before, after = original[source_index], coords[result_index]
|
||||
if obj.data.vertices[source_index].co.x != 1.0:
|
||||
np.testing.assert_allclose(after, before, atol=1e-6)
|
||||
before_depth, after_depth = 1.0 - before[2], 1.0 - after[2]
|
||||
np.testing.assert_allclose(
|
||||
after[:2] / after_depth, before[:2] / before_depth, atol=1e-6
|
||||
)
|
||||
|
||||
|
||||
def test_stored_point_positions_prevent_depth_resampling_on_side_walls():
|
||||
obj, set_value = surface(step=3.0)
|
||||
set_value("Split Threshold", 0.5)
|
||||
set_value("Thickness", 0.1)
|
||||
coords, faces = evaluated(obj)
|
||||
assert_closed(faces)
|
||||
set_value("Thickness", 0.0)
|
||||
front, _ = evaluated(obj)
|
||||
from scipy.spatial import cKDTree
|
||||
assert cKDTree(front).query(coords)[0].max() <= 0.1 + 1e-5
|
||||
assert cKDTree(coords).query(front)[0].max() < 1e-5
|
||||
|
||||
|
||||
@pytest.mark.parametrize("thickness", [0.0, 0.2])
|
||||
@pytest.mark.parametrize("split", [0.0, 0.5])
|
||||
def test_temporary_attributes_are_removed_and_user_attributes_survive(thickness, split):
|
||||
obj, set_value = surface(wrapper=True)
|
||||
obj.data.attributes.new("o_depth_probe", "FLOAT", "POINT")
|
||||
attribute = obj.data.attributes.new("o_user_weight", "FLOAT", "POINT")
|
||||
for value in attribute.data:
|
||||
value.value = 0.75
|
||||
set_value("Split Threshold", split)
|
||||
set_value("Thickness", thickness)
|
||||
set_value("Smooth", 2)
|
||||
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
mesh = result.to_mesh()
|
||||
try:
|
||||
assert not any(a.name.startswith("o_depth_") for a in mesh.attributes)
|
||||
assert "UVMap" in mesh.attributes
|
||||
assert all(
|
||||
v.value == pytest.approx(0.75)
|
||||
for v in mesh.attributes["o_user_weight"].data
|
||||
)
|
||||
finally:
|
||||
result.to_mesh_clear()
|
||||
|
||||
|
||||
def test_zero_center_distance_does_not_split_or_produce_nan():
|
||||
obj, set_value = surface(step=6.0)
|
||||
mesh = bpy.data.meshes.new("Coincident face centers")
|
||||
mesh.from_pydata(
|
||||
[(0, 0, 0), (1, 0, 0), (0.5, 1, 0), (0.5, 1, 0)], [], [(0, 1, 2), (1, 0, 3)]
|
||||
)
|
||||
uv = mesh.uv_layers.new(name="UVMap")
|
||||
for face in mesh.polygons:
|
||||
for li in face.loop_indices:
|
||||
uv.data[li].uv = (0.25 if face.index == 0 else 0.75, 0.5)
|
||||
obj.data = mesh
|
||||
set_value("Split Threshold", 1.0)
|
||||
coords, faces = evaluated(obj)
|
||||
assert len(coords) == 4 and len(faces) == 2
|
||||
assert np.isfinite(coords).all()
|
||||
|
||||
|
||||
def test_triangle_corner_weights_do_not_open_sloped_side_walls():
|
||||
obj, set_value = surface(step=3.0)
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(obj.data)
|
||||
bmesh.ops.triangulate(bm, faces=list(bm.faces))
|
||||
bm.to_mesh(obj.data)
|
||||
bm.free()
|
||||
image = bpy.data.images["Camera"]
|
||||
pixels = np.array(image.pixels[:], np.float32).reshape(32, 64, 4)
|
||||
pixels[..., 2] += np.arange(32)[:, None] * 0.02
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.update()
|
||||
set_value("Split Threshold", 0.5)
|
||||
set_value("Thickness", 0.8)
|
||||
coords, faces = evaluated(obj)
|
||||
assert_closed(faces)
|
||||
assert component_count(faces) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("resolution", [1, 2, 4])
|
||||
def test_linear_depth_slope_has_density_independent_selection(resolution):
|
||||
obj, set_value = surface(step=0.0, resolution=resolution)
|
||||
image = bpy.data.images["Camera"]
|
||||
pixels = np.array(image.pixels[:], np.float32).reshape(32, 64, 4)
|
||||
pixels[..., 2] = 1 + 4 * pixels[..., 0]
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.update()
|
||||
set_value("Split Threshold", 0.5)
|
||||
assert len(evaluated(obj)[0]) == len(obj.data.vertices)
|
||||
set_value("Split Threshold", 1.0)
|
||||
assert len(evaluated(obj)[0]) > len(obj.data.vertices)
|
||||
104
tests/tools/nodes/test_image_layer.py
Normal file
104
tests/tools/nodes/test_image_layer.py
Normal file
@@ -0,0 +1,104 @@
|
||||
import bpy
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
|
||||
from tests.support.materials import image_layer
|
||||
from tools.nodes.groups.image_depth_layer import build_image_depth_layer_group
|
||||
|
||||
|
||||
@pytest.mark.parametrize("image_layer", ["definition", "asset"], indirect=True)
|
||||
def test_alpha_curve_preserves_endpoints_and_suppresses_soft_edges(image_layer):
|
||||
inputs = {
|
||||
item.name: item for item in image_layer.interface.items_tree
|
||||
if item.item_type == "SOCKET" and item.in_out == "INPUT"
|
||||
}
|
||||
assert list(inputs) == [
|
||||
"Color", "Alpha", "Normal", "Bump Scale", "Alpha Fix",
|
||||
"Normal Scale", "Object Space",
|
||||
]
|
||||
panels = [item for item in image_layer.interface.items_tree if item.item_type == "PANEL"]
|
||||
assert len(panels) == 1
|
||||
assert panels[0].name == "Options" and panels[0].default_closed
|
||||
assert [item.name for item in panels[0].interface_items] == ["Alpha Fix", "Normal Scale", "Object Space"]
|
||||
strength = inputs["Alpha Fix"]
|
||||
assert (strength.default_value, strength.min_value, strength.max_value) == (0, 0, 1)
|
||||
assert strength.subtype == "FACTOR"
|
||||
output = next(node for node in image_layer.nodes if node.type == "GROUP_OUTPUT")
|
||||
curve = output.inputs["Alpha"].links[0].from_node
|
||||
assert curve.type == "CURVE_FLOAT"
|
||||
assert curve.inputs["Value"].links[0].from_socket.name == "Alpha"
|
||||
assert curve.inputs["Factor"].links[0].from_socket.name == "Alpha Fix"
|
||||
mapping = curve.mapping
|
||||
np.testing.assert_allclose(
|
||||
[point.location[:] for point in mapping.curves[0].points],
|
||||
[(0, 0), (0.8, 0), (0.925, 0.925), (1, 1)],
|
||||
atol=1e-6,
|
||||
)
|
||||
samples = np.array([
|
||||
mapping.evaluate(mapping.curves[0], float(alpha))
|
||||
for alpha in np.linspace(0, 1, 1001)
|
||||
])
|
||||
np.testing.assert_allclose(samples[:801], 0, atol=3e-4)
|
||||
assert samples[-1] == pytest.approx(1)
|
||||
assert np.all(np.diff(samples) >= -1e-6)
|
||||
assert np.all((samples >= 0) & (samples <= 1))
|
||||
assert mapping.evaluate(mapping.curves[0], 0.85) == pytest.approx(0.1593, abs=1e-3)
|
||||
assert mapping.evaluate(mapping.curves[0], 0.925) == pytest.approx(0.925, abs=5e-4)
|
||||
assert all(socket.is_linked or socket.hide for node in image_layer.nodes for socket in node.outputs)
|
||||
|
||||
|
||||
def test_depth_layer_keeps_foreground_alpha_threshold(image_layer):
|
||||
depth = build_image_depth_layer_group()
|
||||
assert "Alpha Fix" not in {
|
||||
item.name for item in depth.interface.items_tree if item.item_type == "SOCKET"
|
||||
}
|
||||
output = next(node for node in depth.nodes if node.type == "GROUP_OUTPUT")
|
||||
threshold = output.inputs["Alpha"].links[0].from_node
|
||||
assert threshold.operation == "GREATER_THAN"
|
||||
assert threshold.inputs[1].default_value == 0.5
|
||||
assert threshold.inputs[0].links[0].from_socket.name == "Alpha"
|
||||
|
||||
|
||||
def test_alpha_adjustment_shader_blends_curve_strength(image_layer, tmp_path):
|
||||
scene = bpy.context.scene
|
||||
scene.render.engine = "CYCLES"
|
||||
scene.cycles.device = "CPU"
|
||||
scene.cycles.samples = 1
|
||||
scene.cycles.use_denoising = False
|
||||
scene.render.resolution_x = 96
|
||||
scene.render.resolution_y = 32
|
||||
scene.render.resolution_percentage = 100
|
||||
scene.render.image_settings.file_format = "OPEN_EXR"
|
||||
scene.render.image_settings.color_depth = "32"
|
||||
scene.render.filepath = str(tmp_path / "alpha-strength.exr")
|
||||
bpy.ops.object.camera_add(location=(0, 0, 5))
|
||||
camera = bpy.context.object
|
||||
camera.data.type = "ORTHO"
|
||||
camera.data.ortho_scale = 3
|
||||
scene.camera = camera
|
||||
for index, factor in enumerate((0, 0.5, 1)):
|
||||
bpy.ops.mesh.primitive_plane_add(size=1, location=(index - 1, 0, 0))
|
||||
result = bpy.data.materials.new(f"Alpha {factor}")
|
||||
result.use_nodes = True
|
||||
nodes = result.node_tree.nodes
|
||||
nodes.clear()
|
||||
layer = nodes.new("ShaderNodeGroup")
|
||||
layer.node_tree = image_layer
|
||||
layer.inputs["Alpha"].default_value = 0.9
|
||||
layer.inputs["Alpha Fix"].default_value = factor
|
||||
emission = nodes.new("ShaderNodeEmission")
|
||||
output = nodes.new("ShaderNodeOutputMaterial")
|
||||
result.node_tree.links.new(layer.outputs["Alpha"], emission.inputs["Color"])
|
||||
result.node_tree.links.new(emission.outputs[0], output.inputs["Surface"])
|
||||
bpy.context.object.data.materials.append(result)
|
||||
bpy.ops.render.render(write_still=True)
|
||||
rendered = bpy.data.images.load(scene.render.filepath, check_existing=False)
|
||||
try:
|
||||
pixels = np.array(rendered.pixels[:]).reshape(32, 96, 4)
|
||||
values = pixels[16, [16, 48, 80], 0]
|
||||
assert values[0] == pytest.approx(0.9, abs=1e-4)
|
||||
assert values[2] == pytest.approx(0.77396, abs=1e-3)
|
||||
assert values[1] == pytest.approx((values[0] + values[2]) / 2, abs=1e-4)
|
||||
finally:
|
||||
bpy.data.images.remove(rendered)
|
||||
106
tests/tools/nodes/test_image_plane.py
Normal file
106
tests/tools/nodes/test_image_plane.py
Normal file
@@ -0,0 +1,106 @@
|
||||
import bpy
|
||||
|
||||
from anyimage.common.object import (
|
||||
modifier_input_identifier,
|
||||
set_modifier_input,
|
||||
)
|
||||
|
||||
from anyimage.operators.convert_to_plane.image_plane import (
|
||||
add_image_plane_processing,
|
||||
create_image_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 test_saved_image_plane_evaluation():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
mesh = create_image_plane_mesh("Image Plane", (-1.0, 1.0, -0.5, 0.5))
|
||||
assert len(mesh.polygons) == 1
|
||||
material = bpy.data.materials.new("Image Plane Material")
|
||||
mesh.materials.append(material)
|
||||
obj = bpy.data.objects.new("Image Plane", mesh)
|
||||
bpy.context.scene.collection.objects.link(obj)
|
||||
modifier = add_image_plane_processing(obj)
|
||||
group = modifier.node_group
|
||||
assert group.name == "O Image 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"]
|
||||
assert inputs["Subdivide"].default_value == 0
|
||||
assert inputs["Subdivide"].max_value == 10
|
||||
assert inputs["Thickness"].default_value == 0.0
|
||||
assert obj.data.materials[0] == material
|
||||
|
||||
evaluated, result = evaluated_mesh(obj)
|
||||
try:
|
||||
assert result.materials[0].original == material
|
||||
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)
|
||||
assert all(polygon.material_index == 0 for polygon in result.polygons)
|
||||
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 result.materials[0].original == material
|
||||
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())
|
||||
assert all(polygon.material_index == 0 for polygon in result.polygons)
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
set_modifier_input(modifier, modifier_input_identifier(group, "Subdivide"), 0)
|
||||
set_modifier_input(modifier, modifier_input_identifier(group, "Thickness"), 0.01)
|
||||
obj.update_tag(refresh={"DATA"})
|
||||
evaluated, result = evaluated_mesh(obj)
|
||||
try:
|
||||
z_values = {round(vertex.co.z, 6) for vertex in result.vertices}
|
||||
assert len(z_values) >= 4
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
if bpy.app.version >= (5, 0, 0):
|
||||
assert group.library is not None
|
||||
23
tests/tools/nodes/test_inventory.py
Normal file
23
tests/tools/nodes/test_inventory.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""Check the saved library's public inventory and persistence."""
|
||||
import bpy
|
||||
from anyimage.common.node import load_node_group, node_asset_path
|
||||
from tools.nodes.check import GEOMETRY_NAMES, SHADER_NAMES
|
||||
|
||||
|
||||
def test_saved_asset_inventory():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
with bpy.data.libraries.load(str(node_asset_path())) as (source, _):
|
||||
assert set(source.node_groups) == GEOMETRY_NAMES | SHADER_NAMES
|
||||
for name in sorted(GEOMETRY_NAMES | SHADER_NAMES):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
node_group = load_node_group(name)
|
||||
assert node_group.bl_idname == ("GeometryNodeTree" if name in GEOMETRY_NAMES else "ShaderNodeTree")
|
||||
assert node_group.use_fake_user and node_group.users > 0
|
||||
if name in GEOMETRY_NAMES:
|
||||
assert node_group.is_modifier
|
||||
if bpy.app.version >= (5, 0, 0):
|
||||
assert node_group.library is not None
|
||||
if name in {"O Image Depth Layer", "O Shadeless"}:
|
||||
expected = ["Color", "Alpha", "Depth", "Bump Scale"] if name == "O Image Depth Layer" else ["Color"]
|
||||
assert [item.name for item in node_group.interface.items_tree
|
||||
if item.item_type == "SOCKET" and item.in_out == "INPUT"] == expected
|
||||
37
tests/tools/nodes/test_saved_layout.py
Normal file
37
tests/tools/nodes/test_saved_layout.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from tests.support.paths import PROJECT_ROOT
|
||||
import unittest
|
||||
|
||||
|
||||
SOURCE_ROOT = PROJECT_ROOT / "src" / "anyimage"
|
||||
|
||||
|
||||
class NodeAssetsTest(unittest.TestCase):
|
||||
def test_geometry_assets_preserve_node_structure(self):
|
||||
import bpy
|
||||
|
||||
from tests.support.nodes import validate_node_structure
|
||||
from tools.nodes.arrangement.arrange import arrange_nodes
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
with bpy.data.libraries.load(
|
||||
str(SOURCE_ROOT / "assets" / "O_AnyImage.blend")
|
||||
) as (source, target):
|
||||
target.node_groups = list(source.node_groups)
|
||||
groups = [
|
||||
group
|
||||
for group in target.node_groups
|
||||
if group.bl_idname == "GeometryNodeTree"
|
||||
]
|
||||
self.assertEqual(len(groups), 6)
|
||||
for group in groups:
|
||||
validate_node_structure(group)
|
||||
positions = {node.name: tuple(node.location) for node in group.nodes
|
||||
if node.bl_idname not in {"NodeReroute", "NodeGroupInput"}}
|
||||
arrange_nodes(group)
|
||||
for name, position in positions.items():
|
||||
for actual, expected in zip(group.nodes[name].location, position):
|
||||
self.assertAlmostEqual(actual, expected, delta=0.01, msg=f"{group.name}: {name}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
23
tests/tools/test_commands.py
Normal file
23
tests/tools/test_commands.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from tests.support.paths import PROJECT_ROOT
|
||||
import tomllib
|
||||
import unittest
|
||||
|
||||
|
||||
class ToolCommandsTests(unittest.TestCase):
|
||||
def test_repository_tools_have_uv_commands(self):
|
||||
project = tomllib.loads(
|
||||
(PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8")
|
||||
)["project"]
|
||||
self.assertEqual(
|
||||
project["scripts"],
|
||||
{
|
||||
"pack": "tools.pack:main",
|
||||
"build_nodes": "tools.nodes.cli:main",
|
||||
"sync_models": "tools.models.cli:main",
|
||||
"docs": "tools.docs:main",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
42
tests/tools/test_docs.py
Normal file
42
tests/tools/test_docs.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from tests.support.paths import PROJECT_ROOT
|
||||
import importlib.util
|
||||
|
||||
|
||||
SCRIPT_PATH = PROJECT_ROOT / "tools" / "docs.py"
|
||||
SPEC = importlib.util.spec_from_file_location("docs_script", SCRIPT_PATH)
|
||||
docs = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(docs)
|
||||
|
||||
|
||||
def test_documentation_command_deploys_project_version(monkeypatch):
|
||||
monkeypatch.setattr(docs, "project_version", lambda: "1.2.3")
|
||||
|
||||
assert docs.documentation_command() == [
|
||||
"mike",
|
||||
"deploy",
|
||||
"--update-aliases",
|
||||
"1.2.3",
|
||||
"latest",
|
||||
]
|
||||
|
||||
|
||||
def test_documentation_command_previews_current_source():
|
||||
assert docs.documentation_command(dev=True) == ["mkdocs", "serve"]
|
||||
|
||||
|
||||
def test_main_runs_from_project_root(monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
docs.subprocess,
|
||||
"run",
|
||||
lambda *args, **kwargs: calls.append((args, kwargs)),
|
||||
)
|
||||
|
||||
docs.main(["-dev"])
|
||||
|
||||
assert calls == [
|
||||
(
|
||||
(["mkdocs", "serve"],),
|
||||
{"cwd": docs.PROJECT_ROOT, "check": True},
|
||||
)
|
||||
]
|
||||
24
tests/tools/test_manifest.py
Normal file
24
tests/tools/test_manifest.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from tests.support.paths import PROJECT_ROOT
|
||||
import tomllib
|
||||
import unittest
|
||||
|
||||
|
||||
MANIFEST_PATH = PROJECT_ROOT / "src" / "anyimage" / "blender_manifest.toml"
|
||||
|
||||
|
||||
class ExtensionManifestTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.manifest = tomllib.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
def test_declares_modern_extension_metadata(self):
|
||||
self.assertEqual(self.manifest["schema_version"], "1.0.0")
|
||||
self.assertEqual(self.manifest["id"], "anyimage")
|
||||
self.assertEqual(self.manifest["type"], "add-on")
|
||||
self.assertNotIn("bl_info", self.manifest)
|
||||
|
||||
def test_declares_required_environment_permissions(self):
|
||||
self.assertEqual(
|
||||
set(self.manifest["permissions"]),
|
||||
{"network", "files"},
|
||||
)
|
||||
398
tests/tools/test_pack.py
Normal file
398
tests/tools/test_pack.py
Normal file
@@ -0,0 +1,398 @@
|
||||
from tests.support.paths import PROJECT_ROOT
|
||||
import importlib.util
|
||||
import tempfile
|
||||
import tomllib
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def load_pack():
|
||||
specification = importlib.util.spec_from_file_location(
|
||||
"anyimage_pack",
|
||||
PROJECT_ROOT / "tools" / "pack.py",
|
||||
)
|
||||
module = importlib.util.module_from_spec(specification)
|
||||
specification.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class ArchiveContents:
|
||||
def __init__(self, path, records):
|
||||
self.files = {}
|
||||
records[path] = self
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
pass
|
||||
|
||||
def write(self, source, target):
|
||||
self.files[Path(target).as_posix()] = None
|
||||
|
||||
def writestr(self, target, value):
|
||||
self.files[target] = value.encode()
|
||||
|
||||
def namelist(self):
|
||||
return list(self.files)
|
||||
|
||||
def read(self, name):
|
||||
return self.files[name]
|
||||
|
||||
|
||||
class ExtensionPackagingTest(unittest.TestCase):
|
||||
def test_selects_platform_archive_contents_and_manifest(self):
|
||||
pack = load_pack()
|
||||
records = {}
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
directory_path = Path(directory)
|
||||
wheel_path = directory_path / "blendjob-0.1.16-py3-none-any.whl"
|
||||
wheel_path.write_bytes(b"wheel")
|
||||
original_dist = pack.DIST_DIR
|
||||
pack.DIST_DIR = directory_path / "dist"
|
||||
try:
|
||||
with (
|
||||
patch.object(pack, "download_wheels", return_value=[wheel_path]),
|
||||
patch.object(pack, "ZipFile", side_effect=lambda path, *args: ArchiveContents(path, records)),
|
||||
):
|
||||
output_paths = pack.build_all()
|
||||
finally:
|
||||
pack.DIST_DIR = original_dist
|
||||
|
||||
version = tomllib.loads(
|
||||
(pack.SOURCE_DIR / "blender_manifest.toml").read_text(encoding="utf-8")
|
||||
)["version"]
|
||||
self.assertEqual(
|
||||
{path.name for path in output_paths},
|
||||
{f"AnyImage.v{version}.{platform}.zip" for platform in pack.PLATFORMS},
|
||||
)
|
||||
for output_path in output_paths:
|
||||
platform = output_path.stem.split(".")[-1]
|
||||
with records[output_path] as archive:
|
||||
names = set(archive.namelist())
|
||||
manifest = archive.read("blender_manifest.toml").decode("utf-8")
|
||||
|
||||
self.assertIn("__init__.py", names)
|
||||
self.assertIn("operators/clipboard_image/__init__.py", names)
|
||||
self.assertIn("operators/clipboard_image/operators.py", names)
|
||||
self.assertIn("operators/clipboard_image/actions.py", names)
|
||||
self.assertIn("operators/clipboard_image/clipboard.py", names)
|
||||
self.assertIn("blender_manifest.toml", names)
|
||||
self.assertNotIn("assets/O_AnyImage.blend1", names)
|
||||
for path in (
|
||||
"common/__init__.py",
|
||||
"common/ai.py",
|
||||
"common/depth.py",
|
||||
"common/image.py",
|
||||
"common/material.py",
|
||||
"common/node.py",
|
||||
"common/object.py",
|
||||
"common/selection.py",
|
||||
"common/viewport.py",
|
||||
"operators/cutout_tool/__init__.py",
|
||||
"operators/cutout_tool/operators.py",
|
||||
"operators/cutout_tool/interaction.py",
|
||||
"operators/cutout_tool/geometry.py",
|
||||
"operators/cutout_tool/fine_mesh.py",
|
||||
"operators/cutout_tool/object.py",
|
||||
"operators/cutout_tool/shape.py",
|
||||
"operators/convert_to_plane/__init__.py",
|
||||
"operators/convert_to_plane/object.py",
|
||||
"operators/convert_to_plane/operators.py",
|
||||
"operators/convert_to_plane/image_plane.py",
|
||||
"operators/frame_tool.py",
|
||||
"operators/mask_tool.py",
|
||||
"operators/rectify_tool.py",
|
||||
"operators/ai_setup.py",
|
||||
"operators/remove_background.py",
|
||||
):
|
||||
self.assertIn(path, names)
|
||||
self.assertNotIn("operators/convert_to_plane/nodes.py", names)
|
||||
self.assertNotIn("operators/cutout/__init__.py", names)
|
||||
self.assertNotIn("operators/image_mesh/__init__.py", names)
|
||||
self.assertNotIn("operators/mesh/__init__.py", names)
|
||||
self.assertNotIn("operators/image_edit/__init__.py", names)
|
||||
self.assertNotIn("operators/background.py", names)
|
||||
self.assertIn(
|
||||
"assets/O_AnyImage.blend",
|
||||
names,
|
||||
)
|
||||
self.assertIn("server/media/input.py", names)
|
||||
self.assertIn("common/depth.py", names)
|
||||
self.assertIn("server/models/geometry.py", names)
|
||||
self.assertIn("server/jobs/remove_background.py", names)
|
||||
self.assertIn("server/jobs/cutout.py", names)
|
||||
self.assertIn("server/jobs/depth_plane.py", names)
|
||||
self.assertIn("server/geometry/depth_texture.py", names)
|
||||
self.assertIn("server/geometry/prediction_artifacts.py", names)
|
||||
self.assertIn("server/geometry/edge_depth.py", names)
|
||||
self.assertIn("server/geometry/normal_texture.py", names)
|
||||
self.assertNotIn(
|
||||
"server/geometry/generate_geometry_and_normal.py", names
|
||||
)
|
||||
self.assertNotIn("common/image_selection.py", names)
|
||||
self.assertNotIn("server/selection/cutout_images.py", names)
|
||||
self.assertNotIn("server/selection/polygon_mask.py", names)
|
||||
self.assertNotIn("server/jobs/prepare_geometry_data.py", names)
|
||||
self.assertNotIn("server/geometry/cutout.py", names)
|
||||
self.assertIn("server/models/ben2.py", names)
|
||||
self.assertNotIn("server/models/da3.py", names)
|
||||
self.assertIn("server/models/moge2.py", names)
|
||||
self.assertNotIn("server/jobs/debug_predictions.py", names)
|
||||
self.assertNotIn("operators/debug.py", names)
|
||||
self.assertFalse(any(name.startswith("server/outputs/") for name in names))
|
||||
self.assertIn("server/model_catalog.py", names)
|
||||
self.assertIn("server/model_download.py", names)
|
||||
self.assertIn("server/models/onnx_ben2.py", names)
|
||||
self.assertNotIn("server/models/onnx_da3.py", names)
|
||||
self.assertIn("server/models/onnx_moge2.py", names)
|
||||
self.assertIn("server/models/onnx_upscale.py", names)
|
||||
self.assertIn("server/models/upscale.py", names)
|
||||
self.assertIn("server/__init__.py", names)
|
||||
self.assertIn("operators/cutout_tool/polygon.py", names)
|
||||
self.assertIn("licenses/scikit-image.txt", names)
|
||||
self.assertIn(
|
||||
"wheels/blendjob-0.1.16-py3-none-any.whl",
|
||||
names,
|
||||
)
|
||||
self.assertFalse(any(name.startswith("blendjob/") for name in names))
|
||||
self.assertNotIn("anyimage/__init__.py", names)
|
||||
self.assertFalse(any(name.startswith("sources/") for name in names))
|
||||
self.assertFalse(any(name.startswith("vendor/") for name in names))
|
||||
self.assertIn(f'platforms = ["{platform}"]', manifest)
|
||||
self.assertNotIn(
|
||||
"windows-x64" if platform != "windows-x64" else "macos-arm64",
|
||||
manifest.split("platforms =", 1)[-1],
|
||||
)
|
||||
|
||||
def test_reads_extension_dependencies_from_pyproject(self):
|
||||
pack = load_pack()
|
||||
self.assertEqual(
|
||||
pack.project_dependencies(),
|
||||
[
|
||||
"blendjob==0.1.16",
|
||||
"numpy>=1.26,<2.0",
|
||||
"scipy==1.15.3",
|
||||
],
|
||||
)
|
||||
|
||||
def test_manifest_includes_all_resolved_numerical_wheels(self):
|
||||
pack = load_pack()
|
||||
wheels = [
|
||||
Path(name)
|
||||
for name in (
|
||||
"blendjob-0.1.16-py3-none-any.whl",
|
||||
"numpy-1.26.4-cp311-cp311-win_amd64.whl",
|
||||
"scipy-1.15.3-cp311-cp311-win_amd64.whl",
|
||||
)
|
||||
]
|
||||
manifest = tomllib.loads(pack.manifest_for_platform("windows-x64", wheels))
|
||||
self.assertEqual(
|
||||
set(manifest["wheels"]),
|
||||
{
|
||||
f"./wheels/{wheel.name}"
|
||||
for wheel in wheels
|
||||
if not wheel.name.startswith("numpy-")
|
||||
},
|
||||
)
|
||||
|
||||
def test_downloads_both_python_versions_for_each_platform(self):
|
||||
pack = load_pack()
|
||||
records = {}
|
||||
for platform, config in pack.PLATFORM_CONFIGS.items():
|
||||
with self.subTest(platform=platform), tempfile.TemporaryDirectory() as directory:
|
||||
destination = Path(directory)
|
||||
|
||||
def fake_run(command, check):
|
||||
self.assertTrue(check)
|
||||
target = Path(command[command.index("--dest") + 1])
|
||||
tag = "cp" + target.name.replace(".", "")
|
||||
if target.name == "3.13":
|
||||
constraints = Path(command[command.index("--constraint") + 1])
|
||||
self.assertEqual(
|
||||
constraints.read_text(), "blendjob==0.1.16"
|
||||
)
|
||||
else:
|
||||
self.assertNotIn("--constraint", command)
|
||||
for name in (
|
||||
"blendjob-0.1.16-py3-none-any.whl",
|
||||
f"scipy-1.15.3-{tag}-{tag}-{config.pip_platform}.whl",
|
||||
f"numpy-2.1.0-{tag}-{tag}-{config.pip_platform}.whl",
|
||||
):
|
||||
(target / name).write_bytes(b"wheel")
|
||||
|
||||
with patch.object(pack.subprocess, "run", side_effect=fake_run) as run:
|
||||
wheels = pack.download_wheels(platform, destination)
|
||||
|
||||
self.assertEqual(run.call_count, 2)
|
||||
for call, version, numpy_requirement in zip(
|
||||
run.call_args_list,
|
||||
("3.11", "3.13"),
|
||||
("numpy>=1.26,<2.0", "numpy>=2.1,<3.0"),
|
||||
):
|
||||
command = call.args[0]
|
||||
self.assertIn("blendjob==0.1.16", command)
|
||||
self.assertIn(str(pack.SOURCE_DIR / "wheels"), command)
|
||||
self.assertIn(f"--python-version={version}", command)
|
||||
self.assertIn("--implementation=cp", command)
|
||||
self.assertIn(f"--platform={config.pip_platform}", command)
|
||||
self.assertEqual(
|
||||
[value for value in command if value.startswith("numpy")],
|
||||
[numpy_requirement],
|
||||
)
|
||||
self.assertEqual(len(wheels), 3)
|
||||
self.assertEqual(
|
||||
[wheel.name for wheel in wheels], sorted({wheel.name for wheel in wheels})
|
||||
)
|
||||
self.assertFalse(any(wheel.name.startswith("numpy-") for wheel in wheels))
|
||||
with (
|
||||
patch.object(pack, "DIST_DIR", destination / "dist"),
|
||||
patch.object(pack, "ZipFile", side_effect=lambda path, *args: ArchiveContents(path, records)),
|
||||
):
|
||||
output = pack._build_extension(platform, wheels)
|
||||
with records[output] as archive:
|
||||
manifest = tomllib.loads(archive.read("blender_manifest.toml").decode())
|
||||
expected = {f"wheels/{wheel.name}" for wheel in wheels}
|
||||
self.assertEqual({name[2:] for name in manifest["wheels"]}, expected)
|
||||
self.assertEqual(
|
||||
{name for name in archive.namelist() if name.startswith("wheels/")},
|
||||
expected,
|
||||
)
|
||||
|
||||
def test_empty_second_python_download_is_rejected(self):
|
||||
pack = load_pack()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
def fake_run(command, check):
|
||||
target = Path(command[command.index("--dest") + 1])
|
||||
if target.name == "3.11":
|
||||
(target / "blendjob-0.1.16-py3-none-any.whl").write_bytes(b"wheel")
|
||||
|
||||
with patch.object(pack.subprocess, "run", side_effect=fake_run):
|
||||
with self.assertRaisesRegex(RuntimeError, "Python 3.13"):
|
||||
pack.download_wheels("windows-x64", Path(directory))
|
||||
|
||||
def test_rejects_unknown_platform(self):
|
||||
pack = load_pack()
|
||||
with self.assertRaisesRegex(ValueError, "Unsupported platform"):
|
||||
pack.build_extension("windows-arm64")
|
||||
|
||||
def test_platforms_match_manifest(self):
|
||||
pack = load_pack()
|
||||
manifest = tomllib.loads(
|
||||
(pack.SOURCE_DIR / "blender_manifest.toml").read_text(encoding="utf-8")
|
||||
)
|
||||
self.assertTrue(set(manifest["platforms"]) <= set(pack.PLATFORMS))
|
||||
self.assertEqual(
|
||||
set(pack.PLATFORMS),
|
||||
{"windows-x64", "macos-arm64", "linux-x64"},
|
||||
)
|
||||
|
||||
def test_prepares_local_wheels_and_removes_stale_versions(self):
|
||||
pack = load_pack()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
source = root / "source"
|
||||
source.mkdir()
|
||||
manifest_path = source / "blender_manifest.toml"
|
||||
manifest_path.write_text(
|
||||
(pack.SOURCE_DIR / "blender_manifest.toml").read_text(encoding="utf-8"),
|
||||
encoding="utf-8",
|
||||
)
|
||||
destination = source / "wheels"
|
||||
destination.mkdir()
|
||||
(destination / "numpy-1.26.4-cp311-cp311-win_amd64.whl").write_bytes(
|
||||
b"numpy"
|
||||
)
|
||||
(destination / "scipy-1.14.0-cp311-cp311-win_amd64.whl").write_bytes(b"old")
|
||||
(destination / "notes.txt").write_text("keep", encoding="utf-8")
|
||||
wheel = root / "scipy-1.15.3-cp311-cp311-win_amd64.whl"
|
||||
wheel.write_bytes(b"new")
|
||||
with (
|
||||
patch.object(pack, "SOURCE_DIR", source),
|
||||
patch.object(pack, "download_wheels", return_value=[wheel]),
|
||||
):
|
||||
result = pack.prepare_local_dependencies("windows-x64")
|
||||
self.assertEqual(result, [destination / wheel.name])
|
||||
self.assertEqual(result[0].read_bytes(), b"new")
|
||||
self.assertTrue((destination / "notes.txt").exists())
|
||||
manifest = tomllib.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(manifest["wheels"], [f"./wheels/{wheel.name}"])
|
||||
self.assertEqual(manifest["platforms"], ["windows-x64"])
|
||||
self.assertIn("permissions", manifest)
|
||||
|
||||
def test_failed_download_keeps_local_dependencies_and_manifest(self):
|
||||
pack = load_pack()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
source = Path(directory)
|
||||
manifest = source / "blender_manifest.toml"
|
||||
manifest.write_bytes(b"original manifest")
|
||||
wheels = source / "wheels"
|
||||
wheels.mkdir()
|
||||
old = wheels / "old.whl"
|
||||
old.write_bytes(b"original wheel")
|
||||
def fake_run(command, check):
|
||||
target = Path(command[command.index("--dest") + 1])
|
||||
if target.name == "3.13":
|
||||
raise RuntimeError("download failed")
|
||||
(target / "blendjob-0.1.16-py3-none-any.whl").write_bytes(b"wheel")
|
||||
|
||||
with (
|
||||
patch.object(pack, "SOURCE_DIR", source),
|
||||
patch.object(pack.subprocess, "run", side_effect=fake_run),
|
||||
):
|
||||
with self.assertRaisesRegex(RuntimeError, "download failed"):
|
||||
pack.prepare_local_dependencies("windows-x64")
|
||||
self.assertEqual(manifest.read_bytes(), b"original manifest")
|
||||
self.assertEqual(old.read_bytes(), b"original wheel")
|
||||
|
||||
def test_archive_excludes_blender_numpy(self):
|
||||
pack = load_pack()
|
||||
records = {}
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
wheel = root / "numpy-1.26.4-cp311-cp311-win_amd64.whl"
|
||||
wheel.write_bytes(b"numpy")
|
||||
with (
|
||||
patch.object(pack, "DIST_DIR", root / "dist"),
|
||||
patch.object(pack, "ZipFile", side_effect=lambda path, *args: ArchiveContents(path, records)),
|
||||
):
|
||||
output = pack._build_extension("windows-x64", [wheel])
|
||||
with records[output] as archive:
|
||||
self.assertFalse(any("numpy-" in name for name in archive.namelist()))
|
||||
self.assertEqual(
|
||||
tomllib.loads(archive.read("blender_manifest.toml").decode())[
|
||||
"wheels"
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
def test_prepare_command_defaults_to_host_and_does_not_build(self):
|
||||
pack = load_pack()
|
||||
with (
|
||||
patch.object(pack, "current_platform", return_value="windows-x64"),
|
||||
patch.object(
|
||||
pack, "prepare_local_dependencies", return_value=[]
|
||||
) as prepare,
|
||||
patch.object(pack, "build_all") as build,
|
||||
):
|
||||
pack.main(["--prepare"])
|
||||
prepare.assert_called_once_with("windows-x64")
|
||||
build.assert_not_called()
|
||||
|
||||
def test_detects_supported_hosts(self):
|
||||
pack = load_pack()
|
||||
for system, machine, expected in (
|
||||
("Windows", "AMD64", "windows-x64"),
|
||||
("Darwin", "arm64", "macos-arm64"),
|
||||
("Linux", "x86_64", "linux-x64"),
|
||||
):
|
||||
with (
|
||||
patch.object(pack.host_platform, "system", return_value=system),
|
||||
patch.object(pack.host_platform, "machine", return_value=machine),
|
||||
):
|
||||
self.assertEqual(pack.current_platform(), expected)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user