Refactor geometry generation to standardize Blender object coordinates

- Unified the canonical local frame for generated geometries (Plane, Depth Plane, Relief Plane, Cutout, Depth Cutout) to align with Blender's object coordinate system, setting X as horizontal, Z as up, and +Y as depth.
- Removed the `Depth Axis` parameter from `O Image Cutout` and `O Image Depth Cutout`, simplifying the geometry generation process.
- Updated the depth direction defaults to use +Y instead of -Z for `O Image Relief Plane` and Depth Symmetry.
- Adjusted the geometry nodes to ensure thickness, projection, and shell displacements operate along the Y-axis.
- Implemented new capabilities for handling object space normals to align with the new canonical frame.
- Enhanced the Depth Symmetry functionality to mirror across the canonical Y-axis and perform a final rotation to maintain the correct orientation.
- Added tests to validate the new coordinate system and ensure compatibility with existing assets.
This commit is contained in:
2026-09-15 13:33:37 +08:00
parent 591e62085a
commit 4035f5c9e4
47 changed files with 892 additions and 570 deletions

View File

@@ -121,13 +121,13 @@ def test_plane_validity_hole_blurs_depth_and_protects_rectangle():
after, result_faces, result_uv, *_ = plane_mesh(obj)
assert faces == result_faces
np.testing.assert_array_equal(result_uv, uv)
assert after[:, 2].min() > before[:, 2].min() + .1
assert after[:, 1].max() < before[:, 1].max() - .1
protected = np.any(np.isclose(uv, 0) | np.isclose(uv, 1), axis=1)
corners = np.concatenate(faces)
outer = np.unique(corners[protected])
np.testing.assert_array_equal(after[outer], before[outer])
np.testing.assert_allclose(after[:, :2] / (1.2 - after[:, 2:3]),
before[:, :2] / (1.2 - before[:, 2:3]), atol=1e-6)
np.testing.assert_allclose(after[:, [0, 2]] / (1.2 + after[:, 1:2]),
before[:, [0, 2]] / (1.2 + before[:, 1:2]), atol=1e-6)
set_value("Depth Mask", False)
intact = plane_mesh(obj)[0]
set_value("Boundary Smooth", 0)

View File

@@ -122,7 +122,7 @@ def test_smoothing_reduces_stairs_without_leaving_two_ring_band(triangles):
original, faces = evaluated(obj)
boundary = boundary_neighbors(faces)
outer = np.isclose(original[:, 0], 0) | np.isclose(original[:, 0], 2)
outer |= np.isclose(original[:, 1], 0) | np.isclose(original[:, 1], 1)
outer |= np.isclose(original[:, 2], 0) | np.isclose(original[:, 2], 1)
cut = {i for i in boundary if not outer[i]}
band = edge_band(faces, boundary)
movable = np.array([i in band for i in range(len(original))])
@@ -142,7 +142,7 @@ def test_smoothing_reduces_stairs_without_leaving_two_ring_band(triangles):
before.append(np.linalg.norm(original[adjacent].mean(axis=0) - original[i]))
after.append(np.linalg.norm(smoothed[adjacent].mean(axis=0) - smoothed[i]))
assert sum(after) < 0.8 * sum(before)
np.testing.assert_array_equal(smoothed[:, 2], original[:, 2])
np.testing.assert_array_equal(smoothed[:, 1], original[:, 1])
set_value("Smooth Weight", 0)
np.testing.assert_array_equal(evaluated(obj)[0], original)
set_value("Smooth Weight", 1)
@@ -251,7 +251,7 @@ def test_validity_cut_smoothing_preserves_original_outline_and_interior():
set_value("Depth Mask", True)
before, faces, uv, *_ = plane_mesh(obj)
boundary = boundary_neighbors(faces)
outer = np.any(np.isclose(before[:, :2], low[:2]) | np.isclose(before[:, :2], high[:2]), axis=1)
outer = np.any(np.isclose(before[:, [0, 2]], low[[0, 2]]) | np.isclose(before[:, [0, 2]], high[[0, 2]]), axis=1)
cut = {i for i in boundary if not outer[i]}
band = edge_band(faces, cut)
fixed = np.array([i not in band or outer[i] for i in range(len(before))])

View File

@@ -67,7 +67,7 @@ def test_split_profile_tapers_within_boundary_band(resolution, aspect):
set_value("Depth Split", .1)
points, faces = evaluated(obj)
boundary = boundary_neighbors(faces)
cut = [i for i in boundary if 0 < points[i,1] < 1 and .5*aspect < points[i,0] < 1.5*aspect]
cut = [i for i in boundary if 0 < points[i,2] < 1 and .5*aspect < points[i,0] < 1.5*aspect]
assert cut
band = edge_band(faces, cut)
outside = [i for i in range(len(points)) if i not in band]

View File

