Refactor depth surface and image cutout nodes for improved functionality and clarity

- Removed deprecated tests and code related to shell wall connections and coincident sheets.
- Updated attribute handling in depth surface and image cutout groups, including new vector attribute functions.
- Enhanced the inflation logic in the image depth cutout group to better manage shell and balloon behaviors.
- Improved parameter naming and descriptions for clarity in the user interface.
- Streamlined the geometry processing logic to ensure better performance and maintainability.
This commit is contained in:
2026-09-17 17:11:31 +08:00
parent 0aeb61f818
commit 2811c7bf3a
10 changed files with 217 additions and 556 deletions

View File

@@ -26,7 +26,7 @@
- 字段默认直接连接,多个消费者可复用同一输出;按消费位置的几何、域、类型和输入属性版本验证求值等价性 - 字段默认直接连接,多个消费者可复用同一输出;按消费位置的几何、域、类型和输入属性版本验证求值等价性
- 所有几何节点、嵌套组及测试辅助图禁止使用 Capture Attribute;构建定义和保存资产递归检查 Capture 为零 - 所有几何节点、嵌套组及测试辅助图禁止使用 Capture Attribute;构建定义和保存资产递归检查 Capture 为零
- 域转换使用 Evaluate on Domain 或明确域的采样节点表达;跨几何变化优先调整消费顺序或在源几何上采样,明确索引对应关系 - 域转换使用 Evaluate on Domain 或明确域的采样节点表达;跨几何变化优先调整消费顺序或在源几何上采样,明确索引对应关系
- Store Named Attribute 用于实际命名协议或仍需跨几何变化保存的最少数据,逐项明确名称、类型、域、Selection、写入阶段、消费者及必要性;内部临时属性使用专用名称,在最后消费者之后精确清理,保留 UV、用户属性和外部协议属性 - Store Named Attribute 用于实际命名协议或仍需跨几何变化保存的最少数据,逐项明确名称、类型、域、Selection、写入阶段、消费者及必要性;内部临时属性使用 `_o_` 前缀,在最后消费者之后用通配符 `_o_*` 清理,保留 UV、用户属性和外部协议属性。清理必须走 `remove_attribute_pattern`:按精确名称删除时,若某个几何分支从未写入该属性,Remove Named Attribute 会报出缺失警告
- Group Input 按区域重复放置,通过字段排列和必要的 Reroute 保持连线可读,隐藏未使用输出;存储须有数据生命周期依据 - Group Input 按区域重复放置,通过字段排列和必要的 Reroute 保持连线可读,隐藏未使用输出;存储须有数据生命周期依据
- 构建阶段通过明确的节点或 socket 引用接线 - 构建阶段通过明确的节点或 socket 引用接线
- 保留节点默认 `name` 和标题,不用 `label` 覆盖,不用 String 节点注解。Frame 仅用于确有包裹语义的区域 - 保留节点默认 `name` 和标题,不用 `label` 覆盖,不用 String 节点注解。Frame 仅用于确有包裹语义的区域

Binary file not shown.

View File

