From 80a71c1dc19afa40a8044c9e8034a8ea4439192a Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Thu, 11 Sep 2025 12:12:58 +0300 Subject: [PATCH] refactor --- Archive.zip | Bin 56919 -> 114667 bytes __init__.py | 6885 +---------------- __pycache__/__init__.cpython-311.pyc | Bin 0 -> 10201 bytes core/__init__.py | 64 + core/__pycache__/__init__.cpython-311.pyc | Bin 0 -> 1427 bytes .../generation_engine.cpython-311.pyc | Bin 0 -> 4200 bytes core/__pycache__/normal_maps.cpython-311.pyc | Bin 0 -> 4623 bytes core/__pycache__/text_fitting.cpython-311.pyc | Bin 0 -> 14698 bytes .../text_processor.cpython-311.pyc | Bin 0 -> 15826 bytes core/generation_engine.py | 100 + core/normal_maps.py | 102 + core/text_fitting.py | 313 + core/text_processor.py | 342 + operators/__init__.py | 70 + .../__pycache__/__init__.cpython-311.pyc | Bin 0 -> 1758 bytes operators/generation_ops.py | 595 ++ operators/preset_ops.py | 893 +++ operators/utility_ops.py | 607 ++ presets/__init__.py | 12 + presets/migration.py | 125 + presets/storage_system.py | 462 ++ properties/__init__.py | 18 + .../__pycache__/__init__.cpython-311.pyc | Bin 0 -> 524 bytes .../property_groups.cpython-311.pyc | Bin 0 -> 26347 bytes properties/property_groups.py | 1056 +++ ui/__init__.py | 30 + ui/__pycache__/__init__.cpython-311.pyc | Bin 0 -> 337 bytes ui/__pycache__/panels.cpython-311.pyc | Bin 0 -> 43879 bytes ui/panels.py | 940 +++ ui/preview.py | 153 + utils/__init__.py | 13 + utils/constants.py | 13 + utils/system.py | 84 + 33 files changed, 6051 insertions(+), 6826 deletions(-) create mode 100644 __pycache__/__init__.cpython-311.pyc create mode 100644 core/__init__.py create mode 100644 core/__pycache__/__init__.cpython-311.pyc create mode 100644 core/__pycache__/generation_engine.cpython-311.pyc create mode 100644 core/__pycache__/normal_maps.cpython-311.pyc create mode 100644 core/__pycache__/text_fitting.cpython-311.pyc create mode 100644 core/__pycache__/text_processor.cpython-311.pyc create mode 100644 core/generation_engine.py create mode 100644 core/normal_maps.py create mode 100644 core/text_fitting.py create mode 100644 core/text_processor.py create mode 100644 operators/__init__.py create mode 100644 operators/__pycache__/__init__.cpython-311.pyc create mode 100644 operators/generation_ops.py create mode 100644 operators/preset_ops.py create mode 100644 operators/utility_ops.py create mode 100644 presets/__init__.py create mode 100644 presets/migration.py create mode 100644 presets/storage_system.py create mode 100644 properties/__init__.py create mode 100644 properties/__pycache__/__init__.cpython-311.pyc create mode 100644 properties/__pycache__/property_groups.cpython-311.pyc create mode 100644 properties/property_groups.py create mode 100644 ui/__init__.py create mode 100644 ui/__pycache__/__init__.cpython-311.pyc create mode 100644 ui/__pycache__/panels.cpython-311.pyc create mode 100644 ui/panels.py create mode 100644 ui/preview.py create mode 100644 utils/__init__.py create mode 100644 utils/constants.py create mode 100644 utils/system.py diff --git a/Archive.zip b/Archive.zip index d675ce0bd9bcd92d991794d1e10777bd9ef08238..212f963e808e4e5bbe10483cd6daba8cba73271a 100644 GIT binary patch literal 114667 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

}POID)WdvW_zW0WdGH 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 literal 56919 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 diff --git a/__init__.py b/__init__.py index 9e3a403..cea4842 100644 --- a/__init__.py +++ b/__init__.py @@ -10,6832 +10,65 @@ 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 with simplified, reliable synchronization""" - try: - import bpy - if not hasattr(bpy.context, 'scene') or not bpy.context.scene: - return - - props = bpy.context.scene.text_texture_props - if not props: - return - - print(f"[TTG] Starting preset initialization...") - - # Clear existing presets to avoid duplicates - props.presets.clear() - - # Collect all presets from all sources - all_presets = {} - - # 1. Load from blend file (highest priority - travels with file) - try: - blend_presets = get_blend_file_presets() - for name, data in blend_presets.items(): - all_presets[name] = { - 'data': data, - 'source': 'BLEND_FILE' - } - print(f"[TTG] Found {len(blend_presets)} presets in blend file") - except Exception as e: - print(f"[TTG] Error loading blend file presets: {e}") - - # 2. Load from persistent storage (survives addon updates) - try: - persistent_dir = get_persistent_preset_path() - if os.path.exists(persistent_dir): - for filename in os.listdir(persistent_dir): - if filename.endswith('.json'): - preset_name = os.path.splitext(filename)[0] - # Only add if not already in blend file (blend file has priority) - if preset_name not in all_presets: - try: - with open(os.path.join(persistent_dir, filename), 'r') as f: - preset_data = json.load(f) - all_presets[preset_name] = { - 'data': preset_data, - 'source': 'PERSISTENT_FILE' - } - except (json.JSONDecodeError, OSError) as e: - print(f"[TTG] Error reading persistent preset {preset_name}: {e}") - print(f"[TTG] Found {len([p for p in all_presets.values() if p['source'] == 'PERSISTENT_FILE'])} persistent presets") - except Exception as e: - print(f"[TTG] Error loading persistent presets: {e}") - - # 3. Load from legacy storage (backward compatibility) - try: - legacy_dir = get_preset_path() - if os.path.exists(legacy_dir) and legacy_dir != get_persistent_preset_path(): - for filename in os.listdir(legacy_dir): - if filename.endswith('.json'): - preset_name = os.path.splitext(filename)[0] - # Only add if not already found in higher priority sources - if preset_name not in all_presets: - try: - with open(os.path.join(legacy_dir, filename), 'r') as f: - preset_data = json.load(f) - all_presets[preset_name] = { - 'data': preset_data, - 'source': 'LOCAL_FILE' - } - except (json.JSONDecodeError, OSError) as e: - print(f"[TTG] Error reading legacy preset {preset_name}: {e}") - print(f"[TTG] Found {len([p for p in all_presets.values() if p['source'] == 'LOCAL_FILE'])} legacy presets") - except Exception as e: - print(f"[TTG] Error loading legacy presets: {e}") - - # Add all presets to UI list - for preset_name, preset_info in all_presets.items(): - preset = props.presets.add() - preset.name = preset_name - preset.source = preset_info['source'] - - # Report results - blend_count = len([p for p in props.presets if p.source == 'BLEND_FILE']) - persistent_count = len([p for p in props.presets if p.source == 'PERSISTENT_FILE']) - legacy_count = len([p for p in props.presets if p.source == 'LOCAL_FILE']) - - print(f"[TTG] ✅ Initialized {len(props.presets)} presets ({blend_count} blend file, {persistent_count} persistent, {legacy_count} legacy)") - - except Exception as e: - print(f"[TTG] Critical error in initialize_presets: {e}") - import traceback - traceback.print_exc() -def refresh_presets(): - """Refresh the preset list by rescanning files (legacy function)""" - import bpy - refresh_presets_sync() - - # Force UI update - try: - if bpy.context.area: - bpy.context.area.tag_redraw() - except: - pass - -def refresh_presets_sync(): - """Centralized, synchronous preset refresh - guarantees completion""" - try: - import bpy - - print("[TTG] 🔄 Synchronizing presets...") - - # Ensure we have valid context - if not hasattr(bpy.context, 'scene') or not bpy.context.scene: - print("[TTG] ⚠️ No valid scene context for preset refresh") - return False - - props = bpy.context.scene.text_texture_props - if not props: - print("[TTG] ⚠️ No text_texture_props found") - return False - - # Run the simplified initialization - initialize_presets() - - # Validate that presets are actually loaded - preset_count = len(props.presets) - if preset_count == 0: - print("[TTG] ⚠️ Warning: No presets found after refresh") - else: - print(f"[TTG] ✅ Preset sync complete - {preset_count} presets available") - - return True - - except Exception as e: - print(f"[TTG] ❌ Error in refresh_presets_sync: {e}") - import traceback - traceback.print_exc() - return False - -def ensure_presets_available(): - """Defensive programming - ensure presets are loaded before UI operations""" - try: - import bpy - - if not hasattr(bpy.context, 'scene') or not bpy.context.scene: - return False - - props = bpy.context.scene.text_texture_props - if not props: - return False - - # If no presets loaded, try to load them - if len(props.presets) == 0: - print("[TTG] 🛡️ No presets available - attempting to load...") - return refresh_presets_sync() - - return True - - except Exception as e: - print(f"[TTG] Error in ensure_presets_available: {e}") - return False - - -# ============================================================================ -# PREVIEW GENERATION FUNCTIONS -# ============================================================================ - -def test_font_mixed_case_support(font, test_size=20): - """Test if a font supports mixed-case rendering properly""" - try: - from PIL import Image, ImageDraw - - # Test with mixed-case sample text - test_text = "AaBbCc123" - test_img = Image.new('RGB', (100, 50), (255, 255, 255)) - test_draw = ImageDraw.Draw(test_img) - - # DIAGNOSTIC: Add detailed logging for custom font debugging - font_info = "UNKNOWN" - if hasattr(font, 'path'): - font_info = font.path - elif hasattr(font, 'getname'): - try: - font_info = font.getname() - except: - pass - print(f"CUSTOM FONT DEBUG: Testing mixed-case support for font: {font_info}") - - # Try to get bounding box - this will fail if font doesn't support the characters - try: - bbox = test_draw.textbbox((0, 0), test_text, font=font) - print(f"CUSTOM FONT DEBUG: Mixed-case text bbox successful: {bbox}") - except Exception as bbox_error: - print(f"CUSTOM FONT DEBUG: Mixed-case text bbox FAILED: {bbox_error}") - import traceback - print(f"CUSTOM FONT DEBUG: Full exception trace: {traceback.format_exc()}") - return False - - # Test actual rendering - draw the text and check if it renders properly - try: - test_draw.text((5, 5), test_text, fill=(0, 0, 0), font=font) - print(f"CUSTOM FONT DEBUG: Mixed-case text rendering successful") - except Exception as render_error: - print(f"CUSTOM FONT DEBUG: Mixed-case text rendering FAILED: {render_error}") - import traceback - print(f"CUSTOM FONT DEBUG: Full exception trace: {traceback.format_exc()}") - return False - - # Additional validation: check if lowercase letters render differently from uppercase - try: - upper_bbox = test_draw.textbbox((0, 0), "ABC", font=font) - lower_bbox = test_draw.textbbox((0, 0), "abc", font=font) - print(f"CUSTOM FONT DEBUG: Upper bbox: {upper_bbox}, Lower bbox: {lower_bbox}") - - # RELAXED VALIDATION: Allow fonts with identical bounding boxes - # Many valid custom fonts may have identical bounding boxes but still render mixed case properly - if upper_bbox == lower_bbox: - print(f"CUSTOM FONT DEBUG: RELAXED VALIDATION - Identical bounding boxes detected") - print(f"CUSTOM FONT DEBUG: Instead of rejecting, testing actual mixed-case rendering...") - - # Additional test: try rendering actual mixed case text to verify it works - try: - mixed_test = "AaBbCcDd" - mixed_bbox = test_draw.textbbox((0, 0), mixed_test, font=font) - test_draw.text((10, 10), mixed_test, fill=(0, 0, 0), font=font) - print(f"CUSTOM FONT DEBUG: Mixed-case render test '{mixed_test}' PASSED: {mixed_bbox}") - print(f"CUSTOM FONT DEBUG: Font ACCEPTED despite identical bounding boxes (relaxed validation)") - return True - except Exception as mixed_render_error: - print(f"CUSTOM FONT DEBUG: Mixed-case render test FAILED: {mixed_render_error}") - print(f"CUSTOM FONT DEBUG: Font REJECTED due to actual rendering failure") - import traceback - print(f"CUSTOM FONT DEBUG: Full exception trace: {traceback.format_exc()}") - return False - - print(f"CUSTOM FONT DEBUG: Font mixed-case test PASSED - supports both cases properly") - return True - except Exception as bbox_compare_error: - print(f"CUSTOM FONT DEBUG: Bounding box comparison FAILED: {bbox_compare_error}") - return False - - except Exception as e: - print(f"CUSTOM FONT DEBUG: Font mixed-case test FAILED with exception: {e}") - import traceback - traceback.print_exc() - return False - -def get_validated_font(font_path, font_size): - """Load and validate a font for mixed-case support""" - try: - from PIL import ImageFont - - # DIAGNOSTIC: Enhanced logging for custom font debugging - print(f"CUSTOM FONT DEBUG: get_validated_font() called with:") - print(f"CUSTOM FONT DEBUG: font_path: '{font_path}'") - print(f"CUSTOM FONT DEBUG: font_size: {font_size}") - print(f"CUSTOM FONT DEBUG: path exists: {os.path.exists(font_path)}") - - if os.path.exists(font_path): - # Get detailed file information for diagnostics - try: - import stat - file_stats = os.stat(font_path) - print(f"CUSTOM FONT DEBUG: file size: {file_stats.st_size} bytes") - print(f"CUSTOM FONT DEBUG: file permissions: {stat.filemode(file_stats.st_mode)}") - print(f"CUSTOM FONT DEBUG: file extension: {os.path.splitext(font_path)[1]}") - except Exception as stats_error: - print(f"CUSTOM FONT DEBUG: could not get detailed file stats: {stats_error}") - - if not os.path.exists(font_path): - error_msg = f"Font file not found: {font_path}" - print(f"CUSTOM FONT DEBUG: {error_msg}") - return None, error_msg - - # Get file info for debugging - try: - file_stats = os.stat(font_path) - print(f"CUSTOM FONT DEBUG: file size: {file_stats.st_size} bytes") - print(f"CUSTOM FONT DEBUG: file extension: {os.path.splitext(font_path)[1]}") - except Exception as stat_error: - print(f"CUSTOM FONT DEBUG: could not get file stats: {stat_error}") - - # Load the font - print(f"CUSTOM FONT DEBUG: Attempting to load font with ImageFont.truetype()...") - try: - font = ImageFont.truetype(font_path, font_size) - print(f"CUSTOM FONT DEBUG: Font loaded successfully: {font}") - except Exception as load_error: - error_msg = f"Font loading error - ImageFont.truetype() failed: {load_error}" - print(f"CUSTOM FONT DEBUG: {error_msg}") - import traceback - print(f"CUSTOM FONT DEBUG: Full exception trace: {traceback.format_exc()}") - return None, error_msg - - # Test mixed-case support - print(f"CUSTOM FONT DEBUG: Testing mixed-case support...") - if test_font_mixed_case_support(font): - print(f"CUSTOM FONT DEBUG: Mixed-case validation PASSED") - return font, "OK" - else: - error_msg = f"Font does not support mixed-case properly: {font_path}" - print(f"CUSTOM FONT DEBUG: Mixed-case validation FAILED: {error_msg}") - return None, error_msg - - except Exception as e: - error_msg = f"Font loading error (outer exception): {e}" - print(f"CUSTOM FONT DEBUG: {error_msg}") - import traceback - traceback.print_exc() - return None, error_msg - -def get_system_fallback_fonts(): - """Get platform-specific fallback fonts known to support mixed-case""" - system = platform.system() - - if system == "Windows": - # Windows fonts with guaranteed mixed-case support - return [ - "C:/Windows/Fonts/arial.ttf", # Arial - excellent mixed-case - "C:/Windows/Fonts/calibri.ttf", # Calibri - modern, mixed-case - "C:/Windows/Fonts/segoeui.ttf", # Segoe UI - system font, mixed-case - "C:/Windows/Fonts/verdana.ttf", # Verdana - web-safe, mixed-case - "C:/Windows/Fonts/tahoma.ttf", # Tahoma - fallback mixed-case - "C:/Windows/Fonts/times.ttf", # Times - serif mixed-case - "C:/Windows/Fonts/trebuc.ttf" # Trebuchet MS - mixed-case - ] - elif system == "Darwin": # macOS - # macOS fonts with guaranteed mixed-case support - return [ - "/System/Library/Fonts/Helvetica.ttc", # Helvetica - classic mixed-case - "/Library/Fonts/Arial.ttf", # Arial - if available - "/System/Library/Fonts/Times.ttc", # Times - serif mixed-case - "/System/Library/Fonts/Avenir.ttc", # Avenir - modern mixed-case - "/System/Library/Fonts/Geneva.ttf", # Geneva - system mixed-case - "/System/Library/Fonts/Palatino.ttc" # Palatino - elegant mixed-case - ] - else: # Linux - # Linux fonts with guaranteed mixed-case support - return [ - "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", # LibreOffice mixed-case - "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", # DejaVu - comprehensive - "/usr/share/fonts/truetype/ubuntu/Ubuntu-R.ttf", # Ubuntu - modern mixed-case - "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", # Google Noto - universal - "/usr/share/fonts/truetype/lato/Lato-Regular.ttf", # Lato - web font mixed-case - "/usr/share/fonts/truetype/opensans/OpenSans-Regular.ttf" # Open Sans - mixed-case - ] - -# ============================================================================ -# NORMAL MAP GENERATION FUNCTIONS -# ============================================================================ - -def generate_normal_map_from_alpha(img, strength=1.0, blur_radius=1.0, invert=False): - """Generate a normal map from an image's alpha channel using height-based algorithm""" - try: - from PIL import Image, ImageFilter - import numpy as np - - print(f"[Normal Map] Starting generation with strength={strength}, blur={blur_radius}, invert={invert}") - - # Extract alpha channel as height map - if img.mode != 'RGBA': - img = img.convert('RGBA') - - # Get alpha channel - alpha_channel = img.split()[3] # Alpha is the 4th channel - width, height = alpha_channel.size - - print(f"[Normal Map] Processing {width}x{height} alpha channel") - - # Apply blur if specified - if blur_radius > 0: - alpha_channel = alpha_channel.filter(ImageFilter.GaussianBlur(radius=blur_radius)) - print(f"[Normal Map] Applied blur with radius {blur_radius}") - - # Convert to numpy array for gradient calculations - height_map = np.array(alpha_channel, dtype=np.float32) / 255.0 - - # Apply strength multiplier - height_map *= strength - - # Calculate gradients using Sobel operators - # Sobel X kernel for horizontal gradients - sobel_x = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], dtype=np.float32) - # Sobel Y kernel for vertical gradients - sobel_y = np.array([[-1, -2, -1], [0, 0, 0], [1, 2, 1]], dtype=np.float32) - - # Pad the height map to handle edges - padded_height = np.pad(height_map, ((1, 1), (1, 1)), mode='edge') - - # Calculate gradients - grad_x = np.zeros_like(height_map) - grad_y = np.zeros_like(height_map) - - for i in range(height): - for j in range(width): - # Extract 3x3 neighborhood - neighborhood = padded_height[i:i+3, j:j+3] - # Apply Sobel operators - grad_x[i, j] = np.sum(neighborhood * sobel_x) - grad_y[i, j] = np.sum(neighborhood * sobel_y) - - print(f"[Normal Map] Calculated gradients") - - # Convert gradients to normal vectors - # Normal map RGB values are calculated as: - # R = (grad_x + 1) * 0.5 -> maps -1,1 to 0,1 - # G = (-grad_y + 1) * 0.5 -> maps -1,1 to 0,1 (Y is flipped for standard normal maps) - # B = sqrt(1 - grad_x^2 - grad_y^2) -> Z component, always pointing up - - # Clamp gradients to reasonable range - grad_x = np.clip(grad_x, -1, 1) - grad_y = np.clip(grad_y, -1, 1) - - # Apply invert if specified - if invert: - grad_x = -grad_x - grad_y = -grad_y - print(f"[Normal Map] Applied inversion") - - # Calculate normal map channels - # Red channel: X gradient mapped to 0-1 - normal_r = ((grad_x + 1.0) * 0.5 * 255).astype(np.uint8) - - # Green channel: Y gradient mapped to 0-1 (flipped) - normal_g = ((-grad_y + 1.0) * 0.5 * 255).astype(np.uint8) - - # Blue channel: Z component (pointing up) - # Calculate Z from X and Y to maintain unit length - grad_magnitude_sq = grad_x**2 + grad_y**2 - grad_z = np.sqrt(np.maximum(0, 1.0 - grad_magnitude_sq)) - normal_b = (grad_z * 255).astype(np.uint8) - - print(f"[Normal Map] Calculated normal vectors") - - # Create the normal map image - normal_map = Image.new('RGB', (width, height)) - - # Combine channels - for y in range(height): - for x in range(width): - r = int(normal_r[y, x]) - g = int(normal_g[y, x]) - b = int(normal_b[y, x]) - normal_map.putpixel((x, y), (r, g, b)) - - print(f"[Normal Map] Normal map generation completed successfully") - return normal_map - - except Exception as e: - print(f"[Normal Map] Error generating normal map: {e}") - import traceback - traceback.print_exc() - return None - -# ============================================================================ -# TEXT FITTING HELPER FUNCTIONS -# ============================================================================ - -def calculate_available_text_area(base_width, base_height, overlay_areas, margin_x, margin_y, canvas_width, canvas_height): - """Calculate the largest available area for text placement avoiding absolute positioned overlays - - Args: - base_width: Available width without overlay consideration - base_height: Available height without overlay consideration - overlay_areas: List of overlay area dictionaries with x, y, width, height - margin_x: Horizontal margin in pixels - margin_y: Vertical margin in pixels - canvas_width: Total canvas width - canvas_height: Total canvas height - - Returns: - Tuple of (available_width, available_height) for text fitting - """ - if not overlay_areas: - # No overlays to avoid, return base dimensions - return base_width, base_height - - # Define the text fitting area boundaries (respecting margins) - text_area_x = margin_x - text_area_y = margin_y - text_area_width = base_width - text_area_height = base_height - - print(f"DEBUG TEXT AREA: Text fitting area bounds: ({text_area_x}, {text_area_y}) {text_area_width}x{text_area_height}") - - # Find overlays that intersect with the text area - conflicting_overlays = [] - for overlay in overlay_areas: - overlay_x = overlay['x'] - overlay_y = overlay['y'] - overlay_w = overlay['width'] - overlay_h = overlay['height'] - - # Check if overlay intersects with text area - if (overlay_x < text_area_x + text_area_width and - overlay_x + overlay_w > text_area_x and - overlay_y < text_area_y + text_area_height and - overlay_y + overlay_h > text_area_y): - - conflicting_overlays.append(overlay) - print(f"DEBUG TEXT AREA: Conflicting overlay at ({overlay_x}, {overlay_y}) size {overlay_w}x{overlay_h}") - - if not conflicting_overlays: - # No conflicting overlays, return base dimensions - return base_width, base_height - - # Strategy 1: Try to find the largest horizontal strip that avoids overlays - best_width = 0 - best_height = 0 - - # Divide the text area into horizontal strips and find the largest clear one - strip_height = max(1, base_height // 10) # Test 10 horizontal strips - for strip_y in range(text_area_y, text_area_y + text_area_height - strip_height + 1, strip_height): - strip_bottom = min(strip_y + strip_height, text_area_y + text_area_height) - actual_strip_height = strip_bottom - strip_y - - # Check if this strip intersects with any overlay - strip_clear = True - for overlay in conflicting_overlays: - overlay_x = overlay['x'] - overlay_y = overlay['y'] - overlay_w = overlay['width'] - overlay_h = overlay['height'] - - # Check vertical intersection with this strip - if (overlay_y < strip_bottom and overlay_y + overlay_h > strip_y): - strip_clear = False - break - - if strip_clear: - # This strip is clear, use full width - strip_area = text_area_width * actual_strip_height - if strip_area > best_width * best_height: - best_width = text_area_width - best_height = actual_strip_height - print(f"DEBUG TEXT AREA: Found clear horizontal strip: {best_width}x{best_height}") - - # Strategy 2: Try vertical strips if horizontal didn't work well - if best_width * best_height < (text_area_width * text_area_height * 0.5): # Less than 50% of original area - strip_width = max(1, base_width // 10) # Test 10 vertical strips - for strip_x in range(text_area_x, text_area_x + text_area_width - strip_width + 1, strip_width): - strip_right = min(strip_x + strip_width, text_area_x + text_area_width) - actual_strip_width = strip_right - strip_x - - # Check if this strip intersects with any overlay - strip_clear = True - for overlay in conflicting_overlays: - overlay_x = overlay['x'] - overlay_y = overlay['y'] - overlay_w = overlay['width'] - overlay_h = overlay['height'] - - # Check horizontal intersection with this strip - if (overlay_x < strip_right and overlay_x + overlay_w > strip_x): - strip_clear = False - break - - if strip_clear: - # This strip is clear, use full height - strip_area = actual_strip_width * text_area_height - if strip_area > best_width * best_height: - best_width = actual_strip_width - best_height = text_area_height - print(f"DEBUG TEXT AREA: Found clear vertical strip: {best_width}x{best_height}") - - # Strategy 3: Fallback - reduce dimensions by estimated overlay coverage - if best_width * best_height == 0: - # Calculate total overlay area coverage - total_overlay_area = sum(overlay['width'] * overlay['height'] for overlay in conflicting_overlays) - text_area_total = text_area_width * text_area_height - coverage_ratio = min(0.8, total_overlay_area / text_area_total) # Cap at 80% - - # Reduce both dimensions proportionally - reduction_factor = max(0.2, 1.0 - coverage_ratio) # Minimum 20% of original - best_width = int(text_area_width * reduction_factor) - best_height = int(text_area_height * reduction_factor) - print(f"DEBUG TEXT AREA: Fallback proportional reduction: {best_width}x{best_height} (factor: {reduction_factor:.2f})") - - # Ensure minimum dimensions - best_width = max(50, best_width) # Minimum 50px width - best_height = max(20, best_height) # Minimum 20px height - - print(f"DEBUG TEXT AREA: Final available text area: {best_width}x{best_height}") - return best_width, best_height - -# ============================================================================ -# ENHANCED POSITIONING FUNCTIONS -# ============================================================================ - -def calculate_text_position(props, canvas_width, canvas_height, text_width, text_height, overlay_areas=None, scale_factor=1.0, prepend_width=0): - """Calculate text position based on enhanced positioning properties with overlay avoidance - - Args: - prepend_width: Total width occupied by prepend overlays (shifts text to the right) - """ - - if props.position_mode == 'MANUAL': - # Manual positioning mode - if props.manual_position_unit == 'PERCENTAGE': - x = int((props.manual_position_x / 100.0) * canvas_width) - y = int((props.manual_position_y / 100.0) * canvas_height) - else: # PIXELS - x = int(props.manual_position_x) - y = int(props.manual_position_y) - else: - # Preset anchor-based positioning with overlay avoidance - margin_x_px = int((props.margin_x / 100.0) * canvas_width) - margin_y_px = int((props.margin_y / 100.0) * canvas_height) - - # If text fitting is enabled and we have overlay areas, calculate available space - if props.enable_text_fitting and overlay_areas: - print(f"DEBUG TEXT POSITION: Text fitting enabled with {len(overlay_areas)} overlay areas to avoid") - - # Calculate base available space with margins - base_available_width = canvas_width - (2 * margin_x_px) - base_available_height = canvas_height - (2 * margin_y_px) - - # Get the available area that avoids overlays - available_width, available_height = calculate_available_text_area( - base_available_width, base_available_height, - overlay_areas, - margin_x_px, margin_y_px, canvas_width, canvas_height - ) - - print(f"DEBUG TEXT POSITION: Base available: {base_available_width}x{base_available_height}") - print(f"DEBUG TEXT POSITION: Final available (after overlay avoidance): {available_width}x{available_height}") - - # Find the best clear area for text placement - best_position = find_best_text_position_in_available_area( - props, canvas_width, canvas_height, text_width, text_height, - overlay_areas, margin_x_px, margin_y_px, available_width, available_height - ) - - if best_position: - x, y = best_position - print(f"DEBUG TEXT POSITION: Found clear position at ({x}, {y}) avoiding overlays") - else: - # Fallback to standard positioning if no clear area found - print(f"DEBUG TEXT POSITION: No clear area found, using standard positioning with prepend_width={prepend_width}") - x, y = calculate_standard_anchor_position( - props, canvas_width, canvas_height, text_width, text_height, - margin_x_px, margin_y_px, prepend_width - ) - else: - # Standard anchor-based positioning (no overlay avoidance) - print(f"DEBUG TEXT POSITION: Using standard positioning with prepend_width={prepend_width}") - x, y = calculate_standard_anchor_position( - props, canvas_width, canvas_height, text_width, text_height, - margin_x_px, margin_y_px, prepend_width - ) - - # Apply fine-tuning offsets - x += props.offset_x - y += props.offset_y - - # Apply canvas constraints if enabled - if props.constrain_to_canvas: - x = max(0, min(x, canvas_width - text_width)) - y = max(0, min(y, canvas_height - text_height)) - - return x, y - -def calculate_standard_anchor_position(props, canvas_width, canvas_height, text_width, text_height, margin_x_px, margin_y_px, prepend_width=0): - """Calculate standard anchor-based position without overlay avoidance - - Args: - prepend_width: Total width occupied by prepend overlays (shifts text to the right) - """ - # Calculate base position based on anchor point - if 'LEFT' in props.anchor_point: - base_x = margin_x_px + prepend_width - elif 'RIGHT' in props.anchor_point: - base_x = canvas_width - text_width - margin_x_px - else: # CENTER - base_x = (canvas_width - text_width) // 2 + prepend_width - - if 'TOP' in props.anchor_point: - base_y = margin_y_px - elif 'BOTTOM' in props.anchor_point: - base_y = canvas_height - text_height - margin_y_px - else: # MIDDLE - base_y = (canvas_height - text_height) // 2 - - return base_x, base_y - -def find_best_text_position_in_available_area(props, canvas_width, canvas_height, text_width, text_height, overlay_areas, margin_x_px, margin_y_px, available_width, available_height): - """Find the best position for text within available area that avoids overlays""" - - # Define the search area (respecting margins) - search_area_x = margin_x_px - search_area_y = margin_y_px - search_area_width = canvas_width - (2 * margin_x_px) - search_area_height = canvas_height - (2 * margin_y_px) - - print(f"DEBUG TEXT POSITION: Search area: ({search_area_x}, {search_area_y}) {search_area_width}x{search_area_height}") - - # Try anchor-based positions first, but within clear areas - anchor_positions = [] - - # Calculate preferred positions based on anchor point - if 'LEFT' in props.anchor_point: - pref_x = search_area_x - elif 'RIGHT' in props.anchor_point: - pref_x = search_area_x + search_area_width - text_width - else: # CENTER - pref_x = search_area_x + (search_area_width - text_width) // 2 - - if 'TOP' in props.anchor_point: - pref_y = search_area_y - elif 'BOTTOM' in props.anchor_point: - pref_y = search_area_y + search_area_height - text_height - else: # MIDDLE - pref_y = search_area_y + (search_area_height - text_height) // 2 - - # Add preferred position - anchor_positions.append((pref_x, pref_y, "preferred")) - - # Add alternative positions - # Top-left, top-center, top-right - anchor_positions.append((search_area_x, search_area_y, "top-left")) - anchor_positions.append((search_area_x + (search_area_width - text_width) // 2, search_area_y, "top-center")) - anchor_positions.append((search_area_x + search_area_width - text_width, search_area_y, "top-right")) - - # Middle-left, middle-center, middle-right - middle_y = search_area_y + (search_area_height - text_height) // 2 - anchor_positions.append((search_area_x, middle_y, "middle-left")) - anchor_positions.append((search_area_x + (search_area_width - text_width) // 2, middle_y, "middle-center")) - anchor_positions.append((search_area_x + search_area_width - text_width, middle_y, "middle-right")) - - # Bottom-left, bottom-center, bottom-right - bottom_y = search_area_y + search_area_height - text_height - anchor_positions.append((search_area_x, bottom_y, "bottom-left")) - anchor_positions.append((search_area_x + (search_area_width - text_width) // 2, bottom_y, "bottom-center")) - anchor_positions.append((search_area_x + search_area_width - text_width, bottom_y, "bottom-right")) - - # Test each position for conflicts with overlays - for test_x, test_y, position_name in anchor_positions: - # Ensure position is within canvas bounds - test_x = max(0, min(test_x, canvas_width - text_width)) - test_y = max(0, min(test_y, canvas_height - text_height)) - - # Check if this position conflicts with any overlay - text_rect = { - 'x': test_x, - 'y': test_y, - 'width': text_width, - 'height': text_height - } - - conflicts = False - for overlay in overlay_areas: - if rectangles_overlap(text_rect, overlay): - conflicts = True - print(f"DEBUG TEXT POSITION: Position {position_name} at ({test_x}, {test_y}) conflicts with overlay at ({overlay['x']}, {overlay['y']})") - break - - if not conflicts: - print(f"DEBUG TEXT POSITION: Found clear position {position_name} at ({test_x}, {test_y})") - return (test_x, test_y) - - # If no clear position found, return None - print(f"DEBUG TEXT POSITION: No clear position found among {len(anchor_positions)} tested positions") - return None - -def rectangles_overlap(rect1, rect2): - """Check if two rectangles overlap""" - return not (rect1['x'] + rect1['width'] <= rect2['x'] or - rect2['x'] + rect2['width'] <= rect1['x'] or - rect1['y'] + rect1['height'] <= rect2['y'] or - rect2['y'] + rect2['height'] <= rect1['y']) - -def sync_margin_values(props, changed_margin): - """Synchronize margin values when linked""" - if props.margins_linked: - if changed_margin == 'x': - props.margin_y = props.margin_x - elif changed_margin == 'y': - props.margin_x = props.margin_y - -def get_anchor_point_matrix(): - """Return 3x3 matrix of anchor point identifiers for UI layout""" - return [ - ['TOP_LEFT', 'TOP_CENTER', 'TOP_RIGHT'], - ['MIDDLE_LEFT', 'MIDDLE_CENTER', 'MIDDLE_RIGHT'], - ['BOTTOM_LEFT', 'BOTTOM_CENTER', 'BOTTOM_RIGHT'] - ] - -def process_multiline_text(text_content, font, props): - """Process text into lines for multiline rendering""" - # Split text by newlines first - lines = text_content.split('\n') - processed_lines = [] - - for line in lines: - if props.auto_wrap and line.strip(): - # Calculate wrap width - wrap_width = props.wrap_width if props.wrap_width > 0 else props.texture_width - 40 # 20px margin on each side - wrapped_lines = wrap_text_to_width(line, font, wrap_width) - processed_lines.extend(wrapped_lines) - else: - processed_lines.append(line) - - # Apply max lines limit - if props.max_lines > 0 and len(processed_lines) > props.max_lines: - processed_lines = processed_lines[:props.max_lines] - - return processed_lines - -def wrap_text_to_width(text, font, max_width): - """Wrap text to fit within specified width""" - if not text.strip(): - return [text] - - # Ensure minimum width to prevent infinite loops - if max_width < 10: - return [text] - - words = text.split() - if not words: - return [text] - - lines = [] - current_line = "" - - for word in words: - test_line = f"{current_line} {word}".strip() - try: - bbox = font.getbbox(test_line) - text_width = bbox[2] - bbox[0] - except: - # Fallback for font issues - text_width = len(test_line) * (font.size // 2) # Rough estimation - - if text_width <= max_width: - current_line = test_line - else: - if current_line: - lines.append(current_line) - current_line = word - else: - # Word is too long, add it anyway - lines.append(word) - current_line = "" - - if current_line: - lines.append(current_line) - - return lines if lines else [text] - -def draw_multiline_text(draw, lines, font, text_color, props, canvas_width, canvas_height): - """Draw multiline text with proper alignment and spacing""" - if not lines: - return - - try: - # Calculate line metrics - bbox = font.getbbox("Ag") # Use standard characters for height measurement - base_line_height = bbox[3] - bbox[1] - line_height = int(base_line_height * props.line_height) + props.line_spacing_pixels - - # Ensure minimum line height - if line_height < 1: - line_height = base_line_height - - # Calculate total text block dimensions - max_line_width = 0 - for line in lines: - if line.strip(): # Only measure non-empty lines - try: - bbox = font.getbbox(line) - line_width = bbox[2] - bbox[0] - max_line_width = max(max_line_width, line_width) - except: - # Fallback width calculation - line_width = len(line) * (font.size // 2) - max_line_width = max(max_line_width, line_width) - - total_height = len(lines) * line_height - props.line_spacing_pixels # Remove extra spacing from last line - - # Calculate starting position based on text block - use legacy positioning for now - # This maintains compatibility with existing positioning system - x = (canvas_width - max_line_width) // 2 - y = (canvas_height - total_height) // 2 - - # Draw each line - current_y = y - for line in lines: - # Skip drawing if we're outside the canvas bounds - if current_y >= canvas_height: - break - - if line.strip(): # Only process non-empty lines for alignment - # Calculate x position based on alignment - try: - bbox = font.getbbox(line) - line_width = bbox[2] - bbox[0] - except: - line_width = len(line) * (font.size // 2) - - if props.text_alignment == 'LEFT': - line_x = x - elif props.text_alignment == 'CENTER': - line_x = x + max(0, (max_line_width - line_width) // 2) - else: # RIGHT - line_x = x + max(0, (max_line_width - line_width)) - - # Ensure we don't draw outside canvas bounds - line_x = max(0, min(line_x, canvas_width)) - - # Draw the line - draw.text((line_x, current_y), line, fill=text_color, font=font) - - current_y += line_height - - except Exception as e: - print(f"Error drawing multiline text: {e}") - # Fallback to drawing first line only - if lines and lines[0]: - draw.text((10, 10), lines[0], fill=text_color, font=font) - -def draw_multiline_text_with_stroke(draw, lines, font, text_color, props, canvas_width, canvas_height): - """Draw multiline text with stroke support""" - if not lines: - return - - try: - # Calculate line metrics - bbox = font.getbbox("Ag") # Use standard characters for height measurement - base_line_height = bbox[3] - bbox[1] - line_height = int(base_line_height * props.line_height) + props.line_spacing_pixels - - # Ensure minimum line height - if line_height < 1: - line_height = base_line_height - - # Calculate total text block dimensions - max_line_width = 0 - for line in lines: - if line.strip(): # Only measure non-empty lines - try: - bbox = font.getbbox(line) - line_width = bbox[2] - bbox[0] - max_line_width = max(max_line_width, line_width) - except: - # Fallback width calculation - line_width = len(line) * (font.size // 2) - max_line_width = max(max_line_width, line_width) - - total_height = len(lines) * line_height - props.line_spacing_pixels # Remove extra spacing from last line - - # Calculate starting position based on text block - x = (canvas_width - max_line_width) // 2 - y = (canvas_height - total_height) // 2 - - # Stroke helper function with validation and error handling (same as main rendering) - def draw_text_with_stroke(draw_obj, text_str, pos_x, pos_y, font_obj, text_col, stroke_enabled, stroke_width, stroke_color, stroke_pos): - """Draw text with optional stroke""" - try: - # Validation and bounds checking - if not stroke_enabled or stroke_width <= 0 or not text_str.strip(): - # No stroke or empty text, draw normally - draw_obj.text((pos_x, pos_y), text_str, fill=text_col, font=font_obj) - return - - # Validate stroke width bounds (0-20 pixels as per property definition) - stroke_width = max(0, min(20, int(stroke_width))) - if stroke_width == 0: - draw_obj.text((pos_x, pos_y), text_str, fill=text_col, font=font_obj) - return - - # Validate stroke color - if not stroke_color or len(stroke_color) < 3: - print(f"[Stroke] Warning: Invalid stroke color, using black") - stroke_color = (0.0, 0.0, 0.0, 1.0) - - # Convert stroke color with validation - stroke_col = [] - for i, c in enumerate(stroke_color[:4]): - val = max(0.0, min(1.0, float(c))) # Clamp to 0-1 range - stroke_col.append(int(val * 255)) - stroke_col = tuple(stroke_col[:3] + ([stroke_col[3]] if len(stroke_col) > 3 else [255])) - - # Validate stroke position - if stroke_pos not in ['OUTER', 'INNER', 'CENTER']: - print(f"[Stroke] Warning: Invalid stroke position '{stroke_pos}', using OUTER") - stroke_pos = 'OUTER' - - # Draw stroke based on position - if stroke_pos == 'OUTER': - # Outer stroke: draw stroke offsets around text - for dx in range(-stroke_width, stroke_width + 1): - for dy in range(-stroke_width, stroke_width + 1): - if dx*dx + dy*dy <= stroke_width*stroke_width: # Circular stroke - draw_obj.text((pos_x + dx, pos_y + dy), text_str, fill=stroke_col, font=font_obj) - # Draw text on top - draw_obj.text((pos_x, pos_y), text_str, fill=text_col, font=font_obj) - - elif stroke_pos == 'CENTER': - # Center stroke: similar to outer but smaller offset - half_stroke = max(1, stroke_width // 2) - for dx in range(-half_stroke, half_stroke + 1): - for dy in range(-half_stroke, half_stroke + 1): - if dx*dx + dy*dy <= half_stroke*half_stroke: - draw_obj.text((pos_x + dx, pos_y + dy), text_str, fill=stroke_col, font=font_obj) - # Draw text on top - draw_obj.text((pos_x, pos_y), text_str, fill=text_col, font=font_obj) - - elif stroke_pos == 'INNER': - # Inner stroke: draw text first, then smaller inner stroke - draw_obj.text((pos_x, pos_y), text_str, fill=text_col, font=font_obj) - # Create inner stroke effect by drawing smaller stroke inside - inner_offset = max(1, stroke_width // 3) - for dx in range(-inner_offset, inner_offset + 1): - for dy in range(-inner_offset, inner_offset + 1): - if dx*dx + dy*dy <= inner_offset*inner_offset and (dx != 0 or dy != 0): - # Use stroke color with some transparency for inner effect - inner_col = stroke_col[:3] - if len(stroke_col) > 3: - inner_col += (int(stroke_col[3] * 0.7),) - else: - inner_col += (int(255 * 0.7),) - draw_obj.text((pos_x + dx, pos_y + dy), text_str, fill=inner_col, font=font_obj) - - except Exception as e: - print(f"[Stroke] Error drawing multiline stroke: {e}") - # Fallback to normal text drawing - try: - draw_obj.text((pos_x, pos_y), text_str, fill=text_col, font=font_obj) - except Exception as fallback_error: - print(f"[Stroke] Error in fallback multiline text drawing: {fallback_error}") - - # Draw each line with stroke - current_y = y - for line in lines: - # Skip drawing if we're outside the canvas bounds - if current_y >= canvas_height: - break - - if line.strip(): # Only process non-empty lines for alignment - # Calculate x position based on alignment - try: - bbox = font.getbbox(line) - line_width = bbox[2] - bbox[0] - except: - line_width = len(line) * (font.size // 2) - - if props.text_alignment == 'LEFT': - line_x = x - elif props.text_alignment == 'CENTER': - line_x = x + max(0, (max_line_width - line_width) // 2) - else: # RIGHT - line_x = x + max(0, (max_line_width - line_width)) - - # Ensure we don't draw outside canvas bounds - line_x = max(0, min(line_x, canvas_width)) - - # Draw the line with stroke - draw_text_with_stroke(draw, line, line_x, current_y, font, text_color, - props.enable_stroke, props.stroke_width, props.stroke_color, props.stroke_position) - - current_y += line_height - - except Exception as e: - print(f"Error drawing multiline text with stroke: {e}") - # Fallback to drawing first line only - if lines and lines[0]: - draw.text((10, 10), lines[0], fill=text_color, font=font) - - -def calculate_smart_content_fitting(props, width, height, scale_factor): - """Calculate smart content fitting for text + overlays to prevent overflow""" - try: - from PIL import Image, ImageDraw, ImageFont - - # Initial font size calculation - base_font_size = max(6, int(props.font_size * scale_factor)) - - # Load font for measurement - def load_measurement_font(size): - font = None - if props.use_custom_font and props.custom_font_path and os.path.exists(props.custom_font_path): - try: - font = ImageFont.truetype(props.custom_font_path, size) - except: - pass - if not font and props.font_path != "default" and os.path.exists(props.font_path): - try: - font = ImageFont.truetype(props.font_path, size) - except: - pass - if not font: - font = ImageFont.load_default() - return font - - font = load_measurement_font(base_font_size) - - # Get text content - main_text = props.text if props.text else "" - prepend_text = props.prepend_text.strip() if props.prepend_text else "" - append_text = props.append_text.strip() if props.append_text else "" - - # Calculate combined text for horizontal layout - if props.prepend_append_layout == 'HORIZONTAL': - text_parts = [] - if prepend_text: - text_parts.append(prepend_text) - prepend_margin_px = int((props.prepend_margin / 100.0) * base_font_size) - space_count = max(1, int(prepend_margin_px / (base_font_size * 0.3))) - text_parts.append(" " * space_count) - text_parts.append(main_text) - if append_text: - append_margin_px = int((props.append_margin / 100.0) * base_font_size) - space_count = max(1, int(append_margin_px / (base_font_size * 0.3))) - text_parts.append(" " * space_count) - text_parts.append(append_text) - combined_text = "".join(text_parts) - else: - combined_text = main_text # For vertical layout, measure main text only - - # Measure text dimensions - temp_draw = ImageDraw.Draw(Image.new('RGBA', (100, 100))) - try: - text_bbox = temp_draw.textbbox((0, 0), combined_text, font=font) - text_width = text_bbox[2] - text_bbox[0] - text_height = text_bbox[3] - text_bbox[1] - except: - text_width = len(combined_text) * int(base_font_size * 0.6) - text_height = base_font_size - - # Calculate overlay dimensions - enabled_overlays = [overlay for overlay in props.image_overlays if overlay.enabled] - prepend_overlays = [o for o in enabled_overlays if o.positioning_mode == 'PREPEND' and o.image_path and os.path.exists(o.image_path)] - append_overlays = [o for o in enabled_overlays if o.positioning_mode == 'APPEND' and o.image_path and os.path.exists(o.image_path)] - - prepend_total_width = 0 - append_total_width = 0 - - # Calculate prepend overlay widths - for i, overlay in enumerate(prepend_overlays): - try: - overlay_img = Image.open(overlay.image_path).convert("RGBA") - if overlay.scale != 1.0: - final_scale = max(0.01, min(10.0, overlay.scale * scale_factor)) - new_width = max(1, int(overlay_img.width * final_scale)) - overlay_width = new_width - else: - overlay_width = overlay_img.width - - prepend_total_width += overlay_width - if i == 0: # First overlay gets text spacing - text_spacing = max(0, (overlay.text_spacing / 100.0) * width) - prepend_total_width += text_spacing - else: # Subsequent overlays get image spacing - image_spacing = max(0, (overlay.image_spacing / 100.0) * width) - prepend_total_width += image_spacing - except: - # Fallback if image can't be loaded - prepend_total_width += base_font_size - - # Calculate append overlay widths - for i, overlay in enumerate(append_overlays): - try: - overlay_img = Image.open(overlay.image_path).convert("RGBA") - if overlay.scale != 1.0: - final_scale = max(0.01, min(10.0, overlay.scale * scale_factor)) - new_width = max(1, int(overlay_img.width * final_scale)) - overlay_width = new_width - else: - overlay_width = overlay_img.width - - if i == 0: # First overlay gets text spacing - text_spacing = max(0, (overlay.text_spacing / 100.0) * width) - append_total_width += text_spacing + overlay_width - else: # Subsequent overlays get image spacing - image_spacing = max(0, (overlay.image_spacing / 100.0) * width) - append_total_width += image_spacing + overlay_width - except: - # Fallback if image can't be loaded - append_total_width += base_font_size - - # Calculate total content width - total_content_width = prepend_total_width + text_width + append_total_width - - # Add safety margins (10% of canvas width) - safety_margin = int(width * 0.1) - available_width = width - (2 * safety_margin) - - print(f"SMART FITTING: Text width: {text_width}, Prepend: {prepend_total_width}, Append: {append_total_width}") - print(f"SMART FITTING: Total content: {total_content_width}, Available: {available_width}") - - # Determine if smart fitting is needed - fitting_needed = total_content_width > available_width - adjusted_font_size = base_font_size - adjusted_scale_factor = scale_factor - fitting_strategy = "NONE" - - if fitting_needed: - print(f"SMART FITTING: Content overflow detected! Applying smart fitting...") - - # Strategy 1: Proportional scaling - if total_content_width > 0: - scale_ratio = available_width / total_content_width - # Don't scale below 50% of original size to maintain readability - scale_ratio = max(0.5, scale_ratio) - adjusted_font_size = max(6, int(base_font_size * scale_ratio)) - adjusted_scale_factor = scale_factor * scale_ratio - fitting_strategy = "PROPORTIONAL_SCALE" - print(f"SMART FITTING: Applied {scale_ratio:.2f}x scaling, new font size: {adjusted_font_size}") - - return { - 'font_size': adjusted_font_size, - 'scale_factor': adjusted_scale_factor, - 'fitting_needed': fitting_needed, - 'fitting_strategy': fitting_strategy, - 'original_content_width': total_content_width, - 'available_width': available_width, - 'scale_ratio': adjusted_scale_factor / scale_factor if scale_factor > 0 else 1.0 - } - - except Exception as e: - print(f"ERROR in smart content fitting: {e}") - # Return safe defaults - return { - 'font_size': max(6, int(props.font_size * scale_factor)), - 'scale_factor': scale_factor, - 'fitting_needed': False, - 'fitting_strategy': "ERROR_FALLBACK", - 'original_content_width': 0, - 'available_width': width, - 'scale_ratio': 1.0 - } - -def generate_texture_image(props, width, height): - """Generate the actual texture image with overlays - width and height are REQUIRED - Returns: (diffuse_image, normal_map_image) or (diffuse_image, None) if normal map disabled""" - try: - install_pillow() - from PIL import Image, ImageDraw, ImageFont, ImageFilter - import math - - # Use the exact dimensions provided - print(f"Generating PIL image at {width}x{height}") - - # Ensure minimum dimensions - width = max(16, width) - height = max(16, height) - - print(f"DEBUG: Generating texture - requested: {width}x{height}, texture settings: {props.texture_width}x{props.texture_height}") - print(f"DEBUG: Normal map generation enabled: {props.generate_normal_map}") - - # Calculate smart content fitting - scale_factor = width / props.texture_width - fitting_result = calculate_smart_content_fitting(props, width, height, scale_factor) - - # Use adjusted values from smart fitting - base_font_size = fitting_result['font_size'] - adjusted_scale_factor = fitting_result['scale_factor'] - padding = max(0, int(props.padding * adjusted_scale_factor)) - - print(f"DEBUG: Original scale factor: {scale_factor}, adjusted: {adjusted_scale_factor}") - print(f"DEBUG: Smart fitting applied: {fitting_result['fitting_strategy']}") - print(f"DEBUG: Adjusted font size: {base_font_size}, scaled padding: {padding}") - - # Create base image with background - if props.background_transparent: - base_img = Image.new('RGBA', (width, height), (0, 0, 0, 0)) - else: - bg_color = tuple(int(c * 255) for c in props.background_color[:3]) - if len(props.background_color) > 3: - bg_color += (int(props.background_color[3] * 255),) - base_img = Image.new('RGBA', (width, height), bg_color) - - print(f"Base image created: {base_img.size}") - - # Collect all elements with z-index (text is z-index 0) - elements = [] - - # ENHANCEMENT: Filter disabled overlays early to completely exclude them from all calculations - # This ensures disabled overlays don't consume space in APPEND/PREPEND modes and don't render - # When overlays are disabled (enabled=False), they are completely excluded from: - # - Space calculations, text bounds calculations, image positioning, spacing calculations, and rendering - enabled_overlays = [overlay for overlay in props.image_overlays if overlay.enabled] - - # Separate enabled overlays by positioning mode and sort them - absolute_overlays = [] - prepend_overlays = [] - append_overlays = [] - - for overlay in enabled_overlays: - if overlay.image_path and os.path.exists(overlay.image_path): - if overlay.positioning_mode == 'ABSOLUTE': - absolute_overlays.append(overlay) - elif overlay.positioning_mode == 'PREPEND': - prepend_overlays.append(overlay) - elif overlay.positioning_mode == 'APPEND': - append_overlays.append(overlay) - - # Prepend and append overlays are already in order based on list position - # No sorting needed since we removed sequence_index - order is determined by list position - - # PLACEHOLDER: text_bbox will be calculated after text setup - text_bbox = None - - # PERFORMANCE FIX: Cache loaded overlay images to prevent multiple loading - overlay_image_cache = {} - - def load_overlay_image_cached(overlay_path, scale, scale_factor): - """Load and cache overlay images to improve performance""" - cache_key = (overlay_path, scale, scale_factor) - if cache_key in overlay_image_cache: - return overlay_image_cache[cache_key], None # Return cached image, no error - - try: - # ERROR HANDLING: Check file existence and validate path - if not overlay_path or not os.path.exists(overlay_path): - return None, f"Image file not found: {overlay_path}" - - # EDGE CASE: Check file size to prevent memory issues - try: - file_size = os.path.getsize(overlay_path) - if file_size > 50 * 1024 * 1024: # 50MB limit - return None, f"Image file too large: {file_size} bytes" - except OSError: - return None, f"Cannot access image file: {overlay_path}" - - # Load and convert image - overlay_img = Image.open(overlay_path).convert("RGBA") - - # EDGE CASE: Check for zero dimensions - if overlay_img.width == 0 or overlay_img.height == 0: - return None, f"Image has zero dimensions: {overlay_img.width}x{overlay_img.height}" - - # Apply scaling - if scale != 1.0: - # EDGE CASE: Prevent extreme scaling that could cause memory issues - final_scale = max(0.01, min(10.0, scale * scale_factor)) # Limit scale between 1% and 1000% - new_width = max(1, int(overlay_img.width * final_scale)) - new_height = max(1, int(overlay_img.height * final_scale)) - - # EDGE CASE: Prevent extremely large scaled images - if new_width > 4096 or new_height > 4096: - return None, f"Scaled image too large: {new_width}x{new_height}" - - overlay_img = overlay_img.resize((new_width, new_height), Image.LANCZOS) - - # Cache the processed image - overlay_image_cache[cache_key] = overlay_img - return overlay_img, None - - except Exception as e: - error_msg = f"Error loading overlay image '{overlay_path}': {str(e)}" - return None, error_msg - - # Apply overlays in z-index order, grouped by positioning mode - all_overlays_with_mode = [] - - # Add absolute overlays (maintain backward compatibility) - for overlay in absolute_overlays: - all_overlays_with_mode.append((overlay, 'ABSOLUTE')) - - # Add prepend overlays (positioned to the left of text) - prepend_x_offset = 0 - for i, overlay in enumerate(prepend_overlays): - all_overlays_with_mode.append((overlay, 'PREPEND', prepend_x_offset)) - if text_bbox: - # Calculate spacing for next overlay using cached loading with adjusted scale factor - temp_overlay_img, error = load_overlay_image_cached(overlay.image_path, overlay.scale, adjusted_scale_factor) - if temp_overlay_img: - # EDGE CASE: Validate spacing values to prevent extreme spacing (percentage of canvas width) - text_spacing = max(0, min(1000, (overlay.text_spacing / 100.0) * width)) - image_spacing = max(0, min(1000, (overlay.image_spacing / 100.0) * width)) - - # Add image width plus spacing for next position - prepend_x_offset += temp_overlay_img.width + (text_spacing if i == 0 else image_spacing) - else: - print(f"Warning: {error}") - # Fallback spacing if image can't be loaded - prepend_x_offset += base_font_size - - # Add append overlays (positioned to the right of text) - append_x_offset = 0 - for i, overlay in enumerate(append_overlays): - all_overlays_with_mode.append((overlay, 'APPEND', append_x_offset)) - if text_bbox: - # Calculate spacing for next overlay using cached loading with adjusted scale factor - temp_overlay_img, error = load_overlay_image_cached(overlay.image_path, overlay.scale, adjusted_scale_factor) - if temp_overlay_img: - # EDGE CASE: Validate spacing values to prevent extreme spacing (percentage of canvas width) - text_spacing = max(0, min(1000, (overlay.text_spacing / 100.0) * width)) - image_spacing = max(0, min(1000, (overlay.image_spacing / 100.0) * width)) - - # Calculate position for this overlay, then update offset for next - if i > 0: - append_x_offset += image_spacing - else: - append_x_offset += text_spacing - - # Update offset for next overlay - if i < len(append_overlays) - 1: # Don't add for last overlay - append_x_offset += temp_overlay_img.width - else: - print(f"Warning: {error}") - # Fallback spacing if image can't be loaded - append_x_offset += base_font_size if i == 0 else base_font_size * 0.5 - - # Sort all overlays by z-index - all_overlays_with_mode.sort(key=lambda x: x[0].z_index) - - # OVERLAY PROCESSING MOVED: This section is now processed after text positioning - # to ensure text_bbox is available for prepend/append positioning modes - - # Create text element (z-index 0) - text_img = Image.new('RGBA', (width, height), (0, 0, 0, 0)) - draw = ImageDraw.Draw(text_img) - - # Load font with enhanced mixed-case validation - def load_font_at_size(size): - """Helper function to load font at specific size""" - font = None - font_loaded = False - - # Try custom font first with validation - if props.use_custom_font and props.custom_font_path: - font, error_msg = get_validated_font(props.custom_font_path, size) - if font: - font_loaded = True - else: - print(f"FAILED: Custom font validation failed: {error_msg}") - - # Try selected system font with validation - if not font_loaded and props.font_path != "default": - font, error_msg = get_validated_font(props.font_path, size) - if font: - font_loaded = True - else: - print(f"FAILED: System font validation failed: {error_msg}") - - # Enhanced fallback with mixed-case validation - if not font_loaded: - fallback_fonts = get_system_fallback_fonts() - - for fallback_path in fallback_fonts: - if os.path.exists(fallback_path): - font, error_msg = get_validated_font(fallback_path, size) - if font: - font_loaded = True - break - else: - print(f"FAILED: Fallback font validation failed: {error_msg}") - - # Last resort - use default font but warn about mixed-case limitations - if not font_loaded: - font = ImageFont.load_default() - font_loaded = True - print("WARNING: Using default font - this may not support mixed-case properly!") - print("WARNING: Consider installing additional fonts for proper mixed-case support") - - return font, font_loaded - - # Initial font loading - print("DEBUG UPPERCASE FIX: Starting enhanced font loading with mixed-case validation") - font_size_pixels = base_font_size - font, font_loaded = load_font_at_size(font_size_pixels) - - # ============================================================================ - # PREPEND/APPEND TEXT PROCESSING - # ============================================================================ - - # Get the main text - main_text = props.text if props.text is not None else "" - - # Get prepend and append text - prepend_text = props.prepend_text.strip() if props.prepend_text else "" - append_text = props.append_text.strip() if props.append_text else "" - - # Calculate margins as pixels (percentage of font size) - prepend_margin_px = int((props.prepend_margin / 100.0) * font_size_pixels) if prepend_text else 0 - append_margin_px = int((props.append_margin / 100.0) * font_size_pixels) if append_text else 0 - - # Combine text based on layout - if props.prepend_append_layout == 'HORIZONTAL': - # Horizontal layout: [prepend] [margin] [main] [margin] [append] - text_parts = [] - if prepend_text: - text_parts.append(prepend_text) - # Add proper spacing based on margin percentage - if prepend_margin_px > 0: - # Convert margin pixels to approximate space characters - space_count = max(1, int(prepend_margin_px / (font_size_pixels * 0.3))) - text_parts.append(" " * space_count) - else: - text_parts.append(" ") # Default single space - - text_parts.append(main_text) - - if append_text: - # Add proper spacing based on margin percentage - if append_margin_px > 0: - space_count = max(1, int(append_margin_px / (font_size_pixels * 0.3))) - text_parts.append(" " * space_count) - else: - text_parts.append(" ") # Default single space - text_parts.append(append_text) - - text = "".join(text_parts) - else: - # Vertical layout will be handled separately - for now use main text - text = main_text - - # ENHANCED DIAGNOSTIC LOGGING for uppercase text issue - print(f"DEBUG PREPEND/APPEND - Original main text: '{props.text}'") - print(f"DEBUG PREPEND/APPEND - Prepend text: '{prepend_text}'") - print(f"DEBUG PREPEND/APPEND - Append text: '{append_text}'") - print(f"DEBUG PREPEND/APPEND - Layout: '{props.prepend_append_layout}'") - print(f"DEBUG PREPEND/APPEND - Final combined text: '{text}'") - print(f"DEBUG UPPERCASE ISSUE - Text type: {type(text)}") - print(f"DEBUG UPPERCASE ISSUE - Has upper chars: {any(c.isupper() for c in text)}") - print(f"DEBUG UPPERCASE ISSUE - Has lower chars: {any(c.islower() for c in text)}") - print(f"DEBUG UPPERCASE ISSUE - Font being used: {font}") - if hasattr(font, 'path'): - print(f"DEBUG UPPERCASE ISSUE - Font path: {font.path}") - - # Test the current font's mixed-case rendering capability - print("DEBUG UPPERCASE ISSUE - Testing current font mixed-case capability...") - if test_font_mixed_case_support(font): - print("SUCCESS: Current font supports mixed-case rendering properly!") - else: - print("WARNING: Current font may not support mixed-case rendering properly!") - print("This could be the cause of uppercase-only text rendering") - - # ============================================================================ - # TEXT FITTING SYSTEM - # ============================================================================ - - # ============================================================================ - # PREPEND/APPEND TEXT PROCESSING - # ============================================================================ - - # REMOVED: Duplicate text processing code block - already handled above - - # Calculate space occupied by absolute positioned overlays (MOVED OUT OF TEXT FITTING BLOCK) - # This is needed for both text fitting and text positioning, so it must be available regardless - overlay_occupied_areas = [] - enabled_overlays = [overlay for overlay in props.image_overlays if overlay.enabled] - absolute_overlays = [o for o in enabled_overlays if o.positioning_mode == 'ABSOLUTE' and o.image_path and os.path.exists(o.image_path)] - - for overlay in absolute_overlays: - try: - # Load overlay image to get dimensions - overlay_img, error = load_overlay_image_cached(overlay.image_path, overlay.scale, scale_factor) - if overlay_img: - # Calculate absolute position - x_position = max(0.0, min(1.0, overlay.x_position)) - y_position = max(0.0, min(1.0, overlay.y_position)) - overlay_x = int(x_position * width - overlay_img.width / 2) - overlay_y = int((1.0 - y_position) * height - overlay_img.height / 2) - - # Store occupied area (x, y, width, height) - overlay_occupied_areas.append({ - 'x': overlay_x, - 'y': overlay_y, - 'width': overlay_img.width, - 'height': overlay_img.height - }) - print(f"DEBUG OVERLAY AREAS: Absolute overlay at ({overlay_x}, {overlay_y}) size {overlay_img.width}x{overlay_img.height}") - except Exception as e: - print(f"DEBUG OVERLAY AREAS: Error processing absolute overlay {overlay.name}: {e}") - - # Apply text fitting if enabled - if props.enable_text_fitting: - print(f"DEBUG TEXT FITTING: Starting text fitting process") - print(f"DEBUG TEXT FITTING: Canvas size: {width}x{height}") - print(f"DEBUG TEXT FITTING: Initial font size: {font_size_pixels}") - print(f"DEBUG TEXT FITTING: Fitting mode: {props.text_fitting_mode}") - - # Calculate available space with margin and respect for absolute positioned overlays - margin_px_x = int((props.text_fitting_margin / 100.0) * width) - margin_px_y = int((props.text_fitting_margin / 100.0) * height) - base_available_width = width - (2 * margin_px_x) - base_available_height = height - (2 * margin_px_y) - - - # Calculate effective available area by finding the largest contiguous rectangle - # that doesn't overlap with any absolute positioned overlays (use already calculated overlay_occupied_areas) - available_width, available_height = calculate_available_text_area( - base_available_width, base_available_height, - overlay_occupied_areas, - margin_px_x, margin_px_y, width, height - ) - - print(f"DEBUG TEXT FITTING: Base available space: {base_available_width}x{base_available_height} (margin: {margin_px_x}x{margin_px_y})") - print(f"DEBUG TEXT FITTING: Final available space (after overlay exclusion): {available_width}x{available_height}") - - # Apply scale factor to min/max font sizes - min_font_size_scaled = max(4, int(props.min_font_size * scale_factor)) - max_font_size_scaled = max(min_font_size_scaled, int(props.max_font_size * scale_factor)) - - # Binary search for optimal font size - def calculate_text_dimensions(test_font_size): - """Calculate text dimensions for a given font size""" - try: - test_font, _ = load_font_at_size(test_font_size) - temp_draw = ImageDraw.Draw(Image.new('RGBA', (100, 100))) - - if props.prepend_append_layout == 'VERTICAL' and (prepend_text or append_text): - # Calculate vertical layout dimensions - total_width = 0 - total_height = 0 - - if prepend_text: - prepend_bbox = temp_draw.textbbox((0, 0), prepend_text, font=test_font) - prepend_width = prepend_bbox[2] - prepend_bbox[0] - prepend_height = prepend_bbox[3] - prepend_bbox[1] - total_width = max(total_width, prepend_width) - total_height += prepend_height + int((props.prepend_margin / 100.0) * test_font_size) - - main_bbox = temp_draw.textbbox((0, 0), main_text, font=test_font) - main_width = main_bbox[2] - main_bbox[0] - main_height = main_bbox[3] - main_bbox[1] - total_width = max(total_width, main_width) - total_height += main_height - - if append_text: - append_bbox = temp_draw.textbbox((0, 0), append_text, font=test_font) - append_width = append_bbox[2] - append_bbox[0] - append_height = append_bbox[3] - append_bbox[1] - total_width = max(total_width, append_width) - total_height += int((props.append_margin / 100.0) * test_font_size) + append_height - - return total_width, total_height - else: - # Calculate horizontal layout dimensions - bbox = temp_draw.textbbox((0, 0), text, font=test_font) - return bbox[2] - bbox[0], bbox[3] - bbox[1] - except Exception as e: - print(f"DEBUG TEXT FITTING: Error calculating dimensions: {e}") - # Return fallback dimensions - return int(len(text) * test_font_size * 0.6), test_font_size - - # Binary search for optimal font size - low_size = min_font_size_scaled - high_size = max_font_size_scaled - optimal_size = font_size_pixels - - print(f"DEBUG TEXT FITTING: Binary search range: {low_size} to {high_size}") - - try: - for iteration in range(20): # Limit iterations to prevent infinite loops - mid_size = (low_size + high_size) // 2 - if mid_size == low_size or mid_size == high_size: - break - - test_width, test_height = calculate_text_dimensions(mid_size) - - fits_criteria = False - if props.text_fitting_mode == 'FIT_WIDTH': - fits_criteria = test_width <= available_width - elif props.text_fitting_mode == 'FIT_HEIGHT': - fits_criteria = test_height <= available_height - elif props.text_fitting_mode == 'FIT_BOTH': - fits_criteria = test_width <= available_width and test_height <= available_height - elif props.text_fitting_mode == 'FIT_CONTAIN': - fits_criteria = test_width <= available_width and test_height <= available_height - - print(f"DEBUG TEXT FITTING: Iteration {iteration}: size={mid_size}, dims={test_width}x{test_height}, fits={fits_criteria}") - - if fits_criteria: - optimal_size = mid_size - low_size = mid_size - else: - high_size = mid_size - - # Apply the optimal font size - if optimal_size != font_size_pixels: - font_size_pixels = optimal_size - font, font_loaded = load_font_at_size(font_size_pixels) - print(f"DEBUG TEXT FITTING: Applied optimal font size: {font_size_pixels}") - else: - print(f"DEBUG TEXT FITTING: No adjustment needed, keeping original size: {font_size_pixels}") - except Exception as e: - print(f"DEBUG TEXT FITTING: Error in binary search: {e}") - print("DEBUG TEXT FITTING: Continuing with original font size") - - # Handle vertical layout separately if needed - if props.prepend_append_layout == 'VERTICAL' and (prepend_text or append_text): - # For vertical layout, we need to render each text part separately - # and position them vertically with proper margins - - # Calculate dimensions for each text part - main_bbox = draw.textbbox((0, 0), main_text, font=font) - main_width = max(1, main_bbox[2] - main_bbox[0]) - main_height = max(1, main_bbox[3] - main_bbox[1]) - - total_width = main_width - total_height = main_height - text_elements = [(main_text, main_width, main_height)] - - if prepend_text: - prepend_bbox = draw.textbbox((0, 0), prepend_text, font=font) - prepend_width = max(1, prepend_bbox[2] - prepend_bbox[0]) - prepend_height = max(1, prepend_bbox[3] - prepend_bbox[1]) - total_width = max(total_width, prepend_width) - total_height += prepend_height + prepend_margin_px - text_elements.insert(0, (prepend_text, prepend_width, prepend_height)) - - if append_text: - append_bbox = draw.textbbox((0, 0), append_text, font=font) - append_width = max(1, append_bbox[2] - append_bbox[0]) - append_height = max(1, append_bbox[3] - append_bbox[1]) - total_width = max(total_width, append_width) - total_height += append_height + append_margin_px - text_elements.append((append_text, append_width, append_height)) - - text_width = total_width - text_height = total_height - - print(f"DEBUG VERTICAL LAYOUT - Total dimensions: {text_width}x{text_height}") - print(f"DEBUG VERTICAL LAYOUT - Text elements: {len(text_elements)}") - - else: - # Standard bbox calculation for horizontal layout or single text - try: - bbox = draw.textbbox((0, 0), text, font=font) - text_width = max(1, bbox[2] - bbox[0]) - text_height = max(1, bbox[3] - bbox[1]) - except (OSError, ZeroDivisionError, ValueError) as e: - print(f"Error getting text bbox: {e}") - text_width = int(len(text) * font_size_pixels * 0.6) - text_height = font_size_pixels - - # ============================================================================ - # ENHANCED POSITIONING SYSTEM - Replaces hardcoded vertical centering - # ============================================================================ - - # Calculate prepend width for text positioning adjustment - prepend_width = 0 - enabled_overlays = [overlay for overlay in props.image_overlays if overlay.enabled] - prepend_overlays = [o for o in enabled_overlays if o.positioning_mode == 'PREPEND' and o.image_path and os.path.exists(o.image_path)] - - print(f"DEBUG TEXT POSITION: Calculating prepend width from {len(prepend_overlays)} prepend overlays") - - for i, overlay in enumerate(prepend_overlays): - try: - # Load overlay image to get dimensions using the cached loader - overlay_img, error = load_overlay_image_cached(overlay.image_path, overlay.scale, adjusted_scale_factor) - if overlay_img: - # Add image width plus percentage-based spacing - prepend_width += overlay_img.width - - # Add text spacing for the first overlay, image spacing for subsequent ones - # Both spacings are calculated as percentage of canvas width, not font size - if i == 0: - text_spacing_px = max(0, (overlay.text_spacing / 100.0) * width) - prepend_width += text_spacing_px - print(f"DEBUG TEXT POSITION: Prepend overlay {i} adds {overlay_img.width}px + {text_spacing_px}px text spacing (percentage-based)") - else: - image_spacing_px = max(0, (overlay.image_spacing / 100.0) * width) - prepend_width += image_spacing_px - print(f"DEBUG TEXT POSITION: Prepend overlay {i} adds {overlay_img.width}px + {image_spacing_px}px image spacing (percentage-based)") - - print(f"DEBUG TEXT POSITION: Total prepend width: {prepend_width}px") - else: - print(f"DEBUG TEXT POSITION: Warning - Could not load prepend overlay: {error}") - except Exception as e: - print(f"DEBUG TEXT POSITION: Error calculating prepend width for overlay {i}: {e}") - - print(f"DEBUG TEXT POSITION: Final prepend width calculated: {prepend_width}px (percentage-based spacing)") - - # Use enhanced positioning system with overlay avoidance and prepend width - x, y = calculate_text_position(props, width, height, text_width, text_height, overlay_occupied_areas, scale_factor, prepend_width) - - # Apply legacy text_align for backward compatibility if using MIDDLE_CENTER anchor - if props.anchor_point == 'MIDDLE_CENTER' and props.position_mode == 'PRESET': - if props.text_align == 'center': - x = (width - text_width) // 2 - elif props.text_align == 'right': - x = width - text_width - padding - elif props.text_align == 'left': - x = padding - - # Apply offsets even in legacy mode - x += props.offset_x - y += props.offset_y - - # Calculate text bounds for positioning relative overlays (FIXED BUG #1) - # Now that font, x, y, and text variables are properly defined, we can safely calculate text_bbox - if prepend_overlays or append_overlays: - if props.prepend_append_layout == 'VERTICAL' and (prepend_text or append_text): - # For vertical layout, use the total calculated dimensions - text_bbox = (x, y, x + text_width, y + text_height) - else: - # For horizontal layout, calculate bbox using the combined text - try: - # Try new textbbox method (PIL 8.0+) - temp_draw = ImageDraw.Draw(Image.new('RGBA', (100, 100))) - bbox = temp_draw.textbbox((0, 0), text, font=font) - actual_text_width = bbox[2] - bbox[0] - actual_text_height = bbox[3] - bbox[1] - text_bbox = (x, y, x + actual_text_width, y + actual_text_height) - except AttributeError: - # Fallback to deprecated textsize method - temp_draw = ImageDraw.Draw(Image.new('RGBA', (100, 100))) - actual_text_width, actual_text_height = temp_draw.textsize(text, font=font) - text_bbox = (x, y, x + actual_text_width, y + actual_text_height) - else: - # No relative overlays, set bbox to None - text_bbox = None - - # Draw text with stroke support - text_color = tuple(int(c * 255) for c in props.text_color[:3]) - if len(props.text_color) > 3: - text_color += (int(props.text_color[3] * 255),) - - # Stroke rendering helper function with validation and error handling - def draw_text_with_stroke(draw_obj, text_str, pos_x, pos_y, font_obj, text_col, stroke_enabled, stroke_width, stroke_color, stroke_pos): - """Draw text with optional stroke""" - try: - # Validation and bounds checking - if not stroke_enabled or stroke_width <= 0 or not text_str.strip(): - # No stroke or empty text, draw normally - draw_obj.text((pos_x, pos_y), text_str, fill=text_col, font=font_obj) - return - - # Validate stroke width bounds (0-20 pixels as per property definition) - stroke_width = max(0, min(20, int(stroke_width))) - if stroke_width == 0: - draw_obj.text((pos_x, pos_y), text_str, fill=text_col, font=font_obj) - return - - # Validate stroke color - if not stroke_color or len(stroke_color) < 3: - print(f"[Stroke] Warning: Invalid stroke color, using black") - stroke_color = (0.0, 0.0, 0.0, 1.0) - - # Convert stroke color with validation - stroke_col = [] - for i, c in enumerate(stroke_color[:4]): - val = max(0.0, min(1.0, float(c))) # Clamp to 0-1 range - stroke_col.append(int(val * 255)) - stroke_col = tuple(stroke_col[:3] + ([stroke_col[3]] if len(stroke_col) > 3 else [255])) - - # Validate stroke position - if stroke_pos not in ['OUTER', 'INNER', 'CENTER']: - print(f"[Stroke] Warning: Invalid stroke position '{stroke_pos}', using OUTER") - stroke_pos = 'OUTER' - - # Draw stroke based on position - if stroke_pos == 'OUTER': - # Outer stroke: draw stroke offsets around text - for dx in range(-stroke_width, stroke_width + 1): - for dy in range(-stroke_width, stroke_width + 1): - if dx*dx + dy*dy <= stroke_width*stroke_width: # Circular stroke - draw_obj.text((pos_x + dx, pos_y + dy), text_str, fill=stroke_col, font=font_obj) - # Draw text on top - draw_obj.text((pos_x, pos_y), text_str, fill=text_col, font=font_obj) - - elif stroke_pos == 'CENTER': - # Center stroke: similar to outer but smaller offset - half_stroke = max(1, stroke_width // 2) - for dx in range(-half_stroke, half_stroke + 1): - for dy in range(-half_stroke, half_stroke + 1): - if dx*dx + dy*dy <= half_stroke*half_stroke: - draw_obj.text((pos_x + dx, pos_y + dy), text_str, fill=stroke_col, font=font_obj) - # Draw text on top - draw_obj.text((pos_x, pos_y), text_str, fill=text_col, font=font_obj) - - elif stroke_pos == 'INNER': - # Inner stroke: draw text first, then smaller inner stroke - draw_obj.text((pos_x, pos_y), text_str, fill=text_col, font=font_obj) - # Create inner stroke effect by drawing smaller stroke inside - inner_offset = max(1, stroke_width // 3) - for dx in range(-inner_offset, inner_offset + 1): - for dy in range(-inner_offset, inner_offset + 1): - if dx*dx + dy*dy <= inner_offset*inner_offset and (dx != 0 or dy != 0): - # Use stroke color with some transparency for inner effect - inner_col = stroke_col[:3] - if len(stroke_col) > 3: - inner_col += (int(stroke_col[3] * 0.7),) - else: - inner_col += (int(255 * 0.7),) - draw_obj.text((pos_x + dx, pos_y + dy), text_str, fill=inner_col, font=font_obj) - - except Exception as e: - print(f"[Stroke] Error drawing stroke: {e}") - # Fallback to normal text drawing - try: - draw_obj.text((pos_x, pos_y), text_str, fill=text_col, font=font_obj) - except Exception as fallback_error: - print(f"[Stroke] Error in fallback text drawing: {fallback_error}") - - # Handle vertical layout rendering with stroke - if props.prepend_append_layout == 'VERTICAL' and (prepend_text or append_text): - print(f"DEBUG VERTICAL LAYOUT - Rendering vertical text layout with stroke: {props.enable_stroke}") - - # Calculate starting Y position for vertical stack - current_y = y - - # Render prepend text if exists - if prepend_text: - print(f"DEBUG VERTICAL LAYOUT - Drawing prepend text: '{prepend_text}' at y={current_y}") - draw_text_with_stroke(draw, prepend_text, x, current_y, font, text_color, - props.enable_stroke, props.stroke_width, props.stroke_color, props.stroke_position) - prepend_bbox = draw.textbbox((0, 0), prepend_text, font=font) - prepend_height = prepend_bbox[3] - prepend_bbox[1] - current_y += prepend_height + prepend_margin_px - - # Render main text - print(f"DEBUG VERTICAL LAYOUT - Drawing main text: '{main_text}' at y={current_y}") - draw_text_with_stroke(draw, main_text, x, current_y, font, text_color, - props.enable_stroke, props.stroke_width, props.stroke_color, props.stroke_position) - main_bbox = draw.textbbox((0, 0), main_text, font=font) - main_height = main_bbox[3] - main_bbox[1] - current_y += main_height + append_margin_px - - # Render append text if exists - if append_text: - print(f"DEBUG VERTICAL LAYOUT - Drawing append text: '{append_text}' at y={current_y}") - draw_text_with_stroke(draw, append_text, x, current_y, font, text_color, - props.enable_stroke, props.stroke_width, props.stroke_color, props.stroke_position) - - print(f"DEBUG VERTICAL LAYOUT - Vertical text rendering with stroke completed") - - # ============================================================================ - # OVERLAY PROCESSING - MOVED TO CORRECT POSITION AFTER TEXT POSITIONING - # ============================================================================ - - # Now that text positioning is complete and text_bbox is calculated, - # process overlays that depend on text position (PREPEND/APPEND modes) - - # Apply overlays using cached loading with comprehensive error handling - for overlay_data in all_overlays_with_mode: - overlay = overlay_data[0] - mode = overlay_data[1] - - # Use cached loading for better performance and error handling with adjusted scale factor - overlay_img, error = load_overlay_image_cached(overlay.image_path, overlay.scale, adjusted_scale_factor) - - if overlay_img is None: - print(f"Skipping overlay {overlay.name}: {error}") - continue - - try: - # Apply rotation with error handling - if overlay.rotation != 0: - # EDGE CASE: Validate rotation range - rotation_angle = overlay.rotation % 360 # Normalize to 0-360 - if abs(rotation_angle) > 0.1: # Only rotate if meaningful angle - overlay_img = overlay_img.rotate(rotation_angle, expand=True) - - # Calculate position based on positioning mode - if mode == 'ABSOLUTE': - # Use existing absolute positioning (backward compatibility) - # EDGE CASE: Ensure position values are valid - x_position = max(0.0, min(1.0, overlay.x_position)) - y_position = max(0.0, min(1.0, overlay.y_position)) - x_pos = int(x_position * width - overlay_img.width / 2) - y_pos = int((1.0 - y_position) * height - overlay_img.height / 2) - elif mode == 'PREPEND': - if text_bbox: - # Calculate text spacing in pixels from percentage of canvas width (not font size) - text_spacing_px = int((overlay.text_spacing / 100.0) * width) - # Position to the left of text bounds with proper text spacing - x_pos = int(text_bbox[0] - text_spacing_px - overlay_img.width) - y_pos = int(text_bbox[1] + (text_bbox[3] - text_bbox[1] - overlay_img.height) / 2) - print(f"DEBUG PREPEND: Positioning overlay at ({x_pos}, {y_pos}) with text_spacing={text_spacing_px}px (percentage-based) relative to text_bbox {text_bbox}") - else: - # No text bbox available, position at left side of canvas - x_pos = 10 # Small margin from left edge - y_pos = int((height - overlay_img.height) / 2) # Vertically centered - print(f"DEBUG PREPEND: No text_bbox available, positioning at left side: ({x_pos}, {y_pos})") - elif mode == 'APPEND': - if text_bbox: - # Calculate text spacing in pixels from percentage of canvas width (not font size) - text_spacing_px = int((overlay.text_spacing / 100.0) * width) - # Position to the right of text bounds with proper text spacing - x_pos = int(text_bbox[2] + text_spacing_px) - y_pos = int(text_bbox[1] + (text_bbox[3] - text_bbox[1] - overlay_img.height) / 2) - print(f"DEBUG APPEND: Positioning overlay at ({x_pos}, {y_pos}) with text_spacing={text_spacing_px}px (percentage-based) relative to text_bbox {text_bbox}") - else: - # No text bbox available, position at right side of canvas - x_pos = int(width - overlay_img.width - 10) # Small margin from right edge - y_pos = int((height - overlay_img.height) / 2) # Vertically centered - print(f"DEBUG APPEND: No text_bbox available, positioning at right side: ({x_pos}, {y_pos})") - else: - # Fallback to absolute positioning - x_position = max(0.0, min(1.0, overlay.x_position)) - y_position = max(0.0, min(1.0, overlay.y_position)) - x_pos = int(x_position * width - overlay_img.width / 2) - y_pos = int((1.0 - y_position) * height - overlay_img.height / 2) - - # EDGE CASE: Ensure positions are within bounds and handle negative dimensions gracefully - if overlay_img.width <= 0 or overlay_img.height <= 0: - print(f"Warning: Overlay {overlay.name} has invalid dimensions: {overlay_img.width}x{overlay_img.height}") - continue - - x_pos = max(0, min(x_pos, width - overlay_img.width)) - y_pos = max(0, min(y_pos, height - overlay_img.height)) - - # EDGE CASE: Skip overlay if it's completely outside the canvas - if x_pos >= width or y_pos >= height or x_pos + overlay_img.width <= 0 or y_pos + overlay_img.height <= 0: - print(f"Warning: Overlay {overlay.name} is positioned outside canvas bounds, skipping") - continue - - elements.append((overlay.z_index, 'image', overlay_img, x_pos, y_pos)) - print(f"Successfully added overlay {overlay.name} ({mode}) at z-index {overlay.z_index}, pos ({x_pos}, {y_pos})") - - except Exception as e: - print(f"Error processing overlay {overlay.name}: {e}") - import traceback - traceback.print_exc() - - # Handle vertical layout rendering with stroke - if props.prepend_append_layout == 'VERTICAL' and (prepend_text or append_text): - print(f"DEBUG VERTICAL LAYOUT - Rendering vertical text layout with stroke: {props.enable_stroke}") - - # Calculate starting Y position for vertical stack - current_y = y - - # Render prepend text if exists - if prepend_text: - print(f"DEBUG VERTICAL LAYOUT - Drawing prepend text: '{prepend_text}' at y={current_y}") - draw_text_with_stroke(draw, prepend_text, x, current_y, font, text_color, - props.enable_stroke, props.stroke_width, props.stroke_color, props.stroke_position) - prepend_bbox = draw.textbbox((0, 0), prepend_text, font=font) - prepend_height = prepend_bbox[3] - prepend_bbox[1] - current_y += prepend_height + prepend_margin_px - - # Render main text - print(f"DEBUG VERTICAL LAYOUT - Drawing main text: '{main_text}' at y={current_y}") - draw_text_with_stroke(draw, main_text, x, current_y, font, text_color, - props.enable_stroke, props.stroke_width, props.stroke_color, props.stroke_position) - main_bbox = draw.textbbox((0, 0), main_text, font=font) - main_height = main_bbox[3] - main_bbox[1] - current_y += main_height + append_margin_px - - # Render append text if exists - if append_text: - print(f"DEBUG VERTICAL LAYOUT - Drawing append text: '{append_text}' at y={current_y}") - draw_text_with_stroke(draw, append_text, x, current_y, font, text_color, - props.enable_stroke, props.stroke_width, props.stroke_color, props.stroke_position) - - print(f"DEBUG VERTICAL LAYOUT - Vertical text rendering with stroke completed") - - # MULTILINE TEXT PROCESSING with stroke support - elif props.enable_multiline: - print("DEBUG MULTILINE: Processing multiline text with stroke support") - print(f"DEBUG MULTILINE: Text image mode: {text_img.mode}, size: {text_img.size}") - # Process multiline text - lines = process_multiline_text(text, font, props) - print(f"DEBUG MULTILINE: Processed into {len(lines)} lines: {lines}") - - # DEBUG: Check text_img before multiline drawing - pre_draw_pixels = list(text_img.getdata()) - pre_draw_non_transparent = sum(1 for pixel in pre_draw_pixels if len(pixel) >= 4 and pixel[3] > 0) - print(f"DEBUG MULTILINE: Text image has {pre_draw_non_transparent} non-transparent pixels BEFORE multiline drawing") - - # Draw multiline text with stroke support - draw_multiline_text_with_stroke(draw, lines, font, text_color, props, width, height) - - # DEBUG: Check text_img after multiline drawing - post_draw_pixels = list(text_img.getdata()) - post_draw_non_transparent = sum(1 for pixel in post_draw_pixels if len(pixel) >= 4 and pixel[3] > 0) - print(f"DEBUG MULTILINE: Text image has {post_draw_non_transparent} non-transparent pixels AFTER multiline drawing") - print("DEBUG MULTILINE: Multiline text rendering with stroke completed") - - else: - # Standard horizontal rendering with stroke support - print(f"DEBUG UPPERCASE ISSUE - About to draw text with stroke: '{text}' with font: {font}") - draw_text_with_stroke(draw, text, x, y, font, text_color, - props.enable_stroke, props.stroke_width, props.stroke_color, props.stroke_position) - print(f"Text drawn at ({x}, {y}) with stroke: {props.enable_stroke}") - print(f"DEBUG UPPERCASE ISSUE - Text drawing with stroke completed") - - # CRITICAL FIX: Move shadow/glow generation AFTER text rendering - # The issue was that shadow/glow effects were being created before text was fully rendered - # This caused invisible shadows because they were created from empty text images - - # Apply blur to main text if enabled (before shadow/glow generation) - if props.enable_blur and props.blur_radius > 0: - print(f"DEBUG BLUR: Applying blur effect - radius: {props.blur_radius}") - text_img = text_img.filter(ImageFilter.GaussianBlur(radius=props.blur_radius)) - print(f"DEBUG BLUR: Blur effect applied successfully") - - # Now generate shadow/glow effects AFTER text is fully rendered - shadow_glow_img = None - if props.enable_shadow or props.enable_glow: - print(f"DEBUG SHADOW/GLOW: Generating shadow/glow effects - shadow: {props.enable_shadow}, glow: {props.enable_glow}") - shadow_glow_img = Image.new('RGBA', (width, height), (0, 0, 0, 0)) - - # DEBUG: Check source text_img after full rendering - source_pixels = list(text_img.getdata()) - source_non_transparent = sum(1 for pixel in source_pixels if len(pixel) >= 4 and pixel[3] > 0) - print(f"DEBUG SHADOW: Source text_img has {source_non_transparent} non-transparent pixels (after full text rendering)") - - if source_non_transparent == 0: - print(f"WARNING: Source text image is still empty after full rendering! Skipping shadow/glow.") - shadow_glow_img = None - else: - print(f"SUCCESS: Source text image has content, proceeding with shadow/glow generation") - - # Apply effects in rendering order: shadow first, then glow - if shadow_glow_img and props.enable_shadow: - print(f"DEBUG SHADOW: Creating drop shadow - offset: ({props.shadow_offset_x}, {props.shadow_offset_y}), blur: {props.shadow_blur}") - - # Create shadow by offsetting and blurring the text - shadow_img = text_img.copy() - - # Apply shadow color to the text (keep alpha channel from original) - shadow_pixels = list(shadow_img.getdata()) - shadow_color_rgb = tuple(int(c * 255) for c in props.shadow_color[:3]) - - # Enhanced shadow intensity calculation - base_alpha_multiplier = props.shadow_color[3] if len(props.shadow_color) > 3 else 1.0 - shadow_spread = getattr(props, 'shadow_spread', 1.0) # Fallback for compatibility - # Apply shadow spread to the offset distances - shadow_offset_x_spread = int(props.shadow_offset_x * shadow_spread) - shadow_offset_y_spread = int(props.shadow_offset_y * shadow_spread) - - # Keep the original alpha multiplier (no intensity-based changes) - final_alpha_multiplier = base_alpha_multiplier - - # DEBUG: Log shadow color and alpha values - print(f"DEBUG SHADOW: Shadow color RGB: {shadow_color_rgb}") - print(f"DEBUG SHADOW: Base alpha multiplier: {base_alpha_multiplier}") - print(f"DEBUG SHADOW: Shadow spread: {shadow_spread}") - print(f"DEBUG SHADOW: Shadow offset X spread: {shadow_offset_x_spread}") - print(f"DEBUG SHADOW: Shadow offset Y spread: {shadow_offset_y_spread}") - print(f"DEBUG SHADOW: Final alpha multiplier: {final_alpha_multiplier}") - print(f"DEBUG SHADOW: Original shadow_img size: {shadow_img.size}") - print(f"DEBUG SHADOW: Original shadow_img mode: {shadow_img.mode}") - - new_shadow_pixels = [] - modified_pixel_count = 0 - for pixel in shadow_pixels: - if len(pixel) >= 4: # RGBA - r, g, b, a = pixel - if a > 0: # Only modify non-transparent pixels - # Apply shadow color and enhanced alpha with intensity - new_a = int(min(255, a * final_alpha_multiplier)) - new_shadow_pixels.append((*shadow_color_rgb, new_a)) - modified_pixel_count += 1 - else: - new_shadow_pixels.append(pixel) - else: # RGB - new_shadow_pixels.append((*shadow_color_rgb, 255)) - modified_pixel_count += 1 - - print(f"DEBUG SHADOW: Modified {modified_pixel_count} pixels out of {len(shadow_pixels)}") - shadow_img.putdata(new_shadow_pixels) - - # Apply blur if specified - if props.shadow_blur > 0: - shadow_img = shadow_img.filter(ImageFilter.GaussianBlur(radius=props.shadow_blur)) - - # Create offset shadow image - offset_shadow_img = Image.new('RGBA', (width, height), (0, 0, 0, 0)) - - # Calculate offset positions with spread applied - offset_x = shadow_offset_x_spread - offset_y = shadow_offset_y_spread - - print(f"DEBUG SHADOW: Offset position: ({offset_x}, {offset_y})") - print(f"DEBUG SHADOW: Shadow image dimensions: {shadow_img.size}") - print(f"DEBUG SHADOW: Canvas dimensions: {width}x{height}") - - # Paste shadow at offset position - try: - offset_shadow_img.paste(shadow_img, (offset_x, offset_y), shadow_img) - print(f"DEBUG SHADOW: Successfully pasted shadow at offset ({offset_x}, {offset_y})") - except Exception as e: - print(f"DEBUG SHADOW: Error pasting shadow: {e}") - # Fallback to center if offset fails - offset_shadow_img.paste(shadow_img, (0, 0), shadow_img) - print(f"DEBUG SHADOW: Used fallback position (0, 0)") - - # Check if offset_shadow_img has any non-transparent pixels - offset_shadow_pixels = list(offset_shadow_img.getdata()) - non_transparent_count = sum(1 for pixel in offset_shadow_pixels if len(pixel) >= 4 and pixel[3] > 0) - print(f"DEBUG SHADOW: Offset shadow has {non_transparent_count} non-transparent pixels out of {len(offset_shadow_pixels)}") - - # Composite shadow with shadow_glow_img - shadow_glow_img = Image.alpha_composite(shadow_glow_img, offset_shadow_img) - - # Check final shadow_glow_img - final_shadow_pixels = list(shadow_glow_img.getdata()) - final_non_transparent_count = sum(1 for pixel in final_shadow_pixels if len(pixel) >= 4 and pixel[3] > 0) - print(f"DEBUG SHADOW: Final shadow_glow_img has {final_non_transparent_count} non-transparent pixels") - print(f"DEBUG SHADOW: Drop shadow applied successfully") - - if props.enable_glow: - print(f"DEBUG GLOW: Creating glow effect - blur: {props.glow_blur}") - - # Create glow by blurring the text without offset - glow_img = text_img.copy() - - # Apply glow color to the text - glow_pixels = list(glow_img.getdata()) - glow_color_rgb = tuple(int(c * 255) for c in props.glow_color[:3]) - glow_alpha_multiplier = props.glow_color[3] if len(props.glow_color) > 3 else 1.0 - - new_glow_pixels = [] - for pixel in glow_pixels: - if len(pixel) >= 4: # RGBA - r, g, b, a = pixel - if a > 0: # Only modify non-transparent pixels - # Apply glow color and alpha - new_a = int(a * glow_alpha_multiplier) - new_glow_pixels.append((*glow_color_rgb, new_a)) - else: - new_glow_pixels.append(pixel) - else: # RGB - new_glow_pixels.append((*glow_color_rgb, 255)) - - glow_img.putdata(new_glow_pixels) - - # Apply blur for glow effect - if props.glow_blur > 0: - glow_img = glow_img.filter(ImageFilter.GaussianBlur(radius=props.glow_blur)) - - # Composite glow with shadow_glow_img - shadow_glow_img = Image.alpha_composite(shadow_glow_img, glow_img) - print(f"DEBUG GLOW: Glow effect applied successfully") - - # Add shadow/glow to elements list first (behind text, z-index -1) - if shadow_glow_img: - elements.append((-1, 'shadow_glow', shadow_glow_img, 0, 0)) - print(f"DEBUG SHADOW/GLOW: Added shadow/glow to elements list at z-index -1") - - # Add text to elements list (blur was already applied before shadow/glow generation) - elements.append((0, 'text', text_img, 0, 0)) - - # Sort elements by z-index - elements.sort(key=lambda x: x[0]) - - # Composite all elements - final_img = base_img.copy() - for z_index, element_type, element_img, pos_x, pos_y in elements: - print(f"DEBUG COMPOSITING: Processing element type '{element_type}' at z-index {z_index}") - if element_type == 'text': - final_img = Image.alpha_composite(final_img, element_img) - print(f"DEBUG COMPOSITING: Text element composited successfully") - elif element_type == 'image': - # Create a temporary image to paste the overlay - temp_img = Image.new('RGBA', (width, height), (0, 0, 0, 0)) - temp_img.paste(element_img, (pos_x, pos_y), element_img) - final_img = Image.alpha_composite(final_img, temp_img) - print(f"DEBUG COMPOSITING: Image element composited successfully") - elif element_type == 'shadow_glow': - # BUG FIX: Add missing handler for shadow/glow effects - final_img = Image.alpha_composite(final_img, element_img) - print(f"DEBUG COMPOSITING: Shadow/glow element composited successfully") - else: - print(f"DEBUG COMPOSITING: WARNING - Unknown element type '{element_type}' skipped!") - - print(f"Final composite image created with {len(elements)} elements") - - # Generate normal map if enabled - normal_map_img = None - if props.generate_normal_map: - print(f"[Normal Map] Generating normal map with settings: strength={props.normal_map_strength}, blur={props.normal_map_blur_radius}, invert={props.normal_map_invert}") - normal_map_img = generate_normal_map_from_alpha( - final_img, - strength=props.normal_map_strength, - blur_radius=props.normal_map_blur_radius, - invert=props.normal_map_invert - ) - if normal_map_img: - print(f"[Normal Map] Successfully generated normal map") - else: - print(f"[Normal Map] Failed to generate normal map") - - - return final_img, normal_map_img - - except Exception as e: - print(f"Error in generate_texture_image: {e}") - import traceback - traceback.print_exc() - return None, None - -def generate_preview(context): - """Generate preview texture""" - if not context or not hasattr(context, 'scene'): - print("No context or scene") - return None - - props = context.scene.text_texture_props - - # Always generate preview - removed live_preview_enabled check - - try: - # Generate at full texture size for 100% quality preview - preview_w = props.texture_width - preview_h = props.texture_height - - print(f"[TTG] Generating 100% quality preview at full resolution: {preview_w}x{preview_h}") - - print(f"Starting preview generation at {preview_w}x{preview_h} (full resolution)") - print(f"DEBUG: Texture size is {props.texture_width}x{props.texture_height}, preview at {preview_w}x{preview_h}") - - # Generate the images (diffuse and normal map) - result = generate_texture_image(props, preview_w, preview_h) - if not result: - print("Failed to generate PIL image") - return None - - # Extract diffuse image (and optionally normal map) - if isinstance(result, tuple): - img, normal_map_img = result - else: - # Backward compatibility in case function returns single image - img = result - normal_map_img = None - - if not img: - print("Failed to generate diffuse image") - return None - - # Apply preview background if needed - if props.preview_bg_type != 'transparent': - from PIL import Image, ImageDraw - - # Create background image - bg_img = Image.new('RGBA', (preview_w, preview_h)) - draw = ImageDraw.Draw(bg_img) - - if props.preview_bg_type == 'color': - # Solid color background - bg_color = tuple(int(c * 255) for c in props.preview_bg_color[:3]) - bg_color += (int(props.preview_bg_color[3] * 255),) - bg_img.paste(bg_color, (0, 0, preview_w, preview_h)) - elif props.preview_bg_type == 'gradient': - # Gradient background - color1 = tuple(int(c * 255) for c in props.preview_bg_color[:3]) - color2 = tuple(int(c * 255) for c in props.preview_bg_color2[:3]) - - for y in range(preview_h): - ratio = y / preview_h - r = int(color1[0] * (1 - ratio) + color2[0] * ratio) - g = int(color1[1] * (1 - ratio) + color2[1] * ratio) - b = int(color1[2] * (1 - ratio) + color2[2] * ratio) - draw.rectangle([(0, y), (preview_w, y+1)], fill=(r, g, b, 255)) - else: # transparent - create checkerboard - # Checkerboard pattern for transparent - checker_size = 16 - color1 = (128, 128, 128, 255) - color2 = (192, 192, 192, 255) - - for y in range(0, preview_h, checker_size): - for x in range(0, preview_w, checker_size): - color = color1 if ((x // checker_size) + (y // checker_size)) % 2 == 0 else color2 - draw.rectangle([(x, y), (min(x + checker_size, preview_w), min(y + checker_size, preview_h))], - fill=color) - - # Composite the text image over the background - bg_img.alpha_composite(img) - img = bg_img - - # Name for preview image - preview_img_name = "TextTexturePreview" - - # Remove old preview if it exists - if preview_img_name in bpy.data.images: - print("Removing old preview") - old_img = bpy.data.images[preview_img_name] - bpy.data.images.remove(old_img) - - # Create new Blender image for preview - print("Creating new Blender image") - blender_img = bpy.data.images.new(preview_img_name, preview_w, preview_h, alpha=True) - - # Convert PIL to Blender - print("Converting pixels") - pixels = [] - for y in range(preview_h): - for x in range(preview_w): - pixel = img.getpixel((x, preview_h - 1 - y)) - if len(pixel) == 4: - r, g, b, a = pixel - else: - r, g, b = pixel - a = 255 - pixels.extend([r/255.0, g/255.0, b/255.0, a/255.0]) - - print("Setting pixels") - blender_img.pixels[:] = pixels - - # Store reference to the preview image only if not in draw context - try: - props.preview_image = blender_img - print(f"Preview stored: {props.preview_image}") - except AttributeError as attr_err: - if "Writing to ID classes in this context is not allowed" in str(attr_err): - print("Cannot set preview_image during draw - will be set later") - else: - raise - - # Force UI redraw - for area in context.screen.areas: - area.tag_redraw() - - print(f"Preview generated successfully: {blender_img.name}") - return blender_img - - except Exception as e: - print(f"Preview generation error: {e}") - import traceback - traceback.print_exc() - try: - props.preview_image = None - except AttributeError as attr_err: - if "Writing to ID classes in this context is not allowed" not in str(attr_err): - raise - return None - -# Removed update_preview_enabled function - preview is always enabled - -def update_live_preview(self, context): - """Update preview when properties change""" - if not hasattr(context, 'scene'): - return - - props = context.scene.text_texture_props - - print(f"DEBUG: update_live_preview called, is_updating: {props.is_updating}") - - # Always update preview unless already updating - if not props.is_updating: - print("DEBUG: Triggering preview generation from property update") - # Use a timer to debounce rapid property changes for better performance - if hasattr(update_live_preview, '_timer'): - bpy.app.timers.unregister(update_live_preview._timer) - - def delayed_preview(): - generate_preview(context) - return None # Stop timer - - update_live_preview._timer = delayed_preview - bpy.app.timers.register(delayed_preview, first_interval=0.1) - -# ============================================================================ -# PROPERTY GROUPS -# ============================================================================ - -class TEXT_TEXTURE_ImageOverlay(PropertyGroup): - """Single image overlay item""" - name: StringProperty( - name="Image Name", - description="Name identifier for this image overlay", - default="Overlay", - update=update_live_preview - ) - - image_path: StringProperty( - name="Image File", - description="Path to image file", - subtype='FILE_PATH', - default="", - update=update_live_preview - ) - - x_position: FloatProperty( - name="X Position", - description="X position (0.0 = left, 1.0 = right)", - default=0.5, - min=0.0, - max=1.0, - update=update_live_preview - ) - - y_position: FloatProperty( - name="Y Position", - description="Y position (0.0 = bottom, 1.0 = top)", - default=0.5, - min=0.0, - max=1.0, - update=update_live_preview - ) - - scale: FloatProperty( - name="Scale", - description="Scale of the image", - default=1.0, - min=0.1, - max=5.0, - update=update_live_preview - ) - - rotation: FloatProperty( - name="Rotation", - description="Rotation in degrees", - default=0.0, - min=-360.0, - max=360.0, - update=update_live_preview - ) - - z_index: IntProperty( - name="Z-Index", - description="Layer order (0=text level, -1=below text, 1+=above text)", - default=1, - min=-10, - max=10, - update=update_live_preview - ) - - positioning_mode: EnumProperty( - name="Positioning Mode", - description="How the image overlay is positioned relative to the text", - items=[ - ('ABSOLUTE', "Absolute", "Position overlay at absolute coordinates (legacy mode)"), - ('APPEND', "Append", "Position overlay after the text content"), - ('PREPEND', "Prepend", "Position overlay before the text content") - ], - default='ABSOLUTE', - update=update_live_preview - ) - - text_spacing: FloatProperty( - name="Text Spacing (%)", - description="Spacing between text and image as percentage of canvas width (0-100%)", - default=10.0, - min=0.0, - max=100.0, - subtype='PERCENTAGE', - update=update_live_preview - ) - - image_spacing: FloatProperty( - name="Image Spacing (%)", - description="Spacing between consecutive images as percentage of canvas width (0-100%)", - default=5.0, - min=0.0, - max=100.0, - subtype='PERCENTAGE', - update=update_live_preview - ) - - enabled: BoolProperty( - name="Enabled", - description="Enable this image overlay", - default=True, - update=update_live_preview - ) - -class TEXT_TEXTURE_Preset(PropertyGroup): - """Single preset item""" - name: StringProperty( - name="Preset Name", - description="Name of the preset", - default="New Preset" - ) - - source: EnumProperty( - name="Source", - description="Where this preset is stored", - items=[ - ('BLEND_FILE', 'Blend File', 'Preset stored in the blend file (travels with the file)'), - ('PERSISTENT_FILE', 'Persistent File', 'Preset stored in persistent location (survives addon updates)'), - ('LOCAL_FILE', 'Local File', 'Preset stored locally (legacy system)') - ], - default='BLEND_FILE' - ) - -class TEXT_TEXTURE_Properties(PropertyGroup): - """Properties for text texture generation""" - - is_updating: BoolProperty(default=False) - - preview_image: PointerProperty( - type=bpy.types.Image, - name="Preview Image" - ) - - # Collapsible section expand/collapse states - expand_multiline: BoolProperty( - name="Multiline Text", - description="Show/hide Multiline Text section", - default=False - ) - - expand_image_overlays: BoolProperty( - name="Image Overlays", - description="Show/hide Image Overlays section", - default=False - ) - - expand_positioning: BoolProperty( - name="Positioning & Layout", - description="Show/hide Positioning & Layout section", - default=False - ) - - expand_font_settings: BoolProperty( - name="Font Settings", - description="Show/hide Font Settings section", - default=False - ) - - expand_preview_options: BoolProperty( - name="Preview Options", - description="Show/hide Preview Options section", - default=False - ) - - expand_presets: BoolProperty( - name="Presets", - description="Show/hide Presets section", - default=False - ) - - expand_colors: BoolProperty( - name="Colors", - description="Show/hide Colors section", - default=False - ) - - text: StringProperty( - name="Text", - description="Text to render as texture", - default="", - maxlen=1024, - update=update_live_preview - ) - - # Prepend/Append Text Properties - prepend_text: StringProperty( - name="Prepend Text", - description="Text to prepend before main text", - default="", - maxlen=512, - update=update_live_preview - ) - - append_text: StringProperty( - name="Append Text", - description="Text to append after main text", - default="", - maxlen=512, - update=update_live_preview - ) - - prepend_margin: FloatProperty( - name="Prepend Margin", - description="Margin between prepend text and main text as percentage of font size", - default=50.0, - min=0.0, - max=200.0, - precision=1, - update=update_live_preview - ) - - append_margin: FloatProperty( - name="Append Margin", - description="Margin between main text and append text as percentage of font size", - default=50.0, - min=0.0, - max=200.0, - precision=1, - update=update_live_preview - ) - - prepend_append_layout: EnumProperty( - name="Layout", - description="Layout arrangement for prepend/main/append text", - items=[ - ('HORIZONTAL', 'Horizontal', 'Arrange text horizontally: [prepend] [main] [append]'), - ('VERTICAL', 'Vertical', 'Arrange text vertically: prepend above, main center, append below'), - ], - default='HORIZONTAL', - update=update_live_preview - ) - - texture_width: IntProperty( - name="Width", - description="Texture width in pixels", - default=1024, - min=64, - max=4096, - update=update_live_preview - ) - - texture_height: IntProperty( - name="Height", - description="Texture height in pixels", - default=1024, - min=64, - max=4096, - update=update_live_preview - ) - - font_size: IntProperty( - name="Font Size", - description="Font size in pixels", - default=96, - min=8, - max=500, - update=update_live_preview - ) - - padding: IntProperty( - name="Padding", - description="Padding around text in pixels", - default=20, - min=0, - max=200, - update=update_live_preview - ) - - text_color: FloatVectorProperty( - name="Text Color", - description="Color of the text", - subtype='COLOR', - default=(1.0, 1.0, 1.0, 1.0), - size=4, - min=0.0, - max=1.0, - update=update_live_preview - ) - - background_transparent: BoolProperty( - name="Transparent Background", - description="Use transparent background", - default=True, - update=update_live_preview - ) - - background_color: FloatVectorProperty( - name="Background Color", - description="Background color (when not transparent)", - subtype='COLOR', - default=(0.0, 0.0, 0.0, 1.0), - size=4, - min=0.0, - max=1.0, - update=update_live_preview - ) - - font_path: EnumProperty( - name="Font", - description="Font to use for text rendering", - items=get_font_enum_items, - update=update_live_preview - ) - - custom_font_path: StringProperty( - name="Custom Font File", - description="Path to custom font file (.ttf, .otf)", - subtype='FILE_PATH', - default="", - update=update_live_preview - ) - - use_custom_font: BoolProperty( - name="Use Custom Font", - description="Use custom font file instead of system fonts", - default=False, - update=update_live_preview - ) - - text_align: EnumProperty( - name="Alignment", - description="Text alignment", - items=[ - ('left', 'Left', 'Align text to the left'), - ('center', 'Center', 'Center text'), - ('right', 'Right', 'Align text to the right'), - ], - default='center', - update=update_live_preview - ) - - # ============================================================================ - # ENHANCED POSITIONING & ALIGNMENT PROPERTIES - # ============================================================================ - - # 9-point anchor system - anchor_point: EnumProperty( - name="Anchor Point", - description="Text anchor position on canvas", - items=[ - ('TOP_LEFT', 'Top Left', 'Position text from top-left corner'), - ('TOP_CENTER', 'Top Center', 'Position text from top center'), - ('TOP_RIGHT', 'Top Right', 'Position text from top-right corner'), - ('MIDDLE_LEFT', 'Middle Left', 'Position text from middle-left'), - ('MIDDLE_CENTER', 'Middle Center', 'Position text from center (default)'), - ('MIDDLE_RIGHT', 'Middle Right', 'Position text from middle-right'), - ('BOTTOM_LEFT', 'Bottom Left', 'Position text from bottom-left corner'), - ('BOTTOM_CENTER', 'Bottom Center', 'Position text from bottom center'), - ('BOTTOM_RIGHT', 'Bottom Right', 'Position text from bottom-right corner'), - ], - default='MIDDLE_CENTER', - update=update_live_preview - ) - - # Position mode toggle - position_mode: EnumProperty( - name="Position Mode", - description="Choose between preset anchor positions or manual positioning", - items=[ - ('PRESET', 'Preset', 'Use anchor points with margins and offsets'), - ('MANUAL', 'Manual', 'Manually specify X,Y coordinates'), - ], - default='PRESET', - update=update_live_preview - ) - - # Percentage-based margins (0-50%) - margin_x: FloatProperty( - name="X Margin", - description="Horizontal margin from edge as percentage (0-50%)", - subtype='PERCENTAGE', - default=0.0, - min=0.0, - max=50.0, - precision=1, - update=update_live_preview - ) - - margin_y: FloatProperty( - name="Y Margin", - description="Vertical margin from edge as percentage (0-50%)", - subtype='PERCENTAGE', - default=0.0, - min=0.0, - max=50.0, - precision=1, - update=update_live_preview - ) - - # Link margins option - margins_linked: BoolProperty( - name="Link Margins", - description="Keep X and Y margins synchronized", - default=False, - update=update_live_preview - ) - - # Pixel offset fine-tuning (-200 to +200px) - offset_x: IntProperty( - name="X Offset", - description="Fine-tune X position in pixels (-200 to +200)", - default=0, - min=-200, - max=200, - update=update_live_preview - ) - - offset_y: IntProperty( - name="Y Offset", - description="Fine-tune Y position in pixels (-200 to +200)", - default=0, - min=-200, - max=200, - update=update_live_preview - ) - - # Manual positioning properties - manual_position_x: FloatProperty( - name="Manual X", - description="Manual X position as percentage (0-100%)", - subtype='PERCENTAGE', - default=50.0, - min=0.0, - max=100.0, - precision=1, - update=update_live_preview - ) - - manual_position_y: FloatProperty( - name="Manual Y", - description="Manual Y position as percentage (0-100%)", - subtype='PERCENTAGE', - default=50.0, - min=0.0, - max=100.0, - precision=1, - update=update_live_preview - ) - - manual_position_unit: EnumProperty( - name="Position Unit", - description="Units for manual positioning", - items=[ - ('PERCENTAGE', 'Percentage', 'Position as percentage of canvas'), - ('PIXELS', 'Pixels', 'Position in absolute pixels'), - ], - default='PERCENTAGE', - update=update_live_preview - ) - - # Canvas constraint option - constrain_to_canvas: BoolProperty( - name="Constrain to Canvas", - description="Keep text within canvas bounds", - default=True, - update=update_live_preview - ) - - # Visual feedback options - show_position_guides: BoolProperty( - name="Show Position Guides", - description="Display positioning guide lines in preview", - default=False, - update=update_live_preview - ) - - show_text_bounds: BoolProperty( - name="Show Text Bounds", - description="Display text bounding box in preview", - default=False, - update=update_live_preview - ) - - show_canvas_grid: BoolProperty( - name="Show Canvas Grid", - description="Display alignment grid overlay in preview", - default=False, - update=update_live_preview - ) - - grid_density: IntProperty( - name="Grid Density", - description="Number of grid divisions (4-16)", - default=8, - min=4, - max=16, - update=update_live_preview - ) - - # Removed live_preview_enabled - preview is always on - - preview_size: IntProperty( - name="Preview Size", - description="Size of the preview image (does not affect final output)", - default=512, - min=128, - max=2048, - update=update_live_preview - ) - - preview_zoom_level: FloatProperty( - name="Zoom", - description="Zoom level for preview", - default=1.0, - min=0.5, - max=4.0, - update=update_live_preview - ) - - # Preview background options - preview_bg_type: EnumProperty( - name="Preview Background", - description="Background type for preview display", - items=[ - ('transparent', 'Transparent', 'Transparent checkerboard pattern'), - ('color', 'Solid Color', 'Use a solid color background'), - ('gradient', 'Gradient', 'Use a gradient background'), - ], - default='transparent', - update=update_live_preview - ) - - preview_bg_color: FloatVectorProperty( - name="Preview BG Color", - description="Background color for preview", - subtype='COLOR', - default=(0.2, 0.2, 0.2, 1.0), - size=4, - min=0.0, - max=1.0, - update=update_live_preview - ) - - preview_bg_color2: FloatVectorProperty( - name="Preview BG Color 2", - description="Second color for gradient background", - subtype='COLOR', - default=(0.4, 0.4, 0.4, 1.0), - size=4, - min=0.0, - max=1.0, - update=update_live_preview - ) - - # Image overlays - image_overlays: CollectionProperty(type=TEXT_TEXTURE_ImageOverlay) - active_overlay_index: IntProperty(default=0) - - # Presets - presets: CollectionProperty(type=TEXT_TEXTURE_Preset) - active_preset_index: IntProperty(default=0) - - new_preset_name: StringProperty( - name="Preset Name", - description="Name for new preset (Press Enter to save)", - default="New Preset" - ) - - # Property to track when user wants to save with Enter - preset_save_requested: BoolProperty( - name="Save Requested", - description="Internal flag for Enter key save requests", - default=False - ) - - # ============================================================================ - # SHADER GENERATION PROPERTIES - # ============================================================================ - - expand_shader: BoolProperty( - name="Shader Settings", - description="Show/hide Shader Settings section", - default=False - ) - - shader_type: EnumProperty( - name="Shader Type", - description="Type of shader to generate", - items=[ - ('PRINCIPLED', 'Principled BSDF', 'Standard PBR shader with text texture'), - ('EMISSION', 'Emission', 'Emissive shader for glowing text effects'), - ('TRANSPARENT', 'Transparent', 'Transparent shader with alpha blending'), - ], - default='PRINCIPLED' - ) - - shader_connection: EnumProperty( - name="Texture Connection", - description="How to connect the texture to the shader", - items=[ - ('BASE_COLOR', 'Base Color', 'Connect texture to base color input'), - ('EMISSION', 'Emission', 'Connect texture to emission input'), - ('ALPHA', 'Alpha', 'Connect texture alpha to transparency'), - ], - default='BASE_COLOR' - ) - - use_alpha: BoolProperty( - name="Use Alpha Channel", - description="Use texture alpha channel for transparency", - default=True - ) - - emission_strength: FloatProperty( - name="Emission Strength", - description="Strength of emission shader", - default=1.0, - min=0.0, - max=10.0 - ) - - auto_assign_material: BoolProperty( - name="Auto-Assign Material", - description="Automatically assign generated material to active object", - default=True - ) - - custom_material_name: StringProperty( - name="Custom Material Name", - description="Custom name for the generated material (leave empty to use text-based naming)", - default="", - maxlen=128 - ) - - disable_shadows: BoolProperty( - name="Disable Shadows", - description="Make material not cast shadows using light path", - default=False - ) - - disable_reflections: BoolProperty( - name="Disable Reflections", - description="Make material not appear in reflections using light path", - default=False - ) - - disable_backfacing: BoolProperty( - name="Disable Backfacing", - description="Make material not render on backfacing geometry using light path", - default=False - ) - - # ============================================================================ - # NORMAL MAP GENERATION PROPERTIES - # ============================================================================ - - expand_normal_maps: BoolProperty( - name="Normal Maps", - description="Show/hide Normal Maps section", - default=False - ) - - generate_normal_map: BoolProperty( - name="Generate Normal Map", - description="Automatically generate normal map from text alpha channel", - default=False, - update=update_live_preview - ) - - normal_map_strength: FloatProperty( - name="Normal Strength", - description="Intensity of the normal map effect (higher = more pronounced)", - default=1.0, - min=0.1, - max=5.0, - precision=2, - update=update_live_preview - ) - - normal_map_blur_radius: FloatProperty( - name="Blur Radius", - description="Blur radius for smoothing normal map (0 = no blur)", - default=1.0, - min=0.0, - max=10.0, - precision=1, - update=update_live_preview - ) - - normal_map_invert: BoolProperty( - name="Invert Normal Map", - description="Invert the normal map for different height effects (emboss vs deboss)", - default=False, - update=update_live_preview - ) - - # ============================================================================ - # TEXT FITTING PROPERTIES - # ============================================================================ - - expand_text_fitting: BoolProperty( - name="Text Fitting", - description="Show/hide Text Fitting section", - default=False - ) - - enable_text_fitting: BoolProperty( - name="Enable Text Fitting", - description="Automatically adjust text size to fit within the canvas dimensions", - default=False, - update=update_live_preview - ) - - text_fitting_mode: EnumProperty( - name="Fitting Mode", - description="How to fit text within the canvas", - items=[ - ('FIT_WIDTH', 'Fit Width', 'Scale text to fit canvas width'), - ('FIT_HEIGHT', 'Fit Height', 'Scale text to fit canvas height'), - ('FIT_BOTH', 'Fit Both', 'Scale text to fit both width and height (may cause distortion)'), - ('FIT_CONTAIN', 'Fit Contain', 'Scale text to fit entirely within canvas (preserves aspect ratio)'), - ], - default='FIT_CONTAIN', - update=update_live_preview - ) - - text_fitting_margin: FloatProperty( - name="Fitting Margin", - description="Margin to leave around text when fitting (as percentage of canvas)", - default=10.0, - min=0.0, - max=50.0, - precision=1, - subtype='PERCENTAGE', - update=update_live_preview - ) - - min_font_size: IntProperty( - name="Minimum Font Size", - description="Minimum font size when fitting text (prevents text from becoming too small)", - default=8, - min=4, - max=200, - update=update_live_preview - ) - - max_font_size: IntProperty( - name="Maximum Font Size", - description="Maximum font size when fitting text (prevents text from becoming too large)", - default=500, - min=20, - max=1000, - update=update_live_preview - ) - - # ============================================================================ - # MULTILINE TEXT PROPERTIES - # ============================================================================ - - enable_multiline: BoolProperty( - name="Enable Multiline", - description="Enable multiline text rendering", - default=False, - update=update_live_preview - ) - - line_height: FloatProperty( - name="Line Height", - description="Line height multiplier (1.0 = normal, 1.5 = 150% spacing)", - default=1.2, - min=0.1, - max=5.0, - step=0.1, - precision=2, - update=update_live_preview - ) - - text_alignment: EnumProperty( - name="Text Alignment", - description="Text alignment for multiline text", - items=[ - ('LEFT', "Left", "Left align text"), - ('CENTER', "Center", "Center align text"), - ('RIGHT', "Right", "Right align text") - ], - default='CENTER', - update=update_live_preview - ) - - auto_wrap: BoolProperty( - name="Auto Wrap", - description="Automatically wrap text to fit texture width", - default=True, - update=update_live_preview - ) - - wrap_width: IntProperty( - name="Wrap Width", - description="Maximum width in pixels before text wraps (0 = use texture width)", - default=0, - min=0, - max=10000, - update=update_live_preview - ) - - max_lines: IntProperty( - name="Max Lines", - description="Maximum number of lines (0 = unlimited)", - default=0, - min=0, - max=100, - update=update_live_preview - ) - - line_spacing_pixels: IntProperty( - name="Line Spacing (px)", - description="Additional spacing between lines in pixels", - default=0, - min=-50, - max=200, - update=update_live_preview - ) - - # ============================================================================ - # STROKE SYSTEM PROPERTIES - # ============================================================================ - - expand_stroke: BoolProperty( - name="Stroke & Outlines", - description="Show/hide Stroke & Outlines section", - default=False - ) - - enable_stroke: BoolProperty( - name="Enable Stroke", - description="Enable text stroke/outline rendering", - default=False, - update=update_live_preview - ) - - stroke_width: IntProperty( - name="Stroke Width", - description="Width of the text stroke in pixels", - default=2, - min=0, - max=20, - update=update_live_preview - ) - - stroke_position: EnumProperty( - name="Stroke Position", - description="How the stroke is positioned relative to the text", - items=[ - ('OUTER', 'Outer', 'Stroke outside text (expands text bounds)'), - ('INNER', 'Inner', 'Stroke inside text (shrinks text)'), - ('CENTER', 'Center', 'Stroke centered on text edge'), - ], - default='OUTER', - update=update_live_preview - ) - - stroke_color: FloatVectorProperty( - name="Stroke Color", - description="Color of the text stroke", - subtype='COLOR', - default=(0.0, 0.0, 0.0, 1.0), - size=4, - min=0.0, - max=1.0, - update=update_live_preview - ) - - # ============================================================================ - # SHADOW/GLOW SYSTEM PROPERTIES - # ============================================================================ - - expand_shadow_glow: BoolProperty( - name="Shadow & Glow Effects", - description="Show/hide Shadow & Glow Effects section", - default=False - ) - - enable_shadow: BoolProperty( - name="Enable Drop Shadow", - description="Enable drop shadow effect", - default=False, - update=update_live_preview - ) - - shadow_offset_x: FloatProperty( - name="Shadow Offset X", - description="Horizontal offset for drop shadow", - default=8.0, - min=-100.0, - max=100.0, - step=0.1, - precision=1, - update=update_live_preview - ) - - shadow_offset_y: FloatProperty( - name="Shadow Offset Y", - description="Vertical offset for drop shadow", - default=8.0, - min=-100.0, - max=100.0, - step=0.1, - precision=1, - update=update_live_preview - ) - - shadow_blur: FloatProperty( - name="Shadow Blur", - description="Blur radius for drop shadow", - default=6.0, - min=0.0, - max=50.0, - step=0.1, - precision=1, - update=update_live_preview - ) - - shadow_color: FloatVectorProperty( - name="Shadow Color", - description="Color of drop shadow", - subtype='COLOR', - default=(0.0, 0.0, 0.0, 1.0), - size=4, - min=0.0, - max=1.0, - update=update_live_preview - ) - - shadow_spread: FloatProperty( - name="Shadow Spread", - description="Controls how far the shadow extends from the text (multiplies shadow offset)", - default=1.0, - min=0.0, - max=3.0, - precision=2, - step=0.1, - update=update_live_preview - ) - - enable_glow: BoolProperty( - name="Enable Glow", - description="Enable glow effect", - default=False, - update=update_live_preview - ) - - glow_blur: FloatProperty( - name="Glow Blur", - description="Blur radius for glow effect", - default=10.0, - min=0.0, - max=50.0, - step=0.1, - precision=1, - update=update_live_preview - ) - - glow_color: FloatVectorProperty( - name="Glow Color", - description="Color of glow effect", - subtype='COLOR', - default=(1.0, 1.0, 1.0, 0.8), - size=4, - min=0.0, - max=1.0, - update=update_live_preview - ) - - - # ============================================================================ - # BLUR PROPERTIES - # ============================================================================ - - expand_blur: BoolProperty( - name="Blur Effects", - description="Show/hide Blur Effects section", - default=False - ) - - enable_blur: BoolProperty( - name="Enable Blur", - description="Enable blur effect on main text content", - default=False, - update=update_live_preview - ) - - blur_radius: FloatProperty( - name="Blur Radius", - description="Blur radius for text content (0-50 pixels)", - default=2.0, - min=0.0, - max=50.0, - step=0.1, - precision=1, - update=update_live_preview - ) - - -# ============================================================================ -# OPERATORS -# ============================================================================ - -class TEXT_TEXTURE_OT_generate(Operator): - """Generate final texture and pack it""" - bl_idname = "text_texture.generate" - bl_label = "Generate Text Texture" - bl_description = "Generate final high-quality texture and pack it into the blend file" - bl_options = {'REGISTER', 'UNDO'} - - def execute(self, context): - try: - props = context.scene.text_texture_props - - # IMPORTANT: Generate at EXACT user-specified dimensions - final_width = props.texture_width - final_height = props.texture_height - - print(f"Generating texture at {final_width}x{final_height}px") - - # Generate at FULL resolution specified by user - result = generate_texture_image(props, final_width, final_height) - if not result: - self.report({'ERROR'}, "Failed to generate texture image") - return {'CANCELLED'} - - # Extract diffuse and normal map images - if isinstance(result, tuple): - img, normal_map_img = result - else: - # Backward compatibility - img = result - normal_map_img = None - - if not img: - self.report({'ERROR'}, "Failed to generate diffuse image") - return {'CANCELLED'} - - # Create diffuse texture - img_name = f"TextTexture_{props.text[:20].replace(' ', '_')}" - - # Remove existing diffuse texture - if img_name in bpy.data.images: - bpy.data.images.remove(bpy.data.images[img_name]) - - # Create new diffuse image with USER SPECIFIED dimensions - blender_img = bpy.data.images.new(img_name, final_width, final_height, alpha=True) - - # Convert PIL to Blender for diffuse texture - pixels = [] - for y in range(final_height): - for x in range(final_width): - r, g, b, a = img.getpixel((x, final_height - 1 - y)) - pixels.extend([r/255.0, g/255.0, b/255.0, a/255.0]) - - blender_img.pixels = pixels - blender_img.pack() # PACK FINAL IMAGE - - # Create normal map texture if enabled and generated - normal_map_blender_img = None - if normal_map_img and props.generate_normal_map: - normal_map_name = f"TextTexture_Normal_{props.text[:20].replace(' ', '_')}" - - # Remove existing normal map texture - if normal_map_name in bpy.data.images: - bpy.data.images.remove(bpy.data.images[normal_map_name]) - - # Create new normal map image - normal_map_blender_img = bpy.data.images.new(normal_map_name, final_width, final_height, alpha=False) - normal_map_blender_img.colorspace_settings.name = 'Non-Color' # Important for normal maps - - # Convert PIL to Blender for normal map - normal_pixels = [] - for y in range(final_height): - for x in range(final_width): - r, g, b = normal_map_img.getpixel((x, final_height - 1 - y)) - normal_pixels.extend([r/255.0, g/255.0, b/255.0, 1.0]) # Alpha always 1.0 for normal maps - - normal_map_blender_img.pixels = normal_pixels - normal_map_blender_img.pack() # PACK NORMAL MAP - print(f"[Normal Map] Created and packed normal map texture: {normal_map_name}") - - # Add to shader - if context.space_data and context.space_data.type == 'NODE_EDITOR': - if context.space_data.tree_type == 'ShaderNodeTree': - material = context.space_data.id - if material and material.use_nodes: - nodes = material.node_tree.nodes - - # Add diffuse texture node - img_node = nodes.new(type='ShaderNodeTexImage') - img_node.image = blender_img - img_node.location = (0, 0) - img_node.select = True - nodes.active = img_node - - # Add normal map node if normal map was created - if normal_map_blender_img: - normal_node = nodes.new(type='ShaderNodeTexImage') - normal_node.image = normal_map_blender_img - normal_node.location = (0, -300) - normal_node.label = "Normal Map" - print(f"[Normal Map] Added normal map node to shader editor") - - # Provide comprehensive feedback - if normal_map_blender_img: - self.report({'INFO'}, f"Generated and packed: {img_name} + {normal_map_blender_img.name} ({final_width}x{final_height}px)") - else: - self.report({'INFO'}, f"Generated and packed: {img_name} ({final_width}x{final_height}px)") - return {'FINISHED'} - - except Exception as e: - self.report({'ERROR'}, f"Failed: {str(e)}") - return {'CANCELLED'} - -class TEXT_TEXTURE_OT_generate_shader(Operator): - """Generate complete shader with text texture""" - bl_idname = "text_texture.generate_shader" - bl_label = "Generate Shader" - bl_description = "Generate a complete material shader with the text texture" - bl_options = {'REGISTER', 'UNDO'} - - def execute(self, context): - try: - props = context.scene.text_texture_props - - # Generate texture first - final_width = props.texture_width - final_height = props.texture_height - - print(f"Generating shader with texture at {final_width}x{final_height}px") - - result = generate_texture_image(props, final_width, final_height) - if not result: - self.report({'ERROR'}, "Failed to generate texture image") - return {'CANCELLED'} - - # Extract diffuse and normal map images - if isinstance(result, tuple): - img, normal_map_img = result - else: - # Backward compatibility - img = result - normal_map_img = None - - if not img: - self.report({'ERROR'}, "Failed to generate diffuse image") - return {'CANCELLED'} - - # Create diffuse texture name - img_name = f"TextTexture_{props.text[:20].replace(' ', '_')}" - - # Remove existing diffuse texture if it exists - if img_name in bpy.data.images: - bpy.data.images.remove(bpy.data.images[img_name]) - - # Create new diffuse texture - blender_img = bpy.data.images.new(img_name, final_width, final_height, alpha=True) - - # Convert PIL to Blender for diffuse texture - pixels = [] - for y in range(final_height): - for x in range(final_width): - r, g, b, a = img.getpixel((x, final_height - 1 - y)) - pixels.extend([r/255.0, g/255.0, b/255.0, a/255.0]) - - blender_img.pixels = pixels - blender_img.pack() - - # Create normal map texture if enabled and generated - normal_map_blender_img = None - if normal_map_img and props.generate_normal_map: - normal_map_name = f"TextTexture_Normal_{props.text[:20].replace(' ', '_')}" - - # Remove existing normal map texture - if normal_map_name in bpy.data.images: - bpy.data.images.remove(bpy.data.images[normal_map_name]) - - # Create new normal map image - normal_map_blender_img = bpy.data.images.new(normal_map_name, final_width, final_height, alpha=False) - normal_map_blender_img.colorspace_settings.name = 'Non-Color' # Important for normal maps - - # Convert PIL to Blender for normal map - normal_pixels = [] - for y in range(final_height): - for x in range(final_width): - r, g, b = normal_map_img.getpixel((x, final_height - 1 - y)) - normal_pixels.extend([r/255.0, g/255.0, b/255.0, 1.0]) # Alpha always 1.0 for normal maps - - normal_map_blender_img.pixels = normal_pixels - normal_map_blender_img.pack() - print(f"[Normal Map] Created normal map texture for shader: {normal_map_name}") - - # Create or get material using custom name or fallback - if props.custom_material_name.strip(): - material_name = props.custom_material_name.strip() - else: - # Fallback to text-based naming - clean_text = props.text[:15].replace(' ', '_').replace('\n', '_') - material_name = f"TextTexture_Mat_{clean_text}" - - if material_name in bpy.data.materials: - mat = bpy.data.materials[material_name] - # Clear existing nodes - mat.node_tree.nodes.clear() - else: - mat = bpy.data.materials.new(name=material_name) - mat.use_nodes = True - mat.node_tree.nodes.clear() - - # Set up shader nodes - nodes = mat.node_tree.nodes - links = mat.node_tree.links - - # Create nodes - output_node = nodes.new(type='ShaderNodeOutputMaterial') - output_node.location = (400, 0) - - # Shader type based on settings - if props.shader_type == 'PRINCIPLED': - shader_node = nodes.new(type='ShaderNodeBsdfPrincipled') - shader_node.location = (200, 0) - - # Create diffuse texture node - tex_node = nodes.new(type='ShaderNodeTexImage') - tex_node.image = blender_img - tex_node.location = (-200, 0) - - # Connect texture to shader - if props.shader_connection == 'BASE_COLOR': - links.new(tex_node.outputs['Color'], shader_node.inputs['Base Color']) - elif props.shader_connection == 'EMISSION': - links.new(tex_node.outputs['Color'], shader_node.inputs['Emission Color']) - shader_node.inputs['Emission Strength'].default_value = props.emission_strength - elif props.shader_connection == 'ALPHA': - links.new(tex_node.outputs['Alpha'], shader_node.inputs['Alpha']) - # Also connect color for visibility - links.new(tex_node.outputs['Color'], shader_node.inputs['Base Color']) - mat.blend_method = 'BLEND' - - # Connect alpha if enabled - if props.use_alpha: - links.new(tex_node.outputs['Alpha'], shader_node.inputs['Alpha']) - mat.blend_method = 'BLEND' - - # Add normal map nodes if normal map was generated - if normal_map_blender_img: - # Create normal map texture node - normal_tex_node = nodes.new(type='ShaderNodeTexImage') - normal_tex_node.image = normal_map_blender_img - normal_tex_node.location = (-200, -300) - normal_tex_node.label = "Normal Map" - - # Create normal map node - normal_map_node = nodes.new(type='ShaderNodeNormalMap') - normal_map_node.location = (0, -300) - - # Connect normal map texture to normal map node - links.new(normal_tex_node.outputs['Color'], normal_map_node.inputs['Color']) - - # Connect normal map node to shader - links.new(normal_map_node.outputs['Normal'], shader_node.inputs['Normal']) - - print(f"[Normal Map] Added normal map nodes to Principled BSDF shader") - - # Handle shadow/reflection disabling - current_shader = shader_node - final_x_location = 400 - - # Disable shadows if enabled - if props.disable_shadows: - # Create light path node - light_path_node = nodes.new(type='ShaderNodeLightPath') - light_path_node.location = (50, 150) - - # Create transparent BSDF - transparent_node = nodes.new(type='ShaderNodeBsdfTransparent') - transparent_node.location = (200, 150) - - # Create mix shader for shadows - shadow_mix_node = nodes.new(type='ShaderNodeMixShader') - shadow_mix_node.location = (350, 50) - - # Connect light path "Is Shadow Ray" to mix factor - links.new(light_path_node.outputs['Is Shadow Ray'], shadow_mix_node.inputs['Fac']) - - # Mix principled BSDF with transparent BSDF - links.new(current_shader.outputs['BSDF'], shadow_mix_node.inputs[1]) # Shader 1 (not shadow) - links.new(transparent_node.outputs['BSDF'], shadow_mix_node.inputs[2]) # Shader 2 (shadow) - - current_shader = shadow_mix_node - final_x_location = 500 - - # Disable reflections if enabled - if props.disable_reflections: - # Create or reuse light path node - if not props.disable_shadows: - light_path_node = nodes.new(type='ShaderNodeLightPath') - light_path_node.location = (50, 200) - - # Create transparent BSDF for reflections - reflection_transparent_node = nodes.new(type='ShaderNodeBsdfTransparent') - reflection_transparent_node.location = (200, 200) - - # Create mix shader for reflections - reflection_mix_node = nodes.new(type='ShaderNodeMixShader') - reflection_mix_node.location = (final_x_location - 50, 100) - - # Connect light path "Is Reflection Ray" to mix factor - links.new(light_path_node.outputs['Is Reflection Ray'], reflection_mix_node.inputs['Fac']) - - # Mix current shader with transparent BSDF - links.new(current_shader.outputs['Shader' if props.disable_shadows else 'BSDF'], reflection_mix_node.inputs[1]) # Shader 1 (not reflection) - links.new(reflection_transparent_node.outputs['BSDF'], reflection_mix_node.inputs[2]) # Shader 2 (reflection) - - current_shader = reflection_mix_node - final_x_location = final_x_location + 50 - - # Disable backfacing if enabled - if props.disable_backfacing: - # Create or reuse light path node - if not props.disable_shadows and not props.disable_reflections: - light_path_node = nodes.new(type='ShaderNodeLightPath') - light_path_node.location = (50, 250) - - # Create geometry node for backfacing detection - geometry_node = nodes.new(type='ShaderNodeNewGeometry') - geometry_node.location = (50, 300) - - # Create transparent BSDF for backfaces - backface_transparent_node = nodes.new(type='ShaderNodeBsdfTransparent') - backface_transparent_node.location = (200, 250) - - # Create mix shader for backfaces - backface_mix_node = nodes.new(type='ShaderNodeMixShader') - backface_mix_node.location = (final_x_location - 50, 150) - - # Connect geometry "Backfacing" to mix factor - links.new(geometry_node.outputs['Backfacing'], backface_mix_node.inputs['Fac']) - - # Mix current shader with transparent BSDF - current_output = 'Shader' if (props.disable_shadows or props.disable_reflections) else 'BSDF' - links.new(current_shader.outputs[current_output], backface_mix_node.inputs[1]) # Shader 1 (front face) - links.new(backface_transparent_node.outputs['BSDF'], backface_mix_node.inputs[2]) # Shader 2 (backface) - - current_shader = backface_mix_node - final_x_location = final_x_location + 50 - - # Update output node position - output_node.location = (final_x_location, 0) - - # Connect final shader to output - output_socket = 'Shader' if (props.disable_shadows or props.disable_reflections) else 'BSDF' - links.new(current_shader.outputs[output_socket], output_node.inputs['Surface']) - - elif props.shader_type == 'EMISSION': - shader_node = nodes.new(type='ShaderNodeEmission') - shader_node.location = (200, 0) - - # Create texture node - tex_node = nodes.new(type='ShaderNodeTexImage') - tex_node.image = blender_img - tex_node.location = (-200, 0) - - # Connect texture to emission - links.new(tex_node.outputs['Color'], shader_node.inputs['Color']) - shader_node.inputs['Strength'].default_value = props.emission_strength - - # Handle shadow/reflection disabling - current_shader = shader_node - final_x_location = 400 - - # Disable shadows if enabled - if props.disable_shadows: - # Create light path node - light_path_node = nodes.new(type='ShaderNodeLightPath') - light_path_node.location = (50, 150) - - # Create transparent BSDF - transparent_node = nodes.new(type='ShaderNodeBsdfTransparent') - transparent_node.location = (200, 150) - - # Create mix shader for shadows - shadow_mix_node = nodes.new(type='ShaderNodeMixShader') - shadow_mix_node.location = (350, 50) - - # Connect light path "Is Shadow Ray" to mix factor - links.new(light_path_node.outputs['Is Shadow Ray'], shadow_mix_node.inputs['Fac']) - - # Mix emission with transparent BSDF - links.new(current_shader.outputs['Emission'], shadow_mix_node.inputs[1]) # Shader 1 (not shadow) - links.new(transparent_node.outputs['BSDF'], shadow_mix_node.inputs[2]) # Shader 2 (shadow) - - current_shader = shadow_mix_node - final_x_location = 500 - - # Disable reflections if enabled - if props.disable_reflections: - # Create or reuse light path node - if not props.disable_shadows: - light_path_node = nodes.new(type='ShaderNodeLightPath') - light_path_node.location = (50, 200) - - # Create transparent BSDF for reflections - reflection_transparent_node = nodes.new(type='ShaderNodeBsdfTransparent') - reflection_transparent_node.location = (200, 200) - - # Create mix shader for reflections - reflection_mix_node = nodes.new(type='ShaderNodeMixShader') - reflection_mix_node.location = (final_x_location - 50, 100) - - # Connect light path "Is Reflection Ray" to mix factor - links.new(light_path_node.outputs['Is Reflection Ray'], reflection_mix_node.inputs['Fac']) - - # Mix current shader with transparent BSDF - links.new(current_shader.outputs['Shader' if props.disable_shadows else 'Emission'], reflection_mix_node.inputs[1]) # Shader 1 (not reflection) - links.new(reflection_transparent_node.outputs['BSDF'], reflection_mix_node.inputs[2]) # Shader 2 (reflection) - - current_shader = reflection_mix_node - final_x_location = final_x_location + 50 - - # Update output node position - output_node.location = (final_x_location, 0) - - # Connect final shader to output - output_socket = 'Shader' if (props.disable_shadows or props.disable_reflections) else 'Emission' - links.new(current_shader.outputs[output_socket], output_node.inputs['Surface']) - - elif props.shader_type == 'TRANSPARENT': - shader_node = nodes.new(type='ShaderNodeBsdfTransparent') - mix_node = nodes.new(type='ShaderNodeMixShader') - principled_node = nodes.new(type='ShaderNodeBsdfPrincipled') - - shader_node.location = (200, 100) - mix_node.location = (350, 0) - principled_node.location = (200, -100) - - # Create texture node - tex_node = nodes.new(type='ShaderNodeTexImage') - tex_node.image = blender_img - tex_node.location = (-200, 0) - - # Connect texture - links.new(tex_node.outputs['Color'], principled_node.inputs['Base Color']) - links.new(tex_node.outputs['Alpha'], mix_node.inputs['Fac']) - - # Connect shaders to mix - links.new(shader_node.outputs['BSDF'], mix_node.inputs[1]) - links.new(principled_node.outputs['BSDF'], mix_node.inputs[2]) - - # Handle shadow/reflection disabling - current_shader = mix_node - final_x_location = 500 - - # Disable shadows if enabled - if props.disable_shadows: - # Create light path node - light_path_node = nodes.new(type='ShaderNodeLightPath') - light_path_node.location = (50, 200) - - # Create transparent BSDF for shadows (reuse existing transparent node) - shadow_transparent_node = nodes.new(type='ShaderNodeBsdfTransparent') - shadow_transparent_node.location = (350, 150) - - # Create mix shader for shadows - shadow_mix_node = nodes.new(type='ShaderNodeMixShader') - shadow_mix_node.location = (500, 50) - - # Connect light path "Is Shadow Ray" to mix factor - links.new(light_path_node.outputs['Is Shadow Ray'], shadow_mix_node.inputs['Fac']) - - # Mix current shader with transparent BSDF - links.new(current_shader.outputs['Shader'], shadow_mix_node.inputs[1]) # Shader 1 (not shadow) - links.new(shadow_transparent_node.outputs['BSDF'], shadow_mix_node.inputs[2]) # Shader 2 (shadow) - - current_shader = shadow_mix_node - final_x_location = 650 - - # Disable reflections if enabled - if props.disable_reflections: - # Create or reuse light path node - if not props.disable_shadows: - light_path_node = nodes.new(type='ShaderNodeLightPath') - light_path_node.location = (50, 250) - - # Create transparent BSDF for reflections - reflection_transparent_node = nodes.new(type='ShaderNodeBsdfTransparent') - reflection_transparent_node.location = (350, 200) - - # Create mix shader for reflections - reflection_mix_node = nodes.new(type='ShaderNodeMixShader') - reflection_mix_node.location = (final_x_location - 50, 100) - - # Connect light path "Is Reflection Ray" to mix factor - links.new(light_path_node.outputs['Is Reflection Ray'], reflection_mix_node.inputs['Fac']) - - # Mix current shader with transparent BSDF - links.new(current_shader.outputs['Shader'], reflection_mix_node.inputs[1]) # Shader 1 (not reflection) - links.new(reflection_transparent_node.outputs['BSDF'], reflection_mix_node.inputs[2]) # Shader 2 (reflection) - - current_shader = reflection_mix_node - final_x_location = final_x_location + 50 - - # Disable backfacing if enabled - if props.disable_backfacing: - # Create or reuse light path node - if not props.disable_shadows and not props.disable_reflections: - light_path_node = nodes.new(type='ShaderNodeLightPath') - light_path_node.location = (50, 300) - - # Create geometry node for backfacing detection - geometry_node = nodes.new(type='ShaderNodeNewGeometry') - geometry_node.location = (50, 350) - - # Create transparent BSDF for backfaces - backface_transparent_node = nodes.new(type='ShaderNodeBsdfTransparent') - backface_transparent_node.location = (350, 300) - - # Create mix shader for backfaces - backface_mix_node = nodes.new(type='ShaderNodeMixShader') - backface_mix_node.location = (final_x_location + 50, 150) - - # Connect geometry "Backfacing" to mix factor - links.new(geometry_node.outputs['Backfacing'], backface_mix_node.inputs['Fac']) - - # Mix current shader with transparent BSDF - links.new(current_shader.outputs['Shader'], backface_mix_node.inputs[1]) # Shader 1 (front face) - links.new(backface_transparent_node.outputs['BSDF'], backface_mix_node.inputs[2]) # Shader 2 (backface) - - current_shader = backface_mix_node - final_x_location = final_x_location + 150 - - # Update output node position - output_node.location = (final_x_location, 0) - - # Connect final shader to output - links.new(current_shader.outputs['Shader'], output_node.inputs['Surface']) - - mat.blend_method = 'BLEND' - - # Assign material to active object if enabled and possible - if props.auto_assign_material and context.active_object and context.active_object.type in ['MESH', 'CURVE']: - obj = context.active_object - # Check if material slot exists, reuse if found, otherwise create new one - slot_found = False - for i, slot in enumerate(obj.material_slots): - if slot.material and slot.material.name == material_name: - # Material already exists in a slot, update it - obj.material_slots[i].material = mat - slot_found = True - break - - if not slot_found: - # Create new material slot if not found - obj.data.materials.append(mat) - - self.report({'INFO'}, f"Generated shader '{material_name}' and assigned to {obj.name}") - else: - self.report({'INFO'}, f"Generated shader '{material_name}' (assign manually to object)") - - # Switch to Shader Editor if available and set material - for area in context.screen.areas: - if area.type == 'NODE_EDITOR': - for space in area.spaces: - if space.type == 'NODE_EDITOR': - space.shader_type = 'OBJECT' - space.id = mat - area.tag_redraw() - break - - return {'FINISHED'} - - except Exception as e: - self.report({'ERROR'}, f"Shader generation failed: {str(e)}") - import traceback - traceback.print_exc() - return {'CANCELLED'} - -class TEXT_TEXTURE_OT_refresh_preview(Operator): - """Force refresh the preview""" - bl_idname = "text_texture.refresh_preview" - bl_label = "Refresh Preview" - bl_description = "Force refresh the preview image" - bl_options = {'REGISTER', 'UNDO'} - - def execute(self, context): - props = context.scene.text_texture_props - - # Clear existing preview first - props.preview_image = None - - # Generate new preview - result = generate_preview(context) - - if result: - self.report({'INFO'}, f"Preview refreshed at {props.preview_size}px") - else: - self.report({'ERROR'}, "Failed to generate preview - check console for details") - - # Force UI update - for area in context.screen.areas: - area.tag_redraw() - - return {'FINISHED'} - -class TEXT_TEXTURE_OT_view_preview_zoom(Operator): - """Open preview in new Image Editor window with zoom support""" - bl_idname = "text_texture.view_preview_zoom" - bl_label = "Enable Zoom" - bl_description = "Open preview in new Image Editor window with zoom and pan controls" - bl_options = {'REGISTER', 'UNDO'} - - def execute(self, context): - props = context.scene.text_texture_props - - # Generate preview image when button is clicked - print("[TTG] Generating preview on button click...") - result = generate_preview(context) - - if not result: - self.report({'ERROR'}, "Failed to generate preview") - return {'CANCELLED'} - - if not props.preview_image: - self.report({'ERROR'}, "No preview image to display") - return {'CANCELLED'} - - # Open a new window with Image Editor - print(f"[ZOOM] Opening new window for preview image: {props.preview_image.name}") - - try: - # Create a new window - bpy.ops.screen.userpref_show('INVOKE_DEFAULT') - - # Wait a moment and then change the newly opened window to Image Editor - def setup_image_window(): - # Find the newest window (should be the one we just opened) - newest_window = None - for window in bpy.context.window_manager.windows: - if window.screen.name.startswith("temp"): - newest_window = window - break - - if not newest_window: - # Fallback: use any non-main window - for window in bpy.context.window_manager.windows: - if window != bpy.context.window: - newest_window = window - break - - if newest_window: - # Set the area type to Image Editor - for area in newest_window.screen.areas: - if area.type == 'PREFERENCES': - area.type = 'IMAGE_EDITOR' - print("[ZOOM] Changed area to Image Editor") - - # Set the preview image - for space in area.spaces: - if space.type == 'IMAGE_EDITOR': - space.image = props.preview_image - print(f"[ZOOM] Set preview image: {props.preview_image.name}") - - # Set up proper viewing - override = { - 'window': newest_window, - 'screen': newest_window.screen, - 'area': area, - 'region': area.regions[0] if area.regions else None - } - - with bpy.context.temp_override(**override): - try: - bpy.ops.image.view_all(fit_view=True) - print("[ZOOM] Applied fit to view") - except Exception as e: - print(f"[ZOOM] Error applying fit to view: {e}") - - break - break - - self.report({'INFO'}, "✅ NEW WINDOW OPENED! Use mouse wheel to zoom, middle-mouse to pan") - print("[ZOOM] New window zoom feature activated successfully") - else: - print("[ZOOM] Could not find new window") - self.report({'ERROR'}, "Failed to setup new window") - - return None # Stop timer - - # Use a timer to set up the window after it opens - bpy.app.timers.register(setup_image_window, first_interval=0.1) - - except Exception as e: - print(f"[ZOOM] Error creating new window: {e}") - # Fallback to old behavior if new window creation fails - self.report({'WARNING'}, "New window failed, trying area split instead") - - # Find any area to split - for area in context.screen.areas: - if area.type in ['NODE_EDITOR', 'VIEW_3D', 'PROPERTIES']: - print(f"[ZOOM] Fallback: Splitting {area.type} to create Image Editor") - - # Split horizontally to create a new area - with context.temp_override(area=area): - bpy.ops.screen.area_split(direction='HORIZONTAL', factor=0.6) - - # Find the newly created area - for new_area in context.screen.areas: - if new_area != area and new_area.y != area.y: - new_area.type = 'IMAGE_EDITOR' - - # Set the image in the Image Editor - for space in new_area.spaces: - if space.type == 'IMAGE_EDITOR': - space.image = props.preview_image - print("[ZOOM] Fallback: Image set in split Image Editor") - break - - # Tag for redraw and fit to view - new_area.tag_redraw() - override = context.copy() - override['area'] = new_area - override['region'] = new_area.regions[0] - - with context.temp_override(**override): - try: - bpy.ops.image.view_all(fit_view=True) - print("[ZOOM] Fallback: Fit to view applied") - except Exception as e: - print(f"[ZOOM] Fallback: Error applying fit to view: {e}") - - self.report({'INFO'}, "✅ ZOOM ENABLED! Use mouse wheel to zoom, middle-mouse to pan") - return {'FINISHED'} - break - - self.report({'ERROR'}, "Failed to create Image Editor") - return {'CANCELLED'} - - return {'FINISHED'} - - -class TEXT_TEXTURE_OT_save_preset(Operator): - """Save current settings as preset""" - bl_idname = "text_texture.save_preset" - bl_label = "Save Preset" - bl_description = "Save current settings as preset" - bl_options = {'REGISTER', 'UNDO'} - - overwrite: bpy.props.BoolProperty( - name="Overwrite", - description="Overwrite existing preset", - default=False - ) - - preset_name: bpy.props.StringProperty( - name="Preset Name", - description="Name of preset to save/overwrite", - default="" - ) - - def execute(self, context): - props = context.scene.text_texture_props - - # Use provided preset name or fallback to input field - preset_name = self.preset_name.strip() if self.preset_name else props.new_preset_name.strip() - if not preset_name: - self.report({'ERROR'}, "Please enter a preset name") - return {'CANCELLED'} - - # When overwriting from button, update the input field for consistency - if self.preset_name and self.preset_name.strip(): - props.new_preset_name = preset_name - - # Validate preset name characters (avoid filesystem issues) - import re - # Allow Unicode letters, numbers, spaces, hyphens, underscores, and common accented characters - # This pattern supports international characters like ö, ü, ñ, etc. - if not re.match(r'^[\w\s\-]+$', preset_name, re.UNICODE): - self.report({'ERROR'}, "Preset name contains invalid characters for filesystem compatibility") - return {'CANCELLED'} - - # Additional check for filesystem-unsafe characters - invalid_chars = ['<', '>', ':', '"', '|', '?', '*', '/', '\\'] - if any(char in preset_name for char in invalid_chars): - self.report({'ERROR'}, f"Preset name cannot contain: {' '.join(invalid_chars)}") - return {'CANCELLED'} - - # Check for duplicate names - preset_file = os.path.join(get_preset_path(), f"{preset_name}.json") - if os.path.exists(preset_file) and not self.overwrite: - # Suggest alternative names - base_name = preset_name - counter = 1 - suggested_name = f"{base_name}_{counter}" - while os.path.exists(os.path.join(get_preset_path(), f"{suggested_name}.json")): - counter += 1 - suggested_name = f"{base_name}_{counter}" - - self.report({'ERROR'}, f"Preset '{preset_name}' already exists. Suggestion: try '{suggested_name}' or delete the existing preset first.") - return {'CANCELLED'} - - # DETAILED LOGGING: Current shader properties before saving - print(f"[PRESET SAVE DEBUG] ==================== SAVING PRESET: '{preset_name}' ====================") - print(f"[PRESET SAVE DEBUG] Current shader properties from props:") - print(f"[PRESET SAVE DEBUG] disable_shadows: {props.disable_shadows}") - print(f"[PRESET SAVE DEBUG] disable_reflections: {props.disable_reflections}") - print(f"[PRESET SAVE DEBUG] disable_backfacing: {props.disable_backfacing}") - print(f"[PRESET SAVE DEBUG] shader_type: {props.shader_type}") - print(f"[PRESET SAVE DEBUG] shader_connection: {props.shader_connection}") - print(f"[PRESET SAVE DEBUG] use_alpha: {props.use_alpha}") - print(f"[PRESET SAVE DEBUG] emission_strength: {props.emission_strength}") - print(f"[PRESET SAVE DEBUG] auto_assign_material: {props.auto_assign_material}") - - preset_data = { - 'text': props.text, - 'texture_width': props.texture_width, - 'texture_height': props.texture_height, - 'font_size': props.font_size, - 'padding': props.padding, - 'text_color': list(props.text_color), - 'background_transparent': props.background_transparent, - 'background_color': list(props.background_color), - 'font_path': props.font_path, - 'custom_font_path': props.custom_font_path, - 'use_custom_font': props.use_custom_font, - 'text_align': props.text_align, - 'prepend_text': props.prepend_text, - 'append_text': props.append_text, - 'prepend_margin': props.prepend_margin, - 'append_margin': props.append_margin, - 'prepend_append_layout': props.prepend_append_layout, - 'shader_type': props.shader_type, - 'shader_connection': props.shader_connection, - 'use_alpha': props.use_alpha, - 'emission_strength': props.emission_strength, - 'auto_assign_material': props.auto_assign_material, - 'disable_shadows': props.disable_shadows, - 'disable_reflections': props.disable_reflections, - 'disable_backfacing': props.disable_backfacing, - 'generate_normal_map': props.generate_normal_map, - 'normal_map_strength': props.normal_map_strength, - 'normal_map_blur_radius': props.normal_map_blur_radius, - 'normal_map_invert': props.normal_map_invert, - 'enable_multiline': props.enable_multiline, - 'line_height': props.line_height, - 'text_alignment': props.text_alignment, - 'auto_wrap': props.auto_wrap, - 'wrap_width': props.wrap_width, - 'max_lines': props.max_lines, - 'line_spacing_pixels': props.line_spacing_pixels, - 'enable_stroke': props.enable_stroke, - 'stroke_width': props.stroke_width, - 'stroke_position': props.stroke_position, - 'stroke_color': list(props.stroke_color), - 'enable_shadow': props.enable_shadow, - 'shadow_offset_x': props.shadow_offset_x, - 'shadow_offset_y': props.shadow_offset_y, - 'shadow_blur': props.shadow_blur, - 'shadow_color': list(props.shadow_color), - 'shadow_spread': getattr(props, 'shadow_spread', 1.0), - 'enable_glow': props.enable_glow, - 'glow_blur': props.glow_blur, - 'glow_color': list(props.glow_color), - 'enable_blur': props.enable_blur, - 'blur_radius': props.blur_radius, - 'image_overlays': [] - } - - # DETAILED LOGGING: Verify shader properties in preset_data - print(f"[PRESET SAVE DEBUG] Shader properties in preset_data dictionary:") - print(f"[PRESET SAVE DEBUG] disable_shadows: {preset_data.get('disable_shadows', 'NOT FOUND')}") - print(f"[PRESET SAVE DEBUG] disable_reflections: {preset_data.get('disable_reflections', 'NOT FOUND')}") - print(f"[PRESET SAVE DEBUG] disable_backfacing: {preset_data.get('disable_backfacing', 'NOT FOUND')}") - - # Save overlay data - for overlay in props.image_overlays: - overlay_data = { - 'name': overlay.name, - 'image_path': overlay.image_path, - 'x_position': overlay.x_position, - 'y_position': overlay.y_position, - 'scale': overlay.scale, - 'rotation': overlay.rotation, - 'z_index': overlay.z_index, - 'enabled': overlay.enabled - } - preset_data['image_overlays'].append(overlay_data) - - try: - # Primary: Save to blend file (new system) - print(f"[PRESET SAVE DEBUG] Attempting to save to blend file...") - blend_success = save_blend_file_preset(preset_name, preset_data) - print(f"[PRESET SAVE DEBUG] Blend file save result: {blend_success}") - - # ALWAYS save to persistent storage for cross-blend-file sharing and addon update survival - persistent_file = os.path.join(get_persistent_preset_path(), f"{preset_name}.json") - print(f"[PRESET SAVE DEBUG] Attempting to save to persistent file: {persistent_file}") - persistent_success = True - try: - with open(persistent_file, 'w') as f: - json.dump(preset_data, f, indent=2) - print(f"[PRESET SAVE DEBUG] Successfully saved preset '{preset_name}' to persistent storage") - - # VERIFY: Read back the persistent file to confirm shader properties were saved - print(f"[PRESET SAVE DEBUG] Verifying persistent file contents...") - with open(persistent_file, 'r') as f: - saved_data = json.load(f) - print(f"[PRESET SAVE DEBUG] Persistent file shader properties verification:") - print(f"[PRESET SAVE DEBUG] disable_shadows: {saved_data.get('disable_shadows', 'NOT FOUND')}") - print(f"[PRESET SAVE DEBUG] disable_reflections: {saved_data.get('disable_reflections', 'NOT FOUND')}") - print(f"[PRESET SAVE DEBUG] disable_backfacing: {saved_data.get('disable_backfacing', 'NOT FOUND')}") - - except Exception as persistent_error: - print(f"[PRESET SAVE DEBUG] Warning: Failed to save persistent preset file: {persistent_error}") - persistent_success = False - - # Also save to legacy location for backward compatibility (optional) - legacy_success = True - try: - legacy_file = os.path.join(get_preset_path(), f"{preset_name}.json") - with open(legacy_file, 'w') as f: - json.dump(preset_data, f, indent=2) - print(f"[PRESET SAVE DEBUG] Also saved to legacy location for backward compatibility") - except Exception as legacy_error: - print(f"[PRESET SAVE DEBUG] Note: Could not save to legacy location: {legacy_error}") - legacy_success = False - - if not blend_success and not persistent_success: - self.report({'ERROR'}, f"Failed to save preset to both blend file and persistent storage") - return {'CANCELLED'} - - # Check if preset already exists in the collection and update it - existing_preset = None - for preset in props.presets: - if preset.name == preset_name: - existing_preset = preset - break - - if not existing_preset: - preset = props.presets.add() - preset.name = preset_name - # Priority: Blend file > Persistent file - preset.source = 'BLEND_FILE' if blend_success else 'PERSISTENT_FILE' - else: - # Update source based on where it was successfully saved - existing_preset.source = 'BLEND_FILE' if blend_success else 'PERSISTENT_FILE' - - # Don't clear the input field - keep the name for easy re-saving - # props.new_preset_name = "New Preset" - - # Provide clear feedback - storage_info = "" - if blend_success and persistent_success: - storage_info = " (saved to blend file + persistent storage)" - elif blend_success: - storage_info = " (saved to blend file)" - elif persistent_success: - storage_info = " (saved to persistent storage)" - - if self.overwrite: - self.report({'INFO'}, f"✅ Preset '{preset_name}' updated successfully{storage_info}") - else: - self.report({'INFO'}, f"✅ Preset '{preset_name}' saved successfully{storage_info}") - - except (OSError, IOError, json.JSONEncodeError) as e: - self.report({'ERROR'}, f"Failed to save preset: {str(e)}") - return {'CANCELLED'} - except Exception as e: - self.report({'ERROR'}, f"Unexpected error saving preset: {str(e)}") - return {'CANCELLED'} - - return {'FINISHED'} - - def invoke(self, context, event): - # Check if preset exists and prompt for overwrite - props = context.scene.text_texture_props - preset_name = props.new_preset_name.strip() - - if preset_name and preset_name != "New Preset": - preset_file = os.path.join(get_preset_path(), f"{preset_name}.json") - if os.path.exists(preset_file): - return context.window_manager.invoke_confirm(self, event) - - return self.execute(context) - - def draw(self, context): - layout = self.layout - props = context.scene.text_texture_props - preset_name = props.new_preset_name.strip() - layout.label(text=f"Overwrite existing preset '{preset_name}'?") - -class TEXT_TEXTURE_OT_save_preset_enter(Operator): - """Save preset when Enter key is pressed in name field""" - bl_idname = "text_texture.save_preset_enter" - bl_label = "Save Preset (Enter)" - bl_description = "Save current settings as preset when Enter is pressed" - bl_options = {'REGISTER', 'UNDO'} - - def execute(self, context): - # Delegate to the main save preset operator - return bpy.ops.text_texture.save_preset('INVOKE_DEFAULT') - -class TEXT_TEXTURE_OT_load_preset(Operator): - """Load preset""" - bl_idname = "text_texture.load_preset" - bl_label = "Load Preset" - bl_description = "Load selected preset" - bl_options = {'REGISTER', 'UNDO'} - - preset_name: StringProperty() - - def execute(self, context): - props = context.scene.text_texture_props - - # DEFENSIVE PROGRAMMING: Ensure presets are available before loading - if not ensure_presets_available(): - self.report({'ERROR'}, "Failed to ensure presets are loaded - cannot load preset") - return {'CANCELLED'} - - # DETAILED LOGGING: Current shader properties before loading - print(f"[PRESET LOAD DEBUG] ==================== LOADING PRESET: '{self.preset_name}' ====================") - print(f"[PRESET LOAD DEBUG] Current shader properties BEFORE loading:") - print(f"[PRESET LOAD DEBUG] disable_shadows: {props.disable_shadows}") - print(f"[PRESET LOAD DEBUG] disable_reflections: {props.disable_reflections}") - print(f"[PRESET LOAD DEBUG] disable_backfacing: {props.disable_backfacing}") - print(f"[PRESET LOAD DEBUG] shader_type: {props.shader_type}") - print(f"[PRESET LOAD DEBUG] shader_connection: {props.shader_connection}") - - try: - # Three-tier loading priority: Blend File > Persistent File > Legacy File - blend_presets = get_blend_file_presets() - preset_data = None - source = 'BLEND_FILE' - - print(f"[PRESET LOAD DEBUG] Available blend file presets: {list(blend_presets.keys())}") - - if self.preset_name in blend_presets: - preset_data = blend_presets[self.preset_name] - source = 'BLEND_FILE' - print(f"[PRESET LOAD DEBUG] Loading preset '{self.preset_name}' from blend file") - print(f"[PRESET LOAD DEBUG] Blend file preset shader properties:") - print(f"[PRESET LOAD DEBUG] disable_shadows: {preset_data.get('disable_shadows', 'NOT FOUND')}") - print(f"[PRESET LOAD DEBUG] disable_reflections: {preset_data.get('disable_reflections', 'NOT FOUND')}") - print(f"[PRESET LOAD DEBUG] disable_backfacing: {preset_data.get('disable_backfacing', 'NOT FOUND')}") - else: - # Try persistent storage next - persistent_file = os.path.join(get_persistent_preset_path(), f"{self.preset_name}.json") - print(f"[PRESET LOAD DEBUG] Preset not in blend file, trying persistent file: {persistent_file}") - if os.path.exists(persistent_file): - with open(persistent_file, 'r') as f: - preset_data = json.load(f) - source = 'PERSISTENT_FILE' - print(f"[PRESET LOAD DEBUG] Loading preset '{self.preset_name}' from persistent file") - print(f"[PRESET LOAD DEBUG] Persistent file preset shader properties:") - print(f"[PRESET LOAD DEBUG] disable_shadows: {preset_data.get('disable_shadows', 'NOT FOUND')}") - print(f"[PRESET LOAD DEBUG] disable_reflections: {preset_data.get('disable_reflections', 'NOT FOUND')}") - print(f"[PRESET LOAD DEBUG] disable_backfacing: {preset_data.get('disable_backfacing', 'NOT FOUND')}") - else: - # Fallback to legacy local file - legacy_file = os.path.join(get_preset_path(), f"{self.preset_name}.json") - print(f"[PRESET LOAD DEBUG] Preset not in persistent storage, trying legacy file: {legacy_file}") - if os.path.exists(legacy_file): - with open(legacy_file, 'r') as f: - preset_data = json.load(f) - source = 'LOCAL_FILE' - print(f"[PRESET LOAD DEBUG] Loading preset '{self.preset_name}' from legacy file") - print(f"[PRESET LOAD DEBUG] Legacy file preset shader properties:") - print(f"[PRESET LOAD DEBUG] disable_shadows: {preset_data.get('disable_shadows', 'NOT FOUND')}") - print(f"[PRESET LOAD DEBUG] disable_reflections: {preset_data.get('disable_reflections', 'NOT FOUND')}") - print(f"[PRESET LOAD DEBUG] disable_backfacing: {preset_data.get('disable_backfacing', 'NOT FOUND')}") - else: - print(f"[PRESET LOAD DEBUG] Legacy file does not exist: {legacy_file}") - self.report({'ERROR'}, f"Preset not found in any storage location: {self.preset_name}") - return {'CANCELLED'} - - if not preset_data: - print(f"[PRESET LOAD DEBUG] No preset data found!") - self.report({'ERROR'}, f"No data found for preset: {self.preset_name}") - return {'CANCELLED'} - - props.is_updating = True - - # Skip loading text to prevent overwriting current text - # props.text = preset_data.get('text', props.text) - props.texture_width = preset_data.get('texture_width', props.texture_width) - props.texture_height = preset_data.get('texture_height', props.texture_height) - props.font_size = preset_data.get('font_size', props.font_size) - props.padding = preset_data.get('padding', props.padding) - props.text_color = preset_data.get('text_color', props.text_color) - props.background_transparent = preset_data.get('background_transparent', props.background_transparent) - props.background_color = preset_data.get('background_color', props.background_color) - props.font_path = preset_data.get('font_path', props.font_path) - props.custom_font_path = preset_data.get('custom_font_path', props.custom_font_path) - props.use_custom_font = preset_data.get('use_custom_font', props.use_custom_font) - props.text_align = preset_data.get('text_align', props.text_align) - props.prepend_text = preset_data.get('prepend_text', props.prepend_text) - props.append_text = preset_data.get('append_text', props.append_text) - props.prepend_margin = preset_data.get('prepend_margin', props.prepend_margin) - props.append_margin = preset_data.get('append_margin', props.append_margin) - props.prepend_append_layout = preset_data.get('prepend_append_layout', props.prepend_append_layout) - - # DETAILED LOGGING: Shader property loading with before/after values - print(f"[PRESET LOAD DEBUG] Loading shader properties...") - - old_shader_type = props.shader_type - props.shader_type = preset_data.get('shader_type', props.shader_type) - print(f"[PRESET LOAD DEBUG] shader_type: {old_shader_type} -> {props.shader_type}") - - old_shader_connection = props.shader_connection - props.shader_connection = preset_data.get('shader_connection', props.shader_connection) - print(f"[PRESET LOAD DEBUG] shader_connection: {old_shader_connection} -> {props.shader_connection}") - - old_use_alpha = props.use_alpha - props.use_alpha = preset_data.get('use_alpha', props.use_alpha) - print(f"[PRESET LOAD DEBUG] use_alpha: {old_use_alpha} -> {props.use_alpha}") - - old_emission_strength = props.emission_strength - props.emission_strength = preset_data.get('emission_strength', props.emission_strength) - print(f"[PRESET LOAD DEBUG] emission_strength: {old_emission_strength} -> {props.emission_strength}") - - old_auto_assign = props.auto_assign_material - props.auto_assign_material = preset_data.get('auto_assign_material', props.auto_assign_material) - print(f"[PRESET LOAD DEBUG] auto_assign_material: {old_auto_assign} -> {props.auto_assign_material}") - - # CRITICAL SHADER PROPERTIES - Most detailed logging - print(f"[PRESET LOAD DEBUG] ==================== CRITICAL SHADER PROPERTIES ====================") - - old_disable_shadows = props.disable_shadows - new_disable_shadows = preset_data.get('disable_shadows', props.disable_shadows) - props.disable_shadows = new_disable_shadows - print(f"[PRESET LOAD DEBUG] disable_shadows:") - print(f"[PRESET LOAD DEBUG] OLD VALUE: {old_disable_shadows} (type: {type(old_disable_shadows)})") - print(f"[PRESET LOAD DEBUG] PRESET DATA: {preset_data.get('disable_shadows', 'NOT FOUND')} (type: {type(preset_data.get('disable_shadows', 'N/A'))})") - print(f"[PRESET LOAD DEBUG] NEW VALUE: {props.disable_shadows} (type: {type(props.disable_shadows)})") - - old_disable_reflections = props.disable_reflections - new_disable_reflections = preset_data.get('disable_reflections', props.disable_reflections) - props.disable_reflections = new_disable_reflections - print(f"[PRESET LOAD DEBUG] disable_reflections:") - print(f"[PRESET LOAD DEBUG] OLD VALUE: {old_disable_reflections} (type: {type(old_disable_reflections)})") - print(f"[PRESET LOAD DEBUG] PRESET DATA: {preset_data.get('disable_reflections', 'NOT FOUND')} (type: {type(preset_data.get('disable_reflections', 'N/A'))})") - print(f"[PRESET LOAD DEBUG] NEW VALUE: {props.disable_reflections} (type: {type(props.disable_reflections)})") - - old_disable_backfacing = props.disable_backfacing - new_disable_backfacing = preset_data.get('disable_backfacing', props.disable_backfacing) - props.disable_backfacing = new_disable_backfacing - print(f"[PRESET LOAD DEBUG] disable_backfacing:") - print(f"[PRESET LOAD DEBUG] OLD VALUE: {old_disable_backfacing} (type: {type(old_disable_backfacing)})") - print(f"[PRESET LOAD DEBUG] PRESET DATA: {preset_data.get('disable_backfacing', 'NOT FOUND')} (type: {type(preset_data.get('disable_backfacing', 'N/A'))})") - print(f"[PRESET LOAD DEBUG] NEW VALUE: {props.disable_backfacing} (type: {type(props.disable_backfacing)})") - - print(f"[PRESET LOAD DEBUG] ================================================================") - - props.generate_normal_map = preset_data.get('generate_normal_map', props.generate_normal_map) - props.normal_map_strength = preset_data.get('normal_map_strength', props.normal_map_strength) - props.normal_map_blur_radius = preset_data.get('normal_map_blur_radius', props.normal_map_blur_radius) - props.normal_map_invert = preset_data.get('normal_map_invert', props.normal_map_invert) - - # Load multiline settings - props.enable_multiline = preset_data.get('enable_multiline', props.enable_multiline) - props.line_height = preset_data.get('line_height', props.line_height) - props.text_alignment = preset_data.get('text_alignment', props.text_alignment) - props.auto_wrap = preset_data.get('auto_wrap', props.auto_wrap) - props.wrap_width = preset_data.get('wrap_width', props.wrap_width) - props.max_lines = preset_data.get('max_lines', props.max_lines) - props.line_spacing_pixels = preset_data.get('line_spacing_pixels', props.line_spacing_pixels) - - # Load stroke settings - props.enable_stroke = preset_data.get('enable_stroke', props.enable_stroke) - props.stroke_width = preset_data.get('stroke_width', props.stroke_width) - props.stroke_position = preset_data.get('stroke_position', props.stroke_position) - props.stroke_color = preset_data.get('stroke_color', props.stroke_color) - - # Load shadow/glow settings - props.enable_shadow = preset_data.get('enable_shadow', props.enable_shadow) - props.shadow_offset_x = preset_data.get('shadow_offset_x', props.shadow_offset_x) - props.shadow_offset_y = preset_data.get('shadow_offset_y', props.shadow_offset_y) - props.shadow_blur = preset_data.get('shadow_blur', props.shadow_blur) - props.shadow_color = preset_data.get('shadow_color', props.shadow_color) - # Handle new shadow_spread property with backward compatibility - if hasattr(props, 'shadow_spread'): - props.shadow_spread = preset_data.get('shadow_spread', 1.0) - props.enable_glow = preset_data.get('enable_glow', props.enable_glow) - props.glow_blur = preset_data.get('glow_blur', props.glow_blur) - props.glow_color = preset_data.get('glow_color', props.glow_color) - - # Load blur settings - props.enable_blur = preset_data.get('enable_blur', props.enable_blur) - props.blur_radius = preset_data.get('blur_radius', props.blur_radius) - - # Load text fitting settings - props.enable_text_fitting = preset_data.get('enable_text_fitting', props.enable_text_fitting) - props.text_fitting_mode = preset_data.get('text_fitting_mode', props.text_fitting_mode) - props.text_fitting_margin = preset_data.get('text_fitting_margin', props.text_fitting_margin) - props.min_font_size = preset_data.get('min_font_size', props.min_font_size) - props.max_font_size = preset_data.get('max_font_size', props.max_font_size) - - # Load overlay data - props.image_overlays.clear() - overlay_data_list = preset_data.get('image_overlays', []) - for overlay_data in overlay_data_list: - overlay = props.image_overlays.add() - overlay.name = overlay_data.get('name', 'Overlay') - overlay.image_path = overlay_data.get('image_path', '') - overlay.x_position = overlay_data.get('x_position', 0.5) - overlay.y_position = overlay_data.get('y_position', 0.5) - overlay.scale = overlay_data.get('scale', 1.0) - overlay.rotation = overlay_data.get('rotation', 0.0) - overlay.z_index = overlay_data.get('z_index', 1) - overlay.enabled = overlay_data.get('enabled', True) - - props.is_updating = False - - # FINAL VERIFICATION: Check what the properties actually are after loading - print(f"[PRESET LOAD DEBUG] ==================== FINAL VERIFICATION ====================") - print(f"[PRESET LOAD DEBUG] Properties AFTER loading preset '{self.preset_name}':") - print(f"[PRESET LOAD DEBUG] disable_shadows: {props.disable_shadows} (type: {type(props.disable_shadows)})") - print(f"[PRESET LOAD DEBUG] disable_reflections: {props.disable_reflections} (type: {type(props.disable_reflections)})") - print(f"[PRESET LOAD DEBUG] disable_backfacing: {props.disable_backfacing} (type: {type(props.disable_backfacing)})") - print(f"[PRESET LOAD DEBUG] ================================================================") - - # Always generate preview after loading preset - generate_preview(context) - - source_info = { - 'BLEND_FILE': "from blend file", - 'PERSISTENT_FILE': "from persistent storage", - 'LOCAL_FILE': "from legacy storage" - }.get(source, "from unknown source") - self.report({'INFO'}, f"✅ Loaded preset '{self.preset_name}' {source_info}") - - except (OSError, IOError) as e: - props.is_updating = False - self.report({'ERROR'}, f"Failed to read preset file: {str(e)}") - return {'CANCELLED'} - except json.JSONDecodeError as e: - props.is_updating = False - self.report({'ERROR'}, f"Invalid preset file format: {str(e)}") - return {'CANCELLED'} - except Exception as e: - props.is_updating = False - self.report({'ERROR'}, f"Unexpected error loading preset: {str(e)}") - return {'CANCELLED'} - - return {'FINISHED'} - -class TEXT_TEXTURE_OT_delete_preset(Operator): - """Delete preset""" - bl_idname = "text_texture.delete_preset" - bl_label = "Delete Preset" - bl_description = "Delete selected preset" - bl_options = {'REGISTER', 'UNDO'} - - preset_name: StringProperty() - - def execute(self, context): - props = context.scene.text_texture_props - - try: - # Delete from both blend file and local storage - blend_deleted = delete_blend_file_preset(self.preset_name) - - preset_file = os.path.join(get_preset_path(), f"{self.preset_name}.json") - local_deleted = False - if os.path.exists(preset_file): - os.remove(preset_file) - local_deleted = True - - if not blend_deleted and not local_deleted: - self.report({'WARNING'}, f"Preset '{self.preset_name}' not found in blend file or local storage") - - # Remove from UI list - for i, preset in enumerate(props.presets): - if preset.name == self.preset_name: - props.presets.remove(i) - break - - delete_info = [] - if blend_deleted: - delete_info.append("blend file") - if local_deleted: - delete_info.append("local storage") - - location_text = " and ".join(delete_info) if delete_info else "nowhere (not found)" - self.report({'INFO'}, f"🗑️ Deleted preset '{self.preset_name}' from {location_text}") - - except (OSError, IOError) as e: - self.report({'ERROR'}, f"Failed to delete preset file: {str(e)}") - return {'CANCELLED'} - except Exception as e: - self.report({'ERROR'}, f"Unexpected error deleting preset: {str(e)}") - return {'CANCELLED'} - - return {'FINISHED'} - - def invoke(self, context, event): - # Add confirmation dialog for better UX - return context.window_manager.invoke_confirm(self, event) - - def draw(self, context): - layout = self.layout - layout.label(text=f"Delete preset '{self.preset_name}'?") - layout.label(text="This action cannot be undone.", icon='ERROR') - -class TEXT_TEXTURE_OT_open_panel(Operator): - """Open Text Texture Generator panel""" - bl_idname = "text_texture.open_panel" - bl_label = "Open Text Texture Panel" - bl_description = "Open the Text Texture Generator panel in the sidebar" - bl_options = {'REGISTER', 'UNDO'} - - def execute(self, context): - # Ensure the sidebar is visible - for area in context.screen.areas: - if area.type == 'VIEW_3D': - for space in area.spaces: - if space.type == 'VIEW_3D': - space.show_region_ui = True - # Switch to Tool tab - area.tag_redraw() - self.report({'INFO'}, "Text Texture panel opened in 3D View sidebar (press N if not visible)") - return {'FINISHED'} - elif area.type == 'NODE_EDITOR': - for space in area.spaces: - if space.type == 'NODE_EDITOR': - space.show_region_ui = True - area.tag_redraw() - self.report({'INFO'}, "Text Texture panel opened in Shader Editor sidebar") - return {'FINISHED'} - - self.report({'WARNING'}, "Please open a 3D View or Shader Editor first") - return {'CANCELLED'} - -class TEXT_TEXTURE_OT_refresh_fonts(Operator): - """Refresh fonts""" - bl_idname = "text_texture.refresh_fonts" - bl_label = "Refresh Fonts" - bl_description = "Refresh font list" - bl_options = {'REGISTER', 'UNDO'} - - def execute(self, context): - if hasattr(get_font_enum_items, 'cached_fonts'): - del get_font_enum_items.cached_fonts - get_font_enum_items.cached_fonts = get_system_fonts() - self.report({'INFO'}, f"Found {len(get_font_enum_items.cached_fonts)} fonts") - return {'FINISHED'} - -class TEXT_TEXTURE_OT_refresh_presets(Operator): - """Refresh presets from blend file and local storage""" - bl_idname = "text_texture.refresh_presets" - bl_label = "Refresh Presets" - bl_description = "Reload presets from blend file and local storage" - bl_options = {'REGISTER', 'UNDO'} - - def execute(self, context): - try: - # Use the reliable synchronous refresh system - success = refresh_presets_sync() - - if success: - props = context.scene.text_texture_props - total_presets = len(props.presets) - - # Count by source for detailed reporting - blend_count = len([p for p in props.presets if p.source == 'BLEND_FILE']) - persistent_count = len([p for p in props.presets if p.source == 'PERSISTENT_FILE']) - local_count = len([p for p in props.presets if p.source == 'LOCAL_FILE']) - - self.report({'INFO'}, f"✅ Refreshed {total_presets} presets ({blend_count} blend file, {persistent_count} persistent, {local_count} local)") - else: - self.report({'WARNING'}, "Preset refresh completed with issues - check console for details") - - # Force UI redraw - for area in context.screen.areas: - area.tag_redraw() - - return {'FINISHED'} - - except Exception as e: - self.report({'ERROR'}, f"Failed to refresh presets: {str(e)}") - import traceback - traceback.print_exc() - return {'CANCELLED'} - -class TEXT_TEXTURE_OT_add_overlay(Operator): - """Add new image overlay""" - bl_idname = "text_texture.add_overlay" - bl_label = "Add Overlay" - bl_description = "Add new image overlay" - bl_options = {'REGISTER', 'UNDO'} - - def execute(self, context): - props = context.scene.text_texture_props - - overlay = props.image_overlays.add() - overlay.name = f"Overlay {len(props.image_overlays)}" - props.active_overlay_index = len(props.image_overlays) - 1 - - # Force UI redraw - for area in context.screen.areas: - area.tag_redraw() - - self.report({'INFO'}, f"Added overlay: {overlay.name}") - return {'FINISHED'} - -class TEXT_TEXTURE_OT_remove_overlay(Operator): - """Remove active image overlay""" - bl_idname = "text_texture.remove_overlay" - bl_label = "Remove Overlay" - bl_description = "Remove active image overlay" - bl_options = {'REGISTER', 'UNDO'} - - def execute(self, context): - props = context.scene.text_texture_props - - if props.image_overlays and props.active_overlay_index < len(props.image_overlays): - overlay_name = props.image_overlays[props.active_overlay_index].name - props.image_overlays.remove(props.active_overlay_index) - - # Update active index - if props.active_overlay_index >= len(props.image_overlays) and props.image_overlays: - props.active_overlay_index = len(props.image_overlays) - 1 - - # Force UI redraw - for area in context.screen.areas: - area.tag_redraw() - - self.report({'INFO'}, f"Removed overlay: {overlay_name}") - else: - self.report({'WARNING'}, "No overlay to remove") - - return {'FINISHED'} - -class TEXT_TEXTURE_OT_set_anchor_point(Operator): - """Set anchor point for text positioning""" - bl_idname = "text_texture.set_anchor_point" - bl_label = "Set Anchor Point" - bl_description = "Set text anchor position" - bl_options = {'REGISTER', 'UNDO'} - - anchor_point: StringProperty() - - def execute(self, context): - props = context.scene.text_texture_props - props.anchor_point = self.anchor_point - return {'FINISHED'} - -class TEXT_TEXTURE_OT_sync_margins(Operator): - """Sync margin values when linked""" - bl_idname = "text_texture.sync_margins" - bl_label = "Sync Margins" - bl_description = "Synchronize margin values" - bl_options = {'REGISTER', 'UNDO'} - - margin_type: StringProperty() - - def execute(self, context): - props = context.scene.text_texture_props - sync_margin_values(props, self.margin_type) - return {'FINISHED'} - -class TEXT_TEXTURE_OT_export_presets(Operator): - """Export all presets to a file for backup""" - bl_idname = "text_texture.export_presets" - bl_label = "Export Presets" - bl_description = "Export all presets to a file for backup" - bl_options = {'REGISTER', 'UNDO'} - - filepath: StringProperty( - name="File Path", - description="Path to save preset backup file", - subtype='FILE_PATH', - default="text_texture_presets_backup.json" - ) - - def execute(self, context): - try: - props = context.scene.text_texture_props - - # Collect all presets from all sources - all_presets = {} - - # Get blend file presets - blend_presets = get_blend_file_presets() - for name, data in blend_presets.items(): - all_presets[name] = { - "data": data, - "source": "BLEND_FILE" - } - - # Get persistent presets - persistent_dir = get_persistent_preset_path() - if os.path.exists(persistent_dir): - for filename in os.listdir(persistent_dir): - if filename.endswith('.json'): - preset_name = os.path.splitext(filename)[0] - if preset_name not in all_presets: # Don't override blend file presets - try: - with open(os.path.join(persistent_dir, filename), 'r') as f: - preset_data = json.load(f) - all_presets[preset_name] = { - "data": preset_data, - "source": "PERSISTENT_FILE" - } - except (json.JSONDecodeError, OSError) as e: - print(f"[TTG] Error reading persistent preset {preset_name}: {e}") - - # Get legacy presets - legacy_dir = get_preset_path() - if os.path.exists(legacy_dir) and legacy_dir != persistent_dir: - for filename in os.listdir(legacy_dir): - if filename.endswith('.json'): - preset_name = os.path.splitext(filename)[0] - if preset_name not in all_presets: # Don't override higher priority presets - try: - with open(os.path.join(legacy_dir, filename), 'r') as f: - preset_data = json.load(f) - all_presets[preset_name] = { - "data": preset_data, - "source": "LOCAL_FILE" - } - except (json.JSONDecodeError, OSError) as e: - print(f"[TTG] Error reading legacy preset {preset_name}: {e}") - - if not all_presets: - self.report({'WARNING'}, "No presets found to export") - return {'CANCELLED'} - - # Create export data with metadata - export_data = { - "format_version": "1.0", - "addon_version": bl_info["version"], - "export_timestamp": time.time(), - "presets": all_presets - } - - # Write to file - with open(self.filepath, 'w') as f: - json.dump(export_data, f, indent=2) - - self.report({'INFO'}, f"✅ Exported {len(all_presets)} presets to {os.path.basename(self.filepath)}") - return {'FINISHED'} - - except Exception as e: - self.report({'ERROR'}, f"Failed to export presets: {str(e)}") - return {'CANCELLED'} - - def invoke(self, context, event): - # Set default filename with timestamp - import datetime - timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") - self.filepath = f"text_texture_presets_backup_{timestamp}.json" - - context.window_manager.fileselect_add(self) - return {'RUNNING_MODAL'} - -class TEXT_TEXTURE_OT_import_presets(Operator): - """Import presets from a backup file""" - bl_idname = "text_texture.import_presets" - bl_label = "Import Presets" - bl_description = "Import presets from a backup file" - bl_options = {'REGISTER', 'UNDO'} - - filepath: StringProperty( - name="File Path", - description="Path to preset backup file", - subtype='FILE_PATH' - ) - - import_mode: EnumProperty( - name="Import Mode", - description="How to handle existing presets with same names", - items=[ - ('SKIP', 'Skip Existing', 'Skip presets that already exist'), - ('OVERWRITE', 'Overwrite', 'Overwrite existing presets'), - ('RENAME', 'Rename New', 'Rename imported presets if they conflict'), - ], - default='SKIP' - ) - - import_to_blend: BoolProperty( - name="Import to Blend File", - description="Import presets to blend file (recommended for portability)", - default=True - ) - - def execute(self, context): - try: - if not os.path.exists(self.filepath): - self.report({'ERROR'}, "Backup file not found") - return {'CANCELLED'} - - props = context.scene.text_texture_props - - # Read backup file - with open(self.filepath, 'r') as f: - import_data = json.load(f) - - # Validate format - if "presets" not in import_data: - self.report({'ERROR'}, "Invalid backup file format") - return {'CANCELLED'} - - imported_presets = import_data["presets"] - if not imported_presets: - self.report({'WARNING'}, "No presets found in backup file") - return {'CANCELLED'} - - # Get existing presets to check for conflicts - existing_blend_presets = get_blend_file_presets() - existing_preset_names = set(existing_blend_presets.keys()) - - # Also check persistent storage - persistent_dir = get_persistent_preset_path() - if os.path.exists(persistent_dir): - for filename in os.listdir(persistent_dir): - if filename.endswith('.json'): - existing_preset_names.add(os.path.splitext(filename)[0]) - - imported_count = 0 - skipped_count = 0 - errors = [] - - for preset_name, preset_info in imported_presets.items(): - preset_data = preset_info.get("data", {}) - - # Handle conflicts - final_name = preset_name - if preset_name in existing_preset_names: - if self.import_mode == 'SKIP': - skipped_count += 1 - continue - elif self.import_mode == 'RENAME': - counter = 1 - base_name = preset_name - while final_name in existing_preset_names: - final_name = f"{base_name}_imported_{counter}" - counter += 1 - # OVERWRITE mode uses original name - - try: - # Import to blend file or persistent storage based on setting - if self.import_to_blend: - success = save_blend_file_preset(final_name, preset_data) - if not success: - raise Exception("Failed to save to blend file") - else: - # Save to persistent storage - persistent_file = os.path.join(get_persistent_preset_path(), f"{final_name}.json") - with open(persistent_file, 'w') as f: - json.dump(preset_data, f, indent=2) - - imported_count += 1 - - # Add to UI list if not already present - existing_preset = None - for preset in props.presets: - if preset.name == final_name: - existing_preset = preset - break - - if not existing_preset: - new_preset = props.presets.add() - new_preset.name = final_name - new_preset.source = 'BLEND_FILE' if self.import_to_blend else 'PERSISTENT_FILE' - else: - existing_preset.source = 'BLEND_FILE' if self.import_to_blend else 'PERSISTENT_FILE' - - except Exception as e: - errors.append(f"{preset_name}: {str(e)}") - - # Report results - if imported_count > 0: - storage_location = "blend file" if self.import_to_blend else "persistent storage" - self.report({'INFO'}, f"✅ Imported {imported_count} presets to {storage_location}") - if skipped_count > 0: - self.report({'INFO'}, f"⏭️ Skipped {skipped_count} existing presets") - else: - self.report({'WARNING'}, "No presets were imported") - - if errors: - self.report({'WARNING'}, f"Errors importing {len(errors)} presets - check console") - for error in errors[:5]: # Show first 5 errors - print(f"[TTG] Import error: {error}") - - return {'FINISHED'} - - except Exception as e: - self.report({'ERROR'}, f"Failed to import presets: {str(e)}") - return {'CANCELLED'} - - def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {'RUNNING_MODAL'} - - def draw(self, context): - layout = self.layout - layout.prop(self, "import_mode") - layout.prop(self, "import_to_blend") - -class TEXT_TEXTURE_OT_show_migration_report(Operator): - """Show migration report""" - bl_idname = "text_texture.show_migration_report" - bl_label = "Show Migration Report" - bl_description = "Display migration report with details about preset migration" - bl_options = {'REGISTER', 'UNDO'} - - def execute(self, context): - try: - import json - import os - persistent_dir = os.path.dirname(get_persistent_preset_path()) - migration_report_file = os.path.join(persistent_dir, "migration_report.json") - - if os.path.exists(migration_report_file): - with open(migration_report_file, 'r') as f: - migration_data = json.load(f) - - migrated_count = migration_data.get('migrated_count', 0) - total_found = migration_data.get('total_found', 0) - errors = migration_data.get('errors', []) - backup_path = migration_data.get('backup_path', 'Unknown') - - self.report({'INFO'}, f"Migration Report: {migrated_count}/{total_found} presets migrated successfully") - - if errors: - error_summary = f"{len(errors)} errors occurred during migration" - self.report({'WARNING'}, error_summary) - print(f"[TTG] Migration errors:") - for error in errors[:10]: # Show first 10 errors - print(f"[TTG] - {error}") - else: - self.report({'INFO'}, "No migration errors") - - if backup_path != 'Unknown': - print(f"[TTG] Migration backup created at: {backup_path}") - else: - self.report({'INFO'}, "No migration report found - migration may not have been needed") - - return {'FINISHED'} - except Exception as e: - self.report({'ERROR'}, f"Error reading migration report: {e}") - return {'CANCELLED'} - - - - -class TEXT_TEXTURE_OT_duplicate_overlay(Operator): - """Duplicate image overlay""" - bl_idname = "text_texture.duplicate_overlay" - bl_label = "Duplicate Overlay" - bl_description = "Duplicate this image overlay" - bl_options = {'REGISTER', 'UNDO'} - - overlay_index: IntProperty() - - def execute(self, context): - props = context.scene.text_texture_props - - if 0 <= self.overlay_index < len(props.image_overlays): - source_overlay = props.image_overlays[self.overlay_index] - - # Debug logging - print(f"[DUPLICATE DEBUG] Duplicating overlay '{source_overlay.name}'") - print(f"[DUPLICATE DEBUG] Source properties:") - print(f"[DUPLICATE DEBUG] image_path: '{source_overlay.image_path}'") - print(f"[DUPLICATE DEBUG] enabled: {source_overlay.enabled}") - print(f"[DUPLICATE DEBUG] positioning_mode: '{source_overlay.positioning_mode}'") - print(f"[DUPLICATE DEBUG] scale: {source_overlay.scale}") - print(f"[DUPLICATE DEBUG] z_index: {source_overlay.z_index}") - - new_overlay = props.image_overlays.add() - - # Copy all properties with explicit verification - new_overlay.name = f"{source_overlay.name} Copy" - new_overlay.image_path = source_overlay.image_path - new_overlay.x_position = source_overlay.x_position - new_overlay.y_position = source_overlay.y_position - new_overlay.scale = source_overlay.scale - new_overlay.rotation = source_overlay.rotation - # Fix z-index assignment for duplicated overlays to ensure proper visibility - # For PREPEND/APPEND overlays, increment z_index to ensure duplicate appears above original - # This prevents the duplicate from being rendered behind the original overlay - if source_overlay.positioning_mode in ['PREPEND', 'APPEND']: - # Increment z_index to ensure proper layering order for duplicated overlay - new_overlay.z_index = min(source_overlay.z_index + 1, 10) # Cap at max z_index value - print(f"[DUPLICATE DEBUG] Fixed z_index for {source_overlay.positioning_mode} overlay: {source_overlay.z_index} -> {new_overlay.z_index}") - else: - # For ABSOLUTE overlays, keep same z_index (different positions so no overlap issue) - new_overlay.z_index = source_overlay.z_index - new_overlay.positioning_mode = source_overlay.positioning_mode - new_overlay.text_spacing = source_overlay.text_spacing - new_overlay.image_spacing = source_overlay.image_spacing - new_overlay.enabled = source_overlay.enabled - - # Verify the copy was successful - print(f"[DUPLICATE DEBUG] New overlay properties:") - print(f"[DUPLICATE DEBUG] name: '{new_overlay.name}'") - print(f"[DUPLICATE DEBUG] image_path: '{new_overlay.image_path}'") - print(f"[DUPLICATE DEBUG] enabled: {new_overlay.enabled}") - print(f"[DUPLICATE DEBUG] positioning_mode: '{new_overlay.positioning_mode}'") - print(f"[DUPLICATE DEBUG] scale: {new_overlay.scale}") - print(f"[DUPLICATE DEBUG] z_index: {new_overlay.z_index}") - - # Move to position after source - new_index = len(props.image_overlays) - 1 - target_index = min(self.overlay_index + 1, new_index) - - if new_index != target_index: - props.image_overlays.move(new_index, target_index) - print(f"[DUPLICATE DEBUG] Moved overlay from index {new_index} to {target_index}") - - props.active_overlay_index = target_index - - # Clear any cached images to force reload - print(f"[DUPLICATE DEBUG] Clearing image cache and forcing preview update...") - - # Clear the overlay image cache if it exists - if hasattr(generate_texture_image, 'overlay_image_cache'): - generate_texture_image.overlay_image_cache = {} - print(f"[DUPLICATE DEBUG] Cleared overlay image cache") - - # Set the updating flag to prevent recursive updates - props.is_updating = True - - try: - # Force immediate preview regeneration - print(f"[DUPLICATE DEBUG] Generating new preview...") - preview_result = generate_preview(context) - if preview_result: - print(f"[DUPLICATE DEBUG] Preview generated successfully") - else: - print(f"[DUPLICATE DEBUG] Preview generation failed") - - # Force UI redraw - for area in context.screen.areas: - area.tag_redraw() - - except Exception as e: - print(f"[DUPLICATE DEBUG] Error updating preview: {e}") - import traceback - traceback.print_exc() - finally: - props.is_updating = False - - print(f"[DUPLICATE DEBUG] Total overlays now: {len(props.image_overlays)}") - print(f"[DUPLICATE DEBUG] Duplication operation completed") - - self.report({'INFO'}, f"✅ Duplicated overlay: {source_overlay.name}") - else: - self.report({'ERROR'}, "Invalid overlay index") - return {'CANCELLED'} - - return {'FINISHED'} - -class TEXT_TEXTURE_OT_delete_overlay(Operator): - """Delete image overlay""" - bl_idname = "text_texture.delete_overlay" - bl_label = "Delete Overlay" - bl_description = "Delete this image overlay" - bl_options = {'REGISTER', 'UNDO'} - - overlay_index: IntProperty() - - def execute(self, context): - props = context.scene.text_texture_props - - print(f"[DELETE DEBUG] Deleting overlay at index {self.overlay_index}") - - if 0 <= self.overlay_index < len(props.image_overlays): - overlay_name = props.image_overlays[self.overlay_index].name - print(f"[DELETE DEBUG] Deleting overlay: '{overlay_name}'") - - props.image_overlays.remove(self.overlay_index) - - # Update active index - if props.active_overlay_index >= len(props.image_overlays) and props.image_overlays: - props.active_overlay_index = len(props.image_overlays) - 1 - elif not props.image_overlays: - props.active_overlay_index = 0 - - print(f"[DELETE DEBUG] Overlay deleted successfully. Total overlays now: {len(props.image_overlays)}") - - # Force preview update after deletion - try: - generate_preview(context) - print(f"[DELETE DEBUG] Preview updated after deletion") - except Exception as e: - print(f"[DELETE DEBUG] Error updating preview: {e}") - - # Force UI redraw - for area in context.screen.areas: - area.tag_redraw() - - self.report({'INFO'}, f"Deleted overlay: {overlay_name}") - else: - print(f"[DELETE DEBUG] Invalid overlay index: {self.overlay_index}") - self.report({'ERROR'}, "Invalid overlay index") - return {'CANCELLED'} - - return {'FINISHED'} - -class TEXT_TEXTURE_OT_move_overlay(Operator): - """Move image overlay up or down in the list""" - bl_idname = "text_texture.move_overlay" - bl_label = "Move Overlay" - bl_description = "Move overlay up or down in the list" - bl_options = {'REGISTER', 'UNDO'} - - overlay_index: IntProperty() - direction: EnumProperty( - items=[ - ('UP', 'Up', 'Move overlay up in list'), - ('DOWN', 'Down', 'Move overlay down in list') - ] - ) - - def execute(self, context): - props = context.scene.text_texture_props - - print(f"[MOVE DEBUG] Moving overlay at index {self.overlay_index} direction: {self.direction}") - - if 0 <= self.overlay_index < len(props.image_overlays): - current_index = self.overlay_index - new_index = current_index - - if self.direction == 'UP' and current_index > 0: - new_index = current_index - 1 - elif self.direction == 'DOWN' and current_index < len(props.image_overlays) - 1: - new_index = current_index + 1 - - if new_index != current_index: - overlay_name = props.image_overlays[current_index].name - props.image_overlays.move(current_index, new_index) - props.active_overlay_index = new_index - - print(f"[MOVE DEBUG] Successfully moved overlay '{overlay_name}' from {current_index} to {new_index}") - - # Force preview update after moving (order affects rendering) - try: - generate_preview(context) - print(f"[MOVE DEBUG] Preview updated after move operation") - except Exception as e: - print(f"[MOVE DEBUG] Error updating preview: {e}") - - self.report({'INFO'}, f"Moved overlay {self.direction.lower()}") - else: - print(f"[MOVE DEBUG] No movement needed - overlay is already at edge") - self.report({'INFO'}, f"Overlay is already at {self.direction.lower()} edge") - else: - print(f"[MOVE DEBUG] Invalid overlay index: {self.overlay_index}") - self.report({'ERROR'}, "Invalid overlay index") - return {'CANCELLED'} - - return {'FINISHED'} - -# ============================================================================ -# SHARED UI COMPONENTS -# ============================================================================ - -def draw_collapsible_header(layout, prop_name, text, icon='TRIA_DOWN'): - """Draw a collapsible section header with expand/collapse arrow""" - props = bpy.context.scene.text_texture_props - expanded = getattr(props, prop_name) - - box = layout.box() - header_row = box.row() - - # Draw arrow icon and toggle expanded state - arrow_icon = 'TRIA_DOWN' if expanded else 'TRIA_RIGHT' - header_row.prop(props, prop_name, text="", icon=arrow_icon, emboss=False) - header_row.label(text=text, icon=icon) - - return box if expanded else None - -def draw_text_input_section(layout, props): - """Draw the main text input field""" - layout.prop(props, "text", text="") - -def draw_font_dropdown_section(layout, props): - """Draw basic font dropdown for top level""" - row = layout.row(align=True) - row.prop(props, "font_path", text="") - row.operator("text_texture.refresh_fonts", text="", icon='FILE_REFRESH') - -def draw_generate_button(layout): - """Draw the main generate button""" - row = layout.row() - row.scale_y = 1.5 - row.operator("text_texture.generate", text="Generate Text Texture", icon='IMAGE_DATA') - -def draw_image_overlays_section(layout, props): - """Draw image overlays controls with collapsible individual overlays""" - # Add overlay button - row = layout.row() - row.operator("text_texture.add_overlay", text="Add Image Overlay", icon='ADD') - - # Individual overlay controls - if props.image_overlays: - layout.separator() - - for i, overlay in enumerate(props.image_overlays): - # Create collapsible box for each overlay - box = layout.box() - - # Header row with controls - header_row = box.row(align=True) - - # Enable/Disable checkbox with clear label - header_row.prop(overlay, "enabled", text="", icon='CHECKBOX_HLT' if overlay.enabled else 'CHECKBOX_DEHLT') - - # Overlay name - name_row = header_row.row() - name_row.enabled = overlay.enabled # Gray out name if disabled - name_row.prop(overlay, "name", text="") - - # Action buttons - # Duplicate button - dup_op = header_row.operator("text_texture.duplicate_overlay", text="", icon='DUPLICATE') - dup_op.overlay_index = i - - # Delete button - del_op = header_row.operator("text_texture.delete_overlay", text="", icon='TRASH') - del_op.overlay_index = i - - # Move buttons for reordering - if i > 0: - move_up_op = header_row.operator("text_texture.move_overlay", text="", icon='TRIA_UP') - move_up_op.overlay_index = i - move_up_op.direction = 'UP' - - if i < len(props.image_overlays) - 1: - move_down_op = header_row.operator("text_texture.move_overlay", text="", icon='TRIA_DOWN') - move_down_op.overlay_index = i - move_down_op.direction = 'DOWN' - - # Show details section with enable/disable state - details_col = box.column() - details_col.enabled = overlay.enabled # Gray out all controls if disabled - - # Status indicator - if not overlay.enabled: - status_row = details_col.row() - status_row.label(text="⚠️ Overlay Disabled", icon='INFO') - details_col.separator() - - # Image file selection - details_col.prop(overlay, "image_path", text="Image File") - - # Positioning mode - details_col.prop(overlay, "positioning_mode", text="Position Mode") - - if overlay.positioning_mode == 'ABSOLUTE': - # Position controls for absolute mode - pos_row = details_col.row(align=True) - pos_row.prop(overlay, "x_position", text="X Position") - pos_row.prop(overlay, "y_position", text="Y Position") - elif overlay.positioning_mode in ['APPEND', 'PREPEND']: - # Spacing controls for append/prepend modes with percentage labels - details_col.prop(overlay, "text_spacing", text="Text Spacing (%)") - details_col.prop(overlay, "image_spacing", text="Image Spacing (%)") - - # Transform controls - trans_row = details_col.row(align=True) - trans_row.prop(overlay, "scale", text="Scale") - trans_row.prop(overlay, "rotation", text="Rotation") - trans_row.prop(overlay, "z_index", text="Z-Level") - -def draw_positioning_section(layout, props): - """Draw positioning and alignment controls""" - # Dimensions moved here from top level - layout.label(text="Dimensions:", icon='SETTINGS') - row = layout.row(align=True) - row.prop(props, "texture_width", text="Width") - row.prop(props, "texture_height", text="Height") - - layout.separator() - - # Prepend/Append Text Section - layout.label(text="Additional Text:", icon='TEXT') - - # Compact layout selector - layout.prop(props, "prepend_append_layout", text="Layout") - - # Minimal prepend/append controls - row = layout.row(align=True) - row.prop(props, "prepend_text", text="Prepend") - if props.prepend_text.strip(): - row.prop(props, "prepend_margin", text="", slider=True) - - row = layout.row(align=True) - row.prop(props, "append_text", text="Append") - if props.append_text.strip(): - row.prop(props, "append_margin", text="", slider=True) - - layout.separator() - layout.prop(props, "position_mode", text="Mode") - - if props.position_mode == 'PRESET': - layout.separator() - layout.label(text="Anchor Point:", icon='ANCHOR_TOP') - - anchor_matrix = get_anchor_point_matrix() - for row_idx, row in enumerate(anchor_matrix): - grid_row = layout.row(align=True) - grid_row.scale_y = 1.2 - for col_idx, anchor in enumerate(row): - if anchor == props.anchor_point: - op = grid_row.operator("text_texture.set_anchor_point", text="●", depress=True) - else: - op = grid_row.operator("text_texture.set_anchor_point", text="○") - op.anchor_point = anchor - - # Margins - layout.separator() - margin_row = layout.row(align=True) - margin_row.label(text="Margins:") - margin_row.prop(props, "margins_linked", text="", icon='LINKED' if props.margins_linked else 'UNLINKED', toggle=True) - - margins_row = layout.row(align=True) - margins_row.prop(props, "margin_x", text="X") - if not props.margins_linked: - margins_row.prop(props, "margin_y", text="Y") - - # Offsets - layout.separator() - layout.label(text="Fine-tune (pixels):") - offset_row = layout.row(align=True) - offset_row.prop(props, "offset_x", text="X") - offset_row.prop(props, "offset_y", text="Y") - - else: # MANUAL mode - layout.separator() - layout.prop(props, "manual_position_unit", text="Unit") - manual_row = layout.row(align=True) - manual_row.prop(props, "manual_position_x", text="X") - manual_row.prop(props, "manual_position_y", text="Y") - - layout.separator() - layout.prop(props, "constrain_to_canvas") - -def draw_font_settings_section(layout, props): - """Draw extended font settings""" - # Font size moved here from top level - layout.prop(props, "font_size") - - layout.separator() - layout.label(text="Font Selection:") - # Default font dropdown moved here from top level - row = layout.row(align=True) - row.prop(props, "font_path", text="") - row.operator("text_texture.refresh_fonts", text="", icon='FILE_REFRESH') - - layout.separator() - # Custom font controls moved here from top level - layout.prop(props, "use_custom_font") - if props.use_custom_font: - layout.prop(props, "custom_font_path", text="") - - layout.separator() - row = layout.row(align=True) - row.prop(props, "padding") - row.prop(props, "text_align") - -def draw_colors_section(layout, props): - """Draw color controls for text and background""" - layout.label(text="Text Color:", icon='COLOR') - layout.prop(props, "text_color", text="") - - layout.separator() - layout.label(text="Background Color:", icon='IMAGE_PLANE') - layout.prop(props, "background_transparent") - if not props.background_transparent: - layout.prop(props, "background_color", text="") - -def draw_preview_options_section(layout, props): - """Draw preview options (preview-specific settings only)""" - layout.prop(props, "preview_size", text="Preview Size") - layout.separator() - layout.label(text="Preview Background Display:", icon='SCENE') - layout.prop(props, "preview_bg_type", text="Background") - if props.preview_bg_type == 'color': - layout.prop(props, "preview_bg_color", text="Preview Display Color") - elif props.preview_bg_type == 'gradient': - layout.prop(props, "preview_bg_color", text="Color 1") - layout.prop(props, "preview_bg_color2", text="Color 2") - -def draw_presets_section(layout, props): - """Draw presets save/load controls""" - # Save preset section - save_row = layout.row(align=True) - save_row.prop(props, "new_preset_name", text="", placeholder="Preset Name") - save_row.operator("text_texture.save_preset", text="Save", icon='FILE_NEW') - - layout.separator() - - # Import/Export and refresh buttons - backup_row = layout.row(align=True) - backup_row.operator("text_texture.export_presets", text="Export Backup", icon='EXPORT') - backup_row.operator("text_texture.import_presets", text="Import Backup", icon='IMPORT') - - utility_row = layout.row(align=True) - utility_row.operator("text_texture.refresh_presets", text="Refresh", icon='FILE_REFRESH') - utility_row.operator("text_texture.show_migration_report", text="Migration Report", icon='INFO') - - layout.separator() - - # Load presets section - if props.presets: - layout.label(text=f"📁 Saved Presets ({len(props.presets)}):", icon='PRESET') - - # Separate presets by source - blend_presets = [p for p in props.presets if p.source == 'BLEND_FILE'] - persistent_presets = [p for p in props.presets if p.source == 'PERSISTENT_FILE'] - local_presets = [p for p in props.presets if p.source == 'LOCAL_FILE'] - - # Show blend file presets first - if blend_presets: - layout.label(text="🎯 Blend File Presets (travel with file):", icon='FILE_BLEND') - for preset in blend_presets: - row = layout.row() - load_btn = row.operator("text_texture.load_preset", text=f"📋 {preset.name}") - load_btn.preset_name = preset.name - - # Quick overwrite button - overwrite_btn = row.operator("text_texture.save_preset", text="", icon='FILE_REFRESH') - overwrite_btn.overwrite = True - # Set the preset name so it can be overwritten - overwrite_btn.preset_name = preset.name - - delete_btn = row.operator("text_texture.delete_preset", text="", icon='TRASH') - delete_btn.preset_name = preset.name - - # Show persistent presets second - if persistent_presets: - if blend_presets: - layout.separator() - layout.label(text="🛡️ Persistent Presets (survive addon updates):", icon='PREFERENCES') - for preset in persistent_presets: - row = layout.row() - load_btn = row.operator("text_texture.load_preset", text=f"📋 {preset.name}") - load_btn.preset_name = preset.name - - # Quick overwrite button - overwrite_btn = row.operator("text_texture.save_preset", text="", icon='FILE_REFRESH') - overwrite_btn.overwrite = True - # Set the preset name so it can be overwritten - overwrite_btn.preset_name = preset.name - - delete_btn = row.operator("text_texture.delete_preset", text="", icon='TRASH') - delete_btn.preset_name = preset.name - - # Show local presets third - if local_presets: - if blend_presets or persistent_presets: - layout.separator() - layout.label(text="💻 Local Presets (this machine only):", icon='DISK_DRIVE') - for preset in local_presets: - row = layout.row() - load_btn = row.operator("text_texture.load_preset", text=f"📋 {preset.name}") - load_btn.preset_name = preset.name - - # Quick overwrite button - overwrite_btn = row.operator("text_texture.save_preset", text="", icon='FILE_REFRESH') - overwrite_btn.overwrite = True - # Set the preset name so it can be overwritten - overwrite_btn.preset_name = preset.name - - delete_btn = row.operator("text_texture.delete_preset", text="", icon='TRASH') - delete_btn.preset_name = preset.name - else: - layout.label(text="📂 No presets saved yet", icon='INFO') - layout.label(text="💡 Save your first preset above!", icon='LIGHT_DATA') - layout.separator() - info_box = layout.box() - info_box.label(text="ℹ️ Presets are now saved in the blend file", icon='INFO') - info_box.label(text="They will travel with your .blend file!") - -def draw_shader_section(layout, props): - """Draw shader generation controls""" - # Create Shader Button (as requested) - row = layout.row() - row.scale_y = 1.5 - row.operator("text_texture.generate_shader", text="Create Shader", icon='MATERIAL') - - layout.separator() - - # Clean minimalist controls - col = layout.column(align=True) - col.prop(props, "shader_type", text="Type") - - # Show relevant options based on shader type - if props.shader_type == 'PRINCIPLED': - col.prop(props, "shader_connection", text="Connect") - if props.shader_connection == 'EMISSION': - col.prop(props, "emission_strength", text="Strength") - elif props.shader_type == 'EMISSION': - col.prop(props, "emission_strength", text="Strength") - - layout.separator() - - # Simple toggles - col = layout.column(align=True) - col.prop(props, "use_alpha", text="Use Alpha Channel") - col.prop(props, "auto_assign_material", text="Auto-Assign to Object") - - # Clean status info - if props.auto_assign_material: - layout.separator() - info_row = layout.row() - info_row.scale_y = 0.8 - try: - context = bpy.context - if context and context.active_object and context.active_object.type in ['MESH', 'CURVE']: - info_row.label(text=f"→ {context.active_object.name}", icon='CHECKMARK') - else: - info_row.label(text="Select mesh/curve object", icon='INFO') - except: - info_row.label(text="Select mesh/curve object", icon='INFO') - -def draw_shader_options_only(layout, props): - """Draw only shader options without the Create button - for collapsible section""" - # Clean grid layout for shader options - grid = layout.grid_flow(row_major=True, columns=2, align=True) - - grid.prop(props, "shader_type", text="Type") - - if props.shader_type == 'PRINCIPLED': - grid.prop(props, "shader_connection", text="Connect") - if props.shader_connection == 'EMISSION': - grid.prop(props, "emission_strength", text="Strength") - elif props.shader_type == 'EMISSION': - grid.prop(props, "emission_strength", text="Strength") - - # Simple row for toggles - layout.separator() - row = layout.row(align=True) - row.prop(props, "use_alpha", toggle=True) - row.prop(props, "auto_assign_material", toggle=True) - - # Shadow, reflection, and backfacing toggles - row = layout.row(align=True) - row.prop(props, "disable_shadows", toggle=True) - row.prop(props, "disable_reflections", toggle=True) - row.prop(props, "disable_backfacing", toggle=True) - -def draw_normal_maps_section(layout, props): - """Draw normal map generation controls""" - # Main toggle for normal map generation - layout.prop(props, "generate_normal_map", text="Generate Normal Map", icon='TEXTURE') - - # Show controls only if normal map generation is enabled - if props.generate_normal_map: - layout.separator() - - # Normal map settings in a clean layout - col = layout.column(align=True) - col.prop(props, "normal_map_strength", text="Strength", slider=True) - col.prop(props, "normal_map_blur_radius", text="Blur Radius", slider=True) - - layout.separator() - layout.prop(props, "normal_map_invert", text="Invert (Emboss ↔ Deboss)") - - # Info box with usage tips - layout.separator() - info_box = layout.box() - info_box.scale_y = 0.8 - info_box.label(text="💡 Normal maps add depth and detail", icon='INFO') - info_box.label(text="• Higher strength = more pronounced effect") - info_box.label(text="• Blur smooths sharp edges") - info_box.label(text="• Invert changes raised/recessed effect") - -def draw_text_fitting_section(layout, props): - """Draw text fitting controls""" - # Main toggle for text fitting - layout.prop(props, "enable_text_fitting", text="Enable Text Fitting", icon='FULLSCREEN_ENTER') - - # Show controls only if text fitting is enabled - if props.enable_text_fitting: - layout.separator() - - # Fitting mode selection - layout.prop(props, "text_fitting_mode", text="Fitting Mode") - - layout.separator() - - # Margin setting - layout.prop(props, "text_fitting_margin", text="Margin", slider=True) - - layout.separator() - - # Font size limits - col = layout.column(align=True) - col.label(text="Font Size Limits:", icon='RESTRICT_SELECT_ON') - row = col.row(align=True) - row.prop(props, "min_font_size", text="Min") - row.prop(props, "max_font_size", text="Max") - - # Info box with usage tips - layout.separator() - info_box = layout.box() - info_box.scale_y = 0.8 - info_box.label(text="💡 Text fitting automatically sizes text", icon='INFO') - - if props.text_fitting_mode == 'FIT_WIDTH': - info_box.label(text="• Fits text width to canvas width") - elif props.text_fitting_mode == 'FIT_HEIGHT': - info_box.label(text="• Fits text height to canvas height") - elif props.text_fitting_mode == 'FIT_BOTH': - info_box.label(text="• Fits both width and height (may distort)") - elif props.text_fitting_mode == 'FIT_CONTAIN': - info_box.label(text="• Fits entirely within canvas (preserves ratio)") - - info_box.label(text="• Respects min/max font size limits") - -def draw_multiline_section(layout, props): - """Draw multiline text controls""" - # Main toggle for multiline text - layout.prop(props, "enable_multiline", text="Enable Multiline", icon='TEXT') - - # Show controls only if multiline is enabled - if props.enable_multiline: - layout.separator() - - # Line height and alignment settings - col = layout.column(align=True) - col.prop(props, "line_height", text="Line Height", slider=True) - col.prop(props, "text_alignment", text="Alignment") - - layout.separator() - - # Auto wrap settings - layout.prop(props, "auto_wrap", text="Auto Wrap") - if props.auto_wrap: - layout.prop(props, "wrap_width", text="Wrap Width (0 = texture width)") - - layout.separator() - - # Line limits and spacing - col = layout.column(align=True) - col.prop(props, "max_lines", text="Max Lines (0 = unlimited)") - col.prop(props, "line_spacing_pixels", text="Line Spacing (px)") - - # Info box with usage tips - layout.separator() - info_box = layout.box() - info_box.scale_y = 0.8 - info_box.label(text="💡 Multiline text supports:", icon='INFO') - info_box.label(text="• Use \\n for manual line breaks") - info_box.label(text="• Auto-wrap fits text to width") - info_box.label(text="• Alignment works per line") - info_box.label(text="• Line height controls spacing") - -def draw_stroke_section(layout, props): - """Draw stroke and outline controls""" - # Main toggle for stroke - layout.prop(props, "enable_stroke", text="Enable Stroke", icon='MOD_OUTLINE') - - # Show controls only if stroke is enabled - if props.enable_stroke: - layout.separator() - - # Stroke width and color - col = layout.column(align=True) - col.prop(props, "stroke_width", text="Width", slider=True) - col.prop(props, "stroke_color", text="Color") - - layout.separator() - - # Stroke position - layout.prop(props, "stroke_position", text="Position") - - # Info box with usage tips - layout.separator() - info_box = layout.box() - info_box.scale_y = 0.8 - info_box.label(text="💡 Stroke adds outline to text:", icon='INFO') - - if props.stroke_position == 'OUTER': - info_box.label(text="• Outer: Expands text bounds outward") - elif props.stroke_position == 'INNER': - info_box.label(text="• Inner: Shrinks text, stroke inside") - elif props.stroke_position == 'CENTER': - info_box.label(text="• Center: Stroke on text edge") - - info_box.label(text="• Width 0 = no stroke") - info_box.label(text="• Works with multiline text") - -def draw_shadow_glow_section(layout, props): - """Draw shadow and glow effects controls""" - # Drop shadow section - layout.prop(props, "enable_shadow", text="Enable Drop Shadow", icon='SHADERFX') - - if props.enable_shadow: - layout.separator() - - # Shadow offset controls in a row - offset_row = layout.row(align=True) - offset_row.prop(props, "shadow_offset_x", text="X Offset") - offset_row.prop(props, "shadow_offset_y", text="Y Offset") - - # Shadow properties - col = layout.column(align=True) - col.prop(props, "shadow_blur", text="Shadow Blur", slider=True) - col.prop(props, "shadow_spread", text="Shadow Spread", slider=True) - col.prop(props, "shadow_color", text="Shadow Color") - - layout.separator() - - # Glow section - layout.prop(props, "enable_glow", text="Enable Glow", icon='LIGHT_SUN') - - if props.enable_glow: - layout.separator() - - # Glow properties - col = layout.column(align=True) - col.prop(props, "glow_blur", text="Glow Blur", slider=True) - col.prop(props, "glow_color", text="Glow Color") - - # Info box with usage tips - if props.enable_shadow or props.enable_glow: - layout.separator() - info_box = layout.box() - info_box.scale_y = 0.8 - info_box.label(text="💡 Shadow & Glow effects:", icon='INFO') - - if props.enable_shadow: - info_box.label(text="• Drop shadow: offset + blur for depth") - if props.enable_glow: - info_box.label(text="• Glow: blur without offset for aura effect") - - info_box.label(text="• Higher blur = softer effect") - info_box.label(text="• Alpha controls effect intensity") - -def draw_blur_section(layout, props): - """Draw blur effects controls""" - # Main toggle for blur - layout.prop(props, "enable_blur", text="Enable Blur", icon='MOD_SMOOTH') - - # Show controls only if blur is enabled - if props.enable_blur: - layout.separator() - - # Blur radius setting - layout.prop(props, "blur_radius", text="Blur Radius", slider=True) - - # Info box with usage tips - layout.separator() - info_box = layout.box() - info_box.scale_y = 0.8 - info_box.label(text="💡 Blur effect softens text edges:", icon='INFO') - info_box.label(text="• Applied to main text content only") - info_box.label(text="• Higher radius = softer/more blurred") - info_box.label(text="• Works with all other effects") - info_box.label(text="• Radius 0 = no blur effect") - -# ============================================================================ -# MENUS -# ============================================================================ - -def menu_func_node_editor(self, context): - """Add menu item to Node Editor""" - self.layout.separator() - self.layout.label(text="Text Texture Generator", icon='TEXT') - self.layout.operator("text_texture.generate", text="Generate Text Texture", icon='IMAGE_DATA') - -def menu_func_view3d(self, context): - """Add menu item to 3D View""" - self.layout.separator() - self.layout.operator("text_texture.generate", text="Generate Text Texture", icon='TEXT') - -def menu_func_image(self, context): - """Add to Image menu""" - self.layout.separator() - self.layout.operator("text_texture.generate", text="Generate Text Texture", icon='TEXT') - -# ============================================================================ -# UI PANELS -# ============================================================================ - -class TEXT_TEXTURE_PT_panel(Panel): - """Main Panel in Shader Editor""" - bl_label = "Text Texture Generator" - bl_idname = "TEXT_TEXTURE_PT_panel" - bl_space_type = 'NODE_EDITOR' - bl_region_type = 'UI' - bl_category = "Tool" - - @classmethod - def poll(cls, context): - # DEBUG: Log poll method calls - print(f"[TTG DEBUG] Panel poll called - space_data: {context.space_data}") - print(f"[TTG DEBUG] space_data.type: {context.space_data.type if context.space_data else 'None'}") - if hasattr(context.space_data, 'tree_type'): - print(f"[TTG DEBUG] tree_type: {context.space_data.tree_type}") - else: - print("[TTG DEBUG] No tree_type attribute") - - # More permissive poll condition to ensure panel shows up - if context.space_data.type == 'NODE_EDITOR': - if hasattr(context.space_data, 'tree_type'): - result = context.space_data.tree_type == 'ShaderNodeTree' - print(f"[TTG DEBUG] Poll result: {result}") - return result - else: - # Allow panel even without tree_type (sometimes happens) - print("[TTG DEBUG] Poll result: True (no tree_type but NODE_EDITOR)") - return True - - print("[TTG DEBUG] Poll result: False (not NODE_EDITOR)") - return False - - def draw(self, context): - layout = self.layout - props = context.scene.text_texture_props - - # TOP LEVEL - Always Visible Controls - - # Text Input - box = layout.box() - box.label(text="Text:", icon='TEXT') - draw_text_input_section(box, props) - - - # Generate Button - layout.separator(factor=0.5) - draw_generate_button(layout) - - # CREATE SHADER BUTTON AT TOP LEVEL - AS REQUESTED - layout.separator(factor=0.5) - create_shader_row = layout.row() - create_shader_row.scale_y = 1.5 - create_shader_row.operator("text_texture.generate_shader", text="Create Shader", icon='MATERIAL') - - # Preview Button at top level - layout.separator(factor=0.5) - preview_row = layout.row() - preview_row.scale_y = 1.5 - preview_row.operator("text_texture.view_preview_zoom", text="Open Preview Window", icon='WINDOW') - - layout.separator(factor=1.2) - - # COLLAPSIBLE SECTIONS - - # Multiline Section (collapsed by default) - multiline_section = draw_collapsible_header(layout, "expand_multiline", "Multiline Text", 'TEXT') - if multiline_section: - draw_multiline_section(multiline_section, props) - - # Shader Section (collapsed by default) - ONLY OPTIONS, NOT THE BUTTON - shader_section = draw_collapsible_header(layout, "expand_shader", "Shader Options", 'SETTINGS') - if shader_section: - draw_shader_options_only(shader_section, props) - - # Image Overlays Section (collapsed by default) - overlay_section = draw_collapsible_header(layout, "expand_image_overlays", "Image Overlays", 'IMAGE_DATA') - if overlay_section: - draw_image_overlays_section(overlay_section, props) - - # Positioning & Layout Section (collapsed by default) - position_section = draw_collapsible_header(layout, "expand_positioning", "Positioning & Layout", 'ANCHOR_CENTER') - if position_section: - draw_positioning_section(position_section, props) - - # Font Settings Section (collapsed by default) - font_section = draw_collapsible_header(layout, "expand_font_settings", "Font Settings", 'FONT_DATA') - if font_section: - draw_font_settings_section(font_section, props) - - # Text Fitting Section (collapsed by default) - text_fitting_section = draw_collapsible_header(layout, "expand_text_fitting", "Text Fitting", 'FULLSCREEN_ENTER') - if text_fitting_section: - draw_text_fitting_section(text_fitting_section, props) - - # Stroke Section (collapsed by default) - stroke_section = draw_collapsible_header(layout, "expand_stroke", "Stroke & Outlines", 'MOD_OUTLINE') - if stroke_section: - draw_stroke_section(stroke_section, props) - - # Shadow & Glow Section (collapsed by default) - shadow_glow_section = draw_collapsible_header(layout, "expand_shadow_glow", "Shadow & Glow Effects", 'SHADERFX') - if shadow_glow_section: - draw_shadow_glow_section(shadow_glow_section, props) - - # Blur Section (collapsed by default) - blur_section = draw_collapsible_header(layout, "expand_blur", "Blur Effects", 'MOD_SMOOTH') - if blur_section: - draw_blur_section(blur_section, props) - - # Normal Maps Section (collapsed by default) - normal_maps_section = draw_collapsible_header(layout, "expand_normal_maps", "Normal Maps", 'TEXTURE') - if normal_maps_section: - draw_normal_maps_section(normal_maps_section, props) - - # Colors Section (collapsed by default) - colors_section = draw_collapsible_header(layout, "expand_colors", "Colors", 'COLOR') - if colors_section: - draw_colors_section(colors_section, props) - - # Presets Section (collapsed by default) - presets_section = draw_collapsible_header(layout, "expand_presets", "Presets", 'PRESET') - if presets_section: - draw_presets_section(presets_section, props) - -class TEXT_TEXTURE_PT_panel_3d(Panel): - """3D View Panel - Access from sidebar (N key)""" - bl_label = "Text Texture Generator" - bl_idname = "TEXT_TEXTURE_PT_panel_3d" - bl_space_type = 'VIEW_3D' - bl_region_type = 'UI' - bl_category = "Tool" - bl_options = {'DEFAULT_CLOSED'} - - def draw(self, context): - layout = self.layout - props = context.scene.text_texture_props - - # TOP LEVEL - Always Visible Controls - - # Text Input - box = layout.box() - box.label(text="Text:", icon='TEXT') - draw_text_input_section(box, props) - - - # Generate Button - layout.separator(factor=0.5) - draw_generate_button(layout) - - # CREATE SHADER BUTTON AT TOP LEVEL - AS REQUESTED - layout.separator(factor=0.5) - create_shader_row = layout.row() - create_shader_row.scale_y = 1.5 - create_shader_row.operator("text_texture.generate_shader", text="Create Shader", icon='MATERIAL') - - # Preview Button at top level - layout.separator(factor=0.5) - preview_row = layout.row() - preview_row.scale_y = 1.5 - preview_row.operator("text_texture.view_preview_zoom", text="Open Preview Window", icon='WINDOW') - - layout.separator(factor=1.2) - - # COLLAPSIBLE SECTIONS - - # Multiline Section (collapsed by default) - multiline_section = draw_collapsible_header(layout, "expand_multiline", "Multiline Text", 'TEXT') - if multiline_section: - draw_multiline_section(multiline_section, props) - - # Shader Section (collapsed by default) - ONLY OPTIONS, NOT THE BUTTON - shader_section = draw_collapsible_header(layout, "expand_shader", "Shader Options", 'SETTINGS') - if shader_section: - draw_shader_options_only(shader_section, props) - - # Image Overlays Section (collapsed by default) - overlay_section = draw_collapsible_header(layout, "expand_image_overlays", "Image Overlays", 'IMAGE_DATA') - if overlay_section: - draw_image_overlays_section(overlay_section, props) - - # Positioning & Layout Section (collapsed by default) - position_section = draw_collapsible_header(layout, "expand_positioning", "Positioning & Layout", 'ANCHOR_CENTER') - if position_section: - draw_positioning_section(position_section, props) - - # Font Settings Section (collapsed by default) - font_section = draw_collapsible_header(layout, "expand_font_settings", "Font Settings", 'FONT_DATA') - if font_section: - draw_font_settings_section(font_section, props) - - # Text Fitting Section (collapsed by default) - text_fitting_section = draw_collapsible_header(layout, "expand_text_fitting", "Text Fitting", 'FULLSCREEN_ENTER') - if text_fitting_section: - draw_text_fitting_section(text_fitting_section, props) - - # Stroke Section (collapsed by default) - stroke_section = draw_collapsible_header(layout, "expand_stroke", "Stroke & Outlines", 'MOD_OUTLINE') - if stroke_section: - draw_stroke_section(stroke_section, props) - - # Shadow & Glow Section (collapsed by default) - shadow_glow_section = draw_collapsible_header(layout, "expand_shadow_glow", "Shadow & Glow Effects", 'SHADERFX') - if shadow_glow_section: - draw_shadow_glow_section(shadow_glow_section, props) - - # Tiling Section removed - feature was removed from addon - - # Blur Section (collapsed by default) - blur_section = draw_collapsible_header(layout, "expand_blur", "Blur Effects", 'MOD_SMOOTH') - if blur_section: - draw_blur_section(blur_section, props) - - # Batch Processing Section removed - feature was removed from addon - - # Normal Maps Section (collapsed by default) - normal_maps_section = draw_collapsible_header(layout, "expand_normal_maps", "Normal Maps", 'TEXTURE') - if normal_maps_section: - draw_normal_maps_section(normal_maps_section, props) - - # Colors Section (collapsed by default) - colors_section = draw_collapsible_header(layout, "expand_colors", "Colors", 'COLOR') - if colors_section: - draw_colors_section(colors_section, props) - - # Presets Section (collapsed by default) - presets_section = draw_collapsible_header(layout, "expand_presets", "Presets", 'PRESET') - if presets_section: - draw_presets_section(presets_section, props) - -class TEXT_TEXTURE_PT_panel_properties(Panel): - """Properties Panel""" - bl_label = "Text Texture Generator" - bl_idname = "TEXT_TEXTURE_PT_panel_properties" - bl_space_type = 'PROPERTIES' - bl_region_type = 'WINDOW' - bl_context = "material" - bl_category = "Tool" - - def draw(self, context): - layout = self.layout - props = context.scene.text_texture_props - - # TOP LEVEL - Always Visible Controls - - # Text Input - box = layout.box() - box.label(text="Text:", icon='TEXT') - draw_text_input_section(box, props) - - - # Generate Button - layout.separator(factor=0.5) - draw_generate_button(layout) - - # Preview Button at top level - layout.separator(factor=0.5) - preview_row = layout.row() - preview_row.scale_y = 1.5 - preview_row.operator("text_texture.view_preview_zoom", text="Open Preview Window", icon='WINDOW') - - layout.separator(factor=1.2) - - # COLLAPSIBLE SECTIONS - - # Multiline Section (collapsed by default) - multiline_section = draw_collapsible_header(layout, "expand_multiline", "Multiline Text", 'TEXT') - if multiline_section: - draw_multiline_section(multiline_section, props) - - # Image Overlays Section (collapsed by default) - overlay_section = draw_collapsible_header(layout, "expand_image_overlays", "Image Overlays", 'IMAGE_DATA') - if overlay_section: - draw_image_overlays_section(overlay_section, props) - - # Positioning & Layout Section (collapsed by default) - position_section = draw_collapsible_header(layout, "expand_positioning", "Positioning & Layout", 'ANCHOR_CENTER') - if position_section: - draw_positioning_section(position_section, props) - - # Font Settings Section (collapsed by default) - font_section = draw_collapsible_header(layout, "expand_font_settings", "Font Settings", 'FONT_DATA') - if font_section: - draw_font_settings_section(font_section, props) - - # Text Fitting Section (collapsed by default) - text_fitting_section = draw_collapsible_header(layout, "expand_text_fitting", "Text Fitting", 'FULLSCREEN_ENTER') - if text_fitting_section: - draw_text_fitting_section(text_fitting_section, props) - - # Stroke Section (collapsed by default) - stroke_section = draw_collapsible_header(layout, "expand_stroke", "Stroke & Outlines", 'MOD_OUTLINE') - if stroke_section: - draw_stroke_section(stroke_section, props) - - # Shadow & Glow Section (collapsed by default) - shadow_glow_section = draw_collapsible_header(layout, "expand_shadow_glow", "Shadow & Glow Effects", 'SHADERFX') - if shadow_glow_section: - draw_shadow_glow_section(shadow_glow_section, props) - - # Tiling Section removed - feature was removed from addon - - # Blur Section (collapsed by default) - blur_section = draw_collapsible_header(layout, "expand_blur", "Blur Effects", 'MOD_SMOOTH') - if blur_section: - draw_blur_section(blur_section, props) - - # Batch Processing Section removed - feature was removed from addon - - # Normal Maps Section (collapsed by default) - normal_maps_section = draw_collapsible_header(layout, "expand_normal_maps", "Normal Maps", 'TEXTURE') - if normal_maps_section: - draw_normal_maps_section(normal_maps_section, props) - - # Colors Section (collapsed by default) - colors_section = draw_collapsible_header(layout, "expand_colors", "Colors", 'COLOR') - if colors_section: - draw_colors_section(colors_section, props) - - # Presets Section (collapsed by default) - presets_section = draw_collapsible_header(layout, "expand_presets", "Presets", 'PRESET') - if presets_section: - draw_presets_section(presets_section, props) +# Import bl_info from utils +from .utils import bl_info + +# Import utility functions +from .utils import install_pillow, get_system_fonts, get_font_enum_items + +# Import preset system functions +from .presets import ( + initialize_presets, + refresh_presets, + refresh_presets_sync, + ensure_presets_available, + migrate_presets_if_needed +) + +# Import core generation functions +from .core import generate_texture_image, generate_preview + +# Import property groups +from .properties import ( + TEXT_TEXTURE_ImageOverlay, + TEXT_TEXTURE_Preset, + TEXT_TEXTURE_Properties +) + +# Import operators +from .operators import ( + TEXT_TEXTURE_OT_generate, + TEXT_TEXTURE_OT_generate_shader, + TEXT_TEXTURE_OT_refresh_preview, + TEXT_TEXTURE_OT_view_preview_zoom, + TEXT_TEXTURE_OT_save_preset, + TEXT_TEXTURE_OT_save_preset_enter, + TEXT_TEXTURE_OT_load_preset, + TEXT_TEXTURE_OT_delete_preset, + TEXT_TEXTURE_OT_refresh_presets, + 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_refresh_fonts, + TEXT_TEXTURE_OT_set_anchor_point, + TEXT_TEXTURE_OT_sync_margins, + TEXT_TEXTURE_OT_export_presets, + TEXT_TEXTURE_OT_import_presets, + TEXT_TEXTURE_OT_show_migration_report +) + +# Import UI panels and functions +from .ui import ( + TEXT_TEXTURE_PT_panel, + TEXT_TEXTURE_PT_panel_3d, + TEXT_TEXTURE_PT_panel_properties, + menu_func_node_editor, + menu_func_view3d, + menu_func_image +) # ============================================================================ # REGISTRATION diff --git a/__pycache__/__init__.cpython-311.pyc b/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a838ab35bbb89e1d279d124cac0e6b18b316b63a GIT binary patch literal 10201 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 literal 0 HcmV?d00001 diff --git a/core/__pycache__/generation_engine.cpython-311.pyc b/core/__pycache__/generation_engine.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cdf92e9829e877f46f8603ee77e0274c1627431d GIT binary patch literal 4200 zcmbtXO>7&-6`m!zZY(E zX_fF9M^VY}x??M4g$&mCWl3MOWxRS96Ir;Y4TpB9SMw<^ZJZK-mcGBb3-kdBBAszR zA#mAXzCfbK@BsBbVmIO2cKthpM4!mYOu=h-H{1y~jDa&e1|#~@@O`O1<~M;&(A}=< z7kma<^0{WB?~uzP19}(PKYJYNOMB1JKAW|Xc!y{jT zHCWx=`kJdte%IwCx|(*mi^$nG>1=SWc60kCD}!y!V)uT$$#x^yJd(>Btic!_vF)4a zChWs@eJ=`r!?)2iPgftIO<=d{uZDjq?7r0Y%HAjcM#Pmix2?8thS)*?_O#8_Ap>Gi zY?q_i_5#Ns4>CBz58pf7@4&~J_yaD#%lAObIqulKQqX504Oedf>Brn25c(w|rwYLq z%a{@T0zLBVwzp4yS0QACHo6=34Q}@=Kcw>v-Vcfgt~=Z^vy4`}AEIR@(J3f!sX{yy z=i*~tU&OqS(({7cda-Nwx^bnck!^XAWV8vJqoN#9bJ~O*PwMtmhKVjga8MVPa7wRh zFH54n2-*V4V4b%E2#f>F6f`F`2{x>tzH)lU`mtEQ&MtMQ!EoQ~?lcSo1sdb|e^!74>^L zyhCH*4*igUAC&5a9UADY2o2;;Fi+l~`huKW6oBb>A~sGo%{EQcY|_?PJ=N@%3kPnL zhZ&^QEUcZ5MPAFNQdrZ{d0D=#7S6aGaXKVS-P1=I0Y} zi_H;9(JfZVXLI)~AFP#T1=6xA=wqW+$AXahLx!k%MNFntStXWtfoh3@E?E8=%2g%!gJ-JPZhTOBEYv*E@HXoTK>tFs|VYC6G6Mqjkz zO~M5^Pm)9srMzavBn5y?Z|*iYvy#F&ijY=NCqfF-DOf>!mPx#lN`&c@;bvH@eY`Dy z?ciD-C1-`~DJAXcaZ)yuRD>+H!r-|gVge^JV?~=>f%lrz3?@gwd5y+Edi9+0)7;2S z4HIo7E09zcmKn<<9}tz^J8gt+#|dXoPB^=(k`cNpBaO{Y!U6#H55ua*>&M2is@)KB z0tTfpqMd+K_%W*X{+jK5!L^yuxhLH~pne;#wLnYUl`?n5qPM5gD zKmYrYJ^KZ}y2du%GCN11ZXK(&MXnO={EN1}vi>JCd<^P~Nc(5+e)j(J$kAt!qkkLw z^30bP9-k=>oGV49%8@BEGW9ZkXnk>AeI8Fdizl`QAJ05_=bv3>JW-0zm*exraD^Xw z&W}FhM@#&8nIA8P%Utinlk_QZeHE_rIoI=y>nU-)AcqA5uX_87(Mq_Z96nk_fnc-} z`K{SnD}9q@-(;oZaHXTW+>x;9ahtyVpCH>G{VxJioc|$;f>ASixx~$uxmlB&t+e+( zoHyHtOm67Mm*K7|^0B=QV(;tplRui#X{aUcQklDCa+fMxkLlJ57kkbfe#RXJ*UMa= z2@_~PR9tnRAE8u(&?&T94;-6MX5N`Oi~eyoboq?;d+*TYG4J)9X8-)Oe636kx)lxC3Rh~aI{{>Y)$_kynlmtV_84d34M-%b%b4&4YpR^5L2lSaGMoGy^mhhMI$6L6jvviwz@UiJLS6>gKXr06 zQHNRA(#;84O`W7*A6vVV*D{0YTC{a?kXo8TvjB?Vd@Fz-ZAQx3#{VG4DGJ?$?iFDv zp&;D~uCB9jfmqQ4T3Rgpi~vzC5% zbzQRAN-w_=`)i^&_aYKA5B#nanJY);%*b4&v)_#Lzof#6Qe?6mnKUDlmCl1}<8HXK zV_uEe&YXJ^Go$CAmbj@hH)V2DZwhyvMcsYeaMwk{T|Z3olZ(NNeduYVozlM0^e?6^>3w**uhC#_D37-{6CPg)sOp;D2q(E!0+1Kb5u*hE4pSW{bRm{uyJUIh0 uxYS5{7f991Fih3sWqegcNeD6Fk3;mS`lc8N>2-3S>0@BJ`w(&(I{P1r6;I0m literal 0 HcmV?d00001 diff --git a/core/__pycache__/normal_maps.cpython-311.pyc b/core/__pycache__/normal_maps.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a8adcfa28c7f262ed93de62ae04346f7016d9e77 GIT binary patch literal 4623 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 literal 0 HcmV?d00001 diff --git a/core/__pycache__/text_fitting.cpython-311.pyc b/core/__pycache__/text_fitting.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a07f8b0481b3506e5296734b28f31c4ec2371d84 GIT binary patch literal 14698 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 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 \ No newline at end of file diff --git a/core/text_fitting.py b/core/text_fitting.py new file mode 100644 index 0000000..e089f45 --- /dev/null +++ b/core/text_fitting.py @@ -0,0 +1,313 @@ +def calculate_available_text_area(width, height, overlays): + """Calculate available area for text placement, avoiding overlay positions""" + try: + print(f"[Text Fitting] Calculating available area for {width}x{height} with {len(overlays)} overlays") + + # Create a boolean mask for available areas (True = available, False = blocked) + available_mask = [[True for _ in range(width)] for _ in range(height)] + + # Mark overlay positions as unavailable + for overlay in overlays: + overlay_x = int(overlay.get('x', 0)) + overlay_y = int(overlay.get('y', 0)) + overlay_width = int(overlay.get('width', 0)) + overlay_height = int(overlay.get('height', 0)) + + # Add some padding around overlays + padding = 10 + + start_x = max(0, overlay_x - padding) + end_x = min(width, overlay_x + overlay_width + padding) + start_y = max(0, overlay_y - padding) + end_y = min(height, overlay_y + overlay_height + padding) + + for y in range(start_y, end_y): + for x in range(start_x, end_x): + if 0 <= y < height and 0 <= x < width: + available_mask[y][x] = False + + # Find largest available rectangular area + max_area = 0 + best_rect = (0, 0, 0, 0) # x, y, width, height + + # Use dynamic programming approach to find largest rectangle + heights = [0] * width + + for row in range(height): + # Update heights array + for col in range(width): + if available_mask[row][col]: + heights[col] += 1 + else: + heights[col] = 0 + + # Find largest rectangle in histogram + rect = largest_rectangle_in_histogram(heights) + area = rect[2] * rect[3] # width * height + + if area > max_area: + max_area = area + best_rect = (rect[0], row - rect[3] + 1, rect[2], rect[3]) + + print(f"[Text Fitting] Found best rectangle: x={best_rect[0]}, y={best_rect[1]}, w={best_rect[2]}, h={best_rect[3]}, area={max_area}") + + return { + 'x': best_rect[0], + 'y': best_rect[1], + 'width': best_rect[2], + 'height': best_rect[3], + 'area': max_area + } + + except Exception as e: + print(f"[Text Fitting] Error calculating available text area: {e}") + import traceback + traceback.print_exc() + return {'x': 0, 'y': 0, 'width': width, 'height': height, 'area': width * height} + +def largest_rectangle_in_histogram(heights): + """Find the largest rectangle in a histogram using stack-based algorithm""" + stack = [] + max_area = 0 + best_rect = (0, 0, 0, 0) # x, y, width, height + + for i, h in enumerate(heights): + start = i + + while stack and stack[-1][1] > h: + idx, height = stack.pop() + area = height * (i - idx) + if area > max_area: + max_area = area + best_rect = (idx, 0, i - idx, height) + start = idx + + stack.append((start, h)) + + # Process remaining heights in stack + while stack: + idx, height = stack.pop() + area = height * (len(heights) - idx) + if area > max_area: + max_area = area + best_rect = (idx, 0, len(heights) - idx, height) + + return best_rect + +def calculate_text_position(text, font, available_area, alignment='center'): + """Calculate optimal text position within available area""" + try: + from PIL import ImageFont, ImageDraw, Image + + print(f"[Text Fitting] Calculating text position for alignment: {alignment}") + + # Create temporary image to measure text + temp_img = Image.new('RGBA', (1, 1)) + draw = ImageDraw.Draw(temp_img) + + # Get text bounding box + bbox = draw.textbbox((0, 0), text, font=font) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + + area_x = available_area['x'] + area_y = available_area['y'] + area_width = available_area['width'] + area_height = available_area['height'] + + print(f"[Text Fitting] Text size: {text_width}x{text_height}, Available area: {area_width}x{area_height}") + + # Calculate position based on alignment + if alignment == 'center': + x = area_x + (area_width - text_width) // 2 + y = area_y + (area_height - text_height) // 2 + elif alignment == 'top-left': + x = area_x + y = area_y + elif alignment == 'top-center': + x = area_x + (area_width - text_width) // 2 + y = area_y + elif alignment == 'top-right': + x = area_x + area_width - text_width + y = area_y + elif alignment == 'center-left': + x = area_x + y = area_y + (area_height - text_height) // 2 + elif alignment == 'center-right': + x = area_x + area_width - text_width + y = area_y + (area_height - text_height) // 2 + elif alignment == 'bottom-left': + x = area_x + y = area_y + area_height - text_height + elif alignment == 'bottom-center': + x = area_x + (area_width - text_width) // 2 + y = area_y + area_height - text_height + elif alignment == 'bottom-right': + x = area_x + area_width - text_width + y = area_y + area_height - text_height + else: + # Default to center + x = area_x + (area_width - text_width) // 2 + y = area_y + (area_height - text_height) // 2 + + # Ensure position is within bounds + x = max(area_x, min(x, area_x + area_width - text_width)) + y = max(area_y, min(y, area_y + area_height - text_height)) + + print(f"[Text Fitting] Calculated position: ({x}, {y})") + + return { + 'x': x, + 'y': y, + 'text_width': text_width, + 'text_height': text_height + } + + except Exception as e: + print(f"[Text Fitting] Error calculating text position: {e}") + import traceback + traceback.print_exc() + return { + 'x': available_area['x'], + 'y': available_area['y'], + 'text_width': 0, + 'text_height': 0 + } + +def find_optimal_font_size(text, font_path, max_width, max_height, min_size=8, max_size=200): + """Find the largest font size that fits within the given constraints""" + try: + from PIL import ImageFont, ImageDraw, Image + + print(f"[Text Fitting] Finding optimal font size for constraints: {max_width}x{max_height}") + + # Binary search for optimal font size + low, high = min_size, max_size + best_size = min_size + + temp_img = Image.new('RGBA', (1, 1)) + draw = ImageDraw.Draw(temp_img) + + while low <= high: + mid = (low + high) // 2 + + try: + font = ImageFont.truetype(font_path, mid) + bbox = draw.textbbox((0, 0), text, font=font) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + + if text_width <= max_width and text_height <= max_height: + best_size = mid + low = mid + 1 + else: + high = mid - 1 + + except Exception as font_error: + print(f"[Text Fitting] Font size {mid} failed: {font_error}") + high = mid - 1 + + print(f"[Text Fitting] Optimal font size: {best_size}") + return best_size + + except Exception as e: + print(f"[Text Fitting] Error finding optimal font size: {e}") + import traceback + traceback.print_exc() + return min_size + +def calculate_text_scaling(text, font, target_width, target_height): + """Calculate scaling factors to fit text within target dimensions""" + try: + from PIL import ImageDraw, Image + + print(f"[Text Fitting] Calculating scaling for target: {target_width}x{target_height}") + + # Measure current text size + temp_img = Image.new('RGBA', (1, 1)) + draw = ImageDraw.Draw(temp_img) + bbox = draw.textbbox((0, 0), text, font=font) + current_width = bbox[2] - bbox[0] + current_height = bbox[3] - bbox[1] + + if current_width == 0 or current_height == 0: + return 1.0, 1.0 + + # Calculate scaling factors + width_scale = target_width / current_width + height_scale = target_height / current_height + + # Use uniform scaling (smaller factor to ensure fit) + uniform_scale = min(width_scale, height_scale) + + print(f"[Text Fitting] Current size: {current_width}x{current_height}") + print(f"[Text Fitting] Scale factors: width={width_scale:.2f}, height={height_scale:.2f}, uniform={uniform_scale:.2f}") + + return width_scale, height_scale, uniform_scale + + except Exception as e: + print(f"[Text Fitting] Error calculating text scaling: {e}") + import traceback + traceback.print_exc() + return 1.0, 1.0, 1.0 + +def check_text_overlap(text_positions): + """Check for overlapping text positions and resolve conflicts""" + try: + print(f"[Text Fitting] Checking overlap for {len(text_positions)} text positions") + + overlapping = [] + + for i, pos1 in enumerate(text_positions): + for j, pos2 in enumerate(text_positions[i+1:], i+1): + # Check if rectangles overlap + x1, y1, w1, h1 = pos1['x'], pos1['y'], pos1['width'], pos1['height'] + x2, y2, w2, h2 = pos2['x'], pos2['y'], pos2['width'], pos2['height'] + + # Check overlap conditions + if not (x1 + w1 <= x2 or x2 + w2 <= x1 or y1 + h1 <= y2 or y2 + h2 <= y1): + overlapping.append((i, j)) + print(f"[Text Fitting] Overlap detected between positions {i} and {j}") + + return overlapping + + except Exception as e: + print(f"[Text Fitting] Error checking text overlap: {e}") + import traceback + traceback.print_exc() + return [] + +def resolve_text_conflicts(text_positions, canvas_width, canvas_height): + """Resolve overlapping text positions by repositioning""" + try: + print(f"[Text Fitting] Resolving text conflicts for {len(text_positions)} positions") + + resolved_positions = text_positions.copy() + overlaps = check_text_overlap(resolved_positions) + + # Simple conflict resolution: move overlapping text + for i, j in overlaps: + pos1 = resolved_positions[i] + pos2 = resolved_positions[j] + + # Move the second text down or to the right + new_y = pos1['y'] + pos1['height'] + 10 + if new_y + pos2['height'] <= canvas_height: + resolved_positions[j]['y'] = new_y + else: + # Try moving to the right + new_x = pos1['x'] + pos1['width'] + 10 + if new_x + pos2['width'] <= canvas_width: + resolved_positions[j]['x'] = new_x + else: + # If can't fit, reduce to smaller area + print(f"[Text Fitting] Could not resolve conflict for position {j}") + + print(f"[Text Fitting] Resolved {len(overlaps)} conflicts") + return resolved_positions + + except Exception as e: + print(f"[Text Fitting] Error resolving text conflicts: {e}") + import traceback + traceback.print_exc() + return text_positions \ No newline at end of file diff --git a/core/text_processor.py b/core/text_processor.py new file mode 100644 index 0000000..cab9b9e --- /dev/null +++ b/core/text_processor.py @@ -0,0 +1,342 @@ +def process_multiline_text(text, font, max_width, max_height): + """Process multiline text with automatic wrapping and fitting""" + try: + from PIL import ImageFont, ImageDraw, Image + + print(f"[Text Processor] Processing multiline text with constraints: {max_width}x{max_height}") + + # Split text into lines + lines = text.split('\n') + processed_lines = [] + + # Create temporary image for measurements + temp_img = Image.new('RGBA', (1, 1)) + draw = ImageDraw.Draw(temp_img) + + total_height = 0 + + for line in lines: + if not line.strip(): + # Empty line + bbox = draw.textbbox((0, 0), "A", font=font) + line_height = bbox[3] - bbox[1] + processed_lines.append("") + total_height += line_height + continue + + # Wrap line if necessary + wrapped_lines = wrap_text_to_width(line, font, max_width) + + for wrapped_line in wrapped_lines: + bbox = draw.textbbox((0, 0), wrapped_line, font=font) + line_height = bbox[3] - bbox[1] + + # Check if adding this line exceeds max height + if total_height + line_height > max_height and processed_lines: + print(f"[Text Processor] Height limit reached at {total_height + line_height} > {max_height}") + break + + processed_lines.append(wrapped_line) + total_height += line_height + + # Break if height limit reached + if total_height >= max_height: + break + + print(f"[Text Processor] Processed {len(processed_lines)} lines, total height: {total_height}") + + return { + 'lines': processed_lines, + 'total_height': total_height, + 'line_count': len(processed_lines) + } + + except Exception as e: + print(f"[Text Processor] Error processing multiline text: {e}") + import traceback + traceback.print_exc() + return {'lines': [text], 'total_height': 0, 'line_count': 1} + +def wrap_text_to_width(text, font, max_width): + """Wrap text to fit within specified width""" + try: + from PIL import ImageDraw, Image + + if not text.strip(): + return [""] + + # Create temporary image for measurements + temp_img = Image.new('RGBA', (1, 1)) + draw = ImageDraw.Draw(temp_img) + + # Check if entire text fits + bbox = draw.textbbox((0, 0), text, font=font) + text_width = bbox[2] - bbox[0] + + if text_width <= max_width: + return [text] + + # Split into words and wrap + words = text.split() + lines = [] + current_line = "" + + for word in words: + test_line = current_line + (" " if current_line else "") + word + bbox = draw.textbbox((0, 0), test_line, font=font) + test_width = bbox[2] - bbox[0] + + if test_width <= max_width: + current_line = test_line + else: + if current_line: + lines.append(current_line) + current_line = word + else: + # Single word is too long, force wrap + lines.append(word) + current_line = "" + + if current_line: + lines.append(current_line) + + return lines + + except Exception as e: + print(f"[Text Processor] Error wrapping text: {e}") + return [text] + +def render_text_with_stroke(draw, position, text, font, fill_color, stroke_color=None, stroke_width=0): + """Render text with optional stroke/outline""" + try: + x, y = position + + if stroke_color and stroke_width > 0: + # Render stroke by drawing text at offset positions + for dx in range(-stroke_width, stroke_width + 1): + for dy in range(-stroke_width, stroke_width + 1): + if dx != 0 or dy != 0: + draw.text((x + dx, y + dy), text, font=font, fill=stroke_color) + + # Render main text + draw.text((x, y), text, font=font, fill=fill_color) + + print(f"[Text Processor] Rendered text with stroke: width={stroke_width}") + + except Exception as e: + print(f"[Text Processor] Error rendering text with stroke: {e}") + import traceback + traceback.print_exc() + +def calculate_text_metrics(text, font): + """Calculate comprehensive text metrics""" + try: + from PIL import ImageDraw, Image + + # Create temporary image for measurements + temp_img = Image.new('RGBA', (1, 1)) + draw = ImageDraw.Draw(temp_img) + + # Get bounding box + bbox = draw.textbbox((0, 0), text, font=font) + + metrics = { + 'width': bbox[2] - bbox[0], + 'height': bbox[3] - bbox[1], + 'left_bearing': bbox[0], + 'top_bearing': bbox[1], + 'advance_width': bbox[2], + 'advance_height': bbox[3] + } + + # Get additional font metrics if available + try: + metrics['ascent'] = font.getmetrics()[0] + metrics['descent'] = font.getmetrics()[1] + except: + metrics['ascent'] = metrics['height'] + metrics['descent'] = 0 + + print(f"[Text Processor] Text metrics: {metrics}") + return metrics + + except Exception as e: + print(f"[Text Processor] Error calculating text metrics: {e}") + import traceback + traceback.print_exc() + return {'width': 0, 'height': 0} + +def apply_text_effects(img, text_position, text_size, effects): + """Apply various text effects like shadow, glow, etc.""" + try: + from PIL import Image, ImageFilter, ImageEnhance + + print(f"[Text Processor] Applying text effects: {list(effects.keys())}") + + result_img = img.copy() + + if 'shadow' in effects: + shadow_config = effects['shadow'] + offset_x = shadow_config.get('offset_x', 2) + offset_y = shadow_config.get('offset_y', 2) + blur_radius = shadow_config.get('blur', 0) + shadow_color = shadow_config.get('color', (0, 0, 0, 128)) + + # Create shadow layer + shadow_img = Image.new('RGBA', img.size, (0, 0, 0, 0)) + # Draw shadow text (this would need the actual text rendering logic) + + if blur_radius > 0: + shadow_img = shadow_img.filter(ImageFilter.GaussianBlur(radius=blur_radius)) + + # Composite shadow + result_img = Image.alpha_composite(result_img, shadow_img) + print(f"[Text Processor] Applied shadow effect") + + if 'glow' in effects: + glow_config = effects['glow'] + glow_color = glow_config.get('color', (255, 255, 255, 128)) + glow_radius = glow_config.get('radius', 3) + + # Create glow effect + glow_img = Image.new('RGBA', img.size, (0, 0, 0, 0)) + # Apply glow rendering logic here + + result_img = Image.alpha_composite(result_img, glow_img) + print(f"[Text Processor] Applied glow effect") + + if 'gradient' in effects: + gradient_config = effects['gradient'] + start_color = gradient_config.get('start', (255, 255, 255)) + end_color = gradient_config.get('end', (0, 0, 0)) + direction = gradient_config.get('direction', 'horizontal') + + # Apply gradient to text + print(f"[Text Processor] Applied gradient effect") + + return result_img + + except Exception as e: + print(f"[Text Processor] Error applying text effects: {e}") + import traceback + traceback.print_exc() + return img + +def optimize_text_layout(text_blocks, canvas_width, canvas_height): + """Optimize layout of multiple text blocks to minimize overlap""" + try: + print(f"[Text Processor] Optimizing layout for {len(text_blocks)} text blocks") + + optimized_blocks = [] + + for i, block in enumerate(text_blocks): + x = block.get('x', 0) + y = block.get('y', 0) + width = block.get('width', 0) + height = block.get('height', 0) + + # Check for overlaps with previous blocks + overlap_found = False + for prev_block in optimized_blocks: + if rectangles_overlap( + (x, y, width, height), + (prev_block['x'], prev_block['y'], prev_block['width'], prev_block['height']) + ): + overlap_found = True + break + + if overlap_found: + # Find new position + new_x, new_y = find_non_overlapping_position( + width, height, optimized_blocks, canvas_width, canvas_height + ) + x, y = new_x, new_y + print(f"[Text Processor] Moved block {i} to avoid overlap: ({x}, {y})") + + optimized_block = block.copy() + optimized_block.update({'x': x, 'y': y}) + optimized_blocks.append(optimized_block) + + print(f"[Text Processor] Layout optimization complete") + return optimized_blocks + + except Exception as e: + print(f"[Text Processor] Error optimizing text layout: {e}") + import traceback + traceback.print_exc() + return text_blocks + +def rectangles_overlap(rect1, rect2): + """Check if two rectangles overlap""" + x1, y1, w1, h1 = rect1 + x2, y2, w2, h2 = rect2 + + return not (x1 + w1 <= x2 or x2 + w2 <= x1 or y1 + h1 <= y2 or y2 + h2 <= y1) + +def find_non_overlapping_position(width, height, existing_blocks, canvas_width, canvas_height): + """Find a position that doesn't overlap with existing blocks""" + # Try positions from top-left, moving right then down + for y in range(0, canvas_height - height, 20): + for x in range(0, canvas_width - width, 20): + rect = (x, y, width, height) + + overlap = False + for block in existing_blocks: + if rectangles_overlap(rect, (block['x'], block['y'], block['width'], block['height'])): + overlap = True + break + + if not overlap: + return x, y + + # If no position found, return original or default + return 0, 0 + +def validate_text_rendering(img, text_positions): + """Validate that text rendering was successful""" + try: + print(f"[Text Processor] Validating text rendering for {len(text_positions)} positions") + + validation_results = [] + + for i, pos in enumerate(text_positions): + result = { + 'position_index': i, + 'x': pos.get('x', 0), + 'y': pos.get('y', 0), + 'width': pos.get('width', 0), + 'height': pos.get('height', 0), + 'valid': True, + 'issues': [] + } + + # Check bounds + if pos.get('x', 0) < 0 or pos.get('y', 0) < 0: + result['valid'] = False + result['issues'].append('Negative position') + + if pos.get('x', 0) + pos.get('width', 0) > img.width: + result['valid'] = False + result['issues'].append('Exceeds image width') + + if pos.get('y', 0) + pos.get('height', 0) > img.height: + result['valid'] = False + result['issues'].append('Exceeds image height') + + # Check for zero dimensions + if pos.get('width', 0) == 0 or pos.get('height', 0) == 0: + result['valid'] = False + result['issues'].append('Zero dimensions') + + validation_results.append(result) + + valid_count = sum(1 for r in validation_results if r['valid']) + print(f"[Text Processor] Validation complete: {valid_count}/{len(text_positions)} positions valid") + + return validation_results + + except Exception as e: + print(f"[Text Processor] Error validating text rendering: {e}") + import traceback + traceback.print_exc() + return [] \ No newline at end of file diff --git a/operators/__init__.py b/operators/__init__.py new file mode 100644 index 0000000..a3e9c11 --- /dev/null +++ b/operators/__init__.py @@ -0,0 +1,70 @@ +""" +Text Texture Generator - Operators Module +Contains all Blender operators for text texture generation and management. +""" + +from .generation_ops import * +from .preset_ops import * +from .utility_ops import * + +__all__ = [] + +# Import all operator classes and add to __all__ +from .generation_ops import ( + TEXT_TEXTURE_OT_generate, + TEXT_TEXTURE_OT_generate_shader, +) + +from .preset_ops import ( + TEXT_TEXTURE_OT_save_preset, + TEXT_TEXTURE_OT_save_preset_enter, + TEXT_TEXTURE_OT_load_preset, + TEXT_TEXTURE_OT_delete_preset, + TEXT_TEXTURE_OT_refresh_presets, + TEXT_TEXTURE_OT_export_presets, + TEXT_TEXTURE_OT_import_presets, + TEXT_TEXTURE_OT_show_migration_report, +) + +from .utility_ops import ( + TEXT_TEXTURE_OT_refresh_preview, + TEXT_TEXTURE_OT_view_preview_zoom, + TEXT_TEXTURE_OT_open_panel, + TEXT_TEXTURE_OT_refresh_fonts, + TEXT_TEXTURE_OT_add_overlay, + TEXT_TEXTURE_OT_remove_overlay, + TEXT_TEXTURE_OT_set_anchor_point, + TEXT_TEXTURE_OT_sync_margins, + TEXT_TEXTURE_OT_duplicate_overlay, + TEXT_TEXTURE_OT_delete_overlay, + TEXT_TEXTURE_OT_move_overlay, +) + +__all__.extend([ + # Generation operators + 'TEXT_TEXTURE_OT_generate', + 'TEXT_TEXTURE_OT_generate_shader', + + # Preset operators + 'TEXT_TEXTURE_OT_save_preset', + 'TEXT_TEXTURE_OT_save_preset_enter', + 'TEXT_TEXTURE_OT_load_preset', + 'TEXT_TEXTURE_OT_delete_preset', + 'TEXT_TEXTURE_OT_refresh_presets', + 'TEXT_TEXTURE_OT_export_presets', + 'TEXT_TEXTURE_OT_import_presets', + 'TEXT_TEXTURE_OT_show_migration_report', + + # Utility operators + 'TEXT_TEXTURE_OT_refresh_preview', + 'TEXT_TEXTURE_OT_view_preview_zoom', + 'TEXT_TEXTURE_OT_open_panel', + 'TEXT_TEXTURE_OT_refresh_fonts', + 'TEXT_TEXTURE_OT_add_overlay', + 'TEXT_TEXTURE_OT_remove_overlay', + 'TEXT_TEXTURE_OT_set_anchor_point', + 'TEXT_TEXTURE_OT_sync_margins', + 'TEXT_TEXTURE_OT_duplicate_overlay', + 'TEXT_TEXTURE_OT_delete_overlay', + 'TEXT_TEXTURE_OT_move_overlay', +]) \ No newline at end of file diff --git a/operators/__pycache__/__init__.cpython-311.pyc b/operators/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f741829e2e37c136fb868f924532c5348fdaf99b GIT binary patch literal 1758 zcmc(fOK;Oa5P)~{j`L{xNSZc%7$goxqWl5G0aS5PlyXY zfh*zwLi`E%2R&9kAS6ym9D>TJCw7uJgjj_WU~Rr#duDbfGdugHQZbNzzJ?!qAM*%( zhL`xsOc&?x_2LDx5kWS#Q!Z{#=G1gfw^0|7jFzQcEV3jkawLaAC*!6>p5#S=6hx5} zg+UBaA|+8KWlh|0a+2kbi`%!pXpB^W)G2^c6SnagQq&N@dQW7@k_h+2y(0UL1YD<1GLy7C(#)q!x8 zw09-5WBctzdz-sFoNb>7Pw_+TxHmNqqMb#1e(XyXUM_dUFY;@P_CZe`QQ>rTjdWV6 z;-GGol8Ks&WZ>|lWG3J%#4(;vq!iKSqBC7ukNV8xZj?wwy#Jv-6G1YG%(kfBMZdaolzE+=RMeM_7e-qNjiJ|}f~l@Pjmf`e_kGvtFkPohLzvbvmM0v{ z2WZt5biK*~asYiPAXNYq0dQNW5}*vI0IGl`Kn<`ASOKg8)&O-t1F#NgYCN!7QJzv; zb|{UiKl_JDKZuOUp&qYG{m^lpa5#R^7P=!T{bylDIo;v9{okT<9W=VA&n|(RlscXh zQo834FABHB_~N*&u4*ml_+Y3xN@I*isWi@v5Wqlq=M-&E#u?f;jmH_{K w%B67%jQ#|p2r7RUjWYQ7pV&gU@Vxr0Izp|Q4kgAn)WYfS0R3$q%G4$J0olvuVE_OC literal 0 HcmV?d00001 diff --git a/operators/generation_ops.py b/operators/generation_ops.py new file mode 100644 index 0000000..e987f8e --- /dev/null +++ b/operators/generation_ops.py @@ -0,0 +1,595 @@ +""" +Generation operators for text texture creation. +Contains operators for generating textures and complete shaders. +""" + +import bpy +from bpy.types import Operator +import os +import json + +# Import required functions from other modules +from ..core.generation_engine import generate_texture_image, generate_preview + + +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'} + +# Export all operator classes for wildcard imports +__all__ = [ + 'TEXT_TEXTURE_OT_generate', + 'TEXT_TEXTURE_OT_generate_shader', +] \ No newline at end of file diff --git a/operators/preset_ops.py b/operators/preset_ops.py new file mode 100644 index 0000000..c86a215 --- /dev/null +++ b/operators/preset_ops.py @@ -0,0 +1,893 @@ +import bpy +from bpy.types import Operator +from bpy.props import StringProperty, BoolProperty, EnumProperty +import os +import json +import time + +# Import utility functions from main module +from ..presets import ( + get_preset_path, get_persistent_preset_path, get_addon_version, + get_blend_file_presets, save_blend_file_preset, delete_blend_file_preset, + refresh_presets_sync, ensure_presets_available +) +from ..utils import bl_info + +class TEXT_TEXTURE_OT_save_preset(Operator): + """Save current settings as preset""" + bl_idname = "text_texture.save_preset" + bl_label = "Save Preset" + bl_description = "Save current settings as preset" + bl_options = {'REGISTER', 'UNDO'} + + overwrite: bpy.props.BoolProperty( + name="Overwrite", + description="Overwrite existing preset", + default=False + ) + + preset_name: bpy.props.StringProperty( + name="Preset Name", + description="Name of preset to save/overwrite", + default="" + ) + + def execute(self, context): + props = context.scene.text_texture_props + + # Use provided preset name or fallback to input field + preset_name = self.preset_name.strip() if self.preset_name else props.new_preset_name.strip() + if not preset_name: + self.report({'ERROR'}, "Please enter a preset name") + return {'CANCELLED'} + + # When overwriting from button, update the input field for consistency + if self.preset_name and self.preset_name.strip(): + props.new_preset_name = preset_name + + # Validate preset name characters (avoid filesystem issues) + import re + # Allow Unicode letters, numbers, spaces, hyphens, underscores, and common accented characters + # This pattern supports international characters like ö, ü, ñ, etc. + if not re.match(r'^[\w\s\-]+$', preset_name, re.UNICODE): + self.report({'ERROR'}, "Preset name contains invalid characters for filesystem compatibility") + return {'CANCELLED'} + + # Additional check for filesystem-unsafe characters + invalid_chars = ['<', '>', ':', '"', '|', '?', '*', '/', '\\'] + if any(char in preset_name for char in invalid_chars): + self.report({'ERROR'}, f"Preset name cannot contain: {' '.join(invalid_chars)}") + return {'CANCELLED'} + + # Check for duplicate names + preset_file = os.path.join(get_preset_path(), f"{preset_name}.json") + if os.path.exists(preset_file) and not self.overwrite: + # Suggest alternative names + base_name = preset_name + counter = 1 + suggested_name = f"{base_name}_{counter}" + while os.path.exists(os.path.join(get_preset_path(), f"{suggested_name}.json")): + counter += 1 + suggested_name = f"{base_name}_{counter}" + + self.report({'ERROR'}, f"Preset '{preset_name}' already exists. Suggestion: try '{suggested_name}' or delete the existing preset first.") + return {'CANCELLED'} + + # DETAILED LOGGING: Current shader properties before saving + print(f"[PRESET SAVE DEBUG] ==================== SAVING PRESET: '{preset_name}' ====================") + print(f"[PRESET SAVE DEBUG] Current shader properties from props:") + print(f"[PRESET SAVE DEBUG] disable_shadows: {props.disable_shadows}") + print(f"[PRESET SAVE DEBUG] disable_reflections: {props.disable_reflections}") + print(f"[PRESET SAVE DEBUG] disable_backfacing: {props.disable_backfacing}") + print(f"[PRESET SAVE DEBUG] shader_type: {props.shader_type}") + print(f"[PRESET SAVE DEBUG] shader_connection: {props.shader_connection}") + print(f"[PRESET SAVE DEBUG] use_alpha: {props.use_alpha}") + print(f"[PRESET SAVE DEBUG] emission_strength: {props.emission_strength}") + print(f"[PRESET SAVE DEBUG] auto_assign_material: {props.auto_assign_material}") + + preset_data = { + 'text': props.text, + 'texture_width': props.texture_width, + 'texture_height': props.texture_height, + 'font_size': props.font_size, + 'padding': props.padding, + 'text_color': list(props.text_color), + 'background_transparent': props.background_transparent, + 'background_color': list(props.background_color), + 'font_path': props.font_path, + 'custom_font_path': props.custom_font_path, + 'use_custom_font': props.use_custom_font, + 'text_align': props.text_align, + 'prepend_text': props.prepend_text, + 'append_text': props.append_text, + 'prepend_margin': props.prepend_margin, + 'append_margin': props.append_margin, + 'prepend_append_layout': props.prepend_append_layout, + 'shader_type': props.shader_type, + 'shader_connection': props.shader_connection, + 'use_alpha': props.use_alpha, + 'emission_strength': props.emission_strength, + 'auto_assign_material': props.auto_assign_material, + 'disable_shadows': props.disable_shadows, + 'disable_reflections': props.disable_reflections, + 'disable_backfacing': props.disable_backfacing, + 'generate_normal_map': props.generate_normal_map, + 'normal_map_strength': props.normal_map_strength, + 'normal_map_blur_radius': props.normal_map_blur_radius, + 'normal_map_invert': props.normal_map_invert, + 'enable_multiline': props.enable_multiline, + 'line_height': props.line_height, + 'text_alignment': props.text_alignment, + 'auto_wrap': props.auto_wrap, + 'wrap_width': props.wrap_width, + 'max_lines': props.max_lines, + 'line_spacing_pixels': props.line_spacing_pixels, + 'enable_stroke': props.enable_stroke, + 'stroke_width': props.stroke_width, + 'stroke_position': props.stroke_position, + 'stroke_color': list(props.stroke_color), + 'enable_shadow': props.enable_shadow, + 'shadow_offset_x': props.shadow_offset_x, + 'shadow_offset_y': props.shadow_offset_y, + 'shadow_blur': props.shadow_blur, + 'shadow_color': list(props.shadow_color), + 'shadow_spread': getattr(props, 'shadow_spread', 1.0), + 'enable_glow': props.enable_glow, + 'glow_blur': props.glow_blur, + 'glow_color': list(props.glow_color), + 'enable_blur': props.enable_blur, + 'blur_radius': props.blur_radius, + 'image_overlays': [] + } + + # DETAILED LOGGING: Verify shader properties in preset_data + print(f"[PRESET SAVE DEBUG] Shader properties in preset_data dictionary:") + print(f"[PRESET SAVE DEBUG] disable_shadows: {preset_data.get('disable_shadows', 'NOT FOUND')}") + print(f"[PRESET SAVE DEBUG] disable_reflections: {preset_data.get('disable_reflections', 'NOT FOUND')}") + print(f"[PRESET SAVE DEBUG] disable_backfacing: {preset_data.get('disable_backfacing', 'NOT FOUND')}") + + # Save overlay data + for overlay in props.image_overlays: + overlay_data = { + 'name': overlay.name, + 'image_path': overlay.image_path, + 'x_position': overlay.x_position, + 'y_position': overlay.y_position, + 'scale': overlay.scale, + 'rotation': overlay.rotation, + 'z_index': overlay.z_index, + 'enabled': overlay.enabled + } + preset_data['image_overlays'].append(overlay_data) + + try: + # Primary: Save to blend file (new system) + print(f"[PRESET SAVE DEBUG] Attempting to save to blend file...") + blend_success = save_blend_file_preset(preset_name, preset_data) + print(f"[PRESET SAVE DEBUG] Blend file save result: {blend_success}") + + # ALWAYS save to persistent storage for cross-blend-file sharing and addon update survival + persistent_file = os.path.join(get_persistent_preset_path(), f"{preset_name}.json") + print(f"[PRESET SAVE DEBUG] Attempting to save to persistent file: {persistent_file}") + persistent_success = True + try: + with open(persistent_file, 'w') as f: + json.dump(preset_data, f, indent=2) + print(f"[PRESET SAVE DEBUG] Successfully saved preset '{preset_name}' to persistent storage") + + # VERIFY: Read back the persistent file to confirm shader properties were saved + print(f"[PRESET SAVE DEBUG] Verifying persistent file contents...") + with open(persistent_file, 'r') as f: + saved_data = json.load(f) + print(f"[PRESET SAVE DEBUG] Persistent file shader properties verification:") + print(f"[PRESET SAVE DEBUG] disable_shadows: {saved_data.get('disable_shadows', 'NOT FOUND')}") + print(f"[PRESET SAVE DEBUG] disable_reflections: {saved_data.get('disable_reflections', 'NOT FOUND')}") + print(f"[PRESET SAVE DEBUG] disable_backfacing: {saved_data.get('disable_backfacing', 'NOT FOUND')}") + + except Exception as persistent_error: + print(f"[PRESET SAVE DEBUG] Warning: Failed to save persistent preset file: {persistent_error}") + persistent_success = False + + # Also save to legacy location for backward compatibility (optional) + legacy_success = True + try: + legacy_file = os.path.join(get_preset_path(), f"{preset_name}.json") + with open(legacy_file, 'w') as f: + json.dump(preset_data, f, indent=2) + print(f"[PRESET SAVE DEBUG] Also saved to legacy location for backward compatibility") + except Exception as legacy_error: + print(f"[PRESET SAVE DEBUG] Note: Could not save to legacy location: {legacy_error}") + legacy_success = False + + if not blend_success and not persistent_success: + self.report({'ERROR'}, f"Failed to save preset to both blend file and persistent storage") + return {'CANCELLED'} + + # Check if preset already exists in the collection and update it + existing_preset = None + for preset in props.presets: + if preset.name == preset_name: + existing_preset = preset + break + + if not existing_preset: + preset = props.presets.add() + preset.name = preset_name + # Priority: Blend file > Persistent file + preset.source = 'BLEND_FILE' if blend_success else 'PERSISTENT_FILE' + else: + # Update source based on where it was successfully saved + existing_preset.source = 'BLEND_FILE' if blend_success else 'PERSISTENT_FILE' + + # Don't clear the input field - keep the name for easy re-saving + # props.new_preset_name = "New Preset" + + # Provide clear feedback + storage_info = "" + if blend_success and persistent_success: + storage_info = " (saved to blend file + persistent storage)" + elif blend_success: + storage_info = " (saved to blend file)" + elif persistent_success: + storage_info = " (saved to persistent storage)" + + if self.overwrite: + self.report({'INFO'}, f"✅ Preset '{preset_name}' updated successfully{storage_info}") + else: + self.report({'INFO'}, f"✅ Preset '{preset_name}' saved successfully{storage_info}") + + except (OSError, IOError, json.JSONEncodeError) as e: + self.report({'ERROR'}, f"Failed to save preset: {str(e)}") + return {'CANCELLED'} + except Exception as e: + self.report({'ERROR'}, f"Unexpected error saving preset: {str(e)}") + return {'CANCELLED'} + + return {'FINISHED'} + + def invoke(self, context, event): + # Check if preset exists and prompt for overwrite + props = context.scene.text_texture_props + preset_name = props.new_preset_name.strip() + + if preset_name and preset_name != "New Preset": + preset_file = os.path.join(get_preset_path(), f"{preset_name}.json") + if os.path.exists(preset_file): + return context.window_manager.invoke_confirm(self, event) + + return self.execute(context) + + def draw(self, context): + layout = self.layout + props = context.scene.text_texture_props + preset_name = props.new_preset_name.strip() + layout.label(text=f"Overwrite existing preset '{preset_name}'?") + +class TEXT_TEXTURE_OT_save_preset_enter(Operator): + """Save preset when Enter key is pressed in name field""" + bl_idname = "text_texture.save_preset_enter" + bl_label = "Save Preset (Enter)" + bl_description = "Save current settings as preset when Enter is pressed" + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + # Delegate to the main save preset operator + return bpy.ops.text_texture.save_preset('INVOKE_DEFAULT') + +class TEXT_TEXTURE_OT_load_preset(Operator): + """Load preset""" + bl_idname = "text_texture.load_preset" + bl_label = "Load Preset" + bl_description = "Load selected preset" + bl_options = {'REGISTER', 'UNDO'} + + preset_name: StringProperty() + + def execute(self, context): + props = context.scene.text_texture_props + + # DEFENSIVE PROGRAMMING: Ensure presets are available before loading + if not ensure_presets_available(): + self.report({'ERROR'}, "Failed to ensure presets are loaded - cannot load preset") + return {'CANCELLED'} + + # DETAILED LOGGING: Current shader properties before loading + print(f"[PRESET LOAD DEBUG] ==================== LOADING PRESET: '{self.preset_name}' ====================") + print(f"[PRESET LOAD DEBUG] Current shader properties BEFORE loading:") + print(f"[PRESET LOAD DEBUG] disable_shadows: {props.disable_shadows}") + print(f"[PRESET LOAD DEBUG] disable_reflections: {props.disable_reflections}") + print(f"[PRESET LOAD DEBUG] disable_backfacing: {props.disable_backfacing}") + print(f"[PRESET LOAD DEBUG] shader_type: {props.shader_type}") + print(f"[PRESET LOAD DEBUG] shader_connection: {props.shader_connection}") + + try: + # Three-tier loading priority: Blend File > Persistent File > Legacy File + blend_presets = get_blend_file_presets() + preset_data = None + source = 'BLEND_FILE' + + print(f"[PRESET LOAD DEBUG] Available blend file presets: {list(blend_presets.keys())}") + + if self.preset_name in blend_presets: + preset_data = blend_presets[self.preset_name] + source = 'BLEND_FILE' + print(f"[PRESET LOAD DEBUG] Loading preset '{self.preset_name}' from blend file") + print(f"[PRESET LOAD DEBUG] Blend file preset shader properties:") + print(f"[PRESET LOAD DEBUG] disable_shadows: {preset_data.get('disable_shadows', 'NOT FOUND')}") + print(f"[PRESET LOAD DEBUG] disable_reflections: {preset_data.get('disable_reflections', 'NOT FOUND')}") + print(f"[PRESET LOAD DEBUG] disable_backfacing: {preset_data.get('disable_backfacing', 'NOT FOUND')}") + else: + # Try persistent storage next + persistent_file = os.path.join(get_persistent_preset_path(), f"{self.preset_name}.json") + print(f"[PRESET LOAD DEBUG] Preset not in blend file, trying persistent file: {persistent_file}") + if os.path.exists(persistent_file): + with open(persistent_file, 'r') as f: + preset_data = json.load(f) + source = 'PERSISTENT_FILE' + print(f"[PRESET LOAD DEBUG] Loading preset '{self.preset_name}' from persistent file") + print(f"[PRESET LOAD DEBUG] Persistent file preset shader properties:") + print(f"[PRESET LOAD DEBUG] disable_shadows: {preset_data.get('disable_shadows', 'NOT FOUND')}") + print(f"[PRESET LOAD DEBUG] disable_reflections: {preset_data.get('disable_reflections', 'NOT FOUND')}") + print(f"[PRESET LOAD DEBUG] disable_backfacing: {preset_data.get('disable_backfacing', 'NOT FOUND')}") + else: + # Fallback to legacy local file + legacy_file = os.path.join(get_preset_path(), f"{self.preset_name}.json") + print(f"[PRESET LOAD DEBUG] Preset not in persistent storage, trying legacy file: {legacy_file}") + if os.path.exists(legacy_file): + with open(legacy_file, 'r') as f: + preset_data = json.load(f) + source = 'LOCAL_FILE' + print(f"[PRESET LOAD DEBUG] Loading preset '{self.preset_name}' from legacy file") + print(f"[PRESET LOAD DEBUG] Legacy file preset shader properties:") + print(f"[PRESET LOAD DEBUG] disable_shadows: {preset_data.get('disable_shadows', 'NOT FOUND')}") + print(f"[PRESET LOAD DEBUG] disable_reflections: {preset_data.get('disable_reflections', 'NOT FOUND')}") + print(f"[PRESET LOAD DEBUG] disable_backfacing: {preset_data.get('disable_backfacing', 'NOT FOUND')}") + else: + print(f"[PRESET LOAD DEBUG] Legacy file does not exist: {legacy_file}") + self.report({'ERROR'}, f"Preset not found in any storage location: {self.preset_name}") + return {'CANCELLED'} + + if not preset_data: + print(f"[PRESET LOAD DEBUG] No preset data found!") + self.report({'ERROR'}, f"No data found for preset: {self.preset_name}") + return {'CANCELLED'} + + props.is_updating = True + + # Skip loading text to prevent overwriting current text + # props.text = preset_data.get('text', props.text) + props.texture_width = preset_data.get('texture_width', props.texture_width) + props.texture_height = preset_data.get('texture_height', props.texture_height) + props.font_size = preset_data.get('font_size', props.font_size) + props.padding = preset_data.get('padding', props.padding) + props.text_color = preset_data.get('text_color', props.text_color) + props.background_transparent = preset_data.get('background_transparent', props.background_transparent) + props.background_color = preset_data.get('background_color', props.background_color) + props.font_path = preset_data.get('font_path', props.font_path) + props.custom_font_path = preset_data.get('custom_font_path', props.custom_font_path) + props.use_custom_font = preset_data.get('use_custom_font', props.use_custom_font) + props.text_align = preset_data.get('text_align', props.text_align) + props.prepend_text = preset_data.get('prepend_text', props.prepend_text) + props.append_text = preset_data.get('append_text', props.append_text) + props.prepend_margin = preset_data.get('prepend_margin', props.prepend_margin) + props.append_margin = preset_data.get('append_margin', props.append_margin) + props.prepend_append_layout = preset_data.get('prepend_append_layout', props.prepend_append_layout) + + # DETAILED LOGGING: Shader property loading with before/after values + print(f"[PRESET LOAD DEBUG] Loading shader properties...") + + old_shader_type = props.shader_type + props.shader_type = preset_data.get('shader_type', props.shader_type) + print(f"[PRESET LOAD DEBUG] shader_type: {old_shader_type} -> {props.shader_type}") + + old_shader_connection = props.shader_connection + props.shader_connection = preset_data.get('shader_connection', props.shader_connection) + print(f"[PRESET LOAD DEBUG] shader_connection: {old_shader_connection} -> {props.shader_connection}") + + old_use_alpha = props.use_alpha + props.use_alpha = preset_data.get('use_alpha', props.use_alpha) + print(f"[PRESET LOAD DEBUG] use_alpha: {old_use_alpha} -> {props.use_alpha}") + + old_emission_strength = props.emission_strength + props.emission_strength = preset_data.get('emission_strength', props.emission_strength) + print(f"[PRESET LOAD DEBUG] emission_strength: {old_emission_strength} -> {props.emission_strength}") + + old_auto_assign = props.auto_assign_material + props.auto_assign_material = preset_data.get('auto_assign_material', props.auto_assign_material) + print(f"[PRESET LOAD DEBUG] auto_assign_material: {old_auto_assign} -> {props.auto_assign_material}") + + # CRITICAL SHADER PROPERTIES - Most detailed logging + print(f"[PRESET LOAD DEBUG] ==================== CRITICAL SHADER PROPERTIES ====================") + + old_disable_shadows = props.disable_shadows + new_disable_shadows = preset_data.get('disable_shadows', props.disable_shadows) + props.disable_shadows = new_disable_shadows + print(f"[PRESET LOAD DEBUG] disable_shadows:") + print(f"[PRESET LOAD DEBUG] OLD VALUE: {old_disable_shadows} (type: {type(old_disable_shadows)})") + print(f"[PRESET LOAD DEBUG] PRESET DATA: {preset_data.get('disable_shadows', 'NOT FOUND')} (type: {type(preset_data.get('disable_shadows', 'N/A'))})") + print(f"[PRESET LOAD DEBUG] NEW VALUE: {props.disable_shadows} (type: {type(props.disable_shadows)})") + + old_disable_reflections = props.disable_reflections + new_disable_reflections = preset_data.get('disable_reflections', props.disable_reflections) + props.disable_reflections = new_disable_reflections + print(f"[PRESET LOAD DEBUG] disable_reflections:") + print(f"[PRESET LOAD DEBUG] OLD VALUE: {old_disable_reflections} (type: {type(old_disable_reflections)})") + print(f"[PRESET LOAD DEBUG] PRESET DATA: {preset_data.get('disable_reflections', 'NOT FOUND')} (type: {type(preset_data.get('disable_reflections', 'N/A'))})") + print(f"[PRESET LOAD DEBUG] NEW VALUE: {props.disable_reflections} (type: {type(props.disable_reflections)})") + + old_disable_backfacing = props.disable_backfacing + new_disable_backfacing = preset_data.get('disable_backfacing', props.disable_backfacing) + props.disable_backfacing = new_disable_backfacing + print(f"[PRESET LOAD DEBUG] disable_backfacing:") + print(f"[PRESET LOAD DEBUG] OLD VALUE: {old_disable_backfacing} (type: {type(old_disable_backfacing)})") + print(f"[PRESET LOAD DEBUG] PRESET DATA: {preset_data.get('disable_backfacing', 'NOT FOUND')} (type: {type(preset_data.get('disable_backfacing', 'N/A'))})") + print(f"[PRESET LOAD DEBUG] NEW VALUE: {props.disable_backfacing} (type: {type(props.disable_backfacing)})") + + print(f"[PRESET LOAD DEBUG] ================================================================") + + props.generate_normal_map = preset_data.get('generate_normal_map', props.generate_normal_map) + props.normal_map_strength = preset_data.get('normal_map_strength', props.normal_map_strength) + props.normal_map_blur_radius = preset_data.get('normal_map_blur_radius', props.normal_map_blur_radius) + props.normal_map_invert = preset_data.get('normal_map_invert', props.normal_map_invert) + + # Load multiline settings + props.enable_multiline = preset_data.get('enable_multiline', props.enable_multiline) + props.line_height = preset_data.get('line_height', props.line_height) + props.text_alignment = preset_data.get('text_alignment', props.text_alignment) + props.auto_wrap = preset_data.get('auto_wrap', props.auto_wrap) + props.wrap_width = preset_data.get('wrap_width', props.wrap_width) + props.max_lines = preset_data.get('max_lines', props.max_lines) + props.line_spacing_pixels = preset_data.get('line_spacing_pixels', props.line_spacing_pixels) + + # Load stroke settings + props.enable_stroke = preset_data.get('enable_stroke', props.enable_stroke) + props.stroke_width = preset_data.get('stroke_width', props.stroke_width) + props.stroke_position = preset_data.get('stroke_position', props.stroke_position) + props.stroke_color = preset_data.get('stroke_color', props.stroke_color) + + # Load shadow/glow settings + props.enable_shadow = preset_data.get('enable_shadow', props.enable_shadow) + props.shadow_offset_x = preset_data.get('shadow_offset_x', props.shadow_offset_x) + props.shadow_offset_y = preset_data.get('shadow_offset_y', props.shadow_offset_y) + props.shadow_blur = preset_data.get('shadow_blur', props.shadow_blur) + props.shadow_color = preset_data.get('shadow_color', props.shadow_color) + # Handle new shadow_spread property with backward compatibility + if hasattr(props, 'shadow_spread'): + props.shadow_spread = preset_data.get('shadow_spread', 1.0) + props.enable_glow = preset_data.get('enable_glow', props.enable_glow) + props.glow_blur = preset_data.get('glow_blur', props.glow_blur) + props.glow_color = preset_data.get('glow_color', props.glow_color) + + # Load blur settings + props.enable_blur = preset_data.get('enable_blur', props.enable_blur) + props.blur_radius = preset_data.get('blur_radius', props.blur_radius) + + # Load text fitting settings + props.enable_text_fitting = preset_data.get('enable_text_fitting', props.enable_text_fitting) + props.text_fitting_mode = preset_data.get('text_fitting_mode', props.text_fitting_mode) + props.text_fitting_margin = preset_data.get('text_fitting_margin', props.text_fitting_margin) + props.min_font_size = preset_data.get('min_font_size', props.min_font_size) + props.max_font_size = preset_data.get('max_font_size', props.max_font_size) + + # Load overlay data + props.image_overlays.clear() + overlay_data_list = preset_data.get('image_overlays', []) + for overlay_data in overlay_data_list: + overlay = props.image_overlays.add() + overlay.name = overlay_data.get('name', 'Overlay') + overlay.image_path = overlay_data.get('image_path', '') + overlay.x_position = overlay_data.get('x_position', 0.5) + overlay.y_position = overlay_data.get('y_position', 0.5) + overlay.scale = overlay_data.get('scale', 1.0) + overlay.rotation = overlay_data.get('rotation', 0.0) + overlay.z_index = overlay_data.get('z_index', 1) + overlay.enabled = overlay_data.get('enabled', True) + + props.is_updating = False + + # FINAL VERIFICATION: Check what the properties actually are after loading + print(f"[PRESET LOAD DEBUG] ==================== FINAL VERIFICATION ====================") + print(f"[PRESET LOAD DEBUG] Properties AFTER loading preset '{self.preset_name}':") + print(f"[PRESET LOAD DEBUG] disable_shadows: {props.disable_shadows} (type: {type(props.disable_shadows)})") + print(f"[PRESET LOAD DEBUG] disable_reflections: {props.disable_reflections} (type: {type(props.disable_reflections)})") + print(f"[PRESET LOAD DEBUG] disable_backfacing: {props.disable_backfacing} (type: {type(props.disable_backfacing)})") + print(f"[PRESET LOAD DEBUG] ================================================================") + + # Always generate preview after loading preset + # Import preview generation function + from .. import generate_preview + 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_refresh_presets(Operator): + """Refresh presets from blend file and local storage""" + bl_idname = "text_texture.refresh_presets" + bl_label = "Refresh Presets" + bl_description = "Reload presets from blend file and local storage" + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + try: + # Use the reliable synchronous refresh system + success = refresh_presets_sync() + + if success: + props = context.scene.text_texture_props + total_presets = len(props.presets) + + # Count by source for detailed reporting + blend_count = len([p for p in props.presets if p.source == 'BLEND_FILE']) + persistent_count = len([p for p in props.presets if p.source == 'PERSISTENT_FILE']) + local_count = len([p for p in props.presets if p.source == 'LOCAL_FILE']) + + self.report({'INFO'}, f"✅ Refreshed {total_presets} presets ({blend_count} blend file, {persistent_count} persistent, {local_count} local)") + else: + self.report({'WARNING'}, "Preset refresh completed with issues - check console for details") + + # Force UI redraw + for area in context.screen.areas: + area.tag_redraw() + + return {'FINISHED'} + + except Exception as e: + self.report({'ERROR'}, f"Failed to refresh presets: {str(e)}") + import traceback + traceback.print_exc() + return {'CANCELLED'} + +class TEXT_TEXTURE_OT_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 with backup and upgrade information""" + bl_idname = "text_texture.show_migration_report" + bl_label = "Show Migration Report" + bl_description = "Display information about preset migration and backups" + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + return {'FINISHED'} + + def invoke(self, context, event): + return context.window_manager.invoke_props_dialog(self, width=400) + + def draw(self, context): + layout = self.layout + layout.label(text="Migration Report", icon='INFO') + layout.separator() + layout.label(text="This addon has been upgraded to the new preset system.") + layout.label(text="Your existing presets have been preserved.") + layout.separator() + layout.label(text="Features:", icon='CHECKMARK') + layout.label(text="• Presets saved in blend files") + layout.label(text="• Cross-file preset sharing") + layout.label(text="• Automatic backup system") + layout.separator() + +# Export all operator classes for wildcard imports +__all__ = [ + 'TEXT_TEXTURE_OT_save_preset', + 'TEXT_TEXTURE_OT_save_preset_enter', + 'TEXT_TEXTURE_OT_load_preset', + 'TEXT_TEXTURE_OT_delete_preset', + 'TEXT_TEXTURE_OT_refresh_presets', + 'TEXT_TEXTURE_OT_export_presets', + 'TEXT_TEXTURE_OT_import_presets', + 'TEXT_TEXTURE_OT_show_migration_report', +] \ No newline at end of file diff --git a/operators/utility_ops.py b/operators/utility_ops.py new file mode 100644 index 0000000..47c2acb --- /dev/null +++ b/operators/utility_ops.py @@ -0,0 +1,607 @@ +""" +Text Texture Generator - Utility Operators +Handles preview refresh, font refresh, UI operations, overlay management, and positioning utilities. +""" + +import bpy +from bpy.types import Operator +from bpy.props import StringProperty, IntProperty, EnumProperty +import os + +# Import functions from parent module +try: + print("[TTG DEBUG] IMPORT TRACE: Attempting to import from ui.preview...") + print("[TTG DEBUG] IMPORT TRACE: Looking for generate_preview in ui.preview module") + + # First, let's see what's actually available in ui.preview + from .. import ui + print(f"[TTG DEBUG] IMPORT TRACE: ui.preview module contents: {dir(ui.preview)}") + + # Now try the actual import that's failing + from ..core.generation_engine import generate_preview + print("[TTG DEBUG] IMPORT TRACE: SUCCESS - generate_preview imported from ui.preview") + from ..utils.system import get_system_fonts, get_font_enum_items + from ..presets.storage_system import refresh_presets_sync, ensure_presets_available + from ..utils.constants import sync_margin_values +except ImportError as e: + print(f"[TTG DEBUG] IMPORT ERROR: Failed to import from ui.preview: {e}") + print("[TTG DEBUG] IMPORT TRACE: This confirms generate_preview is NOT in ui.preview") + + # Let's check if it's available in core.generation_engine + try: + print("[TTG DEBUG] IMPORT TRACE: Attempting fallback import from core.generation_engine...") + from ..core.generation_engine import generate_preview + print("[TTG DEBUG] IMPORT TRACE: SUCCESS - generate_preview found in core.generation_engine!") + from ..utils.system import get_system_fonts, get_font_enum_items + from ..presets.storage_system import refresh_presets_sync, ensure_presets_available + from ..utils.constants import sync_margin_values + print("[TTG DEBUG] IMPORT TRACE: All imports successful with corrected path") + except ImportError as fallback_error: + print(f"[TTG DEBUG] IMPORT ERROR: Fallback import also failed: {fallback_error}") + print(f"[TTG DEBUG] IMPORT ERROR: Original error was: {e}") + # Fallback imports for development/testing + print("[TTG DEBUG] IMPORT TRACE: Using fallback placeholder functions") + def generate_preview(context): + print("Fallback generate_preview called") + return None + def get_system_fonts(): + return {} + def get_font_enum_items(): + return [("default", "Default Font", "Use system default font", 0)] + def refresh_presets_sync(): + return True + def ensure_presets_available(): + return True + def sync_margin_values(props, changed_margin): + pass + + +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_open_panel(Operator): + """Open Text Texture Generator panel""" + bl_idname = "text_texture.open_panel" + bl_label = "Open Text Texture Panel" + bl_description = "Open the Text Texture Generator panel in the sidebar" + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + # Ensure the sidebar is visible + for area in context.screen.areas: + if area.type == 'VIEW_3D': + for space in area.spaces: + if space.type == 'VIEW_3D': + space.show_region_ui = True + # Switch to Tool tab + area.tag_redraw() + self.report({'INFO'}, "Text Texture panel opened in 3D View sidebar (press N if not visible)") + return {'FINISHED'} + elif area.type == 'NODE_EDITOR': + for space in area.spaces: + if space.type == 'NODE_EDITOR': + space.show_region_ui = True + area.tag_redraw() + self.report({'INFO'}, "Text Texture panel opened in Shader Editor sidebar") + return {'FINISHED'} + + self.report({'WARNING'}, "Please open a 3D View or Shader Editor first") + return {'CANCELLED'} + + +class TEXT_TEXTURE_OT_refresh_fonts(Operator): + """Refresh fonts""" + bl_idname = "text_texture.refresh_fonts" + bl_label = "Refresh Fonts" + bl_description = "Refresh font list" + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + if hasattr(get_font_enum_items, 'cached_fonts'): + del get_font_enum_items.cached_fonts + get_font_enum_items.cached_fonts = get_system_fonts() + self.report({'INFO'}, f"Found {len(get_font_enum_items.cached_fonts)} fonts") + return {'FINISHED'} + + +class TEXT_TEXTURE_OT_refresh_presets(Operator): + """Refresh presets from blend file and local storage""" + bl_idname = "text_texture.refresh_presets" + bl_label = "Refresh Presets" + bl_description = "Reload presets from blend file and local storage" + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + try: + # Use the reliable synchronous refresh system + success = refresh_presets_sync() + + if success: + props = context.scene.text_texture_props + total_presets = len(props.presets) + + # Count by source for detailed reporting + blend_count = len([p for p in props.presets if p.source == 'BLEND_FILE']) + persistent_count = len([p for p in props.presets if p.source == 'PERSISTENT_FILE']) + local_count = len([p for p in props.presets if p.source == 'LOCAL_FILE']) + + self.report({'INFO'}, f"✅ Refreshed {total_presets} presets ({blend_count} blend file, {persistent_count} persistent, {local_count} local)") + else: + self.report({'WARNING'}, "Preset refresh completed with issues - check console for details") + + # Force UI redraw + for area in context.screen.areas: + area.tag_redraw() + + return {'FINISHED'} + + except Exception as e: + self.report({'ERROR'}, f"Failed to refresh presets: {str(e)}") + import traceback + traceback.print_exc() + return {'CANCELLED'} + + +class TEXT_TEXTURE_OT_add_overlay(Operator): + """Add new image overlay""" + bl_idname = "text_texture.add_overlay" + bl_label = "Add Overlay" + bl_description = "Add new image overlay" + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + props = context.scene.text_texture_props + + overlay = props.image_overlays.add() + overlay.name = f"Overlay {len(props.image_overlays)}" + props.active_overlay_index = len(props.image_overlays) - 1 + + # Force UI redraw + for area in context.screen.areas: + area.tag_redraw() + + self.report({'INFO'}, f"Added overlay: {overlay.name}") + return {'FINISHED'} + + +class TEXT_TEXTURE_OT_remove_overlay(Operator): + """Remove active image overlay""" + bl_idname = "text_texture.remove_overlay" + bl_label = "Remove Overlay" + bl_description = "Remove active image overlay" + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + props = context.scene.text_texture_props + + if props.image_overlays and props.active_overlay_index < len(props.image_overlays): + overlay_name = props.image_overlays[props.active_overlay_index].name + props.image_overlays.remove(props.active_overlay_index) + + # Update active index + if props.active_overlay_index >= len(props.image_overlays) and props.image_overlays: + props.active_overlay_index = len(props.image_overlays) - 1 + + # Force UI redraw + for area in context.screen.areas: + area.tag_redraw() + + self.report({'INFO'}, f"Removed overlay: {overlay_name}") + else: + self.report({'WARNING'}, "No overlay to remove") + + return {'FINISHED'} + + +class TEXT_TEXTURE_OT_set_anchor_point(Operator): + """Set anchor point for text positioning""" + bl_idname = "text_texture.set_anchor_point" + bl_label = "Set Anchor Point" + bl_description = "Set text anchor position" + bl_options = {'REGISTER', 'UNDO'} + + anchor_point: StringProperty() + + def execute(self, context): + props = context.scene.text_texture_props + props.anchor_point = self.anchor_point + return {'FINISHED'} + + +class TEXT_TEXTURE_OT_sync_margins(Operator): + """Sync margin values when linked""" + bl_idname = "text_texture.sync_margins" + bl_label = "Sync Margins" + bl_description = "Synchronize margin values" + bl_options = {'REGISTER', 'UNDO'} + + margin_type: StringProperty() + + def execute(self, context): + props = context.scene.text_texture_props + sync_margin_values(props, self.margin_type) + return {'FINISHED'} + + +class TEXT_TEXTURE_OT_duplicate_overlay(Operator): + """Duplicate image overlay""" + bl_idname = "text_texture.duplicate_overlay" + bl_label = "Duplicate Overlay" + bl_description = "Duplicate this image overlay" + bl_options = {'REGISTER', 'UNDO'} + + overlay_index: IntProperty() + + def execute(self, context): + props = context.scene.text_texture_props + + if 0 <= self.overlay_index < len(props.image_overlays): + source_overlay = props.image_overlays[self.overlay_index] + + # Debug logging + print(f"[DUPLICATE DEBUG] Duplicating overlay '{source_overlay.name}'") + print(f"[DUPLICATE DEBUG] Source properties:") + print(f"[DUPLICATE DEBUG] image_path: '{source_overlay.image_path}'") + print(f"[DUPLICATE DEBUG] enabled: {source_overlay.enabled}") + print(f"[DUPLICATE DEBUG] positioning_mode: '{source_overlay.positioning_mode}'") + print(f"[DUPLICATE DEBUG] scale: {source_overlay.scale}") + print(f"[DUPLICATE DEBUG] z_index: {source_overlay.z_index}") + + new_overlay = props.image_overlays.add() + + # Copy all properties with explicit verification + new_overlay.name = f"{source_overlay.name} Copy" + new_overlay.image_path = source_overlay.image_path + new_overlay.x_position = source_overlay.x_position + new_overlay.y_position = source_overlay.y_position + new_overlay.scale = source_overlay.scale + new_overlay.rotation = source_overlay.rotation + # Fix z-index assignment for duplicated overlays to ensure proper visibility + # For PREPEND/APPEND overlays, increment z_index to ensure duplicate appears above original + # This prevents the duplicate from being rendered behind the original overlay + if source_overlay.positioning_mode in ['PREPEND', 'APPEND']: + # Increment z_index to ensure proper layering order for duplicated overlay + new_overlay.z_index = min(source_overlay.z_index + 1, 10) # Cap at max z_index value + print(f"[DUPLICATE DEBUG] Fixed z_index for {source_overlay.positioning_mode} overlay: {source_overlay.z_index} -> {new_overlay.z_index}") + else: + # For ABSOLUTE overlays, keep same z_index (different positions so no overlap issue) + new_overlay.z_index = source_overlay.z_index + new_overlay.positioning_mode = source_overlay.positioning_mode + new_overlay.text_spacing = source_overlay.text_spacing + new_overlay.image_spacing = source_overlay.image_spacing + new_overlay.enabled = source_overlay.enabled + + # Verify the copy was successful + print(f"[DUPLICATE DEBUG] New overlay properties:") + print(f"[DUPLICATE DEBUG] name: '{new_overlay.name}'") + print(f"[DUPLICATE DEBUG] image_path: '{new_overlay.image_path}'") + print(f"[DUPLICATE DEBUG] enabled: {new_overlay.enabled}") + print(f"[DUPLICATE DEBUG] positioning_mode: '{new_overlay.positioning_mode}'") + print(f"[DUPLICATE DEBUG] scale: {new_overlay.scale}") + print(f"[DUPLICATE DEBUG] z_index: {new_overlay.z_index}") + + # Move to position after source + new_index = len(props.image_overlays) - 1 + target_index = min(self.overlay_index + 1, new_index) + + if new_index != target_index: + props.image_overlays.move(new_index, target_index) + print(f"[DUPLICATE DEBUG] Moved overlay from index {new_index} to {target_index}") + + props.active_overlay_index = target_index + + # Clear any cached images to force reload + print(f"[DUPLICATE DEBUG] Clearing image cache and forcing preview update...") + + # 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'} + +# Export all operator classes for wildcard imports +__all__ = [ + 'TEXT_TEXTURE_OT_refresh_preview', + 'TEXT_TEXTURE_OT_view_preview_zoom', + 'TEXT_TEXTURE_OT_open_panel', + 'TEXT_TEXTURE_OT_refresh_fonts', + 'TEXT_TEXTURE_OT_refresh_presets', + 'TEXT_TEXTURE_OT_add_overlay', + 'TEXT_TEXTURE_OT_remove_overlay', + 'TEXT_TEXTURE_OT_set_anchor_point', + 'TEXT_TEXTURE_OT_sync_margins', + 'TEXT_TEXTURE_OT_duplicate_overlay', + 'TEXT_TEXTURE_OT_delete_overlay', + 'TEXT_TEXTURE_OT_move_overlay', +] \ No newline at end of file diff --git a/presets/__init__.py b/presets/__init__.py new file mode 100644 index 0000000..f3bd7ac --- /dev/null +++ b/presets/__init__.py @@ -0,0 +1,12 @@ +""" +Preset System Module + +This module contains the three-tier preset storage system for the Text Texture Generator: +- Blend file storage (highest priority) +- Persistent file storage (survives addon updates) +- Legacy file storage (backward compatibility) +""" + +# Import all preset functionality +from .storage_system import * +from .migration import * \ No newline at end of file diff --git a/presets/migration.py b/presets/migration.py new file mode 100644 index 0000000..dd08a70 --- /dev/null +++ b/presets/migration.py @@ -0,0 +1,125 @@ +# Migration functions for preset storage system +import bpy +import json +import os +import shutil +from datetime import datetime + +def migrate_presets_if_needed(): + """ + Migrate presets from older versions if needed. + This function checks if migration is required and performs it automatically. + Returns: dict with 'migrated_count' key indicating number of presets migrated. + """ + print("[TTG MIGRATION DEBUG] Starting migrate_presets_if_needed()") + preferences = bpy.context.preferences.addons.get("Text_Texture_Generator_Pro") + if not preferences: + print("[TTG MIGRATION DEBUG] No preferences found - returning proper dict format") + return {'migrated_count': 0} + + addon_version = preferences.preferences.get("addon_version", "0.0.0") + current_version = "1.0.0" # This should match the addon's current version + + if addon_version != current_version: + print(f"Text Texture Generator: Migrating presets from version {addon_version} to {current_version}") + + # Backup existing presets before migration + backup_success = backup_presets() + if not backup_success: + print("Text Texture Generator: Warning - Failed to backup presets before migration") + + # Perform migration steps based on version differences + migration_success = True + migrated_count = 0 + + try: + # Version-specific migration logic would go here + # For now, we'll just update the version number + preferences.preferences["addon_version"] = current_version + + # In a real migration, this would be the actual count of migrated presets + # For now, we assume 1 migration operation (version update) + migrated_count = 1 + + if migration_success: + print(f"Text Texture Generator: Successfully migrated presets to version {current_version}") + else: + print("Text Texture Generator: Migration completed with warnings") + + except Exception as e: + print(f"Text Texture Generator: Error during preset migration: {str(e)}") + migration_success = False + migrated_count = 0 + + print(f"[TTG MIGRATION DEBUG] Migration completed, returning proper dict format: migrated_count={migrated_count}") + return {'migrated_count': migrated_count} + + print("[TTG MIGRATION DEBUG] No migration needed, returning proper dict format: migrated_count=0") + return {'migrated_count': 0} # No migration needed + +def backup_presets(): + """ + Create a backup of current presets before migration. + Returns True if backup was successful, False otherwise. + """ + try: + # Get the addon directory + addon_dir = os.path.dirname(__file__) + parent_dir = os.path.dirname(addon_dir) + + # Create backup directory with timestamp + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + backup_dir = os.path.join(parent_dir, f"preset_backup_{timestamp}") + + # Find preset files to backup + preset_files = [] + + # Check for blend file presets + if bpy.data.filepath: + blend_file_dir = os.path.dirname(bpy.data.filepath) + preset_file = os.path.join(blend_file_dir, ".text_texture_presets.json") + if os.path.exists(preset_file): + preset_files.append(preset_file) + + # Check for persistent presets + preferences = bpy.context.preferences.addons.get("Text_Texture_Generator_Pro") + if preferences and hasattr(preferences.preferences, 'persistent_presets_path'): + persistent_path = preferences.preferences.persistent_presets_path + if persistent_path and os.path.exists(persistent_path): + preset_files.append(persistent_path) + + # Check for legacy presets in addon directory + legacy_preset_file = os.path.join(parent_dir, "text_texture_presets.json") + if os.path.exists(legacy_preset_file): + preset_files.append(legacy_preset_file) + + # Create backup if there are files to backup + if preset_files: + os.makedirs(backup_dir, exist_ok=True) + + for preset_file in preset_files: + filename = os.path.basename(preset_file) + backup_path = os.path.join(backup_dir, filename) + shutil.copy2(preset_file, backup_path) + print(f"Text Texture Generator: Backed up {filename} to {backup_dir}") + + # Create a backup info file + backup_info = { + "backup_timestamp": timestamp, + "addon_version_before_migration": bpy.context.preferences.addons.get("Text_Texture_Generator_Pro", {}).get("addon_version", "unknown"), + "files_backed_up": [os.path.basename(f) for f in preset_files] + } + + info_file = os.path.join(backup_dir, "backup_info.json") + with open(info_file, 'w') as f: + json.dump(backup_info, f, indent=2) + + print(f"Text Texture Generator: Created preset backup in {backup_dir}") + return True + else: + print("Text Texture Generator: No preset files found to backup") + return True # Not an error if there are no presets to backup + + except Exception as e: + print(f"Text Texture Generator: Error creating preset backup: {str(e)}") + return False \ No newline at end of file diff --git a/presets/storage_system.py b/presets/storage_system.py new file mode 100644 index 0000000..ddf4856 --- /dev/null +++ b/presets/storage_system.py @@ -0,0 +1,462 @@ +import bpy +import os +import json +import time +import platform + +# ============================================================================ +# 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.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""" + # Import bl_info from the main addon file + import sys + import importlib.util + + # Get the main addon module + main_module_path = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) + init_file = os.path.join(main_module_path, "__init__.py") + + try: + spec = importlib.util.spec_from_file_location("main_addon", init_file) + main_addon = importlib.util.module_from_spec(spec) + spec.loader.exec_module(main_addon) + return main_addon.bl_info["version"] + except Exception as e: + print(f"[TTG] Error getting addon version: {e}") + return (2, 3, 2) # Fallback 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 get_blend_file_presets(): + """Get presets stored in the current blend file""" + try: + # Get custom properties from the scene + import bpy + scene = bpy.context.scene + + # DETAILED LOGGING: Function entry + print(f"[GET_BLEND_FILE_PRESETS DEBUG] ==================== LOADING FROM BLEND FILE ==================") + print(f"[GET_BLEND_FILE_PRESETS DEBUG] Function called to get presets from blend file") + + # Store presets in scene custom properties + if "text_texture_presets" not in scene: + print(f"[GET_BLEND_FILE_PRESETS DEBUG] No 'text_texture_presets' found in scene, creating empty") + scene["text_texture_presets"] = "{}" + else: + print(f"[GET_BLEND_FILE_PRESETS DEBUG] Found 'text_texture_presets' in scene") + + preset_data = scene.get("text_texture_presets", "{}") + print(f"[GET_BLEND_FILE_PRESETS DEBUG] Raw preset data type: {type(preset_data)}") + print(f"[GET_BLEND_FILE_PRESETS DEBUG] Raw preset data length: {len(str(preset_data))} characters") + + if isinstance(preset_data, str): + print(f"[GET_BLEND_FILE_PRESETS DEBUG] Preset data is string, attempting JSON parse...") + try: + import json + parsed_presets = json.loads(preset_data) + print(f"[GET_BLEND_FILE_PRESETS DEBUG] Successfully parsed JSON") + print(f"[GET_BLEND_FILE_PRESETS DEBUG] Parsed presets type: {type(parsed_presets)}") + print(f"[GET_BLEND_FILE_PRESETS DEBUG] Parsed presets count: {len(parsed_presets) if isinstance(parsed_presets, dict) else 'N/A'}") + + if isinstance(parsed_presets, dict): + print(f"[GET_BLEND_FILE_PRESETS DEBUG] Preset names: {list(parsed_presets.keys())}") + + # DETAILED VERIFICATION: Check shader properties in each preset + for preset_name, preset_content in parsed_presets.items(): + print(f"[GET_BLEND_FILE_PRESETS DEBUG] Preset '{preset_name}' analysis:") + if isinstance(preset_content, dict): + print(f"[GET_BLEND_FILE_PRESETS DEBUG] Keys: {list(preset_content.keys())}") + print(f"[GET_BLEND_FILE_PRESETS DEBUG] disable_shadows: {preset_content.get('disable_shadows', 'NOT FOUND')}") + print(f"[GET_BLEND_FILE_PRESETS DEBUG] disable_reflections: {preset_content.get('disable_reflections', 'NOT FOUND')}") + print(f"[GET_BLEND_FILE_PRESETS DEBUG] disable_backfacing: {preset_content.get('disable_backfacing', 'NOT FOUND')}") + else: + print(f"[GET_BLEND_FILE_PRESETS DEBUG] ERROR: Preset content is not a dict: {type(preset_content)}") + + print(f"[GET_BLEND_FILE_PRESETS DEBUG] Returning parsed presets") + print(f"[GET_BLEND_FILE_PRESETS DEBUG] ================================================================") + return parsed_presets + + except json.JSONDecodeError as json_err: + print(f"[GET_BLEND_FILE_PRESETS DEBUG] JSON decode error: {json_err}") + print(f"[GET_BLEND_FILE_PRESETS DEBUG] Raw data causing error: {preset_data[:200]}...") # First 200 chars + print("Error parsing blend file presets, resetting to empty") + scene["text_texture_presets"] = "{}" + print(f"[GET_BLEND_FILE_PRESETS DEBUG] Reset to empty, returning empty dict") + return {} + else: + print(f"[GET_BLEND_FILE_PRESETS DEBUG] Preset data is not string, returning empty dict") + print(f"[GET_BLEND_FILE_PRESETS DEBUG] ================================================================") + return {} + except Exception as e: + print(f"[GET_BLEND_FILE_PRESETS DEBUG] EXCEPTION occurred: {e}") + import traceback + print(f"[GET_BLEND_FILE_PRESETS DEBUG] Full traceback: {traceback.format_exc()}") + print(f"Error getting blend file presets: {e}") + return {} + +def save_blend_file_preset(preset_name, preset_data): + """Save a preset to the current blend file""" + try: + import bpy + import json + scene = bpy.context.scene + + # DETAILED LOGGING: Function entry + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] ==================== SAVING TO BLEND FILE ===================") + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Function called with preset_name: '{preset_name}'") + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Preset data type: {type(preset_data)}") + if isinstance(preset_data, dict): + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Preset data keys: {list(preset_data.keys())}") + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Shader properties in preset_data:") + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_shadows: {preset_data.get('disable_shadows', 'NOT FOUND')}") + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_reflections: {preset_data.get('disable_reflections', 'NOT FOUND')}") + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_backfacing: {preset_data.get('disable_backfacing', 'NOT FOUND')}") + else: + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] ERROR: preset_data is not a dictionary!") + + # Get existing presets + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Getting existing presets from blend file...") + presets = get_blend_file_presets() + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Existing presets count: {len(presets)}") + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Existing preset names: {list(presets.keys())}") + + # Add or update the preset + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Adding/updating preset '{preset_name}'...") + presets[preset_name] = preset_data + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Presets now contains {len(presets)} items") + + # VERIFY: Check that shader properties are in the preset we're about to save + if preset_name in presets and isinstance(presets[preset_name], dict): + saved_preset = presets[preset_name] + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] VERIFICATION - Shader properties in final preset:") + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_shadows: {saved_preset.get('disable_shadows', 'NOT FOUND')}") + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_reflections: {saved_preset.get('disable_reflections', 'NOT FOUND')}") + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_backfacing: {saved_preset.get('disable_backfacing', 'NOT FOUND')}") + + # Save back to scene custom properties + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Converting presets to JSON...") + json_data = json.dumps(presets, indent=2) + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] JSON data length: {len(json_data)} characters") + + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Saving to scene custom properties...") + scene["text_texture_presets"] = json_data + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Successfully saved to scene['text_texture_presets']") + + # FINAL VERIFICATION: Read back from scene to confirm save + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] FINAL VERIFICATION - Reading back from scene...") + readback_data = scene.get("text_texture_presets", "{}") + if readback_data: + try: + readback_presets = json.loads(readback_data) + if preset_name in readback_presets: + readback_preset = readback_presets[preset_name] + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] READBACK VERIFICATION - Shader properties:") + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_shadows: {readback_preset.get('disable_shadows', 'NOT FOUND')}") + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_reflections: {readback_preset.get('disable_reflections', 'NOT FOUND')}") + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_backfacing: {readback_preset.get('disable_backfacing', 'NOT FOUND')}") + else: + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] ERROR: Preset '{preset_name}' not found in readback data!") + except json.JSONDecodeError as json_err: + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] ERROR: Could not parse readback JSON: {json_err}") + else: + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] ERROR: No data in scene['text_texture_presets'] after save!") + + print(f"Saved preset '{preset_name}' to blend file") + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] ================================================================") + return True + except Exception as e: + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] EXCEPTION occurred: {e}") + import traceback + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Full traceback: {traceback.format_exc()}") + print(f"Error saving preset to blend file: {e}") + return False + +def delete_blend_file_preset(preset_name): + """Delete a preset from the current blend file""" + try: + import bpy + import json + scene = bpy.context.scene + + # Get existing presets + presets = get_blend_file_presets() + + # Remove the preset if it exists + if preset_name in presets: + del presets[preset_name] + scene["text_texture_presets"] = json.dumps(presets, indent=2) + print(f"Deleted preset '{preset_name}' from blend file") + return True + else: + print(f"Preset '{preset_name}' not found in blend file") + return False + except Exception as e: + print(f"Error deleting preset from blend file: {e}") + return False + +def initialize_presets(): + """Initialize presets with simplified, reliable synchronization""" + try: + import bpy + if not hasattr(bpy.context, 'scene') or not bpy.context.scene: + return + + props = bpy.context.scene.text_texture_props + if not props: + return + + print(f"[TTG] Starting preset initialization...") + + # Clear existing presets to avoid duplicates + props.presets.clear() + + # Collect all presets from all sources + all_presets = {} + + # 1. Load from blend file (highest priority - travels with file) + try: + blend_presets = get_blend_file_presets() + for name, data in blend_presets.items(): + all_presets[name] = { + 'data': data, + 'source': 'BLEND_FILE' + } + print(f"[TTG] Found {len(blend_presets)} presets in blend file") + except Exception as e: + print(f"[TTG] Error loading blend file presets: {e}") + + # 2. Load from persistent storage (survives addon updates) + try: + persistent_dir = get_persistent_preset_path() + if os.path.exists(persistent_dir): + for filename in os.listdir(persistent_dir): + if filename.endswith('.json'): + preset_name = os.path.splitext(filename)[0] + # Only add if not already in blend file (blend file has priority) + if preset_name not in all_presets: + try: + with open(os.path.join(persistent_dir, filename), 'r') as f: + preset_data = json.load(f) + all_presets[preset_name] = { + 'data': preset_data, + 'source': 'PERSISTENT_FILE' + } + except (json.JSONDecodeError, OSError) as e: + print(f"[TTG] Error reading persistent preset {preset_name}: {e}") + print(f"[TTG] Found {len([p for p in all_presets.values() if p['source'] == 'PERSISTENT_FILE'])} persistent presets") + except Exception as e: + print(f"[TTG] Error loading persistent presets: {e}") + + # 3. Load from legacy storage (backward compatibility) + try: + legacy_dir = get_preset_path() + if os.path.exists(legacy_dir) and legacy_dir != get_persistent_preset_path(): + for filename in os.listdir(legacy_dir): + if filename.endswith('.json'): + preset_name = os.path.splitext(filename)[0] + # Only add if not already found in higher priority sources + if preset_name not in all_presets: + try: + with open(os.path.join(legacy_dir, filename), 'r') as f: + preset_data = json.load(f) + all_presets[preset_name] = { + 'data': preset_data, + 'source': 'LOCAL_FILE' + } + except (json.JSONDecodeError, OSError) as e: + print(f"[TTG] Error reading legacy preset {preset_name}: {e}") + print(f"[TTG] Found {len([p for p in all_presets.values() if p['source'] == 'LOCAL_FILE'])} legacy presets") + except Exception as e: + print(f"[TTG] Error loading legacy presets: {e}") + + # Add all presets to UI list + for preset_name, preset_info in all_presets.items(): + preset = props.presets.add() + preset.name = preset_name + preset.source = preset_info['source'] + + # Report results + blend_count = len([p for p in props.presets if p.source == 'BLEND_FILE']) + persistent_count = len([p for p in props.presets if p.source == 'PERSISTENT_FILE']) + legacy_count = len([p for p in props.presets if p.source == 'LOCAL_FILE']) + + print(f"[TTG] ✅ Initialized {len(props.presets)} presets ({blend_count} blend file, {persistent_count} persistent, {legacy_count} legacy)") + + except Exception as e: + print(f"[TTG] Critical error in initialize_presets: {e}") + import traceback + traceback.print_exc() + +def refresh_presets(): + """Refresh the preset list by rescanning files (legacy function)""" + import bpy + refresh_presets_sync() + + # Force UI update + try: + if bpy.context.area: + bpy.context.area.tag_redraw() + except: + pass + +def refresh_presets_sync(): + """Centralized, synchronous preset refresh - guarantees completion""" + try: + import bpy + + print("[TTG] 🔄 Synchronizing presets...") + + # Ensure we have valid context + if not hasattr(bpy.context, 'scene') or not bpy.context.scene: + print("[TTG] ⚠️ No valid scene context for preset refresh") + return False + + props = bpy.context.scene.text_texture_props + if not props: + print("[TTG] ⚠️ No text_texture_props found") + return False + + # Run the simplified initialization + initialize_presets() + + # Validate that presets are actually loaded + preset_count = len(props.presets) + if preset_count == 0: + print("[TTG] ⚠️ Warning: No presets found after refresh") + else: + print(f"[TTG] ✅ Preset sync complete - {preset_count} presets available") + + return True + + except Exception as e: + print(f"[TTG] ❌ Error in refresh_presets_sync: {e}") + import traceback + traceback.print_exc() + return False + +def ensure_presets_available(): + """Defensive programming - ensure presets are loaded before UI operations""" + try: + import bpy + + if not hasattr(bpy.context, 'scene') or not bpy.context.scene: + return False + + props = bpy.context.scene.text_texture_props + if not props: + return False + + # If no presets loaded, try to load them + if len(props.presets) == 0: + print("[TTG] 🛡️ No presets available - attempting to load...") + return refresh_presets_sync() + + return True + + except Exception as e: + print(f"[TTG] Error in ensure_presets_available: {e}") + return False \ No newline at end of file diff --git a/properties/__init__.py b/properties/__init__.py new file mode 100644 index 0000000..50de3a4 --- /dev/null +++ b/properties/__init__.py @@ -0,0 +1,18 @@ +""" +Text Texture Generator - Properties Module + +This module contains all property group definitions and configuration systems +for the text texture generator addon. +""" + +from .property_groups import ( + TEXT_TEXTURE_ImageOverlay, + TEXT_TEXTURE_Preset, + TEXT_TEXTURE_Properties +) + +__all__ = [ + 'TEXT_TEXTURE_ImageOverlay', + 'TEXT_TEXTURE_Preset', + 'TEXT_TEXTURE_Properties', +] \ No newline at end of file diff --git a/properties/__pycache__/__init__.cpython-311.pyc b/properties/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..078acd9eddf6403901ded83b946142818e039e52 GIT binary patch literal 524 zcmb7?ze~h06vvY^=SjWOK~UT>2{(BD1L7v)pdMO9x`cAc^$KlMl2ojdqyK@kF5;iz zztL3?+zPkdWZwM{bTfeuc`x52-}lICx9cM}K8yG9BSz>0g5@BK#bnnlo{&Hx5?GKF zXCx$<7kU{DDMm38-NtW{YsAxBA`*hIm0EEn!n8qvb&5%Y{(}x(gA)rpv|(!a1mCVBnH>NC literal 0 HcmV?d00001 diff --git a/properties/__pycache__/property_groups.cpython-311.pyc b/properties/__pycache__/property_groups.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0721841162a7c804357e330ff92261e2bbe917da GIT binary patch literal 26347 zcmcJ14SXEOb>9NG1MVAlz!3yMkX(L%#8DtY(vtWgQ6xx$Bxn+V2v8y+%clco0UUWB z(B6S0)>O(QPE03igQj)NI8CcMDPu)U;#*quR56L_ zHg^7RW_EAyZUNcOuLs=R?#_E}-n@D9=FOX#{e^Hipy2b9uYPgnk3Xg;U#A=EQtiqZ;t}vh0aKAxO-ojUW#-lvXkGw!lUX14j zkr%4TYhm@HN+O)n>l7t0rrj#4l+5XxdREJ6da9V$)xGM7o}bn9;=FoR&(F;k0)era zbV1GL*<40bC-b>tDxE8+sZ2(lwIHVnQf1myI+re{^T?h8sp5>L7O50UsFZ2DloVt6 z+`hoTt)iZqENZMw%1I=ho6O8H?Py@HIyP`+EJ?}bO9RQlY-(B?zM<)v)cjF(G@Y9U zXK8w<=EYOub1FSOhdP0sYWxvhD`>@|s44xb1=O2xvsy95QpJ?2)*^6`q_x6P^&+ZY zW>?hwX1X}TB~n#Wl&zwKA~-Ar9#QKJCF;zuC1Eb$gwKqO7IlDFkImp z#TT?maA`jb^N03r=wv>hDc=p`=Cbx(%c*=O18neOKWiSzr*lQkrZ-tHRf~Q+_>!)@ z_ft@lPt<){DdN}qd=mP7AJA{v7=?P$s!!Hkv!Slpd9T$Mn=DL4VGXzIuMu_r`5EPB z>+b{SEgP>+apo6(yaLx#4eR8p$a|`w%e
HCTyStNe0+5BAdSx?{aTUX9sC(Ua% z{c0}m)=kx+zxrNlxZU8yNZJ@5SbES|s&#$4fi*qwRn`P{0+Qx^*l%=So`tSz&_wM< zTDz&SklIn9geYq*WRErFL({_q_~~PeH5v z!eT1fy4RFCrCzxW9jcaYZtD)EXlsn3>{RrYI;BD{8me)wDml+hYx^NZ!Arta`lR=j%$*eT$Xbq-JN$My_WCGccFawdr&LCQXkJ5ubI7hVm4&%#^-?9;1DzoQ=3HkXos) zsH)zE3w^Vd5D$evKms{_BiOQV=-%0-;LhdX&Q+z(+g$QR?lwLQwZ49FIn;Y+;2R$Q zVqoMqFE1Y(!Y}@pJkKwCo?rAl4-ti8%fYTyCFUKdd&d`Ej0`UM&M*7UFZ#}d?pIoO zea^QOJg^)*0K)#0b?=0lUqAc$&{spvX-|1!F=|$^*bqDbxIC>8?R4U9q znL}>&4fGbcGy&gwS!HNv*LKn?4=S&DilklgQ*5@b6y>((4cp+CpDIcvmzBnZ zd0FF+)OXn6mAR*tmv?^zGI`C*)5h1DZr8gHCAXVyH!^%_$h+-jJ_8h|Y6i5|dv23^ z2Gf3eK9|a-CshsOxSB3%S>aDj!JNRIkb^liy9v%xYA#bWLZ`)rdIliI#>)jwEzF}g zWXUHZ2>4^UqD~EHh9*;!Gq5uBr0|Fy3Cmba3KWqs^ynaRj7B1ORMn#d7SKD1KBu>fPKG1Dv18qtfj983809Ry9C zRNqetQ82xnN3$+%`speOsc7X;+pTCL(|8H-5JN3ox z#SQ0{e1prr!A1W6Fc`gap+L(1na-z_UvKM=G<-2!hqPh@w!ED8zQjAYreLn3jMraA zS08VvS2Pc+r!K5{A9(MhgT7^VQmv6SJ`3U;riuBOAM+0HgW!G4g5I)bLJFWl5~_)X zS%fvSD2oBE<$fdJ@D(4xwc2oTwqYH(HXCjuYiBXm!8%>(ZL;CI*k+Wvg>7|(Qze{_ zZDZTn4z`nZyTa{~aDKL%^-L=)@!2}I2ar$H)VS9!c`w_?`cU?Mb^wqE@59f0%l0)< z=MUMCPqL?2=Q@y2OUM8_#169~pjO)+M+t^K19S1L*|Fx_$mnpL8&TIY0(=ss4xvjK zyC_xDOv@G1Q)x|?vp3B8DkfEClbmzCi9wb3#URrPlX`lVTq1opSTY;MfUO_Jja9~( zbVf5;M$lu`VqTJ*!ZXuiS0DiaL@n@X4mJ^NKnvuhpLY`}V8H)QmP&>{So&Yxu7-#l$y30iTD*<%Ocii`C
iQrgcp#O!F$;x$(AjaRQEyh0rv< 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 E0dSP4+W-In literal 0 HcmV?d00001 diff --git a/properties/property_groups.py b/properties/property_groups.py new file mode 100644 index 0000000..c5894b2 --- /dev/null +++ b/properties/property_groups.py @@ -0,0 +1,1056 @@ +""" +Text Texture Generator - Property Groups + +This module contains all property group definitions for the text texture generator addon. +Extracted property definitions include: +- TEXT_TEXTURE_ImageOverlay: Single image overlay configuration +- TEXT_TEXTURE_Preset: Preset storage metadata +- TEXT_TEXTURE_Properties: Main property group with all texture generation settings +""" + +import bpy +from bpy.types import PropertyGroup +from bpy.props import ( + StringProperty, IntProperty, FloatVectorProperty, FloatProperty, + BoolProperty, EnumProperty, CollectionProperty, PointerProperty +) +import os + +# Import update functions from the main module (will need to be adjusted during integration) +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(): + # Import generate_preview function (will need to be adjusted during integration) + try: + from . import generate_preview + generate_preview(context) + except ImportError: + # Fallback during development + print("DEBUG: generate_preview not available during extraction") + return None # Stop timer + + update_live_preview._timer = delayed_preview + bpy.app.timers.register(delayed_preview, first_interval=0.1) + +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'): + # Import get_system_fonts function (will need to be adjusted during integration) + try: + from ..utils.system import get_system_fonts + get_font_enum_items.cached_fonts = get_system_fonts() + except ImportError: + # Fallback during development + get_font_enum_items.cached_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 + +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 + ) \ No newline at end of file diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 0000000..b16a4cf --- /dev/null +++ b/ui/__init__.py @@ -0,0 +1,30 @@ +# Import all UI functionality +from .preview import * +from .panels import ( + TEXT_TEXTURE_PT_panel, + TEXT_TEXTURE_PT_panel_3d, + TEXT_TEXTURE_PT_panel_properties, +) + +# Menu functions for Blender integration +def menu_func_node_editor(self, context): + """Add menu item to Node Editor's Add menu""" + self.layout.operator("text_texture.generate", text="Text Texture", icon='IMAGE_DATA') + +def menu_func_view3d(self, context): + """Add menu item to 3D View's Add menu""" + self.layout.operator("text_texture.generate", text="Text Texture", icon='IMAGE_DATA') + +def menu_func_image(self, context): + """Add menu item to Image Editor menu""" + self.layout.operator("text_texture.generate", text="Text Texture", icon='IMAGE_DATA') + +# Export all UI functions +__all__ = [ + 'TEXT_TEXTURE_PT_panel', + 'TEXT_TEXTURE_PT_panel_3d', + 'TEXT_TEXTURE_PT_panel_properties', + 'menu_func_node_editor', + 'menu_func_view3d', + 'menu_func_image' +] \ No newline at end of file diff --git a/ui/__pycache__/__init__.cpython-311.pyc b/ui/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3311694564cb4bd3ceafcb9d5331c5a2b732f2cc GIT binary patch literal 337 zcmZ3^%ge<81pH+OGgbrX#~=<2FhLogm4J-t3@HpLj5!P;Aet$MF_$TdDVI5lIhQ4h zg^?kJIha9{^y7hvcE6AL5L2L`f;B2Y*I E0B31aYybcN literal 0 HcmV?d00001 diff --git a/ui/__pycache__/panels.cpython-311.pyc b/ui/__pycache__/panels.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..775f114047fabe8b64ebbbbbad1672ebe3fca0e5 GIT binary patch literal 43879 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 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""" + from ..utils.system import get_anchor_point_matrix + + # 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") + + +# ============================================================================ +# 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) \ No newline at end of file diff --git a/ui/preview.py b/ui/preview.py new file mode 100644 index 0000000..8bb0f36 --- /dev/null +++ b/ui/preview.py @@ -0,0 +1,153 @@ +# Font validation and preview functionality +import bpy +import os +import platform +from PIL import Image, ImageDraw, ImageFont + +def test_font_mixed_case_support(font_path): + """ + Test if a font properly supports mixed case rendering. + Returns True if the font supports mixed case, False if it only renders uppercase. + """ + try: + # Test string with mixed case + test_text = "AaBbCc" + + # Create a test image to render the font + test_size = (200, 100) + test_image = Image.new('RGBA', test_size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(test_image) + + # Try to load the font + try: + font = ImageFont.truetype(font_path, 24) + except (OSError, IOError): + print(f"Text Texture Generator: Could not load font for testing: {font_path}") + return False + + # Render the test text + draw.text((10, 10), test_text, font=font, fill=(255, 255, 255, 255)) + + # Get the rendered pixels + pixels = list(test_image.getdata()) + + # Create another image with uppercase version + test_image_upper = Image.new('RGBA', test_size, (0, 0, 0, 0)) + draw_upper = ImageDraw.Draw(test_image_upper) + draw_upper.text((10, 10), test_text.upper(), font=font, fill=(255, 255, 255, 255)) + + pixels_upper = list(test_image_upper.getdata()) + + # Compare the two renderings - if they're identical, the font only supports uppercase + if pixels == pixels_upper: + print(f"Text Texture Generator: Font only supports uppercase rendering: {os.path.basename(font_path)}") + return False + else: + print(f"Text Texture Generator: Font supports mixed case rendering: {os.path.basename(font_path)}") + return True + + except Exception as e: + print(f"Text Texture Generator: Error testing font mixed case support: {str(e)}") + return False + +def get_validated_font(font_path, fallback_fonts=None): + """ + Get a validated font that supports mixed case rendering. + Returns the original font path if valid, otherwise returns a fallback font. + """ + if not font_path or not os.path.exists(font_path): + print(f"Text Texture Generator: Font file not found: {font_path}") + return get_system_fallback_fonts()[0] if get_system_fallback_fonts() else None + + # Test if the font supports mixed case + if test_font_mixed_case_support(font_path): + return font_path + else: + print(f"Text Texture Generator: Font does not support mixed case, using fallback: {font_path}") + + # Try fallback fonts if provided + if fallback_fonts: + for fallback_font in fallback_fonts: + if os.path.exists(fallback_font) and test_font_mixed_case_support(fallback_font): + print(f"Text Texture Generator: Using fallback font: {fallback_font}") + return fallback_font + + # Use system fallback fonts + system_fallbacks = get_system_fallback_fonts() + for fallback_font in system_fallbacks: + if test_font_mixed_case_support(fallback_font): + print(f"Text Texture Generator: Using system fallback font: {fallback_font}") + return fallback_font + + # If no valid font found, return the original (better than nothing) + print("Text Texture Generator: Warning - No valid mixed case font found, using original") + return font_path + +def get_system_fallback_fonts(): + """ + Get a list of system fallback fonts based on the operating system. + Returns a list of font paths that are likely to be available. + """ + system = platform.system() + fallback_fonts = [] + + if system == "Windows": + windows_fonts = [ + "C:/Windows/Fonts/arial.ttf", + "C:/Windows/Fonts/calibri.ttf", + "C:/Windows/Fonts/verdana.ttf", + "C:/Windows/Fonts/tahoma.ttf", + "C:/Windows/Fonts/segoeui.ttf", + "C:/Windows/Fonts/times.ttf" + ] + fallback_fonts.extend([f for f in windows_fonts if os.path.exists(f)]) + + elif system == "Darwin": # macOS + macos_fonts = [ + "/System/Library/Fonts/Helvetica.ttc", + "/System/Library/Fonts/Arial.ttf", + "/System/Library/Fonts/Times.ttc", + "/System/Library/Fonts/Verdana.ttf", + "/Library/Fonts/Arial.ttf", + "/System/Library/Fonts/Courier.dfont" + ] + fallback_fonts.extend([f for f in macos_fonts if os.path.exists(f)]) + + elif system == "Linux": + linux_fonts = [ + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", + "/usr/share/fonts/truetype/ubuntu/Ubuntu-R.ttf", + "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", + "/usr/share/fonts/TTF/arial.ttf", + "/usr/share/fonts/truetype/ttf-bitstream-vera/Vera.ttf" + ] + fallback_fonts.extend([f for f in linux_fonts if os.path.exists(f)]) + + # Also check common font directories + common_directories = [ + "/usr/share/fonts", + "/usr/local/share/fonts", + os.path.expanduser("~/.fonts"), + os.path.expanduser("~/Library/Fonts") + ] + + for font_dir in common_directories: + if os.path.exists(font_dir): + for root, dirs, files in os.walk(font_dir): + for file in files: + if file.lower().endswith(('.ttf', '.otf')): + font_path = os.path.join(root, file) + if font_path not in fallback_fonts: + fallback_fonts.append(font_path) + # Limit the number of fallback fonts to prevent excessive scanning + if len(fallback_fonts) >= 20: + break + if len(fallback_fonts) >= 20: + break + + print(f"Text Texture Generator: Found {len(fallback_fonts)} system fallback fonts") + for i, font in enumerate(fallback_fonts[:5]): # Log first 5 fonts + print(f"Text Texture Generator: Fallback font {i+1}: {font}") + + return fallback_fonts \ No newline at end of file diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..07aac9c --- /dev/null +++ b/utils/__init__.py @@ -0,0 +1,13 @@ +""" +Utility modules for the Text Texture Generator addon. +""" + +from .constants import bl_info +from .system import install_pillow, get_system_fonts, get_font_enum_items + +__all__ = [ + 'bl_info', + 'install_pillow', + 'get_system_fonts', + 'get_font_enum_items' +] \ No newline at end of file diff --git a/utils/constants.py b/utils/constants.py new file mode 100644 index 0000000..28900d7 --- /dev/null +++ b/utils/constants.py @@ -0,0 +1,13 @@ +""" +Constants and metadata for the Text Texture Generator addon. +""" + +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", +} \ No newline at end of file diff --git a/utils/system.py b/utils/system.py new file mode 100644 index 0000000..c7bebf3 --- /dev/null +++ b/utils/system.py @@ -0,0 +1,84 @@ +""" +System utility functions for the Text Texture Generator addon. +""" + +import subprocess +import sys +import platform +import os + +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 + +def get_anchor_point_matrix(): + """Return a 3x3 matrix of anchor point identifiers for UI positioning grid""" + return [ + ['TOP_LEFT', 'TOP_CENTER', 'TOP_RIGHT'], + ['MIDDLE_LEFT', 'MIDDLE_CENTER', 'MIDDLE_RIGHT'], + ['BOTTOM_LEFT', 'BOTTOM_CENTER', 'BOTTOM_RIGHT'] + ] \ No newline at end of file