- Implemented `build_mesh_plane.py` to create a geometry node group for mesh planes with customizable parameters. - Created `build_shadeless.py` for a shader node group that displays color without scene lighting while preserving shadows and reflections. - Added `validate.py` to validate the integrity of the newly created node groups and their inputs. - Introduced `validate_cutout.py` to ensure the functionality of cutout node groups and their dependencies. - Developed `validate_mesh_plane.py` to test the mesh plane creation and its modifier settings.
1250 lines
42 KiB
Python
1250 lines
42 KiB
Python
import sys
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
import bpy
|
|
import numpy as np
|
|
from mathutils import Matrix, Vector
|
|
|
|
SOURCE_ROOT = Path(__file__).parents[1] / "src"
|
|
sys.path.insert(0, str(SOURCE_ROOT))
|
|
|
|
from anyimage.operators.common import material, node_graph
|
|
from anyimage.operators.cutout_tool import build_shapes as mesh
|
|
from anyimage.operators.cutout_tool import node_assets as cutout_nodes
|
|
from anyimage.operators.cutout_tool import shape_types as shapes
|
|
from anyimage.operators.convert_to_plane import nodes as plane_nodes
|
|
from anyimage.operators.convert_to_plane import build as plane_build
|
|
from anyimage.operators.common.normal_map import bake_tangent_normal_image
|
|
|
|
|
|
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 test_depth_surface_image_packs_metric_points_and_edge_validity():
|
|
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))
|
|
depth = np.asarray(((1.0, 1.0), (1.0, 2.0)), dtype=np.float32)
|
|
valid = np.asarray(((True, False), (True, True)))
|
|
|
|
image = mesh._surface_depth_image(source, points, valid, depth)
|
|
|
|
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 packed[0, 1, 3] == -1.0
|
|
assert np.all(packed[valid, 3] >= 0.0)
|
|
assert image.alpha_mode == "CHANNEL_PACKED"
|
|
assert image.name == "Source_depth.exr"
|
|
|
|
|
|
def test_tangent_bake_changes_pixels_using_evaluated_mesh_tbn():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
source = bpy.data.objects.new("Source", None)
|
|
bpy.context.collection.objects.link(source)
|
|
target_mesh = bpy.data.meshes.new("Target")
|
|
target_mesh.from_pydata(
|
|
((0.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)),
|
|
(),
|
|
((0, 1, 2),),
|
|
)
|
|
target_mesh.polygons[0].use_smooth = True
|
|
mesh._assign_uv(
|
|
target_mesh,
|
|
np.asarray(((0.0, 0.0), (1.0, 0.0), (0.0, 1.0))),
|
|
)
|
|
target = bpy.data.objects.new("Target", target_mesh)
|
|
bpy.context.collection.objects.link(target)
|
|
target.matrix_world = Matrix.Rotation(np.radians(-90.0), 4, "Y")
|
|
image = bpy.data.images.new("Object Normal", width=3, height=3, alpha=True)
|
|
pixels = np.ones((3, 3, 4), dtype=np.float32)
|
|
pixels[..., :3] = (1.0, 0.5, 0.5)
|
|
image.pixels.foreach_set(pixels.ravel())
|
|
|
|
bake_tangent_normal_image(bpy.context, source, target, image)
|
|
|
|
baked = np.empty(3 * 3 * 4, dtype=np.float32)
|
|
image.pixels.foreach_get(baked)
|
|
baked = baked.reshape((3, 3, 4))
|
|
assert not np.allclose(baked[0, 0, :3], pixels[0, 0, :3])
|
|
|
|
|
|
def test_cutout_modifier_wraps_four_menu_controlled_shape_groups():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
|
|
plane = plane_nodes.plane_node_group("PLANE")
|
|
depth_plane = plane_nodes.plane_node_group("DEPTH_PLANE")
|
|
cutout = cutout_nodes.cutout_node_group("CUTOUT")
|
|
assert plane.name == "O Mesh Plane"
|
|
assert cutout.name == shapes.CUTOUT_NODE_GROUP_NAME
|
|
assert all(
|
|
cutout_nodes.cutout_node_group(shape) == cutout
|
|
for shape in shapes.CUTOUT_SHAPES
|
|
)
|
|
assert _inputs(plane) == ["Geometry", "Subdivide", "Thickness", "Material"]
|
|
assert _inputs(depth_plane) == [
|
|
"Geometry",
|
|
"Subdivide",
|
|
"Thickness",
|
|
"Depth Amount",
|
|
"Depth Image",
|
|
"Material",
|
|
]
|
|
assert _inputs(cutout) == [
|
|
"Geometry",
|
|
"Shape",
|
|
"Thickness",
|
|
"Depth Scale",
|
|
"Double Sided",
|
|
"Smooth Iterations",
|
|
"Cleanup",
|
|
"Smooth",
|
|
"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",
|
|
"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"
|
|
)
|
|
assert [item.name for item in menu_switch.enum_items] == list(
|
|
shapes.CUTOUT_SHAPE_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
|
|
)
|
|
|
|
child_groups = {
|
|
shape: bpy.data.node_groups[name]
|
|
for shape, name in shapes.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 = shapes.CUTOUT_SHAPE_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["CUTOUT"]) == ["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",
|
|
"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 == {
|
|
"Thickness": set(shapes.CUTOUT_SHAPES),
|
|
"Depth Scale": {"DEPTH_BALLOON", "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 group in child_groups.values()
|
|
)
|
|
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 == {
|
|
"CUTOUT": 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"
|
|
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["CUTOUT"],
|
|
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"
|
|
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.IMAGE_MATERIAL_NODE_GROUP_NAME == "O Image Layer"
|
|
group = material.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.create_material(
|
|
"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.create_material(
|
|
"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.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_build.build_mesh_plane(
|
|
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 shapes.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
|
|
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)
|
|
balloon_material = bpy.data.materials.new("Balloon Sheet.material")
|
|
|
|
result = mesh.build_cutout_object(
|
|
bpy.context,
|
|
source,
|
|
"BALLOON",
|
|
8,
|
|
balloon_material,
|
|
regions=((((0, 0), (64, 0), (64, 64), (0, 64)),),),
|
|
image_size=(64, 64),
|
|
keep_original=True,
|
|
)
|
|
|
|
assert all(abs(vertex.co.x) < 1e-8 for vertex in result.data.vertices)
|
|
assert len(result.data.vertices) > 4
|
|
assert len(result.data.polygons) > 1
|
|
assert shapes.BALLOON_ATTRIBUTE_NAME in result.data.attributes
|
|
profile_attribute = result.data.attributes[shapes.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
|
|
assert result.modifiers[0].node_group.name == shapes.CUTOUT_NODE_GROUP_NAME
|
|
inward = result.matrix_world.to_3x3() @ Vector((1.0, 0.0, 0.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)
|
|
teddy_material = bpy.data.materials.new("Teddy Sheet.material")
|
|
|
|
with patch.object(
|
|
mesh,
|
|
"addon_preferences",
|
|
return_value=SimpleNamespace(balloon_profile_algorithm="TEDDY"),
|
|
):
|
|
result = mesh.build_cutout_object(
|
|
bpy.context,
|
|
source,
|
|
"CUTOUT",
|
|
8,
|
|
teddy_material,
|
|
regions=((((0, 0), (64, 0), (64, 64), (0, 64)),),),
|
|
image_size=(64, 64),
|
|
keep_original=True,
|
|
)
|
|
|
|
attribute = result.data.attributes[shapes.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
|
|
|
|
|
|
def test_depth_surface_object_normal_is_remapped_to_cutout_axes():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
image = bpy.data.images.new(
|
|
"Surface Normal",
|
|
width=1,
|
|
height=1,
|
|
alpha=True,
|
|
float_buffer=True,
|
|
)
|
|
image.pixels.foreach_set((0.5, 0.5, 1.0, 1.0))
|
|
|
|
mesh._orient_cutout_object_normal(image)
|
|
|
|
pixels = np.empty(4, dtype=np.float32)
|
|
image.pixels.foreach_get(pixels)
|
|
assert np.allclose(pixels, (0.0, 0.5, 0.5, 1.0), atol=1e-6)
|
|
|
|
|
|
def test_internal_depth_plane_image_uses_standard_data_name():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
plane_source = bpy.data.objects.new("Plane Source", None)
|
|
plane_source.data = bpy.data.images.new(
|
|
"Plane Source.png", width=2, height=2, alpha=True
|
|
)
|
|
anchored = np.zeros((2, 2, 3), dtype=np.float32)
|
|
plane_depth = plane_build.create_depth_image(plane_source, anchored)
|
|
assert plane_depth.name == "Plane Source_depth.exr"
|
|
|
|
|
|
|
|
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)
|
|
surface_material = bpy.data.materials.new("Surface.material")
|
|
|
|
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
|
|
field = (
|
|
points,
|
|
intrinsics,
|
|
np.ones((size, size), dtype=bool),
|
|
np.ones((size, size), dtype=bool),
|
|
(size, size),
|
|
1.0,
|
|
)
|
|
|
|
result = mesh.build_cutout_object(
|
|
bpy.context,
|
|
source,
|
|
"DEPTH_SURFACE",
|
|
8,
|
|
surface_material,
|
|
field=field,
|
|
regions=((((0, 0), (64, 0), (64, 64), (0, 64)),),),
|
|
image_size=(64, 64),
|
|
keep_original=True,
|
|
)
|
|
|
|
assert all(abs(vertex.co.x) < 1e-8 for vertex in result.data.vertices)
|
|
assert shapes.BALLOON_ATTRIBUTE_NAME in result.data.attributes
|
|
modifier = result.modifiers[0]
|
|
assert modifier.node_group.name == shapes.CUTOUT_NODE_GROUP_NAME
|
|
assert modifier[
|
|
mesh.modifier_input_identifier(modifier.node_group, "Shape")
|
|
] == shapes.CUTOUT_SHAPE_INDICES["DEPTH_SURFACE"]
|
|
expected_cleanup = mesh._cutout_cleanup_area(
|
|
mesh._cutout_point_spacing(source, 8),
|
|
mesh._world_plane_metric(source.matrix_world)[0],
|
|
)
|
|
assert np.isclose(
|
|
modifier[mesh.modifier_input_identifier(modifier.node_group, "Cleanup")],
|
|
expected_cleanup,
|
|
)
|
|
assert modifier[
|
|
mesh.modifier_input_identifier(modifier.node_group, "Smooth")
|
|
] == 1.0
|
|
assert modifier[
|
|
mesh.modifier_input_identifier(
|
|
modifier.node_group, "Smooth Iterations"
|
|
)
|
|
] == 0
|
|
reference_id = mesh.modifier_input_identifier(
|
|
modifier.node_group,
|
|
"Reference Depth",
|
|
)
|
|
assert modifier[reference_id] == 8.0
|
|
mesh.set_modifier_input(
|
|
modifier,
|
|
mesh.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_x = np.asarray([vertex.co.x for vertex in baseline_mesh.vertices])
|
|
baseline_min = baseline_x.min()
|
|
baseline_max = baseline_x.max()
|
|
finally:
|
|
evaluated.to_mesh_clear()
|
|
|
|
mesh.set_modifier_input(
|
|
modifier,
|
|
mesh.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
|
|
x = np.asarray([vertex.co.x for vertex in evaluated_mesh.vertices])
|
|
assert np.isclose(x.min(), baseline_min, atol=1e-5)
|
|
assert np.isclose(x.max(), baseline_max + 1.0, atol=1e-5)
|
|
finally:
|
|
evaluated.to_mesh_clear()
|
|
|
|
mesh.set_modifier_input(
|
|
modifier,
|
|
mesh.modifier_input_identifier(
|
|
modifier.node_group, "Smooth Iterations"
|
|
),
|
|
4,
|
|
)
|
|
mesh.set_modifier_input(
|
|
modifier,
|
|
mesh.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()
|
|
|
|
mesh.set_modifier_input(
|
|
modifier,
|
|
mesh.modifier_input_identifier(
|
|
modifier.node_group, "Smooth Iterations"
|
|
),
|
|
0,
|
|
)
|
|
external_group = bpy.data.node_groups.new("External O Smooth", "GeometryNodeTree")
|
|
external_group.is_modifier = True
|
|
node_graph.interface_socket(
|
|
external_group, "Geometry", "OUTPUT", "NodeSocketGeometry"
|
|
)
|
|
node_graph.interface_socket(
|
|
external_group, "Geometry", "INPUT", "NodeSocketGeometry"
|
|
)
|
|
external_iterations = node_graph.interface_socket(
|
|
external_group, "Iterations", "INPUT", "NodeSocketInt"
|
|
)
|
|
external_iterations.default_value = 4
|
|
external_iterations.min_value = 0
|
|
external_smooth = node_graph.interface_socket(
|
|
external_group, "Smooth", "INPUT", "NodeSocketFloat"
|
|
)
|
|
external_smooth.default_value = 1.0
|
|
external_input = external_group.nodes.new("NodeGroupInput")
|
|
external_output = external_group.nodes.new("NodeGroupOutput")
|
|
external_geometry = node_graph.expand_smooth(
|
|
external_group, external_input.outputs["Geometry"]
|
|
)
|
|
external_group.links.new(
|
|
external_geometry, external_output.inputs["Geometry"]
|
|
)
|
|
external_modifier = result.modifiers.new("External O Smooth", "NODES")
|
|
external_modifier.node_group = external_group
|
|
result.update_tag(refresh={"DATA"})
|
|
bpy.context.view_layer.update()
|
|
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
|
external_mesh = evaluated.to_mesh()
|
|
try:
|
|
external_positions = np.asarray(
|
|
[tuple(vertex.co) for vertex in external_mesh.vertices]
|
|
)
|
|
finally:
|
|
evaluated.to_mesh_clear()
|
|
assert internal_positions.shape == external_positions.shape
|
|
assert np.allclose(internal_positions, external_positions, atol=1e-6)
|
|
|
|
|
|
|
|
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 _add_cutout_modifier(obj, shape):
|
|
group = cutout_nodes.cutout_node_group(shape)
|
|
modifier = obj.modifiers.new(group.name, "NODES")
|
|
modifier.node_group = group
|
|
mesh.set_modifier_input(
|
|
modifier,
|
|
mesh.modifier_input_identifier(group, "Shape"),
|
|
shapes.CUTOUT_SHAPE_INDICES[shape],
|
|
)
|
|
mesh.set_modifier_input(
|
|
modifier,
|
|
mesh.modifier_input_identifier(group, "Cleanup"),
|
|
0.0,
|
|
)
|
|
mesh.set_modifier_input(
|
|
modifier,
|
|
mesh.modifier_input_identifier(group, "Smooth"),
|
|
0.0,
|
|
)
|
|
mesh.set_modifier_input(
|
|
modifier,
|
|
mesh.modifier_input_identifier(group, "Smooth Iterations"),
|
|
1,
|
|
)
|
|
return modifier
|
|
|
|
def test_cutout_thickness_keeps_one_connected_shell():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
vertices = [
|
|
(0.0, float(y), float(z))
|
|
for z in range(3)
|
|
for y in range(3)
|
|
]
|
|
faces = []
|
|
for z in range(2):
|
|
for y in range(2):
|
|
a = z * 3 + y
|
|
faces.extend(((a, a + 4, a + 1), (a, a + 3, a + 4)))
|
|
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, "CUTOUT")
|
|
|
|
mesh.set_modifier_input(
|
|
modifier,
|
|
mesh.modifier_input_identifier(modifier.node_group, "Cleanup"),
|
|
0.0,
|
|
)
|
|
mesh.set_modifier_input(
|
|
modifier,
|
|
mesh.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
|
|
x = np.asarray([vertex.co.x for vertex in result.vertices])
|
|
assert np.isclose(x.min(), -0.5, atol=1e-6)
|
|
assert np.isclose(x.max(), 0.5, atol=1e-6)
|
|
front_faces = [
|
|
polygon for polygon in result.polygons if polygon.normal.x < -0.9
|
|
]
|
|
back_faces = [
|
|
polygon for polygon in result.polygons if polygon.normal.x > 0.9
|
|
]
|
|
side_faces = [
|
|
polygon for polygon in result.polygons if abs(polygon.normal.x) < 0.1
|
|
]
|
|
assert len(front_faces) == 8
|
|
assert len(back_faces) == 8
|
|
assert len(side_faces) == 8
|
|
center = Vector((0.0, 1.0, 1.0))
|
|
for polygon in side_faces:
|
|
radial = polygon.center - center
|
|
assert polygon.normal.dot(radial) > 0.0
|
|
finally:
|
|
evaluated.to_mesh_clear()
|
|
|
|
mesh.set_modifier_input(
|
|
modifier,
|
|
mesh.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.x) < 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 = [
|
|
(0.0, float(y), float(z))
|
|
for z in range(3)
|
|
for y in range(3)
|
|
]
|
|
faces = []
|
|
for z in range(2):
|
|
for y in range(2):
|
|
a = z * 3 + y
|
|
faces.extend(((a, a + 1, a + 4), (a, a + 4, a + 3)))
|
|
data = bpy.data.meshes.new("Balloon Profile")
|
|
data.from_pydata(vertices, (), faces)
|
|
mesh._assign_float_point_attribute(
|
|
data,
|
|
shapes.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")
|
|
|
|
mesh.set_modifier_input(
|
|
modifier,
|
|
mesh.modifier_input_identifier(modifier.node_group, "Cleanup"),
|
|
0.0,
|
|
)
|
|
mesh.set_modifier_input(
|
|
modifier,
|
|
mesh.modifier_input_identifier(modifier.node_group, "Thickness"),
|
|
1.0,
|
|
)
|
|
|
|
bpy.context.view_layer.update()
|
|
positions = _evaluated_positions(obj)
|
|
assert np.isclose(positions[:, 0].min(), -1.0, atol=1e-6)
|
|
assert np.isclose(positions[:, 0].max(), 1.0, atol=1e-6)
|
|
assert np.isclose(
|
|
positions[:, 0].min(),
|
|
-positions[:, 0].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()
|
|
mesh.set_modifier_input(
|
|
modifier,
|
|
mesh.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
|
|
x = np.asarray([vertex.co.x for vertex in result.vertices])
|
|
assert np.isclose(x.min(), -2.0, atol=1e-6)
|
|
assert np.isclose(x.max(), 2.0, atol=1e-6)
|
|
finally:
|
|
evaluated.to_mesh_clear()
|
|
mesh.set_modifier_input(
|
|
modifier,
|
|
mesh.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[:, 0], 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 = [
|
|
(0.0, float(y), float(z))
|
|
for z in range(3)
|
|
for y in range(3)
|
|
]
|
|
faces = []
|
|
for z in range(2):
|
|
for y in range(2):
|
|
a = z * 3 + y
|
|
faces.extend(((a, a + 1, a + 4), (a, a + 4, a + 3)))
|
|
for island in range(12):
|
|
start = len(vertices)
|
|
y = 5.0 + island * 0.1
|
|
vertices.extend(
|
|
((0.0, y, 5.0), (0.0, y + 0.01, 5.0), (0.0, y, 5.01))
|
|
)
|
|
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, "CUTOUT")
|
|
|
|
cleanup_id = mesh.modifier_input_identifier(modifier.node_group, "Cleanup")
|
|
|
|
mesh.set_modifier_input(modifier, cleanup_id, 0.0)
|
|
bpy.context.view_layer.update()
|
|
assert len(_evaluated_positions(obj)) == 45
|
|
|
|
mesh.set_modifier_input(modifier, cleanup_id, 0.001)
|
|
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)
|
|
balloon_material = bpy.data.materials.new("Depth Balloon.material")
|
|
crop_bounds = (50, 20, 90, 80)
|
|
crop = ((0, 0), (40, 0), (40, 60), (0, 60))
|
|
local_bounds = mesh._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,
|
|
)
|
|
field = (
|
|
points,
|
|
intrinsics,
|
|
np.ones((61, 41), dtype=bool),
|
|
np.ones((61, 41), dtype=bool),
|
|
(41, 61),
|
|
2.0,
|
|
)
|
|
|
|
result = mesh.build_cutout_object(
|
|
bpy.context,
|
|
source,
|
|
"DEPTH_BALLOON",
|
|
8,
|
|
balloon_material,
|
|
field=field,
|
|
regions=((crop,),),
|
|
image_size=(40, 60),
|
|
bounds=local_bounds,
|
|
keep_original=True,
|
|
)
|
|
|
|
assert all(abs(vertex.co.x) < 1e-8 for vertex in result.data.vertices)
|
|
assert shapes.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 = mesh.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 = mesh.modifier_input_identifier(
|
|
modifier.node_group,
|
|
"Reference Depth",
|
|
)
|
|
assert modifier[depth_reference_id] == 8.0
|
|
depth_direction_id = mesh.modifier_input_identifier(
|
|
modifier.node_group,
|
|
"Depth Direction",
|
|
)
|
|
direction = np.asarray(modifier[depth_direction_id], dtype=np.float64)
|
|
assert np.array_equal(direction, (0.0, 0.0, 1.0))
|
|
uniform_scale_id = mesh.modifier_input_identifier(
|
|
modifier.node_group,
|
|
"Uniform Scale",
|
|
)
|
|
assert modifier[uniform_scale_id] > 0.0
|
|
thickness_id = mesh.modifier_input_identifier(
|
|
modifier.node_group,
|
|
"Thickness",
|
|
)
|
|
smooth_id = mesh.modifier_input_identifier(
|
|
modifier.node_group,
|
|
"Smooth",
|
|
)
|
|
assert modifier[smooth_id] == 1.0
|
|
mesh.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[:, 1:] - expected_center, axis=1)
|
|
nearest_center = center_distance <= center_distance.min() + 1e-6
|
|
depth_at_thickness_one = positions[nearest_center, 0].min()
|
|
mesh.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[:, 1:] - expected_center, axis=1)
|
|
nearest_center = center_distance <= center_distance.min() + 1e-6
|
|
depth_at_thickness_two = positions[nearest_center, 0].min()
|
|
assert np.isclose(
|
|
depth_at_thickness_one,
|
|
depth_at_thickness_two,
|
|
atol=1e-5,
|
|
)
|
|
mesh.set_modifier_input(modifier, thickness_id, 1.0)
|
|
result.update_tag(refresh={"DATA"})
|
|
bpy.context.view_layer.update()
|
|
|
|
before_positions = _evaluated_positions(result)
|
|
mesh.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[:, 0] - before_positions[:, 0]).min(),
|
|
-0.2,
|
|
atol=1e-5,
|
|
)
|
|
|
|
depth_scale_id = mesh.modifier_input_identifier(
|
|
modifier.node_group,
|
|
"Depth Scale",
|
|
)
|
|
mesh.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)
|
|
mesh.set_modifier_input(
|
|
modifier,
|
|
depth_reference_id,
|
|
modifier[depth_reference_id] + 0.15,
|
|
)
|
|
mesh.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)
|
|
mesh.set_modifier_input(modifier, depth_scale_id, 1.0)
|
|
|
|
double_sided_id = mesh.modifier_input_identifier(
|
|
modifier.node_group,
|
|
"Double Sided",
|
|
)
|
|
mesh.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(y), 6), round(float(z), 6)), []).append(x)
|
|
assert layers
|
|
for x_values in layers.values():
|
|
if len(x_values) == 1:
|
|
assert np.isclose(x_values[0], 0.0, atol=1e-5)
|
|
continue
|
|
assert np.isclose(
|
|
min(x_values) + max(x_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 = mesh._load_normal_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_build.copy_color_image(source)
|
|
second = plane_build.copy_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_local_cutout_selection_matches_uint8_tenth_alpha_boundary():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
image = bpy.data.images.new("Alpha Cutout", width=4, height=4, alpha=True)
|
|
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
|
|
image.pixels.foreach_set(np.flipud(top_down).ravel())
|
|
source = bpy.data.objects.new("Alpha Cutout", None)
|
|
source.empty_display_type = "IMAGE"
|
|
source.data = image
|
|
|
|
_rgba, selection = mesh._local_cutout_rgba(
|
|
source,
|
|
((0, 0), (4, 0), (4, 4), (0, 4)),
|
|
)
|
|
|
|
assert selection[1, 1] == 0.0
|
|
assert selection[1, 2] > 0.1
|
|
|
|
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)
|
|
pixels = np.ones((48, 64, 4), dtype=np.float32)
|
|
image.pixels.foreach_set(pixels.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 = "[[-8,8],[72,8],[72,40],[-8,40]]"
|
|
source_bounds = mesh.plane_bounds(source)
|
|
|
|
with patch.object(mesh, "push_undo") as undo:
|
|
mesh.build_local_cutout(
|
|
SimpleNamespace(mesh_detail=16),
|
|
bpy.context,
|
|
source,
|
|
"CUTOUT",
|
|
selection,
|
|
)
|
|
|
|
result = bpy.context.view_layer.objects.active
|
|
assert result is not source
|
|
assert result.type == "MESH"
|
|
assert result["anyimage_mesh_shape"] == "CUTOUT"
|
|
modifier = result.modifiers[0]
|
|
assert modifier.node_group.name == shapes.CUTOUT_NODE_GROUP_NAME
|
|
assert modifier[
|
|
mesh.modifier_input_identifier(modifier.node_group, "Shape")
|
|
] == shapes.CUTOUT_SHAPE_INDICES["CUTOUT"]
|
|
expected_cleanup = mesh._cutout_cleanup_area(
|
|
mesh._cutout_point_spacing(source, 16),
|
|
mesh._world_plane_metric(source.matrix_world)[0],
|
|
)
|
|
assert np.isclose(
|
|
modifier[mesh.modifier_input_identifier(modifier.node_group, "Cleanup")],
|
|
expected_cleanup,
|
|
)
|
|
assert modifier[
|
|
mesh.modifier_input_identifier(modifier.node_group, "Smooth")
|
|
] == 1.0
|
|
assert modifier[
|
|
mesh.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.x) < 1e-8 for vertex in result.data.vertices)
|
|
assert shapes.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, 34)
|
|
copied = np.empty(34 * 64 * 4, dtype=np.float32)
|
|
color_texture.image.pixels.foreach_get(copied)
|
|
copied = copied.reshape((34, 64, 4))
|
|
assert np.allclose(copied, 1.0)
|
|
undo.assert_called_once_with("Convert to Cutout")
|