Add tests and implement boundary smoothing and cutout functionality

- Introduced tests for boundary smoothing in `test_boundary_smoothing.py`, verifying that smoothing reduces stair-step artifacts while preserving boundary integrity.
- Added tests for cutout boundary behavior in `test_cutout_boundary.py`, ensuring proper handling of boundary triangles and normal blending.
- Created `test_temporary_attributes.py` to check the cleanup of temporary attributes across depth modifier branches, ensuring no warnings are raised.
- Implemented point culling functionality in `test_validity.py`, confirming that invalid points are removed without affecting adjacent geometry.
- Developed boundary smoothing logic in `boundary_smoothing.py`, allowing for depth smoothing while maintaining edge integrity.
- Added cutout boundary management in `cutout_boundary.py`, including functions for removing boundary triangles and tapering thickness profiles.
- Implemented validity checks in `validity.py` to ensure proper deletion of invalid vertices and associated wire edges.
This commit is contained in:
2026-09-11 20:37:20 +08:00
parent e8129fe495
commit 31074bd511
85 changed files with 2501 additions and 1451 deletions

View File

@@ -50,9 +50,9 @@ class AiSetupTest(BlenderTestCase):
with self.assertRaisesRegex(
RuntimeError,
(
"Fast \\| MoGe-2 ViT-S Normal: network unavailable; "
"Fast \\| BiRefNet Lite: network unavailable; "
"Fast \\| Real-ESRGAN General WDN x4v3: network unavailable"
"MoGe-2 ViT-S Normal: network unavailable; "
"BiRefNet Lite: network unavailable; "
"Real-ESRGAN General WDN x4v3: network unavailable"
),
):
self.runtime.post_install(SimpleNamespace(request=request))

View File

@@ -0,0 +1,37 @@
import bpy
import numpy as np
from anyimage.operators.clipboard_image.actions import _set_brush_preview
def test_packed_brush_preview_preserves_image_and_survives_reopen(tmp_path):
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Preview Source", width=80, height=40, alpha=True)
pixels = np.zeros((40, 80, 4), dtype=np.float32)
pixels[:, :40] = (1.0, 0.0, 0.0, 1.0)
image.pixels.foreach_set(pixels.ravel())
image.pack()
original_pixels = tuple(image.pixels)
original_format = image.file_format
brush = bpy.data.brushes.new("Preview Brush", mode="TEXTURE_PAINT")
brush.asset_mark()
try:
_set_brush_preview(bpy.context, brush, image)
assert tuple(brush.preview.image_size) == (80, 40)
preview = np.array(brush.preview.image_pixels_float[:]).reshape(40, 80, 4)
assert np.allclose(preview[:, :40, 0], 1.0, atol=1 / 255)
assert np.all(preview[:, :40, 3] == 1.0)
assert np.all(preview[:, 40:, 3] == 0.0)
assert tuple(image.pixels) == original_pixels
assert tuple(image.size) == (80, 40)
assert image.packed_file is not None
assert image.file_format == original_format
expected_preview = tuple(brush.preview.image_pixels)
path = str(tmp_path / "brush-preview.blend")
bpy.ops.wm.save_as_mainfile(filepath=path)
bpy.ops.wm.open_mainfile(filepath=path)
restored = bpy.data.brushes["Preview Brush"]
assert tuple(restored.preview.image_size) == (80, 40)
assert tuple(restored.preview.image_pixels) == expected_preview
finally:
bpy.ops.wm.read_factory_settings(use_empty=True)

View File

@@ -631,6 +631,89 @@ class ClipboardImageTest(unittest.TestCase):
self.assertEqual(brush.texture_slot.map_mode, "STENCIL")
self.assertEqual(brush.stencil_dimension, (256.0, 144.0))
def test_brush_preview_updates_local_copy_only_in_texture_paint(self):
for mode in ("PAINT_TEXTURE", "SCULPT", "PAINT_VERTEX"):
with self.subTest(mode=mode):
brush = SimpleNamespace(
texture=None,
texture_slot=SimpleNamespace(texture=None),
stencil_dimension=(256.0, 256.0),
update_tag=Mock(),
)
context = SimpleNamespace(mode=mode, brush=object())
images = [SimpleNamespace(name=name, size=(80, 40)) for name in ("A", "B")]
textures = FakeTextures()
with (
patch.object(self.actions.bpy, "data", SimpleNamespace(textures=textures)),
patch.object(self.actions, "_ensure_editable_brush", return_value=brush),
patch.object(self.actions, "_set_brush_preview") as preview,
):
for image in images:
self.actions.set_tool_texture(context, image)
self.assertIs(brush.texture.image, images[-1])
self.assertEqual(preview.call_count, 2 if mode == "PAINT_TEXTURE" else 0)
if mode == "PAINT_TEXTURE":
self.assertEqual([call.args[1:] for call in preview.call_args_list],
[(brush, image) for image in images])
def test_brush_preview_cleans_export_on_success_cancel_and_error(self):
for outcome in ({"FINISHED"}, {"CANCELLED"}, RuntimeError("preview failed")):
with self.subTest(outcome=outcome):
brush, image = object(), object()
context = SimpleNamespace(scene=object(), temp_override=Mock(return_value=nullcontext()))
loader = Mock()
if isinstance(outcome, Exception):
loader.side_effect = outcome
else:
loader.return_value = outcome
with (
patch.object(self.actions, "save_packed_image", return_value="preview.png"),
patch.object(self.actions, "cleanup_image_input") as cleanup,
patch.object(self.actions.bpy, "ops", SimpleNamespace(
ed=SimpleNamespace(lib_id_load_custom_preview=loader)), create=True),
):
expectation = nullcontext() if outcome == {"FINISHED"} else self.assertRaises(self.actions.BrushPreviewError)
with expectation:
self.actions._set_brush_preview(context, brush, image)
cleanup.assert_called_once_with("preview.png", True)
context.temp_override.assert_called_once_with(id=brush)
loader.assert_called_once_with(filepath="preview.png")
def test_preview_failure_preserves_texture_and_redraws(self):
textures = FakeTextures()
brush = SimpleNamespace(
texture=None, texture_slot=SimpleNamespace(texture=None),
stencil_dimension=(256.0, 256.0), update_tag=Mock(),
)
context = SimpleNamespace(mode="PAINT_TEXTURE", brush=brush, area=SimpleNamespace(tag_redraw=Mock()))
image = SimpleNamespace(name="Clipboard", size=(80, 40))
with (
patch.object(self.actions.bpy, "data", SimpleNamespace(textures=textures)),
patch.object(self.actions, "_set_brush_preview", side_effect=self.actions.BrushPreviewError("preview failed")),
self.assertRaises(self.actions.BrushPreviewError),
):
self.actions.set_tool_texture(context, image)
self.assertEqual(len(textures), 1)
self.assertIs(brush.texture.image, image)
context.area.tag_redraw.assert_called_once()
def test_preview_warning_finishes_paste(self):
module = self.addon.clipboard_image
operator = module.PasteClipboardImage()
operator.paste_target = "TOOL_TEXTURE"
operator.location_set = False
operator.import_as = "REFERENCE"
operator.shadeless = False
operator.report = Mock()
with (
patch.object(module, "_should_defer_to_native_paste", return_value=False),
patch.object(module, "read_clipboard_image", return_value=(b"image", ".png")),
patch.object(module, "acquire_packed_image", return_value=(object(), False)),
patch.object(module, "paste_image", side_effect=self.actions.BrushPreviewError("preview failed")),
):
self.assertEqual(operator.execute(SimpleNamespace()), {"FINISHED"})
operator.report.assert_called_once_with({"WARNING"}, "preview failed")
def test_localizes_read_only_active_brush(self):
local_brush = SimpleNamespace(is_editable=True, library=None)
linked_brush = SimpleNamespace(

View File

@@ -26,6 +26,17 @@ def test_new_cutout_does_not_reuse_a_saved_wrapper():
assert any(item.name == "Shape" for item in saved.interface.items_tree)
def test_depth_cutout_loads_boundary_blur_revision_and_preserves_existing_group():
bpy.ops.wm.read_factory_settings(use_empty=True)
saved = bpy.data.node_groups.new("O Image Depth Cutout", "GeometryNodeTree")
saved["anyimage_asset_revision"] = 11
current = load_cutout_node_group("DEPTH_SOLID")
assert current != saved and current["anyimage_asset_revision"] == 18
assert saved in bpy.data.node_groups.values()
assert saved["anyimage_asset_revision"] == 11
assert load_cutout_node_group("DEPTH_SOLID") == current
def test_refined_boundary_ears_have_interior_balloon_samples():
points = np.array([(0, 0), (1, 0), (1, 1), (0, 1)], dtype=float)
points, faces, boundary = refine_balloon_triangles(points, [(0, 1, 2), (0, 2, 3)], np.ones(4, bool))

View File

@@ -46,7 +46,8 @@ def test_cutout_node_groups_are_loaded_from_the_asset():
) as load:
for shape in cutout_shape.CUTOUT_NODE_GROUP_NAMES:
assert cutout_object.load_cutout_node_group(shape) is loaded
load.assert_called_with(cutout_shape.CUTOUT_NODE_GROUP_NAMES[shape], asset_revision=11)
load.assert_called_with(cutout_shape.CUTOUT_NODE_GROUP_NAMES[shape],
asset_revision=18 if shape == "DEPTH_SOLID" else 11)
assert load.call_count == 4

