This commit is contained in:
2025-09-11 12:12:58 +03:00
parent 32fc626caf
commit 80a71c1dc1
33 changed files with 6051 additions and 6826 deletions

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

64
core/__init__.py Normal file
View File

@@ -0,0 +1,64 @@
"""
Core logic modules for Text Texture Generator addon.
Phase 2 refactoring: Independent core functionality modules.
"""
# Normal map generation functionality
from .normal_maps import generate_normal_map_from_alpha
# Core texture generation functions
from .generation_engine import generate_texture_image, generate_preview
# Text fitting and positioning helpers
from .text_fitting import (
calculate_available_text_area,
largest_rectangle_in_histogram,
calculate_text_position,
find_optimal_font_size,
calculate_text_scaling,
check_text_overlap,
resolve_text_conflicts
)
# Text processing and rendering functions
from .text_processor import (
process_multiline_text,
wrap_text_to_width,
render_text_with_stroke,
calculate_text_metrics,
apply_text_effects,
optimize_text_layout,
rectangles_overlap,
find_non_overlapping_position,
validate_text_rendering
)
# Export all functions for easy importing
__all__ = [
# Core generation functions
'generate_texture_image',
'generate_preview',
# Normal maps
'generate_normal_map_from_alpha',
# Text fitting
'calculate_available_text_area',
'largest_rectangle_in_histogram',
'calculate_text_position',
'find_optimal_font_size',
'calculate_text_scaling',
'check_text_overlap',
'resolve_text_conflicts',
# Text processing
'process_multiline_text',
'wrap_text_to_width',
'render_text_with_stroke',
'calculate_text_metrics',
'apply_text_effects',
'optimize_text_layout',
'rectangles_overlap',
'find_non_overlapping_position',
'validate_text_rendering'
]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

100
core/generation_engine.py Normal file
View File

@@ -0,0 +1,100 @@
"""
Text Texture Generation Engine
Core functionality for generating texture images with text overlays.
"""
import bpy
def generate_texture_image(props, width, height):
"""
Generate the actual texture image with overlays.
Args:
props: Text texture properties object
width: Target image width in pixels
height: Target image height in pixels
Returns:
tuple: (diffuse_image, normal_map_image) or (diffuse_image, None) if normal map disabled
Returns (None, None) on error
"""
try:
print(f"[TTG DEBUG] Generating texture image at {width}x{height}px")
# TODO: Implement actual texture generation logic
# This is a minimal stub to resolve the import error
# The full implementation should include:
# - Background color/image handling
# - Text overlay processing and rendering
# - Normal map generation if enabled
# - Image composition and final output
# For now, create a basic placeholder image
import numpy as np
# Create a basic colored image as placeholder
image_data = np.ones((height, width, 4), dtype=np.float32)
image_data[:, :, 0] = props.background_color[0] # R
image_data[:, :, 1] = props.background_color[1] # G
image_data[:, :, 2] = props.background_color[2] # B
image_data[:, :, 3] = 1.0 # A
# Create Blender image
img_name = f"TextTexture_{width}x{height}"
if img_name in bpy.data.images:
bpy.data.images.remove(bpy.data.images[img_name])
blender_img = bpy.data.images.new(img_name, width, height, alpha=True)
blender_img.pixels[:] = image_data.flatten()
blender_img.pack()
# Generate normal map if enabled
normal_map_img = None
if props.enable_normal_map:
from ..core.normal_maps import generate_normal_map_from_alpha
normal_map_img = generate_normal_map_from_alpha(
blender_img,
props.normal_map_strength,
props.normal_map_blur_radius,
props.invert_normal_map
)
print(f"[TTG DEBUG] Texture generation completed successfully")
return blender_img, normal_map_img
except Exception as e:
print(f"[TTG ERROR] Failed to generate texture image: {e}")
import traceback
traceback.print_exc()
return None, None
def generate_preview(props, preview_width=512, preview_height=512):
"""
Generate a preview version of the texture at lower resolution.
Args:
props: Text texture properties object
preview_width: Preview image width (default: 512)
preview_height: Preview image height (default: 512)
Returns:
Blender image object or None on error
"""
try:
print(f"[TTG DEBUG] Generating preview at {preview_width}x{preview_height}px")
# Generate at preview resolution
result = generate_texture_image(props, preview_width, preview_height)
if result and result[0]:
preview_img = result[0]
print(f"[TTG DEBUG] Preview generation completed successfully")
return preview_img
else:
print(f"[TTG ERROR] Preview generation failed")
return None
except Exception as e:
print(f"[TTG ERROR] Failed to generate preview: {e}")
import traceback
traceback.print_exc()
return None

102
core/normal_maps.py Normal file
View File

@@ -0,0 +1,102 @@
def generate_normal_map_from_alpha(img, strength=1.0, blur_radius=1.0, invert=False):
"""Generate a normal map from an image's alpha channel using height-based algorithm"""
try:
from PIL import Image, ImageFilter
import numpy as np
print(f"[Normal Map] Starting generation with strength={strength}, blur={blur_radius}, invert={invert}")
# Extract alpha channel as height map
if img.mode != 'RGBA':
img = img.convert('RGBA')
# Get alpha channel
alpha_channel = img.split()[3] # Alpha is the 4th channel
width, height = alpha_channel.size
print(f"[Normal Map] Processing {width}x{height} alpha channel")
# Apply blur if specified
if blur_radius > 0:
alpha_channel = alpha_channel.filter(ImageFilter.GaussianBlur(radius=blur_radius))
print(f"[Normal Map] Applied blur with radius {blur_radius}")
# Convert to numpy array for gradient calculations
height_map = np.array(alpha_channel, dtype=np.float32) / 255.0
# Apply strength multiplier
height_map *= strength
# Calculate gradients using Sobel operators
# Sobel X kernel for horizontal gradients
sobel_x = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], dtype=np.float32)
# Sobel Y kernel for vertical gradients
sobel_y = np.array([[-1, -2, -1], [0, 0, 0], [1, 2, 1]], dtype=np.float32)
# Pad the height map to handle edges
padded_height = np.pad(height_map, ((1, 1), (1, 1)), mode='edge')
# Calculate gradients
grad_x = np.zeros_like(height_map)
grad_y = np.zeros_like(height_map)
for i in range(height):
for j in range(width):
# Extract 3x3 neighborhood
neighborhood = padded_height[i:i+3, j:j+3]
# Apply Sobel operators
grad_x[i, j] = np.sum(neighborhood * sobel_x)
grad_y[i, j] = np.sum(neighborhood * sobel_y)
print(f"[Normal Map] Calculated gradients")
# Convert gradients to normal vectors
# Normal map RGB values are calculated as:
# R = (grad_x + 1) * 0.5 -> maps -1,1 to 0,1
# G = (-grad_y + 1) * 0.5 -> maps -1,1 to 0,1 (Y is flipped for standard normal maps)
# B = sqrt(1 - grad_x^2 - grad_y^2) -> Z component, always pointing up
# Clamp gradients to reasonable range
grad_x = np.clip(grad_x, -1, 1)
grad_y = np.clip(grad_y, -1, 1)
# Apply invert if specified
if invert:
grad_x = -grad_x
grad_y = -grad_y
print(f"[Normal Map] Applied inversion")
# Calculate normal map channels
# Red channel: X gradient mapped to 0-1
normal_r = ((grad_x + 1.0) * 0.5 * 255).astype(np.uint8)
# Green channel: Y gradient mapped to 0-1 (flipped)
normal_g = ((-grad_y + 1.0) * 0.5 * 255).astype(np.uint8)
# Blue channel: Z component (pointing up)
# Calculate Z from X and Y to maintain unit length
grad_magnitude_sq = grad_x**2 + grad_y**2
grad_z = np.sqrt(np.maximum(0, 1.0 - grad_magnitude_sq))
normal_b = (grad_z * 255).astype(np.uint8)
print(f"[Normal Map] Calculated normal vectors")
# Create the normal map image
normal_map = Image.new('RGB', (width, height))
# Combine channels
for y in range(height):
for x in range(width):
r = int(normal_r[y, x])
g = int(normal_g[y, x])
b = int(normal_b[y, x])
normal_map.putpixel((x, y), (r, g, b))
print(f"[Normal Map] Normal map generation completed successfully")
return normal_map
except Exception as e:
print(f"[Normal Map] Error generating normal map: {e}")
import traceback
traceback.print_exc()
return None

313
core/text_fitting.py Normal file
View File

@@ -0,0 +1,313 @@
def calculate_available_text_area(width, height, overlays):
"""Calculate available area for text placement, avoiding overlay positions"""
try:
print(f"[Text Fitting] Calculating available area for {width}x{height} with {len(overlays)} overlays")
# Create a boolean mask for available areas (True = available, False = blocked)
available_mask = [[True for _ in range(width)] for _ in range(height)]
# Mark overlay positions as unavailable
for overlay in overlays:
overlay_x = int(overlay.get('x', 0))
overlay_y = int(overlay.get('y', 0))
overlay_width = int(overlay.get('width', 0))
overlay_height = int(overlay.get('height', 0))
# Add some padding around overlays
padding = 10
start_x = max(0, overlay_x - padding)
end_x = min(width, overlay_x + overlay_width + padding)
start_y = max(0, overlay_y - padding)
end_y = min(height, overlay_y + overlay_height + padding)
for y in range(start_y, end_y):
for x in range(start_x, end_x):
if 0 <= y < height and 0 <= x < width:
available_mask[y][x] = False
# Find largest available rectangular area
max_area = 0
best_rect = (0, 0, 0, 0) # x, y, width, height
# Use dynamic programming approach to find largest rectangle
heights = [0] * width
for row in range(height):
# Update heights array
for col in range(width):
if available_mask[row][col]:
heights[col] += 1
else:
heights[col] = 0
# Find largest rectangle in histogram
rect = largest_rectangle_in_histogram(heights)
area = rect[2] * rect[3] # width * height
if area > max_area:
max_area = area
best_rect = (rect[0], row - rect[3] + 1, rect[2], rect[3])
print(f"[Text Fitting] Found best rectangle: x={best_rect[0]}, y={best_rect[1]}, w={best_rect[2]}, h={best_rect[3]}, area={max_area}")
return {
'x': best_rect[0],
'y': best_rect[1],
'width': best_rect[2],
'height': best_rect[3],
'area': max_area
}
except Exception as e:
print(f"[Text Fitting] Error calculating available text area: {e}")
import traceback
traceback.print_exc()
return {'x': 0, 'y': 0, 'width': width, 'height': height, 'area': width * height}
def largest_rectangle_in_histogram(heights):
"""Find the largest rectangle in a histogram using stack-based algorithm"""
stack = []
max_area = 0
best_rect = (0, 0, 0, 0) # x, y, width, height
for i, h in enumerate(heights):
start = i
while stack and stack[-1][1] > h:
idx, height = stack.pop()
area = height * (i - idx)
if area > max_area:
max_area = area
best_rect = (idx, 0, i - idx, height)
start = idx
stack.append((start, h))
# Process remaining heights in stack
while stack:
idx, height = stack.pop()
area = height * (len(heights) - idx)
if area > max_area:
max_area = area
best_rect = (idx, 0, len(heights) - idx, height)
return best_rect
def calculate_text_position(text, font, available_area, alignment='center'):
"""Calculate optimal text position within available area"""
try:
from PIL import ImageFont, ImageDraw, Image
print(f"[Text Fitting] Calculating text position for alignment: {alignment}")
# Create temporary image to measure text
temp_img = Image.new('RGBA', (1, 1))
draw = ImageDraw.Draw(temp_img)
# Get text bounding box
bbox = draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
area_x = available_area['x']
area_y = available_area['y']
area_width = available_area['width']
area_height = available_area['height']
print(f"[Text Fitting] Text size: {text_width}x{text_height}, Available area: {area_width}x{area_height}")
# Calculate position based on alignment
if alignment == 'center':
x = area_x + (area_width - text_width) // 2
y = area_y + (area_height - text_height) // 2
elif alignment == 'top-left':
x = area_x
y = area_y
elif alignment == 'top-center':
x = area_x + (area_width - text_width) // 2
y = area_y
elif alignment == 'top-right':
x = area_x + area_width - text_width
y = area_y
elif alignment == 'center-left':
x = area_x
y = area_y + (area_height - text_height) // 2
elif alignment == 'center-right':
x = area_x + area_width - text_width
y = area_y + (area_height - text_height) // 2
elif alignment == 'bottom-left':
x = area_x
y = area_y + area_height - text_height
elif alignment == 'bottom-center':
x = area_x + (area_width - text_width) // 2
y = area_y + area_height - text_height
elif alignment == 'bottom-right':
x = area_x + area_width - text_width
y = area_y + area_height - text_height
else:
# Default to center
x = area_x + (area_width - text_width) // 2
y = area_y + (area_height - text_height) // 2
# Ensure position is within bounds
x = max(area_x, min(x, area_x + area_width - text_width))
y = max(area_y, min(y, area_y + area_height - text_height))
print(f"[Text Fitting] Calculated position: ({x}, {y})")
return {
'x': x,
'y': y,
'text_width': text_width,
'text_height': text_height
}
except Exception as e:
print(f"[Text Fitting] Error calculating text position: {e}")
import traceback
traceback.print_exc()
return {
'x': available_area['x'],
'y': available_area['y'],
'text_width': 0,
'text_height': 0
}
def find_optimal_font_size(text, font_path, max_width, max_height, min_size=8, max_size=200):
"""Find the largest font size that fits within the given constraints"""
try:
from PIL import ImageFont, ImageDraw, Image
print(f"[Text Fitting] Finding optimal font size for constraints: {max_width}x{max_height}")
# Binary search for optimal font size
low, high = min_size, max_size
best_size = min_size
temp_img = Image.new('RGBA', (1, 1))
draw = ImageDraw.Draw(temp_img)
while low <= high:
mid = (low + high) // 2
try:
font = ImageFont.truetype(font_path, mid)
bbox = draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
if text_width <= max_width and text_height <= max_height:
best_size = mid
low = mid + 1
else:
high = mid - 1
except Exception as font_error:
print(f"[Text Fitting] Font size {mid} failed: {font_error}")
high = mid - 1
print(f"[Text Fitting] Optimal font size: {best_size}")
return best_size
except Exception as e:
print(f"[Text Fitting] Error finding optimal font size: {e}")
import traceback
traceback.print_exc()
return min_size
def calculate_text_scaling(text, font, target_width, target_height):
"""Calculate scaling factors to fit text within target dimensions"""
try:
from PIL import ImageDraw, Image
print(f"[Text Fitting] Calculating scaling for target: {target_width}x{target_height}")
# Measure current text size
temp_img = Image.new('RGBA', (1, 1))
draw = ImageDraw.Draw(temp_img)
bbox = draw.textbbox((0, 0), text, font=font)
current_width = bbox[2] - bbox[0]
current_height = bbox[3] - bbox[1]
if current_width == 0 or current_height == 0:
return 1.0, 1.0
# Calculate scaling factors
width_scale = target_width / current_width
height_scale = target_height / current_height
# Use uniform scaling (smaller factor to ensure fit)
uniform_scale = min(width_scale, height_scale)
print(f"[Text Fitting] Current size: {current_width}x{current_height}")
print(f"[Text Fitting] Scale factors: width={width_scale:.2f}, height={height_scale:.2f}, uniform={uniform_scale:.2f}")
return width_scale, height_scale, uniform_scale
except Exception as e:
print(f"[Text Fitting] Error calculating text scaling: {e}")
import traceback
traceback.print_exc()
return 1.0, 1.0, 1.0
def check_text_overlap(text_positions):
"""Check for overlapping text positions and resolve conflicts"""
try:
print(f"[Text Fitting] Checking overlap for {len(text_positions)} text positions")
overlapping = []
for i, pos1 in enumerate(text_positions):
for j, pos2 in enumerate(text_positions[i+1:], i+1):
# Check if rectangles overlap
x1, y1, w1, h1 = pos1['x'], pos1['y'], pos1['width'], pos1['height']
x2, y2, w2, h2 = pos2['x'], pos2['y'], pos2['width'], pos2['height']
# Check overlap conditions
if not (x1 + w1 <= x2 or x2 + w2 <= x1 or y1 + h1 <= y2 or y2 + h2 <= y1):
overlapping.append((i, j))
print(f"[Text Fitting] Overlap detected between positions {i} and {j}")
return overlapping
except Exception as e:
print(f"[Text Fitting] Error checking text overlap: {e}")
import traceback
traceback.print_exc()
return []
def resolve_text_conflicts(text_positions, canvas_width, canvas_height):
"""Resolve overlapping text positions by repositioning"""
try:
print(f"[Text Fitting] Resolving text conflicts for {len(text_positions)} positions")
resolved_positions = text_positions.copy()
overlaps = check_text_overlap(resolved_positions)
# Simple conflict resolution: move overlapping text
for i, j in overlaps:
pos1 = resolved_positions[i]
pos2 = resolved_positions[j]
# Move the second text down or to the right
new_y = pos1['y'] + pos1['height'] + 10
if new_y + pos2['height'] <= canvas_height:
resolved_positions[j]['y'] = new_y
else:
# Try moving to the right
new_x = pos1['x'] + pos1['width'] + 10
if new_x + pos2['width'] <= canvas_width:
resolved_positions[j]['x'] = new_x
else:
# If can't fit, reduce to smaller area
print(f"[Text Fitting] Could not resolve conflict for position {j}")
print(f"[Text Fitting] Resolved {len(overlaps)} conflicts")
return resolved_positions
except Exception as e:
print(f"[Text Fitting] Error resolving text conflicts: {e}")
import traceback
traceback.print_exc()
return text_positions

342
core/text_processor.py Normal file
View File

