From f48bbb3d05bfec7f80d7e8d6e9ec538a474bc42f Mon Sep 17 00:00:00 2001 From: nan Date: Wed, 9 Sep 2026 22:18:59 +0800 Subject: [PATCH] Add tests for image layer, image plane, inventory, saved layout, commands, documentation, manifest, and packaging - Implement tests for image layer functionality including alpha curve and depth layer behavior. - Create tests for image plane evaluation and modifier input handling. - Add tests to verify the saved asset inventory and its persistence. - Introduce tests for the documentation command to ensure correct project version deployment. - Validate the extension manifest structure and required permissions. - Develop tests for the packaging process, ensuring correct platform handling and dependency management. - Enhance image depth plane group functionality with new split threshold input and surface processing. --- AGENTS.md | 2 +- docs/internals/development.md | 2 +- docs/internals/node-assets.md | 6 +- pyproject.toml | 6 +- src/anyimage/assets/O_AnyImage.blend | 4 +- tests/__init__.py | 0 tests/anyimage/__init__.py | 0 tests/anyimage/common/__init__.py | 0 .../common/support.py} | 9 +- .../common}/test_alpha_presentation.py | 3 - .../common}/test_depth_calibration.py | 5 - .../common}/test_empty_image_edits.py | 3 - .../{ => anyimage/common}/test_image_data.py | 3 +- .../common}/test_image_edit_tool.py | 23 +- .../common}/test_image_materials.py | 9 +- tests/anyimage/common/test_material_alpha.py | 27 +++ .../common}/test_material_settings.py | 4 +- .../common}/test_material_view_adaptation.py | 3 - .../common}/test_modifier_inputs.py | 4 +- .../common}/test_node_loading.py | 5 +- tests/{ => anyimage/common}/test_selection.py | 4 - tests/anyimage/operators/__init__.py | 0 .../operators}/test_ai_image_operators.py | 2 +- .../{ => anyimage/operators}/test_ai_setup.py | 2 +- .../operators}/test_balloon_profiles.py | 8 - .../operators}/test_brush_strokes.py | 2 - .../operators}/test_clipboard_image.py | 8 +- tests/anyimage/operators/test_cutout_depth.py | 34 +++ .../operators}/test_cutout_fine_mesh.py | 3 - .../operators}/test_cutout_geometry.py | 6 - .../operators}/test_cutout_interaction.py | 4 - .../operators}/test_cutout_objects.py | 7 +- .../operators}/test_cutout_operators.py | 2 +- .../operators}/test_frame_antialiasing.py | 3 - .../operators}/test_frame_projection.py | 3 - .../operators}/test_frame_tool.py | 2 +- .../operators}/test_image_edit_integration.py | 6 - .../operators}/test_mask_tool.py | 2 +- .../operators}/test_mask_undo.py | 3 - .../operators/test_plane_conversion.py | 69 ++++++ .../operators}/test_plane_objects.py | 8 +- .../operators}/test_plane_operators.py | 2 +- .../operators}/test_rectify_tool.py | 2 +- .../operators}/test_upscale_undo.py | 3 - tests/anyimage/server/__init__.py | 0 .../server/runtime_support.py} | 3 +- .../server/support.py} | 4 +- .../{ => anyimage/server}/test_edge_depth.py | 3 - .../server}/test_geometry_artifacts.py | 2 +- .../{ => anyimage/server}/test_job_server.py | 6 +- .../{ => anyimage/server}/test_media_input.py | 2 +- .../server}/test_model_ben2_onnx.py | 13 +- .../server}/test_model_catalog.py | 5 +- .../server}/test_model_download.py | 4 +- .../server}/test_model_download_jobs.py | 2 +- .../server}/test_model_geometry.py | 8 - .../server}/test_model_inference.py | 2 +- .../server}/test_model_manager.py | 6 +- .../server}/test_model_moge2_onnx.py | 4 +- .../server}/test_model_onnx_runtime.py | 13 +- .../server}/test_model_pisa_sr.py | 3 - .../server}/test_model_upscale_onnx.py | 13 +- .../{ => anyimage/server}/test_server_jobs.py | 2 +- .../{ => anyimage}/test_addon_registration.py | 2 +- tests/{ => anyimage}/test_image_menu.py | 4 +- tests/{ => anyimage}/test_job_operator.py | 2 +- tests/{ => anyimage}/test_preferences.py | 2 +- tests/{ => anyimage}/test_server_panel.py | 2 +- tests/conftest.py | 6 +- tests/support/__init__.py | 0 .../blender.py} | 3 +- .../depth_surface.py} | 9 +- .../image_objects.py} | 7 - tests/support/materials.py | 17 ++ .../nodes.py} | 0 tests/support/paths.py | 4 + tests/support/planes.py | 82 +++++++ tests/test_node_asset_contracts.py | 145 ------------- tests/tools/__init__.py | 0 tests/tools/models/__init__.py | 0 .../models/test_cli.py} | 5 +- tests/tools/nodes/__init__.py | 0 .../nodes/test_arrangement.py} | 2 +- .../nodes/test_cli.py} | 5 +- .../nodes/test_cutouts.py} | 10 +- .../nodes/test_depth_cutout.py} | 65 +----- .../nodes}/test_depth_relief_planes.py | 205 +++++------------- .../nodes}/test_depth_surface_split.py | 10 +- .../nodes/test_image_layer.py} | 39 +--- .../nodes/test_image_plane.py} | 0 tests/tools/nodes/test_inventory.py | 23 ++ .../nodes/test_saved_layout.py} | 12 +- .../test_commands.py} | 7 +- .../test_docs.py} | 4 +- .../test_manifest.py} | 3 +- .../test_pack.py} | 3 +- tools/nodes/groups/image_depth_plane.py | 65 +++--- 97 files changed, 436 insertions(+), 705 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/anyimage/__init__.py create mode 100644 tests/anyimage/common/__init__.py rename tests/{common_test_support.py => anyimage/common/support.py} (92%) rename tests/{ => anyimage/common}/test_alpha_presentation.py (99%) rename tests/{ => anyimage/common}/test_depth_calibration.py (97%) rename tests/{ => anyimage/common}/test_empty_image_edits.py (99%) rename tests/{ => anyimage/common}/test_image_data.py (99%) rename tests/{ => anyimage/common}/test_image_edit_tool.py (88%) rename tests/{ => anyimage/common}/test_image_materials.py (97%) create mode 100644 tests/anyimage/common/test_material_alpha.py rename tests/{ => anyimage/common}/test_material_settings.py (94%) rename tests/{ => anyimage/common}/test_material_view_adaptation.py (99%) rename tests/{ => anyimage/common}/test_modifier_inputs.py (93%) rename tests/{ => anyimage/common}/test_node_loading.py (74%) rename tests/{ => anyimage/common}/test_selection.py (97%) create mode 100644 tests/anyimage/operators/__init__.py rename tests/{ => anyimage/operators}/test_ai_image_operators.py (99%) rename tests/{ => anyimage/operators}/test_ai_setup.py (99%) rename tests/{ => anyimage/operators}/test_balloon_profiles.py (95%) rename tests/{ => anyimage/operators}/test_brush_strokes.py (98%) rename tests/{ => anyimage/operators}/test_clipboard_image.py (99%) create mode 100644 tests/anyimage/operators/test_cutout_depth.py rename tests/{ => anyimage/operators}/test_cutout_fine_mesh.py (99%) rename tests/{ => anyimage/operators}/test_cutout_geometry.py (98%) rename tests/{ => anyimage/operators}/test_cutout_interaction.py (98%) rename tests/{ => anyimage/operators}/test_cutout_objects.py (99%) rename tests/{ => anyimage/operators}/test_cutout_operators.py (99%) rename tests/{ => anyimage/operators}/test_frame_antialiasing.py (98%) rename tests/{ => anyimage/operators}/test_frame_projection.py (93%) rename tests/{ => anyimage/operators}/test_frame_tool.py (99%) rename tests/{ => anyimage/operators}/test_image_edit_integration.py (98%) rename tests/{ => anyimage/operators}/test_mask_tool.py (99%) rename tests/{ => anyimage/operators}/test_mask_undo.py (97%) create mode 100644 tests/anyimage/operators/test_plane_conversion.py rename tests/{ => anyimage/operators}/test_plane_objects.py (97%) rename tests/{ => anyimage/operators}/test_plane_operators.py (99%) rename tests/{ => anyimage/operators}/test_rectify_tool.py (99%) rename tests/{ => anyimage/operators}/test_upscale_undo.py (95%) create mode 100644 tests/anyimage/server/__init__.py rename tests/{server_runtime_test_support.py => anyimage/server/runtime_support.py} (97%) rename tests/{server_test_support.py => anyimage/server/support.py} (98%) rename tests/{ => anyimage/server}/test_edge_depth.py (98%) rename tests/{ => anyimage/server}/test_geometry_artifacts.py (99%) rename tests/{ => anyimage/server}/test_job_server.py (98%) rename tests/{ => anyimage/server}/test_media_input.py (98%) rename tests/{ => anyimage/server}/test_model_ben2_onnx.py (90%) rename tests/{ => anyimage/server}/test_model_catalog.py (98%) rename tests/{ => anyimage/server}/test_model_download.py (98%) rename tests/{ => anyimage/server}/test_model_download_jobs.py (98%) rename tests/{ => anyimage/server}/test_model_geometry.py (95%) rename tests/{ => anyimage/server}/test_model_inference.py (98%) rename tests/{ => anyimage/server}/test_model_manager.py (99%) rename tests/{ => anyimage/server}/test_model_moge2_onnx.py (99%) rename tests/{ => anyimage/server}/test_model_onnx_runtime.py (97%) rename tests/{ => anyimage/server}/test_model_pisa_sr.py (97%) rename tests/{ => anyimage/server}/test_model_upscale_onnx.py (96%) rename tests/{ => anyimage/server}/test_server_jobs.py (99%) rename tests/{ => anyimage}/test_addon_registration.py (99%) rename tests/{ => anyimage}/test_image_menu.py (98%) rename tests/{ => anyimage}/test_job_operator.py (99%) rename tests/{ => anyimage}/test_preferences.py (99%) rename tests/{ => anyimage}/test_server_panel.py (98%) create mode 100644 tests/support/__init__.py rename tests/{blender_test_support.py => support/blender.py} (98%) rename tests/{depth_surface_test_support.py => support/depth_surface.py} (95%) rename tests/{image_object_test_support.py => support/image_objects.py} (98%) create mode 100644 tests/support/materials.py rename tests/{node_asset_test_support.py => support/nodes.py} (100%) create mode 100644 tests/support/paths.py create mode 100644 tests/support/planes.py delete mode 100644 tests/test_node_asset_contracts.py create mode 100644 tests/tools/__init__.py create mode 100644 tests/tools/models/__init__.py rename tests/{test_sync_models.py => tools/models/test_cli.py} (97%) create mode 100644 tests/tools/nodes/__init__.py rename tests/{test_node_arrangement.py => tools/nodes/test_arrangement.py} (99%) rename tests/{test_node_asset_launcher.py => tools/nodes/test_cli.py} (95%) rename tests/{test_node_asset_cutouts.py => tools/nodes/test_cutouts.py} (91%) rename tests/{test_cutout_volume.py => tools/nodes/test_depth_cutout.py} (78%) rename tests/{ => tools/nodes}/test_depth_relief_planes.py (66%) rename tests/{ => tools/nodes}/test_depth_surface_split.py (97%) rename tests/{test_image_layer_alpha.py => tools/nodes/test_image_layer.py} (69%) rename tests/{test_node_asset_plane.py => tools/nodes/test_image_plane.py} (100%) create mode 100644 tests/tools/nodes/test_inventory.py rename tests/{test_node_assets.py => tools/nodes/test_saved_layout.py} (72%) rename tests/{test_tool_commands.py => tools/test_commands.py} (80%) rename tests/{test_docs_command.py => tools/test_docs.py} (90%) rename tests/{test_extension_manifest.py => tools/test_manifest.py} (90%) rename tests/{test_extension_packaging.py => tools/test_pack.py} (99%) diff --git a/AGENTS.md b/AGENTS.md index cfce148..be5f182 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ uv run pytest uv run pack # 构建并验证 Blender 节点资产 -uv run build_node +uv run build_nodes # 准备、校验并同步 ONNX 模型到 R2 uv run sync_models diff --git a/docs/internals/development.md b/docs/internals/development.md index 7a6a5fc..ca930ae 100644 --- a/docs/internals/development.md +++ b/docs/internals/development.md @@ -27,7 +27,7 @@ Blender Stub 共用 `blender_test_support.py`;Server 和实际 Blender 几何 | --- | --- | | `uv run pack` | 生成 Windows、macOS arm64、Linux 扩展包 | | `uv run pack --prepare` | 为本机同步扩展 wheel 与 Manifest | -| `uv run build_node` | 构建、验证节点资产并运行测试 | +| `uv run build_nodes` | 构建、验证节点资产并运行测试 | | `uv run sync_models` | 准备、校验并上传模型声明的文件 | | `uv run docs -dev` | 本地预览文档 | | `uv run docs` | 通过 Mike 发布项目版本并更新 latest | diff --git a/docs/internals/node-assets.md b/docs/internals/node-assets.md index c5c07cf..8de5ee5 100644 --- a/docs/internals/node-assets.md +++ b/docs/internals/node-assets.md @@ -2,7 +2,7 @@ ## 构建节点资产 -生产节点组由 `uv run build_node` 构建到 `src/anyimage/assets/O_AnyImage.blend` +生产节点组由 `uv run build_nodes` 构建到 `src/anyimage/assets/O_AnyImage.blend` 节点组文件在 `tools/nodes/groups` @@ -40,8 +40,8 @@ 保存后,`check.py` 在独立 Blender 进程中重新加载资产并做基本几何求值。接口、默认值和详细几何行为由 `tests/` 检查。 ```bash -uv run build_node --blender "/path/to/blender" -uv run build_node --skip-tests +uv run build_nodes --blender "/path/to/blender" +uv run build_nodes --skip-tests ``` 脚本按 `--blender`、环境变量 `BLENDER_BIN`、项目 `.env`、`PATH` 的顺序查找 Blender。无法自动找到时,在项目根目录的 `.env` 中配置: diff --git a/pyproject.toml b/pyproject.toml index 3e177e8..d2e32f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ dev = [ [project.scripts] pack = "tools.pack:main" -build_node = "tools.nodes.cli:main" +build_nodes = "tools.nodes.cli:main" sync_models = "tools.models.cli:main" docs = "tools.docs:main" @@ -37,3 +37,7 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["tools"] + +[tool.pytest.ini_options] +# Keep native asserts: bpy 4.5 can crash at shutdown after assertion rewriting. +addopts = "--assert=plain" diff --git a/src/anyimage/assets/O_AnyImage.blend b/src/anyimage/assets/O_AnyImage.blend index 8425327..915e687 100644 --- a/src/anyimage/assets/O_AnyImage.blend +++ b/src/anyimage/assets/O_AnyImage.blend @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c017781329942ef15dbe77eaf3d5678bdbba0349af30e994c192ace366e0bc36 -size 4293682 +oid sha256:1a1c2a02bca6bb55221308d257eee7c2966aa35e1dc25cc897a12600ba49ae5c +size 4583729 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/anyimage/__init__.py b/tests/anyimage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/anyimage/common/__init__.py b/tests/anyimage/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/common_test_support.py b/tests/anyimage/common/support.py similarity index 92% rename from tests/common_test_support.py rename to tests/anyimage/common/support.py index 6c1ea87..7e88b7a 100644 --- a/tests/common_test_support.py +++ b/tests/anyimage/common/support.py @@ -1,16 +1,11 @@ +from tests.support.paths import PROJECT_ROOT import importlib import importlib.util import sys -from pathlib import Path from types import ModuleType, SimpleNamespace from unittest.mock import patch -from blender_test_support import mock_bpy_imports +from tests.support.blender import mock_bpy_imports -PROJECT_ROOT = Path(__file__).resolve().parents[1] - -SOURCE_ROOT = PROJECT_ROOT / "src" - -sys.path.insert(0, str(SOURCE_ROOT)) def load_common_modules(): fake_bpy = ModuleType("bpy") diff --git a/tests/test_alpha_presentation.py b/tests/anyimage/common/test_alpha_presentation.py similarity index 99% rename from tests/test_alpha_presentation.py rename to tests/anyimage/common/test_alpha_presentation.py index 78fd59d..d155df1 100644 --- a/tests/test_alpha_presentation.py +++ b/tests/anyimage/common/test_alpha_presentation.py @@ -1,5 +1,3 @@ -import sys -from pathlib import Path from types import SimpleNamespace from unittest.mock import patch import bpy @@ -7,7 +5,6 @@ import numpy as np import pytest from PIL import Image -sys.path.insert(0, str(Path(__file__).parents[1] / "src")) from anyimage.common import image as images from anyimage.operators.convert_to_plane.object import create_color_image diff --git a/tests/test_depth_calibration.py b/tests/anyimage/common/test_depth_calibration.py similarity index 97% rename from tests/test_depth_calibration.py rename to tests/anyimage/common/test_depth_calibration.py index 34789ea..bfb7b28 100644 --- a/tests/test_depth_calibration.py +++ b/tests/anyimage/common/test_depth_calibration.py @@ -1,12 +1,7 @@ import json -import sys -from pathlib import Path from types import SimpleNamespace import numpy as np -SOURCE_ROOT = Path(__file__).parents[1] / "src" - -sys.path.insert(0, str(SOURCE_ROOT)) from anyimage.common.selection import SelectionMask from anyimage.common.depth import ( diff --git a/tests/test_empty_image_edits.py b/tests/anyimage/common/test_empty_image_edits.py similarity index 99% rename from tests/test_empty_image_edits.py rename to tests/anyimage/common/test_empty_image_edits.py index e5b9a56..c4094d4 100644 --- a/tests/test_empty_image_edits.py +++ b/tests/anyimage/common/test_empty_image_edits.py @@ -1,12 +1,9 @@ -import sys -from pathlib import Path from unittest.mock import patch import bpy import numpy as np import pytest from PIL import Image -sys.path.insert(0, str(Path(__file__).parents[1] / "src")) from anyimage.common import image as images diff --git a/tests/test_image_data.py b/tests/anyimage/common/test_image_data.py similarity index 99% rename from tests/test_image_data.py rename to tests/anyimage/common/test_image_data.py index 74d8d3f..793f3ce 100644 --- a/tests/test_image_data.py +++ b/tests/anyimage/common/test_image_data.py @@ -2,8 +2,7 @@ import tempfile import unittest from pathlib import Path from types import SimpleNamespace -from common_test_support import load_common_modules -PROJECT_ROOT=Path(__file__).resolve().parents[1] +from tests.anyimage.common.support import load_common_modules class ImageDataTest(unittest.TestCase): @classmethod diff --git a/tests/test_image_edit_tool.py b/tests/anyimage/common/test_image_edit_tool.py similarity index 88% rename from tests/test_image_edit_tool.py rename to tests/anyimage/common/test_image_edit_tool.py index 8f16a33..7c045da 100644 --- a/tests/test_image_edit_tool.py +++ b/tests/anyimage/common/test_image_edit_tool.py @@ -3,23 +3,11 @@ from pathlib import Path from types import SimpleNamespace from unittest.mock import patch -PROJECT_ROOT = Path(__file__).resolve().parents[1] - -from blender_test_support import BlenderTestCase +from tests.support.blender import BlenderTestCase class ImageEditToolTest(BlenderTestCase): - def test_image_edit_tools_share_the_plain_pixel_edit_result_creator(self): - self.assertIs( - self.edit_mask.create_image_edit_result, - self.image_data.create_image_edit_result, - ) - self.assertIs( - self.rectify.create_image_edit_result, - self.image_data.create_image_edit_result, - ) - self.assertFalse(hasattr(self.image_data, "create_pixel_result_image")) def test_packed_image_export_does_not_use_scene_render_transform(self): @@ -222,12 +210,3 @@ class ImageEditToolTest(BlenderTestCase): "Select an active Image Empty", ): self.image_interaction.active_image_edit_source(context) - - - def test_blender_image_exports_do_not_use_scene_render_color_management(self): - source_root = PROJECT_ROOT / "src" / "anyimage" - offenders = [] - for path in source_root.rglob("*.py"): - if ".save_render(" in path.read_text(encoding="utf-8"): - offenders.append(path.relative_to(source_root)) - self.assertEqual(offenders, []) diff --git a/tests/test_image_materials.py b/tests/anyimage/common/test_image_materials.py similarity index 97% rename from tests/test_image_materials.py rename to tests/anyimage/common/test_image_materials.py index 01b762e..b1171b2 100644 --- a/tests/test_image_materials.py +++ b/tests/anyimage/common/test_image_materials.py @@ -1,21 +1,14 @@ -import sys -from pathlib import Path from types import SimpleNamespace import bpy import numpy as np -SOURCE_ROOT = Path(__file__).parents[1] / "src" - -sys.path.insert(0, str(SOURCE_ROOT)) - - from anyimage.common import image as image_data from anyimage.common import material as material_data from anyimage.operators.convert_to_plane import object as plane_object from anyimage.operators.cutout_tool import object as cutout_object from anyimage.operators.cutout_tool import shape as cutout_shape -from image_object_test_support import _create_cutout_shape, _group_node, _inputs +from tests.support.image_objects import _create_cutout_shape, _group_node, _inputs def test_image_layer_switches_between_tangent_and_object_space_normal(): diff --git a/tests/anyimage/common/test_material_alpha.py b/tests/anyimage/common/test_material_alpha.py new file mode 100644 index 0000000..8a8c7cb --- /dev/null +++ b/tests/anyimage/common/test_material_alpha.py @@ -0,0 +1,27 @@ +import bpy +import pytest +from types import SimpleNamespace +from anyimage.common import material +from tests.support.materials import image_layer + + +@pytest.mark.parametrize("enabled", [False, True]) +@pytest.mark.parametrize("shadeless", [False, True]) +@pytest.mark.parametrize("view", ["AgX", "Unknown"]) +def test_material_alpha_strength_follows_preference(image_layer, monkeypatch, enabled, shadeless, view): + monkeypatch.setattr(material, "material_node_group", lambda depth_plane=False: image_layer) + monkeypatch.setattr(material, "configured_material_view_adaptation", lambda: enabled) + color = bpy.data.images.new("Alpha edge", width=2, height=2, alpha=True) + scene = SimpleNamespace(view_settings=SimpleNamespace(view_transform=view)) + result = material.create_image_material(color, color, shadeless=shadeless, scene=scene) + layer = next(node for node in result.node_tree.nodes if node.type == "GROUP" and node.node_tree == image_layer) + assert layer.inputs["Alpha Fix"].default_value == float(enabled) + alpha_link = layer.outputs["Alpha"].links[0] + assert alpha_link.to_node.type == ("MIX_SHADER" if shadeless else "BSDF_PRINCIPLED") + assert alpha_link.to_socket.name == ("Fac" if shadeless else "Alpha") + layer.inputs["Alpha Fix"].default_value = 0.5 + monkeypatch.setattr(material, "configured_material_view_adaptation", lambda: not enabled) + second = material.create_image_material(color, color, shadeless=shadeless, scene=scene) + second_layer = next(node for node in second.node_tree.nodes if node.type == "GROUP" and node.node_tree == image_layer) + assert second_layer.inputs["Alpha Fix"].default_value == float(not enabled) + assert layer.inputs["Alpha Fix"].default_value == 0.5 diff --git a/tests/test_material_settings.py b/tests/anyimage/common/test_material_settings.py similarity index 94% rename from tests/test_material_settings.py rename to tests/anyimage/common/test_material_settings.py index e060f64..d300505 100644 --- a/tests/test_material_settings.py +++ b/tests/anyimage/common/test_material_settings.py @@ -1,8 +1,6 @@ import unittest -from pathlib import Path from types import SimpleNamespace -from common_test_support import load_common_modules -PROJECT_ROOT=Path(__file__).resolve().parents[1] +from tests.anyimage.common.support import load_common_modules class MaterialSettingsTest(unittest.TestCase): @classmethod diff --git a/tests/test_material_view_adaptation.py b/tests/anyimage/common/test_material_view_adaptation.py similarity index 99% rename from tests/test_material_view_adaptation.py rename to tests/anyimage/common/test_material_view_adaptation.py index 1491a58..65fabe0 100644 --- a/tests/test_material_view_adaptation.py +++ b/tests/anyimage/common/test_material_view_adaptation.py @@ -1,5 +1,3 @@ -import sys -from pathlib import Path from types import SimpleNamespace from unittest.mock import patch import bpy @@ -7,7 +5,6 @@ import numpy as np import pytest from PIL import Image -sys.path.insert(0, str(Path(__file__).parents[1] / "src")) from anyimage import preferences from anyimage.common import image as images diff --git a/tests/test_modifier_inputs.py b/tests/anyimage/common/test_modifier_inputs.py similarity index 93% rename from tests/test_modifier_inputs.py rename to tests/anyimage/common/test_modifier_inputs.py index e7da1f2..7b37c2d 100644 --- a/tests/test_modifier_inputs.py +++ b/tests/anyimage/common/test_modifier_inputs.py @@ -1,8 +1,6 @@ import unittest -from pathlib import Path from types import SimpleNamespace -from common_test_support import load_common_modules -PROJECT_ROOT=Path(__file__).resolve().parents[1] +from tests.anyimage.common.support import load_common_modules class ModifierInputsTest(unittest.TestCase): @classmethod diff --git a/tests/test_node_loading.py b/tests/anyimage/common/test_node_loading.py similarity index 74% rename from tests/test_node_loading.py rename to tests/anyimage/common/test_node_loading.py index ff3cbd7..10c6dba 100644 --- a/tests/test_node_loading.py +++ b/tests/anyimage/common/test_node_loading.py @@ -1,7 +1,6 @@ +from tests.support.paths import PROJECT_ROOT import unittest -from pathlib import Path -from common_test_support import load_common_modules -PROJECT_ROOT=Path(__file__).resolve().parents[1] +from tests.anyimage.common.support import load_common_modules class NodeLoadingTest(unittest.TestCase): @classmethod diff --git a/tests/test_selection.py b/tests/anyimage/common/test_selection.py similarity index 97% rename from tests/test_selection.py rename to tests/anyimage/common/test_selection.py index acac3b2..4239492 100644 --- a/tests/test_selection.py +++ b/tests/anyimage/common/test_selection.py @@ -1,11 +1,7 @@ import json -import sys -from pathlib import Path import numpy as np import pytest -SOURCE_ROOT = Path(__file__).parents[1] / "src" -sys.path.insert(0, str(SOURCE_ROOT)) from anyimage.common import selection diff --git a/tests/anyimage/operators/__init__.py b/tests/anyimage/operators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_ai_image_operators.py b/tests/anyimage/operators/test_ai_image_operators.py similarity index 99% rename from tests/test_ai_image_operators.py rename to tests/anyimage/operators/test_ai_image_operators.py index 8e9304a..602e937 100644 --- a/tests/test_ai_image_operators.py +++ b/tests/anyimage/operators/test_ai_image_operators.py @@ -1,7 +1,7 @@ from pathlib import Path from types import SimpleNamespace from unittest.mock import Mock, patch -from blender_test_support import BlenderTestCase +from tests.support.blender import BlenderTestCase class AiImageOperatorsTest(BlenderTestCase): diff --git a/tests/test_ai_setup.py b/tests/anyimage/operators/test_ai_setup.py similarity index 99% rename from tests/test_ai_setup.py rename to tests/anyimage/operators/test_ai_setup.py index f4a3777..350e186 100644 --- a/tests/test_ai_setup.py +++ b/tests/anyimage/operators/test_ai_setup.py @@ -3,7 +3,7 @@ import tempfile from pathlib import Path from types import SimpleNamespace from unittest.mock import patch -from blender_test_support import BlenderTestCase +from tests.support.blender import BlenderTestCase class AiSetupTest(BlenderTestCase): diff --git a/tests/test_balloon_profiles.py b/tests/anyimage/operators/test_balloon_profiles.py similarity index 95% rename from tests/test_balloon_profiles.py rename to tests/anyimage/operators/test_balloon_profiles.py index 37da303..2904e92 100644 --- a/tests/test_balloon_profiles.py +++ b/tests/anyimage/operators/test_balloon_profiles.py @@ -1,14 +1,6 @@ - - -import sys -from pathlib import Path from unittest.mock import patch import numpy as np -SOURCE_ROOT = Path(__file__).parents[1] / "src" - -sys.path.insert(0, str(SOURCE_ROOT)) - from anyimage.operators.cutout_tool import geometry as cutout_geometry from anyimage.operators.cutout_tool.geometry import poisson_balloon_profile diff --git a/tests/test_brush_strokes.py b/tests/anyimage/operators/test_brush_strokes.py similarity index 98% rename from tests/test_brush_strokes.py rename to tests/anyimage/operators/test_brush_strokes.py index 6e84156..31efa1b 100644 --- a/tests/test_brush_strokes.py +++ b/tests/anyimage/operators/test_brush_strokes.py @@ -1,11 +1,9 @@ import sys -from pathlib import Path from types import SimpleNamespace from unittest.mock import Mock, patch import numpy as np import pytest -sys.path.insert(0, str(Path(__file__).parents[1] / "src")) from anyimage.common import selection, viewport from anyimage.operators import mask_tool as mask diff --git a/tests/test_clipboard_image.py b/tests/anyimage/operators/test_clipboard_image.py similarity index 99% rename from tests/test_clipboard_image.py rename to tests/anyimage/operators/test_clipboard_image.py index 9535d37..31f2dde 100644 --- a/tests/test_clipboard_image.py +++ b/tests/anyimage/operators/test_clipboard_image.py @@ -1,16 +1,12 @@ +from tests.support.paths import PROJECT_ROOT import importlib import struct import sys import tempfile import unittest -from pathlib import Path from types import ModuleType, SimpleNamespace from unittest.mock import Mock, patch -from blender_test_support import mock_bpy_imports - - -PROJECT_ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(PROJECT_ROOT / "src")) +from tests.support.blender import mock_bpy_imports class ClipboardImageTest(unittest.TestCase): diff --git a/tests/anyimage/operators/test_cutout_depth.py b/tests/anyimage/operators/test_cutout_depth.py new file mode 100644 index 0000000..708aa0c --- /dev/null +++ b/tests/anyimage/operators/test_cutout_depth.py @@ -0,0 +1,34 @@ +import bpy +import numpy as np +import pytest +from anyimage.operators.cutout_tool.object import load_cutout_node_group +from anyimage.operators.cutout_tool.operators import normal_mode_for_shape +from anyimage.operators.cutout_tool.geometry import refine_balloon_triangles, poisson_balloon_profile + + +@pytest.mark.parametrize("enabled", [False, True]) +def test_depth_presets_always_generate_the_correct_normal_space(enabled): + assert normal_mode_for_shape("DEPTH_SOLID", enabled) == "OBJECT" + assert normal_mode_for_shape("DEPTH_BALLOON", enabled) == "TANGENT" + for shape in ("FLAT", "SOLID"): + assert normal_mode_for_shape(shape, enabled) == ("TANGENT" if enabled else "NONE") + + +def test_new_cutout_does_not_reuse_a_saved_wrapper(): + bpy.ops.wm.read_factory_settings(use_empty=True) + saved = bpy.data.node_groups.new("O Image Cutout", "GeometryNodeTree") + saved.interface.new_socket(name="Shape", in_out="INPUT", socket_type="NodeSocketMenu") + saved.use_fake_user = True + current = load_cutout_node_group("SOLID") + assert current != saved and current.get("anyimage_asset_revision") == 11 + assert saved in bpy.data.node_groups.values() + assert current == load_cutout_node_group("FLAT") + assert any(item.name == "Shape" for item in saved.interface.items_tree) + + +def test_refined_boundary_ears_have_interior_balloon_samples(): + points = np.array([(0, 0), (1, 0), (1, 1), (0, 1)], dtype=float) + points, faces, boundary = refine_balloon_triangles(points, [(0, 1, 2), (0, 2, 3)], np.ones(4, bool)) + profile = poisson_balloon_profile(points, faces, boundary) + assert np.all(profile[boundary] == 0) + assert all(any(profile[index] > 0 for index in face) for face in faces) diff --git a/tests/test_cutout_fine_mesh.py b/tests/anyimage/operators/test_cutout_fine_mesh.py similarity index 99% rename from tests/test_cutout_fine_mesh.py rename to tests/anyimage/operators/test_cutout_fine_mesh.py index 131f6b4..7f49536 100644 --- a/tests/test_cutout_fine_mesh.py +++ b/tests/anyimage/operators/test_cutout_fine_mesh.py @@ -1,12 +1,9 @@ -import sys -from pathlib import Path import numpy as np import pytest from scipy.sparse import coo_matrix from scipy.sparse.csgraph import connected_components from scipy.spatial import cKDTree -sys.path.insert(0, str(Path(__file__).parents[1] / "src")) from anyimage.operators.cutout_tool import geometry as g from anyimage.operators.cutout_tool import fine_mesh as fine diff --git a/tests/test_cutout_geometry.py b/tests/anyimage/operators/test_cutout_geometry.py similarity index 98% rename from tests/test_cutout_geometry.py rename to tests/anyimage/operators/test_cutout_geometry.py index 9a2cf3a..284dd02 100644 --- a/tests/test_cutout_geometry.py +++ b/tests/anyimage/operators/test_cutout_geometry.py @@ -1,15 +1,9 @@ - import builtins import unittest -import sys -from pathlib import Path from types import SimpleNamespace from unittest.mock import patch import numpy as np -SOURCE_ROOT = Path(__file__).parents[1] / "src" - -sys.path.insert(0, str(SOURCE_ROOT)) from anyimage.common.selection import SelectionPath, rasterize_selection_path from anyimage.operators.cutout_tool import geometry as cutout_geometry diff --git a/tests/test_cutout_interaction.py b/tests/anyimage/operators/test_cutout_interaction.py similarity index 98% rename from tests/test_cutout_interaction.py rename to tests/anyimage/operators/test_cutout_interaction.py index 34d5656..30d12e5 100644 --- a/tests/test_cutout_interaction.py +++ b/tests/anyimage/operators/test_cutout_interaction.py @@ -1,10 +1,6 @@ import json -import sys -from pathlib import Path from types import SimpleNamespace -SOURCE_ROOT = Path(__file__).parents[1] / "src" -sys.path.insert(0, str(SOURCE_ROOT)) from anyimage.common.selection import SelectionPath from anyimage.common import viewport diff --git a/tests/test_cutout_objects.py b/tests/anyimage/operators/test_cutout_objects.py similarity index 99% rename from tests/test_cutout_objects.py rename to tests/anyimage/operators/test_cutout_objects.py index a1939a9..6b8fe76 100644 --- a/tests/test_cutout_objects.py +++ b/tests/anyimage/operators/test_cutout_objects.py @@ -1,5 +1,3 @@ -import sys -from pathlib import Path from types import SimpleNamespace from unittest.mock import patch import bpy @@ -7,9 +5,6 @@ import numpy as np import pytest from mathutils import Matrix, Vector -SOURCE_ROOT = Path(__file__).parents[1] / "src" - -sys.path.insert(0, str(SOURCE_ROOT)) from anyimage.common import depth as depth_data from anyimage.common import image as image_data @@ -24,7 +19,7 @@ from anyimage.server.geometry.depth_texture import ( write_depth_texture, ) from anyimage.server.models.geometry import GeometryFrame -from image_object_test_support import _add_cutout_modifier, _create_cutout_shape, _depth_image, _depth_metadata, _depth_surface_step_object, _evaluated_positions, _evaluated_topology, _group_node, _inputs +from tests.support.image_objects import _add_cutout_modifier, _create_cutout_shape, _depth_image, _depth_metadata, _depth_surface_step_object, _evaluated_positions, _evaluated_topology, _group_node, _inputs def test_object_normal_image_follows_cutout_inward_axis(): diff --git a/tests/test_cutout_operators.py b/tests/anyimage/operators/test_cutout_operators.py similarity index 99% rename from tests/test_cutout_operators.py rename to tests/anyimage/operators/test_cutout_operators.py index b9781e4..bb4905c 100644 --- a/tests/test_cutout_operators.py +++ b/tests/anyimage/operators/test_cutout_operators.py @@ -3,7 +3,7 @@ import json from pathlib import Path from types import SimpleNamespace from unittest.mock import patch -from blender_test_support import BlenderTestCase +from tests.support.blender import BlenderTestCase class CutoutOperatorsTest(BlenderTestCase): diff --git a/tests/test_frame_antialiasing.py b/tests/anyimage/operators/test_frame_antialiasing.py similarity index 98% rename from tests/test_frame_antialiasing.py rename to tests/anyimage/operators/test_frame_antialiasing.py index 5f39b0c..d75ca05 100644 --- a/tests/test_frame_antialiasing.py +++ b/tests/anyimage/operators/test_frame_antialiasing.py @@ -1,10 +1,7 @@ -import sys -from pathlib import Path from unittest.mock import patch import numpy as np import pytest -sys.path.insert(0, str(Path(__file__).parents[1] / "src")) from anyimage.common.image import premultiplied_rgba from anyimage.operators import frame_tool as frame diff --git a/tests/test_frame_projection.py b/tests/anyimage/operators/test_frame_projection.py similarity index 93% rename from tests/test_frame_projection.py rename to tests/anyimage/operators/test_frame_projection.py index 2c15ddd..87850ed 100644 --- a/tests/test_frame_projection.py +++ b/tests/anyimage/operators/test_frame_projection.py @@ -1,9 +1,6 @@ -import sys -from pathlib import Path import numpy as np import pytest -sys.path.insert(0, str(Path(__file__).parents[1] / "src")) from anyimage.common.selection import clip_polygon_halfplanes from anyimage.operators import frame_tool as frame diff --git a/tests/test_frame_tool.py b/tests/anyimage/operators/test_frame_tool.py similarity index 99% rename from tests/test_frame_tool.py rename to tests/anyimage/operators/test_frame_tool.py index 7268eb9..d487182 100644 --- a/tests/test_frame_tool.py +++ b/tests/anyimage/operators/test_frame_tool.py @@ -1,6 +1,6 @@ from types import SimpleNamespace from unittest.mock import Mock, patch -from blender_test_support import BlenderTestCase +from tests.support.blender import BlenderTestCase class FrameToolTest(BlenderTestCase): diff --git a/tests/test_image_edit_integration.py b/tests/anyimage/operators/test_image_edit_integration.py similarity index 98% rename from tests/test_image_edit_integration.py rename to tests/anyimage/operators/test_image_edit_integration.py index 65afff9..addf0ed 100644 --- a/tests/test_image_edit_integration.py +++ b/tests/anyimage/operators/test_image_edit_integration.py @@ -1,14 +1,8 @@ -import sys -from pathlib import Path from types import SimpleNamespace import bpy import numpy as np from mathutils import Matrix -SOURCE_ROOT = Path(__file__).parents[1] / "src" - -sys.path.insert(0, str(SOURCE_ROOT)) - from anyimage.common import image as image_data from anyimage.common.selection import SelectionMask diff --git a/tests/test_mask_tool.py b/tests/anyimage/operators/test_mask_tool.py similarity index 99% rename from tests/test_mask_tool.py rename to tests/anyimage/operators/test_mask_tool.py index 375ca38..7fa42aa 100644 --- a/tests/test_mask_tool.py +++ b/tests/anyimage/operators/test_mask_tool.py @@ -1,7 +1,7 @@ import sys from types import ModuleType, SimpleNamespace from unittest.mock import Mock, patch -from blender_test_support import BlenderTestCase +from tests.support.blender import BlenderTestCase class MaskToolTest(BlenderTestCase): diff --git a/tests/test_mask_undo.py b/tests/anyimage/operators/test_mask_undo.py similarity index 97% rename from tests/test_mask_undo.py rename to tests/anyimage/operators/test_mask_undo.py index c4d5f79..2a33147 100644 --- a/tests/test_mask_undo.py +++ b/tests/anyimage/operators/test_mask_undo.py @@ -1,11 +1,8 @@ -import sys -from pathlib import Path from unittest.mock import patch import bpy import numpy as np import pytest -sys.path.insert(0, str(Path(__file__).parents[1] / "src")) from anyimage.common import image, viewport from anyimage.operators import mask_tool as mask diff --git a/tests/anyimage/operators/test_plane_conversion.py b/tests/anyimage/operators/test_plane_conversion.py new file mode 100644 index 0000000..195a91d --- /dev/null +++ b/tests/anyimage/operators/test_plane_conversion.py @@ -0,0 +1,69 @@ +import bpy +import numpy as np +import pytest +from tests.support.planes import surface + + +def test_conversion_preserves_offset_world_placement_and_initializes_inputs(surface, monkeypatch): + from anyimage.operators.convert_to_plane import object as conversion + from anyimage.common.image import image_empty_bounds + from mathutils import Matrix, Vector + + obj, _, _, inputs, image = surface + group = obj.modifiers[0].node_group + monkeypatch.setattr(conversion, "load_node_group", lambda name: group) + source = bpy.data.objects.new("Offset Image", None) + source.empty_display_type = "IMAGE" + source.data = image + source.empty_display_size = 4 + source.empty_image_offset = (-0.2, -0.8) + source.matrix_world = Matrix.Translation((3, -2, 5)) @ Matrix.Rotation(0.7, 4, "Y") @ Matrix.Diagonal((2, 3, 1, 1)) + bpy.context.collection.objects.link(source) + bounds = image_empty_bounds(source) + matrix = source.matrix_world.copy() + material = bpy.data.materials.new("Conversion") + metadata = {"image_size": (16, 8), "reference_depth": 2.0, + "intrinsics": ((4, 0, 5), (0, 4, 3), (0, 0, 1))} + result = conversion.create_depth_plane_object( + bpy.context, source, 2, material, image, metadata, "DEPTH") + assert result.name == "Offset Image" + assert result["anyimage_mesh_shape"] == "DEPTH_PLANE" + assert bpy.context.view_layer.objects.active == result + expected = sorted(tuple(matrix @ Vector((x, y, 0))) + for x in bounds[:2] for y in bounds[2:]) + actual = sorted(tuple(result.matrix_world @ v.co) for v in result.data.vertices) + np.testing.assert_allclose(actual, expected, atol=1e-6) + center = matrix @ Vector(((bounds[0] + bounds[1]) / 2, (bounds[2] + bounds[3]) / 2, 0)) + np.testing.assert_allclose(result.matrix_world.translation, center, atol=1e-6) + modifier = result.modifiers[0] + assert modifier[inputs["Thickness"].identifier] == 0 + assert modifier[inputs["Depth Image"].identifier] == image + assert modifier[inputs["Valid Only"].identifier] is True + assert modifier[inputs["Reference Depth"].identifier] == pytest.approx( + 2 * modifier[inputs["Uniform Scale"].identifier]) + assert result.data.materials[0] == material + + +def test_failed_modifier_initialization_removes_partial_object(surface, monkeypatch): + from anyimage.operators.convert_to_plane import object as conversion + + obj, _, _, _, image = surface + monkeypatch.setattr(conversion, "load_node_group", lambda name: obj.modifiers[0].node_group) + source = bpy.data.objects.new("Source", None) + source.empty_display_type = "IMAGE" + source.data = image + bpy.context.collection.objects.link(source) + material = bpy.data.materials.new("Unused") + before_objects, before_meshes = set(bpy.data.objects), set(bpy.data.meshes) + + def fail(*args): + raise RuntimeError("input failed") + + monkeypatch.setattr(conversion, "set_modifier_input", fail) + metadata = {"image_size": (16, 8), "reference_depth": 2, + "intrinsics": ((4, 0, 5), (0, 4, 3), (0, 0, 1))} + with pytest.raises(RuntimeError, match="input failed"): + conversion.create_depth_plane_object(bpy.context, source, 2, material, image, metadata, "DEPTH") + assert set(bpy.data.objects) == before_objects + assert set(bpy.data.meshes) == before_meshes + assert material.users == 0 diff --git a/tests/test_plane_objects.py b/tests/anyimage/operators/test_plane_objects.py similarity index 97% rename from tests/test_plane_objects.py rename to tests/anyimage/operators/test_plane_objects.py index d6d3e08..ccf3c41 100644 --- a/tests/test_plane_objects.py +++ b/tests/anyimage/operators/test_plane_objects.py @@ -1,21 +1,15 @@ -import sys -from pathlib import Path from unittest.mock import patch import bpy import numpy as np from mathutils import Matrix, Vector -SOURCE_ROOT = Path(__file__).parents[1] / "src" - -sys.path.insert(0, str(SOURCE_ROOT)) - from anyimage.common import image as image_data from anyimage.common import object as object_data from anyimage.operators.clipboard_image import actions as clipboard_actions from anyimage.operators.convert_to_plane import object as plane_object from anyimage.operators.cutout_tool import shape as cutout_shape -from image_object_test_support import _depth_image, _depth_metadata, _evaluated_positions, _inputs +from tests.support.image_objects import _depth_image, _depth_metadata, _evaluated_positions, _inputs from tools.nodes.groups.image_plane import build_image_plane_group from tools.nodes.groups.image_depth_plane import build_image_depth_plane_group from tools.nodes.groups.image_relief_plane import build_image_relief_plane_group diff --git a/tests/test_plane_operators.py b/tests/anyimage/operators/test_plane_operators.py similarity index 99% rename from tests/test_plane_operators.py rename to tests/anyimage/operators/test_plane_operators.py index 9a8a107..dfeb855 100644 --- a/tests/test_plane_operators.py +++ b/tests/anyimage/operators/test_plane_operators.py @@ -1,7 +1,7 @@ from pathlib import Path from types import SimpleNamespace from unittest.mock import Mock, patch -from blender_test_support import BlenderTestCase +from tests.support.blender import BlenderTestCase class PlaneOperatorsTest(BlenderTestCase): diff --git a/tests/test_rectify_tool.py b/tests/anyimage/operators/test_rectify_tool.py similarity index 99% rename from tests/test_rectify_tool.py rename to tests/anyimage/operators/test_rectify_tool.py index 6cb6943..33f8b30 100644 --- a/tests/test_rectify_tool.py +++ b/tests/anyimage/operators/test_rectify_tool.py @@ -1,5 +1,5 @@ from types import SimpleNamespace -from blender_test_support import BlenderTestCase +from tests.support.blender import BlenderTestCase class RectifyToolTest(BlenderTestCase): diff --git a/tests/test_upscale_undo.py b/tests/anyimage/operators/test_upscale_undo.py similarity index 95% rename from tests/test_upscale_undo.py rename to tests/anyimage/operators/test_upscale_undo.py index 10f4633..4be5b99 100644 --- a/tests/test_upscale_undo.py +++ b/tests/anyimage/operators/test_upscale_undo.py @@ -1,12 +1,9 @@ -import sys -from pathlib import Path from types import SimpleNamespace from unittest.mock import patch import bpy import numpy as np from PIL import Image -sys.path.insert(0, str(Path(__file__).parents[1] / "src")) from anyimage.operators.upscale import UpscaleImage from anyimage.common.image import image_pixels diff --git a/tests/anyimage/server/__init__.py b/tests/anyimage/server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/server_runtime_test_support.py b/tests/anyimage/server/runtime_support.py similarity index 97% rename from tests/server_runtime_test_support.py rename to tests/anyimage/server/runtime_support.py index 69b83df..03d5d4c 100644 --- a/tests/server_runtime_test_support.py +++ b/tests/anyimage/server/runtime_support.py @@ -1,3 +1,4 @@ +from tests.support.paths import PROJECT_ROOT import importlib.util import sys import tempfile @@ -6,8 +7,6 @@ import unittest from pathlib import Path -PROJECT_ROOT = Path(__file__).resolve().parents[1] - def load_server_module(): server_directory = PROJECT_ROOT / "src" / "anyimage" / "server" specification = importlib.util.spec_from_file_location( diff --git a/tests/server_test_support.py b/tests/anyimage/server/support.py similarity index 98% rename from tests/server_test_support.py rename to tests/anyimage/server/support.py index 89bedc8..164fd4a 100644 --- a/tests/server_test_support.py +++ b/tests/anyimage/server/support.py @@ -1,11 +1,9 @@ +from tests.support.paths import PROJECT_ROOT import sys from pathlib import Path from types import SimpleNamespace -PROJECT_ROOT = Path(__file__).resolve().parents[1] - - def load_server_module(name): addon_directory = PROJECT_ROOT / "src" / "anyimage" sys.path.insert(0, str(addon_directory)) diff --git a/tests/test_edge_depth.py b/tests/anyimage/server/test_edge_depth.py similarity index 98% rename from tests/test_edge_depth.py rename to tests/anyimage/server/test_edge_depth.py index eec6f29..d9fe59a 100644 --- a/tests/test_edge_depth.py +++ b/tests/anyimage/server/test_edge_depth.py @@ -1,12 +1,9 @@ -import sys -from pathlib import Path from types import SimpleNamespace from unittest.mock import patch import numpy as np import pytest from PIL import Image -sys.path.insert(0, str(Path(__file__).parents[1] / "src" / "anyimage")) from server.geometry import prediction_artifacts as artifacts from server.geometry.edge_depth import extend_edge_depth diff --git a/tests/test_geometry_artifacts.py b/tests/anyimage/server/test_geometry_artifacts.py similarity index 99% rename from tests/test_geometry_artifacts.py rename to tests/anyimage/server/test_geometry_artifacts.py index 4b60a90..1fa9757 100644 --- a/tests/test_geometry_artifacts.py +++ b/tests/anyimage/server/test_geometry_artifacts.py @@ -3,7 +3,7 @@ import json import sys from types import SimpleNamespace from unittest.mock import patch -from server_runtime_test_support import ServerTestCase +from tests.anyimage.server.runtime_support import ServerTestCase class GeometryArtifactsTest(ServerTestCase): def test_depth_alpha_multiplies_coverage_without_changing_geometry(self): diff --git a/tests/test_job_server.py b/tests/anyimage/server/test_job_server.py similarity index 98% rename from tests/test_job_server.py rename to tests/anyimage/server/test_job_server.py index 636126c..9a0ed3b 100644 --- a/tests/test_job_server.py +++ b/tests/anyimage/server/test_job_server.py @@ -1,11 +1,8 @@ - - - import threading import time from types import SimpleNamespace from unittest.mock import patch -from server_runtime_test_support import ServerTestCase +from tests.anyimage.server.runtime_support import ServerTestCase class JobServerTest(ServerTestCase): def test_dedicated_server_runs_only_one_job_at_a_time(self): @@ -171,4 +168,3 @@ class JobServerTest(ServerTestCase): "generate-cutout-artifacts", }, ) - diff --git a/tests/test_media_input.py b/tests/anyimage/server/test_media_input.py similarity index 98% rename from tests/test_media_input.py rename to tests/anyimage/server/test_media_input.py index 1e8a59c..71932ed 100644 --- a/tests/test_media_input.py +++ b/tests/anyimage/server/test_media_input.py @@ -1,7 +1,7 @@ import tempfile import unittest from pathlib import Path -from server_test_support import load_server_module, _make_fake_av +from tests.anyimage.server.support import load_server_module, _make_fake_av class MediaInputTest(unittest.TestCase): diff --git a/tests/test_model_ben2_onnx.py b/tests/anyimage/server/test_model_ben2_onnx.py similarity index 90% rename from tests/test_model_ben2_onnx.py rename to tests/anyimage/server/test_model_ben2_onnx.py index c4c9676..9c5a078 100644 --- a/tests/test_model_ben2_onnx.py +++ b/tests/anyimage/server/test_model_ben2_onnx.py @@ -1,22 +1,11 @@ -import sys import unittest -from pathlib import Path from types import SimpleNamespace from unittest.mock import patch import numpy as np from PIL import Image -SOURCE_DIR = ( - Path(__file__).resolve().parents[1] - / "src" - / "anyimage" - / "server" - / "models" -) -sys.path.insert(0, str(SOURCE_DIR)) - -import onnx_ben2 +from server.models import onnx_ben2 class FakeSession: diff --git a/tests/test_model_catalog.py b/tests/anyimage/server/test_model_catalog.py similarity index 98% rename from tests/test_model_catalog.py rename to tests/anyimage/server/test_model_catalog.py index fe11b71..daf4752 100644 --- a/tests/test_model_catalog.py +++ b/tests/anyimage/server/test_model_catalog.py @@ -1,9 +1,6 @@ +from tests.support.paths import PROJECT_ROOT import importlib.util import unittest -from pathlib import Path - - -PROJECT_ROOT = Path(__file__).resolve().parents[1] def load_module(name, relative_path): diff --git a/tests/test_model_download.py b/tests/anyimage/server/test_model_download.py similarity index 98% rename from tests/test_model_download.py rename to tests/anyimage/server/test_model_download.py index 7b6a107..2286f5d 100644 --- a/tests/test_model_download.py +++ b/tests/anyimage/server/test_model_download.py @@ -1,3 +1,4 @@ +from tests.support.paths import PROJECT_ROOT import os import sys import unittest @@ -7,9 +8,6 @@ from types import SimpleNamespace from unittest.mock import patch -PROJECT_ROOT = Path(__file__).resolve().parents[1] - - def load_download_module(): addon_directory = PROJECT_ROOT / "src" / "anyimage" sys.path.insert(0, str(addon_directory)) diff --git a/tests/test_model_download_jobs.py b/tests/anyimage/server/test_model_download_jobs.py similarity index 98% rename from tests/test_model_download_jobs.py rename to tests/anyimage/server/test_model_download_jobs.py index e6f75f0..ca78c81 100644 --- a/tests/test_model_download_jobs.py +++ b/tests/anyimage/server/test_model_download_jobs.py @@ -5,7 +5,7 @@ import unittest from pathlib import Path from types import ModuleType, SimpleNamespace from unittest.mock import patch -from server_test_support import load_server_module +from tests.anyimage.server.support import load_server_module class ModelDownloadJobsTest(unittest.TestCase): diff --git a/tests/test_model_geometry.py b/tests/anyimage/server/test_model_geometry.py similarity index 95% rename from tests/test_model_geometry.py rename to tests/anyimage/server/test_model_geometry.py index 6791cc8..7b986a9 100644 --- a/tests/test_model_geometry.py +++ b/tests/anyimage/server/test_model_geometry.py @@ -1,14 +1,6 @@ - - -import sys -from pathlib import Path from unittest.mock import patch import numpy as np -SOURCE_ROOT = Path(__file__).parents[1] / "src" - -sys.path.insert(0, str(SOURCE_ROOT)) - from anyimage.server.models import moge2 as moge2_model from anyimage.server.models.geometry import GeometryFrame diff --git a/tests/test_model_inference.py b/tests/anyimage/server/test_model_inference.py similarity index 98% rename from tests/test_model_inference.py rename to tests/anyimage/server/test_model_inference.py index 3628766..b00bcf4 100644 --- a/tests/test_model_inference.py +++ b/tests/anyimage/server/test_model_inference.py @@ -6,7 +6,7 @@ from unittest.mock import patch import numpy as np import pytest from PIL import Image -from server_test_support import load_server_module, FakeModels +from tests.anyimage.server.support import load_server_module, FakeModels class ModelInferenceTest(unittest.TestCase): diff --git a/tests/test_model_manager.py b/tests/anyimage/server/test_model_manager.py similarity index 99% rename from tests/test_model_manager.py rename to tests/anyimage/server/test_model_manager.py index 11562ab..b85f038 100644 --- a/tests/test_model_manager.py +++ b/tests/anyimage/server/test_model_manager.py @@ -1,11 +1,9 @@ - - import sys import tempfile from pathlib import Path from types import ModuleType, SimpleNamespace from unittest.mock import patch -from server_runtime_test_support import ServerTestCase +from tests.anyimage.server.runtime_support import ServerTestCase class ModelManagerTest(ServerTestCase): def test_required_model_download_skips_ready_model(self): @@ -182,5 +180,3 @@ class ModelManagerTest(ServerTestCase): lambda: cache.get_upscale("upscale", "cuda"), "release_upscale", ) - - diff --git a/tests/test_model_moge2_onnx.py b/tests/anyimage/server/test_model_moge2_onnx.py similarity index 99% rename from tests/test_model_moge2_onnx.py rename to tests/anyimage/server/test_model_moge2_onnx.py index 9d70346..f5b655f 100644 --- a/tests/test_model_moge2_onnx.py +++ b/tests/anyimage/server/test_model_moge2_onnx.py @@ -1,3 +1,4 @@ +from tests.support.paths import PROJECT_ROOT import importlib.util import sys import unittest @@ -7,9 +8,6 @@ from unittest.mock import patch import numpy as np -PROJECT_ROOT = Path(__file__).resolve().parents[1] - - def load_module(): anyimage = ModuleType("anyimage") anyimage.__path__ = [str(PROJECT_ROOT / "src" / "anyimage")] diff --git a/tests/test_model_onnx_runtime.py b/tests/anyimage/server/test_model_onnx_runtime.py similarity index 97% rename from tests/test_model_onnx_runtime.py rename to tests/anyimage/server/test_model_onnx_runtime.py index c21ff7e..6879218 100644 --- a/tests/test_model_onnx_runtime.py +++ b/tests/anyimage/server/test_model_onnx_runtime.py @@ -1,20 +1,10 @@ import sys import unittest -from pathlib import Path from types import ModuleType, SimpleNamespace from unittest.mock import patch -SOURCE_DIR = ( - Path(__file__).resolve().parents[1] - / "src" - / "anyimage" - / "server" - / "models" -) -sys.path.insert(0, str(SOURCE_DIR)) - -import onnx_runtime +from server.models import onnx_runtime class FakeSession: @@ -211,6 +201,5 @@ class OnnxRuntimeTest(unittest.TestCase): self.assertEqual(providers, ["CPUExecutionProvider"]) - if __name__ == "__main__": unittest.main() diff --git a/tests/test_model_pisa_sr.py b/tests/anyimage/server/test_model_pisa_sr.py similarity index 97% rename from tests/test_model_pisa_sr.py rename to tests/anyimage/server/test_model_pisa_sr.py index 15bbf38..c32d76b 100644 --- a/tests/test_model_pisa_sr.py +++ b/tests/anyimage/server/test_model_pisa_sr.py @@ -1,13 +1,10 @@ import hashlib -import sys -from pathlib import Path from types import SimpleNamespace from unittest.mock import patch import numpy as np import pytest from PIL import Image -sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src/anyimage")) from server.models import onnx_pisa_sr as pisa from server.model_manager import ModelManager, ModelSessionCache from server import model_catalog diff --git a/tests/test_model_upscale_onnx.py b/tests/anyimage/server/test_model_upscale_onnx.py similarity index 96% rename from tests/test_model_upscale_onnx.py rename to tests/anyimage/server/test_model_upscale_onnx.py index 790d9dd..8c6d6a0 100644 --- a/tests/test_model_upscale_onnx.py +++ b/tests/anyimage/server/test_model_upscale_onnx.py @@ -1,22 +1,11 @@ -import sys import unittest -from pathlib import Path from types import SimpleNamespace from unittest.mock import patch import numpy as np from PIL import Image -SOURCE_DIR = ( - Path(__file__).resolve().parents[1] - / "src" - / "anyimage" - / "server" - / "models" -) -sys.path.insert(0, str(SOURCE_DIR)) - -import onnx_upscale +from server.models import onnx_upscale class FakeSession: diff --git a/tests/test_server_jobs.py b/tests/anyimage/server/test_server_jobs.py similarity index 99% rename from tests/test_server_jobs.py rename to tests/anyimage/server/test_server_jobs.py index e746dab..253d29f 100644 --- a/tests/test_server_jobs.py +++ b/tests/anyimage/server/test_server_jobs.py @@ -6,7 +6,7 @@ from pathlib import Path from types import ModuleType, SimpleNamespace from unittest.mock import patch import numpy as np -from server_test_support import load_server_module, FakeJobContext +from tests.anyimage.server.support import load_server_module, FakeJobContext class ServerJobsTest(unittest.TestCase): diff --git a/tests/test_addon_registration.py b/tests/anyimage/test_addon_registration.py similarity index 99% rename from tests/test_addon_registration.py rename to tests/anyimage/test_addon_registration.py index de9e47e..3d96297 100644 --- a/tests/test_addon_registration.py +++ b/tests/anyimage/test_addon_registration.py @@ -2,7 +2,7 @@ import importlib import sys from types import SimpleNamespace from unittest.mock import patch -from blender_test_support import BlenderTestCase +from tests.support.blender import BlenderTestCase class AddonRegistrationTest(BlenderTestCase): diff --git a/tests/test_image_menu.py b/tests/anyimage/test_image_menu.py similarity index 98% rename from tests/test_image_menu.py rename to tests/anyimage/test_image_menu.py index b74cbcd..2cd3fdb 100644 --- a/tests/test_image_menu.py +++ b/tests/anyimage/test_image_menu.py @@ -1,6 +1,6 @@ from types import SimpleNamespace from unittest.mock import patch -from blender_test_support import BlenderTestCase +from tests.support.blender import BlenderTestCase class ImageMenuTest(BlenderTestCase): @@ -94,8 +94,6 @@ class ImageMenuTest(BlenderTestCase): self.assertNotIn(("anyimage.setup_ai_environment", True), calls) - - def test_image_operators_stay_clickable_until_server_is_busy(self): context = SimpleNamespace( object=SimpleNamespace(data=SimpleNamespace()), diff --git a/tests/test_job_operator.py b/tests/anyimage/test_job_operator.py similarity index 99% rename from tests/test_job_operator.py rename to tests/anyimage/test_job_operator.py index fd127c7..ed75258 100644 --- a/tests/test_job_operator.py +++ b/tests/anyimage/test_job_operator.py @@ -6,7 +6,7 @@ import threading from pathlib import Path from types import SimpleNamespace from unittest.mock import patch -from blender_test_support import BlenderTestCase +from tests.support.blender import BlenderTestCase class JobOperatorTest(BlenderTestCase): diff --git a/tests/test_preferences.py b/tests/anyimage/test_preferences.py similarity index 99% rename from tests/test_preferences.py rename to tests/anyimage/test_preferences.py index 15eebb2..a36ac95 100644 --- a/tests/test_preferences.py +++ b/tests/anyimage/test_preferences.py @@ -2,7 +2,7 @@ import importlib from pathlib import Path from types import SimpleNamespace from unittest.mock import patch -from blender_test_support import BlenderTestCase +from tests.support.blender import BlenderTestCase class PreferencesTest(BlenderTestCase): diff --git a/tests/test_server_panel.py b/tests/anyimage/test_server_panel.py similarity index 98% rename from tests/test_server_panel.py rename to tests/anyimage/test_server_panel.py index 36a7d03..4c247fa 100644 --- a/tests/test_server_panel.py +++ b/tests/anyimage/test_server_panel.py @@ -1,7 +1,7 @@ import importlib from types import SimpleNamespace from unittest.mock import patch -from blender_test_support import BlenderTestCase +from tests.support.blender import BlenderTestCase class ServerPanelTest(BlenderTestCase): diff --git a/tests/conftest.py b/tests/conftest.py index e3bf2ef..5debf26 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,5 @@ -"""Expose project source packages to the test suite.""" +"""Expose the extension and standalone server packages to the test suite.""" import sys -from pathlib import Path +from tests.support.paths import PROJECT_ROOT -sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) +sys.path[:0] = [str(PROJECT_ROOT / "src"), str(PROJECT_ROOT / "src/anyimage")] diff --git a/tests/support/__init__.py b/tests/support/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/blender_test_support.py b/tests/support/blender.py similarity index 98% rename from tests/blender_test_support.py rename to tests/support/blender.py index 3519d84..9158f0a 100644 --- a/tests/blender_test_support.py +++ b/tests/support/blender.py @@ -1,12 +1,11 @@ +from tests.support.paths import PROJECT_ROOT import importlib import builtins import sys 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" diff --git a/tests/depth_surface_test_support.py b/tests/support/depth_surface.py similarity index 95% rename from tests/depth_surface_test_support.py rename to tests/support/depth_surface.py index 693409a..e2e6007 100644 --- a/tests/depth_surface_test_support.py +++ b/tests/support/depth_surface.py @@ -1,18 +1,11 @@ """Behavior checks for face separation and connected Depth Surface shells.""" -import sys -from pathlib import Path from collections import Counter import bpy import numpy as np -ROOT = Path(__file__).parents[1] - -sys.path.insert(0, str(ROOT / "src")) - - -from node_asset_test_support import CUTOUT_BUILDERS +from tests.support.nodes import CUTOUT_BUILDERS def surface(*, step=6.0, size=1.0, resolution=2, wrapper=False): diff --git a/tests/image_object_test_support.py b/tests/support/image_objects.py similarity index 98% rename from tests/image_object_test_support.py rename to tests/support/image_objects.py index dc3cd2f..ebfe6b8 100644 --- a/tests/image_object_test_support.py +++ b/tests/support/image_objects.py @@ -1,14 +1,7 @@ -import sys -from pathlib import Path import bpy import numpy as np -SOURCE_ROOT = Path(__file__).parents[1] / "src" - -sys.path.insert(0, str(SOURCE_ROOT)) - - from anyimage.common import image as image_data from anyimage.common import object as object_data from anyimage.common.selection import SelectionMask diff --git a/tests/support/materials.py b/tests/support/materials.py new file mode 100644 index 0000000..6efa249 --- /dev/null +++ b/tests/support/materials.py @@ -0,0 +1,17 @@ +import bpy +import pytest +from tests.support.paths import PROJECT_ROOT +from tools.nodes.groups.image_layer import build_image_layer_group + + +@pytest.fixture +def image_layer(request): + bpy.ops.wm.read_factory_settings(use_empty=True) + if getattr(request, "param", "definition") == "asset": + with bpy.data.libraries.load(str(PROJECT_ROOT / "src/anyimage/assets/O_AnyImage.blend")) as (_, target): + target.node_groups = ["O Image Layer"] + group = target.node_groups[0] + else: + group = build_image_layer_group() + yield group + bpy.ops.wm.read_factory_settings(use_empty=True) diff --git a/tests/node_asset_test_support.py b/tests/support/nodes.py similarity index 100% rename from tests/node_asset_test_support.py rename to tests/support/nodes.py diff --git a/tests/support/paths.py b/tests/support/paths.py new file mode 100644 index 0000000..8da26d7 --- /dev/null +++ b/tests/support/paths.py @@ -0,0 +1,4 @@ +"""Resolve project paths independently of test directory depth.""" +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] diff --git a/tests/support/planes.py b/tests/support/planes.py new file mode 100644 index 0000000..7141164 --- /dev/null +++ b/tests/support/planes.py @@ -0,0 +1,82 @@ +"""Evaluate complete-image projection and rectangular surface topology.""" +from tests.support.paths import PROJECT_ROOT + +import bpy +import numpy as np +import pytest + +ROOT = PROJECT_ROOT +from tools.nodes.groups.image_plane import build_image_plane_group +from tools.nodes.groups.image_depth_plane import build_image_depth_plane_group +from tools.nodes.groups.image_relief_plane import build_image_relief_plane_group + + +@pytest.fixture(params=("definition", "asset")) +def surface(request): + return create_surface(request.param, "DEPTH") + + +@pytest.fixture(params=("definition", "asset")) +def relief(request): + return create_surface(request.param, "RELIEF") + + +def create_surface(source, plane_type): + bpy.ops.wm.read_factory_settings(use_empty=True) + group_name = f"O Image {plane_type.title()} Plane" + if source == "asset": + with bpy.data.libraries.load(str(ROOT / "src/anyimage/assets/O_AnyImage.blend")) as (library, target): + target.node_groups = [group_name] + group = target.node_groups[0] + plane = group.nodes["Group"].node_tree + else: + plane = build_image_plane_group() + builder = build_image_depth_plane_group if plane_type == "DEPTH" else build_image_relief_plane_group + group = builder(plane) + mesh = bpy.data.meshes.new("Rectangle") + mesh.from_pydata([(-2, -1, 0), (2, -1, 0), (2, 1, 0), (-2, 1, 0)], [], [(0, 1, 2, 3)]) + material = bpy.data.materials.new("Image") + mesh.materials.append(material) + obj = bpy.data.objects.new("Surface", mesh) + bpy.context.collection.objects.link(obj) + modifier = obj.modifiers.new("Surface", "NODES") + modifier.node_group = group + image = bpy.data.images.new("Camera", width=16, height=8, float_buffer=True) + image.colorspace_settings.name = "Non-Color" + image.alpha_mode = "CHANNEL_PACKED" + yy, xx = np.mgrid[:8, :16] + # Camera principal point is deliberately off center; Y points down. + pixels = np.ones((8, 16, 4), dtype=np.float32) + pixels[..., 0] = ((xx + 0.5) - 5) / 4 + pixels[..., 1] = (3 - (yy + 0.5)) / 4 + pixels[..., 2] = 2 + (xx + 0.5) / 16 + pixels[..., 3] = 1 + image.pixels.foreach_set(pixels.ravel()) + inputs = {item.name: item for item in group.interface.items_tree + if item.item_type == "SOCKET" and item.in_out == "INPUT"} + def set_value(name, value): + modifier[inputs[name].identifier] = value + obj.update_tag(refresh={"DATA"}) + bpy.context.view_layer.update() + + for name, value in (("Subdivide", 2), ("Depth Image", image), + ("Uniform Scale", 0.5), ("Reference Depth", 1.2), + ("Depth Scale", 0.0), ("Thickness", 0.0)): + set_value(name, value) + return obj, set_value, plane, inputs, image + + +def evaluated(obj): + result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) + mesh = result.to_mesh() + try: + return ( + np.array([v.co[:] for v in mesh.vertices]), + [tuple(face.vertices) for face in mesh.polygons], + np.array([value.uv[:] for value in mesh.uv_layers["UVMap"].data]) if mesh.uv_layers else np.empty((0, 2)), + [material.name if material else None for material in mesh.materials], + [face.material_index for face in mesh.polygons], + [attr.name for attr in mesh.attributes], + ) + finally: + result.to_mesh_clear() diff --git a/tests/test_node_asset_contracts.py b/tests/test_node_asset_contracts.py deleted file mode 100644 index 23ab4a1..0000000 --- a/tests/test_node_asset_contracts.py +++ /dev/null @@ -1,145 +0,0 @@ -import bpy - -from node_asset_test_support import validate_node_structure - -from anyimage.common.material import ( - DEPTH_MATERIAL_NODE_GROUP_NAME, - IMAGE_MATERIAL_NODE_GROUP_NAME, -) - -from anyimage.common.node import load_node_group, node_asset_path - -from anyimage.operators.cutout_tool.shape import ( - CUTOUT_NODE_GROUP_NAMES, -) - -DEPTH_PLANE_NODE_GROUP_NAME = "O Image Depth Plane" - -RELIEF_PLANE_NODE_GROUP_NAME = "O Image Relief Plane" - - -def input_names(group): - return [ - item.name - for item in group.interface.items_tree - if item.item_type == "SOCKET" and item.in_out == "INPUT" - ] - - -def test_saved_asset_contracts(): - bpy.ops.wm.read_factory_settings(use_empty=True) - image_plane = load_node_group("O Image Plane") - depth_plane = load_node_group(DEPTH_PLANE_NODE_GROUP_NAME) - relief_plane = load_node_group(RELIEF_PLANE_NODE_GROUP_NAME) - image_layer = load_node_group(IMAGE_MATERIAL_NODE_GROUP_NAME) - depth_layer = load_node_group(DEPTH_MATERIAL_NODE_GROUP_NAME) - shadeless = load_node_group("O Shadeless") - cutout_groups = { - name: load_node_group(name) for name in set(CUTOUT_NODE_GROUP_NAMES.values()) - } - expected_names = { - "O Image Plane", - DEPTH_PLANE_NODE_GROUP_NAME, - RELIEF_PLANE_NODE_GROUP_NAME, - IMAGE_MATERIAL_NODE_GROUP_NAME, - DEPTH_MATERIAL_NODE_GROUP_NAME, - "O Shadeless", - *CUTOUT_NODE_GROUP_NAMES.values(), - } - with bpy.data.libraries.load(str(node_asset_path()), link=False) as (source, _): - assert set(source.node_groups) == expected_names - - assert image_plane.name == "O Image Plane" - assert image_plane.bl_idname == "GeometryNodeTree" - assert image_plane.is_modifier - assert input_names(image_plane) == [ - "Geometry", - "Subdivide", - "Thickness", - ] - for group, normal_controls in ((depth_plane, ["Normal Smooth", "Smooth Weight", "Validity Threshold"]), (relief_plane, [])): - assert group.bl_idname == "GeometryNodeTree" and group.is_modifier - assert input_names(group) == [ - "Geometry", "Subdivide", "Thickness", "Depth Scale", - *(["Smooth", "Valid Only"] if group == depth_plane else []), "Reference Depth", - *normal_controls, "Uniform Scale", "Depth Image", - ] - inputs = {item.name: item for item in group.interface.items_tree - if item.item_type == "SOCKET" and item.in_out == "INPUT"} - assert inputs["Thickness"].default_value == 0 - assert inputs["Thickness"].subtype == inputs["Reference Depth"].subtype == "DISTANCE" - assert inputs["Subdivide"].default_value == 6 - assert all(item.structure_type == "SINGLE" for item in inputs.values()) - panels = [item for item in group.interface.items_tree if item.item_type == "PANEL"] - assert [panel.name for panel in panels] == ["Options", "Data"] - assert [item.name for item in panels[0].interface_items] == ["Reference Depth", *normal_controls] - assert [item.name for item in panels[1].interface_items] == ["Uniform Scale", "Depth Image"] - assert all(panel.default_closed for panel in panels) - assert all(item.hide_in_modifier for item in panels[1].interface_items) - assert image_layer.bl_idname == "ShaderNodeTree" - assert input_names(image_layer) == [ - "Color", - "Alpha", - "Normal", - "Bump Scale", - "Alpha Fix", - "Normal Scale", - "Object Space", - ] - assert depth_layer.bl_idname == "ShaderNodeTree" - adjustment = next( - item for item in image_layer.interface.items_tree - if item.item_type == "SOCKET" and item.in_out == "INPUT" - and item.name == "Alpha Fix" - ) - assert adjustment.default_value == 0.0 - assert (adjustment.min_value, adjustment.max_value, adjustment.subtype) == (0, 1, "FACTOR") - image_output = next(node for node in image_layer.nodes if node.type == "GROUP_OUTPUT") - alpha_curve = image_output.inputs["Alpha"].links[0].from_node - assert alpha_curve.bl_idname == "ShaderNodeFloatCurve" - assert alpha_curve.inputs["Value"].links[0].from_socket.name == "Alpha" - assert alpha_curve.inputs["Factor"].links[0].from_socket.name == "Alpha Fix" - panels = [item for item in image_layer.interface.items_tree if item.item_type == "PANEL"] - assert len(panels) == 1 and panels[0].name == "Options" and panels[0].default_closed - assert [item.name for item in panels[0].interface_items] == ["Alpha Fix", "Normal Scale", "Object Space"] - alpha_points = alpha_curve.mapping.curves[0].points - assert len(alpha_points) == 4 - for point, expected in zip(alpha_points, ((0, 0), (0.8, 0), (0.925, 0.925), (1, 1))): - assert all(abs(actual - value) < 1e-6 for actual, value in zip(point.location, expected)) - assert input_names(depth_layer) == [ - "Color", - "Alpha", - "Depth", - "Bump Scale", - ] - assert shadeless.name == "O Shadeless" - assert shadeless.bl_idname == "ShaderNodeTree" - assert input_names(shadeless) == ["Color"] - - assert set(cutout_groups) == set(CUTOUT_NODE_GROUP_NAMES.values()) - for group in cutout_groups.values(): - assert group.bl_idname == "GeometryNodeTree" - assert group.is_modifier - assert input_names(group)[0] == "Geometry" - - public_groups = { - image_plane, - depth_plane, - relief_plane, - image_layer, - depth_layer, - shadeless, - *cutout_groups.values(), - } - loaded = {*public_groups, *cutout_groups.values()} - for group in loaded: - if group.bl_idname == "GeometryNodeTree": - validate_node_structure(group) - assert all(group.use_fake_user for group in public_groups) - assert all(group.users > 0 for group in cutout_groups.values()) - if bpy.app.version >= (5, 0, 0): - assert all(group.library is not None for group in loaded) - print( - f"Validated Blender {bpy.app.version_string}: " - + ", ".join(sorted(group.name for group in loaded)) - ) diff --git a/tests/tools/__init__.py b/tests/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/tools/models/__init__.py b/tests/tools/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_sync_models.py b/tests/tools/models/test_cli.py similarity index 97% rename from tests/test_sync_models.py rename to tests/tools/models/test_cli.py index c3e746d..02bd83e 100644 --- a/tests/test_sync_models.py +++ b/tests/tools/models/test_cli.py @@ -1,13 +1,10 @@ +from tests.support.paths import PROJECT_ROOT import importlib.util import sys import unittest -from pathlib import Path from unittest.mock import patch -PROJECT_ROOT = Path(__file__).resolve().parents[1] - - def load_sync_models(): path = PROJECT_ROOT / "tools" / "models" / "cli.py" spec = importlib.util.spec_from_file_location("sync_models", path) diff --git a/tests/tools/nodes/__init__.py b/tests/tools/nodes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_node_arrangement.py b/tests/tools/nodes/test_arrangement.py similarity index 99% rename from tests/test_node_arrangement.py rename to tests/tools/nodes/test_arrangement.py index 019232d..53e7f80 100644 --- a/tests/test_node_arrangement.py +++ b/tests/tools/nodes/test_arrangement.py @@ -8,7 +8,7 @@ import pytest from tools.nodes.groups.image_plane import build_image_plane_group from tools.nodes.groups.image_depth_plane import build_image_depth_plane_group from tools.nodes.groups.image_relief_plane import build_image_relief_plane_group -from node_asset_test_support import CUTOUT_BUILDERS +from tests.support.nodes import CUTOUT_BUILDERS from tools.nodes.arrangement.arrange import BRANCH_GAP, FIELD_HEIGHT, ROUTE_RUN, arrange_nodes from tools.nodes.arrangement.flow import geometry_paths, origin_socket, peer_links from tools.nodes.arrangement.dimensions import RULES, estimate_node_dimensions diff --git a/tests/test_node_asset_launcher.py b/tests/tools/nodes/test_cli.py similarity index 95% rename from tests/test_node_asset_launcher.py rename to tests/tools/nodes/test_cli.py index 728af90..cbf0544 100644 --- a/tests/test_node_asset_launcher.py +++ b/tests/tools/nodes/test_cli.py @@ -1,10 +1,9 @@ +from tests.support.paths import PROJECT_ROOT import importlib.util -from pathlib import Path from unittest.mock import patch import pytest -PROJECT_ROOT = Path(__file__).resolve().parents[1] SCRIPT_PATH = PROJECT_ROOT / "tools" / "nodes" / "cli.py" @@ -78,7 +77,7 @@ def test_launcher_runs_asset_build_and_validations_before_tests(monkeypatch, tmp calls = [] monkeypatch.setattr(launcher, "blender_path", lambda _: executable) monkeypatch.setattr(launcher, "run", calls.append) - monkeypatch.setattr("sys.argv", ["build_node"]) + monkeypatch.setattr("sys.argv", ["build_nodes"]) launcher.main() diff --git a/tests/test_node_asset_cutouts.py b/tests/tools/nodes/test_cutouts.py similarity index 91% rename from tests/test_node_asset_cutouts.py rename to tests/tools/nodes/test_cutouts.py index bd72584..628fdac 100644 --- a/tests/test_node_asset_cutouts.py +++ b/tests/tools/nodes/test_cutouts.py @@ -3,17 +3,20 @@ from collections import Counter import bpy import numpy as np +import pytest +from tests.support.nodes import CUTOUT_BUILDERS from anyimage.operators.cutout_tool.object import load_cutout_node_group from anyimage.common.object import modifier_input_identifier, set_modifier_input -from node_asset_test_support import validate_node_structure +from tests.support.nodes import validate_node_structure -def test_saved_cutout_defaults(): +@pytest.mark.parametrize("source", ["definition", "asset"]) +def test_cutout_interfaces_and_thickness_modes(source): bpy.ops.wm.read_factory_settings(use_empty=True) - groups = {shape: load_cutout_node_group(shape) for shape in ("FLAT", "SOLID", "DEPTH_SOLID", "DEPTH_BALLOON")} + groups = {shape: (CUTOUT_BUILDERS[shape]() if source == "definition" else load_cutout_node_group(shape)) for shape in ("FLAT", "SOLID", "DEPTH_SOLID", "DEPTH_BALLOON")} assert groups["FLAT"] == groups["SOLID"] assert len(set(groups.values())) == 3 for group in set(groups.values()): @@ -80,4 +83,3 @@ def test_saved_cutout_defaults(): assert set(uses.values()) == {2} finally: evaluated.to_mesh_clear() - print("Validated three Cutout assets, four presets, both thickness modes and closed defaults") diff --git a/tests/test_cutout_volume.py b/tests/tools/nodes/test_depth_cutout.py similarity index 78% rename from tests/test_cutout_volume.py rename to tests/tools/nodes/test_depth_cutout.py index e075b4e..d2e6111 100644 --- a/tests/test_cutout_volume.py +++ b/tests/tools/nodes/test_depth_cutout.py @@ -1,70 +1,17 @@ """Contracts for smooth depth volume, editable presets and normal masks.""" -from pathlib import Path -import sys import bpy import numpy as np import pytest -ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(ROOT / "src")) -from node_asset_test_support import CUTOUT_BUILDERS from tools.nodes.groups.image_depth_cutout import build_volume_fields from tools.nodes.groups.image_depth_cutout import build_volume_rounding -from anyimage.operators.cutout_tool.object import load_cutout_node_group -from anyimage.operators.cutout_tool.operators import normal_mode_for_shape -from anyimage.operators.cutout_tool.geometry import refine_balloon_triangles, poisson_balloon_profile from anyimage.common.object import modifier_input_identifier, set_modifier_input -@pytest.mark.parametrize("enabled", [False, True]) -def test_depth_presets_always_generate_the_correct_normal_space(enabled): - assert normal_mode_for_shape("DEPTH_SOLID", enabled) == "OBJECT" - assert normal_mode_for_shape("DEPTH_BALLOON", enabled) == "TANGENT" - for shape in ("FLAT", "SOLID"): - assert normal_mode_for_shape(shape, enabled) == ("TANGENT" if enabled else "NONE") - - -def test_new_cutout_does_not_reuse_a_saved_wrapper(): - bpy.ops.wm.read_factory_settings(use_empty=True) - saved = bpy.data.node_groups.new("O Image Cutout", "GeometryNodeTree") - saved.interface.new_socket(name="Shape", in_out="INPUT", socket_type="NodeSocketMenu") - saved.use_fake_user = True - current = load_cutout_node_group("SOLID") - assert current != saved and current.get("anyimage_asset_revision") == 11 - assert saved in bpy.data.node_groups.values() - assert current == load_cutout_node_group("FLAT") - assert any(item.name == "Shape" for item in saved.interface.items_tree) - - -def test_refined_boundary_ears_have_interior_balloon_samples(): - points = np.array([(0, 0), (1, 0), (1, 1), (0, 1)], dtype=float) - points, faces, boundary = refine_balloon_triangles(points, [(0, 1, 2), (0, 2, 3)], np.ones(4, bool)) - profile = poisson_balloon_profile(points, faces, boundary) - assert np.all(profile[boundary] == 0) - assert all(any(profile[index] > 0 for index in face) for face in faces) - - -def test_depth_rounding_options_are_consolidated(): - bpy.ops.wm.read_factory_settings(use_empty=True) - group = CUTOUT_BUILDERS["DEPTH_SOLID"]() - inputs = {s.name: s for s in group.interface.items_tree if s.item_type == "SOCKET" and s.in_out == "INPUT"} - for name, default in (("Edge Round", 0.2), ("Front Inflation", 1), ("Normal Smooth", 50)): - assert inputs[name].default_value == pytest.approx(default) - assert inputs[name].structure_type == "SINGLE" - assert inputs["Edge Round"].parent.name == "Options" - names = list(inputs) - assert names[names.index("Front Inflation") - 1] == "Edge Round" - assert [s.name for s in inputs.values() if s.parent and s.parent.name == "Options"] == [ - "Cleanup Threshold", "Split Threshold", "Reference Depth", "Edge Round", "Front Inflation", "Normal Smooth", "Smooth Weight", - ] - assert not set(inputs) & {"Edge Round Range", "Back Rim Range", "Normal Influence", "Edge Round Amount", "Back Rim Fullness"} - assert all(n.inputs["Name"].default_value != "unused" for n in group.nodes if n.bl_idname == "GeometryNodeRemoveAttribute") - - @pytest.mark.parametrize("iterations", [0, 50]) def test_uniform_thickness_follows_the_tilted_front_normal(iterations): from scipy.spatial import cKDTree - from depth_surface_test_support import surface, evaluated, assert_closed + from tests.support.depth_surface import surface, evaluated, assert_closed obj, set_value = surface(step=0, resolution=4) image = bpy.data.images["Camera"] pixels = np.array(image.pixels[:], dtype=np.float32).reshape(32, 64, 4) @@ -83,7 +30,7 @@ def test_uniform_thickness_follows_the_tilted_front_normal(iterations): def test_balloon_shell_converges_to_front_with_thickness(): from scipy.spatial import cKDTree - from depth_surface_test_support import surface, evaluated, assert_closed + from tests.support.depth_surface import surface, evaluated, assert_closed obj, set_value = surface(step=1, resolution=10) xy = np.array([v.co[:2] for v in obj.data.vertices]) profile = 0.3 * np.sin(np.pi * xy[:, 0] / 2) * np.sin(np.pi * xy[:, 1]) @@ -196,7 +143,7 @@ def test_smooth_back_rejects_front_noise_and_preserves_center_depth(): def test_depth_cutout_normal_map_only_affects_front_faces(): - from depth_surface_test_support import surface + from tests.support.depth_surface import surface obj, set_value = surface() set_value("Thickness", 0.3) bpy.context.view_layer.update() @@ -215,7 +162,7 @@ def test_depth_cutout_normal_map_only_affects_front_faces(): @pytest.mark.parametrize("structure", [0, 1, 4]) @pytest.mark.parametrize("thickness", [0.0, 0.02, 0.1]) def test_uniform_front_inflation_scales_with_distance(thickness, structure): - from depth_surface_test_support import surface, evaluated, assert_closed + from tests.support.depth_surface import surface, evaluated, assert_closed obj, set_value = surface(step=0, resolution=10) xy = np.array([v.co[:2] for v in obj.data.vertices]) profile = 0.3 * np.sin(np.pi * xy[:, 0] / 2) * np.sin(np.pi * xy[:, 1]) @@ -243,7 +190,7 @@ def test_uniform_front_inflation_scales_with_distance(thickness, structure): def test_normal_smooth_controls_front_and_rear_directions(): - from depth_surface_test_support import surface, evaluated + from tests.support.depth_surface import surface, evaluated obj, set_value = surface(step=1, resolution=10) set_value("Front Inflation", 1.0) xy = np.array([v.co[:2] for v in obj.data.vertices]) @@ -272,7 +219,7 @@ def test_normal_smooth_controls_front_and_rear_directions(): def test_balloon_suppression_is_stronger_without_a_startup_threshold(): - from depth_surface_test_support import surface, evaluated + from tests.support.depth_surface import surface, evaluated obj, set_value = surface(step=0, resolution=10) xy = np.array([v.co[:2] for v in obj.data.vertices]) profile = 0.3 * np.sin(np.pi * xy[:, 0] / 2) * np.sin(np.pi * xy[:, 1]) diff --git a/tests/test_depth_relief_planes.py b/tests/tools/nodes/test_depth_relief_planes.py similarity index 66% rename from tests/test_depth_relief_planes.py rename to tests/tools/nodes/test_depth_relief_planes.py index eeeeb26..d66f5d3 100644 --- a/tests/test_depth_relief_planes.py +++ b/tests/tools/nodes/test_depth_relief_planes.py @@ -1,90 +1,15 @@ """Evaluate complete-image projection and rectangular surface topology.""" from collections import Counter -from pathlib import Path import bpy import numpy as np import pytest -ROOT = Path(__file__).parents[1] -from tools.nodes.groups.image_plane import build_image_plane_group -from tools.nodes.groups.image_depth_plane import build_image_depth_plane_group -from tools.nodes.groups.image_relief_plane import build_image_relief_plane_group -from node_asset_test_support import validate_node_structure +from tests.support.planes import surface, relief, evaluated +from tests.support.nodes import validate_node_structure from tools.nodes.arrangement.flow import origin_socket -@pytest.fixture(params=("definition", "asset")) -def surface(request): - return create_surface(request.param, "DEPTH") - - -@pytest.fixture(params=("definition", "asset")) -def relief(request): - return create_surface(request.param, "RELIEF") - - -def create_surface(source, plane_type): - bpy.ops.wm.read_factory_settings(use_empty=True) - group_name = f"O Image {plane_type.title()} Plane" - if source == "asset": - with bpy.data.libraries.load(str(ROOT / "src/anyimage/assets/O_AnyImage.blend")) as (library, target): - target.node_groups = [group_name] - group = target.node_groups[0] - plane = group.nodes["Group"].node_tree - else: - plane = build_image_plane_group() - builder = build_image_depth_plane_group if plane_type == "DEPTH" else build_image_relief_plane_group - group = builder(plane) - mesh = bpy.data.meshes.new("Rectangle") - mesh.from_pydata([(-2, -1, 0), (2, -1, 0), (2, 1, 0), (-2, 1, 0)], [], [(0, 1, 2, 3)]) - material = bpy.data.materials.new("Image") - mesh.materials.append(material) - obj = bpy.data.objects.new("Surface", mesh) - bpy.context.collection.objects.link(obj) - modifier = obj.modifiers.new("Surface", "NODES") - modifier.node_group = group - image = bpy.data.images.new("Camera", width=16, height=8, float_buffer=True) - image.colorspace_settings.name = "Non-Color" - image.alpha_mode = "CHANNEL_PACKED" - yy, xx = np.mgrid[:8, :16] - # Camera principal point is deliberately off center; Y points down. - pixels = np.ones((8, 16, 4), dtype=np.float32) - pixels[..., 0] = ((xx + 0.5) - 5) / 4 - pixels[..., 1] = (3 - (yy + 0.5)) / 4 - pixels[..., 2] = 2 + (xx + 0.5) / 16 - pixels[..., 3] = 1 - image.pixels.foreach_set(pixels.ravel()) - inputs = {item.name: item for item in group.interface.items_tree - if item.item_type == "SOCKET" and item.in_out == "INPUT"} - def set_value(name, value): - modifier[inputs[name].identifier] = value - obj.update_tag(refresh={"DATA"}) - bpy.context.view_layer.update() - - for name, value in (("Subdivide", 2), ("Depth Image", image), - ("Uniform Scale", 0.5), ("Reference Depth", 1.2), - ("Depth Scale", 0.0), ("Thickness", 0.0)): - set_value(name, value) - return obj, set_value, plane, inputs, image - - -def evaluated(obj): - result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) - mesh = result.to_mesh() - try: - return ( - np.array([v.co[:] for v in mesh.vertices]), - [tuple(face.vertices) for face in mesh.polygons], - np.array([value.uv[:] for value in mesh.uv_layers["UVMap"].data]) if mesh.uv_layers else np.empty((0, 2)), - [material.name if material else None for material in mesh.materials], - [face.material_index for face in mesh.polygons], - [attr.name for attr in mesh.attributes], - ) - finally: - result.to_mesh_clear() - - @pytest.mark.parametrize("subdivide", [0, 1, 3]) def test_zero_depth_matches_plane_geometry_and_uv(surface, subdivide): obj, set_value, plane, inputs, _ = surface @@ -148,6 +73,41 @@ def test_shell_keeps_front_and_closes_rectangular_boundary(surface, thickness): assert not any(name.startswith("o_depth_") for name in attributes) +@pytest.mark.parametrize("valid_only", [False, True]) +def test_depth_split_preserves_faces_and_closes_each_shell(surface, valid_only): + from tests.support.depth_surface import component_count + + obj, set_value, _, _, image = surface + pixels = np.asarray(image.pixels[:], dtype=np.float32).reshape(8, 16, 4) + pixels[:, 8:, 2] += 10 + pixels[:2, :, 3] = 0 + image.pixels.foreach_set(pixels.ravel()) + image.update() + set_value("Depth Scale", 1.0) + set_value("Valid Only", valid_only) + base = evaluated(obj) + set_value("Split Threshold", 1.0) + points, faces, uv, materials, indices, attributes = evaluated(obj) + assert len(points) > len(base[0]) + assert len(faces) == len(base[1]) + assert component_count(faces) > component_count(base[1]) + np.testing.assert_allclose(uv, base[2]) + assert (materials, indices) == base[3:5] + assert not any(name.startswith("o_depth_") for name in attributes) + set_value("Thickness", 0.2) + shell_points, shell_faces, shell_uv, *_ = evaluated(obj) + assert len(shell_points) == 2 * len(points) + edges = Counter(tuple(sorted((a, b))) for face in shell_faces + for a, b in zip(face, (*face[1:], face[0]))) + assert set(edges.values()) == {2} + assert np.isfinite(shell_points).all() and np.isfinite(shell_uv).all() + set_value("Thickness", 0.0) + set_value("Split Threshold", 0.0) + restored = evaluated(obj) + np.testing.assert_allclose(restored[0], base[0]) + assert restored[1] == base[1] + + def test_depth_jump_keeps_rectangular_faces(surface): obj, set_value, _, _, image = surface base = evaluated(obj) @@ -229,7 +189,12 @@ def test_curved_shell_smooths_normals_without_shrinking_thickness(surface, norma def test_surface_interface_and_node_structure(surface): obj, _, _, inputs, _ = surface assert list(inputs) == ["Geometry", "Subdivide", "Thickness", "Depth Scale", "Smooth", "Valid Only", - "Reference Depth", "Normal Smooth", "Smooth Weight", "Validity Threshold", "Uniform Scale", "Depth Image"] + "Reference Depth", "Normal Smooth", "Smooth Weight", "Validity Threshold", "Split Threshold", "Uniform Scale", "Depth Image"] + assert inputs["Split Threshold"].default_value == 0 + assert inputs["Split Threshold"].min_value == 0 + assert inputs["Split Threshold"].max_value == 1 + assert inputs["Split Threshold"].subtype == "FACTOR" + assert inputs["Split Threshold"].parent.name == "Options" assert inputs["Validity Threshold"].default_value == pytest.approx(0.9) assert inputs["Validity Threshold"].parent.name == "Options" assert inputs["Validity Threshold"].subtype == "FACTOR" @@ -265,71 +230,6 @@ def test_relief_interface_and_node_structure(relief): validate_node_structure(obj.modifiers[0].node_group) -def test_conversion_preserves_offset_world_placement_and_initializes_inputs(surface, monkeypatch): - from anyimage.operators.convert_to_plane import object as conversion - from anyimage.common.image import image_empty_bounds - from mathutils import Matrix, Vector - - obj, _, _, inputs, image = surface - group = obj.modifiers[0].node_group - monkeypatch.setattr(conversion, "load_node_group", lambda name: group) - source = bpy.data.objects.new("Offset Image", None) - source.empty_display_type = "IMAGE" - source.data = image - source.empty_display_size = 4 - source.empty_image_offset = (-0.2, -0.8) - source.matrix_world = Matrix.Translation((3, -2, 5)) @ Matrix.Rotation(0.7, 4, "Y") @ Matrix.Diagonal((2, 3, 1, 1)) - bpy.context.collection.objects.link(source) - bounds = image_empty_bounds(source) - matrix = source.matrix_world.copy() - material = bpy.data.materials.new("Conversion") - metadata = {"image_size": (16, 8), "reference_depth": 2.0, - "intrinsics": ((4, 0, 5), (0, 4, 3), (0, 0, 1))} - result = conversion.create_depth_plane_object( - bpy.context, source, 2, material, image, metadata, "DEPTH") - assert result.name == "Offset Image" - assert result["anyimage_mesh_shape"] == "DEPTH_PLANE" - assert bpy.context.view_layer.objects.active == result - expected = sorted(tuple(matrix @ Vector((x, y, 0))) - for x in bounds[:2] for y in bounds[2:]) - actual = sorted(tuple(result.matrix_world @ v.co) for v in result.data.vertices) - np.testing.assert_allclose(actual, expected, atol=1e-6) - center = matrix @ Vector(((bounds[0] + bounds[1]) / 2, (bounds[2] + bounds[3]) / 2, 0)) - np.testing.assert_allclose(result.matrix_world.translation, center, atol=1e-6) - modifier = result.modifiers[0] - assert modifier[inputs["Thickness"].identifier] == 0 - assert modifier[inputs["Depth Image"].identifier] == image - assert modifier[inputs["Valid Only"].identifier] is True - assert modifier[inputs["Reference Depth"].identifier] == pytest.approx( - 2 * modifier[inputs["Uniform Scale"].identifier]) - assert result.data.materials[0] == material - - -def test_failed_modifier_initialization_removes_partial_object(surface, monkeypatch): - from anyimage.operators.convert_to_plane import object as conversion - - obj, _, _, _, image = surface - monkeypatch.setattr(conversion, "load_node_group", lambda name: obj.modifiers[0].node_group) - source = bpy.data.objects.new("Source", None) - source.empty_display_type = "IMAGE" - source.data = image - bpy.context.collection.objects.link(source) - material = bpy.data.materials.new("Unused") - before_objects, before_meshes = set(bpy.data.objects), set(bpy.data.meshes) - - def fail(*args): - raise RuntimeError("input failed") - - monkeypatch.setattr(conversion, "set_modifier_input", fail) - metadata = {"image_size": (16, 8), "reference_depth": 2, - "intrinsics": ((4, 0, 5), (0, 4, 3), (0, 0, 1))} - with pytest.raises(RuntimeError, match="input failed"): - conversion.create_depth_plane_object(bpy.context, source, 2, material, image, metadata, "DEPTH") - assert set(bpy.data.objects) == before_objects - assert set(bpy.data.meshes) == before_meshes - assert material.users == 0 - - @pytest.mark.parametrize("threshold", [0.0, 0.4, 0.9, 1.0]) @pytest.mark.parametrize("pattern", ["valid", "invalid", "partial", "half", "stripe"]) def test_valid_only_recomputes_per_point_and_preserves_boundary_faces(surface, pattern, threshold): @@ -400,10 +300,9 @@ def test_validity_graph_matches_reference_domains_and_connections(surface): assert alpha.name == "Alpha" and alpha.node.bl_idname == "FunctionNodeSeparateColor" color = origin_socket(alpha.node.inputs["Color"].links[0].from_socket).node assert origin_socket(color.inputs["Image"].links[0].from_socket).name == "Depth Image" - assert len([node for node in color.id_data.nodes if node.bl_idname == "GeometryNodeImageTexture"]) == 1 consumers = [link for link in group.links if link.to_node.bl_idname != "NodeReroute" and origin_socket(link.from_socket) == color.outputs["Color"]] - assert len(consumers) == 2 + assert any(link.to_node == alpha.node for link in consumers) assert origin_socket(delete.inputs["Geometry"].links[0].from_socket).node.node_tree.name == "O Image Plane" @@ -430,3 +329,19 @@ def test_depth_smooth_changes_positions_and_preserves_topology(surface, thicknes set_value("Smooth Weight", 1.0) set_value("Smooth", 0) np.testing.assert_allclose(evaluated(obj)[0], base[0], atol=1e-6) + + +@pytest.mark.parametrize("kind", ["DEPTH", "RELIEF"]) +@pytest.mark.parametrize("source", ["definition", "asset"]) +def test_plane_interface_panels(source, kind): + from tests.support.planes import create_surface + obj, _, _, _, _ = create_surface(source, kind) + node_group = obj.modifiers[0].node_group + panels = [item for item in node_group.interface.items_tree if item.item_type == "PANEL"] + assert [item.name for item in panels] == ["Options", "Data"] + controls = ["Reference Depth"] + if kind == "DEPTH": + controls += ["Normal Smooth", "Smooth Weight", "Validity Threshold", "Split Threshold"] + assert [item.name for item in panels[0].interface_items] == controls + assert [item.name for item in panels[1].interface_items] == ["Uniform Scale", "Depth Image"] + assert all(panel.default_closed for panel in panels) diff --git a/tests/test_depth_surface_split.py b/tests/tools/nodes/test_depth_surface_split.py similarity index 97% rename from tests/test_depth_surface_split.py rename to tests/tools/nodes/test_depth_surface_split.py index 9f968dd..bd0dd75 100644 --- a/tests/test_depth_surface_split.py +++ b/tests/tools/nodes/test_depth_surface_split.py @@ -1,20 +1,14 @@ """Behavior checks for face separation and connected Depth Surface shells.""" -import sys -from pathlib import Path from collections import Counter import bpy import bmesh import numpy as np import pytest -ROOT = Path(__file__).parents[1] -sys.path.insert(0, str(ROOT / "src")) - - -from node_asset_test_support import CUTOUT_BUILDERS -from depth_surface_test_support import assert_closed, component_count, evaluated, surface +from tests.support.nodes import CUTOUT_BUILDERS +from tests.support.depth_surface import assert_closed, component_count, evaluated, surface @pytest.mark.parametrize("mode", [0, 1]) diff --git a/tests/test_image_layer_alpha.py b/tests/tools/nodes/test_image_layer.py similarity index 69% rename from tests/test_image_layer_alpha.py rename to tests/tools/nodes/test_image_layer.py index 6be6064..ae44c50 100644 --- a/tests/test_image_layer_alpha.py +++ b/tests/tools/nodes/test_image_layer.py @@ -1,26 +1,13 @@ -import sys -from pathlib import Path -from types import SimpleNamespace - import bpy import numpy as np import pytest -sys.path.insert(0, str(Path(__file__).parents[1] / "src")) -from anyimage.common import material -from tools.nodes.groups.image_layer import build_image_layer_group +from tests.support.materials import image_layer from tools.nodes.groups.image_depth_layer import build_image_depth_layer_group -@pytest.fixture -def image_layer(): - bpy.ops.wm.read_factory_settings(use_empty=True) - group = build_image_layer_group() - yield group - bpy.ops.wm.read_factory_settings(use_empty=True) - - +@pytest.mark.parametrize("image_layer", ["definition", "asset"], indirect=True) def test_alpha_curve_preserves_endpoints_and_suppresses_soft_edges(image_layer): inputs = { item.name: item for item in image_layer.interface.items_tree @@ -61,28 +48,6 @@ def test_alpha_curve_preserves_endpoints_and_suppresses_soft_edges(image_layer): assert all(socket.is_linked or socket.hide for node in image_layer.nodes for socket in node.outputs) -@pytest.mark.parametrize("enabled", [False, True]) -@pytest.mark.parametrize("shadeless", [False, True]) -@pytest.mark.parametrize("view", ["AgX", "Unknown"]) -def test_material_alpha_strength_follows_preference(image_layer, monkeypatch, enabled, shadeless, view): - monkeypatch.setattr(material, "material_node_group", lambda depth_plane=False: image_layer) - monkeypatch.setattr(material, "configured_material_view_adaptation", lambda: enabled) - color = bpy.data.images.new("Alpha edge", width=2, height=2, alpha=True) - scene = SimpleNamespace(view_settings=SimpleNamespace(view_transform=view)) - result = material.create_image_material(color, color, shadeless=shadeless, scene=scene) - layer = next(node for node in result.node_tree.nodes if node.type == "GROUP" and node.node_tree == image_layer) - assert layer.inputs["Alpha Fix"].default_value == float(enabled) - alpha_link = layer.outputs["Alpha"].links[0] - assert alpha_link.to_node.type == ("MIX_SHADER" if shadeless else "BSDF_PRINCIPLED") - assert alpha_link.to_socket.name == ("Fac" if shadeless else "Alpha") - layer.inputs["Alpha Fix"].default_value = 0.5 - monkeypatch.setattr(material, "configured_material_view_adaptation", lambda: not enabled) - second = material.create_image_material(color, color, shadeless=shadeless, scene=scene) - second_layer = next(node for node in second.node_tree.nodes if node.type == "GROUP" and node.node_tree == image_layer) - assert second_layer.inputs["Alpha Fix"].default_value == float(not enabled) - assert layer.inputs["Alpha Fix"].default_value == 0.5 - - def test_depth_layer_keeps_foreground_alpha_threshold(image_layer): depth = build_image_depth_layer_group() assert "Alpha Fix" not in { diff --git a/tests/test_node_asset_plane.py b/tests/tools/nodes/test_image_plane.py similarity index 100% rename from tests/test_node_asset_plane.py rename to tests/tools/nodes/test_image_plane.py diff --git a/tests/tools/nodes/test_inventory.py b/tests/tools/nodes/test_inventory.py new file mode 100644 index 0000000..72d4c17 --- /dev/null +++ b/tests/tools/nodes/test_inventory.py @@ -0,0 +1,23 @@ +"""Check the saved library's public inventory and persistence.""" +import bpy +from anyimage.common.node import load_node_group, node_asset_path +from tools.nodes.check import GEOMETRY_NAMES, SHADER_NAMES + + +def test_saved_asset_inventory(): + bpy.ops.wm.read_factory_settings(use_empty=True) + with bpy.data.libraries.load(str(node_asset_path())) as (source, _): + assert set(source.node_groups) == GEOMETRY_NAMES | SHADER_NAMES + for name in sorted(GEOMETRY_NAMES | SHADER_NAMES): + bpy.ops.wm.read_factory_settings(use_empty=True) + node_group = load_node_group(name) + assert node_group.bl_idname == ("GeometryNodeTree" if name in GEOMETRY_NAMES else "ShaderNodeTree") + assert node_group.use_fake_user and node_group.users > 0 + if name in GEOMETRY_NAMES: + assert node_group.is_modifier + if bpy.app.version >= (5, 0, 0): + assert node_group.library is not None + if name in {"O Image Depth Layer", "O Shadeless"}: + expected = ["Color", "Alpha", "Depth", "Bump Scale"] if name == "O Image Depth Layer" else ["Color"] + assert [item.name for item in node_group.interface.items_tree + if item.item_type == "SOCKET" and item.in_out == "INPUT"] == expected diff --git a/tests/test_node_assets.py b/tests/tools/nodes/test_saved_layout.py similarity index 72% rename from tests/test_node_assets.py rename to tests/tools/nodes/test_saved_layout.py index 668c341..c1063be 100644 --- a/tests/test_node_assets.py +++ b/tests/tools/nodes/test_saved_layout.py @@ -1,8 +1,7 @@ +from tests.support.paths import PROJECT_ROOT import unittest -from pathlib import Path -PROJECT_ROOT = Path(__file__).resolve().parents[1] SOURCE_ROOT = PROJECT_ROOT / "src" / "anyimage" @@ -10,7 +9,7 @@ class NodeAssetsTest(unittest.TestCase): def test_geometry_assets_preserve_node_structure(self): import bpy - from node_asset_test_support import validate_node_structure + from tests.support.nodes import validate_node_structure from tools.nodes.arrangement.arrange import arrange_nodes bpy.ops.wm.read_factory_settings(use_empty=True) @@ -33,13 +32,6 @@ class NodeAssetsTest(unittest.TestCase): for actual, expected in zip(group.nodes[name].location, position): self.assertAlmostEqual(actual, expected, delta=0.01, msg=f"{group.name}: {name}") - def test_production_code_does_not_create_node_groups(self): - offenders = [] - for path in SOURCE_ROOT.rglob("*.py"): - if "bpy.data.node_groups.new" in path.read_text(encoding="utf-8"): - offenders.append(path.relative_to(PROJECT_ROOT).as_posix()) - self.assertEqual(offenders, []) - if __name__ == "__main__": unittest.main() diff --git a/tests/test_tool_commands.py b/tests/tools/test_commands.py similarity index 80% rename from tests/test_tool_commands.py rename to tests/tools/test_commands.py index 16c96b3..e8f73a5 100644 --- a/tests/test_tool_commands.py +++ b/tests/tools/test_commands.py @@ -1,9 +1,6 @@ +from tests.support.paths import PROJECT_ROOT import tomllib import unittest -from pathlib import Path - - -PROJECT_ROOT = Path(__file__).resolve().parents[1] class ToolCommandsTests(unittest.TestCase): @@ -15,7 +12,7 @@ class ToolCommandsTests(unittest.TestCase): project["scripts"], { "pack": "tools.pack:main", - "build_node": "tools.nodes.cli:main", + "build_nodes": "tools.nodes.cli:main", "sync_models": "tools.models.cli:main", "docs": "tools.docs:main", }, diff --git a/tests/test_docs_command.py b/tests/tools/test_docs.py similarity index 90% rename from tests/test_docs_command.py rename to tests/tools/test_docs.py index 2934b1f..415ab39 100644 --- a/tests/test_docs_command.py +++ b/tests/tools/test_docs.py @@ -1,8 +1,8 @@ +from tests.support.paths import PROJECT_ROOT import importlib.util -from pathlib import Path -SCRIPT_PATH = Path(__file__).resolve().parents[1] / "tools" / "docs.py" +SCRIPT_PATH = PROJECT_ROOT / "tools" / "docs.py" SPEC = importlib.util.spec_from_file_location("docs_script", SCRIPT_PATH) docs = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(docs) diff --git a/tests/test_extension_manifest.py b/tests/tools/test_manifest.py similarity index 90% rename from tests/test_extension_manifest.py rename to tests/tools/test_manifest.py index 4d91adf..0ea0e0c 100644 --- a/tests/test_extension_manifest.py +++ b/tests/tools/test_manifest.py @@ -1,9 +1,8 @@ +from tests.support.paths import PROJECT_ROOT import tomllib import unittest -from pathlib import Path -PROJECT_ROOT = Path(__file__).resolve().parents[1] MANIFEST_PATH = PROJECT_ROOT / "src" / "anyimage" / "blender_manifest.toml" diff --git a/tests/test_extension_packaging.py b/tests/tools/test_pack.py similarity index 99% rename from tests/test_extension_packaging.py rename to tests/tools/test_pack.py index c435901..af28f98 100644 --- a/tests/test_extension_packaging.py +++ b/tests/tools/test_pack.py @@ -1,3 +1,4 @@ +from tests.support.paths import PROJECT_ROOT import importlib.util import tempfile import tomllib @@ -5,8 +6,6 @@ import unittest from pathlib import Path from unittest.mock import patch -PROJECT_ROOT = Path(__file__).resolve().parents[1] - def load_pack(): specification = importlib.util.spec_from_file_location( diff --git a/tools/nodes/groups/image_depth_plane.py b/tools/nodes/groups/image_depth_plane.py index 606f28e..4b4da8d 100644 --- a/tools/nodes/groups/image_depth_plane.py +++ b/tools/nodes/groups/image_depth_plane.py @@ -3,9 +3,12 @@ import bpy from ..common.nodes import group_input as _group_input, interface_socket as _interface_socket -from ..common.nodes import prepare_node_group +from ..common.nodes import prepare_node_group, float_input from ..common.smoothing import expand_smooth -from ..common.depth_surface import project_depth_surface, thicken_depth_surface, remove_surface_attributes +from ..common.depth_surface import ( + project_depth_surface, thicken_depth_surface, remove_surface_attributes, + split_depth_surface, store_surface_camera, +) DEPTH_PLANE_NODE_GROUP_NAME = "O Image Depth Plane" @@ -62,6 +65,7 @@ def build_image_depth_plane_group(image_plane_group): validity_threshold.default_value = 0.9 validity_threshold.min_value, validity_threshold.max_value = 0.0, 1.0 validity_threshold.subtype = "FACTOR" + float_input(group, "Split Threshold", 0.0, maximum=1, subtype="FACTOR", parent=options) data = group.interface.new_panel(name="Data", default_closed=True) uniform_scale = _interface_socket( group, @@ -99,33 +103,6 @@ def build_image_depth_plane_group(image_plane_group): texture.interpolation, texture.extension = "Linear", "EXTEND" links.new(uv.outputs["Attribute"], texture.inputs["Vector"]) links.new(texture_input.outputs["Depth Image"], texture.inputs["Image"]) - depth_input = _group_input(nodes, {"Reference Depth", "Depth Scale"}) - surface = project_depth_surface( - group, plane.outputs["Geometry"], texture.outputs["Color"], - scale_input.outputs["Uniform Scale"], depth_input.outputs["Reference Depth"], - depth_input.outputs["Depth Scale"], - ) - shell_input = _group_input(nodes, {"Thickness", "Normal Smooth"}) - shell = thicken_depth_surface( - group, surface, shell_input.outputs["Thickness"], - normal_smooth=shell_input.outputs["Normal Smooth"], - ) - positive = nodes.new("ShaderNodeMath") - positive.operation = "GREATER_THAN" - links.new(shell_input.outputs["Thickness"], positive.inputs[0]) - positive.inputs[1].default_value = 0 - choose = nodes.new("GeometryNodeSwitch") - choose.input_type = "GEOMETRY" - links.new(positive.outputs[0], choose.inputs["Switch"]) - links.new(surface, choose.inputs["False"]) - links.new(shell, choose.inputs["True"]) - clean = remove_surface_attributes(group, choose.outputs["Output"]) - # Preserve the repeated cap input independently of its display routing. - cap = nodes.new("NodeReroute") - links.new(nodes["Store Named Attribute.001"].outputs["Geometry"], cap.inputs[0]) - links.new(cap.outputs[0], nodes["Join Geometry"].inputs["Geometry"]) - projection_link = next(link for link in links if link.from_socket == plane.outputs["Geometry"]) - projection_input = projection_link.to_socket threshold_input = _group_input(nodes, {"Validity Threshold"}) channels = nodes.new("FunctionNodeSeparateColor") channels.mode = "RGB" @@ -147,7 +124,35 @@ def build_image_depth_plane_group(image_plane_group): links.new(cull_controls.outputs["Valid Only"], cull.inputs["Switch"]) links.new(delete.outputs["Geometry"], cull.inputs["True"]) links.new(plane.outputs["Geometry"], cull.inputs["False"]) - links.new(cull.outputs["Output"], projection_input) + split_input = _group_input(nodes, {"Split Threshold"}) + geometry = split_depth_surface( + group, cull.outputs["Output"], texture_input.outputs["Depth Image"], + split_input.outputs["Split Threshold"], scale_input.outputs["Uniform Scale"], + ) + geometry, camera = store_surface_camera( + group, geometry, texture.outputs["Color"], split_input.outputs["Split Threshold"], + ) + depth_input = _group_input(nodes, {"Reference Depth", "Depth Scale"}) + surface = project_depth_surface( + group, geometry, camera, + scale_input.outputs["Uniform Scale"], depth_input.outputs["Reference Depth"], + depth_input.outputs["Depth Scale"], + ) + shell_input = _group_input(nodes, {"Thickness", "Normal Smooth"}) + shell = thicken_depth_surface( + group, surface, shell_input.outputs["Thickness"], + normal_smooth=shell_input.outputs["Normal Smooth"], + ) + positive = nodes.new("ShaderNodeMath") + positive.operation = "GREATER_THAN" + links.new(shell_input.outputs["Thickness"], positive.inputs[0]) + positive.inputs[1].default_value = 0 + choose = nodes.new("GeometryNodeSwitch") + choose.input_type = "GEOMETRY" + links.new(positive.outputs[0], choose.inputs["Switch"]) + links.new(surface, choose.inputs["False"]) + links.new(shell, choose.inputs["True"]) + clean = remove_surface_attributes(group, choose.outputs["Output"]) geometry = expand_smooth( group, clean, iterations_name="Smooth", weight_name="Smooth Weight", )