Add comprehensive tests for cutout and mesh functionalities
- Introduced `test_cutout.py` to validate cutout pie menu behavior, image lasso options, and mesh detail calculations. - Added `test_mesh.py` to ensure correct tangent normal rasterization, plane source topology, and depth surface evaluations. - Created `test_mesh_blender.py` to verify material node groups, tangent baking, and local cutout building from Blender images. - Implemented tests for handling alpha regions and ensuring geometry nodes maintain expected behavior.
This commit is contained in:
255
src/anyimage/operators/cutout.py
Normal file
255
src/anyimage/operators/cutout.py
Normal file
@@ -0,0 +1,255 @@
|
||||
import json
|
||||
|
||||
import bpy
|
||||
|
||||
from ..properties import (
|
||||
image_edit_refine_selection_property as refine_selection_property,
|
||||
)
|
||||
from .balloon.operator import hidden_string_property, serialize_matrix
|
||||
from .image import is_image_empty, video_total_frames
|
||||
from .image_edit.common import (
|
||||
ImageEditWarning,
|
||||
apply_image_mask_settings,
|
||||
draw_image_mask_settings,
|
||||
polygon_bounds,
|
||||
report_image_edit_exception,
|
||||
resolve_image_edit_click,
|
||||
)
|
||||
from .image_edit.mask import (
|
||||
ImageMaskGesture,
|
||||
gesture_property,
|
||||
selected_image_rgba,
|
||||
)
|
||||
from .mesh import BALLOON, CUTOUT, DEPTH_BALLOON, DEPTH_SURFACE
|
||||
|
||||
CUTOUT_DETAIL_MIN = 4
|
||||
CUTOUT_DETAIL_MAX = 1024
|
||||
|
||||
|
||||
def cutout_mesh_detail(image_size, pixel_size):
|
||||
longest_edge = max(int(value) for value in image_size)
|
||||
detail = round(longest_edge / max(int(pixel_size), 1))
|
||||
return max(CUTOUT_DETAIL_MIN, min(CUTOUT_DETAIL_MAX, detail))
|
||||
|
||||
|
||||
def _pie_button(
|
||||
pie,
|
||||
shape,
|
||||
label,
|
||||
icon,
|
||||
source_name,
|
||||
selection_data,
|
||||
refine_selection,
|
||||
normal_analysis,
|
||||
invert,
|
||||
mesh_detail,
|
||||
):
|
||||
operator = pie.operator(
|
||||
"anyimage.convert_cutout_shape",
|
||||
text=label,
|
||||
icon=icon,
|
||||
)
|
||||
operator.source_object_name = source_name
|
||||
operator.selection_data = selection_data
|
||||
operator.shape = shape
|
||||
operator.refine_selection = refine_selection
|
||||
operator.normal_analysis = normal_analysis
|
||||
operator.invert = invert
|
||||
operator.mesh_detail = mesh_detail
|
||||
|
||||
|
||||
def _open_shape_pie(
|
||||
context,
|
||||
event,
|
||||
source_object,
|
||||
polygon,
|
||||
refine_selection=False,
|
||||
invert=False,
|
||||
):
|
||||
source_name = source_object.name
|
||||
selection_data = json.dumps(polygon, separators=(",", ":"))
|
||||
settings = context.scene.anyimage_settings
|
||||
pixel_size = settings.cutout_pixel_size
|
||||
normal_analysis = bool(getattr(settings, "cutout_normal_analysis", False))
|
||||
mesh_detail = cutout_mesh_detail(source_object.data.size, pixel_size)
|
||||
|
||||
def draw(menu, _context):
|
||||
pie = menu.layout.menu_pie()
|
||||
_pie_button(
|
||||
pie,
|
||||
CUTOUT,
|
||||
"Cutout",
|
||||
"MESH_GRID",
|
||||
source_name,
|
||||
selection_data,
|
||||
refine_selection,
|
||||
normal_analysis,
|
||||
invert,
|
||||
mesh_detail,
|
||||
)
|
||||
_pie_button(
|
||||
pie,
|
||||
BALLOON,
|
||||
"Balloon",
|
||||
"MESH_UVSPHERE",
|
||||
source_name,
|
||||
selection_data,
|
||||
refine_selection,
|
||||
normal_analysis,
|
||||
invert,
|
||||
mesh_detail,
|
||||
)
|
||||
_pie_button(
|
||||
pie,
|
||||
DEPTH_BALLOON,
|
||||
"Depth Balloon",
|
||||
"META_BALL",
|
||||
source_name,
|
||||
selection_data,
|
||||
refine_selection,
|
||||
normal_analysis,
|
||||
invert,
|
||||
mesh_detail,
|
||||
)
|
||||
_pie_button(
|
||||
pie,
|
||||
DEPTH_SURFACE,
|
||||
"Depth Surface",
|
||||
"SURFACE_NCURVE",
|
||||
source_name,
|
||||
selection_data,
|
||||
refine_selection,
|
||||
normal_analysis,
|
||||
invert,
|
||||
mesh_detail,
|
||||
)
|
||||
|
||||
context.window_manager.popup_menu_pie(
|
||||
event,
|
||||
draw,
|
||||
title="Cutout Shape",
|
||||
icon="MESH_DATA",
|
||||
)
|
||||
|
||||
|
||||
class SelectCutoutRegion(ImageMaskGesture, bpy.types.Operator):
|
||||
bl_idname = "anyimage.select_cutout_region"
|
||||
bl_label = "Select Cutout Region"
|
||||
bl_description = "Lasso an Image Empty region, then choose its mesh shape"
|
||||
bl_options = {"INTERNAL"}
|
||||
active_tool_id = "anyimage.cutout_lasso"
|
||||
|
||||
source_object_name: hidden_string_property()
|
||||
source_matrix_data: hidden_string_property()
|
||||
selection_data: hidden_string_property()
|
||||
refine_selection: refine_selection_property()
|
||||
invert: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"})
|
||||
gesture: gesture_property()
|
||||
full_image: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"})
|
||||
|
||||
_pie_event = None
|
||||
|
||||
def _set_status(self, context):
|
||||
invert = "On" if self.invert else "Off"
|
||||
context.workspace.status_text_set(
|
||||
f"Cutout Lasso: drag and release for shapes; "
|
||||
f"F toggles Invert ({invert}); RMB or Esc cancels"
|
||||
)
|
||||
|
||||
def invoke(self, context, event):
|
||||
self._pie_event = event
|
||||
if not self.full_image and event.value != "DOUBLE_CLICK":
|
||||
return super().invoke(context, event)
|
||||
source_object, should_edit = resolve_image_edit_click(
|
||||
context, event, self.report
|
||||
)
|
||||
if not should_edit:
|
||||
return {"FINISHED"} if source_object is not None else {"CANCELLED"}
|
||||
if source_object is None:
|
||||
return {"CANCELLED"}
|
||||
if video_total_frames(source_object.data) > 1:
|
||||
self.report({"WARNING"}, "Image tools only support a still image")
|
||||
return {"CANCELLED"}
|
||||
apply_image_mask_settings(self, context)
|
||||
self.invert = False
|
||||
self.source_object_name = source_object.name
|
||||
self.source_matrix_data = serialize_matrix(source_object.matrix_world)
|
||||
width, height = (int(value) for value in source_object.data.size)
|
||||
self.selection_data = json.dumps(
|
||||
((0, 0), (width, 0), (width, height), (0, height)),
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return self.execute(context)
|
||||
|
||||
def modal(self, context, event):
|
||||
self._pie_event = event
|
||||
return super().modal(context, event)
|
||||
|
||||
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)
|
||||
polygon_bounds(polygon)
|
||||
selected_image_rgba(
|
||||
source_object, polygon, self.invert, "NONZERO"
|
||||
)
|
||||
except (ImageEditWarning, json.JSONDecodeError, TypeError, ValueError) as error:
|
||||
report_image_edit_exception(self, error)
|
||||
return {"CANCELLED"}
|
||||
_open_shape_pie(
|
||||
context,
|
||||
self._pie_event,
|
||||
source_object,
|
||||
polygon,
|
||||
self.refine_selection,
|
||||
self.invert,
|
||||
)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class ActivateCutoutTool(bpy.types.Operator):
|
||||
bl_idname = "anyimage.activate_cutout_tool"
|
||||
bl_label = "Cutout Tool"
|
||||
bl_description = "Activate the Cutout Lasso tool"
|
||||
bl_options = {"INTERNAL"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return context.area is not None and context.area.type == "VIEW_3D"
|
||||
|
||||
def execute(self, _context):
|
||||
return bpy.ops.wm.tool_set_by_id(
|
||||
name=CutoutTool.bl_idname,
|
||||
space_type="VIEW_3D",
|
||||
)
|
||||
|
||||
|
||||
class CutoutTool(bpy.types.WorkSpaceTool):
|
||||
bl_space_type = "VIEW_3D"
|
||||
bl_context_mode = "OBJECT"
|
||||
bl_idname = "anyimage.cutout_lasso"
|
||||
bl_label = "Cutout"
|
||||
bl_description = "Lasso an Image Empty and choose a mesh shape"
|
||||
bl_icon = "ops.sculpt.lasso_mask"
|
||||
bl_operator = SelectCutoutRegion.bl_idname
|
||||
bl_keymap = (
|
||||
(
|
||||
SelectCutoutRegion.bl_idname,
|
||||
{"type": "LEFTMOUSE", "value": "DOUBLE_CLICK"},
|
||||
{"properties": [("gesture", "LASSO"), ("full_image", True)]},
|
||||
),
|
||||
(
|
||||
SelectCutoutRegion.bl_idname,
|
||||
{"type": "LEFTMOUSE", "value": "PRESS"},
|
||||
{"properties": [("gesture", "LASSO")]},
|
||||
),
|
||||
)
|
||||
|
||||
def draw_settings(context, layout, _tool):
|
||||
settings = context.scene.anyimage_settings
|
||||
layout.prop(settings, "cutout_pixel_size")
|
||||
layout.prop(settings, "cutout_normal_analysis")
|
||||
draw_image_mask_settings(context, layout)
|
||||
Reference in New Issue
Block a user