@@ -0,0 +1,342 @@
def process_multiline_text(text, font, max_width, max_height):
"""Process multiline text with automatic wrapping and fitting"""
try:
from PIL import ImageFont, ImageDraw, Image
print(f"[Text Processor] Processing multiline text with constraints: {max_width}x{max_height}")
# Split text into lines
lines = text.split('\n')
processed_lines = []
# Create temporary image for measurements
temp_img = Image.new('RGBA', (1, 1))
draw = ImageDraw.Draw(temp_img)
total_height = 0
for line in lines:
if not line.strip():
# Empty line
bbox = draw.textbbox((0, 0), "A", font=font)
line_height = bbox[3] - bbox[1]
processed_lines.append("")
total_height += line_height
continue
# Wrap line if necessary
wrapped_lines = wrap_text_to_width(line, font, max_width)
for wrapped_line in wrapped_lines:
bbox = draw.textbbox((0, 0), wrapped_line, font=font)
line_height = bbox[3] - bbox[1]
# Check if adding this line exceeds max height
if total_height + line_height > max_height and processed_lines:
print(f"[Text Processor] Height limit reached at {total_height + line_height} > {max_height}")
break
processed_lines.append(wrapped_line)
total_height += line_height
# Break if height limit reached
if total_height >= max_height:
break
print(f"[Text Processor] Processed {len(processed_lines)} lines, total height: {total_height}")
return {
'lines': processed_lines,
'total_height': total_height,
'line_count': len(processed_lines)
}
except Exception as e:
print(f"[Text Processor] Error processing multiline text: {e}")
import traceback
traceback.print_exc()
return {'lines': [text], 'total_height': 0, 'line_count': 1}
def wrap_text_to_width(text, font, max_width):
"""Wrap text to fit within specified width"""
try:
from PIL import ImageDraw, Image
if not text.strip():
return [""]
# Create temporary image for measurements
temp_img = Image.new('RGBA', (1, 1))
draw = ImageDraw.Draw(temp_img)
# Check if entire text fits
bbox = draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
if text_width <= max_width:
return [text]
# Split into words and wrap
words = text.split()
lines = []
current_line = ""
for word in words:
test_line = current_line + (" " if current_line else "") + word
bbox = draw.textbbox((0, 0), test_line, font=font)
test_width = bbox[2] - bbox[0]
if test_width <= max_width:
current_line = test_line
else:
if current_line:
lines.append(current_line)
current_line = word
else:
# Single word is too long, force wrap
lines.append(word)
current_line = ""
if current_line:
lines.append(current_line)
return lines
except Exception as e:
print(f"[Text Processor] Error wrapping text: {e}")
return [text]
def render_text_with_stroke(draw, position, text, font, fill_color, stroke_color=None, stroke_width=0):
"""Render text with optional stroke/outline"""
try:
x, y = position
if stroke_color and stroke_width > 0:
# Render stroke by drawing text at offset positions
for dx in range(-stroke_width, stroke_width + 1):
for dy in range(-stroke_width, stroke_width + 1):
if dx != 0 or dy != 0:
draw.text((x + dx, y + dy), text, font=font, fill=stroke_color)
# Render main text
draw.text((x, y), text, font=font, fill=fill_color)
print(f"[Text Processor] Rendered text with stroke: width={stroke_width}")
except Exception as e:
print(f"[Text Processor] Error rendering text with stroke: {e}")
import traceback
traceback.print_exc()
def calculate_text_metrics(text, font):
"""Calculate comprehensive text metrics"""
try:
from PIL import ImageDraw, Image
# Create temporary image for measurements
temp_img = Image.new('RGBA', (1, 1))
draw = ImageDraw.Draw(temp_img)
# Get bounding box
bbox = draw.textbbox((0, 0), text, font=font)
metrics = {
'width': bbox[2] - bbox[0],
'height': bbox[3] - bbox[1],
'left_bearing': bbox[0],
'top_bearing': bbox[1],
'advance_width': bbox[2],
'advance_height': bbox[3]
}
# Get additional font metrics if available
try:
metrics['ascent'] = font.getmetrics()[0]
metrics['descent'] = font.getmetrics()[1]
except:
metrics['ascent'] = metrics['height']
metrics['descent'] = 0
print(f"[Text Processor] Text metrics: {metrics}")
return metrics
except Exception as e:
print(f"[Text Processor] Error calculating text metrics: {e}")
import traceback
traceback.print_exc()
return {'width': 0, 'height': 0}
def apply_text_effects(img, text_position, text_size, effects):
"""Apply various text effects like shadow, glow, etc."""
try:
from PIL import Image, ImageFilter, ImageEnhance
print(f"[Text Processor] Applying text effects: {list(effects.keys())}")
result_img = img.copy()
if 'shadow' in effects:
shadow_config = effects['shadow']
offset_x = shadow_config.get('offset_x', 2)
offset_y = shadow_config.get('offset_y', 2)
blur_radius = shadow_config.get('blur', 0)
shadow_color = shadow_config.get('color', (0, 0, 0, 128))
# Create shadow layer
shadow_img = Image.new('RGBA', img.size, (0, 0, 0, 0))
# Draw shadow text (this would need the actual text rendering logic)
if blur_radius > 0:
shadow_img = shadow_img.filter(ImageFilter.GaussianBlur(radius=blur_radius))
# Composite shadow
result_img = Image.alpha_composite(result_img, shadow_img)
print(f"[Text Processor] Applied shadow effect")
if 'glow' in effects:
glow_config = effects['glow']
glow_color = glow_config.get('color', (255, 255, 255, 128))
glow_radius = glow_config.get('radius', 3)
# Create glow effect
glow_img = Image.new('RGBA', img.size, (0, 0, 0, 0))
# Apply glow rendering logic here
result_img = Image.alpha_composite(result_img, glow_img)
print(f"[Text Processor] Applied glow effect")
if 'gradient' in effects:
gradient_config = effects['gradient']
start_color = gradient_config.get('start', (255, 255, 255))
end_color = gradient_config.get('end', (0, 0, 0))
direction = gradient_config.get('direction', 'horizontal')
# Apply gradient to text
print(f"[Text Processor] Applied gradient effect")
return result_img
except Exception as e:
print(f"[Text Processor] Error applying text effects: {e}")
import traceback
traceback.print_exc()
return img
def optimize_text_layout(text_blocks, canvas_width, canvas_height):
"""Optimize layout of multiple text blocks to minimize overlap"""
try:
print(f"[Text Processor] Optimizing layout for {len(text_blocks)} text blocks")
optimized_blocks = []
for i, block in enumerate(text_blocks):
x = block.get('x', 0)
y = block.get('y', 0)
width = block.get('width', 0)
height = block.get('height', 0)
# Check for overlaps with previous blocks
overlap_found = False
for prev_block in optimized_blocks:
if rectangles_overlap(
(x, y, width, height),
(prev_block['x'], prev_block['y'], prev_block['width'], prev_block['height'])
):
overlap_found = True
break
if overlap_found:
# Find new position
new_x, new_y = find_non_overlapping_position(
width, height, optimized_blocks, canvas_width, canvas_height
)
x, y = new_x, new_y
print(f"[Text Processor] Moved block {i} to avoid overlap: ({x}, {y})")
optimized_block = block.copy()
optimized_block.update({'x': x, 'y': y})
optimized_blocks.append(optimized_block)
print(f"[Text Processor] Layout optimization complete")
return optimized_blocks
except Exception as e:
print(f"[Text Processor] Error optimizing text layout: {e}")
import traceback
traceback.print_exc()
return text_blocks
def rectangles_overlap(rect1, rect2):
"""Check if two rectangles overlap"""
x1, y1, w1, h1 = rect1
x2, y2, w2, h2 = rect2
return not (x1 + w1 <= x2 or x2 + w2 <= x1 or y1 + h1 <= y2 or y2 + h2 <= y1)
def find_non_overlapping_position(width, height, existing_blocks, canvas_width, canvas_height):
"""Find a position that doesn't overlap with existing blocks"""
# Try positions from top-left, moving right then down
for y in range(0, canvas_height - height, 20):
for x in range(0, canvas_width - width, 20):
rect = (x, y, width, height)
overlap = False
for block in existing_blocks:
if rectangles_overlap(rect, (block['x'], block['y'], block['width'], block['height'])):
overlap = True
break
if not overlap:
return x, y
# If no position found, return original or default
return 0, 0
def validate_text_rendering(img, text_positions):
"""Validate that text rendering was successful"""
try:
print(f"[Text Processor] Validating text rendering for {len(text_positions)} positions")
validation_results = []
for i, pos in enumerate(text_positions):
result = {
'position_index': i,
'x': pos.get('x', 0),
'y': pos.get('y', 0),
'width': pos.get('width', 0),
'height': pos.get('height', 0),
'valid': True,
'issues': []
}
# Check bounds
if pos.get('x', 0) < 0 or pos.get('y', 0) < 0:
result['valid'] = False
result['issues'].append('Negative position')
if pos.get('x', 0) + pos.get('width', 0) > img.width:
result['valid'] = False
result['issues'].append('Exceeds image width')
if pos.get('y', 0) + pos.get('height', 0) > img.height:
result['valid'] = False
result['issues'].append('Exceeds image height')
# Check for zero dimensions
if pos.get('width', 0) == 0 or pos.get('height', 0) == 0:
result['valid'] = False
result['issues'].append('Zero dimensions')
validation_results.append(result)
valid_count = sum(1 for r in validation_results if r['valid'])
print(f"[Text Processor] Validation complete: {valid_count}/{len(text_positions)} positions valid")
return validation_results
except Exception as e:
print(f"[Text Processor] Error validating text rendering: {e}")
import traceback
traceback.print_exc()
return []

70
operators/__init__.py Normal file
View File

@@ -0,0 +1,70 @@
"""
Text Texture Generator - Operators Module
Contains all Blender operators for text texture generation and management.
"""
from .generation_ops import *
from .preset_ops import *
from .utility_ops import *
__all__ = []
# Import all operator classes and add to __all__
from .generation_ops import (
TEXT_TEXTURE_OT_generate,
TEXT_TEXTURE_OT_generate_shader,
)
from .preset_ops import (
TEXT_TEXTURE_OT_save_preset,
TEXT_TEXTURE_OT_save_preset_enter,
TEXT_TEXTURE_OT_load_preset,
TEXT_TEXTURE_OT_delete_preset,
TEXT_TEXTURE_OT_refresh_presets,
TEXT_TEXTURE_OT_export_presets,
TEXT_TEXTURE_OT_import_presets,
TEXT_TEXTURE_OT_show_migration_report,
)
from .utility_ops import (
TEXT_TEXTURE_OT_refresh_preview,
TEXT_TEXTURE_OT_view_preview_zoom,
TEXT_TEXTURE_OT_open_panel,
TEXT_TEXTURE_OT_refresh_fonts,
TEXT_TEXTURE_OT_add_overlay,
TEXT_TEXTURE_OT_remove_overlay,
TEXT_TEXTURE_OT_set_anchor_point,
TEXT_TEXTURE_OT_sync_margins,
TEXT_TEXTURE_OT_duplicate_overlay,
TEXT_TEXTURE_OT_delete_overlay,
TEXT_TEXTURE_OT_move_overlay,
)
__all__.extend([
# Generation operators
'TEXT_TEXTURE_OT_generate',
'TEXT_TEXTURE_OT_generate_shader',
# Preset operators
'TEXT_TEXTURE_OT_save_preset',
'TEXT_TEXTURE_OT_save_preset_enter',
'TEXT_TEXTURE_OT_load_preset',
'TEXT_TEXTURE_OT_delete_preset',
'TEXT_TEXTURE_OT_refresh_presets',
'TEXT_TEXTURE_OT_export_presets',
'TEXT_TEXTURE_OT_import_presets',
'TEXT_TEXTURE_OT_show_migration_report',
# Utility operators
'TEXT_TEXTURE_OT_refresh_preview',
'TEXT_TEXTURE_OT_view_preview_zoom',
'TEXT_TEXTURE_OT_open_panel',
'TEXT_TEXTURE_OT_refresh_fonts',
'TEXT_TEXTURE_OT_add_overlay',
'TEXT_TEXTURE_OT_remove_overlay',
'TEXT_TEXTURE_OT_set_anchor_point',
'TEXT_TEXTURE_OT_sync_margins',
'TEXT_TEXTURE_OT_duplicate_overlay',
'TEXT_TEXTURE_OT_delete_overlay',
'TEXT_TEXTURE_OT_move_overlay',
])

Binary file not shown.

595
operators/generation_ops.py Normal file
View File

@@ -0,0 +1,595 @@
"""
Generation operators for text texture creation.
Contains operators for generating textures and complete shaders.
"""
import bpy
from bpy.types import Operator
import os
import json
# Import required functions from other modules
from ..core.generation_engine import generate_texture_image, generate_preview
class TEXT_TEXTURE_OT_generate(Operator):
"""Generate final texture and pack it"""
bl_idname = "text_texture.generate"
bl_label = "Generate Text Texture"
bl_description = "Generate final high-quality texture and pack it into the blend file"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
try:
props = context.scene.text_texture_props
# IMPORTANT: Generate at EXACT user-specified dimensions
final_width = props.texture_width
final_height = props.texture_height
print(f"Generating texture at {final_width}x{final_height}px")
# Generate at FULL resolution specified by user
result = generate_texture_image(props, final_width, final_height)
if not result:
self.report({'ERROR'}, "Failed to generate texture image")
return {'CANCELLED'}
# Extract diffuse and normal map images
if isinstance(result, tuple):
img, normal_map_img = result
else:
# Backward compatibility
img = result
normal_map_img = None
if not img:
self.report({'ERROR'}, "Failed to generate diffuse image")
return {'CANCELLED'}
# Create diffuse texture
img_name = f"TextTexture_{props.text[:20].replace(' ', '_')}"
# Remove existing diffuse texture
if img_name in bpy.data.images:
bpy.data.images.remove(bpy.data.images[img_name])
# Create new diffuse image with USER SPECIFIED dimensions
blender_img = bpy.data.images.new(img_name, final_width, final_height, alpha=True)
# Convert PIL to Blender for diffuse texture
pixels = []
for y in range(final_height):
for x in range(final_width):
r, g, b, a = img.getpixel((x, final_height - 1 - y))
pixels.extend([r/255.0, g/255.0, b/255.0, a/255.0])
blender_img.pixels = pixels
blender_img.pack() # PACK FINAL IMAGE
# Create normal map texture if enabled and generated
normal_map_blender_img = None
if normal_map_img and props.generate_normal_map:
normal_map_name = f"TextTexture_Normal_{props.text[:20].replace(' ', '_')}"
# Remove existing normal map texture
if normal_map_name in bpy.data.images:
bpy.data.images.remove(bpy.data.images[normal_map_name])
# Create new normal map image
normal_map_blender_img = bpy.data.images.new(normal_map_name, final_width, final_height, alpha=False)
normal_map_blender_img.colorspace_settings.name = 'Non-Color' # Important for normal maps
# Convert PIL to Blender for normal map
normal_pixels = []
for y in range(final_height):
for x in range(final_width):
r, g, b = normal_map_img.getpixel((x, final_height - 1 - y))
normal_pixels.extend([r/255.0, g/255.0, b/255.0, 1.0]) # Alpha always 1.0 for normal maps
normal_map_blender_img.pixels = normal_pixels
normal_map_blender_img.pack() # PACK NORMAL MAP
print(f"[Normal Map] Created and packed normal map texture: {normal_map_name}")
# Add to shader
if context.space_data and context.space_data.type == 'NODE_EDITOR':
if context.space_data.tree_type == 'ShaderNodeTree':
material = context.space_data.id
if material and material.use_nodes:
nodes = material.node_tree.nodes
# Add diffuse texture node
img_node = nodes.new(type='ShaderNodeTexImage')
img_node.image = blender_img
img_node.location = (0, 0)
img_node.select = True
nodes.active = img_node
# Add normal map node if normal map was created
if normal_map_blender_img:
normal_node = nodes.new(type='ShaderNodeTexImage')
normal_node.image = normal_map_blender_img
normal_node.location = (0, -300)
normal_node.label = "Normal Map"
print(f"[Normal Map] Added normal map node to shader editor")
# Provide comprehensive feedback
if normal_map_blender_img:
self.report({'INFO'}, f"Generated and packed: {img_name} + {normal_map_blender_img.name} ({final_width}x{final_height}px)")
else:
self.report({'INFO'}, f"Generated and packed: {img_name} ({final_width}x{final_height}px)")
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, f"Failed: {str(e)}")
return {'CANCELLED'}
class TEXT_TEXTURE_OT_generate_shader(Operator):
"""Generate complete shader with text texture"""
bl_idname = "text_texture.generate_shader"
bl_label = "Generate Shader"
bl_description = "Generate a complete material shader with the text texture"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
try:
props = context.scene.text_texture_props
# Generate texture first
final_width = props.texture_width
final_height = props.texture_height
print(f"Generating shader with texture at {final_width}x{final_height}px")
result = generate_texture_image(props, final_width, final_height)
if not result:
self.report({'ERROR'}, "Failed to generate texture image")
return {'CANCELLED'}
# Extract diffuse and normal map images
if isinstance(result, tuple):
img, normal_map_img = result
else:
# Backward compatibility
img = result
normal_map_img = None
if not img:
self.report({'ERROR'}, "Failed to generate diffuse image")
return {'CANCELLED'}
# Create diffuse texture name
img_name = f"TextTexture_{props.text[:20].replace(' ', '_')}"
# Remove existing diffuse texture if it exists
if img_name in bpy.data.images:
bpy.data.images.remove(bpy.data.images[img_name])
# Create new diffuse texture
blender_img = bpy.data.images.new(img_name, final_width, final_height, alpha=True)
# Convert PIL to Blender for diffuse texture
pixels = []
for y in range(final_height):
for x in range(final_width):
r, g, b, a = img.getpixel((x, final_height - 1 - y))
pixels.extend([r/255.0, g/255.0, b/255.0, a/255.0])
blender_img.pixels = pixels
blender_img.pack()
# Create normal map texture if enabled and generated
normal_map_blender_img = None
if normal_map_img and props.generate_normal_map:
normal_map_name = f"TextTexture_Normal_{props.text[:20].replace(' ', '_')}"
# Remove existing normal map texture
if normal_map_name in bpy.data.images:
bpy.data.images.remove(bpy.data.images[normal_map_name])
# Create new normal map image
normal_map_blender_img = bpy.data.images.new(normal_map_name, final_width, final_height, alpha=False)
normal_map_blender_img.colorspace_settings.name = 'Non-Color' # Important for normal maps
# Convert PIL to Blender for normal map
normal_pixels = []
for y in range(final_height):
for x in range(final_width):
r, g, b = normal_map_img.getpixel((x, final_height - 1 - y))
normal_pixels.extend([r/255.0, g/255.0, b/255.0, 1.0]) # Alpha always 1.0 for normal maps
normal_map_blender_img.pixels = normal_pixels
normal_map_blender_img.pack()
print(f"[Normal Map] Created normal map texture for shader: {normal_map_name}")
# Create or get material using custom name or fallback
if props.custom_material_name.strip():
material_name = props.custom_material_name.strip()
else:
# Fallback to text-based naming
clean_text = props.text[:15].replace(' ', '_').replace('\n', '_')
material_name = f"TextTexture_Mat_{clean_text}"
if material_name in bpy.data.materials:
mat = bpy.data.materials[material_name]
# Clear existing nodes
mat.node_tree.nodes.clear()
else:
mat = bpy.data.materials.new(name=material_name)
mat.use_nodes = True
mat.node_tree.nodes.clear()
# Set up shader nodes
nodes = mat.node_tree.nodes
links = mat.node_tree.links
# Create nodes
output_node = nodes.new(type='ShaderNodeOutputMaterial')
output_node.location = (400, 0)
# Shader type based on settings
if props.shader_type == 'PRINCIPLED':
shader_node = nodes.new(type='ShaderNodeBsdfPrincipled')
shader_node.location = (200, 0)
# Create diffuse texture node
tex_node = nodes.new(type='ShaderNodeTexImage')
tex_node.image = blender_img
tex_node.location = (-200, 0)
# Connect texture to shader
if props.shader_connection == 'BASE_COLOR':
links.new(tex_node.outputs['Color'], shader_node.inputs['Base Color'])
elif props.shader_connection == 'EMISSION':
links.new(tex_node.outputs['Color'], shader_node.inputs['Emission Color'])
shader_node.inputs['Emission Strength'].default_value = props.emission_strength
elif props.shader_connection == 'ALPHA':
links.new(tex_node.outputs['Alpha'], shader_node.inputs['Alpha'])
# Also connect color for visibility
links.new(tex_node.outputs['Color'], shader_node.inputs['Base Color'])
mat.blend_method = 'BLEND'
# Connect alpha if enabled
if props.use_alpha:
links.new(tex_node.outputs['Alpha'], shader_node.inputs['Alpha'])
mat.blend_method = 'BLEND'
# Add normal map nodes if normal map was generated
if normal_map_blender_img:
# Create normal map texture node
normal_tex_node = nodes.new(type='ShaderNodeTexImage')
normal_tex_node.image = normal_map_blender_img
normal_tex_node.location = (-200, -300)
normal_tex_node.label = "Normal Map"
# Create normal map node
normal_map_node = nodes.new(type='ShaderNodeNormalMap')
normal_map_node.location = (0, -300)
# Connect normal map texture to normal map node
links.new(normal_tex_node.outputs['Color'], normal_map_node.inputs['Color'])
# Connect normal map node to shader
links.new(normal_map_node.outputs['Normal'], shader_node.inputs['Normal'])
print(f"[Normal Map] Added normal map nodes to Principled BSDF shader")
# Handle shadow/reflection disabling
current_shader = shader_node
final_x_location = 400
# Disable shadows if enabled
if props.disable_shadows:
# Create light path node
light_path_node = nodes.new(type='ShaderNodeLightPath')
light_path_node.location = (50, 150)
# Create transparent BSDF
transparent_node = nodes.new(type='ShaderNodeBsdfTransparent')
transparent_node.location = (200, 150)
# Create mix shader for shadows
shadow_mix_node = nodes.new(type='ShaderNodeMixShader')
shadow_mix_node.location = (350, 50)
# Connect light path "Is Shadow Ray" to mix factor
links.new(light_path_node.outputs['Is Shadow Ray'], shadow_mix_node.inputs['Fac'])
# Mix principled BSDF with transparent BSDF
links.new(current_shader.outputs['BSDF'], shadow_mix_node.inputs[1]) # Shader 1 (not shadow)
links.new(transparent_node.outputs['BSDF'], shadow_mix_node.inputs[2]) # Shader 2 (shadow)
current_shader = shadow_mix_node
final_x_location = 500
# Disable reflections if enabled
if props.disable_reflections:
# Create or reuse light path node
if not props.disable_shadows:
light_path_node = nodes.new(type='ShaderNodeLightPath')
light_path_node.location = (50, 200)
# Create transparent BSDF for reflections
reflection_transparent_node = nodes.new(type='ShaderNodeBsdfTransparent')
reflection_transparent_node.location = (200, 200)
# Create mix shader for reflections
reflection_mix_node = nodes.new(type='ShaderNodeMixShader')
reflection_mix_node.location = (final_x_location - 50, 100)
# Connect light path "Is Reflection Ray" to mix factor
links.new(light_path_node.outputs['Is Reflection Ray'], reflection_mix_node.inputs['Fac'])
# Mix current shader with transparent BSDF
links.new(current_shader.outputs['Shader' if props.disable_shadows else 'BSDF'], reflection_mix_node.inputs[1]) # Shader 1 (not reflection)
links.new(reflection_transparent_node.outputs['BSDF'], reflection_mix_node.inputs[2]) # Shader 2 (reflection)
current_shader = reflection_mix_node
final_x_location = final_x_location + 50
# Disable backfacing if enabled
if props.disable_backfacing:
# Create or reuse light path node
if not props.disable_shadows and not props.disable_reflections:
light_path_node = nodes.new(type='ShaderNodeLightPath')
light_path_node.location = (50, 250)
# Create geometry node for backfacing detection
geometry_node = nodes.new(type='ShaderNodeNewGeometry')
geometry_node.location = (50, 300)
# Create transparent BSDF for backfaces
backface_transparent_node = nodes.new(type='ShaderNodeBsdfTransparent')
backface_transparent_node.location = (200, 250)
# Create mix shader for backfaces
backface_mix_node = nodes.new(type='ShaderNodeMixShader')
backface_mix_node.location = (final_x_location - 50, 150)
# Connect geometry "Backfacing" to mix factor
links.new(geometry_node.outputs['Backfacing'], backface_mix_node.inputs['Fac'])
# Mix current shader with transparent BSDF
current_output = 'Shader' if (props.disable_shadows or props.disable_reflections) else 'BSDF'
links.new(current_shader.outputs[current_output], backface_mix_node.inputs[1]) # Shader 1 (front face)
links.new(backface_transparent_node.outputs['BSDF'], backface_mix_node.inputs[2]) # Shader 2 (backface)
current_shader = backface_mix_node
final_x_location = final_x_location + 50
# Update output node position
output_node.location = (final_x_location, 0)
# Connect final shader to output
output_socket = 'Shader' if (props.disable_shadows or props.disable_reflections) else 'BSDF'
links.new(current_shader.outputs[output_socket], output_node.inputs['Surface'])
elif props.shader_type == 'EMISSION':
shader_node = nodes.new(type='ShaderNodeEmission')
shader_node.location = (200, 0)
# Create texture node
tex_node = nodes.new(type='ShaderNodeTexImage')
tex_node.image = blender_img
tex_node.location = (-200, 0)
# Connect texture to emission
links.new(tex_node.outputs['Color'], shader_node.inputs['Color'])
shader_node.inputs['Strength'].default_value = props.emission_strength
# Handle shadow/reflection disabling
current_shader = shader_node
final_x_location = 400
# Disable shadows if enabled
if props.disable_shadows:
# Create light path node
light_path_node = nodes.new(type='ShaderNodeLightPath')
light_path_node.location = (50, 150)
# Create transparent BSDF
transparent_node = nodes.new(type='ShaderNodeBsdfTransparent')
transparent_node.location = (200, 150)
# Create mix shader for shadows
shadow_mix_node = nodes.new(type='ShaderNodeMixShader')
shadow_mix_node.location = (350, 50)
# Connect light path "Is Shadow Ray" to mix factor
links.new(light_path_node.outputs['Is Shadow Ray'], shadow_mix_node.inputs['Fac'])
# Mix emission with transparent BSDF
links.new(current_shader.outputs['Emission'], shadow_mix_node.inputs[1]) # Shader 1 (not shadow)
links.new(transparent_node.outputs['BSDF'], shadow_mix_node.inputs[2]) # Shader 2 (shadow)
current_shader = shadow_mix_node
final_x_location = 500
# Disable reflections if enabled
if props.disable_reflections:
# Create or reuse light path node
if not props.disable_shadows:
light_path_node = nodes.new(type='ShaderNodeLightPath')
light_path_node.location = (50, 200)
# Create transparent BSDF for reflections
reflection_transparent_node = nodes.new(type='ShaderNodeBsdfTransparent')
reflection_transparent_node.location = (200, 200)
# Create mix shader for reflections
reflection_mix_node = nodes.new(type='ShaderNodeMixShader')
reflection_mix_node.location = (final_x_location - 50, 100)
# Connect light path "Is Reflection Ray" to mix factor
links.new(light_path_node.outputs['Is Reflection Ray'], reflection_mix_node.inputs['Fac'])
# Mix current shader with transparent BSDF
links.new(current_shader.outputs['Shader' if props.disable_shadows else 'Emission'], reflection_mix_node.inputs[1]) # Shader 1 (not reflection)
links.new(reflection_transparent_node.outputs['BSDF'], reflection_mix_node.inputs[2]) # Shader 2 (reflection)
current_shader = reflection_mix_node
final_x_location = final_x_location + 50
# Update output node position
output_node.location = (final_x_location, 0)
# Connect final shader to output
output_socket = 'Shader' if (props.disable_shadows or props.disable_reflections) else 'Emission'
links.new(current_shader.outputs[output_socket], output_node.inputs['Surface'])
elif props.shader_type == 'TRANSPARENT':
shader_node = nodes.new(type='ShaderNodeBsdfTransparent')
mix_node = nodes.new(type='ShaderNodeMixShader')
principled_node = nodes.new(type='ShaderNodeBsdfPrincipled')
shader_node.location = (200, 100)
mix_node.location = (350, 0)
principled_node.location = (200, -100)
# Create texture node
tex_node = nodes.new(type='ShaderNodeTexImage')
tex_node.image = blender_img
tex_node.location = (-200, 0)
# Connect texture
links.new(tex_node.outputs['Color'], principled_node.inputs['Base Color'])
links.new(tex_node.outputs['Alpha'], mix_node.inputs['Fac'])
# Connect shaders to mix
links.new(shader_node.outputs['BSDF'], mix_node.inputs[1])
links.new(principled_node.outputs['BSDF'], mix_node.inputs[2])
# Handle shadow/reflection disabling
current_shader = mix_node
final_x_location = 500
# Disable shadows if enabled
if props.disable_shadows:
# Create light path node
light_path_node = nodes.new(type='ShaderNodeLightPath')
light_path_node.location = (50, 200)
# Create transparent BSDF for shadows (reuse existing transparent node)
shadow_transparent_node = nodes.new(type='ShaderNodeBsdfTransparent')
shadow_transparent_node.location = (350, 150)
# Create mix shader for shadows
shadow_mix_node = nodes.new(type='ShaderNodeMixShader')
shadow_mix_node.location = (500, 50)
# Connect light path "Is Shadow Ray" to mix factor
links.new(light_path_node.outputs['Is Shadow Ray'], shadow_mix_node.inputs['Fac'])
# Mix current shader with transparent BSDF
links.new(current_shader.outputs['Shader'], shadow_mix_node.inputs[1]) # Shader 1 (not shadow)
links.new(shadow_transparent_node.outputs['BSDF'], shadow_mix_node.inputs[2]) # Shader 2 (shadow)
current_shader = shadow_mix_node
final_x_location = 650
# Disable reflections if enabled
if props.disable_reflections:
# Create or reuse light path node
if not props.disable_shadows:
light_path_node = nodes.new(type='ShaderNodeLightPath')
light_path_node.location = (50, 250)
# Create transparent BSDF for reflections
reflection_transparent_node = nodes.new(type='ShaderNodeBsdfTransparent')
reflection_transparent_node.location = (350, 200)
# Create mix shader for reflections
reflection_mix_node = nodes.new(type='ShaderNodeMixShader')
reflection_mix_node.location = (final_x_location - 50, 100)
# Connect light path "Is Reflection Ray" to mix factor
links.new(light_path_node.outputs['Is Reflection Ray'], reflection_mix_node.inputs['Fac'])
# Mix current shader with transparent BSDF
links.new(current_shader.outputs['Shader'], reflection_mix_node.inputs[1]) # Shader 1 (not reflection)
links.new(reflection_transparent_node.outputs['BSDF'], reflection_mix_node.inputs[2]) # Shader 2 (reflection)
current_shader = reflection_mix_node
final_x_location = final_x_location + 50
# Disable backfacing if enabled
if props.disable_backfacing:
# Create or reuse light path node
if not props.disable_shadows and not props.disable_reflections:
light_path_node = nodes.new(type='ShaderNodeLightPath')
light_path_node.location = (50, 300)
# Create geometry node for backfacing detection
geometry_node = nodes.new(type='ShaderNodeNewGeometry')
geometry_node.location = (50, 350)
# Create transparent BSDF for backfaces
backface_transparent_node = nodes.new(type='ShaderNodeBsdfTransparent')
backface_transparent_node.location = (350, 300)
# Create mix shader for backfaces
backface_mix_node = nodes.new(type='ShaderNodeMixShader')
backface_mix_node.location = (final_x_location + 50, 150)
# Connect geometry "Backfacing" to mix factor
links.new(geometry_node.outputs['Backfacing'], backface_mix_node.inputs['Fac'])
# Mix current shader with transparent BSDF
links.new(current_shader.outputs['Shader'], backface_mix_node.inputs[1]) # Shader 1 (front face)
links.new(backface_transparent_node.outputs['BSDF'], backface_mix_node.inputs[2]) # Shader 2 (backface)
current_shader = backface_mix_node
final_x_location = final_x_location + 150
# Update output node position
output_node.location = (final_x_location, 0)
# Connect final shader to output
links.new(current_shader.outputs['Shader'], output_node.inputs['Surface'])
mat.blend_method = 'BLEND'
# Assign material to active object if enabled and possible
if props.auto_assign_material and context.active_object and context.active_object.type in ['MESH', 'CURVE']:
obj = context.active_object
# Check if material slot exists, reuse if found, otherwise create new one
slot_found = False
for i, slot in enumerate(obj.material_slots):
if slot.material and slot.material.name == material_name:
# Material already exists in a slot, update it
obj.material_slots[i].material = mat
slot_found = True
break
if not slot_found:
# Create new material slot if not found
obj.data.materials.append(mat)
self.report({'INFO'}, f"Generated shader '{material_name}' and assigned to {obj.name}")
else:
self.report({'INFO'}, f"Generated shader '{material_name}' (assign manually to object)")
# Switch to Shader Editor if available and set material
for area in context.screen.areas:
if area.type == 'NODE_EDITOR':
for space in area.spaces:
if space.type == 'NODE_EDITOR':
space.shader_type = 'OBJECT'
space.id = mat
area.tag_redraw()
break
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, f"Shader generation failed: {str(e)}")
import traceback
traceback.print_exc()
return {'CANCELLED'}
# Export all operator classes for wildcard imports
__all__ = [
'TEXT_TEXTURE_OT_generate',
'TEXT_TEXTURE_OT_generate_shader',
]

