Files
Text-Texture-Generator-for-…/__init__.py
2025-09-10 14:36:36 +03:00

6990 lines
313 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import bpy
import bmesh
from bpy.types import Operator, Panel, PropertyGroup, UIList
from bpy.props import StringProperty, IntProperty, FloatVectorProperty, FloatProperty, BoolProperty, EnumProperty, CollectionProperty, PointerProperty
import os
import tempfile
import subprocess
import sys
import json
import platform
import time
# Addon info
bl_info = {
"name": "Text Texture Generator",
"author": "Marc Mintel <marc@mintel.me>",
"version": (2, 3, 2),
"blender": (4, 0, 0),
"location": "3D View > Sidebar (N) > Tool Tab | Shader Editor > Sidebar > Tool Tab",
"description": "Generate image textures from text with accurate dimensions and instant live preview",
"category": "Material",
}
# ============================================================================
# UTILITY FUNCTIONS
# ============================================================================
def install_pillow():
"""Install Pillow if not available"""
try:
import PIL
except ImportError:
print("Installing Pillow...")
subprocess.check_call([sys.executable, "-m", "pip", "install", "pillow>=10.0.0"])
def get_system_fonts():
"""Get detailed list of available system fonts with paths"""
try:
from PIL import ImageFont
import glob
fonts = {}
system = platform.system()
if system == "Windows":
font_dirs = [
"C:/Windows/Fonts/",
os.path.expanduser("~/AppData/Local/Microsoft/Windows/Fonts/")
]
extensions = ["*.ttf", "*.ttc", "*.otf"]
elif system == "Darwin": # macOS
font_dirs = [
"/System/Library/Fonts/",
"/Library/Fonts/",
os.path.expanduser("~/Library/Fonts/")
]
extensions = ["*.ttf", "*.ttc", "*.otf", "*.dfont"]
else: # Linux
font_dirs = [
"/usr/share/fonts/",
"/usr/local/share/fonts/",
os.path.expanduser("~/.local/share/fonts/"),
os.path.expanduser("~/.fonts/")
]
extensions = ["*.ttf", "*.ttc", "*.otf"]
for font_dir in font_dirs:
if os.path.exists(font_dir):
for ext in extensions:
for font_path in glob.glob(os.path.join(font_dir, "**", ext), recursive=True):
try:
font_name = os.path.splitext(os.path.basename(font_path))[0]
font_name = font_name.replace("-", " ").replace("_", " ")
fonts[font_name] = font_path
except Exception:
continue
return fonts
except ImportError:
return {}
def get_font_enum_items(self, context):
"""Dynamic enum items for font selection"""
items = [("default", "Default Font", "Use system default font", 0)]
if not hasattr(get_font_enum_items, 'cached_fonts'):
get_font_enum_items.cached_fonts = get_system_fonts()
fonts = get_font_enum_items.cached_fonts
for i, (font_name, font_path) in enumerate(sorted(fonts.items())[:50]):
items.append((font_path, font_name, f"Font: {font_name}", i + 1))
return items
# ============================================================================
# PRESET STORAGE SYSTEM - V2.0 (PERSISTENT STORAGE)
# ============================================================================
#
# The Text Texture Generator now uses a THREE-TIER preset storage system
# that ensures preset persistence across addon updates:
#
# 1. BLEND FILE STORAGE (Highest Priority)
# - Presets travel with the .blend file
# - Perfect for project-specific presets
# - Stored in scene custom properties
#
# 2. PERSISTENT FILE STORAGE (Medium Priority)
# - Survives addon updates and Blender upgrades
# - Stored in Blender's user config directory
# - Shared across all blend files
#
# 3. LEGACY FILE STORAGE (Lowest Priority)
# - Old system for backward compatibility
# - Vulnerable to addon updates
# - Will be automatically migrated to persistent storage
#
# MIGRATION SYSTEM:
# - Automatically detects addon version changes
# - Migrates legacy presets to persistent storage
# - Creates backups before migration
# - Provides detailed migration reports
#
# IMPORT/EXPORT SYSTEM:
# - Export all presets for manual backup
# - Import presets with conflict resolution
# - Supports cross-machine preset sharing
#
# ============================================================================
def get_preset_path():
"""Get path for storing presets (legacy local storage)
LEGACY SYSTEM: This storage location is vulnerable to addon updates.
Presets stored here will be lost when the addon is updated through
Blender's Add-ons preferences. This function is maintained for
backward compatibility only.
For new installations, use get_persistent_preset_path() instead.
"""
addon_dir = os.path.dirname(os.path.realpath(__file__))
presets_dir = os.path.join(addon_dir, "presets")
if not os.path.exists(presets_dir):
try:
os.makedirs(presets_dir)
except OSError as e:
print(f"Error creating presets directory: {e}")
return presets_dir
def get_persistent_preset_path():
"""Get persistent path for storing presets (survives addon updates)"""
import bpy
# Get Blender's user config directory
config_dir = bpy.utils.user_resource('CONFIG')
if not config_dir:
# Fallback to addon directory if config path unavailable
print("[TTG] Warning: Could not get user config directory, falling back to addon directory")
return get_preset_path()
# Create addon-specific subdirectory
persistent_dir = os.path.join(config_dir, "text_texture_generator", "presets")
try:
if not os.path.exists(persistent_dir):
os.makedirs(persistent_dir, exist_ok=True)
return persistent_dir
except OSError as e:
print(f"[TTG] Error creating persistent presets directory: {e}")
# Fallback to addon directory
return get_preset_path()
def get_addon_version():
"""Get current addon version for migration tracking"""
return bl_info["version"]
def get_version_file_path():
"""Get path to version tracking file"""
persistent_dir = os.path.dirname(get_persistent_preset_path())
return os.path.join(persistent_dir, "addon_version.json")
def get_stored_version():
"""Get previously stored addon version"""
version_file = get_version_file_path()
try:
if os.path.exists(version_file):
with open(version_file, 'r') as f:
data = json.load(f)
return tuple(data.get('version', (0, 0, 0)))
except (json.JSONDecodeError, OSError) as e:
print(f"[TTG] Error reading version file: {e}")
return (0, 0, 0)
def save_current_version():
"""Save current addon version to persistent storage"""
version_file = get_version_file_path()
try:
version_data = {
'version': get_addon_version(),
'timestamp': time.time()
}
with open(version_file, 'w') as f:
json.dump(version_data, f, indent=2)
print(f"[TTG] Saved addon version {get_addon_version()} to persistent storage")
except (OSError, json.JSONEncodeError) as e:
print(f"[TTG] Error saving version file: {e}")
def migrate_presets_if_needed():
"""Migrate presets from old location to persistent storage if needed"""
current_version = get_addon_version()
stored_version = get_stored_version()
print(f"[TTG] Version check - Current: {current_version}, Stored: {stored_version}")
# Check if this is a fresh installation or version change
if stored_version == (0, 0, 0) or current_version != stored_version:
print(f"[TTG] Version change detected, checking for preset migration...")
# Get paths
old_preset_dir = get_preset_path() # Legacy addon directory
new_preset_dir = get_persistent_preset_path() # Persistent location
migrated_count = 0
migration_errors = []
# Migrate from old addon directory if it exists and has presets
if os.path.exists(old_preset_dir) and old_preset_dir != new_preset_dir:
print(f"[TTG] Migrating presets from {old_preset_dir} to {new_preset_dir}")
try:
# Create backup before migration
backup_path = backup_presets()
if backup_path:
print(f"[TTG] Created migration backup at {backup_path}")
for filename in os.listdir(old_preset_dir):
if filename.endswith('.json'):
preset_name = os.path.splitext(filename)[0]
old_file = os.path.join(old_preset_dir, filename)
new_file = os.path.join(new_preset_dir, filename)
try:
# Only copy if destination doesn't exist or is older
should_copy = True
if os.path.exists(new_file):
old_mtime = os.path.getmtime(old_file)
new_mtime = os.path.getmtime(new_file)
should_copy = old_mtime > new_mtime
if should_copy:
import shutil
shutil.copy2(old_file, new_file)
migrated_count += 1
print(f"[TTG] ✅ Migrated preset: {preset_name}")
else:
print(f"[TTG] ⏭️ Skipped preset (newer exists): {preset_name}")
except Exception as preset_error:
error_msg = f"Failed to migrate '{preset_name}': {preset_error}"
migration_errors.append(error_msg)
print(f"[TTG] ❌ {error_msg}")
# Store migration results for user notification
migration_data = {
'migrated_count': migrated_count,
'total_found': len([f for f in os.listdir(old_preset_dir) if f.endswith('.json')]),
'errors': migration_errors,
'backup_path': backup_path,
'timestamp': time.time()
}
# Save migration report
try:
migration_report_file = os.path.join(os.path.dirname(new_preset_dir), "migration_report.json")
with open(migration_report_file, 'w') as f:
json.dump(migration_data, f, indent=2)
print(f"[TTG] Migration report saved to {migration_report_file}")
except Exception as e:
print(f"[TTG] Warning: Could not save migration report: {e}")
if migrated_count > 0:
print(f"[TTG] ✅ Successfully migrated {migrated_count} presets to persistent storage")
print(f"[TTG] 🛡️ Your presets are now safe from addon updates!")
else:
print(f"[TTG] No presets needed migration")
if migration_errors:
print(f"[TTG] ⚠️ {len(migration_errors)} presets had migration errors")
except OSError as e:
print(f"[TTG] ❌ Error during preset migration: {e}")
migration_errors.append(f"General migration error: {e}")
# Save current version
save_current_version()
# Return migration summary
return {
'migrated_count': migrated_count,
'errors': migration_errors,
'version_updated': True
}
return {
'migrated_count': 0,
'errors': [],
'version_updated': False
}
def backup_presets():
"""Create backup of current presets"""
persistent_dir = get_persistent_preset_path()
backup_dir = os.path.join(os.path.dirname(persistent_dir), "preset_backups")
try:
if not os.path.exists(backup_dir):
os.makedirs(backup_dir, exist_ok=True)
# Create timestamped backup
import datetime
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
backup_subdir = os.path.join(backup_dir, f"backup_{timestamp}")
if os.path.exists(persistent_dir):
import shutil
shutil.copytree(persistent_dir, backup_subdir)
print(f"[TTG] Created preset backup at {backup_subdir}")
return backup_subdir
except OSError as e:
print(f"[TTG] Error creating preset backup: {e}")
return None
def get_blend_file_presets():
"""Get presets stored in the current blend file"""
try:
# Get custom properties from the scene
import bpy
scene = bpy.context.scene
# DETAILED LOGGING: Function entry
print(f"[GET_BLEND_FILE_PRESETS DEBUG] ==================== LOADING FROM BLEND FILE ==================")
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Function called to get presets from blend file")
# Store presets in scene custom properties
if "text_texture_presets" not in scene:
print(f"[GET_BLEND_FILE_PRESETS DEBUG] No 'text_texture_presets' found in scene, creating empty")
scene["text_texture_presets"] = "{}"
else:
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Found 'text_texture_presets' in scene")
preset_data = scene.get("text_texture_presets", "{}")
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Raw preset data type: {type(preset_data)}")
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Raw preset data length: {len(str(preset_data))} characters")
if isinstance(preset_data, str):
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Preset data is string, attempting JSON parse...")
try:
import json
parsed_presets = json.loads(preset_data)
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Successfully parsed JSON")
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Parsed presets type: {type(parsed_presets)}")
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Parsed presets count: {len(parsed_presets) if isinstance(parsed_presets, dict) else 'N/A'}")
if isinstance(parsed_presets, dict):
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Preset names: {list(parsed_presets.keys())}")
# DETAILED VERIFICATION: Check shader properties in each preset
for preset_name, preset_content in parsed_presets.items():
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Preset '{preset_name}' analysis:")
if isinstance(preset_content, dict):
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Keys: {list(preset_content.keys())}")
print(f"[GET_BLEND_FILE_PRESETS DEBUG] disable_shadows: {preset_content.get('disable_shadows', 'NOT FOUND')}")
print(f"[GET_BLEND_FILE_PRESETS DEBUG] disable_reflections: {preset_content.get('disable_reflections', 'NOT FOUND')}")
print(f"[GET_BLEND_FILE_PRESETS DEBUG] disable_backfacing: {preset_content.get('disable_backfacing', 'NOT FOUND')}")
else:
print(f"[GET_BLEND_FILE_PRESETS DEBUG] ERROR: Preset content is not a dict: {type(preset_content)}")
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Returning parsed presets")
print(f"[GET_BLEND_FILE_PRESETS DEBUG] ================================================================")
return parsed_presets
except json.JSONDecodeError as json_err:
print(f"[GET_BLEND_FILE_PRESETS DEBUG] JSON decode error: {json_err}")
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Raw data causing error: {preset_data[:200]}...") # First 200 chars
print("Error parsing blend file presets, resetting to empty")
scene["text_texture_presets"] = "{}"
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Reset to empty, returning empty dict")
return {}
else:
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Preset data is not string, returning empty dict")
print(f"[GET_BLEND_FILE_PRESETS DEBUG] ================================================================")
return {}
except Exception as e:
print(f"[GET_BLEND_FILE_PRESETS DEBUG] EXCEPTION occurred: {e}")
import traceback
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Full traceback: {traceback.format_exc()}")
print(f"Error getting blend file presets: {e}")
return {}
def save_blend_file_preset(preset_name, preset_data):
"""Save a preset to the current blend file"""
try:
import bpy
import json
scene = bpy.context.scene
# DETAILED LOGGING: Function entry
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] ==================== SAVING TO BLEND FILE ===================")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Function called with preset_name: '{preset_name}'")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Preset data type: {type(preset_data)}")
if isinstance(preset_data, dict):
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Preset data keys: {list(preset_data.keys())}")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Shader properties in preset_data:")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_shadows: {preset_data.get('disable_shadows', 'NOT FOUND')}")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_reflections: {preset_data.get('disable_reflections', 'NOT FOUND')}")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_backfacing: {preset_data.get('disable_backfacing', 'NOT FOUND')}")
else:
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] ERROR: preset_data is not a dictionary!")
# Get existing presets
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Getting existing presets from blend file...")
presets = get_blend_file_presets()
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Existing presets count: {len(presets)}")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Existing preset names: {list(presets.keys())}")
# Add or update the preset
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Adding/updating preset '{preset_name}'...")
presets[preset_name] = preset_data
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Presets now contains {len(presets)} items")
# VERIFY: Check that shader properties are in the preset we're about to save
if preset_name in presets and isinstance(presets[preset_name], dict):
saved_preset = presets[preset_name]
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] VERIFICATION - Shader properties in final preset:")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_shadows: {saved_preset.get('disable_shadows', 'NOT FOUND')}")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_reflections: {saved_preset.get('disable_reflections', 'NOT FOUND')}")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_backfacing: {saved_preset.get('disable_backfacing', 'NOT FOUND')}")
# Save back to scene custom properties
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Converting presets to JSON...")
json_data = json.dumps(presets, indent=2)
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] JSON data length: {len(json_data)} characters")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Saving to scene custom properties...")
scene["text_texture_presets"] = json_data
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Successfully saved to scene['text_texture_presets']")
# FINAL VERIFICATION: Read back from scene to confirm save
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] FINAL VERIFICATION - Reading back from scene...")
readback_data = scene.get("text_texture_presets", "{}")
if readback_data:
try:
readback_presets = json.loads(readback_data)
if preset_name in readback_presets:
readback_preset = readback_presets[preset_name]
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] READBACK VERIFICATION - Shader properties:")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_shadows: {readback_preset.get('disable_shadows', 'NOT FOUND')}")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_reflections: {readback_preset.get('disable_reflections', 'NOT FOUND')}")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_backfacing: {readback_preset.get('disable_backfacing', 'NOT FOUND')}")
else:
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] ERROR: Preset '{preset_name}' not found in readback data!")
except json.JSONDecodeError as json_err:
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] ERROR: Could not parse readback JSON: {json_err}")
else:
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] ERROR: No data in scene['text_texture_presets'] after save!")
print(f"Saved preset '{preset_name}' to blend file")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] ================================================================")
return True
except Exception as e:
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] EXCEPTION occurred: {e}")
import traceback
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Full traceback: {traceback.format_exc()}")
print(f"Error saving preset to blend file: {e}")
return False
def delete_blend_file_preset(preset_name):
"""Delete a preset from the current blend file"""
try:
import bpy
import json
scene = bpy.context.scene
# Get existing presets
presets = get_blend_file_presets()
# Remove the preset if it exists
if preset_name in presets:
del presets[preset_name]
scene["text_texture_presets"] = json.dumps(presets, indent=2)
print(f"Deleted preset '{preset_name}' from blend file")
return True
else:
print(f"Preset '{preset_name}' not found in blend file")
return False
except Exception as e:
print(f"Error deleting preset from blend file: {e}")
return False
def initialize_presets():
"""Initialize presets with simplified, reliable synchronization"""
try:
import bpy
if not hasattr(bpy.context, 'scene') or not bpy.context.scene:
return
props = bpy.context.scene.text_texture_props
if not props:
return
print(f"[TTG] Starting preset initialization...")
# Clear existing presets to avoid duplicates
props.presets.clear()
# Collect all presets from all sources
all_presets = {}
# 1. Load from blend file (highest priority - travels with file)
try:
blend_presets = get_blend_file_presets()
for name, data in blend_presets.items():
all_presets[name] = {
'data': data,
'source': 'BLEND_FILE'
}
print(f"[TTG] Found {len(blend_presets)} presets in blend file")
except Exception as e:
print(f"[TTG] Error loading blend file presets: {e}")
# 2. Load from persistent storage (survives addon updates)
try:
persistent_dir = get_persistent_preset_path()
if os.path.exists(persistent_dir):
for filename in os.listdir(persistent_dir):
if filename.endswith('.json'):
preset_name = os.path.splitext(filename)[0]
# Only add if not already in blend file (blend file has priority)
if preset_name not in all_presets:
try:
with open(os.path.join(persistent_dir, filename), 'r') as f:
preset_data = json.load(f)
all_presets[preset_name] = {
'data': preset_data,
'source': 'PERSISTENT_FILE'
}
except (json.JSONDecodeError, OSError) as e:
print(f"[TTG] Error reading persistent preset {preset_name}: {e}")
print(f"[TTG] Found {len([p for p in all_presets.values() if p['source'] == 'PERSISTENT_FILE'])} persistent presets")
except Exception as e:
print(f"[TTG] Error loading persistent presets: {e}")
# 3. Load from legacy storage (backward compatibility)
try:
legacy_dir = get_preset_path()
if os.path.exists(legacy_dir) and legacy_dir != get_persistent_preset_path():
for filename in os.listdir(legacy_dir):
if filename.endswith('.json'):
preset_name = os.path.splitext(filename)[0]
# Only add if not already found in higher priority sources
if preset_name not in all_presets:
try:
with open(os.path.join(legacy_dir, filename), 'r') as f:
preset_data = json.load(f)
all_presets[preset_name] = {
'data': preset_data,
'source': 'LOCAL_FILE'
}
except (json.JSONDecodeError, OSError) as e:
print(f"[TTG] Error reading legacy preset {preset_name}: {e}")
print(f"[TTG] Found {len([p for p in all_presets.values() if p['source'] == 'LOCAL_FILE'])} legacy presets")
except Exception as e:
print(f"[TTG] Error loading legacy presets: {e}")
# Add all presets to UI list
for preset_name, preset_info in all_presets.items():
preset = props.presets.add()
preset.name = preset_name
preset.source = preset_info['source']
# Report results
blend_count = len([p for p in props.presets if p.source == 'BLEND_FILE'])
persistent_count = len([p for p in props.presets if p.source == 'PERSISTENT_FILE'])
legacy_count = len([p for p in props.presets if p.source == 'LOCAL_FILE'])
print(f"[TTG] ✅ Initialized {len(props.presets)} presets ({blend_count} blend file, {persistent_count} persistent, {legacy_count} legacy)")
except Exception as e:
print(f"[TTG] Critical error in initialize_presets: {e}")
import traceback
traceback.print_exc()
def refresh_presets():
"""Refresh the preset list by rescanning files (legacy function)"""
import bpy
refresh_presets_sync()
# Force UI update
try:
if bpy.context.area:
bpy.context.area.tag_redraw()
except:
pass
def refresh_presets_sync():
"""Centralized, synchronous preset refresh - guarantees completion"""
try:
import bpy
print("[TTG] 🔄 Synchronizing presets...")
# Ensure we have valid context
if not hasattr(bpy.context, 'scene') or not bpy.context.scene:
print("[TTG] ⚠️ No valid scene context for preset refresh")
return False
props = bpy.context.scene.text_texture_props
if not props:
print("[TTG] ⚠️ No text_texture_props found")
return False
# Run the simplified initialization
initialize_presets()
# Validate that presets are actually loaded
preset_count = len(props.presets)
if preset_count == 0:
print("[TTG] ⚠️ Warning: No presets found after refresh")
else:
print(f"[TTG] ✅ Preset sync complete - {preset_count} presets available")
return True
except Exception as e:
print(f"[TTG] ❌ Error in refresh_presets_sync: {e}")
import traceback
traceback.print_exc()
return False
def ensure_presets_available():
"""Defensive programming - ensure presets are loaded before UI operations"""
try:
import bpy
if not hasattr(bpy.context, 'scene') or not bpy.context.scene:
return False
props = bpy.context.scene.text_texture_props
if not props:
return False
# If no presets loaded, try to load them
if len(props.presets) == 0:
print("[TTG] 🛡️ No presets available - attempting to load...")
return refresh_presets_sync()
return True
except Exception as e:
print(f"[TTG] Error in ensure_presets_available: {e}")
return False
# ============================================================================
# PREVIEW GENERATION FUNCTIONS
# ============================================================================
def test_font_mixed_case_support(font, test_size=20):
"""Test if a font supports mixed-case rendering properly"""
try:
from PIL import Image, ImageDraw
# Test with mixed-case sample text
test_text = "AaBbCc123"
test_img = Image.new('RGB', (100, 50), (255, 255, 255))
test_draw = ImageDraw.Draw(test_img)
# DIAGNOSTIC: Add detailed logging for custom font debugging
font_info = "UNKNOWN"
if hasattr(font, 'path'):
font_info = font.path
elif hasattr(font, 'getname'):
try:
font_info = font.getname()
except:
pass
print(f"CUSTOM FONT DEBUG: Testing mixed-case support for font: {font_info}")
# Try to get bounding box - this will fail if font doesn't support the characters
try:
bbox = test_draw.textbbox((0, 0), test_text, font=font)
print(f"CUSTOM FONT DEBUG: Mixed-case text bbox successful: {bbox}")
except Exception as bbox_error:
print(f"CUSTOM FONT DEBUG: Mixed-case text bbox FAILED: {bbox_error}")
import traceback
print(f"CUSTOM FONT DEBUG: Full exception trace: {traceback.format_exc()}")
return False
# Test actual rendering - draw the text and check if it renders properly
try:
test_draw.text((5, 5), test_text, fill=(0, 0, 0), font=font)
print(f"CUSTOM FONT DEBUG: Mixed-case text rendering successful")
except Exception as render_error:
print(f"CUSTOM FONT DEBUG: Mixed-case text rendering FAILED: {render_error}")
import traceback
print(f"CUSTOM FONT DEBUG: Full exception trace: {traceback.format_exc()}")
return False
# Additional validation: check if lowercase letters render differently from uppercase
try:
upper_bbox = test_draw.textbbox((0, 0), "ABC", font=font)
lower_bbox = test_draw.textbbox((0, 0), "abc", font=font)
print(f"CUSTOM FONT DEBUG: Upper bbox: {upper_bbox}, Lower bbox: {lower_bbox}")
# RELAXED VALIDATION: Allow fonts with identical bounding boxes
# Many valid custom fonts may have identical bounding boxes but still render mixed case properly
if upper_bbox == lower_bbox:
print(f"CUSTOM FONT DEBUG: RELAXED VALIDATION - Identical bounding boxes detected")
print(f"CUSTOM FONT DEBUG: Instead of rejecting, testing actual mixed-case rendering...")
# Additional test: try rendering actual mixed case text to verify it works
try:
mixed_test = "AaBbCcDd"
mixed_bbox = test_draw.textbbox((0, 0), mixed_test, font=font)
test_draw.text((10, 10), mixed_test, fill=(0, 0, 0), font=font)
print(f"CUSTOM FONT DEBUG: Mixed-case render test '{mixed_test}' PASSED: {mixed_bbox}")
print(f"CUSTOM FONT DEBUG: Font ACCEPTED despite identical bounding boxes (relaxed validation)")
return True
except Exception as mixed_render_error:
print(f"CUSTOM FONT DEBUG: Mixed-case render test FAILED: {mixed_render_error}")
print(f"CUSTOM FONT DEBUG: Font REJECTED due to actual rendering failure")
import traceback
print(f"CUSTOM FONT DEBUG: Full exception trace: {traceback.format_exc()}")
return False
print(f"CUSTOM FONT DEBUG: Font mixed-case test PASSED - supports both cases properly")
return True
except Exception as bbox_compare_error:
print(f"CUSTOM FONT DEBUG: Bounding box comparison FAILED: {bbox_compare_error}")
return False
except Exception as e:
print(f"CUSTOM FONT DEBUG: Font mixed-case test FAILED with exception: {e}")
import traceback
traceback.print_exc()
return False
def get_validated_font(font_path, font_size):
"""Load and validate a font for mixed-case support"""
try:
from PIL import ImageFont
# DIAGNOSTIC: Enhanced logging for custom font debugging
print(f"CUSTOM FONT DEBUG: get_validated_font() called with:")
print(f"CUSTOM FONT DEBUG: font_path: '{font_path}'")
print(f"CUSTOM FONT DEBUG: font_size: {font_size}")
print(f"CUSTOM FONT DEBUG: path exists: {os.path.exists(font_path)}")
if os.path.exists(font_path):
# Get detailed file information for diagnostics
try:
import stat
file_stats = os.stat(font_path)
print(f"CUSTOM FONT DEBUG: file size: {file_stats.st_size} bytes")
print(f"CUSTOM FONT DEBUG: file permissions: {stat.filemode(file_stats.st_mode)}")
print(f"CUSTOM FONT DEBUG: file extension: {os.path.splitext(font_path)[1]}")
except Exception as stats_error:
print(f"CUSTOM FONT DEBUG: could not get detailed file stats: {stats_error}")
if not os.path.exists(font_path):
error_msg = f"Font file not found: {font_path}"
print(f"CUSTOM FONT DEBUG: {error_msg}")
return None, error_msg
# Get file info for debugging
try:
file_stats = os.stat(font_path)
print(f"CUSTOM FONT DEBUG: file size: {file_stats.st_size} bytes")
print(f"CUSTOM FONT DEBUG: file extension: {os.path.splitext(font_path)[1]}")
except Exception as stat_error:
print(f"CUSTOM FONT DEBUG: could not get file stats: {stat_error}")
# Load the font
print(f"CUSTOM FONT DEBUG: Attempting to load font with ImageFont.truetype()...")
try:
font = ImageFont.truetype(font_path, font_size)
print(f"CUSTOM FONT DEBUG: Font loaded successfully: {font}")
except Exception as load_error:
error_msg = f"Font loading error - ImageFont.truetype() failed: {load_error}"
print(f"CUSTOM FONT DEBUG: {error_msg}")
import traceback
print(f"CUSTOM FONT DEBUG: Full exception trace: {traceback.format_exc()}")
return None, error_msg
# Test mixed-case support
print(f"CUSTOM FONT DEBUG: Testing mixed-case support...")
if test_font_mixed_case_support(font):
print(f"CUSTOM FONT DEBUG: Mixed-case validation PASSED")
return font, "OK"
else:
error_msg = f"Font does not support mixed-case properly: {font_path}"
print(f"CUSTOM FONT DEBUG: Mixed-case validation FAILED: {error_msg}")
return None, error_msg
except Exception as e:
error_msg = f"Font loading error (outer exception): {e}"
print(f"CUSTOM FONT DEBUG: {error_msg}")
import traceback
traceback.print_exc()
return None, error_msg
def get_system_fallback_fonts():
"""Get platform-specific fallback fonts known to support mixed-case"""
system = platform.system()
if system == "Windows":
# Windows fonts with guaranteed mixed-case support
return [
"C:/Windows/Fonts/arial.ttf", # Arial - excellent mixed-case
"C:/Windows/Fonts/calibri.ttf", # Calibri - modern, mixed-case
"C:/Windows/Fonts/segoeui.ttf", # Segoe UI - system font, mixed-case
"C:/Windows/Fonts/verdana.ttf", # Verdana - web-safe, mixed-case
"C:/Windows/Fonts/tahoma.ttf", # Tahoma - fallback mixed-case
"C:/Windows/Fonts/times.ttf", # Times - serif mixed-case
"C:/Windows/Fonts/trebuc.ttf" # Trebuchet MS - mixed-case
]
elif system == "Darwin": # macOS
# macOS fonts with guaranteed mixed-case support
return [
"/System/Library/Fonts/Helvetica.ttc", # Helvetica - classic mixed-case
"/Library/Fonts/Arial.ttf", # Arial - if available
"/System/Library/Fonts/Times.ttc", # Times - serif mixed-case
"/System/Library/Fonts/Avenir.ttc", # Avenir - modern mixed-case
"/System/Library/Fonts/Geneva.ttf", # Geneva - system mixed-case
"/System/Library/Fonts/Palatino.ttc" # Palatino - elegant mixed-case
]
else: # Linux
# Linux fonts with guaranteed mixed-case support
return [
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", # LibreOffice mixed-case
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", # DejaVu - comprehensive
"/usr/share/fonts/truetype/ubuntu/Ubuntu-R.ttf", # Ubuntu - modern mixed-case
"/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", # Google Noto - universal
"/usr/share/fonts/truetype/lato/Lato-Regular.ttf", # Lato - web font mixed-case
"/usr/share/fonts/truetype/opensans/OpenSans-Regular.ttf" # Open Sans - mixed-case
]
# ============================================================================
# NORMAL MAP GENERATION FUNCTIONS
# ============================================================================
def generate_normal_map_from_alpha(img, strength=1.0, blur_radius=1.0, invert=False):
"""Generate a normal map from an image's alpha channel using height-based algorithm"""
try:
from PIL import Image, ImageFilter
import numpy as np
print(f"[Normal Map] Starting generation with strength={strength}, blur={blur_radius}, invert={invert}")
# Extract alpha channel as height map
if img.mode != 'RGBA':
img = img.convert('RGBA')
# Get alpha channel
alpha_channel = img.split()[3] # Alpha is the 4th channel
width, height = alpha_channel.size
print(f"[Normal Map] Processing {width}x{height} alpha channel")
# Apply blur if specified
if blur_radius > 0:
alpha_channel = alpha_channel.filter(ImageFilter.GaussianBlur(radius=blur_radius))
print(f"[Normal Map] Applied blur with radius {blur_radius}")
# Convert to numpy array for gradient calculations
height_map = np.array(alpha_channel, dtype=np.float32) / 255.0
# Apply strength multiplier
height_map *= strength
# Calculate gradients using Sobel operators
# Sobel X kernel for horizontal gradients
sobel_x = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], dtype=np.float32)
# Sobel Y kernel for vertical gradients
sobel_y = np.array([[-1, -2, -1], [0, 0, 0], [1, 2, 1]], dtype=np.float32)
# Pad the height map to handle edges
padded_height = np.pad(height_map, ((1, 1), (1, 1)), mode='edge')
# Calculate gradients
grad_x = np.zeros_like(height_map)
grad_y = np.zeros_like(height_map)
for i in range(height):
for j in range(width):
# Extract 3x3 neighborhood
neighborhood = padded_height[i:i+3, j:j+3]
# Apply Sobel operators
grad_x[i, j] = np.sum(neighborhood * sobel_x)
grad_y[i, j] = np.sum(neighborhood * sobel_y)
print(f"[Normal Map] Calculated gradients")
# Convert gradients to normal vectors
# Normal map RGB values are calculated as:
# R = (grad_x + 1) * 0.5 -> maps -1,1 to 0,1
# G = (-grad_y + 1) * 0.5 -> maps -1,1 to 0,1 (Y is flipped for standard normal maps)
# B = sqrt(1 - grad_x^2 - grad_y^2) -> Z component, always pointing up
# Clamp gradients to reasonable range
grad_x = np.clip(grad_x, -1, 1)
grad_y = np.clip(grad_y, -1, 1)
# Apply invert if specified
if invert:
grad_x = -grad_x
grad_y = -grad_y
print(f"[Normal Map] Applied inversion")
# Calculate normal map channels
# Red channel: X gradient mapped to 0-1
normal_r = ((grad_x + 1.0) * 0.5 * 255).astype(np.uint8)
# Green channel: Y gradient mapped to 0-1 (flipped)
normal_g = ((-grad_y + 1.0) * 0.5 * 255).astype(np.uint8)
# Blue channel: Z component (pointing up)
# Calculate Z from X and Y to maintain unit length
grad_magnitude_sq = grad_x**2 + grad_y**2
grad_z = np.sqrt(np.maximum(0, 1.0 - grad_magnitude_sq))
normal_b = (grad_z * 255).astype(np.uint8)
print(f"[Normal Map] Calculated normal vectors")
# Create the normal map image
normal_map = Image.new('RGB', (width, height))
# Combine channels
for y in range(height):
for x in range(width):
r = int(normal_r[y, x])
g = int(normal_g[y, x])
b = int(normal_b[y, x])
normal_map.putpixel((x, y), (r, g, b))
print(f"[Normal Map] Normal map generation completed successfully")
return normal_map
except Exception as e:
print(f"[Normal Map] Error generating normal map: {e}")
import traceback
traceback.print_exc()
return None
# ============================================================================
# TEXT FITTING HELPER FUNCTIONS
# ============================================================================
def calculate_available_text_area(base_width, base_height, overlay_areas, margin_x, margin_y, canvas_width, canvas_height):
"""Calculate the largest available area for text placement avoiding absolute positioned overlays
Args:
base_width: Available width without overlay consideration
base_height: Available height without overlay consideration
overlay_areas: List of overlay area dictionaries with x, y, width, height
margin_x: Horizontal margin in pixels
margin_y: Vertical margin in pixels
canvas_width: Total canvas width
canvas_height: Total canvas height
Returns:
Tuple of (available_width, available_height) for text fitting
"""
if not overlay_areas:
# No overlays to avoid, return base dimensions
return base_width, base_height
# Define the text fitting area boundaries (respecting margins)
text_area_x = margin_x
text_area_y = margin_y
text_area_width = base_width
text_area_height = base_height
print(f"DEBUG TEXT AREA: Text fitting area bounds: ({text_area_x}, {text_area_y}) {text_area_width}x{text_area_height}")
# Find overlays that intersect with the text area
conflicting_overlays = []
for overlay in overlay_areas:
overlay_x = overlay['x']
overlay_y = overlay['y']
overlay_w = overlay['width']
overlay_h = overlay['height']
# Check if overlay intersects with text area
if (overlay_x < text_area_x + text_area_width and
overlay_x + overlay_w > text_area_x and
overlay_y < text_area_y + text_area_height and
overlay_y + overlay_h > text_area_y):
conflicting_overlays.append(overlay)
print(f"DEBUG TEXT AREA: Conflicting overlay at ({overlay_x}, {overlay_y}) size {overlay_w}x{overlay_h}")
if not conflicting_overlays:
# No conflicting overlays, return base dimensions
return base_width, base_height
# Strategy 1: Try to find the largest horizontal strip that avoids overlays
best_width = 0
best_height = 0
# Divide the text area into horizontal strips and find the largest clear one
strip_height = max(1, base_height // 10) # Test 10 horizontal strips
for strip_y in range(text_area_y, text_area_y + text_area_height - strip_height + 1, strip_height):
strip_bottom = min(strip_y + strip_height, text_area_y + text_area_height)
actual_strip_height = strip_bottom - strip_y
# Check if this strip intersects with any overlay
strip_clear = True
for overlay in conflicting_overlays:
overlay_x = overlay['x']
overlay_y = overlay['y']
overlay_w = overlay['width']
overlay_h = overlay['height']
# Check vertical intersection with this strip
if (overlay_y < strip_bottom and overlay_y + overlay_h > strip_y):
strip_clear = False
break
if strip_clear:
# This strip is clear, use full width
strip_area = text_area_width * actual_strip_height
if strip_area > best_width * best_height:
best_width = text_area_width
best_height = actual_strip_height
print(f"DEBUG TEXT AREA: Found clear horizontal strip: {best_width}x{best_height}")
# Strategy 2: Try vertical strips if horizontal didn't work well
if best_width * best_height < (text_area_width * text_area_height * 0.5): # Less than 50% of original area
strip_width = max(1, base_width // 10) # Test 10 vertical strips
for strip_x in range(text_area_x, text_area_x + text_area_width - strip_width + 1, strip_width):
strip_right = min(strip_x + strip_width, text_area_x + text_area_width)
actual_strip_width = strip_right - strip_x
# Check if this strip intersects with any overlay
strip_clear = True
for overlay in conflicting_overlays:
overlay_x = overlay['x']
overlay_y = overlay['y']
overlay_w = overlay['width']
overlay_h = overlay['height']
# Check horizontal intersection with this strip
if (overlay_x < strip_right and overlay_x + overlay_w > strip_x):
strip_clear = False
break
if strip_clear:
# This strip is clear, use full height
strip_area = actual_strip_width * text_area_height
if strip_area > best_width * best_height:
best_width = actual_strip_width
best_height = text_area_height
print(f"DEBUG TEXT AREA: Found clear vertical strip: {best_width}x{best_height}")
# Strategy 3: Fallback - reduce dimensions by estimated overlay coverage
if best_width * best_height == 0:
# Calculate total overlay area coverage
total_overlay_area = sum(overlay['width'] * overlay['height'] for overlay in conflicting_overlays)
text_area_total = text_area_width * text_area_height
coverage_ratio = min(0.8, total_overlay_area / text_area_total) # Cap at 80%
# Reduce both dimensions proportionally
reduction_factor = max(0.2, 1.0 - coverage_ratio) # Minimum 20% of original
best_width = int(text_area_width * reduction_factor)
best_height = int(text_area_height * reduction_factor)
print(f"DEBUG TEXT AREA: Fallback proportional reduction: {best_width}x{best_height} (factor: {reduction_factor:.2f})")
# Ensure minimum dimensions
best_width = max(50, best_width) # Minimum 50px width
best_height = max(20, best_height) # Minimum 20px height
print(f"DEBUG TEXT AREA: Final available text area: {best_width}x{best_height}")
return best_width, best_height
# ============================================================================
# ENHANCED POSITIONING FUNCTIONS
# ============================================================================
def calculate_text_position(props, canvas_width, canvas_height, text_width, text_height, overlay_areas=None, scale_factor=1.0, prepend_width=0):
"""Calculate text position based on enhanced positioning properties with overlay avoidance
Args:
prepend_width: Total width occupied by prepend overlays (shifts text to the right)
"""
if props.position_mode == 'MANUAL':
# Manual positioning mode
if props.manual_position_unit == 'PERCENTAGE':
x = int((props.manual_position_x / 100.0) * canvas_width)
y = int((props.manual_position_y / 100.0) * canvas_height)
else: # PIXELS
x = int(props.manual_position_x)
y = int(props.manual_position_y)
else:
# Preset anchor-based positioning with overlay avoidance
margin_x_px = int((props.margin_x / 100.0) * canvas_width)
margin_y_px = int((props.margin_y / 100.0) * canvas_height)
# If text fitting is enabled and we have overlay areas, calculate available space
if props.enable_text_fitting and overlay_areas:
print(f"DEBUG TEXT POSITION: Text fitting enabled with {len(overlay_areas)} overlay areas to avoid")
# Calculate base available space with margins
base_available_width = canvas_width - (2 * margin_x_px)
base_available_height = canvas_height - (2 * margin_y_px)
# Get the available area that avoids overlays
available_width, available_height = calculate_available_text_area(
base_available_width, base_available_height,
overlay_areas,
margin_x_px, margin_y_px, canvas_width, canvas_height
)
print(f"DEBUG TEXT POSITION: Base available: {base_available_width}x{base_available_height}")
print(f"DEBUG TEXT POSITION: Final available (after overlay avoidance): {available_width}x{available_height}")
# Find the best clear area for text placement
best_position = find_best_text_position_in_available_area(
props, canvas_width, canvas_height, text_width, text_height,
overlay_areas, margin_x_px, margin_y_px, available_width, available_height
)
if best_position:
x, y = best_position
print(f"DEBUG TEXT POSITION: Found clear position at ({x}, {y}) avoiding overlays")
else:
# Fallback to standard positioning if no clear area found
print(f"DEBUG TEXT POSITION: No clear area found, using standard positioning with prepend_width={prepend_width}")
x, y = calculate_standard_anchor_position(
props, canvas_width, canvas_height, text_width, text_height,
margin_x_px, margin_y_px, prepend_width
)
else:
# Standard anchor-based positioning (no overlay avoidance)
print(f"DEBUG TEXT POSITION: Using standard positioning with prepend_width={prepend_width}")
x, y = calculate_standard_anchor_position(
props, canvas_width, canvas_height, text_width, text_height,
margin_x_px, margin_y_px, prepend_width
)
# Apply fine-tuning offsets
x += props.offset_x
y += props.offset_y
# Apply canvas constraints if enabled
if props.constrain_to_canvas:
x = max(0, min(x, canvas_width - text_width))
y = max(0, min(y, canvas_height - text_height))
return x, y
def calculate_standard_anchor_position(props, canvas_width, canvas_height, text_width, text_height, margin_x_px, margin_y_px, prepend_width=0):
"""Calculate standard anchor-based position without overlay avoidance
Args:
prepend_width: Total width occupied by prepend overlays (shifts text to the right)
"""
# Calculate base position based on anchor point
if 'LEFT' in props.anchor_point:
base_x = margin_x_px + prepend_width
elif 'RIGHT' in props.anchor_point:
base_x = canvas_width - text_width - margin_x_px
else: # CENTER
base_x = (canvas_width - text_width) // 2 + prepend_width
if 'TOP' in props.anchor_point:
base_y = margin_y_px
elif 'BOTTOM' in props.anchor_point:
base_y = canvas_height - text_height - margin_y_px
else: # MIDDLE
base_y = (canvas_height - text_height) // 2
return base_x, base_y
def find_best_text_position_in_available_area(props, canvas_width, canvas_height, text_width, text_height, overlay_areas, margin_x_px, margin_y_px, available_width, available_height):
"""Find the best position for text within available area that avoids overlays"""
# Define the search area (respecting margins)
search_area_x = margin_x_px
search_area_y = margin_y_px
search_area_width = canvas_width - (2 * margin_x_px)
search_area_height = canvas_height - (2 * margin_y_px)
print(f"DEBUG TEXT POSITION: Search area: ({search_area_x}, {search_area_y}) {search_area_width}x{search_area_height}")
# Try anchor-based positions first, but within clear areas
anchor_positions = []
# Calculate preferred positions based on anchor point
if 'LEFT' in props.anchor_point:
pref_x = search_area_x
elif 'RIGHT' in props.anchor_point:
pref_x = search_area_x + search_area_width - text_width
else: # CENTER
pref_x = search_area_x + (search_area_width - text_width) // 2
if 'TOP' in props.anchor_point:
pref_y = search_area_y
elif 'BOTTOM' in props.anchor_point:
pref_y = search_area_y + search_area_height - text_height
else: # MIDDLE
pref_y = search_area_y + (search_area_height - text_height) // 2
# Add preferred position
anchor_positions.append((pref_x, pref_y, "preferred"))
# Add alternative positions
# Top-left, top-center, top-right
anchor_positions.append((search_area_x, search_area_y, "top-left"))
anchor_positions.append((search_area_x + (search_area_width - text_width) // 2, search_area_y, "top-center"))
anchor_positions.append((search_area_x + search_area_width - text_width, search_area_y, "top-right"))
# Middle-left, middle-center, middle-right
middle_y = search_area_y + (search_area_height - text_height) // 2
anchor_positions.append((search_area_x, middle_y, "middle-left"))
anchor_positions.append((search_area_x + (search_area_width - text_width) // 2, middle_y, "middle-center"))
anchor_positions.append((search_area_x + search_area_width - text_width, middle_y, "middle-right"))
# Bottom-left, bottom-center, bottom-right
bottom_y = search_area_y + search_area_height - text_height
anchor_positions.append((search_area_x, bottom_y, "bottom-left"))
anchor_positions.append((search_area_x + (search_area_width - text_width) // 2, bottom_y, "bottom-center"))
anchor_positions.append((search_area_x + search_area_width - text_width, bottom_y, "bottom-right"))
# Test each position for conflicts with overlays
for test_x, test_y, position_name in anchor_positions:
# Ensure position is within canvas bounds
test_x = max(0, min(test_x, canvas_width - text_width))
test_y = max(0, min(test_y, canvas_height - text_height))
# Check if this position conflicts with any overlay
text_rect = {
'x': test_x,
'y': test_y,
'width': text_width,
'height': text_height
}
conflicts = False
for overlay in overlay_areas:
if rectangles_overlap(text_rect, overlay):
conflicts = True
print(f"DEBUG TEXT POSITION: Position {position_name} at ({test_x}, {test_y}) conflicts with overlay at ({overlay['x']}, {overlay['y']})")
break
if not conflicts:
print(f"DEBUG TEXT POSITION: Found clear position {position_name} at ({test_x}, {test_y})")
return (test_x, test_y)
# If no clear position found, return None
print(f"DEBUG TEXT POSITION: No clear position found among {len(anchor_positions)} tested positions")
return None
def rectangles_overlap(rect1, rect2):
"""Check if two rectangles overlap"""
return not (rect1['x'] + rect1['width'] <= rect2['x'] or
rect2['x'] + rect2['width'] <= rect1['x'] or
rect1['y'] + rect1['height'] <= rect2['y'] or
rect2['y'] + rect2['height'] <= rect1['y'])
def sync_margin_values(props, changed_margin):
"""Synchronize margin values when linked"""
if props.margins_linked:
if changed_margin == 'x':
props.margin_y = props.margin_x
elif changed_margin == 'y':
props.margin_x = props.margin_y
def get_anchor_point_matrix():
"""Return 3x3 matrix of anchor point identifiers for UI layout"""
return [
['TOP_LEFT', 'TOP_CENTER', 'TOP_RIGHT'],
['MIDDLE_LEFT', 'MIDDLE_CENTER', 'MIDDLE_RIGHT'],
['BOTTOM_LEFT', 'BOTTOM_CENTER', 'BOTTOM_RIGHT']
]
def process_multiline_text(text_content, font, props):
"""Process text into lines for multiline rendering"""
# Split text by newlines first
lines = text_content.split('\n')
processed_lines = []
for line in lines:
if props.auto_wrap and line.strip():
# Calculate wrap width
wrap_width = props.wrap_width if props.wrap_width > 0 else props.texture_width - 40 # 20px margin on each side
wrapped_lines = wrap_text_to_width(line, font, wrap_width)
processed_lines.extend(wrapped_lines)
else:
processed_lines.append(line)
# Apply max lines limit
if props.max_lines > 0 and len(processed_lines) > props.max_lines:
processed_lines = processed_lines[:props.max_lines]
return processed_lines
def wrap_text_to_width(text, font, max_width):
"""Wrap text to fit within specified width"""
if not text.strip():
return [text]
# Ensure minimum width to prevent infinite loops
if max_width < 10:
return [text]
words = text.split()
if not words:
return [text]
lines = []
current_line = ""
for word in words:
test_line = f"{current_line} {word}".strip()
try:
bbox = font.getbbox(test_line)
text_width = bbox[2] - bbox[0]
except:
# Fallback for font issues
text_width = len(test_line) * (font.size // 2) # Rough estimation
if text_width <= max_width:
current_line = test_line
else:
if current_line:
lines.append(current_line)
current_line = word
else:
# Word is too long, add it anyway
lines.append(word)
current_line = ""
if current_line:
lines.append(current_line)
return lines if lines else [text]
def draw_multiline_text(draw, lines, font, text_color, props, canvas_width, canvas_height):
"""Draw multiline text with proper alignment and spacing"""
if not lines:
return
try:
# Calculate line metrics
bbox = font.getbbox("Ag") # Use standard characters for height measurement
base_line_height = bbox[3] - bbox[1]
line_height = int(base_line_height * props.line_height) + props.line_spacing_pixels
# Ensure minimum line height
if line_height < 1:
line_height = base_line_height
# Calculate total text block dimensions
max_line_width = 0
for line in lines:
if line.strip(): # Only measure non-empty lines
try:
bbox = font.getbbox(line)
line_width = bbox[2] - bbox[0]
max_line_width = max(max_line_width, line_width)
except:
# Fallback width calculation
line_width = len(line) * (font.size // 2)
max_line_width = max(max_line_width, line_width)
total_height = len(lines) * line_height - props.line_spacing_pixels # Remove extra spacing from last line
# Calculate starting position based on text block - use legacy positioning for now
# This maintains compatibility with existing positioning system
x = (canvas_width - max_line_width) // 2
y = (canvas_height - total_height) // 2
# Draw each line
current_y = y
for line in lines:
# Skip drawing if we're outside the canvas bounds
if current_y >= canvas_height:
break
if line.strip(): # Only process non-empty lines for alignment
# Calculate x position based on alignment
try:
bbox = font.getbbox(line)
line_width = bbox[2] - bbox[0]
except:
line_width = len(line) * (font.size // 2)
if props.text_alignment == 'LEFT':
line_x = x
elif props.text_alignment == 'CENTER':
line_x = x + max(0, (max_line_width - line_width) // 2)
else: # RIGHT
line_x = x + max(0, (max_line_width - line_width))
# Ensure we don't draw outside canvas bounds
line_x = max(0, min(line_x, canvas_width))
# Draw the line
draw.text((line_x, current_y), line, fill=text_color, font=font)
current_y += line_height
except Exception as e:
print(f"Error drawing multiline text: {e}")
# Fallback to drawing first line only
if lines and lines[0]:
draw.text((10, 10), lines[0], fill=text_color, font=font)
def draw_multiline_text_with_stroke(draw, lines, font, text_color, props, canvas_width, canvas_height):
"""Draw multiline text with stroke support"""
if not lines:
return
try:
# Calculate line metrics
bbox = font.getbbox("Ag") # Use standard characters for height measurement
base_line_height = bbox[3] - bbox[1]
line_height = int(base_line_height * props.line_height) + props.line_spacing_pixels
# Ensure minimum line height
if line_height < 1:
line_height = base_line_height
# Calculate total text block dimensions
max_line_width = 0
for line in lines:
if line.strip(): # Only measure non-empty lines
try:
bbox = font.getbbox(line)
line_width = bbox[2] - bbox[0]
max_line_width = max(max_line_width, line_width)
except:
# Fallback width calculation
line_width = len(line) * (font.size // 2)
max_line_width = max(max_line_width, line_width)
total_height = len(lines) * line_height - props.line_spacing_pixels # Remove extra spacing from last line
# Calculate starting position based on text block
x = (canvas_width - max_line_width) // 2
y = (canvas_height - total_height) // 2
# Stroke helper function with validation and error handling (same as main rendering)
def draw_text_with_stroke(draw_obj, text_str, pos_x, pos_y, font_obj, text_col, stroke_enabled, stroke_width, stroke_color, stroke_pos):
"""Draw text with optional stroke"""
try:
# Validation and bounds checking
if not stroke_enabled or stroke_width <= 0 or not text_str.strip():
# No stroke or empty text, draw normally
draw_obj.text((pos_x, pos_y), text_str, fill=text_col, font=font_obj)
return
# Validate stroke width bounds (0-20 pixels as per property definition)
stroke_width = max(0, min(20, int(stroke_width)))
if stroke_width == 0:
draw_obj.text((pos_x, pos_y), text_str, fill=text_col, font=font_obj)
return
# Validate stroke color
if not stroke_color or len(stroke_color) < 3:
print(f"[Stroke] Warning: Invalid stroke color, using black")
stroke_color = (0.0, 0.0, 0.0, 1.0)
# Convert stroke color with validation
stroke_col = []
for i, c in enumerate(stroke_color[:4]):
val = max(0.0, min(1.0, float(c))) # Clamp to 0-1 range
stroke_col.append(int(val * 255))
stroke_col = tuple(stroke_col[:3] + ([stroke_col[3]] if len(stroke_col) > 3 else [255]))
# Validate stroke position
if stroke_pos not in ['OUTER', 'INNER', 'CENTER']:
print(f"[Stroke] Warning: Invalid stroke position '{stroke_pos}', using OUTER")
stroke_pos = 'OUTER'
# Draw stroke based on position
if stroke_pos == 'OUTER':
# Outer stroke: draw stroke offsets around text
for dx in range(-stroke_width, stroke_width + 1):
for dy in range(-stroke_width, stroke_width + 1):
if dx*dx + dy*dy <= stroke_width*stroke_width: # Circular stroke
draw_obj.text((pos_x + dx, pos_y + dy), text_str, fill=stroke_col, font=font_obj)
# Draw text on top
draw_obj.text((pos_x, pos_y), text_str, fill=text_col, font=font_obj)
elif stroke_pos == 'CENTER':
# Center stroke: similar to outer but smaller offset
half_stroke = max(1, stroke_width // 2)
for dx in range(-half_stroke, half_stroke + 1):
for dy in range(-half_stroke, half_stroke + 1):
if dx*dx + dy*dy <= half_stroke*half_stroke:
draw_obj.text((pos_x + dx, pos_y + dy), text_str, fill=stroke_col, font=font_obj)
# Draw text on top
draw_obj.text((pos_x, pos_y), text_str, fill=text_col, font=font_obj)
elif stroke_pos == 'INNER':
# Inner stroke: draw text first, then smaller inner stroke
draw_obj.text((pos_x, pos_y), text_str, fill=text_col, font=font_obj)
# Create inner stroke effect by drawing smaller stroke inside
inner_offset = max(1, stroke_width // 3)
for dx in range(-inner_offset, inner_offset + 1):
for dy in range(-inner_offset, inner_offset + 1):
if dx*dx + dy*dy <= inner_offset*inner_offset and (dx != 0 or dy != 0):
# Use stroke color with some transparency for inner effect
inner_col = stroke_col[:3]
if len(stroke_col) > 3:
inner_col += (int(stroke_col[3] * 0.7),)
else:
inner_col += (int(255 * 0.7),)
draw_obj.text((pos_x + dx, pos_y + dy), text_str, fill=inner_col, font=font_obj)
except Exception as e:
print(f"[Stroke] Error drawing multiline stroke: {e}")
# Fallback to normal text drawing
try:
draw_obj.text((pos_x, pos_y), text_str, fill=text_col, font=font_obj)
except Exception as fallback_error:
print(f"[Stroke] Error in fallback multiline text drawing: {fallback_error}")
# Draw each line with stroke
current_y = y
for line in lines:
# Skip drawing if we're outside the canvas bounds
if current_y >= canvas_height:
break
if line.strip(): # Only process non-empty lines for alignment
# Calculate x position based on alignment
try:
bbox = font.getbbox(line)
line_width = bbox[2] - bbox[0]
except:
line_width = len(line) * (font.size // 2)
if props.text_alignment == 'LEFT':
line_x = x
elif props.text_alignment == 'CENTER':
line_x = x + max(0, (max_line_width - line_width) // 2)
else: # RIGHT
line_x = x + max(0, (max_line_width - line_width))
# Ensure we don't draw outside canvas bounds
line_x = max(0, min(line_x, canvas_width))
# Draw the line with stroke
draw_text_with_stroke(draw, line, line_x, current_y, font, text_color,
props.enable_stroke, props.stroke_width, props.stroke_color, props.stroke_position)
current_y += line_height
except Exception as e:
print(f"Error drawing multiline text with stroke: {e}")
# Fallback to drawing first line only
if lines and lines[0]:
draw.text((10, 10), lines[0], fill=text_color, font=font)
def calculate_smart_content_fitting(props, width, height, scale_factor):
"""Calculate smart content fitting for text + overlays to prevent overflow"""
try:
from PIL import Image, ImageDraw, ImageFont
# Initial font size calculation
base_font_size = max(6, int(props.font_size * scale_factor))
# Load font for measurement
def load_measurement_font(size):
font = None
if props.use_custom_font and props.custom_font_path and os.path.exists(props.custom_font_path):
try:
font = ImageFont.truetype(props.custom_font_path, size)
except:
pass
if not font and props.font_path != "default" and os.path.exists(props.font_path):
try:
font = ImageFont.truetype(props.font_path, size)
except:
pass
if not font:
font = ImageFont.load_default()
return font
font = load_measurement_font(base_font_size)
# Get text content
main_text = props.text if props.text else ""
prepend_text = props.prepend_text.strip() if props.prepend_text else ""
append_text = props.append_text.strip() if props.append_text else ""
# Calculate combined text for horizontal layout
if props.prepend_append_layout == 'HORIZONTAL':
text_parts = []
if prepend_text:
text_parts.append(prepend_text)
prepend_margin_px = int((props.prepend_margin / 100.0) * base_font_size)
space_count = max(1, int(prepend_margin_px / (base_font_size * 0.3)))
text_parts.append(" " * space_count)
text_parts.append(main_text)
if append_text:
append_margin_px = int((props.append_margin / 100.0) * base_font_size)
space_count = max(1, int(append_margin_px / (base_font_size * 0.3)))
text_parts.append(" " * space_count)
text_parts.append(append_text)
combined_text = "".join(text_parts)
else:
combined_text = main_text # For vertical layout, measure main text only
# Measure text dimensions
temp_draw = ImageDraw.Draw(Image.new('RGBA', (100, 100)))
try:
text_bbox = temp_draw.textbbox((0, 0), combined_text, font=font)
text_width = text_bbox[2] - text_bbox[0]
text_height = text_bbox[3] - text_bbox[1]
except:
text_width = len(combined_text) * int(base_font_size * 0.6)
text_height = base_font_size
# Calculate overlay dimensions
enabled_overlays = [overlay for overlay in props.image_overlays if overlay.enabled]
prepend_overlays = [o for o in enabled_overlays if o.positioning_mode == 'PREPEND' and o.image_path and os.path.exists(o.image_path)]
append_overlays = [o for o in enabled_overlays if o.positioning_mode == 'APPEND' and o.image_path and os.path.exists(o.image_path)]
prepend_total_width = 0
append_total_width = 0
# Calculate prepend overlay widths
for i, overlay in enumerate(prepend_overlays):
try:
overlay_img = Image.open(overlay.image_path).convert("RGBA")
if overlay.scale != 1.0:
final_scale = max(0.01, min(10.0, overlay.scale * scale_factor))
new_width = max(1, int(overlay_img.width * final_scale))
overlay_width = new_width
else:
overlay_width = overlay_img.width
prepend_total_width += overlay_width
if i == 0: # First overlay gets text spacing
text_spacing = max(0, (overlay.text_spacing / 100.0) * width)
prepend_total_width += text_spacing
else: # Subsequent overlays get image spacing
image_spacing = max(0, (overlay.image_spacing / 100.0) * width)
prepend_total_width += image_spacing
except:
# Fallback if image can't be loaded
prepend_total_width += base_font_size
# Calculate append overlay widths
for i, overlay in enumerate(append_overlays):
try:
overlay_img = Image.open(overlay.image_path).convert("RGBA")
if overlay.scale != 1.0:
final_scale = max(0.01, min(10.0, overlay.scale * scale_factor))
new_width = max(1, int(overlay_img.width * final_scale))
overlay_width = new_width
else:
overlay_width = overlay_img.width
if i == 0: # First overlay gets text spacing
text_spacing = max(0, (overlay.text_spacing / 100.0) * width)
append_total_width += text_spacing + overlay_width
else: # Subsequent overlays get image spacing
image_spacing = max(0, (overlay.image_spacing / 100.0) * width)
append_total_width += image_spacing + overlay_width
except:
# Fallback if image can't be loaded
append_total_width += base_font_size
# Calculate total content width
total_content_width = prepend_total_width + text_width + append_total_width
# Add safety margins (10% of canvas width)
safety_margin = int(width * 0.1)
available_width = width - (2 * safety_margin)
print(f"SMART FITTING: Text width: {text_width}, Prepend: {prepend_total_width}, Append: {append_total_width}")
print(f"SMART FITTING: Total content: {total_content_width}, Available: {available_width}")
# Determine if smart fitting is needed
fitting_needed = total_content_width > available_width
adjusted_font_size = base_font_size
adjusted_scale_factor = scale_factor
fitting_strategy = "NONE"
if fitting_needed:
print(f"SMART FITTING: Content overflow detected! Applying smart fitting...")
# Strategy 1: Proportional scaling
if total_content_width > 0:
scale_ratio = available_width / total_content_width
# Don't scale below 50% of original size to maintain readability
scale_ratio = max(0.5, scale_ratio)
adjusted_font_size = max(6, int(base_font_size * scale_ratio))
adjusted_scale_factor = scale_factor * scale_ratio
fitting_strategy = "PROPORTIONAL_SCALE"
print(f"SMART FITTING: Applied {scale_ratio:.2f}x scaling, new font size: {adjusted_font_size}")
return {
'font_size': adjusted_font_size,
'scale_factor': adjusted_scale_factor,
'fitting_needed': fitting_needed,
'fitting_strategy': fitting_strategy,
'original_content_width': total_content_width,
'available_width': available_width,
'scale_ratio': adjusted_scale_factor / scale_factor if scale_factor > 0 else 1.0
}
except Exception as e:
print(f"ERROR in smart content fitting: {e}")
# Return safe defaults
return {
'font_size': max(6, int(props.font_size * scale_factor)),
'scale_factor': scale_factor,
'fitting_needed': False,
'fitting_strategy': "ERROR_FALLBACK",
'original_content_width': 0,
'available_width': width,
'scale_ratio': 1.0
}
def generate_texture_image(props, width, height):
"""Generate the actual texture image with overlays - width and height are REQUIRED
Returns: (diffuse_image, normal_map_image) or (diffuse_image, None) if normal map disabled"""
try:
install_pillow()
from PIL import Image, ImageDraw, ImageFont, ImageFilter
import math
# Use the exact dimensions provided
print(f"Generating PIL image at {width}x{height}")
# Ensure minimum dimensions
width = max(16, width)
height = max(16, height)
print(f"DEBUG: Generating texture - requested: {width}x{height}, texture settings: {props.texture_width}x{props.texture_height}")
print(f"DEBUG: Normal map generation enabled: {props.generate_normal_map}")
# Calculate smart content fitting
scale_factor = width / props.texture_width
fitting_result = calculate_smart_content_fitting(props, width, height, scale_factor)
# Use adjusted values from smart fitting
base_font_size = fitting_result['font_size']
adjusted_scale_factor = fitting_result['scale_factor']
padding = max(0, int(props.padding * adjusted_scale_factor))
print(f"DEBUG: Original scale factor: {scale_factor}, adjusted: {adjusted_scale_factor}")
print(f"DEBUG: Smart fitting applied: {fitting_result['fitting_strategy']}")
print(f"DEBUG: Adjusted font size: {base_font_size}, scaled padding: {padding}")
# Create base image with background
if props.background_transparent:
base_img = Image.new('RGBA', (width, height), (0, 0, 0, 0))
else:
bg_color = tuple(int(c * 255) for c in props.background_color[:3])
if len(props.background_color) > 3:
bg_color += (int(props.background_color[3] * 255),)
base_img = Image.new('RGBA', (width, height), bg_color)
print(f"Base image created: {base_img.size}")
# Collect all elements with z-index (text is z-index 0)
elements = []
# ENHANCEMENT: Filter disabled overlays early to completely exclude them from all calculations
# This ensures disabled overlays don't consume space in APPEND/PREPEND modes and don't render
# When overlays are disabled (enabled=False), they are completely excluded from:
# - Space calculations, text bounds calculations, image positioning, spacing calculations, and rendering
enabled_overlays = [overlay for overlay in props.image_overlays if overlay.enabled]
# Separate enabled overlays by positioning mode and sort them
absolute_overlays = []
prepend_overlays = []
append_overlays = []
for overlay in enabled_overlays:
if overlay.image_path and os.path.exists(overlay.image_path):
if overlay.positioning_mode == 'ABSOLUTE':
absolute_overlays.append(overlay)
elif overlay.positioning_mode == 'PREPEND':
prepend_overlays.append(overlay)
elif overlay.positioning_mode == 'APPEND':
append_overlays.append(overlay)
# Prepend and append overlays are already in order based on list position
# No sorting needed since we removed sequence_index - order is determined by list position
# PLACEHOLDER: text_bbox will be calculated after text setup
text_bbox = None
# PERFORMANCE FIX: Cache loaded overlay images to prevent multiple loading
overlay_image_cache = {}
def load_overlay_image_cached(overlay_path, scale, scale_factor):
"""Load and cache overlay images to improve performance"""
cache_key = (overlay_path, scale, scale_factor)
if cache_key in overlay_image_cache:
return overlay_image_cache[cache_key], None # Return cached image, no error
try:
# ERROR HANDLING: Check file existence and validate path
if not overlay_path or not os.path.exists(overlay_path):
return None, f"Image file not found: {overlay_path}"
# EDGE CASE: Check file size to prevent memory issues
try:
file_size = os.path.getsize(overlay_path)
if file_size > 50 * 1024 * 1024: # 50MB limit
return None, f"Image file too large: {file_size} bytes"
except OSError:
return None, f"Cannot access image file: {overlay_path}"
# Load and convert image
overlay_img = Image.open(overlay_path).convert("RGBA")
# EDGE CASE: Check for zero dimensions
if overlay_img.width == 0 or overlay_img.height == 0:
return None, f"Image has zero dimensions: {overlay_img.width}x{overlay_img.height}"
# Apply scaling
if scale != 1.0:
# EDGE CASE: Prevent extreme scaling that could cause memory issues
final_scale = max(0.01, min(10.0, scale * scale_factor)) # Limit scale between 1% and 1000%
new_width = max(1, int(overlay_img.width * final_scale))
new_height = max(1, int(overlay_img.height * final_scale))
# EDGE CASE: Prevent extremely large scaled images
if new_width > 4096 or new_height > 4096:
return None, f"Scaled image too large: {new_width}x{new_height}"
overlay_img = overlay_img.resize((new_width, new_height), Image.LANCZOS)
# Cache the processed image
overlay_image_cache[cache_key] = overlay_img
return overlay_img, None
except Exception as e:
error_msg = f"Error loading overlay image '{overlay_path}': {str(e)}"
return None, error_msg
# Apply overlays in z-index order, grouped by positioning mode
all_overlays_with_mode = []
# Add absolute overlays (maintain backward compatibility)
for overlay in absolute_overlays:
all_overlays_with_mode.append((overlay, 'ABSOLUTE'))
# Add prepend overlays (positioned to the left of text)
prepend_x_offset = 0
for i, overlay in enumerate(prepend_overlays):
all_overlays_with_mode.append((overlay, 'PREPEND', prepend_x_offset))
if text_bbox:
# Calculate spacing for next overlay using cached loading with adjusted scale factor
temp_overlay_img, error = load_overlay_image_cached(overlay.image_path, overlay.scale, adjusted_scale_factor)
if temp_overlay_img:
# EDGE CASE: Validate spacing values to prevent extreme spacing (percentage of canvas width)
text_spacing = max(0, min(1000, (overlay.text_spacing / 100.0) * width))
image_spacing = max(0, min(1000, (overlay.image_spacing / 100.0) * width))
# Add image width plus spacing for next position
prepend_x_offset += temp_overlay_img.width + (text_spacing if i == 0 else image_spacing)
else:
print(f"Warning: {error}")
# Fallback spacing if image can't be loaded
prepend_x_offset += base_font_size
# Add append overlays (positioned to the right of text)
append_x_offset = 0
for i, overlay in enumerate(append_overlays):
all_overlays_with_mode.append((overlay, 'APPEND', append_x_offset))
if text_bbox:
# Calculate spacing for next overlay using cached loading with adjusted scale factor
temp_overlay_img, error = load_overlay_image_cached(overlay.image_path, overlay.scale, adjusted_scale_factor)
if temp_overlay_img:
# EDGE CASE: Validate spacing values to prevent extreme spacing (percentage of canvas width)
text_spacing = max(0, min(1000, (overlay.text_spacing / 100.0) * width))
image_spacing = max(0, min(1000, (overlay.image_spacing / 100.0) * width))
# Calculate position for this overlay, then update offset for next
if i > 0:
append_x_offset += image_spacing
else:
append_x_offset += text_spacing
# Update offset for next overlay
if i < len(append_overlays) - 1: # Don't add for last overlay
append_x_offset += temp_overlay_img.width
else:
print(f"Warning: {error}")
# Fallback spacing if image can't be loaded
append_x_offset += base_font_size if i == 0 else base_font_size * 0.5
# Sort all overlays by z-index
all_overlays_with_mode.sort(key=lambda x: x[0].z_index)
# OVERLAY PROCESSING MOVED: This section is now processed after text positioning
# to ensure text_bbox is available for prepend/append positioning modes
# Create text element (z-index 0)
text_img = Image.new('RGBA', (width, height), (0, 0, 0, 0))
draw = ImageDraw.Draw(text_img)
# Load font with enhanced mixed-case validation
def load_font_at_size(size):
"""Helper function to load font at specific size"""
font = None
font_loaded = False
# Try custom font first with validation
if props.use_custom_font and props.custom_font_path:
font, error_msg = get_validated_font(props.custom_font_path, size)
if font:
font_loaded = True
else:
print(f"FAILED: Custom font validation failed: {error_msg}")
# Try selected system font with validation
if not font_loaded and props.font_path != "default":
font, error_msg = get_validated_font(props.font_path, size)
if font:
font_loaded = True
else:
print(f"FAILED: System font validation failed: {error_msg}")
# Enhanced fallback with mixed-case validation
if not font_loaded:
fallback_fonts = get_system_fallback_fonts()
for fallback_path in fallback_fonts:
if os.path.exists(fallback_path):
font, error_msg = get_validated_font(fallback_path, size)
if font:
font_loaded = True
break
else:
print(f"FAILED: Fallback font validation failed: {error_msg}")
# Last resort - use default font but warn about mixed-case limitations
if not font_loaded:
font = ImageFont.load_default()
font_loaded = True
print("WARNING: Using default font - this may not support mixed-case properly!")
print("WARNING: Consider installing additional fonts for proper mixed-case support")
return font, font_loaded
# Initial font loading
print("DEBUG UPPERCASE FIX: Starting enhanced font loading with mixed-case validation")
font_size_pixels = base_font_size
font, font_loaded = load_font_at_size(font_size_pixels)
# ============================================================================
# PREPEND/APPEND TEXT PROCESSING
# ============================================================================
# Get the main text
main_text = props.text if props.text is not None else ""
# Get prepend and append text
prepend_text = props.prepend_text.strip() if props.prepend_text else ""
append_text = props.append_text.strip() if props.append_text else ""
# Calculate margins as pixels (percentage of font size)
prepend_margin_px = int((props.prepend_margin / 100.0) * font_size_pixels) if prepend_text else 0
append_margin_px = int((props.append_margin / 100.0) * font_size_pixels) if append_text else 0
# Combine text based on layout
if props.prepend_append_layout == 'HORIZONTAL':
# Horizontal layout: [prepend] [margin] [main] [margin] [append]
text_parts = []
if prepend_text:
text_parts.append(prepend_text)
# Add proper spacing based on margin percentage
if prepend_margin_px > 0:
# Convert margin pixels to approximate space characters
space_count = max(1, int(prepend_margin_px / (font_size_pixels * 0.3)))
text_parts.append(" " * space_count)
else:
text_parts.append(" ") # Default single space
text_parts.append(main_text)
if append_text:
# Add proper spacing based on margin percentage
if append_margin_px > 0:
space_count = max(1, int(append_margin_px / (font_size_pixels * 0.3)))
text_parts.append(" " * space_count)
else:
text_parts.append(" ") # Default single space
text_parts.append(append_text)
text = "".join(text_parts)
else:
# Vertical layout will be handled separately - for now use main text
text = main_text
# ENHANCED DIAGNOSTIC LOGGING for uppercase text issue
print(f"DEBUG PREPEND/APPEND - Original main text: '{props.text}'")
print(f"DEBUG PREPEND/APPEND - Prepend text: '{prepend_text}'")
print(f"DEBUG PREPEND/APPEND - Append text: '{append_text}'")
print(f"DEBUG PREPEND/APPEND - Layout: '{props.prepend_append_layout}'")
print(f"DEBUG PREPEND/APPEND - Final combined text: '{text}'")
print(f"DEBUG UPPERCASE ISSUE - Text type: {type(text)}")
print(f"DEBUG UPPERCASE ISSUE - Has upper chars: {any(c.isupper() for c in text)}")
print(f"DEBUG UPPERCASE ISSUE - Has lower chars: {any(c.islower() for c in text)}")
print(f"DEBUG UPPERCASE ISSUE - Font being used: {font}")
if hasattr(font, 'path'):
print(f"DEBUG UPPERCASE ISSUE - Font path: {font.path}")
# Test the current font's mixed-case rendering capability
print("DEBUG UPPERCASE ISSUE - Testing current font mixed-case capability...")
if test_font_mixed_case_support(font):
print("SUCCESS: Current font supports mixed-case rendering properly!")
else:
print("WARNING: Current font may not support mixed-case rendering properly!")
print("This could be the cause of uppercase-only text rendering")
# ============================================================================
# TEXT FITTING SYSTEM
# ============================================================================
# ============================================================================
# PREPEND/APPEND TEXT PROCESSING
# ============================================================================
# REMOVED: Duplicate text processing code block - already handled above
# Calculate space occupied by absolute positioned overlays (MOVED OUT OF TEXT FITTING BLOCK)
# This is needed for both text fitting and text positioning, so it must be available regardless
overlay_occupied_areas = []
enabled_overlays = [overlay for overlay in props.image_overlays if overlay.enabled]
absolute_overlays = [o for o in enabled_overlays if o.positioning_mode == 'ABSOLUTE' and o.image_path and os.path.exists(o.image_path)]
for overlay in absolute_overlays:
try:
# Load overlay image to get dimensions
overlay_img, error = load_overlay_image_cached(overlay.image_path, overlay.scale, scale_factor)
if overlay_img:
# Calculate absolute position
x_position = max(0.0, min(1.0, overlay.x_position))
y_position = max(0.0, min(1.0, overlay.y_position))
overlay_x = int(x_position * width - overlay_img.width / 2)
overlay_y = int((1.0 - y_position) * height - overlay_img.height / 2)
# Store occupied area (x, y, width, height)
overlay_occupied_areas.append({
'x': overlay_x,
'y': overlay_y,
'width': overlay_img.width,
'height': overlay_img.height
})
print(f"DEBUG OVERLAY AREAS: Absolute overlay at ({overlay_x}, {overlay_y}) size {overlay_img.width}x{overlay_img.height}")
except Exception as e:
print(f"DEBUG OVERLAY AREAS: Error processing absolute overlay {overlay.name}: {e}")
# Apply text fitting if enabled
if props.enable_text_fitting:
print(f"DEBUG TEXT FITTING: Starting text fitting process")
print(f"DEBUG TEXT FITTING: Canvas size: {width}x{height}")
print(f"DEBUG TEXT FITTING: Initial font size: {font_size_pixels}")
print(f"DEBUG TEXT FITTING: Fitting mode: {props.text_fitting_mode}")
# Calculate available space with margin and respect for absolute positioned overlays
margin_px_x = int((props.text_fitting_margin / 100.0) * width)
margin_px_y = int((props.text_fitting_margin / 100.0) * height)
base_available_width = width - (2 * margin_px_x)
base_available_height = height - (2 * margin_px_y)
# Calculate effective available area by finding the largest contiguous rectangle
# that doesn't overlap with any absolute positioned overlays (use already calculated overlay_occupied_areas)
available_width, available_height = calculate_available_text_area(
base_available_width, base_available_height,
overlay_occupied_areas,
margin_px_x, margin_px_y, width, height
)
print(f"DEBUG TEXT FITTING: Base available space: {base_available_width}x{base_available_height} (margin: {margin_px_x}x{margin_px_y})")
print(f"DEBUG TEXT FITTING: Final available space (after overlay exclusion): {available_width}x{available_height}")
# Apply scale factor to min/max font sizes
min_font_size_scaled = max(4, int(props.min_font_size * scale_factor))
max_font_size_scaled = max(min_font_size_scaled, int(props.max_font_size * scale_factor))
# Binary search for optimal font size
def calculate_text_dimensions(test_font_size):
"""Calculate text dimensions for a given font size"""
try:
test_font, _ = load_font_at_size(test_font_size)
temp_draw = ImageDraw.Draw(Image.new('RGBA', (100, 100)))
if props.prepend_append_layout == 'VERTICAL' and (prepend_text or append_text):
# Calculate vertical layout dimensions
total_width = 0
total_height = 0
if prepend_text:
prepend_bbox = temp_draw.textbbox((0, 0), prepend_text, font=test_font)
prepend_width = prepend_bbox[2] - prepend_bbox[0]
prepend_height = prepend_bbox[3] - prepend_bbox[1]
total_width = max(total_width, prepend_width)
total_height += prepend_height + int((props.prepend_margin / 100.0) * test_font_size)
main_bbox = temp_draw.textbbox((0, 0), main_text, font=test_font)
main_width = main_bbox[2] - main_bbox[0]
main_height = main_bbox[3] - main_bbox[1]
total_width = max(total_width, main_width)
total_height += main_height
if append_text:
append_bbox = temp_draw.textbbox((0, 0), append_text, font=test_font)
append_width = append_bbox[2] - append_bbox[0]
append_height = append_bbox[3] - append_bbox[1]
total_width = max(total_width, append_width)
total_height += int((props.append_margin / 100.0) * test_font_size) + append_height
return total_width, total_height
else:
# Calculate horizontal layout dimensions
bbox = temp_draw.textbbox((0, 0), text, font=test_font)
return bbox[2] - bbox[0], bbox[3] - bbox[1]
except Exception as e:
print(f"DEBUG TEXT FITTING: Error calculating dimensions: {e}")
# Return fallback dimensions
return int(len(text) * test_font_size * 0.6), test_font_size
# Binary search for optimal font size
low_size = min_font_size_scaled
high_size = max_font_size_scaled
optimal_size = font_size_pixels
print(f"DEBUG TEXT FITTING: Binary search range: {low_size} to {high_size}")
try:
for iteration in range(20): # Limit iterations to prevent infinite loops
mid_size = (low_size + high_size) // 2
if mid_size == low_size or mid_size == high_size:
break
test_width, test_height = calculate_text_dimensions(mid_size)
fits_criteria = False
if props.text_fitting_mode == 'FIT_WIDTH':
fits_criteria = test_width <= available_width
elif props.text_fitting_mode == 'FIT_HEIGHT':
fits_criteria = test_height <= available_height
elif props.text_fitting_mode == 'FIT_BOTH':
fits_criteria = test_width <= available_width and test_height <= available_height
elif props.text_fitting_mode == 'FIT_CONTAIN':
fits_criteria = test_width <= available_width and test_height <= available_height
print(f"DEBUG TEXT FITTING: Iteration {iteration}: size={mid_size}, dims={test_width}x{test_height}, fits={fits_criteria}")
if fits_criteria:
optimal_size = mid_size
low_size = mid_size
else:
high_size = mid_size
# Apply the optimal font size
if optimal_size != font_size_pixels:
font_size_pixels = optimal_size
font, font_loaded = load_font_at_size(font_size_pixels)
print(f"DEBUG TEXT FITTING: Applied optimal font size: {font_size_pixels}")
else:
print(f"DEBUG TEXT FITTING: No adjustment needed, keeping original size: {font_size_pixels}")
except Exception as e:
print(f"DEBUG TEXT FITTING: Error in binary search: {e}")
print("DEBUG TEXT FITTING: Continuing with original font size")
# Handle vertical layout separately if needed
if props.prepend_append_layout == 'VERTICAL' and (prepend_text or append_text):
# For vertical layout, we need to render each text part separately
# and position them vertically with proper margins
# Calculate dimensions for each text part
main_bbox = draw.textbbox((0, 0), main_text, font=font)
main_width = max(1, main_bbox[2] - main_bbox[0])
main_height = max(1, main_bbox[3] - main_bbox[1])
total_width = main_width
total_height = main_height
text_elements = [(main_text, main_width, main_height)]
if prepend_text:
prepend_bbox = draw.textbbox((0, 0), prepend_text, font=font)
prepend_width = max(1, prepend_bbox[2] - prepend_bbox[0])
prepend_height = max(1, prepend_bbox[3] - prepend_bbox[1])
total_width = max(total_width, prepend_width)
total_height += prepend_height + prepend_margin_px
text_elements.insert(0, (prepend_text, prepend_width, prepend_height))
if append_text:
append_bbox = draw.textbbox((0, 0), append_text, font=font)
append_width = max(1, append_bbox[2] - append_bbox[0])
append_height = max(1, append_bbox[3] - append_bbox[1])
total_width = max(total_width, append_width)
total_height += append_height + append_margin_px
text_elements.append((append_text, append_width, append_height))
text_width = total_width
text_height = total_height
print(f"DEBUG VERTICAL LAYOUT - Total dimensions: {text_width}x{text_height}")
print(f"DEBUG VERTICAL LAYOUT - Text elements: {len(text_elements)}")
else:
# Standard bbox calculation for horizontal layout or single text
try:
bbox = draw.textbbox((0, 0), text, font=font)
text_width = max(1, bbox[2] - bbox[0])
text_height = max(1, bbox[3] - bbox[1])
except (OSError, ZeroDivisionError, ValueError) as e:
print(f"Error getting text bbox: {e}")
text_width = int(len(text) * font_size_pixels * 0.6)
text_height = font_size_pixels
# ============================================================================
# ENHANCED POSITIONING SYSTEM - Replaces hardcoded vertical centering
# ============================================================================
# Calculate prepend width for text positioning adjustment
prepend_width = 0
enabled_overlays = [overlay for overlay in props.image_overlays if overlay.enabled]
prepend_overlays = [o for o in enabled_overlays if o.positioning_mode == 'PREPEND' and o.image_path and os.path.exists(o.image_path)]
print(f"DEBUG TEXT POSITION: Calculating prepend width from {len(prepend_overlays)} prepend overlays")
for i, overlay in enumerate(prepend_overlays):
try:
# Load overlay image to get dimensions using the cached loader
overlay_img, error = load_overlay_image_cached(overlay.image_path, overlay.scale, adjusted_scale_factor)
if overlay_img:
# Add image width plus percentage-based spacing
prepend_width += overlay_img.width
# Add text spacing for the first overlay, image spacing for subsequent ones
# Both spacings are calculated as percentage of canvas width, not font size
if i == 0:
text_spacing_px = max(0, (overlay.text_spacing / 100.0) * width)
prepend_width += text_spacing_px
print(f"DEBUG TEXT POSITION: Prepend overlay {i} adds {overlay_img.width}px + {text_spacing_px}px text spacing (percentage-based)")
else:
image_spacing_px = max(0, (overlay.image_spacing / 100.0) * width)
prepend_width += image_spacing_px
print(f"DEBUG TEXT POSITION: Prepend overlay {i} adds {overlay_img.width}px + {image_spacing_px}px image spacing (percentage-based)")
print(f"DEBUG TEXT POSITION: Total prepend width: {prepend_width}px")
else:
print(f"DEBUG TEXT POSITION: Warning - Could not load prepend overlay: {error}")
except Exception as e:
print(f"DEBUG TEXT POSITION: Error calculating prepend width for overlay {i}: {e}")
print(f"DEBUG TEXT POSITION: Final prepend width calculated: {prepend_width}px (percentage-based spacing)")
# Use enhanced positioning system with overlay avoidance and prepend width
x, y = calculate_text_position(props, width, height, text_width, text_height, overlay_occupied_areas, scale_factor, prepend_width)
# Apply legacy text_align for backward compatibility if using MIDDLE_CENTER anchor
if props.anchor_point == 'MIDDLE_CENTER' and props.position_mode == 'PRESET':
if props.text_align == 'center':
x = (width - text_width) // 2
elif props.text_align == 'right':
x = width - text_width - padding
elif props.text_align == 'left':
x = padding
# Apply offsets even in legacy mode
x += props.offset_x
y += props.offset_y
# Calculate text bounds for positioning relative overlays (FIXED BUG #1)
# Now that font, x, y, and text variables are properly defined, we can safely calculate text_bbox
if prepend_overlays or append_overlays:
if props.prepend_append_layout == 'VERTICAL' and (prepend_text or append_text):
# For vertical layout, use the total calculated dimensions
text_bbox = (x, y, x + text_width, y + text_height)
else:
# For horizontal layout, calculate bbox using the combined text
try:
# Try new textbbox method (PIL 8.0+)
temp_draw = ImageDraw.Draw(Image.new('RGBA', (100, 100)))
bbox = temp_draw.textbbox((0, 0), text, font=font)
actual_text_width = bbox[2] - bbox[0]
actual_text_height = bbox[3] - bbox[1]
text_bbox = (x, y, x + actual_text_width, y + actual_text_height)
except AttributeError:
# Fallback to deprecated textsize method
temp_draw = ImageDraw.Draw(Image.new('RGBA', (100, 100)))
actual_text_width, actual_text_height = temp_draw.textsize(text, font=font)
text_bbox = (x, y, x + actual_text_width, y + actual_text_height)
else:
# No relative overlays, set bbox to None
text_bbox = None
# Draw text with stroke support
text_color = tuple(int(c * 255) for c in props.text_color[:3])
if len(props.text_color) > 3:
text_color += (int(props.text_color[3] * 255),)
# Stroke rendering helper function with validation and error handling
def draw_text_with_stroke(draw_obj, text_str, pos_x, pos_y, font_obj, text_col, stroke_enabled, stroke_width, stroke_color, stroke_pos):
"""Draw text with optional stroke"""
try:
# Validation and bounds checking
if not stroke_enabled or stroke_width <= 0 or not text_str.strip():
# No stroke or empty text, draw normally
draw_obj.text((pos_x, pos_y), text_str, fill=text_col, font=font_obj)
return
# Validate stroke width bounds (0-20 pixels as per property definition)
stroke_width = max(0, min(20, int(stroke_width)))
if stroke_width == 0:
draw_obj.text((pos_x, pos_y), text_str, fill=text_col, font=font_obj)
return
# Validate stroke color
if not stroke_color or len(stroke_color) < 3:
print(f"[Stroke] Warning: Invalid stroke color, using black")
stroke_color = (0.0, 0.0, 0.0, 1.0)
# Convert stroke color with validation
stroke_col = []
for i, c in enumerate(stroke_color[:4]):
val = max(0.0, min(1.0, float(c))) # Clamp to 0-1 range
stroke_col.append(int(val * 255))
stroke_col = tuple(stroke_col[:3] + ([stroke_col[3]] if len(stroke_col) > 3 else [255]))
# Validate stroke position
if stroke_pos not in ['OUTER', 'INNER', 'CENTER']:
print(f"[Stroke] Warning: Invalid stroke position '{stroke_pos}', using OUTER")
stroke_pos = 'OUTER'
# Draw stroke based on position
if stroke_pos == 'OUTER':
# Outer stroke: draw stroke offsets around text
for dx in range(-stroke_width, stroke_width + 1):
for dy in range(-stroke_width, stroke_width + 1):
if dx*dx + dy*dy <= stroke_width*stroke_width: # Circular stroke
draw_obj.text((pos_x + dx, pos_y + dy), text_str, fill=stroke_col, font=font_obj)
# Draw text on top
draw_obj.text((pos_x, pos_y), text_str, fill=text_col, font=font_obj)
elif stroke_pos == 'CENTER':
# Center stroke: similar to outer but smaller offset
half_stroke = max(1, stroke_width // 2)
for dx in range(-half_stroke, half_stroke + 1):
for dy in range(-half_stroke, half_stroke + 1):
if dx*dx + dy*dy <= half_stroke*half_stroke:
draw_obj.text((pos_x + dx, pos_y + dy), text_str, fill=stroke_col, font=font_obj)
# Draw text on top
draw_obj.text((pos_x, pos_y), text_str, fill=text_col, font=font_obj)
elif stroke_pos == 'INNER':
# Inner stroke: draw text first, then smaller inner stroke
draw_obj.text((pos_x, pos_y), text_str, fill=text_col, font=font_obj)
# Create inner stroke effect by drawing smaller stroke inside
inner_offset = max(1, stroke_width // 3)
for dx in range(-inner_offset, inner_offset + 1):
for dy in range(-inner_offset, inner_offset + 1):
if dx*dx + dy*dy <= inner_offset*inner_offset and (dx != 0 or dy != 0):
# Use stroke color with some transparency for inner effect
inner_col = stroke_col[:3]
if len(stroke_col) > 3:
inner_col += (int(stroke_col[3] * 0.7),)
else:
inner_col += (int(255 * 0.7),)
draw_obj.text((pos_x + dx, pos_y + dy), text_str, fill=inner_col, font=font_obj)
except Exception as e:
print(f"[Stroke] Error drawing stroke: {e}")
# Fallback to normal text drawing
try:
draw_obj.text((pos_x, pos_y), text_str, fill=text_col, font=font_obj)
except Exception as fallback_error:
print(f"[Stroke] Error in fallback text drawing: {fallback_error}")
# Handle vertical layout rendering with stroke
if props.prepend_append_layout == 'VERTICAL' and (prepend_text or append_text):
print(f"DEBUG VERTICAL LAYOUT - Rendering vertical text layout with stroke: {props.enable_stroke}")
# Calculate starting Y position for vertical stack
current_y = y
# Render prepend text if exists
if prepend_text:
print(f"DEBUG VERTICAL LAYOUT - Drawing prepend text: '{prepend_text}' at y={current_y}")
draw_text_with_stroke(draw, prepend_text, x, current_y, font, text_color,
props.enable_stroke, props.stroke_width, props.stroke_color, props.stroke_position)
prepend_bbox = draw.textbbox((0, 0), prepend_text, font=font)
prepend_height = prepend_bbox[3] - prepend_bbox[1]
current_y += prepend_height + prepend_margin_px
# Render main text
print(f"DEBUG VERTICAL LAYOUT - Drawing main text: '{main_text}' at y={current_y}")
draw_text_with_stroke(draw, main_text, x, current_y, font, text_color,
props.enable_stroke, props.stroke_width, props.stroke_color, props.stroke_position)
main_bbox = draw.textbbox((0, 0), main_text, font=font)
main_height = main_bbox[3] - main_bbox[1]
current_y += main_height + append_margin_px
# Render append text if exists
if append_text:
print(f"DEBUG VERTICAL LAYOUT - Drawing append text: '{append_text}' at y={current_y}")
draw_text_with_stroke(draw, append_text, x, current_y, font, text_color,
props.enable_stroke, props.stroke_width, props.stroke_color, props.stroke_position)
print(f"DEBUG VERTICAL LAYOUT - Vertical text rendering with stroke completed")
# ============================================================================
# OVERLAY PROCESSING - MOVED TO CORRECT POSITION AFTER TEXT POSITIONING
# ============================================================================
# Now that text positioning is complete and text_bbox is calculated,
# process overlays that depend on text position (PREPEND/APPEND modes)
# Apply overlays using cached loading with comprehensive error handling
for overlay_data in all_overlays_with_mode:
overlay = overlay_data[0]
mode = overlay_data[1]
# Use cached loading for better performance and error handling with adjusted scale factor
overlay_img, error = load_overlay_image_cached(overlay.image_path, overlay.scale, adjusted_scale_factor)
if overlay_img is None:
print(f"Skipping overlay {overlay.name}: {error}")
continue
try:
# Apply rotation with error handling
if overlay.rotation != 0:
# EDGE CASE: Validate rotation range
rotation_angle = overlay.rotation % 360 # Normalize to 0-360
if abs(rotation_angle) > 0.1: # Only rotate if meaningful angle
overlay_img = overlay_img.rotate(rotation_angle, expand=True)
# Calculate position based on positioning mode
if mode == 'ABSOLUTE':
# Use existing absolute positioning (backward compatibility)
# EDGE CASE: Ensure position values are valid
x_position = max(0.0, min(1.0, overlay.x_position))
y_position = max(0.0, min(1.0, overlay.y_position))
x_pos = int(x_position * width - overlay_img.width / 2)
y_pos = int((1.0 - y_position) * height - overlay_img.height / 2)
elif mode == 'PREPEND':
if text_bbox:
# Calculate text spacing in pixels from percentage of canvas width (not font size)
text_spacing_px = int((overlay.text_spacing / 100.0) * width)
# Position to the left of text bounds with proper text spacing
x_pos = int(text_bbox[0] - text_spacing_px - overlay_img.width)
y_pos = int(text_bbox[1] + (text_bbox[3] - text_bbox[1] - overlay_img.height) / 2)
print(f"DEBUG PREPEND: Positioning overlay at ({x_pos}, {y_pos}) with text_spacing={text_spacing_px}px (percentage-based) relative to text_bbox {text_bbox}")
else:
# No text bbox available, position at left side of canvas
x_pos = 10 # Small margin from left edge
y_pos = int((height - overlay_img.height) / 2) # Vertically centered
print(f"DEBUG PREPEND: No text_bbox available, positioning at left side: ({x_pos}, {y_pos})")
elif mode == 'APPEND':
if text_bbox:
# Calculate text spacing in pixels from percentage of canvas width (not font size)
text_spacing_px = int((overlay.text_spacing / 100.0) * width)
# Position to the right of text bounds with proper text spacing
x_pos = int(text_bbox[2] + text_spacing_px)
y_pos = int(text_bbox[1] + (text_bbox[3] - text_bbox[1] - overlay_img.height) / 2)
print(f"DEBUG APPEND: Positioning overlay at ({x_pos}, {y_pos}) with text_spacing={text_spacing_px}px (percentage-based) relative to text_bbox {text_bbox}")
else:
# No text bbox available, position at right side of canvas
x_pos = int(width - overlay_img.width - 10) # Small margin from right edge
y_pos = int((height - overlay_img.height) / 2) # Vertically centered
print(f"DEBUG APPEND: No text_bbox available, positioning at right side: ({x_pos}, {y_pos})")
else:
# Fallback to absolute positioning
x_position = max(0.0, min(1.0, overlay.x_position))
y_position = max(0.0, min(1.0, overlay.y_position))
x_pos = int(x_position * width - overlay_img.width / 2)
y_pos = int((1.0 - y_position) * height - overlay_img.height / 2)
# EDGE CASE: Ensure positions are within bounds and handle negative dimensions gracefully
if overlay_img.width <= 0 or overlay_img.height <= 0:
print(f"Warning: Overlay {overlay.name} has invalid dimensions: {overlay_img.width}x{overlay_img.height}")
continue
x_pos = max(0, min(x_pos, width - overlay_img.width))
y_pos = max(0, min(y_pos, height - overlay_img.height))
# EDGE CASE: Skip overlay if it's completely outside the canvas
if x_pos >= width or y_pos >= height or x_pos + overlay_img.width <= 0 or y_pos + overlay_img.height <= 0:
print(f"Warning: Overlay {overlay.name} is positioned outside canvas bounds, skipping")
continue
elements.append((overlay.z_index, 'image', overlay_img, x_pos, y_pos))
print(f"Successfully added overlay {overlay.name} ({mode}) at z-index {overlay.z_index}, pos ({x_pos}, {y_pos})")
except Exception as e:
print(f"Error processing overlay {overlay.name}: {e}")
import traceback
traceback.print_exc()
# Handle vertical layout rendering with stroke
if props.prepend_append_layout == 'VERTICAL' and (prepend_text or append_text):
print(f"DEBUG VERTICAL LAYOUT - Rendering vertical text layout with stroke: {props.enable_stroke}")
# Calculate starting Y position for vertical stack
current_y = y
# Render prepend text if exists
if prepend_text:
print(f"DEBUG VERTICAL LAYOUT - Drawing prepend text: '{prepend_text}' at y={current_y}")
draw_text_with_stroke(draw, prepend_text, x, current_y, font, text_color,
props.enable_stroke, props.stroke_width, props.stroke_color, props.stroke_position)
prepend_bbox = draw.textbbox((0, 0), prepend_text, font=font)
prepend_height = prepend_bbox[3] - prepend_bbox[1]
current_y += prepend_height + prepend_margin_px
# Render main text
print(f"DEBUG VERTICAL LAYOUT - Drawing main text: '{main_text}' at y={current_y}")
draw_text_with_stroke(draw, main_text, x, current_y, font, text_color,
props.enable_stroke, props.stroke_width, props.stroke_color, props.stroke_position)
main_bbox = draw.textbbox((0, 0), main_text, font=font)
main_height = main_bbox[3] - main_bbox[1]
current_y += main_height + append_margin_px
# Render append text if exists
if append_text:
print(f"DEBUG VERTICAL LAYOUT - Drawing append text: '{append_text}' at y={current_y}")
draw_text_with_stroke(draw, append_text, x, current_y, font, text_color,
props.enable_stroke, props.stroke_width, props.stroke_color, props.stroke_position)
print(f"DEBUG VERTICAL LAYOUT - Vertical text rendering with stroke completed")
# MULTILINE TEXT PROCESSING with stroke support
elif props.enable_multiline:
print("DEBUG MULTILINE: Processing multiline text with stroke support")
print(f"DEBUG MULTILINE: Text image mode: {text_img.mode}, size: {text_img.size}")
# Process multiline text
lines = process_multiline_text(text, font, props)
print(f"DEBUG MULTILINE: Processed into {len(lines)} lines: {lines}")
# DEBUG: Check text_img before multiline drawing
pre_draw_pixels = list(text_img.getdata())
pre_draw_non_transparent = sum(1 for pixel in pre_draw_pixels if len(pixel) >= 4 and pixel[3] > 0)
print(f"DEBUG MULTILINE: Text image has {pre_draw_non_transparent} non-transparent pixels BEFORE multiline drawing")
# Draw multiline text with stroke support
draw_multiline_text_with_stroke(draw, lines, font, text_color, props, width, height)
# DEBUG: Check text_img after multiline drawing
post_draw_pixels = list(text_img.getdata())
post_draw_non_transparent = sum(1 for pixel in post_draw_pixels if len(pixel) >= 4 and pixel[3] > 0)
print(f"DEBUG MULTILINE: Text image has {post_draw_non_transparent} non-transparent pixels AFTER multiline drawing")
print("DEBUG MULTILINE: Multiline text rendering with stroke completed")
else:
# Standard horizontal rendering with stroke support
print(f"DEBUG UPPERCASE ISSUE - About to draw text with stroke: '{text}' with font: {font}")
draw_text_with_stroke(draw, text, x, y, font, text_color,
props.enable_stroke, props.stroke_width, props.stroke_color, props.stroke_position)
print(f"Text drawn at ({x}, {y}) with stroke: {props.enable_stroke}")
print(f"DEBUG UPPERCASE ISSUE - Text drawing with stroke completed")
# CRITICAL FIX: Move shadow/glow generation AFTER text rendering
# The issue was that shadow/glow effects were being created before text was fully rendered
# This caused invisible shadows because they were created from empty text images
# Apply blur to main text if enabled (before shadow/glow generation)
if props.enable_blur and props.blur_radius > 0:
print(f"DEBUG BLUR: Applying blur effect - radius: {props.blur_radius}")
text_img = text_img.filter(ImageFilter.GaussianBlur(radius=props.blur_radius))
print(f"DEBUG BLUR: Blur effect applied successfully")
# Now generate shadow/glow effects AFTER text is fully rendered
shadow_glow_img = None
if props.enable_shadow or props.enable_glow:
print(f"DEBUG SHADOW/GLOW: Generating shadow/glow effects - shadow: {props.enable_shadow}, glow: {props.enable_glow}")
shadow_glow_img = Image.new('RGBA', (width, height), (0, 0, 0, 0))
# DEBUG: Check source text_img after full rendering
source_pixels = list(text_img.getdata())
source_non_transparent = sum(1 for pixel in source_pixels if len(pixel) >= 4 and pixel[3] > 0)
print(f"DEBUG SHADOW: Source text_img has {source_non_transparent} non-transparent pixels (after full text rendering)")
if source_non_transparent == 0:
print(f"WARNING: Source text image is still empty after full rendering! Skipping shadow/glow.")
shadow_glow_img = None
else:
print(f"SUCCESS: Source text image has content, proceeding with shadow/glow generation")
# Apply effects in rendering order: shadow first, then glow
if shadow_glow_img and props.enable_shadow:
print(f"DEBUG SHADOW: Creating drop shadow - offset: ({props.shadow_offset_x}, {props.shadow_offset_y}), blur: {props.shadow_blur}")
# Create shadow by offsetting and blurring the text
shadow_img = text_img.copy()
# Apply shadow color to the text (keep alpha channel from original)
shadow_pixels = list(shadow_img.getdata())
shadow_color_rgb = tuple(int(c * 255) for c in props.shadow_color[:3])
# Enhanced shadow intensity calculation
base_alpha_multiplier = props.shadow_color[3] if len(props.shadow_color) > 3 else 1.0
shadow_spread = getattr(props, 'shadow_spread', 1.0) # Fallback for compatibility
# Apply shadow spread to the offset distances
shadow_offset_x_spread = int(props.shadow_offset_x * shadow_spread)
shadow_offset_y_spread = int(props.shadow_offset_y * shadow_spread)
# Keep the original alpha multiplier (no intensity-based changes)
final_alpha_multiplier = base_alpha_multiplier
# DEBUG: Log shadow color and alpha values
print(f"DEBUG SHADOW: Shadow color RGB: {shadow_color_rgb}")
print(f"DEBUG SHADOW: Base alpha multiplier: {base_alpha_multiplier}")
print(f"DEBUG SHADOW: Shadow spread: {shadow_spread}")
print(f"DEBUG SHADOW: Shadow offset X spread: {shadow_offset_x_spread}")
print(f"DEBUG SHADOW: Shadow offset Y spread: {shadow_offset_y_spread}")
print(f"DEBUG SHADOW: Final alpha multiplier: {final_alpha_multiplier}")
print(f"DEBUG SHADOW: Original shadow_img size: {shadow_img.size}")
print(f"DEBUG SHADOW: Original shadow_img mode: {shadow_img.mode}")
new_shadow_pixels = []
modified_pixel_count = 0
for pixel in shadow_pixels:
if len(pixel) >= 4: # RGBA
r, g, b, a = pixel
if a > 0: # Only modify non-transparent pixels
# Apply shadow color and enhanced alpha with intensity
new_a = int(min(255, a * final_alpha_multiplier))
new_shadow_pixels.append((*shadow_color_rgb, new_a))
modified_pixel_count += 1
else:
new_shadow_pixels.append(pixel)
else: # RGB
new_shadow_pixels.append((*shadow_color_rgb, 255))
modified_pixel_count += 1
print(f"DEBUG SHADOW: Modified {modified_pixel_count} pixels out of {len(shadow_pixels)}")
shadow_img.putdata(new_shadow_pixels)
# Apply blur if specified
if props.shadow_blur > 0:
shadow_img = shadow_img.filter(ImageFilter.GaussianBlur(radius=props.shadow_blur))
# Create offset shadow image
offset_shadow_img = Image.new('RGBA', (width, height), (0, 0, 0, 0))
# Calculate offset positions with spread applied
offset_x = shadow_offset_x_spread
offset_y = shadow_offset_y_spread
print(f"DEBUG SHADOW: Offset position: ({offset_x}, {offset_y})")
print(f"DEBUG SHADOW: Shadow image dimensions: {shadow_img.size}")
print(f"DEBUG SHADOW: Canvas dimensions: {width}x{height}")
# Paste shadow at offset position
try:
offset_shadow_img.paste(shadow_img, (offset_x, offset_y), shadow_img)
print(f"DEBUG SHADOW: Successfully pasted shadow at offset ({offset_x}, {offset_y})")
except Exception as e:
print(f"DEBUG SHADOW: Error pasting shadow: {e}")
# Fallback to center if offset fails
offset_shadow_img.paste(shadow_img, (0, 0), shadow_img)
print(f"DEBUG SHADOW: Used fallback position (0, 0)")
# Check if offset_shadow_img has any non-transparent pixels
offset_shadow_pixels = list(offset_shadow_img.getdata())
non_transparent_count = sum(1 for pixel in offset_shadow_pixels if len(pixel) >= 4 and pixel[3] > 0)
print(f"DEBUG SHADOW: Offset shadow has {non_transparent_count} non-transparent pixels out of {len(offset_shadow_pixels)}")
# Composite shadow with shadow_glow_img
shadow_glow_img = Image.alpha_composite(shadow_glow_img, offset_shadow_img)
# Check final shadow_glow_img
final_shadow_pixels = list(shadow_glow_img.getdata())
final_non_transparent_count = sum(1 for pixel in final_shadow_pixels if len(pixel) >= 4 and pixel[3] > 0)
print(f"DEBUG SHADOW: Final shadow_glow_img has {final_non_transparent_count} non-transparent pixels")
print(f"DEBUG SHADOW: Drop shadow applied successfully")
if props.enable_glow:
print(f"DEBUG GLOW: Creating glow effect - blur: {props.glow_blur}")
# Create glow by blurring the text without offset
glow_img = text_img.copy()
# Apply glow color to the text
glow_pixels = list(glow_img.getdata())
glow_color_rgb = tuple(int(c * 255) for c in props.glow_color[:3])
glow_alpha_multiplier = props.glow_color[3] if len(props.glow_color) > 3 else 1.0
new_glow_pixels = []
for pixel in glow_pixels:
if len(pixel) >= 4: # RGBA
r, g, b, a = pixel
if a > 0: # Only modify non-transparent pixels
# Apply glow color and alpha
new_a = int(a * glow_alpha_multiplier)
new_glow_pixels.append((*glow_color_rgb, new_a))
else:
new_glow_pixels.append(pixel)
else: # RGB
new_glow_pixels.append((*glow_color_rgb, 255))
glow_img.putdata(new_glow_pixels)
# Apply blur for glow effect
if props.glow_blur > 0:
glow_img = glow_img.filter(ImageFilter.GaussianBlur(radius=props.glow_blur))
# Composite glow with shadow_glow_img
shadow_glow_img = Image.alpha_composite(shadow_glow_img, glow_img)
print(f"DEBUG GLOW: Glow effect applied successfully")
# Add shadow/glow to elements list first (behind text, z-index -1)
if shadow_glow_img:
elements.append((-1, 'shadow_glow', shadow_glow_img, 0, 0))
print(f"DEBUG SHADOW/GLOW: Added shadow/glow to elements list at z-index -1")
# Add text to elements list (blur was already applied before shadow/glow generation)
elements.append((0, 'text', text_img, 0, 0))
# Sort elements by z-index
elements.sort(key=lambda x: x[0])
# Composite all elements
final_img = base_img.copy()
for z_index, element_type, element_img, pos_x, pos_y in elements:
print(f"DEBUG COMPOSITING: Processing element type '{element_type}' at z-index {z_index}")
if element_type == 'text':
final_img = Image.alpha_composite(final_img, element_img)
print(f"DEBUG COMPOSITING: Text element composited successfully")
elif element_type == 'image':
# Create a temporary image to paste the overlay
temp_img = Image.new('RGBA', (width, height), (0, 0, 0, 0))
temp_img.paste(element_img, (pos_x, pos_y), element_img)
final_img = Image.alpha_composite(final_img, temp_img)
print(f"DEBUG COMPOSITING: Image element composited successfully")
elif element_type == 'shadow_glow':
# BUG FIX: Add missing handler for shadow/glow effects
final_img = Image.alpha_composite(final_img, element_img)
print(f"DEBUG COMPOSITING: Shadow/glow element composited successfully")
else:
print(f"DEBUG COMPOSITING: WARNING - Unknown element type '{element_type}' skipped!")
print(f"Final composite image created with {len(elements)} elements")
# Generate normal map if enabled
normal_map_img = None
if props.generate_normal_map:
print(f"[Normal Map] Generating normal map with settings: strength={props.normal_map_strength}, blur={props.normal_map_blur_radius}, invert={props.normal_map_invert}")
normal_map_img = generate_normal_map_from_alpha(
final_img,
strength=props.normal_map_strength,
blur_radius=props.normal_map_blur_radius,
invert=props.normal_map_invert
)
if normal_map_img:
print(f"[Normal Map] Successfully generated normal map")
else:
print(f"[Normal Map] Failed to generate normal map")
return final_img, normal_map_img
except Exception as e:
print(f"Error in generate_texture_image: {e}")
import traceback
traceback.print_exc()
return None, None
def generate_preview(context):
"""Generate preview texture"""
if not context or not hasattr(context, 'scene'):
print("No context or scene")
return None
props = context.scene.text_texture_props
# Always generate preview - removed live_preview_enabled check
try:
# Generate at full texture size for 100% quality preview
preview_w = props.texture_width
preview_h = props.texture_height
print(f"[TTG] Generating 100% quality preview at full resolution: {preview_w}x{preview_h}")
print(f"Starting preview generation at {preview_w}x{preview_h} (full resolution)")
print(f"DEBUG: Texture size is {props.texture_width}x{props.texture_height}, preview at {preview_w}x{preview_h}")
# Generate the images (diffuse and normal map)
result = generate_texture_image(props, preview_w, preview_h)
if not result:
print("Failed to generate PIL image")
return None
# Extract diffuse image (and optionally normal map)
if isinstance(result, tuple):
img, normal_map_img = result
else:
# Backward compatibility in case function returns single image
img = result
normal_map_img = None
if not img:
print("Failed to generate diffuse image")
return None
# Apply preview background if needed
if props.preview_bg_type != 'transparent':
from PIL import Image, ImageDraw
# Create background image
bg_img = Image.new('RGBA', (preview_w, preview_h))
draw = ImageDraw.Draw(bg_img)
if props.preview_bg_type == 'color':
# Solid color background
bg_color = tuple(int(c * 255) for c in props.preview_bg_color[:3])
bg_color += (int(props.preview_bg_color[3] * 255),)
bg_img.paste(bg_color, (0, 0, preview_w, preview_h))
elif props.preview_bg_type == 'gradient':
# Gradient background
color1 = tuple(int(c * 255) for c in props.preview_bg_color[:3])
color2 = tuple(int(c * 255) for c in props.preview_bg_color2[:3])
for y in range(preview_h):
ratio = y / preview_h
r = int(color1[0] * (1 - ratio) + color2[0] * ratio)
g = int(color1[1] * (1 - ratio) + color2[1] * ratio)
b = int(color1[2] * (1 - ratio) + color2[2] * ratio)
draw.rectangle([(0, y), (preview_w, y+1)], fill=(r, g, b, 255))
else: # transparent - create checkerboard
# Checkerboard pattern for transparent
checker_size = 16
color1 = (128, 128, 128, 255)
color2 = (192, 192, 192, 255)
for y in range(0, preview_h, checker_size):
for x in range(0, preview_w, checker_size):
color = color1 if ((x // checker_size) + (y // checker_size)) % 2 == 0 else color2
draw.rectangle([(x, y), (min(x + checker_size, preview_w), min(y + checker_size, preview_h))],
fill=color)
# Composite the text image over the background
bg_img.alpha_composite(img)
img = bg_img
# Name for preview image
preview_img_name = "TextTexturePreview"
# Remove old preview if it exists
if preview_img_name in bpy.data.images:
print("Removing old preview")
old_img = bpy.data.images[preview_img_name]
bpy.data.images.remove(old_img)
# Create new Blender image for preview
print("Creating new Blender image")
blender_img = bpy.data.images.new(preview_img_name, preview_w, preview_h, alpha=True)
# Convert PIL to Blender
print("Converting pixels")
pixels = []
for y in range(preview_h):
for x in range(preview_w):
pixel = img.getpixel((x, preview_h - 1 - y))
if len(pixel) == 4:
r, g, b, a = pixel
else:
r, g, b = pixel
a = 255
pixels.extend([r/255.0, g/255.0, b/255.0, a/255.0])
print("Setting pixels")
blender_img.pixels[:] = pixels
# Store reference to the preview image only if not in draw context
try:
props.preview_image = blender_img
print(f"Preview stored: {props.preview_image}")
except AttributeError as attr_err:
if "Writing to ID classes in this context is not allowed" in str(attr_err):
print("Cannot set preview_image during draw - will be set later")
else:
raise
# Force UI redraw
for area in context.screen.areas:
area.tag_redraw()
print(f"Preview generated successfully: {blender_img.name}")
return blender_img
except Exception as e:
print(f"Preview generation error: {e}")
import traceback
traceback.print_exc()
try:
props.preview_image = None
except AttributeError as attr_err:
if "Writing to ID classes in this context is not allowed" not in str(attr_err):
raise
return None
# Removed update_preview_enabled function - preview is always enabled
def update_live_preview(self, context):
"""Update preview when properties change"""
if not hasattr(context, 'scene'):
return
props = context.scene.text_texture_props
print(f"DEBUG: update_live_preview called, is_updating: {props.is_updating}")
# Always update preview unless already updating
if not props.is_updating:
print("DEBUG: Triggering preview generation from property update")
# Use a timer to debounce rapid property changes for better performance
if hasattr(update_live_preview, '_timer'):
bpy.app.timers.unregister(update_live_preview._timer)
def delayed_preview():
generate_preview(context)
return None # Stop timer
update_live_preview._timer = delayed_preview
bpy.app.timers.register(delayed_preview, first_interval=0.1)
# ============================================================================
# PROPERTY GROUPS
# ============================================================================
class TEXT_TEXTURE_ImageOverlay(PropertyGroup):
"""Single image overlay item"""
name: StringProperty(
name="Image Name",
description="Name identifier for this image overlay",
default="Overlay",
update=update_live_preview
)
image_path: StringProperty(
name="Image File",
description="Path to image file",
subtype='FILE_PATH',
default="",
update=update_live_preview
)
x_position: FloatProperty(
name="X Position",
description="X position (0.0 = left, 1.0 = right)",
default=0.5,
min=0.0,
max=1.0,
update=update_live_preview
)
y_position: FloatProperty(
name="Y Position",
description="Y position (0.0 = bottom, 1.0 = top)",
default=0.5,
min=0.0,
max=1.0,
update=update_live_preview
)
scale: FloatProperty(
name="Scale",
description="Scale of the image",
default=1.0,
min=0.1,
max=5.0,
update=update_live_preview
)
rotation: FloatProperty(
name="Rotation",
description="Rotation in degrees",
default=0.0,
min=-360.0,
max=360.0,
update=update_live_preview
)
z_index: IntProperty(
name="Z-Index",
description="Layer order (0=text level, -1=below text, 1+=above text)",
default=1,
min=-10,
max=10,
update=update_live_preview
)
positioning_mode: EnumProperty(
name="Positioning Mode",
description="How the image overlay is positioned relative to the text",
items=[
('ABSOLUTE', "Absolute", "Position overlay at absolute coordinates (legacy mode)"),
('APPEND', "Append", "Position overlay after the text content"),
('PREPEND', "Prepend", "Position overlay before the text content")
],
default='ABSOLUTE',
update=update_live_preview
)
text_spacing: FloatProperty(
name="Text Spacing (%)",
description="Spacing between text and image as percentage of canvas width (0-100%)",
default=10.0,
min=0.0,
max=100.0,
subtype='PERCENTAGE',
update=update_live_preview
)
image_spacing: FloatProperty(
name="Image Spacing (%)",
description="Spacing between consecutive images as percentage of canvas width (0-100%)",
default=5.0,
min=0.0,
max=100.0,
subtype='PERCENTAGE',
update=update_live_preview
)
enabled: BoolProperty(
name="Enabled",
description="Enable this image overlay",
default=True,
update=update_live_preview
)
class TEXT_TEXTURE_Preset(PropertyGroup):
"""Single preset item"""
name: StringProperty(
name="Preset Name",
description="Name of the preset",
default="New Preset"
)
source: EnumProperty(
name="Source",
description="Where this preset is stored",
items=[
('BLEND_FILE', 'Blend File', 'Preset stored in the blend file (travels with the file)'),
('PERSISTENT_FILE', 'Persistent File', 'Preset stored in persistent location (survives addon updates)'),
('LOCAL_FILE', 'Local File', 'Preset stored locally (legacy system)')
],
default='BLEND_FILE'
)
class TEXT_TEXTURE_Properties(PropertyGroup):
"""Properties for text texture generation"""
is_updating: BoolProperty(default=False)
preview_image: PointerProperty(
type=bpy.types.Image,
name="Preview Image"
)
# Collapsible section expand/collapse states
expand_multiline: BoolProperty(
name="Multiline Text",
description="Show/hide Multiline Text section",
default=False
)
expand_image_overlays: BoolProperty(
name="Image Overlays",
description="Show/hide Image Overlays section",
default=False
)
expand_positioning: BoolProperty(
name="Positioning & Layout",
description="Show/hide Positioning & Layout section",
default=False
)
expand_font_settings: BoolProperty(
name="Font Settings",
description="Show/hide Font Settings section",
default=False
)
expand_preview_options: BoolProperty(
name="Preview Options",
description="Show/hide Preview Options section",
default=False
)
expand_presets: BoolProperty(
name="Presets",
description="Show/hide Presets section",
default=False
)
expand_colors: BoolProperty(
name="Colors",
description="Show/hide Colors section",
default=False
)
text: StringProperty(
name="Text",
description="Text to render as texture",
default="",
maxlen=1024,
update=update_live_preview
)
# Prepend/Append Text Properties
prepend_text: StringProperty(
name="Prepend Text",
description="Text to prepend before main text",
default="",
maxlen=512,
update=update_live_preview
)
append_text: StringProperty(
name="Append Text",
description="Text to append after main text",
default="",
maxlen=512,
update=update_live_preview
)
prepend_margin: FloatProperty(
name="Prepend Margin",
description="Margin between prepend text and main text as percentage of font size",
default=50.0,
min=0.0,
max=200.0,
precision=1,
update=update_live_preview
)
append_margin: FloatProperty(
name="Append Margin",
description="Margin between main text and append text as percentage of font size",
default=50.0,
min=0.0,
max=200.0,
precision=1,
update=update_live_preview
)
prepend_append_layout: EnumProperty(
name="Layout",
description="Layout arrangement for prepend/main/append text",
items=[
('HORIZONTAL', 'Horizontal', 'Arrange text horizontally: [prepend] [main] [append]'),
('VERTICAL', 'Vertical', 'Arrange text vertically: prepend above, main center, append below'),
],
default='HORIZONTAL',
update=update_live_preview
)
texture_width: IntProperty(
name="Width",
description="Texture width in pixels",
default=1024,
min=64,
max=4096,
update=update_live_preview
)
texture_height: IntProperty(
name="Height",
description="Texture height in pixels",
default=1024,
min=64,
max=4096,
update=update_live_preview
)
font_size: IntProperty(
name="Font Size",
description="Font size in pixels",
default=96,
min=8,
max=500,
update=update_live_preview
)
padding: IntProperty(
name="Padding",
description="Padding around text in pixels",
default=20,
min=0,
max=200,
update=update_live_preview
)
text_color: FloatVectorProperty(
name="Text Color",
description="Color of the text",
subtype='COLOR',
default=(1.0, 1.0, 1.0, 1.0),
size=4,
min=0.0,
max=1.0,
update=update_live_preview
)
background_transparent: BoolProperty(
name="Transparent Background",
description="Use transparent background",
default=True,
update=update_live_preview
)
background_color: FloatVectorProperty(
name="Background Color",
description="Background color (when not transparent)",
subtype='COLOR',
default=(0.0, 0.0, 0.0, 1.0),
size=4,
min=0.0,
max=1.0,
update=update_live_preview
)
font_path: EnumProperty(
name="Font",
description="Font to use for text rendering",
items=get_font_enum_items,
update=update_live_preview
)
custom_font_path: StringProperty(
name="Custom Font File",
description="Path to custom font file (.ttf, .otf)",
subtype='FILE_PATH',
default="",
update=update_live_preview
)
use_custom_font: BoolProperty(
name="Use Custom Font",
description="Use custom font file instead of system fonts",
default=False,
update=update_live_preview
)
text_align: EnumProperty(
name="Alignment",
description="Text alignment",
items=[
('left', 'Left', 'Align text to the left'),
('center', 'Center', 'Center text'),
('right', 'Right', 'Align text to the right'),
],
default='center',
update=update_live_preview
)
# ============================================================================
# ENHANCED POSITIONING & ALIGNMENT PROPERTIES
# ============================================================================
# 9-point anchor system
anchor_point: EnumProperty(
name="Anchor Point",
description="Text anchor position on canvas",
items=[
('TOP_LEFT', 'Top Left', 'Position text from top-left corner'),
('TOP_CENTER', 'Top Center', 'Position text from top center'),
('TOP_RIGHT', 'Top Right', 'Position text from top-right corner'),
('MIDDLE_LEFT', 'Middle Left', 'Position text from middle-left'),
('MIDDLE_CENTER', 'Middle Center', 'Position text from center (default)'),
('MIDDLE_RIGHT', 'Middle Right', 'Position text from middle-right'),
('BOTTOM_LEFT', 'Bottom Left', 'Position text from bottom-left corner'),
('BOTTOM_CENTER', 'Bottom Center', 'Position text from bottom center'),
('BOTTOM_RIGHT', 'Bottom Right', 'Position text from bottom-right corner'),
],
default='MIDDLE_CENTER',
update=update_live_preview
)
# Position mode toggle
position_mode: EnumProperty(
name="Position Mode",
description="Choose between preset anchor positions or manual positioning",
items=[
('PRESET', 'Preset', 'Use anchor points with margins and offsets'),
('MANUAL', 'Manual', 'Manually specify X,Y coordinates'),
],
default='PRESET',
update=update_live_preview
)
# Percentage-based margins (0-50%)
margin_x: FloatProperty(
name="X Margin",
description="Horizontal margin from edge as percentage (0-50%)",
subtype='PERCENTAGE',
default=0.0,
min=0.0,
max=50.0,
precision=1,
update=update_live_preview
)
margin_y: FloatProperty(
name="Y Margin",
description="Vertical margin from edge as percentage (0-50%)",
subtype='PERCENTAGE',
default=0.0,
min=0.0,
max=50.0,
precision=1,
update=update_live_preview
)
# Link margins option
margins_linked: BoolProperty(
name="Link Margins",
description="Keep X and Y margins synchronized",
default=False,
update=update_live_preview
)
# Pixel offset fine-tuning (-200 to +200px)
offset_x: IntProperty(
name="X Offset",
description="Fine-tune X position in pixels (-200 to +200)",
default=0,
min=-200,
max=200,
update=update_live_preview
)
offset_y: IntProperty(
name="Y Offset",
description="Fine-tune Y position in pixels (-200 to +200)",
default=0,
min=-200,
max=200,
update=update_live_preview
)
# Manual positioning properties
manual_position_x: FloatProperty(
name="Manual X",
description="Manual X position as percentage (0-100%)",
subtype='PERCENTAGE',
default=50.0,
min=0.0,
max=100.0,
precision=1,
update=update_live_preview
)
manual_position_y: FloatProperty(
name="Manual Y",
description="Manual Y position as percentage (0-100%)",
subtype='PERCENTAGE',
default=50.0,
min=0.0,
max=100.0,
precision=1,
update=update_live_preview
)
manual_position_unit: EnumProperty(
name="Position Unit",
description="Units for manual positioning",
items=[
('PERCENTAGE', 'Percentage', 'Position as percentage of canvas'),
('PIXELS', 'Pixels', 'Position in absolute pixels'),
],
default='PERCENTAGE',
update=update_live_preview
)
# Canvas constraint option
constrain_to_canvas: BoolProperty(
name="Constrain to Canvas",
description="Keep text within canvas bounds",
default=True,
update=update_live_preview
)
# Visual feedback options
show_position_guides: BoolProperty(
name="Show Position Guides",
description="Display positioning guide lines in preview",
default=False,
update=update_live_preview
)
show_text_bounds: BoolProperty(
name="Show Text Bounds",
description="Display text bounding box in preview",
default=False,
update=update_live_preview
)
show_canvas_grid: BoolProperty(
name="Show Canvas Grid",
description="Display alignment grid overlay in preview",
default=False,
update=update_live_preview
)
grid_density: IntProperty(
name="Grid Density",
description="Number of grid divisions (4-16)",
default=8,
min=4,
max=16,
update=update_live_preview
)
# Removed live_preview_enabled - preview is always on
preview_size: IntProperty(
name="Preview Size",
description="Size of the preview image (does not affect final output)",
default=512,
min=128,
max=2048,
update=update_live_preview
)
preview_zoom_level: FloatProperty(
name="Zoom",
description="Zoom level for preview",
default=1.0,
min=0.5,
max=4.0,
update=update_live_preview
)
# Preview background options
preview_bg_type: EnumProperty(
name="Preview Background",
description="Background type for preview display",
items=[
('transparent', 'Transparent', 'Transparent checkerboard pattern'),
('color', 'Solid Color', 'Use a solid color background'),
('gradient', 'Gradient', 'Use a gradient background'),
],
default='transparent',
update=update_live_preview
)
preview_bg_color: FloatVectorProperty(
name="Preview BG Color",
description="Background color for preview",
subtype='COLOR',
default=(0.2, 0.2, 0.2, 1.0),
size=4,
min=0.0,
max=1.0,
update=update_live_preview
)
preview_bg_color2: FloatVectorProperty(
name="Preview BG Color 2",
description="Second color for gradient background",
subtype='COLOR',
default=(0.4, 0.4, 0.4, 1.0),
size=4,
min=0.0,
max=1.0,
update=update_live_preview
)
# Image overlays
image_overlays: CollectionProperty(type=TEXT_TEXTURE_ImageOverlay)
active_overlay_index: IntProperty(default=0)
# Presets
presets: CollectionProperty(type=TEXT_TEXTURE_Preset)
active_preset_index: IntProperty(default=0)
new_preset_name: StringProperty(
name="Preset Name",
description="Name for new preset (Press Enter to save)",
default="New Preset"
)
# Property to track when user wants to save with Enter
preset_save_requested: BoolProperty(
name="Save Requested",
description="Internal flag for Enter key save requests",
default=False
)
# ============================================================================
# SHADER GENERATION PROPERTIES
# ============================================================================
expand_shader: BoolProperty(
name="Shader Settings",
description="Show/hide Shader Settings section",
default=False
)
shader_type: EnumProperty(
name="Shader Type",
description="Type of shader to generate",
items=[
('PRINCIPLED', 'Principled BSDF', 'Standard PBR shader with text texture'),
('EMISSION', 'Emission', 'Emissive shader for glowing text effects'),
('TRANSPARENT', 'Transparent', 'Transparent shader with alpha blending'),
],
default='PRINCIPLED'
)
shader_connection: EnumProperty(
name="Texture Connection",
description="How to connect the texture to the shader",
items=[
('BASE_COLOR', 'Base Color', 'Connect texture to base color input'),
('EMISSION', 'Emission', 'Connect texture to emission input'),
('ALPHA', 'Alpha', 'Connect texture alpha to transparency'),
],
default='BASE_COLOR'
)
use_alpha: BoolProperty(
name="Use Alpha Channel",
description="Use texture alpha channel for transparency",
default=True
)
emission_strength: FloatProperty(
name="Emission Strength",
description="Strength of emission shader",
default=1.0,
min=0.0,
max=10.0
)
auto_assign_material: BoolProperty(
name="Auto-Assign Material",
description="Automatically assign generated material to active object",
default=True
)
custom_material_name: StringProperty(
name="Custom Material Name",
description="Custom name for the generated material (leave empty to use text-based naming)",
default="",
maxlen=128
)
disable_shadows: BoolProperty(
name="Disable Shadows",
description="Make material not cast shadows using light path",
default=False
)
disable_reflections: BoolProperty(
name="Disable Reflections",
description="Make material not appear in reflections using light path",
default=False
)
disable_backfacing: BoolProperty(
name="Disable Backfacing",
description="Make material not render on backfacing geometry using light path",
default=False
)
# ============================================================================
# NORMAL MAP GENERATION PROPERTIES
# ============================================================================
expand_normal_maps: BoolProperty(
name="Normal Maps",
description="Show/hide Normal Maps section",
default=False
)
generate_normal_map: BoolProperty(
name="Generate Normal Map",
description="Automatically generate normal map from text alpha channel",
default=False,
update=update_live_preview
)
normal_map_strength: FloatProperty(
name="Normal Strength",
description="Intensity of the normal map effect (higher = more pronounced)",
default=1.0,
min=0.1,
max=5.0,
precision=2,
update=update_live_preview
)
normal_map_blur_radius: FloatProperty(
name="Blur Radius",
description="Blur radius for smoothing normal map (0 = no blur)",
default=1.0,
min=0.0,
max=10.0,
precision=1,
update=update_live_preview
)
normal_map_invert: BoolProperty(
name="Invert Normal Map",
description="Invert the normal map for different height effects (emboss vs deboss)",
default=False,
update=update_live_preview
)
# ============================================================================
# TEXT FITTING PROPERTIES
# ============================================================================
expand_text_fitting: BoolProperty(
name="Text Fitting",
description="Show/hide Text Fitting section",
default=False
)
enable_text_fitting: BoolProperty(
name="Enable Text Fitting",
description="Automatically adjust text size to fit within the canvas dimensions",
default=False,
update=update_live_preview
)
text_fitting_mode: EnumProperty(
name="Fitting Mode",
description="How to fit text within the canvas",
items=[
('FIT_WIDTH', 'Fit Width', 'Scale text to fit canvas width'),
('FIT_HEIGHT', 'Fit Height', 'Scale text to fit canvas height'),
('FIT_BOTH', 'Fit Both', 'Scale text to fit both width and height (may cause distortion)'),
('FIT_CONTAIN', 'Fit Contain', 'Scale text to fit entirely within canvas (preserves aspect ratio)'),
],
default='FIT_CONTAIN',
update=update_live_preview
)
text_fitting_margin: FloatProperty(
name="Fitting Margin",
description="Margin to leave around text when fitting (as percentage of canvas)",
default=10.0,
min=0.0,
max=50.0,
precision=1,
subtype='PERCENTAGE',
update=update_live_preview
)
min_font_size: IntProperty(
name="Minimum Font Size",
description="Minimum font size when fitting text (prevents text from becoming too small)",
default=8,
min=4,
max=200,
update=update_live_preview
)
max_font_size: IntProperty(
name="Maximum Font Size",
description="Maximum font size when fitting text (prevents text from becoming too large)",
default=500,
min=20,
max=1000,
update=update_live_preview
)
# ============================================================================
# MULTILINE TEXT PROPERTIES
# ============================================================================
enable_multiline: BoolProperty(
name="Enable Multiline",
description="Enable multiline text rendering",
default=False,
update=update_live_preview
)
line_height: FloatProperty(
name="Line Height",
description="Line height multiplier (1.0 = normal, 1.5 = 150% spacing)",
default=1.2,
min=0.1,
max=5.0,
step=0.1,
precision=2,
update=update_live_preview
)
text_alignment: EnumProperty(
name="Text Alignment",
description="Text alignment for multiline text",
items=[
('LEFT', "Left", "Left align text"),
('CENTER', "Center", "Center align text"),
('RIGHT', "Right", "Right align text")
],
default='CENTER',
update=update_live_preview
)
auto_wrap: BoolProperty(
name="Auto Wrap",
description="Automatically wrap text to fit texture width",
default=True,
update=update_live_preview
)
wrap_width: IntProperty(
name="Wrap Width",
description="Maximum width in pixels before text wraps (0 = use texture width)",
default=0,
min=0,
max=10000,
update=update_live_preview
)
max_lines: IntProperty(
name="Max Lines",
description="Maximum number of lines (0 = unlimited)",
default=0,
min=0,
max=100,
update=update_live_preview
)
line_spacing_pixels: IntProperty(
name="Line Spacing (px)",
description="Additional spacing between lines in pixels",
default=0,
min=-50,
max=200,
update=update_live_preview
)
# ============================================================================
# STROKE SYSTEM PROPERTIES
# ============================================================================
expand_stroke: BoolProperty(
name="Stroke & Outlines",
description="Show/hide Stroke & Outlines section",
default=False
)
enable_stroke: BoolProperty(
name="Enable Stroke",
description="Enable text stroke/outline rendering",
default=False,
update=update_live_preview
)
stroke_width: IntProperty(
name="Stroke Width",
description="Width of the text stroke in pixels",
default=2,
min=0,
max=20,
update=update_live_preview
)
stroke_position: EnumProperty(
name="Stroke Position",
description="How the stroke is positioned relative to the text",
items=[
('OUTER', 'Outer', 'Stroke outside text (expands text bounds)'),
('INNER', 'Inner', 'Stroke inside text (shrinks text)'),
('CENTER', 'Center', 'Stroke centered on text edge'),
],
default='OUTER',
update=update_live_preview
)
stroke_color: FloatVectorProperty(
name="Stroke Color",
description="Color of the text stroke",
subtype='COLOR',
default=(0.0, 0.0, 0.0, 1.0),
size=4,
min=0.0,
max=1.0,
update=update_live_preview
)
# ============================================================================
# SHADOW/GLOW SYSTEM PROPERTIES
# ============================================================================
expand_shadow_glow: BoolProperty(
name="Shadow & Glow Effects",
description="Show/hide Shadow & Glow Effects section",
default=False
)
enable_shadow: BoolProperty(
name="Enable Drop Shadow",
description="Enable drop shadow effect",
default=False,
update=update_live_preview
)
shadow_offset_x: FloatProperty(
name="Shadow Offset X",
description="Horizontal offset for drop shadow",
default=8.0,
min=-100.0,
max=100.0,
step=0.1,
precision=1,
update=update_live_preview
)
shadow_offset_y: FloatProperty(
name="Shadow Offset Y",
description="Vertical offset for drop shadow",
default=8.0,
min=-100.0,
max=100.0,
step=0.1,
precision=1,
update=update_live_preview
)
shadow_blur: FloatProperty(
name="Shadow Blur",
description="Blur radius for drop shadow",
default=6.0,
min=0.0,
max=50.0,
step=0.1,
precision=1,
update=update_live_preview
)
shadow_color: FloatVectorProperty(
name="Shadow Color",
description="Color of drop shadow",
subtype='COLOR',
default=(0.0, 0.0, 0.0, 1.0),
size=4,
min=0.0,
max=1.0,
update=update_live_preview
)
shadow_spread: FloatProperty(
name="Shadow Spread",
description="Controls how far the shadow extends from the text (multiplies shadow offset)",
default=1.0,
min=0.0,
max=3.0,
precision=2,
step=0.1,
update=update_live_preview
)
enable_glow: BoolProperty(
name="Enable Glow",
description="Enable glow effect",
default=False,
update=update_live_preview
)
glow_blur: FloatProperty(
name="Glow Blur",
description="Blur radius for glow effect",
default=10.0,
min=0.0,
max=50.0,
step=0.1,
precision=1,
update=update_live_preview
)
glow_color: FloatVectorProperty(
name="Glow Color",
description="Color of glow effect",
subtype='COLOR',
default=(1.0, 1.0, 1.0, 0.8),
size=4,
min=0.0,
max=1.0,
update=update_live_preview
)
# ============================================================================
# BLUR PROPERTIES
# ============================================================================
expand_blur: BoolProperty(
name="Blur Effects",
description="Show/hide Blur Effects section",
default=False
)
enable_blur: BoolProperty(
name="Enable Blur",
description="Enable blur effect on main text content",
default=False,
update=update_live_preview
)
blur_radius: FloatProperty(
name="Blur Radius",
description="Blur radius for text content (0-50 pixels)",
default=2.0,
min=0.0,
max=50.0,
step=0.1,
precision=1,
update=update_live_preview
)
# ============================================================================
# OPERATORS
# ============================================================================
class TEXT_TEXTURE_OT_generate(Operator):
"""Generate final texture and pack it"""
bl_idname = "text_texture.generate"
bl_label = "Generate Text Texture"
bl_description = "Generate final high-quality texture and pack it into the blend file"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
try:
props = context.scene.text_texture_props
# IMPORTANT: Generate at EXACT user-specified dimensions
final_width = props.texture_width
final_height = props.texture_height
print(f"Generating texture at {final_width}x{final_height}px")
# Generate at FULL resolution specified by user
result = generate_texture_image(props, final_width, final_height)
if not result:
self.report({'ERROR'}, "Failed to generate texture image")
return {'CANCELLED'}
# Extract diffuse and normal map images
if isinstance(result, tuple):
img, normal_map_img = result
else:
# Backward compatibility
img = result
normal_map_img = None
if not img:
self.report({'ERROR'}, "Failed to generate diffuse image")
return {'CANCELLED'}
# Create diffuse texture
img_name = f"TextTexture_{props.text[:20].replace(' ', '_')}"
# Remove existing diffuse texture
if img_name in bpy.data.images:
bpy.data.images.remove(bpy.data.images[img_name])
# Create new diffuse image with USER SPECIFIED dimensions
blender_img = bpy.data.images.new(img_name, final_width, final_height, alpha=True)
# Convert PIL to Blender for diffuse texture
pixels = []
for y in range(final_height):
for x in range(final_width):
r, g, b, a = img.getpixel((x, final_height - 1 - y))
pixels.extend([r/255.0, g/255.0, b/255.0, a/255.0])
blender_img.pixels = pixels
blender_img.pack() # PACK FINAL IMAGE
# Create normal map texture if enabled and generated
normal_map_blender_img = None
if normal_map_img and props.generate_normal_map:
normal_map_name = f"TextTexture_Normal_{props.text[:20].replace(' ', '_')}"
# Remove existing normal map texture
if normal_map_name in bpy.data.images:
bpy.data.images.remove(bpy.data.images[normal_map_name])
# Create new normal map image
normal_map_blender_img = bpy.data.images.new(normal_map_name, final_width, final_height, alpha=False)
normal_map_blender_img.colorspace_settings.name = 'Non-Color' # Important for normal maps
# Convert PIL to Blender for normal map
normal_pixels = []
for y in range(final_height):
for x in range(final_width):
r, g, b = normal_map_img.getpixel((x, final_height - 1 - y))
normal_pixels.extend([r/255.0, g/255.0, b/255.0, 1.0]) # Alpha always 1.0 for normal maps
normal_map_blender_img.pixels = normal_pixels
normal_map_blender_img.pack() # PACK NORMAL MAP
print(f"[Normal Map] Created and packed normal map texture: {normal_map_name}")
# Add to shader
if context.space_data and context.space_data.type == 'NODE_EDITOR':
if context.space_data.tree_type == 'ShaderNodeTree':
material = context.space_data.id
if material and material.use_nodes:
nodes = material.node_tree.nodes
# Add diffuse texture node
img_node = nodes.new(type='ShaderNodeTexImage')
img_node.image = blender_img
img_node.location = (0, 0)
img_node.select = True
nodes.active = img_node
# Add normal map node if normal map was created
if normal_map_blender_img:
normal_node = nodes.new(type='ShaderNodeTexImage')
normal_node.image = normal_map_blender_img
normal_node.location = (0, -300)
normal_node.label = "Normal Map"
print(f"[Normal Map] Added normal map node to shader editor")
# Provide comprehensive feedback
if normal_map_blender_img:
self.report({'INFO'}, f"Generated and packed: {img_name} + {normal_map_blender_img.name} ({final_width}x{final_height}px)")
else:
self.report({'INFO'}, f"Generated and packed: {img_name} ({final_width}x{final_height}px)")
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, f"Failed: {str(e)}")
return {'CANCELLED'}
class TEXT_TEXTURE_OT_generate_shader(Operator):
"""Generate complete shader with text texture"""
bl_idname = "text_texture.generate_shader"
bl_label = "Generate Shader"
bl_description = "Generate a complete material shader with the text texture"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
try:
props = context.scene.text_texture_props
# Generate texture first
final_width = props.texture_width
final_height = props.texture_height
print(f"Generating shader with texture at {final_width}x{final_height}px")
result = generate_texture_image(props, final_width, final_height)
if not result:
self.report({'ERROR'}, "Failed to generate texture image")
return {'CANCELLED'}
# Extract diffuse and normal map images
if isinstance(result, tuple):
img, normal_map_img = result
else:
# Backward compatibility
img = result
normal_map_img = None
if not img:
self.report({'ERROR'}, "Failed to generate diffuse image")
return {'CANCELLED'}
# Create diffuse texture name
img_name = f"TextTexture_{props.text[:20].replace(' ', '_')}"
# Remove existing diffuse texture if it exists
if img_name in bpy.data.images:
bpy.data.images.remove(bpy.data.images[img_name])
# Create new diffuse texture
blender_img = bpy.data.images.new(img_name, final_width, final_height, alpha=True)
# Convert PIL to Blender for diffuse texture
pixels = []
for y in range(final_height):
for x in range(final_width):
r, g, b, a = img.getpixel((x, final_height - 1 - y))
pixels.extend([r/255.0, g/255.0, b/255.0, a/255.0])
blender_img.pixels = pixels
blender_img.pack()
# Create normal map texture if enabled and generated
normal_map_blender_img = None
if normal_map_img and props.generate_normal_map:
normal_map_name = f"TextTexture_Normal_{props.text[:20].replace(' ', '_')}"
# Remove existing normal map texture
if normal_map_name in bpy.data.images:
bpy.data.images.remove(bpy.data.images[normal_map_name])
# Create new normal map image
normal_map_blender_img = bpy.data.images.new(normal_map_name, final_width, final_height, alpha=False)
normal_map_blender_img.colorspace_settings.name = 'Non-Color' # Important for normal maps
# Convert PIL to Blender for normal map
normal_pixels = []
for y in range(final_height):
for x in range(final_width):
r, g, b = normal_map_img.getpixel((x, final_height - 1 - y))
normal_pixels.extend([r/255.0, g/255.0, b/255.0, 1.0]) # Alpha always 1.0 for normal maps
normal_map_blender_img.pixels = normal_pixels
normal_map_blender_img.pack()
print(f"[Normal Map] Created normal map texture for shader: {normal_map_name}")
# Create or get material using custom name or fallback
if props.custom_material_name.strip():
material_name = props.custom_material_name.strip()
else:
# Fallback to text-based naming
clean_text = props.text[:15].replace(' ', '_').replace('\n', '_')
material_name = f"TextTexture_Mat_{clean_text}"
if material_name in bpy.data.materials:
mat = bpy.data.materials[material_name]
# Clear existing nodes
mat.node_tree.nodes.clear()
else:
mat = bpy.data.materials.new(name=material_name)
mat.use_nodes = True
mat.node_tree.nodes.clear()
# Set up shader nodes
nodes = mat.node_tree.nodes
links = mat.node_tree.links
# Create nodes
output_node = nodes.new(type='ShaderNodeOutputMaterial')
output_node.location = (400, 0)
# Shader type based on settings
if props.shader_type == 'PRINCIPLED':
shader_node = nodes.new(type='ShaderNodeBsdfPrincipled')
shader_node.location = (200, 0)
# Create diffuse texture node
tex_node = nodes.new(type='ShaderNodeTexImage')
tex_node.image = blender_img
tex_node.location = (-200, 0)
# Connect texture to shader
if props.shader_connection == 'BASE_COLOR':
links.new(tex_node.outputs['Color'], shader_node.inputs['Base Color'])
elif props.shader_connection == 'EMISSION':
links.new(tex_node.outputs['Color'], shader_node.inputs['Emission Color'])
shader_node.inputs['Emission Strength'].default_value = props.emission_strength
elif props.shader_connection == 'ALPHA':
links.new(tex_node.outputs['Alpha'], shader_node.inputs['Alpha'])
# Also connect color for visibility
links.new(tex_node.outputs['Color'], shader_node.inputs['Base Color'])
mat.blend_method = 'BLEND'
# Connect alpha if enabled
if props.use_alpha:
links.new(tex_node.outputs['Alpha'], shader_node.inputs['Alpha'])
mat.blend_method = 'BLEND'
# Add normal map nodes if normal map was generated
if normal_map_blender_img:
# Create normal map texture node
normal_tex_node = nodes.new(type='ShaderNodeTexImage')
normal_tex_node.image = normal_map_blender_img
normal_tex_node.location = (-200, -300)
normal_tex_node.label = "Normal Map"
# Create normal map node
normal_map_node = nodes.new(type='ShaderNodeNormalMap')
normal_map_node.location = (0, -300)
# Connect normal map texture to normal map node
links.new(normal_tex_node.outputs['Color'], normal_map_node.inputs['Color'])
# Connect normal map node to shader
links.new(normal_map_node.outputs['Normal'], shader_node.inputs['Normal'])
print(f"[Normal Map] Added normal map nodes to Principled BSDF shader")
# Handle shadow/reflection disabling
current_shader = shader_node
final_x_location = 400
# Disable shadows if enabled
if props.disable_shadows:
# Create light path node
light_path_node = nodes.new(type='ShaderNodeLightPath')
light_path_node.location = (50, 150)
# Create transparent BSDF
transparent_node = nodes.new(type='ShaderNodeBsdfTransparent')
transparent_node.location = (200, 150)
# Create mix shader for shadows
shadow_mix_node = nodes.new(type='ShaderNodeMixShader')
shadow_mix_node.location = (350, 50)
# Connect light path "Is Shadow Ray" to mix factor
links.new(light_path_node.outputs['Is Shadow Ray'], shadow_mix_node.inputs['Fac'])
# Mix principled BSDF with transparent BSDF
links.new(current_shader.outputs['BSDF'], shadow_mix_node.inputs[1]) # Shader 1 (not shadow)
links.new(transparent_node.outputs['BSDF'], shadow_mix_node.inputs[2]) # Shader 2 (shadow)
current_shader = shadow_mix_node
final_x_location = 500
# Disable reflections if enabled
if props.disable_reflections:
# Create or reuse light path node
if not props.disable_shadows:
light_path_node = nodes.new(type='ShaderNodeLightPath')
light_path_node.location = (50, 200)
# Create transparent BSDF for reflections
reflection_transparent_node = nodes.new(type='ShaderNodeBsdfTransparent')
reflection_transparent_node.location = (200, 200)
# Create mix shader for reflections
reflection_mix_node = nodes.new(type='ShaderNodeMixShader')
reflection_mix_node.location = (final_x_location - 50, 100)
# Connect light path "Is Reflection Ray" to mix factor
links.new(light_path_node.outputs['Is Reflection Ray'], reflection_mix_node.inputs['Fac'])
# Mix current shader with transparent BSDF
links.new(current_shader.outputs['Shader' if props.disable_shadows else 'BSDF'], reflection_mix_node.inputs[1]) # Shader 1 (not reflection)
links.new(reflection_transparent_node.outputs['BSDF'], reflection_mix_node.inputs[2]) # Shader 2 (reflection)
current_shader = reflection_mix_node
final_x_location = final_x_location + 50
# Disable backfacing if enabled
if props.disable_backfacing:
# Create or reuse light path node
if not props.disable_shadows and not props.disable_reflections:
light_path_node = nodes.new(type='ShaderNodeLightPath')
light_path_node.location = (50, 250)
# Create geometry node for backfacing detection
geometry_node = nodes.new(type='ShaderNodeNewGeometry')
geometry_node.location = (50, 300)
# Create transparent BSDF for backfaces
backface_transparent_node = nodes.new(type='ShaderNodeBsdfTransparent')
backface_transparent_node.location = (200, 250)
# Create mix shader for backfaces
backface_mix_node = nodes.new(type='ShaderNodeMixShader')
backface_mix_node.location = (final_x_location - 50, 150)
# Connect geometry "Backfacing" to mix factor
links.new(geometry_node.outputs['Backfacing'], backface_mix_node.inputs['Fac'])
# Mix current shader with transparent BSDF
current_output = 'Shader' if (props.disable_shadows or props.disable_reflections) else 'BSDF'
links.new(current_shader.outputs[current_output], backface_mix_node.inputs[1]) # Shader 1 (front face)
links.new(backface_transparent_node.outputs['BSDF'], backface_mix_node.inputs[2]) # Shader 2 (backface)
current_shader = backface_mix_node
final_x_location = final_x_location + 50
# Update output node position
output_node.location = (final_x_location, 0)
# Connect final shader to output
output_socket = 'Shader' if (props.disable_shadows or props.disable_reflections) else 'BSDF'
links.new(current_shader.outputs[output_socket], output_node.inputs['Surface'])
elif props.shader_type == 'EMISSION':
shader_node = nodes.new(type='ShaderNodeEmission')
shader_node.location = (200, 0)
# Create texture node
tex_node = nodes.new(type='ShaderNodeTexImage')
tex_node.image = blender_img
tex_node.location = (-200, 0)
# Connect texture to emission
links.new(tex_node.outputs['Color'], shader_node.inputs['Color'])
shader_node.inputs['Strength'].default_value = props.emission_strength
# Handle shadow/reflection disabling
current_shader = shader_node
final_x_location = 400
# Disable shadows if enabled
if props.disable_shadows:
# Create light path node
light_path_node = nodes.new(type='ShaderNodeLightPath')
light_path_node.location = (50, 150)
# Create transparent BSDF
transparent_node = nodes.new(type='ShaderNodeBsdfTransparent')
transparent_node.location = (200, 150)
# Create mix shader for shadows
shadow_mix_node = nodes.new(type='ShaderNodeMixShader')
shadow_mix_node.location = (350, 50)
# Connect light path "Is Shadow Ray" to mix factor
links.new(light_path_node.outputs['Is Shadow Ray'], shadow_mix_node.inputs['Fac'])
# Mix emission with transparent BSDF
links.new(current_shader.outputs['Emission'], shadow_mix_node.inputs[1]) # Shader 1 (not shadow)
links.new(transparent_node.outputs['BSDF'], shadow_mix_node.inputs[2]) # Shader 2 (shadow)
current_shader = shadow_mix_node
final_x_location = 500
# Disable reflections if enabled
if props.disable_reflections:
# Create or reuse light path node
if not props.disable_shadows:
light_path_node = nodes.new(type='ShaderNodeLightPath')
light_path_node.location = (50, 200)
# Create transparent BSDF for reflections
reflection_transparent_node = nodes.new(type='ShaderNodeBsdfTransparent')
reflection_transparent_node.location = (200, 200)
# Create mix shader for reflections
reflection_mix_node = nodes.new(type='ShaderNodeMixShader')
reflection_mix_node.location = (final_x_location - 50, 100)
# Connect light path "Is Reflection Ray" to mix factor
links.new(light_path_node.outputs['Is Reflection Ray'], reflection_mix_node.inputs['Fac'])
# Mix current shader with transparent BSDF
links.new(current_shader.outputs['Shader' if props.disable_shadows else 'Emission'], reflection_mix_node.inputs[1]) # Shader 1 (not reflection)
links.new(reflection_transparent_node.outputs['BSDF'], reflection_mix_node.inputs[2]) # Shader 2 (reflection)
current_shader = reflection_mix_node
final_x_location = final_x_location + 50
# Update output node position
output_node.location = (final_x_location, 0)
# Connect final shader to output
output_socket = 'Shader' if (props.disable_shadows or props.disable_reflections) else 'Emission'
links.new(current_shader.outputs[output_socket], output_node.inputs['Surface'])
elif props.shader_type == 'TRANSPARENT':
shader_node = nodes.new(type='ShaderNodeBsdfTransparent')
mix_node = nodes.new(type='ShaderNodeMixShader')
principled_node = nodes.new(type='ShaderNodeBsdfPrincipled')
shader_node.location = (200, 100)
mix_node.location = (350, 0)
principled_node.location = (200, -100)
# Create texture node
tex_node = nodes.new(type='ShaderNodeTexImage')
tex_node.image = blender_img
tex_node.location = (-200, 0)
# Connect texture
links.new(tex_node.outputs['Color'], principled_node.inputs['Base Color'])
links.new(tex_node.outputs['Alpha'], mix_node.inputs['Fac'])
# Connect shaders to mix
links.new(shader_node.outputs['BSDF'], mix_node.inputs[1])
links.new(principled_node.outputs['BSDF'], mix_node.inputs[2])
# Handle shadow/reflection disabling
current_shader = mix_node
final_x_location = 500
# Disable shadows if enabled
if props.disable_shadows:
# Create light path node
light_path_node = nodes.new(type='ShaderNodeLightPath')
light_path_node.location = (50, 200)
# Create transparent BSDF for shadows (reuse existing transparent node)
shadow_transparent_node = nodes.new(type='ShaderNodeBsdfTransparent')
shadow_transparent_node.location = (350, 150)
# Create mix shader for shadows
shadow_mix_node = nodes.new(type='ShaderNodeMixShader')
shadow_mix_node.location = (500, 50)
# Connect light path "Is Shadow Ray" to mix factor
links.new(light_path_node.outputs['Is Shadow Ray'], shadow_mix_node.inputs['Fac'])
# Mix current shader with transparent BSDF
links.new(current_shader.outputs['Shader'], shadow_mix_node.inputs[1]) # Shader 1 (not shadow)
links.new(shadow_transparent_node.outputs['BSDF'], shadow_mix_node.inputs[2]) # Shader 2 (shadow)
current_shader = shadow_mix_node
final_x_location = 650
# Disable reflections if enabled
if props.disable_reflections:
# Create or reuse light path node
if not props.disable_shadows:
light_path_node = nodes.new(type='ShaderNodeLightPath')
light_path_node.location = (50, 250)
# Create transparent BSDF for reflections
reflection_transparent_node = nodes.new(type='ShaderNodeBsdfTransparent')
reflection_transparent_node.location = (350, 200)
# Create mix shader for reflections
reflection_mix_node = nodes.new(type='ShaderNodeMixShader')
reflection_mix_node.location = (final_x_location - 50, 100)
# Connect light path "Is Reflection Ray" to mix factor
links.new(light_path_node.outputs['Is Reflection Ray'], reflection_mix_node.inputs['Fac'])
# Mix current shader with transparent BSDF
links.new(current_shader.outputs['Shader'], reflection_mix_node.inputs[1]) # Shader 1 (not reflection)
links.new(reflection_transparent_node.outputs['BSDF'], reflection_mix_node.inputs[2]) # Shader 2 (reflection)
current_shader = reflection_mix_node
final_x_location = final_x_location + 50
# Disable backfacing if enabled
if props.disable_backfacing:
# Create or reuse light path node
if not props.disable_shadows and not props.disable_reflections:
light_path_node = nodes.new(type='ShaderNodeLightPath')
light_path_node.location = (50, 300)
# Create geometry node for backfacing detection
geometry_node = nodes.new(type='ShaderNodeNewGeometry')
geometry_node.location = (50, 350)
# Create transparent BSDF for backfaces
backface_transparent_node = nodes.new(type='ShaderNodeBsdfTransparent')
backface_transparent_node.location = (350, 300)
# Create mix shader for backfaces
backface_mix_node = nodes.new(type='ShaderNodeMixShader')
backface_mix_node.location = (final_x_location + 50, 150)
# Connect geometry "Backfacing" to mix factor
links.new(geometry_node.outputs['Backfacing'], backface_mix_node.inputs['Fac'])
# Mix current shader with transparent BSDF
links.new(current_shader.outputs['Shader'], backface_mix_node.inputs[1]) # Shader 1 (front face)
links.new(backface_transparent_node.outputs['BSDF'], backface_mix_node.inputs[2]) # Shader 2 (backface)
current_shader = backface_mix_node
final_x_location = final_x_location + 150
# Update output node position
output_node.location = (final_x_location, 0)
# Connect final shader to output
links.new(current_shader.outputs['Shader'], output_node.inputs['Surface'])
mat.blend_method = 'BLEND'
# Assign material to active object if enabled and possible
if props.auto_assign_material and context.active_object and context.active_object.type in ['MESH', 'CURVE']:
obj = context.active_object
# Check if material slot exists, reuse if found, otherwise create new one
slot_found = False
for i, slot in enumerate(obj.material_slots):
if slot.material and slot.material.name == material_name:
# Material already exists in a slot, update it
obj.material_slots[i].material = mat
slot_found = True
break
if not slot_found:
# Create new material slot if not found
obj.data.materials.append(mat)
self.report({'INFO'}, f"Generated shader '{material_name}' and assigned to {obj.name}")
else:
self.report({'INFO'}, f"Generated shader '{material_name}' (assign manually to object)")
# Switch to Shader Editor if available and set material
for area in context.screen.areas:
if area.type == 'NODE_EDITOR':
for space in area.spaces:
if space.type == 'NODE_EDITOR':
space.shader_type = 'OBJECT'
space.id = mat
area.tag_redraw()
break
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, f"Shader generation failed: {str(e)}")
import traceback
traceback.print_exc()
return {'CANCELLED'}
class TEXT_TEXTURE_OT_refresh_preview(Operator):
"""Force refresh the preview"""
bl_idname = "text_texture.refresh_preview"
bl_label = "Refresh Preview"
bl_description = "Force refresh the preview image"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
props = context.scene.text_texture_props
# Clear existing preview first
props.preview_image = None
# Generate new preview
result = generate_preview(context)
if result:
self.report({'INFO'}, f"Preview refreshed at {props.preview_size}px")
else:
self.report({'ERROR'}, "Failed to generate preview - check console for details")
# Force UI update
for area in context.screen.areas:
area.tag_redraw()
return {'FINISHED'}
class TEXT_TEXTURE_OT_view_preview_zoom(Operator):
"""Open preview in new Image Editor window with zoom support"""
bl_idname = "text_texture.view_preview_zoom"
bl_label = "Enable Zoom"
bl_description = "Open preview in new Image Editor window with zoom and pan controls"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
props = context.scene.text_texture_props
# Generate preview image when button is clicked
print("[TTG] Generating preview on button click...")
result = generate_preview(context)
if not result:
self.report({'ERROR'}, "Failed to generate preview")
return {'CANCELLED'}
if not props.preview_image:
self.report({'ERROR'}, "No preview image to display")
return {'CANCELLED'}
# Open a new window with Image Editor
print(f"[ZOOM] Opening new window for preview image: {props.preview_image.name}")
try:
# Create a new window
bpy.ops.screen.userpref_show('INVOKE_DEFAULT')
# Wait a moment and then change the newly opened window to Image Editor
def setup_image_window():
# Find the newest window (should be the one we just opened)
newest_window = None
for window in bpy.context.window_manager.windows:
if window.screen.name.startswith("temp"):
newest_window = window
break
if not newest_window:
# Fallback: use any non-main window
for window in bpy.context.window_manager.windows:
if window != bpy.context.window:
newest_window = window
break
if newest_window:
# Set the area type to Image Editor
for area in newest_window.screen.areas:
if area.type == 'PREFERENCES':
area.type = 'IMAGE_EDITOR'
print("[ZOOM] Changed area to Image Editor")
# Set the preview image
for space in area.spaces:
if space.type == 'IMAGE_EDITOR':
space.image = props.preview_image
print(f"[ZOOM] Set preview image: {props.preview_image.name}")
# Set up proper viewing
override = {
'window': newest_window,
'screen': newest_window.screen,
'area': area,
'region': area.regions[0] if area.regions else None
}
with bpy.context.temp_override(**override):
try:
bpy.ops.image.view_all(fit_view=True)
print("[ZOOM] Applied fit to view")
except Exception as e:
print(f"[ZOOM] Error applying fit to view: {e}")
break
break
self.report({'INFO'}, "✅ NEW WINDOW OPENED! Use mouse wheel to zoom, middle-mouse to pan")
print("[ZOOM] New window zoom feature activated successfully")
else:
print("[ZOOM] Could not find new window")
self.report({'ERROR'}, "Failed to setup new window")
return None # Stop timer
# Use a timer to set up the window after it opens
bpy.app.timers.register(setup_image_window, first_interval=0.1)
except Exception as e:
print(f"[ZOOM] Error creating new window: {e}")
# Fallback to old behavior if new window creation fails
self.report({'WARNING'}, "New window failed, trying area split instead")
# Find any area to split
for area in context.screen.areas:
if area.type in ['NODE_EDITOR', 'VIEW_3D', 'PROPERTIES']:
print(f"[ZOOM] Fallback: Splitting {area.type} to create Image Editor")
# Split horizontally to create a new area
with context.temp_override(area=area):
bpy.ops.screen.area_split(direction='HORIZONTAL', factor=0.6)
# Find the newly created area
for new_area in context.screen.areas:
if new_area != area and new_area.y != area.y:
new_area.type = 'IMAGE_EDITOR'
# Set the image in the Image Editor
for space in new_area.spaces:
if space.type == 'IMAGE_EDITOR':
space.image = props.preview_image
print("[ZOOM] Fallback: Image set in split Image Editor")
break
# Tag for redraw and fit to view
new_area.tag_redraw()
override = context.copy()
override['area'] = new_area
override['region'] = new_area.regions[0]
with context.temp_override(**override):
try:
bpy.ops.image.view_all(fit_view=True)
print("[ZOOM] Fallback: Fit to view applied")
except Exception as e:
print(f"[ZOOM] Fallback: Error applying fit to view: {e}")
self.report({'INFO'}, "✅ ZOOM ENABLED! Use mouse wheel to zoom, middle-mouse to pan")
return {'FINISHED'}
break
self.report({'ERROR'}, "Failed to create Image Editor")
return {'CANCELLED'}
return {'FINISHED'}
class TEXT_TEXTURE_OT_save_preset(Operator):
"""Save current settings as preset"""
bl_idname = "text_texture.save_preset"
bl_label = "Save Preset"
bl_description = "Save current settings as preset"
bl_options = {'REGISTER', 'UNDO'}
overwrite: bpy.props.BoolProperty(
name="Overwrite",
description="Overwrite existing preset",
default=False
)
preset_name: bpy.props.StringProperty(
name="Preset Name",
description="Name of preset to save/overwrite",
default=""
)
def execute(self, context):
props = context.scene.text_texture_props
# Use provided preset name or fallback to input field
preset_name = self.preset_name.strip() if self.preset_name else props.new_preset_name.strip()
if not preset_name:
self.report({'ERROR'}, "Please enter a preset name")
return {'CANCELLED'}
# When overwriting from button, update the input field for consistency
if self.preset_name and self.preset_name.strip():
props.new_preset_name = preset_name
# Validate preset name characters (avoid filesystem issues)
import re
# Allow Unicode letters, numbers, spaces, hyphens, underscores, and common accented characters
# This pattern supports international characters like ö, ü, ñ, etc.
if not re.match(r'^[\w\s\-]+$', preset_name, re.UNICODE):
self.report({'ERROR'}, "Preset name contains invalid characters for filesystem compatibility")
return {'CANCELLED'}
# Additional check for filesystem-unsafe characters
invalid_chars = ['<', '>', ':', '"', '|', '?', '*', '/', '\\']
if any(char in preset_name for char in invalid_chars):
self.report({'ERROR'}, f"Preset name cannot contain: {' '.join(invalid_chars)}")
return {'CANCELLED'}
# Check for duplicate names
preset_file = os.path.join(get_preset_path(), f"{preset_name}.json")
if os.path.exists(preset_file) and not self.overwrite:
# Suggest alternative names
base_name = preset_name
counter = 1
suggested_name = f"{base_name}_{counter}"
while os.path.exists(os.path.join(get_preset_path(), f"{suggested_name}.json")):
counter += 1
suggested_name = f"{base_name}_{counter}"
self.report({'ERROR'}, f"Preset '{preset_name}' already exists. Suggestion: try '{suggested_name}' or delete the existing preset first.")
return {'CANCELLED'}
# DETAILED LOGGING: Current shader properties before saving
print(f"[PRESET SAVE DEBUG] ==================== SAVING PRESET: '{preset_name}' ====================")
print(f"[PRESET SAVE DEBUG] Current shader properties from props:")
print(f"[PRESET SAVE DEBUG] disable_shadows: {props.disable_shadows}")
print(f"[PRESET SAVE DEBUG] disable_reflections: {props.disable_reflections}")
print(f"[PRESET SAVE DEBUG] disable_backfacing: {props.disable_backfacing}")
print(f"[PRESET SAVE DEBUG] shader_type: {props.shader_type}")
print(f"[PRESET SAVE DEBUG] shader_connection: {props.shader_connection}")
print(f"[PRESET SAVE DEBUG] use_alpha: {props.use_alpha}")
print(f"[PRESET SAVE DEBUG] emission_strength: {props.emission_strength}")
print(f"[PRESET SAVE DEBUG] auto_assign_material: {props.auto_assign_material}")
preset_data = {
'text': props.text,
'texture_width': props.texture_width,
'texture_height': props.texture_height,
'font_size': props.font_size,
'padding': props.padding,
'text_color': list(props.text_color),
'background_transparent': props.background_transparent,
'background_color': list(props.background_color),
'font_path': props.font_path,
'custom_font_path': props.custom_font_path,
'use_custom_font': props.use_custom_font,
'text_align': props.text_align,
'prepend_text': props.prepend_text,
'append_text': props.append_text,
'prepend_margin': props.prepend_margin,
'append_margin': props.append_margin,
'prepend_append_layout': props.prepend_append_layout,
'shader_type': props.shader_type,
'shader_connection': props.shader_connection,
'use_alpha': props.use_alpha,
'emission_strength': props.emission_strength,
'auto_assign_material': props.auto_assign_material,
'disable_shadows': props.disable_shadows,
'disable_reflections': props.disable_reflections,
'disable_backfacing': props.disable_backfacing,
'generate_normal_map': props.generate_normal_map,
'normal_map_strength': props.normal_map_strength,
'normal_map_blur_radius': props.normal_map_blur_radius,
'normal_map_invert': props.normal_map_invert,
'enable_multiline': props.enable_multiline,
'line_height': props.line_height,
'text_alignment': props.text_alignment,
'auto_wrap': props.auto_wrap,
'wrap_width': props.wrap_width,
'max_lines': props.max_lines,
'line_spacing_pixels': props.line_spacing_pixels,
'enable_stroke': props.enable_stroke,
'stroke_width': props.stroke_width,
'stroke_position': props.stroke_position,
'stroke_color': list(props.stroke_color),
'enable_shadow': props.enable_shadow,
'shadow_offset_x': props.shadow_offset_x,
'shadow_offset_y': props.shadow_offset_y,
'shadow_blur': props.shadow_blur,
'shadow_color': list(props.shadow_color),
'shadow_spread': getattr(props, 'shadow_spread', 1.0),
'enable_glow': props.enable_glow,
'glow_blur': props.glow_blur,
'glow_color': list(props.glow_color),
'enable_blur': props.enable_blur,
'blur_radius': props.blur_radius,
'image_overlays': []
}
# DETAILED LOGGING: Verify shader properties in preset_data
print(f"[PRESET SAVE DEBUG] Shader properties in preset_data dictionary:")
print(f"[PRESET SAVE DEBUG] disable_shadows: {preset_data.get('disable_shadows', 'NOT FOUND')}")
print(f"[PRESET SAVE DEBUG] disable_reflections: {preset_data.get('disable_reflections', 'NOT FOUND')}")
print(f"[PRESET SAVE DEBUG] disable_backfacing: {preset_data.get('disable_backfacing', 'NOT FOUND')}")
# Save overlay data
for overlay in props.image_overlays:
overlay_data = {
'name': overlay.name,
'image_path': overlay.image_path,
'x_position': overlay.x_position,
'y_position': overlay.y_position,
'scale': overlay.scale,
'rotation': overlay.rotation,
'z_index': overlay.z_index,
'enabled': overlay.enabled
}
preset_data['image_overlays'].append(overlay_data)
try:
# Primary: Save to blend file (new system)
print(f"[PRESET SAVE DEBUG] Attempting to save to blend file...")
blend_success = save_blend_file_preset(preset_name, preset_data)
print(f"[PRESET SAVE DEBUG] Blend file save result: {blend_success}")
# ALWAYS save to persistent storage for cross-blend-file sharing and addon update survival
persistent_file = os.path.join(get_persistent_preset_path(), f"{preset_name}.json")
print(f"[PRESET SAVE DEBUG] Attempting to save to persistent file: {persistent_file}")
persistent_success = True
try:
with open(persistent_file, 'w') as f:
json.dump(preset_data, f, indent=2)
print(f"[PRESET SAVE DEBUG] Successfully saved preset '{preset_name}' to persistent storage")
# VERIFY: Read back the persistent file to confirm shader properties were saved
print(f"[PRESET SAVE DEBUG] Verifying persistent file contents...")
with open(persistent_file, 'r') as f:
saved_data = json.load(f)
print(f"[PRESET SAVE DEBUG] Persistent file shader properties verification:")
print(f"[PRESET SAVE DEBUG] disable_shadows: {saved_data.get('disable_shadows', 'NOT FOUND')}")
print(f"[PRESET SAVE DEBUG] disable_reflections: {saved_data.get('disable_reflections', 'NOT FOUND')}")
print(f"[PRESET SAVE DEBUG] disable_backfacing: {saved_data.get('disable_backfacing', 'NOT FOUND')}")
except Exception as persistent_error:
print(f"[PRESET SAVE DEBUG] Warning: Failed to save persistent preset file: {persistent_error}")
persistent_success = False
# Also save to legacy location for backward compatibility (optional)
legacy_success = True
try:
legacy_file = os.path.join(get_preset_path(), f"{preset_name}.json")
with open(legacy_file, 'w') as f:
json.dump(preset_data, f, indent=2)
print(f"[PRESET SAVE DEBUG] Also saved to legacy location for backward compatibility")
except Exception as legacy_error:
print(f"[PRESET SAVE DEBUG] Note: Could not save to legacy location: {legacy_error}")
legacy_success = False
if not blend_success and not persistent_success:
self.report({'ERROR'}, f"Failed to save preset to both blend file and persistent storage")
return {'CANCELLED'}
# Check if preset already exists in the collection and update it
existing_preset = None
for preset in props.presets:
if preset.name == preset_name:
existing_preset = preset
break
if not existing_preset:
preset = props.presets.add()
preset.name = preset_name
# Priority: Blend file > Persistent file
preset.source = 'BLEND_FILE' if blend_success else 'PERSISTENT_FILE'
else:
# Update source based on where it was successfully saved
existing_preset.source = 'BLEND_FILE' if blend_success else 'PERSISTENT_FILE'
# Don't clear the input field - keep the name for easy re-saving
# props.new_preset_name = "New Preset"
# Provide clear feedback
storage_info = ""
if blend_success and persistent_success:
storage_info = " (saved to blend file + persistent storage)"
elif blend_success:
storage_info = " (saved to blend file)"
elif persistent_success:
storage_info = " (saved to persistent storage)"
if self.overwrite:
self.report({'INFO'}, f"✅ Preset '{preset_name}' updated successfully{storage_info}")
else:
self.report({'INFO'}, f"✅ Preset '{preset_name}' saved successfully{storage_info}")
except (OSError, IOError, json.JSONEncodeError) as e:
self.report({'ERROR'}, f"Failed to save preset: {str(e)}")
return {'CANCELLED'}
except Exception as e:
self.report({'ERROR'}, f"Unexpected error saving preset: {str(e)}")
return {'CANCELLED'}
return {'FINISHED'}
def invoke(self, context, event):
# Check if preset exists and prompt for overwrite
props = context.scene.text_texture_props
preset_name = props.new_preset_name.strip()
if preset_name and preset_name != "New Preset":
preset_file = os.path.join(get_preset_path(), f"{preset_name}.json")
if os.path.exists(preset_file):
return context.window_manager.invoke_confirm(self, event)
return self.execute(context)
def draw(self, context):
layout = self.layout
props = context.scene.text_texture_props
preset_name = props.new_preset_name.strip()
layout.label(text=f"Overwrite existing preset '{preset_name}'?")
class TEXT_TEXTURE_OT_save_preset_enter(Operator):
"""Save preset when Enter key is pressed in name field"""
bl_idname = "text_texture.save_preset_enter"
bl_label = "Save Preset (Enter)"
bl_description = "Save current settings as preset when Enter is pressed"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
# Delegate to the main save preset operator
return bpy.ops.text_texture.save_preset('INVOKE_DEFAULT')
class TEXT_TEXTURE_OT_load_preset(Operator):
"""Load preset"""
bl_idname = "text_texture.load_preset"
bl_label = "Load Preset"
bl_description = "Load selected preset"
bl_options = {'REGISTER', 'UNDO'}
preset_name: StringProperty()
def execute(self, context):
props = context.scene.text_texture_props
# DEFENSIVE PROGRAMMING: Ensure presets are available before loading
if not ensure_presets_available():
self.report({'ERROR'}, "Failed to ensure presets are loaded - cannot load preset")
return {'CANCELLED'}
# DETAILED LOGGING: Current shader properties before loading
print(f"[PRESET LOAD DEBUG] ==================== LOADING PRESET: '{self.preset_name}' ====================")
print(f"[PRESET LOAD DEBUG] Current shader properties BEFORE loading:")
print(f"[PRESET LOAD DEBUG] disable_shadows: {props.disable_shadows}")
print(f"[PRESET LOAD DEBUG] disable_reflections: {props.disable_reflections}")
print(f"[PRESET LOAD DEBUG] disable_backfacing: {props.disable_backfacing}")
print(f"[PRESET LOAD DEBUG] shader_type: {props.shader_type}")
print(f"[PRESET LOAD DEBUG] shader_connection: {props.shader_connection}")
try:
# Three-tier loading priority: Blend File > Persistent File > Legacy File
blend_presets = get_blend_file_presets()
preset_data = None
source = 'BLEND_FILE'
print(f"[PRESET LOAD DEBUG] Available blend file presets: {list(blend_presets.keys())}")
if self.preset_name in blend_presets:
preset_data = blend_presets[self.preset_name]
source = 'BLEND_FILE'
print(f"[PRESET LOAD DEBUG] Loading preset '{self.preset_name}' from blend file")
print(f"[PRESET LOAD DEBUG] Blend file preset shader properties:")
print(f"[PRESET LOAD DEBUG] disable_shadows: {preset_data.get('disable_shadows', 'NOT FOUND')}")
print(f"[PRESET LOAD DEBUG] disable_reflections: {preset_data.get('disable_reflections', 'NOT FOUND')}")
print(f"[PRESET LOAD DEBUG] disable_backfacing: {preset_data.get('disable_backfacing', 'NOT FOUND')}")
else:
# Try persistent storage next
persistent_file = os.path.join(get_persistent_preset_path(), f"{self.preset_name}.json")
print(f"[PRESET LOAD DEBUG] Preset not in blend file, trying persistent file: {persistent_file}")
if os.path.exists(persistent_file):
with open(persistent_file, 'r') as f:
preset_data = json.load(f)
source = 'PERSISTENT_FILE'
print(f"[PRESET LOAD DEBUG] Loading preset '{self.preset_name}' from persistent file")
print(f"[PRESET LOAD DEBUG] Persistent file preset shader properties:")
print(f"[PRESET LOAD DEBUG] disable_shadows: {preset_data.get('disable_shadows', 'NOT FOUND')}")
print(f"[PRESET LOAD DEBUG] disable_reflections: {preset_data.get('disable_reflections', 'NOT FOUND')}")
print(f"[PRESET LOAD DEBUG] disable_backfacing: {preset_data.get('disable_backfacing', 'NOT FOUND')}")
else:
# Fallback to legacy local file
legacy_file = os.path.join(get_preset_path(), f"{self.preset_name}.json")
print(f"[PRESET LOAD DEBUG] Preset not in persistent storage, trying legacy file: {legacy_file}")
if os.path.exists(legacy_file):
with open(legacy_file, 'r') as f:
preset_data = json.load(f)
source = 'LOCAL_FILE'
print(f"[PRESET LOAD DEBUG] Loading preset '{self.preset_name}' from legacy file")
print(f"[PRESET LOAD DEBUG] Legacy file preset shader properties:")
print(f"[PRESET LOAD DEBUG] disable_shadows: {preset_data.get('disable_shadows', 'NOT FOUND')}")
print(f"[PRESET LOAD DEBUG] disable_reflections: {preset_data.get('disable_reflections', 'NOT FOUND')}")
print(f"[PRESET LOAD DEBUG] disable_backfacing: {preset_data.get('disable_backfacing', 'NOT FOUND')}")
else:
print(f"[PRESET LOAD DEBUG] Legacy file does not exist: {legacy_file}")
self.report({'ERROR'}, f"Preset not found in any storage location: {self.preset_name}")
return {'CANCELLED'}
if not preset_data:
print(f"[PRESET LOAD DEBUG] No preset data found!")
self.report({'ERROR'}, f"No data found for preset: {self.preset_name}")
return {'CANCELLED'}
props.is_updating = True
# Skip loading text to prevent overwriting current text
# props.text = preset_data.get('text', props.text)
props.texture_width = preset_data.get('texture_width', props.texture_width)
props.texture_height = preset_data.get('texture_height', props.texture_height)
props.font_size = preset_data.get('font_size', props.font_size)
props.padding = preset_data.get('padding', props.padding)
props.text_color = preset_data.get('text_color', props.text_color)
props.background_transparent = preset_data.get('background_transparent', props.background_transparent)
props.background_color = preset_data.get('background_color', props.background_color)
props.font_path = preset_data.get('font_path', props.font_path)
props.custom_font_path = preset_data.get('custom_font_path', props.custom_font_path)
props.use_custom_font = preset_data.get('use_custom_font', props.use_custom_font)
props.text_align = preset_data.get('text_align', props.text_align)
props.prepend_text = preset_data.get('prepend_text', props.prepend_text)
props.append_text = preset_data.get('append_text', props.append_text)
props.prepend_margin = preset_data.get('prepend_margin', props.prepend_margin)
props.append_margin = preset_data.get('append_margin', props.append_margin)
props.prepend_append_layout = preset_data.get('prepend_append_layout', props.prepend_append_layout)
# DETAILED LOGGING: Shader property loading with before/after values
print(f"[PRESET LOAD DEBUG] Loading shader properties...")
old_shader_type = props.shader_type
props.shader_type = preset_data.get('shader_type', props.shader_type)
print(f"[PRESET LOAD DEBUG] shader_type: {old_shader_type} -> {props.shader_type}")
old_shader_connection = props.shader_connection
props.shader_connection = preset_data.get('shader_connection', props.shader_connection)
print(f"[PRESET LOAD DEBUG] shader_connection: {old_shader_connection} -> {props.shader_connection}")
old_use_alpha = props.use_alpha
props.use_alpha = preset_data.get('use_alpha', props.use_alpha)
print(f"[PRESET LOAD DEBUG] use_alpha: {old_use_alpha} -> {props.use_alpha}")
old_emission_strength = props.emission_strength
props.emission_strength = preset_data.get('emission_strength', props.emission_strength)
print(f"[PRESET LOAD DEBUG] emission_strength: {old_emission_strength} -> {props.emission_strength}")
old_auto_assign = props.auto_assign_material
props.auto_assign_material = preset_data.get('auto_assign_material', props.auto_assign_material)
print(f"[PRESET LOAD DEBUG] auto_assign_material: {old_auto_assign} -> {props.auto_assign_material}")
# CRITICAL SHADER PROPERTIES - Most detailed logging
print(f"[PRESET LOAD DEBUG] ==================== CRITICAL SHADER PROPERTIES ====================")
old_disable_shadows = props.disable_shadows
new_disable_shadows = preset_data.get('disable_shadows', props.disable_shadows)
props.disable_shadows = new_disable_shadows
print(f"[PRESET LOAD DEBUG] disable_shadows:")
print(f"[PRESET LOAD DEBUG] OLD VALUE: {old_disable_shadows} (type: {type(old_disable_shadows)})")
print(f"[PRESET LOAD DEBUG] PRESET DATA: {preset_data.get('disable_shadows', 'NOT FOUND')} (type: {type(preset_data.get('disable_shadows', 'N/A'))})")
print(f"[PRESET LOAD DEBUG] NEW VALUE: {props.disable_shadows} (type: {type(props.disable_shadows)})")
old_disable_reflections = props.disable_reflections
new_disable_reflections = preset_data.get('disable_reflections', props.disable_reflections)
props.disable_reflections = new_disable_reflections
print(f"[PRESET LOAD DEBUG] disable_reflections:")
print(f"[PRESET LOAD DEBUG] OLD VALUE: {old_disable_reflections} (type: {type(old_disable_reflections)})")
print(f"[PRESET LOAD DEBUG] PRESET DATA: {preset_data.get('disable_reflections', 'NOT FOUND')} (type: {type(preset_data.get('disable_reflections', 'N/A'))})")
print(f"[PRESET LOAD DEBUG] NEW VALUE: {props.disable_reflections} (type: {type(props.disable_reflections)})")
old_disable_backfacing = props.disable_backfacing
new_disable_backfacing = preset_data.get('disable_backfacing', props.disable_backfacing)
props.disable_backfacing = new_disable_backfacing
print(f"[PRESET LOAD DEBUG] disable_backfacing:")
print(f"[PRESET LOAD DEBUG] OLD VALUE: {old_disable_backfacing} (type: {type(old_disable_backfacing)})")
print(f"[PRESET LOAD DEBUG] PRESET DATA: {preset_data.get('disable_backfacing', 'NOT FOUND')} (type: {type(preset_data.get('disable_backfacing', 'N/A'))})")
print(f"[PRESET LOAD DEBUG] NEW VALUE: {props.disable_backfacing} (type: {type(props.disable_backfacing)})")
print(f"[PRESET LOAD DEBUG] ================================================================")
props.generate_normal_map = preset_data.get('generate_normal_map', props.generate_normal_map)
props.normal_map_strength = preset_data.get('normal_map_strength', props.normal_map_strength)
props.normal_map_blur_radius = preset_data.get('normal_map_blur_radius', props.normal_map_blur_radius)
props.normal_map_invert = preset_data.get('normal_map_invert', props.normal_map_invert)
# Load multiline settings
props.enable_multiline = preset_data.get('enable_multiline', props.enable_multiline)
props.line_height = preset_data.get('line_height', props.line_height)
props.text_alignment = preset_data.get('text_alignment', props.text_alignment)
props.auto_wrap = preset_data.get('auto_wrap', props.auto_wrap)
props.wrap_width = preset_data.get('wrap_width', props.wrap_width)
props.max_lines = preset_data.get('max_lines', props.max_lines)
props.line_spacing_pixels = preset_data.get('line_spacing_pixels', props.line_spacing_pixels)
# Load stroke settings
props.enable_stroke = preset_data.get('enable_stroke', props.enable_stroke)
props.stroke_width = preset_data.get('stroke_width', props.stroke_width)
props.stroke_position = preset_data.get('stroke_position', props.stroke_position)
props.stroke_color = preset_data.get('stroke_color', props.stroke_color)
# Load shadow/glow settings
props.enable_shadow = preset_data.get('enable_shadow', props.enable_shadow)
props.shadow_offset_x = preset_data.get('shadow_offset_x', props.shadow_offset_x)
props.shadow_offset_y = preset_data.get('shadow_offset_y', props.shadow_offset_y)
props.shadow_blur = preset_data.get('shadow_blur', props.shadow_blur)
props.shadow_color = preset_data.get('shadow_color', props.shadow_color)
# Handle new shadow_spread property with backward compatibility
if hasattr(props, 'shadow_spread'):
props.shadow_spread = preset_data.get('shadow_spread', 1.0)
props.enable_glow = preset_data.get('enable_glow', props.enable_glow)
props.glow_blur = preset_data.get('glow_blur', props.glow_blur)
props.glow_color = preset_data.get('glow_color', props.glow_color)
# Load blur settings
props.enable_blur = preset_data.get('enable_blur', props.enable_blur)
props.blur_radius = preset_data.get('blur_radius', props.blur_radius)
# Load text fitting settings
props.enable_text_fitting = preset_data.get('enable_text_fitting', props.enable_text_fitting)
props.text_fitting_mode = preset_data.get('text_fitting_mode', props.text_fitting_mode)
props.text_fitting_margin = preset_data.get('text_fitting_margin', props.text_fitting_margin)
props.min_font_size = preset_data.get('min_font_size', props.min_font_size)
props.max_font_size = preset_data.get('max_font_size', props.max_font_size)
# Load overlay data
props.image_overlays.clear()
overlay_data_list = preset_data.get('image_overlays', [])
for overlay_data in overlay_data_list:
overlay = props.image_overlays.add()
overlay.name = overlay_data.get('name', 'Overlay')
overlay.image_path = overlay_data.get('image_path', '')
overlay.x_position = overlay_data.get('x_position', 0.5)
overlay.y_position = overlay_data.get('y_position', 0.5)
overlay.scale = overlay_data.get('scale', 1.0)
overlay.rotation = overlay_data.get('rotation', 0.0)
overlay.z_index = overlay_data.get('z_index', 1)
overlay.enabled = overlay_data.get('enabled', True)
props.is_updating = False
# FINAL VERIFICATION: Check what the properties actually are after loading
print(f"[PRESET LOAD DEBUG] ==================== FINAL VERIFICATION ====================")
print(f"[PRESET LOAD DEBUG] Properties AFTER loading preset '{self.preset_name}':")
print(f"[PRESET LOAD DEBUG] disable_shadows: {props.disable_shadows} (type: {type(props.disable_shadows)})")
print(f"[PRESET LOAD DEBUG] disable_reflections: {props.disable_reflections} (type: {type(props.disable_reflections)})")
print(f"[PRESET LOAD DEBUG] disable_backfacing: {props.disable_backfacing} (type: {type(props.disable_backfacing)})")
print(f"[PRESET LOAD DEBUG] ================================================================")
# Always generate preview after loading preset
generate_preview(context)
source_info = {
'BLEND_FILE': "from blend file",
'PERSISTENT_FILE': "from persistent storage",
'LOCAL_FILE': "from legacy storage"
}.get(source, "from unknown source")
self.report({'INFO'}, f"✅ Loaded preset '{self.preset_name}' {source_info}")
except (OSError, IOError) as e:
props.is_updating = False
self.report({'ERROR'}, f"Failed to read preset file: {str(e)}")
return {'CANCELLED'}
except json.JSONDecodeError as e:
props.is_updating = False
self.report({'ERROR'}, f"Invalid preset file format: {str(e)}")
return {'CANCELLED'}
except Exception as e:
props.is_updating = False
self.report({'ERROR'}, f"Unexpected error loading preset: {str(e)}")
return {'CANCELLED'}
return {'FINISHED'}
class TEXT_TEXTURE_OT_delete_preset(Operator):
"""Delete preset"""
bl_idname = "text_texture.delete_preset"
bl_label = "Delete Preset"
bl_description = "Delete selected preset"
bl_options = {'REGISTER', 'UNDO'}
preset_name: StringProperty()
def execute(self, context):
props = context.scene.text_texture_props
try:
# Delete from both blend file and local storage
blend_deleted = delete_blend_file_preset(self.preset_name)
preset_file = os.path.join(get_preset_path(), f"{self.preset_name}.json")
local_deleted = False
if os.path.exists(preset_file):
os.remove(preset_file)
local_deleted = True
if not blend_deleted and not local_deleted:
self.report({'WARNING'}, f"Preset '{self.preset_name}' not found in blend file or local storage")
# Remove from UI list
for i, preset in enumerate(props.presets):
if preset.name == self.preset_name:
props.presets.remove(i)
break
delete_info = []
if blend_deleted:
delete_info.append("blend file")
if local_deleted:
delete_info.append("local storage")
location_text = " and ".join(delete_info) if delete_info else "nowhere (not found)"
self.report({'INFO'}, f"🗑️ Deleted preset '{self.preset_name}' from {location_text}")
except (OSError, IOError) as e:
self.report({'ERROR'}, f"Failed to delete preset file: {str(e)}")
return {'CANCELLED'}
except Exception as e:
self.report({'ERROR'}, f"Unexpected error deleting preset: {str(e)}")
return {'CANCELLED'}
return {'FINISHED'}
def invoke(self, context, event):
# Add confirmation dialog for better UX
return context.window_manager.invoke_confirm(self, event)
def draw(self, context):
layout = self.layout
layout.label(text=f"Delete preset '{self.preset_name}'?")
layout.label(text="This action cannot be undone.", icon='ERROR')
class TEXT_TEXTURE_OT_open_panel(Operator):
"""Open Text Texture Generator panel"""
bl_idname = "text_texture.open_panel"
bl_label = "Open Text Texture Panel"
bl_description = "Open the Text Texture Generator panel in the sidebar"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
# Ensure the sidebar is visible
for area in context.screen.areas:
if area.type == 'VIEW_3D':
for space in area.spaces:
if space.type == 'VIEW_3D':
space.show_region_ui = True
# Switch to Tool tab
area.tag_redraw()
self.report({'INFO'}, "Text Texture panel opened in 3D View sidebar (press N if not visible)")
return {'FINISHED'}
elif area.type == 'NODE_EDITOR':
for space in area.spaces:
if space.type == 'NODE_EDITOR':
space.show_region_ui = True
area.tag_redraw()
self.report({'INFO'}, "Text Texture panel opened in Shader Editor sidebar")
return {'FINISHED'}
self.report({'WARNING'}, "Please open a 3D View or Shader Editor first")
return {'CANCELLED'}
class TEXT_TEXTURE_OT_refresh_fonts(Operator):
"""Refresh fonts"""
bl_idname = "text_texture.refresh_fonts"
bl_label = "Refresh Fonts"
bl_description = "Refresh font list"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
if hasattr(get_font_enum_items, 'cached_fonts'):
del get_font_enum_items.cached_fonts
get_font_enum_items.cached_fonts = get_system_fonts()
self.report({'INFO'}, f"Found {len(get_font_enum_items.cached_fonts)} fonts")
return {'FINISHED'}
class TEXT_TEXTURE_OT_refresh_presets(Operator):
"""Refresh presets from blend file and local storage"""
bl_idname = "text_texture.refresh_presets"
bl_label = "Refresh Presets"
bl_description = "Reload presets from blend file and local storage"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
try:
# Use the reliable synchronous refresh system
success = refresh_presets_sync()
if success:
props = context.scene.text_texture_props
total_presets = len(props.presets)
# Count by source for detailed reporting
blend_count = len([p for p in props.presets if p.source == 'BLEND_FILE'])
persistent_count = len([p for p in props.presets if p.source == 'PERSISTENT_FILE'])
local_count = len([p for p in props.presets if p.source == 'LOCAL_FILE'])
self.report({'INFO'}, f"✅ Refreshed {total_presets} presets ({blend_count} blend file, {persistent_count} persistent, {local_count} local)")
else:
self.report({'WARNING'}, "Preset refresh completed with issues - check console for details")
# Force UI redraw
for area in context.screen.areas:
area.tag_redraw()
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, f"Failed to refresh presets: {str(e)}")
import traceback
traceback.print_exc()
return {'CANCELLED'}
class TEXT_TEXTURE_OT_add_overlay(Operator):
"""Add new image overlay"""
bl_idname = "text_texture.add_overlay"
bl_label = "Add Overlay"
bl_description = "Add new image overlay"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
props = context.scene.text_texture_props
overlay = props.image_overlays.add()
overlay.name = f"Overlay {len(props.image_overlays)}"
props.active_overlay_index = len(props.image_overlays) - 1
# Force UI redraw
for area in context.screen.areas:
area.tag_redraw()
self.report({'INFO'}, f"Added overlay: {overlay.name}")
return {'FINISHED'}
class TEXT_TEXTURE_OT_remove_overlay(Operator):
"""Remove active image overlay"""
bl_idname = "text_texture.remove_overlay"
bl_label = "Remove Overlay"
bl_description = "Remove active image overlay"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
props = context.scene.text_texture_props
if props.image_overlays and props.active_overlay_index < len(props.image_overlays):
overlay_name = props.image_overlays[props.active_overlay_index].name
props.image_overlays.remove(props.active_overlay_index)
# Update active index
if props.active_overlay_index >= len(props.image_overlays) and props.image_overlays:
props.active_overlay_index = len(props.image_overlays) - 1
# Force UI redraw
for area in context.screen.areas:
area.tag_redraw()
self.report({'INFO'}, f"Removed overlay: {overlay_name}")
else:
self.report({'WARNING'}, "No overlay to remove")
return {'FINISHED'}
class TEXT_TEXTURE_OT_set_anchor_point(Operator):
"""Set anchor point for text positioning"""
bl_idname = "text_texture.set_anchor_point"
bl_label = "Set Anchor Point"
bl_description = "Set text anchor position"
bl_options = {'REGISTER', 'UNDO'}
anchor_point: StringProperty()
def execute(self, context):
props = context.scene.text_texture_props
props.anchor_point = self.anchor_point
return {'FINISHED'}
class TEXT_TEXTURE_OT_sync_margins(Operator):
"""Sync margin values when linked"""
bl_idname = "text_texture.sync_margins"
bl_label = "Sync Margins"
bl_description = "Synchronize margin values"
bl_options = {'REGISTER', 'UNDO'}
margin_type: StringProperty()
def execute(self, context):
props = context.scene.text_texture_props
sync_margin_values(props, self.margin_type)
return {'FINISHED'}
class TEXT_TEXTURE_OT_export_presets(Operator):
"""Export all presets to a file for backup"""
bl_idname = "text_texture.export_presets"
bl_label = "Export Presets"
bl_description = "Export all presets to a file for backup"
bl_options = {'REGISTER', 'UNDO'}
filepath: StringProperty(
name="File Path",
description="Path to save preset backup file",
subtype='FILE_PATH',
default="text_texture_presets_backup.json"
)
def execute(self, context):
try:
props = context.scene.text_texture_props
# Collect all presets from all sources
all_presets = {}
# Get blend file presets
blend_presets = get_blend_file_presets()
for name, data in blend_presets.items():
all_presets[name] = {
"data": data,
"source": "BLEND_FILE"
}
# Get persistent presets
persistent_dir = get_persistent_preset_path()
if os.path.exists(persistent_dir):
for filename in os.listdir(persistent_dir):
if filename.endswith('.json'):
preset_name = os.path.splitext(filename)[0]
if preset_name not in all_presets: # Don't override blend file presets
try:
with open(os.path.join(persistent_dir, filename), 'r') as f:
preset_data = json.load(f)
all_presets[preset_name] = {
"data": preset_data,
"source": "PERSISTENT_FILE"
}
except (json.JSONDecodeError, OSError) as e:
print(f"[TTG] Error reading persistent preset {preset_name}: {e}")
# Get legacy presets
legacy_dir = get_preset_path()
if os.path.exists(legacy_dir) and legacy_dir != persistent_dir:
for filename in os.listdir(legacy_dir):
if filename.endswith('.json'):
preset_name = os.path.splitext(filename)[0]
if preset_name not in all_presets: # Don't override higher priority presets
try:
with open(os.path.join(legacy_dir, filename), 'r') as f:
preset_data = json.load(f)
all_presets[preset_name] = {
"data": preset_data,
"source": "LOCAL_FILE"
}
except (json.JSONDecodeError, OSError) as e:
print(f"[TTG] Error reading legacy preset {preset_name}: {e}")
if not all_presets:
self.report({'WARNING'}, "No presets found to export")
return {'CANCELLED'}
# Create export data with metadata
export_data = {
"format_version": "1.0",
"addon_version": bl_info["version"],
"export_timestamp": time.time(),
"presets": all_presets
}
# Write to file
with open(self.filepath, 'w') as f:
json.dump(export_data, f, indent=2)
self.report({'INFO'}, f"✅ Exported {len(all_presets)} presets to {os.path.basename(self.filepath)}")
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, f"Failed to export presets: {str(e)}")
return {'CANCELLED'}
def invoke(self, context, event):
# Set default filename with timestamp
import datetime
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
self.filepath = f"text_texture_presets_backup_{timestamp}.json"
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
class TEXT_TEXTURE_OT_import_presets(Operator):
"""Import presets from a backup file"""
bl_idname = "text_texture.import_presets"
bl_label = "Import Presets"
bl_description = "Import presets from a backup file"
bl_options = {'REGISTER', 'UNDO'}
filepath: StringProperty(
name="File Path",
description="Path to preset backup file",
subtype='FILE_PATH'
)
import_mode: EnumProperty(
name="Import Mode",
description="How to handle existing presets with same names",
items=[
('SKIP', 'Skip Existing', 'Skip presets that already exist'),
('OVERWRITE', 'Overwrite', 'Overwrite existing presets'),
('RENAME', 'Rename New', 'Rename imported presets if they conflict'),
],
default='SKIP'
)
import_to_blend: BoolProperty(
name="Import to Blend File",
description="Import presets to blend file (recommended for portability)",
default=True
)
def execute(self, context):
try:
if not os.path.exists(self.filepath):
self.report({'ERROR'}, "Backup file not found")
return {'CANCELLED'}
props = context.scene.text_texture_props
# Read backup file
with open(self.filepath, 'r') as f:
import_data = json.load(f)
# Validate format
if "presets" not in import_data:
self.report({'ERROR'}, "Invalid backup file format")
return {'CANCELLED'}
imported_presets = import_data["presets"]
if not imported_presets:
self.report({'WARNING'}, "No presets found in backup file")
return {'CANCELLED'}
# Get existing presets to check for conflicts
existing_blend_presets = get_blend_file_presets()
existing_preset_names = set(existing_blend_presets.keys())
# Also check persistent storage
persistent_dir = get_persistent_preset_path()
if os.path.exists(persistent_dir):
for filename in os.listdir(persistent_dir):
if filename.endswith('.json'):
existing_preset_names.add(os.path.splitext(filename)[0])
imported_count = 0
skipped_count = 0
errors = []
for preset_name, preset_info in imported_presets.items():
preset_data = preset_info.get("data", {})
# Handle conflicts
final_name = preset_name
if preset_name in existing_preset_names:
if self.import_mode == 'SKIP':
skipped_count += 1
continue
elif self.import_mode == 'RENAME':
counter = 1
base_name = preset_name
while final_name in existing_preset_names:
final_name = f"{base_name}_imported_{counter}"
counter += 1
# OVERWRITE mode uses original name
try:
# Import to blend file or persistent storage based on setting
if self.import_to_blend:
success = save_blend_file_preset(final_name, preset_data)
if not success:
raise Exception("Failed to save to blend file")
else:
# Save to persistent storage
persistent_file = os.path.join(get_persistent_preset_path(), f"{final_name}.json")
with open(persistent_file, 'w') as f:
json.dump(preset_data, f, indent=2)
imported_count += 1
# Add to UI list if not already present
existing_preset = None
for preset in props.presets:
if preset.name == final_name:
existing_preset = preset
break
if not existing_preset:
new_preset = props.presets.add()
new_preset.name = final_name
new_preset.source = 'BLEND_FILE' if self.import_to_blend else 'PERSISTENT_FILE'
else:
existing_preset.source = 'BLEND_FILE' if self.import_to_blend else 'PERSISTENT_FILE'
except Exception as e:
errors.append(f"{preset_name}: {str(e)}")
# Report results
if imported_count > 0:
storage_location = "blend file" if self.import_to_blend else "persistent storage"
self.report({'INFO'}, f"✅ Imported {imported_count} presets to {storage_location}")
if skipped_count > 0:
self.report({'INFO'}, f"⏭️ Skipped {skipped_count} existing presets")
else:
self.report({'WARNING'}, "No presets were imported")
if errors:
self.report({'WARNING'}, f"Errors importing {len(errors)} presets - check console")
for error in errors[:5]: # Show first 5 errors
print(f"[TTG] Import error: {error}")
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, f"Failed to import presets: {str(e)}")
return {'CANCELLED'}
def invoke(self, context, event):
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
def draw(self, context):
layout = self.layout
layout.prop(self, "import_mode")
layout.prop(self, "import_to_blend")
class TEXT_TEXTURE_OT_show_migration_report(Operator):
"""Show migration report"""
bl_idname = "text_texture.show_migration_report"
bl_label = "Show Migration Report"
bl_description = "Display migration report with details about preset migration"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
try:
import json
import os
persistent_dir = os.path.dirname(get_persistent_preset_path())
migration_report_file = os.path.join(persistent_dir, "migration_report.json")
if os.path.exists(migration_report_file):
with open(migration_report_file, 'r') as f:
migration_data = json.load(f)
migrated_count = migration_data.get('migrated_count', 0)
total_found = migration_data.get('total_found', 0)
errors = migration_data.get('errors', [])
backup_path = migration_data.get('backup_path', 'Unknown')
self.report({'INFO'}, f"Migration Report: {migrated_count}/{total_found} presets migrated successfully")
if errors:
error_summary = f"{len(errors)} errors occurred during migration"
self.report({'WARNING'}, error_summary)
print(f"[TTG] Migration errors:")
for error in errors[:10]: # Show first 10 errors
print(f"[TTG] - {error}")
else:
self.report({'INFO'}, "No migration errors")
if backup_path != 'Unknown':
print(f"[TTG] Migration backup created at: {backup_path}")
else:
self.report({'INFO'}, "No migration report found - migration may not have been needed")
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, f"Error reading migration report: {e}")
return {'CANCELLED'}
class TEXT_TEXTURE_OT_duplicate_overlay(Operator):
"""Duplicate image overlay"""
bl_idname = "text_texture.duplicate_overlay"
bl_label = "Duplicate Overlay"
bl_description = "Duplicate this image overlay"
bl_options = {'REGISTER', 'UNDO'}
overlay_index: IntProperty()
def execute(self, context):
props = context.scene.text_texture_props
if 0 <= self.overlay_index < len(props.image_overlays):
source_overlay = props.image_overlays[self.overlay_index]
# Debug logging
print(f"[DUPLICATE DEBUG] Duplicating overlay '{source_overlay.name}'")
print(f"[DUPLICATE DEBUG] Source properties:")
print(f"[DUPLICATE DEBUG] image_path: '{source_overlay.image_path}'")
print(f"[DUPLICATE DEBUG] enabled: {source_overlay.enabled}")
print(f"[DUPLICATE DEBUG] positioning_mode: '{source_overlay.positioning_mode}'")
print(f"[DUPLICATE DEBUG] scale: {source_overlay.scale}")
print(f"[DUPLICATE DEBUG] z_index: {source_overlay.z_index}")
new_overlay = props.image_overlays.add()
# Copy all properties with explicit verification
new_overlay.name = f"{source_overlay.name} Copy"
new_overlay.image_path = source_overlay.image_path
new_overlay.x_position = source_overlay.x_position
new_overlay.y_position = source_overlay.y_position
new_overlay.scale = source_overlay.scale
new_overlay.rotation = source_overlay.rotation
# Fix z-index assignment for duplicated overlays to ensure proper visibility
# For PREPEND/APPEND overlays, increment z_index to ensure duplicate appears above original
# This prevents the duplicate from being rendered behind the original overlay
if source_overlay.positioning_mode in ['PREPEND', 'APPEND']:
# Increment z_index to ensure proper layering order for duplicated overlay
new_overlay.z_index = min(source_overlay.z_index + 1, 10) # Cap at max z_index value
print(f"[DUPLICATE DEBUG] Fixed z_index for {source_overlay.positioning_mode} overlay: {source_overlay.z_index} -> {new_overlay.z_index}")
else:
# For ABSOLUTE overlays, keep same z_index (different positions so no overlap issue)
new_overlay.z_index = source_overlay.z_index
new_overlay.positioning_mode = source_overlay.positioning_mode
new_overlay.text_spacing = source_overlay.text_spacing
new_overlay.image_spacing = source_overlay.image_spacing
new_overlay.enabled = source_overlay.enabled
# Verify the copy was successful
print(f"[DUPLICATE DEBUG] New overlay properties:")
print(f"[DUPLICATE DEBUG] name: '{new_overlay.name}'")
print(f"[DUPLICATE DEBUG] image_path: '{new_overlay.image_path}'")
print(f"[DUPLICATE DEBUG] enabled: {new_overlay.enabled}")
print(f"[DUPLICATE DEBUG] positioning_mode: '{new_overlay.positioning_mode}'")
print(f"[DUPLICATE DEBUG] scale: {new_overlay.scale}")
print(f"[DUPLICATE DEBUG] z_index: {new_overlay.z_index}")
# Move to position after source
new_index = len(props.image_overlays) - 1
target_index = min(self.overlay_index + 1, new_index)
if new_index != target_index:
props.image_overlays.move(new_index, target_index)
print(f"[DUPLICATE DEBUG] Moved overlay from index {new_index} to {target_index}")
props.active_overlay_index = target_index
# Clear any cached images to force reload
print(f"[DUPLICATE DEBUG] Clearing image cache and forcing preview update...")
# Clear the overlay image cache if it exists
if hasattr(generate_texture_image, 'overlay_image_cache'):
generate_texture_image.overlay_image_cache = {}
print(f"[DUPLICATE DEBUG] Cleared overlay image cache")
# Set the updating flag to prevent recursive updates
props.is_updating = True
try:
# Force immediate preview regeneration
print(f"[DUPLICATE DEBUG] Generating new preview...")
preview_result = generate_preview(context)
if preview_result:
print(f"[DUPLICATE DEBUG] Preview generated successfully")
else:
print(f"[DUPLICATE DEBUG] Preview generation failed")
# Force UI redraw
for area in context.screen.areas:
area.tag_redraw()
except Exception as e:
print(f"[DUPLICATE DEBUG] Error updating preview: {e}")
import traceback
traceback.print_exc()
finally:
props.is_updating = False
print(f"[DUPLICATE DEBUG] Total overlays now: {len(props.image_overlays)}")
print(f"[DUPLICATE DEBUG] Duplication operation completed")
self.report({'INFO'}, f"✅ Duplicated overlay: {source_overlay.name}")
else:
self.report({'ERROR'}, "Invalid overlay index")
return {'CANCELLED'}
return {'FINISHED'}
class TEXT_TEXTURE_OT_delete_overlay(Operator):
"""Delete image overlay"""
bl_idname = "text_texture.delete_overlay"
bl_label = "Delete Overlay"
bl_description = "Delete this image overlay"
bl_options = {'REGISTER', 'UNDO'}
overlay_index: IntProperty()
def execute(self, context):
props = context.scene.text_texture_props
print(f"[DELETE DEBUG] Deleting overlay at index {self.overlay_index}")
if 0 <= self.overlay_index < len(props.image_overlays):
overlay_name = props.image_overlays[self.overlay_index].name
print(f"[DELETE DEBUG] Deleting overlay: '{overlay_name}'")
props.image_overlays.remove(self.overlay_index)
# Update active index
if props.active_overlay_index >= len(props.image_overlays) and props.image_overlays:
props.active_overlay_index = len(props.image_overlays) - 1
elif not props.image_overlays:
props.active_overlay_index = 0
print(f"[DELETE DEBUG] Overlay deleted successfully. Total overlays now: {len(props.image_overlays)}")
# Force preview update after deletion
try:
generate_preview(context)
print(f"[DELETE DEBUG] Preview updated after deletion")
except Exception as e:
print(f"[DELETE DEBUG] Error updating preview: {e}")
# Force UI redraw
for area in context.screen.areas:
area.tag_redraw()
self.report({'INFO'}, f"Deleted overlay: {overlay_name}")
else:
print(f"[DELETE DEBUG] Invalid overlay index: {self.overlay_index}")
self.report({'ERROR'}, "Invalid overlay index")
return {'CANCELLED'}
return {'FINISHED'}
class TEXT_TEXTURE_OT_move_overlay(Operator):
"""Move image overlay up or down in the list"""
bl_idname = "text_texture.move_overlay"
bl_label = "Move Overlay"
bl_description = "Move overlay up or down in the list"
bl_options = {'REGISTER', 'UNDO'}
overlay_index: IntProperty()
direction: EnumProperty(
items=[
('UP', 'Up', 'Move overlay up in list'),
('DOWN', 'Down', 'Move overlay down in list')
]
)
def execute(self, context):
props = context.scene.text_texture_props
print(f"[MOVE DEBUG] Moving overlay at index {self.overlay_index} direction: {self.direction}")
if 0 <= self.overlay_index < len(props.image_overlays):
current_index = self.overlay_index
new_index = current_index
if self.direction == 'UP' and current_index > 0:
new_index = current_index - 1
elif self.direction == 'DOWN' and current_index < len(props.image_overlays) - 1:
new_index = current_index + 1
if new_index != current_index:
overlay_name = props.image_overlays[current_index].name
props.image_overlays.move(current_index, new_index)
props.active_overlay_index = new_index
print(f"[MOVE DEBUG] Successfully moved overlay '{overlay_name}' from {current_index} to {new_index}")
# Force preview update after moving (order affects rendering)
try:
generate_preview(context)
print(f"[MOVE DEBUG] Preview updated after move operation")
except Exception as e:
print(f"[MOVE DEBUG] Error updating preview: {e}")
self.report({'INFO'}, f"Moved overlay {self.direction.lower()}")
else:
print(f"[MOVE DEBUG] No movement needed - overlay is already at edge")
self.report({'INFO'}, f"Overlay is already at {self.direction.lower()} edge")
else:
print(f"[MOVE DEBUG] Invalid overlay index: {self.overlay_index}")
self.report({'ERROR'}, "Invalid overlay index")
return {'CANCELLED'}
return {'FINISHED'}
# ============================================================================
# SHARED UI COMPONENTS
# ============================================================================
def draw_collapsible_header(layout, prop_name, text, icon='TRIA_DOWN'):
"""Draw a collapsible section header with expand/collapse arrow"""
props = bpy.context.scene.text_texture_props
expanded = getattr(props, prop_name)
box = layout.box()
header_row = box.row()
# Draw arrow icon and toggle expanded state
arrow_icon = 'TRIA_DOWN' if expanded else 'TRIA_RIGHT'
header_row.prop(props, prop_name, text="", icon=arrow_icon, emboss=False)
header_row.label(text=text, icon=icon)
return box if expanded else None
def draw_text_input_section(layout, props):
"""Draw the main text input field"""
layout.prop(props, "text", text="")
def draw_font_dropdown_section(layout, props):
"""Draw basic font dropdown for top level"""
row = layout.row(align=True)
row.prop(props, "font_path", text="")
row.operator("text_texture.refresh_fonts", text="", icon='FILE_REFRESH')
def draw_generate_button(layout):
"""Draw the main generate button"""
row = layout.row()
row.scale_y = 1.5
row.operator("text_texture.generate", text="Generate Text Texture", icon='IMAGE_DATA')
def draw_image_overlays_section(layout, props):
"""Draw image overlays controls with collapsible individual overlays"""
# Add overlay button
row = layout.row()
row.operator("text_texture.add_overlay", text="Add Image Overlay", icon='ADD')
# Individual overlay controls
if props.image_overlays:
layout.separator()
for i, overlay in enumerate(props.image_overlays):
# Create collapsible box for each overlay
box = layout.box()
# Header row with controls
header_row = box.row(align=True)
# Enable/Disable checkbox with clear label
header_row.prop(overlay, "enabled", text="", icon='CHECKBOX_HLT' if overlay.enabled else 'CHECKBOX_DEHLT')
# Overlay name
name_row = header_row.row()
name_row.enabled = overlay.enabled # Gray out name if disabled
name_row.prop(overlay, "name", text="")
# Action buttons
# Duplicate button
dup_op = header_row.operator("text_texture.duplicate_overlay", text="", icon='DUPLICATE')
dup_op.overlay_index = i
# Delete button
del_op = header_row.operator("text_texture.delete_overlay", text="", icon='TRASH')
del_op.overlay_index = i
# Move buttons for reordering
if i > 0:
move_up_op = header_row.operator("text_texture.move_overlay", text="", icon='TRIA_UP')
move_up_op.overlay_index = i
move_up_op.direction = 'UP'
if i < len(props.image_overlays) - 1:
move_down_op = header_row.operator("text_texture.move_overlay", text="", icon='TRIA_DOWN')
move_down_op.overlay_index = i
move_down_op.direction = 'DOWN'
# Show details section with enable/disable state
details_col = box.column()
details_col.enabled = overlay.enabled # Gray out all controls if disabled
# Status indicator
if not overlay.enabled:
status_row = details_col.row()
status_row.label(text="⚠️ Overlay Disabled", icon='INFO')
details_col.separator()
# Image file selection
details_col.prop(overlay, "image_path", text="Image File")
# Positioning mode
details_col.prop(overlay, "positioning_mode", text="Position Mode")
if overlay.positioning_mode == 'ABSOLUTE':
# Position controls for absolute mode
pos_row = details_col.row(align=True)
pos_row.prop(overlay, "x_position", text="X Position")
pos_row.prop(overlay, "y_position", text="Y Position")
elif overlay.positioning_mode in ['APPEND', 'PREPEND']:
# Spacing controls for append/prepend modes with percentage labels
details_col.prop(overlay, "text_spacing", text="Text Spacing (%)")
details_col.prop(overlay, "image_spacing", text="Image Spacing (%)")
# Transform controls
trans_row = details_col.row(align=True)
trans_row.prop(overlay, "scale", text="Scale")
trans_row.prop(overlay, "rotation", text="Rotation")
trans_row.prop(overlay, "z_index", text="Z-Level")
def draw_positioning_section(layout, props):
"""Draw positioning and alignment controls"""
# Dimensions moved here from top level
layout.label(text="Dimensions:", icon='SETTINGS')
row = layout.row(align=True)
row.prop(props, "texture_width", text="Width")
row.prop(props, "texture_height", text="Height")
layout.separator()
# Prepend/Append Text Section
layout.label(text="Additional Text:", icon='TEXT')
# Compact layout selector
layout.prop(props, "prepend_append_layout", text="Layout")
# Minimal prepend/append controls
row = layout.row(align=True)
row.prop(props, "prepend_text", text="Prepend")
if props.prepend_text.strip():
row.prop(props, "prepend_margin", text="", slider=True)
row = layout.row(align=True)
row.prop(props, "append_text", text="Append")
if props.append_text.strip():
row.prop(props, "append_margin", text="", slider=True)
layout.separator()
layout.prop(props, "position_mode", text="Mode")
if props.position_mode == 'PRESET':
layout.separator()
layout.label(text="Anchor Point:", icon='ANCHOR_TOP')
anchor_matrix = get_anchor_point_matrix()
for row_idx, row in enumerate(anchor_matrix):
grid_row = layout.row(align=True)
grid_row.scale_y = 1.2
for col_idx, anchor in enumerate(row):
if anchor == props.anchor_point:
op = grid_row.operator("text_texture.set_anchor_point", text="", depress=True)
else:
op = grid_row.operator("text_texture.set_anchor_point", text="")
op.anchor_point = anchor
# Margins
layout.separator()
margin_row = layout.row(align=True)
margin_row.label(text="Margins:")
margin_row.prop(props, "margins_linked", text="", icon='LINKED' if props.margins_linked else 'UNLINKED', toggle=True)
margins_row = layout.row(align=True)
margins_row.prop(props, "margin_x", text="X")
if not props.margins_linked:
margins_row.prop(props, "margin_y", text="Y")
# Offsets
layout.separator()
layout.label(text="Fine-tune (pixels):")
offset_row = layout.row(align=True)
offset_row.prop(props, "offset_x", text="X")
offset_row.prop(props, "offset_y", text="Y")
else: # MANUAL mode
layout.separator()
layout.prop(props, "manual_position_unit", text="Unit")
manual_row = layout.row(align=True)
manual_row.prop(props, "manual_position_x", text="X")
manual_row.prop(props, "manual_position_y", text="Y")
layout.separator()
layout.prop(props, "constrain_to_canvas")
def draw_font_settings_section(layout, props):
"""Draw extended font settings"""
# Font size moved here from top level
layout.prop(props, "font_size")
layout.separator()
layout.label(text="Font Selection:")
# Default font dropdown moved here from top level
row = layout.row(align=True)
row.prop(props, "font_path", text="")
row.operator("text_texture.refresh_fonts", text="", icon='FILE_REFRESH')
layout.separator()
# Custom font controls moved here from top level
layout.prop(props, "use_custom_font")
if props.use_custom_font:
layout.prop(props, "custom_font_path", text="")
layout.separator()
row = layout.row(align=True)
row.prop(props, "padding")
row.prop(props, "text_align")
def draw_colors_section(layout, props):
"""Draw color controls for text and background"""
layout.label(text="Text Color:", icon='COLOR')
layout.prop(props, "text_color", text="")
layout.separator()
layout.label(text="Background Color:", icon='IMAGE_PLANE')
layout.prop(props, "background_transparent")
if not props.background_transparent:
layout.prop(props, "background_color", text="")
def draw_preview_options_section(layout, props):
"""Draw preview options (preview-specific settings only)"""
layout.prop(props, "preview_size", text="Preview Size")
layout.separator()
layout.label(text="Preview Background Display:", icon='SCENE')
layout.prop(props, "preview_bg_type", text="Background")
if props.preview_bg_type == 'color':
layout.prop(props, "preview_bg_color", text="Preview Display Color")
elif props.preview_bg_type == 'gradient':
layout.prop(props, "preview_bg_color", text="Color 1")
layout.prop(props, "preview_bg_color2", text="Color 2")
def draw_presets_section(layout, props):
"""Draw presets save/load controls"""
# Save preset section
save_row = layout.row(align=True)
save_row.prop(props, "new_preset_name", text="", placeholder="Preset Name")
save_row.operator("text_texture.save_preset", text="Save", icon='FILE_NEW')
layout.separator()
# Import/Export and refresh buttons
backup_row = layout.row(align=True)
backup_row.operator("text_texture.export_presets", text="Export Backup", icon='EXPORT')
backup_row.operator("text_texture.import_presets", text="Import Backup", icon='IMPORT')
utility_row = layout.row(align=True)
utility_row.operator("text_texture.refresh_presets", text="Refresh", icon='FILE_REFRESH')
utility_row.operator("text_texture.show_migration_report", text="Migration Report", icon='INFO')
layout.separator()
# Load presets section
if props.presets:
layout.label(text=f"📁 Saved Presets ({len(props.presets)}):", icon='PRESET')
# Separate presets by source
blend_presets = [p for p in props.presets if p.source == 'BLEND_FILE']
persistent_presets = [p for p in props.presets if p.source == 'PERSISTENT_FILE']
local_presets = [p for p in props.presets if p.source == 'LOCAL_FILE']
# Show blend file presets first
if blend_presets:
layout.label(text="🎯 Blend File Presets (travel with file):", icon='FILE_BLEND')
for preset in blend_presets:
row = layout.row()
load_btn = row.operator("text_texture.load_preset", text=f"📋 {preset.name}")
load_btn.preset_name = preset.name
# Quick overwrite button
overwrite_btn = row.operator("text_texture.save_preset", text="", icon='FILE_REFRESH')
overwrite_btn.overwrite = True
# Set the preset name so it can be overwritten
overwrite_btn.preset_name = preset.name
delete_btn = row.operator("text_texture.delete_preset", text="", icon='TRASH')
delete_btn.preset_name = preset.name
# Show persistent presets second
if persistent_presets:
if blend_presets:
layout.separator()
layout.label(text="🛡️ Persistent Presets (survive addon updates):", icon='PREFERENCES')
for preset in persistent_presets:
row = layout.row()
load_btn = row.operator("text_texture.load_preset", text=f"📋 {preset.name}")
load_btn.preset_name = preset.name
# Quick overwrite button
overwrite_btn = row.operator("text_texture.save_preset", text="", icon='FILE_REFRESH')
overwrite_btn.overwrite = True
# Set the preset name so it can be overwritten
overwrite_btn.preset_name = preset.name
delete_btn = row.operator("text_texture.delete_preset", text="", icon='TRASH')
delete_btn.preset_name = preset.name
# Show local presets third
if local_presets:
if blend_presets or persistent_presets:
layout.separator()
layout.label(text="💻 Local Presets (this machine only):", icon='DISK_DRIVE')
for preset in local_presets:
row = layout.row()
load_btn = row.operator("text_texture.load_preset", text=f"📋 {preset.name}")
load_btn.preset_name = preset.name
# Quick overwrite button
overwrite_btn = row.operator("text_texture.save_preset", text="", icon='FILE_REFRESH')
overwrite_btn.overwrite = True
# Set the preset name so it can be overwritten
overwrite_btn.preset_name = preset.name
delete_btn = row.operator("text_texture.delete_preset", text="", icon='TRASH')
delete_btn.preset_name = preset.name
else:
layout.label(text="📂 No presets saved yet", icon='INFO')
layout.label(text="💡 Save your first preset above!", icon='LIGHT_DATA')
layout.separator()
info_box = layout.box()
info_box.label(text=" Presets are now saved in the blend file", icon='INFO')
info_box.label(text="They will travel with your .blend file!")
def draw_shader_section(layout, props):
"""Draw shader generation controls"""
# Create Shader Button (as requested)
row = layout.row()
row.scale_y = 1.5
row.operator("text_texture.generate_shader", text="Create Shader", icon='MATERIAL')
layout.separator()
# Clean minimalist controls
col = layout.column(align=True)
col.prop(props, "shader_type", text="Type")
# Show relevant options based on shader type
if props.shader_type == 'PRINCIPLED':
col.prop(props, "shader_connection", text="Connect")
if props.shader_connection == 'EMISSION':
col.prop(props, "emission_strength", text="Strength")
elif props.shader_type == 'EMISSION':
col.prop(props, "emission_strength", text="Strength")
layout.separator()
# Simple toggles
col = layout.column(align=True)
col.prop(props, "use_alpha", text="Use Alpha Channel")
col.prop(props, "auto_assign_material", text="Auto-Assign to Object")
# Clean status info
if props.auto_assign_material:
layout.separator()
info_row = layout.row()
info_row.scale_y = 0.8
try:
context = bpy.context
if context and context.active_object and context.active_object.type in ['MESH', 'CURVE']:
info_row.label(text=f"{context.active_object.name}", icon='CHECKMARK')
else:
info_row.label(text="Select mesh/curve object", icon='INFO')
except:
info_row.label(text="Select mesh/curve object", icon='INFO')
def draw_shader_options_only(layout, props):
"""Draw only shader options without the Create button - for collapsible section"""
# Clean grid layout for shader options
grid = layout.grid_flow(row_major=True, columns=2, align=True)
grid.prop(props, "shader_type", text="Type")
if props.shader_type == 'PRINCIPLED':
grid.prop(props, "shader_connection", text="Connect")
if props.shader_connection == 'EMISSION':
grid.prop(props, "emission_strength", text="Strength")
elif props.shader_type == 'EMISSION':
grid.prop(props, "emission_strength", text="Strength")
# Simple row for toggles
layout.separator()
row = layout.row(align=True)
row.prop(props, "use_alpha", toggle=True)
row.prop(props, "auto_assign_material", toggle=True)
# Shadow, reflection, and backfacing toggles
row = layout.row(align=True)
row.prop(props, "disable_shadows", toggle=True)
row.prop(props, "disable_reflections", toggle=True)
row.prop(props, "disable_backfacing", toggle=True)
def draw_normal_maps_section(layout, props):
"""Draw normal map generation controls"""
# Main toggle for normal map generation
layout.prop(props, "generate_normal_map", text="Generate Normal Map", icon='TEXTURE')
# Show controls only if normal map generation is enabled
if props.generate_normal_map:
layout.separator()
# Normal map settings in a clean layout
col = layout.column(align=True)
col.prop(props, "normal_map_strength", text="Strength", slider=True)
col.prop(props, "normal_map_blur_radius", text="Blur Radius", slider=True)
layout.separator()
layout.prop(props, "normal_map_invert", text="Invert (Emboss ↔ Deboss)")
# Info box with usage tips
layout.separator()
info_box = layout.box()
info_box.scale_y = 0.8
info_box.label(text="💡 Normal maps add depth and detail", icon='INFO')
info_box.label(text="• Higher strength = more pronounced effect")
info_box.label(text="• Blur smooths sharp edges")
info_box.label(text="• Invert changes raised/recessed effect")
def draw_text_fitting_section(layout, props):
"""Draw text fitting controls"""
# Main toggle for text fitting
layout.prop(props, "enable_text_fitting", text="Enable Text Fitting", icon='FULLSCREEN_ENTER')
# Show controls only if text fitting is enabled
if props.enable_text_fitting:
layout.separator()
# Fitting mode selection
layout.prop(props, "text_fitting_mode", text="Fitting Mode")
layout.separator()
# Margin setting
layout.prop(props, "text_fitting_margin", text="Margin", slider=True)
layout.separator()
# Font size limits
col = layout.column(align=True)
col.label(text="Font Size Limits:", icon='RESTRICT_SELECT_ON')
row = col.row(align=True)
row.prop(props, "min_font_size", text="Min")
row.prop(props, "max_font_size", text="Max")
# Info box with usage tips
layout.separator()
info_box = layout.box()
info_box.scale_y = 0.8
info_box.label(text="💡 Text fitting automatically sizes text", icon='INFO')
if props.text_fitting_mode == 'FIT_WIDTH':
info_box.label(text="• Fits text width to canvas width")
elif props.text_fitting_mode == 'FIT_HEIGHT':
info_box.label(text="• Fits text height to canvas height")
elif props.text_fitting_mode == 'FIT_BOTH':
info_box.label(text="• Fits both width and height (may distort)")
elif props.text_fitting_mode == 'FIT_CONTAIN':
info_box.label(text="• Fits entirely within canvas (preserves ratio)")
info_box.label(text="• Respects min/max font size limits")
def draw_multiline_section(layout, props):
"""Draw multiline text controls"""
# Main toggle for multiline text
layout.prop(props, "enable_multiline", text="Enable Multiline", icon='TEXT')
# Show controls only if multiline is enabled
if props.enable_multiline:
layout.separator()
# Line height and alignment settings
col = layout.column(align=True)
col.prop(props, "line_height", text="Line Height", slider=True)
col.prop(props, "text_alignment", text="Alignment")
layout.separator()
# Auto wrap settings
layout.prop(props, "auto_wrap", text="Auto Wrap")
if props.auto_wrap:
layout.prop(props, "wrap_width", text="Wrap Width (0 = texture width)")
layout.separator()
# Line limits and spacing
col = layout.column(align=True)
col.prop(props, "max_lines", text="Max Lines (0 = unlimited)")
col.prop(props, "line_spacing_pixels", text="Line Spacing (px)")
# Info box with usage tips
layout.separator()
info_box = layout.box()
info_box.scale_y = 0.8
info_box.label(text="💡 Multiline text supports:", icon='INFO')
info_box.label(text="• Use \\n for manual line breaks")
info_box.label(text="• Auto-wrap fits text to width")
info_box.label(text="• Alignment works per line")
info_box.label(text="• Line height controls spacing")
def draw_stroke_section(layout, props):
"""Draw stroke and outline controls"""
# Main toggle for stroke
layout.prop(props, "enable_stroke", text="Enable Stroke", icon='MOD_OUTLINE')
# Show controls only if stroke is enabled
if props.enable_stroke:
layout.separator()
# Stroke width and color
col = layout.column(align=True)
col.prop(props, "stroke_width", text="Width", slider=True)
col.prop(props, "stroke_color", text="Color")
layout.separator()
# Stroke position
layout.prop(props, "stroke_position", text="Position")
# Info box with usage tips
layout.separator()
info_box = layout.box()
info_box.scale_y = 0.8
info_box.label(text="💡 Stroke adds outline to text:", icon='INFO')
if props.stroke_position == 'OUTER':
info_box.label(text="• Outer: Expands text bounds outward")
elif props.stroke_position == 'INNER':
info_box.label(text="• Inner: Shrinks text, stroke inside")
elif props.stroke_position == 'CENTER':
info_box.label(text="• Center: Stroke on text edge")
info_box.label(text="• Width 0 = no stroke")
info_box.label(text="• Works with multiline text")
def draw_shadow_glow_section(layout, props):
"""Draw shadow and glow effects controls"""
# Drop shadow section
layout.prop(props, "enable_shadow", text="Enable Drop Shadow", icon='SHADERFX')
if props.enable_shadow:
layout.separator()
# Shadow offset controls in a row
offset_row = layout.row(align=True)
offset_row.prop(props, "shadow_offset_x", text="X Offset")
offset_row.prop(props, "shadow_offset_y", text="Y Offset")
# Shadow properties
col = layout.column(align=True)
col.prop(props, "shadow_blur", text="Shadow Blur", slider=True)
col.prop(props, "shadow_spread", text="Shadow Spread", slider=True)
col.prop(props, "shadow_color", text="Shadow Color")
layout.separator()
# Glow section
layout.prop(props, "enable_glow", text="Enable Glow", icon='LIGHT_SUN')
if props.enable_glow:
layout.separator()
# Glow properties
col = layout.column(align=True)
col.prop(props, "glow_blur", text="Glow Blur", slider=True)
col.prop(props, "glow_color", text="Glow Color")
# Info box with usage tips
if props.enable_shadow or props.enable_glow:
layout.separator()
info_box = layout.box()
info_box.scale_y = 0.8
info_box.label(text="💡 Shadow & Glow effects:", icon='INFO')
if props.enable_shadow:
info_box.label(text="• Drop shadow: offset + blur for depth")
if props.enable_glow:
info_box.label(text="• Glow: blur without offset for aura effect")
info_box.label(text="• Higher blur = softer effect")
info_box.label(text="• Alpha controls effect intensity")
def draw_blur_section(layout, props):
"""Draw blur effects controls"""
# Main toggle for blur
layout.prop(props, "enable_blur", text="Enable Blur", icon='MOD_SMOOTH')
# Show controls only if blur is enabled
if props.enable_blur:
layout.separator()
# Blur radius setting
layout.prop(props, "blur_radius", text="Blur Radius", slider=True)
# Info box with usage tips
layout.separator()
info_box = layout.box()
info_box.scale_y = 0.8
info_box.label(text="💡 Blur effect softens text edges:", icon='INFO')
info_box.label(text="• Applied to main text content only")
info_box.label(text="• Higher radius = softer/more blurred")
info_box.label(text="• Works with all other effects")
info_box.label(text="• Radius 0 = no blur effect")
# ============================================================================
# MENUS
# ============================================================================
def menu_func_node_editor(self, context):
"""Add menu item to Node Editor"""
self.layout.separator()
self.layout.label(text="Text Texture Generator", icon='TEXT')
self.layout.operator("text_texture.generate", text="Generate Text Texture", icon='IMAGE_DATA')
def menu_func_view3d(self, context):
"""Add menu item to 3D View"""
self.layout.separator()
self.layout.operator("text_texture.generate", text="Generate Text Texture", icon='TEXT')
def menu_func_image(self, context):
"""Add to Image menu"""
self.layout.separator()
self.layout.operator("text_texture.generate", text="Generate Text Texture", icon='TEXT')
# ============================================================================
# UI PANELS
# ============================================================================
class TEXT_TEXTURE_PT_panel(Panel):
"""Main Panel in Shader Editor"""
bl_label = "Text Texture Generator"
bl_idname = "TEXT_TEXTURE_PT_panel"
bl_space_type = 'NODE_EDITOR'
bl_region_type = 'UI'
bl_category = "Tool"
@classmethod
def poll(cls, context):
# DEBUG: Log poll method calls
print(f"[TTG DEBUG] Panel poll called - space_data: {context.space_data}")
print(f"[TTG DEBUG] space_data.type: {context.space_data.type if context.space_data else 'None'}")
if hasattr(context.space_data, 'tree_type'):
print(f"[TTG DEBUG] tree_type: {context.space_data.tree_type}")
else:
print("[TTG DEBUG] No tree_type attribute")
# More permissive poll condition to ensure panel shows up
if context.space_data.type == 'NODE_EDITOR':
if hasattr(context.space_data, 'tree_type'):
result = context.space_data.tree_type == 'ShaderNodeTree'
print(f"[TTG DEBUG] Poll result: {result}")
return result
else:
# Allow panel even without tree_type (sometimes happens)
print("[TTG DEBUG] Poll result: True (no tree_type but NODE_EDITOR)")
return True
print("[TTG DEBUG] Poll result: False (not NODE_EDITOR)")
return False
def draw(self, context):
layout = self.layout
props = context.scene.text_texture_props
# TOP LEVEL - Always Visible Controls
# Text Input
box = layout.box()
box.label(text="Text:", icon='TEXT')
draw_text_input_section(box, props)
# Generate Button
layout.separator(factor=0.5)
draw_generate_button(layout)
# CREATE SHADER BUTTON AT TOP LEVEL - AS REQUESTED
layout.separator(factor=0.5)
create_shader_row = layout.row()
create_shader_row.scale_y = 1.5
create_shader_row.operator("text_texture.generate_shader", text="Create Shader", icon='MATERIAL')
# Preview Button at top level
layout.separator(factor=0.5)
preview_row = layout.row()
preview_row.scale_y = 1.5
preview_row.operator("text_texture.view_preview_zoom", text="Open Preview Window", icon='WINDOW')
layout.separator(factor=1.2)
# COLLAPSIBLE SECTIONS
# Multiline Section (collapsed by default)
multiline_section = draw_collapsible_header(layout, "expand_multiline", "Multiline Text", 'TEXT')
if multiline_section:
draw_multiline_section(multiline_section, props)
# Shader Section (collapsed by default) - ONLY OPTIONS, NOT THE BUTTON
shader_section = draw_collapsible_header(layout, "expand_shader", "Shader Options", 'SETTINGS')
if shader_section:
draw_shader_options_only(shader_section, props)
# Image Overlays Section (collapsed by default)
overlay_section = draw_collapsible_header(layout, "expand_image_overlays", "Image Overlays", 'IMAGE_DATA')
if overlay_section:
draw_image_overlays_section(overlay_section, props)
# Positioning & Layout Section (collapsed by default)
position_section = draw_collapsible_header(layout, "expand_positioning", "Positioning & Layout", 'ANCHOR_CENTER')
if position_section:
draw_positioning_section(position_section, props)
# Font Settings Section (collapsed by default)
font_section = draw_collapsible_header(layout, "expand_font_settings", "Font Settings", 'FONT_DATA')
if font_section:
draw_font_settings_section(font_section, props)
# Text Fitting Section (collapsed by default)
text_fitting_section = draw_collapsible_header(layout, "expand_text_fitting", "Text Fitting", 'FULLSCREEN_ENTER')
if text_fitting_section:
draw_text_fitting_section(text_fitting_section, props)
# Stroke Section (collapsed by default)
stroke_section = draw_collapsible_header(layout, "expand_stroke", "Stroke & Outlines", 'MOD_OUTLINE')
if stroke_section:
draw_stroke_section(stroke_section, props)
# Shadow & Glow Section (collapsed by default)
shadow_glow_section = draw_collapsible_header(layout, "expand_shadow_glow", "Shadow & Glow Effects", 'SHADERFX')
if shadow_glow_section:
draw_shadow_glow_section(shadow_glow_section, props)
# Blur Section (collapsed by default)
blur_section = draw_collapsible_header(layout, "expand_blur", "Blur Effects", 'MOD_SMOOTH')
if blur_section:
draw_blur_section(blur_section, props)
# Normal Maps Section (collapsed by default)
normal_maps_section = draw_collapsible_header(layout, "expand_normal_maps", "Normal Maps", 'TEXTURE')
if normal_maps_section:
draw_normal_maps_section(normal_maps_section, props)
# Colors Section (collapsed by default)
colors_section = draw_collapsible_header(layout, "expand_colors", "Colors", 'COLOR')
if colors_section:
draw_colors_section(colors_section, props)
# Presets Section (collapsed by default)
presets_section = draw_collapsible_header(layout, "expand_presets", "Presets", 'PRESET')
if presets_section:
draw_presets_section(presets_section, props)
class TEXT_TEXTURE_PT_panel_3d(Panel):
"""3D View Panel - Access from sidebar (N key)"""
bl_label = "Text Texture Generator"
bl_idname = "TEXT_TEXTURE_PT_panel_3d"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = "Tool"
bl_options = {'DEFAULT_CLOSED'}
def draw(self, context):
layout = self.layout
props = context.scene.text_texture_props
# TOP LEVEL - Always Visible Controls
# Text Input
box = layout.box()
box.label(text="Text:", icon='TEXT')
draw_text_input_section(box, props)
# Generate Button
layout.separator(factor=0.5)
draw_generate_button(layout)
# CREATE SHADER BUTTON AT TOP LEVEL - AS REQUESTED
layout.separator(factor=0.5)
create_shader_row = layout.row()
create_shader_row.scale_y = 1.5
create_shader_row.operator("text_texture.generate_shader", text="Create Shader", icon='MATERIAL')
# Preview Button at top level
layout.separator(factor=0.5)
preview_row = layout.row()
preview_row.scale_y = 1.5
preview_row.operator("text_texture.view_preview_zoom", text="Open Preview Window", icon='WINDOW')
layout.separator(factor=1.2)
# COLLAPSIBLE SECTIONS
# Multiline Section (collapsed by default)
multiline_section = draw_collapsible_header(layout, "expand_multiline", "Multiline Text", 'TEXT')
if multiline_section:
draw_multiline_section(multiline_section, props)
# Shader Section (collapsed by default) - ONLY OPTIONS, NOT THE BUTTON
shader_section = draw_collapsible_header(layout, "expand_shader", "Shader Options", 'SETTINGS')
if shader_section:
draw_shader_options_only(shader_section, props)
# Image Overlays Section (collapsed by default)
overlay_section = draw_collapsible_header(layout, "expand_image_overlays", "Image Overlays", 'IMAGE_DATA')
if overlay_section:
draw_image_overlays_section(overlay_section, props)
# Positioning & Layout Section (collapsed by default)
position_section = draw_collapsible_header(layout, "expand_positioning", "Positioning & Layout", 'ANCHOR_CENTER')
if position_section:
draw_positioning_section(position_section, props)
# Font Settings Section (collapsed by default)
font_section = draw_collapsible_header(layout, "expand_font_settings", "Font Settings", 'FONT_DATA')
if font_section:
draw_font_settings_section(font_section, props)
# Text Fitting Section (collapsed by default)
text_fitting_section = draw_collapsible_header(layout, "expand_text_fitting", "Text Fitting", 'FULLSCREEN_ENTER')
if text_fitting_section:
draw_text_fitting_section(text_fitting_section, props)
# Stroke Section (collapsed by default)
stroke_section = draw_collapsible_header(layout, "expand_stroke", "Stroke & Outlines", 'MOD_OUTLINE')
if stroke_section:
draw_stroke_section(stroke_section, props)
# Shadow & Glow Section (collapsed by default)
shadow_glow_section = draw_collapsible_header(layout, "expand_shadow_glow", "Shadow & Glow Effects", 'SHADERFX')
if shadow_glow_section:
draw_shadow_glow_section(shadow_glow_section, props)
# Tiling Section removed - feature was removed from addon
# Blur Section (collapsed by default)
blur_section = draw_collapsible_header(layout, "expand_blur", "Blur Effects", 'MOD_SMOOTH')
if blur_section:
draw_blur_section(blur_section, props)
# Batch Processing Section removed - feature was removed from addon
# Normal Maps Section (collapsed by default)
normal_maps_section = draw_collapsible_header(layout, "expand_normal_maps", "Normal Maps", 'TEXTURE')
if normal_maps_section:
draw_normal_maps_section(normal_maps_section, props)
# Colors Section (collapsed by default)
colors_section = draw_collapsible_header(layout, "expand_colors", "Colors", 'COLOR')
if colors_section:
draw_colors_section(colors_section, props)
# Presets Section (collapsed by default)
presets_section = draw_collapsible_header(layout, "expand_presets", "Presets", 'PRESET')
if presets_section:
draw_presets_section(presets_section, props)
class TEXT_TEXTURE_PT_panel_properties(Panel):
"""Properties Panel"""
bl_label = "Text Texture Generator"
bl_idname = "TEXT_TEXTURE_PT_panel_properties"
bl_space_type = 'PROPERTIES'
bl_region_type = 'WINDOW'
bl_context = "material"
bl_category = "Tool"
def draw(self, context):
layout = self.layout
props = context.scene.text_texture_props
# TOP LEVEL - Always Visible Controls
# Text Input
box = layout.box()
box.label(text="Text:", icon='TEXT')
draw_text_input_section(box, props)
# Generate Button
layout.separator(factor=0.5)
draw_generate_button(layout)
# Preview Button at top level
layout.separator(factor=0.5)
preview_row = layout.row()
preview_row.scale_y = 1.5
preview_row.operator("text_texture.view_preview_zoom", text="Open Preview Window", icon='WINDOW')
layout.separator(factor=1.2)
# COLLAPSIBLE SECTIONS
# Multiline Section (collapsed by default)
multiline_section = draw_collapsible_header(layout, "expand_multiline", "Multiline Text", 'TEXT')
if multiline_section:
draw_multiline_section(multiline_section, props)
# Image Overlays Section (collapsed by default)
overlay_section = draw_collapsible_header(layout, "expand_image_overlays", "Image Overlays", 'IMAGE_DATA')
if overlay_section:
draw_image_overlays_section(overlay_section, props)
# Positioning & Layout Section (collapsed by default)
position_section = draw_collapsible_header(layout, "expand_positioning", "Positioning & Layout", 'ANCHOR_CENTER')
if position_section:
draw_positioning_section(position_section, props)
# Font Settings Section (collapsed by default)
font_section = draw_collapsible_header(layout, "expand_font_settings", "Font Settings", 'FONT_DATA')
if font_section:
draw_font_settings_section(font_section, props)
# Text Fitting Section (collapsed by default)
text_fitting_section = draw_collapsible_header(layout, "expand_text_fitting", "Text Fitting", 'FULLSCREEN_ENTER')
if text_fitting_section:
draw_text_fitting_section(text_fitting_section, props)
# Stroke Section (collapsed by default)
stroke_section = draw_collapsible_header(layout, "expand_stroke", "Stroke & Outlines", 'MOD_OUTLINE')
if stroke_section:
draw_stroke_section(stroke_section, props)
# Shadow & Glow Section (collapsed by default)
shadow_glow_section = draw_collapsible_header(layout, "expand_shadow_glow", "Shadow & Glow Effects", 'SHADERFX')
if shadow_glow_section:
draw_shadow_glow_section(shadow_glow_section, props)
# Tiling Section removed - feature was removed from addon
# Blur Section (collapsed by default)
blur_section = draw_collapsible_header(layout, "expand_blur", "Blur Effects", 'MOD_SMOOTH')
if blur_section:
draw_blur_section(blur_section, props)
# Batch Processing Section removed - feature was removed from addon
# Normal Maps Section (collapsed by default)
normal_maps_section = draw_collapsible_header(layout, "expand_normal_maps", "Normal Maps", 'TEXTURE')
if normal_maps_section:
draw_normal_maps_section(normal_maps_section, props)
# Colors Section (collapsed by default)
colors_section = draw_collapsible_header(layout, "expand_colors", "Colors", 'COLOR')
if colors_section:
draw_colors_section(colors_section, props)
# Presets Section (collapsed by default)
presets_section = draw_collapsible_header(layout, "expand_presets", "Presets", 'PRESET')
if presets_section:
draw_presets_section(presets_section, props)
# ============================================================================
# REGISTRATION
# ============================================================================
classes = (
TEXT_TEXTURE_ImageOverlay,
TEXT_TEXTURE_Preset,
TEXT_TEXTURE_Properties,
TEXT_TEXTURE_OT_generate,
TEXT_TEXTURE_OT_generate_shader,
TEXT_TEXTURE_OT_refresh_preview,
TEXT_TEXTURE_OT_view_preview_zoom,
TEXT_TEXTURE_OT_add_overlay,
TEXT_TEXTURE_OT_remove_overlay,
TEXT_TEXTURE_OT_duplicate_overlay,
TEXT_TEXTURE_OT_delete_overlay,
TEXT_TEXTURE_OT_move_overlay,
TEXT_TEXTURE_OT_open_panel,
TEXT_TEXTURE_OT_save_preset,
TEXT_TEXTURE_OT_save_preset_enter,
TEXT_TEXTURE_OT_set_anchor_point,
TEXT_TEXTURE_OT_sync_margins,
TEXT_TEXTURE_OT_load_preset,
TEXT_TEXTURE_OT_delete_preset,
TEXT_TEXTURE_OT_refresh_fonts,
TEXT_TEXTURE_OT_refresh_presets,
TEXT_TEXTURE_OT_export_presets,
TEXT_TEXTURE_OT_import_presets,
TEXT_TEXTURE_OT_show_migration_report,
TEXT_TEXTURE_PT_panel,
TEXT_TEXTURE_PT_panel_3d,
TEXT_TEXTURE_PT_panel_properties,
)
def register():
print("[TTG DEBUG] ========== ADDON REGISTRATION START ==========")
for cls in classes:
print(f"[TTG DEBUG] Registering class: {cls.__name__}")
try:
bpy.utils.register_class(cls)
print(f"[TTG DEBUG] Successfully registered: {cls.__name__}")
except Exception as e:
print(f"[TTG DEBUG] FAILED to register {cls.__name__}: {e}")
print("[TTG DEBUG] Registering scene properties...")
bpy.types.Scene.text_texture_props = PointerProperty(type=TEXT_TEXTURE_Properties)
print("[TTG DEBUG] Scene properties registered successfully")
# CRITICAL FIX: Force UI refresh after registration
print("[TTG DEBUG] Forcing UI refresh...")
try:
# Update all areas to ensure panels are properly displayed
for window in bpy.context.window_manager.windows:
for area in window.screen.areas:
area.tag_redraw()
print("[TTG DEBUG] UI refresh completed successfully")
except Exception as e:
print(f"[TTG DEBUG] UI refresh failed: {e}")
# Add to menus
bpy.types.NODE_MT_add.append(menu_func_node_editor)
bpy.types.VIEW3D_MT_add.append(menu_func_view3d)
bpy.types.VIEW3D_MT_mesh_add.append(menu_func_view3d)
bpy.types.NODE_MT_node.append(menu_func_node_editor)
# Add to Image menu if available
if hasattr(bpy.types, 'IMAGE_MT_image'):
bpy.types.IMAGE_MT_image.append(menu_func_image)
# Initialize presets using reliable synchronization - no timer chains needed
print("[TTG DEBUG] Registration completed - initializing presets with reliable system")
# Use the new reliable initialization system
try:
# Perform migration check first
migration_result = migrate_presets_if_needed()
if migration_result['migrated_count'] > 0:
print(f"[TTG DEBUG] Migrated {migration_result['migrated_count']} presets during registration")
# Initialize presets using the simplified system
initialize_presets()
print("[TTG DEBUG] Preset initialization completed successfully")
except Exception as e:
print(f"[TTG DEBUG] Error during preset initialization: {e}")
# Don't fail registration if preset initialization fails
import traceback
traceback.print_exc()
# Add handler to reload presets when blend file changes
@bpy.app.handlers.persistent
def on_file_load(dummy):
"""Reload presets when a blend file is loaded"""
try:
print("[TTG] Reloading presets after file load...")
# Use reliable synchronous refresh instead of timer
success = refresh_presets_sync()
if success:
print("[TTG] Presets reloaded successfully after file load")
else:
print("[TTG] Warning: Preset reload failed after file load")
except Exception as e:
print(f"[TTG] Error reloading presets: {e}")
import traceback
traceback.print_exc()
# Register the file load handler
if on_file_load not in bpy.app.handlers.load_post:
bpy.app.handlers.load_post.append(on_file_load)
print("[TTG DEBUG] ========== ADDON REGISTRATION END ==========")
def unregister():
# Clean up preview
try:
if bpy.context.scene:
props = bpy.context.scene.text_texture_props
if props and props.preview_image:
bpy.data.images.remove(props.preview_image)
except Exception:
pass
if "TextTexturePreview" in bpy.data.images:
bpy.data.images.remove(bpy.data.images["TextTexturePreview"])
# Remove file load handler
try:
for handler in bpy.app.handlers.load_post[:]:
if handler.__name__ == 'on_file_load' and 'TTG' in str(handler):
bpy.app.handlers.load_post.remove(handler)
except Exception as e:
print(f"Error removing file load handler: {e}")
# Remove from menus
bpy.types.NODE_MT_add.remove(menu_func_node_editor)
bpy.types.VIEW3D_MT_add.remove(menu_func_view3d)
bpy.types.VIEW3D_MT_mesh_add.remove(menu_func_view3d)
bpy.types.NODE_MT_node.remove(menu_func_node_editor)
if hasattr(bpy.types, 'IMAGE_MT_image'):
bpy.types.IMAGE_MT_image.remove(menu_func_image)
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
del bpy.types.Scene.text_texture_props
if __name__ == "__main__":
register()