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
+9 -9
View File
@@ -10,13 +10,13 @@ D:\Work\9 iClone\Amazon\
D:\Amazon\00_external-files\ D:\Amazon\00_external-files\
N:\1. CHARACTERS\remapping\ N:\1. CHARACTERS\remapping\
[Recent] [Recent]
A:\1 Amazon_Active_Projects\1 BlenderAssets\
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\stills\2026_new\
A:\1 Amazon_Active_Projects\260706_Leader_Turbotote\Blends\stills\
A:\1 Amazon_Active_Projects\260512_Agile_Gemba\Blends\animations\
A:\1 Amazon_Active_Projects\260622_Stow_and_Go\Blends\animations\
N:\1. CHARACTERS\remapping\
A:\1 Amazon_Active_Projects\260622_Stow_and_Go\Assets\Blends\
F:\renders\ F:\renders\
A:\1 Amazon_Active_Projects\260512_Agile_Gemba\Deliverable\Stills\ C:\Users\Nathan\Downloads\
A:\1 Amazon_Active_Projects\260512_Agile_Gemba\Blends\stills\ A:\1 Amazon_Active_Projects\260706_Leader_Turbotote\Deliverable\Stills\
A:\1 Amazon_Active_Projects\260706_Leader_Turbotote\Blends\stills\
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Deliverable\Scan Aware\
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\stills\Scan Aware\
A:\1 Amazon_Active_Projects\1 BlenderAssets\
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\stills\BD2\
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\stills\2026_new\
A:\1 Amazon_Active_Projects\260512_Agile_Gemba\Blends\animations\
+2 -2
View File
@@ -1,9 +1,9 @@
{ {
"flamenco_version": { "flamenco_version": {
"version": "3.10-alpha0 (b5e531ae)", "version": "3.10-alpha0 (340bb57b)",
"shortversion": "3.10-alpha0", "shortversion": "3.10-alpha0",
"name": "Flamenco", "name": "Flamenco",
"git": "b5e531ae" "git": "340bb57b"
}, },
"shared_storage": { "shared_storage": {
"location": "F:\\jobs", "location": "F:\\jobs",
+1
View File
@@ -19,3 +19,4 @@
{Intel/Intel(R) HD Graphics 630/4.6.0 - Build 27.20.100.9664}=SUPPORTED {Intel/Intel(R) HD Graphics 630/4.6.0 - Build 27.20.100.9664}=SUPPORTED
{NVIDIA Corporation/NVIDIA GeForce RTX 4080 SUPER/NVIDIA 596.36}=SUPPORTED {NVIDIA Corporation/NVIDIA GeForce RTX 4080 SUPER/NVIDIA 596.36}=SUPPORTED
{NVIDIA Corporation/NVIDIA GeForce RTX 4080 SUPER/NVIDIA 610.47}=SUPPORTED {NVIDIA Corporation/NVIDIA GeForce RTX 4080 SUPER/NVIDIA 610.47}=SUPPORTED
{NVIDIA Corporation/NVIDIA GeForce RTX 4080 SUPER/NVIDIA 610.62}=SUPPORTED
+13 -13
View File
@@ -1,6 +1,18 @@
C:\Users\Nathan\Downloads\test.blend
A:\1 Amazon_Active_Projects\260709_Optimal_ADTA_stow\Blends\animations\OptimalStow_animation 2.blend
F:\jobs\OptimalStow_animation 2-1bxe\_outside_project\A\1 Amazon_Active_Projects\1 BlenderAssets\Amazon\Char\Cartoon1\Hailey_v4.4.blend
A:\1 Amazon_Active_Projects\260706_Leader_Turbotote\Blends\stills\iTurboTote_Leader_13b.blend
A:\1 Amazon_Active_Projects\260706_Leader_Turbotote\Blends\stills\iTurboTote_Leader_13a.blend
A:\1 Amazon_Active_Projects\260706_Leader_Turbotote\Blends\stills\iTurboTote_Leader_12b.blend
A:\1 Amazon_Active_Projects\260706_Leader_Turbotote\Blends\stills\iTurboTote_Leader_12a.blend
A:\1 Amazon_Active_Projects\260706_Leader_Turbotote\Blends\stills\iTurboTote_Leader_7.blend
A:\1 Amazon_Active_Projects\260706_Leader_Turbotote\Blends\stills\iTurboTote_Reuse_8_v2.blend
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\stills\Scan Aware\iP&S_scan aware_6.blend
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\stills\Scan Aware\iP&S_scan aware_5.blend
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\stills\BD2\iP&S_BD2_24.blend
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\stills\2026_new\iP&S_2026_4n.blend
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\stills\2026_new\iP&S_2026_4p.blend A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\stills\2026_new\iP&S_2026_4p.blend
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\stills\2026_new\iP&S_2026_4a-b.blend A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\stills\2026_new\iP&S_2026_4a-b.blend
A:\1 Amazon_Active_Projects\260706_Leader_Turbotote\Blends\stills\iTurboTote_Reuse_8_v2.blend
A:\1 Amazon_Active_Projects\260512_Agile_Gemba\Blends\animations\gembawalk_horizontal_animation_2A_ins3.blend A:\1 Amazon_Active_Projects\260512_Agile_Gemba\Blends\animations\gembawalk_horizontal_animation_2A_ins3.blend
A:\1 Amazon_Active_Projects\260512_Agile_Gemba\Blends\animations\gembawalk_horizontal_animation_2A_ins0.blend A:\1 Amazon_Active_Projects\260512_Agile_Gemba\Blends\animations\gembawalk_horizontal_animation_2A_ins0.blend
A:\1 Amazon_Active_Projects\260512_Agile_Gemba\Blends\animations\gembawalk_horizontal_animation_2A.blend A:\1 Amazon_Active_Projects\260512_Agile_Gemba\Blends\animations\gembawalk_horizontal_animation_2A.blend
@@ -186,15 +198,3 @@ A:\1 Amazon_Active_Projects\260422_Upstream_edits_2026\Blends\stills\iUP_M5_6.bl
A:\1 Amazon_Active_Projects\260422_Upstream_edits_2026\Blends\stills\iUP_M2_5.blend A:\1 Amazon_Active_Projects\260422_Upstream_edits_2026\Blends\stills\iUP_M2_5.blend
A:\1 Amazon_Active_Projects\260422_Upstream_edits_2026\Blends\stills\iUP_M5_13.blend A:\1 Amazon_Active_Projects\260422_Upstream_edits_2026\Blends\stills\iUP_M5_13.blend
P:\250827_FestivalTurf\Blends\animations\06 Infill And Powerbrooming\comp_RR\Visual 2B_SPA_comp_RR.blend P:\250827_FestivalTurf\Blends\animations\06 Infill And Powerbrooming\comp_RR\Visual 2B_SPA_comp_RR.blend
P:\250827_FestivalTurf\Blends\animations\06 Infill And Powerbrooming\Visual 2B_SPA.blend
C:\Users\Nathan\Downloads\Visual 2B_SPA\Visual 2B_SPA.blend
F:\jobs\Visual 2B_SPA-393g\Visual 2B_SPA.blend
F:\jobs\Visual_5_SPA-ii75\Visual_5_SPA.blend
P:\250827_FestivalTurf\Blends\animations\07 Final Touches And Maintenance\Visual_5_SPA.blend
F:\jobs\Visual_5_SPA-ki6s\Visual_5_SPA.blend
C:\Users\Nathan\Downloads\Visual_5_SPA.blend
C:\Users\Nathan\Downloads\Visual_5_SPA_106-109.blend
P:\250827_FestivalTurf\Blends\animations\07 Final Touches And Maintenance\Visual_2_broom_SPA.blend
C:\Users\Nathan\Downloads\Visual_new_final_SPA\Visual_new_final_SPA.blend
P:\250827_FestivalTurf\Blends\animations\07 Final Touches And Maintenance\Visual_new_final_SPA.blend
C:\Users\Nathan\Downloads\Visual_new_final\Visual_new_final.blend
Binary file not shown.
BIN
View File
Binary file not shown.
File diff suppressed because one or more lines are too long
+6 -12
View File
@@ -1,42 +1,36 @@
if "bpy" in locals(): if "bpy" in locals():
import importlib import importlib
for mod in [icons, for mod in [operators,
operators,
tools, tools,
ui,
manual, manual,
preferences, preferences,
properties, properties,
ui,
versioning,
]: ]:
importlib.reload(mod) importlib.reload(mod)
print("Add-on Reloaded: Bool Tool") print("Add-on Reloaded: Bool Tool")
else: else:
import bpy import bpy
from . import ( from . import (
icons,
operators, operators,
tools, tools,
ui,
manual, manual,
preferences, preferences,
properties, properties,
ui,
versioning,
) )
#### ------------------------------ REGISTRATION ------------------------------ #### #### ------------------------------ REGISTRATION ------------------------------ ####
modules = [ modules = (
icons,
operators, operators,
tools, tools,
ui,
manual, manual,
preferences, preferences,
properties, properties,
ui, )
versioning,
]
def register(): def register():
for module in modules: for module in modules:
@@ -2,18 +2,17 @@ schema_version = "1.0.0"
id = "bool_tool" id = "bool_tool"
name = "Bool Tool" name = "Bool Tool"
version = "2.0.0" version = "2.1.0"
tagline = "Quick boolean operators and tools for hard surface modeling" tagline = "Quick Boolean operators and tools for hard surface modeling"
tags = ["Modeling", "Object"]
type = "add-on" type = "add-on"
maintainer = "Nika Kutsniashvili <nickberckley@gmail.com>" maintainer = "Nika Kutsniashvili"
website = "https://github.com/nickberckley/bool_tool" website = "https://nickberckley.github.io/bool_tool/"
tags = ["Modeling", "Object"] support = "https://github.com/nickberckley/bool_tool/issues/new?template=bug_report.md"
blender_version_min = "4.5.0" 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 = [ license = [
"SPDX:GPL-3.0-or-later", "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 ------------------------------ #### #### ------------------------------ FUNCTIONS ------------------------------ ####
def draw_shader(type, color, alpha, coords, size=1, indices=None): 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') 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') 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). 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: for face in faces:
face_indices = [] face_indices = []
# Collect unique vertices only (avoid storing verts that are shared by faces multiple times). # Collect unique vertices only (avoid storing verts that are shared by multiple faces).
# (Iterating over face corners because unlike `face.verts` they're ordered). # (NOTE: Iterating over face corners because unlike `face.verts` they're ordered).
for loop in face.loops: for loop in face.loops:
vert = loop.vert vert = loop.vert
co = world_matrix @ Vector(vert.co)
if vert not in vert_index_map: if vert not in vert_index_map:
co = world_matrix @ Vector(vert.co)
vertices.append(co) vertices.append(co)
vert_index_map[vert] = vert_count vert_index_map[vert] = vert_count
face_indices.append(vert_count) face_indices.append(vert_count)
@@ -94,46 +95,28 @@ def draw_bmesh_faces(faces, world_matrix):
return vertices, indices return vertices, indices
def draw_bmesh_edges(edges, world_matrix): def draw_circle_billboard(context, origin: Vector, radius: float, segments: int) -> list:
"""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):
""" """
Draws the screen-aligned circle around given vertex of the object. Draws a view-facing circle in the world-space around the given origin Vector.
Returns the list of vertices for GPU batch. Returns the list of 3D vertices.
""" """
region = context.region region = context.region
rv3d = context.region_data 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) radius = min(radius, 25)
vertices = [] vertices = []
for i in range(segments + 1): for i in range(segments + 1):
angle = i * (2 * math.pi / segments) 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_x = radius * math.cos(angle)
offset_y = radius * math.sin(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: # Convert back to world-space.
# Add offset in screen-space and convert back to world-space. vert_3d = view3d_utils.region_2d_to_location_3d(region, rv3d, vert_2d, origin)
circle_screen = Vector((vert_screen.x + offset_x, vert_screen.y + offset_y)) vertices.append(vert_3d)
circle_3d = view3d_utils.region_2d_to_location_3d(region, rv3d, circle_screen, vert_world)
vertices.append(circle_3d)
return vertices 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 bpy
import bmesh import bmesh
import mathutils
import math import math
from bpy_extras import view3d_utils from mathutils import Vector
from .object import hide_objects
from .types import Ray
#### ------------------------------ FUNCTIONS ------------------------------ #### #### ------------------------------ /poll/ ------------------------------ ####
def extrude_face(bm, face): def is_instanced_mesh(data):
"""Extrudes cutter face (created by carve operation) along view vector to create a non-manifold mesh""" """
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() bm.faces.ensure_lookup_table()
@@ -26,8 +65,8 @@ def extrude_face(bm, face):
return extruded_verts, extruded_edges, extruded_faces return extruded_verts, extruded_edges, extruded_faces
def shade_smooth_by_angle(bm, mesh, angle=30): def shade_smooth_by_angle(bm, mesh, angle: float=30):
"""Replication of "Auto Smooth" functionality: Marks faces as smooth, sharp edges (by angle) as sharp""" """Replication of "Auto Smooth": Marks faces as smooth & edges above the angle as sharp."""
for f in bm.faces: for f in bm.faces:
f.smooth = True f.smooth = True
@@ -51,26 +90,6 @@ def shade_smooth_by_angle(bm, mesh, angle=30):
bm.to_mesh(mesh) 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): def ensure_attribute(bm, name, domain):
"""Ensure that the attribute with the given name and domain exists on mesh.""" """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) attr = bm.verts.layers.float.new(name)
return attr 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 contextlib import contextmanager
from .. import __package__ as base_package from .. import __package__ as base_package
from ..functions.list import ( from .mesh import (
list_pre_boolean_modifiers, is_instanced_mesh,
) )
from .object import ( from .object import (
convert_to_mesh, 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 ------------------------------ #### #### ------------------------------ FUNCTIONS ------------------------------ ####
def add_boolean_modifier(self, context, obj, cutter, mode, solver, pin=False, redo=True): 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': if bpy.app.version < (5, 0, 0) and solver == 'FLOAT':
solver = 'FAST' solver = 'FAST'
prefs = context.preferences.addons[base_package].preferences 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.operation = mode
modifier.object = cutter modifier.object = cutter
modifier.solver = solver 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: if redo:
modifier.material_mode = self.material_mode modifier.material_mode = self.material_mode
modifier.use_self = self.use_self modifier.use_self = self.use_self
modifier.use_hole_tolerant = self.use_hole_tolerant modifier.use_hole_tolerant = self.use_hole_tolerant
modifier.double_threshold = self.double_threshold 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). # Move modifier to the index 0 (make it first in the stack).
if pin: if pin:
index = obj.modifiers.find(modifier.name) 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): def apply_modifiers(context, obj, modifiers: list, force_clean=False):
""" """
Apply modifiers on object. Apply modifiers on object.
Instead of using `bpy.ops.object.modifier_apply`, this function uses Instead of using `bpy.ops.object.modifier_apply`, by default this function uses
`bpy.data.meshes.new_from_object` built-in function to create a temporary `to_mesh` built-in function to create a temporary mesh from the evaluated object
mesh from the evaluated object (basically with visible modifiers applied). (basically with visible modifiers applied). Temporary mesh is then transferred
Temporary mesh is then transferred to objects mesh with `bmesh`. to objects mesh using `bmesh`.
This method is up to 2x faster, although it's considered experimental 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. and may fail in some cases, so a fallback to `bpy.ops.object.modifier_apply` is kept.
""" """
prefs = context.preferences.addons[base_package].preferences prefs = context.preferences.addons[base_package].preferences
_stored_active_obj = context.active_object
# Make object data unique if it's instanced. # 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() context.active_object.data = context.active_object.data.copy()
try: try:
@@ -71,10 +132,13 @@ def apply_modifiers(context, obj, modifiers: list, force_clean=False):
if not force_clean: if not force_clean:
raise Exception() raise Exception()
context.view_layer.objects.active = obj
with hide_modifiers(obj, excluding=modifiers): with hide_modifiers(obj, excluding=modifiers):
# Create a temporary mesh from evaluated object. # Create a temporary mesh from evaluated object.
evaluated_obj = obj.evaluated_get(context.evaluated_depsgraph_get()) depsgraph = context.evaluated_depsgraph_get()
temp_data = bpy.data.meshes.new_from_object(evaluated_obj) 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. # Create `bmesh` from temporary mesh and update edit mesh.
if context.mode == '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 = bmesh.new()
bm.from_mesh(temp_data) bm.from_mesh(temp_data)
bm.to_mesh(obj.data) bm.to_mesh(obj.data)
bm.free() bm.free()
evaluated_obj.to_mesh_clear() evaluated_obj.to_mesh_clear()
# Remove modifiers and purge temporary mesh. # Remove modifiers.
bpy.data.meshes.remove(temp_data)
for mod in modifiers: for mod in modifiers:
obj.modifiers.remove(mod) obj.modifiers.remove(mod)
@@ -99,7 +163,6 @@ def apply_modifiers(context, obj, modifiers: list, force_clean=False):
if obj.data.shape_keys: if obj.data.shape_keys:
obj.shape_key_clear() obj.shape_key_clear()
# Use `bpy.ops` operator to apply modifiers if above fails.
except Exception as e: except Exception as e:
# print("Error applying modifiers with `bmesh` method:", e, "falling back to `bpy.ops` method") # 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: for mod in modifiers:
bpy.ops.object.modifier_apply(modifier=mod.name) bpy.ops.object.modifier_apply(modifier=mod.name)
context.view_layer.objects.active = _stored_active_obj
@contextmanager @contextmanager
def hide_modifiers(obj, excluding: list): 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 = [] visible_modifiers = []
for mod in obj.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): 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: try:
# Load the node group. # Load in the node group.
if bpy.app.version >= (5, 0, 0): if bpy.app.version >= (5, 0, 0):
with bpy.data.libraries.load(path, link=True, pack=True) as (data_from, data_to): with bpy.data.libraries.load(path, link=True, pack=True) as (data_from, data_to):
if asset in data_from.node_groups: 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] 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 = obj.modifiers.new(asset, type='NODES')
mod.node_group = node_group mod.node_group = node_group
mod.show_group_selector = False mod.show_group_selector = False
@@ -168,16 +233,19 @@ def add_modifier_asset(obj, path: str, asset: str):
return None return None
def get_modifiers_to_apply(context, obj, new_modifiers) -> list: def update_modifier_input(modifier, socket: str, value):
"""Returns the list of modifiers that need to be applied based on add-on preferences.""" """Change the value of the geometry nodes modifier input socket."""
prefs = context.preferences.addons[base_package].preferences try:
if bpy.app.version >= (5, 2, 0):
if prefs.apply_order == 'ALL': socket = getattr(modifier.properties.inputs, socket)
modifiers = [mod for mod in obj.modifiers] socket.value = value
elif prefs.apply_order == 'BOOLEANS': else:
modifiers = new_modifiers modifier[f"{socket}"] = value
elif prefs.apply_order == 'BEFORE': except:
modifiers = list_pre_boolean_modifiers(obj) """
NOTE: There are plethora of reasons why this can fail, node trees are finicky.
return modifiers 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 bpy
import bmesh import bmesh
import mathutils from mathutils import Vector, Matrix
from contextlib import contextmanager from contextlib import contextmanager
from .. import __package__ as base_package
#### ------------------------------ FUNCTIONS ------------------------------ #### #### ------------------------------ /poll/ ------------------------------ ####
def set_cutter_properties(context, cutter, mode, display='BOUNDS', collection=True): def is_linked(context, obj) -> bool:
"""Ensures cutter is properly set: has right properties, is hidden, in a collection & parented""" """Checks whether the object is linked from an external .blend file (including library-overrides)."""
# Hide Cutters if obj not in context.editable_objects:
cutter.hide_render = True if obj.library:
cutter.display_type = display return True
cutter.lineart.usage = 'EXCLUDE' else:
object_visibility_set(cutter, value=False) 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 def has_evaluated_mesh(context, obj):
cutter.booleans.cutter = mode.capitalize() """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): 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_camera = value
obj.visible_shadow = value
obj.visible_diffuse = value obj.visible_diffuse = value
obj.visible_glossy = value obj.visible_glossy = value
obj.visible_shadow = value
obj.visible_transmission = value obj.visible_transmission = value
obj.visible_volume_scatter = 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): 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_active = context.active_object
stored_selection = context.selected_objects 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) obj.select_set(True)
context.view_layer.objects.active = obj context.view_layer.objects.active = obj
# Convert.
bpy.ops.object.convert(target='MESH') bpy.ops.object.convert(target='MESH')
# restore_selection if original_mode != 'OBJECT':
for obj in stored_selection: for ob in edit_objects:
obj.select_set(True) 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 context.view_layer.objects.active = stored_active
def ensure_collection(context): def change_parent(context, obj, parent, inverse=False):
"""Checks the existance of boolean cutters collection and creates it if it doesn't exist""" """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 obj.parent = parent
if inverse: if inverse and parent is not None:
obj.matrix_parent_inverse = parent.matrix_world.inverted() obj.matrix_parent_inverse = parent.matrix_world.inverted()
obj.matrix_world = matrix_copy
def create_slice(context, canvas, modifier=False): def set_object_origin(obj, bm, point='CENTER', custom: Vector=None):
"""Creates copy of canvas to be used as slice""" """Sets the origin of a mesh type object to given position by shifting vertices."""
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"""
# Center of the bounding box. # Center of the bounding box.
if point == 'CENTER_OBJ': 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 position_world = obj.matrix_world @ position_local
# Center of the geometry. # Center of the geometry.
elif point == 'CENTER_MESH': elif point == 'CENTER_MESH':
if len(bm.verts) > 0: 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: else:
position_local = mathutils.Vector((0, 0, 0)) position_local = Vector((0, 0, 0))
position_world = obj.matrix_world @ position_local position_world = obj.matrix_world @ position_local
# Custom origin point (should be local Vector). # 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_local = custom
position_world = obj.matrix_world @ custom position_world = obj.matrix_world @ custom
mat = mathutils.Matrix.Translation(position_local) matrix = Matrix.Translation(position_local)
bmesh.ops.transform(bm, matrix=mat.inverted(), verts=bm.verts) bmesh.ops.transform(bm, matrix=matrix.inverted(), verts=bm.verts)
bm.to_mesh(obj.data) bm.to_mesh(obj.data)
obj.location = position_world obj.location = position_world
@@ -179,3 +156,13 @@ def hide_objects(context, exceptions: list):
finally: finally:
for obj in hidden_objects: for obj in hidden_objects:
obj.hide_set(False) 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 import bpy
from .list import ( from ..constants import (
list_canvas_cutters, CONVERTABLE_TYPES,
list_cutter_users, )
from .mesh import (
is_instanced_mesh,
) )
from .object import ( from .object import (
convert_to_mesh, has_evaluated_mesh,
) )
#### ------------------------------ FUNCTIONS ------------------------------ #### #### ------------------------------ FUNCTIONS ------------------------------ ####
def basic_poll(cls, context, check_linked=False): def basic_poll(cls, context, check_active=True):
"""Basic poll for boolean operators.""" """Basic poll for Boolean operators."""
if context.mode != 'OBJECT': if context.mode != 'OBJECT':
return False cls.poll_message_set("Boolean operators can only be performed in Object Mode")
if context.active_object is None:
return False return False
obj = context.active_object if check_active:
if obj.type != 'MESH': if context.active_object is None:
cls.poll_message_set("Boolean operators can only be used for mesh objects") cls.poll_message_set("No active object")
return False 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
return True return True
def is_linked(context, obj): def destructive_op_confirmation(cls, context, event, canvases: list, title="Boolean Operation"):
"""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"):
""" """
Creates & returns the confirmation pop-up window for destructive boolean operators.\n 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 Confirmation window is triggered by canvases that have instanced data or shape keys.\n
If none of the canvas objects have them the operator is executed without any confirmation. 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) has_shape_keys = any(obj for obj in canvases if obj.data.shape_keys)
if has_instanced_data or has_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. # Combined message.
if has_instanced_data and has_shape_keys: if has_instanced_data and has_shape_keys:
message = ("Object(s) you're trying to cut have shape keys and instanced object data.\n" 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?") "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', confirm_text="Yes", icon='WARNING',
message=message) message=message)
cls._unflippable = True
return popup return popup
# Execute without confirmation window. # Execute without confirmation window.
else: 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 bpy
import mathutils
from mathutils import Vector, Matrix from mathutils import Vector, Matrix
@@ -16,8 +15,8 @@ class Ray:
obj, obj,
matrix: Matrix): matrix: Matrix):
self.hit = hit self.hit = hit
self.location = location if location is not None else mathutils.Vector() self.location = location
self.normal = normal if normal is not None else mathutils.Vector() self.normal = normal
self.index = index self.index = index
self.obj = obj 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 ------------------------------ #### #### ------------------------------ FUNCTIONS ------------------------------ ####
def bool_tool_manual_map(): 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 # Carver
url_manual_mapping = (("bpy.ops.object.carve", "Carver"), url_manual_mapping = (# Brush Boolean
# Brush Boolean ("bpy.ops.object.boolean_brush_union", "booleans/brush_booleans.html"),
("bpy.ops.object.boolean_brush_union", "Boolean-Operators"), ("bpy.ops.object.boolean_brush_intersect", "booleans/brush_booleans.html"),
("bpy.ops.object.boolean_brush_intersect", "Boolean-Operators"), ("bpy.ops.object.boolean_brush_difference", "booleans/brush_booleans.html"),
("bpy.ops.object.boolean_brush_difference", "Boolean-Operators"), ("bpy.ops.object.boolean_brush_slice", "booleans/brush_booleans.html"),
("bpy.ops.object.boolean_brush_slice", "Boolean-Operators"),
# Auto Boolean # Auto Boolean
("bpy.ops.object.boolean_auto_union", "Boolean-Operators#auto-boolean-operators"), ("bpy.ops.object.boolean_auto_union", "booleans/auto_booleans.html"),
("bpy.ops.object.boolean_auto_intersect", "Boolean-Operators#auto-boolean-operators"), ("bpy.ops.object.boolean_auto_intersect", "booleans/auto_booleans.html"),
("bpy.ops.object.boolean_auto_difference", "Boolean-Operators#auto-boolean-operators"), ("bpy.ops.object.boolean_auto_difference", "booleans/auto_booleans.html"),
("bpy.ops.object.boolean_auto_slice", "Boolean-Operators#auto-boolean-operators"), ("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 # Cutter Utilities
("bpy.ops.object.boolean_toggle_cutter", "Utility-Operators#toggle-cutter"), ("bpy.ops.object.boolean_toggle_cutter", "utilities/toggle.html"),
("bpy.ops.object.boolean_remove_cutter", "Utility-Operators#remove-cutter"), ("bpy.ops.object.boolean_remove_cutter", "utilities/remove.html"),
("bpy.ops.object.boolean_apply_cutter", "Utility-Operators#apply-cutter"), ("bpy.ops.object.boolean_apply_cutter", "utilities/apply.html"),
# Canvas Utilities # Canvas Utilities
("bpy.ops.object.boolean_toggle_all", "Utility-Operators#toggle-all-cutters"), ("bpy.ops.object.boolean_toggle_all", "utilities/toggle.html"),
("bpy.ops.object.boolean_remove_all", "Utility-Operators#remove-all-cutters"), ("bpy.ops.object.boolean_remove_all", "utilities/remove.html"),
("bpy.ops.object.boolean_apply_all", "Utility-Operators#apply-all-cutters"), ("bpy.ops.object.boolean_apply_all", "utilities/apply.html"),
# Select # Select
("bpy.ops.object.select_cutter_canvas", "Utility-Operators#select-operators"), ("bpy.ops.object.select_cutter_canvas", "utilities/select.html"),
("bpy.ops.object.boolean_select_all", "Utility-Operators#select-operators"), ("bpy.ops.object.boolean_select_all", "utilities/select.html"),
) )
return url_manual_prefix, url_manual_mapping return url_manual_prefix, url_manual_mapping
@@ -18,12 +18,12 @@ else:
#### ------------------------------ REGISTRATION ------------------------------ #### #### ------------------------------ REGISTRATION ------------------------------ ####
modules = [ modules = (
boolean, boolean,
canvas, canvas,
cutter, cutter,
select, select,
] )
def register(): def register():
for module in modules: for module in modules:
@@ -2,12 +2,13 @@ import bpy
from collections import defaultdict from collections import defaultdict
from .. import __package__ as base_package from .. import __package__ as base_package
from ..functions.poll import ( from ..functions.canvas import (
basic_poll, filter_canvases,
is_linked, create_slice,
is_instanced_data, )
list_candidate_objects, from ..functions.cutter import (
destructive_op_confirmation, filter_cutters,
make_cutter,
) )
from ..functions.modifier import ( from ..functions.modifier import (
add_boolean_modifier, add_boolean_modifier,
@@ -15,23 +16,36 @@ from ..functions.modifier import (
get_modifiers_to_apply, get_modifiers_to_apply,
) )
from ..functions.object import ( from ..functions.object import (
set_cutter_properties,
change_parent, change_parent,
create_slice, delete_object,
delete_cutter, )
from ..functions.poll import (
basic_poll,
convert_to_mesh_confirmation,
destructive_op_confirmation,
) )
#### ------------------------------ PROPERTIES ------------------------------ #### #### ------------------------------ 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( material_mode: bpy.props.EnumProperty(
name = "Materials", name = "Materials",
description = "Method for setting materials on the new faces", 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" items = (('INDEX', "Index Based",
"modifier object, the face will use the same material slot or the first if the object doesn't have enough slots.")), ("Set the material on new faces based on the order of the material slot lists. If a material doesn't exist on the\n"
('TRANSFER', "Transfer", ("Transfer materials from non-empty slots to the result mesh, adding new materials as necessary.\n" "modifier object, the face will use the same material slot or the first if the object doesn't have enough slots.")),
"For empty slots, fall back to using the same material index as the operand mesh."))), ('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', default = 'INDEX',
) )
use_self: bpy.props.BoolProperty( use_self: bpy.props.BoolProperty(
@@ -52,12 +66,24 @@ class ModifierProperties():
default = 0.000001, default = 0.000001,
) )
# Built-in Methods.
@classmethod
def poll(cls, context):
return basic_poll(cls, context)
def draw(self, context): def draw(self, context):
prefs = context.preferences.addons[base_package].preferences prefs = context.preferences.addons[base_package].preferences
layout = self.layout layout = self.layout
layout.use_property_split = True layout.use_property_split = True
col = layout.column()
col.prop(self, "flip")
if self._unflippable:
col.enabled = False
layout.separator()
if prefs.solver == 'EXACT': if prefs.solver == 'EXACT':
layout.prop(self, "material_mode") layout.prop(self, "material_mode")
layout.prop(self, "use_self") layout.prop(self, "use_self")
@@ -66,53 +92,86 @@ class ModifierProperties():
layout.prop(self, "double_threshold") 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/ ------------------------------ #### #### ------------------------------ /brush_boolean/ ------------------------------ ####
class BrushBoolean(ModifierProperties): class BrushBoolean(BooleanBase):
@classmethod
def poll(cls, context):
return basic_poll(cls, context)
def invoke(self, context, event): 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: if len(context.selected_objects) < 2:
self.report({'WARNING'}, "Boolean operator needs at least two selected objects") self.report({'WARNING'}, "Boolean operator needs at least two selected objects")
return {'CANCELLED'} return {'CANCELLED'}
# Abort if active object is linked. if not self.flip:
if is_linked(context, context.active_object): cutters = [obj for obj in context.selected_objects if obj != context.active_object]
self.report({'WARNING'}, "Boolean operators cannot be performed on linked objects") else:
return {'CANCELLED'} 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): def execute(self, context):
prefs = context.preferences.addons[base_package].preferences 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'} return {'CANCELLED'}
# Create slices. # Create slices.
if self.mode == "SLICE": if self.mode == "SLICE":
for cutter in cutters: 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) NOTE: Slices need to be created in a separate loop to avoid
add_boolean_modifier(self, context, slice, cutter, "INTERSECT", prefs.solver, pin=prefs.pin) 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: for cutter in cutters:
mode = "DIFFERENCE" if self.mode == "SLICE" else self.mode make_cutter(context, cutter, self.mode,
display = 'WIRE' if prefs.wireframe else 'BOUNDS' display=prefs.display,
set_cutter_properties(context, cutter, self.mode, display=display, collection=prefs.use_collection) collection=prefs.use_collection)
add_boolean_modifier(self, context, canvas, cutter, mode, prefs.solver, pin=prefs.pin)
if prefs.parent:
change_parent(cutter, canvas)
canvas.booleans.canvas = True 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'} return {'FINISHED'}
@@ -120,29 +179,47 @@ class BrushBoolean(ModifierProperties):
class OBJECT_OT_boolean_brush_union(bpy.types.Operator, BrushBoolean): class OBJECT_OT_boolean_brush_union(bpy.types.Operator, BrushBoolean):
bl_idname = "object.boolean_brush_union" bl_idname = "object.boolean_brush_union"
bl_label = "Boolean Union (Brush)" bl_label = "Boolean Union (Brush)"
bl_description = "Merge selected objects into active one"
bl_options = {'REGISTER', 'UNDO'} bl_options = {'REGISTER', 'UNDO'}
mode = "UNION" 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): class OBJECT_OT_boolean_brush_intersect(bpy.types.Operator, BrushBoolean):
bl_idname = "object.boolean_brush_intersect" bl_idname = "object.boolean_brush_intersect"
bl_label = "Boolean Intersection (Brush)" bl_label = "Boolean Intersection (Brush)"
bl_description = "Only keep the parts of the active object that are interesecting selected objects"
bl_options = {'REGISTER', 'UNDO'} bl_options = {'REGISTER', 'UNDO'}
mode = "INTERSECT" 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): class OBJECT_OT_boolean_brush_difference(bpy.types.Operator, BrushBoolean):
bl_idname = "object.boolean_brush_difference" bl_idname = "object.boolean_brush_difference"
bl_label = "Boolean Difference (Brush)" bl_label = "Boolean Difference (Brush)"
bl_description = "Subtract selected objects from active one"
bl_options = {'REGISTER', 'UNDO'} bl_options = {'REGISTER', 'UNDO'}
mode = "DIFFERENCE" 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): class OBJECT_OT_boolean_brush_slice(bpy.types.Operator, BrushBoolean):
bl_idname = "object.boolean_brush_slice" bl_idname = "object.boolean_brush_slice"
@@ -152,15 +229,18 @@ class OBJECT_OT_boolean_brush_slice(bpy.types.Operator, BrushBoolean):
mode = "SLICE" 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/ ------------------------------ #### #### ------------------------------ /auto_boolean/ ------------------------------ ####
class AutoBoolean(ModifierProperties): class AutoBoolean(BooleanBase):
@classmethod
def poll(cls, context):
return basic_poll(cls, context)
def invoke(self, context, event): def invoke(self, context, event):
# Abort if there are less than 2 selected objects. # 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") self.report({'WARNING'}, "Boolean operator needs at least two selected objects")
return {'CANCELLED'} return {'CANCELLED'}
# Abort if active object is linked. if not self.flip:
if is_linked(context, context.active_object): canvases = [context.active_object]
self.report({'ERROR'}, "Modifiers cannot be applied to linked object") else:
return {'CANCELLED'} 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): def execute(self, context):
prefs = context.preferences.addons[base_package].preferences prefs = context.preferences.addons[base_package].preferences
canvas = context.active_object
cutters = list_candidate_objects(self, context, context.active_object)
new_modifiers = defaultdict(list) 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'} return {'CANCELLED'}
# Create slices. # Create slices.
if self.mode == "SLICE": if self.mode == "SLICE":
for cutter in cutters: 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) NOTE: Slices need to be created in a separate loop to avoid
modifier = add_boolean_modifier(self, context, slice, cutter, "INTERSECT", prefs.solver, pin=prefs.pin) inheriting Boolean modifiers that the operator adds.
new_modifiers[slice].append(modifier) """
slice.select_set(True) 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: for cutter in cutters:
# Add boolean modifier on canvas. # Add Boolean modifier on canvases.
mode = "DIFFERENCE" if self.mode == "SLICE" else self.mode mode = "DIFFERENCE" if self.mode == "SLICE" else self.mode
modifier = add_boolean_modifier(self, context, canvas, cutter, mode, prefs.solver, pin=prefs.pin) for canvas in canvases:
new_modifiers[canvas].append(modifier) 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: 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 # Select all faces of the cutter so that newly created faces in canvas
# are also selected after applying the modifier. # are also selected after applying the modifier.
for face in cutter.data.polygons: for face in cutter.data.polygons:
face.select = True face.select = True
# Apply modifiers on canvas & slices. # Apply modifiers on canvases & slices.
for obj, modifiers in new_modifiers.items(): for obj, modifiers in new_modifiers.items():
modifiers = get_modifiers_to_apply(context, obj, modifiers) modifiers = get_modifiers_to_apply(context, obj, modifiers)
apply_modifiers(context, obj, modifiers) apply_modifiers(context, obj, modifiers)
# Delete cutters. # Delete cutters.
for cutter in cutters: for cutter in cutters:
delete_cutter(cutter) delete_object(cutter)
return {'FINISHED'} return {'FINISHED'}
@@ -224,29 +315,47 @@ class AutoBoolean(ModifierProperties):
class OBJECT_OT_boolean_auto_union(bpy.types.Operator, AutoBoolean): class OBJECT_OT_boolean_auto_union(bpy.types.Operator, AutoBoolean):
bl_idname = "object.boolean_auto_union" bl_idname = "object.boolean_auto_union"
bl_label = "Boolean Union (Auto)" bl_label = "Boolean Union (Auto)"
bl_description = "Merge selected objects into active one"
bl_options = {'REGISTER', 'UNDO'} bl_options = {'REGISTER', 'UNDO'}
mode = "UNION" 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): class OBJECT_OT_boolean_auto_difference(bpy.types.Operator, AutoBoolean):
bl_idname = "object.boolean_auto_difference" bl_idname = "object.boolean_auto_difference"
bl_label = "Boolean Difference (Auto)" bl_label = "Boolean Difference (Auto)"
bl_description = "Subtract selected objects from active one"
bl_options = {'REGISTER', 'UNDO'} bl_options = {'REGISTER', 'UNDO'}
mode = "DIFFERENCE" 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): class OBJECT_OT_boolean_auto_intersect(bpy.types.Operator, AutoBoolean):
bl_idname = "object.boolean_auto_intersect" bl_idname = "object.boolean_auto_intersect"
bl_label = "Boolean Intersect (Auto)" bl_label = "Boolean Intersect (Auto)"
bl_description = "Only keep the parts of the active object that are interesecting selected objects"
bl_options = {'REGISTER', 'UNDO'} bl_options = {'REGISTER', 'UNDO'}
mode = "INTERSECT" 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): class OBJECT_OT_boolean_auto_slice(bpy.types.Operator, AutoBoolean):
bl_idname = "object.boolean_auto_slice" bl_idname = "object.boolean_auto_slice"
@@ -256,13 +365,20 @@ class OBJECT_OT_boolean_auto_slice(bpy.types.Operator, AutoBoolean):
mode = "SLICE" 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 ------------------------------ #### #### ------------------------------ REGISTRATION ------------------------------ ####
addon_keymaps = [] addon_keymaps = []
classes = [ classes = (
OBJECT_OT_boolean_brush_union, OBJECT_OT_boolean_brush_union,
OBJECT_OT_boolean_brush_difference, OBJECT_OT_boolean_brush_difference,
OBJECT_OT_boolean_brush_intersect, OBJECT_OT_boolean_brush_intersect,
@@ -272,7 +388,7 @@ classes = [
OBJECT_OT_boolean_auto_difference, OBJECT_OT_boolean_auto_difference,
OBJECT_OT_boolean_auto_intersect, OBJECT_OT_boolean_auto_intersect,
OBJECT_OT_boolean_auto_slice, OBJECT_OT_boolean_auto_slice,
] )
def register(): def register():
@@ -2,29 +2,30 @@ import bpy
import itertools import itertools
from .. import __package__ as base_package from .. import __package__ as base_package
from ..functions.poll import ( from ..functions.canvas import (
basic_poll, list_selected_canvases,
is_canvas, list_canvas_cutters,
is_instanced_data, list_canvas_slices,
destructive_op_confirmation, )
from ..functions.cutter import (
list_cutter_users,
handle_unused_cutters,
) )
from ..functions.modifier import ( from ..functions.modifier import (
apply_modifiers, apply_modifiers,
get_modifiers_to_apply,
is_boolean_modifier,
) )
from ..functions.object import ( from ..functions.object import (
object_visibility_set, delete_object,
delete_empty_collection,
delete_cutter,
change_parent,
) )
from ..functions.list import ( from ..functions.poll import (
list_canvases, basic_poll,
list_canvas_slices, destructive_op_confirmation,
list_canvas_cutters, _guess_toggle_state,
list_cutter_users, )
list_selected_canvases, from ..functions.scene import (
list_unused_cutters, delete_empty_collection,
list_pre_boolean_modifiers,
) )
@@ -34,41 +35,50 @@ from ..functions.list import (
class OBJECT_OT_boolean_toggle_all(bpy.types.Operator): class OBJECT_OT_boolean_toggle_all(bpy.types.Operator):
bl_idname = "object.boolean_toggle_all" bl_idname = "object.boolean_toggle_all"
bl_label = "Toggle Boolean Cutters" 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'} bl_options = {'UNDO'}
@classmethod @classmethod
def poll(cls, context): 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): def execute(self, context):
# Filter canvases.
canvases = list_selected_canvases(context) canvases = list_selected_canvases(context)
if len(canvases) == 0:
self.report({'WARNING'}, "No valid canvases selected")
return {'CANCELLED'}
cutters, modifiers = list_canvas_cutters(canvases) 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 # Toggle Modifiers
for mod in modifiers: for mod in modifiers:
mod.show_viewport = not mod.show_viewport mod.show_viewport = not state
mod.show_render = not mod.show_render mod.show_render = not state
# Hide Slices # Hide Slices
for slice in slices: for slice in slices:
slice.hide_viewport = not slice.hide_viewport slice.hide_viewport = state
slice.hide_render = not slice.hide_render slice.hide_render = state
slice.hide_set(state)
for mod in slice.modifiers: for mod in slice.modifiers:
if mod.type == 'BOOLEAN' and mod.object in cutters: if not is_boolean_modifier(mod):
mod.show_viewport = not mod.show_viewport continue
mod.show_render = not mod.show_render if mod.object in cutters:
mod.show_viewport = not state
mod.show_render = not state
# Hide Unused Cutters # 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: 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'} return {'FINISHED'}
@@ -77,72 +87,49 @@ class OBJECT_OT_boolean_toggle_all(bpy.types.Operator):
class OBJECT_OT_boolean_remove_all(bpy.types.Operator): class OBJECT_OT_boolean_remove_all(bpy.types.Operator):
bl_idname = "object.boolean_remove_all" bl_idname = "object.boolean_remove_all"
bl_label = "Remove Boolean Cutters" bl_label = "Remove Boolean Cutters"
bl_description = "Remove all boolean cutters from selected canvases" bl_description = "Remove all Boolean cutters from selected canvases"
bl_options = {'UNDO'} 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 @classmethod
def poll(cls, context): 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): def execute(self, context):
prefs = context.preferences.addons[base_package].preferences prefs = context.preferences.addons[base_package].preferences
# Filter canvases.
canvases = list_selected_canvases(context) canvases = list_selected_canvases(context)
cutters, __ = list_canvas_cutters(canvases) if len(canvases) == 0:
slices = list_canvas_slices(canvases) self.report({'WARNING'}, "No valid canvases selected")
return {'CANCELLED'}
cutters, modifiers = list_canvas_cutters(canvases)
slices = list_canvas_slices(context, canvases)
# Remove Slices # Remove Slices
for slice in slices: for slice in slices:
if slice in canvases: if slice in canvases:
canvases.remove(slice) canvases.remove(slice)
delete_cutter(slice) delete_object(slice)
for canvas in canvases: # Remove Modifiers
# Remove Modifiers for canvas, mods in modifiers.items():
for mod in canvas.modifiers: for mod in mods:
if mod.type == 'BOOLEAN' and "boolean_" in mod.name: canvas.modifiers.remove(mod)
if mod.object in cutters: canvas.booleans.canvas = False
canvas.modifiers.remove(mod)
# remove_boolean_properties # Handle Unused Cutters
if canvas.booleans.canvas == True: handle_unused_cutters(context, list(cutters), canvases + slices,
canvas.booleans.canvas = False delete=self.delete_cutters)
# Purge Empty Collection
# Restore Orphaned Cutters delete_empty_collection(context)
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])
return {'FINISHED'} return {'FINISHED'}
@@ -151,74 +138,56 @@ class OBJECT_OT_boolean_remove_all(bpy.types.Operator):
class OBJECT_OT_boolean_apply_all(bpy.types.Operator): class OBJECT_OT_boolean_apply_all(bpy.types.Operator):
bl_idname = "object.boolean_apply_all" bl_idname = "object.boolean_apply_all"
bl_label = "Apply All Boolean Cutters" bl_label = "Apply All Boolean Cutters"
bl_description = "Apply all boolean cutters on selected canvases" bl_description = "Apply all Boolean cutters to selected canvases"
bl_options = {'UNDO'} 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 @classmethod
def poll(cls, context): 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): def invoke(self, context, event):
self.canvases = list_selected_canvases(context) # Filter canvases.
return destructive_op_confirmation(self, context, event, self.canvases, title="Apply Boolean Cutters") 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): def execute(self, context):
prefs = context.preferences.addons[base_package].preferences prefs = context.preferences.addons[base_package].preferences
cutters, __ = list_canvas_cutters(self.canvases) canvases = list_selected_canvases(context)
slices = list_canvas_slices(self.canvases) cutters, __ = list_canvas_cutters(canvases)
slices = list_canvas_slices(context, canvases)
# Select all faces of the cutter so that newly created faces in canvas # Select all faces of the cutter so that newly created faces in canvas
# are also selected after applying the modifier. # are also selected after applying the modifier.
for cutter in cutters: for cutter in list(cutters):
for face in cutter.data.polygons: for face in cutter.data.polygons:
face.select = True face.select = True
for canvas in itertools.chain(self.canvases, slices): for canvas in itertools.chain(canvases, slices):
context.view_layer.objects.active = canvas
# Apply Modifiers # Apply Modifiers
if prefs.apply_order == 'ALL': modifiers = get_modifiers_to_apply(context, canvas)
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]
apply_modifiers(context, canvas, modifiers) apply_modifiers(context, canvas, modifiers)
# remove_boolean_properties # Remove Boolean Properties
canvas.booleans.canvas = False canvas.booleans.canvas = False
canvas.booleans.slice = 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 # Purge Empty Collection
unused_cutters, leftovers = list_unused_cutters(cutters, self.canvases, slices, do_leftovers=True) delete_empty_collection(context)
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])
return {'FINISHED'} return {'FINISHED'}
@@ -228,11 +197,11 @@ class OBJECT_OT_boolean_apply_all(bpy.types.Operator):
addon_keymaps = [] addon_keymaps = []
classes = [ classes = (
OBJECT_OT_boolean_toggle_all, OBJECT_OT_boolean_toggle_all,
OBJECT_OT_boolean_remove_all, OBJECT_OT_boolean_remove_all,
OBJECT_OT_boolean_apply_all, OBJECT_OT_boolean_apply_all,
] )
def register(): def register():
@@ -1,27 +1,35 @@
import bpy import bpy
import itertools
from .. import __package__ as base_package from .. import __package__ as base_package
from ..functions.poll import ( from ..functions.canvas import (
basic_poll, list_canvas_cutters,
is_instanced_data, list_canvas_slices,
destructive_op_confirmation, )
from ..functions.cutter import (
list_selected_cutters,
list_cutter_users,
restore_cutter,
handle_unused_cutters,
) )
from ..functions.modifier import ( from ..functions.modifier import (
apply_modifiers, apply_modifiers,
is_boolean_modifier,
) )
from ..functions.object import ( from ..functions.object import (
object_visibility_set,
delete_empty_collection,
delete_cutter,
change_parent, change_parent,
delete_object,
) )
from ..functions.list import ( from ..functions.poll import (
list_canvases, basic_poll,
list_selected_cutters, destructive_op_confirmation,
list_canvas_cutters, _guess_toggle_state,
list_canvas_slices, )
list_cutter_users, from ..functions.scene import (
list_unused_cutters, 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): class OBJECT_OT_boolean_toggle_cutter(bpy.types.Operator):
bl_idname = "object.boolean_toggle_cutter" bl_idname = "object.boolean_toggle_cutter"
bl_label = "Toggle Boolean 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'} 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( method: bpy.props.EnumProperty(
name = "Method", name = "Method",
items = (('ALL', "All", "Remove cutter from all canvases that use it"), items = (('ALL', "All", "Toggle all selected cutters"),
('SPECIFIED', "Specified", "Remove cutter from specified canvas")), ('SPECIFIED', "Specified", "Toggle selected cutter")),
default = 'ALL', default = 'ALL',
) )
@@ -45,57 +59,71 @@ class OBJECT_OT_boolean_toggle_cutter(bpy.types.Operator):
) )
specified_canvas: bpy.props.StringProperty( specified_canvas: bpy.props.StringProperty(
) )
specified_modifier: bpy.props.StringProperty(
)
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):
return basic_poll(cls, context, check_linked=True) return basic_poll(cls, context, check_active=False)
def execute(self, context): def execute(self, context):
# Create lists of cutters & canvases.
if self.method == 'SPECIFIED': if self.method == 'SPECIFIED':
canvases = [context.scene.objects[self.specified_canvas]] cutters: list = [context.scene.objects[self.specified_cutter]]
cutters = [context.scene.objects[self.specified_cutter]] canvases: list = [context.scene.objects[self.specified_canvas]]
slices = list_canvas_slices(canvases) modifiers: list = [canvases[0].modifiers.get(self.specified_modifier)]
slices: list = list_canvas_slices(context, canvases)
elif self.method == 'ALL': elif self.method == 'ALL':
cutters = list_selected_cutters(context) cutters: list = list_selected_cutters(context)
canvases = list_cutter_users(cutters) canvases: dict = list_cutter_users(cutters)
modifiers: list = list(itertools.chain.from_iterable(canvases.values()))
if cutters: if len(cutters) == 0:
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:
self.report({'INFO'}, "Boolean cutters are not selected") 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'} return {'FINISHED'}
@@ -104,9 +132,16 @@ class OBJECT_OT_boolean_toggle_cutter(bpy.types.Operator):
class OBJECT_OT_boolean_remove_cutter(bpy.types.Operator): class OBJECT_OT_boolean_remove_cutter(bpy.types.Operator):
bl_idname = "object.boolean_remove_cutter" bl_idname = "object.boolean_remove_cutter"
bl_label = "Remove Boolean 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'} 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( method: bpy.props.EnumProperty(
name = "Method", name = "Method",
items = (('ALL', "All", "Remove cutter from all canvases that use it"), 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_canvas: bpy.props.StringProperty(
) )
specified_modifier: bpy.props.StringProperty(
)
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):
return basic_poll(cls, context, check_linked=True) return basic_poll(cls, context, check_active=False)
def execute(self, context): def execute(self, context):
prefs = context.preferences.addons[base_package].preferences prefs = context.preferences.addons[base_package].preferences
leftovers = []
# Create lists of cutters & canvases.
if self.method == 'SPECIFIED': if self.method == 'SPECIFIED':
canvases = [context.scene.objects[self.specified_canvas]] cutters: list = [context.scene.objects[self.specified_cutter]]
cutters = [context.scene.objects[self.specified_cutter]] canvases: dict = {
slices = list_canvas_slices(canvases) context.scene.objects[self.specified_canvas]:
[context.scene.objects[self.specified_canvas].modifiers.get(self.specified_modifier)]
}
elif self.method == 'ALL': elif self.method == 'ALL':
cutters = list_selected_cutters(context) cutters: list = list_selected_cutters(context)
canvases = list_cutter_users(cutters) canvases: dict = list_cutter_users(cutters)
if cutters: if len(cutters) == 0:
# 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:
self.report({'INFO'}, "Boolean cutters are not selected") 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'} return {'FINISHED'}
@@ -206,104 +225,100 @@ class OBJECT_OT_boolean_remove_cutter(bpy.types.Operator):
class OBJECT_OT_boolean_apply_cutter(bpy.types.Operator): class OBJECT_OT_boolean_apply_cutter(bpy.types.Operator):
bl_idname = "object.boolean_apply_cutter" bl_idname = "object.boolean_apply_cutter"
bl_label = "Apply Boolean 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 = {'REGISTER', 'UNDO'}
bl_options = {'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( method: bpy.props.EnumProperty(
name = "Method", name = "Method",
items = (('ALL', "All", "Remove cutter from all canvases that use it"), items = (('ALL', "All", "Apply all selected cutter to all canvases that use them"),
('SPECIFIED', "Specified", "Remove cutter from specified canvas")), ('SPECIFIED', "Specified", "Apply selected cutter to a specified canvas")),
options = {'SKIP_SAVE', 'HIDDEN'},
default = 'ALL', default = 'ALL',
) )
specified_cutter: bpy.props.StringProperty( specified_cutter: bpy.props.StringProperty(
options = {'SKIP_SAVE', 'HIDDEN'},
) )
specified_canvas: bpy.props.StringProperty( 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 @classmethod
def poll(cls, context): 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): def invoke(self, context, event):
# Filter Objects # Filter Objects
if self.method == 'SPECIFIED': if self.method == 'SPECIFIED':
self.cutters = [context.scene.objects[self.specified_cutter]] canvases = [context.scene.objects[self.specified_canvas]]
self.canvases = [context.scene.objects[self.specified_canvas]]
self.slices = list_canvas_slices(self.canvases)
elif self.method == 'ALL': elif self.method == 'ALL':
self.cutters = list_selected_cutters(context) cutters = list_selected_cutters(context)
self.canvases = list_cutter_users(self.cutters) canvases = list_cutter_users(cutters).keys()
return destructive_op_confirmation(self, context, event, self.canvases, title="Apply Boolean Cutter")
return destructive_op_confirmation(self, context, event, canvases, title="Apply Boolean Cutter")
def execute(self, context): def execute(self, context):
prefs = bpy.context.preferences.addons[base_package].preferences prefs = bpy.context.preferences.addons[base_package].preferences
leftovers = []
if self.cutters: # Create lists of cutters & canvases.
# Select all faces of the cutter so that newly created faces in canvas if self.method == 'SPECIFIED':
# are also selected after applying the modifier. cutters = [context.scene.objects[self.specified_cutter]]
for cutter in self.cutters: canvases = [context.scene.objects[self.specified_canvas]]
for face in cutter.data.polygons: slices = list_canvas_slices(context, canvases)
face.select = True elif self.method == 'ALL':
cutters = list_selected_cutters(context)
canvases = list_cutter_users(cutters).keys()
slices = []
# Apply Modifiers if len(cutters) == 0:
for canvas in self.canvases: self.report({'INFO'}, "Boolean cutters are not selected")
context.view_layer.objects.active = canvas return {'CANCELLED'}
boolean_mods = [] if not canvases:
for mod in canvas.modifiers: return {'CANCELLED'}
if "boolean_" in mod.name:
if mod.object in self.cutters:
boolean_mods.append(mod)
apply_modifiers(context, canvas, boolean_mods)
# 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]) other_cutters, __ = list_canvas_cutters([canvas])
if len(other_cutters) == 0: if len(other_cutters) == 0:
canvas.booleans.canvas = False canvas.booleans.canvas = False
canvas.booleans.slice = 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': # Purge Empty Collection
# Apply Modifier for Slices (for_specified_method) delete_empty_collection(context)
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")
return {'FINISHED'} return {'FINISHED'}
@@ -313,11 +328,11 @@ class OBJECT_OT_boolean_apply_cutter(bpy.types.Operator):
addon_keymaps = [] addon_keymaps = []
classes = [ classes = (
OBJECT_OT_boolean_toggle_cutter, OBJECT_OT_boolean_toggle_cutter,
OBJECT_OT_boolean_remove_cutter, OBJECT_OT_boolean_remove_cutter,
OBJECT_OT_boolean_apply_cutter, OBJECT_OT_boolean_apply_cutter,
] )
def register(): def register():
@@ -1,17 +1,17 @@
import bpy import bpy
from .. import __package__ as base_package
from ..functions.poll import ( from ..functions.canvas import (
basic_poll,
active_modifier_poll,
is_canvas, is_canvas,
)
from ..functions.list import (
list_selected_cutters,
list_selected_canvases, list_selected_canvases,
list_canvas_cutters, list_canvas_cutters,
)
from ..functions.cutter import (
list_selected_cutters,
list_cutter_users, list_cutter_users,
) )
from ..functions.poll import (
basic_poll,
)
#### ------------------------------ OPERATORS ------------------------------ #### #### ------------------------------ OPERATORS ------------------------------ ####
@@ -20,21 +20,24 @@ from ..functions.list import (
class OBJECT_OT_select_cutter_canvas(bpy.types.Operator): class OBJECT_OT_select_cutter_canvas(bpy.types.Operator):
bl_idname = "object.select_cutter_canvas" bl_idname = "object.select_cutter_canvas"
bl_label = "Select Boolean 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'} bl_options = {'UNDO'}
@classmethod @classmethod
def poll(cls, context): 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): def execute(self, context):
cutters = list_selected_cutters(context) cutters = list_selected_cutters(context)
canvases = list_cutter_users(cutters) canvases = list_cutter_users(cutters).keys()
# Select Canvases for obj in context.scene.objects:
bpy.ops.object.select_all(action='DESELECT') obj.select_set(False)
# Select canvases.
for canvas in canvases: for canvas in canvases:
canvas.select_set(True) if not canvas.booleans.slice:
canvas.select_set(True)
return {'FINISHED'} return {'FINISHED'}
@@ -43,19 +46,21 @@ class OBJECT_OT_select_cutter_canvas(bpy.types.Operator):
class OBJECT_OT_boolean_select_all(bpy.types.Operator): class OBJECT_OT_boolean_select_all(bpy.types.Operator):
bl_idname = "object.boolean_select_all" bl_idname = "object.boolean_select_all"
bl_label = "Select Boolean Cutters" 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'} bl_options = {'UNDO'}
@classmethod @classmethod
def poll(cls, context): 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): def execute(self, context):
canvases = list_selected_canvases(context) canvases = list_selected_canvases(context)
cutters, __ = list_canvas_cutters(canvases) cutters, __ = list_canvas_cutters(canvases)
# select_cutters for obj in context.scene.objects:
bpy.ops.object.select_all(action='DESELECT') obj.select_set(False)
# Select cutters.
for cutter in cutters: for cutter in cutters:
cutter.select_set(True) 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): class OBJECT_OT_boolean_select_cutter(bpy.types.Operator):
bl_idname = "object.boolean_select_cutter" bl_idname = "object.boolean_select_cutter"
bl_label = "Select Boolean Cutter" bl_label = "Select Boolean Cutter"
bl_description = "Select object that is used as boolean cutter by this modifier" bl_options = {'UNDO'}
bl_options = {'REGISTER', 'UNDO'}
@classmethod cutter: bpy.props.StringProperty()
def poll(cls, context): extend: bpy.props.BoolProperty()
prefs = context.preferences.addons[base_package].preferences
return (basic_poll(cls, context) and active_modifier_poll(context.active_object) and def invoke(self, context, event):
context.area.type == 'PROPERTIES' and context.space_data.context == 'MODIFIER' and self.extend = event.shift
prefs.double_click) return self.execute(context)
def execute(self, context): def execute(self, context):
modifier = context.object.modifiers.active cutter = bpy.data.objects.get(self.cutter)
if modifier and modifier.type == "BOOLEAN":
cutter = modifier.object
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) cutter.select_set(True)
context.view_layer.objects.active = cutter
return {'FINISHED'} return {'FINISHED'}
@@ -93,31 +98,16 @@ class OBJECT_OT_boolean_select_cutter(bpy.types.Operator):
addon_keymaps = [] addon_keymaps = []
classes = [ classes = (
OBJECT_OT_select_cutter_canvas, OBJECT_OT_select_cutter_canvas,
OBJECT_OT_boolean_select_all, OBJECT_OT_boolean_select_all,
OBJECT_OT_boolean_select_cutter, OBJECT_OT_boolean_select_cutter,
] )
def register(): def register():
for cls in classes: for cls in classes:
bpy.utils.register_class(cls) 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(): def unregister():
for cls in reversed(classes): for cls in reversed(classes):
bpy.utils.unregister_class(cls) 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.""" """Change sidebar category of add-ons panel."""
panel_classes = [ panel_classes = [
ui.VIEW3D_PT_boolean, ui.panels.VIEW3D_PT_boolean,
ui.VIEW3D_PT_boolean_properties, ui.panels.VIEW3D_PT_boolean_helpers,
ui.VIEW3D_PT_boolean_cutters, ui.panels.VIEW3D_PT_boolean_cutters,
] ]
for cls in panel_classes: for cls in panel_classes:
@@ -29,14 +29,22 @@ class BoolToolPreferences(bpy.types.AddonPreferences):
bl_idname = __package__ bl_idname = __package__
# UI # 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( 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", description = "Add a sidebar panel in 3D Viewport with add-ons operators and properties",
default = True, default = True,
) )
sidebar_category: bpy.props.StringProperty( sidebar_category: bpy.props.StringProperty(
name = "Category Name", 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", default = "Edit",
update = update_sidebar_category, update = update_sidebar_category,
) )
@@ -44,87 +52,67 @@ class BoolToolPreferences(bpy.types.AddonPreferences):
# Defaults # Defaults
solver: bpy.props.EnumProperty( solver: bpy.props.EnumProperty(
name = "Boolean Solver", 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", ""), items = [('FLOAT', "Float", ""),
('EXACT', "Exact", ""), ('EXACT', "Exact", ""),
('MANIFOLD', "Manifold", "")], ('MANIFOLD', "Manifold", "")],
default = 'FLOAT', default = 'FLOAT',
) )
wireframe: bpy.props.BoolProperty( display: bpy.props.EnumProperty(
name = "Display Cutters as Wireframe", name = "Cutter Display",
description = ("When enabled cutters will be displayed as wireframes, instead of bounding boxes.\n" items = (('WIRE', "Wire", "Display the cutter object as a wireframe"),
"It's better for visualizating the shape, but might be harder to see and have performance cost"), ('BOUNDS', "Bounds", "Display only the bounds of the cutter object")),
default = False, default = 'BOUNDS'
) )
show_in_editmode: bpy.props.BoolProperty( show_in_editmode: bpy.props.BoolProperty(
name = "Enable 'Show in Edit Mode' by Default", 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, default = True,
) )
# Advanced # Advanced
use_collection: bpy.props.BoolProperty( use_collection: bpy.props.BoolProperty(
name = "Put Cutters in Collection", 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" description = ("Put all cutters in the same collection, and create one if it doesn't exist"),
"Useful for scene management, and quickly selecting and removing all clutter when needed"),
default = True, default = True,
) )
collection_name: bpy.props.StringProperty( collection_name: bpy.props.StringProperty(
name = "Collection Name", name = "Collection Name",
description = "Name of the collection where cutters will be added",
default = "boolean_cutters", default = "boolean_cutters",
) )
parent: bpy.props.BoolProperty( parent: bpy.props.BoolProperty(
name = "Parent Cutters to Object", 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" description = ("Cutters will be parented to first canvas they're applied to"),
"NOTE: This doesn't affect Carver tool, which has its own property for this"),
default = True, default = True,
) )
apply_order: bpy.props.EnumProperty( apply_order: bpy.props.EnumProperty(
name = "When Applying Cutters...", name = "Apply Modifiers",
description = ("What happens when boolean cutters are applied on object.\n" description = ("Which modifiers to apply when using add-ons destructive operators.\n"
"Either when performing auto-boolean, using 'Apply All Cutters' operator.\n" "NOTE: This option is not used when applying individual cutters"),
"NOTE: This doesn't apply to Carver tool on 'Destructive' mode; or when applying individual cutters"), items = (('ALL', "All",
items = (('ALL', "Apply All Modifiers", "All modifiers on object will be applied (this includes shape keys as well)"), "All modifiers on object (and shape keys) will be applied during destructive operators"),
('BEFORE', "Apply Booleans & Everything Before", "Alongside boolean modifiers all modifiers will be applied that come before the last boolean"), ('BEFORE', "Booleans & Everything Before",
('BOOLEANS', "Only Apply Booleans", "Only apply boolean modifiers. This method will fail if object has shape keys")), "Apply all modifiers that come before the last Boolean modifier in the stack"),
('BOOLEANS', "Booleans",
"Only apply Boolean modifiers")),
default = 'ALL', default = 'ALL',
) )
pin: bpy.props.BoolProperty( pin: bpy.props.BoolProperty(
name = "Pin Boolean Modifiers", name = "Pin Boolean Modifiers",
description = ("When enabled boolean modifiers will be placed above every other modifier on the object (if there are any).\n" description = ("Always make new Boolean modifiers first in the modifier stack.\n"
"Order of modifiers can drastically affect the result (especially when performing auto boolean).\n" "NOTE: Order of modifiers can drastically affect the final result"),
"NOTE: This doesn't affect Carver tool, which has its own property for this"),
default = False, default = False,
) )
# Features # Features
fast_modifier_apply: bpy.props.BoolProperty( fast_modifier_apply: bpy.props.BoolProperty(
name = "Faster Destructive Booleans", name = "Faster Destructive Booleans",
description = ("Experimental method of applying modifiers that results in 30-50% faster destructive booleans.\n" 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"
"However, changing modifier properties in the redo panel (like material transfer)\n" "However, changing modifier properties in the redo panel (like material transfer)\n"
"is not available for this method yet."), "is not available for this method yet."),
default = False, 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): def draw(self, context):
layout = self.layout layout = self.layout
@@ -132,55 +120,57 @@ class BoolToolPreferences(bpy.types.AddonPreferences):
layout.use_property_decorate = False layout.use_property_decorate = False
# UI # UI
col = layout.column(align=True, heading="Show in Sidebar") row = layout.row()
row = col.row(align=True) row.prop(self, "category", expand=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
layout.separator() 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 # Add-on Properties
layout.separator() if self.category == 'ADDON':
col = layout.column(align=True, heading="Put Cutters in Collection") col = layout.column(align=True, heading="Show in Sidebar")
row = col.row(align=True) row = col.row(align=True)
sub = row.row(align=True) sub = row.row(align=True)
sub.prop(self, "use_collection", text="") sub.prop(self, "show_in_sidebar", text="")
sub = sub.row(align=True) sub = sub.row(align=True)
sub.active = self.show_in_sidebar sub.active = self.show_in_sidebar
sub.prop(self, "collection_name", text="") sub.prop(self, "sidebar_category", text="")
col.prop(self, "parent") col.separator()
col.prop(self, "apply_order") col = layout.column(align=True, heading="Features")
col.prop(self, "pin") col.prop(self, "fast_modifier_apply")
# Features # Shared Properties
layout.separator() if self.category == 'SHARED':
col = layout.column(align=True, heading="Features") col = layout.column()
col.prop(self, "fast_modifier_apply") col.prop(self, "show_in_editmode")
col.prop(self, "double_click") col.prop(self, "apply_order")
# Experimentals # Boolean Operator Properties
layout.separator() if self.category == 'OPERATORS':
col = layout.column(align=True) col = layout.column(align=True, heading="Put Cutters in Collection")
col.prop(self, "versioning", text="⚠ Versioning") row = col.row(align=True)
col.prop(self, "experimental", text="⚠ Experimental") 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 ------------------------------ #### #### ------------------------------ REGISTRATION ------------------------------ ####
classes = [ classes = (
BoolToolPreferences, BoolToolPreferences,
] )
def register(): def register():
for cls in classes: for cls in classes:
@@ -32,13 +32,15 @@ class OBJECT_PG_booleans(bpy.types.PropertyGroup):
default = False, default = False,
) )
modifiers_list_index: bpy.props.IntProperty()
#### ------------------------------ REGISTRATION ------------------------------ #### #### ------------------------------ REGISTRATION ------------------------------ ####
classes = [ classes = (
OBJECT_PG_booleans, OBJECT_PG_booleans,
] )
def register(): def register():
for cls in classes: for cls in classes:
@@ -21,12 +21,12 @@ else:
#### ------------------------------ REGISTRATION ------------------------------ #### #### ------------------------------ REGISTRATION ------------------------------ ####
"""NOTE: Order of modules is important because of dependancies. Don't change without a reason.""" """NOTE: Order of modules is important because of dependancies. Don't change without a reason."""
modules = [ modules = (
carver_circle, carver_circle,
carver_box, carver_box,
carver_polyline, carver_polyline,
ui, ui,
] )
main_tools = [ main_tools = [
carver_box.OBJECT_WT_carve_box, carver_box.OBJECT_WT_carve_box,
@@ -1,13 +1,18 @@
import bpy import bpy
import os import os
from mathutils import Vector 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 ( from .common.base import (
CarverBase, CarverBase,
) )
from .common.properties import ( from .common.properties import (
CarverPropsArray,
CarverPropsBevel, CarverPropsBevel,
) )
from .common.types import ( from .common.types import (
@@ -34,7 +39,7 @@ class OBJECT_WT_carve_box(bpy.types.WorkSpaceTool):
bl_space_type = 'VIEW_3D' bl_space_type = 'VIEW_3D'
bl_context_mode = 'OBJECT' 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 = ( bl_keymap = (
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG'}, {"properties": None}), ("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG'}, {"properties": None}),
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "shift": True}, {"properties": None}), ("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "shift": True}, {"properties": None}),
@@ -58,9 +63,7 @@ class MESH_WT_carve_box(OBJECT_WT_carve_box):
#### ------------------------------ OPERATORS ------------------------------ #### #### ------------------------------ OPERATORS ------------------------------ ####
class OBJECT_OT_carve_box(CarverBase, class OBJECT_OT_carve_box(CarverBase, CarverPropsBevel):
CarverPropsArray,
CarverPropsBevel):
bl_idname = "object.carve_box" bl_idname = "object.carve_box"
bl_label = "Box Carve" bl_label = "Box Carve"
bl_description = description bl_description = description
@@ -70,6 +73,7 @@ class OBJECT_OT_carve_box(CarverBase,
# SHAPE-properties # SHAPE-properties
shape = 'BOX' shape = 'BOX'
# NOTE: There are registered on operator level because they need to be overriden or hidden per-tool.
aspect: bpy.props.EnumProperty( aspect: bpy.props.EnumProperty(
name = "Aspect", name = "Aspect",
description = "The initial aspect", description = "The initial aspect",
@@ -115,8 +119,8 @@ class OBJECT_OT_carve_box(CarverBase,
# cached_variables # cached_variables
"""Important for storing context as it was when operator was invoked (untouched by the modal).""" """Important for storing context as it was when operator was invoked (untouched by the modal)."""
self.phase = "DRAW" self.phase = "DRAW"
self.initial_origin = self.origin # Initial shape origin. self._initial_origin = self.origin # Initial shape origin.
self.initial_aspect = self.aspect # Initial shape aspect. self._initial_aspect = self.aspect # Initial shape aspect.
self._stored_phase = "DRAW" self._stored_phase = "DRAW"
# Add Draw Handler # Add Draw Handler
@@ -130,12 +134,11 @@ class OBJECT_OT_carve_box(CarverBase,
def modal(self, context, event): def modal(self, context, event):
redraw_regions(context)
# Status Bar Text # Status Bar Text
self.status(context) self.status(context)
# find_the_limit_of_the_3d_viewport_region
self.redraw_region(context)
# Modifier Keys # Modifier Keys
self.event_aspect(context, event) self.event_aspect(context, event)
self.event_origin(context, event) self.event_origin(context, event)
@@ -151,7 +154,6 @@ class OBJECT_OT_carve_box(CarverBase,
if self.phase != "BEVEL": if self.phase != "BEVEL":
return {'PASS_THROUGH'} return {'PASS_THROUGH'}
# Mouse Move # Mouse Move
if event.type == 'MOUSEMOVE': if event.type == 'MOUSEMOVE':
self.mouse.current = Vector((event.mouse_region_x, event.mouse_region_y)) 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": elif self.phase == "EXTRUDE":
self.set_extrusion_depth(context) self.set_extrusion_depth(context)
# Confirm # Confirm
elif event.type == 'LEFTMOUSE': elif event.type == 'LEFTMOUSE':
# Confirm Shape # Confirm Shape
@@ -178,7 +179,7 @@ class OBJECT_OT_carve_box(CarverBase,
min_distance = 5 min_distance = 5
if delta_x < min_distance or delta_y < min_distance: 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'} return {'FINISHED'}
self.extrude_cutter(context) self.extrude_cutter(context)
@@ -188,18 +189,15 @@ class OBJECT_OT_carve_box(CarverBase,
if self.depth != 'MANUAL': if self.depth != 'MANUAL':
self.confirm(context) self.confirm(context)
return {'FINISHED'} return {'FINISHED'}
else:
return {'RUNNING_MODAL'}
# Confirm Depth # Confirm Depth
if self.phase == "EXTRUDE" and event.value == 'PRESS': if self.phase == "EXTRUDE" and event.value == 'PRESS':
self.confirm(context) self.confirm(context)
return {'FINISHED'} return {'FINISHED'}
# Cancel # Cancel
elif event.type in {'RIGHTMOUSE', 'ESC'}: elif event.type in {'RIGHTMOUSE', 'ESC'}:
self.finalize(context, clean_up=True, abort=True) self.finalize(context, abort=True)
return {'FINISHED'} return {'FINISHED'}
return {'RUNNING_MODAL'} return {'RUNNING_MODAL'}
@@ -305,9 +303,9 @@ class OBJECT_OT_carve_box(CarverBase,
#### ------------------------------ REGISTRATION ------------------------------ #### #### ------------------------------ REGISTRATION ------------------------------ ####
classes = [ classes = (
OBJECT_OT_carve_box, OBJECT_OT_carve_box,
] )
def register(): def register():
for cls in classes: for cls in classes:
@@ -1,6 +1,9 @@
import bpy import bpy
import os import os
from .. import __file__ as base_file
from ..constants import (
ICONS_PATH,
)
from .common.ui import ( from .common.ui import (
carver_ui_common, carver_ui_common,
@@ -20,7 +23,7 @@ class OBJECT_WT_carve_circle(bpy.types.WorkSpaceTool):
bl_space_type = 'VIEW_3D' bl_space_type = 'VIEW_3D'
bl_context_mode = 'OBJECT' 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 = ( bl_keymap = (
("object.carve_circle", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG'}, {"properties": None}), ("object.carve_circle", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG'}, {"properties": None}),
("object.carve_circle", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "shift": True}, {"properties": None}), ("object.carve_circle", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "shift": True}, {"properties": None}),
@@ -51,6 +54,7 @@ class OBJECT_OT_carve_circle(OBJECT_OT_carve_box):
# SHAPE-properties # SHAPE-properties
shape = 'CIRCLE' shape = 'CIRCLE'
# NOTE: There are registered on operator level because they need to be overriden or hidden per-tool.
subdivision: bpy.props.IntProperty( subdivision: bpy.props.IntProperty(
name = "Circle Subdivisions", name = "Circle Subdivisions",
description = "Number of vertices that will make up the circular shape that will be extruded into a cylinder", 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 ------------------------------ #### #### ------------------------------ REGISTRATION ------------------------------ ####
classes = [ classes = (
OBJECT_OT_carve_circle, OBJECT_OT_carve_circle,
] )
def register(): def register():
for cls in classes: for cls in classes:
@@ -1,16 +1,18 @@
import bpy import bpy
import math
import os import os
from mathutils import Vector from mathutils import Vector
from bpy_extras import view3d_utils 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 ( from .common.base import (
CarverBase, CarverBase,
) )
from .common.properties import (
CarverPropsArray,
)
from .common.types import ( from .common.types import (
Selection, Selection,
Mouse, Mouse,
@@ -35,7 +37,7 @@ class OBJECT_WT_carve_polyline(bpy.types.WorkSpaceTool):
bl_space_type = 'VIEW_3D' bl_space_type = 'VIEW_3D'
bl_context_mode = 'OBJECT' 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 = ( bl_keymap = (
("object.carve_polyline", {"type": 'LEFTMOUSE', "value": 'CLICK'}, None), ("object.carve_polyline", {"type": 'LEFTMOUSE', "value": 'CLICK'}, None),
("object.carve_polyline", {"type": 'LEFTMOUSE', "value": 'CLICK', "ctrl": True}, 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 ------------------------------ #### #### ------------------------------ OPERATORS ------------------------------ ####
class OBJECT_OT_carve_polyline(CarverBase, class OBJECT_OT_carve_polyline(CarverBase):
CarverPropsArray):
bl_idname = "object.carve_polyline" bl_idname = "object.carve_polyline"
bl_label = "Polyline Carve" bl_label = "Polyline Carve"
bl_description = description bl_description = description
@@ -108,12 +109,11 @@ class OBJECT_OT_carve_polyline(CarverBase,
def modal(self, context, event): def modal(self, context, event):
redraw_regions(context)
# Status Bar Text # Status Bar Text
self.status(context) self.status(context)
# find_the_limit_of_the_3d_viewport_region
self.redraw_region(context)
# Modifier Keys # Modifier Keys
self.event_array(context, event) self.event_array(context, event)
self.event_move(context, event) self.event_move(context, event)
@@ -124,7 +124,6 @@ class OBJECT_OT_carve_polyline(CarverBase,
if self.phase != "BEVEL": if self.phase != "BEVEL":
return {'PASS_THROUGH'} return {'PASS_THROUGH'}
# Mouse Move # Mouse Move
if event.type == 'MOUSEMOVE': if event.type == 'MOUSEMOVE':
self.mouse.current = Vector((event.mouse_region_x, event.mouse_region_y)) 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": elif self.phase == "EXTRUDE":
self.set_extrusion_depth(context) self.set_extrusion_depth(context)
# Add Points & Confirm # Add Points & Confirm
elif event.type == 'LEFTMOUSE' and event.value == 'PRESS': elif event.type == 'LEFTMOUSE' and event.value == 'PRESS':
if self.phase == "DRAW": if self.phase == "DRAW":
# Confirm Shape (if clicked on the first vert) # Add Point
if self._distance_from_first > 75: if self._distance_from_first < 75:
self._insert_polyline_point()
# Confirm Shape (if clicked near the first vert).
else:
verts = self.cutter.verts verts = self.cutter.verts
if len(verts) > 3: if len(verts) > 3:
self._remove_polyline_point(context, jump_mouse=False) self._remove_polyline_point(context, jump_mouse=False)
@@ -165,17 +167,12 @@ class OBJECT_OT_carve_polyline(CarverBase,
else: else:
return {'RUNNING_MODAL'} return {'RUNNING_MODAL'}
# Add Point
else:
self._insert_polyline_point()
# Confirm Depth # Confirm Depth
if self.phase == "EXTRUDE": if self.phase == "EXTRUDE":
self.confirm(context) self.confirm(context)
return {'FINISHED'} return {'FINISHED'}
# Enter (Confirm)
# Confirm
elif event.type == 'RET': elif event.type == 'RET':
verts = self.cutter.verts verts = self.cutter.verts
if len(verts) > 2: if len(verts) > 2:
@@ -195,18 +192,14 @@ class OBJECT_OT_carve_polyline(CarverBase,
if self.phase == "EXTRUDE" and event.value == 'PRESS': if self.phase == "EXTRUDE" and event.value == 'PRESS':
self.confirm(context) self.confirm(context)
return {'FINISHED'} return {'FINISHED'}
else:
self.report({'WARNING'}, "At least three points are required to make a polygonal shape")
# Remove Last Point # Remove Last Point
if event.type == 'BACK_SPACE' and event.value == 'PRESS': if event.type == 'BACK_SPACE' and event.value == 'PRESS':
self._remove_polyline_point(context) self._remove_polyline_point(context)
# Cancel # Cancel
elif event.type in {'RIGHTMOUSE', 'ESC'}: elif event.type in {'RIGHTMOUSE', 'ESC'}:
self.finalize(context, clean_up=True, abort=True) self.finalize(context, abort=True)
return {'FINISHED'} return {'FINISHED'}
return {'RUNNING_MODAL'} return {'RUNNING_MODAL'}
@@ -301,7 +294,7 @@ class OBJECT_OT_carve_polyline(CarverBase,
context.workspace.status_text_set(modal_keys_extrude) context.workspace.status_text_set(modal_keys_extrude)
# Polyline-specific features. # Polyline-specific methods.
def _insert_polyline_point(self): def _insert_polyline_point(self):
"""Inserts a new vertex in the cutter geometry and connects it to the previous last one.""" """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, context.region_data,
vert_world) vert_world)
if screen_pos: 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 ------------------------------ #### #### ------------------------------ REGISTRATION ------------------------------ ####
classes = [ classes = (
OBJECT_OT_carve_polyline, OBJECT_OT_carve_polyline,
] )
def register(): def register():
for cls in classes: for cls in classes:
@@ -1,33 +1,22 @@
import bpy import bpy
import bmesh import bmesh
import math import math
import mathutils
from bpy_extras import view3d_utils from bpy_extras import view3d_utils
from mathutils import Vector, Matrix from mathutils import Vector, Matrix
from .types import ( from ...functions.cutter import (
Effects, make_cutter,
) )
from .properties import (
CarverPropsOperator,
CarverPropsShape,
CarverPropsModifier,
CarverPropsCutter,
)
from ...functions.draw import ( from ...functions.draw import (
draw_shader, draw_shader,
draw_bmesh_faces, draw_bmesh_faces,
draw_circle_around_point, draw_circle_billboard,
)
from ...functions.math import (
distance_from_point_to_segment,
region_2d_to_plane_3d,
region_2d_to_line_3d,
) )
from ...functions.mesh import ( from ...functions.mesh import (
is_instanced_mesh,
extrude_face, extrude_face,
are_intersecting, are_intersecting,
raycast,
) )
from ...functions.modifier import ( from ...functions.modifier import (
add_boolean_modifier, add_boolean_modifier,
@@ -35,300 +24,44 @@ from ...functions.modifier import (
get_modifiers_to_apply, get_modifiers_to_apply,
) )
from ...functions.object import ( from ...functions.object import (
set_cutter_properties,
delete_empty_collection,
delete_cutter,
set_object_origin,
)
from ...functions.poll import (
is_linked, 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/ ------------------------------ #### #### ------------------------------ CLASSES ------------------------------ ####
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
class CarverBase(bpy.types.Operator, class CarverBase(bpy.types.Operator,
CarverEvents, CarverEvents,
CarverPropsOperator, CarverPropsOperator,
CarverPropsShape, CarverPropsShape,
CarverPropsModifier, CarverPropsModifier,
CarverPropsCutter): CarverPropsCutter,
CarverPropsArray):
"""Base class for Carver operators.""" """Base class for Carver operators."""
def redraw_region(self, context): # Core Methods
"""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()
def validate_selection(self, context): def validate_selection(self, context):
"""Filter out selection to get the list of viable canvases.""" """Filter out selection to get the list of viable canvases."""
@@ -349,10 +82,14 @@ class CarverBase(bpy.types.Operator,
continue continue
if self.mode == 'DESTRUCTIVE': 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") self.report({'WARNING'}, f"Modifiers cannot be applied to {obj.name} because it has instanced object data")
continue 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) selected.append(obj)
# Ensure the active object. # Ensure the active object.
@@ -367,11 +104,10 @@ class CarverBase(bpy.types.Operator,
return selected, active return selected, active
# Core Methods
def calculate_workplane(self, context): def calculate_workplane(self, context):
""" """
Calculates matrix, location (origin point), and normal (direction) 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': if self.alignment == 'SURFACE':
@@ -393,7 +129,7 @@ class CarverBase(bpy.types.Operator,
def create_cutter(self, context): 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 # Create the Mesh & bmesh
mesh = bpy.data.meshes.new(name="boolean_cutter") mesh = bpy.data.meshes.new(name="boolean_cutter")
@@ -402,15 +138,15 @@ class CarverBase(bpy.types.Operator,
# Create the Object # Create the Object
obj = bpy.data.objects.new("boolean_cutter", mesh) obj = bpy.data.objects.new("boolean_cutter", mesh)
obj.matrix_world = self.workplane.matrix obj.matrix_world = self.workplane.matrix
set_cutter_properties(context, obj, "Difference", collection=True, make_cutter(context, obj, "Difference", collection=True,
display='WIRE' if self.shape == 'POLYLINE' else self.display) display='WIRE' if self.shape == 'POLYLINE' else self.display)
obj.booleans.carver = True obj.booleans.carver = True
# Initial Rotation # Initial Rotation
if self.rotation != 0: if self.rotation != 0:
rotation_matrix = Matrix.Rotation(self.rotation, 4, self.workplane.normal) rotation_matrix = Matrix.Rotation(self.rotation, 4, self.workplane.normal)
temp_matrix_world = rotation_matrix @ obj.matrix_world rotation_matrix = rotation_matrix @ obj.matrix_world
obj.rotation_euler = temp_matrix_world.to_euler() obj.rotation_euler = rotation_matrix.to_euler()
# Create Verts # Create Verts
if self.shape == 'BOX': if self.shape == 'BOX':
@@ -471,18 +207,21 @@ class CarverBase(bpy.types.Operator,
# Draw Line # Draw Line
if self.phase in ("BEVEL", "ROTATE", "ARRAY"): if self.phase in ("BEVEL", "ROTATE", "ARRAY"):
current_mouse_pos_3d = region_2d_to_plane_3d(context.region, context.region_data, current_mouse_pos_3d = region_2d_to_plane_3d(context.region, context.region_data,
self.mouse.current, self.mouse.current,
(self.workplane.location, self.workplane.normal)) (self.workplane.location, self.workplane.normal))
if current_mouse_pos_3d is not None: if current_mouse_pos_3d is not None:
vertices = [self.cutter.center, current_mouse_pos_3d] vertices = [self.cutter.center, current_mouse_pos_3d]
if vertices is not None: if vertices is not None:
draw_shader('LINES', (0.00, 0.00, 0.00), 1.0, vertices) 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': if self.shape == 'POLYLINE' and self.phase == 'DRAW':
verts = self.cutter.verts verts = self.cutter.verts
if len(verts) > 3: 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: if len(vertices) > 0:
draw_shader('LINE_LOOP', (0, 0, 0), 1.0, vertices) draw_shader('LINE_LOOP', (0, 0, 0), 1.0, vertices)
@@ -497,7 +236,7 @@ class CarverBase(bpy.types.Operator,
bm = self.cutter.bm bm = self.cutter.bm
face = self.cutter.faces[0] 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, current_mouse_pos_3d = region_2d_to_plane_3d(region, rv3d,
self.mouse.current, self.mouse.current,
(self.workplane.location, self.workplane.normal)) (self.workplane.location, self.workplane.normal))
@@ -582,7 +321,6 @@ class CarverBase(bpy.types.Operator,
if self.alignment == 'CURSOR' and self.depth == 'CURSOR': if self.alignment == 'CURSOR' and self.depth == 'CURSOR':
self.depth = 'AUTO' self.depth = 'AUTO'
# Push the extruded face towards the furthest point of the collective bounding box. # Push the extruded face towards the furthest point of the collective bounding box.
if self.depth == 'AUTO': if self.depth == 'AUTO':
corners = [] corners = []
@@ -599,10 +337,9 @@ class CarverBase(bpy.types.Operator,
offset = self.offset if self.alignment == 'SURFACE' else 0.1 offset = self.offset if self.alignment == 'SURFACE' else 0.1
for v in face.verts: 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': elif self.depth == 'CURSOR':
local_cursor = self.cutter.obj.matrix_world.inverted() @ context.scene.cursor.location local_cursor = self.cutter.obj.matrix_world.inverted() @ context.scene.cursor.location
for v in extruded_verts: for v in extruded_verts:
@@ -647,20 +384,24 @@ class CarverBase(bpy.types.Operator,
region = context.region region = context.region
rv3d = context.region_data 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. # Convert the mouse position into a 3D ray and find closest points between...
# and calculate the distance between that and the extrude origin. # that ray and the line coming from the extrude origin along the workplane normal.
closest_points = region_2d_to_line_3d(region, rv3d, ray_origin, ray_direction = region_2d_to_ray_3d(region, rv3d, self.mouse.current)
self.mouse.current, closest_points = mathutils.geometry.intersect_line_line(ray_origin,
self._extrude_origin, self.workplane.normal) ray_origin + ray_direction,
self._extrude_origin,
self._extrude_origin + self.workplane.normal)
if closest_points is None: if closest_points is None:
return return
# Calculate the distance between the extrude origin and mouse position.
offset_vector = closest_points[1] - self._extrude_origin offset_vector = closest_points[1] - self._extrude_origin
distance = offset_vector.dot(self.workplane.normal) distance = offset_vector.dot(self.workplane.normal)
# Offset vertices of the extruded face from their their original coordinates. # 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: if distance is not None:
for v, vert_co in zip(self._extrude_faces[-1].verts, self._extrude_verts_co): for v, vert_co in zip(self._extrude_faces[-1].verts, self._extrude_verts_co):
offset = normal * distance offset = normal * distance
@@ -695,68 +436,71 @@ class CarverBase(bpy.types.Operator,
ray_obj_matrix = ray.obj.matrix_world ray_obj_matrix = ray.obj.matrix_world
mesh = ray.obj.evaluated_get(context.view_layer.depsgraph).to_mesh() mesh = ray.obj.evaluated_get(context.view_layer.depsgraph).to_mesh()
if mesh is not None: if mesh is None:
# create_temporary_bmesh self.alignment = 'VIEW'
temp_bm = bmesh.new() return None, None, None
temp_bm.from_mesh(mesh)
temp_bm.faces.ensure_lookup_table()
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': face = temp_bm.faces[ray.index]
# Get the tangent, normal, and bitangent from the face normal.
tangent = face.calc_tangent_edge()
normal = face.normal
bitangent = normal.cross(tangent)
elif self.orientation in ('CLOSEST_EDGE', 'LONGEST_EDGE'): if self.orientation == 'FACE':
# Get the tangent, normal, and bitangent from the longest or the closest edge of the 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': elif self.orientation in ('CLOSEST_EDGE', 'LONGEST_EDGE'):
lengths = [ # Get the tangent, normal, and bitangent from the longest or the closest edge of the face.
(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 == 'CLOSEST_EDGE': if self.orientation == 'LONGEST_EDGE':
distances = [ lengths = [
distance_from_point_to_segment( (ray_obj_matrix @ edge.verts[0].co - ray_obj_matrix @ edge.verts[1].co).length
ray.location, for edge in face.edges
ray_obj_matrix @ edge.verts[0].co, ]
ray_obj_matrix @ edge.verts[1].co, longest_edge = sorted(zip(lengths, face.edges), key=lambda x: x[0], reverse=True)[0][1]
) edge = longest_edge
for edge in face.edges
]
closest_edge = sorted(zip(distances, face.edges), key=lambda x: x[0])[0][1]
edge = closest_edge
# Get the loop (face corner) for the edge that is also in the face. elif self.orientation == 'CLOSEST_EDGE':
face_corner = next(loop for loop in edge.link_loops if loop.face == face) 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 # Get the loop (face corner) for the edge that is also in the face.
end = face_corner.link_loop_next.vert face_corner = next(loop for loop in edge.link_loops if loop.face == face)
direction = (end.co - start.co)
tangent = edge.calc_tangent(face_corner) start = face_corner.vert
normal = direction.cross(tangent) end = face_corner.link_loop_next.vert
bitangent = normal.cross(tangent) direction = (end.co - start.co)
# Construct Matrix tangent = edge.calc_tangent(face_corner)
matrix = Matrix.Identity(4) normal = direction.cross(tangent)
matrix[0].xyz = (ray_obj_matrix.to_3x3() @ tangent).normalized() bitangent = normal.cross(tangent)
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)
# destroy_temporary_bmesh # Construct Matrix
temp_bm.free() matrix = Matrix.Identity(4)
del mesh 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() # destroy_temporary_bmesh
location = ray.location + (ray.normal * self.offset) temp_bm.free()
normal = ray.normal del mesh
matrix = matrix.transposed()
location = ray.location + (ray.normal * self.offset)
normal = ray.normal
return matrix, location, normal return matrix, location, normal
@@ -776,8 +520,7 @@ class CarverBase(bpy.types.Operator,
context.scene.cursor.location) context.scene.cursor.location)
else: else:
# Put the location at the closest point of the bounding box of all selected objects. # 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_origin, ray_direction = region_2d_to_ray_3d(region, rv3d, self.mouse.initial)
ray_direction = view3d_utils.region_2d_to_vector_3d(region, rv3d, self.mouse.initial)
corners = [] corners = []
for obj in self.objects.selected: for obj in self.objects.selected:
@@ -803,8 +546,7 @@ class CarverBase(bpy.types.Operator,
matrix = Matrix.Identity(4) matrix = Matrix.Identity(4)
normal = matrix.col[2].xyz normal = matrix.col[2].xyz
ray_origin = view3d_utils.region_2d_to_origin_3d(region, rv3d, self.mouse.initial) ray_origin, ray_direction = region_2d_to_ray_3d(region, rv3d, self.mouse.initial)
ray_direction = view3d_utils.region_2d_to_vector_3d(region, rv3d, self.mouse.initial)
t = (matrix.translation.z - ray_origin.z) / ray_direction.z t = (matrix.translation.z - ray_origin.z) / ray_direction.z
location = ray_origin + ray_direction * t location = ray_origin + ray_direction * t
matrix.translation = location matrix.translation = location
@@ -851,7 +593,6 @@ class CarverBase(bpy.types.Operator,
cutter = self.cutter.obj cutter = self.cutter.obj
# Add Modifier(s)
for obj in self.objects.selected: for obj in self.objects.selected:
mod = add_boolean_modifier(self, context, obj, mod = add_boolean_modifier(self, context, obj,
cutter, "DIFFERENCE", cutter, "DIFFERENCE",
@@ -877,7 +618,7 @@ class CarverBase(bpy.types.Operator,
obj.modifiers.remove(mod) obj.modifiers.remove(mod)
if not intersecting_canvases: if not intersecting_canvases:
self.finalize(context, clean_up=True) self.finalize(context)
return return
# Select all faces of the cutter so that newly created faces in canvas # 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: for obj in intersecting_canvases:
obj.booleans.canvas = True obj.booleans.canvas = True
self.finalize(context) self.finalize(context, clean_up=False)
return return
elif self.mode == 'DESTRUCTIVE': elif self.mode == 'DESTRUCTIVE':
@@ -930,11 +671,11 @@ class CarverBase(bpy.types.Operator,
modifiers = get_modifiers_to_apply(context, obj, [modifiers]) modifiers = get_modifiers_to_apply(context, obj, [modifiers])
apply_modifiers(context, obj, modifiers, force_clean=True) apply_modifiers(context, obj, modifiers, force_clean=True)
self.finalize(context, clean_up=True) self.finalize(context)
return 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. Finalize and clean-up after the operation ends.
Regardless of whether it was confirmed or cancelled. 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. # Operation was aborted, or successfully finished in the Destructive mode.
# Delete everything created by the operator (i.e. cutter). # Delete everything created by the operator (i.e. cutter).
if clean_up: if clean_up:
delete_cutter(self.cutter.obj) delete_object(self.cutter.obj)
self.cutter.bm.free() self.cutter.bm.free()
delete_empty_collection() delete_empty_collection(context)
# Remove modifiers added by the operator. # Remove modifiers added by the operator.
if abort: if abort:
for obj, mod in self.objects.modifiers.items(): for obj, mod in self.objects.modifiers.items():
obj.modifiers.remove(mod) 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') bpy.types.SpaceView3D.draw_handler_remove(self._handler, 'WINDOW')
context.workspace.status_text_set(None) context.workspace.status_text_set(None)
context.window.cursor_set('DEFAULT' if context.mode == 'OBJECT' else 'CROSSHAIR') 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 bpy
import math import math
from ...ui.icons import (
# Import Custom Icons get_custom_icon,
from ... import icons )
svg_icons = icons.svg_icons["main"]
icon_measure = svg_icons["MEASURE"].icon_id
icon_cpu = svg_icons["CPU"].icon_id
#### ------------------------------ PROPERTIES ------------------------------ #### #### ------------------------------ PROPERTIES ------------------------------ ####
class CarverPropsOperator(): 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 # OPERATOR-properties
mode: bpy.props.EnumProperty( mode: bpy.props.EnumProperty(
name = "Mode", name = "Mode",
items = (('DESTRUCTIVE', "Destructive", 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", ('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', default = 'MODIFIER',
) )
alignment: bpy.props.EnumProperty( alignment: bpy.props.EnumProperty(
name = "Alignment", name = "Alignment",
items = (('SURFACE', "Surface", "Align cutters to the surface normal of the mesh under the mouse", 'SNAP_NORMAL', 0), items = (('SURFACE', "Surface",
('VIEW', "View", "Align cutters to the current view", 'VIEW_CAMERA_UNSELECTED', 1), "Align cutters to the surface normal of the mesh under the mouse",
('CURSOR', "3D Cursor", "Align cutters to the 3D cursor orientation", 'ORIENTATION_CURSOR', 2), 'SNAP_NORMAL', 0),
('GRID', "Grid", "Align cutters to the world grid", 'GRID', 3)), ('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', default = 'SURFACE',
) )
depth: bpy.props.EnumProperty( depth: bpy.props.EnumProperty(
name = "Depth", name = "Depth",
items = (('MANUAL', "Manual", "Depth can be manually set after creating a cutter shape", icon_measure, 0), items = get_depth_items,
('AUTO', "Auto", "Depth is set automatically to cover selected objects entirely", icon_cpu, 1), default = 0,
('CURSOR', "3D Cursor", "Depth is set to 3D cursors location", 'PIVOT_CURSOR', 2)),
default = 'MANUAL',
) )
@@ -72,7 +91,7 @@ class CarverPropsShape():
flip_direction: bpy.props.BoolProperty( flip_direction: bpy.props.BoolProperty(
name = "Flip Direction", name = "Flip Direction",
description = "Change which way the geometry is extruded", description = "Change which way the geometry is extruded",
options = {'SKIP_SAVE', 'HIDDEN', 'SKIP_PRESET', }, options = {'SKIP_SAVE', 'HIDDEN'},
default = False, default = False,
) )
@@ -88,7 +107,7 @@ class CarverPropsModifier():
) )
pin: bpy.props.BoolProperty( pin: bpy.props.BoolProperty(
name = "Pin Boolean Modifier", 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)"), "NOTE: Order of modifiers can drastically affect the result (especially in destructive mode)"),
default = True, default = True,
) )
@@ -98,13 +117,13 @@ class CarverPropsCutter():
# CUTTER-properties # CUTTER-properties
hide: bpy.props.BoolProperty( hide: bpy.props.BoolProperty(
name = "Hide Cutter", 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, default = True,
) )
parent: bpy.props.BoolProperty( parent: bpy.props.BoolProperty(
name = "Parent to Canvas", name = "Parent to Canvas",
description = ("Cutters will be parented to active object being cut, even if cutting multiple objects.\n" description = ("Parent new cutters to active object (even if they are cutting multiple objects).\n"
"If there is no active object in selection cutters parent might be chosen seemingly randomly"), "If there is no active object in the selection, parent might be chosen randomly"),
default = True, default = True,
) )
display: bpy.props.EnumProperty( display: bpy.props.EnumProperty(
@@ -115,24 +134,29 @@ class CarverPropsCutter():
) )
cutter_origin: bpy.props.EnumProperty( cutter_origin: bpy.props.EnumProperty(
name = "Cutter Origin Point", name = "Cutter Origin Point",
items = (('CENTER_OBJ', "Bounding Box", "Put the object origin at the center of the cutters bounding box"), items = (('CENTER_OBJ', "Bounding Box",
('CENTER_MESH', "Geometry", "Put the object origin at the center of the cutters geometry (not including effects)"), "Put the object origin at the center of the cutters bounding box"),
('FACE_CENTER', "First Face", "Put the object origin at the center of cutters first face (i.e. shape)"), ('CENTER_MESH', "Geometry",
('MOUSE_INITIAL', "Mouse Click", "Put the object origin at the point where mouse was first clicked"), "Put the object origin at the center of the cutters geometry (not including effects)"),
('CANVAS', "Same as Canvas", "Put the object origin of the cutter to the origin point of the cutter")), ('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', default = 'CENTER_MESH',
) )
auto_smooth: bpy.props.BoolProperty( auto_smooth: bpy.props.BoolProperty(
name = "Shade Auto Smooth", name = "Shade Auto Smooth",
description = ("Cutter object will be shaded smooth with sharp edges (above specified degrees) marked as sharp\n" description = ("Add 'Smooth by Angle' modifier to cutter object"),
"NOTE: This is a one time operator. 'Smooth by Angle' modifier will not be added on cutter"),
default = True, default = True,
) )
sharp_angle: bpy.props.FloatProperty( sharp_angle: bpy.props.FloatProperty(
name = "Angle", name = "Angle",
description = "Maximum face angle for sharp edges", description = "Maximum face angle for sharp edges",
subtype = "ANGLE", subtype = "ANGLE",
precision = 3,
min = 0, max = math.pi, min = 0, max = math.pi,
default = 0.523599, default = 0.523599,
) )
@@ -142,13 +166,13 @@ class CarverPropsArray():
# ARRAY-properties # ARRAY-properties
rows: bpy.props.IntProperty( rows: bpy.props.IntProperty(
name = "Rows", name = "Rows",
description = "Number of times shape is duplicated horizontally", description = "Number of times the shape is duplicated horizontally",
min = 1, soft_max = 16, min = 1, soft_max = 16,
default = 1, default = 1,
) )
columns: bpy.props.IntProperty( columns: bpy.props.IntProperty(
name = "Columns", name = "Columns",
description = "Number of times shape is duplicated vertically", description = "Number of times the shape is duplicated vertically",
min = 1, soft_max = 16, min = 1, soft_max = 16,
default = 1, default = 1,
) )
@@ -9,13 +9,14 @@ from ...functions.mesh import (
) )
from ...functions.modifier import ( from ...functions.modifier import (
add_modifier_asset, add_modifier_asset,
update_modifier_input,
) )
#### ------------------------------ CLASSES ------------------------------ #### #### ------------------------------ CLASSES ------------------------------ ####
class Selection: 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): def __init__(self, selected, active):
self.selected: list = selected self.selected: list = selected
@@ -24,10 +25,7 @@ class Selection:
class Mouse: class Mouse:
""" """Mouse positions throughout different phases of the modal operator."""
Mouse positions throughout different phases of the modal operator.
Each class variable is a 2D vector in screen space (x, y).
"""
def __init__(self): def __init__(self):
self.initial = Vector() self.initial = Vector()
@@ -99,17 +97,17 @@ class Effects:
return self return self
def update(self, cls, effect): def update(self, cls, effect):
"""Update bevel modifier during modal.""" """Update cutter modifiers during modal."""
# Update array count. # Update array count.
if effect == 'ARRAY_COUNT': if effect == "ARRAY_COUNT":
if self.array is None: if self.array is None:
self.add_array_modifier(cls) self.add_array_modifier(cls)
else: else:
if cls.columns > 1 or cls.rows > 1: if cls.columns > 1 or cls.rows > 1:
self.array["Socket_2"] = cls.columns update_modifier_input(self.array, "Socket_2", cls.columns)
self.array["Socket_3"] = cls.rows update_modifier_input(self.array, "Socket_3", cls.rows)
# Remove modifier if it's no longer needed. # Remove modifier if it's no longer needed.
if cls.columns == 1 and cls.rows == 1: if cls.columns == 1 and cls.rows == 1:
@@ -117,12 +115,12 @@ class Effects:
self.array = None self.array = None
# Update array gap. # Update array gap.
if effect == 'ARRAY_GAP': if effect == "ARRAY_GAP":
if cls.columns > 1 or cls.row > 1: if cls.columns > 1 or cls.rows > 1:
if self.array is not None: 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 = False
self.array.show_viewport = True self.array.show_viewport = True
@@ -150,15 +148,16 @@ class Effects:
# Columns # Columns
if cls.columns > 1: if cls.columns > 1:
mod["Socket_2"] = cls.columns update_modifier_input(mod, "Socket_2", cls.columns)
# Rows # Rows
if cls.rows > 1: if cls.rows > 1:
mod["Socket_3"] = cls.rows update_modifier_input(mod, "Socket_3", cls.rows)
# Gap # Gap
mod["Socket_4"] = cls.gap update_modifier_input(mod, "Socket_4", cls.gap)
mod.use_pin_to_last = True
self.array = mod self.array = mod
@@ -233,7 +232,7 @@ class Effects:
# Smooth by Angle # Smooth by Angle
def add_auto_smooth_modifier(self, cls, context): 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 obj = cls.cutter.obj
mesh = cls.cutter.mesh mesh = cls.cutter.mesh
@@ -262,7 +261,7 @@ class Effects:
mod = obj.modifiers.active 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: if mod is None:
dir = os.path.join(os.path.dirname(bpy.app.binary_path), "5.0", "datafiles", "assets") dir = os.path.join(os.path.dirname(bpy.app.binary_path), "5.0", "datafiles", "assets")
assets_path = os.path.join(dir, modifier_asset_file) 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") print("Destructively marking sharp edges and smooth faces in the mesh")
shade_smooth_by_angle(bm, mesh, angle=math.degrees(cls.sharp_angle)) shade_smooth_by_angle(bm, mesh, angle=math.degrees(cls.sharp_angle))
else: else:
# Set smoothing angle. # Set smoothing angle (necessary for all methods except `shade_auto_smooth()`).
for face in bm.faces: for face in bm.faces:
face.smooth = True face.smooth = True
bm.to_mesh(mesh) bm.to_mesh(mesh)
mod.use_pin_to_last = True mod.use_pin_to_last = True
mod["Input_1"] = cls.sharp_angle update_modifier_input(mod, "Input_1", cls.sharp_angle)
self.smooth = mod self.smooth = mod
@@ -1,5 +1,4 @@
import bpy import bpy
from ... import __package__ as base_package
#### ------------------------------ /toolbar/ ------------------------------ #### #### ------------------------------ /toolbar/ ------------------------------ ####
@@ -14,7 +13,7 @@ def carver_ui_common(context, layout, props):
layout.prop(props, "solver", expand=True) layout.prop(props, "solver", expand=True)
else: else:
# Use labels for Properties editor/sidebar. # NOTE: Use labels for Properties editor/sidebar.
layout.prop(props, "mode", text="Mode") layout.prop(props, "mode", text="Mode")
layout.prop(props, "alignment", text="Alignment") layout.prop(props, "alignment", text="Alignment")
layout.prop(props, "depth", text="Depth") 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_split = True
layout.use_property_decorate = False 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 # Box & Circle
if tool.idname == "object.carve_box" or tool.idname == "object.carve_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": if tool.idname == "object.carve_circle":
layout.prop(props, "subdivision", text="Vertices") layout.prop(props, "subdivision", text="Vertices")
layout.prop(props, "rotation") layout.prop(props, "rotation")
layout.prop(props, "aspect", expand=True)
layout.prop(props, "origin", expand=True) layout.prop(props, "origin", expand=True)
layout.prop(props, "aspect", expand=True)
if props.alignment == 'SURFACE': if props.alignment == 'SURFACE':
layout.prop(props, "orientation") layout.prop(props, "orientation")
@@ -67,7 +63,6 @@ class TOPBAR_PT_carver_shape(bpy.types.Panel):
# Polyline # Polyline
elif tool.idname == "object.carve_polyline": elif tool.idname == "object.carve_polyline":
props = tool.operator_properties("object.carve_polyline")
if props.alignment == 'SURFACE': if props.alignment == 'SURFACE':
layout.prop(props, "offset", text="Offset") layout.prop(props, "offset", text="Offset")
layout.prop(props, "align_to_all") 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_split = True
layout.use_property_decorate = False 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'
if tool.idname == "object.carve_box": tool = context.workspace.tools.from_space_view3d_mode(mode)
props = tool.operator_properties("object.carve_box") props = tool.operator_properties(tool.idname)
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")
# Bevel # Bevel
if tool.idname == 'object.carve_box': 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, "rows")
col.prop(props, "gap") col.prop(props, "gap")
class TOPBAR_PT_carver_cutter(bpy.types.Panel): class TOPBAR_PT_carver_cutter(bpy.types.Panel):
bl_label = "Carver Cutter" bl_label = "Carver Cutter"
bl_idname = "TOPBAR_PT_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_split = True
layout.use_property_decorate = False 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'
if tool.idname == "object.carve_box": tool = context.workspace.tools.from_space_view3d_mode(mode)
props = tool.operator_properties("object.carve_box") props = tool.operator_properties(tool.idname)
elif tool.idname == "object.carve_circle":
props = tool.operator_properties("object.carve_circle")
elif tool.idname == "object.carve_polyline":
props = tool.operator_properties("object.carve_polyline")
# modifier_&_cutter # Modifier properties.
col = layout.column()
col.prop(props, "pin", text="Pin Modifier")
col.separator()
# Cutter object properties.
col = layout.column() col = layout.column()
row = col.row() row = col.row()
row.prop(props, "display", text="Display", expand=True) row.prop(props, "display", text="Display", expand=True)
col.prop(props, "pin", text="Pin Modifier")
if props.mode == 'MODIFIER': if props.mode == 'MODIFIER':
col.prop(props, "parent") col.prop(props, "parent")
col.prop(props, "hide") col.prop(props, "hide")
col.prop(props, "cutter_origin", text="Origin") col.prop(props, "cutter_origin", text="Origin")
# auto_smooth # Auto Smooth.
layout.separator() layout.separator()
col = layout.column(align=True) col = layout.column(align=True)
col.prop(props, "auto_smooth", text="Auto Smooth") col.prop(props, "auto_smooth", text="Auto Smooth")
@@ -159,11 +151,11 @@ class TOPBAR_PT_carver_cutter(bpy.types.Panel):
#### ------------------------------ REGISTRATION ------------------------------ #### #### ------------------------------ REGISTRATION ------------------------------ ####
classes = [ classes = (
TOPBAR_PT_carver_shape, TOPBAR_PT_carver_shape,
TOPBAR_PT_carver_effects, TOPBAR_PT_carver_effects,
TOPBAR_PT_carver_cutter, TOPBAR_PT_carver_cutter,
] )
def register(): def register():
for cls in classes: 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 # 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 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(): def register():
# register modules # register modules
for m in modules: for m in modules:
m.register() importlib.reload(m)
if hasattr(m, 'register'):
m.register()
# read addon meta-data # read addon meta-data
with open(f"{utils.get_addon_directory()}/blender_manifest.toml", 'rb') as f: with open(f"{utils.get_addon_directory()}/blender_manifest.toml", 'rb') as f:
@@ -26,7 +29,8 @@ def register():
def unregister(): def unregister():
# un-register modules # un-register modules
for m in reversed(modules): for m in reversed(modules):
m.unregister() if hasattr(m, 'unregister'):
m.unregister()
# Remove addon asset library # Remove addon asset library
utils.unregister_asset_lib() utils.unregister_asset_lib()
@@ -1,7 +1,7 @@
schema_version = "1.0.0" schema_version = "1.0.0"
id = "brushstroke_tools" id = "brushstroke_tools"
version = "1.2.1" version = "1.2.3"
name = "Brushstroke Tools" name = "Brushstroke Tools"
tagline = "Brushstroke painting tools by the Blender Studio" tagline = "Brushstroke painting tools by the Blender Studio"
maintainer = "Simon Thommes <simon@blender.org>" 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): if bpy.app.version >= (5,1):
utils.ensure_resources() utils.ensure_resources()
ng_process = bpy.data.node_groups['.brushstroke_tools.draw_processing'] 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.is_tool = True # workaround to let Blender register the node tool as an operator with the automatically generated id
ng_process.asset_clear()
return {'FINISHED'} return {'FINISHED'}
class BSBST_OT_post_process_brushstroke(bpy.types.Operator): 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 bpy
import random import random
from . import utils, settings from . import utils, settings, geomod
import mathutils import mathutils
class BSBST_OT_new_brushstrokes(bpy.types.Operator): 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) # 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) utils.link_to_collections_by_ref(flow_object, surface_object, unlink=False)
visibility_options = [ visibility_options = [
'visible_camera', 'visible_camera',
'visible_diffuse', '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') utils.mark_socket_context_type(mod_info, 'Socket_2', 'SURFACE_OBJECT')
mod['Socket_2'] = surface_object geomod.set_value(mod, 'Socket_2', surface_object)
mod['Socket_3'] = False geomod.set_value(mod, 'Socket_3', False)
utils.set_deformable(flow_object, settings.deforming_surface) utils.set_deformable(flow_object, settings.deforming_surface)
utils.set_animated(flow_object, settings.animated) utils.set_animated(flow_object, settings.animated)
@@ -94,7 +94,7 @@ class BSBST_OT_new_brushstrokes(bpy.types.Operator):
def main(self, context): def main(self, context):
settings = context.scene.BSBST_settings settings = context.scene.BSBST_settings
utils.ensure_resources() utils.ensure_resources()
if not context.mode == 'OBJECT': if not context.mode == 'OBJECT':
bpy.ops.object.mode_set(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: if not settings.preset_object:
bpy.ops.brushstroke_tools.init_preset() bpy.ops.brushstroke_tools.init_preset()
# assign preset material # assign preset material
preset_material = getattr(settings.preset_object, '["BSBST_material"]', None) 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 # transfer preset modifiers to new brushstrokes TODO: refactor to deduplicate
for mod in settings.preset_object.modifiers: for mod in settings.preset_object.modifiers:
utils.transfer_modifier(mod.name, brushstrokes_object, settings.preset_object) utils.transfer_modifier(mod.name, brushstrokes_object, settings.preset_object)
for v in mod.node_group.interface.items_tree.values(): for v in mod.node_group.interface.items_tree.values():
if type(v) not in utils.linkable_sockets: if type(v) not in utils.linkable_sockets:
continue continue
@@ -161,25 +161,26 @@ class BSBST_OT_new_brushstrokes(bpy.types.Operator):
continue continue
# initialize linked context parameters # initialize linked context parameters
link_context_type = settings.preset_object.modifier_info[mod.name].socket_info[v.identifier].link_context_type 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': 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': 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': 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': elif link_context_type=='UVMAP':
if type(brushstrokes_object.modifiers[mod.name][f'{v.identifier}']) == str: if isinstance(geomod.get_value(bs_mod, v.identifier), str):
brushstrokes_object.modifiers[mod.name][f'{v.identifier}'] = surface_object.data.uv_layers.active.name geomod.set_value(bs_mod, v.identifier, surface_object.data.uv_layers.active.name)
else: else:
brushstrokes_object.modifiers[mod.name][f'{v.identifier}_use_attribute'] = True geomod.set_input_is_attribute(bs_mod, v.identifier, True)
brushstrokes_object.modifiers[mod.name][f'{v.identifier}_attribute_name'] = surface_object.data.uv_layers.active.name geomod.set_attribute_name(bs_mod, v.identifier, surface_object.data.uv_layers.active.name)
elif link_context_type=='RANDOM': elif link_context_type=='RANDOM':
vmin = v.min_value vmin = v.min_value
vmax = v.max_value vmax = v.max_value
val = vmin + random.random() * (vmax - vmin) val = vmin + random.random() * (vmax - vmin)
brushstrokes_object.modifiers[mod.name][f'{v.identifier}_use_attribute'] = False geomod.set_input_is_attribute(bs_mod, v.identifier, False)
brushstrokes_object.modifiers[mod.name][f'{v.identifier}'] = type(brushstrokes_object.modifiers[mod.name][f'{v.identifier}'])(val) 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 # transfer modifier info data from preset to brush strokes
utils.deep_copy_mod_info(settings.preset_object, brushstrokes_object) 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'] mod = brushstrokes_object.modifiers['Brushstrokes']
if settings.brushstroke_method == 'SURFACE_FILL': if settings.brushstroke_method == 'SURFACE_FILL':
# set density # 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 # 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 # 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': elif settings.brushstroke_method == 'SURFACE_DRAW':
# set width # 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 # refresh UI
for mod in brushstrokes_object.modifiers: 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(): for v in mod.node_group.interface.items_tree.values():
if type(v) != bpy.types.NodeTreeInterfaceSocketMaterial: if type(v) != bpy.types.NodeTreeInterfaceSocketMaterial:
continue continue
mat = mod[v.identifier] mat = geomod.get_value(mod, v.identifier)
if not mat: if not mat:
continue continue
if mat in [m_slot.material for m_slot in brushstrokes_object.material_slots]: 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): with context.temp_override(**override):
bpy.ops.object.material_slot_add() bpy.ops.object.material_slot_add()
brushstrokes_object.material_slots[-1].material = mat brushstrokes_object.material_slots[-1].material = mat
# set deformable # set deformable
set_brushstrokes_deformable(brushstrokes_object, settings.deforming_surface) set_brushstrokes_deformable(brushstrokes_object, settings.deforming_surface)
# set animated # set animated
set_brushstrokes_animated(brushstrokes_object, settings.animated) set_brushstrokes_animated(brushstrokes_object, settings.animated)
for mod in brushstrokes_object.modifiers: for mod in brushstrokes_object.modifiers:
mod.show_group_selector = False mod.show_group_selector = False
@@ -239,7 +240,7 @@ class BSBST_OT_new_brushstrokes(bpy.types.Operator):
if name == brushstrokes_object.name: if name == brushstrokes_object.name:
settings.active_context_brushstrokes_index = i settings.active_context_brushstrokes_index = i
break break
utils.edit_active_brushstrokes(context) utils.edit_active_brushstrokes(context)
return {"FINISHED"} return {"FINISHED"}
@@ -247,10 +248,10 @@ class BSBST_OT_new_brushstrokes(bpy.types.Operator):
settings = context.scene.BSBST_settings settings = context.scene.BSBST_settings
settings.silent_switch = True settings.silent_switch = True
state = self.main(context) state = self.main(context)
settings.silent_switch = False settings.silent_switch = False
return state return state
class BSBST_OT_edit_brushstrokes(bpy.types.Operator): class BSBST_OT_edit_brushstrokes(bpy.types.Operator):
""" """
Enter the editing context for the active context brushstrokes. 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 = context.scene.BSBST_settings
settings.silent_switch = True settings.silent_switch = True
state = utils.edit_active_brushstrokes(context) state = utils.edit_active_brushstrokes(context)
settings.silent_switch = False settings.silent_switch = False
return state return state
class BSBST_OT_delete_brushstrokes(bpy.types.Operator): class BSBST_OT_delete_brushstrokes(bpy.types.Operator):
""" """
Delete the active context brushstrokes Delete the active context brushstrokes
@@ -324,7 +325,7 @@ class BSBST_OT_delete_brushstrokes(bpy.types.Operator):
settings.edit_toggle = edit_toggle settings.edit_toggle = edit_toggle
return {'FINISHED'} return {'FINISHED'}
class BSBST_OT_duplicate_brushstrokes(bpy.types.Operator): class BSBST_OT_duplicate_brushstrokes(bpy.types.Operator):
""" """
Duplicate the active context brushstrokes 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) bs_ob = utils.get_active_context_brushstrokes_object(context.scene)
if not bs_ob: if not bs_ob:
return {"CANCELLED"} return {"CANCELLED"}
if not bs_ob.visible_get(view_layer=context.view_layer): 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") self.report({"WARNING"}, f"Skipped Brushstroke layer '{bs_ob.name}' because it is invisible in this context")
return {"CANCELLED"} return {"CANCELLED"}
@@ -354,7 +355,7 @@ class BSBST_OT_duplicate_brushstrokes(bpy.types.Operator):
if context.mode != 'OBJECT': if context.mode != 'OBJECT':
bpy.ops.object.mode_set(mode='OBJECT') bpy.ops.object.mode_set(mode='OBJECT')
bpy.context.view_layer.objects.active = bs_ob bpy.context.view_layer.objects.active = bs_ob
if context.mode != 'OBJECT': if context.mode != 'OBJECT':
bpy.ops.object.mode_set(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': if not mod.type=='NODES':
continue continue
if not mod.node_group: if not mod.node_group:
continue continue
for v in mod.node_group.interface.items_tree.values(): for v in mod.node_group.interface.items_tree.values():
if type(v) not in utils.linkable_sockets: if type(v) not in utils.linkable_sockets:
continue continue
@@ -383,7 +384,7 @@ class BSBST_OT_duplicate_brushstrokes(bpy.types.Operator):
vmin = v.min_value vmin = v.min_value
vmax = v.max_value vmax = v.max_value
val = vmin + random.random() * (vmax - vmin) 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'} return {'FINISHED'}
@@ -459,7 +460,7 @@ class BSBST_OT_copy_brushstrokes(bpy.types.Operator):
ob.parent = surface_object ob.parent = surface_object
utils.set_surface_object(ob, surface_object) utils.set_surface_object(ob, surface_object)
for mod in ob.modifiers: for mod in ob.modifiers:
if not mod.type: if not mod.type:
continue continue
@@ -475,27 +476,28 @@ class BSBST_OT_copy_brushstrokes(bpy.types.Operator):
continue continue
# re-initialize linked context parameters # re-initialize linked context parameters
link_context_type = ob.modifier_info[mod.name].socket_info[v.identifier].link_context_type 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': 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': elif link_context_type=='UVMAP':
if type(ob.modifiers[mod.name][f'{v.identifier}']) == str: if isinstance(geomod.get_value(ob_mod, v.identifier), str):
ob.modifiers[mod.name][f'{v.identifier}'] = surface_object.data.uv_layers.active.name geomod.set_value(ob_mod, v.identifier, surface_object.data.uv_layers.active.name)
else: else:
ob.modifiers[mod.name][f'{v.identifier}_use_attribute'] = True geomod.set_input_is_attribute(ob_mod, v.identifier, True)
ob.modifiers[mod.name][f'{v.identifier}_attribute_name'] = surface_object.data.uv_layers.active.name geomod.set_attribute_name(ob_mod, v.identifier, surface_object.data.uv_layers.active.name)
elif link_context_type=='RANDOM': elif link_context_type=='RANDOM':
vmin = v.min_value vmin = v.min_value
vmax = v.max_value vmax = v.max_value
val = vmin + random.random() * (vmax - vmin) val = vmin + random.random() * (vmax - vmin)
ob.modifiers[mod.name][f'{v.identifier}_use_attribute'] = False geomod.set_input_is_attribute(ob_mod, v.identifier, False)
ob.modifiers[mod.name][f'{v.identifier}'] = type(ob.modifiers[mod.name][f'{v.identifier}'])(val) geomod.set_value(ob_mod, v.identifier, type(geomod.get_value(ob_mod, v.identifier))(val))
# enable rest position # enable rest position
surface_object.add_rest_position_attribute = True surface_object.add_rest_position_attribute = True
return {'FINISHED'} return {'FINISHED'}
class BSBST_OT_select_surface(bpy.types.Operator): class BSBST_OT_select_surface(bpy.types.Operator):
""" """
Select the surface object for the active context brushstrokes. 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) bs_ob = utils.get_active_context_brushstrokes_object(context.scene)
if not bs_ob: if not bs_ob:
return {"CANCELLED"} return {"CANCELLED"}
surface_object = bpy.data.objects.get(self.surface_object) surface_object = bpy.data.objects.get(self.surface_object)
if not surface_object: if not surface_object:
@@ -577,11 +579,11 @@ def set_brushstrokes_deformable(bs_ob, deformable):
if not mod.node_group: if not mod.node_group:
continue continue
if mod.node_group.name == '.brushstroke_tools.pre_processing': 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': 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': 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) mod.node_group.interface_update(bpy.context)
utils.set_deformable(bs_ob, deformable) utils.set_deformable(bs_ob, deformable)
@@ -601,7 +603,7 @@ def set_brushstrokes_animated(bs_ob, animated):
if not mod: if not mod:
mod = ob.modifiers.new('Animation', 'NODES') mod = ob.modifiers.new('Animation', 'NODES')
mod.node_group = utils.ensure_node_group('.brushstroke_tools.animation') mod.node_group = utils.ensure_node_group('.brushstroke_tools.animation')
mod_info = ob.modifier_info.get(mod.name) mod_info = ob.modifier_info.get(mod.name)
if not mod_info: if not mod_info:
mod_info = ob.modifier_info.add() mod_info = ob.modifier_info.add()
@@ -610,7 +612,7 @@ def set_brushstrokes_animated(bs_ob, animated):
# ui visibility settings # ui visibility settings
mod_info.hide_ui = True mod_info.hide_ui = True
else: else:
mod['Socket_5'] = True geomod.set_value(mod, 'Socket_5', True)
mod.node_group.interface_update(bpy.context) mod.node_group.interface_update(bpy.context)
with bpy.context.temp_override(object=ob): with bpy.context.temp_override(object=ob):
bpy.ops.object.modifier_move_to_index(modifier=mod.name, index=0) 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") source_bs: bpy.props.StringProperty(name="Brushstroke Layers")
bs_list: bpy.props.CollectionProperty(type=settings.BSBST_context_brushstrokes) bs_list: bpy.props.CollectionProperty(type=settings.BSBST_context_brushstrokes)
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):
settings = context.scene.BSBST_settings 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) flow_ob_old = utils.get_flow_object(bs_ob)
if not bs_ob: if not bs_ob:
return {"CANCELLED"} return {"CANCELLED"}
# get source # get source
source_ob = bpy.data.objects.get(self.source_bs) 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': if ob.type == 'GREASEPENCIL':
self.report({"WARNING"}, "Grease Pencil does not currently support drawing on deformable surface geometry.") self.report({"WARNING"}, "Grease Pencil does not currently support drawing on deformable surface geometry.")
set_brushstrokes_deformable(ob, self.deformable) set_brushstrokes_deformable(ob, self.deformable)
context.view_layer.depsgraph.update() context.view_layer.depsgraph.update()
return {"FINISHED"} return {"FINISHED"}
@@ -775,11 +777,11 @@ class BSBST_OT_switch_animated(bpy.types.Operator):
for ob in bs_objects: for ob in bs_objects:
set_brushstrokes_animated(ob, self.animated) set_brushstrokes_animated(ob, self.animated)
context.view_layer.depsgraph.update() context.view_layer.depsgraph.update()
return {"FINISHED"} return {"FINISHED"}
class BSBST_OT_init_preset(bpy.types.Operator): class BSBST_OT_init_preset(bpy.types.Operator):
""" """
Initialize the preset to define a modifier stack applied to new brushstrokess. 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): def poll(cls, context):
settings = context.scene.BSBST_settings settings = context.scene.BSBST_settings
return settings.preset_object is None return settings.preset_object is None
def init_fill(self, context): def init_fill(self, context):
settings = context.scene.BSBST_settings settings = context.scene.BSBST_settings
@@ -803,7 +805,7 @@ class BSBST_OT_init_preset(bpy.types.Operator):
## input ## input
mod = preset_object.modifiers.new('Surface Input', 'NODES') mod = preset_object.modifiers.new('Surface Input', 'NODES')
mod.node_group = bpy.data.node_groups['.brushstroke_tools.geometry_input'] mod.node_group = bpy.data.node_groups['.brushstroke_tools.geometry_input']
mod_info = settings.preset_object.modifier_info.get(mod.name) mod_info = settings.preset_object.modifier_info.get(mod.name)
if not mod_info: if not mod_info:
mod_info = settings.preset_object.modifier_info.add() mod_info = settings.preset_object.modifier_info.add()
@@ -820,7 +822,7 @@ class BSBST_OT_init_preset(bpy.types.Operator):
## masking ## masking
mod = preset_object.modifiers.new('Masking', 'NODES') mod = preset_object.modifiers.new('Masking', 'NODES')
mod.node_group = bpy.data.node_groups['.brushstroke_tools.mask_surface'] mod.node_group = bpy.data.node_groups['.brushstroke_tools.mask_surface']
mod_info = settings.preset_object.modifier_info.get(mod.name) mod_info = settings.preset_object.modifier_info.get(mod.name)
if not mod_info: if not mod_info:
mod_info = settings.preset_object.modifier_info.add() mod_info = settings.preset_object.modifier_info.add()
@@ -838,7 +840,7 @@ class BSBST_OT_init_preset(bpy.types.Operator):
## brushstrokes ## brushstrokes
mod = preset_object.modifiers.new('Brushstrokes', 'NODES') mod = preset_object.modifiers.new('Brushstrokes', 'NODES')
mod.node_group = bpy.data.node_groups['.brushstroke_tools.surface_fill'] mod.node_group = bpy.data.node_groups['.brushstroke_tools.surface_fill']
mod_info = settings.preset_object.modifier_info.get(mod.name) mod_info = settings.preset_object.modifier_info.get(mod.name)
if not mod_info: if not mod_info:
mod_info = settings.preset_object.modifier_info.add() mod_info = settings.preset_object.modifier_info.add()
@@ -846,7 +848,7 @@ class BSBST_OT_init_preset(bpy.types.Operator):
# set fill custom defaults # set fill custom defaults
mod['Socket_59'] = 1 # color method: curves geomod.set_value(mod, 'Socket_59', 'Curves') # color method: curves
# context link settings # context link settings
utils.mark_socket_context_type(mod_info, 'Socket_2', 'FLOW_OBJECT') 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: for p in hide_panels:
utils.mark_panel_hidden(mod_info, p) utils.mark_panel_hidden(mod_info, p)
def init_draw(self, context): def init_draw(self, context):
settings = context.scene.BSBST_settings settings = context.scene.BSBST_settings
@@ -889,7 +891,7 @@ class BSBST_OT_init_preset(bpy.types.Operator):
## add pre-processing modifier ## add pre-processing modifier
mod = preset_object.modifiers.new('Pre-Processing', 'NODES') mod = preset_object.modifiers.new('Pre-Processing', 'NODES')
mod.node_group = bpy.data.node_groups['.brushstroke_tools.pre_processing'] mod.node_group = bpy.data.node_groups['.brushstroke_tools.pre_processing']
mod_info = settings.preset_object.modifier_info.get(mod.name) mod_info = settings.preset_object.modifier_info.get(mod.name)
if not mod_info: if not mod_info:
mod_info = settings.preset_object.modifier_info.add() mod_info = settings.preset_object.modifier_info.add()
@@ -903,7 +905,7 @@ class BSBST_OT_init_preset(bpy.types.Operator):
## brushstrokes ## brushstrokes
mod = preset_object.modifiers.new('Brushstrokes', 'NODES') mod = preset_object.modifiers.new('Brushstrokes', 'NODES')
mod.node_group = bpy.data.node_groups['.brushstroke_tools.surface_draw'] mod.node_group = bpy.data.node_groups['.brushstroke_tools.surface_draw']
mod_info = settings.preset_object.modifier_info.get(mod.name) mod_info = settings.preset_object.modifier_info.get(mod.name)
if not mod_info: if not mod_info:
mod_info = settings.preset_object.modifier_info.add() mod_info = settings.preset_object.modifier_info.add()
@@ -937,7 +939,7 @@ class BSBST_OT_init_preset(bpy.types.Operator):
def execute(self, context): def execute(self, context):
settings = context.scene.BSBST_settings settings = context.scene.BSBST_settings
utils.ensure_resources() utils.ensure_resources()
preset_name = f'BSBST-PRESET_{settings.brushstroke_method}' preset_name = f'BSBST-PRESET_{settings.brushstroke_method}'
preset_object = bpy.data.objects.new(preset_name, bpy.data.hair_curves.new(preset_name)) 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) self.init_fill(context)
elif settings.brushstroke_method == "SURFACE_DRAW": elif settings.brushstroke_method == "SURFACE_DRAW":
self.init_draw(context) self.init_draw(context)
# select preset material # select preset material
mat = bpy.data.materials.get('Brush Material') mat = bpy.data.materials.get('Brush Material')
if not mat: if not mat:
@@ -982,7 +984,7 @@ class BSBST_OT_make_preset(bpy.types.Operator):
else: else:
for mod in settings.preset_object.modifiers[:]: for mod in settings.preset_object.modifiers[:]:
settings.preset_object.modifiers.remove(mod) settings.preset_object.modifiers.remove(mod)
# transfer brushstrokes modifiers to preset # transfer brushstrokes modifiers to preset
bs_ob = utils.get_active_context_brushstrokes_object(context.scene) bs_ob = utils.get_active_context_brushstrokes_object(context.scene)
if not bs_ob: if not bs_ob:
@@ -994,7 +996,7 @@ class BSBST_OT_make_preset(bpy.types.Operator):
for mod in settings.preset_object.modifiers: for mod in settings.preset_object.modifiers:
# refresh UI # refresh UI
mod.node_group.interface_update(context) mod.node_group.interface_update(context)
# identify linked sockets # identify linked sockets
for v in mod.node_group.interface.items_tree.values(): for v in mod.node_group.interface.items_tree.values():
if type(v) not in utils.linkable_sockets: 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]: if not settings.preset_object.modifier_info[mod.name].socket_info[v.identifier]:
continue continue
if type(v) == bpy.types.NodeTreeInterfaceSocketObject: if type(v) == bpy.types.NodeTreeInterfaceSocketObject:
if bs_ob['BSBST_surface_object']==mod[v.identifier]: if bs_ob['BSBST_surface_object']==geomod.get_value(mod, v.identifier):
mod[v.identifier] = None geomod.set_value(mod, v.identifier, None)
settings.preset_object.modifier_info[mod.name].socket_info[v.identifier].link_context = True settings.preset_object.modifier_info[mod.name].socket_info[v.identifier].link_context = True
elif type(v) == bpy.types.NodeTreeInterfaceSocketMaterial: elif type(v) == bpy.types.NodeTreeInterfaceSocketMaterial:
pass # TODO: figure out material preset linking pass # TODO: figure out material preset linking
@@ -1109,14 +1111,14 @@ class BSBST_OT_brushstrokes_toggle_attribute(bpy.types.Operator):
if not bs_ob: if not bs_ob:
settings.edit_toggle = edit_toggle settings.edit_toggle = edit_toggle
return {"CANCELLED"} return {"CANCELLED"}
override = context.copy() override = context.copy()
override['object'] = bs_ob override['object'] = bs_ob
with context.temp_override(**override): with context.temp_override(**override):
bpy.ops.object.geometry_nodes_input_attribute_toggle(input_name=self.input_name, bpy.ops.object.geometry_nodes_input_attribute_toggle(input_name=self.input_name,
modifier_name=self.modifier_name) modifier_name=self.modifier_name)
return {"FINISHED"} return {"FINISHED"}
class BSBST_OT_render_setup(bpy.types.Operator): class BSBST_OT_render_setup(bpy.types.Operator):
""" """
Set up render settings. Set up render settings.
@@ -1165,7 +1167,7 @@ class BSBST_OT_render_setup(bpy.types.Operator):
def invoke(self, context, event): def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self) return context.window_manager.invoke_props_dialog(self)
class BSBST_OT_view_all(bpy.types.Operator): class BSBST_OT_view_all(bpy.types.Operator):
""" """
Enable/disable all brushstrokes for the viewport. 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: if bs.name == settings.context_material.brush_style:
self.brush_styles_filtered_active_index = i self.brush_styles_filtered_active_index = i
break break
self.name_filter = '' self.name_filter = ''
return context.window_manager.invoke_props_dialog(self, width=450) return context.window_manager.invoke_props_dialog(self, width=450)
class BSBST_OT_upgrade_resources(bpy.types.Operator): class BSBST_OT_upgrade_resources(bpy.types.Operator):
""" Upgrade all local BST assets to available addon resources. """ Upgrade all local BST assets to available addon resources.
""" """
@@ -1331,12 +1333,12 @@ class BSBST_OT_upgrade_resources(bpy.types.Operator):
layout = self.layout layout = self.layout
split = layout.split(factor=.6) split = layout.split(factor=.6)
col = split.column() col = split.column()
col.prop(self, 'upgrade_shape_modifiers', icon='MODIFIER') col.prop(self, 'upgrade_shape_modifiers', icon='MODIFIER')
col.prop(self, 'upgrade_materials', icon='MATERIAL') col.prop(self, 'upgrade_materials', icon='MATERIAL')
col.prop(self, 'upgrade_brush_styles', icon='BRUSHES_ALL') col.prop(self, 'upgrade_brush_styles', icon='BRUSHES_ALL')
col = split.column() col = split.column()
row = col.row() row = col.row()
row.active = self.upgrade_shape_modifiers row.active = self.upgrade_shape_modifiers
@@ -1422,8 +1424,8 @@ classes = [
def register(): def register():
for c in classes: for c in classes:
bpy.utils.register_class(c) bpy.utils.register_class(c)
def unregister(): def unregister():
for c in reversed(classes): 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 # SPDX-License-Identifier: GPL-3.0-or-later
import bpy import bpy
from . import utils from . import utils, geomod
from . import settings as settings_py from . import settings as settings_py
from bpy.types import (
UILayout,
NodesModifier,
NodeTreeInterfaceItem,
NodeTreeInterfacePanel,
NodeTreeInterfaceSocketMenu,
)
warning_icons_dict = { warning_icons_dict = {
'ERROR': 'CANCEL', 'ERROR': 'CANCEL',
@@ -12,133 +19,92 @@ warning_icons_dict = {
'INFO': 'INFO', 'INFO': 'INFO',
} }
def draw_panel_ui_recursive(panel, panel_name, mod, items, display_mode, hide_panel=False): def draw_panel_ui_recursive(
panel: UILayout | None,
scene = bpy.context.scene panel_name: str,
settings = scene.BSBST_settings mod: NodesModifier,
interface_items: tuple[str, NodeTreeInterfaceItem],
is_preset = mod.id_data == settings.preset_object and mod.id_data display_mode: int,
hide_panel: bool = False,
):
if not panel: if not panel:
return return
mod_info = mod.id_data.modifier_info.get(mod.name) mod_info = mod.id_data.modifier_info.get(mod.name)
if not mod_info:
icon_dict = { return
bpy.types.NodeTreeInterfaceSocketObject: 'OBJECT_DATA',
bpy.types.NodeTreeInterfaceSocketMaterial: 'MATERIAL',
bpy.types.NodeTreeInterfaceSocketImage: 'IMAGE_DATA',
bpy.types.NodeTreeInterfaceSocketCollection: 'OUTLINER_COLLECTION',
}
data_dict = { panel_active = not (mod_info.hide_ui or hide_panel)
bpy.types.NodeTreeInterfaceSocketMaterial: 'materials', inactive_mode_names: list[str] = []
bpy.types.NodeTreeInterfaceSocketImage: 'images',
bpy.types.NodeTreeInterfaceSocketCollection: 'collections',
}
mode_compare = [] for name, interface_item in interface_items:
for k, v in items: if interface_item.parent.name != panel_name:
if not v.parent.name == panel_name:
continue 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 isinstance(interface_item, NodeTreeInterfacePanel):
panel_id = f'Panel_{interface_item.index}' # TODO: replace with panel identifier once exposed in Blender 4.3
if not mod_info: socket_info = mod_info.socket_info.get(panel_id)
if not socket_info:
continue continue
s = mod_info.socket_info.get(v_id) if display_mode == 0 and socket_info.hide_ui:
if not s:
continue continue
if display_mode == 0:
if s.hide_ui:
continue
subpanel_header, subpanel = panel.panel(k, default_closed = v.default_closed) subpanel_header, subpanel = panel.panel(name, default_closed=interface_item.default_closed)
subpanel_header.label(text=k) subpanel_header.label(text=name)
if display_mode != 0: if display_mode != 0:
col = subpanel_header.column() col = subpanel_header.column()
col.active = not (mod_info.hide_ui or hide_panel) col.active = panel_active
col.prop(s, 'hide_ui', icon_only=True, icon='UNPINNED' if s.hide_ui else 'PINNED', emboss=False) col.prop(socket_info, 'hide_ui', icon_only=True,
draw_panel_ui_recursive(subpanel, k, mod, v.interface_items.items(), display_mode, s.hide_ui) icon='UNPINNED' if socket_info.hide_ui else 'PINNED', emboss=False)
mode_compare = [] draw_panel_ui_recursive(subpanel, name, mod, interface_item.interface_items.items(), display_mode, socket_info.hide_ui)
inactive_mode_names = []
else: else:
if v.parent.name != panel_name: if not geomod.is_input_value_settable(mod, interface_item.identifier):
continue continue
if f'{v.identifier}' not in mod.keys(): socket_info = mod_info.socket_info.get(interface_item.identifier)
continue if not socket_info:
if not mod_info:
continue continue
if type(v) == bpy.types.NodeTreeInterfaceSocketMenu: if isinstance(interface_item, NodeTreeInterfaceSocketMenu):
for item in mod.id_properties_ui(f'{v.identifier}').as_dict()['items']: current_value = geomod.get_value(mod, interface_item.identifier)
if item[4] == mod[f'{v.identifier}']: inactive_mode_names += [
continue mode_id for mode_id, mode_value
mode_compare += [item[0]] 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: if display_mode == 0:
comp_match = False if socket_info.hide_ui:
for c in mode_compare:
comp_match = c in v.name
if comp_match:
break
if comp_match:
continue continue
if s.hide_ui: if any(mode_name in interface_item.name for mode_name in inactive_mode_names):
continue continue
row = panel.row(align=True) 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() col = row.column()
input_row = col.row(align=True) settings = bpy.context.scene.BSBST_settings
attribute_toggle = False is_preset = bool(mod.id_data and mod.id_data == settings.preset_object)
if f'{v.identifier}_use_attribute' in mod.keys() and not v.force_non_field: geomod.draw_socket(col.row(align=True), mod, interface_item, name, is_preset)
attribute_toggle = mod[f'{v.identifier}_use_attribute']
if attribute_toggle: if type(interface_item) in utils.linkable_sockets:
input_row.prop(mod, f'["{v.identifier}_attribute_name"]', text=k) col.active = not socket_info.link_context
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)
row.alignment = 'EXPAND' row.alignment = 'EXPAND'
if s.link_context: if socket_info.link_context:
row.prop(s, 'link_context', text='', icon_value=icon) row.prop(socket_info, 'link_context', text='',
else: icon_value=settings_py.icon_from_link_type(socket_info.link_context_type))
if display_mode == -1: elif display_mode == -1:
row.prop(s, 'link_context_type', text='', emboss=True, icon='LINKED', icon_only=True) row.prop(socket_info, 'link_context_type', text='', emboss=True, icon='LINKED', icon_only=True)
if display_mode != 0: if display_mode != 0:
col = row.column() col = row.column()
col.active = not (mod_info.hide_ui or hide_panel) col.active = panel_active
col.prop(s, 'hide_ui', icon_only=True, icon='UNPINNED' if s.hide_ui else 'PINNED', emboss=False) 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): def draw_material_settings(context, layout, material, surface_object=None):
addon_prefs = bpy.context.preferences.addons[__package__].preferences addon_prefs = context.preferences.addons[__package__].preferences
settings = bpy.context.scene.BSBST_settings settings = context.scene.BSBST_settings
material_row = layout.row(align=True) material_row = layout.row(align=True)
material_row.template_ID(settings, 'context_material') material_row.template_ID(settings, 'context_material')
@@ -298,7 +264,7 @@ def draw_effect_panel_recursive(effects_panel, material, prev_node):
panel.active = False panel.active = False
for input in node.inputs[1:]: for input in node.inputs[1:]:
panel.prop(input, 'default_value', text=input.name) panel.prop(input, 'default_value', text=input.name)
draw_effect_panel_recursive(effects_panel, material, node) draw_effect_panel_recursive(effects_panel, material, node)
def draw_advanced_settings(layout, settings): 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) new_advanced_panel.row().prop(settings, 'curve_mode', expand=True)
if settings.curve_mode in ['CURVE', 'GP']: 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.label(text='Curve mode does not support drawing on deformed geometry', icon='ERROR')
new_advanced_panel.prop(settings, 'animated') new_advanced_panel.prop(settings, 'animated')
new_advanced_panel.prop(settings, 'deforming_surface') new_advanced_panel.prop(settings, 'deforming_surface')
new_advanced_panel.prop(settings, 'assign_materials') 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 display_mode == 0:
if mod_info.hide_ui: if mod_info.hide_ui:
continue continue
mod_header, mod_panel = layout.panel(mod.name, default_closed = mod_info.default_closed) mod_header, mod_panel = layout.panel(mod.name, default_closed = mod_info.default_closed)
row = mod_header.row(align=True) row = mod_header.row(align=True)
row.label(text='', icon='GEOMETRY_NODES') row.label(text='', icon='GEOMETRY_NODES')
@@ -362,12 +328,12 @@ def draw_shape_properties(layout, settings, style_object, is_preset, display_mod
mod, mod,
mod.node_group.interface.items_tree.items(), mod.node_group.interface.items_tree.items(),
display_mode) display_mode)
draw_mod_warnings(layout, mod) 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: 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: else:
material_row = layout.row(align=True) material_row = layout.row(align=True)
material_row.template_ID(settings, 'context_material', new='brushstroke_tools.new_material') 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) 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') layout.separator(type='LINE')
row = layout.row(align=True) 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') layout.separator(factor=.0, type='SPACE')
if settings.view_tab == 'MATERIAL': 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': elif settings.view_tab == 'SHAPE':
draw_shape_properties(layout, settings, style_object, is_preset, display_mode) 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): def draw(self, _context):
layout = self.layout layout = self.layout
op = layout.operator('brushstroke_tools.copy_brushstrokes', text='Copy to Selected Objects') op = layout.operator('brushstroke_tools.copy_brushstrokes', text='Copy to Selected Objects')
op.copy_all = False 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: if not settings.preset_object and is_preset:
layout.operator("brushstroke_tools.init_preset", icon='MODIFIER') layout.operator("brushstroke_tools.init_preset", icon='MODIFIER')
else: 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): class BSBST_MT_PIE_brushstroke_data_marking(bpy.types.Menu):
bl_idname= "BSBST_MT_PIE_brushstroke_data_marking" bl_idname= "BSBST_MT_PIE_brushstroke_data_marking"
@@ -607,7 +573,7 @@ def register():
wm = bpy.context.window_manager wm = bpy.context.window_manager
if wm.keyconfigs.addon is not None: if wm.keyconfigs.addon is not None:
km = wm.keyconfigs.addon.keymaps.new(name="Mesh") 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(): def unregister():
for c in reversed(classes): for c in reversed(classes):
@@ -10,6 +10,7 @@ from bpy.app.handlers import persistent
import math, shutil, errno, numpy import math, shutil, errno, numpy
from bpy.app.handlers import persistent from bpy.app.handlers import persistent
from mathutils import Vector from mathutils import Vector
from . import geomod
addon_version = (0,0,0) addon_version = (0,0,0)
@@ -36,6 +37,7 @@ linkable_sockets = [
asset_lib_name = 'Brushstroke Tools Library' asset_lib_name = 'Brushstroke Tools Library'
@persistent @persistent
def find_context_brushstrokes(scene, depsgraph): def find_context_brushstrokes(scene, depsgraph):
settings = scene.BSBST_settings settings = scene.BSBST_settings
@@ -178,7 +180,7 @@ def set_brushstroke_material(ob, material):
continue continue
if not s.link_context_type == 'MATERIAL': if not s.link_context_type == 'MATERIAL':
continue continue
mod[s.name] = material geomod.set_value(mod, s.name, material)
ob.update_tag() ob.update_tag()
if ob.type == 'EMPTY': if ob.type == 'EMPTY':
@@ -655,11 +657,7 @@ def transfer_modifier(modifier_name, target_obj, source_obj):
if source_mod.type == 'NODES': if source_mod.type == 'NODES':
# Transfer geo node attributes # Transfer geo node attributes
for key, value in source_mod.items(): geomod.copy_inputs(source_mod, target_mod)
try:
target_mod[key] = value
except (TypeError, ValueError) as e:
target_mod[key] = type(target_mod[key])(value)
# Transfer geo node bake settings # Transfer geo node bake settings
target_mod.bake_directory = source_mod.bake_directory target_mod.bake_directory = source_mod.bake_directory
@@ -744,7 +742,7 @@ def set_surface_object(bs, surf_ob):
continue continue
if not s.link_context_type == 'SURFACE_OBJECT': if not s.link_context_type == 'SURFACE_OBJECT':
continue continue
mod[s.name] = surf_ob geomod.set_value(mod, s.name, surf_ob)
ob.parent = surf_ob ob.parent = surf_ob
ob.parent_type = 'OBJECT' ob.parent_type = 'OBJECT'
surf_ob.update_tag() surf_ob.update_tag()
@@ -775,7 +773,7 @@ def set_flow_object(bs, ob):
continue continue
if not s.link_context_type == 'FLOW_OBJECT': if not s.link_context_type == 'FLOW_OBJECT':
continue continue
mod[s.name] = ob geomod.set_value(mod, s.name, ob)
ob.update_tag() ob.update_tag()
bs['BSBST_flow_object'] = ob bs['BSBST_flow_object'] = ob
@@ -1106,8 +1104,18 @@ def match_mat(tgt_mat, src_mat):
for attr_path in path_list: for attr_path in path_list:
try: 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: except:
print(f"Could not copy attribute '{attr_path}' on material '{tgt_mat.name}'")
pass pass
tgt_curve_node = tgt_mat.node_tree.nodes.get('Brush Curve') tgt_curve_node = tgt_mat.node_tree.nodes.get('Brush Curve')
@@ -1,6 +1,6 @@
schema_version = "1.0.0" schema_version = "1.0.0"
id = "datablock_utils" id = "datablock_utils"
version = "1.3.0" version = "1.3.2"
name = "Data-Block Utilities" name = "Data-Block Utilities"
tagline = "Show users, merge duplicates, find similar, and more" tagline = "Show users, merge duplicates, find similar, and more"
maintainer = "Leonardo Pike-Excell <leonardopike.excell@gmail.com>" 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 >= (4, 3, 0):
if bpy.app.version >= (5, 0, 0): if bpy.app.version >= (5, 0, 0):
collections.remove(next(c for c in collections if c.identifier == 'annotations')) 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) _assign('CURVES', 'hair_curves', enums, collections)
if bpy.app.version >= (5, 0, 0): if bpy.app.version >= (5, 0, 0):
_assign('GREASEPENCIL', 'grease_pencils', enums, collections, remove=False) _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 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]: def get_invalid_nodes(ntree: NodeTree) -> set[Node]:
settings = get_settings() settings = get_settings()
invalid_nodes = set() 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) @dataclass(slots=True)
class NodeProperties: class NodeProperties:
id_data: Node | NodeTree id_data: Node | NodeTree
@@ -218,7 +240,7 @@ def contents_of_ntrees(
) -> defaultdict[str, list[NodeProperties]]: ) -> defaultdict[str, list[NodeProperties]]:
content_map = defaultdict(list) content_map = defaultdict(list)
for id_data in bl_data: 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 continue
ntree = id_data if isinstance(id_data, NodeTree) else id_data.node_tree 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] tuple(p) if isinstance(p, bpy.types.bpy_prop_array) else p for p in props]
contents.append(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): if not isinstance(id_data, NodeTree):
continue continue
@@ -403,7 +428,7 @@ def find_similar_and_duplicate_ntrees(id_type: str) -> None:
def find_duplicate_images() -> None: def find_duplicate_images() -> None:
duplicates = [] 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) images.sort(key=get_image_props)
for _, raw_group in groupby(images, get_image_props): for _, raw_group in groupby(images, get_image_props):
group = [i.name for i in raw_group] group = [i.name for i in raw_group]
@@ -414,7 +439,7 @@ def find_duplicate_images() -> None:
def find_duplicate_meshes() -> 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() seen = set()
results = [] results = []
for m1 in meshes: for m1 in meshes:
@@ -493,6 +518,8 @@ class DBU_OT_SimilarAndDuplicatesClearResults(Operator):
def merge_ids(duplicate_ids: Iterable[Iterable[bpy.types.ID]]) -> int: def merge_ids(duplicate_ids: Iterable[Iterable[bpy.types.ID]]) -> int:
to_remove = [] to_remove = []
for target, *junk in duplicate_ids: 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) to_remove.extend(junk)
for id_data in junk: for id_data in junk:
id_data.user_remap(target) id_data.user_remap(target)
@@ -154,19 +154,44 @@ class GOVIE_Quick_Export_GLB_Operator(bpy.types.Operator):
# read preset parameters from file # read preset parameters from file
if preset_filepath: if preset_filepath:
class Container(object):
__slots__ = ("__dict__",)
op = Container()
preset_file = open(preset_filepath, "r") preset_file = open(preset_filepath, "r")
# storing the values from the preset on the class
for line in preset_file.readlines()[3::]: 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: else:
gltf_export_param["export_extras"] = True gltf_export_param["export_extras"] = True
@@ -29,7 +29,7 @@ bl_info = {
"author": "Lorenz Wieseke, 3D Interaction Technologies GmbH (contact@govie.de)", "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.", "description": "Transform your model into a web-optimized GLB file for use in the Govie Editor.",
"blender": (4, 2, 0), "blender": (4, 2, 0),
"version": (1, 0, 20), "version": (1, 0, 22),
"location": "View 3D > Property Panel (N-Key in 3D View)", "location": "View 3D > Property Panel (N-Key in 3D View)",
"warning": "", "warning": "",
"category": "Scene", "category": "Scene",
@@ -1,7 +1,7 @@
schema_version = "1.0.0" schema_version = "1.0.0"
id = "govietools" id = "govietools"
version = "1.0.20" version = "1.0.22"
name = "Govie Tools" name = "Govie Tools"
tagline = "Optimize your model for use in the Govie Editor" tagline = "Optimize your model for use in the Govie Editor"
maintainer = "Stefan Bernstein, 3D Interaction Technologies GmbH <contact@govie.de>" maintainer = "Stefan Bernstein, 3D Interaction Technologies GmbH <contact@govie.de>"
@@ -1,6 +1,6 @@
[project] [project]
name = "govietools" name = "govietools"
version = "1.0.20" version = "1.0.21"
description = "Optimize your model for use in the Govie Editor" description = "Optimize your model for use in the Govie Editor"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11.13" 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. [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") ![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 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. - 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 # SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import annotations
import json import json
import os import os
import tempfile import tempfile
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any
import bpy import bpy
from bl_ui.generic_ui_list import draw_ui_list from bl_ui.generic_ui_list import draw_ui_list
@@ -15,15 +18,23 @@ from bpy.props import (
IntProperty, IntProperty,
StringProperty, 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 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 = { bl_info = {
"name": "Incremental Autosave", "name": "Incremental Autosave",
"author": "Demeter Dzadik", "author": "Demeter Dzadik",
"version": (1, 1, 1), "version": (1, 1, 2),
"blender": (2, 90, 0), "blender": (3, 5, 0),
"location": "blender", "location": "blender",
"description": "Autosaves in a way where subsequent autosaves don't overwrite previous ones", "description": "Autosaves in a way where subsequent autosaves don't overwrite previous ones",
"category": "System", "category": "System",
@@ -38,7 +49,14 @@ LAUNCH_TIME = datetime.now()
class INCSAVE_UL_file_paths(UIList): class INCSAVE_UL_file_paths(UIList):
def draw_item( 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 filepath: AutoSavePath = item
@@ -51,12 +69,14 @@ class INCSAVE_UL_file_paths(UIList):
row.prop(filepath, "path", text="") 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 context = context or bpy.context
return context.preferences.addons[__name__].preferences 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) prefs = get_addon_prefs(context)
if not type(prefs).loading: if not type(prefs).loading:
prefs.save_prefs_to_file() prefs.save_prefs_to_file()
@@ -71,7 +91,7 @@ class PrefsFileSaveLoadMixin:
import bpy, json import bpy, json
from pathlib import Path from pathlib import Path
class MyAddonPrefs(PrefsFileSaveLoadMixin, bpy.types.AddonPreferences): class MyAddonPrefs(PrefsFileSaveLoadMixin, AddonPreferences):
some_prop: bpy.props.IntProperty(update=update_prefs_on_file) some_prop: bpy.props.IntProperty(update=update_prefs_on_file)
def register(): def register():
@@ -91,7 +111,7 @@ class PrefsFileSaveLoadMixin:
@staticmethod @staticmethod
def register_autoload_from_file(delay=0.1): def register_autoload_from_file(delay=0.1):
def timer_func(_scene=None): def timer_func(_scene: object = None):
prefs = get_addon_prefs() prefs = get_addon_prefs()
prefs.load_prefs_from_file() prefs.load_prefs_from_file()
@@ -105,7 +125,7 @@ class PrefsFileSaveLoadMixin:
ret = {} ret = {}
rna_class = None rna_class = None
if isinstance(propgroup, bpy.types.AddonPreferences): if isinstance(propgroup, AddonPreferences):
prop_dict = { prop_dict = {
key: getattr(propgroup, key) key: getattr(propgroup, key)
for key in propgroup.bl_rna.properties.keys() for key in propgroup.bl_rna.properties.keys()
@@ -113,9 +133,7 @@ class PrefsFileSaveLoadMixin:
} }
else: else:
property_group_class_name = type(propgroup).__name__ property_group_class_name = type(propgroup).__name__
rna_class = bpy.types.PropertyGroup.bl_rna_get_subclass_py( rna_class = PropertyGroup.bl_rna_get_subclass_py(property_group_class_name)
property_group_class_name
)
if not hasattr(rna_class, "properties"): if not hasattr(rna_class, "properties"):
rna_class = None rna_class = None
prop_dict = { prop_dict = {
@@ -127,7 +145,7 @@ class PrefsFileSaveLoadMixin:
for key, value in prop_dict.items(): for key, value in prop_dict.items():
if key in type(self).omit_from_disk: if key in type(self).omit_from_disk:
continue 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] ret[key] = [self.prefs_to_dict_recursive(elem) for elem in value]
elif type(value) is IDPropertyGroup: elif type(value) is IDPropertyGroup:
ret[key] = self.prefs_to_dict_recursive(value) ret[key] = self.prefs_to_dict_recursive(value)
@@ -143,7 +161,11 @@ class PrefsFileSaveLoadMixin:
ret[key] = value ret[key] = value
return ret 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(): for key, value in data.items():
if not hasattr(propgroup, key): if not hasattr(propgroup, key):
# Property got removed or renamed in the implementation. # Property got removed or renamed in the implementation.
@@ -165,7 +187,7 @@ class PrefsFileSaveLoadMixin:
addon_name = __package__.split(".")[-1] addon_name = __package__.split(".")[-1]
return Path(bpy.utils.user_resource("CONFIG")) / Path(addon_name + ".txt") 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) data_dict = self.prefs_to_dict_recursive(propgroup=self)
with open(self.get_prefs_filepath(), "w") as f: with open(self.get_prefs_filepath(), "w") as f:
@@ -190,7 +212,11 @@ class PrefsFileSaveLoadMixin:
class AutoSavePath(PropertyGroup): 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( path: StringProperty(
name="Autosave Path", 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", 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, update=update_prefs_on_file,
) )
max_save_files: bpy.props.IntProperty( max_save_files: IntProperty(
name="Max Backups Per File", 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", 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, default=10,
@@ -229,7 +255,7 @@ class IncrementalAutoSavePreferences(PrefsFileSaveLoadMixin, AddonPreferences):
max=100, max=100,
update=update_prefs_on_file, update=update_prefs_on_file,
) )
compress_files: bpy.props.BoolProperty( compress_files: BoolProperty(
name="Compress Files", name="Compress Files",
description="Save backups with compression enabled", description="Save backups with compression enabled",
default=True, default=True,
@@ -263,7 +289,7 @@ class IncrementalAutoSavePreferences(PrefsFileSaveLoadMixin, AddonPreferences):
assert bpy.data.filepath assert bpy.data.filepath
return os.sep.join((os.path.dirname(bpy.data.filepath), "Autosaves")) 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.""" """Return an autosave path that will always actually exist, no matter how desperate."""
prefs = get_addon_prefs(context) prefs = get_addon_prefs(context)
if bpy.data.filepath and prefs.relative_to_blend: if bpy.data.filepath and prefs.relative_to_blend:
@@ -287,12 +313,19 @@ class IncrementalAutoSavePreferences(PrefsFileSaveLoadMixin, AddonPreferences):
sys_temp = tempfile.gettempdir() sys_temp = tempfile.gettempdir()
return sys_temp return sys_temp
def draw(self, context): def draw(self, context: Context):
layout = self.layout.column() layout = self.layout.column()
layout.use_property_decorate = False layout.use_property_decorate = False
layout.use_property_split = True 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") header.label(text="Autosave Paths")
if panel: if panel:
split = panel.split(factor=0.4) split = panel.split(factor=0.4)
@@ -384,7 +417,7 @@ def save_file():
if addon_prefs.save_images: if addon_prefs.save_images:
save_images() save_images()
if addon_prefs.save_sculpt: if addon_prefs.save_sculpt:
save_sculpt() realize_sculpt_or_edit_mesh()
bpy.ops.wm.save_as_mainfile( bpy.ops.wm.save_as_mainfile(
filepath=backup_file, copy=True, compress=addon_prefs.compress_files 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}") print(f"Incremental Autosave: Failed to save dirty image: {img.name}")
def save_sculpt(): def realize_sculpt_or_edit_mesh():
"""We just need to enter object mode and then go back to the original mode.""" """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 context = bpy.context
if context.mode == "EDIT_MESH": try:
bpy.ops.object.mode_set(mode="OBJECT") if context.mode == "EDIT_MESH":
bpy.ops.object.mode_set(mode="EDIT") bpy.ops.object.mode_set(mode="OBJECT")
if context.mode == "SCULPT": bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.object.mode_set(mode="OBJECT") if context.mode == "SCULPT":
bpy.ops.object.mode_set(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 @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 # 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: if bpy.data.is_dirty and get_addon_prefs().save_before_close:
save_file() save_file()
def create_autosave_on_timer(): def create_autosave_on_timer() -> int:
now = datetime.now() now = datetime.now()
delta = now - LAUNCH_TIME delta = now - LAUNCH_TIME
prefs = get_addon_prefs() prefs = get_addon_prefs()
@@ -439,7 +483,7 @@ def create_autosave_on_timer():
return prefs.save_interval * 60 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, """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. 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 @persistent
def register_autosave_timer(_dummy=None): def register_autosave_timer(_dummy: object = None):
bpy.app.timers.register(create_autosave_on_timer) bpy.app.timers.register(create_autosave_on_timer)
@@ -1,12 +1,12 @@
schema_version = "1.0.0" schema_version = "1.0.0"
id = "incremental_auto_save" id = "incremental_auto_save"
version = "1.1.1" version = "1.1.2"
name = "Incremental Auto-Save" name = "Incremental Auto-Save"
tagline = "Improvements to Blender's Autosave" tagline = "Improvements to Blender's Autosave"
maintainer = "Demeter Dzadik <demeter@blender.org>" maintainer = "Demeter Dzadik <demeter@blender.org>"
type = "add-on" type = "add-on"
website = "https://projects.blender.org/Mets/incremental-autosave" website = "https://projects.blender.org/Mets/incremental-autosave#incremental-auto-save"
tags = ["System"] tags = ["System"]
blender_version_min = "4.2.0" blender_version_min = "4.2.0"
@@ -15,7 +15,7 @@ license = [
"SPDX:GPL-3.0-or-later" "SPDX:GPL-3.0-or-later"
] ]
copyright = [ copyright = [
"2019-2024 Demeter Dzadik" "2019-2026 Demeter Dzadik"
] ]
[permissions] [permissions]
files = "Save preferences & .blends in chosen directories" 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) [![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/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
""" """
# support reloading sub-modules from . import add_suffix_panel
if "bpy" in locals(): from . import operators
import importlib from . import preferences
from . import ui
importlib.reload(add_suffix_panel) from . import variable_replacer
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
files = [ files = [
add_suffix_panel, add_suffix_panel,
@@ -245,7 +245,7 @@ class VIEW3D_OT_add_type_suf_pre(bpy.types.Operator):
obj_list = [] obj_list = []
for obj in self.get_selection_all(): for obj in self.get_selection_all():
if obj.type == 'GPENCIL': if obj.type in ('GPENCIL', 'GREASEPENCIL'):
obj_list.append(obj) obj_list.append(obj)
self.rename_suffix_prefix(obj_list, pre_suffix=wm.renaming_suffix_prefix_gpencil, object_type='GPENCIL', self.rename_suffix_prefix(obj_list, pre_suffix=wm.renaming_suffix_prefix_gpencil, object_type='GPENCIL',
icon='OUTLINER_OB_GREASEPENCIL') icon='OUTLINER_OB_GREASEPENCIL')
@@ -1,13 +1,13 @@
schema_version = "1.0.0" schema_version = "1.0.0"
id = "simple_renaming_panel" id = "simple_renaming_panel"
version = "2.1.6" version = "2.1.8"
name = "Simple Renaming" name = "Simple Renaming"
tagline = "Effortlessly rename multiple objects with this simple addon" tagline = "Effortlessly rename multiple objects with this simple addon"
maintainer = "Matthias Patscheider <patscheider.matthias@gmail.com>" maintainer = "Matthias Patscheider <patscheider.matthias@gmail.com>"
type = "add-on" 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"] tags = ["3D View", "Scene", "User Interface"]
@@ -1,5 +1,4 @@
import bpy import bpy
from bpy.app.handlers import persistent
from bpy.props import ( from bpy.props import (
BoolProperty, BoolProperty,
EnumProperty, EnumProperty,
@@ -10,15 +9,12 @@ from bpy.props import (
from . import add_pre_suffix from . import add_pre_suffix
from . import case_transform from . import case_transform
from . import reload_addon
from .version_check import start_version_check
from . import name_from_data from . import name_from_data
from . import name_replace from . import name_replace
from . import numerate from . import numerate
from . import search_replace from . import search_replace
from . import search_select from . import search_select
from . import trim_string from . import trim_string
from .renaming_utilities import update_selection_order
enumObjectTypes = [('EMPTY', "", "Rename empty objects", 'OUTLINER_OB_EMPTY', 1), enumObjectTypes = [('EMPTY', "", "Rename empty objects", 'OUTLINER_OB_EMPTY', 1),
('MESH', "", "Rename mesh objects", 'OUTLINER_OB_MESH', 2), ('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), ('CURVE', "", "Rename curve objects", 'OUTLINER_OB_CURVE', 64),
('SURFACE', "", "Rename surface objects", 'OUTLINER_OB_SURFACE', 128), ('SURFACE', "", "Rename surface objects", 'OUTLINER_OB_SURFACE', 128),
('FONT', "", "Rename font objects", 'OUTLINER_OB_FONT', 256), ('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), ('META', "", "Rename metaball objects", 'OUTLINER_OB_META', 1024),
('SPEAKER', "", "Rename empty speakers", 'OUTLINER_OB_SPEAKER', 2048), ('SPEAKER', "", "Rename empty speakers", 'OUTLINER_OB_SPEAKER', 2048),
('LIGHT_PROBE', "", "Rename mesh lightpropes", 'OUTLINER_OB_LIGHTPROBE', 4096), ('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_camel,
case_transform.VIEW3D_OT_case_snake, case_transform.VIEW3D_OT_case_snake,
case_transform.VIEW3D_OT_case_kebab, 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."), 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."), ('Y', "Y Axis", "Sort the object based on the Y axis."),
('Z', "Z Axis", "Sort the object based on the Z axis."), ('Z', "Z Axis", "Sort the object based on the Z axis."), ]
('SELECTION', "Selection", "Sort the objects based on the selection order"), ]
enum_sort_bone_items = [('X', "X Axis", "Sort the object based on the X 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."), ('Y', "Y Axis", "Sort the object based on the Y axis."),
('Z', "Z Axis", "Sort the object based on the Z 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(): def register():
id_store = bpy.types.Scene id_store = bpy.types.Scene
@@ -210,8 +187,6 @@ def register():
for cls in classes: for cls in classes:
register_class(cls) register_class(cls)
bpy.app.handlers.depsgraph_update_post.append(PostChange)
start_version_check()
def unregister(): def unregister():
@@ -237,4 +212,3 @@ def unregister():
del IDStore.renaming_filter_by_index del IDStore.renaming_filter_by_index
del IDStore.renaming_index_target 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: else:
sort_enum = scene.renaming_sort_enum sort_enum = scene.renaming_sort_enum
if sort_enum == 'SELECTION': if sort_enum == 'X':
obj_list = get_ordered_selection_objects()
elif sort_enum == 'X':
obj_list = get_sorted_objects_x(obj_list) obj_list = get_sorted_objects_x(obj_list)
elif sort_enum == 'Y': elif sort_enum == 'Y':
obj_list = get_sorted_objects_y(obj_list) obj_list = get_sorted_objects_y(obj_list)
@@ -51,7 +49,8 @@ def get_renaming_list(context):
if scene.renaming_object_types == 'OBJECT': if scene.renaming_object_types == 'OBJECT':
for obj in obj_list: 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) renaming_list.append(obj)
elif scene.renaming_object_types == 'DATA': elif scene.renaming_object_types == 'DATA':
@@ -65,8 +64,8 @@ def get_renaming_list(context):
if selection_only: if selection_only:
for obj in context.selected_objects: for obj in context.selected_objects:
for mat in obj.material_slots: for mat in obj.material_slots:
if mat is not None and mat.name != '': if mat.material is not None:
renaming_list.append(bpy.data.materials[mat.name]) renaming_list.append(mat.material)
else: else:
renaming_list = list(bpy.data.materials) renaming_list = list(bpy.data.materials)
@@ -124,7 +123,7 @@ def get_renaming_list(context):
renaming_list.append(new_bone) renaming_list.append(new_bone)
elif scene.renaming_object_types == 'COLLECTION': 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"] selected_collections = [c for c in context.selected_ids if c.bl_rna.identifier == "Collection"]
for col in selected_collections: for col in selected_collections:
renaming_list.append(col) renaming_list.append(col)
@@ -300,16 +299,6 @@ def get_global_z(obj):
return obj.matrix_world.to_translation().z 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): def get_sorted_objects_x(objects):
# Sort objects by their global X location # Sort objects by their global X location
sorted_objects = sorted(objects, key=get_global_x) 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) 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'} bl_options = {'REGISTER'}
prefs_tabs: EnumProperty(items=(('UI', "General", "General Settings"), prefs_tabs: EnumProperty(items=(('UI', "General", "General Settings"),
('KEYMAPS', "Keymaps", "Keymaps"), ('KEYMAPS', "Keymaps", "Keymaps")),
('SUPPORT', "Support & Donation", "Support")),
default='UI') default='UI')
renaming_category: StringProperty(name="Category", 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', self.keymap_ui(layout, 'Renaming Sub/Prefix', 'renaming_suf_pre',
'wm.call_panel', "VIEW3D_PT_tools_type_suffix") '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): 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 scene = context.scene
row = layout.row(align=True) row = layout.row(align=True)
@@ -47,7 +40,7 @@ def draw_renaming_panel(layout, context):
elif str(scene.renaming_object_types) in types_selected: elif str(scene.renaming_object_types) in types_selected:
layout.prop(scene, "renaming_only_selection", text="Only Selected") layout.prop(scene, "renaming_only_selection", text="Only Selected")
elif str(scene.renaming_object_types) == 'COLLECTION': 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 = layout.row(align=True)
row.prop(scene, "renaming_only_selection", text="Only Selected") row.prop(scene, "renaming_only_selection", text="Only Selected")
else: else:
@@ -191,13 +184,12 @@ class VIEW3D_PT_tools_renaming_panel(bpy.types.Panel):
def draw_header(self, context): def draw_header(self, context):
layout = self.layout layout = self.layout
row = layout.row(align=True) 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() addon_name = get_addon_name()
op = row.operator("preferences.rename_addon_search", text="", icon='PREFERENCES') op = row.operator("preferences.rename_addon_search", text="", icon='PREFERENCES')
op.addon_name = addon_name op.addon_name = addon_name
op.prefs_tabs = 'UI' op.prefs_tabs = 'UI'
row.operator("renaming.reload_addon", text="", icon='FILE_REFRESH')
def draw(self, context): def draw(self, context):
layout = self.layout layout = self.layout
@@ -21,18 +21,4 @@ class PREFERENCES_OT_open_addon(bpy.types.Operator):
prefs = context.preferences.addons[base_package].preferences prefs = context.preferences.addons[base_package].preferences
prefs.prefs_tabs = self.prefs_tabs 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'} return {'FINISHED'}
@@ -2,9 +2,10 @@
# #
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import importlib
import bpy import bpy
from bpy.utils import register_class, unregister_class from bpy.utils import register_class, unregister_class
import importlib
_VPM_AGENT_IMMEDIATE_REGISTER_DONE = locals().get("_VPM_AGENT_IMMEDIATE_REGISTER_DONE", False) _VPM_AGENT_IMMEDIATE_REGISTER_DONE = locals().get("_VPM_AGENT_IMMEDIATE_REGISTER_DONE", False)
@@ -15,16 +16,19 @@ module_names = (
"prefs", "prefs",
"sidebar", "sidebar",
"tweak_builtin_pies", "tweak_builtin_pies",
"pie_playback",
"pie_animation", "pie_keyframes",
"pie_copy_attributes",
"pie_apply_transform", "pie_apply_transform",
"pie_camera", "pie_camera",
"pie_curve_delete",
"pie_preferences", "pie_preferences",
"pie_editor_split_merge", "pie_editor_split_merge",
"pie_editor_switch", "pie_editor_switch",
"pie_file", "pie_file",
"pie_manipulator", "pie_manipulator",
"pie_mesh_delete", "pie_mesh_delete",
"pie_mesh_edge",
"pie_mesh_flatten", "pie_mesh_flatten",
"pie_mesh_merge", "pie_mesh_merge",
"pie_object_add", "pie_object_add",
@@ -33,16 +37,15 @@ module_names = (
"pie_proportional_editing", "pie_proportional_editing",
"pie_relationship_delete", "pie_relationship_delete",
"pie_sculpt_brush_select", "pie_sculpt_brush_select",
"pie_select_similar",
"pie_selection", "pie_selection",
"pie_set_origin", "pie_set_origin",
"pie_view_3d", "pie_view_3d",
"pie_window", "pie_window",
) )
modules = [ modules = [__import__(__package__ + "." + submod, {}, {}, submod) for submod in module_names]
__import__(__package__ + "." + submod, {}, {}, submod)
for submod in module_names
]
def register_unregister_modules(modules: list, register: bool): def register_unregister_modules(modules: list, register: bool):
"""Recursively register or unregister modules by looking for either """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'): elif hasattr(m, 'unregister'):
m.unregister() m.unregister()
def delayed_register(_scene=None): def delayed_register(_scene=None):
# Register whole add-on with a slight delay, # Register whole add-on with a slight delay,
# to make sure Keymap data we need already exists on Blender launch. # 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. # Otherwise, keyconfigs.user.keymaps is an empty list, we can't find fallback ops.
register_unregister_modules(modules, True) register_unregister_modules(modules, True)
def register(): def register():
""" """
We prefer an *immediate* register during startup, because other add-ons may touch 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. # Keep behavior unchanged (fallback to timer), but avoid raising during registration.
pass 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. # a .blend file directly from a file browser.
bpy.app.timers.register(delayed_register, first_interval=0.0, persistent=True) bpy.app.timers.register(delayed_register, first_interval=0.0, persistent=True)
def unregister(): def unregister():
register_unregister_modules(reversed(modules), False) register_unregister_modules(reversed(modules), False)
@@ -1,7 +1,7 @@
schema_version = "1.0.0" schema_version = "1.0.0"
id = "viewport_pie_menus" id = "viewport_pie_menus"
name = "3D 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" tagline = "Various pie menus to speed up your workflow"
maintainer = "Community" maintainer = "Community"
type = "add-on" 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. # This file requires (and is made possible by) Blender 5.0 due to the find_match() API call.
import hashlib import hashlib
from typing import Callable, Any import json
from typing import Any, Callable
import bpy 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 from bpy.types import KeyMap, KeyMapItem, UILayout
# Preserve across Reload Scripts (module reload) when possible, but also keep # Preserve across Reload Scripts (module reload) when possible, but also keep
@@ -27,11 +29,14 @@ KEYMAP_ICONS = {
'Armature': 'ARMATURE_DATA', 'Armature': 'ARMATURE_DATA',
'Pose': 'POSE_HLT', 'Pose': 'POSE_HLT',
'Weight Paint': 'WPAINT_HLT', 'Weight Paint': 'WPAINT_HLT',
'Graph Editor': 'GRAPH',
'Dopesheet': 'ACTION',
'Curve': 'CURVE_DATA',
} }
KEYMAP_UI_NAMES = { KEYMAP_UI_NAMES = {
'Armature': "Armature Edit", 'Armature': n_("Armature Edit"),
'Object Non-modal': "Object Mode", 'Object Non-modal': n_("Object Mode"),
} }
KMI_DEFAULTS = { 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) existing_kmi = find_kmi_in_km_by_data(addon_km, hotkey_kwargs, bl_idname, op_kwargs)
if existing_kmi: if existing_kmi:
# NOTE: It is extremely important to not register duplicate add-on keymaps, AND # 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. # 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. # will be stored and restored as expected, whenever the add-on is enabled again.
if (addon_km, existing_kmi) not in ADDON_KEYMAPS: if (addon_km, existing_kmi) not in ADDON_KEYMAPS:
ADDON_KEYMAPS.append((addon_km, existing_kmi)) 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 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: 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. 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): def execute(self, context):
num_restored = restore_deleted_keymap_items_global(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'} return {'FINISHED'}
@@ -175,7 +175,7 @@ def x_mirror_value(value):
if isinstance(value, str): if isinstance(value, str):
return flip_name(value) return flip_name(value)
elif isinstance(value, Object): elif isinstance(value, Object):
get_opposite_obj(value) return get_opposite_obj(value)
else: else:
return value 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 # 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 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 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): class WM_OT_call_menu_pie_drag_only(Operator):
"""Summon a pie menu only on mouse drag, otherwise initiate an 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. # 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, # In register_drag_hotkey(), the fallback operator is automagically extracted from the keymap,
# at the moment the hotkey is registered. # at the moment the hotkey is registered.
bl_idname = "wm.call_menu_pie_drag_only" bl_idname = "wm.call_menu_pie_drag_only"
bl_label = "Pie Menu on Drag" 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'): if not hasattr(context, 'keymapitem'):
return return
kmi = context.keymapitem # Set via UILayout.context_pointer_set(). kmi = context.keymapitem # Set via UILayout.context_pointer_set().
kmi.type = kmi.type kmi.type = kmi.type
name: StringProperty(options={'SKIP_SAVE'}) 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_operator: StringProperty(options={'SKIP_SAVE'})
fallback_op_kwargs: StringProperty(default="{}", 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: if not self.on_drag:
return self.execute(context) return self.execute(context)
self.init_mouse_x = event.mouse_x 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) context.window_manager.modal_handler_add(self)
return {'RUNNING_MODAL'} return {'RUNNING_MODAL'}
def invoke_fallback_operator(self): def invoke_fallback_operator(self) -> None:
op_cls = idname_to_op_class(self.fallback_operator) op_cls = idname_to_op_class(self.fallback_operator)
if not op_cls: if not op_cls:
return return
fallback_op_kwargs = json.loads(self.fallback_op_kwargs) fallback_op_kwargs = json.loads(self.fallback_op_kwargs)
if type(fallback_op_kwargs) == str: if isinstance(fallback_op_kwargs, str):
# Not sure why json.loads seems to sometimes return a string, but it does # Not sure why json.loads sometimes returns a string, but it does
# when eg. setting Shift+C to drag, and using it when first launching Blender. # when eg. setting Shift+C to drag and using it on first launch.
# After Reload Scripts, it works fine without this workaround. Weird af. # After Reload Scripts it works fine without this. Weird.
fallback_op_kwargs = json.loads(fallback_op_kwargs) fallback_op_kwargs = json.loads(fallback_op_kwargs)
if op_cls.poll(): if op_cls.poll():
try: try:
# 1. Execute the original operator and capture the result
result = op_cls('INVOKE_DEFAULT', **fallback_op_kwargs) 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': if 'FINISHED' in result and self.fallback_operator == 'wm.save_mainfile':
# Get current filename, or default to untitled # HACK: Fix save notification not appearing when tapping Ctrl+S.
filename = os.path.basename(bpy.data.filepath) if bpy.data.filepath else "untitled.blend" filename = os.path.basename(bpy.data.filepath)
self.report({'INFO'}, f'Saved "{filename}"') self.report({'INFO'}, f'Saved "{filename}"')
except TypeError: except TypeError:
# This can apparently happen sometimes, see issue #86. # This can apparently happen sometimes, see issue #86.
print(f"Pie Menu Fallback Operator failed: {self.fallback_operator}, {self.fallback_op_kwargs}") 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 event.value == 'RELEASE':
if self.fallback_operator: if self.fallback_operator:
self.invoke_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) delta_y = abs(event.mouse_y - self.init_mouse_y)
if delta_x > threshold or delta_y > threshold: if delta_x > threshold or delta_y > threshold:
return self.execute(context) return self.execute(context)
return {'RUNNING_MODAL'} return {'RUNNING_MODAL'}
def execute(self, context): def execute(self, _context: Context) -> set[str]:
bpy.ops.wm.call_menu_pie(name=self.name) bpy.ops.wm.call_menu_pie(name=self.name)
return {'FINISHED'} return {'FINISHED'}
@@ -104,16 +96,16 @@ class WM_OT_call_menu_pie_drag_only(Operator):
*, *,
keymap_name: str, keymap_name: str,
pie_name: str, pie_name: str,
hotkey_kwargs=None, hotkey_kwargs: dict[str, Any] | None = None,
default_fallback_op="", default_fallback_op: str = "",
default_fallback_kwargs=None, default_fallback_kwargs: dict[str, Any] | None = None,
on_drag=True, on_drag: bool = True,
): ) -> None:
if hotkey_kwargs is None: if hotkey_kwargs is None:
hotkey_kwargs = {'type': "SPACE", 'value': "PRESS"} hotkey_kwargs = {'type': "SPACE", 'value': "PRESS"}
context = bpy.context context = bpy.context
fallback_operator = default_fallback_op 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: # IMPORTANT:
# Do NOT derive fallback operator/kwargs from the USER keyconfig. # 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.oskey == hotkey_kwargs.get('oskey', False),
kmi.any == hotkey_kwargs.get('any', False), kmi.any == hotkey_kwargs.get('any', False),
kmi.key_modifier == hotkey_kwargs.get('key_modifier', 'NONE'), kmi.key_modifier == hotkey_kwargs.get('key_modifier', 'NONE'),
kmi.active kmi.active,
]: ]:
if not condition: if not condition:
break break
else: else:
fallback_operator = kmi.idname fallback_operator = kmi.idname
if kmi.properties: 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 break
register_hotkey( register_hotkey(
cls.bl_idname, cls.bl_idname,
op_kwargs={ op_kwargs={
'name': pie_name, 'name': pie_name,
'fallback_operator': fallback_operator, 'fallback_operator': fallback_operator,
# Deterministic JSON (sort_keys=True) helps keep KMI identity stable across sessions. # Deterministic JSON (sort_keys=True) helps keep KMI identity stable across sessions.
'fallback_op_kwargs': json.dumps(fallback_op_kwargs, sort_keys=True), 'fallback_op_kwargs': json.dumps(fallback_op_kwargs, sort_keys=True),
'on_drag': on_drag, 'on_drag': on_drag,
@@ -160,10 +155,20 @@ class WM_OT_call_menu_pie_drag_only(Operator):
keymap_name=keymap_name, 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) 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 # 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. # this operator when the add-on is uninstalled, which is pretty bad.
# bpy.utils.unregister_class(WM_OT_call_menu_pie_drag_only) # 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 # SPDX-License-Identifier: GPL-3.0-or-later
import bpy 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 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_idname = "PIE_MT_apply_transforms"
bl_label = "Apply Transforms" bl_label = "Apply Transforms"
def draw(self, context): def draw(self, context: Context):
layout = self.layout layout = self.layout
pie = layout.menu_pie() pie = layout.menu_pie()
# 4 - LEFT # 4 - LEFT
props = pie.operator("object.transform_apply", text="Rot/Scale", icon='CON_SIZELIKE') props = pie.operator("object.transform_apply", text="Rot/Scale", icon='CON_SIZELIKE')
props.location, props.rotation, props.scale = (False, True, True) props.location, props.rotation, props.scale = (False, True, True)
# 6 - RIGHT # 6 - RIGHT
props = pie.operator( props = pie.operator("object.transform_apply", text="Loc/Rot/Scale", icon='ORIENTATION_LOCAL')
"object.transform_apply", text="Loc/Rot/Scale", icon='ORIENTATION_LOCAL'
)
props.location, props.rotation, props.scale = (True, True, True) props.location, props.rotation, props.scale = (True, True, True)
# 2 - BOTTOM # 2 - BOTTOM
pie.operator("object.apply_transforms_of_constraints", text="Soft-Apply Constraints", icon='CONSTRAINT') pie.operator("object.apply_transforms_of_constraints", text="Soft-Apply Constraints", icon='CONSTRAINT')
# 8 - TOP # 8 - TOP
props = pie.operator( props = pie.operator("object.transform_apply", text="Rotation", icon='CON_ROTLIKE')
"object.transform_apply", text="Rotation", icon='CON_ROTLIKE'
)
props.location, props.rotation, props.scale = (False, True, False) props.location, props.rotation, props.scale = (False, True, False)
# 7 - TOP - LEFT # 7 - TOP - LEFT
props = pie.operator( props = pie.operator("object.transform_apply", text="Location", icon='CON_LOCLIKE')
"object.transform_apply", text="Location", icon='CON_LOCLIKE'
)
props.location, props.rotation, props.scale = (True, False, False) props.location, props.rotation, props.scale = (True, False, False)
# 9 - TOP - RIGHT # 9 - TOP - RIGHT
props = pie.operator( props = pie.operator("object.transform_apply", text="Scale", icon='CON_SIZELIKE')
"object.transform_apply", text="Scale", icon='CON_SIZELIKE'
)
props.location, props.rotation, props.scale = (False, False, True) props.location, props.rotation, props.scale = (False, False, True)
# 1 - BOTTOM - LEFT # 1 - BOTTOM - LEFT
if ( if (
@@ -49,11 +42,10 @@ class PIE_MT_apply_transforms(Menu):
): ):
pie.operator('object.instancer_empty_to_collection', icon='LINKED') pie.operator('object.instancer_empty_to_collection', icon='LINKED')
else: else:
pie.operator( pie.operator("object.apply_modifiers_shapekeys", icon='MODIFIER_DATA')
"object.make_single_user", text="Make Single-User", icon='DUPLICATE'
).obdata = True
# 3 - BOTTOM - RIGHT # 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): class OBJECT_OT_apply_transforms_of_constraints(Operator):
@@ -64,18 +56,13 @@ class OBJECT_OT_apply_transforms_of_constraints(Operator):
bl_options = {'REGISTER'} bl_options = {'REGISTER'}
@classmethod @classmethod
def poll(cls, context): def poll(cls, context: Context) -> bool:
if not any( if not any(any(c.enabled and c.influence > 0 for c in obj.constraints) for obj in context.selected_objects):
[
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.") cls.poll_message_set("No selected objects with enabled constraints.")
return False return False
return True 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. # This is just a wrapper operator, to add a better tooltip and poll message.
return bpy.ops.object.visual_transform_apply() return bpy.ops.object.visual_transform_apply()
@@ -88,15 +75,13 @@ class OBJECT_OT_make_meshes_single_user(Operator):
bl_options = {'REGISTER'} bl_options = {'REGISTER'}
@classmethod @classmethod
def poll(cls, context): def poll(cls, context: Context) -> bool:
if not any( if not any(obj.data and obj.data.users > 1 for obj in context.selected_objects):
[obj.data and obj.data.users > 1 for obj in context.selected_objects]
):
cls.poll_message_set("No selected objects with multi-user meshes.") cls.poll_message_set("No selected objects with multi-user meshes.")
return False return False
return True 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. # This is just a wrapper operator, to add a better tooltip and poll message.
return bpy.ops.object.duplicates_make_real() return bpy.ops.object.duplicates_make_real()
@@ -107,25 +92,63 @@ class OBJECT_OT_clear_all_transforms(Operator):
bl_description = "Clear All Transforms" bl_description = "Clear All Transforms"
bl_options = {'REGISTER', 'UNDO'} bl_options = {'REGISTER', 'UNDO'}
def execute(self, context): def execute(self, _context: Context):
bpy.ops.object.location_clear() bpy.ops.object.location_clear()
bpy.ops.object.rotation_clear() bpy.ops.object.rotation_clear()
bpy.ops.object.scale_clear() bpy.ops.object.scale_clear()
return {'FINISHED'} 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): class PIE_MT_clear_transforms(Menu):
bl_idname = "PIE_MT_clear_menu" bl_idname = "PIE_MT_apply_transforms_extras"
bl_label = "Clear Transforms" bl_label = "Apply Transforms Extras"
def draw(self, context): def draw(self, _context: Context):
layout = self.layout layout = self.layout
layout.operator("clear.all", text="Clear All", icon='NONE') layout.label(text="Clear Transforms")
layout.operator("object.location_clear", text="Clear Location", icon='NONE') layout.operator("clear.all", text="Clear All", icon='X')
layout.operator("object.rotation_clear", text="Clear Rotation", icon='NONE') layout.operator("object.location_clear", text="Clear Location", icon='CON_LOCLIKE')
layout.operator("object.scale_clear", text="Clear Scale", icon='NONE') layout.operator("object.rotation_clear", text="Clear Rotation", icon='CON_ROTLIKE')
layout.operator("object.origin_clear", text="Clear Origin", icon='NONE') 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 = ( registry = (
@@ -133,6 +156,7 @@ registry = (
OBJECT_OT_apply_transforms_of_constraints, OBJECT_OT_apply_transforms_of_constraints,
OBJECT_OT_make_meshes_single_user, OBJECT_OT_make_meshes_single_user,
OBJECT_OT_clear_all_transforms, OBJECT_OT_clear_all_transforms,
OBJECT_OT_apply_modifiers_shapekeys,
PIE_MT_clear_transforms, 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 # SPDX-License-Identifier: GPL-3.0-or-later
import bpy 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 from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
# Some magic numbers that are either hard-coded into Blender, # 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_idname = "PIE_MT_camera"
bl_label = "Camera" bl_label = "Camera"
def draw(self, context): def draw(self, context: Context):
pie = self.layout.menu_pie() pie = self.layout.menu_pie()
scene = context.scene scene = context.scene
space = context.area.spaces.active space = context.area.spaces.active
@@ -35,9 +36,7 @@ class PIE_MT_camera(Menu):
text = "View Active Camera" text = "View Active Camera"
if context.space_data.region_3d.view_perspective == 'CAMERA': if context.space_data.region_3d.view_perspective == 'CAMERA':
text = "Return to Viewport" text = "Return to Viewport"
pie.operator( pie.operator('view3d.view_camera_with_poll', text=text, icon='VIEW_CAMERA_UNSELECTED')
'view3d.view_camera_with_poll', text=text, icon='VIEW_CAMERA_UNSELECTED'
)
# 2 - BOTTOM # 2 - BOTTOM
box = pie.box().column(align=True) box = pie.box().column(align=True)
row = box.row() row = box.row()
@@ -48,9 +47,20 @@ class PIE_MT_camera(Menu):
if camera: if camera:
box.prop(camera.data, 'lens') box.prop(camera.data, 'lens')
box.prop(camera.data, 'sensor_width') 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 = box.row(align=True)
row.prop(camera.data, 'show_passepartout', text="") 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 = box.row(align=True)
row.prop(camera.data, 'show_composition_thirds') row.prop(camera.data, 'show_composition_thirds')
row.prop(camera.data, 'show_composition_center') row.prop(camera.data, 'show_composition_center')
@@ -88,13 +98,13 @@ class VIEW3D_OT_lock_active_camera_transforms(Operator):
bl_options = {'REGISTER', 'UNDO'} bl_options = {'REGISTER', 'UNDO'}
@classmethod @classmethod
def poll(cls, context): def poll(cls, context: Context) -> bool:
if not get_current_camera(context): if not get_current_camera(context):
cls.poll_message_set("No active camera in this viewport.") cls.poll_message_set("No active camera in this viewport.")
return False return False
return True return True
def execute(self, context): def execute(self, context: Context):
camera = get_current_camera(context) camera = get_current_camera(context)
lock = False lock = False
@@ -119,16 +129,14 @@ class VIEW3D_OT_camera_fit_view(Operator):
bl_options = {'REGISTER', 'UNDO'} bl_options = {'REGISTER', 'UNDO'}
@classmethod @classmethod
def poll(cls, context): def poll(cls, context: Context) -> bool:
if not context.space_data or not hasattr(context.space_data, 'region_3d'): if not context.space_data or not hasattr(context.space_data, 'region_3d'):
return False return False
if context.space_data.region_3d.view_perspective == 'CAMERA': if context.space_data.region_3d.view_perspective == 'CAMERA':
cls.poll_message_set("Already in a camera view.") cls.poll_message_set("Already in a camera view.")
return False return False
camera = get_current_camera(context) camera = get_current_camera(context)
if not camera and not ( if not camera and not (context.active_object and context.active_object.type == 'CAMERA'):
context.active_object and context.active_object.type == 'CAMERA'
):
cls.poll_message_set("No active camera.") cls.poll_message_set("No active camera.")
return False return False
if camera and (any(camera.lock_location) or any(camera.lock_rotation)): 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 False
return True return True
def invoke(self, context, _event): def invoke(self, context: Context, _event: Event):
if not context.scene.camera and not ( if not context.scene.camera and not (context.active_object and context.active_object.type == 'CAMERA'):
context.active_object and context.active_object.type == 'CAMERA'
):
self.create_camera = True self.create_camera = True
return self.execute(context) return self.execute(context)
def execute(self, context): def execute(self, context: Context):
cam = get_current_camera(context) cam = get_current_camera(context)
space = context.space_data space = context.space_data
@@ -173,7 +179,7 @@ class VIEW3D_OT_camera_from_view(Operator):
bl_label = "New Camera from View" bl_label = "New Camera from View"
bl_options = {'REGISTER', 'UNDO'} bl_options = {'REGISTER', 'UNDO'}
def execute(self, context): def execute(self, context: Context):
if context.mode != 'OBJECT': if context.mode != 'OBJECT':
bpy.ops.object.mode_set(mode='OBJECT') bpy.ops.object.mode_set(mode='OBJECT')
bpy.ops.object.camera_add() bpy.ops.object.camera_add()
@@ -193,7 +199,7 @@ class VIEW3D_OT_set_active_camera(Operator):
bl_options = {'REGISTER', 'UNDO'} bl_options = {'REGISTER', 'UNDO'}
@classmethod @classmethod
def poll(cls, context): def poll(cls, context: Context) -> bool:
if not context.active_object: if not context.active_object:
cls.poll_message_set("No active object.") cls.poll_message_set("No active object.")
return False return False
@@ -205,7 +211,7 @@ class VIEW3D_OT_set_active_camera(Operator):
return False return False
return True return True
def execute(self, context): def execute(self, context: Context):
context.scene.camera = context.active_object context.scene.camera = context.active_object
self.report({'INFO'}, f"Set active camera: {context.active_object.name}") self.report({'INFO'}, f"Set active camera: {context.active_object.name}")
return {'FINISHED'} return {'FINISHED'}
@@ -219,17 +225,17 @@ class VIEW3D_OT_view_camera_with_poll(Operator):
bl_options = {'REGISTER', 'UNDO'} bl_options = {'REGISTER', 'UNDO'}
@classmethod @classmethod
def poll(cls, context): def poll(cls, context: Context) -> bool:
if not get_current_camera(context): if not get_current_camera(context):
cls.poll_message_set("No active camera.") cls.poll_message_set("No active camera.")
return False return False
return True return True
def execute(self, context): def execute(self, _context: Context):
return bpy.ops.view3d.view_camera() return bpy.ops.view3d.view_camera()
def get_current_camera(context): def get_current_camera(context: Context) -> Object | None:
space = context.area.spaces.active space = context.area.spaces.active
if space.type == 'VIEW_3D' and space.use_local_camera: if space.type == 'VIEW_3D' and space.use_local_camera:
return space.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 # SPDX-License-Identifier: GPL-3.0-or-later
import bpy, sys, json import json
from bpy.types import Menu, Operator
import bpy
from bpy.props import StringProperty from bpy.props import StringProperty
from bpy.types import Context, Menu, Operator
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only
AVAILABLE_PROPERTIES_EDITORS = []
class PIE_MT_editor_switch(Menu): class PIE_MT_editor_switch(Menu):
bl_idname = "PIE_MT_editor_switch" bl_idname = "PIE_MT_editor_switch"
bl_label = "Editor Switch" bl_label = "Editor Switch"
def draw(self, context): def draw(self, _context: Context):
layout = self.layout layout = self.layout
pie = layout.menu_pie() pie = layout.menu_pie()
# 4 - LEFT # 4 - LEFT
pie.operator( pie.operator('wm.call_menu_pie', text="Video Editing...", icon="SEQUENCE").name = "PIE_MT_editor_switch_video"
'wm.call_menu_pie', text="Video Editing...", icon="SEQUENCE"
).name = "PIE_MT_editor_switch_video"
# 6 - RIGHT # 6 - RIGHT
pie.operator('wm.call_menu_pie', text="Nodes...", icon="NODETREE").name = ( pie.operator('wm.call_menu_pie', text="Nodes...", icon="NODETREE").name = "PIE_MT_editor_switch_nodes"
"PIE_MT_editor_switch_nodes"
)
# 2 - BOTTOM # 2 - BOTTOM
pie.operator('wm.call_menu_pie', text="Data...", icon="PRESET").name = ( pie.operator('wm.call_menu_pie', text="Data...", icon="PRESET").name = "PIE_MT_editor_switch_data"
"PIE_MT_editor_switch_data"
)
# 8 - TOP # 8 - TOP
pie.operator( pie.operator(WM_OT_set_area_type.bl_idname, text="3D View", icon="VIEW3D").ui_type = "VIEW_3D"
WM_OT_set_area_type.bl_idname, text="3D View", icon="VIEW3D"
).ui_type = "VIEW_3D"
# 7 - TOP - LEFT # 7 - TOP - LEFT
pie.operator( pie.operator('wm.call_menu_pie', text="2D Editors...", icon="FILE_IMAGE").name = "PIE_MT_editor_switch_image"
'wm.call_menu_pie', text="2D Editors...", icon="FILE_IMAGE"
).name = "PIE_MT_editor_switch_image"
# 9 - TOP - RIGHT # 9 - TOP - RIGHT
pie.operator('wm.call_menu_pie', text="Script...", icon="SCRIPT").name = ( pie.operator('wm.call_menu_pie', text="Script...", icon="SCRIPT").name = "PIE_MT_editor_switch_script"
"PIE_MT_editor_switch_script"
)
# 1 - BOTTOM - LEFT # 1 - BOTTOM - LEFT
pie.separator() pie.separator()
# 3 - BOTTOM - RIGHT # 3 - BOTTOM - RIGHT
pie.operator( pie.operator('wm.call_menu_pie', text="Animation...", icon="ARMATURE_DATA").name = "PIE_MT_editor_switch_anim"
'wm.call_menu_pie', text="Animation...", icon="ARMATURE_DATA"
).name = "PIE_MT_editor_switch_anim"
class PIE_MT_editor_switch_video(Menu): class PIE_MT_editor_switch_video(Menu):
bl_idname = "PIE_MT_editor_switch_video" bl_idname = "PIE_MT_editor_switch_video"
bl_label = "Editor Switch: Video" bl_label = "Editor Switch: Video"
def draw(self, context): def draw(self, _context: Context):
layout = self.layout layout = self.layout
pie = layout.menu_pie() pie = layout.menu_pie()
# 4 - LEFT # 4 - LEFT
op = pie.operator( op = pie.operator(WM_OT_set_area_type.bl_idname, text="Sequencer & Preview", icon="SEQ_SPLITVIEW")
WM_OT_set_area_type.bl_idname,
text="Sequencer & Preview",
icon="SEQ_SPLITVIEW",
)
op.ui_type = "SEQUENCE_EDITOR" op.ui_type = "SEQUENCE_EDITOR"
op.view_type = 'SEQUENCER_PREVIEW' op.view_type = 'SEQUENCER_PREVIEW'
# 6 - RIGHT # 6 - RIGHT
op = pie.operator( op = pie.operator(WM_OT_set_area_type.bl_idname, text="Movie Clip Editor", icon="SEQUENCE")
WM_OT_set_area_type.bl_idname, text="Movie Clip Editor", icon="SEQUENCE"
)
op.ui_type = "CLIP_EDITOR" op.ui_type = "CLIP_EDITOR"
op.mode = 'TRACKING' op.mode = 'TRACKING'
op.view = 'CLIP' op.view = 'CLIP'
# 2 - BOTTOM # 2 - BOTTOM
op = pie.operator( op = pie.operator(WM_OT_set_area_type.bl_idname, text="Clip Mask Editor", icon="MOD_MASK")
WM_OT_set_area_type.bl_idname, text="Clip Mask Editor", icon="MOD_MASK"
)
op.ui_type = "CLIP_EDITOR" op.ui_type = "CLIP_EDITOR"
op.mode = 'MASK' op.mode = 'MASK'
# 8 - TOP # 8 - TOP
pie.separator() pie.separator()
# 7 - TOP - LEFT # 7 - TOP - LEFT
op = pie.operator( op = pie.operator(WM_OT_set_area_type.bl_idname, text="Preview", icon="SEQ_PREVIEW")
WM_OT_set_area_type.bl_idname, text="Preview", icon="SEQ_PREVIEW"
)
op.ui_type = "SEQUENCE_EDITOR" op.ui_type = "SEQUENCE_EDITOR"
op.view_type = 'PREVIEW' op.view_type = 'PREVIEW'
# 9 - TOP - RIGHT # 9 - TOP - RIGHT
op = pie.operator( op = pie.operator(WM_OT_set_area_type.bl_idname, text="Tracking: Dopesheet", icon="ACTION")
WM_OT_set_area_type.bl_idname, text="Tracking: Dopesheet", icon="ACTION"
)
op.ui_type = "CLIP_EDITOR" op.ui_type = "CLIP_EDITOR"
op.mode = 'TRACKING' op.mode = 'TRACKING'
op.view = 'DOPESHEET' op.view = 'DOPESHEET'
# 1 - BOTTOM - LEFT # 1 - BOTTOM - LEFT
op = pie.operator( op = pie.operator(WM_OT_set_area_type.bl_idname, text="Sequencer", icon="SEQ_SEQUENCER")
WM_OT_set_area_type.bl_idname, text="Sequencer", icon="SEQ_SEQUENCER"
)
op.ui_type = "SEQUENCE_EDITOR" op.ui_type = "SEQUENCE_EDITOR"
op.view_type = 'SEQUENCER' op.view_type = 'SEQUENCER'
# 3 - BOTTOM - RIGHT # 3 - BOTTOM - RIGHT
op = pie.operator( op = pie.operator(WM_OT_set_area_type.bl_idname, text="Tracking: Graph", icon="GRAPH")
WM_OT_set_area_type.bl_idname, text="Tracking: Graph", icon="GRAPH"
)
op.ui_type = "CLIP_EDITOR" op.ui_type = "CLIP_EDITOR"
op.mode = 'TRACKING' op.mode = 'TRACKING'
op.view = 'GRAPH' op.view = 'GRAPH'
@@ -113,42 +83,30 @@ class PIE_MT_editor_switch_nodes(Menu):
bl_idname = "PIE_MT_editor_switch_nodes" bl_idname = "PIE_MT_editor_switch_nodes"
bl_label = "Editor Switch: Nodes" bl_label = "Editor Switch: Nodes"
def draw(self, context): def draw(self, context: Context):
layout = self.layout layout = self.layout
pie = layout.menu_pie() pie = layout.menu_pie()
# 4 - LEFT # 4 - LEFT
op = pie.operator( op = pie.operator(WM_OT_set_area_type.bl_idname, text="Compositing", icon="NODE_COMPOSITING")
WM_OT_set_area_type.bl_idname, text="Compositing", icon="NODE_COMPOSITING"
)
op.ui_type = 'CompositorNodeTree' op.ui_type = 'CompositorNodeTree'
# 6 - RIGHT # 6 - RIGHT
op = pie.operator( op = pie.operator(WM_OT_set_area_type.bl_idname, text="Shader", icon="NODE_MATERIAL")
WM_OT_set_area_type.bl_idname, text="Shader", icon="NODE_MATERIAL"
)
op.ui_type = 'ShaderNodeTree' op.ui_type = 'ShaderNodeTree'
op.shader_type = 'OBJECT' op.shader_type = 'OBJECT'
# 2 - BOTTOM # 2 - BOTTOM
op = pie.operator( op = pie.operator(WM_OT_set_area_type.bl_idname, text="Geometry", icon="GEOMETRY_NODES")
WM_OT_set_area_type.bl_idname, text="Geometry", icon="GEOMETRY_NODES"
)
op.ui_type = 'GeometryNodeTree' op.ui_type = 'GeometryNodeTree'
# 8 - TOP # 8 - TOP
op = pie.operator(WM_OT_set_area_type.bl_idname, text="Texture", icon="TEXTURE") op = pie.operator(WM_OT_set_area_type.bl_idname, text="Texture", icon="TEXTURE")
op.ui_type = 'TextureNodeTree' op.ui_type = 'TextureNodeTree'
# 7 - TOP - LEFT # 7 - TOP - LEFT
op = pie.operator( op = pie.operator(WM_OT_set_area_type.bl_idname, text="Line Style", icon="LINE_DATA")
WM_OT_set_area_type.bl_idname, text="Line Style", icon="LINE_DATA"
)
op.ui_type = 'ShaderNodeTree' op.ui_type = 'ShaderNodeTree'
op.shader_type = 'LINESTYLE' op.shader_type = 'LINESTYLE'
# 9 - TOP - RIGHT # 9 - TOP - RIGHT
if get_custom_nodetree_types(context, layout): if get_custom_nodetree_types(context):
pie.menu( pie.menu('PIE_MT_editor_switch_nodes_custom', text="Custom Nodes", icon='NODETREE')
'PIE_MT_editor_switch_nodes_custom',
text="Custom Nodes",
icon='NODETREE',
)
else: else:
pie.separator() pie.separator()
# 1 - BOTTOM - LEFT # 1 - BOTTOM - LEFT
@@ -163,253 +121,117 @@ class PIE_MT_editor_switch_nodes_custom(Menu):
bl_idname = "PIE_MT_editor_switch_nodes_custom" bl_idname = "PIE_MT_editor_switch_nodes_custom"
bl_label = "Editor Switch: Custom Nodes" bl_label = "Editor Switch: Custom Nodes"
def draw(self, context): def draw(self, context: Context):
layout = self.layout layout = self.layout
for ui_type, label, icon in get_custom_nodetree_types(context):
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
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
class PIE_MT_editor_switch_data(Menu): class PIE_MT_editor_switch_data(Menu):
bl_idname = "PIE_MT_editor_switch_data" bl_idname = "PIE_MT_editor_switch_data"
bl_label = "Editor Switch: Data" bl_label = "Editor Switch: Data"
def draw(self, context): def draw(self, _context: Context):
pie = self.layout.menu_pie() pie = self.layout.menu_pie()
# 4 - LEFT # 4 - LEFT
pie.operator( pie.operator(WM_OT_set_area_type.bl_idname, text="File Browser", icon="FILEBROWSER").ui_type = "FILES"
WM_OT_set_area_type.bl_idname, text="File Browser", icon="FILEBROWSER"
).ui_type = "FILES"
# 6 - RIGHT # 6 - RIGHT
pie.operator( pie.operator(WM_OT_set_area_type.bl_idname, text="Preferences", icon="PREFERENCES").ui_type = "PREFERENCES"
WM_OT_set_area_type.bl_idname, text="Preferences", icon="PREFERENCES"
).ui_type = "PREFERENCES"
# 2 - BOTTOM # 2 - BOTTOM
pie.operator( pie.operator(WM_OT_set_area_type.bl_idname, text="Outliner", icon="OUTLINER").ui_type = "OUTLINER"
WM_OT_set_area_type.bl_idname, text="Outliner", icon="OUTLINER"
).ui_type = "OUTLINER"
# 8 - TOP # 8 - TOP
pie.operator( pie.operator(WM_OT_set_area_type.bl_idname, text="Properties", icon="PROPERTIES").ui_type = "PROPERTIES"
WM_OT_set_area_type.bl_idname, text="Properties", icon="PROPERTIES"
).ui_type = "PROPERTIES"
# 7 - TOP - LEFT # 7 - TOP - LEFT
pie.operator( pie.operator(WM_OT_set_area_type.bl_idname, text="Asset Browser", icon="ASSET_MANAGER").ui_type = "ASSETS"
WM_OT_set_area_type.bl_idname, text="Asset Browser", icon="ASSET_MANAGER"
).ui_type = "ASSETS"
# 9 - TOP - RIGHT # 9 - TOP - RIGHT
pie.menu( pie.menu("PIE_MT_editor_switch_properties", text="Properties Types...", icon="PROPERTIES")
"PIE_MT_editor_switch_properties",
text="Properties Types...",
icon="PROPERTIES",
)
# 1 - BOTTOM - LEFT # 1 - BOTTOM - LEFT
pie.operator( pie.operator(WM_OT_set_area_type.bl_idname, text="Spreadsheet", icon="SPREADSHEET").ui_type = "SPREADSHEET"
WM_OT_set_area_type.bl_idname, text="Spreadsheet", icon="SPREADSHEET"
).ui_type = "SPREADSHEET"
# 3 - BOTTOM - RIGHT # 3 - BOTTOM - RIGHT
pie.menu( pie.menu("PIE_MT_editor_switch_outliner", text="Outliner Types...", icon="OUTLINER")
"PIE_MT_editor_switch_outliner", text="Outliner Types...", icon="OUTLINER"
)
class PIE_MT_editor_switch_outliner(Menu): class PIE_MT_editor_switch_outliner(Menu):
bl_idname = "PIE_MT_editor_switch_outliner" bl_idname = "PIE_MT_editor_switch_outliner"
bl_label = "Editor Switch: Outliner" bl_label = "Editor Switch: Outliner"
def draw(self, context): def draw(self, _context: Context):
pie = self.layout#.menu_pie() layout = self.layout
# 4 - LEFT # 4 - LEFT
op = pie.operator( op = layout.operator(WM_OT_set_area_type.bl_idname, text="Scenes", icon="SCENE_DATA")
WM_OT_set_area_type.bl_idname, text="Scenes", icon="SCENE_DATA"
)
op.ui_type = "OUTLINER" op.ui_type = "OUTLINER"
op.display_mode = 'SCENES' op.display_mode = 'SCENES'
# 6 - RIGHT # 6 - RIGHT
op = pie.operator( op = layout.operator(WM_OT_set_area_type.bl_idname, text="View Layer", icon="RENDERLAYERS")
WM_OT_set_area_type.bl_idname, text="View Layer", icon="RENDERLAYERS"
)
op.ui_type = "OUTLINER" op.ui_type = "OUTLINER"
op.display_mode = 'VIEW_LAYER' op.display_mode = 'VIEW_LAYER'
# 2 - BOTTOM # 2 - BOTTOM
op = pie.operator( op = layout.operator(WM_OT_set_area_type.bl_idname, text="Video Sequencer", icon="SEQUENCE")
WM_OT_set_area_type.bl_idname, text="Video Sequencer", icon="SEQUENCE"
)
op.ui_type = "OUTLINER" op.ui_type = "OUTLINER"
op.display_mode = 'SEQUENCE' op.display_mode = 'SEQUENCE'
# 8 - TOP # 8 - TOP
op = pie.operator( op = layout.operator(WM_OT_set_area_type.bl_idname, text="Blender File", icon="FILE_BLEND")
WM_OT_set_area_type.bl_idname, text="Blender File", icon="FILE_BLEND"
)
op.ui_type = "OUTLINER" op.ui_type = "OUTLINER"
op.display_mode = 'LIBRARIES' op.display_mode = 'LIBRARIES'
# 7 - TOP - LEFT # 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.ui_type = "OUTLINER"
op.display_mode = 'DATA_API' op.display_mode = 'DATA_API'
# 9 - TOP - RIGHT # 9 - TOP - RIGHT
op = pie.operator( op = layout.operator(WM_OT_set_area_type.bl_idname, text="Library Overrides", icon="LIBRARY_DATA_OVERRIDE")
WM_OT_set_area_type.bl_idname,
text="Library Overrides",
icon="LIBRARY_DATA_OVERRIDE",
)
op.ui_type = "OUTLINER" op.ui_type = "OUTLINER"
op.display_mode = 'LIBRARY_OVERRIDES' op.display_mode = 'LIBRARY_OVERRIDES'
# 1 - BOTTOM - LEFT # 1 - BOTTOM - LEFT
op = pie.operator( op = layout.operator(WM_OT_set_area_type.bl_idname, text="Orphan Data", icon="ORPHAN_DATA")
WM_OT_set_area_type.bl_idname, text="Orphan Data", icon="ORPHAN_DATA"
)
op.ui_type = "OUTLINER" op.ui_type = "OUTLINER"
op.display_mode = 'ORPHAN_DATA' op.display_mode = 'ORPHAN_DATA'
# 3 - BOTTOM - RIGHT # 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): class PIE_MT_editor_switch_properties(Menu):
bl_idname = "PIE_MT_editor_switch_properties" bl_idname = "PIE_MT_editor_switch_properties"
bl_label = "Editor Switch: Properties" bl_label = "Editor Switch: Properties"
def draw(self, context): def draw(self, context: Context):
layout = self.layout layout = self.layout
available_editors = get_available_properties_editors(context) 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: 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.ui_type = "PROPERTIES"
op.context_name = context_name op.context_name = context_name
else: else:
pie.separator() layout.separator()
safe_draw(layout, "Render", 'SCENE', 'RENDER') draw_entry("Render", 'SCENE', 'RENDER')
safe_draw(layout, "Output", 'OUTPUT', 'OUTPUT') draw_entry("Output", 'OUTPUT', 'OUTPUT')
safe_draw(layout, "View Layer", 'RENDERLAYERS', 'VIEW_LAYER') draw_entry("View Layer", 'RENDERLAYERS', 'VIEW_LAYER')
safe_draw(layout, "Scene", 'SCENE_DATA', 'SCENE') draw_entry("Scene", 'SCENE_DATA', 'SCENE')
safe_draw(layout, "World", 'WORLD', 'WORLD') draw_entry("World", 'WORLD', 'WORLD')
layout.separator() layout.separator()
safe_draw(layout, "Object", 'OBJECT_DATAMODE', 'OBJECT') draw_entry("Object", 'OBJECT_DATAMODE', 'OBJECT')
safe_draw(layout, "Modifiers", 'MODIFIER', 'MODIFIER') draw_entry("Modifiers", 'MODIFIER', 'MODIFIER')
safe_draw(layout, "Particles", 'PARTICLES', 'PARTICLES') draw_entry("Particles", 'PARTICLES', 'PARTICLES')
safe_draw(layout, "Physics", 'PHYSICS', 'PHYSICS') draw_entry("Physics", 'PHYSICS', 'PHYSICS')
safe_draw(layout, "Constraints", 'CONSTRAINT', 'CONSTRAINT') draw_entry("Constraints", 'CONSTRAINT', 'CONSTRAINT')
safe_draw(layout, "Object Data", 'OUTLINER_DATA_MESH', 'DATA') draw_entry("Object Data", 'OUTLINER_DATA_MESH', 'DATA')
safe_draw(layout, "Material", 'MATERIAL', 'MATERIAL') draw_entry("Material", 'MATERIAL', 'MATERIAL')
layout.separator() layout.separator()
safe_draw(layout, "Texture", 'TEXTURE', 'TEXTURE') draw_entry("Texture", 'TEXTURE', 'TEXTURE')
class PIE_MT_editor_switch_image(Menu): class PIE_MT_editor_switch_image(Menu):
bl_idname = "PIE_MT_editor_switch_image" bl_idname = "PIE_MT_editor_switch_image"
bl_label = "Editor Switch: 2D Editors" bl_label = "Editor Switch: 2D Editors"
def draw(self, context): def draw(self, _context: Context):
pie = self.layout.menu_pie() pie = self.layout.menu_pie()
# 4 - LEFT # 4 - LEFT
op = pie.operator( op = pie.operator(WM_OT_set_area_type.bl_idname, text="Image Viewer", icon="SEQ_PREVIEW")
WM_OT_set_area_type.bl_idname, text="Image Viewer", icon="SEQ_PREVIEW"
)
op.ui_type = "IMAGE_EDITOR" op.ui_type = "IMAGE_EDITOR"
op.ui_mode = "VIEW" op.ui_mode = "VIEW"
# 6 - RIGHT # 6 - RIGHT
@@ -420,17 +242,13 @@ class PIE_MT_editor_switch_image(Menu):
# 8 - TOP # 8 - TOP
pie.separator() pie.separator()
# 7 - TOP - LEFT # 7 - TOP - LEFT
op = pie.operator( op = pie.operator(WM_OT_set_area_type.bl_idname, text="Image Paint", icon="TPAINT_HLT")
WM_OT_set_area_type.bl_idname, text="Image Paint", icon="TPAINT_HLT"
)
op.ui_type = "IMAGE_EDITOR" op.ui_type = "IMAGE_EDITOR"
op.ui_mode = "PAINT" op.ui_mode = "PAINT"
# 9 - TOP - RIGHT # 9 - TOP - RIGHT
pie.separator() pie.separator()
# 1 - BOTTOM - LEFT # 1 - BOTTOM - LEFT
op = pie.operator( op = pie.operator(WM_OT_set_area_type.bl_idname, text="Image Masking", icon="MOD_MASK")
WM_OT_set_area_type.bl_idname, text="Image Masking", icon="MOD_MASK"
)
op.ui_type = "IMAGE_EDITOR" op.ui_type = "IMAGE_EDITOR"
op.ui_mode = "PAINT" op.ui_mode = "PAINT"
# 3 - BOTTOM - RIGHT # 3 - BOTTOM - RIGHT
@@ -440,23 +258,17 @@ class PIE_MT_editor_switch_script(Menu):
bl_idname = "PIE_MT_editor_switch_script" bl_idname = "PIE_MT_editor_switch_script"
bl_label = "Editor Switch: Script" bl_label = "Editor Switch: Script"
def draw(self, context): def draw(self, _context: Context):
pie = self.layout.menu_pie() pie = self.layout.menu_pie()
# 4 - LEFT # 4 - LEFT
pie.separator() pie.separator()
# 6 - RIGHT # 6 - RIGHT
pie.operator( pie.operator(WM_OT_set_area_type.bl_idname, text="Text Editor", icon="TEXT").ui_type = "TEXT_EDITOR"
WM_OT_set_area_type.bl_idname, text="Text Editor", icon="TEXT"
).ui_type = "TEXT_EDITOR"
# 2 - BOTTOM # 2 - BOTTOM
pie.operator( pie.operator(WM_OT_set_area_type.bl_idname, text="Python Console", icon="CONSOLE").ui_type = "CONSOLE"
WM_OT_set_area_type.bl_idname, text="Python Console", icon="CONSOLE"
).ui_type = "CONSOLE"
# 8 - TOP # 8 - TOP
pie.operator( pie.operator(WM_OT_set_area_type.bl_idname, text="Info", icon="INFO").ui_type = "INFO"
WM_OT_set_area_type.bl_idname, text="Info", icon="INFO"
).ui_type = "INFO"
# 7 - TOP - LEFT # 7 - TOP - LEFT
# 9 - TOP - RIGHT # 9 - TOP - RIGHT
# 1 - BOTTOM - LEFT # 1 - BOTTOM - LEFT
@@ -467,26 +279,20 @@ class PIE_MT_editor_switch_anim(Menu):
bl_idname = "PIE_MT_editor_switch_anim" bl_idname = "PIE_MT_editor_switch_anim"
bl_label = "Editor Switch: Animation" bl_label = "Editor Switch: Animation"
def draw(self, context): def draw(self, _context: Context):
layout = self.layout layout = self.layout
pie = layout.menu_pie() pie = layout.menu_pie()
# 4 - LEFT # 4 - LEFT
op = pie.operator( op = pie.operator(WM_OT_set_area_type.bl_idname, text="Grease Pencil", icon="GREASEPENCIL")
WM_OT_set_area_type.bl_idname, text="Grease Pencil", icon="GREASEPENCIL"
)
op.ui_type = 'DOPESHEET' op.ui_type = 'DOPESHEET'
op.ui_mode = 'GPENCIL' op.ui_mode = 'GPENCIL'
# 6 - RIGHT # 6 - RIGHT
op = pie.operator( op = pie.operator(WM_OT_set_area_type.bl_idname, text="Dopesheet", icon="ACTION")
WM_OT_set_area_type.bl_idname, text="Dopesheet", icon="ACTION"
)
op.ui_type = 'DOPESHEET' op.ui_type = 'DOPESHEET'
op.ui_mode = 'DOPESHEET' op.ui_mode = 'DOPESHEET'
# 2 - BOTTOM # 2 - BOTTOM
op = pie.operator( op = pie.operator(WM_OT_set_area_type.bl_idname, text="Driver Editor", icon="DRIVER")
WM_OT_set_area_type.bl_idname, text="Driver Editor", icon="DRIVER"
)
op.ui_type = 'DRIVERS' op.ui_type = 'DRIVERS'
# 8 - TOP # 8 - TOP
op = pie.operator(WM_OT_set_area_type.bl_idname, text="Timeline", icon="TIME") 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 # 7 - TOP - LEFT
pie.separator() pie.separator()
# 9 - TOP - RIGHT # 9 - TOP - RIGHT
op = pie.operator( op = pie.operator(WM_OT_set_area_type.bl_idname, text="Graph Editor", icon="GRAPH")
WM_OT_set_area_type.bl_idname, text="Graph Editor", icon="GRAPH"
)
op.ui_type = 'FCURVES' op.ui_type = 'FCURVES'
# 1 - BOTTOM - LEFT # 1 - BOTTOM - LEFT
op = pie.operator(WM_OT_set_area_type.bl_idname, text="NLA Editor", icon="NLA") op = pie.operator(WM_OT_set_area_type.bl_idname, text="NLA Editor", icon="NLA")
op.ui_type = 'NLA_EDITOR' op.ui_type = 'NLA_EDITOR'
# 3 - BOTTOM - RIGHT # 3 - BOTTOM - RIGHT
op = pie.operator( op = pie.operator(WM_OT_set_area_type.bl_idname, text="Action Editor", icon="OBJECT_DATAMODE")
WM_OT_set_area_type.bl_idname, text="Action Editor", icon="OBJECT_DATAMODE"
)
op.ui_type = 'DOPESHEET' op.ui_type = 'DOPESHEET'
op.ui_mode = 'ACTION' op.ui_mode = 'ACTION'
@@ -525,7 +327,7 @@ class WM_OT_set_area_type(Operator):
mode: StringProperty(default="", options={'SKIP_SAVE'}) mode: StringProperty(default="", options={'SKIP_SAVE'})
view: 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 context.area.ui_type = self.ui_type
if self.view_type: if self.view_type:
context.space_data.view_type = 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 context.space_data.mode = self.mode
if self.view: if self.view:
context.space_data.view = self.view context.space_data.view = self.view
return {'FINISHED'} 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 = [ registry = [
PIE_MT_editor_switch, PIE_MT_editor_switch,
PIE_MT_editor_switch_video, 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 # SPDX-License-Identifier: GPL-3.0-or-later
import bpy import bpy
from bpy.types import Menu, Operator
from bpy.props import BoolProperty from bpy.props import BoolProperty
from bpy.types import Context, Menu, Operator
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only 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_idname = "PIE_MT_file"
bl_label = "File" bl_label = "File"
def draw(self, context): def draw(self, _context: Context):
layout = self.layout layout = self.layout
pie = layout.menu_pie() pie = layout.menu_pie()
# 4 - LEFT # 4 - LEFT
@@ -25,13 +25,9 @@ class PIE_MT_file(Menu):
# 8 - TOP # 8 - TOP
pie.menu("TOPBAR_MT_file_import", text="Import...", icon='IMPORT') pie.menu("TOPBAR_MT_file_import", text="Import...", icon='IMPORT')
# 7 - TOP - LEFT # 7 - TOP - LEFT
pie.operator('wm.call_menu_pie', text="Library...", icon='LINK_BLEND').name = ( pie.operator('wm.call_menu_pie', text="Library...", icon='LINK_BLEND').name = "PIE_MT_library"
"PIE_MT_library"
)
# 9 - TOP - RIGHT # 9 - TOP - RIGHT
pie.operator( pie.operator("wm.save_mainfile", text="Save Incremental", icon='FILE_TICK').incremental = True
"wm.save_mainfile", text="Save Incremental", icon='FILE_TICK'
).incremental = True
# 1 - BOTTOM - LEFT # 1 - BOTTOM - LEFT
pie.menu("TOPBAR_MT_file_open_recent", icon='FILE_FOLDER') pie.menu("TOPBAR_MT_file_open_recent", icon='FILE_FOLDER')
# 3 - BOTTOM - RIGHT # 3 - BOTTOM - RIGHT
@@ -42,7 +38,7 @@ class PIE_MT_library(Menu):
bl_idname = "PIE_MT_library" bl_idname = "PIE_MT_library"
bl_label = "Library" bl_label = "Library"
def draw(self, context): def draw(self, _context: Context):
pie = self.layout.menu_pie() pie = self.layout.menu_pie()
# 4 - LEFT # 4 - LEFT
@@ -50,29 +46,17 @@ class PIE_MT_library(Menu):
# 6 - RIGHT # 6 - RIGHT
pie.operator("wm.append", text="Append", icon='APPEND_BLEND') pie.operator("wm.append", text="Append", icon='APPEND_BLEND')
# 2 - BOTTOM # 2 - BOTTOM
pie.operator( pie.operator("object.make_local", text="Make All Local", icon='FILE_BLEND').type = 'ALL'
"object.make_local", text="Make All Local", icon='FILE_BLEND'
).type = 'ALL'
# 8 - TOP # 8 - TOP
pie.prop(bpy.data, 'use_autopack', text="Auto-pack") pie.prop(bpy.data, 'use_autopack', text="Auto-pack")
# 7 - TOP - LEFT # 7 - TOP - LEFT
pie.operator( pie.operator("file.pack_res_and_lib", text="Pack All Local", icon='PACKAGE').pack = True
"file.pack_res_and_lib", text="Pack All Local", icon='PACKAGE'
).pack = True
# 9 - TOP - RIGHT # 9 - TOP - RIGHT
pie.operator( pie.operator("file.pack_res_and_lib", text="Unpack All Local", icon='UGLYPACKAGE').pack = False
"file.pack_res_and_lib", text="Unpack All Local", icon='UGLYPACKAGE'
).pack = False
# 1 - BOTTOM - LEFT # 1 - BOTTOM - LEFT
pie.operator( pie.operator("file.make_paths_relative", icon='FILE', text="Make Paths Relative")
"file.make_paths_relative", icon='FILE', text="Make Paths Relative"
)
# 3 - BOTTOM - RIGHT # 3 - BOTTOM - RIGHT
pie.operator( pie.operator("file.make_paths_absolute", icon='LIBRARY_DATA_BROKEN', text="Make Paths Absolute")
"file.make_paths_absolute",
icon='LIBRARY_DATA_BROKEN',
text="Make Paths Absolute",
)
class WM_OT_pack_res_and_lib(Operator): class WM_OT_pack_res_and_lib(Operator):
@@ -84,17 +68,21 @@ class WM_OT_pack_res_and_lib(Operator):
pack: BoolProperty() pack: BoolProperty()
def execute(self, context): def execute(self, _context: Context):
if self.pack: if self.pack:
bpy.ops.file.pack_libraries() bpy.ops.file.pack_libraries()
bpy.ops.file.pack_all() bpy.ops.file.pack_all()
else: else:
bpy.ops.file.unpack_libraries() bpy.ops.file.unpack_libraries()
bpy.ops.file.unpack_all() bpy.ops.file.unpack_all()
return {'FINISHED'} return {'FINISHED'}
def draw_revert(self, _context: Context):
self.layout.operator('wm.revert_mainfile', icon='LOOP_BACK')
self.layout.separator()
registry = [ registry = [
PIE_MT_file, PIE_MT_file,
PIE_MT_library, 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(): def register():
WM_OT_call_menu_pie_drag_only.register_drag_hotkey( WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
keymap_name="Window", keymap_name="Window",
@@ -115,7 +98,6 @@ def register():
default_fallback_op='wm.save_mainfile', default_fallback_op='wm.save_mainfile',
on_drag=True, on_drag=True,
) )
bpy.types.TOPBAR_MT_file_recover.prepend(draw_revert) 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 # SPDX-License-Identifier: GPL-3.0-or-later
from bpy.types import Menu, Operator from bpy.props import BoolProperty, EnumProperty
from bpy.props import EnumProperty, BoolProperty from bpy.types import Context, Event, Menu, Operator
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only 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_idname = "PIE_MT_manipulator"
bl_label = "Manipulator" bl_label = "Manipulator"
def draw(self, context): def draw(self, context: Context):
layout = self.layout layout = self.layout
pie = layout.menu_pie() pie = layout.menu_pie()
space = context.space_data space = context.space_data
# 4 - LEFT # 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 'view3d.set_manipulator', text="Scale", icon='CON_SIZELIKE', depress=space.show_gizmo_object_scale
).manipulator = 'SCALE' ).manipulator = 'SCALE'
# 2 - BOTTOM # 2 - BOTTOM
pie.operator( pie.operator('view3d.set_manipulator', text="None", icon='X').manipulator = 'NONE'
'view3d.set_manipulator', text="None", icon='X'
).manipulator='NONE'
# 8 - TOP # 8 - TOP
pie.operator( pie.operator(
'view3d.set_manipulator', text="Location", icon='CON_LOCLIKE', depress=space.show_gizmo_object_translate 'view3d.set_manipulator', text="Location", icon='CON_LOCLIKE', depress=space.show_gizmo_object_translate
).manipulator = 'LOC' ).manipulator = 'LOC'
# 7 - TOP - LEFT
# 7 - TOP-LEFT
pie.operator( pie.operator(
'view3d.set_manipulator', text="Loc/Rot", icon='CON_LOCLIKE', depress=space.show_gizmo_object_translate and space.show_gizmo_object_rotate 'view3d.set_manipulator',
).manipulator='LOCROT' text="Loc/Rot",
# 9 - TOP-RIGHT icon='CON_LOCLIKE',
depress=space.show_gizmo_object_translate and space.show_gizmo_object_rotate,
).manipulator = 'LOCROT'
# 9 - TOP - RIGHT
pie.operator( pie.operator(
'view3d.set_manipulator', text="Loc/Scale", icon='CON_SIZELIKE', depress=space.show_gizmo_object_translate and space.show_gizmo_object_scale 'view3d.set_manipulator',
).manipulator='LOCSCALE' text="Loc/Scale",
# 1 - BOT-LEFT icon='CON_SIZELIKE',
depress=space.show_gizmo_object_translate and space.show_gizmo_object_scale,
).manipulator = 'LOCSCALE'
# 1 - BOTTOM - LEFT
pie.operator( 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' ).manipulator = 'LOCROTSCALE'
@@ -75,19 +80,19 @@ class VIEW3D_OT_set_manipulator(Operator):
name="Toggle", name="Toggle",
description="Hold Shift to toggle the selected manipulator rather than setting it as the only active one", description="Hold Shift to toggle the selected manipulator rather than setting it as the only active one",
default=False, default=False,
options={'SKIP_SAVE'} options={'SKIP_SAVE'},
) )
@classmethod @classmethod
def poll(cls, context): def poll(cls, context: Context) -> bool:
space = context.space_data space = context.space_data
return space and space.type == 'VIEW_3D' return space and space.type == 'VIEW_3D'
def invoke(self, context, event): def invoke(self, context: Context, event: Event):
self.toggle = event.shift self.toggle = event.shift
return self.execute(context) return self.execute(context)
def execute(self, context): def execute(self, context: Context):
space = context.space_data space = context.space_data
if self.manipulator != 'NONE': 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 # 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 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_idname = "PIE_MT_mesh_delete"
bl_label = "Mesh Delete" bl_label = "Mesh Delete"
def draw(self, context): def draw(self, _context: Context):
layout = self.layout layout = self.layout
pie = layout.menu_pie() pie = layout.menu_pie()
# 4 - LEFT # 4 - LEFT
box = pie.split().column() box = pie.split().column()
box.operator( box.operator("mesh.dissolve_limited", text="Limited Dissolve", icon='STICKY_UVS_LOC')
"mesh.dissolve_limited", text="Limited Dissolve", icon='STICKY_UVS_LOC'
)
box.operator("mesh.delete_edgeloop", text="Delete Edge Loops", icon='NONE') box.operator("mesh.delete_edgeloop", text="Delete Edge Loops", icon='NONE')
box.operator("mesh.edge_collapse", text="Edge Collapse", icon='UV_EDGESEL') box.operator("mesh.edge_collapse", text="Edge Collapse", icon='UV_EDGESEL')
# 6 - RIGHT # 6 - RIGHT
box = pie.split().column() box = pie.split().column()
box.operator("mesh.remove_doubles", text="Merge By Distance", icon='NONE') box.operator("mesh.remove_doubles", text="Merge By Distance", icon='NONE')
box.operator("mesh.delete", text="Only Edge & Faces", icon='NONE').type = ( box.operator("mesh.delete", text="Only Edge & Faces", icon='NONE').type = 'EDGE_FACE'
'EDGE_FACE' box.operator("mesh.delete", text="Only Faces", icon='UV_FACESEL').type = 'ONLY_FACE'
)
box.operator("mesh.delete", text="Only Faces", icon='UV_FACESEL').type = (
'ONLY_FACE'
)
# 2 - BOTTOM # 2 - BOTTOM
pie.operator("mesh.dissolve_edges", text="Dissolve Edges", icon='SNAP_EDGE') pie.operator("mesh.dissolve_edges", text="Dissolve Edges", icon='SNAP_EDGE')
# 8 - TOP # 8 - TOP
pie.operator("mesh.delete", text="Delete Edges", icon='EDGESEL').type = 'EDGE' pie.operator("mesh.delete", text="Delete Edges", icon='EDGESEL').type = 'EDGE'
# 7 - TOP - LEFT # 7 - TOP - LEFT
pie.operator("mesh.delete", text="Delete Vertices", icon='VERTEXSEL').type = ( pie.operator("mesh.delete", text="Delete Vertices", icon='VERTEXSEL').type = 'VERT'
'VERT'
)
# 9 - TOP - RIGHT # 9 - TOP - RIGHT
pie.operator("mesh.delete", text="Delete Faces", icon='FACESEL').type = 'FACE' pie.operator("mesh.delete", text="Delete Faces", icon='FACESEL').type = 'FACE'
# 1 - BOTTOM - LEFT # 1 - BOTTOM - LEFT
pie.operator( pie.operator("mesh.dissolve_verts", text="Dissolve Vertices", icon='SNAP_VERTEX')
"mesh.dissolve_verts", text="Dissolve Vertices", icon='SNAP_VERTEX'
)
# 3 - BOTTOM - RIGHT # 3 - BOTTOM - RIGHT
pie.operator("mesh.dissolve_faces", text="Dissolve Faces", icon='SNAP_FACE') 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 # SPDX-License-Identifier: GPL-3.0-or-later
import bpy import bpy
from bpy.types import Menu, Operator
from bpy.props import EnumProperty from bpy.props import EnumProperty
from bpy.types import Context, Menu, Operator
from .op_pie_wrappers import WM_OT_call_menu_pie_drag_only 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_idname = "PIE_MT_mesh_flatten"
bl_label = "Mesh Flatten" bl_label = "Mesh Flatten"
def draw(self, context): def draw(self, _context: Context):
layout = self.layout layout = self.layout
pie = layout.menu_pie() pie = layout.menu_pie()
# 4 - LEFT # 4 - LEFT
@@ -65,11 +65,11 @@ class TRANSFORM_OT_flatten_to_center(Operator):
) )
@classmethod @classmethod
def poll(cls, context): def poll(cls, context: Context):
obj = context.active_object obj = context.active_object
return obj and obj.type == "MESH" return obj and obj.type == "MESH"
def execute(self, context): def execute(self, _context: Context):
values = { values = {
'X': [(0, 1, 1), (True, False, False)], 'X': [(0, 1, 1), (True, False, False)],
'Y': [(1, 0, 1), (False, True, False)], 'Y': [(1, 0, 1), (False, True, False)],
@@ -106,11 +106,11 @@ class TRANSFORM_OT_flatten_to_object_origin(Operator):
) )
@classmethod @classmethod
def poll(cls, context): def poll(cls, context: Context):
obj = context.active_object obj = context.active_object
return obj and obj.type == "MESH" return obj and obj.type == "MESH"
def execute(self, context): def execute(self, _context: Context):
bpy.ops.object.mode_set(mode='OBJECT') bpy.ops.object.mode_set(mode='OBJECT')
index = "XYZ".find(self.axis) index = "XYZ".find(self.axis)
for vert in bpy.context.object.data.vertices: for vert in bpy.context.object.data.vertices:
@@ -149,11 +149,11 @@ class TRANSFORM_OT_flatten_to_selection_bounding_box(Operator):
) )
@classmethod @classmethod
def poll(cls, context): def poll(cls, context: Context):
obj = context.active_object obj = context.active_object
return obj and obj.type == "MESH" return obj and obj.type == "MESH"
def execute(self, context): def execute(self, _context: Context):
bpy.ops.object.mode_set(mode='OBJECT') bpy.ops.object.mode_set(mode='OBJECT')
count = 0 count = 0
axis_idx = "XYZ".find(self.axis) axis_idx = "XYZ".find(self.axis)

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