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/preset_ops.py",
"operators/text_editor_ops.py" "operators/text_editor_ops.py"
], ],
"exclude_patterns": ["*batch*", "*advanced*", "*overlay*", "*utility*"], "exclude_patterns": ["*batch*", "*advanced*", "*utility*"],
"max_texture_size": 1024,
"max_concurrent_operations": 1 "max_concurrent_operations": 1
}, },
"full": { "full": {
"description": "Full version - all features included", "description": "Full version - all features included",
"exclude_files": ["blender_manifest.toml"], "exclude_files": ["blender_manifest.toml"],
"exclude_patterns": [], "exclude_patterns": [],
"max_texture_size": 4096,
"max_concurrent_operations": 4 "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 # Define features based on version type
if version_type == "free": if version_type == "free":
enabled_features = ["basic", "preview", "image_overlays", "basic_utilities"] enabled_features = ["basic", "preview"]
else: # full version else: # full version
enabled_features = ["basic", "preview", "image_overlays", "basic_utilities", "advanced_styling", "normal_maps", "presets", "batch_processing", "advanced_utilities", "text_fitting"] 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), 'BLENDER_VERSION_PATCH': str(blender_patch),
'DEPENDENCIES': dependencies_str, 'DEPENDENCIES': dependencies_str,
'ENABLED_FEATURES': json.dumps(enabled_features), 'ENABLED_FEATURES': json.dumps(enabled_features),
'MAX_TEXTURE_SIZE': str(version_config['max_texture_size']),
'MAX_CONCURRENT_OPERATIONS': str(version_config['max_concurrent_operations']) 'MAX_CONCURRENT_OPERATIONS': str(version_config['max_concurrent_operations'])
} }
@@ -204,4 +203,4 @@ if __name__ == "__main__":
sync_version(args.version, args.type) sync_version(args.version, args.type)
except Exception as e: except Exception as e:
print(f"Error: {e}") print(f"Error: {e}")
sys.exit(1) sys.exit(1)

View File

