Implement arrangement framework for node groups

- Introduced `frames.py` to manage field complex calculations and their arrangement within node groups.
- Added functionality to create and remove field frames based on node arrangements.
- Developed `passages.py` to find unobstructed paths for connections around calculation frames.
- Implemented `references.py` to route shared results on peripheral tracks, ensuring efficient distribution of outputs.
- Created `sections.py` to reserve space for field calculations adjacent to geometry consumers.
- Added `sources.py` to position shared calculation sources effectively beside their distribution channels.
- Enhanced outline smoothing with `outline_smoothing.py` to adapt camera depth along original outlines.
This commit is contained in:
2026-09-12 12:19:07 +08:00
parent dd32ccdc44
commit 5b2dc59f79
57 changed files with 2253 additions and 700 deletions

View File

@@ -4,9 +4,7 @@ import numpy as np
import pytest
from tools.nodes.groups.image_depth_cutout import build_volume_fields
from tools.nodes.groups.image_depth_cutout import build_volume_rounding
from anyimage.common.object import modifier_input_identifier, set_modifier_input
from tools.nodes.common.nodes import sample_field
def store_test_fields(group, geometry, fields):
@@ -37,8 +35,7 @@ def set_test_structure(monkeypatch, value):
monkeypatch.setattr(image_depth_cutout, "build_front_fields", fields)
@pytest.mark.parametrize("iterations", [0, 50])
def test_uniform_thickness_follows_the_tilted_front_normal(iterations):
def test_uniform_thickness_follows_the_tilted_front_normal():
from scipy.spatial import cKDTree
from tests.support.depth_surface import surface, evaluated, assert_closed
obj, set_value = surface(step=0, resolution=4)
@@ -47,7 +44,8 @@ def test_uniform_thickness_follows_the_tilted_front_normal(iterations):
pixels[..., 2] = 1 + 0.4 * pixels[..., 0]
image.pixels.foreach_set(pixels.ravel())
image.update()
set_value("Normal Smooth", iterations)
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
front, _ = evaluated(obj)
set_value("Thickness", 0.2)
points, faces = evaluated(obj)
@@ -66,8 +64,6 @@ def test_balloon_shell_converges_to_front_with_thickness():
profile[abs(profile) < 1e-7] = 0
obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile)
set_value("Mode", 0)
set_value("Edge Round", 0.0)
set_value("Normal Smooth", 0)
modifier = obj.modifiers[0]
thickness = modifier_input_identifier(modifier.node_group, "Thickness", subtype="NONE")
def amount(value):
@@ -85,57 +81,6 @@ def test_balloon_shell_converges_to_front_with_thickness():
np.testing.assert_allclose(np.array(gaps[1:]) / gaps[:-1], 0.1, rtol=0.02)
@pytest.mark.parametrize("amount", [0.0, 1.0])
def test_rounding_shares_front_and_back_falloff(amount):
bpy.ops.wm.read_factory_settings(use_empty=True)
n = 15
xy = np.array([(x, y) for y in np.linspace(-1, 1, n) for x in np.linspace(-1, 1, n)])
balloon = 0.4 * np.maximum(0, (1 - xy[:, 0]**2) * (1 - xy[:, 1]**2))
depth = 0.2 * xy[:, 0] + 0.3 * xy[:, 1] + 1.4 * balloon
faces = [(y*n+x, y*n+x+1, (y+1)*n+x+1, (y+1)*n+x) for y in range(n-1) for x in range(n-1)]
mesh = bpy.data.meshes.new("Round profile")
mesh.from_pydata(np.column_stack((xy, depth)), [], faces)
mesh.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", balloon)
obj = bpy.data.objects.new("Round profile", mesh)
bpy.context.collection.objects.link(obj)
group = bpy.data.node_groups.new("Round field test", "GeometryNodeTree")
for name, kind, default in (("Geometry", "NodeSocketGeometry", None), ("Thickness", "NodeSocketFloat", 1), ("Uniform Thickness", "NodeSocketFloat", 0), ("Edge Round", "NodeSocketFloat", amount), ("Normal Smooth", "NodeSocketInt", 20)):
socket = group.interface.new_socket(name=name, in_out="INPUT", socket_type=kind)
if default is not None:
socket.default_value = default
group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
source = group.nodes.new("NodeGroupInput")
fields = build_volume_fields(group)
geometry = source.outputs["Geometry"]
center = {"center": sample_field(group, geometry, fields["center"], "FLOAT")}
mode = group.nodes.new("FunctionNodeInputBool")
result, rounded = build_volume_rounding(group, geometry, fields, mode.outputs[0])
rounded = {name: sample_field(group, geometry, value, "FLOAT_VECTOR" if name == "normal" else "FLOAT")
for name, value in rounded.items()}
result = store_test_fields(group, result, {**center, **rounded})
group.links.new(result, group.nodes.new("NodeGroupOutput").inputs["Geometry"])
obj.modifiers.new("Round", "NODES").node_group = group
bpy.context.view_layer.update()
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
result = evaluated.to_mesh()
try:
def values(name):
return np.array([v.value for v in result.attributes[name].data])
center = values("center")
u = balloon / balloon.max()
weight = amount * (1 - u**3 * (u * (6*u - 15) + 10))
delta = (center + balloon - depth) * weight
original = np.maximum(depth - center + balloon, 0.05 * balloon)
np.testing.assert_allclose(values("edge_weight"), weight, atol=1e-6)
np.testing.assert_allclose(values("thickness"), original + delta + balloon * weight, atol=1e-6)
np.testing.assert_allclose([v.co.z for v in result.vertices], depth + delta, atol=1e-6)
normals = np.array([v.vector[:] for v in result.attributes["normal"].data])
np.testing.assert_allclose(np.linalg.norm(normals, axis=1), 1, atol=1e-6)
assert np.max(abs(normals[:, :2])) > 0.1
finally:
evaluated.to_mesh_clear()
def test_smooth_back_rejects_front_noise_and_preserves_center_depth():
bpy.ops.wm.read_factory_settings(use_empty=True)
n = 41
@@ -197,7 +142,8 @@ def test_depth_cutout_normal_map_only_affects_front_faces():
@pytest.mark.parametrize("structure", [0, 1, 4])
@pytest.mark.parametrize("thickness", [0.0, 0.02, 0.1])
def test_uniform_front_inflation_scales_with_distance(thickness, structure, monkeypatch):
@pytest.mark.parametrize("inflation", [0.0, 0.0001, 0.5, 1.0])
def test_uniform_front_inflation_scales_with_distance(thickness, structure, inflation, monkeypatch):
from tests.support.depth_surface import surface, evaluated, assert_closed
set_test_structure(monkeypatch, structure)
obj, set_value = surface(step=0, resolution=10)
@@ -208,12 +154,12 @@ def test_uniform_front_inflation_scales_with_distance(thickness, structure, monk
set_value("Thickness", thickness)
set_value("Front Inflation", 0.0)
baseline, faces = evaluated(obj)
set_value("Front Inflation", 1.0)
set_value("Front Inflation", inflation)
inflated, inflated_faces = evaluated(obj)
assert faces == inflated_faces
delta = np.linalg.norm(inflated - baseline, axis=1)
front = np.isclose(baseline[:, 2], baseline[:, 2].max(), atol=1e-6)
np.testing.assert_allclose(delta[front], thickness, atol=2e-6)
np.testing.assert_allclose(delta[front], thickness * inflation, atol=2e-6)
np.testing.assert_allclose(delta[~front], 0, atol=2e-6)
if thickness:
assert_closed(inflated_faces)
@@ -221,35 +167,6 @@ def test_uniform_front_inflation_scales_with_distance(thickness, structure, monk
np.testing.assert_array_equal(inflated, baseline)
def test_normal_smooth_controls_front_and_rear_directions():
from tests.support.depth_surface import surface, evaluated
obj, set_value = surface(step=1, resolution=10)
set_value("Front Inflation", 1.0)
xy = np.array([v.co[:2] for v in obj.data.vertices])
profile = 0.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)
group = obj.modifiers[0].node_group
set_value("Mode", 0)
set_modifier_input(obj.modifiers[0], modifier_input_identifier(group, "Thickness", subtype="NONE"), 1.0)
masks = []
coordinates = []
for iterations in (0, 50):
set_value("Normal Smooth", iterations)
points, faces = evaluated(obj)
ev = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
mesh = ev.to_mesh()
mask = [v.value for v in mesh.attributes["o_normal_scale"].data]
front = {v for face, weight in zip(faces, mask) if weight > 0.5 for v in face}
rear = set(range(len(points))) - front
ev.to_mesh_clear()
coordinates.append(points)
masks.append((front, rear))
assert masks[0] == masks[1]
for ids in masks[0]:
assert np.max(abs(coordinates[1][list(ids)] - coordinates[0][list(ids)])) > 1e-5
def test_balloon_suppression_is_stronger_without_a_startup_threshold(monkeypatch):
from tests.support.depth_surface import surface, evaluated
set_test_structure(monkeypatch, 1)
@@ -260,7 +177,6 @@ def test_balloon_suppression_is_stronger_without_a_startup_threshold(monkeypatch
obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile)
group = obj.modifiers[0].node_group
set_value("Mode", 0)
set_value("Edge Round", 0)
thickness_id = modifier_input_identifier(group, "Thickness", subtype="NONE")
previous = 0
for thickness in (0.001, 0.01, 0.1, 0.5, 1):