feat(node_assets): add image plane geometry nodes and validation scripts

- Implemented `image_plane.py` to build the Image Plane Geometry Nodes group with customizable parameters for geometry, subdivide, and thickness.
- Created `image_plane_validation.py` to validate the functionality of the image plane, ensuring correct geometry generation and material assignment.
- Developed `material_layers.py` to construct shader node groups for image materials, including depth and normal handling.
- Authored `plane_layouts.py` to define node arrangements for image geometry assets.
- Introduced `shadeless.py` to create a shadeless shader node group that displays color without scene lighting while preserving shadows and reflections.
This commit is contained in:
2026-09-08 23:24:27 +08:00
parent 196b964efc
commit 4c902ed7db
155 changed files with 9700 additions and 10393 deletions

View File

@@ -1,257 +1,32 @@
import ast
import importlib
import importlib.util
import sys
import tempfile
import unittest
from pathlib import Path
from types import ModuleType, SimpleNamespace
from unittest.mock import patch
PROJECT_ROOT = Path(__file__).resolve().parents[1]
SOURCE_ROOT = PROJECT_ROOT / "src"
sys.path.insert(0, str(SOURCE_ROOT))
COMMON_ROOT = SOURCE_ROOT / "anyimage" / "common"
COMMON_MODULES = {
"ai",
"depth",
"image",
"material",
"node",
"object",
"selection",
"viewport",
}
def test_common_modules_follow_business_boundaries():
module_names = {
path.stem
for path in COMMON_ROOT.glob("*.py")
if path.name != "__init__.py"
}
assert module_names == COMMON_MODULES
for module_name in COMMON_MODULES:
importlib.import_module(f"anyimage.common.{module_name}")
ai_ui_definitions = {
path.name
for path in COMMON_ROOT.glob("*.py")
if "def draw_ai_setup(" in path.read_text(encoding="utf-8")
or "def draw_ai_property(" in path.read_text(encoding="utf-8")
}
assert ai_ui_definitions == {"ai.py"}
init_tree = ast.parse(
(COMMON_ROOT / "__init__.py").read_text(encoding="utf-8")
)
assert not any(
isinstance(node, (ast.Import, ast.ImportFrom))
for node in init_tree.body
)
def load_image_modules():
fake_bpy = ModuleType("bpy")
fake_bpy.props = SimpleNamespace(
BoolProperty=lambda **options: options,
EnumProperty=lambda **options: options,
FloatProperty=lambda **options: options,
IntProperty=lambda **options: options,
StringProperty=lambda **options: options,
)
fake_bpy.types = SimpleNamespace(Menu=object, Operator=object)
fake_bpy.path = SimpleNamespace(abspath=lambda path: path)
fake_bpy.context = SimpleNamespace(scene=None)
module_paths = (
(
"anyimage.common.image",
PROJECT_ROOT
/ "src"
/ "anyimage"
/ "common"
/ "image.py",
),
(
"anyimage.common.node",
PROJECT_ROOT
/ "src"
/ "anyimage"
/ "common"
/ "node.py",
),
(
"anyimage.common.material",
PROJECT_ROOT
/ "src"
/ "anyimage"
/ "common"
/ "material.py",
),
(
"anyimage.common.object",
PROJECT_ROOT
/ "src"
/ "anyimage"
/ "common"
/ "object.py",
),
)
packages = {
name: ModuleType(name)
for name in (
"anyimage",
"anyimage.operators",
"anyimage.common",
"anyimage.operators.convert_to_plane",
)
}
for package in packages.values():
package.__path__ = []
loaded = []
preferences = ModuleType("anyimage.preferences")
preferences.configured_material_view_adaptation = lambda: True
with patch.dict(sys.modules, {"bpy": fake_bpy, "anyimage.preferences": preferences, **packages}):
for module_name, module_path in module_paths:
specification = importlib.util.spec_from_file_location(
module_name,
module_path,
)
module = importlib.util.module_from_spec(specification)
sys.modules[module_name] = module
specification.loader.exec_module(module)
loaded.append(module)
namespace = SimpleNamespace()
for module in loaded:
for name, value in vars(module).items():
if not name.startswith("__"):
setattr(namespace, name, value)
return namespace
from types import SimpleNamespace
from common_test_support import load_common_modules
PROJECT_ROOT=Path(__file__).resolve().parents[1]
class ImageDataTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.image_depth = load_image_modules()
def test_node_asset_path_survives_package_split(self):
self.assertEqual(
self.image_depth.node_asset_path(),
PROJECT_ROOT / "src" / "anyimage" / "assets" / "O_AnyImage.blend",
)
def test_runtime_modifier_enum_input_uses_string_identifier(self):
enum_items = (
SimpleNamespace(identifier="SURFACE"),
SimpleNamespace(identifier="BALLOON"),
)
value_property = SimpleNamespace(type="ENUM", enum_items=enum_items)
input_slot = SimpleNamespace(
value=None,
bl_rna=SimpleNamespace(properties={"value": value_property}),
)
modifier = SimpleNamespace(
properties=SimpleNamespace(
inputs=SimpleNamespace(Shape=input_slot),
)
)
self.image_depth.set_modifier_input(modifier, "Shape", 1)
self.assertEqual(input_slot.value, "BALLOON")
def test_runtime_modifier_non_enum_and_legacy_inputs_keep_values(self):
value_property = SimpleNamespace(type="FLOAT")
input_slot = SimpleNamespace(
value=None,
bl_rna=SimpleNamespace(properties={"value": value_property}),
)
runtime_modifier = SimpleNamespace(
properties=SimpleNamespace(
inputs=SimpleNamespace(Thickness=input_slot),
)
)
self.image_depth.set_modifier_input(runtime_modifier, "Thickness", 2.0)
self.assertEqual(input_slot.value, 2.0)
class LegacyModifier(dict):
properties = None
legacy_modifier = LegacyModifier()
self.image_depth.set_modifier_input(legacy_modifier, "Shape", 1)
self.assertEqual(legacy_modifier["Shape"], 1)
def test_material_color_space_matches_scene_view_transform(self):
mappings = {
"ACES 1.3": "ACES 1.3 sRGB",
"ACES 2.0": "ACES 2.0 sRGB",
"AgX": "AgX Base sRGB",
"Filmic": "Filmic sRGB",
"Khronos PBR Neutral": "Khronos PBR Neutral sRGB",
"Standard": "sRGB",
}
for view_transform, expected in mappings.items():
with self.subTest(view_transform=view_transform):
scene = SimpleNamespace(
view_settings=SimpleNamespace(
view_transform=view_transform,
)
)
self.assertEqual(
self.image_depth.material_color_space_name(scene),
expected,
)
def test_material_color_space_falls_back_when_unsupported(self):
class ColorSpaceSettings:
def __init__(self):
self._name = "sRGB"
@property
def name(self):
return self._name
@name.setter
def name(self, value):
if value != "sRGB":
raise TypeError("unsupported color space")
self._name = value
image = SimpleNamespace(
colorspace_settings=ColorSpaceSettings(),
users=0, use_fake_user=False, alpha_mode="STRAIGHT",
)
scene = SimpleNamespace(
view_settings=SimpleNamespace(view_transform="ACES 2.0")
)
result = self.image_depth.configure_material_color_image(image, scene)
self.assertIs(result, image)
self.assertEqual(image.alpha_mode, "PREMUL")
self.assertEqual(image.colorspace_settings.name, "sRGB")
cls.modules = load_common_modules()
def test_is_animated_image_accepts_movie_and_sequence(self):
self.assertTrue(
self.image_depth.is_animated_image(SimpleNamespace(source="MOVIE"))
self.modules.image.is_animated_image(SimpleNamespace(source="MOVIE"))
)
self.assertTrue(
self.image_depth.is_animated_image(SimpleNamespace(source="SEQUENCE"))
self.modules.image.is_animated_image(SimpleNamespace(source="SEQUENCE"))
)
self.assertFalse(
self.image_depth.is_animated_image(SimpleNamespace(source="FILE"))
self.modules.image.is_animated_image(SimpleNamespace(source="FILE"))
)
def test_video_total_frames_reads_movie_duration(self):
image = SimpleNamespace(source="MOVIE", frame_duration=97)
self.assertEqual(self.image_depth.video_total_frames(image), 97)
self.assertEqual(self.modules.image.video_total_frames(image), 97)
def test_video_total_frames_counts_sequence_files(self):
with tempfile.TemporaryDirectory() as directory:
@@ -263,12 +38,14 @@ class ImageDataTest(unittest.TestCase):
filepath=str(root / "foreground-0001.png"),
)
self.assertEqual(self.image_depth.video_total_frames(image), 2)
self.assertEqual(self.modules.image.video_total_frames(image), 2)
def test_video_total_frames_returns_one_for_still_image(self):
image = SimpleNamespace(source="FILE", frame_duration=1)
self.assertEqual(self.image_depth.video_total_frames(image), 1)
self.assertEqual(self.modules.image.video_total_frames(image), 1)
def test_default_extract_frames_uses_empty_image_frames(self):
source_object = SimpleNamespace(
@@ -276,7 +53,8 @@ class ImageDataTest(unittest.TestCase):
image_user=SimpleNamespace(frame_duration=24),
)
self.assertEqual(self.image_depth.default_extract_frames(source_object), 24)
self.assertEqual(self.modules.image.default_extract_frames(source_object), 24)
def test_default_extract_frames_caps_at_video_total(self):
source_object = SimpleNamespace(
@@ -284,14 +62,16 @@ class ImageDataTest(unittest.TestCase):
image_user=SimpleNamespace(frame_duration=200),
)
self.assertEqual(self.image_depth.default_extract_frames(source_object), 97)
self.assertEqual(self.modules.image.default_extract_frames(source_object), 97)
def test_default_extract_frames_falls_back_to_video_total(self):
source_object = SimpleNamespace(
data=SimpleNamespace(source="MOVIE", frame_duration=97),
)
self.assertEqual(self.image_depth.default_extract_frames(source_object), 97)
self.assertEqual(self.modules.image.default_extract_frames(source_object), 97)
def test_extract_frame_range_honors_offset_within_clip(self):
source_object = SimpleNamespace(
@@ -304,10 +84,11 @@ class ImageDataTest(unittest.TestCase):
)
self.assertEqual(
self.image_depth.extract_frame_range(source_object),
self.modules.image.extract_frame_range(source_object),
(71, 24),
)
def test_extract_frame_range_clamps_large_offset_to_clip_tail(self):
source_object = SimpleNamespace(
data=SimpleNamespace(source="MOVIE", frame_duration=97),
@@ -319,10 +100,11 @@ class ImageDataTest(unittest.TestCase):
)
self.assertEqual(
self.image_depth.extract_frame_range(source_object),
self.modules.image.extract_frame_range(source_object),
(74, 24),
)
def test_extract_frame_range_clamps_negative_offset_to_start(self):
source_object = SimpleNamespace(
data=SimpleNamespace(source="MOVIE", frame_duration=97),
@@ -334,10 +116,11 @@ class ImageDataTest(unittest.TestCase):
)
self.assertEqual(
self.image_depth.extract_frame_range(source_object),
self.modules.image.extract_frame_range(source_object),
(1, 24),
)
def test_extract_frame_range_returns_full_clip_for_still(self):
source_object = SimpleNamespace(
data=SimpleNamespace(source="FILE", frame_duration=1),
@@ -349,10 +132,11 @@ class ImageDataTest(unittest.TestCase):
)
self.assertEqual(
self.image_depth.extract_frame_range(source_object),
self.modules.image.extract_frame_range(source_object),
(1, 1),
)
def test_extract_frame_range_honors_single_frame_setting(self):
source_object = SimpleNamespace(
data=SimpleNamespace(source="MOVIE", frame_duration=97),
@@ -364,10 +148,11 @@ class ImageDataTest(unittest.TestCase):
)
self.assertEqual(
self.image_depth.extract_frame_range(source_object),
self.modules.image.extract_frame_range(source_object),
(71, 1),
)
def test_configure_image_user_preserves_cyclic_setting(self):
image_user = SimpleNamespace(
frame_start=0,
@@ -377,7 +162,7 @@ class ImageDataTest(unittest.TestCase):
frame_duration=1,
)
self.image_depth.configure_image_user(
self.modules.image.configure_image_user(
image_user,
97,
frame_start=4,
@@ -389,12 +174,13 @@ class ImageDataTest(unittest.TestCase):
self.assertEqual(image_user.frame_start, 4)
self.assertEqual(image_user.frame_offset, 70)
self.assertEqual(image_user.frame_duration, 97)
self.image_depth.configure_image_user(
self.modules.image.configure_image_user(
image_user,
use_cyclic=True,
)
self.assertTrue(image_user.use_cyclic)
def test_image_user_settings_reads_empty_values(self):
source_object = SimpleNamespace(
image_user=SimpleNamespace(
@@ -405,54 +191,60 @@ class ImageDataTest(unittest.TestCase):
)
self.assertEqual(
self.image_depth.image_user_settings(source_object),
self.modules.image.image_user_settings(source_object),
(4, 70, 12, False),
)
source_object.image_user.use_cyclic = True
self.assertEqual(
self.image_depth.image_user_settings(source_object),
self.modules.image.image_user_settings(source_object),
(4, 70, 12, True),
)
def test_image_user_settings_defaults_without_user(self):
self.assertEqual(
self.image_depth.image_user_settings(SimpleNamespace(image_user=None)),
self.modules.image.image_user_settings(SimpleNamespace(image_user=None)),
(1, 0, 0, False),
)
def test_image_base_name_strips_video_extension(self):
image = SimpleNamespace(name="微信视频2026-07-23_192756_414.mp4")
self.assertEqual(
self.image_depth.image_base_name(image),
self.modules.image.image_base_name(image),
"微信视频2026-07-23_192756_414",
)
def test_color_name_strips_video_extension(self):
source_object = SimpleNamespace(data=SimpleNamespace(name="clip.mp4"))
self.assertEqual(
self.image_depth.color_data_name(source_object),
self.modules.image.color_data_name(source_object),
"clip_color.png",
)
def test_force_sequence_source_keeps_loaded_sequence(self):
image = SimpleNamespace(source="SEQUENCE")
result = self.image_depth.force_sequence_source(image)
result = self.modules.image.force_sequence_source(image)
self.assertIs(result, image)
self.assertEqual(image.source, "SEQUENCE")
def test_force_sequence_source_switches_single_image(self):
image = SimpleNamespace(source="FILE")
result = self.image_depth.force_sequence_source(image)
result = self.modules.image.force_sequence_source(image)
self.assertIs(result, image)
self.assertEqual(image.source, "SEQUENCE")
def test_force_sequence_source_reports_unloadable_sequence(self):
class BlockedImage:
@property
@@ -464,7 +256,8 @@ class ImageDataTest(unittest.TestCase):
raise RuntimeError("blocked")
with self.assertRaisesRegex(RuntimeError, "cannot load"):
self.image_depth.force_sequence_source(BlockedImage())
self.modules.image.force_sequence_source(BlockedImage())
def test_collect_sequence_frames_returns_numbered_files_in_order(self):
with tempfile.TemporaryDirectory() as directory:
@@ -478,13 +271,14 @@ class ImageDataTest(unittest.TestCase):
filepath=str(root / "frame-0002.jpg"),
)
frames = self.image_depth.collect_sequence_frames(image)
frames = self.modules.image.collect_sequence_frames(image)
self.assertEqual(
[path.name for path in frames],
["frame-0001.jpg", "frame-0002.jpg", "frame-0003.jpg"],
)
def test_sorted_frame_paths_uses_numeric_frame_order(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
@@ -492,13 +286,14 @@ class ImageDataTest(unittest.TestCase):
(root / "frame-10.jpg").touch()
(root / "frame-1.jpg").touch()
frames = self.image_depth.sorted_frame_paths(root)
frames = self.modules.image.sorted_frame_paths(root)
self.assertEqual(
[path.name for path in frames],
["frame-1.jpg", "frame-2.jpg", "frame-10.jpg"],
)
def test_depth_data_uses_source_image_name(self):
source_object = SimpleNamespace(
name="obj_img",
@@ -506,15 +301,16 @@ class ImageDataTest(unittest.TestCase):
)
self.assertEqual(
self.image_depth.depth_data_name(source_object),
self.modules.image.depth_data_name(source_object),
"img_depth.exr",
)
source_object.data.name = "img.png.001"
self.assertEqual(
self.image_depth.depth_data_name(source_object),
self.modules.image.depth_data_name(source_object),
"img.001_depth.exr",
)
def test_moge_normal_name_uses_source_image_name(self):
source_object = SimpleNamespace(
name="obj_img",
@@ -522,36 +318,26 @@ class ImageDataTest(unittest.TestCase):
)
self.assertEqual(
self.image_depth.normal_data_name(source_object),
self.modules.image.normal_data_name(source_object),
"img_normal.png",
)
def test_color_name_uses_source_image_base_name(self):
source_object = SimpleNamespace(
data=SimpleNamespace(name="img.png"),
)
self.assertEqual(
self.image_depth.color_data_name(source_object),
self.modules.image.color_data_name(source_object),
"img_color.png",
)
source_object.data.name = "img.png.001"
self.assertEqual(
self.image_depth.color_data_name(source_object),
self.modules.image.color_data_name(source_object),
"img.001_color.png",
)
def test_surface_displacement_is_set_to_displacement_only(self):
class Material:
surface_displacement_method = "BUMP"
material = Material()
self.image_depth.set_displacement_only(material)
self.assertEqual(
material.surface_displacement_method,
"DISPLACEMENT",
)
def test_image_empty_bounds_keep_image_aspect_and_empty_offset(self):
source = SimpleNamespace(
@@ -560,10 +346,6 @@ class ImageDataTest(unittest.TestCase):
empty_image_offset=(-0.5, -0.5),
)
bounds = self.image_depth.image_empty_bounds(source)
bounds = self.modules.image.image_empty_bounds(source)
self.assertEqual(bounds, (-1.0, 1.0, -0.5, 0.5))
if __name__ == "__main__":
unittest.main()