- Introduced a new `Boundary Padding` preference in the Cutout Tool with a default of 2 px and a configurable range of 1-4 px. - Updated the Cutout client to establish inward mapping from the final Mask, sampling in a fixed direction and processing independent material RGBA and Depth. - Enhanced the handling of RGBA and Depth during cutout operations, ensuring proper mapping and preservation of source data. - Added tests to validate the new boundary padding functionality and its integration with existing cutout processes. feat(geometry): remove overall mesh smoothing from geometry nodes - Removed the `Smooth` input from several geometry node groups to eliminate unnecessary overall mesh smoothing. - Retained `Smooth Weight` for depth nodes while removing it from Cutout and Relief Plane nodes. - Ensured that boundary, normal, and fill smoothing behaviors remain intact and consistent. - Updated tests and documentation to reflect the changes in node interfaces and behaviors. feat(normals): scale tangent normals with depth - Reused the existing `o_normal_reduction` attribute to adjust Tangent Normal strength based on the geometry's `Depth Scale`. - Ensured that Tangent Normal strength is consistent with the geometry depth, preserving full strength for materials that do not provide the attribute. - Updated tests to verify the correct behavior of normal strength adjustments in both Tangent and Object Space.
190 lines
8.9 KiB
Python
190 lines
8.9 KiB
Python
"""Verify conversion ownership, source recovery, and persistent color data."""
|
|
|
|
from types import SimpleNamespace
|
|
|
|
import bpy
|
|
import numpy as np
|
|
import pytest
|
|
from PIL import Image
|
|
|
|
from anyimage.common import image as images, material
|
|
from anyimage.common.color_image import prepare_material_color_input, cleanup_material_color_input
|
|
from anyimage.operators.convert_to_plane import operators, object as conversion, image_plane
|
|
|
|
|
|
@pytest.fixture
|
|
def source():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
image = bpy.data.images.new("Source.png", width=2, height=1, alpha=True)
|
|
image.pixels.foreach_set([0.2, 0.4, 0.8, 0.25, 0.6, 0.3, 0.1, 0])
|
|
obj = bpy.data.objects.new("Source", None)
|
|
obj.empty_display_type = "IMAGE"
|
|
obj.data = image
|
|
bpy.context.collection.objects.link(obj)
|
|
obj.select_set(True)
|
|
bpy.context.view_layer.objects.active = obj
|
|
yield obj
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
|
|
|
|
def share_image(source, sharing):
|
|
if sharing in {"visible", "hidden", "other_scene", "same_path"}:
|
|
other = source.copy()
|
|
if sharing == "same_path":
|
|
source.data.filepath_raw = "//source.png"
|
|
other.data = source.data.copy()
|
|
collection = bpy.context.collection
|
|
if sharing == "other_scene":
|
|
collection = bpy.data.scenes.new("Other scene").collection
|
|
collection.objects.link(other)
|
|
other.hide_viewport = sharing == "hidden"
|
|
elif sharing == "material":
|
|
existing = bpy.data.materials.new("Existing")
|
|
existing.use_nodes = True
|
|
existing.node_tree.nodes.new("ShaderNodeTexImage").image = source.data
|
|
|
|
|
|
def run_conversion(source, entry, monkeypatch):
|
|
if entry == "PLANE":
|
|
operators.ConvertToPlane.execute(SimpleNamespace(mesh_detail=0), bpy.context)
|
|
else:
|
|
metadata = {"image_size": (2, 1), "reference_depth": 1,
|
|
"intrinsics": ((2, 0, 1), (0, 2, 0.5), (0, 0, 1))}
|
|
monkeypatch.setattr(conversion, "load_depth_metadata", lambda _: metadata)
|
|
def load_depth(*_):
|
|
image = bpy.data.images.new("Depth", width=2, height=1, float_buffer=True)
|
|
image.colorspace_settings.name = "Non-Color"
|
|
return image
|
|
monkeypatch.setattr(conversion, "load_depth_result_image", load_depth)
|
|
monkeypatch.setattr(conversion, "load_normal_result_image", load_depth)
|
|
monkeypatch.setattr(conversion, "bpy", SimpleNamespace(data=bpy.data,
|
|
ops=SimpleNamespace(ed=SimpleNamespace(undo_push=lambda **_: {"FINISHED"}))))
|
|
path = prepare_material_color_input(source.data)
|
|
try:
|
|
conversion.create_depth_plane_from_result(bpy.context, SimpleNamespace(file=lambda key: key),
|
|
SimpleNamespace(source_object_name=source.name, source_identity=str(source.as_pointer()),
|
|
image_identity=str(source.data.as_pointer()), color_path=str(path), plane_type=entry, mesh_detail=0))
|
|
finally:
|
|
cleanup_material_color_input(path)
|
|
return bpy.context.object
|
|
|
|
|
|
@pytest.mark.parametrize("entry,sharing", [
|
|
("PLANE", "none"),
|
|
("PLANE", "material"),
|
|
("DEPTH", "visible"),
|
|
])
|
|
def test_conversion_selects_source_by_empty_identity(source, monkeypatch, entry, sharing):
|
|
share_image(source, sharing)
|
|
original = source.data
|
|
before = images.image_pixels(original)
|
|
name, path = original.name, original.filepath_raw
|
|
monkeypatch.setattr(material, "configured_material_view_adaptation", lambda: False)
|
|
obj = run_conversion(source, entry, monkeypatch)
|
|
color = next(n.image for n in obj.data.materials[0].node_tree.nodes if n.type == "TEX_IMAGE")
|
|
assert color != original
|
|
assert color.name == "Source_color.png"
|
|
assert color.filepath_raw != original.filepath_raw
|
|
assert original.name == name and original.filepath_raw == path
|
|
assert color.colorspace_settings.name == "sRGB" and color.alpha_mode == "PREMUL"
|
|
assert original.alpha_mode == "STRAIGHT"
|
|
np.testing.assert_array_equal(images.image_pixels(original), before)
|
|
np.testing.assert_allclose(images.image_pixels(color), before, atol=1 / 255)
|
|
if sharing == "material":
|
|
existing = bpy.data.materials["Existing"]
|
|
assert next(n.image for n in existing.node_tree.nodes if n.type == "TEX_IMAGE") == original
|
|
|
|
|
|
@pytest.mark.parametrize("shared,failure,entry", [
|
|
(False, "material", "PLANE"),
|
|
(True, "modifier", "DEPTH"),
|
|
(False, "finalize", "PLANE"),
|
|
])
|
|
def test_conversion_failure_restores_image_and_releases_resources(source, monkeypatch, shared, failure, entry):
|
|
if shared:
|
|
share_image(source, "visible")
|
|
source.data.pack()
|
|
source.data.pixels[0] = 0.75
|
|
original = source.data
|
|
state = images.image_content_state(original)
|
|
before = {name: set(getattr(bpy.data, name)) for name in ("images", "objects", "meshes", "materials")}
|
|
def fail(*args, **kwargs):
|
|
raise RuntimeError("Conversion failed")
|
|
monkeypatch.setattr(material, "configured_material_view_adaptation", lambda: True)
|
|
if failure == "material":
|
|
monkeypatch.setattr(material, "material_node_group", fail)
|
|
elif failure == "finalize":
|
|
monkeypatch.setattr(conversion, "finalize_object_result", fail)
|
|
elif entry == "PLANE":
|
|
monkeypatch.setattr(image_plane, "add_image_plane_processing", fail)
|
|
else:
|
|
monkeypatch.setattr(conversion, "set_modifier_input", fail)
|
|
with pytest.raises(RuntimeError, match="Conversion failed"):
|
|
run_conversion(source, entry, monkeypatch)
|
|
for name, values in before.items():
|
|
assert set(getattr(bpy.data, name)) == values
|
|
assert source.data == original
|
|
assert (original.colorspace_settings.name, original.alpha_mode) == (state["colorspace"], state["alpha_mode"])
|
|
assert bytes(original.packed_file.data) == state["packed"]
|
|
np.testing.assert_array_equal(original.pixels[:], state["pixels"])
|
|
|
|
|
|
@pytest.mark.parametrize("shared,kind", [(False, "generated"), (True, "file")])
|
|
def test_conversion_pixels_survive_library_reload(source, tmp_path, monkeypatch, kind, shared):
|
|
if kind in {"file", "dirty"}:
|
|
path = tmp_path / "source.png"
|
|
Image.fromarray(np.array([[[51, 102, 204, 64], [153, 76, 25, 0]]], np.uint8)).save(path)
|
|
source.data = bpy.data.images.load(str(path))
|
|
if kind == "dirty":
|
|
source.data.pixels[0] = 0.8
|
|
elif kind == "packed":
|
|
source.data.pack()
|
|
source.data.reload()
|
|
if shared:
|
|
share_image(source, "visible")
|
|
expected = images.image_pixels(source.data)
|
|
monkeypatch.setattr(material, "configured_material_view_adaptation", lambda: True)
|
|
obj = run_conversion(source, "PLANE", monkeypatch)
|
|
color = next(n.image for n in obj.data.materials[0].node_tree.nodes if n.type == "TEX_IMAGE")
|
|
library = tmp_path / "converted.blend"
|
|
bpy.data.libraries.write(str(library), {color})
|
|
with bpy.data.libraries.load(str(library)) as (saved, loaded):
|
|
loaded.images = saved.images
|
|
restored = loaded.images[0]
|
|
assert (restored.colorspace_settings.name, restored.alpha_mode) == (color.colorspace_settings.name, color.alpha_mode)
|
|
np.testing.assert_allclose(images.image_pixels(restored), expected, atol=1 / 255)
|
|
|
|
|
|
@pytest.mark.parametrize("entry", ["DEPTH", "RELIEF"])
|
|
def test_depth_result_rejects_replaced_source_image(source, entry):
|
|
operator = SimpleNamespace(source_object_name=source.name, source_identity=str(source.as_pointer()),
|
|
image_identity=str(source.data.as_pointer()), plane_type=entry)
|
|
source.data = source.data.copy()
|
|
with pytest.raises(ValueError, match="source image has changed"):
|
|
conversion.create_depth_plane_from_result(bpy.context, None, operator)
|
|
|
|
|
|
@pytest.mark.parametrize("shared", [False, True])
|
|
def test_plane_undo_redo_restores_image_configuration(source, monkeypatch, shared):
|
|
if shared:
|
|
share_image(source, "visible")
|
|
original_name = source.data.name
|
|
expected = images.image_pixels(source.data)
|
|
monkeypatch.setattr(material, "configured_material_view_adaptation", lambda: False)
|
|
bpy.context.preferences.edit.use_global_undo = True
|
|
bpy.utils.register_class(operators.ConvertToPlane)
|
|
try:
|
|
bpy.ops.ed.undo_push(message="Before Plane")
|
|
assert bpy.ops.anyimage.convert_to_plane("EXEC_DEFAULT", True) == {"FINISHED"}
|
|
color_name = "Source_color.png"
|
|
assert bpy.data.images[color_name].alpha_mode == "PREMUL"
|
|
assert bpy.ops.ed.undo() == {"FINISHED"}
|
|
assert bpy.data.objects["Source"].type == "EMPTY"
|
|
assert bpy.data.images[original_name].alpha_mode == "STRAIGHT"
|
|
np.testing.assert_allclose(images.image_pixels(bpy.data.images[original_name]), expected, atol=1 / 255)
|
|
assert bpy.ops.ed.redo() == {"FINISHED"}
|
|
assert bpy.data.objects["Source"].type == "MESH"
|
|
assert bpy.data.images[color_name].alpha_mode == "PREMUL"
|
|
finally:
|
|
bpy.utils.unregister_class(operators.ConvertToPlane)
|