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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user