import bpy import bmesh from bpy.types import Operator, Panel, PropertyGroup, UIList from bpy.props import StringProperty, IntProperty, FloatVectorProperty, FloatProperty, BoolProperty, EnumProperty, CollectionProperty, PointerProperty import os import tempfile import subprocess import sys import json import platform import time # Addon info bl_info = { "name": "Text Texture Generator", "author": "Marc Mintel ", "version": (2, 3, 2), "blender": (4, 0, 0), "location": "3D View > Sidebar (N) > Tool Tab | Shader Editor > Sidebar > Tool Tab | 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()