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
@@ -1,14 +1,18 @@
"""AMZN Character Tools - Main addon entry point."""
import bpy
from .ui import OPERATOR_CLASSES, PANEL_CLASSES
from .ui import OPERATOR_CLASSES, PANEL_CLASSES, AMZN_AddonPreferences
CLASSES = (*OPERATOR_CLASSES, *PANEL_CLASSES)
CLASSES = (*OPERATOR_CLASSES, *PANEL_CLASSES, AMZN_AddonPreferences)
def register():
"""Register all addon classes."""
# Set preferences bl_idname to match __package__ (like Rainys_Bulk_Scene_Tools)
AMZN_AddonPreferences.bl_idname = __package__
# Register all classes
for cls in CLASSES:
bpy.utils.register_class(cls)
@@ -16,7 +20,11 @@ def register():
def unregister():
"""Unregister all addon classes."""
for cls in reversed(CLASSES):
bpy.utils.unregister_class(cls)
try:
bpy.utils.unregister_class(cls)
except RuntimeError:
# Class may not have been registered, skip it
pass
if __name__ == "__main__":
@@ -3,7 +3,7 @@ schema_version = "1.0.0"
id = "amzncharactertools"
name = "AMZNCharacterTools"
tagline = "AMZNCharacterTools"
version = "0.9.0"
version = "0.10.1"
type = "add-on"
maintainer = "Nathan Lindsay"
@@ -1,5 +1,23 @@
import bpy
import math
import os
try:
from ..utils import get_addon_preferences
except (ImportError, ValueError):
# Fallback if import fails (e.g., when run as script)
def get_addon_preferences():
test_names = ["bl_ext.vscode_development.AmazonCharacterTools", "amzncharactertools", "AmazonCharacterTools"]
for addon_name in test_names:
addon_prefs = bpy.context.preferences.addons.get(addon_name)
if addon_prefs and hasattr(addon_prefs, 'preferences') and hasattr(addon_prefs.preferences, 'amzn_bsdf_materials_path'):
return addon_prefs.preferences
# Search all addons
for addon_name in bpy.context.preferences.addons.keys():
addon_prefs = bpy.context.preferences.addons.get(addon_name)
if addon_prefs and hasattr(addon_prefs, 'preferences') and hasattr(addon_prefs.preferences, 'amzn_bsdf_materials_path'):
return addon_prefs.preferences
return None
def append_and_parent_device():
# First, find and rename the existing Device to Device-Old
@@ -31,8 +49,17 @@ def append_and_parent_device():
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"
# Get path from addon preferences
prefs = get_addon_preferences()
if not prefs:
print("Error: Could not access addon preferences")
return
device_blend_path = prefs.amzn_device_path
if not device_blend_path or not os.path.exists(device_blend_path):
print(f"Error: Device library path not set or file not found: {device_blend_path}")
return
# Append the Device object
with bpy.data.libraries.load(device_blend_path, link=False) as (data_from, data_to):
@@ -1,9 +1,36 @@
import bpy
import math
import os
try:
from ..utils import get_addon_preferences
except (ImportError, ValueError):
# Fallback if import fails (e.g., when run as script)
def get_addon_preferences():
test_names = ["bl_ext.vscode_development.AmazonCharacterTools", "amzncharactertools", "AmazonCharacterTools"]
for addon_name in test_names:
addon_prefs = bpy.context.preferences.addons.get(addon_name)
if addon_prefs and hasattr(addon_prefs, 'preferences') and hasattr(addon_prefs.preferences, 'amzn_bsdf_materials_path'):
return addon_prefs.preferences
# Search all addons
for addon_name in bpy.context.preferences.addons.keys():
addon_prefs = bpy.context.preferences.addons.get(addon_name)
if addon_prefs and hasattr(addon_prefs, 'preferences') and hasattr(addon_prefs.preferences, 'amzn_bsdf_materials_path'):
return addon_prefs.preferences
return None
def append_and_parent_device():
# Append the Device asset
device_blend_path = r"A:\1 Amazon_Active_Projects\1 BlenderAssets\Amazon\device_v2.blend"
# Get path from addon preferences
prefs = get_addon_preferences()
if not prefs:
print("Error: Could not access addon preferences")
return
device_blend_path = prefs.amzn_device_path
if not device_blend_path or not os.path.exists(device_blend_path):
print(f"Error: Device library path not set or file not found: {device_blend_path}")
return
# Append the Device object
with bpy.data.libraries.load(device_blend_path, link=False) as (data_from, data_to):
@@ -51,8 +78,17 @@ def append_and_parent_device():
print(f"Successfully parented 'Device' to {active_armature.name} bone 'DEF-forearm.L'")
def append_and_parent_finger_scanner():
# Append the Finger-Scanner asset
scanner_blend_path = r"A:\1 Amazon_Active_Projects\1 BlenderAssets\Amazon\amazon-3Dworld-assets_v4.0.blend"
# Get path from addon preferences
prefs = get_addon_preferences()
if not prefs:
print("Error: Could not access addon preferences")
return
scanner_blend_path = prefs.amzn_scanner_assets_path
if not scanner_blend_path or not os.path.exists(scanner_blend_path):
print(f"Error: Scanner assets library path not set or file not found: {scanner_blend_path}")
return
# Append the Finger-Scanner object
with bpy.data.libraries.load(scanner_blend_path, link=False) as (data_from, data_to):
@@ -80,9 +80,17 @@ def create_settings_bone():
# Deselect all bones first
bpy.ops.pose.select_all(action='DESELECT')
# Select the Settings bone
# Select the Settings bone (compatible with Blender 4.2/4.5 LTS and 5.0+)
pose_bone = armature_obj.pose.bones['Settings']
# Blender 5.0+ uses pose_bone.select, older versions use pose_bone.bone.select
if hasattr(pose_bone, 'select'):
# Blender 5.0+
pose_bone.select = True
elif hasattr(pose_bone.bone, 'select'):
# Blender 4.2 LTS, 4.5 LTS
pose_bone.bone.select = True
armature_obj.data.bones.active = pose_bone.bone
# Create widget for the Settings bone
@@ -0,0 +1 @@
"""Operator modules for AMZN Character Tools."""
@@ -1,7 +1,24 @@
import bpy
import os
from mathutils import Matrix
ASSET_BLEND_PATH = r"A:\\1 Amazon_Active_Projects\\1 BlenderAssets\\Amazon\\amazon-asset_Hard-Hat_v1.1.blend"
try:
from ..utils import get_addon_preferences
except (ImportError, ValueError):
# Fallback if import fails (e.g., when run as script)
def get_addon_preferences():
test_names = ["bl_ext.vscode_development.AmazonCharacterTools", "amzncharactertools", "AmazonCharacterTools"]
for addon_name in test_names:
addon_prefs = bpy.context.preferences.addons.get(addon_name)
if addon_prefs and hasattr(addon_prefs, 'preferences') and hasattr(addon_prefs.preferences, 'amzn_bsdf_materials_path'):
return addon_prefs.preferences
# Search all addons
for addon_name in bpy.context.preferences.addons.keys():
addon_prefs = bpy.context.preferences.addons.get(addon_name)
if addon_prefs and hasattr(addon_prefs, 'preferences') and hasattr(addon_prefs.preferences, 'amzn_bsdf_materials_path'):
return addon_prefs.preferences
return None
ASSET_OBJECT_NAME = "hard-hat"
GN_MOD_NAME = "hard-hat-transforms"
@@ -17,7 +34,19 @@ def find_armature_with_head():
def append_hard_hat():
with bpy.data.libraries.load(ASSET_BLEND_PATH, link=False, assets_only=True) as (data_from, data_to):
# Get path from addon preferences
prefs = get_addon_preferences()
if not prefs:
print("Error: Could not access addon preferences")
return None
asset_blend_path = prefs.amzn_hardhat_asset_path
if not asset_blend_path or not os.path.exists(asset_blend_path):
print(f"Error: Hard hat asset library path not set or file not found: {asset_blend_path}")
return None
with bpy.data.libraries.load(asset_blend_path, link=False, assets_only=True) as (data_from, data_to):
if ASSET_OBJECT_NAME in data_from.objects:
data_to.objects = [ASSET_OBJECT_NAME]
else:
@@ -23,7 +23,7 @@ def remap_vector_fonts():
print(f"Found {len(existing_fonts)} existing font(s) starting with '{target_font_name}'")
# Check for numbered variants (.001, .002, etc)
numbered_pattern = re.compile(rf"^{re.escape(target_font_name)}(\.\d{{3}})?$")
numbered_pattern = re.compile(rf"^{re.escape(target_font_name)}(\.\d{{3}})$")
numbered_fonts = []
exact_match = None
@@ -33,35 +33,72 @@ def remap_vector_fonts():
elif numbered_pattern.match(font.name):
numbered_fonts.append(font)
# If we have an exact match, use it
if exact_match:
target_font = exact_match
print(f" Using exact match: '{target_font.name}'")
elif numbered_fonts:
# Sort by the number suffix to find the lowest one
def get_number(font_name):
match = re.search(r"\.(\d{3})$", font_name)
return int(match.group(1)) if match else 0
# Always use the lowest numbered variant as the base, rename it to base name
# If no numbered variants exist, use exact match
def get_number(font_name):
match = re.search(r"\.(\d{3})$", font_name)
return int(match.group(1)) if match else 999999
if numbered_fonts:
# Sort by number to find the lowest numbered variant
numbered_fonts.sort(key=lambda f: get_number(f.name))
base_font = numbered_fonts[0]
# Rename the base font to remove the number suffix
if base_font.name != target_font_name:
print(f" Renaming base font '{base_font.name}' to '{target_font_name}'")
base_font.name = target_font_name
# First, remap the exact match (if it exists) to the lowest numbered variant
# This avoids naming conflicts when we rename the numbered variant
if exact_match and exact_match != base_font:
if exact_match.users > 0:
print(f" Remapping exact match '{exact_match.name}' ({exact_match.users} users) to '{base_font.name}'")
else:
print(f" Remapping unused exact match '{exact_match.name}' to '{base_font.name}'")
try:
exact_match.user_remap(base_font)
except Exception as e:
print(f" Error remapping '{exact_match.name}': {e}")
# Remap all other numbered variants to the base font
for font in numbered_fonts[1:]:
if font.users > 0:
print(f" Remapping '{font.name}' to '{target_font_name}'")
font.user_remap(base_font)
# Now rename the lowest numbered variant to the base name
old_name = base_font.name
if base_font.name != target_font_name:
if base_font.library:
print(f" Warning: Cannot rename linked font '{old_name}' (from {base_font.library.filepath})")
print(f" Using linked font as-is: '{old_name}'")
else:
print(f" Renaming lowest numbered variant '{old_name}' to '{target_font_name}'")
base_font.name = target_font_name
target_font = base_font
# Show linked status if applicable
if target_font.library:
print(f" Target font is linked from: {target_font.library.filepath}")
# 2. Remap all other numbered variants to the base font
other_variants = numbered_fonts[1:]
if other_variants:
print(f" Consolidating {len(other_variants)} other numbered variant(s) to '{target_font_name}'")
for font in other_variants:
if font.users > 0:
print(f" Remapping '{font.name}' ({font.users} users) to '{target_font_name}'")
else:
print(f" Remapping unused variant '{font.name}' to '{target_font_name}'")
try:
font.user_remap(target_font)
except Exception as e:
print(f" Error remapping '{font.name}': {e}")
elif exact_match:
# No numbered variants, just use exact match
target_font = exact_match
if target_font.library:
print(f" Using exact match: '{target_font.name}' ({target_font.users} users) [LINKED from {target_font.library.filepath}]")
else:
print(f" Using exact match: '{target_font.name}' ({target_font.users} users)")
else:
# Use the first one found
# Use the first one found (shouldn't happen, but safety fallback)
target_font = existing_fonts[0]
print(f" Using existing font: '{target_font.name}'")
if target_font.library:
print(f" Using existing font: '{target_font.name}' [LINKED from {target_font.library.filepath}]")
else:
print(f" Using existing font: '{target_font.name}'")
else:
# Try to load from C:\Windows\Fonts
print(f"Target font '{target_font_name}' not found in scene")
@@ -70,41 +107,125 @@ def remap_vector_fonts():
fonts_dir = r"C:\Windows\Fonts"
if os.path.exists(fonts_dir):
# Look for font files that might match
# Try multiple search strategies since font filenames may not match font names exactly
font_files = []
search_terms = [
# Exact match
("amazon", "ember", "heavy"),
# Abbreviated version (AmazonEmber_He.ttf where "He" = "Heavy")
("amazon", "ember", "he"),
# Just ember and heavy (in case filename doesn't have "amazon")
("ember", "heavy"),
# Just ember with "he" abbreviation
("ember", "he"),
# Just "heavy" (in case it's just "Ember Heavy")
("heavy",),
# Just "he" abbreviation
("he",),
]
for file in os.listdir(fonts_dir):
if file.lower().endswith(('.ttf', '.otf', '.ttc')):
# Check if filename contains "Amazon Ember Heavy" or similar
if "amazon" in file.lower() and "ember" in file.lower() and "heavy" in file.lower():
font_files.append(os.path.join(fonts_dir, file))
file_lower = file.lower()
# Try each search strategy
for terms in search_terms:
if all(term in file_lower for term in terms):
full_path = os.path.join(fonts_dir, file)
if full_path not in font_files:
font_files.append(full_path)
break # Found a match, no need to try other strategies for this file
if font_files:
print(f" Found {len(font_files)} potential font file(s):")
for f in font_files:
print(f" - {f}")
# Try to load the first matching font file
font_file = font_files[0]
print(f" Found font file: {font_file}")
try:
target_font = fonts.load(font_file)
# Rename it to the target name
target_font.name = target_font_name
print(f" Successfully loaded '{font_file}' as '{target_font_name}'")
loaded_font = bpy.data.fonts.load(font_file)
# The loaded font might have a different name, so find it in the collection
# and rename it to the target name
if loaded_font:
old_name = loaded_font.name
print(f" Font loaded with name: '{old_name}'")
# Rename to target name
loaded_font.name = target_font_name
# Get the font by the new name to ensure we have the right reference
if target_font_name in bpy.data.fonts:
target_font = bpy.data.fonts[target_font_name]
print(f" Successfully loaded '{font_file}' (was '{old_name}') as '{target_font_name}'")
else:
print(f" Error: Font renamed but not found in collection as '{target_font_name}'")
return
else:
print(f" Error: Font loaded but returned None")
return
except Exception as e:
print(f" Error loading font file '{font_file}': {e}")
else:
# Try loading by name (Blender will search system fonts)
try:
target_font = fonts.load(target_font_name)
target_font.name = target_font_name
print(f" Successfully loaded '{target_font_name}' from system fonts")
except Exception as e:
print(f" Error loading font: {e}")
print(f" Please ensure '{target_font_name}' is available in your system")
import traceback
traceback.print_exc()
return
else:
print(f" No matching font files found in '{fonts_dir}'")
print(f" Listing all font files containing 'ember' or 'heavy' for debugging:")
# List all fonts with ember or heavy for debugging
debug_fonts = []
for file in os.listdir(fonts_dir):
if file.lower().endswith(('.ttf', '.otf', '.ttc')):
if "ember" in file.lower() or "heavy" in file.lower():
debug_fonts.append(file)
if debug_fonts:
for f in sorted(debug_fonts):
print(f" - {f}")
else:
print(f" (none found)")
print(f"\n Error: Could not find 'Amazon Ember Heavy' font file")
print(f" Please ensure the font is installed in Windows Fonts directory")
print(f" Expected filename should contain: 'amazon', 'ember', and 'heavy'")
print(f" Or at least: 'ember' and 'heavy'")
return
else:
print(f" Error: Fonts directory '{fonts_dir}' not found")
return
# Verify the font was actually loaded and is accessible
if target_font_name not in bpy.data.fonts:
print(f" Error: Font '{target_font_name}' was not found in fonts collection after loading")
return
target_font = bpy.data.fonts[target_font_name]
# Check if the loaded font is linked
if target_font.library:
print(f" Note: Font '{target_font_name}' is linked from library: {target_font.library.filepath}")
if not target_font:
print("Error: Could not find or load target font")
return
# Re-validate target font after consolidation (it might have been removed)
# Look up the font by name to ensure it still exists
if target_font_name not in fonts:
print(f"Error: Target font '{target_font_name}' was removed or is not in the fonts collection")
return
# Get the font from the collection to ensure we have a valid reference
target_font = fonts[target_font_name]
# Verify the font datablock is valid
try:
_ = target_font.name
_ = target_font.users
except (ReferenceError, KeyError):
print(f"Error: Target font '{target_font_name}' is not a valid font datablock")
return
# Ensure target font name is correct
if target_font.name != target_font_name:
print(f" Ensuring target font name is '{target_font_name}'")
target_font.name = target_font_name
# Find all text objects and remap their fonts
text_objects = [obj for obj in bpy.data.objects if obj.type == 'FONT']
@@ -122,27 +243,61 @@ def remap_vector_fonts():
else:
skipped_count += 1
# Also remap font datablocks directly (for any other uses)
# Exclude fonts that start with the target name (they're already correct or being handled)
# Check if target font is linked
target_is_linked = target_font.library is not None
if target_is_linked:
print(f"\nTarget font '{target_font_name}' is linked from library: {target_font.library.filepath}")
print(" Remapping local fonts to linked font...")
# Remap all font datablocks directly (regardless of user count)
# Exclude fonts that start with the target name (they're already correct)
# Exclude linked fonts (we shouldn't try to remap fonts in linked libraries)
fonts_to_remap = [
font for font in fonts
if font != target_font
and not font.name.startswith(target_font_name)
and font.users > 0
and font.library is None # Don't remap linked fonts
]
# Count linked fonts that were skipped
linked_fonts_skipped = [
font for font in fonts
if font != target_font
and not font.name.startswith(target_font_name)
and font.library is not None
]
if linked_fonts_skipped:
print(f" Skipping {len(linked_fonts_skipped)} linked font(s) (cannot remap fonts in linked libraries)")
if fonts_to_remap:
print(f"\nRemapping {len(fonts_to_remap)} font datablocks...")
print(f"\nRemapping {len(fonts_to_remap)} local font datablocks...")
for font in fonts_to_remap:
users_before = font.users
if users_before > 0:
try:
font.user_remap(target_font)
users_after = font.users
remapped_users = users_before - users_after
print(f" Remapped '{font.name}': {remapped_users} users")
except Exception as e:
print(f" Error remapping '{font.name}': {e}")
try:
font.user_remap(target_font)
if target_is_linked:
print(f" Remapped '{font.name}' -> '{target_font_name}' (linked font)")
else:
print(f" Remapped '{font.name}' -> '{target_font_name}'")
except Exception as e:
print(f" Error remapping '{font.name}': {e}")
import traceback
traceback.print_exc()
# Final cleanup: remap any remaining unused numbered variants (don't remove them)
numbered_pattern_final = re.compile(rf"^{re.escape(target_font_name)}\.\d{{3}}$")
remaining_variants = [
font for font in fonts
if numbered_pattern_final.match(font.name) and font != target_font and font.users == 0
]
if remaining_variants:
print(f"\nRemapping {len(remaining_variants)} remaining unused numbered variant(s) to '{target_font_name}'...")
for font in remaining_variants:
print(f" Remapping unused variant: '{font.name}'")
try:
font.user_remap(target_font)
except Exception as e:
print(f" Error remapping '{font.name}': {e}")
print(f"\n=== REMAPPING SUMMARY ===")
print(f"Target font: '{target_font.name}'")
@@ -2,10 +2,46 @@ import bpy
import re
import os
try:
from ..utils import get_addon_preferences
except (ImportError, ValueError):
# Fallback if import fails (e.g., when run as script) - try to find addon by searching for our preferences
def get_addon_preferences():
"""Fallback function to get addon preferences."""
# Try extension format first (most likely for vscode_development)
test_names = [
"bl_ext.vscode_development.AmazonCharacterTools",
"amzncharactertools",
"AmazonCharacterTools",
]
for addon_name in test_names:
addon_prefs = bpy.context.preferences.addons.get(addon_name)
if addon_prefs and hasattr(addon_prefs, 'preferences'):
if hasattr(addon_prefs.preferences, 'amzn_bsdf_materials_path'):
return addon_prefs.preferences
# Search all addons for one with our preferences class
for addon_name in bpy.context.preferences.addons.keys():
addon_prefs = bpy.context.preferences.addons.get(addon_name)
if addon_prefs and hasattr(addon_prefs, 'preferences'):
if hasattr(addon_prefs.preferences, 'amzn_bsdf_materials_path'):
return addon_prefs.preferences
return None
def link_bsdf_materials():
"""Link all materials from the BSDF library file"""
library_path = r"A:\1 Amazon_Active_Projects\1 BlenderAssets\Amazon\MATERIALS_BSDF_pallette_v1.0.blend"
# Get path from addon preferences
prefs = get_addon_preferences()
if not prefs:
print("Error: Could not access addon preferences")
return []
library_path = prefs.amzn_bsdf_materials_path
if not library_path:
print("Warning: BSDF materials library path not set in addon preferences")
return []
if not os.path.exists(library_path):
print(f"Warning: Library file not found at {library_path}")
@@ -186,12 +222,132 @@ def remap_missing_datablocks():
print(f"Remapped {remapping_count} missing datablocks to valid linked materials")
return remapping_count
def consolidate_duplicate_target_materials():
"""Consolidate duplicate target materials using datablock-utils operators.
Uses DBU operators to find and merge duplicate materials:
1. Finds similar and duplicate materials
2. Merges the duplicates
"""
print("\n=== CONSOLIDATING DUPLICATE TARGET MATERIALS (using datablock-utils) ===")
# Check if DBU operators are available
if not hasattr(bpy.ops.scene, 'dbu_find_similar_and_duplicates'):
print("Error: datablock-utils addon not available or operators not found")
print(" Please ensure the 'Data-Block Utilities' addon is installed and enabled")
return 0
if not hasattr(bpy.ops.scene, 'dbu_merge_duplicates'):
print("Error: dbu_merge_duplicates operator not found")
return 0
try:
# Try to configure settings for materials
# Check various possible property paths
scene = bpy.context.scene
# Try common property paths for DBU settings
settings_configured = False
if hasattr(scene, 'dbu_similar_settings'):
if hasattr(scene.dbu_similar_settings, 'id_type'):
scene.dbu_similar_settings.id_type = 'MATERIAL'
settings_configured = True
print(" Configured DBU settings: id_type = 'MATERIAL'")
elif hasattr(scene, 'dbu_settings'):
if hasattr(scene.dbu_settings, 'id_type'):
scene.dbu_settings.id_type = 'MATERIAL'
settings_configured = True
print(" Configured DBU settings: id_type = 'MATERIAL'")
if not settings_configured:
print(" Note: Could not find DBU settings property, operators may use defaults")
# Count duplicates before consolidation
def count_duplicates():
"""Count materials with duplicate names."""
material_names = {}
for mat in bpy.data.materials:
if mat.name not in material_names:
material_names[mat.name] = []
material_names[mat.name].append(mat)
duplicates = {name: mats for name, mats in material_names.items() if len(mats) > 1}
return len(duplicates), sum(len(mats) - 1 for mats in duplicates.values())
initial_duplicate_groups, initial_duplicate_count = count_duplicates()
print(f" Initial duplicate groups: {initial_duplicate_groups}, total duplicate materials: {initial_duplicate_count}")
# Call operators multiple times until no more duplicates are found
max_iterations = 10
iteration = 0
total_merged = 0
while iteration < max_iterations:
iteration += 1
print(f"\n Iteration {iteration}:")
# Step 1: Find similar and duplicates
print(" Finding similar and duplicate materials...")
try:
result = bpy.ops.scene.dbu_find_similar_and_duplicates()
if result != {'FINISHED'}:
print(f" Warning: Find duplicates returned: {result}")
except Exception as e:
print(f" Error calling dbu_find_similar_and_duplicates: {e}")
break
# Step 2: Merge duplicates
print(" Merging duplicate materials...")
try:
result = bpy.ops.scene.dbu_merge_duplicates()
if result != {'FINISHED'}:
print(f" Warning: Merge duplicates returned: {result}")
except Exception as e:
print(f" Error calling dbu_merge_duplicates: {e}")
break
# Check if there are still duplicates
remaining_groups, remaining_count = count_duplicates()
print(f" Remaining duplicate groups: {remaining_groups}, remaining duplicates: {remaining_count}")
# If no more duplicates, we're done
if remaining_groups == 0:
print(f" All duplicates merged after {iteration} iteration(s)")
break
# If no progress was made, stop
if iteration > 1 and remaining_count >= initial_duplicate_count:
print(f" No progress made, stopping after {iteration} iteration(s)")
break
initial_duplicate_count = remaining_count
final_groups, final_count = count_duplicates()
if final_groups > 0:
print(f"\n Warning: {final_groups} duplicate groups still remain after consolidation")
print(f" These may be linked materials from different libraries that cannot be automatically merged")
else:
print(f"\n All duplicates successfully consolidated")
print(" Consolidation complete using datablock-utils operators")
return 1
except Exception as e:
print(f" Error during consolidation: {e}")
import traceback
traceback.print_exc()
return 0
def replace_cel_materials():
"""Replace all CEL materials with their BSDF counterparts using Blender's user remapping"""
# First, link BSDF materials from library
linked_materials = link_bsdf_materials()
# Consolidate duplicate target materials FIRST (before any scanning/remapping)
# This ensures all duplicates are merged before we start building material mappings
duplicate_consolidations = consolidate_duplicate_target_materials()
# Then, remap any missing datablocks
missing_remaps = remap_missing_datablocks()
@@ -207,8 +363,11 @@ def replace_cel_materials():
"Wheel-White": "BSDF_WHITE",
"Bag Colors": "BSDF_Bag Colors",
"cardboard": "Package_Cardboard",
"shuttle-cardboard": "Shuttle_Cardboard_1",
"cardboard2": "Shuttle_Cardboard_2",
"wood": "Pallet_Wood",
"Package_Cardboard": "Package_Cardboard",
"Pallet_Wood": "Pallet_Wood",
"blue (triton)": "BSDF_blue-2_TRITON",
"gray (snow)": "BSDF_gray-6_SNOW",
"gray (storm)": "BSDF_gray-2_STORM",
@@ -290,8 +449,13 @@ def replace_cel_materials():
return None
# Helper function to find target material
def find_target_material(name):
"""Find target material (can be linked or local)."""
def find_target_material(name, require_linked=False):
"""Find target material (can be linked or local).
Args:
name: Material name to find
require_linked: If True, only return linked materials (used when remapping same-name materials)
"""
candidates = []
# Try exact match first
if name in exact_material_map:
@@ -304,26 +468,39 @@ def replace_cel_materials():
if not candidates:
return None
# Return the first one (or prefer linked if available)
# Prefer linked materials, or require them if specified
linked = [m for m in candidates if m.library is not None]
if require_linked:
if linked:
return linked[0]
else:
return None # No linked version found
return linked[0] if linked else candidates[0]
# Process custom mappings first
for source_name, target_name in custom_mappings.items():
source_mat = find_best_source_material(source_name)
target_mat = find_target_material(target_name)
# If source and target have the same name, we need a linked target (remapping local -> linked)
require_linked_target = (source_name == target_name)
target_mat = find_target_material(target_name, require_linked=require_linked_target)
if source_mat:
if target_mat:
# Skip if source and target are the same material
if source_mat == target_mat:
print(f"Skipping remapping: '{source_name}' is already '{target_name}'")
print(f"Skipping remapping: '{source_name}' is already '{target_name}' (same material object)")
else:
material_mapping[source_mat] = target_mat
print(f"Found custom mapping: {source_mat.name} ({'local' if source_mat.library is None else 'linked'}) -> {target_mat.name}")
print(f"Found custom mapping: {source_mat.name} ({'local' if source_mat.library is None else 'linked'}) -> {target_mat.name} ({'local' if target_mat.library is None else 'linked'})")
else:
missing_targets.append(f"{source_name} -> {target_name}")
print(f"Warning: Target material '{target_name}' not found for custom mapping '{source_name}'")
if require_linked_target:
print(f"Warning: Target material '{target_name}' not found as linked material for custom mapping '{source_name}' -> '{target_name}'")
print(f" (Need linked version to remap local material to linked)")
else:
missing_targets.append(f"{source_name} -> {target_name}")
print(f"Warning: Target material '{target_name}' not found for custom mapping '{source_name}'")
else:
# Check if there are any materials with this name (even if linked)
if source_name in exact_material_map or source_name.lower() in case_insensitive_map:
@@ -362,11 +539,20 @@ def replace_cel_materials():
continue
print(f"Remapping all users of {source_material.name} to {target_material.name}")
print(f" Source: {source_material.name} (local={source_material.library is None}, users={source_material.users})")
print(f" Target: {target_material.name} (local={target_material.library is None}, users={target_material.users})")
# Verify target is linked when source and target have same name
if source_material.name == target_material.name and target_material.library is None:
print(f" ERROR: Target material '{target_material.name}' is not linked! Cannot remap local->local with same name.")
print(f" Skipping this remapping")
continue
# Get user count before remapping
users_before = source_material.users
users_before_source = source_material.users
users_before_target = target_material.users
if users_before == 0:
if users_before_source == 0:
print(f" Material has no users, skipping remapping")
continue
@@ -376,21 +562,28 @@ def replace_cel_materials():
source_material.user_remap(target_material)
except Exception as e:
print(f" Error during remapping: {e}")
import traceback
traceback.print_exc()
continue
# Get user count after remapping
users_after = source_material.users
users_after_source = source_material.users
users_after_target = target_material.users
replacements_this_material = users_before - users_after
replacements_this_material = users_before_source - users_after_source
replacements_made += replacements_this_material
print(f" Users before: {users_before}, after: {users_after}")
print(f" Source users: {users_before_source} -> {users_after_source}")
print(f" Target users: {users_before_target} -> {users_after_target}")
print(f" Remapped {replacements_this_material} users")
if replacements_this_material == 0 and users_before > 0:
print(f" Warning: Remapping reported 0 users remapped despite {users_before} users before")
if replacements_this_material == 0 and users_before_source > 0:
print(f" Warning: Remapping reported 0 users remapped despite {users_before_source} users before")
print(f" Source material library: {source_material.library}")
print(f" Target material library: {target_material.library}")
elif users_after_target != users_before_target + replacements_this_material:
print(f" Warning: Target user count doesn't match expected increase!")
print(f" Expected target users: {users_before_target + replacements_this_material}, actual: {users_after_target}")
# Optional: Remove unused source materials after remapping
print(f"\nCleaning up unused source materials...")
@@ -409,6 +602,7 @@ def replace_cel_materials():
print(f"Materials linked from library: {len(linked_materials)}")
print(f"Missing datablocks remapped: {missing_remaps}")
print(f"Appended->Linked remappings: {appended_remaps}")
print(f"Duplicate target consolidations: {duplicate_consolidations}")
print(f"Total materials mapped: {len(material_mapping)}")
print(f"Successful mappings: {len(material_mapping)}")
print(f"Total user remappings: {replacements_made}")
@@ -1,39 +0,0 @@
import bpy
# Delete the 'Dual Node Background' world if it exists
if 'Dual Node Background' in bpy.data.worlds:
world_to_delete = bpy.data.worlds['Dual Node Background']
bpy.data.worlds.remove(world_to_delete, do_unlink=True)
# Create new world
new_world = bpy.data.worlds.new(name="World")
# Set world color to pure white (#FFFFFFFF)
new_world.use_nodes = True
nodes = new_world.node_tree.nodes
links = new_world.node_tree.links
# Clear existing nodes
nodes.clear()
# Create background node
background_node = nodes.new(type='ShaderNodeBackground')
background_node.inputs[0].default_value = (1.0, 1.0, 1.0, 1.0) # Pure white RGBA
# Create output node
output_node = nodes.new(type='ShaderNodeOutputWorld')
# Link background to output
links.new(background_node.outputs[0], output_node.inputs[0])
# Set as active world
bpy.context.scene.world = new_world
# Purge orphaned data
bpy.ops.outliner.orphans_purge()
# Enable transparent film rendering
bpy.context.scene.render.film_transparent = True
print("World 'Dual Node Background' deleted and new white world created successfully!")
print("Orphaned data purged and transparent film rendering enabled.")
@@ -1,6 +1,7 @@
"""UI module for AMZN Character Tools."""
from .operators import OPERATOR_CLASSES
from .panels import PANEL_CLASSES
from .preferences import AMZN_AddonPreferences
__all__ = ["OPERATOR_CLASSES", "PANEL_CLASSES"]
__all__ = ["OPERATOR_CLASSES", "PANEL_CLASSES", "AMZN_AddonPreferences"]
@@ -50,15 +50,6 @@ OP_SPECS = [
"panel": "general",
"large": True,
},
{
"name": "WhiteWorld",
"id": "white_world",
"desc": "Removes Dual Node Background world and replaces with pure white world",
"script": "white_world.py",
"button": "White World",
"icon": "WORLD",
"panel": "scene",
},
{
"name": "ApplySubdivWgt",
"id": "apply_subdiv_wgt",
@@ -0,0 +1,63 @@
"""Preferences for AMZN Character Tools."""
import bpy
from bpy.types import AddonPreferences
from bpy.props import StringProperty
class AMZN_AddonPreferences(AddonPreferences):
"""Addon preferences for AMZN Character Tools."""
# bl_idname will be set in __init__.py during registration
# This ensures it uses the correct root package name
bl_idname = "" # Set dynamically
amzn_bsdf_materials_path: StringProperty(
name="BSDF Materials Library",
description="Path to MATERIALS_BSDF_pallette_v1.0.blend",
subtype='FILE_PATH',
default=r"A:\1 Amazon_Active_Projects\1 BlenderAssets\Amazon\MATERIALS_BSDF_pallette_v1.0.blend",
)
amzn_device_path: StringProperty(
name="Device Library",
description="Path to device_v2.blend",
subtype='FILE_PATH',
default=r"A:\1 Amazon_Active_Projects\1 BlenderAssets\Amazon\device_v2.blend",
)
amzn_scanner_assets_path: StringProperty(
name="Scanner Assets Library",
description="Path to amazon-3Dworld-assets_v4.0.blend",
subtype='FILE_PATH',
default=r"A:\1 Amazon_Active_Projects\1 BlenderAssets\Amazon\amazon-3Dworld-assets_v4.0.blend",
)
amzn_hardhat_asset_path: StringProperty(
name="Hard Hat Asset Library",
description="Path to amazon-asset_Hard-Hat_v1.1.blend",
subtype='FILE_PATH',
default=r"A:\1 Amazon_Active_Projects\1 BlenderAssets\Amazon\amazon-asset_Hard-Hat_v1.1.blend",
)
def draw(self, context):
"""Draw the preferences UI."""
layout = self.layout
layout.label(text="Blend File Paths:")
layout.separator()
# BSDF Materials path
row = layout.row()
row.prop(self, "amzn_bsdf_materials_path")
# Device path
row = layout.row()
row.prop(self, "amzn_device_path")
# Scanner assets path
row = layout.row()
row.prop(self, "amzn_scanner_assets_path")
# Hard hat asset path
row = layout.row()
row.prop(self, "amzn_hardhat_asset_path")
@@ -0,0 +1,32 @@
"""Utility functions for AMZN Character Tools."""
import bpy
def get_addon_preferences():
"""Get the addon preferences, finding the correct addon identifier automatically.
Returns:
The addon preferences object, or None if not found.
"""
# Try common identifiers first
test_names = [
"bl_ext.vscode_development.AmazonCharacterTools", # Extension format
"amzncharactertools", # Manifest ID
"AmazonCharacterTools", # Module name
]
for addon_name in test_names:
addon_prefs = bpy.context.preferences.addons.get(addon_name)
if addon_prefs and hasattr(addon_prefs, 'preferences'):
# Verify it's our preferences class
if hasattr(addon_prefs.preferences, 'amzn_bsdf_materials_path'):
return addon_prefs.preferences
# If not found, search all addons for one with our preferences
for addon_name in bpy.context.preferences.addons.keys():
addon_prefs = bpy.context.preferences.addons.get(addon_name)
if addon_prefs and hasattr(addon_prefs, 'preferences'):
if hasattr(addon_prefs.preferences, 'amzn_bsdf_materials_path'):
return addon_prefs.preferences
return None