View File

@@ -187,7 +187,11 @@ def test_cutout_presets_load_three_direct_modifier_assets():
assert inputs["Mode"].default_value == "Balloon"
assert inputs["Smooth"].default_value == 0
assert inputs["Smooth Weight"].default_value == 1
assert "Edge Smooth" not in inputs
if shape == "DEPTH_SOLID":
assert inputs["Boundary Smooth"].default_value == 5
assert inputs["Boundary Smooth"].parent.name == "Options"
else:
assert "Boundary Smooth" not in inputs
assert not any(node.bl_idname == "GeometryNodeRepeatInput" and node.inputs["Iterations"].default_value == 8 for node in group.nodes)
assert next(item for item in groups["DEPTH_BALLOON"].interface.items_tree if item.name == "Double Sided").default_value
@@ -424,17 +428,17 @@ def test_depth_surface_keeps_continuous_points_across_zero_validity_edge():
assert np.ptp(positions[:, 2]) < 1e-4
def test_depth_surface_split_removes_isolated_triangle_without_loose_geometry():
def test_depth_surface_removes_boundary_ears_without_loose_geometry():
bpy.ops.wm.read_factory_settings(use_empty=True)
obj, modifier = _depth_surface_step_object()
split_id = object_data.modifier_input_identifier(modifier.node_group, "Split Threshold")
# The raised corner becomes an isolated triangle after separation.
for value, expected in ((0., (9, 8)), (.5, (8, 7)), (1., (8, 7))):
# Both outer ears have three boundary vertices, including the raised corner.
for value in (0., .5, 1.):
object_data.set_modifier_input(modifier, split_id, value)
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
vertices, faces, loose_edges, loose_points = _evaluated_topology(obj)
assert (vertices, faces) == expected
assert (vertices, faces) == (7, 6)
assert not loose_edges and not loose_points

View File

@@ -81,12 +81,17 @@ def artifacts(tmp_path, source):
def test_result_replaces_source_with_zero_rotation_and_shadeless_material(source, artifacts, monkeypatch):
result, operator = artifacts
saved = bpy.data.node_groups.new("O Image Depth Panorama", "GeometryNodeTree")
saved["anyimage_asset_revision"] = 11
matrix = Matrix.Translation((2, -3, 4)) @ Matrix.Rotation(0.6, 4, "Y") @ Matrix.Diagonal((2, 3, 4, 1))
source.matrix_world = matrix
source.empty_image_offset = (0.3, -0.8)
undo = Mock(return_value={"FINISHED"})
monkeypatch.setattr(conversion, "bpy", SimpleNamespace(data=bpy.data, ops=SimpleNamespace(ed=SimpleNamespace(undo_push=undo))))
obj = conversion.create_panorama_from_result(bpy.context, result, operator)
assert obj.modifiers[0].node_group != saved
assert obj.modifiers[0].node_group["anyimage_asset_revision"] == 14
assert saved in bpy.data.node_groups.values() and saved["anyimage_asset_revision"] == 11
assert obj.name == operator.source_object_name and obj.type == "MESH"
np.testing.assert_allclose(obj.location, matrix.translation, atol=1e-6)
np.testing.assert_allclose(obj.scale, matrix.to_scale(), atol=1e-6)

View File