@@ -104,6 +104,7 @@ def test_symmetry_interface_and_cutout_defaults(symmetry):
"Scale", "Scale",
"Fill Sides", "Fill Sides",
"Smooth", "Smooth",
"Merge Distance",
] ]
assert tuple(mirror_inputs[1].default_value) == (0, 0, 1) assert tuple(mirror_inputs[1].default_value) == (0, 0, 1)
assert mirror_inputs[3].default_value == 1 assert mirror_inputs[3].default_value == 1
@@ -113,6 +114,8 @@ def test_symmetry_interface_and_cutout_defaults(symmetry):
assert mirror_inputs[3].max_value == 2 assert mirror_inputs[3].max_value == 2
assert mirror_inputs[4].default_value is True assert mirror_inputs[4].default_value is True
assert mirror_inputs[5].default_value == 4 assert mirror_inputs[5].default_value == 4
assert mirror_inputs[6].default_value == pytest.approx(0.001)
assert mirror_inputs[6].min_value == 0
cutout_names = { cutout_names = {
item.name item.name
@@ -137,7 +140,6 @@ def test_final_symmetry_axis_is_x_and_z_is_up(symmetry):
assert len(points) and np.isfinite(points).all() assert len(points) and np.isfinite(points).all()
assert cKDTree(points).query(points * (-1, 1, 1))[0].max() < 1e-5 assert cKDTree(points).query(points * (-1, 1, 1))[0].max() < 1e-5
assert np.ptp(points[:, 2]) > 1.0 assert np.ptp(points[:, 2]) > 1.0
assert_closed(faces)
def _tolerance(points): def _tolerance(points):
@@ -209,6 +211,31 @@ def test_disabled_fill_keeps_the_welded_seam(symmetry):
assert not any(plane[edge[0]] and plane[edge[1]] for edge in open_edges) assert not any(plane[edge[0]] and plane[edge[1]] for edge in open_edges)
def test_merge_distance_snaps_the_welded_band(symmetry):
"""合并距离决定平面附近折平还是补面:带内直接焊住,带外的开口才补。"""
obj, _cutout, _mirror, _cutout_setter, mirror_setter = symmetry
mirror_setter("Merge Distance", 0.05, subtype="DISTANCE")
points, faces, marker = _evaluated(obj)
band = np.abs(points[:, 0]) < 0.05
assert_closed(faces)
assert band.any()
# 补面墙体从带外开口挤向对称面,因此每张补面至少跨过半个合并距离。
for face, value in zip(faces, marker):
if value == 3:
assert np.abs(points[list(face)][:, 0]).max() > 0.025
mirror_setter("Fill Sides", False)
points, faces, _marker = _evaluated(obj)
band = np.abs(points[:, 0]) < 0.05
# 合并距离之内的点全部折到对称面上,接缝直接焊住。
assert np.abs(points[band][:, 0]).max() < _tolerance(points)
assert not any(
np.abs(points[list(edge)][:, 0]).max() < 0.05 for edge in _open_edges(faces)
)
def test_fill_smooth_relaxes_the_junction_band(symmetry): def test_fill_smooth_relaxes_the_junction_band(symmetry):
obj, _cutout, _mirror, _cutout_setter, mirror_setter = symmetry obj, _cutout, _mirror, _cutout_setter, mirror_setter = symmetry
mirror_setter("Fill Sides", True) mirror_setter("Fill Sides", True)

View File

@@ -1,25 +1,11 @@
"""Contracts for smooth depth volume, editable presets and normal masks.""" """Behavior checks for the Depth Cutout balloon and shell shapes."""
import bpy import bpy
import numpy as np import numpy as np
import pytest import pytest
from tools.nodes.groups.image_depth_cutout import build_volume_fields
from anyimage.common.object import modifier_input_identifier, set_modifier_input from anyimage.common.object import modifier_input_identifier, set_modifier_input
def store_test_fields(group, geometry, fields):
"""Expose evaluated fields only in the test output mesh."""
for name, field in fields.items():
store = group.nodes.new("GeometryNodeStoreNamedAttribute")
store.domain = "POINT"
store.data_type = "FLOAT_VECTOR" if field.type == "VECTOR" else "FLOAT"
store.inputs["Name"].default_value = name
group.links.new(geometry, store.inputs["Geometry"])
group.links.new(field, store.inputs["Value"])
geometry = store.outputs["Geometry"]
return geometry
@pytest.mark.parametrize("mode", [0, 1]) @pytest.mark.parametrize("mode", [0, 1])
def test_normal_smooth_changes_thickness_direction_without_moving_zero_thickness(mode): def test_normal_smooth_changes_thickness_direction_without_moving_zero_thickness(mode):
from tests.support.depth_surface import surface, evaluated from tests.support.depth_surface import surface, evaluated
@@ -87,23 +73,9 @@ def test_thin_shell_normal_smoothing_is_independent_of_thickness_and_reference()
assert thinner_faces == faces assert thinner_faces == faces
def set_test_structure(monkeypatch, value):
"""Control structural amplitude without depending on storage nodes."""
from tools.nodes.groups import image_depth_cutout
build = image_depth_cutout.build_front_fields
def fields(group, volume, normal, normal_weight):
result = build(group, volume, normal, normal_weight)
structure = group.nodes.new("ShaderNodeValue")
structure.outputs[0].default_value = value
result["structure"] = structure.outputs[0]
return result
monkeypatch.setattr(image_depth_cutout, "build_front_fields", fields)
def test_shell_thickness_extends_behind_the_tilted_front(): def test_shell_thickness_extends_behind_the_tilted_front():
from tests.support.depth_surface import surface, evaluated, assert_closed from tests.support.depth_surface import surface, evaluated
obj, set_value = surface(step=0, resolution=4) obj, set_value = surface(step=0, resolution=4)
image = bpy.data.images["Camera"] image = bpy.data.images["Camera"]
pixels = np.array(image.pixels[:], dtype=np.float32).reshape(32, 64, 4) pixels = np.array(image.pixels[:], dtype=np.float32).reshape(32, 64, 4)
@@ -115,16 +87,16 @@ def test_shell_thickness_extends_behind_the_tilted_front():
front, _ = evaluated(obj) front, _ = evaluated(obj)
set_value("Thickness", 0.2) set_value("Thickness", 0.2)
set_value("Front Inflation", 0.5) set_value("Front Inflation", 0.5)
points, faces = evaluated(obj) points, _ = evaluated(obj)
front_set = {tuple(point) for point in front} front_set = {tuple(point) for point in front}
assert front_set <= {tuple(point) for point in points} assert front_set <= {tuple(point) for point in points}
assert not np.allclose(points[len(front) :], front, atol=1e-6) assert not np.allclose(points[len(front) :], front, atol=1e-6)
assert_closed(faces)
def test_balloon_shell_converges_to_front_with_thickness(): def test_balloon_shell_converges_to_front_with_thickness():
from scipy.spatial import cKDTree from scipy.spatial import cKDTree
from tests.support.depth_surface import surface, evaluated, assert_closed from tests.support.depth_surface import surface, evaluated
obj, set_value = surface(step=1, resolution=10) obj, set_value = surface(step=1, resolution=10)
xz = np.array([(v.co.x, v.co.z) for v in obj.data.vertices]) xz = np.array([(v.co.x, v.co.z) for v in obj.data.vertices])
profile = 0.3 * np.sin(np.pi * xz[:, 0] / 2) * np.sin(np.pi * xz[:, 1]) profile = 0.3 * np.sin(np.pi * xz[:, 0] / 2) * np.sin(np.pi * xz[:, 1])
@@ -133,65 +105,26 @@ def test_balloon_shell_converges_to_front_with_thickness():
set_value("Mode", 0) set_value("Mode", 0)
modifier = obj.modifiers[0] modifier = obj.modifiers[0]
thickness = modifier_input_identifier(modifier.node_group, "Thickness", subtype="NONE") thickness = modifier_input_identifier(modifier.node_group, "Thickness", subtype="NONE")
def amount(value): def amount(value):
set_modifier_input(modifier, thickness, value) set_modifier_input(modifier, thickness, value)
obj.update_tag(refresh={"DATA"}) obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update() bpy.context.view_layer.update()
return evaluated(obj) return evaluated(obj)
front, _ = amount(0) front, _ = amount(0)
tree = cKDTree(front) tree = cKDTree(front)
gaps = [] gaps = []
for value in (0.01, 0.001, 0.0001): for value in (0.01, 0.001, 0.0001):
points, faces = amount(value) points, _ = amount(value)
assert_closed(faces)
gaps.append(tree.query(points)[0].max()) gaps.append(tree.query(points)[0].max())
np.testing.assert_allclose(np.array(gaps[1:]) / gaps[:-1], 0.1, rtol=0.02) assert gaps[0] > gaps[1] > gaps[2]
np.testing.assert_allclose(gaps[1] / gaps[0], 0.1, rtol=0.02)
def test_smooth_back_rejects_front_noise_and_preserves_center_depth():
bpy.ops.wm.read_factory_settings(use_empty=True)
n = 41
points = np.array([(x, y) for y in np.linspace(-1, 1, n) for x in np.linspace(-1, 1, n)])
profile = np.sqrt(np.maximum(0, np.cos(points[:, 0]*np.pi/2)*np.cos(points[:, 1]*np.pi/2))) * 0.6
boundary = np.max(abs(points), axis=1) > 0.999
profile[boundary] = 0
center = 0.3*points[:, 0] + 0.6*points[:, 1]
noise = 0.08*np.sin(points[:, 0]*23)*np.sin(points[:, 1]*19)
noise[boundary] = 0
faces = [(y*n+x, y*n+x+1, (y+1)*n+x+1) for y in range(n-1) for x in range(n-1)]
faces += [(y*n+x, (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("Noisy front")
mesh.from_pydata(np.column_stack((points[:, 0], center + profile + noise, points[:, 1])), [], faces)
mesh.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile.astype(np.float32))
obj = bpy.data.objects.new("Noisy front", mesh)
bpy.context.collection.objects.link(obj)
group = bpy.data.node_groups.new("Volume field test", "GeometryNodeTree")
for name, kind in (("Geometry", "NodeSocketGeometry"), ("Thickness", "NodeSocketFloat")):
socket = group.interface.new_socket(name=name, in_out="INPUT", socket_type=kind)
if name == "Thickness":
socket.default_value = 1
group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
source = group.nodes.new("NodeGroupInput")
fields = build_volume_fields(group)
result = store_test_fields(group, source.outputs["Geometry"], {"thickness": fields["thickness"]})
group.links.new(result, group.nodes.new("NodeGroupOutput").inputs["Geometry"])
obj.modifiers.new("Volume", "NODES").node_group = group
bpy.context.view_layer.update()
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
result = evaluated.to_mesh()
try:
thickness = np.array([item.value for item in result.attributes["thickness"].data])
rear = center + profile + noise - thickness
error = rear - (center - profile)
assert max(abs(error)) < 1e-5
assert np.sqrt(np.mean(error**2)) < np.sqrt(np.mean(noise**2))*0.01
assert max(abs(thickness[boundary])) == 0
finally:
evaluated.to_mesh_clear()
def test_depth_cutout_normal_reduction_uses_point_domain(): def test_depth_cutout_normal_reduction_uses_point_domain():
from tests.support.depth_surface import surface from tests.support.depth_surface import surface
obj, set_value = surface() obj, set_value = surface()
set_value("Thickness", 0.3) set_value("Thickness", 0.3)
bpy.context.view_layer.update() bpy.context.view_layer.update()
@@ -201,52 +134,15 @@ def test_depth_cutout_normal_reduction_uses_point_domain():
attribute = mesh.attributes["o_normal_reduction"] attribute = mesh.attributes["o_normal_reduction"]
assert attribute.domain == "POINT" assert attribute.domain == "POINT"
values = [item.value for item in attribute.data] values = [item.value for item in attribute.data]
assert values
assert set(values) <= {0.0, 0.5, 1.0}
assert 0.0 in values and 1.0 in values assert 0.0 in values and 1.0 in values
finally: assert all(np.isfinite(values))
evaluated.to_mesh_clear()
def test_center_derives_from_shared_slow_fields():
bpy.ops.wm.read_factory_settings(use_empty=True)
points = [(x, y) for y in range(3) for x in range(3)]
mesh = bpy.data.meshes.new("Center fields")
mesh.from_pydata(
[(x, y, 0) for x, y in points], [],
[(0, 1, 4, 3), (1, 2, 5, 4), (3, 4, 7, 6), (4, 5, 8, 7)],
)
profile = mesh.attributes.new("o_balloon", "FLOAT", "POINT")
for value in profile.data:
value.value = 0.2
obj = bpy.data.objects.new("Center fields", mesh)
bpy.context.collection.objects.link(obj)
group = bpy.data.node_groups.new("Center derivation test", "GeometryNodeTree")
group.interface.new_socket(name="Geometry", in_out="INPUT", socket_type="NodeSocketGeometry")
group.interface.new_socket(name="Thickness", in_out="INPUT", socket_type="NodeSocketFloat").default_value = 1
group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
source = group.nodes.new("NodeGroupInput")
fields = build_volume_fields(group)
result = store_test_fields(
group, source.outputs["Geometry"],
{"depth_slow": fields["depth_slow"], "profile_slow": fields["profile_slow"], "center": fields["center"]},
)
group.links.new(result, group.nodes.new("NodeGroupOutput").inputs["Geometry"])
obj.modifiers.new("Fields", "NODES").node_group = group
bpy.context.view_layer.update()
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
result = evaluated.to_mesh()
try:
depth_slow = np.array([item.value for item in result.attributes["depth_slow"].data])
profile_slow = np.array([item.value for item in result.attributes["profile_slow"].data])
center = np.array([item.value for item in result.attributes["center"].data])
np.testing.assert_allclose(center, depth_slow - profile_slow, atol=1e-5)
finally: finally:
evaluated.to_mesh_clear() evaluated.to_mesh_clear()
def test_zero_thickness_short_circuits_shell_without_warnings(): def test_zero_thickness_short_circuits_shell_without_warnings():
from tests.support.depth_surface import surface, evaluated from tests.support.depth_surface import surface, evaluated
obj, set_value = surface() obj, set_value = surface()
set_value("Thickness", 0.0) set_value("Thickness", 0.0)
points, faces = evaluated(obj) points, faces = evaluated(obj)
@@ -266,7 +162,8 @@ def test_zero_thickness_short_circuits_shell_without_warnings():
(0.0, 1.0), (0.02, 0.5), (0.1, 1.0), (0.0, 1.0), (0.02, 0.5), (0.1, 1.0),
]) ])
def test_shell_ignores_front_inflation(thickness, inflation): def test_shell_ignores_front_inflation(thickness, inflation):
from tests.support.depth_surface import surface, evaluated, assert_closed from tests.support.depth_surface import surface, evaluated
obj, set_value = surface(step=0, resolution=10) obj, set_value = surface(step=0, resolution=10)
xz = np.array([(v.co.x, v.co.z) for v in obj.data.vertices]) xz = np.array([(v.co.x, v.co.z) for v in obj.data.vertices])
profile = 0.3 * np.sin(np.pi * xz[:, 0] / 2) * np.sin(np.pi * xz[:, 1]) profile = 0.3 * np.sin(np.pi * xz[:, 0] / 2) * np.sin(np.pi * xz[:, 1])
@@ -274,34 +171,8 @@ def test_shell_ignores_front_inflation(thickness, inflation):
obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile) obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile)
set_value("Thickness", thickness) set_value("Thickness", thickness)
set_value("Front Inflation", 0.0) set_value("Front Inflation", 0.0)
baseline, faces = evaluated(obj) baseline, baseline_faces = evaluated(obj)
set_value("Front Inflation", inflation) set_value("Front Inflation", inflation)
inflated, inflated_faces = evaluated(obj) inflated, inflated_faces = evaluated(obj)
assert faces == inflated_faces assert baseline_faces == inflated_faces
np.testing.assert_array_equal(inflated, baseline) np.testing.assert_array_equal(inflated, baseline)
if thickness:
assert_closed(inflated_faces)
def test_balloon_suppression_is_stronger_without_a_startup_threshold(monkeypatch):
from tests.support.depth_surface import surface, evaluated
set_test_structure(monkeypatch, 1)
obj, set_value = surface(step=0, resolution=10)
xz = np.array([(v.co.x, v.co.z) for v in obj.data.vertices])
profile = 0.3 * np.sin(np.pi * xz[:, 0] / 2) * np.sin(np.pi * xz[:, 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)
thickness_id = modifier_input_identifier(group, "Thickness", subtype="NONE")
previous = 0
for thickness in (0.001, 0.01, 0.1, 0.5, 1):
set_modifier_input(obj.modifiers[0], thickness_id, thickness)
set_value("Front Inflation", 0)
baseline, _ = evaluated(obj)
set_value("Front Inflation", 1)
inflated, _ = evaluated(obj)
growth = np.linalg.norm(inflated - baseline, axis=1).max() / profile.max()
assert growth > previous
previous = growth
assert growth == pytest.approx(0.323, abs=0.001)

View File

@@ -1,6 +1,5 @@
"""Behavior checks for face separation and connected Depth Surface shells.""" """Behavior checks for face separation and connected Depth Surface shells."""
from collections import Counter
import bpy import bpy
import bmesh import bmesh
import numpy as np import numpy as np
@@ -10,41 +9,6 @@ import pytest
from tests.support.depth_surface import assert_closed, component_count, evaluated, surface from tests.support.depth_surface import assert_closed, component_count, evaluated, surface
@pytest.mark.parametrize("mode", [0, 1])
def test_shell_walls_directly_connect_caps(mode):
from anyimage.common.object import modifier_input_identifier, set_modifier_input
obj, set_value = surface(resolution=4)
obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set(
"value", np.full(len(obj.data.vertices), 0.3)
)
set_value("Depth Split", 0.5)
front, faces = evaluated(obj)
edges = Counter(tuple(sorted((a, b))) for face in faces for a, b in zip(face, (*face[1:], face[0])))
boundary_count = sum(count == 1 for count in edges.values())
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
mesh = result.to_mesh()
try:
welded = {i for i, value in enumerate(mesh.attributes["o_balloon"].data)
if mode == 0 and value.value == 0}
finally:
result.to_mesh_clear()
collapsed_walls = sum(count == 1 and a in welded and b in welded for (a, b), count in edges.items())
set_value("Mode", mode)
modifier = obj.modifiers[0]
thickness = modifier_input_identifier(modifier.node_group, "Thickness", subtype="NONE" if mode == 0 else "DISTANCE")
for amount in (0.01, 2.0):
set_modifier_input(modifier, thickness, amount)
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
points, shell_faces = evaluated(obj)
assert len(points) == 2 * len(front) - len(welded)
assert len(shell_faces) == 2 * len(faces) + boundary_count - collapsed_walls
assert all(len(face) in (3, 4) for face in shell_faces)
assert np.isfinite(points).all()
assert_closed(shell_faces)
@pytest.mark.parametrize("thickness", [0.1, 1.4]) @pytest.mark.parametrize("thickness", [0.1, 1.4])
def test_surface_caps_and_segmented_walls_are_connected(thickness): def test_surface_caps_and_segmented_walls_are_connected(thickness):
obj, set_value = surface() obj, set_value = surface()
@@ -123,29 +87,6 @@ def test_full_wrapper_keeps_closed_shell():
assert_closed(faces) assert_closed(faces)
def test_coincident_independent_sheets_do_not_weld_together():
obj, set_value = surface(step=0.0)
mesh = obj.data
vertices = [tuple(v.co) for v in mesh.vertices]
faces = [tuple(f.vertices) for f in mesh.polygons]
n = len(vertices)
duplicate = bpy.data.meshes.new("Close sheets")
duplicate.from_pydata(
vertices + [(x, y + 1e-8, z) for x, y, z in vertices],
[],
faces + [tuple(v + n for v in face) for face in faces],
)
uv = duplicate.uv_layers.new(name="UVMap")
for loop in duplicate.loops:
x, _y, z = duplicate.vertices[loop.vertex_index].co
uv.data[loop.index].uv = (x / 2, z)
obj.data = duplicate
set_value("Thickness", 0.1)
coords, faces = evaluated(obj)
assert_closed(faces)
assert component_count(faces) == 2
@pytest.mark.parametrize("resolution", [1, 4]) @pytest.mark.parametrize("resolution", [1, 4])
def test_planar_depth_preserves_blocks_and_cleans_single_face_width(resolution): def test_planar_depth_preserves_blocks_and_cleans_single_face_width(resolution):
obj, set_value = surface(step=0.0, resolution=resolution) obj, set_value = surface(step=0.0, resolution=resolution)

View File

@@ -83,7 +83,8 @@ def test_geometry_attribute_contract_includes_nested_groups():
"UVMap": ("FLOAT2", "CORNER"), "UVMap": ("FLOAT2", "CORNER"),
"o_balloon": ("FLOAT", "POINT"), "o_balloon": ("FLOAT", "POINT"),
"_o_anyimage_depth_cut": ("BOOLEAN", "CORNER"), "_o_anyimage_depth_cut": ("BOOLEAN", "CORNER"),
"_o_anyimage_symmetry_weld": ("BOOLEAN", "POINT"), "_o_solid_normal": ("FLOAT_VECTOR", "POINT"),
"_o_symmetry_weld": ("BOOLEAN", "FACE"),
"o_depth_rotation": ("FLOAT_VECTOR", "FACE"), "o_depth_rotation": ("FLOAT_VECTOR", "FACE"),
"o_depth_face": ("FLOAT", "FACE"), "o_depth_face": ("FLOAT", "FACE"),
"o_depth_axis": ("FLOAT", "FACE"), "o_depth_axis": ("FLOAT", "FACE"),

View File

@@ -1,8 +1,8 @@
"""Face separation and connected thickness for Depth Surface assets.""" """Face separation and connected thickness for Depth Surface assets."""
from .nodes import ( from .nodes import (
boolean_node, compare_node, evaluate_field, store_float_attribute, boolean_node, compare_node, evaluate_field, store_boolean_attribute,
store_boolean_attribute, remove_attribute_pattern, sample_field, remove_attribute_pattern, sample_field,
) )
from .smoothing import edge_boundary_field from .smoothing import edge_boundary_field
@@ -318,8 +318,7 @@ def build_surface_camera(group, original, separated, cut_vertex, strength):
return evaluate_field(group, choose.outputs["Output"], "FLOAT_VECTOR", "POINT") return evaluate_field(group, choose.outputs["Output"], "FLOAT_VECTOR", "POINT")
def thicken_depth_surface(group, geometry, thickness, normal_smooth=None, *, def thicken_depth_surface(group, geometry, thickness, normal_smooth=None):
back_mask=None, weld_zero_thickness=False):
"""Connect caps with single quads by source vertex and cap identity.""" """Connect caps with single quads by source vertex and cap identity."""
nodes, links = group.nodes, group.links nodes, links = group.nodes, group.links
position = nodes.new("GeometryNodeInputPosition") position = nodes.new("GeometryNodeInputPosition")
@@ -378,29 +377,12 @@ def thicken_depth_surface(group, geometry, thickness, normal_smooth=None, *,
links.new(rear, back_parts.inputs["Geometry"]) links.new(rear, back_parts.inputs["Geometry"])
links.new(rim, back_parts.inputs["Geometry"]) links.new(rim, back_parts.inputs["Geometry"])
rear = back_parts.outputs["Geometry"] rear = back_parts.outputs["Geometry"]
if back_mask is not None:
rear = store_float_attribute(group, rear, back_mask, 1.0)
for value in ( for value in (
saved, saved,
rear, rear,
): ):
links.new(value, join.inputs["Geometry"]) links.new(value, join.inputs["Geometry"])
construction = join.outputs["Geometry"] construction = join.outputs["Geometry"]
if weld_zero_thickness:
positive = compare_node(group, "GREATER_THAN", source_thickness, 1e-12)
flattened = nodes.new("ShaderNodeCombineXYZ")
for axis in ("X", "Z"):
links.new(layer_axes.outputs[axis], flattened.inputs[axis])
collapsed = nodes.new("GeometryNodeSwitch")
collapsed.input_type = "FLOAT"
collapsed.inputs["False"].default_value = 0.0
links.new(positive, collapsed.inputs["Switch"])
links.new(layer_axes.outputs["Y"], collapsed.inputs["True"])
links.new(collapsed.outputs[0], flattened.inputs["Y"])
collapse = nodes.new("GeometryNodeSetPosition")
links.new(construction, collapse.inputs["Geometry"])
links.new(flattened.outputs[0], collapse.inputs["Position"])
construction = collapse.outputs["Geometry"]
merge = nodes.new("GeometryNodeMergeByDistance") merge = nodes.new("GeometryNodeMergeByDistance")
merge.mode = "ALL" merge.mode = "ALL"
merge.inputs["Distance"].default_value = 0.1 merge.inputs["Distance"].default_value = 0.1

View File

@@ -138,6 +138,13 @@ def read_float_attribute(group, name):
return node.outputs["Attribute"] return node.outputs["Attribute"]
def read_vector_attribute(group, name):
node = group.nodes.new("GeometryNodeInputNamedAttribute")
node.data_type = "FLOAT_VECTOR"
node.inputs["Name"].default_value = name
return node.outputs["Attribute"]
def store_float_attribute(group, geometry, name, value, domain="POINT"): def store_float_attribute(group, geometry, name, value, domain="POINT"):
node = group.nodes.new("GeometryNodeStoreNamedAttribute") node = group.nodes.new("GeometryNodeStoreNamedAttribute")
node.domain, node.data_type = domain, "FLOAT" node.domain, node.data_type = domain, "FLOAT"
@@ -150,6 +157,15 @@ def store_float_attribute(group, geometry, name, value, domain="POINT"):
return node.outputs["Geometry"] return node.outputs["Geometry"]
def store_vector_attribute(group, geometry, name, value, domain="POINT"):
node = group.nodes.new("GeometryNodeStoreNamedAttribute")
node.domain, node.data_type = domain, "FLOAT_VECTOR"
node.inputs["Name"].default_value = name
group.links.new(geometry, node.inputs["Geometry"])
group.links.new(value, node.inputs["Value"])
return node.outputs["Geometry"]
def evaluate_field(group, value, data_type, domain): def evaluate_field(group, value, data_type, domain):
"""Evaluate a field on an explicit domain before downstream adaptation.""" """Evaluate a field on an explicit domain before downstream adaptation."""
node = group.nodes.new("GeometryNodeFieldOnDomain") node = group.nodes.new("GeometryNodeFieldOnDomain")
@@ -184,26 +200,12 @@ def store_boolean_attribute(group, geometry, name, value, domain="POINT"):
return node.outputs["Geometry"], read.outputs["Attribute"] return node.outputs["Geometry"], read.outputs["Attribute"]
def remove_attributes(group, geometry, *names, enabled=None):
"""Remove only the explicitly owned temporary attributes."""
original = geometry
for name in names:
node = group.nodes.new("GeometryNodeRemoveAttribute")
node.inputs["Name"].default_value = name
group.links.new(geometry, node.inputs["Geometry"])
geometry = node.outputs["Geometry"]
if enabled is not None:
choose = group.nodes.new("GeometryNodeSwitch")
choose.input_type = "GEOMETRY"
group.links.new(enabled, choose.inputs["Switch"])
group.links.new(original, choose.inputs["False"])
group.links.new(geometry, choose.inputs["True"])
geometry = choose.outputs["Output"]
return geometry
def remove_attribute_pattern(group, geometry, pattern): def remove_attribute_pattern(group, geometry, pattern):
"""Remove every attribute matching an internal wildcard pattern.""" """Remove every attribute matching an internal wildcard pattern.
Always clean internal attributes with a pattern: naming an attribute that a
branch never stored makes the Remove Named Attribute node warn.
"""
node = group.nodes.new("GeometryNodeRemoveAttribute") node = group.nodes.new("GeometryNodeRemoveAttribute")
node.inputs["Name"].default_value = pattern node.inputs["Name"].default_value = pattern
node.pattern_mode = "WILDCARD" node.pattern_mode = "WILDCARD"

View File

@@ -17,11 +17,11 @@ from ..common.smoothing import edge_boundary_field, pinned_smooth
NODE_GROUP_NAME = "O Image Cutout Symmetry" NODE_GROUP_NAME = "O Image Cutout Symmetry"
# 切口环与衔接带各自向内覆盖的圈数。 # 切口环与补面衔接带各自向外覆盖的圈数。
SMOOTH_RINGS = 4 SMOOTH_RINGS = 4
# 衔接带要覆盖镜像焊好的平面环,焊完之后先把这些点记在内部属性上。 # 补面标记:挤出之后按面记下补面,作为补面平滑的范围。
WELD_ATTRIBUTE = '_o_anyimage_symmetry_weld' WELD_ATTRIBUTE = '_o_symmetry_weld'
# 对称面附近按中位边长的倍数降低法线贴图强度。 # 对称面附近按中位边长的倍数降低法线贴图强度。
@@ -76,22 +76,22 @@ def _boundary_edge_field(group):
return compare_node(group, 'EQUAL', neighbors.outputs['Face Count'], 1, data_type='INT') return compare_node(group, 'EQUAL', neighbors.outputs['Face Count'], 1, data_type='INT')
def _beyond_plane_field(group, tolerance): def _beyond_plane_field(group, distance):
"""Report the points sitting beyond the symmetry plane.""" """Report the points sitting farther than the weld distance past the plane."""
nodes = group.nodes nodes = group.nodes
position = nodes.new('GeometryNodeInputPosition') position = nodes.new('GeometryNodeInputPosition')
components = nodes.new('ShaderNodeSeparateXYZ') components = nodes.new('ShaderNodeSeparateXYZ')
group.links.new(position.outputs[0], components.inputs[0]) group.links.new(position.outputs[0], components.inputs[0])
return compare_node(group, 'GREATER_THAN', components.outputs['Y'], tolerance) return compare_node(group, 'GREATER_THAN', components.outputs['Y'], distance)
def _on_plane_field(group, tolerance): def _on_plane_field(group, distance):
"""Report the points sitting on the symmetry plane.""" """Report the points sitting within the weld distance of the plane."""
nodes = group.nodes nodes = group.nodes
position = nodes.new('GeometryNodeInputPosition') position = nodes.new('GeometryNodeInputPosition')
components = nodes.new('ShaderNodeSeparateXYZ') components = nodes.new('ShaderNodeSeparateXYZ')
group.links.new(position.outputs[0], components.inputs[0]) group.links.new(position.outputs[0], components.inputs[0])
return compare_node(group, 'LESS_THAN', _math(group, 'ABSOLUTE', components.outputs['Y']), tolerance) return compare_node(group, 'LESS_THAN', _math(group, 'ABSOLUTE', components.outputs['Y']), distance)
def _merge_points(group, geometry, selection, distance): def _merge_points(group, geometry, selection, distance):
@@ -166,7 +166,7 @@ def _delete_bridge_faces(group, geometry):
return _delete_faces(group, geometry, compare_node(group, 'GREATER_THAN', _boundary_edge_count(group), 1.5)) return _delete_faces(group, geometry, compare_node(group, 'GREATER_THAN', _boundary_edge_count(group), 1.5))
def _delete_flat_faces(group, geometry, tolerance): def _delete_flat_faces(group, geometry, distance):
"""Delete the faces lying on the symmetry plane; they mirror onto themselves.""" """Delete the faces lying on the symmetry plane; they mirror onto themselves."""
nodes, links = group.nodes, group.links nodes, links = group.nodes, group.links
position = nodes.new('GeometryNodeInputPosition') position = nodes.new('GeometryNodeInputPosition')
@@ -180,20 +180,20 @@ def _delete_flat_faces(group, geometry, tolerance):
group, geometry, group, geometry,
boolean_node( boolean_node(
group, 'AND', group, 'AND',
compare_node(group, 'LESS_THAN', center, tolerance), compare_node(group, 'LESS_THAN', center, distance),
compare_node(group, 'GREATER_THAN', _math(group, 'ABSOLUTE', normal_axes.outputs['Y']), 1 - 1e-3), compare_node(group, 'GREATER_THAN', _math(group, 'ABSOLUTE', normal_axes.outputs['Y']), 1 - 1e-3),
), ),
) )
def _delete_crease_faces(group, geometry, tolerance): def _delete_crease_faces(group, geometry, distance):
"""Delete the faces the fold leaves sharing an interior edge on the plane.""" """Delete the faces the fold leaves sharing an interior edge on the plane."""
nodes, links = group.nodes, group.links nodes, links = group.nodes, group.links
heights = _edge_abs_y(group) heights = _edge_abs_y(group)
plane = nodes.new('FunctionNodeBooleanMath') plane = nodes.new('FunctionNodeBooleanMath')
plane.operation = 'AND' plane.operation = 'AND'
links.new(compare_node(group, 'LESS_THAN', heights[0], tolerance), plane.inputs[0]) links.new(compare_node(group, 'LESS_THAN', heights[0], distance), plane.inputs[0])
links.new(compare_node(group, 'LESS_THAN', heights[1], tolerance), plane.inputs[1]) links.new(compare_node(group, 'LESS_THAN', heights[1], distance), plane.inputs[1])
neighbors = nodes.new('GeometryNodeInputMeshEdgeNeighbors') neighbors = nodes.new('GeometryNodeInputMeshEdgeNeighbors')
crease = nodes.new('FunctionNodeBooleanMath') crease = nodes.new('FunctionNodeBooleanMath')
crease.operation = 'AND' crease.operation = 'AND'
@@ -209,10 +209,10 @@ def _delete_crease_faces(group, geometry, tolerance):
return _delete_faces(group, geometry, selected) return _delete_faces(group, geometry, selected)
def _off_plane_edge_field(group, tolerance): def _off_plane_edge_field(group, distance):
"""Report one on edges that keep an endpoint off the symmetry plane.""" """Report one on edges that keep an endpoint farther than the weld distance."""
heights = _edge_abs_y(group) heights = _edge_abs_y(group)
return compare_node(group, 'GREATER_THAN', _math(group, 'MAXIMUM', heights[0], heights[1]), tolerance) return compare_node(group, 'GREATER_THAN', _math(group, 'MAXIMUM', heights[0], heights[1]), distance)
def _edge_abs_y(group): def _edge_abs_y(group):
@@ -235,10 +235,10 @@ def _mirror(group, geometry):
return flip.outputs[0] return flip.outputs[0]
def _seam_field(group, tolerance): def _seam_field(group, distance):
"""Report the cut ring the folded front leaves on the symmetry plane.""" """Report the cut ring the folded front leaves on the symmetry plane."""
boundary = evaluate_field(group, edge_boundary_field(group.nodes, group.links), 'BOOLEAN', 'POINT') boundary = evaluate_field(group, edge_boundary_field(group.nodes, group.links), 'BOOLEAN', 'POINT')
return boolean_node(group, 'AND', boundary, _on_plane_field(group, tolerance)) return boolean_node(group, 'AND', boundary, _on_plane_field(group, distance))
def _falloff(group, center, rings): def _falloff(group, center, rings):
@@ -266,54 +266,42 @@ def _snap_to_plane(group, geometry, selection):
return place.outputs[0] return place.outputs[0]
def _relax_seam(group, geometry, seam, beyond, smooth): def _relax_seam(group, geometry, seam, retract, smooth):
"""Relax the cut seam in three dimensions and keep the cut on the plane.""" """Relax the cut seam in three dimensions and keep the cut on the plane."""
relaxed = pinned_smooth( relaxed = pinned_smooth(
group, geometry, smooth, _falloff(group, seam, SMOOTH_RINGS), group, geometry, smooth, _falloff(group, seam, SMOOTH_RINGS),
pin_boundary=True, pin_boundary=True,
) )
return _snap_to_plane(group, relaxed, boolean_node(group, 'OR', seam, beyond)) return _snap_to_plane(group, relaxed, boolean_node(group, 'OR', seam, retract))
def _relax_fill(group, geometry, weld, smooth): def _relax_fill(group, geometry, fill, smooth):
"""Relax the junction band on the welded mesh, where the seam needs no pinning.""" """Relax the fill faces on the welded mesh and fade outward from them."""
return pinned_smooth( return pinned_smooth(
group, geometry, smooth, _falloff(group, weld, SMOOTH_RINGS), group, geometry, smooth, _falloff(group, fill, SMOOTH_RINGS),
weight=0.1, pin_boundary=False, weight=0.2, pin_boundary=False,
) )
def _extrude_boundary(group, geometry, selection, y_factor): def _build_wall(group, front, boundary):
"""Extrude selected edges to a fraction of their current height.""" """Close the selected boundary edges onto the symmetry plane and mark the fill."""
nodes, links = group.nodes, group.links nodes, links = group.nodes, group.links
extrude = nodes.new('GeometryNodeExtrudeMesh') extrude = nodes.new('GeometryNodeExtrudeMesh')
extrude.mode = 'EDGES' extrude.mode = 'EDGES'
extrude.inputs['Offset Scale'].default_value = 0 extrude.inputs['Offset Scale'].default_value = 0
links.new(geometry, extrude.inputs['Mesh']) links.new(front, extrude.inputs['Mesh'])
links.new(selection, extrude.inputs['Selection']) links.new(boundary, extrude.inputs['Selection'])
position = nodes.new('GeometryNodeInputPosition') geometry = _snap_to_plane(group, extrude.outputs['Mesh'], extrude.outputs['Top'])
scale = nodes.new('ShaderNodeVectorMath') # 挤出之后再标记补面:范围就是补面本身连同它的边界线,挤出不会改变它。
scale.operation = 'MULTIPLY' fill = evaluate_field(group, extrude.outputs['Side'], 'BOOLEAN', 'POINT')
scale.inputs[1].default_value = (1, y_factor, 1) geometry, fill = store_boolean_attribute(group, geometry, WELD_ATTRIBUTE, fill, domain='FACE')
links.new(position.outputs[0], scale.inputs[0])
place = nodes.new('GeometryNodeSetPosition')
links.new(extrude.outputs['Mesh'], place.inputs['Geometry'])
links.new(extrude.outputs['Top'], place.inputs['Selection'])
links.new(scale.outputs[0], place.inputs['Position'])
store = nodes.new('GeometryNodeStoreNamedAttribute') store = nodes.new('GeometryNodeStoreNamedAttribute')
store.data_type, store.domain = 'FLOAT', 'FACE' store.data_type, store.domain = 'FLOAT', 'FACE'
store.inputs['Name'].default_value = FACE_ATTRIBUTE store.inputs['Name'].default_value = FACE_ATTRIBUTE
store.inputs['Value'].default_value = 3 store.inputs['Value'].default_value = 3
links.new(place.outputs[0], store.inputs['Geometry']) links.new(geometry, store.inputs['Geometry'])
links.new(extrude.outputs['Side'], store.inputs['Selection']) links.new(extrude.outputs['Side'], store.inputs['Selection'])
return store.outputs[0], extrude.outputs['Top'] return store.outputs[0], fill
def _build_wall(group, front, boundary):
"""Attach the curved half wall to the front and mark its faces."""
geometry, top = _extrude_boundary(group, front, boundary, .5)
geometry, _ = _extrude_boundary(group, geometry, top, 0)
return geometry
def build_image_cutout_symmetry_group(): def build_image_cutout_symmetry_group():
@@ -331,7 +319,9 @@ def build_image_cutout_symmetry_group():
bool_input(group, 'Fill Sides', True, parent=options).description = 'Connect matching front and mirrored boundaries.' bool_input(group, 'Fill Sides', True, parent=options).description = 'Connect matching front and mirrored boundaries.'
smooth = interface_socket(group, 'Smooth', 'INPUT', 'NodeSocketInt', options) smooth = interface_socket(group, 'Smooth', 'INPUT', 'NodeSocketInt', options)
smooth.default_value, smooth.min_value, smooth.max_value = 4, 0, 16 smooth.default_value, smooth.min_value, smooth.max_value = 4, 0, 16
smooth.description = 'Smooth the cut ring and the junction band around the outline and the fill, each over four rings. Zero keeps both raw.' smooth.description = 'Smooth the cut ring and the fill band around the outline and the fill, each over four rings. Zero keeps both raw.'
merge = float_input(group, 'Merge Distance', 0.001, minimum=0, subtype='DISTANCE', parent=options)
merge.description = 'Weld mirrored geometry within this distance of the symmetry plane; boundaries farther away get filled.'
interface_socket(group, 'Geometry', 'OUTPUT', 'NodeSocketGeometry') interface_socket(group, 'Geometry', 'OUTPUT', 'NodeSocketGeometry')
nodes, links = group.nodes, group.links nodes, links = group.nodes, group.links
inputs = group_input(nodes, set()).outputs inputs = group_input(nodes, set()).outputs
@@ -345,25 +335,25 @@ def build_image_cutout_symmetry_group():
statistics.data_type, statistics.domain = 'FLOAT', 'EDGE' statistics.data_type, statistics.domain = 'FLOAT', 'EDGE'
links.new(aligned, statistics.inputs['Geometry']) links.new(aligned, statistics.inputs['Geometry'])
links.new(edge_length.outputs['Value'], statistics.inputs['Attribute']) links.new(edge_length.outputs['Value'], statistics.inputs['Attribute'])
snap_tolerance = _math( # 合并距离同时决定平面的贴合带和补面边界,两者一致才不会留下细小补面。
group, 'MULTIPLY', _math(group, 'MAXIMUM', statistics.outputs['Median'], 1e-6), 1e-6, merge_distance = _math(group, 'MAXIMUM', inputs['Merge Distance'], 1e-6)
) beyond = _beyond_plane_field(group, merge_distance)
beyond = _beyond_plane_field(group, snap_tolerance) on_plane = _on_plane_field(group, merge_distance)
on_plane = _on_plane_field(group, snap_tolerance) retract = boolean_node(group, 'OR', on_plane, beyond)
is_boundary = _boundary_edge_field(group) is_boundary = _boundary_edge_field(group)
front = _delete_beyond_plane(group, aligned, beyond) front = _delete_beyond_plane(group, aligned, beyond)
front = _delete_bridge_faces(group, front) front = _delete_bridge_faces(group, front)
# 跨界保留的面折回对称面,切口环因此落在平面上。 # 贴合带内和跨界保留的面都折回对称面,切口环因此落在平面上。
front = _snap_to_plane(group, front, beyond) front = _snap_to_plane(group, front, retract)
seam_field = _seam_field(group, snap_tolerance) seam_field = _seam_field(group, merge_distance)
shade = nodes.new('GeometryNodeSetShadeSmooth') shade = nodes.new('GeometryNodeSetShadeSmooth')
shade.domain = 'FACE' shade.domain = 'FACE'
links.new(front, shade.inputs['Geometry']) links.new(front, shade.inputs['Geometry'])
front = store_float_attribute(group, shade.outputs[0], FACE_ATTRIBUTE, 1., domain='FACE') front = store_float_attribute(group, shade.outputs[0], FACE_ATTRIBUTE, 1., domain='FACE')
front = _relax_seam(group, front, seam_field, beyond, inputs['Smooth']) front = _relax_seam(group, front, seam_field, retract, inputs['Smooth'])
# 松弛会把点压回平面,贴平的面和内部贴平边要在松弛之后再清一次。 # 松弛会把点压回平面,贴平的面和内部贴平边要在松弛之后再清一次。
front = _delete_flat_faces(group, front, snap_tolerance) front = _delete_flat_faces(group, front, merge_distance)
front = _delete_crease_faces(group, front, snap_tolerance) front = _delete_crease_faces(group, front, merge_distance)
fill_iterations = nodes.new('GeometryNodeSwitch') fill_iterations = nodes.new('GeometryNodeSwitch')
fill_iterations.input_type = 'INT' fill_iterations.input_type = 'INT'
fill_iterations.inputs['False'].default_value = 0 fill_iterations.inputs['False'].default_value = 0
@@ -373,8 +363,8 @@ def build_image_cutout_symmetry_group():
wall = nodes.new('FunctionNodeBooleanMath') wall = nodes.new('FunctionNodeBooleanMath')
wall.operation = 'AND' wall.operation = 'AND'
links.new(is_boundary, wall.inputs[0]) links.new(is_boundary, wall.inputs[0])
links.new(_off_plane_edge_field(group, snap_tolerance), wall.inputs[1]) links.new(_off_plane_edge_field(group, merge_distance), wall.inputs[1])
walled = _build_wall(group, front, wall.outputs[0]) walled, fill = _build_wall(group, front, wall.outputs[0])
wall_shade = nodes.new('GeometryNodeSetShadeSmooth') wall_shade = nodes.new('GeometryNodeSetShadeSmooth')
wall_shade.domain = 'FACE' wall_shade.domain = 'FACE'
links.new(compare_node(group, 'EQUAL', read_float_attribute(group, FACE_ATTRIBUTE), 3), wall_shade.inputs['Selection']) links.new(compare_node(group, 'EQUAL', read_float_attribute(group, FACE_ATTRIBUTE), 3), wall_shade.inputs['Selection'])
@@ -393,11 +383,10 @@ def build_image_cutout_symmetry_group():
join = nodes.new('GeometryNodeJoinGeometry') join = nodes.new('GeometryNodeJoinGeometry')
for geometry in (body.outputs[0], mark_back.outputs['Geometry']): for geometry in (body.outputs[0], mark_back.outputs['Geometry']):
links.new(geometry, join.inputs[0]) links.new(geometry, join.inputs[0])
# 接缝两侧的位置本来就重合,合并距离收到容差量级,别把平面附近相邻而不同的点连起来。 # 平面附近的点已经折到平面上,接缝两侧位置重合;合并只作用在这条带内。
merged = _merge_points(group, join.outputs[0], on_plane, snap_tolerance) merged = _merge_points(group, join.outputs[0], on_plane, merge_distance)
# 先让镜像焊住接缝,再对焊好的网格松弛衔接带。 # 先让镜像焊住接缝,再松弛补面和它外侧的衔接带。
geometry, weld = store_boolean_attribute(group, merged, WELD_ATTRIBUTE, on_plane, domain='POINT') geometry = _relax_fill(group, merged, fill, fill_iterations.outputs[0])
geometry = _relax_fill(group, geometry, weld, fill_iterations.outputs[0])
geometry = store_float_attribute(group, geometry, AXIS_ATTRIBUTE, 1., domain='FACE') geometry = store_float_attribute(group, geometry, AXIS_ATTRIBUTE, 1., domain='FACE')
orient = nodes.new('GeometryNodeTransform') orient = nodes.new('GeometryNodeTransform')
orient.inputs['Rotation'].default_value = Matrix.Rotation(1.5707963267948966, 4, 'Z').to_euler() orient.inputs['Rotation'].default_value = Matrix.Rotation(1.5707963267948966, 4, 'Z').to_euler()