893
operators/preset_ops.py Normal file
View File

@@ -0,0 +1,893 @@
import bpy
from bpy.types import Operator
from bpy.props import StringProperty, BoolProperty, EnumProperty
import os
import json
import time
# Import utility functions from main module
from ..presets import (
get_preset_path, get_persistent_preset_path, get_addon_version,
get_blend_file_presets, save_blend_file_preset, delete_blend_file_preset,
refresh_presets_sync, ensure_presets_available
)
from ..utils import bl_info
class TEXT_TEXTURE_OT_save_preset(Operator):
"""Save current settings as preset"""
bl_idname = "text_texture.save_preset"
bl_label = "Save Preset"
bl_description = "Save current settings as preset"
bl_options = {'REGISTER', 'UNDO'}
overwrite: bpy.props.BoolProperty(
name="Overwrite",
description="Overwrite existing preset",
default=False
)
preset_name: bpy.props.StringProperty(
name="Preset Name",
description="Name of preset to save/overwrite",
default=""
)
def execute(self, context):
props = context.scene.text_texture_props
# Use provided preset name or fallback to input field
preset_name = self.preset_name.strip() if self.preset_name else props.new_preset_name.strip()
if not preset_name:
self.report({'ERROR'}, "Please enter a preset name")
return {'CANCELLED'}
# When overwriting from button, update the input field for consistency
if self.preset_name and self.preset_name.strip():
props.new_preset_name = preset_name
# Validate preset name characters (avoid filesystem issues)
import re
# Allow Unicode letters, numbers, spaces, hyphens, underscores, and common accented characters
# This pattern supports international characters like ö, ü, ñ, etc.
if not re.match(r'^[\w\s\-]+$', preset_name, re.UNICODE):
self.report({'ERROR'}, "Preset name contains invalid characters for filesystem compatibility")
return {'CANCELLED'}
# Additional check for filesystem-unsafe characters
invalid_chars = ['<', '>', ':', '"', '|', '?', '*', '/', '\\']
if any(char in preset_name for char in invalid_chars):
self.report({'ERROR'}, f"Preset name cannot contain: {' '.join(invalid_chars)}")
return {'CANCELLED'}
# Check for duplicate names
preset_file = os.path.join(get_preset_path(), f"{preset_name}.json")
if os.path.exists(preset_file) and not self.overwrite:
# Suggest alternative names
base_name = preset_name
counter = 1
suggested_name = f"{base_name}_{counter}"
while os.path.exists(os.path.join(get_preset_path(), f"{suggested_name}.json")):
counter += 1
suggested_name = f"{base_name}_{counter}"
self.report({'ERROR'}, f"Preset '{preset_name}' already exists. Suggestion: try '{suggested_name}' or delete the existing preset first.")
return {'CANCELLED'}
# DETAILED LOGGING: Current shader properties before saving
print(f"[PRESET SAVE DEBUG] ==================== SAVING PRESET: '{preset_name}' ====================")
print(f"[PRESET SAVE DEBUG] Current shader properties from props:")
print(f"[PRESET SAVE DEBUG] disable_shadows: {props.disable_shadows}")
print(f"[PRESET SAVE DEBUG] disable_reflections: {props.disable_reflections}")
print(f"[PRESET SAVE DEBUG] disable_backfacing: {props.disable_backfacing}")
print(f"[PRESET SAVE DEBUG] shader_type: {props.shader_type}")
print(f"[PRESET SAVE DEBUG] shader_connection: {props.shader_connection}")
print(f"[PRESET SAVE DEBUG] use_alpha: {props.use_alpha}")
print(f"[PRESET SAVE DEBUG] emission_strength: {props.emission_strength}")
print(f"[PRESET SAVE DEBUG] auto_assign_material: {props.auto_assign_material}")
preset_data = {
'text': props.text,
'texture_width': props.texture_width,
'texture_height': props.texture_height,
'font_size': props.font_size,
'padding': props.padding,
'text_color': list(props.text_color),
'background_transparent': props.background_transparent,
'background_color': list(props.background_color),
'font_path': props.font_path,
'custom_font_path': props.custom_font_path,
'use_custom_font': props.use_custom_font,
'text_align': props.text_align,
'prepend_text': props.prepend_text,
'append_text': props.append_text,
'prepend_margin': props.prepend_margin,
'append_margin': props.append_margin,
'prepend_append_layout': props.prepend_append_layout,
'shader_type': props.shader_type,
'shader_connection': props.shader_connection,
'use_alpha': props.use_alpha,
'emission_strength': props.emission_strength,
'auto_assign_material': props.auto_assign_material,
'disable_shadows': props.disable_shadows,
'disable_reflections': props.disable_reflections,
'disable_backfacing': props.disable_backfacing,
'generate_normal_map': props.generate_normal_map,
'normal_map_strength': props.normal_map_strength,
'normal_map_blur_radius': props.normal_map_blur_radius,
'normal_map_invert': props.normal_map_invert,
'enable_multiline': props.enable_multiline,
'line_height': props.line_height,
'text_alignment': props.text_alignment,
'auto_wrap': props.auto_wrap,
'wrap_width': props.wrap_width,
'max_lines': props.max_lines,
'line_spacing_pixels': props.line_spacing_pixels,
'enable_stroke': props.enable_stroke,
'stroke_width': props.stroke_width,
'stroke_position': props.stroke_position,
'stroke_color': list(props.stroke_color),
'enable_shadow': props.enable_shadow,
'shadow_offset_x': props.shadow_offset_x,
'shadow_offset_y': props.shadow_offset_y,
'shadow_blur': props.shadow_blur,
'shadow_color': list(props.shadow_color),
'shadow_spread': getattr(props, 'shadow_spread', 1.0),
'enable_glow': props.enable_glow,
'glow_blur': props.glow_blur,
'glow_color': list(props.glow_color),
'enable_blur': props.enable_blur,
'blur_radius': props.blur_radius,
'image_overlays': []
}
# DETAILED LOGGING: Verify shader properties in preset_data
print(f"[PRESET SAVE DEBUG] Shader properties in preset_data dictionary:")
print(f"[PRESET SAVE DEBUG] disable_shadows: {preset_data.get('disable_shadows', 'NOT FOUND')}")
print(f"[PRESET SAVE DEBUG] disable_reflections: {preset_data.get('disable_reflections', 'NOT FOUND')}")
print(f"[PRESET SAVE DEBUG] disable_backfacing: {preset_data.get('disable_backfacing', 'NOT FOUND')}")
# Save overlay data
for overlay in props.image_overlays:
overlay_data = {
'name': overlay.name,
'image_path': overlay.image_path,
'x_position': overlay.x_position,
'y_position': overlay.y_position,
'scale': overlay.scale,
'rotation': overlay.rotation,
'z_index': overlay.z_index,
'enabled': overlay.enabled
}
preset_data['image_overlays'].append(overlay_data)
try:
# Primary: Save to blend file (new system)
print(f"[PRESET SAVE DEBUG] Attempting to save to blend file...")
blend_success = save_blend_file_preset(preset_name, preset_data)
print(f"[PRESET SAVE DEBUG] Blend file save result: {blend_success}")
# ALWAYS save to persistent storage for cross-blend-file sharing and addon update survival
persistent_file = os.path.join(get_persistent_preset_path(), f"{preset_name}.json")
print(f"[PRESET SAVE DEBUG] Attempting to save to persistent file: {persistent_file}")
persistent_success = True
try:
with open(persistent_file, 'w') as f:
json.dump(preset_data, f, indent=2)
print(f"[PRESET SAVE DEBUG] Successfully saved preset '{preset_name}' to persistent storage")
# VERIFY: Read back the persistent file to confirm shader properties were saved
print(f"[PRESET SAVE DEBUG] Verifying persistent file contents...")
with open(persistent_file, 'r') as f:
saved_data = json.load(f)
print(f"[PRESET SAVE DEBUG] Persistent file shader properties verification:")
print(f"[PRESET SAVE DEBUG] disable_shadows: {saved_data.get('disable_shadows', 'NOT FOUND')}")
print(f"[PRESET SAVE DEBUG] disable_reflections: {saved_data.get('disable_reflections', 'NOT FOUND')}")
print(f"[PRESET SAVE DEBUG] disable_backfacing: {saved_data.get('disable_backfacing', 'NOT FOUND')}")
except Exception as persistent_error:
print(f"[PRESET SAVE DEBUG] Warning: Failed to save persistent preset file: {persistent_error}")
persistent_success = False
# Also save to legacy location for backward compatibility (optional)
legacy_success = True
try:
legacy_file = os.path.join(get_preset_path(), f"{preset_name}.json")
with open(legacy_file, 'w') as f:
json.dump(preset_data, f, indent=2)
print(f"[PRESET SAVE DEBUG] Also saved to legacy location for backward compatibility")
except Exception as legacy_error:
print(f"[PRESET SAVE DEBUG] Note: Could not save to legacy location: {legacy_error}")
legacy_success = False
if not blend_success and not persistent_success:
self.report({'ERROR'}, f"Failed to save preset to both blend file and persistent storage")
return {'CANCELLED'}
# Check if preset already exists in the collection and update it
existing_preset = None
for preset in props.presets:
if preset.name == preset_name:
existing_preset = preset
break
if not existing_preset:
preset = props.presets.add()
preset.name = preset_name
# Priority: Blend file > Persistent file
preset.source = 'BLEND_FILE' if blend_success else 'PERSISTENT_FILE'
else:
# Update source based on where it was successfully saved
existing_preset.source = 'BLEND_FILE' if blend_success else 'PERSISTENT_FILE'
# Don't clear the input field - keep the name for easy re-saving
# props.new_preset_name = "New Preset"
# Provide clear feedback
storage_info = ""
if blend_success and persistent_success:
storage_info = " (saved to blend file + persistent storage)"
elif blend_success:
storage_info = " (saved to blend file)"
elif persistent_success:
storage_info = " (saved to persistent storage)"
if self.overwrite:
self.report({'INFO'}, f"✅ Preset '{preset_name}' updated successfully{storage_info}")
else:
self.report({'INFO'}, f"✅ Preset '{preset_name}' saved successfully{storage_info}")
except (OSError, IOError, json.JSONEncodeError) as e:
self.report({'ERROR'}, f"Failed to save preset: {str(e)}")
return {'CANCELLED'}
except Exception as e:
self.report({'ERROR'}, f"Unexpected error saving preset: {str(e)}")
return {'CANCELLED'}
return {'FINISHED'}
def invoke(self, context, event):
# Check if preset exists and prompt for overwrite
props = context.scene.text_texture_props
preset_name = props.new_preset_name.strip()
if preset_name and preset_name != "New Preset":
preset_file = os.path.join(get_preset_path(), f"{preset_name}.json")
if os.path.exists(preset_file):
return context.window_manager.invoke_confirm(self, event)
return self.execute(context)
def draw(self, context):
layout = self.layout
props = context.scene.text_texture_props
preset_name = props.new_preset_name.strip()
layout.label(text=f"Overwrite existing preset '{preset_name}'?")
class TEXT_TEXTURE_OT_save_preset_enter(Operator):
"""Save preset when Enter key is pressed in name field"""
bl_idname = "text_texture.save_preset_enter"
bl_label = "Save Preset (Enter)"
bl_description = "Save current settings as preset when Enter is pressed"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
# Delegate to the main save preset operator
return bpy.ops.text_texture.save_preset('INVOKE_DEFAULT')
class TEXT_TEXTURE_OT_load_preset(Operator):
"""Load preset"""
bl_idname = "text_texture.load_preset"
bl_label = "Load Preset"
bl_description = "Load selected preset"
bl_options = {'REGISTER', 'UNDO'}
preset_name: StringProperty()
def execute(self, context):
props = context.scene.text_texture_props
# DEFENSIVE PROGRAMMING: Ensure presets are available before loading
if not ensure_presets_available():
self.report({'ERROR'}, "Failed to ensure presets are loaded - cannot load preset")
return {'CANCELLED'}
# DETAILED LOGGING: Current shader properties before loading
print(f"[PRESET LOAD DEBUG] ==================== LOADING PRESET: '{self.preset_name}' ====================")
print(f"[PRESET LOAD DEBUG] Current shader properties BEFORE loading:")
print(f"[PRESET LOAD DEBUG] disable_shadows: {props.disable_shadows}")
print(f"[PRESET LOAD DEBUG] disable_reflections: {props.disable_reflections}")
print(f"[PRESET LOAD DEBUG] disable_backfacing: {props.disable_backfacing}")
print(f"[PRESET LOAD DEBUG] shader_type: {props.shader_type}")
print(f"[PRESET LOAD DEBUG] shader_connection: {props.shader_connection}")
try:
# Three-tier loading priority: Blend File > Persistent File > Legacy File
blend_presets = get_blend_file_presets()
preset_data = None
source = 'BLEND_FILE'
print(f"[PRESET LOAD DEBUG] Available blend file presets: {list(blend_presets.keys())}")
if self.preset_name in blend_presets:
preset_data = blend_presets[self.preset_name]
source = 'BLEND_FILE'
print(f"[PRESET LOAD DEBUG] Loading preset '{self.preset_name}' from blend file")
print(f"[PRESET LOAD DEBUG] Blend file preset shader properties:")
print(f"[PRESET LOAD DEBUG] disable_shadows: {preset_data.get('disable_shadows', 'NOT FOUND')}")
print(f"[PRESET LOAD DEBUG] disable_reflections: {preset_data.get('disable_reflections', 'NOT FOUND')}")
print(f"[PRESET LOAD DEBUG] disable_backfacing: {preset_data.get('disable_backfacing', 'NOT FOUND')}")
else:
# Try persistent storage next
persistent_file = os.path.join(get_persistent_preset_path(), f"{self.preset_name}.json")
print(f"[PRESET LOAD DEBUG] Preset not in blend file, trying persistent file: {persistent_file}")
if os.path.exists(persistent_file):
with open(persistent_file, 'r') as f:
preset_data = json.load(f)
source = 'PERSISTENT_FILE'
print(f"[PRESET LOAD DEBUG] Loading preset '{self.preset_name}' from persistent file")
print(f"[PRESET LOAD DEBUG] Persistent file preset shader properties:")
print(f"[PRESET LOAD DEBUG] disable_shadows: {preset_data.get('disable_shadows', 'NOT FOUND')}")
print(f"[PRESET LOAD DEBUG] disable_reflections: {preset_data.get('disable_reflections', 'NOT FOUND')}")
print(f"[PRESET LOAD DEBUG] disable_backfacing: {preset_data.get('disable_backfacing', 'NOT FOUND')}")
else:
# Fallback to legacy local file
legacy_file = os.path.join(get_preset_path(), f"{self.preset_name}.json")
print(f"[PRESET LOAD DEBUG] Preset not in persistent storage, trying legacy file: {legacy_file}")
if os.path.exists(legacy_file):
with open(legacy_file, 'r') as f:
preset_data = json.load(f)
source = 'LOCAL_FILE'
print(f"[PRESET LOAD DEBUG] Loading preset '{self.preset_name}' from legacy file")
print(f"[PRESET LOAD DEBUG] Legacy file preset shader properties:")
print(f"[PRESET LOAD DEBUG] disable_shadows: {preset_data.get('disable_shadows', 'NOT FOUND')}")
print(f"[PRESET LOAD DEBUG] disable_reflections: {preset_data.get('disable_reflections', 'NOT FOUND')}")
print(f"[PRESET LOAD DEBUG] disable_backfacing: {preset_data.get('disable_backfacing', 'NOT FOUND')}")
else:
print(f"[PRESET LOAD DEBUG] Legacy file does not exist: {legacy_file}")
self.report({'ERROR'}, f"Preset not found in any storage location: {self.preset_name}")
return {'CANCELLED'}
if not preset_data:
print(f"[PRESET LOAD DEBUG] No preset data found!")
self.report({'ERROR'}, f"No data found for preset: {self.preset_name}")
return {'CANCELLED'}
props.is_updating = True
# Skip loading text to prevent overwriting current text
# props.text = preset_data.get('text', props.text)
props.texture_width = preset_data.get('texture_width', props.texture_width)
props.texture_height = preset_data.get('texture_height', props.texture_height)
props.font_size = preset_data.get('font_size', props.font_size)
props.padding = preset_data.get('padding', props.padding)
props.text_color = preset_data.get('text_color', props.text_color)
props.background_transparent = preset_data.get('background_transparent', props.background_transparent)
props.background_color = preset_data.get('background_color', props.background_color)
props.font_path = preset_data.get('font_path', props.font_path)
props.custom_font_path = preset_data.get('custom_font_path', props.custom_font_path)
props.use_custom_font = preset_data.get('use_custom_font', props.use_custom_font)
props.text_align = preset_data.get('text_align', props.text_align)
props.prepend_text = preset_data.get('prepend_text', props.prepend_text)
props.append_text = preset_data.get('append_text', props.append_text)
props.prepend_margin = preset_data.get('prepend_margin', props.prepend_margin)
props.append_margin = preset_data.get('append_margin', props.append_margin)
props.prepend_append_layout = preset_data.get('prepend_append_layout', props.prepend_append_layout)
# DETAILED LOGGING: Shader property loading with before/after values
print(f"[PRESET LOAD DEBUG] Loading shader properties...")
old_shader_type = props.shader_type
props.shader_type = preset_data.get('shader_type', props.shader_type)
print(f"[PRESET LOAD DEBUG] shader_type: {old_shader_type} -> {props.shader_type}")
old_shader_connection = props.shader_connection
props.shader_connection = preset_data.get('shader_connection', props.shader_connection)
print(f"[PRESET LOAD DEBUG] shader_connection: {old_shader_connection} -> {props.shader_connection}")
old_use_alpha = props.use_alpha
props.use_alpha = preset_data.get('use_alpha', props.use_alpha)
print(f"[PRESET LOAD DEBUG] use_alpha: {old_use_alpha} -> {props.use_alpha}")
old_emission_strength = props.emission_strength
props.emission_strength = preset_data.get('emission_strength', props.emission_strength)
print(f"[PRESET LOAD DEBUG] emission_strength: {old_emission_strength} -> {props.emission_strength}")
old_auto_assign = props.auto_assign_material
props.auto_assign_material = preset_data.get('auto_assign_material', props.auto_assign_material)
print(f"[PRESET LOAD DEBUG] auto_assign_material: {old_auto_assign} -> {props.auto_assign_material}")
# CRITICAL SHADER PROPERTIES - Most detailed logging
print(f"[PRESET LOAD DEBUG] ==================== CRITICAL SHADER PROPERTIES ====================")
old_disable_shadows = props.disable_shadows
new_disable_shadows = preset_data.get('disable_shadows', props.disable_shadows)
props.disable_shadows = new_disable_shadows
print(f"[PRESET LOAD DEBUG] disable_shadows:")
print(f"[PRESET LOAD DEBUG] OLD VALUE: {old_disable_shadows} (type: {type(old_disable_shadows)})")
print(f"[PRESET LOAD DEBUG] PRESET DATA: {preset_data.get('disable_shadows', 'NOT FOUND')} (type: {type(preset_data.get('disable_shadows', 'N/A'))})")
print(f"[PRESET LOAD DEBUG] NEW VALUE: {props.disable_shadows} (type: {type(props.disable_shadows)})")
old_disable_reflections = props.disable_reflections
new_disable_reflections = preset_data.get('disable_reflections', props.disable_reflections)
props.disable_reflections = new_disable_reflections
print(f"[PRESET LOAD DEBUG] disable_reflections:")
print(f"[PRESET LOAD DEBUG] OLD VALUE: {old_disable_reflections} (type: {type(old_disable_reflections)})")
print(f"[PRESET LOAD DEBUG] PRESET DATA: {preset_data.get('disable_reflections', 'NOT FOUND')} (type: {type(preset_data.get('disable_reflections', 'N/A'))})")
print(f"[PRESET LOAD DEBUG] NEW VALUE: {props.disable_reflections} (type: {type(props.disable_reflections)})")
old_disable_backfacing = props.disable_backfacing
new_disable_backfacing = preset_data.get('disable_backfacing', props.disable_backfacing)
props.disable_backfacing = new_disable_backfacing
print(f"[PRESET LOAD DEBUG] disable_backfacing:")
print(f"[PRESET LOAD DEBUG] OLD VALUE: {old_disable_backfacing} (type: {type(old_disable_backfacing)})")
print(f"[PRESET LOAD DEBUG] PRESET DATA: {preset_data.get('disable_backfacing', 'NOT FOUND')} (type: {type(preset_data.get('disable_backfacing', 'N/A'))})")
print(f"[PRESET LOAD DEBUG] NEW VALUE: {props.disable_backfacing} (type: {type(props.disable_backfacing)})")
print(f"[PRESET LOAD DEBUG] ================================================================")
props.generate_normal_map = preset_data.get('generate_normal_map', props.generate_normal_map)
props.normal_map_strength = preset_data.get('normal_map_strength', props.normal_map_strength)
props.normal_map_blur_radius = preset_data.get('normal_map_blur_radius', props.normal_map_blur_radius)
props.normal_map_invert = preset_data.get('normal_map_invert', props.normal_map_invert)
# Load multiline settings
props.enable_multiline = preset_data.get('enable_multiline', props.enable_multiline)
props.line_height = preset_data.get('line_height', props.line_height)
props.text_alignment = preset_data.get('text_alignment', props.text_alignment)
props.auto_wrap = preset_data.get('auto_wrap', props.auto_wrap)
props.wrap_width = preset_data.get('wrap_width', props.wrap_width)
props.max_lines = preset_data.get('max_lines', props.max_lines)
props.line_spacing_pixels = preset_data.get('line_spacing_pixels', props.line_spacing_pixels)
# Load stroke settings
props.enable_stroke = preset_data.get('enable_stroke', props.enable_stroke)
props.stroke_width = preset_data.get('stroke_width', props.stroke_width)
props.stroke_position = preset_data.get('stroke_position', props.stroke_position)
props.stroke_color = preset_data.get('stroke_color', props.stroke_color)
# Load shadow/glow settings
props.enable_shadow = preset_data.get('enable_shadow', props.enable_shadow)
props.shadow_offset_x = preset_data.get('shadow_offset_x', props.shadow_offset_x)
props.shadow_offset_y = preset_data.get('shadow_offset_y', props.shadow_offset_y)
props.shadow_blur = preset_data.get('shadow_blur', props.shadow_blur)
props.shadow_color = preset_data.get('shadow_color', props.shadow_color)
# Handle new shadow_spread property with backward compatibility
if hasattr(props, 'shadow_spread'):
props.shadow_spread = preset_data.get('shadow_spread', 1.0)
props.enable_glow = preset_data.get('enable_glow', props.enable_glow)
props.glow_blur = preset_data.get('glow_blur', props.glow_blur)
props.glow_color = preset_data.get('glow_color', props.glow_color)
# Load blur settings
props.enable_blur = preset_data.get('enable_blur', props.enable_blur)
props.blur_radius = preset_data.get('blur_radius', props.blur_radius)
# Load text fitting settings
props.enable_text_fitting = preset_data.get('enable_text_fitting', props.enable_text_fitting)
props.text_fitting_mode = preset_data.get('text_fitting_mode', props.text_fitting_mode)
props.text_fitting_margin = preset_data.get('text_fitting_margin', props.text_fitting_margin)
props.min_font_size = preset_data.get('min_font_size', props.min_font_size)
props.max_font_size = preset_data.get('max_font_size', props.max_font_size)
# Load overlay data
props.image_overlays.clear()
overlay_data_list = preset_data.get('image_overlays', [])
for overlay_data in overlay_data_list:
overlay = props.image_overlays.add()
overlay.name = overlay_data.get('name', 'Overlay')
overlay.image_path = overlay_data.get('image_path', '')
overlay.x_position = overlay_data.get('x_position', 0.5)
overlay.y_position = overlay_data.get('y_position', 0.5)
overlay.scale = overlay_data.get('scale', 1.0)
overlay.rotation = overlay_data.get('rotation', 0.0)
overlay.z_index = overlay_data.get('z_index', 1)
overlay.enabled = overlay_data.get('enabled', True)
props.is_updating = False
# FINAL VERIFICATION: Check what the properties actually are after loading
print(f"[PRESET LOAD DEBUG] ==================== FINAL VERIFICATION ====================")
print(f"[PRESET LOAD DEBUG] Properties AFTER loading preset '{self.preset_name}':")
print(f"[PRESET LOAD DEBUG] disable_shadows: {props.disable_shadows} (type: {type(props.disable_shadows)})")
print(f"[PRESET LOAD DEBUG] disable_reflections: {props.disable_reflections} (type: {type(props.disable_reflections)})")
print(f"[PRESET LOAD DEBUG] disable_backfacing: {props.disable_backfacing} (type: {type(props.disable_backfacing)})")
print(f"[PRESET LOAD DEBUG] ================================================================")
# Always generate preview after loading preset
# Import preview generation function
from .. import generate_preview
generate_preview(context)
source_info = {
'BLEND_FILE': "from blend file",
'PERSISTENT_FILE': "from persistent storage",
'LOCAL_FILE': "from legacy storage"
}.get(source, "from unknown source")
self.report({'INFO'}, f"✅ Loaded preset '{self.preset_name}' {source_info}")
except (OSError, IOError) as e:
props.is_updating = False
self.report({'ERROR'}, f"Failed to read preset file: {str(e)}")
return {'CANCELLED'}
except json.JSONDecodeError as e:
props.is_updating = False
self.report({'ERROR'}, f"Invalid preset file format: {str(e)}")
return {'CANCELLED'}
except Exception as e:
props.is_updating = False
self.report({'ERROR'}, f"Unexpected error loading preset: {str(e)}")
return {'CANCELLED'}
return {'FINISHED'}
class TEXT_TEXTURE_OT_delete_preset(Operator):
"""Delete preset"""
bl_idname = "text_texture.delete_preset"
bl_label = "Delete Preset"
bl_description = "Delete selected preset"
bl_options = {'REGISTER', 'UNDO'}
preset_name: StringProperty()
def execute(self, context):
props = context.scene.text_texture_props
try:
# Delete from both blend file and local storage
blend_deleted = delete_blend_file_preset(self.preset_name)
preset_file = os.path.join(get_preset_path(), f"{self.preset_name}.json")
local_deleted = False
if os.path.exists(preset_file):
os.remove(preset_file)
local_deleted = True
if not blend_deleted and not local_deleted:
self.report({'WARNING'}, f"Preset '{self.preset_name}' not found in blend file or local storage")
# Remove from UI list
for i, preset in enumerate(props.presets):
if preset.name == self.preset_name:
props.presets.remove(i)
break
delete_info = []
if blend_deleted:
delete_info.append("blend file")
if local_deleted:
delete_info.append("local storage")
location_text = " and ".join(delete_info) if delete_info else "nowhere (not found)"
self.report({'INFO'}, f"🗑️ Deleted preset '{self.preset_name}' from {location_text}")
except (OSError, IOError) as e:
self.report({'ERROR'}, f"Failed to delete preset file: {str(e)}")
return {'CANCELLED'}
except Exception as e:
self.report({'ERROR'}, f"Unexpected error deleting preset: {str(e)}")
return {'CANCELLED'}
return {'FINISHED'}
def invoke(self, context, event):
# Add confirmation dialog for better UX
return context.window_manager.invoke_confirm(self, event)
def draw(self, context):
layout = self.layout
layout.label(text=f"Delete preset '{self.preset_name}'?")
layout.label(text="This action cannot be undone.", icon='ERROR')
class TEXT_TEXTURE_OT_refresh_presets(Operator):
"""Refresh presets from blend file and local storage"""
bl_idname = "text_texture.refresh_presets"
bl_label = "Refresh Presets"
bl_description = "Reload presets from blend file and local storage"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
try:
# Use the reliable synchronous refresh system
success = refresh_presets_sync()
if success:
props = context.scene.text_texture_props
total_presets = len(props.presets)
# Count by source for detailed reporting
blend_count = len([p for p in props.presets if p.source == 'BLEND_FILE'])
persistent_count = len([p for p in props.presets if p.source == 'PERSISTENT_FILE'])
local_count = len([p for p in props.presets if p.source == 'LOCAL_FILE'])
self.report({'INFO'}, f"✅ Refreshed {total_presets} presets ({blend_count} blend file, {persistent_count} persistent, {local_count} local)")
else:
self.report({'WARNING'}, "Preset refresh completed with issues - check console for details")
# Force UI redraw
for area in context.screen.areas:
area.tag_redraw()
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, f"Failed to refresh presets: {str(e)}")
import traceback
traceback.print_exc()
return {'CANCELLED'}
class TEXT_TEXTURE_OT_export_presets(Operator):
"""Export all presets to a file for backup"""
bl_idname = "text_texture.export_presets"
bl_label = "Export Presets"
bl_description = "Export all presets to a file for backup"
bl_options = {'REGISTER', 'UNDO'}
filepath: StringProperty(
name="File Path",
description="Path to save preset backup file",
subtype='FILE_PATH',
default="text_texture_presets_backup.json"
)
def execute(self, context):
try:
props = context.scene.text_texture_props
# Collect all presets from all sources
all_presets = {}
# Get blend file presets
blend_presets = get_blend_file_presets()
for name, data in blend_presets.items():
all_presets[name] = {
"data": data,
"source": "BLEND_FILE"
}
# Get persistent presets
persistent_dir = get_persistent_preset_path()
if os.path.exists(persistent_dir):
for filename in os.listdir(persistent_dir):
if filename.endswith('.json'):
preset_name = os.path.splitext(filename)[0]
if preset_name not in all_presets: # Don't override blend file presets
try:
with open(os.path.join(persistent_dir, filename), 'r') as f:
preset_data = json.load(f)
all_presets[preset_name] = {
"data": preset_data,
"source": "PERSISTENT_FILE"
}
except (json.JSONDecodeError, OSError) as e:
print(f"[TTG] Error reading persistent preset {preset_name}: {e}")
# Get legacy presets
legacy_dir = get_preset_path()
if os.path.exists(legacy_dir) and legacy_dir != persistent_dir:
for filename in os.listdir(legacy_dir):
if filename.endswith('.json'):
preset_name = os.path.splitext(filename)[0]
if preset_name not in all_presets: # Don't override higher priority presets
try:
with open(os.path.join(legacy_dir, filename), 'r') as f:
preset_data = json.load(f)
all_presets[preset_name] = {
"data": preset_data,
"source": "LOCAL_FILE"
}
except (json.JSONDecodeError, OSError) as e:
print(f"[TTG] Error reading legacy preset {preset_name}: {e}")
if not all_presets:
self.report({'WARNING'}, "No presets found to export")
return {'CANCELLED'}
# Create export data with metadata
export_data = {
"format_version": "1.0",
"addon_version": bl_info["version"],
"export_timestamp": time.time(),
"presets": all_presets
}
# Write to file
with open(self.filepath, 'w') as f:
json.dump(export_data, f, indent=2)
self.report({'INFO'}, f"✅ Exported {len(all_presets)} presets to {os.path.basename(self.filepath)}")
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, f"Failed to export presets: {str(e)}")
return {'CANCELLED'}
def invoke(self, context, event):
# Set default filename with timestamp
import datetime
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
self.filepath = f"text_texture_presets_backup_{timestamp}.json"
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
class TEXT_TEXTURE_OT_import_presets(Operator):
"""Import presets from a backup file"""
bl_idname = "text_texture.import_presets"
bl_label = "Import Presets"
bl_description = "Import presets from a backup file"
bl_options = {'REGISTER', 'UNDO'}
filepath: StringProperty(
name="File Path",
description="Path to preset backup file",
subtype='FILE_PATH'
)
import_mode: EnumProperty(
name="Import Mode",
description="How to handle existing presets with same names",
items=[
('SKIP', 'Skip Existing', 'Skip presets that already exist'),
('OVERWRITE', 'Overwrite', 'Overwrite existing presets'),
('RENAME', 'Rename New', 'Rename imported presets if they conflict'),
],
default='SKIP'
)
import_to_blend: BoolProperty(
name="Import to Blend File",
description="Import presets to blend file (recommended for portability)",
default=True
)
def execute(self, context):
try:
if not os.path.exists(self.filepath):
self.report({'ERROR'}, "Backup file not found")
return {'CANCELLED'}
props = context.scene.text_texture_props
# Read backup file
with open(self.filepath, 'r') as f:
import_data = json.load(f)
# Validate format
if "presets" not in import_data:
self.report({'ERROR'}, "Invalid backup file format")
return {'CANCELLED'}
imported_presets = import_data["presets"]
if not imported_presets:
self.report({'WARNING'}, "No presets found in backup file")
return {'CANCELLED'}
# Get existing presets to check for conflicts
existing_blend_presets = get_blend_file_presets()
existing_preset_names = set(existing_blend_presets.keys())
# Also check persistent storage
persistent_dir = get_persistent_preset_path()
if os.path.exists(persistent_dir):
for filename in os.listdir(persistent_dir):
if filename.endswith('.json'):
existing_preset_names.add(os.path.splitext(filename)[0])
imported_count = 0
skipped_count = 0
errors = []
for preset_name, preset_info in imported_presets.items():
preset_data = preset_info.get("data", {})
# Handle conflicts
final_name = preset_name
if preset_name in existing_preset_names:
if self.import_mode == 'SKIP':
skipped_count += 1
continue
elif self.import_mode == 'RENAME':
counter = 1
base_name = preset_name
while final_name in existing_preset_names:
final_name = f"{base_name}_imported_{counter}"
counter += 1
# OVERWRITE mode uses original name
try:
# Import to blend file or persistent storage based on setting
if self.import_to_blend:
success = save_blend_file_preset(final_name, preset_data)
if not success:
raise Exception("Failed to save to blend file")
else:
# Save to persistent storage
persistent_file = os.path.join(get_persistent_preset_path(), f"{final_name}.json")
with open(persistent_file, 'w') as f:
json.dump(preset_data, f, indent=2)
imported_count += 1
# Add to UI list if not already present
existing_preset = None
for preset in props.presets:
if preset.name == final_name:
existing_preset = preset
break
if not existing_preset:
new_preset = props.presets.add()
new_preset.name = final_name
new_preset.source = 'BLEND_FILE' if self.import_to_blend else 'PERSISTENT_FILE'
else:
existing_preset.source = 'BLEND_FILE' if self.import_to_blend else 'PERSISTENT_FILE'
except Exception as e:
errors.append(f"{preset_name}: {str(e)}")
# Report results
if imported_count > 0:
storage_location = "blend file" if self.import_to_blend else "persistent storage"
self.report({'INFO'}, f"✅ Imported {imported_count} presets to {storage_location}")
if skipped_count > 0:
self.report({'INFO'}, f"⏭️ Skipped {skipped_count} existing presets")
else:
self.report({'WARNING'}, "No presets were imported")
if errors:
self.report({'WARNING'}, f"Errors importing {len(errors)} presets - check console")
for error in errors[:5]: # Show first 5 errors
print(f"[TTG] Import error: {error}")
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, f"Failed to import presets: {str(e)}")
return {'CANCELLED'}
def invoke(self, context, event):
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
def draw(self, context):
layout = self.layout
layout.prop(self, "import_mode")
layout.prop(self, "import_to_blend")
class TEXT_TEXTURE_OT_show_migration_report(Operator):
"""Show migration report with backup and upgrade information"""
bl_idname = "text_texture.show_migration_report"
bl_label = "Show Migration Report"
bl_description = "Display information about preset migration and backups"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
return {'FINISHED'}
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self, width=400)
def draw(self, context):
layout = self.layout
layout.label(text="Migration Report", icon='INFO')
layout.separator()
layout.label(text="This addon has been upgraded to the new preset system.")
layout.label(text="Your existing presets have been preserved.")
layout.separator()
layout.label(text="Features:", icon='CHECKMARK')
layout.label(text="• Presets saved in blend files")
layout.label(text="• Cross-file preset sharing")
layout.label(text="• Automatic backup system")
layout.separator()
# Export all operator classes for wildcard imports
__all__ = [
'TEXT_TEXTURE_OT_save_preset',
'TEXT_TEXTURE_OT_save_preset_enter',
'TEXT_TEXTURE_OT_load_preset',
'TEXT_TEXTURE_OT_delete_preset',
'TEXT_TEXTURE_OT_refresh_presets',
'TEXT_TEXTURE_OT_export_presets',
'TEXT_TEXTURE_OT_import_presets',
'TEXT_TEXTURE_OT_show_migration_report',
]

