diff --git a/openspec/changes/restore-depth-surface-stretch-culling/.openspec.yaml b/openspec/changes/restore-depth-surface-stretch-culling/.openspec.yaml new file mode 100644 index 0000000..9696e00 --- /dev/null +++ b/openspec/changes/restore-depth-surface-stretch-culling/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-03 diff --git a/openspec/changes/restore-depth-surface-stretch-culling/design.md b/openspec/changes/restore-depth-surface-stretch-culling/design.md new file mode 100644 index 0000000..a6ee845 --- /dev/null +++ b/openspec/changes/restore-depth-surface-stretch-culling/design.md @@ -0,0 +1,79 @@ +## Context + +Depth Surface 当前从 Vector Depth EXR 的 RGB 采样 Camera XYZ,在 `Set Position` 中把 Cutout 基础网格投影为透视表面,再生成 Thickness 并执行 Smooth。相邻像素位于不同深度层时,投影会把原本相近的顶点拉开,形成跨越前后景的长边和长三角面。外层 `O Image Cutout` 的 Cleanup 只按基础网格的 Mesh Island 面积执行,无法识别仍连接在主体上的拉伸边。 + +旧实现曾依赖预计算的深度连续性 Field;当前 Vector Depth 已保存完整 Camera XYZ,因此可以直接在 Geometry Nodes 中比较同一条边投影前后的长度,不再增加 Server Artifact 或占用 Alpha 通道。 + +## Goals / Non-Goals + +**Goals:** + +- 直接按 Depth Surface 实际投影结果识别过度拉伸的边,并删除其关联面。 +- 让判据随 `Depth Scale` 实时变化,并对不同对象尺寸使用相同的无量纲比例。 +- 用默认 `0` 明确关闭剔面,保证新建结果的默认拓扑不变。 +- 删除剔面后不再属于任何面的松散 Edge 与 Point。 +- 保持节点主链从左到右,接口只暴露与用户结果相关的一个控制量。 + +**Non-Goals:** + +- 不恢复 Depth EXR 中的旧 Depth Continuity 数据,也不修改 RGB 或 Validity Alpha 契约。 +- 不用 Blur Attribute、曲率或模型 Validity 参与拉伸判断。 +- 不为 Surface、Balloon 或 Depth Balloon 增加相同控制。 +- 不新增自动 Remesh、补洞或拓扑重建。 + +## Decisions + +### 1. 在 Point Domain 捕获投影前边长 Field + +`O Image Depth Surface` 在修改顶点位置前使用 Edge Vertices 的两个 Position 计算边长,并用 Capture Attribute 以 `POINT` Domain 保存原始值。同一个未捕获的 Distance Field 直接连接 Divide,在完整 XYZ Set Position 后的 Delete Geometry 上下文中重新求值得到当前边长。 + +该接法与 Blender Geometry Nodes 对 Edge Vertices 距离 Field 的求值方式一致;捕获发生在 `Set Position` 之前,因此后续位置变化不会重算原始值。与在 Blender Python 中预写属性相比,节点内捕获还能让结果跟随基础几何和 Modifier 求值。 + +### 2. Stretch Limit 使用剩余比例阈值 + +拉伸判断等价于: + +```text +remaining ratio = original edge length / current edge length +delete = remaining ratio < Stretch Limit +``` + +接口名为 **Stretch Limit**,类型为 `0–1` Factor,默认值 `0`,放在 Depth Surface 的 Options Panel。原始与当前 Edge 距离 Field 都适配到 Point Domain;值越大,清理越严格。所有正常值都不小于 `0`,因此默认值 `0` 自然关闭剔面,不需要额外 Boolean 分支。 + +不采用 Blur XYZ 的残差,因为它同时响应正常曲率、开放边界和 Mesh Detail。 + +### 3. 在投影后、Thickness 前删除 Point + +节点以 `POINT / ALL` 执行 Delete Geometry。Edge 长度 Field 会适配到 Point Domain,删除超限位置及其相邻 Edge 和 Face,使深度断层成为开放边界。剔除发生在 Thickness 之前,因此后续加厚可以围绕最终表面边界生成侧壁;Smooth 只处理已经清理的表面。 + +拉伸判据直接读取 Set Position 后的几何,所以 `Depth Scale = 0` 时边长回到基础网格,不产生拉伸剔面;调整 Depth Scale 或 Stretch Limit 会实时更新拓扑。现有外层 Mesh Island Cleanup 仍在 Shape 分支前执行,不改变其绝对面积语义。 + +### 4. 按拓扑归属清理松散元素 + +第一次删除后,使用 Edge Neighbors 的 `Face Count == 0` 选择并删除纯线 Edge。随后使用 Vertex Neighbors 的 `Face Count == 0` 删除不再属于任何 Face 的 Point。两步都位于 Stretch Culling 后、Thickness 前。 + +只清理 Face Count 为零的元素,不删除开放边界上仍属于一个 Face 的 Edge,也不运行补洞或 Remesh。 + +### 5. 通过外层菜单组暴露 Depth Surface 专属输入 + +`O Image Depth Surface` 新增 `Stretch Limit` 输入;`O Image Cutout` 增加同名输入并只连接到 Depth Surface 子组。沿用当前 Menu Switch 的未使用输入隐藏行为,使其他三个 Shape 不显示或消费该参数。创建对象时依赖节点接口默认值 `0`,不增加 Operator Property 或 Server Job 参数。 + +## Risks / Trade-offs + +- [阈值接近 `1` 时可能受浮点误差影响] → 默认关闭,并用明显跨过阈值的合成深度断层覆盖求值测试。 +- [删除一条长边会同时删除它两侧的 Face,形成比单面判断更宽的开口] → 这是断层边界的预期行为,并以合成三角网格求值测试锁定范围。 +- [极端阈值可能把表面切成多个较大岛] → 保留用户对 Stretch Limit 和现有 Cleanup 的独立控制,不自动 Remesh 或删除有面的岛。 +- [Delete Geometry 后可能残留纯线或孤立点] → 顺序执行 Edge Face Count 与 Point Face Count 清理,并验证最终网格所有 Edge 和 Point 都属于至少一个 Face。 +- [节点资产源码与 `.blend` 不一致] → 同步构建脚本、验证脚本、资产和真实 Blender 求值测试。 + +## Migration Plan + +1. 为接口、比例判断、关闭语义和松散拓扑增加失败测试。 +2. 更新 Cutout 节点资产构建脚本并重建、验证 `O_AnyImage.blend`。 +3. 更新用户与内部文档,运行 Cutout 定向测试和全量测试。 + +回退时整体恢复节点构建脚本和节点资产即可;默认值为 `0`,无需迁移已有对象数据或 Depth Artifact。 + +## Open Questions + +无。 diff --git a/openspec/changes/restore-depth-surface-stretch-culling/proposal.md b/openspec/changes/restore-depth-surface-stretch-culling/proposal.md new file mode 100644 index 0000000..38ab74b --- /dev/null +++ b/openspec/changes/restore-depth-surface-stretch-culling/proposal.md @@ -0,0 +1,28 @@ +## Why + +Depth Surface 将基础 Cutout 网格投影到相机空间 XYZ 后,深度断层会把相邻顶点拉成跨越前后景的长面。当前 Cleanup 只在 Shape 变形前删除小型 Mesh Island,无法识别这些仍连接在主体上的拉伸面,也无法清理剔面后留下的单线拓扑。 + +## What Changes + +- 为 Depth Surface 增加 **Stretch Limit**,根据深度投影前后的边长比例剔除过度拉伸的 Edge 及其关联 Face。 +- **Stretch Limit** 默认值为 `0`,表示关闭拉伸剔面;正值越大,清理越严格。 +- 在 Depth Surface 投影前按 Point Domain 捕获原始边长,投影 Set Position 后直接重新计算边长,使判据跟随实际 `Depth Scale` 结果且不依赖 Depth EXR 的额外通道。 +- 以 Point Domain 删除超限位置及其相邻拓扑,再删除 `Face Count == 0` 的松散 Edge 和 Point,最后执行 Depth Surface 后续 Thickness 与 Smooth;现有 Mesh Island Cleanup 语义保持不变。 +- 更新 Cutout 节点资产、验证、Blender 求值测试及用户与内部文档。 + +## Capabilities + +### New Capabilities + +- `depth-surface-stretch-culling`: 规定 Depth Surface 的边长比例判据、关闭语义、松散拓扑清理及节点处理顺序。 + +### Modified Capabilities + +无。 + +## Impact + +- 影响 `tools/node_assets/build_cutout.py` 中 `O Image Depth Surface` 与外层 `O Image Cutout` 的接口和节点主链。 +- 影响 `src/anyimage/assets/O_AnyImage.blend`、节点资产验证及 `tests/test_cutout_objects.py` 的真实 Blender 求值覆盖。 +- 需要同步 Cutout 用户说明、内部 Operator 文档与节点资产文档。 +- 不修改 Server Job、模型推理、Depth Metadata 或 Vector Depth EXR 的 RGB/Validity Alpha 协议,不新增依赖。 diff --git a/openspec/changes/restore-depth-surface-stretch-culling/specs/depth-surface-stretch-culling/spec.md b/openspec/changes/restore-depth-surface-stretch-culling/specs/depth-surface-stretch-culling/spec.md new file mode 100644 index 0000000..a0a5329 --- /dev/null +++ b/openspec/changes/restore-depth-surface-stretch-culling/specs/depth-surface-stretch-culling/spec.md @@ -0,0 +1,53 @@ +## ADDED Requirements + +### Requirement: Depth Surface exposes a stretch multiplier limit +系统 SHALL 仅为 Depth Surface 提供 **Stretch Limit** Factor 输入,范围为 `0–1`、默认值为 `0`,并将它放入 Options Panel。系统 SHALL 以原始边长除以投影后边长得到剩余比例;`0` SHALL 关闭拉伸剔面,正值越大 SHALL 越严格地清理拉伸面。 + +#### Scenario: Default value preserves the complete surface +- **WHEN** 用户创建 Depth Surface 且保持 Stretch Limit 为 `0` +- **THEN** 系统 SHALL 不因边长比例删除任何 Edge 或 Face + +#### Scenario: Other Cutout shapes do not consume the limit +- **WHEN** 当前 Shape 为 Surface、Balloon 或 Depth Balloon +- **THEN** Stretch Limit SHALL 不显示为该 Shape 的有效控制,也 SHALL 不改变其几何结果 + +### Requirement: Stretch is measured on matching edges around depth projection +系统 SHALL 在 Depth Surface 的 `Set Position` 之前以 Point Domain 捕获 Edge Vertices Distance Field 的原始值,并将同一个未捕获 Field 直接用于 Set Position 后的当前距离。系统 SHALL 在 `原始距离 / 当前距离 < Stretch Limit` 时以 Point Domain 删除对应位置及其相邻 Edge 和 Face。 + +#### Scenario: Point remains within the threshold +- **WHEN** 一个 Point 上适配后的边长比例不低于 Stretch Limit +- **THEN** 系统 SHALL 保留该 Point 及其相邻拓扑 + +#### Scenario: Point exceeds the threshold +- **WHEN** 一个 Point 上适配后的边长比例低于 Stretch Limit +- **THEN** 系统 SHALL 删除该 Point 及相邻 Edge 和 Face + +#### Scenario: Depth Scale changes the evaluated stretch +- **WHEN** 用户调整 Depth Scale,使同一条 Edge 的投影后长度跨过当前 Stretch Limit +- **THEN** Geometry Nodes SHALL 实时更新该 Edge 及关联 Face 的保留状态 + +#### Scenario: Lower threshold permits more stretch +- **WHEN** 同一个 Point 的剩余比例不低于 Stretch Limit +- **THEN** 系统 SHALL 保留该 Point 及其相邻拓扑 + +### Requirement: Stretch culling removes loose topology +系统 SHALL 在删除拉伸 Point 后删除 `Edge Neighbors.Face Count == 0` 的 Edge,并随后删除 `Vertex Neighbors.Face Count == 0` 的 Point。该清理 SHALL 位于 Depth Surface Thickness 与 Smooth 之前,并 SHALL 保留仍属于至少一个 Face 的开放边界。 + +#### Scenario: Face deletion leaves a single line +- **WHEN** 拉伸剔面后有 Edge 不再属于任何 Face +- **THEN** 系统 SHALL 删除该 Edge,最终 Depth Surface 不包含纯线拓扑 + +#### Scenario: Edge deletion leaves isolated points +- **WHEN** 松散 Edge 清理后有 Point 不再属于任何 Face +- **THEN** 系统 SHALL 删除该 Point,最终 Depth Surface 不包含孤立点 + +#### Scenario: Culling creates an open surface boundary +- **WHEN** 一条 Edge 在拉伸剔面后仍属于一个保留的 Face +- **THEN** 系统 SHALL 保留该 Edge,使后续 Thickness 能围绕最终开放边界构建侧壁 + +### Requirement: Stretch culling preserves Depth Artifact semantics +系统 SHALL 只使用 Geometry Nodes 中投影前后的几何边长完成拉伸判断,并 SHALL 保持 Vector Depth EXR 的 RGB Camera XYZ、Validity Alpha、Depth Metadata 和 Server Job 协议不变。 + +#### Scenario: Depth Surface evaluates stretch data +- **WHEN** Depth Surface 使用生成的 Vector Depth EXR 求值 +- **THEN** 系统 SHALL 从 RGB Camera XYZ 形成投影并比较几何边长,且 SHALL NOT 要求额外 Depth Continuity 通道或贴图 diff --git a/openspec/changes/restore-depth-surface-stretch-culling/tasks.md b/openspec/changes/restore-depth-surface-stretch-culling/tasks.md new file mode 100644 index 0000000..12201bc --- /dev/null +++ b/openspec/changes/restore-depth-surface-stretch-culling/tasks.md @@ -0,0 +1,24 @@ +## 1. 锁定节点接口与求值行为 + +- [x] 1.1 扩展 Cutout 节点资产结构测试,规定 `Stretch Limit` 仅连接 `O Image Depth Surface`、位于 Options、Factor 范围为 `0–1` 且默认值为 `0`,并运行对应 `tests/test_cutout_objects.py` 用例 +- [x] 1.2 增加真实 Blender 求值测试,覆盖 Stretch Limit 为 `0` 时保留完整表面、正阈值分别保留阈值内 Edge 与删除超限 Edge,并验证调整 Depth Scale 会实时改变拉伸判断 +- [x] 1.3 增加剔面后拓扑测试,验证 `Face Count == 0` 的 Edge 与 Point 被删除,而仍属于一个 Face 的开放边界保留 + +## 2. 构建 Depth Surface 拉伸剔面 + +- [x] 2.1 在 `O Image Depth Surface` 的 Set Position 前以 Point Domain 捕获 Edge Vertices Distance 原值,并在 Set Position 后以同一个 Field 直接求当前值 +- [x] 2.2 比较 `原始距离 / 当前距离 < Stretch Limit`,以 `POINT / ALL` 删除超限位置及相邻拓扑;默认 `0` 自然关闭剔面 +- [x] 2.3 在 Stretch Culling 后按 Edge Neighbors Face Count 删除纯线 Edge,再按 Vertex Neighbors Face Count 删除无 Face Point,并将清理后的几何交给 Thickness 与 Smooth +- [x] 2.4 在外层 `O Image Cutout` 暴露并只向 Depth Surface 分支连接 `Stretch Limit`,保持其他 Shape、现有 Mesh Island Cleanup 和 Depth 数据输入不变 + +## 3. 节点资产、文档与验证 + +- [x] 3.1 更新 `tools/node_assets/validate_cutout.py`,构建并验证 `src/anyimage/assets/O_AnyImage.blend` 的接口、节点顺序、无重叠与几何求值 +- [x] 3.2 更新 Cutout 用户说明、`docs/internals/operators/cutout-tool.md` 与 `docs/internals/node-assets.md`,说明 Stretch Limit 的零值关闭、剩余比例语义和松散拓扑清理 +- [x] 3.3 运行 Cutout 定向测试、`uv run build_node` 与 `uv run pytest`,确认 Vector Depth RGB/Validity Alpha、其他三个 Shape 和现有 Cleanup 没有回归 + +## 4. 按最终节点接法修正 + +- [x] 4.1 移除 Projected Position Capture 与 Sample Index,以同一个 Edge Vertices Distance Field 分别捕获原值并在完整 XYZ Set Position 后直接求当前值 +- [x] 4.2 将拉伸 Delete Geometry 改为 Point Domain,并保留后续纯线 Edge 与孤立 Point 清理 +- [x] 4.3 同步结构与真实求值测试、节点资产和文档,运行 OpenSpec、节点构建及全量测试 diff --git a/src/anyimage/assets/O_AnyImage.blend b/src/anyimage/assets/O_AnyImage.blend index 3ae742d..6db1d37 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:a548cfe1c2c61e9dda4ab96c118c94acfb794863c6e5163fe3a79a8e6bb2f621 -size 1664352 +oid sha256:f26a2016d08490b77db73543068082259dfe3b0b4ef32a9dc62a7b0fdedfacbd +size 1731376 diff --git a/tests/test_cutout_objects.py b/tests/test_cutout_objects.py index 3d22441..58c60d6 100644 --- a/tests/test_cutout_objects.py +++ b/tests/test_cutout_objects.py @@ -351,6 +351,7 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups(): "Smooth Iterations", "Cleanup", "Smooth", + "Stretch Limit", "Reference Depth", "Depth Direction", "Uniform Scale", @@ -361,6 +362,7 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups(): assert [item.name for item in panels[0].interface_items] == [ "Cleanup", "Smooth", + "Stretch Limit", "Reference Depth", "Depth Direction", ] @@ -429,6 +431,7 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups(): "Geometry", "Thickness", "Depth Scale", + "Stretch Limit", "Reference Depth", "Uniform Scale", "Depth Image", @@ -449,6 +452,7 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups(): assert dependencies == { "Thickness": set(cutout_shape.CUTOUT_SHAPES), "Depth Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, + "Stretch Limit": {"DEPTH_SURFACE"}, "Double Sided": {"DEPTH_BALLOON"}, "Reference Depth": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, "Depth Direction": {"DEPTH_BALLOON"}, @@ -503,6 +507,112 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups(): ) assert cleanup.default_value == 0.0 assert cleanup.subtype == "NONE" + stretch_limit = next( + item + for item in cutout.interface.items_tree + if item.item_type == "SOCKET" and item.name == "Stretch Limit" + ) + child_stretch_limit = next( + item + for item in child_groups["DEPTH_SURFACE"].interface.items_tree + if item.item_type == "SOCKET" and item.name == "Stretch Limit" + ) + assert stretch_limit.default_value == 0.0 + assert stretch_limit.min_value == 0.0 + assert stretch_limit.max_value == 1.0 + assert stretch_limit.subtype == "FACTOR" + assert getattr(stretch_limit.parent, "name", "") == "Options" + assert child_stretch_limit.default_value == 0.0 + assert child_stretch_limit.min_value == 0.0 + assert child_stretch_limit.max_value == 1.0 + assert child_stretch_limit.subtype == "FACTOR" + assert getattr(child_stretch_limit.parent, "name", "") == "Options" + depth_surface_nodes = child_groups["DEPTH_SURFACE"].nodes + captures = [ + node + for node in depth_surface_nodes + if node.bl_idname == "GeometryNodeCaptureAttribute" + ] + assert len(captures) == 1 + assert captures[0].domain == "POINT" + assert [item.name for item in captures[0].capture_items] == [ + "Original Edge Length" + ] + captured_distance = next( + link.from_node + for link in child_groups["DEPTH_SURFACE"].links + if link.to_node == captures[0] + and link.to_socket.name == "Original Edge Length" + ) + assert captured_distance.bl_idname == "ShaderNodeVectorMath" + assert captured_distance.operation == "DISTANCE" + ratio = next( + link.to_node + for link in child_groups["DEPTH_SURFACE"].links + if link.from_node == captured_distance + and link.to_node.bl_idname == "ShaderNodeMath" + and link.to_node.operation == "DIVIDE" + ) + assert any( + link.from_node == captures[0] + and link.to_node == ratio + and link.to_socket == ratio.inputs[0] + for link in child_groups["DEPTH_SURFACE"].links + ) + assert any( + link.from_node == captured_distance + and link.to_node == ratio + and link.to_socket == ratio.inputs[1] + for link in child_groups["DEPTH_SURFACE"].links + ) + captured_distance = next( + link.from_node + for link in child_groups["DEPTH_SURFACE"].links + if link.to_node == captures[0] + and link.to_socket.name == "Original Edge Length" + ) + assert captured_distance.bl_idname == "ShaderNodeVectorMath" + assert captured_distance.operation == "DISTANCE" + ratio = next( + link.to_node + for link in child_groups["DEPTH_SURFACE"].links + if link.from_node == captured_distance + and link.to_node.bl_idname == "ShaderNodeMath" + and link.to_node.operation == "DIVIDE" + ) + assert any( + link.from_node == captures[0] + and link.to_node == ratio + and link.to_socket == ratio.inputs[0] + for link in child_groups["DEPTH_SURFACE"].links + ) + assert any( + link.from_node == captured_distance + and link.to_node == ratio + and link.to_socket == ratio.inputs[1] + for link in child_groups["DEPTH_SURFACE"].links + ) + samples = [ + node + for node in depth_surface_nodes + if node.bl_idname == "GeometryNodeSampleIndex" + ] + assert not samples + delete_domains = [ + node.domain + for node in depth_surface_nodes + if node.bl_idname == "GeometryNodeDeleteGeometry" + ] + assert delete_domains.count("EDGE") == 1 + assert delete_domains.count("POINT") == 2 + assert any( + node.bl_idname == "GeometryNodeInputMeshEdgeNeighbors" + for node in depth_surface_nodes + ) + assert any( + node.bl_idname == "GeometryNodeInputMeshVertexNeighbors" + for node in depth_surface_nodes + ) cleanup_scale = next( link.to_node for link in cutout.links @@ -1234,6 +1344,112 @@ def _add_cutout_modifier(obj, shape): return modifier +def _depth_surface_stretch_object(): + vertices = [(0.0, float(y), float(z)) for z in range(3) for y in range(3)] + faces = [] + for z in range(2): + for y in range(2): + a = z * 3 + y + faces.extend(((a, a + 4, a + 1), (a, a + 3, a + 4))) + data = bpy.data.meshes.new("Depth Surface Stretch") + data.from_pydata(vertices, (), faces) + uv_layer = data.uv_layers.new(name="UVMap") + for loop in data.loops: + coordinate = data.vertices[loop.vertex_index].co + uv_layer.data[loop.index].uv = (coordinate.y * 0.5, coordinate.z * 0.5) + obj = bpy.data.objects.new("Depth Surface Stretch", data) + bpy.context.collection.objects.link(obj) + modifier = _add_cutout_modifier(obj, "DEPTH_SURFACE") + + points = np.empty((3, 3, 3), dtype=np.float32) + for z in range(3): + for y in range(3): + points[z, y] = (-float(y), -float(z), 1.0) + points[0, 0, 2] = 6.0 + depth_image = _depth_image(points, np.ones((3, 3), dtype=bool)) + for name, value in ( + ("Depth Image", depth_image), + ("Depth Scale", 1.0), + ("Uniform Scale", 1.0), + ("Reference Depth", 1.0), + ("Thickness", 0.0), + ("Smooth Iterations", 0), + ): + object_data.set_modifier_input( + modifier, + object_data.modifier_input_identifier(modifier.node_group, name), + value, + ) + return obj, modifier + + +def _evaluated_topology(obj): + evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) + result = evaluated.to_mesh() + try: + face_edges = { + tuple(sorted((start, end))) + for polygon in result.polygons + for start, end in zip( + polygon.vertices, + (*polygon.vertices[1:], polygon.vertices[0]), + ) + } + mesh_edges = { + tuple(sorted(edge.vertices)) + for edge in result.edges + } + face_vertices = { + vertex + for polygon in result.polygons + for vertex in polygon.vertices + } + return ( + len(result.vertices), + len(result.polygons), + mesh_edges - face_edges, + set(range(len(result.vertices))) - face_vertices, + ) + finally: + evaluated.to_mesh_clear() + + +def test_depth_surface_stretch_limit_tracks_projection_and_cleans_loose_topology(): + bpy.ops.wm.read_factory_settings(use_empty=True) + obj, modifier = _depth_surface_stretch_object() + stretch_id = object_data.modifier_input_identifier( + modifier.node_group, + "Stretch Limit", + ) + depth_scale_id = object_data.modifier_input_identifier( + modifier.node_group, + "Depth Scale", + ) + + object_data.set_modifier_input(modifier, stretch_id, 0.0) + bpy.context.view_layer.update() + assert _evaluated_topology(obj) == (9, 8, set(), set()) + + object_data.set_modifier_input(modifier, stretch_id, 0.5) + obj.update_tag(refresh={"DATA"}) + bpy.context.view_layer.update() + assert _evaluated_topology(obj) == (9, 8, set(), set()) + + object_data.set_modifier_input(modifier, stretch_id, 0.6) + obj.update_tag(refresh={"DATA"}) + bpy.context.view_layer.update() + vertices, faces, loose_edges, loose_points = _evaluated_topology(obj) + assert 0 < faces < 8 + assert vertices < 9 + assert not loose_edges + assert not loose_points + + object_data.set_modifier_input(modifier, depth_scale_id, 0.0) + obj.update_tag(refresh={"DATA"}) + bpy.context.view_layer.update() + assert _evaluated_topology(obj) == (9, 8, set(), set()) + + def test_cutout_thickness_keeps_one_connected_shell(): bpy.ops.wm.read_factory_settings(use_empty=True) vertices = [(0.0, float(y), float(z)) for z in range(3) for y in range(3)] diff --git a/tools/node_assets/build_cutout.py b/tools/node_assets/build_cutout.py index d573d54..52c6fcd 100644 --- a/tools/node_assets/build_cutout.py +++ b/tools/node_assets/build_cutout.py @@ -427,6 +427,81 @@ def _position_field(group, depth_image, location): return position_texture.outputs["Color"] +def _capture_edge_length(group, geometry, name, location): + nodes = group.nodes + links = group.links + x, y = location + edge_vertices = nodes.new("GeometryNodeInputMeshEdgeVertices") + edge_vertices.location = (x, y + 250.0) + edge_length = nodes.new("ShaderNodeVectorMath") + edge_length.operation = "DISTANCE" + edge_length.location = (x + 220.0, y + 250.0) + links.new(edge_vertices.outputs["Position 1"], edge_length.inputs[0]) + links.new(edge_vertices.outputs["Position 2"], edge_length.inputs[1]) + capture = nodes.new("GeometryNodeCaptureAttribute") + capture.domain = "POINT" + capture.capture_items.clear() + capture.capture_items.new("FLOAT", name) + capture.location = (x + 440.0, y) + links.new(geometry, capture.inputs["Geometry"]) + links.new(edge_length.outputs["Value"], capture.inputs[name]) + return capture.outputs["Geometry"], capture.outputs[name], edge_length.outputs["Value"] + + +def _remove_stretched_points( + group, + geometry, + original_edge_length, + current_edge_length, + stretch_limit, + location, +): + nodes = group.nodes + links = group.links + x, y = location + + remaining_ratio = _math(nodes, "DIVIDE", (x + 220.0, y + 300.0)) + links.new(original_edge_length, remaining_ratio.inputs[0]) + links.new(current_edge_length, remaining_ratio.inputs[1]) + stretched = _math(nodes, "LESS_THAN", (x + 450.0, y + 200.0)) + links.new(remaining_ratio.outputs[0], stretched.inputs[0]) + links.new(stretch_limit, stretched.inputs[1]) + delete_stretched = nodes.new("GeometryNodeDeleteGeometry") + delete_stretched.domain = "POINT" + delete_stretched.mode = "ALL" + delete_stretched.location = (x + 680.0, y) + links.new(geometry, delete_stretched.inputs["Geometry"]) + links.new(stretched.outputs[0], delete_stretched.inputs["Selection"]) + + edge_neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors") + edge_neighbors.location = (x + 930.0, y + 500.0) + loose_edge = _math(nodes, "LESS_THAN", (x + 1150.0, y + 500.0)) + loose_edge.inputs[1].default_value = 1.0 + links.new(edge_neighbors.outputs["Face Count"], loose_edge.inputs[0]) + delete_loose_edges = nodes.new("GeometryNodeDeleteGeometry") + delete_loose_edges.domain = "EDGE" + delete_loose_edges.mode = "ALL" + delete_loose_edges.location = (x + 1370.0, y) + links.new(delete_stretched.outputs["Geometry"], delete_loose_edges.inputs["Geometry"]) + links.new(loose_edge.outputs[0], delete_loose_edges.inputs["Selection"]) + + vertex_neighbors = nodes.new("GeometryNodeInputMeshVertexNeighbors") + vertex_neighbors.location = (x + 1620.0, y + 500.0) + loose_point = _math(nodes, "LESS_THAN", (x + 1840.0, y + 500.0)) + loose_point.inputs[1].default_value = 1.0 + links.new(vertex_neighbors.outputs["Face Count"], loose_point.inputs[0]) + delete_loose_points = nodes.new("GeometryNodeDeleteGeometry") + delete_loose_points.domain = "POINT" + delete_loose_points.mode = "ALL" + delete_loose_points.location = (x + 2060.0, y) + links.new( + delete_loose_edges.outputs["Geometry"], + delete_loose_points.inputs["Geometry"], + ) + links.new(loose_point.outputs[0], delete_loose_points.inputs["Selection"]) + return delete_loose_points.outputs["Geometry"] + + def _plane_depth(group, direction, reference_depth, location): nodes = group.nodes links = group.links @@ -610,12 +685,19 @@ def _build_depth_surface( geometry, thickness, depth_scale, + stretch_limit, uniform_scale, reference_depth, depth_image, ): nodes = group.nodes links = group.links + geometry, original_edge_length, current_edge_length = _capture_edge_length( + group, + geometry, + "Original Edge Length", + (400.0, 850.0), + ) camera_position = _position_field(group, depth_image, (-200.0, -200.0)) scaled = nodes.new("ShaderNodeVectorMath") scaled.operation = "SCALE" @@ -651,30 +733,56 @@ def _build_depth_surface( links.new(depth_scale, blend.inputs[0]) links.new(current_position.outputs["Position"], blend.inputs[4]) links.new(projected.outputs["Vector"], blend.inputs[5]) + blended_axes = nodes.new("ShaderNodeSeparateXYZ") + blended_axes.location = (4100.0, -500.0) + links.new(blend.outputs["Result"], blended_axes.inputs["Vector"]) + surface_position = nodes.new("ShaderNodeCombineXYZ") + surface_position.location = (4100.0, -200.0) + links.new(front.outputs[0], surface_position.inputs["X"]) + links.new(blended_axes.outputs["Y"], surface_position.inputs["Y"]) + links.new(blended_axes.outputs["Z"], surface_position.inputs["Z"]) project_geometry = nodes.new("GeometryNodeSetPosition") - project_geometry.location = (4100.0, 400.0) + project_geometry.location = (4400.0, 400.0) links.new(geometry, project_geometry.inputs["Geometry"]) - links.new(blend.outputs["Result"], project_geometry.inputs["Position"]) + links.new(surface_position.outputs["Vector"], project_geometry.inputs["Position"]) + + geometry = _remove_stretched_points( + group, + project_geometry.outputs["Geometry"], + original_edge_length, + current_edge_length, + stretch_limit, + (4700.0, 400.0), + ) + zero_x = nodes.new("ShaderNodeValue") + zero_x.location = (7100.0, 100.0) + zero_x.outputs["Value"].default_value = 0.0 + geometry = _set_geometry_x( + group, + geometry, + zero_x.outputs["Value"], + (7200.0, 400.0), + ) back = _math(nodes, "ADD", (3900.0, -100.0)) links.new(front.outputs[0], back.inputs[0]) links.new(thickness, back.inputs[1]) shaped = _set_segmented_layer_x( group, - project_geometry.outputs["Geometry"], + geometry, front.outputs[0], back.outputs[0], thickness, - (4400.0, 400.0), + (8300.0, 400.0), ) front_only = _set_geometry_x( group, - project_geometry.outputs["Geometry"], + geometry, front.outputs[0], - (4600.0, -200.0), + (8500.0, -200.0), ) geometry = _remove_zero_thickness( - group, front_only, shaped, thickness, (7900.0, 400.0) + group, front_only, shaped, thickness, (11800.0, 400.0) ) return geometry @@ -698,6 +806,7 @@ def build_node_group(shape): thickness.description = "Final thickness in scene distance" depth_image = None + stretch_limit = None if shape == "DEPTH_BALLOON": depth_scale = _float_input(group, "Depth Scale", 1.0) depth_scale.description = "Scale the fitted-plane-corrected metric depth" @@ -734,6 +843,19 @@ def build_node_group(shape): ) elif shape == "DEPTH_SURFACE": options = group.interface.new_panel(name="Options", default_closed=True) + stretch_limit = _float_input( + group, + "Stretch Limit", + 0.0, + 0.0, + 1.0, + subtype="FACTOR", + parent=options, + ) + stretch_limit.description = ( + "Delete faces when original edge length divided by projected edge " + "length falls below this threshold; zero disables culling" + ) reference_depth = _float_input( group, "Reference Depth", @@ -773,6 +895,7 @@ def build_node_group(shape): "Uniform Scale", "Reference Depth", "Depth Direction", + "Stretch Limit", }, ) geometry = controls.outputs["Geometry"] @@ -805,11 +928,12 @@ def build_node_group(shape): geometry, controls.outputs["Thickness"], controls.outputs["Depth Scale"], + controls.outputs["Stretch Limit"], controls.outputs["Uniform Scale"], controls.outputs["Reference Depth"], controls.outputs["Depth Image"], ) - output_location = (8200.0, 400.0) + output_location = (12100.0, 400.0) _localize_group_inputs(group, controls) _shade_output(group, geometry, output_location) group.color_tag = "GEOMETRY" @@ -874,6 +998,19 @@ def build_cutout_modifier_group(shape_groups): parent=options, ) smooth.description = "Blend each smoothing pass into the selected shape" + stretch_limit = _float_input( + group, + "Stretch Limit", + 0.0, + 0.0, + 1.0, + subtype="FACTOR", + parent=options, + ) + stretch_limit.description = ( + "Delete Depth Surface faces when original edge length divided by projected " + "edge length falls below this threshold; zero disables culling" + ) reference_depth = _float_input( group, diff --git a/tools/node_assets/validate_cutout.py b/tools/node_assets/validate_cutout.py index 1ca1c32..7f899d6 100644 --- a/tools/node_assets/validate_cutout.py +++ b/tools/node_assets/validate_cutout.py @@ -140,6 +140,7 @@ def main(): "Smooth Iterations", "Cleanup", "Smooth", + "Stretch Limit", "Reference Depth", "Depth Direction", "Uniform Scale", @@ -162,6 +163,7 @@ def main(): "Geometry", "Thickness", "Depth Scale", + "Stretch Limit", "Reference Depth", "Uniform Scale", "Depth Image", @@ -183,6 +185,7 @@ def main(): assert dependencies == { "Thickness": set(shapes.CUTOUT_SHAPES), "Depth Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, + "Stretch Limit": {"DEPTH_SURFACE"}, "Double Sided": {"DEPTH_BALLOON"}, "Reference Depth": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, "Depth Direction": {"DEPTH_BALLOON"}, @@ -232,6 +235,77 @@ def main(): ) assert cleanup.default_value == 0.0 assert cleanup.subtype == "NONE" + stretch_limit = next( + item + for item in wrapper.interface.items_tree + if item.item_type == "SOCKET" and item.name == "Stretch Limit" + ) + child_stretch_limit = next( + item + for item in groups["DEPTH_SURFACE"].interface.items_tree + if item.item_type == "SOCKET" and item.name == "Stretch Limit" + ) + assert stretch_limit.default_value == 0.0 + assert stretch_limit.min_value == 0.0 + assert stretch_limit.max_value == 1.0 + assert stretch_limit.subtype == "FACTOR" + assert getattr(stretch_limit.parent, "name", "") == "Options" + assert child_stretch_limit.default_value == 0.0 + assert child_stretch_limit.min_value == 0.0 + assert child_stretch_limit.max_value == 1.0 + assert child_stretch_limit.subtype == "FACTOR" + assert getattr(child_stretch_limit.parent, "name", "") == "Options" + depth_surface_nodes = groups["DEPTH_SURFACE"].nodes + captures = [ + node + for node in depth_surface_nodes + if node.bl_idname == "GeometryNodeCaptureAttribute" + ] + assert len(captures) == 1 + assert captures[0].domain == "POINT" + assert [item.name for item in captures[0].capture_items] == [ + "Original Edge Length" + ] + captured_distance = next( + link.from_node + for link in groups["DEPTH_SURFACE"].links + if link.to_node == captures[0] + and link.to_socket.name == "Original Edge Length" + ) + assert captured_distance.bl_idname == "ShaderNodeVectorMath" + assert captured_distance.operation == "DISTANCE" + ratio = next( + link.to_node + for link in groups["DEPTH_SURFACE"].links + if link.from_node == captured_distance + and link.to_node.bl_idname == "ShaderNodeMath" + and link.to_node.operation == "DIVIDE" + ) + assert any( + link.from_node == captures[0] + and link.to_node == ratio + and link.to_socket == ratio.inputs[0] + for link in groups["DEPTH_SURFACE"].links + ) + assert any( + link.from_node == captured_distance + and link.to_node == ratio + and link.to_socket == ratio.inputs[1] + for link in groups["DEPTH_SURFACE"].links + ) + samples = [ + node + for node in depth_surface_nodes + if node.bl_idname == "GeometryNodeSampleIndex" + ] + assert not samples + delete_domains = [ + node.domain + for node in depth_surface_nodes + if node.bl_idname == "GeometryNodeDeleteGeometry" + ] + assert delete_domains.count("EDGE") == 1 + assert delete_domains.count("POINT") == 2 cleanup_scale = next( link.to_node for link in wrapper.links @@ -262,7 +336,7 @@ def main(): assert not ( abs(node.location.x - other.location.x) < 150.0 and abs(node.location.y - other.location.y) < 100.0 - ) + ), (group.name, node.name, other.name) for group_input in ( node for node in group_nodes if node.bl_idname == "NodeGroupInput" ): @@ -392,7 +466,8 @@ def main(): pixels = [] for z in range(3): for y in range(3): - pixels.extend((-y * 0.5, -z * 0.5, 2.0 + y * 0.25, 0.0)) + depth = 7.0 if (y, z) == (0, 0) else 2.0 + y * 0.25 + pixels.extend((-y * 0.5, -z * 0.5, depth, 0.0)) point_image.pixels.foreach_set(pixels) for name, value in ( ("Depth Image", point_image), @@ -417,12 +492,62 @@ def main(): ) for point, target in zip( coordinates, - ((y * 0.25, y * 0.5, z * 0.5) for z in range(3) for y in range(3)), + ( + ( + 5.0 if (y, z) == (0, 0) else y * 0.25, + y * 0.5, + z * 0.5, + ) + for z in range(3) + for y in range(3) + ), ) ) finally: evaluated.to_mesh_clear() + set_modifier_input( + modifier, + modifier_input_identifier(modifier.node_group, "Stretch Limit"), + 0.6, + ) + depth_surface.update_tag(refresh={"DATA"}) + bpy.context.view_layer.update() + evaluated, result = evaluated_mesh(depth_surface) + try: + assert 0 < len(result.polygons) < 8 + face_edges = { + tuple(sorted((start, end))) + for polygon in result.polygons + for start, end in zip( + polygon.vertices, + (*polygon.vertices[1:], polygon.vertices[0]), + ) + } + assert all(tuple(sorted(edge.vertices)) in face_edges for edge in result.edges) + face_vertices = { + vertex + for polygon in result.polygons + for vertex in polygon.vertices + } + assert face_vertices == set(range(len(result.vertices))) + finally: + evaluated.to_mesh_clear() + + set_modifier_input( + modifier, + modifier_input_identifier(modifier.node_group, "Depth Scale"), + 0.0, + ) + depth_surface.update_tag(refresh={"DATA"}) + bpy.context.view_layer.update() + evaluated, result = evaluated_mesh(depth_surface) + try: + assert len(result.vertices) == 9 + assert len(result.polygons) == 8 + finally: + evaluated.to_mesh_clear() + print( f"Validated Blender {bpy.app.version_string}: " f"{wrapper.name} wrapping "