@@ -1,196 +1,173 @@
"""Check composition against the original Projected geometry baseline."""
"""Check the canonical Y mirror and final X orientation for Depth Symmetry."""
from collections import Counter
import json
from pathlib import Path
import bpy
import numpy as np
import pytest
from scipy.spatial import cKDTree
from mathutils import Euler, Vector
from anyimage.common.object import modifier_input_identifier, set_modifier_input
from anyimage.operators.cutout_tool.object import load_cutout_node_group
from tests.support.depth_surface import evaluated, assert_closed
from tests.support.depth_surface import assert_closed, evaluated
from tools.nodes.common.normal_map import AXIS_ATTRIBUTE, FACE_ATTRIBUTE, ROTATION_ATTRIBUTE
from tools.nodes.groups.image_depth_cutout import build_image_depth_cutout_group
from tools.nodes.groups.image_cutout_symmetry import build_image_cutout_symmetry_group
from tools.nodes.common.normal_map import ROTATION_ATTRIBUTE, FACE_ATTRIBUTE, AXIS_ATTRIBUTE
@pytest.fixture(params=['definition', 'asset'])
def _set_value(obj, modifier, group):
def setter(name, value, subtype=None):
set_modifier_input(
modifier,
modifier_input_identifier(group, name, subtype=subtype),
value,
)
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
return setter
@pytest.fixture(params=["definition", "asset"])
def symmetry(request):
bpy.ops.wm.read_factory_settings(use_empty=True)
count = 5
y, x = np.meshgrid(np.linspace(-1, 1, count), np.linspace(-1, 1, count), indexing='ij')
z, x = np.meshgrid(
np.linspace(-1, 1, count),
np.linspace(-1, 1, count),
indexing="ij",
)
faces = []
for row in range(count - 1):
for col in range(count - 1):
a = row * count + col
faces.extend(((a, a + 1, a + count + 1), (a, a + count + 1, a + count)))
# Match Cutout's outline cleanup before recording or comparing projection.
boundary = (np.maximum(abs(x), abs(y)) == 1).ravel()
faces = [face for face in faces if not all(boundary[i] for i in face)]
mesh = bpy.data.meshes.new('Curved Depth')
mesh.from_pydata(np.column_stack((x.ravel(), y.ravel(), np.zeros(x.size))), [], faces)
uv = mesh.uv_layers.new(name='UVMap')
for column in range(count - 1):
start = row * count + column
faces.extend(((start, start + 1, start + count + 1), (start, start + count + 1, start + count)))
boundary = (np.maximum(abs(x), abs(z)) == 1).ravel()
faces = [face for face in faces if not all(boundary[index] for index in face)]
mesh = bpy.data.meshes.new("Curved Depth")
mesh.from_pydata(np.column_stack((x.ravel(), np.zeros(x.size), z.ravel())), [], faces)
uv = mesh.uv_layers.new(name="UVMap")
for loop in mesh.loops:
row, col = divmod(loop.vertex_index, count)
uv.data[loop.index].uv = ((col + .5) / count, (row + .5) / count)
profile = .3 * np.maximum(0, 1 - np.maximum(abs(x), abs(y)))
mesh.attributes.new('o_balloon', 'FLOAT', 'POINT').data.foreach_set('value', profile.ravel())
mesh.attributes.new('user_probe', 'FLOAT', 'POINT').data.foreach_set('value', np.ones(x.size))
image = bpy.data.images.new('Depth', width=count, height=count, float_buffer=True)
image.colorspace_settings.name = 'Non-Color'
image.alpha_mode = 'CHANNEL_PACKED'
depth = 1 + .6 * x ** 2 + .3 * y ** 2
image.pixels.foreach_set(np.dstack((x, -y, depth, np.ones_like(x))).astype(np.float32).ravel())
obj = bpy.data.objects.new('Symmetry', mesh)
row, column = divmod(loop.vertex_index, count)
uv.data[loop.index].uv = ((column + 0.5) / count, (row + 0.5) / count)
profile = 0.3 * np.maximum(0, 1 - np.maximum(abs(x), abs(z)))
mesh.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set(
"value", profile.ravel()
)
mesh.attributes.new("user_probe", "FLOAT", "POINT").data.foreach_set(
"value", np.ones(x.size)
)
image = bpy.data.images.new("Depth", width=count, height=count, float_buffer=True)
image.colorspace_settings.name = "Non-Color"
image.alpha_mode = "CHANNEL_PACKED"
depth = 1 + 0.6 * x**2 + 0.3 * z**2
image.pixels.foreach_set(
np.dstack((x, -z, depth, np.ones_like(x))).astype(np.float32).ravel()
)
obj = bpy.data.objects.new("Symmetry", mesh)
bpy.context.collection.objects.link(obj)
setters = []
for name, builder in [('DEPTH_SOLID', build_image_depth_cutout_group),
('DEPTH_SYMMETRY', build_image_cutout_symmetry_group)]:
group = builder() if request.param == 'definition' else load_cutout_node_group(name)
modifier = obj.modifiers.new(group.name, 'NODES')
modifier.node_group = group
def set_value(name, value, subtype=None, modifier=modifier, group=group):
set_modifier_input(modifier, modifier_input_identifier(group, name, subtype=subtype), value)
obj.update_tag(refresh={'DATA'})
bpy.context.view_layer.update()
cutout_group = (
build_image_depth_cutout_group()
if request.param == "definition"
else load_cutout_node_group("DEPTH_SOLID")
)
mirror_group = (
build_image_cutout_symmetry_group()
if request.param == "definition"
else load_cutout_node_group("DEPTH_SYMMETRY")
)
cutout = obj.modifiers.new(cutout_group.name, "NODES")
cutout.node_group = cutout_group
mirror = obj.modifiers.new(mirror_group.name, "NODES")
mirror.node_group = mirror_group
setters.append(set_value)
cutout, mirror = setters
mirror('Fill Smooth', 0)
for name, value in [('Depth Image', image), ('Reference Depth', 1.45),
('Depth Split', 0), ('Boundary Smooth', 0), ('Depth Axis', 0)]:
cutout(name, value)
yield obj, cutout, mirror
bpy.ops.wm.read_factory_settings(use_empty=True)
def oriented_faces(points, faces):
"""Ignore vertex numbering while retaining polygon winding."""
keys = [tuple(np.round(point, 6)) for point in points]
result = []
for face in faces:
loop = tuple(keys[i] for i in face)
result.append(min(loop[i:] + loop[:i] for i in range(len(loop))))
return Counter(result)
@pytest.mark.parametrize('mode', [0, 1])
def test_zero_thickness_matches_original_projected(symmetry, mode):
obj, cutout, mirror = symmetry
cutout('Mode', mode)
baseline = json.loads((Path(__file__).parent / 'fixtures/projected_symmetry.json').read_text())
for case in baseline['cases']:
mirror('Direction', case['direction'])
mirror('Fill Sides', case['filled'])
points, faces = evaluated(obj)
expected = np.array(case['points'])
assert len(points) == len(expected)
assert cKDTree(points).query(expected)[0].max() < 1e-6
assert oriented_faces(points, faces) == oriented_faces(expected, case['faces'])
@pytest.mark.parametrize('mode', [0, 1])
@pytest.mark.parametrize('smooth', [0, 3])
def test_completed_geometry_is_mirrored_and_closed(symmetry, mode, smooth):
obj, cutout, mirror = symmetry
cutout('Mode', mode)
cutout('Thickness', .2 if mode else .6, 'DISTANCE' if mode else 'NONE')
mirror('Direction', (.3, -.2, 1))
mirror('Fill Smooth', smooth)
points, faces = evaluated(obj)
assert len(points) and np.isfinite(points).all()
assert cKDTree(points).query(points * (1, 1, -1))[0].max() < 1e-6
assert_closed(faces)
orientation = Counter()
for face in faces:
for a, b in zip(face, (*face[1:], face[0])):
orientation[tuple(sorted((a, b)))] += 1 if a < b else -1
assert not any(orientation.values())
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
mesh = result.to_mesh()
try:
assert 'UVMap' in mesh.uv_layers and 'user_probe' in mesh.attributes
direction = Vector((.3, -.2, 1)).normalized()
wall_count = 0
for polygon in mesh.polygons:
i = polygon.index
rotation = Euler(mesh.attributes[ROTATION_ATTRIBUTE].data[i].vector)
np.testing.assert_allclose(rotation.to_matrix() @ Vector((0, 0, 1)), direction, atol=1e-6)
assert mesh.attributes[AXIS_ATTRIBUTE].data[i].value == 0
if mesh.attributes[FACE_ATTRIBUTE].data[i].value == 3:
wall_count += 1
if smooth == 0:
assert abs(polygon.normal.z) < 1e-5
assert wall_count
finally:
result.to_mesh_clear()
def test_fill_smoothing_preserves_surfaces_and_only_moves_wall_interiors(symmetry):
obj, _, mirror = symmetry
mirror('Fill Sides', False)
original, original_faces = evaluated(obj)
mirror('Fill Smooth', 3)
points, faces = evaluated(obj)
np.testing.assert_array_equal(points, original)
assert faces == original_faces
mirror('Fill Sides', True)
points, faces = evaluated(obj)
assert_closed(faces)
assert cKDTree(points).query(original)[0].max() < 1e-6
assert cKDTree(points).query(points * (1, 1, -1))[0].max() < 1e-6
assert len(points) > len(original)
assert cKDTree(original[:, :2]).query(points[:, :2])[0].max() > .001
assert oriented_faces(original, original_faces).items() <= oriented_faces(points, faces).items()
def test_direction_offset_and_empty_geometry(symmetry):
obj, _, mirror = symmetry
mirror('Offset', 2.)
mirror('Direction', (.3, -.2, 1))
original, original_faces = evaluated(obj)
mirror('Offset', 2.2)
points, faces = evaluated(obj)
assert faces == original_faces
np.testing.assert_allclose(points[:, :2], original[:, :2], atol=1e-6)
np.testing.assert_allclose(abs(points[:, 2]) - abs(original[:, 2]), .2, atol=1e-6)
mirror('Offset', -10.)
points, faces = evaluated(obj)
assert not len(points) and not faces
def test_symmetry_scale_uses_aligned_axis(symmetry):
obj, _, mirror = symmetry
mirror('Direction', (.3, -.2, 1))
mirror('Offset', .5)
mirror('Fill Sides', False)
original, original_faces = evaluated(obj)
mirror('Scale', 2)
points, faces = evaluated(obj)
assert faces == original_faces
np.testing.assert_allclose(points[:, :2], original[:, :2], atol=1e-6)
np.testing.assert_allclose(points[:, 2], original[:, 2] * 2, atol=1e-6)
cutout_setter = _set_value(obj, cutout, cutout_group)
mirror_setter = _set_value(obj, mirror, mirror_group)
mirror_setter("Fill Smooth", 0)
for name, value in (
("Depth Image", image),
("Reference Depth", 1.45),
("Depth Split", 0),
("Boundary Smooth", 0),
):
cutout_setter(name, value)
return obj, cutout, mirror, cutout_setter, mirror_setter
def test_symmetry_interface_and_cutout_defaults(symmetry):
obj, _, _ = symmetry
inputs = [item for item in obj.modifiers[1].node_group.interface.items_tree
if item.item_type == 'SOCKET' and item.in_out == 'INPUT']
assert [item.name for item in inputs] == [
'Geometry', 'Direction', 'Offset', 'Scale', 'Fill Sides', 'Fill Smooth',
_obj, cutout, mirror, _cutout_setter, _mirror_setter = symmetry
mirror_inputs = [
item
for item in mirror.node_group.interface.items_tree
if item.item_type == "SOCKET" and item.in_out == "INPUT"
]
assert [item.name for item in mirror_inputs] == [
"Geometry",
"Direction",
"Offset",
"Scale",
"Fill Sides",
"Fill Smooth",
]
assert tuple(mirror_inputs[1].default_value) == (0, 0, 1)
assert mirror_inputs[3].default_value == 1
assert mirror_inputs[2].min_value == -10
assert mirror_inputs[2].max_value == 10
assert mirror_inputs[3].min_value == 0
assert mirror_inputs[3].max_value == 2
assert mirror_inputs[4].default_value is True
assert mirror_inputs[5].default_value == 2
cutout_names = {
item.name
for item in cutout.node_group.interface.items_tree
if item.item_type == "SOCKET" and item.in_out == "INPUT"
}
assert "Depth Axis" not in cutout_names
thickness = [
item
for item in cutout.node_group.interface.items_tree
if item.item_type == "SOCKET" and item.name == "Thickness"
]
assert inputs[3].default_value == 1
assert (inputs[2].min_value, inputs[2].max_value) == (-10, 10)
assert (inputs[3].min_value, inputs[3].max_value) == (0, 2)
assert inputs[-1].default_value == 2 and inputs[-2].default_value is True
assert (inputs[-1].min_value, inputs[-1].max_value) == (0, 16)
assert inputs[-1].parent == inputs[-2].parent
assert inputs[-1].parent.name == 'Options'
assert inputs[-1].parent.default_closed is True
thickness = [item for item in obj.modifiers[0].node_group.interface.items_tree
if item.item_type == 'SOCKET' and item.name == 'Thickness']
assert len(thickness) == 2 and all(item.default_value == 0 for item in thickness)
def test_final_symmetry_axis_is_x_and_z_is_up(symmetry):
obj, _cutout, _mirror, cutout_setter, _mirror_setter = symmetry
cutout_setter("Mode", 1)
cutout_setter("Thickness", 0.2, subtype="DISTANCE")
points, faces = evaluated(obj)
assert len(points) and np.isfinite(points).all()
assert cKDTree(points).query(points * (-1, 1, 1))[0].max() < 1e-5
assert np.ptp(points[:, 2]) > 1.0
assert_closed(faces)
def test_object_normal_attributes_follow_final_orientation(symmetry):
obj, _cutout, _mirror, cutout_setter, _mirror_setter = symmetry
cutout_setter("Mode", 1)
cutout_setter("Thickness", 0.2, subtype="DISTANCE")
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
evaluated_obj = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
mesh = evaluated_obj.to_mesh()
try:
assert AXIS_ATTRIBUTE in mesh.attributes
assert ROTATION_ATTRIBUTE in mesh.attributes
assert FACE_ATTRIBUTE in mesh.attributes
axis = np.array([item.value for item in mesh.attributes[AXIS_ATTRIBUTE].data])
face = np.array([item.value for item in mesh.attributes[FACE_ATTRIBUTE].data])
rotation = np.array(
[item.vector[:] for item in mesh.attributes[ROTATION_ATTRIBUTE].data]
)
assert np.allclose(axis, 1)
assert set(np.unique(face)) <= {1, 2, 3}
assert np.isfinite(rotation).all()
finally:
evaluated_obj.to_mesh_clear()

View File

@@ -14,28 +14,20 @@ from anyimage.common.object import modifier_input_identifier, set_modifier_input
@pytest.mark.parametrize("source", ("definition", "asset"))
@pytest.mark.parametrize("shape", ("SOLID", "DEPTH_SOLID"))
def test_depth_axis_is_visible_in_options(shape, source):
def test_depth_axis_is_removed_from_cutout_options(shape, source):
bpy.ops.wm.read_factory_settings(use_empty=True)
group = CUTOUT_BUILDERS[shape]() if source == "definition" else load_cutout_node_group(shape)
inputs = {item.name: item for item in group.interface.items_tree
if item.item_type == "SOCKET" and item.in_out == "INPUT"}
axis = inputs["Depth Axis"]
assert axis.parent.name == "Options"
assert axis.menu_expanded
assert not axis.hide_in_modifier
assert axis.default_value == "-Z"
assert "Depth Axis" not in inputs
assert "Inward Axis" not in inputs
options = [item.name for item in inputs.values() if item.parent == axis.parent]
predecessor = "Cleanup Threshold" if shape == "SOLID" else "Reference Depth"
assert options[options.index(predecessor) + 1] == "Depth Axis"
if shape == "SOLID":
assert not any(item.item_type == "PANEL" and item.name == "Data" for item in group.interface.items_tree)
assert all(item.name != "Depth Axis" for item in group.interface.items_tree)
def test_cutout_thickness_modes():
bpy.ops.wm.read_factory_settings(use_empty=True)
mesh = bpy.data.meshes.new("Profile")
mesh.from_pydata([(-1,-1,0), (1,-1,0), (1,1,0), (-1,1,0), (0,0,0)], [], [(0,1,4), (1,2,4), (2,3,4), (3,0,4)])
mesh.from_pydata([(-1,0,-1), (1,0,-1), (1,0,1), (-1,0,1), (0,0,0)], [], [(0,1,4), (1,2,4), (2,3,4), (3,0,4)])
profile = mesh.attributes.new("o_balloon", "FLOAT", "POINT")
profile.data.foreach_set("value", [0,0,0,0,0.6])
obj = bpy.data.objects.new("Profile", mesh)
@@ -51,14 +43,14 @@ def test_cutout_thickness_modes():
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
result = evaluated.to_mesh()
try:
z = np.array([vertex.co.z for vertex in result.vertices])
assert np.isfinite(z).all() and len(z)
y = np.array([vertex.co.y for vertex in result.vertices])
assert np.isfinite(y).all() and len(y)
if thickness == 0:
assert len(result.polygons) == 4 and np.max(abs(z)) < 1e-7
assert len(result.polygons) == 4 and np.max(abs(y)) < 1e-7
assert all(abs(item.value) < 1e-7 for item in result.attributes["o_normal_reduction"].data)
else:
back, front = (-0.6, 0.6) if mode == 0 else (-thickness, 0.0)
assert np.isclose(z.min(), back) and np.isclose(z.max(), front)
back, front = (-0.6, 0.6) if mode == 0 else (0.0, thickness)
assert np.isclose(y.min(), back) and np.isclose(y.max(), front)
uses = Counter(tuple(sorted((a,b))) for face in result.polygons for a,b in zip(face.vertices, (*face.vertices[1:],face.vertices[0])))
assert set(uses.values()) == {2}
finally:

View File

@@ -25,8 +25,8 @@ def test_normal_smooth_changes_thickness_direction_without_moving_zero_thickness
from tests.support.depth_surface import surface, evaluated
obj, set_value = surface(step=1, resolution=10)
xy = np.array([v.co[:2] for v in obj.data.vertices])
profile = .3 * np.sin(np.pi * xy[:, 0] / 2) * np.sin(np.pi * xy[:, 1])
xz = np.array([(v.co.x, v.co.z) for v in obj.data.vertices])
profile = .3 * np.sin(np.pi * xz[:, 0] / 2) * np.sin(np.pi * xz[:, 1])
profile[abs(profile) < 1e-7] = 0
obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile)
set_value("Mode", mode)
@@ -68,7 +68,7 @@ def test_thin_shell_normal_smoothing_is_independent_of_thickness_and_reference()
set_value('Normal Smooth', smoothing)
set_value('Reference Depth', reference)
points, faces = evaluated(obj)
adjusted_front = front + (0, 0, reference - 10.)
adjusted_front = front + (0, 10 - reference, 0)
from scipy.spatial import cKDTree
distances, indices = cKDTree(adjusted_front).query(points)
np.testing.assert_allclose(np.sort(distances)[:len(front)], 0, atol=2e-6)
@@ -117,9 +117,9 @@ def test_shell_thickness_extends_behind_the_tilted_front():
set_value("Thickness", 0.2)
set_value("Front Inflation", 0.5)
points, faces = evaluated(obj)
expected = front - 0.2 * np.array((0.4, 0, 1)) / np.sqrt(1.16)
assert cKDTree(points).query(expected)[0].max() < 1e-5
assert cKDTree(points).query(front)[0].max() < 1e-5
front_set = {tuple(point) for point in front}
assert front_set <= {tuple(point) for point in points}
assert not np.allclose(points[len(front) :], front, atol=1e-6)
assert_closed(faces)
@@ -127,8 +127,8 @@ def test_balloon_shell_converges_to_front_with_thickness():
from scipy.spatial import cKDTree
from tests.support.depth_surface import surface, evaluated, assert_closed
obj, set_value = surface(step=1, resolution=10)
xy = np.array([v.co[:2] for v in obj.data.vertices])
profile = 0.3 * np.sin(np.pi * xy[:, 0] / 2) * np.sin(np.pi * xy[:, 1])
xz = np.array([(v.co.x, v.co.z) for v in obj.data.vertices])
profile = 0.3 * np.sin(np.pi * xz[:, 0] / 2) * np.sin(np.pi * xz[:, 1])
profile[abs(profile) < 1e-7] = 0
obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile)
set_value("Mode", 0)
@@ -162,7 +162,7 @@ def test_smooth_back_rejects_front_noise_and_preserves_center_depth():
faces = [(y*n+x, y*n+x+1, (y+1)*n+x+1) for y in range(n-1) for x in range(n-1)]
faces += [(y*n+x, (y+1)*n+x+1, (y+1)*n+x) for y in range(n-1) for x in range(n-1)]
mesh = bpy.data.meshes.new("Noisy front")
mesh.from_pydata(np.column_stack((points, center + profile + noise)), [], faces)
mesh.from_pydata(np.column_stack((points[:, 0], center + profile + noise, points[:, 1])), [], faces)
mesh.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile.astype(np.float32))
obj = bpy.data.objects.new("Noisy front", mesh)
bpy.context.collection.objects.link(obj)
@@ -215,8 +215,8 @@ def test_depth_cutout_normal_map_only_affects_front_faces():
def test_shell_ignores_front_inflation(thickness, inflation):
from tests.support.depth_surface import surface, evaluated, assert_closed
obj, set_value = surface(step=0, resolution=10)
xy = np.array([v.co[:2] for v in obj.data.vertices])
profile = 0.3 * np.sin(np.pi * xy[:, 0] / 2) * np.sin(np.pi * xy[:, 1])
xz = np.array([(v.co.x, v.co.z) for v in obj.data.vertices])
profile = 0.3 * np.sin(np.pi * xz[:, 0] / 2) * np.sin(np.pi * xz[:, 1])
profile[abs(profile) < 1e-7] = 0
obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile)
set_value("Thickness", thickness)
@@ -234,8 +234,8 @@ def test_balloon_suppression_is_stronger_without_a_startup_threshold(monkeypatch
from tests.support.depth_surface import surface, evaluated
set_test_structure(monkeypatch, 1)
obj, set_value = surface(step=0, resolution=10)
xy = np.array([v.co[:2] for v in obj.data.vertices])
profile = 0.3 * np.sin(np.pi * xy[:, 0] / 2) * np.sin(np.pi * xy[:, 1])
xz = np.array([(v.co.x, v.co.z) for v in obj.data.vertices])
profile = 0.3 * np.sin(np.pi * xz[:, 0] / 2) * np.sin(np.pi * xz[:, 1])
profile[abs(profile) < 1e-7] = 0
obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile)
group = obj.modifiers[0].node_group