607
operators/utility_ops.py Normal file
View File

@@ -0,0 +1,607 @@
"""
Text Texture Generator - Utility Operators
Handles preview refresh, font refresh, UI operations, overlay management, and positioning utilities.
"""
import bpy
from bpy.types import Operator
from bpy.props import StringProperty, IntProperty, EnumProperty
import os
# Import functions from parent module
try:
print("[TTG DEBUG] IMPORT TRACE: Attempting to import from ui.preview...")
print("[TTG DEBUG] IMPORT TRACE: Looking for generate_preview in ui.preview module")
# First, let's see what's actually available in ui.preview
from .. import ui
print(f"[TTG DEBUG] IMPORT TRACE: ui.preview module contents: {dir(ui.preview)}")
# Now try the actual import that's failing
from ..core.generation_engine import generate_preview
print("[TTG DEBUG] IMPORT TRACE: SUCCESS - generate_preview imported from ui.preview")
from ..utils.system import get_system_fonts, get_font_enum_items
from ..presets.storage_system import refresh_presets_sync, ensure_presets_available
from ..utils.constants import sync_margin_values
except ImportError as e:
print(f"[TTG DEBUG] IMPORT ERROR: Failed to import from ui.preview: {e}")
print("[TTG DEBUG] IMPORT TRACE: This confirms generate_preview is NOT in ui.preview")
# Let's check if it's available in core.generation_engine
try:
print("[TTG DEBUG] IMPORT TRACE: Attempting fallback import from core.generation_engine...")
from ..core.generation_engine import generate_preview
print("[TTG DEBUG] IMPORT TRACE: SUCCESS - generate_preview found in core.generation_engine!")
from ..utils.system import get_system_fonts, get_font_enum_items
from ..presets.storage_system import refresh_presets_sync, ensure_presets_available
from ..utils.constants import sync_margin_values
print("[TTG DEBUG] IMPORT TRACE: All imports successful with corrected path")
except ImportError as fallback_error:
print(f"[TTG DEBUG] IMPORT ERROR: Fallback import also failed: {fallback_error}")
print(f"[TTG DEBUG] IMPORT ERROR: Original error was: {e}")
# Fallback imports for development/testing
print("[TTG DEBUG] IMPORT TRACE: Using fallback placeholder functions")
def generate_preview(context):
print("Fallback generate_preview called")
return None
def get_system_fonts():
return {}
def get_font_enum_items():
return [("default", "Default Font", "Use system default font", 0)]
def refresh_presets_sync():
return True
def ensure_presets_available():
return True
def sync_margin_values(props, changed_margin):
pass
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_open_panel(Operator):
"""Open Text Texture Generator panel"""
bl_idname = "text_texture.open_panel"
bl_label = "Open Text Texture Panel"
bl_description = "Open the Text Texture Generator panel in the sidebar"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
# Ensure the sidebar is visible
for area in context.screen.areas:
if area.type == 'VIEW_3D':
for space in area.spaces:
if space.type == 'VIEW_3D':
space.show_region_ui = True
# Switch to Tool tab
area.tag_redraw()
self.report({'INFO'}, "Text Texture panel opened in 3D View sidebar (press N if not visible)")
return {'FINISHED'}
elif area.type == 'NODE_EDITOR':
for space in area.spaces:
if space.type == 'NODE_EDITOR':
space.show_region_ui = True
area.tag_redraw()
self.report({'INFO'}, "Text Texture panel opened in Shader Editor sidebar")
return {'FINISHED'}
self.report({'WARNING'}, "Please open a 3D View or Shader Editor first")
return {'CANCELLED'}
class TEXT_TEXTURE_OT_refresh_fonts(Operator):
"""Refresh fonts"""
bl_idname = "text_texture.refresh_fonts"
bl_label = "Refresh Fonts"
bl_description = "Refresh font list"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
if hasattr(get_font_enum_items, 'cached_fonts'):
del get_font_enum_items.cached_fonts
get_font_enum_items.cached_fonts = get_system_fonts()
self.report({'INFO'}, f"Found {len(get_font_enum_items.cached_fonts)} fonts")
return {'FINISHED'}
class TEXT_TEXTURE_OT_refresh_presets(Operator):
"""Refresh presets from blend file and local storage"""
bl_idname = "text_texture.refresh_presets"
bl_label = "Refresh Presets"
bl_description = "Reload presets from blend file and local storage"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
try:
# Use the reliable synchronous refresh system
success = refresh_presets_sync()
if success:
props = context.scene.text_texture_props
total_presets = len(props.presets)
# Count by source for detailed reporting
blend_count = len([p for p in props.presets if p.source == 'BLEND_FILE'])
persistent_count = len([p for p in props.presets if p.source == 'PERSISTENT_FILE'])
local_count = len([p for p in props.presets if p.source == 'LOCAL_FILE'])
self.report({'INFO'}, f"✅ Refreshed {total_presets} presets ({blend_count} blend file, {persistent_count} persistent, {local_count} local)")
else:
self.report({'WARNING'}, "Preset refresh completed with issues - check console for details")
# Force UI redraw
for area in context.screen.areas:
area.tag_redraw()
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, f"Failed to refresh presets: {str(e)}")
import traceback
traceback.print_exc()
return {'CANCELLED'}
class TEXT_TEXTURE_OT_add_overlay(Operator):
"""Add new image overlay"""
bl_idname = "text_texture.add_overlay"
bl_label = "Add Overlay"
bl_description = "Add new image overlay"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
props = context.scene.text_texture_props
overlay = props.image_overlays.add()
overlay.name = f"Overlay {len(props.image_overlays)}"
props.active_overlay_index = len(props.image_overlays) - 1
# Force UI redraw
for area in context.screen.areas:
area.tag_redraw()
self.report({'INFO'}, f"Added overlay: {overlay.name}")
return {'FINISHED'}
class TEXT_TEXTURE_OT_remove_overlay(Operator):
"""Remove active image overlay"""
bl_idname = "text_texture.remove_overlay"
bl_label = "Remove Overlay"
bl_description = "Remove active image overlay"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
props = context.scene.text_texture_props
if props.image_overlays and props.active_overlay_index < len(props.image_overlays):
overlay_name = props.image_overlays[props.active_overlay_index].name
props.image_overlays.remove(props.active_overlay_index)
# Update active index
if props.active_overlay_index >= len(props.image_overlays) and props.image_overlays:
props.active_overlay_index = len(props.image_overlays) - 1
# Force UI redraw
for area in context.screen.areas:
area.tag_redraw()
self.report({'INFO'}, f"Removed overlay: {overlay_name}")
else:
self.report({'WARNING'}, "No overlay to remove")
return {'FINISHED'}
class TEXT_TEXTURE_OT_set_anchor_point(Operator):
"""Set anchor point for text positioning"""
bl_idname = "text_texture.set_anchor_point"
bl_label = "Set Anchor Point"
bl_description = "Set text anchor position"
bl_options = {'REGISTER', 'UNDO'}
anchor_point: StringProperty()
def execute(self, context):
props = context.scene.text_texture_props
props.anchor_point = self.anchor_point
return {'FINISHED'}
class TEXT_TEXTURE_OT_sync_margins(Operator):
"""Sync margin values when linked"""
bl_idname = "text_texture.sync_margins"
bl_label = "Sync Margins"
bl_description = "Synchronize margin values"
bl_options = {'REGISTER', 'UNDO'}
margin_type: StringProperty()
def execute(self, context):
props = context.scene.text_texture_props
sync_margin_values(props, self.margin_type)
return {'FINISHED'}
class TEXT_TEXTURE_OT_duplicate_overlay(Operator):
"""Duplicate image overlay"""
bl_idname = "text_texture.duplicate_overlay"
bl_label = "Duplicate Overlay"
bl_description = "Duplicate this image overlay"
bl_options = {'REGISTER', 'UNDO'}
overlay_index: IntProperty()
def execute(self, context):
props = context.scene.text_texture_props
if 0 <= self.overlay_index < len(props.image_overlays):
source_overlay = props.image_overlays[self.overlay_index]
# Debug logging
print(f"[DUPLICATE DEBUG] Duplicating overlay '{source_overlay.name}'")
print(f"[DUPLICATE DEBUG] Source properties:")
print(f"[DUPLICATE DEBUG] image_path: '{source_overlay.image_path}'")
print(f"[DUPLICATE DEBUG] enabled: {source_overlay.enabled}")
print(f"[DUPLICATE DEBUG] positioning_mode: '{source_overlay.positioning_mode}'")
print(f"[DUPLICATE DEBUG] scale: {source_overlay.scale}")
print(f"[DUPLICATE DEBUG] z_index: {source_overlay.z_index}")
new_overlay = props.image_overlays.add()
# Copy all properties with explicit verification
new_overlay.name = f"{source_overlay.name} Copy"
new_overlay.image_path = source_overlay.image_path
new_overlay.x_position = source_overlay.x_position
new_overlay.y_position = source_overlay.y_position
new_overlay.scale = source_overlay.scale
new_overlay.rotation = source_overlay.rotation
# Fix z-index assignment for duplicated overlays to ensure proper visibility
# For PREPEND/APPEND overlays, increment z_index to ensure duplicate appears above original
# This prevents the duplicate from being rendered behind the original overlay
if source_overlay.positioning_mode in ['PREPEND', 'APPEND']:
# Increment z_index to ensure proper layering order for duplicated overlay
new_overlay.z_index = min(source_overlay.z_index + 1, 10) # Cap at max z_index value
print(f"[DUPLICATE DEBUG] Fixed z_index for {source_overlay.positioning_mode} overlay: {source_overlay.z_index} -> {new_overlay.z_index}")
else:
# For ABSOLUTE overlays, keep same z_index (different positions so no overlap issue)
new_overlay.z_index = source_overlay.z_index
new_overlay.positioning_mode = source_overlay.positioning_mode
new_overlay.text_spacing = source_overlay.text_spacing
new_overlay.image_spacing = source_overlay.image_spacing
new_overlay.enabled = source_overlay.enabled
# Verify the copy was successful
print(f"[DUPLICATE DEBUG] New overlay properties:")
print(f"[DUPLICATE DEBUG] name: '{new_overlay.name}'")
print(f"[DUPLICATE DEBUG] image_path: '{new_overlay.image_path}'")
print(f"[DUPLICATE DEBUG] enabled: {new_overlay.enabled}")
print(f"[DUPLICATE DEBUG] positioning_mode: '{new_overlay.positioning_mode}'")
print(f"[DUPLICATE DEBUG] scale: {new_overlay.scale}")
print(f"[DUPLICATE DEBUG] z_index: {new_overlay.z_index}")
# Move to position after source
new_index = len(props.image_overlays) - 1
target_index = min(self.overlay_index + 1, new_index)
if new_index != target_index:
props.image_overlays.move(new_index, target_index)
print(f"[DUPLICATE DEBUG] Moved overlay from index {new_index} to {target_index}")
props.active_overlay_index = target_index
# Clear any cached images to force reload
print(f"[DUPLICATE DEBUG] Clearing image cache and forcing preview update...")
# Set the updating flag to prevent recursive updates
props.is_updating = True
try:
# Force immediate preview regeneration
print(f"[DUPLICATE DEBUG] Generating new preview...")
preview_result = generate_preview(context)
if preview_result:
print(f"[DUPLICATE DEBUG] Preview generated successfully")
else:
print(f"[DUPLICATE DEBUG] Preview generation failed")
# Force UI redraw
for area in context.screen.areas:
area.tag_redraw()
except Exception as e:
print(f"[DUPLICATE DEBUG] Error updating preview: {e}")
import traceback
traceback.print_exc()
finally:
props.is_updating = False
print(f"[DUPLICATE DEBUG] Total overlays now: {len(props.image_overlays)}")
print(f"[DUPLICATE DEBUG] Duplication operation completed")
self.report({'INFO'}, f"✅ Duplicated overlay: {source_overlay.name}")
else:
self.report({'ERROR'}, "Invalid overlay index")
return {'CANCELLED'}
return {'FINISHED'}
class TEXT_TEXTURE_OT_delete_overlay(Operator):
"""Delete image overlay"""
bl_idname = "text_texture.delete_overlay"
bl_label = "Delete Overlay"
bl_description = "Delete this image overlay"
bl_options = {'REGISTER', 'UNDO'}
overlay_index: IntProperty()
def execute(self, context):
props = context.scene.text_texture_props
print(f"[DELETE DEBUG] Deleting overlay at index {self.overlay_index}")
if 0 <= self.overlay_index < len(props.image_overlays):
overlay_name = props.image_overlays[self.overlay_index].name
print(f"[DELETE DEBUG] Deleting overlay: '{overlay_name}'")
props.image_overlays.remove(self.overlay_index)
# Update active index
if props.active_overlay_index >= len(props.image_overlays) and props.image_overlays:
props.active_overlay_index = len(props.image_overlays) - 1
elif not props.image_overlays:
props.active_overlay_index = 0
print(f"[DELETE DEBUG] Overlay deleted successfully. Total overlays now: {len(props.image_overlays)}")
# Force preview update after deletion
try:
generate_preview(context)
print(f"[DELETE DEBUG] Preview updated after deletion")
except Exception as e:
print(f"[DELETE DEBUG] Error updating preview: {e}")
# Force UI redraw
for area in context.screen.areas:
area.tag_redraw()
self.report({'INFO'}, f"Deleted overlay: {overlay_name}")
else:
print(f"[DELETE DEBUG] Invalid overlay index: {self.overlay_index}")
self.report({'ERROR'}, "Invalid overlay index")
return {'CANCELLED'}
return {'FINISHED'}
class TEXT_TEXTURE_OT_move_overlay(Operator):
"""Move image overlay up or down in the list"""
bl_idname = "text_texture.move_overlay"
bl_label = "Move Overlay"
bl_description = "Move overlay up or down in the list"
bl_options = {'REGISTER', 'UNDO'}
overlay_index: IntProperty()
direction: EnumProperty(
items=[
('UP', 'Up', 'Move overlay up in list'),
('DOWN', 'Down', 'Move overlay down in list')
]
)
def execute(self, context):
props = context.scene.text_texture_props
print(f"[MOVE DEBUG] Moving overlay at index {self.overlay_index} direction: {self.direction}")
if 0 <= self.overlay_index < len(props.image_overlays):
current_index = self.overlay_index
new_index = current_index
if self.direction == 'UP' and current_index > 0:
new_index = current_index - 1
elif self.direction == 'DOWN' and current_index < len(props.image_overlays) - 1:
new_index = current_index + 1
if new_index != current_index:
overlay_name = props.image_overlays[current_index].name
props.image_overlays.move(current_index, new_index)
props.active_overlay_index = new_index
print(f"[MOVE DEBUG] Successfully moved overlay '{overlay_name}' from {current_index} to {new_index}")
# Force preview update after moving (order affects rendering)
try:
generate_preview(context)
print(f"[MOVE DEBUG] Preview updated after move operation")
except Exception as e:
print(f"[MOVE DEBUG] Error updating preview: {e}")
self.report({'INFO'}, f"Moved overlay {self.direction.lower()}")
else:
print(f"[MOVE DEBUG] No movement needed - overlay is already at edge")
self.report({'INFO'}, f"Overlay is already at {self.direction.lower()} edge")
else:
print(f"[MOVE DEBUG] Invalid overlay index: {self.overlay_index}")
self.report({'ERROR'}, "Invalid overlay index")
return {'CANCELLED'}
return {'FINISHED'}
# Export all operator classes for wildcard imports
__all__ = [
'TEXT_TEXTURE_OT_refresh_preview',
'TEXT_TEXTURE_OT_view_preview_zoom',
'TEXT_TEXTURE_OT_open_panel',
'TEXT_TEXTURE_OT_refresh_fonts',
'TEXT_TEXTURE_OT_refresh_presets',
'TEXT_TEXTURE_OT_add_overlay',
'TEXT_TEXTURE_OT_remove_overlay',
'TEXT_TEXTURE_OT_set_anchor_point',
'TEXT_TEXTURE_OT_sync_margins',
'TEXT_TEXTURE_OT_duplicate_overlay',
'TEXT_TEXTURE_OT_delete_overlay',
'TEXT_TEXTURE_OT_move_overlay',
]