View File

@@ -2,18 +2,21 @@
import bpy import bpy
from anyimage.operators.cutout_tool.shape import NORMAL_REDUCTION_ATTRIBUTE_NAME from anyimage.operators.cutout_tool.shape import NORMAL_REDUCTION_ATTRIBUTE_NAME
from ..common.nodes import ( from ..common.nodes import (
interface_socket, group_input, float_input, read_float_attribute, interface_socket, group_input, float_input, read_float_attribute, read_vector_attribute,
store_float_attribute, prepare_node_group, sample_field, evaluate_field, store_float_attribute, store_vector_attribute, remove_attribute_pattern,
compare_node, prepare_node_group, evaluate_field, compare_node,
) )
from ..common.boundary_smoothing import smooth_cut_boundary from ..common.boundary_smoothing import smooth_cut_boundary
from ..common.cutout import _position_field, _shade_output 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, thicken_depth_surface 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.smoothing import edge_boundary_field
from ..common.cutout_boundary import original_boundary_field, 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" NODE_GROUP_NAME = "O Image Depth Cutout"
SOLID_NORMAL_ATTRIBUTE = "_o_solid_normal"
BALLOON_THICKNESS_SCALE = 2.0
FRONT_INFLATION_SCALE = 0.5
def build_normal_weight(group, is_shell): def build_normal_weight(group, is_shell):
@@ -47,229 +50,85 @@ def blend_normal(group, original, smoothed, weight):
return normalize.outputs[0] return normalize.outputs[0]
def build_volume_fields(group, rim=None):
"""Evaluate the center and thickness on the original front surface."""
links = group.links
if rim is None:
rim = evaluate_field(group, edge_boundary_field(group.nodes, links), "FLOAT", "POINT")
profile = read_float_attribute(group, "o_balloon")
position = group.nodes.new("GeometryNodeInputPosition")
axes = group.nodes.new("ShaderNodeSeparateXYZ")
links.new(position.outputs[0], axes.inputs[0])
interior = _math(group, "SUBTRACT", 1, rim)
depth = axes.outputs["Y"]
depth_slow = group.nodes.new("GeometryNodeBlurAttribute")
depth_slow.data_type = "FLOAT"
depth_slow.inputs["Iterations"].default_value = 256
links.new(depth, depth_slow.inputs["Value"])
links.new(interior, depth_slow.inputs["Weight"])
profile_slow = group.nodes.new("GeometryNodeBlurAttribute")
profile_slow.data_type = "FLOAT"
profile_slow.inputs["Iterations"].default_value = 256
links.new(profile, profile_slow.inputs["Value"])
links.new(interior, profile_slow.inputs["Weight"])
center = _math(group, "SUBTRACT", depth_slow.outputs[0], profile_slow.outputs[0])
residual = _math(group, "SUBTRACT", axes.outputs["Y"], center)
full = _math(group, "ADD", residual, profile)
minimum = _math(group, "MULTIPLY", profile, 0.05)
positive = _math(group, "MAXIMUM", full, minimum)
control = group_input(group.nodes, {"Thickness"})
thickness = _math(group, "MULTIPLY", positive, control.outputs["Thickness"])
return {
"profile": profile, "depth": depth, "interior": interior,
"depth_slow": depth_slow.outputs[0], "profile_slow": profile_slow.outputs[0],
"center": center, "thickness": thickness,
}
def build_shell_fields(group, geometry, fields, is_shell, normal_weight=1):
"""Evaluate rear directions on the center surface and select shell thickness."""
nodes, links = group.nodes, group.links
position = nodes.new("GeometryNodeInputPosition")
axes = nodes.new("ShaderNodeSeparateXYZ")
links.new(position.outputs[0], axes.inputs[0])
center_position = nodes.new("ShaderNodeCombineXYZ")
for axis in ("X", "Z"):
links.new(axes.outputs[axis], center_position.inputs[axis])
links.new(fields["center"], center_position.inputs["Y"])
surface = nodes.new("GeometryNodeSetPosition")
links.new(geometry, surface.inputs["Geometry"])
links.new(center_position.outputs[0], surface.inputs["Position"])
normal = nodes.new("GeometryNodeInputNormal")
blur = nodes.new("GeometryNodeBlurAttribute")
blur.data_type = "FLOAT_VECTOR"
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")
control = group_input(nodes, {"Shell Thickness"})
thickness = nodes.new("GeometryNodeSwitch")
thickness.input_type = "FLOAT"
links.new(is_shell, thickness.inputs["Switch"])
links.new(fields["thickness"], thickness.inputs["False"])
links.new(control.outputs["Shell Thickness"], thickness.inputs["True"])
return {"thickness": thickness.outputs[0], "normal": rear_normal}
def build_front_fields(group, volume, front_normal, normal_weight=1):
"""Fit structural amplitude over neighbouring points before creating the shell."""
links = group.links
def blur(value, weight=None):
result = group.nodes.new("GeometryNodeBlurAttribute")
result.data_type = "FLOAT"
result.inputs["Iterations"].default_value = 256
links.new(value, result.inputs["Value"])
if weight is not None:
links.new(weight, result.inputs["Weight"])
return result.outputs[0]
profile, depth = volume["profile"], volume["depth"]
depth_slow, profile_slow = volume["depth_slow"], volume["profile_slow"]
depth_bump = _math(group, "SUBTRACT", depth, depth_slow)
profile_bump = _math(group, "SUBTRACT", profile, profile_slow)
numerator = _math(group, "MULTIPLY", depth_bump, profile_bump)
denominator = _math(group, "MULTIPLY", profile_bump, profile_bump)
numerator = blur(numerator)
denominator = blur(denominator)
denominator = _math(group, "MAXIMUM", denominator, 1e-10)
structure = _math(group, "DIVIDE", numerator, denominator)
structure = _math(group, "MAXIMUM", structure, 0)
normal = front_normal
smooth = group.nodes.new("GeometryNodeBlurAttribute")
smooth.data_type = "FLOAT_VECTOR"
links.new(normal, smooth.inputs["Value"])
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}
def build_inflation_position(group, position, distance, fields):
"""Inflate the Balloon front while leaving the rear cap in place."""
links = group.links
structure = fields["structure"]
control = group.nodes.new("NodeGroupInput")
a = _math(group, "MULTIPLY", control.outputs["Thickness"], control.outputs["Front Inflation"])
# A shifted cubic blend preserves continuous growth and height order.
shifted = _math(group, "ADD", a, 0.75)
quadratic = _math(group, "MULTIPLY", a, shifted)
quadratic = _math(group, "ADD", quadratic, 0.1875)
q = _math(group, "MULTIPLY", a, quadratic)
k = _math(group, "ADD", structure, 0.25)
square = _math(group, "MULTIPLY", k, k)
cube = _math(group, "MULTIPLY", square, k)
total = _math(group, "ADD", cube, q)
root = _math(group, "POWER", total, 1 / 3)
root_square = _math(group, "MULTIPLY", root, root)
product = _math(group, "MULTIPLY", root, k)
denominator = _math(group, "ADD", root_square, product)
denominator = _math(group, "ADD", denominator, square)
response = _math(group, "DIVIDE", q, denominator)
profile = group.nodes.new("GeometryNodeInputNamedAttribute")
profile.data_type = "FLOAT"
profile.inputs["Name"].default_value = "o_balloon"
displacement = _math(group, "MULTIPLY", profile.outputs["Attribute"], response)
height = fields["thickness"]
layer = _math(group, "DIVIDE", distance, height)
layer = _math(group, "ADD", 1, layer)
layer = _math(group, "MAXIMUM", layer, 0)
layer = _math(group, "MINIMUM", layer, 1)
displacement = _math(group, "MULTIPLY", displacement, layer)
normal = fields["inflation_normal"]
offset = group.nodes.new("ShaderNodeVectorMath")
offset.operation = "SCALE"
links.new(normal, offset.inputs[0])
links.new(displacement, offset.inputs[3])
result = group.nodes.new("ShaderNodeVectorMath")
result.operation = "ADD"
links.new(position, result.inputs[0])
links.new(offset.outputs[0], result.inputs[1])
enabled = group.nodes.new("GeometryNodeSwitch")
enabled.input_type = "VECTOR"
links.new(control.outputs["Front Inflation"], enabled.inputs["Switch"])
links.new(position, enabled.inputs["False"])
links.new(result.outputs[0], enabled.inputs["True"])
return enabled.outputs[0]
def build_front_normal(group, normal_weight): def build_front_normal(group, normal_weight):
"""Smooth the projected surface normals that drive the inflation."""
links = group.links links = group.links
normal = group.nodes.new("GeometryNodeInputNormal") normal = group.nodes.new("GeometryNodeInputNormal")
blur = group.nodes.new("GeometryNodeBlurAttribute") blur = group.nodes.new("GeometryNodeBlurAttribute")
blur.data_type = "FLOAT_VECTOR" blur.data_type = "FLOAT_VECTOR"
links.new(normal.outputs[0], blur.inputs["Value"]) links.new(normal.outputs[0], blur.inputs["Value"])
links.new(group_input(group.nodes, {"Normal Smooth"}).outputs["Normal Smooth"], blur.inputs["Iterations"]) 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 blend_normal(group, normal.outputs[0], blur.outputs[0], normal_weight)
return evaluate_field(group, normalized, "FLOAT_VECTOR", "POINT")
def build_shell_position(group, position, front, is_shell, fields): def build_inflation_amount(group, controls, is_shell):
links = group.links """Balloon scales the profile, Shell moves a fixed distance."""
nodes, links = group.nodes, group.links
thickness = _math(
group, "MULTIPLY", controls.outputs["Thickness"], BALLOON_THICKNESS_SCALE,
)
balloon = _math(
group, "MULTIPLY", read_float_attribute(group, "o_balloon"), thickness,
)
amount = nodes.new("GeometryNodeSwitch")
amount.input_type = "FLOAT"
links.new(is_shell, amount.inputs["Switch"])
links.new(balloon, amount.inputs["False"])
links.new(controls.outputs["Shell Thickness"], amount.inputs["True"])
return amount.outputs[0]
def connect(value, target):
if hasattr(value, "node"):
links.new(value, target)
else:
target.default_value = value
def vector(operation, a, b): def build_solid(group, surface, amount, normal, is_shell, controls, weld_distance):
result = group.nodes.new("ShaderNodeVectorMath") """Inflate the projected surface into a front leaf, wall and rear leaf."""
result.operation = operation nodes, links = group.nodes, group.links
connect(a, result.inputs[0])
connect(b, result.inputs[3 if operation == "SCALE" else 1]) def offset(distance):
result = nodes.new("ShaderNodeVectorMath")
result.operation = "SCALE"
links.new(normal, result.inputs[0])
links.new(distance, result.inputs[3])
return result.outputs[0] return result.outputs[0]
def y(value): inflation = _math(
result = group.nodes.new("ShaderNodeSeparateXYZ") group, "MULTIPLY", amount,
links.new(value, result.inputs[0]) _math(group, "MULTIPLY", controls.outputs["Front Inflation"], FRONT_INFLATION_SCALE),
return result.outputs["Y"] )
front_amount = nodes.new("GeometryNodeSwitch")
front_amount.input_type = "FLOAT"
links.new(is_shell, front_amount.inputs["Switch"])
links.new(inflation, front_amount.inputs["False"])
front_amount.inputs["True"].default_value = 0.0
# Shell and the thin end of Balloon share the actual front normal. front = nodes.new("GeometryNodeSetPosition")
front_y = y(front) links.new(surface, front.inputs["Geometry"])
raw_y = y(position) links.new(offset(front_amount.outputs[0]), front.inputs["Offset"])
distance = _math(group, "SUBTRACT", raw_y, front_y)
normal = fields["front_normal"]
thin_offset = vector("SCALE", normal, distance)
shell_position = vector("ADD", front, thin_offset)
control = group_input(group.nodes, {"Thickness"}) span = _math(group, "ADD", amount, front_amount.outputs[0])
clamp = group.nodes.new("ShaderNodeClamp") span = _math(group, "MULTIPLY", span, -1.0)
links.new(control.outputs["Thickness"], clamp.inputs["Value"]) extrude = nodes.new("GeometryNodeExtrudeMesh")
t = clamp.outputs[0] extrude.mode = "FACES"
radius = _math(group, "MULTIPLY", _math(group, "SUBTRACT", front_y, fields["center"]), t) extrude.inputs["Individual"].default_value = False
rear = read_float_attribute(group, NORMAL_REDUCTION_ATTRIBUTE_NAME) links.new(front.outputs["Geometry"], extrude.inputs["Mesh"])
rear_radius = _math(group, "MULTIPLY", rear, _math(group, "ADD", radius, distance)) links.new(offset(span), extrude.inputs["Offset"])
direction = vector("SUBTRACT", fields["normal"], (0, 1, 0))
rear_shift = vector("SCALE", direction, rear_radius)
axis_offset = group.nodes.new("ShaderNodeCombineXYZ")
links.new(distance, axis_offset.inputs["Y"])
full_offset = vector("ADD", axis_offset.outputs[0], rear_shift)
# Smoothly reach the existing full-volume shape at Thickness >= 1. # Extruding against the normal leaves the rear leaf and wall facing inward.
transition = group.nodes.new("ShaderNodeMapRange") flipped = nodes.new("GeometryNodeFlipFaces")
transition.interpolation_type = "SMOOTHERSTEP" links.new(extrude.outputs["Mesh"], flipped.inputs["Mesh"])
links.new(t, transition.inputs["Value"]) rear = store_float_attribute(
mix = group.nodes.new("ShaderNodeMix") group, flipped.outputs["Mesh"], NORMAL_REDUCTION_ATTRIBUTE_NAME, 1.0,
mix.data_type = "VECTOR" )
links.new(transition.outputs[0], mix.inputs[0]) join = nodes.new("GeometryNodeJoinGeometry")
links.new(thin_offset, mix.inputs[4]) links.new(front.outputs["Geometry"], join.inputs["Geometry"])
links.new(full_offset, mix.inputs[5]) links.new(rear, join.inputs["Geometry"])
balloon_position = vector("ADD", front, mix.outputs["Result"]) merge = nodes.new("GeometryNodeMergeByDistance")
choose = group.nodes.new("GeometryNodeSwitch") merge.mode = "ALL"
choose.input_type = "VECTOR" links.new(weld_distance, merge.inputs["Distance"])
links.new(is_shell, choose.inputs["Switch"]) links.new(join.outputs["Geometry"], merge.inputs["Geometry"])
inflated = build_inflation_position(group, balloon_position, distance, fields) return merge.outputs["Geometry"]
links.new(inflated, choose.inputs["False"])
links.new(shell_position, choose.inputs["True"])
return choose.outputs[0]
def _build_depth_surface(group, geometry, controls): def _build_depth_surface(group, geometry, controls):
"""Project the mesh and sample front-surface fields while shaping the shell.""" """Project the depth image and inflate the projected surface into a solid."""
links = group.links links = group.links
split = controls.outputs["Depth Split"] split = controls.outputs["Depth Split"]
image = controls.outputs["Depth Image"] image = controls.outputs["Depth Image"]
@@ -314,40 +173,29 @@ def _build_depth_surface(group, geometry, controls):
mode.inputs["Balloon"].default_value = False mode.inputs["Balloon"].default_value = False
mode.inputs["Shell"].default_value = True mode.inputs["Shell"].default_value = True
is_shell = mode.outputs[0] is_shell = mode.outputs[0]
selected_thickness = group.nodes.new("GeometryNodeSwitch") amount = build_inflation_amount(group, controls, is_shell)
selected_thickness.input_type = "FLOAT" statistics = group.nodes.new("GeometryNodeAttributeStatistic")
links.new(is_shell, selected_thickness.inputs["Switch"]) statistics.data_type, statistics.domain = "FLOAT", "POINT"
links.new(controls.outputs["Thickness"], selected_thickness.inputs["False"]) links.new(projection, statistics.inputs["Geometry"])
links.new(controls.outputs["Shell Thickness"], selected_thickness.inputs["True"]) links.new(amount, statistics.inputs["Attribute"])
has_thickness = compare_node(group, "GREATER_THAN", selected_thickness.outputs[0], 1e-6) has_thickness = compare_node(group, "GREATER_THAN", statistics.outputs["Max"], 1e-6)
zero_shell = group.nodes.new("GeometryNodeSwitch") zero_shell = group.nodes.new("GeometryNodeSwitch")
zero_shell.input_type = "GEOMETRY" zero_shell.input_type = "GEOMETRY"
links.new(has_thickness, zero_shell.inputs["Switch"]) links.new(has_thickness, zero_shell.inputs["Switch"])
links.new(projection, zero_shell.inputs["False"]) links.new(projection, zero_shell.inputs["False"])
volume = build_volume_fields(group, rim) normal = build_front_normal(group, build_normal_weight(group, is_shell))
normal_weight = build_normal_weight(group, is_shell) # Both leaves must inflate along one normal, so it is carried across the
normal = build_front_normal(group, normal_weight) # extrusion instead of being evaluated again on the moved surface.
front = build_front_fields(group, volume, normal, normal_weight) projection = store_vector_attribute(
shell_fields = build_shell_fields(group, projection, volume, is_shell, normal_weight) group, projection, SOLID_NORMAL_ATTRIBUTE, normal,
shell = thicken_depth_surface(
group, projection, shell_fields["thickness"], back_mask=NORMAL_REDUCTION_ATTRIBUTE_NAME, weld_zero_thickness=True,
) )
fields = { solid = build_solid(
name: sample_field(group, projection, value, data_type, index=shell["source_index"]) group, projection, amount, read_vector_attribute(group, SOLID_NORMAL_ATTRIBUTE),
for name, value, data_type in ( is_shell, controls,
("center", volume["center"], "FLOAT"), ("front_normal", normal, "FLOAT_VECTOR"), _math(group, "MULTIPLY", typical.outputs["Mean"], 1e-3),
("structure", front["structure"], "FLOAT"), ("inflation_normal", front["inflation_normal"], "FLOAT_VECTOR"),
) )
} links.new(solid, zero_shell.inputs["True"])
fields.update({ return remove_attribute_pattern(group, zero_shell.outputs[0], "_o_*")
name: sample_field(group, projection, value, "FLOAT_VECTOR" if name == "normal" else "FLOAT",
index=shell["source_index"])
for name, value in shell_fields.items()
})
oriented = build_shell_position(group, shell["position"], shell["front"], is_shell, fields)
links.new(oriented, shell["position_input"])
links.new(shell["geometry"], zero_shell.inputs["True"])
return zero_shell.outputs[0]
def build_image_depth_cutout_group(): def build_image_depth_cutout_group():
@@ -369,9 +217,9 @@ def build_image_depth_cutout_group():
boundary_smooth.default_value, boundary_smooth.min_value, boundary_smooth.max_value = 4, 0, 16 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.' 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 = 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.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.' 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.' 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.'
float_input(group, 'Reference Depth', 1.0, subtype='DISTANCE', parent=options) float_input(group, 'Reference Depth', 1.0, subtype='DISTANCE', parent=options)
data = group.interface.new_panel(name='Data', default_closed=True) data = group.interface.new_panel(name='Data', default_closed=True)
float_input(group, 'Uniform Scale', 1.0, parent=data).hide_in_modifier = True float_input(group, 'Uniform Scale', 1.0, parent=data).hide_in_modifier = True