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