diff --git a/README.md b/README.md index 56e4097..d9c3360 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ A modern Blender addon that generates image textures from text with extensive cu - Per-overlay scaling, rotation, and z-index control - Image spacing and sequencing for complex layouts - Enable/disable individual overlays + - SVG overlays automatically rasterized to preserve sharpness across texture sizes - **Color & Transparency**: @@ -161,6 +162,9 @@ A modern Blender addon that generates image textures from text with extensive cu - **Transform Controls**: Scale (0.1-5.0x), rotation (360°), z-index layering - **Spacing Controls**: Configurable spacing between images and text - **Sequence Control**: Order overlays in prepend/append modes +- **SVG Support**: Vector overlays (.svg) are rasterized on demand and cached for reuse + +Cached raster images are stored under Blender's user config directory in `text_texture_generator/overlay_cache`. ### Normal Maps @@ -225,6 +229,10 @@ The addon automatically installs required dependencies: - **Pillow** (>=10.0.0): For image generation and text rendering +Optional dependency for vector overlays: + +- **CairoSVG** (>=2.7.0): Required to rasterize SVG overlays. Install it inside Blender's Python environment with `pip install cairosvg`. + ## Troubleshooting ### Common Issues diff --git a/dist/text_texture_generator_v1.0.0.zip b/dist/text_texture_generator_v1.0.0.zip index c84f6eb..719868f 100644 Binary files a/dist/text_texture_generator_v1.0.0.zip and b/dist/text_texture_generator_v1.0.0.zip differ diff --git a/dist/text_texture_generator_v1.0.0_free.zip b/dist/text_texture_generator_v1.0.0_free.zip index 9dad90b..1d5e0ea 100644 Binary files a/dist/text_texture_generator_v1.0.0_free.zip and b/dist/text_texture_generator_v1.0.0_free.zip differ diff --git a/marketing/demo.blend b/marketing/demo.blend index 242bf8f..82551c8 100644 Binary files a/marketing/demo.blend and b/marketing/demo.blend differ diff --git a/marketing/demo.blend1 b/marketing/demo.blend1 index 5aa7082..242bf8f 100644 Binary files a/marketing/demo.blend1 and b/marketing/demo.blend1 differ diff --git a/src/__pycache__/__init__.cpython-311.pyc b/src/__pycache__/__init__.cpython-311.pyc index 6cfc2a6..38042fb 100644 Binary files a/src/__pycache__/__init__.cpython-311.pyc and b/src/__pycache__/__init__.cpython-311.pyc differ diff --git a/src/core/__pycache__/generation_engine.cpython-311.pyc b/src/core/__pycache__/generation_engine.cpython-311.pyc index 8ff9ced..c0340fe 100644 Binary files a/src/core/__pycache__/generation_engine.cpython-311.pyc and b/src/core/__pycache__/generation_engine.cpython-311.pyc differ diff --git a/src/core/generation_engine.py b/src/core/generation_engine.py index 4a1bf4e..01bf031 100644 --- a/src/core/generation_engine.py +++ b/src/core/generation_engine.py @@ -3,8 +3,15 @@ Text Texture Generation Engine Core functionality for generating texture images with text overlays. """ +import math + import bpy from ..utils.constants import has_text_fitting +from ..utils.overlay_assets import ( + SVGConversionError, + ensure_svg_raster, + is_svg_path, +) from ..utils.system import find_default_truetype_font def generate_texture_image(props, width, height): @@ -96,20 +103,38 @@ def generate_texture_image(props, width, height): ) for overlay, image_path in overlays_to_apply: + resolved_path = image_path + scale = getattr(overlay, 'scale', 1.0) try: - with Image.open(image_path) as raw_overlay: - overlay_img = raw_overlay.convert('RGBA') - except Exception as overlay_error: - print(f"WARNING: Failed to load overlay image '{image_path}': {overlay_error}") + 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 = float(getattr(overlay, 'scale', 1.0) or 1.0) - if scale <= 0: + 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 '{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 != 1.0: + + if scale_to_apply != 1.0: new_size = ( - max(1, int(round(overlay_img.width * scale))), - max(1, int(round(overlay_img.height * scale))) + 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) diff --git a/src/properties/__pycache__/property_groups.cpython-311.pyc b/src/properties/__pycache__/property_groups.cpython-311.pyc index fcbddcd..77630c1 100644 Binary files a/src/properties/__pycache__/property_groups.cpython-311.pyc and b/src/properties/__pycache__/property_groups.cpython-311.pyc differ diff --git a/src/properties/property_groups.py b/src/properties/property_groups.py index f95335f..7f78e81 100644 --- a/src/properties/property_groups.py +++ b/src/properties/property_groups.py @@ -183,9 +183,9 @@ class TEXT_TEXTURE_Properties(PropertyGroup): ) # Collapsible section expand/collapse states - expand_image_overlays: BoolProperty( - name="Image Overlays", - description="Show/hide Image Overlays section", + expand_composition: BoolProperty( + name="Composition", + description="Show/hide Composition section", default=False ) @@ -281,6 +281,16 @@ class TEXT_TEXTURE_Properties(PropertyGroup): update=update_live_preview ) + composition_active_tab: EnumProperty( + name="Composition Mode", + description="Select which composition controls to display", + items=[ + ('TEXT', "Text", "Configure text layering options"), + ('IMAGE', "Image", "Configure image overlay options"), + ], + default='TEXT' + ) + texture_width: IntProperty( name="Width", description="Texture width in pixels", diff --git a/src/ui/__pycache__/panels.cpython-311.pyc b/src/ui/__pycache__/panels.cpython-311.pyc index d2534c7..2907a24 100644 Binary files a/src/ui/__pycache__/panels.cpython-311.pyc and b/src/ui/__pycache__/panels.cpython-311.pyc differ diff --git a/src/ui/panels.py b/src/ui/panels.py index 46a2dc2..2e10f9e 100644 --- a/src/ui/panels.py +++ b/src/ui/panels.py @@ -199,8 +199,21 @@ def draw_version_info_section(layout, props): def draw_text_input_section(layout, props): pass - """Draw the main text input field""" - layout.prop(props, "text", text="") + """Draw the main text input field with dimensions.""" + content = layout.column(align=True) + content.use_property_split = False + content.use_property_decorate = False + + row = content.row(align=True) + text_col = row.column(align=True) + text_col.prop(props, "text", text="") + + dimensions_col = row.column(align=True) + dimensions_col.use_property_split = False + dimensions_col.use_property_decorate = False + dim_row = dimensions_col.row(align=True) + dim_row.prop(props, "texture_width", text="") + dim_row.prop(props, "texture_height", text="") def draw_font_dropdown_section(layout, props): pass @@ -216,17 +229,22 @@ def draw_generate_button(layout): row.scale_y = 1.5 row.operator("text_texture.generate", text="Generate Text Texture", icon='IMAGE_DATA') -def draw_image_overlays_section(layout, props): +def draw_composition_image_section(layout, props): pass - """Draw image overlays controls with collapsible individual overlays""" + """Draw image overlay controls for the composition panel.""" + section = layout.box() + section.use_property_split = False + section.use_property_decorate = False + section.label(text="Image Elements", icon='IMAGE_DATA') + # Check if image overlays are available if not has_image_overlays(): pass - draw_feature_lock_indicator(layout, 'image_overlays') + draw_feature_lock_indicator(section, 'image_overlays') return # Add overlay button - use safe operator call - row = layout.row() + row = section.row() safe_operator_call(row, "text_texture.add_overlay", lambda layout: draw_feature_lock_indicator(layout, 'image_overlays'), text="Add Image Overlay", icon='ADD') @@ -234,12 +252,12 @@ def draw_image_overlays_section(layout, props): # Individual overlay controls if props.image_overlays: pass - layout.separator() + section.separator() for i, overlay in enumerate(props.image_overlays): pass # Create collapsible box for each overlay - box = layout.box() + box = section.box() # Header row with controls header_row = box.row(align=True) @@ -315,92 +333,146 @@ def draw_image_overlays_section(layout, props): trans_row.prop(overlay, "rotation", text="Rotation") trans_row.prop(overlay, "z_index", text="Z-Level") +def draw_text_composition_section(layout, props): + pass + """Draw additional text controls consolidated in the composition panel.""" + section = layout.box() + section.use_property_split = False + section.use_property_decorate = False + section.label(text="Text Elements", icon='OUTLINER_OB_FONT') + + layout_row = section.row(align=True) + layout_row.prop(props, "prepend_append_layout", text="Layout") + + section.separator(factor=0.6) + + def draw_text_block(label_text, icon, text_prop, margin_prop): + block = section.box() + block.use_property_split = False + block.use_property_decorate = False + header = block.row(align=True) + header.label(text=label_text, icon=icon) + + text_row = block.row(align=True) + text_row.prop(props, text_prop, text="") + + spacing_row = block.row(align=True) + spacing_row.enabled = bool(getattr(props, text_prop).strip()) + spacing_row.prop(props, margin_prop, text="Spacing", slider=True) + + draw_text_block("Prepend Text", 'TRIA_RIGHT', "prepend_text", "prepend_margin") + section.separator(factor=0.4) + draw_text_block("Append Text", 'TRIA_LEFT', "append_text", "append_margin") + +def draw_composition_section(layout, props): + pass + """Draw combined composition controls for text and images.""" + content = layout.column(align=True) + content.use_property_split = False + content.use_property_decorate = False + + tabs_row = content.row(align=True) + tabs_row.prop(props, "composition_active_tab", expand=True) + + content.separator(factor=0.6) + + if props.composition_active_tab == 'IMAGE': + pass + if not has_image_overlays(): + pass + draw_feature_lock_indicator(content, 'image_overlays') + return + draw_composition_image_section(content, props) + else: + pass + draw_text_composition_section(content, props) + def draw_positioning_section(layout, props): pass - """Draw positioning and alignment controls""" + """Draw positioning and alignment controls with streamlined spacing.""" from ..utils.system import get_anchor_point_matrix - # Dimensions moved here from top level - layout.label(text="Dimensions:", icon='SETTINGS') - row = layout.row(align=True) - row.prop(props, "texture_width", text="Width") - row.prop(props, "texture_height", text="Height") + content = layout.column(align=True) + content.use_property_split = True + content.use_property_decorate = False - layout.separator() - - # Prepend/Append Text Section - layout.label(text="Additional Text:", icon='TEXT') - - # Compact layout selector - layout.prop(props, "prepend_append_layout", text="Layout") - - # Minimal prepend/append controls - row = layout.row(align=True) - row.prop(props, "prepend_text", text="Prepend") - if props.prepend_text.strip(): - pass - row.prop(props, "prepend_margin", text="", slider=True) - - row = layout.row(align=True) - row.prop(props, "append_text", text="Append") - if props.append_text.strip(): - pass - row.prop(props, "append_margin", text="", slider=True) - - layout.separator() - layout.prop(props, "position_mode", text="Mode") + content.label(text="Placement Mode", icon='ANCHOR_CENTER') + content.prop(props, "position_mode", text="") + content.separator(factor=0.6) if props.position_mode == 'PRESET': pass - layout.separator() - layout.label(text="Anchor Point:", icon='ANCHOR_TOP') + # Anchor selection grid + anchor_box = content.box() + anchor_box.use_property_split = False + anchor_box.use_property_decorate = False + anchor_box.label(text="Anchor Point", icon='ANCHOR_TOP') anchor_matrix = get_anchor_point_matrix() - for row_idx, row in enumerate(anchor_matrix): + for row in anchor_matrix: pass - grid_row = layout.row(align=True) - grid_row.scale_y = 1.2 - for col_idx, anchor in enumerate(row): + grid_row = anchor_box.row(align=True) + grid_row.scale_y = 1.15 + for anchor in row: pass - if anchor == props.anchor_point: - premium_lock = create_premium_lock_function() - op = safe_operator_call(grid_row, "text_texture.set_anchor_point", premium_lock, - text="●", depress=True) - else: - premium_lock = create_premium_lock_function() - op = safe_operator_call(grid_row, "text_texture.set_anchor_point", premium_lock, - text="○") + premium_lock = create_premium_lock_function() + is_active = anchor == props.anchor_point + op = safe_operator_call( + grid_row, + "text_texture.set_anchor_point", + premium_lock, + text="●" if is_active else "○", + depress=is_active + ) if op: op.anchor_point = anchor - # Margins - layout.separator() - margin_row = layout.row(align=True) - margin_row.label(text="Margins:") - margin_row.prop(props, "margins_linked", text="", icon='LINKED' if props.margins_linked else 'UNLINKED', toggle=True) + anchor_box.separator(factor=0.6) - margins_row = layout.row(align=True) + # Margin controls + margin_box = content.box() + margin_box.use_property_split = True + margin_box.use_property_decorate = False + header_row = margin_box.row(align=True) + header_row.label(text="Margins", icon='ARROW_LEFTRIGHT') + header_row.prop( + props, + "margins_linked", + text="", + icon='LINKED' if props.margins_linked else 'UNLINKED', + toggle=True + ) + + margins_row = margin_box.row(align=True) margins_row.prop(props, "margin_x", text="X") - if not props.margins_linked: - pass - margins_row.prop(props, "margin_y", text="Y") + y_col = margins_row.row(align=True) + y_col.enabled = not props.margins_linked + y_col.prop(props, "margin_y", text="Y") - # Offsets - layout.separator() - layout.label(text="Fine-tune (pixels):") - offset_row = layout.row(align=True) + margin_box.separator(factor=0.6) + + # Pixel offsets + offset_box = content.box() + offset_box.use_property_split = True + offset_box.use_property_decorate = False + offset_box.label(text="Fine-tune (pixels)", icon='DRIVER_DISTANCE') + offset_row = offset_box.row(align=True) offset_row.prop(props, "offset_x", text="X") offset_row.prop(props, "offset_y", text="Y") - else: # MANUAL mode - layout.separator() - layout.prop(props, "manual_position_unit", text="Unit") - manual_row = layout.row(align=True) + else: + pass + manual_box = content.box() + manual_box.use_property_split = True + manual_box.use_property_decorate = False + manual_box.label(text="Manual Placement", icon='DRIVER_TRANSFORM') + manual_box.prop(props, "manual_position_unit", text="Unit") + manual_row = manual_box.row(align=True) manual_row.prop(props, "manual_position_x", text="X") manual_row.prop(props, "manual_position_y", text="Y") - layout.separator() - layout.prop(props, "constrain_to_canvas") + content.separator(factor=0.6) + content.prop(props, "constrain_to_canvas") def draw_font_settings_section(layout, props): pass @@ -537,7 +609,7 @@ def draw_presets_section(layout, props): persistent_count = total_presets - blend_count summary_row = col.row(align=True) - summary_row.label(text=f"{total_presets} presets", icon='PRESET_HLT') + summary_row.label(text=f"{total_presets} presets", icon='PRESET') summary_row.label(text=f"{blend_count} project", icon='FILE_BLEND') summary_row.label(text=f"{persistent_count} global", icon='FILE_CACHE') @@ -957,25 +1029,10 @@ class TEXT_TEXTURE_PT_panel(Panel): if shader_expanded: draw_shader_options_only(shader_expanded, props) - # Image Overlays Section (collapsed by default) - Show with premium indicator - overlay_header_row = layout.row(align=True) - - # Gray out header if locked and prevent expansion - from ..utils.constants import has_image_overlays - if not has_image_overlays(): - overlay_header_row.enabled = False - # Don't allow expansion for locked features - overlay_expanded = None - # Show lock indicator instead of expandable header - lock_header = overlay_header_row.row() - lock_header.label(text="Image Overlays", icon='IMAGE_DATA') - lock_header.label(text="🔒 PRO", icon='LOCKED') - else: - overlay_expanded = draw_collapsible_header(overlay_header_row, "expand_image_overlays", "Image Overlays", 'IMAGE_DATA') - - # Only show content if available and expanded - if overlay_expanded: - draw_image_overlays_section(overlay_expanded, props) + # Composition Section (collapsed by default) + composition_section = draw_collapsible_header(layout, "expand_composition", "Composition", 'SEQ_PREVIEW') + if composition_section: + draw_composition_section(composition_section, props) # Stroke Section (collapsed by default) - Show with premium indicator @@ -1169,25 +1226,10 @@ class TEXT_TEXTURE_PT_panel_3d(Panel): if shader_expanded: draw_shader_options_only(shader_expanded, props) - # Image Overlays Section (collapsed by default) - Show with premium indicator - overlay_header_row = layout.row(align=True) - - # Gray out header if locked and prevent expansion - from ..utils.constants import has_image_overlays - if not has_image_overlays(): - overlay_header_row.enabled = False - # Don't allow expansion for locked features - overlay_expanded = None - # Show lock indicator instead of expandable header - lock_header = overlay_header_row.row() - lock_header.label(text="Image Overlays", icon='IMAGE_DATA') - lock_header.label(text="🔒 PRO", icon='LOCKED') - else: - overlay_expanded = draw_collapsible_header(overlay_header_row, "expand_image_overlays", "Image Overlays", 'IMAGE_DATA') - - # Only show content if available and expanded - if overlay_expanded: - draw_image_overlays_section(overlay_expanded, props) + # Composition Section (collapsed by default) + composition_section = draw_collapsible_header(layout, "expand_composition", "Composition", 'SEQ_PREVIEW') + if composition_section: + draw_composition_section(composition_section, props) # Stroke Section (collapsed by default) - Show with premium indicator @@ -1361,25 +1403,10 @@ class TEXT_TEXTURE_PT_panel_properties(Panel): # === PREMIUM FEATURES === - # Image Overlays Section (collapsed by default) - Show with premium indicator - overlay_header_row = layout.row(align=True) - - # Gray out header if locked and prevent expansion - from ..utils.constants import has_image_overlays - if not has_image_overlays(): - overlay_header_row.enabled = False - # Don't allow expansion for locked features - overlay_expanded = None - # Show lock indicator instead of expandable header - lock_header = overlay_header_row.row() - lock_header.label(text="Image Overlays", icon='IMAGE_DATA') - lock_header.label(text="🔒 PRO", icon='LOCKED') - else: - overlay_expanded = draw_collapsible_header(overlay_header_row, "expand_image_overlays", "Image Overlays", 'IMAGE_DATA') - - # Only show content if available and expanded - if overlay_expanded: - draw_image_overlays_section(overlay_expanded, props) + # Composition Section (collapsed by default) + composition_section = draw_collapsible_header(layout, "expand_composition", "Composition", 'SEQ_PREVIEW') + if composition_section: + draw_composition_section(composition_section, props) # Stroke Section (collapsed by default) - Show with premium indicator diff --git a/src/utils/__pycache__/constants.cpython-311.pyc b/src/utils/__pycache__/constants.cpython-311.pyc index a684cf0..165163f 100644 Binary files a/src/utils/__pycache__/constants.cpython-311.pyc and b/src/utils/__pycache__/constants.cpython-311.pyc differ diff --git a/src/utils/overlay_assets.py b/src/utils/overlay_assets.py new file mode 100644 index 0000000..e5c4692 --- /dev/null +++ b/src/utils/overlay_assets.py @@ -0,0 +1,116 @@ +""" +Utilities for managing image overlay assets, including SVG rasterization. +""" + +from __future__ import annotations + +import hashlib +import math +import os +import sys +import tempfile +from dataclasses import dataclass + + +class SVGConversionError(RuntimeError): + """Raised when an SVG overlay cannot be rasterized.""" + pass + + +@dataclass(frozen=True) +class RasterizationResult: + """Metadata about a rasterized overlay image.""" + + path: str + applied_scale: float + + +def _get_bpy_module(): + """Return bpy module if available, otherwise None.""" + return sys.modules.get("bpy") + + +def is_svg_path(path: str) -> bool: + """Return True if the provided path looks like an SVG file.""" + if not path: + return False + return str(path).lower().endswith(".svg") + + +def get_overlay_cache_dir() -> str: + """ + Return a writable directory for cached overlay images. + + Prefers Blender's CONFIG directory when available, otherwise falls back + to the system temporary directory. + """ + base_dir = None + bpy_module = _get_bpy_module() + + if bpy_module is not None: + bpy_utils = getattr(bpy_module, "utils", None) + user_resource = getattr(bpy_utils, "user_resource", None) + if callable(user_resource): + try: + base_dir = user_resource('CONFIG') + except Exception: + base_dir = None + + if not base_dir: + bpy_app = getattr(bpy_module, "app", None) + base_dir = getattr(bpy_app, "tempdir", None) + + if not base_dir: + base_dir = tempfile.gettempdir() + + cache_dir = os.path.join(os.path.abspath(base_dir), "text_texture_generator", "overlay_cache") + try: + os.makedirs(cache_dir, exist_ok=True) + except OSError: + # Fall back to a temporary directory specific to this process. + fallback_dir = os.path.join(tempfile.gettempdir(), "text_texture_generator", "overlay_cache") + os.makedirs(fallback_dir, exist_ok=True) + cache_dir = fallback_dir + + return cache_dir + + +def ensure_svg_raster(image_path: str, scale: float) -> RasterizationResult: + """ + Ensure a rasterized PNG exists for the given SVG and return its path with applied scale. + + The rasterization scale is applied during conversion so the caller should not + reapply the same scale. + """ + if not image_path or not os.path.exists(image_path): + raise SVGConversionError(f"SVG overlay path does not exist: {image_path}") + + try: + import cairosvg # type: ignore + except ImportError as import_error: + raise SVGConversionError( + "SVG overlay support requires the 'cairosvg' package. " + "Install it via pip (pip install cairosvg) inside Blender's Python environment." + ) from import_error + + # Guard against invalid scales (NaN, infinity, zero, negative) + if not isinstance(scale, (int, float)) or not math.isfinite(scale): + raise SVGConversionError(f"Invalid overlay scale for SVG: {scale!r}") + + if scale <= 0: + raise SVGConversionError("Overlay scale must be greater than zero for SVG rasterization.") + + normalized_scale = max(float(scale), 0.01) + source_mtime = os.path.getmtime(image_path) + cache_dir = get_overlay_cache_dir() + + cache_key = f"{os.path.abspath(image_path)}|{source_mtime:.6f}|{normalized_scale:.6f}" + cache_name = hashlib.sha256(cache_key.encode("utf-8")).hexdigest()[:32] + ".png" + cache_path = os.path.join(cache_dir, cache_name) + + if not os.path.exists(cache_path): + # Perform rasterization and write to cache path + with open(cache_path, "wb") as cache_file: + cairosvg.svg2png(url=image_path, scale=normalized_scale, write_to=cache_file) # type: ignore[arg-type] + + return RasterizationResult(path=cache_path, applied_scale=1.0) diff --git a/tests/unit/core/__pycache__/test_generation_engine.cpython-311-pytest-8.3.5.pyc b/tests/unit/core/__pycache__/test_generation_engine.cpython-311-pytest-8.3.5.pyc index b726b6d..f12241b 100644 Binary files a/tests/unit/core/__pycache__/test_generation_engine.cpython-311-pytest-8.3.5.pyc and b/tests/unit/core/__pycache__/test_generation_engine.cpython-311-pytest-8.3.5.pyc differ diff --git a/tests/unit/core/test_generation_engine.py b/tests/unit/core/test_generation_engine.py index e50e021..bf7e22a 100644 --- a/tests/unit/core/test_generation_engine.py +++ b/tests/unit/core/test_generation_engine.py @@ -4,6 +4,7 @@ import pytest from unittest.mock import Mock, MagicMock, patch from src.core.generation_engine import generate_texture_image, generate_preview +from src.utils.overlay_assets import RasterizationResult # Test configuration constants DEFAULT_WIDTH = 512 DEFAULT_HEIGHT = 512 @@ -507,6 +508,58 @@ class TestGenerateTextureImage: assert center_pixel[2] == pytest.approx(0.0, abs=1e-3) assert center_pixel[3] == pytest.approx(1.0, rel=1e-3) + def test_svg_overlay_is_rasterized_and_applied(self, tmp_path): + """SVG overlays should be rasterized before being composited.""" + Image = pytest.importorskip("PIL.Image") + with patch('src.core.generation_engine.bpy') as mock_bpy, \ + patch('src.core.generation_engine.ensure_svg_raster') as mock_rasterize: + width = height = 64 + svg_path = tmp_path / "overlay.svg" + svg_path.write_text('') + + raster_path = tmp_path / "overlay_raster.png" + overlay_source = Image.new("RGBA", (16, 16), (0, 255, 0, 255)) + overlay_source.save(raster_path) + + mock_rasterize.return_value = RasterizationResult(path=str(raster_path), applied_scale=1.0) + + 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="", + background_transparent=False, + background_color=(1.0, 1.0, 1.0, 1.0) + ) + + overlay = MagicMock() + overlay.enabled = True + overlay.image_path = str(svg_path) + overlay.positioning_mode = 'ABSOLUTE' + overlay.x_position = 0.5 + overlay.y_position = 0.5 + overlay.scale = 1.0 + overlay.rotation = 0.0 + overlay.z_index = 0 + 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 + mock_rasterize.assert_called_once_with(str(svg_path), overlay.scale) + + center_index = ((height // 2) * width + (width // 2)) * 4 + center_pixel = mock_blender_image.pixels[center_index:center_index + 4] + assert center_pixel[0] == pytest.approx(0.0, abs=1e-3) + assert center_pixel[1] == pytest.approx(1.0, rel=1e-3) + assert center_pixel[2] == pytest.approx(0.0, abs=1e-3) + assert center_pixel[3] == pytest.approx(1.0, rel=1e-3) + def test_skips_overlays_without_valid_path(self, tmp_path): """Overlays without a valid image path should be ignored.""" pytest.importorskip("PIL.Image") diff --git a/tests/unit/ui/test_panel_layout_overhaul.py b/tests/unit/ui/test_panel_layout_overhaul.py new file mode 100644 index 0000000..51723e8 --- /dev/null +++ b/tests/unit/ui/test_panel_layout_overhaul.py @@ -0,0 +1,193 @@ +"""Tests for the overhauled Positioning & Layout UI.""" + +from types import SimpleNamespace +import importlib + +import pytest + + +panels = importlib.import_module("src.ui.panels") + + +class FakeLayout: + """Minimal substitute for Blender layout that records UI calls.""" + + def __init__(self, kind="layout"): + self.kind = kind + self.calls = [] + self.children = [] + self.enabled = True + self.alignment = None + self.scale_x = 1.0 + self.scale_y = 1.0 + self.use_property_split = False + self.use_property_decorate = True + + def _child(self, kind, kwargs): + child = FakeLayout(kind) + self.calls.append((kind, kwargs, child)) + self.children.append(child) + return child + + def label(self, text="", **kwargs): + self.calls.append(("label", {"text": text, **kwargs})) + return None + + def prop(self, props, prop_name, **kwargs): + # Access attribute to mirror Blender's behaviour of raising AttributeError + getattr(props, prop_name) + data = {"name": prop_name} + data.update(kwargs) + self.calls.append(("prop", data)) + return None + + def separator(self, **kwargs): + self.calls.append(("separator", kwargs)) + return None + + def row(self, **kwargs): + return self._child("row", kwargs) + + def column(self, **kwargs): + return self._child("column", kwargs) + + def box(self): + return self._child("box", {}) + + def operator(self, operator_id, **kwargs): + self.calls.append(("operator", {"id": operator_id, **kwargs})) + return SimpleNamespace() + + +def collect_calls(layout): + """Flatten recorded calls from layout tree.""" + flattened = [] + for entry in layout.calls: + if len(entry) == 3: + kind, kwargs, child = entry + flattened.append((kind, kwargs)) + flattened.extend(collect_calls(child)) + else: + flattened.append(entry) + return flattened + + +def make_default_props(**overrides): + """Helper to build a props namespace with defaults for positioning tests.""" + defaults = dict( + text="Main", + texture_width=1024, + texture_height=1024, + prepend_text="", + append_text="", + prepend_margin=10.0, + append_margin=10.0, + prepend_append_layout="HORIZONTAL", + position_mode="PRESET", + anchor_point="MIDDLE_CENTER", + margins_linked=False, + margin_x=0.0, + margin_y=0.0, + offset_x=0, + offset_y=0, + manual_position_unit="PERCENTAGE", + manual_position_x=50.0, + manual_position_y=50.0, + constrain_to_canvas=True, + composition_active_tab="TEXT", + image_overlays=[], + active_overlay_index=0, + ) + defaults.update(overrides) + return SimpleNamespace(**defaults) + + +def test_text_input_section_exposes_dimensions(): + """Dimensions should appear alongside the main text control.""" + layout = FakeLayout() + props = make_default_props() + + panels.draw_text_input_section(layout, props) + + calls = collect_calls(layout) + prop_names = [data["name"] for kind, data in calls if kind == "prop"] + assert "texture_width" in prop_names + assert "texture_height" in prop_names + + +def test_positioning_section_drops_additional_text(monkeypatch): + """Positioning panel should no longer render prepend/append controls.""" + layout = FakeLayout() + props = make_default_props() + + monkeypatch.setattr("src.utils.system.get_anchor_point_matrix", lambda: [["CENTER"]]) + monkeypatch.setattr(panels, "safe_operator_call", lambda *args, **kwargs: SimpleNamespace()) + + panels.draw_positioning_section(layout, props) + + calls = collect_calls(layout) + labels = [data["text"] for kind, data in calls if kind == "label"] + prop_names = [data["name"] for kind, data in calls if kind == "prop"] + + assert "Additional Text:" not in labels + assert "prepend_text" not in prop_names + assert "append_text" not in prop_names + + +def test_composition_section_shows_text_controls(monkeypatch): + """Composition panel should surface text controls when text tab is active.""" + layout = FakeLayout() + props = make_default_props( + composition_active_tab="TEXT", + prepend_text="Intro", + append_text="Outro", + ) + + monkeypatch.setattr(panels, "safe_operator_call", lambda *args, **kwargs: SimpleNamespace()) + monkeypatch.setattr(panels, "has_image_overlays", lambda: True) + + panels.draw_composition_section(layout, props) + + calls = collect_calls(layout) + prop_names = [data["name"] for kind, data in calls if kind == "prop"] + labels = [data["text"] for kind, data in calls if kind == "label"] + + assert "composition_active_tab" in prop_names + assert "prepend_text" in prop_names + assert "append_text" in prop_names + assert any("Text" in text for text in labels) + + +def test_composition_section_shows_overlay_controls(monkeypatch): + """Composition panel should surface overlay controls when image tab is active.""" + layout = FakeLayout() + overlay = SimpleNamespace( + enabled=True, + name="Logo", + image_path="/tmp/logo.png", + positioning_mode="ABSOLUTE", + x_position=0.5, + y_position=0.5, + text_spacing=10.0, + image_spacing=5.0, + scale=1.0, + rotation=0.0, + z_index=1, + ) + props = make_default_props( + composition_active_tab="IMAGE", + image_overlays=[overlay], + ) + + monkeypatch.setattr(panels, "safe_operator_call", lambda *args, **kwargs: SimpleNamespace()) + monkeypatch.setattr(panels, "has_image_overlays", lambda: True) + + panels.draw_composition_section(layout, props) + + calls = collect_calls(layout) + prop_names = [data["name"] for kind, data in calls if kind == "prop"] + labels = [data["text"] for kind, data in calls if kind == "label"] + + assert "composition_active_tab" in prop_names + assert "positioning_mode" in prop_names + assert any("Image" in text for text in labels)