From 6eabc02ce8a0fd0c2af47ddc41b5fd6a3a1c350a Mon Sep 17 00:00:00 2001 From: Christophe SEUX Date: Wed, 16 Jun 2021 17:35:17 +0200 Subject: [PATCH] Add Eraser Tool 1.5.0 - feat: Eraser Brush Tool (Need to be enable in the preferences) --- OP_eraser_brush.py | 467 +++++++++++++++++++++++++++++++++++++++++++ TOOL_eraser_brush.py | 68 +++++++ __init__.py | 29 ++- properties.py | 4 + 4 files changed, 565 insertions(+), 3 deletions(-) create mode 100644 OP_eraser_brush.py create mode 100644 TOOL_eraser_brush.py diff --git a/OP_eraser_brush.py b/OP_eraser_brush.py new file mode 100644 index 0000000..0e56444 --- /dev/null +++ b/OP_eraser_brush.py @@ -0,0 +1,467 @@ +import bpy +from bpy.types import Operator +import bgl +from gpu_extras.presets import draw_circle_2d +from time import time +from mathutils import Vector, Matrix, Euler +from mathutils.kdtree import KDTree +from mathutils.geometry import intersect_line_plane, intersect_line_sphere_2d, intersect_line_line +from bpy_extras.view3d_utils import region_2d_to_location_3d, region_2d_to_vector_3d, \ +location_3d_to_region_2d, region_2d_to_origin_3d, region_2d_to_location_3d +from time import time + + +def get_gp_mat(gp, name, set_active=False): + mat = bpy.data.materials.get(name) + if not mat: + mat = bpy.data.materials.new(name) + bpy.data.materials.create_gpencil_data(mat) + + if mat not in gp.data.materials[:]: + gp.data.materials.append(mat) + mat_index = gp.data.materials[:].index(mat) + + if set_active: + gp.active_material_index = mat_index + + return mat + +def get_gp_frame(gp_layer, frame=None): + if frame is None: + frame = bpy.context.scene.frame_current + gp_frame = next((f for f in gp_layer.frames if f.frame_number==frame), None) + if not gp_frame: + gp_frame = gp_layer.frames.new(frame) + + return gp_frame + +def get_gp_layer(gp, name=None): + if not name: + return gp.data.layers.active + + layer = gp.data.layers.get(name) + if not layer: + layer = gp.data.layers.new(name) + + gp.data.layers.active = layer + + return layer + +def co_2d_to_3d(co, depth=0.1): + area = bpy.context.area + region = bpy.context.region + rv3d = area.spaces.active.region_3d + view_mat = rv3d.view_matrix.inverted() + org = view_mat.to_translation() + + depth_3d = view_mat @ Vector((0, 0, -depth)) + #org = region_2d_to_origin_3d(region, rv3d, (region.width/2.0, region.height/2.0)) + + return region_2d_to_location_3d(region, rv3d, co, depth_3d) + + + #vec = (region_2d_to_origin_3d(region, rv3d, co) - org).normalized() + + return org + vec + + return org + region_2d_to_vector_3d(region, rv3d , co) + +def get_cuts_data(strokes, mouse, radius): + gp = bpy.context.object + + area = bpy.context.area + region = bpy.context.region + rv3d = area.spaces.active.region_3d + view_mat = rv3d.view_matrix.inverted() + org = view_mat.to_translation() + mat = gp.matrix_world + + cuts_data = [] + for s in strokes: + is_polyline = 2<=len(s.points)<=5 + + if not is_polyline and not s.select: + continue + + for i, p in enumerate(s.points): + + if not p.select and not is_polyline: + continue + # Test if the next or previous is unselected + + edges = [] + + if i > 0: + prev_p = s.points[i-1] + if not prev_p.select and not is_polyline: + edges.append((i-1, i)) + + if i < len(s.points)-1: + next_p = s.points[i+1] + if not next_p.select or is_polyline: + edges.append((i, i+1)) + + for p1_index, p2_index in edges: + p1 = s.points[p1_index] + p2 = s.points[p2_index] + + length_3d = (p2.co-p1.co).length + + p1_3d = mat @ p1.co + p2_3d = mat @ p2.co + + p1_2d = location_3d_to_region_2d(region, rv3d, p1_3d) + p2_2d = location_3d_to_region_2d(region, rv3d, p2_3d) + + if p1_2d is None or p2_2d is None: + continue + + length_2d = (p2_2d-p1_2d).length + + if length_2d <= 4: + continue + + intersects = intersect_line_sphere_2d(p1_2d, p2_2d, mouse, radius+2) + intersects = [i for i in intersects if i is not None] + if not intersects: + continue + + close_points = [(p1_2d-i).length < 1 or (p2_2d-i).length < 1 for i in intersects] + if any(close_points): + #print('close_points', close_points) + continue + + + line_intersects = [] + for i_2d in intersects: + #factor = ((i_2d-p1_2d).length) / length_2d + #factor_3d = factor_2d * length_3d + #vec = region_2d_to_vector_3d(region, rv3d, i_2d) + p3_3d = region_2d_to_location_3d(region, rv3d, i_2d, org) + p4_3d = region_2d_to_origin_3d(region, rv3d, i_2d) + + #bpy.context.scene.cursor.location = p3_3d + + line_intersect = intersect_line_line(p1_3d, p2_3d, p3_3d, p4_3d) + if not line_intersect: + continue + + + i1_3d, _ = line_intersect + + line_intersects += [i1_3d] + + #context.scene.cursor.location = i1_3d + + if line_intersects: + line_intersects.sort(key=lambda x : (x-p1_3d).length) + #cut_data[-1].sort(key=lambda x : (x-p1_3d).length) + cut_data = [p1_index, p2_index, s, line_intersects] + cuts_data.append(cut_data) + + return cuts_data + +class GPTB_OT_eraser(Operator): + """Draw a line with the mouse""" + bl_idname = "gp.eraser" + bl_label = "Eraser Brush" + bl_options = {'REGISTER', 'UNDO'} + + def draw_callback_px(self): + bgl.glEnable(bgl.GL_BLEND) + draw_circle_2d(self.mouse, (0.75, 0.25, 0.35, 0.85), self.radius, 32) + bgl.glDisable(bgl.GL_BLEND) + + def draw_holdout(self, context, event): + gp = context.object + mat_inv = gp.matrix_world.inverted() + mouse_3d = co_2d_to_3d(self.mouse) + radius_3d = co_2d_to_3d(self.mouse + Vector((self.radius, 0))) + search_radius = (radius_3d-mouse_3d).length + + #print('search_radius', search_radius) + #print('radius', self.radius) + + #bpy.context.scene.cursor.location = mouse_3d + + for gp_frame, hld_stroke in self.hld_strokes: + #print('Add Point') + + hld_stroke.points.add(count=1) + p = hld_stroke.points[-1] + p.co = mat_inv @ mouse_3d + p.pressure = search_radius * 2000 + + #context.scene.cursor.location = mouse_3d + + def get_radius(self, context, event): + pressure = event.pressure or 1 + return context.scene.gptoolprops.eraser_radius * pressure + + + + + def erase(self, context, event): + gp = context.object + mat_inv = gp.matrix_world.inverted() + + new_points = [] + + #print(self.cuts_data) + + for f in self.gp_frames: + for s in [s for s in f.strokes if s.material_index==self.hld_index]: + f.strokes.remove(s) + + gp.data.materials.pop(index=self.hld_index) + bpy.data.materials.remove(self.hld_mat) + + + bpy.ops.object.mode_set(mode='EDIT_GPENCIL') + context.scene.tool_settings.gpencil_selectmode_edit = 'POINT' + #context.scene.tool_settings.gpencil_selectmode_edit = 'POINT' + + #bpy.ops.gpencil.select_circle(x=x, y=y, radius=radius, wait_for_input=False) + + #for cut_data in self.cuts_data: + + # print(cut_data, len(cut_data)) + t0 = time() + print() + + print('Number of cuts', len(self.mouse_path)) + + for mouse in self.mouse_path: + t1 = time() + + print() + x, y = mouse + bpy.ops.gpencil.select_all(action='DESELECT') + bpy.ops.gpencil.select_circle(x=x, y=y, radius=self.radius, wait_for_input=False) + + strokes = [s for f in self.gp_frames for s in f.strokes] + print('select_circle', time()-t1) + + t2 = time() + cut_data = get_cuts_data(strokes, mouse, self.radius) + print('get_cuts_data', time()-t2) + + t3 = time() + for p1_index, p2_index, stroke, intersects in cut_data[::-1]: + bpy.ops.gpencil.select_all(action='DESELECT') + + #print('p1_index', p1_index) + #print('p2_index', p2_index) + + p1 = stroke.points[p1_index] + p2 = stroke.points[p2_index] + + p1.select = True + p2.select = True + + number_cuts = len(intersects) + + bpy.ops.gpencil.stroke_subdivide(number_cuts=number_cuts, only_selected=True) + + + new_p1 = stroke.points[p1_index+1] + new_p1.co = mat_inv@intersects[0] + new_points += [(stroke, p1_index+1)] + + #print('number_cuts', number_cuts) + + if number_cuts == 2: + new_p2 = stroke.points[p1_index+2] + new_p2.co = mat_inv@( (intersects[0] + intersects[1])/2 ) + #new_points += [new_p2] + + new_p3 = stroke.points[p1_index+3] + new_p3.co = mat_inv@intersects[1] + new_points += [(stroke, p1_index+3)] + + print('subdivide', time() - t3) + + bpy.ops.gpencil.select_circle(x=x, y=y, radius=self.radius-2, wait_for_input=False) + + ''' + selected_strokes = [s for f in self.gp_frames for s in f.strokes if s.select] + tip_points = [p for s in selected_strokes for i, p in enumerate(s.points) if p.select and (i==0 or i == len(s.points)-1)] + + bpy.ops.gpencil.select_less() + + for p in tip_points: + p.select = True + for stroke, index in new_points: + stroke.points[index].select = False + ''' + + t4 = time() + selected_strokes = [s for f in self.gp_frames for s in f.strokes if s.select] + + if selected_strokes: + bpy.ops.gpencil.delete(type='POINTS') + + print('remove points', time()- t4) + + + print('Total one cut', time()-t1) + + print('Total all cuts', time()-t0) + #bpy.ops.gpencil.select_less() + #for stroke, index in new_points: + # stroke.points[index].select = False + + #bpy.ops.object.mode_set(mode='OBJECT') + bpy.ops.object.mode_set(mode='PAINT_GPENCIL') + #selected_strokes = [s for s in self.gp_frame.strokes if s.select] + #bpy.ops.object.mode_set(mode='PAINT_GPENCIL') + + def modal(self, context, event): + mouse = Vector((event.mouse_region_x, event.mouse_region_y)) + + self.radius = self.get_radius(context, event) + context.area.tag_redraw() + + if event.type == 'LEFTMOUSE': + #self.mouse = mouse + self.mouse_path.append(self.mouse) + self.erase(context, event) + bpy.types.SpaceView3D.draw_handler_remove(self._handle, 'WINDOW') + return {'FINISHED'} + + if (mouse-self.mouse).length < min(self.radius, 2): + return {'RUNNING_MODAL'} + + self.mouse = mouse + + if event.type in {'MOUSEMOVE', 'INBETWEEN_MOUSEMOVE'}: + self.draw_holdout(context, event) + self.mouse_path.append(self.mouse) + #self.update_cuts_data(context, event) + #self.erase(context, event) + return {'RUNNING_MODAL'} + + elif event.type in {'RIGHTMOUSE', 'ESC'}: + bpy.types.SpaceView3D.draw_handler_remove(self._handle, 'WINDOW') + return {'CANCELLED'} + + return {'RUNNING_MODAL'} + + def invoke(self, context, event): + gp = context.object + matrix = gp.matrix_world + + self.mouse = Vector((event.mouse_region_x, event.mouse_region_y)) + self.mouse_path = [self.mouse] + + area = context.area + region = context.region + w, h = region.width, region.height + + rv3d = area.spaces.active.region_3d + view_mat = rv3d.view_matrix.inverted() + org = self.org = view_mat.to_translation() + + #org = region_2d_to_origin_3d(region, rv3d, (region.width/2.0, region.height/2.0)) + + + #print('ORG', org) + #print('view_mat', view_mat) + + self.radius = self.get_radius(context, event) + self.cuts_data = [] + + #org = self.view_mat @ Vector((0, 0, -10)) + #self.plane_no = self.plane_co-self.org + + #bottom_left = region_2d_to_location_3d(region, rv3d , (0, 0), self.plane_co) + #bottom_right = region_2d_to_location_3d(region, rv3d , (0, w), self.plane_co) + + #bottom_left = intersect_line_plane(self.org, bottom_left, self.plane_co, self.plane_no) + #bottom_right = intersect_line_plane(self.org, bottom_right, self.plane_co, self.plane_no) + + #self.scale_fac = (bottom_right-bottom_left).length / w + + #print('scale_fac', self.scale_fac) + #depth_location = view_mat @ Vector((0, 0, -1)) + #context.scene.cursor.location = depth_location + + #plane_2d = [(0, 0), (0, h), (w, h), (w, h)] + #plane_3d = [region_2d_to_location_3d(p)] + + + t0 = time() + gp_mats = gp.data.materials + gp_layers = [l for l in gp.data.layers if not l.lock or l.hide] + self.gp_frames = [l.active_frame for l in gp_layers] + ''' + points_data = [(s, f, gp_mats[s.material_index]) for f in gp_frames for s in f.strokes] + points_data = [(s, f, m) for s, f, m in points_data if not m.grease_pencil.hide or m.grease_pencil.lock] + print('get_gp_points', time()-t0) + + t0 = time() + #points_data = [(s, f, m, p, get_screen_co(p.co, matrix)) for s, f, m in points_data for p in reversed(s.points)] + points_data = [(s, f, m, p, org + ((matrix @ p.co)-org).normalized()*1) for s, f, m in points_data for p in reversed(s.points)] + print('points_to_2d', time()-t0) + + #print(points_data) + self.points_data = [(s, f, m, p, co) for s, f, m, p, co in points_data if co is not None] + + #for s, f, m, p, co in self.points_data: + # p.co = co + + + t0 = time() + self.kd_tree = KDTree(len(self.points_data)) + for i, point_data in enumerate(self.points_data): + s, f, m, p, co = point_data + self.kd_tree.insert(co, i) + + self.kd_tree.balance() + print('create kdtree', time()-t0) + ''' + + # Create holdout mat + self.hld_mat = get_gp_mat(gp, name='Eraser Holdout Stroke') + self.hld_mat.grease_pencil.use_stroke_holdout = True + self.hld_mat.grease_pencil.show_stroke = True + self.hld_mat.grease_pencil.show_fill = False + self.hld_mat.grease_pencil.use_overlap_strokes = True + + self.hld_index = gp_mats[:].index(self.hld_mat) + + self.hld_strokes = [] + for f in self.gp_frames: + hld_stroke = f.strokes.new() + hld_stroke.start_cap_mode = 'ROUND' + hld_stroke.end_cap_mode = 'ROUND' + hld_stroke.material_index = self.hld_index + + #hld_stroke.line_width = self.radius + + self.hld_strokes.append((f, hld_stroke)) + + self.draw_holdout(context, event) + + + + context.area.tag_redraw() + self._handle = bpy.types.SpaceView3D.draw_handler_add(self.draw_callback_px, (), 'WINDOW', 'POST_PIXEL') + + context.window_manager.modal_handler_add(self) + + return {'RUNNING_MODAL'} + +### --- REGISTER --- + +classes=( +GPTB_OT_eraser, +) + +def register(): + for cls in classes: + bpy.utils.register_class(cls) + +def unregister(): + for cls in reversed(classes): + bpy.utils.unregister_class(cls) diff --git a/TOOL_eraser_brush.py b/TOOL_eraser_brush.py new file mode 100644 index 0000000..bc3376d --- /dev/null +++ b/TOOL_eraser_brush.py @@ -0,0 +1,68 @@ +import bpy +from bpy.types import WorkSpaceTool +from gpu_extras.presets import draw_circle_2d +from time import time +from .utils import get_addon_prefs + + +class GPTB_WT_eraser(WorkSpaceTool): + bl_space_type = 'VIEW_3D' + bl_context_mode = 'PAINT_GPENCIL' + + # The prefix of the idname should be your add-on name. + bl_idname = "gp.eraser_tool" + bl_label = "Eraser" + bl_description = ( + "This is a tooltip\n" + "with multiple lines" + ) + bl_icon = "brush.paint_vertex.draw" + bl_widget = None + bl_keymap = ( + ("gp.eraser", {"type": 'LEFTMOUSE', "value": 'PRESS'}, + {"properties": []}), + ("wm.radial_control", {"type": 'F', "value": 'PRESS'}, + {"properties": [("data_path_primary", 'scene.gptoolprops.eraser_radius')]}), + ) + + bl_cursor = 'DOT' + + ''' + def draw_cursor(context, tool, xy): + from gpu_extras.presets import draw_circle_2d + + radius = context.scene.gptoolprops.eraser_radius + draw_circle_2d(xy, (0.75, 0.25, 0.35, 0.85), radius, 32) + ''' + + def draw_settings(context, layout, tool): + layout.prop(context.scene.gptoolprops, "eraser_radius") + +### --- REGISTER --- + +## --- KEYMAP +addon_keymaps = [] +def register_keymaps(): + addon = bpy.context.window_manager.keyconfigs.addon + + km = addon.keymaps.new(name="Grease Pencil Stroke Paint (Draw brush)", space_type="EMPTY", region_type='WINDOW') + kmi = km.keymap_items.new("gp.eraser", type='LEFTMOUSE', value="PRESS", ctrl=True) + + prefs = get_addon_prefs() + kmi.active = prefs.use_precise_eraser + addon_keymaps.append((km, kmi)) + +def unregister_keymaps(): + for km, kmi in addon_keymaps: + km.keymap_items.remove(kmi) + addon_keymaps.clear() + +def register(): + bpy.utils.register_tool(GPTB_WT_eraser, after={"builtin.cursor"}) + #bpy.context.window_manager.keyconfigs.default.keymaps['Grease Pencil Stroke Paint (Draw brush)'].keymap_items[3].idname = 'gp.eraser' + + register_keymaps() + +def unregister(): + bpy.utils.unregister_tool(GPTB_WT_eraser) + unregister_keymaps() diff --git a/__init__.py b/__init__.py index bb0e91e..7cff6fe 100644 --- a/__init__.py +++ b/__init__.py @@ -14,8 +14,8 @@ bl_info = { "name": "GP toolbox", "description": "Set of tools for Grease Pencil in animation production", -"author": "Samuel Bernou", -"version": (1, 4, 3), +"author": "Samuel Bernou, Christophe Seux", +"version": (1, 5, 0), "blender": (2, 91, 0), "location": "Sidebar (N menu) > Gpencil > Toolbox / Gpencil properties", "warning": "", @@ -48,6 +48,8 @@ from . import OP_copy_paste from . import OP_realign from . import OP_depth_move from . import OP_key_duplicate_send +from . import OP_eraser_brush +from . import TOOL_eraser_brush from . import handler_draw_cam from . import keymaps @@ -97,9 +99,23 @@ def remap_on_save_update(self, context): if 'remap_relative' in [hand.__name__ for hand in bpy.app.handlers.save_pre]: bpy.app.handlers.save_pre.remove(remap_relative) + + +def update_use_precise_eraser(self, context): + + km, kmi = TOOL_eraser_brush.addon_keymaps[0] + + kmi.active = self.use_precise_eraser + + class GPTB_prefs(bpy.types.AddonPreferences): bl_idname = __name__ + use_precise_eraser : BoolProperty( + name='Precise Eraser', + default=False, + update=update_use_precise_eraser + ) ## tabs pref_tabs : EnumProperty( @@ -335,7 +351,7 @@ class GPTB_prefs(bpy.types.AddonPreferences): # box.separator()## Keyframe jumper box = layout.box() - box.label(text='Keyframe Jump option:') + box.label(text='Keyframe Jump options:') box.prop(self, "kfj_use_shortcut", text='Bind shortcuts') if self.kfj_use_shortcut: @@ -381,6 +397,9 @@ class GPTB_prefs(bpy.types.AddonPreferences): box.label(text='Random color options:') box.prop(self, 'separator') + box = layout.box() + box.label(text='Tools options:') + box.prop(self, 'use_precise_eraser') if self.pref_tabs == 'MAN_OPS': # layout.separator()## notes @@ -457,6 +476,8 @@ def register(): OP_realign.register() OP_depth_move.register() OP_key_duplicate_send.register() + OP_eraser_brush.register() + TOOL_eraser_brush.register() handler_draw_cam.register() UI_tools.register() keymaps.register() @@ -480,6 +501,8 @@ def unregister(): bpy.utils.unregister_class(cls) UI_tools.unregister() handler_draw_cam.unregister() + OP_eraser_brush.unregister() + TOOL_eraser_brush.unregister() OP_key_duplicate_send.unregister() OP_depth_move.unregister() OP_realign.unregister() diff --git a/properties.py b/properties.py index 86f6d15..1809c52 100644 --- a/properties.py +++ b/properties.py @@ -9,12 +9,16 @@ from bpy.props import ( from .OP_cursor_snap_canvas import cursor_follow_update + def change_edit_lines_opacity(self, context): for gp in bpy.data.grease_pencils: if not gp.is_annotation: gp.edit_line_color[3]=self.edit_lines_opacity class GP_PG_ToolsSettings(bpy.types.PropertyGroup) : + eraser_radius : IntProperty( + name="Tint hue offset", description="Radius of eraser brush", + default=20, min=0, max=500, subtype='PIXEL') drawcam_passepartout : BoolProperty( name="Show cam passepartout",