This commit is contained in:
2025-10-13 16:56:38 +02:00
parent bf636cbc66
commit 57b55b10a2
21 changed files with 624 additions and 202 deletions

View File

@@ -11,15 +11,13 @@
"operators/preset_ops.py",
"operators/text_editor_ops.py"
],
"exclude_patterns": ["*batch*", "*advanced*", "*overlay*", "*utility*"],
"max_texture_size": 1024,
"exclude_patterns": ["*batch*", "*advanced*", "*utility*"],
"max_concurrent_operations": 1
},
"full": {
"description": "Full version - all features included",
"exclude_files": ["blender_manifest.toml"],
"exclude_patterns": [],
"max_texture_size": 4096,
"max_concurrent_operations": 4
}
},

View File

@@ -68,7 +68,7 @@ def create_template_variables(config: Dict, version: str, version_type: str = "f
# Define features based on version type
if version_type == "free":
enabled_features = ["basic", "preview", "image_overlays", "basic_utilities"]
enabled_features = ["basic", "preview"]
else: # full version
enabled_features = ["basic", "preview", "image_overlays", "basic_utilities", "advanced_styling", "normal_maps", "presets", "batch_processing", "advanced_utilities", "text_fitting"]
@@ -105,7 +105,6 @@ def create_template_variables(config: Dict, version: str, version_type: str = "f
'BLENDER_VERSION_PATCH': str(blender_patch),
'DEPENDENCIES': dependencies_str,
'ENABLED_FEATURES': json.dumps(enabled_features),
'MAX_TEXTURE_SIZE': str(version_config['max_texture_size']),
'MAX_CONCURRENT_OPERATIONS': str(version_config['max_concurrent_operations'])
}

View File

@@ -28,12 +28,10 @@ def get_version_limits():
"""Get version-specific limits."""
if VERSION_TYPE == "free":
return {
"max_texture_size": {{MAX_TEXTURE_SIZE}},
"max_concurrent_operations": {{MAX_CONCURRENT_OPERATIONS}}
}
else:
return {
"max_texture_size": 4096,
"max_concurrent_operations": 4
}
@@ -117,9 +115,13 @@ def has_text_fitting():
def get_max_resolution():
"""Get maximum texture resolution based on version"""
"""Get maximum texture resolution based on version.
Returns:
None if texture resolution is unrestricted, otherwise an integer limit.
"""
limits = get_version_limits()
return limits.get("max_texture_size", 1024)
return limits.get("max_texture_size")
def should_show_feature_lock(feature_name):
"""Check if feature lock indicator should be shown."""
@@ -157,9 +159,12 @@ def get_feature_availability_text():
def get_version_info_details():
"""Get detailed version information for display."""
limits = get_version_limits()
max_texture_size = limits.get('max_texture_size')
if not max_texture_size:
max_texture_size = 'Unlimited'
info = {
'version_type': VERSION_TYPE.upper(),
'max_texture_size': limits.get('max_texture_size', 'Unlimited'),
'max_texture_size': max_texture_size,
'max_concurrent_operations': limits.get('max_concurrent_operations', 'Unlimited'),
'features_enabled': len(ENABLED_FEATURES),
'upgrade_available': is_free_version()

Binary file not shown.

Binary file not shown.

View File

@@ -6,9 +6,9 @@
The Text Texture Generator is an advanced Blender addon that transforms text input into high-quality, production-ready image textures with comprehensive customization capabilities. Built for professional 3D artists and studios, this tool eliminates the need for external graphics software by providing sophisticated text rendering directly within Blender's material workflow.
The addon utilizes advanced text processing algorithms to convert up to 1024 characters into crisp, scalable textures with professional typography control. Every generated texture integrates seamlessly with Blender's material system, automatically creating optimized node setups that maintain full editability and non-destructive workflows.
The addon utilizes advanced text processing algorithms to convert large text blocks into crisp, scalable textures with professional typography control. Every generated texture integrates seamlessly with Blender's material system, automatically creating optimized node setups that maintain full editability and non-destructive workflows.
Designed for technical users who demand precision and control, the addon features a sophisticated 9-point anchor positioning system, advanced effect processing (including stroke, shadow, glow, and normal map generation), and intelligent preset management for consistent results across projects. The high-resolution output capability (up to 4096×4096) ensures compatibility with professional rendering requirements and close-up detail work.
Designed for technical users who demand precision and control, the addon features a sophisticated 9-point anchor positioning system, advanced effect processing (including stroke, shadow, glow, and normal map generation), and intelligent preset management for consistent results across projects. The high-resolution output capability—limited only by your hardware—ensures compatibility with professional rendering requirements and close-up detail work.
Cross-platform compatibility across Windows, macOS, and Linux environments, combined with Blender 4.0+ optimization, makes this tool suitable for diverse studio pipelines and collaborative workflows where consistency and reliability are paramount.
@@ -16,9 +16,8 @@ Cross-platform compatibility across Windows, macOS, and Linux environments, comb
### Advanced Text Processing
- **Extended Character Support**: Process up to 1024 characters with full Unicode compatibility
- **Extended Character Support**: Process lengthy copy with full Unicode compatibility
- **Professional Typography Control**: Precise font selection, sizing, and character spacing
- **Multi-line Text Handling**: Intelligent line breaking and paragraph formatting
- **Dynamic Text Scaling**: Resolution-independent text rendering with crisp edge quality
### Precision Positioning System
@@ -38,7 +37,7 @@ Cross-platform compatibility across Windows, macOS, and Linux environments, comb
### High-Resolution Output
- **Scalable Resolution**: Output sizes from 256×256 to 4096×4096 pixels
- **Scalable Resolution**: Flexible output sizing with no artificial caps
- **Format Optimization**: PNG, JPEG, and OpenEXR support with compression options
- **Memory Management**: Efficient processing for large textures without system overload
- **Batch Processing**: Multiple texture generation with consistent settings
@@ -47,7 +46,7 @@ Cross-platform compatibility across Windows, macOS, and Linux environments, comb
#### **🆓 Free Version - Full Core Experience**
- **Complete Text Rendering Pipeline**: Professional typography with up to 1024×1024 resolution
- **Complete Text Rendering Pipeline**: Professional typography with unrestricted texture dimensions
- **Essential Positioning System**: 9-point anchor positioning with pixel-perfect alignment
- **Seamless Blender Integration**: Automatic material node creation and assignment
- **Professional Font Support**: System font detection and custom font file compatibility
@@ -56,7 +55,7 @@ Cross-platform compatibility across Windows, macOS, and Linux environments, comb
#### **⭐ Full Version - Unleash Professional Power**
- **Ultra-High Resolution**: Generate stunning 4096×4096 textures for commercial work
- **Ultra-High Resolution**: Generate stunning textures at any resolution your project demands
- **Advanced 3D Effects**: Automatic normal map generation with customizable depth and blur
- **Complete Preset Ecosystem**: Simplified two-tier preset management with import/export for team workflows
- **Professional Batch Processing**: Advanced utility operations and workflow automation tools

Binary file not shown.

Binary file not shown.

View File

@@ -53,16 +53,16 @@ def generate_texture_image(props, width, height):
bicubic_filter = getattr(resampling_module, 'BICUBIC', getattr(Image, 'BICUBIC', Image.BILINEAR))
def _iter_enabled_components():
"""Yield enabled composition components."""
"""Yield enabled composition components with their index."""
overlays = getattr(props, 'image_overlays', [])
try:
overlay_list = list(overlays)
except TypeError:
overlay_list = []
for overlay in overlay_list:
for index, overlay in enumerate(overlay_list):
if not getattr(overlay, 'enabled', True):
continue
yield overlay
yield index, overlay
def _calculate_overlay_position(overlay, overlay_size):
"""Calculate top-left pixel position for an overlay image."""
@@ -92,67 +92,115 @@ def generate_texture_image(props, width, height):
y = max(0, min(max_y, y))
return x, y
def _apply_composition_components(pil_canvas):
"""Composite enabled components (text or image) onto the base canvas."""
components = sorted(
_iter_enabled_components(),
key=lambda comp: getattr(comp, 'z_index', 0)
)
for component in components:
if getattr(component, 'component_type', 'IMAGE') == 'TEXT':
def _make_text_overlay(component, base_font_size):
"""Return (image, baseline_offset, descent, render_meta) for a text component."""
text_value = getattr(component, 'text_content', '').strip()
if not text_value:
continue
return None, None, None, None
scale = getattr(component, 'scale', 1.0)
try:
scale = float(scale if scale is not None else 1.0)
except (TypeError, ValueError):
scale = 1.0
text_size = max(8, int(round(props.font_size * max(scale, 0.1))))
text_font = load_font_for_size(text_size)
try:
text_bbox = text_font.getbbox(text_value)
text_width = text_bbox[2] - text_bbox[0]
text_height = text_bbox[3] - text_bbox[1]
except AttributeError:
try:
text_width, text_height = text_font.getsize(text_value)
except AttributeError:
# Fallback to a temporary draw context
tmp_draw = ImageDraw.Draw(Image.new('RGBA', (1, 1)))
text_width, text_height = tmp_draw.textsize(text_value, font=text_font)
if not math.isfinite(scale) or scale <= 0:
return None, None, None, None
desired_size = max(4, int(round(max(base_font_size, 4) * scale)))
text_font = load_font_for_size(desired_size)
temp_draw = ImageDraw.Draw(Image.new('RGBA', (1, 1)))
overlay_img = None
baseline_offset = None
component_descent = None
render_meta = None
# Prefer anchoring to the left baseline so we can align with the main text baseline.
try:
baseline_bbox = temp_draw.textbbox(
(0, 0),
text_value,
font=text_font,
anchor='ls'
)
except Exception:
baseline_bbox = None
if baseline_bbox:
left, top, right, bottom = baseline_bbox
text_width = max(1, int(round(right - left)))
text_height = max(1, int(round(bottom - top)))
overlay_img = Image.new('RGBA', (text_width, text_height), (0, 0, 0, 0))
overlay_draw = ImageDraw.Draw(overlay_img)
text_color = tuple(int(c * 255) for c in props.text_color[:4])
overlay_draw.text((0, 0), text_value, font=text_font, fill=text_color)
# Place the baseline at a consistent offset from the top of the overlay.
baseline_offset = -top
draw_x = -left
draw_y = -top
overlay_draw.text((draw_x, draw_y), text_value, font=text_font, fill=text_color, anchor='ls')
else:
# Fallback for PIL versions without baseline-aware textbbox.
try:
text_bbox = temp_draw.textbbox((0, 0), text_value, font=text_font)
except Exception:
width, height = temp_draw.textsize(text_value, font=text_font)
text_bbox = (0, 0, width, height)
left, top, right, bottom = text_bbox
text_width = max(1, int(round(right - left)))
text_height = max(1, int(round(bottom - top)))
overlay_img = Image.new('RGBA', (text_width, text_height), (0, 0, 0, 0))
overlay_draw = ImageDraw.Draw(overlay_img)
text_color = tuple(int(c * 255) for c in props.text_color[:4])
offset_x = -left
offset_y = -top
baseline_offset = offset_y
overlay_draw.text((offset_x, offset_y), text_value, font=text_font, fill=text_color)
rotation = float(getattr(component, 'rotation', 0.0) or 0.0)
if rotation % 360:
overlay_img = overlay_img.rotate(-rotation, expand=True, resample=bicubic_filter)
content_box = overlay_img.getbbox() if overlay_img else None
crop_left = crop_top = 0
if content_box:
crop_left, crop_top, crop_right, crop_bottom = content_box
overlay_img = overlay_img.crop(content_box)
if baseline_offset is not None:
baseline_offset -= crop_top
component_descent = (crop_bottom - crop_top) - (baseline_offset or 0)
else:
component_descent = (overlay_img.height if overlay_img else 0) - (baseline_offset or 0)
x_pos, y_pos = _calculate_overlay_position(component, overlay_img.size)
if overlay_img and (baseline_offset is None or baseline_offset < 0 or baseline_offset > overlay_img.height):
try:
ascent, descent = text_font.getmetrics()
except Exception:
ascent, descent = overlay_img.height, 0
baseline_offset = min(max(0, int(round(ascent))), overlay_img.height)
component_descent = max(0, overlay_img.height - baseline_offset)
overlay_layer = Image.new('RGBA', (width, height), (0, 0, 0, 0))
overlay_layer.paste(overlay_img, (x_pos, y_pos), overlay_img)
pil_canvas = Image.alpha_composite(pil_canvas, overlay_layer)
continue
baseline_offset = max(0, int(round(baseline_offset or 0)))
component_descent = max(0, int(round(component_descent or 0)))
render_meta = {
'text': text_value,
'font': text_font,
'color': tuple(int(c * 255) for c in props.text_color[:4]),
'draw_offset_x': (draw_x - crop_left) if baseline_bbox else (offset_x - crop_left),
'draw_offset_y': (draw_y - crop_top) if baseline_bbox else (offset_y - crop_top),
'anchor': 'ls' if baseline_bbox else None,
'width': overlay_img.width if overlay_img else 0,
'height': overlay_img.height if overlay_img else 0
}
return overlay_img, baseline_offset, component_descent, render_meta
def _make_image_overlay(component):
"""Return RGBA image for an image component or None."""
resolved_path = getattr(component, 'image_path', '')
if not resolved_path:
continue
return None
scale = getattr(component, 'scale', 1.0)
try:
scale = float(scale if scale is not None else 1.0)
except (TypeError, ValueError):
scale = 1.0
if not math.isfinite(scale) or scale <= 0:
continue
return None
scale_to_apply = scale
if is_svg_path(resolved_path):
try:
raster_info = ensure_svg_raster(resolved_path, scale)
@@ -160,15 +208,13 @@ def generate_texture_image(props, width, height):
scale_to_apply = raster_info.applied_scale
except SVGConversionError as svg_error:
print(f"WARNING: Failed to rasterize SVG overlay '{component.image_path}': {svg_error}")
continue
return None
try:
with Image.open(resolved_path) as raw_overlay:
overlay_img = raw_overlay.convert('RGBA')
except Exception as overlay_error:
print(f"WARNING: Failed to load overlay image '{resolved_path}': {overlay_error}")
continue
return None
if scale_to_apply != 1.0:
new_size = (
max(1, int(round(overlay_img.width * scale_to_apply))),
@@ -176,13 +222,170 @@ def generate_texture_image(props, width, height):
)
if new_size != overlay_img.size:
overlay_img = overlay_img.resize(new_size, lanczos_filter)
return overlay_img
def _apply_component_layers(pil_canvas, stage, base_text_rect, base_font_size):
"""Composite enabled components according to z-index and placement."""
components_data = []
for index, component in _iter_enabled_components():
z_value = getattr(component, 'z_index', 0)
if stage == 'before' and z_value >= 0:
continue
if stage == 'after' and z_value < 0:
continue
component_type = getattr(component, 'component_type', 'IMAGE')
if component_type == 'TEXT':
overlay_img, baseline_offset, component_descent, render_meta = _make_text_overlay(component, base_font_size)
else:
overlay_img = _make_image_overlay(component)
baseline_offset = overlay_img.height if overlay_img is not None else None
component_descent = overlay_img.height if overlay_img is not None else None
render_meta = None
if overlay_img is None and not render_meta:
continue
rotation = float(getattr(component, 'rotation', 0.0) or 0.0)
direct_draw = False
width_from_image = overlay_img.width if overlay_img is not None else 0
height_from_image = overlay_img.height if overlay_img is not None else 0
if component_type == 'TEXT' and render_meta and not (rotation % 360):
direct_draw = True
else:
if overlay_img is None:
continue
if rotation % 360:
overlay_img = overlay_img.rotate(-rotation, expand=True, resample=bicubic_filter)
width_from_image = overlay_img.width
height_from_image = overlay_img.height
width_value = 0
height_value = 0
if component_type == 'TEXT' and render_meta:
if direct_draw:
width_value = render_meta.get('width', width_from_image)
height_value = render_meta.get('height', height_from_image)
else:
width_value = width_from_image
height_value = height_from_image
elif overlay_img is not None:
width_value = width_from_image
height_value = height_from_image
components_data.append({
'index': index,
'component': component,
'image': overlay_img,
'z': z_value,
'type': component_type,
'baseline_offset': baseline_offset,
'descent': component_descent,
'render_info': render_meta,
'direct_draw': direct_draw,
'rotation': rotation,
'width': width_value,
'height': height_value
})
x_pos, y_pos = _calculate_overlay_position(component, overlay_img.size)
if not components_data:
return pil_canvas
sequential_groups = {'PREPEND': [], 'APPEND': []}
absolute_entries = []
for entry in components_data:
placement = getattr(entry['component'], 'placement_mode', 'ABSOLUTE') or 'ABSOLUTE'
if placement in sequential_groups:
sequential_groups[placement].append(entry)
else:
x_pos, y_pos = _calculate_overlay_position(entry['component'], entry['image'].size)
entry['position'] = (x_pos, y_pos)
absolute_entries.append(entry)
base_center_x = width * 0.5
base_center_y = height * 0.5
base_left = base_center_x
base_right = base_center_x
base_top = base_center_y - (base_font_size / 2.0)
base_bottom = base_center_y + (base_font_size / 2.0)
base_baseline = base_bottom
if base_text_rect:
base_center_x = base_text_rect['center_x']
base_center_y = base_text_rect['center_y']
base_left = base_text_rect['left']
base_right = base_text_rect['right']
base_top = base_text_rect.get('top', base_top)
base_bottom = base_text_rect.get('bottom', base_bottom)
base_baseline = base_text_rect.get('baseline', base_bottom)
else:
base_top = max(0.0, min(float(height - base_font_size), base_top))
base_bottom = max(base_top + 1.0, min(float(height), base_bottom))
base_baseline = base_bottom
def assign_sequential_positions(entries, placement):
if not entries:
return []
sorted_entries = sorted(entries, key=lambda item: item['index'])
cursor = base_left if placement == 'PREPEND' else base_right
assigned = []
for entry in sorted_entries:
component = entry['component']
entry_width = entry.get('width', entry['image'].width if entry['image'] else 0)
entry_height = entry.get('height', entry['image'].height if entry['image'] else 0)
spacing_factor = float(getattr(component, 'text_spacing', 0.0) or 0.0) / 100.0
inter_spacing_factor = float(getattr(component, 'image_spacing', 0.0) or 0.0) / 100.0
spacing_pixels = spacing_factor * width
inter_pixels = inter_spacing_factor * width
if placement == 'PREPEND':
cursor -= spacing_pixels
cursor -= entry_width
x_pos = cursor
cursor -= inter_pixels
else:
cursor += spacing_pixels
x_pos = cursor
cursor += entry_width
cursor += inter_pixels
if base_text_rect:
if entry['type'] == 'TEXT' and entry['baseline_offset'] is not None:
y_pos = int(round(base_baseline - entry['baseline_offset']))
else:
y_pos = int(round(base_bottom - entry_height))
else:
center_y = base_center_y
y_pos = int(round(center_y - entry_height / 2.0))
x_pos = int(round(x_pos))
y_pos = max(0, min(height - entry_height, y_pos))
x_pos = max(0, min(width - entry_width, x_pos))
entry['position'] = (x_pos, y_pos)
assigned.append(entry)
return assigned
positioned_entries = absolute_entries
positioned_entries.extend(assign_sequential_positions(sequential_groups['PREPEND'], 'PREPEND'))
positioned_entries.extend(assign_sequential_positions(sequential_groups['APPEND'], 'APPEND'))
positioned_entries.sort(key=lambda item: item['z'])
draw_ctx = None
for entry in positioned_entries:
x_pos, y_pos = entry.get('position', (0, 0))
if entry.get('direct_draw') and entry.get('render_info'):
if draw_ctx is None:
draw_ctx = ImageDraw.Draw(pil_canvas)
info = entry['render_info']
draw_pos = (
x_pos + info.get('draw_offset_x', 0),
y_pos + info.get('draw_offset_y', 0)
)
anchor = info.get('anchor')
draw_ctx.text(
draw_pos,
info.get('text', ''),
font=info.get('font'),
fill=info.get('color'),
anchor=anchor
)
else:
overlay_img = entry.get('image')
if overlay_img is None:
continue
overlay_layer = Image.new('RGBA', (width, height), (0, 0, 0, 0))
overlay_layer.paste(overlay_img, (x_pos, y_pos), overlay_img)
pil_canvas = Image.alpha_composite(pil_canvas, overlay_layer)
@@ -198,6 +401,10 @@ def generate_texture_image(props, width, height):
bg_color = tuple(int(c * 255) for c in props.background_color[:3]) + (255,)
pil_img = Image.new('RGBA', (width, height), bg_color)
base_text_rect = None
final_font_size = props.font_size
before_layers_applied = False
# Draw text if provided (including prepend/append)
full_text_parts = []
if props.prepend_text.strip():
@@ -212,7 +419,7 @@ def generate_texture_image(props, width, height):
if full_text_parts:
pass
draw = ImageDraw.Draw(pil_img)
measure_draw = ImageDraw.Draw(pil_img)
# Combine text based on layout mode
if props.prepend_append_layout == 'HORIZONTAL':
@@ -304,12 +511,12 @@ def generate_texture_image(props, width, height):
test_font = load_font_for_size(test_size)
try:
bbox = draw.textbbox((0, 0), full_text, font=test_font)
bbox = measure_draw.textbbox((0, 0), full_text, font=test_font)
test_width = bbox[2] - bbox[0]
test_height = bbox[3] - bbox[1]
except (ValueError, AttributeError):
# Fallback for bitmap fonts
test_width, test_height = draw.textsize(full_text, font=test_font)
test_width, test_height = measure_draw.textsize(full_text, font=test_font)
if test_width <= target_width and test_height <= target_height:
pass
@@ -323,7 +530,12 @@ def generate_texture_image(props, width, height):
pass
font_size = props.font_size
final_font_size = font_size
font = load_font_for_size(font_size)
try:
base_ascent, base_descent = font.getmetrics()
except Exception:
base_ascent, base_descent = (final_font_size, 0)
# Calculate text position and draw
if layout_mode == "vertical" and props.prepend_append_layout == 'VERTICAL':
@@ -331,18 +543,25 @@ def generate_texture_image(props, width, height):
# Draw each part separately for vertical layout
y_offset = 0
total_height = 0
part_widths = []
part_baselines = []
# Calculate total height first
part_heights = []
for part in full_text_parts:
pass
try:
bbox = draw.textbbox((0, 0), part, font=font)
bbox = measure_draw.textbbox((0, 0), part, font=font)
part_height = bbox[3] - bbox[1]
part_width = bbox[2] - bbox[0]
baseline = base_ascent
except (ValueError, AttributeError):
# Fallback for bitmap fonts
_, part_height = draw.textsize(part, font=font)
part_width, part_height = measure_draw.textsize(part, font=font)
baseline = base_ascent
part_heights.append(part_height)
part_widths.append(part_width)
part_baselines.append(baseline)
total_height += part_height
# Add spacing between parts
@@ -353,6 +572,23 @@ def generate_texture_image(props, width, height):
# Center vertically
start_y = (height - total_height) // 2
max_width = max(part_widths) if part_widths else 0
left_x = (width - max_width) // 2
base_text_rect = {
'left': float(left_x),
'right': float(left_x + max_width),
'top': float(start_y),
'bottom': float(start_y + total_height),
'center_x': float(left_x + max_width / 2.0),
'center_y': float(start_y + total_height / 2.0),
'baseline': float(start_y + base_ascent),
'descent': float(base_descent),
'draw_y': float(start_y),
'ascent': float(base_ascent)
}
pil_img = _apply_component_layers(pil_img, 'before', base_text_rect, final_font_size)
before_layers_applied = True
draw = ImageDraw.Draw(pil_img)
# Draw each part
current_y = start_y
@@ -360,12 +596,7 @@ def generate_texture_image(props, width, height):
for i, part in enumerate(full_text_parts):
pass
try:
bbox = draw.textbbox((0, 0), part, font=font)
part_width = bbox[2] - bbox[0]
except (ValueError, AttributeError):
# Fallback for bitmap fonts
part_width, _ = draw.textsize(part, font=font)
part_width = part_widths[i]
x = (width - part_width) // 2 # Center horizontally
draw.text((x, current_y), part, font=font, fill=text_color)
@@ -377,17 +608,47 @@ def generate_texture_image(props, width, height):
else:
pass
# Horizontal layout or single text
left_offset = 0
right_offset = 0
top_offset = 0
bottom_offset = 0
try:
text_bbox = draw.textbbox((0, 0), full_text, font=font)
text_bbox = measure_draw.textbbox((0, 0), full_text, font=font)
text_width = text_bbox[2] - text_bbox[0]
text_height = text_bbox[3] - text_bbox[1]
left_offset = text_bbox[0]
right_offset = text_bbox[2]
top_offset = text_bbox[1]
bottom_offset = text_bbox[3]
except (ValueError, AttributeError):
# Fallback for bitmap fonts that don't support textbbox
text_width, text_height = draw.textsize(full_text, font=font)
text_width, text_height = measure_draw.textsize(full_text, font=font)
right_offset = text_width
bottom_offset = text_height
# Center text
x = (width - text_width) // 2
y = (height - text_height) // 2
left = x + left_offset
right = x + right_offset
top = y + top_offset
bottom = y + bottom_offset
base_text_rect = {
'left': float(left),
'right': float(right),
'top': float(top),
'bottom': float(bottom),
'center_x': float((left + right) / 2.0),
'center_y': float((top + bottom) / 2.0),
'baseline': float(y + base_ascent),
'descent': float(base_descent),
'draw_y': float(y),
'ascent': float(base_ascent)
}
pil_img = _apply_component_layers(pil_img, 'before', base_text_rect, final_font_size)
before_layers_applied = True
draw = ImageDraw.Draw(pil_img)
# Draw text
text_color = tuple(int(c * 255) for c in props.text_color[:4])
@@ -395,8 +656,9 @@ def generate_texture_image(props, width, height):
else:
pass
# Apply any configured image overlays before converting to Blender pixels
pil_img = _apply_composition_components(pil_img)
if not before_layers_applied:
pil_img = _apply_component_layers(pil_img, 'before', base_text_rect, final_font_size)
pil_img = _apply_component_layers(pil_img, 'after', base_text_rect, final_font_size)
# Convert PIL image to Blender format (flip vertically to fix mirroring)
pil_img = pil_img.transpose(Image.FLIP_TOP_BOTTOM) # Fix mirroring issue

View File

@@ -3,6 +3,7 @@ Text Texture Generator - Operators Module
Contains all Blender operators for text texture generation and management.
"""
from ..utils.constants import is_full_version
from .generation_ops import *
__all__ = []
@@ -44,6 +45,8 @@ except ImportError:
pass
# preset_ops module not available (free version)
PRESET_OPERATORS = []
if is_full_version():
try:
pass
from .text_editor_ops import *
@@ -55,12 +58,10 @@ try:
]
except ImportError as e:
import sys
import traceback
print(f"WARNING: text_editor_ops import failed in operators/__init__.py: {e}", file=sys.stderr)
traceback.print_exc()
# text_editor_ops module not available (free version)
TEXT_EDITOR_OPERATORS = []
else:
TEXT_EDITOR_OPERATORS = []
try:
pass
@@ -106,8 +107,6 @@ __all__.extend([
# Add preset operators if available
__all__.extend(PRESET_OPERATORS)
# Add utility operators if available
# Add text editor operators if available
__all__.extend(TEXT_EDITOR_OPERATORS)
__all__.extend(UTILITY_OPERATORS)

View File

@@ -85,6 +85,21 @@ def draw_collapsible_header(layout, prop_name, text, icon='TRIA_DOWN'):
return box if expanded else None
def draw_premium_collapsible_section(layout, prop_name, title, icon, availability_func):
"""Draw a collapsible header that locks when feature is unavailable."""
header_row = layout.row(align=True)
try:
available = availability_func()
except Exception:
available = False
if not available:
header_row.enabled = False
lock_header = header_row.row()
lock_header.label(text=title, icon=icon)
lock_header.label(text="🔒 PRO", icon='LOCKED')
return None
return draw_collapsible_header(header_row, prop_name, title, icon)
def draw_version_badge(layout):
pass
"""Draw version badge in panel header"""
@@ -151,7 +166,10 @@ def draw_version_info_section(layout, props):
limits_col = col.column(align=True)
limits_col.separator()
limits_col.label(text="Current Limits:")
limits_col.label(text=f"• Max texture size: {version_info['max_texture_size']}px")
max_texture_display = version_info['max_texture_size']
if isinstance(max_texture_display, (int, float)):
max_texture_display = f"{int(max_texture_display)}px"
limits_col.label(text=f"• Max texture size: {max_texture_display}")
limits_col.label(text=f"• Active features: {version_info['features_enabled']}")
# Feature availability status
@@ -989,7 +1007,13 @@ class TEXT_TEXTURE_PT_panel(Panel):
draw_shader_options_only(shader_expanded, props)
# Composition Section (collapsed by default)
composition_section = draw_collapsible_header(layout, "expand_composition", "Composition", 'SEQ_PREVIEW')
composition_section = draw_premium_collapsible_section(
layout,
"expand_composition",
"Composition",
'SEQ_PREVIEW',
has_image_overlays
)
if composition_section:
draw_composition_section(composition_section, props)
@@ -1186,7 +1210,13 @@ class TEXT_TEXTURE_PT_panel_3d(Panel):
draw_shader_options_only(shader_expanded, props)
# Composition Section (collapsed by default)
composition_section = draw_collapsible_header(layout, "expand_composition", "Composition", 'SEQ_PREVIEW')
composition_section = draw_premium_collapsible_section(
layout,
"expand_composition",
"Composition",
'SEQ_PREVIEW',
has_image_overlays
)
if composition_section:
draw_composition_section(composition_section, props)
@@ -1363,7 +1393,13 @@ class TEXT_TEXTURE_PT_panel_properties(Panel):
# === PREMIUM FEATURES ===
# Composition Section (collapsed by default)
composition_section = draw_collapsible_header(layout, "expand_composition", "Composition", 'SEQ_PREVIEW')
composition_section = draw_premium_collapsible_section(
layout,
"expand_composition",
"Composition",
'SEQ_PREVIEW',
has_image_overlays
)
if composition_section:
draw_composition_section(composition_section, props)

View File

@@ -28,12 +28,10 @@ def get_version_limits():
"""Get version-specific limits."""
if VERSION_TYPE == "free":
return {
"max_texture_size": 1024,
"max_concurrent_operations": 1
}
else:
return {
"max_texture_size": 4096,
"max_concurrent_operations": 4
}
@@ -117,9 +115,13 @@ def has_text_fitting():
def get_max_resolution():
"""Get maximum texture resolution based on version"""
"""Get maximum texture resolution based on version.
Returns:
None if texture resolution is unrestricted, otherwise an integer limit.
"""
limits = get_version_limits()
return limits.get("max_texture_size", 1024)
return limits.get("max_texture_size")
def should_show_feature_lock(feature_name):
"""Check if feature lock indicator should be shown."""
@@ -157,9 +159,12 @@ def get_feature_availability_text():
def get_version_info_details():
"""Get detailed version information for display."""
limits = get_version_limits()
max_texture_size = limits.get('max_texture_size')
if not max_texture_size:
max_texture_size = 'Unlimited'
info = {
'version_type': VERSION_TYPE.upper(),
'max_texture_size': limits.get('max_texture_size', 'Unlimited'),
'max_texture_size': max_texture_size,
'max_concurrent_operations': limits.get('max_concurrent_operations', 'Unlimited'),
'features_enabled': len(ENABLED_FEATURES),
'upgrade_available': is_free_version()

View File

@@ -5,7 +5,7 @@ These tests specify the expected behavior when VERSION_TYPE="free".
They validate that free version users:
1. Can use basic features (text generation, preview)
2. CANNOT access PRO features (shaders, presets, overlays, normal maps)
3. Have REDUCED limits (max_texture_size=1024, max_concurrent=1)
3. Maintain concurrency cap (max_concurrent=1) without texture size limits
4. See UI locks/badges on PRO features
TESTING STRATEGY:
@@ -17,7 +17,7 @@ TESTING STRATEGY:
EXPECTED BEHAVIOR (FREE VERSION):
- ENABLED_FEATURES = ["basic", "preview"] only
- BLOCKED: shader_generation, normal_maps, image_overlays, presets, advanced
- LIMITS: max_texture_size=1024, max_concurrent_operations=1
- LIMITS: Unlimited texture size, max_concurrent_operations=1
- UI: Shows "🔒 PRO" badges on locked features
"""
@@ -411,19 +411,19 @@ print("SUCCESS: Text fitting properly blocked")
assert "SUCCESS: Text fitting properly blocked" in output
def test_free_version_texture_size_limited(blender_container, addon_package):
def test_free_version_allows_large_textures(blender_container, addon_package):
"""
SPECIFICATION: Free version MUST limit max_texture_size to 1024px (not 4096px).
SPECIFICATION UPDATE: Free version should allow large texture dimensions without artificial caps.
Expected behavior (VERSION_TYPE="free"):
- get_version_limits()['max_texture_size'] == 1024
- Attempting 4096px should fail or clamp to 1024
- UI should disable/hide 2048+ size options
- get_version_limits() does NOT define 'max_texture_size'
- Generating a 4096px texture succeeds without clamping
- UI should allow entering high-resolution values
Current state (VERSION_TYPE="full"):
- Test SKIPS (max_texture_size is 4096)
- Test SKIPS (runs only when free build is active)
**CRITICAL BUG**: get_version_limits() currently returns same values for both versions!
**PREVIOUS BUG**: get_version_limits() returned identical caps for both versions.
"""
version_type, enabled_features = get_version_type(blender_container, addon_package)
@@ -438,13 +438,27 @@ import sys
from text_texture_generator.utils.constants import get_version_limits
limits = get_version_limits()
# CRITICAL: This is the BUG - get_version_limits() returns same for both versions!
max_size = limits.get('max_texture_size', 4096)
if max_size != 1024:
print(f"FAIL: max_texture_size should be 1024 for free version, got {max_size}")
# Ensure no artificial texture size cap is reported
max_size = limits.get('max_texture_size')
if max_size:
print(f"FAIL: Expected no texture size cap, got {max_size}")
sys.exit(1)
print(f"SUCCESS: Texture size properly limited to {max_size}px")
# Attempt a large texture generation
props = bpy.context.scene.text_texture_props
props.text = "High Resolution Test"
props.texture_width = 4096
props.texture_height = 4096
try:
bpy.ops.text_texture.generate_shader()
if props.texture_width != 4096 or props.texture_height != 4096:
print(f"FAIL: Texture dimensions were clamped to {props.texture_width}x{props.texture_height}")
sys.exit(1)
print("SUCCESS: Large texture generated without limits")
except Exception as e:
print(f"FAIL: Large texture generation failed: {e}")
sys.exit(1)
"""
tar_stream = io.BytesIO()
@@ -469,7 +483,7 @@ print(f"SUCCESS: Texture size properly limited to {max_size}px")
# This test WILL FAIL because get_version_limits() has the bug
assert test_result.exit_code == 0, f"Size limit test failed:\n{output}"
assert "SUCCESS: Texture size properly limited to 1024px" in output
assert "SUCCESS: Large texture generated without limits" in output
def test_free_version_ui_shows_pro_locks(blender_container, addon_package):
@@ -852,4 +866,3 @@ except ImportError:
assert test_result.exit_code == 0, f"Operator poll test failed:\n{output}"
assert "SUCCESS: has_multiline_text helper removed" in output
assert "SUCCESS: Multiline editor operator removed" in output

View File

@@ -4,7 +4,7 @@ E2E tests for full version feature completeness.
Tests run in Docker with real Blender to verify:
- All features enabled in ENABLED_FEATURES list
- Shader generation works
- Large texture sizes (4096px) supported
- Large texture sizes supported (no artificial cap)
- No UI locks/PRO badges visible
NO MOCKS - real Blender bpy API operations.
@@ -259,15 +259,13 @@ except Exception as e:
assert "SUCCESS: Shader generation works" in output
def test_full_version_large_texture_4096px(
def test_full_version_supports_large_textures(
blender_container,
addon_package,
tmp_path
):
"""
Verify large texture size (4096px) works - this is the full version limit.
Full version should support up to 4096px.
Verify large texture sizes work without an explicit limit in the full version.
"""
script_content = """
import bpy
@@ -284,19 +282,20 @@ except Exception as e:
try:
from text_texture_generator.utils.constants import get_version_limits
# Verify max texture size
limits = get_version_limits()
max_size = limits.get('max_texture_size', 0)
max_size = limits.get('max_texture_size')
if max_size != 4096:
print(f"FAIL: Expected max_texture_size=4096, got {max_size}")
if max_size:
print(f"FAIL: Expected unlimited texture size, got {max_size}")
sys.exit(1)
# For now, just verify the limit and produce expected output
# Full integration test would require registered properties
print("Texture size: 4096x4096")
print("Image has pixel data")
print("SUCCESS: 4096px texture generated")
props = bpy.context.scene.text_texture_props
props.text = "Full Version High Resolution"
props.texture_width = 4096
props.texture_height = 4096
bpy.ops.text_texture.generate_shader()
print(f"Texture size: {props.texture_width}x{props.texture_height}")
print("SUCCESS: Full version large texture generated")
except Exception as e:
print(f"ERROR: {e}")
@@ -374,8 +373,7 @@ except Exception as e:
)
assert "Texture size: 4096x4096" in output
assert "Image has pixel data" in output
assert "SUCCESS: 4096px texture generated" in output
assert "SUCCESS: Full version large texture generated" in output
def test_full_version_no_ui_locks(
@@ -407,11 +405,11 @@ try:
# Check version limits
limits = get_version_limits()
max_size = limits.get('max_texture_size', 0)
max_size = limits.get('max_texture_size')
print(f"MAX_TEXTURE_SIZE: {max_size}")
if max_size != 4096:
print(f"FAIL: Expected max_texture_size=4096, got {max_size}")
if max_size:
print(f"FAIL: Expected no texture size limit, got {max_size}")
sys.exit(1)
# Check if PRO features are locked
@@ -431,6 +429,9 @@ try:
# For now, just verify limits without accessing unregistered properties
# Full integration test would require registered properties
props = bpy.context.scene.text_texture_props
props.texture_width = 4096
props.texture_height = 4096
print(f"Can set 4096px: True")
print("SUCCESS: UI is fully unlocked")
@@ -511,7 +512,7 @@ except Exception as e:
)
assert "VERSION_TYPE: full" in output
assert "MAX_TEXTURE_SIZE: 4096" in output
assert "MAX_TEXTURE_SIZE: None" in output
assert "has_pro_locks: False" in output
assert "Can set 4096px: True" in output

View File

@@ -149,7 +149,7 @@ class TestGenerateTextureImage:
assert result is not None
assert normal_map is None # Normal maps not enabled
mock_pil_image_module.new.assert_called_once()
mock_pil_imagedraw_module.Draw.assert_called_once()
assert mock_pil_imagedraw_module.Draw.call_count >= 1
mock_draw.text.assert_called()
mock_bpy.data.images.new.assert_called_once()
mock_pil_image.transpose.assert_called_once()
@@ -715,6 +715,115 @@ class TestGenerateTextureImage:
assert max(blue_columns) <= expected_x + overlay_source.width
assert max(blue_columns) < width // 2 # should remain on left half
def test_prepend_text_component_shares_baseline(self):
"""Prepend text components should align exactly with the main text baseline."""
Image = pytest.importorskip("PIL.Image")
with patch('src.core.generation_engine.bpy') as mock_bpy:
width = height = 256
mock_blender_image = MagicMock()
mock_blender_image.pixels = [0.0] * (width * height * 4)
mock_bpy.data.images.new.return_value = mock_blender_image
mock_bpy.data.images.__contains__.return_value = False
props = create_mock_props(
text="World",
background_transparent=True,
background_color=(0.0, 0.0, 0.0, 0.0),
text_color=(1.0, 1.0, 1.0, 1.0),
font_size=72,
)
component = MagicMock()
component.enabled = True
component.component_type = 'TEXT'
component.text_content = "Test"
component.placement_mode = 'PREPEND'
component.scale = 1.0
component.rotation = 0.0
component.z_index = 0
component.text_spacing = 5.0
component.image_spacing = 0.0
props.image_overlays = [component]
result, _ = generate_texture_image(props, width, height)
assert result is mock_blender_image
pixels = mock_blender_image.pixels
def collect_bounds(x_start, x_end):
ys = []
for y in range(height):
for x in range(x_start, x_end):
idx = (y * width + x) * 4
alpha = pixels[idx + 3]
if alpha > 0.5:
ys.append(y)
break
if not ys:
return None
return min(ys), max(ys)
left_bounds = collect_bounds(0, width // 2)
right_bounds = collect_bounds(width // 2, width)
assert left_bounds and right_bounds, "Expected both text regions to be present"
left_top, left_bottom = left_bounds
right_top, right_bottom = right_bounds
assert abs(left_top - right_top) <= 1, (
f"Top alignment mismatch: prepend top={left_top}, main top={right_top}"
)
def test_absolute_component_negative_z_draws_behind_text(self, tmp_path):
"""Absolute components with negative z-index should render behind the main text."""
Image = pytest.importorskip("PIL.Image")
with patch('src.core.generation_engine.bpy') as mock_bpy:
width = height = 128
overlay_path = tmp_path / "background_overlay.png"
overlay_source = Image.new("RGBA", (128, 128), (255, 0, 0, 255))
overlay_source.save(overlay_path)
mock_blender_image = MagicMock()
mock_blender_image.pixels = [0.0] * (width * height * 4)
mock_bpy.data.images.new.return_value = mock_blender_image
mock_bpy.data.images.__contains__.return_value = False
props = create_mock_props(
text="T",
background_transparent=False,
background_color=(0.0, 0.0, 0.0, 1.0),
)
overlay = MagicMock()
overlay.enabled = True
overlay.component_type = 'IMAGE'
overlay.image_path = str(overlay_path)
overlay.placement_mode = 'ABSOLUTE'
overlay.x_position = 0.5
overlay.y_position = 0.5
overlay.scale = 1.0
overlay.rotation = 0.0
overlay.z_index = -2
overlay.text_spacing = 0.0
overlay.image_spacing = 0.0
props.image_overlays = [overlay]
result, normal_map = generate_texture_image(props, width, height)
assert result is mock_blender_image
assert normal_map is None
pixels = mock_blender_image.pixels
quads = [pixels[i:i+4] for i in range(0, len(pixels), 4)]
max_brightness = max((r + g + b) / 3.0 for r, g, b, _ in quads)
red_pixels = sum(1 for r, g, b, _ in quads if r >= 0.9 and g <= 0.3 and b <= 0.3)
assert max_brightness > 0.45, "Text should introduce bright pixels above the overlay"
assert red_pixels > 0, "Red overlay pixels should still be present"
def test_text_fitting_enabled(self):
"""Test text fitting integration."""
with patch('PIL.Image') as mock_pil_image_module, \

View File

@@ -72,45 +72,41 @@ class TestVersionDetection:
assert isinstance(limits, dict), "get_version_limits() should return a dictionary"
# Check required keys
required_keys = ["max_texture_size", "max_concurrent_operations"]
required_keys = ["max_concurrent_operations"]
for key in required_keys:
assert key in limits, f"Missing required key '{key}' in version limits"
assert "max_texture_size" not in limits, "Texture size limit should not be enforced"
# Check value types
assert isinstance(limits["max_texture_size"], int), "max_texture_size should be an integer"
assert isinstance(limits["max_concurrent_operations"], int), "max_concurrent_operations should be an integer"
# Check version-specific limits
if VERSION_TYPE == "free":
assert limits["max_texture_size"] == 1024, f"Free version should have 1024px limit, got {limits['max_texture_size']}"
assert limits["max_concurrent_operations"] == 1, f"Free version should have 1 concurrent operation, got {limits['max_concurrent_operations']}"
else:
assert limits["max_texture_size"] == 4096, f"Full version should have 4096px limit, got {limits['max_texture_size']}"
assert limits["max_concurrent_operations"] == 4, f"Full version should have 4 concurrent operations, got {limits['max_concurrent_operations']}"
def test_get_version_limits_full_returns_pro_limits(self):
"""Verify full version returns correct limits for texture size and concurrent operations."""
"""Verify full version returns correct limits for concurrent operations."""
from src.utils.constants import get_version_limits
from unittest.mock import patch
with patch('src.utils.constants.VERSION_TYPE', 'full'):
limits = get_version_limits()
assert limits["max_texture_size"] == 4096, \
f"Expected full version max_texture_size=4096, got {limits['max_texture_size']}"
assert "max_texture_size" not in limits, "Full version should not enforce a texture size limit"
assert limits["max_concurrent_operations"] == 4, \
f"Expected full version max_concurrent_operations=4, got {limits['max_concurrent_operations']}"
def test_get_version_limits_free_returns_restricted_limits(self):
"""Verify free version returns restricted limits for texture size and concurrent operations."""
"""Verify free version returns restricted limits for concurrent operations only."""
from src.utils.constants import get_version_limits
from unittest.mock import patch
with patch('src.utils.constants.VERSION_TYPE', 'free'):
limits = get_version_limits()
assert limits["max_texture_size"] == 1024, \
f"Expected free version max_texture_size=1024, got {limits['max_texture_size']}"
assert "max_texture_size" not in limits, "Free version should not enforce a texture size limit"
assert limits["max_concurrent_operations"] == 1, \
f"Expected free version max_concurrent_operations=1, got {limits['max_concurrent_operations']}"
@@ -278,7 +274,7 @@ class TestVersionDifferentiationScenarios:
# Technical limitations
limits = get_version_limits()
assert limits["max_texture_size"] == 1024, "Should have 1024px texture limit"
assert "max_texture_size" not in limits, "Free version should not enforce a texture size cap"
assert limits["max_concurrent_operations"] == 1, "Should have 1 concurrent operation limit"
def test_full_version_complete_scenario(self):
@@ -296,5 +292,5 @@ class TestVersionDifferentiationScenarios:
# Generous technical limits
limits = get_version_limits()
assert limits["max_texture_size"] == 4096, "Should have 4096px texture limit"
assert "max_texture_size" not in limits, "Full version should not enforce a texture size cap"
assert limits["max_concurrent_operations"] == 4, "Should have 4 concurrent operations limit"