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:
187
tools/nodes/groups/image_cutout_symmetry.py
Normal file
187
tools/nodes/groups/image_cutout_symmetry.py
Normal 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
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user