12
presets/__init__.py Normal file
View File

@@ -0,0 +1,12 @@
"""
Preset System Module
This module contains the three-tier preset storage system for the Text Texture Generator:
- Blend file storage (highest priority)
- Persistent file storage (survives addon updates)
- Legacy file storage (backward compatibility)
"""
# Import all preset functionality
from .storage_system import *
from .migration import *

125
presets/migration.py Normal file
View File

@@ -0,0 +1,125 @@
# Migration functions for preset storage system
import bpy
import json
import os
import shutil
from datetime import datetime
def migrate_presets_if_needed():
"""
Migrate presets from older versions if needed.
This function checks if migration is required and performs it automatically.
Returns: dict with 'migrated_count' key indicating number of presets migrated.
"""
print("[TTG MIGRATION DEBUG] Starting migrate_presets_if_needed()")
preferences = bpy.context.preferences.addons.get("Text_Texture_Generator_Pro")
if not preferences:
print("[TTG MIGRATION DEBUG] No preferences found - returning proper dict format")
return {'migrated_count': 0}
addon_version = preferences.preferences.get("addon_version", "0.0.0")
current_version = "1.0.0" # This should match the addon's current version
if addon_version != current_version:
print(f"Text Texture Generator: Migrating presets from version {addon_version} to {current_version}")
# Backup existing presets before migration
backup_success = backup_presets()
if not backup_success:
print("Text Texture Generator: Warning - Failed to backup presets before migration")
# Perform migration steps based on version differences
migration_success = True
migrated_count = 0
try:
# Version-specific migration logic would go here
# For now, we'll just update the version number
preferences.preferences["addon_version"] = current_version
# In a real migration, this would be the actual count of migrated presets
# For now, we assume 1 migration operation (version update)
migrated_count = 1
if migration_success:
print(f"Text Texture Generator: Successfully migrated presets to version {current_version}")
else:
print("Text Texture Generator: Migration completed with warnings")
except Exception as e:
print(f"Text Texture Generator: Error during preset migration: {str(e)}")
migration_success = False
migrated_count = 0
print(f"[TTG MIGRATION DEBUG] Migration completed, returning proper dict format: migrated_count={migrated_count}")
return {'migrated_count': migrated_count}
print("[TTG MIGRATION DEBUG] No migration needed, returning proper dict format: migrated_count=0")
return {'migrated_count': 0} # No migration needed
def backup_presets():
"""
Create a backup of current presets before migration.
Returns True if backup was successful, False otherwise.
"""
try:
# Get the addon directory
addon_dir = os.path.dirname(__file__)
parent_dir = os.path.dirname(addon_dir)
# Create backup directory with timestamp
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_dir = os.path.join(parent_dir, f"preset_backup_{timestamp}")
# Find preset files to backup
preset_files = []
# Check for blend file presets
if bpy.data.filepath:
blend_file_dir = os.path.dirname(bpy.data.filepath)
preset_file = os.path.join(blend_file_dir, ".text_texture_presets.json")
if os.path.exists(preset_file):
preset_files.append(preset_file)
# Check for persistent presets
preferences = bpy.context.preferences.addons.get("Text_Texture_Generator_Pro")
if preferences and hasattr(preferences.preferences, 'persistent_presets_path'):
persistent_path = preferences.preferences.persistent_presets_path
if persistent_path and os.path.exists(persistent_path):
preset_files.append(persistent_path)
# Check for legacy presets in addon directory
legacy_preset_file = os.path.join(parent_dir, "text_texture_presets.json")
if os.path.exists(legacy_preset_file):
preset_files.append(legacy_preset_file)
# Create backup if there are files to backup
if preset_files:
os.makedirs(backup_dir, exist_ok=True)
for preset_file in preset_files:
filename = os.path.basename(preset_file)
backup_path = os.path.join(backup_dir, filename)
shutil.copy2(preset_file, backup_path)
print(f"Text Texture Generator: Backed up {filename} to {backup_dir}")
# Create a backup info file
backup_info = {
"backup_timestamp": timestamp,
"addon_version_before_migration": bpy.context.preferences.addons.get("Text_Texture_Generator_Pro", {}).get("addon_version", "unknown"),
"files_backed_up": [os.path.basename(f) for f in preset_files]
}
info_file = os.path.join(backup_dir, "backup_info.json")
with open(info_file, 'w') as f:
json.dump(backup_info, f, indent=2)
print(f"Text Texture Generator: Created preset backup in {backup_dir}")
return True
else:
print("Text Texture Generator: No preset files found to backup")
return True # Not an error if there are no presets to backup
except Exception as e:
print(f"Text Texture Generator: Error creating preset backup: {str(e)}")
return False

