Files
AnyImage/src/anyimage/common/viewport.py
nan 1994d38ccd Add comprehensive tests for image processing and mask functionalities
- Introduced tests for color image handling, ensuring source preservation and premultiplied alpha usage.
- Implemented tests for brush stroke operations, validating segment boundaries and brush anti-aliasing.
- Added tests for frame anti-aliasing, confirming correct rendering behavior with various alpha values and depth settings.
- Created tests for frame projection, ensuring accurate overlap calculations and half-plane clipping.
- Developed tests for image premultiplied retention, verifying behavior during loading and packing processes.
- Added undo functionality tests for mask operations, ensuring single transaction behavior during edits.
2026-09-05 21:33:11 +08:00

1171 lines
40 KiB
Python

"""View3D projection, gestures, tool settings, and overlay drawing."""
import json
import math
import time
import bpy
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 image_edit_selection_keymap(*, deselect_on_empty=True):
return (
(
"view3d.select",
{"type": "LEFTMOUSE", "value": "CLICK"},
{"properties": [("deselect_all", deselect_on_empty)]},
),
(
"view3d.select",
{"type": "LEFTMOUSE", "value": "CLICK", "shift": True},
{"properties": [("toggle", True)]},
),
)
def image_edit_drag_keymap(
operator_id,
properties=None,
*,
deselect_on_empty=True,
):
return (
(
operator_id,
{"type": "LEFTMOUSE", "value": "CLICK_DRAG"},
properties,
),
*image_edit_selection_keymap(deselect_on_empty=deselect_on_empty),
)
def image_edit_point_keymap(operator_id, properties=None):
return (
(
operator_id,
{"type": "LEFTMOUSE", "value": "PRESS"},
properties,
),
image_edit_selection_keymap()[1],
)
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,
*,
simplify=True,
):
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 = []
screen_points = simplify_screen_path(path) if simplify else path
for point in screen_points:
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 resolve_image_edit_click(context, event, report):
"""Resolve the first click into source selection or image editing."""
active_object = context.active_object
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 clicked_object != active_object:
return clicked_object, False
try:
return active_image_edit_source(context), True
except ImageEditWarning as error:
report({"WARNING"}, str(error))
return None, False
def active_image_edit_source(context):
"""Return the selected active Image Empty used by image edit tools."""
source_object = context.active_object
if (
not is_image_empty(source_object)
or source_object not in context.selected_objects
):
raise ImageEditWarning(
"Select an active Image Empty to use an Image tool"
)
return source_object
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 dashed_line_segments(vertices, dash_length=4.0, gap_length=4.0, offset=0.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 = float(offset)
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_intervals(vertices, sample_y):
edges = tuple(zip(vertices, (*vertices[1:], vertices[0])))
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
while index < len(events) and abs(events[index][0] - x) <= 1e-8:
delta += events[index][1]
index += 1
was_inside = winding != 0
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
return tuple(intervals)
def scanline_fill_bands(vertices, step=2.0):
vertices = tuple(tuple(float(value) for value in vertex) for vertex in vertices)
if len(vertices) < 3:
return ()
minimum_y = min(vertex[1] for vertex in vertices)
maximum_y = max(vertex[1] for vertex in vertices)
band_bottom = minimum_y
bands = []
while band_bottom < maximum_y:
band_top = min(band_bottom + step, maximum_y)
sample_y = (band_bottom + band_top) * 0.5
intervals = _scanline_intervals(vertices, sample_y)
bands.append((band_bottom, band_top, tuple(intervals)))
band_bottom = band_top
return tuple(bands)
def _merge_intervals(intervals):
merged = []
for left, right in sorted(intervals):
if right - left <= 1e-8:
continue
if not merged or left > merged[-1][1] + 1e-8:
merged.append([left, right])
else:
merged[-1][1] = max(merged[-1][1], right)
return tuple((left, right) for left, right in merged)
def scanline_union_bands(polygons, step=2.0):
"""Union simple polygons into local screen-space scanline bands."""
polygons = tuple(tuple(polygon) for polygon in polygons if len(polygon) >= 3)
if not polygons:
return ()
minimum_y = math.floor(min(point[1] for polygon in polygons for point in polygon) / step) * step
maximum_y = math.ceil(max(point[1] for polygon in polygons for point in polygon) / step) * step
bounds = tuple(
(
polygon,
min(point[1] for point in polygon),
max(point[1] for point in polygon),
)
for polygon in polygons
)
bands = []
band_bottom = minimum_y
while band_bottom < maximum_y:
band_top = min(band_bottom + step, maximum_y)
sample_y = (band_bottom + band_top) * 0.5
intervals = _merge_intervals(
interval
for polygon, bottom, top in bounds
if bottom <= sample_y < top
for interval in _scanline_intervals(polygon, sample_y)
)
bands.append((band_bottom, band_top, intervals))
band_bottom = band_top
return tuple(bands)
def _scanline_band_triangles(bands):
triangles = []
for band_bottom, band_top, intervals in bands:
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),
)
)
return tuple(triangles)
def _subtract_intervals(intervals, covered):
difference = []
for left, right in intervals:
position = left
for cover_left, cover_right in covered:
if cover_right <= position:
continue
if cover_left >= right:
break
if cover_left > position:
difference.append((position, min(cover_left, right)))
position = max(position, cover_right)
if position >= right:
break
if position < right:
difference.append((position, right))
return tuple(difference)
def _closed_edge_loops(edges):
def point_key(point):
return round(point[0], 8), round(point[1], 8)
adjacency = {}
keyed_edges = []
points = {}
for index, (start, end) in enumerate(edges):
start_key = point_key(start)
end_key = point_key(end)
keyed_edges.append((start_key, end_key))
points[start_key] = start
points[end_key] = end
adjacency.setdefault(start_key, []).append((index, end_key))
adjacency.setdefault(end_key, []).append((index, start_key))
unused = set(range(len(keyed_edges)))
loops = []
while unused:
edge_index = next(iter(unused))
start_key, current_key = keyed_edges[edge_index]
unused.remove(edge_index)
loop = [points[start_key], points[current_key]]
while current_key != start_key:
candidates = (
candidate
for candidate in adjacency[current_key]
if candidate[0] in unused
)
try:
edge_index, next_key = next(candidates)
except StopIteration:
break
unused.remove(edge_index)
current_key = next_key
loop.append(points[current_key])
if len(loop) > 2:
loops.append(tuple(loop))
return tuple(loops)
def scanline_outline_segments(bands):
"""Return only the dashed outer boundary of merged scanline bands."""
if not bands:
return ()
edges = []
for bottom, top, intervals in bands:
for left, right in intervals:
edges.extend(
(
((left, bottom), (left, top)),
((right, bottom), (right, top)),
)
)
boundaries = [
(bands[0][0], (), bands[0][2]),
*(
(current[0], previous[2], current[2])
for previous, current in zip(bands, bands[1:])
),
(bands[-1][1], bands[-1][2], ()),
]
for y, lower, upper in boundaries:
for left, right in (
*_subtract_intervals(lower, upper),
*_subtract_intervals(upper, lower),
):
edges.append(((left, y), (right, y)))
return tuple(
point
for loop in _closed_edge_loops(edges)
for point in dashed_line_segments(loop)
)
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_geometry(vertices):
"""Return fill triangles and dashed boundary for one merged polygon."""
vertices = tuple(vertices)
if len(vertices) < 3:
outline = (*vertices, vertices[0]) if len(vertices) > 1 else ()
return (), dashed_line_segments(outline)
if not polygon_self_intersects(vertices):
triangles = triangulated_polygon_vertices(vertices)
if triangles:
return triangles, dashed_line_segments((*vertices, vertices[0]))
bands = scanline_fill_bands(vertices)
return _scanline_band_triangles(bands), scanline_outline_segments(bands)
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")
LASSO_POINT_DISTANCE = 2.0
LASSO_SIMPLIFY_TOLERANCE = 0.75
POLYLINE_CLOSE_RADIUS = 15.0
POLYLINE_CLOSE_MIN_RADIUS = 1.0
POLYLINE_DOUBLE_CLICK_DISTANCE = 5.0
POLYLINE_DOUBLE_CLICK_INTERVAL = 0.35
def polyline_close_radius(ui_scale=1.0):
"""Return the screen-space radius that completes a Polyline."""
return POLYLINE_CLOSE_RADIUS * float(ui_scale)
def polyline_close_indicator_radius(distance, ui_scale=1.0):
"""Return Blender's progressive Polyline start-circle radius."""
scale = float(ui_scale)
close_radius = polyline_close_radius(scale)
hint_distance = close_radius * close_radius
distance = float(distance)
if distance >= hint_distance:
return None
factor = max(0.0, distance / hint_distance)
smooth = factor * factor * (3.0 - 2.0 * factor)
minimum_radius = POLYLINE_CLOSE_MIN_RADIUS * scale
return close_radius + (minimum_radius - close_radius) * smooth
def is_polyline_double_click(
previous_point,
previous_time,
point,
current_time,
*,
ui_scale=1.0,
interval=POLYLINE_DOUBLE_CLICK_INTERVAL,
):
"""Return whether two raw presses form a Polyline double-click."""
if previous_point is None or previous_time is None:
return False
elapsed = float(current_time) - float(previous_time)
return (
0.0 <= elapsed <= float(interval)
and math.dist(point, previous_point)
<= POLYLINE_DOUBLE_CLICK_DISTANCE * float(ui_scale)
)
def append_lasso_point(path, point, samples):
"""Append a sample with bounded error over the complete active segment."""
point = (float(point[0]), float(point[1]))
if not path:
path.append(point)
samples[:] = [point]
return True
if point == path[-1]:
return False
previous = path[-1]
if len(path) >= 2 and 1 < len(samples) < 128:
anchor = path[-2]
dx, dy = point[0] - anchor[0], point[1] - anchor[1]
squared = dx * dx + dy * dy
if squared > 0:
def within_segment(sample):
x, y = sample[0] - anchor[0], sample[1] - anchor[1]
factor = (x * dx + y * dy) / squared
return (0 <= factor <= 1 and
abs(x * dy - y * dx) <= LASSO_SIMPLIFY_TOLERANCE * math.sqrt(squared))
if all(within_segment(sample) for sample in samples):
path[-1] = point
samples.append(point)
return True
path.append(point)
samples[:] = [previous, point]
return True
def rectangle_screen_path(start, end):
x0, y0 = start
x1, y1 = end
if abs(x1 - x0) < 2.0 or abs(y1 - y0) < 2.0:
return ()
left, right = sorted((float(x0), float(x1)))
bottom, top = sorted((float(y0), float(y1)))
return ((left, top), (right, top), (right, bottom), (left, bottom))
def circle_vertices(center, radius, segments=32):
"""Return a closed screen-space circle without repeating its first point."""
center_x, center_y = (float(value) for value in center)
radius = float(radius)
if radius <= 0.0 or segments < 3:
raise ValueError("The brush circle is invalid")
return tuple(
(
center_x + math.cos(index * math.tau / segments) * radius,
center_y + math.sin(index * math.tau / segments) * radius,
)
for index in range(segments)
)
def draw_circle_outline(
center,
radius,
color=(1.0, 1.0, 1.0, 0.95),
line_width=1.0,
):
import gpu
from gpu_extras.batch import batch_for_shader
vertices = circle_vertices(center, radius)
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
batch = batch_for_shader(
shader,
"LINE_STRIP",
{"pos": (*vertices, vertices[0])},
)
gpu.state.blend_set("ALPHA")
gpu.state.line_width_set(line_width)
shader.bind()
shader.uniform_float("color", color)
batch.draw(shader)
gpu.state.line_width_set(1.0)
gpu.state.blend_set("NONE")
def brush_footprint_polygons(path, radius):
"""Return circles and connecting strips defining one Brush footprint."""
points = tuple(path)
if not points:
return ()
polygons = [circle_vertices(points[0], radius)]
for start, end in zip(points, points[1:]):
delta_x = end[0] - start[0]
delta_y = end[1] - start[1]
length = math.hypot(delta_x, delta_y)
if length <= 1e-8:
continue
normal_x = -delta_y * radius / length
normal_y = delta_x * radius / length
polygons.append(
(
(start[0] + normal_x, start[1] + normal_y),
(end[0] + normal_x, end[1] + normal_y),
(end[0] - normal_x, end[1] - normal_y),
(start[0] - normal_x, start[1] - normal_y),
)
)
polygons.append(circle_vertices(end, radius))
return tuple(polygons)
def draw_polyline_close_indicator(center, radius, ui_scale=1.0):
draw_circle_outline(center, radius, (1.0, 1.0, 1.0, 0.8))
draw_circle_outline(
center,
radius + float(ui_scale),
(0.4, 0.4, 0.4, 0.8),
)
class BrushPreview:
"""Cache fixed screen rows for a sealed stroke prefix and a movable tail."""
rows_per_chunk = 32
step = 2.0
def __init__(self):
self.prefix = {}
self.tail = {}
self.coverage = {}
self.geometry = {}
self.batches = {}
self.shader = None
self.count = 0
self.key = None
def _polygon_rows(self, polygons):
return {
round(bottom / self.step): intervals
for bottom, _top, intervals in scanline_union_bands(polygons, self.step)
if intervals
}
def update(self, path, radius):
key = (len(path), tuple(path[-2:]), radius)
if key == self.key:
return
self.key = key
changed = set(self.tail)
target = max(1, len(path) - 1)
for index in range(self.count, target):
polygons = (circle_vertices(path[0], radius),) if index == 0 else (
brush_footprint_polygons(path[index - 1:index + 1], radius)[1:]
)
for row, intervals in self._polygon_rows(polygons).items():
self.prefix[row] = _merge_intervals((*self.prefix.get(row, ()), *intervals))
changed.add(row)
self.count = target
self.tail = self._polygon_rows(
brush_footprint_polygons(path[-2:], radius)[1:]
) if len(path) > 1 else {}
changed.update(self.tail)
chunks = set()
for row in changed:
intervals = _merge_intervals((*self.prefix.get(row, ()), *self.tail.get(row, ())))
if intervals == self.coverage.get(row, ()):
continue
if intervals:
self.coverage[row] = intervals
else:
self.coverage.pop(row, None)
chunks.update((row // self.rows_per_chunk, (row + 1) // self.rows_per_chunk))
for chunk in chunks:
self.geometry[chunk] = self._chunk_geometry(chunk)
self.batches.pop(chunk, None)
def _chunk_geometry(self, chunk):
bands, outline = [], []
for row in range(chunk * self.rows_per_chunk, (chunk + 1) * self.rows_per_chunk):
bottom = row * self.step
intervals = self.coverage.get(row, ())
lower = self.coverage.get(row - 1, ())
bands.append((bottom, bottom + self.step, intervals))
for left, right in intervals:
for x in (left, right):
outline.extend(dashed_line_segments(
((x, bottom), (x, bottom + self.step)), offset=bottom
))
for left, right in (*_subtract_intervals(intervals, lower),
*_subtract_intervals(lower, intervals)):
outline.extend(dashed_line_segments(
((left, bottom), (right, bottom)), offset=left
))
return _scanline_band_triangles(bands), tuple(outline)
def draw(self, color):
import gpu
from gpu_extras.batch import batch_for_shader
if self.shader is None:
self.shader = gpu.shader.from_builtin("UNIFORM_COLOR")
shader = self.shader
gpu.state.blend_set("ALPHA")
gpu.state.line_width_set(1.0)
try:
shader.bind()
for chunk, (triangles, outline) in self.geometry.items():
if chunk not in self.batches:
self.batches[chunk] = tuple(
batch_for_shader(shader, kind, {"pos": vertices}) if vertices else None
for kind, vertices in (("TRIS", triangles), ("LINES", outline))
)
fill, line = self.batches[chunk]
if fill is not None:
shader.uniform_float("color", color)
fill.draw(shader)
if line is not None:
shader.uniform_float("color", (1.0, 1.0, 1.0, 0.9))
line.draw(shader)
finally:
gpu.state.blend_set("NONE")
class ImageGesture:
active_tool_id = None
resolve_on_press = False
_path = None
_cursor = None
_handle = None
_preview_polygon = None
_fill_triangles = ()
_brush_preview = None
_outline_segments = ()
_area_pointer = 0
_region_pointer = 0
_path_samples = None
_ui_scale = 1.0
@classmethod
def poll(cls, context):
return image_edit_poll(context)
def _draw_selection(self):
if not self._path or not drawing_in_region(
self._area_pointer, self._region_pointer
):
return
color = ((1.0, 0.25, 0.2, 0.16) if getattr(self, "mode", "SET") == "SUBTRACT"
else (1.0, 1.0, 1.0, 0.16))
if self.gesture == "BRUSH":
if self._brush_preview is None:
self._brush_preview = BrushPreview()
self._brush_preview.update(self._path, self.radius)
self._brush_preview.draw(color)
return
polygon = ImageGesture._screen_path(self)
if polygon != self._preview_polygon:
self._preview_polygon = polygon
self._fill_triangles, self._outline_segments = preview_fill_geometry(polygon)
if self._fill_triangles:
draw_polygon_fill(polygon, self._fill_triangles, color)
if self._outline_segments:
draw_dashed_line((), segments=self._outline_segments)
if (
self.gesture == "POLYLINE"
and len(self._path) >= 3
and self._cursor is not None
):
ui_scale = getattr(self, "_ui_scale", 1.0)
radius = polyline_close_indicator_radius(
math.dist(self._cursor, self._path[0]),
ui_scale,
)
if radius is not None:
draw_polyline_close_indicator(
self._path[0],
radius,
ui_scale,
)
def _screen_path(self):
if self.gesture != "POLYLINE" or self._cursor is None:
return tuple(self._path)
if self._cursor == self._path[-1]:
return tuple(self._path)
return (*self._path, self._cursor)
def _set_status(self, context):
context.workspace.status_text_set(
f"{self.gesture.title()}: drag and release; 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
self._path = None
self._path_samples = None
self._cursor = None
self._preview_polygon = None
self._fill_triangles = ()
self._outline_segments = ()
self._brush_preview = 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 _submit_selection_path(self, _context, _selection_path):
raise NotImplementedError
def _complete_brush(self, _context, _source_object, _matrix):
raise NotImplementedError
def _read_gesture_settings(self, _context):
pass
def invoke(self, context, event):
self._read_gesture_settings(context)
if self.resolve_on_press:
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"}
else:
try:
source_object = active_image_edit_source(context)
except ImageEditWarning as error:
report_image_edit_exception(self, error)
return {"CANCELLED"}
if source_object is None:
return {"CANCELLED"}
if video_total_frames(source_object.data) > 1:
self.report({"WARNING"}, "Image gestures only support a still image")
return {"CANCELLED"}
self.source_object_name = source_object.name
self.source_matrix_data = serialize_matrix(source_object.matrix_world)
point = (float(event.mouse_region_x), float(event.mouse_region_y))
self._path = [point]
self._cursor = point
self._preview_polygon = None
self._fill_triangles = ()
self._brush_preview = None
self._outline_segments = ()
self._area_pointer = context.area.as_pointer()
self._region_pointer = context.region.as_pointer()
self._path_samples = [point]
preferences = getattr(context, "preferences", None)
system = getattr(preferences, "system", None)
self._ui_scale = float(getattr(system, "ui_scale", 1.0) or 1.0)
inputs = getattr(preferences, "inputs", None)
double_click_time = float(
getattr(inputs, "double_click_time", POLYLINE_DOUBLE_CLICK_INTERVAL * 1000)
or POLYLINE_DOUBLE_CLICK_INTERVAL * 1000
)
self._polyline_double_click_interval = double_click_time / 1000.0
self._last_polyline_click_point = point
self._last_polyline_click_time = time.monotonic()
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 == "BRUSH":
result = self._complete_brush(context, source_object, matrix)
self._finish(context)
return result
path = tuple(self._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),
)
selection_path = SelectionPath(
points=selection_path,
)
self._finish(context)
return self._submit_selection_path(context, selection_path)
def _cancel_and_finish(self, context):
self._finish(context)
return {"CANCELLED"}
def _confirm_polyline(self, context):
if len(self._path) < 3:
return {"RUNNING_MODAL"}
try:
return self._complete(context)
except (RuntimeError, TypeError, ValueError) as error:
report_image_edit_exception(self, error)
return self._cancel_and_finish(context)
def _modal_polyline(self, context, event):
if event.type == "BACK_SPACE" and event.value == "PRESS":
if len(self._path) > 1:
self._path.pop()
self._cursor = self._path[-1]
self._last_polyline_click_point = None
self._last_polyline_click_time = None
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 == "LEFTMOUSE" and event.value == "DOUBLE_CLICK":
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(getattr(self, "_ui_scale", 1.0))
):
return self._confirm_polyline(context)
current_time = time.monotonic()
if len(self._path) >= 3 and is_polyline_double_click(
getattr(self, "_last_polyline_click_point", None),
getattr(self, "_last_polyline_click_time", None),
point,
current_time,
ui_scale=getattr(self, "_ui_scale", 1.0),
interval=getattr(
self,
"_polyline_double_click_interval",
POLYLINE_DOUBLE_CLICK_INTERVAL,
),
):
return self._confirm_polyline(context)
if math.dist(point, self._path[-1]) >= LASSO_POINT_DISTANCE:
self._path.append(point)
self._last_polyline_click_point = point
self._last_polyline_click_time = current_time
self._cursor = point
context.area.tag_redraw()
return {"RUNNING_MODAL"}
return {"PASS_THROUGH"}
def modal(self, context, event):
active_tool = active_view3d_tool_id(context)
expected_tool = self.active_tool_id
if active_tool is not None and active_tool != expected_tool:
return self._cancel_and_finish(context)
if event.type in {"ESC", "RIGHTMOUSE"} and event.value == "PRESS":
return self._cancel_and_finish(context)
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 not append_lasso_point(self._path, point, self._path_samples):
return {"RUNNING_MODAL"}
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))
append_lasso_point(self._path, point, self._path_samples)
self._cursor = point
try:
return self._complete(context)
except (RuntimeError, TypeError, ValueError) as error:
report_image_edit_exception(self, error)
return self._cancel_and_finish(context)
return {"PASS_THROUGH"}