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 ", "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 during addon registration - prevents race conditions""" 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 # Check if migration is needed first migration_result = migrate_presets_if_needed() # Show migration notification if there were significant changes if migration_result.get('migrated_count', 0) > 0 or migration_result.get('errors'): def show_migration_notification(): # Use a timer to show notification after UI is ready try: import bpy if hasattr(bpy.context, 'window_manager'): migrated_count = migration_result.get('migrated_count', 0) errors = migration_result.get('errors', []) if migrated_count > 0: print(f"[TTG] 🎉 MIGRATION SUCCESS: {migrated_count} presets migrated to persistent storage!") print(f"[TTG] 🛡️ Your presets are now protected from addon updates.") if errors: print(f"[TTG] ⚠️ WARNING: {len(errors)} presets had migration errors.") print(f"[TTG] Use 'Migration Report' button in Presets section for details.") except Exception as e: print(f"[TTG] Could not show migration notification: {e}") return None # Stop timer # Schedule notification bpy.app.timers.register(show_migration_notification, first_interval=2.0) # Get existing preset names for optimization existing_presets = {p.name: p for p in props.presets} # Three-tier loading priority: Blend File > Persistent File > Legacy File # 1. Load presets from blend file first (highest priority) blend_presets = get_blend_file_presets() updated_presets = set() for preset_name in blend_presets.keys(): updated_presets.add(preset_name) if preset_name in existing_presets: # Update existing preset source if needed if existing_presets[preset_name].source != 'BLEND_FILE': existing_presets[preset_name].source = 'BLEND_FILE' else: # Add new preset preset = props.presets.add() preset.name = preset_name preset.source = 'BLEND_FILE' # 2. Load from persistent storage (survives addon updates) persistent_dir = get_persistent_preset_path() if os.path.exists(persistent_dir): try: for filename in os.listdir(persistent_dir): if filename.endswith('.json'): preset_name = os.path.splitext(filename)[0] # Only add if not already present from blend file if preset_name not in blend_presets: updated_presets.add(preset_name) if preset_name in existing_presets: # Update existing preset source if needed if existing_presets[preset_name].source != 'PERSISTENT_FILE': existing_presets[preset_name].source = 'PERSISTENT_FILE' else: # Add new preset preset = props.presets.add() preset.name = preset_name preset.source = 'PERSISTENT_FILE' print(f"[TTG] Loaded persistent presets from {persistent_dir}") except OSError as e: print(f"[TTG] Error loading persistent presets during initialization: {e}") # 3. Load legacy local presets as fallback (lowest priority) legacy_dir = get_preset_path() if os.path.exists(legacy_dir) and legacy_dir != persistent_dir: try: for filename in os.listdir(legacy_dir): if filename.endswith('.json'): preset_name = os.path.splitext(filename)[0] # Only add if not already present from higher priority sources if preset_name not in blend_presets and preset_name not in updated_presets: updated_presets.add(preset_name) if preset_name in existing_presets: # Update existing preset source if needed if existing_presets[preset_name].source != 'LOCAL_FILE': existing_presets[preset_name].source = 'LOCAL_FILE' else: # Add new preset preset = props.presets.add() preset.name = preset_name preset.source = 'LOCAL_FILE' print(f"[TTG] Loaded legacy presets from {legacy_dir}") except OSError as e: print(f"[TTG] Error loading legacy presets during initialization: {e}") # Remove presets that no longer exist for i in range(len(props.presets) - 1, -1, -1): if props.presets[i].name not in updated_presets: props.presets.remove(i) # Count presets by source for 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']) 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] Error in initialize_presets: {e}") # ============================================================================ # 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 # 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: # Reload presets initialize_presets() props = context.scene.text_texture_props blend_presets = get_blend_file_presets() total_presets = len(props.presets) blend_count = len(blend_presets) local_count = total_presets - blend_count self.report({'INFO'}, f"✅ Refreshed {total_presets} presets ({blend_count} from blend file, {local_count} local)") # 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)}") 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 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 after registration to prevent race conditions # Use a timer to ensure the scene is ready def delayed_preset_init(): initialize_presets() return None # Stop timer print("[TTG DEBUG] Registration completed - scheduling preset initialization") bpy.app.timers.register(delayed_preset_init, first_interval=0.1) # 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...") def delayed_reload(): initialize_presets() return None # Stop timer bpy.app.timers.register(delayed_reload, first_interval=0.1) except Exception as e: print(f"[TTG] Error reloading presets: {e}") # 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()