284 lines
11 KiB
Python
284 lines
11 KiB
Python
"""Check the canonical Y mirror and final X orientation for Depth Symmetry."""
|
|
|
|
from collections import Counter
|
|
|
|
import bpy
|
|
import numpy as np
|
|
import pytest
|
|
from scipy.spatial import cKDTree
|
|
|
|
from anyimage.common.object import modifier_input_identifier, set_modifier_input
|
|
from tests.support.depth_surface import assert_closed, evaluated
|
|
from nodes.common.normal_map import AXIS_ATTRIBUTE, FACE_ATTRIBUTE, ROTATION_ATTRIBUTE
|
|
from nodes.groups.image_depth_cutout import build_image_depth_cutout_group
|
|
from nodes.groups.image_cutout_symmetry import build_image_cutout_symmetry_group
|
|
|
|
|
|
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
|
|
def symmetry():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
count = 5
|
|
z, x = np.meshgrid(
|
|
np.linspace(-1, 1, count),
|
|
np.linspace(-1, 1, count),
|
|
indexing="ij",
|
|
)
|
|
faces = []
|
|
for row in range(count - 1):
|
|
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, 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)
|
|
|
|
cutout_group = build_image_depth_cutout_group()
|
|
mirror_group = build_image_cutout_symmetry_group()
|
|
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
|
|
|
|
cutout_setter = _set_value(obj, cutout, cutout_group)
|
|
mirror_setter = _set_value(obj, mirror, mirror_group)
|
|
mirror_setter("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, cutout, mirror, _cutout_setter, _mirror_setter = symmetry
|
|
assert not any(node.bl_idname == "GeometryNodeMeshBoolean" for node in mirror.node_group.nodes)
|
|
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",
|
|
"Smooth",
|
|
"Merge Distance",
|
|
]
|
|
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 == 4
|
|
assert mirror_inputs[6].default_value == pytest.approx(0.001)
|
|
assert mirror_inputs[6].min_value == 0
|
|
|
|
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 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
|
|
|
|
|
|
def _tolerance(points):
|
|
size = float(np.linalg.norm(points.max(axis=0) - points.min(axis=0)))
|
|
return max(size, 1e-8) * 1e-7
|
|
|
|
|
|
def _evaluated(obj):
|
|
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
|
mesh = result.to_mesh()
|
|
try:
|
|
points = np.array([vertex.co[:] for vertex in mesh.vertices])
|
|
faces = [tuple(polygon.vertices) for polygon in mesh.polygons]
|
|
marker = np.array([item.value for item in mesh.attributes[FACE_ATTRIBUTE].data])
|
|
finally:
|
|
result.to_mesh_clear()
|
|
return points, faces, marker
|
|
|
|
|
|
def _open_edges(faces):
|
|
counts = Counter(
|
|
tuple(sorted((a, b))) for f in faces for a, b in zip(f, (*f[1:], f[0]))
|
|
)
|
|
return [edge for edge, count in counts.items() if count == 1]
|
|
|
|
|
|
def _marked_vertices(points, faces, marker, value):
|
|
indices = {index for face, item in zip(faces, marker) if item == value for index in face}
|
|
return points[sorted(indices)]
|
|
|
|
|
|
def _front_vertices(points, faces, marker, predicate):
|
|
return np.array(
|
|
[point for point in _marked_vertices(points, faces, marker, 1) if predicate(point)]
|
|
)
|
|
|
|
|
|
def _positions(points):
|
|
return {tuple(np.round(point, 6)) for point in points}
|
|
|
|
|
|
def _front_rim(points, faces, marker, tolerance):
|
|
return _front_vertices(points, faces, marker, lambda point: abs(point[0]) <= tolerance)
|
|
|
|
|
|
def test_seam_clamps_geometry_onto_the_symmetry_plane(symmetry):
|
|
obj, _cutout, _mirror, _cutout_setter, _mirror_setter = symmetry
|
|
points, faces, marker = _evaluated(obj)
|
|
tolerance = _tolerance(points)
|
|
plane = np.abs(points[:, 0]) <= tolerance
|
|
|
|
assert _marked_vertices(points, faces, marker, 1)[:, 0].min() >= -tolerance
|
|
assert _marked_vertices(points, faces, marker, 2)[:, 0].max() <= tolerance
|
|
assert plane.any()
|
|
assert not any(all(plane[index] for index in face) for face in faces)
|
|
# 跨界的面保留,切口环被回拉到对称面上。
|
|
assert any(plane[list(face)].any() and not plane[list(face)].all() for face in faces)
|
|
|
|
|
|
def test_disabled_fill_keeps_the_welded_seam(symmetry):
|
|
obj, _cutout, _mirror, _cutout_setter, mirror_setter = symmetry
|
|
mirror_setter("Fill Sides", False)
|
|
points, faces = evaluated(obj)
|
|
tolerance = _tolerance(points)
|
|
plane = np.abs(points[:, 0]) <= tolerance
|
|
open_edges = _open_edges(faces)
|
|
|
|
assert open_edges
|
|
assert not any(plane[edge[0]] and plane[edge[1]] for edge in open_edges)
|
|
|
|
|
|
def test_merge_distance_snaps_the_welded_band(symmetry):
|
|
"""合并距离决定平面附近折平还是补面:带内直接焊住,带外的开口才补。"""
|
|
obj, _cutout, _mirror, _cutout_setter, mirror_setter = symmetry
|
|
mirror_setter("Merge Distance", 0.05, subtype="DISTANCE")
|
|
points, faces, marker = _evaluated(obj)
|
|
band = np.abs(points[:, 0]) < 0.05
|
|
|
|
assert_closed(faces)
|
|
assert band.any()
|
|
# 补面墙体从带外开口挤向对称面,因此每张补面至少跨过半个合并距离。
|
|
for face, value in zip(faces, marker):
|
|
if value == 3:
|
|
assert np.abs(points[list(face)][:, 0]).max() > 0.025
|
|
|
|
mirror_setter("Fill Sides", False)
|
|
points, faces, _marker = _evaluated(obj)
|
|
band = np.abs(points[:, 0]) < 0.05
|
|
|
|
# 合并距离之内的点全部折到对称面上,接缝直接焊住。
|
|
assert np.abs(points[band][:, 0]).max() < _tolerance(points)
|
|
assert not any(
|
|
np.abs(points[list(edge)][:, 0]).max() < 0.05 for edge in _open_edges(faces)
|
|
)
|
|
|
|
|
|
def test_fill_smooth_relaxes_the_junction_band(symmetry):
|
|
obj, _cutout, _mirror, _cutout_setter, mirror_setter = symmetry
|
|
mirror_setter("Fill Sides", True)
|
|
mirror_setter("Smooth", 0)
|
|
plain_points, plain_faces, plain_marker = _evaluated(obj)
|
|
mirror_setter("Smooth", 4)
|
|
smooth_points, smooth_faces, smooth_marker = _evaluated(obj)
|
|
tolerance = _tolerance(plain_points)
|
|
|
|
for points, faces in ((plain_points, plain_faces), (smooth_points, smooth_faces)):
|
|
plane = np.abs(points[:, 0]) <= tolerance
|
|
assert plane.any()
|
|
assert not any(all(plane[index] for index in face) for face in faces)
|
|
assert_closed(faces)
|
|
# 焊好之后衔接带连同补面一起松弛,贴合对称面的一圈随之变形。
|
|
band = 0.25 * np.ptp(plain_points[:, 0])
|
|
plain_band = _positions(plain_points[np.abs(plain_points[:, 0]) <= band])
|
|
smooth_band = _positions(smooth_points[np.abs(smooth_points[:, 0]) <= band])
|
|
assert plain_band != smooth_band
|
|
assert cKDTree(smooth_points).query(smooth_points * (-1, 1, 1))[0].max() < 1e-5
|
|
|
|
|
|
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()
|