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

@@ -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()