Files
AnyImage/src/anyimage/common/viewport.py
nan c97b4a3e41 Add image selection refinement functionality with BEN2 integration
- Implemented viewport.py for handling 3D view projections, gestures, and overlay drawing.
- Created refine_image_selection.py to manage the refinement of gesture masks and output image bounds.
- Added selection module with refine_selection.py to process and crop images based on alpha channel visibility.
- Introduced __init__.py for selection module to expose refine_selection function.
2026-08-31 22:10:05 +08:00

881 lines
30 KiB
Python

"""View3D projection, gestures, tool settings, and overlay drawing."""
import json
import math
import bpy
from ..properties import ai_ready
from .ai import draw_ai_property, draw_ai_setup
from .image import (
image_empty_bounds,
is_image_empty,
require_image_empty,
video_total_frames,
)
from .selection import ImageEditWarning, SelectionPath
MIN_PATH_DISTANCE = 2.0
def simplify_screen_path(path):
pixels = []
for point in path:
point = (float(point[0]), float(point[1]))
if not pixels or math.dist(point, pixels[-1]) >= MIN_PATH_DISTANCE:
pixels.append(point)
return pixels
def screen_path_to_image_pixels(
path,
view_region,
view3d,
matrix_world,
local_bounds,
image_size,
):
from bpy_extras import view3d_utils
inverse = matrix_world.inverted()
x_min, x_max, y_min, y_max = local_bounds
image_width, image_height = image_size
local_width = x_max - x_min
local_height = y_max - y_min
pixels = []
for point in simplify_screen_path(path):
ray_origin = inverse @ view3d_utils.region_2d_to_origin_3d(
view_region,
view3d,
point,
)
ray_direction = inverse.to_3x3() @ view3d_utils.region_2d_to_vector_3d(
view_region,
view3d,
point,
)
if abs(ray_direction.z) <= 1e-10:
raise ValueError("The Image Empty is edge-on to the current view")
distance = -ray_origin.z / ray_direction.z
local = ray_origin + ray_direction * distance
pixels.append(
(
(local.x - x_min) * image_width / local_width,
(y_max - local.y) * image_height / local_height,
)
)
return pixels
def serialize_matrix(matrix):
return json.dumps(
[float(matrix[row][column]) for row in range(4) for column in range(4)],
separators=(",", ":"),
)
def deserialize_matrix(data):
from mathutils import Matrix
values = json.loads(data)
if len(values) != 16:
raise ValueError("The stored Image Empty transform is invalid")
return Matrix(tuple(tuple(values[row * 4 : row * 4 + 4]) for row in range(4)))
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 report_image_edit_exception(operator, error):
level = "WARNING" if isinstance(error, ImageEditWarning) else "ERROR"
operator.report({level}, str(error))
def image_edit_poll(context):
return context.area is not None and context.area.type == "VIEW_3D"
def _restore_selection(context, active_object, selected_objects):
bpy.ops.object.select_all(action="DESELECT")
for selected in selected_objects:
if bpy.data.objects.get(selected.name) is not None:
selected.select_set(True)
if (
active_object is not None
and bpy.data.objects.get(active_object.name) is not None
):
context.view_layer.objects.active = active_object
def resolve_image_edit_click(context, event, report):
"""Resolve the first click into source selection or image editing."""
active_object = context.active_object
selected_objects = tuple(context.selected_objects)
try:
bpy.ops.view3d.select(
location=(event.mouse_region_x, event.mouse_region_y),
extend=False,
deselect_all=False,
)
except RuntimeError:
pass
clicked_object = context.active_object
if is_image_empty(clicked_object) and clicked_object != active_object:
return clicked_object, False
if is_image_empty(clicked_object):
return clicked_object, True
if is_image_empty(active_object):
_restore_selection(context, active_object, selected_objects)
return active_object, True
report({"WARNING"}, "Click an Image Empty to use an Image tool")
return None, False
def active_view3d_tool_id(context):
tools = getattr(getattr(context, "workspace", None), "tools", None)
from_mode = getattr(tools, "from_space_view3d_mode", None)
if not callable(from_mode):
return None
try:
tool = from_mode(getattr(context, "mode", "OBJECT"), create=False)
except (RuntimeError, TypeError):
return None
return getattr(tool, "idname", None)
def refine_selection_setting_name(operator):
setting_names = getattr(operator, "refine_selection_settings", {})
return setting_names.get(operator.gesture)
def apply_selection_settings(operator, context):
settings = context.scene.anyimage_settings
setting_name = refine_selection_setting_name(operator)
operator.refine_selection = (
setting_name is not None and ai_ready() and getattr(settings, setting_name)
)
def draw_selection_operator_settings(layout, operator):
if operator.gesture not in {"BOX", "LASSO"}:
layout.prop(operator, "invert")
return
status = draw_ai_setup(layout)
draw_ai_property(layout, operator, "refine_selection", status)
layout.prop(operator, "invert")
def draw_selection_settings(context, layout, setting_name):
settings = context.scene.anyimage_settings
status = draw_ai_setup(layout)
draw_ai_property(layout, settings, setting_name, status)
def dashed_line_segments(vertices, dash_length=4.0, gap_length=4.0):
if dash_length <= 0.0 or gap_length < 0.0:
raise ValueError(
"Dash length must be positive and gap length cannot be negative"
)
pattern_length = dash_length + gap_length
segments = []
distance_offset = 0.0
for start, end in zip(vertices, vertices[1:]):
start_x, start_y = (float(value) for value in start)
end_x, end_y = (float(value) for value in end)
segment_length = math.hypot(end_x - start_x, end_y - start_y)
if segment_length <= 0.0:
continue
direction_x = (end_x - start_x) / segment_length
direction_y = (end_y - start_y) / segment_length
position = 0.0
while position < segment_length:
phase = (distance_offset + position) % pattern_length
if phase < dash_length:
run_length = min(dash_length - phase, segment_length - position)
segments.extend(
(
(
start_x + direction_x * position,
start_y + direction_y * position,
),
(
start_x + direction_x * (position + run_length),
start_y + direction_y * (position + run_length),
),
)
)
else:
run_length = min(pattern_length - phase, segment_length - position)
position += run_length
distance_offset += segment_length
return tuple(segments)
def draw_dashed_line(
vertices,
color=(1.0, 1.0, 1.0, 0.9),
segments=None,
):
import gpu
from gpu_extras.batch import batch_for_shader
if segments is None:
segments = dashed_line_segments(vertices)
if not segments:
return
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
batch = batch_for_shader(shader, "LINES", {"pos": segments})
gpu.state.blend_set("ALPHA")
gpu.state.line_width_set(1.0)
shader.bind()
shader.uniform_float("color", color)
batch.draw(shader)
gpu.state.blend_set("NONE")
def _signed_area(vertices):
return (
sum(
start[0] * end[1] - end[0] * start[1]
for start, end in zip(vertices, (*vertices[1:], vertices[0]))
)
* 0.5
)
def _triangle_cross(start, middle, end):
return (middle[0] - start[0]) * (end[1] - start[1]) - (middle[1] - start[1]) * (
end[0] - start[0]
)
def _point_in_triangle(point, start, middle, end, orientation, epsilon):
return all(
orientation * cross >= -epsilon
for cross in (
_triangle_cross(start, middle, point),
_triangle_cross(middle, end, point),
_triangle_cross(end, start, point),
)
)
def _triangulated_polygon_vertices_python(vertices):
vertices = tuple(tuple(float(value) for value in vertex) for vertex in vertices)
if len(vertices) < 3:
return ()
area = _signed_area(vertices)
if abs(area) <= 1e-8:
return ()
orientation = 1.0 if area > 0.0 else -1.0
remaining = list(range(len(vertices)))
triangles = []
epsilon = 1e-8
while len(remaining) > 3:
for position, current in enumerate(remaining):
previous = remaining[position - 1]
following = remaining[(position + 1) % len(remaining)]
start = vertices[previous]
middle = vertices[current]
end = vertices[following]
if orientation * _triangle_cross(start, middle, end) <= epsilon:
continue
if any(
_point_in_triangle(
vertices[index],
start,
middle,
end,
orientation,
epsilon,
)
for index in remaining
if index not in {previous, current, following}
):
continue
triangles.extend((start, middle, end))
del remaining[position]
break
else:
return ()
triangles.extend(vertices[index] for index in remaining)
return tuple(triangles)
def triangulated_polygon_vertices(vertices):
vertices = tuple(tuple(float(value) for value in vertex) for vertex in vertices)
try:
import mathutils
points = [mathutils.Vector((vertex[0], vertex[1], 0.0)) for vertex in vertices]
triangles = mathutils.geometry.tessellate_polygon([points])
return tuple(
(float(point[0]), float(point[1]))
for triangle in triangles
for point in triangle
)
except (AttributeError, ImportError, RuntimeError, TypeError, ValueError):
return _triangulated_polygon_vertices_python(vertices)
def scanline_fill_triangles(vertices, fill_rule="EVEN_ODD", step=2.0):
vertices = tuple(tuple(float(value) for value in vertex) for vertex in vertices)
if len(vertices) < 3:
return ()
if fill_rule not in {"EVEN_ODD", "NONZERO"}:
raise ValueError(f"Unknown polygon fill rule: {fill_rule}")
minimum_y = min(vertex[1] for vertex in vertices)
maximum_y = max(vertex[1] for vertex in vertices)
band_bottom = minimum_y
triangles = []
edges = tuple(zip(vertices, (*vertices[1:], vertices[0])))
while band_bottom < maximum_y:
band_top = min(band_bottom + step, maximum_y)
sample_y = (band_bottom + band_top) * 0.5
events = []
for start, end in edges:
if (start[1] <= sample_y < end[1]) or (end[1] <= sample_y < start[1]):
factor = (sample_y - start[1]) / (end[1] - start[1])
events.append(
(
start[0] + (end[0] - start[0]) * factor,
1 if end[1] > start[1] else -1,
)
)
events.sort(key=lambda event: event[0])
intervals = []
winding = 0
interval_start = None
index = 0
while index < len(events):
x = events[index][0]
delta = 0
count = 0
while index < len(events) and abs(events[index][0] - x) <= 1e-8:
delta += events[index][1]
count += 1
index += 1
was_inside = winding != 0
if fill_rule == "EVEN_ODD":
if count % 2:
winding = 0 if was_inside else 1
else:
winding += delta
is_inside = winding != 0
if not was_inside and is_inside:
interval_start = x
elif was_inside and not is_inside and interval_start is not None:
intervals.append((interval_start, x))
interval_start = None
for left, right in intervals:
if right - left <= 1e-8:
continue
triangles.extend(
(
(left, band_bottom),
(right, band_bottom),
(right, band_top),
(left, band_bottom),
(right, band_top),
(left, band_top),
)
)
band_bottom = band_top
return tuple(triangles)
def _point_on_segment(point, start, end, epsilon=1e-8):
return (
min(start[0], end[0]) - epsilon <= point[0] <= max(start[0], end[0]) + epsilon
and min(start[1], end[1]) - epsilon
<= point[1]
<= max(start[1], end[1]) + epsilon
)
def _segments_intersect(first_start, first_end, second_start, second_end):
crosses = (
_triangle_cross(first_start, first_end, second_start),
_triangle_cross(first_start, first_end, second_end),
_triangle_cross(second_start, second_end, first_start),
_triangle_cross(second_start, second_end, first_end),
)
if (crosses[0] > 1e-8) != (crosses[1] > 1e-8) and (crosses[2] > 1e-8) != (
crosses[3] > 1e-8
):
return True
return any(
abs(cross) <= 1e-8 and _point_on_segment(point, start, end)
for cross, point, start, end in (
(crosses[0], second_start, first_start, first_end),
(crosses[1], second_end, first_start, first_end),
(crosses[2], first_start, second_start, second_end),
(crosses[3], first_end, second_start, second_end),
)
)
def polygon_self_intersects(vertices, cell_size=32.0):
vertices = tuple(tuple(float(value) for value in vertex) for vertex in vertices)
if len(vertices) < 4:
return False
edges = tuple(zip(vertices, (*vertices[1:], vertices[0])))
cells = {}
last_edge = len(edges) - 1
for edge_index, (start, end) in enumerate(edges):
left = math.floor(min(start[0], end[0]) / cell_size)
right = math.floor(max(start[0], end[0]) / cell_size)
bottom = math.floor(min(start[1], end[1]) / cell_size)
top = math.floor(max(start[1], end[1]) / cell_size)
edge_cells = tuple(
(x, y) for x in range(left, right + 1) for y in range(bottom, top + 1)
)
candidates = {
candidate for cell in edge_cells for candidate in cells.get(cell, ())
}
for candidate in candidates:
if abs(edge_index - candidate) <= 1 or {
edge_index,
candidate,
} == {0, last_edge}:
continue
if _segments_intersect(start, end, *edges[candidate]):
return True
for cell in edge_cells:
cells.setdefault(cell, []).append(edge_index)
return False
def preview_fill_triangles(vertices, fill_rule="EVEN_ODD"):
if not polygon_self_intersects(vertices):
triangles = triangulated_polygon_vertices(vertices)
if triangles:
return triangles
return scanline_fill_triangles(vertices, fill_rule)
def draw_polygon_fill(
vertices,
triangles=None,
color=(1.0, 1.0, 1.0, 0.16),
):
import gpu
from gpu_extras.batch import batch_for_shader
if triangles is None:
triangles = triangulated_polygon_vertices(vertices)
if not triangles:
return
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
batch = batch_for_shader(shader, "TRIS", {"pos": triangles})
gpu.state.blend_set("ALPHA")
shader.bind()
shader.uniform_float("color", color)
batch.draw(shader)
gpu.state.blend_set("NONE")
GESTURE_TOOL_IDS = {
"BOX": "anyimage.image_box",
"LASSO": "anyimage.image_lasso",
"LINE": "anyimage.image_line",
"POLYLINE": "anyimage.image_polyline",
}
LINE_SNAP_RADIANS = math.radians(5.0)
POLYLINE_CLOSE_RADIUS = 8.0
LASSO_POINT_DISTANCE = 2.0
LASSO_SIMPLIFY_TOLERANCE = 0.75
def append_lasso_point(path, point):
point = (float(point[0]), float(point[1]))
if not path:
path.append(point)
return True
if math.dist(point, path[-1]) < LASSO_POINT_DISTANCE:
return False
if len(path) >= 2:
anchor = path[-2]
previous = path[-1]
segment_x = point[0] - anchor[0]
segment_y = point[1] - anchor[1]
segment_length_squared = segment_x * segment_x + segment_y * segment_y
if segment_length_squared > 0.0:
factor = (
(previous[0] - anchor[0]) * segment_x
+ (previous[1] - anchor[1]) * segment_y
) / segment_length_squared
projected = (
anchor[0] + segment_x * factor,
anchor[1] + segment_y * factor,
)
forward = (previous[0] - anchor[0]) * (point[0] - previous[0]) + (
previous[1] - anchor[1]
) * (point[1] - previous[1])
if (
0.0 <= factor <= 1.0
and forward >= 0.0
and math.dist(previous, projected) <= LASSO_SIMPLIFY_TOLERANCE
):
path[-1] = point
return True
path.append(point)
return True
def gesture_property():
return bpy.props.EnumProperty(
items=(
("BOX", "Box", "Edit with a rectangular gesture"),
("LASSO", "Lasso", "Edit with a freehand gesture"),
("LINE", "Line", "Edit one side of a line"),
("POLYLINE", "Polyline", "Edit with a clicked polygon"),
),
default="BOX",
options={"HIDDEN", "SKIP_SAVE"},
)
def box_screen_path(start, end):
x0, y0 = start
x1, y1 = end
if abs(x1 - x0) < 2.0 or abs(y1 - y0) < 2.0:
return ()
return ((x0, y0), (x1, y0), (x1, y1), (x0, y1))
def snapped_line_endpoint(start, end, snap):
if not snap:
return tuple(float(value) for value in end)
delta_x = float(end[0]) - float(start[0])
delta_y = float(end[1]) - float(start[1])
length = math.hypot(delta_x, delta_y)
if length <= 0.0:
return tuple(float(value) for value in end)
angle = round(math.atan2(delta_y, delta_x) / LINE_SNAP_RADIANS)
angle *= LINE_SNAP_RADIANS
return (
float(start[0]) + math.cos(angle) * length,
float(start[1]) + math.sin(angle) * length,
)
def line_half_plane_polygon(start, end, rectangle_size):
"""Clip a rectangle to the left side of one directed line."""
width, height = (float(value) for value in rectangle_size)
if math.dist(start, end) < 2.0:
return ()
vertices = [(0.0, 0.0), (width, 0.0), (width, height), (0.0, height)]
def side(point):
return (end[0] - start[0]) * (point[1] - start[1]) - (end[1] - start[1]) * (
point[0] - start[0]
)
result = []
previous = vertices[-1]
previous_side = side(previous)
for current in vertices:
current_side = side(current)
if (current_side >= 0.0) != (previous_side >= 0.0):
factor = previous_side / (previous_side - current_side)
result.append(
(
previous[0] + (current[0] - previous[0]) * factor,
previous[1] + (current[1] - previous[1]) * factor,
)
)
if current_side >= 0.0:
result.append(current)
previous = current
previous_side = current_side
return tuple(result)
def image_line_half_plane_polygon(line, image_size):
"""Keep the image-space half-plane shown by the screen-space preview."""
if len(line) != 2:
raise ValueError("Drag a longer line across the image plane")
return line_half_plane_polygon(line[1], line[0], image_size)
class ImageSelectionGesture:
active_tool_id = None
preview_fill_rule = "EVEN_ODD"
_path = None
_cursor = None
_handle = None
_fill_polygon = None
_fill_triangles = ()
_outline_polygon = None
_outline_segments = ()
_area_pointer = 0
_region_pointer = 0
_region_size = (0, 0)
def draw(self, _context):
draw_selection_operator_settings(self.layout, self)
@classmethod
def poll(cls, context):
return image_edit_poll(context)
def _screen_path(self):
if self.gesture == "BOX":
return box_screen_path(self._path[0], self._cursor)
if self.gesture == "LASSO":
return tuple(self._path)
if self.gesture == "LINE":
return line_half_plane_polygon(
self._path[0], self._cursor, self._region_size
)
vertices = list(self._path)
if self._cursor is not None:
vertices.append(self._cursor)
return tuple(vertices)
def _draw_selection(self):
if not self._path or not drawing_in_region(
self._area_pointer, self._region_pointer
):
return
polygon = self._screen_path()
if polygon != self._outline_polygon:
self._outline_polygon = polygon
if self.gesture == "LINE":
outline = (self._path[0], self._cursor)
else:
outline = (*polygon, polygon[0]) if len(polygon) > 1 else ()
self._outline_segments = dashed_line_segments(outline)
if len(polygon) >= 3:
if polygon != self._fill_polygon:
self._fill_polygon = polygon
self._fill_triangles = preview_fill_triangles(
polygon, self.preview_fill_rule
)
draw_polygon_fill(
polygon,
self._fill_triangles,
(1.0, 0.25, 0.2, 0.16) if self.invert else (1.0, 1.0, 1.0, 0.16),
)
if self.gesture == "LINE":
draw_dashed_line((), segments=self._outline_segments)
return
if len(polygon) > 1:
draw_dashed_line((), segments=self._outline_segments)
if (
self.gesture == "POLYLINE"
and len(self._path) >= 3
and self._cursor is not None
and math.dist(self._cursor, self._path[0]) <= POLYLINE_CLOSE_RADIUS
):
self._draw_polyline_close_indicator()
def _draw_polyline_close_indicator(self):
import gpu
from gpu_extras.batch import batch_for_shader
center_x, center_y = self._path[0]
vertices = tuple(
(
center_x + math.cos(index * math.tau / 24.0) * POLYLINE_CLOSE_RADIUS,
center_y + math.sin(index * math.tau / 24.0) * POLYLINE_CLOSE_RADIUS,
)
for index in range(25)
)
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", (1.0, 1.0, 1.0, 0.95))
batch.draw(shader)
gpu.state.line_width_set(1.0)
gpu.state.blend_set("NONE")
def _set_status(self, context):
invert = "On" if self.invert else "Off"
if self.gesture == "POLYLINE":
action = "click points; click start or Enter to finish; Backspace removes"
elif self.gesture == "LINE":
action = "drag line; Ctrl snaps 5°; release to edit"
else:
action = "drag and release to edit"
context.workspace.status_text_set(
f"Image {self.gesture.title()}: {action}; F toggles Invert ({invert}); "
"RMB or Esc cancels"
)
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.tag_redraw()
def cancel(self, context):
self._finish(context)
def invoke(self, 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_selection_settings(self, context)
self.invert = False
self.source_object_name = source_object.name
self.source_matrix_data = serialize_matrix(source_object.matrix_world)
self.selection_path_json = ""
point = (float(event.mouse_region_x), float(event.mouse_region_y))
self._path = [point]
self._cursor = point
self._fill_polygon = None
self._fill_triangles = ()
self._outline_polygon = None
self._outline_segments = ()
self._area_pointer = context.area.as_pointer()
self._region_pointer = context.region.as_pointer()
self._region_size = (context.region.width, context.region.height)
self._handle = bpy.types.SpaceView3D.draw_handler_add(
self._draw_selection, (), "WINDOW", "POST_PIXEL"
)
self._set_status(context)
context.window_manager.modal_handler_add(self)
context.area.tag_redraw()
return {"RUNNING_MODAL"}
def _complete(self, context):
source_object = require_image_empty(self.source_object_name)
matrix = deserialize_matrix(self.source_matrix_data)
if self.gesture == "LINE":
line = screen_path_to_image_pixels(
(self._path[0], self._cursor),
context.region,
context.region_data,
matrix,
image_empty_bounds(source_object),
tuple(source_object.data.size),
)
selection_path = image_line_half_plane_polygon(
line, tuple(source_object.data.size)
)
else:
path = self._screen_path()
if len(path) < 3:
self._finish(context)
return {"CANCELLED"}
selection_path = screen_path_to_image_pixels(
path,
context.region,
context.region_data,
matrix,
image_empty_bounds(source_object),
tuple(source_object.data.size),
)
self.selection_path_json = SelectionPath(
points=selection_path,
invert=self.invert,
fill_rule=self.preview_fill_rule,
).to_json()
self._finish(context)
return self.execute(context)
def modal(self, context, event):
active_tool = active_view3d_tool_id(context)
expected_tool = self.active_tool_id or GESTURE_TOOL_IDS[self.gesture]
if active_tool is not None and active_tool != expected_tool:
self._finish(context)
return {"CANCELLED"}
if event.type in {"ESC", "RIGHTMOUSE"} and event.value == "PRESS":
self._finish(context)
return {"CANCELLED"}
if event.type == "F" and event.value == "PRESS":
self.invert = not self.invert
self._set_status(context)
context.area.tag_redraw()
return {"RUNNING_MODAL"}
if self.gesture == "POLYLINE":
return self._modal_polyline(context, event)
if event.type == "MOUSEMOVE":
point = (float(event.mouse_region_x), float(event.mouse_region_y))
if self.gesture == "LASSO":
append_lasso_point(self._path, point)
self._cursor = point
elif self.gesture == "LINE":
self._cursor = snapped_line_endpoint(self._path[0], point, event.ctrl)
else:
self._cursor = point
context.area.tag_redraw()
return {"RUNNING_MODAL"}
if event.type == "LEFTMOUSE" and event.value == "RELEASE":
point = (float(event.mouse_region_x), float(event.mouse_region_y))
if self.gesture == "LASSO":
append_lasso_point(self._path, point)
self._cursor = point
elif self.gesture == "LINE":
self._cursor = snapped_line_endpoint(self._path[0], point, event.ctrl)
if math.dist(self._path[0], self._cursor) < 2.0:
self._finish(context)
return {"CANCELLED"}
else:
self._cursor = point
try:
return self._complete(context)
except (RuntimeError, TypeError, ValueError) as error:
report_image_edit_exception(self, error)
self._finish(context)
return {"CANCELLED"}
return {"PASS_THROUGH"}
def _modal_polyline(self, context, event):
if event.type == "BACK_SPACE" and event.value == "PRESS":
if len(self._path) > 1:
self._path.pop()
context.area.tag_redraw()
return {"RUNNING_MODAL"}
if event.type in {"RET", "NUMPAD_ENTER"} and event.value == "PRESS":
return self._confirm_polyline(context)
if event.type == "MOUSEMOVE":
self._cursor = (
float(event.mouse_region_x),
float(event.mouse_region_y),
)
context.area.tag_redraw()
return {"RUNNING_MODAL"}
if event.type == "LEFTMOUSE" and event.value == "PRESS":
point = (float(event.mouse_region_x), float(event.mouse_region_y))
if (
len(self._path) >= 3
and math.dist(point, self._path[0]) <= POLYLINE_CLOSE_RADIUS
):
return self._confirm_polyline(context)
if math.dist(point, self._path[-1]) >= 2.0:
self._path.append(point)
context.area.tag_redraw()
return {"RUNNING_MODAL"}
return {"PASS_THROUGH"}
def _confirm_polyline(self, context):
self._cursor = self._path[0]
try:
return self._complete(context)
except (RuntimeError, TypeError, ValueError) as error:
report_image_edit_exception(self, error)
self._finish(context)
return {"CANCELLED"}