diff --git a/AGENTS.md b/AGENTS.md index cac3017..c256de8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,11 +80,18 @@ uv run sync_models - 节点组统一存入 `assets/O_AnyImage.blend`,运行时按名称加载(`load_node_group(group_name)`) - 节点组通过[节点资产](docs/internals/node-assets.md)说明构建,禁止运行时调用 `bpy.data.node_groups.new()` 新建 - 节点组变更须同步构建脚本、验证、测试和节点资产文档 -- 保留节点默认 `name` 和标题,不用 `label` 覆盖 -- 主链从左到右连续排列;辅助分支就近汇入,避免长线、交错、重叠和回折 -- Group Input 可按区域重复放置,并隐藏未连接 sockets -- Frame 仅用于确有包裹语义的区域 +- 将节点组当作程序函数:先确定传递几何的主轴,主轴从左到右连续排列。主轴可适当宽松,支线内部尽量紧凑;同一目的的节点距离应明显小于不同部分之间的距离,用留白表达用途分组 +- Join Geometry、Menu Switch、Combine XYZ 等节点前,承担同级输入的独立分支末端尽量排成一列、输出端对齐;XYZ 三轴也属于同级输入,菜单分支按输入顺序上下排列。共享上游留在公共位置,不为末端对齐搬动整条计算链 +- 属性计算作为并行支线,就近汇入主轴,不因计算节点多而延长主轴。同一目的的计算聚在主轴同一侧,独立用途可在两侧错落展开;不追求两侧均衡,长短和距离随内容变化,优先利用附近空位 +- 长算式适当横排,不强迫所有支线竖排或等长。支线可以长过对应的主链步骤,几何输入也不必是最左侧节点 +- Group Input 按区域重复放置,Named Attribute 等字段入口就近读取;隐藏所有未使用输出,避免从远处拉线 +- 跨区域暂存值用 Store Named Attribute 写入 `o_*` 命名空间,按用途分配前缀并明确 FACE、CORNER、POINT 域和存取时机;末尾用一个 Wildcard 模式的 Remove Named Attribute 清理该前缀,例如 `o_depth_*`,保留用户属性和 UV +- Capture Attribute 适合局部字段固定;若属性连线跨越多个步骤,改为命名属性,让计算分支独立展开 +- 支线确需从主轴向左回接时直接连接,不加 Reroute 绕线,允许这些连线交叉;前向旁路只在确实更清楚时使用少量 Reroute。节点本体仍不得重叠或被遮挡 +- 保留节点默认 `name` 和标题,不用 `label` 覆盖,不用 String 节点注解。Frame 仅用于确有包裹语义的区域 +- 全接线的简单数学运算可折叠;需要阅读的常数、运算域和模式保持可见 - Repeat、Simulation、For Each Geometry Element 等成对节点,确保位置正确,没有遮挡其他节点 - 修改器 Geometry Nodes 的输入 socket 默认使用 `SINGLE` - socket `subtype` 按业务语义设置:长度用 `DISTANCE`,`0–1` 比例用 `FACTOR`,`0–100` 用 `PERCENTAGE`;无对应范围语义时保留默认 -- 接口或布局变更须验证 socket 顺序与结构、节点不重叠、未使用输出隐藏及几何求值 +- 接口或布局变更须验证 socket 顺序与结构、未使用输出隐藏及几何求值;纯布局变更比较运算、默认值和实际连接,确认功能不变 +- 在 Blender 节点编辑器按实际绘制尺寸检查总览和局部步骤,确认主轴、支线长度、节点间距及配对区域清楚;坐标检查不能代替视觉检查 diff --git a/docs/internals/node-assets.md b/docs/internals/node-assets.md index 9e14277..48741a3 100644 --- a/docs/internals/node-assets.md +++ b/docs/internals/node-assets.md @@ -43,6 +43,38 @@ uv run build_node --skip-tests `O Image Depth Plane`、`O Image Cutout`、`O Image Depth Balloon` 与 `O Image Depth Surface` 统一使用 **Reference Depth** 作为采样深度的运算基准。 +### Depth Surface 分离与厚度 + +Options 中的 **Split** 范围为 `0–1`,默认 `0` 关闭分离,数值越大越容易分离。节点比较 `场景 Z 差 × Split > 原始面中心距离 × 2.5`;`0.5` 对应深度坡度阈值 `5`。场景 Z 差包含图片标定的 **Uniform Scale** 和 **Depth Scale**。 + +```mermaid +flowchart TD + A[基础网格与深度图片] --> B[面中心采样与共享边判别] + B --> C[存入面角命名属性并 Split Edges] + C --> D[Cleanup 清理拆后平面区域] + D --> E[切口顶点沿原相机射线采用所属侧深度] + E --> J[固定顶点数据并投影] + J --> F[生成正背面与分段侧壁] + F --> G[按原顶点及厚度层身份连接] + G --> H[恢复场景坐标] + H --> K[移除 o_depth_* 暂存属性] + K --> I[外层 Smooth 与方向变换] +``` + +Split 关闭时使用原顶点采样,Cleanup 仍处理原平面区域。Depth Surface 的 Cleanup 在分离后、投影和厚度前执行;其他 Shape 保持原清理流程。 + +未关联选中切边的顶点使用原始 XYZ。切口顶点以 `原 XYZ × 所属侧 Z / 原 Z` 保持原相机射线,随后执行既有 Uniform Scale、Reference Depth 和 Depth Scale 投影。 + +厚度前固定顶点数据,使正背面和侧壁使用相同位置。连接时以原顶点编号和厚度层构造临时整数坐标,合并同一身份的副本后恢复场景坐标,保持近距离分离区域各自独立。实现位于 `cutout_depth_surface.py`,由 `build_cutout.py` 构建进共享节点资产。 + +暂存值通过 Store Named Attribute 写入保留前缀 `o_depth_`:面中心与相机采样使用 FACE,所属侧相机采样与切口标记使用 CORNER,最终相机坐标、表面位置、源顶点编号与厚度分段数使用 POINT。计算区域就近使用 Named Attribute,末尾用一个 Wildcard 模式的 Remove Named Attribute 匹配 `o_depth_*`,输出保留 UVMap 与用户属性。 + +### 几何节点布局 + +全部七个几何节点组使用连续横排的几何主轴。主轴可适当宽松,支线内部紧凑,同一目的的计算聚在同一侧,内部间距明显小于与其他部分的距离;独立用途可在两侧错落展开。长算式横排,末端就近汇入主轴。Join Geometry、Menu Switch、Combine XYZ 的同级独立分支末端排成一列并对齐输出端,菜单分支按输入顺序上下排列,共享上游留在公共位置。必要的向左引用直接连接。Group Input 与 Named Attribute 就近放置,全接线的简单运算可折叠,常数和域设置保持可见。 + +`geometry_layouts.py` 组织平面、外层 Cutout 和三个 Shape 的布局,`cutout_depth_surface_layout.py` 组织 Depth Surface;`node_layout.py` 统一放置局部输入、隐藏未使用输出并检查节点边界。具体规则见 [AGENTS.md](../../AGENTS.md)。布局验收包含实际节点编辑器预览,以及重排前后的运算、默认值、实际连接和几何结果比较。 + ## 修改节点组 1. 修改 `tools/node_assets/build_*.py`; diff --git a/openspec/changes/replace-depth-surface-stretch-with-split/.openspec.yaml b/openspec/changes/replace-depth-surface-stretch-with-split/.openspec.yaml new file mode 100644 index 0000000..2e24cfa --- /dev/null +++ b/openspec/changes/replace-depth-surface-stretch-with-split/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-07 diff --git a/openspec/changes/replace-depth-surface-stretch-with-split/design.md b/openspec/changes/replace-depth-surface-stretch-with-split/design.md new file mode 100644 index 0000000..ef31d0f --- /dev/null +++ b/openspec/changes/replace-depth-surface-stretch-with-split/design.md @@ -0,0 +1,100 @@ +## Context + +动机见 proposal.md。当前 `_build_depth_surface` 投影后执行 Stretch Limit 删几何,再进入分层厚度。外层 Cutout 在 Shape 前执行 Cleanup,在 Shape 后执行 Smooth。四种 Shape 共用基础网格,节点资产通过构建脚本生成并在运行时加载。 + +最新实验采用场景 Z 差除以实际面中心距离,六个物体共用节点组。用户确认内部阈值约 `5` 时效果较好,同时观察到有厚度时侧壁与正面脱开。已有“坐标有限、面数增加、平面厚度正确”的检查不足以证明封边成功。 + +## Goals + +分离在 Depth Surface 分支内完成;厚度生成使用拆边后的同一份边界数据。零值关闭时沿用完整原始表面的采样和投影行为。验收覆盖节点组本体和真实 Cutout 外层处理链。 + +## Decisions + +### 1. 用实际采样跨度计算深度坡度 + +令 `Cₐ、Cᵦ` 为变形前相邻面的中心,`Zₐ、Zᵦ` 为面中心 UV 采样得到的相机 Z,`U` 为正式 Uniform Scale,`D` 为 Depth Scale: + +```text +g = abs(Zₐ - Zᵦ) × U × abs(D) / distance(Cₐ, Cᵦ) +``` + +基准中心与 `U × Z` 使用一致的对象局部距离单位。复用 `common.depth.depth_uniform_scale` 和现有参考深度计算,不增加新的 Mesh Detail 数据输入。对中心距离为零的退化邻接不选边,避免除零;数值保护采用与模型尺度一致的容差。 + +选择共享边上恰有两个邻面的情况。Corners of Edge → Face of Corner → Evaluate at Index 取得邻面字段。采用 Z 而非 XYZ 距离,避免把正常横向间距计入断层;采用实际中心跨度而非期望网格长度,避免轮廓附近的小三角形在 Low 下被过大的分母抑制。 + +### 2. 使用正向的 Split 控制 + +`Split` 位于内外两层接口的 Options,替代 Stretch Limit 的位置,`FACTOR`、`SINGLE`、范围 `0–1`、默认 `0`。 + +使用简单倒数换算:对于 `s > 0`,内部阈值 `T(s) = 2.5 / s`。节点直接比较 `g × s > 2.5`,零值自然关闭,避免零点除法。 + +| Split | 内部阈值 | 含义 | +|---|---:|---| +| 0 | 显式关闭 | 原表面保持连接 | +| 0.25 | 10 | 较少分离 | +| 0.5 | 5 | 用户认可的实验强度 | +| 0.75 | 3.333… | 更多分离 | +| 1 | 2.5 | 最大强度 | + +常数 `2.5` 仅用于把内部阈值 `5` 放到滑块中点。关闭分支旁路新增面采样与拆边,但仍执行 Cleanup 和原始采样投影。 + +### 3. 固定切口两侧的数据,再生成厚度 + +```mermaid +flowchart TD + A[原基础网格与深度图片] --> B{Split 大于 0} + B -->|是| C[面中心采样并计算深度坡度] + C --> D[存入面角命名属性并拆共享边] + D --> J[Cleanup 清理拆后平面区域] + J --> E[在剩余顶点域固定投影位置和边界身份] + B -->|否| K[Cleanup 清理原平面区域] + K --> F[原顶点采样与投影] + E --> G[生成正背面和共享边界侧壁] + F --> G + G --> L[移除暂存命名属性] + L --> H[外层 Smooth 与输出] +``` + +带局部斜坡的三角阶跃样例已复现:原厚度流程直接使用面角字段时产生 64 条开口边,改为厚度前固定顶点字段后降为零。新增面角、侧面分离和重复挤出改变了面角到顶点的平均权重,使侧壁与盖面的坐标不同。 + +实现上在拆边后、厚度前固定最终顶点投影数据及边界身份,正面、背面、各段侧壁共同引用该数据。侧壁起点必须与正面边界一致,终点必须与背面边界一致,段间共享同一边界环;不得在新增侧面域重新平均原面深度。连接按同一分离区域的边界身份完成,防止全局 Merge by Distance 把原本独立的切口重新焊接。合并容差不能用来掩盖边界坐标不一致。 + +选边字段先在 EDGE 域求值,再转换到 POINT 域标记关联顶点。仅这些顶点采用拆后所属侧的平均 Z,并以 `原 XYZ × 所属侧 Z / 原 Z` 沿原相机射线重建;其他顶点保留原 XYZ。面中心 XYZ 不直接用于替换顶点位置,以免轮廓向面中心收缩。 + +保留现有分层厚度的用户语义和分段能力,修正其中的属性传播或连接环节。先复现开裂并检查面朝向、各段挤出方向、顶点域数据和边界配对,再确定最小修改。对定向正确的流形输入,每个加厚区域必须闭合;片段之间可以分离,片段内部不能露缝。 + +连接使用拆后原顶点编号与厚度层编号作为身份。生成侧壁后,暂时将这两个整数编码成合并坐标,使 Merge by Distance 只合并相同身份的副本,随后恢复捕获的真实表面位置与厚度偏移。编号按整数商、余数拆分以保持浮点表示精确;真实场景中的近距离与重合区域不影响连接身份。 + +跨区域暂存数据使用保留前缀 `o_depth_` 的 Store Named Attribute,在消费区域用 Named Attribute 就近读取。FACE 保存原面中心与相机采样,CORNER 保存所属侧采样与切口标记,POINT 保存最终相机坐标、表面位置、源顶点编号与厚度分段数。输出前用一个 Wildcard 模式的 Remove Named Attribute 匹配 `o_depth_*`,保持 UVMap 和用户属性。 + +### 4. 接入正式资产与外层流程 + +删除 Stretch Limit 接口及其专用边长捕获、删除和松散拓扑清理节点。Depth Surface 将 Cleanup 移到 Split Edges 后、投影和厚度前,按拆后 Mesh Island 的平面面积执行既有清理阈值;避免厚度与侧壁面积影响碎片判定。外层将 Cleanup 值传入此分支,不再对 Depth Surface 提前或重复清理。Split 为零时仍对原平面区域清理。其他三种 Shape 保持原清理位置,外层 Split 仅连接 Depth Surface。 + +多个物体复用同一组资产,图片与标定由各自修改器传入。验证 Smooth 作用于已正确连接的实体,既不重新缝合不同分离区域,也不暴露侧壁接缝。运行时仍按名称加载资产。 + +### 5. 按语义组织节点布局 + +按 AGENTS.md 整理资产中全部七个几何节点组。Depth Surface 的几何主链从左到右连续排列:面采样与判别、拆边、Cleanup、投影、厚度、输出。辅助字段放在使用位置附近,通过局部 Group Input 接入;隐藏未使用输出,保留节点默认 name 与标题,不用 label 替代节点类型。 + +主轴长度由处理阶段决定,字段计算按各自算式长度横向展开,同一目的的计算聚在主轴同一侧,独立用途可在两侧错落分布。支线可以跨越多个主链步骤的横向范围,同侧支线使用不同的距离和起点,并利用附近空位收紧末端连线;必要的向左引用直接连接,允许交叉,不使用 Reroute 绕行。参照 OmooNodes 的 `O Solidfy`、`O Project`、`O Smooth` 和 `O Tube`,使用局部输入与紧凑分支;完整参数已接线的简单运算可折叠。 + +大步骤之间保留明显空白,通过节点默认标题与分支形状辨认处理阶段,不放置 String 注解。计算分支可以向主轴起点左侧展开,暂存值通过命名属性传递。 + +主轴可适当宽松,支线内部间距明显小于不同部分之间的距离,用留白表达用途分组。Depth Plane 的深度偏移与厚度权重在同一侧汇合,Depth Surface 的选边判别构成紧凑的局部计算组。Join Geometry、Menu Switch、Combine XYZ 的独立同级分支末端排成一列、输出端对齐;菜单分支按输入顺序上下排列。供多个部分使用的共享上游留在公共位置。 + +Repeat 输入与输出围住厚度循环内部节点,内部流程保持从左到右,不让区域跨越无关主链或遮挡节点。Frame 仅包裹具有完整语义的区域。接口保持 Options/Data 的业务边界及 SINGLE 结构,长度参数使用 DISTANCE,Split 使用 FACTOR。 + +布局由构建脚本确定。结构验证覆盖节点实际尺寸的非重叠、接口顺序、未使用输出隐藏和区域配对;另在 Blender 节点编辑器检查可读缩放下的主链、长线、交叉和回折。自动拓扑分层或统一网格排布不能替代视觉验收。消除可避免的交叉,共享字段通过就近输入和必要的转接组织,避免为减少节点数而拉出横跨整个图的线。 + +## Risks / Trade-offs + +- 深度坡度也响应连续陡坡 → 使用有界控制范围,以平面、斜坡、深度阶跃共同校验阈值含义。 +- High 比 Low 捕获更多局部变化 → 用规则重采样的合成曲面检查尺度趋势,真实图片用边界位置和碎片形态比较,不以切边总数相等作为验收。 +- 小三角形放大深度噪声 → 检查退化采样跨度、低噪声样本和最大档,不擅自增加改变几何的平滑预处理。 +- 厚度侧壁开裂可能存在多个原因 → 将闭合拓扑、共享边界坐标、法线和真实渲染列为独立验证项。 +- 全局焊接或外层平滑改变切口 → 用间隔小于原焊接容差的双片段覆盖回归。 + +## Migration Plan + +新资产移除旧输入,无旧名转发或兼容控制。替换构建脚本、验证及测试后同步节点资产和必要的节点资产说明;不重写历史 OpenSpec 变更,不构建扩展发行包。已有文件中的旧节点数据由既有资产更新流程处理,不在本变更新增文件迁移机制。 diff --git a/openspec/changes/replace-depth-surface-stretch-with-split/proposal.md b/openspec/changes/replace-depth-surface-stretch-with-split/proposal.md new file mode 100644 index 0000000..761d080 --- /dev/null +++ b/openspec/changes/replace-depth-surface-stretch-with-split/proposal.md @@ -0,0 +1,30 @@ +## Why + +Depth Surface 的 Stretch Limit 通过删几何处理深度断层,在粗网格上产生斑驳缺面。面深度拆边实验已能保留表面并分离遮挡关系,但增加厚度后侧壁与正面脱开,且目前阈值的操作方向不符合使用习惯。 + +## What Changes + +- **BREAKING**:移除 Depth Surface 及外层 Cutout 的 Stretch Limit 接口、拉伸删几何及其松散拓扑清理分支,以 Options 中的 `Split` 替代。 +- 使用“场景 Z 差 / 置换前相邻面中心实际距离”识别需要拆开的共享边。Uniform Scale 使用现有图片标定结果,Depth Scale 参与判断。 +- `Split` 范围为 `0–1`,默认 `0` 完全关闭;越大越容易分离。中点 `0.5` 对应实验内部 Split Ratio `5`。 +- 拆边后保留面所属侧的采样数据,贯穿原 Depth Surface 投影和厚度流程;修复侧壁与正、背面连接,使有厚度的分离区域具有完整边界。 +- Depth Surface 的 Cleanup 放在 Split Edges 之后、投影和厚度之前,按拆分后的平面区域清理小碎片。 +- 多个物体共同引用资产中的节点组,通过各自修改器传入图片和标定数据;四种 Shape 继续共享基础网格。 +- 整理资产中全部七个几何节点组:主轴横排、支线双侧错落并就近汇入,必要的回接直接连接,验证 Repeat 区域的排列;将布局经验写入 AGENTS.md。 + +## Capabilities + +### New Capabilities + +- `depth-surface-face-separation`:规定分离控制、面深度判据、厚度连接完整性和共享节点集成行为。 + +### Modified Capabilities + +无。当前 `openspec/specs` 尚无对应主规格;本变更替代已实现的 `restore-depth-surface-stretch-culling` 的产品行为,不改写其历史记录。 + +## Impact + +- `tools/node_assets/build_cutout.py`、节点验证及 `src/anyimage/assets/O_AnyImage.blend`。 +- Cutout 真实 Blender 求值测试、接口结构测试及节点资产说明。 +- 原 Depth Surface 的厚度生成与采样属性传递需要联合验证,并回归外层 Cleanup、Smooth 和其他 Shape。 +- 复用现有 Depth Image、Uniform Scale 和 Reference Depth 输入;不新增模型推理或依赖。 diff --git a/openspec/changes/replace-depth-surface-stretch-with-split/specs/depth-surface-face-separation/spec.md b/openspec/changes/replace-depth-surface-stretch-with-split/specs/depth-surface-face-separation/spec.md new file mode 100644 index 0000000..f1ebebc --- /dev/null +++ b/openspec/changes/replace-depth-surface-stretch-with-split/specs/depth-surface-face-separation/spec.md @@ -0,0 +1,131 @@ +## Purpose + +规定 Cutout Depth Surface 在深度断层处保留原表面并分离相邻区域的行为,提供默认关闭、随数值增大而增强的控制,并确保厚度生成后每个分离区域的正面、背面和侧壁保持完整连接。 + +## ADDED Requirements + +### Requirement: 分离控制替换拉伸剔除 + +系统 SHALL 在 Depth Surface 及外层 Cutout 的 Options 中以 `Split` 替代 `Stretch Limit`。Split SHALL 为范围 `0–1` 的比例输入,默认 `0`,增大时不得降低同一输入几何的切边选择强度。 + +#### Scenario: 默认关闭 +- **WHEN** 用户使用默认 Split `0` +- **THEN** 系统不增加切边,并在 Cleanup 保留的区域上使用原 Depth Surface 采样与投影行为 + +#### Scenario: 中点与最大强度 +- **WHEN** Split 分别设为 `0.5` 和 `1` +- **THEN** 内部深度坡度阈值分别为 `5` 和 `2.5`,且控制值不能超过 `1`;正值对应阈值为 `2.5 / Split` + +#### Scenario: 接口替换 +- **WHEN** 用户检查新 Cutout 的 Options +- **THEN** 可见 Split,且不存在 Stretch Limit 控制及其拉伸删几何行为 + +### Requirement: 使用相邻面深度与实际跨度选择共享边 + +系统 SHALL 以场景尺度的相邻面中心 Z 差除以变形前两面中心的实际距离作为坡度得分;场景深度 SHALL 包含图片标定的 Uniform Scale 和当前 Depth Scale 的幅度。系统 SHALL 只对恰有两个邻面的共享边执行此判断,并安全处理零跨度输入。 + +#### Scenario: 同一阈值两侧 +- **WHEN** Split 为 `0.5`,两个有效邻接的坡度得分分别为 `4` 和 `6` +- **THEN** 得分 `4` 的边不被选中,得分 `6` 的边被选中进行分离 + +#### Scenario: 等比尺度变化 +- **WHEN** 图片几何和经标定的深度同时等比缩放且 Split 不变 +- **THEN** 相应共享边的坡度得分和选择结果保持一致 + +#### Scenario: 深度归零与退化邻接 +- **WHEN** Depth Scale 为 `0` 或邻面中心距离为零 +- **THEN** 该条件不产生新增切边,且求值不产生非有限坐标 + +### Requirement: 分离保留表面及两侧独立位置 + +系统 SHALL 通过拓扑分离保留原始表面的覆盖,使用各自所属侧的数据确定拆后位置。仅设置选边而继续使用共享 UV 的同一个深度值 SHALL NOT 被视为实现两侧分离。 + +#### Scenario: 零厚度深度阶跃 +- **WHEN** Cleanup 为零,规则网格跨越深度阶跃且 Split 选中贯通边界 +- **THEN** 原始面全部保留,两侧具有独立顶点与不同场景深度,且没有拉伸剔除造成的缺面 + +#### Scenario: 开启控制但没有切边 +- **WHEN** Split 大于零且所有共享边均未达到分离阈值 +- **THEN** 顶点投影与 Split 为零时完全一致,轮廓不因面中心平均而收缩 + +#### Scenario: 切口保持原相机射线 +- **WHEN** 某些共享边被分离 +- **THEN** 仅关联顶点使用所属侧的深度,并保持原 XYZ 的相机射线方向;其他顶点保留原采样位置 + +### Requirement: Cleanup 在拆边后生成厚度前执行 + +Depth Surface SHALL 在 Split Edges 后、投影及厚度生成前执行一次 Cleanup,按拆分后平面区域的面积使用既有清理阈值。其他 Shape SHALL 保持原 Cleanup 顺序。 + +#### Scenario: 清理拆出的碎片 +- **WHEN** 拆边生成低于 Cleanup 面积阈值的独立区域 +- **THEN** 该区域在生成厚度前被清理,其余区域随后投影并加厚 + +#### Scenario: 厚度不改变清理结果 +- **WHEN** Split、基础网格和 Cleanup 保持不变,仅调整 Thickness +- **THEN** 被 Cleanup 保留的平面区域不变 + +#### Scenario: 关闭分离仍可清理 +- **WHEN** Split 为零且 Cleanup 为正 +- **THEN** Cleanup 对原平面区域执行一次,不因分离关闭而失效 + +### Requirement: 厚度侧壁与正背面完整连接 + +系统 SHALL 在加厚时使每个分离区域的侧壁与正面、背面及相邻厚度段共享连续边界。对定向正确的流形片状输入,各加厚区域 SHALL 形成闭合表面,不得跨区域重新焊接切口。 + +#### Scenario: 阶跃区域加厚 +- **WHEN** 对已拆开的深度阶跃区域设置正厚度 +- **THEN** 每个区域的侧壁准确连接其正背面,边界无开口、重叠但未连接的接缝或错位边界 + +#### Scenario: 分段厚度 +- **WHEN** 厚度足以生成多个侧壁段 +- **THEN** 各段相接且总厚度符合输入值,侧壁端点与对应表面边界一致 + +#### Scenario: 近距离独立区域 +- **WHEN** 两个独立分离区域的间隔小于既有几何焊接容差 +- **THEN** 厚度连接仍保持区域独立,不因全局合并恢复原连接 + +### Requirement: 正式 Cutout 流程共享分离能力 + +系统 SHALL 让多个物体共享同一套节点资产,并通过各自修改器传入数据。Split SHALL 仅影响 Depth Surface 分支;外层 Cleanup、Smooth 及其他三种 Shape SHALL 保持各自职责。 + +#### Scenario: 六物体共享节点 +- **WHEN** 三张图片分别创建 High 和 Low 的 Depth Surface +- **THEN** 六个物体引用共享节点组,同一图片的标定值一致,各物体控制值可以独立调整 + +#### Scenario: 完整修改器链 +- **WHEN** Depth Surface 同时启用分离、正厚度及外层 Smooth +- **THEN** 同一区域的正背面和侧壁保持连接,分离区域之间保持独立 + +#### Scenario: 切换其他 Shape +- **WHEN** 用户改变 Split 后选择其他三种基础 Shape +- **THEN** Split 不改变这些 Shape 的基础拓扑与形状结果 + +### Requirement: 节点布局清晰可读 + +资产中全部七个几何节点组 SHALL 遵循 AGENTS.md 的节点组规范:主链从左到右连续排列、辅助分支就近汇入、节点无重叠、保留默认节点标题并隐藏未使用输出。必要的向左引用 SHALL 直接连接,不使用 Reroute 绕行,允许回接连线交叉。 + +跨区域暂存值 SHALL 使用 `o_depth_*` 命名属性并就近读取,在输出前通过一个 Wildcard 模式的 Remove Named Attribute 统一清理。大步骤之间 SHALL 留白,不放置 String 注解。同一目的的计算 SHALL 聚在主轴同一侧,支线内部间距 SHALL 明显小于与其他部分的距离。独立用途可在两侧错落展开,长算式可超出所服务主链步骤的范围;必要的几何引用可回接。 + +#### Scenario: 暂存属性生命周期 +- **WHEN** Split 和 Thickness 分别开启或关闭 +- **THEN** 面、面角和顶点暂存值在正确域传递,输出不残留暂存属性,UVMap 与用户属性保持可用,关闭分支不出现移除缺失属性的警告 + +#### Scenario: 查看所有几何资产 +- **WHEN** 用户打开任一几何节点组 +- **THEN** 计算分支按用途聚拢并利用附近空位,长算式横排,局部输入靠近消费节点,布局调整保持原计算结果 + +#### Scenario: 查看同级分支汇合 +- **WHEN** 多个独立同级分支输入 Join Geometry、Menu Switch 或 Combine XYZ 等汇合节点 +- **THEN** 分支末端排成一列并对齐输出端,XYZ 三轴按同级输入组织,菜单分支按输入顺序上下排列,共享上游留在公共位置 + +#### Scenario: 查看同一用途的计算 +- **WHEN** 用户查看 Depth Plane 的置换计算或 Depth Surface 的 Split 选边判别 +- **THEN** 同一用途的计算聚在主轴同一侧,内部节点间距紧凑,与其他部分之间保留更大的空白,主轴可适当宽松 + +#### Scenario: 查看分离与厚度流程 +- **WHEN** 用户在节点编辑器查看新 Depth Surface +- **THEN** 能沿主链依次辨认采样判别、拆边、投影、厚度和输出,辅助连线不横跨无关区域,节点文字不被遮挡 + +#### Scenario: 查看成对区域 +- **WHEN** 用户检查厚度 Repeat 区域 +- **THEN** 成对节点位置正确,循环内部节点清晰排列,区域不遮挡其他节点,Frame 仅用于确有包裹语义的部分 diff --git a/openspec/changes/replace-depth-surface-stretch-with-split/tasks.md b/openspec/changes/replace-depth-surface-stretch-with-split/tasks.md new file mode 100644 index 0000000..01f223b --- /dev/null +++ b/openspec/changes/replace-depth-surface-stretch-with-split/tasks.md @@ -0,0 +1,67 @@ +## 1. 固定失败样例与控制语义 + +- [x] 1.1 将正厚度侧壁脱开缩减为定向正确的流形网格和深度阶跃样例,以边界配对、非流形边和未焊接接缝检查复现失败,记录根因。 +- [x] 1.2 增加 Split 简单倒数换算测试,覆盖默认关闭、0.5 对应阈值 5、最大值 1 对应阈值 2.5,以及切边选择随控制值单调增强。 +- [x] 1.3 增加原始表面、平面、斜坡、阶跃、等比缩放和零跨度用例,验证面中心 Z 差与实际中心跨度的判据及关闭时的原始投影行为。 + +## 2. 接入分离与厚度连接 + +- [x] 2.1 移除内外节点组的 Stretch Limit 接口与专用删几何分支,在 Options 原位置加入 Split;通过接口测试确认范围、默认值、FACTOR、SINGLE 及只连接 Depth Surface。 +- [x] 2.2 实现面采样、邻面判别、面角捕获和拆边,复用 Uniform Scale 与 Depth Scale;通过零厚度阶跃测试确认保留全部原始面且两侧独立。 +- [x] 2.3 在拆边后固定顶点投影及边界身份,修复厚度侧壁数据传播与连接;通过单段、多段、粗细网格测试确认正背面和侧壁闭合、厚度正确。 +- [x] 2.4 限制连接在对应区域内完成;通过间隔小于原合并容差的双区域用例确认不会重新焊接切口。 +- [x] 2.5 将 Depth Surface 的 Cleanup 移至 Split Edges 后、投影和厚度前,移除该分支提前和重复清理;通过碎片样例验证按拆后平面面积清理、关闭 Split 仍清理且 Thickness 不改变保留区域。 +- [x] 2.6 接入外层 Smooth 和 Shape 分支,通过完整修改器求值测试确认加厚区域保持连接,其他 Shape 的 Cleanup 位置与行为不变。 + +## 3. 整理节点布局与共享资产 + +- [x] 3.1 按 AGENTS.md 重排主链和辅助分支,局部放置 Group Input,保留默认标题并隐藏未使用 sockets;在节点编辑器检查可读性,消除可避免的长线、交错和回折。 +- [x] 3.2 整理厚度 Repeat 配对区域及必要 Frame,通过实际节点尺寸检查确保无重叠、无遮挡,并保存节点编辑器预览供审阅。 +- [x] 3.3 更新节点验证脚本,覆盖接口顺序、SINGLE、subtype、未使用输出隐藏、布局与几何求值;运行验证确认通过。 +- [x] 3.4 构建并验证节点资产,检查六个物体共同引用资产节点组、标定与控制各自传入,运行时不新建节点组。 + +## 4. 综合验收 + +- [x] 4.1 用蜘蛛与蛛丝、甲虫右侧腿与躯干、噬菌体左下交叉腿检查 High/Low,在 Split 0、0.5、1 及正厚度下保存对比;重点确认中点效果、碎片程度和侧壁无缝连接。 +- [x] 4.2 运行 Cutout 定向测试、节点资产验证和相关回归测试,重新打开保存的 Blender 文件复核厚度与共享节点;不能仅以面数或厚度范围通过代替闭合验证。 +- [x] 4.3 同步必要的节点资产说明以符合 AGENTS.md,验证构建脚本、资产接口和说明一致,不构建扩展发行包。 +- [x] 4.4 运行 OpenSpec 严格校验,确认所有规格场景都有测试或明确的视觉验收记录后再标记实施完成。 + +## 5. 轮廓与连线修正 + +- [x] 5.1 只在选中切边的关联顶点应用所属侧深度,沿原相机射线重建位置;验证无切边时投影完全不变、切口外顶点不变、切口顶点保持原射线。 +- [x] 5.2 明确排列侧壁、背面、连接及位置恢复分支,复制局部 Position 字段并用 Reroute 通道组织长线;保存实际节点编辑器预览并验证长连线不斜穿计算区域。 + +## 6. 按处理阶段组织布局 + +- [x] 6.1 阅读用户的 OmooNodes 参考资产,按几何主轴收拢阶段,属性计算在局部竖排和并行展开,局部重复 Group Input。 +- [x] 6.2 验证重排前后运算、默认值和实际连接一致,运行 Cutout 求值检查,并在 Blender 中按实际节点尺寸复核布局、保存预览。 + +## 7. 命名属性与阶段留白 + +- [x] 7.1 将跨区域暂存数据改为 o_depth_* 命名属性并就近读取,输出前统一移除,验证字段域、几何结果和属性清理。 +- [x] 7.2 拉开大步骤间距,以 String 节点标注阶段,按主轴与局部计算树重排并在 Blender 检查实际布局。 +- [x] 7.3 重建资产,复核六物体共享节点与厚度闭合,运行回归和 OpenSpec 校验。 + +## 8. 双侧计算分支与 Wildcard 清理 + +- [x] 8.1 用单个 Wildcard Remove Named Attribute 清理 o_depth_*,验证新增匹配属性同样被清理、用户属性保留。 +- [x] 8.2 移除 String 注解,将分支在主轴上下错落横向展开,允许必要的几何引用回接,检查实际节点布局。 +- [x] 8.3 重建共享资产,比较真实样例几何,运行节点验证、回归与 OpenSpec 校验。 + +## 9. 全部几何资产布局与规则 + +- [x] 9.1 回接直接连接,收紧支线与主轴的间距,将同样的布局方式用于资产中的全部七个几何节点组。 +- [x] 9.2 将主轴、双侧支线、局部输入、命名属性、Wildcard 清理与视觉检查规则整理进 AGENTS.md,更新节点资产说明。 +- [x] 9.3 重建资产,对比运算和连接、执行几何回归,并在 Blender 中逐组检查实际布局,保存预览。 + +## 10. 紧凑布局与同级输入对齐 + +- [x] 10.1 收紧全部几何节点组的间距,将同一目的的节点聚拢,并将汇合节点的独立同级来源排齐,菜单分支按输入顺序排列。 +- [x] 10.2 同步 AGENTS.md 与布局规范,验证汇合分支对齐、实际尺寸和计算结果,保存预览。 + +## 11. 按用途聚拢支线与 XYZ 对齐 + +- [x] 11.1 将 Combine XYZ 纳入同级输入对齐,Depth Plane 同用途计算收在一侧,收紧 Split 选边支线内部间距,同步 AGENTS.md 与布局规范。 +- [x] 11.2 重建资产并检查七组实际节点布局,确认计算与连接不变,运行节点验证及相关回归,保存局部预览。 + diff --git a/openspec/changes/replace-depth-surface-stretch-with-split/validation.md b/openspec/changes/replace-depth-surface-stretch-with-split/validation.md new file mode 100644 index 0000000..8c5b590 --- /dev/null +++ b/openspec/changes/replace-depth-surface-stretch-with-split/validation.md @@ -0,0 +1,57 @@ +## 验收记录 + +- Blender 4.5.10 LTS:三套节点资产验证脚本通过,进程正常退出。 +- 最终 Cutout 定向测试:62 项通过,进程正常退出。 +- 全量回归:649 项通过、2 项预期失败、59 项子测试通过;开发环境 bpy 在解释器退出阶段仍使进程返回 1。正式 Blender 的构建、求值及重新打开检查均正常退出。 +- 三张图片各 High/Low,Split 为 0、0.5、1,Thickness 为 0、0.02,共 36 组求值:零厚度保留全部原始面;正厚度所有面边均成对连接,无开口边或非流形边。 +- 六个物体共用正式 `O Image Cutout` 及其 Depth Surface 子组。重新打开保存文件后,图像数据有效,零厚度顶点坐标与保存前一致;Split 0.5、Thickness 0.02、Smooth Iterations 2 时仍全部闭合。 +- Blender 节点编辑器实际尺寸检查无节点重叠,Repeat 区域内无无关节点;已保存总览、面判别和 Repeat 局部预览。 +- 阶段布局复核:参照 OmooNodes 的四个节点组后,图宽从 27550 缩至 8580 个节点编辑器单位。主轴横排,字段按局部阶段展开;实际节点尺寸检查通过。重排前后全部节点运算、默认输入和穿过 Reroute 后的实际连接完全一致,58 项定向测试与 36 组样例求值通过。 + +## 视觉结果 + +蜘蛛与蛛丝、甲虫右侧腿和噬菌体左下交叉腿的 High/Low 对比均已保存,包含关闭、中点、最大强度和中点加厚。 + +中点能分离主要深度连接,最大强度进一步增加局部分离。High/Low 的三角形布局与深度采样不同,切口位置和碎片形状仍有差异;例如甲虫右侧末端在 High 中点已断开,Low 到最大值才断开。厚度沿各自区域形成连续侧壁,闭合结论同时由完整网格拓扑检查支持。 + +合成用例覆盖默认投影、控制阈值、规则斜坡重采样、等比缩放、零跨度、拆后清理、单段与多段厚度、重合独立区域及外层平滑。接口和依赖检查覆盖其他 Shape 不受 Split 影响。 + +审阅文件位于本机:`C:/Users/icrdr/.codex/visualizations/2026/09/06/01a076f5-7f13-7083-84cd-f624eeeb4c30/corrected-depth-surface/`,其中 `cutout-depth-surface.blend` 为共享正式节点的六物体场景,`*-comparison.png` 为三张对比图,`node-layout-*.png` 为节点预览,`validation.json` 为 36 组完整网格检查记录。 + +最新阶段布局及六物体审阅文件位于同级 `compact-depth-surface/`,节点预览为 `layout-overview.png`、`layout-split.png`、`layout-projection.png`、`layout-thickness.png`。参考资产截图及连接一致性检查位于同级 `layout-study/`。 + +## 命名属性与阶段留白复核 + +- FACE、CORNER、POINT 暂存值已改为 `o_depth_*` 命名属性,8 项属性在输出前按启用分支逐项移除。4 组 Split/Thickness 组合验证无残留且保留 UVMap 和 `o_user_weight`;Blender 修改器界面不再出现关闭厚度时的缺失属性清理警告。 +- 与上一版 `compact-depth-surface/` 的 24 份样例数据对比,面索引完全相同,顶点坐标绝对误差不超过 `1e-6`;36 组完整求值及六个平滑后闭合实体复核通过。 +- 大步骤之间保留至少 500 个节点编辑器单位的空白,6 个 String 节点显示阶段说明。计算树从局部 Named Attribute 和 Group Input 展开,几何起点位于部分计算节点右侧。实际节点尺寸检查无重叠,Repeat 区域未遮挡其他节点。 +- 最新六物体场景位于同级 `named-depth-surface/cutout-depth-surface.blend`;`layout-*.png` 为总览、Split、投影、厚度、位置恢复和属性清理预览。重新打开后图片数据、共享节点与厚度闭合均通过。 + +## 双侧支线与 Wildcard 复核 + +- Remove Named Attribute 使用 `pattern_mode = WILDCARD`,一个节点匹配 `o_depth_*`。删除逐项清理与条件分支,移除全部 String 注解。测试额外注入 `o_depth_probe`,确认前缀匹配生效且保留 `o_user_weight`、UVMap。 +- 计算分支在主轴上下交错展开;面判别与厚度分段算式横向延伸,厚度统计从主链向左引用几何。验证仅要求几何主轴向右,辅助引用允许回接;实际节点尺寸无重叠,Repeat 区域未遮挡其他节点。 +- 62 项 Cutout 定向测试、三套 Blender 资产验证通过。36 组真实样例求值和六个平滑实体保持闭合;与命名属性版的 24 份数据比较,面索引相同,坐标绝对误差不超过 `1e-6`。 +- 最新审阅场景为同级 `tree-depth-surface/cutout-depth-surface.blend`,六个物体共用正式节点。`layout-*.png` 保存总览和局部节点编辑器预览;重新打开检查及 OpenSpec 严格校验通过。 + +## 全部几何资产布局复核 + +- 七个几何节点组均按主轴与双侧计算分支整理,局部重复 Group Input;基础平面在 UV 和网格尺寸区域分别计算相同的源包围盒尺寸。深度表面的厚度统计直接向左引用几何,支线末端移近使用位置,前向旁路仅保留少量转接。 +- 将重复的输入与尺寸计算归一化后,全部资产的运算、默认值和实际连接与重排前一致。24 份真实样例的面索引完全相同,顶点坐标绝对误差不超过 `1e-6`;36 组求值、六个平滑闭合实体和重新打开检查通过。 +- Blender 4.5.10 LTS 中逐组检查总览和实际绘制边界,七组均无节点重叠;Depth Surface 的投影、厚度等阶段另保存局部预览,Repeat 区域检查通过。所有组保留默认标题、隐藏未使用输出且无 String 注解。 +- 三套 Blender 资产验证正常退出。完整回归为 650 项通过、2 项预期失败、59 项子测试通过;开发环境 bpy 在测试完成后的退出阶段仍返回 1。 +- 布局规则已写入 AGENTS.md。最新预览与六物体审阅场景位于同级 `all-geometry-layout/`,七组总览分别按节点组名称保存为 PNG,`drawn-layouts.json` 记录实际尺寸,`semantics.json` 与 `geometry-comparison.json` 记录比较结果。 + +## 紧凑布局与同级输入复核 + +- 全部七组按实际绘制边界比较,布局宽度减少约 13–26%,高度减少约 30–37%。同一目的的计算、正背面汇合与局部输入进一步聚拢,四个 Shape 在菜单前按输入顺序排成一列。 +- 验证独立同级来源的输出端对齐,并保留共享上游与后续处理的主轴关系。实际节点边界与厚度 Repeat 区域检查通过,运算、默认值和实际连接与本轮重排前一致。 +- 65 项相关测试及三套 Blender 4.5.10 LTS 资产验证全部通过,进程正常退出。预览位于同级 `compact-peers-layout/`,`*-peers.png` 为汇合局部,`layout-*.png` 为 Depth Surface 阶段预览。 + +## 用途分组与 XYZ 同级输入复核 + +- Depth Plane 的深度偏移与厚度权重计算收在主轴同一侧;Depth Surface 的 Split 判别和投影支线内部收紧,主轴与不同用途之间保留更大间距。 +- Combine XYZ 的独立输入分支末端对齐,供多个部分使用的共享上游保留在公共位置。几何主轴、同级来源、未使用输出、实际节点边界与 Repeat 区域检查通过,七个几何节点组均在 Blender 中检查总览和相关局部。 +- 与本轮修改前资产比较,全部十个节点组的运算、默认值和实际连接相同。65 项相关测试、三套 Blender 4.5.10 LTS 资产验证通过,进程正常退出。 +- 本轮预览位于同级 `branch-clusters-layout/`,`O Image Depth Surface-selection.png` 为选边判别,两个 `*-projection.png` 为置换计算,`*-xyz.png` 为 XYZ 汇合;`drawn-layouts.json` 和 `semantics.json` 保存实际边界与一致性记录。 + diff --git a/src/anyimage/assets/O_AnyImage.blend b/src/anyimage/assets/O_AnyImage.blend index 11b4607..be54fc8 100644 --- a/src/anyimage/assets/O_AnyImage.blend +++ b/src/anyimage/assets/O_AnyImage.blend @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:26fbd31b659a16a865c2aa82c91817898f9bd04acd41c72e609a24546054f12a -size 1719984 +oid sha256:9e0b94f8957625b95f9bccb1c99b059c52ef058080e76eabcaa8416917f3a2c3 +size 2059430 diff --git a/tests/test_cutout_objects.py b/tests/test_cutout_objects.py index 174331d..362adf5 100644 --- a/tests/test_cutout_objects.py +++ b/tests/test_cutout_objects.py @@ -531,7 +531,7 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups(): "Smooth Iterations", "Cleanup", "Smooth", - "Stretch Limit", + "Split", "Reference Depth", "Depth Direction", "Uniform Scale", @@ -542,7 +542,7 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups(): assert [item.name for item in panels[0].interface_items] == [ "Cleanup", "Smooth", - "Stretch Limit", + "Split", "Reference Depth", "Depth Direction", ] @@ -634,7 +634,8 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups(): "Geometry", "Thickness", "Depth Scale", - "Stretch Limit", + "Split", + "Cleanup", "Reference Depth", "Uniform Scale", "Depth Image", @@ -653,9 +654,10 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups(): ) dependencies.setdefault(link.from_socket.name, set()).add(shape) assert dependencies == { + "Geometry": {"DEPTH_SURFACE"}, "Thickness": set(cutout_shape.CUTOUT_SHAPES), "Depth Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, - "Stretch Limit": {"DEPTH_SURFACE"}, + "Split": {"DEPTH_SURFACE"}, "Double Sided": {"DEPTH_BALLOON"}, "Reference Depth": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, "Depth Direction": {"DEPTH_BALLOON"}, @@ -672,7 +674,7 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups(): ) assert all( not any(node.bl_idname == "GeometryNodeInputMeshIsland" for node in group.nodes) - for group in child_groups.values() + for shape, group in child_groups.items() if shape != "DEPTH_SURFACE" ) assert any(node.bl_idname == "GeometryNodeBlurAttribute" for node in cutout.nodes) assert all( @@ -710,112 +712,33 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups(): ) assert cleanup.default_value == 0.0 assert cleanup.subtype == "NONE" - stretch_limit = next( + split_control = next( item for item in cutout.interface.items_tree - if item.item_type == "SOCKET" and item.name == "Stretch Limit" + if item.item_type == "SOCKET" and item.name == "Split" ) - child_stretch_limit = next( + child_split = next( item for item in child_groups["DEPTH_SURFACE"].interface.items_tree - if item.item_type == "SOCKET" and item.name == "Stretch Limit" + if item.item_type == "SOCKET" and item.name == "Split" ) - assert stretch_limit.default_value == 0.0 - assert stretch_limit.min_value == 0.0 - assert stretch_limit.max_value == 1.0 - assert stretch_limit.subtype == "FACTOR" - assert getattr(stretch_limit.parent, "name", "") == "Options" - assert child_stretch_limit.default_value == 0.0 - assert child_stretch_limit.min_value == 0.0 - assert child_stretch_limit.max_value == 1.0 - assert child_stretch_limit.subtype == "FACTOR" - assert getattr(child_stretch_limit.parent, "name", "") == "Options" + assert split_control.default_value == 0.0 + assert split_control.min_value == 0.0 + assert split_control.max_value == 1.0 + assert split_control.subtype == "FACTOR" + assert getattr(split_control.parent, "name", "") == "Options" + assert child_split.default_value == 0.0 + assert child_split.min_value == 0.0 + assert child_split.max_value == 1.0 + assert child_split.subtype == "FACTOR" + assert getattr(child_split.parent, "name", "") == "Options" depth_surface_nodes = child_groups["DEPTH_SURFACE"].nodes - captures = [ - node - for node in depth_surface_nodes - if node.bl_idname == "GeometryNodeCaptureAttribute" - ] - assert len(captures) == 1 - assert captures[0].domain == "POINT" - assert [item.name for item in captures[0].capture_items] == [ - "Original Edge Length" - ] - captured_distance = next( - link.from_node - for link in child_groups["DEPTH_SURFACE"].links - if link.to_node == captures[0] - and link.to_socket.name == "Original Edge Length" - ) - assert captured_distance.bl_idname == "ShaderNodeVectorMath" - assert captured_distance.operation == "DISTANCE" - ratio = next( - link.to_node - for link in child_groups["DEPTH_SURFACE"].links - if link.from_node == captured_distance - and link.to_node.bl_idname == "ShaderNodeMath" - and link.to_node.operation == "DIVIDE" - ) - assert any( - link.from_node == captures[0] - and link.to_node == ratio - and link.to_socket == ratio.inputs[0] - for link in child_groups["DEPTH_SURFACE"].links - ) - assert any( - link.from_node == captured_distance - and link.to_node == ratio - and link.to_socket == ratio.inputs[1] - for link in child_groups["DEPTH_SURFACE"].links - ) - captured_distance = next( - link.from_node - for link in child_groups["DEPTH_SURFACE"].links - if link.to_node == captures[0] - and link.to_socket.name == "Original Edge Length" - ) - assert captured_distance.bl_idname == "ShaderNodeVectorMath" - assert captured_distance.operation == "DISTANCE" - ratio = next( - link.to_node - for link in child_groups["DEPTH_SURFACE"].links - if link.from_node == captured_distance - and link.to_node.bl_idname == "ShaderNodeMath" - and link.to_node.operation == "DIVIDE" - ) - assert any( - link.from_node == captures[0] - and link.to_node == ratio - and link.to_socket == ratio.inputs[0] - for link in child_groups["DEPTH_SURFACE"].links - ) - assert any( - link.from_node == captured_distance - and link.to_node == ratio - and link.to_socket == ratio.inputs[1] - for link in child_groups["DEPTH_SURFACE"].links - ) - samples = [ - node - for node in depth_surface_nodes - if node.bl_idname == "GeometryNodeSampleIndex" - ] - assert not samples - delete_domains = [ - node.domain - for node in depth_surface_nodes - if node.bl_idname == "GeometryNodeDeleteGeometry" - ] - assert delete_domains.count("EDGE") == 1 - assert delete_domains.count("POINT") == 2 - assert any( - node.bl_idname == "GeometryNodeInputMeshEdgeNeighbors" - for node in depth_surface_nodes - ) - assert any( - node.bl_idname == "GeometryNodeInputMeshVertexNeighbors" - for node in depth_surface_nodes - ) + assert any(node.bl_idname == "GeometryNodeSplitEdges" for node in depth_surface_nodes) + from validate_cutout import validate_depth_surface_attributes + validate_depth_surface_attributes(child_groups["DEPTH_SURFACE"]) + assert [node.domain for node in depth_surface_nodes + if node.bl_idname == "GeometryNodeDeleteGeometry"] == ["FACE"] + assert "Stretch Limit" not in [item.name for item in child_groups["DEPTH_SURFACE"].interface.items_tree] cleanup_scale = next( link.to_node for link in cutout.links @@ -1547,20 +1470,20 @@ def _add_cutout_modifier(obj, shape): return modifier -def _depth_surface_stretch_object(): +def _depth_surface_step_object(): vertices = [(float(x), float(y), 0.0) for y in range(3) for x in range(3)] faces = [] for y in range(2): for x in range(2): a = y * 3 + x faces.extend(((a, a + 1, a + 4), (a, a + 4, a + 3))) - data = bpy.data.meshes.new("Depth Surface Stretch") + data = bpy.data.meshes.new("Depth Surface Step") data.from_pydata(vertices, (), faces) uv_layer = data.uv_layers.new(name="UVMap") for loop in data.loops: coordinate = data.vertices[loop.vertex_index].co uv_layer.data[loop.index].uv = (coordinate.x * 0.5, coordinate.y * 0.5) - obj = bpy.data.objects.new("Depth Surface Stretch", data) + obj = bpy.data.objects.new("Depth Surface Step", data) bpy.context.collection.objects.link(obj) modifier = _add_cutout_modifier(obj, "DEPTH_SURFACE") @@ -1617,40 +1540,17 @@ def _evaluated_topology(obj): evaluated.to_mesh_clear() -def test_depth_surface_stretch_limit_tracks_projection_and_cleans_loose_topology(): +def test_depth_surface_split_preserves_original_faces(): bpy.ops.wm.read_factory_settings(use_empty=True) - obj, modifier = _depth_surface_stretch_object() - stretch_id = object_data.modifier_input_identifier( - modifier.node_group, - "Stretch Limit", - ) - depth_scale_id = object_data.modifier_input_identifier( - modifier.node_group, - "Depth Scale", - ) - - object_data.set_modifier_input(modifier, stretch_id, 0.0) - bpy.context.view_layer.update() - assert _evaluated_topology(obj) == (9, 8, set(), set()) - - object_data.set_modifier_input(modifier, stretch_id, 0.5) - obj.update_tag(refresh={"DATA"}) - bpy.context.view_layer.update() - assert _evaluated_topology(obj) == (9, 8, set(), set()) - - object_data.set_modifier_input(modifier, stretch_id, 0.6) - obj.update_tag(refresh={"DATA"}) - bpy.context.view_layer.update() - vertices, faces, loose_edges, loose_points = _evaluated_topology(obj) - assert 0 < faces < 8 - assert vertices < 9 - assert not loose_edges - assert not loose_points - - object_data.set_modifier_input(modifier, depth_scale_id, 0.0) - obj.update_tag(refresh={"DATA"}) - bpy.context.view_layer.update() - assert _evaluated_topology(obj) == (9, 8, set(), set()) + obj, modifier = _depth_surface_step_object() + split_id = object_data.modifier_input_identifier(modifier.node_group, "Split") + for value in (0., .5, 1.): + object_data.set_modifier_input(modifier, split_id, value) + obj.update_tag(refresh={"DATA"}) + bpy.context.view_layer.update() + vertices, faces, loose_edges, loose_points = _evaluated_topology(obj) + assert vertices >= 9 and faces == 8 + assert not loose_edges and not loose_points def test_cutout_thickness_keeps_one_connected_shell(): diff --git a/tests/test_depth_surface_nodes.py b/tests/test_depth_surface_nodes.py new file mode 100644 index 0000000..b60fbb2 --- /dev/null +++ b/tests/test_depth_surface_nodes.py @@ -0,0 +1,321 @@ +"""Behavior checks for face separation and connected Depth Surface shells.""" + +import sys +from pathlib import Path +from collections import Counter + +import bpy +import bmesh +import numpy as np +import pytest + +ROOT = Path(__file__).parents[1] +sys.path.insert(0, str(ROOT / "src")) +sys.path.insert(0, str(ROOT / "tools" / "node_assets")) +from build_cutout import build_node_group, build_cutout_modifier_group + + +def surface(*, step=6.0, size=1.0, resolution=2, wrapper=False): + bpy.ops.wm.read_factory_settings(use_empty=True) + groups = { + shape: build_node_group(shape) + for shape in ("SURFACE", "BALLOON", "DEPTH_BALLOON", "DEPTH_SURFACE") + } + group = build_cutout_modifier_group(groups) if wrapper else groups["DEPTH_SURFACE"] + # Two columns with a complete depth jump along their common boundary. + points = [ + (x * size / resolution, y * size / resolution, 0) + for y in range(resolution + 1) + for x in range(2 * resolution + 1) + ] + stride = 2 * resolution + 1 + faces = [ + ( + y * stride + x, + y * stride + x + 1, + (y + 1) * stride + x + 1, + (y + 1) * stride + x, + ) + for y in range(resolution) + for x in range(2 * resolution) + ] + mesh = bpy.data.meshes.new("Step") + mesh.from_pydata(points, [], faces) + uv = mesh.uv_layers.new(name="UVMap") + for loop in mesh.loops: + x, y, _ = points[loop.vertex_index] + uv.data[loop.index].uv = (x / (2 * size), y / size) + obj = bpy.data.objects.new("Step", mesh) + bpy.context.collection.objects.link(obj) + image = bpy.data.images.new("Camera", width=64, height=32, float_buffer=True) + image.colorspace_settings.name = "Non-Color" + image.alpha_mode = "CHANNEL_PACKED" + yy, xx = np.mgrid[:32, :64] + pixels = np.ones((32, 64, 4), np.float32) + pixels[..., 0] = (xx + 0.5) / 32 + pixels[..., 1] = -(yy + 0.5) / 32 + pixels[..., 2] = 1 + (xx >= 32) * step + image.pixels.foreach_set(pixels.ravel()) + modifier = obj.modifiers.new("Depth Surface", "NODES") + modifier.node_group = group + inputs = { + s.name: s.identifier + for s in group.interface.items_tree + if s.item_type == "SOCKET" and s.in_out == "INPUT" + } + + def set_value(name, value): + modifier[inputs[name]] = value + obj.update_tag(refresh={"DATA"}) + bpy.context.view_layer.update() + + for name, value in ( + ("Depth Image", image), + ("Uniform Scale", size), + ("Reference Depth", size), + ("Depth Scale", 1.0), + ("Thickness", 0.0), + ("Cleanup", 0.0), + ("Split", 0.0), + ): + set_value(name, value) + if wrapper: + set_value("Shape", 3) + set_value("Inward Axis", 0) + set_value("Smooth Iterations", 0) + return obj, set_value + + +def evaluated(obj): + result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) + mesh = result.to_mesh() + try: + return np.array([v.co[:] for v in mesh.vertices]), [ + tuple(p.vertices) for p in mesh.polygons + ] + finally: + result.to_mesh_clear() + + +def assert_closed(faces): + edges = Counter( + tuple(sorted((a, b))) for f in faces for a, b in zip(f, (*f[1:], f[0])) + ) + assert edges and set(edges.values()) == {2}, Counter(edges.values()) + + +@pytest.mark.parametrize("thickness", [0.1, 1.4]) +@pytest.mark.parametrize("split", [0.0, 0.5]) +def test_surface_caps_and_segmented_walls_are_connected(thickness, split): + obj, set_value = surface() + set_value("Split", split) + set_value("Thickness", thickness) + coords, faces = evaluated(obj) + assert np.isfinite(coords).all() + assert_closed(faces) + assert coords[:, 2].min() == pytest.approx(-6 - thickness, abs=1e-5) + + +@pytest.mark.parametrize("size", [0.01, 1.0, 100.0]) +def test_split_mapping_and_uniform_scale(size): + obj, set_value = surface(step=2.0, size=size) + # Face centers are size/2 apart: gradient is 4. + counts = [] + for split in (0.0, 0.25, 0.5, 1.0): + set_value("Split", split) + coords, faces = evaluated(obj) + counts.append(len(coords)) + assert len(faces) == 8 + assert counts[:3] == [15] * 3 + assert counts[3] == 18 + set_value("Depth Scale", 0.0) + coords, faces = evaluated(obj) + assert len(coords) == 15 and np.abs(coords[:, 2]).max() < 1e-6 + + +def test_midpoint_selects_gradient_six_and_preserves_faces(): + obj, set_value = surface(step=3.0) + set_value("Split", 0.5) + coords, faces = evaluated(obj) + assert len(coords) == 18 and len(faces) == 8 + assert set(np.round(coords[:, 2], 4)) == {0.0, -3.0} + + +def test_cleanup_removes_new_islands_before_thickness(): + obj, set_value = surface() + set_value("Cleanup", 1.1) + assert len(evaluated(obj)[1]) == 8 + set_value("Split", 0.5) + assert len(evaluated(obj)[1]) == 0 + set_value("Thickness", 0.2) + assert len(evaluated(obj)[1]) == 0 + + +def test_full_wrapper_smooth_keeps_closed_shell(): + obj, set_value = surface(wrapper=True) + set_value("Split", 0.5) + set_value("Thickness", 0.2) + set_value("Smooth Iterations", 2) + coords, faces = evaluated(obj) + assert np.isfinite(coords).all() + assert_closed(faces) + + +def component_count(faces): + adjacency = {} + for face in faces: + for a, b in zip(face, (*face[1:], face[0])): + adjacency.setdefault(a, set()).add(b) + adjacency.setdefault(b, set()).add(a) + remaining = set(adjacency) + count = 0 + while remaining: + count += 1 + pending = [remaining.pop()] + while pending: + for neighbor in adjacency[pending.pop()] & remaining: + remaining.remove(neighbor) + pending.append(neighbor) + return count + + +def test_coincident_independent_sheets_do_not_weld_together(): + obj, set_value = surface(step=0.0) + mesh = obj.data + vertices = [tuple(v.co) for v in mesh.vertices] + faces = [tuple(f.vertices) for f in mesh.polygons] + n = len(vertices) + duplicate = bpy.data.meshes.new("Close sheets") + duplicate.from_pydata( + vertices + [(x, y, z + 1e-8) for x, y, z in vertices], + [], + faces + [tuple(v + n for v in face) for face in faces], + ) + uv = duplicate.uv_layers.new(name="UVMap") + for loop in duplicate.loops: + x, y, z = duplicate.vertices[loop.vertex_index].co + uv.data[loop.index].uv = (x / 2, y) + obj.data = duplicate + set_value("Thickness", 0.1) + coords, faces = evaluated(obj) + assert_closed(faces) + assert component_count(faces) == 2 + + +@pytest.mark.parametrize("resolution", [1, 2, 4]) +def test_planar_depth_has_no_split_at_any_density(resolution): + obj, set_value = surface(step=0.0, resolution=resolution) + original, original_faces = evaluated(obj) + set_value("Split", 1.0) + coords, faces = evaluated(obj) + assert len(coords) == len(obj.data.vertices) + assert len(faces) == len(obj.data.polygons) + np.testing.assert_allclose(coords, original, atol=1e-6) + + +def test_small_positive_split_preserves_unsplit_projection(): + obj, set_value = surface(step=2.0) + original, original_faces = evaluated(obj) + set_value("Split", 0.01) + coords, faces = evaluated(obj) + np.testing.assert_allclose(coords, original, atol=1e-6) + assert faces == original_faces + + +def test_cut_vertices_preserve_camera_rays_and_other_vertices(): + obj, set_value = surface(step=3.0) + original, original_faces = evaluated(obj) + set_value("Split", 0.5) + coords, faces = evaluated(obj) + for source_face, result_face in zip(original_faces, faces): + for source_index, result_index in zip(source_face, result_face): + before, after = original[source_index], coords[result_index] + if obj.data.vertices[source_index].co.x != 1.0: + np.testing.assert_allclose(after, before, atol=1e-6) + before_depth, after_depth = 1.0 - before[2], 1.0 - after[2] + np.testing.assert_allclose( + after[:2] / after_depth, before[:2] / before_depth, atol=1e-6 + ) + + +def test_stored_point_positions_prevent_depth_resampling_on_side_walls(): + obj, set_value = surface(step=3.0) + set_value("Split", 0.5) + set_value("Thickness", 0.1) + coords, faces = evaluated(obj) + assert_closed(faces) + assert set(np.round(coords[:, 2], 4)) == {0.0, -0.1, -3.0, -3.1} + + +@pytest.mark.parametrize("thickness", [0.0, 0.2]) +@pytest.mark.parametrize("split", [0.0, 0.5]) +def test_temporary_attributes_are_removed_and_user_attributes_survive(thickness, split): + obj, set_value = surface(wrapper=True) + obj.data.attributes.new("o_depth_probe", "FLOAT", "POINT") + attribute = obj.data.attributes.new("o_user_weight", "FLOAT", "POINT") + for value in attribute.data: + value.value = 0.75 + set_value("Split", split) + set_value("Thickness", thickness) + set_value("Smooth Iterations", 2) + result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) + mesh = result.to_mesh() + try: + assert not any(a.name.startswith("o_depth_") for a in mesh.attributes) + assert "UVMap" in mesh.attributes + assert all( + v.value == pytest.approx(0.75) + for v in mesh.attributes["o_user_weight"].data + ) + finally: + result.to_mesh_clear() + + +def test_zero_center_distance_does_not_split_or_produce_nan(): + obj, set_value = surface(step=6.0) + mesh = bpy.data.meshes.new("Coincident face centers") + mesh.from_pydata( + [(0, 0, 0), (1, 0, 0), (0.5, 1, 0), (0.5, 1, 0)], [], [(0, 1, 2), (1, 0, 3)] + ) + uv = mesh.uv_layers.new(name="UVMap") + for face in mesh.polygons: + for li in face.loop_indices: + uv.data[li].uv = (0.25 if face.index == 0 else 0.75, 0.5) + obj.data = mesh + set_value("Split", 1.0) + coords, faces = evaluated(obj) + assert len(coords) == 4 and len(faces) == 2 + assert np.isfinite(coords).all() + + +def test_triangle_corner_weights_do_not_open_sloped_side_walls(): + obj, set_value = surface(step=3.0) + bm = bmesh.new() + bm.from_mesh(obj.data) + bmesh.ops.triangulate(bm, faces=list(bm.faces)) + bm.to_mesh(obj.data) + bm.free() + image = bpy.data.images["Camera"] + pixels = np.array(image.pixels[:], np.float32).reshape(32, 64, 4) + pixels[..., 2] += np.arange(32)[:, None] * 0.02 + image.pixels.foreach_set(pixels.ravel()) + image.update() + set_value("Split", 0.5) + set_value("Thickness", 0.8) + coords, faces = evaluated(obj) + assert_closed(faces) + assert component_count(faces) == 2 + + +@pytest.mark.parametrize("resolution", [1, 2, 4]) +def test_linear_depth_slope_has_density_independent_selection(resolution): + obj, set_value = surface(step=0.0, resolution=resolution) + image = bpy.data.images["Camera"] + pixels = np.array(image.pixels[:], np.float32).reshape(32, 64, 4) + pixels[..., 2] = 1 + 4 * pixels[..., 0] + image.pixels.foreach_set(pixels.ravel()) + image.update() + set_value("Split", 0.5) + assert len(evaluated(obj)[0]) == len(obj.data.vertices) + set_value("Split", 1.0) + assert len(evaluated(obj)[0]) > len(obj.data.vertices) diff --git a/tests/test_node_assets.py b/tests/test_node_assets.py index 297f229..0a9d94b 100644 --- a/tests/test_node_assets.py +++ b/tests/test_node_assets.py @@ -1,4 +1,5 @@ import unittest +import sys from pathlib import Path @@ -7,6 +8,26 @@ SOURCE_ROOT = PROJECT_ROOT / "src" / "anyimage" class NodeAssetsTest(unittest.TestCase): + def test_geometry_assets_have_readable_local_layouts(self): + import bpy + + sys.path.insert(0, str(PROJECT_ROOT / "tools" / "node_assets")) + from node_layout import validate_node_layout + + bpy.ops.wm.read_factory_settings(use_empty=True) + with bpy.data.libraries.load( + str(SOURCE_ROOT / "assets" / "O_AnyImage.blend") + ) as (source, target): + target.node_groups = list(source.node_groups) + groups = [ + group + for group in target.node_groups + if group.bl_idname == "GeometryNodeTree" + ] + self.assertEqual(len(groups), 7) + for group in groups: + validate_node_layout(group) + def test_production_code_does_not_create_node_groups(self): offenders = [] for path in SOURCE_ROOT.rglob("*.py"): @@ -15,9 +36,9 @@ class NodeAssetsTest(unittest.TestCase): self.assertEqual(offenders, []) def test_runtime_node_group_entries_load_assets(self): - material_source = ( - SOURCE_ROOT / "common" / "material.py" - ).read_text(encoding="utf-8") + material_source = (SOURCE_ROOT / "common" / "material.py").read_text( + encoding="utf-8" + ) mesh_plane_source = ( SOURCE_ROOT / "operators" / "convert_to_plane" / "mesh_plane.py" ).read_text(encoding="utf-8") diff --git a/tools/node_assets/build_cutout.py b/tools/node_assets/build_cutout.py index 4ea6719..4cda7a8 100644 --- a/tools/node_assets/build_cutout.py +++ b/tools/node_assets/build_cutout.py @@ -3,6 +3,14 @@ import bpy from node_graph import expand_smooth +from cutout_depth_surface import ( + store_surface_camera, + remove_surface_attributes, + split_depth_surface, + thicken_depth_surface, +) +from cutout_depth_surface_layout import layout_depth_surface +from geometry_layouts import layout_geometry from anyimage.operators.cutout_tool.shape import ( BALLOON_ATTRIBUTE_NAME, @@ -427,81 +435,6 @@ def _position_field(group, depth_image, location): return position_texture.outputs["Color"] -def _capture_edge_length(group, geometry, name, location): - nodes = group.nodes - links = group.links - x, y = location - edge_vertices = nodes.new("GeometryNodeInputMeshEdgeVertices") - edge_vertices.location = (x, y + 250.0) - edge_length = nodes.new("ShaderNodeVectorMath") - edge_length.operation = "DISTANCE" - edge_length.location = (x + 220.0, y + 250.0) - links.new(edge_vertices.outputs["Position 1"], edge_length.inputs[0]) - links.new(edge_vertices.outputs["Position 2"], edge_length.inputs[1]) - capture = nodes.new("GeometryNodeCaptureAttribute") - capture.domain = "POINT" - capture.capture_items.clear() - capture.capture_items.new("FLOAT", name) - capture.location = (x + 440.0, y) - links.new(geometry, capture.inputs["Geometry"]) - links.new(edge_length.outputs["Value"], capture.inputs[name]) - return capture.outputs["Geometry"], capture.outputs[name], edge_length.outputs["Value"] - - -def _remove_stretched_points( - group, - geometry, - original_edge_length, - current_edge_length, - stretch_limit, - location, -): - nodes = group.nodes - links = group.links - x, y = location - - remaining_ratio = _math(nodes, "DIVIDE", (x + 220.0, y + 300.0)) - links.new(original_edge_length, remaining_ratio.inputs[0]) - links.new(current_edge_length, remaining_ratio.inputs[1]) - stretched = _math(nodes, "LESS_THAN", (x + 450.0, y + 200.0)) - links.new(remaining_ratio.outputs[0], stretched.inputs[0]) - links.new(stretch_limit, stretched.inputs[1]) - delete_stretched = nodes.new("GeometryNodeDeleteGeometry") - delete_stretched.domain = "POINT" - delete_stretched.mode = "ALL" - delete_stretched.location = (x + 680.0, y) - links.new(geometry, delete_stretched.inputs["Geometry"]) - links.new(stretched.outputs[0], delete_stretched.inputs["Selection"]) - - edge_neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors") - edge_neighbors.location = (x + 930.0, y + 500.0) - loose_edge = _math(nodes, "LESS_THAN", (x + 1150.0, y + 500.0)) - loose_edge.inputs[1].default_value = 1.0 - links.new(edge_neighbors.outputs["Face Count"], loose_edge.inputs[0]) - delete_loose_edges = nodes.new("GeometryNodeDeleteGeometry") - delete_loose_edges.domain = "EDGE" - delete_loose_edges.mode = "ALL" - delete_loose_edges.location = (x + 1370.0, y) - links.new(delete_stretched.outputs["Geometry"], delete_loose_edges.inputs["Geometry"]) - links.new(loose_edge.outputs[0], delete_loose_edges.inputs["Selection"]) - - vertex_neighbors = nodes.new("GeometryNodeInputMeshVertexNeighbors") - vertex_neighbors.location = (x + 1620.0, y + 500.0) - loose_point = _math(nodes, "LESS_THAN", (x + 1840.0, y + 500.0)) - loose_point.inputs[1].default_value = 1.0 - links.new(vertex_neighbors.outputs["Face Count"], loose_point.inputs[0]) - delete_loose_points = nodes.new("GeometryNodeDeleteGeometry") - delete_loose_points.domain = "POINT" - delete_loose_points.mode = "ALL" - delete_loose_points.location = (x + 2060.0, y) - links.new( - delete_loose_edges.outputs["Geometry"], - delete_loose_points.inputs["Geometry"], - ) - links.new(loose_point.outputs[0], delete_loose_points.inputs["Selection"]) - return delete_loose_points.outputs["Geometry"] - - def _plane_depth(group, direction, reference_depth, location): nodes = group.nodes links = group.links @@ -673,20 +606,22 @@ def _build_depth_surface( geometry, thickness, depth_scale, - stretch_limit, + split, uniform_scale, reference_depth, depth_image, + cleanup, ): nodes = group.nodes links = group.links - geometry, original_edge_length, current_edge_length = _capture_edge_length( - group, - geometry, - "Original Edge Length", - (400.0, 850.0), + geometry = split_depth_surface( + group, geometry, depth_image, split, uniform_scale, depth_scale + ) + geometry = _island_cleanup(group, geometry, cleanup, (1800.0, 850.0)) + original_camera = _position_field(group, depth_image, (-200.0, -200.0)) + geometry, camera_position = store_surface_camera( + group, geometry, original_camera, split ) - camera_position = _position_field(group, depth_image, (-200.0, -200.0)) scaled = nodes.new("ShaderNodeVectorMath") scaled.operation = "SCALE" scaled.location = (3100.0, -100.0) @@ -731,45 +666,12 @@ def _build_depth_surface( links.new(geometry, project_geometry.inputs["Geometry"]) links.new(surface_position.outputs["Vector"], project_geometry.inputs["Position"]) - geometry = _remove_stretched_points( - group, - project_geometry.outputs["Geometry"], - original_edge_length, - current_edge_length, - stretch_limit, - (4700.0, 400.0), - ) - zero_z = nodes.new("ShaderNodeValue") - zero_z.location = (7100.0, 100.0) - zero_z.outputs["Value"].default_value = 0.0 - geometry = _set_geometry_z( - group, - geometry, - zero_z.outputs["Value"], - (7200.0, 400.0), - ) - - back = _math(nodes, "SUBTRACT", (3900.0, -100.0)) - links.new(front.outputs[0], back.inputs[0]) - links.new(thickness, back.inputs[1]) - shaped = _set_segmented_layer_z( - group, - geometry, - front.outputs[0], - back.outputs[0], - thickness, - (8300.0, 400.0), - ) - front_only = _set_geometry_z( - group, - geometry, - front.outputs[0], - (8500.0, -200.0), - ) + geometry = project_geometry.outputs["Geometry"] + shaped = thicken_depth_surface(group, geometry, thickness) geometry = _remove_zero_thickness( - group, front_only, shaped, thickness, (11800.0, 400.0) + group, project_geometry.outputs["Geometry"], shaped, thickness, (11800.0, 400.0) ) - return geometry + return remove_surface_attributes(group, geometry) def build_node_group(shape): @@ -791,7 +693,7 @@ def build_node_group(shape): thickness.description = "Final thickness in scene distance" depth_image = None - stretch_limit = None + split = None if shape == "DEPTH_BALLOON": depth_scale = _float_input(group, "Depth Scale", 1.0) depth_scale.description = "Scale the fitted-plane-corrected metric depth" @@ -828,19 +730,18 @@ def build_node_group(shape): ) elif shape == "DEPTH_SURFACE": options = group.interface.new_panel(name="Options", default_closed=True) - stretch_limit = _float_input( + split = _float_input( group, - "Stretch Limit", + "Split", 0.0, 0.0, 1.0, subtype="FACTOR", parent=options, ) - stretch_limit.description = ( - "Delete faces when original edge length divided by projected edge " - "length falls below this threshold; zero disables culling" - ) + split.description = "Separate depth discontinuities; zero disables splitting" + cleanup = _float_input(group, "Cleanup", 0.0, 0.0, None, parent=options) + cleanup.description = "Remove split islands below this local-area threshold" reference_depth = _float_input( group, "Reference Depth", @@ -880,7 +781,8 @@ def build_node_group(shape): "Uniform Scale", "Reference Depth", "Depth Direction", - "Stretch Limit", + "Split", + "Cleanup", }, ) geometry = controls.outputs["Geometry"] @@ -913,14 +815,19 @@ def build_node_group(shape): geometry, controls.outputs["Thickness"], controls.outputs["Depth Scale"], - controls.outputs["Stretch Limit"], + controls.outputs["Split"], controls.outputs["Uniform Scale"], controls.outputs["Reference Depth"], controls.outputs["Depth Image"], + controls.outputs["Cleanup"], ) output_location = (12100.0, 400.0) _localize_group_inputs(group, controls) _shade_output(group, geometry, output_location) + if shape == "DEPTH_SURFACE": + layout_depth_surface(group) + else: + layout_geometry(group) group.color_tag = "GEOMETRY" group.description = { "SURFACE": "Symmetrically thicken a Surface sheet.", @@ -983,19 +890,16 @@ def build_cutout_modifier_group(shape_groups): parent=options, ) smooth.description = "Blend each smoothing pass into the selected shape" - stretch_limit = _float_input( + split = _float_input( group, - "Stretch Limit", + "Split", 0.0, 0.0, 1.0, subtype="FACTOR", parent=options, ) - stretch_limit.description = ( - "Delete Depth Surface faces when original edge length divided by projected " - "edge length falls below this threshold; zero disables culling" - ) + split.description = "Separate Depth Surface discontinuities; zero disables splitting" reference_depth = _float_input( group, @@ -1054,7 +958,10 @@ def build_cutout_modifier_group(shape_groups): node.node_tree = shape_groups[shape] node.location = (-850.0, y) node.width = 220.0 - group.links.new(geometry, node.inputs["Geometry"]) + group.links.new( + base_controls.outputs["Geometry"] if shape == "DEPTH_SURFACE" else geometry, + node.inputs["Geometry"], + ) visible = { socket.name for socket in node.inputs @@ -1064,7 +971,10 @@ def build_cutout_modifier_group(shape_groups): for socket in node.inputs: if not socket.name or socket.name == "Geometry": continue - group.links.new(controls.outputs[socket.name], socket) + group.links.new( + cleanup_area.outputs[0] if socket.name == "Cleanup" else controls.outputs[socket.name], + socket, + ) branch_nodes[shape] = node menu_switch = group.nodes.new("GeometryNodeMenuSwitch") @@ -1114,10 +1024,14 @@ def build_cutout_modifier_group(shape_groups): group.links.new(geometry, axis_switch.inputs["-Z"]) group.links.new(orient.outputs["Geometry"], axis_switch.inputs["+X"]) _shade_output(group, axis_switch.outputs["Output"], (3850.0, 550.0)) + layout_geometry(group) group.color_tag = "GEOMETRY" group.description = ( "Build Surface, Balloon, Depth Balloon, or Depth Surface from one " "menu-controlled Cutout modifier." ) + for node in group.nodes: + for socket in node.outputs: + socket.hide = not socket.is_linked group.use_fake_user = True return group diff --git a/tools/node_assets/build_depth_plane.py b/tools/node_assets/build_depth_plane.py index 8cb922c..f088ddf 100644 --- a/tools/node_assets/build_depth_plane.py +++ b/tools/node_assets/build_depth_plane.py @@ -2,6 +2,8 @@ import bpy +from geometry_layouts import layout_geometry + DEPTH_PLANE_NODE_GROUP_NAME = "O Image Depth Plane" @@ -206,4 +208,5 @@ def build_depth_plane_group(mesh_plane_group): "Build a single-sided depth terrain block with a fixed base." ) group.use_fake_user = True + layout_geometry(group) return group diff --git a/tools/node_assets/build_mesh_plane.py b/tools/node_assets/build_mesh_plane.py index a612d9b..7ea1d26 100644 --- a/tools/node_assets/build_mesh_plane.py +++ b/tools/node_assets/build_mesh_plane.py @@ -2,6 +2,8 @@ import bpy +from geometry_layouts import layout_geometry + MESH_PLANE_GROUP_NAME = "O Mesh Plane" @@ -27,6 +29,31 @@ def new_group_input(nodes, location, visible_names): return node +def localize_dimensions(group): + """Repeat the small source-bounds calculation near UV and mesh consumers.""" + sources = [ + group.nodes[name] for name in ("Bounding Box", "Vector Math", "Separate XYZ") + ] + for targets in ( + {"Math.011", "Math.013"}, + {"Grid", "Combine XYZ.001", "Vector Math.001"}, + ): + copies = {source: group.nodes.new(source.bl_idname) for source in sources} + copies[sources[1]].operation = "SUBTRACT" + for link in list(group.links): + if link.to_node in copies: + source = copies.get(link.from_node, link.from_node) + group.links.new( + source.outputs[link.from_socket.identifier], + copies[link.to_node].inputs[link.to_socket.identifier], + ) + elif link.from_node in copies and link.to_node.name in targets: + group.links.new( + copies[link.from_node].outputs[link.from_socket.identifier], + link.to_socket, + ) + + def build_mesh_plane_group(): group = bpy.data.node_groups.new(MESH_PLANE_GROUP_NAME, "GeometryNodeTree") group.is_modifier = True @@ -256,4 +283,6 @@ def build_mesh_plane_group(): "Build an aspect-adaptive image plane and add optional centered thickness." ) group.use_fake_user = True + localize_dimensions(group) + layout_geometry(group) return group diff --git a/tools/node_assets/cutout_depth_surface.py b/tools/node_assets/cutout_depth_surface.py new file mode 100644 index 0000000..4bed9fd --- /dev/null +++ b/tools/node_assets/cutout_depth_surface.py @@ -0,0 +1,296 @@ +"""Face separation and connected thickness for Depth Surface assets.""" + +TEMPORARY_ATTRIBUTES = { + "face_center": ("FLOAT_VECTOR", "FACE"), + "face_camera": ("FLOAT_VECTOR", "FACE"), + "corner_camera": ("FLOAT_VECTOR", "CORNER"), + "cut_vertex": ("BOOLEAN", "CORNER"), + "camera": ("FLOAT_VECTOR", "POINT"), + "surface_position": ("FLOAT_VECTOR", "POINT"), + "source_vertex": ("INT", "POINT"), + "wall_layers": ("FLOAT", "POINT"), +} + + +def _store(group, geometry, name, value): + node = group.nodes.new("GeometryNodeStoreNamedAttribute") + node.data_type, node.domain = TEMPORARY_ATTRIBUTES[name] + node.inputs["Name"].default_value = "o_depth_" + name + group.links.new(geometry, node.inputs["Geometry"]) + group.links.new(value, node.inputs["Value"]) + return node.outputs["Geometry"] + + +def _read(group, name): + node = group.nodes.new("GeometryNodeInputNamedAttribute") + node.data_type = TEMPORARY_ATTRIBUTES[name][0] + node.inputs["Name"].default_value = "o_depth_" + name + return node.outputs["Attribute"] + + +def remove_surface_attributes(group, geometry): + """Clear the reserved temporary namespace with one wildcard operation.""" + node = group.nodes.new("GeometryNodeRemoveAttribute") + node.pattern_mode = "WILDCARD" + node.inputs["Name"].default_value = "o_depth_*" + group.links.new(geometry, node.inputs["Geometry"]) + return node.outputs["Geometry"] + + +def _math(group, operation, a, b=None): + node = group.nodes.new("ShaderNodeMath") + node.operation = operation + for index, value in enumerate((a, b)): + if value is None: + continue + if isinstance(value, (int, float)): + node.inputs[index].default_value = value + else: + group.links.new(value, node.inputs[index]) + return node.outputs[0] + + +def split_depth_surface(group, geometry, image, strength, uniform, depth_scale): + """Keep each face's camera sample on its own corners before splitting.""" + nodes, links = group.nodes, group.links + uv = nodes.new("GeometryNodeInputNamedAttribute") + uv.data_type = "FLOAT_VECTOR" + uv.inputs["Name"].default_value = "UVMap" + face_uv = nodes.new("GeometryNodeFieldOnDomain") + face_uv.data_type = "FLOAT_VECTOR" + face_uv.domain = "FACE" + links.new(uv.outputs["Attribute"], face_uv.inputs["Value"]) + texture = nodes.new("GeometryNodeImageTexture") + texture.interpolation = "Linear" + texture.extension = "EXTEND" + links.new(image, texture.inputs["Image"]) + links.new(face_uv.outputs["Value"], texture.inputs["Vector"]) + position = nodes.new("GeometryNodeInputPosition") + faces = _store(group, geometry, "face_center", position.outputs[0]) + faces = _store(group, faces, "face_camera", texture.outputs["Color"]) + axes = nodes.new("ShaderNodeSeparateXYZ") + links.new(_read(group, "face_camera"), axes.inputs[0]) + center_field = _read(group, "face_center") + centers, depths = [], [] + for side in (0, 1): + corner = nodes.new("GeometryNodeCornersOfEdge") + corner.inputs["Sort Index"].default_value = side + face = nodes.new("GeometryNodeFaceOfCorner") + links.new(corner.outputs["Corner Index"], face.inputs["Corner Index"]) + for data_type, field, results in ( + ("FLOAT_VECTOR", center_field, centers), + ("FLOAT", axes.outputs["Z"], depths), + ): + sample = nodes.new("GeometryNodeFieldAtIndex") + sample.data_type = data_type + sample.domain = "FACE" + links.new(face.outputs["Face Index"], sample.inputs["Index"]) + links.new(field, sample.inputs["Value"]) + results.append(sample.outputs["Value"]) + distance = nodes.new("ShaderNodeVectorMath") + distance.operation = "DISTANCE" + for index, center in enumerate(centers): + links.new(center, distance.inputs[index]) + delta = _math(group, "ABSOLUTE", _math(group, "SUBTRACT", *depths)) + scaled = _math(group, "MULTIPLY", delta, uniform) + scaled = _math(group, "MULTIPLY", scaled, _math(group, "ABSOLUTE", depth_scale)) + # Cross multiplication avoids division and stays invariant under uniform scaling. + selected = _math( + group, + "GREATER_THAN", + _math(group, "MULTIPLY", scaled, strength), + _math(group, "MULTIPLY", distance.outputs["Value"], 2.5), + ) + selected = _math( + group, + "MULTIPLY", + selected, + _math(group, "GREATER_THAN", distance.outputs["Value"], 0.0), + ) + neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors") + selected = _math( + group, + "MULTIPLY", + selected, + _math(group, "COMPARE", neighbors.outputs["Face Count"], 2.0), + ) + cut_edges = nodes.new("GeometryNodeFieldOnDomain") + cut_edges.data_type = "FLOAT" + cut_edges.domain = "EDGE" + links.new(selected, cut_edges.inputs["Value"]) + cut_vertices = nodes.new("GeometryNodeFieldOnDomain") + cut_vertices.data_type = "FLOAT" + cut_vertices.domain = "POINT" + links.new(cut_edges.outputs["Value"], cut_vertices.inputs["Value"]) + corners = _store(group, faces, "corner_camera", _read(group, "face_camera")) + corners = _store( + group, + corners, + "cut_vertex", + _math(group, "GREATER_THAN", cut_vertices.outputs["Value"], 0.0), + ) + split = nodes.new("GeometryNodeSplitEdges") + links.new(corners, split.inputs["Mesh"]) + links.new(selected, split.inputs["Selection"]) + enabled = _math(group, "GREATER_THAN", strength, 0.0) + choose = nodes.new("GeometryNodeSwitch") + choose.input_type = "GEOMETRY" + links.new(enabled, choose.inputs["Switch"]) + links.new(geometry, choose.inputs["False"]) + links.new(split.outputs["Mesh"], choose.inputs["True"]) + return choose.outputs["Output"] + + +def store_surface_camera(group, geometry, original, strength): + """Change cut depth along the original ray, preserving every other vertex.""" + separated = _read(group, "corner_camera") + enabled = _math( + group, + "MULTIPLY", + _math(group, "GREATER_THAN", strength, 0.0), + _read(group, "cut_vertex"), + ) + original_axes = group.nodes.new("ShaderNodeSeparateXYZ") + separated_axes = group.nodes.new("ShaderNodeSeparateXYZ") + group.links.new(original, original_axes.inputs[0]) + group.links.new(separated, separated_axes.inputs[0]) + ratio = _math( + group, "DIVIDE", separated_axes.outputs["Z"], original_axes.outputs["Z"] + ) + projected = group.nodes.new("ShaderNodeVectorMath") + projected.operation = "SCALE" + group.links.new(original, projected.inputs[0]) + group.links.new(ratio, projected.inputs[3]) + choose = group.nodes.new("GeometryNodeSwitch") + choose.input_type = "VECTOR" + group.links.new(enabled, choose.inputs["Switch"]) + group.links.new(original, choose.inputs["False"]) + group.links.new(projected.outputs[0], choose.inputs["True"]) + geometry = _store(group, geometry, "camera", choose.outputs["Output"]) + return geometry, _read(group, "camera") + + +def thicken_depth_surface(group, geometry, thickness): + """Connect caps and wall rings by source vertex and layer identity.""" + nodes, links = group.nodes, group.links + position = nodes.new("GeometryNodeInputPosition") + geometry = _store(group, geometry, "surface_position", position.outputs[0]) + flattened = nodes.new("ShaderNodeVectorMath") + flattened.operation = "MULTIPLY" + flattened.inputs[1].default_value = (1.0, 1.0, 0.0) + links.new(position.outputs[0], flattened.inputs[0]) + sheet = nodes.new("GeometryNodeSetPosition") + links.new(geometry, sheet.inputs["Geometry"]) + links.new(flattened.outputs[0], sheet.inputs["Position"]) + index = nodes.new("GeometryNodeInputIndex") + geometry = _store( + group, sheet.outputs["Geometry"], "source_vertex", index.outputs[0] + ) + edge = nodes.new("GeometryNodeInputMeshEdgeVertices") + length = nodes.new("ShaderNodeVectorMath") + length.operation = "DISTANCE" + links.new(edge.outputs["Position 1"], length.inputs[0]) + links.new(edge.outputs["Position 2"], length.inputs[1]) + neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors") + statistics = nodes.new("GeometryNodeAttributeStatistic") + statistics.domain = "EDGE" + statistics.data_type = "FLOAT" + links.new(geometry, statistics.inputs["Geometry"]) + links.new( + _math(group, "COMPARE", neighbors.outputs["Face Count"], 1.0), + statistics.inputs["Selection"], + ) + links.new(length.outputs["Value"], statistics.inputs["Attribute"]) + count = _math( + group, + "CEIL", + _math( + group, + "DIVIDE", + thickness, + _math(group, "MAXIMUM", statistics.outputs["Median"], 1e-6), + ), + ) + count = _math(group, "MINIMUM", _math(group, "MAXIMUM", count, 1.0), 128.0) + saved = _store(group, geometry, "wall_layers", count) + + # Weld in an integer identity grid, never in scene coordinates. Nearby cuts + # retain distinct IDs; copies of one rim vertex share the same ID and layer. + identity = nodes.new("ShaderNodeCombineXYZ") + for operation, axis in (("MODULO", "X"), ("DIVIDE", "Y")): + part = nodes.new("FunctionNodeIntegerMath") + part.operation = operation + part.inputs[1].default_value = 4096 + links.new(_read(group, "source_vertex"), part.inputs[0]) + links.new(part.outputs[0], identity.inputs[axis]) + + repeat_output = nodes.new("GeometryNodeRepeatOutput") + repeat_input = nodes.new("GeometryNodeRepeatInput") + repeat_input.pair_with_output(repeat_output) + repeat_output.repeat_items.clear() + repeat_output.repeat_items.new("GEOMETRY", "Geometry") + repeat_output.repeat_items.new("GEOMETRY", "Sides") + links.new(count, repeat_input.inputs["Iterations"]) + links.new(saved, repeat_input.inputs["Geometry"]) + extrude = nodes.new("GeometryNodeExtrudeMesh") + extrude.mode = "FACES" + extrude.inputs["Individual"].default_value = False + extrusion_offset = nodes.new("ShaderNodeCombineXYZ") + extrusion_offset.inputs["Z"].default_value = -1.0 + links.new(extrusion_offset.outputs[0], extrude.inputs["Offset"]) + links.new(repeat_input.outputs["Geometry"], extrude.inputs["Mesh"]) + sides = nodes.new("GeometryNodeSeparateGeometry") + sides.domain = "FACE" + links.new(extrude.outputs["Mesh"], sides.inputs["Geometry"]) + links.new(extrude.outputs["Side"], sides.inputs["Selection"]) + join_sides = nodes.new("GeometryNodeJoinGeometry") + links.new(repeat_input.outputs["Sides"], join_sides.inputs["Geometry"]) + links.new(sides.outputs["Selection"], join_sides.inputs["Geometry"]) + links.new(sides.outputs["Inverted"], repeat_output.inputs["Geometry"]) + links.new(join_sides.outputs["Geometry"], repeat_output.inputs["Sides"]) + offset = nodes.new("ShaderNodeCombineXYZ") + links.new( + _math(group, "MULTIPLY", _read(group, "wall_layers"), -1.0), offset.inputs["Z"] + ) + back = nodes.new("GeometryNodeSetPosition") + links.new(saved, back.inputs["Geometry"]) + links.new(offset.outputs[0], back.inputs["Offset"]) + flip_back = nodes.new("GeometryNodeFlipFaces") + links.new(back.outputs["Geometry"], flip_back.inputs["Mesh"]) + flip_sides = nodes.new("GeometryNodeFlipFaces") + links.new(repeat_output.outputs["Sides"], flip_sides.inputs["Mesh"]) + join = nodes.new("GeometryNodeJoinGeometry") + for value in ( + saved, + flip_back.outputs["Mesh"], + flip_sides.outputs["Mesh"], + ): + links.new(value, join.inputs["Geometry"]) + encoded = nodes.new("GeometryNodeSetPosition") + links.new(join.outputs["Geometry"], encoded.inputs["Geometry"]) + links.new(identity.outputs[0], encoded.inputs["Position"]) + merge = nodes.new("GeometryNodeMergeByDistance") + merge.mode = "ALL" + merge.inputs["Distance"].default_value = 0.1 + links.new(encoded.outputs["Geometry"], merge.inputs["Geometry"]) + displacement = nodes.new("ShaderNodeCombineXYZ") + step = _math(group, "DIVIDE", thickness, _read(group, "wall_layers")) + layer_position = nodes.new("GeometryNodeInputPosition") + layer_axes = nodes.new("ShaderNodeSeparateXYZ") + links.new(layer_position.outputs[0], layer_axes.inputs[0]) + links.new( + _math(group, "MULTIPLY", layer_axes.outputs["Z"], step), + displacement.inputs["Z"], + ) + restored = nodes.new("ShaderNodeVectorMath") + restored.operation = "ADD" + links.new(_read(group, "surface_position"), restored.inputs[0]) + links.new(displacement.outputs[0], restored.inputs[1]) + result = nodes.new("GeometryNodeSetPosition") + links.new(merge.outputs["Geometry"], result.inputs["Geometry"]) + links.new(restored.outputs[0], result.inputs["Position"]) + local_position = nodes.new("GeometryNodeInputPosition") + local_axes = nodes.new("ShaderNodeSeparateXYZ") + links.new(local_position.outputs[0], local_axes.inputs[0]) + links.new(local_axes.outputs["Z"], identity.inputs["Z"]) + return result.outputs["Geometry"] diff --git a/tools/node_assets/cutout_depth_surface_layout.py b/tools/node_assets/cutout_depth_surface_layout.py new file mode 100644 index 0000000..f0e6eec --- /dev/null +++ b/tools/node_assets/cutout_depth_surface_layout.py @@ -0,0 +1,209 @@ +"""Arrange staggered calculation branches on both sides of the geometry path.""" + +from node_layout import arrange_nodes + +# Main geometry advances left to right. Local fields grow to either side of it; +# a long calculation may start before the geometry input or span several stages. +POSITIONS = { + "Position": (0, 260), + "Store Named Attribute": (0, 0), + "Store Named Attribute.001": (260, 0), + "Store Named Attribute.002": (520, 0), + "Store Named Attribute.003": (780, 0), + "Split Edges": (1060, 0), + "Switch": (1360, 0), + # Face sampling stays near its stores; selection forms a dense field cluster. + "Named Attribute": (-400, -320), + "Evaluate on Domain": (-180, -320), + "Image Texture": (20, -320), + "Named Attribute.003": (260, 260), + "Named Attribute.001": (-1090, 1060), + "Separate XYZ": (-870, 1060), + "Named Attribute.002": (-390, 590), + "Corners of Edge": (-1090, 910), + "Face of Corner": (-910, 910), + "Corners of Edge.001": (-1090, 770), + "Face of Corner.001": (-910, 770), + "Evaluate at Index": (-170, 750), + "Evaluate at Index.002": (-170, 610), + "Evaluate at Index.001": (-710, 1060), + "Evaluate at Index.003": (-710, 920), + "Vector Math": (10, 710), + "Math": (-530, 1040), + "Math.001": (-350, 1040), + "Math.002": (-170, 1040), + "Math.003": (-350, 830), + "Math.004": (10, 1040), + "Math.005": (190, 1040), + "Math.006": (190, 790), + "Math.007": (370, 980), + "Math.008": (190, 620), + "Math.009": (550, 910), + "Edge Neighbors": (370, 590), + "Math.010": (550, 590), + "Math.011": (730, 810), + "Evaluate on Domain.001": (730, 670), + "Evaluate on Domain.002": (910, 670), + "Math.012": (1090, 670), + "Math.013": (1360, 280), + # Cleanup is a separate compact cluster before projection. + "Face Area": (1460, 780), + "Mesh Island": (1460, 580), + "Accumulate Field": (1680, 780), + "Math.014": (1900, 580), + "Delete Geometry": (2120, 0), + # Camera-ray reconstruction runs below; scene projection runs above. + "Named Attribute.004": (1290, -440), + "Image Texture.001": (1510, -440), + "Separate XYZ.001": (1740, -440), + "Named Attribute.005": (1510, -710), + "Separate XYZ.002": (1740, -710), + "Math.017": (1960, -440), + "Vector Math.001": (2180, -440), + "Named Attribute.006": (1740, -940), + "Math.015": (1510, -940), + "Math.016": (1960, -940), + "Switch.001": (2600, -240), + "Store Named Attribute.004": (2920, 0), + "Named Attribute.007": (2340, 1080), + "Vector Math.002": (2560, 1080), + "Separate XYZ.003": (2740, 1080), + "Math.018": (3100, 730), + "Math.019": (3280, 730), + "Math.020": (2740, 910), + "Combine XYZ": (2920, 1040), + "Position.001": (2920, 790), + "Mix": (3100, 950), + "Separate XYZ.004": (3280, 950), + "Combine XYZ.001": (3460, 850), + "Set Position": (3870, 0), + # Short preparation fields sit above the axis; the long wall-count branch + # sits below, reading geometry back from the source-vertex store. + "Position.002": (4560, 340), + "Store Named Attribute.005": (4560, 0), + "Vector Math.003": (4780, 340), + "Set Position.001": (4780, 0), + "Index": (5010, 340), + "Store Named Attribute.006": (5010, 0), + "Edge Vertices": (3470, -610), + "Vector Math.004": (3700, -610), + "Edge Neighbors.001": (3470, -850), + "Math.021": (3700, -850), + "Attribute Statistic": (3920, -610), + "Math.022": (4150, -610), + "Math.023": (4370, -610), + "Math.024": (4590, -610), + "Math.025": (4820, -610), + "Math.026": (5040, -610), + "Store Named Attribute.007": (5310, 0), + "Repeat Input": (5620, 0), + "Extrude Mesh": (5850, 0), + "Combine XYZ.003": (5620, -340), + "Separate Geometry": (6070, 0), + "Join Geometry": (6300, -180), + "Repeat Output": (6520, 0), + "Flip Faces.001": (6740, 0), + "Join Geometry.001": (6970, 0), + "Named Attribute.010": (5620, -530), + "Math.027": (5850, -530), + "Combine XYZ.004": (6070, -530), + "Set Position.002": (6300, -320), + "Flip Faces": (6740, -320), + # Identity decoding above and position restoration below have independent, + # horizontally spread readers rather than equally tall vertical stacks. + "Named Attribute.008": (6240, 880), + "Integer Math": (6470, 880), + "Named Attribute.009": (6240, 650), + "Integer Math.001": (6470, 650), + "Position.004": (6240, 410), + "Separate XYZ.006": (6470, 410), + "Combine XYZ.002": (6670, 650), + "Set Position.003": (7410, 0), + "Merge by Distance": (7640, 0), + "Position.003": (6910, -950), + "Separate XYZ.005": (7160, -950), + "Named Attribute.011": (6900, -710), + "Math.028": (7140, -580), + "Math.029": (7380, -750), + "Combine XYZ.005": (7600, -680), + "Named Attribute.012": (7600, -370), + "Vector Math.005": (7830, -340), + "Set Position.004": (8080, 0), + "Math.030": (8310, 260), + "Switch.002": (8310, 0), + "Remove Named Attribute": (9000, 0), + "Set Shade Smooth": (9220, 0), + "Group Output": (9440, 0), +} + +INPUT_REGIONS = ( + (("Store Named Attribute",), (-240, 0)), + (("Switch",), (1100, -300)), + (("Image Texture",), (-180, -190)), + (("Math.002",), (-350, 1140)), + (("Math.003",), (-530, 830)), + (("Math.005",), (10, 1140)), + (("Math.013",), (1100, 320)), + (("Math.014",), (1680, 400)), + (("Image Texture.001",), (1290, -270)), + (("Math.015",), (1290, -940)), + (("Vector Math.002",), (2340, 1200)), + (("Math.018", "Math.019"), (2920, 680)), + (("Mix",), (2920, 900)), + (("Math.023",), (4150, -820)), + (("Math.028",), (6900, -540)), + (("Math.030",), (8080, 300)), +) + + +def _route(group, source_name, output, target_name, input_name, points): + """Place geometry references around the local branches they serve.""" + source = group.nodes[source_name].outputs[output] + target = group.nodes[target_name].inputs[input_name] + for link in list(group.links): + if link.from_socket == source and link.to_socket == target: + group.links.remove(link) + for point in points: + node = group.nodes.new("NodeReroute") + node.location = point + node.select = False + group.links.new(source, node.inputs[0]) + source = node.outputs[0] + group.links.new(source, target) + + +def layout_depth_surface(group): + """Keep the geometry path readable while staggering long and short branches.""" + arrange_nodes(group, POSITIONS, INPUT_REGIONS) + _route( + group, + "Repeat Input", + "Sides", + "Join Geometry", + "Geometry", + ((5810, -170), (6280, -170)), + ) + _route( + group, + "Store Named Attribute.007", + "Geometry", + "Join Geometry.001", + "Geometry", + ((5530, 150), (6950, 150)), + ) + _route( + group, + "Store Named Attribute.007", + "Geometry", + "Set Position.002", + "Geometry", + ((5530, -270), (6280, -270)), + ) + _route( + group, + "Set Position", + "Geometry", + "Switch.002", + "True", + ((4110, 240), (8290, 240)), + ) diff --git a/tools/node_assets/geometry_layouts.py b/tools/node_assets/geometry_layouts.py new file mode 100644 index 0000000..3a849e8 --- /dev/null +++ b/tools/node_assets/geometry_layouts.py @@ -0,0 +1,339 @@ +"""Authored processing stages for the shared image geometry assets.""" + +from node_layout import arrange_nodes + +LAYOUTS = { + "O Mesh Plane": ( + { + "Bounding Box": (-2320, 440), + "Vector Math": (-2100, 650), + "Separate XYZ": (-1870, 650), + "Math": (-1870, 990), + "Math.001": (-1650, 850), + "Math.002": (-1430, 850), + "Math.003": (-1200, 850), + "Math.004": (-980, 850), + "Math.005": (-760, 850), + "Math.006": (-1650, 510), + "Math.007": (-1430, 510), + "Math.008": (-1200, 510), + "Math.009": (-980, 510), + "Math.010": (-760, 510), + "Grid": (520, 340), + "Position": (-950, -480), + "Separate XYZ.001": (-720, -480), + "Math.011": (-500, -340), + "Math.012": (-280, -340), + "Math.013": (-500, -610), + "Math.014": (-280, -610), + "Combine XYZ": (0, -340), + "Store Named Attribute": (770, 340), + "Vector Math.001": (740, 710), + "Vector Math.002": (960, 710), + "Transform Geometry": (1030, 340), + "Bounding Box.001": (-1170, -990), + "Vector Math.003": (-950, -990), + "Separate XYZ.002": (-720, -990), + "Bounding Box.002": (-330, 140), + "Vector Math.004": (-100, 140), + "Separate XYZ.003": (120, 270), + "Math.015": (-520, 1390), + "Math.016": (-290, 1390), + "Math.017": (-520, 1160), + "Math.018": (-290, 1160), + "Math.019": (-70, 1390), + "Math.020": (150, 1390), + "Math.021": (380, 1390), + "Math.022": (600, 1390), + "Math.023": (830, 1390), + "Math.024": (1050, 1390), + "Combine XYZ.001": (280, 0), + "Cube": (520, -170), + "Store Named Attribute.001": (770, -170), + "Transform Geometry.001": (1030, -170), + "Compare": (1320, 410), + "Switch": (1360, 0), + "Capture Attribute": (1320, -580), + "Join Geometry": (1720, 0), + "Set Material Index": (1940, 0), + "Delete Geometry": (2170, 0), + "Group Output": (2390, 0), + }, + ( + (("Bounding Box",), (-2550, 440)), + (("Math",), (-2100, 1020)), + (("Math.021",), (150, 1600)), + (("Combine XYZ.001",), (120, 60)), + (("Compare",), (1140, 540)), + (("Capture Attribute",), (1140, -580)), + (("Bounding Box.001",), (-1390, -990)), + (("Bounding Box.002",), (-550, 140)), + ), + ), + "O Image Cutout": ( + { + "Math": (-170, 580), + "Mesh Island": (-620, 140), + "Face Area": (-620, 340), + "Accumulate Field": (-400, 340), + "Math.001": (50, 310), + "Delete Geometry": (430, 270), + "Group": (1030, 520), + "Group.001": (1030, 300), + "Group.002": (1030, 80), + "Group.003": (1030, -220), + "Menu Switch": (1390, 240), + "Repeat Input": (2170, 0), + "Repeat Output": (5090, 0), + "Vertex Neighbors": (2170, -780), + "Compare": (2390, -780), + "Edge Neighbors": (1940, -1020), + "Compare.001": (2170, -1020), + "Evaluate on Domain": (2390, -1020), + "Boolean Math": (2610, -780), + "Evaluate on Domain.001": (2840, -580), + "Separate Geometry": (3110, -310), + "Position": (3300, 540), + "Blur Attribute": (3520, 540), + "Position.001": (3110, -780), + "Blur Attribute.001": (3340, -780), + "Sample Nearest": (3340, -310), + "Sample Index": (3590, -310), + "Vertex Neighbors.001": (2390, 950), + "Compare.002": (2610, 950), + "Edge Neighbors.001": (2170, 1220), + "Compare.003": (2390, 1220), + "Evaluate on Domain.002": (2610, 1220), + "Boolean Math.001": (2840, 950), + "Switch": (3890, 340), + "Mix": (4130, 340), + "Normal": (3060, 1500), + "Blur Attribute.002": (3280, 1500), + "Vector Math": (3510, 1500), + "Switch.001": (3730, 1500), + "Math.002": (3950, 1360), + "Switch.002": (4370, 410), + "Position.002": (4370, 680), + "Mix.001": (4660, 410), + "Set Position": (4830, 0), + "Transform Geometry": (5450, -340), + "Menu Switch.001": (5740, 0), + "Set Shade Smooth": (5970, 0), + "Group Output": (6190, 0), + }, + ( + (("Math",), (-400, 580)), + (("Delete Geometry",), (430, 530)), + (("Group",), (810, 520)), + (("Group.001",), (810, 300)), + (("Group.002",), (810, 80)), + (("Group.003",), (810, -220)), + (("Menu Switch",), (1390, 380)), + (("Repeat Input",), (1940, 0)), + (("Math.002",), (3730, 1220)), + (("Menu Switch.001",), (5520, 340)), + ), + ), + "O Image Depth Plane": ( + { + "Group": (0, 0), + "Transform Geometry": (520, 0), + "Set Position": (2470, 0), + "Set Shade Smooth": (2730, 0), + "Group Output": (2990, 0), + "Math": (60, 340), + "Combine XYZ": (240, 340), + "Named Attribute": (240, -450), + "Image Texture": (460, -450), + "Separate XYZ": (640, -450), + "Math.001": (820, -450), + "Math.002": (1000, -450), + "Math.003": (1180, -450), + "Math.004": (1180, -660), + "Math.005": (1380, -450), + "Math.006": (1740, -1060), + "Switch": (1760, -450), + "Position": (820, -820), + "Separate XYZ.001": (1000, -820), + "Math.007": (1180, -820), + "Math.008": (1380, -820), + "Math.009": (1560, -820), + "Switch.001": (1760, -820), + "Math.010": (1970, -520), + "Combine XYZ.001": (2150, -520), + }, + ( + (("Group",), (-240, 0)), + (("Math",), (-120, 340)), + (("Image Texture",), (240, -310)), + (("Math.001",), (640, -310)), + (("Math.002", "Math.003"), (820, -590)), + (("Math.004",), (1000, -660)), + (("Math.006",), (1560, -1060)), + (("Math.007",), (1000, -980)), + ), + ), + "O Image Balloon": ( + { + "Named Attribute": (-950, 440), + "Attribute Statistic": (-730, 1020), + "Math": (-500, 1020), + "Math.001": (-280, 1020), + "Map Range": (-50, 1020), + "Math.002": (-500, 440), + "Math.003": (-500, 240), + "Math.004": (-280, 240), + "Extrude Mesh": (0, 0), + "Switch": (220, 440), + "Position": (20, 240), + "Separate XYZ": (220, 240), + "Combine XYZ": (420, 340), + "Set Position": (690, 0), + "Separate Geometry": (910, 0), + "Flip Faces": (1140, 0), + "Combine XYZ.001": (910, 530), + "Set Position.001": (1140, 300), + "Combine XYZ.002": (690, -550), + "Set Position.002": (910, -300), + "Flip Faces.001": (1140, -300), + "Join Geometry": (1430, 0), + "Merge by Distance": (1650, 0), + "Math.005": (2100, 310), + "Switch.001": (2100, 0), + "Set Shade Smooth": (2320, 0), + "Group Output": (2550, 0), + }, + ( + (("Extrude Mesh",), (-220, 0)), + (("Attribute Statistic",), (-950, 1020)), + (("Math.002", "Math.003"), (-730, 240)), + (("Set Position.001",), (910, 300)), + (("Set Position.002",), (690, -300)), + (("Math.005",), (1870, 340)), + (("Switch.001",), (1870, -240)), + ), + ), + "O Image Surface": ( + { + "Math": (-90, 680), + "Math.001": (140, 680), + "Edge Vertices": (-1630, -340), + "Vector Math": (-1410, -340), + "Edge Neighbors": (-1630, -560), + "Math.002": (-1410, -560), + "Attribute Statistic": (-1190, -340), + "Math.003": (-960, -340), + "Math.004": (-740, -340), + "Float to Integer": (-520, -340), + "Integer Math": (-290, -340), + "Integer Math.001": (-70, -340), + "Math.005": (190, -580), + "Math.006": (410, -580), + "Combine XYZ": (1630, 480), + "Set Position": (1860, 260), + "Repeat Input": (410, 0), + "Extrude Mesh": (640, 0), + "Separate Geometry": (860, 0), + "Join Geometry": (1080, -180), + "Repeat Output": (1310, 0), + "Combine XYZ.001": (1410, 270), + "Set Position.001": (1630, 0), + "Flip Faces": (1860, 0), + "Combine XYZ.002": (1410, -470), + "Set Position.002": (1630, -260), + "Flip Faces.001": (1860, -260), + "Join Geometry.001": (2150, 0), + "Merge by Distance": (2370, 0), + "Math.007": (2820, 290), + "Switch": (2820, 0), + "Set Shade Smooth": (3040, 0), + "Group Output": (3270, 0), + }, + ( + (("Math",), (-310, 680)), + (("Attribute Statistic",), (-1410, -170)), + (("Math.004",), (-960, -610)), + (("Math.005",), (-30, -580)), + (("Repeat Input",), (190, 0)), + (("Set Position",), (1630, 260)), + (("Set Position.002",), (1410, -260)), + (("Math.007",), (2600, 310)), + (("Switch",), (2600, -240)), + ), + ), + "O Image Depth Balloon": ( + { + "Named Attribute": (-1120, 650), + "Attribute Statistic": (-890, 650), + "Math": (-670, 650), + "Math.001": (-450, 650), + "Map Range": (-220, 650), + "Named Attribute.001": (-1790, -440), + "Image Texture": (-1570, -440), + "Separate XYZ": (-1340, -440), + "Math.002": (-1120, -440), + "Vector Math": (-2240, -950), + "Separate XYZ.001": (-2010, -950), + "Position": (-2240, -1260), + "Separate XYZ.002": (-2010, -1260), + "Math.003": (-1790, -950), + "Math.004": (-1790, -1160), + "Math.005": (-1570, -950), + "Math.006": (-1340, -950), + "Math.007": (-1120, -950), + "Math.008": (-890, -440), + "Math.009": (-670, -440), + "Math.010": (-450, -440), + "Math.011": (-670, 310), + "Math.012": (-670, 70), + "Math.013": (-450, 70), + "Mix": (20, 440), + "Math.014": (240, 710), + "Switch": (460, 710), + "Extrude Mesh": (0, 0), + "Switch.001": (460, 310), + "Position.001": (280, 530), + "Separate XYZ.003": (460, 530), + "Combine XYZ": (660, 430), + "Set Position": (930, 0), + "Separate Geometry": (1150, 0), + "Flip Faces": (1600, 0), + "Combine XYZ.001": (1380, 570), + "Set Position.001": (1600, 330), + "Combine XYZ.002": (1150, -570), + "Set Position.002": (1380, -320), + "Flip Faces.001": (1600, -320), + "Join Geometry": (1870, 0), + "Merge by Distance": (2320, 0), + "Position.002": (1600, 1120), + "Separate XYZ.004": (1820, 1120), + "Combine XYZ.003": (2050, 950), + "Set Position.003": (2320, 680), + "Math.015": (2560, -270), + "Switch.002": (2560, 0), + "Set Shade Smooth": (2790, 0), + "Group Output": (3010, 0), + }, + ( + (("Extrude Mesh",), (-220, 0)), + (("Attribute Statistic",), (-1120, 850)), + (("Image Texture",), (-1790, -270)), + (("Math.002",), (-1340, -750)), + (("Vector Math",), (-2460, -950)), + (("Math.007",), (-1340, -1160)), + (("Math.010",), (-670, -650)), + (("Math.011", "Math.012"), (-890, 310)), + (("Switch",), (240, 920)), + (("Set Position.001",), (1380, 330)), + (("Set Position.002",), (1150, -320)), + (("Set Position.003",), (2100, 610)), + (("Math.015",), (2340, -270)), + ), + ), +} + + +def layout_geometry(group): + """Apply the authored layout for one geometry asset.""" + positions, inputs = LAYOUTS[group.name] + arrange_nodes(group, positions, inputs) diff --git a/tools/node_assets/node_layout.py b/tools/node_assets/node_layout.py new file mode 100644 index 0000000..7cf4e3e --- /dev/null +++ b/tools/node_assets/node_layout.py @@ -0,0 +1,138 @@ +"""Shared presentation rules for authored geometry-node layouts.""" + + +def arrange_nodes(group, positions, input_regions): + """Place authored branches and rebuild their local Group Inputs.""" + controls = [ + (link.to_socket, link.from_socket.name) + for link in group.links + if link.from_node.bl_idname == "NodeGroupInput" + ] + for node in list(group.nodes): + if node.bl_idname == "NodeGroupInput": + group.nodes.remove(node) + assert set(positions) == {node.name for node in group.nodes}, group.name + for name, location in positions.items(): + group.nodes[name].location = location + for targets, location in input_regions: + node = group.nodes.new("NodeGroupInput") + node.location = location + for socket, name in controls: + if socket.node.name in targets: + group.links.new(node.outputs[name], socket) + assert all(socket.is_linked for socket, _ in controls), group.name + for node in group.nodes: + node.select = False + node.width = ( + 200 + if "Attribute" in node.bl_idname or node.bl_idname == "GeometryNodeGroup" + else 160 + ) + node.hide = node.bl_idname in { + "ShaderNodeMath", + "ShaderNodeVectorMath", + } and all( + socket.is_linked + for socket in node.inputs + if socket.enabled and not socket.hide + ) + for socket in node.outputs: + socket.hide = not socket.is_linked + + +def validate_node_layout(group): + """Check visible bounds and sockets, allowing direct backward references.""" + nodes = [n for n in group.nodes if n.bl_idname not in {"NodeReroute", "NodeFrame"}] + assert all(not n.label for n in nodes), group.name + assert not any(n.bl_idname == "FunctionNodeInputString" for n in nodes), group.name + assert all(s.is_linked or s.hide for n in nodes for s in n.outputs), group.name + validate_peer_inputs(group) + for link in group.links: + if ( + link.from_node.bl_idname != "NodeReroute" + or link.to_node.bl_idname == "NodeReroute" + ): + continue + source = link.from_node + while source.bl_idname == "NodeReroute": + source = source.inputs[0].links[0].from_node + assert source.location.x <= link.to_node.location.x, ( + group.name, + source.name, + link.to_node.name, + ) + for index, node in enumerate(nodes): + width = node.dimensions.x or node.width + height = node.dimensions.y or (30 if node.hide else node.height) + for other in nodes[index + 1 :]: + other_width = other.dimensions.x or other.width + other_height = other.dimensions.y or (30 if other.hide else other.height) + overlap = ( + node.location.x < other.location.x + other_width + and node.location.x + width > other.location.x + and node.location.y - height < other.location.y + and node.location.y > other.location.y - other_height + ) + assert not overlap, (group.name, node.name, other.name) + if node.bl_idname == "GeometryNodeRepeatInput": + assert ( + node.paired_output and node.paired_output.location.x > node.location.x + ) + + +def validate_peer_inputs(group): + """Align peer branch ends while leaving shared upstream sources in place.""" + parents = {node: set() for node in group.nodes} + consumers = {node: set() for node in group.nodes} + for link in group.links: + parents[link.to_node].add(link.from_node) + consumers[link.from_node].add(link.to_node) + + def ancestors(node): + found = set() + pending = list(parents[node]) + while pending: + parent = pending.pop() + if parent in found: + continue + found.add(parent) + pending.extend(parents[parent]) + return found + + for node in group.nodes: + if node.bl_idname not in { + "GeometryNodeJoinGeometry", + "GeometryNodeMenuSwitch", + "GeometryNodeSwitch", + "ShaderNodeCombineXYZ", + }: + continue + combine = node.bl_idname == "ShaderNodeCombineXYZ" + sources = set() + for socket in node.inputs: + if not combine and socket.type != "GEOMETRY": + continue + for link in socket.links: + source = link.from_node + while source.bl_idname == "NodeReroute": + source = source.inputs[0].links[0].from_node + if combine or source.bl_idname != "NodeGroupInput": + sources.add(source) + upstream = set().union(*(ancestors(source) for source in sources)) + peers = sources - upstream + if combine: + peers = {source for source in peers if consumers[source] == {node}} + edges = [source.location.x + source.width for source in peers] + assert not edges or max(edges) - min(edges) < 1.0, ( + group.name, + node.name, + [source.name for source in peers], + ) + if node.bl_idname == "GeometryNodeMenuSwitch": + rows = [ + link.from_node.location.y + for socket in node.inputs + for link in socket.links + if link.from_node in peers + ] + assert rows == sorted(rows, reverse=True), (group.name, node.name) diff --git a/tools/node_assets/validate.py b/tools/node_assets/validate.py index 530e1e2..c10a9dd 100644 --- a/tools/node_assets/validate.py +++ b/tools/node_assets/validate.py @@ -4,12 +4,14 @@ from pathlib import Path import bpy PROJECT_ROOT = Path(__file__).parents[2] +sys.path.insert(0, str(Path(__file__).parent)) sys.path.insert(0, str(PROJECT_ROOT / "src")) sys.path.insert( 0, str(next((PROJECT_ROOT / "src" / "anyimage" / "wheels").glob("blendjob-*.whl"))), ) +from node_layout import validate_node_layout from anyimage.common.material import ( DEPTH_MATERIAL_NODE_GROUP_NAME, IMAGE_MATERIAL_NODE_GROUP_NAME, @@ -137,6 +139,9 @@ def main(): cutout, } loaded = {*public_groups, *cutout_groups.values()} + for group in loaded: + if group.bl_idname == "GeometryNodeTree": + validate_node_layout(group) assert all(group.use_fake_user for group in public_groups) assert all(group.users > 0 for group in cutout_groups.values()) if bpy.app.version >= (5, 0, 0): diff --git a/tools/node_assets/validate_cutout.py b/tools/node_assets/validate_cutout.py index 5ea68f8..d82828c 100644 --- a/tools/node_assets/validate_cutout.py +++ b/tools/node_assets/validate_cutout.py @@ -1,15 +1,20 @@ import sys +from collections import Counter +from itertools import pairwise from pathlib import Path import bpy PROJECT_ROOT = Path(__file__).parents[2] +sys.path.insert(0, str(Path(__file__).parent)) sys.path.insert(0, str(PROJECT_ROOT / "src")) sys.path.insert( 0, str(next((PROJECT_ROOT / "src" / "anyimage" / "wheels").glob("blendjob-*.whl"))), ) +from node_layout import validate_node_layout + from anyimage.common.object import ( modifier_input_identifier, set_modifier_input, @@ -24,6 +29,130 @@ def evaluated_mesh(obj): return evaluated, result +def validate_depth_surface_layout(group): + """Check the geometry path, stage spacing and drawn node bounds.""" + nodes = [node for node in group.nodes if node.bl_idname != "NodeReroute"] + main = [ + group.nodes[name] + for name in ( + "Store Named Attribute", + "Store Named Attribute.001", + "Store Named Attribute.002", + "Store Named Attribute.003", + "Split Edges", + "Switch", + "Delete Geometry", + "Store Named Attribute.004", + "Set Position", + "Store Named Attribute.005", + "Set Position.001", + "Store Named Attribute.006", + "Store Named Attribute.007", + "Repeat Input", + "Extrude Mesh", + "Separate Geometry", + "Repeat Output", + "Flip Faces.001", + "Join Geometry.001", + "Set Position.003", + "Merge by Distance", + "Set Position.004", + "Switch.002", + "Remove Named Attribute", + "Set Shade Smooth", + "Group Output", + ) + ] + assert len({node.location.y for node in main}) == 1 + assert all(right.location.x > left.location.x for left, right in pairwise(main)) + for left, right in ( + ("Switch", "Delete Geometry"), + ("Delete Geometry", "Store Named Attribute.004"), + ("Set Position", "Store Named Attribute.005"), + ("Switch.002", "Remove Named Attribute"), + ): + a, b = group.nodes[left], group.nodes[right] + assert b.location.x - (a.location.x + a.width) >= 280 + assert not any(n.bl_idname == "FunctionNodeInputString" for n in nodes) + assert min(n.location.x for n in nodes) < main[0].location.x + + def height(node): + if node.dimensions.y: + return node.dimensions.y + if node.hide or node.bl_idname == "NodeReroute": + return 30.0 + if node.bl_idname == "GeometryNodeJoinGeometry": + return 60.0 + return node.height + + for index, node in enumerate(nodes): + width = node.dimensions.x or node.width + for other in nodes[index + 1 :]: + other_width = other.dimensions.x or other.width + overlap = ( + node.location.x < other.location.x + other_width + and node.location.x + width > other.location.x + and node.location.y - height(node) < other.location.y + and node.location.y > other.location.y - height(other) + ) + assert not overlap, (node.name, other.name) + repeat_input = next( + node for node in nodes if node.bl_idname == "GeometryNodeRepeatInput" + ) + repeat_output = repeat_input.paired_output + inside = {repeat_output} + pending = [repeat_input] + while pending: + node = pending.pop() + if node in inside: + continue + inside.add(node) + pending.extend(link.to_node for link in group.links if link.from_node == node) + zone_bottom = min(node.location.y - height(node) for node in inside) - 60.0 + zone_top = max(node.location.y for node in inside) + 60.0 + for node in nodes: + if repeat_input.location.x <= node.location.x <= repeat_output.location.x: + assert ( + node in inside + or node.location.y <= zone_bottom + or node.location.y - height(node) >= zone_top + ), node.name + + +def validate_depth_surface_attributes(group): + """Check temporary fields are typed, locally read and removed at output.""" + expected = { + "o_depth_face_center": ("FLOAT_VECTOR", "FACE"), + "o_depth_face_camera": ("FLOAT_VECTOR", "FACE"), + "o_depth_corner_camera": ("FLOAT_VECTOR", "CORNER"), + "o_depth_cut_vertex": ("BOOLEAN", "CORNER"), + "o_depth_camera": ("FLOAT_VECTOR", "POINT"), + "o_depth_surface_position": ("FLOAT_VECTOR", "POINT"), + "o_depth_source_vertex": ("INT", "POINT"), + "o_depth_wall_layers": ("FLOAT", "POINT"), + } + stores = { + n.inputs["Name"].default_value: (n.data_type, n.domain) + for n in group.nodes + if n.bl_idname == "GeometryNodeStoreNamedAttribute" + } + assert stores == expected + assert not any(n.bl_idname == "GeometryNodeCaptureAttribute" for n in group.nodes) + removes = [n for n in group.nodes if n.bl_idname == "GeometryNodeRemoveAttribute"] + assert len(removes) == 1 + assert removes[0].pattern_mode == "WILDCARD" + assert removes[0].inputs["Name"].default_value == "o_depth_*" + for node in group.nodes: + if node.bl_idname == "GeometryNodeInputNamedAttribute": + name = node.inputs["Name"].default_value + assert name == "UVMap" or node.data_type == expected[name][0] + assert all( + abs(link.to_node.location.x - node.location.x) <= 600 + for link in group.links + if link.from_node == node + ) + + def input_names(group): return [ item.name @@ -83,6 +212,7 @@ def main(): shape: bpy.data.node_groups[name] for shape, name in shapes.CUTOUT_NODE_GROUP_NAMES.items() } + validate_depth_surface_layout(groups["DEPTH_SURFACE"]) assert all(not group.is_modifier for group in groups.values()) menu_switch = next( @@ -157,7 +287,7 @@ def main(): "Smooth Iterations", "Cleanup", "Smooth", - "Stretch Limit", + "Split", "Reference Depth", "Depth Direction", "Uniform Scale", @@ -180,7 +310,8 @@ def main(): "Geometry", "Thickness", "Depth Scale", - "Stretch Limit", + "Split", + "Cleanup", "Reference Depth", "Uniform Scale", "Depth Image", @@ -200,9 +331,10 @@ def main(): ) dependencies.setdefault(link.from_socket.name, set()).add(shape) assert dependencies == { + "Geometry": {"DEPTH_SURFACE"}, "Thickness": set(shapes.CUTOUT_SHAPES), "Depth Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, - "Stretch Limit": {"DEPTH_SURFACE"}, + "Split": {"DEPTH_SURFACE"}, "Double Sided": {"DEPTH_BALLOON"}, "Reference Depth": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, "Depth Direction": {"DEPTH_BALLOON"}, @@ -232,7 +364,8 @@ def main(): ) assert all( not any(node.bl_idname == "GeometryNodeInputMeshIsland" for node in group.nodes) - for group in groups.values() + for shape, group in groups.items() + if shape != "DEPTH_SURFACE" ) assert any(node.bl_idname == "GeometryNodeBlurAttribute" for node in wrapper.nodes) smooth = next( @@ -252,77 +385,47 @@ def main(): ) assert cleanup.default_value == 0.0 assert cleanup.subtype == "NONE" - stretch_limit = next( + split_control = next( item for item in wrapper.interface.items_tree - if item.item_type == "SOCKET" and item.name == "Stretch Limit" + if item.item_type == "SOCKET" and item.name == "Split" ) - child_stretch_limit = next( + child_split = next( item for item in groups["DEPTH_SURFACE"].interface.items_tree - if item.item_type == "SOCKET" and item.name == "Stretch Limit" + if item.item_type == "SOCKET" and item.name == "Split" ) - assert stretch_limit.default_value == 0.0 - assert stretch_limit.min_value == 0.0 - assert stretch_limit.max_value == 1.0 - assert stretch_limit.subtype == "FACTOR" - assert getattr(stretch_limit.parent, "name", "") == "Options" - assert child_stretch_limit.default_value == 0.0 - assert child_stretch_limit.min_value == 0.0 - assert child_stretch_limit.max_value == 1.0 - assert child_stretch_limit.subtype == "FACTOR" - assert getattr(child_stretch_limit.parent, "name", "") == "Options" + assert split_control.default_value == 0.0 + assert split_control.min_value == 0.0 + assert split_control.max_value == 1.0 + assert split_control.subtype == "FACTOR" + assert getattr(split_control.parent, "name", "") == "Options" + assert child_split.default_value == 0.0 + assert child_split.min_value == 0.0 + assert child_split.max_value == 1.0 + assert child_split.subtype == "FACTOR" + assert getattr(child_split.parent, "name", "") == "Options" depth_surface_nodes = groups["DEPTH_SURFACE"].nodes - captures = [ - node - for node in depth_surface_nodes - if node.bl_idname == "GeometryNodeCaptureAttribute" - ] - assert len(captures) == 1 - assert captures[0].domain == "POINT" - assert [item.name for item in captures[0].capture_items] == [ - "Original Edge Length" - ] - captured_distance = next( - link.from_node - for link in groups["DEPTH_SURFACE"].links - if link.to_node == captures[0] - and link.to_socket.name == "Original Edge Length" - ) - assert captured_distance.bl_idname == "ShaderNodeVectorMath" - assert captured_distance.operation == "DISTANCE" - ratio = next( - link.to_node - for link in groups["DEPTH_SURFACE"].links - if link.from_node == captured_distance - and link.to_node.bl_idname == "ShaderNodeMath" - and link.to_node.operation == "DIVIDE" - ) assert any( - link.from_node == captures[0] - and link.to_node == ratio - and link.to_socket == ratio.inputs[0] - for link in groups["DEPTH_SURFACE"].links + node.bl_idname == "GeometryNodeSplitEdges" for node in depth_surface_nodes ) - assert any( - link.from_node == captured_distance - and link.to_node == ratio - and link.to_socket == ratio.inputs[1] - for link in groups["DEPTH_SURFACE"].links - ) - samples = [ - node - for node in depth_surface_nodes - if node.bl_idname == "GeometryNodeSampleIndex" - ] - assert not samples - delete_domains = [ + validate_depth_surface_attributes(groups["DEPTH_SURFACE"]) + assert [ node.domain for node in depth_surface_nodes if node.bl_idname == "GeometryNodeDeleteGeometry" + ] == ["FACE"] + assert "Stretch Limit" not in [ + item.name for item in groups["DEPTH_SURFACE"].interface.items_tree ] - assert delete_domains.count("EDGE") == 1 - assert delete_domains.count("POINT") == 2 + assert all( + socket.is_linked or socket.hide + for node in depth_surface_nodes + for socket in node.outputs + ) + for node in depth_surface_nodes: + if node.bl_idname == "GeometryNodeRepeatInput": + assert node.paired_output.location.x > node.location.x cleanup_scale = next( link.to_node for link in wrapper.links @@ -342,26 +445,7 @@ def main(): ) for group in (wrapper, *groups.values()): - assert all(not node.label for node in group.nodes) - assert all( - link.from_node.location.x <= link.to_node.location.x for link in group.links - ) - group_nodes = list(group.nodes) - assert len({tuple(node.location) for node in group_nodes}) == len(group_nodes) - for index, node in enumerate(group_nodes): - for other in group_nodes[index + 1 :]: - assert not ( - abs(node.location.x - other.location.x) < 150.0 - and abs(node.location.y - other.location.y) < 100.0 - ), (group.name, node.name, other.name) - for group_input in ( - node for node in group_nodes if node.bl_idname == "NodeGroupInput" - ): - assert all( - output.is_linked or output.hide - for output in group_input.outputs - if output.name - ) + validate_node_layout(group) for item in group.interface.items_tree: if item.item_type == "SOCKET" and item.in_out == "INPUT": assert item.structure_type == "SINGLE" @@ -525,14 +609,14 @@ def main(): set_modifier_input( modifier, - modifier_input_identifier(modifier.node_group, "Stretch Limit"), + modifier_input_identifier(modifier.node_group, "Split"), 0.6, ) depth_surface.update_tag(refresh={"DATA"}) bpy.context.view_layer.update() evaluated, result = evaluated_mesh(depth_surface) try: - assert 0 < len(result.polygons) < 8 + assert len(result.polygons) == 8 face_edges = { tuple(sorted((start, end))) for polygon in result.polygons @@ -543,9 +627,7 @@ def main(): } assert all(tuple(sorted(edge.vertices)) in face_edges for edge in result.edges) face_vertices = { - vertex - for polygon in result.polygons - for vertex in polygon.vertices + vertex for polygon in result.polygons for vertex in polygon.vertices } assert face_vertices == set(range(len(result.vertices))) finally: @@ -565,6 +647,25 @@ def main(): finally: evaluated.to_mesh_clear() + set_modifier_input( + modifier, modifier_input_identifier(modifier.node_group, "Depth Scale"), 1.0 + ) + set_modifier_input( + modifier, modifier_input_identifier(modifier.node_group, "Thickness"), 0.2 + ) + depth_surface.update_tag(refresh={"DATA"}) + bpy.context.view_layer.update() + evaluated, result = evaluated_mesh(depth_surface) + try: + edge_uses = Counter( + tuple(sorted((a, b))) + for face in result.polygons + for a, b in zip(face.vertices, (*face.vertices[1:], face.vertices[0])) + ) + assert edge_uses and set(edge_uses.values()) == {2} + finally: + evaluated.to_mesh_clear() + print( f"Validated Blender {bpy.app.version_string}: " f"{wrapper.name} wrapping "