feat(selection): separate selection geometry from image content processing

- Introduced a new design for selection rasterization that decouples the selection geometry from the image content, ensuring that the SelectionMask is independent of the source image's alpha and color.
- Updated the `rasterize_selection_path` function to only accept selection paths and image dimensions, removing parameters related to source alpha and alpha thresholds.
- Implemented a new approach for handling selection paths using non-zero winding fill, ensuring overlapping areas are preserved.
- Enhanced performance by utilizing a scanline algorithm for rasterization, significantly reducing processing time for high-resolution images.
- Updated internal documentation and added comprehensive tests to validate the new behavior and performance improvements.
- Ensured backward compatibility by maintaining existing interfaces where necessary while introducing breaking changes in the selection processing logic.
This commit is contained in:
2026-09-01 22:36:20 +08:00
parent 2ff7b04f56
commit 7f6e47432e
54 changed files with 2831 additions and 634 deletions

View File

@@ -6,7 +6,7 @@ import threading
import unittest
from pathlib import Path
from types import ModuleType, SimpleNamespace
from unittest.mock import patch
from unittest.mock import Mock, patch
PROJECT_ROOT = Path(__file__).resolve().parents[1]
SOURCE_ROOT = PROJECT_ROOT / "src"
@@ -90,6 +90,9 @@ class BlenderAddonTest(unittest.TestCase):
cls.convert_to_plane = importlib.import_module(
"anyimage.operators.convert_to_plane.conversion"
)
cls.convert_to_plane_object = importlib.import_module(
"anyimage.operators.convert_to_plane.object"
)
cls.cutout_main = importlib.import_module(
"anyimage.operators.cutout_tool.main"
)
@@ -851,6 +854,19 @@ class BlenderAddonTest(unittest.TestCase):
self.assertNotIn("draw", operator.__dict__)
self.assertIn("REGISTER", operator.bl_options)
self.assertIn("UNDO", operator.bl_options)
self.assertEqual(operator.__annotations__["mesh_detail"]["default"], 0)
self.assertEqual(
self.convert_to_plane.ConvertToDepthPlane.__annotations__["mesh_detail"][
"default"
],
6,
)
self.assertEqual(
self.convert_to_plane.GenerateDepthPlane.__annotations__["mesh_detail"][
"default"
],
6,
)
image_empty = SimpleNamespace(
type="EMPTY",
@@ -923,6 +939,128 @@ class BlenderAddonTest(unittest.TestCase):
self.assertNotIn("shape", parameters)
self.assertNotIn("polygon", parameters)
def test_depth_plane_result_connects_the_tangent_normal_material(self):
module = self.convert_to_plane_object
source = SimpleNamespace(data=SimpleNamespace(name="Source"))
color_image = SimpleNamespace(name="")
depth_image = SimpleNamespace(users=0)
normal_image = SimpleNamespace(users=0)
material = SimpleNamespace(users=0)
metadata = {"reference_depth": 1.0}
files = {
"depth_metadata": Path("depth.json"),
"z_depth": Path("z-depth.exr"),
"tangent_normal": Path("tangent-normal.png"),
}
result = SimpleNamespace(
directory=Path("result"),
file=lambda key: files[key],
)
operator = SimpleNamespace(source_object_name="Source", mesh_detail=6)
context = SimpleNamespace(scene=object())
ops = SimpleNamespace(
ed=SimpleNamespace(undo_push=Mock(return_value={"FINISHED"}))
)
with (
patch.object(module, "require_image_empty", return_value=source),
patch.object(module, "load_color_result_image", return_value=color_image),
patch.object(module, "load_depth_metadata", return_value=metadata),
patch.object(module, "load_depth_result_image", return_value=depth_image),
patch.object(
module,
"load_normal_result_image",
return_value=normal_image,
) as load_normal,
patch.object(
module,
"create_image_material",
return_value=material,
) as create_material,
patch.object(module, "create_depth_plane_object") as create_object,
patch.object(module.bpy, "ops", ops, create=True),
):
module.create_depth_plane_from_result(context, result, operator)
load_normal.assert_called_once_with(files["tangent_normal"], source)
create_material.assert_called_once_with(
source,
color_image,
0.0,
normal_image=normal_image,
normal_space="TANGENT",
ior=1.2,
scene=context.scene,
)
create_object.assert_called_once_with(
context,
source,
6,
material,
depth_image,
metadata,
)
def test_depth_plane_result_cleans_unused_depth_and_normal_on_failure(self):
module = self.convert_to_plane_object
source = SimpleNamespace(data=SimpleNamespace(name="Source"))
color_image = SimpleNamespace(name="")
depth_image = SimpleNamespace(users=0)
normal_image = SimpleNamespace(users=0)
material = SimpleNamespace(users=0)
files = {
"depth_metadata": Path("depth.json"),
"z_depth": Path("z-depth.exr"),
"tangent_normal": Path("tangent-normal.png"),
}
result = SimpleNamespace(
directory=Path("result"),
file=lambda key: files[key],
)
remove_material = Mock()
remove_image = Mock()
data = SimpleNamespace(
materials=SimpleNamespace(remove=remove_material),
images=SimpleNamespace(remove=remove_image),
)
with (
patch.object(module, "require_image_empty", return_value=source),
patch.object(module, "load_color_result_image", return_value=color_image),
patch.object(
module,
"load_depth_metadata",
return_value={"reference_depth": 1.0},
),
patch.object(module, "load_depth_result_image", return_value=depth_image),
patch.object(module, "load_normal_result_image", return_value=normal_image),
patch.object(module, "create_image_material", return_value=material),
patch.object(
module,
"create_depth_plane_object",
side_effect=RuntimeError("failed"),
),
patch.object(module.bpy, "data", data),
self.assertRaisesRegex(RuntimeError, "failed"),
):
module.create_depth_plane_from_result(
SimpleNamespace(scene=object()),
result,
SimpleNamespace(source_object_name="Source", mesh_detail=6),
)
remove_material.assert_called_once_with(material, do_unlink=True)
self.assertEqual(
[entry.args[0] for entry in remove_image.call_args_list],
[depth_image, normal_image],
)
self.assertTrue(
all(
entry.kwargs == {"do_unlink": True}
for entry in remove_image.call_args_list
)
)
def test_surface_and_balloon_without_ai_build_locally(self):
import numpy as np
@@ -932,7 +1070,7 @@ class BlenderAddonTest(unittest.TestCase):
context = SimpleNamespace(
view_layer=SimpleNamespace(objects=SimpleNamespace(active=None))
)
selection_mask = self.cutout_main.SelectionMask(
selection_mask = self.image_selection.SelectionMask(
np.ones((8, 8), dtype=np.float32), (1, 1, 9, 9)
)
source_rgba = np.ones((10, 10, 4), dtype=np.float32)
@@ -941,7 +1079,10 @@ class BlenderAddonTest(unittest.TestCase):
operator = SimpleNamespace(
source_object_name="Source",
selection_path_json=json.dumps(
{"points": [[1, 1], [9, 1], [9, 9], [1, 9]]}
{
"points": [[1, 1], [9, 1], [9, 9], [1, 9]],
"invert": False,
}
),
shape=shape,
mesh_resolution=64,
@@ -961,12 +1102,12 @@ class BlenderAddonTest(unittest.TestCase):
),
patch.object(
self.cutout_main, "image_rgba", return_value=source_rgba
),
) as read_rgba,
patch.object(
self.cutout_main,
"rasterize_selection_path",
return_value=selection_mask,
),
) as rasterize,
patch.object(
self.cutout_main,
"configured_max_ai_input_size",
@@ -977,6 +1118,16 @@ class BlenderAddonTest(unittest.TestCase):
"configured_max_cutout_mesh_resolution",
return_value=1024,
),
patch.object(
self.cutout_main,
"_cutout_content_values",
return_value=selection_mask.values,
),
patch.object(
self.cutout_main,
"create_cropped_color_image",
return_value="Color",
),
patch.object(
self.cutout_main, "create_cutout_shape"
) as convert,
@@ -986,15 +1137,164 @@ class BlenderAddonTest(unittest.TestCase):
)
self.assertEqual(result, {"FINISHED"})
read_rgba.assert_called_once_with(source.data)
rasterize.assert_called_once()
convert.assert_called_once_with(
context,
source,
shape,
64,
selection_mask,
source_rgba=source_rgba,
selection_mask.values,
selection_mask.bounds,
color_image="Color",
)
def test_ai_cutout_writes_the_selection_bounds_image(self):
import numpy as np
source = SimpleNamespace(
data=SimpleNamespace(size=(4096, 2048)),
select_set=lambda _value: None,
)
context = SimpleNamespace(
view_layer=SimpleNamespace(objects=SimpleNamespace(active=None))
)
source_rgba = np.ones((4, 8, 4), dtype=np.float32)
selection_mask = self.image_selection.SelectionMask(
np.ones((2, 6), dtype=np.float32),
(1, 1, 7, 3),
)
operator = self.cutout_main.CutoutSelectionToShape()
operator.source_object_name = "Source"
operator.selection_path_json = json.dumps(
{
"points": [[1, 1], [7, 1], [7, 3], [1, 3]],
"invert": False,
}
)
operator.shape = "DEPTH_SURFACE"
operator.mesh_resolution = 64
operator.generate_normal = False
operator.refine_selection = False
operator.report = lambda *_args: None
with (
patch.object(
self.cutout_main,
"require_image_empty",
return_value=source,
),
patch.object(self.cutout_main, "video_total_frames", return_value=1),
patch.object(
self.cutout_main,
"image_rgba",
return_value=source_rgba,
) as read_rgba,
patch.object(
self.cutout_main,
"rasterize_selection_path",
return_value=selection_mask,
) as rasterize,
patch.object(
self.cutout_main,
"configured_max_ai_input_size",
return_value=2048,
),
patch.object(
self.cutout_main,
"configured_max_cutout_mesh_resolution",
return_value=1024,
),
patch.object(
self.cutout_main,
"invoke_ai_setup_if_needed",
return_value=None,
),
patch.object(
self.cutout_main,
"cutout_geometry_settings",
return_value=("MODEL", 5),
),
patch.object(
self.cutout_main,
"prepare_image_bounds_input",
return_value=Path(__file__),
) as prepare_input,
patch.object(
self.cutout_main.JobOperatorBase,
"execute",
return_value={"FINISHED"},
),
):
result = operator.execute(context)
self.assertEqual(result, {"FINISHED"})
read_rgba.assert_called_once_with(source.data)
rasterize.assert_called_once()
prepare_input.assert_called_once_with(
source.data,
source_rgba,
selection_mask.bounds,
)
def test_empty_cutout_selection_is_rejected_after_shape_choice(self):
source = SimpleNamespace(
data=SimpleNamespace(size=(4096, 2048)),
select_set=lambda _value: None,
)
context = SimpleNamespace(
view_layer=SimpleNamespace(objects=SimpleNamespace(active=None))
)
reports = []
operator = SimpleNamespace(
source_object_name="Source",
selection_path_json=json.dumps(
{
"points": [[1, 1], [7, 1], [7, 3], [1, 3]],
"invert": False,
}
),
shape="SURFACE",
mesh_resolution=64,
generate_normal=False,
refine_selection=False,
report=lambda level, message: reports.append((level, message)),
)
with (
patch.object(
self.cutout_main,
"require_image_empty",
return_value=source,
),
patch.object(self.cutout_main, "video_total_frames", return_value=1),
patch.object(self.cutout_main, "image_rgba"),
patch.object(
self.cutout_main,
"rasterize_selection_path",
side_effect=self.image_selection.ImageEditWarning(
"The selection contains no visible image pixels"
),
),
patch.object(self.cutout_main, "create_cutout_shape") as create_shape,
):
result = self.cutout_main.CutoutSelectionToShape.execute(
operator,
context,
)
self.assertEqual(result, {"CANCELLED"})
create_shape.assert_not_called()
self.assertEqual(
reports,
[
(
{"WARNING"},
"The selection contains no visible image pixels",
)
],
)
def test_cutout_request_describes_ai_capabilities(self):
operator = self.cutout_main.CutoutSelectionToShape()
operator.input_path = __file__
@@ -1343,34 +1643,27 @@ class BlenderAddonTest(unittest.TestCase):
(1, 1),
)
even_odd = self.image_selection.rasterize_selection_path(
selection = self.image_selection.rasterize_selection_path(
(12, 9),
self.image_selection.SelectionPath(points=path, fill_rule="EVEN_ODD"),
).full_values((12, 9))
nonzero = self.image_selection.rasterize_selection_path(
(12, 9),
self.image_selection.SelectionPath(points=path, fill_rule="NONZERO"),
self.image_selection.SelectionPath(points=path),
).full_values((12, 9))
self.assertFalse(even_odd[3, 5])
self.assertTrue(nonzero[3, 5])
self.assertTrue(selection[3, 5])
def preview_area(fill_rule):
triangles = self.image_interaction.scanline_fill_triangles(path, fill_rule)
return sum(
abs(
self.image_interaction._triangle_cross(
triangles[index],
triangles[index + 1],
triangles[index + 2],
)
triangles = self.image_interaction.scanline_fill_triangles(path)
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)
)
* 0.5
for index in range(0, len(triangles), 3)
)
self.assertAlmostEqual(preview_area("EVEN_ODD"), 36.0)
self.assertAlmostEqual(preview_area("NONZERO"), 54.0)
self.assertAlmostEqual(preview_area, 54.0)
def test_expected_empty_image_edits_are_reported_as_warnings(self):
reports = []
@@ -1405,6 +1698,7 @@ class BlenderAddonTest(unittest.TestCase):
self.assertIn(operator, self.anyimage.CLASSES)
self.assertIn(self.edit_mask.RefineImageSelection, self.anyimage.CLASSES)
self.assertIn(self.edit_mask.ActivateImageTool, self.anyimage.CLASSES)
self.assertNotIn("selection_path_json", operator.__annotations__)
tool_cases = (
(self.image_box.ImageBoxTool, "BOX", "ops.sculpt.border_mask"),
(self.image_lasso.ImageLassoTool, "LASSO", "ops.sculpt.lasso_mask"),
@@ -1438,6 +1732,14 @@ class BlenderAddonTest(unittest.TestCase):
"anyimage.operators.cutout_tool"
).CutoutTool
cutout_module = sys.modules[cutout_tool.__module__]
self.assertNotIn(
"selection_path_json",
cutout_module.SelectCutoutSelection.__annotations__,
)
self.assertIn(
"selection_path_json",
cutout_module.CutoutSelectionToShape.__annotations__,
)
drawn_settings.clear()
with (
patch.object(
@@ -1894,7 +2196,7 @@ class BlenderAddonTest(unittest.TestCase):
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_triangles(polygon, "NONZERO")
triangles = self.image_interaction.preview_fill_triangles(polygon)
area = sum(
abs(
self.image_interaction._triangle_cross(
@@ -2010,19 +2312,20 @@ class BlenderAddonTest(unittest.TestCase):
(),
)
def test_image_box_completion_serializes_the_image_selection(self):
def test_image_box_completion_submits_the_image_selection_path(self):
source = SimpleNamespace(data=SimpleNamespace(size=(64, 32)))
finished = []
submitted = []
operator = SimpleNamespace(
source_object_name="Source",
source_matrix_data="matrix",
gesture="BOX",
selection_path_json="",
invert=False,
preview_fill_rule="EVEN_ODD",
_screen_path=lambda: ((1, 1), (8, 1), (8, 6), (1, 6)),
_finish=lambda context: finished.append(context),
execute=lambda _context: {"FINISHED"},
_submit_selection_path=lambda context, selection_path: (
submitted.append((context, selection_path)) or {"FINISHED"}
),
)
context = SimpleNamespace(region=object(), region_data=object())
polygon = ((1, 2), (3, 4), (5, 6))
@@ -2042,12 +2345,296 @@ class BlenderAddonTest(unittest.TestCase):
result = self.image_interaction.ImageSelectionGesture._complete(operator, context)
self.assertEqual(result, {"FINISHED"})
self.assertEqual(
json.loads(operator.selection_path_json)["points"],
[[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]],
)
self.assertEqual(submitted[0][0], context)
self.assertEqual(submitted[0][1].points, polygon)
self.assertEqual(finished, [context])
def test_local_image_edit_builds_one_selection(self):
import numpy as np
source = SimpleNamespace(data=SimpleNamespace(size=(65536, 32768)))
source_rgba = np.ones((6, 8, 4), dtype=np.float32)
selection_path = self.image_selection.SelectionPath(
points=((1, 1), (7, 1), (7, 5), (1, 5))
)
selection_mask = self.image_selection.SelectionMask(
np.ones((4, 6), dtype=np.float32),
(1, 1, 7, 5),
)
operator = SimpleNamespace(
source_object_name="Source",
refine_selection=False,
report=lambda *_args: None,
)
with (
patch.object(
self.edit_mask,
"require_image_empty",
return_value=source,
),
patch.object(self.edit_mask, "video_total_frames", return_value=1),
patch.object(
self.edit_mask,
"image_rgba",
return_value=source_rgba,
) as read_rgba,
patch.object(
self.edit_mask,
"rasterize_selection_path",
return_value=selection_mask,
) as rasterize,
patch.object(self.edit_mask, "_edit_image_object") as edit,
):
result = self.edit_mask.EditImageMask._submit_selection_path(
operator,
SimpleNamespace(),
selection_path,
)
self.assertEqual(result, {"FINISHED"})
read_rgba.assert_called_once_with(source.data)
rasterize.assert_called_once()
edit.assert_called_once_with(source, source_rgba, selection_mask)
def test_image_edit_applies_the_local_selection_mask(self):
import numpy as np
source_rgba = np.ones((6, 8, 4), dtype=np.float32)
source_rgba[:, :, 0] = np.arange(8, dtype=np.float32)[None, :]
values = np.ones((4, 6), dtype=np.float32)
values[0, :] = 0.0
values[:, 0] = 0.0
selection_mask = self.image_selection.SelectionMask(
values,
(1, 1, 7, 5),
)
pixels, bounds = self.edit_mask._edit_image_mask_pixels(
source_rgba,
selection_mask,
)
self.assertEqual(bounds, (2, 2, 7, 5))
rgba = np.flipud(pixels.reshape((3, 5, 4)))
self.assertTrue(np.all(rgba[:, :, 3] == 1.0))
self.assertTrue(np.all(rgba[:, :, 0] == np.arange(2, 7)[None, :]))
def test_refined_image_edit_writes_the_selection_bounds_image(self):
import numpy as np
source = SimpleNamespace(data=SimpleNamespace(size=(8, 6)))
source_rgba = np.ones((6, 8, 4), dtype=np.float32)
selection_path = self.image_selection.SelectionPath(
points=((1, 1), (7, 1), (7, 5), (1, 5))
)
selection_mask = self.image_selection.SelectionMask(
np.ones((4, 6), dtype=np.float32),
(1, 1, 7, 5),
)
operator = SimpleNamespace(
source_object_name="Source",
refine_selection=True,
report=lambda *_args: None,
)
refine = Mock(return_value={"FINISHED"})
ops = SimpleNamespace(
anyimage=SimpleNamespace(refine_image_selection=refine)
)
with (
patch.object(
self.edit_mask,
"require_image_empty",
return_value=source,
),
patch.object(self.edit_mask, "video_total_frames", return_value=1),
patch.object(
self.edit_mask,
"image_rgba",
return_value=source_rgba,
) as read_rgba,
patch.object(
self.edit_mask,
"rasterize_selection_path",
return_value=selection_mask,
) as rasterize,
patch.object(
self.edit_mask,
"prepare_image_bounds_input",
return_value=Path(__file__),
) as prepare_input,
patch.object(self.edit_mask.bpy, "ops", ops, create=True),
):
result = self.edit_mask.EditImageMask._submit_selection_path(
operator,
SimpleNamespace(),
selection_path,
)
self.assertEqual(result, {"FINISHED"})
read_rgba.assert_called_once_with(source.data)
rasterize.assert_called_once()
prepare_input.assert_called_once_with(
source.data,
source_rgba,
selection_mask.bounds,
)
refine.assert_called_once()
def test_refined_image_response_uses_the_encoded_result_directly(self):
source_image = SimpleNamespace(
name="Poster.png",
colorspace_settings=SimpleNamespace(name="sRGB"),
alpha_mode="STRAIGHT",
)
source = SimpleNamespace(data=source_image)
refined = SimpleNamespace(users=0)
files = {
"selection_bounds": SimpleNamespace(
read_text=lambda encoding: "[2,1,5,4]"
),
"selection_image": Path(__file__),
}
result = SimpleNamespace(file=lambda key: files[key])
operator = self.edit_mask.RefineImageSelection()
operator.source_object_name = "Source"
operator.selection_bounds_json = "[10,20,18,26]"
ops = SimpleNamespace(
ed=SimpleNamespace(undo_push=Mock(return_value={"FINISHED"}))
)
with (
patch.object(
self.edit_mask,
"require_image_empty",
return_value=source,
),
patch.object(
self.edit_mask,
"load_packed_rgba_image",
return_value=refined,
) as load_result,
patch.object(self.edit_mask, "replace_empty_image") as replace,
patch.object(self.edit_mask, "image_rgba") as read_rgba,
patch.object(
self.edit_mask,
"create_pixel_result_image",
) as create_pixels,
patch.object(self.edit_mask.bpy, "ops", ops, create=True),
):
message = operator.response(None, result)
self.assertEqual(message, "Image Selection refined")
load_result.assert_called_once_with(
Path(__file__),
source_image,
"Poster",
)
replace.assert_called_once_with(
source,
refined,
placement_bounds=(12, 21, 15, 24),
)
read_rgba.assert_not_called()
create_pixels.assert_not_called()
def test_cutout_ai_response_reuses_color_image_and_keeps_lasso_for_no_refine(self):
import numpy as np
source = SimpleNamespace(data=SimpleNamespace())
color_image = SimpleNamespace(users=0)
pure_values = np.ones((3, 4), dtype=np.float32)
pure_values[:, 0] = 0.0
selection_mask = self.image_selection.SelectionMask(
pure_values,
(10, 20, 14, 23),
)
operator = self.cutout_main.CutoutSelectionToShape()
operator.source_object_name = "Source"
operator.selection_bounds_json = "[10,20,14,23]"
operator.input_path = __file__
operator.shape = "SURFACE"
operator.mesh_resolution = 32
operator.generate_normal = True
operator.refine_selection = False
operator._selection_mask = selection_mask
result = SimpleNamespace(value={}, file=lambda _key: Path(__file__))
with (
patch.object(
self.cutout_main,
"require_image_empty",
return_value=source,
),
patch.object(
self.cutout_main,
"_generated_color_result",
return_value=(
color_image,
np.ones((3, 4), dtype=np.float32),
selection_mask.bounds,
),
),
patch.object(self.cutout_main, "create_cutout_shape") as create_shape,
):
message = operator.response(SimpleNamespace(), result)
self.assertEqual(message, "Surface is ready")
content_values = create_shape.call_args.args[4]
np.testing.assert_array_equal(content_values, pure_values)
self.assertEqual(create_shape.call_args.args[5], selection_mask.bounds)
self.assertIs(create_shape.call_args.kwargs["color_image"], color_image)
def test_cutout_refine_keeps_ben2_content_outside_the_lasso(self):
import numpy as np
source = SimpleNamespace(data=SimpleNamespace())
color_image = SimpleNamespace(users=0)
operator = self.cutout_main.CutoutSelectionToShape()
operator.source_object_name = "Source"
operator.selection_bounds_json = "[10,20,16,24]"
operator.input_path = __file__
operator.shape = "SURFACE"
operator.mesh_resolution = 32
operator.generate_normal = False
operator.refine_selection = True
operator._selection_mask = self.image_selection.SelectionMask(
np.zeros((4, 6), dtype=np.float32),
(10, 20, 16, 24),
)
result = SimpleNamespace(
value={"selection_image": "selection.png"},
file=lambda _key: Path(__file__),
)
with (
patch.object(
self.cutout_main,
"require_image_empty",
return_value=source,
),
patch.object(
self.cutout_main,
"_generated_color_result",
return_value=(
color_image,
np.ones((2, 3), dtype=np.float32),
(12, 21, 15, 23),
),
),
patch.object(self.cutout_main, "create_cutout_shape") as create_shape,
):
operator.response(SimpleNamespace(), result)
content_values = create_shape.call_args.args[4]
np.testing.assert_array_equal(
content_values,
np.ones((2, 3), dtype=np.float32),
)
self.assertEqual(create_shape.call_args.args[5], (12, 21, 15, 23))
self.assertIs(create_shape.call_args.kwargs["color_image"], color_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
@@ -2066,7 +2653,6 @@ class BlenderAddonTest(unittest.TestCase):
_path=[polygon[0]],
_area_pointer=1,
_region_pointer=2,
preview_fill_rule="NONZERO",
_fill_polygon=None,
_fill_triangles=(),
_outline_polygon=None,
@@ -2091,7 +2677,7 @@ class BlenderAddonTest(unittest.TestCase):
self.image_interaction.ImageSelectionGesture._draw_selection(operator)
self.image_interaction.ImageSelectionGesture._draw_selection(operator)
build_fill.assert_called_once_with(polygon, "NONZERO")
build_fill.assert_called_once_with(polygon)
build_outline.assert_called_once_with((*polygon, polygon[0]))
self.assertEqual(draw_fill.call_count, 2)
draw_fill.assert_called_with(