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

@@ -1,135 +0,0 @@
from pathlib import Path
import sys
import bpy
PROJECT_ROOT = Path(__file__).parents[1]
sys.path.insert(0, str(PROJECT_ROOT / "src"))
sys.path.insert(
0,
str(next((PROJECT_ROOT / "src" / "anyimage" / "wheels").glob("blendjob-*.whl"))),
)
from anyimage.operators.balloon.depth import (
BALLOON_DEPTH_ATTRIBUTE,
add_balloon_shape_modifier,
balloon_shape_node_group,
)
from anyimage.operators.balloon.operator import (
mark_balloon_material,
merge_balloon_normal_image,
reusable_balloon_material,
)
from anyimage.operators.material import (
BALLOON_MATERIAL_NODE_GROUP_NAME,
material_node_group,
set_modifier_input,
)
def input_socket(group, name):
return next(
item
for item in group.interface.items_tree
if item.item_type == "SOCKET"
and item.in_out == "INPUT"
and item.name == name
)
def main():
bpy.ops.wm.read_factory_settings(use_empty=True)
group = balloon_shape_node_group()
assert group.is_modifier
assert input_socket(group, "Double Sided Depth").default_value is False
smoother = next(
node
for node in group.nodes
if node.bl_idname == "ShaderNodeMapRange"
and node.interpolation_type == "SMOOTHERSTEP"
)
assert smoother.clamp
assert not any(
node.bl_idname == "ShaderNodeMath"
and node.operation == "MAXIMUM"
and node.label == "Clamp Depth"
for node in group.nodes
)
mesh = bpy.data.meshes.new("Balloon Validation Mesh")
mesh.from_pydata(
(
(-1.0, 0.0, 0.0),
(-0.5, 0.0, 0.0),
(0.0, 0.0, 0.0),
(1.0, 0.0, 0.0),
),
(),
(),
)
depth = mesh.attributes.new(BALLOON_DEPTH_ATTRIBUTE, "FLOAT", "POINT")
depth.data.foreach_set("value", (0.0, 2.0, 2.0, 2.0))
obj = bpy.data.objects.new("Balloon Validation", mesh)
bpy.context.scene.collection.objects.link(obj)
modifier = add_balloon_shape_modifier(obj, 1.0)
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
result = evaluated.to_mesh()
try:
x = [vertex.co.x for vertex in result.vertices]
assert abs(x[0] + 1.0) < 1e-5
assert abs(x[1] + 1.25) < 1e-5
assert abs(x[2]) < 1e-5
assert abs(x[3] - 1.0) < 1e-5
finally:
evaluated.to_mesh_clear()
set_modifier_input(modifier, input_socket(group, "Thickness").identifier, 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:
x = [vertex.co.x for vertex in result.vertices]
assert abs(x[0] + 1.0) < 1e-5
assert abs(x[3] - 2.0) < 1e-5
finally:
evaluated.to_mesh_clear()
material_group = material_node_group(
surface_group_name=BALLOON_MATERIAL_NODE_GROUP_NAME,
)
normal_maps = {
node.space
for node in material_group.nodes
if node.bl_idname == "ShaderNodeNormalMap"
}
assert normal_maps == {"TANGENT", "OBJECT"}
assert input_socket(material_group, "Object Space").default_value is False
assert input_socket(material_group, "Normal Scale").default_value == 1.0
source_image = bpy.data.images.new("Reuse Source", 2, 1)
shared_normal = bpy.data.images.new("Shared Normal", 2, 1)
shared_normal.pixels.foreach_set(
(128 / 255, 128 / 255, 1, 1, 128 / 255, 128 / 255, 1, 1)
)
material = bpy.data.materials.new("Unrelated Material Name")
mark_balloon_material(material, source_image, shared_normal)
assert reusable_balloon_material(
source_image,
normal_required=True,
) is material
update_normal = bpy.data.images.new("Normal Update", 2, 1)
update_normal.pixels.foreach_set(
(1, 0.25, 0.75, 1, 128 / 255, 128 / 255, 1, 1)
)
merge_balloon_normal_image(shared_normal, update_normal)
pixels = list(shared_normal.pixels)
assert abs(pixels[0] - 1.0) < 1e-5
assert abs(pixels[4] - 128 / 255) < 1e-5
print(f"Validated Blender {bpy.app.version_string}: {group.name}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,165 @@
from pathlib import Path
import sys
import bpy
PROJECT_ROOT = Path(__file__).parents[1]
sys.path.insert(0, str(PROJECT_ROOT / "src"))
sys.path.insert(
0,
str(next((PROJECT_ROOT / "src" / "anyimage" / "wheels").glob("blendjob-*.whl"))),
)
from anyimage.operators import mesh
from anyimage.operators.material import modifier_input_identifier, set_modifier_input
def evaluated_mesh(obj):
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
result = evaluated.to_mesh()
return evaluated, result
def grid_object(name):
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(name)
data.from_pydata(vertices, (), faces)
obj = bpy.data.objects.new(name, data)
bpy.context.scene.collection.objects.link(obj)
return obj
def add_modifier(obj, shape, thickness):
group = mesh._mesh_node_group(shape)
modifier = obj.modifiers.new(group.name, "NODES")
modifier.node_group = group
set_modifier_input(
modifier,
modifier_input_identifier(group, "Cleanup"),
0.0,
)
set_modifier_input(
modifier,
modifier_input_identifier(group, "Thickness"),
thickness,
)
return modifier
def main():
bpy.ops.wm.read_factory_settings(use_empty=True)
groups = {
shape: mesh._mesh_node_group(shape)
for shape in (
mesh.CUTOUT,
mesh.BALLOON,
mesh.DEPTH_BALLOON,
mesh.DEPTH_SURFACE,
)
}
assert [groups[shape].name for shape in groups] == [
"O Image Surface",
"O Image Balloon",
"O Image Depth Balloon",
"O Image Depth Surface",
]
repeat_counts = {
shape: sum(
node.bl_idname == "GeometryNodeRepeatInput"
for node in group.nodes
)
for shape, group in groups.items()
}
assert repeat_counts == {
mesh.CUTOUT: 1,
mesh.BALLOON: 0,
mesh.DEPTH_BALLOON: 0,
mesh.DEPTH_SURFACE: 2,
}
for group in groups.values():
assert group.is_modifier
assert all(not node.label for node in group.nodes)
assert all(
link.from_node.location.x <= link.to_node.location.x
for link in group.links
)
group_nodes = list(group.nodes)
assert len({tuple(node.location) for node in group_nodes}) == len(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
)
for group_input in (
node for node in group_nodes if node.bl_idname == "NodeGroupInput"
):
assert all(
output.is_linked or output.hide
for output in group_input.outputs
if output.name
)
for item in group.interface.items_tree:
if item.item_type == "SOCKET" and item.in_out == "INPUT":
assert item.structure_type == "SINGLE"
surface = grid_object("Surface Validation")
add_modifier(surface, mesh.CUTOUT, 2.4)
evaluated, result = evaluated_mesh(surface)
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
finally:
evaluated.to_mesh_clear()
balloon = grid_object("Balloon Validation")
profile = balloon.data.attributes.new(mesh.BALLOON_ATTRIBUTE, "FLOAT", "POINT")
profile.data.foreach_set(
"value",
(0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0),
)
modifier = add_modifier(balloon, mesh.BALLOON, 1.0)
evaluated, result = evaluated_mesh(balloon)
try:
initial_faces = len(result.polygons)
initial_extent = max(abs(vertex.co.x) for vertex in result.vertices)
finally:
evaluated.to_mesh_clear()
set_modifier_input(
modifier,
modifier_input_identifier(modifier.node_group, "Thickness"),
2.0,
)
balloon.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
evaluated, result = evaluated_mesh(balloon)
try:
assert len(result.polygons) == initial_faces
extent = max(abs(vertex.co.x) for vertex in result.vertices)
assert abs(extent - initial_extent * 2.0) < 1e-5
finally:
evaluated.to_mesh_clear()
print(
f"Validated Blender {bpy.app.version_string}: "
f"{', '.join(group.name for group in groups.values())}"
)
if __name__ == "__main__":
main()

View File

@@ -161,7 +161,7 @@ def main():
static_depth = next(
node.image
for node in image_textures
if node.image.name == "MoGe Test.depth"
if node.image.name == "MoGe Test_depth.exr"
)
assert static_depth.source != "SEQUENCE"
geometry_textures = [