import sys from types import ModuleType, SimpleNamespace from unittest.mock import Mock, patch from tests.support.blender import BlenderTestCase class MaskToolTest(BlenderTestCase): def test_selection_mask_keeps_nonzero_winding_overlap(self): path = ( (1, 1), (7, 1), (7, 7), (1, 7), (1, 1), (4, 1), (10, 1), (10, 7), (4, 7), (4, 1), (1, 1), ) selection = self.image_selection.rasterize_selection_path( (12, 9), self.image_selection.SelectionPath(points=path), ).full_values((12, 9)) self.assertTrue(selection[3, 5]) triangles = self.image_interaction.preview_fill_geometry(path)[0] preview_area = sum( abs( self.image_interaction._triangle_cross( triangles[index], triangles[index + 1], triangles[index + 2], ) ) * 0.5 for index in range(0, len(triangles), 3) ) self.assertAlmostEqual(preview_area, 54.0) def test_mask_freezes_scene_settings_on_invoke(self): settings = SimpleNamespace( mask_gesture="BRUSH", mask_mode="ADD", mask_radius=64, ) context = SimpleNamespace(scene=SimpleNamespace(anyimage_settings=settings)) operator = SimpleNamespace(gesture="LASSO", mode="SET", radius=25) self.edit_mask.EditImageAlpha._read_gesture_settings(operator, context) self.assertEqual(operator.gesture, "BRUSH") self.assertEqual(operator.mode, "ADD") self.assertEqual(operator.radius, 64) def test_mask_has_no_letter_option_shortcut(self): operator = SimpleNamespace( gesture="LASSO", mode="SUBTRACT", radius=25, active_tool_id="anyimage.mask", ) context = SimpleNamespace( workspace=SimpleNamespace(status_text_set=Mock()) ) event = SimpleNamespace(type="F", value="PRESS") with patch.object( self.image_interaction, "active_view3d_tool_id", return_value=None, ): result = self.image_interaction.ImageGesture.modal( operator, context, event, ) self.edit_mask.EditImageAlpha._set_status(operator, context) self.assertEqual(result, {"PASS_THROUGH"}) status = context.workspace.status_text_set.call_args.args[0] self.assertIn("Subtract", status) self.assertNotIn("F", status) def test_mask_polyline_status_explains_point_controls(self): context = SimpleNamespace( workspace=SimpleNamespace(status_text_set=Mock()) ) operator = SimpleNamespace(gesture="POLYLINE", mode="ADD", 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("Add", status) def test_mask_and_rectify_start_after_an_empty_pick(self): source = SimpleNamespace( name="Source", data=SimpleNamespace(size=(8, 6)), matrix_world="matrix", ) area = SimpleNamespace(as_pointer=lambda: 1, tag_redraw=Mock()) region = SimpleNamespace(as_pointer=lambda: 2, width=100, height=80) window_manager = SimpleNamespace( modal_handler_add=Mock(), event_timer_add=Mock(return_value="timer"), ) context = SimpleNamespace( area=area, region=region, window=object(), window_manager=window_manager, workspace=SimpleNamespace(status_text_set=Mock()), scene=SimpleNamespace( anyimage_settings=SimpleNamespace( mask_gesture="LASSO", mask_mode="SUBTRACT", mask_radius=25, ) ), ) event = SimpleNamespace( mouse_region_x=12, mouse_region_y=18, value="PRESS", ) previous_space = getattr(self.fake_bpy.types, "SpaceView3D", None) self.fake_bpy.types.SpaceView3D = SimpleNamespace( draw_handler_add=Mock(return_value="handler") ) try: with ( patch.object( self.image_interaction, "resolve_image_edit_click", return_value=(source, True), ), patch.object( self.image_interaction, "video_total_frames", return_value=1, ), patch.object( self.image_interaction, "serialize_matrix", return_value="serialized", ), ): mask = self.edit_mask.EditImageAlpha() mask.report = Mock() self.assertEqual(mask.invoke(context, event), {"RUNNING_MODAL"}) self.assertEqual(mask._path, [(12.0, 18.0)]) with ( patch.object( self.rectify, "resolve_image_edit_click", return_value=(source, True), ), patch.object( self.rectify, "video_total_frames", return_value=1, ), patch.object( self.rectify, "serialize_matrix", return_value="serialized", ), ): rectify = self.rectify.RectifyImagePerspective() rectify.report = Mock() self.assertEqual(rectify.invoke(context, event), {"RUNNING_MODAL"}) self.assertEqual(rectify._points, [(12.0, 18.0)]) finally: if previous_space is None: del self.fake_bpy.types.SpaceView3D else: self.fake_bpy.types.SpaceView3D = previous_space def test_mask_and_rectify_cancel_without_an_active_image(self): context = SimpleNamespace( scene=SimpleNamespace( anyimage_settings=SimpleNamespace( mask_gesture="LASSO", mask_mode="SUBTRACT", mask_radius=25, ) ) ) event = SimpleNamespace() with patch.object( self.image_interaction, "resolve_image_edit_click", return_value=(None, False), ): mask = self.edit_mask.EditImageAlpha() mask.report = Mock() self.assertEqual(mask.invoke(context, event), {"CANCELLED"}) with patch.object( self.rectify, "resolve_image_edit_click", return_value=(None, False), ): rectify = self.rectify.RectifyImagePerspective() rectify.report = Mock() self.assertEqual(rectify.invoke(context, event), {"CANCELLED"}) def test_image_mask_fill_triangulates_a_concave_polygon_without_overlap(self): polygon = ( (0, 0), (4, 0), (4, 4), (3, 4), (3, 1), (1, 1), (1, 4), (0, 4), ) triangles = self.image_interaction.triangulated_polygon_vertices(polygon) area = sum( abs( self.image_interaction._triangle_cross( triangles[index], triangles[index + 1], triangles[index + 2], ) ) * 0.5 for index in range(0, len(triangles), 3) ) self.assertEqual(len(triangles), 18) self.assertAlmostEqual(area, 10.0) def test_image_mask_fill_uses_blender_geometry_without_stopping_the_draw(self): mathutils = ModuleType("mathutils") mathutils.Vector = lambda values: values mathutils.geometry = SimpleNamespace( tessellate_polygon=lambda _loops: ( ((0.0, 0.0, 0.0), (2.0, 0.0, 0.0), (0.0, 2.0, 0.0)), ) ) with patch.dict(sys.modules, {"mathutils": mathutils}): triangles = self.image_interaction.triangulated_polygon_vertices( ((0, 0), (2, 0), (0, 2)) ) self.assertEqual( triangles, ((0.0, 0.0), (2.0, 0.0), (0.0, 2.0)), ) def test_image_mask_fill_falls_back_when_blender_tessellation_rejects_path(self): mathutils = ModuleType("mathutils") mathutils.Vector = lambda values: values mathutils.geometry = SimpleNamespace( tessellate_polygon=lambda _loops: (_ for _ in ()).throw(TypeError()) ) with patch.dict(sys.modules, {"mathutils": mathutils}): triangles = self.image_interaction.triangulated_polygon_vertices( ((0, 0), (2, 0), (0, 2)) ) self.assertEqual(len(triangles), 3) def test_image_mask_preview_fills_a_self_intersecting_lasso(self): polygon = ((0, 0), (4, 4), (0, 4), (4, 0)) triangles = self.image_interaction.preview_fill_geometry(polygon)[0] area = sum( abs( self.image_interaction._triangle_cross( triangles[index], triangles[index + 1], triangles[index + 2], ) ) * 0.5 for index in range(0, len(triangles), 3) ) self.assertTrue(triangles) self.assertAlmostEqual(area, 8.0) def test_image_mask_preview_detects_only_actual_self_intersections(self): self.assertTrue( self.image_interaction.polygon_self_intersects( ((0, 0), (4, 4), (0, 4), (4, 0)) ) ) self.assertFalse( self.image_interaction.polygon_self_intersects( ((0, 0), (4, 0), (2, 2), (4, 4), (0, 4)) ) ) def test_mask_alpha_modes_preserve_rgb_and_canvas(self): import numpy as np source = np.zeros((2, 3, 4), dtype=np.float32) source[:, :, :3] = np.asarray((0.2, 0.4, 0.6), dtype=np.float32) source[:, :, 3] = np.asarray(((0.2, 0.5, 0.8), (1.0, 0.4, 0.0))) mask = self.image_selection.SelectionMask( np.asarray(((0.25, 1.0), (0.5, 0.75)), dtype=np.float32), (1, 0, 3, 2), ) full_mask = mask.full_values((3, 2)) expected_alpha = { "SET": source[:, :, 3] * full_mask, "ADD": np.maximum(source[:, :, 3], full_mask), "SUBTRACT": source[:, :, 3] * (1.0 - full_mask), } for mode, expected in expected_alpha.items(): with self.subTest(mode=mode): pixels = self.edit_mask.apply_alpha_mask(source, mask, mode) result = np.flipud(pixels.reshape(source.shape)) np.testing.assert_allclose(result[:, :, :3], source[:, :, :3]) 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, "ADD") 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 single = self.edit_mask.rasterize_brush_path( (120, 50), ((20, 25),), 10, lambda path: path, ) stroke = self.edit_mask.rasterize_brush_path( (120, 50), ((20, 25), (100, 25)), 10, lambda path: path, ) single_values = single.full_values((120, 50)) stroke_values = stroke.full_values((120, 50)) self.assertGreater(single_values[25, 20], 0.9) self.assertEqual(single_values[0, 0], 0.0) self.assertTrue(np.all(stroke_values[25, 20:101] > 0.9)) def test_mask_brush_clips_bounds_and_projects_screen_radius(self): clipped = self.edit_mask.rasterize_brush_path( (80, 40), ((0, 0),), 10, lambda path: path, ) projected = self.edit_mask.rasterize_brush_path( (160, 80), ((30, 30),), 10, lambda path: tuple((x * 2.0, y * 0.5) for x, y in path), ) self.assertEqual(clipped.bounds[:2], (0, 0)) projected_width = projected.bounds[2] - projected.bounds[0] projected_height = projected.bounds[3] - projected.bounds[1] 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)), 5.0, ) bands = self.image_interaction.scanline_union_bands(polygons) center_band = next( band for band in bands if band[0] <= 10.0 < band[1] ) self.assertTrue( all(len(intervals) == 1 for _bottom, _top, intervals in bands if intervals) ) self.assertEqual(len(center_band[2]), 1) sample_y = (center_band[0] + center_band[1]) / 2 half_width = (25 - (sample_y - 10) ** 2) ** 0.5 self.assertAlmostEqual(center_band[2][0][0], 10 - half_width, delta=0.03) self.assertAlmostEqual(center_band[2][0][1], 30 + half_width, delta=0.03) def test_mask_brush_self_intersection_uses_non_zero_winding(self): selection_mask = self.edit_mask.rasterize_brush_path( (80, 80), ((15, 15), (65, 65), (15, 65), (65, 15)), 6, lambda path: path, ) values = selection_mask.full_values((80, 80)) self.assertGreater(values[40, 40], 0.9) def test_mask_brush_path_builds_one_coverage_after_release(self): import numpy as np selection_mask = self.edit_mask.rasterize_brush_path( (80, 40), ((10, 20), (70, 20)), 6, lambda path: path, ) full = selection_mask.full_values((80, 40)) self.assertTrue(np.all(full[20, 10:71] > 0.9)) self.assertLess(selection_mask.bounds[0], 10) 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)), ("ADD", (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) self.assertFalse(self.image_interaction.append_lasso_point(path, (0.0, 0.0), samples)) self.assertEqual(path, [(0.0, 0.0)]) self.assertTrue(self.image_interaction.append_lasso_point(path, (3.0, 0.0), samples)) self.assertTrue(self.image_interaction.append_lasso_point(path, (6.0, 0.2), samples)) self.assertEqual(len(path), 2) self.assertEqual(path[-1], (6.0, 0.2)) self.assertTrue(self.image_interaction.append_lasso_point(path, (6.0, 4.0), samples)) self.assertEqual(len(path), 3)