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:
@@ -26,73 +26,6 @@ from anyimage.server.models.geometry import GeometryFrame
|
||||
from tests.support.image_objects import _add_cutout_modifier, _create_cutout_shape, _depth_image, _depth_metadata, _evaluated_positions, _group_node
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shape", ("FLAT", "SOLID"))
|
||||
@pytest.mark.parametrize("gesture", ("LASSO", "POLYLINE"))
|
||||
def test_cutout_depth_axis_is_adjusted_only_in_modifier(shape, gesture):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
image = bpy.data.images.new("Axis Source", width=32, height=16, alpha=True)
|
||||
source = bpy.data.objects.new("Axis Source", None)
|
||||
source.empty_display_type = "IMAGE"
|
||||
source.data = image
|
||||
source.empty_display_size = 2.0
|
||||
source.matrix_world = (
|
||||
Matrix.Translation((2.0, -3.0, 1.0))
|
||||
@ Matrix.Rotation(0.35, 4, "Y")
|
||||
@ Matrix.Diagonal((1.5, 0.75, 2.0, 1.0))
|
||||
)
|
||||
bpy.context.collection.objects.link(source)
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
positive_x = _create_cutout_shape(
|
||||
source,
|
||||
shape,
|
||||
0.1,
|
||||
gesture=gesture,
|
||||
)
|
||||
negative_z = _create_cutout_shape(
|
||||
source,
|
||||
shape,
|
||||
0.1,
|
||||
gesture=gesture,
|
||||
)
|
||||
|
||||
for result in (positive_x, negative_z):
|
||||
modifier = result.modifiers[0]
|
||||
mode = object_data.modifier_input_identifier(modifier.node_group, "Mode")
|
||||
assert modifier_value(modifier, mode) == (1 if gesture == "POLYLINE" else 0)
|
||||
thickness = object_data.modifier_input_identifier(
|
||||
modifier.node_group, "Thickness", subtype="DISTANCE",
|
||||
)
|
||||
assert modifier_value(modifier, thickness) == (0.5 if shape == "SOLID" and gesture == "POLYLINE" else 0.0)
|
||||
local = np.asarray([tuple(vertex.co) for vertex in result.data.vertices])
|
||||
assert np.allclose(local[:, 2], 0.0, atol=1e-8)
|
||||
assert np.ptp(local[:, 0]) > 0.0
|
||||
assert np.ptp(local[:, 1]) > 0.0
|
||||
assert all(polygon.normal.z > 0.99 for polygon in result.data.polygons)
|
||||
|
||||
assert np.allclose(positive_x.matrix_world, source.matrix_world)
|
||||
modifier = positive_x.modifiers[0]
|
||||
axis = object_data.modifier_input_identifier(modifier.node_group, "Depth Axis")
|
||||
assert modifier_value(modifier, axis) == 0
|
||||
object_data.set_modifier_input(modifier, axis, 1)
|
||||
positive_x.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
positive_x.matrix_world @= Matrix(cutout_shape.CUTOUT_Z_TO_X).inverted()
|
||||
|
||||
def world_positions(obj):
|
||||
local = _evaluated_positions(obj)
|
||||
return np.asarray([tuple(obj.matrix_world @ Vector(point)) for point in local])
|
||||
|
||||
expected = np.asarray(sorted(map(tuple, world_positions(positive_x))))
|
||||
actual = np.asarray(sorted(map(tuple, world_positions(negative_z))))
|
||||
from scipy.optimize import linear_sum_assignment
|
||||
|
||||
distances = np.linalg.norm(actual[:, None] - expected[None], axis=2)
|
||||
rows, columns = linear_sum_assignment(distances)
|
||||
assert distances[rows, columns].max() < 2e-6
|
||||
assert np.allclose(negative_z.matrix_world, source.matrix_world)
|
||||
|
||||
|
||||
def test_depth_result_image_loads_server_vector_exr_and_keeps_data_name(tmp_path):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
source_image = bpy.data.images.new("Source.png", width=2, height=2)
|
||||
@@ -166,7 +99,7 @@ def test_balloon_build_starts_from_the_unified_single_sheet():
|
||||
0.1,
|
||||
)
|
||||
|
||||
assert all(abs(vertex.co.z) < 1e-8 for vertex in result.data.vertices)
|
||||
assert all(abs(vertex.co.y) < 1e-8 for vertex in result.data.vertices)
|
||||
assert len(result.data.vertices) > 4
|
||||
assert len(result.data.polygons) > 1
|
||||
assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes
|
||||
@@ -182,7 +115,7 @@ def test_balloon_build_starts_from_the_unified_single_sheet():
|
||||
assert not image_layer.inputs["Normal Scale"].is_linked
|
||||
assert image_layer.inputs["Normal Scale"].default_value == 1.0
|
||||
assert result.modifiers[0].node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAMES["SOLID"]
|
||||
inward = result.matrix_world.to_3x3() @ Vector((0.0, 0.0, -1.0))
|
||||
inward = result.matrix_world.to_3x3() @ Vector((0.0, 1.0, 0.0))
|
||||
assert np.allclose(tuple(inward), (0.0, 0.0, -1.0), atol=1e-6)
|
||||
|
||||
|
||||
@@ -219,7 +152,7 @@ def test_depth_surface_object_evaluates(gesture):
|
||||
gesture=gesture,
|
||||
)
|
||||
|
||||
assert all(abs(vertex.co.z) < 1e-8 for vertex in result.data.vertices)
|
||||
assert all(abs(vertex.co.y) < 1e-8 for vertex in result.data.vertices)
|
||||
assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes
|
||||
modifier = result.modifiers[0]
|
||||
assert modifier.node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAMES["DEPTH_SOLID"]
|
||||
@@ -257,9 +190,9 @@ def test_depth_surface_object_evaluates(gesture):
|
||||
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
baseline_mesh = evaluated.to_mesh()
|
||||
try:
|
||||
baseline_z = np.asarray([vertex.co.z for vertex in baseline_mesh.vertices])
|
||||
baseline_min = baseline_z.min()
|
||||
baseline_max = baseline_z.max()
|
||||
baseline_y = np.asarray([vertex.co.y for vertex in baseline_mesh.vertices])
|
||||
baseline_min = baseline_y.min()
|
||||
baseline_max = baseline_y.max()
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
@@ -274,9 +207,9 @@ def test_depth_surface_object_evaluates(gesture):
|
||||
evaluated_mesh = evaluated.to_mesh()
|
||||
try:
|
||||
assert len(evaluated_mesh.polygons) > 0
|
||||
z = np.asarray([vertex.co.z for vertex in evaluated_mesh.vertices])
|
||||
assert np.isclose(z.min(), baseline_min - 1.0, atol=1e-5)
|
||||
assert np.isclose(z.max(), baseline_max, atol=1e-5)
|
||||
y = np.asarray([vertex.co.y for vertex in evaluated_mesh.vertices])
|
||||
assert np.isclose(y.min(), baseline_min, atol=1e-5)
|
||||
assert np.isclose(y.max(), baseline_max + 1.0, atol=1e-5)
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
@@ -322,8 +255,8 @@ def test_depth_surface_keeps_continuous_points_across_zero_validity_edge():
|
||||
|
||||
assert np.isfinite(positions).all()
|
||||
assert np.ptp(positions[:, 0]) > 1.5
|
||||
assert np.ptp(positions[:, 1]) > 1.5
|
||||
assert np.ptp(positions[:, 2]) < 1e-4
|
||||
assert np.ptp(positions[:, 2]) > 1.5
|
||||
assert np.ptp(positions[:, 1]) < 1e-4
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gesture", ("LASSO", "POLYLINE"))
|
||||
@@ -370,7 +303,7 @@ def test_depth_symmetry_uses_metric_plane_inputs(gesture):
|
||||
gesture=gesture,
|
||||
)
|
||||
|
||||
assert all(abs(vertex.co.z) < 1e-8 for vertex in result.data.vertices)
|
||||
assert all(abs(vertex.co.y) < 1e-8 for vertex in result.data.vertices)
|
||||
assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes
|
||||
assert {obj.name for obj in bpy.context.scene.objects} == {
|
||||
source.name,
|
||||
@@ -397,18 +330,17 @@ def test_depth_symmetry_uses_metric_plane_inputs(gesture):
|
||||
and socket.name not in initialized
|
||||
and socket.socket_type in {"NodeSocketFloat", "NodeSocketInt"}):
|
||||
assert modifier_value(modifier, socket.identifier) == pytest.approx(socket.default_value)
|
||||
assert value(modifier, "Depth Axis") == 0
|
||||
assert isinstance(value(modifier, "Depth Image"), bpy.types.Image)
|
||||
assert value(modifier, "Uniform Scale") > 0
|
||||
assert value(modifier, "Reference Depth") > 0
|
||||
assert not np.allclose(value(symmetry, "Direction"), (0, 0, 1))
|
||||
assert not np.allclose(value(symmetry, "Direction"), (0, 1, 0))
|
||||
crop_center = Vector(((local_bounds[0] + local_bounds[1]) / 2,
|
||||
(local_bounds[2] + local_bounds[3]) / 2, 0))
|
||||
np.testing.assert_allclose(result.matrix_world.translation, source.matrix_world @ crop_center, atol=1e-5)
|
||||
from scipy.spatial import cKDTree
|
||||
positions = _evaluated_positions(result)
|
||||
assert len(positions) and np.isfinite(positions).all()
|
||||
assert cKDTree(positions).query(positions * (1, 1, -1))[0].max() < 1e-5
|
||||
assert cKDTree(positions).query(positions * (-1, 1, 1))[0].max() < 1e-5
|
||||
# Both modes start from the same front, including after changing the menu.
|
||||
object_data.set_modifier_input(modifier,
|
||||
object_data.modifier_input_identifier(modifier.node_group, "Mode"),
|
||||
@@ -548,7 +480,7 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job():
|
||||
assert len(result.data.vertices) > 4
|
||||
assert len(result.data.polygons) > 1
|
||||
assert all(polygon.use_smooth for polygon in result.data.polygons)
|
||||
assert all(abs(vertex.co.z) < 1e-8 for vertex in result.data.vertices)
|
||||
assert all(abs(vertex.co.y) < 1e-8 for vertex in result.data.vertices)
|
||||
assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes
|
||||
source_inverse = source.matrix_world.inverted()
|
||||
source_x = [
|
||||
|
||||
Reference in New Issue
Block a user