1184 lines
43 KiB
Python
1184 lines
43 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):
|
||
os.makedirs(presets_dir)
|
||
return presets_dir
|
||
|
||
# ============================================================================
|
||
# PREVIEW GENERATION FUNCTIONS
|
||
# ============================================================================
|
||
|
||
def generate_texture_image(props, width, height):
|
||
"""Generate the actual texture image - width and height are REQUIRED"""
|
||
try:
|
||
install_pillow()
|
||
from PIL import Image, ImageDraw, ImageFont
|
||
|
||
# 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
|
||
# If this is a preview (smaller than texture size), scale the font accordingly
|
||
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}")
|
||
|
||
print(f"Font size: {font_size_pixels}, Padding: {padding}")
|
||
|
||
# Create image with background
|
||
if props.background_transparent:
|
||
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),)
|
||
img = Image.new('RGBA', (width, height), bg_color)
|
||
|
||
print(f"Image created: {img.size}")
|
||
|
||
draw = ImageDraw.Draw(img)
|
||
|
||
# Load font with proper size handling
|
||
font = None
|
||
font_loaded = False
|
||
|
||
try:
|
||
if props.use_custom_font and props.custom_font_path and os.path.exists(props.custom_font_path):
|
||
test_font = ImageFont.truetype(props.custom_font_path, font_size_pixels)
|
||
# Test the font before accepting it
|
||
test_draw = ImageDraw.Draw(Image.new('RGB', (10, 10)))
|
||
test_draw.textbbox((0, 0), "A", font=test_font)
|
||
font = test_font
|
||
font_loaded = True
|
||
print(f"Loaded custom font: {props.custom_font_path}")
|
||
elif props.font_path != "default" and os.path.exists(props.font_path):
|
||
test_font = ImageFont.truetype(props.font_path, font_size_pixels)
|
||
# Test the font before accepting it
|
||
test_draw = ImageDraw.Draw(Image.new('RGB', (10, 10)))
|
||
test_draw.textbbox((0, 0), "A", font=test_font)
|
||
font = test_font
|
||
font_loaded = True
|
||
print(f"Loaded font: {props.font_path}")
|
||
except Exception as e:
|
||
print(f"Font loading error: {e}")
|
||
font_loaded = False
|
||
|
||
# Better fallback - try to find a system TrueType font
|
||
if not font_loaded:
|
||
try:
|
||
# Try to find a basic system font
|
||
system = platform.system()
|
||
fallback_fonts = []
|
||
|
||
if system == "Windows":
|
||
fallback_fonts = [
|
||
"C:/Windows/Fonts/arial.ttf",
|
||
"C:/Windows/Fonts/Arial.ttf",
|
||
"C:/Windows/Fonts/calibri.ttf",
|
||
"C:/Windows/Fonts/tahoma.ttf"
|
||
]
|
||
elif system == "Darwin": # macOS
|
||
fallback_fonts = [
|
||
"/System/Library/Fonts/Helvetica.ttc",
|
||
"/Library/Fonts/Arial.ttf",
|
||
"/System/Library/Fonts/Avenir.ttc"
|
||
]
|
||
else: # Linux
|
||
fallback_fonts = [
|
||
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
|
||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||
"/usr/share/fonts/truetype/ubuntu/Ubuntu-R.ttf"
|
||
]
|
||
|
||
for fallback_path in fallback_fonts:
|
||
if os.path.exists(fallback_path):
|
||
try:
|
||
font = ImageFont.truetype(fallback_path, font_size_pixels)
|
||
# Test font by trying to get bbox of a simple character
|
||
test_draw = ImageDraw.Draw(Image.new('RGB', (10, 10)))
|
||
test_draw.textbbox((0, 0), "A", font=font)
|
||
font_loaded = True
|
||
print(f"Using fallback font: {fallback_path}")
|
||
break
|
||
except Exception as font_error:
|
||
print(f"Failed to load fallback font {fallback_path}: {font_error}")
|
||
continue
|
||
except Exception:
|
||
pass
|
||
|
||
# Last resort - use default (but it won't respect size)
|
||
if not font_loaded:
|
||
font = ImageFont.load_default()
|
||
print("Warning: Using default font - size will not be accurate!")
|
||
|
||
# Calculate text position with error handling
|
||
text = props.text if props.text and props.text.strip() else "Hello World"
|
||
|
||
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}")
|
||
# Fallback: estimate text size
|
||
text_width = int(len(text) * font_size_pixels * 0.6)
|
||
text_height = font_size_pixels
|
||
|
||
# Calculate X position based on alignment
|
||
if props.text_align == 'center':
|
||
x = (width - text_width) // 2
|
||
elif props.text_align == 'right':
|
||
x = width - text_width - padding
|
||
else:
|
||
x = padding
|
||
|
||
# Center vertically
|
||
y = (height - text_height) // 2
|
||
|
||
# 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),)
|
||
|
||
draw.text((x, y), text, fill=text_color, font=font)
|
||
print(f"Text drawn at ({x}, {y})")
|
||
|
||
return 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 preview size for performance, not full texture size
|
||
preview_size = props.preview_size # Default is 256
|
||
# Calculate aspect ratio preserving dimensions
|
||
aspect_ratio = props.texture_width / props.texture_height
|
||
if aspect_ratio > 1: # Wider than tall
|
||
preview_w = preview_size
|
||
preview_h = int(preview_size / aspect_ratio)
|
||
else: # Taller than wide or square
|
||
preview_w = int(preview_size * aspect_ratio)
|
||
preview_h = preview_size
|
||
|
||
# Ensure minimum dimensions
|
||
preview_w = max(16, preview_w)
|
||
preview_h = max(16, preview_h)
|
||
|
||
print(f"Starting preview generation at {preview_w}x{preview_h} (preview size: {preview_size})")
|
||
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' or not props.background_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_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"
|
||
)
|
||
|
||
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
|
||
)
|
||
|
||
# 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=256,
|
||
min=128,
|
||
max=512,
|
||
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
|
||
)
|
||
|
||
presets: CollectionProperty(type=TEXT_TEXTURE_Preset)
|
||
active_preset_index: IntProperty(default=0)
|
||
|
||
new_preset_name: StringProperty(
|
||
name="Preset Name",
|
||
description="Name for new preset",
|
||
default="New Preset"
|
||
)
|
||
|
||
# ============================================================================
|
||
# 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'}
|
||
|
||
# Removed view_preview operator - we want inline display only
|
||
|
||
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'}
|
||
|
||
def execute(self, context):
|
||
props = context.scene.text_texture_props
|
||
|
||
if not props.new_preset_name.strip():
|
||
self.report({'ERROR'}, "Enter a preset name")
|
||
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,
|
||
}
|
||
|
||
try:
|
||
preset_file = os.path.join(get_preset_path(), f"{props.new_preset_name}.json")
|
||
with open(preset_file, 'w') as f:
|
||
json.dump(preset_data, f, indent=2)
|
||
|
||
preset = props.presets.add()
|
||
preset.name = props.new_preset_name
|
||
|
||
self.report({'INFO'}, f"Saved: {props.new_preset_name}")
|
||
props.new_preset_name = "New Preset"
|
||
|
||
except Exception as e:
|
||
self.report({'ERROR'}, f"Failed: {str(e)}")
|
||
return {'CANCELLED'}
|
||
|
||
return {'FINISHED'}
|
||
|
||
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")
|
||
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)
|
||
|
||
props.is_updating = False
|
||
# Always generate preview after loading preset
|
||
generate_preview(context)
|
||
|
||
self.report({'INFO'}, f"Loaded: {self.preset_name}")
|
||
|
||
except Exception as e:
|
||
props.is_updating = False
|
||
self.report({'ERROR'}, f"Failed: {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)
|
||
|
||
for i, preset in enumerate(props.presets):
|
||
if preset.name == self.preset_name:
|
||
props.presets.remove(i)
|
||
break
|
||
|
||
self.report({'INFO'}, f"Deleted: {self.preset_name}")
|
||
|
||
except Exception as e:
|
||
self.report({'ERROR'}, f"Failed: {str(e)}")
|
||
return {'CANCELLED'}
|
||
|
||
return {'FINISHED'}
|
||
|
||
class TEXT_TEXTURE_OT_open_panel(Operator):
|
||
"""Open Text Texture Generator panel"""
|
||
bl_idname = "text_texture.open_panel"
|
||
bl_label = "Open Text Texture Panel"
|
||
bl_description = "Open the Text Texture Generator panel in the sidebar"
|
||
bl_options = {'REGISTER', 'UNDO'}
|
||
|
||
def execute(self, context):
|
||
# Ensure the sidebar is visible
|
||
for area in context.screen.areas:
|
||
if area.type == 'VIEW_3D':
|
||
for space in area.spaces:
|
||
if space.type == 'VIEW_3D':
|
||
space.show_region_ui = True
|
||
# Switch to Tool tab
|
||
area.tag_redraw()
|
||
self.report({'INFO'}, "Text Texture panel opened in 3D View sidebar (press N if not visible)")
|
||
return {'FINISHED'}
|
||
elif area.type == 'NODE_EDITOR':
|
||
for space in area.spaces:
|
||
if space.type == 'NODE_EDITOR':
|
||
space.show_region_ui = True
|
||
area.tag_redraw()
|
||
self.report({'INFO'}, "Text Texture panel opened in Shader Editor sidebar")
|
||
return {'FINISHED'}
|
||
|
||
self.report({'WARNING'}, "Please open a 3D View or Shader Editor first")
|
||
return {'CANCELLED'}
|
||
|
||
class TEXT_TEXTURE_OT_refresh_fonts(Operator):
|
||
"""Refresh fonts"""
|
||
bl_idname = "text_texture.refresh_fonts"
|
||
bl_label = "Refresh Fonts"
|
||
bl_description = "Refresh font list"
|
||
bl_options = {'REGISTER', 'UNDO'}
|
||
|
||
def execute(self, context):
|
||
if hasattr(get_font_enum_items, 'cached_fonts'):
|
||
del get_font_enum_items.cached_fonts
|
||
get_font_enum_items.cached_fonts = get_system_fonts()
|
||
self.report({'INFO'}, f"Found {len(get_font_enum_items.cached_fonts)} fonts")
|
||
return {'FINISHED'}
|
||
|
||
# ============================================================================
|
||
# 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):
|
||
return context.space_data.type == 'NODE_EDITOR' and context.space_data.tree_type == 'ShaderNodeTree'
|
||
|
||
def draw(self, context):
|
||
layout = self.layout
|
||
props = context.scene.text_texture_props
|
||
|
||
# Always ensure preview exists and is up to date
|
||
# Generate initial preview on first draw
|
||
if not props.preview_image:
|
||
# Schedule preview generation using timer
|
||
def generate_preview_delayed():
|
||
generate_preview(context)
|
||
return None # Stop timer
|
||
bpy.app.timers.register(generate_preview_delayed, first_interval=0.1)
|
||
|
||
# LIVE PREVIEW - Simplified and clean
|
||
if props.preview_image:
|
||
box = layout.box()
|
||
box.template_ID_preview(props, "preview_image", rows=8, cols=8)
|
||
else:
|
||
box = layout.box()
|
||
box.label(text="Generating preview...", icon='TIME')
|
||
|
||
# TEXT INPUT - BIG
|
||
box = layout.box()
|
||
box.label(text="Text Input:", icon='TEXT')
|
||
row = box.row()
|
||
row.scale_y = 2.0
|
||
row.prop(props, "text", text="")
|
||
|
||
# SETTINGS
|
||
box = layout.box()
|
||
box.label(text="Texture Settings:", icon='SETTINGS')
|
||
|
||
# Dimensions
|
||
col = box.column()
|
||
row = col.row(align=True)
|
||
row.prop(props, "texture_width", text="Width")
|
||
row.prop(props, "texture_height", text="Height")
|
||
col.label(text=f"Output: {props.texture_width}×{props.texture_height}px", icon='INFO')
|
||
|
||
# Font Settings
|
||
col.separator()
|
||
col.label(text="Font:", icon='FONT_DATA')
|
||
col.prop(props, "use_custom_font")
|
||
if props.use_custom_font:
|
||
col.prop(props, "custom_font_path", text="")
|
||
else:
|
||
row = col.row(align=True)
|
||
row.prop(props, "font_path", text="")
|
||
row.operator("text_texture.refresh_fonts", text="", icon='FILE_REFRESH')
|
||
|
||
row = col.row(align=True)
|
||
row.prop(props, "font_size")
|
||
row.prop(props, "padding")
|
||
col.prop(props, "text_align")
|
||
|
||
# Colors
|
||
col.separator()
|
||
col.label(text="Colors:", icon='COLOR')
|
||
col.prop(props, "text_color")
|
||
col.prop(props, "background_transparent")
|
||
if not props.background_transparent:
|
||
col.prop(props, "background_color")
|
||
|
||
# Preview settings (compact)
|
||
col.separator()
|
||
row = col.row(align=True)
|
||
row.prop(props, "preview_size", text="Preview")
|
||
row.prop(props, "preview_bg_type", text="")
|
||
if props.preview_bg_type == 'color':
|
||
col.prop(props, "preview_bg_color", text="")
|
||
elif props.preview_bg_type == 'gradient':
|
||
row = col.row(align=True)
|
||
row.prop(props, "preview_bg_color", text="")
|
||
row.prop(props, "preview_bg_color2", text="")
|
||
|
||
# GENERATE BUTTON
|
||
layout.separator()
|
||
row = layout.row()
|
||
row.scale_y = 2.0
|
||
row.operator("text_texture.generate", text="GENERATE FINAL TEXTURE", icon='IMAGE_DATA')
|
||
|
||
class TEXT_TEXTURE_PT_presets(Panel):
|
||
"""Presets Panel"""
|
||
bl_label = "Presets"
|
||
bl_idname = "TEXT_TEXTURE_PT_presets"
|
||
bl_parent_id = "TEXT_TEXTURE_PT_panel"
|
||
bl_space_type = 'NODE_EDITOR'
|
||
bl_region_type = 'UI'
|
||
bl_options = {'DEFAULT_CLOSED'}
|
||
|
||
def draw(self, context):
|
||
layout = self.layout
|
||
props = context.scene.text_texture_props
|
||
|
||
# Load presets
|
||
if not props.presets:
|
||
preset_dir = get_preset_path()
|
||
if os.path.exists(preset_dir):
|
||
for filename in os.listdir(preset_dir):
|
||
if filename.endswith('.json'):
|
||
preset_name = os.path.splitext(filename)[0]
|
||
if preset_name not in [p.name for p in props.presets]:
|
||
preset = props.presets.add()
|
||
preset.name = preset_name
|
||
|
||
# Save preset
|
||
box = layout.box()
|
||
box.label(text="Save Current Settings:", icon='EXPORT')
|
||
row = box.row(align=True)
|
||
row.prop(props, "new_preset_name", text="")
|
||
row.operator("text_texture.save_preset", text="Save", icon='ADD')
|
||
|
||
# Load preset
|
||
if props.presets:
|
||
box = layout.box()
|
||
box.label(text="Load Preset:", icon='IMPORT')
|
||
for preset in props.presets:
|
||
row = box.row(align=True)
|
||
row.operator("text_texture.load_preset", text=preset.name).preset_name = preset.name
|
||
row.operator("text_texture.delete_preset", text="", icon='X').preset_name = preset.name
|
||
|
||
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
|
||
|
||
# Generate initial preview if needed
|
||
if not props.preview_image:
|
||
# Schedule preview generation using timer
|
||
def generate_preview_delayed():
|
||
generate_preview(context)
|
||
return None # Stop timer
|
||
bpy.app.timers.register(generate_preview_delayed, first_interval=0.1)
|
||
|
||
# LIVE PREVIEW - Simplified
|
||
if props.preview_image:
|
||
box = layout.box()
|
||
box.template_ID_preview(props, "preview_image", rows=6, cols=6)
|
||
else:
|
||
box = layout.box()
|
||
box.label(text="Generating preview...", icon='TIME')
|
||
|
||
# TEXT INPUT
|
||
box = layout.box()
|
||
box.label(text="Text:", icon='TEXT')
|
||
row = box.row()
|
||
row.scale_y = 1.5
|
||
row.prop(props, "text", text="")
|
||
|
||
# Quick Settings
|
||
col = layout.column()
|
||
row = col.row(align=True)
|
||
row.prop(props, "texture_width", text="W")
|
||
row.prop(props, "texture_height", text="H")
|
||
col.prop(props, "font_size")
|
||
col.prop(props, "text_color")
|
||
col.prop(props, "background_transparent")
|
||
|
||
# Preview Toggle
|
||
# Preview is always enabled - removed toggle
|
||
|
||
# Generate
|
||
layout.separator()
|
||
row = layout.row()
|
||
row.scale_y = 1.5
|
||
row.operator("text_texture.generate", text="Generate Texture", icon='IMAGE_DATA')
|
||
|
||
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
|
||
|
||
# Generate initial preview if needed
|
||
if not props.preview_image:
|
||
# Schedule preview generation using timer
|
||
def generate_preview_delayed():
|
||
generate_preview(context)
|
||
return None # Stop timer
|
||
bpy.app.timers.register(generate_preview_delayed, first_interval=0.1)
|
||
|
||
# LIVE PREVIEW - Simplified
|
||
if props.preview_image:
|
||
box = layout.box()
|
||
box.template_ID_preview(props, "preview_image", rows=6, cols=6)
|
||
else:
|
||
box = layout.box()
|
||
box.label(text="Generating preview...", icon='TIME')
|
||
|
||
# TEXT INPUT
|
||
box = layout.box()
|
||
box.label(text="Text:", icon='TEXT')
|
||
row = box.row()
|
||
row.scale_y = 1.5
|
||
row.prop(props, "text", text="")
|
||
|
||
# Quick Settings
|
||
col = layout.column()
|
||
row = col.row(align=True)
|
||
row.prop(props, "texture_width", text="W")
|
||
row.prop(props, "texture_height", text="H")
|
||
col.prop(props, "font_size")
|
||
col.prop(props, "text_color")
|
||
col.prop(props, "background_transparent")
|
||
if not props.background_transparent:
|
||
col.prop(props, "background_color")
|
||
|
||
# Generate
|
||
layout.separator()
|
||
row = layout.row()
|
||
row.scale_y = 1.5
|
||
row.operator("text_texture.generate", text="Generate Texture", icon='IMAGE_DATA')
|
||
|
||
# ============================================================================
|
||
# REGISTRATION
|
||
# ============================================================================
|
||
|
||
classes = (
|
||
TEXT_TEXTURE_Preset,
|
||
TEXT_TEXTURE_Properties,
|
||
TEXT_TEXTURE_OT_generate,
|
||
TEXT_TEXTURE_OT_refresh_preview,
|
||
TEXT_TEXTURE_OT_open_panel,
|
||
TEXT_TEXTURE_OT_save_preset,
|
||
TEXT_TEXTURE_OT_load_preset,
|
||
TEXT_TEXTURE_OT_delete_preset,
|
||
TEXT_TEXTURE_OT_refresh_fonts,
|
||
TEXT_TEXTURE_PT_panel,
|
||
TEXT_TEXTURE_PT_presets,
|
||
TEXT_TEXTURE_PT_panel_3d,
|
||
TEXT_TEXTURE_PT_panel_properties,
|
||
)
|
||
|
||
def register():
|
||
for cls in classes:
|
||
bpy.utils.register_class(cls)
|
||
|
||
bpy.types.Scene.text_texture_props = PointerProperty(type=TEXT_TEXTURE_Properties)
|
||
|
||
# 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)
|
||
|
||
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() |