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

@@ -3,6 +3,14 @@
import bpy
from node_graph import expand_smooth
from cutout_depth_surface import (
store_surface_camera,
remove_surface_attributes,
split_depth_surface,
thicken_depth_surface,
)
from cutout_depth_surface_layout import layout_depth_surface
from geometry_layouts import layout_geometry
from anyimage.operators.cutout_tool.shape import (
BALLOON_ATTRIBUTE_NAME,
@@ -427,81 +435,6 @@ def _position_field(group, depth_image, location):
return position_texture.outputs["Color"]
def _capture_edge_length(group, geometry, name, location):
nodes = group.nodes
links = group.links
x, y = location
edge_vertices = nodes.new("GeometryNodeInputMeshEdgeVertices")
edge_vertices.location = (x, y + 250.0)
edge_length = nodes.new("ShaderNodeVectorMath")
edge_length.operation = "DISTANCE"
edge_length.location = (x + 220.0, y + 250.0)
links.new(edge_vertices.outputs["Position 1"], edge_length.inputs[0])
links.new(edge_vertices.outputs["Position 2"], edge_length.inputs[1])
capture = nodes.new("GeometryNodeCaptureAttribute")
capture.domain = "POINT"
capture.capture_items.clear()
capture.capture_items.new("FLOAT", name)
capture.location = (x + 440.0, y)
links.new(geometry, capture.inputs["Geometry"])
links.new(edge_length.outputs["Value"], capture.inputs[name])
return capture.outputs["Geometry"], capture.outputs[name], edge_length.outputs["Value"]
def _remove_stretched_points(
group,
geometry,
original_edge_length,
current_edge_length,
stretch_limit,
location,
):
nodes = group.nodes
links = group.links
x, y = location
remaining_ratio = _math(nodes, "DIVIDE", (x + 220.0, y + 300.0))
links.new(original_edge_length, remaining_ratio.inputs[0])
links.new(current_edge_length, remaining_ratio.inputs[1])
stretched = _math(nodes, "LESS_THAN", (x + 450.0, y + 200.0))
links.new(remaining_ratio.outputs[0], stretched.inputs[0])
links.new(stretch_limit, stretched.inputs[1])
delete_stretched = nodes.new("GeometryNodeDeleteGeometry")
delete_stretched.domain = "POINT"
delete_stretched.mode = "ALL"
delete_stretched.location = (x + 680.0, y)
links.new(geometry, delete_stretched.inputs["Geometry"])
links.new(stretched.outputs[0], delete_stretched.inputs["Selection"])
edge_neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors")
edge_neighbors.location = (x + 930.0, y + 500.0)
loose_edge = _math(nodes, "LESS_THAN", (x + 1150.0, y + 500.0))
loose_edge.inputs[1].default_value = 1.0
links.new(edge_neighbors.outputs["Face Count"], loose_edge.inputs[0])
delete_loose_edges = nodes.new("GeometryNodeDeleteGeometry")
delete_loose_edges.domain = "EDGE"
delete_loose_edges.mode = "ALL"
delete_loose_edges.location = (x + 1370.0, y)
links.new(delete_stretched.outputs["Geometry"], delete_loose_edges.inputs["Geometry"])
links.new(loose_edge.outputs[0], delete_loose_edges.inputs["Selection"])
vertex_neighbors = nodes.new("GeometryNodeInputMeshVertexNeighbors")
vertex_neighbors.location = (x + 1620.0, y + 500.0)
loose_point = _math(nodes, "LESS_THAN", (x + 1840.0, y + 500.0))
loose_point.inputs[1].default_value = 1.0
links.new(vertex_neighbors.outputs["Face Count"], loose_point.inputs[0])
delete_loose_points = nodes.new("GeometryNodeDeleteGeometry")
delete_loose_points.domain = "POINT"
delete_loose_points.mode = "ALL"
delete_loose_points.location = (x + 2060.0, y)
links.new(
delete_loose_edges.outputs["Geometry"],
delete_loose_points.inputs["Geometry"],
)
links.new(loose_point.outputs[0], delete_loose_points.inputs["Selection"])
return delete_loose_points.outputs["Geometry"]
def _plane_depth(group, direction, reference_depth, location):
nodes = group.nodes
links = group.links
@@ -673,20 +606,22 @@ def _build_depth_surface(
geometry,
thickness,
depth_scale,
stretch_limit,
split,
uniform_scale,
reference_depth,
depth_image,
cleanup,
):
nodes = group.nodes
links = group.links
geometry, original_edge_length, current_edge_length = _capture_edge_length(
group,
geometry,
"Original Edge Length",
(400.0, 850.0),
geometry = split_depth_surface(
group, geometry, depth_image, split, uniform_scale, depth_scale
)
geometry = _island_cleanup(group, geometry, cleanup, (1800.0, 850.0))
original_camera = _position_field(group, depth_image, (-200.0, -200.0))
geometry, camera_position = store_surface_camera(
group, geometry, original_camera, split
)
camera_position = _position_field(group, depth_image, (-200.0, -200.0))
scaled = nodes.new("ShaderNodeVectorMath")
scaled.operation = "SCALE"
scaled.location = (3100.0, -100.0)
@@ -731,45 +666,12 @@ def _build_depth_surface(
links.new(geometry, project_geometry.inputs["Geometry"])
links.new(surface_position.outputs["Vector"], project_geometry.inputs["Position"])
geometry = _remove_stretched_points(
group,
project_geometry.outputs["Geometry"],
original_edge_length,
current_edge_length,
stretch_limit,
(4700.0, 400.0),
)
zero_z = nodes.new("ShaderNodeValue")
zero_z.location = (7100.0, 100.0)
zero_z.outputs["Value"].default_value = 0.0
geometry = _set_geometry_z(
group,
geometry,
zero_z.outputs["Value"],
(7200.0, 400.0),
)
back = _math(nodes, "SUBTRACT", (3900.0, -100.0))
links.new(front.outputs[0], back.inputs[0])
links.new(thickness, back.inputs[1])
shaped = _set_segmented_layer_z(
group,
geometry,
front.outputs[0],
back.outputs[0],
thickness,
(8300.0, 400.0),
)
front_only = _set_geometry_z(
group,
geometry,
front.outputs[0],
(8500.0, -200.0),
)
geometry = project_geometry.outputs["Geometry"]
shaped = thicken_depth_surface(group, geometry, thickness)
geometry = _remove_zero_thickness(
group, front_only, shaped, thickness, (11800.0, 400.0)
group, project_geometry.outputs["Geometry"], shaped, thickness, (11800.0, 400.0)
)
return geometry
return remove_surface_attributes(group, geometry)
def build_node_group(shape):
@@ -791,7 +693,7 @@ def build_node_group(shape):
thickness.description = "Final thickness in scene distance"
depth_image = None
stretch_limit = None
split = None
if shape == "DEPTH_BALLOON":
depth_scale = _float_input(group, "Depth Scale", 1.0)
depth_scale.description = "Scale the fitted-plane-corrected metric depth"
@@ -828,19 +730,18 @@ def build_node_group(shape):
)
elif shape == "DEPTH_SURFACE":
options = group.interface.new_panel(name="Options", default_closed=True)
stretch_limit = _float_input(
split = _float_input(
group,
"Stretch Limit",
"Split",
0.0,
0.0,
1.0,
subtype="FACTOR",
parent=options,
)
stretch_limit.description = (
"Delete faces when original edge length divided by projected edge "
"length falls below this threshold; zero disables culling"
)
split.description = "Separate depth discontinuities; zero disables splitting"
cleanup = _float_input(group, "Cleanup", 0.0, 0.0, None, parent=options)
cleanup.description = "Remove split islands below this local-area threshold"
reference_depth = _float_input(
group,
"Reference Depth",
@@ -880,7 +781,8 @@ def build_node_group(shape):
"Uniform Scale",
"Reference Depth",
"Depth Direction",
"Stretch Limit",
"Split",
"Cleanup",
},
)
geometry = controls.outputs["Geometry"]
@@ -913,14 +815,19 @@ def build_node_group(shape):
geometry,
controls.outputs["Thickness"],
controls.outputs["Depth Scale"],
controls.outputs["Stretch Limit"],
controls.outputs["Split"],
controls.outputs["Uniform Scale"],
controls.outputs["Reference Depth"],
controls.outputs["Depth Image"],
controls.outputs["Cleanup"],
)
output_location = (12100.0, 400.0)
_localize_group_inputs(group, controls)
_shade_output(group, geometry, output_location)
if shape == "DEPTH_SURFACE":
layout_depth_surface(group)
else:
layout_geometry(group)
group.color_tag = "GEOMETRY"
group.description = {
"SURFACE": "Symmetrically thicken a Surface sheet.",
@@ -983,19 +890,16 @@ def build_cutout_modifier_group(shape_groups):
parent=options,
)
smooth.description = "Blend each smoothing pass into the selected shape"
stretch_limit = _float_input(
split = _float_input(
group,
"Stretch Limit",
"Split",
0.0,
0.0,
1.0,
subtype="FACTOR",
parent=options,
)
stretch_limit.description = (
"Delete Depth Surface faces when original edge length divided by projected "
"edge length falls below this threshold; zero disables culling"
)
split.description = "Separate Depth Surface discontinuities; zero disables splitting"
reference_depth = _float_input(
group,
@@ -1054,7 +958,10 @@ def build_cutout_modifier_group(shape_groups):
node.node_tree = shape_groups[shape]
node.location = (-850.0, y)
node.width = 220.0
group.links.new(geometry, node.inputs["Geometry"])
group.links.new(
base_controls.outputs["Geometry"] if shape == "DEPTH_SURFACE" else geometry,
node.inputs["Geometry"],
)
visible = {
socket.name
for socket in node.inputs
@@ -1064,7 +971,10 @@ def build_cutout_modifier_group(shape_groups):
for socket in node.inputs:
if not socket.name or socket.name == "Geometry":
continue
group.links.new(controls.outputs[socket.name], socket)
group.links.new(
cleanup_area.outputs[0] if socket.name == "Cleanup" else controls.outputs[socket.name],
socket,
)
branch_nodes[shape] = node
menu_switch = group.nodes.new("GeometryNodeMenuSwitch")
@@ -1114,10 +1024,14 @@ def build_cutout_modifier_group(shape_groups):
group.links.new(geometry, axis_switch.inputs["-Z"])
group.links.new(orient.outputs["Geometry"], axis_switch.inputs["+X"])
_shade_output(group, axis_switch.outputs["Output"], (3850.0, 550.0))
layout_geometry(group)
group.color_tag = "GEOMETRY"
group.description = (
"Build Surface, Balloon, Depth Balloon, or Depth Surface from one "
"menu-controlled Cutout modifier."
)
for node in group.nodes:
for socket in node.outputs:
socket.hide = not socket.is_linked
group.use_fake_user = True
return group

View File

@@ -2,6 +2,8 @@
import bpy
from geometry_layouts import layout_geometry
DEPTH_PLANE_NODE_GROUP_NAME = "O Image Depth Plane"
@@ -206,4 +208,5 @@ def build_depth_plane_group(mesh_plane_group):
"Build a single-sided depth terrain block with a fixed base."
)
group.use_fake_user = True
layout_geometry(group)
return group

View File

@@ -2,6 +2,8 @@
import bpy
from geometry_layouts import layout_geometry
MESH_PLANE_GROUP_NAME = "O Mesh Plane"
@@ -27,6 +29,31 @@ def new_group_input(nodes, location, visible_names):
return node
def localize_dimensions(group):
"""Repeat the small source-bounds calculation near UV and mesh consumers."""
sources = [
group.nodes[name] for name in ("Bounding Box", "Vector Math", "Separate XYZ")
]
for targets in (
{"Math.011", "Math.013"},
{"Grid", "Combine XYZ.001", "Vector Math.001"},
):
copies = {source: group.nodes.new(source.bl_idname) for source in sources}
copies[sources[1]].operation = "SUBTRACT"
for link in list(group.links):
if link.to_node in copies:
source = copies.get(link.from_node, link.from_node)
group.links.new(
source.outputs[link.from_socket.identifier],
copies[link.to_node].inputs[link.to_socket.identifier],
)
elif link.from_node in copies and link.to_node.name in targets:
group.links.new(
copies[link.from_node].outputs[link.from_socket.identifier],
link.to_socket,
)
def build_mesh_plane_group():
group = bpy.data.node_groups.new(MESH_PLANE_GROUP_NAME, "GeometryNodeTree")
group.is_modifier = True
@@ -256,4 +283,6 @@ def build_mesh_plane_group():
"Build an aspect-adaptive image plane and add optional centered thickness."
)
group.use_fake_user = True
localize_dimensions(group)
layout_geometry(group)
return group

View File

@@ -0,0 +1,296 @@
"""Face separation and connected thickness for Depth Surface assets."""
TEMPORARY_ATTRIBUTES = {
"face_center": ("FLOAT_VECTOR", "FACE"),
"face_camera": ("FLOAT_VECTOR", "FACE"),
"corner_camera": ("FLOAT_VECTOR", "CORNER"),
"cut_vertex": ("BOOLEAN", "CORNER"),
"camera": ("FLOAT_VECTOR", "POINT"),
"surface_position": ("FLOAT_VECTOR", "POINT"),
"source_vertex": ("INT", "POINT"),
"wall_layers": ("FLOAT", "POINT"),
}
def _store(group, geometry, name, value):
node = group.nodes.new("GeometryNodeStoreNamedAttribute")
node.data_type, node.domain = TEMPORARY_ATTRIBUTES[name]
node.inputs["Name"].default_value = "o_depth_" + name
group.links.new(geometry, node.inputs["Geometry"])
group.links.new(value, node.inputs["Value"])
return node.outputs["Geometry"]
def _read(group, name):
node = group.nodes.new("GeometryNodeInputNamedAttribute")
node.data_type = TEMPORARY_ATTRIBUTES[name][0]
node.inputs["Name"].default_value = "o_depth_" + name
return node.outputs["Attribute"]
def remove_surface_attributes(group, geometry):
"""Clear the reserved temporary namespace with one wildcard operation."""
node = group.nodes.new("GeometryNodeRemoveAttribute")
node.pattern_mode = "WILDCARD"
node.inputs["Name"].default_value = "o_depth_*"
group.links.new(geometry, node.inputs["Geometry"])
return node.outputs["Geometry"]
def _math(group, operation, a, b=None):
node = group.nodes.new("ShaderNodeMath")
node.operation = operation
for index, value in enumerate((a, b)):
if value is None:
continue
if isinstance(value, (int, float)):
node.inputs[index].default_value = value
else:
group.links.new(value, node.inputs[index])
return node.outputs[0]
def split_depth_surface(group, geometry, image, strength, uniform, depth_scale):
"""Keep each face's camera sample on its own corners before splitting."""
nodes, links = group.nodes, group.links
uv = nodes.new("GeometryNodeInputNamedAttribute")
uv.data_type = "FLOAT_VECTOR"
uv.inputs["Name"].default_value = "UVMap"
face_uv = nodes.new("GeometryNodeFieldOnDomain")
face_uv.data_type = "FLOAT_VECTOR"
face_uv.domain = "FACE"
links.new(uv.outputs["Attribute"], face_uv.inputs["Value"])
texture = nodes.new("GeometryNodeImageTexture")
texture.interpolation = "Linear"
texture.extension = "EXTEND"
links.new(image, texture.inputs["Image"])
links.new(face_uv.outputs["Value"], texture.inputs["Vector"])
position = nodes.new("GeometryNodeInputPosition")
faces = _store(group, geometry, "face_center", position.outputs[0])
faces = _store(group, faces, "face_camera", texture.outputs["Color"])
axes = nodes.new("ShaderNodeSeparateXYZ")
links.new(_read(group, "face_camera"), axes.inputs[0])
center_field = _read(group, "face_center")
centers, depths = [], []
for side in (0, 1):
corner = nodes.new("GeometryNodeCornersOfEdge")
corner.inputs["Sort Index"].default_value = side
face = nodes.new("GeometryNodeFaceOfCorner")
links.new(corner.outputs["Corner Index"], face.inputs["Corner Index"])
for data_type, field, results in (
("FLOAT_VECTOR", center_field, centers),
("FLOAT", axes.outputs["Z"], depths),
):
sample = nodes.new("GeometryNodeFieldAtIndex")
sample.data_type = data_type
sample.domain = "FACE"
links.new(face.outputs["Face Index"], sample.inputs["Index"])
links.new(field, sample.inputs["Value"])
results.append(sample.outputs["Value"])
distance = nodes.new("ShaderNodeVectorMath")
distance.operation = "DISTANCE"
for index, center in enumerate(centers):
links.new(center, distance.inputs[index])
delta = _math(group, "ABSOLUTE", _math(group, "SUBTRACT", *depths))
scaled = _math(group, "MULTIPLY", delta, uniform)
scaled = _math(group, "MULTIPLY", scaled, _math(group, "ABSOLUTE", depth_scale))
# Cross multiplication avoids division and stays invariant under uniform scaling.
selected = _math(
group,
"GREATER_THAN",
_math(group, "MULTIPLY", scaled, strength),
_math(group, "MULTIPLY", distance.outputs["Value"], 2.5),
)
selected = _math(
group,
"MULTIPLY",
selected,
_math(group, "GREATER_THAN", distance.outputs["Value"], 0.0),
)
neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors")
selected = _math(
group,
"MULTIPLY",
selected,
_math(group, "COMPARE", neighbors.outputs["Face Count"], 2.0),
)
cut_edges = nodes.new("GeometryNodeFieldOnDomain")
cut_edges.data_type = "FLOAT"
cut_edges.domain = "EDGE"
links.new(selected, cut_edges.inputs["Value"])
cut_vertices = nodes.new("GeometryNodeFieldOnDomain")
cut_vertices.data_type = "FLOAT"
cut_vertices.domain = "POINT"
links.new(cut_edges.outputs["Value"], cut_vertices.inputs["Value"])
corners = _store(group, faces, "corner_camera", _read(group, "face_camera"))
corners = _store(
group,
corners,
"cut_vertex",
_math(group, "GREATER_THAN", cut_vertices.outputs["Value"], 0.0),
)
split = nodes.new("GeometryNodeSplitEdges")
links.new(corners, split.inputs["Mesh"])
links.new(selected, split.inputs["Selection"])
enabled = _math(group, "GREATER_THAN", strength, 0.0)
choose = nodes.new("GeometryNodeSwitch")
choose.input_type = "GEOMETRY"
links.new(enabled, choose.inputs["Switch"])
links.new(geometry, choose.inputs["False"])
links.new(split.outputs["Mesh"], choose.inputs["True"])
return choose.outputs["Output"]
def store_surface_camera(group, geometry, original, strength):
"""Change cut depth along the original ray, preserving every other vertex."""
separated = _read(group, "corner_camera")
enabled = _math(
group,
"MULTIPLY",
_math(group, "GREATER_THAN", strength, 0.0),
_read(group, "cut_vertex"),
)
original_axes = group.nodes.new("ShaderNodeSeparateXYZ")
separated_axes = group.nodes.new("ShaderNodeSeparateXYZ")
group.links.new(original, original_axes.inputs[0])
group.links.new(separated, separated_axes.inputs[0])
ratio = _math(
group, "DIVIDE", separated_axes.outputs["Z"], original_axes.outputs["Z"]
)
projected = group.nodes.new("ShaderNodeVectorMath")
projected.operation = "SCALE"
group.links.new(original, projected.inputs[0])
group.links.new(ratio, projected.inputs[3])
choose = group.nodes.new("GeometryNodeSwitch")
choose.input_type = "VECTOR"
group.links.new(enabled, choose.inputs["Switch"])
group.links.new(original, choose.inputs["False"])
group.links.new(projected.outputs[0], choose.inputs["True"])
geometry = _store(group, geometry, "camera", choose.outputs["Output"])
return geometry, _read(group, "camera")
def thicken_depth_surface(group, geometry, thickness):
"""Connect caps and wall rings by source vertex and layer identity."""
nodes, links = group.nodes, group.links
position = nodes.new("GeometryNodeInputPosition")
geometry = _store(group, geometry, "surface_position", position.outputs[0])
flattened = nodes.new("ShaderNodeVectorMath")
flattened.operation = "MULTIPLY"
flattened.inputs[1].default_value = (1.0, 1.0, 0.0)
links.new(position.outputs[0], flattened.inputs[0])
sheet = nodes.new("GeometryNodeSetPosition")
links.new(geometry, sheet.inputs["Geometry"])
links.new(flattened.outputs[0], sheet.inputs["Position"])
index = nodes.new("GeometryNodeInputIndex")
geometry = _store(
group, sheet.outputs["Geometry"], "source_vertex", index.outputs[0]
)
edge = nodes.new("GeometryNodeInputMeshEdgeVertices")
length = nodes.new("ShaderNodeVectorMath")
length.operation = "DISTANCE"
links.new(edge.outputs["Position 1"], length.inputs[0])
links.new(edge.outputs["Position 2"], length.inputs[1])
neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors")
statistics = nodes.new("GeometryNodeAttributeStatistic")
statistics.domain = "EDGE"
statistics.data_type = "FLOAT"
links.new(geometry, statistics.inputs["Geometry"])
links.new(
_math(group, "COMPARE", neighbors.outputs["Face Count"], 1.0),
statistics.inputs["Selection"],
)
links.new(length.outputs["Value"], statistics.inputs["Attribute"])
count = _math(
group,
"CEIL",
_math(
group,
"DIVIDE",
thickness,
_math(group, "MAXIMUM", statistics.outputs["Median"], 1e-6),
),
)
count = _math(group, "MINIMUM", _math(group, "MAXIMUM", count, 1.0), 128.0)
saved = _store(group, geometry, "wall_layers", count)
# Weld in an integer identity grid, never in scene coordinates. Nearby cuts
# retain distinct IDs; copies of one rim vertex share the same ID and layer.
identity = nodes.new("ShaderNodeCombineXYZ")
for operation, axis in (("MODULO", "X"), ("DIVIDE", "Y")):
part = nodes.new("FunctionNodeIntegerMath")
part.operation = operation
part.inputs[1].default_value = 4096
links.new(_read(group, "source_vertex"), part.inputs[0])
links.new(part.outputs[0], identity.inputs[axis])
repeat_output = nodes.new("GeometryNodeRepeatOutput")
repeat_input = nodes.new("GeometryNodeRepeatInput")
repeat_input.pair_with_output(repeat_output)
repeat_output.repeat_items.clear()
repeat_output.repeat_items.new("GEOMETRY", "Geometry")
repeat_output.repeat_items.new("GEOMETRY", "Sides")
links.new(count, repeat_input.inputs["Iterations"])
links.new(saved, repeat_input.inputs["Geometry"])
extrude = nodes.new("GeometryNodeExtrudeMesh")
extrude.mode = "FACES"
extrude.inputs["Individual"].default_value = False
extrusion_offset = nodes.new("ShaderNodeCombineXYZ")
extrusion_offset.inputs["Z"].default_value = -1.0
links.new(extrusion_offset.outputs[0], extrude.inputs["Offset"])
links.new(repeat_input.outputs["Geometry"], extrude.inputs["Mesh"])
sides = nodes.new("GeometryNodeSeparateGeometry")
sides.domain = "FACE"
links.new(extrude.outputs["Mesh"], sides.inputs["Geometry"])
links.new(extrude.outputs["Side"], sides.inputs["Selection"])
join_sides = nodes.new("GeometryNodeJoinGeometry")
links.new(repeat_input.outputs["Sides"], join_sides.inputs["Geometry"])
links.new(sides.outputs["Selection"], join_sides.inputs["Geometry"])
links.new(sides.outputs["Inverted"], repeat_output.inputs["Geometry"])
links.new(join_sides.outputs["Geometry"], repeat_output.inputs["Sides"])
offset = nodes.new("ShaderNodeCombineXYZ")
links.new(
_math(group, "MULTIPLY", _read(group, "wall_layers"), -1.0), offset.inputs["Z"]
)
back = nodes.new("GeometryNodeSetPosition")
links.new(saved, back.inputs["Geometry"])
links.new(offset.outputs[0], back.inputs["Offset"])
flip_back = nodes.new("GeometryNodeFlipFaces")
links.new(back.outputs["Geometry"], flip_back.inputs["Mesh"])
flip_sides = nodes.new("GeometryNodeFlipFaces")
links.new(repeat_output.outputs["Sides"], flip_sides.inputs["Mesh"])
join = nodes.new("GeometryNodeJoinGeometry")
for value in (
saved,
flip_back.outputs["Mesh"],
flip_sides.outputs["Mesh"],
):
links.new(value, join.inputs["Geometry"])
encoded = nodes.new("GeometryNodeSetPosition")
links.new(join.outputs["Geometry"], encoded.inputs["Geometry"])
links.new(identity.outputs[0], encoded.inputs["Position"])
merge = nodes.new("GeometryNodeMergeByDistance")
merge.mode = "ALL"
merge.inputs["Distance"].default_value = 0.1
links.new(encoded.outputs["Geometry"], merge.inputs["Geometry"])
displacement = nodes.new("ShaderNodeCombineXYZ")
step = _math(group, "DIVIDE", thickness, _read(group, "wall_layers"))
layer_position = nodes.new("GeometryNodeInputPosition")
layer_axes = nodes.new("ShaderNodeSeparateXYZ")
links.new(layer_position.outputs[0], layer_axes.inputs[0])
links.new(
_math(group, "MULTIPLY", layer_axes.outputs["Z"], step),
displacement.inputs["Z"],
)
restored = nodes.new("ShaderNodeVectorMath")
restored.operation = "ADD"
links.new(_read(group, "surface_position"), restored.inputs[0])
links.new(displacement.outputs[0], restored.inputs[1])
result = nodes.new("GeometryNodeSetPosition")
links.new(merge.outputs["Geometry"], result.inputs["Geometry"])
links.new(restored.outputs[0], result.inputs["Position"])
local_position = nodes.new("GeometryNodeInputPosition")
local_axes = nodes.new("ShaderNodeSeparateXYZ")
links.new(local_position.outputs[0], local_axes.inputs[0])
links.new(local_axes.outputs["Z"], identity.inputs["Z"])
return result.outputs["Geometry"]

View File

@@ -0,0 +1,209 @@
"""Arrange staggered calculation branches on both sides of the geometry path."""
from node_layout import arrange_nodes
# Main geometry advances left to right. Local fields grow to either side of it;
# a long calculation may start before the geometry input or span several stages.
POSITIONS = {
"Position": (0, 260),
"Store Named Attribute": (0, 0),
"Store Named Attribute.001": (260, 0),
"Store Named Attribute.002": (520, 0),
"Store Named Attribute.003": (780, 0),
"Split Edges": (1060, 0),
"Switch": (1360, 0),
# Face sampling stays near its stores; selection forms a dense field cluster.
"Named Attribute": (-400, -320),
"Evaluate on Domain": (-180, -320),
"Image Texture": (20, -320),
"Named Attribute.003": (260, 260),
"Named Attribute.001": (-1090, 1060),
"Separate XYZ": (-870, 1060),
"Named Attribute.002": (-390, 590),
"Corners of Edge": (-1090, 910),
"Face of Corner": (-910, 910),
"Corners of Edge.001": (-1090, 770),
"Face of Corner.001": (-910, 770),
"Evaluate at Index": (-170, 750),
"Evaluate at Index.002": (-170, 610),
"Evaluate at Index.001": (-710, 1060),
"Evaluate at Index.003": (-710, 920),
"Vector Math": (10, 710),
"Math": (-530, 1040),
"Math.001": (-350, 1040),
"Math.002": (-170, 1040),
"Math.003": (-350, 830),
"Math.004": (10, 1040),
"Math.005": (190, 1040),
"Math.006": (190, 790),
"Math.007": (370, 980),
"Math.008": (190, 620),
"Math.009": (550, 910),
"Edge Neighbors": (370, 590),
"Math.010": (550, 590),
"Math.011": (730, 810),
"Evaluate on Domain.001": (730, 670),
"Evaluate on Domain.002": (910, 670),
"Math.012": (1090, 670),
"Math.013": (1360, 280),
# Cleanup is a separate compact cluster before projection.
"Face Area": (1460, 780),
"Mesh Island": (1460, 580),
"Accumulate Field": (1680, 780),
"Math.014": (1900, 580),
"Delete Geometry": (2120, 0),
# Camera-ray reconstruction runs below; scene projection runs above.
"Named Attribute.004": (1290, -440),
"Image Texture.001": (1510, -440),
"Separate XYZ.001": (1740, -440),
"Named Attribute.005": (1510, -710),
"Separate XYZ.002": (1740, -710),
"Math.017": (1960, -440),
"Vector Math.001": (2180, -440),
"Named Attribute.006": (1740, -940),
"Math.015": (1510, -940),
"Math.016": (1960, -940),
"Switch.001": (2600, -240),
"Store Named Attribute.004": (2920, 0),
"Named Attribute.007": (2340, 1080),
"Vector Math.002": (2560, 1080),
"Separate XYZ.003": (2740, 1080),
"Math.018": (3100, 730),
"Math.019": (3280, 730),
"Math.020": (2740, 910),
"Combine XYZ": (2920, 1040),
"Position.001": (2920, 790),
"Mix": (3100, 950),
"Separate XYZ.004": (3280, 950),
"Combine XYZ.001": (3460, 850),
"Set Position": (3870, 0),
# Short preparation fields sit above the axis; the long wall-count branch
# sits below, reading geometry back from the source-vertex store.
"Position.002": (4560, 340),
"Store Named Attribute.005": (4560, 0),
"Vector Math.003": (4780, 340),
"Set Position.001": (4780, 0),
"Index": (5010, 340),
"Store Named Attribute.006": (5010, 0),
"Edge Vertices": (3470, -610),
"Vector Math.004": (3700, -610),
"Edge Neighbors.001": (3470, -850),
"Math.021": (3700, -850),
"Attribute Statistic": (3920, -610),
"Math.022": (4150, -610),
"Math.023": (4370, -610),
"Math.024": (4590, -610),
"Math.025": (4820, -610),
"Math.026": (5040, -610),
"Store Named Attribute.007": (5310, 0),
"Repeat Input": (5620, 0),
"Extrude Mesh": (5850, 0),
"Combine XYZ.003": (5620, -340),
"Separate Geometry": (6070, 0),
"Join Geometry": (6300, -180),
"Repeat Output": (6520, 0),
"Flip Faces.001": (6740, 0),
"Join Geometry.001": (6970, 0),
"Named Attribute.010": (5620, -530),
"Math.027": (5850, -530),
"Combine XYZ.004": (6070, -530),
"Set Position.002": (6300, -320),
"Flip Faces": (6740, -320),
# Identity decoding above and position restoration below have independent,
# horizontally spread readers rather than equally tall vertical stacks.
"Named Attribute.008": (6240, 880),
"Integer Math": (6470, 880),
"Named Attribute.009": (6240, 650),
"Integer Math.001": (6470, 650),
"Position.004": (6240, 410),
"Separate XYZ.006": (6470, 410),
"Combine XYZ.002": (6670, 650),
"Set Position.003": (7410, 0),
"Merge by Distance": (7640, 0),
"Position.003": (6910, -950),
"Separate XYZ.005": (7160, -950),
"Named Attribute.011": (6900, -710),
"Math.028": (7140, -580),
"Math.029": (7380, -750),
"Combine XYZ.005": (7600, -680),
"Named Attribute.012": (7600, -370),
"Vector Math.005": (7830, -340),
"Set Position.004": (8080, 0),
"Math.030": (8310, 260),
"Switch.002": (8310, 0),
"Remove Named Attribute": (9000, 0),
"Set Shade Smooth": (9220, 0),
"Group Output": (9440, 0),
}
INPUT_REGIONS = (
(("Store Named Attribute",), (-240, 0)),
(("Switch",), (1100, -300)),
(("Image Texture",), (-180, -190)),
(("Math.002",), (-350, 1140)),
(("Math.003",), (-530, 830)),
(("Math.005",), (10, 1140)),
(("Math.013",), (1100, 320)),
(("Math.014",), (1680, 400)),
(("Image Texture.001",), (1290, -270)),
(("Math.015",), (1290, -940)),
(("Vector Math.002",), (2340, 1200)),
(("Math.018", "Math.019"), (2920, 680)),
(("Mix",), (2920, 900)),
(("Math.023",), (4150, -820)),
(("Math.028",), (6900, -540)),
(("Math.030",), (8080, 300)),
)
def _route(group, source_name, output, target_name, input_name, points):
"""Place geometry references around the local branches they serve."""
source = group.nodes[source_name].outputs[output]
target = group.nodes[target_name].inputs[input_name]
for link in list(group.links):
if link.from_socket == source and link.to_socket == target:
group.links.remove(link)
for point in points:
node = group.nodes.new("NodeReroute")
node.location = point
node.select = False
group.links.new(source, node.inputs[0])
source = node.outputs[0]
group.links.new(source, target)
def layout_depth_surface(group):
"""Keep the geometry path readable while staggering long and short branches."""
arrange_nodes(group, POSITIONS, INPUT_REGIONS)
_route(
group,
"Repeat Input",
"Sides",
"Join Geometry",
"Geometry",
((5810, -170), (6280, -170)),
)
_route(
group,
"Store Named Attribute.007",
"Geometry",
"Join Geometry.001",
"Geometry",
((5530, 150), (6950, 150)),
)
_route(
group,
"Store Named Attribute.007",
"Geometry",
"Set Position.002",
"Geometry",
((5530, -270), (6280, -270)),
)
_route(
group,
"Set Position",
"Geometry",
"Switch.002",
"True",
((4110, 240), (8290, 240)),
)

View File

@@ -0,0 +1,339 @@
"""Authored processing stages for the shared image geometry assets."""
from node_layout import arrange_nodes
LAYOUTS = {
"O Mesh Plane": (
{
"Bounding Box": (-2320, 440),
"Vector Math": (-2100, 650),
"Separate XYZ": (-1870, 650),
"Math": (-1870, 990),
"Math.001": (-1650, 850),
"Math.002": (-1430, 850),
"Math.003": (-1200, 850),
"Math.004": (-980, 850),
"Math.005": (-760, 850),
"Math.006": (-1650, 510),
"Math.007": (-1430, 510),
"Math.008": (-1200, 510),
"Math.009": (-980, 510),
"Math.010": (-760, 510),
"Grid": (520, 340),
"Position": (-950, -480),
"Separate XYZ.001": (-720, -480),
"Math.011": (-500, -340),
"Math.012": (-280, -340),
"Math.013": (-500, -610),
"Math.014": (-280, -610),
"Combine XYZ": (0, -340),
"Store Named Attribute": (770, 340),
"Vector Math.001": (740, 710),
"Vector Math.002": (960, 710),
"Transform Geometry": (1030, 340),
"Bounding Box.001": (-1170, -990),
"Vector Math.003": (-950, -990),
"Separate XYZ.002": (-720, -990),
"Bounding Box.002": (-330, 140),
"Vector Math.004": (-100, 140),
"Separate XYZ.003": (120, 270),
"Math.015": (-520, 1390),
"Math.016": (-290, 1390),
"Math.017": (-520, 1160),
"Math.018": (-290, 1160),
"Math.019": (-70, 1390),
"Math.020": (150, 1390),
"Math.021": (380, 1390),
"Math.022": (600, 1390),
"Math.023": (830, 1390),
"Math.024": (1050, 1390),
"Combine XYZ.001": (280, 0),
"Cube": (520, -170),
"Store Named Attribute.001": (770, -170),
"Transform Geometry.001": (1030, -170),
"Compare": (1320, 410),
"Switch": (1360, 0),
"Capture Attribute": (1320, -580),
"Join Geometry": (1720, 0),
"Set Material Index": (1940, 0),
"Delete Geometry": (2170, 0),
"Group Output": (2390, 0),
},
(
(("Bounding Box",), (-2550, 440)),
(("Math",), (-2100, 1020)),
(("Math.021",), (150, 1600)),
(("Combine XYZ.001",), (120, 60)),
(("Compare",), (1140, 540)),
(("Capture Attribute",), (1140, -580)),
(("Bounding Box.001",), (-1390, -990)),
(("Bounding Box.002",), (-550, 140)),
),
),
"O Image Cutout": (
{
"Math": (-170, 580),
"Mesh Island": (-620, 140),
"Face Area": (-620, 340),
"Accumulate Field": (-400, 340),
"Math.001": (50, 310),
"Delete Geometry": (430, 270),
"Group": (1030, 520),
"Group.001": (1030, 300),
"Group.002": (1030, 80),
"Group.003": (1030, -220),
"Menu Switch": (1390, 240),
"Repeat Input": (2170, 0),
"Repeat Output": (5090, 0),
"Vertex Neighbors": (2170, -780),
"Compare": (2390, -780),
"Edge Neighbors": (1940, -1020),
"Compare.001": (2170, -1020),
"Evaluate on Domain": (2390, -1020),
"Boolean Math": (2610, -780),
"Evaluate on Domain.001": (2840, -580),
"Separate Geometry": (3110, -310),
"Position": (3300, 540),
"Blur Attribute": (3520, 540),
"Position.001": (3110, -780),
"Blur Attribute.001": (3340, -780),
"Sample Nearest": (3340, -310),
"Sample Index": (3590, -310),
"Vertex Neighbors.001": (2390, 950),
"Compare.002": (2610, 950),
"Edge Neighbors.001": (2170, 1220),
"Compare.003": (2390, 1220),
"Evaluate on Domain.002": (2610, 1220),
"Boolean Math.001": (2840, 950),
"Switch": (3890, 340),
"Mix": (4130, 340),
"Normal": (3060, 1500),
"Blur Attribute.002": (3280, 1500),
"Vector Math": (3510, 1500),
"Switch.001": (3730, 1500),
"Math.002": (3950, 1360),
"Switch.002": (4370, 410),
"Position.002": (4370, 680),
"Mix.001": (4660, 410),
"Set Position": (4830, 0),
"Transform Geometry": (5450, -340),
"Menu Switch.001": (5740, 0),
"Set Shade Smooth": (5970, 0),
"Group Output": (6190, 0),
},
(
(("Math",), (-400, 580)),
(("Delete Geometry",), (430, 530)),
(("Group",), (810, 520)),
(("Group.001",), (810, 300)),
(("Group.002",), (810, 80)),
(("Group.003",), (810, -220)),
(("Menu Switch",), (1390, 380)),
(("Repeat Input",), (1940, 0)),
(("Math.002",), (3730, 1220)),
(("Menu Switch.001",), (5520, 340)),
),
),
"O Image Depth Plane": (
{
"Group": (0, 0),
"Transform Geometry": (520, 0),
"Set Position": (2470, 0),
"Set Shade Smooth": (2730, 0),
"Group Output": (2990, 0),
"Math": (60, 340),
"Combine XYZ": (240, 340),
"Named Attribute": (240, -450),
"Image Texture": (460, -450),
"Separate XYZ": (640, -450),
"Math.001": (820, -450),
"Math.002": (1000, -450),
"Math.003": (1180, -450),
"Math.004": (1180, -660),
"Math.005": (1380, -450),
"Math.006": (1740, -1060),
"Switch": (1760, -450),
"Position": (820, -820),
"Separate XYZ.001": (1000, -820),
"Math.007": (1180, -820),
"Math.008": (1380, -820),
"Math.009": (1560, -820),
"Switch.001": (1760, -820),
"Math.010": (1970, -520),
"Combine XYZ.001": (2150, -520),
},
(
(("Group",), (-240, 0)),
(("Math",), (-120, 340)),
(("Image Texture",), (240, -310)),
(("Math.001",), (640, -310)),
(("Math.002", "Math.003"), (820, -590)),
(("Math.004",), (1000, -660)),
(("Math.006",), (1560, -1060)),
(("Math.007",), (1000, -980)),
),
),
"O Image Balloon": (
{
"Named Attribute": (-950, 440),
"Attribute Statistic": (-730, 1020),
"Math": (-500, 1020),
"Math.001": (-280, 1020),
"Map Range": (-50, 1020),
"Math.002": (-500, 440),
"Math.003": (-500, 240),
"Math.004": (-280, 240),
"Extrude Mesh": (0, 0),
"Switch": (220, 440),
"Position": (20, 240),
"Separate XYZ": (220, 240),
"Combine XYZ": (420, 340),
"Set Position": (690, 0),
"Separate Geometry": (910, 0),
"Flip Faces": (1140, 0),
"Combine XYZ.001": (910, 530),
"Set Position.001": (1140, 300),
"Combine XYZ.002": (690, -550),
"Set Position.002": (910, -300),
"Flip Faces.001": (1140, -300),
"Join Geometry": (1430, 0),
"Merge by Distance": (1650, 0),
"Math.005": (2100, 310),
"Switch.001": (2100, 0),
"Set Shade Smooth": (2320, 0),
"Group Output": (2550, 0),
},
(
(("Extrude Mesh",), (-220, 0)),
(("Attribute Statistic",), (-950, 1020)),
(("Math.002", "Math.003"), (-730, 240)),
(("Set Position.001",), (910, 300)),
(("Set Position.002",), (690, -300)),
(("Math.005",), (1870, 340)),
(("Switch.001",), (1870, -240)),
),
),
"O Image Surface": (
{
"Math": (-90, 680),
"Math.001": (140, 680),
"Edge Vertices": (-1630, -340),
"Vector Math": (-1410, -340),
"Edge Neighbors": (-1630, -560),
"Math.002": (-1410, -560),
"Attribute Statistic": (-1190, -340),
"Math.003": (-960, -340),
"Math.004": (-740, -340),
"Float to Integer": (-520, -340),
"Integer Math": (-290, -340),
"Integer Math.001": (-70, -340),
"Math.005": (190, -580),
"Math.006": (410, -580),
"Combine XYZ": (1630, 480),
"Set Position": (1860, 260),
"Repeat Input": (410, 0),
"Extrude Mesh": (640, 0),
"Separate Geometry": (860, 0),
"Join Geometry": (1080, -180),
"Repeat Output": (1310, 0),
"Combine XYZ.001": (1410, 270),
"Set Position.001": (1630, 0),
"Flip Faces": (1860, 0),
"Combine XYZ.002": (1410, -470),
"Set Position.002": (1630, -260),
"Flip Faces.001": (1860, -260),
"Join Geometry.001": (2150, 0),
"Merge by Distance": (2370, 0),
"Math.007": (2820, 290),
"Switch": (2820, 0),
"Set Shade Smooth": (3040, 0),
"Group Output": (3270, 0),
},
(
(("Math",), (-310, 680)),
(("Attribute Statistic",), (-1410, -170)),
(("Math.004",), (-960, -610)),
(("Math.005",), (-30, -580)),
(("Repeat Input",), (190, 0)),
(("Set Position",), (1630, 260)),
(("Set Position.002",), (1410, -260)),
(("Math.007",), (2600, 310)),
(("Switch",), (2600, -240)),
),
),
"O Image Depth Balloon": (
{
"Named Attribute": (-1120, 650),
"Attribute Statistic": (-890, 650),
"Math": (-670, 650),
"Math.001": (-450, 650),
"Map Range": (-220, 650),
"Named Attribute.001": (-1790, -440),
"Image Texture": (-1570, -440),
"Separate XYZ": (-1340, -440),
"Math.002": (-1120, -440),
"Vector Math": (-2240, -950),
"Separate XYZ.001": (-2010, -950),
"Position": (-2240, -1260),
"Separate XYZ.002": (-2010, -1260),
"Math.003": (-1790, -950),
"Math.004": (-1790, -1160),
"Math.005": (-1570, -950),
"Math.006": (-1340, -950),
"Math.007": (-1120, -950),
"Math.008": (-890, -440),
"Math.009": (-670, -440),
"Math.010": (-450, -440),
"Math.011": (-670, 310),
"Math.012": (-670, 70),
"Math.013": (-450, 70),
"Mix": (20, 440),
"Math.014": (240, 710),
"Switch": (460, 710),
"Extrude Mesh": (0, 0),
"Switch.001": (460, 310),
"Position.001": (280, 530),
"Separate XYZ.003": (460, 530),
"Combine XYZ": (660, 430),
"Set Position": (930, 0),
"Separate Geometry": (1150, 0),
"Flip Faces": (1600, 0),
"Combine XYZ.001": (1380, 570),
"Set Position.001": (1600, 330),
"Combine XYZ.002": (1150, -570),
"Set Position.002": (1380, -320),
"Flip Faces.001": (1600, -320),
"Join Geometry": (1870, 0),
"Merge by Distance": (2320, 0),
"Position.002": (1600, 1120),
"Separate XYZ.004": (1820, 1120),
"Combine XYZ.003": (2050, 950),
"Set Position.003": (2320, 680),
"Math.015": (2560, -270),
"Switch.002": (2560, 0),
"Set Shade Smooth": (2790, 0),
"Group Output": (3010, 0),
},
(
(("Extrude Mesh",), (-220, 0)),
(("Attribute Statistic",), (-1120, 850)),
(("Image Texture",), (-1790, -270)),
(("Math.002",), (-1340, -750)),
(("Vector Math",), (-2460, -950)),
(("Math.007",), (-1340, -1160)),
(("Math.010",), (-670, -650)),
(("Math.011", "Math.012"), (-890, 310)),
(("Switch",), (240, 920)),
(("Set Position.001",), (1380, 330)),
(("Set Position.002",), (1150, -320)),
(("Set Position.003",), (2100, 610)),
(("Math.015",), (2340, -270)),
),
),
}
def layout_geometry(group):
"""Apply the authored layout for one geometry asset."""
positions, inputs = LAYOUTS[group.name]
arrange_nodes(group, positions, inputs)

View File

@@ -0,0 +1,138 @@
"""Shared presentation rules for authored geometry-node layouts."""
def arrange_nodes(group, positions, input_regions):
"""Place authored branches and rebuild their local Group Inputs."""
controls = [
(link.to_socket, link.from_socket.name)
for link in group.links
if link.from_node.bl_idname == "NodeGroupInput"
]
for node in list(group.nodes):
if node.bl_idname == "NodeGroupInput":
group.nodes.remove(node)
assert set(positions) == {node.name for node in group.nodes}, group.name
for name, location in positions.items():
group.nodes[name].location = location
for targets, location in input_regions:
node = group.nodes.new("NodeGroupInput")
node.location = location
for socket, name in controls:
if socket.node.name in targets:
group.links.new(node.outputs[name], socket)
assert all(socket.is_linked for socket, _ in controls), group.name
for node in group.nodes:
node.select = False
node.width = (
200
if "Attribute" in node.bl_idname or node.bl_idname == "GeometryNodeGroup"
else 160
)
node.hide = node.bl_idname in {
"ShaderNodeMath",
"ShaderNodeVectorMath",
} and all(
socket.is_linked
for socket in node.inputs
if socket.enabled and not socket.hide
)
for socket in node.outputs:
socket.hide = not socket.is_linked
def validate_node_layout(group):
"""Check visible bounds and sockets, allowing direct backward references."""
nodes = [n for n in group.nodes if n.bl_idname not in {"NodeReroute", "NodeFrame"}]
assert all(not n.label for n in nodes), group.name
assert not any(n.bl_idname == "FunctionNodeInputString" for n in nodes), group.name
assert all(s.is_linked or s.hide for n in nodes for s in n.outputs), group.name
validate_peer_inputs(group)
for link in group.links:
if (
link.from_node.bl_idname != "NodeReroute"
or link.to_node.bl_idname == "NodeReroute"
):
continue
source = link.from_node
while source.bl_idname == "NodeReroute":
source = source.inputs[0].links[0].from_node
assert source.location.x <= link.to_node.location.x, (
group.name,
source.name,
link.to_node.name,
)
for index, node in enumerate(nodes):
width = node.dimensions.x or node.width
height = node.dimensions.y or (30 if node.hide else node.height)
for other in nodes[index + 1 :]:
other_width = other.dimensions.x or other.width
other_height = other.dimensions.y or (30 if other.hide else other.height)
overlap = (
node.location.x < other.location.x + other_width
and node.location.x + width > other.location.x
and node.location.y - height < other.location.y
and node.location.y > other.location.y - other_height
)
assert not overlap, (group.name, node.name, other.name)
if node.bl_idname == "GeometryNodeRepeatInput":
assert (
node.paired_output and node.paired_output.location.x > node.location.x
)
def validate_peer_inputs(group):
"""Align peer branch ends while leaving shared upstream sources in place."""
parents = {node: set() for node in group.nodes}
consumers = {node: set() for node in group.nodes}
for link in group.links:
parents[link.to_node].add(link.from_node)
consumers[link.from_node].add(link.to_node)
def ancestors(node):
found = set()
pending = list(parents[node])
while pending:
parent = pending.pop()
if parent in found:
continue
found.add(parent)
pending.extend(parents[parent])
return found
for node in group.nodes:
if node.bl_idname not in {
"GeometryNodeJoinGeometry",
"GeometryNodeMenuSwitch",
"GeometryNodeSwitch",
"ShaderNodeCombineXYZ",
}:
continue
combine = node.bl_idname == "ShaderNodeCombineXYZ"
sources = set()
for socket in node.inputs:
if not combine and socket.type != "GEOMETRY":
continue
for link in socket.links:
source = link.from_node
while source.bl_idname == "NodeReroute":
source = source.inputs[0].links[0].from_node
if combine or source.bl_idname != "NodeGroupInput":
sources.add(source)
upstream = set().union(*(ancestors(source) for source in sources))
peers = sources - upstream
if combine:
peers = {source for source in peers if consumers[source] == {node}}
edges = [source.location.x + source.width for source in peers]
assert not edges or max(edges) - min(edges) < 1.0, (
group.name,
node.name,
[source.name for source in peers],
)
if node.bl_idname == "GeometryNodeMenuSwitch":
rows = [
link.from_node.location.y
for socket in node.inputs
for link in socket.links
if link.from_node in peers
]
assert rows == sorted(rows, reverse=True), (group.name, node.name)

View File

@@ -4,12 +4,14 @@ 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.material import (
DEPTH_MATERIAL_NODE_GROUP_NAME,
IMAGE_MATERIAL_NODE_GROUP_NAME,
@@ -137,6 +139,9 @@ def main():
cutout,
}
loaded = {*public_groups, *cutout_groups.values()}
for group in loaded:
if group.bl_idname == "GeometryNodeTree":
validate_node_layout(group)
assert all(group.use_fake_user for group in public_groups)
assert all(group.users > 0 for group in cutout_groups.values())
if bpy.app.version >= (5, 0, 0):

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 "