feat(cutout): implement boundary padding for cutout textures

- Introduced a new `Boundary Padding` preference in the Cutout Tool with a default of 2 px and a configurable range of 1-4 px.
- Updated the Cutout client to establish inward mapping from the final Mask, sampling in a fixed direction and processing independent material RGBA and Depth.
- Enhanced the handling of RGBA and Depth during cutout operations, ensuring proper mapping and preservation of source data.
- Added tests to validate the new boundary padding functionality and its integration with existing cutout processes.

feat(geometry): remove overall mesh smoothing from geometry nodes

- Removed the `Smooth` input from several geometry node groups to eliminate unnecessary overall mesh smoothing.
- Retained `Smooth Weight` for depth nodes while removing it from Cutout and Relief Plane nodes.
- Ensured that boundary, normal, and fill smoothing behaviors remain intact and consistent.
- Updated tests and documentation to reflect the changes in node interfaces and behaviors.

feat(normals): scale tangent normals with depth

- Reused the existing `o_normal_reduction` attribute to adjust Tangent Normal strength based on the geometry's `Depth Scale`.
- Ensured that Tangent Normal strength is consistent with the geometry depth, preserving full strength for materials that do not provide the attribute.
- Updated tests to verify the correct behavior of normal strength adjustments in both Tangent and Object Space.
This commit is contained in:
2026-09-15 00:40:24 +08:00
parent ab282e4fdf
commit 591e62085a
93 changed files with 1915 additions and 2851 deletions

View File

