2025-12-01

This commit is contained in:
2026-03-17 14:58:51 -06:00
parent 183e865f8b
commit 4b82b57113
6846 changed files with 954887 additions and 162606 deletions
@@ -1,19 +1,59 @@
import bpy
from . import (
carver,
)
if "bpy" in locals():
import importlib
for mod in [carver_box,
carver_circle,
carver_polyline,
ui,
]:
importlib.reload(mod)
else:
import bpy
from . import (
carver_box,
carver_circle,
carver_polyline,
)
from .common import (
ui,
)
#### ------------------------------ REGISTRATION ------------------------------ ####
modules = [
carver,
carver_box,
# carver_circle,
carver_polyline,
ui,
]
main_tools = [
carver_box.OBJECT_WT_carve_box,
carver_box.MESH_WT_carve_box,
]
secondary_tools = [
carver_circle.OBJECT_WT_carve_circle,
carver_circle.MESH_WT_carve_circle,
carver_polyline.OBJECT_WT_carve_polyline,
carver_polyline.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)
@@ -1,798 +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 = 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 rectangular 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_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') and context.area.type == 'VIEW_3D'
def invoke(self, context, event):
self.selected_objects = context.selected_objects
self.initial_selection = context.selected_objects
self.mouse_path = [(event.mouse_region_x, event.mouse_region_y),
(event.mouse_region_x, event.mouse_region_y)]
# initialize_empty_values
self.verts = []
self.cutter = None
self.duplicates = []
self.view_depth = mathutils.Vector()
self.cached_mouse_position = ()
# modifier_keys
self.initial_origin = self.origin
self.initial_aspect = self.aspect
self.snap = False
self.move = False
self.rotate = False
self.gap = False
self.bevel = False
# overlay_position
self.position_x = 0
self.position_y = 0
self.initial_position = False
self.center_origin = []
self.distance_from_first = 0
# Add Draw Handler
self._handle = bpy.types.SpaceView3D.draw_handler_add(carver_overlay, (self, bpy.context), 'WINDOW', 'POST_PIXEL')
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.workspace.status_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.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.workspace.status_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, context, obj, self.cutter, "DIFFERENCE", self.solver, apply=True, pin=self.pin, redo=False)
elif self.mode == 'MODIFIER':
add_boolean_modifier(self, context, 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,
]
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)
@@ -0,0 +1,272 @@
import bpy
import mathutils
import os
from .. import __file__ as base_file
from .common.base import (
CarverModifierKeys,
CarverBase,
)
from .common.properties import (
CarverOperatorProperties,
CarverModifierProperties,
CarverCutterProperties,
CarverArrayProperties,
CarverBevelProperties,
)
from .common.ui import (
carver_ui_common,
)
from ..functions.draw import (
carver_shape_box,
)
from ..functions.select import (
cursor_snap,
selection_fallback,
)
description = "Cut primitive shapes into mesh objects by box drawing"
#### ------------------------------ TOOLS ------------------------------ ####
class OBJECT_WT_carve_box(bpy.types.WorkSpaceTool):
bl_idname = "object.carve_box"
bl_label = "Box Carve"
bl_description = description
bl_space_type = 'VIEW_3D'
bl_context_mode = 'OBJECT'
bl_icon = os.path.join(os.path.dirname(base_file), "icons", "ops.object.carver_box")
bl_keymap = (
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG'}, {"properties": [("shape", 'BOX')]}),
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "shift": True}, {"properties": [("shape", 'BOX')]}),
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "alt": True}, {"properties": [("shape", 'BOX')]}),
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "shift": True, "alt": True}, {"properties": [("shape", 'BOX')]}),
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True}, {"properties": [("shape", 'BOX')]}),
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "shift": True}, {"properties": [("shape", 'BOX')]}),
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "alt": True}, {"properties": [("shape", 'BOX')]}),
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "shift": True, "alt": True}, {"properties": [("shape", 'BOX')]}),
)
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'
#### ------------------------------ OPERATORS ------------------------------ ####
class OBJECT_OT_carve_box(CarverBase, CarverModifierKeys, bpy.types.Operator,
CarverOperatorProperties, CarverModifierProperties, CarverCutterProperties,
CarverArrayProperties, CarverBevelProperties):
bl_idname = "object.carve_box"
bl_label = "Box Carve"
bl_description = description
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,
)
@classmethod
def poll(cls, context):
return context.mode in ('OBJECT', 'EDIT_MESH') and context.area.type == 'VIEW_3D'
def invoke(self, context, event):
self.selected_objects = context.selected_objects
self.mouse_path = [(event.mouse_region_x, event.mouse_region_y),
(event.mouse_region_x, event.mouse_region_y)]
# initialize_empty_values
self.verts = []
self.duplicates = []
self.cutter = None
self.view_depth = mathutils.Vector()
self.cached_mouse_position = () # needed_for_custom_modifier_keys
# cached_variables
"""Important for storing context as it was when operator was invoked (untouched by the modal)"""
self.initial_origin = self.origin
self.initial_aspect = self.aspect
# modifier_keys
self.snap = False
self.move = False
self.rotate = False
self.gap = False
self.bevel = False
# overlay_position (needed_for_moving_the_shape)
self.position_offset_x = 0
self.position_offset_y = 0
self.initial_position = False
# Add Draw Handler
self._handle = bpy.types.SpaceView3D.draw_handler_add(carver_shape_box, (self, context, self.shape), 'WINDOW', 'POST_PIXEL')
context.window.cursor_set("MUTE")
context.window_manager.modal_handler_add(self)
return {'RUNNING_MODAL'}
def modal(self, context, event):
# Status Bar 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" if self.shape == 'BOX' else ""
context.workspace.status_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':
# move
if self.move:
self.position_offset_x += (event.mouse_region_x - self.last_mouse_region_x)
self.position_offset_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
# rotate
elif self.rotate:
self.rotation = event.mouse_region_x * 0.01
# 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, shape='BOX')
for obj in self.selected_objects:
obj.select_set(True)
if len(self.selected_objects) == 0:
self.cancel(context)
return {'FINISHED'}
else:
selection = self.validate_selection(context, shape='BOX')
if not selection:
self.cancel(context)
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 reversed(classes):
bpy.utils.unregister_class(cls)
@@ -0,0 +1,39 @@
import bpy
import os
from .. import __file__ as base_file
from .common.ui import (
carver_ui_common,
)
description = "Cut primitive shapes into mesh objects with brush"
#### ------------------------------ TOOLS ------------------------------ ####
class OBJECT_WT_carve_circle(bpy.types.WorkSpaceTool):
bl_idname = "object.carve_circle"
bl_label = "Circle Carve"
bl_description = description
bl_space_type = 'VIEW_3D'
bl_context_mode = 'OBJECT'
bl_icon = os.path.join(os.path.dirname(base_file), "icons", "ops.object.carver_circle")
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')]}),
)
def draw_settings(context, layout, tool):
props = tool.operator_properties("object.carve_box")
carver_ui_common(context, layout, props)
class MESH_WT_carve_circle(OBJECT_WT_carve_circle):
bl_context_mode = 'EDIT_MESH'
@@ -0,0 +1,241 @@
import bpy
import mathutils
import math
import os
from .. import __file__ as base_file
from .common.base import (
CarverModifierKeys,
CarverBase,
)
from .common.properties import (
CarverOperatorProperties,
CarverModifierProperties,
CarverCutterProperties,
CarverArrayProperties,
)
from .common.ui import (
carver_ui_common,
)
from ..functions.draw import (
carver_shape_polyline,
)
from ..functions.select import (
cursor_snap,
selection_fallback,
)
description = "Cut custom polygonal shapes into mesh objects"
#### ------------------------------ TOOLS ------------------------------ ####
class OBJECT_WT_carve_polyline(bpy.types.WorkSpaceTool):
bl_idname = "object.carve_polyline"
bl_label = "Polyline Carve"
bl_description = description
bl_space_type = 'VIEW_3D'
bl_context_mode = 'OBJECT'
bl_icon = os.path.join(os.path.dirname(base_file), "icons", "ops.object.carver_polyline")
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,
CarverOperatorProperties, CarverModifierProperties, CarverCutterProperties, CarverArrayProperties):
bl_idname = "object.carve_polyline"
bl_label = "Polyline Carve"
bl_description = description
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') and context.area.type == 'VIEW_3D'
def invoke(self, context, event):
self.selected_objects = context.selected_objects
self.mouse_path = [(event.mouse_region_x, event.mouse_region_y),
(event.mouse_region_x, event.mouse_region_y)]
# initialize_empty_values
self.verts = []
self.duplicates = []
self.cutter = None
self.view_depth = mathutils.Vector()
self.cached_mouse_position = () # needed_for_custom_modifier_keys
self.distance_from_first = 0
# cached_variables
"""Important for storing context as it was when operator was invoked (untouched by the modal)"""
self.initial_selection = context.selected_objects
# modifier_keys
self.snap = False
self.move = False
self.gap = False
# overlay_position (needed_for_moving_the_shape)
self.position_offset_x = 0
self.position_offset_y = 0
self.initial_position = False
# Add Draw Handler
self._handle = bpy.types.SpaceView3D.draw_handler_add(carver_shape_polyline, (self, context), 'WINDOW', 'POST_PIXEL')
context.window.cursor_set("MUTE")
context.window_manager.modal_handler_add(self)
return {'RUNNING_MODAL'}
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.workspace.status_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)
if event.type in {'NUMPAD_1', 'NUMPAD_2', 'NUMPAD_3', 'NUMPAD_4',
'NUMPAD_5', 'NUMPAD_6', 'NUMPAD_7', 'NUMPAD_8', 'NUMPAD_9',
'MIDDLEMOUSE', 'WHEELUPMOUSE', 'WHEELDOWNMOUSE', 'N'}:
return {'PASS_THROUGH'}
# Mouse Move
if event.type == 'MOUSEMOVE':
# move
if self.move:
self.position_offset_x += (event.mouse_region_x - self.last_mouse_region_x)
self.position_offset_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_on_every_polyline_click)
if len(self.initial_selection) == 0:
self.selected_objects = selection_fallback(self, context, context.view_layer.objects, shape='POLYLINE')
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)
selection = self.validate_selection(context, shape='POLYLINE')
if not selection:
self.cancel(context)
return {'FINISHED'}
self.confirm(context)
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)
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 reversed(classes):
bpy.utils.unregister_class(cls)
@@ -0,0 +1,239 @@
import bpy
import math
from ...functions.mesh import (
create_cutter_shape,
extrude,
shade_smooth_by_angle,
)
from ...functions.modifier import (
add_boolean_modifier,
apply_modifiers,
)
from ...functions.object import (
set_cutter_properties,
delete_cutter,
set_object_origin,
)
from ...functions.select import (
selection_fallback,
)
#### ------------------------------ FUNCTIONS ------------------------------ ####
def custom_modifier_event(self, context, event, modifier):
"""Creates custom modifier event when key is held and hides cursor until it's released"""
if event.value == 'PRESS':
if not self.move:
self.cached_mouse_position = (self.mouse_path[1][0], self.mouse_path[1][1])
context.window.cursor_set("NONE")
setattr(self, modifier, True)
elif event.value == 'RELEASE':
if not self.move:
context.window.cursor_set("MUTE")
context.window.cursor_warp(int(self.cached_mouse_position[0]), int(self.cached_mouse_position[1]))
setattr(self, modifier, False)
#### ------------------------------ /base/ ------------------------------ ####
class CarverModifierKeys():
"""NOTE: Order of the modifier key events is important, because key value might change after function checks for it"""
"""Functions that check last are most important because they can overwrite all modifier states"""
def modifier_snap(self, context, event):
"""Modifier keys for snapping"""
self.snap = context.scene.tool_settings.use_snap
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, area in enumerate(context.screen.areas):
if area.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
def modifier_aspect(self, context, event):
"""Modifier keys for changing aspect of the shape"""
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
def modifier_origin(self, context, event):
"""Modifier keys for changing the origin of the shape"""
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
def modifier_rotate(self, context, event):
"""Modifier keys for rotating the shape"""
if event.type == 'R':
custom_modifier_event(self, context, event, "rotate")
def modifier_bevel(self, context, event):
"""Modifier keys for beveling the shape"""
if self.shape == 'BOX':
if event.type == 'B':
custom_modifier_event(self, context, event, "bevel")
if self.bevel:
self.use_bevel = True
if event.type == 'WHEELUPMOUSE':
self.bevel_segments += 1
elif event.type == 'WHEELDOWNMOUSE':
self.bevel_segments -= 1
def modifier_array(self, context, event):
"""Modifier keys for creating the array of the shape"""
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'):
custom_modifier_event(self, context, event, "gap")
def modifier_move(self, context, event):
"""Modifier keys for moving the shape"""
if event.type == 'SPACE':
if event.value == 'PRESS':
self.move = True
elif event.value == 'RELEASE':
self.move = False
if self.move:
# reset_initial_position_before_moving_the_shape
if self.initial_position is False:
self.position_offset_x = 0
self.position_offset_y = 0
self.last_mouse_region_x = event.mouse_region_x
self.last_mouse_region_y = event.mouse_region_y
self.initial_position = True
else:
# update_the_shape_coordinates
if self.initial_position:
for i in range(0, len(self.mouse_path)):
l = list(self.mouse_path[i])
l[0] += self.position_offset_x
l[1] += self.position_offset_y
self.mouse_path[i] = tuple(l)
self.position_offset_x = self.position_offset_y = 0
self.initial_position = False
class CarverBase():
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 validate_selection(self, context, shape='BOX'):
"""Filters out objects that are not inside the selection shape bounding box"""
"""Returns selection state (so operator can be cancelled if there are no objects inside the selection bounding box)"""
self.selected_objects = selection_fallback(self, context, self.selected_objects, shape=shape, include_cutters=True)
# silently_fail_if_no_objects_inside_selection_bounding_box
if len(self.selected_objects) == 0:
selection = False
else:
selection = True
return selection
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.workspace.status_text_set(None)
context.window.cursor_set('DEFAULT' if context.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':
# Select all faces of the cutter so that newly created faces in canvas
# are also selected after applying the modifier.
for face in self.cutter.data.polygons:
face.select = True
mod = add_boolean_modifier(self, context, obj, self.cutter, "DIFFERENCE", self.solver, pin=self.pin, redo=False)
apply_modifiers(context, obj, [mod])
elif self.mode == 'MODIFIER':
add_boolean_modifier(self, context, 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)
@@ -0,0 +1,133 @@
import bpy
import math
#### ------------------------------ PROPERTIES ------------------------------ ####
class CarverOperatorProperties():
# 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 are placed inside the collection", 'MODIFIER_DATA', 1)),
default = 'DESTRUCTIVE',
)
depth: bpy.props.EnumProperty(
name = "Depth",
items = (('VIEW', "View", "Depth is automatically calculated from view orientation", 'VIEW_CAMERA_UNSELECTED', 0),
('CURSOR', "Cursor", "Depth is derived from 3D cursors location", 'PIVOT_CURSOR', 1)),
default = 'VIEW',
)
class CarverModifierProperties():
# MODIFIER-properties
solver: bpy.props.EnumProperty(
name = "Solver",
items = [('FLOAT', "Float", ""),
('EXACT', "Exact", ""),
('MANIFOLD', "Manifold", "")],
default = 'FLOAT',
)
pin: bpy.props.BoolProperty(
name = "Pin Boolean Modifier",
description = ("Boolean modifier will be placed first in modifier stack, above other modifier (if there are any).\n"
"NOTE: Order of modifiers can drastically affect the result (especially in destructive mode)"),
default = True,
)
class CarverCutterProperties():
# CUTTER-properties
hide: bpy.props.BoolProperty(
name = "Hide Cutter",
description = ("Hide cutter objects in the viewport after they're created."),
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 specified degrees) marked as sharp\n"
"NOTE: This is a one time operator. 'Smooth by Angle' modifier will not be added on cutter"),
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,
)
class CarverArrayProperties():
# ARRAY-properties
rows: bpy.props.IntProperty(
name = "Rows",
description = "Number of times shape is duplicated horizontally",
min = 1, soft_max = 16,
default = 1,
)
rows_gap: bpy.props.FloatProperty(
name = "Gap between rows (relative unit)",
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 vertically",
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 (relative unit)",
min = 0, soft_max = 250,
default = 50,
)
class CarverBevelProperties():
# 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,
)
@@ -0,0 +1,149 @@
import bpy
from ... import __package__ as base_package
#### ------------------------------ /toolbar/ ------------------------------ ####
def carver_ui_common(context, layout, props):
"""Common tool properties for all Carver tools"""
layout.prop(props, "mode", text="")
layout.prop(props, "depth", text="")
layout.prop(props, "solver", expand=True)
# Popovers
layout.popover("TOPBAR_PT_carver_shape", text="Shape")
layout.popover("TOPBAR_PT_carver_array", text="Array")
layout.popover("TOPBAR_PT_carver_cutter", text="Cutter")
#### ------------------------------ /popovers/ ------------------------------ ####
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
tool = context.workspace.tools.from_space_view3d_mode('OBJECT' if context.mode == 'OBJECT' else 'EDIT_MESH')
# Box
if tool.idname == "object.carve_box" or tool.idname == "object.carve_circle":
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)
# bevel
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
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
tool = context.workspace.tools.from_space_view3d_mode('OBJECT' if context.mode == 'OBJECT' else 'EDIT_MESH')
if tool.idname == "object.carve_box" or tool.idname == "object.carve_circle":
props = tool.operator_properties("object.carve_box")
elif tool.idname == "object.carve_polyline":
props = tool.operator_properties("object.carve_polyline")
# Rows
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")
# Columns
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
tool = context.workspace.tools.from_space_view3d_mode('OBJECT' if context.mode == 'OBJECT' else 'EDIT_MESH')
if tool.idname == "object.carve_box" or tool.idname == "object.carve_circle":
props = tool.operator_properties("object.carve_box")
elif tool.idname == "object.carve_polyline":
props = tool.operator_properties("object.carve_polyline")
# modifier_&_cutter
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 reversed(classes):
bpy.utils.unregister_class(cls)