@@ -11,7 +11,7 @@ def test_conversion_preserves_offset_world_placement_and_initializes_inputs(surf
obj, _, _, inputs, image = surface
group = obj.modifiers[0].node_group
monkeypatch.setattr(conversion, "load_node_group", lambda name: group)
monkeypatch.setattr(conversion, "load_node_group", lambda name, *, asset_revision: group)
source = bpy.data.objects.new("Offset Image", None)
source.empty_display_type = "IMAGE"
source.data = image
@@ -48,7 +48,7 @@ def test_failed_modifier_initialization_removes_partial_object(surface, monkeypa
from anyimage.operators.convert_to_plane import object as conversion
obj, _, _, _, image = surface
monkeypatch.setattr(conversion, "load_node_group", lambda name: obj.modifiers[0].node_group)
monkeypatch.setattr(conversion, "load_node_group", lambda name, *, asset_revision: obj.modifiers[0].node_group)
source = bpy.data.objects.new("Source", None)
source.empty_display_type = "IMAGE"
source.data = image

View File

@@ -73,7 +73,8 @@ def test_clipboard_plane_uses_the_mesh_material_slot():
def test_converted_planes_center_the_origin_without_moving_world_corners():
bpy.ops.wm.read_factory_settings(use_empty=True)
build_image_depth_plane_group(build_image_plane_group())
saved = build_image_depth_plane_group(build_image_plane_group())
saved["anyimage_asset_revision"] = 11
def create_source(name):
image = bpy.data.images.new(name, width=200, height=100)
@@ -149,6 +150,9 @@ def test_converted_planes_center_the_origin_without_moving_world_corners():
assert_centered(depth_plane, depth_bounds, depth_matrix)
assert depth_plane["anyimage_mesh_shape"] == "DEPTH_PLANE"
depth_modifier = depth_plane.modifiers[0]
assert depth_modifier.node_group != saved
assert depth_modifier.node_group["anyimage_asset_revision"] == 14
assert saved in bpy.data.node_groups.values() and saved["anyimage_asset_revision"] == 11
uniform_scale = depth_modifier[
object_data.modifier_input_identifier(
depth_modifier.node_group,

View File

@@ -1,236 +0,0 @@
from types import SimpleNamespace
from unittest.mock import patch
import numpy as np
import pytest
from PIL import Image
from server.geometry import prediction_artifacts as artifacts
from server.geometry.edge_depth import extend_edge_depth
from server.models.geometry import GeometryFrame
from server.models.onnx_moge2 import depth_to_points
def make_frame(depth):
depth = np.asarray(depth, dtype=np.float32)
height, width = depth.shape
normalized = np.array([[1.2, 0, 0.4], [0, 1.4, 0.6], [0, 0, 1]], np.float32)
intrinsics = normalized.copy()
intrinsics[0] *= width
intrinsics[1] *= height
return GeometryFrame(
depth=depth,
validity=np.ones_like(depth),
intrinsics=intrinsics,
points=depth_to_points(depth, normalized),
normal=np.zeros((*depth.shape, 3), np.float32),
)
def silhouette():
alpha = np.zeros((25, 35), np.float32)
alpha[4:21, 4:22] = 1
alpha[4:21, 22:25] = 0.2
depth = np.full(alpha.shape, 20, np.float32)
depth[alpha == 1] = 2
return make_frame(depth), alpha
def test_hair_extension_preserves_core_alpha_and_projection():
frame, alpha = silhouette()
original_alpha = alpha.copy()
result = extend_edge_depth(frame, alpha)
assert np.all(result.depth[alpha >= 0.1] == 2)
np.testing.assert_array_equal(result.points[9:16, 9:16], frame.points[9:16, 9:16])
np.testing.assert_array_equal(alpha, original_alpha)
np.testing.assert_array_equal(frame.depth[alpha == 0.2], 20)
np.testing.assert_allclose(
result.points[..., :2] / result.points[..., 2:],
frame.points[..., :2] / frame.points[..., 2:],
atol=1e-6,
)
assert result.validity is frame.validity
assert result.normal is frame.normal
assert np.isfinite(result.points).all()
def test_sampling_padding_is_bounded():
frame, alpha = silhouette()
result = extend_edge_depth(frame, alpha, alpha_threshold=0.1)
assert np.all(result.depth[12, 24:29] == 2)
assert result.depth[12, 29] == 20
assert (result.depth[12, 24] + result.depth[12, 25]) / 2 == 2
def test_edge_padding_tracks_selected_threshold():
frame, alpha = silhouette()
loose = extend_edge_depth(frame, alpha, alpha_threshold=0.1)
strict = extend_edge_depth(frame, alpha)
assert loose.depth[12, 28] == 2
assert strict.depth[12, 28] == 20
def test_strict_threshold_excludes_lower_alpha_donors():
alpha = np.zeros((10, 10), np.float32)
alpha[3:7, 3:7] = 0.96
alpha[5, 5] = 1
depth = np.full(alpha.shape, 10, np.float32)
depth[5, 5] = 2
result = extend_edge_depth(make_frame(depth), alpha, alpha_threshold=1)
assert result.depth[5, 6] == 2
def test_image_rectangle_does_not_erode_subject():
alpha = np.ones((15, 20), np.float32)
alpha[:, 15:] = 0
depth = np.full(alpha.shape, 2, np.float32)
depth[0, 0] = 3
frame = make_frame(depth)
result = extend_edge_depth(frame, alpha)
assert result.depth[0, 0] == 3
np.testing.assert_array_equal(result.points[0, 0], frame.points[0, 0])
def test_small_components_fall_back_without_borrowing_other_depths():
alpha = np.zeros((15, 30), np.float32)
alpha[5, 5:7] = [0.95, 0.1]
alpha[5, 10:12] = [1, 0.2]
alpha[5, 14] = 0.94
depth = np.full(alpha.shape, 30, np.float32)
depth[5, 5] = 2
depth[5, 10] = 8
result = extend_edge_depth(make_frame(depth), alpha)
assert result.depth[5, 6] == 2
assert result.depth[5, 11] == 8
assert result.depth[5, 14] == 30
assert result.depth[5, 7] == 2
assert result.depth[5, 9] == 8
def test_diagonal_pixels_share_a_component():
alpha = np.zeros((10, 10), np.float32)
alpha[4, 4] = 1
alpha[5, 5] = 0.2
depth = np.full(alpha.shape, 10, np.float32)
depth[4, 4] = 2
result = extend_edge_depth(make_frame(depth), alpha)
assert result.depth[5, 5] == 2
@pytest.mark.parametrize("validity", [0.0, 0.2, 0.5])
def test_invalid_donors_are_excluded(validity):
frame, alpha = silhouette()
frame.depth[10, 6] = 100
frame.validity[10, 6] = validity
result = extend_edge_depth(frame, alpha)
assert result.depth[10, 4] == 2
assert result.depth[10, 6] == 100
assert result.validity[10, 6] == np.float32(validity)
def test_internal_mask_hole_does_not_expand_alpha_repair():
frame, alpha = silhouette()
frame.depth[9:14, 10:15] = 100
frame.validity[9:14, 10:15] = 0
original = frame.depth.copy()
result = extend_edge_depth(frame, alpha)
np.testing.assert_array_equal(result.depth[6:19, 6:20], original[6:19, 6:20])
assert result.depth[12, 24] == 2
def test_opaque_image_keeps_low_mask_depth():
frame = make_frame(np.ones((10, 10)))
frame.depth[5, 5] = 100
frame.validity[5, 5] = 0
assert extend_edge_depth(frame, np.ones((10, 10))) is frame
def test_component_without_valid_donors_keeps_its_depth():
frame, alpha = silhouette()
frame.validity[alpha > 0] = 0
assert extend_edge_depth(frame, alpha) is frame
def test_local_smoothing_reduces_donor_step_and_preserves_core():
frame, alpha = silhouette()
frame.depth[12:, alpha.shape[1] // 4:22] = 4
result = extend_edge_depth(frame, alpha)
assert 2 < result.depth[11, 23] < 4
assert 2 < result.depth[12, 23] < 4
np.testing.assert_array_equal(result.depth[8:17, 8:17], frame.depth[8:17, 8:17])
@pytest.mark.parametrize("alpha_value", [0, 0.3, 1])
def test_images_without_repair_return_original_frame(alpha_value):
frame = make_frame(np.ones((5, 8)))
assert extend_edge_depth(frame, np.full((5, 8), alpha_value)) is frame
def test_alpha_resize_retains_threshold_values(tmp_path):
rgba = np.zeros((1, 4, 4), np.uint8)
rgba[0, :, 3] = [25, 26, 242, 243]
path = tmp_path / "alpha.png"
Image.fromarray(rgba).save(path)
alpha = artifacts._image_alpha(path, (2, 8))
np.testing.assert_array_equal(
alpha >= 0.1, [[False] * 2 + [True] * 6] * 2
)
np.testing.assert_array_equal(
alpha >= 0.95, [[False] * 6 + [True] * 2] * 2
)
@pytest.mark.parametrize(
"generate_depth,normal_mode",
[
(True, "OBJECT"),
(True, "TANGENT"),
(True, "NONE"),
(False, "OBJECT"),
],
)
def test_artifacts_use_corrected_depth_and_original_normals(
tmp_path, generate_depth, normal_mode
):
frame, alpha = silhouette()
rgba = np.full((*alpha.shape, 4), 255, np.uint8)
rgba[..., 3] = np.round(alpha * 255).astype(np.uint8)
path = tmp_path / "source.png"
Image.fromarray(rgba).save(path)
context = SimpleNamespace(
directory=tmp_path,
resource=lambda _: None,
progress=lambda *_: None,
check_cancelled=lambda: None,
)
with (
patch.object(artifacts.moge, "infer", return_value=[frame]),
patch.object(
artifacts, "write_depth_texture",
return_value=tmp_path / "depth.exr",
) as depth,
patch.object(
artifacts, "write_depth_metadata", return_value=tmp_path / "depth.json"
) as metadata,
patch.object(
artifacts, "_write_normal", return_value=tmp_path / "normal.png"
) as normal,
patch.object(artifacts, "extend_edge_depth", wraps=extend_edge_depth) as repair,
):
artifacts.generate_moge_artifacts(
context, {}, path, generate_depth=generate_depth, normal_mode=normal_mode,
)
if normal_mode != "NONE":
assert normal.call_args.args[0] is frame
else:
normal.assert_not_called()
assert repair.call_count == int(generate_depth)
if generate_depth:
written_frame = depth.call_args.args[0]
np.testing.assert_array_equal(depth.call_args.kwargs["alpha"], alpha)
assert metadata.call_args.args[0] is written_frame
assert written_frame.depth[12, 24] == 2
assert written_frame.validity is frame.validity
else:
metadata.assert_not_called()
depth.assert_not_called()

View File

@@ -0,0 +1,104 @@
from types import SimpleNamespace
from unittest.mock import patch
import numpy as np
import pytest
from PIL import Image
from server.geometry import prediction_artifacts as artifacts
from server.models.geometry import GeometryFrame
from server.models.onnx_moge2 import depth_to_points
def make_frame(depth):
depth = np.asarray(depth, dtype=np.float32)
height, width = depth.shape
normalized = np.array([[1.2, 0, 0.4], [0, 1.4, 0.6], [0, 0, 1]], np.float32)
intrinsics = normalized.copy()
intrinsics[0] *= width
intrinsics[1] *= height
return GeometryFrame(
depth=depth,
validity=np.ones_like(depth),
intrinsics=intrinsics,
points=depth_to_points(depth, normalized),
normal=np.zeros((*depth.shape, 3), np.float32),
)
def silhouette():
alpha = np.zeros((25, 35), np.float32)
alpha[4:21, 4:22] = 1
alpha[4:21, 22:25] = 0.2
depth = np.full(alpha.shape, 20, np.float32)
depth[alpha == 1] = 2
return make_frame(depth), alpha
def test_alpha_resize_retains_threshold_values(tmp_path):
rgba = np.zeros((1, 4, 4), np.uint8)
rgba[0, :, 3] = [25, 26, 242, 243]
path = tmp_path / "alpha.png"
Image.fromarray(rgba).save(path)
alpha = artifacts._image_alpha(path, (2, 8))
np.testing.assert_array_equal(
alpha >= 0.1, [[False] * 2 + [True] * 6] * 2
)
np.testing.assert_array_equal(
alpha >= 0.95, [[False] * 6 + [True] * 2] * 2
)
@pytest.mark.parametrize(
"generate_depth,normal_mode",
[
(True, "OBJECT"),
(True, "TANGENT"),
(True, "NONE"),
(False, "OBJECT"),
],
)
def test_artifacts_preserve_original_geometry_with_alpha(
tmp_path, generate_depth, normal_mode
):
frame, alpha = silhouette()
rgba = np.full((*alpha.shape, 4), 255, np.uint8)
rgba[..., 3] = np.round(alpha * 255).astype(np.uint8)
path = tmp_path / "source.png"
Image.fromarray(rgba).save(path)
context = SimpleNamespace(
directory=tmp_path,
resource=lambda _: None,
progress=lambda *_: None,
check_cancelled=lambda: None,
)
with (
patch.object(artifacts.moge, "infer", return_value=[frame]),
patch.object(
artifacts, "write_depth_texture",
return_value=tmp_path / "depth.exr",
) as depth,
patch.object(
artifacts, "write_depth_metadata", return_value=tmp_path / "depth.json"
) as metadata,
patch.object(
artifacts, "_write_normal", return_value=tmp_path / "normal.png"
) as normal,
):
artifacts.generate_moge_artifacts(
context, {}, path, generate_depth=generate_depth, normal_mode=normal_mode,
)
if normal_mode != "NONE":
assert normal.call_args.args[0] is frame
else:
normal.assert_not_called()
if generate_depth:
written_frame = depth.call_args.args[0]
np.testing.assert_array_equal(depth.call_args.kwargs["alpha"], alpha)
assert metadata.call_args.args[0] is written_frame
assert written_frame is frame
assert written_frame.depth[12, 24] == 20
assert written_frame.validity is frame.validity
else:
metadata.assert_not_called()
depth.assert_not_called()

View File

@@ -118,6 +118,21 @@ class PreferencesTest(BlenderTestCase):
geometry = properties.geometry_model_enum_items(None, None)
self.assertEqual(tuple(item[0] for item in geometry), expected)
def test_model_tiers_only_appear_in_choices(self):
properties = self.anyimage.properties
preferences = self.anyimage.preferences
catalog = {model["key"]: model for model in properties.model_catalog()}
for items in (
properties.geometry_model_enum_items(None, None),
preferences.BACKGROUND_MODEL_ITEMS,
preferences.UPSCALE_MODEL_ITEMS,
):
for item, tier in zip(items, ("Fast", "Base", "Pro")):
label = catalog[item[0]]["label"]
self.assertNotIn(" | ", label)
self.assertEqual(item[1], f"{tier} | {label}")
self.assertEqual(properties.upscale_model_label("HAT_GAN_X4_SHARPER"), "HAT Sharper")
def test_model_preferences_keep_valid_choices_and_normalize_retired_keys(self):
module = self.anyimage.preferences
stored = self.fake_bpy.context.preferences.addons["anyimage"].preferences

View File

@@ -68,6 +68,7 @@ def surface(*, step=6.0, size=1.0, resolution=2, wrapper=False):
("Split Threshold", 0.0),
("Mode", 1),
("Smooth", 0),
("Boundary Smooth", 0),
):
set_value(name, value)
if wrapper:

View File

@@ -63,6 +63,8 @@ def create_surface(source, plane_type):
("Uniform Scale", 0.5), ("Reference Depth", 1.2),
("Depth Scale", 0.0), ("Thickness", 0.0)):
set_value(name, value)
if "Boundary Smooth" in inputs:
set_value("Boundary Smooth", 0)
return obj, set_value, plane, inputs, image

View File

@@ -0,0 +1,186 @@
"""Verify boundary depth filtering before camera and radial projection."""
import bpy
import numpy as np
import pytest
from tests.support.depth_surface import evaluated
from tests.tools.nodes.test_boundary_smoothing import boundary_neighbors, edge_band
from tools.nodes.common.boundary_smoothing import smooth_boundary_camera
def test_camera_blur_preserves_rays_interior_protection_and_disconnected_surfaces():
bpy.ops.wm.read_factory_settings(use_empty=True)
points, faces, cameras, protected = [], [], [], []
for offset, depth in ((0, 2), (81, 50)):
for y in range(9):
for x in range(9):
value = 20 if offset == 0 and (x, y) == (4, 0) else depth
if offset == 0 and (x, y) == (8, 8):
value = 0
points.append((x, y, 0))
cameras.append((x * value / 10, y * value / 10, value))
protected.append(x == 0)
faces.extend((offset + y * 9 + x, offset + y * 9 + x + 1,
offset + (y + 1) * 9 + x + 1, offset + (y + 1) * 9 + x)
for y in range(8) for x in range(8))
mesh = bpy.data.meshes.new("Disconnected camera samples")
mesh.from_pydata(points, [], faces)
camera = np.asarray(cameras, np.float32)
mesh.attributes.new("test_camera", "FLOAT_VECTOR", "POINT").data.foreach_set("vector", camera.ravel())
mesh.attributes.new("test_protected", "BOOLEAN", "POINT").data.foreach_set("value", protected)
obj = bpy.data.objects.new("Depth samples", mesh)
bpy.context.collection.objects.link(obj)
group = bpy.data.node_groups.new("Boundary depth test", "GeometryNodeTree")
for direction in ("INPUT", "OUTPUT"):
group.interface.new_socket(name="Geometry", in_out=direction, socket_type="NodeSocketGeometry")
control = group.interface.new_socket(name="Boundary Smooth", in_out="INPUT", socket_type="NodeSocketInt")
source = group.nodes.new("NodeGroupInput")
attributes = []
for name, kind in (("test_camera", "FLOAT_VECTOR"), ("test_protected", "BOOLEAN")):
node = group.nodes.new("GeometryNodeInputNamedAttribute")
node.data_type = kind
node.inputs["Name"].default_value = name
attributes.append(node.outputs["Attribute"])
position = group.nodes.new("GeometryNodeSetPosition")
group.links.new(source.outputs["Geometry"], position.inputs["Geometry"])
group.links.new(smooth_boundary_camera(group, *attributes), position.inputs["Position"])
output = group.nodes.new("NodeGroupOutput")
group.links.new(position.outputs[0], output.inputs["Geometry"])
modifier = obj.modifiers.new("Depth", "NODES")
modifier.node_group = group
modifier[control.identifier] = 0
np.testing.assert_array_equal(evaluated(obj)[0], camera)
modifier[control.identifier] = 12
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
result, result_faces = evaluated(obj)
assert result_faces == faces
assert 2 < result[4, 2] < 10
assert np.isfinite(result).all()
np.testing.assert_array_equal(result[protected], camera[protected])
np.testing.assert_array_equal(result[81:], camera[81:])
np.testing.assert_array_equal(result[80], camera[80])
band = edge_band(faces, set(boundary_neighbors(faces)) - set(np.flatnonzero(protected)))
interior = [i for i in range(len(camera)) if i not in band]
assert interior
np.testing.assert_array_equal(result[interior], camera[interior])
valid = camera[:, 2] > 0
np.testing.assert_allclose(result[valid, :2] / result[valid, 2:3],
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):
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)
obj.data.clear_geometry()
obj.data.from_pydata([(x / 32, y / 32, 0) for x, y in points], [], faces.tolist())
uv = obj.data.uv_layers.get("UVMap") or obj.data.uv_layers.new(name="UVMap")
for loop in obj.data.loops:
uv.data[loop.index].uv = points[loop.vertex_index] / (64, 32)
image = bpy.data.images["Camera"]
pixels = np.array(image.pixels[:], np.float32).reshape(32, 64, 4)
pixels[..., 2] = 2 + 12 * ((xx > 25) & (xx < 39) & (yy > 25))
image.pixels.foreach_set(pixels.ravel())
image.update()
set_value("Boundary Smooth", 0)
set_value("Smooth Weight", 0)
before, original_faces = evaluated(obj)
set_value("Boundary Smooth", 12)
after, result_faces = evaluated(obj)
assert result_faces == original_faces
assert after[:, 2].min() > before[:, 2].min() + 1
# Reference Depth = 1, Uniform Scale = 1 and Depth Scale = 1.
np.testing.assert_allclose(after[:, :2] / (1 - after[:, 2:3]),
before[:, :2] / (1 - before[:, 2:3]), atol=1e-6)
band = edge_band(original_faces, boundary_neighbors(original_faces))
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("Boundary Smooth", 0)
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):
from tests.support.planes import create_surface, evaluated as plane_mesh
obj, set_value, _, _, image = create_surface(source, "DEPTH")
pixels = np.array(image.pixels[:], np.float32).reshape(8, 16, 4)
pixels[..., 2] = 2
pixels[2:6, 6:10, 3] = 0
pixels[1:3, 7:9, 2] = 12
image.pixels.foreach_set(pixels.ravel())
image.update()
for name, value in (("Subdivide", 5), ("Depth Scale", 1), ("Smooth Weight", 0)):
set_value(name, value)
before, faces, uv, *_ = plane_mesh(obj)
set_value("Boundary Smooth", 12)
after, result_faces, result_uv, *_ = plane_mesh(obj)
assert faces == result_faces
np.testing.assert_array_equal(result_uv, uv)
assert after[:, 2].min() > before[:, 2].min() + .1
protected = np.any(np.isclose(uv, 0) | np.isclose(uv, 1), axis=1)
corners = np.concatenate(faces)
outer = np.unique(corners[protected])
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)
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):
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)
hole = (distance**2 + (yy - 32)**2 < 9**2) if region == "seam" else (yy > 54)
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)
after, result_faces, result_uv = panorama_mesh(obj)
assert result_faces == faces
np.testing.assert_array_equal(result_uv, uv)
radii = np.linalg.norm(before, axis=1)
smoothed = np.linalg.norm(after, axis=1)
rim = list(boundary_neighbors(faces))
assert smoothed[rim].max() < radii[rim].max() - .1
np.testing.assert_allclose(after / smoothed[:, None], before / radii[:, None], atol=1e-6)
band = edge_band(faces, boundary_neighbors(faces))
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)
near = panorama_mesh(obj)[0]
set_value("Invalid Distance", 250)
far = panorama_mesh(obj)[0]
valid = np.linalg.norm(near, axis=1) < 50
assert valid.any() and (~valid).any()
np.testing.assert_array_equal(far[valid], near[valid])
np.testing.assert_allclose(np.linalg.norm(far[~valid], axis=1), 250, atol=5e-5)
set_value("Depth Scale", 0)
np.testing.assert_allclose(np.linalg.norm(panorama_mesh(obj)[0], axis=1), 1, atol=1e-6)

View File

@@ -0,0 +1,335 @@
"""Verify pinned-boundary smoothing stays within its fixed edge band."""
from collections import Counter
import bpy
import bmesh
import numpy as np
import pytest
from tests.support.depth_surface import surface, evaluated, assert_closed, component_count
def diagonal_surface(triangles=False):
obj, set_value = surface(resolution=16)
if triangles:
mesh = bmesh.new()
mesh.from_mesh(obj.data)
bmesh.ops.triangulate(mesh, faces=list(mesh.faces))
mesh.to_mesh(obj.data)
mesh.free()
image = bpy.data.images["Camera"]
pixels = np.array(image.pixels[:], np.float32).reshape(32, 64, 4)
yy, xx = np.mgrid[:32, :64]
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 Scale", 0)
return obj, set_value
def boundary_neighbors(faces):
edges = Counter(tuple(sorted((a, b))) for face in faces for a, b in zip(face, (*face[1:], face[0])))
neighbors = {}
for (a, b), count in edges.items():
if count == 1:
neighbors.setdefault(a, []).append(b)
neighbors.setdefault(b, []).append(a)
return neighbors
def edge_band(faces, seeds, rings=2):
adjacency = {}
for face in faces:
for a, b in zip(face, (*face[1:], face[0])):
adjacency.setdefault(a, set()).add(b)
adjacency.setdefault(b, set()).add(a)
selected = set(seeds)
for _ in range(rings):
selected |= {neighbor for vertex in selected for neighbor in adjacency[vertex]}
return selected
@pytest.mark.parametrize("triangles", [False, True])
def test_smoothing_reduces_stairs_without_leaving_two_ring_band(triangles):
obj, set_value = diagonal_surface(triangles)
original, faces = evaluated(obj)
boundary = boundary_neighbors(faces)
outer = np.isclose(original[:, 0], 0) | np.isclose(original[:, 0], 2)
outer |= np.isclose(original[:, 1], 0) | np.isclose(original[:, 1], 1)
cut = {i for i in boundary if not outer[i]}
band = edge_band(faces, boundary)
movable = np.array([i in band for i in range(len(original))])
assert movable.any() and (~movable).any()
set_value("Boundary Smooth", 20)
smoothed, result_faces = evaluated(obj)
assert result_faces == faces
np.testing.assert_array_equal(smoothed[~movable], original[~movable])
assert np.max(np.linalg.norm(smoothed - original, axis=1)) > 0.005
nearby = [i for i in band if i not in boundary and not outer[i]]
assert np.max(np.linalg.norm(smoothed[nearby] - original[nearby], axis=1)) > 1e-4
before, after = [], []
for i in cut:
adjacent = boundary[i]
if len(adjacent) != 2:
continue
before.append(np.linalg.norm(original[adjacent].mean(axis=0) - original[i]))
after.append(np.linalg.norm(smoothed[adjacent].mean(axis=0) - smoothed[i]))
assert sum(after) < 0.8 * sum(before)
np.testing.assert_array_equal(smoothed[:, 2], original[:, 2])
set_value("Smooth Weight", 0)
np.testing.assert_array_equal(evaluated(obj)[0], original)
set_value("Smooth Weight", 1)
set_value("Boundary Smooth", 0)
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):
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)
original, faces = evaluated(obj)
set_value("Boundary Smooth", 20)
result, result_faces = evaluated(obj)
boundary = boundary_neighbors(faces)
band = edge_band(faces, boundary)
outside = [i for i in range(len(original)) if i not in band]
assert np.max(np.linalg.norm(result[list(boundary)] - original[list(boundary)], axis=1)) > 1e-4
np.testing.assert_array_equal(result[outside], original[outside])
assert result_faces == faces
@pytest.mark.parametrize("mode", [0, 1])
def test_smoothed_cut_shells_stay_closed_and_separate(mode):
from anyimage.common.object import modifier_input_identifier, set_modifier_input
obj, set_value = diagonal_surface()
obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set(
"value", np.full(len(obj.data.vertices), 0.3, dtype=np.float32)
)
set_value("Depth Scale", 1)
set_value("Boundary Smooth", 4)
original, faces = evaluated(obj)
count = component_count(faces)
set_value("Mode", mode)
modifier = obj.modifiers[0]
identifier = modifier_input_identifier(modifier.node_group, "Thickness", subtype="NONE" if mode == 0 else "DISTANCE")
set_modifier_input(modifier, identifier, 0.05)
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
result, shell_faces = evaluated(obj)
assert np.isfinite(result).all()
assert_closed(shell_faces)
assert component_count(shell_faces) == count
def test_panorama_smoothing_preserves_interior_and_uvs():
from tests.tools.nodes.test_image_depth_panorama import panorama, evaluated as panorama_mesh
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)
original, faces, uv = panorama_mesh(obj)
rim = boundary_neighbors(faces)
band = edge_band(faces, rim)
interior = [i for i in range(len(original)) if i not in band]
set_value("Boundary Smooth", 4)
result, result_faces, result_uv = panorama_mesh(obj)
assert np.isfinite(result).all()
assert result_faces == faces
np.testing.assert_array_equal(result[interior], original[interior])
np.testing.assert_array_equal(result_uv, uv)
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):
from tests.support.planes import create_surface, evaluated as plane_mesh
obj, set_value, _, inputs, image = create_surface(source, "DEPTH")
assert inputs["Boundary Smooth"].default_value == 5
assert inputs["Boundary Smooth"].parent.name == "Options"
pixels = np.array(image.pixels[:], np.float32).reshape(8, 16, 4)
yy, xx = np.mgrid[:8, :16]
pixels[..., 2] = 1 + (xx > 5 + yy * 0.7) * 6
image.pixels.foreach_set(pixels.ravel())
image.update()
set_value("Subdivide", 5)
set_value("Depth Scale", 1)
set_value("Split Threshold", 0.5)
original, faces, uv, *_ = plane_mesh(obj)
set_value("Boundary Smooth", 4)
result, result_faces, result_uv, *_ = plane_mesh(obj)
assert result_faces == faces
np.testing.assert_array_equal(result_uv, uv)
assert np.max(np.linalg.norm(result-original, axis=1)) > 1e-4
set_value("Thickness", 0.03)
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
assert (smooth.min_value, smooth.max_value) == (0, 20)
assert smooth.parent.name == "Options"
assert not {"Depth Blur Iterations", "Depth Band Rings", "Depth Blur Strength", "Skip Inactive Geometry"} & inputs.keys()
depth_mix = next(n for n in group.nodes if n.bl_idname == "ShaderNodeMix"
and n.data_type == "FLOAT" and n.inputs[3].is_linked
and origin_socket(n.inputs[3].links[0].from_socket).node.bl_idname == "GeometryNodeBlurAttribute")
depth_blur = origin_socket(depth_mix.inputs[3].links[0].from_socket).node
assert origin_socket(depth_blur.inputs["Iterations"].links[0].from_socket).name == "Boundary Smooth"
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):
from tests.support.planes import create_surface, evaluated as plane_mesh
obj, set_value, _, _, image = create_surface(source, "DEPTH")
set_value("Subdivide", 5)
set_value("Split Threshold", 0)
set_value("Valid Only", 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)
yy, xx = np.mgrid[:8, :16]
pixels[..., 3] = xx < 5 + yy * 0.7
image.pixels.foreach_set(pixels.ravel())
image.update()
set_value("Valid Only", 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)
cut = {i for i in boundary if not outer[i]}
band = edge_band(faces, cut)
fixed = np.array([i not in band or outer[i] for i in range(len(before))])
set_value("Boundary Smooth", 5)
after, result_faces, result_uv, *_ = plane_mesh(obj)
assert result_faces == faces
np.testing.assert_array_equal(result_uv, uv)
np.testing.assert_array_equal(after[fixed], before[fixed])
assert np.max(np.linalg.norm(after-before, axis=1)) > 1e-4
before_bends, after_bends = [], []
for i in cut:
if len(boundary[i]) == 2:
before_bends.append(np.linalg.norm(before[boundary[i]].mean(axis=0)-before[i]))
after_bends.append(np.linalg.norm(after[boundary[i]].mean(axis=0)-after[i]))
assert sum(after_bends) < sum(before_bends) * 0.8
set_value("Thickness", 0.03)
assert_closed(plane_mesh(obj)[1])
set_value("Thickness", 0)
set_value("Valid Only", False)
np.testing.assert_array_equal(plane_mesh(obj)[0], whole)
def test_panorama_validity_cut_smoothing_stays_in_two_ring_band():
from tests.tools.nodes.test_image_depth_panorama import panorama, evaluated as panorama_mesh
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)
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]
set_value("Boundary Smooth", 5)
after, result_faces, result_uv = panorama_mesh(obj)
assert result_faces == faces
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

View File

@@ -0,0 +1,128 @@
"""Verify split profile shaping and thickness-weighted normals."""
import bpy
import numpy as np
import pytest
from anyimage.common.object import modifier_input_identifier, set_modifier_input
from tests.support.depth_surface import surface, evaluated
from tests.tools.nodes.test_boundary_smoothing import diagonal_surface, boundary_neighbors, edge_band
from tools.nodes.common.cutout_boundary import remove_boundary_triangles
from tools.nodes.groups.image_depth_cutout import build_normal_weight, blend_normal
def profile_values(obj):
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
mesh = result.to_mesh()
try:
return np.array([v.value for v in mesh.attributes["o_balloon"].data])
finally:
result.to_mesh_clear()
def test_boundary_triangle_cleanup_is_one_pass():
obj, _ = surface(resolution=2)
group = obj.modifiers[0].node_group
mesh = bpy.data.meshes.new("Boundary ears")
mesh.from_pydata([(0,0,0),(1,0,0),(1,1,0),(0,1,0),(.5,.5,0),(2,0,0)], [],
[(0,1,4),(1,2,4),(2,3,4),(3,0,4),(1,5,2)])
obj.data = mesh
source = group.nodes.new("NodeGroupInput")
cleaned = remove_boundary_triangles(group, source.outputs["Geometry"])
group.links.new(cleaned, next(n for n in group.nodes if n.type == "GROUP_OUTPUT").inputs["Geometry"])
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
_, faces = evaluated(obj)
assert len(faces) == 4
assert all(len(f) == 3 for f in faces)
@pytest.mark.parametrize("iterations", [0, 5, 12, 20])
def test_cleaned_sawtooth_smoothing_preserves_face_orientation(iterations):
obj, set_value = diagonal_surface(triangles=True)
original, faces = evaluated(obj)
set_value("Boundary Smooth", iterations)
points, actual = evaluated(obj)
assert actual == faces
indices = np.array(faces)
def normals(p):
return np.cross(p[indices[:,1]]-p[indices[:,0]], p[indices[:,2]]-p[indices[:,0]])
assert np.all(np.sum(normals(original)*normals(points), axis=1) > 0)
@pytest.mark.parametrize("resolution", [12, 24])
@pytest.mark.parametrize("aspect", [.5, 2])
def test_split_profile_tapers_and_rounding_remains_live(resolution, aspect):
obj, set_value = surface(step=6, resolution=resolution)
for vertex in obj.data.vertices:
vertex.co.x *= aspect
image = bpy.data.images["Camera"]
pixels = np.array(image.pixels[:], np.float32).reshape(32, 64, 4)
pixels[..., 0] *= aspect
image.pixels.foreach_set(pixels.ravel())
image.update()
obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set(
"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)
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]
assert cut
band = edge_band(faces, cut)
outside = [i for i in range(len(points)) if i not in band]
assert outside
results = []
for amount in (0, .2, .6):
set_value("Edge Round", amount)
values = profile_values(obj)
np.testing.assert_array_equal(values[cut], 0)
np.testing.assert_allclose(values[outside], .3)
assert np.isfinite(values).all()
assert values.max() == pytest.approx(.3)
results.append(values)
assert np.all(results[2] <= results[0] + 1e-7)
set_value("Split Threshold", 0)
np.testing.assert_allclose(profile_values(obj), .3)
@pytest.mark.parametrize("mode", [0, 1])
@pytest.mark.parametrize("reference", [0, 2])
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.
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])
raw = group.nodes.new("ShaderNodeCombineXYZ")
raw.inputs["Z"].default_value = 1
blurred = group.nodes.new("ShaderNodeCombineXYZ")
blurred.inputs["X"].default_value = 1
result = blend_normal(group, raw.outputs[0], blurred.outputs[0], weight)
store = group.nodes.new("GeometryNodeStoreNamedAttribute")
store.data_type, store.domain = "FLOAT_VECTOR", "POINT"
store.inputs["Name"].default_value = "normal_result"
source = group.nodes.new("NodeGroupInput")
group.links.new(source.outputs["Geometry"], store.inputs["Geometry"])
group.links.new(result, store.inputs["Value"])
group.links.new(store.outputs[0], next(n for n in group.nodes if n.type == "GROUP_OUTPUT").inputs["Geometry"])
modifier = obj.modifiers[0]
identifier = socket.identifier if mode else modifier_input_identifier(group, "Thickness", subtype="NONE")
set_value("Reference Depth", reference)
results = []
for amount in (0, .001, .01, 1):
set_modifier_input(modifier, identifier, amount * (max(reference, 1e-8) if mode else 1))
obj.update_tag(refresh={"DATA"}); bpy.context.view_layer.update()
ev = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()); mesh = ev.to_mesh()
try:
results.append(np.array(mesh.attributes["normal_result"].data[0].vector[:]))
finally:
ev.to_mesh_clear()
np.testing.assert_array_equal(results[0], [0,0,1])
np.testing.assert_array_equal(results[-1], [1,0,0])
assert np.linalg.norm(results[1]-results[0]) < np.linalg.norm(results[2]-results[0]) < .001

