- 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.
143 lines
6.1 KiB
Python
143 lines
6.1 KiB
Python
from types import SimpleNamespace
|
|
from unittest.mock import Mock, patch
|
|
|
|
import bpy
|
|
import numpy as np
|
|
import pytest
|
|
from mathutils import Matrix
|
|
|
|
from anyimage import properties
|
|
from anyimage.operators.cutout_tool import geometry as g
|
|
from anyimage.operators.cutout_tool import operators
|
|
|
|
|
|
def source_plane(size=(100, 100), display_size=1, matrix=None):
|
|
return SimpleNamespace(
|
|
data=SimpleNamespace(size=size),
|
|
empty_display_size=display_size,
|
|
empty_image_offset=(-0.5, -0.5),
|
|
matrix_world=Matrix.Identity(4) if matrix is None else matrix,
|
|
)
|
|
|
|
|
|
def test_relative_floor_uses_visible_world_bounds(monkeypatch):
|
|
monkeypatch.setattr(g, "configured_min_cutout_relative_edge_length", lambda _: 0.01)
|
|
alpha = np.zeros((100, 100))
|
|
alpha[25:75, 25:75] = 1
|
|
source = source_plane(display_size=2)
|
|
resolve = lambda length: g.effective_cutout_edge_length(None, source, length, alpha, (0, 0, 100, 100))
|
|
assert resolve(0.001) == 0.01
|
|
assert resolve(0.1) == 0.1
|
|
source.matrix_world = Matrix.Rotation(0.7, 4, "Y") @ Matrix.Diagonal((2, 3, 1, 1))
|
|
assert resolve(0.001) == pytest.approx(0.03)
|
|
monkeypatch.setattr(g, "configured_min_cutout_relative_edge_length", lambda _: 0.02)
|
|
assert resolve(0.001) == pytest.approx(0.06)
|
|
|
|
|
|
@pytest.mark.parametrize("fine_outline", (False, True))
|
|
def test_mesh_density_survives_resolution_and_canvas_changes(fine_outline):
|
|
meshes = []
|
|
for size, display_size, inset in ((100, 1, 0), (200, 1, 0), (200, 2, 50)):
|
|
source = source_plane((size, size), display_size)
|
|
alpha = np.zeros((size, size))
|
|
alpha[inset:size-inset, inset:size-inset] = 1
|
|
spacing = g.effective_cutout_edge_length(bpy.context, source, 0.1, alpha, (0, 0, size, size))
|
|
assert spacing == 0.1
|
|
meshes.append(g.build_base_shape(bpy.context, source, spacing, alpha, (0, 0, size, size), fine_outline=fine_outline))
|
|
counts = [len(mesh.vertices) for mesh in meshes]
|
|
assert max(counts) / min(counts) < 1.05
|
|
for mesh in meshes:
|
|
assert np.isfinite(mesh.vertices).all()
|
|
np.testing.assert_allclose(np.ptp(mesh.vertices[:, :2], axis=0), (1, 1), atol=0.03)
|
|
|
|
|
|
@pytest.mark.parametrize("fine_outline", (False, True))
|
|
def test_world_scaling_adds_samples_and_rotation_preserves_density(fine_outline):
|
|
alpha = np.ones((100, 100))
|
|
counts = []
|
|
for matrix in (Matrix.Identity(4), Matrix.Diagonal((2, 3, 1, 1)),
|
|
Matrix.Rotation(0.6, 4, "Z") @ Matrix.Diagonal((2, 3, 1, 1))):
|
|
source = source_plane(matrix=matrix)
|
|
mesh = g.build_base_shape(bpy.context, source, 0.1, alpha, (0, 0, 100, 100), fine_outline=fine_outline)
|
|
counts.append(len(mesh.vertices))
|
|
planar = mesh.vertices[:, :2] @ g.metric_transform(g.world_plane_metric(matrix)[0])
|
|
edges = np.unique(np.sort(np.concatenate([mesh.faces[:, [0, 1]], mesh.faces[:, [1, 2]], mesh.faces[:, [2, 0]]]), axis=1), axis=0)
|
|
lengths = np.linalg.norm(planar[edges[:, 0]] - planar[edges[:, 1]], axis=1)
|
|
assert 0.06 < np.median(lengths) < 0.14
|
|
assert counts[1] > counts[0] * 4
|
|
assert abs(counts[2] - counts[1]) < counts[1] * 0.02
|
|
|
|
|
|
def test_length_property_uses_scene_units_and_preserves_physical_value():
|
|
class CutoutLengthTestSettings(bpy.types.PropertyGroup):
|
|
edge_length: properties.cutout_edge_length_property(store_meters=True)
|
|
|
|
scene = bpy.context.scene
|
|
previous_scale, previous_system = scene.unit_settings.scale_length, scene.unit_settings.system
|
|
bpy.utils.register_class(CutoutLengthTestSettings)
|
|
bpy.types.Scene.cutout_length_test = bpy.props.PointerProperty(type=CutoutLengthTestSettings)
|
|
try:
|
|
scene.unit_settings.system = "METRIC"
|
|
scene.unit_settings.scale_length = 0.01
|
|
settings = scene.cutout_length_test
|
|
assert settings.edge_length == pytest.approx(10)
|
|
settings.edge_length = bpy.utils.units.to_value("METRIC", "LENGTH", "0.2m") / scene.unit_settings.scale_length
|
|
assert settings.edge_length == pytest.approx(20)
|
|
scene.unit_settings.scale_length = 1
|
|
assert settings.edge_length == pytest.approx(0.2)
|
|
finally:
|
|
scene.unit_settings.scale_length = previous_scale
|
|
scene.unit_settings.system = previous_system
|
|
if "cutout_length_test" in scene:
|
|
del scene["cutout_length_test"]
|
|
del bpy.types.Scene.cutout_length_test
|
|
bpy.utils.unregister_class(CutoutLengthTestSettings)
|
|
|
|
|
|
def test_operator_length_property_accepts_world_units():
|
|
captured = []
|
|
|
|
class CutoutLengthTestOperator(bpy.types.Operator):
|
|
bl_idname = "anyimage.test_cutout_length"
|
|
bl_label = "Test Cutout Length"
|
|
edge_length: properties.cutout_edge_length_property()
|
|
|
|
def execute(self, context):
|
|
captured.append(self.edge_length)
|
|
return {"FINISHED"}
|
|
|
|
units = bpy.context.scene.unit_settings
|
|
previous = units.scale_length
|
|
bpy.utils.register_class(CutoutLengthTestOperator)
|
|
try:
|
|
units.scale_length = 0.01
|
|
bpy.ops.anyimage.test_cutout_length(edge_length=20)
|
|
assert captured == pytest.approx([20])
|
|
finally:
|
|
units.scale_length = previous
|
|
bpy.utils.unregister_class(CutoutLengthTestOperator)
|
|
|
|
|
|
def test_effective_length_report_and_geometry_share_value():
|
|
report = Mock()
|
|
context = SimpleNamespace(scene=SimpleNamespace(unit_settings=SimpleNamespace(system="METRIC", scale_length=0.01)))
|
|
with patch.object(operators, "effective_cutout_edge_length", return_value=20), \
|
|
patch.object(operators, "build_base_shape") as build, \
|
|
patch.object(operators, "create_shape_object") as create, \
|
|
patch.object(operators.bpy.ops.ed, "undo_push", return_value={"FINISHED"}):
|
|
operators.create_cutout_shape(
|
|
context,
|
|
object(),
|
|
"FLAT",
|
|
10,
|
|
np.ones((10, 10)),
|
|
(0, 0, 10, 10),
|
|
boundary_padding=0.0,
|
|
color_image=object(),
|
|
report=report,
|
|
)
|
|
assert build.call_args.args[2] == 20
|
|
assert create.call_args.args[3] == 20
|
|
assert report.call_args.args[0] == {"INFO"}
|
|
assert "20 cm" in report.call_args.args[1]
|