2026-01-01
This commit is contained in:
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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user