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

@@ -109,9 +109,12 @@ def test_generated_premul_reads_current_pixels(floating):
bpy.data.images.remove(source)
@pytest.mark.parametrize("packed", [False, True])
@pytest.mark.parametrize("floating", [False, True])
@pytest.mark.parametrize("dirty", [False, True])
@pytest.mark.parametrize("packed,floating,dirty", [
(False, False, False),
(True, False, True),
(False, True, True),
(True, True, False),
])
def test_file_read_preserves_byte_and_native_float_cache(tmp_path, packed, floating, dirty):
path = tmp_path / ("source.exr" if floating else "source.png")
if floating:

View File

@@ -22,7 +22,7 @@ def source():
bpy.ops.wm.read_factory_settings(use_empty=True)
@pytest.mark.parametrize("floating,alpha", [(False, "STRAIGHT"), (False, "PREMUL"), (True, "STRAIGHT"), (True, "PREMUL"), (True, "CHANNEL_PACKED")])
@pytest.mark.parametrize("floating,alpha", [(False, "STRAIGHT"), (True, "STRAIGHT")])
@pytest.mark.parametrize("bounds", [None, (1, 0, 3, 2)])
def test_color_artifact_preserves_bounds_and_library_reload(source, tmp_path, monkeypatch, floating, alpha, bounds):
if floating:
@@ -170,66 +170,3 @@ def test_hdr_analysis_preview_is_separate_from_full_precision_color(source):
colors.cleanup_material_color_input(path)
assert not preview.exists()
@pytest.mark.parametrize("alpha_mode,source_kind", [
("STRAIGHT", "generated"), ("PREMUL", "file"),
("CHANNEL_PACKED", "dirty"), ("NONE", "packed"),
])
def test_float_color_conversion_preserves_rendered_color_and_opacity(source, tmp_path, alpha_mode, source_kind):
from anyimage.common.hdr_image import save_hdr_result
rgba = np.array([[[8, 3, 1, 0], [4, 2, .5, .25], [2, 1, .25, .5], [1, .5, .125, 1]]], np.float32)
if source_kind != "generated":
path = tmp_path / "source.exr"
save_hdr_result(rgba, path)
source = bpy.data.images.load(str(path), check_existing=False)
else:
source = bpy.data.images.new("Float Render", width=4, height=1, alpha=True, float_buffer=True)
source.pixels.foreach_set(rgba.ravel())
source.alpha_mode = alpha_mode
if source_kind == "dirty":
source.pixels.foreach_set(rgba.ravel())
elif source_kind == "packed":
source.pack()
scene = bpy.context.scene
scene.render.engine = "CYCLES"
scene.cycles.device = "CPU"
scene.cycles.samples = 16
scene.cycles.use_denoising = False
scene.render.film_transparent = True
scene.render.resolution_x, scene.render.resolution_y = 64, 16
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "OPEN_EXR"
scene.render.image_settings.color_mode = "RGBA"
scene.render.image_settings.color_depth = "32"
bpy.ops.object.camera_add(location=(0, 0, 5))
scene.camera = bpy.context.object
scene.camera.data.type = "ORTHO"
scene.camera.data.ortho_scale = 4
bpy.ops.mesh.primitive_plane_add(size=2)
bpy.context.object.scale = (2, .5, 1)
result = bpy.data.materials.new("Float Interpretation")
result.use_nodes = True
nodes, links = result.node_tree.nodes, result.node_tree.links
nodes.clear()
texture = nodes.new("ShaderNodeTexImage")
texture.interpolation = "Closest"
emission = nodes.new("ShaderNodeEmission")
transparent = nodes.new("ShaderNodeBsdfTransparent")
mix = nodes.new("ShaderNodeMixShader")
output = nodes.new("ShaderNodeOutputMaterial")
links.new(texture.outputs["Color"], emission.inputs["Color"])
links.new(texture.outputs["Alpha"], mix.inputs[0])
links.new(transparent.outputs[0], mix.inputs[1])
links.new(emission.outputs[0], mix.inputs[2])
links.new(mix.outputs[0], output.inputs["Surface"])
bpy.context.object.data.materials.append(result)
with colors.material_color_image(source) as color:
rendered = []
for name, image in (("before", source), ("after", color)):
texture.image = image
scene.render.filepath = str(tmp_path / (name + ".exr"))
bpy.ops.render.render(write_still=True)
image = bpy.data.images.load(scene.render.filepath, check_existing=False)
rendered.append(images.image_rgba(image))
np.testing.assert_allclose(rendered[1], rendered[0], rtol=2e-5, atol=1e-6)

