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:
2026-08-22 09:49:00 +08:00
parent af5603045c
commit 988332548e
49 changed files with 4419 additions and 291 deletions

View File

@@ -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)