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.
This commit is contained in:
2026-09-16 16:30:56 +08:00
parent 7f69630371
commit c62ec2cd48
28 changed files with 880 additions and 173 deletions

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-16

View File

@@ -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。共享实现避免了两套重复复杂度。

View File

@@ -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 接口不变,不涉及依赖与打包。

View File

@@ -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 检查通过

View File

@@ -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 重建节点资产并运行相关测试

View File

@@ -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` 照常作用。

View File

@@ -6,7 +6,7 @@
- 对称节点的切口统一为「把越过对称面的顶点折叠到对称面,删除整体贴平的面与内部贴平边」,使零厚度与非零厚度输入的切边落在同一位置。
- 补面只连接不落在对称面上的边界边。落在对称面上的切口由镜像后的合并直接焊成接缝,不再生成侧壁。
- 新增 `Seam Smooth` 与重定义 `Fill Smooth`,都与上游 Boundary Smooth 同类:只移动面内分量、锁定对称轴,并按两圈渐变从切口环或界外轮廓向内部衰减。
- 新增 `Seam Smooth` 并重定义 `Fill Smooth`:两者都只移动面内分量、锁定对称轴;`Seam Smooth` 按两圈渐变松弛切口环,`Fill Smooth` 按四圈渐变松弛 front 的衔接带,锚点为界外轮廓与对称面上的切缝,因此对称轴上的点同样参与。
- 补面细分始终存在,不再以 `Fill Smooth > 0` 为开关;`Fill Smooth` 平滑界外轮廓,侧壁从平滑后的轮廓构建。
- 不引入布尔、裁切算子、细分或新增采样参数。

View File

@@ -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

View File

@@ -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 记录样例上折叠前越界量分布,作为折面跨度的验证证据

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-16

View File

@@ -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
无。

View File

@@ -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 创建入口、材质与依赖。

View File

@@ -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** 该点保持自身采样深度,输出坐标有限

View File

@@ -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` 及旧路径引用,确认最终差异只涉及本次范围

Binary file not shown.

View File

@@ -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

View File

@@ -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,
)
smooth_interior = _front_vertices(
smooth_points, smooth_faces, smooth_marker, lambda point: point[0] > tolerance
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"
)
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))
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"]
)
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):

View File

@@ -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():

View File

@@ -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")

View File

@@ -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)

View File

@@ -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")
links.new(cut_vertex, enabled.inputs["True"])
combined = nodes.new("FunctionNodeBooleanMath")
combined.operation = "OR"
group.links.new(enabled.outputs["Output"], combined.inputs[0])
group.links.new(always_vertex, combined.inputs[1])
links.new(enabled.outputs["Output"], combined.inputs[0])
links.new(cut_boundary, combined.inputs[1])
enabled = combined.outputs["Boolean"]
else:
enabled = enabled.outputs["Output"]
original_axes = group.nodes.new("ShaderNodeSeparateXYZ")
separated_axes = group.nodes.new("ShaderNodeSeparateXYZ")
group.links.new(original, original_axes.inputs[0])

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -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

View File

@@ -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"],

View File

@@ -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"])

View File

@@ -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"

View File

@@ -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'}: