Files
AnyImage/tests/anyimage/operators/test_cutout_interaction.py
nan 591e62085a feat(cutout): implement boundary padding for cutout textures
- Introduced a new `Boundary Padding` preference in the Cutout Tool with a default of 2 px and a configurable range of 1-4 px.
- Updated the Cutout client to establish inward mapping from the final Mask, sampling in a fixed direction and processing independent material RGBA and Depth.
- Enhanced the handling of RGBA and Depth during cutout operations, ensuring proper mapping and preservation of source data.
- Added tests to validate the new boundary padding functionality and its integration with existing cutout processes.

feat(geometry): remove overall mesh smoothing from geometry nodes

- Removed the `Smooth` input from several geometry node groups to eliminate unnecessary overall mesh smoothing.
- Retained `Smooth Weight` for depth nodes while removing it from Cutout and Relief Plane nodes.
- Ensured that boundary, normal, and fill smoothing behaviors remain intact and consistent.
- Updated tests and documentation to reflect the changes in node interfaces and behaviors.

feat(normals): scale tangent normals with depth

- Reused the existing `o_normal_reduction` attribute to adjust Tangent Normal strength based on the geometry's `Depth Scale`.
- Ensured that Tangent Normal strength is consistent with the geometry depth, preserving full strength for materials that do not provide the attribute.
- Updated tests to verify the correct behavior of normal strength adjustments in both Tangent and Object Space.
2026-09-15 00:40:24 +08:00

284 lines
11 KiB
Python

