- Added a new proposal for boundary smoothing in the weight cutout process, differentiating between Split edges and Outline smoothing requirements. - Introduced a new capability `cutout-boundary-smoothing` to control smoothing strength and default values. - Updated specifications to define the behavior of boundary-specific smoothing strength and default settings. - Created tasks to verify the implementation of boundary behavior and asset updates. - Developed comprehensive tests for depth symmetry and normal scaling to ensure expected behavior and performance. - Introduced new attributes for normal mapping to facilitate persistent face attributes sharing between depth symmetry and image materials. - Built a new node group for O Image Depth Symmetry, integrating the new smoothing capabilities and ensuring compatibility with existing workflows.
223 lines
11 KiB
Python
223 lines
11 KiB
Python
"""Build O Image Cutout."""
|
|
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, menu_switch, read_float_attribute, store_float_attribute, prepare_node_group
|
|
from ..common.smoothing import expand_smooth
|
|
from ..common.cutout import _extrude_layers, _island_cleanup, _offset_geometry_z, _remove_zero_thickness, _set_layer_z, _shade_output
|
|
|
|
|
|
NODE_GROUP_NAME = "O Image Cutout"
|
|
|
|
|
|
def _set_segmented_layer_z(
|
|
group,
|
|
geometry,
|
|
front_z,
|
|
back_z,
|
|
thickness,
|
|
):
|
|
nodes = group.nodes
|
|
links = group.links
|
|
|
|
edge_vertices = nodes.new("GeometryNodeInputMeshEdgeVertices")
|
|
edge_length = nodes.new("ShaderNodeVectorMath")
|
|
edge_length.operation = "DISTANCE"
|
|
links.new(edge_vertices.outputs["Position 1"], edge_length.inputs[0])
|
|
links.new(edge_vertices.outputs["Position 2"], edge_length.inputs[1])
|
|
edge_neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors")
|
|
boundary = math_node(nodes, "COMPARE")
|
|
boundary.inputs[1].default_value = 1.0
|
|
boundary.inputs[2].default_value = 0.1
|
|
links.new(edge_neighbors.outputs["Face Count"], boundary.inputs[0])
|
|
statistics = nodes.new("GeometryNodeAttributeStatistic")
|
|
statistics.data_type = "FLOAT"
|
|
statistics.domain = "EDGE"
|
|
links.new(geometry, statistics.inputs["Geometry"])
|
|
links.new(boundary.outputs[0], statistics.inputs["Selection"])
|
|
links.new(edge_length.outputs["Value"], statistics.inputs["Attribute"])
|
|
safe_edge = math_node(nodes, "MAXIMUM")
|
|
safe_edge.inputs[1].default_value = 1e-6
|
|
links.new(statistics.outputs["Median"], safe_edge.inputs[0])
|
|
segment_ratio = math_node(nodes, "DIVIDE")
|
|
links.new(thickness, segment_ratio.inputs[0])
|
|
links.new(safe_edge.outputs[0], segment_ratio.inputs[1])
|
|
segment_count = nodes.new("FunctionNodeFloatToInt")
|
|
segment_count.rounding_mode = "CEILING"
|
|
links.new(segment_ratio.outputs[0], segment_count.inputs["Float"])
|
|
at_least_one = nodes.new("FunctionNodeIntegerMath")
|
|
at_least_one.operation = "MAXIMUM"
|
|
at_least_one.inputs[1].default_value = 1
|
|
links.new(segment_count.outputs["Integer"], at_least_one.inputs[0])
|
|
segment_limit = nodes.new("FunctionNodeIntegerMath")
|
|
segment_limit.operation = "MINIMUM"
|
|
segment_limit.inputs[1].default_value = 128
|
|
links.new(at_least_one.outputs[0], segment_limit.inputs[0])
|
|
step = math_node(nodes, "DIVIDE")
|
|
links.new(thickness, step.inputs[0])
|
|
links.new(segment_limit.outputs[0], step.inputs[1])
|
|
|
|
front = _offset_geometry_z(group, geometry, front_z)
|
|
repeat_output = nodes.new("GeometryNodeRepeatOutput")
|
|
repeat_input = nodes.new("GeometryNodeRepeatInput")
|
|
repeat_input.pair_with_output(repeat_output)
|
|
repeat_output.repeat_items.new("GEOMETRY", "Geometry")
|
|
repeat_output.repeat_items.new("GEOMETRY", "Sides")
|
|
links.new(segment_limit.outputs[0], repeat_input.inputs["Iterations"])
|
|
links.new(geometry, repeat_input.inputs["Geometry"])
|
|
negative_step = math_node(nodes, "MULTIPLY")
|
|
negative_step.inputs[1].default_value = -1.0
|
|
links.new(step.outputs[0], negative_step.inputs[0])
|
|
extrude = nodes.new("GeometryNodeExtrudeMesh")
|
|
extrude.mode = "FACES"
|
|
extrude.inputs["Individual"].default_value = False
|
|
extrude.inputs["Selection"].default_value = True
|
|
extrude.inputs["Offset"].default_value = (0.0, 0.0, 0.0)
|
|
links.new(negative_step.outputs[0], extrude.inputs["Offset Scale"])
|
|
links.new(repeat_input.outputs["Geometry"], extrude.inputs["Mesh"])
|
|
split_extrude = nodes.new("GeometryNodeSeparateGeometry")
|
|
split_extrude.domain = "FACE"
|
|
links.new(extrude.outputs["Mesh"], split_extrude.inputs["Geometry"])
|
|
links.new(extrude.outputs["Side"], split_extrude.inputs["Selection"])
|
|
join_sides = nodes.new("GeometryNodeJoinGeometry")
|
|
links.new(repeat_input.outputs["Sides"], join_sides.inputs["Geometry"])
|
|
links.new(split_extrude.outputs["Selection"], join_sides.inputs["Geometry"])
|
|
links.new(split_extrude.outputs["Inverted"], repeat_output.inputs["Geometry"])
|
|
links.new(join_sides.outputs["Geometry"], repeat_output.inputs["Sides"])
|
|
|
|
positioned_side = _offset_geometry_z(
|
|
group,
|
|
repeat_output.outputs["Sides"],
|
|
front_z,
|
|
)
|
|
flip_side = nodes.new("GeometryNodeFlipFaces")
|
|
links.new(positioned_side, flip_side.inputs["Mesh"])
|
|
|
|
back = _offset_geometry_z(group, geometry, back_z)
|
|
flip_back = nodes.new("GeometryNodeFlipFaces")
|
|
links.new(back, flip_back.inputs["Mesh"])
|
|
join = nodes.new("GeometryNodeJoinGeometry")
|
|
links.new(front, join.inputs["Geometry"])
|
|
links.new(flip_back.outputs["Mesh"], join.inputs["Geometry"])
|
|
links.new(flip_side.outputs["Mesh"], join.inputs["Geometry"])
|
|
merge = nodes.new("GeometryNodeMergeByDistance")
|
|
merge.mode = "ALL"
|
|
merge.inputs["Distance"].default_value = 1e-6
|
|
links.new(join.outputs["Geometry"], merge.inputs["Geometry"])
|
|
return merge.outputs["Geometry"]
|
|
|
|
|
|
def _build_shell(group, geometry, thickness):
|
|
nodes = group.nodes
|
|
links = group.links
|
|
front = math_node(nodes, "MULTIPLY")
|
|
front.inputs[1].default_value = 0.0
|
|
links.new(thickness, front.inputs[0])
|
|
back = math_node(nodes, "MULTIPLY")
|
|
back.inputs[1].default_value = -1.0
|
|
links.new(thickness, back.inputs[0])
|
|
shaped = _set_segmented_layer_z(
|
|
group,
|
|
geometry,
|
|
front.outputs[0],
|
|
back.outputs[0],
|
|
thickness,
|
|
)
|
|
return _remove_zero_thickness(group, geometry, shaped, thickness)
|
|
|
|
|
|
def _build_balloon(group, geometry, thickness):
|
|
nodes = group.nodes
|
|
links = group.links
|
|
profile = read_float_attribute(group, 'o_balloon')
|
|
front_scale = math_node(nodes, "MULTIPLY")
|
|
links.new(profile, front_scale.inputs[0])
|
|
links.new(thickness, front_scale.inputs[1])
|
|
negative_back = math_node(nodes, "MULTIPLY")
|
|
negative_back.inputs[1].default_value = -1.0
|
|
links.new(front_scale.outputs[0], negative_back.inputs[0])
|
|
extrude = _extrude_layers(group, geometry)
|
|
shaped = _set_layer_z(
|
|
group,
|
|
geometry,
|
|
extrude,
|
|
front_scale.outputs[0],
|
|
negative_back.outputs[0],
|
|
)
|
|
return _remove_zero_thickness(group, geometry, shaped, thickness)
|
|
|
|
|
|
def build_image_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', 1.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.'
|
|
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)
|
|
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.'
|
|
interface_socket(group, 'Depth Axis', 'INPUT', 'NodeSocketMenu', options).menu_expanded = True
|
|
interface_socket(group, 'Geometry', 'OUTPUT', 'NodeSocketGeometry')
|
|
controls = group_input(group.nodes, set())
|
|
balloon = _build_balloon(group, controls.outputs['Geometry'], controls.outputs['Thickness'])
|
|
shell = _build_shell(group, controls.outputs['Geometry'], controls.outputs['Shell Thickness'])
|
|
choose = menu_switch(group, 'Mode', ('Balloon', 'Shell'), 'GEOMETRY')
|
|
group.links.new(balloon, choose.inputs['Balloon'])
|
|
group.links.new(shell, choose.inputs['Shell'])
|
|
geometry = choose.outputs[0]
|
|
planar_inputs = [link.to_socket for link in group.links if link.from_node.bl_idname == 'NodeGroupInput' and link.from_socket.name == 'Geometry']
|
|
base = group_input(group.nodes, {'Geometry', 'Cleanup Threshold'})
|
|
area = math_node(group.nodes, 'MULTIPLY')
|
|
area.inputs[1].default_value = 0.01
|
|
group.links.new(base.outputs['Cleanup Threshold'], area.inputs[0])
|
|
cleaned = _island_cleanup(group, base.outputs['Geometry'], area.outputs[0])
|
|
for target in planar_inputs:
|
|
group.links.new(cleaned, target)
|
|
geometry = expand_smooth(group, geometry, iterations_name='Smooth', weight_name='Smooth Weight')
|
|
profile = read_float_attribute(group, 'o_balloon')
|
|
amount = group_input(group.nodes, {'Thickness'})
|
|
enabled = math_node(group.nodes, 'GREATER_THAN')
|
|
enabled.inputs[1].default_value = 1e-06
|
|
group.links.new(amount.outputs['Thickness'], enabled.inputs[0])
|
|
inflated = group.nodes.new('GeometryNodeSwitch')
|
|
inflated.input_type = 'FLOAT'
|
|
inflated.inputs['False'].default_value = 1
|
|
group.links.new(enabled.outputs[0], inflated.inputs['Switch'])
|
|
group.links.new(profile, inflated.inputs['True'])
|
|
mode = menu_switch(group, 'Mode', ('Balloon', 'Shell'), 'FLOAT')
|
|
mode.inputs['Shell'].default_value = 1
|
|
group.links.new(inflated.outputs[0], mode.inputs['Balloon'])
|
|
scale = mode.outputs[0]
|
|
reduction = math_node(group.nodes, 'SUBTRACT')
|
|
reduction.inputs[0].default_value = 1.0
|
|
group.links.new(scale, reduction.inputs[1])
|
|
geometry = store_float_attribute(group, geometry, NORMAL_REDUCTION_ATTRIBUTE_NAME, reduction.outputs[0])
|
|
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'])
|
|
group.links.new(orient.outputs[0], axis.inputs['+X'])
|
|
_shade_output(group, axis.outputs[0])
|
|
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 == 'Depth Axis':
|
|
item.default_value = '-Z'
|
|
elif item.name == 'Shell Thickness':
|
|
item.name = 'Thickness'
|
|
return group
|