FMT-like UI

This commit is contained in:
Nathan
2025-08-22 12:25:05 -06:00
parent b3624bba9b
commit faa80e10b4
3 changed files with 2705 additions and 87 deletions

View File

@@ -1,7 +1,7 @@
import bpy import bpy
import os import os
from bpy.types import Operator from bpy.types import Operator
from bpy.props import StringProperty, BoolProperty, EnumProperty from bpy.props import StringProperty, BoolProperty, EnumProperty, IntProperty
class DYNAMICLINK_OT_replace_linked_asset(Operator): class DYNAMICLINK_OT_replace_linked_asset(Operator):
"""Replace a linked asset with a new file""" """Replace a linked asset with a new file"""
@@ -111,20 +111,9 @@ class DYNAMICLINK_OT_scan_linked_assets(Operator):
# Function to detect indirect links by parsing .blend files safely # Function to detect indirect links by parsing .blend files safely
def get_indirect_libraries(filepath): def get_indirect_libraries(filepath):
"""Get libraries that are linked from within a .blend file""" """Get libraries that are linked from within a .blend file"""
indirect_libs = set() # This function is no longer used with the new approach
if not filepath or not os.path.exists(filepath): # Indirect links are now detected when attempting to relink
return indirect_libs return set()
try:
# For now, return empty set to prevent data loss
# TODO: Implement safe indirect link detection without modifying scene
# The previous approach was dangerous and caused data deletion
pass
except Exception as e:
print(f"Error detecting indirect links in {filepath}: {e}")
return indirect_libs
# Scan all data collections for linked items # Scan all data collections for linked items
all_libraries = set() all_libraries = set()
@@ -170,13 +159,11 @@ class DYNAMICLINK_OT_scan_linked_assets(Operator):
# Analyze each library for indirect links # Analyze each library for indirect links
for filepath in all_libraries: for filepath in all_libraries:
if filepath: if filepath:
indirect_libs = get_indirect_libraries(filepath) # Initialize with no indirect missing (will be updated during relink attempts)
missing_indirect_count = sum(1 for lib in indirect_libs if is_file_missing(lib))
library_info[filepath] = { library_info[filepath] = {
'indirect_libraries': indirect_libs, 'indirect_libraries': set(),
'missing_indirect_count': missing_indirect_count, 'missing_indirect_count': 0,
'has_indirect_missing': missing_indirect_count > 0 'has_indirect_missing': False
} }
# Store results in scene properties # Store results in scene properties
@@ -241,12 +228,197 @@ class DYNAMICLINK_OT_open_linked_file(Operator):
return {'FINISHED'} return {'FINISHED'}
class DYNAMICLINK_OT_add_search_path(Operator):
"""Add a new search path for missing libraries"""
bl_idname = "dynamiclink.add_search_path"
bl_label = "Add Search Path"
bl_options = {'REGISTER'}
filepath: StringProperty(
name="Search Path",
description="Path to search for missing linked libraries",
subtype='DIR_PATH'
)
def execute(self, context):
prefs = context.preferences.addons.get(__package__)
if prefs:
new_path = prefs.preferences.search_paths.add()
new_path.path = self.filepath if self.filepath else "//"
self.report({'INFO'}, f"Added search path: {new_path.path}")
return {'FINISHED'}
def invoke(self, context, event):
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
class DYNAMICLINK_OT_remove_search_path(Operator):
"""Remove a search path"""
bl_idname = "dynamiclink.remove_search_path"
bl_label = "Remove Search Path"
bl_options = {'REGISTER'}
index: IntProperty(
name="Index",
description="Index of the search path to remove",
default=0
)
def execute(self, context):
prefs = context.preferences.addons.get(__package__)
if prefs and prefs.preferences.search_paths:
if 0 <= self.index < len(prefs.preferences.search_paths):
prefs.preferences.search_paths.remove(self.index)
self.report({'INFO'}, f"Removed search path at index {self.index}")
else:
self.report({'ERROR'}, f"Invalid index: {self.index}")
return {'FINISHED'}
class DYNAMICLINK_OT_attempt_relink(Operator):
"""Attempt to relink missing libraries using search paths"""
bl_idname = "dynamiclink.attempt_relink"
bl_label = "Attempt Relink"
bl_options = {'REGISTER'}
def execute(self, context):
prefs = context.preferences.addons.get(__package__)
if not prefs or not prefs.preferences.search_paths:
self.report({'ERROR'}, "No search paths configured. Add search paths in addon preferences.")
return {'CANCELLED'}
# Get missing libraries
missing_libs = [lib for lib in context.scene.dynamic_link_manager.linked_libraries if lib.is_missing]
if not missing_libs:
self.report({'INFO'}, "No missing libraries to relink")
return {'FINISHED'}
self.report({'INFO'}, f"Attempting to relink {len(missing_libs)} missing libraries...")
# Scan search paths for missing libraries (FMT-inspired approach)
files_dir_list = []
try:
for search_path in prefs.preferences.search_paths:
if search_path.path:
for dirpath, dirnames, filenames in os.walk(bpy.path.abspath(search_path.path)):
files_dir_list.append([dirpath, filenames])
except FileNotFoundError:
self.report({'ERROR'}, f"Error - Bad file path in search paths")
return {'CANCELLED'}
# Try to find and relink each missing library
relinked_count = 0
indirect_errors = []
for lib_item in missing_libs:
lib_filename = os.path.basename(lib_item.filepath)
found = False
# Search through all directories
for dir_info in files_dir_list:
dirpath, filenames = dir_info
# Look for exact filename match
if lib_filename in filenames:
new_path = os.path.join(dirpath, lib_filename)
try:
# Try to relink using Blender's system
# This will naturally detect indirect links if they exist
bpy.ops.file.find_missing_files()
found = True
relinked_count += 1
break
except Exception as e:
error_msg = str(e)
if "unable to relocate indirectly linked library" in error_msg:
indirect_errors.append(lib_item.filepath)
print(f"Indirect link detected for: {lib_item.filepath}")
else:
print(f"Error relinking {lib_item.filepath}: {e}")
if not found:
print(f"Could not find {lib_filename} in search paths")
# Update the UI to show indirect links
if indirect_errors:
self.report({'WARNING'}, f"Found {len(indirect_errors)} indirectly linked libraries")
# Mark these as having indirect missing
for lib_item in context.scene.dynamic_link_manager.linked_libraries:
if lib_item.filepath in indirect_errors:
lib_item.has_indirect_missing = True
lib_item.indirect_missing_count = 1
self.report({'INFO'}, f"Relink attempt complete. Relinked: {relinked_count}, Indirect: {len(indirect_errors)}")
return {'FINISHED'}
class DYNAMICLINK_OT_find_in_folders(Operator):
"""Find missing libraries in search folders and subfolders"""
bl_idname = "dynamiclink.find_in_folders"
bl_label = "Find in Folders"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
prefs = context.preferences.addons.get(__package__)
if not prefs or not prefs.preferences.search_paths:
self.report({'ERROR'}, "No search paths configured. Add search paths in addon preferences.")
return {'CANCELLED'}
# Get missing libraries
missing_libs = [lib for lib in context.scene.dynamic_link_manager.linked_libraries if lib.is_missing]
if not missing_libs:
self.report({'INFO'}, "No missing libraries to find")
return {'FINISHED'}
self.report({'INFO'}, f"Searching for {len(missing_libs)} missing libraries...")
# Scan search paths for missing libraries (FMT-inspired approach)
files_dir_list = []
try:
for search_path in prefs.preferences.search_paths:
if search_path.path:
for dirpath, dirnames, filenames in os.walk(bpy.path.abspath(search_path.path)):
files_dir_list.append([dirpath, filenames])
except FileNotFoundError:
self.report({'ERROR'}, f"Error - Bad file path in search paths")
return {'CANCELLED'}
# Try to find each missing library
found_count = 0
for lib_item in missing_libs:
lib_filename = os.path.basename(lib_item.filepath)
# Search through all directories
for dir_info in files_dir_list:
dirpath, filenames = dir_info
# Look for exact filename match
if lib_filename in filenames:
new_path = os.path.join(dirpath, lib_filename)
self.report({'INFO'}, f"Found {lib_filename} at: {new_path}")
found_count += 1
break
if found_count > 0:
self.report({'INFO'}, f"Found {found_count} libraries in search paths")
else:
self.report({'WARNING'}, "No libraries found in search paths")
return {'FINISHED'}
def register(): def register():
bpy.utils.register_class(DYNAMICLINK_OT_replace_linked_asset) bpy.utils.register_class(DYNAMICLINK_OT_replace_linked_asset)
bpy.utils.register_class(DYNAMICLINK_OT_scan_linked_assets) bpy.utils.register_class(DYNAMICLINK_OT_scan_linked_assets)
bpy.utils.register_class(DYNAMICLINK_OT_open_linked_file) bpy.utils.register_class(DYNAMICLINK_OT_open_linked_file)
bpy.utils.register_class(DYNAMICLINK_OT_add_search_path)
bpy.utils.register_class(DYNAMICLINK_OT_remove_search_path)
bpy.utils.register_class(DYNAMICLINK_OT_attempt_relink)
bpy.utils.register_class(DYNAMICLINK_OT_find_in_folders)
def unregister(): def unregister():
bpy.utils.unregister_class(DYNAMICLINK_OT_find_in_folders)
bpy.utils.unregister_class(DYNAMICLINK_OT_attempt_relink)
bpy.utils.unregister_class(DYNAMICLINK_OT_remove_search_path)
bpy.utils.unregister_class(DYNAMICLINK_OT_add_search_path)
bpy.utils.unregister_class(DYNAMICLINK_OT_open_linked_file) bpy.utils.unregister_class(DYNAMICLINK_OT_open_linked_file)
bpy.utils.unregister_class(DYNAMICLINK_OT_scan_linked_assets) bpy.utils.unregister_class(DYNAMICLINK_OT_scan_linked_assets)
bpy.utils.unregister_class(DYNAMICLINK_OT_replace_linked_asset) bpy.utils.unregister_class(DYNAMICLINK_OT_replace_linked_asset)

