Files
Text-Texture-Generator-for-…/__init__.py
2025-08-12 23:02:52 +07:00

3902 lines
169 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import bpy
import bmesh
from bpy.types import Operator, Panel, PropertyGroup, UIList
from bpy.props import StringProperty, IntProperty, FloatVectorProperty, FloatProperty, BoolProperty, EnumProperty, CollectionProperty, PointerProperty
import os
import tempfile
import subprocess
import sys
import json
import platform
import time
# Addon info
bl_info = {
"name": "Text Texture Generator",
"author": "Marc Mintel <marc@mintel.me>",
"version": (2, 3, 2),
"blender": (4, 0, 0),
"location": "3D View > Sidebar (N) > Tool Tab | Shader Editor > Sidebar > Tool Tab | Properties > Material > Tool",
"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
def get_preset_path():
"""Get path for storing presets (legacy local storage)"""
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_blend_file_presets():
"""Get presets stored in the current blend file"""
try:
# Get custom properties from the scene
import bpy
scene = bpy.context.scene
# DETAILED LOGGING: Function entry
print(f"[GET_BLEND_FILE_PRESETS DEBUG] ==================== LOADING FROM BLEND FILE ==================")
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Function called to get presets from blend file")
# Store presets in scene custom properties
if "text_texture_presets" not in scene:
print(f"[GET_BLEND_FILE_PRESETS DEBUG] No 'text_texture_presets' found in scene, creating empty")
scene["text_texture_presets"] = "{}"
else:
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Found 'text_texture_presets' in scene")
preset_data = scene.get("text_texture_presets", "{}")
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Raw preset data type: {type(preset_data)}")
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Raw preset data length: {len(str(preset_data))} characters")
if isinstance(preset_data, str):
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Preset data is string, attempting JSON parse...")
try:
import json
parsed_presets = json.loads(preset_data)
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Successfully parsed JSON")
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Parsed presets type: {type(parsed_presets)}")
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Parsed presets count: {len(parsed_presets) if isinstance(parsed_presets, dict) else 'N/A'}")
if isinstance(parsed_presets, dict):
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Preset names: {list(parsed_presets.keys())}")
# DETAILED VERIFICATION: Check shader properties in each preset
for preset_name, preset_content in parsed_presets.items():
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Preset '{preset_name}' analysis:")
if isinstance(preset_content, dict):
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Keys: {list(preset_content.keys())}")
print(f"[GET_BLEND_FILE_PRESETS DEBUG] disable_shadows: {preset_content.get('disable_shadows', 'NOT FOUND')}")
print(f"[GET_BLEND_FILE_PRESETS DEBUG] disable_reflections: {preset_content.get('disable_reflections', 'NOT FOUND')}")
print(f"[GET_BLEND_FILE_PRESETS DEBUG] disable_backfacing: {preset_content.get('disable_backfacing', 'NOT FOUND')}")
else:
print(f"[GET_BLEND_FILE_PRESETS DEBUG] ERROR: Preset content is not a dict: {type(preset_content)}")
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Returning parsed presets")
print(f"[GET_BLEND_FILE_PRESETS DEBUG] ================================================================")
return parsed_presets
except json.JSONDecodeError as json_err:
print(f"[GET_BLEND_FILE_PRESETS DEBUG] JSON decode error: {json_err}")
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Raw data causing error: {preset_data[:200]}...") # First 200 chars
print("Error parsing blend file presets, resetting to empty")
scene["text_texture_presets"] = "{}"
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Reset to empty, returning empty dict")
return {}
else:
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Preset data is not string, returning empty dict")
print(f"[GET_BLEND_FILE_PRESETS DEBUG] ================================================================")
return {}
except Exception as e:
print(f"[GET_BLEND_FILE_PRESETS DEBUG] EXCEPTION occurred: {e}")
import traceback
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Full traceback: {traceback.format_exc()}")
print(f"Error getting blend file presets: {e}")
return {}
def save_blend_file_preset(preset_name, preset_data):
"""Save a preset to the current blend file"""
try:
import bpy
import json
scene = bpy.context.scene
# DETAILED LOGGING: Function entry
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] ==================== SAVING TO BLEND FILE ===================")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Function called with preset_name: '{preset_name}'")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Preset data type: {type(preset_data)}")
if isinstance(preset_data, dict):
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Preset data keys: {list(preset_data.keys())}")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Shader properties in preset_data:")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_shadows: {preset_data.get('disable_shadows', 'NOT FOUND')}")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_reflections: {preset_data.get('disable_reflections', 'NOT FOUND')}")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_backfacing: {preset_data.get('disable_backfacing', 'NOT FOUND')}")
else:
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] ERROR: preset_data is not a dictionary!")
# Get existing presets
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Getting existing presets from blend file...")
presets = get_blend_file_presets()
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Existing presets count: {len(presets)}")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Existing preset names: {list(presets.keys())}")
# Add or update the preset
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Adding/updating preset '{preset_name}'...")
presets[preset_name] = preset_data
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Presets now contains {len(presets)} items")
# VERIFY: Check that shader properties are in the preset we're about to save
if preset_name in presets and isinstance(presets[preset_name], dict):
saved_preset = presets[preset_name]
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] VERIFICATION - Shader properties in final preset:")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_shadows: {saved_preset.get('disable_shadows', 'NOT FOUND')}")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_reflections: {saved_preset.get('disable_reflections', 'NOT FOUND')}")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_backfacing: {saved_preset.get('disable_backfacing', 'NOT FOUND')}")
# Save back to scene custom properties
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Converting presets to JSON...")
json_data = json.dumps(presets, indent=2)
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] JSON data length: {len(json_data)} characters")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Saving to scene custom properties...")
scene["text_texture_presets"] = json_data
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Successfully saved to scene['text_texture_presets']")
# FINAL VERIFICATION: Read back from scene to confirm save
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] FINAL VERIFICATION - Reading back from scene...")
readback_data = scene.get("text_texture_presets", "{}")
if readback_data:
try:
readback_presets = json.loads(readback_data)
if preset_name in readback_presets:
readback_preset = readback_presets[preset_name]
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] READBACK VERIFICATION - Shader properties:")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_shadows: {readback_preset.get('disable_shadows', 'NOT FOUND')}")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_reflections: {readback_preset.get('disable_reflections', 'NOT FOUND')}")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_backfacing: {readback_preset.get('disable_backfacing', 'NOT FOUND')}")
else:
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] ERROR: Preset '{preset_name}' not found in readback data!")
except json.JSONDecodeError as json_err:
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] ERROR: Could not parse readback JSON: {json_err}")
else:
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] ERROR: No data in scene['text_texture_presets'] after save!")
print(f"Saved preset '{preset_name}' to blend file")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] ================================================================")
return True
except Exception as e:
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] EXCEPTION occurred: {e}")
import traceback
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Full traceback: {traceback.format_exc()}")
print(f"Error saving preset to blend file: {e}")
return False
def delete_blend_file_preset(preset_name):
"""Delete a preset from the current blend file"""
try:
import bpy
import json
scene = bpy.context.scene
# Get existing presets
presets = get_blend_file_presets()
# Remove the preset if it exists
if preset_name in presets:
del presets[preset_name]
scene["text_texture_presets"] = json.dumps(presets, indent=2)
print(f"Deleted preset '{preset_name}' from blend file")
return True
else:
print(f"Preset '{preset_name}' not found in blend file")
return False
except Exception as e:
print(f"Error deleting preset from blend file: {e}")
return False
def initialize_presets():
"""Initialize presets during addon registration - prevents race conditions"""
try:
import bpy
if not hasattr(bpy.context, 'scene') or not bpy.context.scene:
return
props = bpy.context.scene.text_texture_props
if not props:
return
# Get existing preset names for optimization
existing_presets = {p.name: p for p in props.presets}
# Load presets from blend file first (primary source)
blend_presets = get_blend_file_presets()
updated_presets = set()
for preset_name in blend_presets.keys():
updated_presets.add(preset_name)
if preset_name in existing_presets:
# Update existing preset source if needed
if existing_presets[preset_name].source != 'BLEND_FILE':
existing_presets[preset_name].source = 'BLEND_FILE'
else:
# Add new preset
preset = props.presets.add()
preset.name = preset_name
preset.source = 'BLEND_FILE'
# Load legacy local presets as fallback
preset_dir = get_preset_path()
if os.path.exists(preset_dir):
try:
for filename in os.listdir(preset_dir):
if filename.endswith('.json'):
preset_name = os.path.splitext(filename)[0]
# Only add if not already present from blend file
if preset_name not in blend_presets:
updated_presets.add(preset_name)
if preset_name in existing_presets:
# Update existing preset source if needed
if existing_presets[preset_name].source != 'LOCAL_FILE':
existing_presets[preset_name].source = 'LOCAL_FILE'
else:
# Add new preset
preset = props.presets.add()
preset.name = preset_name
preset.source = 'LOCAL_FILE'
except OSError as e:
print(f"Error loading local presets during initialization: {e}")
# Remove presets that no longer exist
for i in range(len(props.presets) - 1, -1, -1):
if props.presets[i].name not in updated_presets:
props.presets.remove(i)
print(f"Initialized {len(props.presets)} presets ({len(blend_presets)} from blend file)")
except Exception as e:
print(f"Error in initialize_presets: {e}")
# ============================================================================
# PREVIEW GENERATION FUNCTIONS
# ============================================================================
def test_font_mixed_case_support(font, test_size=20):
"""Test if a font supports mixed-case rendering properly"""
try:
from PIL import Image, ImageDraw
# Test with mixed-case sample text
test_text = "AaBbCc123"
test_img = Image.new('RGB', (100, 50), (255, 255, 255))
test_draw = ImageDraw.Draw(test_img)
# DIAGNOSTIC: Add detailed logging for custom font debugging
font_info = "UNKNOWN"
if hasattr(font, 'path'):
font_info = font.path
elif hasattr(font, 'getname'):
try:
font_info = font.getname()
except:
pass
print(f"CUSTOM FONT DEBUG: Testing mixed-case support for font: {font_info}")
# Try to get bounding box - this will fail if font doesn't support the characters
try:
bbox = test_draw.textbbox((0, 0), test_text, font=font)
print(f"CUSTOM FONT DEBUG: Mixed-case text bbox successful: {bbox}")
except Exception as bbox_error:
print(f"CUSTOM FONT DEBUG: Mixed-case text bbox FAILED: {bbox_error}")
import traceback
print(f"CUSTOM FONT DEBUG: Full exception trace: {traceback.format_exc()}")
return False
# Test actual rendering - draw the text and check if it renders properly
try:
test_draw.text((5, 5), test_text, fill=(0, 0, 0), font=font)
print(f"CUSTOM FONT DEBUG: Mixed-case text rendering successful")
except Exception as render_error:
print(f"CUSTOM FONT DEBUG: Mixed-case text rendering FAILED: {render_error}")
import traceback
print(f"CUSTOM FONT DEBUG: Full exception trace: {traceback.format_exc()}")
return False
# Additional validation: check if lowercase letters render differently from uppercase
try:
upper_bbox = test_draw.textbbox((0, 0), "ABC", font=font)
lower_bbox = test_draw.textbbox((0, 0), "abc", font=font)
print(f"CUSTOM FONT DEBUG: Upper bbox: {upper_bbox}, Lower bbox: {lower_bbox}")
# RELAXED VALIDATION: Allow fonts with identical bounding boxes
# Many valid custom fonts may have identical bounding boxes but still render mixed case properly
if upper_bbox == lower_bbox:
print(f"CUSTOM FONT DEBUG: RELAXED VALIDATION - Identical bounding boxes detected")
print(f"CUSTOM FONT DEBUG: Instead of rejecting, testing actual mixed-case rendering...")
# Additional test: try rendering actual mixed case text to verify it works
try:
mixed_test = "AaBbCcDd"
mixed_bbox = test_draw.textbbox((0, 0), mixed_test, font=font)
test_draw.text((10, 10), mixed_test, fill=(0, 0, 0), font=font)
print(f"CUSTOM FONT DEBUG: Mixed-case render test '{mixed_test}' PASSED: {mixed_bbox}")
print(f"CUSTOM FONT DEBUG: Font ACCEPTED despite identical bounding boxes (relaxed validation)")
return True
except Exception as mixed_render_error:
print(f"CUSTOM FONT DEBUG: Mixed-case render test FAILED: {mixed_render_error}")
print(f"CUSTOM FONT DEBUG: Font REJECTED due to actual rendering failure")
import traceback
print(f"CUSTOM FONT DEBUG: Full exception trace: {traceback.format_exc()}")
return False
print(f"CUSTOM FONT DEBUG: Font mixed-case test PASSED - supports both cases properly")
return True
except Exception as bbox_compare_error:
print(f"CUSTOM FONT DEBUG: Bounding box comparison FAILED: {bbox_compare_error}")
return False
except Exception as e:
print(f"CUSTOM FONT DEBUG: Font mixed-case test FAILED with exception: {e}")
import traceback
traceback.print_exc()
return False
def get_validated_font(font_path, font_size):
"""Load and validate a font for mixed-case support"""
try:
from PIL import ImageFont
# DIAGNOSTIC: Enhanced logging for custom font debugging
print(f"CUSTOM FONT DEBUG: get_validated_font() called with:")
print(f"CUSTOM FONT DEBUG: font_path: '{font_path}'")
print(f"CUSTOM FONT DEBUG: font_size: {font_size}")
print(f"CUSTOM FONT DEBUG: path exists: {os.path.exists(font_path)}")
if os.path.exists(font_path):
# Get detailed file information for diagnostics
try:
import stat
file_stats = os.stat(font_path)
print(f"CUSTOM FONT DEBUG: file size: {file_stats.st_size} bytes")
print(f"CUSTOM FONT DEBUG: file permissions: {stat.filemode(file_stats.st_mode)}")
print(f"CUSTOM FONT DEBUG: file extension: {os.path.splitext(font_path)[1]}")
except Exception as stats_error:
print(f"CUSTOM FONT DEBUG: could not get detailed file stats: {stats_error}")
if not os.path.exists(font_path):
error_msg = f"Font file not found: {font_path}"
print(f"CUSTOM FONT DEBUG: {error_msg}")
return None, error_msg
# Get file info for debugging
try:
file_stats = os.stat(font_path)
print(f"CUSTOM FONT DEBUG: file size: {file_stats.st_size} bytes")
print(f"CUSTOM FONT DEBUG: file extension: {os.path.splitext(font_path)[1]}")
except Exception as stat_error:
print(f"CUSTOM FONT DEBUG: could not get file stats: {stat_error}")
# Load the font
print(f"CUSTOM FONT DEBUG: Attempting to load font with ImageFont.truetype()...")
try:
font = ImageFont.truetype(font_path, font_size)
print(f"CUSTOM FONT DEBUG: Font loaded successfully: {font}")
except Exception as load_error:
error_msg = f"Font loading error - ImageFont.truetype() failed: {load_error}"
print(f"CUSTOM FONT DEBUG: {error_msg}")
import traceback
print(f"CUSTOM FONT DEBUG: Full exception trace: {traceback.format_exc()}")
return None, error_msg
# Test mixed-case support
print(f"CUSTOM FONT DEBUG: Testing mixed-case support...")
if test_font_mixed_case_support(font):
print(f"CUSTOM FONT DEBUG: Mixed-case validation PASSED")
return font, "OK"
else:
error_msg = f"Font does not support mixed-case properly: {font_path}"
print(f"CUSTOM FONT DEBUG: Mixed-case validation FAILED: {error_msg}")
return None, error_msg
except Exception as e:
error_msg = f"Font loading error (outer exception): {e}"
print(f"CUSTOM FONT DEBUG: {error_msg}")
import traceback
traceback.print_exc()
return None, error_msg
def get_system_fallback_fonts():
"""Get platform-specific fallback fonts known to support mixed-case"""
system = platform.system()
if system == "Windows":
# Windows fonts with guaranteed mixed-case support
return [
"C:/Windows/Fonts/arial.ttf", # Arial - excellent mixed-case
"C:/Windows/Fonts/calibri.ttf", # Calibri - modern, mixed-case
"C:/Windows/Fonts/segoeui.ttf", # Segoe UI - system font, mixed-case
"C:/Windows/Fonts/verdana.ttf", # Verdana - web-safe, mixed-case
"C:/Windows/Fonts/tahoma.ttf", # Tahoma - fallback mixed-case
"C:/Windows/Fonts/times.ttf", # Times - serif mixed-case
"C:/Windows/Fonts/trebuc.ttf" # Trebuchet MS - mixed-case
]
elif system == "Darwin": # macOS
# macOS fonts with guaranteed mixed-case support
return [
"/System/Library/Fonts/Helvetica.ttc", # Helvetica - classic mixed-case
"/Library/Fonts/Arial.ttf", # Arial - if available
"/System/Library/Fonts/Times.ttc", # Times - serif mixed-case
"/System/Library/Fonts/Avenir.ttc", # Avenir - modern mixed-case
"/System/Library/Fonts/Geneva.ttf", # Geneva - system mixed-case
"/System/Library/Fonts/Palatino.ttc" # Palatino - elegant mixed-case
]
else: # Linux
# Linux fonts with guaranteed mixed-case support
return [
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", # LibreOffice mixed-case
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", # DejaVu - comprehensive
"/usr/share/fonts/truetype/ubuntu/Ubuntu-R.ttf", # Ubuntu - modern mixed-case
"/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", # Google Noto - universal
"/usr/share/fonts/truetype/lato/Lato-Regular.ttf", # Lato - web font mixed-case
"/usr/share/fonts/truetype/opensans/OpenSans-Regular.ttf" # Open Sans - mixed-case
]
# ============================================================================
# NORMAL MAP GENERATION FUNCTIONS
# ============================================================================
def generate_normal_map_from_alpha(img, strength=1.0, blur_radius=1.0, invert=False):
"""Generate a normal map from an image's alpha channel using height-based algorithm"""
try:
from PIL import Image, ImageFilter
import numpy as np
print(f"[Normal Map] Starting generation with strength={strength}, blur={blur_radius}, invert={invert}")
# Extract alpha channel as height map
if img.mode != 'RGBA':
img = img.convert('RGBA')
# Get alpha channel
alpha_channel = img.split()[3] # Alpha is the 4th channel
width, height = alpha_channel.size
print(f"[Normal Map] Processing {width}x{height} alpha channel")
# Apply blur if specified
if blur_radius > 0:
alpha_channel = alpha_channel.filter(ImageFilter.GaussianBlur(radius=blur_radius))
print(f"[Normal Map] Applied blur with radius {blur_radius}")
# Convert to numpy array for gradient calculations
height_map = np.array(alpha_channel, dtype=np.float32) / 255.0
# Apply strength multiplier
height_map *= strength
# Calculate gradients using Sobel operators
# Sobel X kernel for horizontal gradients
sobel_x = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], dtype=np.float32)
# Sobel Y kernel for vertical gradients
sobel_y = np.array([[-1, -2, -1], [0, 0, 0], [1, 2, 1]], dtype=np.float32)
# Pad the height map to handle edges
padded_height = np.pad(height_map, ((1, 1), (1, 1)), mode='edge')
# Calculate gradients
grad_x = np.zeros_like(height_map)
grad_y = np.zeros_like(height_map)
for i in range(height):
for j in range(width):
# Extract 3x3 neighborhood
neighborhood = padded_height[i:i+3, j:j+3]
# Apply Sobel operators
grad_x[i, j] = np.sum(neighborhood * sobel_x)
grad_y[i, j] = np.sum(neighborhood * sobel_y)
print(f"[Normal Map] Calculated gradients")
# Convert gradients to normal vectors
# Normal map RGB values are calculated as:
# R = (grad_x + 1) * 0.5 -> maps -1,1 to 0,1
# G = (-grad_y + 1) * 0.5 -> maps -1,1 to 0,1 (Y is flipped for standard normal maps)
# B = sqrt(1 - grad_x^2 - grad_y^2) -> Z component, always pointing up
# Clamp gradients to reasonable range
grad_x = np.clip(grad_x, -1, 1)
grad_y = np.clip(grad_y, -1, 1)
# Apply invert if specified
if invert:
grad_x = -grad_x
grad_y = -grad_y
print(f"[Normal Map] Applied inversion")
# Calculate normal map channels
# Red channel: X gradient mapped to 0-1
normal_r = ((grad_x + 1.0) * 0.5 * 255).astype(np.uint8)
# Green channel: Y gradient mapped to 0-1 (flipped)
normal_g = ((-grad_y + 1.0) * 0.5 * 255).astype(np.uint8)
# Blue channel: Z component (pointing up)
# Calculate Z from X and Y to maintain unit length
grad_magnitude_sq = grad_x**2 + grad_y**2
grad_z = np.sqrt(np.maximum(0, 1.0 - grad_magnitude_sq))
normal_b = (grad_z * 255).astype(np.uint8)
print(f"[Normal Map] Calculated normal vectors")
# Create the normal map image
normal_map = Image.new('RGB', (width, height))
# Combine channels
for y in range(height):
for x in range(width):
r = int(normal_r[y, x])
g = int(normal_g[y, x])
b = int(normal_b[y, x])
normal_map.putpixel((x, y), (r, g, b))
print(f"[Normal Map] Normal map generation completed successfully")
return normal_map
except Exception as e:
print(f"[Normal Map] Error generating normal map: {e}")
import traceback
traceback.print_exc()
return None
# ============================================================================
# ENHANCED POSITIONING FUNCTIONS
# ============================================================================
def calculate_text_position(props, canvas_width, canvas_height, text_width, text_height):
"""Calculate text position based on enhanced positioning properties"""
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
margin_x_px = int((props.margin_x / 100.0) * canvas_width)
margin_y_px = int((props.margin_y / 100.0) * canvas_height)
# Calculate base position based on anchor point
if 'LEFT' in props.anchor_point:
base_x = margin_x_px
elif 'RIGHT' in props.anchor_point:
base_x = canvas_width - text_width - margin_x_px
else: # CENTER
base_x = (canvas_width - text_width) // 2
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
# Apply fine-tuning offsets
x = base_x + props.offset_x
y = base_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 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 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
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}")
# Scale font size proportionally for preview
scale_factor = width / props.texture_width
font_size_pixels = max(6, int(props.font_size * scale_factor))
padding = max(0, int(props.padding * scale_factor))
print(f"DEBUG: Scale factor: {scale_factor}, scaled font size: {font_size_pixels}, 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 = []
# Add image overlays
for overlay in props.image_overlays:
if overlay.enabled and overlay.image_path and os.path.exists(overlay.image_path):
try:
overlay_img = Image.open(overlay.image_path).convert('RGBA')
# Scale overlay
if overlay.scale != 1.0:
new_size = (int(overlay_img.width * overlay.scale * scale_factor),
int(overlay_img.height * overlay.scale * scale_factor))
overlay_img = overlay_img.resize(new_size, Image.LANCZOS)
# Rotate overlay if needed
if overlay.rotation != 0:
overlay_img = overlay_img.rotate(overlay.rotation, expand=True)
# Calculate position
x_pos = int(overlay.x_position * width - overlay_img.width / 2)
y_pos = int((1.0 - overlay.y_position) * height - overlay_img.height / 2) # Flip Y for intuitive top-to-bottom
elements.append((overlay.z_index, 'image', overlay_img, x_pos, y_pos))
print(f"Added overlay {overlay.name} at z-index {overlay.z_index}, pos ({x_pos}, {y_pos})")
except Exception as e:
print(f"Error loading overlay {overlay.name}: {e}")
# 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
font = None
font_loaded = False
print("DEBUG UPPERCASE FIX: Starting enhanced font loading with mixed-case validation")
# Try custom font first with validation
if props.use_custom_font and props.custom_font_path:
print(f"DEBUG UPPERCASE FIX: Testing custom font: {props.custom_font_path}")
font, error_msg = get_validated_font(props.custom_font_path, font_size_pixels)
if font:
font_loaded = True
print(f"SUCCESS: Loaded and validated custom font: {props.custom_font_path}")
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":
print(f"DEBUG UPPERCASE FIX: Testing system font: {props.font_path}")
font, error_msg = get_validated_font(props.font_path, font_size_pixels)
if font:
font_loaded = True
print(f"SUCCESS: Loaded and validated system font: {props.font_path}")
else:
print(f"FAILED: System font validation failed: {error_msg}")
# Enhanced fallback with mixed-case validation
if not font_loaded:
print("DEBUG UPPERCASE FIX: Starting validated fallback font search...")
fallback_fonts = get_system_fallback_fonts()
for fallback_path in fallback_fonts:
print(f"DEBUG UPPERCASE FIX: Testing fallback font: {fallback_path}")
if os.path.exists(fallback_path):
font, error_msg = get_validated_font(fallback_path, font_size_pixels)
if font:
font_loaded = True
print(f"SUCCESS: Using validated fallback font: {fallback_path}")
break
else:
print(f"FAILED: Fallback font validation failed: {error_msg}")
else:
print(f"SKIP: Fallback font not found: {fallback_path}")
# 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")
# ============================================================================
# PREPEND/APPEND TEXT PROCESSING
# ============================================================================
# Get the main text
main_text = props.text if props.text and props.text.strip() else "Hello World"
# 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")
# 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
# ============================================================================
# Use enhanced positioning system
x, y = calculate_text_position(props, width, height, text_width, text_height)
# 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
# Draw text
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),)
# Handle vertical layout rendering
if props.prepend_append_layout == 'VERTICAL' and (prepend_text or append_text):
print(f"DEBUG VERTICAL LAYOUT - Rendering vertical text layout")
# 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((x, current_y), prepend_text, fill=text_color, font=font)
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((x, current_y), main_text, fill=text_color, font=font)
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((x, current_y), append_text, fill=text_color, font=font)
print(f"DEBUG VERTICAL LAYOUT - Vertical text rendering completed")
else:
# Standard horizontal rendering
print(f"DEBUG UPPERCASE ISSUE - About to draw text: '{text}' with font: {font}")
draw.text((x, y), text, fill=text_color, font=font)
print(f"Text drawn at ({x}, {y})")
print(f"DEBUG UPPERCASE ISSUE - Text drawing completed")
# Add text to elements list
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:
if element_type == 'text':
final_img = Image.alpha_composite(final_img, element_img)
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"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
)
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)'),
('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_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="Hello World",
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
)
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
)
# ============================================================================
# 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
material_name = f"TextTexture_Mat_{props.text[:15].replace(' ', '_')}"
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
# Always create a new material slot
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,
'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 local file for cross-blend-file sharing
print(f"[PRESET SAVE DEBUG] Attempting to save to local file: {preset_file}")
local_success = True
try:
with open(preset_file, 'w') as f:
json.dump(preset_data, f, indent=2)
print(f"[PRESET SAVE DEBUG] Successfully saved preset '{preset_name}' to local storage for cross-file sharing")
# VERIFY: Read back the local file to confirm shader properties were saved
print(f"[PRESET SAVE DEBUG] Verifying local file contents...")
with open(preset_file, 'r') as f:
saved_data = json.load(f)
print(f"[PRESET SAVE DEBUG] Local 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 local_error:
print(f"[PRESET SAVE DEBUG] Warning: Failed to save local preset file: {local_error}")
local_success = False
if not blend_success and not local_success:
self.report({'ERROR'}, f"Failed to save preset to both blend file and local 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
preset.source = 'BLEND_FILE' if blend_success else 'LOCAL_FILE'
else:
# Update source based on where it was successfully saved
existing_preset.source = 'BLEND_FILE' if blend_success else 'LOCAL_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 local_success:
storage_info = " (saved to blend file + local backup)"
elif blend_success:
storage_info = " (saved to blend file)"
elif local_success:
storage_info = " (saved locally only)"
if self.overwrite:
self.report({'INFO'}, f"✅ Preset '{preset_name}' updated successfully{storage_info}")
else:
self.report({'INFO'}, f"✅ Preset '{preset_name}' saved successfully{storage_info}")
except (OSError, IOError, json.JSONEncodeError) as e:
self.report({'ERROR'}, f"Failed to save preset: {str(e)}")
return {'CANCELLED'}
except Exception as e:
self.report({'ERROR'}, f"Unexpected error saving preset: {str(e)}")
return {'CANCELLED'}
return {'FINISHED'}
def invoke(self, context, event):
# Check if preset exists and prompt for overwrite
props = context.scene.text_texture_props
preset_name = props.new_preset_name.strip()
if preset_name and preset_name != "New Preset":
preset_file = os.path.join(get_preset_path(), f"{preset_name}.json")
if os.path.exists(preset_file):
return context.window_manager.invoke_confirm(self, event)
return self.execute(context)
def draw(self, context):
layout = self.layout
props = context.scene.text_texture_props
preset_name = props.new_preset_name.strip()
layout.label(text=f"Overwrite existing preset '{preset_name}'?")
class TEXT_TEXTURE_OT_save_preset_enter(Operator):
"""Save preset when Enter key is pressed in name field"""
bl_idname = "text_texture.save_preset_enter"
bl_label = "Save Preset (Enter)"
bl_description = "Save current settings as preset when Enter is pressed"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
# Delegate to the main save preset operator
return bpy.ops.text_texture.save_preset('INVOKE_DEFAULT')
class TEXT_TEXTURE_OT_load_preset(Operator):
"""Load preset"""
bl_idname = "text_texture.load_preset"
bl_label = "Load Preset"
bl_description = "Load selected preset"
bl_options = {'REGISTER', 'UNDO'}
preset_name: StringProperty()
def execute(self, context):
props = context.scene.text_texture_props
# DETAILED LOGGING: Current shader properties before loading
print(f"[PRESET LOAD DEBUG] ==================== LOADING PRESET: '{self.preset_name}' ====================")
print(f"[PRESET LOAD DEBUG] Current shader properties BEFORE loading:")
print(f"[PRESET LOAD DEBUG] disable_shadows: {props.disable_shadows}")
print(f"[PRESET LOAD DEBUG] disable_reflections: {props.disable_reflections}")
print(f"[PRESET LOAD DEBUG] disable_backfacing: {props.disable_backfacing}")
print(f"[PRESET LOAD DEBUG] shader_type: {props.shader_type}")
print(f"[PRESET LOAD DEBUG] shader_connection: {props.shader_connection}")
try:
# First try to load from blend 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]
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:
# Fallback to local file
preset_file = os.path.join(get_preset_path(), f"{self.preset_name}.json")
print(f"[PRESET LOAD DEBUG] Preset not in blend file, trying local file: {preset_file}")
if os.path.exists(preset_file):
with open(preset_file, 'r') as f:
preset_data = json.load(f)
source = 'LOCAL_FILE'
print(f"[PRESET LOAD DEBUG] Loading preset '{self.preset_name}' from local file")
print(f"[PRESET LOAD DEBUG] Local 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] Local file does not exist: {preset_file}")
self.report({'ERROR'}, f"Preset not found in blend file or local storage: {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 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 = "from blend file" if source == 'BLEND_FILE' else "from local storage"
self.report({'INFO'}, f"✅ Loaded preset '{self.preset_name}' {source_info}")
except (OSError, IOError) as e:
props.is_updating = False
self.report({'ERROR'}, f"Failed to read preset file: {str(e)}")
return {'CANCELLED'}
except json.JSONDecodeError as e:
props.is_updating = False
self.report({'ERROR'}, f"Invalid preset file format: {str(e)}")
return {'CANCELLED'}
except Exception as e:
props.is_updating = False
self.report({'ERROR'}, f"Unexpected error loading preset: {str(e)}")
return {'CANCELLED'}
return {'FINISHED'}
class TEXT_TEXTURE_OT_delete_preset(Operator):
"""Delete preset"""
bl_idname = "text_texture.delete_preset"
bl_label = "Delete Preset"
bl_description = "Delete selected preset"
bl_options = {'REGISTER', 'UNDO'}
preset_name: StringProperty()
def execute(self, context):
props = context.scene.text_texture_props
try:
# Delete from both blend file and local storage
blend_deleted = delete_blend_file_preset(self.preset_name)
preset_file = os.path.join(get_preset_path(), f"{self.preset_name}.json")
local_deleted = False
if os.path.exists(preset_file):
os.remove(preset_file)
local_deleted = True
if not blend_deleted and not local_deleted:
self.report({'WARNING'}, f"Preset '{self.preset_name}' not found in blend file or local storage")
# Remove from UI list
for i, preset in enumerate(props.presets):
if preset.name == self.preset_name:
props.presets.remove(i)
break
delete_info = []
if blend_deleted:
delete_info.append("blend file")
if local_deleted:
delete_info.append("local storage")
location_text = " and ".join(delete_info) if delete_info else "nowhere (not found)"
self.report({'INFO'}, f"🗑️ Deleted preset '{self.preset_name}' from {location_text}")
except (OSError, IOError) as e:
self.report({'ERROR'}, f"Failed to delete preset file: {str(e)}")
return {'CANCELLED'}
except Exception as e:
self.report({'ERROR'}, f"Unexpected error deleting preset: {str(e)}")
return {'CANCELLED'}
return {'FINISHED'}
def invoke(self, context, event):
# Add confirmation dialog for better UX
return context.window_manager.invoke_confirm(self, event)
def draw(self, context):
layout = self.layout
layout.label(text=f"Delete preset '{self.preset_name}'?")
layout.label(text="This action cannot be undone.", icon='ERROR')
class TEXT_TEXTURE_OT_open_panel(Operator):
"""Open Text Texture Generator panel"""
bl_idname = "text_texture.open_panel"
bl_label = "Open Text Texture Panel"
bl_description = "Open the Text Texture Generator panel in the sidebar"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
# Ensure the sidebar is visible
for area in context.screen.areas:
if area.type == 'VIEW_3D':
for space in area.spaces:
if space.type == 'VIEW_3D':
space.show_region_ui = True
# Switch to Tool tab
area.tag_redraw()
self.report({'INFO'}, "Text Texture panel opened in 3D View sidebar (press N if not visible)")
return {'FINISHED'}
elif area.type == 'NODE_EDITOR':
for space in area.spaces:
if space.type == 'NODE_EDITOR':
space.show_region_ui = True
area.tag_redraw()
self.report({'INFO'}, "Text Texture panel opened in Shader Editor sidebar")
return {'FINISHED'}
self.report({'WARNING'}, "Please open a 3D View or Shader Editor first")
return {'CANCELLED'}
class TEXT_TEXTURE_OT_refresh_fonts(Operator):
"""Refresh fonts"""
bl_idname = "text_texture.refresh_fonts"
bl_label = "Refresh Fonts"
bl_description = "Refresh font list"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
if hasattr(get_font_enum_items, 'cached_fonts'):
del get_font_enum_items.cached_fonts
get_font_enum_items.cached_fonts = get_system_fonts()
self.report({'INFO'}, f"Found {len(get_font_enum_items.cached_fonts)} fonts")
return {'FINISHED'}
class TEXT_TEXTURE_OT_refresh_presets(Operator):
"""Refresh presets from blend file and local storage"""
bl_idname = "text_texture.refresh_presets"
bl_label = "Refresh Presets"
bl_description = "Reload presets from blend file and local storage"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
try:
# Reload presets
initialize_presets()
props = context.scene.text_texture_props
blend_presets = get_blend_file_presets()
total_presets = len(props.presets)
blend_count = len(blend_presets)
local_count = total_presets - blend_count
self.report({'INFO'}, f"✅ Refreshed {total_presets} presets ({blend_count} from blend file, {local_count} local)")
# Force UI redraw
for area in context.screen.areas:
area.tag_redraw()
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, f"Failed to refresh presets: {str(e)}")
return {'CANCELLED'}
class TEXT_TEXTURE_OT_add_overlay(Operator):
"""Add new image overlay"""
bl_idname = "text_texture.add_overlay"
bl_label = "Add Overlay"
bl_description = "Add new image overlay"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
props = context.scene.text_texture_props
overlay = props.image_overlays.add()
overlay.name = f"Overlay {len(props.image_overlays)}"
props.active_overlay_index = len(props.image_overlays) - 1
# Force UI redraw
for area in context.screen.areas:
area.tag_redraw()
self.report({'INFO'}, f"Added overlay: {overlay.name}")
return {'FINISHED'}
class TEXT_TEXTURE_OT_remove_overlay(Operator):
"""Remove active image overlay"""
bl_idname = "text_texture.remove_overlay"
bl_label = "Remove Overlay"
bl_description = "Remove active image overlay"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
props = context.scene.text_texture_props
if props.image_overlays and props.active_overlay_index < len(props.image_overlays):
overlay_name = props.image_overlays[props.active_overlay_index].name
props.image_overlays.remove(props.active_overlay_index)
# Update active index
if props.active_overlay_index >= len(props.image_overlays) and props.image_overlays:
props.active_overlay_index = len(props.image_overlays) - 1
# Force UI redraw
for area in context.screen.areas:
area.tag_redraw()
self.report({'INFO'}, f"Removed overlay: {overlay_name}")
else:
self.report({'WARNING'}, "No overlay to remove")
return {'FINISHED'}
class TEXT_TEXTURE_OT_set_anchor_point(Operator):
"""Set anchor point for text positioning"""
bl_idname = "text_texture.set_anchor_point"
bl_label = "Set Anchor Point"
bl_description = "Set text anchor position"
bl_options = {'REGISTER', 'UNDO'}
anchor_point: StringProperty()
def execute(self, context):
props = context.scene.text_texture_props
props.anchor_point = self.anchor_point
return {'FINISHED'}
class TEXT_TEXTURE_OT_sync_margins(Operator):
"""Sync margin values when linked"""
bl_idname = "text_texture.sync_margins"
bl_label = "Sync Margins"
bl_description = "Synchronize margin values"
bl_options = {'REGISTER', 'UNDO'}
margin_type: StringProperty()
def execute(self, context):
props = context.scene.text_texture_props
sync_margin_values(props, self.margin_type)
return {'FINISHED'}
# ============================================================================
# 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"""
# Add/Remove buttons
row = layout.row(align=True)
row.operator("text_texture.add_overlay", text="Add Image Overlay", icon='ADD')
if props.image_overlays:
row.operator("text_texture.remove_overlay", text="Remove", icon='REMOVE')
# Overlay list
if props.image_overlays:
layout.separator()
for i, overlay in enumerate(props.image_overlays):
overlay_row = layout.row()
overlay_row.prop(overlay, "enabled", text="")
overlay_row.prop(overlay, "name", text="")
if overlay.enabled:
layout.prop(overlay, "image_path", text="Image File")
pos_row = layout.row(align=True)
pos_row.prop(overlay, "x_position", text="X")
pos_row.prop(overlay, "y_position", text="Y")
trans_row = layout.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()
# Add refresh button at the top of presets section
refresh_row = layout.row(align=True)
refresh_row.operator("text_texture.refresh_presets", text="Refresh Presets", icon='FILE_REFRESH')
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']
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 local presets second
if local_presets:
if blend_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")
# ============================================================================
# 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
# 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)
# 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
# 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)
# 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
# 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)
# 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_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_PT_panel,
TEXT_TEXTURE_PT_panel_3d,
TEXT_TEXTURE_PT_panel_properties,
)
def register():
print("[TTG DEBUG] ========== ADDON REGISTRATION START ==========")
for cls in classes:
print(f"[TTG DEBUG] Registering class: {cls.__name__}")
try:
bpy.utils.register_class(cls)
print(f"[TTG DEBUG] Successfully registered: {cls.__name__}")
except Exception as e:
print(f"[TTG DEBUG] FAILED to register {cls.__name__}: {e}")
print("[TTG DEBUG] Registering scene properties...")
bpy.types.Scene.text_texture_props = PointerProperty(type=TEXT_TEXTURE_Properties)
print("[TTG DEBUG] Scene properties registered successfully")
# CRITICAL FIX: Force UI refresh after registration
print("[TTG DEBUG] Forcing UI refresh...")
try:
# Update all areas to ensure panels are properly displayed
for window in bpy.context.window_manager.windows:
for area in window.screen.areas:
area.tag_redraw()
print("[TTG DEBUG] UI refresh completed successfully")
except Exception as e:
print(f"[TTG DEBUG] UI refresh failed: {e}")
# Add to menus
bpy.types.NODE_MT_add.append(menu_func_node_editor)
bpy.types.VIEW3D_MT_add.append(menu_func_view3d)
bpy.types.VIEW3D_MT_mesh_add.append(menu_func_view3d)
bpy.types.NODE_MT_node.append(menu_func_node_editor)
# Add to Image menu if available
if hasattr(bpy.types, 'IMAGE_MT_image'):
bpy.types.IMAGE_MT_image.append(menu_func_image)
# Initialize presets after registration to prevent race conditions
# Use a timer to ensure the scene is ready
def delayed_preset_init():
initialize_presets()
return None # Stop timer
print("[TTG DEBUG] Registration completed - scheduling preset initialization")
bpy.app.timers.register(delayed_preset_init, first_interval=0.1)
# Add handler to reload presets when blend file changes
@bpy.app.handlers.persistent
def on_file_load(dummy):
"""Reload presets when a blend file is loaded"""
try:
print("[TTG] Reloading presets after file load...")
def delayed_reload():
initialize_presets()
return None # Stop timer
bpy.app.timers.register(delayed_reload, first_interval=0.1)
except Exception as e:
print(f"[TTG] Error reloading presets: {e}")
# Register the file load handler
if on_file_load not in bpy.app.handlers.load_post:
bpy.app.handlers.load_post.append(on_file_load)
print("[TTG DEBUG] ========== ADDON REGISTRATION END ==========")
def unregister():
# Clean up preview
try:
if bpy.context.scene:
props = bpy.context.scene.text_texture_props
if props and props.preview_image:
bpy.data.images.remove(props.preview_image)
except Exception:
pass
if "TextTexturePreview" in bpy.data.images:
bpy.data.images.remove(bpy.data.images["TextTexturePreview"])
# Remove file load handler
try:
for handler in bpy.app.handlers.load_post[:]:
if handler.__name__ == 'on_file_load' and 'TTG' in str(handler):
bpy.app.handlers.load_post.remove(handler)
except Exception as e:
print(f"Error removing file load handler: {e}")
# Remove from menus
bpy.types.NODE_MT_add.remove(menu_func_node_editor)
bpy.types.VIEW3D_MT_add.remove(menu_func_view3d)
bpy.types.VIEW3D_MT_mesh_add.remove(menu_func_view3d)
bpy.types.NODE_MT_node.remove(menu_func_node_editor)
if hasattr(bpy.types, 'IMAGE_MT_image'):
bpy.types.IMAGE_MT_image.remove(menu_func_image)
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
del bpy.types.Scene.text_texture_props
if __name__ == "__main__":
register()