"""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"}