import json
from types import SimpleNamespace
from types import MethodType
from unittest.mock import Mock
import pytest
from anyimage.common.selection import SelectionPath
from anyimage.common import viewport
from anyimage.operators.cutout_tool import interaction
from anyimage.operators.cutout_tool import operators as cutout_main
class Pie:
def __init__(self):
self.calls = []
def operator(self, identifier, **options):
operator = SimpleNamespace()
self.calls.append((identifier, options, operator))
return operator
class WindowManager:
def __init__(self):
self.pie = Pie()
self.events = []
def popup_menu_pie(self, _event, draw, **_options):
self.events.append(_event)
menu = SimpleNamespace(layout=SimpleNamespace(menu_pie=lambda: self.pie))
draw(menu, None)
def cutout_context(
window_manager,
edge_length=0.1,
generate_normal=False,
alpha_threshold=0.9,
fine_outline=True,
):
settings = SimpleNamespace(
cutout_edge_length=edge_length,
cutout_generate_normal=generate_normal,
cutout_alpha_threshold=alpha_threshold,
cutout_fine_outline=fine_outline,
)
return SimpleNamespace(
window_manager=window_manager,
scene=SimpleNamespace(anyimage_settings=settings),
)
def test_cutout_menu_captures_fine_outline_before_later_redraw(monkeypatch):
window_manager = WindowManager()
context = cutout_context(window_manager)
monkeypatch.setattr(interaction, "ai_ready", lambda: False)
def delayed_popup(_event, draw, **_options):
context.scene.anyimage_settings.cutout_fine_outline = False
draw(SimpleNamespace(layout=SimpleNamespace(menu_pie=lambda: window_manager.pie)), None)
window_manager.popup_menu_pie = delayed_popup
interaction.open_shape_pie(context, object(), cutout_source(),
SelectionPath(points=((0, 0), (10, 0), (0, 10))))
assert all(call[2].fine_outline is True for call in window_manager.pie.calls)
def cutout_source(name="Source", size=(2048, 1024)):
return SimpleNamespace(name=name, data=SimpleNamespace(size=size))
def test_cutout_pie_uses_two_local_shapes_without_ai(monkeypatch):
window_manager = WindowManager()
context = cutout_context(window_manager, generate_normal=True)
source = cutout_source()
monkeypatch.setattr(interaction, "ai_ready", lambda: False)
interaction.open_shape_pie(
context,
object(),
source,
SelectionPath(points=((0, 0), (10, 0), (0, 10))),
)
assert [call[2].shape for call in window_manager.pie.calls] == [
"FLAT",
"SOLID",
]
assert [call[1]["text"] for call in window_manager.pie.calls] == [
"Flat",
"Solid",
]
assert all(
call[0] == "anyimage.cutout_selection_to_shape"
for call in window_manager.pie.calls
)
assert not any(call[2].generate_normal for call in window_manager.pie.calls)
assert all(call[2].edge_length == 0.1 for call in window_manager.pie.calls)
def test_cutout_pie_passes_image_lasso_options_to_all_ai_shapes(monkeypatch):
window_manager = WindowManager()
context = cutout_context(
window_manager,
edge_length=0.05,
generate_normal=True,
alpha_threshold=0.1,
fine_outline=False,
)
source = cutout_source(size=(4096, 2048))
monkeypatch.setattr(interaction, "ai_ready", lambda: True)
serialized = []
original_to_json = SelectionPath.to_json
def record_to_json(selection_path):
value = original_to_json(selection_path)
serialized.append(value)
return value
monkeypatch.setattr(SelectionPath, "to_json", record_to_json)
interaction.open_shape_pie(
context,
object(),
source,
SelectionPath(
points=((0, 0), (10, 0), (0, 10)),
),
)
assert [call[2].shape for call in window_manager.pie.calls] == [
"FLAT",
"SOLID",
"DEPTH_SYMMETRY",
"DEPTH_SOLID",
]
assert all(call[2].generate_normal for call in window_manager.pie.calls)
assert all(call[2].alpha_threshold == 0.1 for call in window_manager.pie.calls)
assert all(call[2].fine_outline is False for call in window_manager.pie.calls)
assert len(serialized) == 1
assert set(json.loads(serialized[0])) == {"points"}
assert all(
call[2].selection_path_json == serialized[0]
for call in window_manager.pie.calls
)
assert all(
SelectionPath.from_json(call[2].selection_path_json).points
== ((0.0, 0.0), (10.0, 0.0), (0.0, 10.0))
for call in window_manager.pie.calls
)
assert all(call[2].edge_length == 0.05 for call in window_manager.pie.calls)
def test_cutout_lasso_submission_passes_only_the_selected_path(monkeypatch):
source = cutout_source()
window_manager = WindowManager()
context = cutout_context(window_manager)
monkeypatch.setattr(cutout_main, "require_image_empty", lambda _name: source)
monkeypatch.setattr(interaction, "ai_ready", lambda: False)
path = SelectionPath(points=((0, 0), (10, 0), (0, 10)))
operator = SimpleNamespace(source_object_name=source.name, _pie_event=object(), gesture="LASSO")
result = cutout_main.SelectCutoutSelection._submit_selection_path(operator, context, path)
assert result == {"FINISHED"}
assert SelectionPath.from_json(window_manager.pie.calls[0][2].selection_path_json) == path
def test_cutout_tool_starts_on_press_and_keeps_shift_selection():
keymap = cutout_main.CutoutTool.bl_keymap
assert keymap[0][1]["value"] == "PRESS"
assert len(keymap) == 2
assert keymap[1][0] == "view3d.select"
assert keymap[1][2] == {"properties": [("toggle", True)]}
assert cutout_main.SelectCutoutSelection.resolve_on_press
def test_cutout_f_has_no_option_shortcut(monkeypatch):
operator = SimpleNamespace(
gesture="LASSO",
active_tool_id="anyimage.cutout_lasso",
)
monkeypatch.setattr(viewport, "active_view3d_tool_id", lambda _context: None)
result = cutout_main.SelectCutoutSelection.modal(
operator,
SimpleNamespace(),
SimpleNamespace(type="F", value="PRESS"),
)
assert result == {"PASS_THROUGH"}
def test_cutout_menu_keeps_submitted_gesture_during_redraw(monkeypatch):
window_manager = WindowManager()
context = cutout_context(window_manager)
monkeypatch.setattr(interaction, "ai_ready", lambda: True)
def delayed_popup(_event, draw, **_options):
context.scene.anyimage_settings.cutout_gesture = "LASSO"
draw(SimpleNamespace(layout=SimpleNamespace(menu_pie=lambda: window_manager.pie)), None)
window_manager.popup_menu_pie = delayed_popup
interaction.open_shape_pie(
context, object(), cutout_source(),
SelectionPath(points=((0, 0), (10, 0), (0, 10))), gesture="POLYLINE",
)
assert all(call[2].gesture == "POLYLINE" for call in window_manager.pie.calls)
@pytest.mark.parametrize("finish", ("START", "DOUBLE_CLICK", "RET", "NUMPAD_ENTER"))
def test_cutout_polyline_edits_and_submits_through_shared_gesture(monkeypatch, finish):
window_manager = WindowManager()
context = cutout_context(window_manager)
context.area = SimpleNamespace(tag_redraw=Mock())
context.region = context.region_data = object()
context.scene.anyimage_settings.cutout_gesture = "POLYLINE"
source = cutout_source()
monkeypatch.setattr(viewport, "active_view3d_tool_id", lambda _context: None)
monkeypatch.setattr(viewport, "require_image_empty", lambda _name: source)
monkeypatch.setattr(cutout_main, "require_image_empty", lambda _name: source)
monkeypatch.setattr(viewport, "deserialize_matrix", lambda _data: None)
monkeypatch.setattr(viewport, "image_empty_bounds", lambda _source: None)
monkeypatch.setattr(viewport, "screen_path_to_image_pixels", lambda path, *_args: path)
monkeypatch.setattr(interaction, "ai_ready", lambda: False)
operator = SimpleNamespace(
source_object_name=source.name, source_matrix_data="matrix",
active_tool_id="anyimage.cutout_lasso", _path=[(0, 0)],
_polyline_confirm_type=None,
_finish=Mock(),
)
for name in ("_modal_polyline", "_confirm_polyline", "_complete", "_submit_selection_path"):
setattr(operator, name, MethodType(getattr(cutout_main.SelectCutoutSelection, name), operator))
cutout_main.SelectCutoutSelection._read_gesture_settings(operator, context)
def event(kind, value="PRESS", point=(100, 100)):
return cutout_main.SelectCutoutSelection.modal(operator, context, SimpleNamespace(
type=kind, value=value, mouse_region_x=point[0], mouse_region_y=point[1],
))
assert event("RET") == {"RUNNING_MODAL"}
event("LEFTMOUSE", point=(100, 0))
event("LEFTMOUSE", point=(100, 100))
event("BACK_SPACE")
assert operator._path == [(0, 0), (100, 0)]
event("LEFTMOUSE", point=(100, 100))
if finish == "START":
result = event("LEFTMOUSE", point=(0, 0))
elif finish == "DOUBLE_CLICK":
result = event("LEFTMOUSE", "DOUBLE_CLICK")
else:
result = event(finish)
assert result == {"RUNNING_MODAL"}
assert window_manager.events == []
operator._finish.assert_not_called()
points = tuple(operator._path)
assert event("MOUSEMOVE", point=(200, 200)) == {"RUNNING_MODAL"}
assert event("BACK_SPACE") == {"RUNNING_MODAL"}
assert tuple(operator._path) == points
release_type = "LEFTMOUSE" if finish in {"START", "DOUBLE_CLICK"} else finish
assert event("SPACE", "RELEASE") == {"RUNNING_MODAL"}
result = event(release_type, "RELEASE")
assert result == {"FINISHED"}
assert len(window_manager.events) == 1
assert window_manager.events[0].value == "RELEASE"
operator._finish.assert_called_once_with(context)
submitted = window_manager.pie.calls[0][2]
assert submitted.gesture == "POLYLINE"
assert SelectionPath.from_json(submitted.selection_path_json).points == ((0, 0), (100, 0), (100, 100))
@pytest.mark.parametrize("reason", ("ESC", "RIGHTMOUSE", "TOOL_SWITCH"))
def test_cutout_polyline_cancel_cleans_preview(monkeypatch, reason):
context = SimpleNamespace()
operator = SimpleNamespace(
gesture="POLYLINE", active_tool_id="anyimage.cutout_lasso", _finish=Mock(),
_polyline_confirm_type="LEFTMOUSE",
)
operator._cancel_and_finish = MethodType(viewport.ImageGesture._cancel_and_finish, operator)
monkeypatch.setattr(viewport, "active_view3d_tool_id", lambda _context: "other" if reason == "TOOL_SWITCH" else None)
result = cutout_main.SelectCutoutSelection.modal(operator, context, SimpleNamespace(type=reason, value="PRESS"))
assert result == {"CANCELLED"}
operator._finish.assert_called_once_with(context)