@@ -1,10 +1,45 @@
import importlib
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from tests.support.blender import BlenderTestCase
class RecordingLayout:
"""Minimal Blender layout that records the properties and operators drawn."""
enabled = True
def __init__(self, operators):
self.properties = []
self.operators = operators
def prop(self, _owner, name, **_options):
self.properties.append(name)
def operator(self, identifier, **_options):
operator = SimpleNamespace(model="")
self.operators.append((identifier, operator))
return operator
def box(self):
return self
def row(self, **_options):
return self
def split(self, **_options):
return self
def column(self, **_options):
return self
def label(self, **_options):
return None
def separator(self, **_options):
return None
class PreferencesTest(BlenderTestCase):
def test_apple_silicon_architectures_support_coreml(self):
properties = self.anyimage.properties
@@ -107,93 +142,30 @@ class PreferencesTest(BlenderTestCase):
setattr(stored, attribute, "UNKNOWN")
self.assertEqual(reader(), default)
self.assertEqual(getattr(stored, attribute), default)
self.assertEqual({item[0]: item[-1] for item in module.BACKGROUND_MODEL_ITEMS}["BEN2_BASE"], 0)
self.assertEqual({item[0]: item[-1] for item in module.UPSCALE_MODEL_ITEMS}["REALESRGAN_X4PLUS"], 2)
self.assertEqual({item[0]: item[-1] for item in self.anyimage.properties.geometry_model_enum_items(None, None)}["MOGE3_VITL"], 3)
def test_global_ai_input_size_defaults_and_clamps(self):
preference_module = self.anyimage.preferences
preferences = self.fake_bpy.context.preferences.addons["anyimage"].preferences
self.assertEqual(
preference_module.configured_max_ai_input_size(),
2048,
)
preferences.max_ai_input_size = 10000
self.assertEqual(
preference_module.configured_max_ai_input_size(),
8192,
)
preferences.max_ai_input_size = 1
self.assertEqual(
preference_module.configured_max_ai_input_size(),
256,
)
def test_cutout_relative_edge_length_defaults_and_clamps(self):
def test_numeric_preferences_clamp_to_supported_ranges(self):
module = self.anyimage.preferences
preferences = self.fake_bpy.context.preferences.addons["anyimage"].preferences
self.assertEqual(module.configured_min_cutout_relative_edge_length(), 0.01)
preferences.min_cutout_relative_edge_length = 2
self.assertEqual(module.configured_min_cutout_relative_edge_length(), 0.02)
preferences.min_cutout_relative_edge_length = 200
self.assertEqual(module.configured_min_cutout_relative_edge_length(), 1)
preferences.min_cutout_relative_edge_length = 0
self.assertAlmostEqual(module.configured_min_cutout_relative_edge_length(), 0.00001)
cases = (
(module.configured_max_ai_input_size, "max_ai_input_size", (10000, 1), (8192, 256)),
(module.configured_max_frame_resolution, "max_frame_resolution", (10000, 1), (8192, 64)),
(module.configured_cutout_boundary_padding, "cutout_boundary_padding", (3.5, 100, -1), (3.5, 4, 1)),
(
module.configured_min_cutout_relative_edge_length,
"min_cutout_relative_edge_length",
(2, 200, 0),
(0.02, 1, 0.00001),
),
)
for reader, attribute, values, expected in cases:
with self.subTest(attribute=attribute):
for value, result in zip(values, expected):
setattr(preferences, attribute, value)
self.assertAlmostEqual(reader(), result)
def test_frame_resolution_defaults_and_clamps_independently(self):
preference_module = self.anyimage.preferences
preferences = self.fake_bpy.context.preferences.addons["anyimage"].preferences
self.assertEqual(preference_module.configured_max_frame_resolution(), 2048)
preferences.max_frame_resolution = 10000
self.assertEqual(preference_module.configured_max_frame_resolution(), 8192)
preferences.max_frame_resolution = 1
self.assertEqual(preference_module.configured_max_frame_resolution(), 64)
def test_preferences_draws_upscale_model_download(self):
preference_module = importlib.import_module("anyimage.preferences")
downloads = []
class Layout:
enabled = True
def prop(self, _owner, name, **_options):
pass
def separator(self, **_options):
return None
def label(self, **_options):
pass
def row(self, **_options):
return self
def split(self, **_options):
return self
def column(self, **_options):
return self
def box(self):
return self
def operator(self, identifier, **_options):
operator = SimpleNamespace(model="")
if identifier == "anyimage.download_model":
downloads.append(operator)
return operator
preferences = preference_module.AnyImagePreferences()
preferences.layout = Layout()
preferences.geometry_model = "MOGE2_VITS_NORMAL"
preferences.background_model = "BEN2_BASE"
preferences.upscale_model = "HAT_GAN_X4_SHARPER"
def test_preferences_draw_follows_ai_status(self):
catalog = tuple(
{
"key": key,
@@ -205,82 +177,46 @@ class PreferencesTest(BlenderTestCase):
for key, family in (
("MOGE2_VITS_NORMAL", "moge"),
("BEN2_BASE", "ben2"),
("REALESRGAN_GENERAL_WDN_X4V3", "upscale"),
("HAT_GAN_X4_SHARPER", "upscale"),
)
)
with (
patch.object(
self.anyimage.properties,
"ai_status",
return_value={
"environment_ready": True,
"missing_models": (),
"ready": True,
},
),
patch.object(
self.anyimage.properties,
"model_catalog",
return_value=catalog,
),
patch.object(self.anyimage.runtime, "server_busy", return_value=False),
):
preferences.draw(None)
missing_environment = {
"environment_ready": False,
"missing_models": (),
"ready": False,
}
operators = self.draw_preferences(missing_environment, ())
self.assertEqual(
[download.model for download in downloads],
[identifier for identifier, _options in operators],
["anyimage.setup_ai_environment", "anyimage.open_server_log"],
)
ready = {"environment_ready": True, "missing_models": (), "ready": True}
operators = self.draw_preferences(ready, catalog)
downloads = [
operator.model
for identifier, operator in operators
if identifier == "anyimage.download_model"
]
self.assertEqual(
downloads,
["MOGE2_VITS_NORMAL", "BEN2_BASE", "HAT_GAN_X4_SHARPER"],
)
def test_preferences_hide_ai_until_server_is_installed(self):
preference_module = importlib.import_module("anyimage.preferences")
def draw_preferences(self, status, catalog):
operators = []
class Layout:
enabled = True
def box(self):
return self
def label(self, **options):
pass
def prop(self, *_args, **_options):
return None
def row(self, **_options):
return self
def separator(self, **_options):
return None
def operator(self, identifier, **_options):
operators.append(identifier)
return SimpleNamespace(model="")
preferences = preference_module.AnyImagePreferences()
preferences.layout = Layout()
preferences = self.anyimage.preferences.AnyImagePreferences()
preferences.layout = RecordingLayout(operators)
preferences.geometry_model = "MOGE2_VITS_NORMAL"
preferences.background_model = "BEN2_BASE"
preferences.upscale_model = "HAT_GAN_X4_SHARPER"
with (
patch.object(
self.anyimage.properties,
"ai_status",
return_value={
"environment_ready": False,
"missing_models": (),
"ready": False,
},
),
patch.object(self.anyimage.properties, "model_catalog", return_value=()),
patch.object(self.anyimage.properties, "ai_status", return_value=status),
patch.object(self.anyimage.properties, "model_catalog", return_value=catalog),
patch.object(self.anyimage.runtime, "server_busy", return_value=False),
):
preferences.draw(None)
self.assertEqual(
operators,
["anyimage.setup_ai_environment", "anyimage.open_server_log"],
)
return operators
def test_depth_plane_request_includes_the_global_ai_input_limit(self):