diff --git a/dist/text_texture_generator_v1.0.0.zip b/dist/text_texture_generator_v1.0.0.zip index fe99ed3..b9ae28e 100644 Binary files a/dist/text_texture_generator_v1.0.0.zip and b/dist/text_texture_generator_v1.0.0.zip differ diff --git a/dist/text_texture_generator_v1.0.0_free.zip b/dist/text_texture_generator_v1.0.0_free.zip index 6684b88..697a519 100644 Binary files a/dist/text_texture_generator_v1.0.0_free.zip and b/dist/text_texture_generator_v1.0.0_free.zip differ diff --git a/src/__pycache__/__init__.cpython-311.pyc b/src/__pycache__/__init__.cpython-311.pyc index 88a8c92..ba30c5d 100644 Binary files a/src/__pycache__/__init__.cpython-311.pyc and b/src/__pycache__/__init__.cpython-311.pyc differ diff --git a/src/core/__pycache__/__init__.cpython-311.pyc b/src/core/__pycache__/__init__.cpython-311.pyc index d5188a4..b46f538 100644 Binary files a/src/core/__pycache__/__init__.cpython-311.pyc and b/src/core/__pycache__/__init__.cpython-311.pyc differ diff --git a/src/core/__pycache__/generation_engine.cpython-311.pyc b/src/core/__pycache__/generation_engine.cpython-311.pyc index 83fd243..010d8e5 100644 Binary files a/src/core/__pycache__/generation_engine.cpython-311.pyc and b/src/core/__pycache__/generation_engine.cpython-311.pyc differ diff --git a/src/core/__pycache__/text_processor.cpython-311.pyc b/src/core/__pycache__/text_processor.cpython-311.pyc index ebb1528..33f4cfc 100644 Binary files a/src/core/__pycache__/text_processor.cpython-311.pyc and b/src/core/__pycache__/text_processor.cpython-311.pyc differ diff --git a/src/core/generation_engine.py b/src/core/generation_engine.py index 12732e3..4122803 100644 --- a/src/core/generation_engine.py +++ b/src/core/generation_engine.py @@ -39,6 +39,92 @@ def generate_texture_image(props, width, height): pass from PIL import Image, ImageDraw, ImageFont + # Determine resampling filters compatible with current Pillow version + resampling_module = getattr(Image, 'Resampling', None) + lanczos_filter = getattr(resampling_module, 'LANCZOS', getattr(Image, 'LANCZOS', Image.BICUBIC)) + bicubic_filter = getattr(resampling_module, 'BICUBIC', getattr(Image, 'BICUBIC', Image.BILINEAR)) + + def _iter_enabled_overlays(): + """Yield (overlay, image_path) tuples for overlays that should be applied.""" + overlays = getattr(props, 'image_overlays', []) + try: + overlay_list = list(overlays) + except TypeError: + overlay_list = [] + for overlay in overlay_list: + if not getattr(overlay, 'enabled', True): + continue + image_path = getattr(overlay, 'image_path', '') + if not image_path: + continue + yield overlay, image_path + + def _calculate_overlay_position(overlay, overlay_size): + """Calculate top-left pixel position for an overlay image.""" + overlay_width, overlay_height = overlay_size + canvas_width = width + canvas_height = height + mode = getattr(overlay, 'positioning_mode', 'ABSOLUTE') or 'ABSOLUTE' + + x_norm = float(getattr(overlay, 'x_position', 0.5) or 0.5) + y_norm = float(getattr(overlay, 'y_position', 0.5) or 0.5) + center_x = x_norm * canvas_width + center_y = y_norm * canvas_height + + spacing_ratio = max(0.0, float(getattr(overlay, 'text_spacing', 0.0) or 0.0)) / 100.0 + + if mode == 'APPEND': + center_x = (canvas_width * 0.5) + (spacing_ratio * canvas_width) + (overlay_width / 2.0) + elif mode == 'PREPEND': + center_x = (canvas_width * 0.5) - (spacing_ratio * canvas_width) - (overlay_width / 2.0) + + x = int(round(center_x - overlay_width / 2.0)) + y = int(round(center_y - overlay_height / 2.0)) + + max_x = max(0, canvas_width - overlay_width) + max_y = max(0, canvas_height - overlay_height) + x = max(0, min(max_x, x)) + y = max(0, min(max_y, y)) + return x, y + + def _apply_image_overlays(pil_canvas): + """Composite enabled image overlays onto the base canvas.""" + overlays_to_apply = sorted( + _iter_enabled_overlays(), + key=lambda item: getattr(item[0], 'z_index', 0) + ) + + for overlay, image_path in overlays_to_apply: + 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}") + continue + + scale = float(getattr(overlay, 'scale', 1.0) or 1.0) + if scale <= 0: + continue + if scale != 1.0: + new_size = ( + max(1, int(round(overlay_img.width * scale))), + max(1, int(round(overlay_img.height * scale))) + ) + if new_size != overlay_img.size: + overlay_img = overlay_img.resize(new_size, lanczos_filter) + + rotation = float(getattr(overlay, 'rotation', 0.0) or 0.0) + if rotation % 360: + overlay_img = overlay_img.rotate(-rotation, expand=True, resample=bicubic_filter) + + x_pos, y_pos = _calculate_overlay_position(overlay, overlay_img.size) + + overlay_layer = Image.new('RGBA', (width, height), (0, 0, 0, 0)) + overlay_layer.paste(overlay_img, (x_pos, y_pos), overlay_img) + pil_canvas = Image.alpha_composite(pil_canvas, overlay_layer) + + return pil_canvas + # Create PIL image with transparent background if props.background_transparent: pass @@ -242,6 +328,9 @@ def generate_texture_image(props, width, height): draw.text((x, y), full_text, font=font, fill=text_color) else: pass + + # Apply any configured image overlays before converting to Blender pixels + pil_img = _apply_image_overlays(pil_img) # Convert PIL image to Blender format (flip vertically to fix mirroring) pil_img = pil_img.transpose(Image.FLIP_TOP_BOTTOM) # Fix mirroring issue diff --git a/src/operators/__pycache__/preset_ops.cpython-311.pyc b/src/operators/__pycache__/preset_ops.cpython-311.pyc index a23c54d..6df10d0 100644 Binary files a/src/operators/__pycache__/preset_ops.cpython-311.pyc and b/src/operators/__pycache__/preset_ops.cpython-311.pyc differ diff --git a/src/properties/__pycache__/property_groups.cpython-311.pyc b/src/properties/__pycache__/property_groups.cpython-311.pyc index 7d9d7c9..9419f73 100644 Binary files a/src/properties/__pycache__/property_groups.cpython-311.pyc and b/src/properties/__pycache__/property_groups.cpython-311.pyc differ diff --git a/src/ui/__pycache__/panels.cpython-311.pyc b/src/ui/__pycache__/panels.cpython-311.pyc index 3dee696..0826de7 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/utils/__pycache__/constants.cpython-311.pyc b/src/utils/__pycache__/constants.cpython-311.pyc index ca98627..4396ae6 100644 Binary files a/src/utils/__pycache__/constants.cpython-311.pyc and b/src/utils/__pycache__/constants.cpython-311.pyc differ diff --git a/tests/__pycache__/conftest.cpython-311-pytest-8.3.5.pyc b/tests/__pycache__/conftest.cpython-311-pytest-8.3.5.pyc index 722c00c..27118b6 100644 Binary files a/tests/__pycache__/conftest.cpython-311-pytest-8.3.5.pyc and b/tests/__pycache__/conftest.cpython-311-pytest-8.3.5.pyc differ diff --git a/tests/unit/core/__pycache__/test_generation_engine.cpython-311-pytest-8.3.5.pyc b/tests/unit/core/__pycache__/test_generation_engine.cpython-311-pytest-8.3.5.pyc index 9d7f549..9d9ba3f 100644 Binary files a/tests/unit/core/__pycache__/test_generation_engine.cpython-311-pytest-8.3.5.pyc and b/tests/unit/core/__pycache__/test_generation_engine.cpython-311-pytest-8.3.5.pyc differ diff --git a/tests/unit/core/test_generation_engine.py b/tests/unit/core/test_generation_engine.py index f659467..501beab 100644 --- a/tests/unit/core/test_generation_engine.py +++ b/tests/unit/core/test_generation_engine.py @@ -94,6 +94,7 @@ def create_mock_props(**kwargs): props = MagicMock() # Set default values + props.image_overlays = kwargs.get('image_overlays', []) props.text = kwargs.get('text', '') props.prepend_text = kwargs.get('prepend_text', '') props.append_text = kwargs.get('append_text', '') @@ -417,12 +418,204 @@ class TestGenerateTextureImage: # Call function result, normal_map = generate_texture_image(props, 256, 512) - - # Assertions - assert result is not None - # Verify multiple draw.text calls for vertical layout - assert mock_draw.text.call_count >= 3 # One per text part - + + def test_applies_absolute_overlay_to_texture(self, tmp_path): + """Image overlays should be composited onto the generated texture.""" + Image = pytest.importorskip("PIL.Image") + with patch('src.core.generation_engine.bpy') as mock_bpy: + width = height = 64 + overlay_path = tmp_path / "overlay.png" + # Create a solid red overlay image + overlay_source = Image.new("RGBA", (16, 16), (255, 0, 0, 255)) + overlay_source.save(overlay_path) + + mock_blender_image = MagicMock() + mock_blender_image.pixels = [0.0] * (width * height * 4) + mock_bpy.data.images.new.return_value = mock_blender_image + mock_bpy.data.images.__contains__.return_value = False + + props = create_mock_props( + text="", + background_transparent=False, + background_color=(1.0, 1.0, 1.0, 1.0) + ) + + overlay = MagicMock() + overlay.enabled = True + overlay.image_path = str(overlay_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 + + center_index = ((height // 2) * width + (width // 2)) * 4 + center_pixel = mock_blender_image.pixels[center_index:center_index + 4] + # Center pixel should now be red from the overlay + assert center_pixel[0] == pytest.approx(1.0, rel=1e-3) + assert center_pixel[1] == 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) + + def test_skips_overlays_without_valid_path(self, tmp_path): + """Overlays without a valid image path should be ignored.""" + pytest.importorskip("PIL.Image") + with patch('PIL.Image.open') as mock_image_open, \ + patch('src.core.generation_engine.bpy') as mock_bpy: + width = height = 32 + + 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=True + ) + + overlay = MagicMock() + overlay.enabled = True + overlay.image_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_image_open.assert_not_called() + + def test_append_overlay_positions_to_right(self, tmp_path): + """Append overlays should be positioned to the right side with spacing applied.""" + Image = pytest.importorskip("PIL.Image") + with patch('src.core.generation_engine.bpy') as mock_bpy: + width = height = 64 + overlay_path = tmp_path / "append.png" + overlay_source = Image.new("RGBA", (10, 10), (0, 255, 0, 255)) + overlay_source.save(overlay_path) + + mock_blender_image = MagicMock() + mock_blender_image.pixels = [0.0] * (width * height * 4) + mock_bpy.data.images.new.return_value = mock_blender_image + mock_bpy.data.images.__contains__.return_value = False + + props = create_mock_props( + text="", + background_transparent=False, + background_color=(1.0, 1.0, 1.0, 1.0) + ) + + overlay = MagicMock() + overlay.enabled = True + overlay.image_path = str(overlay_path) + overlay.positioning_mode = 'APPEND' + overlay.text_spacing = 10.0 # push further right + overlay.image_spacing = 0.0 + overlay.scale = 1.0 + overlay.rotation = 0.0 + overlay.z_index = 0 + overlay.x_position = 0.5 + overlay.y_position = 0.5 + props.image_overlays = [overlay] + + result, normal_map = generate_texture_image(props, width, height) + + assert result is mock_blender_image + assert normal_map is None + + spacing_ratio = overlay.text_spacing / 100.0 + center_x = (width * 0.5) + (spacing_ratio * width) + (overlay_source.width / 2.0) + expected_x = int(round(center_x - overlay_source.width / 2.0)) + expected_y = int(round((height * overlay.y_position) - (overlay_source.height / 2.0))) + expected_y = max(0, min(height - overlay_source.height, expected_y)) + + sample_row = expected_y + overlay_source.height // 2 + green_columns = [] + for x in range(width): + idx = (sample_row * width + x) * 4 + pixel = mock_blender_image.pixels[idx:idx + 4] + if pixel and pixel[1] >= 0.95 and pixel[0] <= 0.1 and pixel[2] <= 0.1: + green_columns.append(x) + + assert green_columns, "Append overlay pixels not found" + assert max(green_columns) >= expected_x + overlay_source.width - 2 + assert min(green_columns) >= expected_x - 1 + assert max(green_columns) > width // 2 # should be on right half + + def test_prepend_overlay_positions_to_left(self, tmp_path): + """Prepend overlays should be positioned to the left side with spacing applied.""" + Image = pytest.importorskip("PIL.Image") + with patch('src.core.generation_engine.bpy') as mock_bpy: + width = height = 64 + overlay_path = tmp_path / "prepend.png" + overlay_source = Image.new("RGBA", (10, 10), (0, 0, 255, 255)) + overlay_source.save(overlay_path) + + mock_blender_image = MagicMock() + mock_blender_image.pixels = [0.0] * (width * height * 4) + mock_bpy.data.images.new.return_value = mock_blender_image + mock_bpy.data.images.__contains__.return_value = False + + props = create_mock_props( + text="", + background_transparent=False, + background_color=(1.0, 1.0, 1.0, 1.0) + ) + + overlay = MagicMock() + overlay.enabled = True + overlay.image_path = str(overlay_path) + overlay.positioning_mode = 'PREPEND' + overlay.text_spacing = 10.0 # push further left + overlay.image_spacing = 0.0 + overlay.scale = 1.0 + overlay.rotation = 0.0 + overlay.z_index = 0 + overlay.x_position = 0.5 + overlay.y_position = 0.5 + props.image_overlays = [overlay] + + result, normal_map = generate_texture_image(props, width, height) + + assert result is mock_blender_image + assert normal_map is None + + spacing_ratio = overlay.text_spacing / 100.0 + center_x = (width * 0.5) - (spacing_ratio * width) - (overlay_source.width / 2.0) + expected_x = int(round(center_x - overlay_source.width / 2.0)) + expected_y = int(round((height * overlay.y_position) - (overlay_source.height / 2.0))) + expected_y = max(0, min(height - overlay_source.height, expected_y)) + + sample_row = expected_y + overlay_source.height // 2 + blue_columns = [] + for x in range(width): + idx = (sample_row * width + x) * 4 + pixel = mock_blender_image.pixels[idx:idx + 4] + if pixel and pixel[2] >= 0.95 and pixel[0] <= 0.1 and pixel[1] <= 0.1: + blue_columns.append(x) + + assert blue_columns, "Prepend overlay pixels not found" + assert min(blue_columns) <= expected_x + 1 + assert max(blue_columns) <= expected_x + overlay_source.width + assert max(blue_columns) < width // 2 # should remain on left half + def test_text_fitting_enabled(self): """Test text fitting integration.""" with patch('PIL.Image') as mock_pil_image_module, \ @@ -696,4 +889,4 @@ class TestGeneratePreview: result = generate_preview(props, preview_width=128, preview_height=128) # Should handle error gracefully - assert result is None \ No newline at end of file + assert result is None