Add cutout profile and teddy balloon height calculations

- Implemented cotangent Poisson field and Poisson balloon profile in cutout_profile.py.
- Added teddy balloon heights calculation and related functions in cutout_teddy.py.
- Introduced interaction utilities for image editing in interaction.py.
- Developed depth normalization and analysis functions in cutout_depth.py.
- Created a validation script for cutout assets in validate_cutout_asset.py.
This commit is contained in:
2026-08-27 22:46:57 +08:00
parent 6c8358f92f
commit 7d39fe8c5e
40 changed files with 3058 additions and 3896 deletions

View File

@@ -5,7 +5,7 @@ from unittest.mock import patch
import bpy
import numpy as np
from mathutils import Matrix
from mathutils import Matrix, Vector
SOURCE_ROOT = Path(__file__).parents[1] / "src"
sys.path.insert(0, str(SOURCE_ROOT))
@@ -66,12 +66,31 @@ def test_shape_node_groups_expose_only_shape_parameters():
plane = mesh._mesh_node_group(mesh.PLANE)
depth_plane = mesh._mesh_node_group(mesh.DEPTH_PLANE)
legacy_surface = bpy.data.node_groups.new(
"O Image Cutout",
"GeometryNodeTree",
)
cutout = mesh._mesh_node_group(mesh.CUTOUT)
balloon = mesh._mesh_node_group(mesh.BALLOON)
depth_balloon = mesh._mesh_node_group(mesh.DEPTH_BALLOON)
depth_surface = mesh._mesh_node_group(mesh.DEPTH_SURFACE)
assert plane.name == "O Mesh Plane"
assert cutout.name == "O Image Surface"
assert cutout == legacy_surface
assert _inputs(plane) == ["Geometry", "Subdivide", "Thickness", "Material"]
assert _inputs(cutout) == ["Geometry", "Thickness"]
assert _inputs(cutout) == ["Geometry", "Thickness", "Cleanup"]
assert _inputs(balloon) == ["Geometry", "Thickness", "Cleanup"]
assert _inputs(depth_balloon) == [
"Geometry",
"Thickness",
"Depth Scale",
"Double Sided",
"Depth Reference",
"Depth Image",
"Cleanup",
"Base Plane",
]
assert _inputs(depth_plane) == [
"Geometry",
"Subdivide",
@@ -85,9 +104,46 @@ def test_shape_node_groups_expose_only_shape_parameters():
"Thickness",
"Depth Scale",
"Iterations",
"Smooth",
"Depth Image",
"Cleanup",
"Depth Continuity",
"Smooth",
]
assert len({group.as_pointer() for group in (
cutout,
balloon,
depth_balloon,
depth_surface,
)}) == 4
expected_options = {
cutout: ["Cleanup"],
balloon: ["Cleanup"],
depth_balloon: ["Cleanup", "Base Plane"],
depth_surface: ["Cleanup", "Depth Continuity", "Smooth"],
}
for group, expected in expected_options.items():
panels = [
item
for item in group.interface.items_tree
if item.item_type == "PANEL"
]
assert [panel.name for panel in panels] == ["Options"]
assert panels[0].default_closed
assert [item.name for item in panels[0].interface_items] == expected
for group in (cutout, balloon, depth_balloon, depth_surface):
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 (depth_plane, cutout, depth_surface):
thickness = next(
item
@@ -95,6 +151,14 @@ def test_shape_node_groups_expose_only_shape_parameters():
if item.item_type == "SOCKET" and item.name == "Thickness"
)
assert thickness.subtype == "DISTANCE"
for group in (balloon, 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
for group, name in (
(depth_plane, "Depth Amount"),
(depth_surface, "Depth Scale"),
@@ -145,6 +209,18 @@ def test_shape_node_groups_expose_only_shape_parameters():
assert any(
node.bl_idname == "GeometryNodeRepeatOutput" for node in depth_surface.nodes
)
repeat_counts = {
group: sum(
node.bl_idname == "GeometryNodeRepeatInput" for node in group.nodes
)
for group in (cutout, balloon, depth_balloon, depth_surface)
}
assert repeat_counts == {
cutout: 1,
balloon: 0,
depth_balloon: 0,
depth_surface: 2,
}
assert any(
node.bl_idname == "GeometryNodeBlurAttribute" for node in depth_surface.nodes
)
@@ -155,7 +231,7 @@ def test_shape_node_groups_expose_only_shape_parameters():
assert sum(
node.bl_idname == "GeometryNodeInputMeshEdgeNeighbors"
for node in depth_surface.nodes
) == 2
) == 3
assert sum(
node.bl_idname == "GeometryNodeInputMeshVertexNeighbors"
for node in depth_surface.nodes
@@ -191,12 +267,51 @@ def test_shape_node_groups_expose_only_shape_parameters():
and node.inputs[0].default_value == 1.0
for node in depth_surface.nodes
)
assert any(
node.bl_idname == "GeometryNodeInputNamedAttribute"
and node.inputs["Name"].default_value == mesh.DEPTH_DISCONTINUITY_ATTRIBUTE
for node in depth_surface.nodes
depth_reference = next(
item
for item in depth_balloon.interface.items_tree
if item.item_type == "SOCKET"
and item.in_out == "INPUT"
and item.name == "Depth Reference"
)
for group in (depth_plane, cutout, depth_surface):
assert depth_reference.hide_in_modifier
assert depth_reference.subtype == "DISTANCE"
for group in (depth_balloon, depth_surface):
depth_image = next(
item
for item in group.interface.items_tree
if item.item_type == "SOCKET"
and item.in_out == "INPUT"
and item.name == "Depth Image"
)
assert depth_image.hide_in_modifier
assert any(
node.bl_idname == "GeometryNodeImageTexture" for node in group.nodes
)
for group in (cutout, balloon, depth_balloon, depth_surface):
assert not any(
node.bl_idname == "GeometryNodeInputShortestEdgePaths"
for node in group.nodes
)
assert not any(
node.bl_idname == "GeometryNodeInputNamedAttribute"
and node.inputs["Name"].default_value == "o_boundary"
for node in group.nodes
)
for group in (balloon, depth_balloon):
profile = next(
node
for node in group.nodes
if node.bl_idname == "GeometryNodeInputNamedAttribute"
and node.inputs["Name"].default_value == mesh.BALLOON_ATTRIBUTE
)
assert profile.data_type == "FLOAT"
assert any(
node.bl_idname == "ShaderNodeMapRange"
and node.interpolation_type == "SMOOTHERSTEP"
for node in group.nodes
)
for group in (depth_plane, cutout, balloon, depth_balloon, depth_surface):
input_sockets = [
item
for item in group.interface.items_tree
@@ -211,21 +326,21 @@ def test_shape_node_groups_expose_only_shape_parameters():
for output in group_input.outputs
if output.name
)
geometry_links = [
link
for link in group.links
if link.from_socket.type == "GEOMETRY"
and link.to_socket.type == "GEOMETRY"
]
assert all(
link.from_node.location.x <= link.to_node.location.x
for link in geometry_links
for link in group.links
)
assert all(not node.label for node in group.nodes)
node_locations = {tuple(node.location) for node in depth_surface.nodes}
assert len(node_locations) == len(depth_surface.nodes)
depth_plane_locations = {tuple(node.location) for node in depth_plane.nodes}
assert len(depth_plane_locations) == len(depth_plane.nodes)
node_locations = {tuple(node.location) for node in group.nodes}
assert len(node_locations) == len(group.nodes)
if group in (cutout, balloon, depth_balloon, depth_surface):
group_nodes = list(group.nodes)
for index, node in enumerate(group_nodes):
for other in group_nodes[index + 1:]:
assert not (
abs(node.location.x - other.location.x) < 150.0
and abs(node.location.y - other.location.y) < 100.0
)
assert any(
node.bl_idname == "GeometryNodeSetShadeSmooth"
and node.inputs["Shade Smooth"].default_value
@@ -344,9 +459,7 @@ def test_build_plane_keeps_one_quad_and_generates_topology_in_geometry_nodes():
assert len(result.data.vertices) == 4
assert len(result.data.polygons) == 1
assert all(polygon.use_smooth for polygon in result.data.polygons)
assert mesh.PROFILE_ATTRIBUTE not in result.data.attributes
assert mesh.DEPTH_ATTRIBUTE not in result.data.attributes
assert mesh.FRONT_FACE_ATTRIBUTE not in result.data.attributes
assert mesh.BALLOON_ATTRIBUTE 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()
@@ -355,6 +468,115 @@ def test_build_plane_keeps_one_quad_and_generates_topology_in_geometry_nodes():
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_mesh_object(
bpy.context,
source,
mesh.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 mesh.BALLOON_ATTRIBUTE in result.data.attributes
profile_attribute = result.data.attributes[mesh.BALLOON_ATTRIBUTE]
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 == "O Image Balloon"
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_mesh_object(
bpy.context,
source,
mesh.CUTOUT,
8,
teddy_material,
regions=((((0, 0), (64, 0), (64, 64), (0, 64)),),),
image_size=(64, 64),
keep_original=True,
)
attribute = result.data.attributes[mesh.BALLOON_ATTRIBUTE]
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_mesh_depth_images_use_standard_data_name():
bpy.ops.wm.read_factory_settings(use_empty=True)
cutout_source = bpy.data.objects.new("Cutout Source", None)
cutout_source.data = bpy.data.images.new(
"Cutout Source.png", width=2, height=2, alpha=True
)
cutout_depth = mesh._cutout_depth_image(
cutout_source,
np.ones((2, 2), dtype=np.float32),
)
assert cutout_depth.name == "Cutout Source_depth.exr"
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 = mesh._depth_field_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)
@@ -396,8 +618,8 @@ def test_depth_surface_protected_smooth_evaluates():
keep_original=True,
)
assert mesh.BOUNDARY_ATTRIBUTE in result.data.attributes
assert mesh.DEPTH_PROTECTION_ATTRIBUTE in result.data.attributes
assert all(abs(vertex.co.x) < 1e-8 for vertex in result.data.vertices)
assert mesh.BALLOON_ATTRIBUTE in result.data.attributes
modifier = result.modifiers[0]
mesh.set_modifier_input(
modifier,
@@ -407,9 +629,9 @@ def test_depth_surface_protected_smooth_evaluates():
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
baseline_mesh = evaluated.to_mesh()
try:
baseline_z = np.asarray([vertex.co.z for vertex in baseline_mesh.vertices])
baseline_min = baseline_z.min()
baseline_max = baseline_z.max()
baseline_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()
@@ -424,9 +646,9 @@ def test_depth_surface_protected_smooth_evaluates():
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.max(), baseline_max, atol=1e-5)
assert np.isclose(z.min(), baseline_min - 1.0, atol=1e-5)
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()
@@ -497,6 +719,361 @@ def test_depth_surface_protected_smooth_evaluates():
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 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 = obj.modifiers.new("O Image Surface", "NODES")
modifier.node_group = mesh._mesh_node_group(mesh.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,
mesh.BALLOON_ATTRIBUTE,
(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 = obj.modifiers.new("O Image Balloon", "NODES")
modifier.node_group = mesh._mesh_node_group(mesh.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_removes_only_small_mesh_island():
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)
]
vertices.extend(
((0.0, 5.0, 5.0), (0.0, 5.01, 5.0), (0.0, 5.0, 5.01))
)
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)))
faces.append((9, 10, 11))
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 = obj.modifiers.new("O Image Surface", "NODES")
modifier.node_group = mesh._mesh_node_group(mesh.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)) == 12
mesh.set_modifier_input(modifier, cleanup_id, 1.0)
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
assert len(_evaluated_positions(obj)) == 9
def test_depth_balloon_base_plane_uses_crop_center_and_matching_arrow_direction():
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),
)
depth_data = {
"depth_signal": np.ones((61, 41), dtype=np.float32),
"baseline_vector": np.zeros(3, dtype=np.float32),
"valid": np.ones((61, 41), dtype=bool),
"origin": (50, 20),
"reference_depth": 1.0,
"crop_size": (40, 60),
"source_size": (100, 100),
}
result = mesh._build_mesh_object(
bpy.context,
source,
mesh.DEPTH_BALLOON,
8,
balloon_material,
depth_data=depth_data,
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 mesh.BALLOON_ATTRIBUTE in result.data.attributes
assert "o_balloon_depth" not in result.data.attributes
assert "o_depth_offset" not in result.data.attributes
modifier = result.modifiers[0]
base_plane_id = mesh.modifier_input_identifier(
modifier.node_group,
"Base Plane",
)
depth_image_id = mesh.modifier_input_identifier(
modifier.node_group,
"Depth Image",
)
base_plane = modifier[base_plane_id]
assert isinstance(modifier[depth_image_id], bpy.types.Image)
expected_center = np.zeros(2, dtype=np.float64)
assert np.allclose(base_plane.location[1:], expected_center, atol=1e-5)
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,
)
arrow = base_plane.rotation_euler.to_matrix() @ Vector((0.0, 0.0, 1.0))
assert np.allclose(tuple(arrow), (-1.0, 0.0, 0.0), atol=1e-5)
depth_reference_id = mesh.modifier_input_identifier(
modifier.node_group,
"Depth Reference",
)
assert modifier[depth_reference_id] > 0.0
thickness_id = mesh.modifier_input_identifier(
modifier.node_group,
"Thickness",
)
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)
base_plane.location += arrow * 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)
base_plane.location += arrow * 0.15
base_plane.rotation_euler.rotate_axis("Y", np.radians(12.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
@@ -558,9 +1135,15 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job():
assert len(result.data.vertices) > 4
assert len(result.data.polygons) > 1
assert all(polygon.use_smooth for polygon in result.data.polygons)
vertex_x = [vertex.co.x for vertex in result.data.vertices]
assert min(vertex_x) < source_bounds[0]
assert max(vertex_x) > source_bounds[1]
assert all(abs(vertex.co.x) < 1e-8 for vertex in result.data.vertices)
assert mesh.BALLOON_ATTRIBUTE 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(