- Implemented `cutout_depth_surface.py` for face separation and connected thickness calculations for depth surface assets. - Created `cutout_depth_surface_layout.py` to arrange nodes for staggered calculation branches in depth surface processing. - Authored `geometry_layouts.py` to define layouts for various geometry assets, including image cutouts and depth planes. - Introduced `node_layout.py` for shared presentation rules, facilitating node arrangement and validation in geometry-node layouts.
2161 lines
77 KiB
Python
2161 lines
77 KiB
Python
import sys
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
import bpy
|
|
import numpy as np
|
|
import pytest
|
|
from mathutils import Matrix, Vector
|
|
|
|
SOURCE_ROOT = Path(__file__).parents[1] / "src"
|
|
sys.path.insert(0, str(SOURCE_ROOT))
|
|
|
|
from anyimage.common import depth as depth_data
|
|
from anyimage.common import image as image_data
|
|
from anyimage.common import material as material_data
|
|
from anyimage.common import node as node_asset
|
|
from anyimage.common import object as object_data
|
|
from anyimage.common.selection import (
|
|
ImageEditWarning,
|
|
SelectionMask,
|
|
SelectionPath,
|
|
rasterize_selection_path,
|
|
)
|
|
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 geometry as cutout_geometry
|
|
from anyimage.operators.cutout_tool import main 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.operators.image_edit_tool import mask as edit_mask
|
|
from anyimage.operators.image_edit_tool import frame as edit_frame
|
|
from anyimage.server.geometry.depth_texture import (
|
|
write_vector_depth_texture,
|
|
write_z_depth_texture,
|
|
)
|
|
from anyimage.server.models.geometry import GeometryFrame
|
|
|
|
|
|
def _inputs(group):
|
|
return [
|
|
item.name
|
|
for item in group.interface.items_tree
|
|
if item.item_type == "SOCKET" and item.in_out == "INPUT"
|
|
]
|
|
|
|
|
|
def _group_node(group, node_tree):
|
|
return next(
|
|
node
|
|
for node in node_tree.nodes
|
|
if node.bl_idname == "ShaderNodeGroup" and node.node_tree == group
|
|
)
|
|
|
|
|
|
def _create_cutout_shape(
|
|
source,
|
|
shape,
|
|
detail,
|
|
*,
|
|
bounds=None,
|
|
depth_image=None,
|
|
depth_metadata=None,
|
|
selection_values=None,
|
|
inward_axis=cutout_object.CutoutInwardAxis.NEGATIVE_Z,
|
|
):
|
|
if bounds is None:
|
|
bounds = (0, 0, *tuple(int(value) for value in source.data.size))
|
|
left, top, right, bottom = bounds
|
|
if selection_values is None:
|
|
selection_values = np.ones((bottom - top, right - left), dtype=np.float32)
|
|
selection_mask = SelectionMask(selection_values, bounds)
|
|
source_rgba = image_data.image_rgba(source.data)
|
|
color_image = cutout_object.create_cropped_color_image(
|
|
source,
|
|
selection_mask.bounds,
|
|
source_rgba,
|
|
)
|
|
base_shape = cutout_geometry.build_base_shape(
|
|
bpy.context,
|
|
source,
|
|
detail,
|
|
selection_mask.values,
|
|
selection_mask.bounds,
|
|
)
|
|
return cutout_object.create_shape_object(
|
|
bpy.context,
|
|
source,
|
|
shape,
|
|
detail,
|
|
selection_mask.values,
|
|
selection_mask.bounds,
|
|
base_shape,
|
|
depth_image=depth_image,
|
|
depth_metadata=depth_metadata,
|
|
inward_axis=inward_axis,
|
|
color_image=color_image,
|
|
)
|
|
|
|
|
|
def _depth_image(points, valid, name="Depth"):
|
|
points = np.asarray(points, dtype=np.float32)
|
|
valid = np.asarray(valid, dtype=bool)
|
|
height, width = valid.shape
|
|
pixels = np.zeros((height, width, 4), dtype=np.float32)
|
|
pixels[..., :3] = points
|
|
pixels[..., 3] = valid
|
|
image = bpy.data.images.new(
|
|
name,
|
|
width=width,
|
|
height=height,
|
|
alpha=True,
|
|
float_buffer=True,
|
|
)
|
|
image.colorspace_settings.name = "Non-Color"
|
|
image.alpha_mode = "CHANNEL_PACKED"
|
|
image.pixels.foreach_set(np.flipud(pixels).ravel())
|
|
image.pack()
|
|
return image
|
|
|
|
|
|
def _depth_metadata(intrinsics, image_size, reference_depth):
|
|
return {
|
|
"intrinsics": tuple(tuple(float(value) for value in row) for row in intrinsics),
|
|
"image_size": tuple(int(value) for value in image_size),
|
|
"reference_depth": float(reference_depth),
|
|
}
|
|
|
|
|
|
def test_image_edit_result_uses_complete_shared_source_name():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
source_image = bpy.data.images.new("Poster.png", width=2, height=2, alpha=True)
|
|
source = bpy.data.objects.new("Poster", None)
|
|
source.empty_display_type = "IMAGE"
|
|
source.data = source_image
|
|
shared = bpy.data.objects.new("Poster Shared", None)
|
|
shared.empty_display_type = "IMAGE"
|
|
shared.data = source_image
|
|
bpy.context.collection.objects.link(source)
|
|
bpy.context.collection.objects.link(shared)
|
|
|
|
result_image = image_data.create_image_edit_result(
|
|
source_image,
|
|
np.zeros(4 * 4 * 4, dtype=np.float32),
|
|
(4, 4),
|
|
)
|
|
result_image = image_data.replace_empty_image(source, result_image)
|
|
|
|
assert shared.data == source_image
|
|
assert source_image.alpha_mode == "STRAIGHT"
|
|
assert result_image.alpha_mode == "PREMUL"
|
|
assert source.use_empty_image_alpha
|
|
assert source.data == result_image
|
|
assert result_image.name == "Poster.png.001"
|
|
assert "_color" not in result_image.name
|
|
|
|
|
|
def test_image_edit_result_recovers_unshared_source_name():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
source_image = bpy.data.images.new("Poster.png", width=2, height=2, alpha=True)
|
|
source = bpy.data.objects.new("Poster", None)
|
|
source.empty_display_type = "IMAGE"
|
|
source.data = source_image
|
|
bpy.context.collection.objects.link(source)
|
|
|
|
result_image = image_data.create_image_edit_result(
|
|
source_image,
|
|
np.zeros(4 * 4 * 4, dtype=np.float32),
|
|
(4, 4),
|
|
)
|
|
result_image = image_data.replace_empty_image(source, result_image)
|
|
|
|
assert source.data == result_image
|
|
assert result_image.name == "Poster.png"
|
|
assert bpy.data.images.get("Poster.png") == result_image
|
|
|
|
|
|
def test_mask_frame_mask_add_preserves_packed_hidden_rgb():
|
|
from io import BytesIO
|
|
from PIL import Image
|
|
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
source_image = bpy.data.images.new("Frame RGB", width=4, height=4, alpha=True)
|
|
rgba = np.ones((4, 4, 4), dtype=np.float32)
|
|
rgba[..., :3] = np.arange(48).reshape((4, 4, 3)) / 47.0
|
|
source_image.pixels.foreach_set(np.flipud(rgba).ravel())
|
|
source_image.update()
|
|
expected = image_data.image_rgba(source_image).copy()
|
|
source = bpy.data.objects.new("Frame RGB", None)
|
|
source.empty_display_type = "IMAGE"
|
|
source.empty_display_size = 2.0
|
|
source.empty_image_offset = (-0.5, -0.5)
|
|
source.data = source_image
|
|
bpy.context.collection.objects.link(source)
|
|
selection = SelectionMask(np.ones((4, 4), dtype=np.float32), (0, 0, 4, 4))
|
|
operator = SimpleNamespace(mode="SUBTRACT", report=lambda *_args: None)
|
|
assert edit_mask.EditImageAlpha._apply_mask(
|
|
operator, SimpleNamespace(), source, selection
|
|
) == {"FINISHED"}
|
|
descriptor = edit_frame._frame_source(
|
|
source, SimpleNamespace(width=4, height=4),
|
|
SimpleNamespace(perspective_matrix=Matrix.Identity(4), is_perspective=False),
|
|
Matrix.Identity(4), active=True,
|
|
)
|
|
np.testing.assert_allclose(descriptor["rgba"][..., :3], expected[..., :3])
|
|
np.testing.assert_array_equal(descriptor["pixels"], 0)
|
|
framed = edit_frame.composite_frame_pixels(
|
|
(descriptor,), ((0, 4), (4, 4), (4, 0), (0, 0)), (4, 4)
|
|
)
|
|
result = image_data.create_image_edit_result(source.data, framed, (4, 4))
|
|
result = image_data.replace_empty_image(source, result)
|
|
assert result.alpha_mode == "PREMUL"
|
|
assert source.use_empty_image_alpha
|
|
assert result.packed_file is not None
|
|
packed = np.asarray(Image.open(BytesIO(result.packed_file.data)).convert("RGBA")) / 255.0
|
|
np.testing.assert_allclose(packed[..., :3], expected[..., :3], atol=1 / 255)
|
|
np.testing.assert_array_equal(packed[..., 3], 0)
|
|
result.reload()
|
|
assert result.alpha_mode == "PREMUL"
|
|
operator.mode = "ADD"
|
|
assert edit_mask.EditImageAlpha._apply_mask(
|
|
operator, SimpleNamespace(), source, selection
|
|
) == {"FINISHED"}
|
|
restored = image_data.image_rgba(source.data)
|
|
np.testing.assert_allclose(restored[..., :3], expected[..., :3], atol=1 / 255)
|
|
np.testing.assert_array_equal(restored[..., 3], 1)
|
|
|
|
|
|
def test_frame_restored_alpha_keeps_foreground_blended_into_white():
|
|
from io import BytesIO
|
|
from PIL import Image
|
|
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
sources = []
|
|
for name, color, depth in (("Background", (1, 1, 1, 0), 2),
|
|
("Foreground", (0.2, 0.4, 0.6, 1), 1)):
|
|
image = bpy.data.images.new(name, width=6, height=2, alpha=True)
|
|
rgba = np.tile(color, (2, 6, 1)).astype(np.float32)
|
|
if name == "Foreground":
|
|
rgba[..., 3] = (0, 0.01, 0.1, 0.25, 0.5, 1)
|
|
rgba[:, 0, :3] = 0
|
|
image.pixels.foreach_set(np.flipud(rgba).ravel())
|
|
image.update()
|
|
source = bpy.data.objects.new(name, None)
|
|
source.empty_display_type = "IMAGE"
|
|
source.empty_display_size = 2
|
|
source.empty_image_offset = (-0.5, -0.5)
|
|
source.data = image
|
|
source.matrix_world = Matrix.Translation((0, 0, -depth))
|
|
bpy.context.collection.objects.link(source)
|
|
sources.append(edit_frame._frame_source(
|
|
source, SimpleNamespace(width=6, height=2),
|
|
SimpleNamespace(perspective_matrix=Matrix.Identity(4), is_perspective=False),
|
|
Matrix.Identity(4), active=name == "Foreground",
|
|
))
|
|
front = sources[1]
|
|
source_alpha = front["rgba"][..., 3:4]
|
|
expected = front["rgba"][..., :3] * source_alpha + 1 - source_alpha
|
|
# The image aspect makes its orthographic screen projection two thirds of a pixel high.
|
|
pixels = edit_frame.composite_frame_pixels(
|
|
sources, ((0, 4 / 3), (6, 4 / 3), (6, 2 / 3), (0, 2 / 3)), (6, 2)
|
|
)
|
|
result = image_data.create_image_edit_result(front["object"].data, pixels, (6, 2))
|
|
result = image_data.replace_empty_image(front["object"], result)
|
|
encoded = np.asarray(Image.open(BytesIO(result.packed_file.data)).convert("RGBA")) / 255
|
|
np.testing.assert_allclose(encoded[..., :3], expected, atol=1 / 255)
|
|
np.testing.assert_allclose(encoded[..., 3:4], source_alpha, atol=1 / 255)
|
|
selection = SelectionMask(np.ones((2, 6), dtype=np.float32), (0, 0, 6, 2))
|
|
operator = SimpleNamespace(mode="ADD", report=lambda *_args: None)
|
|
assert edit_mask.EditImageAlpha._apply_mask(
|
|
operator, SimpleNamespace(), front["object"], selection
|
|
) == {"FINISHED"}
|
|
restored = image_data.image_rgba(front["object"].data)
|
|
np.testing.assert_allclose(restored[..., :3], expected, atol=1 / 255)
|
|
np.testing.assert_array_equal(restored[..., 3], 1)
|
|
assert np.all(np.diff(restored[0, :, 0]) <= 0)
|
|
np.testing.assert_array_equal(restored[0, 0, :3], 1)
|
|
|
|
|
|
def test_brush_release_commits_one_packed_image_without_touching_shared_source():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
source_image = bpy.data.images.new("Mask Source", width=8, height=6, alpha=True)
|
|
source_rgba = np.ones((6, 8, 4), dtype=np.float32)
|
|
source_rgba[:, :, :3] = (0.2, 0.4, 0.6)
|
|
source_image.pixels.foreach_set(np.flipud(source_rgba).ravel())
|
|
source_image.update()
|
|
source = bpy.data.objects.new("Mask Source", None)
|
|
source.empty_display_type = "IMAGE"
|
|
source.data = source_image
|
|
source.empty_display_size = 3.0
|
|
source.empty_image_offset = (-0.25, -0.75)
|
|
source.matrix_world = Matrix.Translation((1.0, 2.0, 3.0))
|
|
shared = bpy.data.objects.new("Mask Shared", None)
|
|
shared.empty_display_type = "IMAGE"
|
|
shared.data = source_image
|
|
bpy.context.collection.objects.link(source)
|
|
bpy.context.collection.objects.link(shared)
|
|
matrix = source.matrix_world.copy()
|
|
|
|
selection_mask = SelectionMask(
|
|
np.ones((2, 3), dtype=np.float32),
|
|
(2, 1, 5, 3),
|
|
)
|
|
operator = SimpleNamespace(mode="SUBTRACT", report=lambda *_args: None)
|
|
status = edit_mask.EditImageAlpha._apply_mask(
|
|
operator,
|
|
SimpleNamespace(),
|
|
source,
|
|
selection_mask,
|
|
)
|
|
|
|
result = source.data
|
|
assert status == {"FINISHED"}
|
|
assert source.data == result
|
|
assert result != source_image
|
|
assert result.packed_file is not None
|
|
assert shared.data == source_image
|
|
np.testing.assert_allclose(image_data.image_rgba(source_image), source_rgba)
|
|
result_rgba = image_data.image_rgba(result)
|
|
np.testing.assert_array_equal(result_rgba[1:3, 2:5, 3], 0.0)
|
|
assert source.matrix_world == matrix
|
|
assert source.empty_display_size == 3.0
|
|
assert tuple(source.empty_image_offset) == (-0.25, -0.75)
|
|
|
|
|
|
def test_brush_drag_does_not_create_or_update_an_image():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
source_image = bpy.data.images.new("Mask Cancel", width=4, height=3, alpha=True)
|
|
source = bpy.data.objects.new("Mask Cancel", None)
|
|
source.empty_display_type = "IMAGE"
|
|
source.data = source_image
|
|
bpy.context.collection.objects.link(source)
|
|
image_count = len(bpy.data.images)
|
|
shared = bpy.data.objects.new("Mask Cancel shared", None)
|
|
shared.empty_display_type = "IMAGE"
|
|
shared.data = source_image
|
|
before = np.array(source_image.pixels[:])
|
|
operator = edit_mask.ImageGesture()
|
|
operator.active_tool_id = "anyimage.mask"
|
|
operator.gesture = "BRUSH"
|
|
operator._path = [(10.0, 10.0)]
|
|
operator._path_samples = list(operator._path)
|
|
operator._cursor = (10.0, 10.0)
|
|
context = SimpleNamespace(area=SimpleNamespace(tag_redraw=lambda: None),
|
|
workspace=SimpleNamespace(status_text_set=lambda value: None))
|
|
event = SimpleNamespace(
|
|
type="MOUSEMOVE",
|
|
value="NOTHING",
|
|
mouse_region_x=20,
|
|
mouse_region_y=25,
|
|
)
|
|
|
|
result = edit_mask.ImageGesture.modal(operator, context, event)
|
|
|
|
assert result == {"RUNNING_MODAL"}
|
|
assert source.data == source_image
|
|
assert len(bpy.data.images) == image_count
|
|
event.type, event.value = "ESC", "PRESS"
|
|
assert operator.modal(context, event) == {"CANCELLED"}
|
|
assert source.data == shared.data == source_image
|
|
np.testing.assert_array_equal(source_image.pixels[:], before)
|
|
assert len(bpy.data.images) == image_count
|
|
assert operator._path is operator._path_samples is operator._brush_preview is None
|
|
|
|
|
|
def test_object_normal_image_follows_cutout_inward_axis():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
image = bpy.data.images.new(
|
|
"Object Normal",
|
|
width=1,
|
|
height=1,
|
|
alpha=True,
|
|
float_buffer=True,
|
|
)
|
|
decoded = np.asarray((0.2, -0.4, 0.8), dtype=np.float32)
|
|
encoded = decoded * 0.5 + 0.5
|
|
image.pixels.foreach_set((*encoded, 0.3))
|
|
|
|
cutout_object.orient_object_normal_image(
|
|
image,
|
|
cutout_object.CutoutInwardAxis.NEGATIVE_Z,
|
|
)
|
|
|
|
actual = np.empty(4, dtype=np.float32)
|
|
image.pixels.foreach_get(actual)
|
|
assert np.allclose(actual[:3], encoded, atol=1e-6)
|
|
assert np.isclose(actual[3], 0.3)
|
|
|
|
cutout_object.orient_object_normal_image(
|
|
image,
|
|
cutout_object.CutoutInwardAxis.POSITIVE_X,
|
|
)
|
|
image.pixels.foreach_get(actual)
|
|
expected = np.asarray((-0.8, -0.2, -0.4), dtype=np.float32) * 0.5 + 0.5
|
|
assert np.allclose(actual[:3], expected, atol=1e-6)
|
|
assert np.isclose(actual[3], 0.3)
|
|
|
|
|
|
@pytest.mark.parametrize("shape", ("SURFACE", "BALLOON"))
|
|
def test_cutout_axes_produce_the_same_world_geometry(shape):
|
|
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,
|
|
8,
|
|
inward_axis=cutout_object.CutoutInwardAxis.POSITIVE_X,
|
|
)
|
|
negative_z = _create_cutout_shape(
|
|
source,
|
|
shape,
|
|
8,
|
|
inward_axis=cutout_object.CutoutInwardAxis.NEGATIVE_Z,
|
|
)
|
|
|
|
for result in (positive_x, negative_z):
|
|
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)
|
|
|
|
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 / "vector-depth.exr"
|
|
write_vector_depth_texture(frame, path)
|
|
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], validity)
|
|
assert image.alpha_mode == "CHANNEL_PACKED"
|
|
assert image.name == "Source_depth.exr"
|
|
|
|
|
|
def test_z_depth_texture_keeps_depth_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),
|
|
validity=np.asarray(((1.0, 0.0),), dtype=np.float32),
|
|
intrinsics=np.eye(3, dtype=np.float32),
|
|
)
|
|
path = tmp_path / "z-depth.exr"
|
|
write_z_depth_texture(frame, path)
|
|
|
|
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], np.repeat(frame.depth[..., None], 3, axis=2))
|
|
assert np.array_equal(packed[..., 3], frame.validity)
|
|
|
|
|
|
def test_cutout_modifier_wraps_four_menu_controlled_shape_groups():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
|
|
plane = node_asset.load_node_group("O Mesh Plane")
|
|
depth_plane = node_asset.load_node_group("O Image Depth Plane")
|
|
cutout = cutout_object.load_cutout_node_group()
|
|
assert plane.name == "O Mesh Plane"
|
|
assert cutout.name == cutout_shape.CUTOUT_NODE_GROUP_NAME
|
|
assert all(
|
|
cutout_object.load_cutout_node_group() == cutout
|
|
for shape in cutout_shape.CUTOUT_SHAPES
|
|
)
|
|
assert _inputs(plane) == ["Geometry", "Subdivide", "Thickness"]
|
|
assert _inputs(depth_plane) == [
|
|
"Geometry",
|
|
"Subdivide",
|
|
"Thickness",
|
|
"Depth Scale",
|
|
"Reference Depth",
|
|
"Uniform Scale",
|
|
"Depth Image",
|
|
]
|
|
assert _inputs(cutout) == [
|
|
"Geometry",
|
|
"Shape",
|
|
"Inward Axis",
|
|
"Thickness",
|
|
"Depth Scale",
|
|
"Double Sided",
|
|
"Smooth Iterations",
|
|
"Cleanup",
|
|
"Smooth",
|
|
"Split",
|
|
"Reference Depth",
|
|
"Depth Direction",
|
|
"Uniform Scale",
|
|
"Depth Image",
|
|
]
|
|
panels = [item for item in cutout.interface.items_tree if item.item_type == "PANEL"]
|
|
assert [panel.name for panel in panels] == ["Options", "Data"]
|
|
assert [item.name for item in panels[0].interface_items] == [
|
|
"Cleanup",
|
|
"Smooth",
|
|
"Split",
|
|
"Reference Depth",
|
|
"Depth Direction",
|
|
]
|
|
assert [item.name for item in panels[1].interface_items] == [
|
|
"Uniform Scale",
|
|
"Depth Image",
|
|
]
|
|
assert all(panel.default_closed for panel in panels)
|
|
|
|
shape_socket = next(
|
|
item
|
|
for item in cutout.interface.items_tree
|
|
if item.item_type == "SOCKET" and item.name == "Shape"
|
|
)
|
|
assert shape_socket.socket_type == "NodeSocketMenu"
|
|
assert not shape_socket.menu_expanded
|
|
menu_switch = next(
|
|
node for node in cutout.nodes if node.bl_idname == "GeometryNodeMenuSwitch"
|
|
)
|
|
labels = {
|
|
shape: shape.replace("_", " ").title()
|
|
for shape in cutout_shape.CUTOUT_NODE_GROUP_NAMES
|
|
}
|
|
assert [item.name for item in menu_switch.enum_items] == list(labels.values())
|
|
assert any(
|
|
link.from_socket.name == "Shape"
|
|
and link.to_node == menu_switch
|
|
and link.to_socket.name == "Menu"
|
|
for link in cutout.links
|
|
)
|
|
axis_switch = next(
|
|
node
|
|
for node in cutout.nodes
|
|
if node.bl_idname == "GeometryNodeMenuSwitch"
|
|
and [item.name for item in node.enum_items] == ["-Z", "+X"]
|
|
)
|
|
negative_z_link = next(
|
|
link
|
|
for link in cutout.links
|
|
if link.to_node == axis_switch and link.to_socket.name == "-Z"
|
|
)
|
|
positive_x_link = next(
|
|
link
|
|
for link in cutout.links
|
|
if link.to_node == axis_switch and link.to_socket.name == "+X"
|
|
)
|
|
assert positive_x_link.from_node.bl_idname == "GeometryNodeTransform"
|
|
assert any(
|
|
link.from_socket == negative_z_link.from_socket
|
|
and link.to_node == positive_x_link.from_node
|
|
and link.to_socket.name == "Geometry"
|
|
for link in cutout.links
|
|
)
|
|
|
|
child_groups = {
|
|
shape: bpy.data.node_groups[name]
|
|
for shape, name in cutout_shape.CUTOUT_NODE_GROUP_NAMES.items()
|
|
}
|
|
assert cutout.is_modifier
|
|
assert all(not group.is_modifier for group in child_groups.values())
|
|
child_nodes = {
|
|
node.node_tree: node
|
|
for node in cutout.nodes
|
|
if node.bl_idname == "GeometryNodeGroup"
|
|
}
|
|
assert set(child_nodes) == set(child_groups.values())
|
|
for shape, group in child_groups.items():
|
|
label = labels[shape]
|
|
assert any(
|
|
link.from_node == child_nodes[group]
|
|
and link.to_node == menu_switch
|
|
and link.to_socket.name == label
|
|
for link in cutout.links
|
|
)
|
|
|
|
assert _inputs(child_groups["SURFACE"]) == ["Geometry", "Thickness"]
|
|
assert _inputs(child_groups["BALLOON"]) == ["Geometry", "Thickness"]
|
|
assert _inputs(child_groups["DEPTH_BALLOON"]) == [
|
|
"Geometry",
|
|
"Thickness",
|
|
"Depth Scale",
|
|
"Double Sided",
|
|
"Reference Depth",
|
|
"Depth Direction",
|
|
"Uniform Scale",
|
|
"Depth Image",
|
|
]
|
|
assert _inputs(child_groups["DEPTH_SURFACE"]) == [
|
|
"Geometry",
|
|
"Thickness",
|
|
"Depth Scale",
|
|
"Split",
|
|
"Cleanup",
|
|
"Reference Depth",
|
|
"Uniform Scale",
|
|
"Depth Image",
|
|
]
|
|
|
|
dependencies = {}
|
|
for link in cutout.links:
|
|
if link.from_node.bl_idname != "NodeGroupInput":
|
|
continue
|
|
if link.to_node.bl_idname != "GeometryNodeGroup":
|
|
continue
|
|
shape = next(
|
|
shape
|
|
for shape, group in child_groups.items()
|
|
if group == link.to_node.node_tree
|
|
)
|
|
dependencies.setdefault(link.from_socket.name, set()).add(shape)
|
|
assert dependencies == {
|
|
"Geometry": {"DEPTH_SURFACE"},
|
|
"Thickness": set(cutout_shape.CUTOUT_SHAPES),
|
|
"Depth Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
|
|
"Split": {"DEPTH_SURFACE"},
|
|
"Double Sided": {"DEPTH_BALLOON"},
|
|
"Reference Depth": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
|
|
"Depth Direction": {"DEPTH_BALLOON"},
|
|
"Uniform Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
|
|
"Depth Image": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
|
|
}
|
|
|
|
assert (
|
|
sum(node.bl_idname == "GeometryNodeInputMeshIsland" for node in cutout.nodes)
|
|
== 1
|
|
)
|
|
assert not any(
|
|
node.bl_idname == "GeometryNodeAttributeStatistic" for node in cutout.nodes
|
|
)
|
|
assert all(
|
|
not any(node.bl_idname == "GeometryNodeInputMeshIsland" for node in group.nodes)
|
|
for shape, group in child_groups.items() if shape != "DEPTH_SURFACE"
|
|
)
|
|
assert any(node.bl_idname == "GeometryNodeBlurAttribute" for node in cutout.nodes)
|
|
assert all(
|
|
not any(node.bl_idname == "GeometryNodeBlurAttribute" for node in group.nodes)
|
|
for group in child_groups.values()
|
|
)
|
|
repeat_counts = {
|
|
shape: sum(node.bl_idname == "GeometryNodeRepeatInput" for node in group.nodes)
|
|
for shape, group in child_groups.items()
|
|
}
|
|
assert repeat_counts == {
|
|
"SURFACE": 1,
|
|
"BALLOON": 0,
|
|
"DEPTH_BALLOON": 0,
|
|
"DEPTH_SURFACE": 1,
|
|
}
|
|
assert (
|
|
sum(node.bl_idname == "GeometryNodeRepeatInput" for node in cutout.nodes) == 1
|
|
)
|
|
|
|
smooth = next(
|
|
item
|
|
for item in cutout.interface.items_tree
|
|
if item.item_type == "SOCKET" and item.name == "Smooth"
|
|
)
|
|
iterations = next(
|
|
item
|
|
for item in cutout.interface.items_tree
|
|
if item.item_type == "SOCKET" and item.name == "Smooth Iterations"
|
|
)
|
|
cleanup = next(
|
|
item
|
|
for item in cutout.interface.items_tree
|
|
if item.item_type == "SOCKET" and item.name == "Cleanup"
|
|
)
|
|
assert cleanup.default_value == 0.0
|
|
assert cleanup.subtype == "NONE"
|
|
split_control = next(
|
|
item
|
|
for item in cutout.interface.items_tree
|
|
if item.item_type == "SOCKET" and item.name == "Split"
|
|
)
|
|
child_split = next(
|
|
item
|
|
for item in child_groups["DEPTH_SURFACE"].interface.items_tree
|
|
if item.item_type == "SOCKET" and item.name == "Split"
|
|
)
|
|
assert split_control.default_value == 0.0
|
|
assert split_control.min_value == 0.0
|
|
assert split_control.max_value == 1.0
|
|
assert split_control.subtype == "FACTOR"
|
|
assert getattr(split_control.parent, "name", "") == "Options"
|
|
assert child_split.default_value == 0.0
|
|
assert child_split.min_value == 0.0
|
|
assert child_split.max_value == 1.0
|
|
assert child_split.subtype == "FACTOR"
|
|
assert getattr(child_split.parent, "name", "") == "Options"
|
|
depth_surface_nodes = child_groups["DEPTH_SURFACE"].nodes
|
|
assert any(node.bl_idname == "GeometryNodeSplitEdges" for node in depth_surface_nodes)
|
|
from validate_cutout import validate_depth_surface_attributes
|
|
validate_depth_surface_attributes(child_groups["DEPTH_SURFACE"])
|
|
assert [node.domain for node in depth_surface_nodes
|
|
if node.bl_idname == "GeometryNodeDeleteGeometry"] == ["FACE"]
|
|
assert "Stretch Limit" not in [item.name for item in child_groups["DEPTH_SURFACE"].interface.items_tree]
|
|
cleanup_scale = next(
|
|
link.to_node
|
|
for link in cutout.links
|
|
if link.from_socket.name == "Cleanup"
|
|
and link.from_node.bl_idname == "NodeGroupInput"
|
|
)
|
|
assert cleanup_scale.bl_idname == "ShaderNodeMath"
|
|
assert cleanup_scale.operation == "MULTIPLY"
|
|
assert np.isclose(cleanup_scale.inputs[1].default_value, 0.01)
|
|
assert smooth.default_value == 1.0
|
|
assert smooth.subtype == "FACTOR"
|
|
assert iterations.default_value == 0
|
|
assert iterations.min_value == 0
|
|
assert getattr(iterations.parent, "name", "") == ""
|
|
thickness = next(
|
|
item
|
|
for item in cutout.interface.items_tree
|
|
if item.item_type == "SOCKET" and item.name == "Thickness"
|
|
)
|
|
assert thickness.subtype == "NONE"
|
|
assert thickness.default_value == 0.0
|
|
|
|
for group in child_groups.values():
|
|
extrudes = [
|
|
node for node in group.nodes if node.bl_idname == "GeometryNodeExtrudeMesh"
|
|
]
|
|
assert extrudes
|
|
assert all(not node.inputs["Individual"].default_value for node in extrudes)
|
|
assert all(
|
|
node.inputs["Offset Scale"].default_value == 1.0 for node in extrudes
|
|
)
|
|
for group in (
|
|
child_groups["SURFACE"],
|
|
child_groups["DEPTH_SURFACE"],
|
|
depth_plane,
|
|
):
|
|
thickness = next(
|
|
item
|
|
for item in group.interface.items_tree
|
|
if item.item_type == "SOCKET" and item.name == "Thickness"
|
|
)
|
|
assert thickness.subtype == "DISTANCE"
|
|
depth_subdivide = next(
|
|
item
|
|
for item in depth_plane.interface.items_tree
|
|
if item.item_type == "SOCKET" and item.name == "Subdivide"
|
|
)
|
|
assert depth_subdivide.default_value == 6
|
|
for group in (
|
|
child_groups["BALLOON"],
|
|
child_groups["DEPTH_BALLOON"],
|
|
):
|
|
thickness = next(
|
|
item
|
|
for item in group.interface.items_tree
|
|
if item.item_type == "SOCKET" and item.name == "Thickness"
|
|
)
|
|
assert thickness.subtype == "NONE"
|
|
assert thickness.default_value == 1.0
|
|
|
|
|
|
def test_image_layer_switches_between_tangent_and_object_space_normal():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
|
|
assert material_data.IMAGE_MATERIAL_NODE_GROUP_NAME == "O Image Layer"
|
|
group = material_data.material_node_group()
|
|
normal = next(
|
|
item
|
|
for item in group.interface.items_tree
|
|
if item.item_type == "SOCKET"
|
|
and item.in_out == "INPUT"
|
|
and item.name == "Normal"
|
|
)
|
|
assert tuple(normal.default_value) == (0.5, 0.5, 1.0, 1.0)
|
|
bump_scale = next(
|
|
item
|
|
for item in group.interface.items_tree
|
|
if item.item_type == "SOCKET"
|
|
and item.in_out == "INPUT"
|
|
and item.name == "Bump Scale"
|
|
)
|
|
assert bump_scale.default_value == 0.0
|
|
normal_scale = next(
|
|
item
|
|
for item in group.interface.items_tree
|
|
if item.item_type == "SOCKET"
|
|
and item.in_out == "INPUT"
|
|
and item.name == "Normal Scale"
|
|
)
|
|
assert normal_scale.default_value == 1.0
|
|
object_space = next(
|
|
item
|
|
for item in group.interface.items_tree
|
|
if item.item_type == "SOCKET"
|
|
and item.in_out == "INPUT"
|
|
and item.name == "Object Space"
|
|
)
|
|
assert object_space.default_value is False
|
|
normal_maps = {
|
|
node.space for node in group.nodes if node.bl_idname == "ShaderNodeNormalMap"
|
|
}
|
|
assert normal_maps == {"TANGENT", "OBJECT"}
|
|
assert all(
|
|
not node.label
|
|
for node in group.nodes
|
|
if node.bl_idname in {"ShaderNodeNormalMap", "ShaderNodeMix"}
|
|
)
|
|
|
|
color = bpy.data.images.new("Color", width=2, height=2)
|
|
normal_image = bpy.data.images.new("Normal", width=2, height=2)
|
|
object_material = material_data.create_image_material(
|
|
SimpleNamespace(data=SimpleNamespace(name="Object Normal")),
|
|
color,
|
|
1.0,
|
|
normal_image=normal_image,
|
|
normal_space="OBJECT",
|
|
)
|
|
layer = _group_node(group, object_material.node_tree)
|
|
assert layer.inputs["Object Space"].default_value is True
|
|
|
|
tangent_material = material_data.create_image_material(
|
|
SimpleNamespace(data=SimpleNamespace(name="Tangent Normal")),
|
|
color,
|
|
1.0,
|
|
normal_image=normal_image,
|
|
)
|
|
layer = _group_node(group, tangent_material.node_tree)
|
|
assert layer.inputs["Object Space"].default_value is False
|
|
reused = material_data.material_node_group()
|
|
assert {
|
|
node.space for node in reused.nodes if node.bl_idname == "ShaderNodeNormalMap"
|
|
} == {"TANGENT", "OBJECT"}
|
|
|
|
|
|
def test_build_plane_keeps_one_quad_and_generates_topology_in_geometry_nodes():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
image = bpy.data.images.new("Source", width=4096, height=2048)
|
|
source = bpy.data.objects.new("Source", None)
|
|
source.empty_display_type = "IMAGE"
|
|
source.data = image
|
|
source.empty_display_size = 2.0
|
|
bpy.context.collection.objects.link(source)
|
|
material = bpy.data.materials.new("Source.material")
|
|
|
|
result = plane_object.create_plane_object(
|
|
bpy.context,
|
|
source,
|
|
2,
|
|
material,
|
|
)
|
|
|
|
assert result["anyimage_mesh_shape"] == "PLANE"
|
|
assert len(result.data.vertices) == 4
|
|
assert len(result.data.polygons) == 1
|
|
assert all(polygon.use_smooth for polygon in result.data.polygons)
|
|
assert cutout_shape.BALLOON_ATTRIBUTE_NAME not in result.data.attributes
|
|
assert result.modifiers[0].node_group.name == "O Mesh Plane"
|
|
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
|
evaluated_mesh = evaluated.to_mesh()
|
|
try:
|
|
assert len(evaluated_mesh.polygons) > 0
|
|
assert result.data.materials[0] == material
|
|
assert evaluated_mesh.materials[0].original == material
|
|
assert all(polygon.material_index == 0 for polygon in evaluated_mesh.polygons)
|
|
finally:
|
|
evaluated.to_mesh_clear()
|
|
|
|
|
|
def test_clipboard_plane_uses_the_mesh_material_slot():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
image = bpy.data.images.new("Clipboard Plane", width=128, height=64, alpha=True)
|
|
|
|
with patch.object(clipboard_actions, "_place_in_view"):
|
|
result = clipboard_actions.add_image_plane(bpy.context, image)
|
|
|
|
assert _inputs(result.modifiers[0].node_group) == [
|
|
"Geometry",
|
|
"Subdivide",
|
|
"Thickness",
|
|
]
|
|
assert len(result.data.materials) == 1
|
|
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
|
evaluated_mesh = evaluated.to_mesh()
|
|
try:
|
|
assert evaluated_mesh.materials[0].original == result.data.materials[0]
|
|
assert all(polygon.material_index == 0 for polygon in evaluated_mesh.polygons)
|
|
finally:
|
|
evaluated.to_mesh_clear()
|
|
|
|
|
|
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,
|
|
"BALLOON",
|
|
8,
|
|
)
|
|
|
|
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.BALLOON_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_NAME
|
|
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,
|
|
"SURFACE",
|
|
8,
|
|
)
|
|
|
|
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 not any(
|
|
node.bl_idname == "ShaderNodeAttribute"
|
|
for node in result.data.materials[0].node_tree.nodes
|
|
)
|
|
|
|
|
|
def test_cutout_material_uses_ior_1_2():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
image = bpy.data.images.new("Cutout", width=16, height=16, alpha=True)
|
|
source = bpy.data.objects.new("Cutout", 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, "SURFACE", 8)
|
|
|
|
shader = next(
|
|
node
|
|
for node in result.data.materials[0].node_tree.nodes
|
|
if node.bl_idname == "ShaderNodeBsdfPrincipled"
|
|
)
|
|
assert np.isclose(shader.inputs["IOR"].default_value, 1.2)
|
|
image_layer = _group_node(
|
|
material_data.material_node_group(),
|
|
result.data.materials[0].node_tree,
|
|
)
|
|
ior_level_link = next(
|
|
link
|
|
for link in result.data.materials[0].node_tree.links
|
|
if link.to_node == shader
|
|
and link.to_socket == shader.inputs["Specular IOR Level"]
|
|
)
|
|
assert ior_level_link.from_node == image_layer
|
|
assert ior_level_link.from_socket == image_layer.outputs["Color"]
|
|
|
|
|
|
def test_plane_material_color_drives_ior_level_but_shadeless_does_not():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
image = bpy.data.images.new("Plane", width=2, height=2, alpha=True)
|
|
source = SimpleNamespace(data=SimpleNamespace(name="Plane"))
|
|
|
|
material = material_data.create_image_material(source, image, 0.0)
|
|
shader = next(
|
|
node
|
|
for node in material.node_tree.nodes
|
|
if node.bl_idname == "ShaderNodeBsdfPrincipled"
|
|
)
|
|
image_layer = _group_node(material_data.material_node_group(), material.node_tree)
|
|
assert any(
|
|
link.from_node == image_layer
|
|
and link.from_socket == image_layer.outputs["Color"]
|
|
and link.to_node == shader
|
|
and link.to_socket == shader.inputs["Specular IOR Level"]
|
|
for link in material.node_tree.links
|
|
)
|
|
|
|
shadeless = material_data.create_image_material(
|
|
source,
|
|
image,
|
|
0.0,
|
|
shadeless=True,
|
|
)
|
|
assert not any(
|
|
node.bl_idname == "ShaderNodeBsdfPrincipled"
|
|
for node in shadeless.node_tree.nodes
|
|
)
|
|
|
|
def test_cutout_material_uses_flat_tangent_normal():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
source = bpy.data.objects.new("Source", None)
|
|
source.empty_display_type = "IMAGE"
|
|
source.data = bpy.data.images.new("Source", width=2, height=2)
|
|
color = bpy.data.images.new("Color", width=2, height=2)
|
|
normal = bpy.data.images.new("Normal", width=1, height=1)
|
|
|
|
result = material_data.create_image_material(
|
|
source,
|
|
color,
|
|
0.0,
|
|
normal_image=normal,
|
|
scene=bpy.context.scene,
|
|
)
|
|
|
|
layer = _group_node(material_data.material_node_group(), result.node_tree)
|
|
assert layer.inputs["Object Space"].default_value is False
|
|
|
|
|
|
def test_depth_surface_protected_smooth_evaluates():
|
|
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_SURFACE",
|
|
8,
|
|
depth_image=depth_image,
|
|
depth_metadata=depth_metadata,
|
|
)
|
|
|
|
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_NAME
|
|
assert (
|
|
modifier[object_data.modifier_input_identifier(modifier.node_group, "Shape")]
|
|
== cutout_shape.CUTOUT_SHAPE_INDICES["DEPTH_SURFACE"]
|
|
)
|
|
expected_cleanup = cutout_geometry.cutout_cleanup_value(
|
|
cutout_geometry.cutout_sampling_spacing(source, 8),
|
|
cutout_geometry.world_plane_metric(source.matrix_world)[0],
|
|
)
|
|
assert np.isclose(
|
|
modifier[object_data.modifier_input_identifier(modifier.node_group, "Cleanup")],
|
|
expected_cleanup,
|
|
)
|
|
assert (
|
|
modifier[object_data.modifier_input_identifier(modifier.node_group, "Smooth")]
|
|
== 1.0
|
|
)
|
|
assert (
|
|
modifier[
|
|
object_data.modifier_input_identifier(modifier.node_group, "Smooth Iterations")
|
|
]
|
|
== 0
|
|
)
|
|
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"),
|
|
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"),
|
|
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 Iterations"),
|
|
4,
|
|
)
|
|
object_data.set_modifier_input(
|
|
modifier,
|
|
object_data.modifier_input_identifier(modifier.node_group, "Smooth"),
|
|
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_SURFACE",
|
|
8,
|
|
depth_image=_depth_image(points, validity),
|
|
depth_metadata=_depth_metadata(intrinsics, (size, size), 1.0),
|
|
)
|
|
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 _evaluated_positions(obj):
|
|
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
|
result = evaluated.to_mesh()
|
|
try:
|
|
return np.asarray([tuple(vertex.co) for vertex in result.vertices])
|
|
finally:
|
|
evaluated.to_mesh_clear()
|
|
|
|
|
|
def test_converted_planes_center_the_origin_without_moving_world_corners():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
|
|
def create_source(name):
|
|
image = bpy.data.images.new(name, width=200, height=100)
|
|
source = bpy.data.objects.new(name, None)
|
|
source.empty_display_type = "IMAGE"
|
|
source.data = image
|
|
source.empty_display_size = 2.0
|
|
source.empty_image_offset = (0.25, -0.2)
|
|
source.matrix_world = (
|
|
Matrix.Translation((3.0, -2.0, 5.0))
|
|
@ Matrix.Rotation(0.4, 4, "Z")
|
|
@ Matrix.Diagonal((1.5, 0.75, 2.0, 1.0))
|
|
)
|
|
bpy.context.collection.objects.link(source)
|
|
return source
|
|
|
|
def assert_centered(result, bounds, source_matrix):
|
|
x_min, x_max, y_min, y_max = bounds
|
|
center = Vector(
|
|
((x_min + x_max) * 0.5, (y_min + y_max) * 0.5, 0.0)
|
|
)
|
|
assert np.allclose(
|
|
tuple(result.matrix_world.translation),
|
|
tuple(source_matrix @ center),
|
|
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)
|
|
expected = sorted(
|
|
tuple(source_matrix @ Vector((x, y, 0.0)))
|
|
for x in (x_min, x_max)
|
|
for y in (y_min, y_max)
|
|
)
|
|
actual = sorted(
|
|
tuple(result.matrix_world @ vertex.co)
|
|
for vertex in result.data.vertices
|
|
)
|
|
assert np.allclose(actual, expected, atol=1e-6)
|
|
|
|
plane_source = create_source("Centered Plane")
|
|
plane_bounds = image_data.image_empty_bounds(plane_source)
|
|
plane_matrix = plane_source.matrix_world.copy()
|
|
plane_material = bpy.data.materials.new("Centered Plane Material")
|
|
plane = plane_object.create_plane_object(
|
|
bpy.context,
|
|
plane_source,
|
|
0,
|
|
plane_material,
|
|
)
|
|
assert_centered(plane, plane_bounds, plane_matrix)
|
|
assert plane["anyimage_mesh_shape"] == "PLANE"
|
|
|
|
depth_source = create_source("Centered Depth Plane")
|
|
depth_bounds = image_data.image_empty_bounds(depth_source)
|
|
depth_matrix = depth_source.matrix_world.copy()
|
|
depth_values = np.ones((2, 2, 3), dtype=np.float32)
|
|
depth_image = _depth_image(depth_values, np.ones((2, 2), dtype=bool))
|
|
depth_material = bpy.data.materials.new("Centered Depth Material")
|
|
depth_plane = plane_object.create_depth_plane_object(
|
|
bpy.context,
|
|
depth_source,
|
|
0,
|
|
depth_material,
|
|
depth_image,
|
|
_depth_metadata(
|
|
((0.5, 0.0, 0.5), (0.0, 0.5, 0.5), (0.0, 0.0, 1.0)),
|
|
(2, 2),
|
|
1.0,
|
|
),
|
|
)
|
|
assert_centered(depth_plane, depth_bounds, depth_matrix)
|
|
assert depth_plane["anyimage_mesh_shape"] == "DEPTH_PLANE"
|
|
depth_modifier = depth_plane.modifiers[0]
|
|
uniform_scale = depth_modifier[
|
|
object_data.modifier_input_identifier(
|
|
depth_modifier.node_group,
|
|
"Uniform Scale",
|
|
)
|
|
]
|
|
assert np.isclose(
|
|
depth_modifier[
|
|
object_data.modifier_input_identifier(
|
|
depth_modifier.node_group,
|
|
"Reference Depth",
|
|
)
|
|
],
|
|
uniform_scale,
|
|
)
|
|
|
|
|
|
def test_depth_plane_builds_a_fixed_base_terrain_block():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
image = bpy.data.images.new("Terrain", width=4, height=4)
|
|
source = bpy.data.objects.new("Terrain", None)
|
|
source.empty_display_type = "IMAGE"
|
|
source.data = image
|
|
source.empty_display_size = 2.0
|
|
bpy.context.collection.objects.link(source)
|
|
material = bpy.data.materials.new("Terrain Material")
|
|
depth_image = _depth_image(
|
|
np.ones((4, 4, 3), dtype=np.float32),
|
|
np.ones((4, 4), dtype=bool),
|
|
)
|
|
result = plane_object.create_depth_plane_object(
|
|
bpy.context,
|
|
source,
|
|
2,
|
|
material,
|
|
depth_image,
|
|
_depth_metadata(
|
|
((1.5, 0.0, 1.5), (0.0, 1.5, 1.5), (0.0, 0.0, 1.0)),
|
|
(4, 4),
|
|
1.0,
|
|
),
|
|
)
|
|
modifier = result.modifiers[0]
|
|
group = modifier.node_group
|
|
|
|
def set_input(name, value):
|
|
object_data.set_modifier_input(
|
|
modifier,
|
|
object_data.modifier_input_identifier(group, name),
|
|
value,
|
|
)
|
|
result.update_tag(refresh={"DATA"})
|
|
bpy.context.view_layer.update()
|
|
|
|
set_input("Depth Scale", 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)
|
|
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)
|
|
}
|
|
|
|
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
|
evaluated_mesh = evaluated.to_mesh()
|
|
try:
|
|
assert result.data.materials[0] == material
|
|
assert evaluated_mesh.materials[0].original == material
|
|
assert all(polygon.material_index == 0 for polygon in evaluated_mesh.polygons)
|
|
assert "UVMap" in evaluated_mesh.uv_layers
|
|
finally:
|
|
evaluated.to_mesh_clear()
|
|
|
|
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)
|
|
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)
|
|
}
|
|
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)
|
|
|
|
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.05, 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 limited[:, 2].max() > 0.0
|
|
assert limited[:, 2].max() < 0.001
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
|
|
def _add_cutout_modifier(obj, shape):
|
|
group = cutout_object.load_cutout_node_group()
|
|
modifier = obj.modifiers.new(group.name, "NODES")
|
|
modifier.node_group = group
|
|
object_data.set_modifier_input(
|
|
modifier,
|
|
object_data.modifier_input_identifier(group, "Shape"),
|
|
cutout_shape.CUTOUT_SHAPE_INDICES[shape],
|
|
)
|
|
object_data.set_modifier_input(
|
|
modifier,
|
|
object_data.modifier_input_identifier(group, "Inward Axis"),
|
|
0,
|
|
)
|
|
object_data.set_modifier_input(
|
|
modifier,
|
|
object_data.modifier_input_identifier(group, "Cleanup"),
|
|
0.0,
|
|
)
|
|
object_data.set_modifier_input(
|
|
modifier,
|
|
object_data.modifier_input_identifier(group, "Smooth"),
|
|
0.0,
|
|
)
|
|
object_data.set_modifier_input(
|
|
modifier,
|
|
object_data.modifier_input_identifier(group, "Smooth Iterations"),
|
|
1,
|
|
)
|
|
return modifier
|
|
|
|
|
|
def _depth_surface_step_object():
|
|
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("Depth Surface Step")
|
|
data.from_pydata(vertices, (), faces)
|
|
uv_layer = data.uv_layers.new(name="UVMap")
|
|
for loop in data.loops:
|
|
coordinate = data.vertices[loop.vertex_index].co
|
|
uv_layer.data[loop.index].uv = (coordinate.x * 0.5, coordinate.y * 0.5)
|
|
obj = bpy.data.objects.new("Depth Surface Step", data)
|
|
bpy.context.collection.objects.link(obj)
|
|
modifier = _add_cutout_modifier(obj, "DEPTH_SURFACE")
|
|
|
|
points = np.empty((3, 3, 3), dtype=np.float32)
|
|
for y in range(3):
|
|
for x in range(3):
|
|
points[y, x] = (float(x), -float(y), 1.0)
|
|
points[0, 0, 2] = 6.0
|
|
depth_image = _depth_image(points, np.ones((3, 3), dtype=bool))
|
|
for name, value in (
|
|
("Depth Image", depth_image),
|
|
("Depth Scale", 1.0),
|
|
("Uniform Scale", 1.0),
|
|
("Reference Depth", 1.0),
|
|
("Thickness", 0.0),
|
|
("Smooth Iterations", 0),
|
|
):
|
|
object_data.set_modifier_input(
|
|
modifier,
|
|
object_data.modifier_input_identifier(modifier.node_group, name),
|
|
value,
|
|
)
|
|
return obj, modifier
|
|
|
|
|
|
def _evaluated_topology(obj):
|
|
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
|
result = evaluated.to_mesh()
|
|
try:
|
|
face_edges = {
|
|
tuple(sorted((start, end)))
|
|
for polygon in result.polygons
|
|
for start, end in zip(
|
|
polygon.vertices,
|
|
(*polygon.vertices[1:], polygon.vertices[0]),
|
|
)
|
|
}
|
|
mesh_edges = {
|
|
tuple(sorted(edge.vertices))
|
|
for edge in result.edges
|
|
}
|
|
face_vertices = {
|
|
vertex
|
|
for polygon in result.polygons
|
|
for vertex in polygon.vertices
|
|
}
|
|
return (
|
|
len(result.vertices),
|
|
len(result.polygons),
|
|
mesh_edges - face_edges,
|
|
set(range(len(result.vertices))) - face_vertices,
|
|
)
|
|
finally:
|
|
evaluated.to_mesh_clear()
|
|
|
|
|
|
def test_depth_surface_split_preserves_original_faces():
|
|
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, "Split")
|
|
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 >= 9 and faces == 8
|
|
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, "SURFACE")
|
|
|
|
object_data.set_modifier_input(
|
|
modifier,
|
|
object_data.modifier_input_identifier(modifier.node_group, "Cleanup"),
|
|
0.0,
|
|
)
|
|
object_data.set_modifier_input(
|
|
modifier,
|
|
object_data.modifier_input_identifier(modifier.node_group, "Thickness"),
|
|
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(), -0.5, atol=1e-6)
|
|
assert np.isclose(z.max(), 0.5, 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"),
|
|
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, "BALLOON")
|
|
|
|
object_data.set_modifier_input(
|
|
modifier,
|
|
object_data.modifier_input_identifier(modifier.node_group, "Cleanup"),
|
|
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")
|
|
data.from_pydata(vertices, (), faces)
|
|
obj = bpy.data.objects.new("Cleanup", data)
|
|
bpy.context.collection.objects.link(obj)
|
|
modifier = _add_cutout_modifier(obj, "SURFACE")
|
|
|
|
cleanup_id = object_data.modifier_input_identifier(modifier.node_group, "Cleanup")
|
|
|
|
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_without_controller():
|
|
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)
|
|
z_depth = np.repeat(points[..., 2, None], 3, axis=2)
|
|
depth_image = _depth_image(z_depth, 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",
|
|
8,
|
|
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 "o_balloon_depth" not in result.data.attributes
|
|
assert "o_depth_offset" not in result.data.attributes
|
|
assert not result.children
|
|
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",
|
|
)
|
|
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_loaded_normal_image_uses_normal_texture_name(tmp_path):
|
|
from PIL import Image
|
|
|
|
path = tmp_path / "normal.png"
|
|
Image.new("RGB", (1, 1), (128, 128, 255)).save(path)
|
|
source = SimpleNamespace(data=SimpleNamespace(name="Poster.png"))
|
|
|
|
image = image_data.load_normal_result_image(path, source)
|
|
|
|
assert image.name == "Poster_normal.png"
|
|
assert image.colorspace_settings.name == "Non-Color"
|
|
assert image.packed_file is not None
|
|
|
|
|
|
def test_converted_color_images_are_new_and_use_blender_duplicate_suffix():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
image = bpy.data.images.new("Poster", width=16, height=16)
|
|
source = bpy.data.objects.new("Poster", None)
|
|
source.empty_display_type = "IMAGE"
|
|
source.data = image
|
|
bpy.context.collection.objects.link(source)
|
|
|
|
first = plane_object.create_color_image(source)
|
|
second = plane_object.create_color_image(source)
|
|
|
|
assert first is not image
|
|
assert second is not image
|
|
assert first is not second
|
|
assert first.name == "Poster_color.png"
|
|
assert second.name == "Poster_color.png.001"
|
|
|
|
|
|
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 = image_data.prepare_image_bounds_input(image, rgba, (1, 1, 5, 4))
|
|
result = None
|
|
try:
|
|
result = image_data.load_packed_rgba_image(
|
|
path,
|
|
image,
|
|
"Bounds Result",
|
|
)
|
|
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
|
|
|
|
image_data.cleanup_image_input(path, True)
|
|
|
|
assert not path.exists()
|
|
assert result.packed_file is not None
|
|
finally:
|
|
image_data.cleanup_image_input(path, True)
|
|
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 = cutout_object.create_cropped_color_image(
|
|
source,
|
|
selection_mask.bounds,
|
|
rgba,
|
|
)
|
|
cutout_main.create_cutout_shape(
|
|
bpy.context,
|
|
source,
|
|
"SURFACE",
|
|
16,
|
|
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"] == "SURFACE"
|
|
modifier = result.modifiers[0]
|
|
assert modifier.node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAME
|
|
assert (
|
|
modifier[object_data.modifier_input_identifier(modifier.node_group, "Shape")]
|
|
== cutout_shape.CUTOUT_SHAPE_INDICES["SURFACE"]
|
|
)
|
|
expected_cleanup = cutout_geometry.cutout_cleanup_value(
|
|
cutout_geometry.cutout_sampling_spacing(source, 16),
|
|
cutout_geometry.world_plane_metric(source.matrix_world)[0],
|
|
)
|
|
assert np.isclose(
|
|
modifier[object_data.modifier_input_identifier(modifier.node_group, "Cleanup")],
|
|
expected_cleanup,
|
|
)
|
|
assert (
|
|
modifier[object_data.modifier_input_identifier(modifier.node_group, "Smooth")]
|
|
== 1.0
|
|
)
|
|
assert (
|
|
modifier[
|
|
object_data.modifier_input_identifier(modifier.node_group, "Smooth Iterations")
|
|
]
|
|
== 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])
|
|
)
|
|
|
|
|
|
def test_balloon_normal_scale_uses_existing_image_layer_input():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
assert cutout_shape.BALLOON_CUTOUT_SHAPES == frozenset(
|
|
{"BALLOON", "DEPTH_BALLOON"}
|
|
)
|
|
group = bpy.data.node_groups.new(
|
|
material_data.IMAGE_MATERIAL_NODE_GROUP_NAME,
|
|
"ShaderNodeTree",
|
|
)
|
|
group.interface.new_socket(
|
|
name="Normal Scale",
|
|
in_out="INPUT",
|
|
socket_type="NodeSocketFloat",
|
|
)
|
|
image_material = bpy.data.materials.new("Balloon")
|
|
image_material.use_nodes = True
|
|
image_material.node_tree.nodes.clear()
|
|
image_layer = image_material.node_tree.nodes.new("ShaderNodeGroup")
|
|
image_layer.node_tree = group
|
|
|
|
cutout_object._connect_balloon_normal_scale(image_material)
|
|
|
|
assert _inputs(group) == ["Normal Scale"]
|
|
attribute = next(
|
|
node
|
|
for node in image_material.node_tree.nodes
|
|
if node != image_layer
|
|
)
|
|
assert attribute.bl_idname == "ShaderNodeAttribute"
|
|
assert attribute.attribute_name == cutout_shape.BALLOON_ATTRIBUTE_NAME
|
|
link = next(iter(image_material.node_tree.links))
|
|
assert link.from_socket == attribute.outputs["Fac"]
|
|
assert link.to_socket == image_layer.inputs["Normal Scale"]
|