Files
AnyImage/tools/nodes/groups/image_cutout.py
nan 89087fc2e2 feat: Add new Geometry Nodes groups for image relief and shadeless shader
- Implemented the "O Image Relief Plane" Geometry Nodes group to create a fixed-base image relief using depth images and various parameters.
- Created the "O Shadeless" shader node group to display color without scene lighting while preserving shadows and reflections.
- Introduced a preview feature for node groups, allowing for interactive visualization of node arrangements in an HTML format.
- Added a template HTML file for rendering node previews with customizable views and controls.
2026-09-09 21:24:57 +08:00

224 lines
10 KiB
Python

"""Build O Image Cutout."""
import bpy
from mathutils import Matrix
from anyimage.operators.cutout_tool.shape import CUTOUT_Z_TO_X, NORMAL_SCALE_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, _profile_field, _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_cutout(group, geometry, thickness):
nodes = group.nodes
links = group.links
half = math_node(nodes, "MULTIPLY")
half.inputs[1].default_value = 0.5
links.new(thickness, half.inputs[0])
back = math_node(nodes, "MULTIPLY")
back.inputs[1].default_value = -1.0
links.new(half.outputs[0], back.inputs[0])
shaped = _set_segmented_layer_z(
group,
geometry,
half.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, _profile_factor = _profile_field(group, geometry)
front_scale = math_node(nodes, "MULTIPLY")
links.new(profile, front_scale.inputs[0])
links.new(thickness, front_scale.inputs[1])
back = math_node(nodes, "MULTIPLY")
links.new(profile, back.inputs[0])
links.new(thickness, back.inputs[1])
negative_back = math_node(nodes, "MULTIPLY")
negative_back.inputs[1].default_value = -1.0
links.new(back.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'
group['anyimage_asset_revision'] = 11
interface_socket(group, 'Geometry', 'INPUT', 'NodeSocketGeometry')
interface_socket(group, 'Mode', 'INPUT', 'NodeSocketMenu')
float_input(group, 'Thickness', 1.0).description = 'Balloon profile multiplier; zero returns the sheet'
float_input(group, 'Uniform Thickness', 0.0, subtype='DISTANCE').description = 'Distance used only by Uniform thickness mode'
iterations = interface_socket(group, 'Smooth', 'INPUT', 'NodeSocketInt')
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)
data = group.interface.new_panel(name='Data', default_closed=True)
interface_socket(group, 'Inward Axis', 'INPUT', 'NodeSocketMenu', data).hide_in_modifier = True
interface_socket(group, 'Geometry', 'OUTPUT', 'NodeSocketGeometry')
controls = group_input(group.nodes, set())
balloon = _build_balloon(group, controls.outputs['Geometry'], controls.outputs['Thickness'])
uniform = _build_cutout(group, controls.outputs['Geometry'], controls.outputs['Uniform Thickness'])
choose = menu_switch(group, 'Mode', ('Balloon', 'Uniform'), 'GEOMETRY')
group.links.new(balloon, choose.inputs['Balloon'])
group.links.new(uniform, choose.inputs['Uniform'])
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', 'Uniform'), 'FLOAT')
mode.inputs['Uniform'].default_value = 1
group.links.new(inflated.outputs[0], mode.inputs['Balloon'])
scale = mode.outputs[0]
geometry = store_float_attribute(group, geometry, NORMAL_SCALE_ATTRIBUTE_NAME, scale)
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, 'Inward 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 == 'Inward Axis':
item.default_value = '-Z'
elif item.name == 'Uniform Thickness':
item.name = 'Thickness'
return group