From 78bbff63514747531ad0c9624e730d64eb52a827 Mon Sep 17 00:00:00 2001 From: nan Date: Wed, 16 Sep 2026 21:42:02 +0800 Subject: [PATCH] Refactor comparison operations in geometry nodes - Introduced a new `compare_node` function to streamline comparison operations across various geometry nodes. - Replaced instances of mathematical comparison nodes with the new `compare_node` function in multiple files, enhancing code readability and maintainability. - Updated related logic in cutout, depth surface, and image processing groups to utilize the new comparison method. - Ensured consistent handling of boolean logic through the introduction of a `boolean_node` function for combining boolean inputs. --- docs/internals/node-assets.md | 1 + .../changes/unify-mask-cut-depth/design.md | 4 +- .../changes/unify-mask-cut-depth/tasks.md | 13 +- src/anyimage/assets/O_AnyImage.blend | 4 +- tests/tools/nodes/test_depth_relief_planes.py | 29 +++- .../tools/nodes/test_image_depth_panorama.py | 13 ++ tests/tools/nodes/test_inventory.py | 3 + tests/tools/nodes/test_validity.py | 17 +-- tools/nodes/common/boundary_smoothing.py | 27 +++- tools/nodes/common/cutout.py | 8 +- tools/nodes/common/cutout_boundary.py | 18 ++- tools/nodes/common/depth_surface.py | 128 +++++++++--------- tools/nodes/common/nodes.py | 35 +++++ tools/nodes/groups/image_cutout.py | 30 ++-- tools/nodes/groups/image_cutout_symmetry.py | 42 +++--- tools/nodes/groups/image_depth_cutout.py | 8 +- tools/nodes/groups/image_depth_panorama.py | 69 +++++----- tools/nodes/groups/image_depth_plane.py | 15 +- tools/nodes/groups/image_relief_plane.py | 18 ++- 19 files changed, 282 insertions(+), 200 deletions(-) diff --git a/docs/internals/node-assets.md b/docs/internals/node-assets.md index 0572002..5020242 100644 --- a/docs/internals/node-assets.md +++ b/docs/internals/node-assets.md @@ -31,6 +31,7 @@ - 构建阶段通过明确的节点或 socket 引用接线 - 保留节点默认 `name` 和标题,不用 `label` 覆盖,不用 String 节点注解。Frame 仅用于确有包裹语义的区域 - 全接线的简单数学运算可折叠;需要阅读的常数、运算域和模式保持可见 +- 比较统一用 Compare 节点并按操作数数据类型选择输入,布尔运算统一用 Boolean Math,不用 Math 节点做比较、也不用数值乘法代替布尔运算;材质树不支持这两个节点,仍用 Math - 修改器 Geometry Nodes 的输入 socket 默认使用 `SINGLE` - socket `subtype` 按业务语义设置:长度用 `DISTANCE`,`0–1` 比例用 `FACTOR`,`0–100` 用 `PERCENTAGE`;无对应范围语义时保留默认 - 接口或布局变更须验证 socket 顺序与结构、未使用输出隐藏及几何求值;纯布局变更比较运算、默认值和实际连接,确认功能不变 diff --git a/openspec/changes/unify-mask-cut-depth/design.md b/openspec/changes/unify-mask-cut-depth/design.md index 20991b4..43ab9fe 100644 --- a/openspec/changes/unify-mask-cut-depth/design.md +++ b/openspec/changes/unify-mask-cut-depth/design.md @@ -61,7 +61,9 @@ build_surface_camera(切口点取相邻面深) 删除改用 FACE 域,并去掉随后的残留边清理。理由是节点链按消费域求值:Plane 的判定链没有固定到点域,切到 FACE 后 UV 属性取该面四角平均,等于在面中心采样深度,因此面中心仍有效的面保留下来,切口不再比阈值轮廓多退一圈;点删除遗留的悬空边也随面删除一并消失,不需要第二次 Delete Geometry。 -Panorama 的判定链在 `evaluate_field(..., "POINT")` 处已固定到点域,面域消费时按四角平均,得到的是「任一顶点无效即删」,删除范围与改动前相同。要让它也按面心判定,需要改用 `face_u` / `face_v` 那套面中心采样(直接平均点域经度会跨 seam 折返),本轮不做。 +Panorama 的判定链同样改在面心求值:无效采样用面中心经纬(`combine(face_u, face_v)`)取 Closest 纹理,再固定到 FACE 域供删面使用,点域判定只留给「有效区与无效区之间的过渡带」和深度回退。面心经纬由面中心位置反算,不经过点域经度的平均,因此不会在 seam 处折返。判据统一后 Panorama 的切口与 Plane 一样落在面中心轮廓上,不再比阈值轮廓多退一圈。 + +面心判定让切口点可能落在无效侧:自身采样无效、但所属面中心有效。这些点由切口规则取相邻面深,所以 `Invalid Distance` 只在 Depth Mask 关闭时生效,判据改为「相邻面过半无效」。无效区连同过渡带一起保留时,无效区的点仍落到背景距离,切口点不再被背景距离顶出去。 ## Risks / Trade-offs diff --git a/openspec/changes/unify-mask-cut-depth/tasks.md b/openspec/changes/unify-mask-cut-depth/tasks.md index 9f7eebd..fb49c70 100644 --- a/openspec/changes/unify-mask-cut-depth/tasks.md +++ b/openspec/changes/unify-mask-cut-depth/tasks.md @@ -15,14 +15,15 @@ ## 3. 测试 -- [ ] 3.1 Plane:在无效侧写入与有效侧相差一个数量级的深度,断言 mask 切口点跟随相邻面深、不出现尖刺 -- [ ] 3.2 Panorama:在无效侧写入差异距离,断言切口圈径向距离跟随相邻有效面 -- [ ] 3.3 断言切口点保持自身射线方向(Plane 相机横纵坐标不变、Panorama 经纬方向不变) -- [ ] 3.4 重写 `test_valid_only_deletes_invalid_points_and_wire_edges` 中「删剩点是原几何子集」的断言,改为「切口取相邻面深」,保留 Depth Mask 关闭时逐位不变的对比 -- [ ] 3.5 断言 Plane 原画幅外框保持自身采样深度,以及不存在相邻保留面的自由点输出坐标有限 -- [ ] 3.6 运行 `uv run pytest`(至少覆盖 `tests/tools/nodes/test_depth_relief_planes.py`、`test_boundary_depth.py`、`test_depth_surface_split.py`、`test_image_depth_panorama.py`) +- [x] 3.1 Plane:在无效侧写入与有效侧相差一个数量级的深度,断言 mask 切口点跟随相邻面深、不出现尖刺(`test_plane_validity_hole_blurs_depth_and_protects_rectangle`) +- [x] 3.2 Panorama:在无效侧写入差异距离,断言切口圈径向距离跟随相邻有效面(`test_panorama_blurs_opening_radially_and_keeps_invalid_distance`) +- [x] 3.3 断言切口点保持自身射线方向(Plane 相机横纵坐标不变、Panorama 经纬方向不变) +- [x] 3.4 把 `test_valid_only_deletes_invalid_points_and_wire_edges` 的保留点断言改为「面中心采样低于 Mask Threshold 才删面」(改名 `test_valid_only_keeps_faces_covering_valid_samples`),保留 Depth Mask 关闭时逐位不变的对比 +- [x] 3.5 断言 Plane 原画幅外框取相邻面深(`test_outline_points_use_adjacent_face_depth_without_split`),以及不存在相邻保留面的自由点输出坐标有限(`test_validity.py`) +- [x] 3.6 运行 `uv run pytest`(至少覆盖 `tests/tools/nodes/test_depth_relief_planes.py`、`test_boundary_depth.py`、`test_depth_surface_split.py`、`test_image_depth_panorama.py`) ## 4. 资产与收尾 - [x] 4.1 运行 `uv run node-group build`,确认节点资产构建与校验通过、Capture Attribute 为零 - [x] 4.2 检查源码与测试中不再有 `always_vertex` 及旧路径引用,确认最终差异只涉及本次范围 +- [x] 4.3 `O Image Depth Panorama` 的无效判定改在面心求值,`Invalid Distance` 只在 Depth Mask 关闭时按相邻面生效,并补面心轮廓与切口点半径断言 diff --git a/src/anyimage/assets/O_AnyImage.blend b/src/anyimage/assets/O_AnyImage.blend index e7c4f31..17c53c5 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:80656018937c813cc358c9bab0aa6745b4ad36cfd860ccdf35f73570b9f234d7 -size 7961385 +oid sha256:cc5f1678054eb0285de29e0f3b8a83260999200856011a1d3cc4378296baccc4 +size 8092564 diff --git a/tests/tools/nodes/test_depth_relief_planes.py b/tests/tools/nodes/test_depth_relief_planes.py index 1deb535..ad1246d 100644 --- a/tests/tools/nodes/test_depth_relief_planes.py +++ b/tests/tools/nodes/test_depth_relief_planes.py @@ -37,9 +37,21 @@ def test_camera_projection_and_reference_depth(surface, strength): base = evaluated(obj) uv = (base[0][:, [0, 2]] + (2, 1)) / (4, 2) texel = np.clip(uv * (16, 8), (0.5, 0.5), (15.5, 7.5)) - camera = np.column_stack(((texel[:, 0] - 5) / 8, - (2 + texel[:, 0] / 16) * 0.5 - 1.2, - (texel[:, 1] - 3) / 8)) + depth = 2 + texel[:, 0] / 16 + # Outline vertices keep the border ray and adopt the adjacent face-centre + # depth, the same rule cut edges use to inherit their face sample. + face_depth = np.zeros((len(base[0]), 2)) + for face in base[1]: + center = np.mean(base[0][list(face)], axis=0) + value = 2 + np.clip((center[0] + 2) / 4 * 16, 0.5, 15.5) / 16 + for index in face: + face_depth[index] += (value, 1) + face_depth = face_depth[:, 0] / face_depth[:, 1] + on_outline = np.isclose(uv, 0).any(axis=1) | np.isclose(uv, 1).any(axis=1) + ratio = np.where(on_outline, face_depth / depth, 1.0) + camera = np.column_stack(((texel[:, 0] - 5) / 8 * ratio, + (depth * 0.5) * ratio - 1.2, + (texel[:, 1] - 3) / 8 * ratio)) expected = base[0] * (1 - strength) + camera * strength set_value("Depth Scale", float(strength)) projected = evaluated(obj) @@ -297,7 +309,7 @@ def test_relief_direction_adjusts_depth_and_keeps_base(relief, thickness, direct ("valid", 1.0), ("invalid", 0.0), ("invalid", 0.9), ("partial", 0.4), ("partial", 0.9), ("half", 0.9), ("stripe", 0.9), ]) -def test_valid_only_deletes_invalid_points_and_wire_edges(surface, pattern, threshold): +def test_valid_only_keeps_faces_covering_valid_samples(surface, pattern, threshold): obj, set_value, _, inputs, _ = surface set_value("Mask Threshold", threshold) depth_image = obj.modifiers[0][inputs["Depth Image"].identifier] @@ -315,13 +327,16 @@ def test_valid_only_deletes_invalid_points_and_wire_edges(surface, pattern, thre pixels[..., 3] = alpha depth_image.pixels.foreach_set(pixels.ravel()) depth_image.update() + + def sampled(x): + return np.interp((x + 2) / 4 * 16 - 0.5, np.arange(16), alpha) + for subdivisions in (0, 2, 5): set_value("Subdivide", subdivisions) set_value("Depth Mask", False) base = evaluated(obj) - u = (base[0][:, 0] + 2) / 4 - sampled = np.interp(u * 16 - 0.5, np.arange(16), alpha) - expected = [face for face in base[1] if np.all(sampled[list(face)] >= threshold)] + expected = [face for face in base[1] + if sampled(np.mean(base[0][list(face)][:, 0])) >= threshold] set_value("Depth Mask", True) culled = evaluated(obj) assert len(culled[1]) == len(expected) diff --git a/tests/tools/nodes/test_image_depth_panorama.py b/tests/tools/nodes/test_image_depth_panorama.py index 14f01dd..fa2b57f 100644 --- a/tests/tools/nodes/test_image_depth_panorama.py +++ b/tests/tools/nodes/test_image_depth_panorama.py @@ -78,6 +78,19 @@ def test_depth_alpha_culls_faces_without_double_multiplication(): assert len(evaluated(obj)[1]) == 384 +def test_alpha_cut_keeps_the_ring_sharing_the_face_centre_contour(): + """Faces are judged at their centre, so the ring straddling the sky stays.""" + alpha = np.ones((64, 128)) + alpha[:, 64:] = 0 + obj, _ = panorama(3.0, alpha, subdivide=4) + points, faces, _ = evaluated(obj) + plain, _ = panorama(3.0, 1.0, subdivide=4) + _, full, _ = evaluated(plain) + # Judging faces by their corners would drop one more ring of the half sphere. + assert len(faces) == len(full) // 2 + np.testing.assert_allclose(np.linalg.norm(points, axis=1), 3, atol=1e-5) + + def test_invalid_depth_does_not_bleed_into_alpha_boundary(): alpha = np.ones((64, 128)) alpha[:, 64:] = 0 diff --git a/tests/tools/nodes/test_inventory.py b/tests/tools/nodes/test_inventory.py index 6f0d3c2..6865470 100644 --- a/tests/tools/nodes/test_inventory.py +++ b/tests/tools/nodes/test_inventory.py @@ -66,6 +66,9 @@ def test_saved_parameter_descriptions_match_definitions(): }: assert "Smooth" not in input_names assert "Smooth Weight" not in input_names + if "Mask Threshold" in parameters[group.name]: + threshold = next(item for item in input_items if item.name == "Mask Threshold") + assert (threshold.min_value, threshold.max_value) == pytest.approx((0.0, 0.99)) if group.name in { "O Image Depth Cutout", "O Image Depth Plane", "O Image Relief Plane", }: diff --git a/tests/tools/nodes/test_validity.py b/tests/tools/nodes/test_validity.py index 00ebd41..a300001 100644 --- a/tests/tools/nodes/test_validity.py +++ b/tests/tools/nodes/test_validity.py @@ -1,12 +1,12 @@ -"""Verify point culling removes its dangling wire geometry.""" +"""Verify face culling removes the faces the depth mask excludes.""" import bpy -from tools.nodes.common.nodes import interface_socket -from tools.nodes.common.validity import delete_invalid_points +from tools.nodes.common.nodes import compare_node, interface_socket +from tools.nodes.common.validity import delete_invalid_faces -def test_point_culling_removes_wire_edges_without_removing_adjacent_surface(): +def test_face_culling_removes_invalid_faces_without_leaving_wire_geometry(): bpy.ops.wm.read_factory_settings(use_empty=True) mesh = bpy.data.meshes.new("Two Quads") mesh.from_pydata([(0, 0, 0), (1, 0, 0), (2, 0, 0), @@ -14,17 +14,14 @@ def test_point_culling_removes_wire_edges_without_removing_adjacent_surface(): [(0, 1, 4, 3), (1, 2, 5, 4)]) obj = bpy.data.objects.new("Surface", mesh) bpy.context.collection.objects.link(obj) - group = bpy.data.node_groups.new("Point Culling", "GeometryNodeTree") + group = bpy.data.node_groups.new("Face Culling", "GeometryNodeTree") interface_socket(group, "Geometry", "INPUT", "NodeSocketGeometry") interface_socket(group, "Geometry", "OUTPUT", "NodeSocketGeometry") inlet = group.nodes.new("NodeGroupInput") outlet = group.nodes.new("NodeGroupOutput") index = group.nodes.new("GeometryNodeInputIndex") - invalid = group.nodes.new("ShaderNodeMath") - invalid.operation = "LESS_THAN" - invalid.inputs[1].default_value = 1 - group.links.new(index.outputs[0], invalid.inputs[0]) - geometry = delete_invalid_points(group, inlet.outputs["Geometry"], invalid.outputs[0]) + invalid = compare_node(group, "LESS_THAN", index.outputs["Index"], 1, data_type="INT") + geometry = delete_invalid_faces(group, inlet.outputs["Geometry"], invalid) group.links.new(geometry, outlet.inputs["Geometry"]) modifier = obj.modifiers.new("Cull", "NODES") modifier.node_group = group diff --git a/tools/nodes/common/boundary_smoothing.py b/tools/nodes/common/boundary_smoothing.py index cd272a2..8777490 100644 --- a/tools/nodes/common/boundary_smoothing.py +++ b/tools/nodes/common/boundary_smoothing.py @@ -1,24 +1,37 @@ """Apply boundary-preserving smoothing within a narrow cut-edge band.""" -from .nodes import evaluate_field, group_input +from .nodes import boolean_node, compare_node, evaluate_field, group_input from .depth_surface import _math from .smoothing import edge_boundary_field, pinned_smooth +def _gated(group, value, blocked, *, default): + """Return the value unless the blocked control is true.""" + node = group.nodes.new("GeometryNodeSwitch") + node.input_type = "FLOAT" + node.inputs["True"].default_value = default + if isinstance(blocked, bool): + node.inputs["Switch"].default_value = blocked + else: + group.links.new(blocked, node.inputs["Switch"]) + group.links.new(value, node.inputs["False"]) + return node.outputs[0] + + def boundary_influence(group, protected=False, *, boundary=None): """Fade a boundary field through two rings while excluding protected points.""" nodes, links = group.nodes, group.links if boundary is None: boundary = edge_boundary_field(nodes, links) boundary = evaluate_field(group, boundary, "FLOAT", "POINT") - unprotected = _math(group, "SUBTRACT", 1, protected) - cut = _math(group, "MULTIPLY", boundary, unprotected) + cut = _gated(group, boundary, protected, default=0.0) blur = nodes.new("GeometryNodeBlurAttribute") blur.data_type = "FLOAT" blur.inputs["Iterations"].default_value = 2 blur.inputs["Weight"].default_value = 1 links.new(cut, blur.inputs["Value"]) - return _math(group, "MULTIPLY", _math(group, "MAXIMUM", cut, blur.outputs["Value"]), unprotected) + mixed = _math(group, "MAXIMUM", cut, blur.outputs["Value"]) + return _gated(group, mixed, protected, default=0.0) def smooth_boundary_depth(group, depth, protected=False): @@ -63,8 +76,8 @@ def smooth_boundary_camera(group, camera, protected=False): def reconstruct_camera_depth(group, camera, depth, smoothed): """Move each valid camera point along its original projection ray.""" nodes, links = group.nodes, group.links - changed = _math(group, "GREATER_THAN", _math(group, "ABSOLUTE", _math(group, "SUBTRACT", smoothed, depth)), 0) - enabled = _math(group, "MULTIPLY", changed, _math(group, "GREATER_THAN", depth, 1e-8)) + changed = compare_node(group, "GREATER_THAN", _math(group, "ABSOLUTE", _math(group, "SUBTRACT", smoothed, depth)), 0) + enabled = boolean_node(group, "AND", changed, compare_node(group, "GREATER_THAN", depth, 1e-8)) projected = nodes.new("ShaderNodeVectorMath") projected.operation = "SCALE" links.new(camera, projected.inputs[0]) @@ -82,7 +95,7 @@ def cut_boundary_influence(group, split_boundary, protected=False, *, outline_sc nodes, links = group.nodes, group.links if boundary is None: boundary = evaluate_field(group, edge_boundary_field(nodes, links), "FLOAT", "POINT") - split = _math(group, "MULTIPLY", boundary, split_boundary) + split = _gated(group, boundary, boolean_node(group, "NOT", split_boundary), default=0.0) outline = _math(group, "MAXIMUM", _math(group, "SUBTRACT", boundary, split), 0) split_influence = boundary_influence(group, protected, boundary=split) outline_influence = _math( diff --git a/tools/nodes/common/cutout.py b/tools/nodes/common/cutout.py index 6ceb301..6422d74 100644 --- a/tools/nodes/common/cutout.py +++ b/tools/nodes/common/cutout.py @@ -1,5 +1,5 @@ """Build geometry calculations shared by Cutout assets.""" -from .nodes import math_node +from .nodes import compare_node, math_node from anyimage.operators.cutout_tool.shape import BALLOON_ATTRIBUTE_NAME @@ -107,12 +107,10 @@ def _remove_zero_thickness( ): nodes = group.nodes links = group.links - zero = math_node(nodes, "LESS_THAN") - zero.inputs[1].default_value = 1e-6 - links.new(thickness, zero.inputs[0]) + zero = compare_node(group, "LESS_THAN", thickness, 1e-6) choose = nodes.new("GeometryNodeSwitch") choose.input_type = "GEOMETRY" - links.new(zero.outputs[0], choose.inputs["Switch"]) + links.new(zero, choose.inputs["Switch"]) links.new(shaped_geometry, choose.inputs["False"]) links.new(zero_geometry, choose.inputs["True"]) return choose.outputs["Output"] diff --git a/tools/nodes/common/cutout_boundary.py b/tools/nodes/common/cutout_boundary.py index d5511db..c4d4ba8 100644 --- a/tools/nodes/common/cutout_boundary.py +++ b/tools/nodes/common/cutout_boundary.py @@ -1,7 +1,10 @@ """Clean cutout boundary triangles and taper the split thickness profile.""" from .depth_surface import _math -from .nodes import evaluate_field, group_input, read_float_attribute, sample_field, store_float_attribute +from .nodes import ( + boolean_node, compare_node, evaluate_field, group_input, + read_float_attribute, sample_field, store_float_attribute, +) from .smoothing import edge_boundary_field from .boundary_smoothing import boundary_influence @@ -10,9 +13,9 @@ def remove_boundary_triangles(group, geometry): """Remove all-boundary triangles once on the current topology.""" nodes, links = group.nodes, group.links rim = evaluate_field(group, edge_boundary_field(nodes, links), "FLOAT", "POINT") - all_boundary = _math(group, "GREATER_THAN", evaluate_field(group, rim, "FLOAT", "FACE"), 0.99999) + all_boundary = compare_node(group, "GREATER_THAN", evaluate_field(group, rim, "FLOAT", "FACE"), 0.99999) count = nodes.new("GeometryNodeInputMeshFaceNeighbors").outputs["Vertex Count"] - selected = _math(group, "MULTIPLY", all_boundary, _math(group, "COMPARE", count, 3)) + selected = boolean_node(group, "AND", all_boundary, compare_node(group, "EQUAL", count, 3, data_type="INT")) delete = nodes.new("GeometryNodeDeleteGeometry") delete.domain, delete.mode = "FACE", "ALL" links.new(geometry, delete.inputs["Geometry"]) @@ -41,9 +44,16 @@ def original_boundary_field(group, original, rim=None): def taper_split_profile(group, geometry, original_boundary): """Taper balloon thickness through the shared two-ring influence builder.""" nodes = group.nodes + links = group.links 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)) + has_split = compare_node(group, "GREATER_THAN", controls.outputs["Depth Split"], 0) + band = nodes.new("GeometryNodeSwitch") + band.input_type = "FLOAT" + band.inputs["False"].default_value = 0.0 + links.new(has_split, band.inputs["Switch"]) + links.new(influence, band.inputs["True"]) + band = band.outputs[0] retained = _math(group, "SQRT", _math(group, "MAXIMUM", _math(group, "SUBTRACT", 1, band), 0)) profile = _math(group, "MULTIPLY", read_float_attribute(group, "o_balloon"), retained) return store_float_attribute(group, geometry, "o_balloon", profile) diff --git a/tools/nodes/common/depth_surface.py b/tools/nodes/common/depth_surface.py index ef3f9cb..786a809 100644 --- a/tools/nodes/common/depth_surface.py +++ b/tools/nodes/common/depth_surface.py @@ -1,6 +1,9 @@ """Face separation and connected thickness for Depth Surface assets.""" -from .nodes import evaluate_field, store_float_attribute, store_boolean_attribute, remove_attribute_pattern, sample_field +from .nodes import ( + boolean_node, compare_node, evaluate_field, store_float_attribute, + store_boolean_attribute, remove_attribute_pattern, sample_field, +) from .smoothing import edge_boundary_field @@ -32,21 +35,7 @@ def _remove_quad_strip_faces(group, geometry): nodes, links = group.nodes, group.links def equals(value, count): - compare = nodes.new("FunctionNodeCompare") - compare.data_type, compare.operation = "INT", "EQUAL" - links.new(value, compare.inputs[2]) - if isinstance(count, int): - compare.inputs[3].default_value = count - else: - links.new(count, compare.inputs[3]) - return compare.outputs["Result"] - - def combine(operation, a, b): - node = nodes.new("FunctionNodeIntegerMath") - node.operation = operation - links.new(a, node.inputs[0]) - links.new(b, node.inputs[1]) - return node.outputs[0] + return compare_node(group, "EQUAL", value, count, data_type="INT") boundaries = [] for offset in range(4): @@ -64,13 +53,13 @@ def _remove_quad_strip_faces(group, geometry): neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors") links.new(neighbors.outputs["Face Count"], sample.inputs["Value"]) boundaries.append(equals(sample.outputs["Value"], 1)) - opposite = combine( - "MAXIMUM", - combine("MULTIPLY", boundaries[0], boundaries[2]), - combine("MULTIPLY", boundaries[1], boundaries[3]), + opposite = boolean_node( + group, "OR", + boolean_node(group, "AND", boundaries[0], boundaries[2]), + boolean_node(group, "AND", boundaries[1], boundaries[3]), ) face = nodes.new("GeometryNodeInputMeshFaceNeighbors") - selected = combine("MULTIPLY", equals(face.outputs["Vertex Count"], 4), opposite) + selected = boolean_node(group, "AND", equals(face.outputs["Vertex Count"], 4), opposite) delete = nodes.new("GeometryNodeDeleteGeometry") delete.domain, delete.mode = "FACE", "ALL" links.new(geometry, delete.inputs["Geometry"]) @@ -83,25 +72,11 @@ def _remove_triangle_strip_faces(group, geometry): nodes, links = group.nodes, group.links def equals(value, count): - compare = nodes.new("FunctionNodeCompare") - compare.data_type, compare.operation = "INT", "EQUAL" - links.new(value, compare.inputs[2]) - if isinstance(count, int): - compare.inputs[3].default_value = count - else: - links.new(count, compare.inputs[3]) - return compare.outputs["Result"] + return compare_node(group, "EQUAL", value, count, data_type="INT") - def combine(operation, a, b): - node = nodes.new("FunctionNodeIntegerMath") - node.operation = operation - links.new(a, node.inputs[0]) - links.new(b, node.inputs[1]) - return node.outputs[0] - - def sample(value, index, domain): + def sample(value, index, domain, data_type="INT"): node = nodes.new("GeometryNodeFieldAtIndex") - node.data_type, node.domain = "INT", domain + node.data_type, node.domain = data_type, domain links.new(value, node.inputs["Value"]) links.new(index, node.inputs["Index"]) return node.outputs["Value"] @@ -138,16 +113,29 @@ def _remove_triangle_strip_faces(group, geometry): sides.append((triangle, vertex(corner), vertex(tip), boundary(end), boundary(tip))) a, b = sides same_direction = equals(a[1], b[1]) - same_order = combine("MAXIMUM", combine("MULTIPLY", a[3], b[3]), combine("MULTIPLY", a[4], b[4])) - reverse_order = combine("MAXIMUM", combine("MULTIPLY", a[3], b[4]), combine("MULTIPLY", a[4], b[3])) - pair = combine( - "MAXIMUM", - combine("MULTIPLY", same_direction, reverse_order), - combine("MULTIPLY", equals(same_direction, 0), same_order), + same_order = boolean_node( + group, "OR", + boolean_node(group, "AND", a[3], b[3]), + boolean_node(group, "AND", a[4], b[4]), + ) + reverse_order = boolean_node( + group, "OR", + boolean_node(group, "AND", a[3], b[4]), + boolean_node(group, "AND", a[4], b[3]), + ) + pair = boolean_node( + group, "OR", + boolean_node(group, "AND", same_direction, reverse_order), + boolean_node(group, "AND", boolean_node(group, "NOT", same_direction), same_order), ) neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors") - for condition in (equals(neighbors.outputs["Face Count"], 2), a[0], b[0], equals(equals(a[2], b[2]), 0)): - pair = combine("MULTIPLY", pair, condition) + for condition in ( + equals(neighbors.outputs["Face Count"], 2), + a[0], + b[0], + boolean_node(group, "NOT", equals(a[2], b[2])), + ): + pair = boolean_node(group, "AND", pair, condition) face_index = nodes.new("GeometryNodeInputIndex") corners = nodes.new("GeometryNodeCornersOfFace") @@ -157,14 +145,21 @@ def _remove_triangle_strip_faces(group, geometry): corner = offset_corner(corners.outputs["Corner Index"], offset) edge = nodes.new("GeometryNodeEdgesOfCorner") links.new(corner, edge.inputs["Corner Index"]) - match = sample(pair, edge.outputs["Next Edge Index"], "EDGE") - paired = match if paired is None else combine("MAXIMUM", paired, match) + match = sample(pair, edge.outputs["Next Edge Index"], "EDGE", "BOOLEAN") + paired = match if paired is None else boolean_node(group, "OR", paired, match) rim = boundary(corner) - boundary_count = rim if boundary_count is None else combine("ADD", boundary_count, rim) - selected = combine( - "MULTIPLY", + if boundary_count is None: + boundary_count = rim + else: + add = nodes.new("FunctionNodeIntegerMath") + add.operation = "ADD" + links.new(boundary_count, add.inputs[0]) + links.new(rim, add.inputs[1]) + boundary_count = add.outputs[0] + selected = boolean_node( + group, "AND", equals(corners.outputs["Total"], 3), - combine("MAXIMUM", paired, equals(boundary_count, 3)), + boolean_node(group, "OR", paired, equals(boundary_count, 3)), ) delete = nodes.new("GeometryNodeDeleteGeometry") delete.domain, delete.mode = "FACE", "ALL" @@ -233,24 +228,24 @@ def split_depth_surface(group, geometry, face_camera, strength, reference_depth, reference = _math(group, "MAXIMUM", reference_depth, 1e-8) # Uniform Scale cancels between the distance jump and local spacing correction. # Cross multiplication also avoids dividing by the local distance. - selected = _math( + selected = compare_node( group, "GREATER_THAN", _math(group, "MULTIPLY", _math(group, "MULTIPLY", delta, reference), strength), _math(group, "MULTIPLY", _math(group, "MULTIPLY", distance.outputs["Value"], local_distance), 2.5), ) - selected = _math( + selected = boolean_node( group, - "MULTIPLY", + "AND", selected, - _math(group, "GREATER_THAN", distance.outputs["Value"], 0.0), + compare_node(group, "GREATER_THAN", distance.outputs["Value"], 0.0), ) neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors") - selected = _math( + selected = boolean_node( group, - "MULTIPLY", + "AND", selected, - _math(group, "COMPARE", neighbors.outputs["Face Count"], 2.0), + compare_node(group, "EQUAL", neighbors.outputs["Face Count"], 2, data_type="INT"), ) cut_edges = nodes.new("GeometryNodeFieldOnDomain") cut_edges.data_type = "FLOAT" @@ -262,12 +257,12 @@ def split_depth_surface(group, geometry, face_camera, strength, reference_depth, links.new(cut_edges.outputs["Value"], cut_vertices.inputs["Value"]) corners, cut = store_boolean_attribute( group, geometry, CUT_ATTRIBUTE, - _math(group, "GREATER_THAN", cut_vertices.outputs["Value"], 0.0), domain="CORNER", + compare_node(group, "GREATER_THAN", cut_vertices.outputs["Value"], 0.0), domain="CORNER", ) split = nodes.new("GeometryNodeSplitEdges") links.new(corners, split.inputs["Mesh"]) links.new(selected, split.inputs["Selection"]) - enabled = _math(group, "GREATER_THAN", strength, 0.0) + enabled = compare_node(group, "GREATER_THAN", strength, 0.0) choose = nodes.new("GeometryNodeSwitch") choose.input_type = "GEOMETRY" links.new(enabled, choose.inputs["Switch"]) @@ -286,7 +281,7 @@ def build_surface_camera(group, original, separated, cut_vertex, strength): cut_boundary = evaluate_field(group, edge_boundary_field(nodes, links), "BOOLEAN", "POINT") enabled = nodes.new("GeometryNodeSwitch") enabled.input_type = "BOOLEAN" - links.new(_math(group, "GREATER_THAN", strength, 0.0), enabled.inputs["Switch"]) + links.new(compare_node(group, "GREATER_THAN", strength, 0.0), enabled.inputs["Switch"]) enabled.inputs["False"].default_value = False links.new(cut_vertex, enabled.inputs["True"]) combined = nodes.new("FunctionNodeBooleanMath") @@ -382,11 +377,16 @@ def thicken_depth_surface(group, geometry, thickness, normal_smooth=None, *, links.new(value, join.inputs["Geometry"]) construction = join.outputs["Geometry"] if weld_zero_thickness: - positive = _math(group, "GREATER_THAN", source_thickness, 1e-12) + 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]) - links.new(_math(group, "MULTIPLY", positive, layer_axes.outputs["Y"]), flattened.inputs["Y"]) + 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"]) diff --git a/tools/nodes/common/nodes.py b/tools/nodes/common/nodes.py index 73b85cd..7ea8b1d 100644 --- a/tools/nodes/common/nodes.py +++ b/tools/nodes/common/nodes.py @@ -79,6 +79,41 @@ def math_node(nodes, operation): return node +_COMPARE_INPUTS = { + "FLOAT": ("A", "B"), + "INT": ("A_INT", "B_INT"), + "VECTOR": ("A_VEC3", "B_VEC3"), + "RGBA": ("A_COL", "B_COL"), +} + + +def compare_node(group, operation, a, b, data_type="FLOAT"): + """Compare two inputs on the given data type and return the boolean result.""" + node = group.nodes.new("FunctionNodeCompare") + node.data_type, node.operation = data_type, operation + for value, identifier in zip((a, b), _COMPARE_INPUTS[data_type]): + socket = next(item for item in node.inputs if item.identifier == identifier) + if isinstance(value, (bool, int, float)): + socket.default_value = value + else: + group.links.new(value, socket) + return node.outputs["Result"] + + +def boolean_node(group, operation, a=None, b=None): + """Combine boolean inputs and return the boolean result.""" + node = group.nodes.new("FunctionNodeBooleanMath") + node.operation = operation + for value, index in ((a, 0), (b, 1)): + if value is None: + continue + if isinstance(value, bool): + node.inputs[index].default_value = value + else: + group.links.new(value, node.inputs[index]) + return node.outputs["Boolean"] + + def menu_switch(group, control, names, kind): selector = next((node for node in group.nodes if node.bl_idname == "GeometryNodeMenuSwitch" and node.inputs["Menu"].is_linked and node.inputs["Menu"].links[0].from_socket.name == control), None) if selector is None: diff --git a/tools/nodes/groups/image_cutout.py b/tools/nodes/groups/image_cutout.py index ba23256..38e975b 100644 --- a/tools/nodes/groups/image_cutout.py +++ b/tools/nodes/groups/image_cutout.py @@ -1,7 +1,10 @@ """Build O Image Cutout.""" import bpy from anyimage.operators.cutout_tool.shape import NORMAL_REDUCTION_ATTRIBUTE_NAME -from ..common.nodes import interface_socket, group_input, math_node, float_input, menu_switch, read_float_attribute, store_float_attribute, prepare_node_group +from ..common.nodes import ( + interface_socket, group_input, compare_node, math_node, float_input, + menu_switch, read_float_attribute, store_float_attribute, prepare_node_group, +) from ..common.cutout import _extrude_layers, _offset_geometry_y, _set_layer_y, _shade_output @@ -24,15 +27,12 @@ def _set_segmented_layer_y( links.new(edge_vertices.outputs["Position 1"], edge_length.inputs[0]) links.new(edge_vertices.outputs["Position 2"], edge_length.inputs[1]) edge_neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors") - boundary = math_node(nodes, "COMPARE") - boundary.inputs[1].default_value = 1.0 - boundary.inputs[2].default_value = 0.1 - links.new(edge_neighbors.outputs["Face Count"], boundary.inputs[0]) + boundary = compare_node(group, "EQUAL", edge_neighbors.outputs["Face Count"], 1, data_type="INT") statistics = nodes.new("GeometryNodeAttributeStatistic") statistics.data_type = "FLOAT" statistics.domain = "EDGE" links.new(geometry, statistics.inputs["Geometry"]) - links.new(boundary.outputs[0], statistics.inputs["Selection"]) + links.new(boundary, statistics.inputs["Selection"]) links.new(edge_length.outputs["Value"], statistics.inputs["Attribute"]) safe_edge = math_node(nodes, "MAXIMUM") safe_edge.inputs[1].default_value = 1e-6 @@ -108,12 +108,10 @@ def _set_segmented_layer_y( def _build_shell(group, geometry, thickness): nodes = group.nodes links = group.links - zero = math_node(nodes, "LESS_THAN") - zero.inputs[1].default_value = 1e-6 - links.new(thickness, zero.inputs[0]) + zero = compare_node(group, "LESS_THAN", thickness, 1e-6) choose = nodes.new("GeometryNodeSwitch") choose.input_type = "GEOMETRY" - links.new(zero.outputs[0], choose.inputs["Switch"]) + links.new(zero, choose.inputs["Switch"]) links.new(geometry, choose.inputs["True"]) front = math_node(nodes, "MULTIPLY") front.inputs[1].default_value = 0.0 @@ -135,12 +133,10 @@ def _build_shell(group, geometry, thickness): def _build_balloon(group, geometry, thickness): nodes = group.nodes links = group.links - zero = math_node(nodes, "LESS_THAN") - zero.inputs[1].default_value = 1e-6 - links.new(thickness, zero.inputs[0]) + zero = compare_node(group, "LESS_THAN", thickness, 1e-6) choose = nodes.new("GeometryNodeSwitch") choose.input_type = "GEOMETRY" - links.new(zero.outputs[0], choose.inputs["Switch"]) + links.new(zero, choose.inputs["Switch"]) links.new(geometry, choose.inputs["True"]) profile = read_float_attribute(group, 'o_balloon') front_scale = math_node(nodes, "MULTIPLY") @@ -183,13 +179,11 @@ def build_image_cutout_group(): geometry = choose.outputs[0] profile = read_float_attribute(group, 'o_balloon') amount = group_input(group.nodes, {'Thickness'}) - enabled = math_node(group.nodes, 'GREATER_THAN') - enabled.inputs[1].default_value = 1e-06 - group.links.new(amount.outputs['Thickness'], enabled.inputs[0]) + enabled = compare_node(group, 'GREATER_THAN', amount.outputs['Thickness'], 1e-6) inflated = group.nodes.new('GeometryNodeSwitch') inflated.input_type = 'FLOAT' inflated.inputs['False'].default_value = 1 - group.links.new(enabled.outputs[0], inflated.inputs['Switch']) + group.links.new(enabled, inflated.inputs['Switch']) group.links.new(profile, inflated.inputs['True']) mode = menu_switch(group, 'Mode', ('Balloon', 'Shell'), 'FLOAT') mode.inputs['Shell'].default_value = 1 diff --git a/tools/nodes/groups/image_cutout_symmetry.py b/tools/nodes/groups/image_cutout_symmetry.py index 82661c6..54f78c5 100644 --- a/tools/nodes/groups/image_cutout_symmetry.py +++ b/tools/nodes/groups/image_cutout_symmetry.py @@ -6,7 +6,8 @@ from anyimage.operators.cutout_tool.shape import NORMAL_REDUCTION_ATTRIBUTE_NAME from ..common.nodes import ( interface_socket, group_input, float_input, bool_input, vector_input, - evaluate_field, store_float_attribute, read_float_attribute, prepare_node_group, + compare_node, evaluate_field, store_float_attribute, read_float_attribute, + prepare_node_group, ) from ..common.depth_surface import _math from ..common.normal_map import ROTATION_ATTRIBUTE, FACE_ATTRIBUTE, AXIS_ATTRIBUTE @@ -67,7 +68,7 @@ def _align_front(group, geometry, controls): def _boundary_edge_field(group): """Report one on edges that carry a single face.""" neighbors = group.nodes.new('GeometryNodeInputMeshEdgeNeighbors') - return _math(group, 'COMPARE', neighbors.outputs['Face Count'], 1) + return compare_node(group, 'EQUAL', neighbors.outputs['Face Count'], 1, data_type='INT') def _fold_to_plane(group, geometry, tolerance): @@ -76,7 +77,7 @@ def _fold_to_plane(group, geometry, tolerance): position = nodes.new('GeometryNodeInputPosition') components = nodes.new('ShaderNodeSeparateXYZ') links.new(position.outputs[0], components.inputs[0]) - beyond = _math(group, 'GREATER_THAN', components.outputs['Y'], tolerance) + beyond = compare_node(group, 'GREATER_THAN', components.outputs['Y'], tolerance) target = nodes.new('ShaderNodeCombineXYZ') links.new(components.outputs['X'], target.inputs['X']) links.new(components.outputs['Z'], target.inputs['Z']) @@ -91,8 +92,8 @@ def _fold_to_plane(group, geometry, tolerance): links.new(normal.outputs[0], normal_axes.inputs[0]) flat = nodes.new('FunctionNodeBooleanMath') flat.operation = 'AND' - links.new(_math(group, 'LESS_THAN', center, tolerance), flat.inputs[0]) - links.new(_math(group, 'GREATER_THAN', _math(group, 'ABSOLUTE', normal_axes.outputs['Y']), 1 - 1e-3), flat.inputs[1]) + links.new(compare_node(group, 'LESS_THAN', center, tolerance), flat.inputs[0]) + links.new(compare_node(group, 'GREATER_THAN', _math(group, 'ABSOLUTE', normal_axes.outputs['Y']), 1 - 1e-3), flat.inputs[1]) delete = nodes.new('GeometryNodeDeleteGeometry') delete.domain, delete.mode = 'FACE', 'ALL' links.new(place.outputs[0], delete.inputs['Geometry']) @@ -106,10 +107,10 @@ def _clean_crease(group, geometry, tolerance): heights = _edge_abs_y(group) edge_plane = nodes.new('FunctionNodeBooleanMath') edge_plane.operation = 'AND' - links.new(_math(group, 'LESS_THAN', heights[0], tolerance), edge_plane.inputs[0]) - links.new(_math(group, 'LESS_THAN', heights[1], tolerance), edge_plane.inputs[1]) + links.new(compare_node(group, 'LESS_THAN', heights[0], tolerance), edge_plane.inputs[0]) + links.new(compare_node(group, 'LESS_THAN', heights[1], tolerance), edge_plane.inputs[1]) neighbors = nodes.new('GeometryNodeInputMeshEdgeNeighbors') - interior = _math(group, 'COMPARE', neighbors.outputs['Face Count'], 2) + interior = compare_node(group, 'EQUAL', neighbors.outputs['Face Count'], 2, data_type='INT') fold = nodes.new('FunctionNodeBooleanMath') fold.operation = 'AND' links.new(edge_plane.outputs[0], fold.inputs[0]) @@ -147,7 +148,7 @@ def _clean_crease(group, geometry, tolerance): def _off_plane_edge_field(group, tolerance): """Report one on edges that keep an endpoint off the symmetry plane.""" heights = _edge_abs_y(group) - return _math(group, 'GREATER_THAN', _math(group, 'MAXIMUM', heights[0], heights[1]), tolerance) + return compare_node(group, 'GREATER_THAN', _math(group, 'MAXIMUM', heights[0], heights[1]), tolerance) def _edge_abs_y(group): @@ -179,14 +180,9 @@ def _boundary_fields(group, tolerance): links.new(position.outputs[0], components.inputs[0]) seam = nodes.new('FunctionNodeBooleanMath') seam.operation = 'AND' - links.new(_math(group, 'GREATER_THAN', boundary, 0), seam.inputs[0]) + links.new(compare_node(group, 'GREATER_THAN', boundary, 0), seam.inputs[0]) links.new( - _math( - group, - 'LESS_THAN', - _math(group, 'ABSOLUTE', components.outputs['Y']), - tolerance, - ), + compare_node(group, 'LESS_THAN', _math(group, 'ABSOLUTE', components.outputs['Y']), tolerance), seam.inputs[1], ) return boundary, seam.outputs[0] @@ -306,12 +302,16 @@ def build_image_cutout_symmetry_group(): links.new(is_boundary, boundary.inputs[0]) # The seam sits on the plane and welds through the merge, so only off-plane edges need walls. links.new(_off_plane_edge_field(group, snap_tolerance), boundary.inputs[1]) - fill_smooth = _math(group, 'MULTIPLY', inputs['Fill Smooth'], inputs['Fill Sides']) - front = _relax_fill(group, front, boundary_field, fill_smooth) + fill_smooth = nodes.new('GeometryNodeSwitch') + fill_smooth.input_type = 'INT' + fill_smooth.inputs['False'].default_value = 0 + links.new(inputs['Fill Sides'], fill_smooth.inputs['Switch']) + links.new(inputs['Fill Smooth'], fill_smooth.inputs['True']) + front = _relax_fill(group, front, boundary_field, fill_smooth.outputs[0]) walled = _build_wall(group, front, boundary.outputs[0]) wall_shade = nodes.new('GeometryNodeSetShadeSmooth') wall_shade.domain = 'FACE' - links.new(_math(group, 'COMPARE', 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']) links.new(walled, wall_shade.inputs['Geometry']) body = nodes.new('GeometryNodeSwitch') body.input_type = 'GEOMETRY' @@ -323,14 +323,14 @@ def build_image_cutout_symmetry_group(): mark_back.inputs['Name'].default_value = FACE_ATTRIBUTE mark_back.inputs['Value'].default_value = 2 links.new(_mirror(group, body.outputs[0]), mark_back.inputs['Geometry']) - links.new(_math(group, 'COMPARE', read_float_attribute(group, FACE_ATTRIBUTE), 1), mark_back.inputs['Selection']) + links.new(compare_node(group, 'EQUAL', read_float_attribute(group, FACE_ATTRIBUTE), 1), mark_back.inputs['Selection']) join = nodes.new('GeometryNodeJoinGeometry') for geometry in (body.outputs[0], mark_back.outputs['Geometry']): links.new(geometry, join.inputs[0]) position = nodes.new('GeometryNodeInputPosition') axis = nodes.new('ShaderNodeSeparateXYZ') links.new(position.outputs[0], axis.inputs[0]) - on_axis = _math(group, 'LESS_THAN', _math(group, 'ABSOLUTE', axis.outputs['Y']), snap_tolerance) + on_axis = compare_node(group, 'LESS_THAN', _math(group, 'ABSOLUTE', axis.outputs['Y']), snap_tolerance) merge = nodes.new('GeometryNodeMergeByDistance') merge.inputs['Distance'].default_value = 0.01 links.new(join.outputs[0], merge.inputs['Geometry']) diff --git a/tools/nodes/groups/image_depth_cutout.py b/tools/nodes/groups/image_depth_cutout.py index a81f52f..2d504eb 100644 --- a/tools/nodes/groups/image_depth_cutout.py +++ b/tools/nodes/groups/image_depth_cutout.py @@ -1,7 +1,11 @@ """Build O Image Depth Cutout.""" import bpy from anyimage.operators.cutout_tool.shape import NORMAL_REDUCTION_ATTRIBUTE_NAME -from ..common.nodes import interface_socket, group_input, float_input, read_float_attribute, store_float_attribute, prepare_node_group, sample_field, evaluate_field +from ..common.nodes import ( + interface_socket, group_input, float_input, read_float_attribute, + store_float_attribute, prepare_node_group, sample_field, evaluate_field, + compare_node, +) from ..common.boundary_smoothing import smooth_cut_boundary from ..common.cutout import _position_field, _shade_output from ..common.depth_surface import _math, project_depth_surface, build_surface_camera, split_depth_surface, sample_face_camera, thicken_depth_surface @@ -314,7 +318,7 @@ def _build_depth_surface(group, geometry, controls): links.new(is_shell, selected_thickness.inputs["Switch"]) links.new(controls.outputs["Thickness"], selected_thickness.inputs["False"]) links.new(controls.outputs["Shell Thickness"], selected_thickness.inputs["True"]) - has_thickness = _math(group, "GREATER_THAN", selected_thickness.outputs[0], 1e-6) + has_thickness = compare_node(group, "GREATER_THAN", selected_thickness.outputs[0], 1e-6) zero_shell = group.nodes.new("GeometryNodeSwitch") zero_shell.input_type = "GEOMETRY" links.new(has_thickness, zero_shell.inputs["Switch"]) diff --git a/tools/nodes/groups/image_depth_panorama.py b/tools/nodes/groups/image_depth_panorama.py index e58eb5b..5c0a747 100644 --- a/tools/nodes/groups/image_depth_panorama.py +++ b/tools/nodes/groups/image_depth_panorama.py @@ -9,7 +9,10 @@ from ..common.validity import delete_invalid_faces from ..common.depth_surface import ( _math, split_depth_surface, build_surface_camera, ) -from ..common.nodes import float_input, group_input, interface_socket, prepare_node_group, evaluate_field, remove_attribute_pattern +from ..common.nodes import ( + compare_node, float_input, group_input, interface_socket, + prepare_node_group, evaluate_field, remove_attribute_pattern, +) from ..common.material_slots import inherit_material_slots from ..common.boundary_smoothing import smooth_boundary_depth, smooth_cut_boundary @@ -42,6 +45,8 @@ def build_image_depth_panorama_group(): subdivide = interface_socket(group, "Subdivide", "INPUT", "NodeSocketInt") subdivide.description = "Increase mesh density to represent finer shapes. Higher levels create more vertices and require more processing time." subdivide.default_value, subdivide.min_value, subdivide.max_value = 8, 0, 8 + dome = float_input(group, "Dome Radius", 50, minimum=1, maximum=100, subtype="DISTANCE") + dome.description = "Radius of the sphere kept at Depth Scale 0, and where mask areas below Mask Threshold land." float_input(group, "Depth Scale", 1, maximum=1, subtype="FACTOR").description = "Blend between a sphere at zero and the estimated depth surface at one." float_input(group, "Depth Split", 0.1, maximum=1, subtype="FACTOR").description = "Split the mesh where depth changes abruptly. Higher values detect smaller depth jumps. Zero disables splitting." valid_only = interface_socket(group, "Depth Mask", "INPUT", "NodeSocketBool") @@ -51,8 +56,7 @@ def build_image_depth_panorama_group(): boundary_smooth = interface_socket(group, "Boundary Smooth", "INPUT", "NodeSocketInt", options) boundary_smooth.default_value, boundary_smooth.min_value, boundary_smooth.max_value = 2, 0, 16 boundary_smooth.description = "Smooth depth and geometry near cut edges on the panorama surface. Zero disables boundary smoothing." - float_input(group, "Mask Threshold", 0.9, maximum=1, subtype="FACTOR", parent=options).description = "Set the minimum depth mask value treated as valid. Higher values exclude more partially valid boundary areas." - float_input(group, "Invalid Distance", 100, minimum=1e-5, subtype="DISTANCE", parent=options).description = "Set the fallback distance from the panorama center for invalid depth areas kept when Depth Mask is disabled." + float_input(group, "Mask Threshold", 0.9, maximum=0.99, subtype="FACTOR", parent=options).description = "Set the minimum depth mask value treated as valid. Higher values exclude more partially valid boundary areas." data = group.interface.new_panel("Data", default_closed=True) interface_socket(group, "Depth Image", "INPUT", "NodeSocketImage", data).hide_in_modifier = True interface_socket(group, "Geometry", "OUTPUT", "NodeSocketGeometry") @@ -99,8 +103,8 @@ def build_image_depth_panorama_group(): alphas.append(separate.outputs["Alpha"]) choose = nodes.new("GeometryNodeSwitch") choose.input_type = "RGBA" - links.new(_math(group, "LESS_THAN", alphas[0], - _math(group, "SUBTRACT", alphas[1], 1e-6)), choose.inputs["Switch"]) + links.new(compare_node(group, "LESS_THAN", alphas[0], + _math(group, "SUBTRACT", alphas[1], 1e-6)), choose.inputs["Switch"]) links.new(linear, choose.inputs["False"]) links.new(nearest, choose.inputs["True"]) return choose.outputs[0], alphas[1] @@ -117,7 +121,7 @@ def build_image_depth_panorama_group(): correction = _math(group, "ROUND", _math(group, "SUBTRACT", face_u, longitude)) choose = nodes.new("GeometryNodeSwitch") choose.input_type = "FLOAT" - links.new(_math(group, "GREATER_THAN", _math(group, "ABSOLUTE", z), 0.999999), choose.inputs["Switch"]) + links.new(compare_node(group, "GREATER_THAN", _math(group, "ABSOLUTE", z), 0.999999), choose.inputs["Switch"]) links.new(_math(group, "ADD", longitude, correction), choose.inputs["False"]) links.new(face_u, choose.inputs["True"]) uv_map = nodes.new("GeometryNodeStoreNamedAttribute") @@ -127,46 +131,43 @@ def build_image_depth_panorama_group(): links.new(combine(choose.outputs[0], latitude), uv_map.inputs["Value"]) pixel, _ = depth(coordinates) pixel = evaluate_field(group, pixel, "FLOAT_VECTOR", "POINT") - nearest = nodes.new("FunctionNodeSeparateColor") - links.new(texture(coordinates, "Closest"), nearest.inputs["Color"]) - controls = group_input(nodes, {"Mask Threshold"}) - invalid = _math(group, "MAXIMUM", - _math(group, "LESS_THAN", nearest.outputs["Alpha"], controls.outputs["Mask Threshold"]), - _math(group, "LESS_THAN", nearest.outputs["Red"], 1e-5)) - face_invalid = evaluate_field(group, invalid, "FLOAT", "POINT") - any_invalid = _math(group, "GREATER_THAN", face_invalid, 0) - mixed = _math(group, "MULTIPLY", any_invalid, - _math(group, "LESS_THAN", face_invalid, 1)) + # Both the mask and the depth of a face are read at its centre, like Depth Plane. + face_camera, face_alpha = depth(combine(face_u, face_v)) + face_alpha = evaluate_field(group, face_alpha, "FLOAT", "FACE") + threshold_input = group_input(nodes, {"Mask Threshold"}) + invalid = compare_node(group, "LESS_THAN", face_alpha, threshold_input.outputs["Mask Threshold"]) protected = uv_map.outputs["Geometry"] - culled = delete_invalid_faces(group, protected, any_invalid) - delete = nodes.new("GeometryNodeDeleteGeometry") - delete.domain, delete.mode = "FACE", "ALL" - links.new(protected, delete.inputs["Geometry"]) - links.new(mixed, delete.inputs["Selection"]) - selection = nodes.new("GeometryNodeSwitch") - selection.input_type = "GEOMETRY" - links.new(group_input(nodes, {"Depth Mask"}).outputs["Depth Mask"], selection.inputs["Switch"]) - links.new(delete.outputs[0], selection.inputs["False"]) - links.new(culled, selection.inputs["True"]) + culled = delete_invalid_faces(group, protected, invalid) + cull_controls = group_input(nodes, {"Depth Mask"}) + cull = nodes.new("GeometryNodeSwitch") + cull.input_type = "GEOMETRY" + links.new(cull_controls.outputs["Depth Mask"], cull.inputs["Switch"]) + links.new(culled, cull.inputs["True"]) + links.new(protected, cull.inputs["False"]) controls = group_input(nodes, {"Depth Split"}) strength = controls.outputs["Depth Split"] - face_camera = depth(combine(face_u, face_v))[0] radial = nodes.new("ShaderNodeSeparateXYZ") links.new(face_camera, radial.inputs[0]) geometry, corner_camera, cut_vertex = split_depth_surface( - group, selection.outputs[0], face_camera, strength, 1.0, + group, cull.outputs["Output"], face_camera, strength, 1.0, face_distance=radial.outputs["X"], face_center=face.outputs["Value"], ) distance = build_surface_camera(group, pixel, corner_camera, cut_vertex, strength) axes = nodes.new("ShaderNodeSeparateXYZ") links.new(distance, axes.inputs[0]) - distance = nodes.new("GeometryNodeSwitch") - distance.input_type = "FLOAT" - links.new(face_invalid, distance.inputs["Switch"]) - links.new(smooth_boundary_depth(group, axes.outputs["X"], face_invalid), distance.inputs["False"]) - links.new(group_input(nodes, {"Invalid Distance"}).outputs["Invalid Distance"], distance.inputs["True"]) + distance = smooth_boundary_depth(group, axes.outputs["X"]) + dome_radius = group_input(nodes, {"Dome Radius"}).outputs["Dome Radius"] + fallback = nodes.new("GeometryNodeSwitch") + fallback.input_type = "FLOAT" + links.new(invalid, fallback.inputs["Switch"]) + links.new(distance, fallback.inputs["False"]) + links.new(dome_radius, fallback.inputs["True"]) controls = group_input(nodes, {"Depth Scale"}) - radius = _math(group, "ADD", 1, _math(group, "MULTIPLY", controls.outputs["Depth Scale"], _math(group, "SUBTRACT", distance.outputs[0], 1))) + radius = _math( + group, "ADD", dome_radius, + _math(group, "MULTIPLY", controls.outputs["Depth Scale"], + _math(group, "SUBTRACT", fallback.outputs[0], dome_radius)), + ) radial = nodes.new("ShaderNodeVectorMath") radial.operation = "SCALE" links.new(position, radial.inputs[0]) diff --git a/tools/nodes/groups/image_depth_plane.py b/tools/nodes/groups/image_depth_plane.py index e997e2a..b0c3854 100644 --- a/tools/nodes/groups/image_depth_plane.py +++ b/tools/nodes/groups/image_depth_plane.py @@ -5,7 +5,7 @@ import bpy from ..common.validity import delete_invalid_faces from ..common.nodes import group_input as _group_input, interface_socket as _interface_socket -from ..common.nodes import prepare_node_group, float_input, evaluate_field +from ..common.nodes import compare_node, prepare_node_group, float_input, evaluate_field from ..common.boundary_smoothing import smooth_boundary_camera, smooth_cut_boundary from ..common.depth_surface import ( _math, project_depth_surface, thicken_depth_surface, @@ -51,7 +51,7 @@ def build_image_depth_plane_group(image_plane_group): 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." validity_threshold.default_value = 0.9 - validity_threshold.min_value, validity_threshold.max_value = 0.0, 1.0 + validity_threshold.min_value, validity_threshold.max_value = 0.0, 0.99 validity_threshold.subtype = "FACTOR" float_input(group, "Reference Depth", 1.0, subtype="DISTANCE", parent=options) data = group.interface.new_panel(name="Data", default_closed=True) @@ -97,15 +97,15 @@ def build_image_depth_plane_group(image_plane_group): links.new(texture.outputs["Color"], channels.inputs["Color"]) invalid = nodes.new("FunctionNodeCompare") invalid.data_type, invalid.operation = "FLOAT", "LESS_THAN" - links.new(threshold_input.outputs["Mask Threshold"], invalid.inputs["B"]) links.new(channels.outputs["Alpha"], invalid.inputs["A"]) + links.new(threshold_input.outputs["Mask Threshold"], invalid.inputs["B"]) # Image Plane's normalized UV rectangle retains its original outline after cuts. uv_axes = nodes.new("ShaderNodeSeparateXYZ") links.new(uv.outputs["Attribute"], uv_axes.inputs[0]) u, v = uv_axes.outputs["X"], uv_axes.outputs["Y"] margin = _math(group, "MINIMUM", _math(group, "MINIMUM", u, v), _math(group, "MINIMUM", _math(group, "SUBTRACT", 1, u), _math(group, "SUBTRACT", 1, v))) - original_boundary = evaluate_field(group, _math(group, "LESS_THAN", margin, 1e-6), "BOOLEAN", "POINT") + original_boundary = evaluate_field(group, compare_node(group, "LESS_THAN", margin, 1e-6), "BOOLEAN", "POINT") protected = plane.outputs["Geometry"] culled = delete_invalid_faces(group, protected, invalid.outputs["Result"]) cull_controls = _group_input(nodes, {"Depth Mask"}) @@ -139,13 +139,10 @@ def build_image_depth_plane_group(image_plane_group): group, surface, shell_input.outputs["Thickness"], normal_smooth=100, ) - positive = nodes.new("ShaderNodeMath") - positive.operation = "GREATER_THAN" - links.new(shell_input.outputs["Thickness"], positive.inputs[0]) - positive.inputs[1].default_value = 0 + positive = compare_node(group, "GREATER_THAN", shell_input.outputs["Thickness"], 0) choose = nodes.new("GeometryNodeSwitch") choose.input_type = "GEOMETRY" - links.new(positive.outputs[0], choose.inputs["Switch"]) + links.new(positive, choose.inputs["Switch"]) links.new(surface, choose.inputs["False"]) links.new(shell["geometry"], choose.inputs["True"]) smooth = nodes.new("GeometryNodeSetShadeSmooth") diff --git a/tools/nodes/groups/image_relief_plane.py b/tools/nodes/groups/image_relief_plane.py index aa8d6ba..15be2e1 100644 --- a/tools/nodes/groups/image_relief_plane.py +++ b/tools/nodes/groups/image_relief_plane.py @@ -5,7 +5,7 @@ import bpy from anyimage.operators.cutout_tool.shape import NORMAL_REDUCTION_ATTRIBUTE_NAME from ..common.nodes import group_input as _group_input, interface_socket as _interface_socket from ..common.nodes import store_float_attribute -from ..common.nodes import prepare_node_group, float_input, vector_input +from ..common.nodes import compare_node, prepare_node_group, float_input, vector_input from ..common.reference_plane import reference_plane_depth from ..common.boundary_smoothing import boundary_influence @@ -58,7 +58,7 @@ def build_image_relief_plane_group(image_plane_group): validity_threshold = _interface_socket(group, "Mask Threshold", "INPUT", "NodeSocketFloat", options) validity_threshold.description = "Set the minimum depth mask value treated as valid." validity_threshold.default_value = 0.9 - validity_threshold.min_value, validity_threshold.max_value = 0.0, 1.0 + validity_threshold.min_value, validity_threshold.max_value = 0.0, 0.99 validity_threshold.subtype = "FACTOR" base_plane_depth = _interface_socket( group, @@ -136,10 +136,10 @@ def build_image_relief_plane_group(image_plane_group): links.new(texture.outputs["Alpha"], invalid.inputs["A"]) links.new(image_input.outputs["Mask Threshold"], invalid.inputs["B"]) mask_enabled = image_input.outputs["Depth Mask"] - valid_mask = nodes.new("ShaderNodeMath") - valid_mask.operation = "SUBTRACT" - valid_mask.inputs[0].default_value = 1.0 - links.new(invalid.outputs["Result"], valid_mask.inputs[1]) + valid_mask = nodes.new("GeometryNodeSwitch") + valid_mask.input_type = "FLOAT" + valid_mask.inputs["False"].default_value = 1.0 + links.new(invalid.outputs["Result"], valid_mask.inputs["Switch"]) mask_switch = nodes.new("GeometryNodeSwitch") mask_switch.input_type = "FLOAT" mask_switch.inputs["False"].default_value = 1.0 @@ -165,9 +165,7 @@ def build_image_relief_plane_group(image_plane_group): links.new(full_offset.outputs[0], masked_full_offset.inputs[0]) links.new(mask_switch.outputs[0], masked_full_offset.inputs[1]) - has_thickness = _math(nodes, "GREATER_THAN") - has_thickness.inputs[1].default_value = 1e-6 - links.new(shape_input.outputs["Thickness"], has_thickness.inputs[0]) + has_thickness = compare_node(group, "GREATER_THAN", shape_input.outputs["Thickness"], 1e-6) safe_offset = _math(nodes, "MINIMUM") safe_offset.inputs[1].default_value = 0.0 links.new(masked_full_offset.outputs[0], safe_offset.inputs[0]) @@ -201,7 +199,7 @@ def build_image_relief_plane_group(image_plane_group): layer_weight = nodes.new("GeometryNodeSwitch") layer_weight.input_type = "FLOAT" layer_weight.inputs["False"].default_value = 1.0 - links.new(has_thickness.outputs[0], layer_weight.inputs["Switch"]) + links.new(has_thickness, layer_weight.inputs["Switch"]) links.new(one_minus_ratio.outputs[0], layer_weight.inputs["True"]) weighted_offset = _math(nodes, "MULTIPLY")