462
presets/storage_system.py Normal file
View File

@@ -0,0 +1,462 @@
import bpy
import os
import json
import time
import platform
# ============================================================================
# PRESET STORAGE SYSTEM - V2.0 (PERSISTENT STORAGE)
# ============================================================================
#
# The Text Texture Generator now uses a THREE-TIER preset storage system
# that ensures preset persistence across addon updates:
#
# 1. BLEND FILE STORAGE (Highest Priority)
# - Presets travel with the .blend file
# - Perfect for project-specific presets
# - Stored in scene custom properties
#
# 2. PERSISTENT FILE STORAGE (Medium Priority)
# - Survives addon updates and Blender upgrades
# - Stored in Blender's user config directory
# - Shared across all blend files
#
# 3. LEGACY FILE STORAGE (Lowest Priority)
# - Old system for backward compatibility
# - Vulnerable to addon updates
# - Will be automatically migrated to persistent storage
#
# MIGRATION SYSTEM:
# - Automatically detects addon version changes
# - Migrates legacy presets to persistent storage
# - Creates backups before migration
# - Provides detailed migration reports
#
# IMPORT/EXPORT SYSTEM:
# - Export all presets for manual backup
# - Import presets with conflict resolution
# - Supports cross-machine preset sharing
#
# ============================================================================
def get_preset_path():
"""Get path for storing presets (legacy local storage)
LEGACY SYSTEM: This storage location is vulnerable to addon updates.
Presets stored here will be lost when the addon is updated through
Blender's Add-ons preferences. This function is maintained for
backward compatibility only.
For new installations, use get_persistent_preset_path() instead.
"""
addon_dir = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
presets_dir = os.path.join(addon_dir, "presets")
if not os.path.exists(presets_dir):
try:
os.makedirs(presets_dir)
except OSError as e:
print(f"Error creating presets directory: {e}")
return presets_dir
def get_persistent_preset_path():
"""Get persistent path for storing presets (survives addon updates)"""
import bpy
# Get Blender's user config directory
config_dir = bpy.utils.user_resource('CONFIG')
if not config_dir:
# Fallback to addon directory if config path unavailable
print("[TTG] Warning: Could not get user config directory, falling back to addon directory")
return get_preset_path()
# Create addon-specific subdirectory
persistent_dir = os.path.join(config_dir, "text_texture_generator", "presets")
try:
if not os.path.exists(persistent_dir):
os.makedirs(persistent_dir, exist_ok=True)
return persistent_dir
except OSError as e:
print(f"[TTG] Error creating persistent presets directory: {e}")
# Fallback to addon directory
return get_preset_path()
def get_addon_version():
"""Get current addon version for migration tracking"""
# Import bl_info from the main addon file
import sys
import importlib.util
# Get the main addon module
main_module_path = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
init_file = os.path.join(main_module_path, "__init__.py")
try:
spec = importlib.util.spec_from_file_location("main_addon", init_file)
main_addon = importlib.util.module_from_spec(spec)
spec.loader.exec_module(main_addon)
return main_addon.bl_info["version"]
except Exception as e:
print(f"[TTG] Error getting addon version: {e}")
return (2, 3, 2) # Fallback version
def get_version_file_path():
"""Get path to version tracking file"""
persistent_dir = os.path.dirname(get_persistent_preset_path())
return os.path.join(persistent_dir, "addon_version.json")
def get_stored_version():
"""Get previously stored addon version"""
version_file = get_version_file_path()
try:
if os.path.exists(version_file):
with open(version_file, 'r') as f:
data = json.load(f)
return tuple(data.get('version', (0, 0, 0)))
except (json.JSONDecodeError, OSError) as e:
print(f"[TTG] Error reading version file: {e}")
return (0, 0, 0)
def save_current_version():
"""Save current addon version to persistent storage"""
version_file = get_version_file_path()
try:
version_data = {
'version': get_addon_version(),
'timestamp': time.time()
}
with open(version_file, 'w') as f:
json.dump(version_data, f, indent=2)
print(f"[TTG] Saved addon version {get_addon_version()} to persistent storage")
except (OSError, json.JSONEncodeError) as e:
print(f"[TTG] Error saving version file: {e}")
def get_blend_file_presets():
"""Get presets stored in the current blend file"""
try:
# Get custom properties from the scene
import bpy
scene = bpy.context.scene
# DETAILED LOGGING: Function entry
print(f"[GET_BLEND_FILE_PRESETS DEBUG] ==================== LOADING FROM BLEND FILE ==================")
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Function called to get presets from blend file")
# Store presets in scene custom properties
if "text_texture_presets" not in scene:
print(f"[GET_BLEND_FILE_PRESETS DEBUG] No 'text_texture_presets' found in scene, creating empty")
scene["text_texture_presets"] = "{}"
else:
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Found 'text_texture_presets' in scene")
preset_data = scene.get("text_texture_presets", "{}")
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Raw preset data type: {type(preset_data)}")
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Raw preset data length: {len(str(preset_data))} characters")
if isinstance(preset_data, str):
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Preset data is string, attempting JSON parse...")
try:
import json
parsed_presets = json.loads(preset_data)
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Successfully parsed JSON")
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Parsed presets type: {type(parsed_presets)}")
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Parsed presets count: {len(parsed_presets) if isinstance(parsed_presets, dict) else 'N/A'}")
if isinstance(parsed_presets, dict):
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Preset names: {list(parsed_presets.keys())}")
# DETAILED VERIFICATION: Check shader properties in each preset
for preset_name, preset_content in parsed_presets.items():
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Preset '{preset_name}' analysis:")
if isinstance(preset_content, dict):
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Keys: {list(preset_content.keys())}")
print(f"[GET_BLEND_FILE_PRESETS DEBUG] disable_shadows: {preset_content.get('disable_shadows', 'NOT FOUND')}")
print(f"[GET_BLEND_FILE_PRESETS DEBUG] disable_reflections: {preset_content.get('disable_reflections', 'NOT FOUND')}")
print(f"[GET_BLEND_FILE_PRESETS DEBUG] disable_backfacing: {preset_content.get('disable_backfacing', 'NOT FOUND')}")
else:
print(f"[GET_BLEND_FILE_PRESETS DEBUG] ERROR: Preset content is not a dict: {type(preset_content)}")
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Returning parsed presets")
print(f"[GET_BLEND_FILE_PRESETS DEBUG] ================================================================")
return parsed_presets
except json.JSONDecodeError as json_err:
print(f"[GET_BLEND_FILE_PRESETS DEBUG] JSON decode error: {json_err}")
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Raw data causing error: {preset_data[:200]}...") # First 200 chars
print("Error parsing blend file presets, resetting to empty")
scene["text_texture_presets"] = "{}"
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Reset to empty, returning empty dict")
return {}
else:
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Preset data is not string, returning empty dict")
print(f"[GET_BLEND_FILE_PRESETS DEBUG] ================================================================")
return {}
except Exception as e:
print(f"[GET_BLEND_FILE_PRESETS DEBUG] EXCEPTION occurred: {e}")
import traceback
print(f"[GET_BLEND_FILE_PRESETS DEBUG] Full traceback: {traceback.format_exc()}")
print(f"Error getting blend file presets: {e}")
return {}
def save_blend_file_preset(preset_name, preset_data):
"""Save a preset to the current blend file"""
try:
import bpy
import json
scene = bpy.context.scene
# DETAILED LOGGING: Function entry
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] ==================== SAVING TO BLEND FILE ===================")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Function called with preset_name: '{preset_name}'")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Preset data type: {type(preset_data)}")
if isinstance(preset_data, dict):
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Preset data keys: {list(preset_data.keys())}")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Shader properties in preset_data:")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_shadows: {preset_data.get('disable_shadows', 'NOT FOUND')}")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_reflections: {preset_data.get('disable_reflections', 'NOT FOUND')}")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_backfacing: {preset_data.get('disable_backfacing', 'NOT FOUND')}")
else:
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] ERROR: preset_data is not a dictionary!")
# Get existing presets
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Getting existing presets from blend file...")
presets = get_blend_file_presets()
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Existing presets count: {len(presets)}")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Existing preset names: {list(presets.keys())}")
# Add or update the preset
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Adding/updating preset '{preset_name}'...")
presets[preset_name] = preset_data
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Presets now contains {len(presets)} items")
# VERIFY: Check that shader properties are in the preset we're about to save
if preset_name in presets and isinstance(presets[preset_name], dict):
saved_preset = presets[preset_name]
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] VERIFICATION - Shader properties in final preset:")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_shadows: {saved_preset.get('disable_shadows', 'NOT FOUND')}")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_reflections: {saved_preset.get('disable_reflections', 'NOT FOUND')}")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_backfacing: {saved_preset.get('disable_backfacing', 'NOT FOUND')}")
# Save back to scene custom properties
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Converting presets to JSON...")
json_data = json.dumps(presets, indent=2)
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] JSON data length: {len(json_data)} characters")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Saving to scene custom properties...")
scene["text_texture_presets"] = json_data
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Successfully saved to scene['text_texture_presets']")
# FINAL VERIFICATION: Read back from scene to confirm save
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] FINAL VERIFICATION - Reading back from scene...")
readback_data = scene.get("text_texture_presets", "{}")
if readback_data:
try:
readback_presets = json.loads(readback_data)
if preset_name in readback_presets:
readback_preset = readback_presets[preset_name]
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] READBACK VERIFICATION - Shader properties:")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_shadows: {readback_preset.get('disable_shadows', 'NOT FOUND')}")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_reflections: {readback_preset.get('disable_reflections', 'NOT FOUND')}")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_backfacing: {readback_preset.get('disable_backfacing', 'NOT FOUND')}")
else:
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] ERROR: Preset '{preset_name}' not found in readback data!")
except json.JSONDecodeError as json_err:
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] ERROR: Could not parse readback JSON: {json_err}")
else:
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] ERROR: No data in scene['text_texture_presets'] after save!")
print(f"Saved preset '{preset_name}' to blend file")
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] ================================================================")
return True
except Exception as e:
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] EXCEPTION occurred: {e}")
import traceback
print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Full traceback: {traceback.format_exc()}")
print(f"Error saving preset to blend file: {e}")
return False
def delete_blend_file_preset(preset_name):
"""Delete a preset from the current blend file"""
try:
import bpy
import json
scene = bpy.context.scene
# Get existing presets
presets = get_blend_file_presets()
# Remove the preset if it exists
if preset_name in presets:
del presets[preset_name]
scene["text_texture_presets"] = json.dumps(presets, indent=2)
print(f"Deleted preset '{preset_name}' from blend file")
return True
else:
print(f"Preset '{preset_name}' not found in blend file")
return False
except Exception as e:
print(f"Error deleting preset from blend file: {e}")
return False
def initialize_presets():
"""Initialize presets with simplified, reliable synchronization"""
try:
import bpy
if not hasattr(bpy.context, 'scene') or not bpy.context.scene:
return
props = bpy.context.scene.text_texture_props
if not props:
return
print(f"[TTG] Starting preset initialization...")
# Clear existing presets to avoid duplicates
props.presets.clear()
# Collect all presets from all sources
all_presets = {}
# 1. Load from blend file (highest priority - travels with file)
try:
blend_presets = get_blend_file_presets()
for name, data in blend_presets.items():
all_presets[name] = {
'data': data,
'source': 'BLEND_FILE'
}
print(f"[TTG] Found {len(blend_presets)} presets in blend file")
except Exception as e:
print(f"[TTG] Error loading blend file presets: {e}")
# 2. Load from persistent storage (survives addon updates)
try:
persistent_dir = get_persistent_preset_path()
if os.path.exists(persistent_dir):
for filename in os.listdir(persistent_dir):
if filename.endswith('.json'):
preset_name = os.path.splitext(filename)[0]
# Only add if not already in blend file (blend file has priority)
if preset_name not in all_presets:
try:
with open(os.path.join(persistent_dir, filename), 'r') as f:
preset_data = json.load(f)
all_presets[preset_name] = {
'data': preset_data,
'source': 'PERSISTENT_FILE'
}
except (json.JSONDecodeError, OSError) as e:
print(f"[TTG] Error reading persistent preset {preset_name}: {e}")
print(f"[TTG] Found {len([p for p in all_presets.values() if p['source'] == 'PERSISTENT_FILE'])} persistent presets")
except Exception as e:
print(f"[TTG] Error loading persistent presets: {e}")
# 3. Load from legacy storage (backward compatibility)
try:
legacy_dir = get_preset_path()
if os.path.exists(legacy_dir) and legacy_dir != get_persistent_preset_path():
for filename in os.listdir(legacy_dir):
if filename.endswith('.json'):
preset_name = os.path.splitext(filename)[0]
# Only add if not already found in higher priority sources
if preset_name not in all_presets:
try:
with open(os.path.join(legacy_dir, filename), 'r') as f:
preset_data = json.load(f)
all_presets[preset_name] = {
'data': preset_data,
'source': 'LOCAL_FILE'
}
except (json.JSONDecodeError, OSError) as e:
print(f"[TTG] Error reading legacy preset {preset_name}: {e}")
print(f"[TTG] Found {len([p for p in all_presets.values() if p['source'] == 'LOCAL_FILE'])} legacy presets")
except Exception as e:
print(f"[TTG] Error loading legacy presets: {e}")
# Add all presets to UI list
for preset_name, preset_info in all_presets.items():
preset = props.presets.add()
preset.name = preset_name
preset.source = preset_info['source']
# Report results
blend_count = len([p for p in props.presets if p.source == 'BLEND_FILE'])
persistent_count = len([p for p in props.presets if p.source == 'PERSISTENT_FILE'])
legacy_count = len([p for p in props.presets if p.source == 'LOCAL_FILE'])
print(f"[TTG] ✅ Initialized {len(props.presets)} presets ({blend_count} blend file, {persistent_count} persistent, {legacy_count} legacy)")
except Exception as e:
print(f"[TTG] Critical error in initialize_presets: {e}")
import traceback
traceback.print_exc()
def refresh_presets():
"""Refresh the preset list by rescanning files (legacy function)"""
import bpy
refresh_presets_sync()
# Force UI update
try:
if bpy.context.area:
bpy.context.area.tag_redraw()
except:
pass
def refresh_presets_sync():
"""Centralized, synchronous preset refresh - guarantees completion"""
try:
import bpy
print("[TTG] 🔄 Synchronizing presets...")
# Ensure we have valid context
if not hasattr(bpy.context, 'scene') or not bpy.context.scene:
print("[TTG] ⚠️ No valid scene context for preset refresh")
return False
props = bpy.context.scene.text_texture_props
if not props:
print("[TTG] ⚠️ No text_texture_props found")
return False
# Run the simplified initialization
initialize_presets()
# Validate that presets are actually loaded
preset_count = len(props.presets)
if preset_count == 0:
print("[TTG] ⚠️ Warning: No presets found after refresh")
else:
print(f"[TTG] ✅ Preset sync complete - {preset_count} presets available")
return True
except Exception as e:
print(f"[TTG] ❌ Error in refresh_presets_sync: {e}")
import traceback
traceback.print_exc()
return False
def ensure_presets_available():
"""Defensive programming - ensure presets are loaded before UI operations"""
try:
import bpy
if not hasattr(bpy.context, 'scene') or not bpy.context.scene:
return False
props = bpy.context.scene.text_texture_props
if not props:
return False
# If no presets loaded, try to load them
if len(props.presets) == 0:
print("[TTG] 🛡️ No presets available - attempting to load...")
return refresh_presets_sync()
return True
except Exception as e:
print(f"[TTG] Error in ensure_presets_available: {e}")
return False

18
properties/__init__.py Normal file
View File

@@ -0,0 +1,18 @@
"""
Text Texture Generator - Properties Module
This module contains all property group definitions and configuration systems
for the text texture generator addon.
"""
from .property_groups import (
TEXT_TEXTURE_ImageOverlay,
TEXT_TEXTURE_Preset,
TEXT_TEXTURE_Properties
)
__all__ = [
'TEXT_TEXTURE_ImageOverlay',
'TEXT_TEXTURE_Preset',
'TEXT_TEXTURE_Properties',
]

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

