From 0e0287d3407ba94ace344a64c4e7dbace34975e7 Mon Sep 17 00:00:00 2001 From: nan Date: Fri, 18 Sep 2026 01:43:52 +0800 Subject: [PATCH] refactor: update depth cutout and surface handling for improved geometry manipulation and clarity --- src/anyimage/assets/O_AnyImage.blend | 4 +- tests/conftest.py | 5 + tests/tools/nodes/test_arrangement.py | 2 +- tests/tools/nodes/test_cutout_boundary.py | 113 +++++---- tests/tools/nodes/test_depth_cutout.py | 42 ++-- tests/tools/nodes/test_depth_surface_split.py | 8 +- tests/tools/nodes/test_inventory.py | 6 +- .../tools/nodes/test_temporary_attributes.py | 5 +- tools/nodes/check.py | 10 +- tools/nodes/common/boundary_smoothing.py | 2 + tools/nodes/common/cutout_boundary.py | 38 +-- tools/nodes/common/depth_surface.py | 2 +- tools/nodes/groups/image_depth_cutout.py | 226 +++++++++++++----- 13 files changed, 272 insertions(+), 191 deletions(-) diff --git a/src/anyimage/assets/O_AnyImage.blend b/src/anyimage/assets/O_AnyImage.blend index a16632c..37d15ca 100644 --- a/src/anyimage/assets/O_AnyImage.blend +++ b/src/anyimage/assets/O_AnyImage.blend @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3fa3260664fda9f97186b5cefe664e78047b15f53c13193caf31faf6c32dfffe -size 7908519 +oid sha256:c49b4737db174008d4fb470de4179e2e4ef0456f96f44406b5f331f5626249ba +size 7934350 diff --git a/tests/conftest.py b/tests/conftest.py index 5debf26..39cb052 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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")] diff --git a/tests/tools/nodes/test_arrangement.py b/tests/tools/nodes/test_arrangement.py index a284e20..b360b85 100644 --- a/tests/tools/nodes/test_arrangement.py +++ b/tests/tools/nodes/test_arrangement.py @@ -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: diff --git a/tests/tools/nodes/test_cutout_boundary.py b/tests/tools/nodes/test_cutout_boundary.py index 2c46326..fec82bc 100644 --- a/tests/tools/nodes/test_cutout_boundary.py +++ b/tests/tools/nodes/test_cutout_boundary.py @@ -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"]) + obj.update_tag(refresh={"DATA"}) + bpy.context.view_layer.update() + result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) + mesh = result.to_mesh() + try: + return np.array([getattr(item, reader) for item in mesh.attributes[name].data]) + finally: + 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] - 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() - try: - results.append(np.array(mesh.attributes["normal_result"].data[0].vector[:])) - 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 + 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) diff --git a/tests/tools/nodes/test_depth_cutout.py b/tests/tools/nodes/test_depth_cutout.py index 3289049..59cccac 100644 --- a/tests/tools/nodes/test_depth_cutout.py +++ b/tests/tools/nodes/test_depth_cutout.py @@ -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 diff --git a/tests/tools/nodes/test_depth_surface_split.py b/tests/tools/nodes/test_depth_surface_split.py index 124f1f1..abf593b 100644 --- a/tests/tools/nodes/test_depth_surface_split.py +++ b/tests/tools/nodes/test_depth_surface_split.py @@ -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) diff --git a/tests/tools/nodes/test_inventory.py b/tests/tools/nodes/test_inventory.py index 4b59211..27b9297 100644 --- a/tests/tools/nodes/test_inventory.py +++ b/tests/tools/nodes/test_inventory.py @@ -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"), diff --git a/tests/tools/nodes/test_temporary_attributes.py b/tests/tools/nodes/test_temporary_attributes.py index e8f3992..dcc74f3 100644 --- a/tests/tools/nodes/test_temporary_attributes.py +++ b/tests/tools/nodes/test_temporary_attributes.py @@ -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 diff --git a/tools/nodes/check.py b/tools/nodes/check.py index 811f0a9..998ca24 100644 --- a/tools/nodes/check.py +++ b/tools/nodes/check.py @@ -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 diff --git a/tools/nodes/common/boundary_smoothing.py b/tools/nodes/common/boundary_smoothing.py index 8777490..1621943 100644 --- a/tools/nodes/common/boundary_smoothing.py +++ b/tools/nodes/common/boundary_smoothing.py @@ -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 diff --git a/tools/nodes/common/cutout_boundary.py b/tools/nodes/common/cutout_boundary.py index c4d4ba8..20cddb2 100644 --- a/tools/nodes/common/cutout_boundary.py +++ b/tools/nodes/common/cutout_boundary.py @@ -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) diff --git a/tools/nodes/common/depth_surface.py b/tools/nodes/common/depth_surface.py index 42b8df7..70e86f6 100644 --- a/tools/nodes/common/depth_surface.py +++ b/tools/nodes/common/depth_surface.py @@ -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): diff --git a/tools/nodes/groups/image_depth_cutout.py b/tools/nodes/groups/image_depth_cutout.py index 6f3502d..0f59707 100644 --- a/tools/nodes/groups/image_depth_cutout.py +++ b/tools/nodes/groups/image_depth_cutout.py @@ -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: