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:
2026-08-20 11:33:19 +08:00
parent 13ef705226
commit 4e1231790b
18 changed files with 864 additions and 851 deletions

View File

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