Files
Text-Texture-Generator-for-…/__init__.py
2025-08-11 12:48:35 +07:00

2303 lines
90 KiB
Python

import bpy
import bmesh
from bpy.types import Operator, Panel, PropertyGroup, UIList
from bpy.props import StringProperty, IntProperty, FloatVectorProperty, FloatProperty, BoolProperty, EnumProperty, CollectionProperty, PointerProperty
import os
import tempfile
import subprocess
import sys
import json
import platform
import time
# Addon info
bl_info = {
"name": "Text Texture Generator",
"author": "Marc Mintel <marc@mintel.me>",
"version": (2, 3, 2),
"blender": (4, 0, 0),
"location": "3D View > Sidebar (N) > Tool Tab | Shader Editor > Sidebar > Tool Tab | 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"""
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 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
# Clear existing presets
props.presets.clear()
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]
preset = props.presets.add()
preset.name = preset_name
except OSError as e:
print(f"Error loading presets during initialization: {e}")
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
]
# ============================================================================
# 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"""
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}")
# 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")
# Calculate text position
text = props.text if props.text and props.text.strip() else "Hello World"
# ENHANCED DIAGNOSTIC LOGGING for uppercase text issue
print(f"DEBUG UPPERCASE ISSUE - Original text input: '{props.text}'")
print(f"DEBUG UPPERCASE ISSUE - Processed 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")
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),)
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")
return final_img
except Exception as e:
print(f"Error in generate_texture_image: {e}")
import traceback
traceback.print_exc()
return 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 image
img = generate_texture_image(props, preview_w, preview_h)
if not img:
print("Failed to generate PIL 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"
)
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
)
text: StringProperty(
name="Text",
description="Text to render as texture",
default="Hello World",
maxlen=1024,
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
)
# ============================================================================
# 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
img = generate_texture_image(props, final_width, final_height)
# Create final name
img_name = f"TextTexture_{props.text[:20].replace(' ', '_')}"
# Remove existing
if img_name in bpy.data.images:
bpy.data.images.remove(bpy.data.images[img_name])
# Create new image with USER SPECIFIED dimensions
blender_img = bpy.data.images.new(img_name, final_width, final_height, alpha=True)
# Convert PIL to Blender
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
# 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
img_node = nodes.new(type='ShaderNodeTexImage')
img_node.image = blender_img
img_node.location = (0, 0)
img_node.select = True
nodes.active = img_node
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_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
)
def execute(self, context):
props = context.scene.text_texture_props
# Validate preset name
preset_name = props.new_preset_name.strip()
if not preset_name:
self.report({'ERROR'}, "Please enter a preset name")
return {'CANCELLED'}
# Validate preset name characters (avoid filesystem issues)
import re
if not re.match(r'^[a-zA-Z0-9_\-\s]+$', preset_name):
self.report({'ERROR'}, "Preset name can only contain letters, numbers, spaces, hyphens, and underscores")
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'}
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,
'image_overlays': []
}
# 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:
with open(preset_file, 'w') as f:
json.dump(preset_data, f, indent=2)
# 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
# Clear the input field
props.new_preset_name = "New Preset"
# Provide clear feedback
if self.overwrite:
self.report({'INFO'}, f"✅ Preset '{preset_name}' updated successfully")
else:
self.report({'INFO'}, f"✅ Preset '{preset_name}' saved successfully")
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
try:
preset_file = os.path.join(get_preset_path(), f"{self.preset_name}.json")
if not os.path.exists(preset_file):
self.report({'ERROR'}, f"Preset file not found: {self.preset_name}")
return {'CANCELLED'}
with open(preset_file, 'r') as f:
preset_data = json.load(f)
props.is_updating = True
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)
# 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
# Always generate preview after loading preset
generate_preview(context)
self.report({'INFO'}, f"Loaded: {self.preset_name}")
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:
preset_file = os.path.join(get_preset_path(), f"{self.preset_name}.json")
if os.path.exists(preset_file):
os.remove(preset_file)
else:
self.report({'WARNING'}, f"Preset file not found: {self.preset_name}")
for i, preset in enumerate(props.presets):
if preset.name == self.preset_name:
props.presets.remove(i)
break
self.report({'INFO'}, f"🗑️ Deleted preset: {self.preset_name}")
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_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()
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")
layout.separator()
layout.label(text="Colors:")
layout.prop(props, "text_color")
layout.prop(props, "background_transparent")
if not props.background_transparent:
layout.prop(props, "background_color")
def draw_preview_options_section(layout, props):
"""Draw preview options"""
layout.prop(props, "preview_size", text="Preview Size")
layout.prop(props, "preview_bg_type", text="Background")
if props.preview_bg_type == 'color':
layout.prop(props, "preview_bg_color", text="Background 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')
# Status info
if props.new_preset_name and props.new_preset_name.strip() != "New Preset":
preset_file = os.path.join(get_preset_path(), f"{props.new_preset_name.strip()}.json")
if os.path.exists(preset_file):
status_row = layout.row()
status_row.label(text="⚠️ Preset exists - will prompt to overwrite", icon='INFO')
layout.separator()
# Load presets section
if props.presets:
layout.label(text=f"📁 Saved Presets ({len(props.presets)}):", icon='PRESET')
for i, preset in enumerate(props.presets):
row = layout.row()
load_btn = row.operator("text_texture.load_preset", text=f"📋 {preset.name}")
load_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')
# ============================================================================
# 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()
draw_generate_button(layout)
layout.separator()
# 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)
# Preview Options Section (collapsed by default)
preview_section = draw_collapsible_header(layout, "expand_preview_options", "Preview Options", 'IMAGE_DATA')
if preview_section:
draw_preview_options_section(preview_section, props)
# Preview Button in expanded section
layout.separator()
preview_section.separator()
button_row = preview_section.row()
button_row.operator("text_texture.view_preview_zoom", text="Open Preview Window", icon='WINDOW')
# 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()
draw_generate_button(layout)
layout.separator()
# 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)
# Preview Options Section (collapsed by default)
preview_section = draw_collapsible_header(layout, "expand_preview_options", "Preview Options", 'IMAGE_DATA')
if preview_section:
draw_preview_options_section(preview_section, props)
# Preview Button in expanded section
preview_section.separator()
button_row = preview_section.row()
button_row.operator("text_texture.view_preview_zoom", text="Open Preview Window", icon='WINDOW')
# 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()
draw_generate_button(layout)
layout.separator()
# 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)
# Preview Options Section (collapsed by default)
preview_section = draw_collapsible_header(layout, "expand_preview_options", "Preview Options", 'IMAGE_DATA')
if preview_section:
draw_preview_options_section(preview_section, props)
# Preview Button in expanded section
preview_section.separator()
button_row = preview_section.row()
button_row.operator("text_texture.view_preview_zoom", text="Open Preview Window", icon='WINDOW')
# 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_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_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)
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 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()