Files
AnyImage/tools/nodes/groups/image_depth_cutout.py
nan 2811c7bf3a Refactor depth surface and image cutout nodes for improved functionality and clarity
- Removed deprecated tests and code related to shell wall connections and coincident sheets.
- Updated attribute handling in depth surface and image cutout groups, including new vector attribute functions.
- Enhanced the inflation logic in the image depth cutout group to better manage shell and balloon behaviors.
- Improved parameter naming and descriptions for clarity in the user interface.
- Streamlined the geometry processing logic to ensure better performance and maintainability.
2026-09-17 17:11:31 +08:00

241 lines
12 KiB
Python

"""Build O Image Depth Cutout."""
import bpy
from anyimage.operators.cutout_tool.shape import NORMAL_REDUCTION_ATTRIBUTE_NAME
from ..common.nodes import (
interface_socket, group_input, float_input, read_float_attribute, read_vector_attribute,
store_float_attribute, store_vector_attribute, remove_attribute_pattern,
prepare_node_group, evaluate_field, compare_node,
)
from ..common.boundary_smoothing import smooth_cut_boundary
from ..common.cutout import _position_field, _shade_output
from ..common.depth_surface import _math, project_depth_surface, build_surface_camera, split_depth_surface, sample_face_camera
from ..common.smoothing import edge_boundary_field
from ..common.cutout_boundary import original_boundary_field, remove_boundary_triangles, taper_split_profile
NODE_GROUP_NAME = "O Image Depth Cutout"
SOLID_NORMAL_ATTRIBUTE = "_o_solid_normal"
BALLOON_THICKNESS_SCALE = 2.0
FRONT_INFLATION_SCALE = 0.5
def build_normal_weight(group, is_shell):
"""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"])
amount.inputs["True"].default_value = 1
ramp = group.nodes.new("ShaderNodeMapRange")
ramp.interpolation_type = "SMOOTHSTEP"
ramp.clamp = True
group.links.new(amount.outputs[0], ramp.inputs["Value"])
return ramp.outputs["Result"]
def blend_normal(group, original, smoothed, weight):
"""Normalize the thickness-weighted blend on the consumer geometry."""
mix = group.nodes.new("ShaderNodeMix")
mix.data_type = "VECTOR"
if isinstance(weight, (int, float)):
mix.inputs[0].default_value = weight
else:
group.links.new(weight, mix.inputs[0])
group.links.new(original, mix.inputs[4])
group.links.new(smoothed, mix.inputs[5])
normalize = group.nodes.new("ShaderNodeVectorMath")
normalize.operation = "NORMALIZE"
group.links.new(mix.outputs["Result"], normalize.inputs[0])
return normalize.outputs[0]
def build_front_normal(group, normal_weight):
"""Smooth the projected surface normals that drive the inflation."""
links = group.links
normal = group.nodes.new("GeometryNodeInputNormal")
blur = group.nodes.new("GeometryNodeBlurAttribute")
blur.data_type = "FLOAT_VECTOR"
links.new(normal.outputs[0], blur.inputs["Value"])
links.new(group_input(group.nodes, {"Normal Smooth"}).outputs["Normal Smooth"], blur.inputs["Iterations"])
return blend_normal(group, normal.outputs[0], blur.outputs[0], normal_weight)
def build_inflation_amount(group, controls, is_shell):
"""Balloon scales the profile, Shell moves a fixed distance."""
nodes, links = group.nodes, group.links
thickness = _math(
group, "MULTIPLY", controls.outputs["Thickness"], BALLOON_THICKNESS_SCALE,
)
balloon = _math(
group, "MULTIPLY", read_float_attribute(group, "o_balloon"), thickness,
)
amount = nodes.new("GeometryNodeSwitch")
amount.input_type = "FLOAT"
links.new(is_shell, amount.inputs["Switch"])
links.new(balloon, amount.inputs["False"])
links.new(controls.outputs["Shell Thickness"], amount.inputs["True"])
return amount.outputs[0]
def build_solid(group, surface, amount, normal, is_shell, controls, weld_distance):
"""Inflate the projected surface into a front leaf, wall and rear leaf."""
nodes, links = group.nodes, group.links
def offset(distance):
result = nodes.new("ShaderNodeVectorMath")
result.operation = "SCALE"
links.new(normal, result.inputs[0])
links.new(distance, result.inputs[3])
return result.outputs[0]
inflation = _math(
group, "MULTIPLY", amount,
_math(group, "MULTIPLY", controls.outputs["Front Inflation"], FRONT_INFLATION_SCALE),
)
front_amount = nodes.new("GeometryNodeSwitch")
front_amount.input_type = "FLOAT"
links.new(is_shell, front_amount.inputs["Switch"])
links.new(inflation, front_amount.inputs["False"])
front_amount.inputs["True"].default_value = 0.0
front = nodes.new("GeometryNodeSetPosition")
links.new(surface, front.inputs["Geometry"])
links.new(offset(front_amount.outputs[0]), front.inputs["Offset"])
span = _math(group, "ADD", amount, front_amount.outputs[0])
span = _math(group, "MULTIPLY", span, -1.0)
extrude = nodes.new("GeometryNodeExtrudeMesh")
extrude.mode = "FACES"
extrude.inputs["Individual"].default_value = False
links.new(front.outputs["Geometry"], extrude.inputs["Mesh"])
links.new(offset(span), extrude.inputs["Offset"])
# Extruding against the normal leaves the rear leaf and wall facing inward.
flipped = nodes.new("GeometryNodeFlipFaces")
links.new(extrude.outputs["Mesh"], flipped.inputs["Mesh"])
rear = store_float_attribute(
group, flipped.outputs["Mesh"], NORMAL_REDUCTION_ATTRIBUTE_NAME, 1.0,
)
join = nodes.new("GeometryNodeJoinGeometry")
links.new(front.outputs["Geometry"], join.inputs["Geometry"])
links.new(rear, join.inputs["Geometry"])
merge = nodes.new("GeometryNodeMergeByDistance")
merge.mode = "ALL"
links.new(weld_distance, merge.inputs["Distance"])
links.new(join.outputs["Geometry"], merge.inputs["Geometry"])
return merge.outputs["Geometry"]
def _build_depth_surface(group, geometry, controls):
"""Project the depth image and inflate the projected surface into a solid."""
links = group.links
split = controls.outputs["Depth Split"]
image = controls.outputs["Depth Image"]
scale = controls.outputs["Uniform Scale"]
original = geometry
geometry, corner_camera, cut_vertex = split_depth_surface(
group, geometry, sample_face_camera(group, image), split, controls.outputs["Reference Depth"],
controls.outputs["Depth Scale"], triangles=True,
)
geometry = remove_boundary_triangles(group, geometry)
boundary = edge_boundary_field(group.nodes, group.links)
rim = evaluate_field(group, boundary, "FLOAT", "POINT")
camera = build_surface_camera(
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"],
)
projection = smooth_cut_boundary(group, projection, cut_vertex, boundary=rim)
# Clamped depth pixels can collapse adjacent outline samples after projection.
edge = group.nodes.new("GeometryNodeInputMeshEdgeVertices")
length = group.nodes.new("ShaderNodeVectorMath")
length.operation = "DISTANCE"
links.new(edge.outputs["Position 1"], length.inputs[0])
links.new(edge.outputs["Position 2"], length.inputs[1])
typical = group.nodes.new("GeometryNodeAttributeStatistic")
typical.data_type, typical.domain = "FLOAT", "EDGE"
links.new(projection, typical.inputs["Geometry"])
links.new(length.outputs["Value"], typical.inputs["Attribute"])
merge = group.nodes.new("GeometryNodeMergeByDistance")
merge.mode = "CONNECTED"
links.new(projection, merge.inputs["Geometry"])
links.new(_math(group, "MULTIPLY", typical.outputs["Mean"], 1e-6), merge.inputs["Distance"])
projection = merge.outputs["Geometry"]
projection = taper_split_profile(group, projection, original_boundary_field(group, original, rim))
# The mode field is shared by the geometry and thickness choices.
mode = group.nodes.new("GeometryNodeMenuSwitch")
mode.data_type = "BOOLEAN"
for item, name in zip(mode.enum_items, ("Balloon", "Shell")):
item.name = name
links.new(controls.outputs["Mode"], mode.inputs["Menu"])
mode.inputs["Balloon"].default_value = False
mode.inputs["Shell"].default_value = True
is_shell = mode.outputs[0]
amount = build_inflation_amount(group, controls, is_shell)
statistics = group.nodes.new("GeometryNodeAttributeStatistic")
statistics.data_type, statistics.domain = "FLOAT", "POINT"
links.new(projection, statistics.inputs["Geometry"])
links.new(amount, statistics.inputs["Attribute"])
has_thickness = compare_node(group, "GREATER_THAN", statistics.outputs["Max"], 1e-6)
zero_shell = group.nodes.new("GeometryNodeSwitch")
zero_shell.input_type = "GEOMETRY"
links.new(has_thickness, zero_shell.inputs["Switch"])
links.new(projection, zero_shell.inputs["False"])
normal = build_front_normal(group, build_normal_weight(group, is_shell))
# Both leaves must inflate along one normal, so it is carried across the
# extrusion instead of being evaluated again on the moved surface.
projection = store_vector_attribute(
group, projection, SOLID_NORMAL_ATTRIBUTE, normal,
)
solid = build_solid(
group, projection, amount, read_vector_attribute(group, SOLID_NORMAL_ATTRIBUTE),
is_shell, controls,
_math(group, "MULTIPLY", typical.outputs["Mean"], 1e-3),
)
links.new(solid, zero_shell.inputs["True"])
return remove_attribute_pattern(group, zero_shell.outputs[0], "_o_*")
def build_image_depth_cutout_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', 0.0, maximum=2.0).description = 'Control how much the surface bulges in Balloon mode. Zero removes the bulge.'
float_input(group, 'Shell Thickness', 0.0, maximum=1.0, subtype='DISTANCE').description = 'Add thickness behind the original surface while keeping the front surface in place.'
float_input(group, 'Depth Scale', 1.0, maximum=2.0)
float_input(group, 'Depth Split', 0.1, maximum=1, subtype='FACTOR').description = 'Split the mesh where depth changes abruptly. Scaled by Depth Scale: a flat projection stays whole, higher values detect smaller depth jumps.'
options = group.interface.new_panel(name='Options', default_closed=True)
boundary_smooth = interface_socket(group, 'Boundary Smooth', 'INPUT', 'NodeSocketInt', options)
boundary_smooth.default_value, boundary_smooth.min_value, boundary_smooth.max_value = 4, 0, 16
boundary_smooth.description = 'Smooth geometry near the original outline and Depth Split edges. Zero disables boundary smoothing.'
normal_smooth = interface_socket(group, 'Normal Smooth', 'INPUT', 'NodeSocketInt', options)
normal_smooth.default_value, normal_smooth.min_value, normal_smooth.max_value = 512, 16, 512
normal_smooth.description = 'Control smoothing of front and rear displacement normals. Higher values produce smoother directions.'
float_input(group, 'Front Inflation', 0.2, maximum=1, subtype='FACTOR', parent=options).description = 'Control how much the front surface bulges in Balloon mode, reaching half of the balloon thickness. Zero keeps the original front surface.'
float_input(group, 'Reference Depth', 1.0, subtype='DISTANCE', parent=options)
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())
geometry = store_float_attribute(group, controls.outputs['Geometry'], NORMAL_REDUCTION_ATTRIBUTE_NAME, 0.0)
geometry = _build_depth_surface(group, geometry, controls)
_shade_output(group, 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 = 'Balloon'
elif item.name == 'Shell Thickness':
item.name = 'Thickness'
return group