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.
This commit is contained in:
2026-09-15 00:40:24 +08:00
parent ab282e4fdf
commit 591e62085a
93 changed files with 1915 additions and 2851 deletions

View File

@@ -59,20 +59,6 @@ class MaskToolTest(BlenderTestCase):
self.assertEqual(operator.radius, 64)
def test_mask_polyline_status_explains_point_controls(self):
context = SimpleNamespace(
workspace=SimpleNamespace(status_text_set=Mock())
)
operator = SimpleNamespace(gesture="POLYLINE", mode="EXTEND", radius=25)
self.edit_mask.EditImageAlpha._set_status(operator, context)
status = context.workspace.status_text_set.call_args.args[0]
self.assertIn("click start, double-click, or Enter", status)
self.assertIn("Backspace removes", status)
self.assertIn("Extend", status)
def test_mask_and_rectify_start_after_an_empty_pick(self):
source = SimpleNamespace(
name="Source",
@@ -307,23 +293,6 @@ class MaskToolTest(BlenderTestCase):
np.testing.assert_allclose(result[:, :, 3], expected)
def test_mask_alpha_can_be_removed_and_added_again(self):
import numpy as np
source = np.ones((2, 2, 4), dtype=np.float32)
mask = self.image_selection.SelectionMask(
np.ones((2, 2), dtype=np.float32),
(0, 0, 2, 2),
)
removed = self.edit_mask.apply_alpha_mask(source, mask, "SUBTRACT")
transparent = np.flipud(removed.reshape(source.shape))
self.assertFalse(np.any(transparent[:, :, 3]))
added = self.edit_mask.apply_alpha_mask(transparent, mask, "EXTEND")
restored = np.flipud(added.reshape(source.shape))
np.testing.assert_array_equal(restored[:, :, 3], 1.0)
def test_mask_brush_stroke_is_circular_and_continuous(self):
import numpy as np
@@ -367,45 +336,6 @@ class MaskToolTest(BlenderTestCase):
self.assertGreater(projected_width, projected_height * 3)
def test_mask_brush_commit_uses_the_shared_viewport_footprint(self):
import numpy as np
polygon = ((1.0, 2.0), (8.0, 2.0), (8.0, 9.0))
local_mask = self.image_selection.SelectionMask(
np.ones((2, 2), dtype=np.float32),
(1, 1, 3, 3),
)
with (
patch.object(
self.edit_mask,
"brush_footprint_polygons",
return_value=(polygon,),
) as footprint,
patch.object(
self.edit_mask,
"rasterize_selection_union",
return_value=local_mask,
) as rasterize,
):
result = self.edit_mask.rasterize_brush_path(
(20, 20),
((4.0, 5.0), (7.0, 8.0)),
6,
lambda path: tuple((x * 2.0, y * 3.0) for x, y in path),
)
self.assertEqual(result.bounds, local_mask.bounds)
np.testing.assert_array_equal(result.values, local_mask.values)
footprint.assert_called_once_with(((4.0, 5.0), (7.0, 8.0)), 6)
selection_path = rasterize.call_args.args[1][0]
self.assertEqual(
selection_path.points,
((2.0, 6.0), (16.0, 6.0), (16.0, 27.0)),
)
self.assertTrue(rasterize.call_args.kwargs["antialias"])
def test_mask_brush_round_turn_stays_inside_the_radius(self):
polygons = self.image_interaction.brush_footprint_polygons(
((10.0, 10.0), (30.0, 10.0), (15.0, 11.0)),
@@ -454,597 +384,6 @@ class MaskToolTest(BlenderTestCase):
self.assertGreater(selection_mask.bounds[2], 70)
def test_mask_brush_completion_projects_and_applies_once(self):
source = SimpleNamespace(data=SimpleNamespace(size=(8, 6)))
selection_mask = object()
operator = SimpleNamespace(
_path=[(10.0, 20.0), (30.0, 40.0)],
radius=12,
_apply_mask=Mock(return_value={"FINISHED"}),
)
context = SimpleNamespace(region="region", region_data="region_data")
with (
patch.object(
self.edit_mask,
"image_empty_bounds",
return_value="bounds",
),
patch.object(
self.edit_mask,
"rasterize_brush_path",
return_value=selection_mask,
) as rasterize,
patch.object(
self.edit_mask,
"screen_path_to_image_pixels",
return_value="projected",
) as project,
):
result = self.edit_mask.EditImageAlpha._complete_brush(
operator,
context,
source,
"matrix",
)
path_projector = rasterize.call_args.args[3]
self.assertEqual(path_projector(((1.0, 2.0),)), "projected")
self.assertEqual(result, {"FINISHED"})
operator._apply_mask.assert_called_once_with(context, source, selection_mask)
project.assert_called_once_with(
((1.0, 2.0),),
"region",
"region_data",
"matrix",
"bounds",
(8, 6),
simplify=False,
)
def test_mask_brush_drag_only_updates_the_screen_path(self):
class Operator(self.image_interaction.ImageGesture):
active_tool_id = "anyimage.mask"
gesture = "BRUSH"
operator = Operator()
operator._path = [(10.0, 10.0)]
operator._path_samples = list(operator._path)
operator._cursor = (10.0, 10.0)
context = SimpleNamespace(area=SimpleNamespace(tag_redraw=Mock()))
event = SimpleNamespace(
type="MOUSEMOVE",
value="NOTHING",
mouse_region_x=30,
mouse_region_y=25,
)
with (
patch.object(
self.image_interaction,
"active_view3d_tool_id",
return_value=None,
),
patch.object(self.edit_mask, "rasterize_brush_path") as rasterize,
patch.object(self.edit_mask, "image_rgba") as read_pixels,
):
result = operator.modal(context, event)
self.assertEqual(result, {"RUNNING_MODAL"})
self.assertEqual(operator._path, [(10.0, 10.0), (30.0, 25.0)])
rasterize.assert_not_called()
read_pixels.assert_not_called()
def test_mask_replaces_with_a_same_sized_image(self):
import numpy as np
source_image = SimpleNamespace(size=(8, 6))
source = SimpleNamespace(data=source_image)
result_image = SimpleNamespace(users=0)
mask = self.image_selection.SelectionMask(
np.ones((2, 3), dtype=np.float32),
(1, 2, 4, 4),
)
operator = SimpleNamespace(mode="SET", report=Mock())
source_rgba = np.ones((6, 8, 4), dtype=np.float32)
with (
patch.object(self.edit_mask, "image_rgba", return_value=source_rgba),
patch.object(
self.edit_mask,
"create_image_edit_result",
return_value=result_image,
) as create,
patch.object(self.edit_mask, "replace_empty_image") as replace,
):
result = self.edit_mask.EditImageAlpha._apply_mask(
operator,
SimpleNamespace(),
source,
mask,
)
self.assertEqual(result, {"FINISHED"})
self.assertEqual(create.call_args.args[2], (8, 6))
replace.assert_called_once_with(source, result_image)
def test_image_selection_uses_white_dashed_segments(self):
segments = self.image_interaction.dashed_line_segments(
((0, 0), (10, 0)), dash_length=3.0, gap_length=2.0
)
self.assertEqual(
segments,
((0.0, 0.0), (3.0, 0.0), (5.0, 0.0), (8.0, 0.0)),
)
def test_merged_scanline_outline_omits_internal_band_edges(self):
segments = self.image_interaction.scanline_outline_segments(
(
(0.0, 2.0, ((0.0, 8.0),)),
(2.0, 4.0, ((0.0, 8.0),)),
)
)
edges = tuple(zip(segments[::2], segments[1::2]))
self.assertFalse(
any(start[1] == end[1] == 2.0 for start, end in edges)
)
def test_mask_brush_outline_matches_the_screen_radius(self):
polygons = self.image_interaction.brush_footprint_polygons(
((10.0, 10.0), (30.0, 10.0)),
5.0,
)
vertices = tuple(point for polygon in polygons for point in polygon)
self.assertAlmostEqual(min(point[0] for point in vertices), 5.0)
self.assertAlmostEqual(max(point[0] for point in vertices), 35.0)
self.assertAlmostEqual(min(point[1] for point in vertices), 5.0)
self.assertAlmostEqual(max(point[1] for point in vertices), 15.0)
def test_image_lasso_preview_uses_fill_and_a_closed_dashed_outline(self):
polygon = ((1.0, 2.0), (8.0, 3.0), (4.0, 9.0))
operator = SimpleNamespace(
gesture="LASSO",
mode="SET",
_path=list(polygon),
_area_pointer=1,
_region_pointer=2,
_preview_polygon=None,
_fill_triangles=(),
_brush_preview=None,
_outline_segments=(),
)
with (
patch.object(self.image_interaction, "drawing_in_region", return_value=True),
patch.object(
self.image_interaction,
"preview_fill_geometry",
return_value=(("triangles",), ("segments",)),
) as build_preview,
patch.object(self.image_interaction, "draw_polygon_fill") as draw_fill,
patch.object(self.image_interaction, "draw_dashed_line") as draw_outline,
):
self.image_interaction.ImageGesture._draw_selection(operator)
self.image_interaction.ImageGesture._draw_selection(operator)
build_preview.assert_called_once_with(polygon)
self.assertEqual(draw_fill.call_count, 2)
draw_fill.assert_called_with(
polygon,
("triangles",),
(1.0, 1.0, 1.0, 0.16),
)
self.assertEqual(draw_outline.call_count, 2)
draw_outline.assert_called_with((), segments=("segments",))
def test_mask_polyline_modal_adds_removes_and_confirms_points(self):
redraw = Mock()
context = SimpleNamespace(area=SimpleNamespace(tag_redraw=redraw))
confirmed = Mock(return_value={"FINISHED"})
operator = SimpleNamespace(
_path=[(10.0, 10.0)],
_cursor=(10.0, 10.0),
_confirm_polyline=confirmed,
)
result = self.image_interaction.ImageGesture._modal_polyline(
operator,
context,
SimpleNamespace(
type="LEFTMOUSE",
value="PRESS",
mouse_region_x=30,
mouse_region_y=10,
),
)
self.assertEqual(result, {"RUNNING_MODAL"})
self.assertEqual(operator._path, [(10.0, 10.0), (30.0, 10.0)])
self.image_interaction.ImageGesture._modal_polyline(
operator,
context,
SimpleNamespace(type="BACK_SPACE", value="PRESS"),
)
self.assertEqual(operator._path, [(10.0, 10.0)])
operator._path.extend(((30.0, 10.0), (30.0, 30.0)))
result = self.image_interaction.ImageGesture._modal_polyline(
operator,
context,
SimpleNamespace(
type="LEFTMOUSE",
value="PRESS",
mouse_region_x=12,
mouse_region_y=11,
),
)
self.assertEqual(result, {"FINISHED"})
confirmed.assert_called_once_with(context)
def test_mask_polyline_click_uses_close_radius_not_hint_distance(self):
context = SimpleNamespace(area=SimpleNamespace(tag_redraw=Mock()))
path = [(10.0, 10.0), (30.0, 10.0), (30.0, 30.0)]
confirmed = Mock(return_value={"FINISHED"})
operator = SimpleNamespace(
_path=list(path),
_cursor=path[-1],
_ui_scale=1.0,
_confirm_polyline=confirmed,
)
result = self.image_interaction.ImageGesture._modal_polyline(
operator,
context,
SimpleNamespace(
type="LEFTMOUSE",
value="PRESS",
mouse_region_x=26,
mouse_region_y=10,
),
)
self.assertEqual(result, {"RUNNING_MODAL"})
self.assertEqual(operator._path[-1], (26.0, 10.0))
confirmed.assert_not_called()
operator._path = list(path)
result = self.image_interaction.ImageGesture._modal_polyline(
operator,
context,
SimpleNamespace(
type="LEFTMOUSE",
value="PRESS",
mouse_region_x=25,
mouse_region_y=10,
),
)
self.assertEqual(result, {"FINISHED"})
confirmed.assert_called_once_with(context)
def test_mask_polyline_double_click_confirms_without_an_extra_point(self):
context = SimpleNamespace()
path = [(10.0, 10.0), (30.0, 10.0), (30.0, 30.0)]
confirmed = Mock(return_value={"FINISHED"})
operator = SimpleNamespace(
_path=list(path),
_confirm_polyline=confirmed,
)
result = self.image_interaction.ImageGesture._modal_polyline(
operator,
context,
SimpleNamespace(type="LEFTMOUSE", value="DOUBLE_CLICK"),
)
self.assertEqual(result, {"FINISHED"})
self.assertEqual(operator._path, path)
confirmed.assert_called_once_with(context)
def test_mask_polyline_consecutive_presses_confirm_double_click(self):
context = SimpleNamespace(area=SimpleNamespace(tag_redraw=Mock()))
path = [(10.0, 10.0), (30.0, 10.0), (30.0, 30.0)]
confirmed = Mock(return_value={"FINISHED"})
operator = SimpleNamespace(
_path=list(path),
_cursor=path[-1],
_ui_scale=1.0,
_polyline_double_click_interval=0.35,
_last_polyline_click_point=None,
_last_polyline_click_time=None,
_confirm_polyline=confirmed,
)
event = SimpleNamespace(
type="LEFTMOUSE",
value="PRESS",
mouse_region_x=50,
mouse_region_y=50,
)
with patch.object(
self.image_interaction.time,
"monotonic",
side_effect=(10.0, 10.2),
):
first_result = self.image_interaction.ImageGesture._modal_polyline(
operator, context, event
)
second_result = self.image_interaction.ImageGesture._modal_polyline(
operator, context, event
)
self.assertEqual(first_result, {"RUNNING_MODAL"})
self.assertEqual(second_result, {"FINISHED"})
self.assertEqual(operator._path, [*path, (50.0, 50.0)])
confirmed.assert_called_once_with(context)
def test_polyline_raw_double_click_requires_time_and_proximity(self):
is_double_click = self.image_interaction.is_polyline_double_click
self.assertTrue(is_double_click((10, 10), 1.0, (14, 13), 1.2))
self.assertFalse(is_double_click((10, 10), 1.0, (16, 10), 1.2))
self.assertFalse(is_double_click((10, 10), 1.0, (10, 10), 1.4))
def test_mask_polyline_double_click_requires_three_points(self):
class Operator(self.image_interaction.ImageGesture):
pass
operator = Operator()
operator._path = [(10.0, 10.0), (30.0, 10.0)]
operator._complete = Mock(return_value={"FINISHED"})
result = operator._modal_polyline(
SimpleNamespace(),
SimpleNamespace(type="LEFTMOUSE", value="DOUBLE_CLICK"),
)
self.assertEqual(result, {"RUNNING_MODAL"})
self.assertEqual(operator._path, [(10.0, 10.0), (30.0, 10.0)])
operator._complete.assert_not_called()
def test_mask_polyline_enter_requires_three_points(self):
context = SimpleNamespace()
complete = Mock(return_value={"FINISHED"})
operator = SimpleNamespace(_path=[(0, 0), (10, 0)], _complete=complete)
self.assertEqual(
self.image_interaction.ImageGesture._confirm_polyline(
operator, context
),
{"RUNNING_MODAL"},
)
operator._path.append((10, 10))
self.assertEqual(
self.image_interaction.ImageGesture._confirm_polyline(
operator, context
),
{"FINISHED"},
)
complete.assert_called_once_with(context)
def test_mask_polyline_completion_uses_the_lasso_selection_pipeline(self):
source = SimpleNamespace(data=SimpleNamespace(size=(100, 80)))
submitted = Mock(return_value={"FINISHED"})
operator = SimpleNamespace(
gesture="POLYLINE",
source_object_name="Source",
source_matrix_data="matrix",
_path=[(10.0, 10.0), (60.0, 10.0), (40.0, 50.0)],
_submit_selection_path=submitted,
_finish=Mock(),
)
context = SimpleNamespace(region=object(), region_data=object())
with (
patch.object(
self.image_interaction,
"require_image_empty",
return_value=source,
),
patch.object(
self.image_interaction,
"deserialize_matrix",
return_value="matrix",
),
patch.object(
self.image_interaction,
"image_empty_bounds",
return_value=(-0.5, 0.5, -0.5, 0.5),
),
patch.object(
self.image_interaction,
"screen_path_to_image_pixels",
return_value=((1, 2), (8, 2), (4, 9)),
) as project,
):
result = self.image_interaction.ImageGesture._complete(
operator, context
)
self.assertEqual(result, {"FINISHED"})
self.assertEqual(
submitted.call_args.args[1].points,
((1.0, 2.0), (8.0, 2.0), (4.0, 9.0)),
)
self.assertEqual(project.call_args.args[0], tuple(operator._path))
def test_mask_polyline_overlay_previews_cursor_and_close_target(self):
operator = SimpleNamespace(
gesture="POLYLINE",
mode="SUBTRACT",
_path=[(10.0, 10.0), (30.0, 10.0), (30.0, 30.0)],
_cursor=(12.0, 11.0),
_ui_scale=1.0,
_area_pointer=1,
_region_pointer=2,
_preview_polygon=None,
_fill_triangles=(),
_brush_preview=None,
_outline_segments=(),
)
with (
patch.object(self.image_interaction, "drawing_in_region", return_value=True),
patch.object(
self.image_interaction,
"preview_fill_geometry",
return_value=(("triangles",), ("segments",)),
),
patch.object(self.image_interaction, "draw_polygon_fill") as fill,
patch.object(self.image_interaction, "draw_dashed_line"),
patch.object(
self.image_interaction,
"draw_polyline_close_indicator",
) as close,
):
self.image_interaction.ImageGesture._draw_selection(operator)
polygon = (*operator._path, operator._cursor)
fill.assert_called_once_with(
polygon,
("triangles",),
(1.0, 0.25, 0.2, 0.16),
)
close.assert_called_once_with(
operator._path[0],
self.image_interaction.polyline_close_indicator_radius(
self.image_interaction.math.dist(
operator._cursor,
operator._path[0],
),
1.0,
),
1.0,
)
def test_polyline_close_indicator_matches_blender_scale_and_distance(self):
indicator = self.image_interaction.polyline_close_indicator_radius
close_radius = self.image_interaction.polyline_close_radius
self.assertEqual(close_radius(1.0), 15.0)
self.assertEqual(indicator(0.0, 1.0), 15.0)
self.assertGreater(indicator(20.0, 1.0), indicator(120.0, 1.0))
self.assertIsNone(indicator(225.0, 1.0))
self.assertEqual(close_radius(2.0), 30.0)
self.assertEqual(indicator(0.0, 2.0), 30.0)
self.assertIsNotNone(indicator(899.0, 2.0))
self.assertIsNone(indicator(900.0, 2.0))
def test_mask_polyline_overlay_hides_close_target_outside_hint_distance(self):
operator = SimpleNamespace(
gesture="POLYLINE",
mode="SUBTRACT",
_path=[(10.0, 10.0), (30.0, 10.0), (30.0, 30.0)],
_cursor=(236.0, 10.0),
_ui_scale=1.0,
_area_pointer=1,
_region_pointer=2,
_preview_polygon=None,
_fill_triangles=(),
_brush_preview=None,
_outline_segments=(),
)
with (
patch.object(self.image_interaction, "drawing_in_region", return_value=True),
patch.object(
self.image_interaction,
"preview_fill_geometry",
return_value=(("triangles",), ("segments",)),
),
patch.object(self.image_interaction, "draw_polygon_fill"),
patch.object(self.image_interaction, "draw_dashed_line"),
patch.object(
self.image_interaction,
"draw_polyline_close_indicator",
) as close,
):
self.image_interaction.ImageGesture._draw_selection(operator)
close.assert_not_called()
def test_mask_brush_overlay_matches_lasso_fill_and_outline(self):
for mode, color in (
("SUBTRACT", (1.0, 0.25, 0.2, 0.16)),
("EXTEND", (1.0, 1.0, 1.0, 0.16)),
):
cache = Mock()
operator = SimpleNamespace(
gesture="BRUSH", mode=mode, radius=12,
_path=[(10.0, 10.0), (20.0, 20.0)],
_area_pointer=1, _region_pointer=2, _brush_preview=cache,
)
with patch.object(self.image_interaction, "drawing_in_region", return_value=True):
self.image_interaction.ImageGesture._draw_selection(operator)
cache.update.assert_called_once_with(operator._path, 12)
cache.draw.assert_called_once_with(color)
def test_mask_brush_retains_the_complete_uncommitted_path(self):
class Operator(self.image_interaction.ImageGesture):
active_tool_id = "anyimage.mask"
gesture = "BRUSH"
operator = Operator()
operator._path = [(10.0, 10.0)]
operator._path_samples = list(operator._path)
operator._cursor = (10.0, 10.0)
context = SimpleNamespace(area=SimpleNamespace(tag_redraw=Mock()))
event = SimpleNamespace(
type="MOUSEMOVE",
value="NOTHING",
mouse_region_x=20,
mouse_region_y=25,
)
with patch.object(
self.image_interaction,
"active_view3d_tool_id",
return_value=None,
):
result = operator.modal(context, event)
self.assertEqual(result, {"RUNNING_MODAL"})
self.assertEqual(operator._path, [(10.0, 10.0), (20.0, 25.0)])
def test_mask_tool_switch_ends_the_uncommitted_overlay(self):
class Operator(self.image_interaction.ImageGesture):
active_tool_id = "anyimage.mask"
gesture = "BRUSH"
operator = Operator()
operator._finish = Mock()
with patch.object(
self.image_interaction,
"active_view3d_tool_id",
return_value="anyimage.frame",
):
result = operator.modal(SimpleNamespace(), SimpleNamespace())
self.assertEqual(result, {"CANCELLED"})
operator._finish.assert_called_once()
def test_image_lasso_does_not_add_stationary_or_nearly_collinear_points(self):
path = [(0.0, 0.0)]
samples = list(path)