feat: Implement depth cutout symmetry and associated functionalities

- Introduced a new proposal for depth cutout symmetry, allowing for the reuse of depth replacement and geometry operations.
- Added requirements for maintaining default thickness values for cutout nodes and ensuring proper initialization during object creation.
- Enhanced symmetry node to handle existing geometry, including alignment, mirroring, and boundary filling.
- Implemented fill smoothing to control the geometry of generated walls while preserving original surfaces.
- Created tests to validate the functionality of the new symmetry features against baseline geometry.
- Updated existing assets and node groups to support the new symmetry capabilities without introducing additional dependencies.
This commit is contained in:
2026-09-14 14:58:48 +08:00
parent dc000e50b1
commit ab282e4fdf
28 changed files with 716 additions and 689 deletions

View File

@@ -53,6 +53,7 @@
"GeometryNodeRepeatOutput": 3,
"GeometryNodeSampleIndex": 3,
"GeometryNodeSampleNearest": 1,
"GeometryNodeProximity": 1,
"GeometryNodeSampleNearestSurface": 1,
"GeometryNodeSeparateGeometry": 1,
"GeometryNodeSetMaterialIndex": 0,

View File

@@ -9,7 +9,7 @@ from .groups.image_depth_plane import build_image_depth_plane_group
from .groups.image_relief_plane import build_image_relief_plane_group
from .groups.image_cutout import build_image_cutout_group
from .groups.image_depth_cutout import build_image_depth_cutout_group
from .groups.image_depth_symmetry import build_image_depth_symmetry_group
from .groups.image_cutout_symmetry import build_image_cutout_symmetry_group
from .groups.image_layer import build_image_layer_group
from .groups.image_depth_layer import build_image_depth_layer_group
from .groups.shadeless import build_shadeless_group
@@ -29,7 +29,7 @@ def build_node_groups():
build_image_relief_plane_group(image_plane),
build_image_cutout_group(),
build_image_depth_cutout_group(),
build_image_depth_symmetry_group(),
build_image_cutout_symmetry_group(),
build_image_layer_group(),
build_image_depth_layer_group(),
build_shadeless_group(),

View File

@@ -9,7 +9,7 @@ ASSET_PATH = Path(__file__).resolve().parents[2] / "src/anyimage/assets/O_AnyIma
GEOMETRY_NAMES = {
"O Image Depth Panorama",
"O Image Plane", "O Image Depth Plane", "O Image Relief Plane",
"O Image Cutout", "O Image Depth Cutout", "O Image Depth Symmetry",
"O Image Cutout", "O Image Depth Cutout", "O Image Cutout Symmetry",
}
SHADER_NAMES = {"O Image Layer", "O Image Depth Layer", "O Shadeless"}
@@ -60,8 +60,8 @@ def check_asset_library(asset_path):
values = {"Subdivide": 0, "Thickness": 0.2, "Reference Depth": 2.0,
"Depth Image": depth_image, "Uniform Scale": 1.0, "Normal Smooth": 0,
"Depth Split": 0.0}
if node_group.name == "O Image Depth Symmetry":
values["Reference Depth"] = 2.5
if node_group.name == "O Image Cutout Symmetry":
values["Depth Offset"] = 0.5
for item in node_group.interface.items_tree:
if item.item_type == "SOCKET" and item.in_out == "INPUT" and item.name in values:
modifier[item.identifier] = values[item.name]

View File