30
ui/__init__.py Normal file
View File

@@ -0,0 +1,30 @@
# Import all UI functionality
from .preview import *
from .panels import (
TEXT_TEXTURE_PT_panel,
TEXT_TEXTURE_PT_panel_3d,
TEXT_TEXTURE_PT_panel_properties,
)
# Menu functions for Blender integration
def menu_func_node_editor(self, context):
"""Add menu item to Node Editor's Add menu"""
self.layout.operator("text_texture.generate", text="Text Texture", icon='IMAGE_DATA')
def menu_func_view3d(self, context):
"""Add menu item to 3D View's Add menu"""
self.layout.operator("text_texture.generate", text="Text Texture", icon='IMAGE_DATA')
def menu_func_image(self, context):
"""Add menu item to Image Editor menu"""
self.layout.operator("text_texture.generate", text="Text Texture", icon='IMAGE_DATA')
# Export all UI functions
__all__ = [
'TEXT_TEXTURE_PT_panel',
'TEXT_TEXTURE_PT_panel_3d',
'TEXT_TEXTURE_PT_panel_properties',
'menu_func_node_editor',
'menu_func_view3d',
'menu_func_image'
]

Binary file not shown.

Binary file not shown.

940
ui/panels.py Normal file
View File

@@ -0,0 +1,940 @@
"""
Text Texture Generator - UI Panels Module
Contains all UI panel classes for different editor contexts.
"""
import bpy
from bpy.types import Panel
from ..core.generation_engine import generate_preview
# ============================================================================
# SHARED UI COMPONENTS
# ============================================================================
def draw_collapsible_header(layout, prop_name, text, icon='TRIA_DOWN'):
"""Draw a collapsible section header with expand/collapse arrow"""
props = bpy.context.scene.text_texture_props
expanded = getattr(props, prop_name)
box = layout.box()
header_row = box.row()
# Draw arrow icon and toggle expanded state
arrow_icon = 'TRIA_DOWN' if expanded else 'TRIA_RIGHT'
header_row.prop(props, prop_name, text="", icon=arrow_icon, emboss=False)
header_row.label(text=text, icon=icon)
return box if expanded else None
def draw_text_input_section(layout, props):
"""Draw the main text input field"""
layout.prop(props, "text", text="")
def draw_font_dropdown_section(layout, props):
"""Draw basic font dropdown for top level"""
row = layout.row(align=True)
row.prop(props, "font_path", text="")
row.operator("text_texture.refresh_fonts", text="", icon='FILE_REFRESH')
def draw_generate_button(layout):
"""Draw the main generate button"""
row = layout.row()
row.scale_y = 1.5
row.operator("text_texture.generate", text="Generate Text Texture", icon='IMAGE_DATA')
def draw_image_overlays_section(layout, props):
"""Draw image overlays controls with collapsible individual overlays"""
# Add overlay button
row = layout.row()
row.operator("text_texture.add_overlay", text="Add Image Overlay", icon='ADD')
# Individual overlay controls
if props.image_overlays:
layout.separator()
for i, overlay in enumerate(props.image_overlays):
# Create collapsible box for each overlay
box = layout.box()
# Header row with controls
header_row = box.row(align=True)
# Enable/Disable checkbox with clear label
header_row.prop(overlay, "enabled", text="", icon='CHECKBOX_HLT' if overlay.enabled else 'CHECKBOX_DEHLT')
# Overlay name
name_row = header_row.row()
name_row.enabled = overlay.enabled # Gray out name if disabled
name_row.prop(overlay, "name", text="")
# Action buttons
# Duplicate button
dup_op = header_row.operator("text_texture.duplicate_overlay", text="", icon='DUPLICATE')
dup_op.overlay_index = i
# Delete button
del_op = header_row.operator("text_texture.delete_overlay", text="", icon='TRASH')
del_op.overlay_index = i
# Move buttons for reordering
if i > 0:
move_up_op = header_row.operator("text_texture.move_overlay", text="", icon='TRIA_UP')
move_up_op.overlay_index = i
move_up_op.direction = 'UP'
if i < len(props.image_overlays) - 1:
move_down_op = header_row.operator("text_texture.move_overlay", text="", icon='TRIA_DOWN')
move_down_op.overlay_index = i
move_down_op.direction = 'DOWN'
# Show details section with enable/disable state
details_col = box.column()
details_col.enabled = overlay.enabled # Gray out all controls if disabled
# Status indicator
if not overlay.enabled:
status_row = details_col.row()
status_row.label(text="⚠️ Overlay Disabled", icon='INFO')
details_col.separator()
# Image file selection
details_col.prop(overlay, "image_path", text="Image File")
# Positioning mode
details_col.prop(overlay, "positioning_mode", text="Position Mode")
if overlay.positioning_mode == 'ABSOLUTE':
# Position controls for absolute mode
pos_row = details_col.row(align=True)
pos_row.prop(overlay, "x_position", text="X Position")
pos_row.prop(overlay, "y_position", text="Y Position")
elif overlay.positioning_mode in ['APPEND', 'PREPEND']:
# Spacing controls for append/prepend modes with percentage labels
details_col.prop(overlay, "text_spacing", text="Text Spacing (%)")
details_col.prop(overlay, "image_spacing", text="Image Spacing (%)")
# Transform controls
trans_row = details_col.row(align=True)
trans_row.prop(overlay, "scale", text="Scale")
trans_row.prop(overlay, "rotation", text="Rotation")
trans_row.prop(overlay, "z_index", text="Z-Level")
def draw_positioning_section(layout, props):
"""Draw positioning and alignment controls"""
from ..utils.system import get_anchor_point_matrix
# Dimensions moved here from top level
layout.label(text="Dimensions:", icon='SETTINGS')
row = layout.row(align=True)
row.prop(props, "texture_width", text="Width")
row.prop(props, "texture_height", text="Height")
layout.separator()
# Prepend/Append Text Section
layout.label(text="Additional Text:", icon='TEXT')
# Compact layout selector
layout.prop(props, "prepend_append_layout", text="Layout")
# Minimal prepend/append controls
row = layout.row(align=True)
row.prop(props, "prepend_text", text="Prepend")
if props.prepend_text.strip():
row.prop(props, "prepend_margin", text="", slider=True)
row = layout.row(align=True)
row.prop(props, "append_text", text="Append")
if props.append_text.strip():
row.prop(props, "append_margin", text="", slider=True)
layout.separator()
layout.prop(props, "position_mode", text="Mode")
if props.position_mode == 'PRESET':
layout.separator()
layout.label(text="Anchor Point:", icon='ANCHOR_TOP')
anchor_matrix = get_anchor_point_matrix()
for row_idx, row in enumerate(anchor_matrix):
grid_row = layout.row(align=True)
grid_row.scale_y = 1.2
for col_idx, anchor in enumerate(row):
if anchor == props.anchor_point:
op = grid_row.operator("text_texture.set_anchor_point", text="", depress=True)
else:
op = grid_row.operator("text_texture.set_anchor_point", text="")
op.anchor_point = anchor
# Margins
layout.separator()
margin_row = layout.row(align=True)
margin_row.label(text="Margins:")
margin_row.prop(props, "margins_linked", text="", icon='LINKED' if props.margins_linked else 'UNLINKED', toggle=True)
margins_row = layout.row(align=True)
margins_row.prop(props, "margin_x", text="X")
if not props.margins_linked:
margins_row.prop(props, "margin_y", text="Y")
# Offsets
layout.separator()
layout.label(text="Fine-tune (pixels):")
offset_row = layout.row(align=True)
offset_row.prop(props, "offset_x", text="X")
offset_row.prop(props, "offset_y", text="Y")
else: # MANUAL mode
layout.separator()
layout.prop(props, "manual_position_unit", text="Unit")
manual_row = layout.row(align=True)
manual_row.prop(props, "manual_position_x", text="X")
manual_row.prop(props, "manual_position_y", text="Y")
layout.separator()
layout.prop(props, "constrain_to_canvas")
def draw_font_settings_section(layout, props):
"""Draw extended font settings"""
# Font size moved here from top level
layout.prop(props, "font_size")
layout.separator()
layout.label(text="Font Selection:")
# Default font dropdown moved here from top level
row = layout.row(align=True)
row.prop(props, "font_path", text="")
row.operator("text_texture.refresh_fonts", text="", icon='FILE_REFRESH')
layout.separator()
# Custom font controls moved here from top level
layout.prop(props, "use_custom_font")
if props.use_custom_font:
layout.prop(props, "custom_font_path", text="")
layout.separator()
row = layout.row(align=True)
row.prop(props, "padding")
row.prop(props, "text_align")
def draw_colors_section(layout, props):
"""Draw color controls for text and background"""
layout.label(text="Text Color:", icon='COLOR')
layout.prop(props, "text_color", text="")
layout.separator()
layout.label(text="Background Color:", icon='IMAGE_PLANE')
layout.prop(props, "background_transparent")
if not props.background_transparent:
layout.prop(props, "background_color", text="")
def draw_preview_options_section(layout, props):
"""Draw preview options (preview-specific settings only)"""
layout.prop(props, "preview_size", text="Preview Size")
layout.separator()
layout.label(text="Preview Background Display:", icon='SCENE')
layout.prop(props, "preview_bg_type", text="Background")
if props.preview_bg_type == 'color':
layout.prop(props, "preview_bg_color", text="Preview Display Color")
elif props.preview_bg_type == 'gradient':
layout.prop(props, "preview_bg_color", text="Color 1")
layout.prop(props, "preview_bg_color2", text="Color 2")
def draw_presets_section(layout, props):
"""Draw presets save/load controls"""
# Save preset section
save_row = layout.row(align=True)
save_row.prop(props, "new_preset_name", text="", placeholder="Preset Name")
save_row.operator("text_texture.save_preset", text="Save", icon='FILE_NEW')
layout.separator()
# Import/Export and refresh buttons
backup_row = layout.row(align=True)
backup_row.operator("text_texture.export_presets", text="Export Backup", icon='EXPORT')
backup_row.operator("text_texture.import_presets", text="Import Backup", icon='IMPORT')
utility_row = layout.row(align=True)
utility_row.operator("text_texture.refresh_presets", text="Refresh", icon='FILE_REFRESH')
utility_row.operator("text_texture.show_migration_report", text="Migration Report", icon='INFO')
layout.separator()
# Load presets section
if props.presets:
layout.label(text=f"📁 Saved Presets ({len(props.presets)}):", icon='PRESET')
# Separate presets by source
blend_presets = [p for p in props.presets if p.source == 'BLEND_FILE']
persistent_presets = [p for p in props.presets if p.source == 'PERSISTENT_FILE']
local_presets = [p for p in props.presets if p.source == 'LOCAL_FILE']
# Show blend file presets first
if blend_presets:
layout.label(text="🎯 Blend File Presets (travel with file):", icon='FILE_BLEND')
for preset in blend_presets:
row = layout.row()
load_btn = row.operator("text_texture.load_preset", text=f"📋 {preset.name}")
load_btn.preset_name = preset.name
# Quick overwrite button
overwrite_btn = row.operator("text_texture.save_preset", text="", icon='FILE_REFRESH')
overwrite_btn.overwrite = True
# Set the preset name so it can be overwritten
overwrite_btn.preset_name = preset.name
delete_btn = row.operator("text_texture.delete_preset", text="", icon='TRASH')
delete_btn.preset_name = preset.name
# Show persistent presets second
if persistent_presets:
if blend_presets:
layout.separator()
layout.label(text="🛡️ Persistent Presets (survive addon updates):", icon='PREFERENCES')
for preset in persistent_presets:
row = layout.row()
load_btn = row.operator("text_texture.load_preset", text=f"📋 {preset.name}")
load_btn.preset_name = preset.name
# Quick overwrite button
overwrite_btn = row.operator("text_texture.save_preset", text="", icon='FILE_REFRESH')
overwrite_btn.overwrite = True
# Set the preset name so it can be overwritten
overwrite_btn.preset_name = preset.name
delete_btn = row.operator("text_texture.delete_preset", text="", icon='TRASH')
delete_btn.preset_name = preset.name
# Show local presets third
if local_presets:
if blend_presets or persistent_presets:
layout.separator()
layout.label(text="💻 Local Presets (this machine only):", icon='DISK_DRIVE')
for preset in local_presets:
row = layout.row()
load_btn = row.operator("text_texture.load_preset", text=f"📋 {preset.name}")
load_btn.preset_name = preset.name
# Quick overwrite button
overwrite_btn = row.operator("text_texture.save_preset", text="", icon='FILE_REFRESH')
overwrite_btn.overwrite = True
# Set the preset name so it can be overwritten
overwrite_btn.preset_name = preset.name
delete_btn = row.operator("text_texture.delete_preset", text="", icon='TRASH')
delete_btn.preset_name = preset.name
else:
layout.label(text="📂 No presets saved yet", icon='INFO')
layout.label(text="💡 Save your first preset above!", icon='LIGHT_DATA')
layout.separator()
info_box = layout.box()
info_box.label(text=" Presets are now saved in the blend file", icon='INFO')
info_box.label(text="They will travel with your .blend file!")
def draw_shader_section(layout, props):
"""Draw shader generation controls"""
# Create Shader Button (as requested)
row = layout.row()
row.scale_y = 1.5
row.operator("text_texture.generate_shader", text="Create Shader", icon='MATERIAL')
layout.separator()
# Clean minimalist controls
col = layout.column(align=True)
col.prop(props, "shader_type", text="Type")
# Show relevant options based on shader type
if props.shader_type == 'PRINCIPLED':
col.prop(props, "shader_connection", text="Connect")
if props.shader_connection == 'EMISSION':
col.prop(props, "emission_strength", text="Strength")
elif props.shader_type == 'EMISSION':
col.prop(props, "emission_strength", text="Strength")
layout.separator()
# Simple toggles
col = layout.column(align=True)
col.prop(props, "use_alpha", text="Use Alpha Channel")
col.prop(props, "auto_assign_material", text="Auto-Assign to Object")
# Clean status info
if props.auto_assign_material:
layout.separator()
info_row = layout.row()
info_row.scale_y = 0.8
try:
context = bpy.context
if context and context.active_object and context.active_object.type in ['MESH', 'CURVE']:
info_row.label(text=f"{context.active_object.name}", icon='CHECKMARK')
else:
info_row.label(text="Select mesh/curve object", icon='INFO')
except:
info_row.label(text="Select mesh/curve object", icon='INFO')
def draw_shader_options_only(layout, props):
"""Draw only shader options without the Create button - for collapsible section"""
# Clean grid layout for shader options
grid = layout.grid_flow(row_major=True, columns=2, align=True)
grid.prop(props, "shader_type", text="Type")
if props.shader_type == 'PRINCIPLED':
grid.prop(props, "shader_connection", text="Connect")
if props.shader_connection == 'EMISSION':
grid.prop(props, "emission_strength", text="Strength")
elif props.shader_type == 'EMISSION':
grid.prop(props, "emission_strength", text="Strength")
# Simple row for toggles
layout.separator()
row = layout.row(align=True)
row.prop(props, "use_alpha", toggle=True)
row.prop(props, "auto_assign_material", toggle=True)
# Shadow, reflection, and backfacing toggles
row = layout.row(align=True)
row.prop(props, "disable_shadows", toggle=True)
row.prop(props, "disable_reflections", toggle=True)
row.prop(props, "disable_backfacing", toggle=True)
def draw_normal_maps_section(layout, props):
"""Draw normal map generation controls"""
# Main toggle for normal map generation
layout.prop(props, "generate_normal_map", text="Generate Normal Map", icon='TEXTURE')
# Show controls only if normal map generation is enabled
if props.generate_normal_map:
layout.separator()
# Normal map settings in a clean layout
col = layout.column(align=True)
col.prop(props, "normal_map_strength", text="Strength", slider=True)
col.prop(props, "normal_map_blur_radius", text="Blur Radius", slider=True)
layout.separator()
layout.prop(props, "normal_map_invert", text="Invert (Emboss ↔ Deboss)")
# Info box with usage tips
layout.separator()
info_box = layout.box()
info_box.scale_y = 0.8
info_box.label(text="💡 Normal maps add depth and detail", icon='INFO')
info_box.label(text="• Higher strength = more pronounced effect")
info_box.label(text="• Blur smooths sharp edges")
info_box.label(text="• Invert changes raised/recessed effect")
def draw_text_fitting_section(layout, props):
"""Draw text fitting controls"""
# Main toggle for text fitting
layout.prop(props, "enable_text_fitting", text="Enable Text Fitting", icon='FULLSCREEN_ENTER')
# Show controls only if text fitting is enabled
if props.enable_text_fitting:
layout.separator()
# Fitting mode selection
layout.prop(props, "text_fitting_mode", text="Fitting Mode")
layout.separator()
# Margin setting
layout.prop(props, "text_fitting_margin", text="Margin", slider=True)
layout.separator()
# Font size limits
col = layout.column(align=True)
col.label(text="Font Size Limits:", icon='RESTRICT_SELECT_ON')
row = col.row(align=True)
row.prop(props, "min_font_size", text="Min")
row.prop(props, "max_font_size", text="Max")
# Info box with usage tips
layout.separator()
info_box = layout.box()
info_box.scale_y = 0.8
info_box.label(text="💡 Text fitting automatically sizes text", icon='INFO')
if props.text_fitting_mode == 'FIT_WIDTH':
info_box.label(text="• Fits text width to canvas width")
elif props.text_fitting_mode == 'FIT_HEIGHT':
info_box.label(text="• Fits text height to canvas height")
elif props.text_fitting_mode == 'FIT_BOTH':
info_box.label(text="• Fits both width and height (may distort)")
elif props.text_fitting_mode == 'FIT_CONTAIN':
info_box.label(text="• Fits entirely within canvas (preserves ratio)")
info_box.label(text="• Respects min/max font size limits")
def draw_multiline_section(layout, props):
"""Draw multiline text controls"""
# Main toggle for multiline text
layout.prop(props, "enable_multiline", text="Enable Multiline", icon='TEXT')
# Show controls only if multiline is enabled
if props.enable_multiline:
layout.separator()
# Line height and alignment settings
col = layout.column(align=True)
col.prop(props, "line_height", text="Line Height", slider=True)
col.prop(props, "text_alignment", text="Alignment")
layout.separator()
# Auto wrap settings
layout.prop(props, "auto_wrap", text="Auto Wrap")
if props.auto_wrap:
layout.prop(props, "wrap_width", text="Wrap Width (0 = texture width)")
layout.separator()
# Line limits and spacing
col = layout.column(align=True)
col.prop(props, "max_lines", text="Max Lines (0 = unlimited)")
col.prop(props, "line_spacing_pixels", text="Line Spacing (px)")
# Info box with usage tips
layout.separator()
info_box = layout.box()
info_box.scale_y = 0.8
info_box.label(text="💡 Multiline text supports:", icon='INFO')
info_box.label(text="• Use \\n for manual line breaks")
info_box.label(text="• Auto-wrap fits text to width")
info_box.label(text="• Alignment works per line")
info_box.label(text="• Line height controls spacing")
def draw_stroke_section(layout, props):
"""Draw stroke and outline controls"""
# Main toggle for stroke
layout.prop(props, "enable_stroke", text="Enable Stroke", icon='MOD_OUTLINE')
# Show controls only if stroke is enabled
if props.enable_stroke:
layout.separator()
# Stroke width and color
col = layout.column(align=True)
col.prop(props, "stroke_width", text="Width", slider=True)
col.prop(props, "stroke_color", text="Color")
layout.separator()
# Stroke position
layout.prop(props, "stroke_position", text="Position")
# Info box with usage tips
layout.separator()
info_box = layout.box()
info_box.scale_y = 0.8
info_box.label(text="💡 Stroke adds outline to text:", icon='INFO')
if props.stroke_position == 'OUTER':
info_box.label(text="• Outer: Expands text bounds outward")
elif props.stroke_position == 'INNER':
info_box.label(text="• Inner: Shrinks text, stroke inside")
elif props.stroke_position == 'CENTER':
info_box.label(text="• Center: Stroke on text edge")
info_box.label(text="• Width 0 = no stroke")
info_box.label(text="• Works with multiline text")
def draw_shadow_glow_section(layout, props):
"""Draw shadow and glow effects controls"""
# Drop shadow section
layout.prop(props, "enable_shadow", text="Enable Drop Shadow", icon='SHADERFX')
if props.enable_shadow:
layout.separator()
# Shadow offset controls in a row
offset_row = layout.row(align=True)
offset_row.prop(props, "shadow_offset_x", text="X Offset")
offset_row.prop(props, "shadow_offset_y", text="Y Offset")
# Shadow properties
col = layout.column(align=True)
col.prop(props, "shadow_blur", text="Shadow Blur", slider=True)
col.prop(props, "shadow_spread", text="Shadow Spread", slider=True)
col.prop(props, "shadow_color", text="Shadow Color")
layout.separator()
# Glow section
layout.prop(props, "enable_glow", text="Enable Glow", icon='LIGHT_SUN')
if props.enable_glow:
layout.separator()
# Glow properties
col = layout.column(align=True)
col.prop(props, "glow_blur", text="Glow Blur", slider=True)
col.prop(props, "glow_color", text="Glow Color")
# Info box with usage tips
if props.enable_shadow or props.enable_glow:
layout.separator()
info_box = layout.box()
info_box.scale_y = 0.8
info_box.label(text="💡 Shadow & Glow effects:", icon='INFO')
if props.enable_shadow:
info_box.label(text="• Drop shadow: offset + blur for depth")
if props.enable_glow:
info_box.label(text="• Glow: blur without offset for aura effect")
info_box.label(text="• Higher blur = softer effect")
info_box.label(text="• Alpha controls effect intensity")
def draw_blur_section(layout, props):
"""Draw blur effects controls"""
# Main toggle for blur
layout.prop(props, "enable_blur", text="Enable Blur", icon='MOD_SMOOTH')
# Show controls only if blur is enabled
if props.enable_blur:
layout.separator()
# Blur radius setting
layout.prop(props, "blur_radius", text="Blur Radius", slider=True)
# Info box with usage tips
layout.separator()
info_box = layout.box()
info_box.scale_y = 0.8
info_box.label(text="💡 Blur effect softens text edges:", icon='INFO')
info_box.label(text="• Applied to main text content only")
info_box.label(text="• Higher radius = softer/more blurred")
info_box.label(text="• Works with all other effects")
info_box.label(text="• Radius 0 = no blur effect")
# ============================================================================
# UI PANELS
# ============================================================================
class TEXT_TEXTURE_PT_panel(Panel):
"""Main Panel in Shader Editor"""
bl_label = "Text Texture Generator"
bl_idname = "TEXT_TEXTURE_PT_panel"
bl_space_type = 'NODE_EDITOR'
bl_region_type = 'UI'
bl_category = "Tool"
@classmethod
def poll(cls, context):
# DEBUG: Log poll method calls
print(f"[TTG DEBUG] Panel poll called - space_data: {context.space_data}")
print(f"[TTG DEBUG] space_data.type: {context.space_data.type if context.space_data else 'None'}")
if hasattr(context.space_data, 'tree_type'):
print(f"[TTG DEBUG] tree_type: {context.space_data.tree_type}")
else:
print("[TTG DEBUG] No tree_type attribute")
# More permissive poll condition to ensure panel shows up
if context.space_data.type == 'NODE_EDITOR':
if hasattr(context.space_data, 'tree_type'):
result = context.space_data.tree_type == 'ShaderNodeTree'
print(f"[TTG DEBUG] Poll result: {result}")
return result
else:
# Allow panel even without tree_type (sometimes happens)
print("[TTG DEBUG] Poll result: True (no tree_type but NODE_EDITOR)")
return True
print("[TTG DEBUG] Poll result: False (not NODE_EDITOR)")
return False
def draw(self, context):
layout = self.layout
props = context.scene.text_texture_props
# TOP LEVEL - Always Visible Controls
# Text Input
box = layout.box()
box.label(text="Text:", icon='TEXT')
draw_text_input_section(box, props)
# Generate Button
layout.separator(factor=0.5)
draw_generate_button(layout)
# CREATE SHADER BUTTON AT TOP LEVEL - AS REQUESTED
layout.separator(factor=0.5)
create_shader_row = layout.row()
create_shader_row.scale_y = 1.5
create_shader_row.operator("text_texture.generate_shader", text="Create Shader", icon='MATERIAL')
# Preview Button at top level
layout.separator(factor=0.5)
preview_row = layout.row()
preview_row.scale_y = 1.5
preview_row.operator("text_texture.view_preview_zoom", text="Open Preview Window", icon='WINDOW')
layout.separator(factor=1.2)
# COLLAPSIBLE SECTIONS
# Multiline Section (collapsed by default)
multiline_section = draw_collapsible_header(layout, "expand_multiline", "Multiline Text", 'TEXT')
if multiline_section:
draw_multiline_section(multiline_section, props)
# Shader Section (collapsed by default) - ONLY OPTIONS, NOT THE BUTTON
shader_section = draw_collapsible_header(layout, "expand_shader", "Shader Options", 'SETTINGS')
if shader_section:
draw_shader_options_only(shader_section, props)
# Image Overlays Section (collapsed by default)
overlay_section = draw_collapsible_header(layout, "expand_image_overlays", "Image Overlays", 'IMAGE_DATA')
if overlay_section:
draw_image_overlays_section(overlay_section, props)
# Positioning & Layout Section (collapsed by default)
position_section = draw_collapsible_header(layout, "expand_positioning", "Positioning & Layout", 'ANCHOR_CENTER')
if position_section:
draw_positioning_section(position_section, props)
# Font Settings Section (collapsed by default)
font_section = draw_collapsible_header(layout, "expand_font_settings", "Font Settings", 'FONT_DATA')
if font_section:
draw_font_settings_section(font_section, props)
# Text Fitting Section (collapsed by default)
text_fitting_section = draw_collapsible_header(layout, "expand_text_fitting", "Text Fitting", 'FULLSCREEN_ENTER')
if text_fitting_section:
draw_text_fitting_section(text_fitting_section, props)
# Stroke Section (collapsed by default)
stroke_section = draw_collapsible_header(layout, "expand_stroke", "Stroke & Outlines", 'MOD_OUTLINE')
if stroke_section:
draw_stroke_section(stroke_section, props)
# Shadow & Glow Section (collapsed by default)
shadow_glow_section = draw_collapsible_header(layout, "expand_shadow_glow", "Shadow & Glow Effects", 'SHADERFX')
if shadow_glow_section:
draw_shadow_glow_section(shadow_glow_section, props)
# Blur Section (collapsed by default)
blur_section = draw_collapsible_header(layout, "expand_blur", "Blur Effects", 'MOD_SMOOTH')
if blur_section:
draw_blur_section(blur_section, props)
# Normal Maps Section (collapsed by default)
normal_maps_section = draw_collapsible_header(layout, "expand_normal_maps", "Normal Maps", 'TEXTURE')
if normal_maps_section:
draw_normal_maps_section(normal_maps_section, props)
# Colors Section (collapsed by default)
colors_section = draw_collapsible_header(layout, "expand_colors", "Colors", 'COLOR')
if colors_section:
draw_colors_section(colors_section, props)
# Presets Section (collapsed by default)
presets_section = draw_collapsible_header(layout, "expand_presets", "Presets", 'PRESET')
if presets_section:
draw_presets_section(presets_section, props)
class TEXT_TEXTURE_PT_panel_3d(Panel):
"""3D View Panel - Access from sidebar (N key)"""
bl_label = "Text Texture Generator"
bl_idname = "TEXT_TEXTURE_PT_panel_3d"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = "Tool"
bl_options = {'DEFAULT_CLOSED'}
def draw(self, context):
layout = self.layout
props = context.scene.text_texture_props
# TOP LEVEL - Always Visible Controls
# Text Input
box = layout.box()
box.label(text="Text:", icon='TEXT')
draw_text_input_section(box, props)
# Generate Button
layout.separator(factor=0.5)
draw_generate_button(layout)
# CREATE SHADER BUTTON AT TOP LEVEL - AS REQUESTED
layout.separator(factor=0.5)
create_shader_row = layout.row()
create_shader_row.scale_y = 1.5
create_shader_row.operator("text_texture.generate_shader", text="Create Shader", icon='MATERIAL')
# Preview Button at top level
layout.separator(factor=0.5)
preview_row = layout.row()
preview_row.scale_y = 1.5
preview_row.operator("text_texture.view_preview_zoom", text="Open Preview Window", icon='WINDOW')
layout.separator(factor=1.2)
# COLLAPSIBLE SECTIONS
# Multiline Section (collapsed by default)
multiline_section = draw_collapsible_header(layout, "expand_multiline", "Multiline Text", 'TEXT')
if multiline_section:
draw_multiline_section(multiline_section, props)
# Shader Section (collapsed by default) - ONLY OPTIONS, NOT THE BUTTON
shader_section = draw_collapsible_header(layout, "expand_shader", "Shader Options", 'SETTINGS')
if shader_section:
draw_shader_options_only(shader_section, props)
# Image Overlays Section (collapsed by default)
overlay_section = draw_collapsible_header(layout, "expand_image_overlays", "Image Overlays", 'IMAGE_DATA')
if overlay_section:
draw_image_overlays_section(overlay_section, props)
# Positioning & Layout Section (collapsed by default)
position_section = draw_collapsible_header(layout, "expand_positioning", "Positioning & Layout", 'ANCHOR_CENTER')
if position_section:
draw_positioning_section(position_section, props)
# Font Settings Section (collapsed by default)
font_section = draw_collapsible_header(layout, "expand_font_settings", "Font Settings", 'FONT_DATA')
if font_section:
draw_font_settings_section(font_section, props)
# Text Fitting Section (collapsed by default)
text_fitting_section = draw_collapsible_header(layout, "expand_text_fitting", "Text Fitting", 'FULLSCREEN_ENTER')
if text_fitting_section:
draw_text_fitting_section(text_fitting_section, props)
# Stroke Section (collapsed by default)
stroke_section = draw_collapsible_header(layout, "expand_stroke", "Stroke & Outlines", 'MOD_OUTLINE')
if stroke_section:
draw_stroke_section(stroke_section, props)
# Shadow & Glow Section (collapsed by default)
shadow_glow_section = draw_collapsible_header(layout, "expand_shadow_glow", "Shadow & Glow Effects", 'SHADERFX')
if shadow_glow_section:
draw_shadow_glow_section(shadow_glow_section, props)
# Tiling Section removed - feature was removed from addon
# Blur Section (collapsed by default)
blur_section = draw_collapsible_header(layout, "expand_blur", "Blur Effects", 'MOD_SMOOTH')
if blur_section:
draw_blur_section(blur_section, props)
# Batch Processing Section removed - feature was removed from addon
# Normal Maps Section (collapsed by default)
normal_maps_section = draw_collapsible_header(layout, "expand_normal_maps", "Normal Maps", 'TEXTURE')
if normal_maps_section:
draw_normal_maps_section(normal_maps_section, props)
# Colors Section (collapsed by default)
colors_section = draw_collapsible_header(layout, "expand_colors", "Colors", 'COLOR')
if colors_section:
draw_colors_section(colors_section, props)
# Presets Section (collapsed by default)
presets_section = draw_collapsible_header(layout, "expand_presets", "Presets", 'PRESET')
if presets_section:
draw_presets_section(presets_section, props)
class TEXT_TEXTURE_PT_panel_properties(Panel):
"""Properties Panel"""
bl_label = "Text Texture Generator"
bl_idname = "TEXT_TEXTURE_PT_panel_properties"
bl_space_type = 'PROPERTIES'
bl_region_type = 'WINDOW'
bl_context = "material"
bl_category = "Tool"
def draw(self, context):
layout = self.layout
props = context.scene.text_texture_props
# TOP LEVEL - Always Visible Controls
# Text Input
box = layout.box()
box.label(text="Text:", icon='TEXT')
draw_text_input_section(box, props)
# Generate Button
layout.separator(factor=0.5)
draw_generate_button(layout)
# Preview Button at top level
layout.separator(factor=0.5)
preview_row = layout.row()
preview_row.scale_y = 1.5
preview_row.operator("text_texture.view_preview_zoom", text="Open Preview Window", icon='WINDOW')
layout.separator(factor=1.2)
# COLLAPSIBLE SECTIONS
# Multiline Section (collapsed by default)
multiline_section = draw_collapsible_header(layout, "expand_multiline", "Multiline Text", 'TEXT')
if multiline_section:
draw_multiline_section(multiline_section, props)
# Image Overlays Section (collapsed by default)
overlay_section = draw_collapsible_header(layout, "expand_image_overlays", "Image Overlays", 'IMAGE_DATA')
if overlay_section:
draw_image_overlays_section(overlay_section, props)
# Positioning & Layout Section (collapsed by default)
position_section = draw_collapsible_header(layout, "expand_positioning", "Positioning & Layout", 'ANCHOR_CENTER')
if position_section:
draw_positioning_section(position_section, props)
# Font Settings Section (collapsed by default)
font_section = draw_collapsible_header(layout, "expand_font_settings", "Font Settings", 'FONT_DATA')
if font_section:
draw_font_settings_section(font_section, props)
# Text Fitting Section (collapsed by default)
text_fitting_section = draw_collapsible_header(layout, "expand_text_fitting", "Text Fitting", 'FULLSCREEN_ENTER')
if text_fitting_section:
draw_text_fitting_section(text_fitting_section, props)
# Stroke Section (collapsed by default)
stroke_section = draw_collapsible_header(layout, "expand_stroke", "Stroke & Outlines", 'MOD_OUTLINE')
if stroke_section:
draw_stroke_section(stroke_section, props)
# Shadow & Glow Section (collapsed by default)
shadow_glow_section = draw_collapsible_header(layout, "expand_shadow_glow", "Shadow & Glow Effects", 'SHADERFX')
if shadow_glow_section:
draw_shadow_glow_section(shadow_glow_section, props)
# Tiling Section removed - feature was removed from addon
# Blur Section (collapsed by default)
blur_section = draw_collapsible_header(layout, "expand_blur", "Blur Effects", 'MOD_SMOOTH')
if blur_section:
draw_blur_section(blur_section, props)
# Batch Processing Section removed - feature was removed from addon
# Normal Maps Section (collapsed by default)
normal_maps_section = draw_collapsible_header(layout, "expand_normal_maps", "Normal Maps", 'TEXTURE')
if normal_maps_section:
draw_normal_maps_section(normal_maps_section, props)
# Colors Section (collapsed by default)
colors_section = draw_collapsible_header(layout, "expand_colors", "Colors", 'COLOR')
if colors_section:
draw_colors_section(colors_section, props)
# Presets Section (collapsed by default)
presets_section = draw_collapsible_header(layout, "expand_presets", "Presets", 'PRESET')
if presets_section:
draw_presets_section(presets_section, props)

