2026-01-01

This commit is contained in:
2026-03-17 15:16:34 -06:00
parent ec4cf523fb
commit b80274187b
263 changed files with 95164 additions and 3848 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -1,6 +1,7 @@
if "bpy" in locals():
import importlib
for mod in [operators,
for mod in [icons,
operators,
tools,
manual,
preferences,
@@ -13,6 +14,7 @@ if "bpy" in locals():
else:
import bpy
from . import (
icons,
operators,
tools,
manual,
@@ -26,6 +28,7 @@ else:
#### ------------------------------ REGISTRATION ------------------------------ ####
modules = [
icons,
operators,
tools,
manual,
Binary file not shown.
@@ -2,7 +2,7 @@ schema_version = "1.0.0"
id = "bool_tool"
name = "Bool Tool"
version = "1.1.5"
version = "2.0.0"
tagline = "Quick boolean operators and tools for hard surface modeling"
type = "add-on"
@@ -1,22 +1,15 @@
import bpy
import gpu
import math
import mathutils
from bpy_extras import view3d_utils
from mathutils import Vector
from gpu_extras.batch import batch_for_shader
from .math import (
draw_circle,
draw_polygon,
draw_array,
)
magic_number = 1.41
color = (0.48, 0.04, 0.04, 1.0)
secondary_color = (0.28, 0.04, 0.04, 1.0)
#### ------------------------------ FUNCTIONS ------------------------------ ####
def draw_shader(color, alpha, type, coords, size=1, indices=None):
def draw_shader(type, color, alpha, coords, size=1, indices=None):
"""Creates a batch for a draw type"""
gpu.state.blend_set('ALPHA')
@@ -29,6 +22,7 @@ def draw_shader(color, alpha, type, coords, size=1, indices=None):
batch = batch_for_shader(shader, 'POINTS', {"pos": coords}, indices=indices)
elif type in 'LINES':
gpu.state.line_width_set(size)
shader = gpu.shader.from_builtin('POLYLINE_UNIFORM_COLOR')
shader.uniform_float("viewportSize", gpu.state.viewport_get()[2:])
shader.uniform_float("lineWidth", size)
@@ -43,134 +37,103 @@ def draw_shader(color, alpha, type, coords, size=1, indices=None):
batch = batch_for_shader(shader, 'LINE_LOOP', {"pos": coords})
if type == 'SOLID':
gpu.state.depth_test_set('NONE')
shader = gpu.shader.from_builtin('UNIFORM_COLOR')
shader.uniform_float("color", (color[0], color[1], color[2], alpha))
batch = batch_for_shader(shader, 'TRIS', {"pos": coords}, indices=indices)
if type == 'OUTLINE':
shader = gpu.shader.from_builtin('UNIFORM_COLOR')
shader.uniform_float("color", (color[0], color[1], color[2], alpha))
batch = batch_for_shader(shader, 'LINE_STRIP', {"pos": coords})
gpu.state.line_width_set(size)
batch.draw(shader)
gpu.state.point_size_set(1.0)
gpu.state.line_width_set(1.0)
gpu.state.blend_set('NONE')
def carver_shape_box(self, context, shape):
"""Shape overlay for box carver tool"""
def draw_bmesh_faces(faces, world_matrix):
"""
Get world-space vertex pairs and indices from `bmesh` face. To be used in GPU batch.
Adapted from "Blockout" extension by niewinny (https://github.com/niewinny/blockout).
"""
subdivision = self.subdivision if shape == 'CIRCLE' else 4
rotation = 0 if shape == 'CIRCLE' else 45
if not faces:
return None, None
# Create Shape
coords, indices, bounds = draw_circle(self, subdivision, rotation)
self.verts = coords
vertices = []
indices = []
# Draw Shaders
draw_shader(color, 0.4, 'SOLID', coords, size=2, indices=indices[:-2])
if not self.rotate and not self.bevel:
draw_shader(color, 0.6, 'OUTLINE', bounds, size=2)
vert_index_map = {}
vert_count = 0
for face in faces:
face_indices = []
# Array
if self.rows > 1 or self.columns > 1:
carver_shape_array(self, coords, indices, 'SOLID')
# Collect unique vertices only (avoid storing verts that are shared by faces multiple times).
# (Iterating over face corners because unlike `face.verts` they're ordered).
for loop in face.loops:
vert = loop.vert
co = world_matrix @ Vector(vert.co)
if vert not in vert_index_map:
vertices.append(co)
vert_index_map[vert] = vert_count
face_indices.append(vert_count)
vert_count += 1
else:
face_indices.append(vert_index_map[vert])
# Triangulate face and map local indices to global vertex indices.
if len(face_indices) >= 3:
try:
face_verts_co = [vertices[idx] for idx in face_indices]
tris = mathutils.geometry.tessellate_polygon([face_verts_co])
for tri in tris:
indices.append((face_indices[tri[0]], face_indices[tri[1]], face_indices[tri[2]]))
except:
# Fallback to simple fan triangulation if tessellation fails.
for i in range(1, len(face_indices) - 1):
indices.append((face_indices[0], face_indices[i], face_indices[i + 1]))
return vertices, indices
if self.snap:
mini_grid(self, context)
def draw_bmesh_edges(edges, world_matrix):
"""Convert bmesh edges into world-space vertex pairs to be used in GPU batch."""
gpu.state.blend_set('NONE')
if not edges:
return None
vertices = []
for edge in edges:
v1 = world_matrix @ edge.verts[0].co
v2 = world_matrix @ edge.verts[1].co
vertices.append(v1)
vertices.append(v2)
return vertices
def carver_shape_polyline(self, context):
"""Shape overlay for polyline carver tool"""
# Create Shape
coords, indices, first_point, array_coords = draw_polygon(self)
self.verts = list(dict.fromkeys(self.mouse_path))
# Draw Shaders
draw_shader(color, 1.0, 'POINTS', coords, size=5)
draw_shader(color, 1.0, 'LINE_LOOP' if self.closed else 'LINES', coords, size=2)
if self.closed and len(self.mouse_path) > 2:
# polygon_fill
draw_shader(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)
# Array
if len(self.mouse_path) > 2 and (self.rows > 1 or self.columns > 1):
carver_shape_array(self, array_coords, indices, 'LINE_LOOP' if self.closed == False else 'SOLID')
if self.snap:
mini_grid(self, context)
gpu.state.blend_set('NONE')
def carver_shape_array(self, verts, indices, shader):
"""Draws given shape for each row and column of the array"""
rows, columns = draw_array(self, verts)
self.duplicates = {**{f"row_{k}": v for k, v in rows.items()}, **{f"column_{k}": v for k, v in columns.items()}}
if self.rows > 1:
for i, duplicate in rows.items():
draw_shader(secondary_color, 0.4, shader, duplicate, size=2, indices=indices[:-2])
if self.columns > 1:
for i, duplicate in columns.items():
draw_shader(secondary_color, 0.4, shader, duplicate, size=2, indices=indices[:-2])
def mini_grid(self, context):
"""Draws snap mini-grid around the cursor based on the overlay grid"""
def draw_circle_around_point(context, obj, vert, radius, segments):
"""
Draws the screen-aligned circle around given vertex of the object.
Returns the list of vertices for GPU batch.
"""
region = context.region
rv3d = context.region_data
vert_world = obj.matrix_world @ vert.co
radius = min(radius, 25)
for i, area in enumerate(context.screen.areas):
if area.type == 'VIEW_3D':
space = context.screen.areas[i].spaces.active
screen_height = context.screen.areas[i].height
screen_width = context.screen.areas[i].width
vertices = []
for i in range(segments + 1):
angle = i * (2 * math.pi / segments)
# draw_the_snap_grid_(only_in_the_orthographic_view)
if not space.region_3d.is_perspective:
grid_scale = space.overlay.grid_scale
grid_subdivisions = space.overlay.grid_subdivisions
increment = (grid_scale / grid_subdivisions)
# Calculate offset and vertex position in screen-space.
offset_x = radius * math.cos(angle)
offset_y = radius * math.sin(angle)
vert_screen = view3d_utils.location_3d_to_region_2d(region, rv3d, vert_world)
# get_the_3d_location_of_the_mouse_forced_to_a_snap_value_in_the_operator
mouse_coord = self.mouse_path[len(self.mouse_path) - 1]
snap_loc = view3d_utils.region_2d_to_location_3d(region, rv3d, mouse_coord, (0, 0, 0))
if vert_screen:
# Add offset in screen-space and convert back to world-space.
circle_screen = Vector((vert_screen.x + offset_x, vert_screen.y + offset_y))
circle_3d = view3d_utils.region_2d_to_location_3d(region, rv3d, circle_screen, vert_world)
vertices.append(circle_3d)
# add_the_increment_to_get_the_closest_location_on_the_grid
snap_loc[0] += increment
snap_loc[1] += increment
# get_the_2d_location_of_the_snap_location
snap_loc = view3d_utils.location_3d_to_region_2d(region, rv3d, snap_loc)
# get_the_increment_value
snap_value = snap_loc[0] - mouse_coord[0]
# draw_lines_on_x_and_z_axis_from_the_cursor_through_the_screen
grid_coords = [(0, mouse_coord[1]), (screen_width, mouse_coord[1]),
(mouse_coord[0], 0), (mouse_coord[0], screen_height)]
grid_coords += [(mouse_coord[0] + snap_value, mouse_coord[1] + 25 + snap_value),
(mouse_coord[0] + snap_value, mouse_coord[1] - 25 - snap_value),
(mouse_coord[0] + 25 + snap_value, mouse_coord[1] + snap_value),
(mouse_coord[0] - 25 - snap_value, mouse_coord[1] + snap_value),
(mouse_coord[0] - snap_value, mouse_coord[1] + 25 + snap_value),
(mouse_coord[0] - snap_value, mouse_coord[1] - 25 - snap_value),
(mouse_coord[0] + 25 + snap_value, mouse_coord[1] - snap_value),
(mouse_coord[0] - 25 - snap_value, mouse_coord[1] - snap_value),]
draw_shader((1.0, 1.0, 1.0), 0.66, 'LINES', grid_coords, size=1.5)
return vertices
@@ -1,237 +1,77 @@
import bpy
import math
import mathutils
from mathutils import Vector
from bpy_extras import view3d_utils
magic_number = 1.41
#### ------------------------------ FUNCTIONS ------------------------------ ####
def draw_circle(self, subdivision, rotation):
"""Returns the coordinates & indices of a 2d circle in screen-space"""
def distance_from_point_to_segment(point, start, end) -> float:
"""
Calculates the shortest distance between a point and a segment.
All three inputs should be `mathutils.Vector` objects.
This is an alternative to `mathutils.geometry.intersect_point_line`.
Adapted from "Blockout" extension by niewinny (https://github.com/niewinny/blockout).
"""
def create_2d_circle(self, step, rotation):
"""Create the vertices of a 2d circle at (0, 0)"""
segment = end - start
start_to_point = point - start
modifier = 2 if self.shape == 'CIRCLE' else magic_number
if self.origin == 'CENTER':
modifier /= 2
# projection_along_segment
c1 = start_to_point.dot(segment)
if c1 <= 0:
return (point - start).length
verts = []
for i in range(step):
angle = (360 / step) * i + rotation
verts.append(math.cos(math.radians(angle)) * ((self.mouse_path[1][0] - self.mouse_path[0][0]) / modifier))
verts.append(math.sin(math.radians(angle)) * ((self.mouse_path[1][1] - self.mouse_path[0][1]) / modifier))
verts.append(0.0)
# segment_length_squared
c2 = segment.dot(segment)
if c2 <= c1:
return (point - end).length
verts.append(math.cos(math.radians(0.0 + rotation)) * ((self.mouse_path[1][0] - self.mouse_path[0][0]) / modifier))
verts.append(math.sin(math.radians(0.0 + rotation)) * ((self.mouse_path[1][1] - self.mouse_path[0][1]) / modifier))
verts.append(0.0)
t = c1 / c2
closest_point = start + t * segment
distance = (point - closest_point).length
return verts
tris_verts = []
indices = []
verts = create_2d_circle(self, int(subdivision), rotation)
rotation_matrix = mathutils.Matrix.Rotation(self.rotation, 4, 'Z')
fixed_point = mathutils.Vector((self.mouse_path[0][0], self.mouse_path[0][1], 0.0))
current_mouse_position = mathutils.Vector((self.mouse_path[1][0], self.mouse_path[1][1], 0.0))
shape_center = fixed_point + (current_mouse_position - fixed_point) / 2
for idx in range((len(verts) // 3) - 1):
x = verts[idx * 3]
y = verts[idx * 3 + 1]
z = verts[idx * 3 + 2]
vert = mathutils.Vector((x, y, z))
vert = rotation_matrix @ vert
vert = vert + fixed_point if self.origin == 'CENTER' else shape_center - vert
vert += mathutils.Vector((self.position_offset_x, self.position_offset_y, 0.0))
tris_verts.append(vert)
i1 = idx + 1
i2 = idx + 2 if idx + 2 <= ((360 / int(subdivision)) * (idx + 1) + rotation) else 1
indices.append((0, i1, i2))
# BEVEL
if self.use_bevel and self.bevel_radius > 0.01:
tris_verts, indices = bevel_verts(self, tris_verts, (self.bevel_radius * 50), self.bevel_segments)
return distance
# BOUNDING_BOX
min_x, min_y, max_x, max_y = get_bounding_box(tris_verts)
bounds = [
mathutils.Vector((min_x, min_y, 0)), # bottom-left
mathutils.Vector((max_x, min_y, 0)), # bottom-right
mathutils.Vector((max_x, max_y, 0)), # top-right
mathutils.Vector((min_x, max_y, 0)), # top-left
mathutils.Vector((min_x, min_y, 0)) # closing_the_loop_manually
]
def region_2d_to_line_3d(region, rv3d, point_2d: Vector, line_origin: Vector, line_direction: Vector) -> tuple[Vector, Vector]:
"""
Converts a 2D screen-space point into a 3D ray and finds closest
points between that ray and a given 3D line.
"""
return tris_verts, indices, bounds
if line_origin is None or line_direction is None:
return None, None
# Convert the screen-space 2D point Vector into a world-space 3D ray (origin + direction).
ray_origin = view3d_utils.region_2d_to_origin_3d(region, rv3d, point_2d)
ray_direction = view3d_utils.region_2d_to_vector_3d(region, rv3d, point_2d)
# Find closest points to each other on each line (second line being a ray).
closest_points = mathutils.geometry.intersect_line_line(ray_origin,
ray_origin + ray_direction,
line_origin,
line_origin + line_direction)
return closest_points
def draw_polygon(self):
"""Returns polygonal 2d shape in screen-space where each cursor click is taken as a new vertice"""
def region_2d_to_plane_3d(region, rv3d, point_2d: Vector, plane: tuple[Vector]) -> Vector:
"""
Converts a 2D screen-space point into a 3D point on a plane in world-space.
Adapted from "Blockout" extension by niewinny (https://github.com/niewinny/blockout).
"""
indices = []
coords = []
for idx, vals in enumerate(self.mouse_path):
vert = mathutils.Vector([vals[0], vals[1], 0.0])
vert += mathutils.Vector([self.position_offset_x, self.position_offset_y, 0.0])
coords.append(vert)
location, normal = plane
i1 = idx + 1
i2 = idx + 2 if idx <= len(self.mouse_path) else 1
indices.append((0, i1, i2))
# Convert the screen-space 2D point Vector into a world-space 3D ray (origin + direction).
p3_origin = view3d_utils.region_2d_to_origin_3d(region, rv3d, point_2d)
p3_direction = view3d_utils.region_2d_to_vector_3d(region, rv3d, point_2d)
# circle_around_first_point
radius = self.distance_from_first
segments = 4
# Intersect the point with the plane.
p3_on_plane = mathutils.geometry.intersect_line_plane(p3_origin, # First point of line.
p3_origin + p3_direction, # Second point of line.
location, # `plane_co` (a point on the plane).
normal) # `plane_no` (the direction the plane is facing).
click_point = [coords[0]]
for i in range(segments + 1):
angle = i * (2 * math.pi / segments)
x = coords[0][0] + radius * math.cos(angle)
y = coords[0][1] + radius * math.sin(angle)
z = coords[0][2]
vector = mathutils.Vector((x, y, z))
click_point.append(vector)
# ARRAY (remove_duplicate_verts)
"""NOTE: This is needed to remove extra vertices for duplicates which are not removed because `dict.fromkeys()`..."""
"""NOTE: can't be called on `coords` list, because it contains unfrozen Vectors."""
unique_verts = []
for vert in coords:
if vert not in unique_verts:
unique_verts.append(vert)
array_coords = unique_verts if self.closed else unique_verts[:-1]
return coords, indices, click_point, array_coords
def draw_array(self, verts):
"""Duplicates given list of vertices in rows and columns (on screen-space x and y axis)"""
"""Returns two dicts of lists of vertices for rows and columns separately"""
# get_bounding_box_of_the_shape
"""NOTE: Calculated separately because verts needed for array differs from verts needed for shape for polyline"""
min_x, min_y, max_x, max_y = get_bounding_box(verts)
rows = {}
if self.rows > 1:
# Offset
offset = mathutils.Vector((((max_x - min_x) + (self.rows_gap)), 0.0, 0.0))
if self.rows_direction == 'LEFT':
offset.x = -offset.x
for i in range(self.rows - 1):
accumulated_offset = offset * (i + 1)
rows[i] = [vert.copy() + accumulated_offset for vert in verts]
columns = {}
if self.columns > 1:
# Offset
offset = mathutils.Vector((0.0, -((max_y - min_y) + (self.columns_gap)), 0.0))
if self.columns_direction == 'UP':
offset.y = -offset.y
for i in range(self.columns - 1):
accumulated_offset = offset * (i + 1)
columns[i] = [vert.copy() + accumulated_offset for vert in verts]
for row_idx, row in rows.items():
columns[(i, row_idx)] = [vert.copy() + accumulated_offset for vert in row]
return rows, columns
def bevel_verts(self, verts, radius, segments):
"""Takes in list of verts(Vectors) and bevels them, Returns a new list with new vertices"""
def get_rounded_corner(self, angular_point, p1, p2, radius, segments):
# get_bounding_box_of_the_shape
min_x, min_y, max_x, max_y = get_bounding_box(verts)
width = max_x - min_x
height = max_y - min_y
# clamp_radius_to_reduce_clipping
max_radius = min(width / 2.5, height / 2.5)
clamped_radius = min(radius, max_radius)
if radius > clamped_radius:
radius = clamped_radius
# calculate_vectors (NOTE: Why it only works when reversed like this is unknown to me)
if self.bevel_profile == 'CONVEX':
vector1 = -(p1 - angular_point)
vector2 = -(p2 - angular_point)
elif self.bevel_profile == 'CONCAVE':
vector1 = p2 - angular_point
vector2 = p1 - angular_point
# compute_lengths_of_vectors
length1 = vector1.length
length2 = vector2.length
if length1 == 0 or length2 == 0:
return [angular_point] * segments
vector1.normalize()
vector2.normalize()
# calculate_the_angle_between_the_vectors
dot_product = vector1.dot(vector2)
angle = math.acos(max(-1.0, min(1.0, dot_product)))
arc_length = radius * angle
segment_length = arc_length / (segments - 1)
bisector = (vector1 + vector2).normalized()
# generate_points_along_the_arc
rounded_corners = []
for i in range(segments):
fraction = i / (segments - 1)
theta = angle * fraction
interpolated_vector = (vector1 * math.sin(theta) + vector2 * math.cos(theta)).normalized() * radius
if self.bevel_profile == 'CONVEX':
point_on_arc = angular_point + interpolated_vector - bisector * (clamped_radius * magic_number)
elif self.bevel_profile == 'CONCAVE':
point_on_arc = angular_point + interpolated_vector - bisector / (clamped_radius)
rounded_corners.append(point_on_arc)
return rounded_corners
rounded_verts = []
indices = []
num_verts = len(verts)
for idx in range(num_verts):
angular_point = verts[idx]
prev_idx = (idx - 1) % num_verts
next_idx = (idx + 1) % num_verts
p1 = verts[prev_idx]
p2 = verts[next_idx]
corner_points = get_rounded_corner(self, angular_point, p1, p2, radius, segments)
rounded_verts.extend(corner_points)
for idx, vert in enumerate(reversed(rounded_verts)):
i1 = idx + 1
i2 = idx + 2 if idx + 2 <= len(rounded_verts) else 1
indices.append((0, i1, i2))
return rounded_verts, indices
def get_bounding_box(verts):
"""Calculates the bounding box coordinates from a list of vertices"""
min_x = min(v[0] for v in verts)
max_x = max(v[0] for v in verts)
min_y = min(v[1] for v in verts)
max_y = max(v[1] for v in verts)
return min_x, min_y, max_x, max_y
return p3_on_plane
@@ -4,164 +4,102 @@ import mathutils
import math
from bpy_extras import view3d_utils
from .object import hide_objects
from .types import Ray
#### ------------------------------ FUNCTIONS ------------------------------ ####
def create_cutter_shape(self, context):
"""Creates flat mesh from the vertices provided in `self.verts` (which is created by `carver_overlay`)"""
# ALIGNMENT: View
coords = self.mouse_path[0][0], self.mouse_path[0][1]
region = context.region
rv3d = context.region_data
depth_location = view3d_utils.region_2d_to_vector_3d(region, rv3d, coords)
self.view_depth = depth_location
plane_direction = depth_location.normalized()
# depth
if self.depth == 'CURSOR':
plane_point = context.scene.cursor.location
elif self.depth == 'VIEW':
__, plane_point = combined_bounding_box(self.selected_objects)
plane_point = mathutils.Vector(plane_point)
# Create Mesh & Object
faces = {}
mesh = bpy.data.meshes.new(name='cutter')
bm = bmesh.new()
bm.from_mesh(mesh)
obj = bpy.data.objects.new('cutter', mesh)
obj.booleans.carver = True
self.cutter = obj
context.collection.objects.link(obj)
# Create Faces from `self.verts`
create_face(context, plane_direction, plane_point,
bm, "original", faces, self.verts)
# ARRAY
if len(self.duplicates) > 0:
for i, duplicate in self.duplicates.items():
create_face(context, plane_direction, plane_point,
bm, str(i), faces, duplicate)
bm.verts.index_update()
for i, face in faces.items():
bm.faces.new(face)
# remove_doubles
bmesh.ops.remove_doubles(bm, verts=[v for v in bm.verts], dist=0.0001)
bm.to_mesh(mesh)
def extrude(self, mesh):
def extrude_face(bm, face):
"""Extrudes cutter face (created by carve operation) along view vector to create a non-manifold mesh"""
bm = bmesh.new()
bm.from_mesh(mesh)
faces = [f for f in bm.faces]
bm.faces.ensure_lookup_table()
# move_the_mesh_towards_view
box_bounding, __ = combined_bounding_box(self.selected_objects)
for face in faces:
for vert in face.verts:
vert.co += -self.view_depth * box_bounding
# Extrude
result = bmesh.ops.extrude_face_region(bm, geom=[bm.faces[face.index]])
# extrude_the_face
ret = bmesh.ops.extrude_face_region(bm, geom=faces)
verts_extruded = [v for v in ret['geom'] if isinstance(v, bmesh.types.BMVert)]
for v in verts_extruded:
if self.depth == 'CURSOR':
v.co += self.view_depth * box_bounding
elif self.depth == 'VIEW':
v.co += self.view_depth * box_bounding * 2
# Offset extruded vertices.
extruded_verts = [v for v in result['geom'] if isinstance(v, bmesh.types.BMVert)]
extruded_edges = [e for e in result['geom'] if isinstance(e, bmesh.types.BMEdge)]
extruded_faces = [f for f in result['geom'] if isinstance(f, bmesh.types.BMFace)]
# correct_normals
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
bm.to_mesh(mesh)
mesh.update()
bm.free()
return extruded_verts, extruded_edges, extruded_faces
def combined_bounding_box(objects):
"""Calculate the combined bounding box of multiple objects."""
min_corner = mathutils.Vector((float('inf'), float('inf'), float('inf')))
max_corner = mathutils.Vector((-float('inf'), -float('inf'), -float('inf')))
for obj in objects:
# Transform the bounding box corners to world space
bbox_corners = [obj.matrix_world @ mathutils.Vector(corner) for corner in obj.bound_box]
for corner in bbox_corners:
min_corner.x = min(min_corner.x, corner.x)
min_corner.y = min(min_corner.y, corner.y)
min_corner.z = min(min_corner.z, corner.z)
max_corner.x = max(max_corner.x, corner.x)
max_corner.y = max(max_corner.y, corner.y)
max_corner.z = max(max_corner.z, corner.z)
# Calculate the diagonal of the combined bounding box
bounding_box_diag = (max_corner - min_corner).length
# Calculate the center of bounding box
bounding_box_center = (max_corner + min_corner) * 0.5
return bounding_box_diag, bounding_box_center
def create_face(context, direction, depth, bm, name, faces, verts, polyline=False):
"""Creates bmesh face with given list of vertices and appends it to given 'faces' dict"""
def intersect_line_plane(context, vert, direction, depth):
"""Finds the intersection of a line going through each vertex and the infinite plane"""
region = context.region
rv3d = context.region_data
vec = view3d_utils.region_2d_to_vector_3d(region, rv3d, vert)
p0 = view3d_utils.region_2d_to_location_3d(region, rv3d, vert, vec)
p1 = p0 + direction
loc = mathutils.geometry.intersect_line_plane(p0, p1, depth, direction)
return loc
face_verts = []
for i, vert in enumerate(verts):
loc = intersect_line_plane(context, vert, direction, depth)
vertex = bm.verts.new(loc)
face_verts.append(vertex)
faces[name] = face_verts
def shade_smooth_by_angle(obj, angle=30):
def shade_smooth_by_angle(bm, mesh, angle=30):
"""Replication of "Auto Smooth" functionality: Marks faces as smooth, sharp edges (by angle) as sharp"""
mesh = obj.data
bm = bmesh.new()
bm.from_mesh(mesh)
# shade_smooth
for f in bm.faces:
f.smooth = True
# select_sharp_edges
for edge in bm.edges:
if len(edge.link_faces) == 2:
face1, face2 = edge.link_faces
edge_angle = math.degrees(face1.normal.angle(face2.normal))
if edge_angle >= angle:
edge.select = True
if len(edge.link_faces) != 2:
continue
face1, face2 = edge.link_faces
if face1.normal.length <= 0 or face2.normal.length <= 0:\
continue
edge_angle = math.degrees(face1.normal.angle(face2.normal))
if edge_angle < 0:
continue
if edge_angle < angle:
continue
edge.smooth = False
bm.to_mesh(mesh)
bm.free()
mesh.update()
# mark_sharp_edges
for edge in mesh.edges:
if edge.select:
edge.use_edge_sharp = True
mesh.update()
def are_intersecting(obj_a, obj_b):
"""Checks if bounding boxes of two given objects intersect."""
def world_bounds(obj):
corners = [obj.matrix_world @ mathutils.Vector(c) for c in obj.bound_box]
xs = [c.x for c in corners]
ys = [c.y for c in corners]
zs = [c.z for c in corners]
return (min(xs), max(xs)), (min(ys), max(ys)), (min(zs), max(zs))
(ax0, ax1), (ay0, ay1), (az0, az1) = world_bounds(obj_a)
(bx0, bx1), (by0, by1), (bz0, bz1) = world_bounds(obj_b)
return (
ax1 >= bx0 and ax0 <= bx1 and
ay1 >= by0 and ay0 <= by1 and
az1 >= bz0 and az0 <= bz1
)
def ensure_attribute(bm, name, domain):
"""Ensure that the attribute with the given name and domain exists on mesh."""
if domain == 'EDGE':
attr = bm.edges.layers.float.get(name)
if not attr:
attr = bm.edges.layers.float.new(name)
elif domain == 'VERTEX':
attr = bm.verts.layers.float.get(name)
if not attr:
attr = bm.verts.layers.float.new(name)
return attr
def raycast(context, position, objects):
"""Cast a ray in the scene to get the surface on any of the given objects."""
region = context.region
rv3d = context.region_data
depsgraph = context.view_layer.depsgraph
origin = view3d_utils.region_2d_to_origin_3d(region, rv3d, position)
direction = view3d_utils.region_2d_to_vector_3d(region, rv3d, position)
# Cast Ray
with hide_objects(context, exceptions=objects):
hit, location, normal, index, object, matrix = context.scene.ray_cast(depsgraph, origin, direction)
ray = Ray(hit, location, normal, index, object, matrix)
return ray
@@ -3,6 +3,9 @@ import bmesh
from contextlib import contextmanager
from .. import __package__ as base_package
from ..functions.list import (
list_pre_boolean_modifiers,
)
from .object import (
convert_to_mesh,
)
@@ -21,7 +24,7 @@ def add_boolean_modifier(self, context, obj, cutter, mode, solver, pin=False, re
prefs = context.preferences.addons[base_package].preferences
modifier = obj.modifiers.new("boolean_" + cutter.name, 'BOOLEAN')
modifier = obj.modifiers.new("boolean_" + cutter.name.replace("boolean_", ""), 'BOOLEAN')
modifier.operation = mode
modifier.object = cutter
modifier.solver = solver
@@ -44,7 +47,7 @@ def add_boolean_modifier(self, context, obj, cutter, mode, solver, pin=False, re
return modifier
def apply_modifiers(context, obj, modifiers: list):
def apply_modifiers(context, obj, modifiers: list, force_clean=False):
"""
Apply modifiers on object.
Instead of using `bpy.ops.object.modifier_apply`, this function uses
@@ -63,9 +66,10 @@ def apply_modifiers(context, obj, modifiers: list):
context.active_object.data = context.active_object.data.copy()
try:
# Don't use this method if it's not enabled by user in add-on preferences.
# Don't use this method if it's not enabled by user in preferences, unless caller forces it.
if not prefs.fast_modifier_apply:
raise Exception("")
if not force_clean:
raise Exception()
with hide_modifiers(obj, excluding=modifiers):
# Create a temporary mesh from evaluated object.
@@ -99,7 +103,7 @@ def apply_modifiers(context, obj, modifiers: list):
except Exception as e:
# print("Error applying modifiers with `bmesh` method:", e, "falling back to `bpy.ops` method")
context_override = {"object": obj, "mode": 'OBJECT'}
context_override = {"active_object": obj, "mode": 'OBJECT'}
with context.temp_override(**context_override):
# Apply shape keys if there are any.
if obj.data.shape_keys:
@@ -132,3 +136,48 @@ def hide_modifiers(obj, excluding: list):
finally:
for mod in visible_modifiers:
mod.show_viewport = True
def add_modifier_asset(obj, path: str, asset: str):
"""Loads the node group asset and adds a Geometry Nodes modifier using it."""
try:
# Load the node group.
if bpy.app.version >= (5, 0, 0):
with bpy.data.libraries.load(path, link=True, pack=True) as (data_from, data_to):
if asset in data_from.node_groups:
data_to.node_groups = [asset]
else:
with bpy.data.libraries.load(path) as (data_from, data_to):
if asset in data_from.node_groups:
data_to.node_groups = [asset]
node_group = bpy.data.node_groups[asset]
# Add modifier on the object.
mod = obj.modifiers.new(asset, type='NODES')
mod.node_group = node_group
mod.show_group_selector = False
mod.show_manage_panel = False
return mod
except Exception as e:
print("Modifier node group could not be loaded:", e)
return None
def get_modifiers_to_apply(context, obj, new_modifiers) -> list:
"""Returns the list of modifiers that need to be applied based on add-on preferences."""
prefs = context.preferences.addons[base_package].preferences
if prefs.apply_order == 'ALL':
modifiers = [mod for mod in obj.modifiers]
elif prefs.apply_order == 'BOOLEANS':
modifiers = new_modifiers
elif prefs.apply_order == 'BEFORE':
modifiers = list_pre_boolean_modifiers(obj)
return modifiers
@@ -1,35 +1,26 @@
import bpy
import bmesh
import mathutils
from contextlib import contextmanager
from .. import __package__ as base_package
#### ------------------------------ FUNCTIONS ------------------------------ ####
def set_cutter_properties(context, canvas, cutter, mode, parent=True, hide=False, collection=True):
def set_cutter_properties(context, cutter, mode, display='BOUNDS', collection=True):
"""Ensures cutter is properly set: has right properties, is hidden, in a collection & parented"""
prefs = context.preferences.addons[base_package].preferences
# Hide Cutters
cutter.hide_render = True
cutter.display_type = 'WIRE' if prefs.wireframe else 'BOUNDS'
cutter.display_type = display
cutter.lineart.usage = 'EXCLUDE'
object_visibility_set(cutter, value=False)
if hide:
cutter.hide_set(True)
# parent_to_active_canvas
if parent and cutter.parent == None:
cutter.parent = canvas
cutter.matrix_parent_inverse = canvas.matrix_world.inverted()
# Cutters Collection
if collection:
cutters_collection = ensure_collection(context)
if cutters_collection not in cutter.users_collection:
cutters_collection.objects.link(cutter)
if cutter.booleans.carver and parent == False:
context.collection.objects.unlink(cutter)
# add_boolean_property
cutter.booleans.cutter = mode.capitalize()
@@ -103,12 +94,18 @@ def delete_cutter(cutter):
bpy.data.meshes.remove(orphaned_mesh)
def change_parent(object, parent):
def change_parent(obj, parent, force=False, inverse=False):
"""Changes or removes parent from cutter object while keeping the transformation"""
matrix_copy = object.matrix_world.copy()
object.parent = parent
object.matrix_world = matrix_copy
if obj.parent is not None:
if not force:
return
matrix_copy = obj.matrix_world.copy()
obj.parent = parent
if inverse:
obj.matrix_parent_inverse = parent.matrix_world.inverted()
obj.matrix_world = matrix_copy
def create_slice(context, canvas, modifier=False):
@@ -136,14 +133,49 @@ def create_slice(context, canvas, modifier=False):
return slice
def set_object_origin(obj, position=False):
def set_object_origin(obj, bm, point='CENTER', custom=None):
"""Sets object origin to given position by shifting vertices"""
# default_to_center_of_bounding_box_if_no_position_provided
if position == False:
position = 0.125 * sum((mathutils.Vector(b) for b in obj.bound_box), mathutils.Vector())
# Center of the bounding box.
if point == 'CENTER_OBJ':
position_local = 0.125 * sum((mathutils.Vector(b) for b in obj.bound_box), mathutils.Vector())
position_world = obj.matrix_world @ position_local
mat = mathutils.Matrix.Translation(position - obj.location)
obj.location = position
obj.data.transform(mat.inverted())
obj.data.update()
# Center of the geometry.
elif point == 'CENTER_MESH':
if len(bm.verts) > 0:
position_local = sum((v.co for v in bm.verts), mathutils.Vector()) / len(bm.verts)
else:
position_local = mathutils.Vector((0, 0, 0))
position_world = obj.matrix_world @ position_local
# Custom origin point (should be local Vector).
elif point == 'CUSTOM':
position_local = custom
position_world = obj.matrix_world @ custom
mat = mathutils.Matrix.Translation(position_local)
bmesh.ops.transform(bm, matrix=mat.inverted(), verts=bm.verts)
bm.to_mesh(obj.data)
obj.location = position_world
@contextmanager
def hide_objects(context, exceptions: list):
"""Hides objects during the context, and restores their visibility afterwards."""
hidden_objects = []
for obj in context.scene.objects:
if obj in exceptions:
continue
if obj.hide_get() == False:
hidden_objects.append(obj)
obj.hide_set(True)
try:
yield
finally:
for obj in hidden_objects:
obj.hide_set(False)
@@ -1,129 +0,0 @@
import bpy
import mathutils
from bpy_extras import view3d_utils
from .math import get_bounding_box
from .poll import is_linked, is_instanced_data
#### ------------------------------ FUNCTIONS ------------------------------ ####
def cursor_snap(self, context, event, mouse_pos):
"""Find the closest position on the overlay grid and snap the mouse on it"""
region = context.region
rv3d = context.region_data
for i, a in enumerate(context.screen.areas):
if a.type == 'VIEW_3D':
space = context.screen.areas[i].spaces.active
# get_the_grid_overlay
grid_scale = space.overlay.grid_scale
grid_subdivisions = space.overlay.grid_subdivisions
# use_grid_scale_and_subdivision_to_get_the_increment
increment = (grid_scale / grid_subdivisions)
half_increment = increment / 2
# convert_2d_location_of_the_mouse_in_3d
for index, loc in enumerate(reversed(mouse_pos)):
mouse_loc_3d = view3d_utils.region_2d_to_location_3d(region, rv3d, loc, (0, 0, 0))
# get_the_remainder_from_the_mouse_location_and_the_ratio (test_if_the_remainder_>_to_the_half_of_the_increment)
for i in range(3):
modulo = mouse_loc_3d[i] % increment
if modulo < half_increment:
modulo = -modulo
else:
modulo = increment - modulo
# add_the_remainder_to_get_the_closest_location_on_the_grid
mouse_loc_3d[i] = mouse_loc_3d[i] + modulo
snap_loc_2d = view3d_utils.location_3d_to_region_2d(region, rv3d, mouse_loc_3d)
# replace_the_last_mouse_location_by_the_snapped_location
if len(self.mouse_path) > 0:
self.mouse_path[len(self.mouse_path) - (index + 1) ] = tuple(snap_loc_2d)
def is_inside_selection(context, obj, rect_min, rect_max):
"""Checks if the bounding box of an object intersects with the selection bounding box"""
region = context.region
rv3d = context.space_data.region_3d
bound_corners = [obj.matrix_world @ mathutils.Vector(corner) for corner in obj.bound_box]
bound_corners_2d = [view3d_utils.location_3d_to_region_2d(region, rv3d, corner) for corner in bound_corners]
# check_if_2d_point_is_inside_rectangle_(defined_by_min_and_max_points)
for corner_2d in bound_corners_2d:
if corner_2d and (rect_min.x <= corner_2d.x <= rect_max.x and rect_min.y <= corner_2d.y <= rect_max.y):
return True
# check_if_any_part_of_the_bounding_box_intersects_the_selection_rectangle
min_x = min(corner_2d.x for corner_2d in bound_corners_2d if corner_2d)
max_x = max(corner_2d.x for corner_2d in bound_corners_2d if corner_2d)
min_y = min(corner_2d.y for corner_2d in bound_corners_2d if corner_2d)
max_y = max(corner_2d.y for corner_2d in bound_corners_2d if corner_2d)
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, shape='BOX', include_cutters=False):
"""Returns mesh objects that fall inside given 2d rectangle (bounding box of the shape) coordinates"""
"""Needed to know exactly which objects should be carved, to avoid adding and applying unnecessary modifiers"""
"""NOTE: bounding box isn't always returning correct results, but checking full shape would be too expensive"""
if 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)))
elif shape == 'BOX':
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':
# get_bounding_box_of_the_shape
min_x, min_y, max_x, max_y = get_bounding_box(self.verts)
rect_min = mathutils.Vector((min(min_x, max_x), min(min_y, max_y)))
rect_max = mathutils.Vector((max(min_x, max_x), max(min_y, max_y)))
# ARRAY
if self.rows > 1:
rect_max.x = rect_min.x + (rect_max.x - rect_min.x) * self.rows + (self.rows_gap * (self.rows - 1))
if self.columns > 1:
rect_min.y = rect_max.y - (rect_max.y - rect_min.y) * self.columns - (self.columns_gap * (self.columns - 1))
intersecting_objects = []
for obj in objects:
if obj.type != 'MESH':
continue
if obj == self.cutter:
continue
if tuple(round(v, 4) for v in obj.dimensions) == (0.0, 0.0, 0.0):
continue
if include_cutters == False and obj.booleans.cutter != "":
continue
if is_inside_selection(context, obj, rect_min, rect_max):
if is_linked(context, obj):
self.report({'ERROR'}, f"{obj.name} is linked and can not be carved")
continue
if self.mode == 'DESTRUCTIVE':
if is_instanced_data(obj):
self.report({'ERROR'}, f"Modifiers cannot be applied to {obj.name} because it has instanced object data")
continue
intersecting_objects.append(obj)
return intersecting_objects
@@ -0,0 +1,23 @@
import bpy
import mathutils
from mathutils import Vector, Matrix
#### ------------------------------ CLASSES ------------------------------ ####
class Ray:
"""Class object for storing raycast results."""
def __init__(self,
hit: bool,
location: Vector,
normal: Vector,
index: int,
obj,
matrix: Matrix):
self.hit = hit
self.location = location if location is not None else mathutils.Vector()
self.normal = normal if normal is not None else mathutils.Vector()
self.index = index
self.obj = obj
self.matrix = matrix if matrix is not None else mathutils.Matrix()
@@ -0,0 +1,24 @@
import bpy
import os
import bpy.utils.previews
#### ------------------------------ REGISTRATION ------------------------------ ####
svg_icons = {}
icons = bpy.utils.previews.new()
dir = os.path.join(os.path.dirname(__file__))
icons.load("MEASURE", os.path.join(dir, "measure.svg"), 'IMAGE')
icons.load("CPU", os.path.join(dir, "cpu.svg"), 'IMAGE')
svg_icons["main"] = icons
def register():
...
def unregister():
# ICONS
for pcoll in svg_icons.values():
bpy.utils.previews.remove(pcoll)
svg_icons.clear()
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="800" fill="#fff" viewBox="0 0 24 24"><g fill-rule="evenodd"><path d="M8.75 8a.75.75 0 0 0-.75.75v6.5c0 .414.336.75.75.75h6.5a.75.75 0 0 0 .75-.75v-6.5a.75.75 0 0 0-.75-.75h-6.5zm.75 6.5v-5h5v5h-5z"/><path d="M15.25 1a.75.75 0 0 1 .75.75V4h2.25c.966 0 1.75.784 1.75 1.75V8h2.25a.75.75 0 0 1 0 1.5H20v5h2.25a.75.75 0 0 1 0 1.5H20v2.25A1.75 1.75 0 0 1 18.25 20H16v2.25a.75.75 0 0 1-1.5 0V20h-5v2.25a.75.75 0 0 1-1.5 0V20H5.75A1.75 1.75 0 0 1 4 18.25V16H1.75a.75.75 0 0 1 0-1.5H4v-5H1.75a.75.75 0 0 1 0-1.5H4V5.75C4 4.784 4.784 4 5.75 4H8V1.75a.75.75 0 0 1 1.5 0V4h5V1.75a.75.75 0 0 1 .75-.75zm3 17.5a.25.25 0 0 0 .25-.25V5.75a.25.25 0 0 0-.25-.25H5.75a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h12.5z"/></g></svg>

After

Width:  |  Height:  |  Size: 772 B

@@ -0,0 +1,7 @@
<?xml version="1.0"?>
<svg width="23.999999999999996" height="23.999999999999996" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg">
<g class="layer">
<title>Layer 1</title>
<path clip-rule="evenodd" d="m13.18,4.48l3.62,3.65a0.75,0.75 0 0 1 -1.16,1.09l-3.52,-3.59l-1.54,1.51l2.91,2.89a0.75,0.75 0 1 1 -1.06,1.06l-2.92,-2.88l-1.44,1.37l3.62,3.59a0.75,0.75 0 1 1 -1.06,1.06l-3.62,-3.59l-1.51,1.54l2.85,2.84a0.75,0.75 0 1 1 -1.06,1.06c-0.29,-0.3 -2.53,-2.51 -2.82,-2.81c-0.46,0.47 -1.18,1.23 -1.41,1.54c-0.29,0.38 -0.36,0.6 -0.36,0.8c0,0.19 0.07,0.41 0.36,0.79c0.3,0.4 0.77,0.87 1.47,1.57l1.44,1.44c0.7,0.7 1.17,1.17 1.57,1.47c0.38,0.29 0.6,0.36 0.79,0.36c0.2,0 0.42,-0.07 0.8,-0.36c0.4,-0.3 0.87,-0.77 1.57,-1.47l8.67,-8.67c0.7,-0.7 1.17,-1.17 1.47,-1.57c0.29,-0.38 0.36,-0.6 0.36,-0.8c0,-0.19 -0.07,-0.41 -0.36,-0.79c-0.3,-0.4 -0.77,-0.87 -1.47,-1.57l-1.44,-1.44c-0.7,-0.7 -1.17,-1.17 -1.57,-1.47c-0.38,-0.29 -0.6,-0.36 -0.79,-0.36c-0.2,0 -0.42,0.07 -0.8,0.36l-1.61,1.37zm0.7,-2.56c0.51,-0.4 1.05,-0.67 1.71,-0.67c0.65,0 1.19,0.27 1.7,0.67c0.49,0.37 1.03,0.91 1.68,1.56l1.52,1.52c0.65,0.65 1.19,1.19 1.56,1.68c0.4,0.51 0.67,1.05 0.67,1.7c0,0.66 -0.27,1.2 -0.67,1.71c-0.37,0.49 -0.91,1.03 -1.56,1.68l-8.75,8.75c-0.65,0.65 -1.19,1.19 -1.68,1.56c-0.51,0.4 -1.05,0.67 -1.71,0.67c-0.65,0 -1.19,-0.27 -1.7,-0.67c-0.49,-0.37 -1.03,-0.91 -1.68,-1.56l-1.52,-1.52c-0.65,-0.65 -1.19,-1.19 -1.57,-1.68c-0.39,-0.51 -0.66,-1.05 -0.66,-1.7c0,-0.66 0.27,-1.2 0.66,-1.71c0.38,-0.49 0.92,-1.03 1.57,-1.68l8.75,-8.75c0.65,-0.65 1.19,-1.19 1.68,-1.57l0,0.01z" fill="#ffffff" fill-rule="evenodd" id="svg_1"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -12,6 +12,7 @@ from ..functions.poll import (
from ..functions.modifier import (
add_boolean_modifier,
apply_modifiers,
get_modifiers_to_apply,
)
from ..functions.object import (
set_cutter_properties,
@@ -19,9 +20,6 @@ from ..functions.object import (
create_slice,
delete_cutter,
)
from ..functions.list import (
list_pre_boolean_modifiers,
)
#### ------------------------------ PROPERTIES ------------------------------ ####
@@ -108,8 +106,11 @@ class BrushBoolean(ModifierProperties):
for cutter in cutters:
mode = "DIFFERENCE" if self.mode == "SLICE" else self.mode
set_cutter_properties(context, canvas, cutter, self.mode, parent=prefs.parent, collection=prefs.use_collection)
display = 'WIRE' if prefs.wireframe else 'BOUNDS'
set_cutter_properties(context, cutter, self.mode, display=display, collection=prefs.use_collection)
add_boolean_modifier(self, context, canvas, cutter, mode, prefs.solver, pin=prefs.pin)
if prefs.parent:
change_parent(cutter, canvas)
canvas.booleans.canvas = True
@@ -210,7 +211,7 @@ class AutoBoolean(ModifierProperties):
# Apply modifiers on canvas & slices.
for obj, modifiers in new_modifiers.items():
modifiers = self._get_modifiers_to_apply(prefs, obj, modifiers)
modifiers = get_modifiers_to_apply(context, obj, modifiers)
apply_modifiers(context, obj, modifiers)
# Delete cutters.
@@ -220,19 +221,6 @@ class AutoBoolean(ModifierProperties):
return {'FINISHED'}
def _get_modifiers_to_apply(self, prefs, obj, new_modifiers) -> list:
"""Returns a list of modifiers that need to be applied based on add-on preferences."""
if prefs.apply_order == 'ALL':
modifiers = [mod for mod in obj.modifiers]
elif prefs.apply_order == 'BOOLEANS':
modifiers = new_modifiers
elif prefs.apply_order == 'BEFORE':
modifiers = list_pre_boolean_modifiers(obj)
return modifiers
class OBJECT_OT_boolean_auto_union(bpy.types.Operator, AutoBoolean):
bl_idname = "object.boolean_auto_union"
bl_label = "Boolean Union (Auto)"
@@ -20,9 +20,10 @@ else:
#### ------------------------------ REGISTRATION ------------------------------ ####
"""NOTE: Order of modules is important because of dependancies. Don't change without a reason."""
modules = [
carver_circle,
carver_box,
# carver_circle,
carver_polyline,
ui,
]
@@ -1,31 +1,26 @@
import bpy
import mathutils
import os
from mathutils import Vector
from .. import __file__ as base_file
from .common.base import (
CarverModifierKeys,
CarverBase,
)
from .common.properties import (
CarverOperatorProperties,
CarverModifierProperties,
CarverCutterProperties,
CarverArrayProperties,
CarverBevelProperties,
CarverPropsArray,
CarverPropsBevel,
)
from .common.types import (
Selection,
Mouse,
Workplane,
Cutter,
Effects,
)
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"
@@ -39,16 +34,16 @@ class OBJECT_WT_carve_box(bpy.types.WorkSpaceTool):
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_icon = os.path.join(os.path.dirname(base_file), "icons", "tool_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')]}),
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG'}, {"properties": None}),
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "shift": True}, {"properties": None}),
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "alt": True}, {"properties": None}),
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "shift": True, "alt": True}, {"properties": None}),
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True}, {"properties": None}),
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "shift": True}, {"properties": None}),
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "alt": True}, {"properties": None}),
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "shift": True, "alt": True}, {"properties": None}),
)
def draw_settings(context, layout, tool):
@@ -63,26 +58,21 @@ class MESH_WT_carve_box(OBJECT_WT_carve_box):
#### ------------------------------ OPERATORS ------------------------------ ####
class OBJECT_OT_carve_box(CarverBase, CarverModifierKeys, bpy.types.Operator,
CarverOperatorProperties, CarverModifierProperties, CarverCutterProperties,
CarverArrayProperties, CarverBevelProperties):
class OBJECT_OT_carve_box(CarverBase,
CarverPropsArray,
CarverPropsBevel):
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
shape = 'BOX'
aspect: bpy.props.EnumProperty(
name = "Aspect",
description = "The initial aspect",
items = (('FREE', "Free", "Use an unconstrained aspect"),
('FIXED', "Fixed", "Use a fixed 1:1 aspect")),
default = 'FREE',
@@ -100,12 +90,6 @@ class OBJECT_OT_carve_box(CarverBase, CarverModifierKeys, bpy.types.Operator,
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
@@ -114,36 +98,31 @@ class OBJECT_OT_carve_box(CarverBase, CarverModifierKeys, bpy.types.Operator,
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)]
# Validate Selection
self.objects = Selection(*self.validate_selection(context))
# initialize_empty_values
self.verts = []
self.duplicates = []
self.cutter = None
self.view_depth = mathutils.Vector()
self.cached_mouse_position = () # needed_for_custom_modifier_keys
if len(self.objects.selected) == 0:
self.report({'WARNING'}, "Select mesh objects that should be carved")
bpy.ops.view3d.select_box('INVOKE_DEFAULT')
return {'CANCELLED'}
# Initialize Core Components
self.mouse = Mouse().from_event(event)
self.workplane = Workplane(*self.calculate_workplane(context))
self.cutter = Cutter(*self.create_cutter(context))
self.effects = Effects().from_invoke(self, context)
# 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
"""Important for storing context as it was when operator was invoked (untouched by the modal)."""
self.phase = "DRAW"
self.initial_origin = self.origin # Initial shape origin.
self.initial_aspect = self.aspect # Initial shape aspect.
self._stored_phase = "DRAW"
# Add Draw Handler
self._handle = bpy.types.SpaceView3D.draw_handler_add(carver_shape_box, (self, context, self.shape), 'WINDOW', 'POST_PIXEL')
self._handler = bpy.types.SpaceView3D.draw_handler_add(self.draw_shaders,
(context,),
'WINDOW', 'POST_VIEW')
context.window.cursor_set("MUTE")
context.window_manager.modal_handler_add(self)
@@ -152,110 +131,177 @@ class OBJECT_OT_carve_box(CarverBase, CarverModifierKeys, bpy.types.Operator,
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)
self.status(context)
# 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)
self.event_aspect(context, event)
self.event_origin(context, event)
self.event_rotate(context, event)
self.event_bevel(context, event)
self.event_array(context, event)
self.event_flip(context, event)
self.event_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'}:
if event.type in {'MIDDLEMOUSE'}:
return {'PASS_THROUGH'}
if event.type in {'WHEELUPMOUSE', 'WHEELDOWNMOUSE'}:
if self.phase != "BEVEL":
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
self.mouse.current = Vector((event.mouse_region_x, event.mouse_region_y))
# rotate
elif self.rotate:
self.rotation = event.mouse_region_x * 0.01
# Draw
if self.phase == "DRAW":
self.update_cutter_shape(context)
# 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)
# Extrude
elif self.phase == "EXTRUDE":
self.set_extrusion_depth(context)
# 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)
elif event.type == 'LEFTMOUSE':
# Confirm Shape
if self.phase == "DRAW" and event.value == 'RELEASE':
"""
Protection against creating a very small rectangle (or even with 0 dimensions)
by clicking and releasing very quickly, in a very small distance.
"""
delta_x = abs(event.mouse_region_x - self.mouse.initial[0])
delta_y = abs(event.mouse_region_y - self.mouse.initial[1])
min_distance = 5
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)
if delta_x < min_distance or delta_y < min_distance:
self.finalize(context, clean_up=True, abort=True)
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
self.extrude_cutter(context)
self.Cut(context)
if delta_x > min_distance or delta_y > min_distance:
# Not setting depth manually, performing a cut here.
if self.depth != 'MANUAL':
self.confirm(context)
return {'FINISHED'}
else:
return {'RUNNING_MODAL'}
# Confirm Depth
if self.phase == "EXTRUDE" and event.value == 'PRESS':
self.confirm(context)
return {'FINISHED'}
# Cancel
elif event.type in {'RIGHTMOUSE', 'ESC'}:
self.cancel(context)
self.finalize(context, clean_up=True, abort=True)
return {'FINISHED'}
return {'RUNNING_MODAL'}
def status(cls, context):
"""Set the status bar text to modal modifier keys."""
# Draw
def modal_keys_draw(self, context):
layout = self.layout
row = layout.row(align=True)
row.label(text="", icon='MOUSE_MOVE')
row.label(text="Draw")
row.label(text="", icon='MOUSE_LMB')
row.label(text="Confirm")
row.label(text="", icon='MOUSE_MMB')
row.label(text="Rotate View")
row.label(text="", icon='MOUSE_RMB')
row.label(text="Cancel")
row.label(text="", icon='EVENT_SPACEKEY')
row.label(text=" Move")
row.label(text="", icon='EVENT_R')
row.label(text="Rotate")
row.label(text="", icon='KEY_SHIFT')
row.label(text="Aspect")
row.label(text="", icon='EVENT_ALT')
row.label(text=" Origin")
row.label(text="", icon='EVENT_LEFT_ARROW')
row.label(text="", icon='EVENT_DOWN_ARROW')
row.label(text="", icon='EVENT_RIGHT_ARROW')
row.label(text="", icon='EVENT_UP_ARROW')
row.label(text="Array")
row.label(text="", icon='EVENT_B')
row.label(text="Bevel")
# Restore rest of the status bar.
layout.separator_spacer()
layout.template_reports_banner()
layout.separator_spacer()
layout.template_running_jobs()
layout.separator_spacer()
row = layout.row()
row.alignment = "RIGHT"
text = context.screen.statusbar_info()
row.label(text=text + " ")
# Extrude
def modal_keys_extrude(self, context):
layout = self.layout
row = layout.row(align=True)
row.label(text="", icon='MOUSE_MOVE')
row.label(text="Set Depth")
row.label(text="", icon='MOUSE_LMB')
row.label(text="Confirm")
row.label(text="", icon='MOUSE_MMB')
row.label(text="Rotate View")
row.label(text="", icon='MOUSE_RMB')
row.label(text="Cancel")
row.label(text="", icon='EVENT_SPACEKEY')
row.label(text=" Move")
row.label(text="", icon='EVENT_R')
row.label(text="Rotate")
row.label(text="", icon='EVENT_F')
row.label(text="Flip Direction")
row.label(text="", icon='EVENT_LEFT_ARROW')
row.label(text="", icon='EVENT_DOWN_ARROW')
row.label(text="", icon='EVENT_RIGHT_ARROW')
row.label(text="", icon='EVENT_UP_ARROW')
row.label(text="Array")
row.label(text="", icon='EVENT_B')
row.label(text="Bevel")
# Restore rest of the status bar.
layout.separator_spacer()
layout.template_reports_banner()
layout.separator_spacer()
layout.template_running_jobs()
layout.separator_spacer()
row = layout.row()
row.alignment = "RIGHT"
text = context.screen.statusbar_info()
row.label(text=text + " ")
# Missing keys:
# Wheelup and Wheeldown to control bevel segments when B is pressed.
# A to adjust array gap when array effect is used.
if cls.phase == 'DRAW':
context.workspace.status_text_set(modal_keys_draw)
elif cls.phase == 'EXTRUDE':
context.workspace.status_text_set(modal_keys_extrude)
#### ------------------------------ REGISTRATION ------------------------------ ####
@@ -5,6 +5,7 @@ from .. import __file__ as base_file
from .common.ui import (
carver_ui_common,
)
from .carver_box import OBJECT_OT_carve_box
description = "Cut primitive shapes into mesh objects with brush"
@@ -19,21 +20,70 @@ class OBJECT_WT_carve_circle(bpy.types.WorkSpaceTool):
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_icon = os.path.join(os.path.dirname(base_file), "icons", "tool_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')]}),
("object.carve_circle", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG'}, {"properties": None}),
("object.carve_circle", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "shift": True}, {"properties": None}),
("object.carve_circle", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "alt": True}, {"properties": None}),
("object.carve_circle", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "shift": True, "alt": True}, {"properties": None}),
("object.carve_circle", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True}, {"properties": None}),
("object.carve_circle", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "shift": True}, {"properties": None}),
("object.carve_circle", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "alt": True}, {"properties": None}),
("object.carve_circle", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "shift": True, "alt": True}, {"properties": None}),
)
def draw_settings(context, layout, tool):
props = tool.operator_properties("object.carve_box")
props = tool.operator_properties("object.carve_circle")
carver_ui_common(context, layout, props)
class MESH_WT_carve_circle(OBJECT_WT_carve_circle):
bl_context_mode = 'EDIT_MESH'
#### ------------------------------ OPERATORS ------------------------------ ####
class OBJECT_OT_carve_circle(OBJECT_OT_carve_box):
bl_idname = "object.carve_circle"
bl_label = "Box Carve"
bl_description = description
# SHAPE-properties
shape = 'CIRCLE'
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,
)
aspect: bpy.props.EnumProperty(
name = "Aspect",
description = "The initial aspect",
items = (('FREE', "Free", "Use an unconstrained aspect"),
('FIXED', "Fixed", "Use a fixed 1:1 aspect")),
default = 'FIXED',
)
origin: bpy.props.EnumProperty(
name = "Origin",
description = "The initial position for placement",
items = (('EDGE', "Edge", ""),
('CENTER', "Center", "")),
default = 'CENTER',
)
#### ------------------------------ 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)
@@ -1,31 +1,27 @@
import bpy
import mathutils
import math
import os
from mathutils import Vector
from bpy_extras import view3d_utils
from .. import __file__ as base_file
from .common.base import (
CarverModifierKeys,
CarverBase,
)
from .common.properties import (
CarverOperatorProperties,
CarverModifierProperties,
CarverCutterProperties,
CarverArrayProperties,
CarverPropsArray,
)
from .common.types import (
Selection,
Mouse,
Workplane,
Cutter,
Effects,
)
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"
@@ -39,7 +35,7 @@ class OBJECT_WT_carve_polyline(bpy.types.WorkSpaceTool):
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_icon = os.path.join(os.path.dirname(base_file), "icons", "tool_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),
@@ -61,8 +57,8 @@ class MESH_WT_carve_polyline(OBJECT_WT_carve_polyline):
#### ------------------------------ OPERATORS ------------------------------ ####
class OBJECT_OT_carve_polyline(CarverBase, CarverModifierKeys, bpy.types.Operator,
CarverOperatorProperties, CarverModifierProperties, CarverCutterProperties, CarverArrayProperties):
class OBJECT_OT_carve_polyline(CarverBase,
CarverPropsArray):
bl_idname = "object.carve_polyline"
bl_label = "Polyline Carve"
bl_description = description
@@ -70,11 +66,11 @@ class OBJECT_OT_carve_polyline(CarverBase, CarverModifierKeys, bpy.types.Operato
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,
)
shape = 'POLYLINE'
origin = None
aspect = None
rotation = 0
@classmethod
def poll(cls, context):
@@ -82,34 +78,29 @@ class OBJECT_OT_carve_polyline(CarverBase, CarverModifierKeys, bpy.types.Operato
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)]
# Validate Selection
self.objects = Selection(*self.validate_selection(context))
# 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
if len(self.objects.selected) == 0:
bpy.ops.view3d.select('INVOKE_DEFAULT')
return {'CANCELLED'}
# cached_variables
"""Important for storing context as it was when operator was invoked (untouched by the modal)"""
self.initial_selection = context.selected_objects
# Initialize Core Components
self.mouse = Mouse().from_event(event)
self.workplane = Workplane(*self.calculate_workplane(context))
self.cutter = Cutter(*self.create_cutter(context))
self.effects = Effects().from_invoke(self, context)
# 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
# cached_variables
"""Important for storing context as it was when operator was invoked (untouched by the modal)."""
self.phase = "DRAW"
self._distance_from_first = 0
self._stored_phase = "DRAW"
# Add Draw Handler
self._handle = bpy.types.SpaceView3D.draw_handler_add(carver_shape_polyline, (self, context), 'WINDOW', 'POST_PIXEL')
self._handler = bpy.types.SpaceView3D.draw_handler_add(self.draw_shaders,
(context,),
'WINDOW', 'POST_VIEW')
context.window.cursor_set("MUTE")
context.window_manager.modal_handler_add(self)
@@ -117,114 +108,284 @@ class OBJECT_OT_carve_polyline(CarverBase, CarverModifierKeys, bpy.types.Operato
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)
# Status Bar Text
self.status(context)
# 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)
self.event_array(context, event)
self.event_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'}:
if event.type in {'MIDDLEMOUSE'}:
return {'PASS_THROUGH'}
if event.type in {'WHEELUPMOUSE', 'WHEELDOWNMOUSE'}:
if self.phase != "BEVEL":
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
self.mouse.current = Vector((event.mouse_region_x, 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
if self.phase == "DRAW":
# Calculate the distance from the initial mouse position.
if self.mouse.current_3d:
first_vert_world = self.cutter.obj.matrix_world @ self.cutter.verts[0].co
first_vert_screen = view3d_utils.location_3d_to_region_2d(context.region,
context.region_data,
first_vert_world)
distance_screen = (Vector(self.mouse.current) - first_vert_screen).length
self._distance_from_first = max(100 - distance_screen, 0)
# 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)
self.update_cutter_shape(context)
# 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)
# Extrude
elif self.phase == "EXTRUDE":
self.set_extrusion_depth(context)
# 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)
elif event.type == 'LEFTMOUSE' and event.value == 'PRESS':
if self.phase == "DRAW":
# Confirm Shape (if clicked on the first vert)
if self._distance_from_first > 75:
verts = self.cutter.verts
if len(verts) > 3:
self._remove_polyline_point(context, jump_mouse=False)
self.extrude_cutter(context)
self.Cut(context)
# Not setting depth manually, performing a cut here.
if self.depth != 'MANUAL':
self.confirm(context)
return {'FINISHED'}
else:
return {'RUNNING_MODAL'}
# 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'}
# Add Point
else:
self._insert_polyline_point()
# Confirm Depth
if self.phase == "EXTRUDE":
self.confirm(context)
return {'FINISHED'}
# Confirm
elif event.type == 'RET':
verts = self.cutter.verts
if len(verts) > 2:
# Confirm Shape
if self.phase == "DRAW" and event.value == 'RELEASE':
self.extrude_cutter(context)
self.Cut(context)
# Not setting depth manually, performing a cut here.
if self.depth != 'MANUAL':
self.confirm(context)
return {'FINISHED'}
else:
return {'RUNNING_MODAL'}
# Confirm Depth
if self.phase == "EXTRUDE" and event.value == 'PRESS':
self.confirm(context)
return {'FINISHED'}
else:
self.report({'WARNING'}, "At least three points are required to make a polygonal shape")
# 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]
self._remove_polyline_point(context)
# Cancel
elif event.type in {'RIGHTMOUSE', 'ESC'}:
self.cancel(context)
self.finalize(context, clean_up=True, abort=True)
return {'FINISHED'}
return {'RUNNING_MODAL'}
def status(cls, context):
"""Set the status bar text to modal modifier keys."""
# Draw
def modal_keys_draw(self, context):
layout = self.layout
row = layout.row(align=True)
row.label(text="", icon='MOUSE_LMB')
row.label(text="Insert Point")
row.label(text="", icon='MOUSE_MMB')
row.label(text="Rotate View")
row.label(text="", icon='MOUSE_RMB')
row.label(text="Cancel")
row.label(text="", icon='KEY_RETURN')
row.label(text="Confirm")
row.label(text="", icon='EVENT_SPACEKEY')
row.label(text=" Move")
row.label(text="", icon='EVENT_BACKSPACE')
row.label(text=" Remove Last Point")
row.label(text="", icon='EVENT_LEFT_ARROW')
row.label(text="", icon='EVENT_DOWN_ARROW')
row.label(text="", icon='EVENT_RIGHT_ARROW')
row.label(text="", icon='EVENT_UP_ARROW')
row.label(text="Array")
# Restore rest of the status bar.
layout.separator_spacer()
layout.template_reports_banner()
layout.separator_spacer()
layout.template_running_jobs()
layout.separator_spacer()
row = layout.row()
row.alignment = "RIGHT"
text = context.screen.statusbar_info()
row.label(text=text + " ")
# Extrude
def modal_keys_extrude(self, context):
layout = self.layout
row = layout.row(align=True)
row.label(text="", icon='MOUSE_MOVE')
row.label(text="Set Depth")
row.label(text="", icon='MOUSE_LMB')
row.label(text="", icon='KEY_RETURN')
row.label(text="Confirm")
row.label(text="", icon='MOUSE_MMB')
row.label(text="Rotate View")
row.label(text="", icon='MOUSE_RMB')
row.label(text="Cancel")
row.label(text="", icon='EVENT_SPACEKEY')
row.label(text=" Move")
row.label(text="", icon='EVENT_R')
row.label(text="Rotate")
row.label(text="", icon='EVENT_F')
row.label(text="Flip Direction")
row.label(text="", icon='EVENT_LEFT_ARROW')
row.label(text="", icon='EVENT_DOWN_ARROW')
row.label(text="", icon='EVENT_RIGHT_ARROW')
row.label(text="", icon='EVENT_UP_ARROW')
row.label(text="Array")
# Restore rest of the status bar.
layout.separator_spacer()
layout.template_reports_banner()
layout.separator_spacer()
layout.template_running_jobs()
layout.separator_spacer()
row = layout.row()
row.alignment = "RIGHT"
text = context.screen.statusbar_info()
row.label(text=text + " ")
# Missing keys:
# A to adjust array gap when array effect is used.
if cls.phase == 'DRAW':
context.workspace.status_text_set(modal_keys_draw)
elif cls.phase == 'EXTRUDE':
context.workspace.status_text_set(modal_keys_extrude)
# Polyline-specific features.
def _insert_polyline_point(self):
"""Inserts a new vertex in the cutter geometry and connects it to the previous last one."""
bm = self.cutter.bm
verts = self.cutter.verts
x, y = self.mouse.current_3d.x, self.mouse.current_3d.y
# Lock the position of the last vert to cursor position at the moment of press.
last_vert = verts[-1]
last_vert.co = Vector((x, y, 0))
# Find and remove edge between last vert and the first vert.
if verts.index(last_vert) != 1:
first_vert = verts[0]
edge_to_remove = None
for edge in last_vert.link_edges:
if first_vert in edge.verts:
edge_to_remove = edge
break
if edge_to_remove:
self.cutter.bm.edges.remove(edge_to_remove)
# Insert new point in bmesh and connect to last one.
new_vert = bm.verts.new(Vector((x, y, 0)))
bm.edges.new([last_vert, new_vert])
verts.append(new_vert)
# Create a new face.
if len(verts) >= 3:
face = self.cutter.bm.faces.new(verts)
self.cutter.faces = [face]
# Update bmesh.
bm.to_mesh(self.cutter.mesh)
def _remove_polyline_point(self, context, jump_mouse=True):
"""Removes the last vertex in cutter geometry and moves cursor to the one before that."""
if self.phase != "DRAW":
return
obj = self.cutter.obj
bm = self.cutter.bm
verts = self.cutter.verts
faces = self.cutter.faces
if len(verts) <= 2:
return
# Remove last vertex.
last_vert = verts[-1]
bm.verts.remove(last_vert)
verts.pop()
# Reconstruct the face.
face = faces[0]
if face is not None:
if len(verts) >= 3:
new_face = bm.faces.new(verts)
faces[0] = new_face
else:
faces[0] = None
# Create an edge between new last vertex and the first vertex.
new_last = verts[-1]
first_vert = verts[0]
edge_exists = any(first_vert in edge.verts for edge in new_last.link_edges)
if not edge_exists:
bm.edges.new([new_last, first_vert])
# Update bmesh.
bm.to_mesh(self.cutter.mesh)
# Jump mouse to the new last vert.
if jump_mouse:
vert_world = obj.matrix_world @ new_last.co
screen_pos = view3d_utils.location_3d_to_region_2d(context.region,
context.region_data,
vert_world)
if screen_pos:
context.window.cursor_warp(int(screen_pos.x), int(screen_pos.y))
#### ------------------------------ REGISTRATION ------------------------------ ####
File diff suppressed because it is too large Load Diff
@@ -2,25 +2,82 @@ import bpy
import math
# Import Custom Icons
from ... import icons
svg_icons = icons.svg_icons["main"]
icon_measure = svg_icons["MEASURE"].icon_id
icon_cpu = svg_icons["CPU"].icon_id
#### ------------------------------ PROPERTIES ------------------------------ ####
class CarverOperatorProperties():
class CarverPropsOperator():
# 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',
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 = 'MODIFIER',
)
alignment: bpy.props.EnumProperty(
name = "Alignment",
items = (('SURFACE', "Surface", "Align cutters to the surface normal of the mesh under the mouse", 'SNAP_NORMAL', 0),
('VIEW', "View", "Align cutters to the current view", 'VIEW_CAMERA_UNSELECTED', 1),
('CURSOR', "3D Cursor", "Align cutters to the 3D cursor orientation", 'ORIENTATION_CURSOR', 2),
('GRID', "Grid", "Align cutters to the world grid", 'GRID', 3)),
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 derived from 3D cursors location", 'PIVOT_CURSOR', 1)),
default = 'VIEW',
items = (('MANUAL', "Manual", "Depth can be manually set after creating a cutter shape", icon_measure, 0),
('AUTO', "Auto", "Depth is set automatically to cover selected objects entirely", icon_cpu, 1),
('CURSOR', "3D Cursor", "Depth is set to 3D cursors location", 'PIVOT_CURSOR', 2)),
default = 'MANUAL',
)
class CarverModifierProperties():
class CarverPropsShape():
# SHAPE-properties
orientation: bpy.props.EnumProperty(
name = "Orientation",
description = "Orientation method for the shape placement",
items = (('FACE', "Face Normal", "Orient the shape along the normal of the face"),
('CLOSEST_EDGE', "Closest Edge", "Orient the shape along the closest edge of the face"),
('LONGEST_EDGE', "Longest Edge", "Orient the shape along the longest edge of the face")),
default = 'CLOSEST_EDGE',
)
offset: bpy.props.FloatProperty(
name = "Offset from Surface",
description = ("Distance between the shape and the surface of the mesh.\n"
"Offset is important for avoiding Z-fighting issues and solver failures"),
min = 0.0, soft_max = 0.1,
default = 0.01,
)
align_to_all: bpy.props.BoolProperty(
name = "Align to Anything",
description = "Use all visible objects for surface alignment, not just selected objects",
default = True,
)
alignment_axis: bpy.props.EnumProperty(
name = "Alignment Axis",
description = "Which axis of the world grid or 3D cursor should be used for workplane alignment",
items = (('X', "X", ""),
('Y', "Y", ""),
('Z', "Z", "")),
default = 'Z',
)
flip_direction: bpy.props.BoolProperty(
name = "Flip Direction",
description = "Change which way the geometry is extruded",
options = {'SKIP_SAVE', 'HIDDEN', 'SKIP_PRESET', },
default = False,
)
class CarverPropsModifier():
# MODIFIER-properties
solver: bpy.props.EnumProperty(
name = "Solver",
@@ -37,7 +94,7 @@ class CarverModifierProperties():
)
class CarverCutterProperties():
class CarverPropsCutter():
# CUTTER-properties
hide: bpy.props.BoolProperty(
name = "Hide Cutter",
@@ -50,6 +107,21 @@ class CarverCutterProperties():
"If there is no active object in selection cutters parent might be chosen seemingly randomly"),
default = True,
)
display: bpy.props.EnumProperty(
name = "Cutter Display",
items = (('WIRE', "Wire", "Display the cutter object as a wireframe"),
('BOUNDS', "Bounds", "Display only the bounds of the cutter object")),
default = 'BOUNDS'
)
cutter_origin: bpy.props.EnumProperty(
name = "Cutter Origin Point",
items = (('CENTER_OBJ', "Bounding Box", "Put the object origin at the center of the cutters bounding box"),
('CENTER_MESH', "Geometry", "Put the object origin at the center of the cutters geometry (not including effects)"),
('FACE_CENTER', "First Face", "Put the object origin at the center of cutters first face (i.e. shape)"),
('MOUSE_INITIAL', "Mouse Click", "Put the object origin at the point where mouse was first clicked"),
('CANVAS', "Same as Canvas", "Put the object origin of the cutter to the origin point of the cutter")),
default = 'CENTER_MESH',
)
auto_smooth: bpy.props.BoolProperty(
name = "Shade Auto Smooth",
@@ -66,7 +138,7 @@ class CarverCutterProperties():
)
class CarverArrayProperties():
class CarverPropsArray():
# ARRAY-properties
rows: bpy.props.IntProperty(
name = "Rows",
@@ -74,60 +146,41 @@ class CarverArrayProperties():
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,
gap: bpy.props.FloatProperty(
name = "Gap",
description = "Spacing between duplicates, both in rows and columns (relative unit)",
min = 1, soft_max = 10,
default = 1.1,
)
class CarverBevelProperties():
class CarverPropsBevel():
# 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,
min = 1, 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,
bevel_width: bpy.props.FloatProperty(
name = "Bevel Width",
min = 0, soft_max = 5,
default = 0.1,
)
bevel_profile: bpy.props.FloatProperty(
name = "Bevel Profile",
description = "The bevel profile shape (0.5 = round)",
min = 0, max = 1,
default = 0.5,
)
@@ -0,0 +1,292 @@
import bpy
import math
import os
from mathutils import Vector, Matrix
from ...functions.mesh import (
ensure_attribute,
shade_smooth_by_angle,
)
from ...functions.modifier import (
add_modifier_asset,
)
#### ------------------------------ CLASSES ------------------------------ ####
class Selection:
"""Storage of viable selected and active object(s) throughout the modal."""
def __init__(self, selected, active):
self.selected: list = selected
self.active = active
self.modifiers = {}
class Mouse:
"""
Mouse positions throughout different phases of the modal operator.
Each class variable is a 2D vector in screen space (x, y).
"""
def __init__(self):
self.initial = Vector()
self.current = Vector()
self.extrude = Vector()
self.cached = Vector() # Used for custom modifier keys.
self.current_3d = Vector()
self.cached_3d = Vector()
@classmethod
def from_event(self, event):
self.initial = Vector((event.mouse_region_x, event.mouse_region_y))
self.current = Vector((event.mouse_region_x, event.mouse_region_y))
self.current_3d = None
return self
class Workplane:
"""Local 3D coordinate system used as the drawing plane for creating shapes."""
def __init__(self, matrix, location, normal):
self.matrix: Matrix = matrix # full 4x4 transform matrix.
self.location: Vector = location # origin point of the plane in world space.
self.normal: Vector = normal # perpendicular direction of the plane.
class Cutter:
"""Object created for cutting, as well as it's `bmesh`, and other properties."""
def __init__(self, obj, mesh, bm, faces, verts):
self.obj = obj
self.mesh = mesh
self.bm = bm
self.faces: list = faces
self.verts: list = verts
self.center = Vector() # Center of the geometry.
# Effects
class Effects:
def __init__(self):
self.array = None
self.bevel = None
self.smooth = None
self.weld = None
def from_invoke(self, cls, context):
"""Add modifiers to the cutter object during invoke, if they're enabled on tool level."""
# Smooth by Angle
if cls.auto_smooth:
self.add_auto_smooth_modifier(cls, context)
# Array
if cls.rows > 1 or cls.columns > 1:
self.add_array_modifier(cls)
else:
self.array = None
# Bevel
if hasattr(cls, "use_bevel") and cls.use_bevel:
self.add_bevel_modifier(cls, affect='VERTICES')
else:
self.bevel = None
return self
def update(self, cls, effect):
"""Update bevel modifier during modal."""
# Update array count.
if effect == 'ARRAY_COUNT':
if self.array is None:
self.add_array_modifier(cls)
else:
if cls.columns > 1 or cls.rows > 1:
self.array["Socket_2"] = cls.columns
self.array["Socket_3"] = cls.rows
# Remove modifier if it's no longer needed.
if cls.columns == 1 and cls.rows == 1:
cls.cutter.obj.modifiers.remove(self.array)
self.array = None
# Update array gap.
if effect == 'ARRAY_GAP':
if cls.columns > 1 or cls.row > 1:
if self.array is not None:
self.array["Socket_4"] = cls.gap
# Force the modifier to update in viewport.
self.array.show_viewport = False
self.array.show_viewport = True
# Update bevel width & segments
if effect == 'BEVEL':
self.bevel.segments = cls.bevel_segments
self.bevel.width = cls.bevel_width
# Array
def add_array_modifier(self, cls):
"""Adds an array modifier(s) on the cutter object."""
cutter = cls.cutter.obj
# Load geometry nodes modifier asset.
if self.array is None:
root = os.path.abspath(os.path.join(__file__, "..", "..", ".."))
assets_path = os.path.join(root, "assets.blend")
mod = add_modifier_asset(cutter, path=assets_path, asset="cutter_array")
if not mod:
cls.report({'WARNING'}, "Array modifier cannot be loaded for cutter")
return
# Columns
if cls.columns > 1:
mod["Socket_2"] = cls.columns
# Rows
if cls.rows > 1:
mod["Socket_3"] = cls.rows
# Gap
mod["Socket_4"] = cls.gap
self.array = mod
# Bevel
def add_bevel_modifier(self, cls, affect='EDGES'):
"""Adds a bevel modifier on the cutter object."""
cutter = cls.cutter.obj
bm = cls.cutter.bm
faces = cls.cutter.faces
mod = cutter.modifiers.new("cutter_bevel", 'BEVEL')
mod.limit_method = 'WEIGHT'
mod.segments = cls.bevel_segments
mod.width = cls.bevel_width
mod.profile = cls.bevel_profile
"""NOTE:
In order to allow beveling during the shape creation phase,
when we only have one face, we need to bevel vertices instead of edges,
and then change it to edges when cutter is manifold (and transfer weights).
"""
mod.affect = affect
if affect == 'EDGES':
attr = ensure_attribute(bm, "bevel_weight_edge", 'EDGE')
# Mark all edges except ones belonging to original and extruded face.
for edge in bm.edges:
if edge in faces[0].edges:
continue
if edge in faces[-1].edges:
continue
edge[attr] = 1.0
elif affect == 'VERTICES':
attr = ensure_attribute(bm, "bevel_weight_vert", 'VERTEX')
face = cls.cutter.faces[0]
# Mark vertices of the original face.
verts = [vert for vert in face.verts]
for v in verts:
v[attr] = 1.0
# Add Weld modifier (necessary for merging overlapping vertices).
# Otherwise live cut produces corrupted booleans because of non-manifold geometry.
self.add_weld_modifier(cls)
self.bevel = mod
def transfer_bevel_weights(self, cls):
"""Transfer bevel weights from vertices to edges."""
if not cls.use_bevel:
return
bm = cls.cutter.bm
faces = cls.cutter.faces
# Ensure default edge weights attribute.
edge_attr = ensure_attribute(bm, "bevel_weight_edge", 'EDGE')
for edge in bm.edges:
if edge in faces[0].edges:
continue
if edge in faces[-1].edges:
continue
edge[edge_attr] = 1.0
self.bevel.affect = 'EDGES'
# Smooth by Angle
def add_auto_smooth_modifier(self, cls, context):
"""Adds a 'Smooth by Angle' modifier on cutter object, a.k.a. Auto Smooth."""
obj = cls.cutter.obj
mesh = cls.cutter.mesh
bm = cls.cutter.bm
modifier_asset_path = "nodes\\geometry_nodes_essentials.blend\\NodeTree\\Smooth by Angle"
modifier_asset_file = modifier_asset_path[:modifier_asset_path.find(".blend") + 6]
modifier_asset_name = modifier_asset_path.rsplit("\\", 1)[1]
# Try adding modifier with `bpy.ops` operator(s) first.
context_override = {
"object": obj,
"active_object": obj,
"selected_objects": [obj],
"selected_editable_objects": [obj],
}
with context.temp_override(**context_override):
try:
# Try adding the modifier with `shade_auto_smooth` operator.
bpy.ops.object.shade_auto_smooth()
except:
# Try adding the modifier with path to Essentials library.
bpy.ops.object.modifier_add_node_group(asset_library_type="ESSENTIALS",
asset_library_identifier="",
relative_asset_identifier=modifier_asset_path)
mod = obj.modifiers.active
# Try loading the node group manually if `bpy.ops` operators fail.
if mod is None:
dir = os.path.join(os.path.dirname(bpy.app.binary_path), "5.0", "datafiles", "assets")
assets_path = os.path.join(dir, modifier_asset_file)
mod = add_modifier_asset(obj, path=assets_path, asset=modifier_asset_name)
# Resort to destructive editing if everything fails.
if mod is None:
print("Smooth by Angle modifier couldn't be added.")
print("Destructively marking sharp edges and smooth faces in the mesh")
shade_smooth_by_angle(bm, mesh, angle=math.degrees(cls.sharp_angle))
else:
# Set smoothing angle.
for face in bm.faces:
face.smooth = True
bm.to_mesh(mesh)
mod.use_pin_to_last = True
mod["Input_1"] = cls.sharp_angle
self.smooth = mod
# Weld
def add_weld_modifier(self, cls):
if self.weld is None:
self.weld = cls.cutter.obj.modifiers.new("cutter_weld", 'WELD')
return self.weld
@@ -7,13 +7,24 @@ from ... import __package__ as base_package
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)
if context.region.type == 'TOOL_HEADER':
layout.prop(props, "mode", text="")
layout.prop(props, "alignment", text="")
layout.prop(props, "depth", text="")
layout.prop(props, "solver", expand=True)
else:
# Use labels for Properties editor/sidebar.
layout.prop(props, "mode", text="Mode")
layout.prop(props, "alignment", text="Alignment")
layout.prop(props, "depth", text="Depth")
row = layout.row()
row.prop(props, "solver", expand=True)
layout.separator()
# Popovers
layout.popover("TOPBAR_PT_carver_shape", text="Shape")
layout.popover("TOPBAR_PT_carver_array", text="Array")
layout.popover("TOPBAR_PT_carver_effects", text="Effects")
layout.popover("TOPBAR_PT_carver_cutter", text="Cutter")
@@ -21,7 +32,7 @@ def carver_ui_common(context, layout, props):
#### ------------------------------ /popovers/ ------------------------------ ####
class TOPBAR_PT_carver_shape(bpy.types.Panel):
bl_label = "Carver Shape"
bl_label = "Cutter Shape"
bl_idname = "TOPBAR_PT_carver_shape"
bl_region_type = 'HEADER'
bl_space_type = 'TOPBAR'
@@ -32,12 +43,14 @@ class TOPBAR_PT_carver_shape(bpy.types.Panel):
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
# Box & Circle
if tool.idname == "object.carve_box" or tool.idname == "object.carve_circle":
props = tool.operator_properties("object.carve_box")
if tool.idname == "object.carve_box":
props = tool.operator_properties("object.carve_box")
else:
props = tool.operator_properties("object.carve_circle")
if tool.idname == "object.carve_circle":
layout.prop(props, "subdivision", text="Vertices")
@@ -45,29 +58,24 @@ class TOPBAR_PT_carver_shape(bpy.types.Panel):
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
if props.alignment == 'SURFACE':
layout.prop(props, "orientation")
layout.prop(props, "offset", text="Offset")
layout.prop(props, "align_to_all")
if props.alignment == 'CURSOR':
layout.prop(props, "alignment_axis", text="Align to", expand=True)
# Polyline
elif tool.idname == "object.carve_polyline":
props = tool.operator_properties("object.carve_polyline")
layout.prop(props, "closed")
if props.alignment == 'SURFACE':
layout.prop(props, "offset", text="Offset")
layout.prop(props, "align_to_all")
class TOPBAR_PT_carver_array(bpy.types.Panel):
bl_label = "Carver Array"
bl_idname = "TOPBAR_PT_carver_array"
class TOPBAR_PT_carver_effects(bpy.types.Panel):
bl_label = "Cutter Effects"
bl_idname = "TOPBAR_PT_carver_effects"
bl_region_type = 'HEADER'
bl_space_type = 'TOPBAR'
bl_category = 'Tool'
@@ -78,26 +86,35 @@ class TOPBAR_PT_carver_array(bpy.types.Panel):
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":
if tool.idname == "object.carve_box":
props = tool.operator_properties("object.carve_box")
elif tool.idname == "object.carve_circle":
props = tool.operator_properties("object.carve_circle")
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")
# Bevel
if tool.idname == 'object.carve_box':
header, panel = layout.panel("OBJECT_OT_carver_effects_bevel", default_closed=False)
header.label(text="Bevel")
if panel:
panel.prop(props, "use_bevel", text="Side Bevel")
col = panel.column(align=True)
col.prop(props, "bevel_segments", text="Segments")
col.prop(props, "bevel_width", text="Radius")
col.prop(props, "bevel_profile", text="Profile", slider=True)
# 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")
if props.use_bevel == False:
col.enabled = False
# Array
header, panel = layout.panel("OBJECT_OT_carver_effects_array", default_closed=False)
header.label(text="Array")
if panel:
col = panel.column(align=True)
col.prop(props, "columns")
col.prop(props, "rows")
col.prop(props, "gap")
class TOPBAR_PT_carver_cutter(bpy.types.Panel):
bl_label = "Carver Cutter"
@@ -112,23 +129,31 @@ class TOPBAR_PT_carver_cutter(bpy.types.Panel):
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":
if tool.idname == "object.carve_box":
props = tool.operator_properties("object.carve_box")
elif tool.idname == "object.carve_circle":
props = tool.operator_properties("object.carve_circle")
elif tool.idname == "object.carve_polyline":
props = tool.operator_properties("object.carve_polyline")
# modifier_&_cutter
col = layout.column()
row = col.row()
row.prop(props, "display", text="Display", expand=True)
col.prop(props, "pin", text="Pin Modifier")
if props.mode == 'MODIFIER':
col.prop(props, "parent")
col.prop(props, "hide")
col.prop(props, "cutter_origin", text="Origin")
# auto_smooth
layout.separator()
col = layout.column(align=True)
col.prop(props, "auto_smooth", text="Auto Smooth")
col.prop(props, "sharp_angle")
col1 = layout.column()
col1.prop(props, "sharp_angle")
if not props.auto_smooth:
col1.enabled = False
@@ -136,7 +161,7 @@ class TOPBAR_PT_carver_cutter(bpy.types.Panel):
classes = [
TOPBAR_PT_carver_shape,
TOPBAR_PT_carver_array,
TOPBAR_PT_carver_effects,
TOPBAR_PT_carver_cutter,
]
@@ -17,18 +17,6 @@
# Link to base names: Sybren, Texture renamer: Yadoob
# ###
bl_info = {
"name": "Material Utilities",
"author": "MichaleW, ChrisHinde",
"version": (2, 2, 2),
"blender": (3, 0, 0),
"location": "View3D > Shift + Q key",
"description": "Menu of material tools (assign, select..) in the 3D View",
"warning": "Beta",
"doc_url": "{BLENDER_MANUAL_URL}/addons/materials/material_utils.html",
"category": "Material"
}
"""
This script has several functions and operators, grouped for convenience:
@@ -1,12 +1,15 @@
schema_version = "1.0.0"
id = "material_utilities"
name = "Material Utilities"
version = "2.2.2"
version = "2.2.3"
tagline = "Menu of material tools (assign, select..) in the 3D View"
maintainer = "Community"
type = "add-on"
tags = ["Material"]
blender_version_min = "4.2.0"
license = ["SPDX:GPL-2.0-or-later"]
license = ["SPDX:GPL-3.0-or-later"]
website = "https://projects.blender.org/extensions/materials_utils"
copyright = ["2024 MichaleW", "2024 ChrisHinde"]
copyright = [
"2024 MichaleW",
"2024 ChrisHinde",
]
@@ -512,6 +512,8 @@ def mu_remove_all_materials(self, for_active_object = False):
objects = bpy.context.selected_editable_objects
for obj in objects:
if not hasattr(obj.data, "materials"):
continue
obj.data.materials.clear()
bpy.context.view_layer.objects.active = last_active
@@ -285,7 +285,16 @@ class VIEW3D_OT_materialutilities_remove_all_material_slots(bpy.types.Operator):
@classmethod
def poll(cls, context):
return (context.active_object is not None) and (context.active_object.mode != 'EDIT')
if (obj := context.active_object) is None:
cls.poll_message_set("No active object selected.")
return False
elif not hasattr(obj.data, "materials"):
cls.poll_message_set("Active object doesn't support materials.")
return False
elif obj.mode == "EDIT":
cls.poll_message_set("Active object is in EDIT mode.")
return False
return True
def draw(self, context):
layout = self.layout
@@ -6,6 +6,8 @@ import bpy
from bpy.utils import register_class, unregister_class
import importlib
_VPM_AGENT_IMMEDIATE_REGISTER_DONE = locals().get("_VPM_AGENT_IMMEDIATE_REGISTER_DONE", False)
module_names = (
"op_pie_wrappers",
"op_copy_to_selected",
@@ -57,10 +59,8 @@ def register_unregister_modules(modules: list, register: bool):
for c in m.registry:
try:
register_func(c)
except Exception as e:
print(
f"Warning: Pie Menus failed to {un}register class: {c.__name__}"
)
except (AttributeError, RuntimeError, TypeError, ValueError) as e:
print(f"Warning: Pie Menus failed to {un}register class: {c.__name__}")
print(e)
if hasattr(m, 'modules'):
@@ -78,8 +78,27 @@ def delayed_register(_scene=None):
register_unregister_modules(modules, True)
def register():
# NOTE: persistent=True must be set, otherwise this doesn't work when opening a .blend file directly from a file browser.
bpy.app.timers.register(delayed_register, first_interval=0.5, persistent=True)
"""
We prefer an *immediate* register during startup, because other add-ons may touch
keyconfig initialization very early, and Blender's keymap diff application appears
sensitive to timing.
If immediate registration fails (e.g. missing WM in edge cases), fall back to the
legacy timer-based delayed registration.
"""
global _VPM_AGENT_IMMEDIATE_REGISTER_DONE
if not _VPM_AGENT_IMMEDIATE_REGISTER_DONE:
try:
register_unregister_modules(modules, True)
_VPM_AGENT_IMMEDIATE_REGISTER_DONE = True
return
except Exception as e:
# Keep behavior unchanged (fallback to timer), but avoid raising during registration.
pass
# NOTE: persistent=True must be set, otherwise this doesn't work when opening
# a .blend file directly from a file browser.
bpy.app.timers.register(delayed_register, first_interval=0.0, persistent=True)
def unregister():
register_unregister_modules(reversed(modules), False)
@@ -1,7 +1,7 @@
schema_version = "1.0.0"
id = "viewport_pie_menus"
name = "3D Viewport Pie Menus"
version = "1.7.1"
version = "1.7.3"
tagline = "Various pie menus to speed up your workflow"
maintainer = "Community"
type = "add-on"
@@ -4,11 +4,17 @@
# This file requires (and is made possible by) Blender 5.0 due to the find_match() API call.
import hashlib
from typing import Callable, Any
import bpy
import json
from bpy.types import KeyMap, KeyMapItem, UILayout
if "ADDON_KEYMAPS" not in locals():
ADDON_KEYMAPS = []
# Preserve across Reload Scripts (module reload) when possible, but also keep
# names defined for static analyzers.
ADDON_KEYMAPS = locals().get("ADDON_KEYMAPS", [])
KMI_HASHES = locals().get("KMI_HASHES", {})
KEYMAP_ICONS = {
'Object Mode': 'OBJECT_DATAMODE',
@@ -29,20 +35,30 @@ KEYMAP_UI_NAMES = {
}
KMI_DEFAULTS = {
prop: KeyMapItem.bl_rna.properties[prop].default
for prop in KeyMapItem.bl_rna.properties.keys()
prop.identifier: prop.default
for prop in KeyMapItem.bl_rna.properties
if hasattr(prop, 'default')
}
def register_hotkey(
bl_idname,
*,
op_kwargs={},
hotkey_kwargs={'type': "SPACE", 'value': "PRESS"},
bl_idname,
*,
op_kwargs=None,
hotkey_kwargs=None,
keymap_name='Window'
):
global ADDON_KEYMAPS
wm = bpy.context.window_manager
if op_kwargs is None:
op_kwargs = {}
if hotkey_kwargs is None:
hotkey_kwargs = {'type': "SPACE", 'value': "PRESS"}
context = bpy.context
wm = context.window_manager
kmi_hash = any_to_hash(op_kwargs, hotkey_kwargs, keymap_name)
if kmi_hash in KMI_HASHES:
# Avoid re-registering on Reload Scripts.
return
space_type = wm.keyconfigs.default.keymaps[keymap_name].space_type
@@ -63,26 +79,27 @@ def register_hotkey(
# it is SUPPOSED TO stick around for ever.
# This allows Blender to store the associated user keymap, meaning the user's modifications
# will be stored and restored as expected, whenever the add-on is enabled again.
# if (addon_km, existing_kmi) not in ADDON_KEYMAPS:
# ADDON_KEYMAPS.append((addon_km, existing_kmi))
if (addon_km, existing_kmi) not in ADDON_KEYMAPS:
ADDON_KEYMAPS.append((addon_km, existing_kmi))
return
addon_kmi = addon_km.keymap_items.new(bl_idname, **hotkey_kwargs)
for key in op_kwargs:
value = op_kwargs[key]
setattr(addon_kmi.properties, key, value)
KMI_HASHES[kmi_hash] = (addon_km, addon_kmi)
ADDON_KEYMAPS.append((addon_km, addon_kmi))
def draw_hotkey_list(
context,
layout,
*,
compact=False,
debug=False,
sort_mode='BY_KEYMAP',
ignore_missing=False,
button_draw_func: callable=None,
):
context,
layout,
*,
compact=False,
debug=False,
sort_mode='BY_KEYMAP',
ignore_missing=False,
button_draw_func: Callable = None,
):
"""Draw the list of hotkeys registered by this add-on.
Will find the corresponding User KeyMapItems, which are safe to modify.
Supports two sorting modes:
@@ -99,7 +116,7 @@ def draw_hotkey_list(
if sort_mode == 'BY_OPERATOR':
layout = layout.column(align=True)
if compact == None:
if compact is None:
sidebar = get_sidebar(context)
if sidebar:
compact = sidebar.width < 600
@@ -130,41 +147,57 @@ def draw_hotkey_list(
if prev_kmi_name != kmi_name:
layout.separator()
draw_kmi(user_km, user_kmi, layout, compact=compact, button_draw_func=button_draw_func, debug=debug)
draw_kmi(
user_km,
user_kmi,
layout,
compact=compact,
button_draw_func=button_draw_func,
debug=debug
)
def get_user_kmis_of_addon(context) -> list[tuple[KeyMap, KeyMapItem]]:
def get_user_kmis_of_addon(context, *, do_update=True) -> list[tuple[KeyMap, KeyMapItem]]:
"""Return a list of (KeyMap, KeyMapItem) tuples of user-shortcuts (the ones that can be modified by user)."""
ret = []
assert bpy.app.version >= (5, 0, 0), "This function requires Blender 5.0 or later."
context.window_manager.keyconfigs.update()
if do_update:
context.window_manager.keyconfigs.update()
for addon_km, addon_kmi in ADDON_KEYMAPS:
user_km = context.window_manager.keyconfigs.user.keymaps.get(addon_km.name)
if not user_km:
# This should never happen.
print("Failed to find User KeyMap: ", addon_km.name)
continue
user_kmi = user_km.keymap_items.find_match(addon_km, addon_kmi)
if not user_kmi:
# This shouldn't really happen, but maybe it can, eg. if user changes idname.
print("Failed to find User KeyMapItem: ", addon_km.name, addon_kmi.idname)
continue
ret.append((user_km, user_kmi))
user_km, user_kmi = get_user_kmi_of_addon(context, addon_km, addon_kmi)
if user_kmi:
ret.append((user_km, user_kmi))
return ret
def get_user_kmi_of_addon(context, addon_km, addon_kmi) -> tuple[KeyMap | None, KeyMapItem | None]:
user_km = context.window_manager.keyconfigs.user.keymaps.get(addon_km.name)
if not user_km:
# This should never happen.
print("Failed to find User KeyMap: ", addon_km.name)
return None, None
user_kmi = user_km.keymap_items.find_match(addon_km, addon_kmi)
if not user_kmi:
# This shouldn't really happen, but maybe it can, eg. if user changes idname.
print("Failed to find User KeyMapItem: ", addon_km.name, addon_kmi.idname)
return None, None
return user_km, user_kmi
def get_kmi_ui_info(km, kmi) -> tuple[str, str, str]:
km_name = km.name
km_name: str = km.name
km_icon = KEYMAP_ICONS.get(km_name, 'BLANK1')
km_name = KEYMAP_UI_NAMES.get(km_name, km_name)
if kmi.properties and 'name' in kmi.properties:
name = kmi.properties.name
if name:
try:
if hasattr(bpy.types, kmi.properties.name):
bpy_type = getattr(bpy.types, kmi.properties.name)
kmi_name = bpy_type.bl_label
except:
else:
kmi_name = "Missing (code 1). Try restarting."
else:
kmi_name = "Missing (code 2). Try restarting."
@@ -174,7 +207,7 @@ def get_kmi_ui_info(km, kmi) -> tuple[str, str, str]:
bpy_type = getattr(bpy.ops, parts[0])
bpy_type = getattr(bpy_type, parts[1])
kmi_name = bpy_type.get_rna_type().name
except:
except (AttributeError, IndexError, TypeError):
kmi_name = "Missing (code 3). Try restarting."
return km_icon, km_name, kmi_name
@@ -194,30 +227,49 @@ def find_kmi_in_km_by_data(km: KeyMap, hotkey_kwargs: dict, op_idname: str, op_k
if value != getattr(kmi, key):
return False
want_to_crash = False
if want_to_crash:
# These checks cause https://projects.blender.org/Mets/CloudRig/issues/201
# They don't seem necessary.
if kmi.properties == None:
# IMPORTANT:
# `wm.keyconfigs.addon` is shared by *all* add-ons. If we only match on idname+hotkey,
# we may incorrectly treat another add-on's KeyMapItem as ours and skip registering,
# which prevents Blender from applying the user's stored overrides on next startup
# (manifesting as "prefs/hotkeys reset", eg. when used together with Pie Menu Editor).
#
# We therefore include operator properties in the match, but do it defensively:
# - only compare simple scalar types (str/int/float/bool/None)
# - if anything unexpected is encountered, treat it as a mismatch rather than risking
# false positives or Blender RNA edge-case crashes.
if op_kwargs:
try:
if kmi.properties is None:
return False
for key, expected in op_kwargs.items():
if key not in kmi.properties:
return False
actual = getattr(kmi.properties, key, None)
# Compare only stable scalar values.
scalar_types = (str, int, float, bool, type(None))
if isinstance(expected, scalar_types) and isinstance(actual, scalar_types):
if actual != expected:
return False
else:
# Unknown/complex value: do not assume a match.
return False
except (AttributeError, KeyError, TypeError, RuntimeError):
# Be conservative: if Blender throws, don't match.
return False
for key, value in op_kwargs.items():
if key not in kmi.properties:
return False
if value != kmi.properties[key]:
return False
return True
return next((kmi for kmi in km.keymap_items if is_kmi_matching(kmi, hotkey_kwargs, op_idname, op_kwargs)), None)
def draw_kmi(
km: KeyMap,
kmi: KeyMapItem,
layout: UILayout,
compact=False,
button_draw_func: callable=None,
debug=False,
):
km: KeyMap,
kmi: KeyMapItem,
layout: UILayout,
compact=False,
button_draw_func: Callable = None,
debug=False,
):
"""Draw a KeyMapItem in the provided UI.
This function is designed specifically to be used in an add-on's preferences:
- It does not allow removing the KeyMapItem, since add-on KMIs should never be removed.
@@ -249,14 +301,14 @@ def draw_kmi(
sub.prop(kmi, "type", text="", full_event=True)
if kmi.is_user_modified:
row2.context_pointer_set("keymap", km) # NOTE: Yes, this actually matters.
row2.operator(
"preferences.keyitem_restore", text="", icon='BACK'
).item_id = kmi.id
# Make `context.keymap` available in the drawing code (in this case blender's native code)
row2.context_pointer_set("keymap", km)
row2.operator("preferences.keyitem_restore", text="", icon='BACK').item_id = kmi.id
if debug and kmi.show_expanded:
layout.template_keymap_item_properties(kmi)
def get_sidebar(context):
if not context.area.type == 'VIEW_3D':
return None
@@ -264,16 +316,17 @@ def get_sidebar(context):
if region.type == 'UI':
return region
def find_matching_km_and_kmi(context, target_kc, src_km, src_kmi) -> tuple[KeyMap or None, KeyMapItem or None]:
def find_matching_km_and_kmi(context, target_kc, src_km, src_kmi) -> tuple[KeyMap | None, KeyMapItem | None]:
target_km = find_matching_keymap(context, target_kc, src_km)
if not target_km:
raise Exception(f"Failed to find KeyMap '{src_km.name}' in KeyConfig '{target_kc.name}'")
raise RuntimeError(f"Failed to find KeyMap '{src_km.name}' in KeyConfig '{target_kc.name}'")
kc_user = context.window_manager.keyconfigs.user
# If we want to find a matching User KeyMapItem, that's easy, because that's what the API was meant for.
if target_kc == kc_user:
return target_km, target_km.keymap_items.find_match(src_km, src_kmi)
user_km, user_kmi = src_km, src_kmi
user_km = src_km
# If we want to find any other type of KeyMapItem, we have to do it indirectly, since we can only directly check for matches in the User KeyConfig.
# So eg. if we want to find an Addon KeyMapItem based on a User KeyMapItem, we have to loop over all Addon KeyMapItems, and find which one matches with the given User KeyMapItem.
for target_kmi in target_km.keymap_items:
@@ -283,14 +336,14 @@ def find_matching_km_and_kmi(context, target_kc, src_km, src_kmi) -> tuple[KeyMa
return target_km, target_kmi
except RuntimeError:
print("Failed to find matching KeyMapItem for: ", target_km.name, target_kmi.to_string())
# raise Exception(f"Failed to find KeyMapItem '{src_kmi.idname}' ({src_kmi.to_string()}) in KeyConfig '{target_kc.name}', KeyMap '{target_km.name}'")
# We will return here eg. when looking for an add-on keymap in the default keyconfig.
return None, None
def find_matching_keymap(context, target_kc, src_km):
"""Find the equivalent keymap in another keyconfig."""
kc_user = context.window_manager.keyconfigs.user
# If we want to find a matching User KeyMap, that's easy, because that's what the API was meant for.
@@ -306,6 +359,7 @@ def find_matching_keymap(context, target_kc, src_km):
if match == src_km:
return km
class WINDOW_OT_restore_deleted_hotkeys(bpy.types.Operator):
bl_idname = "window.restore_deleted_hotkeys"
bl_description = "Restore any missing built-in or add-on hotkeys.\n(These should be disabled instead of being deleted.)\nThis operation cannot be undone!"
@@ -318,6 +372,7 @@ class WINDOW_OT_restore_deleted_hotkeys(bpy.types.Operator):
self.report({'INFO'}, f"Restored {num_restored} deleted keymaps.")
return {'FINISHED'}
def restore_deleted_keymap_items_global(context) -> int:
"""Deleting built-in or add-on KeyMapItems should never be done by users, as there's no way to recover them.
Changing the operator name also shouldn't be done, since that makes it impossible to track modifications.
@@ -339,6 +394,7 @@ def restore_deleted_keymap_items_global(context) -> int:
total_restored += num_restored
return total_restored
def restore_deleted_keymap_items(context, user_km_name) -> int:
keyconfigs = context.window_manager.keyconfigs
user_kc = keyconfigs.user
@@ -348,7 +404,7 @@ def restore_deleted_keymap_items(context, user_km_name) -> int:
user_km = user_kc.keymaps[user_km_name]
# Step 1: Store modified and added KeyMapItems in a temp keymap.
temp_km_name = "temp_"+user_km_name
temp_km_name = "temp_" + user_km_name
temp_km = user_kc.keymaps.new(temp_km_name)
kmis_user_modified = []
kmis_user_defined = []
@@ -359,8 +415,8 @@ def restore_deleted_keymap_items(context, user_km_name) -> int:
continue
if user_kmi.is_user_modified:
temp_kmi = temp_km.keymap_items.new_from_item(user_kmi)
# Find the original keymap in either the Blender default or Addon KeyConfigs.
# Not sure if this works with presets like Industry Compatible keymap,
# Find the original keymap in either the Blender default or Addon KeyConfigs.
# Not sure if this works with presets like Industry Compatible keymap,
# but I assume they change the contents of the "default" keyconfig, so it would work.
default_km, default_kmi = find_matching_km_and_kmi(context, default_kc, user_km, user_kmi)
if not default_kmi:
@@ -370,7 +426,7 @@ def restore_deleted_keymap_items(context, user_km_name) -> int:
# Step 2: Restore User KeyMap to default.
num_kmis = len(user_km.keymap_items)
user_km.restore_to_default()
# XXX: restore_to_default() will shuffle the memory addresses, so we need to re-reference user_km.
# NOTE: restore_to_default() will shuffle the memory addresses, so we need to re-reference user_km.
# I don't think this was the case pre-Blender 5.0!!
user_km = user_kc.keymaps[user_km_name]
temp_km = user_kc.keymaps[temp_km_name]
@@ -381,7 +437,8 @@ def restore_deleted_keymap_items(context, user_km_name) -> int:
for (default_km, default_kmi), (temp_km, temp_kmi) in kmis_user_modified:
user_km, user_kmi = find_matching_km_and_kmi(context, user_kc, default_km, default_kmi)
for key in ('active', 'alt', 'any', 'ctrl', 'hyper', 'key_modifier', 'map_type', 'oskey', 'shift', 'repeat', 'type', 'value'):
for key in ('active', 'alt', 'any', 'ctrl', 'hyper', 'key_modifier',
'map_type', 'oskey', 'shift', 'repeat', 'type', 'value'):
setattr(user_kmi, key, getattr(temp_kmi, key))
if temp_kmi.properties:
for key in temp_kmi.properties.keys():
@@ -401,4 +458,23 @@ def restore_deleted_keymap_items(context, user_km_name) -> int:
return len(user_km.keymap_items) - num_kmis
def any_to_hash(*args) -> str:
"""Hash whatever."""
def stable(obj: Any):
# Make hashing deterministic across runs and independent of dict insertion order.
# Keep it conservative to avoid surprises with Blender RNA objects.
if isinstance(obj, dict):
return {str(k): stable(v) for k, v in sorted(obj.items(), key=lambda kv: str(kv[0]))}
if isinstance(obj, (list, tuple)):
return [stable(v) for v in obj]
return obj
try:
stringified = json.dumps([stable(a) for a in args], sort_keys=True, separators=(",", ":"), default=str)
except (TypeError, ValueError):
# Fallback: last resort stringification
stringified = ";".join([str(arg) for arg in args])
return hashlib.sha256(stringified.encode("utf-8")).hexdigest()
registry = [WINDOW_OT_restore_deleted_hotkeys]
@@ -1,12 +1,16 @@
import json
import os
from pathlib import Path
import bpy, json, os
from bpy.types import PropertyGroup
import bpy
from bpy.types import AddonPreferences, PropertyGroup
from rna_prop_ui import IDPropertyGroup
from bpy.types import AddonPreferences
from .. import __package__ as base_package
assert base_package
class PrefsFileSaveLoadMixin:
"""Mix-in class that can be used by any add-on to store their preferences in a file,
so that they don't get lost when the add-on is disabled.
@@ -48,6 +52,7 @@ class PrefsFileSaveLoadMixin:
return
if prefs:
prefs.load_and_apply_prefs_from_file()
bpy.app.timers.register(timer_func, first_interval=delay)
def apply_prefs_from_dict_recursive(self, propgroup: PropertyGroup, data: dict):
@@ -55,14 +60,14 @@ class PrefsFileSaveLoadMixin:
if not hasattr(propgroup, key):
# Property got removed or renamed in the implementation.
continue
if type(value) == list:
if type(value) is list:
for elem in value:
collprop = getattr(propgroup, key)
entry = collprop.get(elem['name'])
entry = collprop.get(elem["name"])
if not entry:
entry = collprop.add()
self.apply_prefs_from_dict_recursive(entry, elem)
elif type(value) == dict:
elif type(value) is dict:
self.apply_prefs_from_dict_recursive(getattr(propgroup, key), value)
else:
setattr(propgroup, key, value)
@@ -125,15 +130,17 @@ def props_to_dict_recursive(propgroup: IDPropertyGroup, skip=[]) -> dict:
ret = {}
for key in propgroup.bl_rna.properties.keys():
if key in skip or key in ['rna_type', 'bl_idname']:
if key in skip or key in ["rna_type", "bl_idname"]:
continue
value = getattr(propgroup, key)
if isinstance(value, bpy.types.bpy_prop_collection):
ret[key] = [props_to_dict_recursive(elem) for elem in value]
elif type(value) == IDPropertyGroup or isinstance(value, bpy.types.PropertyGroup):
elif isinstance(value, IDPropertyGroup) or isinstance(
value, bpy.types.PropertyGroup
):
ret[key] = props_to_dict_recursive(value)
else:
if hasattr(propgroup.bl_rna.properties[key], 'enum_items'):
if hasattr(propgroup.bl_rna.properties[key], "enum_items"):
# Save enum values as string, not int.
ret[key] = propgroup.bl_rna.properties[key].enum_items[value].identifier
else:
@@ -146,7 +153,7 @@ def get_addon_prefs(context=None) -> AddonPreferences | None:
context = bpy.context
addons = context.preferences.addons
if base_package.startswith('bl_ext'):
if base_package.startswith("bl_ext"):
# 4.2 and later
addon_key = base_package
else:
@@ -154,7 +161,7 @@ def get_addon_prefs(context=None) -> AddonPreferences | None:
addon_key = base_package.split(".")[0]
addon = addons.get(addon_key)
if addon == None:
if addon is None:
# print("This happens when packaging the extension, due to the registration delay.")
return
@@ -2,54 +2,69 @@
#
# SPDX-License-Identifier: GPL-3.0-or-later
from typing import Any
import bpy
from bpy.types import PropertyGroup, bpy_prop_collection, Object
from rna_prop_ui import IDPropertyGroup
from bpy.types import Object, PropertyGroup, bpy_prop_collection
from bpy.utils import flip_name
from mathutils import Matrix, Vector
from rna_prop_ui import IDPropertyGroup
# Functions to manage runtime properties, which include custom properties and add-on properties.
# These functions aim to abstract away that distinction, and also abstract away whether something is a single value,
# a PropertyGroup, or a CollectionProperty.
# Compatible with the API changes in 5.0, but also older versions.
def copy_all_runtime_properties(src_id, tgt_id, x_mirror=False):
"""Copy add-on and custom properties from source to target.
"""Copy add-on and custom properties from source to target.
Both should be the same type.
Should support anything that supports custom properties or property registration.
"""
for prop_name in get_all_runtime_prop_names(src_id):
copy_runtime_property(src_id, tgt_id, prop_name, x_mirror)
def copy_all_custom_properties(src_id, tgt_id, x_mirror=False):
for prop_name in get_custom_prop_names(src_id):
copy_custom_property(src_id, tgt_id, prop_name=prop_name, x_mirror=x_mirror)
def get_all_runtime_prop_names(owner):
custom_props = list(owner.keys())
addon_props = get_addon_prop_names(owner)
props = custom_props + addon_props
return props
def get_custom_prop_names(owner):
for prop_name in get_all_runtime_prop_names(owner):
if is_custom_prop(owner, prop_name):
yield prop_name
def get_addon_prop_names(owner):
if bpy.app.version >= (5, 0, 0):
sys_props = owner.bl_system_properties_get()
if sys_props == None:
if sys_props is None:
# If there aren't any add-on properties.
return []
return list(sys_props.keys())
else:
return [prop_name for prop_name in owner.keys() if is_addon_prop(owner, prop_name)]
return [
prop_name for prop_name in owner.keys()
if is_addon_prop(owner, prop_name)
]
def rename_custom_prop(owner, from_name, to_name):
assert is_custom_prop(owner, from_name), f"Property {from_name} of {owner} is not a Custom Property."
assert is_custom_prop(owner, from_name), (
f"Property {from_name} of {owner} is not a Custom Property."
)
copy_custom_property(owner, owner, from_name, new_name=to_name, x_mirror=False)
remove_property(owner, from_name)
def copy_runtime_property(src_id, tgt_id, prop_name, x_mirror=False):
"""Copy add-on properties or custom properties."""
if is_addon_prop(src_id, prop_name):
@@ -64,7 +79,7 @@ def copy_runtime_property(src_id, tgt_id, prop_name, x_mirror=False):
copy_single_addon_prop(src_id, tgt_id, prop_name, x_mirror)
else:
if bpy.app.version >= (5, 0, 0):
# HACK: If we need to copy add-on properties, but the add-on is not present,
# HACK: If we need to copy add-on properties, but the add-on is not present,
# we have to write to the system properties, which is API abuse that could
# lose support any moment, but there is no other way to do this atm.
tgt_props = tgt_id.bl_system_properties_get()
@@ -81,6 +96,7 @@ def copy_runtime_property(src_id, tgt_id, prop_name, x_mirror=False):
else:
copy_custom_property(src_id, tgt_id, prop_name)
def copy_property_group(src_pg: PropertyGroup, tgt_pg: PropertyGroup, x_mirror=False):
"""
Copy the values from one PropertyGroup into another of the same type.
@@ -90,7 +106,7 @@ def copy_property_group(src_pg: PropertyGroup, tgt_pg: PropertyGroup, x_mirror=F
assert tgt_pg.__class__ == src_pg.__class__
for prop_name in src_pg.bl_rna.properties.keys():
if prop_name in ('rna_type', 'bl_rna'):
if prop_name in ("rna_type", "bl_rna"):
continue
if not src_pg.is_property_set(prop_name):
tgt_pg.property_unset(prop_name)
@@ -108,6 +124,7 @@ def copy_property_group(src_pg: PropertyGroup, tgt_pg: PropertyGroup, x_mirror=F
# PropertyGroups also support custom properties.
copy_custom_property(src_pg, tgt_pg, prop_name, x_mirror)
def copy_coll_prop(src_cp, tgt_cp, x_mirror=False):
tgt_cp.clear()
for src_pg in src_cp:
@@ -115,6 +132,7 @@ def copy_coll_prop(src_cp, tgt_cp, x_mirror=False):
tgt_pg = tgt_cp.add()
copy_property_group(src_pg, tgt_pg, x_mirror)
def copy_custom_property(src_owner, tgt_owner, prop_name, new_name="", x_mirror=False):
"""Copy a custom property (one that was created via the UI or via Python dictionary syntax)."""
if not new_name:
@@ -133,10 +151,13 @@ def copy_custom_property(src_owner, tgt_owner, prop_name, new_name="", x_mirror=
tgt_owner[new_name] = value
new_prop = tgt_owner.id_properties_ui(new_name)
new_prop.update_from(src_prop)
tgt_owner.property_overridable_library_set(f'["{new_name}"]', src_owner.is_property_overridable_library(f'["{prop_name}"]'))
tgt_owner.property_overridable_library_set(
f'["{new_name}"]', src_owner.is_property_overridable_library(f'["{prop_name}"]')
)
return tgt_owner.id_properties_ui(new_name)
def copy_single_addon_prop(src, tgt, prop_name, x_mirror=False) -> True:
def copy_single_addon_prop(src, tgt, prop_name, x_mirror=False) -> bool:
if src.is_property_readonly(prop_name):
# This "early" exit has to come after CollectionProperty & PropertyGroup
# checks, since they are technically read-only.
@@ -145,10 +166,11 @@ def copy_single_addon_prop(src, tgt, prop_name, x_mirror=False) -> True:
value = getattr(src, prop_name)
if x_mirror:
value = x_mirror_value(value)
setattr(tgt, prop_name, value)
return True
def x_mirror_value(value):
if isinstance(value, str):
return flip_name(value)
@@ -157,29 +179,38 @@ def x_mirror_value(value):
else:
return value
def get_opposite_obj(obj: Object) -> Object:
"""Return the X-mirrored version of a Blender object by name (and library if linked)."""
flipped_name = flip_name(obj.name)
lib = obj.library
return (
bpy.data.objects.get((lib, flipped_name)) if lib else
bpy.data.objects.get(flipped_name)
bpy.data.objects.get((lib, flipped_name))
if lib
else bpy.data.objects.get(flipped_name)
) or obj
def is_addon_prop(owner, prop_name):
if bpy.app.version >= (5, 0, 0):
return prop_name in get_addon_prop_names(owner)
else:
# NOTE: I don't think it's possible to detect pre-5.0 non-PropertyGroup/CollectionProperty non-registered add-on properties.
# They just behave completely as custom properties.
return prop_name in owner and (isinstance(owner[prop_name], IDPropertyGroup) or isinstance(owner[prop_name], list))
return prop_name in owner and (
isinstance(owner[prop_name], IDPropertyGroup)
or isinstance(owner[prop_name], list)
)
def is_registered_addon_prop(owner, prop_name):
return is_addon_prop(owner, prop_name) and prop_name in owner.bl_rna.properties
def is_custom_prop(owner, prop_name):
return prop_name in owner.keys() and not is_addon_prop(owner, prop_name)
def remove_property(obj, prop_name):
if is_custom_prop(obj, prop_name):
del obj[prop_name]
@@ -190,3 +221,33 @@ def remove_property(obj, prop_name):
del disabled_addon_props[prop_name]
else:
raise KeyError(f"{prop_name} not found in {obj.name}")
def get_property_defaults(bpy_type: type, exclude: list[str] = []) -> dict[str, Any]:
def get_default(prop):
if not hasattr(prop, "default"):
return None
if hasattr(prop, "default"):
if hasattr(prop, "default_array"):
default_array = list(prop.default_array)
if default_array:
if len(default_array) == 9:
return Matrix.Identity(3)
elif len(default_array) == 16:
return Matrix.Identity(4)
elif len(default_array) == 3 and prop.type == 'FLOAT':
default = default_array[0]
return Vector((default, default, default))
else:
return default_array
return prop.default
assert False, f"Couldn't find default for {prop}"
return {
prop.identifier: get_default(prop)
for prop in bpy_type.bl_rna.properties
if not prop.is_readonly and prop.identifier not in exclude
}
@@ -4,7 +4,8 @@
from bpy.types import UILayout
def aligned_label(layout: UILayout, *, alert=False, alignment='LEFT', **kwargs):
def aligned_label(layout: UILayout, *, alert=False, alignment="LEFT", **kwargs):
"""Draw some text in the single-column-layout style, ie. offset by 60%."""
row = layout.split(factor=0.4)
row.separator()
@@ -12,11 +13,12 @@ def aligned_label(layout: UILayout, *, alert=False, alignment='LEFT', **kwargs):
row.alignment = alignment
row.label(**kwargs)
def label_split(layout: UILayout, *, alert=False, **kwargs) -> UILayout:
"""Return an empty UILayout with a text label to its left in the single-column-layout style."""
split = layout.split(factor=0.4, align=True)
split.alert = alert
row = split.row(align=True)
row.alignment = 'RIGHT'
row.alignment = "RIGHT"
row.label(**kwargs)
return split
return split
@@ -3,6 +3,7 @@
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
import os
from bpy.types import Operator
from bpy.props import StringProperty, BoolProperty
import json
@@ -68,7 +69,14 @@ class WM_OT_call_menu_pie_drag_only(Operator):
if op_cls.poll():
try:
return op_cls('INVOKE_DEFAULT', **fallback_op_kwargs)
# 1. Execute the original operator and capture the result
result = op_cls('INVOKE_DEFAULT', **fallback_op_kwargs)
# 2. [Added] Check if it's a Save operation and report the message manually
if 'FINISHED' in result and self.fallback_operator == 'wm.save_mainfile':
# Get current filename, or default to untitled
filename = os.path.basename(bpy.data.filepath) if bpy.data.filepath else "untitled.blend"
self.report({'INFO'}, f'Saved "{filename}"')
except TypeError:
# This can apparently happen sometimes, see issue #86.
print(f"Pie Menu Fallback Operator failed: {self.fallback_operator}, {self.fallback_op_kwargs}")
@@ -96,19 +104,30 @@ class WM_OT_call_menu_pie_drag_only(Operator):
*,
keymap_name: str,
pie_name: str,
hotkey_kwargs={'type': "SPACE", 'value': "PRESS"},
hotkey_kwargs=None,
default_fallback_op="",
default_fallback_kwargs={},
default_fallback_kwargs=None,
on_drag=True,
):
if hotkey_kwargs is None:
hotkey_kwargs = {'type': "SPACE", 'value': "PRESS"}
context = bpy.context
fallback_operator = default_fallback_op
fallback_op_kwargs = default_fallback_kwargs
user_kc = context.window_manager.keyconfigs.user
km = user_kc.keymaps.get(keymap_name)
fallback_op_kwargs = default_fallback_kwargs if default_fallback_kwargs is not None else {}
# IMPORTANT:
# Do NOT derive fallback operator/kwargs from the USER keyconfig.
# Other add-ons (eg. Pie Menu Editor) can legitimately alter user keymaps,
# and baking that dynamic state into our add-on KeyMapItem properties can
# make Blender fail to match/restore user overrides across restarts.
#
# Instead, derive fallback from the DEFAULT (active preset) keyconfig,
# which is stable and represents expected built-in behavior.
default_kc = context.window_manager.keyconfigs.default
km = default_kc.keymaps.get(keymap_name)
if km:
for kmi in km.keymap_items:
for i, condition in enumerate([
for condition in [
kmi.idname != 'wm.call_menu_pie_drag_only',
kmi.type == hotkey_kwargs.get('type', ""),
kmi.value == hotkey_kwargs.get('value', "PRESS"),
@@ -119,7 +138,7 @@ class WM_OT_call_menu_pie_drag_only(Operator):
kmi.any == hotkey_kwargs.get('any', False),
kmi.key_modifier == hotkey_kwargs.get('key_modifier', 'NONE'),
kmi.active
]):
]:
if not condition:
break
else:
@@ -133,14 +152,19 @@ class WM_OT_call_menu_pie_drag_only(Operator):
op_kwargs={
'name': pie_name,
'fallback_operator': fallback_operator,
'fallback_op_kwargs': json.dumps(fallback_op_kwargs),
# Deterministic JSON (sort_keys=True) helps keep KMI identity stable across sessions.
'fallback_op_kwargs': json.dumps(fallback_op_kwargs, sort_keys=True),
'on_drag': on_drag,
},
hotkey_kwargs=hotkey_kwargs,
keymap_name=keymap_name,
)
def register():
bpy.utils.register_class(WM_OT_call_menu_pie_drag_only)
registry = [
WM_OT_call_menu_pie_drag_only,
]
def unregister():
# HACK: As a workaround to https://projects.blender.org/blender/blender/issues/150229, we do not unregister
# this operator when the add-on is uninstalled, which is pretty bad.
# bpy.utils.unregister_class(WM_OT_call_menu_pie_drag_only)
pass
@@ -4,6 +4,7 @@
import bpy
from .bs_utils.prefs import get_addon_prefs
from .prefs import draw_prefs
class VIEW3D_PT_extra_pies(bpy.types.Panel):
bl_label = "Extra Pies"