refactor: update depth cutout and surface handling for improved geometry manipulation and clarity

This commit is contained in:
2026-09-18 01:43:52 +08:00
parent 076a8c90f1
commit 0e0287d340
13 changed files with 272 additions and 191 deletions

Binary file not shown.

View File

@@ -1,5 +1,10 @@
"""Expose the extension and standalone server packages to the test suite."""
import sys
# Import the pinned dependencies before bpy prepends Blender's extension
# site-packages, where a half-removed install can shadow the project's own scipy.
import scipy # noqa: F401
from tests.support.paths import PROJECT_ROOT
sys.path[:0] = [str(PROJECT_ROOT / "src"), str(PROJECT_ROOT / "src/anyimage")]

View File

@@ -113,7 +113,7 @@ def _surface(node_group):
image.pixels.foreach_set(pixels.ravel())
values = {"Subdivide": 1, "Thickness": 0.2, "Depth Image": image,
"Uniform Scale": 1.0, "Reference Depth": 2.5, "Depth Scale": 0.8,
"Normal Smooth": 2, "Depth Split": 0.25,
"Edge Turn": 2, "Depth Split": 0.25,
"Offset": 0.5}
mode = None
for item in node_group.interface.items_tree:

View File

@@ -1,4 +1,4 @@
"""Verify split profile shaping and thickness-weighted normals."""
"""Verify split profile shaping and displacement direction shaping."""
import bpy
import numpy as np
@@ -6,9 +6,9 @@ import pytest
from anyimage.common.object import modifier_input_identifier, set_modifier_input
from tests.support.depth_surface import surface, evaluated
from tests.tools.nodes.test_boundary_smoothing import diagonal_surface, boundary_neighbors, edge_band
from tests.tools.nodes.test_boundary_smoothing import diagonal_surface
from tools.nodes.common.cutout_boundary import remove_boundary_triangles
from tools.nodes.groups.image_depth_cutout import build_normal_weight, blend_normal
from tools.nodes.groups.image_depth_cutout import build_normal_direction, build_normal_weight
def profile_values(obj):
@@ -77,63 +77,76 @@ def test_split_profile_tapers_within_boundary_band():
set_value("Depth Scale", 1)
np.testing.assert_allclose(profile_values(obj), .3)
set_value("Depth Split", .1)
points, faces = evaluated(obj)
boundary = boundary_neighbors(faces)
uv = vertex_uv(obj)
# The depth step sits at u=0.5, so that column is the split line.
cut = [i for i in boundary if abs(uv[i,0] - .5) < 1e-4 and 1e-4 < uv[i,1] < 1 - 1e-4]
assert cut
band = edge_band(faces, cut)
outside = [i for i in range(len(points)) if i not in band]
assert outside
values = profile_values(obj)
uv = vertex_uv(obj)
# The depth step sits at u=0.5, so only that column is tapered.
cut = (np.abs(uv[:, 0] - .5) < 1e-4) & (uv[:, 1] > 1e-3) & (uv[:, 1] < 1 - 1e-3)
assert cut.any()
np.testing.assert_array_equal(values[cut], 0)
np.testing.assert_allclose(values[outside], .3)
untouched = (np.abs(uv[:, 0] - .25) < 1e-3) | (np.abs(uv[:, 0] - .75) < 1e-3)
assert untouched.any()
np.testing.assert_allclose(values[untouched], .3)
assert np.isfinite(values).all()
assert values.max() == pytest.approx(.3)
set_value("Depth Split", 0)
np.testing.assert_allclose(profile_values(obj), .3)
@pytest.mark.parametrize("mode", [0, 1])
def test_normal_blend_weights_balloon_and_keeps_full_shell_smoothing(mode):
obj, set_value = surface(resolution=2)
group = obj.modifiers[0].node_group
# The production interface renames the Shell thickness socket after building.
socket = next(s for s in group.interface.items_tree if s.item_type == "SOCKET" and s.in_out == "INPUT"
and s.name == "Thickness" and s.subtype == "DISTANCE")
socket.name = "Shell Thickness"
is_shell = group.nodes.new("FunctionNodeInputBool")
is_shell.boolean = bool(mode)
weight = build_normal_weight(group, is_shell.outputs[0])
raw = group.nodes.new("ShaderNodeCombineXYZ")
raw.inputs["Z"].default_value = 1
blurred = group.nodes.new("ShaderNodeCombineXYZ")
blurred.inputs["X"].default_value = 1
result = blend_normal(group, raw.outputs[0], blurred.outputs[0], weight)
def sampled(obj, group, value, name):
"""Store one field on the points and read it back from the evaluated mesh."""
data_type, reader = {"VECTOR": ("FLOAT_VECTOR", "vector"), "VALUE": ("FLOAT", "value")}[value.type]
store = group.nodes.new("GeometryNodeStoreNamedAttribute")
store.data_type, store.domain = "FLOAT_VECTOR", "POINT"
store.inputs["Name"].default_value = "normal_result"
store.data_type, store.domain = data_type, "POINT"
store.inputs["Name"].default_value = name
source = group.nodes.new("NodeGroupInput")
group.links.new(source.outputs["Geometry"], store.inputs["Geometry"])
group.links.new(result, store.inputs["Value"])
group.links.new(value, store.inputs["Value"])
group.links.new(store.outputs[0], next(n for n in group.nodes if n.type == "GROUP_OUTPUT").inputs["Geometry"])
modifier = obj.modifiers[0]
identifier = socket.identifier if mode else modifier_input_identifier(group, "Thickness", subtype="NONE")
reference = 2
set_value("Reference Depth", reference)
results = []
for amount in (0, .001, .01, 1):
set_modifier_input(modifier, identifier, amount * (max(reference, 1e-8) if mode else 1))
obj.update_tag(refresh={"DATA"}); bpy.context.view_layer.update()
ev = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()); mesh = ev.to_mesh()
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
mesh = result.to_mesh()
try:
results.append(np.array(mesh.attributes["normal_result"].data[0].vector[:]))
return np.array([getattr(item, reader) for item in mesh.attributes[name].data])
finally:
ev.to_mesh_clear()
np.testing.assert_array_equal(results[-1], [1,0,0])
if mode:
np.testing.assert_array_equal(results, [[1,0,0]] * len(results))
else:
np.testing.assert_array_equal(results[0], [0,0,1])
assert np.linalg.norm(results[1]-results[0]) < np.linalg.norm(results[2]-results[0]) < .001
result.to_mesh_clear()
def test_normal_weight_follows_profile_only_in_balloon_mode():
obj, _ = surface(resolution=2)
group = obj.modifiers[0].node_group
is_shell = group.nodes.new("FunctionNodeInputBool")
profile = group.nodes.new("ShaderNodeValue")
profile.outputs[0].default_value = .25
weight = build_normal_weight(group, is_shell.outputs[0], profile.outputs[0])
np.testing.assert_allclose(sampled(obj, group, weight, "normal_weight"), .25)
is_shell.boolean = True
np.testing.assert_allclose(sampled(obj, group, weight, "normal_weight"), 0.0)
def test_normal_direction_turns_the_edge_with_edge_turn():
obj, _ = surface(resolution=2)
group = obj.modifiers[0].node_group
smoothed = group.nodes.new("ShaderNodeCombineXYZ")
smoothed.inputs["Z"].default_value = 1
average = group.nodes.new("ShaderNodeCombineXYZ")
average.inputs["X"].default_value = 1
weight = group.nodes.new("ShaderNodeValue")
controls = group.nodes.new("NodeGroupInput")
direction = build_normal_direction(
group, controls, smoothed.outputs[0], average.outputs[0], weight.outputs[0],
)
modifier = obj.modifiers[0]
turn = modifier_input_identifier(group, "Edge Turn")
def result(weight_value, turn_value):
weight.outputs[0].default_value = weight_value
set_modifier_input(modifier, turn, turn_value)
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
return sampled(obj, group, direction, "normal_direction")[0]
np.testing.assert_allclose(result(0.0, 0.0), (0, 0, 1), atol=1e-6)
np.testing.assert_allclose(result(0.0, 1.0), (1, 0, 0), atol=1e-6)
np.testing.assert_allclose(result(0.0, 2.0), (2, 0, -1) / np.sqrt(5), atol=1e-6)
np.testing.assert_allclose(result(1.0, 2.0), (1, 0, 0), atol=1e-6)