@@ -0,0 +1,187 @@
"""Mirror completed depth geometry and fill matching boundaries."""
import bpy
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,
)
from ..common.depth_surface import _math
from ..common.normal_map import ROTATION_ATTRIBUTE, FACE_ATTRIBUTE, AXIS_ATTRIBUTE
NODE_GROUP_NAME = "O Image Cutout Symmetry"
def _align_front(group, geometry, controls):
"""Align existing geometry with the fixed XY symmetry plane."""
nodes, links = group.nodes, group.links
align = nodes.new('FunctionNodeAlignEulerToVector')
align.axis = 'Z'
links.new(controls['Symmetry Direction'], align.inputs['Vector'])
position = nodes.new('GeometryNodeInputPosition')
rotate = nodes.new('ShaderNodeVectorRotate')
rotate.rotation_type = 'EULER_XYZ'
rotate.invert = True
links.new(position.outputs[0], rotate.inputs['Vector'])
links.new(align.outputs['Rotation'], rotate.inputs['Rotation'])
place = nodes.new('GeometryNodeSetPosition')
links.new(geometry, place.inputs['Geometry'])
links.new(rotate.outputs[0], place.inputs['Position'])
offset = nodes.new('ShaderNodeCombineXYZ')
links.new(controls['Depth Offset'], offset.inputs['Z'])
links.new(offset.outputs[0], place.inputs['Offset'])
store = nodes.new('GeometryNodeStoreNamedAttribute')
store.data_type, store.domain = 'FLOAT_VECTOR', 'FACE'
store.inputs['Name'].default_value = ROTATION_ATTRIBUTE
links.new(place.outputs[0], store.inputs['Geometry'])
links.new(align.outputs['Rotation'], store.inputs['Value'])
return store.outputs['Geometry']
def _delete_negative_faces(group, front, tolerance):
"""Discard faces touching negative Z without leaving wires or isolated vertices."""
position = group.nodes.new('GeometryNodeInputPosition')
components = group.nodes.new('ShaderNodeSeparateXYZ')
group.links.new(position.outputs[0], components.inputs[0])
negative = _math(group, 'LESS_THAN', components.outputs['Z'], tolerance)
point_negative = evaluate_field(group, negative, 'FLOAT', 'POINT')
face_negative = evaluate_field(group, point_negative, 'FLOAT', 'FACE')
delete = group.nodes.new('GeometryNodeDeleteGeometry')
delete.domain = 'FACE'
delete.mode = 'ALL'
group.links.new(front, delete.inputs['Geometry'])
group.links.new(_math(group, 'GREATER_THAN', face_negative, 0), delete.inputs['Selection'])
return delete.outputs[0]
def _mirror(group, geometry):
mirror = group.nodes.new('GeometryNodeTransform')
mirror.inputs['Scale'].default_value = (1, 1, -1)
group.links.new(geometry, mirror.inputs['Geometry'])
flip = group.nodes.new('GeometryNodeFlipFaces')
group.links.new(mirror.outputs[0], flip.inputs['Mesh'])
return flip.outputs[0]
def _extrude_boundary(group, geometry, selection, z_factor):
"""Extrude selected edges to a fraction of their current height."""
nodes, links = group.nodes, group.links
extrude = nodes.new('GeometryNodeExtrudeMesh')
extrude.mode = 'EDGES'
extrude.inputs['Offset Scale'].default_value = 0
links.new(geometry, extrude.inputs['Mesh'])
links.new(selection, extrude.inputs['Selection'])
position = nodes.new('GeometryNodeInputPosition')
scale = nodes.new('ShaderNodeVectorMath')
scale.operation = 'MULTIPLY'
scale.inputs[1].default_value = (1, 1, z_factor)
links.new(position.outputs[0], scale.inputs[0])
place = nodes.new('GeometryNodeSetPosition')
links.new(extrude.outputs['Mesh'], place.inputs['Geometry'])
links.new(extrude.outputs['Top'], place.inputs['Selection'])
links.new(scale.outputs[0], place.inputs['Position'])
store = nodes.new('GeometryNodeStoreNamedAttribute')
store.data_type, store.domain = 'FLOAT', 'FACE'
store.inputs['Name'].default_value = FACE_ATTRIBUTE
store.inputs['Value'].default_value = 3
links.new(place.outputs[0], store.inputs['Geometry'])
links.new(extrude.outputs['Side'], store.inputs['Selection'])
return store.outputs[0], extrude.outputs['Top']
def _walls(group, front, boundary, smooth, tolerance, *, curved):
"""Build straight walls or a smoothed half-strip followed by its reflection."""
nodes, links = group.nodes, group.links
geometry, top = _extrude_boundary(group, front, boundary, .5 if curved else -1)
if curved:
geometry, _ = _extrude_boundary(group, geometry, top, 0)
sides = nodes.new('GeometryNodeSeparateGeometry')
sides.domain = 'FACE'
links.new(geometry, sides.inputs['Geometry'])
links.new(_math(group, 'COMPARE', read_float_attribute(group, FACE_ATTRIBUTE), 3), sides.inputs['Selection'])
geometry = sides.outputs['Selection']
if curved:
# Query the retained front to pin the original seam; only new ring XY moves.
proximity = nodes.new('GeometryNodeProximity')
proximity.target_element = 'POINTS'
links.new(front, proximity.inputs['Target'])
movable = _math(group, 'GREATER_THAN', proximity.outputs['Distance'], tolerance)
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'])
# Keep heights fixed so neither the seam nor the symmetry plane can drift.
difference = nodes.new('ShaderNodeVectorMath')
difference.operation = 'SUBTRACT'
links.new(blur.outputs[0], difference.inputs[0])
links.new(position.outputs[0], difference.inputs[1])
mask = nodes.new('ShaderNodeVectorMath')
mask.operation = 'MULTIPLY'
mask.inputs[1].default_value = (.5, .5, 0)
links.new(difference.outputs[0], mask.inputs[0])
place = nodes.new('GeometryNodeSetPosition')
links.new(geometry, place.inputs['Geometry'])
links.new(movable, place.inputs['Selection'])
links.new(mask.outputs[0], place.inputs['Offset'])
join = nodes.new('GeometryNodeJoinGeometry')
links.new(place.outputs[0], join.inputs[0])
links.new(_mirror(group, place.outputs[0]), join.inputs[0])
geometry = join.outputs[0]
shade = nodes.new('GeometryNodeSetShadeSmooth')
shade.domain = 'FACE'
shade.inputs['Shade Smooth'].default_value = curved
links.new(geometry, shade.inputs['Geometry'])
return shade.outputs[0]
def build_image_cutout_symmetry_group():
existing = bpy.data.node_groups.get(NODE_GROUP_NAME)
if existing is not None:
return existing
group = bpy.data.node_groups.new(NODE_GROUP_NAME, 'GeometryNodeTree')
group.is_modifier, group.use_fake_user = True, True
group.color_tag = 'GEOMETRY'
interface_socket(group, 'Geometry', 'INPUT', 'NodeSocketGeometry')
vector_input(group, 'Symmetry Direction', (0, 0, 1), subtype='DIRECTION').description = 'Align this direction with the local symmetry axis.'
float_input(group, 'Depth Offset', 0, minimum=-1e10, subtype='DISTANCE').description = 'Offset the aligned geometry along the symmetry axis.'
bool_input(group, 'Fill Sides', True).description = 'Connect matching front and mirrored boundaries.'
smooth = interface_socket(group, 'Fill Smooth', 'INPUT', 'NodeSocketInt')
smooth.default_value, smooth.min_value, smooth.max_value = 2, 0, 20
smooth.description = 'Smooth only the generated wall interiors. Zero keeps straight walls.'
interface_socket(group, 'Geometry', 'OUTPUT', 'NodeSocketGeometry')
nodes, links = group.nodes, group.links
inputs = group_input(nodes, set()).outputs
bounds = nodes.new('GeometryNodeBoundBox')
links.new(inputs['Geometry'], 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)
front = _delete_negative_faces(group, _align_front(group, inputs['Geometry'], inputs), tolerance)
shade = nodes.new('GeometryNodeSetShadeSmooth')
shade.domain = 'FACE'
links.new(front, shade.inputs['Geometry'])
front = store_float_attribute(group, shade.outputs[0], FACE_ATTRIBUTE, 1., domain='FACE')
count = nodes.new('GeometryNodeInputMeshEdgeNeighbors')
boundary = _math(group, 'COMPARE', count.outputs['Face Count'], 1)
walls = nodes.new('GeometryNodeSwitch')
walls.input_type = 'GEOMETRY'
links.new(_math(group, 'GREATER_THAN', inputs['Fill Smooth'], 0), walls.inputs['Switch'])
for curved, socket in ((False, 'False'), (True, 'True')):
links.new(_walls(group, front, boundary, inputs['Fill Smooth'], tolerance, curved=curved), walls.inputs[socket])
enabled = nodes.new('GeometryNodeSwitch')
enabled.input_type = 'GEOMETRY'
links.new(inputs['Fill Sides'], enabled.inputs['Switch'])
links.new(walls.outputs[0], enabled.inputs['True'])
back = store_float_attribute(group, _mirror(group, front), FACE_ATTRIBUTE, 2., domain='FACE')
join = nodes.new('GeometryNodeJoinGeometry')
for geometry in (front, back, enabled.outputs[0]):
links.new(geometry, join.inputs[0])
merge = nodes.new('GeometryNodeMergeByDistance')
links.new(tolerance, merge.inputs['Distance'])
links.new(join.outputs[0], merge.inputs['Geometry'])
geometry = store_float_attribute(group, merge.outputs[0], AXIS_ATTRIBUTE, 0., domain='FACE')
links.new(geometry, nodes.new('NodeGroupOutput').inputs['Geometry'])
prepare_node_group(group)
return group

View File

@@ -15,15 +15,13 @@ NODE_GROUP_NAME = "O Image Depth Cutout"
def build_normal_weight(group, is_shell):
"""Scale normal smoothing by the active mode's relative thickness."""
control = group_input(group.nodes, {"Thickness", "Shell Thickness", "Reference Depth"})
"""Use full shell smoothing and thickness-weighted balloon smoothing."""
control = group_input(group.nodes, {"Thickness"})
amount = group.nodes.new("GeometryNodeSwitch")
amount.input_type = "FLOAT"
group.links.new(is_shell, amount.inputs["Switch"])
group.links.new(control.outputs["Thickness"], amount.inputs["False"])
ratio = _math(group, "DIVIDE", control.outputs["Shell Thickness"],
_math(group, "MAXIMUM", control.outputs["Reference Depth"], 1e-8))
group.links.new(ratio, amount.inputs["True"])
amount.inputs["True"].default_value = 1
ramp = group.nodes.new("ShaderNodeMapRange")
ramp.interpolation_type = "SMOOTHSTEP"
ramp.clamp = True
@@ -343,8 +341,8 @@ def build_image_depth_cutout_group():
group.color_tag = 'GEOMETRY'
interface_socket(group, 'Geometry', 'INPUT', 'NodeSocketGeometry')
interface_socket(group, 'Mode', 'INPUT', 'NodeSocketMenu')
float_input(group, 'Thickness', 1.0).description = 'Control how much the surface bulges in Balloon mode. Zero removes the bulge.'
float_input(group, 'Shell Thickness', 0.5, subtype='DISTANCE').description = 'Add thickness behind the original surface while keeping the front surface in place.'
float_input(group, 'Thickness', 0.0).description = 'Control how much the surface bulges in Balloon mode. Zero removes the bulge.'
float_input(group, 'Shell Thickness', 0.0, subtype='DISTANCE').description = 'Add thickness behind the original surface while keeping the front surface in place.'
float_input(group, 'Depth Scale', 1.0)
float_input(group, 'Depth Split', 0.1, maximum=1, subtype='FACTOR').description = 'Split the mesh where depth changes abruptly. Higher values detect smaller depth jumps. Zero disables splitting.'
iterations = interface_socket(group, 'Smooth', 'INPUT', 'NodeSocketInt')

View File

@@ -1,289 +0,0 @@
"""Build O Image Depth Symmetry."""
import bpy
from mathutils import Matrix
from anyimage.operators.cutout_tool.shape import CUTOUT_Z_TO_X, NORMAL_REDUCTION_ATTRIBUTE_NAME
from ..common.nodes import (
interface_socket, group_input, math_node, float_input, bool_input, vector_input,
menu_switch, evaluate_field, read_float_attribute, store_float_attribute, prepare_node_group,
)
from ..common.smoothing import expand_smooth
from ..common.boundary_smoothing import smooth_cut_boundary
from ..common.depth_surface import _math, project_depth_surface
from ..common.reference_plane import reference_plane_depth
from ..common.normal_map import ROTATION_ATTRIBUTE, FACE_ATTRIBUTE, AXIS_ATTRIBUTE
from ..common.cutout import _extrude_layers, _island_cleanup, _position_field, _profile_field, _remove_zero_thickness, _set_layer_z
NODE_GROUP_NAME = "O Image Depth Symmetry"
def _set_geometry_z(group, geometry, z_value):
nodes = group.nodes
links = group.links
position = nodes.new("GeometryNodeInputPosition")
separate = nodes.new("ShaderNodeSeparateXYZ")
links.new(position.outputs["Position"], separate.inputs["Vector"])
combine = nodes.new("ShaderNodeCombineXYZ")
links.new(separate.outputs["X"], combine.inputs["X"])
links.new(separate.outputs["Y"], combine.inputs["Y"])
links.new(z_value, combine.inputs["Z"])
set_position = nodes.new("GeometryNodeSetPosition")
links.new(geometry, set_position.inputs["Geometry"])
links.new(combine.outputs["Vector"], set_position.inputs["Position"])
return set_position.outputs["Geometry"]
def _build_balloon(
group,
geometry,
thickness,
depth_scale,
double_sided,
uniform_scale,
depth_direction,
depth_image,
depth_reference,
depth_offset,
):
nodes = group.nodes
links = group.links
profile, profile_factor = _profile_field(group, geometry)
camera_position = _position_field(group, depth_image)
separate_camera = nodes.new("ShaderNodeSeparateXYZ")
links.new(camera_position, separate_camera.inputs["Vector"])
metric_depth = math_node(nodes, "MULTIPLY")
links.new(separate_camera.outputs["Z"], metric_depth.inputs[0])
links.new(uniform_scale, metric_depth.inputs[1])
plane_depth = reference_plane_depth(
group,
depth_direction,
depth_reference,
)
corrected_depth = math_node(nodes, "SUBTRACT")
links.new(plane_depth, corrected_depth.inputs[0])
links.new(metric_depth.outputs[0], corrected_depth.inputs[1])
nonnegative_depth = math_node(nodes, "MAXIMUM")
nonnegative_depth.inputs[1].default_value = 0.0
links.new(corrected_depth.outputs[0], nonnegative_depth.inputs[0])
scaled_depth = math_node(nodes, "MULTIPLY")
links.new(nonnegative_depth.outputs[0], scaled_depth.inputs[0])
links.new(depth_scale, scaled_depth.inputs[1])
front_depth = _math(group, 'MAXIMUM', _math(group, 'ADD', scaled_depth.outputs[0], depth_offset), 0.0)
base_front = math_node(nodes, "MULTIPLY")
links.new(profile, base_front.inputs[0])
links.new(thickness, base_front.inputs[1])
negate_back = math_node(nodes, "MULTIPLY")
negate_back.inputs[1].default_value = -1.0
links.new(base_front.outputs[0], negate_back.inputs[0])
front_mix = nodes.new("ShaderNodeMix")
front_mix.data_type = "FLOAT"
front_mix.clamp_factor = True
links.new(profile_factor, front_mix.inputs["Factor"])
links.new(base_front.outputs[0], front_mix.inputs["A"])
links.new(front_depth, front_mix.inputs["B"])
mirrored_front = math_node(nodes, "MULTIPLY")
mirrored_front.inputs[1].default_value = -1.0
links.new(front_mix.outputs["Result"], mirrored_front.inputs[0])
back_choice = nodes.new("GeometryNodeSwitch")
back_choice.input_type = "FLOAT"
links.new(double_sided, back_choice.inputs["Switch"])
links.new(negate_back.outputs[0], back_choice.inputs["False"])
links.new(mirrored_front.outputs[0], back_choice.inputs["True"])
extrude = _extrude_layers(group, geometry)
shaped = _set_layer_z(
group,
geometry,
extrude,
front_mix.outputs["Result"],
back_choice.outputs["Output"],
)
front_only = _set_geometry_z(
group, geometry, front_mix.outputs["Result"]
)
return _remove_zero_thickness(group, front_only, shaped, thickness)
def _build_projected_front(group, geometry, controls):
"""Project camera depth and align its direction with the fixed XY plane."""
nodes, links = group.nodes, group.links
camera = _position_field(group, controls['Depth Image'])
projected = project_depth_surface(
group, geometry, camera, controls['Uniform Scale'],
controls['Reference Depth'], controls['Depth Scale'], 0.0,
)
align = nodes.new('FunctionNodeAlignEulerToVector')
align.axis = 'Z'
links.new(controls['Depth Direction'], align.inputs['Vector'])
position = nodes.new('GeometryNodeInputPosition')
rotate = nodes.new('ShaderNodeVectorRotate')
rotate.rotation_type = 'EULER_XYZ'
rotate.invert = True
links.new(position.outputs[0], rotate.inputs['Vector'])
links.new(align.outputs['Rotation'], rotate.inputs['Rotation'])
place = nodes.new('GeometryNodeSetPosition')
links.new(projected, place.inputs['Geometry'])
links.new(rotate.outputs[0], place.inputs['Position'])
offset = nodes.new('ShaderNodeCombineXYZ')
links.new(controls['Depth Offset'], offset.inputs['Z'])
links.new(offset.outputs[0], place.inputs['Offset'])
store = nodes.new('GeometryNodeStoreNamedAttribute')
store.data_type, store.domain = 'FLOAT_VECTOR', 'FACE'
store.inputs['Name'].default_value = ROTATION_ATTRIBUTE
links.new(place.outputs[0], store.inputs['Geometry'])
links.new(align.outputs['Rotation'], store.inputs['Value'])
return store.outputs['Geometry']
def _delete_negative_faces(group, front, tolerance):
"""Discard faces touching negative Z without leaving wires or isolated vertices."""
position = group.nodes.new('GeometryNodeInputPosition')
components = group.nodes.new('ShaderNodeSeparateXYZ')
group.links.new(position.outputs[0], components.inputs[0])
negative = _math(group, 'LESS_THAN', components.outputs['Z'], tolerance)
point_negative = evaluate_field(group, negative, 'FLOAT', 'POINT')
face_negative = evaluate_field(group, point_negative, 'FLOAT', 'FACE')
delete = group.nodes.new('GeometryNodeDeleteGeometry')
delete.domain = 'FACE'
delete.mode = 'ALL'
group.links.new(front, delete.inputs['Geometry'])
group.links.new(_math(group, 'GREATER_THAN', face_negative, 0), delete.inputs['Selection'])
return delete.outputs[0]
def _build_straight_volume(group, front, double_sided, fill_sides, tolerance):
"""Mirror the positive surface and join matching boundary vertices with straight walls."""
nodes, links = group.nodes, group.links
shade = nodes.new('GeometryNodeSetShadeSmooth')
shade.domain = 'FACE'
links.new(front, shade.inputs['Geometry'])
front = shade.outputs[0]
front = store_float_attribute(group, front, FACE_ATTRIBUTE, 1., domain='FACE')
count = nodes.new('GeometryNodeInputMeshEdgeNeighbors')
boundary = _math(group, 'COMPARE', count.outputs['Face Count'], 1)
extrude = nodes.new('GeometryNodeExtrudeMesh')
extrude.mode = 'EDGES'
extrude.inputs['Offset'].default_value = (0, 0, 0)
extrude.inputs['Offset Scale'].default_value = 0
links.new(front, extrude.inputs['Mesh'])
links.new(boundary, extrude.inputs['Selection'])
position = nodes.new('GeometryNodeInputPosition')
components = nodes.new('ShaderNodeSeparateXYZ')
links.new(position.outputs[0], components.inputs[0])
mirror_point = nodes.new('ShaderNodeCombineXYZ')
links.new(components.outputs['X'], mirror_point.inputs['X'])
links.new(components.outputs['Y'], mirror_point.inputs['Y'])
links.new(_math(group, 'MULTIPLY', components.outputs['Z'], -1), mirror_point.inputs['Z'])
place = nodes.new('GeometryNodeSetPosition')
links.new(extrude.outputs['Mesh'], place.inputs['Geometry'])
links.new(extrude.outputs['Top'], place.inputs['Selection'])
links.new(mirror_point.outputs[0], place.inputs['Position'])
sides = nodes.new('GeometryNodeSeparateGeometry')
sides.domain = 'FACE'
links.new(place.outputs[0], sides.inputs['Geometry'])
links.new(extrude.outputs['Side'], sides.inputs['Selection'])
flat = nodes.new('GeometryNodeSetShadeSmooth')
flat.domain = 'FACE'
flat.inputs['Shade Smooth'].default_value = False
links.new(sides.outputs['Selection'], flat.inputs['Geometry'])
mirror = nodes.new('GeometryNodeTransform')
mirror.inputs['Scale'].default_value = (1, 1, -1)
links.new(front, mirror.inputs['Geometry'])
flip = nodes.new('GeometryNodeFlipFaces')
links.new(mirror.outputs[0], flip.inputs['Mesh'])
join = nodes.new('GeometryNodeJoinGeometry')
back = store_float_attribute(group, flip.outputs[0], FACE_ATTRIBUTE, 2., domain='FACE')
walls = store_float_attribute(group, flat.outputs[0], FACE_ATTRIBUTE, 3., domain='FACE')
wall_switch = nodes.new('GeometryNodeSwitch')
wall_switch.input_type = 'GEOMETRY'
links.new(fill_sides, wall_switch.inputs['Switch'])
links.new(walls, wall_switch.inputs['True'])
for geometry in (front, back, wall_switch.outputs[0]):
links.new(geometry, join.inputs[0])
merge = nodes.new('GeometryNodeMergeByDistance')
links.new(tolerance, merge.inputs['Distance'])
links.new(join.outputs[0], merge.inputs[0])
switch = nodes.new('GeometryNodeSwitch')
switch.input_type = 'GEOMETRY'
links.new(double_sided, switch.inputs['Switch'])
links.new(front, switch.inputs['False'])
links.new(merge.outputs[0], switch.inputs['True'])
return switch.outputs[0]
def build_image_depth_symmetry_group():
name = NODE_GROUP_NAME
existing = bpy.data.node_groups.get(name)
if existing is not None:
return existing
group = bpy.data.node_groups.new(name, 'GeometryNodeTree')
group.is_modifier, group.use_fake_user = (True, True)
group.color_tag = 'GEOMETRY'
interface_socket(group, 'Geometry', 'INPUT', 'NodeSocketGeometry')
interface_socket(group, 'Mode', 'INPUT', 'NodeSocketMenu')
float_input(group, 'Thickness', 1.0).description = 'Control the bulge in Balloon mode. Zero keeps only the front surface.'
float_input(group, 'Depth Scale', 1.0)
vector_input(group, 'Depth Direction', (0, 0, 1), subtype='DIRECTION')
float_input(group, 'Depth Offset', 0.0, minimum=-1e10, subtype='DISTANCE').description = 'Offset the front depth along the symmetry axis after direction adjustment. Positive values move it away from the symmetry plane.'
iterations = interface_socket(group, 'Smooth', 'INPUT', 'NodeSocketInt')
iterations.description = 'Set the number of mesh smoothing passes. Higher values smooth the surface more but may soften details. Zero disables smoothing.'
iterations.default_value = 0
iterations.min_value, iterations.max_value = (0, 20)
options = group.interface.new_panel(name='Options', default_closed=True)
bool_input(group, 'Double Sided', True, parent=options)
bool_input(group, 'Fill Sides', True, parent=options).description = 'Close the gap between the front and mirrored back with straight walls in Projected mode.'
float_input(group, 'Cleanup Threshold', 0.0, parent=options)
float_input(group, 'Smooth Weight', 1.0, maximum=1, subtype='FACTOR', parent=options).description = 'Control the strength of each mesh smoothing pass. Higher values move vertices more.'
float_input(group, 'Reference Depth', 1.0, subtype='DISTANCE', parent=options).description = 'Calibrate the source depth before direction adjustment.'
interface_socket(group, 'Depth Axis', 'INPUT', 'NodeSocketMenu', options).menu_expanded = True
boundary = interface_socket(group, 'Boundary Smooth', 'INPUT', 'NodeSocketInt', options)
boundary.default_value, boundary.min_value, boundary.max_value = 1, 0, 16
boundary.description = 'Smooth the projected outline before clipping and closing the surface.'
data = group.interface.new_panel(name='Data', default_closed=True)
float_input(group, 'Uniform Scale', 1.0, parent=data).hide_in_modifier = True
interface_socket(group, 'Depth Image', 'INPUT', 'NodeSocketImage', data).hide_in_modifier = True
interface_socket(group, 'Geometry', 'OUTPUT', 'NodeSocketGeometry')
controls = group_input(group.nodes, set())
inputs = controls.outputs
cleaned = _island_cleanup(
group, inputs['Geometry'], _math(group, 'MULTIPLY', inputs['Cleanup Threshold'], 0.01),
)
balloon = _build_balloon(
group, cleaned, inputs['Thickness'], inputs['Depth Scale'], inputs['Double Sided'],
inputs['Uniform Scale'], inputs['Depth Direction'], inputs['Depth Image'], inputs['Reference Depth'],
inputs['Depth Offset'],
)
balloon = expand_smooth(group, balloon, iterations_name='Smooth', weight_name='Smooth Weight')
shade = group.nodes.new('GeometryNodeSetShadeSmooth')
shade.domain = 'FACE'
group.links.new(balloon, shade.inputs['Geometry'])
tolerance = _math(group, 'MULTIPLY', _math(group, 'MAXIMUM', inputs['Uniform Scale'], 1e-8), 1e-7)
front = smooth_cut_boundary(group, _build_projected_front(group, cleaned, inputs))
front = _delete_negative_faces(group, front, tolerance)
front = expand_smooth(group, front, iterations_name='Smooth', weight_name='Smooth Weight')
projected = _build_straight_volume(group, front, inputs['Double Sided'], inputs['Fill Sides'], tolerance)
modes = menu_switch(group, 'Mode', ('Balloon', 'Projected'), 'GEOMETRY')
group.links.new(shade.outputs[0], modes.inputs['Balloon'])
group.links.new(projected, modes.inputs['Projected'])
geometry = modes.outputs[0]
profile = read_float_attribute(group, 'o_balloon')
reduction = _math(group, 'SUBTRACT', 1.0, profile)
geometry = store_float_attribute(group, geometry, NORMAL_REDUCTION_ATTRIBUTE_NAME, reduction)
geometry = store_float_attribute(group, geometry, AXIS_ATTRIBUTE, 0., domain='FACE')
orient = group.nodes.new('GeometryNodeTransform')
orient.inputs['Rotation'].default_value = Matrix(CUTOUT_Z_TO_X).to_euler()
group.links.new(geometry, orient.inputs['Geometry'])
axis = menu_switch(group, 'Depth Axis', ('-Z', '+X'), 'GEOMETRY')
group.links.new(geometry, axis.inputs['-Z'])
oriented = store_float_attribute(group, orient.outputs[0], AXIS_ATTRIBUTE, 1., domain='FACE')
group.links.new(oriented, axis.inputs['+X'])
output = group.nodes.new('NodeGroupOutput')
group.links.new(axis.outputs[0], output.inputs['Geometry'])
prepare_node_group(group)
for item in group.interface.items_tree:
if item.item_type != 'SOCKET' or item.in_out != 'INPUT':
continue
if item.name == 'Mode':
item.default_value = 'Projected'
elif item.name == 'Depth Axis':
item.default_value = '-Z'
return group