Add comprehensive tests for edge depth extension, empty image edits, and material view adaptation

- Introduced `test_edge_depth.py` to validate the functionality of edge depth extension, ensuring core alpha preservation, proper depth handling, and edge padding behavior.
- Created `test_empty_image_edits.py` to verify the behavior of image edits in Blender, focusing on shared edits, preservation of image properties, and handling of failed operations.
- Developed `test_material_view_adaptation.py` to test material adaptation preferences, ensuring color space and alpha settings are correctly applied and preserved across various scenarios.
This commit is contained in:
2026-09-06 21:43:47 +08:00
parent 1994d38ccd
commit 7e55af6722
58 changed files with 3607 additions and 200 deletions

View File

@@ -340,7 +340,7 @@ def image_path(image):
filepath_from_user = getattr(image, "filepath_from_user", None)
filepath = (
filepath_from_user()
if callable(filepath_from_user)
if callable(filepath_from_user) and getattr(image, "source", None) != "SEQUENCE"
else getattr(image, "filepath", "")
)
if not filepath:
@@ -558,7 +558,7 @@ def configure_image_user(
def pack_sequence_if_possible(image, expected_frames):
"""Pack a frame sequence when Blender supports it; otherwise keep external files."""
if expected_frames <= 1:
if expected_frames <= 1 or not image.has_data:
return False
try:
image.pack()
@@ -655,71 +655,149 @@ def create_image_edit_result(source_image, pixels, output_size):
return image
def replace_empty_image(source_object, result_image, *, placement_bounds=None):
def has_other_image_empty(source_object, *, removed_objects=()):
"""Check surviving image Empty objects for the same Image datablock."""
return any(
candidate != source_object
and candidate not in removed_objects
and is_image_empty(candidate)
and candidate.data == source_object.data
for candidate in bpy.data.objects
)
def image_content_state(image):
"""Capture encoded content and unsaved pixels for an Image transaction."""
import numpy as np
pixels = None
if image.is_dirty:
pixels = np.empty(len(image.pixels), dtype=np.float32)
image.pixels.foreach_get(pixels)
return {
"source": image.source,
"filepath": image.filepath_raw,
"colorspace": image.colorspace_settings.name,
"alpha_mode": image.alpha_mode,
"file_format": image.file_format,
"packed": bytes(image.packed_file.data) if image.packed_file else None,
"pixels": pixels,
"size": tuple(image.size),
"generated": {
name: getattr(image, name)
for name in ("generated_width", "generated_height", "generated_type",
"use_generated_float")
},
"generated_color": tuple(image.generated_color),
}
def restore_image_content(image, state):
"""Apply captured image content while retaining the destination ID."""
if image.packed_file:
image.source = "FILE"
image.unpack(method="REMOVE")
image.filepath_raw = state["filepath"]
image.source = state["source"]
if state["source"] == "GENERATED":
for name, value in state["generated"].items():
setattr(image, name, value)
image.generated_color = state["generated_color"]
image.colorspace_settings.name = state["colorspace"]
image.alpha_mode = state["alpha_mode"]
image.file_format = state["file_format"]
if state["packed"] is not None:
data = state["packed"]
image.source = "FILE"
image.pack(data=data, data_len=len(data))
image.source = state["source"]
image.reload()
if state["pixels"] is not None:
if tuple(image.size) != state["size"]:
image.scale(*state["size"])
image.pixels.foreach_set(state["pixels"])
image.update()
def replace_empty_image(source_object, result_image, *, placement_bounds=None,
removed_objects=()):
"""Replace one Empty image, retaining shared source images."""
source_image = source_object.data
source_name = source_image.name
original_fake_user = source_image.use_fake_user
original_blending = getattr(source_object, "use_empty_image_alpha", False)
image_user = getattr(source_object, "image_user", None)
original_timing = image_user_settings(source_object) if image_user is not None else None
frame_count = (
video_total_frames(result_image)
if is_animated_image(result_image)
else None
)
alignment = (
empty_display_alignment(
source_object,
tuple(source_image.size),
tuple(result_image.size),
placement_bounds,
)
if placement_bounds is not None
else None
)
if frame_count is not None:
(
original_frame_start,
_original_frame_offset,
_original_frame_duration,
original_use_cyclic,
) = image_user_settings(source_object)
source_image.use_fake_user = True
original_alignment = (
(source_object.empty_display_size, tuple(source_object.empty_image_offset))
if alignment is not None else None
)
try:
source_object.data = result_image
source_object.use_empty_image_alpha = True
if frame_count is not None:
configure_image_user(
image_user,
frame_count,
frame_start=original_frame_start,
use_cyclic=original_use_cyclic,
source_image = source_object.data
original_fake_user = source_image.use_fake_user
original_blending = getattr(source_object, "use_empty_image_alpha", False)
image_user = getattr(source_object, "image_user", None)
original_timing = image_user_settings(source_object) if image_user is not None else None
original_auto_refresh = getattr(image_user, "use_auto_refresh", False)
frame_count = (
video_total_frames(result_image)
if is_animated_image(result_image)
else None
)
alignment = (
empty_display_alignment(
source_object,
tuple(source_image.size),
tuple(result_image.size),
placement_bounds,
)
if alignment is not None:
source_object.empty_display_size = alignment[0]
source_object.empty_image_offset = alignment[1]
except Exception:
source_object.data = source_image
source_object.use_empty_image_alpha = original_blending
if original_alignment is not None:
source_object.empty_display_size, source_object.empty_image_offset = original_alignment
if original_timing is not None:
(image_user.frame_start, image_user.frame_offset,
image_user.frame_duration, image_user.use_cyclic) = original_timing
source_image.use_fake_user = original_fake_user
raise
if placement_bounds is not None
else None
)
if frame_count is not None:
(
original_frame_start,
_original_frame_offset,
_original_frame_duration,
original_use_cyclic,
) = image_user_settings(source_object)
source_image.use_fake_user = original_fake_user
if not original_fake_user and int(source_image.users) == 0:
bpy.data.images.remove(source_image, do_unlink=True)
result_image.name = source_name
return result_image
source_image.use_fake_user = True
original_alignment = (
(source_object.empty_display_size, tuple(source_object.empty_image_offset))
if alignment is not None else None
)
original_content = None
try:
shared = has_other_image_empty(source_object, removed_objects=removed_objects)
if shared:
final_image = result_image
else:
result_content = image_content_state(result_image)
original_content = image_content_state(source_image)
restore_image_content(source_image, result_content)
final_image = source_image
source_object.data = final_image
source_object.use_empty_image_alpha = True
if frame_count is not None:
configure_image_user(
image_user,
frame_count,
frame_start=original_frame_start,
use_cyclic=original_use_cyclic,
)
if alignment is not None:
source_object.empty_display_size = alignment[0]
source_object.empty_image_offset = alignment[1]
except Exception:
if original_content is not None:
restore_image_content(source_image, original_content)
source_object.data = source_image
source_object.use_empty_image_alpha = original_blending
if original_alignment is not None:
source_object.empty_display_size, source_object.empty_image_offset = original_alignment
if original_timing is not None:
(image_user.frame_start, image_user.frame_offset,
image_user.frame_duration, image_user.use_cyclic) = original_timing
image_user.use_auto_refresh = original_auto_refresh
source_image.use_fake_user = original_fake_user
raise
source_image.use_fake_user = original_fake_user
return final_image
finally:
if result_image.users == 0:
bpy.data.images.remove(result_image)
def _load_packed_result_image(

View File

@@ -2,8 +2,8 @@
import bpy
from ..preferences import configured_material_view_adaptation
from .image import (
configure_color_alpha,
configure_image_user,
image_base_name,
image_pixels,
@@ -36,31 +36,51 @@ def material_color_space_name(scene):
return "sRGB"
def set_material_color_space(image, scene=None):
def configure_material_color_image(image, scene=None):
"""Configure an owned material Color image using the current shading preference."""
if scene is None:
scene = getattr(bpy.context, "scene", None)
settings = image.colorspace_settings
preferred = material_color_space_name(scene)
original = settings.name
pixels = None
if preferred != original and (
getattr(image, "source", None) == "GENERATED"
preferred = (
material_color_space_name(scene)
if configured_material_view_adaptation()
else "sRGB"
)
pixels = (
image_pixels(image)
if getattr(image, "source", None) == "GENERATED"
or getattr(image, "is_dirty", False)
):
pixels = image_pixels(image)
for color_space in dict.fromkeys((preferred, "sRGB", original)):
if not color_space:
continue
try:
settings.name = color_space
except (TypeError, ValueError):
continue
if settings.name == color_space:
else None
)
shared = image.users > int(image.use_fake_user)
result = image.copy() if shared else image
settings = result.colorspace_settings
original_space = settings.name
original_alpha = result.alpha_mode
try:
for color_space in dict.fromkeys((preferred, "sRGB")):
try:
settings.name = color_space
except (TypeError, ValueError):
continue
if settings.name == color_space:
break
else:
raise RuntimeError("Unable to configure the material color space")
result.alpha_mode = "PREMUL" if settings.name == "sRGB" else "STRAIGHT"
if pixels is not None:
result.pixels.foreach_set(pixels)
result.update()
except Exception:
if shared:
bpy.data.images.remove(result)
else:
settings.name = original_space
result.alpha_mode = original_alpha
if pixels is not None:
image.pixels.foreach_set(pixels)
image.update()
return color_space
return settings.name
result.pixels.foreach_set(pixels)
result.update()
raise
return result
def material_node_group(depth_plane=False):
@@ -92,6 +112,7 @@ def create_image_material(
ior=None,
scene=None,
):
material_image = configure_material_color_image(color_image, scene)
material = bpy.data.materials.new(image_base_name(source_object.data))
material.use_nodes = True
if hasattr(material, "surface_render_method"):
@@ -105,9 +126,6 @@ def create_image_material(
uv_map.uv_map = "UVMap"
uv_map.location = (-1250.0, 350.0)
material_image = color_image
set_material_color_space(material_image, scene)
configure_color_alpha(material_image)
color_texture.image = material_image
color_texture.interpolation = "Linear"
color_texture.extension = "EXTEND"