- 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.
319 lines
12 KiB
Python
319 lines
12 KiB
Python
from unittest.mock import patch
|
|
import bpy
|
|
import numpy as np
|
|
from mathutils import Matrix, Vector
|
|
|
|
|
|
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 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
|
|
|
|
|
|
def test_build_plane_keeps_one_quad_and_generates_topology_in_geometry_nodes():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
image = bpy.data.images.new("Source", width=4096, height=2048)
|
|
source = bpy.data.objects.new("Source", None)
|
|
source.empty_display_type = "IMAGE"
|
|
source.data = image
|
|
source.empty_display_size = 2.0
|
|
bpy.context.collection.objects.link(source)
|
|
material = bpy.data.materials.new("Source.material")
|
|
|
|
result = plane_object.create_plane_object(
|
|
bpy.context,
|
|
source,
|
|
2,
|
|
material,
|
|
)
|
|
|
|
assert result["anyimage_mesh_shape"] == "PLANE"
|
|
assert len(result.data.vertices) == 4
|
|
assert len(result.data.polygons) == 1
|
|
assert all(polygon.use_smooth for polygon in result.data.polygons)
|
|
assert cutout_shape.BALLOON_ATTRIBUTE_NAME not in result.data.attributes
|
|
assert result.modifiers[0].node_group.name == "O Image Plane"
|
|
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
|
evaluated_mesh = evaluated.to_mesh()
|
|
try:
|
|
assert len(evaluated_mesh.polygons) > 0
|
|
assert result.data.materials[0] == material
|
|
assert evaluated_mesh.materials[0].original == material
|
|
assert all(polygon.material_index == 0 for polygon in evaluated_mesh.polygons)
|
|
finally:
|
|
evaluated.to_mesh_clear()
|
|
|
|
|
|
def test_clipboard_plane_uses_the_mesh_material_slot():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
image = bpy.data.images.new("Clipboard Plane", width=128, height=64, alpha=True)
|
|
|
|
with patch.object(clipboard_actions, "_place_in_view"):
|
|
result = clipboard_actions.add_image_plane(bpy.context, image)
|
|
|
|
assert _inputs(result.modifiers[0].node_group) == [
|
|
"Geometry",
|
|
"Subdivide",
|
|
"Thickness",
|
|
]
|
|
assert len(result.data.materials) == 1
|
|
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
|
evaluated_mesh = evaluated.to_mesh()
|
|
try:
|
|
assert evaluated_mesh.materials[0].original == result.data.materials[0]
|
|
assert all(polygon.material_index == 0 for polygon in evaluated_mesh.polygons)
|
|
finally:
|
|
evaluated.to_mesh_clear()
|
|
|
|
|
|
def test_converted_planes_center_the_origin_without_moving_world_corners():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
saved = build_image_depth_plane_group(build_image_plane_group())
|
|
|
|
def create_source(name):
|
|
image = bpy.data.images.new(name, width=200, height=100)
|
|
source = bpy.data.objects.new(name, None)
|
|
source.empty_display_type = "IMAGE"
|
|
source.data = image
|
|
source.empty_display_size = 2.0
|
|
source.empty_image_offset = (0.25, -0.2)
|
|
source.matrix_world = (
|
|
Matrix.Translation((3.0, -2.0, 5.0))
|
|
@ Matrix.Rotation(0.4, 4, "Z")
|
|
@ Matrix.Diagonal((1.5, 0.75, 2.0, 1.0))
|
|
)
|
|
bpy.context.collection.objects.link(source)
|
|
return source
|
|
|
|
def assert_centered(result, bounds, source_matrix):
|
|
x_min, x_max, y_min, y_max = bounds
|
|
center = Vector(
|
|
((x_min + x_max) * 0.5, (y_min + y_max) * 0.5, 0.0)
|
|
)
|
|
assert np.allclose(
|
|
tuple(result.matrix_world.translation),
|
|
tuple(source_matrix @ center),
|
|
atol=1e-6,
|
|
)
|
|
local = np.asarray([tuple(vertex.co) for vertex in result.data.vertices])
|
|
assert np.allclose(local[:, :2].min(axis=0), (-1.0, -0.5), atol=1e-6)
|
|
assert np.allclose(local[:, :2].max(axis=0), (1.0, 0.5), atol=1e-6)
|
|
expected = sorted(
|
|
tuple(source_matrix @ Vector((x, y, 0.0)))
|
|
for x in (x_min, x_max)
|
|
for y in (y_min, y_max)
|
|
)
|
|
actual = sorted(
|
|
tuple(result.matrix_world @ vertex.co)
|
|
for vertex in result.data.vertices
|
|
)
|
|
assert np.allclose(actual, expected, atol=1e-6)
|
|
|
|
plane_source = create_source("Centered Plane")
|
|
plane_bounds = image_data.image_empty_bounds(plane_source)
|
|
plane_matrix = plane_source.matrix_world.copy()
|
|
plane_material = bpy.data.materials.new("Centered Plane Material")
|
|
plane = plane_object.create_plane_object(
|
|
bpy.context,
|
|
plane_source,
|
|
0,
|
|
plane_material,
|
|
)
|
|
assert_centered(plane, plane_bounds, plane_matrix)
|
|
assert plane["anyimage_mesh_shape"] == "PLANE"
|
|
|
|
depth_source = create_source("Centered Depth Plane")
|
|
depth_bounds = image_data.image_empty_bounds(depth_source)
|
|
depth_matrix = depth_source.matrix_world.copy()
|
|
depth_values = np.ones((2, 2, 3), dtype=np.float32)
|
|
depth_image = _depth_image(depth_values, np.ones((2, 2), dtype=bool))
|
|
depth_material = bpy.data.materials.new("Centered Depth Material")
|
|
depth_plane = plane_object.create_depth_plane_object(
|
|
bpy.context,
|
|
depth_source,
|
|
0,
|
|
depth_material,
|
|
depth_image,
|
|
_depth_metadata(
|
|
((0.5, 0.0, 0.5), (0.0, 0.5, 0.5), (0.0, 0.0, 1.0)),
|
|
(2, 2),
|
|
1.0,
|
|
),
|
|
"DEPTH",
|
|
)
|
|
assert_centered(depth_plane, depth_bounds, depth_matrix)
|
|
assert depth_plane["anyimage_mesh_shape"] == "DEPTH_PLANE"
|
|
depth_modifier = depth_plane.modifiers[0]
|
|
assert depth_modifier.node_group == saved
|
|
assert saved.name == "O Image Depth Plane"
|
|
uniform_scale = depth_modifier[
|
|
object_data.modifier_input_identifier(
|
|
depth_modifier.node_group,
|
|
"Uniform Scale",
|
|
)
|
|
]
|
|
assert np.isclose(
|
|
depth_modifier[
|
|
object_data.modifier_input_identifier(
|
|
depth_modifier.node_group,
|
|
"Reference Depth",
|
|
)
|
|
],
|
|
uniform_scale,
|
|
)
|
|
|
|
|
|
def test_relief_plane_builds_a_fixed_base_terrain_block():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
build_image_relief_plane_group(build_image_plane_group())
|
|
image = bpy.data.images.new("Terrain", width=4, height=4)
|
|
source = bpy.data.objects.new("Terrain", None)
|
|
source.empty_display_type = "IMAGE"
|
|
source.data = image
|
|
source.empty_display_size = 2.0
|
|
bpy.context.collection.objects.link(source)
|
|
material = bpy.data.materials.new("Terrain Material")
|
|
depth_image = _depth_image(
|
|
np.ones((4, 4, 3), dtype=np.float32),
|
|
np.ones((4, 4), dtype=bool),
|
|
)
|
|
result = plane_object.create_depth_plane_object(
|
|
bpy.context,
|
|
source,
|
|
2,
|
|
material,
|
|
depth_image,
|
|
_depth_metadata(
|
|
((1.5, 0.0, 1.5), (0.0, 1.5, 1.5), (0.0, 0.0, 1.0)),
|
|
(4, 4),
|
|
1.0,
|
|
),
|
|
"RELIEF",
|
|
)
|
|
assert result["anyimage_mesh_shape"] == "RELIEF_PLANE"
|
|
assert result.modifiers[0].node_group.name == "O Image Relief Plane"
|
|
modifier = result.modifiers[0]
|
|
group = modifier.node_group
|
|
for name in ("Thickness", "Depth Scale"):
|
|
identifier = object_data.modifier_input_identifier(group, name)
|
|
assert modifier[identifier] == 0.5
|
|
assert modifier[
|
|
object_data.modifier_input_identifier(group, "Depth Offset")
|
|
] == 1.0
|
|
|
|
def set_input(name, value):
|
|
object_data.set_modifier_input(
|
|
modifier,
|
|
object_data.modifier_input_identifier(group, name),
|
|
value,
|
|
)
|
|
result.update_tag(refresh={"DATA"})
|
|
bpy.context.view_layer.update()
|
|
|
|
set_input("Depth Scale", 0.0)
|
|
set_input("Thickness", 0.1)
|
|
set_input("Depth Offset", 0.0)
|
|
baseline = _evaluated_positions(result)
|
|
assert np.isclose(baseline[:, 2].min(), 0.0, atol=1e-6)
|
|
assert np.isclose(baseline[:, 2].max(), 0.1, atol=1e-6)
|
|
baseline_base = {
|
|
(round(float(x), 6), round(float(y), 6))
|
|
for x, y, z in baseline
|
|
if np.isclose(z, 0.0, atol=1e-6)
|
|
}
|
|
|
|
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
|
evaluated_mesh = evaluated.to_mesh()
|
|
try:
|
|
assert result.data.materials[0] == material
|
|
assert evaluated_mesh.materials[0].original == material
|
|
assert all(polygon.material_index == 0 for polygon in evaluated_mesh.polygons)
|
|
assert "UVMap" in evaluated_mesh.uv_layers
|
|
finally:
|
|
evaluated.to_mesh_clear()
|
|
|
|
set_input("Depth Scale", 1.0)
|
|
set_input("Reference Depth", 1.2)
|
|
outward = _evaluated_positions(result)
|
|
assert np.isclose(outward[:, 2].min(), 0.0, atol=1e-6)
|
|
assert np.isclose(outward[:, 2].max(), 0.3, atol=1e-5)
|
|
outward_base = {
|
|
(round(float(x), 6), round(float(y), 6))
|
|
for x, y, z in outward
|
|
if np.isclose(z, 0.0, atol=1e-6)
|
|
}
|
|
assert outward_base == baseline_base
|
|
z_levels = np.unique(np.round(outward[:, 2], 6))
|
|
assert len(z_levels) >= 4
|
|
assert np.allclose(np.diff(z_levels), np.diff(z_levels)[0], atol=1e-5)
|
|
|
|
set_input("Reference Depth", 0.95)
|
|
inward = _evaluated_positions(result)
|
|
assert np.isclose(inward[:, 2].min(), 0.0, atol=1e-6)
|
|
assert np.isclose(inward[:, 2].max(), 0.1, atol=1e-5)
|
|
|
|
set_input("Reference Depth", 0.0)
|
|
limited = _evaluated_positions(result)
|
|
assert np.isclose(limited[:, 2].min(), 0.0, atol=1e-6)
|
|
assert np.isclose(limited[:, 2].max(), 0.1, atol=1e-5)
|
|
|
|
set_input("Reference Depth", 1.3)
|
|
outward_beyond_thickness = _evaluated_positions(result)
|
|
assert np.isclose(outward_beyond_thickness[:, 2].max(), 0.4, atol=1e-5)
|
|
|
|
set_input("Thickness", 0.0)
|
|
set_input("Reference Depth", 1.2)
|
|
sheet = _evaluated_positions(result)
|
|
assert np.allclose(sheet[:, 2], 0.2, atol=1e-5)
|
|
set_input("Reference Depth", 0.8)
|
|
assert np.allclose(_evaluated_positions(result)[:, 2], 0.0, atol=1e-5)
|
|
|
|
|
|
def test_relief_plane_initializes_a_fitted_depth_direction():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
build_image_relief_plane_group(build_image_plane_group())
|
|
image = bpy.data.images.new("Sloped Relief", width=5, height=3)
|
|
source = bpy.data.objects.new("Sloped Relief", None)
|
|
source.empty_display_type = "IMAGE"
|
|
source.data = image
|
|
source.empty_display_size = 2.0
|
|
bpy.context.collection.objects.link(source)
|
|
bounds = image_data.image_empty_bounds(source)
|
|
x = bounds[0] + (np.arange(5) + 0.5) / 5 * (bounds[1] - bounds[0])
|
|
y = bounds[3] - (np.arange(3) + 0.5) / 3 * (bounds[3] - bounds[2])
|
|
xx, yy = np.meshgrid(x, y)
|
|
depth = 2.0 + 0.2 * xx + 0.1 * yy
|
|
depth_image = _depth_image(
|
|
np.repeat(depth[..., None], 3, axis=2),
|
|
np.ones(depth.shape, dtype=bool),
|
|
)
|
|
metadata = _depth_metadata(
|
|
((2.0, 0.0, 2.0), (0.0, 2.0, 1.0), (0.0, 0.0, 1.0)),
|
|
(5, 3),
|
|
2.0,
|
|
)
|
|
result = plane_object.create_depth_plane_object(
|
|
bpy.context,
|
|
source,
|
|
2,
|
|
bpy.data.materials.new("Sloped Relief Material"),
|
|
depth_image,
|
|
metadata,
|
|
"RELIEF",
|
|
)
|
|
modifier = result.modifiers[0]
|
|
direction = modifier[
|
|
object_data.modifier_input_identifier(modifier.node_group, "Depth Direction")
|
|
]
|
|
uniform_scale = modifier[
|
|
object_data.modifier_input_identifier(modifier.node_group, "Uniform Scale")
|
|
]
|
|
expected = np.asarray((0.2 * uniform_scale, 0.1 * uniform_scale, 1.0))
|
|
expected /= np.linalg.norm(expected)
|
|
np.testing.assert_allclose(direction, expected, atol=1e-6)
|