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

@@ -42,7 +42,7 @@ def test_depth_symmetry_plane_calibration_matches_cutout_axes():
local_y = 1.0 - row
for column in range(3):
local_x = column - 1.0
vertices.append((local_x, local_y, 0.0))
vertices.append((local_x, 0.0, local_y))
uv.append((column * 0.5, 1.0 - row * 0.5))
camera_depth[row, column] = 2.0 + 0.2 * local_x + 0.1 * local_y
@@ -53,7 +53,7 @@ def test_depth_symmetry_plane_calibration_matches_cutout_axes():
np.ones((3, 3), dtype=bool),
0.5,
)
expected_direction = np.asarray((0.1, 0.05, 1.0), dtype=np.float64)
expected_direction = np.asarray((-0.1, -0.05, 1.0), dtype=np.float64)
expected_direction /= np.linalg.norm(expected_direction)
assert np.isclose(
@@ -73,7 +73,7 @@ def test_depth_symmetry_reference_uses_the_shared_upper_depth_percentile():
uv = []
for y in range(size):
for x in range(size):
vertices.append((float(x), float(size - 1 - y), 0.0))
vertices.append((float(x), 0.0, float(size - 1 - y)))
uv.append((x / (size - 1), 1.0 - y / (size - 1)))
reference, direction = depth_calibration.depth_symmetry_projection_parameters(

View File

@@ -48,7 +48,7 @@ def test_mesh_density_survives_resolution_and_canvas_changes(fine_outline):
assert max(counts) / min(counts) < 1.05
for mesh in meshes:
assert np.isfinite(mesh.vertices).all()
np.testing.assert_allclose(np.ptp(mesh.vertices[:, :2], axis=0), (1, 1), atol=0.03)
np.testing.assert_allclose(np.ptp(mesh.vertices[:, [0, 2]], axis=0), (1, 1), atol=0.03)
@pytest.mark.parametrize("fine_outline", (False, True))
@@ -60,7 +60,7 @@ def test_world_scaling_adds_samples_and_rotation_preserves_density(fine_outline)
source = source_plane(matrix=matrix)
mesh = g.build_base_shape(bpy.context, source, 0.1, alpha, (0, 0, 100, 100), fine_outline=fine_outline)
counts.append(len(mesh.vertices))
planar = mesh.vertices[:, :2] @ g.metric_transform(g.world_plane_metric(matrix)[0])
planar = mesh.vertices[:, [0, 2]] @ g.metric_transform(g.world_plane_metric(matrix)[0])
edges = np.unique(np.sort(np.concatenate([mesh.faces[:, [0, 1]], mesh.faces[:, [1, 2]], mesh.faces[:, [2, 0]]]), axis=1), axis=0)
lengths = np.linalg.norm(planar[edges[:, 0]] - planar[edges[:, 1]], axis=1)
assert 0.06 < np.median(lengths) < 0.14

View File

@@ -211,7 +211,7 @@ def test_cutout_domain_centers_vertices_on_crop_bounds():
center = (vertices[:, :2].min(axis=0) + vertices[:, :2].max(axis=0)) * 0.5
assert np.allclose(center, 0.0, atol=1e-6)
expected_uv = vertices[:, :2] / np.asarray((4.0, 4.0)) + 0.5
expected_uv = vertices[:, [0, 2]] / np.asarray((4.0, 4.0)) + 0.5
assert np.allclose(uv, expected_uv, atol=1e-6)

View File

@@ -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 = [

View File

@@ -6,6 +6,7 @@ from mathutils import Matrix, Vector
from anyimage.common import image as image_data
from anyimage.common import object as object_data
from anyimage.common.coordinate import canonical_direction_to_legacy
from anyimage.operators.clipboard_image import actions as clipboard_actions
from anyimage.operators.convert_to_plane import object as plane_object
from anyimage.operators.cutout_tool import shape as cutout_shape
@@ -101,8 +102,8 @@ def test_converted_planes_center_the_origin_without_moving_world_corners():
atol=1e-6,
)
local = np.asarray([tuple(vertex.co) for vertex in result.data.vertices])
assert np.allclose(local[:, :2].min(axis=0), (-1.0, -0.5), atol=1e-6)
assert np.allclose(local[:, :2].max(axis=0), (1.0, 0.5), atol=1e-6)
assert np.allclose(local[:, [0, 2]].min(axis=0), (-1.0, -0.5), atol=1e-6)
assert np.allclose(local[:, [0, 2]].max(axis=0), (1.0, 0.5), atol=1e-6)
expected = sorted(
tuple(source_matrix @ Vector((x, y, 0.0)))
for x in (x_min, x_max)
@@ -199,9 +200,12 @@ def test_relief_plane_builds_a_fixed_base_terrain_block():
assert result.modifiers[0].node_group.name == "O Image Relief Plane"
modifier = result.modifiers[0]
group = modifier.node_group
for name in ("Thickness", "Depth Scale"):
identifier = object_data.modifier_input_identifier(group, name)
assert modifier[identifier] == 0.5
assert modifier[
object_data.modifier_input_identifier(group, "Thickness")
] == 0.0
assert modifier[
object_data.modifier_input_identifier(group, "Depth Scale")
] == 0.5
assert modifier[
object_data.modifier_input_identifier(group, "Depth Offset")
] == 1.0
@@ -219,12 +223,12 @@ def test_relief_plane_builds_a_fixed_base_terrain_block():
set_input("Thickness", 0.1)
set_input("Depth Offset", 0.0)
baseline = _evaluated_positions(result)
assert np.isclose(baseline[:, 2].min(), 0.0, atol=1e-6)
assert np.isclose(baseline[:, 2].max(), 0.1, atol=1e-6)
assert np.isclose(baseline[:, 1].min(), 0.0, atol=1e-6)
assert np.isclose(baseline[:, 1].max(), 0.1, atol=1e-6)
baseline_base = {
(round(float(x), 6), round(float(y), 6))
for x, y, z in baseline
if np.isclose(z, 0.0, atol=1e-6)
if np.isclose(y, 0.0, atol=1e-6)
}
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
@@ -240,38 +244,38 @@ def test_relief_plane_builds_a_fixed_base_terrain_block():
set_input("Depth Scale", 1.0)
set_input("Reference Depth", 1.2)
outward = _evaluated_positions(result)
assert np.isclose(outward[:, 2].min(), 0.0, atol=1e-6)
assert np.isclose(outward[:, 2].max(), 0.3, atol=1e-5)
assert np.isclose(outward[:, 1].min(), -0.2, atol=1e-6)
assert np.isclose(outward[:, 1].max(), 0.1, atol=1e-5)
outward_base = {
(round(float(x), 6), round(float(y), 6))
for x, y, z in outward
if np.isclose(z, 0.0, atol=1e-6)
if np.isclose(y, 0.0, atol=1e-6)
}
assert outward_base == baseline_base
z_levels = np.unique(np.round(outward[:, 2], 6))
assert len(z_levels) >= 4
assert np.allclose(np.diff(z_levels), np.diff(z_levels)[0], atol=1e-5)
y_levels = np.unique(np.round(outward[:, 1], 6))
assert len(y_levels) >= 4
assert np.allclose(np.diff(y_levels), np.diff(y_levels)[0], atol=1e-5)
set_input("Reference Depth", 0.95)
inward = _evaluated_positions(result)
assert np.isclose(inward[:, 2].min(), 0.0, atol=1e-6)
assert np.isclose(inward[:, 2].max(), 0.1, atol=1e-5)
assert np.isclose(inward[:, 1].min(), 0.0, atol=1e-6)
assert np.isclose(inward[:, 1].max(), 0.1, atol=1e-5)
set_input("Reference Depth", 0.0)
limited = _evaluated_positions(result)
assert np.isclose(limited[:, 2].min(), 0.0, atol=1e-6)
assert np.isclose(limited[:, 2].max(), 0.1, atol=1e-5)
assert np.isclose(limited[:, 1].min(), 0.0, atol=1e-6)
assert np.isclose(limited[:, 1].max(), 0.1, atol=1e-5)
set_input("Reference Depth", 1.3)
outward_beyond_thickness = _evaluated_positions(result)
assert np.isclose(outward_beyond_thickness[:, 2].max(), 0.4, atol=1e-5)
assert np.isclose(outward_beyond_thickness[:, 1].max(), 0.1, atol=1e-5)
set_input("Thickness", 0.0)
set_input("Reference Depth", 1.2)
sheet = _evaluated_positions(result)
assert np.allclose(sheet[:, 2], 0.2, atol=1e-5)
assert np.isfinite(sheet).all()
set_input("Reference Depth", 0.8)
assert np.allclose(_evaluated_positions(result)[:, 2], 0.0, atol=1e-5)
assert np.allclose(_evaluated_positions(result)[:, 1], 0.0, atol=1e-5)
def test_relief_plane_initializes_a_fitted_depth_direction():
@@ -313,6 +317,7 @@ def test_relief_plane_initializes_a_fitted_depth_direction():
uniform_scale = modifier[
object_data.modifier_input_identifier(modifier.node_group, "Uniform Scale")
]
expected = np.asarray((0.2 * uniform_scale, 0.1 * uniform_scale, 1.0))
canonical = np.asarray((0.2 * uniform_scale, 1.0, -0.1 * uniform_scale))
expected = np.asarray(canonical_direction_to_legacy(canonical))
expected /= np.linalg.norm(expected)
np.testing.assert_allclose(direction, expected, atol=1e-6)