feat: Introduce Balloon Lasso Tool for mesh extraction
- Added BalloonTool workspace tool to facilitate the extraction of balloon meshes in the 3D viewport. - Implemented ActivateBalloonTool operator to activate the Balloon Lasso Tool. - Refactored BalloonLasso operator into ExtractBalloon for better clarity and functionality. - Enhanced properties for balloon extraction, including mode, thickness, resolution, and material reuse options. - Updated SnapFormSettings to include new balloon-related properties. - Improved material and image handling for balloon extraction, ensuring compatibility and reusability. - Added tests to validate the registration and functionality of the new balloon tools and properties.
This commit is contained in:
@@ -27,6 +27,7 @@ class AddonTest(unittest.TestCase):
|
||||
BoolProperty=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,
|
||||
@@ -37,6 +38,7 @@ class AddonTest(unittest.TestCase):
|
||||
Operator=object,
|
||||
Panel=object,
|
||||
PropertyGroup=object,
|
||||
WorkSpaceTool=object,
|
||||
Scene=type("Scene", (), {}),
|
||||
STATUSBAR_HT_header=SimpleNamespace(
|
||||
append=append_status_bar,
|
||||
@@ -52,12 +54,19 @@ class AddonTest(unittest.TestCase):
|
||||
),
|
||||
)
|
||||
cls.registered = []
|
||||
cls.registered_tools = []
|
||||
cls.status_bar_draws = []
|
||||
cls.context_menu_draws = []
|
||||
cls.outliner_menu_draws = []
|
||||
fake_bpy.utils = SimpleNamespace(
|
||||
register_class=cls.registered.append,
|
||||
unregister_class=cls.registered.remove,
|
||||
register_tool=lambda tool, **options: cls.registered_tools.append(
|
||||
(tool, options)
|
||||
),
|
||||
unregister_tool=lambda tool: cls.registered_tools.remove(
|
||||
next(item for item in cls.registered_tools if item[0] is tool)
|
||||
),
|
||||
)
|
||||
fake_bpy.context = SimpleNamespace(
|
||||
preferences=SimpleNamespace(addons={}),
|
||||
@@ -82,6 +91,9 @@ class AddonTest(unittest.TestCase):
|
||||
cls.depth_operator = importlib.import_module(
|
||||
"snapform.operators.depth_plane.operator"
|
||||
)
|
||||
cls.depth_build = importlib.import_module(
|
||||
"snapform.operators.depth_plane.build"
|
||||
)
|
||||
cls.image_background = importlib.import_module(
|
||||
"snapform.operators.background"
|
||||
)
|
||||
@@ -125,6 +137,7 @@ class AddonTest(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
self.addon.runtime.close_active()
|
||||
self.registered.clear()
|
||||
self.registered_tools.clear()
|
||||
self.status_bar_draws.clear()
|
||||
self.context_menu_draws.clear()
|
||||
self.outliner_menu_draws.clear()
|
||||
@@ -520,7 +533,7 @@ class AddonTest(unittest.TestCase):
|
||||
|
||||
self.assertEqual(
|
||||
calls[0],
|
||||
("snapform.balloon_lasso", True),
|
||||
("snapform.activate_balloon_tool", True),
|
||||
)
|
||||
self.assertIn(
|
||||
("snapform.download_required_models", True),
|
||||
@@ -564,7 +577,7 @@ class AddonTest(unittest.TestCase):
|
||||
|
||||
self.assertEqual(
|
||||
calls[0],
|
||||
("snapform.balloon_lasso", True),
|
||||
("snapform.activate_balloon_tool", True),
|
||||
)
|
||||
self.assertIn(("snapform.install_environment", True), calls)
|
||||
self.assertIn(("snapform.install_environment_mirror", True), calls)
|
||||
@@ -779,79 +792,136 @@ class AddonTest(unittest.TestCase):
|
||||
self.assertTrue(edges)
|
||||
self.assertTrue(all(users == 2 for users in edges.values()))
|
||||
|
||||
def test_balloon_controller_and_extract_operator_are_registered(self):
|
||||
def test_balloon_tool_operators_are_registered(self):
|
||||
self.assertIn(self.balloon.ExtractBalloon, self.addon.CLASSES)
|
||||
self.assertIn(self.balloon.BalloonLasso, self.addon.CLASSES)
|
||||
self.assertIn(self.balloon.ActivateBalloonTool, self.addon.CLASSES)
|
||||
self.assertNotIn(self.balloon.BalloonTool, self.addon.CLASSES)
|
||||
self.assertLess(
|
||||
self.addon.CLASSES.index(self.balloon.ExtractBalloon),
|
||||
self.addon.CLASSES.index(self.balloon.BalloonLasso),
|
||||
self.addon.CLASSES.index(self.balloon.ActivateBalloonTool),
|
||||
)
|
||||
def test_balloon_redo_properties_use_mode_and_world_resolution(self):
|
||||
|
||||
def test_balloon_redo_properties_use_mode_and_meter_resolution(self):
|
||||
import inspect
|
||||
|
||||
spacing, inflation = self.balloon.balloon_geometry_parameters(2.0, 0.2)
|
||||
scene = SimpleNamespace(
|
||||
unit_settings=SimpleNamespace(scale_length=0.01),
|
||||
)
|
||||
|
||||
self.assertEqual(spacing, 0.2)
|
||||
self.assertEqual(inflation, 0.9)
|
||||
self.assertEqual(
|
||||
self.balloon.meters_to_world_distance(scene, 0.1),
|
||||
10.0,
|
||||
)
|
||||
operator = self.balloon.ExtractBalloon
|
||||
self.assertIn("REGISTER", operator.bl_options)
|
||||
self.assertEqual(operator.bl_options, {"REGISTER", "UNDO"})
|
||||
self.assertIn("mode", operator.__annotations__)
|
||||
self.assertIn("thickness", operator.__annotations__)
|
||||
self.assertIn("resolution", operator.__annotations__)
|
||||
self.assertIn("reuse_material", operator.__annotations__)
|
||||
self.assertIn("source_matrix_data", operator.__annotations__)
|
||||
resolution_source = inspect.getsource(self.balloon.resolution_property)
|
||||
thickness_source = inspect.getsource(self.balloon.thickness_property)
|
||||
self.assertIn('subtype="DISTANCE"', resolution_source)
|
||||
self.assertIn('unit="LENGTH"', resolution_source)
|
||||
settings = self.addon.properties.SnapFormSettings.__annotations__
|
||||
self.assertIn("balloon_mode", settings)
|
||||
self.assertIn("balloon_thickness", settings)
|
||||
self.assertIn("balloon_resolution", settings)
|
||||
self.assertIn("balloon_reuse_material", settings)
|
||||
self.assertIn("balloon_thickness_radial_zoom", settings)
|
||||
self.assertIn("balloon_resolution_radial_zoom", settings)
|
||||
self.assertEqual(
|
||||
settings["balloon_thickness_radial_zoom"]["default"],
|
||||
(100.0, 100.0),
|
||||
)
|
||||
self.assertEqual(
|
||||
settings["balloon_resolution_radial_zoom"]["default"],
|
||||
(1000.0, 1000.0),
|
||||
)
|
||||
resolution_source = inspect.getsource(
|
||||
self.addon.properties.balloon_resolution_property
|
||||
)
|
||||
thickness_source = inspect.getsource(
|
||||
self.addon.properties.balloon_thickness_property
|
||||
)
|
||||
self.assertIn('name="Resolution (m)"', resolution_source)
|
||||
self.assertNotIn("subtype=", resolution_source)
|
||||
self.assertNotIn("unit=", resolution_source)
|
||||
self.assertIn("soft_max=5.0", thickness_source)
|
||||
self.assertNotIn(" max=", thickness_source)
|
||||
|
||||
def test_balloon_lasso_passes_hud_and_navigation_events(self):
|
||||
regions = (
|
||||
SimpleNamespace(type="WINDOW", x=0, y=0, width=800, height=600),
|
||||
SimpleNamespace(type="HUD", x=20, y=20, width=240, height=180),
|
||||
)
|
||||
context = SimpleNamespace(area=SimpleNamespace(regions=regions))
|
||||
|
||||
self.assertTrue(
|
||||
self.balloon.event_over_interface_region(
|
||||
context,
|
||||
SimpleNamespace(mouse_x=40, mouse_y=40),
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
self.balloon.event_over_interface_region(
|
||||
context,
|
||||
SimpleNamespace(mouse_x=500, mouse_y=400),
|
||||
)
|
||||
)
|
||||
for event_type in (
|
||||
"MIDDLEMOUSE",
|
||||
"WHEELUPMOUSE",
|
||||
"TRACKPADPAN",
|
||||
"NDOF_MOTION",
|
||||
):
|
||||
self.assertIn(event_type, self.balloon.VIEW_NAVIGATION_EVENTS)
|
||||
self.assertEqual(self.balloon.BalloonLasso.bl_options, {"INTERNAL"})
|
||||
def test_balloon_lasso_dispatches_individual_redo_operator(self):
|
||||
def test_balloon_workspace_tool_uses_one_top_level_operator_per_stroke(self):
|
||||
import inspect
|
||||
|
||||
execute_source = inspect.getsource(self.balloon.ExtractBalloon.execute)
|
||||
invoke_source = inspect.getsource(self.balloon.BalloonLasso.invoke)
|
||||
modal_source = inspect.getsource(self.balloon.BalloonLasso.modal)
|
||||
invoke_source = inspect.getsource(self.balloon.ExtractBalloon.invoke)
|
||||
modal_source = inspect.getsource(self.balloon.ExtractBalloon.modal)
|
||||
|
||||
self.assertIn("json.loads(self.selection_data)", execute_source)
|
||||
self.assertIn("deserialize_matrix(self.source_matrix_data)", execute_source)
|
||||
self.assertIn("select_result=not self.keep_source_selected", execute_source)
|
||||
self.assertIn("extract_balloon_object(", execute_source)
|
||||
self.assertIn("select_result=False", execute_source)
|
||||
self.assertIn("reuse_material=self.reuse_material", execute_source)
|
||||
self.assertIn("meters_to_world_distance(", execute_source)
|
||||
self.assertIn("serialize_matrix(source_object.matrix_world)", invoke_source)
|
||||
self.assertIn("bpy.ops.snapform.extract_balloon(", modal_source)
|
||||
self.assertIn('"EXEC_DEFAULT"', modal_source)
|
||||
self.assertIn("keep_source_selected=True", modal_source)
|
||||
self.assertIn('event.type == "ESC"', modal_source)
|
||||
self.assertIn("settings.balloon_thickness", invoke_source)
|
||||
self.assertIn("settings.balloon_resolution", invoke_source)
|
||||
self.assertIn("settings.balloon_reuse_material", invoke_source)
|
||||
self.assertIn("return self.execute(context)", modal_source)
|
||||
self.assertIn('return {"RUNNING_MODAL"}', modal_source)
|
||||
self.assertIn('return {"PASS_THROUGH"}', modal_source)
|
||||
self.assertIn("event_over_interface_region(context, event)", modal_source)
|
||||
self.assertNotIn("extract_balloon_object(", modal_source)
|
||||
self.assertNotIn("bpy.ops.snapform.extract_balloon", modal_source)
|
||||
|
||||
tool = self.balloon.BalloonTool
|
||||
self.assertEqual(tool.bl_space_type, "VIEW_3D")
|
||||
self.assertEqual(tool.bl_context_mode, "OBJECT")
|
||||
self.assertEqual(tool.bl_operator, self.balloon.ExtractBalloon.bl_idname)
|
||||
self.assertEqual(tool.bl_icon, "ops.mesh.polybuild_hover")
|
||||
self.assertEqual(tool.bl_cursor, "PAINT_BRUSH")
|
||||
keymap_operators = tuple(item[0] for item in tool.bl_keymap)
|
||||
self.assertEqual(keymap_operators[0], self.balloon.ExtractBalloon.bl_idname)
|
||||
self.assertEqual(
|
||||
keymap_operators[1:],
|
||||
("wm.radial_control", "wm.radial_control"),
|
||||
)
|
||||
self.assertEqual(
|
||||
tool.bl_keymap[1][2]["properties"],
|
||||
[
|
||||
(
|
||||
"data_path_primary",
|
||||
"scene.snapform_settings.balloon_thickness",
|
||||
),
|
||||
(
|
||||
"zoom_path",
|
||||
"scene.snapform_settings.balloon_thickness_radial_zoom",
|
||||
),
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
tool.bl_keymap[2][2]["properties"],
|
||||
[
|
||||
(
|
||||
"data_path_primary",
|
||||
"scene.snapform_settings.balloon_resolution",
|
||||
),
|
||||
(
|
||||
"zoom_path",
|
||||
"scene.snapform_settings.balloon_resolution_radial_zoom",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
def test_balloon_draw_handlers_are_filtered_to_the_invoking_viewport(self):
|
||||
import inspect
|
||||
|
||||
source = inspect.getsource(self.balloon.drawing_in_region)
|
||||
operator_source = inspect.getsource(self.balloon.ExtractBalloon)
|
||||
|
||||
self.assertIn("area.as_pointer() == area_pointer", source)
|
||||
self.assertIn("region.as_pointer() == region_pointer", source)
|
||||
self.assertIn("drawing_in_region(", operator_source)
|
||||
self.assertNotIn("wireframe_opacity", operator_source)
|
||||
self.assertNotIn("shading.type", operator_source)
|
||||
|
||||
def test_balloon_matrix_serialization_keeps_all_components(self):
|
||||
import json
|
||||
|
||||
@@ -872,18 +942,81 @@ class AddonTest(unittest.TestCase):
|
||||
self.assertIn("selected_object = balloon if select_result else source_object", source)
|
||||
self.assertIn("balloon.matrix_world = matrix_world.copy()", source)
|
||||
self.assertIn("create_material(", source)
|
||||
self.assertIn("material_name = material_data_name(source_object)", source)
|
||||
self.assertIn(
|
||||
"reusable_balloon_material(material_name, color_name)",
|
||||
source,
|
||||
)
|
||||
self.assertIn("if reuse_material", source)
|
||||
self.assertIn("color_image = image.copy()", source)
|
||||
self.assertIn("color_image.name = color_data_name(source_object)", source)
|
||||
self.assertIn("balloon_name,\n color_image,\n 0.0,", source)
|
||||
self.assertIn("color_image.name = color_name", source)
|
||||
self.assertIn(
|
||||
"material_name,\n color_image,\n 0.0,",
|
||||
source,
|
||||
)
|
||||
self.assertIn(
|
||||
"surface_group_name=TEXTURE_MATERIAL_NODE_GROUP_NAME",
|
||||
source,
|
||||
)
|
||||
self.assertIn("owns_material", source)
|
||||
self.assertIn("owns_color_image", source)
|
||||
self.assertEqual(
|
||||
self.image_depth.TEXTURE_MATERIAL_NODE_GROUP_NAME,
|
||||
"O Texture Layer",
|
||||
)
|
||||
|
||||
def test_balloon_material_reuse_requires_matching_image_and_node_group(self):
|
||||
color_image = SimpleNamespace(name="cow.color")
|
||||
texture_node = SimpleNamespace(type="TEX_IMAGE", image=color_image)
|
||||
texture_layer = SimpleNamespace(
|
||||
type="GROUP",
|
||||
node_tree=SimpleNamespace(name="O Texture Layer"),
|
||||
)
|
||||
compatible = SimpleNamespace(
|
||||
name="cow.001",
|
||||
node_tree=SimpleNamespace(nodes=[texture_node, texture_layer]),
|
||||
)
|
||||
wrong_group = SimpleNamespace(
|
||||
name="cow",
|
||||
node_tree=SimpleNamespace(
|
||||
nodes=[
|
||||
texture_node,
|
||||
SimpleNamespace(
|
||||
type="GROUP",
|
||||
node_tree=SimpleNamespace(name="O DA3 Surface Layer"),
|
||||
),
|
||||
]
|
||||
),
|
||||
)
|
||||
self.fake_bpy.data = SimpleNamespace(
|
||||
objects=SimpleNamespace(get=lambda _name: None),
|
||||
materials=[wrong_group, compatible],
|
||||
images=[color_image],
|
||||
)
|
||||
|
||||
self.assertIs(
|
||||
self.balloon.reusable_balloon_material("cow", "cow.color"),
|
||||
compatible,
|
||||
)
|
||||
self.assertIs(
|
||||
self.balloon.reusable_color_image("cow.color"),
|
||||
color_image,
|
||||
)
|
||||
|
||||
def test_surface_and_depth_plane_material_names_use_image_data(self):
|
||||
import inspect
|
||||
|
||||
surface_source = inspect.getsource(self.image_mesh.build_image_mesh)
|
||||
original_depth_source = inspect.getsource(
|
||||
self.depth_build.build_image_depth_plane
|
||||
)
|
||||
depth_source = inspect.getsource(self.depth_build.build_depth_plane)
|
||||
expected = "material_data_name(source_object)"
|
||||
|
||||
self.assertIn(expected, surface_source)
|
||||
self.assertIn(expected, original_depth_source)
|
||||
self.assertIn(expected, depth_source)
|
||||
|
||||
def test_image_operators_require_environment_and_required_models(self):
|
||||
context = SimpleNamespace(
|
||||
object=SimpleNamespace(data=SimpleNamespace()),
|
||||
@@ -1017,11 +1150,23 @@ class AddonTest(unittest.TestCase):
|
||||
system_classes = self.addon.runtime.operator_classes()
|
||||
|
||||
self.assertTrue(set(system_classes) <= set(self.registered))
|
||||
self.assertEqual(
|
||||
self.registered_tools,
|
||||
[
|
||||
(
|
||||
self.balloon.BalloonTool,
|
||||
{
|
||||
"separator": False,
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
self.assertEqual(len(self.status_bar_draws), 1)
|
||||
self.assertEqual(self.status_bar_draws[0]._owner, "snapform")
|
||||
|
||||
self.addon.unregister()
|
||||
self.assertEqual(self.registered, [])
|
||||
self.assertEqual(self.registered_tools, [])
|
||||
self.assertEqual(self.status_bar_draws, [])
|
||||
|
||||
def test_status_bar_reads_runtime_state(self):
|
||||
|
||||
Reference in New Issue
Block a user