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