- Replaced the Mesh Detail parameter with a new Edge Length property to define cutout sampling in physical units. - Introduced Minimum Relative Edge Length preference to control effective spacing based on the bounding box of the cutout content. - Updated tests to validate the new edge length functionality and ensure consistent behavior across different resolutions and canvas sizes. - Removed deprecated parameters and adjusted related properties to align with the new edge length implementation. - Enhanced geometry sampling logic to utilize effective world spacing for both standard and fine outline modes.
934 lines
35 KiB
Python
934 lines
35 KiB
Python
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
import bpy
|
|
import numpy as np
|
|
import pytest
|
|
from mathutils import Matrix, Vector
|
|
|
|
|
|
from anyimage.common import depth as depth_data
|
|
from anyimage.common import color_image as colors
|
|
from tests.support.color_image import color_image as prepared_color_image
|
|
from anyimage.common import image as image_data
|
|
from anyimage.common import material as material_data
|
|
from anyimage.common import object as object_data
|
|
from anyimage.common.selection import ImageEditWarning, SelectionPath, rasterize_selection_path
|
|
from anyimage.operators.cutout_tool import geometry as cutout_geometry
|
|
from anyimage.operators.cutout_tool import operators as cutout_main
|
|
from anyimage.operators.cutout_tool import object as cutout_object
|
|
from anyimage.operators.cutout_tool import shape as cutout_shape
|
|
from anyimage.server.geometry.depth_texture import (
|
|
write_depth_texture,
|
|
)
|
|
from anyimage.server.models.geometry import GeometryFrame
|
|
from tests.support.image_objects import _add_cutout_modifier, _create_cutout_shape, _depth_image, _depth_metadata, _depth_surface_step_object, _evaluated_positions, _evaluated_topology, _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)
|
|
|
|
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[mode] == (1 if gesture == "POLYLINE" else 0)
|
|
thickness = object_data.modifier_input_identifier(
|
|
modifier.node_group, "Thickness", subtype="DISTANCE",
|
|
)
|
|
assert 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[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)
|
|
source = bpy.data.objects.new("Source", None)
|
|
source.data = source_image
|
|
points = np.arange(12, dtype=np.float32).reshape((2, 2, 3))
|
|
validity = np.asarray(((1.0, 0.0), (0.5, 1.0)), dtype=np.float32)
|
|
frame = GeometryFrame(
|
|
depth=points[..., 2].copy(),
|
|
validity=validity,
|
|
intrinsics=np.eye(3, dtype=np.float32),
|
|
points=points,
|
|
)
|
|
path = tmp_path / "depth.exr"
|
|
alpha = np.asarray(((0.8, 1.0), (0.75, 0.0)), dtype=np.float32)
|
|
write_depth_texture(frame, path, alpha=alpha)
|
|
metadata = _depth_metadata(np.eye(3), (2, 2), 1.0)
|
|
|
|
image = depth_data.load_depth_result_image(path, source, metadata)
|
|
|
|
packed = np.empty(16, dtype=np.float32)
|
|
image.pixels.foreach_get(packed)
|
|
packed = np.flipud(packed.reshape((2, 2, 4)))
|
|
assert np.array_equal(packed[..., :3], points)
|
|
assert np.array_equal(packed[..., 3], alpha * validity)
|
|
camera_depth, valid = depth_data.camera_depth_values(image)
|
|
np.testing.assert_array_equal(camera_depth, points[..., 2])
|
|
np.testing.assert_array_equal(valid, alpha * validity > 0.5)
|
|
assert image.alpha_mode == "CHANNEL_PACKED"
|
|
assert image.name == "Source_depth.exr"
|
|
|
|
|
|
def test_depth_texture_keeps_points_outside_positive_validity(tmp_path):
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
source_image = bpy.data.images.new("Source.png", width=2, height=1)
|
|
source = bpy.data.objects.new("Source", None)
|
|
source.data = source_image
|
|
frame = GeometryFrame(
|
|
depth=np.asarray(((2.0, 7.0),), dtype=np.float32),
|
|
points=np.asarray((((-1.0, 3.0, 2.0), (4.0, -2.0, 7.0)),), dtype=np.float32),
|
|
validity=np.asarray(((1.0, 0.0),), dtype=np.float32),
|
|
intrinsics=np.eye(3, dtype=np.float32),
|
|
)
|
|
path = tmp_path / "depth.exr"
|
|
write_depth_texture(frame, path, alpha=np.ones_like(frame.validity))
|
|
|
|
image = depth_data.load_depth_result_image(
|
|
path,
|
|
source,
|
|
_depth_metadata(np.eye(3), (2, 1), 2.0),
|
|
)
|
|
|
|
packed = np.empty(8, dtype=np.float32)
|
|
image.pixels.foreach_get(packed)
|
|
packed = packed.reshape((1, 2, 4))
|
|
assert np.array_equal(packed[..., :3], frame.points)
|
|
assert np.array_equal(packed[..., 3], frame.validity)
|
|
|
|
|
|
def test_balloon_build_starts_from_the_unified_single_sheet():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
image = bpy.data.images.new("Balloon Sheet", width=64, height=64, alpha=True)
|
|
source = bpy.data.objects.new("Balloon Sheet", None)
|
|
source.empty_display_type = "IMAGE"
|
|
source.data = image
|
|
source.empty_display_size = 2.0
|
|
bpy.context.collection.objects.link(source)
|
|
result = _create_cutout_shape(
|
|
source,
|
|
"SOLID",
|
|
0.1,
|
|
)
|
|
|
|
assert all(abs(vertex.co.z) < 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
|
|
profile_attribute = result.data.attributes[cutout_shape.BALLOON_ATTRIBUTE_NAME]
|
|
assert profile_attribute.data_type == "FLOAT"
|
|
assert profile_attribute.domain == "POINT"
|
|
profile = np.empty(len(result.data.vertices), dtype=np.float32)
|
|
profile_attribute.data.foreach_get("value", profile)
|
|
assert profile.min() == 0.0 and profile.max() > 0.0
|
|
material_tree = result.data.materials[0].node_tree
|
|
normal_scale_attribute = next(
|
|
node
|
|
for node in material_tree.nodes
|
|
if node.bl_idname == "ShaderNodeAttribute"
|
|
)
|
|
assert normal_scale_attribute.attribute_type == "GEOMETRY"
|
|
assert normal_scale_attribute.attribute_name == cutout_shape.NORMAL_SCALE_ATTRIBUTE_NAME
|
|
image_layer = _group_node(material_data.material_node_group(), material_tree)
|
|
normal_scale_link = next(
|
|
link
|
|
for link in material_tree.links
|
|
if link.to_node == image_layer
|
|
and link.to_socket == image_layer.inputs["Normal Scale"]
|
|
)
|
|
assert normal_scale_link.from_node == normal_scale_attribute
|
|
assert normal_scale_link.from_socket == normal_scale_attribute.outputs["Fac"]
|
|
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))
|
|
assert np.allclose(tuple(inward), (0.0, 0.0, -1.0), atol=1e-6)
|
|
|
|
|
|
def test_teddy_preference_writes_the_shared_balloon_attribute():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
image = bpy.data.images.new("Teddy Sheet", width=64, height=64, alpha=True)
|
|
source = bpy.data.objects.new("Teddy Sheet", None)
|
|
source.empty_display_type = "IMAGE"
|
|
source.data = image
|
|
source.empty_display_size = 2.0
|
|
bpy.context.collection.objects.link(source)
|
|
with patch.object(
|
|
cutout_geometry,
|
|
"addon_preferences",
|
|
return_value=SimpleNamespace(balloon_profile_algorithm="TEDDY"),
|
|
):
|
|
result = _create_cutout_shape(
|
|
source,
|
|
"FLAT",
|
|
0.1,
|
|
)
|
|
|
|
attribute = result.data.attributes[cutout_shape.BALLOON_ATTRIBUTE_NAME]
|
|
profile = np.empty(len(result.data.vertices), dtype=np.float32)
|
|
attribute.data.foreach_get("value", profile)
|
|
assert profile.min() == 0.0 and profile.max() > 0.0
|
|
assert any(
|
|
node.bl_idname == "ShaderNodeAttribute"
|
|
for node in result.data.materials[0].node_tree.nodes
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("gesture", ("LASSO", "POLYLINE"))
|
|
def test_depth_surface_protected_smooth_evaluates(gesture):
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
image = bpy.data.images.new("Surface", width=64, height=64)
|
|
source = bpy.data.objects.new("Surface", None)
|
|
source.empty_display_type = "IMAGE"
|
|
source.data = image
|
|
source.empty_display_size = 2.0
|
|
bpy.context.collection.objects.link(source)
|
|
size = 8
|
|
depth = np.ones((size, size), dtype=np.float32)
|
|
x, y = np.meshgrid(np.arange(size), np.arange(size))
|
|
intrinsics = np.asarray(
|
|
((8.0, 0.0, 3.5), (0.0, 8.0, 3.5), (0.0, 0.0, 1.0)),
|
|
dtype=np.float32,
|
|
)
|
|
points = np.empty((size, size, 3), dtype=np.float32)
|
|
points[..., 0] = (x - intrinsics[0, 2]) / intrinsics[0, 0] * depth
|
|
points[..., 1] = (y - intrinsics[1, 2]) / intrinsics[1, 1] * depth
|
|
points[..., 2] = depth
|
|
valid = np.ones((size, size), dtype=bool)
|
|
depth_image = _depth_image(points, valid)
|
|
depth_metadata = _depth_metadata(intrinsics, (size, size), 1.0)
|
|
|
|
result = _create_cutout_shape(
|
|
source,
|
|
"DEPTH_SOLID",
|
|
0.25,
|
|
depth_image=depth_image,
|
|
depth_metadata=depth_metadata,
|
|
gesture=gesture,
|
|
)
|
|
|
|
assert all(abs(vertex.co.z) < 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"]
|
|
assert modifier[object_data.modifier_input_identifier(modifier.node_group, "Mode")] == (1 if gesture == "POLYLINE" else 0)
|
|
assert modifier[object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="DISTANCE")] == 0.5
|
|
assert modifier[object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="NONE")] == 1.0
|
|
object_data.set_modifier_input(
|
|
modifier,
|
|
object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="DISTANCE"),
|
|
0.0,
|
|
)
|
|
expected_cleanup = cutout_geometry.cutout_cleanup_value(
|
|
0.25,
|
|
cutout_geometry.world_plane_metric(source.matrix_world)[0],
|
|
)
|
|
assert np.isclose(
|
|
modifier[object_data.modifier_input_identifier(modifier.node_group, "Cleanup Threshold")],
|
|
expected_cleanup,
|
|
)
|
|
assert np.isclose(modifier[object_data.modifier_input_identifier(modifier.node_group, "Smooth Weight")], 1.0)
|
|
assert (
|
|
modifier[
|
|
object_data.modifier_input_identifier(modifier.node_group, "Smooth")
|
|
]
|
|
== 0
|
|
)
|
|
for name, value in (("Mode", 1), ("Front Inflation", 0.0)):
|
|
object_data.set_modifier_input(modifier, object_data.modifier_input_identifier(modifier.node_group, name), value)
|
|
reference_id = object_data.modifier_input_identifier(
|
|
modifier.node_group,
|
|
"Reference Depth",
|
|
)
|
|
assert modifier[reference_id] == 8.0
|
|
object_data.set_modifier_input(
|
|
modifier,
|
|
object_data.modifier_input_identifier(modifier.node_group, "Smooth Weight"),
|
|
0.0,
|
|
)
|
|
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()
|
|
finally:
|
|
evaluated.to_mesh_clear()
|
|
|
|
object_data.set_modifier_input(
|
|
modifier,
|
|
object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="DISTANCE"),
|
|
1.0,
|
|
)
|
|
result.update_tag(refresh={"DATA"})
|
|
bpy.context.view_layer.update()
|
|
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
|
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)
|
|
finally:
|
|
evaluated.to_mesh_clear()
|
|
|
|
object_data.set_modifier_input(
|
|
modifier,
|
|
object_data.modifier_input_identifier(modifier.node_group, "Smooth"),
|
|
4,
|
|
)
|
|
object_data.set_modifier_input(
|
|
modifier,
|
|
object_data.modifier_input_identifier(modifier.node_group, "Smooth Weight"),
|
|
1.0,
|
|
)
|
|
result.update_tag(refresh={"DATA"})
|
|
bpy.context.view_layer.update()
|
|
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
|
internal_mesh = evaluated.to_mesh()
|
|
try:
|
|
internal_positions = np.asarray(
|
|
[tuple(vertex.co) for vertex in internal_mesh.vertices]
|
|
)
|
|
finally:
|
|
evaluated.to_mesh_clear()
|
|
|
|
assert internal_positions.ndim == 2
|
|
assert internal_positions.shape[1] == 3
|
|
assert np.isfinite(internal_positions).all()
|
|
|
|
|
|
def test_depth_surface_keeps_continuous_points_across_zero_validity_edge():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
image = bpy.data.images.new("Surface", width=64, height=64)
|
|
source = bpy.data.objects.new("Surface", None)
|
|
source.empty_display_type = "IMAGE"
|
|
source.data = image
|
|
source.empty_display_size = 2.0
|
|
bpy.context.collection.objects.link(source)
|
|
size = 8
|
|
depth = np.ones((size, size), dtype=np.float32)
|
|
x, y = np.meshgrid(np.arange(size), np.arange(size))
|
|
intrinsics = np.asarray(
|
|
((8.0, 0.0, 3.5), (0.0, 8.0, 3.5), (0.0, 0.0, 1.0)),
|
|
dtype=np.float32,
|
|
)
|
|
points = np.empty((size, size, 3), dtype=np.float32)
|
|
points[..., 0] = (x - intrinsics[0, 2]) / intrinsics[0, 0] * depth
|
|
points[..., 1] = (y - intrinsics[1, 2]) / intrinsics[1, 1] * depth
|
|
points[..., 2] = depth
|
|
validity = np.ones((size, size), dtype=bool)
|
|
validity[[0, -1], :] = False
|
|
validity[:, [0, -1]] = False
|
|
|
|
result = _create_cutout_shape(
|
|
source,
|
|
"DEPTH_SOLID",
|
|
0.1,
|
|
depth_image=_depth_image(points, validity),
|
|
depth_metadata=_depth_metadata(intrinsics, (size, size), 1.0),
|
|
)
|
|
modifier = result.modifiers[0]
|
|
for name, value in (("Thickness", 0.0), ("Smooth", 0)):
|
|
object_data.set_modifier_input(modifier, object_data.modifier_input_identifier(modifier.node_group, name), value)
|
|
result.update_tag(refresh={"DATA"})
|
|
bpy.context.view_layer.update()
|
|
positions = _evaluated_positions(result)
|
|
|
|
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
|
|
|
|
|
|
def test_depth_surface_removes_boundary_ears_without_loose_geometry():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
obj, modifier = _depth_surface_step_object()
|
|
split_id = object_data.modifier_input_identifier(modifier.node_group, "Depth Split")
|
|
# Both outer ears have three boundary vertices, including the raised corner.
|
|
for value in (0., .5, 1.):
|
|
object_data.set_modifier_input(modifier, split_id, value)
|
|
obj.update_tag(refresh={"DATA"})
|
|
bpy.context.view_layer.update()
|
|
vertices, faces, loose_edges, loose_points = _evaluated_topology(obj)
|
|
assert (vertices, faces) == (7, 6)
|
|
assert not loose_edges and not loose_points
|
|
|
|
|
|
def test_cutout_thickness_keeps_one_connected_shell():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
vertices = [(float(x), float(y), 0.0) for y in range(3) for x in range(3)]
|
|
faces = []
|
|
for y in range(2):
|
|
for x in range(2):
|
|
a = y * 3 + x
|
|
faces.extend(((a, a + 1, a + 4), (a, a + 4, a + 3)))
|
|
data = bpy.data.meshes.new("Connected Cutout")
|
|
data.from_pydata(vertices, (), faces)
|
|
obj = bpy.data.objects.new("Connected Cutout", data)
|
|
bpy.context.collection.objects.link(obj)
|
|
modifier = _add_cutout_modifier(obj, "FLAT")
|
|
|
|
object_data.set_modifier_input(
|
|
modifier,
|
|
object_data.modifier_input_identifier(modifier.node_group, "Cleanup Threshold"),
|
|
0.0,
|
|
)
|
|
object_data.set_modifier_input(
|
|
modifier,
|
|
object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="DISTANCE"),
|
|
1.0,
|
|
)
|
|
|
|
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
|
result = evaluated.to_mesh()
|
|
try:
|
|
assert len(result.vertices) == 18
|
|
assert len(result.polygons) == 24
|
|
z = np.asarray([vertex.co.z for vertex in result.vertices])
|
|
assert np.isclose(z.min(), -1.0, atol=1e-6)
|
|
assert np.isclose(z.max(), 0.0, atol=1e-6)
|
|
front_faces = [
|
|
polygon for polygon in result.polygons if polygon.normal.z > 0.9
|
|
]
|
|
back_faces = [polygon for polygon in result.polygons if polygon.normal.z < -0.9]
|
|
side_faces = [
|
|
polygon for polygon in result.polygons if abs(polygon.normal.z) < 0.1
|
|
]
|
|
assert len(front_faces) == 8
|
|
assert len(back_faces) == 8
|
|
assert len(side_faces) == 8
|
|
center = Vector((1.0, 1.0, 0.0))
|
|
for polygon in side_faces:
|
|
radial = polygon.center - center
|
|
assert polygon.normal.dot(radial) > 0.0
|
|
finally:
|
|
evaluated.to_mesh_clear()
|
|
|
|
object_data.set_modifier_input(
|
|
modifier,
|
|
object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="DISTANCE"),
|
|
2.4,
|
|
)
|
|
obj.update_tag(refresh={"DATA"})
|
|
bpy.context.view_layer.update()
|
|
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
|
result = evaluated.to_mesh()
|
|
try:
|
|
assert len(result.vertices) == 34
|
|
assert len(result.polygons) == 40
|
|
side_faces = [
|
|
polygon for polygon in result.polygons if abs(polygon.normal.z) < 0.1
|
|
]
|
|
assert len(side_faces) == 24
|
|
for polygon in side_faces:
|
|
coordinates = [result.vertices[index].co for index in polygon.vertices]
|
|
edge_lengths = [
|
|
(coordinates[(index + 1) % len(coordinates)] - coordinate).length
|
|
for index, coordinate in enumerate(coordinates)
|
|
]
|
|
assert max(edge_lengths) / min(edge_lengths) <= 1.25 + 1e-6
|
|
finally:
|
|
evaluated.to_mesh_clear()
|
|
|
|
|
|
def test_balloon_uses_injected_profile_at_default_height():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
vertices = [(float(x), float(y), 0.0) for y in range(3) for x in range(3)]
|
|
faces = []
|
|
for y in range(2):
|
|
for x in range(2):
|
|
a = y * 3 + x
|
|
faces.extend(((a, a + 1, a + 4), (a, a + 4, a + 3)))
|
|
data = bpy.data.meshes.new("Balloon Profile")
|
|
data.from_pydata(vertices, (), faces)
|
|
cutout_object._assign_float_point_attribute(
|
|
data,
|
|
cutout_shape.BALLOON_ATTRIBUTE_NAME,
|
|
(0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0),
|
|
)
|
|
obj = bpy.data.objects.new("Balloon Profile", data)
|
|
bpy.context.collection.objects.link(obj)
|
|
modifier = _add_cutout_modifier(obj, "SOLID")
|
|
|
|
object_data.set_modifier_input(
|
|
modifier,
|
|
object_data.modifier_input_identifier(modifier.node_group, "Cleanup Threshold"),
|
|
0.0,
|
|
)
|
|
object_data.set_modifier_input(
|
|
modifier,
|
|
object_data.modifier_input_identifier(modifier.node_group, "Thickness"),
|
|
1.0,
|
|
)
|
|
|
|
bpy.context.view_layer.update()
|
|
positions = _evaluated_positions(obj)
|
|
assert np.isclose(positions[:, 2].min(), -1.0, atol=1e-6)
|
|
assert np.isclose(positions[:, 2].max(), 1.0, atol=1e-6)
|
|
assert np.isclose(
|
|
positions[:, 2].min(),
|
|
-positions[:, 2].max(),
|
|
atol=1e-5,
|
|
)
|
|
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
|
result = evaluated.to_mesh()
|
|
try:
|
|
baseline_face_count = len(result.polygons)
|
|
finally:
|
|
evaluated.to_mesh_clear()
|
|
object_data.set_modifier_input(
|
|
modifier,
|
|
object_data.modifier_input_identifier(modifier.node_group, "Thickness"),
|
|
2.0,
|
|
)
|
|
obj.update_tag(refresh={"DATA"})
|
|
bpy.context.view_layer.update()
|
|
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
|
result = evaluated.to_mesh()
|
|
try:
|
|
assert len(result.polygons) == baseline_face_count
|
|
z = np.asarray([vertex.co.z for vertex in result.vertices])
|
|
assert np.isclose(z.min(), -2.0, atol=1e-6)
|
|
assert np.isclose(z.max(), 2.0, atol=1e-6)
|
|
finally:
|
|
evaluated.to_mesh_clear()
|
|
object_data.set_modifier_input(
|
|
modifier,
|
|
object_data.modifier_input_identifier(modifier.node_group, "Thickness"),
|
|
0.0,
|
|
)
|
|
obj.update_tag(refresh={"DATA"})
|
|
bpy.context.view_layer.update()
|
|
zero_positions = _evaluated_positions(obj)
|
|
assert len(zero_positions) == len(vertices)
|
|
assert np.allclose(zero_positions[:, 2], 0.0, atol=1e-6)
|
|
|
|
|
|
def test_cutout_cleanup_uses_absolute_area_with_many_small_islands():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
vertices = [(float(x), float(y), 0.0) for y in range(3) for x in range(3)]
|
|
faces = []
|
|
for y in range(2):
|
|
for x in range(2):
|
|
a = y * 3 + x
|
|
faces.extend(((a, a + 1, a + 4), (a, a + 4, a + 3)))
|
|
for island in range(12):
|
|
start = len(vertices)
|
|
x = 5.0 + island * 0.1
|
|
vertices.extend(((x, 5.0, 0.0), (x + 0.01, 5.0, 0.0), (x, 5.01, 0.0)))
|
|
faces.append((start, start + 1, start + 2))
|
|
data = bpy.data.meshes.new("Cleanup Threshold")
|
|
data.from_pydata(vertices, (), faces)
|
|
obj = bpy.data.objects.new("Cleanup Threshold", data)
|
|
bpy.context.collection.objects.link(obj)
|
|
modifier = _add_cutout_modifier(obj, "FLAT")
|
|
|
|
cleanup_id = object_data.modifier_input_identifier(modifier.node_group, "Cleanup Threshold")
|
|
|
|
object_data.set_modifier_input(modifier, cleanup_id, 0.0)
|
|
bpy.context.view_layer.update()
|
|
assert len(_evaluated_positions(obj)) == 45
|
|
|
|
object_data.set_modifier_input(modifier, cleanup_id, 0.1)
|
|
obj.update_tag(refresh={"DATA"})
|
|
bpy.context.view_layer.update()
|
|
assert len(_evaluated_positions(obj)) == 9
|
|
|
|
|
|
def test_depth_balloon_uses_metric_plane_inputs():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
image = bpy.data.images.new("Depth Balloon", width=100, height=100, alpha=True)
|
|
source = bpy.data.objects.new("Depth Balloon", None)
|
|
source.empty_display_type = "IMAGE"
|
|
source.data = image
|
|
source.empty_display_size = 2.0
|
|
bpy.context.collection.objects.link(source)
|
|
crop_bounds = (50, 20, 90, 80)
|
|
local_bounds = cutout_geometry.crop_plane_bounds(
|
|
source,
|
|
crop_bounds,
|
|
(100, 100),
|
|
)
|
|
y, x = np.indices((61, 41), dtype=np.float32)
|
|
normalized_x = x / 40.0 * 2.0 - 1.0
|
|
normalized_y = y / 60.0 * 2.0 - 1.0
|
|
points = np.zeros((61, 41, 3), dtype=np.float32)
|
|
points[..., 0] = normalized_x
|
|
points[..., 1] = normalized_y
|
|
points[..., 2] = 2.0 + normalized_x * 0.15 - normalized_y * 0.1
|
|
points[27:34, 17:24, 2] -= 0.25
|
|
intrinsics = np.asarray(
|
|
((50.0, 0.0, 20.0), (0.0, 50.0, 30.0), (0.0, 0.0, 1.0)),
|
|
dtype=np.float32,
|
|
)
|
|
valid = np.ones((61, 41), dtype=bool)
|
|
depth_image = _depth_image(points, valid)
|
|
depth_metadata = _depth_metadata(intrinsics, (41, 61), 2.0)
|
|
|
|
selection_values = np.ones((60, 40), dtype=np.float32)
|
|
selection_values[:, :2] = 0.0
|
|
result = _create_cutout_shape(
|
|
source,
|
|
"DEPTH_BALLOON",
|
|
0.1,
|
|
depth_image=depth_image,
|
|
depth_metadata=depth_metadata,
|
|
bounds=crop_bounds,
|
|
selection_values=selection_values,
|
|
)
|
|
|
|
assert all(abs(vertex.co.z) < 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,
|
|
result.name,
|
|
}
|
|
modifier = result.modifiers[0]
|
|
depth_image_id = object_data.modifier_input_identifier(
|
|
modifier.node_group,
|
|
"Depth Image",
|
|
)
|
|
assert isinstance(modifier[depth_image_id], bpy.types.Image)
|
|
expected_center = np.zeros(2, dtype=np.float64)
|
|
crop_center = Vector(
|
|
(
|
|
(local_bounds[0] + local_bounds[1]) * 0.5,
|
|
(local_bounds[2] + local_bounds[3]) * 0.5,
|
|
0.0,
|
|
)
|
|
)
|
|
expected_origin = source.matrix_world @ crop_center
|
|
assert np.allclose(
|
|
tuple(result.matrix_world.translation),
|
|
tuple(expected_origin),
|
|
atol=1e-5,
|
|
)
|
|
|
|
depth_reference_id = object_data.modifier_input_identifier(
|
|
modifier.node_group,
|
|
"Reference Depth",
|
|
)
|
|
assert not np.isclose(modifier[depth_reference_id], 8.0)
|
|
depth_direction_id = object_data.modifier_input_identifier(
|
|
modifier.node_group,
|
|
"Depth Direction",
|
|
)
|
|
direction = np.asarray(modifier[depth_direction_id], dtype=np.float64)
|
|
assert not np.allclose(direction, (0.0, 0.0, 1.0))
|
|
uniform_scale_id = object_data.modifier_input_identifier(
|
|
modifier.node_group,
|
|
"Uniform Scale",
|
|
)
|
|
assert modifier[uniform_scale_id] > 0.0
|
|
thickness_id = object_data.modifier_input_identifier(
|
|
modifier.node_group,
|
|
"Thickness",
|
|
)
|
|
smooth_id = object_data.modifier_input_identifier(
|
|
modifier.node_group,
|
|
"Smooth Weight",
|
|
)
|
|
assert modifier[smooth_id] == 1.0
|
|
object_data.set_modifier_input(modifier, smooth_id, 0.0)
|
|
result.update_tag(refresh={"DATA"})
|
|
bpy.context.view_layer.update()
|
|
positions = _evaluated_positions(result)
|
|
center_distance = np.linalg.norm(positions[:, :2] - expected_center, axis=1)
|
|
nearest_center = center_distance <= center_distance.min() + 1e-6
|
|
depth_at_thickness_one = positions[nearest_center, 2].max()
|
|
object_data.set_modifier_input(modifier, thickness_id, 2.0)
|
|
result.update_tag(refresh={"DATA"})
|
|
bpy.context.view_layer.update()
|
|
positions = _evaluated_positions(result)
|
|
center_distance = np.linalg.norm(positions[:, :2] - expected_center, axis=1)
|
|
nearest_center = center_distance <= center_distance.min() + 1e-6
|
|
depth_at_thickness_two = positions[nearest_center, 2].max()
|
|
assert np.isclose(
|
|
depth_at_thickness_one,
|
|
depth_at_thickness_two,
|
|
atol=1e-5,
|
|
)
|
|
object_data.set_modifier_input(modifier, thickness_id, 1.0)
|
|
result.update_tag(refresh={"DATA"})
|
|
bpy.context.view_layer.update()
|
|
|
|
before_positions = _evaluated_positions(result)
|
|
object_data.set_modifier_input(
|
|
modifier,
|
|
depth_reference_id,
|
|
modifier[depth_reference_id] + 0.2,
|
|
)
|
|
result.update_tag(refresh={"DATA"})
|
|
bpy.context.view_layer.update()
|
|
after_positions = _evaluated_positions(result)
|
|
assert np.isclose(
|
|
(after_positions[:, 2] - before_positions[:, 2]).max(),
|
|
0.2,
|
|
atol=1e-5,
|
|
)
|
|
|
|
depth_scale_id = object_data.modifier_input_identifier(
|
|
modifier.node_group,
|
|
"Depth Scale",
|
|
)
|
|
object_data.set_modifier_input(modifier, depth_scale_id, 0.0)
|
|
result.update_tag(refresh={"DATA"})
|
|
bpy.context.view_layer.update()
|
|
zero_scale_before = _evaluated_positions(result)
|
|
object_data.set_modifier_input(
|
|
modifier,
|
|
depth_reference_id,
|
|
modifier[depth_reference_id] + 0.15,
|
|
)
|
|
object_data.set_modifier_input(modifier, depth_direction_id, (0.2, 0.1, 1.0))
|
|
result.update_tag(refresh={"DATA"})
|
|
bpy.context.view_layer.update()
|
|
zero_scale_after = _evaluated_positions(result)
|
|
assert np.allclose(zero_scale_after, zero_scale_before, atol=1e-5)
|
|
object_data.set_modifier_input(modifier, depth_scale_id, 1.0)
|
|
|
|
double_sided_id = object_data.modifier_input_identifier(
|
|
modifier.node_group,
|
|
"Double Sided",
|
|
)
|
|
object_data.set_modifier_input(modifier, double_sided_id, True)
|
|
result.update_tag(refresh={"DATA"})
|
|
bpy.context.view_layer.update()
|
|
mirrored = _evaluated_positions(result)
|
|
layers = {}
|
|
for x, y, z in mirrored:
|
|
layers.setdefault((round(float(x), 6), round(float(y), 6)), []).append(z)
|
|
assert layers
|
|
for z_values in layers.values():
|
|
if len(z_values) == 1:
|
|
assert np.isclose(z_values[0], 0.0, atol=1e-5)
|
|
continue
|
|
assert np.isclose(
|
|
min(z_values) + max(z_values),
|
|
0.0,
|
|
atol=1e-5,
|
|
)
|
|
|
|
|
|
def test_bounds_input_preserves_rectangular_rgba_and_stays_packed_after_cleanup():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
image = bpy.data.images.new("Bounds Source", width=5, height=4, alpha=True)
|
|
rgba = np.arange(4 * 5 * 4, dtype=np.float32).reshape((4, 5, 4)) / 100.0
|
|
rgba[:, :, 3] = np.asarray(
|
|
(
|
|
(0.0, 0.1, 0.2, 0.3, 0.4),
|
|
(0.5, 0.6, 0.7, 0.8, 0.9),
|
|
(1.0, 0.9, 0.8, 0.7, 0.6),
|
|
(0.5, 0.4, 0.3, 0.2, 0.1),
|
|
),
|
|
dtype=np.float32,
|
|
)
|
|
path = colors.prepare_material_color_input(image, (1, 1, 5, 4), rgba)
|
|
result = None
|
|
try:
|
|
result = colors.load_material_color_image(path)
|
|
loaded = image_data.image_rgba(result)
|
|
np.testing.assert_allclose(loaded, rgba[1:4, 1:5], atol=1.0 / 255.0)
|
|
assert result.packed_file is not None
|
|
|
|
colors.cleanup_material_color_input(path)
|
|
|
|
assert not path.exists()
|
|
assert result.packed_file is not None
|
|
finally:
|
|
colors.cleanup_material_color_input(path)
|
|
if result is not None:
|
|
bpy.data.images.remove(result, do_unlink=True)
|
|
|
|
|
|
def test_local_cutout_selection_matches_uint8_tenth_alpha_boundary():
|
|
top_down = np.ones((4, 4, 4), dtype=np.float32)
|
|
top_down[1, 1, 3] = 25.0 / 255.0
|
|
top_down[1, 2, 3] = 26.0 / 255.0
|
|
selection_mask = rasterize_selection_path(
|
|
(4, 4),
|
|
SelectionPath(
|
|
points=((0, 0), (4, 0), (4, 4), (0, 4)),
|
|
),
|
|
)
|
|
selection = cutout_main._cutout_content_values(
|
|
top_down[:, :, 3],
|
|
selection_mask.values,
|
|
)
|
|
|
|
assert selection[1, 1] <= cutout_geometry.CUTOUT_ALPHA_THRESHOLD
|
|
assert selection[1, 2] > 0.1
|
|
|
|
|
|
def test_cutout_content_merge_keeps_the_geometric_selection_unchanged():
|
|
selection = np.asarray(((0.25, 1.0), (0.5, 0.75)), dtype=np.float32)
|
|
original = selection.copy()
|
|
alpha = np.asarray(((1.0, 0.5), (0.0, 0.25)), dtype=np.float32)
|
|
|
|
content = cutout_main._cutout_content_values(
|
|
alpha, selection, alpha_threshold=0.5
|
|
)
|
|
|
|
np.testing.assert_array_equal(selection, original)
|
|
np.testing.assert_allclose(content, alpha * original)
|
|
with pytest.raises(ImageEditWarning, match="no visible image pixels"):
|
|
cutout_main._cutout_content_values(np.zeros((2, 2)), selection)
|
|
|
|
|
|
def test_local_cutout_builds_from_blender_image_pixels_without_a_job():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
image = bpy.data.images.new("Local Cutout", width=64, height=48, alpha=True)
|
|
source_rgba = np.ones((48, 64, 4), dtype=np.float32)
|
|
source_rgba[:, :, 3] = np.linspace(0.25, 1.0, 48, dtype=np.float32)[:, None]
|
|
image.pixels.foreach_set(np.flipud(source_rgba).ravel())
|
|
source = bpy.data.objects.new("Local Cutout", None)
|
|
source.empty_display_type = "IMAGE"
|
|
source.data = image
|
|
bpy.context.collection.objects.link(source)
|
|
source.select_set(True)
|
|
bpy.context.view_layer.objects.active = source
|
|
selection_path = SelectionPath(
|
|
points=((-8, 8), (72, 8), (72, 40), (-8, 40)),
|
|
)
|
|
source_bounds = image_data.image_empty_bounds(source)
|
|
|
|
with patch.object(
|
|
image_data,
|
|
"image_rgba",
|
|
wraps=image_data.image_rgba,
|
|
) as read_rgba:
|
|
rgba = image_data.image_rgba(image)
|
|
selection_mask = rasterize_selection_path(
|
|
tuple(image.size),
|
|
selection_path,
|
|
antialias=True,
|
|
)
|
|
left, top, right, bottom = selection_mask.bounds
|
|
content_values = cutout_main._cutout_content_values(
|
|
rgba[top:bottom, left:right, 3],
|
|
selection_mask.values,
|
|
alpha_threshold=0.5,
|
|
)
|
|
color_image = prepared_color_image(
|
|
source.data,
|
|
selection_mask.bounds,
|
|
rgba,
|
|
)
|
|
cutout_main.create_cutout_shape(
|
|
bpy.context,
|
|
source,
|
|
"FLAT",
|
|
0.1,
|
|
content_values,
|
|
selection_mask.bounds,
|
|
alpha_threshold=0.5,
|
|
color_image=color_image,
|
|
)
|
|
|
|
read_rgba.assert_called_once_with(image)
|
|
|
|
result = bpy.context.view_layer.objects.active
|
|
assert result is not source
|
|
assert result.type == "MESH"
|
|
assert result["anyimage_mesh_shape"] == "FLAT"
|
|
modifier = result.modifiers[0]
|
|
assert modifier.node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAMES["FLAT"]
|
|
expected_cleanup = cutout_geometry.cutout_cleanup_value(
|
|
0.1,
|
|
cutout_geometry.world_plane_metric(source.matrix_world)[0],
|
|
)
|
|
assert np.isclose(
|
|
modifier[object_data.modifier_input_identifier(modifier.node_group, "Cleanup Threshold")],
|
|
expected_cleanup,
|
|
)
|
|
assert (
|
|
modifier[object_data.modifier_input_identifier(modifier.node_group, "Smooth Weight")]
|
|
== 1.0
|
|
)
|
|
assert (
|
|
modifier[
|
|
object_data.modifier_input_identifier(modifier.node_group, "Smooth")
|
|
]
|
|
== 0
|
|
)
|
|
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 cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes
|
|
source_inverse = source.matrix_world.inverted()
|
|
source_x = [
|
|
(source_inverse @ (result.matrix_world @ vertex.co)).x
|
|
for vertex in result.data.vertices
|
|
]
|
|
assert min(source_x) >= source_bounds[0]
|
|
assert max(source_x) <= source_bounds[1]
|
|
assert bpy.data.objects.get(source.name) is source
|
|
assert result.data.materials[0].node_tree is not None
|
|
color_texture = next(
|
|
node
|
|
for node in result.data.materials[0].node_tree.nodes
|
|
if node.bl_idname == "ShaderNodeTexImage"
|
|
)
|
|
assert color_texture.image is not image
|
|
assert tuple(color_texture.image.size) == (64, 36)
|
|
copied = np.empty(36 * 64 * 4, dtype=np.float32)
|
|
color_texture.image.pixels.foreach_get(copied)
|
|
copied = copied.reshape((36, 64, 4))
|
|
assert np.allclose(copied[:, :, :3], 1.0)
|
|
left, top, right, bottom = selection_mask.bounds
|
|
assert np.allclose(
|
|
copied[:, :, 3], np.flipud(rgba[top:bottom, left:right, 3])
|
|
)
|