View File

@@ -18,6 +18,26 @@ class ImageDataTest(unittest.TestCase):
self.modules.image.is_animated_image(SimpleNamespace(source="FILE"))
)
def test_panorama_crop_bounds_returns_largest_centered_two_to_one_region(self):
cases = (
((32, 16), (0, 0, 32, 16)),
((40, 16), (4, 0, 36, 16)),
((30, 20), (0, 2, 30, 17)),
((31, 20), (0, 2, 30, 17)),
((35, 16), (1, 0, 33, 16)),
)
for size, expected in cases:
with self.subTest(size=size):
self.assertEqual(
self.modules.image.panorama_crop_bounds(*size), expected
)
def test_panorama_crop_bounds_rejects_unusable_dimensions(self):
for size in ((0, 1), (1, 1), (2, 0), (-2, 1)):
with self.subTest(size=size):
with self.assertRaisesRegex(ValueError, "at least 2 x 1 pixels"):
self.modules.image.panorama_crop_bounds(*size)
def test_image_user_settings_reads_empty_values(self):
source_object = SimpleNamespace(

View File

@@ -8,6 +8,59 @@ from anyimage.common import material as material_data
from tests.support.image_objects import _create_cutout_shape, _group_node
def test_generated_materials_use_implicit_uv_and_readable_layout():
bpy.ops.wm.read_factory_settings(use_empty=True)
source = SimpleNamespace(name="Source")
color = bpy.data.images.new("Color", width=4, height=2)
normal = bpy.data.images.new("Normal", width=4, height=2)
depth = bpy.data.images.new("Depth", width=4, height=2)
material = material_data.create_image_material(source, color, normal_image=normal)
nodes = material.node_tree.nodes
assert not any(node.bl_idname == "ShaderNodeUVMap" for node in nodes)
assert all(not node.inputs["Vector"].is_linked for node in nodes if node.type == "TEX_IMAGE")
texture_location = lambda image: tuple(next(
node for node in nodes if node.type == "TEX_IMAGE" and node.image == image
).location)
assert texture_location(color) == (-700.0, 300.0)
assert texture_location(normal) == (-700.0, 0.0)
assert tuple(next(node for node in nodes if node.type == "GROUP").location) == (-300.0, 300.0)
assert tuple(next(node for node in nodes if node.type == "BSDF_PRINCIPLED").location) == (100.0, 300.0)
assert tuple(next(node for node in nodes if node.type == "OUTPUT_MATERIAL").location) == (500.0, 300.0)
depth_material = material_data.create_image_material(
source,
color,
displacement_image=depth,
displacement_midlevel=0.5,
displacement_scale=2.0,
)
nodes = depth_material.node_tree.nodes
assert not any(node.bl_idname == "ShaderNodeUVMap" for node in nodes)
assert all(not node.inputs["Vector"].is_linked for node in nodes if node.type == "TEX_IMAGE")
depth_texture = next(node for node in nodes if node.type == "TEX_IMAGE" and node.image == depth)
assert tuple(depth_texture.location) == (-1200.0, -300.0)
math_locations = {node.operation: tuple(node.location) for node in nodes if node.type == "MATH"}
assert math_locations == {
"SUBTRACT": (-950.0, -300.0),
"MULTIPLY": (-700.0, -300.0),
}
panorama = material_data.create_image_material(
source, color, shadeless=True, texture_extension="REPEAT")
nodes = panorama.node_tree.nodes
assert not any(node.bl_idname in {
"ShaderNodeUVMap", "ShaderNodeSeparateXYZ", "ShaderNodeClamp", "ShaderNodeCombineXYZ",
} for node in nodes)
texture = next(node for node in nodes if node.type == "TEX_IMAGE")
shader = next(node for node in nodes if node.type == "GROUP")
output = next(node for node in nodes if node.type == "OUTPUT_MATERIAL")
assert not texture.inputs["Vector"].is_linked
assert texture.extension == "REPEAT"
assert (tuple(texture.location), tuple(shader.location), tuple(output.location)) == (
(-700.0, 300.0), (-300.0, 300.0), (100.0, 300.0))
def test_image_layer_switches_between_tangent_and_object_space_normal():
bpy.ops.wm.read_factory_settings(use_empty=True)