This commit is contained in:
2025-10-13 12:56:15 +02:00
parent 065ab855c1
commit 2494897360
17 changed files with 572 additions and 140 deletions

View File

@@ -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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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)

View File

@@ -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",

View File

@@ -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

116
src/utils/overlay_assets.py Normal file
View File

@@ -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)

View File

@@ -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('<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"></svg>')
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")

View File

@@ -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)