working
This commit is contained in:
@@ -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
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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'])
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
BIN
dist/text_texture_generator_v1.0.0.zip
vendored
BIN
dist/text_texture_generator_v1.0.0.zip
vendored
Binary file not shown.
BIN
dist/text_texture_generator_v1.0.0_free.zip
vendored
BIN
dist/text_texture_generator_v1.0.0_free.zip
vendored
Binary file not shown.
@@ -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.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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,7 +45,9 @@ except ImportError:
|
||||
pass
|
||||
# preset_ops module not available (free version)
|
||||
PRESET_OPERATORS = []
|
||||
try:
|
||||
|
||||
if is_full_version():
|
||||
try:
|
||||
pass
|
||||
from .text_editor_ops import *
|
||||
from .text_editor_ops import (
|
||||
@@ -53,14 +56,12 @@ try:
|
||||
TEXT_EDITOR_OPERATORS = [
|
||||
'TEXT_TEXTURE_OT_edit_text',
|
||||
]
|
||||
except ImportError as e:
|
||||
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)
|
||||
|
||||
Binary file not shown.
@@ -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)
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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, \
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user