Files
AnyImage/tools/node_assets/build_cutout_modifiers.py
nan 196b964efc Add cutout shell and volume node assets for depth volume processing
- Implemented `cutout_shell.py` to build front normals and shell positions for balloon geometry.
- Created `cutout_volume.py` to establish smooth center fields and manage balloon surface attributes.
- Introduced `pisa_sr_export.py` for exporting PiSA-SR components with dependencies and asset finalization.
2026-09-08 17:18:03 +08:00

253 lines
13 KiB
Python

"""Build the three directly editable Cutout modifier assets."""
import bpy
from types import SimpleNamespace
from mathutils import Matrix
from anyimage.operators.cutout_tool.shape import CUTOUT_NODE_GROUP_NAMES, CUTOUT_Z_TO_X, NORMAL_SCALE_ATTRIBUTE_NAME
from cutout_shapes import (
_socket, _float_input, _bool_input, _vector_input, _group_input, _math,
_island_cleanup, _build_cutout, _build_balloon, _build_depth_balloon,
_build_depth_surface, _shade_output,
)
from cutout_shape_layouts import LAYOUTS
from cutout_depth_surface_layout import POSITIONS, INPUT_REGIONS, layout_geometry_references
from cutout_layout import layout_stage, finish_layout
from cutout_volume import build_volume_fields, shift_stage
from cutout_rounding import build_volume_rounding
from cutout_inflation import build_front_fields
from cutout_shell import build_front_normals, build_shell_position
from node_graph import expand_smooth
def _menu(group, control, names, kind, location):
selector = next((node for node in group.nodes if node.bl_idname == "GeometryNodeMenuSwitch" and node.inputs["Menu"].is_linked and node.inputs["Menu"].links[0].from_socket.name == control), None)
if selector is None:
selector = group.nodes.new("GeometryNodeMenuSwitch")
selector.data_type = "BOOLEAN"
selector.location = (location[0] - 240, location[1] + 450)
for item, name in zip(selector.enum_items, names):
item.name = name
selector.inputs[names[0]].default_value = False
selector.inputs[names[1]].default_value = True
local = _group_input(group.nodes, (location[0] - 480, location[1] + 450), {control})
group.links.new(local.outputs[control], selector.inputs["Menu"])
node = group.nodes.new("GeometryNodeSwitch")
node.input_type = kind
node.location = location
group.links.new(selector.outputs[0], node.inputs["Switch"])
return SimpleNamespace(inputs={names[0]: node.inputs["False"], names[1]: node.inputs["True"]}, outputs=node.outputs)
def _read(group, name, location):
node = group.nodes.new("GeometryNodeInputNamedAttribute")
node.data_type = "FLOAT"
node.inputs["Name"].default_value = name
node.location = location
return node.outputs["Attribute"]
def _store(group, geometry, name, value, location, domain="POINT"):
node = group.nodes.new("GeometryNodeStoreNamedAttribute")
node.domain, node.data_type = domain, "FLOAT"
node.location = location
node.inputs["Name"].default_value = name
group.links.new(geometry, node.inputs["Geometry"])
if isinstance(value, (int, float)):
node.inputs["Value"].default_value = value
else:
group.links.new(value, node.inputs["Value"])
return node.outputs["Geometry"]
def _depth_volume(group, stage):
"""Insert the volume field ahead of identity-based shell construction."""
links = group.links
projection = stage["Set Position"].outputs["Geometry"]
shell_input = stage["Store Named Attribute.005"].inputs["Geometry"]
for node in group.nodes:
if node.location.x >= 4150:
node.location.x += 5100
projection = build_front_normals(group, projection)
volume = build_volume_fields(group, projection, (4150, 0))
shared = build_front_fields(group, volume.node.inputs["Geometry"].links[0].from_socket)
links.new(shared, volume.node.inputs["Geometry"])
uniform_control = _group_input(group.nodes, (8210, -330), {"Uniform Thickness"})
uniform = _store(group, shared, "o_balloon_surface_thickness", uniform_control.outputs["Uniform Thickness"], (8490, -330))
volume.node.location.x = uniform.node.location.x
choose = _menu(group, "Mode", ("Balloon", "Uniform"), "GEOMETRY", (9000, 0))
links.new(volume, choose.inputs["Balloon"])
links.new(uniform, choose.inputs["Uniform"])
links.new(choose.outputs[0], shell_input)
thickness = _read(group, "o_balloon_surface_thickness", (12000, -1100))
links.new(thickness, stage["Math.018"].inputs[1])
control = _group_input(group.nodes, (13300, 700), {"Thickness", "Uniform Thickness"})
amount = _menu(group, "Mode", ("Balloon", "Uniform"), "FLOAT", (13600, 500))
links.new(control.outputs["Thickness"], amount.inputs["Balloon"])
links.new(control.outputs["Uniform Thickness"], amount.inputs["Uniform"])
links.new(amount.outputs[0], stage["Math.019"].inputs[0])
identity_thickness = _read(group, "o_balloon_surface_thickness", (11100, 1500))
positive = _math(group.nodes, "GREATER_THAN", (11340, 1500))
positive.inputs[1].default_value = 1e-12
links.new(identity_thickness, positive.inputs[0])
layer = _math(group.nodes, "MULTIPLY", (11570, 1200))
links.new(positive.outputs[0], layer.inputs[0])
links.new(stage["Separate XYZ.006"].outputs["Z"], layer.inputs[1])
links.new(layer.outputs[0], stage["Combine XYZ.002"].inputs["Z"])
for node in group.nodes:
if node.location.x >= 12070:
node.location.x += 400
for name in ("Flip Faces", "Flip Faces.001"):
source = stage[name].outputs["Mesh"]
outgoing = [link for link in links if link.from_socket == source]
targets = [link.to_socket for link in outgoing]
for link in outgoing:
links.remove(link)
masked = _store(group, source, NORMAL_SCALE_ATTRIBUTE_NAME, 0.0, (12100, stage[name].location.y), domain="FACE")
for target in targets:
links.new(masked, target)
for node in group.nodes:
if node.location.x >= 8490:
node.location.x += 3200
volume.node.location.x = 8290
rounded = build_volume_rounding(group, volume, (8600, 0))
rounded.node.location.x = uniform.node.location.x + 40
links.new(rounded, choose.inputs["Balloon"])
restored = stage["Vector Math.004"].outputs[0]
targets = [link.to_socket for link in restored.links]
uniform_mode = choose.outputs[0].node.inputs["Switch"].links[0].from_socket
oriented = build_shell_position(group, restored, uniform_mode, (14100, -2100))
for target in targets:
links.new(oriented, target)
def build_cutout_group(shape):
name = CUTOUT_NODE_GROUP_NAMES[shape]
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
depth = shape in {"DEPTH_SOLID", "DEPTH_BALLOON"}
dual = shape != "DEPTH_BALLOON"
_socket(group, "Geometry", "INPUT", "NodeSocketGeometry")
if dual:
_socket(group, "Mode", "INPUT", "NodeSocketMenu")
_float_input(group, "Thickness", 1.0).description = "Balloon profile multiplier; zero returns the sheet"
if dual:
_float_input(group, "Uniform Thickness", 0.0, subtype="DISTANCE").description = "Distance used only by Uniform thickness mode"
if depth:
_float_input(group, "Depth Scale", 1.0)
if shape == "DEPTH_BALLOON":
_bool_input(group, "Double Sided", True)
iterations = _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)
if shape == "DEPTH_SOLID":
_float_input(group, "Split Threshold", 0.0, maximum=1, subtype="FACTOR", parent=options)
if depth:
_float_input(group, "Reference Depth", 1.0, subtype="DISTANCE", parent=options)
if shape == "DEPTH_SOLID":
_float_input(group, "Edge Round", 0.2, maximum=1, subtype="FACTOR", parent=options)
_float_input(group, "Front Inflation", 1.0, maximum=32, parent=options)
normal_iterations = _socket(group, "Normal Smooth", "INPUT", "NodeSocketInt", options)
normal_iterations.default_value = 50
normal_iterations.min_value, normal_iterations.max_value = 0, 50
_float_input(group, "Smooth Weight", 1.0, maximum=1, subtype="FACTOR", parent=options)
if shape == "DEPTH_BALLOON":
_vector_input(group, "Depth Direction", (0, 0, 1), subtype="DIRECTION", parent=options)
data = group.interface.new_panel(name="Data", default_closed=True)
_socket(group, "Inward Axis", "INPUT", "NodeSocketMenu", data).hide_in_modifier = True
if depth:
_float_input(group, "Uniform Scale", 1.0, parent=data).hide_in_modifier = True
_socket(group, "Depth Image", "INPUT", "NodeSocketImage", data).hide_in_modifier = True
_socket(group, "Geometry", "OUTPUT", "NodeSocketGeometry")
controls = _group_input(group.nodes, (-5000, 0), set())
previous = set(group.nodes)
if shape == "DEPTH_SOLID":
geometry = _build_depth_surface(group, controls.outputs["Geometry"], controls.outputs["Thickness"], controls.outputs["Depth Scale"], controls.outputs["Split Threshold"], controls.outputs["Uniform Scale"], controls.outputs["Reference Depth"], controls.outputs["Depth Image"])
stage = layout_stage(group, previous, POSITIONS, INPUT_REGIONS)
_depth_volume(group, stage)
end = 18000
elif shape == "DEPTH_BALLOON":
geometry = _build_depth_balloon(group, controls.outputs["Geometry"], controls.outputs["Thickness"], controls.outputs["Depth Scale"], controls.outputs["Double Sided"], controls.outputs["Uniform Scale"], controls.outputs["Depth Direction"], controls.outputs["Depth Image"], controls.outputs["Reference Depth"])
layout_stage(group, previous, *LAYOUTS["DEPTH_BALLOON"])
end = 3000
else:
balloon = _build_balloon(group, controls.outputs["Geometry"], controls.outputs["Thickness"])
layout_stage(group, previous, *LAYOUTS["BALLOON"])
previous = set(group.nodes)
uniform = _build_cutout(group, controls.outputs["Geometry"], controls.outputs["Uniform Thickness"])
layout_stage(group, previous, *LAYOUTS["UNIFORM"], offset=(0, -2400))
balloon.node.location.x = uniform.node.location.x
choose = _menu(group, "Mode", ("Balloon", "Uniform"), "GEOMETRY", (3200, 0))
group.links.new(balloon, choose.inputs["Balloon"])
group.links.new(uniform, choose.inputs["Uniform"])
geometry, end = choose.outputs[0], 3700
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, (-4800, 0), {"Geometry", "Cleanup Threshold"})
area = _math(group.nodes, "MULTIPLY", (-4520, 500))
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], (-4520, 0))
if shape == "DEPTH_SOLID":
cleaned = _store(group, cleaned, NORMAL_SCALE_ATTRIBUTE_NAME, 1.0, (-3540, 0), domain="FACE")
for target in planar_inputs:
group.links.new(cleaned, target)
previous = set(group.nodes)
geometry = expand_smooth(group, geometry, iterations_name="Smooth", weight_name="Smooth Weight")
shift_stage(group, previous, (end, -550))
end += 3450
if shape == "DEPTH_SOLID":
remove = group.nodes.new("GeometryNodeRemoveAttribute")
remove.pattern_mode = "WILDCARD"
remove.inputs["Name"].default_value = "o_balloon_surface_*"
remove.location = (end, 0)
group.links.new(geometry, remove.inputs["Geometry"])
geometry, end = remove.outputs[0], end + 300
else:
profile = _read(group, "o_balloon", (end, 800))
scale = profile
if dual:
amount = _group_input(group.nodes, (end, 1100), {"Thickness"})
enabled = _math(group.nodes, "GREATER_THAN", (end + 250, 1100))
enabled.inputs[1].default_value = 1e-6
group.links.new(amount.outputs["Thickness"], enabled.inputs[0])
inflated = group.nodes.new("GeometryNodeSwitch")
inflated.input_type = "FLOAT"
inflated.location = (end + 480, 800)
inflated.inputs["False"].default_value = 1
group.links.new(enabled.outputs[0], inflated.inputs["Switch"])
group.links.new(profile, inflated.inputs["True"])
mode = _menu(group, "Mode", ("Balloon", "Uniform"), "FLOAT", (end + 750, 800))
mode.inputs["Uniform"].default_value = 1
group.links.new(inflated.outputs[0], mode.inputs["Balloon"])
scale = mode.outputs[0]
geometry = _store(group, geometry, NORMAL_SCALE_ATTRIBUTE_NAME, scale, (end + 1050, 0))
end += 1350
orient = group.nodes.new("GeometryNodeTransform")
orient.location = (end, -350)
orient.inputs["Rotation"].default_value = Matrix(CUTOUT_Z_TO_X).to_euler()
group.links.new(geometry, orient.inputs["Geometry"])
axis = _menu(group, "Inward Axis", ("-Z", "+X"), "GEOMETRY", (end + 300, 0))
group.links.new(geometry, axis.inputs["-Z"])
group.links.new(orient.outputs[0], axis.inputs["+X"])
_shade_output(group, axis.outputs[0], (end + 600, 0))
if shape == "DEPTH_SOLID":
layout_geometry_references(group, stage)
finish_layout(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