Refactor depth node groups and simplify options
- Removed `Smooth Weight` input from `O Image Depth Plane`, `O Image Depth Cutout`, and `O Image Depth Panorama` to streamline the interface and maintain consistent boundary smoothing behavior. - Adjusted the `Reference Depth` input to appear last in the options panel for better organization, reflecting its role as a calibration value. - Consolidated the `expand_smooth()` function to only accept necessary parameters, eliminating unused branches related to `Smooth Weight` and `pin_sharp`. - Updated related tests and node assets to ensure functionality remains intact and consistent with the new structure. - Introduced new specifications to document the changes and ensure compliance with the updated requirements.
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-16
|
||||
72
openspec/changes/refine-cutout-symmetry-seam/design.md
Normal file
72
openspec/changes/refine-cutout-symmetry-seam/design.md
Normal file
@@ -0,0 +1,72 @@
|
||||
## Context
|
||||
|
||||
`tools/nodes/groups/image_cutout_symmetry.py` 当前先对齐方向、按 `Offset` 与 `Scale` 成形,再用 `_delete_positive_faces` 删除越过对称面的几何,最后镜像、补侧壁并合并。
|
||||
|
||||
两处现状造成中缝杂乱:
|
||||
|
||||
- `_delete_positive_faces` 把 POINT 域的越界布尔求值到 FACE 域再取 `> 0`,等价于「任一顶点越界即删除整个面」。保留区整体退让,退让量随局部深度梯度逐处变化,切口不落在对称面上。
|
||||
- `build` 里的补面边界取「只有一个相邻面的边」,没有排除切口那条。直壁模式下每条切口边生成宽度等于两倍退让量的侧壁,宽度逐边不同且平面着色;`_walls` 的平滑只按 `0.5 / 0` 比例写入新增环的对称轴坐标,并用 `(.5, 0, .5)` 掩码只移动面内分量,因此无法消除宽度差。
|
||||
|
||||
同一节点曾用布尔原型验证过正确构成:`C:/Users/icrdr/AppData/Local/Temp/anyimage-car-symmetry/close_projected.py` 的补面选择为「边界边且位于界内一侧」,切口依靠合并自焊,验证 `open_edges = 0`、`nonmanifold_edges = 0`。本轮用面删除加边缘归零在无布尔的条件下复现该构成。
|
||||
|
||||
约束:不引入布尔或裁切算子;不使用 Capture Attribute;切边平滑只改面内分量,对称轴坐标保持为零。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:** 切口严格落在对称面上并由镜像合并自焊,零厚度与非零厚度输入走同一条折叠路径;补面只覆盖外轮廓;切口环由独立参数按两圈渐变平滑;节点资产名不变。
|
||||
|
||||
**Non-Goals:** 精确裁切、布尔实现、细分加密、外轮廓侧壁的形态重设计、既有 `.blend` 内嵌节点的自动迁移。
|
||||
|
||||
## Decisions
|
||||
|
||||
### 开口薄片:折叠过界部分
|
||||
|
||||
薄片的做法是把配平到对称面上的工作量压到最小:只删除越过对称面的顶点所对应的折叠结果,不预先删除与之相连的面。
|
||||
|
||||
| 顶点 | 处理 |
|
||||
| --- | --- |
|
||||
| 越过对称面 | 保留,对称轴坐标归零 |
|
||||
| 界内 | 保留,不改位置 |
|
||||
| 归零后整面落在对称面上 | 该面删除 |
|
||||
|
||||
跨界面的顶点被拉回平面而不是被删除,因此保留的面比整面删除多(同一 33×33 薄片样例:3356 面对 3044 面),界内顶点完全不动,形变只发生在跨界面上;切口边缘写死为 0,镜像后两侧严格重合,合并即焊成接缝。
|
||||
|
||||
### 统一折叠,并清理内部贴平边
|
||||
|
||||
闭合壳体(非零厚度输入)同样折叠,折叠会在壳体边缘留下「内部贴平边」:一条落在对称面上的边仍邻接两个面,镜像后这条边会变成四面共边。这类边不是有效切口,而是折叠后两层表面叠在一起形成的非法退化,因此统一清理掉贴平边邻接的面,让切口在此重新成为边界边,再与镜像焊接。这样不按输入是否闭合分支,零厚度与非零厚度共用一条路径;测试壳体上非流形边从 4–6 条降为 0。
|
||||
|
||||
清理用 `InputMeshEdgeVertices` 判断边两端都在对称面上,用 `InputMeshEdgeNeighbors` 判断边仍邻接两个面,再经 `CornersOfFace` / `EdgesOfCorner` / `FieldAtIndex` 把边的非法性聚合到面并删除。布尔裁切精度最高,但既有对照测量为 5–7 倍耗时且依赖输入流形,不采用。
|
||||
|
||||
实现只依赖逐点与逐边求值。这里不能用「把 POINT 域布尔求值到 FACE 域再判断」的写法:函数节点链在不同域上按该元素自身的位置求值,FACE 域得到的是面中心是否越界,而不是任一顶点越界,部分越界的面会既没被删除也进不了切口环。判断面是否整体贴平面时改为「面中心贴平面且面法线沿对称轴」,避免把斜跨对称面的面一起删掉。
|
||||
|
||||
### 补面只覆盖不落在对称面上的边界边
|
||||
|
||||
边界判据保持「只有一个相邻面」,再与「顶点越出对称面容差」求交,判断按顶点而非边的平均位置。落在对称面上的切口环在合并后已是共享边,再生成侧壁只会得到退化面或三面共享边。
|
||||
|
||||
### 两个两圈渐变平滑
|
||||
|
||||
`Seam Smooth` 与 `Fill Smooth` 语义对齐 `Boundary Smooth`:都只移动面内分量、锁定对称轴,并都把中心字段模糊两圈形成渐变。
|
||||
|
||||
`Seam Smooth` 作用于折叠后的切口环,减少切边的面内量化折线;`Fill Smooth` 作用于界外轮廓,即补面的外侧端点,按两圈渐变把轮廓向内部松弛,随后从平滑后的轮廓构建侧壁,因此侧壁自然跟随轮廓。补面的细分始终存在,不再以 `Fill Smooth > 0` 为开关;`Fill Smooth = 0` 只表示不平滑。
|
||||
|
||||
两个平滑都在镜像之前完成,两半保持一致,且都与 Fill Sides 无关:关闭补面时 `Fill Smooth` 没有轮廓可作用,`Seam Smooth` 照常作用。
|
||||
|
||||
### 折叠与清理属于几何正确性,与 Fill Sides 无关
|
||||
|
||||
Fill Sides 关闭时跳过侧壁与侧壁平滑,折叠、贴平面片清理与切边平滑照常执行,否则两半会互相穿插。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- 跨界面被拉向对称面,界内顶点不移动 → 用样例记录越界量分布,确认参考深度切得较深时形变可接受。
|
||||
- 切口环的面内折线仍是网格分辨率级近似 → 由 Seam Smooth 与上游 Boundary Smooth 共同改善,测试明确其为近似边界。
|
||||
- 壳体折叠后的非法贴平边由清理移除,缝隙在有 Fill Sides 时由侧壁补上 → 测试断言无越过对称面的顶点、无四面共边、切口自焊。
|
||||
- 参考深度把整片几何置于界外时输出为空 → 沿用现有空结果处理与测试。
|
||||
- 源码与资产不同步 → 修改后运行 `uv run node-group build`,源码、测试与 `.blend` 一并更新。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
直接替换删除与补面逻辑,不保留旧路径或开关;重新构建节点资产;不重写用户工程中的旧节点组。新增 `Seam Smooth` 的默认值为 2,与既有默认观感一致;用户工程中的旧节点实例沿用保存时的接口。回滚时恢复本次构建函数与测试并重建资产。
|
||||
|
||||
## Open Questions
|
||||
|
||||
无阻塞决策。若试作后切边形变在参考深度切入较深时仍显眼,再把归零目标改为平行于对称面的一个偏移面作为后续选项。
|
||||
28
openspec/changes/refine-cutout-symmetry-seam/proposal.md
Normal file
28
openspec/changes/refine-cutout-symmetry-seam/proposal.md
Normal file
@@ -0,0 +1,28 @@
|
||||
## Why
|
||||
|
||||
`O Image Cutout Symmetry` 在补面之前按「任一顶点越过对称面即删除整个面」切掉界外几何,保留区因此整体退让,退让量随局部深度梯度逐处不同。补面随后不得不把这条不共面的切口也连起来,生成的侧壁宽度逐边变化且平面着色,Fill Smooth 只柔化新增环的面内位置,无法消除宽度差;对称对象在中缝处出现一圈明显杂乱的面。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 对称节点的切口统一为「把越过对称面的顶点折叠到对称面,删除整体贴平的面与内部贴平边」,使零厚度与非零厚度输入的切边落在同一位置。
|
||||
- 补面只连接不落在对称面上的边界边。落在对称面上的切口由镜像后的合并直接焊成接缝,不再生成侧壁。
|
||||
- 新增 `Seam Smooth` 与重定义 `Fill Smooth`,都与上游 Boundary Smooth 同类:只移动面内分量、锁定对称轴,并按两圈渐变从切口环或界外轮廓向内部衰减。
|
||||
- 补面细分始终存在,不再以 `Fill Smooth > 0` 为开关;`Fill Smooth` 平滑界外轮廓,侧壁从平滑后的轮廓构建。
|
||||
- 不引入布尔、裁切算子、细分或新增采样参数。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `cutout-symmetry-seam`: 对称节点切口与补面的边界语义,包括切口落在对称面上、镜像自焊、补面只覆盖外轮廓,以及独立的切边平滑参数。
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
无。`openspec/specs` 尚无可修改的已归档规格,已完成的 `compose-depth-cutout-symmetry` 仍未归档。
|
||||
|
||||
## Impact
|
||||
|
||||
- `tools/nodes/groups/image_cutout_symmetry.py`:删除、吸附与补面选择。
|
||||
- `tests/tools/nodes/test_cutout_symmetry.py`:切口位置、封闭性与属性断言。
|
||||
- `src/anyimage/assets/O_AnyImage.blend`:重新构建节点资产。
|
||||
- 不改动 `O Image Depth Cutout`、Cutout 创建入口、材质或依赖。
|
||||
@@ -0,0 +1,96 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 切口落在对称面上
|
||||
|
||||
对称节点 SHALL 让切口边缘落在对称面上,零厚度与非零厚度输入走同一条折叠路径:越过对称面的顶点 SHALL 拉回对称面,只删除因此整体落在对称面上的面,并删除折叠后仍邻接两个面的内部贴平边及其邻接面。保留几何中不得存在越过对称面的顶点,也不得留下整体落在对称面上的面或四面共边。
|
||||
|
||||
#### Scenario: 折叠过界部分
|
||||
|
||||
- **WHEN** 输入几何的一部分穿过对称面
|
||||
- **THEN** 跨界面的面保留,越界顶点对称轴坐标归零,界内顶点位置不变
|
||||
- **AND** 保留几何中不存在越过对称面的顶点
|
||||
|
||||
#### Scenario: 清理内部贴平边
|
||||
|
||||
- **WHEN** 折叠后出现落在对称面上、且仍邻接两个面的边
|
||||
- **THEN** 这条边及其邻接面被删除,切口重新成为边界边
|
||||
- **AND** 输出中不存在四面共边
|
||||
|
||||
#### Scenario: 不同厚度得到同一切边位置
|
||||
|
||||
- **WHEN** 同一 Cutout 分别以零厚度与非零厚度连接到对称节点
|
||||
- **THEN** 两种情况都折叠并把切口边缘归零,切边落在对称面上
|
||||
- **AND** 两侧顶点关于对称面对称,且都不越过对称面的几何尺度派生容差
|
||||
|
||||
#### Scenario: 裁切后不留平面面片
|
||||
|
||||
- **WHEN** 切口边缘归零使某个面完全落到对称面上,或输入本身存在这类面
|
||||
- **THEN** 该面不出现在输出中
|
||||
|
||||
#### Scenario: 几何全部位于界外
|
||||
|
||||
- **WHEN** 输入几何整体越过对称面
|
||||
- **THEN** 输出为空几何,节点不报错
|
||||
|
||||
### Requirement: 接缝由镜像合并自焊
|
||||
|
||||
对称节点 SHALL 在镜像之前完成切口归零,使两侧切口顶点重合,并 SHALL 在合并后形成共享边。开启 Fill Sides 时输出网格 SHALL 封闭,且边与顶点流形。
|
||||
|
||||
#### Scenario: 开启补面的封闭结果
|
||||
|
||||
- **WHEN** Fill Sides 开启且输入包含跨对称面的几何
|
||||
- **THEN** 输出的每条边恰好邻接两个面,顶点与边满足流形性,面绕序一致
|
||||
- **AND** 两侧顶点关于对称面对称
|
||||
|
||||
#### Scenario: 关闭补面
|
||||
|
||||
- **WHEN** Fill Sides 关闭
|
||||
- **THEN** 切口仍吸附到对称面并完成自焊
|
||||
- **AND** 开边只出现在没有侧壁的外轮廓处
|
||||
|
||||
### Requirement: 补面只覆盖不落在对称面上的边界边
|
||||
|
||||
Fill Sides 生成的侧壁 SHALL 只连接不落在对称面上的边界边,落在对称面上的切口环 SHALL 不生成侧壁几何。补面选择 SHALL 按顶点是否越过对称面容差判断。
|
||||
|
||||
#### Scenario: 切口处不生成侧壁
|
||||
|
||||
- **WHEN** Fill Sides 开启且切口环顶点归零后落在对称面上
|
||||
- **THEN** 输出中不存在顶点全部落在对称面上的补面面片
|
||||
- **AND** 切口环在合并后为共享边
|
||||
|
||||
#### Scenario: 外轮廓仍生成侧壁
|
||||
|
||||
- **WHEN** 输入外轮廓的边界顶点不落在对称面上
|
||||
- **THEN** 这些边界按现有直壁或平滑侧壁构成连接
|
||||
- **AND** Fill Sides 关闭时不生成侧壁
|
||||
|
||||
### Requirement: 切边平滑独立控制
|
||||
|
||||
`Seam Smooth` SHALL 控制切口环的面内平滑次数,`Fill Smooth` SHALL 控制界外轮廓的面内平滑次数。两者都只移动面内分量并锁定对称轴坐标,影响范围按两圈渐变从中心向内部衰减。侧壁细分 SHALL 始终存在,不以 `Fill Smooth > 0` 为开关。`Seam Smooth` 为 0 SHALL 保持原始切口,`Fill Smooth` 为 0 SHALL 保持原始轮廓;两个参数 SHALL 互不影响,且都与 Fill Sides 独立。
|
||||
|
||||
#### Scenario: 切边平滑保持切口落在对称面
|
||||
|
||||
- **WHEN** Seam Smooth 大于 0
|
||||
- **THEN** 切口环顶点的对称轴坐标保持在容差内,镜像对称关系成立
|
||||
- **AND** 平滑位移从切口环起按两圈渐变衰减,两圈之外保留表面顶点位置不变
|
||||
|
||||
#### Scenario: 两个平滑参数互不影响
|
||||
|
||||
- **WHEN** 只改变 Fill Smooth 或只改变 Seam Smooth
|
||||
- **THEN** 前者只改变界外轮廓及其侧壁,后者只改变切口环及其两圈渐变范围
|
||||
- **AND** Seam Smooth 为 0 时切口保持原始形态,Fill Smooth 为 0 时轮廓保持原始形态且侧壁仍保持细分
|
||||
|
||||
#### Scenario: 关闭补面仍可平滑切边
|
||||
|
||||
- **WHEN** Fill Sides 关闭且 Seam Smooth 大于 0
|
||||
- **THEN** 切口环仍按该参数平滑,侧壁不生成,Fill Smooth 无可作用的轮廓
|
||||
|
||||
### Requirement: 节点不引入布尔与裁切
|
||||
|
||||
对称节点 SHALL 沿用现有节点集合完成切口,不新增布尔、裁切或细分节点。
|
||||
|
||||
#### Scenario: 检查构建结果
|
||||
|
||||
- **WHEN** 检查构建出的 `O Image Cutout Symmetry` 节点组
|
||||
- **THEN** 节点图中不存在布尔类节点
|
||||
- **AND** 接口在原有输入之外只新增 Seam Smooth
|
||||
23
openspec/changes/refine-cutout-symmetry-seam/tasks.md
Normal file
23
openspec/changes/refine-cutout-symmetry-seam/tasks.md
Normal file
@@ -0,0 +1,23 @@
|
||||
## 1. 切口与补面构建
|
||||
|
||||
- [x] 1.1 用「越界顶点折叠到对称面」替换 `_delete_positive_faces`,删除整体贴平面的面
|
||||
- [x] 1.2 删除折叠后仍邻接两个面的内部贴平边及其邻接面
|
||||
- [x] 1.3 把补面边界判据改为「只有一个相邻面且不落在对称面上」
|
||||
- [x] 1.4 新增 Seam Smooth 按两圈渐变平滑切口环,Fill Smooth 收窄为侧壁内部
|
||||
- [x] 1.5 保持 FACE、ROTATION、AXIS 属性写入点与最终朝向不变
|
||||
- [x] 1.6 零厚度与非零厚度输入走同一条折叠路径,不按闭合性分支
|
||||
|
||||
## 2. 节点测试
|
||||
|
||||
- [ ] 2.1 断言输出不存在越过对称面的顶点、整体贴平面的面或四面共边
|
||||
- [ ] 2.2 断言 Fill Sides 开启时网格封闭、边与顶点流形、镜像成立,并覆盖零厚度与非零厚度输入
|
||||
- [ ] 2.3 断言 Fill Sides 关闭时切口自焊,落在对称面上的边没有开边
|
||||
- [ ] 2.4 断言 Seam Smooth 大于 0 时切口对称轴坐标不漂移,且按两圈渐变影响切口邻域
|
||||
- [ ] 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.4 记录样例上折叠前越界量分布,作为折面跨度的验证证据
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-16
|
||||
42
openspec/changes/simplify-depth-group-options/design.md
Normal file
42
openspec/changes/simplify-depth-group-options/design.md
Normal file
@@ -0,0 +1,42 @@
|
||||
## Context
|
||||
|
||||
`O Image Depth Plane`、`O Image Depth Cutout`、`O Image Depth Panorama` 共用 `common/boundary_smoothing.py` 的 `smooth_cut_boundary()`,最终调用 `common/smoothing.py` 的 `expand_smooth()`。`expand_smooth()` 目前支持 `weight_name`、`pin_sharp` 与无影响场三种形态,但边界平滑只使用「影响场 + 固定权重」这一种。三个节点组把该权重公开为 `Smooth Weight`,默认值始终为 1。
|
||||
|
||||
Options 面板当前的 `Reference Depth` 排在首位,而该值是服务端标定结果,只有更换深度尺度时才需要手工调整。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- 三个深度节点组不再公开 `Smooth Weight`,边界平滑保持默认强度下的现有结果。
|
||||
- `expand_smooth()` 只保留实际使用的调用形态。
|
||||
- 深度与浮雕节点组的 Options 中 `Reference Depth` 位于最后一项。
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- 不改变边界平滑的影响带宽度、迭代次数、保护规则和轮廓权重。
|
||||
- 不改变 `Reference Depth` 的几何含义、初始化来源与取值范围。
|
||||
- 不为已保存的 `Smooth Weight` 值提供兼容入口。
|
||||
|
||||
## Decisions
|
||||
|
||||
### 边界平滑权重固定为 1
|
||||
|
||||
权重只作为位置混合系数与影响场相乘,默认值 1 意味着混合完全由影响场决定。直接删除该输入并把混合系数接到影响场,节点图少一层乘法,结果与默认参数一致。
|
||||
|
||||
### `expand_smooth()` 收敛为单一形态
|
||||
|
||||
唯一调用方传入影响场且不平滑法线,因此删除 `weight_name`、`pin_sharp` 分支与 `influence=None` 分支,签名收敛为 `expand_smooth(group, geometry, iterations_name, influence)`。相比保留未用参数,直接删除可避免后续再出现同类权重输入。
|
||||
|
||||
### `Reference Depth` 放到 Options 末尾
|
||||
|
||||
三个组的 Options 依次为:Depth Plane `Boundary Smooth`、`Mask Threshold`、`Reference Depth`;Depth Cutout `Boundary Smooth`、`Normal Smooth`、`Front Inflation`、`Reference Depth`;Relief Plane `Mask Threshold`、`Reference Depth`。标定输入不再挤占艺术调整参数的位置。
|
||||
|
||||
### 测试用 `Boundary Smooth` 与影响带判断替代权重隔离
|
||||
|
||||
原先用 `Smooth Weight = 0` 关闭位置平滑的用例,改为 `Boundary Smooth = 0` 的直通路径,或把断言限制在影响带之外的点。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [移除权重后无法再单独关闭位置平滑] → 位置平滑与迭代次数同属一次边界平滑,`Boundary Smooth = 0` 已是完整的关闭路径。
|
||||
- [已保存场景的 Modifier 仍带 `Smooth Weight`] → 已保存数值按原样保留,该输入不再影响求值,重新生成几何时按默认强度构建。
|
||||
26
openspec/changes/simplify-depth-group-options/proposal.md
Normal file
26
openspec/changes/simplify-depth-group-options/proposal.md
Normal file
@@ -0,0 +1,26 @@
|
||||
## Why
|
||||
|
||||
`Smooth Weight` 在三个深度节点组中始终以 `1` 参与边界平滑,单独暴露只会增加接口与实现复杂度;`Reference Depth` 是标定值,用户极少调整,却占据 Options 的首位。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **BREAKING**:从 `O Image Depth Plane`、`O Image Depth Cutout`、`O Image Depth Panorama` 的公开接口移除 `Smooth Weight`,边界平滑固定使用完整强度。
|
||||
- 删除共享 smoothing 中只为 `Smooth Weight` 与 `pin_sharp` 服务的分支和节点。
|
||||
- 将 `O Image Depth Plane`、`O Image Depth Cutout`、`O Image Relief Plane` 的 Options 中 `Reference Depth` 移到面板最后一项。
|
||||
- 重建并验证 `O_AnyImage.blend`,同步节点接口清单与相关测试。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `depth-node-options`: 规定深度与浮雕几何节点组 Options 面板的公开控制项集合与排列顺序。
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
无。
|
||||
|
||||
## Impact
|
||||
|
||||
- 影响 `O Image Depth Plane`、`O Image Depth Cutout`、`O Image Depth Panorama`、`O Image Relief Plane` 的公开 modifier 接口。
|
||||
- 影响 `tools/nodes/groups`、`tools/nodes/common` 的边界平滑构建代码、节点资产与节点相关测试。
|
||||
- 现有依赖 `Smooth Weight` socket identifier 的 modifier 配置或调用方需要停止写入该输入;默认值下的几何结果保持不变。
|
||||
@@ -0,0 +1,47 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 深度节点组不公开 Smooth Weight
|
||||
|
||||
`O Image Depth Plane`、`O Image Depth Cutout`、`O Image Depth Panorama` SHALL 不在公开接口中提供 `Smooth Weight`,边界平滑 MUST 按完整影响场混合顶点位置。
|
||||
|
||||
#### Scenario: 检查节点组接口
|
||||
|
||||
- **WHEN** 读取三个节点组的输入接口
|
||||
- **THEN** 接口不存在 `Smooth Weight`,且 `Boundary Smooth` 仍控制边界平滑迭代次数
|
||||
|
||||
#### Scenario: 默认边界平滑结果
|
||||
|
||||
- **WHEN** 用户保持默认参数并提高 `Boundary Smooth`
|
||||
- **THEN** 边界带内的顶点按影响场强度移动到平滑位置,影响带外的顶点保持不变
|
||||
|
||||
### Requirement: 共享平滑构建只保留影响场形态
|
||||
|
||||
`expand_smooth()` SHALL 只接受迭代次数输入、影响场与几何,并 MUST 在迭代次数为零时原样返回输入几何。
|
||||
|
||||
#### Scenario: 迭代次数为零
|
||||
|
||||
- **WHEN** `Boundary Smooth` 为 0
|
||||
- **THEN** 输出几何与输入几何完全一致
|
||||
|
||||
### Requirement: Options 中的 Reference Depth 位于最后
|
||||
|
||||
包含 `Reference Depth` 的节点组 SHALL 在 Options 面板最后一项提供该输入,其余 Options 控制项 MUST 保持各自职责与默认值。
|
||||
|
||||
#### Scenario: 检查 Depth Plane 与 Depth Cutout 排列
|
||||
|
||||
- **WHEN** 读取 `O Image Depth Plane` 与 `O Image Depth Cutout` 的 Options 项顺序
|
||||
- **THEN** 最后一项为 `Reference Depth`,之前的项依次为各自的艺术调整参数
|
||||
|
||||
#### Scenario: 检查 Relief Plane 排列
|
||||
|
||||
- **WHEN** 读取 `O Image Relief Plane` 的 Options 项顺序
|
||||
- **THEN** 项顺序为 `Mask Threshold`、`Reference Depth`
|
||||
|
||||
### Requirement: Reference Depth 保持既有几何含义
|
||||
|
||||
移动面板位置 SHALL 不改变 `Reference Depth` 的类型、子类型、默认值、范围与几何运算方式。
|
||||
|
||||
#### Scenario: 校验输入属性
|
||||
|
||||
- **WHEN** 读取 `Reference Depth` 输入
|
||||
- **THEN** 该输入使用 `DISTANCE` 子类型,并继续作为采样深度的运算基准
|
||||
15
openspec/changes/simplify-depth-group-options/tasks.md
Normal file
15
openspec/changes/simplify-depth-group-options/tasks.md
Normal file
@@ -0,0 +1,15 @@
|
||||
## 1. 共享平滑收敛
|
||||
|
||||
- [x] 1.1 将 `expand_smooth()` 收敛为影响场形态,删除 `weight_name`、`pin_sharp` 与无影响场分支
|
||||
- [x] 1.2 更新 `smooth_cut_boundary()` 调用并确认影响带、保护规则与轮廓权重不变
|
||||
|
||||
## 2. 节点组接口
|
||||
|
||||
- [x] 2.1 从 Depth Plane、Depth Cutout、Depth Panorama 删除 `Smooth Weight` 输入
|
||||
- [x] 2.2 将 Depth Plane、Depth Cutout、Relief Plane 的 `Reference Depth` 移到 Options 最后一项
|
||||
|
||||
## 3. 测试与资产
|
||||
|
||||
- [x] 3.1 更新节点库存、Options 排列与边界平滑行为测试,移除 `Smooth Weight` 依赖
|
||||
- [x] 3.2 运行 `uv run node-group build` 重建并验证节点资产
|
||||
- [x] 3.3 运行节点与 Operator 相关测试,检查残留引用与最终差异
|
||||
Binary file not shown.
@@ -23,7 +23,7 @@ from anyimage.server.geometry.depth_texture import (
|
||||
write_depth_texture,
|
||||
)
|
||||
from anyimage.server.models.geometry import GeometryFrame
|
||||
from tests.support.image_objects import _add_cutout_modifier, _create_cutout_shape, _depth_image, _depth_metadata, _evaluated_positions, _group_node
|
||||
from tests.support.image_objects import _create_cutout_shape, _depth_image, _depth_metadata, _evaluated_positions, _group_node
|
||||
|
||||
|
||||
def test_depth_result_image_loads_camera_exr_and_keeps_data_name(tmp_path):
|
||||
@@ -169,7 +169,6 @@ def test_depth_surface_object_evaluates(gesture):
|
||||
object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="DISTANCE"),
|
||||
0.0,
|
||||
)
|
||||
assert np.isclose(modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Smooth Weight")), 1.0)
|
||||
for name, value in (("Mode", 1), ("Front Inflation", 0.0)):
|
||||
object_data.set_modifier_input(modifier, object_data.modifier_input_identifier(modifier.node_group, name), value)
|
||||
scale_id = object_data.modifier_input_identifier(
|
||||
@@ -186,8 +185,8 @@ def test_depth_surface_object_evaluates(gesture):
|
||||
)
|
||||
object_data.set_modifier_input(
|
||||
modifier,
|
||||
object_data.modifier_input_identifier(modifier.node_group, "Smooth Weight"),
|
||||
0.0,
|
||||
object_data.modifier_input_identifier(modifier.node_group, "Boundary Smooth"),
|
||||
0,
|
||||
)
|
||||
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
baseline_mesh = evaluated.to_mesh()
|
||||
|
||||
@@ -91,6 +91,30 @@ def assert_closed(faces):
|
||||
assert edges and set(edges.values()) == {2}, Counter(edges.values())
|
||||
|
||||
|
||||
def assert_single_vertex_fans(faces):
|
||||
"""Every vertex must close one face fan so touching sheets stay identifiable."""
|
||||
incident = {}
|
||||
for index, face in enumerate(faces):
|
||||
for start, end in zip(face, (*face[1:], face[0])):
|
||||
edge = tuple(sorted((start, end)))
|
||||
incident.setdefault(start, {}).setdefault(edge, []).append(index)
|
||||
incident.setdefault(end, {}).setdefault(edge, []).append(index)
|
||||
for vertex, edges in incident.items():
|
||||
shared = {}
|
||||
for faces_at_edge in edges.values():
|
||||
assert len(faces_at_edge) == 2, (vertex, faces_at_edge)
|
||||
first, second = faces_at_edge
|
||||
shared.setdefault(first, set()).add(second)
|
||||
shared.setdefault(second, set()).add(first)
|
||||
assert all(len(neighbors) == 2 for neighbors in shared.values()), vertex
|
||||
reached, pending = {next(iter(shared))}, [next(iter(shared))]
|
||||
while pending:
|
||||
for neighbor in shared[pending.pop()] - reached:
|
||||
reached.add(neighbor)
|
||||
pending.append(neighbor)
|
||||
assert len(reached) == len(shared), vertex
|
||||
|
||||
|
||||
def component_count(faces):
|
||||
adjacency = {}
|
||||
for face in faces:
|
||||
|
||||
@@ -3,7 +3,6 @@ import numpy as np
|
||||
|
||||
|
||||
from anyimage.common import image as image_data
|
||||
from anyimage.common import object as object_data
|
||||
from anyimage.common.selection import SelectionMask
|
||||
from anyimage.operators.cutout_tool import geometry as cutout_geometry
|
||||
from anyimage.operators.cutout_tool import object as cutout_object
|
||||
@@ -107,18 +106,3 @@ def _evaluated_positions(obj):
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
|
||||
def _add_cutout_modifier(obj, shape):
|
||||
group = cutout_object.load_cutout_node_group(shape)
|
||||
modifier = obj.modifiers.new(group.name, "NODES")
|
||||
modifier.node_group = group
|
||||
if shape in {"FLAT", "DEPTH_SOLID"}:
|
||||
object_data.set_modifier_input(modifier, object_data.modifier_input_identifier(group, "Mode"), 1)
|
||||
if shape == "DEPTH_SOLID":
|
||||
object_data.set_modifier_input(
|
||||
modifier,
|
||||
object_data.modifier_input_identifier(group, "Smooth Weight"),
|
||||
0.0,
|
||||
)
|
||||
return modifier
|
||||
|
||||
|
||||
|
||||
@@ -90,7 +90,6 @@ def test_cutout_fine_outline_smooths_only_within_boundary_band():
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.update()
|
||||
set_value("Boundary Smooth", 0)
|
||||
set_value("Smooth Weight", 1)
|
||||
before, original_faces = evaluated(obj)
|
||||
set_value("Boundary Smooth", 12)
|
||||
after, result_faces = evaluated(obj)
|
||||
@@ -114,7 +113,7 @@ def test_plane_validity_hole_blurs_depth_and_protects_rectangle():
|
||||
pixels[1:3, 7:9, 2] = 12
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.update()
|
||||
for name, value in (("Subdivide", 5), ("Depth Scale", 1), ("Smooth Weight", 0)):
|
||||
for name, value in (("Subdivide", 5), ("Depth Scale", 1)):
|
||||
set_value(name, value)
|
||||
before, faces, uv, *_ = plane_mesh(obj)
|
||||
set_value("Boundary Smooth", 12)
|
||||
@@ -126,8 +125,11 @@ def test_plane_validity_hole_blurs_depth_and_protects_rectangle():
|
||||
corners = np.concatenate(faces)
|
||||
outer = np.unique(corners[protected])
|
||||
np.testing.assert_array_equal(after[outer], before[outer])
|
||||
np.testing.assert_allclose(after[:, [0, 2]] / (1.2 + after[:, 1:2]),
|
||||
before[:, [0, 2]] / (1.2 + before[:, 1:2]), atol=1e-6)
|
||||
# Position smoothing moves the cut band off its projection ray by design.
|
||||
band = edge_band(faces, boundary_neighbors(faces))
|
||||
kept = [i for i in range(len(before)) if i not in band]
|
||||
np.testing.assert_allclose(after[kept][:, [0, 2]] / (1.2 + after[kept][:, 1:2]),
|
||||
before[kept][:, [0, 2]] / (1.2 + before[kept][:, 1:2]), atol=1e-6)
|
||||
set_value("Depth Mask", False)
|
||||
intact = plane_mesh(obj)[0]
|
||||
set_value("Boundary Smooth", 0)
|
||||
@@ -144,7 +146,6 @@ def test_panorama_blurs_opening_radially_and_keeps_invalid_distance(region):
|
||||
spike = ((distance < 4) & (yy > 40) & (yy < 44)) if region == "seam" else ((yy > 50) & (xx < 8))
|
||||
depth = np.where(spike, 10., 3.)
|
||||
obj, set_value = panorama(depth, (~hole).astype(float), subdivide=5)
|
||||
set_value("Smooth Weight", 0)
|
||||
before, faces, uv = panorama_mesh(obj)
|
||||
set_value("Boundary Smooth", 12)
|
||||
after, result_faces, result_uv = panorama_mesh(obj)
|
||||
@@ -154,18 +155,28 @@ def test_panorama_blurs_opening_radially_and_keeps_invalid_distance(region):
|
||||
smoothed = np.linalg.norm(after, axis=1)
|
||||
rim = list(boundary_neighbors(faces))
|
||||
assert smoothed[rim].max() < radii[rim].max() - .1
|
||||
np.testing.assert_allclose(after / smoothed[:, None], before / radii[:, None], atol=1e-6)
|
||||
band = edge_band(faces, boundary_neighbors(faces))
|
||||
interior = [i for i in range(len(before)) if i not in band]
|
||||
assert interior
|
||||
# Position smoothing moves the opening rim off its radial direction by design.
|
||||
np.testing.assert_allclose(after[interior] / smoothed[interior, None],
|
||||
before[interior] / radii[interior, None], atol=1e-6)
|
||||
np.testing.assert_array_equal(after[interior], before[interior])
|
||||
set_value("Depth Mask", False)
|
||||
near = panorama_mesh(obj)[0]
|
||||
set_value("Invalid Distance", 250)
|
||||
far = panorama_mesh(obj)[0]
|
||||
far, far_faces, _ = panorama_mesh(obj)
|
||||
valid = np.linalg.norm(near, axis=1) < 50
|
||||
assert valid.any() and (~valid).any()
|
||||
np.testing.assert_array_equal(far[valid], near[valid])
|
||||
np.testing.assert_allclose(np.linalg.norm(far[~valid], axis=1), 250, atol=5e-5)
|
||||
# The smoothed rim blends into its neighbours, so only the interior keeps the exact distance.
|
||||
far_band = edge_band(far_faces, boundary_neighbors(far_faces))
|
||||
kept = [i for i in range(len(far)) if i not in far_band and not valid[i]]
|
||||
assert kept
|
||||
np.testing.assert_allclose(np.linalg.norm(far[kept], axis=1), 250, atol=5e-5)
|
||||
set_value("Depth Scale", 0)
|
||||
np.testing.assert_allclose(np.linalg.norm(panorama_mesh(obj)[0], axis=1), 1, atol=1e-6)
|
||||
sphere, sphere_faces, _ = panorama_mesh(obj)
|
||||
sphere_band = edge_band(sphere_faces, boundary_neighbors(sphere_faces))
|
||||
interior = [i for i in range(len(sphere)) if i not in sphere_band]
|
||||
assert interior
|
||||
np.testing.assert_allclose(np.linalg.norm(sphere[interior], axis=1), 1, atol=1e-6)
|
||||
|
||||
@@ -143,9 +143,6 @@ def test_smoothing_reduces_stairs_without_leaving_two_ring_band(triangles):
|
||||
after.append(np.linalg.norm(smoothed[adjacent].mean(axis=0) - smoothed[i]))
|
||||
assert sum(after) < 0.8 * sum(before)
|
||||
np.testing.assert_array_equal(smoothed[:, 1], original[:, 1])
|
||||
set_value("Smooth Weight", 0)
|
||||
np.testing.assert_array_equal(evaluated(obj)[0], original)
|
||||
set_value("Smooth Weight", 1)
|
||||
set_value("Boundary Smooth", 0)
|
||||
np.testing.assert_array_equal(evaluated(obj)[0], original)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from scipy.spatial import cKDTree
|
||||
|
||||
from anyimage.common.object import modifier_input_identifier, set_modifier_input
|
||||
from anyimage.operators.cutout_tool.object import load_cutout_node_group
|
||||
from tests.support.depth_surface import assert_closed, evaluated
|
||||
from tests.support.depth_surface import assert_closed, assert_single_vertex_fans, evaluated
|
||||
from tools.nodes.common.normal_map import AXIS_ATTRIBUTE, FACE_ATTRIBUTE, ROTATION_ATTRIBUTE
|
||||
from tools.nodes.groups.image_depth_cutout import build_image_depth_cutout_group
|
||||
from tools.nodes.groups.image_cutout_symmetry import build_image_cutout_symmetry_group
|
||||
@@ -100,6 +100,7 @@ def symmetry(request):
|
||||
|
||||
def test_symmetry_interface_and_cutout_defaults(symmetry):
|
||||
_obj, cutout, mirror, _cutout_setter, _mirror_setter = symmetry
|
||||
assert not any(node.bl_idname == "GeometryNodeMeshBoolean" for node in mirror.node_group.nodes)
|
||||
mirror_inputs = [
|
||||
item
|
||||
for item in mirror.node_group.interface.items_tree
|
||||
@@ -146,6 +147,102 @@ def test_final_symmetry_axis_is_x_and_z_is_up(symmetry):
|
||||
assert cKDTree(points).query(points * (-1, 1, 1))[0].max() < 1e-5
|
||||
assert np.ptp(points[:, 2]) > 1.0
|
||||
assert_closed(faces)
|
||||
assert_single_vertex_fans(faces)
|
||||
|
||||
|
||||
def _tolerance(points):
|
||||
size = float(np.linalg.norm(points.max(axis=0) - points.min(axis=0)))
|
||||
return max(size, 1e-8) * 1e-7
|
||||
|
||||
|
||||
def _evaluated(obj):
|
||||
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
mesh = result.to_mesh()
|
||||
try:
|
||||
points = np.array([vertex.co[:] for vertex in mesh.vertices])
|
||||
faces = [tuple(polygon.vertices) for polygon in mesh.polygons]
|
||||
marker = np.array([item.value for item in mesh.attributes[FACE_ATTRIBUTE].data])
|
||||
finally:
|
||||
result.to_mesh_clear()
|
||||
return points, faces, marker
|
||||
|
||||
|
||||
def _open_edges(faces):
|
||||
counts = Counter(
|
||||
tuple(sorted((a, b))) for f in faces for a, b in zip(f, (*f[1:], f[0]))
|
||||
)
|
||||
return [edge for edge, count in counts.items() if count == 1]
|
||||
|
||||
|
||||
def _marked_vertices(points, faces, marker, value):
|
||||
indices = {index for face, item in zip(faces, marker) if item == value for index in face}
|
||||
return points[sorted(indices)]
|
||||
|
||||
|
||||
def _front_vertices(points, faces, marker, predicate):
|
||||
return np.array(
|
||||
[point for point in _marked_vertices(points, faces, marker, 1) if predicate(point)]
|
||||
)
|
||||
|
||||
|
||||
def _positions(points):
|
||||
return {tuple(np.round(point, 6)) for point in points}
|
||||
|
||||
|
||||
def _front_rim(points, faces, marker, tolerance):
|
||||
return _front_vertices(points, faces, marker, lambda point: abs(point[0]) <= tolerance)
|
||||
|
||||
|
||||
def test_seam_clamps_geometry_onto_the_symmetry_plane(symmetry):
|
||||
obj, _cutout, _mirror, _cutout_setter, _mirror_setter = symmetry
|
||||
points, faces, marker = _evaluated(obj)
|
||||
tolerance = _tolerance(points)
|
||||
plane = np.abs(points[:, 0]) <= tolerance
|
||||
|
||||
assert _marked_vertices(points, faces, marker, 1)[:, 0].min() >= -tolerance
|
||||
assert _marked_vertices(points, faces, marker, 2)[:, 0].max() <= tolerance
|
||||
assert plane.any()
|
||||
assert not any(all(plane[index] for index in face) for face in faces)
|
||||
# Kept straddling faces fold onto the plane instead of being discarded.
|
||||
assert any(plane[list(face)].any() and not plane[list(face)].all() for face in faces)
|
||||
|
||||
|
||||
def test_disabled_fill_keeps_the_welded_seam(symmetry):
|
||||
obj, _cutout, _mirror, _cutout_setter, mirror_setter = symmetry
|
||||
mirror_setter("Fill Sides", False)
|
||||
points, faces = evaluated(obj)
|
||||
tolerance = _tolerance(points)
|
||||
plane = np.abs(points[:, 0]) <= tolerance
|
||||
open_edges = _open_edges(faces)
|
||||
|
||||
assert open_edges
|
||||
assert not any(plane[edge[0]] and plane[edge[1]] for edge in open_edges)
|
||||
|
||||
|
||||
def test_fill_smooth_only_relaxes_the_seam(symmetry):
|
||||
obj, _cutout, _mirror, _cutout_setter, mirror_setter = symmetry
|
||||
mirror_setter("Fill Sides", True)
|
||||
mirror_setter("Fill Smooth", 0)
|
||||
plain_points, plain_faces, plain_marker = _evaluated(obj)
|
||||
mirror_setter("Fill Smooth", 4)
|
||||
smooth_points, smooth_faces, smooth_marker = _evaluated(obj)
|
||||
tolerance = _tolerance(plain_points)
|
||||
|
||||
for points, faces in ((plain_points, plain_faces), (smooth_points, smooth_faces)):
|
||||
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
|
||||
)
|
||||
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))
|
||||
|
||||
|
||||
def test_object_normal_attributes_follow_final_orientation(symmetry):
|
||||
|
||||
@@ -132,7 +132,8 @@ def test_controls_and_material_slot_inheritance():
|
||||
assert inputs["Invalid Distance"].parent.name == "Options"
|
||||
assert inputs["Invalid Distance"].default_value == 100
|
||||
assert inputs["Invalid Distance"].subtype == "DISTANCE"
|
||||
assert inputs["Smooth Weight"].parent.name == "Options"
|
||||
assert inputs["Boundary Smooth"].parent.name == "Options"
|
||||
assert "Smooth Weight" not in inputs
|
||||
assert inputs["Depth Mask"].parent == inputs["Subdivide"].parent
|
||||
assert inputs["Depth Mask"].default_value
|
||||
obj.data.from_pydata([(0, 0, 0)], [], [])
|
||||
|
||||
@@ -18,7 +18,7 @@ def test_saved_parameter_descriptions_match_definitions():
|
||||
parameters = {
|
||||
"O Image Plane": {"Subdivide"},
|
||||
"O Image Depth Plane": {
|
||||
"Subdivide", "Thickness", "Depth Split", "Smooth Weight",
|
||||
"Subdivide", "Thickness", "Depth Split",
|
||||
"Depth Mask", "Mask Threshold", "Boundary Smooth",
|
||||
},
|
||||
"O Image Relief Plane": {
|
||||
@@ -26,14 +26,14 @@ def test_saved_parameter_descriptions_match_definitions():
|
||||
},
|
||||
"O Image Cutout": {"Thickness"},
|
||||
"O Image Depth Cutout": {
|
||||
"Thickness", "Depth Split", "Smooth Weight",
|
||||
"Thickness", "Depth Split",
|
||||
"Boundary Smooth", "Normal Smooth", "Front Inflation",
|
||||
},
|
||||
"O Image Cutout Symmetry": {
|
||||
"Direction", "Offset", "Scale", "Fill Sides", "Fill Smooth",
|
||||
},
|
||||
"O Image Depth Panorama": {
|
||||
"Subdivide", "Depth Scale", "Depth Split", "Smooth Weight",
|
||||
"Subdivide", "Depth Scale", "Depth Split",
|
||||
"Depth Mask", "Mask Threshold", "Boundary Smooth", "Invalid Distance",
|
||||
},
|
||||
"O Image Layer": {"Alpha Fix", "Normal Scale", "Bump Scale", "Object Space"},
|
||||
@@ -65,8 +65,21 @@ def test_saved_parameter_descriptions_match_definitions():
|
||||
"O Image Depth Panorama", "O Image Relief Plane",
|
||||
}:
|
||||
assert "Smooth" not in input_names
|
||||
if group.name in {"O Image Cutout", "O Image Relief Plane"}:
|
||||
assert "Smooth Weight" not in input_names
|
||||
if group.name in {
|
||||
"O Image Depth Cutout", "O Image Depth Plane", "O Image Relief Plane",
|
||||
}:
|
||||
options = next(
|
||||
item for item in group.interface.items_tree
|
||||
if item.item_type == "PANEL" and item.name == "Options"
|
||||
)
|
||||
reference = next(item for item in input_items if item.name == "Reference Depth")
|
||||
assert reference.parent == options
|
||||
siblings = [
|
||||
item for item in group.interface.items_tree
|
||||
if item.parent == options and item.item_type == "SOCKET"
|
||||
]
|
||||
assert siblings[-1].name == "Reference Depth"
|
||||
if group.name in {"O Image Depth Plane", "O Image Depth Cutout"}:
|
||||
boundary = next(item for item in sockets if item.name == "Boundary Smooth")
|
||||
assert boundary.default_value == 4
|
||||
|
||||
@@ -92,12 +92,9 @@ 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):
|
||||
"""Blend O Smooth through split and outline masks with configurable weights."""
|
||||
"""Move boundary vertices toward the smoothed 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, iterations_name="Boundary Smooth", weight_name="Smooth Weight",
|
||||
influence=influence, pin_sharp=False,
|
||||
)
|
||||
return expand_smooth(group, geometry, "Boundary Smooth", influence)
|
||||
|
||||
@@ -40,14 +40,10 @@ def edge_boundary_field(nodes, links):
|
||||
return is_boundary.outputs["Boolean"]
|
||||
|
||||
|
||||
def expand_smooth(
|
||||
group, geometry, iterations_name, weight_name, *,
|
||||
influence=None, pin_sharp=True,
|
||||
):
|
||||
def expand_smooth(group, geometry, iterations_name, influence):
|
||||
nodes = group.nodes
|
||||
links = group.links
|
||||
iteration_controls = group_input(nodes, {iterations_name})
|
||||
weight_controls = group_input(nodes, {weight_name})
|
||||
repeat_output = nodes.new("GeometryNodeRepeatOutput")
|
||||
repeat_input = nodes.new("GeometryNodeRepeatInput")
|
||||
repeat_input.pair_with_output(repeat_output)
|
||||
@@ -69,18 +65,15 @@ def expand_smooth(
|
||||
links.new(geometry, repeat_input.inputs["Geometry"])
|
||||
nearest_boundary = nodes.new("GeometryNodeSampleNearest")
|
||||
nearest_boundary.domain = "POINT"
|
||||
if influence is not None:
|
||||
# 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"])
|
||||
else:
|
||||
links.new(boundary_geometry.outputs["Selection"], nearest_boundary.inputs["Geometry"])
|
||||
# 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")
|
||||
@@ -109,44 +102,19 @@ def expand_smooth(
|
||||
links.new(smooth_all.outputs["Value"], choose_boundary.inputs["False"])
|
||||
links.new(sample_boundary.outputs["Value"], choose_boundary.inputs["True"])
|
||||
|
||||
weight = weight_controls.outputs[weight_name]
|
||||
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"])
|
||||
normal_coherence = nodes.new("ShaderNodeVectorMath")
|
||||
normal_coherence.operation = "LENGTH"
|
||||
links.new(smooth_normal.outputs["Value"], normal_coherence.inputs[0])
|
||||
smooth_weight = nodes.new("ShaderNodeMath")
|
||||
smooth_weight.operation = "MULTIPLY"
|
||||
links.new(weight_controls.outputs[weight_name], smooth_weight.inputs[0])
|
||||
links.new(normal_coherence.outputs["Value"], smooth_weight.inputs[1])
|
||||
weight = smooth_weight.outputs[0]
|
||||
if influence is not None:
|
||||
masked_weight = nodes.new("ShaderNodeMath")
|
||||
masked_weight.operation = "MULTIPLY"
|
||||
links.new(weight, masked_weight.inputs[0])
|
||||
links.new(influence, masked_weight.inputs[1])
|
||||
weight = masked_weight.outputs[0]
|
||||
|
||||
current_position = nodes.new("GeometryNodeInputPosition")
|
||||
mix_position = nodes.new("ShaderNodeMix")
|
||||
mix_position.data_type = "VECTOR"
|
||||
links.new(weight, mix_position.inputs[0])
|
||||
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])
|
||||
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(set_position.outputs["Geometry"], repeat_output.inputs["Geometry"])
|
||||
if influence is not None:
|
||||
enabled = nodes.new("GeometryNodeSwitch")
|
||||
enabled.input_type = "GEOMETRY"
|
||||
links.new(iteration_controls.outputs[iterations_name], enabled.inputs["Switch"])
|
||||
links.new(geometry, enabled.inputs["False"])
|
||||
links.new(repeat_output.outputs["Geometry"], enabled.inputs["True"])
|
||||
return enabled.outputs["Output"]
|
||||
return repeat_output.outputs["Geometry"]
|
||||
enabled = nodes.new("GeometryNodeSwitch")
|
||||
enabled.input_type = "GEOMETRY"
|
||||
links.new(iteration_controls.outputs[iterations_name], enabled.inputs["Switch"])
|
||||
links.new(geometry, enabled.inputs["False"])
|
||||
links.new(repeat_output.outputs["Geometry"], enabled.inputs["True"])
|
||||
return enabled.outputs["Output"]
|
||||
|
||||
@@ -9,6 +9,7 @@ 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
|
||||
|
||||
NODE_GROUP_NAME = "O Image Cutout Symmetry"
|
||||
|
||||
@@ -55,20 +56,101 @@ def _align_front(group, geometry, controls):
|
||||
return store.outputs['Geometry']
|
||||
|
||||
|
||||
def _delete_positive_faces(group, front, tolerance):
|
||||
"""Discard faces touching positive Y without leaving wires or isolated vertices."""
|
||||
position = group.nodes.new('GeometryNodeInputPosition')
|
||||
components = group.nodes.new('ShaderNodeSeparateXYZ')
|
||||
group.links.new(position.outputs[0], components.inputs[0])
|
||||
positive = _math(group, 'GREATER_THAN', components.outputs['Y'], tolerance)
|
||||
point_positive = evaluate_field(group, positive, 'FLOAT', 'POINT')
|
||||
face_positive = evaluate_field(group, point_positive, 'FLOAT', 'FACE')
|
||||
delete = group.nodes.new('GeometryNodeDeleteGeometry')
|
||||
delete.domain = 'FACE'
|
||||
delete.mode = 'ALL'
|
||||
group.links.new(front, delete.inputs['Geometry'])
|
||||
group.links.new(_math(group, 'GREATER_THAN', face_positive, 0), delete.inputs['Selection'])
|
||||
return delete.outputs[0]
|
||||
def _boundary_edge_field(group):
|
||||
"""Report one on edges that carry a single face."""
|
||||
neighbors = group.nodes.new('GeometryNodeInputMeshEdgeNeighbors')
|
||||
return _math(group, 'COMPARE', neighbors.outputs['Face Count'], 1)
|
||||
|
||||
|
||||
def _fold_to_plane(group, geometry, tolerance):
|
||||
"""Move over-plane points onto the plane and drop the faces that collapse."""
|
||||
nodes, links = group.nodes, group.links
|
||||
position = nodes.new('GeometryNodeInputPosition')
|
||||
components = nodes.new('ShaderNodeSeparateXYZ')
|
||||
links.new(position.outputs[0], components.inputs[0])
|
||||
beyond = _math(group, 'GREATER_THAN', components.outputs['Y'], tolerance)
|
||||
target = nodes.new('ShaderNodeCombineXYZ')
|
||||
links.new(components.outputs['X'], target.inputs['X'])
|
||||
links.new(components.outputs['Z'], target.inputs['Z'])
|
||||
place = nodes.new('GeometryNodeSetPosition')
|
||||
links.new(geometry, place.inputs['Geometry'])
|
||||
links.new(beyond, place.inputs['Selection'])
|
||||
links.new(target.outputs[0], place.inputs['Position'])
|
||||
# A face lying on the plane would be mirrored onto itself.
|
||||
center = evaluate_field(group, _math(group, 'ABSOLUTE', components.outputs['Y']), 'FLOAT', 'FACE')
|
||||
normal = nodes.new('GeometryNodeInputNormal')
|
||||
normal_axes = nodes.new('ShaderNodeSeparateXYZ')
|
||||
links.new(normal.outputs[0], normal_axes.inputs[0])
|
||||
flat = nodes.new('FunctionNodeBooleanMath')
|
||||
flat.operation = 'AND'
|
||||
links.new(_math(group, 'LESS_THAN', center, tolerance), flat.inputs[0])
|
||||
links.new(_math(group, 'GREATER_THAN', _math(group, 'ABSOLUTE', normal_axes.outputs['Y']), 1 - 1e-3), flat.inputs[1])
|
||||
delete = nodes.new('GeometryNodeDeleteGeometry')
|
||||
delete.domain, delete.mode = 'FACE', 'ALL'
|
||||
links.new(place.outputs[0], delete.inputs['Geometry'])
|
||||
links.new(flat.outputs[0], delete.inputs['Selection'])
|
||||
return _clean_crease(group, delete.outputs[0], tolerance)
|
||||
|
||||
|
||||
def _clean_crease(group, geometry, tolerance):
|
||||
"""Open interior in-plane edges left by folding a closed surface."""
|
||||
nodes, links = group.nodes, group.links
|
||||
heights = _edge_abs_y(group)
|
||||
edge_plane = nodes.new('FunctionNodeBooleanMath')
|
||||
edge_plane.operation = 'AND'
|
||||
links.new(_math(group, 'LESS_THAN', heights[0], tolerance), edge_plane.inputs[0])
|
||||
links.new(_math(group, 'LESS_THAN', heights[1], tolerance), edge_plane.inputs[1])
|
||||
neighbors = nodes.new('GeometryNodeInputMeshEdgeNeighbors')
|
||||
interior = _math(group, 'COMPARE', neighbors.outputs['Face Count'], 2)
|
||||
fold = nodes.new('FunctionNodeBooleanMath')
|
||||
fold.operation = 'AND'
|
||||
links.new(edge_plane.outputs[0], fold.inputs[0])
|
||||
links.new(interior, fold.inputs[1])
|
||||
|
||||
face_index = nodes.new('GeometryNodeInputIndex')
|
||||
corners = nodes.new('GeometryNodeCornersOfFace')
|
||||
links.new(face_index.outputs[0], corners.inputs['Face Index'])
|
||||
face_fold = None
|
||||
for offset in range(4):
|
||||
corner = nodes.new('GeometryNodeOffsetCornerInFace')
|
||||
links.new(corners.outputs['Corner Index'], corner.inputs['Corner Index'])
|
||||
corner.inputs['Offset'].default_value = offset
|
||||
edge_of_corner = nodes.new('GeometryNodeEdgesOfCorner')
|
||||
links.new(corner.outputs['Corner Index'], edge_of_corner.inputs['Corner Index'])
|
||||
sample = nodes.new('GeometryNodeFieldAtIndex')
|
||||
sample.data_type, sample.domain = 'BOOLEAN', 'EDGE'
|
||||
links.new(fold.outputs[0], sample.inputs['Value'])
|
||||
links.new(edge_of_corner.outputs['Next Edge Index'], sample.inputs['Index'])
|
||||
if face_fold is None:
|
||||
face_fold = sample.outputs['Value']
|
||||
else:
|
||||
combined = nodes.new('FunctionNodeBooleanMath')
|
||||
combined.operation = 'OR'
|
||||
links.new(face_fold, combined.inputs[0])
|
||||
links.new(sample.outputs['Value'], combined.inputs[1])
|
||||
face_fold = combined.outputs['Boolean']
|
||||
cleanup = nodes.new('GeometryNodeDeleteGeometry')
|
||||
cleanup.domain, cleanup.mode = 'FACE', 'ALL'
|
||||
links.new(geometry, cleanup.inputs['Geometry'])
|
||||
links.new(face_fold, cleanup.inputs['Selection'])
|
||||
return cleanup.outputs[0]
|
||||
|
||||
|
||||
def _off_plane_edge_field(group, tolerance):
|
||||
"""Report one on edges that keep an endpoint off the symmetry plane."""
|
||||
heights = _edge_abs_y(group)
|
||||
return _math(group, 'GREATER_THAN', _math(group, 'MAXIMUM', heights[0], heights[1]), tolerance)
|
||||
|
||||
|
||||
def _edge_abs_y(group):
|
||||
"""Return both endpoint heights on the symmetry axis for an edge."""
|
||||
edge = group.nodes.new('GeometryNodeInputMeshEdgeVertices')
|
||||
heights = []
|
||||
for socket in ('Position 1', 'Position 2'):
|
||||
axis = group.nodes.new('ShaderNodeSeparateXYZ')
|
||||
group.links.new(edge.outputs[socket], axis.inputs[0])
|
||||
heights.append(_math(group, 'ABSOLUTE', axis.outputs['Y']))
|
||||
return heights
|
||||
|
||||
|
||||
def _mirror(group, geometry):
|
||||
@@ -80,6 +162,67 @@ def _mirror(group, geometry):
|
||||
return flip.outputs[0]
|
||||
|
||||
|
||||
def _cut_fields(group, tolerance):
|
||||
"""Return point-domain seam and outline fields for the cut."""
|
||||
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)
|
||||
)
|
||||
|
||||
|
||||
def _falloff(group, center, rings=2):
|
||||
"""Blur a 0/1 center into a falloff over the requested rings."""
|
||||
blur = group.nodes.new('GeometryNodeBlurAttribute')
|
||||
blur.data_type = 'FLOAT'
|
||||
blur.inputs['Iterations'].default_value = rings
|
||||
group.links.new(center, blur.inputs['Value'])
|
||||
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_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)
|
||||
|
||||
|
||||
def _extrude_boundary(group, geometry, selection, y_factor):
|
||||
"""Extrude selected edges to a fraction of their current height."""
|
||||
nodes, links = group.nodes, group.links
|
||||
@@ -106,50 +249,11 @@ def _extrude_boundary(group, geometry, selection, y_factor):
|
||||
return store.outputs[0], extrude.outputs['Top']
|
||||
|
||||
|
||||
def _walls(group, front, boundary, smooth, tolerance, *, curved):
|
||||
"""Build straight walls or a smoothed half-strip followed by its reflection."""
|
||||
nodes, links = group.nodes, group.links
|
||||
geometry, top = _extrude_boundary(group, front, boundary, .5 if curved else -1)
|
||||
if curved:
|
||||
geometry, _ = _extrude_boundary(group, geometry, top, 0)
|
||||
sides = nodes.new('GeometryNodeSeparateGeometry')
|
||||
sides.domain = 'FACE'
|
||||
links.new(geometry, sides.inputs['Geometry'])
|
||||
links.new(_math(group, 'COMPARE', read_float_attribute(group, FACE_ATTRIBUTE), 3), sides.inputs['Selection'])
|
||||
geometry = sides.outputs['Selection']
|
||||
if curved:
|
||||
# Query the retained front to pin the original seam; only new ring XY moves.
|
||||
proximity = nodes.new('GeometryNodeProximity')
|
||||
proximity.target_element = 'POINTS'
|
||||
links.new(front, proximity.inputs['Target'])
|
||||
movable = _math(group, 'GREATER_THAN', proximity.outputs['Distance'], tolerance)
|
||||
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'])
|
||||
# Keep heights fixed so neither the seam nor the symmetry plane can drift.
|
||||
difference = nodes.new('ShaderNodeVectorMath')
|
||||
difference.operation = 'SUBTRACT'
|
||||
links.new(blur.outputs[0], difference.inputs[0])
|
||||
links.new(position.outputs[0], difference.inputs[1])
|
||||
mask = nodes.new('ShaderNodeVectorMath')
|
||||
mask.operation = 'MULTIPLY'
|
||||
mask.inputs[1].default_value = (.5, 0, .5)
|
||||
links.new(difference.outputs[0], mask.inputs[0])
|
||||
place = nodes.new('GeometryNodeSetPosition')
|
||||
links.new(geometry, place.inputs['Geometry'])
|
||||
links.new(movable, place.inputs['Selection'])
|
||||
links.new(mask.outputs[0], place.inputs['Offset'])
|
||||
join = nodes.new('GeometryNodeJoinGeometry')
|
||||
links.new(place.outputs[0], join.inputs[0])
|
||||
links.new(_mirror(group, place.outputs[0]), join.inputs[0])
|
||||
geometry = join.outputs[0]
|
||||
shade = nodes.new('GeometryNodeSetShadeSmooth')
|
||||
shade.domain = 'FACE'
|
||||
shade.inputs['Shade Smooth'].default_value = curved
|
||||
links.new(geometry, shade.inputs['Geometry'])
|
||||
return shade.outputs[0]
|
||||
def _build_wall(group, front, boundary):
|
||||
"""Attach the curved half wall to the front and mark its faces."""
|
||||
geometry, top = _extrude_boundary(group, front, boundary, .5)
|
||||
geometry, _ = _extrude_boundary(group, geometry, top, 0)
|
||||
return geometry
|
||||
|
||||
|
||||
def build_image_cutout_symmetry_group():
|
||||
@@ -167,36 +271,54 @@ 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 only the generated wall interiors. Zero keeps straight walls.'
|
||||
smooth.description = 'Smooth the outline and the wall it builds through a two-ring falloff. 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.'
|
||||
interface_socket(group, 'Geometry', 'OUTPUT', 'NodeSocketGeometry')
|
||||
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(inputs['Geometry'], bounds.inputs['Geometry'])
|
||||
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)
|
||||
front = _delete_positive_faces(group, _align_front(group, inputs['Geometry'], inputs), tolerance)
|
||||
is_boundary = _boundary_edge_field(group)
|
||||
seam_field, outline_field = _cut_fields(group, tolerance)
|
||||
front = _fold_to_plane(group, aligned, tolerance)
|
||||
shade = nodes.new('GeometryNodeSetShadeSmooth')
|
||||
shade.domain = 'FACE'
|
||||
links.new(front, shade.inputs['Geometry'])
|
||||
front = store_float_attribute(group, shade.outputs[0], FACE_ATTRIBUTE, 1., domain='FACE')
|
||||
count = nodes.new('GeometryNodeInputMeshEdgeNeighbors')
|
||||
boundary = _math(group, 'COMPARE', count.outputs['Face Count'], 1)
|
||||
walls = nodes.new('GeometryNodeSwitch')
|
||||
walls.input_type = 'GEOMETRY'
|
||||
links.new(_math(group, 'GREATER_THAN', inputs['Fill Smooth'], 0), walls.inputs['Switch'])
|
||||
for curved, socket in ((False, 'False'), (True, 'True')):
|
||||
links.new(_walls(group, front, boundary, inputs['Fill Smooth'], tolerance, curved=curved), walls.inputs[socket])
|
||||
enabled = nodes.new('GeometryNodeSwitch')
|
||||
enabled.input_type = 'GEOMETRY'
|
||||
links.new(inputs['Fill Sides'], enabled.inputs['Switch'])
|
||||
links.new(walls.outputs[0], enabled.inputs['True'])
|
||||
back = store_float_attribute(group, _mirror(group, front), FACE_ATTRIBUTE, 2., domain='FACE')
|
||||
front = _relax_seam(group, front, seam_field, inputs['Seam Smooth'])
|
||||
boundary = nodes.new('FunctionNodeBooleanMath')
|
||||
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])
|
||||
fill_smooth = _math(group, 'MULTIPLY', inputs['Fill Smooth'], inputs['Fill Sides'])
|
||||
front = _apply_planar_relax(group, front, _falloff(group, outline_field), fill_smooth)
|
||||
walled = _build_wall(group, front, boundary.outputs[0])
|
||||
wall_shade = nodes.new('GeometryNodeSetShadeSmooth')
|
||||
wall_shade.domain = 'FACE'
|
||||
links.new(_math(group, 'COMPARE', read_float_attribute(group, FACE_ATTRIBUTE), 3), wall_shade.inputs['Selection'])
|
||||
links.new(walled, wall_shade.inputs['Geometry'])
|
||||
body = nodes.new('GeometryNodeSwitch')
|
||||
body.input_type = 'GEOMETRY'
|
||||
links.new(inputs['Fill Sides'], body.inputs['Switch'])
|
||||
links.new(front, body.inputs['False'])
|
||||
links.new(wall_shade.outputs[0], body.inputs['True'])
|
||||
mark_back = nodes.new('GeometryNodeStoreNamedAttribute')
|
||||
mark_back.data_type, mark_back.domain = 'FLOAT', 'FACE'
|
||||
mark_back.inputs['Name'].default_value = FACE_ATTRIBUTE
|
||||
mark_back.inputs['Value'].default_value = 2
|
||||
links.new(_mirror(group, body.outputs[0]), mark_back.inputs['Geometry'])
|
||||
links.new(_math(group, 'COMPARE', read_float_attribute(group, FACE_ATTRIBUTE), 1), mark_back.inputs['Selection'])
|
||||
join = nodes.new('GeometryNodeJoinGeometry')
|
||||
for geometry in (front, back, enabled.outputs[0]):
|
||||
for geometry in (body.outputs[0], mark_back.outputs['Geometry']):
|
||||
links.new(geometry, join.inputs[0])
|
||||
merge = nodes.new('GeometryNodeMergeByDistance')
|
||||
links.new(tolerance, merge.inputs['Distance'])
|
||||
|
||||
@@ -361,8 +361,6 @@ def build_image_depth_cutout_group():
|
||||
float_input(group, 'Depth Scale', 1.0, maximum=2.0)
|
||||
float_input(group, 'Depth Split', 0.1, maximum=1, subtype='FACTOR').description = 'Split the mesh where depth changes abruptly. Higher values detect smaller depth jumps. Zero disables splitting.'
|
||||
options = group.interface.new_panel(name='Options', default_closed=True)
|
||||
float_input(group, 'Smooth Weight', 1.0, maximum=1, subtype='FACTOR', parent=options).description = 'Control the strength of boundary smoothing. Higher values move boundary vertices more.'
|
||||
float_input(group, 'Reference Depth', 1.0, subtype='DISTANCE', parent=options)
|
||||
boundary_smooth = interface_socket(group, 'Boundary Smooth', 'INPUT', 'NodeSocketInt', options)
|
||||
boundary_smooth.default_value, boundary_smooth.min_value, boundary_smooth.max_value = 4, 0, 16
|
||||
boundary_smooth.description = 'Smooth geometry near the original outline and Depth Split edges. Zero disables boundary smoothing.'
|
||||
@@ -370,6 +368,7 @@ def build_image_depth_cutout_group():
|
||||
normal_smooth.default_value, normal_smooth.min_value, normal_smooth.max_value = 64, 16, 512
|
||||
normal_smooth.description = 'Control smoothing of front and rear displacement normals. Higher values produce smoother directions.'
|
||||
float_input(group, 'Front Inflation', 0.2, maximum=1, subtype='FACTOR', parent=options).description = 'Control how much the front surface bulges in Balloon mode. Zero keeps the original front surface.'
|
||||
float_input(group, 'Reference Depth', 1.0, subtype='DISTANCE', parent=options)
|
||||
data = group.interface.new_panel(name='Data', default_closed=True)
|
||||
float_input(group, 'Uniform Scale', 1.0, parent=data).hide_in_modifier = True
|
||||
interface_socket(group, 'Depth Image', 'INPUT', 'NodeSocketImage', data).hide_in_modifier = True
|
||||
|
||||
@@ -51,7 +51,6 @@ def build_image_depth_panorama_group():
|
||||
boundary_smooth = interface_socket(group, "Boundary Smooth", "INPUT", "NodeSocketInt", options)
|
||||
boundary_smooth.default_value, boundary_smooth.min_value, boundary_smooth.max_value = 2, 0, 16
|
||||
boundary_smooth.description = "Smooth depth and geometry near cut edges on the panorama surface. Zero disables boundary smoothing."
|
||||
float_input(group, "Smooth Weight", 1, maximum=1, subtype="FACTOR", parent=options).description = "Control the strength of boundary smoothing. Higher values move boundary vertices more."
|
||||
float_input(group, "Mask Threshold", 0.9, maximum=1, subtype="FACTOR", parent=options).description = "Set the minimum depth mask value treated as valid. Higher values exclude more partially valid boundary areas."
|
||||
float_input(group, "Invalid Distance", 100, minimum=1e-5, subtype="DISTANCE", parent=options).description = "Set the fallback distance from the panorama center for invalid depth areas kept when Depth Mask is disabled."
|
||||
data = group.interface.new_panel("Data", default_closed=True)
|
||||
|
||||
@@ -45,8 +45,6 @@ def build_image_depth_plane_group(image_plane_group):
|
||||
valid_only.description = "Remove mesh areas marked invalid by the depth mask."
|
||||
valid_only.default_value = True
|
||||
options = group.interface.new_panel(name="Options", default_closed=True)
|
||||
float_input(group, "Smooth Weight", 1.0, maximum=1, subtype="FACTOR", parent=options).description = "Control the strength of boundary smoothing. Higher values move boundary vertices more."
|
||||
float_input(group, "Reference Depth", 1.0, subtype="DISTANCE", parent=options)
|
||||
boundary_smooth = _interface_socket(group, "Boundary Smooth", "INPUT", "NodeSocketInt", parent=options)
|
||||
boundary_smooth.default_value, boundary_smooth.min_value, boundary_smooth.max_value = 4, 0, 16
|
||||
boundary_smooth.description = "Smooth depth and geometry near cut edges while preserving the original image border. Zero disables boundary smoothing."
|
||||
@@ -55,6 +53,7 @@ def build_image_depth_plane_group(image_plane_group):
|
||||
validity_threshold.default_value = 0.9
|
||||
validity_threshold.min_value, validity_threshold.max_value = 0.0, 1.0
|
||||
validity_threshold.subtype = "FACTOR"
|
||||
float_input(group, "Reference Depth", 1.0, subtype="DISTANCE", parent=options)
|
||||
data = group.interface.new_panel(name="Data", default_closed=True)
|
||||
uniform_scale = _interface_socket(
|
||||
group,
|
||||
|
||||
@@ -55,6 +55,11 @@ def build_image_relief_plane_group(image_plane_group):
|
||||
valid_only.description = "Treat depth-mask-invalid areas as zero depth instead of deleting them."
|
||||
valid_only.default_value = True
|
||||
options = group.interface.new_panel(name="Options", default_closed=True)
|
||||
validity_threshold = _interface_socket(group, "Mask Threshold", "INPUT", "NodeSocketFloat", options)
|
||||
validity_threshold.description = "Set the minimum depth mask value treated as valid."
|
||||
validity_threshold.default_value = 0.9
|
||||
validity_threshold.min_value, validity_threshold.max_value = 0.0, 1.0
|
||||
validity_threshold.subtype = "FACTOR"
|
||||
base_plane_depth = _interface_socket(
|
||||
group,
|
||||
"Reference Depth",
|
||||
@@ -65,11 +70,6 @@ def build_image_relief_plane_group(image_plane_group):
|
||||
base_plane_depth.default_value = 1.0
|
||||
base_plane_depth.min_value = 0.0
|
||||
base_plane_depth.subtype = "DISTANCE"
|
||||
validity_threshold = _interface_socket(group, "Mask Threshold", "INPUT", "NodeSocketFloat", options)
|
||||
validity_threshold.description = "Set the minimum depth mask value treated as valid."
|
||||
validity_threshold.default_value = 0.9
|
||||
validity_threshold.min_value, validity_threshold.max_value = 0.0, 1.0
|
||||
validity_threshold.subtype = "FACTOR"
|
||||
data = group.interface.new_panel(name="Data", default_closed=True)
|
||||
uniform_scale = _interface_socket(
|
||||
group,
|
||||
|
||||
Reference in New Issue
Block a user