@@ -28,12 +28,10 @@ def get_version_limits():
"""Get version-specific limits.""" """Get version-specific limits."""
if VERSION_TYPE == "free": if VERSION_TYPE == "free":
return { return {
"max_texture_size": {{MAX_TEXTURE_SIZE}},
"max_concurrent_operations": {{MAX_CONCURRENT_OPERATIONS}} "max_concurrent_operations": {{MAX_CONCURRENT_OPERATIONS}}
} }
else: else:
return { return {
"max_texture_size": 4096,
"max_concurrent_operations": 4 "max_concurrent_operations": 4
} }
@@ -117,9 +115,13 @@ def has_text_fitting():
def get_max_resolution(): 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() limits = get_version_limits()
return limits.get("max_texture_size", 1024) return limits.get("max_texture_size")
def should_show_feature_lock(feature_name): def should_show_feature_lock(feature_name):
"""Check if feature lock indicator should be shown.""" """Check if feature lock indicator should be shown."""
@@ -157,11 +159,14 @@ def get_feature_availability_text():
def get_version_info_details(): def get_version_info_details():
"""Get detailed version information for display.""" """Get detailed version information for display."""
limits = get_version_limits() limits = get_version_limits()
max_texture_size = limits.get('max_texture_size')
if not max_texture_size:
max_texture_size = 'Unlimited'
info = { info = {
'version_type': VERSION_TYPE.upper(), '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'), 'max_concurrent_operations': limits.get('max_concurrent_operations', 'Unlimited'),
'features_enabled': len(ENABLED_FEATURES), 'features_enabled': len(ENABLED_FEATURES),
'upgrade_available': is_free_version() 'upgrade_available': is_free_version()
} }
return info return info

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 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. 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 ### 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 - **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 - **Dynamic Text Scaling**: Resolution-independent text rendering with crisp edge quality
### Precision Positioning System ### Precision Positioning System
@@ -38,7 +37,7 @@ Cross-platform compatibility across Windows, macOS, and Linux environments, comb
### High-Resolution Output ### 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 - **Format Optimization**: PNG, JPEG, and OpenEXR support with compression options
- **Memory Management**: Efficient processing for large textures without system overload - **Memory Management**: Efficient processing for large textures without system overload
- **Batch Processing**: Multiple texture generation with consistent settings - **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** #### **🆓 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 - **Essential Positioning System**: 9-point anchor positioning with pixel-perfect alignment
- **Seamless Blender Integration**: Automatic material node creation and assignment - **Seamless Blender Integration**: Automatic material node creation and assignment
- **Professional Font Support**: System font detection and custom font file compatibility - **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** #### **⭐ 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 - **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 - **Complete Preset Ecosystem**: Simplified two-tier preset management with import/export for team workflows
- **Professional Batch Processing**: Advanced utility operations and workflow automation tools - **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)) bicubic_filter = getattr(resampling_module, 'BICUBIC', getattr(Image, 'BICUBIC', Image.BILINEAR))
def _iter_enabled_components(): def _iter_enabled_components():
"""Yield enabled composition components.""" """Yield enabled composition components with their index."""
overlays = getattr(props, 'image_overlays', []) overlays = getattr(props, 'image_overlays', [])
try: try:
overlay_list = list(overlays) overlay_list = list(overlays)
except TypeError: except TypeError:
overlay_list = [] overlay_list = []
for overlay in overlay_list: for index, overlay in enumerate(overlay_list):
if not getattr(overlay, 'enabled', True): if not getattr(overlay, 'enabled', True):
continue continue
yield overlay yield index, overlay
def _calculate_overlay_position(overlay, overlay_size): def _calculate_overlay_position(overlay, overlay_size):
"""Calculate top-left pixel position for an overlay image.""" """Calculate top-left pixel position for an overlay image."""
@@ -91,102 +91,305 @@ def generate_texture_image(props, width, height):
x = max(0, min(max_x, x)) x = max(0, min(max_x, x))
y = max(0, min(max_y, y)) y = max(0, min(max_y, y))
return x, y return x, y
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:
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
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)
def _apply_composition_components(pil_canvas): temp_draw = ImageDraw.Draw(Image.new('RGBA', (1, 1)))
"""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: overlay_img = None
if getattr(component, 'component_type', 'IMAGE') == 'TEXT': baseline_offset = None
text_value = getattr(component, 'text_content', '').strip() component_descent = None
if not text_value: 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])
# 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)
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)
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)
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:
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:
return None
scale_to_apply = scale
if is_svg_path(resolved_path):
try:
raster_info = ensure_svg_raster(resolved_path, scale)
resolved_path = raster_info.path
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}")
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}")
return None
if scale_to_apply != 1.0:
new_size = (
max(1, int(round(overlay_img.width * scale_to_apply))),
max(1, int(round(overlay_img.height * scale_to_apply)))
)
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 continue
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)
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)
rotation = float(getattr(component, 'rotation', 0.0) or 0.0)
if rotation % 360: if rotation % 360:
overlay_img = overlay_img.rotate(-rotation, expand=True, resample=bicubic_filter) 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
})
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
x_pos, y_pos = _calculate_overlay_position(component, overlay_img.size) 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 = Image.new('RGBA', (width, height), (0, 0, 0, 0))
overlay_layer.paste(overlay_img, (x_pos, y_pos), overlay_img) overlay_layer.paste(overlay_img, (x_pos, y_pos), overlay_img)
pil_canvas = Image.alpha_composite(pil_canvas, overlay_layer) pil_canvas = Image.alpha_composite(pil_canvas, overlay_layer)
continue
resolved_path = getattr(component, 'image_path', '')
if not resolved_path:
continue
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
scale_to_apply = scale
if is_svg_path(resolved_path):
try:
raster_info = ensure_svg_raster(resolved_path, scale)
resolved_path = raster_info.path
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
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
if scale_to_apply != 1.0:
new_size = (
max(1, int(round(overlay_img.width * scale_to_apply))),
max(1, int(round(overlay_img.height * scale_to_apply)))
)
if new_size != overlay_img.size:
overlay_img = overlay_img.resize(new_size, lanczos_filter)
rotation = float(getattr(component, 'rotation', 0.0) or 0.0)
if rotation % 360:
overlay_img = overlay_img.rotate(-rotation, expand=True, resample=bicubic_filter)
x_pos, y_pos = _calculate_overlay_position(component, overlay_img.size)
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)
return pil_canvas return pil_canvas
# Create PIL image with transparent background # Create PIL image with transparent background
@@ -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,) bg_color = tuple(int(c * 255) for c in props.background_color[:3]) + (255,)
pil_img = Image.new('RGBA', (width, height), bg_color) 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) # Draw text if provided (including prepend/append)
full_text_parts = [] full_text_parts = []
if props.prepend_text.strip(): if props.prepend_text.strip():
@@ -212,7 +419,7 @@ def generate_texture_image(props, width, height):
if full_text_parts: if full_text_parts:
pass pass
draw = ImageDraw.Draw(pil_img) measure_draw = ImageDraw.Draw(pil_img)
# Combine text based on layout mode # Combine text based on layout mode
if props.prepend_append_layout == 'HORIZONTAL': 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) test_font = load_font_for_size(test_size)
try: 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_width = bbox[2] - bbox[0]
test_height = bbox[3] - bbox[1] test_height = bbox[3] - bbox[1]
except (ValueError, AttributeError): except (ValueError, AttributeError):
# Fallback for bitmap fonts # 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: if test_width <= target_width and test_height <= target_height:
pass pass
@@ -323,7 +530,12 @@ def generate_texture_image(props, width, height):
pass pass
font_size = props.font_size font_size = props.font_size
final_font_size = font_size
font = load_font_for_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 # Calculate text position and draw
if layout_mode == "vertical" and props.prepend_append_layout == 'VERTICAL': 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 # Draw each part separately for vertical layout
y_offset = 0 y_offset = 0
total_height = 0 total_height = 0
part_widths = []
part_baselines = []
# Calculate total height first # Calculate total height first
part_heights = [] part_heights = []
for part in full_text_parts: for part in full_text_parts:
pass pass
try: 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_height = bbox[3] - bbox[1]
part_width = bbox[2] - bbox[0]
baseline = base_ascent
except (ValueError, AttributeError): except (ValueError, AttributeError):
# Fallback for bitmap fonts # 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_heights.append(part_height)
part_widths.append(part_width)
part_baselines.append(baseline)
total_height += part_height total_height += part_height
# Add spacing between parts # Add spacing between parts
@@ -353,6 +572,23 @@ def generate_texture_image(props, width, height):
# Center vertically # Center vertically
start_y = (height - total_height) // 2 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 # Draw each part
current_y = start_y current_y = start_y
@@ -360,12 +596,7 @@ def generate_texture_image(props, width, height):
for i, part in enumerate(full_text_parts): for i, part in enumerate(full_text_parts):
pass pass
try: part_width = part_widths[i]
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)
x = (width - part_width) // 2 # Center horizontally x = (width - part_width) // 2 # Center horizontally
draw.text((x, current_y), part, font=font, fill=text_color) draw.text((x, current_y), part, font=font, fill=text_color)
@@ -377,17 +608,47 @@ def generate_texture_image(props, width, height):
else: else:
pass pass
# Horizontal layout or single text # Horizontal layout or single text
left_offset = 0
right_offset = 0
top_offset = 0
bottom_offset = 0
try: 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_width = text_bbox[2] - text_bbox[0]
text_height = text_bbox[3] - text_bbox[1] 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): except (ValueError, AttributeError):
# Fallback for bitmap fonts that don't support textbbox # 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 # Center text
x = (width - text_width) // 2 x = (width - text_width) // 2
y = (height - text_height) // 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 # Draw text
text_color = tuple(int(c * 255) for c in props.text_color[:4]) 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: else:
pass pass
# Apply any configured image overlays before converting to Blender pixels if not before_layers_applied:
pil_img = _apply_composition_components(pil_img) 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) # Convert PIL image to Blender format (flip vertically to fix mirroring)
pil_img = pil_img.transpose(Image.FLIP_TOP_BOTTOM) # Fix mirroring issue 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. Contains all Blender operators for text texture generation and management.
""" """
from ..utils.constants import is_full_version
from .generation_ops import * from .generation_ops import *
__all__ = [] __all__ = []
@@ -44,23 +45,23 @@ except ImportError:
pass pass
# preset_ops module not available (free version) # preset_ops module not available (free version)
PRESET_OPERATORS = [] PRESET_OPERATORS = []
try:
pass
from .text_editor_ops import *
from .text_editor_ops import (
TEXT_TEXTURE_OT_edit_text,
)
TEXT_EDITOR_OPERATORS = [
'TEXT_TEXTURE_OT_edit_text',
]
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 = []
if is_full_version():
try:
pass
from .text_editor_ops import *
from .text_editor_ops import (
TEXT_TEXTURE_OT_edit_text,
)
TEXT_EDITOR_OPERATORS = [
'TEXT_TEXTURE_OT_edit_text',
]
except ImportError as e:
import sys
print(f"WARNING: text_editor_ops import failed in operators/__init__.py: {e}", file=sys.stderr)
TEXT_EDITOR_OPERATORS = []
else:
TEXT_EDITOR_OPERATORS = []
try: try:
pass pass
@@ -106,8 +107,6 @@ __all__.extend([
# Add preset operators if available # Add preset operators if available
__all__.extend(PRESET_OPERATORS) __all__.extend(PRESET_OPERATORS)
# Add utility operators if available
# Add text editor operators if available # Add text editor operators if available
__all__.extend(TEXT_EDITOR_OPERATORS) __all__.extend(TEXT_EDITOR_OPERATORS)
__all__.extend(UTILITY_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 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): def draw_version_badge(layout):
pass pass
"""Draw version badge in panel header""" """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 = col.column(align=True)
limits_col.separator() limits_col.separator()
limits_col.label(text="Current Limits:") 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']}") limits_col.label(text=f"• Active features: {version_info['features_enabled']}")
# Feature availability status # Feature availability status
@@ -989,7 +1007,13 @@ class TEXT_TEXTURE_PT_panel(Panel):
draw_shader_options_only(shader_expanded, props) draw_shader_options_only(shader_expanded, props)
# Composition Section (collapsed by default) # 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: if composition_section:
draw_composition_section(composition_section, props) draw_composition_section(composition_section, props)
@@ -1186,7 +1210,13 @@ class TEXT_TEXTURE_PT_panel_3d(Panel):
draw_shader_options_only(shader_expanded, props) draw_shader_options_only(shader_expanded, props)
# Composition Section (collapsed by default) # 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: if composition_section:
draw_composition_section(composition_section, props) draw_composition_section(composition_section, props)
@@ -1363,7 +1393,13 @@ class TEXT_TEXTURE_PT_panel_properties(Panel):
# === PREMIUM FEATURES === # === PREMIUM FEATURES ===
# Composition Section (collapsed by default) # 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: if composition_section:
draw_composition_section(composition_section, props) draw_composition_section(composition_section, props)