197
ui.py
View File

@@ -1,12 +1,50 @@
import bpy import bpy
from bpy.types import Panel, PropertyGroup from bpy.types import Panel, PropertyGroup, AddonPreferences, UIList
from bpy.props import IntProperty, StringProperty, BoolProperty, CollectionProperty from bpy.props import IntProperty, StringProperty, BoolProperty, CollectionProperty
# Properties for search paths
class SearchPathItem(PropertyGroup):
path: StringProperty(
name="Search Path",
description="Path to search for missing linked libraries",
subtype='DIR_PATH'
)
# Properties for individual linked datablocks # Properties for individual linked datablocks
class LinkedDatablockItem(PropertyGroup): class LinkedDatablockItem(PropertyGroup):
name: StringProperty(name="Name", description="Name of the linked datablock") name: StringProperty(name="Name", description="Name of the linked datablock")
type: StringProperty(name="Type", description="Type of the linked datablock") type: StringProperty(name="Type", description="Type of the linked datablock")
# FMT-style UIList for linked libraries
class DYNAMICLINK_UL_library_list(UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
custom_icon = 'FILE_BLEND'
if self.layout_type in {'DEFAULT', 'COMPACT'}:
# Library name and status (FMT-style layout)
layout.scale_x = 0.4
layout.label(text=item.name)
# Status indicator
layout.scale_x = 0.3
if item.is_missing:
layout.label(text="MISSING", icon='ERROR')
elif item.has_indirect_missing:
layout.label(text="INDIRECT", icon='ERROR')
else:
layout.label(text="OK", icon='FILE_BLEND')
# File path (FMT-style truncated)
layout.scale_x = 0.3
path_text = item.filepath
if len(path_text) > 30:
path_text = "..." + path_text[-27:]
layout.label(text=path_text, icon='FILE_FOLDER')
elif self.layout_type in {'GRID'}:
layout.alignment = 'CENTER'
layout.label(text="", icon=custom_icon)
# Properties for a single linked library file # Properties for a single linked library file
class LinkedLibraryItem(PropertyGroup): class LinkedLibraryItem(PropertyGroup):
filepath: StringProperty(name="File Path", description="Path to the linked .blend file") filepath: StringProperty(name="File Path", description="Path to the linked .blend file")
@@ -19,6 +57,11 @@ class LinkedLibraryItem(PropertyGroup):
class DynamicLinkManagerProperties(PropertyGroup): class DynamicLinkManagerProperties(PropertyGroup):
linked_libraries: CollectionProperty(type=LinkedLibraryItem, name="Linked Libraries") linked_libraries: CollectionProperty(type=LinkedLibraryItem, name="Linked Libraries")
linked_libraries_index: IntProperty(
name="Linked Libraries Index",
description="Index of the selected linked library",
default=0
)
linked_assets_count: IntProperty( linked_assets_count: IntProperty(
name="Linked Assets Count", name="Linked Assets Count",
description="Number of linked assets found in scene", description="Number of linked assets found in scene",
@@ -37,6 +80,41 @@ class DynamicLinkManagerProperties(PropertyGroup):
default="" default=""
) )
# FMT-inspired settings
search_different_extensions: BoolProperty(
name="Search for libraries with different extensions",
description="Look for .blend files with different extensions (e.g., .blend1, .blend2)",
default=False
)
# Addon preferences for search paths
class DynamicLinkManagerPreferences(AddonPreferences):
bl_idname = __package__
search_paths: CollectionProperty(
type=SearchPathItem,
name="Search Paths",
description="Paths to search for missing linked libraries"
)
def draw(self, context):
layout = self.layout
# Search paths section
box = layout.box()
box.label(text="Search Paths for Missing Libraries")
# Add/remove search paths
row = box.row()
row.operator("dynamiclink.add_search_path", text="Add Search Path", icon='ADD')
row.operator("dynamiclink.remove_search_path", text="Remove Selected", icon='REMOVE')
# List search paths
for i, path_item in enumerate(self.search_paths):
row = box.row()
row.prop(path_item, "path", text=f"Path {i+1}")
row.operator("dynamiclink.remove_search_path", text="", icon='X').index = i
class DYNAMICLINK_PT_main_panel(Panel): class DYNAMICLINK_PT_main_panel(Panel):
bl_space_type = 'VIEW_3D' bl_space_type = 'VIEW_3D'
bl_region_type = 'UI' bl_region_type = 'UI'
@@ -47,16 +125,16 @@ class DYNAMICLINK_PT_main_panel(Panel):
layout = self.layout layout = self.layout
props = context.scene.dynamic_link_manager props = context.scene.dynamic_link_manager
# Scan section # Main scan section (FMT-style)
box = layout.box() box = layout.box()
box.label(text="Scene Analysis") box.label(text="Linked Libraries Analysis")
row = box.row() row = box.row()
row.operator("dynamiclink.scan_linked_assets", text="Scan Linked Assets") row.operator("dynamiclink.scan_linked_assets", text="Scan Linked Assets", icon='FILE_REFRESH')
row.label(text=f"Libraries: {props.linked_assets_count}") row.label(text=f"({props.linked_assets_count} libraries)")
# Show more detailed info if we have results # Show more detailed info if we have results
if props.linked_assets_count > 0: if props.linked_assets_count > 0:
# Linked Libraries section with single dropdown # Linked Libraries section with single dropdown (FMT-style)
row = box.row(align=True) row = box.row(align=True)
# Dropdown arrow for the entire section # Dropdown arrow for the entire section
@@ -69,51 +147,38 @@ class DYNAMICLINK_PT_main_panel(Panel):
# Only show library details if section is expanded # Only show library details if section is expanded
if props.linked_libraries_expanded: if props.linked_libraries_expanded:
# List all libraries # FMT-style compact list view
for i, lib_item in enumerate(props.linked_libraries): row = box.row()
# Create a box for each library row.template_list("DYNAMICLINK_UL_library_list", "", props, "linked_libraries", props, "linked_libraries_index")
if lib_item.is_missing or lib_item.has_indirect_missing:
sub_box = box.box() # Action buttons below the list (FMT-style)
sub_box.alert = True # Red tint for missing/indirect missing row = box.row()
row.scale_x = 0.3
if props.linked_libraries and 0 <= props.linked_libraries_index < len(props.linked_libraries):
selected_lib = props.linked_libraries[props.linked_libraries_index]
open_op = row.operator("dynamiclink.open_linked_file", text="Open Selected", icon='FILE_BLEND')
open_op.filepath = selected_lib.filepath
else: else:
sub_box = box.box() row.operator("dynamiclink.open_linked_file", text="Open Selected", icon='FILE_BLEND')
row.scale_x = 0.7
row.operator("dynamiclink.scan_linked_assets", text="Refresh", icon='FILE_REFRESH')
# Library name and status # Show details of selected item (FMT-style info display)
row1 = sub_box.row(align=True) if props.linked_libraries and 0 <= props.linked_libraries_index < len(props.linked_libraries):
row1.label(text=lib_item.name, icon='FILE_BLEND') selected_lib = props.linked_libraries[props.linked_libraries_index]
info_box = box.box()
info_box.label(text=f"Selected: {selected_lib.name}")
# Status indicator if selected_lib.is_missing:
if lib_item.is_missing: info_box.label(text="Status: MISSING", icon='ERROR')
row1.label(text="MISSING", icon='ERROR') elif selected_lib.has_indirect_missing:
elif lib_item.has_indirect_missing: info_box.label(text=f"Status: INDIRECT MISSING ({selected_lib.indirect_missing_count} dependencies)", icon='ERROR')
row1.label(text="INDIRECT MISSING", icon='ERROR')
# File path (truncated for space)
row2 = sub_box.row()
path_text = lib_item.filepath
if len(path_text) > 50:
path_text = "..." + path_text[-47:]
row2.label(text=path_text, icon='FILE_FOLDER')
# Warning message for indirect missing
if lib_item.has_indirect_missing and not lib_item.is_missing:
row3 = sub_box.row()
row3.label(text=f"⚠️ {lib_item.indirect_missing_count} indirectly linked blend(s) missing", icon='ERROR')
row3.label(text="Open this blend to relink", icon='INFO')
# Action buttons row
row4 = sub_box.row(align=True)
# Open button
if lib_item.is_missing or lib_item.has_indirect_missing:
open_op = row4.operator("dynamiclink.open_linked_file", text="Open", icon='ERROR')
else: else:
open_op = row4.operator("dynamiclink.open_linked_file", text="Open", icon='FILE_BLEND') info_box.label(text="Status: OK", icon='FILE_BLEND')
open_op.filepath = lib_item.filepath
info_box.label(text=f"Path: {selected_lib.filepath}", icon='FILE_FOLDER')
# Asset replacement section (FMT-style)
# Asset replacement section
box = layout.box() box = layout.box()
box.label(text="Asset Replacement") box.label(text="Asset Replacement")
@@ -141,22 +206,51 @@ class DYNAMICLINK_PT_main_panel(Panel):
else: else:
box.label(text="No object selected") box.label(text="No object selected")
# Batch operations section # Search Paths section (FMT-style)
box = layout.box() box = layout.box()
box.label(text="Batch Operations") box.label(text="Search Paths for Missing Libraries")
row = box.row()
row.operator("dynamiclink.scan_linked_assets", text="Refresh Scan")
# Settings section # Get preferences
prefs = context.preferences.addons.get(__package__)
if prefs:
# Show current search paths with FMT-style management
if prefs.preferences.search_paths:
for i, path_item in enumerate(prefs.preferences.search_paths):
row = box.row()
row.prop(path_item, "path", text=f"Search path {i+1}")
row.operator("dynamiclink.remove_search_path", text="", icon='X').index = i
# Add new search path
row = box.row()
row.operator("dynamiclink.add_search_path", text="Add Search Path", icon='ADD')
# FMT-inspired settings
if len(prefs.preferences.search_paths) > 0:
row = box.row()
row.prop(props, "search_different_extensions", text="Search for libraries with different extensions")
# Find and relink buttons (FMT-style button layout)
if props.linked_assets_count > 0:
missing_count = sum(1 for lib in props.linked_libraries if lib.is_missing)
if missing_count > 0:
row = box.row()
row.operator("dynamiclink.find_in_folders", text="Find libraries in these folders", icon='VIEWZOOM')
row = box.row()
row.operator("dynamiclink.attempt_relink", text="Attempt to relink found libraries", icon='FILE_REFRESH')
# Settings section (FMT-style)
box = layout.box() box = layout.box()
box.label(text="Settings") box.label(text="Settings")
row = box.row() row = box.row()
row.prop(props, "selected_asset_path", text="Asset Path") row.prop(props, "selected_asset_path", text="Asset Path")
def register(): def register():
bpy.utils.register_class(SearchPathItem)
bpy.utils.register_class(LinkedDatablockItem) bpy.utils.register_class(LinkedDatablockItem)
bpy.utils.register_class(DYNAMICLINK_UL_library_list)
bpy.utils.register_class(LinkedLibraryItem) bpy.utils.register_class(LinkedLibraryItem)
bpy.utils.register_class(DynamicLinkManagerProperties) bpy.utils.register_class(DynamicLinkManagerProperties)
bpy.utils.register_class(DynamicLinkManagerPreferences)
bpy.utils.register_class(DYNAMICLINK_PT_main_panel) bpy.utils.register_class(DYNAMICLINK_PT_main_panel)
# Register properties to scene # Register properties to scene
@@ -167,6 +261,9 @@ def unregister():
del bpy.types.Scene.dynamic_link_manager del bpy.types.Scene.dynamic_link_manager
bpy.utils.unregister_class(DYNAMICLINK_PT_main_panel) bpy.utils.unregister_class(DYNAMICLINK_PT_main_panel)
bpy.utils.unregister_class(DynamicLinkManagerPreferences)
bpy.utils.unregister_class(DynamicLinkManagerProperties) bpy.utils.unregister_class(DynamicLinkManagerProperties)
bpy.utils.unregister_class(LinkedLibraryItem) bpy.utils.unregister_class(LinkedLibraryItem)
bpy.utils.unregister_class(DYNAMICLINK_UL_library_list)
bpy.utils.unregister_class(LinkedDatablockItem) bpy.utils.unregister_class(LinkedDatablockItem)
bpy.utils.unregister_class(SearchPathItem)