Refactor Cutout Tool to Use World Space Edge Length

- Replaced the Mesh Detail parameter with a new Edge Length property to define cutout sampling in physical units.
- Introduced Minimum Relative Edge Length preference to control effective spacing based on the bounding box of the cutout content.
- Updated tests to validate the new edge length functionality and ensure consistent behavior across different resolutions and canvas sizes.
- Removed deprecated parameters and adjusted related properties to align with the new edge length implementation.
- Enhanced geometry sampling logic to utilize effective world spacing for both standard and fine outline modes.
This commit is contained in:
2026-09-12 23:03:24 +08:00
parent 938d95db0f
commit 18799e8828
28 changed files with 505 additions and 279 deletions

View File

@@ -4,7 +4,7 @@ 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
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"
@@ -129,16 +129,13 @@ def _build_shell(group, geometry, thickness):
def _build_balloon(group, geometry, thickness):
nodes = group.nodes
links = group.links
profile, _profile_factor = _profile_field(group, geometry)
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])
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])
links.new(front_scale.outputs[0], negative_back.inputs[0])
extrude = _extrude_layers(group, geometry)
shaped = _set_layer_z(
group,

View File

@@ -68,12 +68,9 @@ def _build_depth_balloon(
base_front = math_node(nodes, "MULTIPLY")
links.new(profile, base_front.inputs[0])
links.new(thickness, base_front.inputs[1])
base_back = math_node(nodes, "MULTIPLY")
links.new(profile, base_back.inputs[0])
links.new(thickness, base_back.inputs[1])
negate_back = math_node(nodes, "MULTIPLY")
negate_back.inputs[1].default_value = -1.0
links.new(base_back.outputs[0], negate_back.inputs[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

View File

@@ -70,7 +70,10 @@ def build_volume_fields(group):
positive = _math(group, "MAXIMUM", full, minimum)
control = group_input(group.nodes, {"Thickness"})
thickness = _math(group, "MULTIPLY", positive, control.outputs["Thickness"])
return {"rim": rim, "center": center, "thickness": thickness}
return {
"profile": profile, "depth": axes.outputs["Z"], "interior": interior,
"center": center, "thickness": thickness,
}
def build_shell_fields(group, geometry, fields, is_shell, normal_weight=1):
@@ -102,7 +105,7 @@ def build_shell_fields(group, geometry, fields, is_shell, normal_weight=1):
return {"thickness": thickness.outputs[0], "normal": rear_normal}
def build_front_fields(group, rim, front_normal, normal_weight=1):
def build_front_fields(group, volume, front_normal, normal_weight=1):
"""Fit structural amplitude over neighbouring points before creating the shell."""
links = group.links
@@ -115,15 +118,7 @@ def build_front_fields(group, rim, front_normal, normal_weight=1):
links.new(weight, result.inputs["Weight"])
return result.outputs[0]
profile_node = group.nodes.new("GeometryNodeInputNamedAttribute")
profile_node.data_type = "FLOAT"
profile_node.inputs["Name"].default_value = "o_balloon"
profile = profile_node.outputs["Attribute"]
interior = _math(group, "SUBTRACT", 1, rim)
position = group.nodes.new("GeometryNodeInputPosition")
axes = group.nodes.new("ShaderNodeSeparateXYZ")
links.new(position.outputs[0], axes.inputs[0])
depth = axes.outputs["Z"]
profile, depth, interior = volume["profile"], volume["depth"], volume["interior"]
depth_slow = blur(depth, interior)
profile_slow = blur(profile, interior)
depth_bump = _math(group, "SUBTRACT", depth, depth_slow)
@@ -285,18 +280,21 @@ def _build_depth_surface(group, geometry, controls):
projection = smooth_cut_boundary(group, projection)
projection = taper_split_profile(group, original, projection)
# The mode field is shared by the geometry and thickness choices.
mode = menu_switch(group, "Mode", ("Balloon", "Shell"), "BOOLEAN")
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]
volume = build_volume_fields(group)
normal_weight = build_normal_weight(group, is_shell)
normal = build_front_normal(group, normal_weight)
front = build_front_fields(group, volume["rim"], normal, normal_weight)
front = build_front_fields(group, volume, normal, normal_weight)
shell_fields = build_shell_fields(group, projection, volume, is_shell, normal_weight)
thickness = sample_field(group, projection, shell_fields["thickness"], "FLOAT")
shell = thicken_depth_surface(
group, projection, thickness, back_mask=NORMAL_SCALE_ATTRIBUTE_NAME, weld_zero_thickness=True,
group, projection, shell_fields["thickness"], back_mask=NORMAL_SCALE_ATTRIBUTE_NAME, weld_zero_thickness=True,
)
fields = {
name: sample_field(group, projection, value, data_type, index=shell["source_index"])
@@ -350,7 +348,7 @@ def build_image_depth_cutout_group():
outline_smooth = interface_socket(group, 'Outline Depth Fix', 'INPUT', 'NodeSocketInt', options)
outline_smooth.default_value, outline_smooth.min_value, outline_smooth.max_value = 16, 0, 200
outline_smooth.description = 'Reduce abrupt depth changes near the original Cutout outline. Higher values apply more correction passes. Zero disables correction.'
float_input(group, 'Front Inflation', 0.5, maximum=1, subtype='FACTOR', parent=options).description = 'Control how much the front surface bulges in Balloon mode. Zero keeps the original front surface.'
float_input(group, 'Front Inflation', 0.0, maximum=1, subtype='FACTOR', parent=options).description = 'Control how much the front surface bulges in Balloon mode. Zero keeps the original front 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