2026-01-01
This commit is contained in:
+11
-10
@@ -10,13 +10,14 @@ D:\Work\9 iClone\Amazon\
|
||||
D:\Amazon\00_external-files\
|
||||
N:\1. CHARACTERS\remapping\
|
||||
[Recent]
|
||||
P:\250827_FestivalTurf\Blends\animations\07 Final Touches And Maintenance\
|
||||
T:\250827_FestivalTurf\Blends\animations\07 Final Touches And Maintenance\blendcache_Visual_7_phone_insert\
|
||||
C:\Users\Nathan\AppData\Local\Temp\
|
||||
P:\250827_FestivalTurf\Assets\Mocap\07 final\
|
||||
P:\250827_FestivalTurf\Assets\Blends\
|
||||
T:\251031_Tatt2Away_Idol\Assets\Blends\Char\
|
||||
D:\Work\9 iClone\Demon Hunters\Blender_v2\
|
||||
T:\250827_FestivalTurf\Blends\animations\07 Final Touches And Maintenance\
|
||||
D:\Work\9 iClone\Demon Hunters\Blender_v2\imports\Zoey\
|
||||
F:\jobs\2025-12-04-163406.132815-Visual_2_push_broom\blendcache_Visual_2_push_broom.flamenco\
|
||||
A:\1 Amazon_Active_Projects\251216_NonCon_Media\Assets\Blends\
|
||||
!ToDraw
|
||||
D:\2.ToDraw\Amazon Projects\
|
||||
G:\Amazon\2025\1. CHARACTERS\1. Current\241219_Cartoon\textures\
|
||||
A:\1 Amazon_Active_Projects\251216_NonCon_Media\Blends\animations\leader\
|
||||
T:\1 BlenderAssets\Amazon\Char\Cartoon2\AS\
|
||||
T:\1 BlenderAssets\Amazon\Char\Cartoon2\AS\textures\Paul\
|
||||
T:\1 BlenderAssets\Amazon\Char\Cartoon2\AM\
|
||||
A:\1 Amazon_Active_Projects\251216_NonCon_Media\Deliverable\Stills\Leader\
|
||||
A:\1 Amazon_Active_Projects\251216_NonCon_Media\Blends\stills\Leader\
|
||||
A:\1 Amazon_Active_Projects\251216_NonCon_Media\Blends\stills\
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"flamenco_version": {
|
||||
"version": "3.8",
|
||||
"shortversion": "3.8",
|
||||
"version": "3.8.2",
|
||||
"shortversion": "3.8.2",
|
||||
"name": "Flamenco",
|
||||
"git": "ed2c15ea"
|
||||
"git": "51a41a19"
|
||||
},
|
||||
"shared_storage": {
|
||||
"location": "F:\\jobs",
|
||||
|
||||
@@ -10,3 +10,4 @@
|
||||
{NVIDIA Corporation/NVIDIA GeForce RTX 4080 SUPER/NVIDIA 580.97}=SUPPORTED
|
||||
{NVIDIA Corporation/NVIDIA GeForce RTX 4080 SUPER/NVIDIA 581.57}=SUPPORTED
|
||||
{NVIDIA Corporation/NVIDIA GeForce RTX 4080 SUPER/PCIe/SSE2/4.6.0 NVIDIA 581.57}=SUPPORTED
|
||||
{NVIDIA Corporation/NVIDIA GeForce RTX 4080 SUPER/NVIDIA 591.44}=SUPPORTED
|
||||
|
||||
+30
-30
@@ -1,30 +1,30 @@
|
||||
P:\250827_FestivalTurf\Blends\animations\07 Final Touches And Maintenance\Visual_0_5_talking.blend
|
||||
A:\1 Amazon_Active_Projects\251203_ADTA_December_2025\Blends\animations\Bag2PackScan_animation 1a.blend
|
||||
T:\251203_ADTA_December_2025\Blends\animations\Pack2BagScan_animation 6d.blend
|
||||
P:\250827_FestivalTurf\Blends\animations\07 Final Touches And Maintenance\Visual_8.blend
|
||||
P:\250827_FestivalTurf\Blends\animations\07 Final Touches And Maintenance\Visual_5.blend
|
||||
P:\250827_FestivalTurf\Blends\animations\07 Final Touches And Maintenance\Visual_4_leaf_blower_insert.blend
|
||||
P:\250827_FestivalTurf\Blends\animations\07 Final Touches And Maintenance\Visual_2_broom.blend
|
||||
P:\250827_FestivalTurf\Blends\animations\07 Final Touches And Maintenance\Visual_2_push_broom.blend
|
||||
F:\jobs\2025-12-08-164101.711842-Visual_8\Visual_8.flamenco.blend
|
||||
P:\250827_FestivalTurf\Blends\animations\04 Securing Your Seam\08_smooth blend.blend
|
||||
P:\250827_FestivalTurf\Blends\animations\07 Final Touches And Maintenance\Visual_7_phone_insert.blend
|
||||
T:\250827_FestivalTurf\Blends\animations\07 Final Touches And Maintenance\Visual_7_phone_insert.blend
|
||||
C:\Users\Nathan\AppData\Local\Temp\2025-11-08_17-11_Visual_7_phone_insert.blend
|
||||
P:\250827_FestivalTurf\Blends\animations\07 Final Touches And Maintenance\Visual_7.blend
|
||||
P:\250827_FestivalTurf\Blends\animations\06 Infill And Powerbrooming\0template.blend
|
||||
C:\Users\Nathan\Downloads\Visual_7.blend
|
||||
P:\250827_FestivalTurf\Blends\animations\06 Infill And Powerbrooming\Visual 3B.blend
|
||||
P:\250827_FestivalTurf\Blends\animations\06 Infill And Powerbrooming\Visual 4A.blend
|
||||
P:\250827_FestivalTurf\Blends\animations\07 Final Touches And Maintenance\Visual_3_PE_spread.blend
|
||||
P:\250827_FestivalTurf\Blends\animations\07 Final Touches And Maintenance\Visual_4_leaf_blower.blend
|
||||
P:\250827_FestivalTurf\Blends\animations\07 Final Touches And Maintenance\Visual_6.blend
|
||||
C:\Users\Nathan\Downloads\Visual_4_leaf_blower.blend
|
||||
C:\Users\Nathan\Downloads\Visual_4_leaf_blower_insert.blend
|
||||
F:\jobs\2025-12-08-125318.280619-Bag2PackScan_animation 1b\Bag2PackScan_animation 1b.flamenco.blend
|
||||
F:\jobs\2025-12-08-121420.823326-Bag2PackScan_animation 1a\Bag2PackScan_animation 1a.flamenco.blend
|
||||
F:\jobs\2025-12-08-111004.205815-Bag2PackScan_animation 2b\Bag2PackScan_animation 2b.flamenco.blend
|
||||
F:\jobs\2025-12-08-104805.832937-Bag2PackScan_animation 2a\Bag2PackScan_animation 2a.flamenco.blend
|
||||
P:\250827_FestivalTurf\Assets\Blends\Spa Pitbull.blend
|
||||
T:\251031_Tatt2Away_Idol\Assets\Blends\Char\Rumi_v1.blend
|
||||
D:\Work\9 iClone\Demon Hunters\Blender_v2\Rumi_v1.blend
|
||||
A:\1 Amazon_Active_Projects\251216_NonCon_Media\Assets\Blends\non-con_animation 1_induct characters.blend
|
||||
A:\1 Amazon_Active_Projects\251216_NonCon_Media\Blends\animations\leader\non-con_animation 1_leader.blend
|
||||
T:\1 BlenderAssets\Amazon\Char\Cartoon2\AS\Sarah_v3.2.blend
|
||||
T:\1 BlenderAssets\Amazon\Char\Cartoon2\AS\Heidi_v3.2.blend
|
||||
T:\1 BlenderAssets\Amazon\Char\Cartoon2\AS\Paul_v3.3.blend
|
||||
T:\1 BlenderAssets\Amazon\Char\Cartoon2\AS\Marvin_v3.2.blend
|
||||
T:\1 BlenderAssets\Amazon\Char\Cartoon2\AS\Kennedy_v3.2.blend
|
||||
T:\1 BlenderAssets\Amazon\Char\Cartoon2\AS\Ciara_v3.3.blend
|
||||
T:\1 BlenderAssets\Amazon\Char\Cartoon2\AM\AM_Tamu_v3.2.blend
|
||||
T:\1 BlenderAssets\Amazon\Char\Cartoon2\AM\AM_Glenna_v3.2.blend
|
||||
T:\1 BlenderAssets\Amazon\Char\Cartoon2\AM\AM_Toni_v3.2.blend
|
||||
T:\1 BlenderAssets\Amazon\Char\Cartoon2\AM\AM_Beth_v3.2.blend
|
||||
T:\1 BlenderAssets\Amazon\Char\Cartoon1\Chan_v4.3.blend
|
||||
A:\1 Amazon_Active_Projects\251216_NonCon_Media\Blends\stills\Leader\Non-Con_15.blend
|
||||
A:\1 Amazon_Active_Projects\251216_NonCon_Media\Blends\stills\Non-Con_15.blend
|
||||
A:\1 Amazon_Active_Projects\251216_NonCon_Media\Blends\stills\Leader\Non-Con_6.blend
|
||||
A:\1 Amazon_Active_Projects\251216_NonCon_Media\Blends\stills\Non-Con_6.blend
|
||||
A:\1 Amazon_Active_Projects\251222_Same-Day-Delivery_Updates\Blends\animations\SSD_2B.blend
|
||||
A:\1 Amazon_Active_Projects\251222_Same-Day-Delivery_Updates\Blends\animations\SSD_1G.blend
|
||||
C:\Users\Nathan\SynologyDrive\work\2025 websitetalkingheads\2025-05-21 NewZoo Rigs Optimization\Charlie_Owl_009.blend
|
||||
A:\1 Amazon_Active_Projects\251222_Same-Day-Delivery_Updates\Blends\animations\SSD_2A.blend
|
||||
A:\1 Amazon_Active_Projects\251222_Same-Day-Delivery_Updates\Blends\animations\SSD_2K.blend
|
||||
A:\1 Amazon_Active_Projects\251222_Same-Day-Delivery_Updates\Blends\animations\SSD_2KA.blend
|
||||
A:\1 Amazon_Active_Projects\251222_Same-Day-Delivery_Updates\Blends\animations\SSD_2G_2H.blend
|
||||
A:\1 Amazon_Active_Projects\0 AssetArchive\Amazon\Char\Cartoon1\Kirk_v4.3.blend
|
||||
A:\1 Amazon_Active_Projects\251222_Same-Day-Delivery_Updates\Blends\animations\WS-A_1A.blend
|
||||
G:\Amazon\2025\250404_Dock-2.5\Blends\animations\Waterspider A\WS-A_1A.blend
|
||||
P:\251120_monty-python-crm\blends\castle-wall_shadow-test1.blend
|
||||
F:\jobs\2025-12-22-170344.595818-SSD_2J\SSD_2J.flamenco.blend
|
||||
F:\jobs\2025-12-22-154849.096298-SSD_1F\SSD_1F.flamenco.blend
|
||||
|
||||
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,6 +1,7 @@
|
||||
if "bpy" in locals():
|
||||
import importlib
|
||||
for mod in [operators,
|
||||
for mod in [icons,
|
||||
operators,
|
||||
tools,
|
||||
manual,
|
||||
preferences,
|
||||
@@ -13,6 +14,7 @@ if "bpy" in locals():
|
||||
else:
|
||||
import bpy
|
||||
from . import (
|
||||
icons,
|
||||
operators,
|
||||
tools,
|
||||
manual,
|
||||
@@ -26,6 +28,7 @@ else:
|
||||
#### ------------------------------ REGISTRATION ------------------------------ ####
|
||||
|
||||
modules = [
|
||||
icons,
|
||||
operators,
|
||||
tools,
|
||||
manual,
|
||||
|
||||
Binary file not shown.
@@ -2,7 +2,7 @@ schema_version = "1.0.0"
|
||||
|
||||
id = "bool_tool"
|
||||
name = "Bool Tool"
|
||||
version = "1.1.5"
|
||||
version = "2.0.0"
|
||||
tagline = "Quick boolean operators and tools for hard surface modeling"
|
||||
type = "add-on"
|
||||
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
import bpy
|
||||
import gpu
|
||||
import math
|
||||
import mathutils
|
||||
from bpy_extras import view3d_utils
|
||||
from mathutils import Vector
|
||||
from gpu_extras.batch import batch_for_shader
|
||||
|
||||
from .math import (
|
||||
draw_circle,
|
||||
draw_polygon,
|
||||
draw_array,
|
||||
)
|
||||
|
||||
|
||||
magic_number = 1.41
|
||||
color = (0.48, 0.04, 0.04, 1.0)
|
||||
secondary_color = (0.28, 0.04, 0.04, 1.0)
|
||||
|
||||
#### ------------------------------ FUNCTIONS ------------------------------ ####
|
||||
|
||||
def draw_shader(color, alpha, type, coords, size=1, indices=None):
|
||||
def draw_shader(type, color, alpha, coords, size=1, indices=None):
|
||||
"""Creates a batch for a draw type"""
|
||||
|
||||
gpu.state.blend_set('ALPHA')
|
||||
@@ -29,6 +22,7 @@ def draw_shader(color, alpha, type, coords, size=1, indices=None):
|
||||
batch = batch_for_shader(shader, 'POINTS', {"pos": coords}, indices=indices)
|
||||
|
||||
elif type in 'LINES':
|
||||
gpu.state.line_width_set(size)
|
||||
shader = gpu.shader.from_builtin('POLYLINE_UNIFORM_COLOR')
|
||||
shader.uniform_float("viewportSize", gpu.state.viewport_get()[2:])
|
||||
shader.uniform_float("lineWidth", size)
|
||||
@@ -43,134 +37,103 @@ def draw_shader(color, alpha, type, coords, size=1, indices=None):
|
||||
batch = batch_for_shader(shader, 'LINE_LOOP', {"pos": coords})
|
||||
|
||||
if type == 'SOLID':
|
||||
gpu.state.depth_test_set('NONE')
|
||||
shader = gpu.shader.from_builtin('UNIFORM_COLOR')
|
||||
shader.uniform_float("color", (color[0], color[1], color[2], alpha))
|
||||
batch = batch_for_shader(shader, 'TRIS', {"pos": coords}, indices=indices)
|
||||
|
||||
if type == 'OUTLINE':
|
||||
shader = gpu.shader.from_builtin('UNIFORM_COLOR')
|
||||
shader.uniform_float("color", (color[0], color[1], color[2], alpha))
|
||||
batch = batch_for_shader(shader, 'LINE_STRIP', {"pos": coords})
|
||||
gpu.state.line_width_set(size)
|
||||
|
||||
batch.draw(shader)
|
||||
gpu.state.point_size_set(1.0)
|
||||
gpu.state.line_width_set(1.0)
|
||||
gpu.state.blend_set('NONE')
|
||||
|
||||
|
||||
def carver_shape_box(self, context, shape):
|
||||
"""Shape overlay for box carver tool"""
|
||||
def draw_bmesh_faces(faces, world_matrix):
|
||||
"""
|
||||
Get world-space vertex pairs and indices from `bmesh` face. To be used in GPU batch.
|
||||
Adapted from "Blockout" extension by niewinny (https://github.com/niewinny/blockout).
|
||||
"""
|
||||
|
||||
subdivision = self.subdivision if shape == 'CIRCLE' else 4
|
||||
rotation = 0 if shape == 'CIRCLE' else 45
|
||||
if not faces:
|
||||
return None, None
|
||||
|
||||
# Create Shape
|
||||
coords, indices, bounds = draw_circle(self, subdivision, rotation)
|
||||
self.verts = coords
|
||||
vertices = []
|
||||
indices = []
|
||||
|
||||
# Draw Shaders
|
||||
draw_shader(color, 0.4, 'SOLID', coords, size=2, indices=indices[:-2])
|
||||
if not self.rotate and not self.bevel:
|
||||
draw_shader(color, 0.6, 'OUTLINE', bounds, size=2)
|
||||
vert_index_map = {}
|
||||
vert_count = 0
|
||||
for face in faces:
|
||||
face_indices = []
|
||||
|
||||
# Array
|
||||
if self.rows > 1 or self.columns > 1:
|
||||
carver_shape_array(self, coords, indices, 'SOLID')
|
||||
# Collect unique vertices only (avoid storing verts that are shared by faces multiple times).
|
||||
# (Iterating over face corners because unlike `face.verts` they're ordered).
|
||||
for loop in face.loops:
|
||||
vert = loop.vert
|
||||
co = world_matrix @ Vector(vert.co)
|
||||
|
||||
if vert not in vert_index_map:
|
||||
vertices.append(co)
|
||||
vert_index_map[vert] = vert_count
|
||||
face_indices.append(vert_count)
|
||||
vert_count += 1
|
||||
else:
|
||||
face_indices.append(vert_index_map[vert])
|
||||
|
||||
# Triangulate face and map local indices to global vertex indices.
|
||||
if len(face_indices) >= 3:
|
||||
try:
|
||||
face_verts_co = [vertices[idx] for idx in face_indices]
|
||||
tris = mathutils.geometry.tessellate_polygon([face_verts_co])
|
||||
for tri in tris:
|
||||
indices.append((face_indices[tri[0]], face_indices[tri[1]], face_indices[tri[2]]))
|
||||
except:
|
||||
# Fallback to simple fan triangulation if tessellation fails.
|
||||
for i in range(1, len(face_indices) - 1):
|
||||
indices.append((face_indices[0], face_indices[i], face_indices[i + 1]))
|
||||
|
||||
return vertices, indices
|
||||
|
||||
|
||||
if self.snap:
|
||||
mini_grid(self, context)
|
||||
def draw_bmesh_edges(edges, world_matrix):
|
||||
"""Convert bmesh edges into world-space vertex pairs to be used in GPU batch."""
|
||||
|
||||
gpu.state.blend_set('NONE')
|
||||
if not edges:
|
||||
return None
|
||||
|
||||
vertices = []
|
||||
for edge in edges:
|
||||
v1 = world_matrix @ edge.verts[0].co
|
||||
v2 = world_matrix @ edge.verts[1].co
|
||||
vertices.append(v1)
|
||||
vertices.append(v2)
|
||||
|
||||
return vertices
|
||||
|
||||
|
||||
def carver_shape_polyline(self, context):
|
||||
"""Shape overlay for polyline carver tool"""
|
||||
|
||||
# Create Shape
|
||||
coords, indices, first_point, array_coords = draw_polygon(self)
|
||||
self.verts = list(dict.fromkeys(self.mouse_path))
|
||||
|
||||
# Draw Shaders
|
||||
draw_shader(color, 1.0, 'POINTS', coords, size=5)
|
||||
draw_shader(color, 1.0, 'LINE_LOOP' if self.closed else 'LINES', coords, size=2)
|
||||
|
||||
if self.closed and len(self.mouse_path) > 2:
|
||||
# polygon_fill
|
||||
draw_shader(color, 0.4, 'SOLID', coords, size=2, indices=indices[:-2])
|
||||
|
||||
if (self.closed and len(coords) > 3) or (self.closed == False and len(coords) > 4):
|
||||
# circle_around_first_point
|
||||
draw_shader(color, 0.8, 'OUTLINE', first_point, size=3)
|
||||
|
||||
# Array
|
||||
if len(self.mouse_path) > 2 and (self.rows > 1 or self.columns > 1):
|
||||
carver_shape_array(self, array_coords, indices, 'LINE_LOOP' if self.closed == False else 'SOLID')
|
||||
|
||||
|
||||
if self.snap:
|
||||
mini_grid(self, context)
|
||||
|
||||
gpu.state.blend_set('NONE')
|
||||
|
||||
|
||||
def carver_shape_array(self, verts, indices, shader):
|
||||
"""Draws given shape for each row and column of the array"""
|
||||
|
||||
rows, columns = draw_array(self, verts)
|
||||
self.duplicates = {**{f"row_{k}": v for k, v in rows.items()}, **{f"column_{k}": v for k, v in columns.items()}}
|
||||
|
||||
if self.rows > 1:
|
||||
for i, duplicate in rows.items():
|
||||
draw_shader(secondary_color, 0.4, shader, duplicate, size=2, indices=indices[:-2])
|
||||
if self.columns > 1:
|
||||
for i, duplicate in columns.items():
|
||||
draw_shader(secondary_color, 0.4, shader, duplicate, size=2, indices=indices[:-2])
|
||||
|
||||
|
||||
def mini_grid(self, context):
|
||||
"""Draws snap mini-grid around the cursor based on the overlay grid"""
|
||||
def draw_circle_around_point(context, obj, vert, radius, segments):
|
||||
"""
|
||||
Draws the screen-aligned circle around given vertex of the object.
|
||||
Returns the list of vertices for GPU batch.
|
||||
"""
|
||||
|
||||
region = context.region
|
||||
rv3d = context.region_data
|
||||
vert_world = obj.matrix_world @ vert.co
|
||||
radius = min(radius, 25)
|
||||
|
||||
for i, area in enumerate(context.screen.areas):
|
||||
if area.type == 'VIEW_3D':
|
||||
space = context.screen.areas[i].spaces.active
|
||||
screen_height = context.screen.areas[i].height
|
||||
screen_width = context.screen.areas[i].width
|
||||
vertices = []
|
||||
for i in range(segments + 1):
|
||||
angle = i * (2 * math.pi / segments)
|
||||
|
||||
# draw_the_snap_grid_(only_in_the_orthographic_view)
|
||||
if not space.region_3d.is_perspective:
|
||||
grid_scale = space.overlay.grid_scale
|
||||
grid_subdivisions = space.overlay.grid_subdivisions
|
||||
increment = (grid_scale / grid_subdivisions)
|
||||
# Calculate offset and vertex position in screen-space.
|
||||
offset_x = radius * math.cos(angle)
|
||||
offset_y = radius * math.sin(angle)
|
||||
vert_screen = view3d_utils.location_3d_to_region_2d(region, rv3d, vert_world)
|
||||
|
||||
# get_the_3d_location_of_the_mouse_forced_to_a_snap_value_in_the_operator
|
||||
mouse_coord = self.mouse_path[len(self.mouse_path) - 1]
|
||||
snap_loc = view3d_utils.region_2d_to_location_3d(region, rv3d, mouse_coord, (0, 0, 0))
|
||||
if vert_screen:
|
||||
# Add offset in screen-space and convert back to world-space.
|
||||
circle_screen = Vector((vert_screen.x + offset_x, vert_screen.y + offset_y))
|
||||
circle_3d = view3d_utils.region_2d_to_location_3d(region, rv3d, circle_screen, vert_world)
|
||||
vertices.append(circle_3d)
|
||||
|
||||
# add_the_increment_to_get_the_closest_location_on_the_grid
|
||||
snap_loc[0] += increment
|
||||
snap_loc[1] += increment
|
||||
|
||||
# get_the_2d_location_of_the_snap_location
|
||||
snap_loc = view3d_utils.location_3d_to_region_2d(region, rv3d, snap_loc)
|
||||
|
||||
# get_the_increment_value
|
||||
snap_value = snap_loc[0] - mouse_coord[0]
|
||||
|
||||
# draw_lines_on_x_and_z_axis_from_the_cursor_through_the_screen
|
||||
grid_coords = [(0, mouse_coord[1]), (screen_width, mouse_coord[1]),
|
||||
(mouse_coord[0], 0), (mouse_coord[0], screen_height)]
|
||||
|
||||
grid_coords += [(mouse_coord[0] + snap_value, mouse_coord[1] + 25 + snap_value),
|
||||
(mouse_coord[0] + snap_value, mouse_coord[1] - 25 - snap_value),
|
||||
(mouse_coord[0] + 25 + snap_value, mouse_coord[1] + snap_value),
|
||||
(mouse_coord[0] - 25 - snap_value, mouse_coord[1] + snap_value),
|
||||
(mouse_coord[0] - snap_value, mouse_coord[1] + 25 + snap_value),
|
||||
(mouse_coord[0] - snap_value, mouse_coord[1] - 25 - snap_value),
|
||||
(mouse_coord[0] + 25 + snap_value, mouse_coord[1] - snap_value),
|
||||
(mouse_coord[0] - 25 - snap_value, mouse_coord[1] - snap_value),]
|
||||
|
||||
draw_shader((1.0, 1.0, 1.0), 0.66, 'LINES', grid_coords, size=1.5)
|
||||
return vertices
|
||||
|
||||
@@ -1,237 +1,77 @@
|
||||
import bpy
|
||||
import math
|
||||
import mathutils
|
||||
from mathutils import Vector
|
||||
from bpy_extras import view3d_utils
|
||||
|
||||
|
||||
magic_number = 1.41
|
||||
|
||||
#### ------------------------------ FUNCTIONS ------------------------------ ####
|
||||
|
||||
def draw_circle(self, subdivision, rotation):
|
||||
"""Returns the coordinates & indices of a 2d circle in screen-space"""
|
||||
def distance_from_point_to_segment(point, start, end) -> float:
|
||||
"""
|
||||
Calculates the shortest distance between a point and a segment.
|
||||
All three inputs should be `mathutils.Vector` objects.
|
||||
This is an alternative to `mathutils.geometry.intersect_point_line`.
|
||||
Adapted from "Blockout" extension by niewinny (https://github.com/niewinny/blockout).
|
||||
"""
|
||||
|
||||
def create_2d_circle(self, step, rotation):
|
||||
"""Create the vertices of a 2d circle at (0, 0)"""
|
||||
segment = end - start
|
||||
start_to_point = point - start
|
||||
|
||||
modifier = 2 if self.shape == 'CIRCLE' else magic_number
|
||||
if self.origin == 'CENTER':
|
||||
modifier /= 2
|
||||
# projection_along_segment
|
||||
c1 = start_to_point.dot(segment)
|
||||
if c1 <= 0:
|
||||
return (point - start).length
|
||||
|
||||
verts = []
|
||||
for i in range(step):
|
||||
angle = (360 / step) * i + rotation
|
||||
verts.append(math.cos(math.radians(angle)) * ((self.mouse_path[1][0] - self.mouse_path[0][0]) / modifier))
|
||||
verts.append(math.sin(math.radians(angle)) * ((self.mouse_path[1][1] - self.mouse_path[0][1]) / modifier))
|
||||
verts.append(0.0)
|
||||
# segment_length_squared
|
||||
c2 = segment.dot(segment)
|
||||
if c2 <= c1:
|
||||
return (point - end).length
|
||||
|
||||
verts.append(math.cos(math.radians(0.0 + rotation)) * ((self.mouse_path[1][0] - self.mouse_path[0][0]) / modifier))
|
||||
verts.append(math.sin(math.radians(0.0 + rotation)) * ((self.mouse_path[1][1] - self.mouse_path[0][1]) / modifier))
|
||||
verts.append(0.0)
|
||||
t = c1 / c2
|
||||
closest_point = start + t * segment
|
||||
distance = (point - closest_point).length
|
||||
|
||||
return verts
|
||||
|
||||
tris_verts = []
|
||||
indices = []
|
||||
verts = create_2d_circle(self, int(subdivision), rotation)
|
||||
|
||||
rotation_matrix = mathutils.Matrix.Rotation(self.rotation, 4, 'Z')
|
||||
fixed_point = mathutils.Vector((self.mouse_path[0][0], self.mouse_path[0][1], 0.0))
|
||||
current_mouse_position = mathutils.Vector((self.mouse_path[1][0], self.mouse_path[1][1], 0.0))
|
||||
shape_center = fixed_point + (current_mouse_position - fixed_point) / 2
|
||||
|
||||
for idx in range((len(verts) // 3) - 1):
|
||||
x = verts[idx * 3]
|
||||
y = verts[idx * 3 + 1]
|
||||
z = verts[idx * 3 + 2]
|
||||
vert = mathutils.Vector((x, y, z))
|
||||
vert = rotation_matrix @ vert
|
||||
vert = vert + fixed_point if self.origin == 'CENTER' else shape_center - vert
|
||||
vert += mathutils.Vector((self.position_offset_x, self.position_offset_y, 0.0))
|
||||
tris_verts.append(vert)
|
||||
|
||||
i1 = idx + 1
|
||||
i2 = idx + 2 if idx + 2 <= ((360 / int(subdivision)) * (idx + 1) + rotation) else 1
|
||||
indices.append((0, i1, i2))
|
||||
|
||||
# BEVEL
|
||||
if self.use_bevel and self.bevel_radius > 0.01:
|
||||
tris_verts, indices = bevel_verts(self, tris_verts, (self.bevel_radius * 50), self.bevel_segments)
|
||||
return distance
|
||||
|
||||
|
||||
# BOUNDING_BOX
|
||||
min_x, min_y, max_x, max_y = get_bounding_box(tris_verts)
|
||||
bounds = [
|
||||
mathutils.Vector((min_x, min_y, 0)), # bottom-left
|
||||
mathutils.Vector((max_x, min_y, 0)), # bottom-right
|
||||
mathutils.Vector((max_x, max_y, 0)), # top-right
|
||||
mathutils.Vector((min_x, max_y, 0)), # top-left
|
||||
mathutils.Vector((min_x, min_y, 0)) # closing_the_loop_manually
|
||||
]
|
||||
def region_2d_to_line_3d(region, rv3d, point_2d: Vector, line_origin: Vector, line_direction: Vector) -> tuple[Vector, Vector]:
|
||||
"""
|
||||
Converts a 2D screen-space point into a 3D ray and finds closest
|
||||
points between that ray and a given 3D line.
|
||||
"""
|
||||
|
||||
return tris_verts, indices, bounds
|
||||
if line_origin is None or line_direction is None:
|
||||
return None, None
|
||||
|
||||
# Convert the screen-space 2D point Vector into a world-space 3D ray (origin + direction).
|
||||
ray_origin = view3d_utils.region_2d_to_origin_3d(region, rv3d, point_2d)
|
||||
ray_direction = view3d_utils.region_2d_to_vector_3d(region, rv3d, point_2d)
|
||||
|
||||
# Find closest points to each other on each line (second line being a ray).
|
||||
closest_points = mathutils.geometry.intersect_line_line(ray_origin,
|
||||
ray_origin + ray_direction,
|
||||
line_origin,
|
||||
line_origin + line_direction)
|
||||
|
||||
return closest_points
|
||||
|
||||
|
||||
def draw_polygon(self):
|
||||
"""Returns polygonal 2d shape in screen-space where each cursor click is taken as a new vertice"""
|
||||
def region_2d_to_plane_3d(region, rv3d, point_2d: Vector, plane: tuple[Vector]) -> Vector:
|
||||
"""
|
||||
Converts a 2D screen-space point into a 3D point on a plane in world-space.
|
||||
Adapted from "Blockout" extension by niewinny (https://github.com/niewinny/blockout).
|
||||
"""
|
||||
|
||||
indices = []
|
||||
coords = []
|
||||
for idx, vals in enumerate(self.mouse_path):
|
||||
vert = mathutils.Vector([vals[0], vals[1], 0.0])
|
||||
vert += mathutils.Vector([self.position_offset_x, self.position_offset_y, 0.0])
|
||||
coords.append(vert)
|
||||
location, normal = plane
|
||||
|
||||
i1 = idx + 1
|
||||
i2 = idx + 2 if idx <= len(self.mouse_path) else 1
|
||||
indices.append((0, i1, i2))
|
||||
# Convert the screen-space 2D point Vector into a world-space 3D ray (origin + direction).
|
||||
p3_origin = view3d_utils.region_2d_to_origin_3d(region, rv3d, point_2d)
|
||||
p3_direction = view3d_utils.region_2d_to_vector_3d(region, rv3d, point_2d)
|
||||
|
||||
# circle_around_first_point
|
||||
radius = self.distance_from_first
|
||||
segments = 4
|
||||
# Intersect the point with the plane.
|
||||
p3_on_plane = mathutils.geometry.intersect_line_plane(p3_origin, # First point of line.
|
||||
p3_origin + p3_direction, # Second point of line.
|
||||
location, # `plane_co` (a point on the plane).
|
||||
normal) # `plane_no` (the direction the plane is facing).
|
||||
|
||||
click_point = [coords[0]]
|
||||
for i in range(segments + 1):
|
||||
angle = i * (2 * math.pi / segments)
|
||||
x = coords[0][0] + radius * math.cos(angle)
|
||||
y = coords[0][1] + radius * math.sin(angle)
|
||||
z = coords[0][2]
|
||||
vector = mathutils.Vector((x, y, z))
|
||||
click_point.append(vector)
|
||||
|
||||
|
||||
# ARRAY (remove_duplicate_verts)
|
||||
"""NOTE: This is needed to remove extra vertices for duplicates which are not removed because `dict.fromkeys()`..."""
|
||||
"""NOTE: can't be called on `coords` list, because it contains unfrozen Vectors."""
|
||||
unique_verts = []
|
||||
for vert in coords:
|
||||
if vert not in unique_verts:
|
||||
unique_verts.append(vert)
|
||||
|
||||
array_coords = unique_verts if self.closed else unique_verts[:-1]
|
||||
|
||||
return coords, indices, click_point, array_coords
|
||||
|
||||
|
||||
def draw_array(self, verts):
|
||||
"""Duplicates given list of vertices in rows and columns (on screen-space x and y axis)"""
|
||||
"""Returns two dicts of lists of vertices for rows and columns separately"""
|
||||
|
||||
# get_bounding_box_of_the_shape
|
||||
"""NOTE: Calculated separately because verts needed for array differs from verts needed for shape for polyline"""
|
||||
min_x, min_y, max_x, max_y = get_bounding_box(verts)
|
||||
|
||||
rows = {}
|
||||
if self.rows > 1:
|
||||
# Offset
|
||||
offset = mathutils.Vector((((max_x - min_x) + (self.rows_gap)), 0.0, 0.0))
|
||||
if self.rows_direction == 'LEFT':
|
||||
offset.x = -offset.x
|
||||
|
||||
for i in range(self.rows - 1):
|
||||
accumulated_offset = offset * (i + 1)
|
||||
rows[i] = [vert.copy() + accumulated_offset for vert in verts]
|
||||
|
||||
columns = {}
|
||||
if self.columns > 1:
|
||||
# Offset
|
||||
offset = mathutils.Vector((0.0, -((max_y - min_y) + (self.columns_gap)), 0.0))
|
||||
if self.columns_direction == 'UP':
|
||||
offset.y = -offset.y
|
||||
|
||||
for i in range(self.columns - 1):
|
||||
accumulated_offset = offset * (i + 1)
|
||||
columns[i] = [vert.copy() + accumulated_offset for vert in verts]
|
||||
for row_idx, row in rows.items():
|
||||
columns[(i, row_idx)] = [vert.copy() + accumulated_offset for vert in row]
|
||||
|
||||
return rows, columns
|
||||
|
||||
|
||||
def bevel_verts(self, verts, radius, segments):
|
||||
"""Takes in list of verts(Vectors) and bevels them, Returns a new list with new vertices"""
|
||||
|
||||
def get_rounded_corner(self, angular_point, p1, p2, radius, segments):
|
||||
# get_bounding_box_of_the_shape
|
||||
min_x, min_y, max_x, max_y = get_bounding_box(verts)
|
||||
width = max_x - min_x
|
||||
height = max_y - min_y
|
||||
|
||||
# clamp_radius_to_reduce_clipping
|
||||
max_radius = min(width / 2.5, height / 2.5)
|
||||
clamped_radius = min(radius, max_radius)
|
||||
|
||||
if radius > clamped_radius:
|
||||
radius = clamped_radius
|
||||
|
||||
|
||||
# calculate_vectors (NOTE: Why it only works when reversed like this is unknown to me)
|
||||
if self.bevel_profile == 'CONVEX':
|
||||
vector1 = -(p1 - angular_point)
|
||||
vector2 = -(p2 - angular_point)
|
||||
elif self.bevel_profile == 'CONCAVE':
|
||||
vector1 = p2 - angular_point
|
||||
vector2 = p1 - angular_point
|
||||
|
||||
# compute_lengths_of_vectors
|
||||
length1 = vector1.length
|
||||
length2 = vector2.length
|
||||
if length1 == 0 or length2 == 0:
|
||||
return [angular_point] * segments
|
||||
|
||||
vector1.normalize()
|
||||
vector2.normalize()
|
||||
|
||||
# calculate_the_angle_between_the_vectors
|
||||
dot_product = vector1.dot(vector2)
|
||||
angle = math.acos(max(-1.0, min(1.0, dot_product)))
|
||||
|
||||
arc_length = radius * angle
|
||||
segment_length = arc_length / (segments - 1)
|
||||
bisector = (vector1 + vector2).normalized()
|
||||
|
||||
# generate_points_along_the_arc
|
||||
rounded_corners = []
|
||||
for i in range(segments):
|
||||
fraction = i / (segments - 1)
|
||||
theta = angle * fraction
|
||||
interpolated_vector = (vector1 * math.sin(theta) + vector2 * math.cos(theta)).normalized() * radius
|
||||
if self.bevel_profile == 'CONVEX':
|
||||
point_on_arc = angular_point + interpolated_vector - bisector * (clamped_radius * magic_number)
|
||||
elif self.bevel_profile == 'CONCAVE':
|
||||
point_on_arc = angular_point + interpolated_vector - bisector / (clamped_radius)
|
||||
rounded_corners.append(point_on_arc)
|
||||
|
||||
return rounded_corners
|
||||
|
||||
rounded_verts = []
|
||||
indices = []
|
||||
num_verts = len(verts)
|
||||
|
||||
for idx in range(num_verts):
|
||||
angular_point = verts[idx]
|
||||
prev_idx = (idx - 1) % num_verts
|
||||
next_idx = (idx + 1) % num_verts
|
||||
|
||||
p1 = verts[prev_idx]
|
||||
p2 = verts[next_idx]
|
||||
|
||||
corner_points = get_rounded_corner(self, angular_point, p1, p2, radius, segments)
|
||||
rounded_verts.extend(corner_points)
|
||||
|
||||
for idx, vert in enumerate(reversed(rounded_verts)):
|
||||
i1 = idx + 1
|
||||
i2 = idx + 2 if idx + 2 <= len(rounded_verts) else 1
|
||||
indices.append((0, i1, i2))
|
||||
|
||||
return rounded_verts, indices
|
||||
|
||||
|
||||
def get_bounding_box(verts):
|
||||
"""Calculates the bounding box coordinates from a list of vertices"""
|
||||
|
||||
min_x = min(v[0] for v in verts)
|
||||
max_x = max(v[0] for v in verts)
|
||||
min_y = min(v[1] for v in verts)
|
||||
max_y = max(v[1] for v in verts)
|
||||
|
||||
return min_x, min_y, max_x, max_y
|
||||
return p3_on_plane
|
||||
|
||||
@@ -4,164 +4,102 @@ import mathutils
|
||||
import math
|
||||
from bpy_extras import view3d_utils
|
||||
|
||||
from .object import hide_objects
|
||||
from .types import Ray
|
||||
|
||||
|
||||
#### ------------------------------ FUNCTIONS ------------------------------ ####
|
||||
|
||||
def create_cutter_shape(self, context):
|
||||
"""Creates flat mesh from the vertices provided in `self.verts` (which is created by `carver_overlay`)"""
|
||||
|
||||
# ALIGNMENT: View
|
||||
coords = self.mouse_path[0][0], self.mouse_path[0][1]
|
||||
region = context.region
|
||||
rv3d = context.region_data
|
||||
depth_location = view3d_utils.region_2d_to_vector_3d(region, rv3d, coords)
|
||||
self.view_depth = depth_location
|
||||
plane_direction = depth_location.normalized()
|
||||
|
||||
# depth
|
||||
if self.depth == 'CURSOR':
|
||||
plane_point = context.scene.cursor.location
|
||||
elif self.depth == 'VIEW':
|
||||
__, plane_point = combined_bounding_box(self.selected_objects)
|
||||
plane_point = mathutils.Vector(plane_point)
|
||||
|
||||
# Create Mesh & Object
|
||||
faces = {}
|
||||
mesh = bpy.data.meshes.new(name='cutter')
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(mesh)
|
||||
|
||||
obj = bpy.data.objects.new('cutter', mesh)
|
||||
obj.booleans.carver = True
|
||||
self.cutter = obj
|
||||
context.collection.objects.link(obj)
|
||||
|
||||
# Create Faces from `self.verts`
|
||||
create_face(context, plane_direction, plane_point,
|
||||
bm, "original", faces, self.verts)
|
||||
|
||||
# ARRAY
|
||||
if len(self.duplicates) > 0:
|
||||
for i, duplicate in self.duplicates.items():
|
||||
create_face(context, plane_direction, plane_point,
|
||||
bm, str(i), faces, duplicate)
|
||||
|
||||
bm.verts.index_update()
|
||||
for i, face in faces.items():
|
||||
bm.faces.new(face)
|
||||
|
||||
# remove_doubles
|
||||
bmesh.ops.remove_doubles(bm, verts=[v for v in bm.verts], dist=0.0001)
|
||||
|
||||
bm.to_mesh(mesh)
|
||||
|
||||
|
||||
def extrude(self, mesh):
|
||||
def extrude_face(bm, face):
|
||||
"""Extrudes cutter face (created by carve operation) along view vector to create a non-manifold mesh"""
|
||||
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(mesh)
|
||||
faces = [f for f in bm.faces]
|
||||
bm.faces.ensure_lookup_table()
|
||||
|
||||
# move_the_mesh_towards_view
|
||||
box_bounding, __ = combined_bounding_box(self.selected_objects)
|
||||
for face in faces:
|
||||
for vert in face.verts:
|
||||
vert.co += -self.view_depth * box_bounding
|
||||
# Extrude
|
||||
result = bmesh.ops.extrude_face_region(bm, geom=[bm.faces[face.index]])
|
||||
|
||||
# extrude_the_face
|
||||
ret = bmesh.ops.extrude_face_region(bm, geom=faces)
|
||||
verts_extruded = [v for v in ret['geom'] if isinstance(v, bmesh.types.BMVert)]
|
||||
for v in verts_extruded:
|
||||
if self.depth == 'CURSOR':
|
||||
v.co += self.view_depth * box_bounding
|
||||
elif self.depth == 'VIEW':
|
||||
v.co += self.view_depth * box_bounding * 2
|
||||
# Offset extruded vertices.
|
||||
extruded_verts = [v for v in result['geom'] if isinstance(v, bmesh.types.BMVert)]
|
||||
extruded_edges = [e for e in result['geom'] if isinstance(e, bmesh.types.BMEdge)]
|
||||
extruded_faces = [f for f in result['geom'] if isinstance(f, bmesh.types.BMFace)]
|
||||
|
||||
# correct_normals
|
||||
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
|
||||
|
||||
bm.to_mesh(mesh)
|
||||
mesh.update()
|
||||
bm.free()
|
||||
return extruded_verts, extruded_edges, extruded_faces
|
||||
|
||||
|
||||
def combined_bounding_box(objects):
|
||||
"""Calculate the combined bounding box of multiple objects."""
|
||||
|
||||
min_corner = mathutils.Vector((float('inf'), float('inf'), float('inf')))
|
||||
max_corner = mathutils.Vector((-float('inf'), -float('inf'), -float('inf')))
|
||||
|
||||
for obj in objects:
|
||||
# Transform the bounding box corners to world space
|
||||
bbox_corners = [obj.matrix_world @ mathutils.Vector(corner) for corner in obj.bound_box]
|
||||
|
||||
for corner in bbox_corners:
|
||||
min_corner.x = min(min_corner.x, corner.x)
|
||||
min_corner.y = min(min_corner.y, corner.y)
|
||||
min_corner.z = min(min_corner.z, corner.z)
|
||||
max_corner.x = max(max_corner.x, corner.x)
|
||||
max_corner.y = max(max_corner.y, corner.y)
|
||||
max_corner.z = max(max_corner.z, corner.z)
|
||||
|
||||
# Calculate the diagonal of the combined bounding box
|
||||
bounding_box_diag = (max_corner - min_corner).length
|
||||
# Calculate the center of bounding box
|
||||
bounding_box_center = (max_corner + min_corner) * 0.5
|
||||
|
||||
return bounding_box_diag, bounding_box_center
|
||||
|
||||
|
||||
def create_face(context, direction, depth, bm, name, faces, verts, polyline=False):
|
||||
"""Creates bmesh face with given list of vertices and appends it to given 'faces' dict"""
|
||||
|
||||
def intersect_line_plane(context, vert, direction, depth):
|
||||
"""Finds the intersection of a line going through each vertex and the infinite plane"""
|
||||
|
||||
region = context.region
|
||||
rv3d = context.region_data
|
||||
|
||||
vec = view3d_utils.region_2d_to_vector_3d(region, rv3d, vert)
|
||||
p0 = view3d_utils.region_2d_to_location_3d(region, rv3d, vert, vec)
|
||||
p1 = p0 + direction
|
||||
loc = mathutils.geometry.intersect_line_plane(p0, p1, depth, direction)
|
||||
|
||||
return loc
|
||||
|
||||
face_verts = []
|
||||
for i, vert in enumerate(verts):
|
||||
loc = intersect_line_plane(context, vert, direction, depth)
|
||||
vertex = bm.verts.new(loc)
|
||||
face_verts.append(vertex)
|
||||
|
||||
faces[name] = face_verts
|
||||
|
||||
|
||||
def shade_smooth_by_angle(obj, angle=30):
|
||||
def shade_smooth_by_angle(bm, mesh, angle=30):
|
||||
"""Replication of "Auto Smooth" functionality: Marks faces as smooth, sharp edges (by angle) as sharp"""
|
||||
|
||||
mesh = obj.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(mesh)
|
||||
|
||||
# shade_smooth
|
||||
for f in bm.faces:
|
||||
f.smooth = True
|
||||
|
||||
# select_sharp_edges
|
||||
for edge in bm.edges:
|
||||
if len(edge.link_faces) == 2:
|
||||
face1, face2 = edge.link_faces
|
||||
edge_angle = math.degrees(face1.normal.angle(face2.normal))
|
||||
if edge_angle >= angle:
|
||||
edge.select = True
|
||||
if len(edge.link_faces) != 2:
|
||||
continue
|
||||
|
||||
face1, face2 = edge.link_faces
|
||||
if face1.normal.length <= 0 or face2.normal.length <= 0:\
|
||||
continue
|
||||
|
||||
edge_angle = math.degrees(face1.normal.angle(face2.normal))
|
||||
if edge_angle < 0:
|
||||
continue
|
||||
if edge_angle < angle:
|
||||
continue
|
||||
|
||||
edge.smooth = False
|
||||
|
||||
bm.to_mesh(mesh)
|
||||
bm.free()
|
||||
mesh.update()
|
||||
|
||||
# mark_sharp_edges
|
||||
for edge in mesh.edges:
|
||||
if edge.select:
|
||||
edge.use_edge_sharp = True
|
||||
mesh.update()
|
||||
|
||||
def are_intersecting(obj_a, obj_b):
|
||||
"""Checks if bounding boxes of two given objects intersect."""
|
||||
|
||||
def world_bounds(obj):
|
||||
corners = [obj.matrix_world @ mathutils.Vector(c) for c in obj.bound_box]
|
||||
xs = [c.x for c in corners]
|
||||
ys = [c.y for c in corners]
|
||||
zs = [c.z for c in corners]
|
||||
return (min(xs), max(xs)), (min(ys), max(ys)), (min(zs), max(zs))
|
||||
|
||||
(ax0, ax1), (ay0, ay1), (az0, az1) = world_bounds(obj_a)
|
||||
(bx0, bx1), (by0, by1), (bz0, bz1) = world_bounds(obj_b)
|
||||
|
||||
return (
|
||||
ax1 >= bx0 and ax0 <= bx1 and
|
||||
ay1 >= by0 and ay0 <= by1 and
|
||||
az1 >= bz0 and az0 <= bz1
|
||||
)
|
||||
|
||||
|
||||
def ensure_attribute(bm, name, domain):
|
||||
"""Ensure that the attribute with the given name and domain exists on mesh."""
|
||||
|
||||
if domain == 'EDGE':
|
||||
attr = bm.edges.layers.float.get(name)
|
||||
if not attr:
|
||||
attr = bm.edges.layers.float.new(name)
|
||||
|
||||
elif domain == 'VERTEX':
|
||||
attr = bm.verts.layers.float.get(name)
|
||||
if not attr:
|
||||
attr = bm.verts.layers.float.new(name)
|
||||
|
||||
return attr
|
||||
|
||||
|
||||
def raycast(context, position, objects):
|
||||
"""Cast a ray in the scene to get the surface on any of the given objects."""
|
||||
|
||||
region = context.region
|
||||
rv3d = context.region_data
|
||||
depsgraph = context.view_layer.depsgraph
|
||||
|
||||
origin = view3d_utils.region_2d_to_origin_3d(region, rv3d, position)
|
||||
direction = view3d_utils.region_2d_to_vector_3d(region, rv3d, position)
|
||||
|
||||
# Cast Ray
|
||||
with hide_objects(context, exceptions=objects):
|
||||
hit, location, normal, index, object, matrix = context.scene.ray_cast(depsgraph, origin, direction)
|
||||
ray = Ray(hit, location, normal, index, object, matrix)
|
||||
|
||||
return ray
|
||||
|
||||
@@ -3,6 +3,9 @@ import bmesh
|
||||
from contextlib import contextmanager
|
||||
from .. import __package__ as base_package
|
||||
|
||||
from ..functions.list import (
|
||||
list_pre_boolean_modifiers,
|
||||
)
|
||||
from .object import (
|
||||
convert_to_mesh,
|
||||
)
|
||||
@@ -21,7 +24,7 @@ def add_boolean_modifier(self, context, obj, cutter, mode, solver, pin=False, re
|
||||
|
||||
prefs = context.preferences.addons[base_package].preferences
|
||||
|
||||
modifier = obj.modifiers.new("boolean_" + cutter.name, 'BOOLEAN')
|
||||
modifier = obj.modifiers.new("boolean_" + cutter.name.replace("boolean_", ""), 'BOOLEAN')
|
||||
modifier.operation = mode
|
||||
modifier.object = cutter
|
||||
modifier.solver = solver
|
||||
@@ -44,7 +47,7 @@ def add_boolean_modifier(self, context, obj, cutter, mode, solver, pin=False, re
|
||||
return modifier
|
||||
|
||||
|
||||
def apply_modifiers(context, obj, modifiers: list):
|
||||
def apply_modifiers(context, obj, modifiers: list, force_clean=False):
|
||||
"""
|
||||
Apply modifiers on object.
|
||||
Instead of using `bpy.ops.object.modifier_apply`, this function uses
|
||||
@@ -63,9 +66,10 @@ def apply_modifiers(context, obj, modifiers: list):
|
||||
context.active_object.data = context.active_object.data.copy()
|
||||
|
||||
try:
|
||||
# Don't use this method if it's not enabled by user in add-on preferences.
|
||||
# Don't use this method if it's not enabled by user in preferences, unless caller forces it.
|
||||
if not prefs.fast_modifier_apply:
|
||||
raise Exception("")
|
||||
if not force_clean:
|
||||
raise Exception()
|
||||
|
||||
with hide_modifiers(obj, excluding=modifiers):
|
||||
# Create a temporary mesh from evaluated object.
|
||||
@@ -99,7 +103,7 @@ def apply_modifiers(context, obj, modifiers: list):
|
||||
except Exception as e:
|
||||
# print("Error applying modifiers with `bmesh` method:", e, "falling back to `bpy.ops` method")
|
||||
|
||||
context_override = {"object": obj, "mode": 'OBJECT'}
|
||||
context_override = {"active_object": obj, "mode": 'OBJECT'}
|
||||
with context.temp_override(**context_override):
|
||||
# Apply shape keys if there are any.
|
||||
if obj.data.shape_keys:
|
||||
@@ -132,3 +136,48 @@ def hide_modifiers(obj, excluding: list):
|
||||
finally:
|
||||
for mod in visible_modifiers:
|
||||
mod.show_viewport = True
|
||||
|
||||
|
||||
def add_modifier_asset(obj, path: str, asset: str):
|
||||
"""Loads the node group asset and adds a Geometry Nodes modifier using it."""
|
||||
|
||||
try:
|
||||
# Load the node group.
|
||||
if bpy.app.version >= (5, 0, 0):
|
||||
with bpy.data.libraries.load(path, link=True, pack=True) as (data_from, data_to):
|
||||
if asset in data_from.node_groups:
|
||||
data_to.node_groups = [asset]
|
||||
|
||||
else:
|
||||
with bpy.data.libraries.load(path) as (data_from, data_to):
|
||||
if asset in data_from.node_groups:
|
||||
data_to.node_groups = [asset]
|
||||
|
||||
node_group = bpy.data.node_groups[asset]
|
||||
|
||||
# Add modifier on the object.
|
||||
mod = obj.modifiers.new(asset, type='NODES')
|
||||
mod.node_group = node_group
|
||||
mod.show_group_selector = False
|
||||
mod.show_manage_panel = False
|
||||
|
||||
return mod
|
||||
|
||||
except Exception as e:
|
||||
print("Modifier node group could not be loaded:", e)
|
||||
return None
|
||||
|
||||
|
||||
def get_modifiers_to_apply(context, obj, new_modifiers) -> list:
|
||||
"""Returns the list of modifiers that need to be applied based on add-on preferences."""
|
||||
|
||||
prefs = context.preferences.addons[base_package].preferences
|
||||
|
||||
if prefs.apply_order == 'ALL':
|
||||
modifiers = [mod for mod in obj.modifiers]
|
||||
elif prefs.apply_order == 'BOOLEANS':
|
||||
modifiers = new_modifiers
|
||||
elif prefs.apply_order == 'BEFORE':
|
||||
modifiers = list_pre_boolean_modifiers(obj)
|
||||
|
||||
return modifiers
|
||||
|
||||
@@ -1,35 +1,26 @@
|
||||
import bpy
|
||||
import bmesh
|
||||
import mathutils
|
||||
from contextlib import contextmanager
|
||||
from .. import __package__ as base_package
|
||||
|
||||
|
||||
#### ------------------------------ FUNCTIONS ------------------------------ ####
|
||||
|
||||
def set_cutter_properties(context, canvas, cutter, mode, parent=True, hide=False, collection=True):
|
||||
def set_cutter_properties(context, cutter, mode, display='BOUNDS', collection=True):
|
||||
"""Ensures cutter is properly set: has right properties, is hidden, in a collection & parented"""
|
||||
|
||||
prefs = context.preferences.addons[base_package].preferences
|
||||
|
||||
# Hide Cutters
|
||||
cutter.hide_render = True
|
||||
cutter.display_type = 'WIRE' if prefs.wireframe else 'BOUNDS'
|
||||
cutter.display_type = display
|
||||
cutter.lineart.usage = 'EXCLUDE'
|
||||
object_visibility_set(cutter, value=False)
|
||||
if hide:
|
||||
cutter.hide_set(True)
|
||||
|
||||
# parent_to_active_canvas
|
||||
if parent and cutter.parent == None:
|
||||
cutter.parent = canvas
|
||||
cutter.matrix_parent_inverse = canvas.matrix_world.inverted()
|
||||
|
||||
# Cutters Collection
|
||||
if collection:
|
||||
cutters_collection = ensure_collection(context)
|
||||
if cutters_collection not in cutter.users_collection:
|
||||
cutters_collection.objects.link(cutter)
|
||||
if cutter.booleans.carver and parent == False:
|
||||
context.collection.objects.unlink(cutter)
|
||||
|
||||
# add_boolean_property
|
||||
cutter.booleans.cutter = mode.capitalize()
|
||||
@@ -103,12 +94,18 @@ def delete_cutter(cutter):
|
||||
bpy.data.meshes.remove(orphaned_mesh)
|
||||
|
||||
|
||||
def change_parent(object, parent):
|
||||
def change_parent(obj, parent, force=False, inverse=False):
|
||||
"""Changes or removes parent from cutter object while keeping the transformation"""
|
||||
|
||||
matrix_copy = object.matrix_world.copy()
|
||||
object.parent = parent
|
||||
object.matrix_world = matrix_copy
|
||||
if obj.parent is not None:
|
||||
if not force:
|
||||
return
|
||||
|
||||
matrix_copy = obj.matrix_world.copy()
|
||||
obj.parent = parent
|
||||
if inverse:
|
||||
obj.matrix_parent_inverse = parent.matrix_world.inverted()
|
||||
obj.matrix_world = matrix_copy
|
||||
|
||||
|
||||
def create_slice(context, canvas, modifier=False):
|
||||
@@ -136,14 +133,49 @@ def create_slice(context, canvas, modifier=False):
|
||||
return slice
|
||||
|
||||
|
||||
def set_object_origin(obj, position=False):
|
||||
def set_object_origin(obj, bm, point='CENTER', custom=None):
|
||||
"""Sets object origin to given position by shifting vertices"""
|
||||
|
||||
# default_to_center_of_bounding_box_if_no_position_provided
|
||||
if position == False:
|
||||
position = 0.125 * sum((mathutils.Vector(b) for b in obj.bound_box), mathutils.Vector())
|
||||
# Center of the bounding box.
|
||||
if point == 'CENTER_OBJ':
|
||||
position_local = 0.125 * sum((mathutils.Vector(b) for b in obj.bound_box), mathutils.Vector())
|
||||
position_world = obj.matrix_world @ position_local
|
||||
|
||||
mat = mathutils.Matrix.Translation(position - obj.location)
|
||||
obj.location = position
|
||||
obj.data.transform(mat.inverted())
|
||||
obj.data.update()
|
||||
# Center of the geometry.
|
||||
elif point == 'CENTER_MESH':
|
||||
if len(bm.verts) > 0:
|
||||
position_local = sum((v.co for v in bm.verts), mathutils.Vector()) / len(bm.verts)
|
||||
else:
|
||||
position_local = mathutils.Vector((0, 0, 0))
|
||||
position_world = obj.matrix_world @ position_local
|
||||
|
||||
# Custom origin point (should be local Vector).
|
||||
elif point == 'CUSTOM':
|
||||
position_local = custom
|
||||
position_world = obj.matrix_world @ custom
|
||||
|
||||
mat = mathutils.Matrix.Translation(position_local)
|
||||
bmesh.ops.transform(bm, matrix=mat.inverted(), verts=bm.verts)
|
||||
bm.to_mesh(obj.data)
|
||||
|
||||
obj.location = position_world
|
||||
|
||||
|
||||
@contextmanager
|
||||
def hide_objects(context, exceptions: list):
|
||||
"""Hides objects during the context, and restores their visibility afterwards."""
|
||||
|
||||
hidden_objects = []
|
||||
for obj in context.scene.objects:
|
||||
if obj in exceptions:
|
||||
continue
|
||||
if obj.hide_get() == False:
|
||||
hidden_objects.append(obj)
|
||||
obj.hide_set(True)
|
||||
|
||||
try:
|
||||
yield
|
||||
|
||||
finally:
|
||||
for obj in hidden_objects:
|
||||
obj.hide_set(False)
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
import bpy
|
||||
import mathutils
|
||||
from bpy_extras import view3d_utils
|
||||
|
||||
from .math import get_bounding_box
|
||||
from .poll import is_linked, is_instanced_data
|
||||
|
||||
|
||||
#### ------------------------------ FUNCTIONS ------------------------------ ####
|
||||
|
||||
def cursor_snap(self, context, event, mouse_pos):
|
||||
"""Find the closest position on the overlay grid and snap the mouse on it"""
|
||||
|
||||
region = context.region
|
||||
rv3d = context.region_data
|
||||
|
||||
for i, a in enumerate(context.screen.areas):
|
||||
if a.type == 'VIEW_3D':
|
||||
space = context.screen.areas[i].spaces.active
|
||||
|
||||
# get_the_grid_overlay
|
||||
grid_scale = space.overlay.grid_scale
|
||||
grid_subdivisions = space.overlay.grid_subdivisions
|
||||
|
||||
# use_grid_scale_and_subdivision_to_get_the_increment
|
||||
increment = (grid_scale / grid_subdivisions)
|
||||
half_increment = increment / 2
|
||||
|
||||
# convert_2d_location_of_the_mouse_in_3d
|
||||
for index, loc in enumerate(reversed(mouse_pos)):
|
||||
mouse_loc_3d = view3d_utils.region_2d_to_location_3d(region, rv3d, loc, (0, 0, 0))
|
||||
|
||||
# get_the_remainder_from_the_mouse_location_and_the_ratio (test_if_the_remainder_>_to_the_half_of_the_increment)
|
||||
for i in range(3):
|
||||
modulo = mouse_loc_3d[i] % increment
|
||||
if modulo < half_increment:
|
||||
modulo = -modulo
|
||||
else:
|
||||
modulo = increment - modulo
|
||||
|
||||
# add_the_remainder_to_get_the_closest_location_on_the_grid
|
||||
mouse_loc_3d[i] = mouse_loc_3d[i] + modulo
|
||||
|
||||
snap_loc_2d = view3d_utils.location_3d_to_region_2d(region, rv3d, mouse_loc_3d)
|
||||
|
||||
# replace_the_last_mouse_location_by_the_snapped_location
|
||||
if len(self.mouse_path) > 0:
|
||||
self.mouse_path[len(self.mouse_path) - (index + 1) ] = tuple(snap_loc_2d)
|
||||
|
||||
|
||||
def is_inside_selection(context, obj, rect_min, rect_max):
|
||||
"""Checks if the bounding box of an object intersects with the selection bounding box"""
|
||||
|
||||
region = context.region
|
||||
rv3d = context.space_data.region_3d
|
||||
|
||||
bound_corners = [obj.matrix_world @ mathutils.Vector(corner) for corner in obj.bound_box]
|
||||
bound_corners_2d = [view3d_utils.location_3d_to_region_2d(region, rv3d, corner) for corner in bound_corners]
|
||||
|
||||
# check_if_2d_point_is_inside_rectangle_(defined_by_min_and_max_points)
|
||||
for corner_2d in bound_corners_2d:
|
||||
if corner_2d and (rect_min.x <= corner_2d.x <= rect_max.x and rect_min.y <= corner_2d.y <= rect_max.y):
|
||||
return True
|
||||
|
||||
# check_if_any_part_of_the_bounding_box_intersects_the_selection_rectangle
|
||||
min_x = min(corner_2d.x for corner_2d in bound_corners_2d if corner_2d)
|
||||
max_x = max(corner_2d.x for corner_2d in bound_corners_2d if corner_2d)
|
||||
min_y = min(corner_2d.y for corner_2d in bound_corners_2d if corner_2d)
|
||||
max_y = max(corner_2d.y for corner_2d in bound_corners_2d if corner_2d)
|
||||
|
||||
return not (max_x < rect_min.x or min_x > rect_max.x or max_y < rect_min.y or min_y > rect_max.y)
|
||||
|
||||
|
||||
def selection_fallback(self, context, objects, shape='BOX', include_cutters=False):
|
||||
"""Returns mesh objects that fall inside given 2d rectangle (bounding box of the shape) coordinates"""
|
||||
"""Needed to know exactly which objects should be carved, to avoid adding and applying unnecessary modifiers"""
|
||||
"""NOTE: bounding box isn't always returning correct results, but checking full shape would be too expensive"""
|
||||
|
||||
if shape == 'POLYLINE':
|
||||
x_values = [point[0] for point in self.mouse_path]
|
||||
y_values = [point[1] for point in self.mouse_path]
|
||||
rect_min = mathutils.Vector((min(x_values), min(y_values)))
|
||||
rect_max = mathutils.Vector((max(x_values), max(y_values)))
|
||||
|
||||
elif shape == 'BOX':
|
||||
if self.origin == 'EDGE':
|
||||
rect_min = mathutils.Vector((min(self.mouse_path[0][0], self.mouse_path[1][0]),
|
||||
min(self.mouse_path[0][1], self.mouse_path[1][1])))
|
||||
rect_max = mathutils.Vector((max(self.mouse_path[0][0], self.mouse_path[1][0]),
|
||||
max(self.mouse_path[0][1], self.mouse_path[1][1])))
|
||||
|
||||
elif self.origin == 'CENTER':
|
||||
# get_bounding_box_of_the_shape
|
||||
min_x, min_y, max_x, max_y = get_bounding_box(self.verts)
|
||||
|
||||
rect_min = mathutils.Vector((min(min_x, max_x), min(min_y, max_y)))
|
||||
rect_max = mathutils.Vector((max(min_x, max_x), max(min_y, max_y)))
|
||||
|
||||
# ARRAY
|
||||
if self.rows > 1:
|
||||
rect_max.x = rect_min.x + (rect_max.x - rect_min.x) * self.rows + (self.rows_gap * (self.rows - 1))
|
||||
if self.columns > 1:
|
||||
rect_min.y = rect_max.y - (rect_max.y - rect_min.y) * self.columns - (self.columns_gap * (self.columns - 1))
|
||||
|
||||
|
||||
intersecting_objects = []
|
||||
for obj in objects:
|
||||
if obj.type != 'MESH':
|
||||
continue
|
||||
if obj == self.cutter:
|
||||
continue
|
||||
if tuple(round(v, 4) for v in obj.dimensions) == (0.0, 0.0, 0.0):
|
||||
continue
|
||||
if include_cutters == False and obj.booleans.cutter != "":
|
||||
continue
|
||||
|
||||
if is_inside_selection(context, obj, rect_min, rect_max):
|
||||
if is_linked(context, obj):
|
||||
self.report({'ERROR'}, f"{obj.name} is linked and can not be carved")
|
||||
continue
|
||||
|
||||
if self.mode == 'DESTRUCTIVE':
|
||||
if is_instanced_data(obj):
|
||||
self.report({'ERROR'}, f"Modifiers cannot be applied to {obj.name} because it has instanced object data")
|
||||
continue
|
||||
|
||||
intersecting_objects.append(obj)
|
||||
|
||||
return intersecting_objects
|
||||
@@ -0,0 +1,23 @@
|
||||
import bpy
|
||||
import mathutils
|
||||
from mathutils import Vector, Matrix
|
||||
|
||||
|
||||
#### ------------------------------ CLASSES ------------------------------ ####
|
||||
|
||||
class Ray:
|
||||
"""Class object for storing raycast results."""
|
||||
|
||||
def __init__(self,
|
||||
hit: bool,
|
||||
location: Vector,
|
||||
normal: Vector,
|
||||
index: int,
|
||||
obj,
|
||||
matrix: Matrix):
|
||||
self.hit = hit
|
||||
self.location = location if location is not None else mathutils.Vector()
|
||||
self.normal = normal if normal is not None else mathutils.Vector()
|
||||
self.index = index
|
||||
self.obj = obj
|
||||
self.matrix = matrix if matrix is not None else mathutils.Matrix()
|
||||
@@ -0,0 +1,24 @@
|
||||
import bpy
|
||||
import os
|
||||
import bpy.utils.previews
|
||||
|
||||
|
||||
#### ------------------------------ REGISTRATION ------------------------------ ####
|
||||
|
||||
svg_icons = {}
|
||||
icons = bpy.utils.previews.new()
|
||||
dir = os.path.join(os.path.dirname(__file__))
|
||||
|
||||
icons.load("MEASURE", os.path.join(dir, "measure.svg"), 'IMAGE')
|
||||
icons.load("CPU", os.path.join(dir, "cpu.svg"), 'IMAGE')
|
||||
svg_icons["main"] = icons
|
||||
|
||||
|
||||
def register():
|
||||
...
|
||||
|
||||
def unregister():
|
||||
# ICONS
|
||||
for pcoll in svg_icons.values():
|
||||
bpy.utils.previews.remove(pcoll)
|
||||
svg_icons.clear()
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="800" fill="#fff" viewBox="0 0 24 24"><g fill-rule="evenodd"><path d="M8.75 8a.75.75 0 0 0-.75.75v6.5c0 .414.336.75.75.75h6.5a.75.75 0 0 0 .75-.75v-6.5a.75.75 0 0 0-.75-.75h-6.5zm.75 6.5v-5h5v5h-5z"/><path d="M15.25 1a.75.75 0 0 1 .75.75V4h2.25c.966 0 1.75.784 1.75 1.75V8h2.25a.75.75 0 0 1 0 1.5H20v5h2.25a.75.75 0 0 1 0 1.5H20v2.25A1.75 1.75 0 0 1 18.25 20H16v2.25a.75.75 0 0 1-1.5 0V20h-5v2.25a.75.75 0 0 1-1.5 0V20H5.75A1.75 1.75 0 0 1 4 18.25V16H1.75a.75.75 0 0 1 0-1.5H4v-5H1.75a.75.75 0 0 1 0-1.5H4V5.75C4 4.784 4.784 4 5.75 4H8V1.75a.75.75 0 0 1 1.5 0V4h5V1.75a.75.75 0 0 1 .75-.75zm3 17.5a.25.25 0 0 0 .25-.25V5.75a.25.25 0 0 0-.25-.25H5.75a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h12.5z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 772 B |
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0"?>
|
||||
<svg width="23.999999999999996" height="23.999999999999996" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<g class="layer">
|
||||
<title>Layer 1</title>
|
||||
<path clip-rule="evenodd" d="m13.18,4.48l3.62,3.65a0.75,0.75 0 0 1 -1.16,1.09l-3.52,-3.59l-1.54,1.51l2.91,2.89a0.75,0.75 0 1 1 -1.06,1.06l-2.92,-2.88l-1.44,1.37l3.62,3.59a0.75,0.75 0 1 1 -1.06,1.06l-3.62,-3.59l-1.51,1.54l2.85,2.84a0.75,0.75 0 1 1 -1.06,1.06c-0.29,-0.3 -2.53,-2.51 -2.82,-2.81c-0.46,0.47 -1.18,1.23 -1.41,1.54c-0.29,0.38 -0.36,0.6 -0.36,0.8c0,0.19 0.07,0.41 0.36,0.79c0.3,0.4 0.77,0.87 1.47,1.57l1.44,1.44c0.7,0.7 1.17,1.17 1.57,1.47c0.38,0.29 0.6,0.36 0.79,0.36c0.2,0 0.42,-0.07 0.8,-0.36c0.4,-0.3 0.87,-0.77 1.57,-1.47l8.67,-8.67c0.7,-0.7 1.17,-1.17 1.47,-1.57c0.29,-0.38 0.36,-0.6 0.36,-0.8c0,-0.19 -0.07,-0.41 -0.36,-0.79c-0.3,-0.4 -0.77,-0.87 -1.47,-1.57l-1.44,-1.44c-0.7,-0.7 -1.17,-1.17 -1.57,-1.47c-0.38,-0.29 -0.6,-0.36 -0.79,-0.36c-0.2,0 -0.42,0.07 -0.8,0.36l-1.61,1.37zm0.7,-2.56c0.51,-0.4 1.05,-0.67 1.71,-0.67c0.65,0 1.19,0.27 1.7,0.67c0.49,0.37 1.03,0.91 1.68,1.56l1.52,1.52c0.65,0.65 1.19,1.19 1.56,1.68c0.4,0.51 0.67,1.05 0.67,1.7c0,0.66 -0.27,1.2 -0.67,1.71c-0.37,0.49 -0.91,1.03 -1.56,1.68l-8.75,8.75c-0.65,0.65 -1.19,1.19 -1.68,1.56c-0.51,0.4 -1.05,0.67 -1.71,0.67c-0.65,0 -1.19,-0.27 -1.7,-0.67c-0.49,-0.37 -1.03,-0.91 -1.68,-1.56l-1.52,-1.52c-0.65,-0.65 -1.19,-1.19 -1.57,-1.68c-0.39,-0.51 -0.66,-1.05 -0.66,-1.7c0,-0.66 0.27,-1.2 0.66,-1.71c0.38,-0.49 0.92,-1.03 1.57,-1.68l8.75,-8.75c0.65,-0.65 1.19,-1.19 1.68,-1.57l0,0.01z" fill="#ffffff" fill-rule="evenodd" id="svg_1"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -12,6 +12,7 @@ from ..functions.poll import (
|
||||
from ..functions.modifier import (
|
||||
add_boolean_modifier,
|
||||
apply_modifiers,
|
||||
get_modifiers_to_apply,
|
||||
)
|
||||
from ..functions.object import (
|
||||
set_cutter_properties,
|
||||
@@ -19,9 +20,6 @@ from ..functions.object import (
|
||||
create_slice,
|
||||
delete_cutter,
|
||||
)
|
||||
from ..functions.list import (
|
||||
list_pre_boolean_modifiers,
|
||||
)
|
||||
|
||||
|
||||
#### ------------------------------ PROPERTIES ------------------------------ ####
|
||||
@@ -108,8 +106,11 @@ class BrushBoolean(ModifierProperties):
|
||||
|
||||
for cutter in cutters:
|
||||
mode = "DIFFERENCE" if self.mode == "SLICE" else self.mode
|
||||
set_cutter_properties(context, canvas, cutter, self.mode, parent=prefs.parent, collection=prefs.use_collection)
|
||||
display = 'WIRE' if prefs.wireframe else 'BOUNDS'
|
||||
set_cutter_properties(context, cutter, self.mode, display=display, collection=prefs.use_collection)
|
||||
add_boolean_modifier(self, context, canvas, cutter, mode, prefs.solver, pin=prefs.pin)
|
||||
if prefs.parent:
|
||||
change_parent(cutter, canvas)
|
||||
|
||||
canvas.booleans.canvas = True
|
||||
|
||||
@@ -210,7 +211,7 @@ class AutoBoolean(ModifierProperties):
|
||||
|
||||
# Apply modifiers on canvas & slices.
|
||||
for obj, modifiers in new_modifiers.items():
|
||||
modifiers = self._get_modifiers_to_apply(prefs, obj, modifiers)
|
||||
modifiers = get_modifiers_to_apply(context, obj, modifiers)
|
||||
apply_modifiers(context, obj, modifiers)
|
||||
|
||||
# Delete cutters.
|
||||
@@ -220,19 +221,6 @@ class AutoBoolean(ModifierProperties):
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
def _get_modifiers_to_apply(self, prefs, obj, new_modifiers) -> list:
|
||||
"""Returns a list of modifiers that need to be applied based on add-on preferences."""
|
||||
|
||||
if prefs.apply_order == 'ALL':
|
||||
modifiers = [mod for mod in obj.modifiers]
|
||||
elif prefs.apply_order == 'BOOLEANS':
|
||||
modifiers = new_modifiers
|
||||
elif prefs.apply_order == 'BEFORE':
|
||||
modifiers = list_pre_boolean_modifiers(obj)
|
||||
|
||||
return modifiers
|
||||
|
||||
|
||||
class OBJECT_OT_boolean_auto_union(bpy.types.Operator, AutoBoolean):
|
||||
bl_idname = "object.boolean_auto_union"
|
||||
bl_label = "Boolean Union (Auto)"
|
||||
|
||||
@@ -20,9 +20,10 @@ else:
|
||||
|
||||
#### ------------------------------ REGISTRATION ------------------------------ ####
|
||||
|
||||
"""NOTE: Order of modules is important because of dependancies. Don't change without a reason."""
|
||||
modules = [
|
||||
carver_circle,
|
||||
carver_box,
|
||||
# carver_circle,
|
||||
carver_polyline,
|
||||
ui,
|
||||
]
|
||||
|
||||
@@ -1,31 +1,26 @@
|
||||
import bpy
|
||||
import mathutils
|
||||
import os
|
||||
from mathutils import Vector
|
||||
from .. import __file__ as base_file
|
||||
|
||||
from .common.base import (
|
||||
CarverModifierKeys,
|
||||
CarverBase,
|
||||
)
|
||||
from .common.properties import (
|
||||
CarverOperatorProperties,
|
||||
CarverModifierProperties,
|
||||
CarverCutterProperties,
|
||||
CarverArrayProperties,
|
||||
CarverBevelProperties,
|
||||
CarverPropsArray,
|
||||
CarverPropsBevel,
|
||||
)
|
||||
from .common.types import (
|
||||
Selection,
|
||||
Mouse,
|
||||
Workplane,
|
||||
Cutter,
|
||||
Effects,
|
||||
)
|
||||
from .common.ui import (
|
||||
carver_ui_common,
|
||||
)
|
||||
|
||||
from ..functions.draw import (
|
||||
carver_shape_box,
|
||||
)
|
||||
from ..functions.select import (
|
||||
cursor_snap,
|
||||
selection_fallback,
|
||||
)
|
||||
|
||||
|
||||
description = "Cut primitive shapes into mesh objects by box drawing"
|
||||
|
||||
@@ -39,16 +34,16 @@ class OBJECT_WT_carve_box(bpy.types.WorkSpaceTool):
|
||||
bl_space_type = 'VIEW_3D'
|
||||
bl_context_mode = 'OBJECT'
|
||||
|
||||
bl_icon = os.path.join(os.path.dirname(base_file), "icons", "ops.object.carver_box")
|
||||
bl_icon = os.path.join(os.path.dirname(base_file), "icons", "tool_icons", "ops.object.carver_box")
|
||||
bl_keymap = (
|
||||
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG'}, {"properties": [("shape", 'BOX')]}),
|
||||
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "shift": True}, {"properties": [("shape", 'BOX')]}),
|
||||
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "alt": True}, {"properties": [("shape", 'BOX')]}),
|
||||
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "shift": True, "alt": True}, {"properties": [("shape", 'BOX')]}),
|
||||
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True}, {"properties": [("shape", 'BOX')]}),
|
||||
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "shift": True}, {"properties": [("shape", 'BOX')]}),
|
||||
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "alt": True}, {"properties": [("shape", 'BOX')]}),
|
||||
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "shift": True, "alt": True}, {"properties": [("shape", 'BOX')]}),
|
||||
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG'}, {"properties": None}),
|
||||
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "shift": True}, {"properties": None}),
|
||||
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "alt": True}, {"properties": None}),
|
||||
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "shift": True, "alt": True}, {"properties": None}),
|
||||
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True}, {"properties": None}),
|
||||
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "shift": True}, {"properties": None}),
|
||||
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "alt": True}, {"properties": None}),
|
||||
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "shift": True, "alt": True}, {"properties": None}),
|
||||
)
|
||||
|
||||
def draw_settings(context, layout, tool):
|
||||
@@ -63,26 +58,21 @@ class MESH_WT_carve_box(OBJECT_WT_carve_box):
|
||||
|
||||
#### ------------------------------ OPERATORS ------------------------------ ####
|
||||
|
||||
class OBJECT_OT_carve_box(CarverBase, CarverModifierKeys, bpy.types.Operator,
|
||||
CarverOperatorProperties, CarverModifierProperties, CarverCutterProperties,
|
||||
CarverArrayProperties, CarverBevelProperties):
|
||||
class OBJECT_OT_carve_box(CarverBase,
|
||||
CarverPropsArray,
|
||||
CarverPropsBevel):
|
||||
bl_idname = "object.carve_box"
|
||||
bl_label = "Box Carve"
|
||||
bl_description = description
|
||||
bl_options = {'REGISTER', 'UNDO', 'DEPENDS_ON_CURSOR'}
|
||||
bl_cursor_pending = 'PICK_AREA'
|
||||
|
||||
shape: bpy.props.EnumProperty(
|
||||
name = "Shape",
|
||||
items = (('BOX', "Box", ""),
|
||||
('CIRCLE', "Circle", ""),
|
||||
('POLYLINE', "Polyline", "")),
|
||||
default = 'BOX',
|
||||
)
|
||||
|
||||
# SHAPE-properties
|
||||
shape = 'BOX'
|
||||
|
||||
aspect: bpy.props.EnumProperty(
|
||||
name = "Aspect",
|
||||
description = "The initial aspect",
|
||||
items = (('FREE', "Free", "Use an unconstrained aspect"),
|
||||
('FIXED', "Fixed", "Use a fixed 1:1 aspect")),
|
||||
default = 'FREE',
|
||||
@@ -100,12 +90,6 @@ class OBJECT_OT_carve_box(CarverBase, CarverModifierKeys, bpy.types.Operator,
|
||||
soft_min = -360, soft_max = 360,
|
||||
default = 0,
|
||||
)
|
||||
subdivision: bpy.props.IntProperty(
|
||||
name = "Circle Subdivisions",
|
||||
description = "Number of vertices that will make up the circular shape that will be extruded into a cylinder",
|
||||
min = 3, soft_max = 128,
|
||||
default = 16,
|
||||
)
|
||||
|
||||
|
||||
@classmethod
|
||||
@@ -114,36 +98,31 @@ class OBJECT_OT_carve_box(CarverBase, CarverModifierKeys, bpy.types.Operator,
|
||||
|
||||
|
||||
def invoke(self, context, event):
|
||||
self.selected_objects = context.selected_objects
|
||||
self.mouse_path = [(event.mouse_region_x, event.mouse_region_y),
|
||||
(event.mouse_region_x, event.mouse_region_y)]
|
||||
# Validate Selection
|
||||
self.objects = Selection(*self.validate_selection(context))
|
||||
|
||||
# initialize_empty_values
|
||||
self.verts = []
|
||||
self.duplicates = []
|
||||
self.cutter = None
|
||||
self.view_depth = mathutils.Vector()
|
||||
self.cached_mouse_position = () # needed_for_custom_modifier_keys
|
||||
if len(self.objects.selected) == 0:
|
||||
self.report({'WARNING'}, "Select mesh objects that should be carved")
|
||||
bpy.ops.view3d.select_box('INVOKE_DEFAULT')
|
||||
return {'CANCELLED'}
|
||||
|
||||
# Initialize Core Components
|
||||
self.mouse = Mouse().from_event(event)
|
||||
self.workplane = Workplane(*self.calculate_workplane(context))
|
||||
self.cutter = Cutter(*self.create_cutter(context))
|
||||
self.effects = Effects().from_invoke(self, context)
|
||||
|
||||
# cached_variables
|
||||
"""Important for storing context as it was when operator was invoked (untouched by the modal)"""
|
||||
self.initial_origin = self.origin
|
||||
self.initial_aspect = self.aspect
|
||||
|
||||
# modifier_keys
|
||||
self.snap = False
|
||||
self.move = False
|
||||
self.rotate = False
|
||||
self.gap = False
|
||||
self.bevel = False
|
||||
|
||||
# overlay_position (needed_for_moving_the_shape)
|
||||
self.position_offset_x = 0
|
||||
self.position_offset_y = 0
|
||||
self.initial_position = False
|
||||
"""Important for storing context as it was when operator was invoked (untouched by the modal)."""
|
||||
self.phase = "DRAW"
|
||||
self.initial_origin = self.origin # Initial shape origin.
|
||||
self.initial_aspect = self.aspect # Initial shape aspect.
|
||||
self._stored_phase = "DRAW"
|
||||
|
||||
# Add Draw Handler
|
||||
self._handle = bpy.types.SpaceView3D.draw_handler_add(carver_shape_box, (self, context, self.shape), 'WINDOW', 'POST_PIXEL')
|
||||
self._handler = bpy.types.SpaceView3D.draw_handler_add(self.draw_shaders,
|
||||
(context,),
|
||||
'WINDOW', 'POST_VIEW')
|
||||
context.window.cursor_set("MUTE")
|
||||
context.window_manager.modal_handler_add(self)
|
||||
|
||||
@@ -152,110 +131,177 @@ class OBJECT_OT_carve_box(CarverBase, CarverModifierKeys, bpy.types.Operator,
|
||||
|
||||
def modal(self, context, event):
|
||||
# Status Bar Text
|
||||
snap_text = ", [MOUSEWHEEL]: Change Snapping Increment" if self.snap else ""
|
||||
shape_text = "[SHIFT]: Aspect, [ALT]: Origin, [R]: Rotate, [ARROWS]: Array"
|
||||
array_text = ", [A]: Gap" if (self.rows > 1 or self.columns > 1) else ""
|
||||
bevel_text = ", [B]: Bevel" if self.shape == 'BOX' else ""
|
||||
context.workspace.status_text_set("[CTRL]: Snap Invert, [SPACEBAR]: Move, " + shape_text + bevel_text + array_text + snap_text)
|
||||
self.status(context)
|
||||
|
||||
# find_the_limit_of_the_3d_viewport_region
|
||||
self.redraw_region(context)
|
||||
|
||||
|
||||
# Modifier Keys
|
||||
self.modifier_snap(context, event)
|
||||
self.modifier_aspect(context, event)
|
||||
self.modifier_origin(context, event)
|
||||
self.modifier_rotate(context, event)
|
||||
self.modifier_bevel(context, event)
|
||||
self.modifier_array(context, event)
|
||||
self.modifier_move(context, event)
|
||||
self.event_aspect(context, event)
|
||||
self.event_origin(context, event)
|
||||
self.event_rotate(context, event)
|
||||
self.event_bevel(context, event)
|
||||
self.event_array(context, event)
|
||||
self.event_flip(context, event)
|
||||
self.event_move(context, event)
|
||||
|
||||
if event.type in {'NUMPAD_1', 'NUMPAD_2', 'NUMPAD_3', 'NUMPAD_4',
|
||||
'NUMPAD_5', 'NUMPAD_6', 'NUMPAD_7', 'NUMPAD_8', 'NUMPAD_9',
|
||||
'MIDDLEMOUSE', 'N'}:
|
||||
return {'PASS_THROUGH'}
|
||||
|
||||
if self.bevel == False and event.type in {'WHEELUPMOUSE', 'WHEELDOWNMOUSE'}:
|
||||
if event.type in {'MIDDLEMOUSE'}:
|
||||
return {'PASS_THROUGH'}
|
||||
if event.type in {'WHEELUPMOUSE', 'WHEELDOWNMOUSE'}:
|
||||
if self.phase != "BEVEL":
|
||||
return {'PASS_THROUGH'}
|
||||
|
||||
|
||||
# Mouse Move
|
||||
if event.type == 'MOUSEMOVE':
|
||||
# move
|
||||
if self.move:
|
||||
self.position_offset_x += (event.mouse_region_x - self.last_mouse_region_x)
|
||||
self.position_offset_y += (event.mouse_region_y - self.last_mouse_region_y)
|
||||
self.last_mouse_region_x = event.mouse_region_x
|
||||
self.last_mouse_region_y = event.mouse_region_y
|
||||
self.mouse.current = Vector((event.mouse_region_x, event.mouse_region_y))
|
||||
|
||||
# rotate
|
||||
elif self.rotate:
|
||||
self.rotation = event.mouse_region_x * 0.01
|
||||
# Draw
|
||||
if self.phase == "DRAW":
|
||||
self.update_cutter_shape(context)
|
||||
|
||||
# array
|
||||
elif self.gap:
|
||||
self.rows_gap = event.mouse_region_x * 0.1
|
||||
self.columns_gap = event.mouse_region_y * 0.1
|
||||
|
||||
# bevel
|
||||
elif self.bevel:
|
||||
self.bevel_radius = event.mouse_region_x * 0.002
|
||||
|
||||
# Draw Shape
|
||||
else:
|
||||
if len(self.mouse_path) > 0:
|
||||
# aspect
|
||||
if self.aspect == 'FIXED':
|
||||
side = max(abs(event.mouse_region_x - self.mouse_path[0][0]),
|
||||
abs(event.mouse_region_y - self.mouse_path[0][1]))
|
||||
self.mouse_path[len(self.mouse_path) - 1] = \
|
||||
(self.mouse_path[0][0] + (side if event.mouse_region_x >= self.mouse_path[0][0] else -side),
|
||||
self.mouse_path[0][1] + (side if event.mouse_region_y >= self.mouse_path[0][1] else -side))
|
||||
|
||||
elif self.aspect == 'FREE':
|
||||
self.mouse_path[len(self.mouse_path) - 1] = (event.mouse_region_x, event.mouse_region_y)
|
||||
|
||||
# snap (find_the_closest_position_on_the_overlay_grid_and_snap_the_shape_to_it)
|
||||
if self.snap:
|
||||
cursor_snap(self, context, event, self.mouse_path)
|
||||
# Extrude
|
||||
elif self.phase == "EXTRUDE":
|
||||
self.set_extrusion_depth(context)
|
||||
|
||||
|
||||
# Confirm
|
||||
elif (event.type == 'LEFTMOUSE' and event.value == 'RELEASE') or (event.type == 'RET' and event.value == 'PRESS'):
|
||||
# selection_fallback
|
||||
if len(self.selected_objects) == 0:
|
||||
self.selected_objects = selection_fallback(self, context, context.view_layer.objects, shape='BOX')
|
||||
for obj in self.selected_objects:
|
||||
obj.select_set(True)
|
||||
elif event.type == 'LEFTMOUSE':
|
||||
# Confirm Shape
|
||||
if self.phase == "DRAW" and event.value == 'RELEASE':
|
||||
"""
|
||||
Protection against creating a very small rectangle (or even with 0 dimensions)
|
||||
by clicking and releasing very quickly, in a very small distance.
|
||||
"""
|
||||
delta_x = abs(event.mouse_region_x - self.mouse.initial[0])
|
||||
delta_y = abs(event.mouse_region_y - self.mouse.initial[1])
|
||||
min_distance = 5
|
||||
|
||||
if len(self.selected_objects) == 0:
|
||||
self.cancel(context)
|
||||
return {'FINISHED'}
|
||||
else:
|
||||
selection = self.validate_selection(context, shape='BOX')
|
||||
if not selection:
|
||||
self.cancel(context)
|
||||
if delta_x < min_distance or delta_y < min_distance:
|
||||
self.finalize(context, clean_up=True, abort=True)
|
||||
return {'FINISHED'}
|
||||
|
||||
# protection_against_returning_no_rectangle_by_clicking
|
||||
delta_x = abs(event.mouse_region_x - self.mouse_path[0][0])
|
||||
delta_y = abs(event.mouse_region_y - self.mouse_path[0][1])
|
||||
min_distance = 5
|
||||
self.extrude_cutter(context)
|
||||
self.Cut(context)
|
||||
|
||||
if delta_x > min_distance or delta_y > min_distance:
|
||||
# Not setting depth manually, performing a cut here.
|
||||
if self.depth != 'MANUAL':
|
||||
self.confirm(context)
|
||||
return {'FINISHED'}
|
||||
else:
|
||||
return {'RUNNING_MODAL'}
|
||||
|
||||
# Confirm Depth
|
||||
if self.phase == "EXTRUDE" and event.value == 'PRESS':
|
||||
self.confirm(context)
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
# Cancel
|
||||
elif event.type in {'RIGHTMOUSE', 'ESC'}:
|
||||
self.cancel(context)
|
||||
self.finalize(context, clean_up=True, abort=True)
|
||||
return {'FINISHED'}
|
||||
|
||||
return {'RUNNING_MODAL'}
|
||||
|
||||
|
||||
def status(cls, context):
|
||||
"""Set the status bar text to modal modifier keys."""
|
||||
|
||||
# Draw
|
||||
def modal_keys_draw(self, context):
|
||||
layout = self.layout
|
||||
row = layout.row(align=True)
|
||||
|
||||
row.label(text="", icon='MOUSE_MOVE')
|
||||
row.label(text="Draw")
|
||||
row.label(text="", icon='MOUSE_LMB')
|
||||
row.label(text="Confirm")
|
||||
row.label(text="", icon='MOUSE_MMB')
|
||||
row.label(text="Rotate View")
|
||||
row.label(text="", icon='MOUSE_RMB')
|
||||
row.label(text="Cancel")
|
||||
|
||||
row.label(text="", icon='EVENT_SPACEKEY')
|
||||
row.label(text=" Move")
|
||||
row.label(text="", icon='EVENT_R')
|
||||
row.label(text="Rotate")
|
||||
row.label(text="", icon='KEY_SHIFT')
|
||||
row.label(text="Aspect")
|
||||
row.label(text="", icon='EVENT_ALT')
|
||||
row.label(text=" Origin")
|
||||
|
||||
row.label(text="", icon='EVENT_LEFT_ARROW')
|
||||
row.label(text="", icon='EVENT_DOWN_ARROW')
|
||||
row.label(text="", icon='EVENT_RIGHT_ARROW')
|
||||
row.label(text="", icon='EVENT_UP_ARROW')
|
||||
row.label(text="Array")
|
||||
row.label(text="", icon='EVENT_B')
|
||||
row.label(text="Bevel")
|
||||
|
||||
# Restore rest of the status bar.
|
||||
layout.separator_spacer()
|
||||
layout.template_reports_banner()
|
||||
layout.separator_spacer()
|
||||
layout.template_running_jobs()
|
||||
|
||||
layout.separator_spacer()
|
||||
row = layout.row()
|
||||
row.alignment = "RIGHT"
|
||||
text = context.screen.statusbar_info()
|
||||
row.label(text=text + " ")
|
||||
|
||||
# Extrude
|
||||
def modal_keys_extrude(self, context):
|
||||
layout = self.layout
|
||||
row = layout.row(align=True)
|
||||
|
||||
row.label(text="", icon='MOUSE_MOVE')
|
||||
row.label(text="Set Depth")
|
||||
row.label(text="", icon='MOUSE_LMB')
|
||||
row.label(text="Confirm")
|
||||
row.label(text="", icon='MOUSE_MMB')
|
||||
row.label(text="Rotate View")
|
||||
row.label(text="", icon='MOUSE_RMB')
|
||||
row.label(text="Cancel")
|
||||
|
||||
row.label(text="", icon='EVENT_SPACEKEY')
|
||||
row.label(text=" Move")
|
||||
row.label(text="", icon='EVENT_R')
|
||||
row.label(text="Rotate")
|
||||
row.label(text="", icon='EVENT_F')
|
||||
row.label(text="Flip Direction")
|
||||
|
||||
row.label(text="", icon='EVENT_LEFT_ARROW')
|
||||
row.label(text="", icon='EVENT_DOWN_ARROW')
|
||||
row.label(text="", icon='EVENT_RIGHT_ARROW')
|
||||
row.label(text="", icon='EVENT_UP_ARROW')
|
||||
row.label(text="Array")
|
||||
row.label(text="", icon='EVENT_B')
|
||||
row.label(text="Bevel")
|
||||
|
||||
# Restore rest of the status bar.
|
||||
layout.separator_spacer()
|
||||
layout.template_reports_banner()
|
||||
layout.separator_spacer()
|
||||
layout.template_running_jobs()
|
||||
|
||||
layout.separator_spacer()
|
||||
row = layout.row()
|
||||
row.alignment = "RIGHT"
|
||||
text = context.screen.statusbar_info()
|
||||
row.label(text=text + " ")
|
||||
|
||||
# Missing keys:
|
||||
# Wheelup and Wheeldown to control bevel segments when B is pressed.
|
||||
# A to adjust array gap when array effect is used.
|
||||
|
||||
if cls.phase == 'DRAW':
|
||||
context.workspace.status_text_set(modal_keys_draw)
|
||||
elif cls.phase == 'EXTRUDE':
|
||||
context.workspace.status_text_set(modal_keys_extrude)
|
||||
|
||||
|
||||
|
||||
#### ------------------------------ REGISTRATION ------------------------------ ####
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from .. import __file__ as base_file
|
||||
from .common.ui import (
|
||||
carver_ui_common,
|
||||
)
|
||||
from .carver_box import OBJECT_OT_carve_box
|
||||
|
||||
|
||||
description = "Cut primitive shapes into mesh objects with brush"
|
||||
@@ -19,21 +20,70 @@ class OBJECT_WT_carve_circle(bpy.types.WorkSpaceTool):
|
||||
bl_space_type = 'VIEW_3D'
|
||||
bl_context_mode = 'OBJECT'
|
||||
|
||||
bl_icon = os.path.join(os.path.dirname(base_file), "icons", "ops.object.carver_circle")
|
||||
bl_icon = os.path.join(os.path.dirname(base_file), "icons", "tool_icons", "ops.object.carver_circle")
|
||||
bl_keymap = (
|
||||
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG'}, {"properties": [("shape", 'CIRCLE')]}),
|
||||
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "shift": True}, {"properties": [("shape", 'CIRCLE')]}),
|
||||
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "alt": True}, {"properties": [("shape", 'CIRCLE')]}),
|
||||
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "shift": True, "alt": True}, {"properties": [("shape", 'CIRCLE')]}),
|
||||
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True}, {"properties": [("shape", 'CIRCLE')]}),
|
||||
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "shift": True}, {"properties": [("shape", 'CIRCLE')]}),
|
||||
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "alt": True}, {"properties": [("shape", 'CIRCLE')]}),
|
||||
("object.carve_box", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "shift": True, "alt": True}, {"properties": [("shape", 'CIRCLE')]}),
|
||||
("object.carve_circle", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG'}, {"properties": None}),
|
||||
("object.carve_circle", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "shift": True}, {"properties": None}),
|
||||
("object.carve_circle", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "alt": True}, {"properties": None}),
|
||||
("object.carve_circle", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "shift": True, "alt": True}, {"properties": None}),
|
||||
("object.carve_circle", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True}, {"properties": None}),
|
||||
("object.carve_circle", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "shift": True}, {"properties": None}),
|
||||
("object.carve_circle", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "alt": True}, {"properties": None}),
|
||||
("object.carve_circle", {"type": 'LEFTMOUSE', "value": 'CLICK_DRAG', "ctrl": True, "shift": True, "alt": True}, {"properties": None}),
|
||||
)
|
||||
|
||||
def draw_settings(context, layout, tool):
|
||||
props = tool.operator_properties("object.carve_box")
|
||||
props = tool.operator_properties("object.carve_circle")
|
||||
carver_ui_common(context, layout, props)
|
||||
|
||||
class MESH_WT_carve_circle(OBJECT_WT_carve_circle):
|
||||
bl_context_mode = 'EDIT_MESH'
|
||||
|
||||
|
||||
|
||||
#### ------------------------------ OPERATORS ------------------------------ ####
|
||||
|
||||
class OBJECT_OT_carve_circle(OBJECT_OT_carve_box):
|
||||
bl_idname = "object.carve_circle"
|
||||
bl_label = "Box Carve"
|
||||
bl_description = description
|
||||
|
||||
# SHAPE-properties
|
||||
shape = 'CIRCLE'
|
||||
|
||||
subdivision: bpy.props.IntProperty(
|
||||
name = "Circle Subdivisions",
|
||||
description = "Number of vertices that will make up the circular shape that will be extruded into a cylinder",
|
||||
min = 3, soft_max = 128,
|
||||
default = 16,
|
||||
)
|
||||
aspect: bpy.props.EnumProperty(
|
||||
name = "Aspect",
|
||||
description = "The initial aspect",
|
||||
items = (('FREE', "Free", "Use an unconstrained aspect"),
|
||||
('FIXED', "Fixed", "Use a fixed 1:1 aspect")),
|
||||
default = 'FIXED',
|
||||
)
|
||||
origin: bpy.props.EnumProperty(
|
||||
name = "Origin",
|
||||
description = "The initial position for placement",
|
||||
items = (('EDGE', "Edge", ""),
|
||||
('CENTER', "Center", "")),
|
||||
default = 'CENTER',
|
||||
)
|
||||
|
||||
|
||||
|
||||
#### ------------------------------ REGISTRATION ------------------------------ ####
|
||||
|
||||
classes = [
|
||||
OBJECT_OT_carve_circle,
|
||||
]
|
||||
|
||||
def register():
|
||||
for cls in classes:
|
||||
bpy.utils.register_class(cls)
|
||||
|
||||
def unregister():
|
||||
for cls in reversed(classes):
|
||||
bpy.utils.unregister_class(cls)
|
||||
|
||||
@@ -1,31 +1,27 @@
|
||||
import bpy
|
||||
import mathutils
|
||||
import math
|
||||
import os
|
||||
from mathutils import Vector
|
||||
from bpy_extras import view3d_utils
|
||||
from .. import __file__ as base_file
|
||||
|
||||
from .common.base import (
|
||||
CarverModifierKeys,
|
||||
CarverBase,
|
||||
)
|
||||
from .common.properties import (
|
||||
CarverOperatorProperties,
|
||||
CarverModifierProperties,
|
||||
CarverCutterProperties,
|
||||
CarverArrayProperties,
|
||||
CarverPropsArray,
|
||||
)
|
||||
from .common.types import (
|
||||
Selection,
|
||||
Mouse,
|
||||
Workplane,
|
||||
Cutter,
|
||||
Effects,
|
||||
)
|
||||
from .common.ui import (
|
||||
carver_ui_common,
|
||||
)
|
||||
|
||||
from ..functions.draw import (
|
||||
carver_shape_polyline,
|
||||
)
|
||||
from ..functions.select import (
|
||||
cursor_snap,
|
||||
selection_fallback,
|
||||
)
|
||||
|
||||
|
||||
description = "Cut custom polygonal shapes into mesh objects"
|
||||
|
||||
@@ -39,7 +35,7 @@ class OBJECT_WT_carve_polyline(bpy.types.WorkSpaceTool):
|
||||
bl_space_type = 'VIEW_3D'
|
||||
bl_context_mode = 'OBJECT'
|
||||
|
||||
bl_icon = os.path.join(os.path.dirname(base_file), "icons", "ops.object.carver_polyline")
|
||||
bl_icon = os.path.join(os.path.dirname(base_file), "icons", "tool_icons", "ops.object.carver_polyline")
|
||||
bl_keymap = (
|
||||
("object.carve_polyline", {"type": 'LEFTMOUSE', "value": 'CLICK'}, None),
|
||||
("object.carve_polyline", {"type": 'LEFTMOUSE', "value": 'CLICK', "ctrl": True}, None),
|
||||
@@ -61,8 +57,8 @@ class MESH_WT_carve_polyline(OBJECT_WT_carve_polyline):
|
||||
|
||||
#### ------------------------------ OPERATORS ------------------------------ ####
|
||||
|
||||
class OBJECT_OT_carve_polyline(CarverBase, CarverModifierKeys, bpy.types.Operator,
|
||||
CarverOperatorProperties, CarverModifierProperties, CarverCutterProperties, CarverArrayProperties):
|
||||
class OBJECT_OT_carve_polyline(CarverBase,
|
||||
CarverPropsArray):
|
||||
bl_idname = "object.carve_polyline"
|
||||
bl_label = "Polyline Carve"
|
||||
bl_description = description
|
||||
@@ -70,11 +66,11 @@ class OBJECT_OT_carve_polyline(CarverBase, CarverModifierKeys, bpy.types.Operato
|
||||
bl_cursor_pending = 'PICK_AREA'
|
||||
|
||||
# SHAPE-properties
|
||||
closed: bpy.props.BoolProperty(
|
||||
name = "Closed Polygon",
|
||||
description = "When enabled, mouse position at the moment of execution will be registered as last point of the polygon",
|
||||
default = True,
|
||||
)
|
||||
shape = 'POLYLINE'
|
||||
origin = None
|
||||
aspect = None
|
||||
rotation = 0
|
||||
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
@@ -82,34 +78,29 @@ class OBJECT_OT_carve_polyline(CarverBase, CarverModifierKeys, bpy.types.Operato
|
||||
|
||||
|
||||
def invoke(self, context, event):
|
||||
self.selected_objects = context.selected_objects
|
||||
self.mouse_path = [(event.mouse_region_x, event.mouse_region_y),
|
||||
(event.mouse_region_x, event.mouse_region_y)]
|
||||
# Validate Selection
|
||||
self.objects = Selection(*self.validate_selection(context))
|
||||
|
||||
# initialize_empty_values
|
||||
self.verts = []
|
||||
self.duplicates = []
|
||||
self.cutter = None
|
||||
self.view_depth = mathutils.Vector()
|
||||
self.cached_mouse_position = () # needed_for_custom_modifier_keys
|
||||
self.distance_from_first = 0
|
||||
if len(self.objects.selected) == 0:
|
||||
bpy.ops.view3d.select('INVOKE_DEFAULT')
|
||||
return {'CANCELLED'}
|
||||
|
||||
# cached_variables
|
||||
"""Important for storing context as it was when operator was invoked (untouched by the modal)"""
|
||||
self.initial_selection = context.selected_objects
|
||||
# Initialize Core Components
|
||||
self.mouse = Mouse().from_event(event)
|
||||
self.workplane = Workplane(*self.calculate_workplane(context))
|
||||
self.cutter = Cutter(*self.create_cutter(context))
|
||||
self.effects = Effects().from_invoke(self, context)
|
||||
|
||||
# modifier_keys
|
||||
self.snap = False
|
||||
self.move = False
|
||||
self.gap = False
|
||||
|
||||
# overlay_position (needed_for_moving_the_shape)
|
||||
self.position_offset_x = 0
|
||||
self.position_offset_y = 0
|
||||
self.initial_position = False
|
||||
# cached_variables
|
||||
"""Important for storing context as it was when operator was invoked (untouched by the modal)."""
|
||||
self.phase = "DRAW"
|
||||
self._distance_from_first = 0
|
||||
self._stored_phase = "DRAW"
|
||||
|
||||
# Add Draw Handler
|
||||
self._handle = bpy.types.SpaceView3D.draw_handler_add(carver_shape_polyline, (self, context), 'WINDOW', 'POST_PIXEL')
|
||||
self._handler = bpy.types.SpaceView3D.draw_handler_add(self.draw_shaders,
|
||||
(context,),
|
||||
'WINDOW', 'POST_VIEW')
|
||||
context.window.cursor_set("MUTE")
|
||||
context.window_manager.modal_handler_add(self)
|
||||
|
||||
@@ -117,114 +108,284 @@ class OBJECT_OT_carve_polyline(CarverBase, CarverModifierKeys, bpy.types.Operato
|
||||
|
||||
|
||||
def modal(self, context, event):
|
||||
# Tool Settings Text
|
||||
snap_text = ", [MOUSEWHEEL]: Change Snapping Increment" if self.snap else ""
|
||||
shape_text = "[BACKSPACE]: Remove Last Point, [ENTER]: Confirm"
|
||||
array_text = ", [A]: Gap" if (self.rows > 1 or self.columns > 1) else ""
|
||||
context.workspace.status_text_set("[CTRL]: Snap Invert, [SPACEBAR]: Move, " + shape_text + array_text + snap_text)
|
||||
# Status Bar Text
|
||||
self.status(context)
|
||||
|
||||
# find_the_limit_of_the_3d_viewport_region
|
||||
self.redraw_region(context)
|
||||
|
||||
|
||||
# Modifier Keys
|
||||
self.modifier_snap(context, event)
|
||||
self.modifier_array(context, event)
|
||||
self.modifier_move(context, event)
|
||||
self.event_array(context, event)
|
||||
self.event_move(context, event)
|
||||
|
||||
if event.type in {'NUMPAD_1', 'NUMPAD_2', 'NUMPAD_3', 'NUMPAD_4',
|
||||
'NUMPAD_5', 'NUMPAD_6', 'NUMPAD_7', 'NUMPAD_8', 'NUMPAD_9',
|
||||
'MIDDLEMOUSE', 'WHEELUPMOUSE', 'WHEELDOWNMOUSE', 'N'}:
|
||||
if event.type in {'MIDDLEMOUSE'}:
|
||||
return {'PASS_THROUGH'}
|
||||
if event.type in {'WHEELUPMOUSE', 'WHEELDOWNMOUSE'}:
|
||||
if self.phase != "BEVEL":
|
||||
return {'PASS_THROUGH'}
|
||||
|
||||
|
||||
# Mouse Move
|
||||
if event.type == 'MOUSEMOVE':
|
||||
# move
|
||||
if self.move:
|
||||
self.position_offset_x += (event.mouse_region_x - self.last_mouse_region_x)
|
||||
self.position_offset_y += (event.mouse_region_y - self.last_mouse_region_y)
|
||||
self.last_mouse_region_x = event.mouse_region_x
|
||||
self.last_mouse_region_y = event.mouse_region_y
|
||||
self.mouse.current = Vector((event.mouse_region_x, event.mouse_region_y))
|
||||
|
||||
# array
|
||||
elif self.gap:
|
||||
self.rows_gap = event.mouse_region_x * 0.1
|
||||
self.columns_gap = event.mouse_region_y * 0.1
|
||||
# Draw
|
||||
if self.phase == "DRAW":
|
||||
# Calculate the distance from the initial mouse position.
|
||||
if self.mouse.current_3d:
|
||||
first_vert_world = self.cutter.obj.matrix_world @ self.cutter.verts[0].co
|
||||
first_vert_screen = view3d_utils.location_3d_to_region_2d(context.region,
|
||||
context.region_data,
|
||||
first_vert_world)
|
||||
distance_screen = (Vector(self.mouse.current) - first_vert_screen).length
|
||||
self._distance_from_first = max(100 - distance_screen, 0)
|
||||
|
||||
# Draw Shape
|
||||
else:
|
||||
if len(self.mouse_path) > 0:
|
||||
self.mouse_path[len(self.mouse_path) - 1] = (event.mouse_region_x, event.mouse_region_y)
|
||||
self.update_cutter_shape(context)
|
||||
|
||||
# snap (find_the_closest_position_on_the_overlay_grid_and_snap_the_shape_to_it)
|
||||
if self.snap:
|
||||
cursor_snap(self, context, event, self.mouse_path)
|
||||
|
||||
# get_distance_from_first_point
|
||||
distance = math.sqrt((self.mouse_path[-1][0] - self.mouse_path[0][0]) ** 2 +
|
||||
(self.mouse_path[-1][1] - self.mouse_path[0][1]) ** 2)
|
||||
min_radius = 0
|
||||
max_radius = 30
|
||||
self.distance_from_first = max(max_radius - distance, min_radius)
|
||||
# Extrude
|
||||
elif self.phase == "EXTRUDE":
|
||||
self.set_extrusion_depth(context)
|
||||
|
||||
|
||||
# Add Points & Confirm
|
||||
elif (event.type == 'LEFTMOUSE' and event.value == 'RELEASE') or (event.type == 'RET' and event.value == 'PRESS'):
|
||||
# selection_fallback (expand_selection_on_every_polyline_click)
|
||||
if len(self.initial_selection) == 0:
|
||||
self.selected_objects = selection_fallback(self, context, context.view_layer.objects, shape='POLYLINE')
|
||||
for obj in self.selected_objects:
|
||||
obj.select_set(True)
|
||||
elif event.type == 'LEFTMOUSE' and event.value == 'PRESS':
|
||||
if self.phase == "DRAW":
|
||||
# Confirm Shape (if clicked on the first vert)
|
||||
if self._distance_from_first > 75:
|
||||
verts = self.cutter.verts
|
||||
if len(verts) > 3:
|
||||
self._remove_polyline_point(context, jump_mouse=False)
|
||||
self.extrude_cutter(context)
|
||||
self.Cut(context)
|
||||
|
||||
# Not setting depth manually, performing a cut here.
|
||||
if self.depth != 'MANUAL':
|
||||
self.confirm(context)
|
||||
return {'FINISHED'}
|
||||
else:
|
||||
return {'RUNNING_MODAL'}
|
||||
|
||||
# add_new_points
|
||||
if not (event.type == 'RET' and event.value == 'PRESS') and (self.distance_from_first < 15):
|
||||
self.mouse_path.append((event.mouse_region_x, event.mouse_region_y))
|
||||
if self.closed == False:
|
||||
"""NOTE: Additional vert is needed for open loop."""
|
||||
self.mouse_path.append((event.mouse_region_x, event.mouse_region_y))
|
||||
|
||||
# confirm_cut
|
||||
else:
|
||||
if self.closed == False:
|
||||
self.verts.pop() # dont_add_current_mouse_position_as_vert
|
||||
|
||||
if self.distance_from_first > 15:
|
||||
self.verts[-1] = self.verts[0]
|
||||
|
||||
if len(self.verts) / 2 <= 1:
|
||||
self.report({'INFO'}, "At least two points are required to make polygonal shape")
|
||||
self.cancel(context)
|
||||
return {'FINISHED'}
|
||||
|
||||
if self.closed and self.mouse_path[-1] == self.mouse_path[-2]:
|
||||
context.window.cursor_warp(event.mouse_region_x - 1, event.mouse_region_y)
|
||||
|
||||
selection = self.validate_selection(context, shape='POLYLINE')
|
||||
if not selection:
|
||||
self.cancel(context)
|
||||
return {'FINISHED'}
|
||||
# Add Point
|
||||
else:
|
||||
self._insert_polyline_point()
|
||||
|
||||
# Confirm Depth
|
||||
if self.phase == "EXTRUDE":
|
||||
self.confirm(context)
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
# Confirm
|
||||
elif event.type == 'RET':
|
||||
verts = self.cutter.verts
|
||||
if len(verts) > 2:
|
||||
# Confirm Shape
|
||||
if self.phase == "DRAW" and event.value == 'RELEASE':
|
||||
self.extrude_cutter(context)
|
||||
self.Cut(context)
|
||||
|
||||
# Not setting depth manually, performing a cut here.
|
||||
if self.depth != 'MANUAL':
|
||||
self.confirm(context)
|
||||
return {'FINISHED'}
|
||||
else:
|
||||
return {'RUNNING_MODAL'}
|
||||
|
||||
# Confirm Depth
|
||||
if self.phase == "EXTRUDE" and event.value == 'PRESS':
|
||||
self.confirm(context)
|
||||
return {'FINISHED'}
|
||||
else:
|
||||
self.report({'WARNING'}, "At least three points are required to make a polygonal shape")
|
||||
|
||||
|
||||
# Remove Last Point
|
||||
if event.type == 'BACK_SPACE' and event.value == 'PRESS':
|
||||
if len(self.mouse_path) > 2:
|
||||
context.window.cursor_warp(int(self.mouse_path[-2][0]), int(self.mouse_path[-2][1]))
|
||||
self.mouse_path = self.mouse_path[:-1]
|
||||
self._remove_polyline_point(context)
|
||||
|
||||
|
||||
# Cancel
|
||||
elif event.type in {'RIGHTMOUSE', 'ESC'}:
|
||||
self.cancel(context)
|
||||
self.finalize(context, clean_up=True, abort=True)
|
||||
return {'FINISHED'}
|
||||
|
||||
return {'RUNNING_MODAL'}
|
||||
|
||||
|
||||
def status(cls, context):
|
||||
"""Set the status bar text to modal modifier keys."""
|
||||
|
||||
# Draw
|
||||
def modal_keys_draw(self, context):
|
||||
layout = self.layout
|
||||
row = layout.row(align=True)
|
||||
|
||||
row.label(text="", icon='MOUSE_LMB')
|
||||
row.label(text="Insert Point")
|
||||
row.label(text="", icon='MOUSE_MMB')
|
||||
row.label(text="Rotate View")
|
||||
row.label(text="", icon='MOUSE_RMB')
|
||||
row.label(text="Cancel")
|
||||
row.label(text="", icon='KEY_RETURN')
|
||||
row.label(text="Confirm")
|
||||
|
||||
row.label(text="", icon='EVENT_SPACEKEY')
|
||||
row.label(text=" Move")
|
||||
row.label(text="", icon='EVENT_BACKSPACE')
|
||||
row.label(text=" Remove Last Point")
|
||||
|
||||
row.label(text="", icon='EVENT_LEFT_ARROW')
|
||||
row.label(text="", icon='EVENT_DOWN_ARROW')
|
||||
row.label(text="", icon='EVENT_RIGHT_ARROW')
|
||||
row.label(text="", icon='EVENT_UP_ARROW')
|
||||
row.label(text="Array")
|
||||
|
||||
# Restore rest of the status bar.
|
||||
layout.separator_spacer()
|
||||
layout.template_reports_banner()
|
||||
layout.separator_spacer()
|
||||
layout.template_running_jobs()
|
||||
|
||||
layout.separator_spacer()
|
||||
row = layout.row()
|
||||
row.alignment = "RIGHT"
|
||||
text = context.screen.statusbar_info()
|
||||
row.label(text=text + " ")
|
||||
|
||||
# Extrude
|
||||
def modal_keys_extrude(self, context):
|
||||
layout = self.layout
|
||||
row = layout.row(align=True)
|
||||
|
||||
row.label(text="", icon='MOUSE_MOVE')
|
||||
row.label(text="Set Depth")
|
||||
row.label(text="", icon='MOUSE_LMB')
|
||||
row.label(text="", icon='KEY_RETURN')
|
||||
row.label(text="Confirm")
|
||||
row.label(text="", icon='MOUSE_MMB')
|
||||
row.label(text="Rotate View")
|
||||
row.label(text="", icon='MOUSE_RMB')
|
||||
row.label(text="Cancel")
|
||||
|
||||
row.label(text="", icon='EVENT_SPACEKEY')
|
||||
row.label(text=" Move")
|
||||
row.label(text="", icon='EVENT_R')
|
||||
row.label(text="Rotate")
|
||||
row.label(text="", icon='EVENT_F')
|
||||
row.label(text="Flip Direction")
|
||||
|
||||
row.label(text="", icon='EVENT_LEFT_ARROW')
|
||||
row.label(text="", icon='EVENT_DOWN_ARROW')
|
||||
row.label(text="", icon='EVENT_RIGHT_ARROW')
|
||||
row.label(text="", icon='EVENT_UP_ARROW')
|
||||
row.label(text="Array")
|
||||
|
||||
# Restore rest of the status bar.
|
||||
layout.separator_spacer()
|
||||
layout.template_reports_banner()
|
||||
layout.separator_spacer()
|
||||
layout.template_running_jobs()
|
||||
|
||||
layout.separator_spacer()
|
||||
row = layout.row()
|
||||
row.alignment = "RIGHT"
|
||||
text = context.screen.statusbar_info()
|
||||
row.label(text=text + " ")
|
||||
|
||||
# Missing keys:
|
||||
# A to adjust array gap when array effect is used.
|
||||
|
||||
if cls.phase == 'DRAW':
|
||||
context.workspace.status_text_set(modal_keys_draw)
|
||||
elif cls.phase == 'EXTRUDE':
|
||||
context.workspace.status_text_set(modal_keys_extrude)
|
||||
|
||||
|
||||
# Polyline-specific features.
|
||||
def _insert_polyline_point(self):
|
||||
"""Inserts a new vertex in the cutter geometry and connects it to the previous last one."""
|
||||
|
||||
bm = self.cutter.bm
|
||||
verts = self.cutter.verts
|
||||
x, y = self.mouse.current_3d.x, self.mouse.current_3d.y
|
||||
|
||||
# Lock the position of the last vert to cursor position at the moment of press.
|
||||
last_vert = verts[-1]
|
||||
last_vert.co = Vector((x, y, 0))
|
||||
|
||||
# Find and remove edge between last vert and the first vert.
|
||||
if verts.index(last_vert) != 1:
|
||||
first_vert = verts[0]
|
||||
edge_to_remove = None
|
||||
for edge in last_vert.link_edges:
|
||||
if first_vert in edge.verts:
|
||||
edge_to_remove = edge
|
||||
break
|
||||
if edge_to_remove:
|
||||
self.cutter.bm.edges.remove(edge_to_remove)
|
||||
|
||||
# Insert new point in bmesh and connect to last one.
|
||||
new_vert = bm.verts.new(Vector((x, y, 0)))
|
||||
bm.edges.new([last_vert, new_vert])
|
||||
verts.append(new_vert)
|
||||
|
||||
# Create a new face.
|
||||
if len(verts) >= 3:
|
||||
face = self.cutter.bm.faces.new(verts)
|
||||
self.cutter.faces = [face]
|
||||
|
||||
# Update bmesh.
|
||||
bm.to_mesh(self.cutter.mesh)
|
||||
|
||||
|
||||
def _remove_polyline_point(self, context, jump_mouse=True):
|
||||
"""Removes the last vertex in cutter geometry and moves cursor to the one before that."""
|
||||
|
||||
if self.phase != "DRAW":
|
||||
return
|
||||
|
||||
obj = self.cutter.obj
|
||||
bm = self.cutter.bm
|
||||
verts = self.cutter.verts
|
||||
faces = self.cutter.faces
|
||||
|
||||
if len(verts) <= 2:
|
||||
return
|
||||
|
||||
# Remove last vertex.
|
||||
last_vert = verts[-1]
|
||||
bm.verts.remove(last_vert)
|
||||
verts.pop()
|
||||
|
||||
# Reconstruct the face.
|
||||
face = faces[0]
|
||||
if face is not None:
|
||||
if len(verts) >= 3:
|
||||
new_face = bm.faces.new(verts)
|
||||
faces[0] = new_face
|
||||
else:
|
||||
faces[0] = None
|
||||
|
||||
# Create an edge between new last vertex and the first vertex.
|
||||
new_last = verts[-1]
|
||||
first_vert = verts[0]
|
||||
edge_exists = any(first_vert in edge.verts for edge in new_last.link_edges)
|
||||
if not edge_exists:
|
||||
bm.edges.new([new_last, first_vert])
|
||||
|
||||
# Update bmesh.
|
||||
bm.to_mesh(self.cutter.mesh)
|
||||
|
||||
# Jump mouse to the new last vert.
|
||||
if jump_mouse:
|
||||
vert_world = obj.matrix_world @ new_last.co
|
||||
screen_pos = view3d_utils.location_3d_to_region_2d(context.region,
|
||||
context.region_data,
|
||||
vert_world)
|
||||
if screen_pos:
|
||||
context.window.cursor_warp(int(screen_pos.x), int(screen_pos.y))
|
||||
|
||||
|
||||
|
||||
#### ------------------------------ REGISTRATION ------------------------------ ####
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,25 +2,82 @@ import bpy
|
||||
import math
|
||||
|
||||
|
||||
# Import Custom Icons
|
||||
from ... import icons
|
||||
svg_icons = icons.svg_icons["main"]
|
||||
icon_measure = svg_icons["MEASURE"].icon_id
|
||||
icon_cpu = svg_icons["CPU"].icon_id
|
||||
|
||||
|
||||
#### ------------------------------ PROPERTIES ------------------------------ ####
|
||||
|
||||
class CarverOperatorProperties():
|
||||
class CarverPropsOperator():
|
||||
# OPERATOR-properties
|
||||
mode: bpy.props.EnumProperty(
|
||||
name = "Mode",
|
||||
items = (('DESTRUCTIVE', "Destructive", "Boolean cutters are immediatelly applied and removed after the cut", 'MESH_DATA', 0),
|
||||
('MODIFIER', "Modifier", "Cuts are stored as boolean modifiers and cutters are placed inside the collection", 'MODIFIER_DATA', 1)),
|
||||
default = 'DESTRUCTIVE',
|
||||
items = (('DESTRUCTIVE', "Destructive",
|
||||
"Boolean cutters are immediatelly applied and removed after the cut", 'MESH_DATA', 0),
|
||||
('MODIFIER', "Modifier",
|
||||
"Cuts are stored as boolean modifiers and cutters are placed inside the collection", 'MODIFIER_DATA', 1)),
|
||||
default = 'MODIFIER',
|
||||
)
|
||||
alignment: bpy.props.EnumProperty(
|
||||
name = "Alignment",
|
||||
items = (('SURFACE', "Surface", "Align cutters to the surface normal of the mesh under the mouse", 'SNAP_NORMAL', 0),
|
||||
('VIEW', "View", "Align cutters to the current view", 'VIEW_CAMERA_UNSELECTED', 1),
|
||||
('CURSOR', "3D Cursor", "Align cutters to the 3D cursor orientation", 'ORIENTATION_CURSOR', 2),
|
||||
('GRID', "Grid", "Align cutters to the world grid", 'GRID', 3)),
|
||||
default = 'SURFACE',
|
||||
)
|
||||
depth: bpy.props.EnumProperty(
|
||||
name = "Depth",
|
||||
items = (('VIEW', "View", "Depth is automatically calculated from view orientation", 'VIEW_CAMERA_UNSELECTED', 0),
|
||||
('CURSOR', "Cursor", "Depth is derived from 3D cursors location", 'PIVOT_CURSOR', 1)),
|
||||
default = 'VIEW',
|
||||
items = (('MANUAL', "Manual", "Depth can be manually set after creating a cutter shape", icon_measure, 0),
|
||||
('AUTO', "Auto", "Depth is set automatically to cover selected objects entirely", icon_cpu, 1),
|
||||
('CURSOR', "3D Cursor", "Depth is set to 3D cursors location", 'PIVOT_CURSOR', 2)),
|
||||
default = 'MANUAL',
|
||||
)
|
||||
|
||||
|
||||
class CarverModifierProperties():
|
||||
class CarverPropsShape():
|
||||
# SHAPE-properties
|
||||
orientation: bpy.props.EnumProperty(
|
||||
name = "Orientation",
|
||||
description = "Orientation method for the shape placement",
|
||||
items = (('FACE', "Face Normal", "Orient the shape along the normal of the face"),
|
||||
('CLOSEST_EDGE', "Closest Edge", "Orient the shape along the closest edge of the face"),
|
||||
('LONGEST_EDGE', "Longest Edge", "Orient the shape along the longest edge of the face")),
|
||||
default = 'CLOSEST_EDGE',
|
||||
)
|
||||
offset: bpy.props.FloatProperty(
|
||||
name = "Offset from Surface",
|
||||
description = ("Distance between the shape and the surface of the mesh.\n"
|
||||
"Offset is important for avoiding Z-fighting issues and solver failures"),
|
||||
min = 0.0, soft_max = 0.1,
|
||||
default = 0.01,
|
||||
)
|
||||
align_to_all: bpy.props.BoolProperty(
|
||||
name = "Align to Anything",
|
||||
description = "Use all visible objects for surface alignment, not just selected objects",
|
||||
default = True,
|
||||
)
|
||||
alignment_axis: bpy.props.EnumProperty(
|
||||
name = "Alignment Axis",
|
||||
description = "Which axis of the world grid or 3D cursor should be used for workplane alignment",
|
||||
items = (('X', "X", ""),
|
||||
('Y', "Y", ""),
|
||||
('Z', "Z", "")),
|
||||
default = 'Z',
|
||||
)
|
||||
|
||||
flip_direction: bpy.props.BoolProperty(
|
||||
name = "Flip Direction",
|
||||
description = "Change which way the geometry is extruded",
|
||||
options = {'SKIP_SAVE', 'HIDDEN', 'SKIP_PRESET', },
|
||||
default = False,
|
||||
)
|
||||
|
||||
|
||||
class CarverPropsModifier():
|
||||
# MODIFIER-properties
|
||||
solver: bpy.props.EnumProperty(
|
||||
name = "Solver",
|
||||
@@ -37,7 +94,7 @@ class CarverModifierProperties():
|
||||
)
|
||||
|
||||
|
||||
class CarverCutterProperties():
|
||||
class CarverPropsCutter():
|
||||
# CUTTER-properties
|
||||
hide: bpy.props.BoolProperty(
|
||||
name = "Hide Cutter",
|
||||
@@ -50,6 +107,21 @@ class CarverCutterProperties():
|
||||
"If there is no active object in selection cutters parent might be chosen seemingly randomly"),
|
||||
default = True,
|
||||
)
|
||||
display: bpy.props.EnumProperty(
|
||||
name = "Cutter Display",
|
||||
items = (('WIRE', "Wire", "Display the cutter object as a wireframe"),
|
||||
('BOUNDS', "Bounds", "Display only the bounds of the cutter object")),
|
||||
default = 'BOUNDS'
|
||||
)
|
||||
cutter_origin: bpy.props.EnumProperty(
|
||||
name = "Cutter Origin Point",
|
||||
items = (('CENTER_OBJ', "Bounding Box", "Put the object origin at the center of the cutters bounding box"),
|
||||
('CENTER_MESH', "Geometry", "Put the object origin at the center of the cutters geometry (not including effects)"),
|
||||
('FACE_CENTER', "First Face", "Put the object origin at the center of cutters first face (i.e. shape)"),
|
||||
('MOUSE_INITIAL', "Mouse Click", "Put the object origin at the point where mouse was first clicked"),
|
||||
('CANVAS', "Same as Canvas", "Put the object origin of the cutter to the origin point of the cutter")),
|
||||
default = 'CENTER_MESH',
|
||||
)
|
||||
|
||||
auto_smooth: bpy.props.BoolProperty(
|
||||
name = "Shade Auto Smooth",
|
||||
@@ -66,7 +138,7 @@ class CarverCutterProperties():
|
||||
)
|
||||
|
||||
|
||||
class CarverArrayProperties():
|
||||
class CarverPropsArray():
|
||||
# ARRAY-properties
|
||||
rows: bpy.props.IntProperty(
|
||||
name = "Rows",
|
||||
@@ -74,60 +146,41 @@ class CarverArrayProperties():
|
||||
min = 1, soft_max = 16,
|
||||
default = 1,
|
||||
)
|
||||
rows_gap: bpy.props.FloatProperty(
|
||||
name = "Gap between rows (relative unit)",
|
||||
min = 0, soft_max = 250,
|
||||
default = 50,
|
||||
)
|
||||
rows_direction: bpy.props.EnumProperty(
|
||||
name = "Direction of Rows",
|
||||
items = (('LEFT', "Left", ""),
|
||||
('RIGHT', "Right", "")),
|
||||
default = 'RIGHT',
|
||||
)
|
||||
|
||||
columns: bpy.props.IntProperty(
|
||||
name = "Columns",
|
||||
description = "Number of times shape is duplicated vertically",
|
||||
min = 1, soft_max = 16,
|
||||
default = 1,
|
||||
)
|
||||
columns_direction: bpy.props.EnumProperty(
|
||||
name = "Direction of Rows",
|
||||
items = (('UP', "Up", ""),
|
||||
('DOWN', "Down", "")),
|
||||
default = 'DOWN',
|
||||
)
|
||||
columns_gap: bpy.props.FloatProperty(
|
||||
name = "Gap between columns (relative unit)",
|
||||
min = 0, soft_max = 250,
|
||||
default = 50,
|
||||
gap: bpy.props.FloatProperty(
|
||||
name = "Gap",
|
||||
description = "Spacing between duplicates, both in rows and columns (relative unit)",
|
||||
min = 1, soft_max = 10,
|
||||
default = 1.1,
|
||||
)
|
||||
|
||||
|
||||
class CarverBevelProperties():
|
||||
class CarverPropsBevel():
|
||||
# BEVEL-properties
|
||||
|
||||
use_bevel: bpy.props.BoolProperty(
|
||||
name = "Bevel Cutter",
|
||||
description = "Bevel each side edge of the cutter",
|
||||
default = False,
|
||||
)
|
||||
bevel_profile: bpy.props.EnumProperty(
|
||||
name = "Bevel Profile",
|
||||
items = (('CONVEX', "Convex", "Outside bevel (rounded corners)"),
|
||||
('CONCAVE', "Concave", "Inside bevel")),
|
||||
default = 'CONVEX',
|
||||
)
|
||||
bevel_segments: bpy.props.IntProperty(
|
||||
name = "Bevel Segments",
|
||||
description = "Segments for curved edge",
|
||||
min = 2, soft_max = 32,
|
||||
min = 1, soft_max = 32,
|
||||
default = 8,
|
||||
)
|
||||
bevel_radius: bpy.props.FloatProperty(
|
||||
name = "Bevel Radius",
|
||||
description = "Amout of the bevel (in screen-space units)",
|
||||
min = 0.01, soft_max = 5,
|
||||
default = 1,
|
||||
bevel_width: bpy.props.FloatProperty(
|
||||
name = "Bevel Width",
|
||||
min = 0, soft_max = 5,
|
||||
default = 0.1,
|
||||
)
|
||||
bevel_profile: bpy.props.FloatProperty(
|
||||
name = "Bevel Profile",
|
||||
description = "The bevel profile shape (0.5 = round)",
|
||||
min = 0, max = 1,
|
||||
default = 0.5,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
import bpy
|
||||
import math
|
||||
import os
|
||||
from mathutils import Vector, Matrix
|
||||
|
||||
from ...functions.mesh import (
|
||||
ensure_attribute,
|
||||
shade_smooth_by_angle,
|
||||
)
|
||||
from ...functions.modifier import (
|
||||
add_modifier_asset,
|
||||
)
|
||||
|
||||
|
||||
#### ------------------------------ CLASSES ------------------------------ ####
|
||||
|
||||
class Selection:
|
||||
"""Storage of viable selected and active object(s) throughout the modal."""
|
||||
|
||||
def __init__(self, selected, active):
|
||||
self.selected: list = selected
|
||||
self.active = active
|
||||
self.modifiers = {}
|
||||
|
||||
|
||||
class Mouse:
|
||||
"""
|
||||
Mouse positions throughout different phases of the modal operator.
|
||||
Each class variable is a 2D vector in screen space (x, y).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.initial = Vector()
|
||||
self.current = Vector()
|
||||
self.extrude = Vector()
|
||||
self.cached = Vector() # Used for custom modifier keys.
|
||||
|
||||
self.current_3d = Vector()
|
||||
self.cached_3d = Vector()
|
||||
|
||||
@classmethod
|
||||
def from_event(self, event):
|
||||
self.initial = Vector((event.mouse_region_x, event.mouse_region_y))
|
||||
self.current = Vector((event.mouse_region_x, event.mouse_region_y))
|
||||
|
||||
self.current_3d = None
|
||||
return self
|
||||
|
||||
|
||||
class Workplane:
|
||||
"""Local 3D coordinate system used as the drawing plane for creating shapes."""
|
||||
|
||||
def __init__(self, matrix, location, normal):
|
||||
self.matrix: Matrix = matrix # full 4x4 transform matrix.
|
||||
self.location: Vector = location # origin point of the plane in world space.
|
||||
self.normal: Vector = normal # perpendicular direction of the plane.
|
||||
|
||||
|
||||
class Cutter:
|
||||
"""Object created for cutting, as well as it's `bmesh`, and other properties."""
|
||||
|
||||
def __init__(self, obj, mesh, bm, faces, verts):
|
||||
self.obj = obj
|
||||
self.mesh = mesh
|
||||
self.bm = bm
|
||||
self.faces: list = faces
|
||||
self.verts: list = verts
|
||||
self.center = Vector() # Center of the geometry.
|
||||
|
||||
|
||||
# Effects
|
||||
class Effects:
|
||||
|
||||
def __init__(self):
|
||||
self.array = None
|
||||
self.bevel = None
|
||||
self.smooth = None
|
||||
self.weld = None
|
||||
|
||||
def from_invoke(self, cls, context):
|
||||
"""Add modifiers to the cutter object during invoke, if they're enabled on tool level."""
|
||||
|
||||
# Smooth by Angle
|
||||
if cls.auto_smooth:
|
||||
self.add_auto_smooth_modifier(cls, context)
|
||||
|
||||
# Array
|
||||
if cls.rows > 1 or cls.columns > 1:
|
||||
self.add_array_modifier(cls)
|
||||
else:
|
||||
self.array = None
|
||||
|
||||
# Bevel
|
||||
if hasattr(cls, "use_bevel") and cls.use_bevel:
|
||||
self.add_bevel_modifier(cls, affect='VERTICES')
|
||||
else:
|
||||
self.bevel = None
|
||||
|
||||
return self
|
||||
|
||||
def update(self, cls, effect):
|
||||
"""Update bevel modifier during modal."""
|
||||
|
||||
# Update array count.
|
||||
if effect == 'ARRAY_COUNT':
|
||||
if self.array is None:
|
||||
self.add_array_modifier(cls)
|
||||
|
||||
else:
|
||||
if cls.columns > 1 or cls.rows > 1:
|
||||
self.array["Socket_2"] = cls.columns
|
||||
self.array["Socket_3"] = cls.rows
|
||||
|
||||
# Remove modifier if it's no longer needed.
|
||||
if cls.columns == 1 and cls.rows == 1:
|
||||
cls.cutter.obj.modifiers.remove(self.array)
|
||||
self.array = None
|
||||
|
||||
# Update array gap.
|
||||
if effect == 'ARRAY_GAP':
|
||||
if cls.columns > 1 or cls.row > 1:
|
||||
if self.array is not None:
|
||||
self.array["Socket_4"] = cls.gap
|
||||
|
||||
# Force the modifier to update in viewport.
|
||||
self.array.show_viewport = False
|
||||
self.array.show_viewport = True
|
||||
|
||||
# Update bevel width & segments
|
||||
if effect == 'BEVEL':
|
||||
self.bevel.segments = cls.bevel_segments
|
||||
self.bevel.width = cls.bevel_width
|
||||
|
||||
|
||||
# Array
|
||||
def add_array_modifier(self, cls):
|
||||
"""Adds an array modifier(s) on the cutter object."""
|
||||
|
||||
cutter = cls.cutter.obj
|
||||
|
||||
# Load geometry nodes modifier asset.
|
||||
if self.array is None:
|
||||
root = os.path.abspath(os.path.join(__file__, "..", "..", ".."))
|
||||
assets_path = os.path.join(root, "assets.blend")
|
||||
mod = add_modifier_asset(cutter, path=assets_path, asset="cutter_array")
|
||||
|
||||
if not mod:
|
||||
cls.report({'WARNING'}, "Array modifier cannot be loaded for cutter")
|
||||
return
|
||||
|
||||
# Columns
|
||||
if cls.columns > 1:
|
||||
mod["Socket_2"] = cls.columns
|
||||
|
||||
# Rows
|
||||
if cls.rows > 1:
|
||||
mod["Socket_3"] = cls.rows
|
||||
|
||||
# Gap
|
||||
mod["Socket_4"] = cls.gap
|
||||
|
||||
self.array = mod
|
||||
|
||||
|
||||
# Bevel
|
||||
def add_bevel_modifier(self, cls, affect='EDGES'):
|
||||
"""Adds a bevel modifier on the cutter object."""
|
||||
|
||||
cutter = cls.cutter.obj
|
||||
bm = cls.cutter.bm
|
||||
faces = cls.cutter.faces
|
||||
|
||||
mod = cutter.modifiers.new("cutter_bevel", 'BEVEL')
|
||||
mod.limit_method = 'WEIGHT'
|
||||
mod.segments = cls.bevel_segments
|
||||
mod.width = cls.bevel_width
|
||||
mod.profile = cls.bevel_profile
|
||||
|
||||
"""NOTE:
|
||||
In order to allow beveling during the shape creation phase,
|
||||
when we only have one face, we need to bevel vertices instead of edges,
|
||||
and then change it to edges when cutter is manifold (and transfer weights).
|
||||
"""
|
||||
mod.affect = affect
|
||||
if affect == 'EDGES':
|
||||
attr = ensure_attribute(bm, "bevel_weight_edge", 'EDGE')
|
||||
|
||||
# Mark all edges except ones belonging to original and extruded face.
|
||||
for edge in bm.edges:
|
||||
if edge in faces[0].edges:
|
||||
continue
|
||||
if edge in faces[-1].edges:
|
||||
continue
|
||||
edge[attr] = 1.0
|
||||
|
||||
elif affect == 'VERTICES':
|
||||
attr = ensure_attribute(bm, "bevel_weight_vert", 'VERTEX')
|
||||
face = cls.cutter.faces[0]
|
||||
|
||||
# Mark vertices of the original face.
|
||||
verts = [vert for vert in face.verts]
|
||||
for v in verts:
|
||||
v[attr] = 1.0
|
||||
|
||||
# Add Weld modifier (necessary for merging overlapping vertices).
|
||||
# Otherwise live cut produces corrupted booleans because of non-manifold geometry.
|
||||
self.add_weld_modifier(cls)
|
||||
|
||||
self.bevel = mod
|
||||
|
||||
|
||||
def transfer_bevel_weights(self, cls):
|
||||
"""Transfer bevel weights from vertices to edges."""
|
||||
|
||||
if not cls.use_bevel:
|
||||
return
|
||||
|
||||
bm = cls.cutter.bm
|
||||
faces = cls.cutter.faces
|
||||
|
||||
# Ensure default edge weights attribute.
|
||||
edge_attr = ensure_attribute(bm, "bevel_weight_edge", 'EDGE')
|
||||
|
||||
for edge in bm.edges:
|
||||
if edge in faces[0].edges:
|
||||
continue
|
||||
if edge in faces[-1].edges:
|
||||
continue
|
||||
edge[edge_attr] = 1.0
|
||||
|
||||
self.bevel.affect = 'EDGES'
|
||||
|
||||
|
||||
# Smooth by Angle
|
||||
def add_auto_smooth_modifier(self, cls, context):
|
||||
"""Adds a 'Smooth by Angle' modifier on cutter object, a.k.a. Auto Smooth."""
|
||||
|
||||
obj = cls.cutter.obj
|
||||
mesh = cls.cutter.mesh
|
||||
bm = cls.cutter.bm
|
||||
|
||||
modifier_asset_path = "nodes\\geometry_nodes_essentials.blend\\NodeTree\\Smooth by Angle"
|
||||
modifier_asset_file = modifier_asset_path[:modifier_asset_path.find(".blend") + 6]
|
||||
modifier_asset_name = modifier_asset_path.rsplit("\\", 1)[1]
|
||||
|
||||
# Try adding modifier with `bpy.ops` operator(s) first.
|
||||
context_override = {
|
||||
"object": obj,
|
||||
"active_object": obj,
|
||||
"selected_objects": [obj],
|
||||
"selected_editable_objects": [obj],
|
||||
}
|
||||
with context.temp_override(**context_override):
|
||||
try:
|
||||
# Try adding the modifier with `shade_auto_smooth` operator.
|
||||
bpy.ops.object.shade_auto_smooth()
|
||||
except:
|
||||
# Try adding the modifier with path to Essentials library.
|
||||
bpy.ops.object.modifier_add_node_group(asset_library_type="ESSENTIALS",
|
||||
asset_library_identifier="",
|
||||
relative_asset_identifier=modifier_asset_path)
|
||||
|
||||
mod = obj.modifiers.active
|
||||
|
||||
# Try loading the node group manually if `bpy.ops` operators fail.
|
||||
if mod is None:
|
||||
dir = os.path.join(os.path.dirname(bpy.app.binary_path), "5.0", "datafiles", "assets")
|
||||
assets_path = os.path.join(dir, modifier_asset_file)
|
||||
mod = add_modifier_asset(obj, path=assets_path, asset=modifier_asset_name)
|
||||
|
||||
# Resort to destructive editing if everything fails.
|
||||
if mod is None:
|
||||
print("Smooth by Angle modifier couldn't be added.")
|
||||
print("Destructively marking sharp edges and smooth faces in the mesh")
|
||||
shade_smooth_by_angle(bm, mesh, angle=math.degrees(cls.sharp_angle))
|
||||
else:
|
||||
# Set smoothing angle.
|
||||
for face in bm.faces:
|
||||
face.smooth = True
|
||||
bm.to_mesh(mesh)
|
||||
|
||||
mod.use_pin_to_last = True
|
||||
mod["Input_1"] = cls.sharp_angle
|
||||
|
||||
self.smooth = mod
|
||||
|
||||
|
||||
# Weld
|
||||
def add_weld_modifier(self, cls):
|
||||
if self.weld is None:
|
||||
self.weld = cls.cutter.obj.modifiers.new("cutter_weld", 'WELD')
|
||||
return self.weld
|
||||
@@ -7,13 +7,24 @@ from ... import __package__ as base_package
|
||||
def carver_ui_common(context, layout, props):
|
||||
"""Common tool properties for all Carver tools"""
|
||||
|
||||
layout.prop(props, "mode", text="")
|
||||
layout.prop(props, "depth", text="")
|
||||
layout.prop(props, "solver", expand=True)
|
||||
if context.region.type == 'TOOL_HEADER':
|
||||
layout.prop(props, "mode", text="")
|
||||
layout.prop(props, "alignment", text="")
|
||||
layout.prop(props, "depth", text="")
|
||||
layout.prop(props, "solver", expand=True)
|
||||
|
||||
else:
|
||||
# Use labels for Properties editor/sidebar.
|
||||
layout.prop(props, "mode", text="Mode")
|
||||
layout.prop(props, "alignment", text="Alignment")
|
||||
layout.prop(props, "depth", text="Depth")
|
||||
row = layout.row()
|
||||
row.prop(props, "solver", expand=True)
|
||||
layout.separator()
|
||||
|
||||
# Popovers
|
||||
layout.popover("TOPBAR_PT_carver_shape", text="Shape")
|
||||
layout.popover("TOPBAR_PT_carver_array", text="Array")
|
||||
layout.popover("TOPBAR_PT_carver_effects", text="Effects")
|
||||
layout.popover("TOPBAR_PT_carver_cutter", text="Cutter")
|
||||
|
||||
|
||||
@@ -21,7 +32,7 @@ def carver_ui_common(context, layout, props):
|
||||
#### ------------------------------ /popovers/ ------------------------------ ####
|
||||
|
||||
class TOPBAR_PT_carver_shape(bpy.types.Panel):
|
||||
bl_label = "Carver Shape"
|
||||
bl_label = "Cutter Shape"
|
||||
bl_idname = "TOPBAR_PT_carver_shape"
|
||||
bl_region_type = 'HEADER'
|
||||
bl_space_type = 'TOPBAR'
|
||||
@@ -32,12 +43,14 @@ class TOPBAR_PT_carver_shape(bpy.types.Panel):
|
||||
layout.use_property_split = True
|
||||
layout.use_property_decorate = False
|
||||
|
||||
prefs = context.preferences.addons[base_package].preferences
|
||||
tool = context.workspace.tools.from_space_view3d_mode('OBJECT' if context.mode == 'OBJECT' else 'EDIT_MESH')
|
||||
|
||||
# Box
|
||||
# Box & Circle
|
||||
if tool.idname == "object.carve_box" or tool.idname == "object.carve_circle":
|
||||
props = tool.operator_properties("object.carve_box")
|
||||
if tool.idname == "object.carve_box":
|
||||
props = tool.operator_properties("object.carve_box")
|
||||
else:
|
||||
props = tool.operator_properties("object.carve_circle")
|
||||
|
||||
if tool.idname == "object.carve_circle":
|
||||
layout.prop(props, "subdivision", text="Vertices")
|
||||
@@ -45,29 +58,24 @@ class TOPBAR_PT_carver_shape(bpy.types.Panel):
|
||||
layout.prop(props, "aspect", expand=True)
|
||||
layout.prop(props, "origin", expand=True)
|
||||
|
||||
# bevel
|
||||
if tool.idname == 'object.carve_box':
|
||||
layout.separator()
|
||||
layout.prop(props, "use_bevel", text="Bevel")
|
||||
col = layout.column(align=True)
|
||||
row = col.row(align=True)
|
||||
if prefs.experimental:
|
||||
row.prop(props, "bevel_profile", text="Profile", expand=True)
|
||||
col.prop(props, "bevel_segments", text="Segments")
|
||||
col.prop(props, "bevel_radius", text="Radius")
|
||||
|
||||
if props.use_bevel == False:
|
||||
col.enabled = False
|
||||
if props.alignment == 'SURFACE':
|
||||
layout.prop(props, "orientation")
|
||||
layout.prop(props, "offset", text="Offset")
|
||||
layout.prop(props, "align_to_all")
|
||||
if props.alignment == 'CURSOR':
|
||||
layout.prop(props, "alignment_axis", text="Align to", expand=True)
|
||||
|
||||
# Polyline
|
||||
elif tool.idname == "object.carve_polyline":
|
||||
props = tool.operator_properties("object.carve_polyline")
|
||||
layout.prop(props, "closed")
|
||||
if props.alignment == 'SURFACE':
|
||||
layout.prop(props, "offset", text="Offset")
|
||||
layout.prop(props, "align_to_all")
|
||||
|
||||
|
||||
class TOPBAR_PT_carver_array(bpy.types.Panel):
|
||||
bl_label = "Carver Array"
|
||||
bl_idname = "TOPBAR_PT_carver_array"
|
||||
class TOPBAR_PT_carver_effects(bpy.types.Panel):
|
||||
bl_label = "Cutter Effects"
|
||||
bl_idname = "TOPBAR_PT_carver_effects"
|
||||
bl_region_type = 'HEADER'
|
||||
bl_space_type = 'TOPBAR'
|
||||
bl_category = 'Tool'
|
||||
@@ -78,26 +86,35 @@ class TOPBAR_PT_carver_array(bpy.types.Panel):
|
||||
layout.use_property_decorate = False
|
||||
|
||||
tool = context.workspace.tools.from_space_view3d_mode('OBJECT' if context.mode == 'OBJECT' else 'EDIT_MESH')
|
||||
if tool.idname == "object.carve_box" or tool.idname == "object.carve_circle":
|
||||
if tool.idname == "object.carve_box":
|
||||
props = tool.operator_properties("object.carve_box")
|
||||
elif tool.idname == "object.carve_circle":
|
||||
props = tool.operator_properties("object.carve_circle")
|
||||
elif tool.idname == "object.carve_polyline":
|
||||
props = tool.operator_properties("object.carve_polyline")
|
||||
|
||||
# Rows
|
||||
col = layout.column(align=True)
|
||||
col.prop(props, "rows")
|
||||
row = col.row(align=True)
|
||||
row.prop(props, "rows_direction", text="Direction", expand=True)
|
||||
col.prop(props, "rows_gap", text="Gap")
|
||||
# Bevel
|
||||
if tool.idname == 'object.carve_box':
|
||||
header, panel = layout.panel("OBJECT_OT_carver_effects_bevel", default_closed=False)
|
||||
header.label(text="Bevel")
|
||||
if panel:
|
||||
panel.prop(props, "use_bevel", text="Side Bevel")
|
||||
col = panel.column(align=True)
|
||||
col.prop(props, "bevel_segments", text="Segments")
|
||||
col.prop(props, "bevel_width", text="Radius")
|
||||
col.prop(props, "bevel_profile", text="Profile", slider=True)
|
||||
|
||||
# Columns
|
||||
layout.separator()
|
||||
col = layout.column(align=True)
|
||||
col.prop(props, "columns")
|
||||
row = col.row(align=True)
|
||||
row.prop(props, "columns_direction", text="Direction", expand=True)
|
||||
col.prop(props, "columns_gap", text="Gap")
|
||||
if props.use_bevel == False:
|
||||
col.enabled = False
|
||||
|
||||
# Array
|
||||
header, panel = layout.panel("OBJECT_OT_carver_effects_array", default_closed=False)
|
||||
header.label(text="Array")
|
||||
if panel:
|
||||
col = panel.column(align=True)
|
||||
col.prop(props, "columns")
|
||||
col.prop(props, "rows")
|
||||
col.prop(props, "gap")
|
||||
|
||||
class TOPBAR_PT_carver_cutter(bpy.types.Panel):
|
||||
bl_label = "Carver Cutter"
|
||||
@@ -112,23 +129,31 @@ class TOPBAR_PT_carver_cutter(bpy.types.Panel):
|
||||
layout.use_property_decorate = False
|
||||
|
||||
tool = context.workspace.tools.from_space_view3d_mode('OBJECT' if context.mode == 'OBJECT' else 'EDIT_MESH')
|
||||
if tool.idname == "object.carve_box" or tool.idname == "object.carve_circle":
|
||||
if tool.idname == "object.carve_box":
|
||||
props = tool.operator_properties("object.carve_box")
|
||||
elif tool.idname == "object.carve_circle":
|
||||
props = tool.operator_properties("object.carve_circle")
|
||||
elif tool.idname == "object.carve_polyline":
|
||||
props = tool.operator_properties("object.carve_polyline")
|
||||
|
||||
# modifier_&_cutter
|
||||
col = layout.column()
|
||||
row = col.row()
|
||||
row.prop(props, "display", text="Display", expand=True)
|
||||
col.prop(props, "pin", text="Pin Modifier")
|
||||
if props.mode == 'MODIFIER':
|
||||
col.prop(props, "parent")
|
||||
col.prop(props, "hide")
|
||||
col.prop(props, "cutter_origin", text="Origin")
|
||||
|
||||
# auto_smooth
|
||||
layout.separator()
|
||||
col = layout.column(align=True)
|
||||
col.prop(props, "auto_smooth", text="Auto Smooth")
|
||||
col.prop(props, "sharp_angle")
|
||||
col1 = layout.column()
|
||||
col1.prop(props, "sharp_angle")
|
||||
if not props.auto_smooth:
|
||||
col1.enabled = False
|
||||
|
||||
|
||||
|
||||
@@ -136,7 +161,7 @@ class TOPBAR_PT_carver_cutter(bpy.types.Panel):
|
||||
|
||||
classes = [
|
||||
TOPBAR_PT_carver_shape,
|
||||
TOPBAR_PT_carver_array,
|
||||
TOPBAR_PT_carver_effects,
|
||||
TOPBAR_PT_carver_cutter,
|
||||
]
|
||||
|
||||
|
||||
@@ -17,18 +17,6 @@
|
||||
# Link to base names: Sybren, Texture renamer: Yadoob
|
||||
# ###
|
||||
|
||||
bl_info = {
|
||||
"name": "Material Utilities",
|
||||
"author": "MichaleW, ChrisHinde",
|
||||
"version": (2, 2, 2),
|
||||
"blender": (3, 0, 0),
|
||||
"location": "View3D > Shift + Q key",
|
||||
"description": "Menu of material tools (assign, select..) in the 3D View",
|
||||
"warning": "Beta",
|
||||
"doc_url": "{BLENDER_MANUAL_URL}/addons/materials/material_utils.html",
|
||||
"category": "Material"
|
||||
}
|
||||
|
||||
"""
|
||||
This script has several functions and operators, grouped for convenience:
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
schema_version = "1.0.0"
|
||||
id = "material_utilities"
|
||||
name = "Material Utilities"
|
||||
version = "2.2.2"
|
||||
version = "2.2.3"
|
||||
tagline = "Menu of material tools (assign, select..) in the 3D View"
|
||||
maintainer = "Community"
|
||||
type = "add-on"
|
||||
tags = ["Material"]
|
||||
blender_version_min = "4.2.0"
|
||||
license = ["SPDX:GPL-2.0-or-later"]
|
||||
license = ["SPDX:GPL-3.0-or-later"]
|
||||
website = "https://projects.blender.org/extensions/materials_utils"
|
||||
copyright = ["2024 MichaleW", "2024 ChrisHinde"]
|
||||
copyright = [
|
||||
"2024 MichaleW",
|
||||
"2024 ChrisHinde",
|
||||
]
|
||||
|
||||
@@ -512,6 +512,8 @@ def mu_remove_all_materials(self, for_active_object = False):
|
||||
objects = bpy.context.selected_editable_objects
|
||||
|
||||
for obj in objects:
|
||||
if not hasattr(obj.data, "materials"):
|
||||
continue
|
||||
obj.data.materials.clear()
|
||||
|
||||
bpy.context.view_layer.objects.active = last_active
|
||||
|
||||
@@ -285,7 +285,16 @@ class VIEW3D_OT_materialutilities_remove_all_material_slots(bpy.types.Operator):
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return (context.active_object is not None) and (context.active_object.mode != 'EDIT')
|
||||
if (obj := context.active_object) is None:
|
||||
cls.poll_message_set("No active object selected.")
|
||||
return False
|
||||
elif not hasattr(obj.data, "materials"):
|
||||
cls.poll_message_set("Active object doesn't support materials.")
|
||||
return False
|
||||
elif obj.mode == "EDIT":
|
||||
cls.poll_message_set("Active object is in EDIT mode.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
|
||||
@@ -6,6 +6,8 @@ import bpy
|
||||
from bpy.utils import register_class, unregister_class
|
||||
import importlib
|
||||
|
||||
_VPM_AGENT_IMMEDIATE_REGISTER_DONE = locals().get("_VPM_AGENT_IMMEDIATE_REGISTER_DONE", False)
|
||||
|
||||
module_names = (
|
||||
"op_pie_wrappers",
|
||||
"op_copy_to_selected",
|
||||
@@ -57,10 +59,8 @@ def register_unregister_modules(modules: list, register: bool):
|
||||
for c in m.registry:
|
||||
try:
|
||||
register_func(c)
|
||||
except Exception as e:
|
||||
print(
|
||||
f"Warning: Pie Menus failed to {un}register class: {c.__name__}"
|
||||
)
|
||||
except (AttributeError, RuntimeError, TypeError, ValueError) as e:
|
||||
print(f"Warning: Pie Menus failed to {un}register class: {c.__name__}")
|
||||
print(e)
|
||||
|
||||
if hasattr(m, 'modules'):
|
||||
@@ -78,8 +78,27 @@ def delayed_register(_scene=None):
|
||||
register_unregister_modules(modules, True)
|
||||
|
||||
def register():
|
||||
# NOTE: persistent=True must be set, otherwise this doesn't work when opening a .blend file directly from a file browser.
|
||||
bpy.app.timers.register(delayed_register, first_interval=0.5, persistent=True)
|
||||
"""
|
||||
We prefer an *immediate* register during startup, because other add-ons may touch
|
||||
keyconfig initialization very early, and Blender's keymap diff application appears
|
||||
sensitive to timing.
|
||||
|
||||
If immediate registration fails (e.g. missing WM in edge cases), fall back to the
|
||||
legacy timer-based delayed registration.
|
||||
"""
|
||||
global _VPM_AGENT_IMMEDIATE_REGISTER_DONE
|
||||
if not _VPM_AGENT_IMMEDIATE_REGISTER_DONE:
|
||||
try:
|
||||
register_unregister_modules(modules, True)
|
||||
_VPM_AGENT_IMMEDIATE_REGISTER_DONE = True
|
||||
return
|
||||
except Exception as e:
|
||||
# Keep behavior unchanged (fallback to timer), but avoid raising during registration.
|
||||
pass
|
||||
|
||||
# NOTE: persistent=True must be set, otherwise this doesn't work when opening
|
||||
# a .blend file directly from a file browser.
|
||||
bpy.app.timers.register(delayed_register, first_interval=0.0, persistent=True)
|
||||
|
||||
def unregister():
|
||||
register_unregister_modules(reversed(modules), False)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
schema_version = "1.0.0"
|
||||
id = "viewport_pie_menus"
|
||||
name = "3D Viewport Pie Menus"
|
||||
version = "1.7.1"
|
||||
version = "1.7.3"
|
||||
tagline = "Various pie menus to speed up your workflow"
|
||||
maintainer = "Community"
|
||||
type = "add-on"
|
||||
|
||||
@@ -4,11 +4,17 @@
|
||||
|
||||
# This file requires (and is made possible by) Blender 5.0 due to the find_match() API call.
|
||||
|
||||
import hashlib
|
||||
from typing import Callable, Any
|
||||
|
||||
import bpy
|
||||
import json
|
||||
from bpy.types import KeyMap, KeyMapItem, UILayout
|
||||
|
||||
if "ADDON_KEYMAPS" not in locals():
|
||||
ADDON_KEYMAPS = []
|
||||
# Preserve across Reload Scripts (module reload) when possible, but also keep
|
||||
# names defined for static analyzers.
|
||||
ADDON_KEYMAPS = locals().get("ADDON_KEYMAPS", [])
|
||||
KMI_HASHES = locals().get("KMI_HASHES", {})
|
||||
|
||||
KEYMAP_ICONS = {
|
||||
'Object Mode': 'OBJECT_DATAMODE',
|
||||
@@ -29,20 +35,30 @@ KEYMAP_UI_NAMES = {
|
||||
}
|
||||
|
||||
KMI_DEFAULTS = {
|
||||
prop: KeyMapItem.bl_rna.properties[prop].default
|
||||
for prop in KeyMapItem.bl_rna.properties.keys()
|
||||
prop.identifier: prop.default
|
||||
for prop in KeyMapItem.bl_rna.properties
|
||||
if hasattr(prop, 'default')
|
||||
}
|
||||
|
||||
def register_hotkey(
|
||||
bl_idname,
|
||||
*,
|
||||
op_kwargs={},
|
||||
hotkey_kwargs={'type': "SPACE", 'value': "PRESS"},
|
||||
bl_idname,
|
||||
*,
|
||||
op_kwargs=None,
|
||||
hotkey_kwargs=None,
|
||||
keymap_name='Window'
|
||||
):
|
||||
global ADDON_KEYMAPS
|
||||
wm = bpy.context.window_manager
|
||||
if op_kwargs is None:
|
||||
op_kwargs = {}
|
||||
if hotkey_kwargs is None:
|
||||
hotkey_kwargs = {'type': "SPACE", 'value': "PRESS"}
|
||||
|
||||
context = bpy.context
|
||||
wm = context.window_manager
|
||||
|
||||
kmi_hash = any_to_hash(op_kwargs, hotkey_kwargs, keymap_name)
|
||||
if kmi_hash in KMI_HASHES:
|
||||
# Avoid re-registering on Reload Scripts.
|
||||
return
|
||||
|
||||
space_type = wm.keyconfigs.default.keymaps[keymap_name].space_type
|
||||
|
||||
@@ -63,26 +79,27 @@ def register_hotkey(
|
||||
# it is SUPPOSED TO stick around for ever.
|
||||
# This allows Blender to store the associated user keymap, meaning the user's modifications
|
||||
# will be stored and restored as expected, whenever the add-on is enabled again.
|
||||
# if (addon_km, existing_kmi) not in ADDON_KEYMAPS:
|
||||
# ADDON_KEYMAPS.append((addon_km, existing_kmi))
|
||||
if (addon_km, existing_kmi) not in ADDON_KEYMAPS:
|
||||
ADDON_KEYMAPS.append((addon_km, existing_kmi))
|
||||
return
|
||||
addon_kmi = addon_km.keymap_items.new(bl_idname, **hotkey_kwargs)
|
||||
for key in op_kwargs:
|
||||
value = op_kwargs[key]
|
||||
setattr(addon_kmi.properties, key, value)
|
||||
|
||||
KMI_HASHES[kmi_hash] = (addon_km, addon_kmi)
|
||||
ADDON_KEYMAPS.append((addon_km, addon_kmi))
|
||||
|
||||
def draw_hotkey_list(
|
||||
context,
|
||||
layout,
|
||||
*,
|
||||
compact=False,
|
||||
debug=False,
|
||||
sort_mode='BY_KEYMAP',
|
||||
ignore_missing=False,
|
||||
button_draw_func: callable=None,
|
||||
):
|
||||
context,
|
||||
layout,
|
||||
*,
|
||||
compact=False,
|
||||
debug=False,
|
||||
sort_mode='BY_KEYMAP',
|
||||
ignore_missing=False,
|
||||
button_draw_func: Callable = None,
|
||||
):
|
||||
"""Draw the list of hotkeys registered by this add-on.
|
||||
Will find the corresponding User KeyMapItems, which are safe to modify.
|
||||
Supports two sorting modes:
|
||||
@@ -99,7 +116,7 @@ def draw_hotkey_list(
|
||||
if sort_mode == 'BY_OPERATOR':
|
||||
layout = layout.column(align=True)
|
||||
|
||||
if compact == None:
|
||||
if compact is None:
|
||||
sidebar = get_sidebar(context)
|
||||
if sidebar:
|
||||
compact = sidebar.width < 600
|
||||
@@ -130,41 +147,57 @@ def draw_hotkey_list(
|
||||
if prev_kmi_name != kmi_name:
|
||||
layout.separator()
|
||||
|
||||
draw_kmi(user_km, user_kmi, layout, compact=compact, button_draw_func=button_draw_func, debug=debug)
|
||||
draw_kmi(
|
||||
user_km,
|
||||
user_kmi,
|
||||
layout,
|
||||
compact=compact,
|
||||
button_draw_func=button_draw_func,
|
||||
debug=debug
|
||||
)
|
||||
|
||||
def get_user_kmis_of_addon(context) -> list[tuple[KeyMap, KeyMapItem]]:
|
||||
|
||||
def get_user_kmis_of_addon(context, *, do_update=True) -> list[tuple[KeyMap, KeyMapItem]]:
|
||||
"""Return a list of (KeyMap, KeyMapItem) tuples of user-shortcuts (the ones that can be modified by user)."""
|
||||
ret = []
|
||||
|
||||
assert bpy.app.version >= (5, 0, 0), "This function requires Blender 5.0 or later."
|
||||
|
||||
context.window_manager.keyconfigs.update()
|
||||
if do_update:
|
||||
context.window_manager.keyconfigs.update()
|
||||
for addon_km, addon_kmi in ADDON_KEYMAPS:
|
||||
user_km = context.window_manager.keyconfigs.user.keymaps.get(addon_km.name)
|
||||
if not user_km:
|
||||
# This should never happen.
|
||||
print("Failed to find User KeyMap: ", addon_km.name)
|
||||
continue
|
||||
user_kmi = user_km.keymap_items.find_match(addon_km, addon_kmi)
|
||||
if not user_kmi:
|
||||
# This shouldn't really happen, but maybe it can, eg. if user changes idname.
|
||||
print("Failed to find User KeyMapItem: ", addon_km.name, addon_kmi.idname)
|
||||
continue
|
||||
ret.append((user_km, user_kmi))
|
||||
user_km, user_kmi = get_user_kmi_of_addon(context, addon_km, addon_kmi)
|
||||
if user_kmi:
|
||||
ret.append((user_km, user_kmi))
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
def get_user_kmi_of_addon(context, addon_km, addon_kmi) -> tuple[KeyMap | None, KeyMapItem | None]:
|
||||
user_km = context.window_manager.keyconfigs.user.keymaps.get(addon_km.name)
|
||||
if not user_km:
|
||||
# This should never happen.
|
||||
print("Failed to find User KeyMap: ", addon_km.name)
|
||||
return None, None
|
||||
user_kmi = user_km.keymap_items.find_match(addon_km, addon_kmi)
|
||||
if not user_kmi:
|
||||
# This shouldn't really happen, but maybe it can, eg. if user changes idname.
|
||||
print("Failed to find User KeyMapItem: ", addon_km.name, addon_kmi.idname)
|
||||
return None, None
|
||||
return user_km, user_kmi
|
||||
|
||||
|
||||
def get_kmi_ui_info(km, kmi) -> tuple[str, str, str]:
|
||||
km_name = km.name
|
||||
km_name: str = km.name
|
||||
km_icon = KEYMAP_ICONS.get(km_name, 'BLANK1')
|
||||
km_name = KEYMAP_UI_NAMES.get(km_name, km_name)
|
||||
if kmi.properties and 'name' in kmi.properties:
|
||||
name = kmi.properties.name
|
||||
if name:
|
||||
try:
|
||||
if hasattr(bpy.types, kmi.properties.name):
|
||||
bpy_type = getattr(bpy.types, kmi.properties.name)
|
||||
kmi_name = bpy_type.bl_label
|
||||
except:
|
||||
else:
|
||||
kmi_name = "Missing (code 1). Try restarting."
|
||||
else:
|
||||
kmi_name = "Missing (code 2). Try restarting."
|
||||
@@ -174,7 +207,7 @@ def get_kmi_ui_info(km, kmi) -> tuple[str, str, str]:
|
||||
bpy_type = getattr(bpy.ops, parts[0])
|
||||
bpy_type = getattr(bpy_type, parts[1])
|
||||
kmi_name = bpy_type.get_rna_type().name
|
||||
except:
|
||||
except (AttributeError, IndexError, TypeError):
|
||||
kmi_name = "Missing (code 3). Try restarting."
|
||||
|
||||
return km_icon, km_name, kmi_name
|
||||
@@ -194,30 +227,49 @@ def find_kmi_in_km_by_data(km: KeyMap, hotkey_kwargs: dict, op_idname: str, op_k
|
||||
if value != getattr(kmi, key):
|
||||
return False
|
||||
|
||||
want_to_crash = False
|
||||
if want_to_crash:
|
||||
# These checks cause https://projects.blender.org/Mets/CloudRig/issues/201
|
||||
# They don't seem necessary.
|
||||
if kmi.properties == None:
|
||||
# IMPORTANT:
|
||||
# `wm.keyconfigs.addon` is shared by *all* add-ons. If we only match on idname+hotkey,
|
||||
# we may incorrectly treat another add-on's KeyMapItem as ours and skip registering,
|
||||
# which prevents Blender from applying the user's stored overrides on next startup
|
||||
# (manifesting as "prefs/hotkeys reset", eg. when used together with Pie Menu Editor).
|
||||
#
|
||||
# We therefore include operator properties in the match, but do it defensively:
|
||||
# - only compare simple scalar types (str/int/float/bool/None)
|
||||
# - if anything unexpected is encountered, treat it as a mismatch rather than risking
|
||||
# false positives or Blender RNA edge-case crashes.
|
||||
if op_kwargs:
|
||||
try:
|
||||
if kmi.properties is None:
|
||||
return False
|
||||
for key, expected in op_kwargs.items():
|
||||
if key not in kmi.properties:
|
||||
return False
|
||||
actual = getattr(kmi.properties, key, None)
|
||||
|
||||
# Compare only stable scalar values.
|
||||
scalar_types = (str, int, float, bool, type(None))
|
||||
if isinstance(expected, scalar_types) and isinstance(actual, scalar_types):
|
||||
if actual != expected:
|
||||
return False
|
||||
else:
|
||||
# Unknown/complex value: do not assume a match.
|
||||
return False
|
||||
except (AttributeError, KeyError, TypeError, RuntimeError):
|
||||
# Be conservative: if Blender throws, don't match.
|
||||
return False
|
||||
for key, value in op_kwargs.items():
|
||||
if key not in kmi.properties:
|
||||
return False
|
||||
if value != kmi.properties[key]:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
return next((kmi for kmi in km.keymap_items if is_kmi_matching(kmi, hotkey_kwargs, op_idname, op_kwargs)), None)
|
||||
|
||||
def draw_kmi(
|
||||
km: KeyMap,
|
||||
kmi: KeyMapItem,
|
||||
layout: UILayout,
|
||||
compact=False,
|
||||
button_draw_func: callable=None,
|
||||
debug=False,
|
||||
):
|
||||
km: KeyMap,
|
||||
kmi: KeyMapItem,
|
||||
layout: UILayout,
|
||||
compact=False,
|
||||
button_draw_func: Callable = None,
|
||||
debug=False,
|
||||
):
|
||||
"""Draw a KeyMapItem in the provided UI.
|
||||
This function is designed specifically to be used in an add-on's preferences:
|
||||
- It does not allow removing the KeyMapItem, since add-on KMIs should never be removed.
|
||||
@@ -249,14 +301,14 @@ def draw_kmi(
|
||||
sub.prop(kmi, "type", text="", full_event=True)
|
||||
|
||||
if kmi.is_user_modified:
|
||||
row2.context_pointer_set("keymap", km) # NOTE: Yes, this actually matters.
|
||||
row2.operator(
|
||||
"preferences.keyitem_restore", text="", icon='BACK'
|
||||
).item_id = kmi.id
|
||||
# Make `context.keymap` available in the drawing code (in this case blender's native code)
|
||||
row2.context_pointer_set("keymap", km)
|
||||
row2.operator("preferences.keyitem_restore", text="", icon='BACK').item_id = kmi.id
|
||||
|
||||
if debug and kmi.show_expanded:
|
||||
layout.template_keymap_item_properties(kmi)
|
||||
|
||||
|
||||
def get_sidebar(context):
|
||||
if not context.area.type == 'VIEW_3D':
|
||||
return None
|
||||
@@ -264,16 +316,17 @@ def get_sidebar(context):
|
||||
if region.type == 'UI':
|
||||
return region
|
||||
|
||||
def find_matching_km_and_kmi(context, target_kc, src_km, src_kmi) -> tuple[KeyMap or None, KeyMapItem or None]:
|
||||
|
||||
def find_matching_km_and_kmi(context, target_kc, src_km, src_kmi) -> tuple[KeyMap | None, KeyMapItem | None]:
|
||||
target_km = find_matching_keymap(context, target_kc, src_km)
|
||||
if not target_km:
|
||||
raise Exception(f"Failed to find KeyMap '{src_km.name}' in KeyConfig '{target_kc.name}'")
|
||||
raise RuntimeError(f"Failed to find KeyMap '{src_km.name}' in KeyConfig '{target_kc.name}'")
|
||||
kc_user = context.window_manager.keyconfigs.user
|
||||
# If we want to find a matching User KeyMapItem, that's easy, because that's what the API was meant for.
|
||||
if target_kc == kc_user:
|
||||
return target_km, target_km.keymap_items.find_match(src_km, src_kmi)
|
||||
|
||||
user_km, user_kmi = src_km, src_kmi
|
||||
user_km = src_km
|
||||
# If we want to find any other type of KeyMapItem, we have to do it indirectly, since we can only directly check for matches in the User KeyConfig.
|
||||
# So eg. if we want to find an Addon KeyMapItem based on a User KeyMapItem, we have to loop over all Addon KeyMapItems, and find which one matches with the given User KeyMapItem.
|
||||
for target_kmi in target_km.keymap_items:
|
||||
@@ -283,14 +336,14 @@ def find_matching_km_and_kmi(context, target_kc, src_km, src_kmi) -> tuple[KeyMa
|
||||
return target_km, target_kmi
|
||||
except RuntimeError:
|
||||
print("Failed to find matching KeyMapItem for: ", target_km.name, target_kmi.to_string())
|
||||
|
||||
|
||||
# raise Exception(f"Failed to find KeyMapItem '{src_kmi.idname}' ({src_kmi.to_string()}) in KeyConfig '{target_kc.name}', KeyMap '{target_km.name}'")
|
||||
# We will return here eg. when looking for an add-on keymap in the default keyconfig.
|
||||
return None, None
|
||||
|
||||
def find_matching_keymap(context, target_kc, src_km):
|
||||
"""Find the equivalent keymap in another keyconfig."""
|
||||
|
||||
|
||||
kc_user = context.window_manager.keyconfigs.user
|
||||
|
||||
# If we want to find a matching User KeyMap, that's easy, because that's what the API was meant for.
|
||||
@@ -306,6 +359,7 @@ def find_matching_keymap(context, target_kc, src_km):
|
||||
if match == src_km:
|
||||
return km
|
||||
|
||||
|
||||
class WINDOW_OT_restore_deleted_hotkeys(bpy.types.Operator):
|
||||
bl_idname = "window.restore_deleted_hotkeys"
|
||||
bl_description = "Restore any missing built-in or add-on hotkeys.\n(These should be disabled instead of being deleted.)\nThis operation cannot be undone!"
|
||||
@@ -318,6 +372,7 @@ class WINDOW_OT_restore_deleted_hotkeys(bpy.types.Operator):
|
||||
self.report({'INFO'}, f"Restored {num_restored} deleted keymaps.")
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
def restore_deleted_keymap_items_global(context) -> int:
|
||||
"""Deleting built-in or add-on KeyMapItems should never be done by users, as there's no way to recover them.
|
||||
Changing the operator name also shouldn't be done, since that makes it impossible to track modifications.
|
||||
@@ -339,6 +394,7 @@ def restore_deleted_keymap_items_global(context) -> int:
|
||||
total_restored += num_restored
|
||||
return total_restored
|
||||
|
||||
|
||||
def restore_deleted_keymap_items(context, user_km_name) -> int:
|
||||
keyconfigs = context.window_manager.keyconfigs
|
||||
user_kc = keyconfigs.user
|
||||
@@ -348,7 +404,7 @@ def restore_deleted_keymap_items(context, user_km_name) -> int:
|
||||
user_km = user_kc.keymaps[user_km_name]
|
||||
|
||||
# Step 1: Store modified and added KeyMapItems in a temp keymap.
|
||||
temp_km_name = "temp_"+user_km_name
|
||||
temp_km_name = "temp_" + user_km_name
|
||||
temp_km = user_kc.keymaps.new(temp_km_name)
|
||||
kmis_user_modified = []
|
||||
kmis_user_defined = []
|
||||
@@ -359,8 +415,8 @@ def restore_deleted_keymap_items(context, user_km_name) -> int:
|
||||
continue
|
||||
if user_kmi.is_user_modified:
|
||||
temp_kmi = temp_km.keymap_items.new_from_item(user_kmi)
|
||||
# Find the original keymap in either the Blender default or Addon KeyConfigs.
|
||||
# Not sure if this works with presets like Industry Compatible keymap,
|
||||
# Find the original keymap in either the Blender default or Addon KeyConfigs.
|
||||
# Not sure if this works with presets like Industry Compatible keymap,
|
||||
# but I assume they change the contents of the "default" keyconfig, so it would work.
|
||||
default_km, default_kmi = find_matching_km_and_kmi(context, default_kc, user_km, user_kmi)
|
||||
if not default_kmi:
|
||||
@@ -370,7 +426,7 @@ def restore_deleted_keymap_items(context, user_km_name) -> int:
|
||||
# Step 2: Restore User KeyMap to default.
|
||||
num_kmis = len(user_km.keymap_items)
|
||||
user_km.restore_to_default()
|
||||
# XXX: restore_to_default() will shuffle the memory addresses, so we need to re-reference user_km.
|
||||
# NOTE: restore_to_default() will shuffle the memory addresses, so we need to re-reference user_km.
|
||||
# I don't think this was the case pre-Blender 5.0!!
|
||||
user_km = user_kc.keymaps[user_km_name]
|
||||
temp_km = user_kc.keymaps[temp_km_name]
|
||||
@@ -381,7 +437,8 @@ def restore_deleted_keymap_items(context, user_km_name) -> int:
|
||||
|
||||
for (default_km, default_kmi), (temp_km, temp_kmi) in kmis_user_modified:
|
||||
user_km, user_kmi = find_matching_km_and_kmi(context, user_kc, default_km, default_kmi)
|
||||
for key in ('active', 'alt', 'any', 'ctrl', 'hyper', 'key_modifier', 'map_type', 'oskey', 'shift', 'repeat', 'type', 'value'):
|
||||
for key in ('active', 'alt', 'any', 'ctrl', 'hyper', 'key_modifier',
|
||||
'map_type', 'oskey', 'shift', 'repeat', 'type', 'value'):
|
||||
setattr(user_kmi, key, getattr(temp_kmi, key))
|
||||
if temp_kmi.properties:
|
||||
for key in temp_kmi.properties.keys():
|
||||
@@ -401,4 +458,23 @@ def restore_deleted_keymap_items(context, user_km_name) -> int:
|
||||
|
||||
return len(user_km.keymap_items) - num_kmis
|
||||
|
||||
def any_to_hash(*args) -> str:
|
||||
"""Hash whatever."""
|
||||
def stable(obj: Any):
|
||||
# Make hashing deterministic across runs and independent of dict insertion order.
|
||||
# Keep it conservative to avoid surprises with Blender RNA objects.
|
||||
if isinstance(obj, dict):
|
||||
return {str(k): stable(v) for k, v in sorted(obj.items(), key=lambda kv: str(kv[0]))}
|
||||
if isinstance(obj, (list, tuple)):
|
||||
return [stable(v) for v in obj]
|
||||
return obj
|
||||
|
||||
try:
|
||||
stringified = json.dumps([stable(a) for a in args], sort_keys=True, separators=(",", ":"), default=str)
|
||||
except (TypeError, ValueError):
|
||||
# Fallback: last resort stringification
|
||||
stringified = ";".join([str(arg) for arg in args])
|
||||
return hashlib.sha256(stringified.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
registry = [WINDOW_OT_restore_deleted_hotkeys]
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import bpy, json, os
|
||||
from bpy.types import PropertyGroup
|
||||
import bpy
|
||||
from bpy.types import AddonPreferences, PropertyGroup
|
||||
from rna_prop_ui import IDPropertyGroup
|
||||
from bpy.types import AddonPreferences
|
||||
|
||||
from .. import __package__ as base_package
|
||||
|
||||
assert base_package
|
||||
|
||||
|
||||
class PrefsFileSaveLoadMixin:
|
||||
"""Mix-in class that can be used by any add-on to store their preferences in a file,
|
||||
so that they don't get lost when the add-on is disabled.
|
||||
@@ -48,6 +52,7 @@ class PrefsFileSaveLoadMixin:
|
||||
return
|
||||
if prefs:
|
||||
prefs.load_and_apply_prefs_from_file()
|
||||
|
||||
bpy.app.timers.register(timer_func, first_interval=delay)
|
||||
|
||||
def apply_prefs_from_dict_recursive(self, propgroup: PropertyGroup, data: dict):
|
||||
@@ -55,14 +60,14 @@ class PrefsFileSaveLoadMixin:
|
||||
if not hasattr(propgroup, key):
|
||||
# Property got removed or renamed in the implementation.
|
||||
continue
|
||||
if type(value) == list:
|
||||
if type(value) is list:
|
||||
for elem in value:
|
||||
collprop = getattr(propgroup, key)
|
||||
entry = collprop.get(elem['name'])
|
||||
entry = collprop.get(elem["name"])
|
||||
if not entry:
|
||||
entry = collprop.add()
|
||||
self.apply_prefs_from_dict_recursive(entry, elem)
|
||||
elif type(value) == dict:
|
||||
elif type(value) is dict:
|
||||
self.apply_prefs_from_dict_recursive(getattr(propgroup, key), value)
|
||||
else:
|
||||
setattr(propgroup, key, value)
|
||||
@@ -125,15 +130,17 @@ def props_to_dict_recursive(propgroup: IDPropertyGroup, skip=[]) -> dict:
|
||||
ret = {}
|
||||
|
||||
for key in propgroup.bl_rna.properties.keys():
|
||||
if key in skip or key in ['rna_type', 'bl_idname']:
|
||||
if key in skip or key in ["rna_type", "bl_idname"]:
|
||||
continue
|
||||
value = getattr(propgroup, key)
|
||||
if isinstance(value, bpy.types.bpy_prop_collection):
|
||||
ret[key] = [props_to_dict_recursive(elem) for elem in value]
|
||||
elif type(value) == IDPropertyGroup or isinstance(value, bpy.types.PropertyGroup):
|
||||
elif isinstance(value, IDPropertyGroup) or isinstance(
|
||||
value, bpy.types.PropertyGroup
|
||||
):
|
||||
ret[key] = props_to_dict_recursive(value)
|
||||
else:
|
||||
if hasattr(propgroup.bl_rna.properties[key], 'enum_items'):
|
||||
if hasattr(propgroup.bl_rna.properties[key], "enum_items"):
|
||||
# Save enum values as string, not int.
|
||||
ret[key] = propgroup.bl_rna.properties[key].enum_items[value].identifier
|
||||
else:
|
||||
@@ -146,7 +153,7 @@ def get_addon_prefs(context=None) -> AddonPreferences | None:
|
||||
context = bpy.context
|
||||
|
||||
addons = context.preferences.addons
|
||||
if base_package.startswith('bl_ext'):
|
||||
if base_package.startswith("bl_ext"):
|
||||
# 4.2 and later
|
||||
addon_key = base_package
|
||||
else:
|
||||
@@ -154,7 +161,7 @@ def get_addon_prefs(context=None) -> AddonPreferences | None:
|
||||
addon_key = base_package.split(".")[0]
|
||||
|
||||
addon = addons.get(addon_key)
|
||||
if addon == None:
|
||||
if addon is None:
|
||||
# print("This happens when packaging the extension, due to the registration delay.")
|
||||
return
|
||||
|
||||
|
||||
@@ -2,54 +2,69 @@
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from typing import Any
|
||||
|
||||
import bpy
|
||||
from bpy.types import PropertyGroup, bpy_prop_collection, Object
|
||||
from rna_prop_ui import IDPropertyGroup
|
||||
from bpy.types import Object, PropertyGroup, bpy_prop_collection
|
||||
from bpy.utils import flip_name
|
||||
from mathutils import Matrix, Vector
|
||||
from rna_prop_ui import IDPropertyGroup
|
||||
|
||||
# Functions to manage runtime properties, which include custom properties and add-on properties.
|
||||
# These functions aim to abstract away that distinction, and also abstract away whether something is a single value,
|
||||
# a PropertyGroup, or a CollectionProperty.
|
||||
# Compatible with the API changes in 5.0, but also older versions.
|
||||
|
||||
|
||||
def copy_all_runtime_properties(src_id, tgt_id, x_mirror=False):
|
||||
"""Copy add-on and custom properties from source to target.
|
||||
"""Copy add-on and custom properties from source to target.
|
||||
Both should be the same type.
|
||||
Should support anything that supports custom properties or property registration.
|
||||
"""
|
||||
for prop_name in get_all_runtime_prop_names(src_id):
|
||||
copy_runtime_property(src_id, tgt_id, prop_name, x_mirror)
|
||||
|
||||
|
||||
def copy_all_custom_properties(src_id, tgt_id, x_mirror=False):
|
||||
for prop_name in get_custom_prop_names(src_id):
|
||||
copy_custom_property(src_id, tgt_id, prop_name=prop_name, x_mirror=x_mirror)
|
||||
|
||||
|
||||
def get_all_runtime_prop_names(owner):
|
||||
custom_props = list(owner.keys())
|
||||
addon_props = get_addon_prop_names(owner)
|
||||
props = custom_props + addon_props
|
||||
return props
|
||||
|
||||
|
||||
def get_custom_prop_names(owner):
|
||||
for prop_name in get_all_runtime_prop_names(owner):
|
||||
if is_custom_prop(owner, prop_name):
|
||||
yield prop_name
|
||||
|
||||
|
||||
def get_addon_prop_names(owner):
|
||||
if bpy.app.version >= (5, 0, 0):
|
||||
sys_props = owner.bl_system_properties_get()
|
||||
if sys_props == None:
|
||||
if sys_props is None:
|
||||
# If there aren't any add-on properties.
|
||||
return []
|
||||
return list(sys_props.keys())
|
||||
else:
|
||||
return [prop_name for prop_name in owner.keys() if is_addon_prop(owner, prop_name)]
|
||||
return [
|
||||
prop_name for prop_name in owner.keys()
|
||||
if is_addon_prop(owner, prop_name)
|
||||
]
|
||||
|
||||
|
||||
def rename_custom_prop(owner, from_name, to_name):
|
||||
assert is_custom_prop(owner, from_name), f"Property {from_name} of {owner} is not a Custom Property."
|
||||
assert is_custom_prop(owner, from_name), (
|
||||
f"Property {from_name} of {owner} is not a Custom Property."
|
||||
)
|
||||
copy_custom_property(owner, owner, from_name, new_name=to_name, x_mirror=False)
|
||||
remove_property(owner, from_name)
|
||||
|
||||
|
||||
def copy_runtime_property(src_id, tgt_id, prop_name, x_mirror=False):
|
||||
"""Copy add-on properties or custom properties."""
|
||||
if is_addon_prop(src_id, prop_name):
|
||||
@@ -64,7 +79,7 @@ def copy_runtime_property(src_id, tgt_id, prop_name, x_mirror=False):
|
||||
copy_single_addon_prop(src_id, tgt_id, prop_name, x_mirror)
|
||||
else:
|
||||
if bpy.app.version >= (5, 0, 0):
|
||||
# HACK: If we need to copy add-on properties, but the add-on is not present,
|
||||
# HACK: If we need to copy add-on properties, but the add-on is not present,
|
||||
# we have to write to the system properties, which is API abuse that could
|
||||
# lose support any moment, but there is no other way to do this atm.
|
||||
tgt_props = tgt_id.bl_system_properties_get()
|
||||
@@ -81,6 +96,7 @@ def copy_runtime_property(src_id, tgt_id, prop_name, x_mirror=False):
|
||||
else:
|
||||
copy_custom_property(src_id, tgt_id, prop_name)
|
||||
|
||||
|
||||
def copy_property_group(src_pg: PropertyGroup, tgt_pg: PropertyGroup, x_mirror=False):
|
||||
"""
|
||||
Copy the values from one PropertyGroup into another of the same type.
|
||||
@@ -90,7 +106,7 @@ def copy_property_group(src_pg: PropertyGroup, tgt_pg: PropertyGroup, x_mirror=F
|
||||
assert tgt_pg.__class__ == src_pg.__class__
|
||||
|
||||
for prop_name in src_pg.bl_rna.properties.keys():
|
||||
if prop_name in ('rna_type', 'bl_rna'):
|
||||
if prop_name in ("rna_type", "bl_rna"):
|
||||
continue
|
||||
if not src_pg.is_property_set(prop_name):
|
||||
tgt_pg.property_unset(prop_name)
|
||||
@@ -108,6 +124,7 @@ def copy_property_group(src_pg: PropertyGroup, tgt_pg: PropertyGroup, x_mirror=F
|
||||
# PropertyGroups also support custom properties.
|
||||
copy_custom_property(src_pg, tgt_pg, prop_name, x_mirror)
|
||||
|
||||
|
||||
def copy_coll_prop(src_cp, tgt_cp, x_mirror=False):
|
||||
tgt_cp.clear()
|
||||
for src_pg in src_cp:
|
||||
@@ -115,6 +132,7 @@ def copy_coll_prop(src_cp, tgt_cp, x_mirror=False):
|
||||
tgt_pg = tgt_cp.add()
|
||||
copy_property_group(src_pg, tgt_pg, x_mirror)
|
||||
|
||||
|
||||
def copy_custom_property(src_owner, tgt_owner, prop_name, new_name="", x_mirror=False):
|
||||
"""Copy a custom property (one that was created via the UI or via Python dictionary syntax)."""
|
||||
if not new_name:
|
||||
@@ -133,10 +151,13 @@ def copy_custom_property(src_owner, tgt_owner, prop_name, new_name="", x_mirror=
|
||||
tgt_owner[new_name] = value
|
||||
new_prop = tgt_owner.id_properties_ui(new_name)
|
||||
new_prop.update_from(src_prop)
|
||||
tgt_owner.property_overridable_library_set(f'["{new_name}"]', src_owner.is_property_overridable_library(f'["{prop_name}"]'))
|
||||
tgt_owner.property_overridable_library_set(
|
||||
f'["{new_name}"]', src_owner.is_property_overridable_library(f'["{prop_name}"]')
|
||||
)
|
||||
return tgt_owner.id_properties_ui(new_name)
|
||||
|
||||
def copy_single_addon_prop(src, tgt, prop_name, x_mirror=False) -> True:
|
||||
|
||||
def copy_single_addon_prop(src, tgt, prop_name, x_mirror=False) -> bool:
|
||||
if src.is_property_readonly(prop_name):
|
||||
# This "early" exit has to come after CollectionProperty & PropertyGroup
|
||||
# checks, since they are technically read-only.
|
||||
@@ -145,10 +166,11 @@ def copy_single_addon_prop(src, tgt, prop_name, x_mirror=False) -> True:
|
||||
value = getattr(src, prop_name)
|
||||
if x_mirror:
|
||||
value = x_mirror_value(value)
|
||||
|
||||
|
||||
setattr(tgt, prop_name, value)
|
||||
return True
|
||||
|
||||
|
||||
def x_mirror_value(value):
|
||||
if isinstance(value, str):
|
||||
return flip_name(value)
|
||||
@@ -157,29 +179,38 @@ def x_mirror_value(value):
|
||||
else:
|
||||
return value
|
||||
|
||||
|
||||
def get_opposite_obj(obj: Object) -> Object:
|
||||
"""Return the X-mirrored version of a Blender object by name (and library if linked)."""
|
||||
flipped_name = flip_name(obj.name)
|
||||
lib = obj.library
|
||||
return (
|
||||
bpy.data.objects.get((lib, flipped_name)) if lib else
|
||||
bpy.data.objects.get(flipped_name)
|
||||
bpy.data.objects.get((lib, flipped_name))
|
||||
if lib
|
||||
else bpy.data.objects.get(flipped_name)
|
||||
) or obj
|
||||
|
||||
|
||||
def is_addon_prop(owner, prop_name):
|
||||
if bpy.app.version >= (5, 0, 0):
|
||||
return prop_name in get_addon_prop_names(owner)
|
||||
else:
|
||||
# NOTE: I don't think it's possible to detect pre-5.0 non-PropertyGroup/CollectionProperty non-registered add-on properties.
|
||||
# They just behave completely as custom properties.
|
||||
return prop_name in owner and (isinstance(owner[prop_name], IDPropertyGroup) or isinstance(owner[prop_name], list))
|
||||
return prop_name in owner and (
|
||||
isinstance(owner[prop_name], IDPropertyGroup)
|
||||
or isinstance(owner[prop_name], list)
|
||||
)
|
||||
|
||||
|
||||
def is_registered_addon_prop(owner, prop_name):
|
||||
return is_addon_prop(owner, prop_name) and prop_name in owner.bl_rna.properties
|
||||
|
||||
|
||||
def is_custom_prop(owner, prop_name):
|
||||
return prop_name in owner.keys() and not is_addon_prop(owner, prop_name)
|
||||
|
||||
|
||||
def remove_property(obj, prop_name):
|
||||
if is_custom_prop(obj, prop_name):
|
||||
del obj[prop_name]
|
||||
@@ -190,3 +221,33 @@ def remove_property(obj, prop_name):
|
||||
del disabled_addon_props[prop_name]
|
||||
else:
|
||||
raise KeyError(f"{prop_name} not found in {obj.name}")
|
||||
|
||||
|
||||
def get_property_defaults(bpy_type: type, exclude: list[str] = []) -> dict[str, Any]:
|
||||
def get_default(prop):
|
||||
if not hasattr(prop, "default"):
|
||||
return None
|
||||
|
||||
if hasattr(prop, "default"):
|
||||
if hasattr(prop, "default_array"):
|
||||
default_array = list(prop.default_array)
|
||||
if default_array:
|
||||
if len(default_array) == 9:
|
||||
return Matrix.Identity(3)
|
||||
elif len(default_array) == 16:
|
||||
return Matrix.Identity(4)
|
||||
elif len(default_array) == 3 and prop.type == 'FLOAT':
|
||||
default = default_array[0]
|
||||
return Vector((default, default, default))
|
||||
else:
|
||||
return default_array
|
||||
|
||||
return prop.default
|
||||
|
||||
assert False, f"Couldn't find default for {prop}"
|
||||
|
||||
return {
|
||||
prop.identifier: get_default(prop)
|
||||
for prop in bpy_type.bl_rna.properties
|
||||
if not prop.is_readonly and prop.identifier not in exclude
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
from bpy.types import UILayout
|
||||
|
||||
def aligned_label(layout: UILayout, *, alert=False, alignment='LEFT', **kwargs):
|
||||
|
||||
def aligned_label(layout: UILayout, *, alert=False, alignment="LEFT", **kwargs):
|
||||
"""Draw some text in the single-column-layout style, ie. offset by 60%."""
|
||||
row = layout.split(factor=0.4)
|
||||
row.separator()
|
||||
@@ -12,11 +13,12 @@ def aligned_label(layout: UILayout, *, alert=False, alignment='LEFT', **kwargs):
|
||||
row.alignment = alignment
|
||||
row.label(**kwargs)
|
||||
|
||||
|
||||
def label_split(layout: UILayout, *, alert=False, **kwargs) -> UILayout:
|
||||
"""Return an empty UILayout with a text label to its left in the single-column-layout style."""
|
||||
split = layout.split(factor=0.4, align=True)
|
||||
split.alert = alert
|
||||
row = split.row(align=True)
|
||||
row.alignment = 'RIGHT'
|
||||
row.alignment = "RIGHT"
|
||||
row.label(**kwargs)
|
||||
return split
|
||||
return split
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import bpy
|
||||
import os
|
||||
from bpy.types import Operator
|
||||
from bpy.props import StringProperty, BoolProperty
|
||||
import json
|
||||
@@ -68,7 +69,14 @@ class WM_OT_call_menu_pie_drag_only(Operator):
|
||||
|
||||
if op_cls.poll():
|
||||
try:
|
||||
return op_cls('INVOKE_DEFAULT', **fallback_op_kwargs)
|
||||
# 1. Execute the original operator and capture the result
|
||||
result = op_cls('INVOKE_DEFAULT', **fallback_op_kwargs)
|
||||
|
||||
# 2. [Added] Check if it's a Save operation and report the message manually
|
||||
if 'FINISHED' in result and self.fallback_operator == 'wm.save_mainfile':
|
||||
# Get current filename, or default to untitled
|
||||
filename = os.path.basename(bpy.data.filepath) if bpy.data.filepath else "untitled.blend"
|
||||
self.report({'INFO'}, f'Saved "{filename}"')
|
||||
except TypeError:
|
||||
# This can apparently happen sometimes, see issue #86.
|
||||
print(f"Pie Menu Fallback Operator failed: {self.fallback_operator}, {self.fallback_op_kwargs}")
|
||||
@@ -96,19 +104,30 @@ class WM_OT_call_menu_pie_drag_only(Operator):
|
||||
*,
|
||||
keymap_name: str,
|
||||
pie_name: str,
|
||||
hotkey_kwargs={'type': "SPACE", 'value': "PRESS"},
|
||||
hotkey_kwargs=None,
|
||||
default_fallback_op="",
|
||||
default_fallback_kwargs={},
|
||||
default_fallback_kwargs=None,
|
||||
on_drag=True,
|
||||
):
|
||||
if hotkey_kwargs is None:
|
||||
hotkey_kwargs = {'type': "SPACE", 'value': "PRESS"}
|
||||
context = bpy.context
|
||||
fallback_operator = default_fallback_op
|
||||
fallback_op_kwargs = default_fallback_kwargs
|
||||
user_kc = context.window_manager.keyconfigs.user
|
||||
km = user_kc.keymaps.get(keymap_name)
|
||||
fallback_op_kwargs = default_fallback_kwargs if default_fallback_kwargs is not None else {}
|
||||
|
||||
# IMPORTANT:
|
||||
# Do NOT derive fallback operator/kwargs from the USER keyconfig.
|
||||
# Other add-ons (eg. Pie Menu Editor) can legitimately alter user keymaps,
|
||||
# and baking that dynamic state into our add-on KeyMapItem properties can
|
||||
# make Blender fail to match/restore user overrides across restarts.
|
||||
#
|
||||
# Instead, derive fallback from the DEFAULT (active preset) keyconfig,
|
||||
# which is stable and represents expected built-in behavior.
|
||||
default_kc = context.window_manager.keyconfigs.default
|
||||
km = default_kc.keymaps.get(keymap_name)
|
||||
if km:
|
||||
for kmi in km.keymap_items:
|
||||
for i, condition in enumerate([
|
||||
for condition in [
|
||||
kmi.idname != 'wm.call_menu_pie_drag_only',
|
||||
kmi.type == hotkey_kwargs.get('type', ""),
|
||||
kmi.value == hotkey_kwargs.get('value', "PRESS"),
|
||||
@@ -119,7 +138,7 @@ class WM_OT_call_menu_pie_drag_only(Operator):
|
||||
kmi.any == hotkey_kwargs.get('any', False),
|
||||
kmi.key_modifier == hotkey_kwargs.get('key_modifier', 'NONE'),
|
||||
kmi.active
|
||||
]):
|
||||
]:
|
||||
if not condition:
|
||||
break
|
||||
else:
|
||||
@@ -133,14 +152,19 @@ class WM_OT_call_menu_pie_drag_only(Operator):
|
||||
op_kwargs={
|
||||
'name': pie_name,
|
||||
'fallback_operator': fallback_operator,
|
||||
'fallback_op_kwargs': json.dumps(fallback_op_kwargs),
|
||||
# Deterministic JSON (sort_keys=True) helps keep KMI identity stable across sessions.
|
||||
'fallback_op_kwargs': json.dumps(fallback_op_kwargs, sort_keys=True),
|
||||
'on_drag': on_drag,
|
||||
},
|
||||
hotkey_kwargs=hotkey_kwargs,
|
||||
keymap_name=keymap_name,
|
||||
)
|
||||
|
||||
def register():
|
||||
bpy.utils.register_class(WM_OT_call_menu_pie_drag_only)
|
||||
|
||||
registry = [
|
||||
WM_OT_call_menu_pie_drag_only,
|
||||
]
|
||||
def unregister():
|
||||
# HACK: As a workaround to https://projects.blender.org/blender/blender/issues/150229, we do not unregister
|
||||
# this operator when the add-on is uninstalled, which is pretty bad.
|
||||
# bpy.utils.unregister_class(WM_OT_call_menu_pie_drag_only)
|
||||
pass
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import bpy
|
||||
from .bs_utils.prefs import get_addon_prefs
|
||||
from .prefs import draw_prefs
|
||||
|
||||
class VIEW3D_PT_extra_pies(bpy.types.Panel):
|
||||
bl_label = "Extra Pies"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"id": "basedplayblast",
|
||||
"name": "BasedPlayblast",
|
||||
"tagline": "Easily create playblasts from Blender and Flamenco",
|
||||
"version": "2.6.0",
|
||||
"version": "2.6.1",
|
||||
"type": "add-on",
|
||||
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
|
||||
"license": [
|
||||
@@ -24,16 +24,16 @@
|
||||
"Workflow",
|
||||
"Video"
|
||||
],
|
||||
"archive_url": "https://github.com/RaincloudTheDragon/BasedPlayblast/releases/download/v2.6.0/BasedPlayblast.v2.6.0.zip",
|
||||
"archive_size": 47989,
|
||||
"archive_hash": "sha256:ba8307675a0ca0d24496c7151e84349608fee709cc088dc82acaacec56d1dc7f"
|
||||
"archive_url": "https://github.com/RaincloudTheDragon/BasedPlayblast/releases/download/v2.6.1/BasedPlayblast.v2.6.1.zip",
|
||||
"archive_size": 48471,
|
||||
"archive_hash": "sha256:ce9740ad252a00643f75202b53c9ef1e9c6ee8b5a2d34cbaf751b4084e78665c"
|
||||
},
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"id": "rainclouds_bulk_scene_tools",
|
||||
"name": "Raincloud's Bulk Scene Tools",
|
||||
"tagline": "Bulk utilities for optimizing scene data",
|
||||
"version": "0.12.0",
|
||||
"version": "0.14.0",
|
||||
"type": "add-on",
|
||||
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
|
||||
"license": [
|
||||
@@ -49,16 +49,16 @@
|
||||
"Workflow",
|
||||
"Materials"
|
||||
],
|
||||
"archive_url": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools/releases/download/v0.12.0/Rainys_Bulk_Scene_Tools.v0.12.0.zip",
|
||||
"archive_size": 75117,
|
||||
"archive_hash": "sha256:0607fafbd9f74f792fdb96e5913f03d9e4cc13cff8b5e3225468174959ca5b18"
|
||||
"archive_url": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools/releases/download/v0.14.0/Rainys_Bulk_Scene_Tools.v0.14.0.zip",
|
||||
"archive_size": 78363,
|
||||
"archive_hash": "sha256:943c723511fb8d7199bf079cb94ba63c552d6477b9a4e003bfffc185c169ea4b"
|
||||
},
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"id": "atomic_data_manager",
|
||||
"name": "Atomic Data Manager",
|
||||
"tagline": "Smart cleanup and inspection of Blender data-blocks",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"type": "add-on",
|
||||
"maintainer": "RaincloudTheDragon",
|
||||
"license": [
|
||||
@@ -70,9 +70,9 @@
|
||||
"management",
|
||||
"cleanup"
|
||||
],
|
||||
"archive_url": "https://github.com/RaincloudTheDragon/atomic-data-manager/releases/download/v2.0.0/Atomic_Data_Manager.v2.0.0.zip",
|
||||
"archive_size": 67447,
|
||||
"archive_hash": "sha256:5adf9ff89d1d24eaa79012b2a6c86f962fc107abc09b16a065e8327fbe57fb10"
|
||||
"archive_url": "https://github.com/RaincloudTheDragon/atomic-data-manager/releases/download/v2.1.0/Atomic_Data_Manager.v2.1.0.zip",
|
||||
"archive_size": 73646,
|
||||
"archive_hash": "sha256:a10f6b7eb9d7c437574c66dc15f73d74a0ff86e793c7460804d7bf5cb7cb29cc"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"version": "v1",
|
||||
"blocklist": [],
|
||||
"data": [
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"id": "basedplayblast",
|
||||
"name": "BasedPlayblast",
|
||||
"tagline": "Easily create playblasts from Blender and Flamenco",
|
||||
"version": "2.6.1",
|
||||
"type": "add-on",
|
||||
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
|
||||
"license": [
|
||||
"GPL-3.0-or-later"
|
||||
],
|
||||
"blender_version_min": "4.2.0",
|
||||
"website": "https://github.com/RaincloudTheDragon/BasedPlayblast",
|
||||
"permissions": {
|
||||
"files": "Import/export files and data"
|
||||
},
|
||||
"tags": [
|
||||
"Animation",
|
||||
"Render",
|
||||
"Workflow",
|
||||
"Video"
|
||||
],
|
||||
"archive_url": "https://github.com/RaincloudTheDragon/BasedPlayblast/releases/download/v2.6.1/BasedPlayblast.v2.6.1.zip",
|
||||
"archive_size": 48471,
|
||||
"archive_hash": "sha256:ce9740ad252a00643f75202b53c9ef1e9c6ee8b5a2d34cbaf751b4084e78665c"
|
||||
},
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"id": "rainclouds_bulk_scene_tools",
|
||||
"name": "Raincloud's Bulk Scene Tools",
|
||||
"tagline": "Bulk utilities for optimizing scene data",
|
||||
"version": "0.14.0",
|
||||
"type": "add-on",
|
||||
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
|
||||
"license": [
|
||||
"GPL-3.0-or-later"
|
||||
],
|
||||
"blender_version_min": "4.2.0",
|
||||
"website": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools",
|
||||
"permissions": {
|
||||
"files": "Read and write external resources referenced by scenes"
|
||||
},
|
||||
"tags": [
|
||||
"Scene",
|
||||
"Workflow",
|
||||
"Materials"
|
||||
],
|
||||
"archive_url": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools/releases/download/v0.14.0/Rainys_Bulk_Scene_Tools.v0.14.0.zip",
|
||||
"archive_size": 78363,
|
||||
"archive_hash": "sha256:943c723511fb8d7199bf079cb94ba63c552d6477b9a4e003bfffc185c169ea4b"
|
||||
},
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"id": "atomic_data_manager",
|
||||
"name": "Atomic Data Manager",
|
||||
"tagline": "Smart cleanup and inspection of Blender data-blocks",
|
||||
"version": "2.1.0",
|
||||
"type": "add-on",
|
||||
"maintainer": "RaincloudTheDragon",
|
||||
"license": [
|
||||
"GPL-3.0-or-later"
|
||||
],
|
||||
"blender_version_min": "4.2.0",
|
||||
"tags": [
|
||||
"utility",
|
||||
"management",
|
||||
"cleanup"
|
||||
],
|
||||
"archive_url": "https://github.com/RaincloudTheDragon/atomic-data-manager/releases/download/v2.1.0/Atomic_Data_Manager.v2.1.0.zip",
|
||||
"archive_size": 73646,
|
||||
"archive_hash": "sha256:a10f6b7eb9d7c437574c66dc15f73d74a0ff86e793c7460804d7bf5cb7cb29cc"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"version": "v1",
|
||||
"blocklist": [],
|
||||
"data": [
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"id": "basedplayblast",
|
||||
"name": "BasedPlayblast",
|
||||
"tagline": "Easily create playblasts from Blender and Flamenco",
|
||||
"version": "2.6.1",
|
||||
"type": "add-on",
|
||||
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
|
||||
"license": [
|
||||
"GPL-3.0-or-later"
|
||||
],
|
||||
"blender_version_min": "4.2.0",
|
||||
"website": "https://github.com/RaincloudTheDragon/BasedPlayblast",
|
||||
"permissions": {
|
||||
"files": "Import/export files and data"
|
||||
},
|
||||
"tags": [
|
||||
"Animation",
|
||||
"Render",
|
||||
"Workflow",
|
||||
"Video"
|
||||
],
|
||||
"archive_url": "https://github.com/RaincloudTheDragon/BasedPlayblast/releases/download/v2.6.1/BasedPlayblast.v2.6.1.zip",
|
||||
"archive_size": 48471,
|
||||
"archive_hash": "sha256:ce9740ad252a00643f75202b53c9ef1e9c6ee8b5a2d34cbaf751b4084e78665c"
|
||||
},
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"id": "rainclouds_bulk_scene_tools",
|
||||
"name": "Raincloud's Bulk Scene Tools",
|
||||
"tagline": "Bulk utilities for optimizing scene data",
|
||||
"version": "0.13.1",
|
||||
"type": "add-on",
|
||||
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
|
||||
"license": [
|
||||
"GPL-3.0-or-later"
|
||||
],
|
||||
"blender_version_min": "4.2.0",
|
||||
"website": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools",
|
||||
"permissions": {
|
||||
"files": "Read and write external resources referenced by scenes"
|
||||
},
|
||||
"tags": [
|
||||
"Scene",
|
||||
"Workflow",
|
||||
"Materials"
|
||||
],
|
||||
"archive_url": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools/releases/download/v.013.1/Rainys_Bulk_Scene_Tools.v0.13.1.zip",
|
||||
"archive_size": 77484,
|
||||
"archive_hash": "sha256:7c6e6f92e4c9f871d471e2eb31c22ff9b7614a24dbfa5d1f668f7909908b8307"
|
||||
},
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"id": "atomic_data_manager",
|
||||
"name": "Atomic Data Manager",
|
||||
"tagline": "Smart cleanup and inspection of Blender data-blocks",
|
||||
"version": "2.0.0",
|
||||
"type": "add-on",
|
||||
"maintainer": "RaincloudTheDragon",
|
||||
"license": [
|
||||
"GPL-3.0-or-later"
|
||||
],
|
||||
"blender_version_min": "4.2.0",
|
||||
"tags": [
|
||||
"utility",
|
||||
"management",
|
||||
"cleanup"
|
||||
],
|
||||
"archive_url": "https://github.com/RaincloudTheDragon/atomic-data-manager/releases/download/v2.0.0/Atomic_Data_Manager.v2.0.0.zip",
|
||||
"archive_size": 67447,
|
||||
"archive_hash": "sha256:5adf9ff89d1d24eaa79012b2a6c86f962fc107abc09b16a065e8327fbe57fb10"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"version": "v1",
|
||||
"blocklist": [],
|
||||
"data": [
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"id": "basedplayblast",
|
||||
"name": "BasedPlayblast",
|
||||
"tagline": "Easily create playblasts from Blender and Flamenco",
|
||||
"version": "2.6.1",
|
||||
"type": "add-on",
|
||||
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
|
||||
"license": [
|
||||
"GPL-3.0-or-later"
|
||||
],
|
||||
"blender_version_min": "4.2.0",
|
||||
"website": "https://github.com/RaincloudTheDragon/BasedPlayblast",
|
||||
"permissions": {
|
||||
"files": "Import/export files and data"
|
||||
},
|
||||
"tags": [
|
||||
"Animation",
|
||||
"Render",
|
||||
"Workflow",
|
||||
"Video"
|
||||
],
|
||||
"archive_url": "https://github.com/RaincloudTheDragon/BasedPlayblast/releases/download/v2.6.1/BasedPlayblast.v2.6.1.zip",
|
||||
"archive_size": 48471,
|
||||
"archive_hash": "sha256:ce9740ad252a00643f75202b53c9ef1e9c6ee8b5a2d34cbaf751b4084e78665c"
|
||||
},
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"id": "rainclouds_bulk_scene_tools",
|
||||
"name": "Raincloud's Bulk Scene Tools",
|
||||
"tagline": "Bulk utilities for optimizing scene data",
|
||||
"version": "0.12.0",
|
||||
"type": "add-on",
|
||||
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
|
||||
"license": [
|
||||
"GPL-3.0-or-later"
|
||||
],
|
||||
"blender_version_min": "4.2.0",
|
||||
"website": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools",
|
||||
"permissions": {
|
||||
"files": "Read and write external resources referenced by scenes"
|
||||
},
|
||||
"tags": [
|
||||
"Scene",
|
||||
"Workflow",
|
||||
"Materials"
|
||||
],
|
||||
"archive_url": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools/releases/download/v0.12.0/Rainys_Bulk_Scene_Tools.v0.12.0.zip",
|
||||
"archive_size": 75117,
|
||||
"archive_hash": "sha256:0607fafbd9f74f792fdb96e5913f03d9e4cc13cff8b5e3225468174959ca5b18"
|
||||
},
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"id": "atomic_data_manager",
|
||||
"name": "Atomic Data Manager",
|
||||
"tagline": "Smart cleanup and inspection of Blender data-blocks",
|
||||
"version": "2.0.0",
|
||||
"type": "add-on",
|
||||
"maintainer": "RaincloudTheDragon",
|
||||
"license": [
|
||||
"GPL-3.0-or-later"
|
||||
],
|
||||
"blender_version_min": "4.2.0",
|
||||
"tags": [
|
||||
"utility",
|
||||
"management",
|
||||
"cleanup"
|
||||
],
|
||||
"archive_url": "https://github.com/RaincloudTheDragon/atomic-data-manager/releases/download/v2.0.0/Atomic_Data_Manager.v2.0.0.zip",
|
||||
"archive_size": 67447,
|
||||
"archive_hash": "sha256:5adf9ff89d1d24eaa79012b2a6c86f962fc107abc09b16a065e8327fbe57fb10"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,41 @@
|
||||
# Changelog
|
||||
## [v2.1.0] - 2025-12-18
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
### Features
|
||||
- Added support for detecting unused objects and armatures (#1)
|
||||
- Objects not present in any scene collections are now detected as unused
|
||||
- Armatures not used by any objects in scenes (including direct use, modifiers, and constraints like "Child Of") are detected as unused
|
||||
- Smart Select and Clean operations now support objects and armatures
|
||||
|
||||
### Fixes
|
||||
- Fixed material detection in Geometry Nodes Set Material nodes
|
||||
- Materials used in Geometry Nodes' "Set Material" nodes are now correctly detected as used
|
||||
- Fixed legacy issue where materials in node groups (e.g., "outline-highlight" in "box-highlight" node group) were incorrectly flagged as unused
|
||||
- This was a hangover from Atomic never being developed past Blender 2.93, where Geometry Nodes Set Material nodes use input sockets rather than direct material properties
|
||||
- Performance optimizations for Smart Select and Clean operations (#3)
|
||||
- Removed inefficient threading implementation that was causing poor performance
|
||||
- Implemented short-circuiting logic in Smart Select to exit early when unused items are found
|
||||
- Fixed UI operators to use cached values instead of recalculating on every draw call
|
||||
- Note: Further performance improvements are limited by Blender's Python API being single-threaded and requiring sequential access to `bpy.data` collections, making true parallelization impossible without risking data corruption
|
||||
|
||||
### Internal
|
||||
- Removed incorrect "Remington Creative" copyright notices from newly created files
|
||||
- Updated repository configuration in manifest
|
||||
|
||||
## [v2.0.3] - 2025-12-17
|
||||
|
||||
### Fixes
|
||||
- Fixed missing import error in missing file detection
|
||||
|
||||
## [v2.0.2] - 2025-12-17
|
||||
|
||||
### Fixes
|
||||
- Atomic now completely ignores all library-linked and override datablocks across all operations, as originally intended.
|
||||
|
||||
## [v2.0.1] - 2025-12-16
|
||||
|
||||
### Fixes
|
||||
- Blender 5.0 compatibility: Fixed `AttributeError` when detecting missing library files (Library objects use `packed_file` singular, Image objects use `packed_files` plural in 5.0)
|
||||
- Fixed unregistration errors in Blender 4.5 by using safe unregister functions throughout the codebase
|
||||
|
||||
## [v2.0.0] - Raincloud's first re-release
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ registration for all packages within the add-on.
|
||||
|
||||
import bpy
|
||||
from bpy.utils import register_class
|
||||
from bpy.utils import unregister_class
|
||||
from .utils import compat
|
||||
from . import ops
|
||||
from . import ui
|
||||
from .ui import inspect_ui
|
||||
@@ -40,8 +40,10 @@ class ATOMIC_PG_main(bpy.types.PropertyGroup):
|
||||
lights: bpy.props.BoolProperty(default=False)
|
||||
materials: bpy.props.BoolProperty(default=False)
|
||||
node_groups: bpy.props.BoolProperty(default=False)
|
||||
objects: bpy.props.BoolProperty(default=False)
|
||||
particles: bpy.props.BoolProperty(default=False)
|
||||
textures: bpy.props.BoolProperty(default=False)
|
||||
armatures: bpy.props.BoolProperty(default=False)
|
||||
worlds: bpy.props.BoolProperty(default=False)
|
||||
|
||||
# inspect data-block search fields
|
||||
@@ -66,6 +68,12 @@ class ATOMIC_PG_main(bpy.types.PropertyGroup):
|
||||
textures_field: bpy.props.StringProperty(
|
||||
update=inspect_ui.update_inspection)
|
||||
|
||||
objects_field: bpy.props.StringProperty(
|
||||
update=inspect_ui.update_inspection)
|
||||
|
||||
armatures_field: bpy.props.StringProperty(
|
||||
update=inspect_ui.update_inspection)
|
||||
|
||||
worlds_field: bpy.props.StringProperty(
|
||||
update=inspect_ui.update_inspection)
|
||||
|
||||
@@ -111,6 +119,16 @@ class ATOMIC_PG_main(bpy.types.PropertyGroup):
|
||||
'WORLDS',
|
||||
'Worlds',
|
||||
'Worlds'
|
||||
),
|
||||
(
|
||||
'OBJECTS',
|
||||
'Objects',
|
||||
'Objects'
|
||||
),
|
||||
(
|
||||
'ARMATURES',
|
||||
'Armatures',
|
||||
'Armatures'
|
||||
)
|
||||
],
|
||||
default='COLLECTIONS'
|
||||
@@ -188,7 +206,14 @@ class ATOMIC_PG_main(bpy.types.PropertyGroup):
|
||||
'Worlds',
|
||||
'WORLD',
|
||||
9
|
||||
)
|
||||
),
|
||||
(
|
||||
'ARMATURES',
|
||||
'Armatures',
|
||||
'Armatures',
|
||||
'ARMATURE_DATA',
|
||||
10
|
||||
)
|
||||
],
|
||||
default='OVERVIEW'
|
||||
)
|
||||
@@ -220,5 +245,5 @@ def unregister():
|
||||
ui.unregister()
|
||||
ops.unregister()
|
||||
|
||||
unregister_class(ATOMIC_PG_main)
|
||||
compat.safe_unregister_class(ATOMIC_PG_main)
|
||||
del bpy.types.Scene.atomic
|
||||
|
||||
@@ -2,14 +2,14 @@ schema_version = "1.0.0"
|
||||
|
||||
id = "atomic_data_manager"
|
||||
name = "Atomic Data Manager"
|
||||
version = "2.0.0"
|
||||
version = "2.1.0"
|
||||
type = "add-on"
|
||||
author = "RaincloudTheDragon"
|
||||
maintainer = "RaincloudTheDragon"
|
||||
blender_version_min = "4.2.0"
|
||||
license = ["GPL-3.0-or-later"]
|
||||
description = "An Intelligent Data Manager for Blender."
|
||||
homepage = "https://github.com/grantwilk/atomic-data-manager"
|
||||
homepage = "https://github.com/RaincloudTheDragon/atomic-data-manager/"
|
||||
tagline = "Smart cleanup and inspection of Blender data-blocks"
|
||||
|
||||
tags = ["utility", "management", "cleanup"]
|
||||
|
||||
@@ -30,9 +30,10 @@ intefaces in Blender.
|
||||
|
||||
import bpy
|
||||
from bpy.utils import register_class
|
||||
from bpy.utils import unregister_class
|
||||
from ..utils import compat
|
||||
from .. import config
|
||||
from ..stats import unused
|
||||
from ..stats import unused_parallel
|
||||
from .utils import nuke
|
||||
from .utils import clean
|
||||
from ..ui.utils import ui_layouts
|
||||
@@ -157,8 +158,10 @@ class ATOMIC_OT_clean_all(bpy.types.Operator):
|
||||
unused_lights = []
|
||||
unused_materials = []
|
||||
unused_node_groups = []
|
||||
unused_objects = []
|
||||
unused_particles = []
|
||||
unused_textures = []
|
||||
unused_armatures = []
|
||||
unused_worlds = []
|
||||
|
||||
def draw(self, context):
|
||||
@@ -167,67 +170,74 @@ class ATOMIC_OT_clean_all(bpy.types.Operator):
|
||||
col = layout.column()
|
||||
col.label(text="Remove the following data-blocks?")
|
||||
|
||||
collections = sorted(unused.collections_deep())
|
||||
# Use cached values from invoke() instead of recalculating
|
||||
ui_layouts.box_list(
|
||||
layout=layout,
|
||||
title="Collections",
|
||||
items=collections,
|
||||
items=sorted(self.unused_collections),
|
||||
icon="OUTLINER_OB_GROUP_INSTANCE"
|
||||
)
|
||||
|
||||
images = sorted(unused.images_deep())
|
||||
ui_layouts.box_list(
|
||||
layout=layout,
|
||||
title="Images",
|
||||
items=images,
|
||||
items=sorted(self.unused_images),
|
||||
icon="IMAGE_DATA"
|
||||
)
|
||||
|
||||
lights = sorted(unused.lights_deep())
|
||||
ui_layouts.box_list(
|
||||
layout=layout,
|
||||
title="Lights",
|
||||
items=lights,
|
||||
items=sorted(self.unused_lights),
|
||||
icon="OUTLINER_OB_LIGHT"
|
||||
)
|
||||
|
||||
materials = sorted(unused.materials_deep())
|
||||
ui_layouts.box_list(
|
||||
layout=layout,
|
||||
title="Materials",
|
||||
items=materials,
|
||||
items=sorted(self.unused_materials),
|
||||
icon="MATERIAL"
|
||||
)
|
||||
|
||||
node_groups = sorted(unused.node_groups_deep())
|
||||
ui_layouts.box_list(
|
||||
layout=layout,
|
||||
title="Node Groups",
|
||||
items=node_groups,
|
||||
items=sorted(self.unused_node_groups),
|
||||
icon="NODETREE"
|
||||
)
|
||||
|
||||
particles = sorted(unused.particles_deep())
|
||||
ui_layouts.box_list(
|
||||
layout=layout,
|
||||
title="Objects",
|
||||
items=sorted(self.unused_objects),
|
||||
icon="OBJECT_DATA"
|
||||
)
|
||||
|
||||
ui_layouts.box_list(
|
||||
layout=layout,
|
||||
title="Particle Systems",
|
||||
items=particles,
|
||||
items=sorted(self.unused_particles),
|
||||
icon="PARTICLES"
|
||||
)
|
||||
|
||||
textures = sorted(unused.textures_deep())
|
||||
ui_layouts.box_list(
|
||||
layout=layout,
|
||||
title="Textures",
|
||||
items=textures,
|
||||
items=sorted(self.unused_textures),
|
||||
icon="TEXTURE"
|
||||
)
|
||||
|
||||
worlds = sorted(unused.worlds())
|
||||
ui_layouts.box_list(
|
||||
layout=layout,
|
||||
title="Armatures",
|
||||
items=sorted(self.unused_armatures),
|
||||
icon="ARMATURE_DATA"
|
||||
)
|
||||
|
||||
ui_layouts.box_list(
|
||||
layout=layout,
|
||||
title="Worlds",
|
||||
items=worlds,
|
||||
items=sorted(self.unused_worlds),
|
||||
icon="WORLD"
|
||||
)
|
||||
|
||||
@@ -240,8 +250,10 @@ class ATOMIC_OT_clean_all(bpy.types.Operator):
|
||||
clean.lights()
|
||||
clean.materials()
|
||||
clean.node_groups()
|
||||
clean.objects()
|
||||
clean.particles()
|
||||
clean.textures()
|
||||
clean.armatures()
|
||||
clean.worlds()
|
||||
|
||||
return {'FINISHED'}
|
||||
@@ -249,14 +261,19 @@ class ATOMIC_OT_clean_all(bpy.types.Operator):
|
||||
def invoke(self, context, event):
|
||||
wm = context.window_manager
|
||||
|
||||
self.unused_collections = unused.collections_deep()
|
||||
self.unused_images = unused.images_deep()
|
||||
self.unused_lights = unused.lights_deep()
|
||||
self.unused_materials = unused.materials_deep()
|
||||
self.unused_node_groups = unused.node_groups_deep()
|
||||
self.unused_particles = unused.particles_deep()
|
||||
self.unused_textures = unused.textures_deep()
|
||||
self.unused_worlds = unused.worlds()
|
||||
# Use parallel execution for better performance
|
||||
all_unused = unused_parallel.get_all_unused_parallel()
|
||||
|
||||
self.unused_collections = all_unused['collections']
|
||||
self.unused_images = all_unused['images']
|
||||
self.unused_lights = all_unused['lights']
|
||||
self.unused_materials = all_unused['materials']
|
||||
self.unused_node_groups = all_unused['node_groups']
|
||||
self.unused_objects = all_unused['objects']
|
||||
self.unused_particles = all_unused['particles']
|
||||
self.unused_textures = all_unused['textures']
|
||||
self.unused_armatures = all_unused['armatures']
|
||||
self.unused_worlds = all_unused['worlds']
|
||||
|
||||
return wm.invoke_props_dialog(self)
|
||||
|
||||
@@ -790,4 +807,4 @@ def register():
|
||||
|
||||
def unregister():
|
||||
for item in reg_list:
|
||||
unregister_class(item)
|
||||
compat.safe_unregister_class(item)
|
||||
|
||||
@@ -26,11 +26,18 @@ operators.
|
||||
|
||||
import bpy
|
||||
from bpy.utils import register_class
|
||||
from bpy.utils import unregister_class
|
||||
from ..utils import compat
|
||||
from .utils import delete
|
||||
from .utils import duplicate
|
||||
|
||||
|
||||
def _check_library_or_override(datablock):
|
||||
"""Check if datablock is library-linked or override, return error message if so."""
|
||||
if compat.is_library_or_override(datablock):
|
||||
return "Cannot modify library-linked or override datablocks"
|
||||
return None
|
||||
|
||||
|
||||
# Atomic Data Manager Inspection Rename Operator
|
||||
class ATOMIC_OT_inspection_rename(bpy.types.Operator):
|
||||
"""Give this data-block a new name"""
|
||||
@@ -51,35 +58,75 @@ class ATOMIC_OT_inspection_rename(bpy.types.Operator):
|
||||
name = atom.rename_field
|
||||
|
||||
if inspection == 'COLLECTIONS':
|
||||
bpy.data.collections[atom.collections_field].name = name
|
||||
collection = bpy.data.collections[atom.collections_field]
|
||||
error = _check_library_or_override(collection)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
collection.name = name
|
||||
atom.collections_field = name
|
||||
|
||||
if inspection == 'IMAGES':
|
||||
bpy.data.images[atom.images_field].name = name
|
||||
image = bpy.data.images[atom.images_field]
|
||||
error = _check_library_or_override(image)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
image.name = name
|
||||
atom.images_field = name
|
||||
|
||||
if inspection == 'LIGHTS':
|
||||
bpy.data.lights[atom.lights_field].name = name
|
||||
light = bpy.data.lights[atom.lights_field]
|
||||
error = _check_library_or_override(light)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
light.name = name
|
||||
atom.lights_field = name
|
||||
|
||||
if inspection == 'MATERIALS':
|
||||
bpy.data.materials[atom.materials_field].name = name
|
||||
material = bpy.data.materials[atom.materials_field]
|
||||
error = _check_library_or_override(material)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
material.name = name
|
||||
atom.materials_field = name
|
||||
|
||||
if inspection == 'NODE_GROUPS':
|
||||
bpy.data.node_groups[atom.node_groups_field].name = name
|
||||
node_group = bpy.data.node_groups[atom.node_groups_field]
|
||||
error = _check_library_or_override(node_group)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
node_group.name = name
|
||||
atom.node_groups_field = name
|
||||
|
||||
if inspection == 'PARTICLES':
|
||||
bpy.data.particles[atom.particles_field].name = name
|
||||
particle = bpy.data.particles[atom.particles_field]
|
||||
error = _check_library_or_override(particle)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
particle.name = name
|
||||
atom.particles_field = name
|
||||
|
||||
if inspection == 'TEXTURES':
|
||||
bpy.data.textures[atom.textures_field].name = name
|
||||
texture = bpy.data.textures[atom.textures_field]
|
||||
error = _check_library_or_override(texture)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
texture.name = name
|
||||
atom.textures_field = name
|
||||
|
||||
if inspection == 'WORLDS':
|
||||
bpy.data.worlds[atom.worlds_field].name = name
|
||||
world = bpy.data.worlds[atom.worlds_field]
|
||||
error = _check_library_or_override(world)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
world.name = name
|
||||
atom.worlds_field = name
|
||||
|
||||
atom.rename_field = ""
|
||||
@@ -172,44 +219,72 @@ class ATOMIC_OT_inspection_replace(bpy.types.Operator):
|
||||
|
||||
if inspection == 'IMAGES' and \
|
||||
atom.replace_field in bpy.data.images.keys():
|
||||
bpy.data.images[atom.images_field].user_remap(
|
||||
bpy.data.images[atom.replace_field])
|
||||
image = bpy.data.images[atom.images_field]
|
||||
error = _check_library_or_override(image)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
image.user_remap(bpy.data.images[atom.replace_field])
|
||||
atom.images_field = atom.replace_field
|
||||
|
||||
if inspection == 'LIGHTS' and \
|
||||
atom.replace_field in bpy.data.lights.keys():
|
||||
bpy.data.lights[atom.lights_field].user_remap(
|
||||
bpy.data.lights[atom.replace_field])
|
||||
light = bpy.data.lights[atom.lights_field]
|
||||
error = _check_library_or_override(light)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
light.user_remap(bpy.data.lights[atom.replace_field])
|
||||
atom.lights_field = atom.replace_field
|
||||
|
||||
if inspection == 'MATERIALS' and \
|
||||
atom.replace_field in bpy.data.materials.keys():
|
||||
bpy.data.materials[atom.materials_field].user_remap(
|
||||
bpy.data.materials[atom.replace_field])
|
||||
material = bpy.data.materials[atom.materials_field]
|
||||
error = _check_library_or_override(material)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
material.user_remap(bpy.data.materials[atom.replace_field])
|
||||
atom.materials_field = atom.replace_field
|
||||
|
||||
if inspection == 'NODE_GROUPS' and \
|
||||
atom.replace_field in bpy.data.node_groups.keys():
|
||||
bpy.data.node_groups[atom.node_groups_field].user_remap(
|
||||
bpy.data.node_groups[atom.replace_field])
|
||||
node_group = bpy.data.node_groups[atom.node_groups_field]
|
||||
error = _check_library_or_override(node_group)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
node_group.user_remap(bpy.data.node_groups[atom.replace_field])
|
||||
atom.node_groups_field = atom.replace_field
|
||||
|
||||
if inspection == 'PARTICLES' and \
|
||||
atom.replace_field in bpy.data.particles.keys():
|
||||
bpy.data.particles[atom.particles_field].user_remap(
|
||||
bpy.data.particles[atom.replace_field])
|
||||
particle = bpy.data.particles[atom.particles_field]
|
||||
error = _check_library_or_override(particle)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
particle.user_remap(bpy.data.particles[atom.replace_field])
|
||||
atom.particles_field = atom.replace_field
|
||||
|
||||
if inspection == 'TEXTURES' and \
|
||||
atom.replace_field in bpy.data.textures.keys():
|
||||
bpy.data.textures[atom.textures_field].user_remap(
|
||||
bpy.data.textures[atom.replace_field])
|
||||
texture = bpy.data.textures[atom.textures_field]
|
||||
error = _check_library_or_override(texture)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
texture.user_remap(bpy.data.textures[atom.replace_field])
|
||||
atom.textures_field = atom.replace_field
|
||||
|
||||
if inspection == 'WORLDS' and \
|
||||
atom.replace_field in bpy.data.worlds.keys():
|
||||
bpy.data.worlds[atom.worlds_field].user_remap(
|
||||
bpy.data.worlds[atom.replace_field])
|
||||
world = bpy.data.worlds[atom.worlds_field]
|
||||
error = _check_library_or_override(world)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
world.user_remap(bpy.data.worlds[atom.replace_field])
|
||||
atom.worlds_field = atom.replace_field
|
||||
|
||||
atom.replace_field = ""
|
||||
@@ -232,38 +307,59 @@ class ATOMIC_OT_inspection_toggle_fake_user(bpy.types.Operator):
|
||||
|
||||
if inspection == 'IMAGES':
|
||||
image = bpy.data.images[atom.images_field]
|
||||
bpy.data.images[atom.images_field].use_fake_user = \
|
||||
not image.use_fake_user
|
||||
error = _check_library_or_override(image)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
image.use_fake_user = not image.use_fake_user
|
||||
|
||||
if inspection == 'LIGHTS':
|
||||
light = bpy.data.lights[atom.lights_field]
|
||||
bpy.data.lights[atom.lights_field].use_fake_user = \
|
||||
not light.use_fake_user
|
||||
error = _check_library_or_override(light)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
light.use_fake_user = not light.use_fake_user
|
||||
|
||||
if inspection == 'MATERIALS':
|
||||
material = bpy.data.materials[atom.materials_field]
|
||||
bpy.data.materials[atom.materials_field].use_fake_user = \
|
||||
not material.use_fake_user
|
||||
error = _check_library_or_override(material)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
material.use_fake_user = not material.use_fake_user
|
||||
|
||||
if inspection == 'NODE_GROUPS':
|
||||
node_group = bpy.data.node_groups[atom.node_groups_field]
|
||||
bpy.data.node_groups[atom.node_groups_field].use_fake_user = \
|
||||
not node_group.use_fake_user
|
||||
error = _check_library_or_override(node_group)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
node_group.use_fake_user = not node_group.use_fake_user
|
||||
|
||||
if inspection == 'PARTICLES':
|
||||
particle = bpy.data.particles[atom.particles_field]
|
||||
bpy.data.particles[atom.particles_field].use_fake_user = \
|
||||
not particle.use_fake_user
|
||||
error = _check_library_or_override(particle)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
particle.use_fake_user = not particle.use_fake_user
|
||||
|
||||
if inspection == 'TEXTURES':
|
||||
texture = bpy.data.textures[atom.textures_field]
|
||||
bpy.data.textures[atom.textures_field].use_fake_user = \
|
||||
not texture.use_fake_user
|
||||
error = _check_library_or_override(texture)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
texture.use_fake_user = not texture.use_fake_user
|
||||
|
||||
if inspection == 'WORLDS':
|
||||
world = bpy.data.worlds[atom.worlds_field]
|
||||
bpy.data.worlds[atom.worlds_field].use_fake_user = \
|
||||
not world.use_fake_user
|
||||
error = _check_library_or_override(world)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
world.use_fake_user = not world.use_fake_user
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
@@ -283,6 +379,11 @@ class ATOMIC_OT_inspection_duplicate(bpy.types.Operator):
|
||||
collections = bpy.data.collections
|
||||
|
||||
if key in collections.keys():
|
||||
collection = collections[key]
|
||||
error = _check_library_or_override(collection)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
copy_key = duplicate.collection(key)
|
||||
atom.collections_field = copy_key
|
||||
|
||||
@@ -291,6 +392,11 @@ class ATOMIC_OT_inspection_duplicate(bpy.types.Operator):
|
||||
images = bpy.data.images
|
||||
|
||||
if key in images.keys():
|
||||
image = images[key]
|
||||
error = _check_library_or_override(image)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
copy_key = duplicate.image(key)
|
||||
atom.images_field = copy_key
|
||||
|
||||
@@ -299,6 +405,11 @@ class ATOMIC_OT_inspection_duplicate(bpy.types.Operator):
|
||||
lights = bpy.data.lights
|
||||
|
||||
if key in lights.keys():
|
||||
light = lights[key]
|
||||
error = _check_library_or_override(light)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
copy_key = duplicate.light(key)
|
||||
atom.lights_field = copy_key
|
||||
|
||||
@@ -307,6 +418,11 @@ class ATOMIC_OT_inspection_duplicate(bpy.types.Operator):
|
||||
materials = bpy.data.materials
|
||||
|
||||
if key in materials.keys():
|
||||
material = materials[key]
|
||||
error = _check_library_or_override(material)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
copy_key = duplicate.material(key)
|
||||
atom.materials_field = copy_key
|
||||
|
||||
@@ -315,6 +431,11 @@ class ATOMIC_OT_inspection_duplicate(bpy.types.Operator):
|
||||
node_groups = bpy.data.node_groups
|
||||
|
||||
if key in node_groups.keys():
|
||||
node_group = node_groups[key]
|
||||
error = _check_library_or_override(node_group)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
copy_key = duplicate.node_group(key)
|
||||
atom.node_groups_field = copy_key
|
||||
|
||||
@@ -323,6 +444,11 @@ class ATOMIC_OT_inspection_duplicate(bpy.types.Operator):
|
||||
particles = bpy.data.particles
|
||||
|
||||
if key in particles.keys():
|
||||
particle = particles[key]
|
||||
error = _check_library_or_override(particle)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
copy_key = duplicate.particle(key)
|
||||
atom.particles_field = copy_key
|
||||
|
||||
@@ -331,6 +457,11 @@ class ATOMIC_OT_inspection_duplicate(bpy.types.Operator):
|
||||
textures = bpy.data.textures
|
||||
|
||||
if key in textures.keys():
|
||||
texture = textures[key]
|
||||
error = _check_library_or_override(texture)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
copy_key = duplicate.texture(key)
|
||||
atom.textures_field = copy_key
|
||||
|
||||
@@ -339,6 +470,11 @@ class ATOMIC_OT_inspection_duplicate(bpy.types.Operator):
|
||||
worlds = bpy.data.worlds
|
||||
|
||||
if key in worlds.keys():
|
||||
world = worlds[key]
|
||||
error = _check_library_or_override(world)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
copy_key = duplicate.world(key)
|
||||
atom.worlds_field = copy_key
|
||||
|
||||
@@ -360,6 +496,11 @@ class ATOMIC_OT_inspection_delete(bpy.types.Operator):
|
||||
collections = bpy.data.collections
|
||||
|
||||
if key in collections.keys():
|
||||
collection = collections[key]
|
||||
error = _check_library_or_override(collection)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
delete.collection(key)
|
||||
atom.collections_field = ""
|
||||
|
||||
@@ -368,6 +509,11 @@ class ATOMIC_OT_inspection_delete(bpy.types.Operator):
|
||||
images = bpy.data.images
|
||||
|
||||
if key in images.keys():
|
||||
image = images[key]
|
||||
error = _check_library_or_override(image)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
delete.image(key)
|
||||
atom.images_field = ""
|
||||
|
||||
@@ -376,6 +522,11 @@ class ATOMIC_OT_inspection_delete(bpy.types.Operator):
|
||||
lights = bpy.data.lights
|
||||
|
||||
if key in lights.keys():
|
||||
light = lights[key]
|
||||
error = _check_library_or_override(light)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
delete.light(key)
|
||||
atom.lights_field = ""
|
||||
|
||||
@@ -384,6 +535,11 @@ class ATOMIC_OT_inspection_delete(bpy.types.Operator):
|
||||
materials = bpy.data.materials
|
||||
|
||||
if key in materials.keys():
|
||||
material = materials[key]
|
||||
error = _check_library_or_override(material)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
delete.material(key)
|
||||
atom.materials_field = ""
|
||||
|
||||
@@ -392,6 +548,11 @@ class ATOMIC_OT_inspection_delete(bpy.types.Operator):
|
||||
node_groups = bpy.data.node_groups
|
||||
|
||||
if key in node_groups.keys():
|
||||
node_group = node_groups[key]
|
||||
error = _check_library_or_override(node_group)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
delete.node_group(key)
|
||||
atom.node_groups_field = ""
|
||||
|
||||
@@ -399,6 +560,11 @@ class ATOMIC_OT_inspection_delete(bpy.types.Operator):
|
||||
key = atom.particles_field
|
||||
particles = bpy.data.particles
|
||||
if key in particles.keys():
|
||||
particle = particles[key]
|
||||
error = _check_library_or_override(particle)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
delete.particle(key)
|
||||
atom.particles_field = ""
|
||||
|
||||
@@ -407,6 +573,11 @@ class ATOMIC_OT_inspection_delete(bpy.types.Operator):
|
||||
textures = bpy.data.textures
|
||||
|
||||
if key in textures.keys():
|
||||
texture = textures[key]
|
||||
error = _check_library_or_override(texture)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
delete.texture(key)
|
||||
atom.textures_field = ""
|
||||
|
||||
@@ -415,6 +586,11 @@ class ATOMIC_OT_inspection_delete(bpy.types.Operator):
|
||||
worlds = bpy.data.worlds
|
||||
|
||||
if key in worlds.keys():
|
||||
world = worlds[key]
|
||||
error = _check_library_or_override(world)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
delete.world(key)
|
||||
atom.worlds_field = ""
|
||||
|
||||
@@ -437,4 +613,4 @@ def register():
|
||||
|
||||
def unregister():
|
||||
for item in reg_list:
|
||||
unregister_class(item)
|
||||
compat.safe_unregister_class(item)
|
||||
|
||||
@@ -26,8 +26,9 @@ various selection operations.
|
||||
|
||||
import bpy
|
||||
from bpy.utils import register_class
|
||||
from bpy.utils import unregister_class
|
||||
from ..utils import compat
|
||||
from ..stats import unused
|
||||
from ..stats import unused_parallel
|
||||
from .utils import clean
|
||||
from .utils import nuke
|
||||
from ..ui.utils import ui_layouts
|
||||
@@ -57,7 +58,9 @@ class ATOMIC_OT_nuke(bpy.types.Operator):
|
||||
|
||||
# display when the main panel collections property is toggled
|
||||
if atom.collections:
|
||||
collections = sorted(bpy.data.collections.keys())
|
||||
from ..utils import compat
|
||||
collections = sorted([c.name for c in bpy.data.collections
|
||||
if not compat.is_library_or_override(c)])
|
||||
ui_layouts.box_list(
|
||||
layout=layout,
|
||||
title="Collections",
|
||||
@@ -67,7 +70,9 @@ class ATOMIC_OT_nuke(bpy.types.Operator):
|
||||
|
||||
# display when the main panel images property is toggled
|
||||
if atom.images:
|
||||
images = sorted(bpy.data.images.keys())
|
||||
from ..utils import compat
|
||||
images = sorted([i.name for i in bpy.data.images
|
||||
if not compat.is_library_or_override(i)])
|
||||
ui_layouts.box_list(
|
||||
layout=layout,
|
||||
title="Images",
|
||||
@@ -77,7 +82,9 @@ class ATOMIC_OT_nuke(bpy.types.Operator):
|
||||
|
||||
# display when the main panel lights property is toggled
|
||||
if atom.lights:
|
||||
lights = sorted(bpy.data.lights.keys())
|
||||
from ..utils import compat
|
||||
lights = sorted([l.name for l in bpy.data.lights
|
||||
if not compat.is_library_or_override(l)])
|
||||
ui_layouts.box_list(
|
||||
layout=layout,
|
||||
title="Lights",
|
||||
@@ -87,7 +94,9 @@ class ATOMIC_OT_nuke(bpy.types.Operator):
|
||||
|
||||
# display when the main panel materials property is toggled
|
||||
if atom.materials:
|
||||
materials = sorted(bpy.data.materials.keys())
|
||||
from ..utils import compat
|
||||
materials = sorted([m.name for m in bpy.data.materials
|
||||
if not compat.is_library_or_override(m)])
|
||||
ui_layouts.box_list(
|
||||
layout=layout,
|
||||
title="Materials",
|
||||
@@ -97,7 +106,9 @@ class ATOMIC_OT_nuke(bpy.types.Operator):
|
||||
|
||||
# display when the main panel node groups property is toggled
|
||||
if atom.node_groups:
|
||||
node_groups = sorted(bpy.data.node_groups.keys())
|
||||
from ..utils import compat
|
||||
node_groups = sorted([ng.name for ng in bpy.data.node_groups
|
||||
if not compat.is_library_or_override(ng)])
|
||||
ui_layouts.box_list(
|
||||
layout=layout,
|
||||
title="Node Groups",
|
||||
@@ -107,7 +118,9 @@ class ATOMIC_OT_nuke(bpy.types.Operator):
|
||||
|
||||
# display when the main panel particle systems property is toggled
|
||||
if atom.particles:
|
||||
particles = sorted(bpy.data.particles.keys())
|
||||
from ..utils import compat
|
||||
particles = sorted([p.name for p in bpy.data.particles
|
||||
if not compat.is_library_or_override(p)])
|
||||
ui_layouts.box_list(
|
||||
layout=layout,
|
||||
title="Particle Systems",
|
||||
@@ -117,7 +130,9 @@ class ATOMIC_OT_nuke(bpy.types.Operator):
|
||||
|
||||
# display when the main panel textures property is toggled
|
||||
if atom.textures:
|
||||
textures = sorted(bpy.data.textures.keys())
|
||||
from ..utils import compat
|
||||
textures = sorted([t.name for t in bpy.data.textures
|
||||
if not compat.is_library_or_override(t)])
|
||||
ui_layouts.box_list(
|
||||
layout=layout,
|
||||
title="Textures",
|
||||
@@ -127,7 +142,9 @@ class ATOMIC_OT_nuke(bpy.types.Operator):
|
||||
|
||||
# display when the main panel worlds property is toggled
|
||||
if atom.worlds:
|
||||
worlds = sorted(bpy.data.worlds.keys())
|
||||
from ..utils import compat
|
||||
worlds = sorted([w.name for w in bpy.data.worlds
|
||||
if not compat.is_library_or_override(w)])
|
||||
ui_layouts.box_list(
|
||||
layout=layout,
|
||||
title="Worlds",
|
||||
@@ -184,8 +201,10 @@ class ATOMIC_OT_clean(bpy.types.Operator):
|
||||
unused_lights = []
|
||||
unused_materials = []
|
||||
unused_node_groups = []
|
||||
unused_objects = []
|
||||
unused_particles = []
|
||||
unused_textures = []
|
||||
unused_armatures = []
|
||||
unused_worlds = []
|
||||
|
||||
def draw(self, context):
|
||||
@@ -197,8 +216,9 @@ class ATOMIC_OT_clean(bpy.types.Operator):
|
||||
|
||||
# display if no main panel properties are toggled
|
||||
if not (atom.collections or atom.images or atom.lights or
|
||||
atom.materials or atom.node_groups or atom.particles
|
||||
or atom.textures or atom.worlds):
|
||||
atom.materials or atom.node_groups or atom.objects or
|
||||
atom.particles or atom.textures or atom.armatures or
|
||||
atom.worlds):
|
||||
|
||||
ui_layouts.box_list(
|
||||
layout=layout,
|
||||
@@ -249,6 +269,15 @@ class ATOMIC_OT_clean(bpy.types.Operator):
|
||||
icon="NODETREE"
|
||||
)
|
||||
|
||||
# display when the main panel objects property is toggled
|
||||
if atom.objects:
|
||||
ui_layouts.box_list(
|
||||
layout=layout,
|
||||
title="Objects",
|
||||
items=self.unused_objects,
|
||||
icon="OBJECT_DATA"
|
||||
)
|
||||
|
||||
# display when the main panel particle systems property is toggled
|
||||
if atom.particles:
|
||||
ui_layouts.box_list(
|
||||
@@ -260,14 +289,22 @@ class ATOMIC_OT_clean(bpy.types.Operator):
|
||||
|
||||
# display when the main panel textures property is toggled
|
||||
if atom.textures:
|
||||
textures = sorted(unused.textures_deep())
|
||||
ui_layouts.box_list(
|
||||
layout=layout,
|
||||
title="Textures",
|
||||
items=textures,
|
||||
items=self.unused_textures,
|
||||
icon="TEXTURE"
|
||||
)
|
||||
|
||||
# display when the main panel armatures property is toggled
|
||||
if atom.armatures:
|
||||
ui_layouts.box_list(
|
||||
layout=layout,
|
||||
title="Armatures",
|
||||
items=self.unused_armatures,
|
||||
icon="ARMATURE_DATA"
|
||||
)
|
||||
|
||||
# display when the main panel worlds property is toggled
|
||||
if atom.worlds:
|
||||
ui_layouts.box_list(
|
||||
@@ -297,12 +334,18 @@ class ATOMIC_OT_clean(bpy.types.Operator):
|
||||
if atom.node_groups:
|
||||
clean.node_groups()
|
||||
|
||||
if atom.objects:
|
||||
clean.objects()
|
||||
|
||||
if atom.particles:
|
||||
clean.particles()
|
||||
|
||||
if atom.textures:
|
||||
clean.textures()
|
||||
|
||||
if atom.armatures:
|
||||
clean.armatures()
|
||||
|
||||
if atom.worlds:
|
||||
clean.worlds()
|
||||
|
||||
@@ -314,29 +357,38 @@ class ATOMIC_OT_clean(bpy.types.Operator):
|
||||
wm = context.window_manager
|
||||
atom = bpy.context.scene.atomic
|
||||
|
||||
# Use parallel execution for better performance
|
||||
all_unused = unused_parallel.get_all_unused_parallel()
|
||||
|
||||
if atom.collections:
|
||||
self.unused_collections = unused.collections_deep()
|
||||
self.unused_collections = all_unused['collections']
|
||||
|
||||
if atom.images:
|
||||
self.unused_images = unused.images_deep()
|
||||
self.unused_images = all_unused['images']
|
||||
|
||||
if atom.lights:
|
||||
self.unused_lights = unused.lights_deep()
|
||||
self.unused_lights = all_unused['lights']
|
||||
|
||||
if atom.materials:
|
||||
self.unused_materials = unused.materials_deep()
|
||||
self.unused_materials = all_unused['materials']
|
||||
|
||||
if atom.node_groups:
|
||||
self.unused_node_groups = unused.node_groups_deep()
|
||||
self.unused_node_groups = all_unused['node_groups']
|
||||
|
||||
if atom.objects:
|
||||
self.unused_objects = all_unused['objects']
|
||||
|
||||
if atom.particles:
|
||||
self.unused_particles = unused.particles_deep()
|
||||
self.unused_particles = all_unused['particles']
|
||||
|
||||
if atom.textures:
|
||||
self.unused_textures = unused.textures_deep()
|
||||
self.unused_textures = all_unused['textures']
|
||||
|
||||
if atom.armatures:
|
||||
self.unused_armatures = all_unused['armatures']
|
||||
|
||||
if atom.worlds:
|
||||
self.unused_worlds = unused.worlds()
|
||||
self.unused_worlds = all_unused['worlds']
|
||||
|
||||
return wm.invoke_props_dialog(self)
|
||||
|
||||
@@ -359,30 +411,20 @@ class ATOMIC_OT_smart_select(bpy.types.Operator):
|
||||
bl_label = "Smart Select"
|
||||
|
||||
def execute(self, context):
|
||||
|
||||
bpy.context.scene.atomic.collections = \
|
||||
any(unused.collections_deep())
|
||||
|
||||
bpy.context.scene.atomic.images = \
|
||||
any(unused.images_deep())
|
||||
|
||||
bpy.context.scene.atomic.lights = \
|
||||
any(unused.lights_deep())
|
||||
|
||||
bpy.context.scene.atomic.materials = \
|
||||
any(unused.materials_deep())
|
||||
|
||||
bpy.context.scene.atomic.node_groups = \
|
||||
any(unused.node_groups_deep())
|
||||
|
||||
bpy.context.scene.atomic.particles = \
|
||||
any(unused.particles_deep())
|
||||
|
||||
bpy.context.scene.atomic.textures = \
|
||||
any(unused.textures_deep())
|
||||
|
||||
bpy.context.scene.atomic.worlds = \
|
||||
any(unused.worlds())
|
||||
# Use parallel execution for better performance
|
||||
unused_flags = unused_parallel.get_unused_for_smart_select()
|
||||
|
||||
atom = bpy.context.scene.atomic
|
||||
atom.collections = unused_flags['collections']
|
||||
atom.images = unused_flags['images']
|
||||
atom.lights = unused_flags['lights']
|
||||
atom.materials = unused_flags['materials']
|
||||
atom.node_groups = unused_flags['node_groups']
|
||||
atom.objects = unused_flags['objects']
|
||||
atom.particles = unused_flags['particles']
|
||||
atom.textures = unused_flags['textures']
|
||||
atom.armatures = unused_flags['armatures']
|
||||
atom.worlds = unused_flags['worlds']
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
@@ -441,4 +483,4 @@ def register():
|
||||
|
||||
def unregister():
|
||||
for item in reg_list:
|
||||
unregister_class(item)
|
||||
compat.safe_unregister_class(item)
|
||||
|
||||
@@ -31,7 +31,7 @@ attempting to reload missing project files.
|
||||
|
||||
import bpy
|
||||
from bpy.utils import register_class
|
||||
from bpy.utils import unregister_class
|
||||
from ..utils import compat
|
||||
from ..stats import missing
|
||||
from ..ui.utils import ui_layouts
|
||||
|
||||
@@ -192,4 +192,4 @@ def register():
|
||||
|
||||
def unregister():
|
||||
for item in reg_list:
|
||||
unregister_class(item)
|
||||
compat.safe_unregister_class(item)
|
||||
|
||||
@@ -28,7 +28,7 @@ support page in the web browser.
|
||||
import bpy
|
||||
import webbrowser
|
||||
from bpy.utils import register_class
|
||||
from bpy.utils import unregister_class
|
||||
from ..utils import compat
|
||||
|
||||
|
||||
# Atomic Data Manager Open Support Me Operator
|
||||
@@ -52,4 +52,4 @@ def register():
|
||||
|
||||
def unregister():
|
||||
for cls in reg_list:
|
||||
unregister_class(cls)
|
||||
compat.safe_unregister_class(cls)
|
||||
|
||||
@@ -72,3 +72,15 @@ def worlds():
|
||||
# removes all unused worlds from the project
|
||||
for world_key in unused.worlds():
|
||||
bpy.data.worlds.remove(bpy.data.worlds[world_key])
|
||||
|
||||
|
||||
def objects():
|
||||
# removes all unused objects from the project
|
||||
for object_key in unused.objects_deep():
|
||||
bpy.data.objects.remove(bpy.data.objects[object_key])
|
||||
|
||||
|
||||
def armatures():
|
||||
# removes all unused armatures from the project
|
||||
for armature_key in unused.armatures_deep():
|
||||
bpy.data.armatures.remove(bpy.data.armatures[armature_key])
|
||||
|
||||
@@ -24,11 +24,18 @@ data categories.
|
||||
"""
|
||||
|
||||
import bpy
|
||||
from ...utils import compat
|
||||
|
||||
|
||||
def nuke_data(data):
|
||||
# removes all data-blocks from the indicated set of data
|
||||
# Skip library-linked and override datablocks
|
||||
keys_to_remove = []
|
||||
for key in data.keys():
|
||||
datablock = data[key]
|
||||
if not compat.is_library_or_override(datablock):
|
||||
keys_to_remove.append(key)
|
||||
for key in keys_to_remove:
|
||||
data.remove(data[key])
|
||||
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ This file contains functions that count quantities of various sets of data.
|
||||
"""
|
||||
|
||||
import bpy
|
||||
from ..utils import compat
|
||||
from . import unused
|
||||
from . import unnamed
|
||||
from . import missing
|
||||
@@ -30,8 +31,11 @@ from . import missing
|
||||
|
||||
def collections():
|
||||
# returns the number of collections in the project
|
||||
|
||||
return len(bpy.data.collections)
|
||||
count = 0
|
||||
for collection in bpy.data.collections:
|
||||
if not compat.is_library_or_override(collection):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def collections_unused():
|
||||
@@ -48,8 +52,11 @@ def collections_unnamed():
|
||||
|
||||
def images():
|
||||
# returns the number of images in the project
|
||||
|
||||
return len(bpy.data.images)
|
||||
count = 0
|
||||
for image in bpy.data.images:
|
||||
if not compat.is_library_or_override(image):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def images_unused():
|
||||
@@ -72,8 +79,11 @@ def images_missing():
|
||||
|
||||
def lights():
|
||||
# returns the number of lights in the project
|
||||
|
||||
return len(bpy.data.lights)
|
||||
count = 0
|
||||
for light in bpy.data.lights:
|
||||
if not compat.is_library_or_override(light):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def lights_unused():
|
||||
@@ -90,8 +100,11 @@ def lights_unnamed():
|
||||
|
||||
def materials():
|
||||
# returns the number of materials in the project
|
||||
|
||||
return len(bpy.data.materials)
|
||||
count = 0
|
||||
for material in bpy.data.materials:
|
||||
if not compat.is_library_or_override(material):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def materials_unused():
|
||||
@@ -108,8 +121,11 @@ def materials_unnamed():
|
||||
|
||||
def node_groups():
|
||||
# returns the number of node groups in the project
|
||||
|
||||
return len(bpy.data.node_groups)
|
||||
count = 0
|
||||
for node_group in bpy.data.node_groups:
|
||||
if not compat.is_library_or_override(node_group):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def node_groups_unused():
|
||||
@@ -126,8 +142,11 @@ def node_groups_unnamed():
|
||||
|
||||
def objects():
|
||||
# returns the number of objects in the project
|
||||
|
||||
return len(bpy.data.objects)
|
||||
count = 0
|
||||
for obj in bpy.data.objects:
|
||||
if not compat.is_library_or_override(obj):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def objects_unnamed():
|
||||
@@ -138,8 +157,11 @@ def objects_unnamed():
|
||||
|
||||
def particles():
|
||||
# returns the number of particles in the project
|
||||
|
||||
return len(bpy.data.particles)
|
||||
count = 0
|
||||
for particle in bpy.data.particles:
|
||||
if not compat.is_library_or_override(particle):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def particles_unused():
|
||||
@@ -156,8 +178,11 @@ def particles_unnamed():
|
||||
|
||||
def textures():
|
||||
# returns the number of textures in the project
|
||||
|
||||
return len(bpy.data.textures)
|
||||
count = 0
|
||||
for texture in bpy.data.textures:
|
||||
if not compat.is_library_or_override(texture):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def textures_unused():
|
||||
@@ -174,8 +199,11 @@ def textures_unnamed():
|
||||
|
||||
def worlds():
|
||||
# returns the number of worlds in the project
|
||||
|
||||
return len(bpy.data.worlds)
|
||||
count = 0
|
||||
for world in bpy.data.worlds:
|
||||
if not compat.is_library_or_override(world):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def worlds_unused():
|
||||
|
||||
@@ -25,6 +25,7 @@ project.
|
||||
|
||||
import bpy
|
||||
import os
|
||||
from ..utils import version, compat
|
||||
|
||||
|
||||
def get_missing(data):
|
||||
@@ -37,12 +38,29 @@ def get_missing(data):
|
||||
do_not_flag = ["Render Result", "Viewer Node", "D-NOISE Export"]
|
||||
|
||||
for datablock in data:
|
||||
# Skip library-linked and override datablocks
|
||||
if compat.is_library_or_override(datablock):
|
||||
continue
|
||||
|
||||
# the absolute path to our data-block
|
||||
abspath = bpy.path.abspath(datablock.filepath)
|
||||
|
||||
# Check if data-block is packed
|
||||
# Blender 5.0+: Image objects use 'packed_files' (plural), Library objects use 'packed_file' (singular)
|
||||
# Blender 4.2/4.5: Both Image and Library objects use 'packed_file' (singular)
|
||||
is_packed = False
|
||||
if version.is_version_at_least(5, 0, 0):
|
||||
# Blender 5.0+: Check type-specific attributes
|
||||
if isinstance(datablock, bpy.types.Image):
|
||||
is_packed = bool(datablock.packed_files) if hasattr(datablock, 'packed_files') else False
|
||||
elif isinstance(datablock, bpy.types.Library):
|
||||
is_packed = bool(datablock.packed_file) if hasattr(datablock, 'packed_file') else False
|
||||
else:
|
||||
# Blender 4.2/4.5: Both use 'packed_file' (singular)
|
||||
is_packed = bool(datablock.packed_file) if hasattr(datablock, 'packed_file') else False
|
||||
|
||||
# if data-block is not packed and has an invalid filepath
|
||||
if not datablock.packed_files and not os.path.isfile(abspath):
|
||||
if not is_packed and not os.path.isfile(abspath):
|
||||
|
||||
# if data-block is not in our do not flag list
|
||||
# append it to the missing data list
|
||||
@@ -50,7 +68,7 @@ def get_missing(data):
|
||||
missing.append(datablock.name)
|
||||
|
||||
# if data-block is packed but it does not have a filepath
|
||||
elif datablock.packed_files and not abspath:
|
||||
elif is_packed and not abspath:
|
||||
|
||||
# if data-block is not in our do not flag list
|
||||
# append it to the missing data list
|
||||
|
||||
@@ -25,6 +25,7 @@ Blender project.
|
||||
|
||||
import bpy
|
||||
import re
|
||||
from ..utils import compat
|
||||
|
||||
|
||||
def collections():
|
||||
@@ -32,6 +33,9 @@ def collections():
|
||||
unnamed = []
|
||||
|
||||
for collection in bpy.data.collections:
|
||||
# Skip library-linked and override datablocks
|
||||
if compat.is_library_or_override(collection):
|
||||
continue
|
||||
if re.match(r'.*\.\d\d\d$', collection.name) or \
|
||||
collection.name.startswith("Collection"):
|
||||
unnamed.append(collection.name)
|
||||
@@ -44,6 +48,9 @@ def images():
|
||||
unnamed = []
|
||||
|
||||
for image in bpy.data.images:
|
||||
# Skip library-linked and override datablocks
|
||||
if compat.is_library_or_override(image):
|
||||
continue
|
||||
if re.match(r'.*\.\d\d\d$', image.name) or \
|
||||
image.name.startswith("Untitled"):
|
||||
unnamed.append(image.name)
|
||||
@@ -56,6 +63,9 @@ def lights():
|
||||
unnamed = []
|
||||
|
||||
for light in bpy.data.lights:
|
||||
# Skip library-linked and override datablocks
|
||||
if compat.is_library_or_override(light):
|
||||
continue
|
||||
if re.match(r'.*\.\d\d\d$', light.name) or \
|
||||
light.name.startswith("Light"):
|
||||
unnamed.append(light.name)
|
||||
@@ -67,7 +77,10 @@ def materials():
|
||||
# returns the keys of all unnamed materials in the project
|
||||
unnamed = []
|
||||
|
||||
for material in bpy.data.lights:
|
||||
for material in bpy.data.materials:
|
||||
# Skip library-linked and override datablocks
|
||||
if compat.is_library_or_override(material):
|
||||
continue
|
||||
if re.match(r'.*\.\d\d\d$', material.name) or \
|
||||
material.name.startswith("Material"):
|
||||
unnamed.append(material.name)
|
||||
@@ -152,6 +165,9 @@ def objects():
|
||||
unnamed = []
|
||||
|
||||
for obj in bpy.data.objects:
|
||||
# Skip library-linked and override datablocks
|
||||
if compat.is_library_or_override(obj):
|
||||
continue
|
||||
if re.match(r'.*\.\d\d\d$', obj.name) or \
|
||||
obj.name.startswith(default_obj_names):
|
||||
unnamed.append(obj.name)
|
||||
@@ -164,6 +180,9 @@ def node_groups():
|
||||
unnamed = []
|
||||
|
||||
for node_group in bpy.data.node_groups:
|
||||
# Skip library-linked and override datablocks
|
||||
if compat.is_library_or_override(node_group):
|
||||
continue
|
||||
if re.match(r'.*\.\d\d\d$', node_group.name) or \
|
||||
node_group.name.startswith("NodeGroup"):
|
||||
unnamed.append(node_group.name)
|
||||
@@ -176,6 +195,9 @@ def particles():
|
||||
unnamed = []
|
||||
|
||||
for particle in bpy.data.particles:
|
||||
# Skip library-linked and override datablocks
|
||||
if compat.is_library_or_override(particle):
|
||||
continue
|
||||
if re.match(r'.*\.\d\d\d$', particle.name) or \
|
||||
particle.name.startswith("ParticleSettings"):
|
||||
unnamed.append(particle.name)
|
||||
@@ -188,6 +210,9 @@ def textures():
|
||||
unnamed = []
|
||||
|
||||
for texture in bpy.data.textures:
|
||||
# Skip library-linked and override datablocks
|
||||
if compat.is_library_or_override(texture):
|
||||
continue
|
||||
if re.match(r'.*\.\d\d\d$', texture.name) or \
|
||||
texture.name.startswith("Texture"):
|
||||
unnamed.append(texture.name)
|
||||
@@ -200,6 +225,9 @@ def worlds():
|
||||
unnamed = []
|
||||
|
||||
for world in bpy.data.worlds:
|
||||
# Skip library-linked and override datablocks
|
||||
if compat.is_library_or_override(world):
|
||||
continue
|
||||
if re.match(r'.*\.\d\d\d$', world.name) or \
|
||||
world.name.startswith("World"):
|
||||
unnamed.append(world.name)
|
||||
|
||||
@@ -25,6 +25,7 @@ as determined by stats.users.py
|
||||
|
||||
import bpy
|
||||
from .. import config
|
||||
from ..utils import compat
|
||||
from . import users
|
||||
|
||||
|
||||
@@ -35,6 +36,9 @@ def shallow(data):
|
||||
unused = []
|
||||
|
||||
for datablock in data:
|
||||
# Skip library-linked and override datablocks
|
||||
if compat.is_library_or_override(datablock):
|
||||
continue
|
||||
|
||||
# if data-block has no users or if it has a fake user and
|
||||
# ignore fake users is enabled
|
||||
@@ -52,6 +56,9 @@ def collections_deep():
|
||||
unused = []
|
||||
|
||||
for collection in bpy.data.collections:
|
||||
# Skip library-linked and override datablocks
|
||||
if compat.is_library_or_override(collection):
|
||||
continue
|
||||
if not users.collection_all(collection.name):
|
||||
unused.append(collection.name)
|
||||
|
||||
@@ -65,6 +72,9 @@ def collections_shallow():
|
||||
unused = []
|
||||
|
||||
for collection in bpy.data.collections:
|
||||
# Skip library-linked and override datablocks
|
||||
if compat.is_library_or_override(collection):
|
||||
continue
|
||||
if not (collection.objects or collection.children):
|
||||
unused.append(collection.name)
|
||||
|
||||
@@ -81,6 +91,9 @@ def images_deep():
|
||||
do_not_flag = ["Render Result", "Viewer Node", "D-NOISE Export"]
|
||||
|
||||
for image in bpy.data.images:
|
||||
# Skip library-linked and override datablocks
|
||||
if compat.is_library_or_override(image):
|
||||
continue
|
||||
if not users.image_all(image.name):
|
||||
|
||||
# check if image has a fake user or if ignore fake users
|
||||
@@ -118,6 +131,9 @@ def lights_deep():
|
||||
unused = []
|
||||
|
||||
for light in bpy.data.lights:
|
||||
# Skip library-linked and override datablocks
|
||||
if compat.is_library_or_override(light):
|
||||
continue
|
||||
if not users.light_all(light.name):
|
||||
|
||||
# check if light has a fake user or if ignore fake users
|
||||
@@ -141,6 +157,9 @@ def materials_deep():
|
||||
unused = []
|
||||
|
||||
for material in bpy.data.materials:
|
||||
# Skip library-linked and override datablocks
|
||||
if compat.is_library_or_override(material):
|
||||
continue
|
||||
if not users.material_all(material.name):
|
||||
|
||||
# check if material has a fake user or if ignore fake users
|
||||
@@ -164,6 +183,9 @@ def node_groups_deep():
|
||||
unused = []
|
||||
|
||||
for node_group in bpy.data.node_groups:
|
||||
# Skip library-linked and override datablocks
|
||||
if compat.is_library_or_override(node_group):
|
||||
continue
|
||||
if not users.node_group_all(node_group.name):
|
||||
|
||||
# check if node group has a fake user or if ignore fake users
|
||||
@@ -190,6 +212,9 @@ def particles_deep():
|
||||
unused = []
|
||||
|
||||
for particle in bpy.data.particles:
|
||||
# Skip library-linked and override datablocks
|
||||
if compat.is_library_or_override(particle):
|
||||
continue
|
||||
if not users.particle_all(particle.name):
|
||||
|
||||
# check if particle system has a fake user or if ignore fake
|
||||
@@ -216,6 +241,9 @@ def textures_deep():
|
||||
unused = []
|
||||
|
||||
for texture in bpy.data.textures:
|
||||
# Skip library-linked and override datablocks
|
||||
if compat.is_library_or_override(texture):
|
||||
continue
|
||||
if not users.texture_all(texture.name):
|
||||
|
||||
# check if texture has a fake user or if ignore fake users
|
||||
@@ -239,6 +267,9 @@ def worlds():
|
||||
unused = []
|
||||
|
||||
for world in bpy.data.worlds:
|
||||
# Skip library-linked and override datablocks
|
||||
if compat.is_library_or_override(world):
|
||||
continue
|
||||
|
||||
# if data-block has no users or if it has a fake user and
|
||||
# ignore fake users is enabled
|
||||
@@ -248,3 +279,55 @@ def worlds():
|
||||
unused.append(world.name)
|
||||
|
||||
return unused
|
||||
|
||||
|
||||
def objects_deep():
|
||||
# returns a list of keys of unused objects
|
||||
|
||||
unused = []
|
||||
|
||||
for obj in bpy.data.objects:
|
||||
# Skip library-linked and override datablocks
|
||||
if compat.is_library_or_override(obj):
|
||||
continue
|
||||
if not users.object_all(obj.name):
|
||||
|
||||
# check if object has a fake user or if ignore fake users
|
||||
# is enabled
|
||||
if not obj.use_fake_user or config.include_fake_users:
|
||||
unused.append(obj.name)
|
||||
|
||||
return unused
|
||||
|
||||
|
||||
def objects_shallow():
|
||||
# returns a list of keys of unused objects that may be
|
||||
# incomplete, but is significantly faster than doing a deep search
|
||||
|
||||
return shallow(bpy.data.objects)
|
||||
|
||||
|
||||
def armatures_deep():
|
||||
# returns a list of keys of unused armatures
|
||||
|
||||
unused = []
|
||||
|
||||
for armature in bpy.data.armatures:
|
||||
# Skip library-linked and override datablocks
|
||||
if compat.is_library_or_override(armature):
|
||||
continue
|
||||
if not users.armature_all(armature.name):
|
||||
|
||||
# check if armature has a fake user or if ignore fake users
|
||||
# is enabled
|
||||
if not armature.use_fake_user or config.include_fake_users:
|
||||
unused.append(armature.name)
|
||||
|
||||
return unused
|
||||
|
||||
|
||||
def armatures_shallow():
|
||||
# returns a list of keys of unused armatures that may be
|
||||
# incomplete, but is significantly faster than doing a deep search
|
||||
|
||||
return shallow(bpy.data.armatures)
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import bpy
|
||||
from ..stats import unused
|
||||
from ..stats import users
|
||||
from .. import config
|
||||
from ..utils import compat
|
||||
|
||||
|
||||
def get_all_unused_parallel():
|
||||
"""
|
||||
Get all unused data-blocks efficiently in a single batch.
|
||||
|
||||
Returns a dictionary with keys:
|
||||
- collections: list of unused collection names
|
||||
- images: list of unused image names
|
||||
- lights: list of unused light names
|
||||
- materials: list of unused material names
|
||||
- node_groups: list of unused node group names
|
||||
- objects: list of unused object names
|
||||
- particles: list of unused particle names
|
||||
- textures: list of unused texture names
|
||||
- armatures: list of unused armature names
|
||||
- worlds: list of unused world names
|
||||
"""
|
||||
# Execute all checks sequentially but in a clean batch
|
||||
# This avoids threading overhead while keeping code organized
|
||||
return {
|
||||
'collections': unused.collections_deep(),
|
||||
'images': unused.images_deep(),
|
||||
'lights': unused.lights_deep(),
|
||||
'materials': unused.materials_deep(),
|
||||
'node_groups': unused.node_groups_deep(),
|
||||
'objects': unused.objects_deep(),
|
||||
'particles': unused.particles_deep(),
|
||||
'textures': unused.textures_deep(),
|
||||
'armatures': unused.armatures_deep(),
|
||||
'worlds': unused.worlds(),
|
||||
}
|
||||
|
||||
|
||||
def _has_any_unused_collections():
|
||||
"""Check if there are any unused collections (short-circuits early)."""
|
||||
for collection in bpy.data.collections:
|
||||
if compat.is_library_or_override(collection):
|
||||
continue
|
||||
if not users.collection_all(collection.name):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _has_any_unused_images():
|
||||
"""Check if there are any unused images (short-circuits early)."""
|
||||
do_not_flag = ["Render Result", "Viewer Node", "D-NOISE Export"]
|
||||
|
||||
for image in bpy.data.images:
|
||||
if compat.is_library_or_override(image):
|
||||
continue
|
||||
if not users.image_all(image.name):
|
||||
if not image.use_fake_user or config.include_fake_users:
|
||||
if image.name not in do_not_flag:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _has_any_unused_lights():
|
||||
"""Check if there are any unused lights (short-circuits early)."""
|
||||
for light in bpy.data.lights:
|
||||
if compat.is_library_or_override(light):
|
||||
continue
|
||||
if not users.light_all(light.name):
|
||||
if not light.use_fake_user or config.include_fake_users:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _has_any_unused_materials():
|
||||
"""Check if there are any unused materials (short-circuits early)."""
|
||||
for material in bpy.data.materials:
|
||||
if compat.is_library_or_override(material):
|
||||
continue
|
||||
if not users.material_all(material.name):
|
||||
if not material.use_fake_user or config.include_fake_users:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _has_any_unused_node_groups():
|
||||
"""Check if there are any unused node groups (short-circuits early)."""
|
||||
for node_group in bpy.data.node_groups:
|
||||
if compat.is_library_or_override(node_group):
|
||||
continue
|
||||
if not users.node_group_all(node_group.name):
|
||||
if not node_group.use_fake_user or config.include_fake_users:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _has_any_unused_particles():
|
||||
"""Check if there are any unused particles (short-circuits early)."""
|
||||
if not hasattr(bpy.data, 'particles'):
|
||||
return False
|
||||
|
||||
for particle in bpy.data.particles:
|
||||
if compat.is_library_or_override(particle):
|
||||
continue
|
||||
if not users.particle_all(particle.name):
|
||||
if not particle.use_fake_user or config.include_fake_users:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _has_any_unused_textures():
|
||||
"""Check if there are any unused textures (short-circuits early)."""
|
||||
if not hasattr(bpy.data, 'textures'):
|
||||
return False
|
||||
|
||||
for texture in bpy.data.textures:
|
||||
if compat.is_library_or_override(texture):
|
||||
continue
|
||||
if not users.texture_all(texture.name):
|
||||
if not texture.use_fake_user or config.include_fake_users:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _has_any_unused_worlds():
|
||||
"""Check if there are any unused worlds (short-circuits early)."""
|
||||
for world in bpy.data.worlds:
|
||||
if compat.is_library_or_override(world):
|
||||
continue
|
||||
if world.users == 0 or (world.users == 1 and
|
||||
world.use_fake_user and
|
||||
config.include_fake_users):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _has_any_unused_objects():
|
||||
"""Check if there are any unused objects (short-circuits early)."""
|
||||
for obj in bpy.data.objects:
|
||||
if compat.is_library_or_override(obj):
|
||||
continue
|
||||
if not users.object_all(obj.name):
|
||||
if not obj.use_fake_user or config.include_fake_users:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _has_any_unused_armatures():
|
||||
"""Check if there are any unused armatures (short-circuits early)."""
|
||||
for armature in bpy.data.armatures:
|
||||
if compat.is_library_or_override(armature):
|
||||
continue
|
||||
if not users.armature_all(armature.name):
|
||||
if not armature.use_fake_user or config.include_fake_users:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_unused_for_smart_select():
|
||||
"""
|
||||
Get unused data for smart select operation (returns booleans).
|
||||
Optimized to short-circuit early - stops checking each category
|
||||
as soon as unused data is found. This is much faster than computing
|
||||
the full list of unused items.
|
||||
|
||||
Returns a dictionary with boolean values indicating if each category
|
||||
has unused data-blocks.
|
||||
"""
|
||||
# Use optimized short-circuit versions that stop as soon as
|
||||
# they find ONE unused item, rather than computing the full list
|
||||
return {
|
||||
'collections': _has_any_unused_collections(),
|
||||
'images': _has_any_unused_images(),
|
||||
'lights': _has_any_unused_lights(),
|
||||
'materials': _has_any_unused_materials(),
|
||||
'node_groups': _has_any_unused_node_groups(),
|
||||
'objects': _has_any_unused_objects(),
|
||||
'particles': _has_any_unused_particles(),
|
||||
'textures': _has_any_unused_textures(),
|
||||
'armatures': _has_any_unused_armatures(),
|
||||
'worlds': _has_any_unused_worlds(),
|
||||
}
|
||||
|
||||
|
||||
@@ -426,12 +426,24 @@ def light_objects(light_key):
|
||||
|
||||
def material_all(material_key):
|
||||
# returns a list of keys of every data-block that uses this material
|
||||
return material_objects(material_key) + \
|
||||
material_geometry_nodes(material_key)
|
||||
# Use comprehensive custom detection that covers all usage contexts
|
||||
users = []
|
||||
|
||||
# Check direct object usage (material slots)
|
||||
users.extend(material_objects(material_key))
|
||||
|
||||
# Check Geometry Nodes usage (materials in node groups used by objects)
|
||||
users.extend(material_geometry_nodes(material_key))
|
||||
|
||||
# Check node group usage (materials in node groups used elsewhere)
|
||||
users.extend(material_node_groups(material_key))
|
||||
|
||||
return distinct(users)
|
||||
|
||||
|
||||
def material_geometry_nodes(material_key):
|
||||
# returns a list of object keys that use the material via Geometry Nodes
|
||||
# Only counts objects that are in scene collections (recursive check)
|
||||
|
||||
users = []
|
||||
material = bpy.data.materials[material_key]
|
||||
@@ -440,17 +452,104 @@ def material_geometry_nodes(material_key):
|
||||
from ..utils import compat
|
||||
|
||||
for obj in bpy.data.objects:
|
||||
# Skip library-linked and override objects
|
||||
if compat.is_library_or_override(obj):
|
||||
continue
|
||||
|
||||
# Check if object is in any scene collection (reuse object_all logic)
|
||||
# This ensures recursive checking: if the object using the material isn't in a scene,
|
||||
# the material isn't considered used
|
||||
obj_scenes = object_all(obj.name)
|
||||
is_in_scene = bool(obj_scenes)
|
||||
|
||||
if not is_in_scene:
|
||||
continue # Skip objects not in scene collections
|
||||
|
||||
if hasattr(obj, 'modifiers'):
|
||||
for modifier in obj.modifiers:
|
||||
if compat.is_geometry_nodes_modifier(modifier):
|
||||
ng = compat.get_geometry_nodes_modifier_node_group(modifier)
|
||||
if ng:
|
||||
# Check if this node group or any nested node groups contain the material
|
||||
if node_group_has_material(ng.name, material.name):
|
||||
users.append(obj.name)
|
||||
|
||||
return distinct(users)
|
||||
|
||||
|
||||
def material_node_groups(material_key):
|
||||
# returns a list of keys indicating where the material is used via node groups
|
||||
# This checks if the material is used in any node group, and if that node group
|
||||
# is itself used anywhere. This complements material_geometry_nodes() by checking
|
||||
# additional usage contexts (materials, other node groups, compositor, etc.)
|
||||
# Note: Geometry Nodes usage is already checked by material_geometry_nodes()
|
||||
# Optimized to return early when usage is found
|
||||
|
||||
from ..utils import compat
|
||||
material = bpy.data.materials[material_key]
|
||||
|
||||
# Check all node groups to see if they contain this material
|
||||
for node_group in bpy.data.node_groups:
|
||||
# Skip library-linked and override node groups
|
||||
if compat.is_library_or_override(node_group):
|
||||
continue
|
||||
if node_group_has_material(node_group.name, material.name):
|
||||
# This node group contains the material, check if the node group is used
|
||||
# Check usage contexts in order of likelihood, return early when found
|
||||
|
||||
# First check: is it used in Geometry Nodes modifiers? (most common case)
|
||||
# Note: material_geometry_nodes() already checks this, but we verify here too
|
||||
obj_users = node_group_objects(node_group.name)
|
||||
if obj_users:
|
||||
return obj_users # Return immediately - material is used
|
||||
|
||||
# Second check: is it used in materials?
|
||||
mat_users = node_group_materials(node_group.name)
|
||||
if mat_users:
|
||||
return mat_users # Return immediately - material is used
|
||||
|
||||
# Third check: is it used in compositor?
|
||||
comp_users = node_group_compositors(node_group.name)
|
||||
if comp_users:
|
||||
return comp_users # Return immediately - material is used
|
||||
|
||||
# Fourth check: is it used in textures?
|
||||
tex_users = node_group_textures(node_group.name)
|
||||
if tex_users:
|
||||
return tex_users # Return immediately - material is used
|
||||
|
||||
# Fifth check: is it used in worlds?
|
||||
world_users = node_group_worlds(node_group.name)
|
||||
if world_users:
|
||||
return world_users # Return immediately - material is used
|
||||
|
||||
# Last check: is it used in other node groups? (recursive, but only if needed)
|
||||
ng_users = node_group_node_groups(node_group.name)
|
||||
if ng_users:
|
||||
# Check if any parent node groups are used (quick check only)
|
||||
for parent_ng_name in ng_users:
|
||||
# Quick check: see if parent is used in objects (most common)
|
||||
parent_obj_users = node_group_objects(parent_ng_name)
|
||||
if parent_obj_users:
|
||||
return parent_obj_users
|
||||
# Quick check: see if parent is used in materials
|
||||
parent_mat_users = node_group_materials(parent_ng_name)
|
||||
if parent_mat_users:
|
||||
return parent_mat_users
|
||||
# Also check if parent is used in compositor, textures, worlds
|
||||
parent_comp_users = node_group_compositors(parent_ng_name)
|
||||
if parent_comp_users:
|
||||
return parent_comp_users
|
||||
parent_tex_users = node_group_textures(parent_ng_name)
|
||||
if parent_tex_users:
|
||||
return parent_tex_users
|
||||
parent_world_users = node_group_worlds(parent_ng_name)
|
||||
if parent_world_users:
|
||||
return parent_world_users
|
||||
|
||||
return [] # Material not used in any node groups
|
||||
|
||||
|
||||
def material_objects(material_key):
|
||||
# returns a list of object keys that use this material
|
||||
|
||||
@@ -760,22 +859,122 @@ def node_group_has_material(node_group_key, material_key):
|
||||
# returns true if a node group contains this material (directly or nested)
|
||||
|
||||
has_material = False
|
||||
node_group = bpy.data.node_groups[node_group_key]
|
||||
material = bpy.data.materials[material_key]
|
||||
try:
|
||||
node_group = bpy.data.node_groups[node_group_key]
|
||||
material = bpy.data.materials[material_key]
|
||||
except (KeyError, AttributeError):
|
||||
return False
|
||||
|
||||
for node in node_group.nodes:
|
||||
# base case: nodes with a material property (e.g., Set Material)
|
||||
if hasattr(node, 'material') and node.material:
|
||||
if node.material.name == material.name:
|
||||
has_material = True
|
||||
try:
|
||||
for node in node_group.nodes:
|
||||
try:
|
||||
# Explicitly check for GeometryNodeSetMaterial nodes first
|
||||
# This is the most reliable way to detect Set Material nodes in Geometry Nodes
|
||||
if hasattr(node, 'bl_idname'):
|
||||
try:
|
||||
if node.bl_idname == 'GeometryNodeSetMaterial':
|
||||
# Geometry Nodes Set Material nodes use input sockets, not a direct material property
|
||||
# Check the material input socket
|
||||
try:
|
||||
# Try to access the Material input socket directly by name
|
||||
if hasattr(node, 'inputs') and 'Material' in node.inputs:
|
||||
try:
|
||||
material_socket = node.inputs['Material']
|
||||
# Check the default_value (for unlinked materials)
|
||||
if hasattr(material_socket, 'default_value'):
|
||||
socket_material = material_socket.default_value
|
||||
if socket_material and hasattr(socket_material, 'name'):
|
||||
if (socket_material.name == material.name or
|
||||
socket_material == material):
|
||||
has_material = True
|
||||
except (KeyError, AttributeError, ReferenceError, RuntimeError, TypeError):
|
||||
pass
|
||||
|
||||
# Also check all inputs as fallback (in case socket name differs)
|
||||
if not has_material:
|
||||
for input_socket in getattr(node, 'inputs', []):
|
||||
try:
|
||||
# Check socket type - material sockets are typically 'MATERIAL' type
|
||||
socket_type = getattr(input_socket, 'type', '')
|
||||
if socket_type == 'MATERIAL' or 'material' in str(input_socket).lower():
|
||||
# Check if this socket has a default_value that is a material
|
||||
if hasattr(input_socket, 'default_value') and input_socket.default_value:
|
||||
socket_material = input_socket.default_value
|
||||
if socket_material and hasattr(socket_material, 'name'):
|
||||
if (socket_material.name == material.name or
|
||||
socket_material == material):
|
||||
has_material = True
|
||||
break # Found it, no need to continue
|
||||
except (AttributeError, ReferenceError, RuntimeError, TypeError):
|
||||
continue # Skip this socket if we can't access it
|
||||
|
||||
# Also check if the node has a direct material property (fallback for some versions)
|
||||
if not has_material and hasattr(node, 'material'):
|
||||
try:
|
||||
if node.material:
|
||||
if (node.material.name == material.name or
|
||||
node.material == material):
|
||||
has_material = True
|
||||
break # Found it, no need to continue
|
||||
except (AttributeError, ReferenceError, RuntimeError):
|
||||
pass # Skip if material access fails
|
||||
|
||||
if has_material:
|
||||
break # Break outer loop if we found it
|
||||
except (AttributeError, ReferenceError, RuntimeError):
|
||||
pass # Skip if Set Material node input access fails
|
||||
except (AttributeError, ReferenceError, RuntimeError):
|
||||
pass # Skip if bl_idname access fails
|
||||
|
||||
# Fallback: Check for any node with a material property (e.g., Set Material)
|
||||
# This catches other node types that might have materials
|
||||
if not has_material and hasattr(node, 'material'):
|
||||
try:
|
||||
if node.material:
|
||||
# Check both by name and by direct reference for robustness
|
||||
if (node.material.name == material.name or
|
||||
node.material == material):
|
||||
has_material = True
|
||||
break # Found it, no need to continue
|
||||
except (AttributeError, ReferenceError, RuntimeError):
|
||||
pass # Skip if material access fails
|
||||
|
||||
# Also check node type by substring for Set Material nodes (backup check)
|
||||
if not has_material and hasattr(node, 'bl_idname'):
|
||||
try:
|
||||
node_type = node.bl_idname
|
||||
# Check for Geometry Nodes Set Material node type (substring match)
|
||||
if 'SetMaterial' in node_type or 'SET_MATERIAL' in node_type.upper():
|
||||
if hasattr(node, 'material'):
|
||||
try:
|
||||
if node.material:
|
||||
if (node.material.name == material.name or
|
||||
node.material == material):
|
||||
has_material = True
|
||||
break
|
||||
except (AttributeError, ReferenceError, RuntimeError):
|
||||
pass # Skip if material access fails
|
||||
except (AttributeError, ReferenceError, RuntimeError):
|
||||
pass # Skip if bl_idname access fails
|
||||
|
||||
# recurse case: nested node groups
|
||||
elif hasattr(node, 'node_tree') and node.node_tree:
|
||||
has_material = node_group_has_material(
|
||||
node.node_tree.name, material.name)
|
||||
# recurse case: nested node groups
|
||||
# Check this separately (not elif) in case we need to recurse
|
||||
if not has_material and hasattr(node, 'node_tree'):
|
||||
try:
|
||||
if node.node_tree:
|
||||
has_material = node_group_has_material(
|
||||
node.node_tree.name, material.name)
|
||||
except (KeyError, AttributeError, ReferenceError, RuntimeError):
|
||||
continue # Skip invalid node groups
|
||||
|
||||
if has_material:
|
||||
break
|
||||
if has_material:
|
||||
break
|
||||
except (AttributeError, ReferenceError, RuntimeError):
|
||||
# Skip nodes that cause errors (e.g., invalid/corrupted nodes)
|
||||
continue
|
||||
except (AttributeError, ReferenceError, RuntimeError):
|
||||
# If we can't even iterate nodes, return False
|
||||
return False
|
||||
|
||||
return has_material
|
||||
|
||||
@@ -969,6 +1168,75 @@ def texture_particles(texture_key):
|
||||
return distinct(users)
|
||||
|
||||
|
||||
def object_all(object_key):
|
||||
# returns a list of scene names where the object is used
|
||||
# An object is "used" if it's in any collection that's part of any scene's collection hierarchy
|
||||
|
||||
users = []
|
||||
obj = bpy.data.objects[object_key]
|
||||
|
||||
# Get all collections that contain this object
|
||||
for collection in obj.users_collection:
|
||||
# Check if this collection is in any scene's hierarchy
|
||||
for scene in bpy.data.scenes:
|
||||
if _scene_collection_contains(scene.collection, collection):
|
||||
if scene.name not in users:
|
||||
users.append(scene.name)
|
||||
|
||||
return distinct(users)
|
||||
|
||||
|
||||
def armature_all(armature_key):
|
||||
# returns a list of object names that use the armature
|
||||
# Checks direct usage, modifier usage, and constraint usage
|
||||
# Only counts objects that are actually in scene collections (recursive check)
|
||||
|
||||
users = []
|
||||
armature = bpy.data.armatures[armature_key]
|
||||
|
||||
# Check all objects - but only count those that are in scene collections
|
||||
for obj in bpy.data.objects:
|
||||
# Skip library-linked and override objects
|
||||
from ..utils import compat
|
||||
if compat.is_library_or_override(obj):
|
||||
continue
|
||||
|
||||
# Check if object is in any scene collection (reuse object_all logic)
|
||||
obj_scenes = object_all(obj.name)
|
||||
is_in_scene = bool(obj_scenes)
|
||||
|
||||
# Check for usage regardless of scene status (we'll filter later)
|
||||
found_usage = False
|
||||
|
||||
# 1. Direct usage: ARMATURE objects where object.data == armature
|
||||
if obj.type == 'ARMATURE' and obj.data == armature:
|
||||
found_usage = True
|
||||
|
||||
# 2. Modifier usage: Armature modifiers where modifier.object.data == armature
|
||||
if not found_usage and hasattr(obj, 'modifiers'):
|
||||
for modifier in obj.modifiers:
|
||||
if modifier.type == 'ARMATURE':
|
||||
if hasattr(modifier, 'object') and modifier.object:
|
||||
if modifier.object.type == 'ARMATURE' and modifier.object.data == armature:
|
||||
found_usage = True
|
||||
break
|
||||
|
||||
# 3. Constraint usage: Constraints that target ARMATURE objects using this armature
|
||||
if not found_usage and hasattr(obj, 'constraints'):
|
||||
for constraint in obj.constraints:
|
||||
if hasattr(constraint, 'target') and constraint.target:
|
||||
if constraint.target.type == 'ARMATURE' and constraint.target.data == armature:
|
||||
found_usage = True
|
||||
break
|
||||
|
||||
# Only add to users if the object is actually in a scene
|
||||
# This implements recursive checking: if the user object is unused, it doesn't count
|
||||
if found_usage and is_in_scene:
|
||||
users.append(obj.name)
|
||||
|
||||
return distinct(users)
|
||||
|
||||
|
||||
def distinct(seq):
|
||||
# returns a list of distinct elements
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ This file contains the inspection user interface.
|
||||
|
||||
import bpy
|
||||
from bpy.utils import register_class
|
||||
from bpy.utils import unregister_class
|
||||
from ..utils import compat
|
||||
from ..stats import users
|
||||
from .utils import ui_layouts
|
||||
|
||||
@@ -700,14 +700,138 @@ class ATOMIC_OT_inspect_worlds(bpy.types.Operator):
|
||||
return wm.invoke_props_dialog(self)
|
||||
|
||||
|
||||
# Atomic Data Manager Inspect Objects UI Operator
|
||||
class ATOMIC_OT_inspect_objects(bpy.types.Operator):
|
||||
"""Inspect Objects"""
|
||||
bl_idname = "atomic.inspect_objects"
|
||||
bl_label = "Inspect Objects"
|
||||
|
||||
# user lists
|
||||
users_scenes = []
|
||||
|
||||
def draw(self, context):
|
||||
global inspection_update_trigger
|
||||
atom = bpy.context.scene.atomic
|
||||
|
||||
layout = self.layout
|
||||
|
||||
# inspect objects header
|
||||
ui_layouts.inspect_header(
|
||||
layout=layout,
|
||||
atom_prop="objects_field",
|
||||
data="objects"
|
||||
)
|
||||
|
||||
# inspection update code
|
||||
if inspection_update_trigger:
|
||||
# if key is valid, update the user lists
|
||||
if atom.objects_field in bpy.data.objects.keys():
|
||||
self.users_scenes = users.object_all(atom.objects_field)
|
||||
|
||||
# if key is invalid, empty the user lists
|
||||
else:
|
||||
self.users_scenes = []
|
||||
|
||||
inspection_update_trigger = False
|
||||
|
||||
# scenes box list
|
||||
ui_layouts.box_list(
|
||||
layout=layout,
|
||||
title="Scenes",
|
||||
items=self.users_scenes,
|
||||
icon="SCENE_DATA"
|
||||
)
|
||||
|
||||
row = layout.row() # extra row for spacing
|
||||
|
||||
def execute(self, context):
|
||||
return {'FINISHED'}
|
||||
|
||||
def invoke(self, context, event):
|
||||
# update inspection context
|
||||
atom = bpy.context.scene.atomic
|
||||
atom.active_inspection = "OBJECTS"
|
||||
|
||||
# trigger update on invoke
|
||||
global inspection_update_trigger
|
||||
inspection_update_trigger = True
|
||||
|
||||
# invoke inspect dialog
|
||||
wm = context.window_manager
|
||||
return wm.invoke_props_dialog(self)
|
||||
|
||||
|
||||
# Atomic Data Manager Inspect Armatures UI Operator
|
||||
class ATOMIC_OT_inspect_armatures(bpy.types.Operator):
|
||||
"""Inspect Armatures"""
|
||||
bl_idname = "atomic.inspect_armatures"
|
||||
bl_label = "Inspect Armatures"
|
||||
|
||||
# user lists
|
||||
users_objects = []
|
||||
|
||||
def draw(self, context):
|
||||
global inspection_update_trigger
|
||||
atom = bpy.context.scene.atomic
|
||||
|
||||
layout = self.layout
|
||||
|
||||
# inspect armatures header
|
||||
ui_layouts.inspect_header(
|
||||
layout=layout,
|
||||
atom_prop="armatures_field",
|
||||
data="armatures"
|
||||
)
|
||||
|
||||
# inspection update code
|
||||
if inspection_update_trigger:
|
||||
# if key is valid, update the user lists
|
||||
if atom.armatures_field in bpy.data.armatures.keys():
|
||||
self.users_objects = users.armature_all(atom.armatures_field)
|
||||
|
||||
# if key is invalid, empty the user lists
|
||||
else:
|
||||
self.users_objects = []
|
||||
|
||||
inspection_update_trigger = False
|
||||
|
||||
# objects box list
|
||||
ui_layouts.box_list(
|
||||
layout=layout,
|
||||
title="Objects",
|
||||
items=self.users_objects,
|
||||
icon="OBJECT_DATA"
|
||||
)
|
||||
|
||||
row = layout.row() # extra row for spacing
|
||||
|
||||
def execute(self, context):
|
||||
return {'FINISHED'}
|
||||
|
||||
def invoke(self, context, event):
|
||||
# update inspection context
|
||||
atom = bpy.context.scene.atomic
|
||||
atom.active_inspection = "ARMATURES"
|
||||
|
||||
# trigger update on invoke
|
||||
global inspection_update_trigger
|
||||
inspection_update_trigger = True
|
||||
|
||||
# invoke inspect dialog
|
||||
wm = context.window_manager
|
||||
return wm.invoke_props_dialog(self)
|
||||
|
||||
|
||||
reg_list = [
|
||||
ATOMIC_OT_inspect_collections,
|
||||
ATOMIC_OT_inspect_images,
|
||||
ATOMIC_OT_inspect_lights,
|
||||
ATOMIC_OT_inspect_materials,
|
||||
ATOMIC_OT_inspect_node_groups,
|
||||
ATOMIC_OT_inspect_objects,
|
||||
ATOMIC_OT_inspect_particles,
|
||||
ATOMIC_OT_inspect_textures,
|
||||
ATOMIC_OT_inspect_armatures,
|
||||
ATOMIC_OT_inspect_worlds
|
||||
]
|
||||
|
||||
@@ -719,4 +843,4 @@ def register():
|
||||
|
||||
def unregister():
|
||||
for cls in reg_list:
|
||||
unregister_class(cls)
|
||||
compat.safe_unregister_class(cls)
|
||||
|
||||
@@ -28,7 +28,7 @@ category toggles and the category selection tools.
|
||||
|
||||
import bpy
|
||||
from bpy.utils import register_class
|
||||
from bpy.utils import unregister_class
|
||||
from ..utils import compat
|
||||
from ..stats import count
|
||||
from .utils import ui_layouts
|
||||
|
||||
@@ -50,8 +50,10 @@ class ATOMIC_PT_main_panel(bpy.types.Panel):
|
||||
atom.lights,
|
||||
atom.materials,
|
||||
atom.node_groups,
|
||||
atom.objects,
|
||||
atom.particles,
|
||||
atom.textures,
|
||||
atom.armatures,
|
||||
atom.worlds
|
||||
]
|
||||
|
||||
@@ -87,6 +89,23 @@ class ATOMIC_PT_main_panel(bpy.types.Panel):
|
||||
text=""
|
||||
)
|
||||
|
||||
# objects buttons
|
||||
splitcol = col.split(factor=0.8, align=True)
|
||||
|
||||
splitcol.prop(
|
||||
atom,
|
||||
"objects",
|
||||
text="Objects",
|
||||
icon='OBJECT_DATA',
|
||||
toggle=True
|
||||
)
|
||||
|
||||
splitcol.operator(
|
||||
"atomic.inspect_objects",
|
||||
icon='VIEWZOOM',
|
||||
text=""
|
||||
)
|
||||
|
||||
# lights buttons
|
||||
splitcol = col.split(factor=0.8, align=True)
|
||||
|
||||
@@ -175,6 +194,23 @@ class ATOMIC_PT_main_panel(bpy.types.Panel):
|
||||
text=""
|
||||
)
|
||||
|
||||
# armatures buttons
|
||||
splitcol = col.split(factor=0.8, align=True)
|
||||
|
||||
splitcol.prop(
|
||||
atom,
|
||||
"armatures",
|
||||
text="Armatures",
|
||||
icon='ARMATURE_DATA',
|
||||
toggle=True
|
||||
)
|
||||
|
||||
splitcol.operator(
|
||||
"atomic.inspect_armatures",
|
||||
icon='VIEWZOOM',
|
||||
text=""
|
||||
)
|
||||
|
||||
# particles buttons
|
||||
splitcol = col.split(factor=0.8, align=True)
|
||||
|
||||
@@ -242,4 +278,4 @@ def register():
|
||||
|
||||
def unregister():
|
||||
for cls in reg_list:
|
||||
unregister_class(cls)
|
||||
compat.safe_unregister_class(cls)
|
||||
|
||||
@@ -25,7 +25,7 @@ pops up when missing files are detected on file load.
|
||||
|
||||
import bpy
|
||||
from bpy.utils import register_class
|
||||
from bpy.utils import unregister_class
|
||||
from ..utils import compat
|
||||
from bpy.app.handlers import persistent
|
||||
from .. import config
|
||||
from ..stats import missing
|
||||
@@ -189,7 +189,7 @@ def register():
|
||||
|
||||
def unregister():
|
||||
for item in reg_list:
|
||||
unregister_class(item)
|
||||
compat.safe_unregister_class(item)
|
||||
|
||||
# stop running missing file auto-detection after loading a Blender file
|
||||
bpy.app.handlers.load_post.remove(autodetect_missing_files)
|
||||
|
||||
@@ -25,7 +25,7 @@ registration.
|
||||
|
||||
import bpy
|
||||
from bpy.utils import register_class
|
||||
from bpy.utils import unregister_class
|
||||
from ..utils import compat
|
||||
|
||||
|
||||
# Atomic Data Manager Main Pie Menu
|
||||
@@ -197,4 +197,4 @@ def register():
|
||||
|
||||
def unregister():
|
||||
for cls in reg_list:
|
||||
unregister_class(cls)
|
||||
compat.safe_unregister_class(cls)
|
||||
|
||||
@@ -25,7 +25,7 @@ some functions for syncing the preference properties with external factors.
|
||||
|
||||
import bpy
|
||||
from bpy.utils import register_class
|
||||
from bpy.utils import unregister_class
|
||||
from ..utils import compat
|
||||
from .. import config
|
||||
# updater removed in Blender 4.5 extension format
|
||||
|
||||
@@ -328,6 +328,6 @@ def register():
|
||||
|
||||
def unregister():
|
||||
for cls in reg_list:
|
||||
unregister_class(cls)
|
||||
compat.safe_unregister_class(cls)
|
||||
|
||||
remove_pie_menu_hotkeys()
|
||||
|
||||
@@ -28,7 +28,7 @@ it.
|
||||
|
||||
import bpy
|
||||
from bpy.utils import register_class
|
||||
from bpy.utils import unregister_class
|
||||
from ..utils import compat
|
||||
from ..stats import count
|
||||
from ..stats import misc
|
||||
from .utils import ui_layouts
|
||||
@@ -373,4 +373,4 @@ def register():
|
||||
|
||||
def unregister():
|
||||
for cls in reg_list:
|
||||
unregister_class(cls)
|
||||
compat.safe_unregister_class(cls)
|
||||
|
||||
@@ -26,7 +26,7 @@ support Remington Creative popup.
|
||||
import bpy
|
||||
import time
|
||||
from bpy.utils import register_class
|
||||
from bpy.utils import unregister_class
|
||||
from ..utils import compat
|
||||
from bpy.app.handlers import persistent
|
||||
from .. import config
|
||||
from . import preferences_ui
|
||||
@@ -123,6 +123,6 @@ def register():
|
||||
|
||||
def unregister():
|
||||
for cls in reg_list:
|
||||
unregister_class(cls)
|
||||
compat.safe_unregister_class(cls)
|
||||
|
||||
bpy.app.handlers.load_post.remove(show_support_me_popup)
|
||||
|
||||
@@ -1,23 +1,4 @@
|
||||
"""
|
||||
Copyright (C) 2019 Remington Creative
|
||||
|
||||
This file is part of Atomic Data Manager.
|
||||
|
||||
Atomic Data Manager is free software: you can redistribute
|
||||
it and/or modify it under the terms of the GNU General Public License
|
||||
as published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
Atomic Data Manager is distributed in the hope that it will
|
||||
be useful, but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along
|
||||
with Atomic Data Manager. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
---
|
||||
|
||||
This package contains utility modules for version detection and API compatibility.
|
||||
|
||||
"""
|
||||
|
||||
@@ -1,23 +1,4 @@
|
||||
"""
|
||||
Copyright (C) 2019 Remington Creative
|
||||
|
||||
This file is part of Atomic Data Manager.
|
||||
|
||||
Atomic Data Manager is free software: you can redistribute
|
||||
it and/or modify it under the terms of the GNU General Public License
|
||||
as published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
Atomic Data Manager is distributed in the hope that it will
|
||||
be useful, but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along
|
||||
with Atomic Data Manager. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
---
|
||||
|
||||
This module provides API compatibility functions for handling differences
|
||||
between Blender 4.2 LTS, 4.5 LTS, and 5.0.
|
||||
|
||||
@@ -152,3 +133,25 @@ def get_scene_compositor_node_tree(scene):
|
||||
if hasattr(scene, 'node_tree') and scene.node_tree:
|
||||
return scene.node_tree
|
||||
return None
|
||||
|
||||
|
||||
def is_library_or_override(datablock):
|
||||
"""
|
||||
Check if a datablock is library-linked or an override.
|
||||
Atomic should completely ignore all datablocks within libraries.
|
||||
|
||||
Args:
|
||||
datablock: The datablock to check
|
||||
|
||||
Returns:
|
||||
bool: True if the datablock is library-linked or an override, False otherwise
|
||||
"""
|
||||
# Check if datablock is linked from a library
|
||||
if hasattr(datablock, 'library') and datablock.library:
|
||||
return True
|
||||
|
||||
# Check if datablock is an override (Blender 3.0+)
|
||||
if hasattr(datablock, 'override_library') and datablock.override_library:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@@ -1,23 +1,4 @@
|
||||
"""
|
||||
Copyright (C) 2019 Remington Creative
|
||||
|
||||
This file is part of Atomic Data Manager.
|
||||
|
||||
Atomic Data Manager is free software: you can redistribute
|
||||
it and/or modify it under the terms of the GNU General Public License
|
||||
as published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
Atomic Data Manager is distributed in the hope that it will
|
||||
be useful, but WITHOUT ANY WARRANTY; without even the implied warranty
|
||||
of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along
|
||||
with Atomic Data Manager. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
---
|
||||
|
||||
This module provides version detection and comparison utilities for
|
||||
multi-version Blender support (4.2 LTS, 4.5 LTS, and 5.0).
|
||||
|
||||
|
||||
@@ -676,17 +676,25 @@ class BPL_OT_create_playblast(Operator):
|
||||
glob.glob(os.path.join(frame_output_dir, "*.jpeg")))
|
||||
file_output_done = len(frame_files) >= expected_frames
|
||||
elif getattr(self, '_needs_video_encode', False):
|
||||
# Check for last frame file (PNG or JPEG) or completed frame count
|
||||
# Check for last frame file (PNG or JPEG) - must check for actual last frame number
|
||||
# Determine extension from current render settings
|
||||
current_format = context.scene.render.image_settings.file_format
|
||||
frame_ext = ".jpg" if current_format == "JPEG" else ".png"
|
||||
expected_frame = f"{output_path}{expected_frames:04d}{frame_ext}"
|
||||
if os.path.exists(expected_frame):
|
||||
# Check for the actual last frame file (frame_end, not expected_frames)
|
||||
last_frame_file = f"{output_path}{self._frame_end:04d}{frame_ext}"
|
||||
if os.path.exists(last_frame_file):
|
||||
file_output_done = True
|
||||
else:
|
||||
# Check both PNG and JPEG files
|
||||
# Also check both PNG and JPEG files, but verify we have the last frame
|
||||
frame_matches = glob.glob(f"{output_path}*.png") + glob.glob(f"{output_path}*.jpg") + glob.glob(f"{output_path}*.jpeg")
|
||||
file_output_done = len(frame_matches) >= expected_frames
|
||||
# Check if we have at least expected_frames AND the last frame exists
|
||||
if len(frame_matches) >= expected_frames:
|
||||
# Verify the last frame file exists
|
||||
last_frame_png = f"{output_path}{self._frame_end:04d}.png"
|
||||
last_frame_jpg = f"{output_path}{self._frame_end:04d}.jpg"
|
||||
file_output_done = os.path.exists(last_frame_png) or os.path.exists(last_frame_jpg)
|
||||
else:
|
||||
file_output_done = False
|
||||
else:
|
||||
file_ext = get_file_extension(context.scene.basedplayblast.video_format)
|
||||
file_output_done = os.path.exists(output_path + file_ext)
|
||||
@@ -700,17 +708,36 @@ class BPL_OT_create_playblast(Operator):
|
||||
self._render_job_finished_time = time.time()
|
||||
|
||||
ready_to_finalize = False
|
||||
if rendered_frames_done:
|
||||
ready_to_finalize = True
|
||||
elif file_output_done:
|
||||
ready_to_finalize = True
|
||||
elif frame_range_done and self._render_job_finished_time is not None:
|
||||
if (time.time() - self._render_job_finished_time) >= self._render_job_grace:
|
||||
# For video encoding path, we MUST have both the last frame file AND the render job must be finished
|
||||
if getattr(self, '_needs_video_encode', False):
|
||||
# Strict check: need the last frame file AND render job must be finished
|
||||
if file_output_done and not render_job_running:
|
||||
if self._render_job_finished_time is not None:
|
||||
# Wait a bit after render job finishes to ensure all files are written
|
||||
if (time.time() - self._render_job_finished_time) >= 0.5:
|
||||
ready_to_finalize = True
|
||||
elif not self._render_job_was_running:
|
||||
# If render job was never running (unlikely but possible), just check file
|
||||
ready_to_finalize = file_output_done
|
||||
# Additional safeguard: if we've seen the end frame and render job finished, wait a bit then finalize
|
||||
elif frame_range_done and not render_job_running and self._render_job_finished_time is not None:
|
||||
if (time.time() - self._render_job_finished_time) >= self._render_job_grace:
|
||||
ready_to_finalize = True
|
||||
print("Render job ended; finalizing after grace period (video encode path).")
|
||||
else:
|
||||
# For non-video-encode paths, use original logic
|
||||
if rendered_frames_done:
|
||||
ready_to_finalize = True
|
||||
print("Render job ended; finalizing after grace period without detecting file.")
|
||||
elif file_output_done:
|
||||
ready_to_finalize = True
|
||||
elif frame_range_done and self._render_job_finished_time is not None:
|
||||
if (time.time() - self._render_job_finished_time) >= self._render_job_grace:
|
||||
ready_to_finalize = True
|
||||
print("Render job ended; finalizing after grace period without detecting file.")
|
||||
|
||||
# Additional safeguard: if we've seen the end frame and no progress change for a moment, finalize
|
||||
if not ready_to_finalize and frame_range_done and self._last_frame_change_time:
|
||||
# But only if render job is not running
|
||||
if not ready_to_finalize and frame_range_done and not render_job_running and self._last_frame_change_time:
|
||||
if (time.time() - self._last_frame_change_time) >= 1.0:
|
||||
ready_to_finalize = True
|
||||
print("Frame progress stalled at end frame; finalizing to prevent hang.")
|
||||
@@ -1493,9 +1520,12 @@ class BPL_OT_create_playblast(Operator):
|
||||
# Build FFmpeg command with proper structure:
|
||||
# 1. All inputs first (video, then audio if present)
|
||||
# 2. Then all encoding options
|
||||
# Note: FFmpeg's %04d pattern expects frames starting at 0000, but our frames start at frame_start
|
||||
# We need to add -start_number to tell FFmpeg the actual starting frame number
|
||||
ffmpeg_cmd = [
|
||||
"ffmpeg", "-y", # Overwrite output file
|
||||
"-framerate", str(framerate),
|
||||
"-start_number", str(self._frame_start), # Tell FFmpeg the starting frame number
|
||||
"-i", frame_pattern,
|
||||
]
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ schema_version = "1.0.0"
|
||||
id = "basedplayblast"
|
||||
name = "BasedPlayblast"
|
||||
tagline = "Easily create playblasts from Blender and Flamenco"
|
||||
version = "2.6.0"
|
||||
version = "2.6.1"
|
||||
type = "add-on"
|
||||
|
||||
maintainer = "RaincloudTheDragon <raincloudthedragon@gmail.com>"
|
||||
|
||||
@@ -10,6 +10,7 @@ from .ops.Rename_images_by_mat import Rename_images_by_mat, RENAME_OT_summary_di
|
||||
from .ops.FreeGPU import BST_FreeGPU
|
||||
from .ops import ghost_buster
|
||||
from . import rainys_repo_bootstrap
|
||||
from .utils import compat
|
||||
|
||||
# Addon preferences class for update settings
|
||||
class BST_AddonPreferences(AddonPreferences):
|
||||
@@ -58,7 +59,7 @@ classes = (
|
||||
def register():
|
||||
# Register classes from this module (do this first to ensure preferences are available)
|
||||
for cls in classes:
|
||||
bpy.utils.register_class(cls)
|
||||
compat.safe_register_class(cls)
|
||||
|
||||
# Print debug info about preferences
|
||||
try:
|
||||
@@ -128,10 +129,7 @@ def unregister():
|
||||
rainys_repo_bootstrap.unregister()
|
||||
# Unregister classes from this module
|
||||
for cls in reversed(classes):
|
||||
try:
|
||||
bpy.utils.unregister_class(cls)
|
||||
except RuntimeError:
|
||||
pass
|
||||
compat.safe_unregister_class(cls)
|
||||
|
||||
if __name__ == "__main__":
|
||||
register()
|
||||
|
||||
@@ -3,7 +3,7 @@ schema_version = "1.0.0"
|
||||
id = "rainclouds_bulk_scene_tools"
|
||||
name = "Raincloud's Bulk Scene Tools"
|
||||
tagline = "Bulk utilities for optimizing scene data"
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
type = "add-on"
|
||||
|
||||
maintainer = "RaincloudTheDragon <raincloudthedragon@gmail.com>"
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
# v0.14.0
|
||||
- Added operator to select all images with absolute paths (#3)
|
||||
- Added search functionality to filter datablocks in PathMan and Data Remapper panels (#4)
|
||||
|
||||
# v0.13.1
|
||||
- Fix github workflow to include new utils folder
|
||||
|
||||
# v0.13.0
|
||||
- Set up compat for #9, still needs bugchecking, but the main setup is complete.
|
||||
- Fixed #10
|
||||
|
||||
# v0.12.0
|
||||
- Integrate Rainy's Extension Repo bootstrapper
|
||||
- Set minimum Blender version to 4.2 for #9
|
||||
|
||||
@@ -7,6 +7,7 @@ from ..panels.bulk_path_management import (
|
||||
set_image_paths,
|
||||
ensure_directory_for_path,
|
||||
)
|
||||
from ..utils import compat
|
||||
|
||||
class AUTOMAT_OT_summary_dialog(bpy.types.Operator):
|
||||
"""Show AutoMat Extractor operation summary"""
|
||||
@@ -70,8 +71,9 @@ class AutoMatExtractor(bpy.types.Operator):
|
||||
def execute(self, context):
|
||||
# Get addon preferences
|
||||
addon_name = __package__.split('.')[0]
|
||||
prefs = context.preferences.addons.get(addon_name).preferences
|
||||
common_outside = prefs.automat_common_outside_blend
|
||||
addon_entry = context.preferences.addons.get(addon_name)
|
||||
prefs = addon_entry.preferences if addon_entry else None
|
||||
common_outside = prefs.automat_common_outside_blend if prefs else False
|
||||
|
||||
# Get selected images
|
||||
selected_images = [img for img in bpy.data.images if hasattr(img, "bst_selected") and img.bst_selected]
|
||||
@@ -190,12 +192,23 @@ class AutoMatExtractor(bpy.types.Operator):
|
||||
img = self.selected_images[self.current_index]
|
||||
props.operation_status = f"Building path for {img.name}..."
|
||||
|
||||
# Get blend file name
|
||||
blend_name = bpy.path.basename(bpy.data.filepath)
|
||||
if blend_name:
|
||||
blend_name = os.path.splitext(blend_name)[0]
|
||||
# Get blend file name - respect user preference if set
|
||||
if props.use_blend_subfolder:
|
||||
blend_name = props.blend_subfolder
|
||||
if not blend_name:
|
||||
# Fall back to filename if not specified
|
||||
blend_path = bpy.data.filepath
|
||||
if blend_path:
|
||||
blend_name = os.path.splitext(os.path.basename(blend_path))[0]
|
||||
else:
|
||||
blend_name = "untitled"
|
||||
else:
|
||||
blend_name = "untitled"
|
||||
# Derive from filename
|
||||
blend_path = bpy.data.filepath
|
||||
if blend_path:
|
||||
blend_name = os.path.splitext(os.path.basename(blend_path))[0]
|
||||
else:
|
||||
blend_name = "untitled"
|
||||
blend_name = self.sanitize_filename(blend_name)
|
||||
|
||||
# Determine common path
|
||||
@@ -532,9 +545,9 @@ classes = (
|
||||
|
||||
def register():
|
||||
for cls in classes:
|
||||
bpy.utils.register_class(cls)
|
||||
compat.safe_register_class(cls)
|
||||
|
||||
def unregister():
|
||||
for cls in reversed(classes):
|
||||
bpy.utils.unregister_class(cls)
|
||||
compat.safe_unregister_class(cls)
|
||||
|
||||
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
import bpy
|
||||
import re
|
||||
from ..utils import compat
|
||||
|
||||
class RENAME_OT_summary_dialog(bpy.types.Operator):
|
||||
"""Show rename operation summary"""
|
||||
@@ -505,9 +506,9 @@ classes = (
|
||||
|
||||
def register():
|
||||
for cls in classes:
|
||||
bpy.utils.register_class(cls)
|
||||
compat.safe_register_class(cls)
|
||||
|
||||
def unregister():
|
||||
for cls in reversed(classes):
|
||||
bpy.utils.unregister_class(cls)
|
||||
compat.safe_unregister_class(cls)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import bpy
|
||||
from bpy.types import Operator
|
||||
from ..utils import compat
|
||||
|
||||
class CreateOrthoCamera(Operator):
|
||||
"""Create an orthographic camera with predefined settings"""
|
||||
@@ -38,10 +39,10 @@ class CreateOrthoCamera(Operator):
|
||||
return {'FINISHED'}
|
||||
|
||||
def register():
|
||||
bpy.utils.register_class(CreateOrthoCamera)
|
||||
compat.safe_register_class(CreateOrthoCamera)
|
||||
|
||||
def unregister():
|
||||
bpy.utils.unregister_class(CreateOrthoCamera)
|
||||
compat.safe_unregister_class(CreateOrthoCamera)
|
||||
|
||||
if __name__ == "__main__":
|
||||
register()
|
||||
@@ -1,4 +1,5 @@
|
||||
import bpy
|
||||
from ..utils import compat
|
||||
|
||||
def safe_wgt_removal():
|
||||
"""Safely remove only WGT widget objects that are clearly ghosts"""
|
||||
@@ -680,11 +681,8 @@ classes = (
|
||||
|
||||
def register():
|
||||
for cls in classes:
|
||||
bpy.utils.register_class(cls)
|
||||
compat.safe_register_class(cls)
|
||||
|
||||
def unregister():
|
||||
for cls in reversed(classes):
|
||||
try:
|
||||
bpy.utils.unregister_class(cls)
|
||||
except RuntimeError:
|
||||
pass
|
||||
compat.safe_unregister_class(cls)
|
||||
+3
-2
@@ -1,4 +1,5 @@
|
||||
import bpy
|
||||
from ..utils import compat
|
||||
|
||||
class RemoveCustomSplitNormals(bpy.types.Operator):
|
||||
"""Remove custom split normals and apply smooth shading to all accessible mesh objects"""
|
||||
@@ -53,10 +54,10 @@ class RemoveCustomSplitNormals(bpy.types.Operator):
|
||||
|
||||
# Registration
|
||||
def register():
|
||||
bpy.utils.register_class(MESH_OT_RemoveCustomSplitNormals)
|
||||
compat.safe_register_class(RemoveCustomSplitNormals)
|
||||
|
||||
def unregister():
|
||||
bpy.utils.unregister_class(MESH_OT_RemoveCustomSplitNormals)
|
||||
compat.safe_unregister_class(RemoveCustomSplitNormals)
|
||||
|
||||
# Only run if this script is run directly
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -6,6 +6,7 @@ import subprocess
|
||||
|
||||
# Import ghost buster functionality
|
||||
from ..ops.ghost_buster import GhostBuster, GhostDetector, ResyncEnforce
|
||||
from ..utils import compat
|
||||
|
||||
# Regular expression to match numbered suffixes like .001, .002, _001, _0001, etc.
|
||||
NUMBERED_SUFFIX_PATTERN = re.compile(r'(.*?)[._](\d{3,})$')
|
||||
@@ -91,6 +92,31 @@ def register_dataremap_properties():
|
||||
default=False
|
||||
)
|
||||
|
||||
# Search filter properties for each data type
|
||||
bpy.types.Scene.dataremap_search_images = bpy.props.StringProperty( # type: ignore
|
||||
name="Search Images",
|
||||
description="Filter images by name (case-insensitive)",
|
||||
default=""
|
||||
)
|
||||
|
||||
bpy.types.Scene.dataremap_search_materials = bpy.props.StringProperty( # type: ignore
|
||||
name="Search Materials",
|
||||
description="Filter materials by name (case-insensitive)",
|
||||
default=""
|
||||
)
|
||||
|
||||
bpy.types.Scene.dataremap_search_fonts = bpy.props.StringProperty( # type: ignore
|
||||
name="Search Fonts",
|
||||
description="Filter fonts by name (case-insensitive)",
|
||||
default=""
|
||||
)
|
||||
|
||||
bpy.types.Scene.dataremap_search_worlds = bpy.props.StringProperty( # type: ignore
|
||||
name="Search Worlds",
|
||||
description="Filter worlds by name (case-insensitive)",
|
||||
default=""
|
||||
)
|
||||
|
||||
# Dictionary to store excluded groups
|
||||
if not hasattr(bpy.types.Scene, "excluded_remap_groups"):
|
||||
bpy.types.Scene.excluded_remap_groups = {}
|
||||
@@ -859,6 +885,21 @@ def draw_drag_selectable_checkbox(layout, context, data_type, group_key):
|
||||
op.group_key = group_key
|
||||
op.data_type = data_type
|
||||
|
||||
def search_matches_group(group, search_string):
|
||||
"""Check if search string matches group base name or any item in group"""
|
||||
if not search_string:
|
||||
return True
|
||||
search_lower = search_string.lower()
|
||||
base_name, items = group
|
||||
# Check base name
|
||||
if search_lower in base_name.lower():
|
||||
return True
|
||||
# Check all item names in group
|
||||
for item in items:
|
||||
if search_lower in item.name.lower():
|
||||
return True
|
||||
return False
|
||||
|
||||
# Update the UI code to use the custom draw function
|
||||
def draw_data_duplicates(layout, context, data_type, data_groups):
|
||||
"""Draw the list of duplicate data items with drag-selectable checkboxes and click to rename"""
|
||||
@@ -881,6 +922,13 @@ def draw_data_duplicates(layout, context, data_type, data_groups):
|
||||
if hasattr(context.scene, sort_prop_name):
|
||||
select_row.prop(context.scene, sort_prop_name, text="Sort by Selected")
|
||||
|
||||
# Add search filter
|
||||
search_row = box_dup.row()
|
||||
search_row.label(text="", icon='VIEWZOOM')
|
||||
search_prop_name = f"dataremap_search_{data_type}"
|
||||
if hasattr(context.scene, search_prop_name):
|
||||
search_row.prop(context.scene, search_prop_name, text="")
|
||||
|
||||
box_dup.separator(factor=0.5)
|
||||
|
||||
# Initialize the expanded groups dictionary if it doesn't exist
|
||||
@@ -890,6 +938,15 @@ def draw_data_duplicates(layout, context, data_type, data_groups):
|
||||
# Get the groups and possibly sort them
|
||||
group_items = list(data_groups.items())
|
||||
|
||||
# Filter by search string if provided
|
||||
search_prop_name = f"dataremap_search_{data_type}"
|
||||
search_string = ""
|
||||
if hasattr(context.scene, search_prop_name):
|
||||
search_string = getattr(context.scene, search_prop_name)
|
||||
|
||||
if search_string:
|
||||
group_items = [group for group in group_items if search_matches_group(group, search_string)]
|
||||
|
||||
# Sort by selection if enabled
|
||||
sort_prop_name = f"dataremap_sort_{data_type}"
|
||||
if hasattr(context.scene, sort_prop_name) and getattr(context.scene, sort_prop_name):
|
||||
@@ -1443,14 +1500,11 @@ def register():
|
||||
register_dataremap_properties()
|
||||
|
||||
for cls in classes:
|
||||
bpy.utils.register_class(cls)
|
||||
compat.safe_register_class(cls)
|
||||
|
||||
def unregister():
|
||||
for cls in reversed(classes):
|
||||
try:
|
||||
bpy.utils.unregister_class(cls)
|
||||
except RuntimeError:
|
||||
pass
|
||||
compat.safe_unregister_class(cls)
|
||||
# Unregister properties
|
||||
try:
|
||||
unregister_dataremap_properties()
|
||||
|
||||
+82
-4
@@ -3,6 +3,7 @@ from bpy.types import Panel, Operator, PropertyGroup # type: ignore
|
||||
from bpy.props import StringProperty, BoolProperty, EnumProperty, PointerProperty, CollectionProperty # type: ignore
|
||||
import os
|
||||
import re
|
||||
from ..utils import compat
|
||||
|
||||
class REMOVE_EXT_OT_summary_dialog(bpy.types.Operator):
|
||||
"""Show remove extensions operation summary"""
|
||||
@@ -257,6 +258,13 @@ class BST_PathProperties(PropertyGroup):
|
||||
default=True
|
||||
) # type: ignore
|
||||
|
||||
# Search filter for images
|
||||
search_filter: StringProperty(
|
||||
name="Search Filter",
|
||||
description="Filter images by name (case-insensitive)",
|
||||
default=""
|
||||
) # type: ignore
|
||||
|
||||
# Smart pathing properties
|
||||
smart_base_path: StringProperty(
|
||||
name="Base Path",
|
||||
@@ -594,6 +602,61 @@ class BST_OT_select_active_images(Operator):
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
# Operator to select all images with absolute paths
|
||||
class BST_OT_select_absolute_images(Operator):
|
||||
bl_idname = "bst.select_absolute_images"
|
||||
bl_label = "Select Absolute Images"
|
||||
bl_description = "Select all images with absolute file paths"
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
def execute(self, context):
|
||||
selected_count = 0
|
||||
|
||||
# Iterate through all images
|
||||
for img in bpy.data.images:
|
||||
# Skip images that shouldn't be checked
|
||||
if (img.source == 'GENERATED' or # Procedurally generated
|
||||
img.source == 'VIEWER' or # Render Result, Viewer Node, etc.
|
||||
img.name in ['Render Result', 'Viewer Node']): # Special Blender images
|
||||
continue
|
||||
|
||||
# Check if image has a file path
|
||||
if not img.filepath and not img.filepath_raw:
|
||||
continue
|
||||
|
||||
# Check both filepath and filepath_raw for absolute paths
|
||||
is_absolute = False
|
||||
|
||||
# Check filepath
|
||||
if img.filepath:
|
||||
# Skip Blender relative paths (starting with //)
|
||||
if not img.filepath.startswith('//'):
|
||||
# Convert to absolute path and check
|
||||
abs_path = bpy.path.abspath(img.filepath)
|
||||
if abs_path and os.path.isabs(abs_path):
|
||||
is_absolute = True
|
||||
|
||||
# Check filepath_raw if filepath wasn't absolute
|
||||
if not is_absolute and img.filepath_raw:
|
||||
# Skip Blender relative paths (starting with //)
|
||||
if not img.filepath_raw.startswith('//'):
|
||||
# Convert to absolute path and check
|
||||
abs_path = bpy.path.abspath(img.filepath_raw)
|
||||
if abs_path and os.path.isabs(abs_path):
|
||||
is_absolute = True
|
||||
|
||||
# Select image if it has an absolute path
|
||||
if is_absolute:
|
||||
img.bst_selected = True
|
||||
selected_count += 1
|
||||
|
||||
if selected_count > 0:
|
||||
self.report({'INFO'}, f"Selected {selected_count} images with absolute paths")
|
||||
else:
|
||||
self.report({'INFO'}, "No images with absolute paths found")
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
# Add a class for renaming datablocks
|
||||
class BST_OT_rename_datablock(Operator):
|
||||
"""Click to rename datablock"""
|
||||
@@ -1482,7 +1545,8 @@ class NODE_PT_bulk_path_tools(Panel):
|
||||
|
||||
# Get addon preferences
|
||||
addon_name = __package__.split('.')[0]
|
||||
prefs = context.preferences.addons.get(addon_name).preferences
|
||||
addon_entry = context.preferences.addons.get(addon_name)
|
||||
prefs = addon_entry.preferences if addon_entry else None
|
||||
|
||||
row = box.row(align=True)
|
||||
row.enabled = any_selected
|
||||
@@ -1495,7 +1559,8 @@ class NODE_PT_bulk_path_tools(Panel):
|
||||
|
||||
# Right side: checkbox
|
||||
col = split.column()
|
||||
col.prop(prefs, "automat_common_outside_blend", text="", icon='FOLDER_REDIRECT')
|
||||
if prefs:
|
||||
col.prop(prefs, "automat_common_outside_blend", text="", icon='FOLDER_REDIRECT')
|
||||
|
||||
# Bulk operations section
|
||||
box = layout.box()
|
||||
@@ -1521,10 +1586,16 @@ class NODE_PT_bulk_path_tools(Panel):
|
||||
row = box.row(align=True)
|
||||
row.operator("bst.select_material_images", text="Material Images")
|
||||
row.operator("bst.select_active_images", text="Active Images")
|
||||
row.operator("bst.select_absolute_images", text="Absolute Images", icon='FOLDER_REDIRECT')
|
||||
|
||||
# Sorting option
|
||||
row = box.row()
|
||||
row.prop(path_props, "sort_by_selected", text="Sort by Selected")
|
||||
|
||||
# Search filter
|
||||
row = box.row()
|
||||
row.label(text="", icon='VIEWZOOM')
|
||||
row.prop(path_props, "search_filter", text="")
|
||||
|
||||
box.separator()
|
||||
|
||||
@@ -1539,6 +1610,12 @@ class NODE_PT_bulk_path_tools(Panel):
|
||||
# Use original order
|
||||
sorted_images = bpy.data.images
|
||||
|
||||
# Filter by search string if provided
|
||||
search_filter = path_props.search_filter
|
||||
if search_filter:
|
||||
search_lower = search_filter.lower()
|
||||
sorted_images = [img for img in sorted_images if search_lower in img.name.lower()]
|
||||
|
||||
for img in sorted_images:
|
||||
# Add bst_selected attribute if it doesn't exist
|
||||
if not hasattr(img, "bst_selected"):
|
||||
@@ -1590,6 +1667,7 @@ classes = (
|
||||
BST_OT_toggle_path_edit,
|
||||
BST_OT_select_material_images,
|
||||
BST_OT_select_active_images,
|
||||
BST_OT_select_absolute_images,
|
||||
BST_OT_rename_datablock,
|
||||
BST_OT_toggle_image_selection,
|
||||
BST_OT_reuse_material_path,
|
||||
@@ -1609,7 +1687,7 @@ classes = (
|
||||
|
||||
def register():
|
||||
for cls in classes:
|
||||
bpy.utils.register_class(cls)
|
||||
compat.safe_register_class(cls)
|
||||
|
||||
# Register properties
|
||||
bpy.types.Scene.bst_path_props = PointerProperty(type=BST_PathProperties)
|
||||
@@ -1633,7 +1711,7 @@ def unregister():
|
||||
|
||||
# Unregister classes
|
||||
for cls in reversed(classes):
|
||||
bpy.utils.unregister_class(cls)
|
||||
compat.safe_unregister_class(cls)
|
||||
|
||||
if __name__ == "__main__":
|
||||
register()
|
||||
+3
-5
@@ -7,6 +7,7 @@ from ..ops.delete_single_keyframe_actions import DeleteSingleKeyframeActions
|
||||
from ..ops.find_material_users import FindMaterialUsers, MATERIAL_USERS_OT_summary_dialog
|
||||
from ..ops.remove_unused_material_slots import RemoveUnusedMaterialSlots
|
||||
from ..ops.convert_relations_to_constraint import ConvertRelationsToConstraint
|
||||
from ..utils import compat
|
||||
|
||||
class BulkSceneGeneral(bpy.types.Panel):
|
||||
"""Bulk Scene General Panel"""
|
||||
@@ -76,7 +77,7 @@ classes = (
|
||||
# Registration
|
||||
def register():
|
||||
for cls in classes:
|
||||
bpy.utils.register_class(cls)
|
||||
compat.safe_register_class(cls)
|
||||
# Register the window manager property for the checkbox
|
||||
bpy.types.WindowManager.bst_no_subdiv_only_selected = bpy.props.BoolProperty(
|
||||
name="Selected Only",
|
||||
@@ -92,10 +93,7 @@ def register():
|
||||
|
||||
def unregister():
|
||||
for cls in reversed(classes):
|
||||
try:
|
||||
bpy.utils.unregister_class(cls)
|
||||
except RuntimeError:
|
||||
pass
|
||||
compat.safe_unregister_class(cls)
|
||||
# Unregister the window manager property
|
||||
if hasattr(bpy.types.WindowManager, "bst_no_subdiv_only_selected"):
|
||||
del bpy.types.WindowManager.bst_no_subdiv_only_selected
|
||||
|
||||
+3
-5
@@ -5,6 +5,7 @@ import os
|
||||
from enum import Enum
|
||||
import colorsys # Add colorsys for RGB to HSV conversion
|
||||
from ..ops.select_diffuse_nodes import select_diffuse_nodes # Import the specific function
|
||||
from ..utils import compat
|
||||
|
||||
# Material processing status enum
|
||||
class MaterialStatus(Enum):
|
||||
@@ -1014,7 +1015,7 @@ classes = (
|
||||
# Registration
|
||||
def register():
|
||||
for cls in classes:
|
||||
bpy.utils.register_class(cls)
|
||||
compat.safe_register_class(cls)
|
||||
|
||||
# Register properties
|
||||
register_viewport_properties()
|
||||
@@ -1027,7 +1028,4 @@ def unregister():
|
||||
pass
|
||||
# Unregister classes
|
||||
for cls in reversed(classes):
|
||||
try:
|
||||
bpy.utils.unregister_class(cls)
|
||||
except RuntimeError:
|
||||
pass
|
||||
compat.safe_unregister_class(cls)
|
||||
@@ -7,7 +7,7 @@ A couple Blender tools to help me automate some tedious tasks in scene optimizat
|
||||
- Bulk Data Remap
|
||||
- Bulk Viewport Display
|
||||
|
||||
Officially supports Blender 4.4.1, but may still work on older versions.
|
||||
Officially supports Blender 4.2, 4.4, 4.5, and 5.0.
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
This package contains utility modules for version detection and API compatibility.
|
||||
|
||||
"""
|
||||
|
||||
from . import version
|
||||
from . import compat
|
||||
|
||||
__all__ = ['version', 'compat']
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
This module provides API compatibility functions for handling differences
|
||||
between Blender 4.2, 4.4, 4.5, and 5.0.
|
||||
|
||||
"""
|
||||
|
||||
import bpy
|
||||
from bpy.utils import register_class, unregister_class
|
||||
from . import version
|
||||
|
||||
|
||||
def safe_register_class(cls):
|
||||
"""
|
||||
Safely register a class, handling any version-specific registration issues.
|
||||
|
||||
Args:
|
||||
cls: The class to register
|
||||
|
||||
Returns:
|
||||
bool: True if registration succeeded, False otherwise
|
||||
"""
|
||||
try:
|
||||
register_class(cls)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to register {cls.__name__}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def safe_unregister_class(cls):
|
||||
"""
|
||||
Safely unregister a class, handling any version-specific unregistration issues.
|
||||
|
||||
Args:
|
||||
cls: The class to unregister
|
||||
|
||||
Returns:
|
||||
bool: True if unregistration succeeded, False otherwise
|
||||
"""
|
||||
try:
|
||||
unregister_class(cls)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to unregister {cls.__name__}: {e}")
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
This module provides version detection and comparison utilities for
|
||||
multi-version Blender support (4.2, 4.4, 4.5, and 5.0).
|
||||
|
||||
"""
|
||||
|
||||
import bpy
|
||||
|
||||
# Version constants
|
||||
VERSION_4_2 = (4, 2, 0)
|
||||
VERSION_4_4 = (4, 4, 0)
|
||||
VERSION_4_5 = (4, 5, 0)
|
||||
VERSION_5_0 = (5, 0, 0)
|
||||
|
||||
|
||||
def get_blender_version():
|
||||
"""
|
||||
Returns the current Blender version as a tuple (major, minor, patch).
|
||||
|
||||
Returns:
|
||||
tuple: (major, minor, patch) version numbers
|
||||
"""
|
||||
return bpy.app.version
|
||||
|
||||
|
||||
def get_version_string():
|
||||
"""
|
||||
Returns the current Blender version as a string (e.g., "4.2.0").
|
||||
|
||||
Returns:
|
||||
str: Version string in format "major.minor.patch"
|
||||
"""
|
||||
version = get_blender_version()
|
||||
return f"{version[0]}.{version[1]}.{version[2]}"
|
||||
|
||||
|
||||
def is_version_at_least(major, minor=0, patch=0):
|
||||
"""
|
||||
Check if the current Blender version is at least the specified version.
|
||||
|
||||
Args:
|
||||
major (int): Major version number
|
||||
minor (int): Minor version number (default: 0)
|
||||
patch (int): Patch version number (default: 0)
|
||||
|
||||
Returns:
|
||||
bool: True if current version >= specified version
|
||||
"""
|
||||
current = get_blender_version()
|
||||
target = (major, minor, patch)
|
||||
|
||||
if current[0] != target[0]:
|
||||
return current[0] > target[0]
|
||||
if current[1] != target[1]:
|
||||
return current[1] > target[1]
|
||||
return current[2] >= target[2]
|
||||
|
||||
|
||||
def is_version_less_than(major, minor=0, patch=0):
|
||||
"""
|
||||
Check if the current Blender version is less than the specified version.
|
||||
|
||||
Args:
|
||||
major (int): Major version number
|
||||
minor (int): Minor version number (default: 0)
|
||||
patch (int): Patch version number (default: 0)
|
||||
|
||||
Returns:
|
||||
bool: True if current version < specified version
|
||||
"""
|
||||
return not is_version_at_least(major, minor, patch)
|
||||
|
||||
|
||||
def get_version_category():
|
||||
"""
|
||||
Returns the version category string for the current Blender version.
|
||||
|
||||
Returns:
|
||||
str: '4.2', '4.4', '4.5', or '5.0' based on the current version
|
||||
"""
|
||||
version = get_blender_version()
|
||||
major, minor = version[0], version[1]
|
||||
|
||||
if major == 4:
|
||||
if minor < 4:
|
||||
return '4.2'
|
||||
elif minor < 5:
|
||||
return '4.4'
|
||||
else:
|
||||
return '4.5'
|
||||
elif major >= 5:
|
||||
return '5.0'
|
||||
else:
|
||||
# Fallback for older versions
|
||||
return f"{major}.{minor}"
|
||||
|
||||
|
||||
def is_version_4_2():
|
||||
"""Check if running Blender 4.2 (4.2.x only, not 4.3 or 4.4)."""
|
||||
version = get_blender_version()
|
||||
return version[0] == 4 and version[1] == 2
|
||||
|
||||
|
||||
def is_version_4_4():
|
||||
"""Check if running Blender 4.4 (4.4.x only, not 4.5)."""
|
||||
version = get_blender_version()
|
||||
return version[0] == 4 and version[1] == 4
|
||||
|
||||
|
||||
def is_version_4_5():
|
||||
"""Check if running Blender 4.5 LTS."""
|
||||
return is_version_at_least(4, 5, 0) and is_version_less_than(5, 0, 0)
|
||||
|
||||
|
||||
def is_version_5_0():
|
||||
"""Check if running Blender 5.0 or later."""
|
||||
return is_version_at_least(5, 0, 0)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,26 @@
|
||||
schema_version = "1.0.0"
|
||||
|
||||
id = "amzncharactertools"
|
||||
version = "0.6.3"
|
||||
name = "AMZNCharacterTools"
|
||||
tagline = "AMZNCharacterTools"
|
||||
maintainer = "Nathan Lindsay"
|
||||
version = "0.9.0"
|
||||
type = "add-on"
|
||||
tags = ["3D View"]
|
||||
blender_version_min = "4.5.0"
|
||||
license = [
|
||||
"SPDX:GPL-2.0-or-later",
|
||||
|
||||
maintainer = "Nathan Lindsay"
|
||||
license = ["None"]
|
||||
blender_version_min = "4.2.0"
|
||||
|
||||
tags = ["Rigging", "Workflow"]
|
||||
|
||||
[permissions]
|
||||
files = "Read and write external resources referenced by scenes"
|
||||
|
||||
[build]
|
||||
paths_exclude_pattern = [
|
||||
"__pycache__/",
|
||||
"*.pyc",
|
||||
".git/",
|
||||
".github/",
|
||||
"docs/",
|
||||
"tests/",
|
||||
]
|
||||
@@ -0,0 +1,129 @@
|
||||
import bpy
|
||||
|
||||
def add_body_masks():
|
||||
# Find the CC_Base_Body object
|
||||
body_obj = bpy.data.objects.get('CC_Base_Body')
|
||||
if not body_obj:
|
||||
print("Error: CC_Base_Body object not found")
|
||||
return
|
||||
|
||||
print(f"Found body object: {body_obj.name}")
|
||||
|
||||
# Define vertex groups for each mask
|
||||
main_mask_groups = [
|
||||
# Arms
|
||||
'DEF-shoulder.L', 'DEF-shoulder.R',
|
||||
'DEF-upper_arm.L', 'DEF-upper_arm.L.001', 'DEF-upper_arm.R', 'DEF-upper_arm.R.001',
|
||||
'DEF-elbow_share.L', 'DEF-elbow_share.R',
|
||||
'DEF-forearm.L', 'DEF-forearm.L.001', 'DEF-forearm.R', 'DEF-forearm.R.001',
|
||||
# Head and chest (upper torso)
|
||||
'DEF-spine.003', 'DEF-spine.004', 'DEF-spine.005', 'DEF-spine.006',
|
||||
'DEF-jaw', 'DEF-jaw.L', 'DEF-jaw.R', 'DEF-jaw.L.001', 'DEF-jaw.R.001',
|
||||
'DEF-forehead.L', 'DEF-forehead.R', 'DEF-forehead.L.001', 'DEF-forehead.R.001', 'DEF-forehead.L.002', 'DEF-forehead.R.002',
|
||||
'DEF-temple.L', 'DEF-temple.R',
|
||||
'DEF-brow.B.L', 'DEF-brow.B.L.001', 'DEF-brow.B.L.002', 'DEF-brow.B.L.003',
|
||||
'DEF-brow.B.R', 'DEF-brow.B.R.001', 'DEF-brow.B.R.002', 'DEF-brow.B.R.003',
|
||||
'DEF-brow.T.L', 'DEF-brow.T.L.001', 'DEF-brow.T.L.002', 'DEF-brow.T.L.003',
|
||||
'DEF-brow.T.R', 'DEF-brow.T.R.001', 'DEF-brow.T.R.002', 'DEF-brow.T.R.003',
|
||||
'DEF-lid.B.L', 'DEF-lid.B.L.001', 'DEF-lid.B.L.002', 'DEF-lid.B.L.003',
|
||||
'DEF-lid.B.R', 'DEF-lid.B.R.001', 'DEF-lid.B.R.002', 'DEF-lid.B.R.003',
|
||||
'DEF-lid.T.L', 'DEF-lid.T.L.001', 'DEF-lid.T.L.002', 'DEF-lid.T.L.003',
|
||||
'DEF-lid.T.R', 'DEF-lid.T.R.001', 'DEF-lid.T.R.002', 'DEF-lid.T.R.003',
|
||||
'DEF-ear.L', 'DEF-ear.L.001', 'DEF-ear.L.002', 'DEF-ear.L.003', 'DEF-ear.L.004',
|
||||
'DEF-ear.R', 'DEF-ear.R.001', 'DEF-ear.R.002', 'DEF-ear.R.003', 'DEF-ear.R.004',
|
||||
'DEF-chin', 'DEF-chin.001', 'DEF-chin.L', 'DEF-chin.R',
|
||||
'DEF-cheek.T.L', 'DEF-cheek.T.R', 'DEF-cheek.T.L.001', 'DEF-cheek.T.R.001',
|
||||
'DEF-cheek.B.L', 'DEF-cheek.B.R', 'DEF-cheek.B.L.001', 'DEF-cheek.B.R.001',
|
||||
'DEF-nose', 'DEF-nose.L', 'DEF-nose.R', 'DEF-nose.001', 'DEF-nose.002', 'DEF-nose.003', 'DEF-nose.004',
|
||||
'DEF-nose.L.001', 'DEF-nose.R.001',
|
||||
'DEF-lip.B.L', 'DEF-lip.B.R', 'DEF-lip.B.L.001', 'DEF-lip.B.R.001',
|
||||
'DEF-lip.T.L', 'DEF-lip.T.R', 'DEF-lip.T.L.001', 'DEF-lip.T.R.001',
|
||||
'DEF-tongue', 'DEF-tongue.001', 'DEF-tongue.002',
|
||||
'DEF-breast.L', 'DEF-breast.R', 'DEF-breast_twist.L', 'DEF-breast_twist.R'
|
||||
]
|
||||
|
||||
hand_groups = [
|
||||
# Hands
|
||||
'DEF-hand.L', 'DEF-hand.R',
|
||||
'DEF-f_pinky.01.L', 'DEF-f_pinky.02.L', 'DEF-f_pinky.03.L',
|
||||
'DEF-f_ring.01.L', 'DEF-f_ring.02.L', 'DEF-f_ring.03.L',
|
||||
'DEF-f_middle.01.L', 'DEF-f_middle.02.L', 'DEF-f_middle.03.L',
|
||||
'DEF-f_index.01.L', 'DEF-f_index.02.L', 'DEF-f_index.03.L',
|
||||
'DEF-thumb.01.L', 'DEF-thumb.02.L', 'DEF-thumb.03.L',
|
||||
'DEF-f_pinky.01.R', 'DEF-f_pinky.02.R', 'DEF-f_pinky.03.R',
|
||||
'DEF-f_ring.01.R', 'DEF-f_ring.02.R', 'DEF-f_ring.03.R',
|
||||
'DEF-f_middle.01.R', 'DEF-f_middle.02.R', 'DEF-f_middle.03.R',
|
||||
'DEF-f_index.01.R', 'DEF-f_index.02.R', 'DEF-f_index.03.R',
|
||||
'DEF-thumb.01.R', 'DEF-thumb.02.R', 'DEF-thumb.03.R'
|
||||
]
|
||||
|
||||
# Create vertex groups for masks
|
||||
def create_mask_vertex_group(obj, group_name, vertex_group_names):
|
||||
# Remove existing group if it exists
|
||||
existing_group = obj.vertex_groups.get(group_name)
|
||||
if existing_group:
|
||||
obj.vertex_groups.remove(existing_group)
|
||||
|
||||
# Create new vertex group
|
||||
mask_group = obj.vertex_groups.new(name=group_name)
|
||||
|
||||
# Get indices of source vertex groups
|
||||
source_group_indices = []
|
||||
for vg_name in vertex_group_names:
|
||||
vg = obj.vertex_groups.get(vg_name)
|
||||
if vg:
|
||||
source_group_indices.append(vg.index)
|
||||
|
||||
if not source_group_indices:
|
||||
print(f"Warning: No source vertex groups found for {group_name}")
|
||||
return None
|
||||
|
||||
# Add vertices that have weights in any of the source groups
|
||||
vertices_to_add = []
|
||||
for vert_idx, vert in enumerate(obj.data.vertices):
|
||||
for group in vert.groups:
|
||||
if group.group in source_group_indices and group.weight > 0.0:
|
||||
vertices_to_add.append(vert_idx)
|
||||
break
|
||||
|
||||
if vertices_to_add:
|
||||
mask_group.add(vertices_to_add, 1.0, 'REPLACE')
|
||||
print(f"Created {group_name} vertex group with {len(vertices_to_add)} vertices")
|
||||
|
||||
return mask_group
|
||||
|
||||
# Create Main mask vertex group (head, arms, chest)
|
||||
main_mask_vg = create_mask_vertex_group(body_obj, "Main_Mask", main_mask_groups)
|
||||
|
||||
# Create Hand mask vertex group (main + hands)
|
||||
hand_mask_vg = create_mask_vertex_group(body_obj, "Hand_Mask", main_mask_groups + hand_groups)
|
||||
|
||||
# Add mask modifiers
|
||||
# Remove existing mask modifiers if they exist
|
||||
modifiers_to_remove = []
|
||||
for modifier in body_obj.modifiers:
|
||||
if modifier.type == 'MASK' and modifier.name in ['Main_Mask', 'Hand_Mask']:
|
||||
modifiers_to_remove.append(modifier)
|
||||
|
||||
for modifier in modifiers_to_remove:
|
||||
body_obj.modifiers.remove(modifier)
|
||||
print(f"Removed existing {modifier.name} modifier")
|
||||
|
||||
# Add Main mask modifier
|
||||
if main_mask_vg:
|
||||
main_mask_modifier = body_obj.modifiers.new(name="Main_Mask", type='MASK')
|
||||
main_mask_modifier.vertex_group = "Main_Mask"
|
||||
print("Added Main_Mask modifier (head, arms, chest)")
|
||||
|
||||
# Add Hand mask modifier
|
||||
if hand_mask_vg:
|
||||
hand_mask_modifier = body_obj.modifiers.new(name="Hand_Mask", type='MASK')
|
||||
hand_mask_modifier.vertex_group = "Hand_Mask"
|
||||
print("Added Hand_Mask modifier (head, arms, chest + hands)")
|
||||
|
||||
print("\nBody masking completed!")
|
||||
print("Main_Mask: Shows head, arms, and chest")
|
||||
print("Hand_Mask: Shows head, arms, chest, and hands")
|
||||
|
||||
# Execute the operation
|
||||
add_body_masks()
|
||||
@@ -0,0 +1,122 @@
|
||||
import bpy
|
||||
import math
|
||||
|
||||
def append_and_parent_device():
|
||||
# First, find and rename the existing Device to Device-Old
|
||||
old_device = bpy.data.objects.get('Device')
|
||||
if old_device:
|
||||
print("Found existing Device, renaming to Device-Old")
|
||||
old_device.name = 'Device-Old'
|
||||
# Store the transforms of the old device
|
||||
old_location = old_device.location.copy()
|
||||
old_rotation = old_device.rotation_euler.copy()
|
||||
old_scale = old_device.scale.copy()
|
||||
old_parent = old_device.parent
|
||||
old_parent_type = old_device.parent_type
|
||||
old_parent_bone = old_device.parent_bone
|
||||
else:
|
||||
print("No existing Device found, using default transforms")
|
||||
# Default transforms if no old device exists
|
||||
old_location = (-0.030083, 0.002195, -0.000632)
|
||||
old_rotation = (math.radians(-89.493), math.radians(0.63873), math.radians(85.309))
|
||||
old_scale = (1.0, 1.0, 1.0)
|
||||
# Get the active armature for default parenting
|
||||
active_armature = bpy.context.active_object
|
||||
if active_armature and active_armature.type == 'ARMATURE':
|
||||
old_parent = active_armature
|
||||
old_parent_type = 'BONE'
|
||||
old_parent_bone = 'DEF-forearm.L'
|
||||
else:
|
||||
old_parent = None
|
||||
old_parent_type = 'OBJECT'
|
||||
old_parent_bone = ''
|
||||
|
||||
# Append the new Device asset
|
||||
device_blend_path = r"A:\1 Amazon_Active_Projects\1 BlenderAssets\Amazon\device_v2.blend"
|
||||
|
||||
# Append the Device object
|
||||
with bpy.data.libraries.load(device_blend_path, link=False) as (data_from, data_to):
|
||||
if 'Device' in data_from.objects:
|
||||
data_to.objects = ['Device']
|
||||
|
||||
# Link the new Device to the current scene
|
||||
if 'Device' in bpy.data.objects:
|
||||
device_obj = bpy.data.objects['Device']
|
||||
bpy.context.collection.objects.link(device_obj)
|
||||
|
||||
# Make it no longer an asset
|
||||
device_obj.asset_clear()
|
||||
|
||||
# Apply the transforms from the old device
|
||||
device_obj.location = old_location
|
||||
device_obj.rotation_euler = old_rotation
|
||||
device_obj.scale = old_scale
|
||||
|
||||
# Apply the parenting from the old device
|
||||
device_obj.parent = old_parent
|
||||
device_obj.parent_type = old_parent_type
|
||||
if old_parent_bone:
|
||||
device_obj.parent_bone = old_parent_bone
|
||||
|
||||
print("Successfully appended new Device with old device transforms")
|
||||
else:
|
||||
print("Error: Device not found in blend file")
|
||||
return
|
||||
|
||||
def rename_device_band():
|
||||
# Find and rename arm-band or armband to device-band
|
||||
band_variants = ['arm-band', 'armband', 'Arm-band', 'Armband', 'ARM-BAND', 'ARMBAND']
|
||||
|
||||
for variant in band_variants:
|
||||
obj = bpy.data.objects.get(variant)
|
||||
if obj:
|
||||
print(f"Found {variant}, renaming to device-band")
|
||||
obj.name = 'device-band'
|
||||
return True
|
||||
|
||||
print("No arm-band or armband object found to rename")
|
||||
return False
|
||||
|
||||
def rename_geometry_data():
|
||||
# Select all geometry objects (meshes, curves, etc.)
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
|
||||
renamed_count = 0
|
||||
skipped_count = 0
|
||||
not_in_view_layer_count = 0
|
||||
|
||||
for obj in bpy.data.objects:
|
||||
if obj.type in ['MESH', 'CURVE', 'SURFACE', 'META']:
|
||||
# Check if object is in current view layer before selecting
|
||||
if obj.name in bpy.context.view_layer.objects:
|
||||
obj.select_set(True)
|
||||
else:
|
||||
not_in_view_layer_count += 1
|
||||
skipped_count += 1
|
||||
# Try to rename the data directly
|
||||
try:
|
||||
if obj.data and obj.data.name != obj.name:
|
||||
# Check if data is shared with other objects
|
||||
data_users = [o for o in bpy.data.objects if o.data == obj.data]
|
||||
if len(data_users) == 1:
|
||||
# Only one user, safe to rename
|
||||
obj.data.name = obj.name
|
||||
renamed_count += 1
|
||||
else:
|
||||
skipped_count += 1
|
||||
except AttributeError:
|
||||
skipped_count += 1
|
||||
|
||||
# Set the first selected object as active (for any remaining operations)
|
||||
selected_objects = [obj for obj in bpy.data.objects if obj.select_get()]
|
||||
if selected_objects:
|
||||
bpy.context.view_layer.objects.active = selected_objects[0]
|
||||
# Now run the operator on the selection
|
||||
bpy.ops.renaming.data_name_from_obj()
|
||||
|
||||
print(f"Renamed {renamed_count} objects, skipped {skipped_count} objects (not in view layer: {not_in_view_layer_count})")
|
||||
|
||||
# Execute all operations
|
||||
append_and_parent_device()
|
||||
rename_device_band()
|
||||
rename_geometry_data()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user