Refactor depth surface handling and unify depth sampling rules

- Removed the `always_vertex` parameter from `build_surface_camera` to streamline depth sampling for cutouts and ensure consistent behavior across depth plane and panorama.
- Updated depth sampling logic to ensure that all free boundary points, including the original frame of the depth plane, use neighboring face depth instead of their own texture sampling.
- Adjusted the depth mask deletion process to operate on faces rather than points, eliminating the need for additional cleanup of dangling edges.
- Ensured that the cutout rules are consistently applied across all relevant nodes, removing exceptions and simplifying the logic.
- Updated tests to verify the new behavior, ensuring that cutout points follow the depth of adjacent faces and that the original frame maintains its depth sampling.
- Refactored geometry handling in multiple depth-related nodes to improve clarity and maintainability.
This commit is contained in:
2026-09-16 16:30:56 +08:00
parent 7f69630371
commit c62ec2cd48
28 changed files with 880 additions and 173 deletions

View File

@@ -2,6 +2,8 @@
import bpy
from mathutils import Matrix
from anyimage.operators.cutout_tool.shape import NORMAL_REDUCTION_ATTRIBUTE_NAME
from ..common.nodes import (
interface_socket, group_input, float_input, bool_input, vector_input,
evaluate_field, store_float_attribute, read_float_attribute, prepare_node_group,
@@ -9,10 +11,16 @@ from ..common.nodes import (
from ..common.depth_surface import _math
from ..common.normal_map import ROTATION_ATTRIBUTE, FACE_ATTRIBUTE, AXIS_ATTRIBUTE
from ..common.direction import symmetry_legacy_direction_to_canonical
from ..common.smoothing import edge_boundary_field
from ..common.smoothing import edge_boundary_field, pinned_smooth
NODE_GROUP_NAME = "O Image Cutout Symmetry"
# 衔接带从轮廓向内覆盖的圈数。
FILL_SMOOTH_RINGS = 4
# 对称面附近按中位边长的倍数降低法线贴图强度。
SEAM_NORMAL_BAND = 4
def _align_front(group, geometry, controls):
"""Align existing geometry with the fixed XY symmetry plane."""
@@ -162,25 +170,26 @@ def _mirror(group, geometry):
return flip.outputs[0]
def _cut_fields(group, tolerance):
"""Return point-domain seam and outline fields for the cut."""
def _boundary_fields(group, tolerance):
"""Return the folded front's boundary field and its on-plane seam."""
nodes, links = group.nodes, group.links
boundary = evaluate_field(group, edge_boundary_field(nodes, links), 'FLOAT', 'POINT')
position = nodes.new('GeometryNodeInputPosition')
components = nodes.new('ShaderNodeSeparateXYZ')
links.new(position.outputs[0], components.inputs[0])
absolute = _math(group, 'ABSOLUTE', components.outputs['Y'])
def split(side):
node = nodes.new('FunctionNodeBooleanMath')
node.operation = 'AND'
links.new(_math(group, 'GREATER_THAN', boundary, 0), node.inputs[0])
links.new(side, node.inputs[1])
return node.outputs[0]
return split(_math(group, 'LESS_THAN', absolute, tolerance)), split(
_math(group, 'GREATER_THAN', absolute, tolerance)
seam = nodes.new('FunctionNodeBooleanMath')
seam.operation = 'AND'
links.new(_math(group, 'GREATER_THAN', boundary, 0), seam.inputs[0])
links.new(
_math(
group,
'LESS_THAN',
_math(group, 'ABSOLUTE', components.outputs['Y']),
tolerance,
),
seam.inputs[1],
)
return boundary, seam.outputs[0]
def _falloff(group, center, rings=2):
@@ -192,35 +201,28 @@ def _falloff(group, center, rings=2):
return _math(group, 'MAXIMUM', center, blur.outputs[0])
def _apply_planar_relax(group, geometry, influence, smooth):
"""Move points inside the plane by their blurred position, gated by influence."""
nodes, links = group.nodes, group.links
position = nodes.new('GeometryNodeInputPosition')
blur = nodes.new('GeometryNodeBlurAttribute')
blur.data_type = 'FLOAT_VECTOR'
links.new(position.outputs[0], blur.inputs['Value'])
links.new(smooth, blur.inputs['Iterations'])
difference = nodes.new('ShaderNodeVectorMath')
difference.operation = 'SUBTRACT'
links.new(blur.outputs[0], difference.inputs[0])
links.new(position.outputs[0], difference.inputs[1])
in_plane = nodes.new('ShaderNodeVectorMath')
in_plane.operation = 'MULTIPLY'
in_plane.inputs[1].default_value = (.5, 0, .5)
links.new(difference.outputs[0], in_plane.inputs[0])
scaled = nodes.new('ShaderNodeVectorMath')
scaled.operation = 'SCALE'
links.new(in_plane.outputs[0], scaled.inputs[0])
links.new(influence, scaled.inputs[3])
place = nodes.new('GeometryNodeSetPosition')
links.new(geometry, place.inputs['Geometry'])
links.new(scaled.outputs[0], place.inputs['Offset'])
return place.outputs[0]
def _relax_in_plane(group, geometry, region, iterations, pin_boundary):
"""Relax points without leaving the symmetry plane."""
return pinned_smooth(
group,
geometry,
iterations,
region,
weight=0.5,
movement=(1, 0, 1),
pin_boundary=pin_boundary,
)
def _relax_seam(group, geometry, seam_field, smooth):
"""Relax the cut seam in plane through a two-ring falloff."""
return _apply_planar_relax(group, geometry, _falloff(group, seam_field), smooth)
return _relax_in_plane(group, geometry, _falloff(group, seam_field), smooth, pin_boundary=True)
def _relax_fill(group, geometry, boundary_field, smooth):
"""Smooth the junction band the wall builds from, including the symmetry axis."""
region = _falloff(group, boundary_field, FILL_SMOOTH_RINGS)
return _relax_in_plane(group, geometry, region, smooth, pin_boundary=False)
def _extrude_boundary(group, geometry, selection, y_factor):
@@ -271,7 +273,7 @@ def build_image_cutout_symmetry_group():
bool_input(group, 'Fill Sides', True, parent=options).description = 'Connect matching front and mirrored boundaries.'
smooth = interface_socket(group, 'Fill Smooth', 'INPUT', 'NodeSocketInt', options)
smooth.default_value, smooth.min_value, smooth.max_value = 2, 0, 16
smooth.description = 'Smooth the outline and the wall it builds through a two-ring falloff. Zero keeps the raw outline.'
smooth.description = 'Smooth the band around the front boundary and the symmetry seam over four rings. Zero keeps the raw outline.'
seam = interface_socket(group, 'Seam Smooth', 'INPUT', 'NodeSocketInt', options)
seam.default_value, seam.min_value, seam.max_value = 2, 0, 16
seam.description = 'Smooth the cut seam inside the symmetry plane. Zero keeps the raw seam.'
@@ -279,16 +281,21 @@ def build_image_cutout_symmetry_group():
nodes, links = group.nodes, group.links
inputs = group_input(nodes, set()).outputs
aligned = _align_front(group, inputs['Geometry'], inputs)
bounds = nodes.new('GeometryNodeBoundBox')
links.new(aligned, bounds.inputs['Geometry'])
size = nodes.new('ShaderNodeVectorMath')
size.operation = 'DISTANCE'
links.new(bounds.outputs['Min'], size.inputs[0])
links.new(bounds.outputs['Max'], size.inputs[1])
tolerance = _math(group, 'MULTIPLY', _math(group, 'MAXIMUM', size.outputs['Value'], 1e-8), 1e-7)
edge = nodes.new('GeometryNodeInputMeshEdgeVertices')
edge_length = nodes.new('ShaderNodeVectorMath')
edge_length.operation = 'DISTANCE'
links.new(edge.outputs['Position 1'], edge_length.inputs[0])
links.new(edge.outputs['Position 2'], edge_length.inputs[1])
statistics = nodes.new('GeometryNodeAttributeStatistic')
statistics.data_type, statistics.domain = 'FLOAT', 'EDGE'
links.new(aligned, statistics.inputs['Geometry'])
links.new(edge_length.outputs['Value'], statistics.inputs['Attribute'])
snap_tolerance = _math(
group, 'MULTIPLY', _math(group, 'MAXIMUM', statistics.outputs['Median'], 1e-6), 1e-6,
)
is_boundary = _boundary_edge_field(group)
seam_field, outline_field = _cut_fields(group, tolerance)
front = _fold_to_plane(group, aligned, tolerance)
boundary_field, seam_field = _boundary_fields(group, snap_tolerance)
front = _fold_to_plane(group, aligned, snap_tolerance)
shade = nodes.new('GeometryNodeSetShadeSmooth')
shade.domain = 'FACE'
links.new(front, shade.inputs['Geometry'])
@@ -298,9 +305,9 @@ def build_image_cutout_symmetry_group():
boundary.operation = 'AND'
links.new(is_boundary, boundary.inputs[0])
# The seam sits on the plane and welds through the merge, so only off-plane edges need walls.
links.new(_off_plane_edge_field(group, tolerance), boundary.inputs[1])
links.new(_off_plane_edge_field(group, snap_tolerance), boundary.inputs[1])
fill_smooth = _math(group, 'MULTIPLY', inputs['Fill Smooth'], inputs['Fill Sides'])
front = _apply_planar_relax(group, front, _falloff(group, outline_field), fill_smooth)
front = _relax_fill(group, front, boundary_field, fill_smooth)
walled = _build_wall(group, front, boundary.outputs[0])
wall_shade = nodes.new('GeometryNodeSetShadeSmooth')
wall_shade.domain = 'FACE'
@@ -320,13 +327,37 @@ def build_image_cutout_symmetry_group():
join = nodes.new('GeometryNodeJoinGeometry')
for geometry in (body.outputs[0], mark_back.outputs['Geometry']):
links.new(geometry, join.inputs[0])
position = nodes.new('GeometryNodeInputPosition')
axis = nodes.new('ShaderNodeSeparateXYZ')
links.new(position.outputs[0], axis.inputs[0])
on_axis = _math(group, 'LESS_THAN', _math(group, 'ABSOLUTE', axis.outputs['Y']), snap_tolerance)
merge = nodes.new('GeometryNodeMergeByDistance')
links.new(tolerance, merge.inputs['Distance'])
merge.inputs['Distance'].default_value = 0.01
links.new(join.outputs[0], merge.inputs['Geometry'])
links.new(on_axis, merge.inputs['Selection'])
geometry = store_float_attribute(group, merge.outputs[0], AXIS_ATTRIBUTE, 1., domain='FACE')
orient = nodes.new('GeometryNodeTransform')
orient.inputs['Rotation'].default_value = Matrix.Rotation(1.5707963267948966, 4, 'Z').to_euler()
links.new(geometry, orient.inputs['Geometry'])
links.new(orient.outputs[0], nodes.new('NodeGroupOutput').inputs['Geometry'])
# Fade the material normal map near the symmetry plane so the weld shades smoother.
position = nodes.new('GeometryNodeInputPosition')
components = nodes.new('ShaderNodeSeparateXYZ')
links.new(position.outputs[0], components.inputs[0])
ramp = nodes.new('ShaderNodeMapRange')
ramp.interpolation_type = 'SMOOTHSTEP'
ramp.clamp = True
links.new(_math(group, 'ABSOLUTE', components.outputs['X']), ramp.inputs['Value'])
ramp.inputs['From Min'].default_value = 0.0
links.new(_math(group, 'MULTIPLY', statistics.outputs['Median'], SEAM_NORMAL_BAND), ramp.inputs['From Max'])
ramp.inputs['To Min'].default_value = 1.0
ramp.inputs['To Max'].default_value = 0.0
reduction = _math(
group, 'MAXIMUM',
read_float_attribute(group, NORMAL_REDUCTION_ATTRIBUTE_NAME), ramp.outputs['Result'],
)
geometry = store_float_attribute(
group, orient.outputs[0], NORMAL_REDUCTION_ATTRIBUTE_NAME, reduction,
)
links.new(geometry, nodes.new('NodeGroupOutput').inputs['Geometry'])
prepare_node_group(group)
return group

View File

@@ -276,10 +276,9 @@ def _build_depth_surface(group, geometry, controls):
)
geometry = remove_boundary_triangles(group, geometry)
boundary = edge_boundary_field(group.nodes, group.links)
outline = evaluate_field(group, boundary, "BOOLEAN", "POINT")
rim = evaluate_field(group, boundary, "FLOAT", "POINT")
camera = build_surface_camera(
group, _position_field(group, image), corner_camera, cut_vertex, split, always_vertex=outline,
group, _position_field(group, image), corner_camera, cut_vertex, split,
)
projection = project_depth_surface(
group, geometry, camera, scale, controls.outputs["Reference Depth"], controls.outputs["Depth Scale"],

View File

@@ -4,7 +4,7 @@ import math
import bpy
from ..common.validity import delete_invalid_points
from ..common.validity import delete_invalid_faces
from ..common.depth_surface import (
_math, split_depth_surface, build_surface_camera,
@@ -138,7 +138,7 @@ def build_image_depth_panorama_group():
mixed = _math(group, "MULTIPLY", any_invalid,
_math(group, "LESS_THAN", face_invalid, 1))
protected = uv_map.outputs["Geometry"]
culled = delete_invalid_points(group, protected, any_invalid)
culled = delete_invalid_faces(group, protected, any_invalid)
delete = nodes.new("GeometryNodeDeleteGeometry")
delete.domain, delete.mode = "FACE", "ALL"
links.new(protected, delete.inputs["Geometry"])

View File

@@ -2,7 +2,7 @@
import bpy
from ..common.validity import delete_invalid_points
from ..common.validity import delete_invalid_faces
from ..common.nodes import group_input as _group_input, interface_socket as _interface_socket
from ..common.nodes import prepare_node_group, float_input, evaluate_field
@@ -107,7 +107,7 @@ def build_image_depth_plane_group(image_plane_group):
_math(group, "MINIMUM", _math(group, "SUBTRACT", 1, u), _math(group, "SUBTRACT", 1, v)))
original_boundary = evaluate_field(group, _math(group, "LESS_THAN", margin, 1e-6), "BOOLEAN", "POINT")
protected = plane.outputs["Geometry"]
culled = delete_invalid_points(group, protected, invalid.outputs["Result"])
culled = delete_invalid_faces(group, protected, invalid.outputs["Result"])
cull_controls = _group_input(nodes, {"Depth Mask"})
cull = nodes.new("GeometryNodeSwitch")
cull.input_type = "GEOMETRY"

View File

@@ -19,7 +19,7 @@ def build_image_layer_group():
input_sockets.insert(4, ('Object Space', 'NodeSocketBool'))
input_sockets.insert(2, ('Alpha Fix', 'NodeSocketFloat'))
options = interface.new_panel(name='Options', default_closed=True)
option_names = {'Alpha Fix', 'Normal Scale', 'Object Space'}
option_names = {'Alpha Fix', 'Object Space'}
for name, socket_type in input_sockets:
socket = interface.new_socket(name=name, in_out='INPUT', socket_type=socket_type, parent=options if name in option_names else None)
if name in {'Alpha', 'Alpha Fix'}: