update addons (for 5.2)

This commit is contained in:
Nathan
2026-07-14 14:44:58 -06:00
parent a232ea1c9c
commit e3310263f3
246 changed files with 21097 additions and 9607 deletions
File diff suppressed because one or more lines are too long
+6 -12
View File
@@ -1,42 +1,36 @@
if "bpy" in locals():
import importlib
for mod in [icons,
operators,
for mod in [operators,
tools,
ui,
manual,
preferences,
properties,
ui,
versioning,
]:
importlib.reload(mod)
print("Add-on Reloaded: Bool Tool")
else:
import bpy
from . import (
icons,
operators,
tools,
ui,
manual,
preferences,
properties,
ui,
versioning,
)
#### ------------------------------ REGISTRATION ------------------------------ ####
modules = [
icons,
modules = (
operators,
tools,
ui,
manual,
preferences,
properties,
ui,
versioning,
]
)
def register():
for module in modules:
@@ -2,18 +2,17 @@ schema_version = "1.0.0"
id = "bool_tool"
name = "Bool Tool"
version = "2.0.0"
tagline = "Quick boolean operators and tools for hard surface modeling"
version = "2.1.0"
tagline = "Quick Boolean operators and tools for hard surface modeling"
tags = ["Modeling", "Object"]
type = "add-on"
maintainer = "Nika Kutsniashvili <nickberckley@gmail.com>"
website = "https://github.com/nickberckley/bool_tool"
tags = ["Modeling", "Object"]
maintainer = "Nika Kutsniashvili"
website = "https://nickberckley.github.io/bool_tool/"
support = "https://github.com/nickberckley/bool_tool/issues/new?template=bug_report.md"
blender_version_min = "4.5.0"
# License conforming to https://spdx.org/licenses/ (use "SPDX: prefix)
# https://docs.blender.org/manual/en/dev/advanced/extensions/licenses.html
license = [
"SPDX:GPL-3.0-or-later",
]
@@ -0,0 +1,15 @@
import os
# Paths
ICONS_PATH = os.path.join(os.path.dirname(__file__), "ui", "icons")
# Object types that can have evaluated mesh, and can be converted to Mesh.
CONVERTABLE_TYPES = (
'CURVE',
'CURVES',
'FONT',
# 'POINTCLOUD', # Doesn't work in Blender 5.2
# 'GREASEPENCIL' # Doesn't work in Blender 5.2
'EMPTY',
)
@@ -0,0 +1,144 @@
import bpy
from .modifier import (
is_boolean_modifier,
)
from .object import (
is_linked,
change_parent,
)
#### ------------------------------ /poll/ ------------------------------ ####
def is_canvas(obj):
"""Checks whether the object is a Boolean canvas (i.e. has Boolean cutters)."""
if obj.booleans.canvas == False:
return False
else:
# Even if object is marked as a canvas, check if it actually has any cutters.
cutters, __ = list_canvas_cutters([obj])
if len(cutters) > 0:
return True
else:
return False
#### ------------------------------ /list/ ------------------------------ ####
def list_all_canvases(scene):
"""Returns the list of all Boolean canvases in the scene."""
canvases = []
for obj in scene.objects:
if is_canvas(obj):
canvases.append(obj)
return canvases
def list_selected_canvases(context):
"""Returns the list of canvases in the selection."""
canvases = []
active_object = context.active_object
selected_objects = context.selected_objects
if selected_objects:
for obj in selected_objects:
if obj == active_object:
continue
if obj.type != 'MESH':
continue
if is_canvas(obj):
canvases.append(obj)
if active_object:
if is_canvas(active_object):
canvases.append(active_object)
return canvases
def list_canvas_cutters(canvases: list) -> tuple[list, dict]:
"""List cutters (and their associated modifiers) that are used by specified canvases."""
cutters = []
modifiers = {}
for canvas in canvases:
for mod in canvas.modifiers:
if not is_boolean_modifier(mod):
continue
if mod.object not in cutters:
cutters.append(mod.object)
modifiers.setdefault(canvas, []).append(mod)
return cutters, modifiers
def list_canvas_slices(context, canvases: list):
"""Returns the list of slices of specified canvases."""
slices = []
for obj in context.scene.objects:
if obj.booleans.slice:
if obj.booleans.slice_of in canvases:
slices.append(obj)
return slices
#### ------------------------------ /filter/ ------------------------------ ####
def filter_canvases(self, context, canvases: list) -> list:
"""Filter out objects from the given list if they can't be cut."""
usable_canvases = []
for canvas in canvases:
# Exclude non-Mesh types.
if canvas.type != 'MESH':
self.report({'WARNING'}, f"{canvas.name} is not a Mesh type. Only Meshes can be cut")
continue
# Exclude linked objects.
if is_linked(context, canvas):
self.report({'WARNING'}, f"{canvas.name} is linked and can not be used as a cutter")
continue
usable_canvases.append(canvas)
return usable_canvases
#### ------------------------------ /filter/ ------------------------------ ####
def create_slice(context, canvas, modifier=False):
"""Creates copy of canvas to be used as slice."""
slice = canvas.copy()
slice.data = canvas.data.copy()
slice.name = slice.data.name = canvas.name + "_slice"
# Parent to canvas.
change_parent(context, slice, canvas, inverse=True)
# Set Boolean properties.
if modifier == True:
slice.booleans.canvas = True
slice.booleans.slice = True
slice.booleans.slice_of = canvas
# Add to canvas collections.
for coll in canvas.users_collection:
coll.objects.link(slice)
# Add slices to local view.
if context.space_data.local_view:
slice.local_view_set(context.space_data, True)
return slice
@@ -0,0 +1,188 @@
import bpy
from .. import __package__ as base_package
from ..constants import (
CONVERTABLE_TYPES,
)
from .modifier import (
is_boolean_modifier,
)
from .object import (
is_linked,
has_evaluated_mesh,
object_visibility_set,
convert_to_mesh,
change_parent,
delete_object,
)
from .scene import (
ensure_collection,
)
#### ------------------------------ /list/ ------------------------------ ####
def list_selected_cutters(context):
"""Returns the list of cutters in the selection."""
cutters = []
active_object = context.active_object
selected_objects = context.selected_objects
if selected_objects:
for obj in selected_objects:
if obj == active_object:
continue
if obj.type != 'MESH':
continue
if obj.booleans.cutter:
cutters.append(obj)
if active_object:
if active_object.booleans.cutter:
cutters.append(active_object)
return cutters
def list_cutter_users(cutters: list, exclude: list=None) -> dict:
"""
List canvases that use specified cutters.
Canvases that should be excluded from the search can be specified with the `exclude` arg.
Returns a dict of canvases (keys) and set of their Boolean modifiers that use cutters (values).
"""
cutter_users = {}
for key, values in bpy.data.user_map(subset=cutters).items():
for value in values:
if value.id_type != 'OBJECT':
continue
if exclude and value in exclude:
continue
if len(value.modifiers) == 0:
continue
for mod in value.modifiers:
if not is_boolean_modifier(mod):
continue
if mod.object not in cutters:
continue
cutter_users.setdefault(value, set()).add(mod)
return cutter_users
#### ------------------------------ /filter/ ------------------------------ ####
def filter_cutters(self, context, cutters: list, canvases: list) -> list:
"""
Filter out objects from the given list if they can't be used as a cutter.
If non-mesh type object has evaluated mesh, and can be converted to mesh it will be.
"""
usable_cutters = []
for cutter in cutters:
# Exclude object if it is in both lists.
if cutter in canvases:
continue
# Exclude linked objects.
if is_linked(context, cutter):
self.report({'WARNING'}, f"{cutter.name} is linked and can not be used as a cutter")
continue
if cutter.type == 'MESH':
# Exclude if object is already a cutter for canvas.
users = list_cutter_users([cutter]).keys()
if any(canvas in users for canvas in canvases):
continue
# Exclude if canvas is cutting the object (avoid dependancy loop).
users = list_cutter_users(canvases).keys()
if cutter in users:
self.report({'WARNING'}, f"{cutter.name} can not cut its own cutter (dependancy loop)")
continue
usable_cutters.append(cutter)
elif cutter.type in CONVERTABLE_TYPES:
if not has_evaluated_mesh(context, cutter):
continue
convert_to_mesh(context, cutter)
usable_cutters.append(cutter)
return usable_cutters
#### ------------------------------ /operate/ ------------------------------ ####
def make_cutter(context, cutter, mode: str, display='BOUNDS', collection=True):
"""Ensures the cutter has the correct properties."""
# Hide Cutters
cutter.hide_render = True
cutter.display_type = display
cutter.lineart.usage = 'EXCLUDE'
object_visibility_set(cutter, value=False)
# Cutters Collection
if collection:
cutters_collection = ensure_collection(context)
if cutters_collection not in cutter.users_collection:
cutters_collection.objects.link(cutter)
# Set Boolean Property
cutter.booleans.cutter = mode.capitalize()
def restore_cutter(context, cutter, unparent=True, unlink_collection=True):
"""Remove Boolean properties from a cutter object to restore it to a normal state."""
prefs = context.preferences.addons[base_package].preferences
# Restore Unused Cutters
cutter.hide_render = False
cutter.display_type = 'TEXTURED'
cutter.lineart.usage = 'INHERIT'
object_visibility_set(cutter, value=True)
cutter.booleans.cutter = ""
# Remove Parent & Collection
if unparent:
change_parent(context, cutter, None)
if unlink_collection:
cutters_collection = bpy.data.collections.get(prefs.collection_name)
if cutters_collection in cutter.users_collection:
cutters_collection.objects.unlink(cutter)
def handle_unused_cutters(context, cutters: list, canvases: list, delete=True):
"""Deletes or restores cutters with no remaining users (besides given list of canvases)."""
prefs = context.preferences.addons[base_package].preferences
for cutter in cutters:
other_canvases = list_cutter_users([cutter], exclude=canvases).keys()
if len(other_canvases) == 0:
if delete:
# Delete unused cutters & transfer their children to canvas.
for child in cutter.children:
change_parent(context, child, cutter.parent, inverse=True)
delete_object(cutter)
else:
# Restore unused cutters.
restore_cutter(context, cutter,
unparent=prefs.parent and cutter.parent in canvases,
unlink_collection=prefs.use_collection)
else:
# Cutter has other users, parent it to one of them.
if prefs.parent and cutter.parent in canvases:
new_parent = next(c for c in other_canvases if not c.booleans.slice)
change_parent(context, cutter, new_parent, inverse=True)
@@ -10,7 +10,7 @@ from gpu_extras.batch import batch_for_shader
#### ------------------------------ FUNCTIONS ------------------------------ ####
def draw_shader(type, color, alpha, coords, size=1, indices=None):
"""Creates a batch for a draw type"""
"""Sets up and draws a batch for a given GPU shader type."""
gpu.state.blend_set('ALPHA')
@@ -48,9 +48,10 @@ def draw_shader(type, color, alpha, coords, size=1, indices=None):
gpu.state.blend_set('NONE')
def draw_bmesh_faces(faces, world_matrix):
def draw_bmesh_faces(faces, world_matrix) -> tuple[list, list]:
"""
Get world-space vertex pairs and indices from `bmesh` face. To be used in GPU batch.
Gets world-space vertex pairs and indices from `bmesh` faces.
Returns the list of 3D vertices and the list of triangle indices to be used in GPU batch.
Adapted from "Blockout" extension by niewinny (https://github.com/niewinny/blockout).
"""
@@ -65,13 +66,13 @@ def draw_bmesh_faces(faces, world_matrix):
for face in faces:
face_indices = []
# 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).
# Collect unique vertices only (avoid storing verts that are shared by multiple faces).
# (NOTE: 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:
co = world_matrix @ Vector(vert.co)
vertices.append(co)
vert_index_map[vert] = vert_count
face_indices.append(vert_count)
@@ -94,46 +95,28 @@ def draw_bmesh_faces(faces, world_matrix):
return vertices, indices
def draw_bmesh_edges(edges, world_matrix):
"""Convert bmesh edges into world-space vertex pairs to be used in GPU batch."""
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 draw_circle_around_point(context, obj, vert, radius, segments):
def draw_circle_billboard(context, origin: Vector, radius: float, segments: int) -> list:
"""
Draws the screen-aligned circle around given vertex of the object.
Returns the list of vertices for GPU batch.
Draws a view-facing circle in the world-space around the given origin Vector.
Returns the list of 3D vertices.
"""
region = context.region
rv3d = context.region_data
vert_world = obj.matrix_world @ vert.co
origin_2d = view3d_utils.location_3d_to_region_2d(region, rv3d, origin)
radius = min(radius, 25)
vertices = []
for i in range(segments + 1):
angle = i * (2 * math.pi / segments)
# Calculate offset and vertex position in screen-space.
# Add offset 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)
vert_2d = Vector((origin_2d.x + offset_x, origin_2d.y + offset_y))
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)
# Convert back to world-space.
vert_3d = view3d_utils.region_2d_to_location_3d(region, rv3d, vert_2d, origin)
vertices.append(vert_3d)
return vertices
@@ -1,162 +0,0 @@
import bpy
#### ------------------------------ /all/ ------------------------------ ####
def list_canvases():
"""List all canvases in the scene"""
canvas = []
for obj in bpy.context.scene.objects:
if obj.booleans.canvas:
canvas.append(obj)
return canvas
#### ------------------------------ /selected/ ------------------------------ ####
def list_selected_cutters(context):
"""List selected cutters"""
cutters = []
active_object = context.active_object
selected_objects = context.selected_objects
if selected_objects:
for obj in selected_objects:
if obj != active_object and obj.type == 'MESH':
if obj.booleans.cutter:
cutters.append(obj)
if active_object:
if active_object.booleans.cutter:
cutters.append(active_object)
return cutters
def list_selected_canvases(context):
"""List selected canvases"""
canvases = []
active_object = context.active_object
selected_objects = context.selected_objects
if selected_objects:
for obj in selected_objects:
if obj != active_object and obj.type == 'MESH':
if obj.booleans.canvas:
canvases.append(obj)
if active_object:
if active_object.booleans.canvas:
canvases.append(active_object)
return canvases
#### ------------------------------ /users/ ------------------------------ ####
def list_canvas_cutters(canvases):
"""List cutters that are used by specified canvases"""
cutters = []
modifiers = []
for canvas in canvases:
for mod in canvas.modifiers:
if mod.type == 'BOOLEAN' and "boolean_" in mod.name:
if mod.object:
cutters.append(mod.object)
modifiers.append(mod)
return cutters, modifiers
def list_canvas_slices(canvases):
"""Returns list of slices for specified canvases"""
slices = []
for obj in bpy.context.scene.objects:
if obj.booleans.slice:
if obj.booleans.slice_of in canvases:
slices.append(obj)
return slices
def list_cutter_users(cutters):
"""List canvases that use specified cutters"""
cutter_users = []
for cutter in cutters:
object = bpy.data.objects.get(cutter.name)
for key, values in bpy.data.user_map(subset=[object]).items():
for value in values:
# filter_only_object_type_users
if value.id_type == 'OBJECT':
for mod in value.modifiers:
if mod.type == 'BOOLEAN':
if mod.object and mod.object == cutter:
cutter_users.append(value)
return cutter_users
def list_cutter_modifiers(canvases, cutters):
"""List modifiers on specified canvases that use specified cutters"""
if not canvases:
canvases = list_canvases()
modifiers = []
for canvas in canvases:
for mod in canvas.modifiers:
if mod.type == 'BOOLEAN':
if mod.object in cutters:
modifiers.append(mod)
return modifiers
def list_unused_cutters(cutters, *canvases, do_leftovers=False):
"""Takes in list of cutters and returns only those that have no other user besides specified canvas"""
"""When `include_visible` is True it will return cutters that aren't used by any visible modifiers"""
other_canvases = list_canvases()
original_cutters = cutters[:]
for obj in other_canvases:
if obj in canvases:
return
if any(mod.object in cutters for mod in obj.modifiers if mod.type == 'BOOLEAN'):
cutters[:] = [cutter for cutter in cutters if cutter not in [mod.object for mod in obj.modifiers]]
leftovers = []
# return_cutters_that_do_have_other_users_(so_that_parents_can_be_reassigned)
if do_leftovers:
leftovers = [cutter for cutter in original_cutters if cutter not in cutters]
return cutters, leftovers
def list_pre_boolean_modifiers(obj) -> list:
"""Returns a list of boolean modifiers & modifiers that come before last boolean modifier"""
# Find the index of a last boolean modifier
last_boolean_index = -1
for i in reversed(range(len(obj.modifiers))):
if obj.modifiers[i].type == 'BOOLEAN':
last_boolean_index = i
break
# If boolean modifier is found, list all modifiers that come before it.
if last_boolean_index != -1:
return [mod for mod in obj.modifiers[:last_boolean_index + 1]]
else:
return []
@@ -1,77 +0,0 @@
import bpy
import mathutils
from mathutils import Vector
from bpy_extras import view3d_utils
#### ------------------------------ FUNCTIONS ------------------------------ ####
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).
"""
segment = end - start
start_to_point = point - start
# projection_along_segment
c1 = start_to_point.dot(segment)
if c1 <= 0:
return (point - start).length
# segment_length_squared
c2 = segment.dot(segment)
if c2 <= c1:
return (point - end).length
t = c1 / c2
closest_point = start + t * segment
distance = (point - closest_point).length
return distance
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.
"""
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 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).
"""
location, normal = plane
# 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)
# 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).
return p3_on_plane
@@ -1,17 +1,56 @@
import bpy
import bmesh
import mathutils
import math
from bpy_extras import view3d_utils
from .object import hide_objects
from .types import Ray
from mathutils import Vector
#### ------------------------------ FUNCTIONS ------------------------------ ####
#### ------------------------------ /poll/ ------------------------------ ####
def extrude_face(bm, face):
"""Extrudes cutter face (created by carve operation) along view vector to create a non-manifold mesh"""
def is_instanced_mesh(data):
"""
Checks if `obj.data` has more than one users, i.e. is instanced.
Function only considers object types as users, and excludes other pointers.
"""
data = bpy.data.meshes.get(data.name)
users = 0
for key, values in bpy.data.user_map(subset=[data]).items():
for value in values:
if value.id_type == 'OBJECT':
users += 1
if users > 1:
return True
else:
return False
def are_intersecting(obj_a, obj_b):
"""Checks if bounding boxes of two given objects intersect."""
def world_bounds(obj):
corners = [obj.matrix_world @ 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
)
#### ------------------------------ /operate/ ------------------------------ ####
def extrude_face(bm, face) -> tuple[list[bmesh.types.BMVert], list[bmesh.types.BMEdge], list[bmesh.types.BMFace]]:
"""Extrudes the `bmesh` face and returns tuple of lists of extruded vertices, edges and faces."""
bm.faces.ensure_lookup_table()
@@ -26,8 +65,8 @@ def extrude_face(bm, face):
return extruded_verts, extruded_edges, extruded_faces
def shade_smooth_by_angle(bm, mesh, angle=30):
"""Replication of "Auto Smooth" functionality: Marks faces as smooth, sharp edges (by angle) as sharp"""
def shade_smooth_by_angle(bm, mesh, angle: float=30):
"""Replication of "Auto Smooth": Marks faces as smooth & edges above the angle as sharp."""
for f in bm.faces:
f.smooth = True
@@ -51,26 +90,6 @@ def shade_smooth_by_angle(bm, mesh, angle=30):
bm.to_mesh(mesh)
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."""
@@ -85,21 +104,3 @@ def ensure_attribute(bm, name, domain):
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,42 +3,102 @@ import bmesh
from contextlib import contextmanager
from .. import __package__ as base_package
from ..functions.list import (
list_pre_boolean_modifiers,
from .mesh import (
is_instanced_mesh,
)
from .object import (
convert_to_mesh,
)
from .poll import (
is_instanced_data,
)
#### ------------------------------ /list/ ------------------------------ ####
def enumerate_boolean_modifiers(obj) -> int:
"""Returns the number of Boolean modifiers on the object."""
num = 0
for mod in obj.modifiers:
if is_boolean_modifier(mod):
num += 1
return num
def get_modifiers_to_apply(context, obj, custom_list=None) -> list:
"""Returns the list of modifiers that need to be applied based on add-on preferences."""
prefs = context.preferences.addons[base_package].preferences
# Apply all modifiers.
if prefs.apply_order == 'ALL':
modifiers = list(obj.modifiers)
# Apply only Boolean modifiers.
elif prefs.apply_order == 'BOOLEANS':
if custom_list is None:
modifiers = [mod for mod in obj.modifiers if is_boolean_modifier(mod)]
else:
modifiers = custom_list
# Apply all modifiers that come before last Boolean modifier.
elif prefs.apply_order == 'BEFORE':
# Find the index of a last Boolean modifier.
last_boolean_index = -1
for i in reversed(range(len(obj.modifiers))):
if obj.modifiers[i].type == 'BOOLEAN':
last_boolean_index = i
break
# If a Boolean modifier is found, list all modifiers that come before it.
if last_boolean_index != -1:
modifiers = [mod for mod in obj.modifiers[:last_boolean_index + 1]]
else:
modifiers = []
return modifiers
#### ------------------------------ /poll/ ------------------------------ ####
def is_boolean_modifier(mod, check_cutter=True) -> bool:
"""Checks if a modifier is a Boolean modifier (and optionally if it has a valid cutter)."""
if mod is None:
return False
if mod.type != 'BOOLEAN':
return False
if check_cutter and mod.object is None:
return False
return True
#### ------------------------------ FUNCTIONS ------------------------------ ####
def add_boolean_modifier(self, context, obj, cutter, mode, solver, pin=False, redo=True):
"Adds boolean modifier with specified cutter and properties to a single object"
"""Adds the Boolean modifier with specified cutter and properties to a given object."""
if bpy.app.version < (5, 0, 0) and solver == 'FLOAT':
solver = 'FAST'
prefs = context.preferences.addons[base_package].preferences
name = "boolean_" + cutter.name.replace("boolean_", "")
modifier = obj.modifiers.new("boolean_" + cutter.name.replace("boolean_", ""), 'BOOLEAN')
modifier = obj.modifiers.new(name, 'BOOLEAN')
modifier.operation = mode
modifier.object = cutter
modifier.solver = solver
modifier.show_in_editmode = prefs.show_in_editmode
# Set solver options (inherited from operator properties).
# Set solver options (inherited from operator properties, i.e. `self`).
if redo:
modifier.material_mode = self.material_mode
modifier.use_self = self.use_self
modifier.use_hole_tolerant = self.use_hole_tolerant
modifier.double_threshold = self.double_threshold
if prefs.show_in_editmode:
modifier.show_in_editmode = True
# Move modifier to the index 0 (make it first in the stack).
if pin:
index = obj.modifiers.find(modifier.name)
@@ -50,19 +110,20 @@ def add_boolean_modifier(self, context, obj, cutter, mode, solver, pin=False, re
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
`bpy.data.meshes.new_from_object` built-in function to create a temporary
mesh from the evaluated object (basically with visible modifiers applied).
Temporary mesh is then transferred to objects mesh with `bmesh`.
Instead of using `bpy.ops.object.modifier_apply`, by default this function uses
`to_mesh` built-in function to create a temporary mesh from the evaluated object
(basically with visible modifiers applied). Temporary mesh is then transferred
to objects mesh using `bmesh`.
This method is up to 2x faster, although it's considered experimental
and may fail in some cases, so a fallback to `bpy.ops.object.modifier_apply` is kept.
"""
prefs = context.preferences.addons[base_package].preferences
_stored_active_obj = context.active_object
# Make object data unique if it's instanced.
if is_instanced_data(obj):
if is_instanced_mesh(obj.data):
context.active_object.data = context.active_object.data.copy()
try:
@@ -71,10 +132,13 @@ def apply_modifiers(context, obj, modifiers: list, force_clean=False):
if not force_clean:
raise Exception()
context.view_layer.objects.active = obj
with hide_modifiers(obj, excluding=modifiers):
# Create a temporary mesh from evaluated object.
evaluated_obj = obj.evaluated_get(context.evaluated_depsgraph_get())
temp_data = bpy.data.meshes.new_from_object(evaluated_obj)
depsgraph = context.evaluated_depsgraph_get()
evaluated_obj = obj.evaluated_get(depsgraph)
temp_data = evaluated_obj.to_mesh(preserve_all_data_layers=True,
depsgraph=depsgraph)
# Create `bmesh` from temporary mesh and update edit mesh.
if context.mode == 'EDIT_MESH':
@@ -86,11 +150,11 @@ def apply_modifiers(context, obj, modifiers: list, force_clean=False):
bm = bmesh.new()
bm.from_mesh(temp_data)
bm.to_mesh(obj.data)
bm.free()
evaluated_obj.to_mesh_clear()
# Remove modifiers and purge temporary mesh.
bpy.data.meshes.remove(temp_data)
# Remove modifiers.
for mod in modifiers:
obj.modifiers.remove(mod)
@@ -99,7 +163,6 @@ def apply_modifiers(context, obj, modifiers: list, force_clean=False):
if obj.data.shape_keys:
obj.shape_key_clear()
# Use `bpy.ops` operator to apply modifiers if above fails.
except Exception as e:
# print("Error applying modifiers with `bmesh` method:", e, "falling back to `bpy.ops` method")
@@ -118,10 +181,12 @@ def apply_modifiers(context, obj, modifiers: list, force_clean=False):
for mod in modifiers:
bpy.ops.object.modifier_apply(modifier=mod.name)
context.view_layer.objects.active = _stored_active_obj
@contextmanager
def hide_modifiers(obj, excluding: list):
"""Hides all modifiers of a given object in viewport except those in excluding list"""
"""Hides all modifiers of a given object in the viewport except those in `excluding` list."""
visible_modifiers = []
for mod in obj.modifiers:
@@ -139,10 +204,10 @@ def hide_modifiers(obj, excluding: list):
def add_modifier_asset(obj, path: str, asset: str):
"""Loads the node group asset and adds a Geometry Nodes modifier using it."""
"""Loads in the node group asset and adds a Geometry Nodes modifier using it."""
try:
# Load the node group.
# Load in 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:
@@ -155,7 +220,7 @@ def add_modifier_asset(obj, path: str, asset: str):
node_group = bpy.data.node_groups[asset]
# Add modifier on the object.
# Add modifier to the object.
mod = obj.modifiers.new(asset, type='NODES')
mod.node_group = node_group
mod.show_group_selector = False
@@ -168,16 +233,19 @@ def add_modifier_asset(obj, path: str, asset: str):
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."""
def update_modifier_input(modifier, socket: str, value):
"""Change the value of the geometry nodes modifier input socket."""
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
try:
if bpy.app.version >= (5, 2, 0):
socket = getattr(modifier.properties.inputs, socket)
socket.value = value
else:
modifier[f"{socket}"] = value
except:
"""
NOTE: There are plethora of reasons why this can fail, node trees are finicky.
Accounting for all possible cases is borderline impossible, so this check is necessary
to at least fail silently and not throw Python error to users.
"""
pass
@@ -1,152 +1,129 @@
import bpy
import bmesh
import mathutils
from mathutils import Vector, Matrix
from contextlib import contextmanager
from .. import __package__ as base_package
#### ------------------------------ FUNCTIONS ------------------------------ ####
#### ------------------------------ /poll/ ------------------------------ ####
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"""
def is_linked(context, obj) -> bool:
"""Checks whether the object is linked from an external .blend file (including library-overrides)."""
# Hide Cutters
cutter.hide_render = True
cutter.display_type = display
cutter.lineart.usage = 'EXCLUDE'
object_visibility_set(cutter, value=False)
if obj not in context.editable_objects:
if obj.library:
return True
else:
return False
else:
if obj.override_library:
return True
else:
return False
# Cutters Collection
if collection:
cutters_collection = ensure_collection(context)
if cutters_collection not in cutter.users_collection:
cutters_collection.objects.link(cutter)
# add_boolean_property
cutter.booleans.cutter = mode.capitalize()
def has_evaluated_mesh(context, obj):
"""Checks if an evaluated object has mesh (created by Geometry Nodes modifiers)."""
# Exclude cases that return Python errors.
if not obj:
return False
if bpy.app.version < (5, 2, 0) and obj.type == 'EMPTY':
return False
if obj.instance_type != 'NONE':
return False
depsgraph = context.view_layer.depsgraph
obj_eval = depsgraph.id_eval_get(obj)
geometry = None
try:
geometry = obj_eval.evaluated_geometry()
except:
pass
if not geometry or not geometry.mesh:
return False
else:
return True
#### ------------------------------ /operate/ ------------------------------ ####
def object_visibility_set(obj, value=False):
"Sets object visibility properties to either True or False"
"""Sets object visibility properties to either True or False."""
obj.visible_camera = value
obj.visible_shadow = value
obj.visible_diffuse = value
obj.visible_glossy = value
obj.visible_shadow = value
obj.visible_transmission = value
obj.visible_volume_scatter = value
if bpy.app.version >= (5, 2, 0):
obj.visible_raycast = value
obj.hide_probe_volume = not value
obj.hide_probe_sphere = not value
obj.hide_probe_plane = not value
def convert_to_mesh(context, obj):
"Converts active object into mesh (applying all modifiers and shape keys in process)"
"""Converts active object into mesh (applying all modifiers and shape keys in the process)."""
# store_selection
original_mode = obj.mode
if original_mode != 'OBJECT':
edit_objects = context.objects_in_mode
bpy.ops.object.mode_set(mode='OBJECT')
# Store selection.
stored_active = context.active_object
stored_selection = context.selected_objects
bpy.ops.object.select_all(action='DESELECT')
for ob in context.scene.objects:
ob.select_set(False)
# Convert
# Make `obj` active and only one selected.
obj.select_set(True)
context.view_layer.objects.active = obj
# Convert.
bpy.ops.object.convert(target='MESH')
# restore_selection
for obj in stored_selection:
obj.select_set(True)
if original_mode != 'OBJECT':
for ob in edit_objects:
ob.select_set(True)
bpy.ops.object.mode_set(mode=original_mode)
# Restore selection.
for ob in stored_selection:
ob.select_set(True)
context.view_layer.objects.active = stored_active
def ensure_collection(context):
"""Checks the existance of boolean cutters collection and creates it if it doesn't exist"""
def change_parent(context, obj, parent, inverse=False):
"""Changes or removes parent from an object while keeping the transformation."""
prefs = context.preferences.addons[base_package].preferences
context.evaluated_depsgraph_get().update()
collection_name = prefs.collection_name
cutters_collection = bpy.data.collections.get(collection_name)
if cutters_collection is None:
cutters_collection = bpy.data.collections.new(collection_name)
context.scene.collection.children.link(cutters_collection)
cutters_collection.hide_render = True
cutters_collection.color_tag = 'COLOR_01'
# cutters_collection.hide_viewport = True
# context.view_layer.layer_collection.children[collection_name].exclude = True
return cutters_collection
def delete_empty_collection():
"""Removes boolean cutters collection if it has no more objects in it"""
prefs = bpy.context.preferences.addons[base_package].preferences
collection = bpy.data.collections.get(prefs.collection_name)
if collection and not collection.objects:
bpy.data.collections.remove(collection)
def delete_cutter(cutter):
"""Deletes cutter object and purges it's mesh data"""
orphaned_mesh = cutter.data
bpy.data.objects.remove(cutter)
if orphaned_mesh.users == 0:
bpy.data.meshes.remove(orphaned_mesh)
def change_parent(obj, parent, force=False, inverse=False):
"""Changes or removes parent from cutter object while keeping the transformation"""
if obj.parent is not None:
if not force:
return
matrix_copy = obj.matrix_world.copy()
obj.parent = parent
if inverse:
if inverse and parent is not None:
obj.matrix_parent_inverse = parent.matrix_world.inverted()
obj.matrix_world = matrix_copy
def create_slice(context, canvas, modifier=False):
"""Creates copy of canvas to be used as slice"""
slice = canvas.copy()
slice.data = canvas.data.copy()
slice.name = slice.data.name = canvas.name + "_slice"
change_parent(slice, canvas)
# Set Boolean Properties
if modifier == True:
slice.booleans.canvas = True
slice.booleans.slice = True
slice.booleans.slice_of = canvas
# Add to Canvas Collections
for coll in canvas.users_collection:
coll.objects.link(slice)
# add_slices_to_local_view
if context.space_data.local_view:
slice.local_view_set(context.space_data, True)
return slice
def set_object_origin(obj, bm, point='CENTER', custom=None):
"""Sets object origin to given position by shifting vertices"""
def set_object_origin(obj, bm, point='CENTER', custom: Vector=None):
"""Sets the origin of a mesh type object to given position by shifting vertices."""
# 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_local = 0.125 * sum((Vector(b) for b in obj.bound_box), Vector())
position_world = obj.matrix_world @ position_local
# 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)
position_local = sum((v.co for v in bm.verts), Vector()) / len(bm.verts)
else:
position_local = mathutils.Vector((0, 0, 0))
position_local = Vector((0, 0, 0))
position_world = obj.matrix_world @ position_local
# Custom origin point (should be local Vector).
@@ -154,8 +131,8 @@ def set_object_origin(obj, bm, point='CENTER', custom=None):
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)
matrix = Matrix.Translation(position_local)
bmesh.ops.transform(bm, matrix=matrix.inverted(), verts=bm.verts)
bm.to_mesh(obj.data)
obj.location = position_world
@@ -179,3 +156,13 @@ def hide_objects(context, exceptions: list):
finally:
for obj in hidden_objects:
obj.hide_set(False)
def delete_object(cutter, purge_data=True):
"""Deletes the object and optionally purges its data if it has no more users."""
orphaned_data = cutter.data
bpy.data.objects.remove(cutter)
if purge_data and orphaned_data.users == 0:
bpy.data.meshes.remove(orphaned_data)
@@ -1,153 +1,44 @@
import bpy
from .list import (
list_canvas_cutters,
list_cutter_users,
from ..constants import (
CONVERTABLE_TYPES,
)
from .mesh import (
is_instanced_mesh,
)
from .object import (
convert_to_mesh,
has_evaluated_mesh,
)
#### ------------------------------ FUNCTIONS ------------------------------ ####
def basic_poll(cls, context, check_linked=False):
"""Basic poll for boolean operators."""
def basic_poll(cls, context, check_active=True):
"""Basic poll for Boolean operators."""
if context.mode != 'OBJECT':
return False
if context.active_object is None:
cls.poll_message_set("Boolean operators can only be performed in Object Mode")
return False
obj = context.active_object
if obj.type != 'MESH':
cls.poll_message_set("Boolean operators can only be used for mesh objects")
return False
if check_linked and is_linked(context, obj) == True:
cls.poll_message_set("Boolean operators can not be executed on linked objects")
return False
if check_active:
if context.active_object is None:
cls.poll_message_set("No active object")
return False
return True
def is_linked(context, obj):
"""Checks whether the object is linked from an external .blend file (including library-overrides)."""
if obj not in context.editable_objects:
if obj.library:
return True
else:
return False
else:
if obj.override_library:
return True
else:
return False
def is_canvas(obj):
"""Checks whether the object is a boolean canvas (i.e. has boolean cutters)."""
if obj.booleans.canvas == False:
return False
else:
# Even if object is marked as canvas, check if it actually has any cutters
cutters, __ = list_canvas_cutters([obj])
if len(cutters) > 0:
return True
else:
return False
def is_instanced_data(obj):
"""Checks if `obj.data` has more than one users, i.e. is instanced."""
"""Function only considers object types as users, and excludes pointers."""
data = bpy.data.meshes.get(obj.data.name)
users = 0
for key, values in bpy.data.user_map(subset=[data]).items():
for value in values:
if value.id_type == 'OBJECT':
users += 1
if users > 1:
return True
else:
return False
def active_modifier_poll(obj):
"""Checks whether the active modifier for active object is a boolean."""
# Check if active modifier exists.
if len(obj.modifiers) == 0:
return False
if obj.modifiers.active is None:
return False
# Check if active modifier is a boolean with a valid object.
modifier = obj.modifiers.active
if modifier.type != "BOOLEAN":
return False
if modifier.object is None:
return False
return True
def has_evaluated_mesh(context, obj):
"""Checks if an object (non-mesh type) has an evaluated mesh created by Geometry Nodes modifiers."""
depsgraph = context.view_layer.depsgraph
obj_eval = depsgraph.id_eval_get(obj)
geometry = obj_eval.evaluated_geometry()
if geometry.mesh:
return True
else:
return False
def list_candidate_objects(self, context, canvas):
"""Filter out objects from the selection that can't be used as a cutter."""
cutters = []
for obj in context.selected_objects:
if obj == context.active_object:
continue
if is_linked(context, obj):
self.report({'WARNING'}, f"{obj.name} is linked and can not be used as a cutter")
continue
if obj.type == 'MESH':
# Exclude if object is already a cutter for canvas.
if canvas in list_cutter_users([obj]):
continue
# Exclude if canvas is cutting the object (avoid dependancy loop).
if obj in list_cutter_users([canvas]):
self.report({'WARNING'}, f"{obj.name} can not cut its own cutter (dependancy loop)")
continue
cutters.append(obj)
elif obj.type in ('CURVE', 'FONT'):
if has_evaluated_mesh(context, obj):
convert_to_mesh(context, obj)
cutters.append(obj)
return cutters
def destructive_op_confirmation(self, context, event, canvases: list, title="Boolean Operation"):
def destructive_op_confirmation(cls, context, event, canvases: list, title="Boolean Operation"):
"""
Creates & returns the confirmation pop-up window for destructive boolean operators.\n
Confirmation window is triggered by canvas objects that have instanced object data or shape keys.\n
If none of the canvas objects have them the operator is executed without any confirmation.
Creates & returns the confirmation pop-up window for destructive Boolean operators.\n
Confirmation window is triggered by canvases that have instanced data or shape keys.\n
If none of the canvases have them the operator is executed without any confirmation.
"""
has_instanced_data = any(obj for obj in canvases if is_instanced_data(obj))
if len(canvases) == 0:
return cls.execute(context)
has_instanced_data = any(obj for obj in canvases if is_instanced_mesh(obj.data))
has_shape_keys = any(obj for obj in canvases if obj.data.shape_keys)
if has_instanced_data or has_shape_keys:
@@ -166,15 +57,74 @@ def destructive_op_confirmation(self, context, event, canvases: list, title="Boo
# Combined message.
if has_instanced_data and has_shape_keys:
message = ("Object(s) you're trying to cut have shape keys and instanced object data.\n"
"In order to apply modifiers shape keys need to be applied, and object data made single user.\n"
"In order to apply modifiers shape keys need to be applied & object data made single user.\n"
"Do you proceed?")
popup = context.window_manager.invoke_confirm(self, event, title=title,
popup = context.window_manager.invoke_confirm(cls, event, title=title,
confirm_text="Yes", icon='WARNING',
message=message)
cls._unflippable = True
return popup
# Execute without confirmation window.
else:
return self.execute(context)
return cls.execute(context)
def convert_to_mesh_confirmation(cls, context, event, cutters: list, title="Boolean Operation"):
"""
Creates & returns the confirmation pop-up window when the object is
about to be converted to mesh to be used as a cutter.
NOTE (1): Only triggers during brush Boolean operators,
because object gets destroyed in the destructive one anyway.
NOTE (2): This is only required because of the limitation of legacy Boolean modifier.
Geometry nodes implementation works with any object type. When the add-on is
updated to work with custom modifiers this will not be necesary anymore.
"""
if len(cutters) == 0:
return cls.execute(context)
is_convertable = any(
obj.type in CONVERTABLE_TYPES and has_evaluated_mesh(context, obj)
for obj in cutters
)
if is_convertable:
message = ("Some of the selected objects are not of the Mesh type, but output mesh.\n"
"In order to use them as cutters, they need to be converted to mesh.\n"
"This is a destructive operator. Do you proceed?")
popup = context.window_manager.invoke_confirm(cls, event, title=title,
confirm_text="Yes", icon='WARNING',
message=message)
cls._unflippable = True
return popup
# Execute without confirmation window.
else:
return cls.execute(context)
#### ------------------------------ /operator_helpers/ ------------------------------ ####
def _guess_toggle_state(modifiers):
"""Guess whether cutters should be hidden or revealed."""
enabled = 0
disabled = 0
for mod in modifiers:
if mod.show_viewport:
enabled += 1
else:
disabled += 1
if enabled > disabled:
return "On"
else:
return "Off"
@@ -0,0 +1,68 @@
import bpy
from .. import __package__ as base_package
from .object import (
hide_objects,
)
from .types import (
Ray,
)
from .view import (
region_2d_to_ray_3d,
)
#### ------------------------------ FUNCTIONS ------------------------------ ####
def ensure_collection(context) -> bpy.types.Collection:
"""Returns the Boolean cutters collection and creates it if it doesn't exist."""
prefs = context.preferences.addons[base_package].preferences
collection_name = prefs.collection_name
coll = bpy.data.collections.get(collection_name)
# Create the collection if it doesn't exist.
if coll is None:
coll = bpy.data.collections.new(collection_name)
coll.hide_render = True
coll.color_tag = 'COLOR_01'
context.scene.collection.children.link(coll)
return coll
def delete_empty_collection(context):
"""Removes Boolean cutters collection if it has no more objects in it."""
prefs = context.preferences.addons[base_package].preferences
if not prefs.use_collection:
return
collection_name = prefs.collection_name
collection = bpy.data.collections.get(collection_name)
if not collection:
return
if collection.objects:
return
bpy.data.collections.remove(collection)
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, direction = region_2d_to_ray_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
@@ -1,5 +1,4 @@
import bpy
import mathutils
from mathutils import Vector, Matrix
@@ -16,8 +15,8 @@ class Ray:
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.location = location
self.normal = normal
self.index = index
self.obj = obj
self.matrix = matrix if matrix is not None else mathutils.Matrix()
self.matrix = matrix
@@ -0,0 +1,75 @@
import bpy
import mathutils
from mathutils import Vector
from bpy_extras import view3d_utils
#### ------------------------------ FUNCTIONS ------------------------------ ####
def redraw_regions(context):
"""Redraw regions to find the limits of the 3D viewport."""
for area in context.window.screen.areas:
if area.type != 'VIEW_3D':
continue
for region in area.regions:
if region.type in {'WINDOW', 'UI'}:
region.tag_redraw()
def distance_from_point_to_segment(point: Vector, line_p1: Vector, line_p2: Vector) -> float:
"""
Calculates the shortest distance between the point and the finite segment.
This is an alternative to `mathutils.geometry.intersect_point_line` (w/ clamping).
Adapted from "Blockout" extension by niewinny (https://github.com/niewinny/blockout).
"""
segment = line_p2 - line_p1
start_to_point = point - line_p1
# Projection along segment.
c1 = start_to_point.dot(segment)
if c1 <= 0:
return (point - line_p1).length
# Segment length squared.
c2 = segment.dot(segment)
if c2 <= c1:
return (point - line_p2).length
t = c1 / c2
closest_point = line_p1 + t * segment
distance = (point - closest_point).length
return distance
def region_2d_to_ray_3d(region, rv3d, point_2d: Vector) -> tuple[Vector, Vector]:
"""
Converts a 2D screen-space point into a 3D ray in the world-space.
Returns a tuple of `ray_origin` and `ray_direction` Vectors.
"""
origin = view3d_utils.region_2d_to_origin_3d(region, rv3d, point_2d)
direction = view3d_utils.region_2d_to_vector_3d(region, rv3d, point_2d)
return origin, direction
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).
"""
location, normal = plane
p3_origin, p3_direction = region_2d_to_ray_3d(region, rv3d, point_2d)
# 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).
return p3_on_plane
@@ -1,24 +0,0 @@
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()
+22 -19
View File
@@ -4,31 +4,34 @@ import bpy
#### ------------------------------ FUNCTIONS ------------------------------ ####
def bool_tool_manual_map():
url_manual_prefix = "https://github.com/nickberckley/bool_tool/wiki/"
url_manual_prefix = "https://nickberckley.github.io/bool_tool/"
# Carver
url_manual_mapping = (("bpy.ops.object.carve", "Carver"),
# Brush Boolean
("bpy.ops.object.boolean_brush_union", "Boolean-Operators"),
("bpy.ops.object.boolean_brush_intersect", "Boolean-Operators"),
("bpy.ops.object.boolean_brush_difference", "Boolean-Operators"),
("bpy.ops.object.boolean_brush_slice", "Boolean-Operators"),
url_manual_mapping = (# Brush Boolean
("bpy.ops.object.boolean_brush_union", "booleans/brush_booleans.html"),
("bpy.ops.object.boolean_brush_intersect", "booleans/brush_booleans.html"),
("bpy.ops.object.boolean_brush_difference", "booleans/brush_booleans.html"),
("bpy.ops.object.boolean_brush_slice", "booleans/brush_booleans.html"),
# Auto Boolean
("bpy.ops.object.boolean_auto_union", "Boolean-Operators#auto-boolean-operators"),
("bpy.ops.object.boolean_auto_intersect", "Boolean-Operators#auto-boolean-operators"),
("bpy.ops.object.boolean_auto_difference", "Boolean-Operators#auto-boolean-operators"),
("bpy.ops.object.boolean_auto_slice", "Boolean-Operators#auto-boolean-operators"),
("bpy.ops.object.boolean_auto_union", "booleans/auto_booleans.html"),
("bpy.ops.object.boolean_auto_intersect", "booleans/auto_booleans.html"),
("bpy.ops.object.boolean_auto_difference", "booleans/auto_booleans.html"),
("bpy.ops.object.boolean_auto_slice", "booleans/auto_booleans.html"),
# Carver
("bpy.ops.object.carve_box", "carver/index.html"),
("bpy.ops.object.carve_circle", "carver/index.html"),
("bpy.ops.object.carve_polyline", "carver/index.html"),
# Cutter Utilities
("bpy.ops.object.boolean_toggle_cutter", "Utility-Operators#toggle-cutter"),
("bpy.ops.object.boolean_remove_cutter", "Utility-Operators#remove-cutter"),
("bpy.ops.object.boolean_apply_cutter", "Utility-Operators#apply-cutter"),
("bpy.ops.object.boolean_toggle_cutter", "utilities/toggle.html"),
("bpy.ops.object.boolean_remove_cutter", "utilities/remove.html"),
("bpy.ops.object.boolean_apply_cutter", "utilities/apply.html"),
# Canvas Utilities
("bpy.ops.object.boolean_toggle_all", "Utility-Operators#toggle-all-cutters"),
("bpy.ops.object.boolean_remove_all", "Utility-Operators#remove-all-cutters"),
("bpy.ops.object.boolean_apply_all", "Utility-Operators#apply-all-cutters"),
("bpy.ops.object.boolean_toggle_all", "utilities/toggle.html"),
("bpy.ops.object.boolean_remove_all", "utilities/remove.html"),
("bpy.ops.object.boolean_apply_all", "utilities/apply.html"),
# Select
("bpy.ops.object.select_cutter_canvas", "Utility-Operators#select-operators"),
("bpy.ops.object.boolean_select_all", "Utility-Operators#select-operators"),
("bpy.ops.object.select_cutter_canvas", "utilities/select.html"),
("bpy.ops.object.boolean_select_all", "utilities/select.html"),
)
return url_manual_prefix, url_manual_mapping
@@ -18,12 +18,12 @@ else:
#### ------------------------------ REGISTRATION ------------------------------ ####
modules = [
modules = (
boolean,
canvas,
cutter,
select,
]
)
def register():
for module in modules:
@@ -2,12 +2,13 @@ import bpy
from collections import defaultdict
from .. import __package__ as base_package
from ..functions.poll import (
basic_poll,
is_linked,
is_instanced_data,
list_candidate_objects,
destructive_op_confirmation,
from ..functions.canvas import (
filter_canvases,
create_slice,
)
from ..functions.cutter import (
filter_cutters,
make_cutter,
)
from ..functions.modifier import (
add_boolean_modifier,
@@ -15,23 +16,36 @@ from ..functions.modifier import (
get_modifiers_to_apply,
)
from ..functions.object import (
set_cutter_properties,
change_parent,
create_slice,
delete_cutter,
delete_object,
)
from ..functions.poll import (
basic_poll,
convert_to_mesh_confirmation,
destructive_op_confirmation,
)
#### ------------------------------ PROPERTIES ------------------------------ ####
class ModifierProperties():
class BooleanBase():
# Add-on properties.
flip: bpy.props.BoolProperty(
name = "Flip Canvas & Cutters",
options = {'SKIP_SAVE'},
default = False,
)
# Boolean modifier properties.
material_mode: bpy.props.EnumProperty(
name = "Materials",
description = "Method for setting materials on the new faces",
items = (('INDEX', "Index Based", ("Set the material on new faces based on the order of the material slot lists. If a material doesn't exist on the\n"
"modifier object, the face will use the same material slot or the first if the object doesn't have enough slots.")),
('TRANSFER', "Transfer", ("Transfer materials from non-empty slots to the result mesh, adding new materials as necessary.\n"
"For empty slots, fall back to using the same material index as the operand mesh."))),
items = (('INDEX', "Index Based",
("Set the material on new faces based on the order of the material slot lists. If a material doesn't exist on the\n"
"modifier object, the face will use the same material slot or the first if the object doesn't have enough slots.")),
('TRANSFER', "Transfer",
("Transfer materials from non-empty slots to the result mesh, adding new materials as necessary.\n"
"For empty slots, fall back to using the same material index as the operand mesh."))),
default = 'INDEX',
)
use_self: bpy.props.BoolProperty(
@@ -52,12 +66,24 @@ class ModifierProperties():
default = 0.000001,
)
# Built-in Methods.
@classmethod
def poll(cls, context):
return basic_poll(cls, context)
def draw(self, context):
prefs = context.preferences.addons[base_package].preferences
layout = self.layout
layout.use_property_split = True
col = layout.column()
col.prop(self, "flip")
if self._unflippable:
col.enabled = False
layout.separator()
if prefs.solver == 'EXACT':
layout.prop(self, "material_mode")
layout.prop(self, "use_self")
@@ -66,53 +92,86 @@ class ModifierProperties():
layout.prop(self, "double_threshold")
# Custom Methods.
def _filter_objects(self, context) -> tuple[list, list]:
"""Returns lists of cutters & canvases."""
canvases = [context.active_object]
cutters = [obj for obj in context.selected_objects if obj != context.active_object]
# Flip.
if self.flip:
canvases, cutters = cutters, canvases
# Filter Canvases.
canvases = filter_canvases(self, context, canvases)
if len(canvases) == 0:
self.report({'WARNING'}, "No valid canvases selected")
return None, None
# Filter Cutters.
cutters = filter_cutters(self, context, cutters, canvases)
if len(cutters) == 0:
self.report({'WARNING'}, "No valid cutters selected")
return None, None
return canvases, cutters
#### ------------------------------ /brush_boolean/ ------------------------------ ####
class BrushBoolean(ModifierProperties):
@classmethod
def poll(cls, context):
return basic_poll(cls, context)
class BrushBoolean(BooleanBase):
def invoke(self, context, event):
# Abort if there are less than 2 selected objects.
# Abort if there are less than 2 objects selected.
if len(context.selected_objects) < 2:
self.report({'WARNING'}, "Boolean operator needs at least two selected objects")
return {'CANCELLED'}
# Abort if active object is linked.
if is_linked(context, context.active_object):
self.report({'WARNING'}, "Boolean operators cannot be performed on linked objects")
return {'CANCELLED'}
if not self.flip:
cutters = [obj for obj in context.selected_objects if obj != context.active_object]
else:
cutters = [context.active_object]
return self.execute(context)
self._unflippable = False
return convert_to_mesh_confirmation(self, context, event, cutters, "Brush Boolean")
def execute(self, context):
prefs = context.preferences.addons[base_package].preferences
canvas = context.active_object
cutters = list_candidate_objects(self, context, context.active_object)
if len(cutters) == 0:
# Create lists of cutters & canvases.
canvases, cutters = self._filter_objects(context)
if canvases is None or cutters is None:
return {'CANCELLED'}
# Create slices.
if self.mode == "SLICE":
for cutter in cutters:
"""NOTE: Slices need to be created in a separate loop to avoid inheriting boolean modifiers that the operator adds."""
slice = create_slice(context, canvas, modifier=True)
add_boolean_modifier(self, context, slice, cutter, "INTERSECT", prefs.solver, pin=prefs.pin)
"""
NOTE: Slices need to be created in a separate loop to avoid
inheriting Boolean modifiers that the operator adds.
"""
for canvas in canvases:
slice = create_slice(context, canvas, modifier=True)
add_boolean_modifier(self, context, slice, cutter, "INTERSECT", prefs.solver, pin=prefs.pin)
for cutter in cutters:
mode = "DIFFERENCE" if self.mode == "SLICE" else self.mode
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)
make_cutter(context, cutter, self.mode,
display=prefs.display,
collection=prefs.use_collection)
canvas.booleans.canvas = True
mode = "DIFFERENCE" if self.mode == "SLICE" else self.mode
for canvas in canvases:
add_boolean_modifier(self, context, canvas, cutter, mode, prefs.solver, pin=prefs.pin)
if prefs.parent:
change_parent(context, cutter, canvases[0], inverse=True)
# Set Boolean property to canvases.
for canvas in canvases:
canvas.booleans.canvas = True
return {'FINISHED'}
@@ -120,29 +179,47 @@ class BrushBoolean(ModifierProperties):
class OBJECT_OT_boolean_brush_union(bpy.types.Operator, BrushBoolean):
bl_idname = "object.boolean_brush_union"
bl_label = "Boolean Union (Brush)"
bl_description = "Merge selected objects into active one"
bl_options = {'REGISTER', 'UNDO'}
mode = "UNION"
@classmethod
def description(cls, context, properties):
if not properties.flip:
return "Merge selected objects into the active one"
else:
return "Merge the active object into selected ones"
class OBJECT_OT_boolean_brush_intersect(bpy.types.Operator, BrushBoolean):
bl_idname = "object.boolean_brush_intersect"
bl_label = "Boolean Intersection (Brush)"
bl_description = "Only keep the parts of the active object that are interesecting selected objects"
bl_options = {'REGISTER', 'UNDO'}
mode = "INTERSECT"
@classmethod
def description(cls, context, properties):
if not properties.flip:
return "Only keep parts of the active object that are interesecting selected objects"
else:
return "Only keep parts of selected objects that are interesecting the active one"
class OBJECT_OT_boolean_brush_difference(bpy.types.Operator, BrushBoolean):
bl_idname = "object.boolean_brush_difference"
bl_label = "Boolean Difference (Brush)"
bl_description = "Subtract selected objects from active one"
bl_options = {'REGISTER', 'UNDO'}
mode = "DIFFERENCE"
@classmethod
def description(cls, context, properties):
if not properties.flip:
return "Subtract selected objects from the active one"
else:
return "Subtract the active object from selected ones"
class OBJECT_OT_boolean_brush_slice(bpy.types.Operator, BrushBoolean):
bl_idname = "object.boolean_brush_slice"
@@ -152,15 +229,18 @@ class OBJECT_OT_boolean_brush_slice(bpy.types.Operator, BrushBoolean):
mode = "SLICE"
@classmethod
def description(cls, context, properties):
if not properties.flip:
return "Slice the active object by selected ones. Will create slices as separate objects"
else:
return "Slice selected objects by the active one. Will create slices as separate objects"
#### ------------------------------ /auto_boolean/ ------------------------------ ####
class AutoBoolean(ModifierProperties):
@classmethod
def poll(cls, context):
return basic_poll(cls, context)
class AutoBoolean(BooleanBase):
def invoke(self, context, event):
# Abort if there are less than 2 selected objects.
@@ -168,55 +248,66 @@ class AutoBoolean(ModifierProperties):
self.report({'WARNING'}, "Boolean operator needs at least two selected objects")
return {'CANCELLED'}
# Abort if active object is linked.
if is_linked(context, context.active_object):
self.report({'ERROR'}, "Modifiers cannot be applied to linked object")
return {'CANCELLED'}
if not self.flip:
canvases = [context.active_object]
else:
canvases = [obj for obj in context.selected_objects if obj != context.active_object]
return destructive_op_confirmation(self, context, event, [context.active_object], title="Auto Boolean")
for canvas in canvases:
if canvas.type != 'MESH':
canvases.remove(canvas)
self._unflippable = False
return destructive_op_confirmation(self, context, event, canvases, "Auto Boolean")
def execute(self, context):
prefs = context.preferences.addons[base_package].preferences
canvas = context.active_object
cutters = list_candidate_objects(self, context, context.active_object)
new_modifiers = defaultdict(list)
if len(cutters) == 0:
# Create lists of cutters & canvases.
canvases, cutters = self._filter_objects(context)
if canvases is None or cutters is None:
return {'CANCELLED'}
# Create slices.
if self.mode == "SLICE":
for cutter in cutters:
"""NOTE: Slices need to be created in a separate loop to avoid inheriting boolean modifiers that the operator adds."""
slice = create_slice(context, canvas)
modifier = add_boolean_modifier(self, context, slice, cutter, "INTERSECT", prefs.solver, pin=prefs.pin)
new_modifiers[slice].append(modifier)
slice.select_set(True)
"""
NOTE: Slices need to be created in a separate loop to avoid
inheriting Boolean modifiers that the operator adds.
"""
for canvas in canvases:
slice = create_slice(context, canvas)
modifier = add_boolean_modifier(self, context, slice, cutter, "INTERSECT",
prefs.solver, pin=prefs.pin)
new_modifiers[slice].append(modifier)
slice.select_set(True)
for cutter in cutters:
# Add boolean modifier on canvas.
# Add Boolean modifier on canvases.
mode = "DIFFERENCE" if self.mode == "SLICE" else self.mode
modifier = add_boolean_modifier(self, context, canvas, cutter, mode, prefs.solver, pin=prefs.pin)
new_modifiers[canvas].append(modifier)
for canvas in canvases:
modifier = add_boolean_modifier(self, context, canvas, cutter, mode, prefs.solver, pin=prefs.pin)
new_modifiers[canvas].append(modifier)
# Transfer cutters children to canvas.
# Transfer cutters children to a canvas.
for child in cutter.children:
change_parent(child, canvas)
change_parent(context, child, canvases[0])
# Select all faces of the cutter so that newly created faces in canvas
# are also selected after applying the modifier.
for face in cutter.data.polygons:
face.select = True
# Apply modifiers on canvas & slices.
# Apply modifiers on canvases & slices.
for obj, modifiers in new_modifiers.items():
modifiers = get_modifiers_to_apply(context, obj, modifiers)
apply_modifiers(context, obj, modifiers)
# Delete cutters.
for cutter in cutters:
delete_cutter(cutter)
delete_object(cutter)
return {'FINISHED'}
@@ -224,29 +315,47 @@ class AutoBoolean(ModifierProperties):
class OBJECT_OT_boolean_auto_union(bpy.types.Operator, AutoBoolean):
bl_idname = "object.boolean_auto_union"
bl_label = "Boolean Union (Auto)"
bl_description = "Merge selected objects into active one"
bl_options = {'REGISTER', 'UNDO'}
mode = "UNION"
@classmethod
def description(cls, context, properties):
if not properties.flip:
return "Merge selected objects into the active one"
else:
return "Merge the active object into selected ones"
class OBJECT_OT_boolean_auto_difference(bpy.types.Operator, AutoBoolean):
bl_idname = "object.boolean_auto_difference"
bl_label = "Boolean Difference (Auto)"
bl_description = "Subtract selected objects from active one"
bl_options = {'REGISTER', 'UNDO'}
mode = "DIFFERENCE"
@classmethod
def description(cls, context, properties):
if not properties.flip:
return "Subtract selected objects from the active one"
else:
return "Subtract the active object from selected ones"
class OBJECT_OT_boolean_auto_intersect(bpy.types.Operator, AutoBoolean):
bl_idname = "object.boolean_auto_intersect"
bl_label = "Boolean Intersect (Auto)"
bl_description = "Only keep the parts of the active object that are interesecting selected objects"
bl_options = {'REGISTER', 'UNDO'}
mode = "INTERSECT"
@classmethod
def description(cls, context, properties):
if not properties.flip:
return "Only keep parts of the active object that are interesecting selected objects"
else:
return "Only keep parts of selected objects that are interesecting the active one"
class OBJECT_OT_boolean_auto_slice(bpy.types.Operator, AutoBoolean):
bl_idname = "object.boolean_auto_slice"
@@ -256,13 +365,20 @@ class OBJECT_OT_boolean_auto_slice(bpy.types.Operator, AutoBoolean):
mode = "SLICE"
@classmethod
def description(cls, context, properties):
if not properties.flip:
return "Slice the active object by selected ones. Will create slices as separate objects"
else:
return "Slice selected objects by the active one. Will create slices as separate objects"
#### ------------------------------ REGISTRATION ------------------------------ ####
addon_keymaps = []
classes = [
classes = (
OBJECT_OT_boolean_brush_union,
OBJECT_OT_boolean_brush_difference,
OBJECT_OT_boolean_brush_intersect,
@@ -272,7 +388,7 @@ classes = [
OBJECT_OT_boolean_auto_difference,
OBJECT_OT_boolean_auto_intersect,
OBJECT_OT_boolean_auto_slice,
]
)
def register():
@@ -2,29 +2,30 @@ import bpy
import itertools
from .. import __package__ as base_package
from ..functions.poll import (
basic_poll,
is_canvas,
is_instanced_data,
destructive_op_confirmation,
from ..functions.canvas import (
list_selected_canvases,
list_canvas_cutters,
list_canvas_slices,
)
from ..functions.cutter import (
list_cutter_users,
handle_unused_cutters,
)
from ..functions.modifier import (
apply_modifiers,
get_modifiers_to_apply,
is_boolean_modifier,
)
from ..functions.object import (
object_visibility_set,
delete_empty_collection,
delete_cutter,
change_parent,
delete_object,
)
from ..functions.list import (
list_canvases,
list_canvas_slices,
list_canvas_cutters,
list_cutter_users,
list_selected_canvases,
list_unused_cutters,
list_pre_boolean_modifiers,
from ..functions.poll import (
basic_poll,
destructive_op_confirmation,
_guess_toggle_state,
)
from ..functions.scene import (
delete_empty_collection,
)
@@ -34,41 +35,50 @@ from ..functions.list import (
class OBJECT_OT_boolean_toggle_all(bpy.types.Operator):
bl_idname = "object.boolean_toggle_all"
bl_label = "Toggle Boolean Cutters"
bl_description = "Toggle all boolean cutters affecting selected canvases"
bl_description = "Toggle all Boolean cutters affecting selected canvases"
bl_options = {'UNDO'}
@classmethod
def poll(cls, context):
return basic_poll(cls, context, check_linked=True) and is_canvas(context.active_object)
return basic_poll(cls, context, check_active=False)
def execute(self, context):
# Filter canvases.
canvases = list_selected_canvases(context)
if len(canvases) == 0:
self.report({'WARNING'}, "No valid canvases selected")
return {'CANCELLED'}
cutters, modifiers = list_canvas_cutters(canvases)
slices = list_canvas_slices(canvases)
modifiers = list(itertools.chain.from_iterable(modifiers.values()))
slices = list_canvas_slices(context, canvases)
state = _guess_toggle_state(modifiers)
state = True if state == "On" else False
# Toggle Modifiers
for mod in modifiers:
mod.show_viewport = not mod.show_viewport
mod.show_render = not mod.show_render
mod.show_viewport = not state
mod.show_render = not state
# Hide Slices
for slice in slices:
slice.hide_viewport = not slice.hide_viewport
slice.hide_render = not slice.hide_render
slice.hide_viewport = state
slice.hide_render = state
slice.hide_set(state)
for mod in slice.modifiers:
if mod.type == 'BOOLEAN' and mod.object in cutters:
mod.show_viewport = not mod.show_viewport
mod.show_render = not mod.show_render
if not is_boolean_modifier(mod):
continue
if mod.object in cutters:
mod.show_viewport = not state
mod.show_render = not state
# Hide Unused Cutters
other_canvases = list_canvases()
for obj in other_canvases:
if obj not in canvases + slices:
if any(mod.object in cutters and mod.show_viewport for mod in obj.modifiers if mod.type == 'BOOLEAN'):
cutters[:] = [cutter for cutter in cutters if cutter not in [mod.object for mod in obj.modifiers]]
for cutter in cutters:
cutter.hide_viewport = not cutter.hide_viewport
other_canvases = list_cutter_users([cutter], exclude=canvases + slices).keys()
if len(other_canvases) == 0:
cutter.hide_viewport = state
cutter.hide_set(state)
return {'FINISHED'}
@@ -77,72 +87,49 @@ class OBJECT_OT_boolean_toggle_all(bpy.types.Operator):
class OBJECT_OT_boolean_remove_all(bpy.types.Operator):
bl_idname = "object.boolean_remove_all"
bl_label = "Remove Boolean Cutters"
bl_description = "Remove all boolean cutters from selected canvases"
bl_options = {'UNDO'}
bl_description = "Remove all Boolean cutters from selected canvases"
bl_options = {'REGISTER', 'UNDO'}
delete_cutters: bpy.props.BoolProperty(
name = "Delete Unused Cutters",
description = "Completely remove cutters if they're not used by any other remaining canvas",
default = True,
)
@classmethod
def poll(cls, context):
return basic_poll(cls, context, check_linked=True) and is_canvas(context.active_object)
return basic_poll(cls, context, check_active=False)
def execute(self, context):
prefs = context.preferences.addons[base_package].preferences
# Filter canvases.
canvases = list_selected_canvases(context)
cutters, __ = list_canvas_cutters(canvases)
slices = list_canvas_slices(canvases)
if len(canvases) == 0:
self.report({'WARNING'}, "No valid canvases selected")
return {'CANCELLED'}
cutters, modifiers = list_canvas_cutters(canvases)
slices = list_canvas_slices(context, canvases)
# Remove Slices
for slice in slices:
if slice in canvases:
canvases.remove(slice)
delete_cutter(slice)
delete_object(slice)
for canvas in canvases:
# Remove Modifiers
for mod in canvas.modifiers:
if mod.type == 'BOOLEAN' and "boolean_" in mod.name:
if mod.object in cutters:
canvas.modifiers.remove(mod)
# Remove Modifiers
for canvas, mods in modifiers.items():
for mod in mods:
canvas.modifiers.remove(mod)
canvas.booleans.canvas = False
# remove_boolean_properties
if canvas.booleans.canvas == True:
canvas.booleans.canvas = False
# Handle Unused Cutters
handle_unused_cutters(context, list(cutters), canvases + slices,
delete=self.delete_cutters)
# Restore Orphaned Cutters
unused_cutters, leftovers = list_unused_cutters(cutters, canvases, slices, do_leftovers=True)
for cutter in unused_cutters:
if cutter.booleans.carver:
delete_cutter(cutter)
else:
# restore_visibility
cutter.hide_render = False
cutter.display_type = 'TEXTURED'
cutter.lineart.usage = 'INHERIT'
object_visibility_set(cutter, value=True)
cutter.booleans.cutter = ""
# remove_parent_&_collection
if prefs.parent and cutter.parent in canvases:
change_parent(cutter, None)
if prefs.use_collection:
cutters_collection = bpy.data.collections.get(prefs.collection_name)
if cutters_collection in cutter.users_collection:
bpy.data.collections.get(prefs.collection_name).objects.unlink(cutter)
# purge_empty_collection
if prefs.use_collection:
delete_empty_collection()
# Change Leftover Cutter Parent
if prefs.parent:
for cutter in leftovers:
if cutter.parent in canvases:
other_canvases = list_cutter_users([cutter])
change_parent(cutter, other_canvases[0])
# Purge Empty Collection
delete_empty_collection(context)
return {'FINISHED'}
@@ -151,74 +138,56 @@ class OBJECT_OT_boolean_remove_all(bpy.types.Operator):
class OBJECT_OT_boolean_apply_all(bpy.types.Operator):
bl_idname = "object.boolean_apply_all"
bl_label = "Apply All Boolean Cutters"
bl_description = "Apply all boolean cutters on selected canvases"
bl_options = {'UNDO'}
bl_description = "Apply all Boolean cutters to selected canvases"
bl_options = {'REGISTER', 'UNDO'}
delete_cutters: bpy.props.BoolProperty(
name = "Delete Unused Cutters",
description = "Completely remove cutters if they're not used by any other remaining canvas",
default = True,
)
@classmethod
def poll(cls, context):
return basic_poll(cls, context, check_linked=True) and is_canvas(context.active_object)
return basic_poll(cls, context, check_active=False)
def invoke(self, context, event):
self.canvases = list_selected_canvases(context)
return destructive_op_confirmation(self, context, event, self.canvases, title="Apply Boolean Cutters")
# Filter canvases.
canvases = list_selected_canvases(context)
if len(canvases) == 0:
self.report({'WARNING'}, "No valid canvases selected")
return {'CANCELLED'}
return destructive_op_confirmation(self, context, event, canvases, title="Apply Boolean Cutters")
def execute(self, context):
prefs = context.preferences.addons[base_package].preferences
cutters, __ = list_canvas_cutters(self.canvases)
slices = list_canvas_slices(self.canvases)
canvases = list_selected_canvases(context)
cutters, __ = list_canvas_cutters(canvases)
slices = list_canvas_slices(context, canvases)
# Select all faces of the cutter so that newly created faces in canvas
# are also selected after applying the modifier.
for cutter in cutters:
for cutter in list(cutters):
for face in cutter.data.polygons:
face.select = True
for canvas in itertools.chain(self.canvases, slices):
context.view_layer.objects.active = canvas
for canvas in itertools.chain(canvases, slices):
# Apply Modifiers
if prefs.apply_order == 'ALL':
modifiers = [mod for mod in canvas.modifiers]
elif prefs.apply_order == 'BEFORE':
modifiers = list_pre_boolean_modifiers(canvas)
elif prefs.apply_order == 'BOOLEANS':
modifiers = [mod for mod in canvas.modifiers if mod.type == 'BOOLEAN' and "boolean_" in mod.name]
modifiers = get_modifiers_to_apply(context, canvas)
apply_modifiers(context, canvas, modifiers)
# remove_boolean_properties
# Remove Boolean Properties
canvas.booleans.canvas = False
canvas.booleans.slice = False
canvas.booleans.slice_of = None
# Handle Unused Cutters
handle_unused_cutters(context, list(cutters), canvases, delete=self.delete_cutters)
# Purge Orphaned Cutters
unused_cutters, leftovers = list_unused_cutters(cutters, self.canvases, slices, do_leftovers=True)
purged_cutters = []
for cutter in unused_cutters:
if cutter not in purged_cutters:
# Transfer Children
for child in cutter.children:
change_parent(child, cutter.parent)
# Purge
delete_cutter(cutter)
purged_cutters.append(cutter)
# purge_empty_collection
if prefs.use_collection:
delete_empty_collection()
# Change Leftover Cutter Parent
if prefs.parent:
for cutter in leftovers:
if cutter.parent in self.canvases:
other_canvases = list_cutter_users([cutter])
change_parent(cutter, other_canvases[0])
# Purge Empty Collection
delete_empty_collection(context)
return {'FINISHED'}
@@ -228,11 +197,11 @@ class OBJECT_OT_boolean_apply_all(bpy.types.Operator):
addon_keymaps = []
classes = [
classes = (
OBJECT_OT_boolean_toggle_all,
OBJECT_OT_boolean_remove_all,
OBJECT_OT_boolean_apply_all,
]
)
def register():
@@ -1,27 +1,35 @@
import bpy
import itertools
from .. import __package__ as base_package
from ..functions.poll import (
basic_poll,
is_instanced_data,
destructive_op_confirmation,
from ..functions.canvas import (
list_canvas_cutters,
list_canvas_slices,
)
from ..functions.cutter import (
list_selected_cutters,
list_cutter_users,
restore_cutter,
handle_unused_cutters,
)
from ..functions.modifier import (
apply_modifiers,
is_boolean_modifier,
)
from ..functions.object import (
object_visibility_set,
delete_empty_collection,
delete_cutter,
change_parent,
delete_object,
)
from ..functions.list import (
list_canvases,
list_selected_cutters,
list_canvas_cutters,
list_canvas_slices,
list_cutter_users,
list_unused_cutters,
from ..functions.poll import (
basic_poll,
destructive_op_confirmation,
_guess_toggle_state,
)
from ..functions.scene import (
delete_empty_collection,
)
from ..ui.common import (
preserve_list_index,
)
@@ -31,13 +39,19 @@ from ..functions.list import (
class OBJECT_OT_boolean_toggle_cutter(bpy.types.Operator):
bl_idname = "object.boolean_toggle_cutter"
bl_label = "Toggle Boolean Cutter"
bl_description = "Toggle this boolean cutter. If cutter is the active object it will be toggled for every canvas that uses it"
bl_options = {'UNDO'}
@classmethod
def description(cls, context, properties):
if properties.specified_cutter:
return ("Toggle selected cutter and its effect")
else:
return ("Toggle all selected cutters and their effects")
method: bpy.props.EnumProperty(
name = "Method",
items = (('ALL', "All", "Remove cutter from all canvases that use it"),
('SPECIFIED', "Specified", "Remove cutter from specified canvas")),
items = (('ALL', "All", "Toggle all selected cutters"),
('SPECIFIED', "Specified", "Toggle selected cutter")),
default = 'ALL',
)
@@ -45,57 +59,71 @@ class OBJECT_OT_boolean_toggle_cutter(bpy.types.Operator):
)
specified_canvas: bpy.props.StringProperty(
)
specified_modifier: bpy.props.StringProperty(
)
@classmethod
def poll(cls, context):
return basic_poll(cls, context, check_linked=True)
return basic_poll(cls, context, check_active=False)
def execute(self, context):
# Create lists of cutters & canvases.
if self.method == 'SPECIFIED':
canvases = [context.scene.objects[self.specified_canvas]]
cutters = [context.scene.objects[self.specified_cutter]]
slices = list_canvas_slices(canvases)
cutters: list = [context.scene.objects[self.specified_cutter]]
canvases: list = [context.scene.objects[self.specified_canvas]]
modifiers: list = [canvases[0].modifiers.get(self.specified_modifier)]
slices: list = list_canvas_slices(context, canvases)
elif self.method == 'ALL':
cutters = list_selected_cutters(context)
canvases = list_cutter_users(cutters)
cutters: list = list_selected_cutters(context)
canvases: dict = list_cutter_users(cutters)
modifiers: list = list(itertools.chain.from_iterable(canvases.values()))
if cutters:
for canvas in canvases:
# toggle_slices_visibility (for_all_canvases)
if canvas.booleans.slice == True:
if any(modifier.object in cutters for modifier in canvas.modifiers):
canvas.hide_viewport = not canvas.hide_viewport
canvas.hide_render = not canvas.hide_render
# Toggle Modifiers
for mod in canvas.modifiers:
if mod.type == 'BOOLEAN' and mod.object in cutters:
mod.show_viewport = not mod.show_viewport
mod.show_render = not mod.show_render
if self.method == 'SPECIFIED':
# toggle_slices_visibility (for_specified_canvas)
for slice in slices:
for mod in slice.modifiers:
if mod.type == 'BOOLEAN' and mod.object in cutters:
slice.hide_viewport = not slice.hide_viewport
slice.hide_render = not slice.hide_render
mod.show_viewport = not mod.show_viewport
mod.show_render = not mod.show_render
# hide_cutter_if_not_used_by_any_visible_modifiers
other_canvases = list_canvases()
for obj in other_canvases:
if obj not in canvases + slices:
if any(mod.object in cutters and mod.show_viewport for mod in obj.modifiers if mod.type == 'BOOLEAN'):
cutters[:] = [cutter for cutter in cutters if cutter not in [mod.object for mod in obj.modifiers]]
for cutter in cutters:
cutter.hide_viewport = not cutter.hide_viewport
else:
if len(cutters) == 0:
self.report({'INFO'}, "Boolean cutters are not selected")
return {'CANCELLED'}
if not canvases:
return {'CANCELLED'}
state = _guess_toggle_state(modifiers)
state = True if state == "On" else False
# Toggle Modifiers
for mod in modifiers:
mod.show_viewport = not state
mod.show_render = not state
# Hide Slices
if self.method == 'ALL':
for canvas in canvases.keys():
if not canvas.booleans.slice:
continue
slice = canvas
if any(modifier.object in cutters for modifier in slice.modifiers):
slice.hide_viewport = state
slice.hide_render = state
slice.hide_set(state)
elif self.method == 'SPECIFIED':
for slice in slices:
for mod in slice.modifiers:
if not is_boolean_modifier(mod):
continue
if mod.object in cutters:
mod.show_viewport = not state
mod.show_render = not state
slice.hide_viewport = state
slice.hide_render = state
slice.hide_set(state)
# Hide Unused Cutters
for cutter in cutters:
other_canvases = list_cutter_users([cutter], exclude=canvases + slices).keys()
if len(other_canvases) == 0:
cutter.hide_viewport = state
cutter.hide_set(state)
return {'FINISHED'}
@@ -104,9 +132,16 @@ class OBJECT_OT_boolean_toggle_cutter(bpy.types.Operator):
class OBJECT_OT_boolean_remove_cutter(bpy.types.Operator):
bl_idname = "object.boolean_remove_cutter"
bl_label = "Remove Boolean Cutter"
bl_description = "Remove this boolean cutter. If cutter is the active object it will be removed from every canvas that uses it"
bl_options = {'UNDO'}
@classmethod
def description(cls, context, properties):
if properties.specified_cutter:
return ("Remove this cutter and the Boolean modifier that uses it.\n"
"If the cutter is not used by any other canvas it will be deleted from the scene")
else:
return ("Remove selected Boolean cutters from all canvases that uses them; restore their visibility")
method: bpy.props.EnumProperty(
name = "Method",
items = (('ALL', "All", "Remove cutter from all canvases that use it"),
@@ -118,86 +153,70 @@ class OBJECT_OT_boolean_remove_cutter(bpy.types.Operator):
)
specified_canvas: bpy.props.StringProperty(
)
specified_modifier: bpy.props.StringProperty(
)
@classmethod
def poll(cls, context):
return basic_poll(cls, context, check_linked=True)
return basic_poll(cls, context, check_active=False)
def execute(self, context):
prefs = context.preferences.addons[base_package].preferences
leftovers = []
# Create lists of cutters & canvases.
if self.method == 'SPECIFIED':
canvases = [context.scene.objects[self.specified_canvas]]
cutters = [context.scene.objects[self.specified_cutter]]
slices = list_canvas_slices(canvases)
cutters: list = [context.scene.objects[self.specified_cutter]]
canvases: dict = {
context.scene.objects[self.specified_canvas]:
[context.scene.objects[self.specified_canvas].modifiers.get(self.specified_modifier)]
}
elif self.method == 'ALL':
cutters = list_selected_cutters(context)
canvases = list_cutter_users(cutters)
cutters: list = list_selected_cutters(context)
canvases: dict = list_cutter_users(cutters)
if cutters:
# Remove Modifiers
for canvas in canvases:
for mod in canvas.modifiers:
if "boolean_" in mod.name:
if mod.object in cutters:
canvas.modifiers.remove(mod)
# remove_canvas_property_if_needed
other_cutters, __ = list_canvas_cutters([canvas])
if len(other_cutters) == 0:
canvas.booleans.canvas = False
# Remove Slices (for_all_method)
if canvas.booleans.slice == True:
delete_cutter(canvas)
if self.method == 'SPECIFIED':
# Remove Slices (for_specified_method)
for slice in slices:
for mod in slice.modifiers:
if mod.type == 'BOOLEAN' and mod.object in cutters:
delete_cutter(slice)
cutters, leftovers = list_unused_cutters(cutters, canvases, do_leftovers=True)
# Restore Orphaned Cutters
for cutter in cutters:
if self.method == 'SPECIFIED' and cutter.booleans.carver:
delete_cutter(cutter)
else:
# restore_visibility
cutter.hide_render = False
cutter.display_type = 'TEXTURED'
cutter.lineart.usage = 'INHERIT'
object_visibility_set(cutter, value=True)
cutter.booleans.cutter = ""
# remove_parent_&_collection
if prefs.parent and cutter.parent in canvases:
change_parent(cutter, None)
if prefs.use_collection:
cutters_collection = bpy.data.collections.get(prefs.collection_name)
if cutters_collection in cutter.users_collection:
bpy.data.collections.get(prefs.collection_name).objects.unlink(cutter)
# purge_empty_collection
if prefs.use_collection:
delete_empty_collection()
# Change Leftover Cutter Parent
if prefs.parent and leftovers != None:
for cutter in leftovers:
if cutter.parent in canvases:
other_canvases = list_cutter_users([cutter])
change_parent(cutter, other_canvases[0])
else:
if len(cutters) == 0:
self.report({'INFO'}, "Boolean cutters are not selected")
return {'CANCELLED'}
if not canvases:
return {'CANCELLED'}
# Remove Slices
slices = list_canvas_slices(context, canvases.keys())
for slice in slices:
for mod in slice.modifiers:
if not is_boolean_modifier(mod):
continue
if mod.object in cutters:
if slice in canvases:
del canvases[slice]
delete_object(slice)
continue
# Remove Modifiers
for canvas, modifiers in canvases.items():
for mod in modifiers:
with preserve_list_index(canvas.booleans, "modifiers_list_index"):
canvas.modifiers.remove(mod)
# Unset canvas property if it's no longer needed.
other_cutters, __ = list_canvas_cutters([canvas])
if len(other_cutters) == 0:
canvas.booleans.canvas = False
# Handle Unused Cutters
if self.method == 'SPECIFIED':
handle_unused_cutters(context, cutters, list(canvases.keys()), delete=True)
elif self.method == 'ALL':
for cutter in cutters:
# Restore unused cutters, even if they don't have other users.
restore_cutter(context, cutter,
unparent=prefs.parent and cutter.parent in canvases,
unlink_collection=prefs.use_collection)
# Purge Empty Collection
delete_empty_collection(context)
return {'FINISHED'}
@@ -206,104 +225,100 @@ class OBJECT_OT_boolean_remove_cutter(bpy.types.Operator):
class OBJECT_OT_boolean_apply_cutter(bpy.types.Operator):
bl_idname = "object.boolean_apply_cutter"
bl_label = "Apply Boolean Cutter"
bl_description = "Apply this boolean cutter. If cutter is the active object it will be applied to every canvas that uses it"
bl_options = {'UNDO'}
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def description(cls, context, properties):
if properties.specified_cutter:
return ("Apply the modifier using this Boolean cutter")
else:
return ("Apply this Boolean cutter to every canvas object that uses it")
method: bpy.props.EnumProperty(
name = "Method",
items = (('ALL', "All", "Remove cutter from all canvases that use it"),
('SPECIFIED', "Specified", "Remove cutter from specified canvas")),
items = (('ALL', "All", "Apply all selected cutter to all canvases that use them"),
('SPECIFIED', "Specified", "Apply selected cutter to a specified canvas")),
options = {'SKIP_SAVE', 'HIDDEN'},
default = 'ALL',
)
specified_cutter: bpy.props.StringProperty(
options = {'SKIP_SAVE', 'HIDDEN'},
)
specified_canvas: bpy.props.StringProperty(
options = {'SKIP_SAVE', 'HIDDEN'},
)
delete: bpy.props.BoolProperty(
name = "Delete Unused Cutter",
description = "Completely remove the cutter object if it's not used by any other canvas",
default = True,
)
@classmethod
def poll(cls, context):
return basic_poll(cls, context, check_linked=True)
return basic_poll(cls, context, check_active=False)
def invoke(self, context, event):
# Filter Objects
if self.method == 'SPECIFIED':
self.cutters = [context.scene.objects[self.specified_cutter]]
self.canvases = [context.scene.objects[self.specified_canvas]]
self.slices = list_canvas_slices(self.canvases)
canvases = [context.scene.objects[self.specified_canvas]]
elif self.method == 'ALL':
self.cutters = list_selected_cutters(context)
self.canvases = list_cutter_users(self.cutters)
return destructive_op_confirmation(self, context, event, self.canvases, title="Apply Boolean Cutter")
cutters = list_selected_cutters(context)
canvases = list_cutter_users(cutters).keys()
return destructive_op_confirmation(self, context, event, canvases, title="Apply Boolean Cutter")
def execute(self, context):
prefs = bpy.context.preferences.addons[base_package].preferences
leftovers = []
if self.cutters:
# Select all faces of the cutter so that newly created faces in canvas
# are also selected after applying the modifier.
for cutter in self.cutters:
for face in cutter.data.polygons:
face.select = True
# Create lists of cutters & canvases.
if self.method == 'SPECIFIED':
cutters = [context.scene.objects[self.specified_cutter]]
canvases = [context.scene.objects[self.specified_canvas]]
slices = list_canvas_slices(context, canvases)
elif self.method == 'ALL':
cutters = list_selected_cutters(context)
canvases = list_cutter_users(cutters).keys()
slices = []
# Apply Modifiers
for canvas in self.canvases:
context.view_layer.objects.active = canvas
if len(cutters) == 0:
self.report({'INFO'}, "Boolean cutters are not selected")
return {'CANCELLED'}
boolean_mods = []
for mod in canvas.modifiers:
if "boolean_" in mod.name:
if mod.object in self.cutters:
boolean_mods.append(mod)
apply_modifiers(context, canvas, boolean_mods)
if not canvases:
return {'CANCELLED'}
# remove_canvas_property_if_needed
# Select all faces of the cutter so that newly created faces in canvas
# are also selected after applying the modifier.
for cutter in cutters:
for face in cutter.data.polygons:
face.select = True
# Apply Modifiers
for canvas in itertools.chain(canvases, slices):
boolean_mods = []
for mod in canvas.modifiers:
if not is_boolean_modifier(mod):
continue
if mod.object in cutters:
boolean_mods.append(mod)
if boolean_mods:
with preserve_list_index(canvas.booleans, "modifiers_list_index"):
apply_modifiers(context, canvas, boolean_mods)
# Unset canvas property if it's no longer needed.
other_cutters, __ = list_canvas_cutters([canvas])
if len(other_cutters) == 0:
canvas.booleans.canvas = False
canvas.booleans.slice = False
canvas.booleans.slice_of = None
# Handle Unused Cutters
handle_unused_cutters(context, cutters, canvases, delete=self.delete)
if self.method == 'SPECIFIED':
# Apply Modifier for Slices (for_specified_method)
for slice in self.slices:
boolean_mods = []
for mod in slice.modifiers:
if mod.type == 'BOOLEAN' and mod.object in self.cutters:
boolean_mods.append(mod)
apply_modifiers(context, slice, boolean_mods)
unused_cutters, leftovers = list_unused_cutters(self.cutters, self.canvases, do_leftovers=True)
for cutter in unused_cutters:
# Transfer Children
for child in cutter.children:
change_parent(child, cutter.parent)
# Purge Orphaned Cutters
delete_cutter(cutter)
# purge_empty_collection
if prefs.use_collection:
delete_empty_collection()
# Change Leftover Cutter Parent
if prefs.parent and leftovers != None:
for cutter in leftovers:
if cutter.parent in self.canvases:
other_canvases = list_cutter_users([cutter])
change_parent(cutter, other_canvases[0])
else:
self.report({'INFO'}, "Boolean cutters are not selected")
# Purge Empty Collection
delete_empty_collection(context)
return {'FINISHED'}
@@ -313,11 +328,11 @@ class OBJECT_OT_boolean_apply_cutter(bpy.types.Operator):
addon_keymaps = []
classes = [
classes = (
OBJECT_OT_boolean_toggle_cutter,
OBJECT_OT_boolean_remove_cutter,
OBJECT_OT_boolean_apply_cutter,
]
)
def register():
@@ -1,17 +1,17 @@
import bpy
from .. import __package__ as base_package
from ..functions.poll import (
basic_poll,
active_modifier_poll,
from ..functions.canvas import (
is_canvas,
)
from ..functions.list import (
list_selected_cutters,
list_selected_canvases,
list_canvas_cutters,
)
from ..functions.cutter import (
list_selected_cutters,
list_cutter_users,
)
from ..functions.poll import (
basic_poll,
)
#### ------------------------------ OPERATORS ------------------------------ ####
@@ -20,21 +20,24 @@ from ..functions.list import (
class OBJECT_OT_select_cutter_canvas(bpy.types.Operator):
bl_idname = "object.select_cutter_canvas"
bl_label = "Select Boolean Canvas"
bl_description = "Select all the objects that use selected objects as boolean cutters"
bl_description = "Select all canvases that use selected objects as Boolean cutters"
bl_options = {'UNDO'}
@classmethod
def poll(cls, context):
return basic_poll(cls, context) and context.active_object.booleans.cutter
return basic_poll(cls, context, check_active=True) and context.active_object.booleans.cutter
def execute(self, context):
cutters = list_selected_cutters(context)
canvases = list_cutter_users(cutters)
canvases = list_cutter_users(cutters).keys()
# Select Canvases
bpy.ops.object.select_all(action='DESELECT')
for obj in context.scene.objects:
obj.select_set(False)
# Select canvases.
for canvas in canvases:
canvas.select_set(True)
if not canvas.booleans.slice:
canvas.select_set(True)
return {'FINISHED'}
@@ -43,19 +46,21 @@ class OBJECT_OT_select_cutter_canvas(bpy.types.Operator):
class OBJECT_OT_boolean_select_all(bpy.types.Operator):
bl_idname = "object.boolean_select_all"
bl_label = "Select Boolean Cutters"
bl_description = "Select all boolean cutters affecting active object"
bl_description = "Select all Boolean cutters affecting selected canvases"
bl_options = {'UNDO'}
@classmethod
def poll(cls, context):
return basic_poll(cls, context) and is_canvas(context.active_object)
return basic_poll(cls, context, check_active=True) and is_canvas(context.active_object)
def execute(self, context):
canvases = list_selected_canvases(context)
cutters, __ = list_canvas_cutters(canvases)
# select_cutters
bpy.ops.object.select_all(action='DESELECT')
for obj in context.scene.objects:
obj.select_set(False)
# Select cutters.
for cutter in cutters:
cutter.select_set(True)
@@ -66,24 +71,24 @@ class OBJECT_OT_boolean_select_all(bpy.types.Operator):
class OBJECT_OT_boolean_select_cutter(bpy.types.Operator):
bl_idname = "object.boolean_select_cutter"
bl_label = "Select Boolean Cutter"
bl_description = "Select object that is used as boolean cutter by this modifier"
bl_options = {'REGISTER', 'UNDO'}
bl_options = {'UNDO'}
@classmethod
def poll(cls, context):
prefs = context.preferences.addons[base_package].preferences
return (basic_poll(cls, context) and active_modifier_poll(context.active_object) and
context.area.type == 'PROPERTIES' and context.space_data.context == 'MODIFIER' and
prefs.double_click)
cutter: bpy.props.StringProperty()
extend: bpy.props.BoolProperty()
def invoke(self, context, event):
self.extend = event.shift
return self.execute(context)
def execute(self, context):
modifier = context.object.modifiers.active
if modifier and modifier.type == "BOOLEAN":
cutter = modifier.object
cutter = bpy.data.objects.get(self.cutter)
bpy.ops.object.select_all(action='DESELECT')
if not self.extend:
for obj in context.scene.objects:
obj.select_set(False)
if cutter:
cutter.select_set(True)
context.view_layer.objects.active = cutter
return {'FINISHED'}
@@ -93,31 +98,16 @@ class OBJECT_OT_boolean_select_cutter(bpy.types.Operator):
addon_keymaps = []
classes = [
classes = (
OBJECT_OT_select_cutter_canvas,
OBJECT_OT_boolean_select_all,
OBJECT_OT_boolean_select_cutter,
]
)
def register():
for cls in classes:
bpy.utils.register_class(cls)
# KEYMAP
addon = bpy.context.window_manager.keyconfigs.addon
km = addon.keymaps.new(name="Property Editor", space_type='PROPERTIES')
kmi = km.keymap_items.new("object.boolean_select_cutter", type='LEFTMOUSE', value='DOUBLE_CLICK')
kmi.active = True
addon_keymaps.append((km, kmi))
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
# KEYMAP
for km, kmi in addon_keymaps:
km.keymap_items.remove(kmi)
addon_keymaps.clear()
+75 -85
View File
@@ -8,9 +8,9 @@ def update_sidebar_category(self, context):
"""Change sidebar category of add-ons panel."""
panel_classes = [
ui.VIEW3D_PT_boolean,
ui.VIEW3D_PT_boolean_properties,
ui.VIEW3D_PT_boolean_cutters,
ui.panels.VIEW3D_PT_boolean,
ui.panels.VIEW3D_PT_boolean_helpers,
ui.panels.VIEW3D_PT_boolean_cutters,
]
for cls in panel_classes:
@@ -29,14 +29,22 @@ class BoolToolPreferences(bpy.types.AddonPreferences):
bl_idname = __package__
# UI
category: bpy.props.EnumProperty(
name = "Category",
items = (('ADDON', "Add-on", "General add-on features"),
('SHARED', "Shared", "Features shared by all of add-ons operators and tools"),
('OPERATORS', "Boolean Operators", "Features for brush and auto Boolean operators")),
default = 'OPERATORS'
)
show_in_sidebar: bpy.props.BoolProperty(
name = "Show Addon Panel in Sidebar",
name = "Show Add-on Panel in Sidebar",
description = "Add a sidebar panel in 3D Viewport with add-ons operators and properties",
default = True,
)
sidebar_category: bpy.props.StringProperty(
name = "Category Name",
description = "Sidebar category name. Using the name of the existing category will add panel there",
description = "Sidebar category name. Using the name of the existing category will add the panel there",
default = "Edit",
update = update_sidebar_category,
)
@@ -44,87 +52,67 @@ class BoolToolPreferences(bpy.types.AddonPreferences):
# Defaults
solver: bpy.props.EnumProperty(
name = "Boolean Solver",
description = "Which solver to use for automatic and brush booleans",
description = "Which solver to use for automatic and brush Boolean operators",
items = [('FLOAT', "Float", ""),
('EXACT', "Exact", ""),
('MANIFOLD', "Manifold", "")],
default = 'FLOAT',
)
wireframe: bpy.props.BoolProperty(
name = "Display Cutters as Wireframe",
description = ("When enabled cutters will be displayed as wireframes, instead of bounding boxes.\n"
"It's better for visualizating the shape, but might be harder to see and have performance cost"),
default = False,
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'
)
show_in_editmode: bpy.props.BoolProperty(
name = "Enable 'Show in Edit Mode' by Default",
description = "Every new boolean modifier created with brush boolean wil have 'Show in Edit Mode' enabled by default",
description = "Added Boolean modifiers will have 'Show in Edit Mode' enabled by default",
default = True,
)
# Advanced
use_collection: bpy.props.BoolProperty(
name = "Put Cutters in Collection",
description = ("Brush boolean operators will put all cutters in same collection, and create one if it doesn't exist.\n"
"Useful for scene management, and quickly selecting and removing all clutter when needed"),
description = ("Put all cutters in the same collection, and create one if it doesn't exist"),
default = True,
)
collection_name: bpy.props.StringProperty(
name = "Collection Name",
description = "Name of the collection where cutters will be added",
default = "boolean_cutters",
)
parent: bpy.props.BoolProperty(
name = "Parent Cutters to Object",
description = ("Cutters will be parented to first canvas they're applied to. Works best when one cutter is used one canvas.\n"
"NOTE: This doesn't affect Carver tool, which has its own property for this"),
description = ("Cutters will be parented to first canvas they're applied to"),
default = True,
)
apply_order: bpy.props.EnumProperty(
name = "When Applying Cutters...",
description = ("What happens when boolean cutters are applied on object.\n"
"Either when performing auto-boolean, using 'Apply All Cutters' operator.\n"
"NOTE: This doesn't apply to Carver tool on 'Destructive' mode; or when applying individual cutters"),
items = (('ALL', "Apply All Modifiers", "All modifiers on object will be applied (this includes shape keys as well)"),
('BEFORE', "Apply Booleans & Everything Before", "Alongside boolean modifiers all modifiers will be applied that come before the last boolean"),
('BOOLEANS', "Only Apply Booleans", "Only apply boolean modifiers. This method will fail if object has shape keys")),
name = "Apply Modifiers",
description = ("Which modifiers to apply when using add-ons destructive operators.\n"
"NOTE: This option is not used when applying individual cutters"),
items = (('ALL', "All",
"All modifiers on object (and shape keys) will be applied during destructive operators"),
('BEFORE', "Booleans & Everything Before",
"Apply all modifiers that come before the last Boolean modifier in the stack"),
('BOOLEANS', "Booleans",
"Only apply Boolean modifiers")),
default = 'ALL',
)
pin: bpy.props.BoolProperty(
name = "Pin Boolean Modifiers",
description = ("When enabled boolean modifiers will be placed above every other modifier on the object (if there are any).\n"
"Order of modifiers can drastically affect the result (especially when performing auto boolean).\n"
"NOTE: This doesn't affect Carver tool, which has its own property for this"),
description = ("Always make new Boolean modifiers first in the modifier stack.\n"
"NOTE: Order of modifiers can drastically affect the final result"),
default = False,
)
# Features
fast_modifier_apply: bpy.props.BoolProperty(
name = "Faster Destructive Booleans",
description = ("Experimental method of applying modifiers that results in 30-50% faster destructive booleans.\n"
"Performance improvements also affect the add-ons operators that apply cutters.\n"
description = ("Experimental method of applying modifiers that results in 30-50% faster destructive Booleans.\n"
"However, changing modifier properties in the redo panel (like material transfer)\n"
"is not available for this method yet."),
default = False,
)
double_click: bpy.props.BoolProperty(
name = "Double-click Select",
description = ("Select boolean cutters by dbl-clicking on the boolean modifier.\n"
"This feature works in entire modifier properties area, not just on boolean modifier header,\n"
"therefore can result in lot of misclicks and unintended selections."),
default = False,
)
# Debug
versioning: bpy.props.BoolProperty(
name = "Versioning",
description = ("Because of the drastic changes in add-on data, it's necessary to do versioning when loading old files\n"
"where Bool Tool cutters(brushes) are not applied. If you don't have files like that, you can ignore this")
)
experimental: bpy.props.BoolProperty(
name = "Experimental",
description = "Enable experimental features",
default = False,
)
def draw(self, context):
layout = self.layout
@@ -132,55 +120,57 @@ class BoolToolPreferences(bpy.types.AddonPreferences):
layout.use_property_decorate = False
# UI
col = layout.column(align=True, heading="Show in Sidebar")
row = col.row(align=True)
sub = row.row(align=True)
sub.prop(self, "show_in_sidebar", text="")
sub = sub.row(align=True)
sub.active = self.show_in_sidebar
sub.prop(self, "sidebar_category", text="")
# Defaults
row = layout.row()
row.prop(self, "category", expand=True)
layout.separator()
col = layout.column(align=True)
row = col.row(align=True)
row.prop(self, "solver", text="Solver", expand=True)
col.prop(self, "wireframe")
col.prop(self, "show_in_editmode")
# Advanced
layout.separator()
col = layout.column(align=True, heading="Put Cutters in Collection")
row = col.row(align=True)
sub = row.row(align=True)
sub.prop(self, "use_collection", text="")
sub = sub.row(align=True)
sub.active = self.show_in_sidebar
sub.prop(self, "collection_name", text="")
# Add-on Properties
if self.category == 'ADDON':
col = layout.column(align=True, heading="Show in Sidebar")
row = col.row(align=True)
sub = row.row(align=True)
sub.prop(self, "show_in_sidebar", text="")
sub = sub.row(align=True)
sub.active = self.show_in_sidebar
sub.prop(self, "sidebar_category", text="")
col.prop(self, "parent")
col.prop(self, "apply_order")
col.prop(self, "pin")
col.separator()
col = layout.column(align=True, heading="Features")
col.prop(self, "fast_modifier_apply")
# Features
layout.separator()
col = layout.column(align=True, heading="Features")
col.prop(self, "fast_modifier_apply")
col.prop(self, "double_click")
# Shared Properties
if self.category == 'SHARED':
col = layout.column()
col.prop(self, "show_in_editmode")
col.prop(self, "apply_order")
# Experimentals
layout.separator()
col = layout.column(align=True)
col.prop(self, "versioning", text="⚠ Versioning")
col.prop(self, "experimental", text="⚠ Experimental")
# Boolean Operator Properties
if self.category == 'OPERATORS':
col = layout.column(align=True, heading="Put Cutters in Collection")
row = col.row(align=True)
sub = row.row(align=True)
sub.prop(self, "use_collection", text="")
sub = sub.row(align=True)
sub.active = self.show_in_sidebar
sub.prop(self, "collection_name", text="", placeholder="boolean_cutters")
col = layout.column()
row = col.row(align=True)
row.prop(self, "solver", text="Solver", expand=True)
row = col.row(align=True)
row.prop(self, "display", expand=True)
col = layout.column()
col.prop(self, "parent")
col.prop(self, "pin")
#### ------------------------------ REGISTRATION ------------------------------ ####
classes = [
classes = (
BoolToolPreferences,
]
)
def register():
for cls in classes:
@@ -32,13 +32,15 @@ class OBJECT_PG_booleans(bpy.types.PropertyGroup):
default = False,
)
modifiers_list_index: bpy.props.IntProperty()
#### ------------------------------ REGISTRATION ------------------------------ ####
classes = [
classes = (
OBJECT_PG_booleans,
]
)
def register():
for cls in classes:
@@ -21,12 +21,12 @@ else:
#### ------------------------------ REGISTRATION ------------------------------ ####
"""NOTE: Order of modules is important because of dependancies. Don't change without a reason."""
modules = [
modules = (
carver_circle,
carver_box,
carver_polyline,
ui,
]
)
main_tools = [
carver_box.OBJECT_WT_carve_box,
@@ -1,13 +1,18 @@
import bpy
import os
from mathutils import Vector
from .. import __file__ as base_file
from ..constants import (
ICONS_PATH,
)
from ..functions.view import (
redraw_regions,
)
from .common.base import (
CarverBase,
)
from .common.properties import (
CarverPropsArray,
CarverPropsBevel,
)
from .common.types import (
@@ -34,7 +39,7 @@ 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", "tool_icons", "ops.object.carver_box")
bl_icon = os.path.join(ICONS_PATH, "dat", "ops.object.carver_box")
bl_keymap = (
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG'}, {"properties": None}),
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "shift": True}, {"properties": None}),
@@ -58,9 +63,7 @@ class MESH_WT_carve_box(OBJECT_WT_carve_box):
#### ------------------------------ OPERATORS ------------------------------ ####
class OBJECT_OT_carve_box(CarverBase,
CarverPropsArray,
CarverPropsBevel):
class OBJECT_OT_carve_box(CarverBase, CarverPropsBevel):
bl_idname = "object.carve_box"
bl_label = "Box Carve"
bl_description = description
@@ -70,6 +73,7 @@ class OBJECT_OT_carve_box(CarverBase,
# SHAPE-properties
shape = 'BOX'
# NOTE: There are registered on operator level because they need to be overriden or hidden per-tool.
aspect: bpy.props.EnumProperty(
name = "Aspect",
description = "The initial aspect",
@@ -115,8 +119,8 @@ class OBJECT_OT_carve_box(CarverBase,
# cached_variables
"""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._initial_origin = self.origin # Initial shape origin.
self._initial_aspect = self.aspect # Initial shape aspect.
self._stored_phase = "DRAW"
# Add Draw Handler
@@ -130,12 +134,11 @@ class OBJECT_OT_carve_box(CarverBase,
def modal(self, context, event):
redraw_regions(context)
# Status Bar Text
self.status(context)
# find_the_limit_of_the_3d_viewport_region
self.redraw_region(context)
# Modifier Keys
self.event_aspect(context, event)
self.event_origin(context, event)
@@ -151,7 +154,6 @@ class OBJECT_OT_carve_box(CarverBase,
if self.phase != "BEVEL":
return {'PASS_THROUGH'}
# Mouse Move
if event.type == 'MOUSEMOVE':
self.mouse.current = Vector((event.mouse_region_x, event.mouse_region_y))
@@ -164,7 +166,6 @@ class OBJECT_OT_carve_box(CarverBase,
elif self.phase == "EXTRUDE":
self.set_extrusion_depth(context)
# Confirm
elif event.type == 'LEFTMOUSE':
# Confirm Shape
@@ -178,7 +179,7 @@ class OBJECT_OT_carve_box(CarverBase,
min_distance = 5
if delta_x < min_distance or delta_y < min_distance:
self.finalize(context, clean_up=True, abort=True)
self.finalize(context, abort=True)
return {'FINISHED'}
self.extrude_cutter(context)
@@ -188,18 +189,15 @@ class OBJECT_OT_carve_box(CarverBase,
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.finalize(context, clean_up=True, abort=True)
self.finalize(context, abort=True)
return {'FINISHED'}
return {'RUNNING_MODAL'}
@@ -305,9 +303,9 @@ class OBJECT_OT_carve_box(CarverBase,
#### ------------------------------ REGISTRATION ------------------------------ ####
classes = [
classes = (
OBJECT_OT_carve_box,
]
)
def register():
for cls in classes:
@@ -1,6 +1,9 @@
import bpy
import os
from .. import __file__ as base_file
from ..constants import (
ICONS_PATH,
)
from .common.ui import (
carver_ui_common,
@@ -20,7 +23,7 @@ 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", "tool_icons", "ops.object.carver_circle")
bl_icon = os.path.join(ICONS_PATH, "dat", "ops.object.carver_circle")
bl_keymap = (
("object.carve_circle", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG'}, {"properties": None}),
("object.carve_circle", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "shift": True}, {"properties": None}),
@@ -51,6 +54,7 @@ class OBJECT_OT_carve_circle(OBJECT_OT_carve_box):
# SHAPE-properties
shape = 'CIRCLE'
# NOTE: There are registered on operator level because they need to be overriden or hidden per-tool.
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",
@@ -76,9 +80,9 @@ class OBJECT_OT_carve_circle(OBJECT_OT_carve_box):
#### ------------------------------ REGISTRATION ------------------------------ ####
classes = [
classes = (
OBJECT_OT_carve_circle,
]
)
def register():
for cls in classes:
@@ -1,16 +1,18 @@
import bpy
import math
import os
from mathutils import Vector
from bpy_extras import view3d_utils
from .. import __file__ as base_file
from ..constants import (
ICONS_PATH,
)
from ..functions.view import (
redraw_regions,
)
from .common.base import (
CarverBase,
)
from .common.properties import (
CarverPropsArray,
)
from .common.types import (
Selection,
Mouse,
@@ -35,7 +37,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", "tool_icons", "ops.object.carver_polyline")
bl_icon = os.path.join(ICONS_PATH, "dat", "ops.object.carver_polyline")
bl_keymap = (
("object.carve_polyline", {"type": 'LEFTMOUSE', "value": 'CLICK'}, None),
("object.carve_polyline", {"type": 'LEFTMOUSE', "value": 'CLICK', "ctrl": True}, None),
@@ -57,8 +59,7 @@ class MESH_WT_carve_polyline(OBJECT_WT_carve_polyline):
#### ------------------------------ OPERATORS ------------------------------ ####
class OBJECT_OT_carve_polyline(CarverBase,
CarverPropsArray):
class OBJECT_OT_carve_polyline(CarverBase):
bl_idname = "object.carve_polyline"
bl_label = "Polyline Carve"
bl_description = description
@@ -108,12 +109,11 @@ class OBJECT_OT_carve_polyline(CarverBase,
def modal(self, context, event):
redraw_regions(context)
# Status Bar Text
self.status(context)
# find_the_limit_of_the_3d_viewport_region
self.redraw_region(context)
# Modifier Keys
self.event_array(context, event)
self.event_move(context, event)
@@ -124,7 +124,6 @@ class OBJECT_OT_carve_polyline(CarverBase,
if self.phase != "BEVEL":
return {'PASS_THROUGH'}
# Mouse Move
if event.type == 'MOUSEMOVE':
self.mouse.current = Vector((event.mouse_region_x, event.mouse_region_y))
@@ -146,12 +145,15 @@ class OBJECT_OT_carve_polyline(CarverBase,
elif self.phase == "EXTRUDE":
self.set_extrusion_depth(context)
# Add Points & Confirm
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:
# Add Point
if self._distance_from_first < 75:
self._insert_polyline_point()
# Confirm Shape (if clicked near the first vert).
else:
verts = self.cutter.verts
if len(verts) > 3:
self._remove_polyline_point(context, jump_mouse=False)
@@ -165,17 +167,12 @@ class OBJECT_OT_carve_polyline(CarverBase,
else:
return {'RUNNING_MODAL'}
# Add Point
else:
self._insert_polyline_point()
# Confirm Depth
if self.phase == "EXTRUDE":
self.confirm(context)
return {'FINISHED'}
# Confirm
# Enter (Confirm)
elif event.type == 'RET':
verts = self.cutter.verts
if len(verts) > 2:
@@ -195,18 +192,14 @@ class OBJECT_OT_carve_polyline(CarverBase,
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':
self._remove_polyline_point(context)
# Cancel
elif event.type in {'RIGHTMOUSE', 'ESC'}:
self.finalize(context, clean_up=True, abort=True)
self.finalize(context, abort=True)
return {'FINISHED'}
return {'RUNNING_MODAL'}
@@ -301,7 +294,7 @@ class OBJECT_OT_carve_polyline(CarverBase,
context.workspace.status_text_set(modal_keys_extrude)
# Polyline-specific features.
# Polyline-specific methods.
def _insert_polyline_point(self):
"""Inserts a new vertex in the cutter geometry and connects it to the previous last one."""
@@ -383,15 +376,16 @@ class OBJECT_OT_carve_polyline(CarverBase,
context.region_data,
vert_world)
if screen_pos:
context.window.cursor_warp(int(screen_pos.x), int(screen_pos.y))
x, y = int(screen_pos.x), int(screen_pos.y)
context.window.cursor_warp(x, y)
#### ------------------------------ REGISTRATION ------------------------------ ####
classes = [
classes = (
OBJECT_OT_carve_polyline,
]
)
def register():
for cls in classes:
@@ -1,33 +1,22 @@
import bpy
import bmesh
import math
import mathutils
from bpy_extras import view3d_utils
from mathutils import Vector, Matrix
from .types import (
Effects,
from ...functions.cutter import (
make_cutter,
)
from .properties import (
CarverPropsOperator,
CarverPropsShape,
CarverPropsModifier,
CarverPropsCutter,
)
from ...functions.draw import (
draw_shader,
draw_bmesh_faces,
draw_circle_around_point,
)
from ...functions.math import (
distance_from_point_to_segment,
region_2d_to_plane_3d,
region_2d_to_line_3d,
draw_circle_billboard,
)
from ...functions.mesh import (
is_instanced_mesh,
extrude_face,
are_intersecting,
raycast,
)
from ...functions.modifier import (
add_boolean_modifier,
@@ -35,300 +24,44 @@ from ...functions.modifier import (
get_modifiers_to_apply,
)
from ...functions.object import (
set_cutter_properties,
delete_empty_collection,
delete_cutter,
set_object_origin,
)
from ...functions.poll import (
is_linked,
is_instanced_data,
set_object_origin,
delete_object,
)
from ...functions.scene import (
delete_empty_collection,
raycast,
)
from ...functions.view import (
distance_from_point_to_segment,
region_2d_to_ray_3d,
region_2d_to_plane_3d,
)
from .events import (
CarverEvents,
)
from .properties import (
CarverPropsOperator,
CarverPropsShape,
CarverPropsModifier,
CarverPropsCutter,
CarverPropsArray,
)
#### ------------------------------ /base/ ------------------------------ ####
class CarverEvents():
def _custom_modifier_event(self, context, event, modifier,
cursor='NONE', store_values=False, restore_mouse=True,
postprocess=None):
"""Creates custom modifier event when key is held and hides cursor until it's released"""
# Initialize Modifier Phase
if event.value == 'PRESS':
if self.phase in ("DRAW", "EXTRUDE"):
self._stored_phase = self.phase
self.phase = modifier
self.mouse.cached = self.mouse.current
context.window.cursor_set(cursor)
if store_values:
# Store center of the geometry as a Vector.
verts = [v.co for v in self.cutter.bm.verts]
center = sum(verts, Vector()) / len(verts)
self.cutter.center = self.cutter.obj.matrix_world @ center
# End Modifier Phase
elif event.value == 'RELEASE':
if self.phase == modifier:
context.window.cursor_set('MUTE')
if restore_mouse:
context.window.cursor_warp(int(self.mouse.cached[0]), int(self.mouse.cached[1]) + 100)
self.mouse.current = self.mouse.cached
self.phase = self._stored_phase
if postprocess is not None:
postprocess(self)
return self._stored_phase
# Individual Events
def event_aspect(self, context, event):
"""Modifier keys for changing aspect of the shape"""
if event.shift and self.phase == "DRAW":
if self.initial_aspect == 'FREE':
self.aspect = 'FIXED'
elif self.initial_aspect == 'FIXED':
self.aspect = 'FREE'
else:
self.aspect = self.initial_aspect
def event_origin(self, context, event):
"""Modifier keys for changing the origin of the shape"""
if event.alt and self.phase == "DRAW":
if self.initial_origin == 'EDGE':
self.origin = 'CENTER'
elif self.initial_origin == 'CENTER':
self.origin = 'EDGE'
else:
self.origin = self.initial_origin
def event_rotate(self, context, event):
"""Modifier keys for rotating the shape"""
def _remove_rotate_phase_properties(self):
del self._stored_mouse_pos_3d
del self._stored_rotation
del self._stored_cutter_center
del self._stored_cutter_euler
# Restore origin at edge (first vertex).
if self.origin == 'EDGE':
point = self.cutter.bm.verts[0].co
set_object_origin(self.cutter.obj, self.cutter.bm, point='CUSTOM', custom=point)
# Set correct phase.
if event.type == 'R':
stored_phase = self._custom_modifier_event(context, event, "ROTATE",
cursor='MOVE_X', store_values=True, restore_mouse=False,
postprocess=_remove_rotate_phase_properties)
if self.phase == "ROTATE":
region = context.region
rv3d = context.region_data
# Project current mouse position onto the workplane.
current_mouse_pos_3d = region_2d_to_plane_3d(region, rv3d,
self.mouse.current,
(self.workplane.location, self.workplane.normal))
if current_mouse_pos_3d is not None:
# Store values.
obj = self.cutter.obj
if not hasattr(self, '_stored_mouse_pos_3d'):
self._stored_mouse_pos_3d = current_mouse_pos_3d.copy()
self._stored_rotation = self.rotation
self._stored_cutter_center = self.cutter.center
self._stored_cutter_euler = obj.rotation_euler.copy()
# Calculate angle and direction.
to_start = (self._stored_mouse_pos_3d - self._stored_cutter_center).normalized()
to_current = (current_mouse_pos_3d - self._stored_cutter_center).normalized()
angle = to_start.angle(to_current)
cross = to_start.cross(to_current)
if cross.dot(self.workplane.normal) < 0:
angle = -angle
if abs(angle) > 0.0001:
self.rotation = self._stored_rotation + angle
# Offset the object location when drawing from edge to move rotation pivot to center.
if self.origin == 'EDGE':
set_object_origin(obj, self.cutter.bm, point='CENTER_OBJ')
# Calculate rotation amount.
rotation_total = Matrix.Rotation(self.rotation, 4, self.workplane.normal)
rotation_stored = Matrix.Rotation(self._stored_rotation, 4, self.workplane.normal)
rotation_matrix = rotation_total @ rotation_stored.inverted()
new_rot = rotation_matrix @ self._stored_cutter_euler.to_matrix().to_4x4()
# Rotate.
obj.rotation_euler = new_rot.to_euler()
def event_bevel(self, context, event):
"""Modifier keys for beveling the shape"""
def _remove_empty_bevel_modifier(self):
bevel = self.effects.bevel
if bevel.width == 0:
self.cutter.obj.modifiers.remove(bevel)
self.effects.bevel = None
if self.effects.weld is not None:
self.cutter.obj.modifiers.remove(self.effects.weld)
self.effects.weld = None
if self.shape != 'BOX':
return
# Set correct phase.
if event.type == 'B':
stored_phase = self._custom_modifier_event(context, event, "BEVEL",
cursor='PICK_AREA', store_values=True,
postprocess=_remove_empty_bevel_modifier)
if self.phase == "BEVEL":
self.use_bevel = True
# Initialize bevel effect if it doesn't exist.
if self.effects.bevel is None:
self.bevel_width = 0
affect = 'VERTICES' if stored_phase == "DRAW" else 'EDGES'
self.effects.add_bevel_modifier(self, affect=affect)
# Force the geometry to update.
if stored_phase == "DRAW":
self.update_cutter_shape(context)
elif stored_phase == "EXTRUDE":
self.set_extrusion_depth(context)
# Calculate bevel width.
region = context.region
rv3d = context.region_data
self.mouse.cached_3d = view3d_utils.region_2d_to_location_3d(region, rv3d, self.mouse.cached, self.cutter.center)
self.mouse.current_3d = view3d_utils.region_2d_to_location_3d(region, rv3d, self.mouse.current, self.cutter.center)
d = (self.cutter.center - self.mouse.current_3d).length - (self.cutter.center - self.mouse.cached_3d).length
self.bevel_width = d * 0.2
# Adjust bevel segments.
if event.type == 'WHEELUPMOUSE':
self.bevel_segments += 1
elif event.type == 'WHEELDOWNMOUSE':
self.bevel_segments -= 1
# Update modifier.
self.effects.update(self, 'BEVEL')
def event_array(self, context, event):
"""Modifier keys for creating the array of the shape"""
# Add duplicates.
if event.type == 'LEFT_ARROW' and event.value == 'PRESS':
self.columns -= 1
if event.type == 'RIGHT_ARROW' and event.value == 'PRESS':
self.columns += 1
if event.type == 'DOWN_ARROW' and event.value == 'PRESS':
self.rows -= 1
if event.type == 'UP_ARROW' and event.value == 'PRESS':
self.rows += 1
if event.type in ['LEFT_ARROW',
'RIGHT_ARROW',
'DOWN_ARROW',
'UP_ARROW',] and event.value == 'PRESS':
self.effects.update(self, 'ARRAY_COUNT')
# Force the geometry to update.
if self.phase == "DRAW":
self.update_cutter_shape(context)
elif self.phase == "EXTRUDE":
self.set_extrusion_depth(context)
# Adjust gap.
if (self.rows > 1 or self.columns > 1) and (event.type == 'A'):
stored_phase = self._custom_modifier_event(context, event, "ARRAY", cursor='MUTE',
store_values=True, restore_mouse=True)
if self.phase == "ARRAY":
region = context.region
rv3d = context.region_data
self.mouse.cached_3d = view3d_utils.region_2d_to_location_3d(region, rv3d, self.mouse.cached, self.cutter.center)
self.mouse.current_3d = view3d_utils.region_2d_to_location_3d(region, rv3d, self.mouse.current, self.cutter.center)
d = (self.cutter.center - self.mouse.current_3d).length - (self.cutter.center - self.mouse.cached_3d).length
self.gap = 1 + (d * 0.2)
self.effects.update(self, 'ARRAY_GAP')
def event_flip(self, context, event):
"""Modifier keys for flipping the direction of extrusion."""
if event.type == 'F' and event.value == 'PRESS':
if self.phase == 'EXTRUDE':
self.flip_direction = not self.flip_direction
def event_move(self, context, event):
"""Modifier keys for moving the cutter shape."""
def _remove_move_phase_properties(self):
del self._stored_cutter_location
self.mouse.cached_3d = None
if event.type == 'SPACE':
stored_phase = self._custom_modifier_event(context, event, "MOVE",
cursor='SCROLL_XY', restore_mouse=False,
postprocess=_remove_move_phase_properties)
if self.phase == "MOVE":
region = context.region
rv3d = context.region_data
# Project current mouse position onto the workplane.
current_mouse_pos_3d = region_2d_to_plane_3d(region, rv3d,
self.mouse.current,
(self.workplane.location, self.workplane.normal))
if current_mouse_pos_3d is not None:
if not hasattr(self, '_stored_cutter_location'):
self.mouse.cached_3d = current_mouse_pos_3d.copy()
self._stored_cutter_location = self.cutter.obj.location.copy()
offset = current_mouse_pos_3d - self.mouse.cached_3d
self.cutter.obj.location = self._stored_cutter_location + offset
#### ------------------------------ CLASSES ------------------------------ ####
class CarverBase(bpy.types.Operator,
CarverEvents,
CarverPropsOperator,
CarverPropsShape,
CarverPropsModifier,
CarverPropsCutter):
CarverPropsCutter,
CarverPropsArray):
"""Base class for Carver operators."""
def redraw_region(self, context):
"""Redraw the region to find the limits of the 3D viewport."""
region_types = {'WINDOW', 'UI'}
for area in context.window.screen.areas:
if area.type == 'VIEW_3D':
for region in area.regions:
if not region_types or region.type in region_types:
region.tag_redraw()
# Core Methods
def validate_selection(self, context):
"""Filter out selection to get the list of viable canvases."""
@@ -349,10 +82,14 @@ class CarverBase(bpy.types.Operator,
continue
if self.mode == 'DESTRUCTIVE':
if is_instanced_data(obj):
if is_instanced_mesh(obj.data):
self.report({'WARNING'}, f"Modifiers cannot be applied to {obj.name} because it has instanced object data")
continue
if obj.data.shape_keys:
self.report({'WARNING'}, f"Modifiers cannot be applied to {obj.name} because it has shape keys")
continue
selected.append(obj)
# Ensure the active object.
@@ -367,11 +104,10 @@ class CarverBase(bpy.types.Operator,
return selected, active
# Core Methods
def calculate_workplane(self, context):
"""
Calculates matrix, location (origin point), and normal (direction)
of the workplane based on the active alignment method.
of the workplane based on the chosen alignment method.
"""
if self.alignment == 'SURFACE':
@@ -393,7 +129,7 @@ class CarverBase(bpy.types.Operator,
def create_cutter(self, context):
"""Creates a cutter object with correct properties & initializes `bmesh` geometry."""
"""Creates a cutter object with correct properties & initializes `bmesh`."""
# Create the Mesh & bmesh
mesh = bpy.data.meshes.new(name="boolean_cutter")
@@ -402,15 +138,15 @@ class CarverBase(bpy.types.Operator,
# Create the Object
obj = bpy.data.objects.new("boolean_cutter", mesh)
obj.matrix_world = self.workplane.matrix
set_cutter_properties(context, obj, "Difference", collection=True,
display='WIRE' if self.shape == 'POLYLINE' else self.display)
make_cutter(context, obj, "Difference", collection=True,
display='WIRE' if self.shape == 'POLYLINE' else self.display)
obj.booleans.carver = True
# Initial Rotation
if self.rotation != 0:
rotation_matrix = Matrix.Rotation(self.rotation, 4, self.workplane.normal)
temp_matrix_world = rotation_matrix @ obj.matrix_world
obj.rotation_euler = temp_matrix_world.to_euler()
rotation_matrix = rotation_matrix @ obj.matrix_world
obj.rotation_euler = rotation_matrix.to_euler()
# Create Verts
if self.shape == 'BOX':
@@ -471,18 +207,21 @@ class CarverBase(bpy.types.Operator,
# Draw Line
if self.phase in ("BEVEL", "ROTATE", "ARRAY"):
current_mouse_pos_3d = region_2d_to_plane_3d(context.region, context.region_data,
self.mouse.current,
(self.workplane.location, self.workplane.normal))
self.mouse.current,
(self.workplane.location, self.workplane.normal))
if current_mouse_pos_3d is not None:
vertices = [self.cutter.center, current_mouse_pos_3d]
if vertices is not None:
draw_shader('LINES', (0.00, 0.00, 0.00), 1.0, vertices)
# Draw Circle around First Vertex
# Draw circle around first vertex.
if self.shape == 'POLYLINE' and self.phase == 'DRAW':
verts = self.cutter.verts
if len(verts) > 3:
vertices = draw_circle_around_point(context, obj, verts[0], self._distance_from_first, 4)
vertices = draw_circle_billboard(context,
obj.matrix_world @ verts[0].co,
radius=self._distance_from_first,
segments=4)
if len(vertices) > 0:
draw_shader('LINE_LOOP', (0, 0, 0), 1.0, vertices)
@@ -497,7 +236,7 @@ class CarverBase(bpy.types.Operator,
bm = self.cutter.bm
face = self.cutter.faces[0]
# Get the mouse positon on the workplane.
# Get the mouse positon x, y on the workplane.
current_mouse_pos_3d = region_2d_to_plane_3d(region, rv3d,
self.mouse.current,
(self.workplane.location, self.workplane.normal))
@@ -582,7 +321,6 @@ class CarverBase(bpy.types.Operator,
if self.alignment == 'CURSOR' and self.depth == 'CURSOR':
self.depth = 'AUTO'
# Push the extruded face towards the furthest point of the collective bounding box.
if self.depth == 'AUTO':
corners = []
@@ -599,10 +337,9 @@ class CarverBase(bpy.types.Operator,
offset = self.offset if self.alignment == 'SURFACE' else 0.1
for v in face.verts:
if self.depth == 'AUTO':
v.co += normal * (furthest_corner - offset)
v.co += normal * (furthest_corner - offset)
# Push the extruded face towards the plane of 3D cursor.
# Push the extruded face towards the plane of the 3D cursor.
elif self.depth == 'CURSOR':
local_cursor = self.cutter.obj.matrix_world.inverted() @ context.scene.cursor.location
for v in extruded_verts:
@@ -647,20 +384,24 @@ class CarverBase(bpy.types.Operator,
region = context.region
rv3d = context.region_data
normal = self.cutter.obj.matrix_world.to_3x3().inverted() @ self.workplane.normal
# Find the point on 3D line (along the normal) closest to the cursor.
# and calculate the distance between that and the extrude origin.
closest_points = region_2d_to_line_3d(region, rv3d,
self.mouse.current,
self._extrude_origin, self.workplane.normal)
# Convert the mouse position into a 3D ray and find closest points between...
# that ray and the line coming from the extrude origin along the workplane normal.
ray_origin, ray_direction = region_2d_to_ray_3d(region, rv3d, self.mouse.current)
closest_points = mathutils.geometry.intersect_line_line(ray_origin,
ray_origin + ray_direction,
self._extrude_origin,
self._extrude_origin + self.workplane.normal)
if closest_points is None:
return
# Calculate the distance between the extrude origin and mouse position.
offset_vector = closest_points[1] - self._extrude_origin
distance = offset_vector.dot(self.workplane.normal)
# Offset vertices of the extruded face from their their original coordinates.
normal = self.cutter.obj.matrix_world.to_3x3().inverted() @ self.workplane.normal
if distance is not None:
for v, vert_co in zip(self._extrude_faces[-1].verts, self._extrude_verts_co):
offset = normal * distance
@@ -695,68 +436,71 @@ class CarverBase(bpy.types.Operator,
ray_obj_matrix = ray.obj.matrix_world
mesh = ray.obj.evaluated_get(context.view_layer.depsgraph).to_mesh()
if mesh is not None:
# create_temporary_bmesh
temp_bm = bmesh.new()
temp_bm.from_mesh(mesh)
temp_bm.faces.ensure_lookup_table()
if mesh is None:
self.alignment = 'VIEW'
return None, None, None
face = temp_bm.faces[ray.index]
# Create temporary `bmesh`.
temp_bm = bmesh.new()
temp_bm.from_mesh(mesh)
temp_bm.faces.ensure_lookup_table()
if self.orientation == 'FACE':
# Get the tangent, normal, and bitangent from the face normal.
tangent = face.calc_tangent_edge()
normal = face.normal
bitangent = normal.cross(tangent)
face = temp_bm.faces[ray.index]
elif self.orientation in ('CLOSEST_EDGE', 'LONGEST_EDGE'):
# Get the tangent, normal, and bitangent from the longest or the closest edge of the face.
if self.orientation == 'FACE':
# Get the tangent, normal, and bitangent from the face normal.
tangent = face.calc_tangent_edge()
normal = face.normal
bitangent = normal.cross(tangent)
if self.orientation == 'LONGEST_EDGE':
lengths = [
(ray_obj_matrix @ edge.verts[0].co - ray_obj_matrix @ edge.verts[1].co).length
for edge in face.edges
]
longest_edge = sorted(zip(lengths, face.edges), key=lambda x: x[0], reverse=True)[0][1]
edge = longest_edge
elif self.orientation in ('CLOSEST_EDGE', 'LONGEST_EDGE'):
# Get the tangent, normal, and bitangent from the longest or the closest edge of the face.
elif self.orientation == 'CLOSEST_EDGE':
distances = [
distance_from_point_to_segment(
ray.location,
ray_obj_matrix @ edge.verts[0].co,
ray_obj_matrix @ edge.verts[1].co,
)
for edge in face.edges
]
closest_edge = sorted(zip(distances, face.edges), key=lambda x: x[0])[0][1]
edge = closest_edge
if self.orientation == 'LONGEST_EDGE':
lengths = [
(ray_obj_matrix @ edge.verts[0].co - ray_obj_matrix @ edge.verts[1].co).length
for edge in face.edges
]
longest_edge = sorted(zip(lengths, face.edges), key=lambda x: x[0], reverse=True)[0][1]
edge = longest_edge
# Get the loop (face corner) for the edge that is also in the face.
face_corner = next(loop for loop in edge.link_loops if loop.face == face)
elif self.orientation == 'CLOSEST_EDGE':
distances = [
distance_from_point_to_segment(
ray.location,
ray_obj_matrix @ edge.verts[0].co,
ray_obj_matrix @ edge.verts[1].co,
)
for edge in face.edges
]
closest_edge = sorted(zip(distances, face.edges), key=lambda x: x[0])[0][1]
edge = closest_edge
start = face_corner.vert
end = face_corner.link_loop_next.vert
direction = (end.co - start.co)
# Get the loop (face corner) for the edge that is also in the face.
face_corner = next(loop for loop in edge.link_loops if loop.face == face)
tangent = edge.calc_tangent(face_corner)
normal = direction.cross(tangent)
bitangent = normal.cross(tangent)
start = face_corner.vert
end = face_corner.link_loop_next.vert
direction = (end.co - start.co)
# Construct Matrix
matrix = Matrix.Identity(4)
matrix[0].xyz = (ray_obj_matrix.to_3x3() @ tangent).normalized()
matrix[1].xyz = (ray_obj_matrix.to_3x3() @ bitangent).normalized()
matrix[2].xyz = (ray_obj_matrix.to_3x3() @ normal).normalized()
matrix[3].xyz = ray.location + (ray.normal * self.offset)
tangent = edge.calc_tangent(face_corner)
normal = direction.cross(tangent)
bitangent = normal.cross(tangent)
# destroy_temporary_bmesh
temp_bm.free()
del mesh
# Construct Matrix
matrix = Matrix.Identity(4)
matrix[0].xyz = (ray_obj_matrix.to_3x3() @ tangent).normalized()
matrix[1].xyz = (ray_obj_matrix.to_3x3() @ bitangent).normalized()
matrix[2].xyz = (ray_obj_matrix.to_3x3() @ normal).normalized()
matrix[3].xyz = ray.location + (ray.normal * self.offset)
matrix = matrix.transposed()
location = ray.location + (ray.normal * self.offset)
normal = ray.normal
# destroy_temporary_bmesh
temp_bm.free()
del mesh
matrix = matrix.transposed()
location = ray.location + (ray.normal * self.offset)
normal = ray.normal
return matrix, location, normal
@@ -776,8 +520,7 @@ class CarverBase(bpy.types.Operator,
context.scene.cursor.location)
else:
# Put the location at the closest point of the bounding box of all selected objects.
ray_origin = view3d_utils.region_2d_to_origin_3d(region, rv3d, self.mouse.initial)
ray_direction = view3d_utils.region_2d_to_vector_3d(region, rv3d, self.mouse.initial)
ray_origin, ray_direction = region_2d_to_ray_3d(region, rv3d, self.mouse.initial)
corners = []
for obj in self.objects.selected:
@@ -803,8 +546,7 @@ class CarverBase(bpy.types.Operator,
matrix = Matrix.Identity(4)
normal = matrix.col[2].xyz
ray_origin = view3d_utils.region_2d_to_origin_3d(region, rv3d, self.mouse.initial)
ray_direction = view3d_utils.region_2d_to_vector_3d(region, rv3d, self.mouse.initial)
ray_origin, ray_direction = region_2d_to_ray_3d(region, rv3d, self.mouse.initial)
t = (matrix.translation.z - ray_origin.z) / ray_direction.z
location = ray_origin + ray_direction * t
matrix.translation = location
@@ -851,7 +593,6 @@ class CarverBase(bpy.types.Operator,
cutter = self.cutter.obj
# Add Modifier(s)
for obj in self.objects.selected:
mod = add_boolean_modifier(self, context, obj,
cutter, "DIFFERENCE",
@@ -877,7 +618,7 @@ class CarverBase(bpy.types.Operator,
obj.modifiers.remove(mod)
if not intersecting_canvases:
self.finalize(context, clean_up=True)
self.finalize(context)
return
# Select all faces of the cutter so that newly created faces in canvas
@@ -920,7 +661,7 @@ class CarverBase(bpy.types.Operator,
for obj in intersecting_canvases:
obj.booleans.canvas = True
self.finalize(context)
self.finalize(context, clean_up=False)
return
elif self.mode == 'DESTRUCTIVE':
@@ -930,11 +671,11 @@ class CarverBase(bpy.types.Operator,
modifiers = get_modifiers_to_apply(context, obj, [modifiers])
apply_modifiers(context, obj, modifiers, force_clean=True)
self.finalize(context, clean_up=True)
self.finalize(context)
return
def finalize(self, context, clean_up=False, abort=False):
def finalize(self, context, clean_up=True, abort=False):
"""
Finalize and clean-up after the operation ends.
Regardless of whether it was confirmed or cancelled.
@@ -943,15 +684,19 @@ class CarverBase(bpy.types.Operator,
# Operation was aborted, or successfully finished in the Destructive mode.
# Delete everything created by the operator (i.e. cutter).
if clean_up:
delete_cutter(self.cutter.obj)
delete_object(self.cutter.obj)
self.cutter.bm.free()
delete_empty_collection()
delete_empty_collection(context)
# Remove modifiers added by the operator.
if abort:
for obj, mod in self.objects.modifiers.items():
obj.modifiers.remove(mod)
# Clean-up temporary changes made by operator.
if self.effects.array:
self.effects.array.use_pin_to_last = False
bpy.types.SpaceView3D.draw_handler_remove(self._handler, 'WINDOW')
context.workspace.status_text_set(None)
context.window.cursor_set('DEFAULT' if context.mode == 'OBJECT' else 'CROSSHAIR')
@@ -0,0 +1,285 @@
import bpy
from bpy_extras import view3d_utils
from mathutils import Vector, Matrix
from ...functions.object import (
set_object_origin,
)
from ...functions.view import (
region_2d_to_plane_3d,
)
#### ------------------------------ CLASSES ------------------------------ ####
class CarverEvents():
# Private Methods.
def _custom_modifier_event(self, context, event, modifier,
cursor='NONE', store_values=False, restore_mouse=True,
postprocess=None):
"""Creates a custom modifier event when the key is held down."""
# Initialize Modifier Phase
if event.value == 'PRESS':
if self.phase in ("DRAW", "EXTRUDE"):
self._stored_phase = self.phase
self.phase = modifier
self.mouse.cached = self.mouse.current
context.window.cursor_set(cursor)
if store_values:
# Store center of the geometry as a Vector.
verts = [v.co for v in self.cutter.bm.verts]
center = sum(verts, Vector()) / len(verts)
self.cutter.center = self.cutter.obj.matrix_world @ center
# End Modifier Phase
elif event.value == 'RELEASE':
if self.phase == modifier:
context.window.cursor_set('MUTE')
if restore_mouse:
context.window.cursor_warp(int(self.mouse.cached[0]), int(self.mouse.cached[1]) + 100)
self.mouse.current = self.mouse.cached
self.phase = self._stored_phase
if postprocess is not None:
postprocess(self)
return self._stored_phase
# Public Methods.
def event_aspect(self, context, event):
"""Modifier key for changing the aspect of the shape."""
if event.shift and self.phase == "DRAW":
if self._initial_aspect == 'FREE':
self.aspect = 'FIXED'
elif self._initial_aspect == 'FIXED':
self.aspect = 'FREE'
else:
self.aspect = self._initial_aspect
def event_origin(self, context, event):
"""Modifier key for changing the origin of the shape."""
if event.alt and self.phase == "DRAW":
if self._initial_origin == 'EDGE':
self.origin = 'CENTER'
elif self._initial_origin == 'CENTER':
self.origin = 'EDGE'
else:
self.origin = self._initial_origin
def event_rotate(self, context, event):
"""Modifier key for rotating the shape."""
def _remove_rotate_phase_properties(self):
del self._stored_mouse_pos_3d
del self._stored_rotation
del self._stored_cutter_center
del self._stored_cutter_euler
# Restore origin at edge (first vertex).
if self.origin == 'EDGE':
self.cutter.bm.verts.ensure_lookup_table()
point = self.cutter.bm.verts[0].co
set_object_origin(self.cutter.obj, self.cutter.bm, point='CUSTOM', custom=point)
# Set correct phase.
if event.type == 'R':
_rm = False if self._stored_phase == "DRAW" else True
stored_phase = self._custom_modifier_event(context, event, "ROTATE",
cursor='MOVE_X', store_values=True, restore_mouse=_rm,
postprocess=_remove_rotate_phase_properties)
if self.phase == "ROTATE":
region = context.region
rv3d = context.region_data
# Project current mouse position onto the workplane.
current_mouse_pos_3d = region_2d_to_plane_3d(region, rv3d,
self.mouse.current,
(self.workplane.location, self.workplane.normal))
if current_mouse_pos_3d is not None:
# Store values.
obj = self.cutter.obj
if not hasattr(self, '_stored_mouse_pos_3d'):
self._stored_mouse_pos_3d = current_mouse_pos_3d.copy()
self._stored_rotation = self.rotation
self._stored_cutter_center = self.cutter.center
self._stored_cutter_euler = obj.rotation_euler.copy()
# Calculate angle and direction.
to_start = (self._stored_mouse_pos_3d - self._stored_cutter_center).normalized()
to_current = (current_mouse_pos_3d - self._stored_cutter_center).normalized()
angle = to_start.angle(to_current)
cross = to_start.cross(to_current)
if cross.dot(self.workplane.normal) < 0:
angle = -angle
if abs(angle) > 0.0001:
self.rotation = self._stored_rotation + angle
# Offset the object location when drawing from edge to move rotation pivot to center.
if self.origin == 'EDGE':
set_object_origin(obj, self.cutter.bm, point='CENTER_OBJ')
# Calculate rotation amount.
rotation_total = Matrix.Rotation(self.rotation, 4, self.workplane.normal)
rotation_stored = Matrix.Rotation(self._stored_rotation, 4, self.workplane.normal)
rotation_matrix = rotation_total @ rotation_stored.inverted()
new_rot = rotation_matrix @ self._stored_cutter_euler.to_matrix().to_4x4()
# Rotate.
obj.rotation_euler = new_rot.to_euler()
def event_bevel(self, context, event):
"""Modifier key for beveling the shape."""
def _remove_empty_bevel_modifier(self):
bevel = self.effects.bevel
if bevel.width == 0:
self.cutter.obj.modifiers.remove(bevel)
self.effects.bevel = None
if self.effects.weld is not None:
self.cutter.obj.modifiers.remove(self.effects.weld)
self.effects.weld = None
if self.shape != 'BOX':
return
# Set correct phase.
if event.type == 'B':
stored_phase = self._custom_modifier_event(context, event, "BEVEL",
cursor='PICK_AREA', store_values=True,
postprocess=_remove_empty_bevel_modifier)
if self.phase == "BEVEL":
self.use_bevel = True
# Initialize bevel effect if it doesn't exist.
if self.effects.bevel is None:
self.bevel_width = 0
affect = 'VERTICES' if stored_phase == "DRAW" else 'EDGES'
self.effects.add_bevel_modifier(self, affect=affect)
# Force the geometry to update.
if stored_phase == "DRAW":
self.update_cutter_shape(context)
elif stored_phase == "EXTRUDE":
self.set_extrusion_depth(context)
# Calculate bevel width.
region = context.region
rv3d = context.region_data
self.mouse.cached_3d = view3d_utils.region_2d_to_location_3d(region, rv3d,
self.mouse.cached,
self.cutter.center)
self.mouse.current_3d = view3d_utils.region_2d_to_location_3d(region, rv3d,
self.mouse.current,
self.cutter.center)
d = (self.cutter.center - self.mouse.current_3d).length - (self.cutter.center - self.mouse.cached_3d).length
self.bevel_width = d * 0.2
# Adjust bevel segments.
if event.type == 'WHEELUPMOUSE':
self.bevel_segments += 1
elif event.type == 'WHEELDOWNMOUSE':
self.bevel_segments -= 1
# Update modifier.
self.effects.update(self, "BEVEL")
def event_array(self, context, event):
"""Modifier key for arraying the shape."""
# Add duplicates.
if event.type == 'LEFT_ARROW' and event.value == 'PRESS':
self.columns -= 1
if event.type == 'RIGHT_ARROW' and event.value == 'PRESS':
self.columns += 1
if event.type == 'DOWN_ARROW' and event.value == 'PRESS':
self.rows -= 1
if event.type == 'UP_ARROW' and event.value == 'PRESS':
self.rows += 1
if event.type in ['LEFT_ARROW',
'RIGHT_ARROW',
'DOWN_ARROW',
'UP_ARROW',] and event.value == 'PRESS':
self.effects.update(self, "ARRAY_COUNT")
# Force the geometry to update.
if self.phase == "DRAW":
self.update_cutter_shape(context)
elif self.phase == "EXTRUDE":
self.set_extrusion_depth(context)
# Adjust gap.
if (self.rows > 1 or self.columns > 1) and (event.type == 'A'):
stored_phase = self._custom_modifier_event(context, event, "ARRAY",
cursor='MUTE', store_values=True)
if self.phase == "ARRAY":
region = context.region
rv3d = context.region_data
self.mouse.cached_3d = view3d_utils.region_2d_to_location_3d(region, rv3d,
self.mouse.cached,
self.cutter.center)
self.mouse.current_3d = view3d_utils.region_2d_to_location_3d(region, rv3d,
self.mouse.current,
self.cutter.center)
d = (self.cutter.center - self.mouse.current_3d).length - (self.cutter.center - self.mouse.cached_3d).length
self.gap = 1 + (d * 0.2)
self.effects.update(self, "ARRAY_GAP")
def event_flip(self, context, event):
"""Modifier key for flipping the direction of extrusion."""
if event.type == 'F' and event.value == 'PRESS':
if self.phase == 'EXTRUDE':
self.flip_direction = not self.flip_direction
def event_move(self, context, event):
"""Modifier key for moving the shape."""
def _remove_move_phase_properties(self):
del self._stored_cutter_location
self.mouse.cached_3d = None
if event.type == 'SPACE':
stored_phase = self._custom_modifier_event(context, event, "MOVE",
cursor='SCROLL_XY', restore_mouse=False,
postprocess=_remove_move_phase_properties)
if self.phase == "MOVE":
region = context.region
rv3d = context.region_data
# Project current mouse position onto the workplane.
current_mouse_pos_3d = region_2d_to_plane_3d(region, rv3d,
self.mouse.current,
(self.workplane.location, self.workplane.normal))
if current_mouse_pos_3d is not None:
if not hasattr(self, '_stored_cutter_location'):
self.mouse.cached_3d = current_mouse_pos_3d.copy()
self._stored_cutter_location = self.cutter.obj.location.copy()
offset = current_mouse_pos_3d - self.mouse.cached_3d
self.cutter.obj.location = self._stored_cutter_location + offset
@@ -1,40 +1,59 @@
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
from ...ui.icons import (
get_custom_icon,
)
#### ------------------------------ PROPERTIES ------------------------------ ####
class CarverPropsOperator():
# NOTE: Defining enum items inside a function is necessary to allow correct import of custom icons.
# Without this, classes (and their properties) are registered before `register` function of modules
# are called, resulting in empty preview collections.
def get_depth_items(self, context):
icon_measure = get_custom_icon("MEASURE")
icon_cpu = get_custom_icon("CPU")
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),
]
return items
# OPERATOR-properties
mode: bpy.props.EnumProperty(
name = "Mode",
items = (('DESTRUCTIVE', "Destructive",
"Boolean cutters are immediatelly applied and removed after the cut", 'MESH_DATA', 0),
"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)),
"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)),
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 = (('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',
items = get_depth_items,
default = 0,
)
@@ -72,7 +91,7 @@ class CarverPropsShape():
flip_direction: bpy.props.BoolProperty(
name = "Flip Direction",
description = "Change which way the geometry is extruded",
options = {'SKIP_SAVE', 'HIDDEN', 'SKIP_PRESET', },
options = {'SKIP_SAVE', 'HIDDEN'},
default = False,
)
@@ -88,7 +107,7 @@ class CarverPropsModifier():
)
pin: bpy.props.BoolProperty(
name = "Pin Boolean Modifier",
description = ("Boolean modifier will be placed first in modifier stack, above other modifier (if there are any).\n"
description = ("Always make new Boolean modifiers first in the modifier stack.\n"
"NOTE: Order of modifiers can drastically affect the result (especially in destructive mode)"),
default = True,
)
@@ -98,13 +117,13 @@ class CarverPropsCutter():
# CUTTER-properties
hide: bpy.props.BoolProperty(
name = "Hide Cutter",
description = ("Hide cutter objects in the viewport after they're created."),
description = ("Hide cutter objects in the viewport after they are created."),
default = True,
)
parent: bpy.props.BoolProperty(
name = "Parent to Canvas",
description = ("Cutters will be parented to active object being cut, even if cutting multiple objects.\n"
"If there is no active object in selection cutters parent might be chosen seemingly randomly"),
description = ("Parent new cutters to active object (even if they are cutting multiple objects).\n"
"If there is no active object in the selection, parent might be chosen randomly"),
default = True,
)
display: bpy.props.EnumProperty(
@@ -115,24 +134,29 @@ class CarverPropsCutter():
)
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")),
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",
description = ("Cutter object will be shaded smooth with sharp edges (above specified degrees) marked as sharp\n"
"NOTE: This is a one time operator. 'Smooth by Angle' modifier will not be added on cutter"),
description = ("Add 'Smooth by Angle' modifier to cutter object"),
default = True,
)
sharp_angle: bpy.props.FloatProperty(
name = "Angle",
description = "Maximum face angle for sharp edges",
subtype = "ANGLE",
precision = 3,
min = 0, max = math.pi,
default = 0.523599,
)
@@ -142,13 +166,13 @@ class CarverPropsArray():
# ARRAY-properties
rows: bpy.props.IntProperty(
name = "Rows",
description = "Number of times shape is duplicated horizontally",
description = "Number of times the shape is duplicated horizontally",
min = 1, soft_max = 16,
default = 1,
)
columns: bpy.props.IntProperty(
name = "Columns",
description = "Number of times shape is duplicated vertically",
description = "Number of times the shape is duplicated vertically",
min = 1, soft_max = 16,
default = 1,
)
@@ -9,13 +9,14 @@ from ...functions.mesh import (
)
from ...functions.modifier import (
add_modifier_asset,
update_modifier_input,
)
#### ------------------------------ CLASSES ------------------------------ ####
class Selection:
"""Storage of viable selected and active object(s) throughout the modal."""
"""Storage of viable cannvas objects and their Boolean modifiers."""
def __init__(self, selected, active):
self.selected: list = selected
@@ -24,10 +25,7 @@ class Selection:
class Mouse:
"""
Mouse positions throughout different phases of the modal operator.
Each class variable is a 2D vector in screen space (x, y).
"""
"""Mouse positions throughout different phases of the modal operator."""
def __init__(self):
self.initial = Vector()
@@ -99,17 +97,17 @@ class Effects:
return self
def update(self, cls, effect):
"""Update bevel modifier during modal."""
"""Update cutter modifiers during modal."""
# Update array count.
if effect == '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
update_modifier_input(self.array, "Socket_2", cls.columns)
update_modifier_input(self.array, "Socket_3", cls.rows)
# Remove modifier if it's no longer needed.
if cls.columns == 1 and cls.rows == 1:
@@ -117,12 +115,12 @@ class Effects:
self.array = None
# Update array gap.
if effect == 'ARRAY_GAP':
if cls.columns > 1 or cls.row > 1:
if effect == "ARRAY_GAP":
if cls.columns > 1 or cls.rows > 1:
if self.array is not None:
self.array["Socket_4"] = cls.gap
update_modifier_input(self.array, "Socket_4", cls.gap)
# Force the modifier to update in viewport.
# NOTE: Force the modifier to update in viewport when key is pressed.
self.array.show_viewport = False
self.array.show_viewport = True
@@ -150,15 +148,16 @@ class Effects:
# Columns
if cls.columns > 1:
mod["Socket_2"] = cls.columns
update_modifier_input(mod, "Socket_2", cls.columns)
# Rows
if cls.rows > 1:
mod["Socket_3"] = cls.rows
update_modifier_input(mod, "Socket_3", cls.rows)
# Gap
mod["Socket_4"] = cls.gap
update_modifier_input(mod, "Socket_4", cls.gap)
mod.use_pin_to_last = True
self.array = mod
@@ -233,7 +232,7 @@ class Effects:
# 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."""
"""Adds a 'Smooth by Angle' modifier on the cutter object (a.k.a. Auto Smooth)."""
obj = cls.cutter.obj
mesh = cls.cutter.mesh
@@ -262,7 +261,7 @@ class Effects:
mod = obj.modifiers.active
# Try loading the node group manually if `bpy.ops` operators fail.
# Try loading the node group manually if `bpy.ops` operators failed.
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)
@@ -274,13 +273,13 @@ class Effects:
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.
# Set smoothing angle (necessary for all methods except `shade_auto_smooth()`).
for face in bm.faces:
face.smooth = True
bm.to_mesh(mesh)
mod.use_pin_to_last = True
mod["Input_1"] = cls.sharp_angle
update_modifier_input(mod, "Input_1", cls.sharp_angle)
self.smooth = mod
@@ -1,5 +1,4 @@
import bpy
from ... import __package__ as base_package
#### ------------------------------ /toolbar/ ------------------------------ ####
@@ -14,7 +13,7 @@ def carver_ui_common(context, layout, props):
layout.prop(props, "solver", expand=True)
else:
# Use labels for Properties editor/sidebar.
# NOTE: Use labels for Properties editor/sidebar.
layout.prop(props, "mode", text="Mode")
layout.prop(props, "alignment", text="Alignment")
layout.prop(props, "depth", text="Depth")
@@ -43,20 +42,17 @@ class TOPBAR_PT_carver_shape(bpy.types.Panel):
layout.use_property_split = True
layout.use_property_decorate = False
tool = context.workspace.tools.from_space_view3d_mode('OBJECT' if context.mode == 'OBJECT' else 'EDIT_MESH')
mode = 'OBJECT' if context.mode == 'OBJECT' else 'EDIT_MESH'
tool = context.workspace.tools.from_space_view3d_mode(mode)
props = tool.operator_properties(tool.idname)
# Box & Circle
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")
else:
props = tool.operator_properties("object.carve_circle")
if tool.idname == "object.carve_circle":
layout.prop(props, "subdivision", text="Vertices")
layout.prop(props, "rotation")
layout.prop(props, "aspect", expand=True)
layout.prop(props, "origin", expand=True)
layout.prop(props, "aspect", expand=True)
if props.alignment == 'SURFACE':
layout.prop(props, "orientation")
@@ -67,7 +63,6 @@ class TOPBAR_PT_carver_shape(bpy.types.Panel):
# Polyline
elif tool.idname == "object.carve_polyline":
props = tool.operator_properties("object.carve_polyline")
if props.alignment == 'SURFACE':
layout.prop(props, "offset", text="Offset")
layout.prop(props, "align_to_all")
@@ -85,13 +80,9 @@ class TOPBAR_PT_carver_effects(bpy.types.Panel):
layout.use_property_split = True
layout.use_property_decorate = False
tool = context.workspace.tools.from_space_view3d_mode('OBJECT' if context.mode == 'OBJECT' else 'EDIT_MESH')
if tool.idname == "object.carve_box":
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")
mode = 'OBJECT' if context.mode == 'OBJECT' else 'EDIT_MESH'
tool = context.workspace.tools.from_space_view3d_mode(mode)
props = tool.operator_properties(tool.idname)
# Bevel
if tool.idname == 'object.carve_box':
@@ -116,6 +107,7 @@ class TOPBAR_PT_carver_effects(bpy.types.Panel):
col.prop(props, "rows")
col.prop(props, "gap")
class TOPBAR_PT_carver_cutter(bpy.types.Panel):
bl_label = "Carver Cutter"
bl_idname = "TOPBAR_PT_carver_cutter"
@@ -128,25 +120,25 @@ class TOPBAR_PT_carver_cutter(bpy.types.Panel):
layout.use_property_split = True
layout.use_property_decorate = False
tool = context.workspace.tools.from_space_view3d_mode('OBJECT' if context.mode == 'OBJECT' else 'EDIT_MESH')
if tool.idname == "object.carve_box":
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")
mode = 'OBJECT' if context.mode == 'OBJECT' else 'EDIT_MESH'
tool = context.workspace.tools.from_space_view3d_mode(mode)
props = tool.operator_properties(tool.idname)
# modifier_&_cutter
# Modifier properties.
col = layout.column()
col.prop(props, "pin", text="Pin Modifier")
col.separator()
# Cutter object properties.
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
# Auto Smooth.
layout.separator()
col = layout.column(align=True)
col.prop(props, "auto_smooth", text="Auto Smooth")
@@ -159,11 +151,11 @@ class TOPBAR_PT_carver_cutter(bpy.types.Panel):
#### ------------------------------ REGISTRATION ------------------------------ ####
classes = [
classes = (
TOPBAR_PT_carver_shape,
TOPBAR_PT_carver_effects,
TOPBAR_PT_carver_cutter,
]
)
def register():
for cls in classes:
-270
View File
@@ -1,270 +0,0 @@
import bpy
from .functions.poll import is_canvas
from .functions.list import list_canvas_cutters
#### ------------------------------ /ui/ ------------------------------ ####
def carve_menu(self, context):
layout = self.layout
layout.operator("object.carve_box", text="Box Carve").shape='BOX'
layout.operator("object.carve_box", text="Circle Carve").shape='CIRCLE'
layout.operator("object.carve_polyline", text="Polyline Carve")
def boolean_operators_menu(self, context):
layout = self.layout
layout.operator_context = 'INVOKE_DEFAULT'
col = layout.column(align=True)
col.label(text="Auto Boolean")
col.operator("object.boolean_auto_difference", text="Difference", icon='SELECT_SUBTRACT')
col.operator("object.boolean_auto_union", text="Union", icon='SELECT_EXTEND')
col.operator("object.boolean_auto_intersect", text="Intersect", icon='SELECT_INTERSECT')
col.operator("object.boolean_auto_slice", text="Slice", icon='SELECT_DIFFERENCE')
col.separator()
col.label(text="Brush Boolean")
col.operator("object.boolean_brush_difference", text="Difference", icon='SELECT_SUBTRACT')
col.operator("object.boolean_brush_union", text="Union", icon='SELECT_EXTEND')
col.operator("object.boolean_brush_intersect", text="Intersect", icon='SELECT_INTERSECT')
col.operator("object.boolean_brush_slice", text="Slice", icon='SELECT_DIFFERENCE')
def boolean_extras_menu(self, context):
layout = self.layout
layout.operator_context = 'INVOKE_DEFAULT'
col = layout.column(align=True)
if context.active_object:
# Canvas operators
active_object = context.active_object
if active_object.booleans.canvas == True and any(mod.name.startswith("boolean_") for mod in active_object.modifiers):
col.separator()
col.operator("object.boolean_toggle_all", text="Toggle All Cuters")
col.operator("object.boolean_apply_all", text="Apply All Cutters")
col.operator("object.boolean_remove_all", text="Remove All Cutters")
# Cutter operators
if active_object.booleans.cutter:
col.separator()
col.operator("object.boolean_toggle_cutter", text="Toggle Cutter").method='ALL'
col.operator("object.boolean_apply_cutter", text="Apply Cutter").method='ALL'
col.operator("object.boolean_remove_cutter", text="Remove Cutter").method='ALL'
#### ------------------------------ PANELS ------------------------------ ####
# Boolean Operators Panel
class VIEW3D_PT_boolean(bpy.types.Panel):
bl_label = "Boolean"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "Edit"
bl_context = "objectmode"
bl_options = {'DEFAULT_CLOSED'}
@classmethod
def poll(cls, context):
prefs = context.preferences.addons[__package__].preferences
return prefs.show_in_sidebar
def draw(self, context):
boolean_operators_menu(self, context)
# Properties Panel
class VIEW3D_PT_boolean_properties(bpy.types.Panel):
bl_label = "Properties"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "Edit"
bl_context = "objectmode"
bl_parent_id = "VIEW3D_PT_boolean"
@classmethod
def poll(cls, context):
prefs = context.preferences.addons[__package__].preferences
if prefs.show_in_sidebar:
if context.active_object:
if is_canvas(context.active_object) or context.active_object.booleans.cutter:
return True
else:
return False
else:
return False
else:
return False
def draw(self, context):
boolean_extras_menu(self, context)
# Cutters Panel
class VIEW3D_PT_boolean_cutters(bpy.types.Panel):
bl_label = "Cutters"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "Edit"
bl_context = "objectmode"
bl_parent_id = "VIEW3D_PT_boolean"
@classmethod
def poll(cls, context):
prefs = context.preferences.addons[__package__].preferences
if prefs.show_in_sidebar:
if context.active_object:
if is_canvas(context.active_object):
return True
else:
return False
else:
return False
else:
return False
def draw(self, context):
layout = self.layout
canvas = context.active_object
__, modifiers = list_canvas_cutters([canvas])
for mod in modifiers:
col = layout.column(align=True)
row = col.row(align=True)
# icon
if mod.operation == 'DIFFERENCE':
icon = 'SELECT_SUBTRACT'
elif mod.operation == 'UNION':
icon = 'SELECT_EXTEND'
elif mod.operation == 'INTERSECT':
icon = 'SELECT_INTERSECT'
row.prop(mod.object, "name", text="", icon=icon)
# Toggle
op_toggle = row.operator("object.boolean_toggle_cutter", text="", icon='HIDE_OFF' if mod.show_viewport else 'HIDE_ON')
op_toggle.method = 'SPECIFIED'
op_toggle.specified_cutter = mod.object.name
op_toggle.specified_canvas = canvas.name
# Apply
op_apply = row.operator("object.boolean_apply_cutter", text="", icon='CHECKMARK')
op_apply.method = 'SPECIFIED'
op_apply.specified_cutter = mod.object.name
op_apply.specified_canvas = canvas.name
# Remove
op_remove = row.operator("object.boolean_remove_cutter", text="", icon='X')
op_remove.method = 'SPECIFIED'
op_remove.specified_cutter = mod.object.name
op_remove.specified_canvas = canvas.name
#### ------------------------------ MENUS ------------------------------ ####
# Carve Menu
class VIEW3D_MT_carve(bpy.types.Menu):
bl_label = "Carve"
bl_idname = "VIEW3D_MT_carve"
def draw(self, context):
carve_menu(self, context)
# 3D Viewport (Object Mode) -> Object
class VIEW3D_MT_boolean(bpy.types.Menu):
bl_label = "Boolean"
bl_idname = "VIEW3D_MT_boolean"
def draw(self, context):
layout = self.layout
layout.menu("VIEW3D_MT_carve")
layout.separator()
boolean_operators_menu(self, context)
boolean_extras_menu(self, context)
# Shift-Ctrl-B Menu
class VIEW3D_MT_boolean_popup(bpy.types.Menu):
bl_label = "Boolean"
bl_idname = "VIEW3D_MT_boolean_popup"
def draw(self, context):
boolean_operators_menu(self, context)
boolean_extras_menu(self, context)
def object_mode_menu(self, context):
layout = self.layout
layout.separator()
layout.menu("VIEW3D_MT_boolean")
def edit_mode_menu(self, context):
layout = self.layout
layout.separator()
layout.menu("VIEW3D_MT_carve")
def boolean_select_menu(self, context):
layout = self.layout
active_obj = context.active_object
if active_obj:
if active_obj.booleans.canvas == True or active_obj.booleans.cutter:
layout.separator()
if active_obj.booleans.canvas == True:
layout.operator("object.boolean_select_all", text="Boolean Cutters")
if active_obj.booleans.cutter:
layout.operator("object.select_cutter_canvas", text="Boolean Canvases")
#### ------------------------------ REGISTRATION ------------------------------ ####
addon_keymaps = []
classes = [
VIEW3D_MT_carve,
VIEW3D_MT_boolean,
VIEW3D_MT_boolean_popup,
VIEW3D_PT_boolean,
VIEW3D_PT_boolean_properties,
VIEW3D_PT_boolean_cutters,
]
def register():
for cls in classes:
bpy.utils.register_class(cls)
# MENU
bpy.types.VIEW3D_MT_object.append(object_mode_menu)
bpy.types.VIEW3D_MT_select_object.append(boolean_select_menu)
bpy.types.VIEW3D_MT_edit_mesh.append(edit_mode_menu)
# KEYMAP
addon = bpy.context.window_manager.keyconfigs.addon
km = addon.keymaps.new(name="Object Mode")
kmi = km.keymap_items.new("wm.call_menu", 'B', 'PRESS', ctrl=True, shift=True)
kmi.properties.name = "VIEW3D_MT_boolean_popup"
kmi.active = True
addon_keymaps.append((km, kmi))
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
# MENU
bpy.types.VIEW3D_MT_object.remove(object_mode_menu)
bpy.types.VIEW3D_MT_select_object.remove(boolean_select_menu)
bpy.types.VIEW3D_MT_edit_mesh.remove(edit_mode_menu)
# KEYMAP
for km, kmi in addon_keymaps:
km.keymap_items.remove(kmi)
addon_keymaps.clear()
@@ -0,0 +1,34 @@
if "bpy" in locals():
import importlib
for mod in [icons,
lists,
menus,
panels,
]:
importlib.reload(mod)
else:
import bpy
from . import (
icons,
lists,
menus,
panels,
)
#### ------------------------------ REGISTRATION ------------------------------ ####
modules = (
icons,
lists,
menus,
panels,
)
def register():
for module in modules:
module.register()
def unregister():
for module in reversed(modules):
module.unregister()
@@ -0,0 +1,96 @@
import bpy
from contextlib import contextmanager
from ..functions.canvas import (
is_canvas,
)
from ..functions.modifier import (
enumerate_boolean_modifiers,
is_boolean_modifier,
)
#### ------------------------------ FUNCTIONS ------------------------------ ####
def get_modifier_from_list_index(obj, index: int):
"""
Returns the modifier of an object based on Cutters list index.
Filters out non-Boolean modifiers to leave a list that matches Cutters one in length.
"""
# Create a list of only Boolean modifiers.
boolean_modifiers = []
for mod in obj.modifiers:
if is_boolean_modifier(mod):
boolean_modifiers.append(mod)
if 0 <= index < len(boolean_modifiers):
return boolean_modifiers[index]
return None
@contextmanager
def preserve_list_index(prop_owner, index_prop: str):
"""Preserves index of active item in UI list."""
index = getattr(prop_owner, index_prop, 0)
stored_index = index
try:
yield
finally:
new_index = stored_index - 1
if new_index < 0:
new_index = 0
setattr(prop_owner, index_prop, new_index)
#### ------------------------------ /ui/ ------------------------------ ####
def boolean_operators_menu(self, context):
layout = self.layout
layout.operator_context = 'INVOKE_DEFAULT'
col = layout.column(align=True)
col.label(text="Auto Boolean")
col.operator("object.boolean_auto_difference", text="Difference", icon='SELECT_SUBTRACT')
col.operator("object.boolean_auto_union", text="Union", icon='SELECT_EXTEND')
col.operator("object.boolean_auto_intersect", text="Intersect", icon='SELECT_INTERSECT')
col.operator("object.boolean_auto_slice", text="Slice", icon='SELECT_DIFFERENCE')
col.separator()
col.label(text="Brush Boolean")
col.operator("object.boolean_brush_difference", text="Difference", icon='SELECT_SUBTRACT')
col.operator("object.boolean_brush_union", text="Union", icon='SELECT_EXTEND')
col.operator("object.boolean_brush_intersect", text="Intersect", icon='SELECT_INTERSECT')
col.operator("object.boolean_brush_slice", text="Slice", icon='SELECT_DIFFERENCE')
def boolean_extras_menu(self, context, cutter_only=False):
layout = self.layout
layout.operator_context = 'INVOKE_DEFAULT'
col = layout.column(align=True)
obj = context.active_object
if not obj:
return
# Canvas operators
if is_canvas(obj) and not cutter_only:
col.separator()
col.operator("object.boolean_toggle_all", text="Toggle All Cuters")
col.operator("object.boolean_apply_all", text="Apply All Cutters")
col.operator("object.boolean_remove_all", text="Remove All Cutters")
# Cutter operators
if obj.booleans.cutter:
if not cutter_only:
col.separator()
col.operator("object.boolean_toggle_cutter", text="Toggle Cutter").method='ALL'
col.operator("object.boolean_apply_cutter", text="Apply Cutter").method='ALL'
col.operator("object.boolean_remove_cutter", text="Remove Cutter").method='ALL'
@@ -0,0 +1,46 @@
import bpy
import os
import bpy.utils.previews
#### ------------------------------ FUNCTIONS ------------------------------ ####
def get_custom_icon(icon_name: str):
"""Returns the ID of a custom icon registered by add-on."""
svg_icons = preview_collections["main"]
if not svg_icons:
return 0
icon = svg_icons[icon_name].icon_id
if not icon:
return 0
return icon
#### ------------------------------ REGISTRATION ------------------------------ ####
preview_collections = {}
svg_icons_dir = os.path.join(os.path.dirname(__file__), "svg")
icons = {
"MEASURE": "measure.svg",
"CPU": "cpu.svg",
}
def register():
svg_icons = bpy.utils.previews.new()
for key, file in icons.items():
svg_icons.load(key, os.path.join(svg_icons_dir, file), 'IMAGE')
preview_collections["main"] = svg_icons
def unregister():
for pcoll in preview_collections.values():
bpy.utils.previews.remove(pcoll)
preview_collections.clear()

Before

Width:  |  Height:  |  Size: 772 B

After

Width:  |  Height:  |  Size: 772 B

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,92 @@
import bpy
from ..functions.modifier import (
is_boolean_modifier,
)
#### ------------------------------ /cutters_list/ ------------------------------ ####
class VIEW3D_UL_boolean_cutters(bpy.types.UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
canvas = context.active_object
mod = item
# Pick Icon
if mod.operation == 'DIFFERENCE':
icon = 'SELECT_SUBTRACT'
elif mod.operation == 'UNION':
icon = 'SELECT_EXTEND'
elif mod.operation == 'INTERSECT':
icon = 'SELECT_INTERSECT'
row = layout.row(align=True)
row.prop(mod.object, "name", text="", icon=icon, emboss=False)
# Select Cutter
op_select = row.operator("object.boolean_select_cutter", text="", icon='RESTRICT_SELECT_OFF', emboss=False)
op_select.cutter = mod.object.name
# Toggle Cutter
icon = 'HIDE_OFF' if mod.show_viewport else 'HIDE_ON'
op_toggle = row.operator("object.boolean_toggle_cutter", text="", icon=icon, emboss=False)
op_toggle.method = 'SPECIFIED'
op_toggle.specified_cutter = mod.object.name
op_toggle.specified_canvas = canvas.name
op_toggle.specified_modifier = mod.name
def filter_items(self, context, data, propname):
flags = []
indices = []
modifiers = getattr(data, propname)
for mod in modifiers:
if is_boolean_modifier(mod):
flags.append(self.bitflag_filter_item)
else:
flags.append(0)
# Search Filter
if self.filter_name:
filter_name = self.filter_name.lower()
for i, mod in enumerate(modifiers):
if flags[i] != self.bitflag_filter_item:
continue
if filter_name not in mod.object.name.lower():
flags[i] = 0
# Invert
if self.use_filter_invert:
for i, mod in enumerate(modifiers):
if not is_boolean_modifier(mod):
continue
flags[i] ^= self.bitflag_filter_item
# Sort by Name
indices = list(range(len(modifiers)))
if self.use_filter_sort_alpha:
sorted_indices = sorted(range(len(modifiers)),
key=lambda i: modifiers[i].object.name if modifiers[i].object else "")
indices = [0] * len(modifiers)
for rank, original_i in enumerate(sorted_indices):
indices[original_i] = rank
return flags, indices
#### ------------------------------ REGISTRATION ------------------------------ ####
classes = (
VIEW3D_UL_boolean_cutters,
)
def register():
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
@@ -0,0 +1,129 @@
import bpy
from ..functions.canvas import (
is_canvas,
)
from .common import (
boolean_operators_menu,
boolean_extras_menu,
)
#### ------------------------------ /boolean_menu/ ------------------------------ ####
# 3D Viewport (Object Mode) -> Object
class VIEW3D_MT_boolean(bpy.types.Menu):
bl_label = "Boolean"
bl_idname = "VIEW3D_MT_boolean"
def draw(self, context):
layout = self.layout
layout.menu("VIEW3D_MT_carve")
layout.separator()
boolean_operators_menu(self, context)
boolean_extras_menu(self, context)
def object_mode_menu(self, context):
layout = self.layout
layout.separator()
layout.menu("VIEW3D_MT_boolean")
#### ------------------------------ /popup_menu/ ------------------------------ ####
# Shift-Ctrl-B Menu
class VIEW3D_MT_boolean_popup(bpy.types.Menu):
bl_label = "Boolean"
bl_idname = "VIEW3D_MT_boolean_popup"
def draw(self, context):
boolean_operators_menu(self, context)
boolean_extras_menu(self, context)
#### ------------------------------ /carve_menu/ ------------------------------ ####
class VIEW3D_MT_carve(bpy.types.Menu):
bl_label = "Carve"
bl_idname = "VIEW3D_MT_carve"
def draw(self, context):
layout = self.layout
layout.operator("object.carve_box", text="Box Carve")
layout.operator("object.carve_circle", text="Circle Carve")
layout.operator("object.carve_polyline", text="Polyline Carve")
# Separate "Carve" menu in Edit Mode (where we don't have "Boolean" menu).
def edit_mode_menu(self, context):
layout = self.layout
layout.separator()
layout.menu("VIEW3D_MT_carve")
#### ------------------------------ /select_menu/ ------------------------------ ####
def select_menu(self, context):
layout = self.layout
obj = context.active_object
if not obj:
return
if is_canvas(obj) or obj.booleans.cutter:
layout.separator()
if is_canvas(obj):
layout.operator("object.boolean_select_all", text="Boolean Cutters")
if obj.booleans.cutter:
layout.operator("object.select_cutter_canvas", text="Boolean Canvases")
#### ------------------------------ REGISTRATION ------------------------------ ####
addon_keymaps = []
classes = (
VIEW3D_MT_boolean,
VIEW3D_MT_boolean_popup,
VIEW3D_MT_carve,
)
def register():
for cls in classes:
bpy.utils.register_class(cls)
# MENU
bpy.types.VIEW3D_MT_object.append(object_mode_menu)
bpy.types.VIEW3D_MT_edit_mesh.append(edit_mode_menu)
bpy.types.VIEW3D_MT_select_object.append(select_menu)
# KEYMAP
addon = bpy.context.window_manager.keyconfigs.addon
km = addon.keymaps.new(name="Object Mode")
kmi = km.keymap_items.new("wm.call_menu", 'B', 'PRESS', ctrl=True, shift=True)
kmi.properties.name = "VIEW3D_MT_boolean_popup"
kmi.active = True
addon_keymaps.append((km, kmi))
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
# MENU
bpy.types.VIEW3D_MT_object.remove(object_mode_menu)
bpy.types.VIEW3D_MT_edit_mesh.remove(edit_mode_menu)
bpy.types.VIEW3D_MT_select_object.remove(select_menu)
# KEYMAP
for km, kmi in addon_keymaps:
km.keymap_items.remove(kmi)
addon_keymaps.clear()
@@ -0,0 +1,182 @@
import bpy
from .. import __package__ as base_package
from ..functions.canvas import (
is_canvas,
)
from .common import (
get_modifier_from_list_index,
boolean_extras_menu,
)
#### ------------------------------ PANELS ------------------------------ ####
# Boolean Operators Panel
class VIEW3D_PT_boolean(bpy.types.Panel):
bl_label = "Boolean"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "Edit"
bl_context = "objectmode"
bl_options = {'DEFAULT_CLOSED'}
@classmethod
def poll(cls, context):
prefs = context.preferences.addons[base_package].preferences
return prefs.show_in_sidebar
def draw(self, context):
layout = self.layout
layout.operator_context = 'INVOKE_DEFAULT'
col = layout.column(align=True)
col.label(text="Auto Boolean")
row = col.row(align=False)
row.operator("object.boolean_auto_difference", text="Difference", icon='SELECT_SUBTRACT')
row.operator("object.boolean_auto_difference", text="", icon='UV_SYNC_SELECT').flip=True
row = col.row(align=False)
row.operator("object.boolean_auto_union", text="Union", icon='SELECT_EXTEND')
row.operator("object.boolean_auto_union", text="", icon='UV_SYNC_SELECT').flip=True
row = col.row(align=False)
row.operator("object.boolean_auto_intersect", text="Intersect", icon='SELECT_INTERSECT')
row.operator("object.boolean_auto_intersect", text="", icon='UV_SYNC_SELECT').flip=True
row = col.row(align=False)
row.operator("object.boolean_auto_slice", text="Slice", icon='SELECT_DIFFERENCE')
row.operator("object.boolean_auto_slice", text="", icon='UV_SYNC_SELECT').flip=True
col.separator()
col.label(text="Brush Boolean")
row = col.row(align=False)
row.operator("object.boolean_brush_difference", text="Difference", icon='SELECT_SUBTRACT')
row.operator("object.boolean_brush_difference", text="", icon='UV_SYNC_SELECT').flip=True
row = col.row(align=False)
row.operator("object.boolean_brush_union", text="Union", icon='SELECT_EXTEND')
row.operator("object.boolean_brush_union", text="", icon='UV_SYNC_SELECT').flip=True
row = col.row(align=False)
row.operator("object.boolean_brush_intersect", text="Intersect", icon='SELECT_INTERSECT')
row.operator("object.boolean_brush_intersect", text="", icon='UV_SYNC_SELECT').flip=True
row = col.row(align=False)
row.operator("object.boolean_brush_slice", text="Slice", icon='SELECT_DIFFERENCE')
row.operator("object.boolean_brush_slice", text="", icon='UV_SYNC_SELECT').flip=True
# Cutters Panel
class VIEW3D_PT_boolean_cutters(bpy.types.Panel):
bl_label = "Cutters"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "Edit"
bl_context = "objectmode"
bl_parent_id = "VIEW3D_PT_boolean"
@classmethod
def poll(cls, context):
prefs = context.preferences.addons[base_package].preferences
if prefs.show_in_sidebar:
if context.active_object:
if is_canvas(context.active_object):
return True
else:
return False
else:
return False
else:
return False
def draw(self, context):
layout = self.layout
canvas = context.active_object
# Cutters List
row = layout.row()
col = row.column()
col.template_list(
"VIEW3D_UL_boolean_cutters",
"",
canvas, "modifiers",
canvas.booleans, "modifiers_list_index",
rows=5,
)
# Filter & Operators
col = row.column(align=True)
cutters_list_index = canvas.booleans.modifiers_list_index
mod = get_modifier_from_list_index(canvas, cutters_list_index)
sub = col.column(align=True)
# Apply Cutter
op_apply = sub.operator("object.boolean_apply_cutter", text="", icon='CHECKMARK')
op_apply.method = 'SPECIFIED'
op_apply.specified_cutter = mod.object.name if mod else ""
op_apply.specified_canvas = canvas.name
# Remove Cutter
op_remove = sub.operator("object.boolean_remove_cutter", text="", icon='X')
op_remove.method = 'SPECIFIED'
op_remove.specified_cutter = mod.object.name if mod else ""
op_remove.specified_canvas = canvas.name
op_remove.specified_modifier = mod.name if mod else ""
if cutters_list_index < 0 or not mod:
sub.enabled = False
col.separator()
col.menu("VIEW3D_MT_boolean_specials", icon='DOWNARROW_HLT', text="")
# Helpers Panel
class VIEW3D_PT_boolean_helpers(bpy.types.Panel):
bl_label = "Helpers"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "Edit"
bl_context = "objectmode"
bl_parent_id = "VIEW3D_PT_boolean"
@classmethod
def poll(cls, context):
prefs = context.preferences.addons[base_package].preferences
if not prefs.show_in_sidebar:
return False
if not context.active_object:
return False
if context.active_object.booleans.cutter:
return True
return False
def draw(self, context):
boolean_extras_menu(self, context, cutter_only=True)
#### ------------------------------ MENUS ------------------------------ ####
# Specials
class VIEW3D_MT_boolean_specials(bpy.types.Menu):
bl_label = "Boolean Operators"
bl_idname = "VIEW3D_MT_boolean_specials"
def draw(self, context):
boolean_extras_menu(self, context)
#### ------------------------------ REGISTRATION ------------------------------ ####
classes = (
VIEW3D_PT_boolean,
VIEW3D_PT_boolean_cutters,
VIEW3D_PT_boolean_helpers,
VIEW3D_MT_boolean_specials,
)
def register():
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
@@ -1,43 +0,0 @@
import bpy
#### ------------------------------ FUNCTIONS ------------------------------ ####
@bpy.app.handlers.persistent
def populate_boolean_properties(scene):
prefs = bpy.context.preferences.addons[__package__].preferences
if prefs.versioning:
for obj in bpy.data.objects:
if not obj.get("BoolToolRoot"):
continue
# Convert Canvas
if obj.get("BoolToolRoot"):
obj.booleans.canvas = True
del obj["BoolToolRoot"]
if obj.get("BoolTool_FTransform"):
del obj["BoolTool_FTransform"]
for mod in obj.modifiers:
if mod.type == 'BOOLEAN' and "BTool_" in mod.name:
mod.name = "boolean_" + mod.object.name
cutter = mod.object
# Convert Canvases
if cutter.get("BoolToolBrush"):
cutter.booleans.cutter = cutter.get("BoolToolBrush")
del cutter["BoolToolBrush"]
if cutter.get("BoolTool_FTransform"):
del cutter["BoolTool_FTransform"]
#### ------------------------------ REGISTRATION ------------------------------ ####
def register():
# HANDLERS
bpy.app.handlers.load_post.append(populate_boolean_properties)
def unregister():
# HANDLERS
bpy.app.handlers.load_post.remove(populate_boolean_properties)
@@ -2,15 +2,18 @@
#
# SPDX-License-Identifier: GPL-3.0-or-later
from . import utils, icons, settings, preferences, ui, draw_tool, ops
import importlib
from . import geomod, utils, icons, settings, preferences, ui, draw_tool, ops
import tomllib as toml
modules = [utils, icons, settings, preferences, ui, draw_tool, ops]
modules = [geomod, utils, icons, settings, preferences, ui, draw_tool, ops]
def register():
# register modules
for m in modules:
m.register()
importlib.reload(m)
if hasattr(m, 'register'):
m.register()
# read addon meta-data
with open(f"{utils.get_addon_directory()}/blender_manifest.toml", 'rb') as f:
@@ -26,7 +29,8 @@ def register():
def unregister():
# un-register modules
for m in reversed(modules):
m.unregister()
if hasattr(m, 'unregister'):
m.unregister()
# Remove addon asset library
utils.unregister_asset_lib()
@@ -1,7 +1,7 @@
schema_version = "1.0.0"
id = "brushstroke_tools"
version = "1.2.1"
version = "1.2.3"
name = "Brushstroke Tools"
tagline = "Brushstroke painting tools by the Blender Studio"
maintainer = "Simon Thommes <simon@blender.org>"
@@ -97,9 +97,7 @@ class BSBST_OT_pre_process_brushstroke(bpy.types.Operator):
if bpy.app.version >= (5,1):
utils.ensure_resources()
ng_process = bpy.data.node_groups['.brushstroke_tools.draw_processing']
ng_process.asset_mark() # workaround to let Blender register the node tool as an operator with the automatically generated id
ng_process.asset_clear()
ng_process.is_tool = True # workaround to let Blender register the node tool as an operator with the automatically generated id
return {'FINISHED'}
class BSBST_OT_post_process_brushstroke(bpy.types.Operator):
@@ -0,0 +1,188 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
"""
Compatibility wrappers for the GeoNodes modifier input API.
Blender 5.2 replaced dict-style custom-property access on GeoNodes modifiers
with proper RNA properties. All modifier input reads and writes in this add-on
go through these helpers so version branching stays in one place.
Before 5.2: modifier["identifier"] = value
5.2+: modifier.properties.inputs.identifier.value = value
"""
from typing import Any
import bpy
from bpy.types import NodesModifier, UILayout, NodeTreeInterfaceSocket
_ICON_FOR_SOCKET = {
bpy.types.NodeTreeInterfaceSocketObject: 'OBJECT_DATA',
bpy.types.NodeTreeInterfaceSocketMaterial: 'MATERIAL',
bpy.types.NodeTreeInterfaceSocketImage: 'IMAGE_DATA',
bpy.types.NodeTreeInterfaceSocketCollection: 'OUTLINER_COLLECTION',
}
_DATA_COLLECTION_FOR_SOCKET = {
bpy.types.NodeTreeInterfaceSocketMaterial: 'materials',
bpy.types.NodeTreeInterfaceSocketImage: 'images',
bpy.types.NodeTreeInterfaceSocketCollection: 'collections',
}
def get_value(mod: NodesModifier, identifier: str) -> Any:
if bpy.app.version >= (5, 2, 0):
return getattr(mod.properties.inputs, identifier).value
return mod[identifier]
def set_value(mod: NodesModifier, identifier: str, value: Any):
if bpy.app.version >= (5, 2, 0):
getattr(mod.properties.inputs, identifier).value = value
else:
prop_ui = mod.id_properties_ui(identifier).as_dict()
if 'items' in prop_ui.keys():
for item in prop_ui['items']:
if item[0] == value:
mod[identifier] = item[4]
return
print(f"Error: Item '{value}' not found in menu '{identifier}' of modifier '{mod.name}'")
else:
mod[identifier] = value
def is_input_value_settable(mod: NodesModifier, identifier: str) -> bool:
"""Return True if the modifier exposes a settable input with this identifier."""
if bpy.app.version >= (5, 2, 0):
inp = getattr(mod.properties.inputs, identifier, None)
return inp is not None and hasattr(inp, 'value')
return identifier in mod.keys()
def can_input_be_attribute(mod: NodesModifier, identifier: str) -> bool:
"""Return True if this input can be driven by an attribute field."""
if bpy.app.version >= (5, 2, 0):
inp = getattr(mod.properties.inputs, identifier, None)
if inp is None or not hasattr(inp, 'type'):
return False
return any(item.identifier == 'ATTRIBUTE'
for item in inp.bl_rna.properties['type'].enum_items)
return f'{identifier}_use_attribute' in mod.keys()
def get_input_is_attribute(mod: NodesModifier, identifier: str) -> bool:
"""Return True if this input is currently being driven by an attribute field."""
if bpy.app.version >= (5, 2, 0):
return getattr(mod.properties.inputs, identifier).type == 'ATTRIBUTE'
return mod[f'{identifier}_use_attribute']
def set_input_is_attribute(mod: NodesModifier, identifier: str, value: bool):
if bpy.app.version >= (5, 2, 0):
getattr(mod.properties.inputs, identifier).type = 'ATTRIBUTE' if value else 'VALUE'
else:
mod[f'{identifier}_use_attribute'] = value
def set_attribute_name(mod: NodesModifier, identifier: str, name: str):
if bpy.app.version >= (5, 2, 0):
getattr(mod.properties.inputs, identifier).attribute_name = name
else:
mod[f'{identifier}_attribute_name'] = name
def get_enum_value_to_compare(mod: NodesModifier, identifier: str) -> list[tuple[str, str | int]]:
"""Return list of (identifier_str, comparable_value) pairs for a Menu socket.
The comparable_value matches whatever get_value() returns for the same socket:
an identifier string on 5.2+ (where .value is a string), an integer on older versions."""
if bpy.app.version >= (5, 2, 0):
inp = getattr(mod.properties.inputs, identifier)
return [(item.identifier, item.identifier)
for item in inp.bl_rna.properties['value'].enum_items]
return [(item[0], item[4])
for item in mod.id_properties_ui(identifier).as_dict()['items']]
def copy_inputs(source_mod: NodesModifier, target_mod: NodesModifier):
"""Copy all GeoNodes input values (and attribute settings) between modifiers."""
if bpy.app.version >= (5, 2, 0):
props = source_mod.properties
if not props:
return
target_props = target_mod.properties
for socket_name in props.inputs.keys():
source_socket = getattr(props.inputs, socket_name, None)
target_socket = getattr(target_props.inputs, socket_name, None)
if source_socket and target_socket and hasattr(source_socket, "value"):
target_socket.value = source_socket.value
else:
for key, value in source_mod.items():
try:
target_mod[key] = value
except (TypeError, ValueError):
target_mod[key] = type(target_mod[key])(value)
def draw_socket(
layout: UILayout,
mod: NodesModifier,
v: NodeTreeInterfaceSocket,
label: str,
is_preset: bool,
):
"""Draw a GeoNodes input socket: value widget plus attribute toggle if supported."""
if can_input_be_attribute(mod, v.identifier) and not v.force_non_field:
if get_input_is_attribute(mod, v.identifier):
_draw_attribute_name(layout, mod, v.identifier, text=label)
else:
_draw_input_simple(layout, mod, v.identifier, text=label)
operator_id = ('brushstroke_tools.preset_toggle_attribute' if is_preset
else 'brushstroke_tools.brushstrokes_toggle_attribute')
toggle = layout.operator(operator_id, text='',
depress=get_input_is_attribute(mod, v.identifier),
icon='SPREADSHEET')
toggle.modifier_name = mod.name
toggle.input_name = v.identifier
else:
socket_type = type(v)
icon = _ICON_FOR_SOCKET.get(socket_type, 'NONE')
search_prop = _DATA_COLLECTION_FOR_SOCKET.get(socket_type)
_draw_input_simple(
layout, mod, v.identifier,
search_prop=search_prop,
text=label, icon=icon,
)
def _draw_attribute_name(
layout: UILayout,
mod: NodesModifier,
identifier: str,
**kwargs,
):
"""Draw a modifier input's attribute-name field."""
if bpy.app.version >= (5, 2, 0):
layout.prop(getattr(mod.properties.inputs, identifier), 'attribute_name', **kwargs)
else:
layout.prop(mod, f'["{identifier}_attribute_name"]', **kwargs)
def _draw_input_simple(
layout: UILayout,
mod: NodesModifier,
identifier: str,
search_prop: str | None = None,
**kwargs,
):
"""Draw a modifier input value widget.
Pass search_prop for prop_search on Blender < 4.2.
Later versions use pointers, so no need for prop_search.
"""
if bpy.app.version >= (5, 2, 0):
layout.prop(getattr(mod.properties.inputs, identifier), 'value', **kwargs)
elif search_prop:
layout.prop_search(mod, f'["{identifier}"]', bpy.data, search_prop, **kwargs)
else:
layout.prop(mod, f'["{identifier}"]', **kwargs)
+81 -79
View File
@@ -4,7 +4,7 @@
import bpy
import random
from . import utils, settings
from . import utils, settings, geomod
import mathutils
class BSBST_OT_new_brushstrokes(bpy.types.Operator):
@@ -64,7 +64,7 @@ class BSBST_OT_new_brushstrokes(bpy.types.Operator):
# link to surface object's collections (fall back to active collection if all are linked data)
utils.link_to_collections_by_ref(flow_object, surface_object, unlink=False)
visibility_options = [
'visible_camera',
'visible_diffuse',
@@ -85,8 +85,8 @@ class BSBST_OT_new_brushstrokes(bpy.types.Operator):
utils.mark_socket_context_type(mod_info, 'Socket_2', 'SURFACE_OBJECT')
mod['Socket_2'] = surface_object
mod['Socket_3'] = False
geomod.set_value(mod, 'Socket_2', surface_object)
geomod.set_value(mod, 'Socket_3', False)
utils.set_deformable(flow_object, settings.deforming_surface)
utils.set_animated(flow_object, settings.animated)
@@ -94,7 +94,7 @@ class BSBST_OT_new_brushstrokes(bpy.types.Operator):
def main(self, context):
settings = context.scene.BSBST_settings
utils.ensure_resources()
if not context.mode == 'OBJECT':
bpy.ops.object.mode_set(mode='OBJECT')
@@ -135,7 +135,7 @@ class BSBST_OT_new_brushstrokes(bpy.types.Operator):
if not settings.preset_object:
bpy.ops.brushstroke_tools.init_preset()
# assign preset material
preset_material = getattr(settings.preset_object, '["BSBST_material"]', None)
@@ -153,7 +153,7 @@ class BSBST_OT_new_brushstrokes(bpy.types.Operator):
# transfer preset modifiers to new brushstrokes TODO: refactor to deduplicate
for mod in settings.preset_object.modifiers:
utils.transfer_modifier(mod.name, brushstrokes_object, settings.preset_object)
for v in mod.node_group.interface.items_tree.values():
if type(v) not in utils.linkable_sockets:
continue
@@ -161,25 +161,26 @@ class BSBST_OT_new_brushstrokes(bpy.types.Operator):
continue
# initialize linked context parameters
link_context_type = settings.preset_object.modifier_info[mod.name].socket_info[v.identifier].link_context_type
bs_mod = brushstrokes_object.modifiers[mod.name]
if link_context_type=='SURFACE_OBJECT':
brushstrokes_object.modifiers[mod.name][f'{v.identifier}'] = surface_object
geomod.set_value(bs_mod, v.identifier, surface_object)
elif link_context_type=='FLOW_OBJECT':
brushstrokes_object.modifiers[mod.name][f'{v.identifier}'] = flow_object
geomod.set_value(bs_mod, v.identifier, flow_object)
elif link_context_type=='MATERIAL':
brushstrokes_object.modifiers[mod.name][f'{v.identifier}'] = settings.context_material
geomod.set_value(bs_mod, v.identifier, settings.context_material)
elif link_context_type=='UVMAP':
if type(brushstrokes_object.modifiers[mod.name][f'{v.identifier}']) == str:
brushstrokes_object.modifiers[mod.name][f'{v.identifier}'] = surface_object.data.uv_layers.active.name
if isinstance(geomod.get_value(bs_mod, v.identifier), str):
geomod.set_value(bs_mod, v.identifier, surface_object.data.uv_layers.active.name)
else:
brushstrokes_object.modifiers[mod.name][f'{v.identifier}_use_attribute'] = True
brushstrokes_object.modifiers[mod.name][f'{v.identifier}_attribute_name'] = surface_object.data.uv_layers.active.name
geomod.set_input_is_attribute(bs_mod, v.identifier, True)
geomod.set_attribute_name(bs_mod, v.identifier, surface_object.data.uv_layers.active.name)
elif link_context_type=='RANDOM':
vmin = v.min_value
vmax = v.max_value
val = vmin + random.random() * (vmax - vmin)
brushstrokes_object.modifiers[mod.name][f'{v.identifier}_use_attribute'] = False
brushstrokes_object.modifiers[mod.name][f'{v.identifier}'] = type(brushstrokes_object.modifiers[mod.name][f'{v.identifier}'])(val)
geomod.set_input_is_attribute(bs_mod, v.identifier, False)
geomod.set_value(bs_mod, v.identifier, type(geomod.get_value(bs_mod, v.identifier))(val))
# transfer modifier info data from preset to brush strokes
utils.deep_copy_mod_info(settings.preset_object, brushstrokes_object)
@@ -195,14 +196,14 @@ class BSBST_OT_new_brushstrokes(bpy.types.Operator):
mod = brushstrokes_object.modifiers['Brushstrokes']
if settings.brushstroke_method == 'SURFACE_FILL':
# set density
mod['Socket_7'] = utils.round_n((1000 / surf_est) ** 0.5, 2)
geomod.set_value(mod, 'Socket_7', utils.round_n((1000 / surf_est) ** 0.5, 2))
# set length
mod['Socket_11'] = utils.round_n(bb_radius * 0.5, 2)
geomod.set_value(mod, 'Socket_11', utils.round_n(bb_radius * 0.5, 2))
# set width
mod['Socket_13'] = utils.round_n(bb_radius * 0.05, 2)
geomod.set_value(mod, 'Socket_13', utils.round_n(bb_radius * 0.05, 2))
elif settings.brushstroke_method == 'SURFACE_DRAW':
# set width
mod['Socket_5'] = utils.round_n(bb_radius * 0.05, 2)
geomod.set_value(mod, 'Socket_5', utils.round_n(bb_radius * 0.05, 2))
# refresh UI
for mod in brushstrokes_object.modifiers:
@@ -213,7 +214,7 @@ class BSBST_OT_new_brushstrokes(bpy.types.Operator):
for v in mod.node_group.interface.items_tree.values():
if type(v) != bpy.types.NodeTreeInterfaceSocketMaterial:
continue
mat = mod[v.identifier]
mat = geomod.get_value(mod, v.identifier)
if not mat:
continue
if mat in [m_slot.material for m_slot in brushstrokes_object.material_slots]:
@@ -223,13 +224,13 @@ class BSBST_OT_new_brushstrokes(bpy.types.Operator):
with context.temp_override(**override):
bpy.ops.object.material_slot_add()
brushstrokes_object.material_slots[-1].material = mat
# set deformable
set_brushstrokes_deformable(brushstrokes_object, settings.deforming_surface)
# set animated
set_brushstrokes_animated(brushstrokes_object, settings.animated)
for mod in brushstrokes_object.modifiers:
mod.show_group_selector = False
@@ -239,7 +240,7 @@ class BSBST_OT_new_brushstrokes(bpy.types.Operator):
if name == brushstrokes_object.name:
settings.active_context_brushstrokes_index = i
break
utils.edit_active_brushstrokes(context)
return {"FINISHED"}
@@ -247,10 +248,10 @@ class BSBST_OT_new_brushstrokes(bpy.types.Operator):
settings = context.scene.BSBST_settings
settings.silent_switch = True
state = self.main(context)
settings.silent_switch = False
return state
class BSBST_OT_edit_brushstrokes(bpy.types.Operator):
"""
Enter the editing context for the active context brushstrokes.
@@ -269,10 +270,10 @@ class BSBST_OT_edit_brushstrokes(bpy.types.Operator):
settings = context.scene.BSBST_settings
settings.silent_switch = True
state = utils.edit_active_brushstrokes(context)
settings.silent_switch = False
return state
class BSBST_OT_delete_brushstrokes(bpy.types.Operator):
"""
Delete the active context brushstrokes
@@ -324,7 +325,7 @@ class BSBST_OT_delete_brushstrokes(bpy.types.Operator):
settings.edit_toggle = edit_toggle
return {'FINISHED'}
class BSBST_OT_duplicate_brushstrokes(bpy.types.Operator):
"""
Duplicate the active context brushstrokes
@@ -345,7 +346,7 @@ class BSBST_OT_duplicate_brushstrokes(bpy.types.Operator):
bs_ob = utils.get_active_context_brushstrokes_object(context.scene)
if not bs_ob:
return {"CANCELLED"}
if not bs_ob.visible_get(view_layer=context.view_layer):
self.report({"WARNING"}, f"Skipped Brushstroke layer '{bs_ob.name}' because it is invisible in this context")
return {"CANCELLED"}
@@ -354,7 +355,7 @@ class BSBST_OT_duplicate_brushstrokes(bpy.types.Operator):
if context.mode != 'OBJECT':
bpy.ops.object.mode_set(mode='OBJECT')
bpy.context.view_layer.objects.active = bs_ob
if context.mode != 'OBJECT':
bpy.ops.object.mode_set(mode='OBJECT')
@@ -371,7 +372,7 @@ class BSBST_OT_duplicate_brushstrokes(bpy.types.Operator):
if not mod.type=='NODES':
continue
if not mod.node_group:
continue
continue
for v in mod.node_group.interface.items_tree.values():
if type(v) not in utils.linkable_sockets:
continue
@@ -383,7 +384,7 @@ class BSBST_OT_duplicate_brushstrokes(bpy.types.Operator):
vmin = v.min_value
vmax = v.max_value
val = vmin + random.random() * (vmax - vmin)
mod[f'{v.identifier}'] = type(ob.modifiers[mod.name][f'{v.identifier}'])(val)
geomod.set_value(mod, v.identifier, type(geomod.get_value(mod, v.identifier))(val))
return {'FINISHED'}
@@ -459,7 +460,7 @@ class BSBST_OT_copy_brushstrokes(bpy.types.Operator):
ob.parent = surface_object
utils.set_surface_object(ob, surface_object)
for mod in ob.modifiers:
if not mod.type:
continue
@@ -475,27 +476,28 @@ class BSBST_OT_copy_brushstrokes(bpy.types.Operator):
continue
# re-initialize linked context parameters
link_context_type = ob.modifier_info[mod.name].socket_info[v.identifier].link_context_type
ob_mod = ob.modifiers[mod.name]
if link_context_type=='SURFACE_OBJECT':
ob.modifiers[mod.name][f'{v.identifier}'] = surface_object
geomod.set_value(ob_mod, v.identifier, surface_object)
elif link_context_type=='UVMAP':
if type(ob.modifiers[mod.name][f'{v.identifier}']) == str:
ob.modifiers[mod.name][f'{v.identifier}'] = surface_object.data.uv_layers.active.name
if isinstance(geomod.get_value(ob_mod, v.identifier), str):
geomod.set_value(ob_mod, v.identifier, surface_object.data.uv_layers.active.name)
else:
ob.modifiers[mod.name][f'{v.identifier}_use_attribute'] = True
ob.modifiers[mod.name][f'{v.identifier}_attribute_name'] = surface_object.data.uv_layers.active.name
geomod.set_input_is_attribute(ob_mod, v.identifier, True)
geomod.set_attribute_name(ob_mod, v.identifier, surface_object.data.uv_layers.active.name)
elif link_context_type=='RANDOM':
vmin = v.min_value
vmax = v.max_value
val = vmin + random.random() * (vmax - vmin)
ob.modifiers[mod.name][f'{v.identifier}_use_attribute'] = False
ob.modifiers[mod.name][f'{v.identifier}'] = type(ob.modifiers[mod.name][f'{v.identifier}'])(val)
geomod.set_input_is_attribute(ob_mod, v.identifier, False)
geomod.set_value(ob_mod, v.identifier, type(geomod.get_value(ob_mod, v.identifier))(val))
# enable rest position
surface_object.add_rest_position_attribute = True
return {'FINISHED'}
class BSBST_OT_select_surface(bpy.types.Operator):
"""
Select the surface object for the active context brushstrokes.
@@ -547,7 +549,7 @@ class BSBST_OT_assign_surface(bpy.types.Operator):
bs_ob = utils.get_active_context_brushstrokes_object(context.scene)
if not bs_ob:
return {"CANCELLED"}
surface_object = bpy.data.objects.get(self.surface_object)
if not surface_object:
@@ -577,11 +579,11 @@ def set_brushstrokes_deformable(bs_ob, deformable):
if not mod.node_group:
continue
if mod.node_group.name == '.brushstroke_tools.pre_processing':
mod['Socket_3'] = deformable
geomod.set_value(mod, 'Socket_3', deformable)
elif mod.node_group.name == '.brushstroke_tools.surface_fill':
mod['Socket_27'] = deformable
geomod.set_value(mod, 'Socket_27', deformable)
elif mod.node_group.name == '.brushstroke_tools.surface_draw':
mod['Socket_15'] = deformable
geomod.set_value(mod, 'Socket_15', deformable)
mod.node_group.interface_update(bpy.context)
utils.set_deformable(bs_ob, deformable)
@@ -601,7 +603,7 @@ def set_brushstrokes_animated(bs_ob, animated):
if not mod:
mod = ob.modifiers.new('Animation', 'NODES')
mod.node_group = utils.ensure_node_group('.brushstroke_tools.animation')
mod_info = ob.modifier_info.get(mod.name)
if not mod_info:
mod_info = ob.modifier_info.add()
@@ -610,7 +612,7 @@ def set_brushstrokes_animated(bs_ob, animated):
# ui visibility settings
mod_info.hide_ui = True
else:
mod['Socket_5'] = True
geomod.set_value(mod, 'Socket_5', True)
mod.node_group.interface_update(bpy.context)
with bpy.context.temp_override(object=ob):
bpy.ops.object.modifier_move_to_index(modifier=mod.name, index=0)
@@ -632,7 +634,7 @@ class BSBST_OT_copy_flow(bpy.types.Operator):
source_bs: bpy.props.StringProperty(name="Brushstroke Layers")
bs_list: bpy.props.CollectionProperty(type=settings.BSBST_context_brushstrokes)
@classmethod
def poll(cls, context):
settings = context.scene.BSBST_settings
@@ -643,7 +645,7 @@ class BSBST_OT_copy_flow(bpy.types.Operator):
flow_ob_old = utils.get_flow_object(bs_ob)
if not bs_ob:
return {"CANCELLED"}
# get source
source_ob = bpy.data.objects.get(self.source_bs)
@@ -738,7 +740,7 @@ class BSBST_OT_switch_deformable(bpy.types.Operator):
if ob.type == 'GREASEPENCIL':
self.report({"WARNING"}, "Grease Pencil does not currently support drawing on deformable surface geometry.")
set_brushstrokes_deformable(ob, self.deformable)
context.view_layer.depsgraph.update()
return {"FINISHED"}
@@ -775,11 +777,11 @@ class BSBST_OT_switch_animated(bpy.types.Operator):
for ob in bs_objects:
set_brushstrokes_animated(ob, self.animated)
context.view_layer.depsgraph.update()
return {"FINISHED"}
class BSBST_OT_init_preset(bpy.types.Operator):
"""
Initialize the preset to define a modifier stack applied to new brushstrokess.
@@ -793,7 +795,7 @@ class BSBST_OT_init_preset(bpy.types.Operator):
def poll(cls, context):
settings = context.scene.BSBST_settings
return settings.preset_object is None
def init_fill(self, context):
settings = context.scene.BSBST_settings
@@ -803,7 +805,7 @@ class BSBST_OT_init_preset(bpy.types.Operator):
## input
mod = preset_object.modifiers.new('Surface Input', 'NODES')
mod.node_group = bpy.data.node_groups['.brushstroke_tools.geometry_input']
mod_info = settings.preset_object.modifier_info.get(mod.name)
if not mod_info:
mod_info = settings.preset_object.modifier_info.add()
@@ -820,7 +822,7 @@ class BSBST_OT_init_preset(bpy.types.Operator):
## masking
mod = preset_object.modifiers.new('Masking', 'NODES')
mod.node_group = bpy.data.node_groups['.brushstroke_tools.mask_surface']
mod_info = settings.preset_object.modifier_info.get(mod.name)
if not mod_info:
mod_info = settings.preset_object.modifier_info.add()
@@ -838,7 +840,7 @@ class BSBST_OT_init_preset(bpy.types.Operator):
## brushstrokes
mod = preset_object.modifiers.new('Brushstrokes', 'NODES')
mod.node_group = bpy.data.node_groups['.brushstroke_tools.surface_fill']
mod_info = settings.preset_object.modifier_info.get(mod.name)
if not mod_info:
mod_info = settings.preset_object.modifier_info.add()
@@ -846,7 +848,7 @@ class BSBST_OT_init_preset(bpy.types.Operator):
# set fill custom defaults
mod['Socket_59'] = 1 # color method: curves
geomod.set_value(mod, 'Socket_59', 'Curves') # color method: curves
# context link settings
utils.mark_socket_context_type(mod_info, 'Socket_2', 'FLOW_OBJECT')
@@ -879,7 +881,7 @@ class BSBST_OT_init_preset(bpy.types.Operator):
]
for p in hide_panels:
utils.mark_panel_hidden(mod_info, p)
def init_draw(self, context):
settings = context.scene.BSBST_settings
@@ -889,7 +891,7 @@ class BSBST_OT_init_preset(bpy.types.Operator):
## add pre-processing modifier
mod = preset_object.modifiers.new('Pre-Processing', 'NODES')
mod.node_group = bpy.data.node_groups['.brushstroke_tools.pre_processing']
mod_info = settings.preset_object.modifier_info.get(mod.name)
if not mod_info:
mod_info = settings.preset_object.modifier_info.add()
@@ -903,7 +905,7 @@ class BSBST_OT_init_preset(bpy.types.Operator):
## brushstrokes
mod = preset_object.modifiers.new('Brushstrokes', 'NODES')
mod.node_group = bpy.data.node_groups['.brushstroke_tools.surface_draw']
mod_info = settings.preset_object.modifier_info.get(mod.name)
if not mod_info:
mod_info = settings.preset_object.modifier_info.add()
@@ -937,7 +939,7 @@ class BSBST_OT_init_preset(bpy.types.Operator):
def execute(self, context):
settings = context.scene.BSBST_settings
utils.ensure_resources()
preset_name = f'BSBST-PRESET_{settings.brushstroke_method}'
preset_object = bpy.data.objects.new(preset_name, bpy.data.hair_curves.new(preset_name))
@@ -947,7 +949,7 @@ class BSBST_OT_init_preset(bpy.types.Operator):
self.init_fill(context)
elif settings.brushstroke_method == "SURFACE_DRAW":
self.init_draw(context)
# select preset material
mat = bpy.data.materials.get('Brush Material')
if not mat:
@@ -982,7 +984,7 @@ class BSBST_OT_make_preset(bpy.types.Operator):
else:
for mod in settings.preset_object.modifiers[:]:
settings.preset_object.modifiers.remove(mod)
# transfer brushstrokes modifiers to preset
bs_ob = utils.get_active_context_brushstrokes_object(context.scene)
if not bs_ob:
@@ -994,7 +996,7 @@ class BSBST_OT_make_preset(bpy.types.Operator):
for mod in settings.preset_object.modifiers:
# refresh UI
mod.node_group.interface_update(context)
# identify linked sockets
for v in mod.node_group.interface.items_tree.values():
if type(v) not in utils.linkable_sockets:
@@ -1002,8 +1004,8 @@ class BSBST_OT_make_preset(bpy.types.Operator):
if not settings.preset_object.modifier_info[mod.name].socket_info[v.identifier]:
continue
if type(v) == bpy.types.NodeTreeInterfaceSocketObject:
if bs_ob['BSBST_surface_object']==mod[v.identifier]:
mod[v.identifier] = None
if bs_ob['BSBST_surface_object']==geomod.get_value(mod, v.identifier):
geomod.set_value(mod, v.identifier, None)
settings.preset_object.modifier_info[mod.name].socket_info[v.identifier].link_context = True
elif type(v) == bpy.types.NodeTreeInterfaceSocketMaterial:
pass # TODO: figure out material preset linking
@@ -1109,14 +1111,14 @@ class BSBST_OT_brushstrokes_toggle_attribute(bpy.types.Operator):
if not bs_ob:
settings.edit_toggle = edit_toggle
return {"CANCELLED"}
override = context.copy()
override['object'] = bs_ob
with context.temp_override(**override):
bpy.ops.object.geometry_nodes_input_attribute_toggle(input_name=self.input_name,
modifier_name=self.modifier_name)
return {"FINISHED"}
class BSBST_OT_render_setup(bpy.types.Operator):
"""
Set up render settings.
@@ -1165,7 +1167,7 @@ class BSBST_OT_render_setup(bpy.types.Operator):
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self)
class BSBST_OT_view_all(bpy.types.Operator):
"""
Enable/disable all brushstrokes for the viewport.
@@ -1299,11 +1301,11 @@ class BSBST_OT_select_brush_style(bpy.types.Operator):
if bs.name == settings.context_material.brush_style:
self.brush_styles_filtered_active_index = i
break
self.name_filter = ''
return context.window_manager.invoke_props_dialog(self, width=450)
class BSBST_OT_upgrade_resources(bpy.types.Operator):
""" Upgrade all local BST assets to available addon resources.
"""
@@ -1331,12 +1333,12 @@ class BSBST_OT_upgrade_resources(bpy.types.Operator):
layout = self.layout
split = layout.split(factor=.6)
col = split.column()
col.prop(self, 'upgrade_shape_modifiers', icon='MODIFIER')
col.prop(self, 'upgrade_materials', icon='MATERIAL')
col.prop(self, 'upgrade_brush_styles', icon='BRUSHES_ALL')
col = split.column()
row = col.row()
row.active = self.upgrade_shape_modifiers
@@ -1422,8 +1424,8 @@ classes = [
def register():
for c in classes:
bpy.utils.register_class(c)
bpy.utils.register_class(c)
def unregister():
for c in reversed(classes):
bpy.utils.unregister_class(c)
bpy.utils.unregister_class(c)
+78 -112
View File
@@ -3,8 +3,15 @@
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from . import utils
from . import utils, geomod
from . import settings as settings_py
from bpy.types import (
UILayout,
NodesModifier,
NodeTreeInterfaceItem,
NodeTreeInterfacePanel,
NodeTreeInterfaceSocketMenu,
)
warning_icons_dict = {
'ERROR': 'CANCEL',
@@ -12,133 +19,92 @@ warning_icons_dict = {
'INFO': 'INFO',
}
def draw_panel_ui_recursive(panel, panel_name, mod, items, display_mode, hide_panel=False):
scene = bpy.context.scene
settings = scene.BSBST_settings
is_preset = mod.id_data == settings.preset_object and mod.id_data
def draw_panel_ui_recursive(
panel: UILayout | None,
panel_name: str,
mod: NodesModifier,
interface_items: tuple[str, NodeTreeInterfaceItem],
display_mode: int,
hide_panel: bool = False,
):
if not panel:
return
mod_info = mod.id_data.modifier_info.get(mod.name)
icon_dict = {
bpy.types.NodeTreeInterfaceSocketObject: 'OBJECT_DATA',
bpy.types.NodeTreeInterfaceSocketMaterial: 'MATERIAL',
bpy.types.NodeTreeInterfaceSocketImage: 'IMAGE_DATA',
bpy.types.NodeTreeInterfaceSocketCollection: 'OUTLINER_COLLECTION',
}
if not mod_info:
return
data_dict = {
bpy.types.NodeTreeInterfaceSocketMaterial: 'materials',
bpy.types.NodeTreeInterfaceSocketImage: 'images',
bpy.types.NodeTreeInterfaceSocketCollection: 'collections',
}
panel_active = not (mod_info.hide_ui or hide_panel)
inactive_mode_names: list[str] = []
mode_compare = []
for k, v in items:
if not v.parent.name == panel_name:
for name, interface_item in interface_items:
if interface_item.parent.name != panel_name:
continue
if type(v) == bpy.types.NodeTreeInterfacePanel:
v_id = f'Panel_{v.index}' # TODO: replace with panel identifier once that is exposed in Blender 4.3
if not mod_info:
if isinstance(interface_item, NodeTreeInterfacePanel):
panel_id = f'Panel_{interface_item.index}' # TODO: replace with panel identifier once exposed in Blender 4.3
socket_info = mod_info.socket_info.get(panel_id)
if not socket_info:
continue
s = mod_info.socket_info.get(v_id)
if not s:
if display_mode == 0 and socket_info.hide_ui:
continue
if display_mode == 0:
if s.hide_ui:
continue
subpanel_header, subpanel = panel.panel(k, default_closed = v.default_closed)
subpanel_header.label(text=k)
subpanel_header, subpanel = panel.panel(name, default_closed=interface_item.default_closed)
subpanel_header.label(text=name)
if display_mode != 0:
col = subpanel_header.column()
col.active = not (mod_info.hide_ui or hide_panel)
col.prop(s, 'hide_ui', icon_only=True, icon='UNPINNED' if s.hide_ui else 'PINNED', emboss=False)
draw_panel_ui_recursive(subpanel, k, mod, v.interface_items.items(), display_mode, s.hide_ui)
mode_compare = []
col.active = panel_active
col.prop(socket_info, 'hide_ui', icon_only=True,
icon='UNPINNED' if socket_info.hide_ui else 'PINNED', emboss=False)
draw_panel_ui_recursive(subpanel, name, mod, interface_item.interface_items.items(), display_mode, socket_info.hide_ui)
inactive_mode_names = []
else:
if v.parent.name != panel_name:
if not geomod.is_input_value_settable(mod, interface_item.identifier):
continue
if f'{v.identifier}' not in mod.keys():
continue
if not mod_info:
socket_info = mod_info.socket_info.get(interface_item.identifier)
if not socket_info:
continue
if type(v) == bpy.types.NodeTreeInterfaceSocketMenu:
for item in mod.id_properties_ui(f'{v.identifier}').as_dict()['items']:
if item[4] == mod[f'{v.identifier}']:
continue
mode_compare += [item[0]]
if isinstance(interface_item, NodeTreeInterfaceSocketMenu):
current_value = geomod.get_value(mod, interface_item.identifier)
inactive_mode_names += [
mode_id for mode_id, mode_value
in geomod.get_enum_value_to_compare(mod, interface_item.identifier)
if mode_value != current_value
]
s = mod_info.socket_info.get(v.identifier)
if not s:
continue
if display_mode == 0:
comp_match = False
for c in mode_compare:
comp_match = c in v.name
if comp_match:
break
if comp_match:
if socket_info.hide_ui:
continue
if s.hide_ui:
if any(mode_name in interface_item.name for mode_name in inactive_mode_names):
continue
row = panel.row(align=True)
row.active = not (mod_info.hide_ui or hide_panel or s.hide_ui)
row.active = not (mod_info.hide_ui or hide_panel or socket_info.hide_ui)
col = row.column()
input_row = col.row(align=True)
attribute_toggle = False
if f'{v.identifier}_use_attribute' in mod.keys() and not v.force_non_field:
attribute_toggle = mod[f'{v.identifier}_use_attribute']
if attribute_toggle:
input_row.prop(mod, f'["{v.identifier}_attribute_name"]', text=k)
else:
input_row.prop(mod, f'["{v.identifier}"]', text=k)
if is_preset:
toggle = input_row.operator('brushstroke_tools.preset_toggle_attribute',
text='',
depress=mod[f'{v.identifier}_use_attribute'],
icon='SPREADSHEET')
else:
toggle = input_row.operator('brushstroke_tools.brushstrokes_toggle_attribute',
text='',
depress=mod[f'{v.identifier}_use_attribute'],
icon='SPREADSHEET')
toggle.modifier_name = mod.name
toggle.input_name = v.identifier
else:
if type(v) in icon_dict.keys():
icon = icon_dict[type(v)]
else:
icon='NONE'
if type(v) in data_dict.keys():
input_row.prop_search(mod, f'["{v.identifier}"]', bpy.data, data_dict[type(v)], text=k, icon=icon)
else:
input_row.prop(mod, f'["{v.identifier}"]', text=k, icon=icon)
if type(v) in utils.linkable_sockets:
col.active = not s.link_context
icon = settings_py.icon_from_link_type(s.link_context_type)
settings = bpy.context.scene.BSBST_settings
is_preset = bool(mod.id_data and mod.id_data == settings.preset_object)
geomod.draw_socket(col.row(align=True), mod, interface_item, name, is_preset)
if type(interface_item) in utils.linkable_sockets:
col.active = not socket_info.link_context
row.alignment = 'EXPAND'
if s.link_context:
row.prop(s, 'link_context', text='', icon_value=icon)
else:
if display_mode == -1:
row.prop(s, 'link_context_type', text='', emboss=True, icon='LINKED', icon_only=True)
if socket_info.link_context:
row.prop(socket_info, 'link_context', text='',
icon_value=settings_py.icon_from_link_type(socket_info.link_context_type))
elif display_mode == -1:
row.prop(socket_info, 'link_context_type', text='', emboss=True, icon='LINKED', icon_only=True)
if display_mode != 0:
col = row.column()
col.active = not (mod_info.hide_ui or hide_panel)
col.prop(s, 'hide_ui', icon_only=True, icon='UNPINNED' if s.hide_ui else 'PINNED', emboss=False)
col.active = panel_active
icon = 'UNPINNED' if socket_info.hide_ui else 'PINNED'
col.prop(socket_info, 'hide_ui', icon_only=True, icon=icon, emboss=False)
def draw_material_settings(layout, material, surface_object=None):
addon_prefs = bpy.context.preferences.addons[__package__].preferences
settings = bpy.context.scene.BSBST_settings
def draw_material_settings(context, layout, material, surface_object=None):
addon_prefs = context.preferences.addons[__package__].preferences
settings = context.scene.BSBST_settings
material_row = layout.row(align=True)
material_row.template_ID(settings, 'context_material')
@@ -298,7 +264,7 @@ def draw_effect_panel_recursive(effects_panel, material, prev_node):
panel.active = False
for input in node.inputs[1:]:
panel.prop(input, 'default_value', text=input.name)
draw_effect_panel_recursive(effects_panel, material, node)
def draw_advanced_settings(layout, settings):
@@ -309,7 +275,7 @@ def draw_advanced_settings(layout, settings):
new_advanced_panel.row().prop(settings, 'curve_mode', expand=True)
if settings.curve_mode in ['CURVE', 'GP']:
new_advanced_panel.label(text='Curve mode does not support drawing on deformed geometry', icon='ERROR')
new_advanced_panel.prop(settings, 'animated')
new_advanced_panel.prop(settings, 'deforming_surface')
new_advanced_panel.prop(settings, 'assign_materials')
@@ -329,7 +295,7 @@ def draw_shape_properties(layout, settings, style_object, is_preset, display_mod
if display_mode == 0:
if mod_info.hide_ui:
continue
mod_header, mod_panel = layout.panel(mod.name, default_closed = mod_info.default_closed)
row = mod_header.row(align=True)
row.label(text='', icon='GEOMETRY_NODES')
@@ -362,12 +328,12 @@ def draw_shape_properties(layout, settings, style_object, is_preset, display_mod
mod,
mod.node_group.interface.items_tree.items(),
display_mode)
draw_mod_warnings(layout, mod)
def draw_material_properties(layout, settings, surface_object):
def draw_material_properties(context, layout, settings, surface_object):
if settings.context_material:
draw_material_settings(layout, settings.context_material, surface_object=surface_object)
draw_material_settings(context, layout, settings.context_material, surface_object=surface_object)
else:
material_row = layout.row(align=True)
material_row.template_ID(settings, 'context_material', new='brushstroke_tools.new_material')
@@ -383,7 +349,7 @@ def draw_settings_properties(layout, settings, style_object):
layout.prop(style_object, 'visible_shadow', icon='LIGHT', emboss=True)
def draw_properties_panel(layout, settings, style_object, surface_object, is_preset, display_mode):
def draw_properties_panel(context, layout, settings, style_object, surface_object, is_preset, display_mode):
layout.separator(type='LINE')
row = layout.row(align=True)
@@ -391,7 +357,7 @@ def draw_properties_panel(layout, settings, style_object, surface_object, is_pre
layout.separator(factor=.0, type='SPACE')
if settings.view_tab == 'MATERIAL':
draw_material_properties(layout, settings, surface_object)
draw_material_properties(context, layout, settings, surface_object)
elif settings.view_tab == 'SHAPE':
draw_shape_properties(layout, settings, style_object, is_preset, display_mode)
@@ -441,7 +407,7 @@ class BSBST_MT_bs_context_menu(bpy.types.Menu):
def draw(self, _context):
layout = self.layout
op = layout.operator('brushstroke_tools.copy_brushstrokes', text='Copy to Selected Objects')
op.copy_all = False
@@ -545,7 +511,7 @@ class BSBST_PT_brushstroke_tools_panel(bpy.types.Panel):
if not settings.preset_object and is_preset:
layout.operator("brushstroke_tools.init_preset", icon='MODIFIER')
else:
draw_properties_panel(style_panel, settings, style_object, surface_object, is_preset, display_mode)
draw_properties_panel(context, style_panel, settings, style_object, surface_object, is_preset, display_mode)
class BSBST_MT_PIE_brushstroke_data_marking(bpy.types.Menu):
bl_idname= "BSBST_MT_PIE_brushstroke_data_marking"
@@ -607,7 +573,7 @@ def register():
wm = bpy.context.window_manager
if wm.keyconfigs.addon is not None:
km = wm.keyconfigs.addon.keymaps.new(name="Mesh")
kmi = km.keymap_items.new("brushstroke_tools.data_marking","F", "PRESS",shift=False, ctrl=True, alt=True)
kmi = km.keymap_items.new("brushstroke_tools.data_marking","F", "PRESS",shift=False, ctrl=True, alt=True)
def unregister():
for c in reversed(classes):
@@ -10,6 +10,7 @@ from bpy.app.handlers import persistent
import math, shutil, errno, numpy
from bpy.app.handlers import persistent
from mathutils import Vector
from . import geomod
addon_version = (0,0,0)
@@ -36,6 +37,7 @@ linkable_sockets = [
asset_lib_name = 'Brushstroke Tools Library'
@persistent
def find_context_brushstrokes(scene, depsgraph):
settings = scene.BSBST_settings
@@ -178,7 +180,7 @@ def set_brushstroke_material(ob, material):
continue
if not s.link_context_type == 'MATERIAL':
continue
mod[s.name] = material
geomod.set_value(mod, s.name, material)
ob.update_tag()
if ob.type == 'EMPTY':
@@ -655,11 +657,7 @@ def transfer_modifier(modifier_name, target_obj, source_obj):
if source_mod.type == 'NODES':
# Transfer geo node attributes
for key, value in source_mod.items():
try:
target_mod[key] = value
except (TypeError, ValueError) as e:
target_mod[key] = type(target_mod[key])(value)
geomod.copy_inputs(source_mod, target_mod)
# Transfer geo node bake settings
target_mod.bake_directory = source_mod.bake_directory
@@ -744,7 +742,7 @@ def set_surface_object(bs, surf_ob):
continue
if not s.link_context_type == 'SURFACE_OBJECT':
continue
mod[s.name] = surf_ob
geomod.set_value(mod, s.name, surf_ob)
ob.parent = surf_ob
ob.parent_type = 'OBJECT'
surf_ob.update_tag()
@@ -775,7 +773,7 @@ def set_flow_object(bs, ob):
continue
if not s.link_context_type == 'FLOW_OBJECT':
continue
mod[s.name] = ob
geomod.set_value(mod, s.name, ob)
ob.update_tag()
bs['BSBST_flow_object'] = ob
@@ -1106,8 +1104,18 @@ def match_mat(tgt_mat, src_mat):
for attr_path in path_list:
try:
exec(f'tgt_mat.{attr_path} = src_mat.{attr_path}')
path_parts = attr_path.replace("'", '"').split('.')
attr_name = path_parts[-1]
item_path = ".".join(path_parts[:-1])
if item_path:
tgt_item = tgt_mat.path_resolve(item_path)
src_item = src_mat.path_resolve(item_path)
else:
tgt_item = tgt_mat
src_item = src_mat
setattr(tgt_item, attr_name, getattr(src_item, attr_name))
except:
print(f"Could not copy attribute '{attr_path}' on material '{tgt_mat.name}'")
pass
tgt_curve_node = tgt_mat.node_tree.nodes.get('Brush Curve')
@@ -1,6 +1,6 @@
schema_version = "1.0.0"
id = "datablock_utils"
version = "1.3.0"
version = "1.3.2"
name = "Data-Block Utilities"
tagline = "Show users, merge duplicates, find similar, and more"
maintainer = "Leonardo Pike-Excell <leonardopike.excell@gmail.com>"
@@ -49,6 +49,8 @@ def _generate_id_types() -> dict[str, IDType]:
if bpy.app.version >= (4, 3, 0):
if bpy.app.version >= (5, 0, 0):
collections.remove(next(c for c in collections if c.identifier == 'annotations'))
if bpy.app.version >= (5, 2, 0):
collections.remove(next(c for c in collections if c.identifier == 'all_ids'))
_assign('CURVES', 'hair_curves', enums, collections)
if bpy.app.version >= (5, 0, 0):
_assign('GREASEPENCIL', 'grease_pencils', enums, collections, remove=False)
@@ -24,6 +24,10 @@ def get_settings() -> DBU_PG_FindSimilarSettings:
return bpy.context.scene.dbu_similar_settings # type: ignore
def is_local_id(id_data: bpy.types.ID) -> bool:
return not id_data.library and not id_data.override_library
def get_invalid_nodes(ntree: NodeTree) -> set[Node]:
settings = get_settings()
invalid_nodes = set()
@@ -100,6 +104,24 @@ def get_image_props(img: bpy.types.Image) -> tuple[Any, ...]:
)
def get_light_props(light: bpy.types.Light) -> tuple[Any, ...]:
props: list[Any] = [
tuple(light.color),
light.energy,
light.exposure,
light.use_shadow,
light.normalize,
]
match light.type:
case 'AREA':
props.extend((light.size, light.size_y, light.shape))
case 'SPOT':
props.extend((light.spot_size, light.spot_blend))
case 'SUN':
props.append(light.angle)
return tuple(props)
@dataclass(slots=True)
class NodeProperties:
id_data: Node | NodeTree
@@ -218,7 +240,7 @@ def contents_of_ntrees(
) -> defaultdict[str, list[NodeProperties]]:
content_map = defaultdict(list)
for id_data in bl_data:
if id_data.library or (not isinstance(id_data, NodeTree) and not id_data.use_nodes):
if not is_local_id(id_data) or (not isinstance(id_data, NodeTree) and not id_data.use_nodes):
continue
ntree = id_data if isinstance(id_data, NodeTree) else id_data.node_tree
@@ -238,6 +260,9 @@ def contents_of_ntrees(
tuple(p) if isinstance(p, bpy.types.bpy_prop_array) else p for p in props]
contents.append(props)
if isinstance(id_data, bpy.types.Light) and bpy.app.version >= (5, 1, 0):
contents.append(NodeProperties(id_data, ['LIGHT PROPS', *get_light_props(id_data)]))
if not isinstance(id_data, NodeTree):
continue
@@ -403,7 +428,7 @@ def find_similar_and_duplicate_ntrees(id_type: str) -> None:
def find_duplicate_images() -> None:
duplicates = []
images = [i for i in bpy.data.images if not i.library]
images = [i for i in bpy.data.images if is_local_id(i)]
images.sort(key=get_image_props)
for _, raw_group in groupby(images, get_image_props):
group = [i.name for i in raw_group]
@@ -414,7 +439,7 @@ def find_duplicate_images() -> None:
def find_duplicate_meshes() -> None:
meshes = [m for m in bpy.data.meshes if not m.library]
meshes = [m for m in bpy.data.meshes if is_local_id(m)]
seen = set()
results = []
for m1 in meshes:
@@ -493,6 +518,8 @@ class DBU_OT_SimilarAndDuplicatesClearResults(Operator):
def merge_ids(duplicate_ids: Iterable[Iterable[bpy.types.ID]]) -> int:
to_remove = []
for target, *junk in duplicate_ids:
if not is_local_id(target) or any(not is_local_id(id_data) for id_data in junk):
continue
to_remove.extend(junk)
for id_data in junk:
id_data.user_remap(target)
@@ -154,19 +154,44 @@ class GOVIE_Quick_Export_GLB_Operator(bpy.types.Operator):
# read preset parameters from file
if preset_filepath:
class Container(object):
__slots__ = ("__dict__",)
op = Container()
preset_file = open(preset_filepath, "r")
# storing the values from the preset on the class
for line in preset_file.readlines()[3::]:
exec(line, globals(), locals())
preset_key = line.split("op.")[1].split(" = ")[0]
preset_value = line.split("op.")[1].split(" = ")[1].rstrip()
if preset_value.startswith("'") and preset_value.endswith("'"):
# value is a string
gltf_export_param[preset_key] = preset_value[1:-1]
continue
if preset_value == "True":
# value is a bool
gltf_export_param[preset_key] = True
continue
if preset_value == "False":
# value is a bool
gltf_export_param[preset_key] = False
continue
try:
preset_value_converted = int(preset_value)
# if no error is thrown its an int
gltf_export_param[preset_key] = preset_value_converted
continue
except ValueError:
try:
# should be a float
preset_value_converted = float(preset_value)
gltf_export_param[preset_key] = preset_value_converted
except ValueError:
print(
"Could not convert preset parameter from gltf preset at line: {}".format(
line
)
)
# pass class dictionary to the operator
gltf_export_param = op.__dict__
else:
gltf_export_param["export_extras"] = True
@@ -29,7 +29,7 @@ bl_info = {
"author": "Lorenz Wieseke, 3D Interaction Technologies GmbH (contact@govie.de)",
"description": "Transform your model into a web-optimized GLB file for use in the Govie Editor.",
"blender": (4, 2, 0),
"version": (1, 0, 20),
"version": (1, 0, 22),
"location": "View 3D > Property Panel (N-Key in 3D View)",
"warning": "",
"category": "Scene",
@@ -1,7 +1,7 @@
schema_version = "1.0.0"
id = "govietools"
version = "1.0.20"
version = "1.0.22"
name = "Govie Tools"
tagline = "Optimize your model for use in the Govie Editor"
maintainer = "Stefan Bernstein, 3D Interaction Technologies GmbH <contact@govie.de>"
@@ -1,6 +1,6 @@
[project]
name = "govietools"
version = "1.0.20"
version = "1.0.21"
description = "Optimize your model for use in the Govie Editor"
readme = "README.md"
requires-python = ">=3.11.13"
+8
View File
@@ -0,0 +1,8 @@
version = 1
revision = 2
requires-python = ">=3.11.13"
[[package]]
name = "govietools"
version = "1.0.20"
source = { virtual = "." }
@@ -1,3 +1,5 @@
### Incremental Auto-Save
[Incremental Auto-Save](https://extensions.blender.org/add-ons/incremental-auto-save/) makes Blender's autosaves more configurable and reliable.
![Addon Preferences UI](/docs/incremental_autosave.png "Addon Preferences UI")
@@ -13,4 +15,4 @@
- Save modified images: For texture painting workflows, this will auto-save your images as well as the .blend file.
- Save Mesh/Sculpt: If you spend a long time in Edit/Sculpt mode and Blender crashes, you will lose your work, because the mesh data is not written back into the object until you go to Object Mode. The add-on will periodically enter object mode for you, without interrupting any Sculpt/Paint/Transform operations.
It goes without saying that this add-on can introduce periodic lag. This is simply the price of saving data to a disk. A slow disk or a large file will exacerbate this, and all you can do is adjust your save interval according to what you can put up with.
It goes without saying that this add-on can introduce periodic lag. This is simply the price of saving data to a disk. A slow disk or a large file will exacerbate this, and all you can do is adjust your save interval according to what you can put up with.
@@ -1,10 +1,13 @@
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import annotations
import json
import os
import tempfile
from datetime import datetime
from pathlib import Path
from typing import Any
import bpy
from bl_ui.generic_ui_list import draw_ui_list
@@ -15,15 +18,23 @@ from bpy.props import (
IntProperty,
StringProperty,
)
from bpy.types import AddonPreferences, PropertyGroup, UIList
from bpy.types import (
AddonPreferences,
Context,
PropertyGroup,
UILayout,
UIList,
bpy_prop_collection,
)
from rna_prop_ui import IDPropertyGroup
# The add-on is confirmed to work with Blender 3.5 in 2026.
# As long as that remains true, there's no point removing this bl_info block.
bl_info = {
"name": "Incremental Autosave",
"author": "Demeter Dzadik",
"version": (1, 1, 1),
"blender": (2, 90, 0),
"version": (1, 1, 2),
"blender": (3, 5, 0),
"location": "blender",
"description": "Autosaves in a way where subsequent autosaves don't overwrite previous ones",
"category": "System",
@@ -38,7 +49,14 @@ LAUNCH_TIME = datetime.now()
class INCSAVE_UL_file_paths(UIList):
def draw_item(
self, context, layout, data, item, icon_value, active_data, active_propname
self,
_context: Context,
layout: UILayout,
_data: object,
item: AutoSavePath,
_icon_value: int,
_active_data: object,
_active_propname: str,
):
filepath: AutoSavePath = item
@@ -51,12 +69,14 @@ class INCSAVE_UL_file_paths(UIList):
row.prop(filepath, "path", text="")
def get_addon_prefs(context=None):
def get_addon_prefs(
context: Context | None = None,
) -> IncrementalAutoSavePreferences:
context = context or bpy.context
return context.preferences.addons[__name__].preferences
def update_prefs_on_file(self=None, context=None):
def update_prefs_on_file(_self=None, context: Context | None = None):
prefs = get_addon_prefs(context)
if not type(prefs).loading:
prefs.save_prefs_to_file()
@@ -71,7 +91,7 @@ class PrefsFileSaveLoadMixin:
import bpy, json
from pathlib import Path
class MyAddonPrefs(PrefsFileSaveLoadMixin, bpy.types.AddonPreferences):
class MyAddonPrefs(PrefsFileSaveLoadMixin, AddonPreferences):
some_prop: bpy.props.IntProperty(update=update_prefs_on_file)
def register():
@@ -91,7 +111,7 @@ class PrefsFileSaveLoadMixin:
@staticmethod
def register_autoload_from_file(delay=0.1):
def timer_func(_scene=None):
def timer_func(_scene: object = None):
prefs = get_addon_prefs()
prefs.load_prefs_from_file()
@@ -105,7 +125,7 @@ class PrefsFileSaveLoadMixin:
ret = {}
rna_class = None
if isinstance(propgroup, bpy.types.AddonPreferences):
if isinstance(propgroup, AddonPreferences):
prop_dict = {
key: getattr(propgroup, key)
for key in propgroup.bl_rna.properties.keys()
@@ -113,9 +133,7 @@ class PrefsFileSaveLoadMixin:
}
else:
property_group_class_name = type(propgroup).__name__
rna_class = bpy.types.PropertyGroup.bl_rna_get_subclass_py(
property_group_class_name
)
rna_class = PropertyGroup.bl_rna_get_subclass_py(property_group_class_name)
if not hasattr(rna_class, "properties"):
rna_class = None
prop_dict = {
@@ -127,7 +145,7 @@ class PrefsFileSaveLoadMixin:
for key, value in prop_dict.items():
if key in type(self).omit_from_disk:
continue
if type(value) in (list, bpy.types.bpy_prop_collection_idprop):
if type(value) is list or isinstance(value, bpy_prop_collection):
ret[key] = [self.prefs_to_dict_recursive(elem) for elem in value]
elif type(value) is IDPropertyGroup:
ret[key] = self.prefs_to_dict_recursive(value)
@@ -143,7 +161,11 @@ class PrefsFileSaveLoadMixin:
ret[key] = value
return ret
def apply_prefs_from_dict_recursive(self, propgroup, data):
def apply_prefs_from_dict_recursive(
self,
propgroup: IDPropertyGroup,
data: dict[str, Any],
):
for key, value in data.items():
if not hasattr(propgroup, key):
# Property got removed or renamed in the implementation.
@@ -165,7 +187,7 @@ class PrefsFileSaveLoadMixin:
addon_name = __package__.split(".")[-1]
return Path(bpy.utils.user_resource("CONFIG")) / Path(addon_name + ".txt")
def save_prefs_to_file(self, _context=None):
def save_prefs_to_file(self, _context: Context | None = None):
data_dict = self.prefs_to_dict_recursive(propgroup=self)
with open(self.get_prefs_filepath(), "w") as f:
@@ -190,7 +212,11 @@ class PrefsFileSaveLoadMixin:
class AutoSavePath(PropertyGroup):
name: StringProperty(update=update_prefs_on_file)
name: StringProperty(
name="Autosave Path Identifier",
description="Name of this autosave path",
update=update_prefs_on_file,
)
path: StringProperty(
name="Autosave Path",
description="An autosave path. If this filepath doesn't exist, the next existing one will be used. If none are valid, the Blender auto-save path will be used. If that's not valid either, the OS default temp folder will be used",
@@ -221,7 +247,7 @@ class IncrementalAutoSavePreferences(PrefsFileSaveLoadMixin, AddonPreferences):
update=update_prefs_on_file,
)
max_save_files: bpy.props.IntProperty(
max_save_files: IntProperty(
name="Max Backups Per File",
description="Maximum number of backups to save for each file, 0 means unlimited. Otherwise, the oldest file will be deleted after reaching the limit",
default=10,
@@ -229,7 +255,7 @@ class IncrementalAutoSavePreferences(PrefsFileSaveLoadMixin, AddonPreferences):
max=100,
update=update_prefs_on_file,
)
compress_files: bpy.props.BoolProperty(
compress_files: BoolProperty(
name="Compress Files",
description="Save backups with compression enabled",
default=True,
@@ -263,7 +289,7 @@ class IncrementalAutoSavePreferences(PrefsFileSaveLoadMixin, AddonPreferences):
assert bpy.data.filepath
return os.sep.join((os.path.dirname(bpy.data.filepath), "Autosaves"))
def get_valid_autosave_path(self, context) -> str:
def get_valid_autosave_path(self, context: Context) -> str:
"""Return an autosave path that will always actually exist, no matter how desperate."""
prefs = get_addon_prefs(context)
if bpy.data.filepath and prefs.relative_to_blend:
@@ -287,12 +313,19 @@ class IncrementalAutoSavePreferences(PrefsFileSaveLoadMixin, AddonPreferences):
sys_temp = tempfile.gettempdir()
return sys_temp
def draw(self, context):
def draw(self, context: Context):
layout = self.layout.column()
layout.use_property_decorate = False
layout.use_property_split = True
header, panel = layout.panel("Incremental Autosave: Paths", default_closed=True)
if hasattr(layout, "panel"):
header, panel = layout.panel(
"Incremental Autosave: Paths", default_closed=True
)
else:
header = layout.row()
panel = layout
header.label(text="Autosave Paths")
if panel:
split = panel.split(factor=0.4)
@@ -384,7 +417,7 @@ def save_file():
if addon_prefs.save_images:
save_images()
if addon_prefs.save_sculpt:
save_sculpt()
realize_sculpt_or_edit_mesh()
bpy.ops.wm.save_as_mainfile(
filepath=backup_file, copy=True, compress=addon_prefs.compress_files
)
@@ -409,25 +442,36 @@ def save_images():
print(f"Incremental Autosave: Failed to save dirty image: {img.name}")
def save_sculpt():
"""We just need to enter object mode and then go back to the original mode."""
def realize_sculpt_or_edit_mesh():
"""We just need to enter object mode and then go back to the original mode,
to write the mesh data to the object.
"""
context = bpy.context
if context.mode == "EDIT_MESH":
bpy.ops.object.mode_set(mode="OBJECT")
bpy.ops.object.mode_set(mode="EDIT")
if context.mode == "SCULPT":
bpy.ops.object.mode_set(mode="OBJECT")
bpy.ops.object.mode_set(mode="SCULPT")
try:
if context.mode == "EDIT_MESH":
bpy.ops.object.mode_set(mode="OBJECT")
bpy.ops.object.mode_set(mode="EDIT")
if context.mode == "SCULPT":
bpy.ops.object.mode_set(mode="OBJECT")
bpy.ops.object.mode_set(mode="SCULPT")
except RuntimeError:
# This can happen if we happen to catch user during some modal operator that's
# not accounted for by save_after_modal_operation().
print(
"Incremental Autosave Error: Mesh could not be written to Object due to running modal operators: ",
[op.bl_idname for op in context.window.modal_operators],
)
print("You could report this as a bug!")
@persistent
def save_before_close(_dummy=None):
def save_before_close(_dummy: object = None):
# is_dirty means there are changes that haven't been saved to disk
if bpy.data.is_dirty and get_addon_prefs().save_before_close:
save_file()
def create_autosave_on_timer():
def create_autosave_on_timer() -> int:
now = datetime.now()
delta = now - LAUNCH_TIME
prefs = get_addon_prefs()
@@ -439,7 +483,7 @@ def create_autosave_on_timer():
return prefs.save_interval * 60
def save_after_modal_operation():
def save_after_modal_operation() -> float | None:
"""Mesh edits, sculpt strokes, and texture paint strokes trigger a depsgraph update,
so it's better to make a save after one of those operations, rather than on a timer.
"""
@@ -462,7 +506,7 @@ def save_after_modal_operation():
@persistent
def register_autosave_timer(_dummy=None):
def register_autosave_timer(_dummy: object = None):
bpy.app.timers.register(create_autosave_on_timer)
@@ -1,12 +1,12 @@
schema_version = "1.0.0"
id = "incremental_auto_save"
version = "1.1.1"
version = "1.1.2"
name = "Incremental Auto-Save"
tagline = "Improvements to Blender's Autosave"
maintainer = "Demeter Dzadik <demeter@blender.org>"
type = "add-on"
website = "https://projects.blender.org/Mets/incremental-autosave"
website = "https://projects.blender.org/Mets/incremental-autosave#incremental-auto-save"
tags = ["System"]
blender_version_min = "4.2.0"
@@ -15,7 +15,7 @@ license = [
"SPDX:GPL-3.0-or-later"
]
copyright = [
"2019-2024 Demeter Dzadik"
"2019-2026 Demeter Dzadik"
]
[permissions]
files = "Save preferences & .blends in chosen directories"
@@ -1,4 +1,4 @@
Documentation: https://weisl.github.io/renaming_overview/
Documentation: https://weisl.github.io/simple_renaming/renaming_overview/
[![paypal](https://www.paypalobjects.com/en_US/i/btn/btn_donateCC_LG.gif)](https://www.paypal.com/donate?hosted_button_id=JV7KRF77TY78A)
@@ -18,22 +18,11 @@ Created by Matthias Patscheider
along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
# support reloading sub-modules
if "bpy" in locals():
import importlib
importlib.reload(add_suffix_panel)
importlib.reload(operators)
importlib.reload(preferences)
importlib.reload(ui)
importlib.reload(variable_replacer)
else:
from . import add_suffix_panel
from . import operators
from . import preferences
from . import ui
from . import variable_replacer
from . import add_suffix_panel
from . import operators
from . import preferences
from . import ui
from . import variable_replacer
files = [
add_suffix_panel,
@@ -245,7 +245,7 @@ class VIEW3D_OT_add_type_suf_pre(bpy.types.Operator):
obj_list = []
for obj in self.get_selection_all():
if obj.type == 'GPENCIL':
if obj.type in ('GPENCIL', 'GREASEPENCIL'):
obj_list.append(obj)
self.rename_suffix_prefix(obj_list, pre_suffix=wm.renaming_suffix_prefix_gpencil, object_type='GPENCIL',
icon='OUTLINER_OB_GREASEPENCIL')
@@ -1,13 +1,13 @@
schema_version = "1.0.0"
id = "simple_renaming_panel"
version = "2.1.6"
version = "2.1.8"
name = "Simple Renaming"
tagline = "Effortlessly rename multiple objects with this simple addon"
maintainer = "Matthias Patscheider <patscheider.matthias@gmail.com>"
type = "add-on"
website = "https://weisl.github.io/renaming_overview/"
website = "https://weisl.github.io/simple_renaming/renaming_overview/"
tags = ["3D View", "Scene", "User Interface"]
@@ -1,5 +1,4 @@
import bpy
from bpy.app.handlers import persistent
from bpy.props import (
BoolProperty,
EnumProperty,
@@ -10,15 +9,12 @@ from bpy.props import (
from . import add_pre_suffix
from . import case_transform
from . import reload_addon
from .version_check import start_version_check
from . import name_from_data
from . import name_replace
from . import numerate
from . import search_replace
from . import search_select
from . import trim_string
from .renaming_utilities import update_selection_order
enumObjectTypes = [('EMPTY', "", "Rename empty objects", 'OUTLINER_OB_EMPTY', 1),
('MESH', "", "Rename mesh objects", 'OUTLINER_OB_MESH', 2),
@@ -29,7 +25,7 @@ enumObjectTypes = [('EMPTY', "", "Rename empty objects", 'OUTLINER_OB_EMPTY', 1)
('CURVE', "", "Rename curve objects", 'OUTLINER_OB_CURVE', 64),
('SURFACE', "", "Rename surface objects", 'OUTLINER_OB_SURFACE', 128),
('FONT', "", "Rename font objects", 'OUTLINER_OB_FONT', 256),
('GPENCIL', "", "Rename greace pencil objects", 'OUTLINER_OB_GREASEPENCIL', 512),
('GPENCIL', "", "Rename grease pencil objects", 'OUTLINER_OB_GREASEPENCIL', 512),
('META', "", "Rename metaball objects", 'OUTLINER_OB_META', 1024),
('SPEAKER', "", "Rename empty speakers", 'OUTLINER_OB_SPEAKER', 2048),
('LIGHT_PROBE', "", "Rename mesh lightpropes", 'OUTLINER_OB_LIGHTPROBE', 4096),
@@ -83,36 +79,17 @@ classes = (
case_transform.VIEW3D_OT_case_camel,
case_transform.VIEW3D_OT_case_snake,
case_transform.VIEW3D_OT_case_kebab,
reload_addon.VIEW3D_OT_reload_addon,
)
enum_sort_items = [('X', "X Axis", "Sort the object based on the X axis."),
('Y', "Y Axis", "Sort the object based on the Y axis."),
('Z', "Z Axis", "Sort the object based on the Z axis."),
('SELECTION', "Selection", "Sort the objects based on the selection order"), ]
('Z', "Z Axis", "Sort the object based on the Z axis."), ]
enum_sort_bone_items = [('X', "X Axis", "Sort the object based on the X axis."),
('Y', "Y Axis", "Sort the object based on the Y axis."),
('Z', "Z Axis", "Sort the object based on the Z axis."), ]
# persistent is needed for handler to work in addons https://docs.blender.org/api/current/bpy.app.handlers.html
@persistent
def PostChange(scene):
if bpy.context.mode != "OBJECT":
return
# Any() function returns True if any element of an iterable is True. If not, it returns False.
is_selection_update = any(
not u.is_updated_geometry
and not u.is_updated_transform
and not u.is_updated_shading
for u in bpy.context.view_layer.depsgraph.updates
)
if is_selection_update:
update_selection_order()
def register():
id_store = bpy.types.Scene
@@ -210,8 +187,6 @@ def register():
for cls in classes:
register_class(cls)
bpy.app.handlers.depsgraph_update_post.append(PostChange)
start_version_check()
def unregister():
@@ -237,4 +212,3 @@ def unregister():
del IDStore.renaming_filter_by_index
del IDStore.renaming_index_target
bpy.app.handlers.depsgraph_update_post.remove(PostChange)
@@ -1,61 +0,0 @@
import bpy
from bpy.types import Operator
class VIEW3D_OT_reload_addon(Operator):
"""Reload all Simple Renaming scripts."""
bl_idname = "renaming.reload_addon"
bl_label = "Reload Addon"
bl_description = "Reload all Simple Renaming scripts"
def execute(self, context):
import importlib
import sys
# Derive the addon root package name by stripping the sub-package suffix.
# Works for both legacy addons ("simple_renaming.operators")
# and extensions ("bl_ext.user_default.simple_renaming.operators").
root_pkg = __package__.rsplit(".", 1)[0]
# Snapshot the module names now, before any reload happens.
# Sort key: deeper modules first (so core.* sub-modules reload before
# core.__init__), and alphabetically within the same depth so that
# "core.*" always reloads before "operators.*" before "ui.*".
mod_names = sorted(
[name for name in sys.modules
if name == root_pkg or name.startswith(root_pkg + ".")],
key=lambda n: (-n.count("."), n),
)
# Defer the actual reload to the next event-loop iteration so that this
# operator's own execute() has finished (and its class has been removed
# from the call stack) before we unregister and reload everything.
def _do_reload():
root_mod = sys.modules.get(root_pkg)
if root_mod and hasattr(root_mod, "unregister"):
try:
root_mod.unregister()
except Exception as exc:
print(f"[RENAMING] unregister error: {exc}")
for name in mod_names:
mod = sys.modules.get(name)
if mod is not None:
try:
importlib.reload(mod)
except Exception as exc:
print(f"[RENAMING] reload error for '{name}': {exc}")
# Re-fetch root after in-place reload to pick up any top-level changes.
root_mod = sys.modules.get(root_pkg)
if root_mod and hasattr(root_mod, "register"):
try:
root_mod.register()
except Exception as exc:
print(f"[RENAMING] register error: {exc}")
print(f"[RENAMING] Reloaded {len(mod_names)} modules from '{root_pkg}'")
bpy.app.timers.register(_do_reload, first_interval=0.0)
self.report({'INFO'}, f"Queued reload of {len(mod_names)} modules…")
return {'FINISHED'}
@@ -37,9 +37,7 @@ def get_renaming_list(context):
else:
sort_enum = scene.renaming_sort_enum
if sort_enum == 'SELECTION':
obj_list = get_ordered_selection_objects()
elif sort_enum == 'X':
if sort_enum == 'X':
obj_list = get_sorted_objects_x(obj_list)
elif sort_enum == 'Y':
obj_list = get_sorted_objects_y(obj_list)
@@ -51,7 +49,8 @@ def get_renaming_list(context):
if scene.renaming_object_types == 'OBJECT':
for obj in obj_list:
if obj.type in scene.renaming_object_types_specified:
effective_type = 'GPENCIL' if obj.type == 'GREASEPENCIL' else obj.type
if effective_type in scene.renaming_object_types_specified:
renaming_list.append(obj)
elif scene.renaming_object_types == 'DATA':
@@ -65,8 +64,8 @@ def get_renaming_list(context):
if selection_only:
for obj in context.selected_objects:
for mat in obj.material_slots:
if mat is not None and mat.name != '':
renaming_list.append(bpy.data.materials[mat.name])
if mat.material is not None:
renaming_list.append(mat.material)
else:
renaming_list = list(bpy.data.materials)
@@ -124,7 +123,7 @@ def get_renaming_list(context):
renaming_list.append(new_bone)
elif scene.renaming_object_types == 'COLLECTION':
if bpy.context.space_data.type == 'OUTLINER' and selection_only is True:
if bpy.context.space_data and bpy.context.space_data.type == 'OUTLINER' and selection_only is True:
selected_collections = [c for c in context.selected_ids if c.bl_rna.identifier == "Collection"]
for col in selected_collections:
renaming_list.append(col)
@@ -300,16 +299,6 @@ def get_global_z(obj):
return obj.matrix_world.to_translation().z
def get_ordered_selection_objects():
tagged_objects = []
for o in bpy.data.objects:
order_index = o.get("selection_order", -1)
if order_index >= 0:
tagged_objects.append((order_index, o))
tagged_objects = sorted(tagged_objects, key=lambda item: item[0])
return [o for i, o in tagged_objects]
def get_sorted_objects_x(objects):
# Sort objects by their global X location
sorted_objects = sorted(objects, key=get_global_x)
@@ -368,44 +357,3 @@ def update_bone_drivers(old_name, new_name):
target.data_path = target.data_path.replace(old_token, new_token)
def clear_order_flag(obj):
try:
del obj["selection_order"]
except KeyError:
pass
def update_selection_order():
if not (getattr(bpy.context, 'selected_objects', None) or list(bpy.context.view_layer.objects.selected)):
for o in bpy.data.objects:
clear_order_flag(o)
return
selection_order = get_ordered_selection_objects()
idx = 0
for o in selection_order:
if not o.select_get():
selection_order.remove(o)
clear_order_flag(o)
else:
o["selection_order"] = idx
# Remove any existing fcurves for 'selection_order'
if o.animation_data and o.animation_data.action:
action = o.animation_data.action
import bpy_extras.anim_utils as anim_utils
# Get the default channelbag for the action (slot 0)
channelbag = anim_utils.action_get_channelbag_for_slot(action, 0)
if channelbag:
fcurves = channelbag.fcurves
for fcurve in fcurves:
if fcurve.data_path == '["selection_order"]':
fcurves.remove(fcurve)
idx += 1
for o in bpy.context.selected_objects:
if o not in selection_order:
o["selection_order"] = len(selection_order)
selection_order.append(o)
@@ -1,62 +0,0 @@
import threading
import urllib.request
import urllib.error
import json
# Module-level state — read by the panel draw function
update_available = False
latest_version_str = ""
_RELEASES_URL = "https://api.github.com/repos/Weisl/simple_renaming/releases/latest"
def _parse_version(version_str):
"""Convert '2.1.4' or 'v2.1.4' to (2, 1, 4)."""
return tuple(int(x) for x in version_str.lstrip("v").split("."))
def _fetch():
global update_available, latest_version_str
try:
req = urllib.request.Request(
_RELEASES_URL,
headers={"User-Agent": "simple-renaming-addon"},
)
with urllib.request.urlopen(req, timeout=5) as response:
data = json.loads(response.read().decode())
tag = data.get("tag_name", "")
if not tag:
return
latest = _parse_version(tag)
# Read current version from blender_manifest.toml at the addon root
import os
manifest_path = os.path.join(os.path.dirname(__file__), "..", "blender_manifest.toml")
current_str = ""
with open(manifest_path, encoding="utf-8") as f:
for line in f:
if line.startswith("version"):
current_str = line.split("=")[1].strip().strip('"')
break
if not current_str:
return
current = _parse_version(current_str)
if latest > current:
update_available = True
latest_version_str = tag.lstrip("v")
else:
print(f"[RENAMING] Addon is up to date (v{current_str})")
except Exception as exc:
print(f"[RENAMING] version check failed: {exc}")
def start_version_check():
"""Fire a background thread to check for a newer release on GitHub."""
t = threading.Thread(target=_fetch, daemon=True)
t.start()
@@ -92,8 +92,7 @@ class VIEW3D_OT_renaming_preferences(bpy.types.AddonPreferences):
bl_options = {'REGISTER'}
prefs_tabs: EnumProperty(items=(('UI', "General", "General Settings"),
('KEYMAPS', "Keymaps", "Keymaps"),
('SUPPORT', "Support & Donation", "Support")),
('KEYMAPS', "Keymaps", "Keymaps")),
default='UI')
renaming_category: StringProperty(name="Category",
@@ -382,52 +381,3 @@ class VIEW3D_OT_renaming_preferences(bpy.types.AddonPreferences):
self.keymap_ui(layout, 'Renaming Sub/Prefix', 'renaming_suf_pre',
'wm.call_panel', "VIEW3D_PT_tools_type_suffix")
elif self.prefs_tabs == 'SUPPORT':
text = "Support me developing great tools!"
label_multiline(
context=context,
text=text,
parent=layout
)
# Donations
box = layout.box()
text = "Consider supporting the development of this addon with a donation!"
label_multiline(
context=context,
text=text,
parent=box
)
col = box.column(align=True)
row = col.row()
row.operator("wm.url_open", text="Gumroad",
icon="URL").url = "https://weisl.gumroad.com/l/simple_renaming"
row = col.row()
row.operator("wm.url_open", text="Superhive Market",
icon="URL").url = "https://superhivemarket.com/products/simple-renaming"
row = col.row()
row.operator("wm.url_open", text="PayPal Donation",
icon="URL").url = "https://www.paypal.com/donate?hosted_button_id=JV7KRF77TY78A"
# Cross Promotion
box = layout.box()
text = "Explore my other Blender Addons designed for more efficient game asset workflows!"
label_multiline(
context=context,
text=text,
parent=box
)
col = box.column(align=True)
row = col.row()
row.operator("wm.url_open", text="Simple Collider",
icon="URL").url = "https://superhivemarket.com/products/simple-collider"
row = col.row()
row.operator("wm.url_open", text="Simple Camera Manager",
icon="URL").url = "https://superhivemarket.com/products/simple-camera-manager"
row = col.row()
row.label(text='Support & Feedback')
row = col.row()
row.operator("wm.url_open", text="Join Discord", icon="URL").url = "https://discord.gg/VRzdcFpczm"
@@ -15,13 +15,6 @@ types_of_selected = (
def draw_renaming_panel(layout, context):
from ..operators.version_check import update_available, latest_version_str
if update_available:
row = layout.row(align=True)
row.alert = True
row.label(text=f"Update available: v{latest_version_str}", icon='ERROR')
scene = context.scene
row = layout.row(align=True)
@@ -47,7 +40,7 @@ def draw_renaming_panel(layout, context):
elif str(scene.renaming_object_types) in types_selected:
layout.prop(scene, "renaming_only_selection", text="Only Selected")
elif str(scene.renaming_object_types) == 'COLLECTION':
if bpy.context.space_data.type == 'OUTLINER':
if bpy.context.space_data and bpy.context.space_data.type == 'OUTLINER':
row = layout.row(align=True)
row.prop(scene, "renaming_only_selection", text="Only Selected")
else:
@@ -191,13 +184,12 @@ class VIEW3D_PT_tools_renaming_panel(bpy.types.Panel):
def draw_header(self, context):
layout = self.layout
row = layout.row(align=True)
row.operator("wm.url_open", text="", icon='HELP').url = "https://weisl.github.io/renaming_overview/"
row.operator("wm.url_open", text="", icon='HELP').url = "https://weisl.github.io/simple_renaming/renaming_overview/"
addon_name = get_addon_name()
op = row.operator("preferences.rename_addon_search", text="", icon='PREFERENCES')
op.addon_name = addon_name
op.prefs_tabs = 'UI'
row.operator("renaming.reload_addon", text="", icon='FILE_REFRESH')
def draw(self, context):
layout = self.layout
@@ -21,18 +21,4 @@ class PREFERENCES_OT_open_addon(bpy.types.Operator):
prefs = context.preferences.addons[base_package].preferences
prefs.prefs_tabs = self.prefs_tabs
import addon_utils
mod = addon_utils.addons_fake_modules.get('simple_renaming')
# mod is None the first time the operation is called :/
if mod:
mod.bl_info['show_expanded'] = True
# Find User Preferences area and redraw it
for window in bpy.context.window_manager.windows:
for area in window.screen.areas:
if area.type == 'USER_PREFERENCES':
area.tag_redraw()
bpy.ops.preferences.addon_expand(module=self.addon_name)
return {'FINISHED'}
@@ -2,9 +2,10 @@
#
# SPDX-License-Identifier: GPL-3.0-or-later
import importlib
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)
@@ -15,16 +16,19 @@ module_names = (
"prefs",
"sidebar",
"tweak_builtin_pies",
"pie_animation",
"pie_playback",
"pie_keyframes",
"pie_copy_attributes",
"pie_apply_transform",
"pie_camera",
"pie_curve_delete",
"pie_preferences",
"pie_editor_split_merge",
"pie_editor_switch",
"pie_file",
"pie_manipulator",
"pie_mesh_delete",
"pie_mesh_edge",
"pie_mesh_flatten",
"pie_mesh_merge",
"pie_object_add",
@@ -33,16 +37,15 @@ module_names = (
"pie_proportional_editing",
"pie_relationship_delete",
"pie_sculpt_brush_select",
"pie_select_similar",
"pie_selection",
"pie_set_origin",
"pie_view_3d",
"pie_window",
)
modules = [
__import__(__package__ + "." + submod, {}, {}, submod)
for submod in module_names
]
modules = [__import__(__package__ + "." + submod, {}, {}, submod) for submod in module_names]
def register_unregister_modules(modules: list, register: bool):
"""Recursively register or unregister modules by looking for either
@@ -71,12 +74,14 @@ def register_unregister_modules(modules: list, register: bool):
elif hasattr(m, 'unregister'):
m.unregister()
def delayed_register(_scene=None):
# Register whole add-on with a slight delay,
# to make sure Keymap data we need already exists on Blender launch.
# Otherwise, keyconfigs.user.keymaps is an empty list, we can't find fallback ops.
register_unregister_modules(modules, True)
def register():
"""
We prefer an *immediate* register during startup, because other add-ons may touch
@@ -96,9 +101,10 @@ def register():
# 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
# 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.3"
version = "1.7.4"
tagline = "Various pie menus to speed up your workflow"
maintainer = "Community"
type = "add-on"
@@ -5,10 +5,12 @@
# 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 json
from typing import Any, Callable
import bpy
import json
from bpy.app.translations import pgettext_n as n_
from bpy.app.translations import pgettext_rpt as rpt_
from bpy.types import KeyMap, KeyMapItem, UILayout
# Preserve across Reload Scripts (module reload) when possible, but also keep
@@ -27,11 +29,14 @@ KEYMAP_ICONS = {
'Armature': 'ARMATURE_DATA',
'Pose': 'POSE_HLT',
'Weight Paint': 'WPAINT_HLT',
'Graph Editor': 'GRAPH',
'Dopesheet': 'ACTION',
'Curve': 'CURVE_DATA',
}
KEYMAP_UI_NAMES = {
'Armature': "Armature Edit",
'Object Non-modal': "Object Mode",
'Armature': n_("Armature Edit"),
'Object Non-modal': n_("Object Mode"),
}
KMI_DEFAULTS = {
@@ -75,9 +80,9 @@ def register_hotkey(
existing_kmi = find_kmi_in_km_by_data(addon_km, hotkey_kwargs, bl_idname, op_kwargs)
if existing_kmi:
# NOTE: It is extremely important to not register duplicate add-on keymaps, AND
# to NOT remove them on add-on unregister, because once an add-on keymap is registered,
# to NOT remove them on add-on unregister, because once an add-on keymap is registered,
# it is SUPPOSED TO stick around for ever.
# This allows Blender to store the associated user keymap, meaning the user's modifications
# 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))
@@ -213,7 +218,7 @@ def get_kmi_ui_info(km, kmi) -> tuple[str, str, str]:
return km_icon, km_name, kmi_name
def find_kmi_in_km_by_data(km: KeyMap, hotkey_kwargs: dict, op_idname: str, op_kwargs: dict) -> KeyMapItem | None:
"""Loop over KeyMapItems of the provided KeyMap, and return the first entry,
"""Loop over KeyMapItems of the provided KeyMap, and return the first entry,
if any, which matches the passed key combo, operator, and operator properties.
"""
@@ -369,7 +374,7 @@ class WINDOW_OT_restore_deleted_hotkeys(bpy.types.Operator):
def execute(self, context):
num_restored = restore_deleted_keymap_items_global(context)
self.report({'INFO'}, f"Restored {num_restored} deleted keymaps.")
self.report({'INFO'}, rpt_("Restored {num_restored} deleted keymaps.").format(num_restored=num_restored))
return {'FINISHED'}
@@ -175,7 +175,7 @@ def x_mirror_value(value):
if isinstance(value, str):
return flip_name(value)
elif isinstance(value, Object):
get_opposite_obj(value)
return get_opposite_obj(value)
else:
return value
@@ -1,39 +1,35 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
# SPDX-FileCopyrightText: 2016-2026 Blender Foundation
#
# 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
import os
from typing import Any
import bpy
from bpy.props import BoolProperty, StringProperty
from bpy.types import Context, Event, Operator
from .bs_utils.hotkeys import register_hotkey
def idname_to_op_class(idname: str):
parts = idname.split(".")
op = bpy.ops
for part in parts:
op = getattr(op, part)
return op
class WM_OT_call_menu_pie_drag_only(Operator):
"""Summon a pie menu only on mouse drag, otherwise initiate an operator"""
# This class is for cases where we want to overwrite a built-in shortcut which triggers on Press,
# with a pie menu that only appears on mouse drag.
# This class is for cases where we want to overwrite a built-in shortcut which triggers on Press,
# with a pie menu that only appears on mouse drag.
# If the user does not mouse drag, invoke the provided fallback operator.
# In register_drag_hotkey(), the fallback operator is automagically extracted from the keymap,
# at the moment the hotkey is registered.
bl_idname = "wm.call_menu_pie_drag_only"
bl_label = "Pie Menu on Drag"
bl_options = {'REGISTER', 'INTERNAL'}
bl_options = {'INTERNAL'}
def update_kmi(self, context):
def update_kmi(self, context: Context) -> None:
if not hasattr(context, 'keymapitem'):
return
kmi = context.keymapitem # Set via UILayout.context_pointer_set().
kmi = context.keymapitem # Set via UILayout.context_pointer_set().
kmi.type = kmi.type
name: StringProperty(options={'SKIP_SAVE'})
@@ -47,7 +43,7 @@ class WM_OT_call_menu_pie_drag_only(Operator):
fallback_operator: StringProperty(options={'SKIP_SAVE'})
fallback_op_kwargs: StringProperty(default="{}", options={'SKIP_SAVE'})
def invoke(self, context, event):
def invoke(self, context: Context, event: Event) -> set[str]:
if not self.on_drag:
return self.execute(context)
self.init_mouse_x = event.mouse_x
@@ -55,33 +51,30 @@ class WM_OT_call_menu_pie_drag_only(Operator):
context.window_manager.modal_handler_add(self)
return {'RUNNING_MODAL'}
def invoke_fallback_operator(self):
def invoke_fallback_operator(self) -> None:
op_cls = idname_to_op_class(self.fallback_operator)
if not op_cls:
return
fallback_op_kwargs = json.loads(self.fallback_op_kwargs)
if type(fallback_op_kwargs) == str:
# Not sure why json.loads seems to sometimes return a string, but it does
# when eg. setting Shift+C to drag, and using it when first launching Blender.
# After Reload Scripts, it works fine without this workaround. Weird af.
if isinstance(fallback_op_kwargs, str):
# Not sure why json.loads sometimes returns a string, but it does
# when eg. setting Shift+C to drag and using it on first launch.
# After Reload Scripts it works fine without this. Weird.
fallback_op_kwargs = json.loads(fallback_op_kwargs)
if op_cls.poll():
try:
# 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"
# HACK: Fix save notification not appearing when tapping Ctrl+S.
filename = os.path.basename(bpy.data.filepath)
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}")
def modal(self, context, event):
def modal(self, context: Context, event: Event) -> set[str]:
if event.value == 'RELEASE':
if self.fallback_operator:
self.invoke_fallback_operator()
@@ -91,10 +84,9 @@ class WM_OT_call_menu_pie_drag_only(Operator):
delta_y = abs(event.mouse_y - self.init_mouse_y)
if delta_x > threshold or delta_y > threshold:
return self.execute(context)
return {'RUNNING_MODAL'}
def execute(self, context):
def execute(self, _context: Context) -> set[str]:
bpy.ops.wm.call_menu_pie(name=self.name)
return {'FINISHED'}
@@ -104,16 +96,16 @@ class WM_OT_call_menu_pie_drag_only(Operator):
*,
keymap_name: str,
pie_name: str,
hotkey_kwargs=None,
default_fallback_op="",
default_fallback_kwargs=None,
on_drag=True,
):
hotkey_kwargs: dict[str, Any] | None = None,
default_fallback_op: str = "",
default_fallback_kwargs: dict[str, Any] | None = None,
on_drag: bool = True,
) -> None:
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 if default_fallback_kwargs is not None else {}
fallback_op_kwargs: dict[str, Any] = default_fallback_kwargs if default_fallback_kwargs is not None else {}
# IMPORTANT:
# Do NOT derive fallback operator/kwargs from the USER keyconfig.
@@ -137,21 +129,24 @@ class WM_OT_call_menu_pie_drag_only(Operator):
kmi.oskey == hotkey_kwargs.get('oskey', False),
kmi.any == hotkey_kwargs.get('any', False),
kmi.key_modifier == hotkey_kwargs.get('key_modifier', 'NONE'),
kmi.active
kmi.active,
]:
if not condition:
break
else:
fallback_operator = kmi.idname
if kmi.properties:
fallback_op_kwargs = {k:getattr(kmi.properties, k) if hasattr(kmi.properties, k) else v for k, v in kmi.properties.items()}
fallback_op_kwargs = {
k: getattr(kmi.properties, k) if hasattr(kmi.properties, k) else v
for k, v in kmi.properties.items()
}
break
register_hotkey(
cls.bl_idname,
op_kwargs={
'name': pie_name,
'fallback_operator': fallback_operator,
'name': pie_name,
'fallback_operator': fallback_operator,
# 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,
@@ -160,10 +155,20 @@ class WM_OT_call_menu_pie_drag_only(Operator):
keymap_name=keymap_name,
)
def register():
def idname_to_op_class(idname: str) -> Any:
parts = idname.split(".")
op = bpy.ops
for part in parts:
op = getattr(op, part)
return op
def register() -> None:
bpy.utils.register_class(WM_OT_call_menu_pie_drag_only)
def unregister():
def unregister() -> None:
# 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)
@@ -1,9 +1,10 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
# SPDX-FileCopyrightText: 2016-2026 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from bpy.types import Menu, Operator
from bpy.types import Context, Event, Menu, Operator
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
@@ -11,34 +12,26 @@ class PIE_MT_apply_transforms(Menu):
bl_idname = "PIE_MT_apply_transforms"
bl_label = "Apply Transforms"
def draw(self, context):
def draw(self, context: Context):
layout = self.layout
pie = layout.menu_pie()
# 4 - LEFT
props = pie.operator("object.transform_apply", text="Rot/Scale", icon='CON_SIZELIKE')
props.location, props.rotation, props.scale = (False, True, True)
# 6 - RIGHT
props = pie.operator(
"object.transform_apply", text="Loc/Rot/Scale", icon='ORIENTATION_LOCAL'
)
props = pie.operator("object.transform_apply", text="Loc/Rot/Scale", icon='ORIENTATION_LOCAL')
props.location, props.rotation, props.scale = (True, True, True)
# 2 - BOTTOM
pie.operator("object.apply_transforms_of_constraints", text="Soft-Apply Constraints", icon='CONSTRAINT')
# 8 - TOP
props = pie.operator(
"object.transform_apply", text="Rotation", icon='CON_ROTLIKE'
)
props = pie.operator("object.transform_apply", text="Rotation", icon='CON_ROTLIKE')
props.location, props.rotation, props.scale = (False, True, False)
# 7 - TOP - LEFT
props = pie.operator(
"object.transform_apply", text="Location", icon='CON_LOCLIKE'
)
props = pie.operator("object.transform_apply", text="Location", icon='CON_LOCLIKE')
props.location, props.rotation, props.scale = (True, False, False)
# 9 - TOP - RIGHT
props = pie.operator(
"object.transform_apply", text="Scale", icon='CON_SIZELIKE'
)
props = pie.operator("object.transform_apply", text="Scale", icon='CON_SIZELIKE')
props.location, props.rotation, props.scale = (False, False, True)
# 1 - BOTTOM - LEFT
if (
@@ -49,11 +42,10 @@ class PIE_MT_apply_transforms(Menu):
):
pie.operator('object.instancer_empty_to_collection', icon='LINKED')
else:
pie.operator(
"object.make_single_user", text="Make Single-User", icon='DUPLICATE'
).obdata = True
pie.operator("object.apply_modifiers_shapekeys", icon='MODIFIER_DATA')
# 3 - BOTTOM - RIGHT
pie.menu("PIE_MT_clear_menu", text="Clear Transforms", icon='THREE_DOTS')
pie.menu("PIE_MT_apply_transforms_extras", text="More...", icon='THREE_DOTS')
class OBJECT_OT_apply_transforms_of_constraints(Operator):
@@ -64,18 +56,13 @@ class OBJECT_OT_apply_transforms_of_constraints(Operator):
bl_options = {'REGISTER'}
@classmethod
def poll(cls, context):
if not any(
[
any([c.enabled and c.influence > 0 for c in obj.constraints])
for obj in context.selected_objects
]
):
def poll(cls, context: Context) -> bool:
if not any(any(c.enabled and c.influence > 0 for c in obj.constraints) for obj in context.selected_objects):
cls.poll_message_set("No selected objects with enabled constraints.")
return False
return True
def execute(self, context):
def execute(self, _context: Context):
# This is just a wrapper operator, to add a better tooltip and poll message.
return bpy.ops.object.visual_transform_apply()
@@ -88,15 +75,13 @@ class OBJECT_OT_make_meshes_single_user(Operator):
bl_options = {'REGISTER'}
@classmethod
def poll(cls, context):
if not any(
[obj.data and obj.data.users > 1 for obj in context.selected_objects]
):
def poll(cls, context: Context) -> bool:
if not any(obj.data and obj.data.users > 1 for obj in context.selected_objects):
cls.poll_message_set("No selected objects with multi-user meshes.")
return False
return True
def execute(self, context):
def execute(self, _context: Context):
# This is just a wrapper operator, to add a better tooltip and poll message.
return bpy.ops.object.duplicates_make_real()
@@ -107,25 +92,63 @@ class OBJECT_OT_clear_all_transforms(Operator):
bl_description = "Clear All Transforms"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
def execute(self, _context: Context):
bpy.ops.object.location_clear()
bpy.ops.object.rotation_clear()
bpy.ops.object.scale_clear()
return {'FINISHED'}
class OBJECT_OT_apply_modifiers_shapekeys(Operator):
"""Apply the object's modifier stack and shape keys. Same as calling "Convert To -> Mesh".
Shift: Keep the original object as a duplicate."""
bl_idname = "object.apply_modifiers_shapekeys"
bl_label = "Apply Modifiers & Shape Keys"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context: Context) -> bool:
return bool(context.active_object)
def invoke(self, _context: Context, event: Event):
bpy.ops.object.convert(target='MESH', keep_original=event.shift)
return {'FINISHED'}
class PIE_MT_clear_transforms(Menu):
bl_idname = "PIE_MT_clear_menu"
bl_label = "Clear Transforms"
bl_idname = "PIE_MT_apply_transforms_extras"
bl_label = "Apply Transforms Extras"
def draw(self, context):
def draw(self, _context: Context):
layout = self.layout
layout.operator("clear.all", text="Clear All", icon='NONE')
layout.operator("object.location_clear", text="Clear Location", icon='NONE')
layout.operator("object.rotation_clear", text="Clear Rotation", icon='NONE')
layout.operator("object.scale_clear", text="Clear Scale", icon='NONE')
layout.operator("object.origin_clear", text="Clear Origin", icon='NONE')
layout.label(text="Clear Transforms")
layout.operator("clear.all", text="Clear All", icon='X')
layout.operator("object.location_clear", text="Clear Location", icon='CON_LOCLIKE')
layout.operator("object.rotation_clear", text="Clear Rotation", icon='CON_ROTLIKE')
layout.operator("object.scale_clear", text="Clear Scale", icon='CON_SIZELIKE')
layout.operator("object.origin_clear", text="Clear Origin", icon='PIVOT_CURSOR')
layout.separator()
layout.label(text="To Deltas")
props = layout.operator("object.transforms_to_deltas", text="Location to Deltas", icon='CON_LOCLIKE')
props.mode = 'LOC'
props = layout.operator("object.transforms_to_deltas", text="Rotation to Deltas", icon='CON_ROTLIKE')
props.mode = 'ROT'
props = layout.operator("object.transforms_to_deltas", text="Scale to Deltas", icon='CON_SIZELIKE')
props.mode = 'SCALE'
props = layout.operator(
"object.transforms_to_deltas", text="All Transforms to Deltas", icon='ORIENTATION_LOCAL'
)
props.mode = 'ALL'
layout.operator("object.anim_transforms_to_deltas", text="Animated Transforms to Deltas", icon='KEYINGSET')
layout.separator()
layout.label(text="Mesh")
make_single = layout.operator("object.make_single_user", text="Make Single-User", icon='DUPLICATE')
make_single.obdata = True
layout.operator("object.duplicates_make_real", text="Make Instances Real", icon='DUPLICATE')
registry = (
@@ -133,6 +156,7 @@ registry = (
OBJECT_OT_apply_transforms_of_constraints,
OBJECT_OT_make_meshes_single_user,
OBJECT_OT_clear_all_transforms,
OBJECT_OT_apply_modifiers_shapekeys,
PIE_MT_clear_transforms,
)
@@ -1,9 +1,10 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
# SPDX-FileCopyrightText: 2016-2026 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from bpy.types import Menu, Operator
from bpy.types import Context, Event, Menu, Object, Operator
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
# Some magic numbers that are either hard-coded into Blender,
@@ -16,7 +17,7 @@ class PIE_MT_camera(Menu):
bl_idname = "PIE_MT_camera"
bl_label = "Camera"
def draw(self, context):
def draw(self, context: Context):
pie = self.layout.menu_pie()
scene = context.scene
space = context.area.spaces.active
@@ -35,9 +36,7 @@ class PIE_MT_camera(Menu):
text = "View Active Camera"
if context.space_data.region_3d.view_perspective == 'CAMERA':
text = "Return to Viewport"
pie.operator(
'view3d.view_camera_with_poll', text=text, icon='VIEW_CAMERA_UNSELECTED'
)
pie.operator('view3d.view_camera_with_poll', text=text, icon='VIEW_CAMERA_UNSELECTED')
# 2 - BOTTOM
box = pie.box().column(align=True)
row = box.row()
@@ -48,9 +47,20 @@ class PIE_MT_camera(Menu):
if camera:
box.prop(camera.data, 'lens')
box.prop(camera.data, 'sensor_width')
row = box.row(align=True)
row.prop(camera.data.dof, 'use_dof', text="")
fstop_row = row.row()
fstop_row.enabled = camera.data.dof.use_dof
fstop_row.prop(camera.data.dof, 'aperture_fstop', text="Depth of Field F-Stop")
box.separator()
row = box.row(align=True)
row.prop(camera.data, 'show_passepartout', text="")
row.prop(camera.data, 'passepartout_alpha')
row_alpha = row.row()
row_alpha.enabled = camera.data.show_passepartout
row_alpha.prop(camera.data, 'passepartout_alpha')
row = box.row(align=True)
row.prop(camera.data, 'show_composition_thirds')
row.prop(camera.data, 'show_composition_center')
@@ -88,13 +98,13 @@ class VIEW3D_OT_lock_active_camera_transforms(Operator):
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
def poll(cls, context: Context) -> bool:
if not get_current_camera(context):
cls.poll_message_set("No active camera in this viewport.")
return False
return True
def execute(self, context):
def execute(self, context: Context):
camera = get_current_camera(context)
lock = False
@@ -119,16 +129,14 @@ class VIEW3D_OT_camera_fit_view(Operator):
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
def poll(cls, context: Context) -> bool:
if not context.space_data or not hasattr(context.space_data, 'region_3d'):
return False
if context.space_data.region_3d.view_perspective == 'CAMERA':
cls.poll_message_set("Already in a camera view.")
return False
camera = get_current_camera(context)
if not camera and not (
context.active_object and context.active_object.type == 'CAMERA'
):
if not camera and not (context.active_object and context.active_object.type == 'CAMERA'):
cls.poll_message_set("No active camera.")
return False
if camera and (any(camera.lock_location) or any(camera.lock_rotation)):
@@ -136,14 +144,12 @@ class VIEW3D_OT_camera_fit_view(Operator):
return False
return True
def invoke(self, context, _event):
if not context.scene.camera and not (
context.active_object and context.active_object.type == 'CAMERA'
):
def invoke(self, context: Context, _event: Event):
if not context.scene.camera and not (context.active_object and context.active_object.type == 'CAMERA'):
self.create_camera = True
return self.execute(context)
def execute(self, context):
def execute(self, context: Context):
cam = get_current_camera(context)
space = context.space_data
@@ -173,7 +179,7 @@ class VIEW3D_OT_camera_from_view(Operator):
bl_label = "New Camera from View"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
def execute(self, context: Context):
if context.mode != 'OBJECT':
bpy.ops.object.mode_set(mode='OBJECT')
bpy.ops.object.camera_add()
@@ -193,7 +199,7 @@ class VIEW3D_OT_set_active_camera(Operator):
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
def poll(cls, context: Context) -> bool:
if not context.active_object:
cls.poll_message_set("No active object.")
return False
@@ -205,7 +211,7 @@ class VIEW3D_OT_set_active_camera(Operator):
return False
return True
def execute(self, context):
def execute(self, context: Context):
context.scene.camera = context.active_object
self.report({'INFO'}, f"Set active camera: {context.active_object.name}")
return {'FINISHED'}
@@ -219,17 +225,17 @@ class VIEW3D_OT_view_camera_with_poll(Operator):
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
def poll(cls, context: Context) -> bool:
if not get_current_camera(context):
cls.poll_message_set("No active camera.")
return False
return True
def execute(self, context):
def execute(self, _context: Context):
return bpy.ops.view3d.view_camera()
def get_current_camera(context):
def get_current_camera(context: Context) -> Object | None:
space = context.area.spaces.active
if space.type == 'VIEW_3D' and space.use_local_camera:
return space.camera
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,150 @@
# SPDX-FileCopyrightText: 2016-2026 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from bpy.types import Context, Menu, Object, Operator, Spline
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
class PIE_MT_curve_delete(Menu):
bl_idname = "PIE_MT_curve_delete"
bl_label = "Curve Delete"
def draw(self, _context: Context):
layout = self.layout
pie = layout.menu_pie()
# 4 - LEFT
pie.operator(CURVE_OT_dissolve_verts.bl_idname, text="Dissolve Points")
# 6 - RIGHT
pie.operator(CURVE_OT_delete_verts.bl_idname, text="Delete Points")
# 2 - BOTTOM
pie.operator(CURVE_OT_delete_segment.bl_idname, text="Delete Segments")
# 8 - TOP
# 7 - TOP - LEFT
# 9 - TOP - RIGHT
# 1 - BOTTOM - LEFT
# 3 - BOTTOM - RIGHT
class CURVE_OT_delete_verts(Operator):
"""Delete selected curve points"""
bl_idname = "curve.delete_verts"
bl_label = "Delete Points"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context: Context) -> bool:
curve_objects = _curve_objects_in_edit_mode(context)
if not curve_objects:
cls.poll_message_set("Must be editing a curve.")
return False
if any(spline_has_any_selected(spline) for obj in curve_objects for spline in obj.data.splines):
return True
cls.poll_message_set("No points are selected.")
return False
def execute(self, _context: Context):
return bpy.ops.curve.delete(type='VERT')
class CURVE_OT_dissolve_verts(Operator):
"""Dissolve selected Bezier points, preserving the curve shape"""
bl_idname = "curve.dissolve_verts_py"
bl_label = "Dissolve Points"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context: Context) -> bool:
curve_objects = _curve_objects_in_edit_mode(context)
if not curve_objects:
cls.poll_message_set("Must be editing a curve.")
return False
if any(
spline_has_dissolvable_bezier_point(spline) #
for obj in curve_objects
for spline in obj.data.splines
):
return True
cls.poll_message_set("No selected Bezier points with neighbours on both sides.")
return False
def execute(self, _context: Context):
return bpy.ops.curve.dissolve_verts()
class CURVE_OT_delete_segment(Operator):
"""Delete segments between selected consecutive curve points"""
bl_idname = "curve.delete_segment"
bl_label = "Delete Segments"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context: Context) -> bool:
curve_objects = _curve_objects_in_edit_mode(context)
if not curve_objects:
cls.poll_message_set("Must be editing a curve.")
return False
if any(
spline_has_consecutive_selected(spline) #
for obj in curve_objects
for spline in obj.data.splines
):
return True
cls.poll_message_set("No two consecutive points are selected.")
return False
def execute(self, _context: Context):
return bpy.ops.curve.delete(type='SEGMENT')
def _curve_objects_in_edit_mode(context: Context) -> list[Object]:
return [obj for obj in (context.objects_in_mode or []) if obj.type == 'CURVE' and obj.data]
def spline_has_dissolvable_bezier_point(spline: Spline) -> bool:
if spline.type != 'BEZIER':
return False
points = spline.bezier_points
point_count = len(points)
if spline.use_cyclic_u:
return point_count > 2 and spline_has_any_selected(spline)
return point_count >= 3 and any(point.select_control_point for point in points[1 : point_count - 1])
def spline_has_any_selected(spline: Spline) -> bool:
if spline.type == 'BEZIER':
return any(point.select_control_point for point in spline.bezier_points)
return any(point.select for point in spline.points)
def spline_has_consecutive_selected(spline: Spline) -> bool:
if spline.type == 'BEZIER':
sel = [point.select_control_point for point in spline.bezier_points]
else:
sel = [point.select for point in spline.points]
for prev, curr in zip(sel, sel[1:]):
if prev and curr:
return True
return spline.use_cyclic_u and len(sel) >= 2 and sel[-1] and sel[0]
registry = [
CURVE_OT_delete_verts,
CURVE_OT_dissolve_verts,
CURVE_OT_delete_segment,
PIE_MT_curve_delete,
]
def register():
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name="Curve",
pie_name=PIE_MT_curve_delete.bl_idname,
hotkey_kwargs={'type': "X", 'value': "PRESS"},
on_drag=True,
)
@@ -1,109 +1,79 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
# SPDX-FileCopyrightText: 2016-2026 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy, sys, json
from bpy.types import Menu, Operator
import json
import bpy
from bpy.props import StringProperty
from bpy.types import Context, Menu, Operator
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
AVAILABLE_PROPERTIES_EDITORS = []
class PIE_MT_editor_switch(Menu):
bl_idname = "PIE_MT_editor_switch"
bl_label = "Editor Switch"
def draw(self, context):
def draw(self, _context: Context):
layout = self.layout
pie = layout.menu_pie()
# 4 - LEFT
pie.operator(
'wm.call_menu_pie', text="Video Editing...", icon="SEQUENCE"
).name = "PIE_MT_editor_switch_video"
pie.operator('wm.call_menu_pie', text="Video Editing...", icon="SEQUENCE").name = "PIE_MT_editor_switch_video"
# 6 - RIGHT
pie.operator('wm.call_menu_pie', text="Nodes...", icon="NODETREE").name = (
"PIE_MT_editor_switch_nodes"
)
pie.operator('wm.call_menu_pie', text="Nodes...", icon="NODETREE").name = "PIE_MT_editor_switch_nodes"
# 2 - BOTTOM
pie.operator('wm.call_menu_pie', text="Data...", icon="PRESET").name = (
"PIE_MT_editor_switch_data"
)
pie.operator('wm.call_menu_pie', text="Data...", icon="PRESET").name = "PIE_MT_editor_switch_data"
# 8 - TOP
pie.operator(
WM_OT_set_area_type.bl_idname, text="3D View", icon="VIEW3D"
).ui_type = "VIEW_3D"
pie.operator(WM_OT_set_area_type.bl_idname, text="3D View", icon="VIEW3D").ui_type = "VIEW_3D"
# 7 - TOP - LEFT
pie.operator(
'wm.call_menu_pie', text="2D Editors...", icon="FILE_IMAGE"
).name = "PIE_MT_editor_switch_image"
pie.operator('wm.call_menu_pie', text="2D Editors...", icon="FILE_IMAGE").name = "PIE_MT_editor_switch_image"
# 9 - TOP - RIGHT
pie.operator('wm.call_menu_pie', text="Script...", icon="SCRIPT").name = (
"PIE_MT_editor_switch_script"
)
pie.operator('wm.call_menu_pie', text="Script...", icon="SCRIPT").name = "PIE_MT_editor_switch_script"
# 1 - BOTTOM - LEFT
pie.separator()
# 3 - BOTTOM - RIGHT
pie.operator(
'wm.call_menu_pie', text="Animation...", icon="ARMATURE_DATA"
).name = "PIE_MT_editor_switch_anim"
pie.operator('wm.call_menu_pie', text="Animation...", icon="ARMATURE_DATA").name = "PIE_MT_editor_switch_anim"
class PIE_MT_editor_switch_video(Menu):
bl_idname = "PIE_MT_editor_switch_video"
bl_label = "Editor Switch: Video"
def draw(self, context):
def draw(self, _context: Context):
layout = self.layout
pie = layout.menu_pie()
# 4 - LEFT
op = pie.operator(
WM_OT_set_area_type.bl_idname,
text="Sequencer & Preview",
icon="SEQ_SPLITVIEW",
)
op = pie.operator(WM_OT_set_area_type.bl_idname, text="Sequencer & Preview", icon="SEQ_SPLITVIEW")
op.ui_type = "SEQUENCE_EDITOR"
op.view_type = 'SEQUENCER_PREVIEW'
# 6 - RIGHT
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Movie Clip Editor", icon="SEQUENCE"
)
op = pie.operator(WM_OT_set_area_type.bl_idname, text="Movie Clip Editor", icon="SEQUENCE")
op.ui_type = "CLIP_EDITOR"
op.mode = 'TRACKING'
op.view = 'CLIP'
# 2 - BOTTOM
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Clip Mask Editor", icon="MOD_MASK"
)
op = pie.operator(WM_OT_set_area_type.bl_idname, text="Clip Mask Editor", icon="MOD_MASK")
op.ui_type = "CLIP_EDITOR"
op.mode = 'MASK'
# 8 - TOP
pie.separator()
# 7 - TOP - LEFT
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Preview", icon="SEQ_PREVIEW"
)
op = pie.operator(WM_OT_set_area_type.bl_idname, text="Preview", icon="SEQ_PREVIEW")
op.ui_type = "SEQUENCE_EDITOR"
op.view_type = 'PREVIEW'
# 9 - TOP - RIGHT
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Tracking: Dopesheet", icon="ACTION"
)
op = pie.operator(WM_OT_set_area_type.bl_idname, text="Tracking: Dopesheet", icon="ACTION")
op.ui_type = "CLIP_EDITOR"
op.mode = 'TRACKING'
op.view = 'DOPESHEET'
# 1 - BOTTOM - LEFT
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Sequencer", icon="SEQ_SEQUENCER"
)
op = pie.operator(WM_OT_set_area_type.bl_idname, text="Sequencer", icon="SEQ_SEQUENCER")
op.ui_type = "SEQUENCE_EDITOR"
op.view_type = 'SEQUENCER'
# 3 - BOTTOM - RIGHT
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Tracking: Graph", icon="GRAPH"
)
op = pie.operator(WM_OT_set_area_type.bl_idname, text="Tracking: Graph", icon="GRAPH")
op.ui_type = "CLIP_EDITOR"
op.mode = 'TRACKING'
op.view = 'GRAPH'
@@ -113,42 +83,30 @@ class PIE_MT_editor_switch_nodes(Menu):
bl_idname = "PIE_MT_editor_switch_nodes"
bl_label = "Editor Switch: Nodes"
def draw(self, context):
def draw(self, context: Context):
layout = self.layout
pie = layout.menu_pie()
# 4 - LEFT
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Compositing", icon="NODE_COMPOSITING"
)
op = pie.operator(WM_OT_set_area_type.bl_idname, text="Compositing", icon="NODE_COMPOSITING")
op.ui_type = 'CompositorNodeTree'
# 6 - RIGHT
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Shader", icon="NODE_MATERIAL"
)
op = pie.operator(WM_OT_set_area_type.bl_idname, text="Shader", icon="NODE_MATERIAL")
op.ui_type = 'ShaderNodeTree'
op.shader_type = 'OBJECT'
# 2 - BOTTOM
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Geometry", icon="GEOMETRY_NODES"
)
op = pie.operator(WM_OT_set_area_type.bl_idname, text="Geometry", icon="GEOMETRY_NODES")
op.ui_type = 'GeometryNodeTree'
# 8 - TOP
op = pie.operator(WM_OT_set_area_type.bl_idname, text="Texture", icon="TEXTURE")
op.ui_type = 'TextureNodeTree'
# 7 - TOP - LEFT
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Line Style", icon="LINE_DATA"
)
op = pie.operator(WM_OT_set_area_type.bl_idname, text="Line Style", icon="LINE_DATA")
op.ui_type = 'ShaderNodeTree'
op.shader_type = 'LINESTYLE'
# 9 - TOP - RIGHT
if get_custom_nodetree_types(context, layout):
pie.menu(
'PIE_MT_editor_switch_nodes_custom',
text="Custom Nodes",
icon='NODETREE',
)
if get_custom_nodetree_types(context):
pie.menu('PIE_MT_editor_switch_nodes_custom', text="Custom Nodes", icon='NODETREE')
else:
pie.separator()
# 1 - BOTTOM - LEFT
@@ -163,253 +121,117 @@ class PIE_MT_editor_switch_nodes_custom(Menu):
bl_idname = "PIE_MT_editor_switch_nodes_custom"
bl_label = "Editor Switch: Custom Nodes"
def draw(self, context):
def draw(self, context: Context):
layout = self.layout
for ui_type, label, icon in get_custom_nodetree_types(context, layout):
layout.operator(
WM_OT_set_area_type.bl_idname, text=label, icon=icon
).ui_type = ui_type
def get_custom_nodetree_types(context, layout):
"""There are no rules In love, war, and Python scripting.
The only way to get custom node tree types is this:
- Try to set the UI type to some nonsense
- The resulting error tells you the possible UI types
- Catch and crunch the error string into a set
- Subtract from this the set of built-in UI types
- The result is a set of the custom node tree type names
- We can get their class with bl_rna_get_subclass_py
"""
builtin_types = set(
[
'VIEW_3D',
'IMAGE_EDITOR',
'UV',
'CompositorNodeTree',
'TextureNodeTree',
'GeometryNodeTree',
'ShaderNodeTree',
'SEQUENCE_EDITOR',
'CLIP_EDITOR',
'DOPESHEET',
'TIMELINE',
'FCURVES',
'DRIVERS',
'NLA_EDITOR',
'TEXT_EDITOR',
'CONSOLE',
'INFO',
'OUTLINER',
'PROPERTIES',
'FILES',
'ASSETS',
'SPREADSHEET',
'PREFERENCES',
]
)
ret = []
try:
context.area.ui_type = "Nonsense."
except Exception as exc:
exc_type, exc_value, tb = sys.exc_info()
if not exc_value:
return []
type_list_str = (
str(exc_value)
.split("not found in ")[-1]
.replace("(", "[")
.replace(")", "]")
.replace("'", '"')
)
type_list = set(json.loads(type_list_str))
custom_types = type_list - builtin_types
for type_name in custom_types:
type_class = bpy.types.NodeTree.bl_rna_get_subclass_py(type_name)
if not type_class:
continue
ret.append((type_name, type_class.bl_label, type_class.bl_icon))
return ret
for ui_type, label, icon in get_custom_nodetree_types(context):
layout.operator(WM_OT_set_area_type.bl_idname, text=label, icon=icon).ui_type = ui_type
class PIE_MT_editor_switch_data(Menu):
bl_idname = "PIE_MT_editor_switch_data"
bl_label = "Editor Switch: Data"
def draw(self, context):
def draw(self, _context: Context):
pie = self.layout.menu_pie()
# 4 - LEFT
pie.operator(
WM_OT_set_area_type.bl_idname, text="File Browser", icon="FILEBROWSER"
).ui_type = "FILES"
pie.operator(WM_OT_set_area_type.bl_idname, text="File Browser", icon="FILEBROWSER").ui_type = "FILES"
# 6 - RIGHT
pie.operator(
WM_OT_set_area_type.bl_idname, text="Preferences", icon="PREFERENCES"
).ui_type = "PREFERENCES"
pie.operator(WM_OT_set_area_type.bl_idname, text="Preferences", icon="PREFERENCES").ui_type = "PREFERENCES"
# 2 - BOTTOM
pie.operator(
WM_OT_set_area_type.bl_idname, text="Outliner", icon="OUTLINER"
).ui_type = "OUTLINER"
pie.operator(WM_OT_set_area_type.bl_idname, text="Outliner", icon="OUTLINER").ui_type = "OUTLINER"
# 8 - TOP
pie.operator(
WM_OT_set_area_type.bl_idname, text="Properties", icon="PROPERTIES"
).ui_type = "PROPERTIES"
pie.operator(WM_OT_set_area_type.bl_idname, text="Properties", icon="PROPERTIES").ui_type = "PROPERTIES"
# 7 - TOP - LEFT
pie.operator(
WM_OT_set_area_type.bl_idname, text="Asset Browser", icon="ASSET_MANAGER"
).ui_type = "ASSETS"
pie.operator(WM_OT_set_area_type.bl_idname, text="Asset Browser", icon="ASSET_MANAGER").ui_type = "ASSETS"
# 9 - TOP - RIGHT
pie.menu(
"PIE_MT_editor_switch_properties",
text="Properties Types...",
icon="PROPERTIES",
)
pie.menu("PIE_MT_editor_switch_properties", text="Properties Types...", icon="PROPERTIES")
# 1 - BOTTOM - LEFT
pie.operator(
WM_OT_set_area_type.bl_idname, text="Spreadsheet", icon="SPREADSHEET"
).ui_type = "SPREADSHEET"
pie.operator(WM_OT_set_area_type.bl_idname, text="Spreadsheet", icon="SPREADSHEET").ui_type = "SPREADSHEET"
# 3 - BOTTOM - RIGHT
pie.menu(
"PIE_MT_editor_switch_outliner", text="Outliner Types...", icon="OUTLINER"
)
pie.menu("PIE_MT_editor_switch_outliner", text="Outliner Types...", icon="OUTLINER")
class PIE_MT_editor_switch_outliner(Menu):
bl_idname = "PIE_MT_editor_switch_outliner"
bl_label = "Editor Switch: Outliner"
def draw(self, context):
pie = self.layout#.menu_pie()
def draw(self, _context: Context):
layout = self.layout
# 4 - LEFT
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Scenes", icon="SCENE_DATA"
)
op = layout.operator(WM_OT_set_area_type.bl_idname, text="Scenes", icon="SCENE_DATA")
op.ui_type = "OUTLINER"
op.display_mode = 'SCENES'
# 6 - RIGHT
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="View Layer", icon="RENDERLAYERS"
)
op = layout.operator(WM_OT_set_area_type.bl_idname, text="View Layer", icon="RENDERLAYERS")
op.ui_type = "OUTLINER"
op.display_mode = 'VIEW_LAYER'
# 2 - BOTTOM
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Video Sequencer", icon="SEQUENCE"
)
op = layout.operator(WM_OT_set_area_type.bl_idname, text="Video Sequencer", icon="SEQUENCE")
op.ui_type = "OUTLINER"
op.display_mode = 'SEQUENCE'
# 8 - TOP
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Blender File", icon="FILE_BLEND"
)
op = layout.operator(WM_OT_set_area_type.bl_idname, text="Blender File", icon="FILE_BLEND")
op.ui_type = "OUTLINER"
op.display_mode = 'LIBRARIES'
# 7 - TOP - LEFT
op = pie.operator(WM_OT_set_area_type.bl_idname, text="Data API", icon="RNA")
op = layout.operator(WM_OT_set_area_type.bl_idname, text="Data API", icon="RNA")
op.ui_type = "OUTLINER"
op.display_mode = 'DATA_API'
# 9 - TOP - RIGHT
op = pie.operator(
WM_OT_set_area_type.bl_idname,
text="Library Overrides",
icon="LIBRARY_DATA_OVERRIDE",
)
op = layout.operator(WM_OT_set_area_type.bl_idname, text="Library Overrides", icon="LIBRARY_DATA_OVERRIDE")
op.ui_type = "OUTLINER"
op.display_mode = 'LIBRARY_OVERRIDES'
# 1 - BOTTOM - LEFT
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Orphan Data", icon="ORPHAN_DATA"
)
op = layout.operator(WM_OT_set_area_type.bl_idname, text="Orphan Data", icon="ORPHAN_DATA")
op.ui_type = "OUTLINER"
op.display_mode = 'ORPHAN_DATA'
# 3 - BOTTOM - RIGHT
def get_available_properties_editors(context):
"""Similar hack as get_custom_nodetree_types.
Ideally we need an existing Properties editor, otherwise
the list of available types is not up to date.
Although even in that case, we still don't get an error,
so this is fine.
"""
org_ui_type = context.area.ui_type
try:
space = None
for area in context.screen.areas:
if area.ui_type == 'PROPERTIES':
space = area.spaces.active
break
if not space:
context.area.ui_type = 'PROPERTIES'
space = context.space_data
space.context = "Nonsense."
except Exception as exc:
exc_type, exc_value, tb = sys.exc_info()
if not exc_value:
return []
type_list_str = (
str(exc_value)
.split("not found in ")[-1]
.replace("(", "[")
.replace(")", "]")
.replace("'", '"')
)
available_types = set(json.loads(type_list_str))
context.area.ui_type = org_ui_type
return available_types
class PIE_MT_editor_switch_properties(Menu):
bl_idname = "PIE_MT_editor_switch_properties"
bl_label = "Editor Switch: Properties"
def draw(self, context):
def draw(self, context: Context):
layout = self.layout
available_editors = get_available_properties_editors(context)
def safe_draw(pie, text, icon, context_name):
def draw_entry(text, icon, context_name):
if context_name in available_editors:
op = pie.operator(WM_OT_set_area_type.bl_idname, text=text, icon=icon)
op = layout.operator(WM_OT_set_area_type.bl_idname, text=text, icon=icon)
op.ui_type = "PROPERTIES"
op.context_name = context_name
else:
pie.separator()
layout.separator()
safe_draw(layout, "Render", 'SCENE', 'RENDER')
safe_draw(layout, "Output", 'OUTPUT', 'OUTPUT')
safe_draw(layout, "View Layer", 'RENDERLAYERS', 'VIEW_LAYER')
safe_draw(layout, "Scene", 'SCENE_DATA', 'SCENE')
safe_draw(layout, "World", 'WORLD', 'WORLD')
draw_entry("Render", 'SCENE', 'RENDER')
draw_entry("Output", 'OUTPUT', 'OUTPUT')
draw_entry("View Layer", 'RENDERLAYERS', 'VIEW_LAYER')
draw_entry("Scene", 'SCENE_DATA', 'SCENE')
draw_entry("World", 'WORLD', 'WORLD')
layout.separator()
safe_draw(layout, "Object", 'OBJECT_DATAMODE', 'OBJECT')
safe_draw(layout, "Modifiers", 'MODIFIER', 'MODIFIER')
safe_draw(layout, "Particles", 'PARTICLES', 'PARTICLES')
safe_draw(layout, "Physics", 'PHYSICS', 'PHYSICS')
safe_draw(layout, "Constraints", 'CONSTRAINT', 'CONSTRAINT')
safe_draw(layout, "Object Data", 'OUTLINER_DATA_MESH', 'DATA')
safe_draw(layout, "Material", 'MATERIAL', 'MATERIAL')
draw_entry("Object", 'OBJECT_DATAMODE', 'OBJECT')
draw_entry("Modifiers", 'MODIFIER', 'MODIFIER')
draw_entry("Particles", 'PARTICLES', 'PARTICLES')
draw_entry("Physics", 'PHYSICS', 'PHYSICS')
draw_entry("Constraints", 'CONSTRAINT', 'CONSTRAINT')
draw_entry("Object Data", 'OUTLINER_DATA_MESH', 'DATA')
draw_entry("Material", 'MATERIAL', 'MATERIAL')
layout.separator()
safe_draw(layout, "Texture", 'TEXTURE', 'TEXTURE')
draw_entry("Texture", 'TEXTURE', 'TEXTURE')
class PIE_MT_editor_switch_image(Menu):
bl_idname = "PIE_MT_editor_switch_image"
bl_label = "Editor Switch: 2D Editors"
def draw(self, context):
def draw(self, _context: Context):
pie = self.layout.menu_pie()
# 4 - LEFT
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Image Viewer", icon="SEQ_PREVIEW"
)
op = pie.operator(WM_OT_set_area_type.bl_idname, text="Image Viewer", icon="SEQ_PREVIEW")
op.ui_type = "IMAGE_EDITOR"
op.ui_mode = "VIEW"
# 6 - RIGHT
@@ -420,17 +242,13 @@ class PIE_MT_editor_switch_image(Menu):
# 8 - TOP
pie.separator()
# 7 - TOP - LEFT
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Image Paint", icon="TPAINT_HLT"
)
op = pie.operator(WM_OT_set_area_type.bl_idname, text="Image Paint", icon="TPAINT_HLT")
op.ui_type = "IMAGE_EDITOR"
op.ui_mode = "PAINT"
# 9 - TOP - RIGHT
pie.separator()
# 1 - BOTTOM - LEFT
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Image Masking", icon="MOD_MASK"
)
op = pie.operator(WM_OT_set_area_type.bl_idname, text="Image Masking", icon="MOD_MASK")
op.ui_type = "IMAGE_EDITOR"
op.ui_mode = "PAINT"
# 3 - BOTTOM - RIGHT
@@ -440,23 +258,17 @@ class PIE_MT_editor_switch_script(Menu):
bl_idname = "PIE_MT_editor_switch_script"
bl_label = "Editor Switch: Script"
def draw(self, context):
def draw(self, _context: Context):
pie = self.layout.menu_pie()
# 4 - LEFT
pie.separator()
# 6 - RIGHT
pie.operator(
WM_OT_set_area_type.bl_idname, text="Text Editor", icon="TEXT"
).ui_type = "TEXT_EDITOR"
pie.operator(WM_OT_set_area_type.bl_idname, text="Text Editor", icon="TEXT").ui_type = "TEXT_EDITOR"
# 2 - BOTTOM
pie.operator(
WM_OT_set_area_type.bl_idname, text="Python Console", icon="CONSOLE"
).ui_type = "CONSOLE"
pie.operator(WM_OT_set_area_type.bl_idname, text="Python Console", icon="CONSOLE").ui_type = "CONSOLE"
# 8 - TOP
pie.operator(
WM_OT_set_area_type.bl_idname, text="Info", icon="INFO"
).ui_type = "INFO"
pie.operator(WM_OT_set_area_type.bl_idname, text="Info", icon="INFO").ui_type = "INFO"
# 7 - TOP - LEFT
# 9 - TOP - RIGHT
# 1 - BOTTOM - LEFT
@@ -467,26 +279,20 @@ class PIE_MT_editor_switch_anim(Menu):
bl_idname = "PIE_MT_editor_switch_anim"
bl_label = "Editor Switch: Animation"
def draw(self, context):
def draw(self, _context: Context):
layout = self.layout
pie = layout.menu_pie()
# 4 - LEFT
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Grease Pencil", icon="GREASEPENCIL"
)
op = pie.operator(WM_OT_set_area_type.bl_idname, text="Grease Pencil", icon="GREASEPENCIL")
op.ui_type = 'DOPESHEET'
op.ui_mode = 'GPENCIL'
# 6 - RIGHT
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Dopesheet", icon="ACTION"
)
op = pie.operator(WM_OT_set_area_type.bl_idname, text="Dopesheet", icon="ACTION")
op.ui_type = 'DOPESHEET'
op.ui_mode = 'DOPESHEET'
# 2 - BOTTOM
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Driver Editor", icon="DRIVER"
)
op = pie.operator(WM_OT_set_area_type.bl_idname, text="Driver Editor", icon="DRIVER")
op.ui_type = 'DRIVERS'
# 8 - TOP
op = pie.operator(WM_OT_set_area_type.bl_idname, text="Timeline", icon="TIME")
@@ -494,17 +300,13 @@ class PIE_MT_editor_switch_anim(Menu):
# 7 - TOP - LEFT
pie.separator()
# 9 - TOP - RIGHT
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Graph Editor", icon="GRAPH"
)
op = pie.operator(WM_OT_set_area_type.bl_idname, text="Graph Editor", icon="GRAPH")
op.ui_type = 'FCURVES'
# 1 - BOTTOM - LEFT
op = pie.operator(WM_OT_set_area_type.bl_idname, text="NLA Editor", icon="NLA")
op.ui_type = 'NLA_EDITOR'
# 3 - BOTTOM - RIGHT
op = pie.operator(
WM_OT_set_area_type.bl_idname, text="Action Editor", icon="OBJECT_DATAMODE"
)
op = pie.operator(WM_OT_set_area_type.bl_idname, text="Action Editor", icon="OBJECT_DATAMODE")
op.ui_type = 'DOPESHEET'
op.ui_mode = 'ACTION'
@@ -525,7 +327,7 @@ class WM_OT_set_area_type(Operator):
mode: StringProperty(default="", options={'SKIP_SAVE'})
view: StringProperty(default="", options={'SKIP_SAVE'})
def execute(self, context):
def execute(self, context: Context):
context.area.ui_type = self.ui_type
if self.view_type:
context.space_data.view_type = self.view_type
@@ -541,10 +343,66 @@ class WM_OT_set_area_type(Operator):
context.space_data.mode = self.mode
if self.view:
context.space_data.view = self.view
return {'FINISHED'}
def get_custom_nodetree_types(context: Context) -> list[tuple[str, str, str]]:
"""There are no rules in love, war, and Python scripting.
The only way to get custom node tree types is this:
- Try to set the UI type to some nonsense
- The resulting error tells you the possible UI types
- Catch and crunch the error string into a set
- Subtract from this the set of built-in UI types
- The result is a set of the custom node tree type names
- We can get their class with bl_rna_get_subclass_py
"""
builtin_types = {
'VIEW_3D', 'IMAGE_EDITOR', 'UV',
'CompositorNodeTree', 'TextureNodeTree', 'GeometryNodeTree', 'ShaderNodeTree',
'SEQUENCE_EDITOR', 'CLIP_EDITOR',
'DOPESHEET', 'TIMELINE', 'FCURVES', 'DRIVERS', 'NLA_EDITOR',
'TEXT_EDITOR', 'CONSOLE', 'INFO',
'OUTLINER', 'PROPERTIES', 'FILES', 'ASSETS', 'SPREADSHEET', 'PREFERENCES',
}
ret = []
try:
context.area.ui_type = "Nonsense."
except Exception as exc:
type_list_str = str(exc).split("not found in ")[-1].replace("(", "[").replace(")", "]").replace("'", '"')
custom_types = set(json.loads(type_list_str)) - builtin_types
for type_name in custom_types:
type_class = bpy.types.NodeTree.bl_rna_get_subclass_py(type_name)
if not type_class:
continue
ret.append((type_name, type_class.bl_label, type_class.bl_icon))
return ret
def get_available_properties_editors(context: Context) -> set[str]:
"""Similar hack as get_custom_nodetree_types.
Ideally we need an existing Properties editor, otherwise
the list of available types is not up to date.
Although even in that case, we still don't get an error,
so this is fine.
"""
org_ui_type = context.area.ui_type
try:
space = None
for area in context.screen.areas:
if area.ui_type == 'PROPERTIES':
space = area.spaces.active
break
if not space:
context.area.ui_type = 'PROPERTIES'
space = context.space_data
space.context = "Nonsense."
except Exception as exc:
type_list_str = str(exc).split("not found in ")[-1].replace("(", "[").replace(")", "]").replace("'", '"')
context.area.ui_type = org_ui_type
return set(json.loads(type_list_str))
return set()
registry = [
PIE_MT_editor_switch,
PIE_MT_editor_switch_video,
@@ -1,11 +1,11 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
# SPDX-FileCopyrightText: 2016-2026 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from bpy.types import Menu, Operator
from bpy.props import BoolProperty
from bpy.types import Context, Menu, Operator
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
@@ -13,7 +13,7 @@ class PIE_MT_file(Menu):
bl_idname = "PIE_MT_file"
bl_label = "File"
def draw(self, context):
def draw(self, _context: Context):
layout = self.layout
pie = layout.menu_pie()
# 4 - LEFT
@@ -25,13 +25,9 @@ class PIE_MT_file(Menu):
# 8 - TOP
pie.menu("TOPBAR_MT_file_import", text="Import...", icon='IMPORT')
# 7 - TOP - LEFT
pie.operator('wm.call_menu_pie', text="Library...", icon='LINK_BLEND').name = (
"PIE_MT_library"
)
pie.operator('wm.call_menu_pie', text="Library...", icon='LINK_BLEND').name = "PIE_MT_library"
# 9 - TOP - RIGHT
pie.operator(
"wm.save_mainfile", text="Save Incremental", icon='FILE_TICK'
).incremental = True
pie.operator("wm.save_mainfile", text="Save Incremental", icon='FILE_TICK').incremental = True
# 1 - BOTTOM - LEFT
pie.menu("TOPBAR_MT_file_open_recent", icon='FILE_FOLDER')
# 3 - BOTTOM - RIGHT
@@ -42,7 +38,7 @@ class PIE_MT_library(Menu):
bl_idname = "PIE_MT_library"
bl_label = "Library"
def draw(self, context):
def draw(self, _context: Context):
pie = self.layout.menu_pie()
# 4 - LEFT
@@ -50,29 +46,17 @@ class PIE_MT_library(Menu):
# 6 - RIGHT
pie.operator("wm.append", text="Append", icon='APPEND_BLEND')
# 2 - BOTTOM
pie.operator(
"object.make_local", text="Make All Local", icon='FILE_BLEND'
).type = 'ALL'
pie.operator("object.make_local", text="Make All Local", icon='FILE_BLEND').type = 'ALL'
# 8 - TOP
pie.prop(bpy.data, 'use_autopack', text="Auto-pack")
# 7 - TOP - LEFT
pie.operator(
"file.pack_res_and_lib", text="Pack All Local", icon='PACKAGE'
).pack = True
pie.operator("file.pack_res_and_lib", text="Pack All Local", icon='PACKAGE').pack = True
# 9 - TOP - RIGHT
pie.operator(
"file.pack_res_and_lib", text="Unpack All Local", icon='UGLYPACKAGE'
).pack = False
pie.operator("file.pack_res_and_lib", text="Unpack All Local", icon='UGLYPACKAGE').pack = False
# 1 - BOTTOM - LEFT
pie.operator(
"file.make_paths_relative", icon='FILE', text="Make Paths Relative"
)
pie.operator("file.make_paths_relative", icon='FILE', text="Make Paths Relative")
# 3 - BOTTOM - RIGHT
pie.operator(
"file.make_paths_absolute",
icon='LIBRARY_DATA_BROKEN',
text="Make Paths Absolute",
)
pie.operator("file.make_paths_absolute", icon='LIBRARY_DATA_BROKEN', text="Make Paths Absolute")
class WM_OT_pack_res_and_lib(Operator):
@@ -84,17 +68,21 @@ class WM_OT_pack_res_and_lib(Operator):
pack: BoolProperty()
def execute(self, context):
def execute(self, _context: Context):
if self.pack:
bpy.ops.file.pack_libraries()
bpy.ops.file.pack_all()
else:
bpy.ops.file.unpack_libraries()
bpy.ops.file.unpack_all()
return {'FINISHED'}
def draw_revert(self, _context: Context):
self.layout.operator('wm.revert_mainfile', icon='LOOP_BACK')
self.layout.separator()
registry = [
PIE_MT_file,
PIE_MT_library,
@@ -102,11 +90,6 @@ registry = [
]
def draw_revert(self, context):
self.layout.operator('wm.revert_mainfile', icon='LOOP_BACK')
self.layout.separator()
def register():
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name="Window",
@@ -115,7 +98,6 @@ def register():
default_fallback_op='wm.save_mainfile',
on_drag=True,
)
bpy.types.TOPBAR_MT_file_recover.prepend(draw_revert)
@@ -0,0 +1,191 @@
# SPDX-FileCopyrightText: 2016-2026 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
from bpy.types import Context, Menu
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
class PIE_MT_keyframe_type(Menu):
bl_idname = "PIE_MT_keyframe_type"
bl_label = "Keyframe Type"
def draw(self, context: Context):
pie = self.layout.menu_pie()
# 4 - LEFT
pie.operator('action.keyframe_type', text="Keyframe", icon='KEYTYPE_KEYFRAME_VEC').type = 'KEYFRAME'
# 6 - RIGHT
pie.operator('action.keyframe_type', text="Extreme", icon='KEYTYPE_EXTREME_VEC').type = 'EXTREME'
# 2 - BOTTOM
pie.operator('action.keyframe_type', text="Breakdown", icon='KEYTYPE_BREAKDOWN_VEC').type = 'BREAKDOWN'
# 8 - TOP
pie.operator('action.keyframe_type', text="Moving Hold", icon='KEYTYPE_MOVING_HOLD_VEC').type = 'MOVING_HOLD'
# 7 - TOP - LEFT
pie.separator()
# 9 - TOP - RIGHT
pie.separator()
# 1 - BOTTOM - LEFT
pie.operator('action.keyframe_type', text="Generated", icon='KEYTYPE_GENERATED_VEC').type = 'GENERATED'
# 3 - BOTTOM - RIGHT
pie.operator('action.keyframe_type', text="Jitter", icon='KEYTYPE_JITTER_VEC').type = 'JITTER'
class PIE_MT_handle_type(Menu):
bl_idname = "PIE_MT_handle_type"
bl_label = "Handle Type"
def draw(self, context: Context):
pie = self.layout.menu_pie()
base = 'graph' if context.area.type == 'GRAPH_EDITOR' else 'action'
# 4 - LEFT
pie.operator(f'{base}.handle_type', text="Free", icon='HANDLE_FREE').type = 'FREE'
# 6 - RIGHT
pie.operator(f'{base}.handle_type', text="Aligned", icon='HANDLE_ALIGNED').type = 'ALIGNED'
# 2 - BOTTOM
pie.operator(f'{base}.handle_type', text="Vector", icon='HANDLE_VECTOR').type = 'VECTOR'
# 8 - TOP
pie.operator(f'{base}.handle_type', text="Automatic", icon='HANDLE_AUTO').type = 'AUTO'
# 7 - TOP - LEFT
pie.operator(f'{base}.handle_type', text="Auto Clamped", icon='HANDLE_AUTOCLAMPED').type = 'AUTO_CLAMPED'
class PIE_MT_interpolation_mode(Menu):
bl_idname = "PIE_MT_interpolation_mode"
bl_label = "Interpolation Mode"
def draw(self, context: Context):
pie = self.layout.menu_pie()
base = 'graph' if context.area.type == 'GRAPH_EDITOR' else 'action'
# 4 - LEFT
pie.operator(f'{base}.interpolation_type', text="Constant", icon='IPO_CONSTANT').type = 'CONSTANT'
# 6 - RIGHT
pie.operator(f'{base}.interpolation_type', text="Bezier", icon='IPO_BEZIER').type = 'BEZIER'
# 2 - BOTTOM
pie.separator()
# 8 - TOP
pie.operator(f'{base}.interpolation_type', text="Linear", icon='IPO_LINEAR').type = 'LINEAR'
# 7 - TOP - LEFT
pie.separator()
# 9 - TOP - RIGHT
pie.menu("PIE_MT_interpolation_dynamic", text="Dynamic...", icon='IPO_BACK')
# 1 - BOTTOM - LEFT
pie.separator()
# 3 - BOTTOM - RIGHT
pie.menu("PIE_MT_interpolation_easing", text="Easing...", icon='IPO_SINE')
class PIE_MT_interpolation_easing(Menu):
bl_idname = "PIE_MT_interpolation_easing"
bl_label = "Easing Interpolation"
def draw(self, context: Context):
layout = self.layout
base = 'graph' if context.area.type == 'GRAPH_EDITOR' else 'action'
layout.operator(f'{base}.interpolation_type', text="Sinusoidal", icon='IPO_SINE').type = 'SINE'
layout.operator(f'{base}.interpolation_type', text="Quadratic", icon='IPO_QUAD').type = 'QUAD'
layout.operator(f'{base}.interpolation_type', text="Cubic", icon='IPO_CUBIC').type = 'CUBIC'
layout.operator(f'{base}.interpolation_type', text="Quartic", icon='IPO_QUART').type = 'QUART'
layout.operator(f'{base}.interpolation_type', text="Quintic", icon='IPO_QUINT').type = 'QUINT'
layout.operator(f'{base}.interpolation_type', text="Exponential", icon='IPO_EXPO').type = 'EXPO'
layout.operator(f'{base}.interpolation_type', text="Circular", icon='IPO_CIRC').type = 'CIRC'
class PIE_MT_interpolation_dynamic(Menu):
bl_idname = "PIE_MT_interpolation_dynamic"
bl_label = "Dynamic Effects"
def draw(self, context: Context):
layout = self.layout
base = 'graph' if context.area.type == 'GRAPH_EDITOR' else 'action'
layout.operator(f'{base}.interpolation_type', text="Back", icon='IPO_BACK').type = 'BACK'
layout.operator(f'{base}.interpolation_type', text="Bounce", icon='IPO_BOUNCE').type = 'BOUNCE'
layout.operator(f'{base}.interpolation_type', text="Elastic", icon='IPO_ELASTIC').type = 'ELASTIC'
class PIE_MT_easing_mode(Menu):
bl_idname = "PIE_MT_easing_mode"
bl_label = "Easing Mode"
def draw(self, context: Context):
pie = self.layout.menu_pie()
base = 'graph' if context.area.type == 'GRAPH_EDITOR' else 'action'
# 4 - LEFT
pie.operator(f'{base}.easing_type', text="Ease In", icon='IPO_EASE_IN').type = 'EASE_IN'
# 6 - RIGHT
pie.operator(f'{base}.easing_type', text="Ease Out", icon='IPO_EASE_OUT').type = 'EASE_OUT'
# 2 - BOTTOM
pie.operator(f'{base}.easing_type', text="Ease In and Out", icon='IPO_EASE_IN_OUT').type = 'EASE_IN_OUT'
# 8 - TOP
pie.operator(f'{base}.easing_type', text="Automatic", icon='AUTO').type = 'AUTO'
class PIE_MT_extrapolation_mode(Menu):
bl_idname = "PIE_MT_extrapolation_mode"
bl_label = "Extrapolation Mode"
def draw(self, _context: Context):
pie = self.layout.menu_pie()
# 4 - LEFT
pie.operator('graph.extrapolation_type', text="Constant", icon='IPO_CONSTANT').type = 'CONSTANT'
# 6 - RIGHT
pie.operator('graph.extrapolation_type', text="Linear", icon='IPO_LINEAR').type = 'LINEAR'
# 2 - BOTTOM
pie.operator('graph.extrapolation_type', text="Clear Cyclic", icon='X').type = 'CLEAR_CYCLIC'
# 8 - TOP
pie.operator('graph.extrapolation_type', text="Make Cyclic", icon='FILE_REFRESH').type = 'MAKE_CYCLIC'
registry = [
PIE_MT_keyframe_type,
PIE_MT_handle_type,
PIE_MT_interpolation_mode,
PIE_MT_interpolation_easing,
PIE_MT_interpolation_dynamic,
PIE_MT_easing_mode,
PIE_MT_extrapolation_mode,
]
def register():
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name='Dopesheet',
pie_name=PIE_MT_keyframe_type.bl_idname,
hotkey_kwargs={'type': "R", 'value': "PRESS"},
on_drag=False,
)
for keymap in ('Dopesheet', 'Graph Editor'):
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name=keymap,
pie_name=PIE_MT_handle_type.bl_idname,
hotkey_kwargs={'type': "V", 'value': "PRESS"},
on_drag=False,
)
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name=keymap,
pie_name=PIE_MT_interpolation_mode.bl_idname,
hotkey_kwargs={'type': "T", 'value': "PRESS"},
on_drag=False,
)
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name=keymap,
pie_name=PIE_MT_easing_mode.bl_idname,
hotkey_kwargs={'type': "E", 'value': "PRESS", 'ctrl': True},
on_drag=False,
)
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name=keymap,
pie_name=PIE_MT_extrapolation_mode.bl_idname,
hotkey_kwargs={'type': "E", 'value': "PRESS", 'shift': True},
on_drag=False,
)
@@ -1,9 +1,9 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
# SPDX-FileCopyrightText: 2016-2026 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
from bpy.types import Menu, Operator
from bpy.props import EnumProperty, BoolProperty
from bpy.props import BoolProperty, EnumProperty
from bpy.types import Context, Event, Menu, Operator
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
@@ -12,10 +12,9 @@ class PIE_MT_manipulator(Menu):
bl_idname = "PIE_MT_manipulator"
bl_label = "Manipulator"
def draw(self, context):
def draw(self, context: Context):
layout = self.layout
pie = layout.menu_pie()
space = context.space_data
# 4 - LEFT
@@ -27,25 +26,31 @@ class PIE_MT_manipulator(Menu):
'view3d.set_manipulator', text="Scale", icon='CON_SIZELIKE', depress=space.show_gizmo_object_scale
).manipulator = 'SCALE'
# 2 - BOTTOM
pie.operator(
'view3d.set_manipulator', text="None", icon='X'
).manipulator='NONE'
pie.operator('view3d.set_manipulator', text="None", icon='X').manipulator = 'NONE'
# 8 - TOP
pie.operator(
'view3d.set_manipulator', text="Location", icon='CON_LOCLIKE', depress=space.show_gizmo_object_translate
).manipulator = 'LOC'
# 7 - TOP-LEFT
# 7 - TOP - LEFT
pie.operator(
'view3d.set_manipulator', text="Loc/Rot", icon='CON_LOCLIKE', depress=space.show_gizmo_object_translate and space.show_gizmo_object_rotate
).manipulator='LOCROT'
# 9 - TOP-RIGHT
'view3d.set_manipulator',
text="Loc/Rot",
icon='CON_LOCLIKE',
depress=space.show_gizmo_object_translate and space.show_gizmo_object_rotate,
).manipulator = 'LOCROT'
# 9 - TOP - RIGHT
pie.operator(
'view3d.set_manipulator', text="Loc/Scale", icon='CON_SIZELIKE', depress=space.show_gizmo_object_translate and space.show_gizmo_object_scale
).manipulator='LOCSCALE'
# 1 - BOT-LEFT
'view3d.set_manipulator',
text="Loc/Scale",
icon='CON_SIZELIKE',
depress=space.show_gizmo_object_translate and space.show_gizmo_object_scale,
).manipulator = 'LOCSCALE'
# 1 - BOTTOM - LEFT
pie.operator(
'view3d.set_manipulator', text="Loc/Rot/Scale", icon='CON_LOCLIKE', depress=space.show_gizmo_object_translate and space.show_gizmo_object_rotate and space.show_gizmo_object_scale
'view3d.set_manipulator',
text="Loc/Rot/Scale",
icon='CON_LOCLIKE',
depress=space.show_gizmo_object_translate and space.show_gizmo_object_rotate and space.show_gizmo_object_scale,
).manipulator = 'LOCROTSCALE'
@@ -75,19 +80,19 @@ class VIEW3D_OT_set_manipulator(Operator):
name="Toggle",
description="Hold Shift to toggle the selected manipulator rather than setting it as the only active one",
default=False,
options={'SKIP_SAVE'}
options={'SKIP_SAVE'},
)
@classmethod
def poll(cls, context):
def poll(cls, context: Context) -> bool:
space = context.space_data
return space and space.type == 'VIEW_3D'
def invoke(self, context, event):
def invoke(self, context: Context, event: Event):
self.toggle = event.shift
return self.execute(context)
def execute(self, context):
def execute(self, context: Context):
space = context.space_data
if self.manipulator != 'NONE':
@@ -1,8 +1,8 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
# SPDX-FileCopyrightText: 2016-2026 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
from bpy.types import Menu
from bpy.types import Context, Menu
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
@@ -11,39 +11,29 @@ class PIE_MT_mesh_delete(Menu):
bl_idname = "PIE_MT_mesh_delete"
bl_label = "Mesh Delete"
def draw(self, context):
def draw(self, _context: Context):
layout = self.layout
pie = layout.menu_pie()
# 4 - LEFT
box = pie.split().column()
box.operator(
"mesh.dissolve_limited", text="Limited Dissolve", icon='STICKY_UVS_LOC'
)
box.operator("mesh.dissolve_limited", text="Limited Dissolve", icon='STICKY_UVS_LOC')
box.operator("mesh.delete_edgeloop", text="Delete Edge Loops", icon='NONE')
box.operator("mesh.edge_collapse", text="Edge Collapse", icon='UV_EDGESEL')
# 6 - RIGHT
box = pie.split().column()
box.operator("mesh.remove_doubles", text="Merge By Distance", icon='NONE')
box.operator("mesh.delete", text="Only Edge & Faces", icon='NONE').type = (
'EDGE_FACE'
)
box.operator("mesh.delete", text="Only Faces", icon='UV_FACESEL').type = (
'ONLY_FACE'
)
box.operator("mesh.delete", text="Only Edge & Faces", icon='NONE').type = 'EDGE_FACE'
box.operator("mesh.delete", text="Only Faces", icon='UV_FACESEL').type = 'ONLY_FACE'
# 2 - BOTTOM
pie.operator("mesh.dissolve_edges", text="Dissolve Edges", icon='SNAP_EDGE')
# 8 - TOP
pie.operator("mesh.delete", text="Delete Edges", icon='EDGESEL').type = 'EDGE'
# 7 - TOP - LEFT
pie.operator("mesh.delete", text="Delete Vertices", icon='VERTEXSEL').type = (
'VERT'
)
pie.operator("mesh.delete", text="Delete Vertices", icon='VERTEXSEL').type = 'VERT'
# 9 - TOP - RIGHT
pie.operator("mesh.delete", text="Delete Faces", icon='FACESEL').type = 'FACE'
# 1 - BOTTOM - LEFT
pie.operator(
"mesh.dissolve_verts", text="Dissolve Vertices", icon='SNAP_VERTEX'
)
pie.operator("mesh.dissolve_verts", text="Dissolve Vertices", icon='SNAP_VERTEX')
# 3 - BOTTOM - RIGHT
pie.operator("mesh.dissolve_faces", text="Dissolve Faces", icon='SNAP_FACE')
@@ -0,0 +1,179 @@
# SPDX-FileCopyrightText: 2016-2026 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bmesh
import bpy
from bmesh.types import BMEdge
from bpy.types import Context, Menu, Object, Operator
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
# Above this edge count the state check is skipped to avoid poll lag.
_EDGE_POLL_LIMIT = 100_000
class PIE_MT_mesh_edge(Menu):
bl_idname = "PIE_MT_mesh_edge"
bl_label = "Mesh Edge"
def draw(self, context: Context):
layout = self.layout
pie = layout.menu_pie()
# 4 - LEFT
pie.operator(MESH_OT_pie_clear_seam.bl_idname, text="Clear Seam", icon='EDGE_SEAM')
# 6 - RIGHT
pie.operator(MESH_OT_pie_mark_seam.bl_idname, text="Mark Seam", icon='EDGE_SEAM')
# 2 - BOTTOM
pie.operator(MESH_OT_pie_clear_sharp.bl_idname, text="Clear Sharp", icon='EDGE_SHARP')
# 8 - TOP
pie.operator(MESH_OT_pie_mark_sharp.bl_idname, text="Mark Sharp", icon='EDGE_SHARP')
# 7 - TOP - LEFT
pie.operator(MESH_OT_pie_clear_freestyle_edge.bl_idname, text="Clear Freestyle Edge", icon='EDGESEL')
# 9 - TOP - RIGHT
if context.tool_settings.mesh_select_mode[0]:
pie.operator("transform.vert_crease", text="Vertex Crease", icon='VERTEX_CREASE')
else:
pie.operator("transform.edge_crease", text="Edge Crease", icon='EDGE_CREASE')
# 1 - BOTTOM - LEFT
pie.operator(MESH_OT_pie_mark_freestyle_edge.bl_idname, text="Mark Freestyle Edge", icon='EDGESEL')
# 3 - BOTTOM - RIGHT
pie.operator("transform.edge_bevelweight", text="Edge Bevel Weight", icon='EDGE_BEVEL')
class _EdgeMarkBase(Operator):
bl_options = {'REGISTER', 'UNDO'}
edge_data_type: str
do_clear: bool
@classmethod
def poll(cls, context: Context) -> bool:
obj = context.active_object
if not (obj and obj.type == 'MESH' and obj.mode == 'EDIT'):
cls.poll_message_set("Must be in Mesh Edit mode")
return False
if len(obj.data.edges) > _EDGE_POLL_LIMIT:
return True
any_have, all_have = selected_edge_data_state(obj, cls.edge_data_type)
if not any_have and all_have:
cls.poll_message_set("No edges selected")
return False
if cls.do_clear:
if not any_have:
cls.poll_message_set("No selected edges are marked")
return False
else:
if all_have:
cls.poll_message_set("All selected edges are already marked")
return False
return True
def execute(self, _context: Context):
if self.edge_data_type == 'SEAM':
bpy.ops.mesh.mark_seam(clear=self.do_clear)
elif self.edge_data_type == 'SHARP':
bpy.ops.mesh.mark_sharp(clear=self.do_clear)
elif self.edge_data_type == 'FREESTYLE':
bpy.ops.mesh.mark_freestyle_edge(clear=self.do_clear)
return {'FINISHED'}
class MESH_OT_pie_mark_seam(_EdgeMarkBase):
bl_idname = "mesh.pie_mark_seam"
bl_label = "Mark Seam"
bl_description = "Mark selected edges as seams for UV unwrapping"
edge_data_type = 'SEAM'
do_clear = False
class MESH_OT_pie_clear_seam(_EdgeMarkBase):
bl_idname = "mesh.pie_clear_seam"
bl_label = "Clear Seam"
bl_description = "Remove seam marks from selected edges"
edge_data_type = 'SEAM'
do_clear = True
class MESH_OT_pie_mark_sharp(_EdgeMarkBase):
bl_idname = "mesh.pie_mark_sharp"
bl_label = "Mark Sharp"
bl_description = "Mark selected edges as sharp"
edge_data_type = 'SHARP'
do_clear = False
class MESH_OT_pie_clear_sharp(_EdgeMarkBase):
bl_idname = "mesh.pie_clear_sharp"
bl_label = "Clear Sharp"
bl_description = "Remove sharp marks from selected edges"
edge_data_type = 'SHARP'
do_clear = True
class MESH_OT_pie_mark_freestyle_edge(_EdgeMarkBase):
bl_idname = "mesh.pie_mark_freestyle_edge"
bl_label = "Mark Freestyle Edge"
bl_description = "Mark selected edges as Freestyle edges"
edge_data_type = 'FREESTYLE'
do_clear = False
class MESH_OT_pie_clear_freestyle_edge(_EdgeMarkBase):
bl_idname = "mesh.pie_clear_freestyle_edge"
bl_label = "Clear Freestyle Edge"
bl_description = "Remove Freestyle marks from selected edges"
edge_data_type = 'FREESTYLE'
do_clear = True
def _edge_has_data(edge: BMEdge, edge_data_type: str, freestyle_layer) -> bool:
if edge_data_type == 'SEAM':
return edge.seam
if edge_data_type == 'SHARP':
return not edge.smooth
if edge_data_type == 'FREESTYLE':
return edge[freestyle_layer] if freestyle_layer else False
return False
def selected_edge_data_state(obj: Object, edge_data_type: str) -> tuple[bool, bool]:
"""Return (any_have, all_have) for selected edges of the given data type."""
bm = bmesh.from_edit_mesh(obj.data)
freestyle_layer = bm.edges.layers.bool.get("freestyle_edge") if edge_data_type == 'FREESTYLE' else None
any_have = False
all_have = True
found = False
for edge in bm.edges:
if not edge.select:
continue
found = True
has = _edge_has_data(edge, edge_data_type, freestyle_layer)
any_have |= has
all_have &= has
if any_have and not all_have:
break # Both states seen, result can't change.
if not found:
return False, False
return any_have, all_have
registry = [
PIE_MT_mesh_edge,
MESH_OT_pie_mark_seam,
MESH_OT_pie_clear_seam,
MESH_OT_pie_mark_sharp,
MESH_OT_pie_clear_sharp,
MESH_OT_pie_mark_freestyle_edge,
MESH_OT_pie_clear_freestyle_edge,
]
def register():
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name="Mesh",
pie_name=PIE_MT_mesh_edge.bl_idname,
hotkey_kwargs={'type': "E", 'value': "PRESS", 'shift': True},
on_drag=True,
)
@@ -1,10 +1,10 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
# SPDX-FileCopyrightText: 2016-2026 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from bpy.types import Menu, Operator
from bpy.props import EnumProperty
from bpy.types import Context, Menu, Operator
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
@@ -13,7 +13,7 @@ class PIE_MT_mesh_flatten(Menu):
bl_idname = "PIE_MT_mesh_flatten"
bl_label = "Mesh Flatten"
def draw(self, context):
def draw(self, _context: Context):
layout = self.layout
pie = layout.menu_pie()
# 4 - LEFT
@@ -65,11 +65,11 @@ class TRANSFORM_OT_flatten_to_center(Operator):
)
@classmethod
def poll(cls, context):
def poll(cls, context: Context):
obj = context.active_object
return obj and obj.type == "MESH"
def execute(self, context):
def execute(self, _context: Context):
values = {
'X': [(0, 1, 1), (True, False, False)],
'Y': [(1, 0, 1), (False, True, False)],
@@ -106,11 +106,11 @@ class TRANSFORM_OT_flatten_to_object_origin(Operator):
)
@classmethod
def poll(cls, context):
def poll(cls, context: Context):
obj = context.active_object
return obj and obj.type == "MESH"
def execute(self, context):
def execute(self, _context: Context):
bpy.ops.object.mode_set(mode='OBJECT')
index = "XYZ".find(self.axis)
for vert in bpy.context.object.data.vertices:
@@ -149,11 +149,11 @@ class TRANSFORM_OT_flatten_to_selection_bounding_box(Operator):
)
@classmethod
def poll(cls, context):
def poll(cls, context: Context):
obj = context.active_object
return obj and obj.type == "MESH"
def execute(self, context):
def execute(self, _context: Context):
bpy.ops.object.mode_set(mode='OBJECT')
count = 0
axis_idx = "XYZ".find(self.axis)
@@ -1,8 +1,8 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
# SPDX-FileCopyrightText: 2016-2026 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
from bpy.types import Menu
from bpy.types import Context, Menu
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
@@ -11,16 +11,14 @@ class PIE_MT_mesh_merge(Menu):
bl_idname = "PIE_MT_mesh_merge"
bl_label = "Mesh Merge"
def draw(self, context):
def draw(self, _context: Context):
pie = self.layout.menu_pie()
# 4 - LEFT
pie.operator('mesh.remove_doubles', text="By Distance", icon='PROP_ON')
# 6 - RIGHT
pie.operator('mesh.merge', text="At Center", icon='SNAP_FACE_CENTER').type = (
'CENTER'
)
pie.operator('mesh.merge', text="At Center", icon='SNAP_FACE_CENTER').type = 'CENTER'
# 2 - BOTTOM
op = pie.operator('mesh.merge', text="Collapse", icon='FULLSCREEN_EXIT')
@@ -37,13 +35,9 @@ class PIE_MT_mesh_merge(Menu):
# This will raise an error if the option isn't available.
op.type = 'FIRST'
# 7 - TOP - LEFT
pie.operator(
'mesh.merge', text="At First", icon='TRACKING_REFINE_BACKWARDS'
).type = 'FIRST'
pie.operator('mesh.merge', text="At First", icon='TRACKING_REFINE_BACKWARDS').type = 'FIRST'
# 9 - TOP - RIGHT
pie.operator(
'mesh.merge', text="At Last", icon='TRACKING_REFINE_FORWARDS'
).type = 'LAST'
pie.operator('mesh.merge', text="At Last", icon='TRACKING_REFINE_FORWARDS').type = 'LAST'
except:
op.type = 'COLLAPSE'
pie.separator()
@@ -52,9 +46,7 @@ class PIE_MT_mesh_merge(Menu):
# 1 - BOTTOM - LEFT
pie.separator()
# 3 - BOTTOM - RIGHT
pie.operator('mesh.merge', text="At 3D Cursor", icon='PIVOT_CURSOR').type = (
'CURSOR'
)
pie.operator('mesh.merge', text="At 3D Cursor", icon='PIVOT_CURSOR').type = 'CURSOR'
registry = [PIE_MT_mesh_merge]
@@ -1,10 +1,11 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
# SPDX-FileCopyrightText: 2016-2026 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
# Requested by users here: https://projects.blender.org/extensions/space_view3d_pie_menus/issues/67
from bpy.types import Menu
from bpy.types import Context, Menu
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
@@ -12,7 +13,7 @@ class PIE_MT_object_add(Menu):
bl_idname = "PIE_MT_object_add"
bl_label = "Add Object"
def draw(self, context):
def draw(self, _context: Context):
pie = self.layout.menu_pie()
# 4 - LEFT
@@ -1,9 +1,10 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
# SPDX-FileCopyrightText: 2016-2026 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from bpy.types import Menu, Operator
from bpy.types import Context, Menu, Operator
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
@@ -12,10 +13,10 @@ class PIE_MT_object_display(Menu):
bl_label = "Object Display"
@classmethod
def poll(cls, context):
def poll(cls, context: Context):
return context.active_object
def draw(self, context):
def draw(self, context: Context):
pie = self.layout.menu_pie()
obj = context.active_object
@@ -37,7 +38,7 @@ class PIE_MT_object_display(Menu):
box.label(text="Color")
row = box.row(align=True)
row.prop(obj, 'color', text="")
row.operator('view3d.copy_property_to_selected', text="", icon='LOOP_FORWARDS').rna_path='color'
row.operator('view3d.copy_property_to_selected', text="", icon='LOOP_FORWARDS').rna_path = 'color'
else:
pie.separator()
@@ -95,7 +96,7 @@ class OBJECT_OT_add_weighted_normals(Operator):
bl_label = "Weighted Normals"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
def execute(self, context: Context):
for obj in context.selected_objects:
if obj.type != 'MESH':
continue
@@ -113,7 +114,7 @@ class OBJECT_OT_reset_normals(Operator):
bl_label = "Reset Normals"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
def execute(self, context: Context):
for obj in context.selected_objects:
if obj.type != 'MESH':
continue
@@ -1,10 +1,10 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
# SPDX-FileCopyrightText: 2016-2026 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from bpy.types import Menu, Operator
from bpy.props import BoolProperty, EnumProperty
from bpy.types import Context, Menu, Operator, OperatorProperties
from mathutils import Matrix
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
@@ -15,10 +15,10 @@ class OBJECT_MT_parenting_pie(Menu):
bl_idname = 'OBJECT_MT_parenting_pie'
@classmethod
def poll(cls, context):
def poll(cls, context: Context):
return context.mode == 'OBJECT'
def draw(self, context):
def draw(self, _context: Context):
layout = self.layout
pie = layout.menu_pie()
@@ -64,7 +64,7 @@ class OBJECT_MT_parenting_pie(Menu):
pie.separator()
def selected_objs_with_parents(context):
def selected_objs_with_parents(context: Context):
return [ob for ob in context.selected_objects if ob.parent]
@@ -81,21 +81,21 @@ class OBJECT_OT_clear_parent(Operator):
)
@classmethod
def description(cls, context, properties):
def description(cls, _context: Context, properties: OperatorProperties):
if properties.keep_transform:
return "Clear the parent of selected objects, while preserving their position in space"
else:
return "Clear the parent of selected objects, without affecting their Loc/Rot/Scale values. This may cause the now parentless children to change position"
@classmethod
def poll(cls, context):
def poll(cls, context: Context):
if not selected_objs_with_parents(context):
cls.poll_message_set("No selected objects have parents.")
return False
return set_parent_poll_check_linked(cls, context)
def execute(self, context):
def execute(self, context: Context):
objs = selected_objs_with_parents(context)
op_type = 'CLEAR_KEEP_TRANSFORM' if self.keep_transform else 'CLEAR'
@@ -115,22 +115,20 @@ class OBJECT_OT_clear_parent_inverse_matrix(Operator):
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
def poll(cls, context: Context):
objs_with_parents = selected_objs_with_parents(context)
if not objs_with_parents:
cls.poll_message_set("No selected objects have parents.")
return False
identity_matrix = Matrix.Identity(4)
if not any(
[obj.matrix_parent_inverse != identity_matrix for obj in objs_with_parents]
):
if not any([obj.matrix_parent_inverse != identity_matrix for obj in objs_with_parents]):
cls.poll_message_set("No selected objects have a parenting offset set.")
return False
return set_parent_poll_check_linked(cls, context)
def execute(self, context):
def execute(self, context: Context):
objs = selected_objs_with_parents(context)
bpy.ops.object.parent_clear(type='CLEAR_INVERSE')
@@ -148,16 +146,14 @@ class OBJECT_OT_parent_set_simple(Operator):
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
def poll(cls, context: Context):
if not len(context.selected_objects) > 1 and context.active_object:
cls.poll_message_set(
"Only one object is selected. You can't parent an object to itself."
)
cls.poll_message_set("Only one object is selected. You can't parent an object to itself.")
return False
return set_parent_poll_check_linked(cls, context)
def execute(self, context):
def execute(self, context: Context):
parent_ob = context.active_object
objs_to_parent = [obj for obj in context.selected_objects if obj != parent_ob]
@@ -168,11 +164,7 @@ class OBJECT_OT_parent_set_simple(Operator):
return {'CANCELLED'}
# Report what was done.
objs_str = (
objs_to_parent[0].name
if len(objs_to_parent) == 1
else f"{len(objs_to_parent)} objects"
)
objs_str = objs_to_parent[0].name if len(objs_to_parent) == 1 else f"{len(objs_to_parent)} objects"
self.report({'INFO'}, f"Parented {objs_str} to {parent_ob.name}")
return {'FINISHED'}
@@ -185,16 +177,14 @@ class OBJECT_OT_parent_set_advanced(Operator):
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
def poll(cls, context: Context):
if not len(context.selected_objects) > 1 and context.active_object:
cls.poll_message_set(
"Only one object is selected. You can't parent an object to itself."
)
cls.poll_message_set("Only one object is selected. You can't parent an object to itself.")
return False
return set_parent_poll_check_linked(cls, context)
def get_parent_method_items(self, context):
def get_parent_method_items(self, context: Context):
parent_ob = context.active_object
items = [
(
@@ -273,9 +263,7 @@ class OBJECT_OT_parent_set_advanced(Operator):
)
)
return [
(item[0], item[1], item[2], item[3], idx) for idx, item in enumerate(items)
]
return [(item[0], item[1], item[2], item[3], idx) for idx, item in enumerate(items)]
parent_method: EnumProperty(
name="Parent Method",
@@ -428,9 +416,7 @@ class OBJECT_OT_parent_set_advanced(Operator):
keep_transform = self.transform_correction == 'MATRIX_INTERNAL'
objs_to_parent = [obj for obj in context.selected_objects if obj != parent_ob]
matrix_backups = [
(obj.matrix_world.copy(), obj.matrix_local.copy()) for obj in objs_to_parent
]
matrix_backups = [(obj.matrix_world.copy(), obj.matrix_local.copy()) for obj in objs_to_parent]
op_parent_method = self.parent_method
@@ -459,9 +445,7 @@ class OBJECT_OT_parent_set_advanced(Operator):
return self.parent_with_copy_transforms_con(context)
try:
bpy.ops.object.parent_set(
type=op_parent_method, keep_transform=keep_transform
)
bpy.ops.object.parent_set(type=op_parent_method, keep_transform=keep_transform)
except Exception as exc:
self.report({'ERROR'}, str(exc))
return {'CANCELLED'}
@@ -477,11 +461,7 @@ class OBJECT_OT_parent_set_advanced(Operator):
obj.matrix_world = matrices[0]
# Report what was done.
objs_str = (
objs_to_parent[0].name
if len(objs_to_parent) == 1
else f"{len(objs_to_parent)} objects"
)
objs_str = objs_to_parent[0].name if len(objs_to_parent) == 1 else f"{len(objs_to_parent)} objects"
self.report({'INFO'}, f"Parented {objs_str} to {parent_ob.name}")
return {'FINISHED'}
@@ -515,9 +495,7 @@ class OBJECT_OT_parent_set_advanced(Operator):
target.subtarget = active_bone.name
# Draw a nice info message.
objs_str = (
obj.name if len(objs_to_parent) == 1 else f"{len(objs_to_parent)} objects"
)
objs_str = obj.name if len(objs_to_parent) == 1 else f"{len(objs_to_parent)} objects"
self.report({'INFO'}, f"Constrained {objs_str} to {active_bone.name}")
return {'FINISHED'}
@@ -543,21 +521,13 @@ class OBJECT_OT_parent_set_advanced(Operator):
childof.target = parent_ob
if context.active_pose_bone:
childof.subtarget = context.active_pose_bone.name
childof.inverse_matrix = (
parent_ob.matrix_world @ context.active_pose_bone.matrix
).inverted()
childof.inverse_matrix = (parent_ob.matrix_world @ context.active_pose_bone.matrix).inverted()
else:
childof.inverse_matrix = parent_ob.matrix_world.inverted()
# Draw a nice info message.
objs_str = (
obj.name if len(objs_to_parent) == 1 else f"{len(objs_to_parent)} objects"
)
parent_str = (
context.active_pose_bone.name
if context.active_pose_bone
else parent_ob.name
)
objs_str = obj.name if len(objs_to_parent) == 1 else f"{len(objs_to_parent)} objects"
parent_str = context.active_pose_bone.name if context.active_pose_bone else parent_ob.name
self.report({'INFO'}, f"Constrained {objs_str} to {parent_str}")
return {'FINISHED'}
@@ -573,23 +543,15 @@ class OBJECT_OT_parent_set_advanced(Operator):
copytrans.subtarget = context.active_pose_bone.name
# Draw a nice info message.
objs_str = (
obj.name if len(objs_to_parent) == 1 else f"{len(objs_to_parent)} objects"
)
parent_str = (
context.active_pose_bone.name
if context.active_pose_bone
else parent_ob.name
)
objs_str = obj.name if len(objs_to_parent) == 1 else f"{len(objs_to_parent)} objects"
parent_str = context.active_pose_bone.name if context.active_pose_bone else parent_ob.name
self.report({'INFO'}, f"Constrained {objs_str} to {parent_str}")
return {'FINISHED'}
def set_parent_poll_check_linked(cls, context):
def set_parent_poll_check_linked(cls, context: Context):
if any([ob.library for ob in context.selected_objects]):
cls.poll_message_set(
"An object is linked. You need to override it to change the parenting."
)
cls.poll_message_set("An object is linked. You need to override it to change the parenting.")
return False
if any([ob.override_library and ob.override_library.is_system_override for ob in context.selected_objects]):
cls.poll_message_set(
@@ -598,8 +560,9 @@ def set_parent_poll_check_linked(cls, context):
return False
return True
### Header Menu UI
def draw_new_header_menu(self, context):
def draw_new_header_menu(self, _context: Context):
layout = self.layout
# Set Parent
@@ -1,16 +1,17 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
# SPDX-FileCopyrightText: 2016-2026 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
from bpy.types import Menu
from bpy.types import Context, Menu
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
class PIE_MT_animation(Menu):
bl_idname = "PIE_MT_animation"
bl_label = "Animation"
class PIE_MT_playback(Menu):
bl_idname = "PIE_MT_playback"
bl_label = "Playback"
def draw(self, context):
def draw(self, context: Context):
layout = self.layout
pie = layout.menu_pie()
# 4 - LEFT
@@ -18,22 +19,16 @@ class PIE_MT_animation(Menu):
# 6 - RIGHT
pie.operator("screen.frame_jump", text="Jump to End", icon='FF').end = True
# 2 - BOTTOM
pie.operator(
"screen.animation_play", text="Play Reverse", icon='PLAY_REVERSE'
).reverse = True
pie.operator("screen.animation_play", text="Play Reverse", icon='PLAY_REVERSE').reverse = True
# 8 - TOP
if not context.screen.is_animation_playing: # Play / Pause
pie.operator("screen.animation_play", text="Play", icon='PLAY')
else:
pie.operator("screen.animation_play", text="Stop", icon='PAUSE')
# 7 - TOP - LEFT
pie.operator(
"screen.keyframe_jump", text="Previous Keyframe", icon='PREV_KEYFRAME'
).next = False
pie.operator("screen.keyframe_jump", text="Previous Keyframe", icon='PREV_KEYFRAME').next = False
# 9 - TOP - RIGHT
pie.operator(
"screen.keyframe_jump", text="Next Keyframe", icon='NEXT_KEYFRAME'
).next = True
pie.operator("screen.keyframe_jump", text="Next Keyframe", icon='NEXT_KEYFRAME').next = True
# 1 - BOTTOM - LEFT
pie.prop(context.tool_settings, "use_keyframe_insert_auto", text="Auto Keying")
# 3 - BOTTOM - RIGHT
@@ -41,14 +36,14 @@ class PIE_MT_animation(Menu):
registry = [
PIE_MT_animation,
PIE_MT_playback,
]
def register():
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name="Object Non-modal",
pie_name=PIE_MT_animation.bl_idname,
pie_name=PIE_MT_playback.bl_idname,
hotkey_kwargs={'type': "SPACE", 'value': "PRESS", 'shift': True},
default_fallback_op='screen.animation_play',
on_drag=True,
@@ -3,6 +3,7 @@
# SPDX-License-Identifier: GPL-3.0-or-later
from bpy.types import Menu
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only

Some files were not shown because too many files have changed in this diff Show More