From 1bf858b17ea849435f2712776903be84d904ed7f Mon Sep 17 00:00:00 2001 From: nan Date: Sun, 13 Sep 2026 22:08:32 +0800 Subject: [PATCH] refactor: Update cutout and depth handling by removing unused functions and adjusting parameters --- src/anyimage/assets/O_AnyImage.blend | 4 +- tests/anyimage/operators/test_cutout_depth.py | 19 ++-- tests/tools/nodes/test_boundary_depth.py | 9 +- tests/tools/nodes/test_boundary_smoothing.py | 10 +- tests/tools/nodes/test_cutout_boundary.py | 2 +- tests/tools/nodes/test_depth_cutout.py | 30 +++++ tests/tools/nodes/test_inventory.py | 18 ++- tests/tools/nodes/test_outline_smoothing.py | 104 ------------------ tools/nodes/common/cutout_boundary.py | 13 ++- tools/nodes/common/cutout_center.py | 78 ------------- tools/nodes/common/outline_smoothing.py | 98 ----------------- tools/nodes/groups/image_depth_cutout.py | 27 +++-- tools/nodes/groups/image_depth_plane.py | 2 +- 13 files changed, 88 insertions(+), 326 deletions(-) delete mode 100644 tests/tools/nodes/test_outline_smoothing.py delete mode 100644 tools/nodes/common/cutout_center.py delete mode 100644 tools/nodes/common/outline_smoothing.py diff --git a/src/anyimage/assets/O_AnyImage.blend b/src/anyimage/assets/O_AnyImage.blend index 44142f0..cd6c9a4 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:dfb219aae1aaaa7031dad450b021a9204cf35b3524bd79a8aa57fa3267dc938b -size 9802561 +oid sha256:b0c3caa511801ab8da4b132caf48af0403999756609d4ee4c2283362f1360b26 +size 9345087 diff --git a/tests/anyimage/operators/test_cutout_depth.py b/tests/anyimage/operators/test_cutout_depth.py index 8669b25..11b733a 100644 --- a/tests/anyimage/operators/test_cutout_depth.py +++ b/tests/anyimage/operators/test_cutout_depth.py @@ -15,27 +15,22 @@ def test_depth_presets_always_generate_the_correct_normal_space(enabled): assert normal_mode_for_shape(shape, enabled) == ("TANGENT" if enabled else "NONE") -def test_new_cutout_does_not_reuse_a_saved_wrapper(): +def test_new_cutout_reuses_a_saved_wrapper(): bpy.ops.wm.read_factory_settings(use_empty=True) saved = bpy.data.node_groups.new("O Image Cutout", "GeometryNodeTree") saved.interface.new_socket(name="Shape", in_out="INPUT", socket_type="NodeSocketMenu") saved.use_fake_user = True - current = load_cutout_node_group("SOLID") - assert current != saved and current.get("anyimage_asset_revision") == 13 - assert saved in bpy.data.node_groups.values() - assert current == load_cutout_node_group("FLAT") + assert load_cutout_node_group("SOLID") == saved + assert load_cutout_node_group("FLAT") == saved + assert saved.name == "O Image Cutout" assert any(item.name == "Shape" for item in saved.interface.items_tree) -def test_depth_cutout_loads_uniform_thickness_revision_and_preserves_existing_group(): +def test_depth_cutout_reuses_existing_group(): bpy.ops.wm.read_factory_settings(use_empty=True) saved = bpy.data.node_groups.new("O Image Depth Cutout", "GeometryNodeTree") - saved["anyimage_asset_revision"] = 11 - current = load_cutout_node_group("DEPTH_SOLID") - assert current != saved and current["anyimage_asset_revision"] == 26 - assert saved in bpy.data.node_groups.values() - assert saved["anyimage_asset_revision"] == 11 - assert load_cutout_node_group("DEPTH_SOLID") == current + assert load_cutout_node_group("DEPTH_SOLID") == saved + assert saved.name == "O Image Depth Cutout" def test_small_quality_mesh_has_positive_balloon_support(): diff --git a/tests/tools/nodes/test_boundary_depth.py b/tests/tools/nodes/test_boundary_depth.py index 577d465..86ce039 100644 --- a/tests/tools/nodes/test_boundary_depth.py +++ b/tests/tools/nodes/test_boundary_depth.py @@ -71,7 +71,7 @@ def test_camera_blur_preserves_rays_interior_protection_and_disconnected_surface -def test_cutout_fine_outline_blurs_depth_without_moving_projection(): +def test_cutout_fine_outline_smooths_only_within_boundary_band(): from tests.support.depth_surface import surface from anyimage.operators.cutout_tool.mesh import build_mesh @@ -90,19 +90,16 @@ def test_cutout_fine_outline_blurs_depth_without_moving_projection(): image.pixels.foreach_set(pixels.ravel()) image.update() set_value("Boundary Smooth", 0) - set_value("Smooth Weight", 0) + set_value("Smooth Weight", 1) before, original_faces = evaluated(obj) set_value("Boundary Smooth", 12) after, result_faces = evaluated(obj) assert result_faces == original_faces - assert after[:, 2].min() > before[:, 2].min() + 1 - # Reference Depth = 1, Uniform Scale = 1 and Depth Scale = 1. - np.testing.assert_allclose(after[:, :2] / (1 - after[:, 2:3]), - before[:, :2] / (1 - before[:, 2:3]), atol=1e-6) band = edge_band(original_faces, boundary_neighbors(original_faces)) interior = [i for i in range(len(before)) if i not in band] assert interior np.testing.assert_array_equal(after[interior], before[interior]) + assert np.max(np.linalg.norm(after - before, axis=1)) > 1e-4 set_value("Boundary Smooth", 0) np.testing.assert_array_equal(evaluated(obj)[0], before) diff --git a/tests/tools/nodes/test_boundary_smoothing.py b/tests/tools/nodes/test_boundary_smoothing.py index 30259d0..618054b 100644 --- a/tests/tools/nodes/test_boundary_smoothing.py +++ b/tests/tools/nodes/test_boundary_smoothing.py @@ -62,7 +62,7 @@ def test_smoothing_reduces_stairs_without_leaving_two_ring_band(triangles): band = edge_band(faces, boundary) movable = np.array([i in band for i in range(len(original))]) assert movable.any() and (~movable).any() - set_value("Boundary Smooth", 20) + set_value("Boundary Smooth", 16) smoothed, result_faces = evaluated(obj) assert result_faces == faces np.testing.assert_array_equal(smoothed[~movable], original[~movable]) @@ -85,11 +85,11 @@ def test_smoothing_reduces_stairs_without_leaving_two_ring_band(triangles): np.testing.assert_array_equal(evaluated(obj)[0], original) -def test_smoothing_without_split_moves_original_boundary_only_within_band(): +def test_smoothing_without_split_moves_outline_only_within_boundary_band(): obj, set_value = diagonal_surface() set_value("Depth Split", 0) original, faces = evaluated(obj) - set_value("Boundary Smooth", 20) + set_value("Boundary Smooth", 16) result, result_faces = evaluated(obj) boundary = boundary_neighbors(faces) band = edge_band(faces, boundary) @@ -97,6 +97,8 @@ def test_smoothing_without_split_moves_original_boundary_only_within_band(): assert np.max(np.linalg.norm(result[list(boundary)] - original[list(boundary)], axis=1)) > 1e-4 np.testing.assert_array_equal(result[outside], original[outside]) assert result_faces == faces + set_value("Boundary Smooth", 0) + np.testing.assert_array_equal(evaluated(obj)[0], original) @pytest.mark.parametrize("mode", [0, 1]) @@ -147,7 +149,7 @@ def test_plane_edge_smoothing_preserves_uvs_and_closes_thickness(): from tests.support.planes import create_surface, evaluated as plane_mesh obj, set_value, _, inputs, image = create_surface("DEPTH") - assert inputs["Boundary Smooth"].default_value == 2 + assert inputs["Boundary Smooth"].default_value == 4 assert inputs["Boundary Smooth"].parent.name == "Options" pixels = np.array(image.pixels[:], np.float32).reshape(8, 16, 4) yy, xx = np.mgrid[:8, :16] diff --git a/tests/tools/nodes/test_cutout_boundary.py b/tests/tools/nodes/test_cutout_boundary.py index 28aba7c..eb2a362 100644 --- a/tests/tools/nodes/test_cutout_boundary.py +++ b/tests/tools/nodes/test_cutout_boundary.py @@ -37,7 +37,7 @@ def test_boundary_triangle_cleanup_is_one_pass(): assert all(len(f) == 3 for f in faces) -@pytest.mark.parametrize("iterations", [0, 20]) +@pytest.mark.parametrize("iterations", [0, 16]) def test_cleaned_sawtooth_smoothing_preserves_face_orientation(iterations): obj, set_value = diagonal_surface(triangles=True) original, faces = evaluated(obj) diff --git a/tests/tools/nodes/test_depth_cutout.py b/tests/tools/nodes/test_depth_cutout.py index 8582953..d568011 100644 --- a/tests/tools/nodes/test_depth_cutout.py +++ b/tests/tools/nodes/test_depth_cutout.py @@ -20,6 +20,36 @@ def store_test_fields(group, geometry, fields): return geometry +@pytest.mark.parametrize("mode", [0, 1]) +def test_normal_smooth_changes_thickness_direction_without_moving_zero_thickness(mode): + from tests.support.depth_surface import surface, evaluated + + obj, set_value = surface(step=1, resolution=10) + xy = np.array([v.co[:2] for v in obj.data.vertices]) + profile = .3 * np.sin(np.pi * xy[:, 0] / 2) * np.sin(np.pi * xy[:, 1]) + profile[abs(profile) < 1e-7] = 0 + obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile) + set_value("Mode", mode) + set_value("Front Inflation", .2) + modifier = obj.modifiers[0] + thickness = modifier_input_identifier(modifier.node_group, "Thickness", subtype="DISTANCE" if mode else "NONE") + + def result(amount, smoothing): + set_modifier_input(modifier, thickness, amount) + set_value("Normal Smooth", smoothing) + 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 + + def set_test_structure(monkeypatch, value): """Control structural amplitude without depending on storage nodes.""" from tools.nodes.groups import image_depth_cutout diff --git a/tests/tools/nodes/test_inventory.py b/tests/tools/nodes/test_inventory.py index 21c52ca..fe8538b 100644 --- a/tests/tools/nodes/test_inventory.py +++ b/tests/tools/nodes/test_inventory.py @@ -25,7 +25,7 @@ def test_saved_parameter_descriptions_match_definitions(): "O Image Cutout": {"Thickness", "Smooth", "Smooth Weight"}, "O Image Depth Cutout": { "Thickness", "Depth Split", "Smooth", "Smooth Weight", - "Boundary Smooth", "Outline Depth Fix", "Front Inflation", + "Boundary Smooth", "Normal Smooth", "Front Inflation", }, "O Image Depth Balloon": {"Thickness", "Smooth", "Smooth Weight"}, "O Image Depth Panorama": { @@ -48,6 +48,12 @@ def test_saved_parameter_descriptions_match_definitions(): ] assert {item.name for item in sockets} == parameters[group.name] assert all(item.description for item in sockets), group.name + if group.name in {"O Image Depth Plane", "O Image Depth Cutout"}: + boundary = next(item for item in sockets if item.name == "Boundary Smooth") + assert boundary.default_value == (4 if group.name == "O Image Depth Plane" else 0) + assert boundary.min_value == 0 + assert boundary.max_value == 16 + assert boundary.parent.name == "Options" if group.name == "O Image Depth Panorama": defaults = {item.name: item.default_value for item in sockets if item.name in {"Subdivide", "Depth Split", "Boundary Smooth"}} @@ -66,8 +72,16 @@ def test_saved_parameter_descriptions_match_definitions(): thickness = [item.description for item in sockets if item.name == "Thickness"] assert len(thickness) == 2 and len(set(thickness)) == 2 if group.name == "O Image Depth Cutout": + assert not {"Outline Depth Fix", "Center Smooth", "Center Subdivide", "Show Center"} & { + item.name for item in group.interface.items_tree if item.item_type == "SOCKET" + } inflation = next(item for item in sockets if item.name == "Front Inflation") - assert inflation.default_value == 0.0 + assert inflation.default_value == pytest.approx(0.2) + normal_smooth = next(item for item in sockets if item.name == "Normal Smooth") + assert normal_smooth.default_value == 64 + assert normal_smooth.min_value == 16 + assert normal_smooth.max_value == 512 + assert normal_smooth.parent.name == "Options" result[group.name] = [ (item.identifier, item.name, item.description) for item in sockets ] diff --git a/tests/tools/nodes/test_outline_smoothing.py b/tests/tools/nodes/test_outline_smoothing.py deleted file mode 100644 index 682bafa..0000000 --- a/tests/tools/nodes/test_outline_smoothing.py +++ /dev/null @@ -1,104 +0,0 @@ -"""Check adaptive outline batches against a numerical graph reference.""" - -from collections import Counter - -import bpy -import numpy as np -import pytest - -from tests.support.depth_surface import evaluated -from tools.nodes.common.outline_smoothing import smooth_outline_camera -from tools.nodes.common.depth_surface import CUT_ATTRIBUTE - - -@pytest.mark.parametrize("steps", [1, 4]) -def test_outline_batches_update_weights_preserve_split_and_camera_rays(steps): - bpy.ops.wm.read_factory_settings(use_empty=True) - points = np.array([(x, y, 0) for y in range(9) for x in range(9)], dtype=float) - faces = [(y*9+x, y*9+x+1, (y+1)*9+x+1, (y+1)*9+x) for y in range(8) for x in range(8)] - deleted = [i % 8 == 4 for i in range(len(faces))] - result_faces = [face for face, remove in zip(faces, deleted) if not remove] - cut = np.isin(points[:, 0], [4, 5]) - depth = np.full(81, 2.0) - depth[2] = 20 - depth[80] = 0 - camera = np.column_stack((points[:, :2]*depth[:, None]/10, depth)) - distance = np.linalg.norm(camera, axis=1) - mesh = bpy.data.meshes.new("Split outline") - mesh.from_pydata(points.tolist(), [], faces) - for name, kind, domain, values, prop in ( - ("test_camera", "FLOAT_VECTOR", "POINT", camera.ravel(), "vector"), - (CUT_ATTRIBUTE, "FLOAT", "POINT", cut, "value"), - ("test_deleted", "BOOLEAN", "FACE", deleted, "value"), - ): - mesh.attributes.new(name, kind, domain).data.foreach_set(prop, values) - obj = bpy.data.objects.new("Outline", mesh) - bpy.context.collection.objects.link(obj) - group = bpy.data.node_groups.new("Adaptive outline test", "GeometryNodeTree") - for direction in ("INPUT", "OUTPUT"): - group.interface.new_socket(name="Geometry", in_out=direction, socket_type="NodeSocketGeometry") - ids = {} - for name in ("Outline Depth Fix", "Boundary Smooth"): - ids[name] = group.interface.new_socket(name=name, in_out="INPUT", socket_type="NodeSocketInt").identifier - source = group.nodes.new("NodeGroupInput") - attributes = {} - for name, kind in (("test_camera", "FLOAT_VECTOR"), ("test_deleted", "BOOLEAN")): - node = group.nodes.new("GeometryNodeInputNamedAttribute") - node.data_type = kind - node.inputs["Name"].default_value = name - attributes[name] = node.outputs["Attribute"] - delete = group.nodes.new("GeometryNodeDeleteGeometry") - delete.domain, delete.mode = "FACE", "ONLY_FACE" - group.links.new(source.outputs["Geometry"], delete.inputs["Geometry"]) - group.links.new(attributes["test_deleted"], delete.inputs["Selection"]) - scale = group.nodes.new("ShaderNodeValue") - scale.outputs[0].default_value = 1 - result = smooth_outline_camera(group, source.outputs["Geometry"], delete.outputs[0], attributes["test_camera"], scale.outputs[0]) - position = group.nodes.new("GeometryNodeSetPosition") - group.links.new(delete.outputs[0], position.inputs["Geometry"]) - group.links.new(result, position.inputs["Position"]) - output = group.nodes.new("NodeGroupOutput") - group.links.new(position.outputs[0], output.inputs["Geometry"]) - modifier = obj.modifiers.new("Outline", "NODES") - modifier.node_group = group - - # ONLY_FACE retains the separating column's edges in the current topology. - edges = np.array([tuple(e.vertices) for e in mesh.edges]) - adjacency = np.zeros((81, 81)) - adjacency[edges[:, 0], edges[:, 1]] = 1 - adjacency += adjacency.T - degree = adjacency.sum(axis=1) - def blur(value, weight): - return (value + weight*(adjacency @ value))/(1 + weight*degree) - counts = Counter(tuple(sorted((a, b))) for f in result_faces for a, b in zip(f, (*f[1:], f[0]))) - boundary = np.zeros(81) - for edge, count in counts.items(): - if count == 1: - boundary[list(edge)] = 1 - protected = boundary.astype(bool) & cut - seed = boundary*(~protected) - influence = np.maximum(seed, blur(blur(seed, 1), 1))*(~protected) - influence *= depth > 1e-8 - for rounds, batch in ((0, steps), (16, 0), (16, steps)): - modifier[ids["Outline Depth Fix"]] = rounds - modifier[ids["Boundary Smooth"]] = batch - obj.update_tag(refresh={"DATA"}) - bpy.context.view_layer.update() - actual, actual_faces = evaluated(obj) - expected = distance.copy() - for _ in range(rounds): - ratio = abs(expected-blur(expected, .5)) - t = np.clip((ratio-.25)/.25, 0, 1) - weight = t*t*(3-2*t)*influence - for _ in range(batch): - expected = blur(expected, weight) - np.testing.assert_allclose(np.linalg.norm(actual, axis=1), expected, atol=2e-5) - assert actual_faces == result_faces - np.testing.assert_array_equal(actual[influence == 0], camera[influence == 0].astype(np.float32)) - if rounds and batch: - assert 2 < actual[2, 2] < 15 - valid = depth > 0 - np.testing.assert_allclose(actual[valid, :2]/actual[valid, 2:3], camera[valid, :2]/camera[valid, 2:3], atol=1e-6) - else: - np.testing.assert_array_equal(actual, camera.astype(np.float32)) - assert not any(n.bl_idname in {"GeometryNodeCaptureAttribute", "GeometryNodeStoreNamedAttribute"} for n in group.nodes) diff --git a/tools/nodes/common/cutout_boundary.py b/tools/nodes/common/cutout_boundary.py index 4b0c509..3b44821 100644 --- a/tools/nodes/common/cutout_boundary.py +++ b/tools/nodes/common/cutout_boundary.py @@ -20,8 +20,8 @@ def remove_boundary_triangles(group, geometry): return delete.outputs["Geometry"] -def taper_split_profile(group, original, geometry): - """Taper balloon thickness through the shared two-ring influence builder.""" +def original_boundary_field(group, original): + """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" @@ -34,8 +34,13 @@ def taper_split_profile(group, original, geometry): links.new(original_uv.outputs["Geometry"], nearest.inputs["Geometry"]) links.new(uv.outputs["Attribute"], nearest.inputs["Sample Position"]) rim = evaluate_field(group, edge_boundary_field(nodes, links), "FLOAT", "POINT") - protected = sample_field(group, original, rim, "FLOAT", index=nearest.outputs["Index"]) - influence = boundary_influence(group, protected) + 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.""" + nodes = group.nodes + influence = boundary_influence(group, original_boundary) controls = group_input(nodes, {"Depth Split"}) band = _math(group, "MULTIPLY", influence, _math(group, "GREATER_THAN", controls.outputs["Depth Split"], 0)) retained = _math(group, "SQRT", _math(group, "MAXIMUM", _math(group, "SUBTRACT", 1, band), 0)) diff --git a/tools/nodes/common/cutout_center.py b/tools/nodes/common/cutout_center.py deleted file mode 100644 index 4d09753..0000000 --- a/tools/nodes/common/cutout_center.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Build a shared center on the original Cutout mesh.""" - - -from .nodes import evaluate_field, group_input, sample_field - - -def vector(group, operation, first, second=None): - node = group.nodes.new("ShaderNodeVectorMath") - node.operation = operation - for value, socket in ((first, node.inputs[0]), (second, node.inputs[3 if operation == "SCALE" else 1])): - if value is None: - continue - if hasattr(value, "node"): - group.links.new(value, socket) - else: - socket.default_value = value - return node.outputs[0] - - -def axes(group, value): - node = group.nodes.new("ShaderNodeSeparateXYZ") - group.links.new(value, node.inputs[0]) - return node.outputs - - -def combine(group, x, y, z=0): - node = group.nodes.new("ShaderNodeCombineXYZ") - for value, socket in zip((x, y, z), node.inputs): - if hasattr(value, "node"): - group.links.new(value, socket) - else: - socket.default_value = value - return node.outputs[0] - - -def set_position(group, geometry, position): - node = group.nodes.new("GeometryNodeSetPosition") - group.links.new(geometry, node.inputs["Geometry"]) - group.links.new(position, node.inputs["Position"]) - return node.outputs[0] - - -def sample_surface(group, geometry, value, data_type, domain_position, query): - """Keep point indices on points before the face interpolation adapts domains.""" - field = evaluate_field(group, sample_field(group, geometry, value, data_type), data_type, "POINT") - flat = set_position(group, geometry, domain_position) - sample = group.nodes.new("GeometryNodeSampleNearestSurface") - sample.data_type = data_type - group.links.new(flat, sample.inputs["Mesh"]) - group.links.new(field, sample.inputs["Value"]) - group.links.new(query, sample.inputs["Sample Position"]) - return sample.outputs[0] - - -def build_center_surface(group, unsplit): - """Smooth the unsplit displaced surface without moving its XY coordinates.""" - nodes, links = group.nodes, group.links - controls = group_input(nodes, {"Center Smooth"}) - position = nodes.new("GeometryNodeInputPosition").outputs[0] - components = axes(group, position) - uv = nodes.new("GeometryNodeInputNamedAttribute") - uv.data_type = "FLOAT_VECTOR" - uv.inputs["Name"].default_value = "UVMap" - coordinates = uv.outputs["Attribute"] - blur = nodes.new("GeometryNodeBlurAttribute") - blur.data_type = "FLOAT" - links.new(components["Z"], blur.inputs["Value"]) - links.new(controls.outputs["Center Smooth"], blur.inputs["Iterations"]) - support = set_position(group, unsplit, combine(group, components["X"], components["Y"], blur.outputs[0])) - height = sample_surface(group, support, components["Z"], "FLOAT", coordinates, coordinates) - normal = nodes.new("GeometryNodeInputNormal").outputs[0] - normal_blur = nodes.new("GeometryNodeBlurAttribute") - normal_blur.data_type = "FLOAT_VECTOR" - links.new(evaluate_field(group, normal, "FLOAT_VECTOR", "POINT"), normal_blur.inputs["Value"]) - links.new(controls.outputs["Center Smooth"], normal_blur.inputs["Iterations"]) - normal = normal_blur.outputs[0] - normal = vector(group, "NORMALIZE", sample_surface(group, support, normal, "FLOAT_VECTOR", coordinates, coordinates)) - return {"geometry": support, "preview": support, "height": height, "normal": normal} diff --git a/tools/nodes/common/outline_smoothing.py b/tools/nodes/common/outline_smoothing.py deleted file mode 100644 index 3b66818..0000000 --- a/tools/nodes/common/outline_smoothing.py +++ /dev/null @@ -1,98 +0,0 @@ -"""Adapt camera depth along the original outline with batched weight updates.""" - -from .boundary_smoothing import boundary_influence, reconstruct_camera_depth -from .depth_surface import CUT_ATTRIBUTE, _math, ray_distance -from .nodes import evaluate_field, group_input, read_float_attribute, sample_field -from .smoothing import edge_boundary_field - - -def _outline_influence(group, original, geometry): - """Identify original outline points and protect split boundary intersections.""" - nodes, links = group.nodes, group.links - rim = evaluate_field(group, edge_boundary_field(nodes, links), "FLOAT", "POINT") - nearest = nodes.new("GeometryNodeSampleNearest") - nearest.domain = "POINT" - links.new(original, nearest.inputs["Geometry"]) - position = nodes.new("GeometryNodeInputPosition").outputs["Position"] - links.new(position, nearest.inputs["Sample Position"]) - original_rim = sample_field(group, original, rim, "FLOAT", index=nearest.outputs["Index"]) - original_rim = _math(group, "GREATER_THAN", original_rim, 0) - cut = _math(group, "GREATER_THAN", read_float_attribute(group, CUT_ATTRIBUTE), 0) - protected = _math(group, "MULTIPLY", _math(group, "GREATER_THAN", rim, 0), - _math(group, "MAXIMUM", cut, _math(group, "SUBTRACT", 1, original_rim))) - # Resolve planar correspondence before replacing positions with camera depth. - return sample_field(group, geometry, boundary_influence(group, protected), "FLOAT") - - -def smooth_outline_camera(group, original, geometry, camera, uniform_scale): - """Update outline weights and blur ray distance in fixed-weight batches.""" - nodes, links = group.nodes, group.links - controls = group_input(nodes, {"Outline Depth Fix", "Boundary Smooth"}) - influence = _outline_influence(group, original, geometry) - depth = ray_distance(group, camera) - - endpoints = nodes.new("GeometryNodeInputMeshEdgeVertices") - length = nodes.new("ShaderNodeVectorMath") - length.operation = "DISTANCE" - links.new(endpoints.outputs["Position 1"], length.inputs[0]) - links.new(endpoints.outputs["Position 2"], length.inputs[1]) - statistic = nodes.new("GeometryNodeAttributeStatistic") - statistic.data_type, statistic.domain = "FLOAT", "EDGE" - links.new(geometry, statistic.inputs["Geometry"]) - links.new(length.outputs["Value"], statistic.inputs["Attribute"]) - scale = _math(group, "DIVIDE", uniform_scale, - _math(group, "MAXIMUM", statistic.outputs["Median"], 1e-8)) - - seed = nodes.new("ShaderNodeCombineXYZ") - links.new(depth, seed.inputs["Z"]) - carrier = nodes.new("GeometryNodeSetPosition") - links.new(geometry, carrier.inputs["Geometry"]) - links.new(seed.outputs[0], carrier.inputs["Position"]) - repeat_output = nodes.new("GeometryNodeRepeatOutput") - repeat_input = nodes.new("GeometryNodeRepeatInput") - repeat_input.pair_with_output(repeat_output) - links.new(carrier.outputs[0], repeat_input.inputs["Geometry"]) - links.new(controls.outputs["Outline Depth Fix"], repeat_input.inputs["Iterations"]) - - position = nodes.new("GeometryNodeInputPosition") - current = nodes.new("ShaderNodeSeparateXYZ") - links.new(position.outputs[0], current.inputs[0]) - reference = nodes.new("GeometryNodeBlurAttribute") - reference.data_type = "FLOAT" - reference.inputs["Iterations"].default_value = 1 - reference.inputs["Weight"].default_value = 0.5 - links.new(current.outputs["Z"], reference.inputs["Value"]) - residual = _math(group, "ABSOLUTE", _math(group, "SUBTRACT", current.outputs["Z"], reference.outputs[0])) - ratio = _math(group, "MULTIPLY", residual, scale) - weight = nodes.new("ShaderNodeMapRange") - weight.interpolation_type, weight.clamp = "SMOOTHSTEP", True - weight.inputs["From Min"].default_value = 0.25 - weight.inputs["From Max"].default_value = 0.5 - links.new(ratio, weight.inputs["Value"]) - active = _math(group, "MULTIPLY", influence, _math(group, "GREATER_THAN", depth, 1e-8)) - active = sample_field(group, geometry, active, "FLOAT") - weighted = _math(group, "MULTIPLY", weight.outputs[0], active) - # Sampling the round's input prevents the inner Blur from updating its weight. - frozen = sample_field(group, repeat_input.outputs["Geometry"], weighted, "FLOAT") - blur = nodes.new("GeometryNodeBlurAttribute") - blur.data_type = "FLOAT" - links.new(current.outputs["Z"], blur.inputs["Value"]) - links.new(frozen, blur.inputs["Weight"]) - links.new(controls.outputs["Boundary Smooth"], blur.inputs["Iterations"]) - updated = nodes.new("ShaderNodeCombineXYZ") - links.new(blur.outputs[0], updated.inputs["Z"]) - move = nodes.new("GeometryNodeSetPosition") - links.new(repeat_input.outputs["Geometry"], move.inputs["Geometry"]) - links.new(updated.outputs[0], move.inputs["Position"]) - links.new(move.outputs[0], repeat_output.inputs["Geometry"]) - - result = sample_field(group, repeat_output.outputs["Geometry"], current.outputs["Z"], "FLOAT") - enabled = _math(group, "MULTIPLY", active, - _math(group, "MULTIPLY", controls.outputs["Outline Depth Fix"], - controls.outputs["Boundary Smooth"])) - choose = nodes.new("GeometryNodeSwitch") - choose.input_type = "FLOAT" - links.new(enabled, choose.inputs["Switch"]) - links.new(depth, choose.inputs["False"]) - links.new(result, choose.inputs["True"]) - return reconstruct_camera_depth(group, camera, depth, choose.outputs[0]) diff --git a/tools/nodes/groups/image_depth_cutout.py b/tools/nodes/groups/image_depth_cutout.py index 7f1d5bd..f475e77 100644 --- a/tools/nodes/groups/image_depth_cutout.py +++ b/tools/nodes/groups/image_depth_cutout.py @@ -5,11 +5,10 @@ from anyimage.operators.cutout_tool.shape import CUTOUT_Z_TO_X, NORMAL_SCALE_ATT from ..common.nodes import interface_socket, group_input, math_node, float_input, menu_switch, read_float_attribute, store_float_attribute, prepare_node_group, sample_field, evaluate_field from ..common.smoothing import expand_smooth from ..common.boundary_smoothing import smooth_cut_boundary -from ..common.outline_smoothing import smooth_outline_camera from ..common.cutout import _island_cleanup, _position_field, _remove_zero_thickness, _shade_output from ..common.depth_surface import _math, project_depth_surface, build_surface_camera, split_depth_surface, sample_face_camera, thicken_depth_surface from ..common.smoothing import edge_boundary_field -from ..common.cutout_boundary import remove_boundary_triangles, taper_split_profile +from ..common.cutout_boundary import original_boundary_field, remove_boundary_triangles, taper_split_profile NODE_GROUP_NAME = "O Image Depth Cutout" @@ -92,7 +91,7 @@ def build_shell_fields(group, geometry, fields, is_shell, normal_weight=1): normal = nodes.new("GeometryNodeInputNormal") blur = nodes.new("GeometryNodeBlurAttribute") blur.data_type = "FLOAT_VECTOR" - blur.inputs["Iterations"].default_value = 100 + links.new(group_input(nodes, {"Normal Smooth"}).outputs["Normal Smooth"], blur.inputs["Iterations"]) links.new(normal.outputs[0], blur.inputs["Value"]) normalized = blend_normal(group, normal.outputs[0], blur.outputs[0], normal_weight) rear_normal = sample_field(group, surface.outputs[0], normalized, "FLOAT_VECTOR") @@ -135,7 +134,9 @@ def build_front_fields(group, volume, front_normal, normal_weight=1): smooth = group.nodes.new("GeometryNodeBlurAttribute") smooth.data_type = "FLOAT_VECTOR" links.new(normal, smooth.inputs["Value"]) - smooth.inputs["Iterations"].default_value = 512 + iterations = group_input(group.nodes, {"Normal Smooth"}).outputs["Normal Smooth"] + # Preserve the existing extra smoothing of the inflation direction at 100. + links.new(_math(group, "MULTIPLY", iterations, 5.12), smooth.inputs["Iterations"]) normalized = blend_normal(group, normal, smooth.outputs[0], normal_weight) return {"structure": structure, "inflation_normal": normalized} @@ -195,7 +196,7 @@ def build_front_normal(group, normal_weight): blur = group.nodes.new("GeometryNodeBlurAttribute") blur.data_type = "FLOAT_VECTOR" links.new(normal.outputs[0], blur.inputs["Value"]) - blur.inputs["Iterations"].default_value = 100 + links.new(group_input(group.nodes, {"Normal Smooth"}).outputs["Normal Smooth"], blur.inputs["Iterations"]) normalized = blend_normal(group, normal.outputs[0], blur.outputs[0], normal_weight) return evaluate_field(group, normalized, "FLOAT_VECTOR", "POINT") @@ -273,7 +274,6 @@ def _build_depth_surface(group, geometry, controls): ) geometry = remove_boundary_triangles(group, geometry) camera = build_surface_camera(group, _position_field(group, image), corner_camera, cut_vertex, split) - camera = smooth_outline_camera(group, original, geometry, camera, scale) projection = project_depth_surface( group, geometry, camera, scale, controls.outputs["Reference Depth"], controls.outputs["Depth Scale"], split, ) @@ -293,7 +293,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, original, projection) + projection = taper_split_profile(group, projection, original_boundary_field(group, original)) # The mode field is shared by the geometry and thickness choices. mode = group.nodes.new("GeometryNodeMenuSwitch") mode.data_type = "BOOLEAN" @@ -341,7 +341,6 @@ def build_image_depth_cutout_group(): 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'] = 26 interface_socket(group, 'Geometry', 'INPUT', 'NodeSocketGeometry') interface_socket(group, 'Mode', 'INPUT', 'NodeSocketMenu') float_input(group, 'Thickness', 1.0).description = 'Control how much the surface bulges in Balloon mode. Zero removes the bulge.' @@ -358,12 +357,12 @@ def build_image_depth_cutout_group(): float_input(group, 'Reference Depth', 1.0, subtype='DISTANCE', parent=options) interface_socket(group, 'Depth Axis', 'INPUT', 'NodeSocketMenu', options).menu_expanded = True boundary_smooth = interface_socket(group, 'Boundary Smooth', 'INPUT', 'NodeSocketInt', options) - boundary_smooth.default_value, boundary_smooth.min_value, boundary_smooth.max_value = 2, 1, 20 - boundary_smooth.description = 'Control boundary smoothing within each Outline Depth Fix pass. Higher values apply more smoothing per pass.' - 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.0, maximum=1, subtype='FACTOR', parent=options).description = 'Control how much the front surface bulges in Balloon mode. Zero keeps the original front surface.' + boundary_smooth.default_value, boundary_smooth.min_value, boundary_smooth.max_value = 0, 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 = 64, 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. 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 diff --git a/tools/nodes/groups/image_depth_plane.py b/tools/nodes/groups/image_depth_plane.py index 30f16b9..9e2b47f 100644 --- a/tools/nodes/groups/image_depth_plane.py +++ b/tools/nodes/groups/image_depth_plane.py @@ -53,7 +53,7 @@ def build_image_depth_plane_group(image_plane_group): float_input(group, "Smooth Weight", 1.0, maximum=1, subtype="FACTOR", parent=options).description = "Control the strength of each mesh smoothing pass. Higher values move vertices more." float_input(group, "Reference Depth", 1.0, subtype="DISTANCE", parent=options) boundary_smooth = _interface_socket(group, "Boundary Smooth", "INPUT", "NodeSocketInt", parent=options) - boundary_smooth.default_value, boundary_smooth.min_value, boundary_smooth.max_value = 2, 0, 20 + boundary_smooth.default_value, boundary_smooth.min_value, boundary_smooth.max_value = 4, 0, 16 boundary_smooth.description = "Smooth depth and geometry near cut edges while preserving the original image border. Zero disables boundary smoothing." validity_threshold = _interface_socket(group, "Mask Threshold", "INPUT", "NodeSocketFloat", parent=options) validity_threshold.description = "Set the minimum depth mask value treated as valid. Higher values exclude more partially valid boundary areas."