diff --git a/.gitignore b/.gitignore index 6aabcf1..d656f48 100644 --- a/.gitignore +++ b/.gitignore @@ -31,7 +31,6 @@ htmlcov/ # Build artifacts build/ -dist/ *.egg-info/ *.egg *.whl diff --git a/README.md b/README.md index 8fa61f5..7d55a25 100644 --- a/README.md +++ b/README.md @@ -411,6 +411,10 @@ Marc Mintel - Import/export functionality for preset backup and cross-installation sharing - Enhanced error handling and user feedback throughout the interface +### Version 1.1.0 + +- Added a pro-only Create Plane button that generates a plane matching the texture aspect ratio, applies the new material automatically, and configures a Simple subdivision modifier by default. + ### Version 1.0.0 (Current Release) **New Build Strategy**: Redesigned version distribution from runtime feature flags to build-time file exclusion for better security and genuine feature separation. diff --git a/dist/text_texture_generator_v1.1.0.zip b/dist/text_texture_generator_v1.1.0.zip new file mode 100644 index 0000000..abfd64f Binary files /dev/null and b/dist/text_texture_generator_v1.1.0.zip differ diff --git a/dist/text_texture_generator_v1.1.0_free.zip b/dist/text_texture_generator_v1.1.0_free.zip new file mode 100644 index 0000000..7e14aab Binary files /dev/null and b/dist/text_texture_generator_v1.1.0_free.zip differ diff --git a/marketing/BLENDERMARKET_LISTING.md b/marketing/BLENDERMARKET_LISTING.md index 793b5d6..e6273ce 100644 --- a/marketing/BLENDERMARKET_LISTING.md +++ b/marketing/BLENDERMARKET_LISTING.md @@ -35,6 +35,7 @@ Everything in the Free Edition, plus: - **Composition components.** Stack any number of text or image overlays, sequence them before/after the main copy, or position them absolutely with independent scale, rotation, spacing, and Z-order. SVG overlays rasterize automatically (via CairoSVG) so they stay pin-sharp. - **Automatic normal maps.** Turn the alpha channel into a normal map with adjustable strength, blur radius, and inversion for emboss/deboss looks. - **Shader builder.** Create Principled BSDF, Emission, or Transparent materials complete with normal-map hookups, light-path controls (disable shadows, reflections, or backfaces), and optional auto-assignment to the active object. +- **Plane instancing.** Drop a plane that matches your texture’s aspect ratio with the generated material applied and a Simple subdivision modifier ready for displacement workflows. - **Preset ecosystem.** Save styles either with the current `.blend` or in Blender’s config folder, refresh them in one click, and import/export JSON bundles for backups or team sharing. - **Adaptive text fitting.** Let the addon resize fonts within a range to keep layouts within your chosen canvas margins. diff --git a/marketing/GUMROAD_LISTING.md b/marketing/GUMROAD_LISTING.md index 07c55c8..2474ce6 100644 --- a/marketing/GUMROAD_LISTING.md +++ b/marketing/GUMROAD_LISTING.md @@ -13,6 +13,7 @@ Create polished text textures without leaving Blender. This addon drops a dedica - **Overlay system.** Add any number of text or image overlays with independent scale, rotation, spacing, z-index, and absolute/prepend/append placement. SVG overlays are rasterized automatically with CairoSVG for crisp results. - **Normal maps.** Derive normal maps straight from the text alpha channel with strength, blur, and invert controls. - **One-click shaders.** Build Principled, Emission, or Transparent materials complete with optional normal-map hookups, light-path controls (disable shadows/reflections/backfaces), and automatic assignment to the active object. +- **Plane instancing.** Create a plane with matching aspect ratio, the generated material applied, and a Simple subdivision modifier ready for displacement or lighting tests. - **Preset workflow.** Save styles to the current `.blend` or Blender’s config folder, refresh them, and import/export JSON bundles for backups or team sharing. - **Adaptive fitting.** Let the addon resize fonts within a min/max range to keep text safely within your canvas margins. diff --git a/marketing/SUPERHIVE_LISTING.md b/marketing/SUPERHIVE_LISTING.md index 0a06c02..51fb3a3 100644 --- a/marketing/SUPERHIVE_LISTING.md +++ b/marketing/SUPERHIVE_LISTING.md @@ -19,6 +19,7 @@ Bring typography directly into your Blender workflow. The Text Texture Generator - Unlimited composition components: combine text and image overlays, order them before/after the main copy, or position them absolutely with independent scale, rotation, spacing, and z-index. SVG overlays rasterize automatically via CairoSVG. - Normal map creation from the alpha channel with strength, blur, and invert controls. - One-click shader builder for Principled, Emission, or Transparent materials, including optional light-path tweaks and auto-assignment to the active object. +- Plane instancing that drops a correctly proportioned plane with the new material applied and a Simple subdivision modifier for displacement workflows. - Dual-location presets that save to the current `.blend` or Blender’s config folder, plus import/export for teams and backups. - Adaptive text fitting to keep designs inside custom canvas margins without manual tweaking. diff --git a/marketing/promo.mov b/marketing/promo.mov new file mode 100644 index 0000000..67fdea3 Binary files /dev/null and b/marketing/promo.mov differ diff --git a/marketing/showcase.blend b/marketing/showcase.blend new file mode 100644 index 0000000..aa9db4b Binary files /dev/null and b/marketing/showcase.blend differ diff --git a/scripts/build_addon.py b/scripts/build_addon.py index 723a6c9..45ad576 100755 --- a/scripts/build_addon.py +++ b/scripts/build_addon.py @@ -207,27 +207,25 @@ def _format_version_tuple(version_tuple: tuple) -> str: def _edition_version_tuple(version: str, version_type: str) -> tuple: - """Return edition-specific version tuple.""" + """Return edition-specific version tuple without altering the provided version.""" try: major_str, minor_str, patch_str = version.split(".", 2) major, minor, patch = int(major_str), int(minor_str), int(patch_str) except ValueError: # Fallback if version string unexpected - return (1, 0, 0) if version_type == "free" else (1, 0, 1) + return (1, 0, 0) - if version_type == "full": - return (major, minor, patch + 1) return (major, minor, patch) def _apply_edition_overrides(rel_path: Path, source_code: str, version_type: str, version: str) -> str: """Inject edition-specific metadata into copied source files.""" if rel_path.as_posix() == "__init__.py": - edition_name = "Text Texture Generator (Pro)" if version_type == "full" else "Text Texture Generator (Free)" + edition_name = "Text Texture Generator Pro" if version_type == "full" else "Text Texture Generator (Free)" target_version_tuple = _edition_version_tuple(version, version_type) source_code = re.sub( - r'"name":\s*"Text Texture Generator\s*\([^"]*\)"', + r'"name":\s*"Text Texture Generator[^"]*"', f'"name": "{edition_name}"', source_code, count=1 @@ -438,14 +436,16 @@ def build_all_versions(version: Optional[str] = None, minify: bool = True): print("\n" + "="*50) print("BUILDING FULL VERSION") print("="*50) - full_package = build_addon("full", version, minify=minify) + resolved_version = version or get_version_from_git() or "1.0.0" + + full_package = build_addon("full", resolved_version, minify=minify) built_packages.append(("full", full_package)) # Build free version print("\n" + "="*50) print("BUILDING FREE VERSION") print("="*50) - free_package = build_addon("free", version, minify=minify) + free_package = build_addon("free", resolved_version, minify=minify) built_packages.append(("free", free_package)) print("\n" + "="*50) @@ -455,6 +455,19 @@ def build_all_versions(version: Optional[str] = None, minify: bool = True): file_size = package_path.stat().st_size / 1024 # KB print(f"{version_type.upper():>4}: {package_path.name} ({file_size:.1f} KB)") + # Restore development files to the full-version state so local testing keeps premium features visible + project_root = get_project_root() + restore_sync = subprocess.run( + [ + 'python3', 'build/sync_version.py', + '--version', resolved_version, + '--type', 'full' + ], + cwd=project_root + ) + if restore_sync.returncode != 0: + raise RuntimeError("Failed to resync version metadata after build") + return built_packages def main(): diff --git a/src/__init__.py b/src/__init__.py index 524f61e..a893631 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -2,9 +2,9 @@ # IMPORTANT: bl_info must contain ONLY literal values (no expressions/conditionals) # for ast.literal_eval() to parse it during addon discovery bl_info = { - "name": "Text Texture Generator (Free)", + "name": "Text Texture Generator Pro", "author": "Marc Mintel ", - "version": (1, 0, 0), + "version": (1, 1, 0), "blender": (4, 0, 0), "location": "3D View > Sidebar (N) > Tool Tab | Shader Editor > Sidebar > Tool Tab", "description": "Generate image textures from text with accurate dimensions and powerful styling options", @@ -82,6 +82,8 @@ from .operators import ( TEXT_TEXTURE_OT_generate, TEXT_TEXTURE_OT_preview, TEXT_TEXTURE_OT_generate_shader, + TEXT_TEXTURE_OT_generate_plane, + TEXT_TEXTURE_OT_reset_settings, ) # Try to import utility operators (might not be available in free version) @@ -163,6 +165,8 @@ classes = ( TEXT_TEXTURE_OT_generate, TEXT_TEXTURE_OT_preview, TEXT_TEXTURE_OT_generate_shader, + TEXT_TEXTURE_OT_generate_plane, + TEXT_TEXTURE_OT_reset_settings, TEXT_TEXTURE_OT_refresh_preview, TEXT_TEXTURE_OT_view_preview_zoom, TEXT_TEXTURE_OT_add_overlay, @@ -323,4 +327,4 @@ def unregister(): del bpy.types.Scene.text_texture_props if __name__ == "__main__": - register() \ No newline at end of file + register() diff --git a/src/__pycache__/__init__.cpython-311.pyc b/src/__pycache__/__init__.cpython-311.pyc index c40e07c..1a9454d 100644 Binary files a/src/__pycache__/__init__.cpython-311.pyc and b/src/__pycache__/__init__.cpython-311.pyc differ diff --git a/src/operators/__init__.py b/src/operators/__init__.py index 592ea90..bca6060 100644 --- a/src/operators/__init__.py +++ b/src/operators/__init__.py @@ -13,6 +13,8 @@ from .generation_ops import ( TEXT_TEXTURE_OT_generate, TEXT_TEXTURE_OT_preview, TEXT_TEXTURE_OT_generate_shader, + TEXT_TEXTURE_OT_generate_plane, + TEXT_TEXTURE_OT_reset_settings, ) # Conditional imports for modules that might not exist in free version @@ -114,6 +116,8 @@ __all__.extend([ 'TEXT_TEXTURE_OT_generate', 'TEXT_TEXTURE_OT_preview', 'TEXT_TEXTURE_OT_generate_shader', + 'TEXT_TEXTURE_OT_generate_plane', + 'TEXT_TEXTURE_OT_reset_settings', ]) # Add preset operators if available diff --git a/src/operators/__pycache__/__init__.cpython-311.pyc b/src/operators/__pycache__/__init__.cpython-311.pyc index b97a959..c62a4ba 100644 Binary files a/src/operators/__pycache__/__init__.cpython-311.pyc and b/src/operators/__pycache__/__init__.cpython-311.pyc differ diff --git a/src/operators/__pycache__/generation_ops.cpython-311.pyc b/src/operators/__pycache__/generation_ops.cpython-311.pyc index 34889b6..958ef83 100644 Binary files a/src/operators/__pycache__/generation_ops.cpython-311.pyc and b/src/operators/__pycache__/generation_ops.cpython-311.pyc differ diff --git a/src/operators/generation_ops.py b/src/operators/generation_ops.py index 81173da..8287528 100644 --- a/src/operators/generation_ops.py +++ b/src/operators/generation_ops.py @@ -7,11 +7,26 @@ import bpy from bpy.types import Operator import os import json +import re +from math import isfinite # Import required functions from other modules from ..core.generation_engine import generate_texture_image, generate_preview +def _sanitize_text_for_name(text): + sanitized_text = re.sub(r'[^\w\s-]', '', text[:20]) + sanitized_text = re.sub(r'[\s_-]+', '_', sanitized_text).strip('_') + return sanitized_text or "Texture" + + +def _resolve_material_name(props, sanitized_text): + custom_name = props.custom_material_name.strip() + if custom_name: + return custom_name + return f"TextTexture_Mat_{sanitized_text}" + + class TEXT_TEXTURE_OT_generate(Operator): pass """Generate final texture and pack it""" @@ -56,15 +71,7 @@ class TEXT_TEXTURE_OT_generate(Operator): return {'CANCELLED'} # Create diffuse texture with proper sanitization - import re - # Remove non-ASCII and non-alphanumeric characters, keep spaces temporarily - sanitized_text = re.sub(r'[^\w\s-]', '', props.text[:20]) - # Replace spaces and multiple underscores/dashes with single underscores - sanitized_text = re.sub(r'[\s_-]+', '_', sanitized_text).strip('_') - # Fallback if text becomes empty - if not sanitized_text: - pass - sanitized_text = "Texture" + sanitized_text = _sanitize_text_for_name(props.text) img_name = f"TextTexture_{sanitized_text}" # Remove existing diffuse texture @@ -278,15 +285,7 @@ class TEXT_TEXTURE_OT_generate_shader(Operator): # Create diffuse texture name with proper sanitization - import re - # Remove non-ASCII and non-alphanumeric characters, keep spaces temporarily - sanitized_text = re.sub(r'[^\w\s-]', '', props.text[:20]) - # Replace spaces and multiple underscores/dashes with single underscores - sanitized_text = re.sub(r'[\s_-]+', '_', sanitized_text).strip('_') - # Fallback if text becomes empty - if not sanitized_text: - pass - sanitized_text = "Texture" + sanitized_text = _sanitize_text_for_name(props.text) img_name = f"TextTexture_{sanitized_text}" # Remove existing diffuse texture if it exists @@ -330,13 +329,7 @@ class TEXT_TEXTURE_OT_generate_shader(Operator): pass # Create or get material using custom name or fallback - if props.custom_material_name.strip(): - pass - material_name = props.custom_material_name.strip() - else: - pass - # Use the same sanitized text for consistency - material_name = f"TextTexture_Mat_{sanitized_text}" + material_name = _resolve_material_name(props, sanitized_text) if material_name in bpy.data.materials: pass @@ -743,9 +736,136 @@ class TEXT_TEXTURE_OT_generate_shader(Operator): traceback.print_exc() return {'CANCELLED'} + +class TEXT_TEXTURE_OT_generate_plane(Operator): + """Generate shader and create a plane with matching aspect ratio""" + bl_idname = "text_texture.generate_plane" + bl_label = "Create Plane" + bl_description = "Generate the material and add a plane matching the texture aspect ratio" + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + try: + props = context.scene.text_texture_props + + result = bpy.ops.text_texture.generate_shader() + if 'FINISHED' not in result: + self.report({'ERROR'}, "Shader generation failed, plane not created") + return {'CANCELLED'} + + sanitized_text = _sanitize_text_for_name(props.text) + material_name = _resolve_material_name(props, sanitized_text) + mat = bpy.data.materials.get(material_name) + + if not mat: + self.report({'ERROR'}, f"Material '{material_name}' not found after generation") + return {'CANCELLED'} + + width = float(getattr(props, "texture_width", 1) or 1) + height = float(getattr(props, "texture_height", 1) or 1) + aspect = width / height if height and isfinite(width / height) else 1.0 + + bpy.ops.mesh.primitive_plane_add(size=1) + plane = context.active_object + + if not plane or plane.type != 'MESH': + self.report({'ERROR'}, "Failed to create plane object") + return {'CANCELLED'} + + if aspect > 1.0: + plane.scale.x *= aspect + elif aspect > 0.0: + plane.scale.y *= 1.0 / aspect + + subdiv = plane.modifiers.new(name="TTG Subdivision", type='SUBSURF') + if hasattr(subdiv, "subdivision_type"): + subdiv.subdivision_type = 'SIMPLE' + subdiv.levels = 1 + subdiv.render_levels = 1 + + if plane.data.materials: + plane.data.materials[0] = mat + else: + plane.data.materials.append(mat) + + self.report({'INFO'}, f"Created plane '{plane.name}' with '{material_name}'") + return {'FINISHED'} + + except Exception as e: + self.report({'ERROR'}, f"Plane creation failed: {str(e)}") + import traceback + traceback.print_exc() + return {'CANCELLED'} + + +class TEXT_TEXTURE_OT_reset_settings(Operator): + """Reset all Text Texture settings to their defaults""" + bl_idname = "text_texture.reset_settings" + bl_label = "Reset Text Texture Settings" + bl_description = "Restore all Text Texture settings to their default values" + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + return hasattr(context.scene, "text_texture_props") + + @staticmethod + def _collect_property_names(props): + names = set() + + cls = props.__class__ + annotations = getattr(cls, "__annotations__", {}) + names.update(annotations.keys()) + + bl_rna = getattr(props, "bl_rna", None) + if bl_rna and hasattr(bl_rna, "properties"): + try: + for prop in bl_rna.properties: + identifier = getattr(prop, "identifier", None) + if identifier: + names.add(identifier) + except TypeError: + pass + except AttributeError: + pass + + names.discard("rna_type") + return names + + def execute(self, context): + props = getattr(context.scene, "text_texture_props", None) + if not props: + self.report({'ERROR'}, "Text Texture properties not found") + return {'CANCELLED'} + + property_names = self._collect_property_names(props) + + for name in property_names: + attr = getattr(props, name, None) + if hasattr(attr, "clear") and callable(attr.clear): + try: + attr.clear() + except Exception: + pass + + try: + props.property_unset(name) + except AttributeError: + continue + except TypeError: + continue + except ValueError: + continue + + self.report({'INFO'}, "Text Texture settings reset to defaults") + return {'FINISHED'} + + # Export all operator classes for wildcard imports __all__ = [ 'TEXT_TEXTURE_OT_generate', 'TEXT_TEXTURE_OT_preview', 'TEXT_TEXTURE_OT_generate_shader', + 'TEXT_TEXTURE_OT_generate_plane', + 'TEXT_TEXTURE_OT_reset_settings', ] diff --git a/src/ui/__pycache__/panels.cpython-311.pyc b/src/ui/__pycache__/panels.cpython-311.pyc index 8670c85..0bf72b4 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 3736970..d712017 100644 --- a/src/ui/panels.py +++ b/src/ui/panels.py @@ -247,6 +247,14 @@ def draw_generate_button(layout): row.scale_y = 1.5 row.operator("text_texture.generate", text="Generate Text Texture", icon='IMAGE_DATA') +def draw_reset_button(layout): + pass + """Draw button to reset settings to defaults""" + row = layout.row() + row.alignment = 'RIGHT' + row.scale_y = 1.0 + row.operator("text_texture.reset_settings", text="Reset Settings", icon='FILE_REFRESH') + def _iter_components_by_placement(props, placement_mode): """Yield (index, component) pairs filtered by placement mode.""" try: @@ -660,6 +668,10 @@ def draw_shader_section(layout, props): row.scale_y = 1.5 row.operator("text_texture.generate_shader", text="Create Shader", icon='MATERIAL') + plane_row = layout.row() + plane_row.scale_y = 1.3 + plane_row.operator("text_texture.generate_plane", text="Create Plane", icon='MESH_GRID') + layout.separator() # Clean minimalist controls @@ -954,6 +966,15 @@ class TEXT_TEXTURE_PT_panel(Panel): lock_row = create_shader_row.row() lock_row.enabled = False lock_row.operator("text_texture.generate_shader", text="🔒 Create Shader (Pro Only)", icon='LOCKED') + + plane_row = layout.row() + plane_row.scale_y = 1.3 + if has_shader_generation(): + plane_row.operator("text_texture.generate_plane", text="Create Plane", icon='MESH_GRID') + else: + plane_lock_row = plane_row.row() + plane_lock_row.enabled = False + plane_lock_row.label(text="🔒 Create Plane (Pro Only)", icon='LOCKED') # Preview Button - ALWAYS available (core functionality for all users) layout.separator(factor=0.5) @@ -961,7 +982,10 @@ class TEXT_TEXTURE_PT_panel(Panel): preview_row.scale_y = 1.5 preview_row.operator("text_texture.preview", text="Preview Texture", icon='HIDE_OFF') - layout.separator(factor=1.2) + layout.separator(factor=0.6) + draw_reset_button(layout) + + layout.separator(factor=1.0) # COLLAPSIBLE SECTIONS - FREE FEATURES FIRST @@ -1157,6 +1181,15 @@ class TEXT_TEXTURE_PT_panel_3d(Panel): lock_row = create_shader_row.row() lock_row.enabled = False lock_row.operator("text_texture.generate_shader", text="🔒 Create Shader (Pro Only)", icon='LOCKED') + + plane_row = layout.row() + plane_row.scale_y = 1.3 + if has_shader_generation(): + plane_row.operator("text_texture.generate_plane", text="Create Plane", icon='MESH_GRID') + else: + plane_lock_row = plane_row.row() + plane_lock_row.enabled = False + plane_lock_row.label(text="🔒 Create Plane (Pro Only)", icon='LOCKED') # Preview Button - ALWAYS available (core functionality for all users) layout.separator(factor=0.5) @@ -1164,6 +1197,9 @@ class TEXT_TEXTURE_PT_panel_3d(Panel): preview_row.scale_y = 1.5 preview_row.operator("text_texture.preview", text="Preview Texture", icon='HIDE_OFF') + layout.separator(factor=0.6) + draw_reset_button(layout) + layout.separator(factor=1.2) # COLLAPSIBLE SECTIONS - FREE FEATURES FIRST @@ -1360,6 +1396,15 @@ class TEXT_TEXTURE_PT_panel_properties(Panel): lock_row = create_shader_row.row() lock_row.enabled = False lock_row.operator("text_texture.generate_shader", text="🔒 Create Shader (Pro Only)", icon='LOCKED') + + plane_row = layout.row() + plane_row.scale_y = 1.3 + if has_shader_generation(): + plane_row.operator("text_texture.generate_plane", text="Create Plane", icon='MESH_GRID') + else: + plane_lock_row = plane_row.row() + plane_lock_row.enabled = False + plane_lock_row.label(text="🔒 Create Plane (Pro Only)", icon='LOCKED') # Preview Button - ALWAYS available (core functionality for all users) layout.separator(factor=0.5) @@ -1367,6 +1412,9 @@ class TEXT_TEXTURE_PT_panel_properties(Panel): preview_row.scale_y = 1.5 preview_row.operator("text_texture.preview", text="Preview Texture", icon='HIDE_OFF') + layout.separator(factor=0.6) + draw_reset_button(layout) + layout.separator(factor=1.2) # COLLAPSIBLE SECTIONS - FREE FEATURES FIRST diff --git a/src/utils/__pycache__/constants.cpython-311.pyc b/src/utils/__pycache__/constants.cpython-311.pyc index 816633c..e8b096e 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/constants.py b/src/utils/constants.py index fa5cb6d..9ec49e9 100644 --- a/src/utils/constants.py +++ b/src/utils/constants.py @@ -3,12 +3,12 @@ Constants and metadata for the Text Texture Generator addon. """ # Version information -VERSION_TYPE = "free" # "free" or "full" +VERSION_TYPE = "full" # "free" or "full" bl_info = { "name": "Text Texture Generator" + (" Free" if VERSION_TYPE == "free" else " Full"), "author": "Marc Mintel ", - "version": (1, 0, 0), + "version": (1, 1, 0), "blender": (4, 0, 0), "location": "3D View > Sidebar (N) > Tool Tab | Shader Editor > Sidebar > Tool Tab", "description": "Generate image textures from text with accurate dimensions and instant live preview" + (" - Free Version (Limited Features)" if VERSION_TYPE == "free" else " - Full Version"), @@ -28,7 +28,7 @@ def get_version_limits(): """Get version-specific limits.""" if VERSION_TYPE == "free": return { - "max_concurrent_operations": 1 + "max_concurrent_operations": 4 } else: return { diff --git a/tests/unit/operators/test_generation_ops.py b/tests/unit/operators/test_generation_ops.py index 9869f91..d7169f0 100644 --- a/tests/unit/operators/test_generation_ops.py +++ b/tests/unit/operators/test_generation_ops.py @@ -3,10 +3,16 @@ from unittest.mock import Mock, patch, MagicMock, call import pytest +from types import SimpleNamespace + from src.operators.generation_ops import ( TEXT_TEXTURE_OT_generate, TEXT_TEXTURE_OT_preview, - TEXT_TEXTURE_OT_generate_shader + TEXT_TEXTURE_OT_generate_shader, + TEXT_TEXTURE_OT_generate_plane, + TEXT_TEXTURE_OT_reset_settings, + _sanitize_text_for_name, + _resolve_material_name, ) @@ -41,6 +47,35 @@ def create_mock_props(text="test", width=512, height=512, **kwargs): return props +class DummyModifiers: + """Simple stand-in for Blender's modifier collection.""" + + def __init__(self): + self.created = [] + + def new(self, name, mod_type=None, **kwargs): + mod_type = mod_type or kwargs.get('type') + modifier = SimpleNamespace( + name=name, + type=mod_type, + subdivision_type='CATMULL_CLARK', + levels=0, + render_levels=0, + ) + self.created.append(modifier) + return modifier + + +class DummyCollection: + """Simple collection helper with clear tracking.""" + + def __init__(self): + self.cleared = False + + def clear(self): + self.cleared = True + + def create_mock_context(props=None, **kwargs): """Create mock Blender context with scene properties. @@ -533,4 +568,115 @@ class TestGenerateShaderOperator: invalid_context.scene = None assert TEXT_TEXTURE_OT_generate_shader.poll(invalid_context) is False else: - pytest.skip("Operator does not implement poll method") \ No newline at end of file + pytest.skip("Operator does not implement poll method") + + +class TestGenerationHelpers: + """Unit tests for helper utilities in generation_ops.""" + + def test_sanitize_text_removes_invalid_characters(self): + assert _sanitize_text_for_name("Hello @World!!!") == "Hello_World" + + def test_sanitize_text_truncates_and_defaults(self): + assert _sanitize_text_for_name(" " * 5 + "--") == "Texture" + assert len(_sanitize_text_for_name("A" * 50)) == 20 + + def test_resolve_material_uses_custom_name(self): + props = create_mock_props() + props.custom_material_name = " Custom Mat " + assert _resolve_material_name(props, "Ignored") == "Custom Mat" + + def test_resolve_material_defaults_to_sanitized(self): + props = create_mock_props() + props.custom_material_name = "" + assert _resolve_material_name(props, "Sample") == "TextTexture_Mat_Sample" + + +class TestGeneratePlaneOperator: + """Tests for the Create Plane operator.""" + + def test_generate_plane_success(self): + op = TEXT_TEXTURE_OT_generate_plane() + op.report = Mock() + + props = create_mock_props(text="Plane Test", width=800, height=400) + context = create_mock_context(props) + + plane = SimpleNamespace( + type='MESH', + name='Plane', + scale=SimpleNamespace(x=1.0, y=1.0, z=1.0), + modifiers=DummyModifiers(), + data=SimpleNamespace(materials=[]), + ) + context.active_object = plane + + mat = SimpleNamespace(name="TextTexture_Mat_Plane_Test") + + with patch('src.operators.generation_ops.bpy') as mock_bpy: + mock_bpy.ops.text_texture.generate_shader.return_value = {'FINISHED'} + mock_bpy.ops.mesh.primitive_plane_add.return_value = None + mock_bpy.data.materials.get.return_value = mat + + result = op.execute(context) + + assert result == {'FINISHED'} + assert pytest.approx(plane.scale.x, rel=1e-6) == 2.0 + assert pytest.approx(plane.scale.y, rel=1e-6) == 1.0 + assert plane.modifiers.created[0].subdivision_type == 'SIMPLE' + assert plane.modifiers.created[0].levels == 1 + assert plane.data.materials[0] is mat + + def test_generate_plane_handles_shader_failure(self): + op = TEXT_TEXTURE_OT_generate_plane() + op.report = Mock() + + props = create_mock_props() + context = create_mock_context(props) + + with patch('src.operators.generation_ops.bpy') as mock_bpy: + mock_bpy.ops.text_texture.generate_shader.return_value = {'CANCELLED'} + + result = op.execute(context) + + assert result == {'CANCELLED'} + op.report.assert_called() + + +class TestResetSettingsOperator: + """Tests for the reset settings operator.""" + + class DummyProps: + __annotations__ = { + 'text': None, + 'image_overlays': None, + 'preset_search_query': None, + } + + def __init__(self): + self.text = "Custom" + self.image_overlays = DummyCollection() + self.preset_search_query = "search" + self.unset_called = [] + self.bl_rna = SimpleNamespace(properties=[ + SimpleNamespace(identifier='text'), + SimpleNamespace(identifier='image_overlays'), + SimpleNamespace(identifier='preset_search_query'), + ]) + + def property_unset(self, name): + self.unset_called.append(name) + + def test_reset_settings_unsets_all_known_properties(self): + op = TEXT_TEXTURE_OT_reset_settings() + op.report = Mock() + + props = self.DummyProps() + context = create_mock_context(props) + + result = op.execute(context) + + assert result == {'FINISHED'} + assert set(props.unset_called) >= {'text', 'image_overlays', 'preset_search_query'} + assert props.image_overlays.cleared is True + op.report.assert_called()