View File

@@ -7,7 +7,8 @@ from anyimage.common.object import modifier_input_identifier, set_modifier_input
@pytest.mark.parametrize("mode", [0, 1])
def test_normal_smooth_changes_thickness_direction_without_moving_zero_thickness(mode):
def test_edge_turn_reshapes_thickness_without_moving_zero_thickness(mode):
"""Edge Turn swings the wall direction and leaves zero thickness untouched."""
from tests.support.depth_surface import surface, evaluated
obj, set_value = surface(step=1, resolution=10)
@@ -19,24 +20,28 @@ def test_normal_smooth_changes_thickness_direction_without_moving_zero_thickness
set_value("Front Inflation", .2)
modifier = obj.modifiers[0]
thickness = modifier_input_identifier(modifier.node_group, "Thickness", subtype="DISTANCE" if mode else "NONE")
turn = modifier_input_identifier(modifier.node_group, "Edge Turn")
def result(amount, smoothing):
def result(amount, edge_turn):
set_modifier_input(modifier, thickness, amount)
set_value("Normal Smooth", smoothing)
set_modifier_input(modifier, turn, edge_turn)
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
return evaluated(obj)
raw, faces = result(0, 16)
smooth, smooth_faces = result(0, 512)
np.testing.assert_array_equal(raw, smooth)
assert faces == smooth_faces
raw, faces = result(1, 16)
smooth, smooth_faces = result(1, 512)
assert faces == smooth_faces
assert np.isfinite(smooth).all()
assert np.max(np.abs(raw - smooth)) > 1e-5
plain, faces = result(0, 0)
turned, turned_faces = result(0, 2)
np.testing.assert_array_equal(plain, turned)
assert faces == turned_faces
plain, faces = result(1, 0)
turned, turned_faces = result(1, 2)
assert faces == turned_faces
assert np.isfinite(turned).all()
assert np.max(np.abs(plain - turned)) > 1e-5
def test_thin_shell_normal_smoothing_is_independent_of_thickness_and_reference():
def test_shell_offset_scales_with_thickness_and_ignores_reference():
"""A thin Shell moves a distance proportional to its thickness."""
from tests.support.depth_surface import surface, evaluated
obj, set_value = surface(step=0, resolution=12)
@@ -49,9 +54,8 @@ def test_thin_shell_normal_smoothing_is_independent_of_thickness_and_reference()
set_value('Thickness', 0.)
front, _ = evaluated(obj)
def offsets(thickness, smoothing, reference=10.):
def offsets(thickness, reference=10.):
set_value('Thickness', thickness)
set_value('Normal Smooth', smoothing)
set_value('Reference Depth', reference)
points, faces = evaluated(obj)
adjusted_front = front + (0, 10 - reference, 0)
@@ -63,11 +67,9 @@ def test_thin_shell_normal_smoothing_is_independent_of_thickness_and_reference()
order = np.argsort(indices[rear])
return ((points[rear] - adjusted_front[indices[rear]]) / thickness)[order], faces
raw, _ = offsets(.02, 16)
smooth, faces = offsets(.02, 512)
assert np.max(np.linalg.norm(raw - smooth, axis=1)) > .05
thinner, thinner_faces = offsets(.01, 512)
distant, _ = offsets(.02, 512, 20.)
smooth, faces = offsets(.02)
thinner, thinner_faces = offsets(.01)
distant, _ = offsets(.02, 20.)
np.testing.assert_allclose(thinner, smooth, atol=2e-4)
np.testing.assert_allclose(distant, smooth, atol=2e-4)
assert thinner_faces == faces

