2026-02-16

This commit is contained in:
2026-03-17 15:25:32 -06:00
parent d5dd373de0
commit 60100fbab2
560 changed files with 33397 additions and 20776 deletions
@@ -328,6 +328,8 @@ class ATOMIC_OT_inspect_materials(bpy.types.Operator):
# user lists
users_objects = []
users_brushes = []
users_node_groups = []
def draw(self, context):
global inspection_update_trigger
@@ -349,13 +351,35 @@ class ATOMIC_OT_inspect_materials(bpy.types.Operator):
if atom.materials_field in bpy.data.materials.keys():
self.users_objects = \
users.material_objects(atom.materials_field)
self.users_brushes = \
users.material_brushes(atom.materials_field)
self.users_node_groups = \
users.material_node_groups_list(atom.materials_field)
# if key is invalid, empty the user lists
else:
self.users_objects = []
self.users_brushes = []
self.users_node_groups = []
inspection_update_trigger = False
# brushes box list
ui_layouts.box_list(
layout=layout,
title="Brushes",
items=self.users_brushes,
icon="BRUSH_DATA"
)
# node groups box list
ui_layouts.box_list(
layout=layout,
title="Node Groups",
items=self.users_node_groups,
icon="NODETREE"
)
# objects box list
ui_layouts.box_list_diverse(
layout=layout,
@@ -57,6 +57,27 @@ class ATOMIC_PT_main_panel(bpy.types.Panel):
atom.worlds
]
# Progress display section (only visible when operation is running)
if atom.is_operation_running:
box = layout.box()
col = box.column(align=True)
# Progress bar with percentage (Blender shows percentage in the bar with PERCENTAGE subtype)
progress_row = col.row(align=True)
progress_row.scale_y = 1.5
progress_row.prop(atom, "operation_progress", text="", slider=True)
# Status text
if atom.operation_status:
col.label(text=atom.operation_status, icon='TIME')
# Cancel button
row = col.row()
row.scale_y = 1.5
row.operator("atomic.cancel_operation", text="Cancel", icon='X')
layout.separator()
# nuke and clean buttons
row = layout.row(align=True)
row.scale_y = 2.0
@@ -160,10 +181,12 @@ class ATOMIC_PT_main_panel(bpy.types.Panel):
# right column
col = split.column(align=True)
# images buttons
splitcol = col.split(factor=0.8, align=True)
splitcol.prop(
# images buttons (deep scan checkbox, images checkbox, inspect button)
# Standard split layout for images (matches other categories)
images_split = col.split(factor=0.8, align=True)
# Images checkbox (will be slightly offset due to deep scan, but inspect aligns)
images_split.prop(
atom,
"images",
text="Images",
@@ -171,7 +194,8 @@ class ATOMIC_PT_main_panel(bpy.types.Panel):
icon='IMAGE_DATA'
)
splitcol.operator(
# Inspect button (right, aligns with other inspect buttons)
images_split.operator(
"atomic.inspect_images",
icon='VIEWZOOM',
text=""
@@ -267,7 +291,11 @@ class ATOMIC_PT_main_panel(bpy.types.Panel):
icon='RESTRICT_SELECT_OFF'
)
# Cache and missing file management
row = layout.row(align=True)
row.operator("atomic.clear_cache", text="Clear Cache", icon="FILE_REFRESH")
row.operator("atomic.detect_missing", text="Detect Missing", icon="LIBRARY_DATA_DIRECT")
reg_list = [ATOMIC_PT_main_panel]
@@ -31,6 +31,41 @@ from .. import config
from ..stats import missing
from .utils import ui_layouts
# Module-level state for detect missing operator instance
_detect_missing_operator_instance = None
def _warp_cursor_to_area_center(context, prefer_area_type="VIEW_3D") -> None:
"""Best-effort: move cursor to area center so popups appear centered.
Blender's popup placement is often tied to the last event mouse position.
Warping is a hack, but it's the only reliable way to 'center' popups in some contexts.
"""
win = getattr(context, "window", None)
screen = getattr(context, "screen", None)
if win is None or screen is None:
return
area = None
for a in screen.areas:
if a.type == prefer_area_type:
area = a
break
if area is None and screen.areas:
area = screen.areas[0]
if area is None:
return
try:
# cursor_warp expects WINDOW-relative coordinates (0,0 at window bottom-left),
# not OS desktop coordinates. Warping to the window center is the most
# reliable option across layouts.
x = int(win.width / 2)
y = int(win.height / 2)
win.cursor_warp(x, y)
except Exception:
pass
# Atomic Data Manager Detect Missing Files Popup
class ATOMIC_OT_detect_missing(bpy.types.Operator):
@@ -42,38 +77,6 @@ class ATOMIC_OT_detect_missing(bpy.types.Operator):
missing_images = []
missing_libraries = []
# missing file recovery option enum property
recovery_option: bpy.props.EnumProperty(
items=[
(
'IGNORE',
'Ignore Missing Files',
'Ignore the missing files and leave them offline'
),
(
'RELOAD',
'Reload Missing Files',
'Reload the missing files from their existing file paths'
),
(
'REMOVE',
'Remove Missing Files',
'Remove the missing files from the project'
),
(
'SEARCH',
'Search for Missing Files (under development)',
'Search for the missing files in a directory'
),
(
'REPLACE',
'Specify Replacement Files (under development)',
'Replace missing files with new files'
),
],
default='IGNORE'
)
def draw(self, context):
layout = self.layout
@@ -109,12 +112,20 @@ class ATOMIC_OT_detect_missing(bpy.types.Operator):
row = layout.separator() # extra space
# recovery option selection
# recovery option buttons
row = layout.row()
row.label(text="What would you like to do?")
row = layout.row()
row.prop(self, 'recovery_option', text="")
row.scale_y = 1.5
op_reload = row.operator("atomic.reload_missing", text="Reload", icon="FILE_REFRESH")
op_remove = row.operator("atomic.remove_missing", text="Remove", icon="TRASH")
op_search = row.operator("atomic.search_missing", text="Search", icon="VIEWZOOM")
op_replace = row.operator("atomic.replace_missing", text="Replace", icon="FILEBROWSER")
# Refresh button
row = layout.row()
refresh_op = row.operator("atomic.detect_missing_refresh", text="Refresh", icon="FILE_REFRESH")
# missing files interface if no missing files are found
else:
@@ -129,54 +140,89 @@ class ATOMIC_OT_detect_missing(bpy.types.Operator):
row = layout.separator() # extra space
def execute(self, context):
# ignore missing files will take no action
# reload missing files
if self.recovery_option == 'RELOAD':
bpy.ops.atomic.reload_missing('INVOKE_DEFAULT')
# remove missing files
elif self.recovery_option == 'REMOVE':
bpy.ops.atomic.remove_missing('INVOKE_DEFAULT')
# search for missing files
elif self.recovery_option == 'SEARCH':
bpy.ops.atomic.search_missing('INVOKE_DEFAULT')
# replace missing files
elif self.recovery_option == 'REPLACE':
bpy.ops.atomic.replace_missing('INVOKE_DEFAULT')
# Buttons now directly invoke operators, so execute just closes the dialog
# IGNORE is the default behavior (no action taken)
return {'FINISHED'}
def invoke(self, context, event):
# update missing file lists
global _detect_missing_operator_instance
# Store operator instance for refresh functionality
_detect_missing_operator_instance = self
# Always refresh missing file lists when invoked
self.missing_images = missing.images()
self.missing_libraries = missing.libraries()
wm = context.window_manager
# Force popup placement to screen center (see BlenderArtists reference).
_warp_cursor_to_area_center(context)
# invoke large dialog if there are missing files
if self.missing_images or self.missing_libraries:
return wm.invoke_props_dialog(self, width=500)
# invoke small dialog if there are no missing files
else:
return wm.invoke_popup(self, width=300)
return wm.invoke_props_dialog(self, width=300)
@persistent
def autodetect_missing_files(dummy=None):
# invokes the detect missing popup when missing files are detected upon
# loading a new Blender project
# Use a timer to defer the operator call since load_post handlers
# cannot directly invoke operators that modify data
if config.enable_missing_file_warning and \
(missing.images() or missing.libraries()):
def invoke_detect_missing():
try:
bpy.ops.atomic.detect_missing('INVOKE_DEFAULT')
except RuntimeError:
# If still in invalid context, ignore (will be handled on next user action)
pass
return None # Run once
bpy.app.timers.register(invoke_detect_missing, first_interval=0.1)
# Refresh operator for missing file detection
class ATOMIC_OT_detect_missing_refresh(bpy.types.Operator):
"""Refresh missing file detection"""
bl_idname = "atomic.detect_missing_refresh"
bl_label = "Refresh Missing Files"
bl_options = {'INTERNAL'}
def execute(self, context):
global _detect_missing_operator_instance
# Update the stored operator instance if it exists and is valid
if _detect_missing_operator_instance is not None:
try:
# Check if operator instance is still valid
_ = _detect_missing_operator_instance.bl_idname
# Update the missing file lists
_detect_missing_operator_instance.missing_images = missing.images()
_detect_missing_operator_instance.missing_libraries = missing.libraries()
# Redraw all areas to refresh the dialog
for area in context.screen.areas:
area.tag_redraw()
self.report({'INFO'}, "Missing files list refreshed")
return {'FINISHED'}
except (ReferenceError, AttributeError, TypeError):
# Operator instance invalidated, clear it
_detect_missing_operator_instance = None
# If no valid instance, invoke a new dialog
bpy.ops.atomic.detect_missing('INVOKE_DEFAULT')
return {'FINISHED'}
reg_list = [ATOMIC_OT_detect_missing]
reg_list = [ATOMIC_OT_detect_missing, ATOMIC_OT_detect_missing_refresh]
def register():
@@ -24,11 +24,41 @@ some functions for syncing the preference properties with external factors.
"""
import bpy
import os
from bpy.utils import register_class
from ..utils import compat
from .. import config
import sys
# updater removed in Blender 4.5 extension format
# Get the root module name dynamically
def _get_addon_module_name():
"""Get the root addon module name for bl_idname."""
# In Blender 5.0 extensions loaded via VSCode, the module name is the full path
# e.g., "bl_ext.vscode_development.atomic_data_manager"
# We need to get it from the parent package (atomic_data_manager)
try:
# Get parent package name from __package__ (remove .ui suffix)
if __package__:
parent_pkg = __package__.rsplit('.', 1)[0] if '.' in __package__ else __package__
# Get the actual module from sys.modules to get its __name__
parent_module = sys.modules.get(parent_pkg)
if parent_module and hasattr(parent_module, '__name__'):
module_name = parent_module.__name__
config.debug_print(f"[Atomic Debug] Using parent module __name__ as bl_idname: {module_name}")
return module_name
else:
# Use the package name directly
config.debug_print(f"[Atomic Debug] Using parent package name as bl_idname: {parent_pkg}")
return parent_pkg
except Exception as e:
config.debug_print(f"[Atomic Debug] Could not get parent module name: {e}")
# Last fallback
module_name = "atomic_data_manager"
config.debug_print(f"[Atomic Debug] Using fallback bl_idname: {module_name}")
return module_name
def _get_addon_prefs():
# robustly find our AddonPreferences instance regardless of module name
@@ -106,6 +136,9 @@ def copy_prefs_to_config(self, context):
config.include_fake_users = \
atomic_preferences.include_fake_users
config.enable_debug_prints = \
atomic_preferences.enable_debug_prints
# hidden atomic preferences
config.pie_menu_type = \
atomic_preferences.pie_menu_type
@@ -192,7 +225,9 @@ def remove_pie_menu_hotkeys():
# Atomic Data Manager Preference Panel UI
class ATOMIC_PT_preferences_panel(bpy.types.AddonPreferences):
bl_idname = "atomic_data_manager"
# bl_idname must match the add-on's module name exactly
# Get it dynamically to ensure it matches what Blender registered
bl_idname = _get_addon_module_name()
# visible atomic preferences
enable_missing_file_warning: bpy.props.BoolProperty(
@@ -214,6 +249,11 @@ class ATOMIC_PT_preferences_panel(bpy.types.AddonPreferences):
update=update_pie_menu_hotkeys
)
enable_debug_prints: bpy.props.BoolProperty(
description="Enable debug print statements in the console",
default=False
)
# hidden atomic preferences
pie_menu_type: bpy.props.StringProperty(
default="D"
@@ -243,6 +283,9 @@ class ATOMIC_PT_preferences_panel(bpy.types.AddonPreferences):
def draw(self, context):
layout = self.layout
# Debug: verify draw is being called
config.debug_print("[Atomic Debug] Preferences draw() method called")
split = layout.split()
@@ -266,6 +309,13 @@ class ATOMIC_PT_preferences_panel(bpy.types.AddonPreferences):
text="Include Fake Users"
)
# enable debug prints toggle
col.prop(
self,
"enable_debug_prints",
text="Enable Debug Prints"
)
# pie menu settings
pie_split = col.split(factor=0.55) # nice
@@ -317,7 +367,13 @@ keymaps = []
def register():
for cls in reg_list:
register_class(cls)
try:
register_class(cls)
config.debug_print(f"[Atomic Debug] Registered preferences class: {cls.__name__} with bl_idname: {cls.bl_idname}")
except Exception as e:
print(f"[Atomic Error] Failed to register preferences class {cls.__name__}: {e}")
import traceback
traceback.print_exc()
# make sure global preferences are updated on registration
copy_prefs_to_config(None, None)
@@ -362,7 +362,6 @@ class ATOMIC_PT_stats_panel(bpy.types.Panel):
text="Unnamed: {0}".format(count.worlds_unnamed())
)
reg_list = [ATOMIC_PT_stats_panel]