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

@@ -2,6 +2,7 @@ import bpy
from .runtime import runtime
from .operators import CLASSES as OPERATOR_CLASSES
from .operators.balloon import BalloonTool
from .preferences import SnapFormPreferences
from .properties import SnapFormSettings
from .menu import SnapFormImageMenu, draw_image_context_menu
@@ -32,6 +33,10 @@ CLASSES = (
def register():
for class_type in CLASSES:
bpy.utils.register_class(class_type)
bpy.utils.register_tool(
BalloonTool,
separator=False,
)
bpy.types.Scene.snapform_settings = bpy.props.PointerProperty(
type=SnapFormSettings,
)
@@ -42,6 +47,7 @@ def register():
def unregister():
runtime.unregister()
bpy.utils.unregister_tool(BalloonTool)
bpy.types.VIEW3D_MT_object_context_menu.remove(draw_image_context_menu)
bpy.types.OUTLINER_MT_object.remove(draw_image_context_menu)
if hasattr(bpy.types.Scene, "snapform_settings"):

View File

@@ -14,8 +14,8 @@ class SnapFormImageMenu(bpy.types.Menu):
layout = self.layout
layout.operator_context = "INVOKE_DEFAULT"
layout.operator(
"snapform.balloon_lasso",
text="Balloon Mesh (Lasso)",
"snapform.activate_balloon_tool",
text="Balloon Lasso Tool",
icon="MOD_SKIN",
)
layout.separator()

View File

@@ -1,5 +1,8 @@
from .background import ProcessImageBackground, RemoveImageBackground
from .balloon import BalloonLasso, ExtractBalloon
from .balloon import (
ActivateBalloonTool,
ExtractBalloon,
)
from .debug import DebugBen2, DebugDa3, DebugMoge2
from .depth_plane import (
ConvertToDepthPlane,
@@ -27,7 +30,7 @@ CLASSES = (
ExtractOriginalFrames,
ClearModels,
ExtractBalloon,
BalloonLasso,
ActivateBalloonTool,
RemoveImageBackground,
ConvertToDepthPlane,
ConvertToSurface,

View File

@@ -1 +1,5 @@
from .operator import BalloonLasso, ExtractBalloon
from .operator import (
ActivateBalloonTool,
BalloonTool,
ExtractBalloon,
)

View File

@@ -4,7 +4,18 @@ import math
import bpy
from .geometry import INFLATION, POINT_SPACING, triangulate_balloon
from ..image import color_data_name, is_image_empty, plane_bounds
from ...properties import (
balloon_mode_property as mode_property,
balloon_resolution_property as resolution_property,
balloon_reuse_material_property as reuse_material_property,
balloon_thickness_property as thickness_property,
)
from ..image import (
color_data_name,
is_image_empty,
material_data_name,
plane_bounds,
)
from ..material import TEXTURE_MATERIAL_NODE_GROUP_NAME, create_material
from ..surface import assign_vertex_uv
@@ -99,6 +110,55 @@ def balloon_geometry_parameters(thickness, resolution):
return resolution, INFLATION * thickness
def data_name_matches(name, base_name):
if name == base_name:
return True
suffix = name.removeprefix(f"{base_name}.")
return suffix != name and suffix.isdigit()
def material_uses_balloon_texture(material, color_name):
node_tree = getattr(material, "node_tree", None)
if node_tree is None:
return False
has_texture_layer = any(
getattr(node, "type", None) == "GROUP"
and getattr(getattr(node, "node_tree", None), "name", None)
== TEXTURE_MATERIAL_NODE_GROUP_NAME
for node in node_tree.nodes
)
has_color_image = any(
getattr(node, "type", None) == "TEX_IMAGE"
and getattr(node, "image", None) is not None
and data_name_matches(node.image.name, color_name)
for node in node_tree.nodes
)
return has_texture_layer and has_color_image
def reusable_balloon_material(material_name, color_name):
return next(
(
material
for material in bpy.data.materials
if data_name_matches(material.name, material_name)
and material_uses_balloon_texture(material, color_name)
),
None,
)
def reusable_color_image(color_name):
return next(
(
image
for image in bpy.data.images
if data_name_matches(image.name, color_name)
),
None,
)
def extract_balloon_object(
context,
source_object,
@@ -108,6 +168,7 @@ def extract_balloon_object(
thickness=1.0,
resolution=POINT_SPACING,
select_result=True,
reuse_material=True,
):
image = source_object.data
image_size = tuple(image.size)
@@ -130,19 +191,37 @@ def extract_balloon_object(
source_name = source_object.name
balloon_name = source_name
color_image = image.copy()
color_image.name = color_data_name(source_object)
material = None
material_name = material_data_name(source_object)
color_name = color_data_name(source_object)
color_image = None
material = (
reusable_balloon_material(material_name, color_name)
if reuse_material
else None
)
owns_color_image = False
owns_material = False
mesh = None
balloon = None
try:
material = create_material(
balloon_name,
color_image,
0.0,
surface_group_name=TEXTURE_MATERIAL_NODE_GROUP_NAME,
scene=context.scene,
)
if material is None:
color_image = (
reusable_color_image(color_name)
if reuse_material
else None
)
if color_image is None:
color_image = image.copy()
color_image.name = color_name
owns_color_image = True
material = create_material(
material_name,
color_image,
0.0,
surface_group_name=TEXTURE_MATERIAL_NODE_GROUP_NAME,
scene=context.scene,
)
owns_material = True
mesh = bpy.data.meshes.new(balloon_name)
mesh.from_pydata(vertices, [], faces)
mesh.update()
@@ -164,9 +243,13 @@ def extract_balloon_object(
bpy.data.objects.remove(balloon, do_unlink=True)
if mesh is not None and mesh.users == 0:
bpy.data.meshes.remove(mesh)
if material is not None and material.users == 0:
if owns_material and material is not None and material.users == 0:
bpy.data.materials.remove(material)
if color_image.users == 0:
if (
owns_color_image
and color_image is not None
and color_image.users == 0
):
bpy.data.images.remove(color_image)
raise
@@ -177,97 +260,44 @@ def extract_balloon_object(
return balloon
def thickness_property():
return bpy.props.FloatProperty(
name="Thickness",
description="Scale the Balloon thickness while keeping the lasso region",
default=1.0,
min=0.05,
soft_max=5.0,
)
def mode_property():
return bpy.props.EnumProperty(
name="Mode",
description="Algorithm used to inflate the saved lasso region",
items=(
(
"POISSON",
"Poisson",
"Use a global Poisson field for smooth inflation",
),
(
"TEDDY",
"Teddy / Chordal Axis",
"Use a Teddy-style chordal axis and local oval sections",
),
),
default="POISSON",
)
def resolution_property():
return bpy.props.FloatProperty(
name="Resolution",
description=(
"World-space distance between sampled mesh points; smaller "
"values create more geometry"
),
default=POINT_SPACING,
min=0.005,
soft_min=0.02,
soft_max=0.5,
max=10.0,
subtype="DISTANCE",
unit="LENGTH",
)
def hidden_string_property():
return bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
def hidden_bool_property(default=False):
return bpy.props.BoolProperty(
default=default,
options={"HIDDEN", "SKIP_SAVE"},
def meters_to_world_distance(scene, meters):
unit_settings = getattr(scene, "unit_settings", None)
scale_length = getattr(unit_settings, "scale_length", 1.0)
if scale_length <= 0.0:
scale_length = 1.0
return meters / scale_length
def draw_line_strip(vertices, color, width=2.0):
import gpu
from gpu_extras.batch import batch_for_shader
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
batch = batch_for_shader(shader, "LINE_STRIP", {"pos": vertices})
gpu.state.blend_set("ALPHA")
gpu.state.line_width_set(width)
shader.bind()
shader.uniform_float("color", color)
batch.draw(shader)
gpu.state.line_width_set(1.0)
gpu.state.blend_set("NONE")
def drawing_in_region(area_pointer, region_pointer):
area = getattr(bpy.context, "area", None)
region = getattr(bpy.context, "region", None)
return (
area is not None
and region is not None
and area.as_pointer() == area_pointer
and region.as_pointer() == region_pointer
)
def event_over_interface_region(context, event):
area = getattr(context, "area", None)
if area is None:
return False
mouse_x = getattr(event, "mouse_x", -1)
mouse_y = getattr(event, "mouse_y", -1)
for region in area.regions:
if region.type == "WINDOW":
continue
if (
region.x <= mouse_x < region.x + region.width
and region.y <= mouse_y < region.y + region.height
):
return True
return False
VIEW_NAVIGATION_EVENTS = {
"MIDDLEMOUSE",
"WHEELUPMOUSE",
"WHEELDOWNMOUSE",
"WHEELINMOUSE",
"WHEELOUTMOUSE",
"WHEELLEFTMOUSE",
"WHEELRIGHTMOUSE",
"TRACKPADPAN",
"TRACKPADZOOM",
"MOUSEROTATE",
"MOUSESMARTZOOM",
"NDOF_MOTION",
}
class ExtractBalloon(bpy.types.Operator):
bl_idname = "snapform.extract_balloon"
bl_label = "Extract Balloon"
@@ -278,148 +308,94 @@ class ExtractBalloon(bpy.types.Operator):
thickness: thickness_property()
resolution: resolution_property()
source_object_name: hidden_string_property()
reuse_material: reuse_material_property()
source_matrix_data: hidden_string_property()
selection_data: hidden_string_property()
keep_source_selected: hidden_bool_property()
_path = None
_handle = None
_area_pointer = 0
_region_pointer = 0
def draw(self, context):
layout = self.layout
layout.prop(self, "mode")
layout.prop(self, "thickness")
layout.prop(self, "resolution")
def execute(self, context):
source_object = bpy.data.objects.get(self.source_object_name)
if not is_image_empty(source_object):
self.report({"ERROR"}, "The source Image Empty is no longer available")
return {"CANCELLED"}
try:
polygon = json.loads(self.selection_data)
if len(polygon) < 3:
raise ValueError("The stored lasso region is invalid")
matrix_world = deserialize_matrix(self.source_matrix_data)
extract_balloon_object(
context,
source_object,
polygon,
matrix_world,
self.mode,
thickness=self.thickness,
resolution=self.resolution,
select_result=not self.keep_source_selected,
)
except (json.JSONDecodeError, RuntimeError, TypeError, ValueError) as error:
self.report({"ERROR"}, str(error))
return {"CANCELLED"}
return {"FINISHED"}
class BalloonLasso(bpy.types.Operator):
bl_idname = "snapform.balloon_lasso"
bl_label = "Balloon Mesh"
bl_description = "Continuously draw lasso regions and extract Balloon meshes"
bl_options = {"INTERNAL"}
mode: mode_property()
thickness: thickness_property()
resolution: resolution_property()
source_object_name: hidden_string_property()
source_matrix_data: hidden_string_property()
_path = None
_drawing = False
_handle = None
layout.prop(self, "resolution", text="Resolution (m)")
layout.prop(self, "reuse_material")
@classmethod
def poll(cls, context):
return context.area is not None and context.area.type == "VIEW_3D"
return (
context.area is not None
and context.area.type == "VIEW_3D"
and is_image_empty(context.active_object)
)
def _draw_lasso(self, context):
if not self._path:
def _draw_lasso(self):
if (
not self._path
or not drawing_in_region(self._area_pointer, self._region_pointer)
):
return
import gpu
from gpu_extras.batch import batch_for_shader
vertices = list(self._path)
if len(vertices) > 2:
vertices.append(vertices[0])
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
batch = batch_for_shader(shader, "LINE_STRIP", {"pos": vertices})
gpu.state.blend_set("ALPHA")
gpu.state.line_width_set(2.0)
shader.bind()
shader.uniform_float("color", (0.15, 0.65, 1.0, 0.95))
batch.draw(shader)
gpu.state.line_width_set(1.0)
gpu.state.blend_set("NONE")
draw_line_strip(vertices, (0.15, 0.65, 1.0, 0.95), 2.0)
def _finish(self, context):
if self._handle is not None:
bpy.types.SpaceView3D.draw_handler_remove(self._handle, "WINDOW")
self._handle = None
context.workspace.status_text_set(None)
if context.area is not None:
context.area.header_text_set(None)
context.area.tag_redraw()
def cancel(self, context):
self._finish(context)
def invoke(self, context, event):
source_object = context.active_object
if not is_image_empty(source_object):
self.report({"ERROR"}, "Select an Image Empty before extracting")
return {"CANCELLED"}
settings = context.scene.snapform_settings
self.mode = settings.balloon_mode
self.thickness = settings.balloon_thickness
self.resolution = settings.balloon_resolution
self.reuse_material = settings.balloon_reuse_material
self.source_object_name = source_object.name
self.source_matrix_data = serialize_matrix(source_object.matrix_world)
self._path = []
self._drawing = False
self.selection_data = ""
self._path = [(event.mouse_region_x, event.mouse_region_y)]
self._area_pointer = context.area.as_pointer()
self._region_pointer = context.region.as_pointer()
self._handle = bpy.types.SpaceView3D.draw_handler_add(
self._draw_lasso,
(context,),
(),
"WINDOW",
"POST_PIXEL",
)
context.window_manager.modal_handler_add(self)
context.area.header_text_set(
"Balloon Mesh: drag LMB repeatedly, navigate normally, Esc to finish"
context.workspace.status_text_set(
"Balloon Lasso: release LMB to extract, RMB or Esc to cancel"
)
context.window_manager.modal_handler_add(self)
context.area.tag_redraw()
return {"RUNNING_MODAL"}
def modal(self, context, event):
if event.type == "ESC":
if event.type in {"ESC", "RIGHTMOUSE"} and event.value == "PRESS":
self._finish(context)
return {"CANCELLED"}
if (
event.type in VIEW_NAVIGATION_EVENTS
or event_over_interface_region(context, event)
or (event.type == "LEFTMOUSE" and event.alt)
):
return {"PASS_THROUGH"}
if event.type == "RIGHTMOUSE" and event.value == "PRESS":
self._drawing = False
self._path = []
context.area.tag_redraw()
return {"RUNNING_MODAL"}
if event.type == "LEFTMOUSE" and event.value == "PRESS":
self._drawing = True
self._path = [(event.mouse_region_x, event.mouse_region_y)]
context.area.tag_redraw()
return {"RUNNING_MODAL"}
if event.type == "MOUSEMOVE" and self._drawing:
if event.type == "MOUSEMOVE":
self._path.append((event.mouse_region_x, event.mouse_region_y))
context.area.tag_redraw()
return {"RUNNING_MODAL"}
if event.type == "LEFTMOUSE" and event.value == "RELEASE" and self._drawing:
self._drawing = False
if event.type == "LEFTMOUSE" and event.value == "RELEASE":
path = simplify_screen_path(self._path)
self._path = []
if len(path) < 3:
self.report({"WARNING"}, "Draw a larger lasso")
context.area.tag_redraw()
return {"RUNNING_MODAL"}
self._finish(context)
return {"CANCELLED"}
source_object = bpy.data.objects.get(self.source_object_name)
if not is_image_empty(source_object):
@@ -436,21 +412,125 @@ class BalloonLasso(bpy.types.Operator):
plane_bounds(source_object),
tuple(source_object.data.size),
)
result = bpy.ops.snapform.extract_balloon(
"EXEC_DEFAULT",
source_object_name=self.source_object_name,
source_matrix_data=self.source_matrix_data,
selection_data=json.dumps(polygon, separators=(",", ":")),
mode=self.mode,
thickness=self.thickness,
resolution=self.resolution,
keep_source_selected=True,
)
if "FINISHED" not in result:
raise RuntimeError("Unable to extract the Balloon")
except (json.JSONDecodeError, RuntimeError, TypeError, ValueError) as error:
self.selection_data = json.dumps(polygon, separators=(",", ":"))
except (RuntimeError, TypeError, ValueError) as error:
self.report({"ERROR"}, str(error))
context.area.tag_redraw()
return {"RUNNING_MODAL"}
self._finish(context)
return {"CANCELLED"}
self._finish(context)
return self.execute(context)
return {"PASS_THROUGH"}
def execute(self, context):
source_object = bpy.data.objects.get(self.source_object_name)
if not is_image_empty(source_object):
self.report({"ERROR"}, "The source Image Empty is no longer available")
return {"CANCELLED"}
try:
polygon = json.loads(self.selection_data)
if len(polygon) < 3:
raise ValueError("The stored lasso region is invalid")
matrix_world = deserialize_matrix(self.source_matrix_data)
resolution_world = meters_to_world_distance(
context.scene,
self.resolution,
)
balloon = extract_balloon_object(
context,
source_object,
polygon,
matrix_world,
self.mode,
thickness=self.thickness,
resolution=resolution_world,
select_result=False,
reuse_material=self.reuse_material,
)
balloon["snapform_resolution_m"] = self.resolution
except (json.JSONDecodeError, RuntimeError, TypeError, ValueError) as error:
self.report({"ERROR"}, str(error))
return {"CANCELLED"}
return {"FINISHED"}
class ActivateBalloonTool(bpy.types.Operator):
bl_idname = "snapform.activate_balloon_tool"
bl_label = "Balloon Lasso Tool"
bl_description = "Activate the Balloon Lasso tool in the 3D Viewport"
bl_options = {"INTERNAL"}
@classmethod
def poll(cls, context):
return (
context.area is not None
and context.area.type == "VIEW_3D"
and is_image_empty(context.active_object)
)
def execute(self, context):
return bpy.ops.wm.tool_set_by_id(
name=BalloonTool.bl_idname,
space_type="VIEW_3D",
)
class BalloonTool(bpy.types.WorkSpaceTool):
bl_space_type = "VIEW_3D"
bl_context_mode = "OBJECT"
bl_idname = "snapform.balloon_lasso"
bl_label = "Balloon Lasso"
bl_description = (
"Draw an Image Empty region and extract one independently undoable "
"Balloon per stroke"
)
bl_icon = "ops.mesh.polybuild_hover"
bl_cursor = "PAINT_BRUSH"
bl_operator = ExtractBalloon.bl_idname
bl_keymap = (
(
ExtractBalloon.bl_idname,
{"type": "LEFTMOUSE", "value": "PRESS"},
None,
),
(
"wm.radial_control",
{"type": "S", "value": "PRESS"},
{
"properties": [
(
"data_path_primary",
"scene.snapform_settings.balloon_thickness",
),
(
"zoom_path",
"scene.snapform_settings.balloon_thickness_radial_zoom",
)
]
},
),
(
"wm.radial_control",
{"type": "D", "value": "PRESS"},
{
"properties": [
(
"data_path_primary",
"scene.snapform_settings.balloon_resolution",
),
(
"zoom_path",
"scene.snapform_settings.balloon_resolution_radial_zoom",
)
]
},
), )
def draw_settings(context, layout, tool):
settings = context.scene.snapform_settings
layout.prop(settings, "balloon_mode", text="")
layout.prop(settings, "balloon_thickness")
layout.prop(settings, "balloon_resolution", text="Resolution (m)")
layout.prop(settings, "balloon_reuse_material")

View File

@@ -6,6 +6,7 @@ from ..image import (
depth_data_name,
image_user_settings,
is_animated_image,
material_data_name,
plane_bounds,
sorted_frame_paths,
)
@@ -298,7 +299,7 @@ def build_image_depth_plane(
depth_scale = abs(float(plane_object.scale.z))
material = create_material(
source_name,
material_data_name(source_object),
material_image,
depth_scale,
displacement_image=depth_source_image,
@@ -401,7 +402,7 @@ def build_depth_plane(
)
material = create_material(
source_name,
material_data_name(source_object),
material_image,
depth_scale,
displacement_image=depth_image,

View File

@@ -159,6 +159,10 @@ def color_data_name(source_object):
return f"{image_base_name(source_object.data)}.color"
def material_data_name(source_object):
return image_base_name(source_object.data)
def scaled_process_resolution(image, scale_percent):
"""Return the scaled long edge clamped and aligned for DA3 inference."""
width, height = image.size

View File

@@ -19,6 +19,7 @@ from .common import (
from .image import (
draw_process_resolution,
is_image_empty,
material_data_name,
normal_data_name,
plane_bounds,
prepare_image_input,
@@ -464,7 +465,7 @@ def build_image_mesh(
else None
)
material = create_material(
source_name,
material_data_name(source_object),
color_image,
depth_scale,
shadeless=shadeless,

View File

@@ -172,7 +172,77 @@ def surface_model_enum_items(_owner, _context):
return _model_items(models)
BALLOON_MODE_ITEMS = (
(
"POISSON",
"Poisson",
"Use a global Poisson field for smooth inflation",
),
(
"TEDDY",
"Teddy / Chordal Axis",
"Use a Teddy-style chordal axis and local oval sections",
),
)
def balloon_mode_property():
return bpy.props.EnumProperty(
name="Mode",
description="Algorithm used to inflate the saved lasso region",
items=BALLOON_MODE_ITEMS,
default="POISSON",
)
def balloon_thickness_property():
return bpy.props.FloatProperty(
name="Thickness",
description="Scale the Balloon thickness while keeping the lasso region",
default=1.0,
min=0.05,
soft_max=5.0,
)
def balloon_resolution_property():
return bpy.props.FloatProperty(
name="Resolution (m)",
description=(
"Distance in meters between sampled mesh points; smaller values "
"create more geometry"
),
default=0.1,
min=0.005,
soft_min=0.02,
soft_max=0.5,
max=10.0,
)
def balloon_reuse_material_property():
return bpy.props.BoolProperty(
name="Reuse Material",
description="Reuse a compatible Balloon material made from the same image",
default=True,
)
def balloon_radial_zoom_property(scale):
return bpy.props.FloatVectorProperty(
size=2,
default=(scale, scale),
min=0.01,
options={"HIDDEN", "SKIP_SAVE"},
)
class SnapFormSettings(bpy.types.PropertyGroup):
balloon_mode: balloon_mode_property()
balloon_thickness: balloon_thickness_property()
balloon_resolution: balloon_resolution_property()
balloon_reuse_material: balloon_reuse_material_property()
balloon_thickness_radial_zoom: balloon_radial_zoom_property(100.0)
balloon_resolution_radial_zoom: balloon_radial_zoom_property(1000.0)
input_path: bpy.props.StringProperty(
name="Image or Video",
description="A single image or video file used for reconstruction",