Add tests for clipboard image functionality and mesh plane asset creation
- Implemented a comprehensive test suite for the clipboard image module, covering various functionalities including operator registration, context handling, and image processing. - Created a new mesh plane asset with customizable parameters and integrated it into the geometry node system. - Developed a validation script to ensure the correct behavior of the mesh plane asset, including input handling and output verification.
This commit is contained in:
@@ -90,8 +90,12 @@ class AddonTest(unittest.TestCase):
|
||||
cls.depth_build = importlib.import_module(
|
||||
"anyimage.operators.depth_plane.build"
|
||||
)
|
||||
cls.mesh_plane = importlib.import_module(
|
||||
"anyimage.operators.mesh_plane"
|
||||
)
|
||||
cls.image_background = importlib.import_module("anyimage.operators.background")
|
||||
cls.image_mesh = importlib.import_module("anyimage.operators.surface")
|
||||
cls.image_lasso = importlib.import_module("anyimage.operators.image_lasso")
|
||||
cls.balloon = importlib.import_module("anyimage.operators.balloon.operator")
|
||||
cls.balloon_geometry = importlib.import_module(
|
||||
"anyimage.operators.balloon.geometry"
|
||||
@@ -514,65 +518,230 @@ class AddonTest(unittest.TestCase):
|
||||
{"models": ["MOGE2_VITS_NORMAL", "BEN2_BASE"]},
|
||||
)
|
||||
|
||||
def test_image_menu_offers_required_model_repair_when_missing(self):
|
||||
calls = []
|
||||
|
||||
class Layout:
|
||||
operator_context = ""
|
||||
enabled = True
|
||||
|
||||
def __init__(self, enabled=True):
|
||||
self.enabled = enabled
|
||||
|
||||
def row(self):
|
||||
return Layout(self.enabled)
|
||||
|
||||
def operator(self, identifier, **_options):
|
||||
calls.append((identifier, self.enabled))
|
||||
return SimpleNamespace()
|
||||
|
||||
def separator(self):
|
||||
calls.append(("separator", self.enabled))
|
||||
|
||||
menu = self.addon.AnyImageImageMenu()
|
||||
menu.layout = Layout()
|
||||
def test_ai_status_checks_environment_and_required_model_files(self):
|
||||
properties = self.addon.properties
|
||||
with (
|
||||
patch.object(self.addon.runtime, "environment_ready", return_value=True),
|
||||
patch.object(
|
||||
self.addon.runtime,
|
||||
"environment_ready",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"anyimage.properties.required_models_ready",
|
||||
return_value=False,
|
||||
properties,
|
||||
"model_catalog",
|
||||
return_value=(
|
||||
{"key": "MOGE2_VITS_NORMAL", "ready": True},
|
||||
{"key": "BEN2_BASE", "ready": False},
|
||||
),
|
||||
),
|
||||
):
|
||||
menu.draw(None)
|
||||
status = properties.ai_status()
|
||||
self.assertTrue(status["environment_ready"])
|
||||
self.assertEqual(status["missing_models"], ("BEN2_BASE",))
|
||||
self.assertFalse(status["ready"])
|
||||
self.assertEqual(properties.ai_setup_label(status), "Download Required Models…")
|
||||
|
||||
self.assertEqual(
|
||||
calls[0],
|
||||
("anyimage.activate_balloon_tool", True),
|
||||
with (
|
||||
patch.object(self.addon.runtime, "environment_ready", return_value=False),
|
||||
patch.object(
|
||||
properties,
|
||||
"model_catalog",
|
||||
return_value=tuple(
|
||||
{"key": key, "ready": True}
|
||||
for key in properties.REQUIRED_MODEL_KEYS
|
||||
),
|
||||
),
|
||||
):
|
||||
status = properties.ai_status()
|
||||
self.assertFalse(status["ready"])
|
||||
self.assertEqual(properties.ai_setup_label(status), "Set Up AI Environment…")
|
||||
|
||||
def test_setup_ai_environment_selects_the_needed_install_step(self):
|
||||
calls = []
|
||||
ops = SimpleNamespace(
|
||||
anyimage=SimpleNamespace(
|
||||
install_environment=lambda *_args: calls.append("official")
|
||||
or {"FINISHED"},
|
||||
install_environment_mirror=lambda *_args: calls.append("mirror")
|
||||
or {"FINISHED"},
|
||||
download_required_models=lambda *_args: calls.append("models")
|
||||
or {"FINISHED"},
|
||||
)
|
||||
)
|
||||
operator = self.model_operators.SetupAIEnvironment()
|
||||
with patch.object(self.fake_bpy, "ops", ops, create=True):
|
||||
operator.use_mirror = True
|
||||
with patch.object(
|
||||
self.model_operators,
|
||||
"ai_status",
|
||||
return_value={
|
||||
"environment_ready": False,
|
||||
"missing_models": ("MOGE2_VITS_NORMAL", "BEN2_BASE"),
|
||||
"ready": False,
|
||||
},
|
||||
):
|
||||
self.assertEqual(operator.execute(None), {"FINISHED"})
|
||||
|
||||
with patch.object(
|
||||
self.model_operators,
|
||||
"ai_status",
|
||||
return_value={
|
||||
"environment_ready": True,
|
||||
"missing_models": ("BEN2_BASE",),
|
||||
"ready": False,
|
||||
},
|
||||
):
|
||||
self.assertEqual(operator.execute(None), {"FINISHED"})
|
||||
|
||||
self.assertEqual(calls, ["mirror", "models"])
|
||||
|
||||
def test_ai_environment_ui_types_are_registered(self):
|
||||
self.assertIn(self.model_operators.SetupAIEnvironment, self.addon.CLASSES)
|
||||
self.assertIn(
|
||||
("anyimage.download_required_models", True),
|
||||
calls,
|
||||
self.model_operators.OpenAIEnvironmentSettings,
|
||||
self.addon.CLASSES,
|
||||
)
|
||||
self.assertIn(("anyimage.convert_to_depth_plane", False), calls)
|
||||
self.assertIn(("anyimage.convert_to_surface", False), calls)
|
||||
self.assertIn(("anyimage.remove_image_background", False), calls)
|
||||
panel = importlib.import_module("anyimage.panel")
|
||||
self.assertFalse(hasattr(panel, "ModelsPanel"))
|
||||
|
||||
def test_image_menu_keeps_disabled_features_when_environment_is_missing(self):
|
||||
def test_mesh_plane_operator_is_registered_with_material_options(self):
|
||||
operator = self.mesh_plane.ConvertToMeshPlane
|
||||
self.assertIn(operator, self.addon.CLASSES)
|
||||
self.assertEqual(operator.bl_idname, "anyimage.convert_to_mesh_plane")
|
||||
self.assertFalse(operator.__annotations__["shadeless"]["default"])
|
||||
self.assertFalse(operator.__annotations__["keep_original"]["default"])
|
||||
self.assertIn("invoke", operator.__dict__)
|
||||
self.assertNotIn("draw", operator.__dict__)
|
||||
self.assertIn("REGISTER", operator.bl_options)
|
||||
self.assertIn("UNDO", operator.bl_options)
|
||||
|
||||
image_empty = SimpleNamespace(
|
||||
type="EMPTY",
|
||||
empty_display_type="IMAGE",
|
||||
data=object(),
|
||||
)
|
||||
mesh_plane = SimpleNamespace(
|
||||
type="MESH",
|
||||
modifiers=[
|
||||
SimpleNamespace(node_group=SimpleNamespace(name="O Mesh Plane"))
|
||||
],
|
||||
)
|
||||
plain_mesh = SimpleNamespace(type="MESH", modifiers=[])
|
||||
self.assertTrue(operator.poll(SimpleNamespace(object=image_empty)))
|
||||
self.assertTrue(operator.poll(SimpleNamespace(object=mesh_plane)))
|
||||
self.assertFalse(operator.poll(SimpleNamespace(object=plain_mesh)))
|
||||
|
||||
def test_mesh_plane_redo_keeps_the_original_world_matrix(self):
|
||||
operator = self.mesh_plane.ConvertToMeshPlane()
|
||||
operator.shadeless = False
|
||||
operator.keep_original = True
|
||||
operator.source_object_name = ""
|
||||
original_matrix = (
|
||||
(1.0, 0.0, 0.0, 3.0),
|
||||
(0.0, 1.0, 0.0, -2.0),
|
||||
(0.0, 0.0, 1.0, 7.0),
|
||||
(0.0, 0.0, 0.0, 1.0),
|
||||
)
|
||||
source = SimpleNamespace(
|
||||
name="Source Image",
|
||||
type="EMPTY",
|
||||
empty_display_type="IMAGE",
|
||||
data=object(),
|
||||
matrix_world=original_matrix,
|
||||
)
|
||||
context = SimpleNamespace(object=source)
|
||||
matrices = []
|
||||
|
||||
def capture_build(*_args, **kwargs):
|
||||
matrices.append(kwargs["matrix_world"])
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
self.mesh_plane.bpy.data.objects,
|
||||
"get",
|
||||
return_value=source,
|
||||
),
|
||||
patch.object(
|
||||
self.mesh_plane,
|
||||
"restore_matrix",
|
||||
side_effect=lambda values: tuple(values),
|
||||
),
|
||||
patch.object(
|
||||
self.mesh_plane,
|
||||
"build_mesh_plane",
|
||||
side_effect=capture_build,
|
||||
),
|
||||
):
|
||||
self.assertEqual(operator.invoke(context, None), {"FINISHED"})
|
||||
source.matrix_world = (
|
||||
(1.0, 0.0, 0.0, 0.0),
|
||||
(0.0, 1.0, 0.0, 0.0),
|
||||
(0.0, 0.0, 1.0, 0.0),
|
||||
(0.0, 0.0, 0.0, 1.0),
|
||||
)
|
||||
self.assertEqual(operator.execute(context), {"FINISHED"})
|
||||
|
||||
expected = self.mesh_plane.flatten_matrix(original_matrix)
|
||||
self.assertEqual(matrices, [expected, expected])
|
||||
|
||||
def test_mesh_plane_uses_a_single_quad_and_geometry_nodes(self):
|
||||
import inspect
|
||||
|
||||
mesh_source = inspect.getsource(self.mesh_plane.create_mesh_plane_mesh)
|
||||
processing_source = inspect.getsource(
|
||||
self.mesh_plane.add_mesh_plane_processing
|
||||
)
|
||||
self.assertIn("((0, 1, 2, 3),)", mesh_source)
|
||||
self.assertIn('("Subdivide", max(0, int(subdivisions)))', processing_source)
|
||||
self.assertIn('("Thickness", max(0.0, float(thickness)))', processing_source)
|
||||
self.assertIn('("Material", material)', processing_source)
|
||||
|
||||
def test_image_menu_keeps_ai_features_clickable_and_offers_models(self):
|
||||
calls = []
|
||||
labels = {}
|
||||
|
||||
class Layout:
|
||||
operator_context = ""
|
||||
enabled = True
|
||||
|
||||
def __init__(self, enabled=True):
|
||||
self.enabled = enabled
|
||||
def operator(self, identifier, **options):
|
||||
calls.append((identifier, self.enabled))
|
||||
labels[identifier] = options.get("text")
|
||||
return SimpleNamespace()
|
||||
|
||||
def row(self):
|
||||
return Layout(self.enabled)
|
||||
def separator(self):
|
||||
calls.append(("separator", self.enabled))
|
||||
|
||||
menu = self.addon.AnyImageImageMenu()
|
||||
menu.layout = Layout()
|
||||
with patch(
|
||||
"anyimage.properties.ai_status",
|
||||
return_value={
|
||||
"environment_ready": True,
|
||||
"missing_models": ("BEN2_BASE",),
|
||||
"ready": False,
|
||||
},
|
||||
):
|
||||
menu.draw(None)
|
||||
|
||||
self.assertEqual(
|
||||
calls,
|
||||
[
|
||||
("anyimage.convert_to_mesh_plane", True),
|
||||
("anyimage.convert_to_depth_plane", True),
|
||||
("anyimage.convert_to_surface", True),
|
||||
("anyimage.remove_image_background", True),
|
||||
("separator", True),
|
||||
("anyimage.setup_ai_environment", True),
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
labels["anyimage.setup_ai_environment"],
|
||||
"Download Required Models…",
|
||||
)
|
||||
|
||||
def test_image_menu_opens_environment_settings_when_ai_is_ready(self):
|
||||
calls = []
|
||||
|
||||
class Layout:
|
||||
operator_context = ""
|
||||
enabled = True
|
||||
|
||||
def operator(self, identifier, **_options):
|
||||
calls.append((identifier, self.enabled))
|
||||
@@ -583,29 +752,18 @@ class AddonTest(unittest.TestCase):
|
||||
|
||||
menu = self.addon.AnyImageImageMenu()
|
||||
menu.layout = Layout()
|
||||
with (
|
||||
patch.object(
|
||||
self.addon.runtime,
|
||||
"environment_ready",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"anyimage.properties.required_models_ready",
|
||||
return_value=False,
|
||||
),
|
||||
with patch(
|
||||
"anyimage.properties.ai_status",
|
||||
return_value={
|
||||
"environment_ready": True,
|
||||
"missing_models": (),
|
||||
"ready": True,
|
||||
},
|
||||
):
|
||||
menu.draw(None)
|
||||
|
||||
self.assertEqual(
|
||||
calls[0],
|
||||
("anyimage.activate_balloon_tool", True),
|
||||
)
|
||||
self.assertIn(("anyimage.install_environment", True), calls)
|
||||
self.assertIn(("anyimage.install_environment_mirror", True), calls)
|
||||
self.assertNotIn(("anyimage.download_required_models", True), calls)
|
||||
self.assertIn(("anyimage.convert_to_depth_plane", False), calls)
|
||||
self.assertIn(("anyimage.convert_to_surface", False), calls)
|
||||
self.assertIn(("anyimage.remove_image_background", False), calls)
|
||||
self.assertIn(("anyimage.open_ai_environment_settings", True), calls)
|
||||
self.assertNotIn(("anyimage.setup_ai_environment", True), calls)
|
||||
|
||||
def test_balloon_contour_is_smooth_and_evenly_resampled(self):
|
||||
import numpy as np
|
||||
@@ -621,6 +779,157 @@ class AddonTest(unittest.TestCase):
|
||||
self.assertGreater(self.balloon_geometry.polygon_area(contour), 0.0)
|
||||
self.assertLess(float(lengths.std() / lengths.mean()), 0.15)
|
||||
|
||||
def test_image_lasso_clears_rgba_outside_the_selection(self):
|
||||
import numpy as np
|
||||
|
||||
top_down = np.full((3, 3, 4), (0.2, 0.4, 0.6, 1.0), dtype=np.float32)
|
||||
source = np.flipud(top_down).ravel()
|
||||
pixels, bounds = self.image_lasso.extract_lasso_pixels(
|
||||
source,
|
||||
(3, 3),
|
||||
((0, 0), (3, 0), (0, 3)),
|
||||
)
|
||||
result = np.flipud(np.asarray(pixels).reshape((3, 3, 4)))
|
||||
|
||||
self.assertEqual(bounds, (0, 0, 3, 3))
|
||||
np.testing.assert_allclose(result[2, 2], (0.0, 0.0, 0.0, 0.0))
|
||||
self.assertEqual(float(result[0, 0, 3]), 1.0)
|
||||
|
||||
def test_image_lasso_clears_hidden_rgb_from_source_transparency(self):
|
||||
import numpy as np
|
||||
|
||||
top_down = np.full((2, 2, 4), (0.2, 0.4, 0.6, 1.0), dtype=np.float32)
|
||||
top_down[0, 0, 3] = 0.0
|
||||
pixels, _bounds = self.image_lasso.extract_lasso_pixels(
|
||||
np.flipud(top_down).ravel(),
|
||||
(2, 2),
|
||||
((0, 0), (2, 0), (2, 2), (0, 2)),
|
||||
)
|
||||
result = np.flipud(np.asarray(pixels).reshape((2, 2, 4)))
|
||||
|
||||
np.testing.assert_allclose(result[0, 0], (0.0, 0.0, 0.0, 0.0))
|
||||
|
||||
def test_image_lasso_keeps_out_of_image_canvas_transparent(self):
|
||||
import numpy as np
|
||||
|
||||
top_down = np.ones((2, 2, 4), dtype=np.float32)
|
||||
pixels, bounds = self.image_lasso.extract_lasso_pixels(
|
||||
np.flipud(top_down).ravel(),
|
||||
(2, 2),
|
||||
((-1, -1), (2, -1), (2, 2), (-1, 2)),
|
||||
)
|
||||
result = np.flipud(np.asarray(pixels).reshape((3, 3, 4)))
|
||||
|
||||
self.assertEqual(bounds, (-1, -1, 2, 2))
|
||||
self.assertEqual(float(result[0, 0, 3]), 0.0)
|
||||
self.assertEqual(float(result[1, 1, 3]), 1.0)
|
||||
|
||||
def test_image_lasso_rejects_a_fully_out_of_image_region(self):
|
||||
import numpy as np
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "does not overlap"):
|
||||
self.image_lasso.extract_lasso_pixels(
|
||||
np.ones(2 * 2 * 4, dtype=np.float32),
|
||||
(2, 2),
|
||||
((3, 3), (4, 3), (4, 4), (3, 4)),
|
||||
)
|
||||
|
||||
def test_image_lasso_crops_to_visible_alpha_with_pixel_padding(self):
|
||||
import numpy as np
|
||||
|
||||
rgba = np.zeros((4, 5, 4), dtype=np.float32)
|
||||
rgba[1:3, 2:4] = (0.2, 0.4, 0.6, 1.0)
|
||||
pixels, bounds = self.image_lasso.crop_pixels_to_alpha(
|
||||
np.flipud(rgba).ravel(),
|
||||
(10, 20, 15, 24),
|
||||
padding=1,
|
||||
)
|
||||
result = np.flipud(np.asarray(pixels).reshape((4, 4, 4)))
|
||||
|
||||
self.assertEqual(bounds, (11, 20, 15, 24))
|
||||
self.assertEqual(result.shape, (4, 4, 4))
|
||||
self.assertTrue(np.all(result[[0, -1], :, 3] == 0.0))
|
||||
self.assertTrue(np.all(result[:, [0, -1], 3] == 0.0))
|
||||
self.assertTrue(np.all(result[1:3, 1:3, 3] == 1.0))
|
||||
|
||||
def test_image_lasso_tool_and_operators_are_registered(self):
|
||||
self.assertIn(self.image_lasso.ExtractImageLasso, self.addon.CLASSES)
|
||||
self.assertIn(self.image_lasso.AnalyzeImageLasso, self.addon.CLASSES)
|
||||
self.assertIn(self.image_lasso.ActivateImageLassoTool, self.addon.CLASSES)
|
||||
self.assertNotIn(self.image_lasso.ImageLassoTool, self.addon.CLASSES)
|
||||
self.assertNotIn(self.image_lasso.ImageBoxTool, self.addon.CLASSES)
|
||||
self.assertEqual(
|
||||
self.image_lasso.ImageLassoTool.bl_operator,
|
||||
self.image_lasso.ExtractImageLasso.bl_idname,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.image_lasso.ImageLassoTool.bl_icon,
|
||||
"ops.sculpt.lasso_mask",
|
||||
)
|
||||
self.assertEqual(
|
||||
self.image_lasso.ImageBoxTool.bl_icon,
|
||||
"ops.sculpt.border_mask",
|
||||
)
|
||||
self.assertEqual(
|
||||
self.image_lasso.ImageBoxTool.bl_keymap[0][2]["properties"],
|
||||
[("selection_shape", "BOX")],
|
||||
)
|
||||
self.assertEqual(
|
||||
self.image_lasso.ImageLassoTool.bl_keymap[0][2]["properties"],
|
||||
[("selection_shape", "LASSO")],
|
||||
)
|
||||
self.assertNotIn("bl_cursor", self.image_lasso.ImageBoxTool.__dict__)
|
||||
self.assertNotIn("bl_cursor", self.image_lasso.ImageLassoTool.__dict__)
|
||||
self.assertEqual(
|
||||
self.image_lasso.ExtractImageLasso.__annotations__["selection_shape"][
|
||||
"default"
|
||||
],
|
||||
"BOX",
|
||||
)
|
||||
operator = self.image_lasso.ExtractImageLasso
|
||||
self.assertIn("keep_original", operator.__annotations__)
|
||||
self.assertFalse(operator.__annotations__["keep_original"]["default"])
|
||||
self.assertIn("padding", operator.__annotations__)
|
||||
self.assertEqual(operator.__annotations__["padding"]["default"], 0)
|
||||
settings = self.addon.properties.AnyImageSettings.__annotations__
|
||||
self.assertFalse(settings["image_lasso_keep_original"]["default"])
|
||||
self.assertEqual(settings["image_lasso_padding"]["default"], 0)
|
||||
|
||||
def test_image_box_builds_a_rectangular_screen_path(self):
|
||||
self.assertEqual(
|
||||
self.image_lasso.box_screen_path(((2, 3), (8, 9))),
|
||||
((2, 3), (8, 3), (8, 9), (2, 9)),
|
||||
)
|
||||
self.assertEqual(
|
||||
self.image_lasso.box_screen_path(((2, 3), (3, 9))),
|
||||
(),
|
||||
)
|
||||
|
||||
def test_image_extract_selection_uses_white_dashed_segments(self):
|
||||
segments = self.image_lasso.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_image_extract_activation_defaults_to_box(self):
|
||||
import inspect
|
||||
|
||||
source = inspect.getsource(self.image_lasso.ActivateImageLassoTool.execute)
|
||||
self.assertIn("name=ImageBoxTool.bl_idname", source)
|
||||
|
||||
def test_keep_original_restores_the_source_empty_selection(self):
|
||||
import inspect
|
||||
|
||||
execute_source = inspect.getsource(self.image_lasso.ExtractImageLasso.execute)
|
||||
response_source = inspect.getsource(self.image_lasso.AnalyzeImageLasso.response)
|
||||
expected = "source_object if self.keep_original else target"
|
||||
self.assertIn(expected, execute_source)
|
||||
self.assertIn(expected, response_source)
|
||||
|
||||
def test_balloon_interior_points_have_uniform_spacing(self):
|
||||
import numpy as np
|
||||
|
||||
@@ -909,6 +1218,10 @@ class AddonTest(unittest.TestCase):
|
||||
response_source = inspect.getsource(self.balloon.AnalyzeBalloon.response)
|
||||
tool_source = inspect.getsource(self.balloon.BalloonTool.draw_settings)
|
||||
self.assertIn("bpy.ops.anyimage.analyze_balloon", execute_source)
|
||||
self.assertIn("ready = ai_ready()", invoke_source)
|
||||
self.assertIn("status = ai_status()", tool_source)
|
||||
self.assertIn('"anyimage.setup_ai_environment"', tool_source)
|
||||
self.assertIn("ai_setup_label(status)", tool_source)
|
||||
self.assertIn("prepare_image_input(", execute_source)
|
||||
self.assertIn(
|
||||
"settings.balloon_contour_analysis",
|
||||
@@ -1360,7 +1673,7 @@ class AddonTest(unittest.TestCase):
|
||||
self.assertIn(expected, original_depth_source)
|
||||
self.assertIn(expected, depth_source)
|
||||
|
||||
def test_image_operators_require_environment_and_required_models(self):
|
||||
def test_image_operators_stay_clickable_until_server_is_busy(self):
|
||||
context = SimpleNamespace(
|
||||
object=SimpleNamespace(data=SimpleNamespace()),
|
||||
)
|
||||
@@ -1370,31 +1683,13 @@ class AddonTest(unittest.TestCase):
|
||||
(self.image_background, self.image_background.RemoveImageBackground),
|
||||
)
|
||||
|
||||
states = (
|
||||
(False, True, False),
|
||||
(True, False, False),
|
||||
(True, True, True),
|
||||
)
|
||||
for environment_ready, models_ready, expected in states:
|
||||
for busy, expected in ((False, True), (True, False)):
|
||||
with (
|
||||
self.subTest(
|
||||
environment_ready=environment_ready,
|
||||
models_ready=models_ready,
|
||||
expected=expected,
|
||||
),
|
||||
patch.object(
|
||||
self.addon.runtime,
|
||||
"environment_ready",
|
||||
return_value=environment_ready,
|
||||
),
|
||||
self.subTest(busy=busy),
|
||||
patch.object(
|
||||
self.addon.runtime,
|
||||
"server_busy",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"anyimage.properties.required_models_ready",
|
||||
return_value=models_ready,
|
||||
return_value=busy,
|
||||
),
|
||||
):
|
||||
for module, operator in operators:
|
||||
@@ -1500,6 +1795,14 @@ class AddonTest(unittest.TestCase):
|
||||
self.assertEqual(attribute, "server")
|
||||
|
||||
def test_runtime_registers_system_operators_and_status_bar(self):
|
||||
self.assertIn(
|
||||
self.addon.clipboard_image.PasteClipboardImage,
|
||||
self.addon.CLASSES,
|
||||
)
|
||||
self.assertIn(
|
||||
self.addon.clipboard_image.TrackNativeCopy,
|
||||
self.addon.CLASSES,
|
||||
)
|
||||
self.addon.register()
|
||||
system_classes = self.addon.runtime.operator_classes()
|
||||
|
||||
@@ -1507,12 +1810,26 @@ class AddonTest(unittest.TestCase):
|
||||
self.assertEqual(
|
||||
self.registered_tools,
|
||||
[
|
||||
(
|
||||
self.image_lasso.ImageBoxTool,
|
||||
{
|
||||
"group": True,
|
||||
"separator": True,
|
||||
},
|
||||
),
|
||||
(
|
||||
self.image_lasso.ImageLassoTool,
|
||||
{
|
||||
"after": {self.image_lasso.ImageBoxTool.bl_idname},
|
||||
"separator": False,
|
||||
},
|
||||
),
|
||||
(
|
||||
self.balloon.BalloonTool,
|
||||
{
|
||||
"separator": False,
|
||||
},
|
||||
)
|
||||
),
|
||||
],
|
||||
)
|
||||
self.assertEqual(len(self.status_bar_draws), 1)
|
||||
|
||||
892
tests/test_clipboard_image.py
Normal file
892
tests/test_clipboard_image.py
Normal file
@@ -0,0 +1,892 @@
|
||||
import importlib
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import ModuleType, SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(PROJECT_ROOT / "src"))
|
||||
|
||||
|
||||
class ClipboardImageTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.temporary_directory = tempfile.TemporaryDirectory()
|
||||
cls.remove_anyimage_modules()
|
||||
fake_bpy = ModuleType("bpy")
|
||||
fake_bpy.app = SimpleNamespace(version=(5, 1, 0))
|
||||
fake_bpy.props = SimpleNamespace(
|
||||
BoolProperty=lambda **options: options,
|
||||
CollectionProperty=lambda **options: options,
|
||||
EnumProperty=lambda **options: options,
|
||||
FloatProperty=lambda **options: options,
|
||||
FloatVectorProperty=lambda **options: options,
|
||||
IntProperty=lambda **options: options,
|
||||
PointerProperty=lambda **options: options,
|
||||
StringProperty=lambda **options: options,
|
||||
)
|
||||
fake_bpy.types = SimpleNamespace(
|
||||
GizmoGroup=object,
|
||||
Image=object,
|
||||
Menu=object,
|
||||
Operator=object,
|
||||
Panel=object,
|
||||
PropertyGroup=object,
|
||||
Scene=object,
|
||||
TOPBAR_MT_file_defaults=SimpleNamespace(),
|
||||
)
|
||||
fake_bpy.utils = SimpleNamespace()
|
||||
fake_bpy.context = SimpleNamespace(
|
||||
window_manager=SimpleNamespace(
|
||||
keyconfigs=SimpleNamespace(addon=None),
|
||||
)
|
||||
)
|
||||
fake_bpy.data = SimpleNamespace()
|
||||
sys.modules["bpy"] = fake_bpy
|
||||
cls.fake_bpy = fake_bpy
|
||||
|
||||
cls.addon = ModuleType("anyimage")
|
||||
cls.addon.__path__ = [str(PROJECT_ROOT / "src" / "anyimage")]
|
||||
sys.modules["anyimage"] = cls.addon
|
||||
cls.addon.clipboard_image = importlib.import_module(
|
||||
"anyimage.clipboard_image"
|
||||
)
|
||||
cls.actions = cls.addon.clipboard_image.actions
|
||||
cls.clipboard = cls.addon.clipboard_image.clipboard
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
sys.modules.pop("bpy", None)
|
||||
cls.remove_anyimage_modules()
|
||||
cls.temporary_directory.cleanup()
|
||||
|
||||
def test_operators_use_anyimage_prefix_and_plain_properties(self):
|
||||
clipboard_image = self.addon.clipboard_image
|
||||
self.assertEqual(
|
||||
clipboard_image.PasteClipboardImage.bl_idname,
|
||||
"anyimage.paste_clipboard_image",
|
||||
)
|
||||
self.assertEqual(
|
||||
clipboard_image.TrackNativeCopy.bl_idname,
|
||||
"anyimage.track_native_copy",
|
||||
)
|
||||
for name in clipboard_image.PasteClipboardImage.__annotations__:
|
||||
self.assertFalse(name.startswith("anyimage_"), name)
|
||||
self.assertFalse(name.startswith("o_"), name)
|
||||
|
||||
def test_registers_and_removes_clipboard_shortcuts(self):
|
||||
class KeyMapItems:
|
||||
def __init__(self):
|
||||
self.items = []
|
||||
|
||||
def new(self, operator_id, event_type, value, **modifiers):
|
||||
item = SimpleNamespace(
|
||||
idname=operator_id,
|
||||
type=event_type,
|
||||
value=value,
|
||||
**modifiers,
|
||||
)
|
||||
self.items.append(item)
|
||||
return item
|
||||
|
||||
def remove(self, item):
|
||||
self.items.remove(item)
|
||||
|
||||
class KeyMaps:
|
||||
def __init__(self):
|
||||
self.items = []
|
||||
|
||||
def new(self, name, space_type):
|
||||
keymap = SimpleNamespace(
|
||||
name=name,
|
||||
space_type=space_type,
|
||||
keymap_items=KeyMapItems(),
|
||||
)
|
||||
self.items.append(keymap)
|
||||
return keymap
|
||||
|
||||
keymaps = KeyMaps()
|
||||
window_manager = SimpleNamespace(
|
||||
keyconfigs=SimpleNamespace(
|
||||
addon=SimpleNamespace(keymaps=keymaps),
|
||||
),
|
||||
)
|
||||
clipboard_image = self.addon.clipboard_image
|
||||
with patch.object(
|
||||
self.fake_bpy.context,
|
||||
"window_manager",
|
||||
window_manager,
|
||||
):
|
||||
clipboard_image.register_keymaps()
|
||||
items = [
|
||||
item
|
||||
for keymap in keymaps.items
|
||||
for item in keymap.keymap_items.items
|
||||
]
|
||||
self.assertEqual(
|
||||
[item.idname for item in items],
|
||||
[
|
||||
"anyimage.paste_clipboard_image",
|
||||
"anyimage.track_native_copy",
|
||||
"anyimage.paste_clipboard_image",
|
||||
"anyimage.track_native_copy",
|
||||
],
|
||||
)
|
||||
modifier = "oskey" if sys.platform == "darwin" else "ctrl"
|
||||
self.assertTrue(all(getattr(item, modifier) for item in items))
|
||||
clipboard_image.unregister_keymaps()
|
||||
|
||||
self.assertTrue(
|
||||
all(not keymap.keymap_items.items for keymap in keymaps.items)
|
||||
)
|
||||
|
||||
def test_routes_supported_contexts(self):
|
||||
contexts = (
|
||||
(self.make_context("VIEW_3D"), "PLANE"),
|
||||
(
|
||||
self.make_context("VIEW_3D", mode="SCULPT"),
|
||||
"TOOL_TEXTURE",
|
||||
),
|
||||
(
|
||||
self.make_context("VIEW_3D", mode="PAINT_VERTEX"),
|
||||
"TOOL_TEXTURE",
|
||||
),
|
||||
(
|
||||
self.make_context("VIEW_3D", mode="PAINT_TEXTURE"),
|
||||
"TOOL_TEXTURE",
|
||||
),
|
||||
(
|
||||
self.make_context(
|
||||
"NODE_EDITOR",
|
||||
tree_type="ShaderNodeTree",
|
||||
),
|
||||
"NODE",
|
||||
),
|
||||
(
|
||||
self.make_context(
|
||||
"NODE_EDITOR",
|
||||
tree_type="CompositorNodeTree",
|
||||
),
|
||||
"NODE",
|
||||
),
|
||||
(
|
||||
self.make_context(
|
||||
"NODE_EDITOR",
|
||||
tree_type="GeometryNodeTree",
|
||||
),
|
||||
"NODE",
|
||||
),
|
||||
)
|
||||
|
||||
for context, expected_target in contexts:
|
||||
with self.subTest(expected_target=expected_target):
|
||||
self.assertEqual(
|
||||
self.actions.target_for_context(context),
|
||||
expected_target,
|
||||
)
|
||||
|
||||
def test_rejects_unsupported_node_tree(self):
|
||||
context = self.make_context(
|
||||
"NODE_EDITOR",
|
||||
tree_type="TextureNodeTree",
|
||||
)
|
||||
|
||||
self.assertIsNone(self.actions.target_for_context(context))
|
||||
|
||||
def test_world_shader_uses_environment_texture(self):
|
||||
world_space = SimpleNamespace(
|
||||
tree_type="ShaderNodeTree",
|
||||
shader_type="WORLD",
|
||||
)
|
||||
material_space = SimpleNamespace(
|
||||
tree_type="ShaderNodeTree",
|
||||
shader_type="OBJECT",
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.actions._node_type_for_space(world_space),
|
||||
"ShaderNodeTexEnvironment",
|
||||
)
|
||||
self.assertEqual(
|
||||
self.actions._node_type_for_space(material_space),
|
||||
"ShaderNodeTexImage",
|
||||
)
|
||||
|
||||
def test_pinned_world_tree_uses_environment_texture(self):
|
||||
world_space = SimpleNamespace(
|
||||
tree_type="ShaderNodeTree",
|
||||
shader_type="OBJECT",
|
||||
id=SimpleNamespace(
|
||||
bl_rna=SimpleNamespace(identifier="World"),
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.actions._node_type_for_space(world_space),
|
||||
"ShaderNodeTexEnvironment",
|
||||
)
|
||||
|
||||
def test_node_location_matches_native_ui_scale_and_anchor_offset(self):
|
||||
view2d = SimpleNamespace(
|
||||
region_to_view=Mock(return_value=(200.0, 100.0)),
|
||||
)
|
||||
context = SimpleNamespace(
|
||||
region=SimpleNamespace(view2d=view2d),
|
||||
preferences=SimpleNamespace(
|
||||
system=SimpleNamespace(ui_scale=2.0),
|
||||
),
|
||||
space_data=SimpleNamespace(cursor_location=(1.0, 2.0)),
|
||||
)
|
||||
event = SimpleNamespace(mouse_region_x=80, mouse_region_y=60)
|
||||
|
||||
location = self.actions._node_location(context, event)
|
||||
|
||||
self.assertEqual(location, (85.0, 55.0))
|
||||
view2d.region_to_view.assert_called_once_with(80, 60)
|
||||
|
||||
def test_node_location_without_event_uses_node_cursor(self):
|
||||
context = SimpleNamespace(
|
||||
space_data=SimpleNamespace(cursor_location=(12.0, -8.0)),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.actions._node_location(context, None),
|
||||
(12.0, -8.0),
|
||||
)
|
||||
|
||||
def test_view3d_location_projects_mouse_at_cursor_depth(self):
|
||||
projected_location = (4.0, 5.0, 6.0)
|
||||
project = Mock(return_value=projected_location)
|
||||
bpy_extras = ModuleType("bpy_extras")
|
||||
view3d_utils = ModuleType("bpy_extras.view3d_utils")
|
||||
view3d_utils.region_2d_to_location_3d = project
|
||||
bpy_extras.view3d_utils = view3d_utils
|
||||
cursor_location = Mock()
|
||||
context = SimpleNamespace(
|
||||
scene=SimpleNamespace(
|
||||
cursor=SimpleNamespace(location=cursor_location),
|
||||
),
|
||||
region=object(),
|
||||
region_data=object(),
|
||||
)
|
||||
event = SimpleNamespace(mouse_region_x=120, mouse_region_y=75)
|
||||
|
||||
with patch.dict(
|
||||
sys.modules,
|
||||
{
|
||||
"bpy_extras": bpy_extras,
|
||||
"bpy_extras.view3d_utils": view3d_utils,
|
||||
},
|
||||
):
|
||||
result = self.actions._view3d_location(context, event)
|
||||
|
||||
self.assertIs(result, projected_location)
|
||||
project.assert_called_once_with(
|
||||
context.region,
|
||||
context.region_data,
|
||||
(120, 75),
|
||||
cursor_location,
|
||||
)
|
||||
|
||||
def test_view3d_location_without_event_copies_cursor_location(self):
|
||||
copied_location = object()
|
||||
cursor_location = SimpleNamespace(
|
||||
copy=Mock(return_value=copied_location),
|
||||
)
|
||||
context = SimpleNamespace(
|
||||
scene=SimpleNamespace(
|
||||
cursor=SimpleNamespace(location=cursor_location),
|
||||
),
|
||||
)
|
||||
|
||||
result = self.actions._view3d_location(context)
|
||||
|
||||
self.assertIs(result, copied_location)
|
||||
cursor_location.copy.assert_called_once_with()
|
||||
|
||||
def test_preserves_image_aspect_ratio_for_plane(self):
|
||||
self.assertEqual(
|
||||
self.actions._plane_half_dimensions(1920, 1080),
|
||||
(1.0, 0.5625),
|
||||
)
|
||||
self.assertEqual(
|
||||
self.actions._plane_half_dimensions(800, 1200),
|
||||
(2 / 3, 1.0),
|
||||
)
|
||||
|
||||
def test_plane_creation_delegates_to_mesh_plane_geometry_nodes(self):
|
||||
import inspect
|
||||
|
||||
source = inspect.getsource(self.actions.add_image_plane)
|
||||
self.assertIn("create_mesh_plane_object", source)
|
||||
self.assertNotIn("_plane_grid", source)
|
||||
self.assertNotIn("_block_grid", source)
|
||||
|
||||
def test_plane_import_defaults_to_reference_image(self):
|
||||
options = (
|
||||
self.addon.clipboard_image.PasteClipboardImage
|
||||
.__annotations__["import_as"]
|
||||
)
|
||||
|
||||
self.assertEqual(options["default"], "REFERENCE")
|
||||
self.assertEqual(
|
||||
[item[0] for item in options["items"]],
|
||||
["PLANE", "REFERENCE"],
|
||||
)
|
||||
|
||||
def test_plane_shadeless_is_disabled_by_default(self):
|
||||
options = (
|
||||
self.addon.clipboard_image.PasteClipboardImage
|
||||
.__annotations__["shadeless"]
|
||||
)
|
||||
|
||||
self.assertFalse(options["default"])
|
||||
annotations = self.addon.clipboard_image.PasteClipboardImage.__annotations__
|
||||
self.assertNotIn("subdivisions", annotations)
|
||||
self.assertNotIn("thickness", annotations)
|
||||
self.assertNotIn("unshaded", annotations)
|
||||
|
||||
def test_native_copy_takes_priority_while_clipboard_is_unchanged(self):
|
||||
clipboard_image = self.addon.clipboard_image
|
||||
original_token = clipboard_image._native_copy_token
|
||||
try:
|
||||
clipboard_image._native_copy_token = 41
|
||||
with patch.object(
|
||||
clipboard_image,
|
||||
"clipboard_change_token",
|
||||
return_value=41,
|
||||
):
|
||||
self.assertTrue(clipboard_image._should_defer_to_native_paste())
|
||||
finally:
|
||||
clipboard_image._native_copy_token = original_token
|
||||
|
||||
def test_new_system_clipboard_content_restores_image_paste(self):
|
||||
clipboard_image = self.addon.clipboard_image
|
||||
original_token = clipboard_image._native_copy_token
|
||||
try:
|
||||
clipboard_image._native_copy_token = 41
|
||||
with patch.object(
|
||||
clipboard_image,
|
||||
"clipboard_change_token",
|
||||
return_value=42,
|
||||
):
|
||||
self.assertFalse(clipboard_image._should_defer_to_native_paste())
|
||||
finally:
|
||||
clipboard_image._native_copy_token = original_token
|
||||
|
||||
def test_native_copy_tracker_passes_through(self):
|
||||
clipboard_image = self.addon.clipboard_image
|
||||
original_token = clipboard_image._native_copy_token
|
||||
try:
|
||||
with patch.object(
|
||||
clipboard_image,
|
||||
"clipboard_change_token",
|
||||
return_value=73,
|
||||
):
|
||||
result = clipboard_image.TrackNativeCopy().execute(None)
|
||||
self.assertEqual(result, {"PASS_THROUGH"})
|
||||
self.assertEqual(clipboard_image._native_copy_token, 73)
|
||||
finally:
|
||||
clipboard_image._native_copy_token = original_token
|
||||
|
||||
def test_shadeless_nodes_mix_emission_with_transparency(self):
|
||||
created_nodes = {}
|
||||
|
||||
def create_node(node_type):
|
||||
node = SimpleNamespace(location=None, inputs={}, outputs={})
|
||||
if node_type == "ShaderNodeBsdfTransparent":
|
||||
node.outputs["BSDF"] = object()
|
||||
elif node_type == "ShaderNodeEmission":
|
||||
node.inputs["Color"] = object()
|
||||
node.outputs["Emission"] = object()
|
||||
else:
|
||||
node.inputs = [object(), object(), object()]
|
||||
node.outputs["Shader"] = object()
|
||||
created_nodes[node_type] = node
|
||||
return node
|
||||
|
||||
nodes = SimpleNamespace(new=Mock(side_effect=create_node))
|
||||
links = SimpleNamespace(new=Mock())
|
||||
image_node = SimpleNamespace(
|
||||
outputs={"Color": object(), "Alpha": object()},
|
||||
)
|
||||
output_node = SimpleNamespace(inputs={"Surface": object()})
|
||||
|
||||
self.actions._create_shadeless_nodes(
|
||||
nodes,
|
||||
links,
|
||||
image_node,
|
||||
output_node,
|
||||
)
|
||||
|
||||
mix_node = created_nodes["ShaderNodeMixShader"]
|
||||
links.new.assert_any_call(image_node.outputs["Alpha"], mix_node.inputs[0])
|
||||
links.new.assert_any_call(
|
||||
created_nodes["ShaderNodeEmission"].outputs["Emission"],
|
||||
mix_node.inputs[2],
|
||||
)
|
||||
self.assertEqual(links.new.call_count, 5)
|
||||
|
||||
def test_reference_import_routes_to_image_empty(self):
|
||||
context = self.make_context("VIEW_3D")
|
||||
image = object()
|
||||
expected_reference = object()
|
||||
expected_location = (1.0, 2.0, 3.0)
|
||||
original_add_reference_image = self.actions.add_reference_image
|
||||
self.actions.add_reference_image = (
|
||||
lambda received_context, received_image, location=None: expected_reference
|
||||
if (
|
||||
received_context is context
|
||||
and received_image is image
|
||||
and location == expected_location
|
||||
)
|
||||
else None
|
||||
)
|
||||
try:
|
||||
result = self.actions.paste_image(
|
||||
context,
|
||||
image,
|
||||
location=expected_location,
|
||||
import_as="REFERENCE",
|
||||
)
|
||||
finally:
|
||||
self.actions.add_reference_image = original_add_reference_image
|
||||
|
||||
self.assertIs(result, expected_reference)
|
||||
|
||||
def test_only_import_as_and_shadeless_are_drawn_for_plane_paste(self):
|
||||
operator = self.addon.clipboard_image.PasteClipboardImage()
|
||||
drawn_properties = []
|
||||
operator.layout = SimpleNamespace(
|
||||
prop=lambda owner, name: drawn_properties.append((owner, name)),
|
||||
)
|
||||
|
||||
operator.paste_target = "PLANE"
|
||||
operator.import_as = "PLANE"
|
||||
operator.draw(None)
|
||||
operator.import_as = "REFERENCE"
|
||||
operator.draw(None)
|
||||
operator.paste_target = "NODE"
|
||||
operator.draw(None)
|
||||
|
||||
self.assertEqual(
|
||||
drawn_properties,
|
||||
[
|
||||
(operator, "import_as"),
|
||||
(operator, "shadeless"),
|
||||
(operator, "import_as"),
|
||||
],
|
||||
)
|
||||
|
||||
def test_redo_reuses_captured_node_location(self):
|
||||
operator = self.addon.clipboard_image.PasteClipboardImage()
|
||||
operator.paste_target = "NODE"
|
||||
operator.location = (10.0, 20.0, 0.0)
|
||||
operator.location_set = True
|
||||
operator.import_as = "REFERENCE"
|
||||
operator.shadeless = False
|
||||
operator.report = Mock()
|
||||
context = self.make_context(
|
||||
"NODE_EDITOR",
|
||||
tree_type="ShaderNodeTree",
|
||||
)
|
||||
image = SimpleNamespace(name="Clipboard Image")
|
||||
|
||||
clipboard_image = self.addon.clipboard_image
|
||||
with (
|
||||
patch.object(
|
||||
clipboard_image,
|
||||
"_should_defer_to_native_paste",
|
||||
return_value=False,
|
||||
),
|
||||
patch.object(
|
||||
clipboard_image,
|
||||
"read_clipboard_image",
|
||||
return_value=(b"image", ".png"),
|
||||
),
|
||||
patch.object(
|
||||
clipboard_image,
|
||||
"acquire_packed_image",
|
||||
return_value=(image, True),
|
||||
),
|
||||
patch.object(clipboard_image, "paste_image") as paste_image,
|
||||
):
|
||||
result = operator.execute(context)
|
||||
|
||||
self.assertEqual(result, {"FINISHED"})
|
||||
paste_image.assert_called_once_with(
|
||||
context,
|
||||
image,
|
||||
None,
|
||||
location=(10.0, 20.0),
|
||||
import_as="REFERENCE",
|
||||
shadeless=False,
|
||||
)
|
||||
|
||||
def test_image_plane_requires_object_mode(self):
|
||||
context = SimpleNamespace(mode="EDIT_MESH")
|
||||
image = SimpleNamespace(size=(100, 100))
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
self.actions.PasteTargetError,
|
||||
"Object Mode",
|
||||
):
|
||||
self.actions.add_image_plane(context, image)
|
||||
|
||||
def test_stencil_dimensions_preserve_scale_and_image_aspect(self):
|
||||
self.assertEqual(
|
||||
self.actions._stencil_dimensions(
|
||||
(1920, 1080),
|
||||
(400.0, 200.0),
|
||||
),
|
||||
(400.0, 225.0),
|
||||
)
|
||||
self.assertEqual(
|
||||
self.actions._stencil_dimensions(
|
||||
(800, 1200),
|
||||
(128.0, 256.0),
|
||||
),
|
||||
(256.0 * 2 / 3, 256.0),
|
||||
)
|
||||
|
||||
def test_finds_existing_packed_clipboard_image(self):
|
||||
matching_image = FakeImage(
|
||||
self.actions.CLIPBOARD_HASH_PROPERTY,
|
||||
"matching-hash",
|
||||
packed=True,
|
||||
)
|
||||
unpacked_image = FakeImage(
|
||||
self.actions.CLIPBOARD_HASH_PROPERTY,
|
||||
"unpacked-hash",
|
||||
packed=False,
|
||||
)
|
||||
original_data = self.actions.bpy.data
|
||||
self.actions.bpy.data = SimpleNamespace(
|
||||
images=[matching_image, unpacked_image],
|
||||
)
|
||||
try:
|
||||
self.assertIs(
|
||||
self.actions._find_packed_clipboard_image("matching-hash"),
|
||||
matching_image,
|
||||
)
|
||||
self.assertIsNone(
|
||||
self.actions._find_packed_clipboard_image("unpacked-hash")
|
||||
)
|
||||
finally:
|
||||
self.actions.bpy.data = original_data
|
||||
|
||||
def test_sets_and_reuses_stencil_brush_texture(self):
|
||||
textures = FakeTextures()
|
||||
original_data = self.actions.bpy.data
|
||||
self.actions.bpy.data = SimpleNamespace(textures=textures)
|
||||
brush = SimpleNamespace(
|
||||
is_editable=True,
|
||||
texture=None,
|
||||
texture_slot=SimpleNamespace(
|
||||
texture=None,
|
||||
map_mode="VIEW_PLANE",
|
||||
),
|
||||
stencil_dimension=(256.0, 256.0),
|
||||
update_tag=lambda: None,
|
||||
)
|
||||
context = SimpleNamespace(brush=brush)
|
||||
image = SimpleNamespace(name="Clipboard Image", size=(1920, 1080))
|
||||
try:
|
||||
first_texture = self.actions.set_tool_texture(context, image)
|
||||
second_texture = self.actions.set_tool_texture(context, image)
|
||||
finally:
|
||||
self.actions.bpy.data = original_data
|
||||
|
||||
self.assertIs(first_texture, second_texture)
|
||||
self.assertEqual(len(textures), 1)
|
||||
self.assertIs(brush.texture, first_texture)
|
||||
self.assertIs(brush.texture_slot.texture, first_texture)
|
||||
self.assertEqual(brush.texture_slot.map_mode, "STENCIL")
|
||||
self.assertEqual(brush.stencil_dimension, (256.0, 144.0))
|
||||
|
||||
def test_localizes_read_only_active_brush(self):
|
||||
local_brush = SimpleNamespace(is_editable=True, library=None)
|
||||
linked_brush = SimpleNamespace(
|
||||
is_editable=False,
|
||||
library=None,
|
||||
make_local=lambda: local_brush,
|
||||
)
|
||||
|
||||
self.assertIs(
|
||||
self.actions._ensure_editable_brush(
|
||||
SimpleNamespace(),
|
||||
linked_brush,
|
||||
),
|
||||
local_brush,
|
||||
)
|
||||
|
||||
def test_copies_and_activates_runtime_library_brush(self):
|
||||
local_brush = SimpleNamespace(
|
||||
name="Paint Hard Clipboard",
|
||||
is_editable=True,
|
||||
library=None,
|
||||
)
|
||||
library_brush = SimpleNamespace(
|
||||
name="Paint Hard",
|
||||
is_editable=True,
|
||||
library=object(),
|
||||
)
|
||||
paint_settings = SimpleNamespace(brush=library_brush)
|
||||
context = SimpleNamespace(
|
||||
mode="SCULPT",
|
||||
tool_settings=SimpleNamespace(sculpt=paint_settings),
|
||||
)
|
||||
|
||||
original_ops = getattr(self.actions.bpy, "ops", None)
|
||||
|
||||
def save_brush_as(**options):
|
||||
self.assertEqual(
|
||||
options["asset_library_reference"],
|
||||
"LOCAL",
|
||||
)
|
||||
self.assertEqual(options["name"], "Paint Hard Clipboard")
|
||||
paint_settings.brush = local_brush
|
||||
return {"FINISHED"}
|
||||
|
||||
self.actions.bpy.ops = SimpleNamespace(
|
||||
brush=SimpleNamespace(asset_save_as=save_brush_as),
|
||||
)
|
||||
try:
|
||||
result = self.actions._ensure_editable_brush(
|
||||
context,
|
||||
library_brush,
|
||||
)
|
||||
finally:
|
||||
if original_ops is None:
|
||||
del self.actions.bpy.ops
|
||||
else:
|
||||
self.actions.bpy.ops = original_ops
|
||||
|
||||
self.assertIs(result, local_brush)
|
||||
|
||||
def test_active_brush_prefers_current_mode_settings(self):
|
||||
correct_brush = object()
|
||||
context = SimpleNamespace(
|
||||
brush=object(),
|
||||
mode="SCULPT",
|
||||
tool_settings=SimpleNamespace(
|
||||
sculpt=SimpleNamespace(brush=correct_brush),
|
||||
),
|
||||
)
|
||||
|
||||
self.assertIs(self.actions._active_brush(context), correct_brush)
|
||||
|
||||
def test_vertex_and_texture_paint_use_unbiased_white(self):
|
||||
for mode in ("PAINT_VERTEX", "PAINT_TEXTURE"):
|
||||
with self.subTest(mode=mode):
|
||||
brush = SimpleNamespace(color=(0.2, 0.4, 0.8))
|
||||
unified_settings = SimpleNamespace(color=(0.8, 0.4, 0.2))
|
||||
context = SimpleNamespace(
|
||||
mode=mode,
|
||||
tool_settings=SimpleNamespace(
|
||||
**{
|
||||
self.actions.PAINT_SETTINGS_BY_MODE[mode][0]:
|
||||
SimpleNamespace(
|
||||
unified_paint_settings=unified_settings,
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
self.actions._set_unbiased_paint_color(context, brush)
|
||||
|
||||
self.assertEqual(brush.color, (1.0, 1.0, 1.0))
|
||||
self.assertEqual(
|
||||
unified_settings.color,
|
||||
(1.0, 1.0, 1.0),
|
||||
)
|
||||
|
||||
def test_sculpt_keeps_brush_color(self):
|
||||
brush = SimpleNamespace(color=(0.2, 0.4, 0.8))
|
||||
|
||||
self.actions._set_unbiased_paint_color(
|
||||
SimpleNamespace(mode="SCULPT"),
|
||||
brush,
|
||||
)
|
||||
|
||||
self.assertEqual(brush.color, (0.2, 0.4, 0.8))
|
||||
|
||||
def test_wraps_info_dib_as_bmp(self):
|
||||
dib_header = struct.pack(
|
||||
"<IiiHHIIiiII",
|
||||
40,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
24,
|
||||
0,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
dib_data = dib_header + b"\x00\x00\xff\x00"
|
||||
|
||||
bmp_data = self.clipboard.dib_to_bmp(dib_data)
|
||||
|
||||
signature, file_size, _, _, pixel_offset = struct.unpack_from(
|
||||
"<2sIHHI",
|
||||
bmp_data,
|
||||
)
|
||||
self.assertEqual(signature, b"BM")
|
||||
self.assertEqual(file_size, len(bmp_data))
|
||||
self.assertEqual(pixel_offset, 54)
|
||||
self.assertEqual(bmp_data[14:], dib_data)
|
||||
|
||||
def test_rejects_truncated_dib(self):
|
||||
with self.assertRaises(self.clipboard.ClipboardImageError):
|
||||
self.clipboard.dib_to_bmp(b"\x28\x00\x00\x00")
|
||||
|
||||
def test_reads_macos_clipboard_image_as_png(self):
|
||||
def export_image(_osascript, output_file):
|
||||
output_file.write_bytes(b"png image")
|
||||
|
||||
with patch.object(
|
||||
self.clipboard.shutil,
|
||||
"which",
|
||||
return_value="/usr/bin/osascript",
|
||||
), patch.object(
|
||||
self.clipboard,
|
||||
"_export_macos_clipboard_image",
|
||||
side_effect=export_image,
|
||||
):
|
||||
image_data, suffix = self.clipboard._read_macos_clipboard_image()
|
||||
|
||||
self.assertEqual(image_data, b"png image")
|
||||
self.assertEqual(suffix, ".png")
|
||||
|
||||
def test_macos_clipboard_without_image_is_unavailable(self):
|
||||
with patch.object(
|
||||
self.clipboard.shutil,
|
||||
"which",
|
||||
return_value="/usr/bin/osascript",
|
||||
), patch.object(
|
||||
self.clipboard,
|
||||
"_export_macos_clipboard_image",
|
||||
):
|
||||
with self.assertRaises(self.clipboard.ClipboardImageUnavailable):
|
||||
self.clipboard._read_macos_clipboard_image()
|
||||
|
||||
def test_linux_clipboard_prefers_wayland_png(self):
|
||||
def find_command(command_name):
|
||||
if command_name == "wl-paste":
|
||||
return "/usr/bin/wl-paste"
|
||||
return None
|
||||
|
||||
with patch.object(
|
||||
self.clipboard.shutil,
|
||||
"which",
|
||||
side_effect=find_command,
|
||||
), patch.object(
|
||||
self.clipboard,
|
||||
"_run_image_command",
|
||||
return_value=b"png image",
|
||||
) as run_command:
|
||||
image_data, suffix = self.clipboard._read_linux_clipboard_image()
|
||||
|
||||
self.assertEqual(image_data, b"png image")
|
||||
self.assertEqual(suffix, ".png")
|
||||
run_command.assert_called_once_with(
|
||||
[
|
||||
"/usr/bin/wl-paste",
|
||||
"--no-newline",
|
||||
"--type",
|
||||
"image/png",
|
||||
]
|
||||
)
|
||||
|
||||
def test_linux_clipboard_falls_back_to_xclip(self):
|
||||
def find_command(command_name):
|
||||
if command_name == "xclip":
|
||||
return "/usr/bin/xclip"
|
||||
return None
|
||||
|
||||
with patch.object(
|
||||
self.clipboard.shutil,
|
||||
"which",
|
||||
side_effect=find_command,
|
||||
), patch.object(
|
||||
self.clipboard,
|
||||
"_run_image_command",
|
||||
return_value=b"png image",
|
||||
) as run_command:
|
||||
image_data, suffix = self.clipboard._read_linux_clipboard_image()
|
||||
|
||||
self.assertEqual(image_data, b"png image")
|
||||
self.assertEqual(suffix, ".png")
|
||||
run_command.assert_called_once_with(
|
||||
[
|
||||
"/usr/bin/xclip",
|
||||
"-selection",
|
||||
"clipboard",
|
||||
"-t",
|
||||
"image/png",
|
||||
"-o",
|
||||
]
|
||||
)
|
||||
|
||||
def test_linux_clipboard_requires_supported_command(self):
|
||||
with patch.object(
|
||||
self.clipboard.shutil,
|
||||
"which",
|
||||
return_value=None,
|
||||
):
|
||||
with self.assertRaises(self.clipboard.ClipboardImageUnavailable):
|
||||
self.clipboard._read_linux_clipboard_image()
|
||||
|
||||
@staticmethod
|
||||
def make_context(area_type, mode="OBJECT", **space_options):
|
||||
return SimpleNamespace(
|
||||
area=SimpleNamespace(type=area_type),
|
||||
mode=mode,
|
||||
region=SimpleNamespace(type="WINDOW"),
|
||||
space_data=SimpleNamespace(**space_options),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def remove_anyimage_modules():
|
||||
for module_name in tuple(sys.modules):
|
||||
if module_name == "anyimage" or module_name.startswith("anyimage."):
|
||||
sys.modules.pop(module_name)
|
||||
|
||||
|
||||
class FakeImage:
|
||||
def __init__(self, property_name, image_hash, packed):
|
||||
self.properties = {property_name: image_hash}
|
||||
self.packed_file = object() if packed else None
|
||||
self.packed_files = ()
|
||||
|
||||
def get(self, property_name, default=None):
|
||||
return self.properties.get(property_name, default)
|
||||
|
||||
|
||||
class FakeTexture(dict):
|
||||
def __init__(self, name, texture_type):
|
||||
super().__init__()
|
||||
self.name = name
|
||||
self.type = texture_type
|
||||
self.image = None
|
||||
|
||||
|
||||
class FakeTextures(list):
|
||||
def new(self, name, type):
|
||||
texture = FakeTexture(name, type)
|
||||
self.append(texture)
|
||||
return texture
|
||||
|
||||
def remove(self, texture):
|
||||
super().remove(texture)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -60,10 +60,15 @@ class PackTest(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertIn("__init__.py", names)
|
||||
self.assertIn("clipboard_image/__init__.py", names)
|
||||
self.assertIn("clipboard_image/actions.py", names)
|
||||
self.assertIn("clipboard_image/clipboard.py", names)
|
||||
self.assertIn("blender_manifest.toml", names)
|
||||
self.assertIn("operators/depth_plane/operator.py", names)
|
||||
self.assertIn("operators/mesh_plane.py", names)
|
||||
self.assertIn("operators/image.py", names)
|
||||
self.assertIn("operators/balloon/operator.py", names)
|
||||
self.assertIn("operators/image_lasso.py", names)
|
||||
self.assertIn("operators/surface.py", names)
|
||||
self.assertIn(
|
||||
"assets/O_AnyImage.blend",
|
||||
@@ -74,6 +79,7 @@ class PackTest(unittest.TestCase):
|
||||
self.assertIn("server/geometry/grid_mesh.py", names)
|
||||
self.assertIn("server/geometry/surface.py", names)
|
||||
self.assertIn("server/jobs/background.py", names)
|
||||
self.assertIn("server/jobs/image_lasso.py", names)
|
||||
self.assertIn("server/models/ben2.py", names)
|
||||
self.assertIn("server/models/da3.py", names)
|
||||
self.assertIn("server/models/moge2.py", names)
|
||||
|
||||
@@ -220,6 +220,7 @@ class ServerTest(unittest.TestCase):
|
||||
"download-model",
|
||||
"download-required-models",
|
||||
"analyze-balloon",
|
||||
"analyze-image-lasso",
|
||||
"remove-background",
|
||||
"extract-frames",
|
||||
"generate-depth",
|
||||
@@ -400,6 +401,46 @@ class ServerTest(unittest.TestCase):
|
||||
self.assertEqual(tuple(result[0, 0]), (20, 40, 60, 64))
|
||||
self.assertEqual(tuple(result[3, 3]), (0, 0, 0, 0))
|
||||
|
||||
def test_image_lasso_canvas_clears_rgba_outside_the_selection(self):
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
image_lasso = sys.modules["server.jobs.image_lasso"]
|
||||
source_path = self.storage_root / "source.png"
|
||||
pixels = np.full((2, 2, 4), (20, 40, 60, 255), dtype=np.uint8)
|
||||
Image.fromarray(pixels, mode="RGBA").save(source_path)
|
||||
|
||||
canvas, _selection, bounds = image_lasso.create_lasso_canvas(
|
||||
source_path,
|
||||
((-1, -1), (2, -1), (-1, 2)),
|
||||
)
|
||||
result = np.asarray(canvas)
|
||||
|
||||
self.assertEqual(bounds, (-1, -1, 2, 2))
|
||||
self.assertEqual(result.shape, (3, 3, 4))
|
||||
self.assertEqual(tuple(result[2, 2]), (0, 0, 0, 0))
|
||||
self.assertEqual(tuple(result[1, 1]), (20, 40, 60, 255))
|
||||
|
||||
def test_image_lasso_crops_to_alpha_with_pixel_padding(self):
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
image_lasso = sys.modules["server.jobs.image_lasso"]
|
||||
pixels = np.zeros((4, 5, 4), dtype=np.uint8)
|
||||
pixels[1:3, 2:4] = (20, 40, 60, 255)
|
||||
result, bounds = image_lasso.crop_to_alpha(
|
||||
Image.fromarray(pixels, mode="RGBA"),
|
||||
(10, 20, 15, 24),
|
||||
padding=1,
|
||||
)
|
||||
result = np.asarray(result)
|
||||
|
||||
self.assertEqual(bounds, (11, 20, 15, 24))
|
||||
self.assertEqual(result.shape, (4, 4, 4))
|
||||
self.assertTrue(np.all(result[[0, -1], :, 3] == 0))
|
||||
self.assertTrue(np.all(result[:, [0, -1], 3] == 0))
|
||||
self.assertTrue(np.all(result[1:3, 1:3, 3] == 255))
|
||||
|
||||
def test_balloon_job_sends_the_masked_crop_to_ben2(self):
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
@@ -506,6 +506,9 @@ class WorkerTest(unittest.TestCase):
|
||||
def putalpha(self, _alpha):
|
||||
return None
|
||||
|
||||
def paste(self, *_args):
|
||||
return None
|
||||
|
||||
def save(self, path, **options):
|
||||
save_options.append(options)
|
||||
Path(path).touch()
|
||||
@@ -540,7 +543,13 @@ class WorkerTest(unittest.TestCase):
|
||||
inference_options.append((image, {})) or FakeImage()
|
||||
)
|
||||
fake_pil = ModuleType("PIL")
|
||||
fake_pil.Image = SimpleNamespace(open=lambda _path: FakeImage())
|
||||
fake_pil.Image = SimpleNamespace(
|
||||
open=lambda _path: FakeImage(),
|
||||
new=lambda *_args, **_kwargs: FakeImage(),
|
||||
)
|
||||
fake_pil.ImageChops = SimpleNamespace(
|
||||
multiply=lambda _source, _foreground: FakeImage()
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory, patch.dict(
|
||||
sys.modules,
|
||||
@@ -571,6 +580,42 @@ class WorkerTest(unittest.TestCase):
|
||||
)
|
||||
self.assertIn("Background removed in", context.status["message"])
|
||||
|
||||
def test_ben2_preserves_input_alpha_and_hides_transparent_rgb(self):
|
||||
from PIL import Image
|
||||
|
||||
onnx_ben2 = load_server_module("models.onnx_ben2")
|
||||
source_pixels = np.asarray(
|
||||
(((255, 0, 0, 0), (0, 255, 0, 128)),),
|
||||
dtype=np.uint8,
|
||||
)
|
||||
model_result = Image.new("RGBA", (2, 1), (10, 20, 30, 128))
|
||||
received = []
|
||||
|
||||
def infer(_model, image):
|
||||
received.append(np.asarray(image.convert("RGB"), dtype=np.uint8))
|
||||
return model_result
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
input_path = Path(directory) / "input.png"
|
||||
Image.fromarray(source_pixels, mode="RGBA").save(input_path)
|
||||
with (
|
||||
patch.object(onnx_ben2, "create_session", return_value=object()),
|
||||
patch.object(onnx_ben2, "infer", side_effect=infer),
|
||||
):
|
||||
result, _load_ms, _inference_ms = self.ben2.infer_foreground(
|
||||
input_path,
|
||||
"model",
|
||||
"cpu",
|
||||
preserve_input_alpha=True,
|
||||
)
|
||||
|
||||
inference_pixels = received[0]
|
||||
result_pixels = np.asarray(result.convert("RGBA"), dtype=np.uint8)
|
||||
self.assertEqual(tuple(inference_pixels[0, 0]), (0, 0, 0))
|
||||
self.assertEqual(tuple(inference_pixels[0, 1]), (0, 128, 0))
|
||||
self.assertEqual(tuple(result_pixels[0, 0]), (255, 0, 0, 0))
|
||||
self.assertEqual(tuple(result_pixels[0, 1]), (0, 255, 0, 64))
|
||||
|
||||
def test_ben2_background_removal_processes_each_frame(self):
|
||||
inference_options = []
|
||||
|
||||
@@ -593,6 +638,9 @@ class WorkerTest(unittest.TestCase):
|
||||
def putalpha(self, _alpha):
|
||||
return None
|
||||
|
||||
def paste(self, *_args):
|
||||
return None
|
||||
|
||||
def save(self, path, **options):
|
||||
Path(path).touch()
|
||||
|
||||
@@ -624,7 +672,13 @@ class WorkerTest(unittest.TestCase):
|
||||
inference_options.append((image, {})) or FakeImage()
|
||||
)
|
||||
fake_pil = ModuleType("PIL")
|
||||
fake_pil.Image = SimpleNamespace(open=lambda _path: FakeImage())
|
||||
fake_pil.Image = SimpleNamespace(
|
||||
open=lambda _path: FakeImage(),
|
||||
new=lambda *_args, **_kwargs: FakeImage(),
|
||||
)
|
||||
fake_pil.ImageChops = SimpleNamespace(
|
||||
multiply=lambda _source, _foreground: FakeImage()
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory, patch.dict(
|
||||
sys.modules,
|
||||
|
||||
Reference in New Issue
Block a user