update
This commit is contained in:
@@ -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
|
- Per-overlay scaling, rotation, and z-index control
|
||||||
- Image spacing and sequencing for complex layouts
|
- Image spacing and sequencing for complex layouts
|
||||||
- Enable/disable individual overlays
|
- Enable/disable individual overlays
|
||||||
|
- SVG overlays automatically rasterized to preserve sharpness across texture sizes
|
||||||
|
|
||||||
- **Color & Transparency**:
|
- **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
|
- **Transform Controls**: Scale (0.1-5.0x), rotation (360°), z-index layering
|
||||||
- **Spacing Controls**: Configurable spacing between images and text
|
- **Spacing Controls**: Configurable spacing between images and text
|
||||||
- **Sequence Control**: Order overlays in prepend/append modes
|
- **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
|
### Normal Maps
|
||||||
|
|
||||||
@@ -225,6 +229,10 @@ The addon automatically installs required dependencies:
|
|||||||
|
|
||||||
- **Pillow** (>=10.0.0): For image generation and text rendering
|
- **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
|
## Troubleshooting
|
||||||
|
|
||||||
### Common Issues
|
### Common Issues
|
||||||
|
|||||||
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -3,8 +3,15 @@ Text Texture Generation Engine
|
|||||||
Core functionality for generating texture images with text overlays.
|
Core functionality for generating texture images with text overlays.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
import bpy
|
import bpy
|
||||||
from ..utils.constants import has_text_fitting
|
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
|
from ..utils.system import find_default_truetype_font
|
||||||
|
|
||||||
def generate_texture_image(props, width, height):
|
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:
|
for overlay, image_path in overlays_to_apply:
|
||||||
|
resolved_path = image_path
|
||||||
|
scale = getattr(overlay, 'scale', 1.0)
|
||||||
try:
|
try:
|
||||||
with Image.open(image_path) as raw_overlay:
|
scale = float(scale if scale is not None else 1.0)
|
||||||
overlay_img = raw_overlay.convert('RGBA')
|
except (TypeError, ValueError):
|
||||||
except Exception as overlay_error:
|
scale = 1.0
|
||||||
print(f"WARNING: Failed to load overlay image '{image_path}': {overlay_error}")
|
|
||||||
|
if not math.isfinite(scale) or scale <= 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
scale = float(getattr(overlay, 'scale', 1.0) or 1.0)
|
scale_to_apply = scale
|
||||||
if scale <= 0:
|
|
||||||
|
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
|
continue
|
||||||
if scale != 1.0:
|
|
||||||
|
if scale_to_apply != 1.0:
|
||||||
new_size = (
|
new_size = (
|
||||||
max(1, int(round(overlay_img.width * scale))),
|
max(1, int(round(overlay_img.width * scale_to_apply))),
|
||||||
max(1, int(round(overlay_img.height * scale)))
|
max(1, int(round(overlay_img.height * scale_to_apply)))
|
||||||
)
|
)
|
||||||
if new_size != overlay_img.size:
|
if new_size != overlay_img.size:
|
||||||
overlay_img = overlay_img.resize(new_size, lanczos_filter)
|
overlay_img = overlay_img.resize(new_size, lanczos_filter)
|
||||||
|
|||||||
Binary file not shown.
@@ -183,9 +183,9 @@ class TEXT_TEXTURE_Properties(PropertyGroup):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Collapsible section expand/collapse states
|
# Collapsible section expand/collapse states
|
||||||
expand_image_overlays: BoolProperty(
|
expand_composition: BoolProperty(
|
||||||
name="Image Overlays",
|
name="Composition",
|
||||||
description="Show/hide Image Overlays section",
|
description="Show/hide Composition section",
|
||||||
default=False
|
default=False
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -281,6 +281,16 @@ class TEXT_TEXTURE_Properties(PropertyGroup):
|
|||||||
update=update_live_preview
|
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(
|
texture_width: IntProperty(
|
||||||
name="Width",
|
name="Width",
|
||||||
description="Texture width in pixels",
|
description="Texture width in pixels",
|
||||||
|
|||||||
Binary file not shown.
283
src/ui/panels.py
283
src/ui/panels.py
@@ -199,8 +199,21 @@ def draw_version_info_section(layout, props):
|
|||||||
|
|
||||||
def draw_text_input_section(layout, props):
|
def draw_text_input_section(layout, props):
|
||||||
pass
|
pass
|
||||||
"""Draw the main text input field"""
|
"""Draw the main text input field with dimensions."""
|
||||||
layout.prop(props, "text", text="")
|
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):
|
def draw_font_dropdown_section(layout, props):
|
||||||
pass
|
pass
|
||||||
@@ -216,17 +229,22 @@ def draw_generate_button(layout):
|
|||||||
row.scale_y = 1.5
|
row.scale_y = 1.5
|
||||||
row.operator("text_texture.generate", text="Generate Text Texture", icon='IMAGE_DATA')
|
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
|
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
|
# Check if image overlays are available
|
||||||
if not has_image_overlays():
|
if not has_image_overlays():
|
||||||
pass
|
pass
|
||||||
draw_feature_lock_indicator(layout, 'image_overlays')
|
draw_feature_lock_indicator(section, 'image_overlays')
|
||||||
return
|
return
|
||||||
|
|
||||||
# Add overlay button - use safe operator call
|
# Add overlay button - use safe operator call
|
||||||
row = layout.row()
|
row = section.row()
|
||||||
safe_operator_call(row, "text_texture.add_overlay",
|
safe_operator_call(row, "text_texture.add_overlay",
|
||||||
lambda layout: draw_feature_lock_indicator(layout, 'image_overlays'),
|
lambda layout: draw_feature_lock_indicator(layout, 'image_overlays'),
|
||||||
text="Add Image Overlay", icon='ADD')
|
text="Add Image Overlay", icon='ADD')
|
||||||
@@ -234,12 +252,12 @@ def draw_image_overlays_section(layout, props):
|
|||||||
# Individual overlay controls
|
# Individual overlay controls
|
||||||
if props.image_overlays:
|
if props.image_overlays:
|
||||||
pass
|
pass
|
||||||
layout.separator()
|
section.separator()
|
||||||
|
|
||||||
for i, overlay in enumerate(props.image_overlays):
|
for i, overlay in enumerate(props.image_overlays):
|
||||||
pass
|
pass
|
||||||
# Create collapsible box for each overlay
|
# Create collapsible box for each overlay
|
||||||
box = layout.box()
|
box = section.box()
|
||||||
|
|
||||||
# Header row with controls
|
# Header row with controls
|
||||||
header_row = box.row(align=True)
|
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, "rotation", text="Rotation")
|
||||||
trans_row.prop(overlay, "z_index", text="Z-Level")
|
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):
|
def draw_positioning_section(layout, props):
|
||||||
pass
|
pass
|
||||||
"""Draw positioning and alignment controls"""
|
"""Draw positioning and alignment controls with streamlined spacing."""
|
||||||
from ..utils.system import get_anchor_point_matrix
|
from ..utils.system import get_anchor_point_matrix
|
||||||
|
|
||||||
# Dimensions moved here from top level
|
content = layout.column(align=True)
|
||||||
layout.label(text="Dimensions:", icon='SETTINGS')
|
content.use_property_split = True
|
||||||
row = layout.row(align=True)
|
content.use_property_decorate = False
|
||||||
row.prop(props, "texture_width", text="Width")
|
|
||||||
row.prop(props, "texture_height", text="Height")
|
|
||||||
|
|
||||||
layout.separator()
|
content.label(text="Placement Mode", icon='ANCHOR_CENTER')
|
||||||
|
content.prop(props, "position_mode", text="")
|
||||||
# Prepend/Append Text Section
|
content.separator(factor=0.6)
|
||||||
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")
|
|
||||||
|
|
||||||
if props.position_mode == 'PRESET':
|
if props.position_mode == 'PRESET':
|
||||||
pass
|
pass
|
||||||
layout.separator()
|
# Anchor selection grid
|
||||||
layout.label(text="Anchor Point:", icon='ANCHOR_TOP')
|
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()
|
anchor_matrix = get_anchor_point_matrix()
|
||||||
for row_idx, row in enumerate(anchor_matrix):
|
for row in anchor_matrix:
|
||||||
pass
|
pass
|
||||||
grid_row = layout.row(align=True)
|
grid_row = anchor_box.row(align=True)
|
||||||
grid_row.scale_y = 1.2
|
grid_row.scale_y = 1.15
|
||||||
for col_idx, anchor in enumerate(row):
|
for anchor in row:
|
||||||
pass
|
pass
|
||||||
if anchor == props.anchor_point:
|
premium_lock = create_premium_lock_function()
|
||||||
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,
|
op = safe_operator_call(
|
||||||
text="●", depress=True)
|
grid_row,
|
||||||
else:
|
"text_texture.set_anchor_point",
|
||||||
premium_lock = create_premium_lock_function()
|
premium_lock,
|
||||||
op = safe_operator_call(grid_row, "text_texture.set_anchor_point", premium_lock,
|
text="●" if is_active else "○",
|
||||||
text="○")
|
depress=is_active
|
||||||
|
)
|
||||||
if op:
|
if op:
|
||||||
op.anchor_point = anchor
|
op.anchor_point = anchor
|
||||||
|
|
||||||
# Margins
|
anchor_box.separator(factor=0.6)
|
||||||
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)
|
|
||||||
|
|
||||||
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")
|
margins_row.prop(props, "margin_x", text="X")
|
||||||
if not props.margins_linked:
|
y_col = margins_row.row(align=True)
|
||||||
pass
|
y_col.enabled = not props.margins_linked
|
||||||
margins_row.prop(props, "margin_y", text="Y")
|
y_col.prop(props, "margin_y", text="Y")
|
||||||
|
|
||||||
# Offsets
|
margin_box.separator(factor=0.6)
|
||||||
layout.separator()
|
|
||||||
layout.label(text="Fine-tune (pixels):")
|
# Pixel offsets
|
||||||
offset_row = layout.row(align=True)
|
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_x", text="X")
|
||||||
offset_row.prop(props, "offset_y", text="Y")
|
offset_row.prop(props, "offset_y", text="Y")
|
||||||
|
|
||||||
else: # MANUAL mode
|
else:
|
||||||
layout.separator()
|
pass
|
||||||
layout.prop(props, "manual_position_unit", text="Unit")
|
manual_box = content.box()
|
||||||
manual_row = layout.row(align=True)
|
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_x", text="X")
|
||||||
manual_row.prop(props, "manual_position_y", text="Y")
|
manual_row.prop(props, "manual_position_y", text="Y")
|
||||||
|
|
||||||
layout.separator()
|
content.separator(factor=0.6)
|
||||||
layout.prop(props, "constrain_to_canvas")
|
content.prop(props, "constrain_to_canvas")
|
||||||
|
|
||||||
def draw_font_settings_section(layout, props):
|
def draw_font_settings_section(layout, props):
|
||||||
pass
|
pass
|
||||||
@@ -537,7 +609,7 @@ def draw_presets_section(layout, props):
|
|||||||
persistent_count = total_presets - blend_count
|
persistent_count = total_presets - blend_count
|
||||||
|
|
||||||
summary_row = col.row(align=True)
|
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"{blend_count} project", icon='FILE_BLEND')
|
||||||
summary_row.label(text=f"{persistent_count} global", icon='FILE_CACHE')
|
summary_row.label(text=f"{persistent_count} global", icon='FILE_CACHE')
|
||||||
|
|
||||||
@@ -957,25 +1029,10 @@ class TEXT_TEXTURE_PT_panel(Panel):
|
|||||||
if shader_expanded:
|
if shader_expanded:
|
||||||
draw_shader_options_only(shader_expanded, props)
|
draw_shader_options_only(shader_expanded, props)
|
||||||
|
|
||||||
# Image Overlays Section (collapsed by default) - Show with premium indicator
|
# Composition Section (collapsed by default)
|
||||||
overlay_header_row = layout.row(align=True)
|
composition_section = draw_collapsible_header(layout, "expand_composition", "Composition", 'SEQ_PREVIEW')
|
||||||
|
if composition_section:
|
||||||
# Gray out header if locked and prevent expansion
|
draw_composition_section(composition_section, props)
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
# Stroke Section (collapsed by default) - Show with premium indicator
|
# Stroke Section (collapsed by default) - Show with premium indicator
|
||||||
@@ -1169,25 +1226,10 @@ class TEXT_TEXTURE_PT_panel_3d(Panel):
|
|||||||
if shader_expanded:
|
if shader_expanded:
|
||||||
draw_shader_options_only(shader_expanded, props)
|
draw_shader_options_only(shader_expanded, props)
|
||||||
|
|
||||||
# Image Overlays Section (collapsed by default) - Show with premium indicator
|
# Composition Section (collapsed by default)
|
||||||
overlay_header_row = layout.row(align=True)
|
composition_section = draw_collapsible_header(layout, "expand_composition", "Composition", 'SEQ_PREVIEW')
|
||||||
|
if composition_section:
|
||||||
# Gray out header if locked and prevent expansion
|
draw_composition_section(composition_section, props)
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
# Stroke Section (collapsed by default) - Show with premium indicator
|
# Stroke Section (collapsed by default) - Show with premium indicator
|
||||||
@@ -1361,25 +1403,10 @@ class TEXT_TEXTURE_PT_panel_properties(Panel):
|
|||||||
|
|
||||||
# === PREMIUM FEATURES ===
|
# === PREMIUM FEATURES ===
|
||||||
|
|
||||||
# Image Overlays Section (collapsed by default) - Show with premium indicator
|
# Composition Section (collapsed by default)
|
||||||
overlay_header_row = layout.row(align=True)
|
composition_section = draw_collapsible_header(layout, "expand_composition", "Composition", 'SEQ_PREVIEW')
|
||||||
|
if composition_section:
|
||||||
# Gray out header if locked and prevent expansion
|
draw_composition_section(composition_section, props)
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
# Stroke Section (collapsed by default) - Show with premium indicator
|
# Stroke Section (collapsed by default) - Show with premium indicator
|
||||||
|
|||||||
Binary file not shown.
116
src/utils/overlay_assets.py
Normal file
116
src/utils/overlay_assets.py
Normal 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)
|
||||||
Binary file not shown.
@@ -4,6 +4,7 @@ import pytest
|
|||||||
from unittest.mock import Mock, MagicMock, patch
|
from unittest.mock import Mock, MagicMock, patch
|
||||||
|
|
||||||
from src.core.generation_engine import generate_texture_image, generate_preview
|
from src.core.generation_engine import generate_texture_image, generate_preview
|
||||||
|
from src.utils.overlay_assets import RasterizationResult
|
||||||
# Test configuration constants
|
# Test configuration constants
|
||||||
DEFAULT_WIDTH = 512
|
DEFAULT_WIDTH = 512
|
||||||
DEFAULT_HEIGHT = 512
|
DEFAULT_HEIGHT = 512
|
||||||
@@ -507,6 +508,58 @@ class TestGenerateTextureImage:
|
|||||||
assert center_pixel[2] == pytest.approx(0.0, abs=1e-3)
|
assert center_pixel[2] == pytest.approx(0.0, abs=1e-3)
|
||||||
assert center_pixel[3] == pytest.approx(1.0, rel=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):
|
def test_skips_overlays_without_valid_path(self, tmp_path):
|
||||||
"""Overlays without a valid image path should be ignored."""
|
"""Overlays without a valid image path should be ignored."""
|
||||||
pytest.importorskip("PIL.Image")
|
pytest.importorskip("PIL.Image")
|
||||||
|
|||||||
193
tests/unit/ui/test_panel_layout_overhaul.py
Normal file
193
tests/unit/ui/test_panel_layout_overhaul.py
Normal 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)
|
||||||
Reference in New Issue
Block a user