- 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.
881 lines
30 KiB
Python
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"}
|