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
@@ -0,0 +1,144 @@
import bpy
from .modifier import (
is_boolean_modifier,
)
from .object import (
is_linked,
change_parent,
)
#### ------------------------------ /poll/ ------------------------------ ####
def is_canvas(obj):
"""Checks whether the object is a Boolean canvas (i.e. has Boolean cutters)."""
if obj.booleans.canvas == False:
return False
else:
# Even if object is marked as a canvas, check if it actually has any cutters.
cutters, __ = list_canvas_cutters([obj])
if len(cutters) > 0:
return True
else:
return False
#### ------------------------------ /list/ ------------------------------ ####
def list_all_canvases(scene):
"""Returns the list of all Boolean canvases in the scene."""
canvases = []
for obj in scene.objects:
if is_canvas(obj):
canvases.append(obj)
return canvases
def list_selected_canvases(context):
"""Returns the list of canvases in the selection."""
canvases = []
active_object = context.active_object
selected_objects = context.selected_objects
if selected_objects:
for obj in selected_objects:
if obj == active_object:
continue
if obj.type != 'MESH':
continue
if is_canvas(obj):
canvases.append(obj)
if active_object:
if is_canvas(active_object):
canvases.append(active_object)
return canvases
def list_canvas_cutters(canvases: list) -> tuple[list, dict]:
"""List cutters (and their associated modifiers) that are used by specified canvases."""
cutters = []
modifiers = {}
for canvas in canvases:
for mod in canvas.modifiers:
if not is_boolean_modifier(mod):
continue
if mod.object not in cutters:
cutters.append(mod.object)
modifiers.setdefault(canvas, []).append(mod)
return cutters, modifiers
def list_canvas_slices(context, canvases: list):
"""Returns the list of slices of specified canvases."""
slices = []
for obj in context.scene.objects:
if obj.booleans.slice:
if obj.booleans.slice_of in canvases:
slices.append(obj)
return slices
#### ------------------------------ /filter/ ------------------------------ ####
def filter_canvases(self, context, canvases: list) -> list:
"""Filter out objects from the given list if they can't be cut."""
usable_canvases = []
for canvas in canvases:
# Exclude non-Mesh types.
if canvas.type != 'MESH':
self.report({'WARNING'}, f"{canvas.name} is not a Mesh type. Only Meshes can be cut")
continue
# Exclude linked objects.
if is_linked(context, canvas):
self.report({'WARNING'}, f"{canvas.name} is linked and can not be used as a cutter")
continue
usable_canvases.append(canvas)
return usable_canvases
#### ------------------------------ /filter/ ------------------------------ ####
def create_slice(context, canvas, modifier=False):
"""Creates copy of canvas to be used as slice."""
slice = canvas.copy()
slice.data = canvas.data.copy()
slice.name = slice.data.name = canvas.name + "_slice"
# Parent to canvas.
change_parent(context, slice, canvas, inverse=True)
# Set Boolean properties.
if modifier == True:
slice.booleans.canvas = True
slice.booleans.slice = True
slice.booleans.slice_of = canvas
# Add to canvas collections.
for coll in canvas.users_collection:
coll.objects.link(slice)
# Add slices to local view.
if context.space_data.local_view:
slice.local_view_set(context.space_data, True)
return slice
@@ -0,0 +1,188 @@
import bpy
from .. import __package__ as base_package
from ..constants import (
CONVERTABLE_TYPES,
)
from .modifier import (
is_boolean_modifier,
)
from .object import (
is_linked,
has_evaluated_mesh,
object_visibility_set,
convert_to_mesh,
change_parent,
delete_object,
)
from .scene import (
ensure_collection,
)
#### ------------------------------ /list/ ------------------------------ ####
def list_selected_cutters(context):
"""Returns the list of cutters in the selection."""
cutters = []
active_object = context.active_object
selected_objects = context.selected_objects
if selected_objects:
for obj in selected_objects:
if obj == active_object:
continue
if obj.type != 'MESH':
continue
if obj.booleans.cutter:
cutters.append(obj)
if active_object:
if active_object.booleans.cutter:
cutters.append(active_object)
return cutters
def list_cutter_users(cutters: list, exclude: list=None) -> dict:
"""
List canvases that use specified cutters.
Canvases that should be excluded from the search can be specified with the `exclude` arg.
Returns a dict of canvases (keys) and set of their Boolean modifiers that use cutters (values).
"""
cutter_users = {}
for key, values in bpy.data.user_map(subset=cutters).items():
for value in values:
if value.id_type != 'OBJECT':
continue
if exclude and value in exclude:
continue
if len(value.modifiers) == 0:
continue
for mod in value.modifiers:
if not is_boolean_modifier(mod):
continue
if mod.object not in cutters:
continue
cutter_users.setdefault(value, set()).add(mod)
return cutter_users
#### ------------------------------ /filter/ ------------------------------ ####
def filter_cutters(self, context, cutters: list, canvases: list) -> list:
"""
Filter out objects from the given list if they can't be used as a cutter.
If non-mesh type object has evaluated mesh, and can be converted to mesh it will be.
"""
usable_cutters = []
for cutter in cutters:
# Exclude object if it is in both lists.
if cutter in canvases:
continue
# Exclude linked objects.
if is_linked(context, cutter):
self.report({'WARNING'}, f"{cutter.name} is linked and can not be used as a cutter")
continue
if cutter.type == 'MESH':
# Exclude if object is already a cutter for canvas.
users = list_cutter_users([cutter]).keys()
if any(canvas in users for canvas in canvases):
continue
# Exclude if canvas is cutting the object (avoid dependancy loop).
users = list_cutter_users(canvases).keys()
if cutter in users:
self.report({'WARNING'}, f"{cutter.name} can not cut its own cutter (dependancy loop)")
continue
usable_cutters.append(cutter)
elif cutter.type in CONVERTABLE_TYPES:
if not has_evaluated_mesh(context, cutter):
continue
convert_to_mesh(context, cutter)
usable_cutters.append(cutter)
return usable_cutters
#### ------------------------------ /operate/ ------------------------------ ####
def make_cutter(context, cutter, mode: str, display='BOUNDS', collection=True):
"""Ensures the cutter has the correct properties."""
# Hide Cutters
cutter.hide_render = True
cutter.display_type = display
cutter.lineart.usage = 'EXCLUDE'
object_visibility_set(cutter, value=False)
# Cutters Collection
if collection:
cutters_collection = ensure_collection(context)
if cutters_collection not in cutter.users_collection:
cutters_collection.objects.link(cutter)
# Set Boolean Property
cutter.booleans.cutter = mode.capitalize()
def restore_cutter(context, cutter, unparent=True, unlink_collection=True):
"""Remove Boolean properties from a cutter object to restore it to a normal state."""
prefs = context.preferences.addons[base_package].preferences
# Restore Unused Cutters
cutter.hide_render = False
cutter.display_type = 'TEXTURED'
cutter.lineart.usage = 'INHERIT'
object_visibility_set(cutter, value=True)
cutter.booleans.cutter = ""
# Remove Parent & Collection
if unparent:
change_parent(context, cutter, None)
if unlink_collection:
cutters_collection = bpy.data.collections.get(prefs.collection_name)
if cutters_collection in cutter.users_collection:
cutters_collection.objects.unlink(cutter)
def handle_unused_cutters(context, cutters: list, canvases: list, delete=True):
"""Deletes or restores cutters with no remaining users (besides given list of canvases)."""
prefs = context.preferences.addons[base_package].preferences
for cutter in cutters:
other_canvases = list_cutter_users([cutter], exclude=canvases).keys()
if len(other_canvases) == 0:
if delete:
# Delete unused cutters & transfer their children to canvas.
for child in cutter.children:
change_parent(context, child, cutter.parent, inverse=True)
delete_object(cutter)
else:
# Restore unused cutters.
restore_cutter(context, cutter,
unparent=prefs.parent and cutter.parent in canvases,
unlink_collection=prefs.use_collection)
else:
# Cutter has other users, parent it to one of them.
if prefs.parent and cutter.parent in canvases:
new_parent = next(c for c in other_canvases if not c.booleans.slice)
change_parent(context, cutter, new_parent, inverse=True)
@@ -10,7 +10,7 @@ from gpu_extras.batch import batch_for_shader
#### ------------------------------ FUNCTIONS ------------------------------ ####
def draw_shader(type, color, alpha, coords, size=1, indices=None):
"""Creates a batch for a draw type"""
"""Sets up and draws a batch for a given GPU shader type."""
gpu.state.blend_set('ALPHA')
@@ -48,9 +48,10 @@ def draw_shader(type, color, alpha, coords, size=1, indices=None):
gpu.state.blend_set('NONE')
def draw_bmesh_faces(faces, world_matrix):
def draw_bmesh_faces(faces, world_matrix) -> tuple[list, list]:
"""
Get world-space vertex pairs and indices from `bmesh` face. To be used in GPU batch.
Gets world-space vertex pairs and indices from `bmesh` faces.
Returns the list of 3D vertices and the list of triangle indices to be used in GPU batch.
Adapted from "Blockout" extension by niewinny (https://github.com/niewinny/blockout).
"""
@@ -65,13 +66,13 @@ def draw_bmesh_faces(faces, world_matrix):
for face in faces:
face_indices = []
# Collect unique vertices only (avoid storing verts that are shared by faces multiple times).
# (Iterating over face corners because unlike `face.verts` they're ordered).
# Collect unique vertices only (avoid storing verts that are shared by multiple faces).
# (NOTE: Iterating over face corners because unlike `face.verts` they're ordered).
for loop in face.loops:
vert = loop.vert
co = world_matrix @ Vector(vert.co)
if vert not in vert_index_map:
co = world_matrix @ Vector(vert.co)
vertices.append(co)
vert_index_map[vert] = vert_count
face_indices.append(vert_count)
@@ -94,46 +95,28 @@ def draw_bmesh_faces(faces, world_matrix):
return vertices, indices
def draw_bmesh_edges(edges, world_matrix):
"""Convert bmesh edges into world-space vertex pairs to be used in GPU batch."""
if not edges:
return None
vertices = []
for edge in edges:
v1 = world_matrix @ edge.verts[0].co
v2 = world_matrix @ edge.verts[1].co
vertices.append(v1)
vertices.append(v2)
return vertices
def draw_circle_around_point(context, obj, vert, radius, segments):
def draw_circle_billboard(context, origin: Vector, radius: float, segments: int) -> list:
"""
Draws the screen-aligned circle around given vertex of the object.
Returns the list of vertices for GPU batch.
Draws a view-facing circle in the world-space around the given origin Vector.
Returns the list of 3D vertices.
"""
region = context.region
rv3d = context.region_data
vert_world = obj.matrix_world @ vert.co
origin_2d = view3d_utils.location_3d_to_region_2d(region, rv3d, origin)
radius = min(radius, 25)
vertices = []
for i in range(segments + 1):
angle = i * (2 * math.pi / segments)
# Calculate offset and vertex position in screen-space.
# Add offset in screen-space.
offset_x = radius * math.cos(angle)
offset_y = radius * math.sin(angle)
vert_screen = view3d_utils.location_3d_to_region_2d(region, rv3d, vert_world)
vert_2d = Vector((origin_2d.x + offset_x, origin_2d.y + offset_y))
if vert_screen:
# Add offset in screen-space and convert back to world-space.
circle_screen = Vector((vert_screen.x + offset_x, vert_screen.y + offset_y))
circle_3d = view3d_utils.region_2d_to_location_3d(region, rv3d, circle_screen, vert_world)
vertices.append(circle_3d)
# Convert back to world-space.
vert_3d = view3d_utils.region_2d_to_location_3d(region, rv3d, vert_2d, origin)
vertices.append(vert_3d)
return vertices
@@ -1,162 +0,0 @@
import bpy
#### ------------------------------ /all/ ------------------------------ ####
def list_canvases():
"""List all canvases in the scene"""
canvas = []
for obj in bpy.context.scene.objects:
if obj.booleans.canvas:
canvas.append(obj)
return canvas
#### ------------------------------ /selected/ ------------------------------ ####
def list_selected_cutters(context):
"""List selected cutters"""
cutters = []
active_object = context.active_object
selected_objects = context.selected_objects
if selected_objects:
for obj in selected_objects:
if obj != active_object and obj.type == 'MESH':
if obj.booleans.cutter:
cutters.append(obj)
if active_object:
if active_object.booleans.cutter:
cutters.append(active_object)
return cutters
def list_selected_canvases(context):
"""List selected canvases"""
canvases = []
active_object = context.active_object
selected_objects = context.selected_objects
if selected_objects:
for obj in selected_objects:
if obj != active_object and obj.type == 'MESH':
if obj.booleans.canvas:
canvases.append(obj)
if active_object:
if active_object.booleans.canvas:
canvases.append(active_object)
return canvases
#### ------------------------------ /users/ ------------------------------ ####
def list_canvas_cutters(canvases):
"""List cutters that are used by specified canvases"""
cutters = []
modifiers = []
for canvas in canvases:
for mod in canvas.modifiers:
if mod.type == 'BOOLEAN' and "boolean_" in mod.name:
if mod.object:
cutters.append(mod.object)
modifiers.append(mod)
return cutters, modifiers
def list_canvas_slices(canvases):
"""Returns list of slices for specified canvases"""
slices = []
for obj in bpy.context.scene.objects:
if obj.booleans.slice:
if obj.booleans.slice_of in canvases:
slices.append(obj)
return slices
def list_cutter_users(cutters):
"""List canvases that use specified cutters"""
cutter_users = []
for cutter in cutters:
object = bpy.data.objects.get(cutter.name)
for key, values in bpy.data.user_map(subset=[object]).items():
for value in values:
# filter_only_object_type_users
if value.id_type == 'OBJECT':
for mod in value.modifiers:
if mod.type == 'BOOLEAN':
if mod.object and mod.object == cutter:
cutter_users.append(value)
return cutter_users
def list_cutter_modifiers(canvases, cutters):
"""List modifiers on specified canvases that use specified cutters"""
if not canvases:
canvases = list_canvases()
modifiers = []
for canvas in canvases:
for mod in canvas.modifiers:
if mod.type == 'BOOLEAN':
if mod.object in cutters:
modifiers.append(mod)
return modifiers
def list_unused_cutters(cutters, *canvases, do_leftovers=False):
"""Takes in list of cutters and returns only those that have no other user besides specified canvas"""
"""When `include_visible` is True it will return cutters that aren't used by any visible modifiers"""
other_canvases = list_canvases()
original_cutters = cutters[:]
for obj in other_canvases:
if obj in canvases:
return
if any(mod.object in cutters for mod in obj.modifiers if mod.type == 'BOOLEAN'):
cutters[:] = [cutter for cutter in cutters if cutter not in [mod.object for mod in obj.modifiers]]
leftovers = []
# return_cutters_that_do_have_other_users_(so_that_parents_can_be_reassigned)
if do_leftovers:
leftovers = [cutter for cutter in original_cutters if cutter not in cutters]
return cutters, leftovers
def list_pre_boolean_modifiers(obj) -> list:
"""Returns a list of boolean modifiers & modifiers that come before last boolean modifier"""
# Find the index of a last boolean modifier
last_boolean_index = -1
for i in reversed(range(len(obj.modifiers))):
if obj.modifiers[i].type == 'BOOLEAN':
last_boolean_index = i
break
# If boolean modifier is found, list all modifiers that come before it.
if last_boolean_index != -1:
return [mod for mod in obj.modifiers[:last_boolean_index + 1]]
else:
return []
@@ -1,77 +0,0 @@
import bpy
import mathutils
from mathutils import Vector
from bpy_extras import view3d_utils
#### ------------------------------ FUNCTIONS ------------------------------ ####
def distance_from_point_to_segment(point, start, end) -> float:
"""
Calculates the shortest distance between a point and a segment.
All three inputs should be `mathutils.Vector` objects.
This is an alternative to `mathutils.geometry.intersect_point_line`.
Adapted from "Blockout" extension by niewinny (https://github.com/niewinny/blockout).
"""
segment = end - start
start_to_point = point - start
# projection_along_segment
c1 = start_to_point.dot(segment)
if c1 <= 0:
return (point - start).length
# segment_length_squared
c2 = segment.dot(segment)
if c2 <= c1:
return (point - end).length
t = c1 / c2
closest_point = start + t * segment
distance = (point - closest_point).length
return distance
def region_2d_to_line_3d(region, rv3d, point_2d: Vector, line_origin: Vector, line_direction: Vector) -> tuple[Vector, Vector]:
"""
Converts a 2D screen-space point into a 3D ray and finds closest
points between that ray and a given 3D line.
"""
if line_origin is None or line_direction is None:
return None, None
# Convert the screen-space 2D point Vector into a world-space 3D ray (origin + direction).
ray_origin = view3d_utils.region_2d_to_origin_3d(region, rv3d, point_2d)
ray_direction = view3d_utils.region_2d_to_vector_3d(region, rv3d, point_2d)
# Find closest points to each other on each line (second line being a ray).
closest_points = mathutils.geometry.intersect_line_line(ray_origin,
ray_origin + ray_direction,
line_origin,
line_origin + line_direction)
return closest_points
def region_2d_to_plane_3d(region, rv3d, point_2d: Vector, plane: tuple[Vector]) -> Vector:
"""
Converts a 2D screen-space point into a 3D point on a plane in world-space.
Adapted from "Blockout" extension by niewinny (https://github.com/niewinny/blockout).
"""
location, normal = plane
# Convert the screen-space 2D point Vector into a world-space 3D ray (origin + direction).
p3_origin = view3d_utils.region_2d_to_origin_3d(region, rv3d, point_2d)
p3_direction = view3d_utils.region_2d_to_vector_3d(region, rv3d, point_2d)
# Intersect the point with the plane.
p3_on_plane = mathutils.geometry.intersect_line_plane(p3_origin, # First point of line.
p3_origin + p3_direction, # Second point of line.
location, # `plane_co` (a point on the plane).
normal) # `plane_no` (the direction the plane is facing).
return p3_on_plane
@@ -1,17 +1,56 @@
import bpy
import bmesh
import mathutils
import math
from bpy_extras import view3d_utils
from .object import hide_objects
from .types import Ray
from mathutils import Vector
#### ------------------------------ FUNCTIONS ------------------------------ ####
#### ------------------------------ /poll/ ------------------------------ ####
def extrude_face(bm, face):
"""Extrudes cutter face (created by carve operation) along view vector to create a non-manifold mesh"""
def is_instanced_mesh(data):
"""
Checks if `obj.data` has more than one users, i.e. is instanced.
Function only considers object types as users, and excludes other pointers.
"""
data = bpy.data.meshes.get(data.name)
users = 0
for key, values in bpy.data.user_map(subset=[data]).items():
for value in values:
if value.id_type == 'OBJECT':
users += 1
if users > 1:
return True
else:
return False
def are_intersecting(obj_a, obj_b):
"""Checks if bounding boxes of two given objects intersect."""
def world_bounds(obj):
corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
xs = [c.x for c in corners]
ys = [c.y for c in corners]
zs = [c.z for c in corners]
return (min(xs), max(xs)), (min(ys), max(ys)), (min(zs), max(zs))
(ax0, ax1), (ay0, ay1), (az0, az1) = world_bounds(obj_a)
(bx0, bx1), (by0, by1), (bz0, bz1) = world_bounds(obj_b)
return (
ax1 >= bx0 and ax0 <= bx1 and
ay1 >= by0 and ay0 <= by1 and
az1 >= bz0 and az0 <= bz1
)
#### ------------------------------ /operate/ ------------------------------ ####
def extrude_face(bm, face) -> tuple[list[bmesh.types.BMVert], list[bmesh.types.BMEdge], list[bmesh.types.BMFace]]:
"""Extrudes the `bmesh` face and returns tuple of lists of extruded vertices, edges and faces."""
bm.faces.ensure_lookup_table()
@@ -26,8 +65,8 @@ def extrude_face(bm, face):
return extruded_verts, extruded_edges, extruded_faces
def shade_smooth_by_angle(bm, mesh, angle=30):
"""Replication of "Auto Smooth" functionality: Marks faces as smooth, sharp edges (by angle) as sharp"""
def shade_smooth_by_angle(bm, mesh, angle: float=30):
"""Replication of "Auto Smooth": Marks faces as smooth & edges above the angle as sharp."""
for f in bm.faces:
f.smooth = True
@@ -51,26 +90,6 @@ def shade_smooth_by_angle(bm, mesh, angle=30):
bm.to_mesh(mesh)
def are_intersecting(obj_a, obj_b):
"""Checks if bounding boxes of two given objects intersect."""
def world_bounds(obj):
corners = [obj.matrix_world @ mathutils.Vector(c) for c in obj.bound_box]
xs = [c.x for c in corners]
ys = [c.y for c in corners]
zs = [c.z for c in corners]
return (min(xs), max(xs)), (min(ys), max(ys)), (min(zs), max(zs))
(ax0, ax1), (ay0, ay1), (az0, az1) = world_bounds(obj_a)
(bx0, bx1), (by0, by1), (bz0, bz1) = world_bounds(obj_b)
return (
ax1 >= bx0 and ax0 <= bx1 and
ay1 >= by0 and ay0 <= by1 and
az1 >= bz0 and az0 <= bz1
)
def ensure_attribute(bm, name, domain):
"""Ensure that the attribute with the given name and domain exists on mesh."""
@@ -85,21 +104,3 @@ def ensure_attribute(bm, name, domain):
attr = bm.verts.layers.float.new(name)
return attr
def raycast(context, position, objects):
"""Cast a ray in the scene to get the surface on any of the given objects."""
region = context.region
rv3d = context.region_data
depsgraph = context.view_layer.depsgraph
origin = view3d_utils.region_2d_to_origin_3d(region, rv3d, position)
direction = view3d_utils.region_2d_to_vector_3d(region, rv3d, position)
# Cast Ray
with hide_objects(context, exceptions=objects):
hit, location, normal, index, object, matrix = context.scene.ray_cast(depsgraph, origin, direction)
ray = Ray(hit, location, normal, index, object, matrix)
return ray
@@ -3,42 +3,102 @@ import bmesh
from contextlib import contextmanager
from .. import __package__ as base_package
from ..functions.list import (
list_pre_boolean_modifiers,
from .mesh import (
is_instanced_mesh,
)
from .object import (
convert_to_mesh,
)
from .poll import (
is_instanced_data,
)
#### ------------------------------ /list/ ------------------------------ ####
def enumerate_boolean_modifiers(obj) -> int:
"""Returns the number of Boolean modifiers on the object."""
num = 0
for mod in obj.modifiers:
if is_boolean_modifier(mod):
num += 1
return num
def get_modifiers_to_apply(context, obj, custom_list=None) -> list:
"""Returns the list of modifiers that need to be applied based on add-on preferences."""
prefs = context.preferences.addons[base_package].preferences
# Apply all modifiers.
if prefs.apply_order == 'ALL':
modifiers = list(obj.modifiers)
# Apply only Boolean modifiers.
elif prefs.apply_order == 'BOOLEANS':
if custom_list is None:
modifiers = [mod for mod in obj.modifiers if is_boolean_modifier(mod)]
else:
modifiers = custom_list
# Apply all modifiers that come before last Boolean modifier.
elif prefs.apply_order == 'BEFORE':
# Find the index of a last Boolean modifier.
last_boolean_index = -1
for i in reversed(range(len(obj.modifiers))):
if obj.modifiers[i].type == 'BOOLEAN':
last_boolean_index = i
break
# If a Boolean modifier is found, list all modifiers that come before it.
if last_boolean_index != -1:
modifiers = [mod for mod in obj.modifiers[:last_boolean_index + 1]]
else:
modifiers = []
return modifiers
#### ------------------------------ /poll/ ------------------------------ ####
def is_boolean_modifier(mod, check_cutter=True) -> bool:
"""Checks if a modifier is a Boolean modifier (and optionally if it has a valid cutter)."""
if mod is None:
return False
if mod.type != 'BOOLEAN':
return False
if check_cutter and mod.object is None:
return False
return True
#### ------------------------------ FUNCTIONS ------------------------------ ####
def add_boolean_modifier(self, context, obj, cutter, mode, solver, pin=False, redo=True):
"Adds boolean modifier with specified cutter and properties to a single object"
"""Adds the Boolean modifier with specified cutter and properties to a given object."""
if bpy.app.version < (5, 0, 0) and solver == 'FLOAT':
solver = 'FAST'
prefs = context.preferences.addons[base_package].preferences
name = "boolean_" + cutter.name.replace("boolean_", "")
modifier = obj.modifiers.new("boolean_" + cutter.name.replace("boolean_", ""), 'BOOLEAN')
modifier = obj.modifiers.new(name, 'BOOLEAN')
modifier.operation = mode
modifier.object = cutter
modifier.solver = solver
modifier.show_in_editmode = prefs.show_in_editmode
# Set solver options (inherited from operator properties).
# Set solver options (inherited from operator properties, i.e. `self`).
if redo:
modifier.material_mode = self.material_mode
modifier.use_self = self.use_self
modifier.use_hole_tolerant = self.use_hole_tolerant
modifier.double_threshold = self.double_threshold
if prefs.show_in_editmode:
modifier.show_in_editmode = True
# Move modifier to the index 0 (make it first in the stack).
if pin:
index = obj.modifiers.find(modifier.name)
@@ -50,19 +110,20 @@ def add_boolean_modifier(self, context, obj, cutter, mode, solver, pin=False, re
def apply_modifiers(context, obj, modifiers: list, force_clean=False):
"""
Apply modifiers on object.
Instead of using `bpy.ops.object.modifier_apply`, this function uses
`bpy.data.meshes.new_from_object` built-in function to create a temporary
mesh from the evaluated object (basically with visible modifiers applied).
Temporary mesh is then transferred to objects mesh with `bmesh`.
Instead of using `bpy.ops.object.modifier_apply`, by default this function uses
`to_mesh` built-in function to create a temporary mesh from the evaluated object
(basically with visible modifiers applied). Temporary mesh is then transferred
to objects mesh using `bmesh`.
This method is up to 2x faster, although it's considered experimental
and may fail in some cases, so a fallback to `bpy.ops.object.modifier_apply` is kept.
"""
prefs = context.preferences.addons[base_package].preferences
_stored_active_obj = context.active_object
# Make object data unique if it's instanced.
if is_instanced_data(obj):
if is_instanced_mesh(obj.data):
context.active_object.data = context.active_object.data.copy()
try:
@@ -71,10 +132,13 @@ def apply_modifiers(context, obj, modifiers: list, force_clean=False):
if not force_clean:
raise Exception()
context.view_layer.objects.active = obj
with hide_modifiers(obj, excluding=modifiers):
# Create a temporary mesh from evaluated object.
evaluated_obj = obj.evaluated_get(context.evaluated_depsgraph_get())
temp_data = bpy.data.meshes.new_from_object(evaluated_obj)
depsgraph = context.evaluated_depsgraph_get()
evaluated_obj = obj.evaluated_get(depsgraph)
temp_data = evaluated_obj.to_mesh(preserve_all_data_layers=True,
depsgraph=depsgraph)
# Create `bmesh` from temporary mesh and update edit mesh.
if context.mode == 'EDIT_MESH':
@@ -86,11 +150,11 @@ def apply_modifiers(context, obj, modifiers: list, force_clean=False):
bm = bmesh.new()
bm.from_mesh(temp_data)
bm.to_mesh(obj.data)
bm.free()
evaluated_obj.to_mesh_clear()
# Remove modifiers and purge temporary mesh.
bpy.data.meshes.remove(temp_data)
# Remove modifiers.
for mod in modifiers:
obj.modifiers.remove(mod)
@@ -99,7 +163,6 @@ def apply_modifiers(context, obj, modifiers: list, force_clean=False):
if obj.data.shape_keys:
obj.shape_key_clear()
# Use `bpy.ops` operator to apply modifiers if above fails.
except Exception as e:
# print("Error applying modifiers with `bmesh` method:", e, "falling back to `bpy.ops` method")
@@ -118,10 +181,12 @@ def apply_modifiers(context, obj, modifiers: list, force_clean=False):
for mod in modifiers:
bpy.ops.object.modifier_apply(modifier=mod.name)
context.view_layer.objects.active = _stored_active_obj
@contextmanager
def hide_modifiers(obj, excluding: list):
"""Hides all modifiers of a given object in viewport except those in excluding list"""
"""Hides all modifiers of a given object in the viewport except those in `excluding` list."""
visible_modifiers = []
for mod in obj.modifiers:
@@ -139,10 +204,10 @@ def hide_modifiers(obj, excluding: list):
def add_modifier_asset(obj, path: str, asset: str):
"""Loads the node group asset and adds a Geometry Nodes modifier using it."""
"""Loads in the node group asset and adds a Geometry Nodes modifier using it."""
try:
# Load the node group.
# Load in the node group.
if bpy.app.version >= (5, 0, 0):
with bpy.data.libraries.load(path, link=True, pack=True) as (data_from, data_to):
if asset in data_from.node_groups:
@@ -155,7 +220,7 @@ def add_modifier_asset(obj, path: str, asset: str):
node_group = bpy.data.node_groups[asset]
# Add modifier on the object.
# Add modifier to the object.
mod = obj.modifiers.new(asset, type='NODES')
mod.node_group = node_group
mod.show_group_selector = False
@@ -168,16 +233,19 @@ def add_modifier_asset(obj, path: str, asset: str):
return None
def get_modifiers_to_apply(context, obj, new_modifiers) -> list:
"""Returns the list of modifiers that need to be applied based on add-on preferences."""
def update_modifier_input(modifier, socket: str, value):
"""Change the value of the geometry nodes modifier input socket."""
prefs = context.preferences.addons[base_package].preferences
if prefs.apply_order == 'ALL':
modifiers = [mod for mod in obj.modifiers]
elif prefs.apply_order == 'BOOLEANS':
modifiers = new_modifiers
elif prefs.apply_order == 'BEFORE':
modifiers = list_pre_boolean_modifiers(obj)
return modifiers
try:
if bpy.app.version >= (5, 2, 0):
socket = getattr(modifier.properties.inputs, socket)
socket.value = value
else:
modifier[f"{socket}"] = value
except:
"""
NOTE: There are plethora of reasons why this can fail, node trees are finicky.
Accounting for all possible cases is borderline impossible, so this check is necessary
to at least fail silently and not throw Python error to users.
"""
pass
@@ -1,152 +1,129 @@
import bpy
import bmesh
import mathutils
from mathutils import Vector, Matrix
from contextlib import contextmanager
from .. import __package__ as base_package
#### ------------------------------ FUNCTIONS ------------------------------ ####
#### ------------------------------ /poll/ ------------------------------ ####
def set_cutter_properties(context, cutter, mode, display='BOUNDS', collection=True):
"""Ensures cutter is properly set: has right properties, is hidden, in a collection & parented"""
def is_linked(context, obj) -> bool:
"""Checks whether the object is linked from an external .blend file (including library-overrides)."""
# Hide Cutters
cutter.hide_render = True
cutter.display_type = display
cutter.lineart.usage = 'EXCLUDE'
object_visibility_set(cutter, value=False)
if obj not in context.editable_objects:
if obj.library:
return True
else:
return False
else:
if obj.override_library:
return True
else:
return False
# Cutters Collection
if collection:
cutters_collection = ensure_collection(context)
if cutters_collection not in cutter.users_collection:
cutters_collection.objects.link(cutter)
# add_boolean_property
cutter.booleans.cutter = mode.capitalize()
def has_evaluated_mesh(context, obj):
"""Checks if an evaluated object has mesh (created by Geometry Nodes modifiers)."""
# Exclude cases that return Python errors.
if not obj:
return False
if bpy.app.version < (5, 2, 0) and obj.type == 'EMPTY':
return False
if obj.instance_type != 'NONE':
return False
depsgraph = context.view_layer.depsgraph
obj_eval = depsgraph.id_eval_get(obj)
geometry = None
try:
geometry = obj_eval.evaluated_geometry()
except:
pass
if not geometry or not geometry.mesh:
return False
else:
return True
#### ------------------------------ /operate/ ------------------------------ ####
def object_visibility_set(obj, value=False):
"Sets object visibility properties to either True or False"
"""Sets object visibility properties to either True or False."""
obj.visible_camera = value
obj.visible_shadow = value
obj.visible_diffuse = value
obj.visible_glossy = value
obj.visible_shadow = value
obj.visible_transmission = value
obj.visible_volume_scatter = value
if bpy.app.version >= (5, 2, 0):
obj.visible_raycast = value
obj.hide_probe_volume = not value
obj.hide_probe_sphere = not value
obj.hide_probe_plane = not value
def convert_to_mesh(context, obj):
"Converts active object into mesh (applying all modifiers and shape keys in process)"
"""Converts active object into mesh (applying all modifiers and shape keys in the process)."""
# store_selection
original_mode = obj.mode
if original_mode != 'OBJECT':
edit_objects = context.objects_in_mode
bpy.ops.object.mode_set(mode='OBJECT')
# Store selection.
stored_active = context.active_object
stored_selection = context.selected_objects
bpy.ops.object.select_all(action='DESELECT')
for ob in context.scene.objects:
ob.select_set(False)
# Convert
# Make `obj` active and only one selected.
obj.select_set(True)
context.view_layer.objects.active = obj
# Convert.
bpy.ops.object.convert(target='MESH')
# restore_selection
for obj in stored_selection:
obj.select_set(True)
if original_mode != 'OBJECT':
for ob in edit_objects:
ob.select_set(True)
bpy.ops.object.mode_set(mode=original_mode)
# Restore selection.
for ob in stored_selection:
ob.select_set(True)
context.view_layer.objects.active = stored_active
def ensure_collection(context):
"""Checks the existance of boolean cutters collection and creates it if it doesn't exist"""
def change_parent(context, obj, parent, inverse=False):
"""Changes or removes parent from an object while keeping the transformation."""
prefs = context.preferences.addons[base_package].preferences
context.evaluated_depsgraph_get().update()
collection_name = prefs.collection_name
cutters_collection = bpy.data.collections.get(collection_name)
if cutters_collection is None:
cutters_collection = bpy.data.collections.new(collection_name)
context.scene.collection.children.link(cutters_collection)
cutters_collection.hide_render = True
cutters_collection.color_tag = 'COLOR_01'
# cutters_collection.hide_viewport = True
# context.view_layer.layer_collection.children[collection_name].exclude = True
return cutters_collection
def delete_empty_collection():
"""Removes boolean cutters collection if it has no more objects in it"""
prefs = bpy.context.preferences.addons[base_package].preferences
collection = bpy.data.collections.get(prefs.collection_name)
if collection and not collection.objects:
bpy.data.collections.remove(collection)
def delete_cutter(cutter):
"""Deletes cutter object and purges it's mesh data"""
orphaned_mesh = cutter.data
bpy.data.objects.remove(cutter)
if orphaned_mesh.users == 0:
bpy.data.meshes.remove(orphaned_mesh)
def change_parent(obj, parent, force=False, inverse=False):
"""Changes or removes parent from cutter object while keeping the transformation"""
if obj.parent is not None:
if not force:
return
matrix_copy = obj.matrix_world.copy()
obj.parent = parent
if inverse:
if inverse and parent is not None:
obj.matrix_parent_inverse = parent.matrix_world.inverted()
obj.matrix_world = matrix_copy
def create_slice(context, canvas, modifier=False):
"""Creates copy of canvas to be used as slice"""
slice = canvas.copy()
slice.data = canvas.data.copy()
slice.name = slice.data.name = canvas.name + "_slice"
change_parent(slice, canvas)
# Set Boolean Properties
if modifier == True:
slice.booleans.canvas = True
slice.booleans.slice = True
slice.booleans.slice_of = canvas
# Add to Canvas Collections
for coll in canvas.users_collection:
coll.objects.link(slice)
# add_slices_to_local_view
if context.space_data.local_view:
slice.local_view_set(context.space_data, True)
return slice
def set_object_origin(obj, bm, point='CENTER', custom=None):
"""Sets object origin to given position by shifting vertices"""
def set_object_origin(obj, bm, point='CENTER', custom: Vector=None):
"""Sets the origin of a mesh type object to given position by shifting vertices."""
# Center of the bounding box.
if point == 'CENTER_OBJ':
position_local = 0.125 * sum((mathutils.Vector(b) for b in obj.bound_box), mathutils.Vector())
position_local = 0.125 * sum((Vector(b) for b in obj.bound_box), Vector())
position_world = obj.matrix_world @ position_local
# Center of the geometry.
elif point == 'CENTER_MESH':
if len(bm.verts) > 0:
position_local = sum((v.co for v in bm.verts), mathutils.Vector()) / len(bm.verts)
position_local = sum((v.co for v in bm.verts), Vector()) / len(bm.verts)
else:
position_local = mathutils.Vector((0, 0, 0))
position_local = Vector((0, 0, 0))
position_world = obj.matrix_world @ position_local
# Custom origin point (should be local Vector).
@@ -154,8 +131,8 @@ def set_object_origin(obj, bm, point='CENTER', custom=None):
position_local = custom
position_world = obj.matrix_world @ custom
mat = mathutils.Matrix.Translation(position_local)
bmesh.ops.transform(bm, matrix=mat.inverted(), verts=bm.verts)
matrix = Matrix.Translation(position_local)
bmesh.ops.transform(bm, matrix=matrix.inverted(), verts=bm.verts)
bm.to_mesh(obj.data)
obj.location = position_world
@@ -179,3 +156,13 @@ def hide_objects(context, exceptions: list):
finally:
for obj in hidden_objects:
obj.hide_set(False)
def delete_object(cutter, purge_data=True):
"""Deletes the object and optionally purges its data if it has no more users."""
orphaned_data = cutter.data
bpy.data.objects.remove(cutter)
if purge_data and orphaned_data.users == 0:
bpy.data.meshes.remove(orphaned_data)
@@ -1,153 +1,44 @@
import bpy
from .list import (
list_canvas_cutters,
list_cutter_users,
from ..constants import (
CONVERTABLE_TYPES,
)
from .mesh import (
is_instanced_mesh,
)
from .object import (
convert_to_mesh,
has_evaluated_mesh,
)
#### ------------------------------ FUNCTIONS ------------------------------ ####
def basic_poll(cls, context, check_linked=False):
"""Basic poll for boolean operators."""
def basic_poll(cls, context, check_active=True):
"""Basic poll for Boolean operators."""
if context.mode != 'OBJECT':
return False
if context.active_object is None:
cls.poll_message_set("Boolean operators can only be performed in Object Mode")
return False
obj = context.active_object
if obj.type != 'MESH':
cls.poll_message_set("Boolean operators can only be used for mesh objects")
return False
if check_linked and is_linked(context, obj) == True:
cls.poll_message_set("Boolean operators can not be executed on linked objects")
return False
if check_active:
if context.active_object is None:
cls.poll_message_set("No active object")
return False
return True
def is_linked(context, obj):
"""Checks whether the object is linked from an external .blend file (including library-overrides)."""
if obj not in context.editable_objects:
if obj.library:
return True
else:
return False
else:
if obj.override_library:
return True
else:
return False
def is_canvas(obj):
"""Checks whether the object is a boolean canvas (i.e. has boolean cutters)."""
if obj.booleans.canvas == False:
return False
else:
# Even if object is marked as canvas, check if it actually has any cutters
cutters, __ = list_canvas_cutters([obj])
if len(cutters) > 0:
return True
else:
return False
def is_instanced_data(obj):
"""Checks if `obj.data` has more than one users, i.e. is instanced."""
"""Function only considers object types as users, and excludes pointers."""
data = bpy.data.meshes.get(obj.data.name)
users = 0
for key, values in bpy.data.user_map(subset=[data]).items():
for value in values:
if value.id_type == 'OBJECT':
users += 1
if users > 1:
return True
else:
return False
def active_modifier_poll(obj):
"""Checks whether the active modifier for active object is a boolean."""
# Check if active modifier exists.
if len(obj.modifiers) == 0:
return False
if obj.modifiers.active is None:
return False
# Check if active modifier is a boolean with a valid object.
modifier = obj.modifiers.active
if modifier.type != "BOOLEAN":
return False
if modifier.object is None:
return False
return True
def has_evaluated_mesh(context, obj):
"""Checks if an object (non-mesh type) has an evaluated mesh created by Geometry Nodes modifiers."""
depsgraph = context.view_layer.depsgraph
obj_eval = depsgraph.id_eval_get(obj)
geometry = obj_eval.evaluated_geometry()
if geometry.mesh:
return True
else:
return False
def list_candidate_objects(self, context, canvas):
"""Filter out objects from the selection that can't be used as a cutter."""
cutters = []
for obj in context.selected_objects:
if obj == context.active_object:
continue
if is_linked(context, obj):
self.report({'WARNING'}, f"{obj.name} is linked and can not be used as a cutter")
continue
if obj.type == 'MESH':
# Exclude if object is already a cutter for canvas.
if canvas in list_cutter_users([obj]):
continue
# Exclude if canvas is cutting the object (avoid dependancy loop).
if obj in list_cutter_users([canvas]):
self.report({'WARNING'}, f"{obj.name} can not cut its own cutter (dependancy loop)")
continue
cutters.append(obj)
elif obj.type in ('CURVE', 'FONT'):
if has_evaluated_mesh(context, obj):
convert_to_mesh(context, obj)
cutters.append(obj)
return cutters
def destructive_op_confirmation(self, context, event, canvases: list, title="Boolean Operation"):
def destructive_op_confirmation(cls, context, event, canvases: list, title="Boolean Operation"):
"""
Creates & returns the confirmation pop-up window for destructive boolean operators.\n
Confirmation window is triggered by canvas objects that have instanced object data or shape keys.\n
If none of the canvas objects have them the operator is executed without any confirmation.
Creates & returns the confirmation pop-up window for destructive Boolean operators.\n
Confirmation window is triggered by canvases that have instanced data or shape keys.\n
If none of the canvases have them the operator is executed without any confirmation.
"""
has_instanced_data = any(obj for obj in canvases if is_instanced_data(obj))
if len(canvases) == 0:
return cls.execute(context)
has_instanced_data = any(obj for obj in canvases if is_instanced_mesh(obj.data))
has_shape_keys = any(obj for obj in canvases if obj.data.shape_keys)
if has_instanced_data or has_shape_keys:
@@ -166,15 +57,74 @@ def destructive_op_confirmation(self, context, event, canvases: list, title="Boo
# Combined message.
if has_instanced_data and has_shape_keys:
message = ("Object(s) you're trying to cut have shape keys and instanced object data.\n"
"In order to apply modifiers shape keys need to be applied, and object data made single user.\n"
"In order to apply modifiers shape keys need to be applied & object data made single user.\n"
"Do you proceed?")
popup = context.window_manager.invoke_confirm(self, event, title=title,
popup = context.window_manager.invoke_confirm(cls, event, title=title,
confirm_text="Yes", icon='WARNING',
message=message)
cls._unflippable = True
return popup
# Execute without confirmation window.
else:
return self.execute(context)
return cls.execute(context)
def convert_to_mesh_confirmation(cls, context, event, cutters: list, title="Boolean Operation"):
"""
Creates & returns the confirmation pop-up window when the object is
about to be converted to mesh to be used as a cutter.
NOTE (1): Only triggers during brush Boolean operators,
because object gets destroyed in the destructive one anyway.
NOTE (2): This is only required because of the limitation of legacy Boolean modifier.
Geometry nodes implementation works with any object type. When the add-on is
updated to work with custom modifiers this will not be necesary anymore.
"""
if len(cutters) == 0:
return cls.execute(context)
is_convertable = any(
obj.type in CONVERTABLE_TYPES and has_evaluated_mesh(context, obj)
for obj in cutters
)
if is_convertable:
message = ("Some of the selected objects are not of the Mesh type, but output mesh.\n"
"In order to use them as cutters, they need to be converted to mesh.\n"
"This is a destructive operator. Do you proceed?")
popup = context.window_manager.invoke_confirm(cls, event, title=title,
confirm_text="Yes", icon='WARNING',
message=message)
cls._unflippable = True
return popup
# Execute without confirmation window.
else:
return cls.execute(context)
#### ------------------------------ /operator_helpers/ ------------------------------ ####
def _guess_toggle_state(modifiers):
"""Guess whether cutters should be hidden or revealed."""
enabled = 0
disabled = 0
for mod in modifiers:
if mod.show_viewport:
enabled += 1
else:
disabled += 1
if enabled > disabled:
return "On"
else:
return "Off"
@@ -0,0 +1,68 @@
import bpy
from .. import __package__ as base_package
from .object import (
hide_objects,
)
from .types import (
Ray,
)
from .view import (
region_2d_to_ray_3d,
)
#### ------------------------------ FUNCTIONS ------------------------------ ####
def ensure_collection(context) -> bpy.types.Collection:
"""Returns the Boolean cutters collection and creates it if it doesn't exist."""
prefs = context.preferences.addons[base_package].preferences
collection_name = prefs.collection_name
coll = bpy.data.collections.get(collection_name)
# Create the collection if it doesn't exist.
if coll is None:
coll = bpy.data.collections.new(collection_name)
coll.hide_render = True
coll.color_tag = 'COLOR_01'
context.scene.collection.children.link(coll)
return coll
def delete_empty_collection(context):
"""Removes Boolean cutters collection if it has no more objects in it."""
prefs = context.preferences.addons[base_package].preferences
if not prefs.use_collection:
return
collection_name = prefs.collection_name
collection = bpy.data.collections.get(collection_name)
if not collection:
return
if collection.objects:
return
bpy.data.collections.remove(collection)
def raycast(context, position, objects):
"""Cast a ray in the scene to get the surface on any of the given objects."""
region = context.region
rv3d = context.region_data
depsgraph = context.view_layer.depsgraph
origin, direction = region_2d_to_ray_3d(region, rv3d, position)
# Cast Ray
with hide_objects(context, exceptions=objects):
hit, location, normal, index, object, matrix = context.scene.ray_cast(depsgraph, origin, direction)
ray = Ray(hit, location, normal, index, object, matrix)
return ray
@@ -1,5 +1,4 @@
import bpy
import mathutils
from mathutils import Vector, Matrix
@@ -16,8 +15,8 @@ class Ray:
obj,
matrix: Matrix):
self.hit = hit
self.location = location if location is not None else mathutils.Vector()
self.normal = normal if normal is not None else mathutils.Vector()
self.location = location
self.normal = normal
self.index = index
self.obj = obj
self.matrix = matrix if matrix is not None else mathutils.Matrix()
self.matrix = matrix
@@ -0,0 +1,75 @@
import bpy
import mathutils
from mathutils import Vector
from bpy_extras import view3d_utils
#### ------------------------------ FUNCTIONS ------------------------------ ####
def redraw_regions(context):
"""Redraw regions to find the limits of the 3D viewport."""
for area in context.window.screen.areas:
if area.type != 'VIEW_3D':
continue
for region in area.regions:
if region.type in {'WINDOW', 'UI'}:
region.tag_redraw()
def distance_from_point_to_segment(point: Vector, line_p1: Vector, line_p2: Vector) -> float:
"""
Calculates the shortest distance between the point and the finite segment.
This is an alternative to `mathutils.geometry.intersect_point_line` (w/ clamping).
Adapted from "Blockout" extension by niewinny (https://github.com/niewinny/blockout).
"""
segment = line_p2 - line_p1
start_to_point = point - line_p1
# Projection along segment.
c1 = start_to_point.dot(segment)
if c1 <= 0:
return (point - line_p1).length
# Segment length squared.
c2 = segment.dot(segment)
if c2 <= c1:
return (point - line_p2).length
t = c1 / c2
closest_point = line_p1 + t * segment
distance = (point - closest_point).length
return distance
def region_2d_to_ray_3d(region, rv3d, point_2d: Vector) -> tuple[Vector, Vector]:
"""
Converts a 2D screen-space point into a 3D ray in the world-space.
Returns a tuple of `ray_origin` and `ray_direction` Vectors.
"""
origin = view3d_utils.region_2d_to_origin_3d(region, rv3d, point_2d)
direction = view3d_utils.region_2d_to_vector_3d(region, rv3d, point_2d)
return origin, direction
def region_2d_to_plane_3d(region, rv3d, point_2d: Vector, plane: tuple[Vector]) -> Vector:
"""
Converts a 2D screen-space point into a 3D point on a plane in world-space.
Adapted from "Blockout" extension by niewinny (https://github.com/niewinny/blockout).
"""
location, normal = plane
p3_origin, p3_direction = region_2d_to_ray_3d(region, rv3d, point_2d)
# Intersect the point with the plane.
p3_on_plane = mathutils.geometry.intersect_line_plane(
p3_origin, # First point of line.
p3_origin + p3_direction, # Second point of line.
location, # `plane_co` (a point on the plane).
normal) # `plane_no` (the direction the plane is facing).
return p3_on_plane