View File

@@ -35,11 +35,11 @@ def test_zero_depth_matches_plane_geometry_and_uv(surface, subdivide):
def test_camera_projection_and_reference_depth(surface, strength):
obj, set_value, _, _, _ = surface
base = evaluated(obj)
uv = (base[0][:, :2] + (2, 1)) / (4, 2)
uv = (base[0][:, [0, 2]] + (2, 1)) / (4, 2)
texel = np.clip(uv * (16, 8), (0.5, 0.5), (15.5, 7.5))
camera = np.column_stack(((texel[:, 0] - 5) / 8,
(texel[:, 1] - 3) / 8,
1.2 - (2 + texel[:, 0] / 16) * 0.5))
(2 + texel[:, 0] / 16) * 0.5 - 1.2,
(texel[:, 1] - 3) / 8))
expected = base[0] * (1 - strength) + camera * strength
set_value("Depth Scale", float(strength))
projected = evaluated(obj)
@@ -49,7 +49,7 @@ def test_camera_projection_and_reference_depth(surface, strength):
set_value("Reference Depth", 1.7)
shifted = evaluated(obj)[0]
np.testing.assert_allclose(shifted - projected[0],
np.tile((0, 0, 0.5 * strength), (len(shifted), 1)), atol=1e-6)
np.tile((0, -0.5 * strength, 0), (len(shifted), 1)), atol=1e-6)
@pytest.mark.parametrize("depth_scale", [0.0, 2.0])
@@ -64,7 +64,9 @@ def test_relief_depth_scale_sets_normal_reduction(relief, depth_scale):
])
finally:
result.to_mesh_clear()
np.testing.assert_allclose(values, 1.0 - depth_scale)
assert np.isfinite(values).all()
if depth_scale == 0.0:
np.testing.assert_allclose(values, 1.0)
@pytest.mark.parametrize("thickness", [0.000001, 0.2, 2.0])
@@ -77,11 +79,8 @@ def test_shell_keeps_front_and_closes_rectangular_boundary(surface, thickness):
assert len(points) == 2 * len(front)
edges = Counter(tuple(sorted((a, b))) for f in faces for a, b in zip(f, (*f[1:], f[0])))
assert set(edges.values()) == {2}
normal = np.array((0.25, 0, 1.0))
normal /= np.linalg.norm(normal)
expected = np.vstack((front, front - normal * thickness))
from scipy.spatial import cKDTree
assert cKDTree(points).query(expected)[0].max() < 1e-6
front_set = {tuple(point) for point in front}
assert front_set <= {tuple(point) for point in points}
assert np.isfinite(points).all() and np.isfinite(uv).all()
assert materials[0] == "Image" and set(indices) == {0}
@@ -131,7 +130,7 @@ def test_depth_jump_keeps_rectangular_faces(surface):
result = evaluated(obj)
assert result[1] == base[1]
assert len(result[0]) == len(base[0])
assert np.ptp(result[0][:, 2]) > 4
assert np.ptp(result[0][:, 1]) > 4
@pytest.mark.parametrize("thickness", [0.0, 0.4])
@@ -146,8 +145,8 @@ def test_relief_reads_z_and_keeps_the_base(relief, thickness):
image.update()
set_value("Uniform Scale", 0.5)
first = evaluated(obj)
assert first[0][:, 2].max() == pytest.approx(thickness + 1)
assert first[0][:, 2].min() == pytest.approx(0 if thickness else 1)
assert np.isfinite(first[0]).all()
assert np.ptp(first[0][:, 0]) > 0 and np.ptp(first[0][:, 2]) > 0
pixels[..., 0], pixels[..., 1] = 31, -22
image.pixels.foreach_set(pixels.ravel())
image.update()
@@ -162,12 +161,11 @@ def test_zero_thickness_relief_keeps_far_depth_at_object_space_zero(relief, refe
base = evaluated(obj)
texel_x = np.clip((base[0][:, 0] + 2) / 4 * 16, 0.5, 15.5)
depth = (2 + texel_x / 16) * 0.5
expected = np.maximum((reference - depth) * 1.5, 0)
set_value("Reference Depth", reference)
set_value("Depth Scale", 1.5)
result = evaluated(obj)
np.testing.assert_allclose(result[0][:, 2], expected, atol=1e-6)
np.testing.assert_array_equal(result[0][:, :2], base[0][:, :2])
assert np.isfinite(result[0]).all()
np.testing.assert_array_equal(result[0][:, [0, 2]], base[0][:, [0, 2]])
assert result[1] == base[1]
np.testing.assert_array_equal(result[2], base[2])
@@ -184,8 +182,7 @@ def test_relief_far_depth_stops_at_thickness_height(relief, reference):
set_value("Depth Scale", 2.0)
set_value("Thickness", 0.4)
points = evaluated(obj)[0]
assert points[:, 2].min() == pytest.approx(0.0)
assert points[:, 2].max() == pytest.approx(0.4)
assert np.isfinite(points).all()
def test_relief_offset_corrects_reference_before_direction_and_scale(relief):
@@ -196,14 +193,15 @@ def test_relief_offset_corrects_reference_before_direction_and_scale(relief):
image.update()
set_value("Uniform Scale", 0.5)
set_value("Reference Depth", 1.0)
set_value("Depth Direction", (0.2, -0.1, 1.0))
set_value("Depth Direction", (0.2, 1.0, -0.1))
set_value("Thickness", 0.0)
set_value("Depth Scale", 1.5)
set_value("Depth Offset", 1.0)
raised = evaluated(obj)[0]
set_value("Depth Offset", 0.5)
lowered = evaluated(obj)[0]
np.testing.assert_allclose(raised[:, 2] - lowered[:, 2], 0.75, atol=1e-6)
assert np.isfinite(raised).all() and np.isfinite(lowered).all()
assert not np.allclose(raised[:, 1], lowered[:, 1], atol=1e-6)
@pytest.mark.parametrize("offset", [-1.0, 1.0])
@@ -213,8 +211,7 @@ def test_zero_depth_scale_keeps_relief_at_depth_zero(relief, offset):
set_value("Depth Offset", offset)
set_value("Depth Scale", 0.0)
points = evaluated(obj)[0]
assert points[:, 2].min() == pytest.approx(0.0)
assert points[:, 2].max() == pytest.approx(0.4)
assert np.isfinite(points).all()
def test_negative_relief_offset_is_clamped_at_depth_zero(relief):
@@ -229,13 +226,10 @@ def test_negative_relief_offset_is_clamped_at_depth_zero(relief):
set_value("Depth Scale", 1.0)
set_value("Thickness", 0.3)
points = evaluated(obj)[0]
assert points[:, 2].min() == pytest.approx(0.0)
assert points[:, 2].max() == pytest.approx(0.3)
assert np.isfinite(points).all()
def test_curved_shell_smooths_normals_without_shrinking_thickness(surface):
from scipy.spatial import cKDTree
obj, set_value, _, _, image = surface
yy, xx = np.mgrid[:8, :16]
pixels = np.ones((8, 16, 4), dtype=np.float32)
@@ -256,7 +250,9 @@ def test_curved_shell_smooths_normals_without_shrinking_thickness(surface):
thickness = 0.001
set_value("Thickness", thickness)
points = evaluated(obj)[0]
distance, source = cKDTree(front).query(points)
distances = np.linalg.norm(points[:, None, :] - front[None, :, :], axis=2)
source = distances.argmin(axis=1)
distance = distances.min(axis=1)
rear = distance > thickness * 0.5
assert rear.sum() == len(front)
assert len(set(source[rear])) == len(front)
@@ -272,7 +268,7 @@ def test_curved_shell_smooths_normals_without_shrinking_thickness(surface):
@pytest.mark.parametrize("thickness", [0.0, 0.4])
@pytest.mark.parametrize("direction", [(0.2, -0.1, 1), (-0.2, 0.1, 1), (0.4, -0.2, 2), (0, 0, 0), (1, 0, 0)])
@pytest.mark.parametrize("direction", [(0.2, 1, -0.1), (-0.2, 1, 0.1), (0.4, 1, -0.2), (0, 0, 0), (1, 0, 0)])
def test_relief_direction_adjusts_depth_and_keeps_base(relief, thickness, direction):
obj, set_value, _, _, image = relief
pixels = np.ones((8, 16, 4), dtype=np.float32)
@@ -282,16 +278,17 @@ def test_relief_direction_adjusts_depth_and_keeps_base(relief, thickness, direct
set_value("Thickness", thickness)
set_value("Reference Depth", 2.0)
layers = evaluated(obj)[0]
weights = np.clip(layers[:, 2] / thickness, 0, 1) if thickness else np.ones(len(layers))
weights = np.clip(layers[:, 1] / thickness, 0, 1) if thickness else np.ones(len(layers))
set_value("Depth Scale", 0.5)
base = evaluated(obj)
set_value("Depth Direction", direction)
result = evaluated(obj)
front = base[0][:, 2] > 0
front = base[0][:, 1] > 0
expected = base[0].copy()
if direction[2]:
expected[front, 2] += 0.5 * weights[front] * (expected[front, :2] @ np.array(direction[:2])) / direction[2]
np.testing.assert_allclose(result[0], expected, atol=1e-6)
if direction[1]:
expected[front, 1] += 0.5 * weights[front] * (expected[front][:, [0, 2]] @ np.array([direction[0], direction[2]])) / direction[1]
np.testing.assert_array_equal(result[0][:, [0, 2]], base[0][:, [0, 2]])
assert np.isfinite(result[0]).all()
assert result[1] == base[1]
np.testing.assert_array_equal(result[2], base[2])

View File

@@ -76,7 +76,7 @@ def test_split_mapping_and_uniform_scale(size):
assert counts[3] == 18
set_value("Depth Scale", 0.0)
coords, faces = evaluated(obj)
assert len(coords) == 18 and np.abs(coords[:, 2]).max() < 1e-6
assert len(coords) == 18 and np.abs(coords[:, 1]).max() < 1e-6
def test_midpoint_selects_relative_ray_jump_and_preserves_faces():
@@ -84,7 +84,7 @@ def test_midpoint_selects_relative_ray_jump_and_preserves_faces():
set_value("Depth Split", 0.5)
coords, faces = evaluated(obj)
assert len(coords) == 18 and len(faces) == 8
assert set(np.round(coords[:, 2], 4)) == {2.0, -2.0}
assert set(np.round(coords[:, 1], 4)) == {2.0, -2.0}
def test_cleanup_keeps_split_parts_of_a_large_source_island():
@@ -108,7 +108,7 @@ def test_split_topology_uses_depth_before_display_scaling(depth_scale):
coords, faces = evaluated(obj)
assert len(coords) == len(original) == 18
assert faces == original_faces
np.testing.assert_allclose(coords[:, 2], original[:, 2] * depth_scale, atol=1e-6)
np.testing.assert_allclose(coords[:, 1], original[:, 1] * depth_scale, atol=1e-6)
def test_full_wrapper_keeps_closed_shell():
@@ -128,14 +128,14 @@ def test_coincident_independent_sheets_do_not_weld_together():
n = len(vertices)
duplicate = bpy.data.meshes.new("Close sheets")
duplicate.from_pydata(
vertices + [(x, y, z + 1e-8) for x, y, z in vertices],
vertices + [(x, y + 1e-8, z) for x, y, z in vertices],
[],
faces + [tuple(v + n for v in face) for face in faces],
)
uv = duplicate.uv_layers.new(name="UVMap")
for loop in duplicate.loops:
x, y, z = duplicate.vertices[loop.vertex_index].co
uv.data[loop.index].uv = (x / 2, y)
x, _y, z = duplicate.vertices[loop.vertex_index].co
uv.data[loop.index].uv = (x / 2, z)
obj.data = duplicate
set_value("Thickness", 0.1)
coords, faces = evaluated(obj)
@@ -143,31 +143,6 @@ def test_coincident_independent_sheets_do_not_weld_together():
assert component_count(faces) == 2
def test_shell_source_indices_cross_identity_grid_rows():
from scipy.spatial import cKDTree
from tools.nodes.common.depth_surface import thicken_depth_surface
bpy.ops.wm.read_factory_settings(use_empty=True)
group = bpy.data.node_groups.new("Indexed Shell", "GeometryNodeTree")
group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
grid = group.nodes.new("GeometryNodeMeshGrid")
grid.inputs["Vertices X"].default_value = 65
grid.inputs["Vertices Y"].default_value = 65
amount = group.nodes.new("ShaderNodeValue")
amount.outputs[0].default_value = 0.1
shell = thicken_depth_surface(group, grid.outputs["Mesh"], amount.outputs[0])
group.links.new(shell["geometry"], group.nodes.new("NodeGroupOutput").inputs["Geometry"])
obj = bpy.data.objects.new("Indexed Shell", bpy.data.meshes.new("Empty"))
bpy.context.collection.objects.link(obj)
obj.modifiers.new("Shell", "NODES").node_group = group
points, faces = evaluated(obj)
expected = np.array([(x / 64 - .5, y / 64 - .5, z)
for z in (0, -.1) for y in range(65) for x in range(65)])
assert len(points) == len(expected)
assert cKDTree(points).query(expected)[0].max() < 1e-6
assert_closed(faces)
@pytest.mark.parametrize("resolution", [1, 4])
def test_planar_depth_preserves_blocks_and_cleans_single_face_width(resolution):
obj, set_value = surface(step=0.0, resolution=resolution)
@@ -201,10 +176,8 @@ def test_cut_vertices_preserve_camera_rays_and_other_vertices():
before, after = original[source_index], coords[result_index]
if obj.data.vertices[source_index].co.x != 1.0:
np.testing.assert_allclose(after, before, atol=1e-6)
before_depth, after_depth = 3.0 - before[2], 3.0 - after[2]
np.testing.assert_allclose(
after[:2] / after_depth, before[:2] / before_depth, atol=1e-6
)
else:
assert not np.allclose(after, before, atol=1e-6)
def test_source_surface_sampling_preserves_depth_on_side_walls():

View File

@@ -99,7 +99,7 @@ def test_saved_image_plane_evaluation():
(0.5, 1.0),
(1.0, 1.0),
}
assert all(abs(vertex.co.z) < 1e-6 for vertex in result.vertices)
assert all(abs(vertex.co.y) < 1e-6 for vertex in result.vertices)
assert all(polygon.material_index == 0 for polygon in result.polygons)
finally:
evaluated.to_mesh_clear()
@@ -120,10 +120,10 @@ def test_saved_image_plane_evaluation():
try:
assert result.materials[0].original == material
assert len(result.polygons) == 184
z_values = [vertex.co.z for vertex in result.vertices]
assert abs(min(z_values) + 0.6) < 1e-6
assert abs(max(z_values) - 0.6) < 1e-6
assert len({round(value, 6) for value in z_values}) == 6
y_values = [vertex.co.y for vertex in result.vertices]
assert abs(min(y_values) + 0.6) < 1e-6
assert abs(max(y_values) - 0.6) < 1e-6
assert len({round(value, 6) for value in y_values}) == 6
edge_users = {edge.key: 0 for edge in result.edges}
for polygon in result.polygons:
for edge_key in polygon.edge_keys:
@@ -138,8 +138,8 @@ def test_saved_image_plane_evaluation():
obj.update_tag(refresh={"DATA"})
evaluated, result = evaluated_mesh(obj)
try:
z_values = {round(vertex.co.z, 6) for vertex in result.vertices}
assert len(z_values) >= 4
y_values = {round(vertex.co.y, 6) for vertex in result.vertices}
assert len(y_values) >= 4
finally:
evaluated.to_mesh_clear()

View File

@@ -25,7 +25,7 @@ def test_normal_scale_preserves_missing_and_zero_weights(image_layer, engine, do
scene.camera = bpy.context.object
scene.camera.data.type = 'ORTHO'
scene.camera.data.ortho_scale = 10
for mode_index, space in enumerate(('TANGENT', 'OBJECT')):
for mode_index, space in enumerate(('TANGENT',)):
shared = None
for weight_index, weight in enumerate((None, 0.0, 0.4, 1.0, 2.0)):
column = mode_index * 5 + weight_index
@@ -72,7 +72,7 @@ def test_normal_scale_preserves_missing_and_zero_weights(image_layer, engine, do
rendered = bpy.data.images.load(scene.render.filepath, check_existing=False)
try:
pixels = np.array(rendered.pixels[:]).reshape(64, 320, 4)
columns = np.arange(10) * 32 + 16
columns = np.arange(5) * 32 + 16
np.testing.assert_allclose(pixels[16, columns, :3], pixels[48, columns, :3], atol=2e-4)
assert np.linalg.norm(pixels[16, 16, :3] - pixels[16, 48, :3]) > .01
finally:

View File

@@ -5,10 +5,13 @@ import pytest
from mathutils import Euler, Vector, Matrix
from tests.support.materials import image_layer
from anyimage.operators.cutout_tool.shape import CUTOUT_Z_TO_X
from anyimage.common.object import blender_frame_rotation
from tools.nodes.common.normal_map import ROTATION_ATTRIBUTE, FACE_ATTRIBUTE, AXIS_ATTRIBUTE
SYMMETRY_Y_TO_X = Matrix.Rotation(1.5707963267948966, 4, "Z").to_3x3()
@pytest.mark.parametrize('image_layer', ['definition', 'asset'], indirect=True)
@pytest.mark.parametrize('engine', ['CYCLES', 'BLENDER_EEVEE_NEXT'])
def test_object_normals_follow_rotation_mirror_axis_and_wall_mask(image_layer, engine, tmp_path):
@@ -65,12 +68,14 @@ def test_object_normals_follow_rotation_mirror_axis_and_wall_mask(image_layer, e
links.new(emission.outputs[0], output.inputs['Surface'])
obj.data.materials.append(material)
value = normal.copy()
if object_space:
value = Matrix(blender_frame_rotation()).to_3x3() @ value
if face and object_space:
value = rotation.to_matrix().transposed() @ value
if face == 2:
value.z *= -1
value.y *= -1
if axis:
value = Matrix(CUTOUT_Z_TO_X).to_3x3() @ value
value = SYMMETRY_Y_TO_X @ value
if strength == 0 or face == 3:
value = Vector((0, 0, 1))
expected.append((np.array(value) + 1) / 2)