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:
@@ -7,7 +7,6 @@ from mathutils import Matrix, Vector
|
||||
|
||||
|
||||
from anyimage.common import depth as depth_data
|
||||
from anyimage.common import color_image as colors
|
||||
from tests.support.color_image import color_image as prepared_color_image
|
||||
from anyimage.common import image as image_data
|
||||
from anyimage.common import material as material_data
|
||||
@@ -15,13 +14,16 @@ from anyimage.common import object as object_data
|
||||
from anyimage.common.selection import ImageEditWarning, SelectionPath, rasterize_selection_path
|
||||
from anyimage.operators.cutout_tool import geometry as cutout_geometry
|
||||
from anyimage.operators.cutout_tool import operators as cutout_main
|
||||
from anyimage.operators.cutout_tool import object as cutout_object
|
||||
from anyimage.operators.cutout_tool import shape as cutout_shape
|
||||
from anyimage.operators.cutout_tool.boundary_padding import (
|
||||
image_rgba_buffer,
|
||||
pad_cutout_images,
|
||||
)
|
||||
from anyimage.server.geometry.depth_texture import (
|
||||
write_depth_texture,
|
||||
)
|
||||
from anyimage.server.models.geometry import GeometryFrame
|
||||
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
|
||||
from tests.support.image_objects import _add_cutout_modifier, _create_cutout_shape, _depth_image, _depth_metadata, _evaluated_positions, _group_node
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shape", ("FLAT", "SOLID"))
|
||||
@@ -184,37 +186,8 @@ def test_balloon_build_starts_from_the_unified_single_sheet():
|
||||
assert np.allclose(tuple(inward), (0.0, 0.0, -1.0), atol=1e-6)
|
||||
|
||||
|
||||
def test_teddy_preference_writes_the_shared_balloon_attribute():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
image = bpy.data.images.new("Teddy Sheet", width=64, height=64, alpha=True)
|
||||
source = bpy.data.objects.new("Teddy Sheet", None)
|
||||
source.empty_display_type = "IMAGE"
|
||||
source.data = image
|
||||
source.empty_display_size = 2.0
|
||||
bpy.context.collection.objects.link(source)
|
||||
with patch.object(
|
||||
cutout_geometry,
|
||||
"addon_preferences",
|
||||
return_value=SimpleNamespace(balloon_profile_algorithm="TEDDY"),
|
||||
):
|
||||
result = _create_cutout_shape(
|
||||
source,
|
||||
"FLAT",
|
||||
0.1,
|
||||
)
|
||||
|
||||
attribute = result.data.attributes[cutout_shape.BALLOON_ATTRIBUTE_NAME]
|
||||
profile = np.empty(len(result.data.vertices), dtype=np.float32)
|
||||
attribute.data.foreach_get("value", profile)
|
||||
assert profile.min() == 0.0 and profile.max() > 0.0
|
||||
assert not any(
|
||||
node.bl_idname == "ShaderNodeAttribute"
|
||||
for node in result.data.materials[0].node_tree.nodes
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gesture", ("LASSO", "POLYLINE"))
|
||||
def test_depth_surface_protected_smooth_evaluates(gesture):
|
||||
def test_depth_surface_object_evaluates(gesture):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
image = bpy.data.images.new("Surface", width=64, height=64)
|
||||
source = bpy.data.objects.new("Surface", None)
|
||||
@@ -269,10 +242,6 @@ def test_depth_surface_protected_smooth_evaluates(gesture):
|
||||
expected_cleanup,
|
||||
)
|
||||
assert np.isclose(modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Smooth Weight")), 1.0)
|
||||
assert (
|
||||
modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Smooth"))
|
||||
== 0
|
||||
)
|
||||
for name, value in (("Mode", 1), ("Front Inflation", 0.0)):
|
||||
object_data.set_modifier_input(modifier, object_data.modifier_input_identifier(modifier.node_group, name), value)
|
||||
reference_id = object_data.modifier_input_identifier(
|
||||
@@ -311,32 +280,6 @@ def test_depth_surface_protected_smooth_evaluates(gesture):
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
object_data.set_modifier_input(
|
||||
modifier,
|
||||
object_data.modifier_input_identifier(modifier.node_group, "Smooth"),
|
||||
4,
|
||||
)
|
||||
object_data.set_modifier_input(
|
||||
modifier,
|
||||
object_data.modifier_input_identifier(modifier.node_group, "Smooth Weight"),
|
||||
1.0,
|
||||
)
|
||||
result.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
internal_mesh = evaluated.to_mesh()
|
||||
try:
|
||||
internal_positions = np.asarray(
|
||||
[tuple(vertex.co) for vertex in internal_mesh.vertices]
|
||||
)
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
assert internal_positions.ndim == 2
|
||||
assert internal_positions.shape[1] == 3
|
||||
assert np.isfinite(internal_positions).all()
|
||||
|
||||
|
||||
def test_depth_surface_keeps_continuous_points_across_zero_validity_edge():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
image = bpy.data.images.new("Surface", width=64, height=64)
|
||||
@@ -368,8 +311,11 @@ def test_depth_surface_keeps_continuous_points_across_zero_validity_edge():
|
||||
depth_metadata=_depth_metadata(intrinsics, (size, size), 1.0),
|
||||
)
|
||||
modifier = result.modifiers[0]
|
||||
for name, value in (("Thickness", 0.0), ("Smooth", 0)):
|
||||
object_data.set_modifier_input(modifier, object_data.modifier_input_identifier(modifier.node_group, name), value)
|
||||
object_data.set_modifier_input(
|
||||
modifier,
|
||||
object_data.modifier_input_identifier(modifier.node_group, "Thickness"),
|
||||
0.0,
|
||||
)
|
||||
result.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
positions = _evaluated_positions(result)
|
||||
@@ -380,201 +326,6 @@ def test_depth_surface_keeps_continuous_points_across_zero_validity_edge():
|
||||
assert np.ptp(positions[:, 2]) < 1e-4
|
||||
|
||||
|
||||
def test_depth_surface_removes_boundary_ears_without_loose_geometry():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
obj, modifier = _depth_surface_step_object()
|
||||
split_id = object_data.modifier_input_identifier(modifier.node_group, "Depth Split")
|
||||
# Both outer ears have three boundary vertices, including the raised corner.
|
||||
for value in (0., .5, 1.):
|
||||
object_data.set_modifier_input(modifier, split_id, value)
|
||||
obj.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
vertices, faces, loose_edges, loose_points = _evaluated_topology(obj)
|
||||
assert (vertices, faces) == (7, 6)
|
||||
assert not loose_edges and not loose_points
|
||||
|
||||
|
||||
def test_cutout_thickness_keeps_one_connected_shell():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
vertices = [(float(x), float(y), 0.0) for y in range(3) for x in range(3)]
|
||||
faces = []
|
||||
for y in range(2):
|
||||
for x in range(2):
|
||||
a = y * 3 + x
|
||||
faces.extend(((a, a + 1, a + 4), (a, a + 4, a + 3)))
|
||||
data = bpy.data.meshes.new("Connected Cutout")
|
||||
data.from_pydata(vertices, (), faces)
|
||||
obj = bpy.data.objects.new("Connected Cutout", data)
|
||||
bpy.context.collection.objects.link(obj)
|
||||
modifier = _add_cutout_modifier(obj, "FLAT")
|
||||
|
||||
object_data.set_modifier_input(
|
||||
modifier,
|
||||
object_data.modifier_input_identifier(modifier.node_group, "Cleanup Threshold"),
|
||||
0.0,
|
||||
)
|
||||
object_data.set_modifier_input(
|
||||
modifier,
|
||||
object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="DISTANCE"),
|
||||
1.0,
|
||||
)
|
||||
|
||||
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
result = evaluated.to_mesh()
|
||||
try:
|
||||
assert len(result.vertices) == 18
|
||||
assert len(result.polygons) == 24
|
||||
z = np.asarray([vertex.co.z for vertex in result.vertices])
|
||||
assert np.isclose(z.min(), -1.0, atol=1e-6)
|
||||
assert np.isclose(z.max(), 0.0, atol=1e-6)
|
||||
front_faces = [
|
||||
polygon for polygon in result.polygons if polygon.normal.z > 0.9
|
||||
]
|
||||
back_faces = [polygon for polygon in result.polygons if polygon.normal.z < -0.9]
|
||||
side_faces = [
|
||||
polygon for polygon in result.polygons if abs(polygon.normal.z) < 0.1
|
||||
]
|
||||
assert len(front_faces) == 8
|
||||
assert len(back_faces) == 8
|
||||
assert len(side_faces) == 8
|
||||
center = Vector((1.0, 1.0, 0.0))
|
||||
for polygon in side_faces:
|
||||
radial = polygon.center - center
|
||||
assert polygon.normal.dot(radial) > 0.0
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
object_data.set_modifier_input(
|
||||
modifier,
|
||||
object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="DISTANCE"),
|
||||
2.4,
|
||||
)
|
||||
obj.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
result = evaluated.to_mesh()
|
||||
try:
|
||||
assert len(result.vertices) == 34
|
||||
assert len(result.polygons) == 40
|
||||
side_faces = [
|
||||
polygon for polygon in result.polygons if abs(polygon.normal.z) < 0.1
|
||||
]
|
||||
assert len(side_faces) == 24
|
||||
for polygon in side_faces:
|
||||
coordinates = [result.vertices[index].co for index in polygon.vertices]
|
||||
edge_lengths = [
|
||||
(coordinates[(index + 1) % len(coordinates)] - coordinate).length
|
||||
for index, coordinate in enumerate(coordinates)
|
||||
]
|
||||
assert max(edge_lengths) / min(edge_lengths) <= 1.25 + 1e-6
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
|
||||
def test_balloon_uses_injected_profile_at_default_height():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
vertices = [(float(x), float(y), 0.0) for y in range(3) for x in range(3)]
|
||||
faces = []
|
||||
for y in range(2):
|
||||
for x in range(2):
|
||||
a = y * 3 + x
|
||||
faces.extend(((a, a + 1, a + 4), (a, a + 4, a + 3)))
|
||||
data = bpy.data.meshes.new("Balloon Profile")
|
||||
data.from_pydata(vertices, (), faces)
|
||||
cutout_object._assign_float_point_attribute(
|
||||
data,
|
||||
cutout_shape.BALLOON_ATTRIBUTE_NAME,
|
||||
(0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0),
|
||||
)
|
||||
obj = bpy.data.objects.new("Balloon Profile", data)
|
||||
bpy.context.collection.objects.link(obj)
|
||||
modifier = _add_cutout_modifier(obj, "SOLID")
|
||||
|
||||
object_data.set_modifier_input(
|
||||
modifier,
|
||||
object_data.modifier_input_identifier(modifier.node_group, "Cleanup Threshold"),
|
||||
0.0,
|
||||
)
|
||||
object_data.set_modifier_input(
|
||||
modifier,
|
||||
object_data.modifier_input_identifier(modifier.node_group, "Thickness"),
|
||||
1.0,
|
||||
)
|
||||
|
||||
bpy.context.view_layer.update()
|
||||
positions = _evaluated_positions(obj)
|
||||
assert np.isclose(positions[:, 2].min(), -1.0, atol=1e-6)
|
||||
assert np.isclose(positions[:, 2].max(), 1.0, atol=1e-6)
|
||||
assert np.isclose(
|
||||
positions[:, 2].min(),
|
||||
-positions[:, 2].max(),
|
||||
atol=1e-5,
|
||||
)
|
||||
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
result = evaluated.to_mesh()
|
||||
try:
|
||||
baseline_face_count = len(result.polygons)
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
object_data.set_modifier_input(
|
||||
modifier,
|
||||
object_data.modifier_input_identifier(modifier.node_group, "Thickness"),
|
||||
2.0,
|
||||
)
|
||||
obj.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
result = evaluated.to_mesh()
|
||||
try:
|
||||
assert len(result.polygons) == baseline_face_count
|
||||
z = np.asarray([vertex.co.z for vertex in result.vertices])
|
||||
assert np.isclose(z.min(), -2.0, atol=1e-6)
|
||||
assert np.isclose(z.max(), 2.0, atol=1e-6)
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
object_data.set_modifier_input(
|
||||
modifier,
|
||||
object_data.modifier_input_identifier(modifier.node_group, "Thickness"),
|
||||
0.0,
|
||||
)
|
||||
obj.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
zero_positions = _evaluated_positions(obj)
|
||||
assert len(zero_positions) == len(vertices)
|
||||
assert np.allclose(zero_positions[:, 2], 0.0, atol=1e-6)
|
||||
|
||||
|
||||
def test_cutout_cleanup_uses_absolute_area_with_many_small_islands():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
vertices = [(float(x), float(y), 0.0) for y in range(3) for x in range(3)]
|
||||
faces = []
|
||||
for y in range(2):
|
||||
for x in range(2):
|
||||
a = y * 3 + x
|
||||
faces.extend(((a, a + 1, a + 4), (a, a + 4, a + 3)))
|
||||
for island in range(12):
|
||||
start = len(vertices)
|
||||
x = 5.0 + island * 0.1
|
||||
vertices.extend(((x, 5.0, 0.0), (x + 0.01, 5.0, 0.0), (x, 5.01, 0.0)))
|
||||
faces.append((start, start + 1, start + 2))
|
||||
data = bpy.data.meshes.new("Cleanup Threshold")
|
||||
data.from_pydata(vertices, (), faces)
|
||||
obj = bpy.data.objects.new("Cleanup Threshold", data)
|
||||
bpy.context.collection.objects.link(obj)
|
||||
modifier = _add_cutout_modifier(obj, "FLAT")
|
||||
|
||||
cleanup_id = object_data.modifier_input_identifier(modifier.node_group, "Cleanup Threshold")
|
||||
|
||||
object_data.set_modifier_input(modifier, cleanup_id, 0.0)
|
||||
bpy.context.view_layer.update()
|
||||
assert len(_evaluated_positions(obj)) == 45
|
||||
|
||||
object_data.set_modifier_input(modifier, cleanup_id, 0.1)
|
||||
obj.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
assert len(_evaluated_positions(obj)) == 9
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gesture", ("LASSO", "POLYLINE"))
|
||||
def test_depth_symmetry_uses_metric_plane_inputs(gesture):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
@@ -650,7 +401,7 @@ def test_depth_symmetry_uses_metric_plane_inputs(gesture):
|
||||
assert isinstance(value(modifier, "Depth Image"), bpy.types.Image)
|
||||
assert value(modifier, "Uniform Scale") > 0
|
||||
assert value(modifier, "Reference Depth") > 0
|
||||
assert not np.allclose(value(symmetry, "Symmetry Direction"), (0, 0, 1))
|
||||
assert not np.allclose(value(symmetry, "Direction"), (0, 0, 1))
|
||||
crop_center = Vector(((local_bounds[0] + local_bounds[1]) / 2,
|
||||
(local_bounds[2] + local_bounds[3]) / 2, 0))
|
||||
np.testing.assert_allclose(result.matrix_world.translation, source.matrix_world @ crop_center, atol=1e-5)
|
||||
@@ -668,35 +419,29 @@ def test_depth_symmetry_uses_metric_plane_inputs(gesture):
|
||||
np.testing.assert_array_equal(changed, positions)
|
||||
|
||||
|
||||
def test_bounds_input_preserves_rectangular_rgba_and_stays_packed_after_cleanup():
|
||||
@pytest.mark.parametrize("float_buffer", (False, True))
|
||||
def test_boundary_padding_preserves_independent_blender_image_interpretation(float_buffer):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
image = bpy.data.images.new("Bounds Source", width=5, height=4, alpha=True)
|
||||
rgba = np.arange(4 * 5 * 4, dtype=np.float32).reshape((4, 5, 4)) / 100.0
|
||||
rgba[:, :, 3] = np.asarray(
|
||||
(
|
||||
(0.0, 0.1, 0.2, 0.3, 0.4),
|
||||
(0.5, 0.6, 0.7, 0.8, 0.9),
|
||||
(1.0, 0.9, 0.8, 0.7, 0.6),
|
||||
(0.5, 0.4, 0.3, 0.2, 0.1),
|
||||
),
|
||||
dtype=np.float32,
|
||||
source = bpy.data.images.new(
|
||||
"Padding Source", width=9, height=9, alpha=True, float_buffer=float_buffer
|
||||
)
|
||||
path = colors.prepare_material_color_input(image, (1, 1, 5, 4), rgba)
|
||||
result = None
|
||||
try:
|
||||
result = colors.load_material_color_image(path)
|
||||
loaded = image_data.image_rgba(result)
|
||||
np.testing.assert_allclose(loaded, rgba[1:4, 1:5], atol=1.0 / 255.0)
|
||||
assert result.packed_file is not None
|
||||
padded = bpy.data.images.new(
|
||||
"Padding Color", width=9, height=9, alpha=True, float_buffer=float_buffer
|
||||
)
|
||||
source.alpha_mode = padded.alpha_mode = "CHANNEL_PACKED"
|
||||
values = np.zeros((9, 9, 4), dtype=np.float32)
|
||||
values[2:7, 2:7] = (0.25, 0.5, 0.75, 1.0)
|
||||
source.pixels.foreach_set(np.flipud(values).ravel())
|
||||
padded.pixels.foreach_set(np.flipud(values).ravel())
|
||||
original = image_rgba_buffer(source)
|
||||
|
||||
colors.cleanup_material_color_input(path)
|
||||
pad_cutout_images(padded, None, None, values[..., 3], 0.5, 2.0)
|
||||
|
||||
assert not path.exists()
|
||||
assert result.packed_file is not None
|
||||
finally:
|
||||
colors.cleanup_material_color_input(path)
|
||||
if result is not None:
|
||||
bpy.data.images.remove(result, do_unlink=True)
|
||||
np.testing.assert_array_equal(image_rgba_buffer(source), original)
|
||||
assert image_rgba_buffer(padded)[1, 4, 3] == pytest.approx(1.0)
|
||||
assert padded.is_float is float_buffer
|
||||
assert padded.alpha_mode == "CHANNEL_PACKED"
|
||||
assert padded.packed_file is not None
|
||||
|
||||
|
||||
def test_local_cutout_selection_matches_uint8_tenth_alpha_boundary():
|
||||
@@ -780,6 +525,7 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job():
|
||||
content_values,
|
||||
selection_mask.bounds,
|
||||
alpha_threshold=0.5,
|
||||
boundary_padding=0.0,
|
||||
color_image=color_image,
|
||||
)
|
||||
|
||||
@@ -799,14 +545,6 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job():
|
||||
modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Cleanup Threshold")),
|
||||
expected_cleanup,
|
||||
)
|
||||
assert (
|
||||
modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Smooth Weight"))
|
||||
== 1.0
|
||||
)
|
||||
assert (
|
||||
modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Smooth"))
|
||||
== 0
|
||||
)
|
||||
assert len(result.data.vertices) > 4
|
||||
assert len(result.data.polygons) > 1
|
||||
assert all(polygon.use_smooth for polygon in result.data.polygons)
|
||||
|
||||
Reference in New Issue
Block a user