Add new functionality for image rectification and depth metadata handling
- Implemented a preview tool for rectifying images with interactive aspect controls in `preview.py`. - Added tests for depth metadata loading to ensure consistent schema usage in `test_depth_metadata.py`. - Developed comprehensive tests for cutout calibration processes in `test_cutout_calibration.py`, including stability checks and depth calibration. - Created tests for frame compositing operations in `test_frame_compositing.py`, ensuring correct handling of depth and transparency. - Introduced material slot inheritance functionality in `material_slots.py` to manage geometry material assignments. - Built shared shader nodes for material processing in `shader.py`, including roughness and bump mapping setups.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -70,16 +70,12 @@ def test_camera_blur_preserves_rays_interior_protection_and_disconnected_surface
|
||||
camera[valid, :2] / camera[valid, 2:3], atol=1e-6)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["definition", "asset"])
|
||||
def test_cutout_fine_outline_blurs_depth_without_moving_projection(source):
|
||||
|
||||
def test_cutout_fine_outline_blurs_depth_without_moving_projection():
|
||||
from tests.support.depth_surface import surface
|
||||
from anyimage.operators.cutout_tool.fine_mesh import build_fine_mesh
|
||||
from anyimage.operators.cutout_tool.object import load_cutout_node_group
|
||||
|
||||
obj, set_value = surface(step=0, resolution=8)
|
||||
if source == "asset":
|
||||
bpy.data.node_groups.remove(obj.modifiers[0].node_group, do_unlink=True)
|
||||
obj.modifiers[0].node_group = load_cutout_node_group("DEPTH_SOLID")
|
||||
yy, xx = np.mgrid[:32, :64]
|
||||
alpha = ((xx - 32)**2 / 27**2 + (yy - 16)**2 / 13**2 < 1).astype(float)
|
||||
points, faces, _, _ = build_fine_mesh(alpha, 3, .9)
|
||||
@@ -111,11 +107,10 @@ def test_cutout_fine_outline_blurs_depth_without_moving_projection(source):
|
||||
np.testing.assert_array_equal(evaluated(obj)[0], before)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["definition", "asset"])
|
||||
def test_plane_validity_hole_blurs_depth_and_protects_rectangle(source):
|
||||
def test_plane_validity_hole_blurs_depth_and_protects_rectangle():
|
||||
from tests.support.planes import create_surface, evaluated as plane_mesh
|
||||
|
||||
obj, set_value, _, _, image = create_surface(source, "DEPTH")
|
||||
obj, set_value, _, _, image = create_surface("DEPTH")
|
||||
pixels = np.array(image.pixels[:], np.float32).reshape(8, 16, 4)
|
||||
pixels[..., 2] = 2
|
||||
pixels[2:6, 6:10, 3] = 0
|
||||
@@ -136,17 +131,15 @@ def test_plane_validity_hole_blurs_depth_and_protects_rectangle(source):
|
||||
np.testing.assert_array_equal(after[outer], before[outer])
|
||||
np.testing.assert_allclose(after[:, :2] / (1.2 - after[:, 2:3]),
|
||||
before[:, :2] / (1.2 - before[:, 2:3]), atol=1e-6)
|
||||
set_value("Valid Only", False)
|
||||
set_value("Depth Mask", False)
|
||||
intact = plane_mesh(obj)[0]
|
||||
set_value("Boundary Smooth", 0)
|
||||
np.testing.assert_array_equal(plane_mesh(obj)[0], intact)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["definition", "asset"])
|
||||
@pytest.mark.parametrize("region", ["seam", "pole"])
|
||||
def test_panorama_blurs_opening_radially_and_keeps_invalid_distance(source, region):
|
||||
def test_panorama_blurs_opening_radially_and_keeps_invalid_distance(region):
|
||||
from tests.tools.nodes.test_image_depth_panorama import panorama, evaluated as panorama_mesh
|
||||
from tools.nodes.build import OUTPUT_PATH
|
||||
|
||||
yy, xx = np.mgrid[:64, :128]
|
||||
distance = np.minimum(xx, 128 - xx)
|
||||
@@ -154,11 +147,6 @@ def test_panorama_blurs_opening_radially_and_keeps_invalid_distance(source, regi
|
||||
spike = ((distance < 4) & (yy > 40) & (yy < 44)) if region == "seam" else ((yy > 50) & (xx < 8))
|
||||
depth = np.where(spike, 10., 3.)
|
||||
obj, set_value = panorama(depth, (~hole).astype(float), subdivide=5)
|
||||
if source == "asset":
|
||||
obj.modifiers[0].node_group.name = "Panorama definition"
|
||||
with bpy.data.libraries.load(str(OUTPUT_PATH)) as (_, target):
|
||||
target.node_groups = ["O Image Depth Panorama"]
|
||||
obj.modifiers[0].node_group = target.node_groups[0]
|
||||
set_value("Smooth Weight", 0)
|
||||
before, faces, uv = panorama_mesh(obj)
|
||||
set_value("Boundary Smooth", 12)
|
||||
@@ -174,7 +162,7 @@ def test_panorama_blurs_opening_radially_and_keeps_invalid_distance(source, regi
|
||||
interior = [i for i in range(len(before)) if i not in band]
|
||||
assert interior
|
||||
np.testing.assert_array_equal(after[interior], before[interior])
|
||||
set_value("Valid Only", False)
|
||||
set_value("Depth Mask", False)
|
||||
near = panorama_mesh(obj)[0]
|
||||
set_value("Invalid Distance", 250)
|
||||
far = panorama_mesh(obj)[0]
|
||||
|
||||
@@ -24,7 +24,7 @@ def diagonal_surface(triangles=False):
|
||||
pixels[..., 2] = 1 + (xx > 18 + yy * 0.7) * 6
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.update()
|
||||
set_value("Split Threshold", 0.5)
|
||||
set_value("Depth Split", 0.5)
|
||||
set_value("Depth Scale", 0)
|
||||
return obj, set_value
|
||||
|
||||
@@ -85,16 +85,9 @@ def test_smoothing_reduces_stairs_without_leaving_two_ring_band(triangles):
|
||||
np.testing.assert_array_equal(evaluated(obj)[0], original)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["definition", "asset"])
|
||||
def test_smoothing_without_split_moves_original_boundary_only_within_band(source):
|
||||
def test_smoothing_without_split_moves_original_boundary_only_within_band():
|
||||
obj, set_value = diagonal_surface()
|
||||
if source == "asset":
|
||||
from anyimage.common.node import node_asset_path
|
||||
|
||||
with bpy.data.libraries.load(str(node_asset_path())) as (_, target):
|
||||
target.node_groups = ["O Image Depth Cutout"]
|
||||
obj.modifiers[0].node_group = target.node_groups[0]
|
||||
set_value("Split Threshold", 0)
|
||||
set_value("Depth Split", 0)
|
||||
original, faces = evaluated(obj)
|
||||
set_value("Boundary Smooth", 20)
|
||||
result, result_faces = evaluated(obj)
|
||||
@@ -136,7 +129,7 @@ def test_panorama_smoothing_preserves_interior_and_uvs():
|
||||
yy, xx = np.mgrid[:64, :128]
|
||||
depth = np.where(xx > 35 + yy * 0.6, 8.0, 2.0)
|
||||
obj, set_value = panorama(depth, subdivide=4)
|
||||
set_value("Split Threshold", 0.5)
|
||||
set_value("Depth Split", 0.5)
|
||||
original, faces, uv = panorama_mesh(obj)
|
||||
rim = boundary_neighbors(faces)
|
||||
band = edge_band(faces, rim)
|
||||
@@ -150,11 +143,10 @@ def test_panorama_smoothing_preserves_interior_and_uvs():
|
||||
assert np.max(np.linalg.norm(result-original, axis=1)) > 1e-4
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["definition", "asset"])
|
||||
def test_plane_edge_smoothing_preserves_uvs_and_closes_thickness(source):
|
||||
def test_plane_edge_smoothing_preserves_uvs_and_closes_thickness():
|
||||
from tests.support.planes import create_surface, evaluated as plane_mesh
|
||||
|
||||
obj, set_value, _, inputs, image = create_surface(source, "DEPTH")
|
||||
obj, set_value, _, inputs, image = create_surface("DEPTH")
|
||||
assert inputs["Boundary Smooth"].default_value == 2
|
||||
assert inputs["Boundary Smooth"].parent.name == "Options"
|
||||
pixels = np.array(image.pixels[:], np.float32).reshape(8, 16, 4)
|
||||
@@ -164,7 +156,7 @@ def test_plane_edge_smoothing_preserves_uvs_and_closes_thickness(source):
|
||||
image.update()
|
||||
set_value("Subdivide", 5)
|
||||
set_value("Depth Scale", 1)
|
||||
set_value("Split Threshold", 0.5)
|
||||
set_value("Depth Split", 0.5)
|
||||
original, faces, uv, *_ = plane_mesh(obj)
|
||||
set_value("Boundary Smooth", 4)
|
||||
result, result_faces, result_uv, *_ = plane_mesh(obj)
|
||||
@@ -175,76 +167,13 @@ def test_plane_edge_smoothing_preserves_uvs_and_closes_thickness(source):
|
||||
assert_closed(plane_mesh(obj)[1])
|
||||
|
||||
|
||||
def test_edge_iterations_reuse_boundary_topology_and_mapping():
|
||||
from tools.nodes.arrangement.flow import origin_socket
|
||||
from tools.nodes.arrangement.zones import node_zones
|
||||
|
||||
obj, _ = diagonal_surface()
|
||||
group = obj.modifiers[0].node_group
|
||||
entry, _, members = next(
|
||||
zone for zone in node_zones(group)
|
||||
if origin_socket(zone[0].inputs["Iterations"].links[0].from_socket).name == "Boundary Smooth"
|
||||
)
|
||||
nearest = next(node for node in group.nodes if node.bl_idname == "GeometryNodeSampleNearest"
|
||||
and any(link.to_node in members for link in node.outputs["Index"].links))
|
||||
fixed_position = origin_socket(nearest.inputs["Sample Position"].links[0].from_socket).node
|
||||
assert fixed_position.bl_idname == "GeometryNodeSampleIndex"
|
||||
source = origin_socket(entry.inputs["Geometry"].links[0].from_socket)
|
||||
assert origin_socket(fixed_position.inputs["Geometry"].links[0].from_socket) == source
|
||||
assert fixed_position.domain == "POINT"
|
||||
assert any(node.bl_idname == "GeometryNodeSeparateGeometry" for node in members)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["definition", "asset"])
|
||||
def test_every_split_node_enables_boundary_smoothing_by_default(source):
|
||||
from tools.nodes.build import OUTPUT_PATH, build_node_groups
|
||||
from tools.nodes.arrangement.flow import origin_socket
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
if source == "definition":
|
||||
groups = build_node_groups()
|
||||
else:
|
||||
with bpy.data.libraries.load(str(OUTPUT_PATH)) as (library, target):
|
||||
target.node_groups = list(library.node_groups)
|
||||
groups = target.node_groups
|
||||
split_groups = []
|
||||
for group in groups:
|
||||
inputs = {item.name: item for item in group.interface.items_tree
|
||||
if item.item_type == "SOCKET" and item.in_out == "INPUT"}
|
||||
assert "Edge Smooth" not in inputs
|
||||
if "Split Threshold" not in inputs:
|
||||
continue
|
||||
split_groups.append(group.name)
|
||||
smooth = inputs["Boundary Smooth"]
|
||||
assert smooth.default_value == (5 if group.name == "O Image Depth Panorama" else 2)
|
||||
assert (smooth.min_value, smooth.max_value) == (1 if "Outline Depth Fix" in inputs else 0, 20)
|
||||
assert smooth.parent.name == "Options"
|
||||
assert not {"Depth Blur Iterations", "Depth Band Rings", "Depth Blur Strength", "Skip Inactive Geometry"} & inputs.keys()
|
||||
depth_blurs = [n for n in group.nodes if n.bl_idname == "GeometryNodeBlurAttribute"
|
||||
and n.data_type == "FLOAT" and n.inputs["Iterations"].is_linked
|
||||
and origin_socket(n.inputs["Iterations"].links[0].from_socket).name == "Boundary Smooth"]
|
||||
assert len(depth_blurs) == 1
|
||||
if "Outline Depth Fix" in inputs:
|
||||
assert inputs["Outline Depth Fix"].default_value == 16
|
||||
assert origin_socket(depth_blurs[0].inputs["Weight"].links[0].from_socket).node.bl_idname == "GeometryNodeSampleIndex"
|
||||
continue
|
||||
depth_bypass = [n for n in group.nodes if n.bl_idname == "GeometryNodeSwitch"
|
||||
and n.input_type == "FLOAT" and n.inputs["Switch"].is_linked
|
||||
and origin_socket(n.inputs["Switch"].links[0].from_socket).name == "Boundary Smooth"]
|
||||
assert len(depth_bypass) == 1
|
||||
assert set(split_groups) == {
|
||||
"O Image Depth Plane", "O Image Depth Cutout", "O Image Depth Panorama",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["definition", "asset"])
|
||||
def test_validity_cut_smoothing_preserves_original_outline_and_interior(source):
|
||||
def test_validity_cut_smoothing_preserves_original_outline_and_interior():
|
||||
from tests.support.planes import create_surface, evaluated as plane_mesh
|
||||
|
||||
obj, set_value, _, _, image = create_surface(source, "DEPTH")
|
||||
obj, set_value, _, _, image = create_surface("DEPTH")
|
||||
set_value("Subdivide", 5)
|
||||
set_value("Split Threshold", 0)
|
||||
set_value("Valid Only", False)
|
||||
set_value("Depth Split", 0)
|
||||
set_value("Depth Mask", False)
|
||||
whole = plane_mesh(obj)[0]
|
||||
low, high = whole.min(axis=0), whole.max(axis=0)
|
||||
pixels = np.array(image.pixels[:], np.float32).reshape(8, 16, 4)
|
||||
@@ -252,7 +181,7 @@ def test_validity_cut_smoothing_preserves_original_outline_and_interior(source):
|
||||
pixels[..., 3] = xx < 5 + yy * 0.7
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.update()
|
||||
set_value("Valid Only", True)
|
||||
set_value("Depth Mask", True)
|
||||
before, faces, uv, *_ = plane_mesh(obj)
|
||||
boundary = boundary_neighbors(faces)
|
||||
outer = np.any(np.isclose(before[:, :2], low[:2]) | np.isclose(before[:, :2], high[:2]), axis=1)
|
||||
@@ -274,7 +203,7 @@ def test_validity_cut_smoothing_preserves_original_outline_and_interior(source):
|
||||
set_value("Thickness", 0.03)
|
||||
assert_closed(plane_mesh(obj)[1])
|
||||
set_value("Thickness", 0)
|
||||
set_value("Valid Only", False)
|
||||
set_value("Depth Mask", False)
|
||||
np.testing.assert_array_equal(plane_mesh(obj)[0], whole)
|
||||
|
||||
|
||||
@@ -284,7 +213,7 @@ def test_panorama_validity_cut_smoothing_stays_in_two_ring_band():
|
||||
yy, xx = np.mgrid[:64, :128]
|
||||
alpha = ((xx-64)**2 + (yy-32)**2 > 15**2).astype(np.float32)
|
||||
obj, set_value = panorama(np.full((64, 128), 3), alpha, subdivide=4)
|
||||
set_value("Split Threshold", 0)
|
||||
set_value("Depth Split", 0)
|
||||
before, faces, uv = panorama_mesh(obj)
|
||||
band = edge_band(faces, boundary_neighbors(faces))
|
||||
outside = [i for i in range(len(before)) if i not in band]
|
||||
@@ -294,45 +223,3 @@ def test_panorama_validity_cut_smoothing_stays_in_two_ring_band():
|
||||
np.testing.assert_array_equal(result_uv, uv)
|
||||
np.testing.assert_array_equal(after[outside], before[outside])
|
||||
assert np.max(np.linalg.norm(after-before, axis=1)) > 1e-4
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["definition", "asset"])
|
||||
@pytest.mark.parametrize("kind", ["cutout", "plane", "panorama"])
|
||||
@pytest.mark.parametrize("iterations,weight", [(0, 1.0), (8, 1.0), (8, 0.0)])
|
||||
def test_boundary_loop_bypass_uses_iterations_only(source, kind, iterations, weight):
|
||||
from anyimage.common.node import node_asset_path
|
||||
from tests.support.planes import create_surface
|
||||
from tests.tools.nodes.test_image_depth_panorama import panorama
|
||||
from tools.nodes.arrangement.flow import origin_socket
|
||||
|
||||
if kind == "cutout":
|
||||
obj, set_value = surface(resolution=4)
|
||||
elif kind == "plane":
|
||||
obj, set_value, *_ = create_surface("definition", "DEPTH")
|
||||
else:
|
||||
obj, set_value = panorama(subdivide=2)
|
||||
if source == "asset":
|
||||
with bpy.data.libraries.load(str(node_asset_path())) as (_, target):
|
||||
target.node_groups = [obj.modifiers[0].node_group.name]
|
||||
obj.modifiers[0].node_group = target.node_groups[0].copy()
|
||||
set_value("Split Threshold", 0)
|
||||
set_value("Smooth Weight", weight)
|
||||
set_value("Boundary Smooth", iterations)
|
||||
group = obj.modifiers[0].node_group
|
||||
entry = next(n for n in group.nodes if n.bl_idname == "GeometryNodeRepeatInput"
|
||||
and origin_socket(n.inputs["Iterations"].links[0].from_socket).name == "Boundary Smooth")
|
||||
bypass = next(n for n in group.nodes if n.bl_idname == "GeometryNodeSwitch"
|
||||
and n.input_type == "GEOMETRY" and n.inputs["True"].is_linked
|
||||
and origin_socket(n.inputs["True"].links[0].from_socket).node == entry.paired_output)
|
||||
control = origin_socket(bypass.inputs["Switch"].links[0].from_socket)
|
||||
assert control.name == "Boundary Smooth"
|
||||
assert control.identifier == origin_socket(entry.inputs["Iterations"].links[0].from_socket).identifier
|
||||
assert origin_socket(bypass.inputs["False"].links[0].from_socket) == origin_socket(entry.inputs["Geometry"].links[0].from_socket)
|
||||
original, faces = evaluated(obj)
|
||||
group.links.remove(bypass.inputs["Switch"].links[0])
|
||||
bypass.inputs["Switch"].default_value = True
|
||||
obj.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
unskipped, unskipped_faces = evaluated(obj)
|
||||
np.testing.assert_array_equal(original, unskipped)
|
||||
assert faces == unskipped_faces
|
||||
|
||||
@@ -70,69 +70,31 @@ def test_blender_path_reports_missing_configuration(tmp_path):
|
||||
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", ["node-group", "build"])
|
||||
|
||||
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")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("skip_tests", [False, True])
|
||||
def test_check_validates_without_building(monkeypatch, skip_tests):
|
||||
launcher = load_launcher()
|
||||
calls = []
|
||||
monkeypatch.setattr(launcher, "blender_path", lambda _: "blender")
|
||||
monkeypatch.setattr(launcher, "run", calls.append)
|
||||
monkeypatch.setattr("sys.argv", ["node-group", "check", *(["--skip-tests"] if skip_tests else [])])
|
||||
launcher.main()
|
||||
assert len(calls) == (1 if skip_tests else 2)
|
||||
assert "tools.nodes.check" in calls[0][-1]
|
||||
assert "tools.nodes.build" not in calls[0][-1]
|
||||
|
||||
|
||||
def test_build_stops_after_blender_failure(monkeypatch):
|
||||
launcher = load_launcher()
|
||||
monkeypatch.setattr(launcher, "blender_path", lambda _: "blender")
|
||||
monkeypatch.setattr("sys.argv", ["node-group", "build"])
|
||||
with patch.object(launcher, "run", side_effect=RuntimeError("failed")) as run:
|
||||
with pytest.raises(RuntimeError, match="failed"):
|
||||
launcher.main()
|
||||
assert run.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arguments,opens", [([], True), (["--no-open"], False), (["--fragment"], False)])
|
||||
def test_preview_passes_isolated_arguments_and_opens_result(monkeypatch, tmp_path, arguments, opens):
|
||||
@pytest.mark.parametrize("arguments,modules,run_tests", [
|
||||
(["build"], ("tools.nodes.build", "tools.nodes.check"), True),
|
||||
(["check", "--skip-tests"], ("tools.nodes.check",), False),
|
||||
(["preview", "--fragment"], ("tools.nodes.preview.export",), False),
|
||||
])
|
||||
def test_commands_dispatch_requested_work(monkeypatch, tmp_path, arguments, modules, run_tests):
|
||||
launcher = load_launcher()
|
||||
output = tmp_path / "node groups.html"
|
||||
calls = []
|
||||
command = ["node-group", *arguments]
|
||||
if arguments[0] == "preview":
|
||||
command += ["--output", str(output)]
|
||||
monkeypatch.setattr("sys.argv", command)
|
||||
monkeypatch.setattr(launcher, "blender_path", lambda _: "blender")
|
||||
monkeypatch.setattr(launcher, "run", calls.append)
|
||||
monkeypatch.setattr("sys.argv", ["node-group", "preview", "--output", str(output), *arguments])
|
||||
with patch.object(launcher.webbrowser, "open") as open_browser:
|
||||
with (
|
||||
patch.object(launcher, "run_blender_modules") as blender,
|
||||
patch.object(launcher, "run") as run,
|
||||
patch.object(launcher.webbrowser, "open") as open_browser,
|
||||
):
|
||||
launcher.main()
|
||||
assert len(calls) == 1
|
||||
expression = calls[0][-1]
|
||||
import ast
|
||||
assignment = next(
|
||||
node for node in ast.parse(expression).body
|
||||
if isinstance(node, ast.Assign) and ast.unparse(node.targets[0]) == "sys.argv"
|
||||
)
|
||||
assert ast.literal_eval(assignment.value) == [
|
||||
"tools.nodes.preview.export", "--output", str(output),
|
||||
*(["--fragment"] if "--fragment" in arguments else []),
|
||||
]
|
||||
if opens:
|
||||
open_browser.assert_called_once_with(output.as_uri())
|
||||
else:
|
||||
if arguments[0] == "preview":
|
||||
blender.assert_called_once_with("blender", modules, ["--output", str(output), "--fragment"])
|
||||
open_browser.assert_not_called()
|
||||
else:
|
||||
blender.assert_called_once_with("blender", modules)
|
||||
if run_tests:
|
||||
run.assert_called_once_with((launcher.sys.executable, "-m", "pytest"))
|
||||
else:
|
||||
run.assert_not_called()
|
||||
|
||||
@@ -37,7 +37,7 @@ def test_boundary_triangle_cleanup_is_one_pass():
|
||||
assert all(len(f) == 3 for f in faces)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("iterations", [0, 5, 12, 20])
|
||||
@pytest.mark.parametrize("iterations", [0, 20])
|
||||
def test_cleaned_sawtooth_smoothing_preserves_face_orientation(iterations):
|
||||
obj, set_value = diagonal_surface(triangles=True)
|
||||
original, faces = evaluated(obj)
|
||||
@@ -50,8 +50,7 @@ def test_cleaned_sawtooth_smoothing_preserves_face_orientation(iterations):
|
||||
assert np.all(np.sum(normals(original)*normals(points), axis=1) > 0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("resolution", [12, 24])
|
||||
@pytest.mark.parametrize("aspect", [.5, 2])
|
||||
@pytest.mark.parametrize("resolution,aspect", [(12, .5), (24, 2)])
|
||||
def test_split_profile_tapers_within_boundary_band(resolution, aspect):
|
||||
obj, set_value = surface(step=6, resolution=resolution)
|
||||
for vertex in obj.data.vertices:
|
||||
@@ -65,7 +64,7 @@ def test_split_profile_tapers_within_boundary_band(resolution, aspect):
|
||||
"value", np.full(len(obj.data.vertices), .3, dtype=np.float32))
|
||||
set_value("Depth Scale", 0)
|
||||
np.testing.assert_allclose(profile_values(obj), .3)
|
||||
set_value("Split Threshold", .1)
|
||||
set_value("Depth Split", .1)
|
||||
points, faces = evaluated(obj)
|
||||
boundary = boundary_neighbors(faces)
|
||||
cut = [i for i in boundary if 0 < points[i,1] < 1 and .5*aspect < points[i,0] < 1.5*aspect]
|
||||
@@ -78,7 +77,7 @@ def test_split_profile_tapers_within_boundary_band(resolution, aspect):
|
||||
np.testing.assert_allclose(values[outside], .3)
|
||||
assert np.isfinite(values).all()
|
||||
assert values.max() == pytest.approx(.3)
|
||||
set_value("Split Threshold", 0)
|
||||
set_value("Depth Split", 0)
|
||||
np.testing.assert_allclose(profile_values(obj), .3)
|
||||
|
||||
|
||||
@@ -87,13 +86,13 @@ def test_split_profile_tapers_within_boundary_band(resolution, aspect):
|
||||
def test_normal_blend_converges_to_raw_and_reaches_full_smoothing(mode, reference):
|
||||
obj, set_value = surface(resolution=2)
|
||||
group = obj.modifiers[0].node_group
|
||||
# The production interface renames the uniform socket after building.
|
||||
# The production interface renames the Shell thickness socket after building.
|
||||
socket = next(s for s in group.interface.items_tree if s.item_type == "SOCKET" and s.in_out == "INPUT"
|
||||
and s.name == "Thickness" and s.subtype == "DISTANCE")
|
||||
socket.name = "Uniform Thickness"
|
||||
uniform = group.nodes.new("FunctionNodeInputBool")
|
||||
uniform.boolean = bool(mode)
|
||||
weight = build_normal_weight(group, uniform.outputs[0])
|
||||
socket.name = "Shell Thickness"
|
||||
is_shell = group.nodes.new("FunctionNodeInputBool")
|
||||
is_shell.boolean = bool(mode)
|
||||
weight = build_normal_weight(group, is_shell.outputs[0])
|
||||
raw = group.nodes.new("ShaderNodeCombineXYZ")
|
||||
raw.inputs["Z"].default_value = 1
|
||||
blurred = group.nodes.new("ShaderNodeCombineXYZ")
|
||||
|
||||
@@ -4,67 +4,36 @@ import bpy
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from tools.nodes.groups.image_cutout import build_image_cutout_group
|
||||
from tests.support.nodes import CUTOUT_BUILDERS
|
||||
from tools.nodes.arrangement.flow import origin_socket
|
||||
|
||||
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):
|
||||
@pytest.mark.parametrize("source", ("definition", "asset"))
|
||||
@pytest.mark.parametrize("shape", ("SOLID", "DEPTH_SOLID", "DEPTH_BALLOON"))
|
||||
def test_depth_axis_is_visible_in_options(shape, source):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
group = CUTOUT_BUILDERS[shape]() if source == "definition" else load_cutout_node_group(shape)
|
||||
inputs = {item.name: item for item in group.interface.items_tree
|
||||
if item.item_type == "SOCKET" and item.in_out == "INPUT"}
|
||||
axis = inputs["Depth Axis"]
|
||||
assert axis.parent.name == "Options"
|
||||
assert axis.menu_expanded
|
||||
assert not axis.hide_in_modifier
|
||||
assert axis.default_value == "-Z"
|
||||
assert "Inward Axis" not in inputs
|
||||
options = [item.name for item in inputs.values() if item.parent == axis.parent]
|
||||
predecessor = "Smooth Weight" if shape == "SOLID" else "Reference Depth"
|
||||
assert options[options.index(predecessor) + 1] == "Depth Axis"
|
||||
if shape == "SOLID":
|
||||
assert not any(item.item_type == "PANEL" and item.name == "Data" for item in group.interface.items_tree)
|
||||
|
||||
|
||||
def test_cutout_thickness_modes():
|
||||
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)
|
||||
revision = 23 if group == groups["DEPTH_SOLID"] else 12 if group == groups["DEPTH_BALLOON"] else 11
|
||||
assert group.is_modifier and group.get("anyimage_asset_revision") == revision
|
||||
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["Split Threshold"].default_value, 0.1)
|
||||
assert not {"Edge Round", "Normal Smooth"} & inputs.keys()
|
||||
normal_blurs = [n for n in group.nodes if n.bl_idname == "GeometryNodeBlurAttribute" and n.data_type == "FLOAT_VECTOR"
|
||||
and not n.inputs["Iterations"].is_linked and n.inputs["Iterations"].default_value == 100]
|
||||
assert len(normal_blurs) == 2
|
||||
assert inputs["Front Inflation"].default_value == 0
|
||||
assert inputs["Front Inflation"].subtype == "FACTOR"
|
||||
assert (inputs["Front Inflation"].min_value, inputs["Front Inflation"].max_value) == (0, 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 += ["Smooth Weight", "Reference Depth", "Boundary Smooth", "Outline Depth Fix", "Front Inflation"]
|
||||
assert inputs["Outline Depth Fix"].default_value == 16
|
||||
assert not set(inputs) & {"Ratio Lower", "Ratio Upper", "Weight Blur Iterations", "Weight Update Rounds", "Depth Steps Per Round"}
|
||||
elif group == groups["DEPTH_BALLOON"]:
|
||||
expected += ["Smooth Weight", "Reference Depth"]
|
||||
if group not in (groups["DEPTH_SOLID"], groups["DEPTH_BALLOON"]):
|
||||
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.to_node.bl_idname != "NodeReroute"
|
||||
and origin_socket(link.from_socket).node.bl_idname == "NodeGroupInput"
|
||||
and origin_socket(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")
|
||||
@@ -72,7 +41,7 @@ def test_cutout_interfaces_and_thickness_modes(source):
|
||||
obj = bpy.data.objects.new("Profile", mesh)
|
||||
bpy.context.collection.objects.link(obj)
|
||||
modifier = obj.modifiers.new("Cutout", "NODES")
|
||||
modifier.node_group = groups["SOLID"]
|
||||
modifier.node_group = build_image_cutout_group()
|
||||
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
|
||||
@@ -88,8 +57,8 @@ def test_cutout_interfaces_and_thickness_modes(source):
|
||||
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)
|
||||
back, front = (-0.6, 0.6) if mode == 0 else (-thickness, 0.0)
|
||||
assert np.isclose(z.min(), back) and np.isclose(z.max(), front)
|
||||
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:
|
||||
|
||||
@@ -35,7 +35,7 @@ def set_test_structure(monkeypatch, value):
|
||||
monkeypatch.setattr(image_depth_cutout, "build_front_fields", fields)
|
||||
|
||||
|
||||
def test_uniform_thickness_follows_the_tilted_front_normal():
|
||||
def test_shell_thickness_extends_behind_the_tilted_front():
|
||||
from scipy.spatial import cKDTree
|
||||
from tests.support.depth_surface import surface, evaluated, assert_closed
|
||||
obj, set_value = surface(step=0, resolution=4)
|
||||
@@ -48,6 +48,7 @@ def test_uniform_thickness_follows_the_tilted_front_normal():
|
||||
bpy.context.view_layer.update()
|
||||
front, _ = evaluated(obj)
|
||||
set_value("Thickness", 0.2)
|
||||
set_value("Front Inflation", 0.5)
|
||||
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
|
||||
@@ -140,12 +141,12 @@ def test_depth_cutout_normal_map_only_affects_front_faces():
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("structure", [0, 1, 4])
|
||||
@pytest.mark.parametrize("thickness", [0.0, 0.02, 0.1])
|
||||
@pytest.mark.parametrize("inflation", [0.0, 0.0001, 0.5, 1.0])
|
||||
def test_uniform_front_inflation_scales_with_distance(thickness, structure, inflation, monkeypatch):
|
||||
@pytest.mark.parametrize("thickness,inflation", [
|
||||
(0.0, 1.0), (0.02, 0.0), (0.02, 0.0001),
|
||||
(0.02, 0.5), (0.1, 0.5), (0.1, 1.0),
|
||||
])
|
||||
def test_shell_ignores_front_inflation(thickness, inflation):
|
||||
from tests.support.depth_surface import surface, evaluated, assert_closed
|
||||
set_test_structure(monkeypatch, structure)
|
||||
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])
|
||||
@@ -157,14 +158,9 @@ def test_uniform_front_inflation_scales_with_distance(thickness, structure, infl
|
||||
set_value("Front Inflation", inflation)
|
||||
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 * inflation, atol=2e-6)
|
||||
np.testing.assert_allclose(delta[~front], 0, atol=2e-6)
|
||||
np.testing.assert_array_equal(inflated, baseline)
|
||||
if thickness:
|
||||
assert_closed(inflated_faces)
|
||||
else:
|
||||
np.testing.assert_array_equal(inflated, baseline)
|
||||
|
||||
|
||||
def test_balloon_suppression_is_stronger_without_a_startup_threshold(monkeypatch):
|
||||
|
||||
@@ -6,8 +6,6 @@ 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])
|
||||
@@ -59,7 +57,7 @@ def test_shell_keeps_front_and_closes_rectangular_boundary(surface, thickness):
|
||||
set_value("Depth Scale", 1.0)
|
||||
front = evaluated(obj)[0]
|
||||
set_value("Thickness", thickness)
|
||||
points, faces, uv, materials, indices, attributes = evaluated(obj)
|
||||
points, faces, uv, materials, indices, _ = 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}
|
||||
@@ -70,7 +68,6 @@ def test_shell_keeps_front_and_closes_rectangular_boundary(surface, thickness):
|
||||
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])
|
||||
@@ -84,16 +81,15 @@ def test_depth_split_preserves_faces_and_closes_each_shell(surface, valid_only):
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.update()
|
||||
set_value("Depth Scale", 1.0)
|
||||
set_value("Valid Only", valid_only)
|
||||
set_value("Depth Mask", valid_only)
|
||||
base = evaluated(obj)
|
||||
set_value("Split Threshold", 1.0)
|
||||
points, faces, uv, materials, indices, attributes = evaluated(obj)
|
||||
set_value("Depth Split", 1.0)
|
||||
points, faces, uv, materials, indices, _ = 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)
|
||||
@@ -102,7 +98,7 @@ def test_depth_split_preserves_faces_and_closes_each_shell(surface, valid_only):
|
||||
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)
|
||||
set_value("Depth Split", 0.0)
|
||||
restored = evaluated(obj)
|
||||
np.testing.assert_allclose(restored[0], base[0])
|
||||
assert restored[1] == base[1]
|
||||
@@ -197,35 +193,6 @@ def test_curved_shell_smooths_normals_without_shrinking_thickness(surface):
|
||||
assert roughness(normals) < roughness(raw_normals)
|
||||
|
||||
|
||||
def test_surface_interface_and_node_structure(surface):
|
||||
obj, _, _, inputs, _ = surface
|
||||
expected = ["Geometry", "Subdivide", "Thickness", "Depth Scale", "Split Threshold", "Smooth", "Valid Only",
|
||||
"Smooth Weight", "Reference Depth", "Boundary Smooth", "Validity Threshold", "Uniform Scale", "Depth Image"]
|
||||
assert list(inputs) == expected
|
||||
assert inputs["Split Threshold"].default_value == pytest.approx(0.1)
|
||||
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 == inputs["Smooth"].parent
|
||||
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 not {"Normal Smooth", "Cleanup Threshold"} & inputs.keys()
|
||||
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("thickness", [0.0, 0.4])
|
||||
@pytest.mark.parametrize("direction", [(0.2, -0.1, 1), (-0.2, 0.1, 1), (0.4, -0.2, 2), (0, 0, 0), (1, 0, 0)])
|
||||
@@ -278,30 +245,13 @@ def test_relief_smooth_preserves_topology_uv_and_materials(relief, thickness):
|
||||
np.testing.assert_allclose(evaluated(obj)[0], base[0], atol=1e-6)
|
||||
|
||||
|
||||
def test_relief_interface_and_node_structure(relief):
|
||||
obj, _, _, inputs, _ = relief
|
||||
assert list(inputs) == ["Geometry", "Subdivide", "Thickness", "Depth Scale", "Smooth",
|
||||
"Reference Depth", "Smooth Weight", "Depth Direction", "Uniform Scale", "Depth Image"]
|
||||
assert inputs["Smooth"].default_value == 0
|
||||
assert (inputs["Smooth"].min_value, inputs["Smooth"].max_value) == (0, 20)
|
||||
assert inputs["Smooth Weight"].default_value == 1
|
||||
assert inputs["Smooth Weight"].subtype == "FACTOR"
|
||||
assert tuple(inputs["Depth Direction"].default_value) == (0, 0, 1)
|
||||
assert inputs["Depth Direction"].subtype == "DIRECTION"
|
||||
assert inputs["Thickness"].default_value == 0.5
|
||||
assert inputs["Subdivide"].default_value == 6
|
||||
assert inputs["Depth Scale"].default_value == 0.5
|
||||
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"])
|
||||
@pytest.mark.parametrize("pattern,threshold", [
|
||||
("valid", 1.0), ("invalid", 0.0), ("invalid", 0.9),
|
||||
("partial", 0.4), ("partial", 0.9), ("half", 0.9), ("stripe", 0.9),
|
||||
])
|
||||
def test_valid_only_deletes_invalid_points_and_wire_edges(surface, pattern, threshold):
|
||||
obj, set_value, _, inputs, _ = surface
|
||||
set_value("Validity Threshold", threshold)
|
||||
set_value("Mask 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)
|
||||
@@ -319,18 +269,18 @@ def test_valid_only_deletes_invalid_points_and_wire_edges(surface, pattern, thre
|
||||
depth_image.update()
|
||||
for subdivisions in (0, 2, 5):
|
||||
set_value("Subdivide", subdivisions)
|
||||
set_value("Valid Only", False)
|
||||
set_value("Depth Mask", 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 np.all(sampled[list(face)] >= threshold)]
|
||||
set_value("Valid Only", True)
|
||||
set_value("Depth Mask", 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()
|
||||
set_value("Valid Only", False)
|
||||
set_value("Depth Mask", False)
|
||||
np.testing.assert_allclose(evaluated(obj)[0], base[0])
|
||||
|
||||
|
||||
@@ -351,26 +301,6 @@ def test_valid_only_precedes_closed_shell_generation(surface):
|
||||
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) == ("POINT", "ALL")
|
||||
compare = origin_socket(delete.inputs["Selection"].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)
|
||||
protected = origin_socket(delete.inputs["Geometry"].links[0].from_socket).node
|
||||
assert protected.bl_idname == "GeometryNodeGroup"
|
||||
assert protected.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
|
||||
@@ -394,21 +324,3 @@ def test_depth_smooth_changes_positions_and_preserves_topology(surface, thicknes
|
||||
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 = ["Smooth Weight", "Reference Depth", "Boundary Smooth", "Validity Threshold"]
|
||||
else:
|
||||
controls += ["Smooth Weight", "Depth Direction"]
|
||||
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)
|
||||
|
||||
@@ -5,9 +5,6 @@ import numpy as np
|
||||
import pytest
|
||||
|
||||
from tools.nodes.common.depth_surface import _remove_strip_faces
|
||||
from tools.nodes.groups.image_depth_cutout import build_image_depth_cutout_group
|
||||
from tools.nodes.groups.image_depth_plane import build_image_depth_plane_group
|
||||
from tools.nodes.groups.image_plane import build_image_plane_group
|
||||
|
||||
|
||||
def cleaned_faces(vertices, faces, *, triangles=False):
|
||||
@@ -122,30 +119,13 @@ def test_triangle_cleanup_marks_both_faces_without_recursive_peeling():
|
||||
assert cleaned_faces(vertices, faces, triangles=True) == {0, 2, 3}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["plane", "cutout"])
|
||||
def test_split_input_precedes_smooth_without_cleanup_switch(kind):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
group = (build_image_depth_plane_group(build_image_plane_group()) if kind == "plane"
|
||||
else build_image_depth_cutout_group())
|
||||
inputs = [s for s in group.interface.items_tree if s.item_type == "SOCKET" and s.in_out == "INPUT"]
|
||||
names = [s.name for s in inputs]
|
||||
assert names.count("Split Threshold") == 1
|
||||
assert names[names.index("Smooth") - 1] == "Split Threshold"
|
||||
split = inputs[names.index("Split Threshold")]
|
||||
assert split.parent == inputs[names.index("Smooth")].parent
|
||||
assert split.parent.name != "Options"
|
||||
assert split.default_value == pytest.approx(0.1)
|
||||
assert (split.min_value, split.max_value, split.subtype) == (0, 1, "FACTOR")
|
||||
assert "Remove Strip Faces" not in names
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["plane", "cutout"])
|
||||
def test_strips_are_removed_before_projection_and_closed_thickness(kind):
|
||||
from tests.support.depth_surface import surface, evaluated, assert_closed, component_count
|
||||
from tests.support.planes import create_surface
|
||||
|
||||
if kind == "plane":
|
||||
obj, set_value, _, _, image = create_surface("definition", "DEPTH")
|
||||
obj, set_value, _, _, image = create_surface("DEPTH")
|
||||
set_value("Subdivide", 1)
|
||||
else:
|
||||
obj, set_value = surface(step=0.0)
|
||||
@@ -159,7 +139,7 @@ def test_strips_are_removed_before_projection_and_closed_thickness(kind):
|
||||
set_value("Depth Scale", 1.0)
|
||||
set_value("Reference Depth", 11.0)
|
||||
assert len(evaluated(obj)[1]) == 8
|
||||
set_value("Split Threshold", 0.5)
|
||||
set_value("Depth Split", 0.5)
|
||||
vertices, faces = evaluated(obj)
|
||||
assert len(faces) == 4 and len(vertices) == 9
|
||||
assert component_count(faces) == 1
|
||||
@@ -183,18 +163,18 @@ def test_zero_split_bypasses_cleanup_and_positive_split_can_empty_mesh(kind):
|
||||
from tests.support.planes import create_surface
|
||||
|
||||
if kind == "plane":
|
||||
obj, set_value, _, _, _ = create_surface("definition", "DEPTH")
|
||||
obj, set_value, _, _, _ = create_surface("DEPTH")
|
||||
set_value("Subdivide", 0)
|
||||
else:
|
||||
obj, set_value = surface(step=0.0, resolution=1)
|
||||
original, faces = evaluated(obj)
|
||||
assert len(faces) == 2
|
||||
set_value("Split Threshold", 0.001)
|
||||
set_value("Depth Split", 0.001)
|
||||
assert not evaluated(obj)[1]
|
||||
set_value("Thickness", 0.2)
|
||||
assert not evaluated(obj)[1]
|
||||
set_value("Thickness", 0.0)
|
||||
set_value("Split Threshold", 0.0)
|
||||
set_value("Depth Split", 0.0)
|
||||
restored, restored_faces = evaluated(obj)
|
||||
assert restored_faces == faces
|
||||
np.testing.assert_array_equal(restored, original)
|
||||
@@ -212,7 +192,7 @@ def test_cutout_removes_an_all_boundary_triangle_strip():
|
||||
mesh.free()
|
||||
# Every vertex lies on the boundary, even before any depth separation.
|
||||
for split in (0.0, 0.001, 0.0):
|
||||
set_value("Split Threshold", split)
|
||||
set_value("Depth Split", split)
|
||||
for thickness in (0.0, 0.2):
|
||||
set_value("Thickness", thickness)
|
||||
points, faces = evaluated(obj)
|
||||
|
||||
@@ -7,7 +7,6 @@ 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
|
||||
|
||||
|
||||
@@ -20,7 +19,7 @@ def test_shell_walls_directly_connect_caps(mode, split):
|
||||
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)
|
||||
set_value("Depth Split", 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())
|
||||
@@ -51,7 +50,7 @@ def test_shell_walls_directly_connect_caps(mode, split):
|
||||
@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("Depth Split", split)
|
||||
set_value("Thickness", thickness)
|
||||
coords, faces = evaluated(obj)
|
||||
assert np.isfinite(coords).all()
|
||||
@@ -69,7 +68,7 @@ def test_split_mapping_and_uniform_scale(size):
|
||||
# The normalized ray jump crosses the threshold only at full strength.
|
||||
counts = []
|
||||
for split in (0.0, 0.25, 0.5, 1.0):
|
||||
set_value("Split Threshold", split)
|
||||
set_value("Depth Split", split)
|
||||
coords, faces = evaluated(obj)
|
||||
counts.append(len(coords))
|
||||
assert len(faces) == 8
|
||||
@@ -82,7 +81,7 @@ def test_split_mapping_and_uniform_scale(size):
|
||||
|
||||
def test_midpoint_selects_relative_ray_jump_and_preserves_faces():
|
||||
obj, set_value = surface(step=4.0)
|
||||
set_value("Split Threshold", 0.5)
|
||||
set_value("Depth Split", 0.5)
|
||||
coords, faces = evaluated(obj)
|
||||
assert len(coords) == 18 and len(faces) == 8
|
||||
assert set(np.round(coords[:, 2], 4)) == {2.0, -2.0}
|
||||
@@ -92,7 +91,7 @@ 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)
|
||||
set_value("Depth Split", 0.5)
|
||||
assert len(evaluated(obj)[1]) == 8
|
||||
set_value("Thickness", 0.2)
|
||||
assert_closed(evaluated(obj)[1])
|
||||
@@ -103,7 +102,7 @@ def test_cleanup_keeps_split_parts_of_a_large_source_island():
|
||||
@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=4.0)
|
||||
set_value("Split Threshold", 0.5)
|
||||
set_value("Depth Split", 0.5)
|
||||
original, original_faces = evaluated(obj)
|
||||
set_value("Depth Scale", depth_scale)
|
||||
coords, faces = evaluated(obj)
|
||||
@@ -112,18 +111,9 @@ def test_split_topology_uses_depth_before_display_scaling(depth_scale):
|
||||
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("Depth Split", 0.5)
|
||||
set_value("Thickness", 0.2)
|
||||
set_value("Smooth", 2)
|
||||
coords, faces = evaluated(obj)
|
||||
@@ -183,7 +173,7 @@ def test_shell_source_indices_cross_identity_grid_rows():
|
||||
def test_planar_depth_preserves_blocks_and_cleans_single_face_width(resolution):
|
||||
obj, set_value = surface(step=0.0, resolution=resolution)
|
||||
original, original_faces = evaluated(obj)
|
||||
set_value("Split Threshold", 1.0)
|
||||
set_value("Depth Split", 1.0)
|
||||
coords, faces = evaluated(obj)
|
||||
if resolution == 1:
|
||||
assert len(coords) == 0 and not faces
|
||||
@@ -196,7 +186,7 @@ def test_planar_depth_preserves_blocks_and_cleans_single_face_width(resolution):
|
||||
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)
|
||||
set_value("Depth Split", 0.01)
|
||||
coords, faces = evaluated(obj)
|
||||
np.testing.assert_allclose(coords, original, atol=1e-6)
|
||||
assert faces == original_faces
|
||||
@@ -205,7 +195,7 @@ def test_small_positive_split_preserves_unsplit_projection():
|
||||
def test_cut_vertices_preserve_camera_rays_and_other_vertices():
|
||||
obj, set_value = surface(step=4.0)
|
||||
original, original_faces = evaluated(obj)
|
||||
set_value("Split Threshold", 0.5)
|
||||
set_value("Depth Split", 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):
|
||||
@@ -220,7 +210,7 @@ def test_cut_vertices_preserve_camera_rays_and_other_vertices():
|
||||
|
||||
def test_source_surface_sampling_preserves_depth_on_side_walls():
|
||||
obj, set_value = surface(step=4.0)
|
||||
set_value("Split Threshold", 0.5)
|
||||
set_value("Depth Split", 0.5)
|
||||
set_value("Thickness", 0.1)
|
||||
coords, faces = evaluated(obj)
|
||||
assert_closed(faces)
|
||||
@@ -240,7 +230,7 @@ def test_temporary_fields_are_removed_and_user_attributes_survive(thickness, spl
|
||||
obj.data.attributes.new(".o_anyimage_user_weight", "FLOAT", "POINT")
|
||||
for value in attribute.data:
|
||||
value.value = 0.75
|
||||
set_value("Split Threshold", split)
|
||||
set_value("Depth Split", split)
|
||||
set_value("Thickness", thickness)
|
||||
set_value("Smooth", 2)
|
||||
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
@@ -279,7 +269,7 @@ def test_zero_center_distance_does_not_split_or_produce_nan():
|
||||
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)
|
||||
set_value("Depth Split", 1.0)
|
||||
coords, faces = evaluated(obj)
|
||||
assert len(coords) == 4 and len(faces) == 2
|
||||
assert np.isfinite(coords).all()
|
||||
@@ -297,7 +287,7 @@ def test_triangle_corner_weights_do_not_open_sloped_side_walls():
|
||||
pixels[..., 2] += np.arange(32)[:, None] * 0.02
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.update()
|
||||
set_value("Split Threshold", 0.5)
|
||||
set_value("Depth Split", 0.5)
|
||||
set_value("Thickness", 0.8)
|
||||
coords, faces = evaluated(obj)
|
||||
assert_closed(faces)
|
||||
@@ -319,34 +309,28 @@ def test_relative_distance_slope_has_density_independent_selection(resolution):
|
||||
pixels[..., :2] = 0
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.update()
|
||||
set_value("Split Threshold", 0.25)
|
||||
set_value("Depth Split", 0.25)
|
||||
assert len(evaluated(obj)[0]) == len(obj.data.vertices)
|
||||
set_value("Split Threshold", 1.0)
|
||||
set_value("Depth Split", 1.0)
|
||||
assert len(evaluated(obj)[0]) > len(obj.data.vertices)
|
||||
|
||||
@pytest.mark.parametrize("kind", ["plane", "cutout"])
|
||||
@pytest.mark.parametrize("source", ["definition", "asset"])
|
||||
def test_split_uses_ray_distance_and_ignores_absolute_distance_scale(kind, source):
|
||||
def test_split_uses_ray_distance_and_ignores_absolute_distance_scale(kind):
|
||||
from tests.support.planes import create_surface
|
||||
|
||||
if kind == "plane":
|
||||
obj, set_value, _, _, image = create_surface(source, "DEPTH")
|
||||
obj, set_value, _, _, image = create_surface("DEPTH")
|
||||
set_value("Subdivide", 1)
|
||||
else:
|
||||
obj, set_value = surface(step=0)
|
||||
if source == "asset":
|
||||
from tests.support.paths import PROJECT_ROOT
|
||||
with bpy.data.libraries.load(str(PROJECT_ROOT / "src/anyimage/assets/O_AnyImage.blend")) as (_, target):
|
||||
target.node_groups = ["O Image Depth Cutout"]
|
||||
obj.modifiers[0].node_group = target.node_groups[0]
|
||||
image = bpy.data.images["Camera"]
|
||||
output_split_mesh(obj)
|
||||
set_value("Reference Depth", 3)
|
||||
set_value("Split Threshold", 0)
|
||||
set_value("Depth Split", 0)
|
||||
width, height = image.size
|
||||
pixels = np.ones((height, width, 4), np.float32)
|
||||
original_count = len(evaluated(obj)[0])
|
||||
set_value("Split Threshold", 1)
|
||||
set_value("Depth Split", 1)
|
||||
# Equal axial Z, but a tenfold ray-distance jump: Z-only splitting misses it.
|
||||
pixels[..., :3] = (0, 0, 1)
|
||||
pixels[:, width // 2:, 0] = np.sqrt(99)
|
||||
|
||||
@@ -8,15 +8,11 @@ import pytest
|
||||
|
||||
from anyimage.common.object import modifier_input_identifier, set_modifier_input
|
||||
from tools.nodes.groups.image_depth_panorama import build_image_depth_panorama_group
|
||||
from tests.support.nodes import validate_node_structure
|
||||
|
||||
|
||||
def panorama(depth=3, alpha=1, subdivide=3):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
group = build_image_depth_panorama_group()
|
||||
assert group.name == "O Image Depth Panorama"
|
||||
validate_node_structure(group)
|
||||
assert not any(node.bl_idname == "GeometryNodeGroup" for node in group.nodes)
|
||||
image = bpy.data.images.new("Radial Depth", width=128, height=64, float_buffer=True)
|
||||
image.colorspace_settings.name = "Non-Color"
|
||||
image.alpha_mode = "CHANNEL_PACKED"
|
||||
@@ -37,6 +33,7 @@ def panorama(depth=3, alpha=1, subdivide=3):
|
||||
for name, value in (("Depth Image", image), ("Subdivide", subdivide)):
|
||||
set_value(name, value)
|
||||
set_value("Boundary Smooth", 0)
|
||||
set_value("Depth Split", 0)
|
||||
return obj, set_value
|
||||
|
||||
|
||||
@@ -47,7 +44,6 @@ def evaluated(obj):
|
||||
points = np.array([v.co[:] for v in mesh.vertices]).reshape(-1, 3)
|
||||
faces = [tuple(f.vertices) for f in mesh.polygons]
|
||||
uv = np.array([v.uv[:] for v in mesh.uv_layers.active.data]) if mesh.uv_layers.active else np.empty((0, 2))
|
||||
assert not any(a.name.startswith(("o_depth_", "o_panorama_")) for a in mesh.attributes)
|
||||
return points, faces, uv
|
||||
finally:
|
||||
result.to_mesh_clear()
|
||||
@@ -75,10 +71,10 @@ def test_quad_sphere_topology_and_radial_endpoints(subdivide):
|
||||
def test_depth_alpha_culls_faces_without_double_multiplication():
|
||||
obj, set_value = panorama(alpha=0.5)
|
||||
assert not len(evaluated(obj)[0])
|
||||
set_value("Valid Only", False)
|
||||
set_value("Depth Mask", False)
|
||||
assert len(evaluated(obj)[1]) == 384
|
||||
set_value("Valid Only", True)
|
||||
set_value("Validity Threshold", 0.49)
|
||||
set_value("Depth Mask", True)
|
||||
set_value("Mask Threshold", 0.49)
|
||||
assert len(evaluated(obj)[1]) == 384
|
||||
|
||||
|
||||
@@ -115,7 +111,7 @@ def test_invalid_distance_removes_faces_between_valid_and_invalid_regions(sky_al
|
||||
assert np.linalg.norm(points, axis=1).max() < 3.01
|
||||
set_value("Invalid Distance", 250)
|
||||
assert evaluated(obj)[1] == original_faces
|
||||
set_value("Valid Only", False)
|
||||
set_value("Depth Mask", False)
|
||||
for background in (100, 250):
|
||||
set_value("Invalid Distance", background)
|
||||
points, faces, _ = evaluated(obj)
|
||||
@@ -132,14 +128,13 @@ def test_controls_smoothing_and_material_slot_inheritance():
|
||||
obj, set_value = panorama()
|
||||
group = obj.modifiers[0].node_group
|
||||
inputs = {s.name: s for s in group.interface.items_tree if s.item_type == "SOCKET" and s.in_out == "INPUT"}
|
||||
assert inputs["Validity Threshold"].parent.name == "Options"
|
||||
assert inputs["Mask Threshold"].parent.name == "Options"
|
||||
assert inputs["Invalid Distance"].parent.name == "Options"
|
||||
assert inputs["Invalid Distance"].default_value == 100
|
||||
assert inputs["Invalid Distance"].subtype == "DISTANCE"
|
||||
assert inputs["Smooth Weight"].parent.name == "Options"
|
||||
assert inputs["Valid Only"].parent == inputs["Subdivide"].parent
|
||||
assert inputs["Valid Only"].default_value
|
||||
assert "Material" not in inputs
|
||||
assert inputs["Depth Mask"].parent == inputs["Subdivide"].parent
|
||||
assert inputs["Depth Mask"].default_value
|
||||
obj.data.from_pydata([(0, 0, 0)], [], [])
|
||||
material = bpy.data.materials.new("Slot Material")
|
||||
obj.data.materials.append(material)
|
||||
@@ -193,7 +188,7 @@ def test_split_strength_and_distance_scale_invariance():
|
||||
obj, set_value = panorama(depth * scale)
|
||||
counts = []
|
||||
for split in (0, 0.1, 0.5, 1):
|
||||
set_value("Split Threshold", split)
|
||||
set_value("Depth Split", split)
|
||||
points, faces, uv = evaluated(obj)
|
||||
assert np.isfinite(points).all() and np.isfinite(uv).all()
|
||||
edges = Counter(tuple(sorted((a, b))) for f in faces for a, b in zip(f, (*f[1:], f[0])))
|
||||
@@ -209,7 +204,7 @@ def test_continuous_spherical_slope_does_not_split_at_seam_or_poles():
|
||||
depth = 3 + 0.3 * np.cos(2 * np.pi * u) * np.sin(np.pi * v)
|
||||
obj, set_value = panorama(depth)
|
||||
original = evaluated(obj)
|
||||
set_value("Split Threshold", 1)
|
||||
set_value("Depth Split", 1)
|
||||
set_value("Boundary Smooth", 12)
|
||||
points, faces, uv = evaluated(obj)
|
||||
np.testing.assert_allclose(points, original[0], atol=1e-6)
|
||||
@@ -226,7 +221,7 @@ def test_split_corners_keep_their_rays_and_own_face_distance():
|
||||
original, _, _ = evaluated(obj)
|
||||
original_radii = np.linalg.norm(original, axis=1)
|
||||
original_rays = original / original_radii[:, None]
|
||||
set_value("Split Threshold", 1)
|
||||
set_value("Depth Split", 1)
|
||||
points, _, uv = evaluated(obj)
|
||||
radii = np.linalg.norm(points, axis=1)
|
||||
distance, indices = cKDTree(original_rays).query(points / radii[:, None])
|
||||
@@ -245,7 +240,7 @@ def test_zero_split_bypasses_single_snapshot_strip_cleanup(monkeypatch):
|
||||
with monkeypatch.context() as patch:
|
||||
patch.setattr(depth_surface, "_remove_strip_faces", lambda group, geometry, **kw: geometry)
|
||||
obj, set_value = panorama(depth)
|
||||
set_value("Split Threshold", 1)
|
||||
set_value("Depth Split", 1)
|
||||
_, faces, _ = evaluated(obj)
|
||||
edges = Counter(tuple(sorted((a, b))) for f in faces for a, b in zip(f, (*f[1:], f[0])))
|
||||
strips = 0
|
||||
@@ -255,7 +250,7 @@ def test_zero_split_bypasses_single_snapshot_strip_cleanup(monkeypatch):
|
||||
assert strips > 0
|
||||
obj, set_value = panorama(depth)
|
||||
original_faces = evaluated(obj)[1]
|
||||
set_value("Split Threshold", 1)
|
||||
set_value("Depth Split", 1)
|
||||
assert len(evaluated(obj)[1]) == len(faces) - strips
|
||||
set_value("Split Threshold", 0)
|
||||
set_value("Depth Split", 0)
|
||||
assert evaluated(obj)[1] == original_faces
|
||||
|
||||
@@ -50,9 +50,6 @@ def test_alpha_curve_preserves_endpoints_and_suppresses_soft_edges(image_layer):
|
||||
|
||||
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"
|
||||
|
||||
@@ -20,7 +20,7 @@ def evaluated_mesh(obj):
|
||||
|
||||
@pytest.mark.parametrize("source_count", [0, 4, 9])
|
||||
def test_material_inheritance_removes_only_source_vertices(source_count):
|
||||
from tools.nodes.common.material import inherit_material_slots
|
||||
from tools.nodes.common.material_slots import inherit_material_slots
|
||||
from tools.nodes.arrangement.arrange import arrange_nodes
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
|
||||
@@ -1,27 +1,85 @@
|
||||
"""Check the saved library's public inventory and persistence."""
|
||||
import bpy
|
||||
import pytest
|
||||
from anyimage.common.node import load_node_group, node_asset_path
|
||||
from tools.nodes.check import GEOMETRY_NAMES, SHADER_NAMES
|
||||
from anyimage.common.node import node_asset_path
|
||||
from tools.nodes.check import GEOMETRY_NAMES
|
||||
|
||||
|
||||
def test_saved_asset_inventory():
|
||||
def test_saved_assets_evaluate():
|
||||
from tools.nodes.check import check_asset_library
|
||||
|
||||
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", "Alpha"]
|
||||
assert [item.name for item in node_group.interface.items_tree
|
||||
if item.item_type == "SOCKET" and item.in_out == "INPUT"] == expected
|
||||
check_asset_library(node_asset_path())
|
||||
|
||||
|
||||
def test_saved_parameter_descriptions_match_definitions():
|
||||
from tools.nodes.build import build_node_groups
|
||||
|
||||
parameters = {
|
||||
"O Image Plane": {"Subdivide"},
|
||||
"O Image Depth Plane": {
|
||||
"Subdivide", "Thickness", "Depth Split", "Smooth", "Smooth Weight",
|
||||
"Depth Mask", "Mask Threshold", "Boundary Smooth",
|
||||
},
|
||||
"O Image Relief Plane": {"Subdivide", "Thickness", "Smooth", "Smooth Weight"},
|
||||
"O Image Cutout": {"Thickness", "Smooth", "Smooth Weight"},
|
||||
"O Image Depth Cutout": {
|
||||
"Thickness", "Depth Split", "Smooth", "Smooth Weight",
|
||||
"Boundary Smooth", "Outline Depth Fix", "Front Inflation",
|
||||
},
|
||||
"O Image Depth Balloon": {"Thickness", "Smooth", "Smooth Weight"},
|
||||
"O Image Depth Panorama": {
|
||||
"Subdivide", "Depth Scale", "Depth Split", "Smooth", "Smooth Weight",
|
||||
"Depth Mask", "Mask Threshold", "Boundary Smooth", "Invalid Distance",
|
||||
},
|
||||
"O Image Layer": {"Alpha Fix", "Normal Scale", "Bump Scale", "Object Space"},
|
||||
"O Image Depth Layer": {"Bump Scale"},
|
||||
}
|
||||
|
||||
def descriptions(groups):
|
||||
result = {}
|
||||
for group in groups:
|
||||
if group.name not in parameters:
|
||||
continue
|
||||
sockets = [
|
||||
item for item in group.interface.items_tree
|
||||
if item.item_type == "SOCKET" and item.in_out == "INPUT"
|
||||
and item.name in parameters[group.name]
|
||||
]
|
||||
assert {item.name for item in sockets} == parameters[group.name]
|
||||
assert all(item.description for item in sockets), group.name
|
||||
if group.name == "O Image Depth Panorama":
|
||||
defaults = {item.name: item.default_value for item in sockets
|
||||
if item.name in {"Subdivide", "Depth Split", "Boundary Smooth"}}
|
||||
assert defaults == pytest.approx({
|
||||
"Subdivide": 8, "Depth Split": 0.1, "Boundary Smooth": 2,
|
||||
})
|
||||
if "Depth Mask" in parameters[group.name]:
|
||||
names = [item.name for item in group.interface.items_tree
|
||||
if item.item_type == "SOCKET" and item.in_out == "INPUT"]
|
||||
assert names.index("Depth Mask") == names.index("Depth Split") + 1
|
||||
if group.name in {"O Image Cutout", "O Image Depth Cutout"}:
|
||||
mode = next(node for node in group.nodes
|
||||
if node.bl_idname == "GeometryNodeMenuSwitch"
|
||||
and node.inputs["Menu"].links[0].from_socket.name == "Mode")
|
||||
assert [item.name for item in mode.enum_items] == ["Balloon", "Shell"]
|
||||
thickness = [item.description for item in sockets if item.name == "Thickness"]
|
||||
assert len(thickness) == 2 and len(set(thickness)) == 2
|
||||
if group.name == "O Image Depth Cutout":
|
||||
inflation = next(item for item in sockets if item.name == "Front Inflation")
|
||||
assert inflation.default_value == 0.5
|
||||
result[group.name] = [
|
||||
(item.identifier, item.name, item.description) for item in sockets
|
||||
]
|
||||
assert result.keys() == parameters.keys()
|
||||
return result
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
expected = descriptions(build_node_groups())
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
with bpy.data.libraries.load(str(node_asset_path())) as (library, target):
|
||||
target.node_groups = list(library.node_groups)
|
||||
assert descriptions(target.node_groups) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["definition", "asset"])
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
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), 7)
|
||||
for group in groups:
|
||||
validate_node_structure(group)
|
||||
positions = {node.name: tuple(node.location_absolute) 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_absolute, position):
|
||||
self.assertAlmostEqual(actual, expected, delta=0.01, msg=f"{group.name}: {name}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -3,30 +3,22 @@
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
from anyimage.common.node import node_asset_path
|
||||
from tests.support.depth_surface import surface
|
||||
from tests.support.planes import create_surface
|
||||
from tests.tools.nodes.test_image_depth_panorama import panorama
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["definition", "asset"])
|
||||
@pytest.mark.parametrize("kind", ["cutout", "plane", "panorama"])
|
||||
def test_depth_attribute_cleanup_has_no_warnings(source, kind):
|
||||
def test_depth_attribute_cleanup_has_no_warnings(kind):
|
||||
if kind == "cutout":
|
||||
obj, set_value = surface()
|
||||
elif kind == "plane":
|
||||
obj, set_value, *_ = create_surface("definition", "DEPTH")
|
||||
obj, set_value, *_ = create_surface("DEPTH")
|
||||
else:
|
||||
obj, set_value = panorama(subdivide=2)
|
||||
modifier = obj.modifiers[0]
|
||||
if source == "asset":
|
||||
name = modifier.node_group.name
|
||||
modifier.node_group.name += " Definition"
|
||||
with bpy.data.libraries.load(str(node_asset_path())) as (_, target):
|
||||
target.node_groups = [name]
|
||||
modifier.node_group = target.node_groups[0]
|
||||
for split in (0.0, 0.5, 0.0):
|
||||
set_value("Split Threshold", split)
|
||||
set_value("Depth Split", split)
|
||||
for smooth in (0, 5):
|
||||
set_value("Boundary Smooth", smooth)
|
||||
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
from tests.support.paths import PROJECT_ROOT
|
||||
import tomllib
|
||||
import unittest
|
||||
import importlib
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.parametrize("module_name", ["tools.nodes.cli", "tools.models.cli"])
|
||||
@pytest.mark.parametrize("arguments,code", [([], None), (["--help"], 0), (["unknown"], 2), (["--bad-option"], 2)])
|
||||
def test_help_and_invalid_arguments_do_not_start_work(module_name, arguments, code, capsys):
|
||||
module = importlib.import_module(module_name)
|
||||
with patch("sys.argv", ["tool", *arguments]), patch.object(module.subprocess, "run") as run:
|
||||
if code is None:
|
||||
module.main()
|
||||
else:
|
||||
with pytest.raises(SystemExit) as error:
|
||||
module.main()
|
||||
assert error.value.code == code
|
||||
run.assert_not_called()
|
||||
captured = capsys.readouterr()
|
||||
assert "usage:" in captured.out + captured.err
|
||||
|
||||
|
||||
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",
|
||||
"node-group": "tools.nodes.cli:main",
|
||||
"model": "tools.models.cli:main",
|
||||
"docs": "tools.docs:main",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,42 +0,0 @@
|
||||
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},
|
||||
)
|
||||
]
|
||||
@@ -15,7 +15,6 @@ class ExtensionManifestTest(unittest.TestCase):
|
||||
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(
|
||||
|
||||
@@ -104,19 +104,19 @@ class ExtensionPackagingTest(unittest.TestCase):
|
||||
"operators/convert_to_panorama/__init__.py",
|
||||
"operators/convert_to_panorama/operators.py",
|
||||
"operators/convert_to_panorama/object.py",
|
||||
"operators/frame_tool.py",
|
||||
"operators/frame_tool/__init__.py",
|
||||
"operators/frame_tool/operators.py",
|
||||
"operators/frame_tool/projection.py",
|
||||
"operators/frame_tool/compositing.py",
|
||||
"operators/mask_tool.py",
|
||||
"operators/rectify_tool.py",
|
||||
"operators/rectify_tool/__init__.py",
|
||||
"operators/rectify_tool/operators.py",
|
||||
"operators/rectify_tool/geometry.py",
|
||||
"operators/rectify_tool/preview.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,
|
||||
@@ -132,26 +132,12 @@ class ExtensionPackagingTest(unittest.TestCase):
|
||||
self.assertIn("server/geometry/depth_texture.py", names)
|
||||
self.assertIn("server/geometry/prediction_artifacts.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/background.py", names)
|
||||
self.assertNotIn("server/models/da3.py", names)
|
||||
self.assertIn("server/models/moge.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.assertIn("server/models/onnx_birefnet.py", names)
|
||||
self.assertNotIn("server/models/onnx_pisa_sr.py", names)
|
||||
self.assertNotIn("server/models/onnx_da3.py", names)
|
||||
self.assertIn("server/models/onnx_moge2.py", names)
|
||||
self.assertIn("server/models/onnx_moge3.py", names)
|
||||
self.assertIn("server/models/onnx_upscale.py", names)
|
||||
@@ -163,10 +149,7 @@ class ExtensionPackagingTest(unittest.TestCase):
|
||||
"wheels/blendjob-0.1.17-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",
|
||||
|
||||
Reference in New Issue
Block a user