View File

@@ -22,7 +22,8 @@ def test_cutout_interfaces_and_thickness_modes(source):
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
revision = 18 if group == groups["DEPTH_SOLID"] 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
@@ -34,6 +35,7 @@ def test_cutout_interfaces_and_thickness_modes(source):
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 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"
@@ -44,6 +46,7 @@ def test_cutout_interfaces_and_thickness_modes(source):
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.insert(0, "Boundary Smooth")
expected += ["Reference Depth", "Edge Round", "Front Inflation", "Normal Smooth"]
elif group == groups["DEPTH_BALLOON"]:
expected += ["Reference Depth"]

View File

@@ -6,7 +6,7 @@ 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
from tools.nodes.common.nodes import capture_fields
from tools.nodes.common.nodes import sample_field
def store_test_fields(group, geometry, fields):
@@ -27,8 +27,8 @@ def set_test_structure(monkeypatch, value):
from tools.nodes.groups import image_depth_cutout
build = image_depth_cutout.build_front_fields
def fields(group, rim, normal):
result = build(group, rim, normal)
def fields(group, rim, normal, normal_weight):
result = build(group, rim, normal, normal_weight)
structure = group.nodes.new("ShaderNodeValue")
structure.outputs[0].default_value = value
result["structure"] = structure.outputs[0]
@@ -106,9 +106,12 @@ def test_rounding_shares_front_and_back_falloff(amount):
group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
source = group.nodes.new("NodeGroupInput")
fields = build_volume_fields(group)
geometry, center = capture_fields(group, source.outputs["Geometry"], "POINT", center=("FLOAT", fields["center"]))
geometry = source.outputs["Geometry"]
center = {"center": sample_field(group, geometry, fields["center"], "FLOAT")}
mode = group.nodes.new("FunctionNodeInputBool")
result, rounded = build_volume_rounding(group, geometry, {**fields, **center}, mode.outputs[0])
result, rounded = build_volume_rounding(group, geometry, fields, mode.outputs[0])
rounded = {name: sample_field(group, geometry, value, "FLOAT_VECTOR" if name == "normal" else "FLOAT")
for name, value in rounded.items()}
result = store_test_fields(group, result, {**center, **rounded})
group.links.new(result, group.nodes.new("NodeGroupOutput").inputs["Geometry"])
obj.modifiers.new("Round", "NODES").node_group = group