View File

@@ -174,7 +174,7 @@ def test_temporary_fields_are_removed_and_user_attributes_survive(thickness):
obj, set_value = surface()
obj.data.attributes.new("o_depth_probe", "FLOAT", "POINT")
attribute = obj.data.attributes.new("o_user_weight", "FLOAT", "POINT")
obj.data.attributes.new(".o_anyimage_user_weight", "FLOAT", "POINT")
obj.data.attributes.new(".o_user_weight", "FLOAT", "POINT")
for value in attribute.data:
value.value = 0.75
set_value("Depth Split", 0.5)
@@ -184,10 +184,8 @@ def test_temporary_fields_are_removed_and_user_attributes_survive(thickness):
try:
assert {a.name for a in mesh.attributes if a.name.startswith(("o_depth_", "o_balloon_surface_"))} == {"o_depth_probe"}
assert "UVMap" in mesh.attributes
assert ".o_anyimage_user_weight" in mesh.attributes
assert "_o_anyimage_depth_cut" not in mesh.attributes
assert ".o_anyimage_original_boundary" not in mesh.attributes
assert ".o_anyimage_depth_cut" not in mesh.attributes
assert ".o_user_weight" in mesh.attributes
assert not any("o_anyimage" in a.name for a in mesh.attributes)
assert not any(a.name.startswith("_o_") for a in mesh.attributes)
assert all(
v.value == pytest.approx(0.75)

View File

@@ -19,7 +19,7 @@ def test_saved_parameter_descriptions_match_definitions():
"O Image Depth Plane": {"Subdivide", "Thickness", "Depth Split", "Depth Mask", "Mask Threshold", "Boundary Smooth"},
"O Image Relief Plane": {"Subdivide", "Thickness", "Depth Direction", "Depth Offset"},
"O Image Cutout": {"Thickness"},
"O Image Depth Cutout": {"Thickness", "Depth Split", "Boundary Smooth", "Normal Smooth", "Front Inflation"},
"O Image Depth Cutout": {"Thickness", "Depth Split", "Boundary Smooth", "Edge Turn", "Front Inflation"},
"O Image Cutout Symmetry": {"Direction", "Offset", "Scale", "Fill Sides", "Smooth"},
"O Image Depth Panorama": {"Subdivide", "Depth Scale", "Depth Split", "Dome Radius", "Depth Mask", "Mask Threshold", "Boundary Smooth"},
"O Image Layer": {"Alpha Fix", "Normal Scale", "Bump Scale", "Object Space"},
@@ -82,8 +82,8 @@ def test_geometry_attribute_contract_includes_nested_groups():
allowed = {
"UVMap": ("FLOAT2", "CORNER"),
"o_balloon": ("FLOAT", "POINT"),
"_o_anyimage_depth_cut": ("BOOLEAN", "CORNER"),
"_o_solid_normal": ("FLOAT_VECTOR", "POINT"),
"_o_depth_cut": ("BOOLEAN", "CORNER"),
"_o_front_normal": ("FLOAT_VECTOR", "POINT"),
"_o_symmetry_weld": ("BOOLEAN", "FACE"),
"o_depth_rotation": ("FLOAT_VECTOR", "FACE"),
"o_depth_face": ("FLOAT", "FACE"),

View File

@@ -26,10 +26,7 @@ def test_depth_attribute_cleanup_has_no_warnings(kind):
mesh = evaluated.to_mesh()
try:
assert mesh.vertices
assert not {a.name for a in mesh.attributes} & {
".o_anyimage_depth_cut", ".o_anyimage_original_boundary",
"_o_anyimage_depth_cut",
}
assert not any("o_anyimage" in a.name for a in mesh.attributes)
assert not any(a.name.startswith("_o_") for a in mesh.attributes)
assert not list(modifier.node_warnings), [
warning.message for warning in modifier.node_warnings

View File

@@ -58,8 +58,7 @@ def check_asset_library(asset_path):
modifier = obj.modifiers.new("Asset Check", "NODES")
modifier.node_group = node_group
values = {"Subdivide": 0, "Thickness": 0.2, "Reference Depth": 2.0,
"Depth Image": depth_image, "Uniform Scale": 1.0, "Normal Smooth": 0,
"Depth Split": 0.0}
"Depth Image": depth_image, "Uniform Scale": 1.0, "Depth Split": 0.0}
if node_group.name == "O Image Cutout Symmetry":
values["Offset"] = 0.5
for item in node_group.interface.items_tree:
@@ -75,10 +74,9 @@ def check_asset_library(asset_path):
assert not list(modifier.node_warnings), (
node_group.name, [warning.message for warning in modifier.node_warnings],
)
assert not {attribute.name for attribute in result.attributes} & {
".o_anyimage_depth_cut", ".o_anyimage_original_boundary",
"_o_anyimage_depth_cut",
}, node_group.name
assert not any(
"o_anyimage" in attribute.name for attribute in result.attributes
), node_group.name
assert not any(
attribute.name.startswith("_o_") for attribute in result.attributes
), node_group.name

View File

@@ -7,6 +7,8 @@ from .smoothing import edge_boundary_field, pinned_smooth
def _gated(group, value, blocked, *, default):
"""Return the value unless the blocked control is true."""
if blocked is False:
return value
node = group.nodes.new("GeometryNodeSwitch")
node.input_type = "FLOAT"
node.inputs["True"].default_value = default

View File

@@ -1,10 +1,7 @@
"""Clean cutout boundary triangles and taper the split thickness profile."""
from .depth_surface import _math
from .nodes import (
boolean_node, compare_node, evaluate_field, group_input,
read_float_attribute, sample_field, store_float_attribute,
)
from .nodes import boolean_node, compare_node, evaluate_field, read_float_attribute, store_float_attribute
from .smoothing import edge_boundary_field
from .boundary_smoothing import boundary_influence
@@ -23,37 +20,10 @@ def remove_boundary_triangles(group, geometry):
return delete.outputs["Geometry"]
def original_boundary_field(group, original, rim=None):
"""Sample the unsplit outline in UV space after projection and separation."""
nodes, links = group.nodes, group.links
uv = nodes.new("GeometryNodeInputNamedAttribute")
uv.data_type = "FLOAT_VECTOR"
uv.inputs["Name"].default_value = "UVMap"
original_uv = nodes.new("GeometryNodeSetPosition")
links.new(original, original_uv.inputs["Geometry"])
links.new(uv.outputs["Attribute"], original_uv.inputs["Position"])
nearest = nodes.new("GeometryNodeSampleNearest")
nearest.domain = "POINT"
links.new(original_uv.outputs["Geometry"], nearest.inputs["Geometry"])
links.new(uv.outputs["Attribute"], nearest.inputs["Sample Position"])
if rim is None:
rim = evaluate_field(group, edge_boundary_field(nodes, links), "FLOAT", "POINT")
return sample_field(group, original, rim, "FLOAT", index=nearest.outputs["Index"])
def taper_split_profile(group, geometry, original_boundary):
"""Taper balloon thickness through the shared two-ring influence builder."""
def taper_split_profile(group, geometry, cut_boundary):
"""Taper the balloon profile to zero across the Depth Split cuts."""
nodes = group.nodes
links = group.links
influence = boundary_influence(group, original_boundary)
controls = group_input(nodes, {"Depth Split"})
has_split = compare_node(group, "GREATER_THAN", controls.outputs["Depth Split"], 0)
band = nodes.new("GeometryNodeSwitch")
band.input_type = "FLOAT"
band.inputs["False"].default_value = 0.0
links.new(has_split, band.inputs["Switch"])
links.new(influence, band.inputs["True"])
band = band.outputs[0]
retained = _math(group, "SQRT", _math(group, "MAXIMUM", _math(group, "SUBTRACT", 1, band), 0))
retained = _math(group, "SUBTRACT", 1, boundary_influence(group, boundary=cut_boundary))
profile = _math(group, "MULTIPLY", read_float_attribute(group, "o_balloon"), retained)
return store_float_attribute(group, geometry, "o_balloon", profile)

View File

@@ -7,7 +7,7 @@ from .nodes import (
from .smoothing import edge_boundary_field
CUT_ATTRIBUTE = "_o_anyimage_depth_cut"
CUT_ATTRIBUTE = "_o_depth_cut"
def _math(group, operation, a, b=None):

View File

@@ -2,7 +2,7 @@
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,
interface_socket, group_input, float_input, boolean_node, read_float_attribute, read_vector_attribute,
store_float_attribute, store_vector_attribute, remove_attribute_pattern,
prepare_node_group, evaluate_field, compare_node,
)
@@ -10,62 +10,97 @@ 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
from ..common.cutout_boundary import 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
FRONT_NORMAL_ATTRIBUTE = "_o_front_normal"
BALLOON_THICKNESS_BASE = 1.0
NORMAL_SMOOTH_ITERATIONS = 256
SOLID_WELD_SCALE = 1e-3
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 build_normal_weight(group, is_shell, profile):
"""Keep shell smoothing and let the balloon profile drive the blend."""
weight = group.nodes.new("GeometryNodeSwitch")
weight.input_type = "FLOAT"
group.links.new(is_shell, weight.inputs["Switch"])
group.links.new(profile, weight.inputs["False"])
weight.inputs["True"].default_value = 0.0
return weight.outputs[0]
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")
def build_profile_weight(group, geometry):
"""Normalize the corrected balloon profile into a zero to one blend weight."""
profile = read_float_attribute(group, "o_balloon")
statistics = group.nodes.new("GeometryNodeAttributeStatistic")
statistics.data_type, statistics.domain = "FLOAT", "POINT"
group.links.new(geometry, statistics.inputs["Geometry"])
group.links.new(profile, statistics.inputs["Attribute"])
return _math(
group, "DIVIDE", profile,
_math(group, "MAXIMUM", statistics.outputs["Max"], 1e-6),
)
def build_island_normal(group, normal):
"""Average the smoothed normals of one connected mesh island."""
nodes, links = group.nodes, group.links
island = nodes.new("GeometryNodeInputMeshIsland")
accumulate = nodes.new("GeometryNodeAccumulateField")
accumulate.data_type, accumulate.domain = "FLOAT_VECTOR", "POINT"
links.new(normal, accumulate.inputs["Value"])
links.new(island.outputs["Island Index"], accumulate.inputs["Group Index"])
normalize = nodes.new("ShaderNodeVectorMath")
normalize.operation = "NORMALIZE"
group.links.new(mix.outputs["Result"], normalize.inputs[0])
return normalize.outputs[0]
links.new(accumulate.outputs["Total"], normalize.inputs[0])
return normalize.outputs["Vector"]
def build_front_normal(group, normal_weight):
"""Smooth the projected surface normals that drive the inflation."""
links = group.links
def build_smoothed_normals(group):
"""Return the smoothed normals and the average normal of each mesh island."""
nodes, links = group.nodes, 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)
blur.inputs["Iterations"].default_value = NORMAL_SMOOTH_ITERATIONS
smoothed = blur.outputs[0]
return smoothed, build_island_normal(group, smoothed)
def build_normal_direction(group, controls, smoothed, average, weight):
"""Blend the smoothed normal against the island average normal."""
nodes, links = group.nodes, group.links
difference = group.nodes.new("ShaderNodeVectorMath")
difference.operation = "SUBTRACT"
links.new(average, difference.inputs[0])
links.new(smoothed, difference.inputs[1])
lean = _math(
group, "MULTIPLY", _math(group, "SUBTRACT", 1.0, weight),
controls.outputs["Edge Turn"],
)
blend = _math(group, "ADD", weight, lean)
offset = group.nodes.new("ShaderNodeVectorMath")
offset.operation = "SCALE"
links.new(difference.outputs["Vector"], offset.inputs[0])
links.new(blend, offset.inputs[3])
direction = group.nodes.new("ShaderNodeVectorMath")
direction.operation = "ADD"
links.new(smoothed, direction.inputs[0])
links.new(offset.outputs["Vector"], direction.inputs[1])
normalize = group.nodes.new("ShaderNodeVectorMath")
normalize.operation = "NORMALIZE"
links.new(direction.outputs["Vector"], normalize.inputs[0])
return normalize.outputs["Vector"]
def build_inflation_amount(group, controls, is_shell):
"""Balloon scales the profile, Shell moves a fixed distance."""
"""Balloon scales the profile by Depth Scale, Shell moves a fixed distance."""
nodes, links = group.nodes, group.links
thickness = _math(
group, "MULTIPLY", controls.outputs["Thickness"], BALLOON_THICKNESS_SCALE,
group, "MULTIPLY", controls.outputs["Thickness"],
_math(group, "ADD", BALLOON_THICKNESS_BASE, controls.outputs["Depth Scale"]),
)
balloon = _math(
group, "MULTIPLY", read_float_attribute(group, "o_balloon"), thickness,
@@ -89,9 +124,15 @@ def build_solid(group, surface, amount, normal, is_shell, controls, weld_distanc
links.new(distance, result.inputs[3])
return result.outputs[0]
# The front leaf moves by Thickness x o_balloon x Front Inflation, independent of
# the Depth Scale that scales the rear recession.
inflation = _math(
group, "MULTIPLY", amount,
_math(group, "MULTIPLY", controls.outputs["Front Inflation"], FRONT_INFLATION_SCALE),
group, "MULTIPLY",
_math(
group, "MULTIPLY", read_float_attribute(group, "o_balloon"),
controls.outputs["Thickness"],
),
controls.outputs["Front Inflation"],
)
front_amount = nodes.new("GeometryNodeSwitch")
front_amount.input_type = "FLOAT"
@@ -103,17 +144,47 @@ def build_solid(group, surface, amount, normal, is_shell, controls, weld_distanc
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)
# The rear leaf inflates along the negated front direction, straight from the
# untouched surface so the front offset cannot drag it along.
span = _math(group, "MULTIPLY", amount, -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(surface, extrude.inputs["Mesh"])
links.new(offset(span), extrude.inputs["Offset"])
# The rear surface smooths in proportion to its thickness and the balloon profile,
# so thin shells and cut boundaries keep their exact positions.
thickness = nodes.new("GeometryNodeSwitch")
thickness.input_type = "FLOAT"
links.new(is_shell, thickness.inputs["Switch"])
links.new(controls.outputs["Thickness"], thickness.inputs["False"])
links.new(controls.outputs["Shell Thickness"], thickness.inputs["True"])
weight = _math(
group, "MULTIPLY", _math(group, "MINIMUM", thickness.outputs[0], 1.0),
read_float_attribute(group, "o_balloon"),
)
position = nodes.new("GeometryNodeInputPosition")
blur = nodes.new("GeometryNodeBlurAttribute")
blur.data_type = "FLOAT_VECTOR"
blur.inputs["Weight"].default_value = 1.0
links.new(position.outputs["Position"], blur.inputs["Value"])
links.new(group_input(nodes, {"Back Smooth"}).outputs["Back Smooth"], blur.inputs["Iterations"])
difference = nodes.new("ShaderNodeVectorMath")
difference.operation = "SUBTRACT"
links.new(blur.outputs["Value"], difference.inputs[0])
links.new(position.outputs["Position"], difference.inputs[1])
smooth_offset = nodes.new("ShaderNodeVectorMath")
smooth_offset.operation = "SCALE"
links.new(difference.outputs["Vector"], smooth_offset.inputs[0])
links.new(weight, smooth_offset.inputs[3])
smoothed = nodes.new("GeometryNodeSetPosition")
links.new(extrude.outputs["Mesh"], smoothed.inputs["Geometry"])
links.new(smooth_offset.outputs["Vector"], smoothed.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"])
links.new(smoothed.outputs["Geometry"], flipped.inputs["Mesh"])
rear = store_float_attribute(
group, flipped.outputs["Mesh"], NORMAL_REDUCTION_ATTRIBUTE_NAME, 1.0,
)
@@ -133,7 +204,6 @@ def _build_depth_surface(group, geometry, controls):
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,
@@ -141,6 +211,11 @@ def _build_depth_surface(group, geometry, controls):
geometry = remove_boundary_triangles(group, geometry)
boundary = edge_boundary_field(group.nodes, group.links)
rim = evaluate_field(group, boundary, "FLOAT", "POINT")
cut = evaluate_field(
group, boolean_node(group, "AND", boundary, cut_vertex), "FLOAT", "POINT",
)
# Only the Depth Split cuts taper the profile; the outline keeps its thickness.
geometry = taper_split_profile(group, geometry, cut)
camera = build_surface_camera(
group, _position_field(group, image), corner_camera, cut_vertex, split,
)
@@ -163,8 +238,7 @@ def _build_depth_surface(group, geometry, controls):
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.
# The mode field is shared by the thickness and direction choices.
mode = group.nodes.new("GeometryNodeMenuSwitch")
mode.data_type = "BOOLEAN"
for item, name in zip(mode.enum_items, ("Balloon", "Shell")):
@@ -179,23 +253,45 @@ def _build_depth_surface(group, geometry, controls):
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.
profile = build_profile_weight(group, projection)
smoothed, average = build_smoothed_normals(group)
weight = build_normal_weight(group, is_shell, profile)
# Material normal strength: the balloon profile gates it, Depth Scale attenuates it.
inflated = group.nodes.new("GeometryNodeSwitch")
inflated.input_type = "FLOAT"
links.new(is_shell, inflated.inputs["Switch"])
links.new(profile, inflated.inputs["False"])
inflated.inputs["True"].default_value = 1.0
normal_strength = group.nodes.new("GeometryNodeSwitch")
normal_strength.input_type = "FLOAT"
links.new(has_thickness, normal_strength.inputs["Switch"])
normal_strength.inputs["False"].default_value = 1.0
links.new(inflated.outputs[0], normal_strength.inputs["True"])
reduction = _math(
group, "SUBTRACT", 1.0,
_math(group, "MULTIPLY", controls.outputs["Depth Scale"], normal_strength.outputs[0]),
)
projection = store_float_attribute(
group, projection, NORMAL_REDUCTION_ATTRIBUTE_NAME, reduction,
)
# The rear leaf inflates along the negated front direction, so one direction is
# carried across the extrusion instead of being re-evaluated on the moved surface.
projection = store_vector_attribute(
group, projection, SOLID_NORMAL_ATTRIBUTE, normal,
group, projection, FRONT_NORMAL_ATTRIBUTE,
build_normal_direction(group, controls, smoothed, average, weight),
)
solid = build_solid(
group, projection, amount, read_vector_attribute(group, SOLID_NORMAL_ATTRIBUTE),
group, projection, amount,
read_vector_attribute(group, FRONT_NORMAL_ATTRIBUTE),
is_shell, controls,
_math(group, "MULTIPLY", typical.outputs["Mean"], 1e-3),
_math(group, "MULTIPLY", typical.outputs["Mean"], SOLID_WELD_SCALE),
)
links.new(solid, zero_shell.inputs["True"])
return remove_attribute_pattern(group, zero_shell.outputs[0], "_o_*")
shaped = group.nodes.new("GeometryNodeSwitch")
shaped.input_type = "GEOMETRY"
links.new(has_thickness, shaped.inputs["Switch"])
links.new(projection, shaped.inputs["False"])
links.new(solid, shaped.inputs["True"])
return remove_attribute_pattern(group, shaped.outputs[0], "_o_*")
def build_image_depth_cutout_group():
@@ -216,18 +312,18 @@ def build_image_depth_cutout_group():
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.'
back_smooth = interface_socket(group, 'Back Smooth', 'INPUT', 'NodeSocketInt', options)
back_smooth.default_value, back_smooth.min_value, back_smooth.max_value = 8, 0, 16
back_smooth.description = 'Soften the rear surface where the balloon is thick. The effect follows Thickness and the balloon profile, so thin geometry and cut boundaries keep their positions.'
float_input(group, 'Edge Turn', 2.0, minimum=-3.0, maximum=3.0, parent=options).description = 'Turn the outline direction: zero follows the smoothed normal, one reaches the island average normal, two mirrors the smoothed normal across it, and higher or negative values keep turning either way.'
float_input(group, 'Front Inflation', 0.2, maximum=1, subtype='FACTOR', parent=options).description = 'Control how much the front surface bulges in Balloon mode, as a share 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)
geometry = _build_depth_surface(group, controls.outputs['Geometry'], controls)
_shade_output(group, geometry)
prepare_node_group(group)
for item in group.interface.items_tree: