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(