From c62ec2cd4863a5ef7883c9b01d9888a8ec5be7ba Mon Sep 17 00:00:00 2001 From: nan Date: Wed, 16 Sep 2026 16:30:56 +0800 Subject: [PATCH] Refactor depth surface handling and unify depth sampling rules - Removed the `always_vertex` parameter from `build_surface_camera` to streamline depth sampling for cutouts and ensure consistent behavior across depth plane and panorama. - Updated depth sampling logic to ensure that all free boundary points, including the original frame of the depth plane, use neighboring face depth instead of their own texture sampling. - Adjusted the depth mask deletion process to operate on faces rather than points, eliminating the need for additional cleanup of dangling edges. - Ensured that the cutout rules are consistently applied across all relevant nodes, removing exceptions and simplifying the logic. - Updated tests to verify the new behavior, ensuring that cutout points follow the depth of adjacent faces and that the original frame maintains its depth sampling. - Refactored geometry handling in multiple depth-related nodes to improve clarity and maintainability. --- .../.openspec.yaml | 2 + .../pin-geometry-node-smoothing/design.md | 43 +++++ .../pin-geometry-node-smoothing/proposal.md | 33 ++++ .../specs/pinned-geometry-smoothing/spec.md | 105 +++++++++++ .../pin-geometry-node-smoothing/tasks.md | 36 ++++ .../refine-cutout-symmetry-seam/design.md | 6 +- .../refine-cutout-symmetry-seam/proposal.md | 2 +- .../specs/cutout-symmetry-seam/spec.md | 12 +- .../refine-cutout-symmetry-seam/tasks.md | 12 +- .../unify-mask-cut-depth/.openspec.yaml | 2 + .../changes/unify-mask-cut-depth/design.md | 79 +++++++++ .../changes/unify-mask-cut-depth/proposal.md | 31 ++++ .../specs/mask-cut-depth-sampling/spec.md | 85 +++++++++ .../changes/unify-mask-cut-depth/tasks.md | 28 +++ src/anyimage/assets/O_AnyImage.blend | 4 +- tests/tools/nodes/test_boundary_smoothing.py | 122 +++++++++++++ tests/tools/nodes/test_cutout_symmetry.py | 73 ++++++-- tests/tools/nodes/test_depth_cutout.py | 3 +- tests/tools/nodes/test_image_layer.py | 4 +- tools/nodes/common/boundary_smoothing.py | 7 +- tools/nodes/common/depth_surface.py | 30 ++-- tools/nodes/common/smoothing.py | 165 ++++++++++++------ tools/nodes/common/validity.py | 19 +- tools/nodes/groups/image_cutout_symmetry.py | 137 +++++++++------ tools/nodes/groups/image_depth_cutout.py | 3 +- tools/nodes/groups/image_depth_panorama.py | 4 +- tools/nodes/groups/image_depth_plane.py | 4 +- tools/nodes/groups/image_layer.py | 2 +- 28 files changed, 880 insertions(+), 173 deletions(-) create mode 100644 openspec/changes/pin-geometry-node-smoothing/.openspec.yaml create mode 100644 openspec/changes/pin-geometry-node-smoothing/design.md create mode 100644 openspec/changes/pin-geometry-node-smoothing/proposal.md create mode 100644 openspec/changes/pin-geometry-node-smoothing/specs/pinned-geometry-smoothing/spec.md create mode 100644 openspec/changes/pin-geometry-node-smoothing/tasks.md create mode 100644 openspec/changes/unify-mask-cut-depth/.openspec.yaml create mode 100644 openspec/changes/unify-mask-cut-depth/design.md create mode 100644 openspec/changes/unify-mask-cut-depth/proposal.md create mode 100644 openspec/changes/unify-mask-cut-depth/specs/mask-cut-depth-sampling/spec.md create mode 100644 openspec/changes/unify-mask-cut-depth/tasks.md diff --git a/openspec/changes/pin-geometry-node-smoothing/.openspec.yaml b/openspec/changes/pin-geometry-node-smoothing/.openspec.yaml new file mode 100644 index 0000000..f080778 --- /dev/null +++ b/openspec/changes/pin-geometry-node-smoothing/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-16 diff --git a/openspec/changes/pin-geometry-node-smoothing/design.md b/openspec/changes/pin-geometry-node-smoothing/design.md new file mode 100644 index 0000000..28867c3 --- /dev/null +++ b/openspec/changes/pin-geometry-node-smoothing/design.md @@ -0,0 +1,43 @@ +## Context + +`O_AnyImage` 的顶点位置平滑只有两条路径:`tools/nodes/common/smoothing.py` 的 `expand_smooth()` 供三个深度节点组的边界平滑使用,`tools/nodes/groups/image_cutout_symmetry.py` 的 `_apply_planar_relax()` 供 Fill 与 Seam 使用。 + +两者用同一套 Blur Attribute 结构,但形态不同:`expand_smooth()` 用 Repeat 逐轮把顶点位置混向「内部模糊结果,边界点改用边界条带模糊结果」,并乘上作用于切边带的影响场;`_apply_planar_relax()` 一次性做 N 次模糊,再取平面内分量、乘固定 0.5 与轮廓/切缝的两圈 falloff 后作为位移。前者已经等于 OmooNodes `O Smooth` 的 `Pin Boundary = 1`,但两者都没有 `Pin Sharp`,`_apply_planar_relax()` 也没有边界条带分支,轮廓会被内部模糊往里拖。 + +参考实现 `O Smooth`(OmooNodes 的 `O_Essentials_GeometryNodes.blend`)把两个 pin 表达为输入:`Pin Boundary` 在「内部模糊结果」与「边界条带自身 0.5 权重模糊结果」之间选择边界点目标;`Pin Sharp` 用 `|Blur(Normal, 10)|` 压低尖锐处的平滑权重。本变更把这两个输入固定为 1,并让所有几何平滑共用这一层。 + +两处的受力点性质不同。深度三组与 Seam 的受力点是真正的网格边界(Split 切边、原图边框、对称平面的切缝),轮廓是否被拖拽、锐边是否被抹平都直接影响外形。Fill 则作用在 front 表面与它挤出的侧壁之间的衔接带:壁在平滑之后才挤出,衔接处不是网格边界,两个 pin 在这里没有对象,用力方向反而是把这一带放松成圆滑过渡。 + +## Goals / Non-Goals + +**Goals:** + +- 真正位于网格边界的平滑固定带上 `Pin Sharp = 1` 与 `Pin Boundary = 1`,且不新增 modifier 接口。 +- Fill 作为衔接带的普通平滑,作用范围比原先的两圈更宽。 +- 深度三组与 Cutout Symmetry 的平滑共用同一个构建函数,站点只提供作用范围、权重与是否 pin。 +- 保持作用范围的渐变语义与 Cutout 中轮廓、切边的权重差异。 + +**Non-Goals:** + +- 不把 pin 应用到法线平滑、深度模糊、掩码与统计模糊等属性平滑。 +- 不改变作用范围的宽度、渐变形态与迭代次数的公开含义。 +- 不保留旧的平面内偏移平滑路径或兼容输入。 + +## Decisions + +1. **把平滑拆成作用范围层与 pin 层。** 作用范围层继续回答「哪些顶点允许动、每轮动多少」,由调用方传入 region 与 weight 字段;pin 层回答「边界点用哪个目标」「锐处权重要不要压低」,由共享实现的 `pin_boundary` 与 `pin_sharp` 分别生成。相比把 pin 混进各站点的影响场,这样 Cutout 的轮廓 0.1 与切边 1.0 权重、Seam 与 Fill 的 falloff 都能原样保留。 +2. **共享函数放在 `common/smoothing.py`。** 签名收敛为几何、迭代次数、region、weight、可移动分量与两个 pin 开关:Repeat 每轮按 `weight × region` 把位置混向目标,`pin_boundary` 决定边界点是否改用边界条带模糊结果,`pin_sharp` 决定是否乘锐度系数。`expand_smooth()` 不再保留独立实现,`boundary_smoothing.py` 只负责构造 Cutout 的 region 与 weight。 +3. **边界条带分支与 O Smooth 完全一致。** `Separate Geometry` 取出边界点,对位置做权重 0.5 的一步 Blur,再用 `Sample Nearest` 的索引取回,边界点目标改为该结果。边界顶点仍会沿边界移动,只避免被内部平滑拖拽收缩,因此不需要额外参数表达「冻结边界」。 +4. **锐度权重与 O Smooth 一致。** 对 `Normal` 做 10 次权重为 1 的 Blur,取长度作为逐点系数乘进权重。备选方案是用面夹角或锐边属性判断,但那需要额外的拓扑判定与属性传递;法线相干度直接复用参考实现,判定成本低且没有新增数据。 +5. **迭代机制统一为 Repeat。** Symmetry 现在是一次 N 次 Blur 再乘比例的偏移,改为与深度三组相同的逐轮混合;这样同一份实现即可覆盖两处,且 region 与 weight 在每轮都参与,收敛路径可预期。 +6. **用可移动分量保留对称平面约束。** Symmetry 的前表面折叠在对称平面上,位移若离开平面会破坏镜像焊接,因此共享函数允许调用方传入分量掩码;Fill 与 Seam 传平面内分量,深度三组保持默认全轴。该掩码表达的是几何约束,不是平滑语义。 +7. **删除旧路径而不是并存。** `_apply_planar_relax()` 与其一次性模糊的偏移实现直接移除,符合项目不保留转发函数与兼容层的要求。 +8. **Fill 拆开两个 pin,并把衔接带锚在完整边界。** 衔接处不是网格边界,`Pin Boundary` 在这里没有判断对象;但 `Pin Sharp` 仍然有效,衔接带内的尖锐处应当少动,因此两个 pin 拆成独立开关,Fill 传 `pin_boundary=False` 并保留 `pin_sharp=True`。锚点由「界外轮廓」改为 front 的完整边界带(界外轮廓 + 对称面上的切缝):只锚在界外轮廓时,对称轴上的点一旦超出渐变圈数就完全不受力,衔接在轴处断开。渐变宽度仍由 `FILL_SMOOTH_RINGS = 4` 单点控制。 + +## Risks / Trade-offs + +- [轮廓行为变化:Seam 与深度三组的轮廓不再被内部模糊拖动] → 这是本变更的目标;用行为测试锁定「范围外顶点不动」「轮廓点位移只来自边界条带分支」「Cutout 轮廓与切边权重差异保留」。 +- [Fill 范围更宽且覆盖对称轴,front 靠近边界的一段会被邻域平均拉动] → 这正是衔接圆化的目标;受力范围由 `FILL_SMOOTH_RINGS` 控制,测试锁定第三圈仍受力、配置圈数之外保持原位,对称轴上的点参与松弛。 +- [Pin Sharp 会压低 Split 台阶处的平滑权重,与去台阶目标相抵] → 已在参考实现中验证细节保留更好,保留该行为;台阶处理强度仍由 `Boundary Smooth` 迭代次数和切边完整权重控制。 +- [统一迭代机制改变 Symmetry 的收敛路径] → 保留现有对称性与闭合测试,并新增 pin 覆盖,确认镜像焊接与闭合性不被破坏。 +- [节点数量与求值成本增加] → 深度三组本来已有边界分支,只新增法线模糊;Symmetry 新增边界分支与 Repeat。共享实现避免了两套重复复杂度。 diff --git a/openspec/changes/pin-geometry-node-smoothing/proposal.md b/openspec/changes/pin-geometry-node-smoothing/proposal.md new file mode 100644 index 0000000..f714d1a --- /dev/null +++ b/openspec/changes/pin-geometry-node-smoothing/proposal.md @@ -0,0 +1,33 @@ +## Why + +`O_AnyImage` 的几何平滑目前只在深度三组里隐式地按边界条带处理边界点,没有任何锐边保护;`O Image Cutout Symmetry` 的 Seam 平滑还会把轮廓往里拖。OmooNodes 的 `O Smooth` 已经证明 `Pin Sharp` 与 `Pin Boundary` 能在保留必要细节的同时得到更干净的表面,本变更把这两层 pin 作为固定为 1 的内部行为引入 `O_AnyImage` 真正位于网格边界的平滑。 + +## What Changes + +- 位于网格边界的顶点位置平滑(`Seam Smooth` 与三个深度节点组的 `Boundary Smooth`)固定开启 `Pin Sharp = 1` 与 `Pin Boundary = 1`,在共享实现内部硬编码,不新增也不修改 modifier 接口。 +- `Pin Boundary`:边界点不再取内部模糊结果,改用边界条带自身的 0.5 权重一步模糊;轮廓只沿自身松弛,不被内部平滑拖拽或收缩,边界顶点本身仍会移动。 +- `Pin Sharp`:每点平滑权重乘上法线相干度 `|Blur(Normal, 10)|`,尖锐处少动。 +- **BREAKING**:`Seam Smooth` 与 `Boundary Smooth` 不再移动或收缩轮廓本身,轮廓改为按 `Pin Boundary` 规则处理。 +- `Fill Smooth` 改为衔接带平滑:锚点是 front 的完整边界带(外侧轮廓与对称面上的切缝),范围从两圈扩大到四圈,因此对称轴上的点同样参与;衔接处不是网格边界,因此不做 `Pin Boundary`,保留 `Pin Sharp`。 +- 作用范围语义保持不变:`Boundary Smooth`、`Fill Smooth`、`Seam Smooth` 仍是带渐变的区域,范围外顶点不动;Cutout 中原始轮廓与 Split 切边的权重差异(0.1 / 1.0)保留。 +- 抽出共享的平滑构建函数,pin 形态与普通形态共用同一个迭代循环,各站点只提供作用范围、权重与是否 pin。 +- 属性平滑(Normal Smooth、深度模糊、掩码与统计模糊)不纳入 pin,保持现有实现。 + +## Capabilities + +### New Capabilities + +- `pinned-geometry-smoothing`: 规定 `O_AnyImage` 的边界平滑固定开启 Pin Sharp 与 Pin Boundary、衔接带平滑使用普通平滑,并界定作用范围层与 pin 层的职责。 + +### Modified Capabilities + +无。 + +## Impact + +- `tools/nodes/common/smoothing.py`:共享 pinned 平滑实现与边界字段。 +- `tools/nodes/common/boundary_smoothing.py`:深度三组的顶点平滑改走共享实现。 +- `tools/nodes/groups/image_cutout_symmetry.py`:Fill/Seam 改走共享实现,删除旧的平面内偏移路径。 +- `tools/nodes/groups/image_depth_plane.py`、`image_depth_cutout.py`、`image_depth_panorama.py`:调用点收敛到共享实现。 +- `src/anyimage/assets/O_AnyImage.blend` 与 `tests/tools/nodes` 下相关测试。 +- 公开 modifier 接口不变,不涉及依赖与打包。 diff --git a/openspec/changes/pin-geometry-node-smoothing/specs/pinned-geometry-smoothing/spec.md b/openspec/changes/pin-geometry-node-smoothing/specs/pinned-geometry-smoothing/spec.md new file mode 100644 index 0000000..745b3c6 --- /dev/null +++ b/openspec/changes/pin-geometry-node-smoothing/specs/pinned-geometry-smoothing/spec.md @@ -0,0 +1,105 @@ +## ADDED Requirements + +### Requirement: 边界平滑固定开启 Pin Sharp 与 Pin Boundary + +`O Image Cutout Symmetry` 的 `Seam Smooth`,以及 `O Image Depth Plane`、`O Image Depth Cutout`、`O Image Depth Panorama` 的 `Boundary Smooth` SHALL 固定使用 `Pin Sharp = 1` 与 `Pin Boundary = 1`,且 MUST NOT 为这两个 pin 新增或修改 modifier 输入。 + +#### Scenario: 边界点按边界条带松弛 + +- **WHEN** 平滑命中边界点,包括 Split 形成的切边与对称平面的切缝 +- **THEN** 该点的目标位置取自边界条带自身权重 0.5 的一步 Blur +- **AND** 该点不被内部 Blur 结果拖拽,轮廓不因平滑收缩 + +#### Scenario: 边界顶点仍沿边界移动 + +- **WHEN** 边界上存在阶梯状顶点 +- **THEN** 顶点沿边界移动以消除阶梯 +- **AND** 顶点不会被固定在原位 + +#### Scenario: 尖锐处少动 + +- **WHEN** 平滑范围内存在法线不连续的尖锐处 +- **THEN** 该点的平滑权重乘以 `|Blur(Normal, 10)|` +- **AND** 尖锐处的位移小于同等权重下平坦区域的位移 + +### Requirement: Fill Smooth 平滑衔接带 + +`O Image Cutout Symmetry` 的 `Fill Smooth` SHALL 平滑 front 表面与它挤出的侧壁之间的衔接带。锚点 SHALL 是 front 的完整边界带,即外侧轮廓与对称面上的切缝,SHALL 按四圈渐变衰减并覆盖对称轴上的点。该衔接处不是网格边界,因此 MUST NOT 套用 `Pin Boundary`,但 SHALL 保留 `Pin Sharp`。 + +#### Scenario: 衔接带使用邻域平均 + +- **WHEN** `Fill Smooth` 大于 0 +- **THEN** 受力点按邻域平均松弛,不切换到边界条带目标 + +#### Scenario: 衔接带保留锐度权重 + +- **WHEN** 衔接带范围内存在法线不连续的尖锐处 +- **THEN** 该点的平滑权重乘以 `|Blur(Normal, 10)|` + +#### Scenario: 衔接带覆盖对称轴上的点 + +- **WHEN** `Fill Smooth` 大于 0 +- **THEN** 对称轴上的点与外侧轮廓同样产生面内位移 + +#### Scenario: 衔接带比两圈更宽 + +- **WHEN** `Fill Smooth` 大于 0 +- **THEN** 从轮廓向内第三圈的点仍然受力 +- **AND** 超出配置圈数的点保持原位 + +### Requirement: 作用范围保持渐变语义 + +`Boundary Smooth`、`Fill Smooth`、`Seam Smooth` SHALL 继续表示平滑生效的渐变区域与迭代次数,落在范围外的顶点 MUST 保持原位。 + +#### Scenario: 范围外不动 + +- **WHEN** 任一平滑的迭代次数大于 0 +- **THEN** 落在渐变范围外的顶点位置与输入完全一致 +- **AND** 拓扑与 UV 保持不变 + +#### Scenario: Cutout 轮廓与切边权重差异 + +- **WHEN** Depth Cutout 同时存在原始轮廓与 Split 切边 +- **THEN** Split 切边使用完整权重 +- **AND** 原始轮廓使用 0.1 倍权重 +- **AND** Depth Plane 与 Panorama 的轮廓权重保持 1.0 + +#### Scenario: 关闭平滑 + +- **WHEN** 迭代次数为 0 +- **THEN** 输出几何与未平滑的基线完全一致 + +### Requirement: 共享平滑实现 + +`common/smoothing.py` SHALL 提供单一的平滑构建函数:pin 形态使用边界条带目标与锐度系数,普通形态只使用邻域平均目标。各节点组只提供几何、迭代次数、作用范围、权重、可移动分量与是否 pin;系统 MUST NOT 为单个节点组保留独立的平滑实现或旧偏移路径。 + +#### Scenario: 站点调用一致 + +- **WHEN** 任一节点组构建顶点位置平滑 +- **THEN** 该组只传入几何、迭代次数、作用范围、权重、允许移动的分量与是否 pin +- **AND** 边界条带分支、锐度权重与普通邻域平均目标由共享实现生成 + +#### Scenario: 对称平面约束 + +- **WHEN** `O Image Cutout Symmetry` 应用 Fill 或 Seam 平滑 +- **THEN** 位移限制在对称平面内的分量 +- **AND** 镜像焊接与闭合性保持不变 + +### Requirement: 属性平滑保持现状 + +法线平滑、深度模糊、掩码与统计模糊 MUST NOT 纳入 pin,保持既有节点与权重。 + +#### Scenario: 属性平滑不受影响 + +- **WHEN** 构建 Depth Cutout、Depth Plane 或 Panorama +- **THEN** 法线平滑与深度模糊的节点结构、迭代次数与权重与变更前一致 + +### Requirement: 资产与测试同步 + +源码、保存资产与测试 SHALL 保持一致;几何节点及嵌套组 MUST 保持 Capture Attribute 为零。 + +#### Scenario: 重建资产 + +- **WHEN** 执行节点资产构建并在独立 Blender 进程重新加载 +- **THEN** 资产与源码构建结果通过相同的行为验证 +- **AND** Capture Attribute 检查通过 diff --git a/openspec/changes/pin-geometry-node-smoothing/tasks.md b/openspec/changes/pin-geometry-node-smoothing/tasks.md new file mode 100644 index 0000000..27b0cba --- /dev/null +++ b/openspec/changes/pin-geometry-node-smoothing/tasks.md @@ -0,0 +1,36 @@ +## 1. 共享实现 + +- [x] 1.1 在 `tools/nodes/common/smoothing.py` 实现 pinned 平滑构建函数:Repeat 每轮生成内部 Blur 目标、边界条带 0.5 权重目标与 `|Blur(Normal, 10)|` 锐度系数,按 `weight × region` 混向目标,并支持可移动分量掩码 +- [x] 1.2 删除 `expand_smooth()`,保留 `edge_boundary_field()` 供作用范围与边界分支使用 +- [x] 1.3 更新 `tools/nodes/common/boundary_smoothing.py`,轮廓与切边权重留在作用范围层,顶点平滑改走共享函数 + +## 2. 节点组 + +- [x] 2.1 确认三个深度节点组的顶点平滑经 `smooth_cut_boundary()` 走共享函数,调用签名不变 +- [x] 2.2 删除 `image_cutout_symmetry.py` 的 `_apply_planar_relax()` 与旧的平面内偏移路径,Fill 与 Seam 改走共享函数并传对称平面内的可移动分量 +- [x] 2.3 确认公开接口没有新增 pin 输入,法线平滑、深度模糊、掩码与统计模糊保持不变 + +## 3. 测试 + +- [x] 3.1 在 `tests/tools/nodes/test_boundary_smoothing.py` 覆盖边界条带松弛、尖锐处位移更小与可移动分量掩码,保留范围外不动、迭代 0 关闭、轮廓与切边权重差异的既有覆盖 +- [x] 3.2 确认 Fill 与 Seam 改走共享函数后镜像焊接与闭合性测试通过;`test_cutout_symmetry.py` 中 4 个失败属于 `refine-cutout-symmetry-seam` 未完成项,改动前后一致 +- [x] 3.3 运行节点与 operator 测试:919 passed(含 60 subtests),仅上述 4 个既有失败 + +## 4. 资产 + +- [x] 4.1 运行 `uv run node-group build` 更新并验证 `src/anyimage/assets/O_AnyImage.blend` +- [x] 4.2 检查几何节点与嵌套组 Capture Attribute 为零,确认源码、资产与测试无残留旧平滑路径 + +## 5. Fill Smooth 语义调整 + +- [x] 5.1 让共享平滑支持普通形态:不接边界条带分支时只使用邻域平均目标与 region 权重,pin 形态与普通形态共用同一个迭代循环 +- [x] 5.2 `Fill Smooth` 改走衔接带形态,falloff 由两圈扩大到 `FILL_SMOOTH_RINGS = 4` 圈,Seam 保持 pin +- [x] 5.3 覆盖普通平滑的邻域平均目标与衔接带第三圈仍受力、配置圈数之外保持原位 +- [x] 5.4 重建节点资产并运行相关测试 + +## 6. 衔接带锚点与 pin 拆分 + +- [x] 6.1 把共享平滑的 pin 拆成 `pin_boundary` 与 `pin_sharp` 两个独立开关,深度三组与 Seam 保持两者开启 +- [x] 6.2 `Fill Smooth` 保留 `Pin Sharp`、关闭 `Pin Boundary`,锚点由界外轮廓改为 front 的完整边界带,对称轴上的点因此纳入作用范围 +- [x] 6.3 更新测试:衔接带使用邻域平均但保留锐度权重、对称轴上的点产生位移,并修正 `refine-cutout-symmetry-seam` 里与旧 Fill 语义冲突的断言 +- [x] 6.4 重建节点资产并运行相关测试 diff --git a/openspec/changes/refine-cutout-symmetry-seam/design.md b/openspec/changes/refine-cutout-symmetry-seam/design.md index 2f0bd13..21579dd 100644 --- a/openspec/changes/refine-cutout-symmetry-seam/design.md +++ b/openspec/changes/refine-cutout-symmetry-seam/design.md @@ -43,11 +43,11 @@ 边界判据保持「只有一个相邻面」,再与「顶点越出对称面容差」求交,判断按顶点而非边的平均位置。落在对称面上的切口环在合并后已是共享边,再生成侧壁只会得到退化面或三面共享边。 -### 两个两圈渐变平滑 +### 切口环与衔接带两个平滑 -`Seam Smooth` 与 `Fill Smooth` 语义对齐 `Boundary Smooth`:都只移动面内分量、锁定对称轴,并都把中心字段模糊两圈形成渐变。 +`Seam Smooth` 与 `Fill Smooth` 都只移动面内分量、锁定对称轴。 -`Seam Smooth` 作用于折叠后的切口环,减少切边的面内量化折线;`Fill Smooth` 作用于界外轮廓,即补面的外侧端点,按两圈渐变把轮廓向内部松弛,随后从平滑后的轮廓构建侧壁,因此侧壁自然跟随轮廓。补面的细分始终存在,不再以 `Fill Smooth > 0` 为开关;`Fill Smooth = 0` 只表示不平滑。 +`Seam Smooth` 作用于折叠后的切口环,减少切边的面内量化折线,按两圈渐变衰减,并按 `pinned-geometry-smoothing` 的 `Pin Boundary` 与 `Pin Sharp` 处理。`Fill Smooth` 作用于 front 的衔接带,即界外轮廓与对称面上的切缝共同围出的边界带,按四圈渐变向内松弛,随后从平滑后的轮廓构建侧壁,因此侧壁自然跟随轮廓;衔接处不是网格边界,只保留 `Pin Sharp`。补面的细分始终存在,不再以 `Fill Smooth > 0` 为开关;`Fill Smooth = 0` 只表示不平滑。 两个平滑都在镜像之前完成,两半保持一致,且都与 Fill Sides 无关:关闭补面时 `Fill Smooth` 没有轮廓可作用,`Seam Smooth` 照常作用。 diff --git a/openspec/changes/refine-cutout-symmetry-seam/proposal.md b/openspec/changes/refine-cutout-symmetry-seam/proposal.md index efb4b55..1569e83 100644 --- a/openspec/changes/refine-cutout-symmetry-seam/proposal.md +++ b/openspec/changes/refine-cutout-symmetry-seam/proposal.md @@ -6,7 +6,7 @@ - 对称节点的切口统一为「把越过对称面的顶点折叠到对称面,删除整体贴平的面与内部贴平边」,使零厚度与非零厚度输入的切边落在同一位置。 - 补面只连接不落在对称面上的边界边。落在对称面上的切口由镜像后的合并直接焊成接缝,不再生成侧壁。 -- 新增 `Seam Smooth` 与重定义 `Fill Smooth`,都与上游 Boundary Smooth 同类:只移动面内分量、锁定对称轴,并按两圈渐变从切口环或界外轮廓向内部衰减。 +- 新增 `Seam Smooth` 并重定义 `Fill Smooth`:两者都只移动面内分量、锁定对称轴;`Seam Smooth` 按两圈渐变松弛切口环,`Fill Smooth` 按四圈渐变松弛 front 的衔接带,锚点为界外轮廓与对称面上的切缝,因此对称轴上的点同样参与。 - 补面细分始终存在,不再以 `Fill Smooth > 0` 为开关;`Fill Smooth` 平滑界外轮廓,侧壁从平滑后的轮廓构建。 - 不引入布尔、裁切算子、细分或新增采样参数。 diff --git a/openspec/changes/refine-cutout-symmetry-seam/specs/cutout-symmetry-seam/spec.md b/openspec/changes/refine-cutout-symmetry-seam/specs/cutout-symmetry-seam/spec.md index ed0a1b9..76476ee 100644 --- a/openspec/changes/refine-cutout-symmetry-seam/specs/cutout-symmetry-seam/spec.md +++ b/openspec/changes/refine-cutout-symmetry-seam/specs/cutout-symmetry-seam/spec.md @@ -66,7 +66,9 @@ Fill Sides 生成的侧壁 SHALL 只连接不落在对称面上的边界边, ### Requirement: 切边平滑独立控制 -`Seam Smooth` SHALL 控制切口环的面内平滑次数,`Fill Smooth` SHALL 控制界外轮廓的面内平滑次数。两者都只移动面内分量并锁定对称轴坐标,影响范围按两圈渐变从中心向内部衰减。侧壁细分 SHALL 始终存在,不以 `Fill Smooth > 0` 为开关。`Seam Smooth` 为 0 SHALL 保持原始切口,`Fill Smooth` 为 0 SHALL 保持原始轮廓;两个参数 SHALL 互不影响,且都与 Fill Sides 独立。 +`Seam Smooth` SHALL 控制切口环的面内平滑次数,`Fill Smooth` SHALL 控制 front 衔接带的面内平滑次数。两者都只移动面内分量并锁定对称轴坐标。侧壁细分 SHALL 始终存在,不以 `Fill Smooth > 0` 为开关。`Seam Smooth` 为 0 SHALL 保持原始切口,`Fill Smooth` 为 0 SHALL 保持原始轮廓;两个参数 SHALL 互不影响,且都与 Fill Sides 独立。 + +`Seam Smooth` 按切口环的两圈渐变衰减,切口环按 `pinned-geometry-smoothing` 的 `Pin Boundary` 与 `Pin Sharp` 处理。`Fill Smooth` 的锚点 SHALL 是 front 的完整边界带,即界外轮廓与对称面上的切缝,SHALL 按四圈渐变衰减并覆盖对称轴上的点;衔接处不是网格边界,因此 MUST NOT 套用 `Pin Boundary`,只保留 `Pin Sharp`。 #### Scenario: 切边平滑保持切口落在对称面 @@ -77,9 +79,15 @@ Fill Sides 生成的侧壁 SHALL 只连接不落在对称面上的边界边, #### Scenario: 两个平滑参数互不影响 - **WHEN** 只改变 Fill Smooth 或只改变 Seam Smooth -- **THEN** 前者只改变界外轮廓及其侧壁,后者只改变切口环及其两圈渐变范围 +- **THEN** 前者只改变衔接带及其侧壁,后者只改变切口环及其两圈渐变范围 - **AND** Seam Smooth 为 0 时切口保持原始形态,Fill Smooth 为 0 时轮廓保持原始形态且侧壁仍保持细分 +#### Scenario: 衔接带覆盖对称轴 + +- **WHEN** Fill Smooth 大于 0 +- **THEN** 界外轮廓与对称轴上的点都产生面内位移 +- **AND** 位移从完整边界带向内按四圈渐变衰减 + #### Scenario: 关闭补面仍可平滑切边 - **WHEN** Fill Sides 关闭且 Seam Smooth 大于 0 diff --git a/openspec/changes/refine-cutout-symmetry-seam/tasks.md b/openspec/changes/refine-cutout-symmetry-seam/tasks.md index dae3b64..572b478 100644 --- a/openspec/changes/refine-cutout-symmetry-seam/tasks.md +++ b/openspec/changes/refine-cutout-symmetry-seam/tasks.md @@ -9,15 +9,15 @@ ## 2. 节点测试 -- [ ] 2.1 断言输出不存在越过对称面的顶点、整体贴平面的面或四面共边 -- [ ] 2.2 断言 Fill Sides 开启时网格封闭、边与顶点流形、镜像成立,并覆盖零厚度与非零厚度输入 -- [ ] 2.3 断言 Fill Sides 关闭时切口自焊,落在对称面上的边没有开边 +- [x] 2.1 断言输出不存在越过对称面的顶点、整体贴平面的面或四面共边 +- [x] 2.2 断言 Fill Sides 开启时网格封闭、边与顶点流形、镜像成立,并覆盖零厚度与非零厚度输入 +- [x] 2.3 断言 Fill Sides 关闭时切口自焊,落在对称面上的边没有开边 - [ ] 2.4 断言 Seam Smooth 大于 0 时切口对称轴坐标不漂移,且按两圈渐变影响切口邻域 -- [ ] 2.5 断言节点组不含布尔节点,接口只新增 Seam Smooth +- [x] 2.5 断言节点组不含布尔节点,接口只新增 Seam Smooth ## 3. 验证 - [x] 3.1 运行 `uv run node-group build --skip-tests` 并确认资产重载校验通过 -- [ ] 3.2 更新节点测试后运行 `uv run pytest tests/tools/nodes/test_cutout_symmetry.py` -- [ ] 3.3 运行受影响的 Cutout 对象创建测试 +- [x] 3.2 更新节点测试后运行 `pytest tests/tools/nodes/test_cutout_symmetry.py`,全部通过 +- [x] 3.3 运行受影响的 Cutout 对象创建测试 - [x] 3.4 记录样例上折叠前越界量分布,作为折面跨度的验证证据 diff --git a/openspec/changes/unify-mask-cut-depth/.openspec.yaml b/openspec/changes/unify-mask-cut-depth/.openspec.yaml new file mode 100644 index 0000000..f080778 --- /dev/null +++ b/openspec/changes/unify-mask-cut-depth/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-16 diff --git a/openspec/changes/unify-mask-cut-depth/design.md b/openspec/changes/unify-mask-cut-depth/design.md new file mode 100644 index 0000000..20991b4 --- /dev/null +++ b/openspec/changes/unify-mask-cut-depth/design.md @@ -0,0 +1,79 @@ +## Context + +`common/depth_surface.py` 的 `build_surface_camera` 负责把「点的相机采样」换成「面的相机采样」:保持点自身的射线方向,只沿射线把深度缩放到相邻面的采样值(`projected = original × separated.z / original.z`)。`separated` 是 FACE 域的采样,消费到 POINT 域时按该点的保留相邻面取平均。 + +三个调用点的接入方式不一致: + +| 组 | 切口来源 | 面深覆盖范围 | +| --- | --- | --- | +| `O Image Depth Cutout` | `Depth Split` 切边、网格轮廓 | `cut_vertex` + `always_vertex=outline`(全部自由边界点) | +| `O Image Depth Plane` | `Depth Mask` 删点、`Depth Split` 切边 | 仅 `cut_vertex` | +| `O Image Depth Panorama` | `Depth Mask` 删点、`Depth Split` 切边 | 仅 `cut_vertex` | + +Plane 的矩形网格另有一条始终存在的自由边界:原画幅外框(UV 边距 0)。它以 `original_boundary` 的形式被 `smooth_boundary_camera` 与 `smooth_cut_boundary` 的 `protected` 参数排除在平滑之外。Panorama 的球面是闭合流形,Depth Mask 打开时唯一的自由边界就是删除产生的切口。 + +`Depth Mask` 删点后,切口点的深度仍取该点自身的纹理采样。Plane 的深度纹理使用 Linear + EXTEND 采样,外框行或无效区旁的像素被 clamp 或跨断裂混合时,切口点会顶出尖刺;Panorama 的点采样已有 nearest 保护,但切口与 Depth Split 切边仍走两套深度来源。 + +`common/validity.py` 的删点用 POINT 域的 Delete Geometry:无效顶点连同所有相邻面一起消失,切口因此比阈值轮廓多退一圈,剩下的悬空边还要再删一次。Panorama 的无效判定链先经 `evaluate_field(..., "POINT")` 固定到点域,Plane 的判定链则直接由 UV 属性与纹理采样组成。 + +## Goals / Non-Goals + +**Goals:** Depth Plane 与 Panorama 的 mask 切口与 Depth Split 切边遵循同一条深度规则,并与 Depth Cutout 的既有约定一致;规则内建在共享 helper 里,调用方不再枚举哪些点用面深,切口规则本身不带例外。 + +**Non-Goals:** Panorama 点采样的 nearest 保护、`Boundary Smooth` 与位置平滑的语义(含原画幅外框在边界带平滑中的保护)、`O Image Depth Cutout` 与 `O Image Relief Plane` 的行为。 + +## Decisions + +### 切口边界内建,删除 `always_vertex` 参数 + +规则统一后三个调用点都要它,参数只剩「要不要这条规则」一个含义,而三处答案相同,因此把它移进 `build_surface_camera`:helper 内部用 `edge_boundary_field` 求值边界点,再与 `cut_vertex` 一起接入既有的 `OR → Switch`。`always_vertex` 参数删除,不留兼容分支。代价是将来若有调用方需要退出这条规则,得重新引入参数——目前看不出这样的调用方。 + +### 切口 = 全部自由边界,不设例外 + +`build_surface_camera` 内部把自由边界直接当作切口,不接受调用方给定的点集合,也不接受排除字段。Plane 的原画幅外框因此与 mask 切口同规则,Depth Mask 关闭时外框也会取相邻面采样。 + +曾做过一版「外框例外」:以 UV 边距为零排除外框,并把被 mask 判为无效的外框点并回切口。实际结果是外框上仍会剩下一两个自身采样有效、又紧贴 mask 边界的点按点采样,边界上出现个别不合规则的深度;判据还多出「外框 ∧ 自身采样是否有效」这层状态。相比之下,把全部自由边界当作切口既符合规则本身,也少一层例外,因此取消。 + +外框在边界带平滑(`smooth_boundary_camera`、`smooth_cut_boundary` 的 `protected`)中的保护保持不变,与深度规则互不影响。 + +### 深度替换沿用射线方向不变的比率法 + +切口点保持自身的射线方向,只取相邻保留面的深度。替代方案是整点改用面采样(方向与深度都换),那会把切口点拉离它所在的行列,破坏 Plane 的规则网格与 Panorama 的等距经纬投影;比率法只改深度分量,是既有 Depth Split 与 Cutout 轮廓已验证的形式。 + +### 平滑顺序保持不变 + +``` +删除面(切口成为自由边界) + │ + ▼ +build_surface_camera(切口点取相邻面深) + │ + ▼ +切口带内深度模糊,沿射线重建(Plane)/径向重建(Panorama) + │ + ▼ +切口带内位置平滑 +``` + +面深决定切口点贴在哪张面上,带内模糊决定切口附近怎么过渡,两者职责不同,先锚定再过渡,与 Depth Cutout 一致。可观测后果是切口点的最终深度等于「面深在切口带内与邻点模糊一次」的结果,而不是相邻面深的精确值。要让切口严格等于面深,只能把替换挪到模糊之后或把切口点也排除出模糊,两者都会在切口与第二圈之间留出台阶,不采用。 + +### Depth Mask 按面删除 + +删除改用 FACE 域,并去掉随后的残留边清理。理由是节点链按消费域求值:Plane 的判定链没有固定到点域,切到 FACE 后 UV 属性取该面四角平均,等于在面中心采样深度,因此面中心仍有效的面保留下来,切口不再比阈值轮廓多退一圈;点删除遗留的悬空边也随面删除一并消失,不需要第二次 Delete Geometry。 + +Panorama 的判定链在 `evaluate_field(..., "POINT")` 处已固定到点域,面域消费时按四角平均,得到的是「任一顶点无效即删」,删除范围与改动前相同。要让它也按面心判定,需要改用 `face_u` / `face_v` 那套面中心采样(直接平均点域经度会跨 seam 折返),本轮不做。 + +## Risks / Trade-offs + +- 推翻「Depth Mask 只删点、不改位置」的既有契约 → 重写相关断言为「切口取相邻面深」,并保留 Depth Mask 关闭时结果逐位不变的断言。 +- 没有相邻保留面的点参与面深替换时 FACE→POINT 求值为 0,比率会塌到原点 → `edge_boundary_field` 只标记开口边界与残留边端点,不标记孤立点,并加断言兜住。 +- 外框改按面深后,Depth Mask 关闭时外框的深度也随之变化 → 由切口规则测试覆盖;外框在边界带平滑中的保护保持不变。 +- 源码与 `.blend` 资产不同步 → 修改后运行 `uv run node-group build`,源码、测试与资产一并更新。 + +## Migration Plan + +直接替换构建函数、两个组的接入与相关测试,不保留旧参数或开关;重新构建节点资产。回滚时恢复 `build_surface_camera`、两个组与测试并重建资产。用户工程中已保存的节点实例沿用保存时的接口,不做迁移。 + +## Open Questions + +无。 diff --git a/openspec/changes/unify-mask-cut-depth/proposal.md b/openspec/changes/unify-mask-cut-depth/proposal.md new file mode 100644 index 0000000..1eda96e --- /dev/null +++ b/openspec/changes/unify-mask-cut-depth/proposal.md @@ -0,0 +1,31 @@ +## Why + +`O Image Depth Plane` 与 `O Image Depth Panorama` 上由 Depth Mask 删点形成的切口,深度仍取该点自己的纹理采样;同一场景里 `O Image Depth Cutout` 的轮廓点和 Depth Split 切边早已改为取相邻面的采样。同类切口因此落在不同深度,clamp 或跨深度断裂的像素会把切口顶出尖刺。 + +## What Changes + +- `build_surface_camera` 内建切口规则:自由边界点与 Depth Split 切边一样,深度取相邻面的采样,射线方向不变;删除仅有单一调用方使用的 `always_vertex` 参数。 +- `O Image Depth Plane` 的原画幅外框与 mask 切口同规则:所有自由边界点都取相邻面采样,切口规则不带例外,外框也不再保留自身采样。 +- `O Image Depth Panorama` 不传排除项:球面闭合,删除后出现的自由边界只有 mask 切口。 +- Depth Mask 的删除由点改为面:面中心的采样低于阈值才删除该面,切口因此不再比阈值轮廓多退一圈,残留边也不需要单独清理。 +- `build_surface_camera` 不再接受排除字段:切口规则覆盖全部自由边界点,取消上一版「外框例外」。 +- 平滑顺序保持不变:切口先取面深,切口带的深度模糊与位置平滑照常在其后作用。 +- 重建 `O_AnyImage.blend`,补齐切口面深与实位不变的断言。 + +## Capabilities + +### New Capabilities + +- `mask-cut-depth-sampling`: Depth Plane 与 Panorama 上由 Depth Mask 删除产生的切口如何确定深度,以及原画幅外框的例外。 + +### Modified Capabilities + +无。`openspec/specs` 尚无可修改的已归档规格。 + +## Impact + +- `tools/nodes/common/depth_surface.py`:切口边界内建在 `build_surface_camera`,`always_vertex` 参数移除。 +- `tools/nodes/groups/image_depth_plane.py`、`tools/nodes/groups/image_depth_panorama.py`:接入切口规则,Plane 额外声明原画幅外框。 +- `tests/tools/nodes/`:切口面深断言,以及既有「Depth Mask 只删点、不改位置」断言的重写。 +- `src/anyimage/assets/O_AnyImage.blend`:重新构建节点资产。 +- 不改动 `O Image Depth Cutout`、`O Image Relief Plane`、Cutout 创建入口、材质与依赖。 diff --git a/openspec/changes/unify-mask-cut-depth/specs/mask-cut-depth-sampling/spec.md b/openspec/changes/unify-mask-cut-depth/specs/mask-cut-depth-sampling/spec.md new file mode 100644 index 0000000..27c77ba --- /dev/null +++ b/openspec/changes/unify-mask-cut-depth/specs/mask-cut-depth-sampling/spec.md @@ -0,0 +1,85 @@ +## ADDED Requirements + +### Requirement: 自由边界与切边取相邻面的深度采样 + +`O Image Depth Plane` 与 `O Image Depth Panorama` 上由 Depth Mask 删除产生的切口点,以及 `O Image Depth Plane` 原画幅外框上的点,其深度 MUST 取该点相邻保留面的采样值,而不是该点自己的纹理采样。切口点 MUST 保持自身的射线方向,只改变沿射线的深度分量。 + +#### Scenario: 无效侧深度尖刺不影响 Plane 切口 + +- **WHEN** Depth Mask 删除一片区域,且紧邻该区域的无效侧像素深度与有效侧相差一个数量级 +- **THEN** 切口点的深度等于相邻有效面中心的采样深度,切口不出现跟随无效侧像素的尖刺 + +#### Scenario: Panorama 切口跟随有效侧半径 + +- **WHEN** Panorama 的 Depth Mask 删除一片区域,且被删区域的距离值与有效侧相差一个数量级 +- **THEN** 切口圈各点的径向距离等于相邻有效面中心的采样距离 + +#### Scenario: 射线方向保持 + +- **WHEN** 切口点取相邻面深度 +- **THEN** Plane 切口点的相机横纵坐标不变,Panorama 切口点仍落在原经纬方向上 + +#### Scenario: 关闭 Depth Split 时仍然生效 + +- **WHEN** Depth Split 为 0 +- **THEN** 切口点仍取相邻面深度,Depth Split 切边不参与深度替换 + +#### Scenario: 外框与切口同规则 + +- **WHEN** `O Image Depth Plane` 用同一深度图求值,Depth Mask 打开或关闭 +- **THEN** 原画幅外框上的点与外框邻接面的采样深度一致,不保留自身采样 + +### Requirement: 切口规则内建在共享相机采样 + +切口规则 MUST 由 `build_surface_camera` 自身求值自由边界并应用,调用方 MUST NOT 传入「哪些点使用面深」的集合,也 MUST NOT 传入排除字段;Depth Split 切边 MUST 与切口点走同一条深度替换路径。 + +#### Scenario: 三个调用点行为一致 + +- **WHEN** 比较 `O Image Depth Cutout`、`O Image Depth Plane` 与 `O Image Depth Panorama` 的相机采样接线 +- **THEN** 三者都不传入点集合或排除字段,切口点与 Depth Split 切边由同一规则覆盖 + +### Requirement: Depth Mask 按面删除 + +Depth Mask 的删除 MUST 按面判定:面中心的采样低于 Mask Threshold 时删除该面,其余面连同其顶点一起保留。删除后 MUST NOT 再单独清理残留边。 + +#### Scenario: 切口不再多退一圈 + +- **WHEN** Depth Mask 打开,且无效区与有效区之间存在只跨过部分顶点的面 +- **THEN** 该面在面中心仍有效时保留,切口落在面中心轮廓上,而不是所有相邻顶点都有效的内圈 + +#### Scenario: 删除后没有悬空边 + +- **WHEN** Depth Mask 删除整片面 +- **THEN** 输出的边都至少属于一个保留面,没有只由删除残留形成的边 + +### Requirement: 原画幅外框的平滑保护独立于切口规则 + +`O Image Depth Plane` 的原画幅外框(UV 边距为 0 的点)MUST 继续作为 `smooth_boundary_camera` 与 `smooth_cut_boundary` 的保护范围,边界带平滑 MUST NOT 因此改变切口规则的覆盖范围。 + +#### Scenario: 外框保护不参与深度规则 + +- **WHEN** Boundary Smooth 大于 0 且存在切口 +- **THEN** 原画幅外框保持不被边界带平滑拖入,同时其深度仍取相邻面采样 + +#### Scenario: 关闭 Depth Mask 时几何逐位不变 + +- **WHEN** Depth Mask 关闭 +- **THEN** `O Image Depth Plane` 与 `O Image Depth Panorama` 的输出与改动前逐位一致 + +### Requirement: 切口深度先锚定后过渡 + +切口点的面深替换 MUST 发生在切口带深度模糊与位置平滑之前。切口点的最终几何 MUST 由「面深在切口带内模糊一次,再沿射线重建」得到,带外点的位置与深度 MUST 与改动前一致。 + +#### Scenario: 切口带保留平滑过渡 + +- **WHEN** Boundary Smooth 大于 0 且存在 mask 切口 +- **THEN** 切口带各点沿各自射线过渡到相邻面深,切口带以外的点与 Boundary Smooth 为 0 时逐位一致 + +### Requirement: 无相邻面的边界点不参与面深替换 + +切口规则 MUST 在删除无面边(wire edge)之后求值自由边界。没有相邻保留面的点 MUST NOT 进入面深替换,避免深度塌陷到原点。 + +#### Scenario: 孤立点保持自身深度 + +- **WHEN** 几何中存在没有相邻面的自由点 +- **THEN** 该点保持自身采样深度,输出坐标有限 diff --git a/openspec/changes/unify-mask-cut-depth/tasks.md b/openspec/changes/unify-mask-cut-depth/tasks.md new file mode 100644 index 0000000..9f7eebd --- /dev/null +++ b/openspec/changes/unify-mask-cut-depth/tasks.md @@ -0,0 +1,28 @@ +## 1. 共享相机采样 + +- [x] 1.1 在 `build_surface_camera` 内部求值 `edge_boundary_field` 得到切口边界,删除 `always_vertex` 参数,让切口点与 Depth Split 切边走同一条深度替换路径 +- [x] 1.2 为 `build_surface_camera` 增加可选排除字段并施加在切口边界上:被排除的点保持自身采样 +- [x] 1.3 更新 `build_surface_camera` 的 docstring,说明切口取相邻面深、射线方向不变、排除字段的语义 + +## 2. 三个组接入 + +- [x] 2.1 `O Image Depth Plane`:去掉 `always_vertex` 用法,外框与切口同规则,不再传排除字段 +- [x] 2.2 `O Image Depth Panorama`:去掉 `always_vertex` 用法,不传排除字段 +- [x] 2.3 `O Image Depth Cutout`:去掉局部 `outline` 变量与 `always_vertex` 传参,确认轮廓与切边仍由内建规则覆盖 +- [x] 2.4 确认 `O Image Relief Plane`、Cutout 创建入口与材质接线未受影响 +- [x] 2.5 `delete_invalid_points` 改为 `delete_invalid_faces`:Delete Geometry 用 FACE 域,去掉残留边的第二次删除 +- [x] 2.6 `O Image Depth Plane`:取消外框例外,`build_surface_camera` 去掉排除字段,全部自由边界点取相邻面采样 + +## 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`) + +## 4. 资产与收尾 + +- [x] 4.1 运行 `uv run node-group build`,确认节点资产构建与校验通过、Capture Attribute 为零 +- [x] 4.2 检查源码与测试中不再有 `always_vertex` 及旧路径引用,确认最终差异只涉及本次范围 diff --git a/src/anyimage/assets/O_AnyImage.blend b/src/anyimage/assets/O_AnyImage.blend index 699a373..e7c4f31 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:2452638853ca24e2fea88ee4efb5621f2c0d991b3bbe872e95efe7fb83e1f7e8 -size 7662213 +oid sha256:80656018937c813cc358c9bab0aa6745b4ad36cfd860ccdf35f73570b9f234d7 +size 7961385 diff --git a/tests/tools/nodes/test_boundary_smoothing.py b/tests/tools/nodes/test_boundary_smoothing.py index fc9095b..21d22f3 100644 --- a/tests/tools/nodes/test_boundary_smoothing.py +++ b/tests/tools/nodes/test_boundary_smoothing.py @@ -9,6 +9,67 @@ import pytest from tests.support.depth_surface import surface, evaluated, assert_closed, component_count from tools.nodes.common.boundary_smoothing import cut_boundary_influence +from tools.nodes.common.smoothing import pinned_smooth + + +def pinned_group( + iterations=1, + region=1.0, + weight=1.0, + movement=None, + pin_boundary=True, + pin_sharp=True, +): + group = bpy.data.node_groups.new("Pinned smooth", "GeometryNodeTree") + for direction in ("INPUT", "OUTPUT"): + group.interface.new_socket( + name="Geometry", in_out=direction, socket_type="NodeSocketGeometry" + ) + smooth = group.interface.new_socket( + name="Smooth", in_out="INPUT", socket_type="NodeSocketInt" + ) + smooth.default_value = iterations + source = group.nodes.new("NodeGroupInput") + control = group.nodes.new("NodeGroupInput") + region_value = group.nodes.new("ShaderNodeValue") + region_value.outputs[0].default_value = region + result = pinned_smooth( + group, + source.outputs["Geometry"], + control.outputs["Smooth"], + region_value.outputs[0], + weight=weight, + movement=movement, + pin_boundary=pin_boundary, + pin_sharp=pin_sharp, + ) + group.links.new(result, group.nodes.new("NodeGroupOutput").inputs["Geometry"]) + return group + + +def pinned_result(points, faces, **options): + bpy.ops.wm.read_factory_settings(use_empty=True) + mesh = bpy.data.meshes.new("Pinned") + mesh.from_pydata(points, [], faces) + obj = bpy.data.objects.new("Pinned", mesh) + bpy.context.collection.objects.link(obj) + obj.modifiers.new("Pinned", "NODES").node_group = pinned_group(**options) + return evaluated(obj)[0] + + +def grid(size): + points = [(x, y, 0.0) for y in range(size) for x in range(size)] + faces = [ + ( + y * size + x, + y * size + x + 1, + (y + 1) * size + x + 1, + (y + 1) * size + x, + ) + for y in range(size - 1) + for x in range(size - 1) + ] + return points, faces def diagonal_surface(triangles=False): @@ -287,3 +348,64 @@ def test_panorama_validity_cut_smoothing_stays_in_two_ring_band(): np.testing.assert_array_equal(result_uv, uv) np.testing.assert_array_equal(after[outside], before[outside]) assert np.max(np.linalg.norm(after-before, axis=1)) > 1e-4 + + +def test_boundary_points_follow_the_boundary_strip_blur(): + """A boundary spike relaxes along the outline instead of being pulled inward.""" + points, faces = grid(4) + points[1] = (1.0, -0.5, 0.0) + result = pinned_result(points, faces) + # 边界条带只连接边界邻居:自身权重 1,两个边界邻居权重 0.5。 + np.testing.assert_allclose(result[1], (1.0, -0.25, 0.0), atol=1e-6) + + +def test_sharp_features_lower_the_smoothing_weight(): + """A crease inside the normal blur range reduces the pinned displacement.""" + points, faces = grid(3) + points[4] = (1.0, -0.5, 0.0) + flat = pinned_result(points, faces) + creased = list(points) + creased[0] = (0.0, 0.0, 1.0) + creased = pinned_result(creased, faces) + # 折痕不在尖点的邻居集合内,两次运行的平滑目标相同,只有锐度系数不同。 + flat_step = abs(flat[4][1] + 0.5) + creased_step = abs(creased[4][1] + 0.5) + assert flat_step > 1e-3 + assert creased_step < flat_step * 0.99 + + +def test_junction_band_uses_the_neighbourhood_average(): + """Without the boundary pin a boundary spike follows every connected neighbour.""" + points, faces = grid(4) + points[1] = (1.0, -0.5, 0.0) + result = pinned_result(points, faces, pin_boundary=False) + # 自身权重 1 与三个邻居的平均:(-0.5 + 0 + 0 + 1) / 4。 + np.testing.assert_allclose(result[1], (1.0, 0.125, 0.0), atol=1e-6) + + +def test_junction_band_keeps_the_sharpness_weight(): + """The sharpness weight stays active without the boundary pin.""" + points, faces = grid(3) + points[4] = (1.0, -0.5, 0.0) + flat = pinned_result(points, faces, pin_boundary=False) + creased = list(points) + creased[0] = (0.0, 0.0, 1.0) + creased = pinned_result(creased, faces, pin_boundary=False) + flat_step = abs(flat[4][1] + 0.5) + creased_step = abs(creased[4][1] + 0.5) + assert flat_step > 1e-3 + assert creased_step < flat_step * 0.99 + + +def test_movement_mask_keeps_the_symmetry_plane(): + """Restricting the movement leaves the masked axis untouched.""" + points, faces = grid(4) + # 网格落在 XZ 对称平面上,Y 是只允许被松开的对称轴。 + points = [(x, z, y) for x, y, z in points] + points[5] = (2.0, 0.5, 1.0) + original = np.array(points) + masked = pinned_result(points, faces, movement=(1, 0, 1)) + np.testing.assert_array_equal(masked[:, 1], original[:, 1]) + assert np.max(np.abs(masked[:, 0] - original[:, 0])) > 1e-4 + full = pinned_result(points, faces) + assert np.max(np.abs(full[:, 1] - original[:, 1])) > 1e-4 diff --git a/tests/tools/nodes/test_cutout_symmetry.py b/tests/tools/nodes/test_cutout_symmetry.py index bf40e46..b03f052 100644 --- a/tests/tools/nodes/test_cutout_symmetry.py +++ b/tests/tools/nodes/test_cutout_symmetry.py @@ -113,6 +113,7 @@ def test_symmetry_interface_and_cutout_defaults(symmetry): "Scale", "Fill Sides", "Fill Smooth", + "Seam Smooth", ] assert tuple(mirror_inputs[1].default_value) == (0, 0, 1) assert mirror_inputs[3].default_value == 1 @@ -219,7 +220,7 @@ def test_disabled_fill_keeps_the_welded_seam(symmetry): assert not any(plane[edge[0]] and plane[edge[1]] for edge in open_edges) -def test_fill_smooth_only_relaxes_the_seam(symmetry): +def test_fill_smooth_relaxes_the_junction_band(symmetry): obj, _cutout, _mirror, _cutout_setter, mirror_setter = symmetry mirror_setter("Fill Sides", True) mirror_setter("Fill Smooth", 0) @@ -232,17 +233,67 @@ def test_fill_smooth_only_relaxes_the_seam(symmetry): plane = np.abs(points[:, 0]) <= tolerance assert plane.any() assert not any(all(plane[index] for index in face) for face in faces) - plain_interior = _front_vertices( - plain_points, plain_faces, plain_marker, lambda point: point[0] > tolerance + assert smooth_faces == plain_faces + axis = np.abs(plain_points[:, 0]) <= tolerance + moved = np.linalg.norm(smooth_points - plain_points, axis=1) + # 衔接带同时覆盖对称轴上的点与外侧轮廓。 + assert moved[axis].max() > 1e-4 + plain_rim = _positions(_front_rim(plain_points, plain_faces, plain_marker, tolerance)) + smooth_rim = _positions(_front_rim(smooth_points, smooth_faces, smooth_marker, tolerance)) + assert plain_rim != smooth_rim + + +def test_fill_smooth_band_reaches_beyond_two_rings(): + """Fill Smooth 平滑的是衔接带,覆盖范围比原先的两圈更宽。""" + from tools.nodes.common.depth_surface import _math + from tools.nodes.groups.image_cutout_symmetry import FILL_SMOOTH_RINGS, _falloff + + bpy.ops.wm.read_factory_settings(use_empty=True) + size, rows = FILL_SMOOTH_RINGS + 4, 3 + points = [(x, 0.0, y) for y in range(rows) for x in range(size)] + faces = [ + ( + y * size + x, + y * size + x + 1, + (y + 1) * size + x + 1, + (y + 1) * size + x, + ) + for y in range(rows - 1) + for x in range(size - 1) + ] + mesh = bpy.data.meshes.new("Fill band") + mesh.from_pydata(points, [], faces) + obj = bpy.data.objects.new("Fill band", mesh) + bpy.context.collection.objects.link(obj) + group = bpy.data.node_groups.new("Fill band", "GeometryNodeTree") + for direction in ("INPUT", "OUTPUT"): + group.interface.new_socket( + name="Geometry", in_out=direction, socket_type="NodeSocketGeometry" + ) + source = group.nodes.new("NodeGroupInput") + position = group.nodes.new("GeometryNodeInputPosition") + axes = group.nodes.new("ShaderNodeSeparateXYZ") + group.links.new(position.outputs[0], axes.inputs[0]) + outline = _math(group, "COMPARE", axes.outputs["X"], 0) + store = group.nodes.new("GeometryNodeStoreNamedAttribute") + store.data_type, store.domain = "FLOAT", "POINT" + store.inputs["Name"].default_value = "fill_band" + group.links.new(source.outputs["Geometry"], store.inputs["Geometry"]) + group.links.new(_falloff(group, outline, FILL_SMOOTH_RINGS), store.inputs["Value"]) + group.links.new( + store.outputs["Geometry"], group.nodes.new("NodeGroupOutput").inputs["Geometry"] ) - smooth_interior = _front_vertices( - smooth_points, smooth_faces, smooth_marker, lambda point: point[0] > tolerance - ) - assert _positions(plain_interior) == _positions(smooth_interior) - plain_rim = _front_rim(plain_points, plain_faces, plain_marker, tolerance) - smooth_rim = _front_rim(smooth_points, smooth_faces, smooth_marker, tolerance) - assert plain_rim.shape == smooth_rim.shape - assert not np.allclose(np.sort(plain_rim[:, 1:], axis=0), np.sort(smooth_rim[:, 1:], axis=0)) + obj.modifiers.new("Fill band", "NODES").node_group = group + result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) + data = result.to_mesh() + try: + band = np.array( + [item.value for item in data.attributes["fill_band"].data] + ).reshape(rows, size) + finally: + result.to_mesh_clear() + assert (band[:, 3] > 0).all() + assert (band[:, FILL_SMOOTH_RINGS + 1] == 0).all() def test_object_normal_attributes_follow_final_orientation(symmetry): diff --git a/tests/tools/nodes/test_depth_cutout.py b/tests/tools/nodes/test_depth_cutout.py index b0112f9..6122d70 100644 --- a/tests/tools/nodes/test_depth_cutout.py +++ b/tests/tools/nodes/test_depth_cutout.py @@ -215,7 +215,8 @@ def test_depth_cutout_reuses_boundary_and_slow_field_nodes(): group = build_image_depth_cutout_group() counts = Counter(node.bl_idname for node in group.nodes) assert counts["GeometryNodeInputMeshEdgeNeighbors"] <= 15 - assert counts["GeometryNodeBlurAttribute"] <= 12 + # 内部平滑、边界条带与 pin sharp 的法线相干度各一个 Blur。 + assert counts["GeometryNodeBlurAttribute"] <= 13 def test_center_derives_from_shared_slow_fields(): diff --git a/tests/tools/nodes/test_image_layer.py b/tests/tools/nodes/test_image_layer.py index 56d3919..3b01553 100644 --- a/tests/tools/nodes/test_image_layer.py +++ b/tests/tools/nodes/test_image_layer.py @@ -13,8 +13,8 @@ def test_alpha_curve_preserves_endpoints_and_suppresses_soft_edges(image_layer): if item.item_type == "SOCKET" and item.in_out == "INPUT" } assert list(inputs) == [ - "Color", "Alpha", "Normal", "Bump Scale", "Alpha Fix", - "Normal Scale", "Object Space", + "Color", "Alpha", "Normal", "Normal Scale", "Bump Scale", + "Alpha Fix", "Object Space", ] strength = inputs["Alpha Fix"] output = next(node for node in image_layer.nodes if node.type == "GROUP_OUTPUT") diff --git a/tools/nodes/common/boundary_smoothing.py b/tools/nodes/common/boundary_smoothing.py index 55060af..cd272a2 100644 --- a/tools/nodes/common/boundary_smoothing.py +++ b/tools/nodes/common/boundary_smoothing.py @@ -2,7 +2,7 @@ from .nodes import evaluate_field, group_input from .depth_surface import _math -from .smoothing import edge_boundary_field, expand_smooth +from .smoothing import edge_boundary_field, pinned_smooth def boundary_influence(group, protected=False, *, boundary=None): @@ -92,9 +92,10 @@ def cut_boundary_influence(group, split_boundary, protected=False, *, outline_sc def smooth_cut_boundary(group, geometry, split_boundary=False, *, protected=False, outline_scale=0.1, boundary=None): - """Move boundary vertices toward the smoothed position through split and outline influence.""" + """Move boundary vertices toward the pinned position through split and outline influence.""" influence = cut_boundary_influence( group, split_boundary, protected, outline_scale=outline_scale, boundary=boundary, ) # The support band depends on topology, which stays unchanged in the loop. - return expand_smooth(group, geometry, "Boundary Smooth", influence) + iterations = group_input(group.nodes, {"Boundary Smooth"}).outputs["Boundary Smooth"] + return pinned_smooth(group, geometry, iterations, influence) diff --git a/tools/nodes/common/depth_surface.py b/tools/nodes/common/depth_surface.py index 7eb0553..ef3f9cb 100644 --- a/tools/nodes/common/depth_surface.py +++ b/tools/nodes/common/depth_surface.py @@ -1,6 +1,7 @@ """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 .smoothing import edge_boundary_field CUT_ATTRIBUTE = "_o_anyimage_depth_cut" @@ -275,21 +276,24 @@ def split_depth_surface(group, geometry, face_camera, strength, reference_depth, return choose.outputs["Output"], face_camera, cut -def build_surface_camera(group, original, separated, cut_vertex, strength, *, always_vertex=None): - """Change cut depth along the original ray, preserving every other vertex.""" - enabled = group.nodes.new("GeometryNodeSwitch") +def build_surface_camera(group, original, separated, cut_vertex, strength): + """Take cut depth from the adjacent face sample along each unchanged ray. + + Depth Split edges and every open boundary left by Depth Mask deletion share + one replacement, so each cut edge sits on the surface it belongs to. + """ + nodes, links = group.nodes, group.links + cut_boundary = evaluate_field(group, edge_boundary_field(nodes, links), "BOOLEAN", "POINT") + enabled = nodes.new("GeometryNodeSwitch") enabled.input_type = "BOOLEAN" - group.links.new(_math(group, "GREATER_THAN", strength, 0.0), enabled.inputs["Switch"]) + links.new(_math(group, "GREATER_THAN", strength, 0.0), enabled.inputs["Switch"]) enabled.inputs["False"].default_value = False - group.links.new(cut_vertex, enabled.inputs["True"]) - if always_vertex is not None: - combined = group.nodes.new("FunctionNodeBooleanMath") - combined.operation = "OR" - group.links.new(enabled.outputs["Output"], combined.inputs[0]) - group.links.new(always_vertex, combined.inputs[1]) - enabled = combined.outputs["Boolean"] - else: - enabled = enabled.outputs["Output"] + links.new(cut_vertex, enabled.inputs["True"]) + combined = nodes.new("FunctionNodeBooleanMath") + combined.operation = "OR" + links.new(enabled.outputs["Output"], combined.inputs[0]) + links.new(cut_boundary, combined.inputs[1]) + enabled = combined.outputs["Boolean"] original_axes = group.nodes.new("ShaderNodeSeparateXYZ") separated_axes = group.nodes.new("ShaderNodeSeparateXYZ") group.links.new(original, original_axes.inputs[0]) diff --git a/tools/nodes/common/smoothing.py b/tools/nodes/common/smoothing.py index 15e59de..a40e6ad 100644 --- a/tools/nodes/common/smoothing.py +++ b/tools/nodes/common/smoothing.py @@ -1,8 +1,9 @@ -"""Build shared boundary fields and iterative smoothing.""" -from .nodes import group_input, sample_field +"""Build shared boundary fields and pinned position smoothing.""" +from .nodes import evaluate_field, sample_field def edge_boundary_field(nodes, links): + """Report the points sitting on an open or loose boundary.""" vertex_neighbors = nodes.new("GeometryNodeInputMeshVertexNeighbors") loose_vertex = nodes.new("FunctionNodeCompare") loose_vertex.data_type = "INT" @@ -40,54 +41,46 @@ def edge_boundary_field(nodes, links): return is_boundary.outputs["Boolean"] -def expand_smooth(group, geometry, iterations_name, influence): +def _multiply(group, a, b): + node = group.nodes.new("ShaderNodeMath") + node.operation = "MULTIPLY" + for index, value in enumerate((a, b)): + if isinstance(value, (int, float)): + node.inputs[index].default_value = value + else: + group.links.new(value, node.inputs[index]) + return node.outputs[0] + + +def _boundary_target(group, geometry, current, boundary_points): + """Blur the boundary strip alone and sample it back onto every point.""" nodes = group.nodes links = group.links - iteration_controls = group_input(nodes, {iterations_name}) - repeat_output = nodes.new("GeometryNodeRepeatOutput") - repeat_input = nodes.new("GeometryNodeRepeatInput") - repeat_input.pair_with_output(repeat_output) - links.new( - iteration_controls.outputs[iterations_name], - repeat_input.inputs["Iterations"], - ) - - boundary_field = edge_boundary_field(nodes, links) - boundary_points = nodes.new("GeometryNodeFieldOnDomain") - boundary_points.data_type = "BOOLEAN" - boundary_points.domain = "POINT" - links.new(boundary_field, boundary_points.inputs["Value"]) - boundary_geometry = nodes.new("GeometryNodeSeparateGeometry") boundary_geometry.domain = "POINT" - links.new(repeat_input.outputs["Geometry"], boundary_geometry.inputs["Geometry"]) - links.new(boundary_points.outputs["Value"], boundary_geometry.inputs["Selection"]) - links.new(geometry, repeat_input.inputs["Geometry"]) - nearest_boundary = nodes.new("GeometryNodeSampleNearest") - nearest_boundary.domain = "POINT" - # Both boundary meshes keep the same compact index order: only positions change. - source_boundary = nodes.new("GeometryNodeSeparateGeometry") - source_boundary.domain = "POINT" - links.new(geometry, source_boundary.inputs["Geometry"]) - links.new(boundary_points.outputs["Value"], source_boundary.inputs["Selection"]) - links.new(source_boundary.outputs["Selection"], nearest_boundary.inputs["Geometry"]) - source_position = nodes.new("GeometryNodeInputPosition").outputs["Position"] - fixed_position = sample_field(group, geometry, source_position, "FLOAT_VECTOR") - links.new(fixed_position, nearest_boundary.inputs["Sample Position"]) - - position = nodes.new("GeometryNodeInputPosition") - smooth_all = nodes.new("GeometryNodeBlurAttribute") - smooth_all.data_type = "FLOAT_VECTOR" - smooth_all.inputs["Iterations"].default_value = 1 - smooth_all.inputs["Weight"].default_value = 1.0 - links.new(position.outputs["Position"], smooth_all.inputs["Value"]) - + links.new(current, boundary_geometry.inputs["Geometry"]) + links.new(boundary_points, boundary_geometry.inputs["Selection"]) boundary_position = nodes.new("GeometryNodeInputPosition") smooth_boundary = nodes.new("GeometryNodeBlurAttribute") smooth_boundary.data_type = "FLOAT_VECTOR" smooth_boundary.inputs["Iterations"].default_value = 1 smooth_boundary.inputs["Weight"].default_value = 0.5 links.new(boundary_position.outputs["Position"], smooth_boundary.inputs["Value"]) + + nearest_boundary = nodes.new("GeometryNodeSampleNearest") + nearest_boundary.domain = "POINT" + # Both boundary meshes keep the same compact index order: only positions change, + # so the lookup can stay anchored on the untouched input geometry. + source_boundary = nodes.new("GeometryNodeSeparateGeometry") + source_boundary.domain = "POINT" + links.new(geometry, source_boundary.inputs["Geometry"]) + links.new(boundary_points, source_boundary.inputs["Selection"]) + links.new(source_boundary.outputs["Selection"], nearest_boundary.inputs["Geometry"]) + fixed_position = sample_field( + group, geometry, boundary_position.outputs["Position"], "FLOAT_VECTOR" + ) + links.new(fixed_position, nearest_boundary.inputs["Sample Position"]) + sample_boundary = nodes.new("GeometryNodeSampleIndex") sample_boundary.data_type = "FLOAT_VECTOR" sample_boundary.domain = "POINT" @@ -95,26 +88,90 @@ def expand_smooth(group, geometry, iterations_name, influence): links.new(boundary_geometry.outputs["Selection"], sample_boundary.inputs["Geometry"]) links.new(smooth_boundary.outputs["Value"], sample_boundary.inputs["Value"]) links.new(nearest_boundary.outputs["Index"], sample_boundary.inputs["Index"]) + return sample_boundary.outputs["Value"] - choose_boundary = nodes.new("GeometryNodeSwitch") - choose_boundary.input_type = "VECTOR" - links.new(boundary_points.outputs["Value"], choose_boundary.inputs["Switch"]) - links.new(smooth_all.outputs["Value"], choose_boundary.inputs["False"]) - links.new(sample_boundary.outputs["Value"], choose_boundary.inputs["True"]) - current_position = nodes.new("GeometryNodeInputPosition") - mix_position = nodes.new("ShaderNodeMix") - mix_position.data_type = "VECTOR" - links.new(influence, mix_position.inputs[0]) - links.new(current_position.outputs["Position"], mix_position.inputs[4]) - links.new(choose_boundary.outputs["Output"], mix_position.inputs[5]) +def pinned_smooth( + group, + geometry, + iterations, + region, + weight=1.0, + movement=None, + pin_boundary=True, + pin_sharp=True, +): + """Smooth positions toward a pinned target inside an influence region. + + ``pin_boundary`` sends boundary points to a boundary-only blur so an outline + is never dragged inward. ``pin_sharp`` lowers the weight of creases through + the sharpness of the normal field. Junction bands keep the sharpness weight + without the boundary rule, where the influenced points are not a boundary. + """ + nodes, links = group.nodes, group.links + repeat_output = nodes.new("GeometryNodeRepeatOutput") + repeat_input = nodes.new("GeometryNodeRepeatInput") + repeat_input.pair_with_output(repeat_output) + links.new(iterations, repeat_input.inputs["Iterations"]) + links.new(geometry, repeat_input.inputs["Geometry"]) + current = repeat_input.outputs["Geometry"] + + position = nodes.new("GeometryNodeInputPosition") + smooth_all = nodes.new("GeometryNodeBlurAttribute") + smooth_all.data_type = "FLOAT_VECTOR" + smooth_all.inputs["Iterations"].default_value = 1 + smooth_all.inputs["Weight"].default_value = 1.0 + links.new(position.outputs["Position"], smooth_all.inputs["Value"]) + target = smooth_all.outputs["Value"] + if pin_boundary: + boundary_points = evaluate_field( + group, edge_boundary_field(nodes, links), "BOOLEAN", "POINT" + ) + choose = nodes.new("GeometryNodeSwitch") + choose.input_type = "VECTOR" + links.new(boundary_points, choose.inputs["Switch"]) + links.new(smooth_all.outputs["Value"], choose.inputs["False"]) + links.new( + _boundary_target(group, geometry, current, boundary_points), + choose.inputs["True"], + ) + target = choose.outputs["Output"] + + if pin_sharp: + normal = nodes.new("GeometryNodeInputNormal") + smooth_normal = nodes.new("GeometryNodeBlurAttribute") + smooth_normal.data_type = "FLOAT_VECTOR" + smooth_normal.inputs["Iterations"].default_value = 10 + smooth_normal.inputs["Weight"].default_value = 1.0 + links.new(normal.outputs["Normal"], smooth_normal.inputs["Value"]) + coherence = nodes.new("ShaderNodeVectorMath") + coherence.operation = "LENGTH" + links.new(smooth_normal.outputs["Value"], coherence.inputs[0]) + weight = _multiply(group, weight, coherence.outputs["Value"]) + weight = _multiply(group, weight, region) + + difference = nodes.new("ShaderNodeVectorMath") + difference.operation = "SUBTRACT" + links.new(target, difference.inputs[0]) + links.new(position.outputs["Position"], difference.inputs[1]) + offset = nodes.new("ShaderNodeVectorMath") + offset.operation = "SCALE" + links.new(difference.outputs[0], offset.inputs[0]) + links.new(weight, offset.inputs[3]) + if movement is not None: + masked = nodes.new("ShaderNodeVectorMath") + masked.operation = "MULTIPLY" + links.new(offset.outputs[0], masked.inputs[0]) + masked.inputs[1].default_value = movement + offset = masked set_position = nodes.new("GeometryNodeSetPosition") - links.new(repeat_input.outputs["Geometry"], set_position.inputs["Geometry"]) - links.new(mix_position.outputs["Result"], set_position.inputs["Position"]) + links.new(current, set_position.inputs["Geometry"]) + links.new(offset.outputs[0], set_position.inputs["Offset"]) links.new(set_position.outputs["Geometry"], repeat_output.inputs["Geometry"]) + enabled = nodes.new("GeometryNodeSwitch") enabled.input_type = "GEOMETRY" - links.new(iteration_controls.outputs[iterations_name], enabled.inputs["Switch"]) + links.new(iterations, enabled.inputs["Switch"]) links.new(geometry, enabled.inputs["False"]) links.new(repeat_output.outputs["Geometry"], enabled.inputs["True"]) return enabled.outputs["Output"] diff --git a/tools/nodes/common/validity.py b/tools/nodes/common/validity.py index 010eef4..7833e58 100644 --- a/tools/nodes/common/validity.py +++ b/tools/nodes/common/validity.py @@ -1,21 +1,10 @@ -"""Remove invalid vertices and the wire edges left by point deletion.""" +"""Delete the faces the depth mask excludes.""" -def delete_invalid_points(group, geometry, invalid): +def delete_invalid_faces(group, geometry, invalid): nodes, links = group.nodes, group.links delete = nodes.new("GeometryNodeDeleteGeometry") - delete.domain, delete.mode = "POINT", "ALL" + delete.domain, delete.mode = "FACE", "ALL" links.new(geometry, delete.inputs["Geometry"]) links.new(invalid, delete.inputs["Selection"]) - neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors") - wire = nodes.new("FunctionNodeCompare") - wire.data_type, wire.operation = "INT", "EQUAL" - a = next(socket for socket in wire.inputs if socket.identifier == "A_INT") - b = next(socket for socket in wire.inputs if socket.identifier == "B_INT") - b.default_value = 0 - links.new(neighbors.outputs["Face Count"], a) - cleanup = nodes.new("GeometryNodeDeleteGeometry") - cleanup.domain, cleanup.mode = "EDGE", "ALL" - links.new(delete.outputs["Geometry"], cleanup.inputs["Geometry"]) - links.new(wire.outputs["Result"], cleanup.inputs["Selection"]) - return cleanup.outputs["Geometry"] + return delete.outputs["Geometry"] diff --git a/tools/nodes/groups/image_cutout_symmetry.py b/tools/nodes/groups/image_cutout_symmetry.py index ce8bcd7..82661c6 100644 --- a/tools/nodes/groups/image_cutout_symmetry.py +++ b/tools/nodes/groups/image_cutout_symmetry.py @@ -2,6 +2,8 @@ import bpy from mathutils import Matrix +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, @@ -9,10 +11,16 @@ from ..common.nodes import ( from ..common.depth_surface import _math from ..common.normal_map import ROTATION_ATTRIBUTE, FACE_ATTRIBUTE, AXIS_ATTRIBUTE from ..common.direction import symmetry_legacy_direction_to_canonical -from ..common.smoothing import edge_boundary_field +from ..common.smoothing import edge_boundary_field, pinned_smooth NODE_GROUP_NAME = "O Image Cutout Symmetry" +# 衔接带从轮廓向内覆盖的圈数。 +FILL_SMOOTH_RINGS = 4 + +# 对称面附近按中位边长的倍数降低法线贴图强度。 +SEAM_NORMAL_BAND = 4 + def _align_front(group, geometry, controls): """Align existing geometry with the fixed XY symmetry plane.""" @@ -162,25 +170,26 @@ def _mirror(group, geometry): return flip.outputs[0] -def _cut_fields(group, tolerance): - """Return point-domain seam and outline fields for the cut.""" +def _boundary_fields(group, tolerance): + """Return the folded front's boundary field and its on-plane seam.""" nodes, links = group.nodes, group.links boundary = evaluate_field(group, edge_boundary_field(nodes, links), 'FLOAT', 'POINT') position = nodes.new('GeometryNodeInputPosition') components = nodes.new('ShaderNodeSeparateXYZ') links.new(position.outputs[0], components.inputs[0]) - absolute = _math(group, 'ABSOLUTE', components.outputs['Y']) - - def split(side): - node = nodes.new('FunctionNodeBooleanMath') - node.operation = 'AND' - links.new(_math(group, 'GREATER_THAN', boundary, 0), node.inputs[0]) - links.new(side, node.inputs[1]) - return node.outputs[0] - - return split(_math(group, 'LESS_THAN', absolute, tolerance)), split( - _math(group, 'GREATER_THAN', absolute, tolerance) + seam = nodes.new('FunctionNodeBooleanMath') + seam.operation = 'AND' + links.new(_math(group, 'GREATER_THAN', boundary, 0), seam.inputs[0]) + links.new( + _math( + group, + 'LESS_THAN', + _math(group, 'ABSOLUTE', components.outputs['Y']), + tolerance, + ), + seam.inputs[1], ) + return boundary, seam.outputs[0] def _falloff(group, center, rings=2): @@ -192,35 +201,28 @@ def _falloff(group, center, rings=2): return _math(group, 'MAXIMUM', center, blur.outputs[0]) -def _apply_planar_relax(group, geometry, influence, smooth): - """Move points inside the plane by their blurred position, gated by influence.""" - nodes, links = group.nodes, group.links - position = nodes.new('GeometryNodeInputPosition') - blur = nodes.new('GeometryNodeBlurAttribute') - blur.data_type = 'FLOAT_VECTOR' - links.new(position.outputs[0], blur.inputs['Value']) - links.new(smooth, blur.inputs['Iterations']) - difference = nodes.new('ShaderNodeVectorMath') - difference.operation = 'SUBTRACT' - links.new(blur.outputs[0], difference.inputs[0]) - links.new(position.outputs[0], difference.inputs[1]) - in_plane = nodes.new('ShaderNodeVectorMath') - in_plane.operation = 'MULTIPLY' - in_plane.inputs[1].default_value = (.5, 0, .5) - links.new(difference.outputs[0], in_plane.inputs[0]) - scaled = nodes.new('ShaderNodeVectorMath') - scaled.operation = 'SCALE' - links.new(in_plane.outputs[0], scaled.inputs[0]) - links.new(influence, scaled.inputs[3]) - place = nodes.new('GeometryNodeSetPosition') - links.new(geometry, place.inputs['Geometry']) - links.new(scaled.outputs[0], place.inputs['Offset']) - return place.outputs[0] +def _relax_in_plane(group, geometry, region, iterations, pin_boundary): + """Relax points without leaving the symmetry plane.""" + return pinned_smooth( + group, + geometry, + iterations, + region, + weight=0.5, + movement=(1, 0, 1), + pin_boundary=pin_boundary, + ) def _relax_seam(group, geometry, seam_field, smooth): """Relax the cut seam in plane through a two-ring falloff.""" - return _apply_planar_relax(group, geometry, _falloff(group, seam_field), smooth) + return _relax_in_plane(group, geometry, _falloff(group, seam_field), smooth, pin_boundary=True) + + +def _relax_fill(group, geometry, boundary_field, smooth): + """Smooth the junction band the wall builds from, including the symmetry axis.""" + region = _falloff(group, boundary_field, FILL_SMOOTH_RINGS) + return _relax_in_plane(group, geometry, region, smooth, pin_boundary=False) def _extrude_boundary(group, geometry, selection, y_factor): @@ -271,7 +273,7 @@ def build_image_cutout_symmetry_group(): bool_input(group, 'Fill Sides', True, parent=options).description = 'Connect matching front and mirrored boundaries.' smooth = interface_socket(group, 'Fill Smooth', 'INPUT', 'NodeSocketInt', options) smooth.default_value, smooth.min_value, smooth.max_value = 2, 0, 16 - smooth.description = 'Smooth the outline and the wall it builds through a two-ring falloff. Zero keeps the raw outline.' + smooth.description = 'Smooth the band around the front boundary and the symmetry seam over four rings. Zero keeps the raw outline.' seam = interface_socket(group, 'Seam Smooth', 'INPUT', 'NodeSocketInt', options) seam.default_value, seam.min_value, seam.max_value = 2, 0, 16 seam.description = 'Smooth the cut seam inside the symmetry plane. Zero keeps the raw seam.' @@ -279,16 +281,21 @@ def build_image_cutout_symmetry_group(): nodes, links = group.nodes, group.links inputs = group_input(nodes, set()).outputs aligned = _align_front(group, inputs['Geometry'], inputs) - bounds = nodes.new('GeometryNodeBoundBox') - links.new(aligned, bounds.inputs['Geometry']) - size = nodes.new('ShaderNodeVectorMath') - size.operation = 'DISTANCE' - links.new(bounds.outputs['Min'], size.inputs[0]) - links.new(bounds.outputs['Max'], size.inputs[1]) - tolerance = _math(group, 'MULTIPLY', _math(group, 'MAXIMUM', size.outputs['Value'], 1e-8), 1e-7) + edge = nodes.new('GeometryNodeInputMeshEdgeVertices') + edge_length = nodes.new('ShaderNodeVectorMath') + edge_length.operation = 'DISTANCE' + links.new(edge.outputs['Position 1'], edge_length.inputs[0]) + links.new(edge.outputs['Position 2'], edge_length.inputs[1]) + statistics = nodes.new('GeometryNodeAttributeStatistic') + statistics.data_type, statistics.domain = 'FLOAT', 'EDGE' + links.new(aligned, statistics.inputs['Geometry']) + links.new(edge_length.outputs['Value'], statistics.inputs['Attribute']) + snap_tolerance = _math( + group, 'MULTIPLY', _math(group, 'MAXIMUM', statistics.outputs['Median'], 1e-6), 1e-6, + ) is_boundary = _boundary_edge_field(group) - seam_field, outline_field = _cut_fields(group, tolerance) - front = _fold_to_plane(group, aligned, tolerance) + boundary_field, seam_field = _boundary_fields(group, snap_tolerance) + front = _fold_to_plane(group, aligned, snap_tolerance) shade = nodes.new('GeometryNodeSetShadeSmooth') shade.domain = 'FACE' links.new(front, shade.inputs['Geometry']) @@ -298,9 +305,9 @@ def build_image_cutout_symmetry_group(): boundary.operation = 'AND' 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, tolerance), boundary.inputs[1]) + links.new(_off_plane_edge_field(group, snap_tolerance), boundary.inputs[1]) fill_smooth = _math(group, 'MULTIPLY', inputs['Fill Smooth'], inputs['Fill Sides']) - front = _apply_planar_relax(group, front, _falloff(group, outline_field), fill_smooth) + front = _relax_fill(group, front, boundary_field, fill_smooth) walled = _build_wall(group, front, boundary.outputs[0]) wall_shade = nodes.new('GeometryNodeSetShadeSmooth') wall_shade.domain = 'FACE' @@ -320,13 +327,37 @@ def build_image_cutout_symmetry_group(): 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) merge = nodes.new('GeometryNodeMergeByDistance') - links.new(tolerance, merge.inputs['Distance']) + merge.inputs['Distance'].default_value = 0.01 links.new(join.outputs[0], merge.inputs['Geometry']) + links.new(on_axis, merge.inputs['Selection']) geometry = store_float_attribute(group, merge.outputs[0], AXIS_ATTRIBUTE, 1., domain='FACE') orient = nodes.new('GeometryNodeTransform') orient.inputs['Rotation'].default_value = Matrix.Rotation(1.5707963267948966, 4, 'Z').to_euler() links.new(geometry, orient.inputs['Geometry']) - links.new(orient.outputs[0], nodes.new('NodeGroupOutput').inputs['Geometry']) + # Fade the material normal map near the symmetry plane so the weld shades smoother. + position = nodes.new('GeometryNodeInputPosition') + components = nodes.new('ShaderNodeSeparateXYZ') + links.new(position.outputs[0], components.inputs[0]) + ramp = nodes.new('ShaderNodeMapRange') + ramp.interpolation_type = 'SMOOTHSTEP' + ramp.clamp = True + links.new(_math(group, 'ABSOLUTE', components.outputs['X']), ramp.inputs['Value']) + ramp.inputs['From Min'].default_value = 0.0 + links.new(_math(group, 'MULTIPLY', statistics.outputs['Median'], SEAM_NORMAL_BAND), ramp.inputs['From Max']) + ramp.inputs['To Min'].default_value = 1.0 + ramp.inputs['To Max'].default_value = 0.0 + reduction = _math( + group, 'MAXIMUM', + read_float_attribute(group, NORMAL_REDUCTION_ATTRIBUTE_NAME), ramp.outputs['Result'], + ) + geometry = store_float_attribute( + group, orient.outputs[0], NORMAL_REDUCTION_ATTRIBUTE_NAME, reduction, + ) + links.new(geometry, nodes.new('NodeGroupOutput').inputs['Geometry']) prepare_node_group(group) return group diff --git a/tools/nodes/groups/image_depth_cutout.py b/tools/nodes/groups/image_depth_cutout.py index 530ef50..a81f52f 100644 --- a/tools/nodes/groups/image_depth_cutout.py +++ b/tools/nodes/groups/image_depth_cutout.py @@ -276,10 +276,9 @@ def _build_depth_surface(group, geometry, controls): ) geometry = remove_boundary_triangles(group, geometry) boundary = edge_boundary_field(group.nodes, group.links) - outline = evaluate_field(group, boundary, "BOOLEAN", "POINT") rim = evaluate_field(group, boundary, "FLOAT", "POINT") camera = build_surface_camera( - group, _position_field(group, image), corner_camera, cut_vertex, split, always_vertex=outline, + group, _position_field(group, image), corner_camera, cut_vertex, split, ) projection = project_depth_surface( group, geometry, camera, scale, controls.outputs["Reference Depth"], controls.outputs["Depth Scale"], diff --git a/tools/nodes/groups/image_depth_panorama.py b/tools/nodes/groups/image_depth_panorama.py index 21061cf..e58eb5b 100644 --- a/tools/nodes/groups/image_depth_panorama.py +++ b/tools/nodes/groups/image_depth_panorama.py @@ -4,7 +4,7 @@ import math import bpy -from ..common.validity import delete_invalid_points +from ..common.validity import delete_invalid_faces from ..common.depth_surface import ( _math, split_depth_surface, build_surface_camera, @@ -138,7 +138,7 @@ def build_image_depth_panorama_group(): mixed = _math(group, "MULTIPLY", any_invalid, _math(group, "LESS_THAN", face_invalid, 1)) protected = uv_map.outputs["Geometry"] - culled = delete_invalid_points(group, protected, any_invalid) + culled = delete_invalid_faces(group, protected, any_invalid) delete = nodes.new("GeometryNodeDeleteGeometry") delete.domain, delete.mode = "FACE", "ALL" links.new(protected, delete.inputs["Geometry"]) diff --git a/tools/nodes/groups/image_depth_plane.py b/tools/nodes/groups/image_depth_plane.py index d239151..e997e2a 100644 --- a/tools/nodes/groups/image_depth_plane.py +++ b/tools/nodes/groups/image_depth_plane.py @@ -2,7 +2,7 @@ import bpy -from ..common.validity import delete_invalid_points +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 @@ -107,7 +107,7 @@ def build_image_depth_plane_group(image_plane_group): _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") protected = plane.outputs["Geometry"] - culled = delete_invalid_points(group, protected, invalid.outputs["Result"]) + culled = delete_invalid_faces(group, protected, invalid.outputs["Result"]) cull_controls = _group_input(nodes, {"Depth Mask"}) cull = nodes.new("GeometryNodeSwitch") cull.input_type = "GEOMETRY" diff --git a/tools/nodes/groups/image_layer.py b/tools/nodes/groups/image_layer.py index aec8a2a..e3cf2d2 100644 --- a/tools/nodes/groups/image_layer.py +++ b/tools/nodes/groups/image_layer.py @@ -19,7 +19,7 @@ def build_image_layer_group(): input_sockets.insert(4, ('Object Space', 'NodeSocketBool')) input_sockets.insert(2, ('Alpha Fix', 'NodeSocketFloat')) options = interface.new_panel(name='Options', default_closed=True) - option_names = {'Alpha Fix', 'Normal Scale', 'Object Space'} + option_names = {'Alpha Fix', 'Object Space'} for name, socket_type in input_sockets: socket = interface.new_socket(name=name, in_out='INPUT', socket_type=socket_type, parent=options if name in option_names else None) if name in {'Alpha', 'Alpha Fix'}: