From caabfa873ecfa645edc2af2d9c45d5336047afaf Mon Sep 17 00:00:00 2001 From: Nika Kutsniashvili Date: Mon, 18 Nov 2024 16:26:50 +0400 Subject: [PATCH 1/4] Initial implementation of new circle (brush) Carver tool --- functions/draw.py | 100 +++++++++++++++++++++++++++++++++++++++----- functions/select.py | 87 +++++++++++++++++++++++++++++++++++++- properties.py | 57 ++++++++++++++++++++++++- tools/__init__.py | 24 +++++++++++ tools/carver.py | 64 ++++++++++------------------ tools/circle.py | 63 ++++++++++++++++++++++++++++ 6 files changed, 339 insertions(+), 56 deletions(-) create mode 100644 tools/circle.py diff --git a/functions/draw.py b/functions/draw.py index 1a71540..2a653d2 100644 --- a/functions/draw.py +++ b/functions/draw.py @@ -2,6 +2,9 @@ from gpu_extras.batch import batch_for_shader from bpy_extras import view3d_utils + +primary_color = (0.48, 0.04, 0.04, 1.0) +secondary_color = (0.28, 0.04, 0.04, 1.0) magic_number = 1.41 #### ------------------------------ FUNCTIONS ------------------------------ #### @@ -44,19 +47,16 @@ def draw_shader(color, alpha, type, coords, size=1, indices=None): def carver_overlay(self, context): """Shape (rectangle, circle) overlay for carver tool""" - color = (0.48, 0.04, 0.04, 1.0) - secondary_color = (0.28, 0.04, 0.04, 1.0) - if self.shape == 'CIRCLE': coords, indices, rows, columns = draw_circle(self, self.subdivision, 0) # coords = coords[1:] # remove_extra_vertex self.verts = coords self.duplicates = {**{f"row_{k}": v for k, v in rows.items()}, **{f"column_{k}": v for k, v in columns.items()}} - draw_shader(color, 0.4, 'SOLID', coords, size=2, indices=indices[:-2]) + draw_shader(primary_color, 0.4, 'SOLID', coords, size=2, indices=indices[:-2]) if not self.rotate: bounds, __, __ = get_bounding_box_coords(self, coords) - draw_shader(color, 0.6, 'OUTLINE', bounds, size=2) + draw_shader(primary_color, 0.6, 'OUTLINE', bounds, size=2) elif self.shape == 'BOX': @@ -64,10 +64,10 @@ def carver_overlay(self, context): self.verts = coords self.duplicates = {**{f"row_{k}": v for k, v in rows.items()}, **{f"column_{k}": v for k, v in columns.items()}} - draw_shader(color, 0.4, 'SOLID', coords, size=2, indices=indices[:-2]) + draw_shader(primary_color, 0.4, 'SOLID', coords, size=2, indices=indices[:-2]) if (self.rotate == False) and (self.bevel == False): bounds, __, __ = get_bounding_box_coords(self, coords) - draw_shader(color, 0.6, 'OUTLINE', bounds, size=2) + draw_shader(primary_color, 0.6, 'OUTLINE', bounds, size=2) elif self.shape == 'POLYLINE': @@ -75,15 +75,15 @@ def carver_overlay(self, context): self.verts = list(dict.fromkeys(self.mouse_path)) self.duplicates = {**{f"row_{k}": v for k, v in rows.items()}, **{f"column_{k}": v for k, v in columns.items()}} - draw_shader(color, 1.0, 'LINE_LOOP' if self.closed else 'LINES', coords, size=2) - draw_shader(color, 1.0, 'POINTS', coords, size=5) + draw_shader(primary_color, 1.0, 'LINE_LOOP' if self.closed else 'LINES', coords, size=2) + draw_shader(primary_color, 1.0, 'POINTS', coords, size=5) if self.closed and len(self.mouse_path) > 2: # polygon_fill - draw_shader(color, 0.4, 'SOLID', coords, size=2, indices=indices[:-2]) + draw_shader(primary_color, 0.4, 'SOLID', coords, size=2, indices=indices[:-2]) if (self.closed and len(coords) > 3) or (self.closed == False and len(coords) > 4): # circle_around_first_point - draw_shader(color, 0.8, 'OUTLINE', first_point, size=3) + draw_shader(primary_color, 0.8, 'OUTLINE', first_point, size=3) # Snapping Grid @@ -102,6 +102,84 @@ def carver_overlay(self, context): gpu.state.blend_set('NONE') +def carver_brush(mode, context, obj_matrix=None, location=None, normal=None, xy=None, radius=None): + """Draws brush circle around the cursor, 2D (view-aligned) or 3D (normal-aligned) based on raycast result""" + # This is a (modified) code from '3D Hair Brush' by VFX Grace (GPL license) + + def draw_circle_2d(x, y, segments): + """Draws circle around the cursor""" + + mul = (1.0 / (segments - 1)) * (math.pi * 2) + points_2d = [(math.sin(i * mul) + x, math.cos(i * mul) + y) for i in range(segments)] + points_3d = [(v[0], v[1], 0) for v in points_2d] + + indices = [] + for idx in range(len(points_3d)): + i1 = idx + 1 + i2 = idx + 2 if idx + 2 <= ((360 / int(segments)) * (idx + 1)) else 1 + indices.append((0, i1, i2)) + + return points_2d, points_3d, indices + + + with gpu.matrix.push_pop(): + if mode == '3D': + window = context.window + region = context.region + rv3d = context.region_data + + # rotation_matrix + obj_matrix = obj_matrix @ mathutils.Matrix.Translation(location) + z_axis = mathutils.Vector((0, 0, 1)) + if abs(normal.dot(z_axis)) < 0.999: + perp_vector = normal.cross(z_axis).normalized() + else: + perp_vector = mathutils.Vector((1, 0, 0)) + + aligned_y_axis = perp_vector.cross(normal).normalized() + rotation_matrix = mathutils.Matrix((perp_vector, aligned_y_axis, normal)).transposed().to_4x4() + + + # Prepare Matrix + gpu.state.viewport_set(region.x, region.y, region.width, region.height) # constraint_gpu_viewport_to_active_3d_viewport_(editor)_bounds + gpu.matrix.load_identity() # load_"clean/neutral"_matrix_(no_transformations_applied_yet) + gpu.matrix.push_projection() # store_original_projection + gpu.matrix.load_projection_matrix(rv3d.window_matrix) + gpu.matrix.load_matrix(rv3d.view_matrix) + + gpu.matrix.push() + gpu.matrix.multiply_matrix(obj_matrix) + gpu.matrix.multiply_matrix(rotation_matrix) + gpu.matrix.scale_uniform(radius) # scales to screen-size, disabling it makes it act like Scene-unit scale, but needs way to change radius in other way + + + # calculate_shapes_and_draw_shaders + __, circle_3d, __ = draw_circle_2d(0, 0, 48) + __, rectangle, indices = draw_circle_2d(0, 0, 5) + + draw_shader(primary_color, 0.8, 'OUTLINE', circle_3d, size=3) + draw_shader(primary_color, 0.4, 'SOLID', rectangle, size=2, indices=indices) + # draw_shader(secondary_color, 0.8, 'LINES', [(0, 0, 0), (0, 0, -2)], size=2) + + + # Reset Matrix + gpu.matrix.pop() + gpu.matrix.pop_projection() + gpu.state.viewport_set(0, 0, window.width, window.height) + + + elif mode == '2D': + gpu.matrix.translate(xy) + gpu.matrix.scale_uniform(radius) + + # calculate_shapes_and_draw_shaders + circle_2d, __, __ = draw_circle_2d(0, 0, 48) + __, rectangle, indices = draw_circle_2d(0, 0, 5) + + draw_shader(primary_color, 1.0, 'OUTLINE', circle_2d, size=2) + draw_shader(primary_color, 0.4, 'SOLID', rectangle, size=2, indices=indices) + + def draw_polygon(self): """Returns polygonal 2d shape in which each cursor click is taken as a new vertice""" diff --git a/functions/select.py b/functions/select.py index 150fb90..e4c655e 100644 --- a/functions/select.py +++ b/functions/select.py @@ -1,4 +1,4 @@ -import bpy, mathutils +import bpy, mathutils, math from bpy_extras import view3d_utils from .draw import get_bounding_box_coords from .poll import is_linked @@ -122,3 +122,88 @@ def selection_fallback(self, context, objects, include_cutters=False): intersecting_objects.append(obj) return intersecting_objects + + +def raycast_from_cursor(region, rv3d, obj, xy): + """Casts rays from cursor position and picks out mesh object (and its normals) underneath it""" + # This function and everything down below is a (modified) code from '3D Hair Brush' by VFX Grace (GPL license) + + coords = xy[0] - region.x, xy[1] - region.y + clamp = None + if not rv3d.is_perspective and rv3d.view_perspective != 'CAMERA': + clamp = rv3d.view_distance * 2 + + # get_the_ray_from_the_viewport_and_mouse + view_vector = view3d_utils.region_2d_to_vector_3d(region, rv3d, coords) + ray_origin = view3d_utils.region_2d_to_origin_3d(region, rv3d, coords, clamp=clamp) + ray_target = ray_origin + view_vector + + # get_the_ray_relative_to_the_object + obj_matrix = obj.matrix_world.copy() + matrix_inv = obj_matrix.inverted() + ray_origin_obj = matrix_inv @ ray_origin + ray_target_obj = matrix_inv @ ray_target + ray_direction_obj = ray_target_obj - ray_origin_obj + + # Raycast + result, location, normal, index = obj.ray_cast(ray_origin_obj, ray_direction_obj) + + return result, location, normal + + +def calc_unprojected_radius(obj_matrix, location, region, rv3d, pixel_rad): + """""" + + world_location = obj_matrix @ location + perspective_matrix = rv3d.perspective_matrix.copy() + z_factor = math.fabs(world_location.dot(perspective_matrix[3].xyz) + perspective_matrix[3][3]) + if z_factor < 1e-6: + z_factor = 1.0 + + dx = 2.0 * pixel_rad * z_factor / region.width + perspective_matrix.invert() + delta = mathutils.Vector((perspective_matrix[0][0] * dx, perspective_matrix[1][0] * dx, perspective_matrix[2][0] * dx)) + radius_3d = delta.length + + scale = (obj_matrix.to_3x3() @ mathutils.Vector((1, 1, 1)).normalized()).length + if scale == 0: + scale = 1.0 + + return radius_3d, radius_3d / scale + + +def calc_projected_radius(obj_matrix, location, region, rv3d, radius): + """""" + + def location_3d_to_vector_3d(rv3d, location): + if rv3d.is_perspective: + p2 = rv3d.view_matrix @ mathutils.Vector((*location, 1.0)) + p2.xyz *= 2.0 + p2 = rv3d.view_matrix.inverted() @ p2 + return (location - p2.xyz).normalized() + + return rv3d.view_matrix.inverted().col[2].xyz.normalized() + + view = location_3d_to_vector_3d(rv3d, location) + non_orthographic = view.copy() + + if math.fabs(non_orthographic.x) < 0.1: + non_orthographic.x += 1.0 + elif math.fabs(non_orthographic.y) < 0.1: + non_orthographic.y += 1.0 + else: + non_orthographic.z += 1.0 + + ortho = non_orthographic.cross(view).normalized() + offset = location + ortho * radius + p1 = view3d_utils.location_3d_to_region_2d(region, rv3d, location) + p2 = view3d_utils.location_3d_to_region_2d(region, rv3d, offset) + + if p1 and p2: + scale = (obj_matrix.to_3x3() @ mathutils.Vector((1, 1, 1)).normalized()).length + if scale == 0: + scale = 1.0 + + return int((p1 - p2).length * scale) + + return 0 diff --git a/properties.py b/properties.py index 35dc90c..593d598 100644 --- a/properties.py +++ b/properties.py @@ -1,8 +1,44 @@ -import bpy +import bpy, mathutils +from .functions.select import( + calc_projected_radius, + calc_unprojected_radius, +) #### ------------------------------ PROPERTIES ------------------------------ #### +class ToolRuntimeData: + """Runtime Data for Circle Carve Tool""" + + def __init__(self): + self.raycast = False + self.world_location = mathutils.Vector() + self.world_normal = mathutils.Vector() + + self.rad_3d = 0.0 + self.brush_size = 0.0 + + # self.pack_source_circle = False + + def update_raycast_status(self, raycast, obj_matrix, location, normal): + self.raycast = raycast + + if raycast: + self.world_location = obj_matrix @ location + self.world_normal = obj_matrix.to_quaternion() @ normal + else: + self.world_location.zero() + self.world_normal.zero() + + def update_brush_size(self, wm, brush, obj_matrix, location, region, rv3d): + if not wm.scale_size or brush.size != self.brush_size: + self.rad_3d, wm.unprojected_radius = calc_unprojected_radius(obj_matrix, location, region, rv3d, brush.size) + if brush.size != self.brush_size: + self.brush_size = brush.size + else: + brush.size = self.brush_size = calc_projected_radius(obj_matrix, self.loc_world, region, rv3d, wm.unprojected_radius) + + class OBJECT_PG_booleans(bpy.types.PropertyGroup): # OBJECT-level Properties @@ -33,11 +69,27 @@ class OBJECT_PG_booleans(bpy.types.PropertyGroup): ) +class TOOL_PG_carver(bpy.types.PropertyGroup): + # TOOL-level Properties + + scale_size: bpy.props.BoolProperty( + name = "Brush Scale", + description = "Toggle between 2D space and 3D space where the brush is in when zooming", + default = False, + ) + unprojected_radius: bpy.props.FloatProperty( + name = "Unprojected Radius", + subtype = 'DISTANCE', + min = 0.001, soft_max = 1, step = 1, precision = -1, + ) + + #### ------------------------------ REGISTRATION ------------------------------ #### classes = [ OBJECT_PG_booleans, + TOOL_PG_carver, ] def register(): @@ -46,6 +98,8 @@ def register(): # PROPERTY bpy.types.Object.booleans = bpy.props.PointerProperty(type=OBJECT_PG_booleans, name="Booleans") + bpy.types.WindowManager.carver = bpy.props.PointerProperty(type=TOOL_PG_carver, name="Carver") + def unregister(): @@ -54,3 +108,4 @@ def unregister(): # PROPERTY del bpy.types.Object.booleans + del bpy.types.WindowManager.carver diff --git a/tools/__init__.py b/tools/__init__.py index 3e792d9..857b3ca 100644 --- a/tools/__init__.py +++ b/tools/__init__.py @@ -1,6 +1,7 @@ import bpy from . import ( carver, + circle, ) @@ -8,12 +9,35 @@ modules = [ carver, + # circle, ] +main_tools = [ + carver.OBJECT_WT_carve_box, + carver.MESH_WT_carve_box, +] +secondary_tools = [ + circle.OBJECT_WT_carve_circle, + circle.MESH_WT_carve_circle, + carver.OBJECT_WT_carve_polyline, + carver.MESH_WT_carve_polyline, +] + + def register(): for module in modules: module.register() + for tool in main_tools: + bpy.utils.register_tool(tool, separator=False, after="builtin.primitive_cube_add", group=True) + for tool in secondary_tools: + bpy.utils.register_tool(tool, separator=False, after="object.carve_box", group=False) + def unregister(): for module in reversed(modules): module.unregister() + + for tool in main_tools: + bpy.utils.unregister_tool(tool) + for tool in secondary_tools: + bpy.utils.unregister_tool(tool) diff --git a/tools/carver.py b/tools/carver.py index f7d5d50..7bcdc63 100644 --- a/tools/carver.py +++ b/tools/carver.py @@ -142,7 +142,7 @@ def draw(self, context): class OBJECT_WT_carve_box(bpy.types.WorkSpaceTool, CarverToolshelf): bl_idname = "object.carve_box" bl_label = "Box Carve" - bl_description = ("Boolean cut rectangular shapes into mesh objects") + bl_description = ("Boolean cut primitive shapes into mesh objects by drawing rectangles with cursor") bl_space_type = 'VIEW_3D' bl_context_mode = 'OBJECT' @@ -164,29 +164,29 @@ class MESH_WT_carve_box(OBJECT_WT_carve_box): bl_context_mode = 'EDIT_MESH' -class OBJECT_WT_carve_circle(bpy.types.WorkSpaceTool, CarverToolshelf): - bl_idname = "object.carve_circle" - bl_label = "Circle Carve" - bl_description = ("Boolean cut circlular shapes into mesh objects") +# class OBJECT_WT_carve_circle(bpy.types.WorkSpaceTool, CarverToolshelf): +# bl_idname = "object.carve_circle" +# bl_label = "Circle Carve" +# bl_description = ("Boolean cut circlular shapes into mesh objects") - bl_space_type = 'VIEW_3D' - bl_context_mode = 'OBJECT' +# bl_space_type = 'VIEW_3D' +# bl_context_mode = 'OBJECT' - bl_icon = os.path.join(os.path.join(os.path.dirname(os.path.dirname(__file__)), "icons") , "ops.object.carver_circle") - # bl_widget = 'VIEW3D_GGT_placement' - bl_keymap = ( - ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG'}, {"properties": [("shape", 'CIRCLE')]}), - ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "shift": True}, {"properties": [("shape", 'CIRCLE')]}), - ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "alt": True}, {"properties": [("shape", 'CIRCLE')]}), - ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "shift": True, "alt": True}, {"properties": [("shape", 'CIRCLE')]}), - ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True}, {"properties": [("shape", 'CIRCLE')]}), - ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "shift": True}, {"properties": [("shape", 'CIRCLE')]}), - ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "alt": True}, {"properties": [("shape", 'CIRCLE')]}), - ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "shift": True, "alt": True}, {"properties": [("shape", 'CIRCLE')]}), - ) +# bl_icon = os.path.join(os.path.join(os.path.dirname(os.path.dirname(__file__)), "icons") , "ops.object.carver_circle") +# # bl_widget = 'VIEW3D_GGT_placement' +# bl_keymap = ( +# ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG'}, {"properties": [("shape", 'CIRCLE')]}), +# ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "shift": True}, {"properties": [("shape", 'CIRCLE')]}), +# ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "alt": True}, {"properties": [("shape", 'CIRCLE')]}), +# ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "shift": True, "alt": True}, {"properties": [("shape", 'CIRCLE')]}), +# ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True}, {"properties": [("shape", 'CIRCLE')]}), +# ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "shift": True}, {"properties": [("shape", 'CIRCLE')]}), +# ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "alt": True}, {"properties": [("shape", 'CIRCLE')]}), +# ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "shift": True, "alt": True}, {"properties": [("shape", 'CIRCLE')]}), +# ) -class MESH_WT_carve_circle(OBJECT_WT_carve_circle): - bl_context_mode = 'EDIT_MESH' +# class MESH_WT_carve_circle(OBJECT_WT_carve_circle): +# bl_context_mode = 'EDIT_MESH' class OBJECT_WT_carve_polyline(bpy.types.WorkSpaceTool, CarverToolshelf): @@ -777,32 +777,10 @@ def Cut(self, context): TOPBAR_PT_carver_cutter, ] -main_tools = [ - OBJECT_WT_carve_box, - MESH_WT_carve_box, -] -secondary_tools = [ - OBJECT_WT_carve_circle, - OBJECT_WT_carve_polyline, - MESH_WT_carve_circle, - MESH_WT_carve_polyline, -] - - def register(): for cls in classes: bpy.utils.register_class(cls) - for tool in main_tools: - bpy.utils.register_tool(tool, separator=False, after="builtin.primitive_cube_add", group=True) - for tool in secondary_tools: - bpy.utils.register_tool(tool, separator=False, after="object.carve_box", group=False) - def unregister(): for cls in classes: bpy.utils.unregister_class(cls) - - for tool in main_tools: - bpy.utils.unregister_tool(tool) - for tool in secondary_tools: - bpy.utils.unregister_tool(tool) diff --git a/tools/circle.py b/tools/circle.py new file mode 100644 index 0000000..0a1da39 --- /dev/null +++ b/tools/circle.py @@ -0,0 +1,63 @@ +import bpy, os + +from ..functions.draw import( + carver_brush, +) +from ..functions.select import( + raycast_from_cursor, +) + + +from ..properties import ToolRuntimeData +tool_runtime_data = ToolRuntimeData() + +#### ------------------------------ TOOLS ------------------------------ #### + +class OBJECT_WT_carve_circle(bpy.types.WorkSpaceTool): + bl_idname = "object.carve_circle" + bl_label = "Circle Carve" + bl_description = ("Boolean cut primitive shapes into mesh objects with fixed-size brush") + + bl_space_type = 'VIEW_3D' + bl_context_mode = 'OBJECT' + + bl_icon = os.path.join(os.path.join(os.path.dirname(os.path.dirname(__file__)), "icons") , "ops.object.carver_circle") + # bl_widget = 'VIEW3D_GGT_placement' + # bl_keymap = ( + # ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG'}, {"properties": [("shape", 'BOX')]}), + # ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "shift": True}, {"properties": [("shape", 'BOX')]}), + # ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "alt": True}, {"properties": [("shape", 'BOX')]}), + # ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "shift": True, "alt": True}, {"properties": [("shape", 'BOX')]}), + # ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True}, {"properties": [("shape", 'BOX')]}), + # ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "shift": True}, {"properties": [("shape", 'BOX')]}), + # ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "alt": True}, {"properties": [("shape", 'BOX')]}), + # ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "shift": True, "alt": True}, {"properties": [("shape", 'BOX')]}), + # ) + bl_keymap = ( + ("wm.radial_control", {"type": 'F', "value": 'PRESS'}, {"properties": [("data_path_primary", 'tool_settings.unified_paint_settings.size')]}), + ) + + @staticmethod + def draw_cursor(context, tool, xy): + if context.active_object: + obj = context.active_object + brush = context.tool_settings.unified_paint_settings + wm = context.window_manager.carver + + # Raycast + region = context.region + rv3d = context.region_data + result, location, normal = raycast_from_cursor(region, rv3d, obj, xy) + tool_runtime_data.update_raycast_status(result, obj.matrix_world, location, normal) + + if result: + tool_runtime_data.update_brush_size(wm, brush, obj.matrix_world, location, region, rv3d) + carver_brush('3D', context, obj_matrix=obj.matrix_world, location=location, normal=normal, radius=wm.unprojected_radius) + return + else: + carver_brush('2D', context, radius=brush.size, xy=xy) + return + + +class MESH_WT_carve_circle(OBJECT_WT_carve_circle): + bl_context_mode = 'EDIT_MESH' From de23fa0db6c87250c0702aeb29f4e8be39e23136 Mon Sep 17 00:00:00 2001 From: Nika Kutsniashvili Date: Wed, 25 Dec 2024 22:04:56 +0400 Subject: [PATCH 2/4] Refactor: Split `object.carve` operator as `carve_box` and `carve_polyline` For circle object third operator will be created, which will not require modal support. --- functions/draw.py | 10 +- functions/select.py | 44 +-- tools/__init__.py | 24 +- tools/carver.py | 786 --------------------------------------- tools/carver_box.py | 299 +++++++++++++++ tools/carver_polyline.py | 217 +++++++++++ tools/circle.py | 63 ---- tools/common.py | 479 ++++++++++++++++++++++++ ui.py | 6 +- 9 files changed, 1039 insertions(+), 889 deletions(-) delete mode 100644 tools/carver.py create mode 100644 tools/carver_box.py create mode 100644 tools/carver_polyline.py delete mode 100644 tools/circle.py create mode 100644 tools/common.py diff --git a/functions/draw.py b/functions/draw.py index 2a653d2..864ecf6 100644 --- a/functions/draw.py +++ b/functions/draw.py @@ -44,10 +44,10 @@ def draw_shader(color, alpha, type, coords, size=1, indices=None): gpu.state.blend_set('NONE') -def carver_overlay(self, context): +def carver_overlay(self, context, shape): """Shape (rectangle, circle) overlay for carver tool""" - if self.shape == 'CIRCLE': + if shape == 'CIRCLE': coords, indices, rows, columns = draw_circle(self, self.subdivision, 0) # coords = coords[1:] # remove_extra_vertex self.verts = coords @@ -59,7 +59,7 @@ def carver_overlay(self, context): draw_shader(primary_color, 0.6, 'OUTLINE', bounds, size=2) - elif self.shape == 'BOX': + elif shape == 'BOX': coords, indices, rows, columns = draw_circle(self, 4, 45) self.verts = coords self.duplicates = {**{f"row_{k}": v for k, v in rows.items()}, **{f"column_{k}": v for k, v in columns.items()}} @@ -70,7 +70,7 @@ def carver_overlay(self, context): draw_shader(primary_color, 0.6, 'OUTLINE', bounds, size=2) - elif self.shape == 'POLYLINE': + elif shape == 'POLYLINE': coords, indices, first_point, rows, columns = draw_polygon(self) self.verts = list(dict.fromkeys(self.mouse_path)) self.duplicates = {**{f"row_{k}": v for k, v in rows.items()}, **{f"column_{k}": v for k, v in columns.items()}} @@ -91,7 +91,7 @@ def carver_overlay(self, context): mini_grid(self, context) # ARRAY - array_shader = 'LINE_LOOP' if self.shape == 'POLYLINE' and self.closed == False else 'SOLID' + array_shader = 'LINE_LOOP' if shape == 'POLYLINE' and self.closed == False else 'SOLID' if self.rows > 1: for i, duplicate in rows.items(): draw_shader(secondary_color, 0.4, array_shader, duplicate, size=2, indices=indices[:-2]) diff --git a/functions/select.py b/functions/select.py index e4c655e..d44d8ce 100644 --- a/functions/select.py +++ b/functions/select.py @@ -69,33 +69,33 @@ def is_inside_selection(context, obj, rect_min, rect_max): return not (max_x < rect_min.x or min_x > rect_max.x or max_y < rect_min.y or min_y > rect_max.y) -def selection_fallback(self, context, objects, include_cutters=False): +def selection_fallback(self, context, objects, polyline=False, include_cutters=False): """Selects mesh objects that fall inside given 2d rectangle coordinates""" """Used to get exactly which objects should be cut and avoid adding and applying unnecessary modifiers""" """NOTE: bounding box isn't always returning correct results for objects, but full surface check would be too expensive""" # convert_2d_rectangle_coordinates_to_world_coordinates - if self.origin == 'EDGE': - if self.shape == 'POLYLINE': - x_values = [point[0] for point in self.mouse_path] - y_values = [point[1] for point in self.mouse_path] - rect_min = mathutils.Vector((min(x_values), min(y_values))) - rect_max = mathutils.Vector((max(x_values), max(y_values))) - else: - rect_min = mathutils.Vector((min(self.mouse_path[0][0], self.mouse_path[1][0]), - min(self.mouse_path[0][1], self.mouse_path[1][1]))) - rect_max = mathutils.Vector((max(self.mouse_path[0][0], self.mouse_path[1][0]), - max(self.mouse_path[0][1], self.mouse_path[1][1]))) - - elif self.origin == 'CENTER': - # ensure_bounding_box_(needed_when_array_is_set_before_original_is_drawn) - if len(self.center_origin) == 0: - get_bounding_box_coords(self, self.verts) - - rect_min = mathutils.Vector((min(self.center_origin[0][0], self.center_origin[1][0]), - min(self.center_origin[0][1], self.center_origin[1][1]))) - rect_max = mathutils.Vector((max(self.center_origin[0][0], self.center_origin[1][0]), - max(self.center_origin[0][1], self.center_origin[1][1]))) + if polyline: + x_values = [point[0] for point in self.mouse_path] + y_values = [point[1] for point in self.mouse_path] + rect_min = mathutils.Vector((min(x_values), min(y_values))) + rect_max = mathutils.Vector((max(x_values), max(y_values))) + else: + if self.origin == 'EDGE': + rect_min = mathutils.Vector((min(self.mouse_path[0][0], self.mouse_path[1][0]), + min(self.mouse_path[0][1], self.mouse_path[1][1]))) + rect_max = mathutils.Vector((max(self.mouse_path[0][0], self.mouse_path[1][0]), + max(self.mouse_path[0][1], self.mouse_path[1][1]))) + + elif self.origin == 'CENTER': + # ensure_bounding_box_(needed_when_array_is_set_before_original_is_drawn) + if len(self.center_origin) == 0: + get_bounding_box_coords(self, self.verts) + + rect_min = mathutils.Vector((min(self.center_origin[0][0], self.center_origin[1][0]), + min(self.center_origin[0][1], self.center_origin[1][1]))) + rect_max = mathutils.Vector((max(self.center_origin[0][0], self.center_origin[1][0]), + max(self.center_origin[0][1], self.center_origin[1][1]))) # ARRAY if self.rows > 1: diff --git a/tools/__init__.py b/tools/__init__.py index 857b3ca..d7ab876 100644 --- a/tools/__init__.py +++ b/tools/__init__.py @@ -1,26 +1,30 @@ import bpy from . import ( - carver, - circle, + carver_box, + carver_circle, + carver_polyline, + common, ) #### ------------------------------ REGISTRATION ------------------------------ #### modules = [ - carver, - # circle, + carver_box, + carver_circle, + carver_polyline, + common, ] main_tools = [ - carver.OBJECT_WT_carve_box, - carver.MESH_WT_carve_box, + carver_box.OBJECT_WT_carve_box, + carver_box.MESH_WT_carve_box, ] secondary_tools = [ - circle.OBJECT_WT_carve_circle, - circle.MESH_WT_carve_circle, - carver.OBJECT_WT_carve_polyline, - carver.MESH_WT_carve_polyline, + carver_circle.OBJECT_WT_carve_circle, + carver_circle.MESH_WT_carve_circle, + carver_polyline.OBJECT_WT_carve_polyline, + carver_polyline.MESH_WT_carve_polyline, ] diff --git a/tools/carver.py b/tools/carver.py deleted file mode 100644 index 7bcdc63..0000000 --- a/tools/carver.py +++ /dev/null @@ -1,786 +0,0 @@ -import bpy, mathutils, math, os -from .. import __package__ as base_package - -from ..functions.draw import ( - carver_overlay, -) -from ..functions.object import ( - add_boolean_modifier, - set_cutter_properties, - delete_cutter, - set_object_origin, -) -from ..functions.mesh import ( - create_cutter_shape, - extrude, - shade_smooth_by_angle, -) -from ..functions.select import ( - cursor_snap, - selection_fallback, -) - - -#### ------------------------------ /tool_shelf_draw/ ------------------------------ #### - -class CarverToolshelf(): - def draw_settings(context, layout, tool): - props = tool.operator_properties("object.carve") - if context.object: - mode = "OBJECT" if context.object.mode == 'OBJECT' else "EDIT_MESH" - active_tool = context.workspace.tools.from_space_view3d_mode(mode, create=False).idname - - layout.prop(props, "mode", text="") - layout.prop(props, "depth", text="") - row = layout.row() - row.prop(props, "solver", expand=True) - - if context.object: - layout.popover("TOPBAR_PT_carver_shape", text="Shape") - layout.popover("TOPBAR_PT_carver_array", text="Array") - layout.popover("TOPBAR_PT_carver_cutter", text="Cutter") - -class TOPBAR_PT_carver_shape(bpy.types.Panel): - bl_label = "Carver Shape" - bl_idname = "TOPBAR_PT_carver_shape" - bl_region_type = 'HEADER' - bl_space_type = 'TOPBAR' - bl_category = 'Tool' - - def draw(self, context): - layout = self.layout - layout.use_property_split = True - - prefs = bpy.context.preferences.addons[base_package].preferences - mode = "OBJECT" if context.object.mode == 'OBJECT' else "EDIT_MESH" - tool = context.workspace.tools.from_space_view3d_mode(mode, create=False) - op = tool.operator_properties("object.carve") - - if tool.idname == "object.carve_polyline": - layout.prop(op, "closed") - else: - if tool.idname == "object.carve_circle": - layout.prop(op, "subdivision", text="Vertices") - layout.prop(op, "rotation") - layout.prop(op, "aspect", expand=True) - layout.prop(op, "origin", expand=True) - - if tool.idname == 'object.carve_box': - layout.separator() - layout.prop(op, "use_bevel", text="Bevel") - col = layout.column(align=True) - row = col.row(align=True) - if prefs.experimental: - row.prop(op, "bevel_profile", text="Profile", expand=True) - col.prop(op, "bevel_segments", text="Segments") - col.prop(op, "bevel_radius", text="Radius") - - if op.use_bevel == False: - col.enabled = False - - -class TOPBAR_PT_carver_array(bpy.types.Panel): - bl_label = "Carver Array" - bl_idname = "TOPBAR_PT_carver_array" - bl_region_type = 'HEADER' - bl_space_type = 'TOPBAR' - bl_category = 'Tool' - - def draw(self, context): - layout = self.layout - layout.use_property_split = True - - mode = "OBJECT" if context.object.mode == 'OBJECT' else "EDIT_MESH" - tool = context.workspace.tools.from_space_view3d_mode(mode, create=False) - op = tool.operator_properties("object.carve") - - col = layout.column(align=True) - col.prop(op, "rows") - row = col.row(align=True) - row.prop(op, "rows_direction", text="Direction", expand=True) - col.prop(op, "rows_gap", text="Gap") - - layout.separator() - col = layout.column(align=True) - col.prop(op, "columns") - row = col.row(align=True) - row.prop(op, "columns_direction", text="Direction", expand=True) - col.prop(op, "columns_gap", text="Gap") - - -class TOPBAR_PT_carver_cutter(bpy.types.Panel): - bl_label = "Carver Cutter" - bl_idname = "TOPBAR_PT_carver_cutter" - bl_region_type = 'HEADER' - bl_space_type = 'TOPBAR' - bl_category = 'Tool' - - def draw(self, context): - layout = self.layout - layout.use_property_split = True - - mode = "OBJECT" if context.object.mode == 'OBJECT' else "EDIT_MESH" - tool = context.workspace.tools.from_space_view3d_mode(mode, create=False) - op = tool.operator_properties("object.carve") - - col = layout.column() - col.prop(op, "pin", text="Pin Modifier") - col.prop(op, "parent") - if op.mode == 'MODIFIER': - col.prop(op, "hide") - - # auto_smooth - layout.separator() - col = layout.column(align=True) - col.prop(op, "auto_smooth", text="Auto Smooth") - col.prop(op, "sharp_angle") - - - -#### ------------------------------ TOOLS ------------------------------ #### - -class OBJECT_WT_carve_box(bpy.types.WorkSpaceTool, CarverToolshelf): - bl_idname = "object.carve_box" - bl_label = "Box Carve" - bl_description = ("Boolean cut primitive shapes into mesh objects by drawing rectangles with cursor") - - bl_space_type = 'VIEW_3D' - bl_context_mode = 'OBJECT' - - bl_icon = os.path.join(os.path.join(os.path.dirname(os.path.dirname(__file__)), "icons") , "ops.object.carver_box") - # bl_widget = 'VIEW3D_GGT_placement' - bl_keymap = ( - ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG'}, {"properties": [("shape", 'BOX')]}), - ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "shift": True}, {"properties": [("shape", 'BOX')]}), - ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "alt": True}, {"properties": [("shape", 'BOX')]}), - ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "shift": True, "alt": True}, {"properties": [("shape", 'BOX')]}), - ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True}, {"properties": [("shape", 'BOX')]}), - ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "shift": True}, {"properties": [("shape", 'BOX')]}), - ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "alt": True}, {"properties": [("shape", 'BOX')]}), - ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "shift": True, "alt": True}, {"properties": [("shape", 'BOX')]}), - ) - -class MESH_WT_carve_box(OBJECT_WT_carve_box): - bl_context_mode = 'EDIT_MESH' - - -# class OBJECT_WT_carve_circle(bpy.types.WorkSpaceTool, CarverToolshelf): -# bl_idname = "object.carve_circle" -# bl_label = "Circle Carve" -# bl_description = ("Boolean cut circlular shapes into mesh objects") - -# bl_space_type = 'VIEW_3D' -# bl_context_mode = 'OBJECT' - -# bl_icon = os.path.join(os.path.join(os.path.dirname(os.path.dirname(__file__)), "icons") , "ops.object.carver_circle") -# # bl_widget = 'VIEW3D_GGT_placement' -# bl_keymap = ( -# ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG'}, {"properties": [("shape", 'CIRCLE')]}), -# ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "shift": True}, {"properties": [("shape", 'CIRCLE')]}), -# ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "alt": True}, {"properties": [("shape", 'CIRCLE')]}), -# ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "shift": True, "alt": True}, {"properties": [("shape", 'CIRCLE')]}), -# ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True}, {"properties": [("shape", 'CIRCLE')]}), -# ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "shift": True}, {"properties": [("shape", 'CIRCLE')]}), -# ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "alt": True}, {"properties": [("shape", 'CIRCLE')]}), -# ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "shift": True, "alt": True}, {"properties": [("shape", 'CIRCLE')]}), -# ) - -# class MESH_WT_carve_circle(OBJECT_WT_carve_circle): -# bl_context_mode = 'EDIT_MESH' - - -class OBJECT_WT_carve_polyline(bpy.types.WorkSpaceTool, CarverToolshelf): - bl_idname = "object.carve_polyline" - bl_label = "Polyline Carve" - bl_description = ("Boolean cut custom polygonal shapes into mesh objects") - - bl_space_type = 'VIEW_3D' - bl_context_mode = 'OBJECT' - - bl_icon = os.path.join(os.path.join(os.path.dirname(os.path.dirname(__file__)), "icons") , "ops.object.carver_polyline") - # bl_widget = 'VIEW3D_GGT_placement' - bl_keymap = ( - ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK'}, {"properties": [("shape", 'POLYLINE')]}), - ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK', "ctrl": True}, {"properties": [("shape", 'POLYLINE')]}), - # select - ("view3d.select_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG'}, None), - ("view3d.select_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "shift": True}, {"properties": [("mode", 'ADD')]}), - ("view3d.select_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True}, {"properties": [("mode", 'SUB')]}), - ) - -class MESH_WT_carve_polyline(OBJECT_WT_carve_polyline): - bl_context_mode = 'EDIT_MESH' - - - -#### ------------------------------ OPERATORS ------------------------------ #### - -class OBJECT_OT_carve(bpy.types.Operator): - bl_idname = "object.carve" - bl_label = "Carve" - bl_description = "Boolean cut square shapes into mesh objects" - bl_options = {'REGISTER', 'UNDO', 'DEPENDS_ON_CURSOR'} - bl_cursor_pending = 'PICK_AREA' - - # OPERATOR-properties - shape: bpy.props.EnumProperty( - name = "Shape", - items = (('BOX', "Box", ""), - ('CIRCLE', "Circle", ""), - ('POLYLINE', "Polyline", "")), - default = 'BOX', - ) - mode: bpy.props.EnumProperty( - name = "Mode", - items = (('DESTRUCTIVE', "Destructive", "Boolean cutters are immediatelly applied and removed after the cut", 'MESH_DATA', 0), - ('MODIFIER', "Modifier", "Cuts are stored as boolean modifiers and cutters placed inside the collection", 'MODIFIER_DATA', 1)), - default = 'DESTRUCTIVE', - ) - # orientation: bpy.props.EnumProperty( - # name = "Orientation", - # items = (('SURFACE', "Surface", "Surface normal of the mesh under the cursor"), - # ('VIEW', "View", "View-aligned orientation")), - # default = 'SURFACE', - # ) - depth: bpy.props.EnumProperty( - name = "Depth", - items = (('VIEW', "View", "Depth is automatically calculated from view orientation", 'VIEW_CAMERA_UNSELECTED', 0), - ('CURSOR', "Cursor", "Depth is automatically set at 3D cursor location", 'PIVOT_CURSOR', 1)), - default = 'VIEW', - ) - - # SHAPE-properties - aspect: bpy.props.EnumProperty( - name = "Aspect", - items = (('FREE', "Free", "Use an unconstrained aspect"), - ('FIXED', "Fixed", "Use a fixed 1:1 aspect")), - default = 'FREE', - ) - origin: bpy.props.EnumProperty( - name = "Origin", - description = "The initial position for placement", - items = (('EDGE', "Edge", ""), - ('CENTER', "Center", "")), - default = 'EDGE', - ) - rotation: bpy.props.FloatProperty( - name = "Rotation", - subtype = "ANGLE", - soft_min = -360, soft_max = 360, - default = 0, - ) - subdivision: bpy.props.IntProperty( - name = "Circle Subdivisions", - description = "Number of vertices that will make up the circular shape that will be extruded into a cylinder", - min = 3, soft_max = 128, - default = 16, - ) - closed: bpy.props.BoolProperty( - name = "Closed Polygon", - description = "When enabled, mouse position at the moment of execution will be registered as last point of the polygon", - default = True, - ) - - # CUTTER-properties - hide: bpy.props.BoolProperty( - name = "Hide Cutter", - description = ("Hide cutter objects in the viewport after they're created.\n" - "NOTE: They are hidden in render regardless of this property"), - default = True, - ) - parent: bpy.props.BoolProperty( - name = "Parent to Canvas", - description = ("Cutters will be parented to active object being cut, even if cutting multiple objects.\n" - "If there is no active object in selection cutters parent might be chosen seemingly randomly"), - default = True, - ) - auto_smooth: bpy.props.BoolProperty( - name = "Shade Auto Smooth", - description = ("Cutter object will be shaded smooth with sharp edges (above 30 degrees) marked as sharp\n" - "NOTE: This is one time operator. 'Smooth by Angle' modifier will not be added on object"), - default = True, - ) - sharp_angle: bpy.props.FloatProperty( - name = "Angle", - description = "Maximum face angle for sharp edges", - subtype = "ANGLE", - min = 0, max = math.pi, - default = 0.523599, - ) - - # ARRAY-properties - rows: bpy.props.IntProperty( - name = "Rows", - description = "Number of times shape is duplicated on X axis", - min = 1, soft_max = 16, - default = 1, - ) - rows_gap: bpy.props.FloatProperty( - name = "Gap between Rows", - min = 0, soft_max = 250, - default = 50, - ) - rows_direction: bpy.props.EnumProperty( - name = "Direction of Rows", - items = (('LEFT', "Left", ""), - ('RIGHT', "Right", "")), - default = 'RIGHT', - ) - - columns: bpy.props.IntProperty( - name = "Columns", - description = "Number of times shape is duplicated on Y axis", - min = 1, soft_max = 16, - default = 1, - ) - columns_direction: bpy.props.EnumProperty( - name = "Direction of Rows", - items = (('UP', "Up", ""), - ('DOWN', "Down", "")), - default = 'DOWN', - ) - columns_gap: bpy.props.FloatProperty( - name = "Gap between Columns", - min = 0, soft_max = 250, - default = 50, - ) - - # BEVEL-properties - use_bevel: bpy.props.BoolProperty( - name = "Bevel Cutter", - description = "Bevel each side edge of the cutter", - default = False, - ) - bevel_profile: bpy.props.EnumProperty( - name = "Bevel Profile", - items = (('CONVEX', "Convex", "Outside bevel (rounded corners)"), - ('CONCAVE', "Concave", "Inside bevel")), - default = 'CONVEX', - ) - bevel_segments: bpy.props.IntProperty( - name = "Bevel Segments", - description = "Segments for curved edge", - min = 2, soft_max = 32, - default = 8, - ) - bevel_radius: bpy.props.FloatProperty( - name = "Bevel Radius", - description = "Amout of the bevel (in screen-space units)", - min = 0.01, soft_max = 5, - default = 1, - ) - - # MODIFIER-properties - solver: bpy.props.EnumProperty( - name = "Solver", - items = [('FAST', "Fast", ""), - ('EXACT', "Exact", "")], - default = 'FAST', - ) - pin: bpy.props.BoolProperty( - name = "Pin Boolean Modifier", - description = ("When enabled boolean modifier will be moved above every other modifier on the object (if there are any).\n" - "Order of modifiers can drastically affect the result (especially in destructive mode)"), - default = True, - ) - - - @classmethod - def poll(cls, context): - return context.mode in ('OBJECT', 'EDIT_MESH') - - - def __init__(self): - self.mouse_path = [(0, 0), (0, 0)] - self.view_vector = mathutils.Vector() - self.verts = [] - self.cutter = None - self.duplicates = [] - - args = (self, bpy.context) - self._handle = bpy.types.SpaceView3D.draw_handler_add(carver_overlay, args, 'WINDOW', 'POST_PIXEL') - - # Modifier Keys - self.snap = False - self.move = False - self.rotate = False - self.gap = False - self.bevel = False - - # Cache - self.initial_origin = self.origin - self.initial_aspect = self.aspect - self.cached_mouse_position = () - - # overlay_position - self.position_x = 0 - self.position_y = 0 - self.initial_position = False - self.center_origin = [] - self.distance_from_first = 0 - - - def invoke(self, context, event): - if context.area.type != 'VIEW_3D': - self.report({'WARNING'}, "Carver tool can only be called from 3D viewport") - self.cancel(context) - return {'CANCELLED'} - - self.selected_objects = context.selected_objects - self.initial_selection = context.selected_objects - self.mouse_path[0] = (event.mouse_region_x, event.mouse_region_y) - self.mouse_path[1] = (event.mouse_region_x, event.mouse_region_y) - - context.window.cursor_set("MUTE") - context.window_manager.modal_handler_add(self) - return {'RUNNING_MODAL'} - - - def modal(self, context, event): - snap_text = ", [MOUSEWHEEL]: Change Snapping Increment" if self.snap else "" - if self.shape == 'POLYLINE': - shape_text = "[BACKSPACE]: Remove Last Point, [ENTER]: Confirm" - else: - shape_text = "[SHIFT]: Aspect, [ALT]: Origin, [R]: Rotate, [ARROWS]: Array" - array_text = ", [A]: Gap" if (self.rows > 1 or self.columns > 1) else "" - bevel_text = ", [B]: Bevel" if self.shape == 'BOX' else "" - context.area.header_text_set("[CTRL]: Snap Invert, [SPACEBAR]: Move, " + shape_text + bevel_text + array_text + snap_text) - - # find_the_limit_of_the_3d_viewport_region - region_types = {'WINDOW', 'UI'} - for area in context.window.screen.areas: - if area.type == 'VIEW_3D': - for region in area.regions: - if not region_types or region.type in region_types: - region.tag_redraw() - - - # SNAP - # change_the_snap_increment_value_using_the_wheel_mouse - if (self.move is False) and (self.rotate is False): - for i, a in enumerate(context.screen.areas): - if a.type == 'VIEW_3D': - space = context.screen.areas[i].spaces.active - - if event.type == 'WHEELUPMOUSE': - space.overlay.grid_subdivisions -= 1 - elif event.type == 'WHEELDOWNMOUSE': - space.overlay.grid_subdivisions += 1 - - self.snap = context.scene.tool_settings.use_snap - if event.ctrl and (self.move is False) and (self.rotate is False): - self.snap = not self.snap - - - # ASPECT - if event.shift and (self.shape != 'POLYLINE'): - if self.initial_aspect == 'FREE': - self.aspect = 'FIXED' - elif self.initial_aspect == 'FIXED': - self.aspect = 'FREE' - else: - self.aspect = self.initial_aspect - - - # ORIGIN - if event.alt and (self.shape != 'POLYLINE'): - if self.initial_origin == 'EDGE': - self.origin = 'CENTER' - elif self.initial_origin == 'CENTER': - self.origin = 'EDGE' - else: - self.origin = self.initial_origin - - - # ROTATE - if event.type == 'R' and (self.shape != 'POLYLINE'): - if event.value == 'PRESS': - self.cached_mouse_position = (self.mouse_path[1][0], self.mouse_path[1][1]) - context.window.cursor_set("NONE") - self.rotate = True - elif event.value == 'RELEASE': - context.window.cursor_set("MUTE") - context.window.cursor_warp(int(self.cached_mouse_position[0]), int(self.cached_mouse_position[1])) - self.rotate = False - - - # BEVEL - if event.type == 'B' and (self.shape == 'BOX'): - if event.value == 'PRESS': - self.use_bevel = True - self.cached_mouse_position = (self.mouse_path[1][0], self.mouse_path[1][1]) - context.window.cursor_set("NONE") - self.bevel = True - elif event.value == 'RELEASE': - context.window.cursor_set("MUTE") - context.window.cursor_warp(int(self.cached_mouse_position[0]), int(self.cached_mouse_position[1])) - self.bevel = False - - if self.bevel: - if event.type == 'WHEELUPMOUSE': - self.bevel_segments += 1 - elif event.type == 'WHEELDOWNMOUSE': - self.bevel_segments -= 1 - - - # ARRAY - if event.type == 'LEFT_ARROW' and event.value == 'PRESS': - self.rows -= 1 - if event.type == 'RIGHT_ARROW' and event.value == 'PRESS': - self.rows += 1 - if event.type == 'DOWN_ARROW' and event.value == 'PRESS': - self.columns -= 1 - if event.type == 'UP_ARROW' and event.value == 'PRESS': - self.columns += 1 - - if (self.rows > 1 or self.columns > 1) and (event.type == 'A'): - if event.value == 'PRESS': - self.cached_mouse_position = (self.mouse_path[1][0], self.mouse_path[1][1]) - context.window.cursor_set("NONE") - self.gap = True - elif event.value == 'RELEASE': - context.window.cursor_set("MUTE") - context.window.cursor_warp(self.cached_mouse_position[0], self.cached_mouse_position[1]) - self.gap = False - - - # MOVE - if event.type == 'SPACE': - if event.value == 'PRESS': - self.move = True - elif event.value == 'RELEASE': - self.move = False - - if self.move: - # initial_position_variable_before_moving_the_brush - if self.initial_position is False: - self.position_x = 0 - self.position_y = 0 - self.last_mouse_region_x = event.mouse_region_x - self.last_mouse_region_y = event.mouse_region_y - self.initial_position = True - self.move = True - - # update_the_coordinates - if self.initial_position and self.move is False: - for i in range(0, len(self.mouse_path)): - l = list(self.mouse_path[i]) - l[0] += self.position_x - l[1] += self.position_y - self.mouse_path[i] = tuple(l) - - self.position_x = self.position_y = 0 - self.initial_position = False - - - # Remove Point (Polyline) - if event.type == 'BACK_SPACE' and event.value == 'PRESS': - if len(self.mouse_path) > 2: - context.window.cursor_warp(self.mouse_path[-2][0], self.mouse_path[-2][1]) - self.mouse_path = self.mouse_path[:-2] - - - if event.type in {'MIDDLEMOUSE', 'N', 'NUMPAD_1', 'NUMPAD_2', 'NUMPAD_3', 'NUMPAD_4', - 'NUMPAD_5', 'NUMPAD_6', 'NUMPAD_7', 'NUMPAD_8', 'NUMPAD_9'}: - return {'PASS_THROUGH'} - if self.bevel == False and event.type in {'WHEELUPMOUSE', 'WHEELDOWNMOUSE'}: - return {'PASS_THROUGH'} - - - # mouse_move - if event.type == 'MOUSEMOVE': - if self.rotate: - self.rotation = event.mouse_region_x * 0.01 - - elif self.move: - # MOVE - self.position_x += (event.mouse_region_x - self.last_mouse_region_x) - self.position_y += (event.mouse_region_y - self.last_mouse_region_y) - - self.last_mouse_region_x = event.mouse_region_x - self.last_mouse_region_y = event.mouse_region_y - - elif self.gap: - self.rows_gap = event.mouse_region_x * 0.1 - self.columns_gap = event.mouse_region_y * 0.1 - - elif self.bevel: - self.bevel_radius = event.mouse_region_x * 0.002 - - else: - if len(self.mouse_path) > 0: - # ASPECT - if self.aspect == 'FIXED': - side = max(abs(event.mouse_region_x - self.mouse_path[0][0]), - abs(event.mouse_region_y - self.mouse_path[0][1])) - self.mouse_path[len(self.mouse_path) - 1] = \ - (self.mouse_path[0][0] + (side if event.mouse_region_x >= self.mouse_path[0][0] else -side), - self.mouse_path[0][1] + (side if event.mouse_region_y >= self.mouse_path[0][1] else -side)) - - elif self.aspect == 'FREE': - self.mouse_path[len(self.mouse_path) - 1] = (event.mouse_region_x, event.mouse_region_y) - - # SNAP (find_the_closest_position_on_the_overlay_grid_and_snap_the_shape_to_it) - if self.snap: - cursor_snap(self, context, event, self.mouse_path) - - if self.shape == 'POLYLINE': - # get_distance_from_first_point - distance = math.sqrt((self.mouse_path[-1][0] - self.mouse_path[0][0]) ** 2 + - (self.mouse_path[-1][1] - self.mouse_path[0][1]) ** 2) - min_radius = 0 - max_radius = 30 - self.distance_from_first = max(max_radius - distance, min_radius) - - - # Confirm - elif (event.type == 'LEFTMOUSE' and event.value == 'RELEASE') or (event.type == 'RET' and event.value == 'PRESS'): - # selection_fallback - if self.shape != 'POLYLINE': - if len(self.selected_objects) == 0: - self.selected_objects = selection_fallback(self, context, context.view_layer.objects) - for obj in self.selected_objects: - obj.select_set(True) - - if len(self.selected_objects) == 0: - self.report({'INFO'}, "Only selected objects can be carved") - self.cancel(context) - return {'FINISHED'} - else: - empty = self.selection_fallback(context) - if empty: - return {'FINISHED'} - else: - if len(self.initial_selection) == 0: - # expand_selection_fallback_on_every_polyline_click - self.selected_objects = selection_fallback(self, context, context.view_layer.objects) - for obj in self.selected_objects: - obj.select_set(True) - - # Polyline - if self.shape == 'POLYLINE': - if not (event.type == 'RET' and event.value == 'PRESS') and (self.distance_from_first < 15): - self.mouse_path.append((event.mouse_region_x, event.mouse_region_y)) - if self.closed == False: - # NOTE: Additional vert is needed for open loop. - self.mouse_path.append((event.mouse_region_x, event.mouse_region_y)) - else: - # Confirm Cut (Polyline) - if self.closed == False: - self.verts.pop() # dont_add_current_mouse_position_as_vert - - if self.distance_from_first > 15: - self.verts[-1] = self.verts[0] - - if len(self.verts) / 2 <= 1: - self.report({'INFO'}, "At least two points are required to make polygonal shape") - self.cancel(context) - return {'FINISHED'} - - if self.closed and self.mouse_path[-1] == self.mouse_path[-2]: - context.window.cursor_warp(event.mouse_region_x - 1, event.mouse_region_y) - - # NOTE: Polyline needs separate selection fallback, because it needs to calculate selection bounding box... - # NOTE: after all points are already drawn, i.e. before execution. - empty = self.selection_fallback(context) - if empty: - return {'FINISHED'} - - self.confirm(context) - return {'FINISHED'} - - # Confirm Cut (Box, Circle) - else: - # protection_against_returning_no_rectangle_by_clicking - delta_x = abs(event.mouse_region_x - self.mouse_path[0][0]) - delta_y = abs(event.mouse_region_y - self.mouse_path[0][1]) - min_distance = 5 - - if delta_x > min_distance or delta_y > min_distance: - self.confirm(context) - return {'FINISHED'} - - - # Cancel - elif event.type in {'RIGHTMOUSE', 'ESC'}: - self.cancel(context) - return {'FINISHED'} - - return {'RUNNING_MODAL'} - - - def confirm(self, context): - create_cutter_shape(self, context) - extrude(self, self.cutter.data) - set_object_origin(self.cutter) - if self.auto_smooth: - shade_smooth_by_angle(self.cutter, angle=math.degrees(self.sharp_angle)) - - self.Cut(context) - self.cancel(context) - - - def cancel(self, context): - bpy.types.SpaceView3D.draw_handler_remove(self._handle, 'WINDOW') - context.area.header_text_set(None) - context.window.cursor_set('DEFAULT' if context.object.mode == 'OBJECT' else 'CROSSHAIR') - - - def selection_fallback(self, context): - # filter_out_objects_not_inside_the_selection_bounding_box - self.selected_objects = selection_fallback(self, context, self.selected_objects, include_cutters=True) - - # silently_fail_if_no_objects_inside_selection_bounding_box - empty = False - if len(self.selected_objects) == 0: - self.cancel(context) - empty = True - - return empty - - - def Cut(self, context): - # ensure_active_object - if not context.active_object: - context.view_layer.objects.active = self.selected_objects[0] - - # Add Modifier - for obj in self.selected_objects: - if self.mode == 'DESTRUCTIVE': - add_boolean_modifier(self, obj, self.cutter, "DIFFERENCE", self.solver, apply=True, pin=self.pin, redo=False) - elif self.mode == 'MODIFIER': - add_boolean_modifier(self, obj, self.cutter, "DIFFERENCE", self.solver, pin=self.pin, redo=False) - obj.booleans.canvas = True - - if self.mode == 'DESTRUCTIVE': - # Remove Cutter - delete_cutter(self.cutter) - - elif self.mode == 'MODIFIER': - # Set Cutter Properties - canvas = None - if context.active_object and context.active_object in self.selected_objects: - canvas = context.active_object - else: - canvas = self.selected_objects[0] - - set_cutter_properties(context, canvas, self.cutter, "Difference", parent=self.parent, hide=self.hide) - - - -#### ------------------------------ REGISTRATION ------------------------------ #### - -classes = [ - OBJECT_OT_carve, - TOPBAR_PT_carver_shape, - TOPBAR_PT_carver_array, - TOPBAR_PT_carver_cutter, -] - -def register(): - for cls in classes: - bpy.utils.register_class(cls) - -def unregister(): - for cls in classes: - bpy.utils.unregister_class(cls) diff --git a/tools/carver_box.py b/tools/carver_box.py new file mode 100644 index 0000000..7c61102 --- /dev/null +++ b/tools/carver_box.py @@ -0,0 +1,299 @@ +import bpy, mathutils, math, os + +from .common import ( + CarverModifierKeys, + CarverBase, + carver_ui_common, +) +from ..functions.draw import ( + carver_overlay, +) +from ..functions.select import ( + cursor_snap, + selection_fallback, +) + + +#### ------------------------------ TOOLS ------------------------------ #### + +class OBJECT_WT_carve_box(bpy.types.WorkSpaceTool): + bl_idname = "object.carve_box" + bl_label = "Box Carve" + bl_description = ("Boolean cut primitive shapes into mesh objects by drawing rectangles with cursor") + + bl_space_type = 'VIEW_3D' + bl_context_mode = 'OBJECT' + + bl_icon = os.path.join(os.path.join(os.path.dirname(os.path.dirname(__file__)), "icons") , "ops.object.carver_box") + # bl_widget = 'VIEW3D_GGT_placement' + bl_keymap = ( + ("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG'}, None), + ("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "shift": True}, None), + ("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "alt": True}, None), + ("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "shift": True, "alt": True}, None), + ("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True}, None), + ("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "shift": True}, None), + ("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "alt": True}, None), + ("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "shift": True, "alt": True}, None), + ) + + def draw_settings(context, layout, tool): + props = tool.operator_properties("object.carve_box") + carver_ui_common(context, layout, props) + + +class MESH_WT_carve_box(OBJECT_WT_carve_box): + bl_context_mode = 'EDIT_MESH' + + +# class OBJECT_WT_carve_circle(bpy.types.WorkSpaceTool, CarverUserInterface): +# bl_idname = "object.carve_circle" +# bl_label = "Circle Carve" +# bl_description = ("Boolean cut circlular shapes into mesh objects") + +# bl_space_type = 'VIEW_3D' +# bl_context_mode = 'OBJECT' + +# bl_icon = os.path.join(os.path.join(os.path.dirname(os.path.dirname(__file__)), "icons") , "ops.object.carver_circle") +# # bl_widget = 'VIEW3D_GGT_placement' +# bl_keymap = ( +# ("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG'}, {"properties": [("shape", 'CIRCLE')]}), +# ("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "shift": True}, {"properties": [("shape", 'CIRCLE')]}), +# ("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "alt": True}, {"properties": [("shape", 'CIRCLE')]}), +# ("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "shift": True, "alt": True}, {"properties": [("shape", 'CIRCLE')]}), +# ("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True}, {"properties": [("shape", 'CIRCLE')]}), +# ("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "shift": True}, {"properties": [("shape", 'CIRCLE')]}), +# ("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "alt": True}, {"properties": [("shape", 'CIRCLE')]}), +# ("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "shift": True, "alt": True}, {"properties": [("shape", 'CIRCLE')]}), +# ) + +# class MESH_WT_carve_circle(OBJECT_WT_carve_circle): +# bl_context_mode = 'EDIT_MESH' + + + +#### ------------------------------ OPERATORS ------------------------------ #### + +class OBJECT_OT_carve_box(CarverBase, CarverModifierKeys, bpy.types.Operator): + bl_idname = "object.carve_box" + bl_label = "Box Carve" + bl_description = "Boolean cut square shapes into mesh objects" + bl_options = {'REGISTER', 'UNDO', 'DEPENDS_ON_CURSOR'} + bl_cursor_pending = 'PICK_AREA' + + shape: bpy.props.EnumProperty( + name = "Shape", + items = (('BOX', "Box", ""), + ('CIRCLE', "Circle", ""), + ('POLYLINE', "Polyline", "")), + default = 'BOX', + ) + + # SHAPE-properties + aspect: bpy.props.EnumProperty( + name = "Aspect", + items = (('FREE', "Free", "Use an unconstrained aspect"), + ('FIXED', "Fixed", "Use a fixed 1:1 aspect")), + default = 'FREE', + ) + origin: bpy.props.EnumProperty( + name = "Origin", + description = "The initial position for placement", + items = (('EDGE', "Edge", ""), + ('CENTER', "Center", "")), + default = 'EDGE', + ) + rotation: bpy.props.FloatProperty( + name = "Rotation", + subtype = "ANGLE", + soft_min = -360, soft_max = 360, + default = 0, + ) + subdivision: bpy.props.IntProperty( + name = "Circle Subdivisions", + description = "Number of vertices that will make up the circular shape that will be extruded into a cylinder", + min = 3, soft_max = 128, + default = 16, + ) + + # BEVEL-properties + use_bevel: bpy.props.BoolProperty( + name = "Bevel Cutter", + description = "Bevel each side edge of the cutter", + default = False, + ) + bevel_profile: bpy.props.EnumProperty( + name = "Bevel Profile", + items = (('CONVEX', "Convex", "Outside bevel (rounded corners)"), + ('CONCAVE', "Concave", "Inside bevel")), + default = 'CONVEX', + ) + bevel_segments: bpy.props.IntProperty( + name = "Bevel Segments", + description = "Segments for curved edge", + min = 2, soft_max = 32, + default = 8, + ) + bevel_radius: bpy.props.FloatProperty( + name = "Bevel Radius", + description = "Amout of the bevel (in screen-space units)", + min = 0.01, soft_max = 5, + default = 1, + ) + + + @classmethod + def poll(cls, context): + return context.mode in ('OBJECT', 'EDIT_MESH') + + + def __init__(self): + self.mouse_path = [(0, 0), (0, 0)] + self.view_vector = mathutils.Vector() + self.verts = [] + self.cutter = None + self.duplicates = [] + + args = (self, bpy.context, 'BOX') + self._handle = bpy.types.SpaceView3D.draw_handler_add(carver_overlay, args, 'WINDOW', 'POST_PIXEL') + + # Modifier Keys + self.snap = False + self.move = False + self.rotate = False + self.gap = False + self.bevel = False + + # Cache + self.initial_origin = self.origin + self.initial_aspect = self.aspect + self.cached_mouse_position = () + + # overlay_position + self.position_x = 0 + self.position_y = 0 + self.initial_position = False + self.center_origin = [] + self.distance_from_first = 0 + + + def modal(self, context, event): + # Tool Settings Text + snap_text = ", [MOUSEWHEEL]: Change Snapping Increment" if self.snap else "" + shape_text = "[SHIFT]: Aspect, [ALT]: Origin, [R]: Rotate, [ARROWS]: Array" + array_text = ", [A]: Gap" if (self.rows > 1 or self.columns > 1) else "" + bevel_text = ", [B]: Bevel" + context.area.header_text_set("[CTRL]: Snap Invert, [SPACEBAR]: Move, " + shape_text + bevel_text + array_text + snap_text) + + # find_the_limit_of_the_3d_viewport_region + self.redraw_region(context) + + + # Modifier Keys + self.modifier_snap(context, event) + self.modifier_aspect(context, event) + self.modifier_origin(context, event) + self.modifier_rotate(context, event) + self.modifier_bevel(context, event) + self.modifier_array(context, event) + self.modifier_move(context, event) + + if event.type in {'NUMPAD_1', 'NUMPAD_2', 'NUMPAD_3', 'NUMPAD_4', + 'NUMPAD_5', 'NUMPAD_6', 'NUMPAD_7', 'NUMPAD_8', 'NUMPAD_9', + 'MIDDLEMOUSE', 'N'}: + return {'PASS_THROUGH'} + + if self.bevel == False and event.type in {'WHEELUPMOUSE', 'WHEELDOWNMOUSE'}: + return {'PASS_THROUGH'} + + + # Mouse Move + if event.type == 'MOUSEMOVE': + # rotate + if self.rotate: + self.rotation = event.mouse_region_x * 0.01 + + # move + elif self.move: + self.position_x += (event.mouse_region_x - self.last_mouse_region_x) + self.position_y += (event.mouse_region_y - self.last_mouse_region_y) + self.last_mouse_region_x = event.mouse_region_x + self.last_mouse_region_y = event.mouse_region_y + + # array + elif self.gap: + self.rows_gap = event.mouse_region_x * 0.1 + self.columns_gap = event.mouse_region_y * 0.1 + + # bevel + elif self.bevel: + self.bevel_radius = event.mouse_region_x * 0.002 + + # Draw Shape + else: + if len(self.mouse_path) > 0: + # aspect + if self.aspect == 'FIXED': + side = max(abs(event.mouse_region_x - self.mouse_path[0][0]), + abs(event.mouse_region_y - self.mouse_path[0][1])) + self.mouse_path[len(self.mouse_path) - 1] = \ + (self.mouse_path[0][0] + (side if event.mouse_region_x >= self.mouse_path[0][0] else -side), + self.mouse_path[0][1] + (side if event.mouse_region_y >= self.mouse_path[0][1] else -side)) + + elif self.aspect == 'FREE': + self.mouse_path[len(self.mouse_path) - 1] = (event.mouse_region_x, event.mouse_region_y) + + # snap (find_the_closest_position_on_the_overlay_grid_and_snap_the_shape_to_it) + if self.snap: + cursor_snap(self, context, event, self.mouse_path) + + + # Confirm + elif (event.type == 'LEFTMOUSE' and event.value == 'RELEASE') or (event.type == 'RET' and event.value == 'PRESS'): + # selection_fallback + if len(self.selected_objects) == 0: + self.selected_objects = selection_fallback(self, context, context.view_layer.objects) + for obj in self.selected_objects: + obj.select_set(True) + + if len(self.selected_objects) == 0: + self.report({'INFO'}, "Only selected objects can be carved") + self.cancel(context) + return {'FINISHED'} + else: + empty = self.selection_fallback(context) + if empty: + return {'FINISHED'} + + # protection_against_returning_no_rectangle_by_clicking + delta_x = abs(event.mouse_region_x - self.mouse_path[0][0]) + delta_y = abs(event.mouse_region_y - self.mouse_path[0][1]) + min_distance = 5 + + if delta_x > min_distance or delta_y > min_distance: + self.confirm(context) + return {'FINISHED'} + + + # Cancel + elif event.type in {'RIGHTMOUSE', 'ESC'}: + self.cancel(context) + return {'FINISHED'} + + return {'RUNNING_MODAL'} + + + +#### ------------------------------ REGISTRATION ------------------------------ #### + +classes = [ + OBJECT_OT_carve_box, +] + +def register(): + for cls in classes: + bpy.utils.register_class(cls) + +def unregister(): + for cls in classes: + bpy.utils.unregister_class(cls) diff --git a/tools/carver_polyline.py b/tools/carver_polyline.py new file mode 100644 index 0000000..624bc67 --- /dev/null +++ b/tools/carver_polyline.py @@ -0,0 +1,217 @@ +import bpy, mathutils, math, os + +from .common import ( + CarverModifierKeys, + CarverBase, + carver_ui_common, +) +from ..functions.draw import ( + carver_overlay, +) +from ..functions.select import ( + cursor_snap, + selection_fallback, +) + + +### ------------------------------ TOOLS ------------------------------ #### + +class OBJECT_WT_carve_polyline(bpy.types.WorkSpaceTool): + bl_idname = "object.carve_polyline" + bl_label = "Polyline Carve" + bl_description = ("Boolean cut custom polygonal shapes into mesh objects") + + bl_space_type = 'VIEW_3D' + bl_context_mode = 'OBJECT' + + bl_icon = os.path.join(os.path.join(os.path.dirname(os.path.dirname(__file__)), "icons") , "ops.object.carver_polyline") + # bl_widget = 'VIEW3D_GGT_placement' + bl_keymap = ( + ("object.carve_polyline", {"type": 'LEFTMOUSE', "value": 'CLICK'}, None), + ("object.carve_polyline", {"type": 'LEFTMOUSE', "value": 'CLICK', "ctrl": True}, None), + # select + ("view3d.select_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG'}, None), + ("view3d.select_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "shift": True}, {"properties": [("mode", 'ADD')]}), + ("view3d.select_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True}, {"properties": [("mode", 'SUB')]}), + ) + + def draw_settings(context, layout, tool): + props = tool.operator_properties("object.carve_polyline") + carver_ui_common(context, layout, props) + + +class MESH_WT_carve_polyline(OBJECT_WT_carve_polyline): + bl_context_mode = 'EDIT_MESH' + + + +### ------------------------------ OPERATORS ------------------------------ #### + +class OBJECT_OT_carve_polyline(CarverBase, CarverModifierKeys, bpy.types.Operator): + bl_idname = "object.carve_polyline" + bl_label = "Polyline Carve" + bl_description = "Boolean cut custom polygonal shapes into mesh objects" + bl_options = {'REGISTER', 'UNDO', 'DEPENDS_ON_CURSOR'} + bl_cursor_pending = 'PICK_AREA' + + # SHAPE-properties + closed: bpy.props.BoolProperty( + name = "Closed Polygon", + description = "When enabled, mouse position at the moment of execution will be registered as last point of the polygon", + default = True, + ) + + @classmethod + def poll(cls, context): + return context.mode in ('OBJECT', 'EDIT_MESH') + + + def __init__(self): + self.mouse_path = [(0, 0), (0, 0)] + self.view_vector = mathutils.Vector() + self.verts = [] + self.cutter = None + self.duplicates = [] + + args = (self, bpy.context, 'POLYLINE') + self._handle = bpy.types.SpaceView3D.draw_handler_add(carver_overlay, args, 'WINDOW', 'POST_PIXEL') + + # Modifier Keys + self.snap = False + self.move = False + self.gap = False + + # Cache + self.cached_mouse_position = () + + # overlay_position + self.position_x = 0 + self.position_y = 0 + self.initial_position = False + self.center_origin = [] + self.distance_from_first = 0 + + + def modal(self, context, event): + # Tool Settings Text + snap_text = ", [MOUSEWHEEL]: Change Snapping Increment" if self.snap else "" + shape_text = "[BACKSPACE]: Remove Last Point, [ENTER]: Confirm" + array_text = ", [A]: Gap" if (self.rows > 1 or self.columns > 1) else "" + context.area.header_text_set("[CTRL]: Snap Invert, [SPACEBAR]: Move, " + shape_text + array_text + snap_text) + + # find_the_limit_of_the_3d_viewport_region + self.redraw_region(context) + + + # Modifier Keys + self.modifier_snap(context, event) + self.modifier_array(context, event) + self.modifier_move(context, event) + + # remove_last_point + if event.type == 'BACK_SPACE' and event.value == 'PRESS': + if len(self.mouse_path) > 2: + context.window.cursor_warp(self.mouse_path[-2][0], self.mouse_path[-2][1]) + self.mouse_path = self.mouse_path[:-2] + + if event.type in {'NUMPAD_1', 'NUMPAD_2', 'NUMPAD_3', 'NUMPAD_4', + 'NUMPAD_5', 'NUMPAD_6', 'NUMPAD_7', 'NUMPAD_8', 'NUMPAD_9', + 'WHEELUPMOUSE', 'WHEELDOWNMOUSE', 'MIDDLEMOUSE', 'N'}: + return {'PASS_THROUGH'} + + + # Mouse Move + if event.type == 'MOUSEMOVE': + # move + if self.move: + self.position_x += (event.mouse_region_x - self.last_mouse_region_x) + self.position_y += (event.mouse_region_y - self.last_mouse_region_y) + self.last_mouse_region_x = event.mouse_region_x + self.last_mouse_region_y = event.mouse_region_y + + # array + elif self.gap: + self.rows_gap = event.mouse_region_x * 0.1 + self.columns_gap = event.mouse_region_y * 0.1 + + # Draw Shape + else: + if len(self.mouse_path) > 0: + self.mouse_path[len(self.mouse_path) - 1] = (event.mouse_region_x, event.mouse_region_y) + + # snap (find_the_closest_position_on_the_overlay_grid_and_snap_the_shape_to_it) + if self.snap: + cursor_snap(self, context, event, self.mouse_path) + + # get_distance_from_first_point + distance = math.sqrt((self.mouse_path[-1][0] - self.mouse_path[0][0]) ** 2 + + (self.mouse_path[-1][1] - self.mouse_path[0][1]) ** 2) + min_radius = 0 + max_radius = 30 + self.distance_from_first = max(max_radius - distance, min_radius) + + + # Add Points & Confirm + elif (event.type == 'LEFTMOUSE' and event.value == 'RELEASE') or (event.type == 'RET' and event.value == 'PRESS'): + # selection_fallback (expand_selection_fallback_on_every_polyline_click) + if len(self.initial_selection) == 0: + self.selected_objects = selection_fallback(self, context, context.view_layer.objects, polyline=True) + for obj in self.selected_objects: + obj.select_set(True) + + + # add_new_points + if not (event.type == 'RET' and event.value == 'PRESS') and (self.distance_from_first < 15): + self.mouse_path.append((event.mouse_region_x, event.mouse_region_y)) + if self.closed == False: + """NOTE: Additional vert is needed for open loop.""" + self.mouse_path.append((event.mouse_region_x, event.mouse_region_y)) + + # confirm_cut + else: + if self.closed == False: + self.verts.pop() # dont_add_current_mouse_position_as_vert + + if self.distance_from_first > 15: + self.verts[-1] = self.verts[0] + + if len(self.verts) / 2 <= 1: + self.report({'INFO'}, "At least two points are required to make polygonal shape") + self.cancel(context) + return {'FINISHED'} + + if self.closed and self.mouse_path[-1] == self.mouse_path[-2]: + context.window.cursor_warp(event.mouse_region_x - 1, event.mouse_region_y) + + """NOTE: Polyline needs separate selection fallback, because it needs to calculate selection bounding box...""" + """NOTE: after all points are already drawn, i.e. before execution.""" + empty = self.selection_fallback(context, polyline=True) + if empty: + return {'FINISHED'} + + self.confirm(context) + return {'FINISHED'} + + + # Cancel + elif event.type in {'RIGHTMOUSE', 'ESC'}: + self.cancel(context) + return {'FINISHED'} + + return {'RUNNING_MODAL'} + + + +### ------------------------------ REGISTRATION ------------------------------ #### + +classes = [ + OBJECT_OT_carve_polyline, +] + +def register(): + for cls in classes: + bpy.utils.register_class(cls) + +def unregister(): + for cls in classes: + bpy.utils.unregister_class(cls) diff --git a/tools/circle.py b/tools/circle.py deleted file mode 100644 index 0a1da39..0000000 --- a/tools/circle.py +++ /dev/null @@ -1,63 +0,0 @@ -import bpy, os - -from ..functions.draw import( - carver_brush, -) -from ..functions.select import( - raycast_from_cursor, -) - - -from ..properties import ToolRuntimeData -tool_runtime_data = ToolRuntimeData() - -#### ------------------------------ TOOLS ------------------------------ #### - -class OBJECT_WT_carve_circle(bpy.types.WorkSpaceTool): - bl_idname = "object.carve_circle" - bl_label = "Circle Carve" - bl_description = ("Boolean cut primitive shapes into mesh objects with fixed-size brush") - - bl_space_type = 'VIEW_3D' - bl_context_mode = 'OBJECT' - - bl_icon = os.path.join(os.path.join(os.path.dirname(os.path.dirname(__file__)), "icons") , "ops.object.carver_circle") - # bl_widget = 'VIEW3D_GGT_placement' - # bl_keymap = ( - # ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG'}, {"properties": [("shape", 'BOX')]}), - # ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "shift": True}, {"properties": [("shape", 'BOX')]}), - # ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "alt": True}, {"properties": [("shape", 'BOX')]}), - # ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "shift": True, "alt": True}, {"properties": [("shape", 'BOX')]}), - # ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True}, {"properties": [("shape", 'BOX')]}), - # ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "shift": True}, {"properties": [("shape", 'BOX')]}), - # ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "alt": True}, {"properties": [("shape", 'BOX')]}), - # ("object.carve", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "shift": True, "alt": True}, {"properties": [("shape", 'BOX')]}), - # ) - bl_keymap = ( - ("wm.radial_control", {"type": 'F', "value": 'PRESS'}, {"properties": [("data_path_primary", 'tool_settings.unified_paint_settings.size')]}), - ) - - @staticmethod - def draw_cursor(context, tool, xy): - if context.active_object: - obj = context.active_object - brush = context.tool_settings.unified_paint_settings - wm = context.window_manager.carver - - # Raycast - region = context.region - rv3d = context.region_data - result, location, normal = raycast_from_cursor(region, rv3d, obj, xy) - tool_runtime_data.update_raycast_status(result, obj.matrix_world, location, normal) - - if result: - tool_runtime_data.update_brush_size(wm, brush, obj.matrix_world, location, region, rv3d) - carver_brush('3D', context, obj_matrix=obj.matrix_world, location=location, normal=normal, radius=wm.unprojected_radius) - return - else: - carver_brush('2D', context, radius=brush.size, xy=xy) - return - - -class MESH_WT_carve_circle(OBJECT_WT_carve_circle): - bl_context_mode = 'EDIT_MESH' diff --git a/tools/common.py b/tools/common.py new file mode 100644 index 0000000..1aabb8b --- /dev/null +++ b/tools/common.py @@ -0,0 +1,479 @@ +import bpy, math +from .. import __package__ as base_package + +from ..functions.mesh import ( + create_cutter_shape, + extrude, + shade_smooth_by_angle, +) +from ..functions.object import ( + add_boolean_modifier, + set_cutter_properties, + delete_cutter, + set_object_origin, +) +from ..functions.select import ( + selection_fallback, +) + + +#### ------------------------------ OPERATORS ------------------------------ #### + + +class CarverModifierKeys(): + # Snap + def modifier_snap(self, context, event): + self.snap = context.scene.tool_settings.use_snap + if (hasattr(self, "move") and self.move == False) and (hasattr(self, "rotate") and self.rotate == False): + # change_the_snap_increment_value_using_the_wheel_mouse + for i, a in enumerate(context.screen.areas): + if a.type == 'VIEW_3D': + space = context.screen.areas[i].spaces.active + + if event.type == 'WHEELUPMOUSE': + space.overlay.grid_subdivisions -= 1 + elif event.type == 'WHEELDOWNMOUSE': + space.overlay.grid_subdivisions += 1 + + # invert_snapping + if event.ctrl: + self.snap = not self.snap + + # Aspect + def modifier_aspect(self, context, event): + if event.shift: + if self.initial_aspect == 'FREE': + self.aspect = 'FIXED' + elif self.initial_aspect == 'FIXED': + self.aspect = 'FREE' + else: + self.aspect = self.initial_aspect + + # Origin + def modifier_origin(self, context, event): + if event.alt: + if self.initial_origin == 'EDGE': + self.origin = 'CENTER' + elif self.initial_origin == 'CENTER': + self.origin = 'EDGE' + else: + self.origin = self.initial_origin + + # Rotate + def modifier_rotate(self, context, event): + if event.type == 'R': + if event.value == 'PRESS': + self.cached_mouse_position = (self.mouse_path[1][0], self.mouse_path[1][1]) + context.window.cursor_set("NONE") + self.rotate = True + elif event.value == 'RELEASE': + context.window.cursor_set("MUTE") + context.window.cursor_warp(int(self.cached_mouse_position[0]), int(self.cached_mouse_position[1])) + self.rotate = False + + # Bevel + def modifier_bevel(self, context, event): + if event.type == 'B': + if event.value == 'PRESS': + self.use_bevel = True + self.cached_mouse_position = (self.mouse_path[1][0], self.mouse_path[1][1]) + context.window.cursor_set("NONE") + self.bevel = True + elif event.value == 'RELEASE': + context.window.cursor_set("MUTE") + context.window.cursor_warp(int(self.cached_mouse_position[0]), int(self.cached_mouse_position[1])) + self.bevel = False + + if self.bevel: + if event.type == 'WHEELUPMOUSE': + self.bevel_segments += 1 + elif event.type == 'WHEELDOWNMOUSE': + self.bevel_segments -= 1 + + # Array + def modifier_array(self, context, event): + if event.type == 'LEFT_ARROW' and event.value == 'PRESS': + self.rows -= 1 + if event.type == 'RIGHT_ARROW' and event.value == 'PRESS': + self.rows += 1 + if event.type == 'DOWN_ARROW' and event.value == 'PRESS': + self.columns -= 1 + if event.type == 'UP_ARROW' and event.value == 'PRESS': + self.columns += 1 + + if (self.rows > 1 or self.columns > 1) and (event.type == 'A'): + if event.value == 'PRESS': + self.cached_mouse_position = (self.mouse_path[1][0], self.mouse_path[1][1]) + context.window.cursor_set("NONE") + self.gap = True + elif event.value == 'RELEASE': + context.window.cursor_set("MUTE") + context.window.cursor_warp(self.cached_mouse_position[0], self.cached_mouse_position[1]) + self.gap = False + + # Move + def modifier_move(self, context, event): + if event.type == 'SPACE': + if event.value == 'PRESS': + self.move = True + elif event.value == 'RELEASE': + self.move = False + + if self.move: + # initial_position_variable_before_moving_the_brush + if self.initial_position is False: + self.position_x = 0 + self.position_y = 0 + self.last_mouse_region_x = event.mouse_region_x + self.last_mouse_region_y = event.mouse_region_y + self.initial_position = True + self.move = True + + # update_the_coordinates + if self.initial_position and self.move is False: + for i in range(0, len(self.mouse_path)): + l = list(self.mouse_path[i]) + l[0] += self.position_x + l[1] += self.position_y + self.mouse_path[i] = tuple(l) + + self.position_x = self.position_y = 0 + self.initial_position = False + + + # Remove Point (Polyline) + if event.type == 'BACK_SPACE' and event.value == 'PRESS': + if len(self.mouse_path) > 2: + context.window.cursor_warp(self.mouse_path[-2][0], self.mouse_path[-2][1]) + self.mouse_path = self.mouse_path[:-2] + + +class CarverBase(): + # OPERATOR-properties + mode: bpy.props.EnumProperty( + name = "Mode", + items = (('DESTRUCTIVE', "Destructive", "Boolean cutters are immediatelly applied and removed after the cut", 'MESH_DATA', 0), + ('MODIFIER', "Modifier", "Cuts are stored as boolean modifiers and cutters placed inside the collection", 'MODIFIER_DATA', 1)), + default = 'DESTRUCTIVE', + ) + # orientation: bpy.props.EnumProperty( + # name = "Orientation", + # items = (('SURFACE', "Surface", "Surface normal of the mesh under the cursor"), + # ('VIEW', "View", "View-aligned orientation")), + # default = 'SURFACE', + # ) + depth: bpy.props.EnumProperty( + name = "Depth", + items = (('VIEW', "View", "Depth is automatically calculated from view orientation", 'VIEW_CAMERA_UNSELECTED', 0), + ('CURSOR', "Cursor", "Depth is automatically set at 3D cursor location", 'PIVOT_CURSOR', 1)), + default = 'VIEW', + ) + + # CUTTER-properties + hide: bpy.props.BoolProperty( + name = "Hide Cutter", + description = ("Hide cutter objects in the viewport after they're created.\n" + "NOTE: They are hidden in render regardless of this property"), + default = True, + ) + parent: bpy.props.BoolProperty( + name = "Parent to Canvas", + description = ("Cutters will be parented to active object being cut, even if cutting multiple objects.\n" + "If there is no active object in selection cutters parent might be chosen seemingly randomly"), + default = True, + ) + auto_smooth: bpy.props.BoolProperty( + name = "Shade Auto Smooth", + description = ("Cutter object will be shaded smooth with sharp edges (above 30 degrees) marked as sharp\n" + "NOTE: This is one time operator. 'Smooth by Angle' modifier will not be added on object"), + default = True, + ) + sharp_angle: bpy.props.FloatProperty( + name = "Angle", + description = "Maximum face angle for sharp edges", + subtype = "ANGLE", + min = 0, max = math.pi, + default = 0.523599, + ) + + # MODIFIER-properties + solver: bpy.props.EnumProperty( + name = "Solver", + items = [('FAST', "Fast", ""), + ('EXACT', "Exact", "")], + default = 'FAST', + ) + pin: bpy.props.BoolProperty( + name = "Pin Boolean Modifier", + description = ("When enabled boolean modifier will be moved above every other modifier on the object (if there are any).\n" + "Order of modifiers can drastically affect the result (especially in destructive mode)"), + default = True, + ) + + + # ARRAY-properties + rows: bpy.props.IntProperty( + name = "Rows", + description = "Number of times shape is duplicated on X axis", + min = 1, soft_max = 16, + default = 1, + ) + rows_gap: bpy.props.FloatProperty( + name = "Gap between Rows", + min = 0, soft_max = 250, + default = 50, + ) + rows_direction: bpy.props.EnumProperty( + name = "Direction of Rows", + items = (('LEFT', "Left", ""), + ('RIGHT', "Right", "")), + default = 'RIGHT', + ) + + columns: bpy.props.IntProperty( + name = "Columns", + description = "Number of times shape is duplicated on Y axis", + min = 1, soft_max = 16, + default = 1, + ) + columns_direction: bpy.props.EnumProperty( + name = "Direction of Rows", + items = (('UP', "Up", ""), + ('DOWN', "Down", "")), + default = 'DOWN', + ) + columns_gap: bpy.props.FloatProperty( + name = "Gap between Columns", + min = 0, soft_max = 250, + default = 50, + ) + + + def invoke(self, context, event): + if context.area.type != 'VIEW_3D': + self.report({'WARNING'}, "Carver tool can only be called from 3D viewport") + self.cancel(context) + return {'CANCELLED'} + + self.selected_objects = context.selected_objects + self.initial_selection = context.selected_objects + self.mouse_path[0] = (event.mouse_region_x, event.mouse_region_y) + self.mouse_path[1] = (event.mouse_region_x, event.mouse_region_y) + + context.window.cursor_set("MUTE") + context.window_manager.modal_handler_add(self) + return {'RUNNING_MODAL'} + + + def redraw_region(self, context): + """Redraw region to find the limits of the 3D viewport""" + + region_types = {'WINDOW', 'UI'} + for area in context.window.screen.areas: + if area.type == 'VIEW_3D': + for region in area.regions: + if not region_types or region.type in region_types: + region.tag_redraw() + + + def selection_fallback(self, context, polyline=False): + # filter_out_objects_not_inside_the_selection_bounding_box + self.selected_objects = selection_fallback(self, context, self.selected_objects, polyline=polyline, include_cutters=True) + + # silently_fail_if_no_objects_inside_selection_bounding_box + empty = False + if len(self.selected_objects) == 0: + self.cancel(context) + empty = True + + return empty + + + def confirm(self, context): + create_cutter_shape(self, context) + extrude(self, self.cutter.data) + set_object_origin(self.cutter) + if self.auto_smooth: + shade_smooth_by_angle(self.cutter, angle=math.degrees(self.sharp_angle)) + + self.Cut(context) + self.cancel(context) + + + def cancel(self, context): + bpy.types.SpaceView3D.draw_handler_remove(self._handle, 'WINDOW') + context.area.header_text_set(None) + context.window.cursor_set('DEFAULT' if context.object.mode == 'OBJECT' else 'CROSSHAIR') + + + def Cut(self, context): + # ensure_active_object + if not context.active_object: + context.view_layer.objects.active = self.selected_objects[0] + + # Add Modifier + for obj in self.selected_objects: + if self.mode == 'DESTRUCTIVE': + add_boolean_modifier(self, obj, self.cutter, "DIFFERENCE", self.solver, apply=True, pin=self.pin, redo=False) + elif self.mode == 'MODIFIER': + add_boolean_modifier(self, obj, self.cutter, "DIFFERENCE", self.solver, pin=self.pin, redo=False) + obj.booleans.canvas = True + + if self.mode == 'DESTRUCTIVE': + # Remove Cutter + delete_cutter(self.cutter) + + elif self.mode == 'MODIFIER': + # Set Cutter Properties + canvas = None + if context.active_object and context.active_object in self.selected_objects: + canvas = context.active_object + else: + canvas = self.selected_objects[0] + + set_cutter_properties(context, canvas, self.cutter, "Difference", parent=self.parent, hide=self.hide) + + + +#### ------------------------------ PANELS ------------------------------ #### + +def carver_ui_common(context, layout, props): + """Tool properties common for all Carver operators""" + + layout.prop(props, "mode", text="") + layout.prop(props, "depth", text="") + row = layout.row() + row.prop(props, "solver", expand=True) + + if context.object: + layout.popover("TOPBAR_PT_carver_shape", text="Shape") + layout.popover("TOPBAR_PT_carver_array", text="Array") + layout.popover("TOPBAR_PT_carver_cutter", text="Cutter") + + +class TOPBAR_PT_carver_shape(bpy.types.Panel): + bl_label = "Carver Shape" + bl_idname = "TOPBAR_PT_carver_shape" + bl_region_type = 'HEADER' + bl_space_type = 'TOPBAR' + bl_category = 'Tool' + + def draw(self, context): + layout = self.layout + layout.use_property_split = True + layout.use_property_decorate = False + + prefs = context.preferences.addons[base_package].preferences + mode = "OBJECT" if context.object.mode == 'OBJECT' else "EDIT_MESH" + tool = context.workspace.tools.from_space_view3d_mode(mode, create=False) + + # Box Properties + if tool.idname == "object.carve_box": + props = tool.operator_properties("object.carve_box") + + if tool.idname == "object.carve_circle": + layout.prop(props, "subdivision", text="Vertices") + layout.prop(props, "rotation") + layout.prop(props, "aspect", expand=True) + layout.prop(props, "origin", expand=True) + + if tool.idname == 'object.carve_box': + layout.separator() + layout.prop(props, "use_bevel", text="Bevel") + col = layout.column(align=True) + row = col.row(align=True) + if prefs.experimental: + row.prop(props, "bevel_profile", text="Profile", expand=True) + col.prop(props, "bevel_segments", text="Segments") + col.prop(props, "bevel_radius", text="Radius") + + if props.use_bevel == False: + col.enabled = False + + # Polyline Properties + elif tool.idname == "object.carve_polyline": + props = tool.operator_properties("object.carve_polyline") + layout.prop(props, "closed") + + +class TOPBAR_PT_carver_array(bpy.types.Panel): + bl_label = "Carver Array" + bl_idname = "TOPBAR_PT_carver_array" + bl_region_type = 'HEADER' + bl_space_type = 'TOPBAR' + bl_category = 'Tool' + + def draw(self, context): + layout = self.layout + layout.use_property_split = True + layout.use_property_decorate = False + + mode = "OBJECT" if context.object.mode == 'OBJECT' else "EDIT_MESH" + tool = context.workspace.tools.from_space_view3d_mode(mode, create=False) + if tool.idname == "object.carve_box": + props = tool.operator_properties("object.carve_box") + elif tool.idname == "object.carve_polyline": + props = tool.operator_properties("object.carve_polyline") + + col = layout.column(align=True) + col.prop(props, "rows") + row = col.row(align=True) + row.prop(props, "rows_direction", text="Direction", expand=True) + col.prop(props, "rows_gap", text="Gap") + + layout.separator() + col = layout.column(align=True) + col.prop(props, "columns") + row = col.row(align=True) + row.prop(props, "columns_direction", text="Direction", expand=True) + col.prop(props, "columns_gap", text="Gap") + + +class TOPBAR_PT_carver_cutter(bpy.types.Panel): + bl_label = "Carver Cutter" + bl_idname = "TOPBAR_PT_carver_cutter" + bl_region_type = 'HEADER' + bl_space_type = 'TOPBAR' + bl_category = 'Tool' + + def draw(self, context): + layout = self.layout + layout.use_property_split = True + layout.use_property_decorate = False + + mode = "OBJECT" if context.object.mode == 'OBJECT' else "EDIT_MESH" + tool = context.workspace.tools.from_space_view3d_mode(mode, create=False) + if tool.idname == "object.carve_box": + props = tool.operator_properties("object.carve_box") + elif tool.idname == "object.carve_polyline": + props = tool.operator_properties("object.carve_polyline") + + col = layout.column() + col.prop(props, "pin", text="Pin Modifier") + if props.mode == 'MODIFIER': + col.prop(props, "parent") + col.prop(props, "hide") + + # auto_smooth + layout.separator() + col = layout.column(align=True) + col.prop(props, "auto_smooth", text="Auto Smooth") + col.prop(props, "sharp_angle") + + + +#### ------------------------------ REGISTRATION ------------------------------ #### + +classes = [ + TOPBAR_PT_carver_shape, + TOPBAR_PT_carver_array, + TOPBAR_PT_carver_cutter, +] + +def register(): + for cls in classes: + bpy.utils.register_class(cls) + +def unregister(): + for cls in classes: + bpy.utils.unregister_class(cls) diff --git a/ui.py b/ui.py index 96be109..d52d323 100644 --- a/ui.py +++ b/ui.py @@ -28,9 +28,9 @@ def update_sidebar_category(self, context): def carve_menu(self, context): layout = self.layout - layout.operator("object.carve", text="Box Carve").shape='BOX' - layout.operator("object.carve", text="Circle Carve").shape='CIRCLE' - layout.operator("object.carve", text="Polyline Carve").shape='POLYLINE' + layout.operator("object.carve_box", text="Box Carve").shape='BOX' + layout.operator("object.carve_box", text="Circle Carve").shape='CIRCLE' + layout.operator("object.carve_polyline", text="Polyline Carve").shape='POLYLINE' def boolean_operators_menu(self, context): From 0bb7dabf5cb6644cf97fc04aed2d16d05a6a6209 Mon Sep 17 00:00:00 2001 From: Nika Kutsniashvili Date: Fri, 17 Jan 2025 16:40:17 +0400 Subject: [PATCH 3/4] Initial `carve.circle` operator Shape isn't created in the correct place yet, but it is being created. --- functions/draw.py | 15 +++++ properties.py | 12 +++- tools/carver_circle.py | 132 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 tools/carver_circle.py diff --git a/functions/draw.py b/functions/draw.py index 864ecf6..20393e5 100644 --- a/functions/draw.py +++ b/functions/draw.py @@ -162,11 +162,24 @@ def draw_circle_2d(x, y, segments): # draw_shader(secondary_color, 0.8, 'LINES', [(0, 0, 0), (0, 0, -2)], size=2) + # convert_vertices_to_world_space_(to_be_used_for_mesh_creation) + world_vertices = [] + for vertex in rectangle: + screen_pos = mathutils.Vector((vertex[0] * region.width, vertex[1] * region.height)) + world_pos = view3d_utils.region_2d_to_location_3d(region, rv3d, screen_pos, aligned_y_axis) + world_pos = obj_matrix @ world_pos + world_pos = rotation_matrix @ world_pos + world_pos = world_pos * radius + world_vertices.append(world_pos) + + # Reset Matrix gpu.matrix.pop() gpu.matrix.pop_projection() gpu.state.viewport_set(0, 0, window.width, window.height) + return world_vertices, indices + elif mode == '2D': gpu.matrix.translate(xy) @@ -179,6 +192,8 @@ def draw_circle_2d(x, y, segments): draw_shader(primary_color, 1.0, 'OUTLINE', circle_2d, size=2) draw_shader(primary_color, 0.4, 'SOLID', rectangle, size=2, indices=indices) + return + def draw_polygon(self): """Returns polygonal 2d shape in which each cursor click is taken as a new vertice""" diff --git a/properties.py b/properties.py index 593d598..60e1559 100644 --- a/properties.py +++ b/properties.py @@ -7,8 +7,8 @@ #### ------------------------------ PROPERTIES ------------------------------ #### -class ToolRuntimeData: - """Runtime Data for Circle Carve Tool""" +class CarverRuntimeData: + """Runtime Data for Circle Carve tool""" def __init__(self): self.raycast = False @@ -18,7 +18,8 @@ def __init__(self): self.rad_3d = 0.0 self.brush_size = 0.0 - # self.pack_source_circle = False + self.verts = [] + self.indices = [] def update_raycast_status(self, raycast, obj_matrix, location, normal): self.raycast = raycast @@ -38,6 +39,11 @@ def update_brush_size(self, wm, brush, obj_matrix, location, region, rv3d): else: brush.size = self.brush_size = calc_projected_radius(obj_matrix, self.loc_world, region, rv3d, wm.unprojected_radius) + def update_verts(self, wm, verts, indices): + self.verts = verts + self.indices = indices + + class OBJECT_PG_booleans(bpy.types.PropertyGroup): # OBJECT-level Properties diff --git a/tools/carver_circle.py b/tools/carver_circle.py new file mode 100644 index 0000000..eb605e1 --- /dev/null +++ b/tools/carver_circle.py @@ -0,0 +1,132 @@ +import bpy, mathutils, math, os + +from .common import ( + CarverBase, + carver_ui_common, +) +from ..functions.draw import( + carver_brush, +) +from ..functions.mesh import ( + create_cutter_shape, + extrude, + shade_smooth_by_angle, +) +from ..functions.object import ( + add_boolean_modifier, + set_cutter_properties, + delete_cutter, + set_object_origin, +) +from ..functions.select import( + raycast_from_cursor, +) + + +from ..properties import CarverRuntimeData +tool_runtime_data = CarverRuntimeData() + +#### ------------------------------ TOOLS ------------------------------ #### + +class OBJECT_WT_carve_circle(bpy.types.WorkSpaceTool): + bl_idname = "object.carve_circle" + bl_label = "Circle Carve" + bl_description = ("Boolean cut primitive shapes into mesh objects with fixed-size brush") + + bl_space_type = 'VIEW_3D' + bl_context_mode = 'OBJECT' + + bl_icon = os.path.join(os.path.join(os.path.dirname(os.path.dirname(__file__)), "icons") , "ops.object.carver_circle") + # bl_widget = 'VIEW3D_GGT_placement' + bl_keymap = ( + ("object.carve_circle", {"type": 'LEFTMOUSE', "value": 'PRESS'}, {"properties": None}), + ("wm.radial_control", {"type": 'F', "value": 'PRESS'}, {"properties": [("data_path_primary", 'tool_settings.unified_paint_settings.size')]}), + ) + + @staticmethod + def draw_cursor(context, tool, xy): + if context.active_object: + obj = context.active_object + brush = context.tool_settings.unified_paint_settings + wm = context.window_manager.carver + global tool_runtime_data + + # Raycast + region = context.region + rv3d = context.region_data + result, location, normal = raycast_from_cursor(region, rv3d, obj, xy) + tool_runtime_data.update_raycast_status(result, obj.matrix_world, location, normal) + + if result: + tool_runtime_data.update_brush_size(wm, brush, obj.matrix_world, location, region, rv3d) + rectangle, indices = carver_brush('3D', context, obj_matrix=obj.matrix_world, location=location, normal=normal, radius=wm.unprojected_radius) + tool_runtime_data.update_verts(wm, rectangle, indices) + else: + carver_brush('2D', context, xy=xy, radius=brush.size) + + +class MESH_WT_carve_circle(OBJECT_WT_carve_circle): + bl_context_mode = 'EDIT_MESH' + + + +#### ------------------------------ OPERATORS ------------------------------ #### + +class OBJECT_OT_carve_circle(CarverBase, bpy.types.Operator): + bl_idname = "object.carve_circle" + bl_label = "Circle Carve" + bl_description = "Cut shapes into mesh objects with brush" + bl_options = {'REGISTER', 'UNDO'} + + + @classmethod + def poll(cls, context): + return context.mode in ('OBJECT', 'EDIT_MESH') + + + def __init__(self): + self.mouse_path = [(0, 0), (0, 0)] + self.view_vector = mathutils.Vector() + self.verts = [] + self.cutter = None + self.duplicates = [] + + + def invoke(self, context, event): + global tool_runtime_data + self.verts = tool_runtime_data.verts + + self.selected_objects = context.selected_objects + + return self.execute(context) + + + def execute(self, context): + if self.verts: + print(str(self.verts)) + + create_cutter_shape(self, context) + # extrude(self, self.cutter.data) + # set_object_origin(self.cutter) + # if self.auto_smooth: + # shade_smooth_by_angle(self.cutter, angle=math.degrees(self.sharp_angle)) + + # self.Cut(context) + + return {'FINISHED'} + + + +#### ------------------------------ REGISTRATION ------------------------------ #### + +classes = [ + OBJECT_OT_carve_circle, +] + +def register(): + for cls in classes: + bpy.utils.register_class(cls) + +def unregister(): + for cls in reversed(classes): + bpy.utils.unregister_class(cls) From c79fbdee41c8bae25cc6a99212ef6a96bf6c57ab Mon Sep 17 00:00:00 2001 From: Nika Kutsniashvili Date: Fri, 17 Jan 2025 17:14:42 +0400 Subject: [PATCH 4/4] Clean-up --- tools/carver_box.py | 31 +++---------------------------- tools/carver_polyline.py | 23 ++++++++++++----------- tools/common.py | 12 ++---------- 3 files changed, 17 insertions(+), 49 deletions(-) diff --git a/tools/carver_box.py b/tools/carver_box.py index 7c61102..da5ea27 100644 --- a/tools/carver_box.py +++ b/tools/carver_box.py @@ -1,4 +1,4 @@ -import bpy, mathutils, math, os +import bpy, mathutils, os from .common import ( CarverModifierKeys, @@ -46,38 +46,13 @@ class MESH_WT_carve_box(OBJECT_WT_carve_box): bl_context_mode = 'EDIT_MESH' -# class OBJECT_WT_carve_circle(bpy.types.WorkSpaceTool, CarverUserInterface): -# bl_idname = "object.carve_circle" -# bl_label = "Circle Carve" -# bl_description = ("Boolean cut circlular shapes into mesh objects") - -# bl_space_type = 'VIEW_3D' -# bl_context_mode = 'OBJECT' - -# bl_icon = os.path.join(os.path.join(os.path.dirname(os.path.dirname(__file__)), "icons") , "ops.object.carver_circle") -# # bl_widget = 'VIEW3D_GGT_placement' -# bl_keymap = ( -# ("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG'}, {"properties": [("shape", 'CIRCLE')]}), -# ("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "shift": True}, {"properties": [("shape", 'CIRCLE')]}), -# ("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "alt": True}, {"properties": [("shape", 'CIRCLE')]}), -# ("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "shift": True, "alt": True}, {"properties": [("shape", 'CIRCLE')]}), -# ("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True}, {"properties": [("shape", 'CIRCLE')]}), -# ("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "shift": True}, {"properties": [("shape", 'CIRCLE')]}), -# ("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "alt": True}, {"properties": [("shape", 'CIRCLE')]}), -# ("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "shift": True, "alt": True}, {"properties": [("shape", 'CIRCLE')]}), -# ) - -# class MESH_WT_carve_circle(OBJECT_WT_carve_circle): -# bl_context_mode = 'EDIT_MESH' - - #### ------------------------------ OPERATORS ------------------------------ #### class OBJECT_OT_carve_box(CarverBase, CarverModifierKeys, bpy.types.Operator): bl_idname = "object.carve_box" bl_label = "Box Carve" - bl_description = "Boolean cut square shapes into mesh objects" + bl_description = "Cut shapes into mesh objects with box drawing" bl_options = {'REGISTER', 'UNDO', 'DEPENDS_ON_CURSOR'} bl_cursor_pending = 'PICK_AREA' @@ -295,5 +270,5 @@ def register(): bpy.utils.register_class(cls) def unregister(): - for cls in classes: + for cls in reversed(classes): bpy.utils.unregister_class(cls) diff --git a/tools/carver_polyline.py b/tools/carver_polyline.py index 624bc67..125c1dc 100644 --- a/tools/carver_polyline.py +++ b/tools/carver_polyline.py @@ -14,7 +14,7 @@ ) -### ------------------------------ TOOLS ------------------------------ #### +#### ------------------------------ TOOLS ------------------------------ #### class OBJECT_WT_carve_polyline(bpy.types.WorkSpaceTool): bl_idname = "object.carve_polyline" @@ -45,12 +45,12 @@ class MESH_WT_carve_polyline(OBJECT_WT_carve_polyline): -### ------------------------------ OPERATORS ------------------------------ #### +#### ------------------------------ OPERATORS ------------------------------ #### class OBJECT_OT_carve_polyline(CarverBase, CarverModifierKeys, bpy.types.Operator): bl_idname = "object.carve_polyline" bl_label = "Polyline Carve" - bl_description = "Boolean cut custom polygonal shapes into mesh objects" + bl_description = "Cut custom polygonal shapes into mesh objects" bl_options = {'REGISTER', 'UNDO', 'DEPENDS_ON_CURSOR'} bl_cursor_pending = 'PICK_AREA' @@ -108,12 +108,6 @@ def modal(self, context, event): self.modifier_array(context, event) self.modifier_move(context, event) - # remove_last_point - if event.type == 'BACK_SPACE' and event.value == 'PRESS': - if len(self.mouse_path) > 2: - context.window.cursor_warp(self.mouse_path[-2][0], self.mouse_path[-2][1]) - self.mouse_path = self.mouse_path[:-2] - if event.type in {'NUMPAD_1', 'NUMPAD_2', 'NUMPAD_3', 'NUMPAD_4', 'NUMPAD_5', 'NUMPAD_6', 'NUMPAD_7', 'NUMPAD_8', 'NUMPAD_9', 'WHEELUPMOUSE', 'WHEELDOWNMOUSE', 'MIDDLEMOUSE', 'N'}: @@ -193,6 +187,13 @@ def modal(self, context, event): return {'FINISHED'} + # Remove Last Point + if event.type == 'BACK_SPACE' and event.value == 'PRESS': + if len(self.mouse_path) > 2: + context.window.cursor_warp(int(self.mouse_path[-2][0]), int(self.mouse_path[-2][1])) + self.mouse_path = self.mouse_path[:-1] + + # Cancel elif event.type in {'RIGHTMOUSE', 'ESC'}: self.cancel(context) @@ -202,7 +203,7 @@ def modal(self, context, event): -### ------------------------------ REGISTRATION ------------------------------ #### +#### ------------------------------ REGISTRATION ------------------------------ #### classes = [ OBJECT_OT_carve_polyline, @@ -213,5 +214,5 @@ def register(): bpy.utils.register_class(cls) def unregister(): - for cls in classes: + for cls in reversed(classes): bpy.utils.unregister_class(cls) diff --git a/tools/common.py b/tools/common.py index 1aabb8b..c9041dd 100644 --- a/tools/common.py +++ b/tools/common.py @@ -19,12 +19,11 @@ #### ------------------------------ OPERATORS ------------------------------ #### - class CarverModifierKeys(): # Snap def modifier_snap(self, context, event): self.snap = context.scene.tool_settings.use_snap - if (hasattr(self, "move") and self.move == False) and (hasattr(self, "rotate") and self.rotate == False): + if (self.move == False) and (not hasattr(self, "rotate") or (hasattr(self, "rotate") and not self.rotate)): # change_the_snap_increment_value_using_the_wheel_mouse for i, a in enumerate(context.screen.areas): if a.type == 'VIEW_3D': @@ -141,13 +140,6 @@ def modifier_move(self, context, event): self.initial_position = False - # Remove Point (Polyline) - if event.type == 'BACK_SPACE' and event.value == 'PRESS': - if len(self.mouse_path) > 2: - context.window.cursor_warp(self.mouse_path[-2][0], self.mouse_path[-2][1]) - self.mouse_path = self.mouse_path[:-2] - - class CarverBase(): # OPERATOR-properties mode: bpy.props.EnumProperty( @@ -475,5 +467,5 @@ def register(): bpy.utils.register_class(cls) def unregister(): - for cls in classes: + for cls in reversed(classes): bpy.utils.unregister_class(cls)