View File

@@ -205,7 +205,7 @@ def test_curved_shell_smooths_normals_without_shrinking_thickness(surface, norma
def test_surface_interface_and_node_structure(surface):
obj, _, _, inputs, _ = surface
expected = ["Geometry", "Subdivide", "Thickness", "Depth Scale", "Split Threshold", "Smooth", "Valid Only",
"Reference Depth", "Normal Smooth", "Smooth Weight", "Validity Threshold", "Uniform Scale", "Depth Image"]
"Boundary Smooth", "Reference Depth", "Normal Smooth", "Smooth Weight", "Validity Threshold", "Uniform Scale", "Depth Image"]
assert list(inputs) == expected
assert inputs["Split Threshold"].default_value == 0
assert inputs["Split Threshold"].min_value == 0
@@ -307,7 +307,7 @@ def test_relief_interface_and_node_structure(relief):
@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_samples_face_centers(surface, pattern, threshold):
def test_valid_only_deletes_invalid_points_and_wire_edges(surface, pattern, threshold):
obj, set_value, _, inputs, _ = surface
set_value("Validity Threshold", threshold)
depth_image = obj.modifiers[0][inputs["Depth Image"].identifier]
@@ -329,9 +329,9 @@ def test_valid_only_samples_face_centers(surface, pattern, threshold):
set_value("Subdivide", subdivisions)
set_value("Valid Only", False)
base = evaluated(obj)
u = np.array([(base[0][list(face), 0].mean() + 2) / 4 for face in base[1]])
u = (base[0][:, 0] + 2) / 4
sampled = np.interp(u * 16 - 0.5, np.arange(16), alpha)
expected = [face for face, alpha_value in zip(base[1], sampled) if alpha_value >= threshold]
expected = [face for face in base[1] if np.all(sampled[list(face)] >= threshold)]
set_value("Valid Only", True)
culled = evaluated(obj)
assert len(culled[1]) == len(expected)
@@ -363,7 +363,7 @@ 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")
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"
@@ -374,7 +374,9 @@ def test_validity_graph_matches_reference_domains_and_connections(surface):
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"
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])
@@ -412,6 +414,7 @@ def test_plane_interface_panels(source, kind):
assert [item.name for item in panels] == ["Options", "Data"]
controls = ["Reference Depth"]
if kind == "DEPTH":
controls.insert(0, "Boundary Smooth")
controls += ["Normal Smooth", "Smooth Weight", "Validity Threshold"]
else:
controls += ["Smooth Weight", "Depth Direction"]

View File

@@ -134,7 +134,8 @@ def test_split_input_precedes_smooth_without_cleanup_switch(kind):
split = inputs[names.index("Split Threshold")]
assert split.parent == inputs[names.index("Smooth")].parent
assert split.parent.name != "Options"
assert (split.default_value, split.min_value, split.max_value, split.subtype) == (0, 0, 1, "FACTOR")
assert split.default_value == pytest.approx(0.1 if kind == "cutout" else 0)
assert (split.min_value, split.max_value, split.subtype) == (0, 1, "FACTOR")
assert "Remove Strip Faces" not in names
@@ -198,7 +199,7 @@ def test_zero_split_bypasses_cleanup_and_positive_split_can_empty_mesh(kind):
np.testing.assert_array_equal(restored, original)
def test_cutout_cleans_triangle_strips_only_when_split_is_enabled():
def test_cutout_removes_an_all_boundary_triangle_strip():
import bmesh
from tests.support.depth_surface import surface, evaluated
@@ -208,15 +209,10 @@ def test_cutout_cleans_triangle_strips_only_when_split_is_enabled():
bmesh.ops.triangulate(mesh, faces=list(mesh.faces))
mesh.to_mesh(obj.data)
mesh.free()
set_value("Split Threshold", 0.0)
original, faces = evaluated(obj)
assert len(faces) == 4
set_value("Split Threshold", 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)
restored, restored_faces = evaluated(obj)
assert restored_faces == faces
np.testing.assert_array_equal(restored, original)
# 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)
for thickness in (0.0, 0.2):
set_value("Thickness", thickness)
points, faces = evaluated(obj)
assert not faces and not len(points)

View File