View File

@@ -28,12 +28,10 @@ def get_version_limits():
"""Get version-specific limits.""" """Get version-specific limits."""
if VERSION_TYPE == "free": if VERSION_TYPE == "free":
return { return {
"max_texture_size": 1024,
"max_concurrent_operations": 1 "max_concurrent_operations": 1
} }
else: else:
return { return {
"max_texture_size": 4096,
"max_concurrent_operations": 4 "max_concurrent_operations": 4
} }
@@ -117,9 +115,13 @@ def has_text_fitting():
def get_max_resolution(): 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() limits = get_version_limits()
return limits.get("max_texture_size", 1024) return limits.get("max_texture_size")
def should_show_feature_lock(feature_name): def should_show_feature_lock(feature_name):
"""Check if feature lock indicator should be shown.""" """Check if feature lock indicator should be shown."""
@@ -157,11 +159,14 @@ def get_feature_availability_text():
def get_version_info_details(): def get_version_info_details():
"""Get detailed version information for display.""" """Get detailed version information for display."""
limits = get_version_limits() limits = get_version_limits()
max_texture_size = limits.get('max_texture_size')
if not max_texture_size:
max_texture_size = 'Unlimited'
info = { info = {
'version_type': VERSION_TYPE.upper(), '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'), 'max_concurrent_operations': limits.get('max_concurrent_operations', 'Unlimited'),
'features_enabled': len(ENABLED_FEATURES), 'features_enabled': len(ENABLED_FEATURES),
'upgrade_available': is_free_version() 'upgrade_available': is_free_version()
} }
return info return info

