Add depth surface processing and layout tools

- 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.
This commit is contained in:
2026-09-07 19:21:03 +08:00
parent a0adf79db0
commit 7dec3b7ca4
21 changed files with 2075 additions and 373 deletions

View File

@@ -1,15 +1,20 @@
import sys
from collections import Counter
from itertools import pairwise
from pathlib import Path
import bpy
PROJECT_ROOT = Path(__file__).parents[2]
sys.path.insert(0, str(Path(__file__).parent))
sys.path.insert(0, str(PROJECT_ROOT / "src"))
sys.path.insert(
0,
str(next((PROJECT_ROOT / "src" / "anyimage" / "wheels").glob("blendjob-*.whl"))),
)
from node_layout import validate_node_layout
from anyimage.common.object import (
modifier_input_identifier,
set_modifier_input,
@@ -24,6 +29,130 @@ def evaluated_mesh(obj):
return evaluated, result
def validate_depth_surface_layout(group):
"""Check the geometry path, stage spacing and drawn node bounds."""
nodes = [node for node in group.nodes if node.bl_idname != "NodeReroute"]
main = [
group.nodes[name]
for name in (
"Store Named Attribute",
"Store Named Attribute.001",
"Store Named Attribute.002",
"Store Named Attribute.003",
"Split Edges",
"Switch",
"Delete Geometry",
"Store Named Attribute.004",
"Set Position",
"Store Named Attribute.005",
"Set Position.001",
"Store Named Attribute.006",
"Store Named Attribute.007",
"Repeat Input",
"Extrude Mesh",
"Separate Geometry",
"Repeat Output",
"Flip Faces.001",
"Join Geometry.001",
"Set Position.003",
"Merge by Distance",
"Set Position.004",
"Switch.002",
"Remove Named Attribute",
"Set Shade Smooth",
"Group Output",
)
]
assert len({node.location.y for node in main}) == 1
assert all(right.location.x > left.location.x for left, right in pairwise(main))
for left, right in (
("Switch", "Delete Geometry"),
("Delete Geometry", "Store Named Attribute.004"),
("Set Position", "Store Named Attribute.005"),
("Switch.002", "Remove Named Attribute"),
):
a, b = group.nodes[left], group.nodes[right]
assert b.location.x - (a.location.x + a.width) >= 280
assert not any(n.bl_idname == "FunctionNodeInputString" for n in nodes)
assert min(n.location.x for n in nodes) < main[0].location.x
def height(node):
if node.dimensions.y:
return node.dimensions.y
if node.hide or node.bl_idname == "NodeReroute":
return 30.0
if node.bl_idname == "GeometryNodeJoinGeometry":
return 60.0
return node.height
for index, node in enumerate(nodes):
width = node.dimensions.x or node.width
for other in nodes[index + 1 :]:
other_width = other.dimensions.x or other.width
overlap = (
node.location.x < other.location.x + other_width
and node.location.x + width > other.location.x
and node.location.y - height(node) < other.location.y
and node.location.y > other.location.y - height(other)
)
assert not overlap, (node.name, other.name)
repeat_input = next(
node for node in nodes if node.bl_idname == "GeometryNodeRepeatInput"
)
repeat_output = repeat_input.paired_output
inside = {repeat_output}
pending = [repeat_input]
while pending:
node = pending.pop()
if node in inside:
continue
inside.add(node)
pending.extend(link.to_node for link in group.links if link.from_node == node)
zone_bottom = min(node.location.y - height(node) for node in inside) - 60.0
zone_top = max(node.location.y for node in inside) + 60.0
for node in nodes:
if repeat_input.location.x <= node.location.x <= repeat_output.location.x:
assert (
node in inside
or node.location.y <= zone_bottom
or node.location.y - height(node) >= zone_top
), node.name
def validate_depth_surface_attributes(group):
"""Check temporary fields are typed, locally read and removed at output."""
expected = {
"o_depth_face_center": ("FLOAT_VECTOR", "FACE"),
"o_depth_face_camera": ("FLOAT_VECTOR", "FACE"),
"o_depth_corner_camera": ("FLOAT_VECTOR", "CORNER"),
"o_depth_cut_vertex": ("BOOLEAN", "CORNER"),
"o_depth_camera": ("FLOAT_VECTOR", "POINT"),
"o_depth_surface_position": ("FLOAT_VECTOR", "POINT"),
"o_depth_source_vertex": ("INT", "POINT"),
"o_depth_wall_layers": ("FLOAT", "POINT"),
}
stores = {
n.inputs["Name"].default_value: (n.data_type, n.domain)
for n in group.nodes
if n.bl_idname == "GeometryNodeStoreNamedAttribute"
}
assert stores == expected
assert not any(n.bl_idname == "GeometryNodeCaptureAttribute" for n in group.nodes)
removes = [n for n in group.nodes if n.bl_idname == "GeometryNodeRemoveAttribute"]
assert len(removes) == 1
assert removes[0].pattern_mode == "WILDCARD"
assert removes[0].inputs["Name"].default_value == "o_depth_*"
for node in group.nodes:
if node.bl_idname == "GeometryNodeInputNamedAttribute":
name = node.inputs["Name"].default_value
assert name == "UVMap" or node.data_type == expected[name][0]
assert all(
abs(link.to_node.location.x - node.location.x) <= 600
for link in group.links
if link.from_node == node
)
def input_names(group):
return [
item.name
@@ -83,6 +212,7 @@ def main():
shape: bpy.data.node_groups[name]
for shape, name in shapes.CUTOUT_NODE_GROUP_NAMES.items()
}
validate_depth_surface_layout(groups["DEPTH_SURFACE"])
assert all(not group.is_modifier for group in groups.values())
menu_switch = next(
@@ -157,7 +287,7 @@ def main():
"Smooth Iterations",
"Cleanup",
"Smooth",
"Stretch Limit",
"Split",
"Reference Depth",
"Depth Direction",
"Uniform Scale",
@@ -180,7 +310,8 @@ def main():
"Geometry",
"Thickness",
"Depth Scale",
"Stretch Limit",
"Split",
"Cleanup",
"Reference Depth",
"Uniform Scale",
"Depth Image",
@@ -200,9 +331,10 @@ def main():
)
dependencies.setdefault(link.from_socket.name, set()).add(shape)
assert dependencies == {
"Geometry": {"DEPTH_SURFACE"},
"Thickness": set(shapes.CUTOUT_SHAPES),
"Depth Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
"Stretch Limit": {"DEPTH_SURFACE"},
"Split": {"DEPTH_SURFACE"},
"Double Sided": {"DEPTH_BALLOON"},
"Reference Depth": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
"Depth Direction": {"DEPTH_BALLOON"},
@@ -232,7 +364,8 @@ def main():
)
assert all(
not any(node.bl_idname == "GeometryNodeInputMeshIsland" for node in group.nodes)
for group in groups.values()
for shape, group in groups.items()
if shape != "DEPTH_SURFACE"
)
assert any(node.bl_idname == "GeometryNodeBlurAttribute" for node in wrapper.nodes)
smooth = next(
@@ -252,77 +385,47 @@ def main():
)
assert cleanup.default_value == 0.0
assert cleanup.subtype == "NONE"
stretch_limit = next(
split_control = next(
item
for item in wrapper.interface.items_tree
if item.item_type == "SOCKET" and item.name == "Stretch Limit"
if item.item_type == "SOCKET" and item.name == "Split"
)
child_stretch_limit = next(
child_split = next(
item
for item in groups["DEPTH_SURFACE"].interface.items_tree
if item.item_type == "SOCKET" and item.name == "Stretch Limit"
if item.item_type == "SOCKET" and item.name == "Split"
)
assert stretch_limit.default_value == 0.0
assert stretch_limit.min_value == 0.0
assert stretch_limit.max_value == 1.0
assert stretch_limit.subtype == "FACTOR"
assert getattr(stretch_limit.parent, "name", "") == "Options"
assert child_stretch_limit.default_value == 0.0
assert child_stretch_limit.min_value == 0.0
assert child_stretch_limit.max_value == 1.0
assert child_stretch_limit.subtype == "FACTOR"
assert getattr(child_stretch_limit.parent, "name", "") == "Options"
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 = groups["DEPTH_SURFACE"].nodes
captures = [
node
for node in depth_surface_nodes
if node.bl_idname == "GeometryNodeCaptureAttribute"
]
assert len(captures) == 1
assert captures[0].domain == "POINT"
assert [item.name for item in captures[0].capture_items] == [
"Original Edge Length"
]
captured_distance = next(
link.from_node
for link in groups["DEPTH_SURFACE"].links
if link.to_node == captures[0]
and link.to_socket.name == "Original Edge Length"
)
assert captured_distance.bl_idname == "ShaderNodeVectorMath"
assert captured_distance.operation == "DISTANCE"
ratio = next(
link.to_node
for link in groups["DEPTH_SURFACE"].links
if link.from_node == captured_distance
and link.to_node.bl_idname == "ShaderNodeMath"
and link.to_node.operation == "DIVIDE"
)
assert any(
link.from_node == captures[0]
and link.to_node == ratio
and link.to_socket == ratio.inputs[0]
for link in groups["DEPTH_SURFACE"].links
node.bl_idname == "GeometryNodeSplitEdges" for node in depth_surface_nodes
)
assert any(
link.from_node == captured_distance
and link.to_node == ratio
and link.to_socket == ratio.inputs[1]
for link in groups["DEPTH_SURFACE"].links
)
samples = [
node
for node in depth_surface_nodes
if node.bl_idname == "GeometryNodeSampleIndex"
]
assert not samples
delete_domains = [
validate_depth_surface_attributes(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 groups["DEPTH_SURFACE"].interface.items_tree
]
assert delete_domains.count("EDGE") == 1
assert delete_domains.count("POINT") == 2
assert all(
socket.is_linked or socket.hide
for node in depth_surface_nodes
for socket in node.outputs
)
for node in depth_surface_nodes:
if node.bl_idname == "GeometryNodeRepeatInput":
assert node.paired_output.location.x > node.location.x
cleanup_scale = next(
link.to_node
for link in wrapper.links
@@ -342,26 +445,7 @@ def main():
)
for group in (wrapper, *groups.values()):
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
), (group.name, node.name, other.name)
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
)
validate_node_layout(group)
for item in group.interface.items_tree:
if item.item_type == "SOCKET" and item.in_out == "INPUT":
assert item.structure_type == "SINGLE"
@@ -525,14 +609,14 @@ def main():
set_modifier_input(
modifier,
modifier_input_identifier(modifier.node_group, "Stretch Limit"),
modifier_input_identifier(modifier.node_group, "Split"),
0.6,
)
depth_surface.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
evaluated, result = evaluated_mesh(depth_surface)
try:
assert 0 < len(result.polygons) < 8
assert len(result.polygons) == 8
face_edges = {
tuple(sorted((start, end)))
for polygon in result.polygons
@@ -543,9 +627,7 @@ def main():
}
assert all(tuple(sorted(edge.vertices)) in face_edges for edge in result.edges)
face_vertices = {
vertex
for polygon in result.polygons
for vertex in polygon.vertices
vertex for polygon in result.polygons for vertex in polygon.vertices
}
assert face_vertices == set(range(len(result.vertices)))
finally:
@@ -565,6 +647,25 @@ def main():
finally:
evaluated.to_mesh_clear()
set_modifier_input(
modifier, modifier_input_identifier(modifier.node_group, "Depth Scale"), 1.0
)
set_modifier_input(
modifier, modifier_input_identifier(modifier.node_group, "Thickness"), 0.2
)
depth_surface.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
evaluated, result = evaluated_mesh(depth_surface)
try:
edge_uses = Counter(
tuple(sorted((a, b)))
for face in result.polygons
for a, b in zip(face.vertices, (*face.vertices[1:], face.vertices[0]))
)
assert edge_uses and set(edge_uses.values()) == {2}
finally:
evaluated.to_mesh_clear()
print(
f"Validated Blender {bpy.app.version_string}: "
f"{wrapper.name} wrapping "