153
ui/preview.py Normal file
View File

@@ -0,0 +1,153 @@
# Font validation and preview functionality
import bpy
import os
import platform
from PIL import Image, ImageDraw, ImageFont
def test_font_mixed_case_support(font_path):
"""
Test if a font properly supports mixed case rendering.
Returns True if the font supports mixed case, False if it only renders uppercase.
"""
try:
# Test string with mixed case
test_text = "AaBbCc"
# Create a test image to render the font
test_size = (200, 100)
test_image = Image.new('RGBA', test_size, (0, 0, 0, 0))
draw = ImageDraw.Draw(test_image)
# Try to load the font
try:
font = ImageFont.truetype(font_path, 24)
except (OSError, IOError):
print(f"Text Texture Generator: Could not load font for testing: {font_path}")
return False
# Render the test text
draw.text((10, 10), test_text, font=font, fill=(255, 255, 255, 255))
# Get the rendered pixels
pixels = list(test_image.getdata())
# Create another image with uppercase version
test_image_upper = Image.new('RGBA', test_size, (0, 0, 0, 0))
draw_upper = ImageDraw.Draw(test_image_upper)
draw_upper.text((10, 10), test_text.upper(), font=font, fill=(255, 255, 255, 255))
pixels_upper = list(test_image_upper.getdata())
# Compare the two renderings - if they're identical, the font only supports uppercase
if pixels == pixels_upper:
print(f"Text Texture Generator: Font only supports uppercase rendering: {os.path.basename(font_path)}")
return False
else:
print(f"Text Texture Generator: Font supports mixed case rendering: {os.path.basename(font_path)}")
return True
except Exception as e:
print(f"Text Texture Generator: Error testing font mixed case support: {str(e)}")
return False
def get_validated_font(font_path, fallback_fonts=None):
"""
Get a validated font that supports mixed case rendering.
Returns the original font path if valid, otherwise returns a fallback font.
"""
if not font_path or not os.path.exists(font_path):
print(f"Text Texture Generator: Font file not found: {font_path}")
return get_system_fallback_fonts()[0] if get_system_fallback_fonts() else None
# Test if the font supports mixed case
if test_font_mixed_case_support(font_path):
return font_path
else:
print(f"Text Texture Generator: Font does not support mixed case, using fallback: {font_path}")
# Try fallback fonts if provided
if fallback_fonts:
for fallback_font in fallback_fonts:
if os.path.exists(fallback_font) and test_font_mixed_case_support(fallback_font):
print(f"Text Texture Generator: Using fallback font: {fallback_font}")
return fallback_font
# Use system fallback fonts
system_fallbacks = get_system_fallback_fonts()
for fallback_font in system_fallbacks:
if test_font_mixed_case_support(fallback_font):
print(f"Text Texture Generator: Using system fallback font: {fallback_font}")
return fallback_font
# If no valid font found, return the original (better than nothing)
print("Text Texture Generator: Warning - No valid mixed case font found, using original")
return font_path
def get_system_fallback_fonts():
"""
Get a list of system fallback fonts based on the operating system.
Returns a list of font paths that are likely to be available.
"""
system = platform.system()
fallback_fonts = []
if system == "Windows":
windows_fonts = [
"C:/Windows/Fonts/arial.ttf",
"C:/Windows/Fonts/calibri.ttf",
"C:/Windows/Fonts/verdana.ttf",
"C:/Windows/Fonts/tahoma.ttf",
"C:/Windows/Fonts/segoeui.ttf",
"C:/Windows/Fonts/times.ttf"
]
fallback_fonts.extend([f for f in windows_fonts if os.path.exists(f)])
elif system == "Darwin": # macOS
macos_fonts = [
"/System/Library/Fonts/Helvetica.ttc",
"/System/Library/Fonts/Arial.ttf",
"/System/Library/Fonts/Times.ttc",
"/System/Library/Fonts/Verdana.ttf",
"/Library/Fonts/Arial.ttf",
"/System/Library/Fonts/Courier.dfont"
]
fallback_fonts.extend([f for f in macos_fonts if os.path.exists(f)])
elif system == "Linux":
linux_fonts = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/truetype/ubuntu/Ubuntu-R.ttf",
"/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
"/usr/share/fonts/TTF/arial.ttf",
"/usr/share/fonts/truetype/ttf-bitstream-vera/Vera.ttf"
]
fallback_fonts.extend([f for f in linux_fonts if os.path.exists(f)])
# Also check common font directories
common_directories = [
"/usr/share/fonts",
"/usr/local/share/fonts",
os.path.expanduser("~/.fonts"),
os.path.expanduser("~/Library/Fonts")
]
for font_dir in common_directories:
if os.path.exists(font_dir):
for root, dirs, files in os.walk(font_dir):
for file in files:
if file.lower().endswith(('.ttf', '.otf')):
font_path = os.path.join(root, file)
if font_path not in fallback_fonts:
fallback_fonts.append(font_path)
# Limit the number of fallback fonts to prevent excessive scanning
if len(fallback_fonts) >= 20:
break
if len(fallback_fonts) >= 20:
break
print(f"Text Texture Generator: Found {len(fallback_fonts)} system fallback fonts")
for i, font in enumerate(fallback_fonts[:5]): # Log first 5 fonts
print(f"Text Texture Generator: Fallback font {i+1}: {font}")
return fallback_fonts

13
utils/__init__.py Normal file
View File

@@ -0,0 +1,13 @@
"""
Utility modules for the Text Texture Generator addon.
"""
from .constants import bl_info
from .system import install_pillow, get_system_fonts, get_font_enum_items
__all__ = [
'bl_info',
'install_pillow',
'get_system_fonts',
'get_font_enum_items'
]

13
utils/constants.py Normal file
View File

@@ -0,0 +1,13 @@
"""
Constants and metadata for the Text Texture Generator addon.
"""
bl_info = {
"name": "Text Texture Generator",
"author": "Marc Mintel <marc@mintel.me>",
"version": (2, 3, 2),
"blender": (4, 0, 0),
"location": "3D View > Sidebar (N) > Tool Tab | Shader Editor > Sidebar > Tool Tab",
"description": "Generate image textures from text with accurate dimensions and instant live preview",
"category": "Material",
}

84
utils/system.py Normal file
View File

@@ -0,0 +1,84 @@
"""
System utility functions for the Text Texture Generator addon.
"""
import subprocess
import sys
import platform
import os
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_anchor_point_matrix():
"""Return a 3x3 matrix of anchor point identifiers for UI positioning grid"""
return [
['TOP_LEFT', 'TOP_CENTER', 'TOP_RIGHT'],
['MIDDLE_LEFT', 'MIDDLE_CENTER', 'MIDDLE_RIGHT'],
['BOTTOM_LEFT', 'BOTTOM_CENTER', 'BOTTOM_RIGHT']
]