@@ -24,6 +24,14 @@ def test_shell_walls_directly_connect_caps(mode, 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())
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
mesh = result.to_mesh()
try:
welded = {i for i, value in enumerate(mesh.attributes["o_balloon"].data)
if mode == 0 and value.value == 0}
finally:
result.to_mesh_clear()
collapsed_walls = sum(count == 1 and a in welded and b in welded for (a, b), count in edges.items())
set_value("Mode", mode)
modifier = obj.modifiers[0]
thickness = modifier_input_identifier(modifier.node_group, "Thickness", subtype="NONE" if mode == 0 else "DISTANCE")
@@ -32,9 +40,9 @@ def test_shell_walls_directly_connect_caps(mode, split):
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 len(points) == 2 * len(front) - len(welded)
assert len(shell_faces) == 2 * len(faces) + boundary_count - collapsed_walls
assert all(len(face) in (3, 4) for face in shell_faces)
assert np.isfinite(points).all()
assert_closed(shell_faces)
@@ -146,6 +154,31 @@ def test_coincident_independent_sheets_do_not_weld_together():
assert component_count(faces) == 2
def test_shell_source_indices_cross_identity_grid_rows():
from scipy.spatial import cKDTree
from tools.nodes.common.depth_surface import thicken_depth_surface
bpy.ops.wm.read_factory_settings(use_empty=True)
group = bpy.data.node_groups.new("Indexed Shell", "GeometryNodeTree")
group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
grid = group.nodes.new("GeometryNodeMeshGrid")
grid.inputs["Vertices X"].default_value = 65
grid.inputs["Vertices Y"].default_value = 65
amount = group.nodes.new("ShaderNodeValue")
amount.outputs[0].default_value = 0.1
shell = thicken_depth_surface(group, grid.outputs["Mesh"], amount.outputs[0])
group.links.new(shell["geometry"], group.nodes.new("NodeGroupOutput").inputs["Geometry"])
obj = bpy.data.objects.new("Indexed Shell", bpy.data.meshes.new("Empty"))
bpy.context.collection.objects.link(obj)
obj.modifiers.new("Shell", "NODES").node_group = group
points, faces = evaluated(obj)
expected = np.array([(x / 64 - .5, y / 64 - .5, z)
for z in (0, -.1) for y in range(65) for x in range(65)])
assert len(points) == len(expected)
assert cKDTree(points).query(expected)[0].max() < 1e-6
assert_closed(faces)
@pytest.mark.parametrize("resolution", [1, 2, 4])
def test_planar_depth_preserves_blocks_and_cleans_single_face_width(resolution):
obj, set_value = surface(step=0.0, resolution=resolution)
@@ -185,7 +218,7 @@ def test_cut_vertices_preserve_camera_rays_and_other_vertices():
)
def test_stored_point_positions_prevent_depth_resampling_on_side_walls():
def test_source_surface_sampling_preserves_depth_on_side_walls():
obj, set_value = surface(step=3.0)
set_value("Split Threshold", 0.5)
set_value("Thickness", 0.1)
@@ -200,10 +233,11 @@ def test_stored_point_positions_prevent_depth_resampling_on_side_walls():
@pytest.mark.parametrize("thickness", [0.0, 0.2])
@pytest.mark.parametrize("split", [0.0, 0.5])
def test_internal_fields_stay_anonymous_and_user_attributes_survive(thickness, split):
def test_temporary_fields_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")
obj.data.attributes.new(".o_anyimage_user_weight", "FLOAT", "POINT")
for value in attribute.data:
value.value = 0.75
set_value("Split Threshold", split)
@@ -214,6 +248,9 @@ def test_internal_fields_stay_anonymous_and_user_attributes_survive(thickness, s
try:
assert {a.name for a in mesh.attributes if a.name.startswith(("o_depth_", "o_balloon_surface_"))} == {"o_depth_probe"}
assert "UVMap" in mesh.attributes
assert ".o_anyimage_user_weight" in mesh.attributes
assert ".o_anyimage_original_boundary" not in mesh.attributes
assert ".o_anyimage_depth_cut" not in mesh.attributes
assert all(
v.value == pytest.approx(0.75)
for v in mesh.attributes["o_user_weight"].data

View File

@@ -36,6 +36,7 @@ def panorama(depth=3, alpha=1, subdivide=3, reference=3):
for name, value in (("Depth Image", image), ("Subdivide", subdivide), ("Reference Distance", reference)):
set_value(name, value)
set_value("Boundary Smooth", 0)
return obj, set_value
@@ -209,6 +210,7 @@ def test_continuous_spherical_slope_does_not_split_at_seam_or_poles():
obj, set_value = panorama(depth)
original = evaluated(obj)
set_value("Split Threshold", 1)
set_value("Boundary Smooth", 12)
points, faces, uv = evaluated(obj)
np.testing.assert_allclose(points, original[0], atol=1e-6)
assert faces == original[1]

View File

@@ -1,4 +1,5 @@
import bpy
import pytest
from anyimage.common.object import (
modifier_input_identifier,
@@ -17,6 +18,46 @@ def evaluated_mesh(obj):
return evaluated, evaluated.to_mesh()
@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.arrangement.arrange import arrange_nodes
bpy.ops.wm.read_factory_settings(use_empty=True)
mesh = bpy.data.meshes.new("Source")
mesh.from_pydata([(20 + i % 3, i // 3, 0) for i in range(source_count)], [],
[(0, 1, 3, 2)] if source_count else [])
materials = [bpy.data.materials.new(name) for name in ("First", "Second")]
for material in materials:
mesh.materials.append(material)
obj = bpy.data.objects.new("Material Source", mesh)
bpy.context.collection.objects.link(obj)
group = bpy.data.node_groups.new("Material Inheritance", "GeometryNodeTree")
group.interface.new_socket(name="Geometry", in_out="INPUT", socket_type="NodeSocketGeometry")
group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
source = group.nodes.new("NodeGroupInput")
grid = group.nodes.new("GeometryNodeMeshGrid")
grid.inputs["Vertices X"].default_value = 3
grid.inputs["Vertices Y"].default_value = 2
geometry = inherit_material_slots(group, grid.outputs["Mesh"], source.outputs["Geometry"])
group.links.new(geometry, group.nodes.new("NodeGroupOutput").inputs["Geometry"])
obj.modifiers.new("Material Inheritance", "NODES").node_group = group
for arranged in (False, True):
if arranged:
arrange_nodes(group)
obj.update_tag(refresh={"DATA"})
ev, result = evaluated_mesh(obj)
try:
assert len(result.vertices) == 6 and len(result.polygons) == 2
assert all(abs(v.co.x) <= 0.5 for v in result.vertices)
if source_count:
assert [material.original for material in result.materials if material] == materials
assert result.materials[0].original == materials[0]
assert all(face.material_index == 0 for face in result.polygons)
finally:
ev.to_mesh_clear()
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))

View File

@@ -1,5 +1,6 @@
"""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
@@ -15,11 +16,46 @@ def test_saved_asset_inventory():
assert node_group.use_fake_user and node_group.users > 0
if name in GEOMETRY_NAMES:
assert node_group.is_modifier
stores = [node for node in node_group.nodes if node.bl_idname == "GeometryNodeStoreNamedAttribute"]
assert all(node.inputs["Name"].default_value in {"UVMap", "o_normal_scale"} for node in stores), name
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
@pytest.mark.parametrize("source", ["definition", "asset"])
def test_geometry_attribute_contract_includes_nested_groups(source):
from tools.nodes.build import build_node_groups
bpy.ops.wm.read_factory_settings(use_empty=True)
if source == "definition":
pending = build_node_groups()
else:
with bpy.data.libraries.load(str(node_asset_path())) as (library, target):
target.node_groups = list(library.node_groups)
pending = list(target.node_groups)
seen = set()
allowed = {
"UVMap": ("FLOAT2", "CORNER"),
"o_balloon": ("FLOAT", "POINT"),
".o_anyimage_depth_cut": ("BOOLEAN", "CORNER"),
}
while pending:
group = pending.pop()
if group in seen or group.bl_idname != "GeometryNodeTree":
continue
seen.add(group)
for node in group.nodes:
assert node.bl_idname != "GeometryNodeCaptureAttribute", group.name
if node.bl_idname == "GeometryNodeGroup" and node.node_tree:
pending.append(node.node_tree)
if node.bl_idname != "GeometryNodeStoreNamedAttribute":
continue
name = node.inputs["Name"].default_value
if name == "o_normal_scale":
assert node.data_type == "FLOAT" and node.domain in {"POINT", "FACE"}
else:
assert name in allowed, (group.name, name)
assert (node.data_type, node.domain) == allowed[name]
assert {group.name for group in seen} == GEOMETRY_NAMES

View File

@@ -0,0 +1,43 @@
"""Check temporary attribute lifetimes across depth modifier branches."""
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):
if kind == "cutout":
obj, set_value = surface()
elif kind == "plane":
obj, set_value, *_ = create_surface("definition", "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)
for smooth in (0, 5):
set_value("Boundary Smooth", smooth)
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
mesh = evaluated.to_mesh()
try:
assert mesh.vertices
assert not {a.name for a in mesh.attributes} & {
".o_anyimage_depth_cut", ".o_anyimage_original_boundary",
}
assert not list(modifier.node_warnings), [
warning.message for warning in modifier.node_warnings
]
finally:
evaluated.to_mesh_clear()

View File

@@ -0,0 +1,40 @@
"""Verify point culling removes its dangling wire geometry."""
import bpy
from tools.nodes.common.nodes import interface_socket
from tools.nodes.common.validity import delete_invalid_points
def test_point_culling_removes_wire_edges_without_removing_adjacent_surface():
bpy.ops.wm.read_factory_settings(use_empty=True)
mesh = bpy.data.meshes.new("Two Quads")
mesh.from_pydata([(0, 0, 0), (1, 0, 0), (2, 0, 0),
(0, 1, 0), (1, 1, 0), (2, 1, 0)], [],
[(0, 1, 4, 3), (1, 2, 5, 4)])
obj = bpy.data.objects.new("Surface", mesh)
bpy.context.collection.objects.link(obj)
group = bpy.data.node_groups.new("Point Culling", "GeometryNodeTree")
interface_socket(group, "Geometry", "INPUT", "NodeSocketGeometry")
interface_socket(group, "Geometry", "OUTPUT", "NodeSocketGeometry")
inlet = group.nodes.new("NodeGroupInput")
outlet = group.nodes.new("NodeGroupOutput")
index = group.nodes.new("GeometryNodeInputIndex")
invalid = group.nodes.new("ShaderNodeMath")
invalid.operation = "LESS_THAN"
invalid.inputs[1].default_value = 1
group.links.new(index.outputs[0], invalid.inputs[0])
geometry = delete_invalid_points(group, inlet.outputs["Geometry"], invalid.outputs[0])
group.links.new(geometry, outlet.inputs["Geometry"])
modifier = obj.modifiers.new("Cull", "NODES")
modifier.node_group = group
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
result = evaluated.to_mesh()
try:
assert len(result.polygons) == 1
assert len(result.edges) == 4
assert {tuple(vertex.co) for vertex in result.vertices} == {
(1, 0, 0), (2, 0, 0), (1, 1, 0), (2, 1, 0),
}
finally:
evaluated.to_mesh_clear()

View File

@@ -131,7 +131,6 @@ class ExtensionPackagingTest(unittest.TestCase):
self.assertIn("server/geometry/panorama.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