View File

@@ -5,7 +5,7 @@ These tests specify the expected behavior when VERSION_TYPE="free".
They validate that free version users: They validate that free version users:
1. Can use basic features (text generation, preview) 1. Can use basic features (text generation, preview)
2. CANNOT access PRO features (shaders, presets, overlays, normal maps) 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 4. See UI locks/badges on PRO features
TESTING STRATEGY: TESTING STRATEGY:
@@ -17,7 +17,7 @@ TESTING STRATEGY:
EXPECTED BEHAVIOR (FREE VERSION): EXPECTED BEHAVIOR (FREE VERSION):
- ENABLED_FEATURES = ["basic", "preview"] only - ENABLED_FEATURES = ["basic", "preview"] only
- BLOCKED: shader_generation, normal_maps, image_overlays, presets, advanced - 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 - 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 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"): Expected behavior (VERSION_TYPE="free"):
- get_version_limits()['max_texture_size'] == 1024 - get_version_limits() does NOT define 'max_texture_size'
- Attempting 4096px should fail or clamp to 1024 - Generating a 4096px texture succeeds without clamping
- UI should disable/hide 2048+ size options - UI should allow entering high-resolution values
Current state (VERSION_TYPE="full"): 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) 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 from text_texture_generator.utils.constants import get_version_limits
limits = get_version_limits() limits = get_version_limits()
# CRITICAL: This is the BUG - get_version_limits() returns same for both versions! # Ensure no artificial texture size cap is reported
max_size = limits.get('max_texture_size', 4096) max_size = limits.get('max_texture_size')
if max_size != 1024: if max_size:
print(f"FAIL: max_texture_size should be 1024 for free version, got {max_size}") print(f"FAIL: Expected no texture size cap, got {max_size}")
sys.exit(1) 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() 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 # 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 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): 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 test_result.exit_code == 0, f"Operator poll test failed:\n{output}"
assert "SUCCESS: has_multiline_text helper removed" in output assert "SUCCESS: has_multiline_text helper removed" in output
assert "SUCCESS: Multiline editor operator 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: Tests run in Docker with real Blender to verify:
- All features enabled in ENABLED_FEATURES list - All features enabled in ENABLED_FEATURES list
- Shader generation works - Shader generation works
- Large texture sizes (4096px) supported - Large texture sizes supported (no artificial cap)
- No UI locks/PRO badges visible - No UI locks/PRO badges visible
NO MOCKS - real Blender bpy API operations. NO MOCKS - real Blender bpy API operations.
@@ -259,15 +259,13 @@ except Exception as e:
assert "SUCCESS: Shader generation works" in output assert "SUCCESS: Shader generation works" in output
def test_full_version_large_texture_4096px( def test_full_version_supports_large_textures(
blender_container, blender_container,
addon_package, addon_package,
tmp_path tmp_path
): ):
""" """
Verify large texture size (4096px) works - this is the full version limit. Verify large texture sizes work without an explicit limit in the full version.
Full version should support up to 4096px.
""" """
script_content = """ script_content = """
import bpy import bpy
@@ -284,19 +282,20 @@ except Exception as e:
try: try:
from text_texture_generator.utils.constants import get_version_limits from text_texture_generator.utils.constants import get_version_limits
# Verify max texture size
limits = get_version_limits() limits = get_version_limits()
max_size = limits.get('max_texture_size', 0) max_size = limits.get('max_texture_size')
if max_size != 4096: if max_size:
print(f"FAIL: Expected max_texture_size=4096, got {max_size}") print(f"FAIL: Expected unlimited texture size, got {max_size}")
sys.exit(1) sys.exit(1)
# For now, just verify the limit and produce expected output props = bpy.context.scene.text_texture_props
# Full integration test would require registered properties props.text = "Full Version High Resolution"
print("Texture size: 4096x4096") props.texture_width = 4096
print("Image has pixel data") props.texture_height = 4096
print("SUCCESS: 4096px texture generated") 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: except Exception as e:
print(f"ERROR: {e}") print(f"ERROR: {e}")
@@ -374,8 +373,7 @@ except Exception as e:
) )
assert "Texture size: 4096x4096" in output assert "Texture size: 4096x4096" in output
assert "Image has pixel data" in output assert "SUCCESS: Full version large texture generated" in output
assert "SUCCESS: 4096px texture generated" in output
def test_full_version_no_ui_locks( def test_full_version_no_ui_locks(
@@ -407,11 +405,11 @@ try:
# Check version limits # Check version limits
limits = get_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}") print(f"MAX_TEXTURE_SIZE: {max_size}")
if max_size != 4096: if max_size:
print(f"FAIL: Expected max_texture_size=4096, got {max_size}") print(f"FAIL: Expected no texture size limit, got {max_size}")
sys.exit(1) sys.exit(1)
# Check if PRO features are locked # Check if PRO features are locked
@@ -431,6 +429,9 @@ try:
# For now, just verify limits without accessing unregistered properties # For now, just verify limits without accessing unregistered properties
# Full integration test would require registered 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(f"Can set 4096px: True")
print("SUCCESS: UI is fully unlocked") print("SUCCESS: UI is fully unlocked")
@@ -511,7 +512,7 @@ except Exception as e:
) )
assert "VERSION_TYPE: full" in output 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 "has_pro_locks: False" in output
assert "Can set 4096px: True" in output assert "Can set 4096px: True" in output

View File

@@ -149,7 +149,7 @@ class TestGenerateTextureImage:
assert result is not None assert result is not None
assert normal_map is None # Normal maps not enabled assert normal_map is None # Normal maps not enabled
mock_pil_image_module.new.assert_called_once() 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_draw.text.assert_called()
mock_bpy.data.images.new.assert_called_once() mock_bpy.data.images.new.assert_called_once()
mock_pil_image.transpose.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) <= expected_x + overlay_source.width
assert max(blue_columns) < width // 2 # should remain on left half 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): def test_text_fitting_enabled(self):
"""Test text fitting integration.""" """Test text fitting integration."""
with patch('PIL.Image') as mock_pil_image_module, \ 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" assert isinstance(limits, dict), "get_version_limits() should return a dictionary"
# Check required keys # Check required keys
required_keys = ["max_texture_size", "max_concurrent_operations"] required_keys = ["max_concurrent_operations"]
for key in required_keys: for key in required_keys:
assert key in limits, f"Missing required key '{key}' in version limits" 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 # 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" assert isinstance(limits["max_concurrent_operations"], int), "max_concurrent_operations should be an integer"
# Check version-specific limits # Check version-specific limits
if VERSION_TYPE == "free": 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']}" assert limits["max_concurrent_operations"] == 1, f"Free version should have 1 concurrent operation, got {limits['max_concurrent_operations']}"
else: 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']}" 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): 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 src.utils.constants import get_version_limits
from unittest.mock import patch from unittest.mock import patch
with patch('src.utils.constants.VERSION_TYPE', 'full'): with patch('src.utils.constants.VERSION_TYPE', 'full'):
limits = get_version_limits() limits = get_version_limits()
assert limits["max_texture_size"] == 4096, \ assert "max_texture_size" not in limits, "Full version should not enforce a texture size limit"
f"Expected full version max_texture_size=4096, got {limits['max_texture_size']}"
assert limits["max_concurrent_operations"] == 4, \ assert limits["max_concurrent_operations"] == 4, \
f"Expected full version max_concurrent_operations=4, got {limits['max_concurrent_operations']}" f"Expected full version max_concurrent_operations=4, got {limits['max_concurrent_operations']}"
def test_get_version_limits_free_returns_restricted_limits(self): 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 src.utils.constants import get_version_limits
from unittest.mock import patch from unittest.mock import patch
with patch('src.utils.constants.VERSION_TYPE', 'free'): with patch('src.utils.constants.VERSION_TYPE', 'free'):
limits = get_version_limits() limits = get_version_limits()
assert limits["max_texture_size"] == 1024, \ assert "max_texture_size" not in limits, "Free version should not enforce a texture size limit"
f"Expected free version max_texture_size=1024, got {limits['max_texture_size']}"
assert limits["max_concurrent_operations"] == 1, \ assert limits["max_concurrent_operations"] == 1, \
f"Expected free version max_concurrent_operations=1, got {limits['max_concurrent_operations']}" f"Expected free version max_concurrent_operations=1, got {limits['max_concurrent_operations']}"
@@ -278,7 +274,7 @@ class TestVersionDifferentiationScenarios:
# Technical limitations # Technical limitations
limits = get_version_limits() 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" assert limits["max_concurrent_operations"] == 1, "Should have 1 concurrent operation limit"
def test_full_version_complete_scenario(self): def test_full_version_complete_scenario(self):
@@ -296,5 +292,5 @@ class TestVersionDifferentiationScenarios:
# Generous technical limits # Generous technical limits
limits = get_version_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" assert limits["max_concurrent_operations"] == 4, "Should have 4 concurrent operations limit"