diff --git a/AGENTS.md b/AGENTS.md index 12bdbaa..cfce148 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ - 按业务职责梳理代码结构与命名;简单功能用单文件,复杂 Operator 功能包统一以 `operators.py` 为入口,`__init__.py` 负责导出与注册清单。 - 沿真实调用链清理无用代码,不把仅有测试引用当作业务用途;查证动态调用、框架回调和注册入口后再删除。 - 精简测试,保留有效行为覆盖,使测试名称、归属与功能对应,并检查状态隔离。 -- 同步整理 `tools/node_assets`、`docs/internals` 和相关引用,使名称、说明与实际实现一致。本触发指令包含内部文档整理。 +- 同步整理 `tools/nodes`、`docs/internals` 和相关引用,使名称、说明与实际实现一致。本触发指令包含内部文档整理。 - 保留已有的其他修改和有效功能,运行全量测试并确认正常退出,检查旧引用与最终差异,简要报告调整及验证结果;不构建文档或产物。 ## 查询文档 @@ -88,22 +88,4 @@ uv run sync_models ## Blender 节点组规范 - 节点组统一存入 `assets/O_AnyImage.blend`,运行时按名称加载(`load_node_group(group_name)`) -- 节点组通过[节点资产](docs/internals/node-assets.md)说明构建,禁止运行时调用 `bpy.data.node_groups.new()` 新建 -- 节点组变更须同步构建脚本、验证、测试和节点资产文档 -- 将节点组当作程序函数:先确定传递几何的主轴,主轴从左到右连续排列。主轴可适当宽松,支线内部尽量紧凑;同一目的的节点距离应明显小于不同部分之间的距离,用留白表达用途分组 -- 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 分段引导;同源引用共用干线,在用途区域附近分叉,避免多条斜线横穿主轴和计算支线 -- Switch 的原始几何旁路用 Reroute 沿主链外侧绕行,接近 Switch 时再汇入;旁路下置时,将该段属性计算集中在主链上方,留出连续通道 -- 支线确需从主轴向左回接时直接连接,允许这些连线交叉;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 顺序与结构、未使用输出隐藏及几何求值;纯布局变更比较运算、默认值和实际连接,确认功能不变 -- 在 Blender 节点编辑器按实际绘制尺寸检查总览和局部步骤,确认主轴、支线长度、节点间距及配对区域清楚; +- 根据[节点资产](docs/internals/node-assets.md)说明构建节点组,禁止调用 `bpy.data.node_groups.new()` 新建 diff --git a/docs/internals/development.md b/docs/internals/development.md index 468b576..7a6a5fc 100644 --- a/docs/internals/development.md +++ b/docs/internals/development.md @@ -34,7 +34,7 @@ Blender Stub 共用 `blender_test_support.py`;Server 和实际 Blender 几何 `tools/pack.py` 从 `pyproject.toml` 读取依赖,按平台与 Blender Python 版本解析 wheel,使用 Blender 提供的 NumPy。产物命名为 `AnyImage.v..zip`,写入 `dist/`。本地 BlendJob wheel 内容变化后运行 `uv lock --refresh`,并核对锁文件、Manifest 与打包测试。 -`tools/sync_models.py` 根据模型目录选择上游 ONNX 或本地导出流程;`model_exports.py` 处理对应 PyTorch 权重导出。文件校验依据声明大小和 SHA-256,上传使用逐文件复制。`--prepare-only` 用于本地准备。 +`tools/models/cli.py` 根据模型目录选择上游 ONNX 或本地导出流程;`tools/models/exports.py` 处理对应 PyTorch 权重导出。文件校验依据声明大小和 SHA-256,上传使用逐文件复制。`--prepare-only` 用于本地准备。 ## CI 与文档导航 diff --git a/docs/internals/node-assets.md b/docs/internals/node-assets.md index e3b2c9f..c5c07cf 100644 --- a/docs/internals/node-assets.md +++ b/docs/internals/node-assets.md @@ -1,149 +1,51 @@ # 节点资产 -生产节点组由脚本构建到 `src/anyimage/assets/O_AnyImage.blend`;运行时只加载,不创建节点组。 +## 构建节点资产 -## 构建 +生产节点组由 `uv run build_node` 构建到 `src/anyimage/assets/O_AnyImage.blend` -在项目根目录执行: +节点组文件在 `tools/nodes/groups` -```bash -uv run build_node -``` +节点组规则: -脚本依次构建资产、运行三项 Blender 验证和完整测试,任一步失败即退出。 +- Group Input 按区域重复放置,Named Attribute 等字段入口就近读取;隐藏所有未使用输出,避免从远处拉线 +- 跨区域暂存值用 Store Named Attribute 写入 `o_*` 命名空间,按用途分配前缀并明确 FACE、CORNER、POINT 域和存取时机;末尾用一个 Wildcard 模式的 Remove Named Attribute 清理该前缀,例如 `o_depth_*`,保留用户属性和 UV +- Capture Attribute 适合局部字段固定;若属性连线跨越多个步骤,改为命名属性,让计算分支独立展开 +- 保留节点默认 `name` 和标题,不用 `label` 覆盖,不用 String 节点注解。Frame 仅用于确有包裹语义的区域 +- 全接线的简单数学运算可折叠;需要阅读的常数、运算域和模式保持可见 +- 修改器 Geometry Nodes 的输入 socket 默认使用 `SINGLE` +- socket `subtype` 按业务语义设置:长度用 `DISTANCE`,`0–1` 比例用 `FACTOR`,`0–100` 用 `PERCENTAGE`;无对应范围语义时保留默认 +- 接口或布局变更须验证 socket 顺序与结构、未使用输出隐藏及几何求值;纯布局变更比较运算、默认值和实际连接,确认功能不变 -脚本按 `--blender`、环境变量 `BLENDER_BIN`、项目 `.env`、`PATH` 的顺序查找 Blender。无法自动找到时,在项目根目录的 `.env` 中配置: - -```dotenv -BLENDER_BIN="/absolute/path/to/blender" -``` - -`.env` 已被 Git 忽略。临时指定路径或跳过 Python 测试: - -```bash -uv run build_node --blender "/path/to/blender" -uv run build_node --skip-tests -``` ## 资产清单 | 节点组 | 构建模块 | | --- | --- | | `O Image Plane` | `image_plane.py` | -| `O Image Depth Plane` | `depth_plane.py` | -| `O Image Layer`、`O Image Depth Layer` | `material_layers.py` | +| `O Image Depth Plane` | `image_depth_plane.py` | +| `O Image Relief Plane` | `image_relief_plane.py` | +| `O Image Layer` | `image_layer.py` | +| `O Image Depth Layer` | `image_depth_layer.py` | | `O Shadeless` | `shadeless.py` | -| `O Image Cutout`、`O Image Depth Cutout`、`O Image Depth Balloon` | `cutout.py` | +| `O Image Cutout` | `image_cutout.py` | +| `O Image Depth Cutout` | `image_depth_cutout.py` | +| `O Image Depth Balloon` | `image_depth_balloon.py` | -构建从空 Factory 文件生成全部节点组,关闭 `.blend1` 备份,并覆盖唯一发布资产。 -## 源码职责 +## 构建资产脚本 -`tools/node_assets/` 的模块按资产、几何计算、布局和验证组织: +`tools/nodes/build.py` 按依赖顺序构建资产,调用 `arrangement/` 统一排列几何节点组后保存。`common/` 提供共用构建函数,`preview/` 导出交互预览。 -| 模块 | 职责 | -| --- | --- | -| `asset_library.py` | 构建全部节点组并保存唯一资产 | -| `image_plane.py`、`depth_plane.py` | 普通平面与 Relief / Camera 深度平面 | -| `material_layers.py`、`shadeless.py` | 图像材质层与无光照着色 | -| `cutout.py`、`cutout_shapes.py` | Cutout 修改器接口与各预设几何 | -| `depth_plane_camera.py`、`depth_surface.py` | Camera 平面、共享投影、深度分离与厚度 | -| `cutout_volume.py`、`cutout_rounding.py` | 平滑中心曲面与轮廓圆润 | -| `cutout_inflation.py`、`cutout_shell.py` | 正面膨胀、法线和正背面位置 | -| `plane_layouts.py`、`depth_plane_layout.py` | 平面资产与 Camera 分支布局 | -| `cutout_layout.py`、`cutout_shape_layouts.py`、`depth_cutout_layout.py` | Cutout 阶段布局、坐标与跨区域几何引用 | -| `node_graph.py`、`node_layout.py` | 共用字段节点、平滑构建、布局与边界检查 | -| `asset_validation.py` | 资产清单、接口、结构与布局检查 | -| `image_plane_validation.py`、`cutout_validation.py` | 平面和 Cutout 几何求值验证 | +保存后,`check.py` 在独立 Blender 进程中重新加载资产并做基本几何求值。接口、默认值和详细几何行为由 `tests/` 检查。 -`tools/build_node.py` 依次启动资产构建与三个验证脚本。运行时加载入口位于 `common/node.py`。 - -Depth Cutout 的布局由 `depth_cutout_layout.py` 编排:Split 属性采样位于主链上方,原始几何旁路沿下方的 Reroute 通道汇入 Switch;长距离投影引用共用分段干线,并在 Uniform 厚度与零厚度输出附近分叉。几何统计支线就近放置在其使用区域。 - -三个 Cutout 节点组直接作为修改器使用,统一接收局部 XY 平面,以 `-Z` 表示向图片内部的方向。每组在塑形与平滑后处理 **Inward Axis**:默认 `-Z` 直接输出原生几何,`+X` 分支执行一次 Z 到 X 的轴转换;运行时对象矩阵使用逆变换补偿,保证两种模式落在相同世界空间位置。 - -`O Image Depth Plane`、`O Image Depth Cutout` 与 `O Image Depth Balloon` 使用 **Reference Depth** 作为采样深度的运算基准。 - -### Cutout 预设与厚度 - -| 创建入口 | 修改器节点组 | 默认设置 | -| --- | --- | --- | -| Flat | O Image Cutout | Balloon,Thickness 0 | -| Solid(默认) | O Image Cutout | Balloon,Thickness 1 | -| Depth Solid | O Image Depth Cutout | Balloon,Thickness 1 | -| Depth Balloon | O Image Depth Balloon | Thickness 1,Double Sided 开启 | - -前两种节点组提供 **Mode**,默认显示 Balloon。两个独立输入均显示为 **Thickness**:第一个是 Balloon 的无量纲倍率,第二个是 Uniform 的距离,各自保存数值。普通 Cutout 在零厚度时输出平面,深度 Cutout 输出深度表面。全部基础网格包含 POINT 域的 `o_balloon`,Flat 可直接调整为 Solid。 - -深度 Cutout 的 Balloon 厚度由 `cutout_volume.py` 构造平滑中心,再由 `cutout_rounding.py` 处理圆润轮廓与凸起方向。Options 中位于 Front Inflation 前的 **Edge Round** 默认 0.2,正背面共用 `1 − smootherstep(0, max(o_balloon), o_balloon)` 衰减权重。正面向中心加 Balloon 的轮廓靠拢,背面按 Balloon 值补充饱满度。Options 中的 **Normal Smooth** 默认 50:在中心曲面上平滑并归一化法线,背面沿该方向凸起,正面按边缘权重过渡。两个边缘范围及法线影响固定为 1。 - -中心位置以投影正面 Z 减去 `o_balloon` 初始化,固定轮廓与切口,在内部通过一个 Blur Attribute 执行 256 次邻域平滑,边界 Weight 为 0。圆润处理前,深凹处保留至少 `0.05 × o_balloon` 的厚度。轮廓零厚度点按来源身份合并,暂存字段使用 POINT 域 `o_balloon_surface_*` 并在输出前清理。`cutout_shell.py` 让薄厚度的 Balloon 从正面沿法线向内生长,再按平滑过渡达到 Thickness 为 1 的圆润体积;大于 1 时继续沿用该体积公式。Uniform 沿正面法线偏移指定距离,同样受 Normal Smooth 控制。Thickness 为 0 时返回原深度表面。整体 Smooth 复用公共构建函数,三个 Cutout 节点组的 Smooth 默认 0,Smooth Weight 默认 1。 - -`cutout_inflation.py` 在邻域中拟合深度已有的结构性鼓起,Options 中 **Front Inflation** 默认 1。Balloon 令结构量为 k、Thickness 与 Front Inflation 的乘积为 a,正面增加 `o_balloon × (cbrt((k+0.25)³+(a+0.25)³−0.25³)−k−0.25)`,通过有理化形式求值以保持小厚度精度。已有 1 倍鼓起、两个参数均为 1 时,额外增加约 0.323 倍 Balloon。Uniform 直接增加 `Thickness × Front Inflation`,不受结构量或轮廓权重限制;Front Inflation 为 1 时,正面边缘和中心的增量均等于背面的厚度距离。两种模式厚度为 0 时仍返回原表面,单层侧壁直接连接正背面。 - -**Normal Smooth** 同时控制正面、背面与 Uniform 的法线;正面膨胀在基础法线平滑之后使用 5.12 倍迭代范围,默认 50 对应额外 256 次,保持大膨胀量下的连续方向。设置为 0 时两阶段均停止平滑。Front Inflation 为 0 时关闭正面额外鼓起。 - -Normal Map 勾选控制 Flat 与 Solid 的 TANGENT 法线;Depth Solid 固定生成 OBJECT 法线,Depth Balloon 固定生成 TANGENT 法线。材质读取 `o_normal_scale`:普通 Cutout 和 Depth Balloon 使用 POINT 域的轮廓适配权重;Depth Cutout 使用 FACE 域的正面标记,让背面与切口保留几何法线。该属性属于输出材质协议,随 UVMap 与 o_balloon 保留。 - -Options 依次排列 Cleanup Threshold、Split Threshold、Reference Depth、Edge Round、Front Inflation、Normal Smooth、Smooth Weight;各节点组仅显示适用项,Depth Balloon 的 Depth Direction 位于末尾。 - -### Depth Cutout 分离与厚度 - -Options 中的 **Split Threshold** 范围为 `0–1`,默认 `0` 关闭分离,数值越大越容易分离。节点比较 `原始 Z 差 × Uniform Scale × Split Threshold > 原始面中心距离 × 2.5`;`0.5` 对应深度坡度阈值 `5`。**Uniform Scale** 将原始深度标定到网格单位,**Depth Scale** 仅控制最终置换幅度。 - -```mermaid -flowchart TD - A[基础网格与深度图片] --> D[共用 Cleanup 按原平面岛面积清理] - D --> B[面中心采样与共享边判别] - B --> C[存入面角命名属性并 Split Edges] - C --> E[切口顶点沿原相机射线采用所属侧深度] - E --> J[固定顶点数据并投影] - J --> L[选择平滑 Balloon 或 Uniform 厚度] - L --> F[生成正背面与单层四边侧壁] - F --> G[按原顶点及正背面身份连接] - G --> H[恢复场景坐标] - H --> K[移除 o_depth_* 暂存属性] - K --> I[边缘与整体平滑及方向变换] +```bash +uv run build_node --blender "/path/to/blender" +uv run build_node --skip-tests ``` -三个节点组在塑形前复用 Cleanup,按原平面岛的总面积清理小区域。Depth Cutout 随后执行 Split,保留分离产生的小片;Split 关闭时使用原顶点采样。 +脚本按 `--blender`、环境变量 `BLENDER_BIN`、项目 `.env`、`PATH` 的顺序查找 Blender。无法自动找到时,在项目根目录的 `.env` 中配置: -未关联选中切边的顶点使用原始 XYZ。切口顶点以 `原 XYZ × 所属侧 Z / 原 Z` 保持原相机射线,随后执行既有 Uniform Scale、Reference Depth 和 Depth Scale 投影。 - -厚度前固定顶点数据,使正背面和侧壁使用相同位置。连接时以原顶点编号和正背面身份构造临时整数坐标,合并同一身份的副本后恢复场景坐标,保持近距离分离区域各自独立。实现位于 `depth_surface.py`,由 `cutout.py` 构建进共享节点资产。 - -暂存值通过 Store Named Attribute 写入保留前缀 `o_depth_`:面中心与相机采样使用 FACE,所属侧相机采样与切口标记使用 CORNER,最终相机坐标、表面位置与源顶点编号使用 POINT。计算区域就近使用 Named Attribute,末尾用一个 Wildcard 模式的 Remove Named Attribute 匹配 `o_depth_*`,输出保留 UVMap 与用户属性。 - -### 整图 Depth Plane - -`O Image Depth Plane` 提供默认 **Relief** 与 **Camera** 模式,共用完整矩形网格和 `depth.exr`。RGB 保存相机 XYZ,Alpha 保存有效性。Relief 与 Depth Balloon 读取 B/Z,Camera 与 Depth Cutout 读取 XYZ。用户控制为 **Mode**、**Subdivide**、**Thickness**、**Depth Scale** 和 Options 中的 **Reference Depth**、**Normal Smooth**;**Depth Image** 与 **Uniform Scale** 为隐藏数据输入。 - -```mermaid -flowchart TD - A[O Image Plane 零厚度矩形细分] --> B[按 Depth Scale 插值相机 XY 与相对 Z] - B --> C{Thickness} - C -- 0 --> D[单层曲面] - C -- 大于 0 --> E[沿平滑法线向内生成背面并连接边界] - D --> F[清理临时属性并输出] - E --> F +```dotenv +BLENDER_BIN="/absolute/path/to/blender" ``` - -两个独立输入均显示 Thickness,默认均为 0 m:第一个控制 Relief 的固定底面基础厚度,第二个控制 Camera 的向内壳厚度。上图描述 Camera 分支:Depth Scale 为 0 时恢复平面,1 时使用完整相机投影;正面 POINT 法线按 Normal Smooth 平滑并归一化,再按 Thickness 向内生成背面。Normal Smooth 范围 0–50、默认 50,仅影响 Camera。模式切换保留各自厚度值并复用图片。Camera 分支位于 `depth_plane_camera.py`,投影与厚度共用 `depth_surface.py`,布局由 `depth_plane_layout.py` 编排。 - -### 几何节点布局 - -全部五个几何节点组使用连续横排的几何主轴。主轴可适当宽松,支线内部紧凑,同一目的的计算聚在同一侧,内部间距明显小于与其他部分的距离;独立用途可在两侧错落展开。长算式横排,末端就近汇入主轴。Join Geometry、Menu Switch、Combine XYZ 的同级独立分支末端排成一列并对齐输出端,菜单分支按输入顺序上下排列,共享上游留在公共位置。必要的向左引用直接连接。Group Input 与 Named Attribute 就近放置,全接线的简单运算可折叠,常数和域设置保持可见。 - -`plane_layouts.py` 组织平面资产布局,`cutout_shape_layouts.py` 和 `depth_cutout_layout.py` 组织 Cutout 的局部计算阶段;`cutout_layout.py` 将阶段布局放入三个修改器,`node_layout.py` 验证节点边界与同级输入对齐。布局验收包含实际节点编辑器预览及几何求值。 - -## 修改节点组 - -1. 修改 `tools/node_assets/` 中对应资产、计算或布局模块; -2. 更新对应验证; -3. 运行一键构建脚本; -4. 搜索并清理旧名称、接口和路径; -5. 检查 `.blend`、代码、测试和文档差异。 - -接口、节点、连线、默认值或布局变化时必须重建。只修改运行时 Python、Markdown 或打包逻辑时无需重建。 - -`O_AnyImage.blend1` 等备份不应提交或打包。 diff --git a/openspec/changes/add-image-layer-alpha-adjustment/.openspec.yaml b/openspec/changes/add-image-layer-alpha-adjustment/.openspec.yaml new file mode 100644 index 0000000..1ea7e36 --- /dev/null +++ b/openspec/changes/add-image-layer-alpha-adjustment/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-09 diff --git a/openspec/changes/add-image-layer-alpha-adjustment/design.md b/openspec/changes/add-image-layer-alpha-adjustment/design.md new file mode 100644 index 0000000..8479d23 --- /dev/null +++ b/openspec/changes/add-image-layer-alpha-adjustment/design.md @@ -0,0 +1,44 @@ +## Context + +`build_material_layer_group()` 共用构建 O Image Layer 和 O Image Depth Layer。普通层直接传出输入 Alpha,深度层使用前景阈值。`create_image_material()` 已集中创建材质,并通过 `configured_material_view_adaptation()` 配置颜色图片。 + +## Goals / Non-Goals + +目标是将参考图的 Alpha 调整内置到 O Image Layer,支持连续强度调节,并在创建时读取现有偏好。变更范围为普通图像材质层;深度层继续使用其前景阈值协议。 + +## Decisions + +### 曲线与接口 + +O Image Layer 的默认折叠面板 `Options` 依次包含 `Alpha Fix`、`Normal Scale`、`Object Space`。Alpha Fix 类型为 NodeSocketFloat,subtype 为 FACTOR,范围 0–1,默认 0。直接连接 ShaderNodeFloatCurve 的 Factor,复用节点自身的混合语义:`output = (1 - factor) * alpha + factor * curve(alpha)`。 + +曲线使用四个点:起点 `(0, 0)`、低端控制点 `(0.8, 0)`、上方控制点 `(0.925, 0.925)` 和终点 `(1, 1)`。上方控制点为参考图估计值。采用自动钳制手柄、水平延伸及 0–1 的裁剪与采样区间。低端控制点之后快速上升,完整调整时输入 0.85 输出约 0.1593、输入 0.9 输出约 0.774;低端附近存在 Blender 曲线查表插值误差,测试容差为 0.0003。 + +```mermaid +flowchart TD + A[组输入 Alpha] --> C[Float Curve Value] + B[组输入 Alpha Fix] --> D[Float Curve Factor] + C --> E[曲线调整并按 Factor 混合] + D --> E + E --> F[组输出 Alpha] + F --> G[Principled Alpha 或 Shadeless 混合系数] +``` + +Alpha 曲线放在独立且就近的支线,保持曲线可见、默认节点标题和未使用输出隐藏;Color、Roughness 与 Normal 保持各自连接。 + +### 创建时应用偏好 + +`create_image_material()` 在普通层节点绑定后读取现有公共偏好函数,将节点实例的 `Alpha Fix` 设为 1 或 0。判断依据就是偏好值,因此开启偏好但颜色空间回退到 sRGB 时仍设为 1。手动拖入节点组时采用资产默认值 0。 + +用户随后可独立调整每个材质实例;偏好切换只影响后续创建。普通着色与 Shadeless 共用组输出 Alpha。Color 图片的颜色空间和 Alpha 模式继续由现有配置入口处理。 + +### 验证与交付 + +修改构建脚本、资产验证及材质测试;通过测试期间构建的节点组检查接口、连接与曲线采样。节点资产文档按节点组专项规范同步描述接口。用户已授权更新节点资产,O_AnyImage.blend 中的 O Image Layer 与运行时代码配套交付;在 Blender 节点编辑器确认实际尺寸、节点间距和连接,并运行全量测试及资产验证。 + +## Risks / Trade-offs + +- 截图未展示所有精确坐标与手柄设置 → 用上述初值实现,检查曲线采样,并对照实际节点编辑器及透明边缘效果验收。 +- 曲线会削弱中低 Alpha → 默认手动实例强度为 0,创建时按用户偏好启用,保留每个实例的强度控制。 +- 工作区已有材质与节点资产修改 → 在当前实现上追加变更,保留已有修改并检查最终差异。 +- 旧资产缺少输入 → 验证资产接口与运行时代码一致,正式发布配套资产。 diff --git a/openspec/changes/add-image-layer-alpha-adjustment/proposal.md b/openspec/changes/add-image-layer-alpha-adjustment/proposal.md new file mode 100644 index 0000000..030f0f3 --- /dev/null +++ b/openspec/changes/add-image-layer-alpha-adjustment/proposal.md @@ -0,0 +1,27 @@ +## Why + +使用 inverse 类颜色空间时,用户观察到 Alpha 无法正确遮盖图片边缘,手动添加 Float Curve 后可改善。将这项调整放入 O Image Layer,让材质创建时自动应用,并允许用户调节强度。 + +## What Changes + +- O Image Layer 内新增 Alpha Float Curve,依据参考图设置曲线。 +- 默认折叠的 `Options` 面板依次包含 `Alpha Fix`、`Normal Scale`、`Object Space`;Alpha Fix 连接 Float Curve 的 Factor,范围 0–1,默认 0。 +- 创建使用 O Image Layer 的材质时,读取 `Adapt to Scene View Transform`:开启设为 1,关闭设为 0。 +- 验证曲线、接口、材质创建和着色输出行为。 + +## Capabilities + +### New Capabilities + +- `image-layer-alpha-adjustment`: 材质层 Alpha 曲线调整、强度输入与创建时偏好联动。 + +### Modified Capabilities + +无。 + +## Impact + +- `tools/node_assets/material_layers.py`:节点组接口、曲线与布局。 +- `src/anyimage/common/material.py`:创建节点实例时设置调整强度。 +- `tools/node_assets/asset_validation.py` 及材质相关测试:接口、连接、曲线求值和偏好联动验证。 +- 发布节点资产 `src/anyimage/assets/O_AnyImage.blend` 需要包含新接口;本轮仅生成提案。 diff --git a/openspec/changes/add-image-layer-alpha-adjustment/specs/image-layer-alpha-adjustment/spec.md b/openspec/changes/add-image-layer-alpha-adjustment/specs/image-layer-alpha-adjustment/spec.md new file mode 100644 index 0000000..d158b62 --- /dev/null +++ b/openspec/changes/add-image-layer-alpha-adjustment/specs/image-layer-alpha-adjustment/spec.md @@ -0,0 +1,56 @@ +## ADDED Requirements + +### Requirement: Image layer exposes alpha adjustment strength + +O Image Layer SHALL 在默认折叠的 `Options` 面板中依次提供 `Alpha Fix`、`Normal Scale`、`Object Space`。Alpha Fix SHALL 为浮点输入,范围 0–1,subtype 为 FACTOR,默认 0,并以该值控制内部 Alpha Float Curve 的 Factor。 + +#### Scenario: Options are initially collapsed +- **WHEN** 创建 O Image Layer 节点实例 +- **THEN** Options 面板 SHALL 默认折叠,包含 Alpha Fix、Normal Scale、Object Space +- **AND** Color、Alpha、Normal、Bump Scale SHALL 位于面板外 + +#### Scenario: Adjustment is disabled +- **WHEN** Alpha Fix 为 0 +- **THEN** 组输出 Alpha SHALL 等于输入 Alpha + +#### Scenario: Adjustment is fully applied +- **WHEN** Alpha Fix 为 1 +- **THEN** 组输出 Alpha SHALL 等于内部 Float Curve 对输入 Alpha 的求值 + +#### Scenario: Adjustment is partially applied +- **WHEN** Alpha Fix 为 0.5 +- **THEN** 输出 SHALL 等于原始 Alpha 与完整曲线结果的平均值 + +### Requirement: Alpha curve follows the reference profile + +Alpha 曲线 SHALL 使用四个点:起点 `(0, 0)`、低端控制点 `(0.8, 0)`、靠近右上方的平滑控制点和终点 `(1, 1)`,形成参考图中的快速上升轮廓。对输入范围 0–1,完整调整后的输出 SHALL 保持在 0–1,保持单调不减,并保持完全透明与完全不透明端值。 + +#### Scenario: Low alpha is suppressed +- **WHEN** 输入 Alpha 在 0–0.8 且调整强度为 1 +- **THEN** 输出 Alpha SHALL 为 0,允许浮点求值误差 + +#### Scenario: Opaque alpha is preserved +- **WHEN** 输入 Alpha 为 1 +- **THEN** 任意调整强度下输出 Alpha SHALL 为 1 + +### Requirement: Material creation initializes adjustment from preferences + +创建使用 O Image Layer 的材质时,系统 SHALL 通过现有偏好读取入口,将 Alpha Fix 初始化为 `Adapt to Scene View Transform` 开启时的 1、关闭时的 0。 + +#### Scenario: Adaptation is enabled +- **WHEN** 开启适配后创建普通着色或 Shadeless 材质 +- **THEN** O Image Layer 实例的 Alpha Fix SHALL 为 1 +- **AND** 对应着色器 SHALL 使用该组调整后的 Alpha 输出 + +#### Scenario: Adaptation is disabled +- **WHEN** 关闭适配后创建材质 +- **THEN** O Image Layer 实例的 Alpha Fix SHALL 为 0 + +#### Scenario: Color space falls back +- **WHEN** 适配开启且颜色空间解析回退到 sRGB +- **THEN** 新建 O Image Layer 实例的 Alpha Fix SHALL 仍为 1 + +#### Scenario: Existing material is manually adjusted +- **WHEN** 用户修改现有材质的 Alpha Fix 后切换偏好 +- **THEN** 已有实例的调整值 SHALL 保留 +- **AND** 后续新建材质 SHALL 使用当前偏好初始化 diff --git a/openspec/changes/add-image-layer-alpha-adjustment/tasks.md b/openspec/changes/add-image-layer-alpha-adjustment/tasks.md new file mode 100644 index 0000000..542f08a --- /dev/null +++ b/openspec/changes/add-image-layer-alpha-adjustment/tasks.md @@ -0,0 +1,18 @@ +## 1. Alpha 曲线与接口 + +- [x] 1.1 在 `material_layers.py` 的 O Image Layer 分支加入 Alpha Fix 输入及 Float Curve,按设计设置坐标、手柄、裁剪和连接。 +- [x] 1.2 调整节点布局及未使用输出隐藏,在实际 Blender 节点编辑器检查总览与曲线局部。 +- [x] 1.3 更新资产验证及对应测试,检查 socket 顺序、默认值、连接和曲线采样;覆盖强度 0、0.5、1、低 Alpha、端点、单调性与范围。 + +## 2. 材质创建联动 + +- [x] 2.1 在 `create_image_material()` 中复用偏好读取入口,为 O Image Layer 实例设置 Alpha Fix。 +- [x] 2.2 补充材质行为测试,覆盖偏好开关、sRGB 回退、普通着色、Shadeless 及现有材质手动值保留,验证 O Image Depth Layer 的既有 Alpha 行为。 + +## 3. 验收 + +- [x] 3.1 对照参考图验证曲线形状,使用透明边缘图片比较强度 0、0.5、1 的效果,记录估计控制点的最终取值。 +- [x] 3.2 按节点组专项规范同步 `docs/internals/node-assets.md` 的新输入与曲线说明。 +- [x] 3.3 按用户授权更新 O Image Layer 节点资产,运行全量测试并确认正常退出,检查最终差异和接口引用;验证 Blender 节点资产,不构建文档或扩展发布包。 + +验证结果:766 passed、2 xfailed、50 subtests passed,退出码 0;Blender 4.5.10 资产验证与 OpenSpec 严格校验通过。 diff --git a/openspec/changes/separate-depth-alpha-and-model-input/.openspec.yaml b/openspec/changes/separate-depth-alpha-and-model-input/.openspec.yaml new file mode 100644 index 0000000..1ea7e36 --- /dev/null +++ b/openspec/changes/separate-depth-alpha-and-model-input/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-09 diff --git a/openspec/changes/separate-depth-alpha-and-model-input/design.md b/openspec/changes/separate-depth-alpha-and-model-input/design.md new file mode 100644 index 0000000..9e53739 --- /dev/null +++ b/openspec/changes/separate-depth-alpha-and-model-input/design.md @@ -0,0 +1,57 @@ +## Context + +动机见 proposal.md。当前共享 Artifact 入口合成黑底;GeometryFrame 保存原始模型 mask;EXR 写入 XYZ 与 mask。补边按 alpha 分量组织,但用包含 mask 的来源集合计算内部距离,会把内部 mask 孔洞也纳入修补。Depth Plane 已支持读取 EXR Alpha,默认 Validity Threshold 为 0.9。 + +## Goals / Non-Goals + +本设计将输入颜色、轮廓深度修补和输出有效性分别放在模型输入准备、深度处理、EXR 写入三个边界。实施范围为共享深度产物及消费者测试,节点结构和参数沿用当前实现。 + +## Decisions + +### RGB 输入与 alpha 并行处理 + +```mermaid +flowchart TD + A[原始输入图] --> B[读取 RGB 后按输入上限缩放] + B --> C[MoGe2 预测] + C --> D[GeometryFrame:depth、points、validity 等] + A --> E[读取原图 alpha 并对齐预测尺寸] + D --> F[按 alpha 轮廓延伸深度] + E --> F + F --> G[EXR RGB:相机空间 XYZ] + D --> H[原图 alpha × validity] + E --> H + H --> I[EXR Alpha] + I --> J[Depth Plane 现有剔除节点] +``` + +`_limited_model_input` 先 `convert("RGB")` 再 `limit_image`,使缩放直接处理 RGB。原图 alpha 继续从业务输入图单独读取,使用现有 NEAREST 对齐预测尺寸。这里的原图是本次 Job 输入,包含 Remove BG 等已经完成的 alpha 编辑。 + +### alpha 决定修补范围,mask 筛选来源 + +在 `edge_depth.py` 中分别计算 alpha 轮廓区域与深度来源:可见分量沿用调用方 alpha_threshold;内部距离由 alpha 条件确定。保留现有内部 2 像素保护带、轮廓外 4 像素采样延伸及局部平滑。 + +来源同时满足 alpha 条件、原始 validity > 0.5、有限正深度,优先使用同一分量内部来源;细小分量沿用分量内来源回退。低 mask 仅影响来源资格,不扩大修补目标或侵蚀轮廓内部区域。alpha 全 1 时保持现有直接返回行为。 + +修改深度后按目标像素射线重建 XYZ,保持未修补区域原值。修补结果的 validity 仍为模型 mask。0.5 是来源筛选条件,Depth Plane 的 0.9 是最终几何筛选阈值,两者各自保持现有用途。 + +### 在 EXR 写入边界组合 alpha + +`write_depth_texture` 显式接收已对齐的原图 alpha,检查尺寸并以 float32 写入 `alpha * frame.validity`。统一迁移调用点,参数必需。GeometryFrame 始终保留原始模型信号,供延伸、Normal 和 Metadata 使用。 + +XYZ 与有效性通道各自计算:补边只改变 XYZ,alpha 乘积只用于 EXR A。Metadata 继续使用原始模型有效性、可见区域和有限正深度计算参考深度。 + +### 消费者沿用职责 + +Depth Plane 的 Valid Only 读取组合后的 EXR Alpha,继续使用现有阈值、线性采样和面域规则。Cutout 曲面由自身轮廓生成并采样 XYZ。检查共享 EXR Alpha 的校准读取,保证 Cutout 和 Relief Plane 的有效样本筛选符合组合通道语义。 + +## Risks / Trade-offs + +- 输入图的隐藏 RGB 可能已被清除 → 使用实际可用 RGB;用保留天空 RGB 的 Remove BG 输出验证输入一致性。 +- 两个连续值相乘会降低边缘有效性,例如 0.95 × 0.95 = 0.9025 → 测试直接固定乘积与当前阈值的消费行为。 +- alpha 延伸只能稳定轮廓附近的深度 → 用合成深度场验证边界与来源隔离;有原始风景图时再做模型对照,截图不作为预测正确性的证明。 +- 工作区有其他进行中修改 → 以当前实现为基础,独立提交本提案文件,实施时保留已有修改。 + +## Migration Plan + +依次修改 RGB 准备、alpha 延伸范围、EXR 写入及所有调用,运行针对性与全量测试并确认正常退出。重新执行 Job 获得新产物。仅回退本变更引入的修改即可恢复旧行为。 diff --git a/openspec/changes/separate-depth-alpha-and-model-input/proposal.md b/openspec/changes/separate-depth-alpha-and-model-input/proposal.md new file mode 100644 index 0000000..a9645ef --- /dev/null +++ b/openspec/changes/separate-depth-alpha-and-model-input/proposal.md @@ -0,0 +1,25 @@ +## Why + +透明图合成黑底后交给 MoGe2,会改变天空等背景及轮廓附近的 RGB,影响场景深度预测。Depth Plane 需要同时按原图覆盖范围和模型有效性剔除,Cutout 则需要稳定的 alpha 轮廓深度。 + +## What Changes + +- MoGe2 输入先读取 RGB 再缩放,保留透明像素下的颜色。 +- 深度延伸以原图 alpha 轮廓确定范围,模型 mask 用于筛选可靠来源。 +- `GeometryFrame.validity` 保持模型 mask;导出 `depth.exr` 时 Alpha 为原图 alpha 与 validity 的乘积,RGB 保存延伸后的相机空间 XYZ。 +- Depth Plane 通过现有 Valid Only 与 Validity Threshold 消费 EXR Alpha;Cutout 继续按自身轮廓生成几何。 + +## Capabilities + +### New Capabilities + +- `depth-alpha-semantics`: 定义 MoGe2 RGB 输入、alpha 轮廓延伸以及深度产物有效性通道。 + +### Modified Capabilities + +## Impact + +- 涉及 `server/geometry/prediction_artifacts.py`、`edge_depth.py`、`depth_texture.py` 及对应调用与测试。 +- 检查 Depth Plane、Cutout、Relief Plane 对共享深度产物的消费,包括校准用途。 +- 接续工作区已有 Depth Plane 与 Relief Plane 拆分实现,采用当前节点阈值和剔除规则。 +- 深度产物需重新生成以获得新 Alpha 语义。实现只运行测试,不同步内部文档或构建节点资产、扩展包;无新增依赖。 diff --git a/openspec/changes/separate-depth-alpha-and-model-input/specs/depth-alpha-semantics/spec.md b/openspec/changes/separate-depth-alpha-and-model-input/specs/depth-alpha-semantics/spec.md new file mode 100644 index 0000000..55edf7c --- /dev/null +++ b/openspec/changes/separate-depth-alpha-and-model-input/specs/depth-alpha-semantics/spec.md @@ -0,0 +1,65 @@ +## Purpose + +规定透明图进入 MoGe2 时的颜色语义、轮廓深度延伸范围和深度产物的有效性通道,使 Depth Plane 根据图像覆盖与模型判断剔除几何,同时为 Cutout 提供稳定的轮廓深度采样。 + +## ADDED Requirements + +### Requirement: Model input preserves source RGB + +系统 SHALL 使用输入图 RGB 准备 MoGe2 输入,缩放 SHALL 基于 RGB 执行,alpha MUST 保持为独立信号。 + +#### Scenario: Same RGB with different alpha +- **WHEN** 两张尺寸、RGB 相同的图仅 alpha 不同,并使用相同输入尺寸上限 +- **THEN** 它们生成的模型 RGB 输入相同,包括触发缩放的情况 + +#### Scenario: Transparent sky retains color context +- **WHEN** Remove BG 输出保留天空 RGB 并将其 alpha 设为零 +- **THEN** MoGe2 输入保留天空 RGB,输出深度产物对应像素的 Alpha 为零 + +### Requirement: Alpha silhouette determines depth extension + +系统 SHALL 根据原图 alpha 确定可见分量和轮廓修补范围,使用同一分量的可靠深度延伸轮廓。模型有效性 MUST 用于排除不可靠来源,修补范围 SHALL 独立于模型 mask 孔洞。延伸 SHALL 保持原始 alpha、模型有效性与未修补区域的深度。 + +#### Scenario: Repair the alpha edge +- **WHEN** alpha 轮廓内侧保护带及轮廓外采样带存在不稳定深度,且分量内部存在可靠来源 +- **THEN** 系统使用该分量可靠深度修补边界,并保持目标像素的相机投影射线 + +#### Scenario: Internal mask hole +- **WHEN** 一个低 mask 区域完全位于 alpha 轮廓保护带以外的内部 +- **THEN** 该区域不会仅因 mask 偏低而成为延伸目标,其模型有效性保持原值 + +#### Scenario: Fully opaque input +- **WHEN** 输入 alpha 全为 1 +- **THEN** 系统保留原始深度场,包括低 mask 区域 + +#### Scenario: Component lacks reliable depth +- **WHEN** 某个 alpha 分量没有可靠深度来源 +- **THEN** 该分量保留原始深度,不从其他分量借用深度 + +### Requirement: Depth artifact alpha combines coverage and model validity + +`depth.exr` SHALL 在 RGB 保存相机空间 XYZ,在 Alpha 保存对齐预测尺寸的原图 alpha 与原始模型有效性的逐像素乘积。两个信号 MUST 保留连续值,补边 MUST 保持乘积所用的原始信号。 + +#### Scenario: Continuous values +- **WHEN** 某像素原图 alpha 为 0.8,模型有效性为 0.75 +- **THEN** 导出的 Alpha 为 0.6,XYZ 保留该像素的深度处理结果 + +#### Scenario: Invisible or model-invalid pixel +- **WHEN** 原图 alpha 或模型有效性任一为零 +- **THEN** EXR Alpha 为零,XYZ 仍保存可供采样的数值场 + +### Requirement: Geometry consumers use depth channels by purpose + +Depth Plane SHALL 通过现有 Valid Only 和 Validity Threshold 按 EXR Alpha 剔除。Cutout SHALL 根据自身轮廓生成曲面并读取 XYZ,EXR Alpha 不得引入额外曲面剔除。 + +#### Scenario: Depth plane culling +- **WHEN** Valid Only 开启且原图 alpha 或模型有效性使乘积低于当前阈值 +- **THEN** 该采样位置按现有节点的面域规则参与剔除 + +#### Scenario: Culling disabled +- **WHEN** 用户关闭 Valid Only +- **THEN** 完整细分网格继续进入深度投影与厚度处理 + +#### Scenario: Cutout keeps its contour +- **WHEN** Cutout 轮廓内含低 EXR Alpha 区域 +- **THEN** 曲面拓扑保持由 Cutout 轮廓决定,深度采样使用导出的 XYZ diff --git a/openspec/changes/separate-depth-alpha-and-model-input/tasks.md b/openspec/changes/separate-depth-alpha-and-model-input/tasks.md new file mode 100644 index 0000000..789afde --- /dev/null +++ b/openspec/changes/separate-depth-alpha-and-model-input/tasks.md @@ -0,0 +1,21 @@ +## 1. 模型输入 + +- [x] 1.1 将共享模型输入准备改为读取 RGB 后缩放,更新输入测试,验证相同 RGB、不同 alpha 在缩放和原尺寸情况下得到相同模型输入。 + +## 2. alpha 轮廓延伸 + +- [x] 2.1 将 alpha 轮廓与 mask 来源筛选分开,固定仅由 alpha 决定的修补范围;通过内部低 mask 孔洞及全不透明输入测试确认深度保持原值。 +- [x] 2.2 验证轮廓内侧保护带、外侧 4 像素延伸、局部平滑、分量隔离、无可靠来源回退和 XYZ 射线一致性,并确认 alpha 与 validity 未改写。 + +## 3. 深度产物与消费者 + +- [x] 3.1 为深度 EXR 写入添加必需的原图 alpha 参数并迁移全部调用;验证尺寸检查、零值、连续乘积和 XYZ 原值保存,确认 GeometryFrame.validity 仍为模型 mask。 +- [x] 3.2 更新 Artifact 集成测试,验证 alpha 对齐预测尺寸、Metadata 使用原始模型有效性与可见参考区域、Normal 使用原始预测。 +- [x] 3.3 按用户要求保留全部几何节点相关内容;验证 EXR 图像读取保留组合 Alpha 与 XYZ,检查现有校准读取,运行消费者回归测试,节点调整由另一 agent 负责。 + +## 4. 整体验证 + +- [x] 4.1 运行针对性测试后执行 `uv run pytest`,确认正常退出;检查最终差异与旧调用引用,保留工作区其他修改,仅报告本变更结果,不同步内部文档或构建资产及扩展包。 + + +验证记录:针对性测试 48 passed、2 subtests passed;全量测试 805 passed、2 xfailed,模型同步模块并行迁移导致 6 项初始化错误。迁移后单独重跑 tests/test_sync_models.py,6 passed,退出码 0。最终差异及写入调用检查通过;本变更未修改几何节点脚本、资产或节点测试。 diff --git a/openspec/changes/split-depth-and-relief-planes/.openspec.yaml b/openspec/changes/split-depth-and-relief-planes/.openspec.yaml new file mode 100644 index 0000000..1ea7e36 --- /dev/null +++ b/openspec/changes/split-depth-and-relief-planes/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-09 diff --git a/openspec/changes/split-depth-and-relief-planes/design.md b/openspec/changes/split-depth-and-relief-planes/design.md new file mode 100644 index 0000000..c255e35 --- /dev/null +++ b/openspec/changes/split-depth-and-relief-planes/design.md @@ -0,0 +1,68 @@ +## Context + +两个转换入口分别使用相机投影和浮雕节点组。Server 生成相机 XYZ 深度与对应空间的法线,客户端创建矩形基础网格并加载节点组。depth.exr 的 Alpha 存储模型有效性,作为 Valid Only 的判定依据。 + +工作区已有材质和转换代码修改,实施须以当前代码为基础保留这些行为。 + +## Goals + +提供独立的 Depth Plane 与 Relief Plane 转换;Depth Plane 在节点内按深度有效性 Alpha 剔除几何,保留动态细分,并使用 Object Space 法线。 + +## Decisions + +### 入口与节点资产 + +| 入口 | Operator | 节点组 | 形状 | 法线空间 | +| --- | --- | --- | --- | --- | +| Convert to Depth Plane | `anyimage.convert_to_depth_plane` | `O Image Depth Plane` | 相机投影曲面 | OBJECT | +| Convert to Relief Plane | `anyimage.convert_to_relief_plane` | `O Image Relief Plane` | 固定底面浮雕 | TANGENT | + +两个节点组各自保留 Subdivide、Thickness、Depth Scale、Reference Depth 及所需数据输入。Depth Plane 额外保留 Normal Smooth,增加默认开启的 Valid Only。Relief Plane 延续原 Relief 的厚度与位移语义。移除 Mode 与 Camera Thickness 命名,各组使用自己的 Thickness。 + +两个 Operator 共用图片准备、Job 调度和产物加载函数,通过可序列化业务参数区分转换类型。后端沿用整图深度 Job,并根据转换类型生成对应的单份法线产物。新类型纳入注册和逆序注销。 + +### 节点内 Alpha 剔除 + +```mermaid +flowchart TD + A[矩形基础网格与 UV] --> B[Subdivide 生成网格] + B --> C{Valid Only} + D[depth.exr 有效性 Alpha] --> E[Alpha 小于 1 后在 Point 域求值] + E --> F[剔除透明区域几何] + C -- 开启 --> F + C -- 关闭 --> G[相机 XYZ 投影] + F --> G + G --> H[曲面厚度] + H --> I[清理临时属性与输出] +``` + +剔除读取depth.exr 的有效性 Alpha,并使用原图 UV。直接使用 Data 中已有的 Depth Image 输入。用户只控制 Valid Only 和已有 Subdivide;内部使用 Linear / Extend 深度采样,Separate Color 的 Alpha 接 Float / Less Than(B = 1),再经 Boolean / Point 的 Evaluate on Domain 接 Face / All 的 Delete Geometry。仅全部顶点有效性均小于 1 的面删除,混合边界面保留。 + +剔除在几何节点中每次求值,避免将轮廓烘焙为固定网格。Valid Only 关闭时,投影与厚度接收完整细分网格。节点处理保持 UV、原图构图和世界位置。 + +### 最终几何平滑 + +Depth Plane 在投影、厚度及临时属性清理后直接调用 `node_graph.expand_smooth()`,复用 Depth Cutout 的 Smooth 模块。主接口 Smooth 默认 0、范围 0–20;Options 中 Smooth Weight 默认 1、范围 0–1,subtype 为 FACTOR。其后执行平滑着色与输出。 + +### 法线产物与材质 + +Depth Plane 请求并读取 `object_normal`,通过 `create_image_material(..., normal_space="OBJECT")` 创建材质;Relief Plane 请求并读取 `tangent_normal`,使用 TANGENT。复用现有法线编码和材质入口,确认相机轴到对象局部轴的转换与曲面投影一致。 + +深度继续使用 `depth`、`depth_metadata` 协议,RGB 保存相机 XYZ,Alpha 保存有效性。 + +## Risks / Trade-offs + +- Alpha 边界受网格密度影响 → 用有效性为 0、介于 0 和 1、混合边界和细窄结构验证参考图的宽松规则,并检查调整 Subdivide 后的实际结果。 +- Object Normal 与局部投影坐标不一致会造成明暗反向 → 验证法线轴向、材质空间设置及对象旋转后的表现。 +- 裁剪后的开口边界影响曲面厚度 → 在零厚度和正厚度下验证边界、UV 与几何求值。 +- 节点资产接口发生破坏性变化 → 构建脚本、验证及测试按两个独立资产同步更新,删除合并模式的旧连接与引用。 + +## Migration Plan + +实现顺序为入口与请求分流、法线产物绑定、节点组拆分、Alpha 剔除、验证与测试。沿用现有 Depth Plane 标识作为 Camera 入口,新增 Relief 标识,不增加旧模式兼容层。节点接口变化同步节点资产文档。 + +代码阶段运行测试;资产构建与 Blender 节点编辑器视觉验收作为后续资产更新阶段单独执行,交付时分别说明完成状态。 + +## Reference Graph + +用户参考图已确认:Separate Color / Alpha → Less Than 1 → Evaluate on Domain(Boolean、Point)→ Delete Geometry(Face、All)。Valid Only 通过几何 Switch 控制此段,关闭时使用完整细分网格。节点资产构建和 Blender 检查已获用户授权。 diff --git a/openspec/changes/split-depth-and-relief-planes/proposal.md b/openspec/changes/split-depth-and-relief-planes/proposal.md new file mode 100644 index 0000000..70c3d6b --- /dev/null +++ b/openspec/changes/split-depth-and-relief-planes/proposal.md @@ -0,0 +1,28 @@ +## Why + +整图相机投影与固定底面浮雕需要独立的转换入口和几何节点。相机投影生成背景模型时,透明区域需要参与几何剔除,同时保留修改器中调整网格密度的能力。 + +## What Changes + +- **BREAKING**:拆分现有 Relief / Camera 模式,`Convert to Depth Plane` 专门生成相机投影曲面,新增 `Convert to Relief Plane` 生成固定底面浮雕。 +- 两个入口分别加载 `O Image Depth Plane` 和 `O Image Relief Plane`,各自提供所需参数,移除 Mode 选择与跨模式参数。 +- Depth Plane 在基础网格细分后、深度投影与厚度处理前,根据depth.exr 有效性 Alpha 宽松剔除几何。修改器提供默认开启的 `Valid Only` 开关,保留 `Subdivide` 动态调节。 +- 宽松剔除使用深度有效性 Alpha < 1,经 Boolean / Point 的 Evaluate on Domain 后连接 Face / All 的 Delete Geometry。 +- Depth Plane 生成并使用 Object Space Normal Map;Relief Plane 延续 Tangent Space Normal Map。 + +- Depth Plane 复用 Depth Cutout 的 Smooth 模块,在最终曲面或厚度几何上执行平滑,提供 Smooth 和 Smooth Weight。 + +## Capabilities + +### New Capabilities + +- `separate-depth-relief-planes`:独立转换入口、节点资产、参数及对应法线空间。 +- `depth-plane-alpha-culling`:可开关、随细分重新求值的 Alpha 几何剔除。 + +### Modified Capabilities + +无;当前 `openspec/specs/` 无已归档规范。本提案接续 `unify-depth-plane-and-artifact` 的现有实现。 + +## Impact + +涉及转换 Operator、菜单与注册、Server Job 参数与法线产物、对象和材质创建、节点资产构建脚本、布局、验证及测试。节点资产文档随接口更新。复用现有深度协议与 Object Normal 生成能力,无需新增依赖。实施时保留工作区已有修改;执行测试,节点资产构建与 Blender 视觉验收单独安排。 diff --git a/openspec/changes/split-depth-and-relief-planes/specs/depth-plane-alpha-culling/spec.md b/openspec/changes/split-depth-and-relief-planes/specs/depth-plane-alpha-culling/spec.md new file mode 100644 index 0000000..4e26400 --- /dev/null +++ b/openspec/changes/split-depth-and-relief-planes/specs/depth-plane-alpha-culling/spec.md @@ -0,0 +1,36 @@ +## ADDED Requirements + +### Requirement: Optional alpha culling defaults to enabled +Depth Plane 修改器 SHALL 提供默认开启的 Valid Only 开关,按 depth.exr 有效性 Alpha 剔除几何。该处理 MUST 在基础网格细分之后、深度投影和厚度之前执行。 + +#### Scenario: Default conversion +- **WHEN** 用户转换包含无效深度区域的图片为 Depth Plane +- **THEN** Valid Only 默认开启,深度有效性 Alpha 参与基础网格的几何剔除 + +#### Scenario: Disable alpha culling +- **WHEN** 用户关闭 Valid Only +- **THEN** 完整细分网格进入相机投影与厚度流程 + +#### Scenario: Fully valid depth +- **WHEN** depth.exr 有效性 Alpha 全部为 1 +- **THEN** Alpha 剔除保留完整基础网格 + +### Requirement: Culling remains responsive to subdivision +系统 SHALL 在节点内重新求值 Alpha 剔除,允许用户继续通过 Subdivide 调节网格。剔除后的几何 MUST 保留正确 UV,并进入现有相机投影和曲面厚度计算。 + +#### Scenario: Change subdivision +- **WHEN** 用户修改 Subdivide +- **THEN** 节点重新生成网格并依据该网格重新检测 Alpha,无需重新运行 AI + +#### Scenario: Apply thickness after culling +- **WHEN** 用户对已剔除的曲面设置正 Thickness +- **THEN** 厚度从保留的曲面生成,无效区域已删除的基础面不会重新出现 + +### Requirement: Permissive culling follows the reference graph +Alpha 剔除 MUST 使用用户参考图中的宽松判定连接,且只公开 Valid Only 开关,网格精度使用已有 Subdivide。 + +深度图片 SHALL 使用 Linear / Extend 采样;Separate Color 的 Alpha SHALL 接 Float / Less Than(B = 1),结果通过 Boolean / Point 的 Evaluate on Domain 后接 Delete Geometry(Face、All)。一个面仅在所有顶点都满足 Alpha < 1 时删除;至少一个顶点 Alpha 为 1 的边界面保留。 + +#### Scenario: Verify the reference rule +- **WHEN** 实现 Alpha 判定 +- **THEN** 节点连接、运算、默认值和域设置与参考图一致,有效与无效区域交界网格的保留结果符合该图规则 diff --git a/openspec/changes/split-depth-and-relief-planes/specs/separate-depth-relief-planes/spec.md b/openspec/changes/split-depth-and-relief-planes/specs/separate-depth-relief-planes/spec.md new file mode 100644 index 0000000..c24291a --- /dev/null +++ b/openspec/changes/split-depth-and-relief-planes/specs/separate-depth-relief-planes/spec.md @@ -0,0 +1,50 @@ +## ADDED Requirements + +### Requirement: Independent conversion entries and assets +系统 SHALL 提供 Convert to Depth Plane 和 Convert to Relief Plane 两个入口,分别加载 `O Image Depth Plane` 与 `O Image Relief Plane`。Depth Plane MUST 使用相机投影,Relief Plane MUST 使用固定底面浮雕。两组 SHALL 提供各自参数并移除 Mode 选择。 + +#### Scenario: Convert an image to a depth plane +- **WHEN** 用户对静态 Image Empty 执行 Convert to Depth Plane +- **THEN** 系统创建相机投影曲面,绑定独立 Depth Plane 节点组,并保留原图构图、UV 与世界位置 + +#### Scenario: Convert an image to a relief plane +- **WHEN** 用户对静态 Image Empty 执行 Convert to Relief Plane +- **THEN** 系统创建固定底面浮雕,绑定独立 Relief Plane 节点组,Thickness 与 Depth Scale 延续原 Relief 语义 + +#### Scenario: Inspect modifier controls +- **WHEN** 用户查看两种结果的修改器 +- **THEN** 两者各自提供 Subdivide、Thickness、Depth Scale、Reference Depth,Depth Plane 提供 Normal Smooth,两者均无 Mode 选择 + +### Requirement: Normal space follows conversion type +系统 SHALL 为 Depth Plane 生成并消费 `object_normal`,材质使用 OBJECT 空间;为 Relief Plane 生成并消费 `tangent_normal`,材质使用 TANGENT 空间。法线编码 MUST 与对应对象局部坐标一致。 + +#### Scenario: Depth material uses object normals +- **WHEN** Depth Plane Job 完成并创建材质 +- **THEN** 材质加载 Object Normal 产物并启用 Object Space,法线轴向与相机投影后的局部几何一致 + +#### Scenario: Relief material uses tangent normals +- **WHEN** Relief Plane Job 完成并创建材质 +- **THEN** 材质加载 Tangent Normal 产物并使用 Tangent Space + +### Requirement: Shared conversion lifecycle +两个转换 SHALL 使用静态图与 AI 就绪检查、统一深度产物协议、对象替换及失败清理流程。新增 Operator MUST 纳入注册与逆序注销。 + +#### Scenario: Failed result creation +- **WHEN** 任一转换在加载产物或创建对象时失败 +- **THEN** 系统保留可用源对象,并清理本次创建且未被使用的数据块 + +#### Scenario: Extension lifecycle +- **WHEN** 扩展注册后逆序注销 +- **THEN** 两个转换入口均正确注册与移除 + + +### Requirement: Depth plane shares Cutout smoothing +Depth Plane SHALL 在投影与厚度之后复用 Depth Cutout 的 Smooth 模块。Smooth MUST 默认 0、范围 0–20;Options 中的 Smooth Weight MUST 默认 1、范围 0–1,使用 FACTOR 子类型。 + +#### Scenario: Smooth final geometry +- **WHEN** 用户增加 Smooth 并使用正 Smooth Weight +- **THEN** 最终顶点位置按 Cutout 的共用规则平滑,面连接与 UV 保持一致 + +#### Scenario: Disable smoothing +- **WHEN** Smooth 为 0 或 Smooth Weight 为 0 +- **THEN** 几何位置与未平滑结果相同 diff --git a/openspec/changes/split-depth-and-relief-planes/tasks.md b/openspec/changes/split-depth-and-relief-planes/tasks.md new file mode 100644 index 0000000..1e33f9c --- /dev/null +++ b/openspec/changes/split-depth-and-relief-planes/tasks.md @@ -0,0 +1,39 @@ +## 1. 补齐 Alpha 规则 + +- [x] 1.1 获取用户节点连接参考图,记录 Alpha 采样、阈值、字段域、删除模式和宽松边界结果,补齐 design 与 Alpha 规格;本项完成后实施第 4 组任务。 + +## 2. 转换入口与法线 + +- [x] 2.1 新增 ConvertToReliefPlane、菜单项与注册清单,ConvertToDepthPlane 改为相机投影语义,复用整图任务调度函数。 +- [x] 2.2 为整图 Job 增加可序列化转换类型参数,Depth 生成 object_normal,Relief 生成 tangent_normal,验证各自产物键与深度协议。 +- [x] 2.3 按转换类型加载节点组和法线,绑定 OBJECT / TANGENT 材质空间,保持对象替换、Undo 与失败清理行为。 + +## 3. 独立节点资产 + +- [x] 3.1 将相机投影与浮雕分别整理为 O Image Depth Plane 和 O Image Relief Plane 构建脚本,删除 Mode 与跨模式接口、旧连接及无用引用。 +- [x] 3.2 整理两组布局与验证脚本,确认接口顺序、参数默认值、未使用输出隐藏,以及独立 Thickness 和 Normal Smooth 的归属。 + +## 4. 节点内 Alpha 剔除 + +- [x] 4.1 为 Depth Plane 添加默认开启的 Valid Only ,复用现有 Depth Image 输入读取 depth.exr 的有效性 Alpha。 +- [x] 4.2 按已确认参考图在细分后、投影前连接宽松剔除,关闭开关时旁路完整网格,保留 UV 并衔接厚度计算。 +- [x] 4.3 验证全无效、全有效、中间有效性、混合边界与细窄区域,检查 Subdivide 改变后重新求值及零厚度、正厚度结果。 + +## 5. 测试与接口说明 + +- [x] 5.1 更新入口、注册注销、Job 产物、对象创建与材质测试,覆盖 Object Normal 轴向和 Relief 行为。 +- [x] 5.2 更新节点结构与几何求值测试,按参考图检查运算、默认值、域及连接,覆盖开关和动态细分。 +- [x] 5.3 同步节点资产文档中的两个资产接口与处理流程,检查当前源码和资产脚本中的旧模式引用。 +- [ ] 5.4 完成最终全量测试并确认正常退出。当前全量运行在 tests/test_node_arrangement.py 调用 node_arrangement._route_branch_exits 时发生 Blender access violation,独立复核仍复现。本次节点资产与转换相关 69 项测试通过,退出码 0。 + +## 6. 后续节点资产更新阶段 + +- [x] 6.1 已按用户授权构建 src/anyimage/assets/O_AnyImage.blend,Blender 4.5.10 的资产清单、接口、布局与几何验证通过。 +- [ ] 6.2 在 Blender 中按实际节点尺寸检查总览与局部布局,并以风景透明图检查剔除轮廓、厚度、细分调节和对象旋转后的法线表现。当前界面检查受阻:Computer Use 返回“Computer Use was not approved to use Blender”;待获得界面控制授权后完成。 + + +## 7. 复用 Cutout Smooth + +- [x] 7.1 在 Depth Plane 最终几何上复用 expand_smooth,沿用 Smooth 与 Smooth Weight 的默认值、范围及接口归属。 +- [x] 7.2 覆盖零厚度和正厚度的平滑、关闭平滑、面连接与 UV 保持,并同步节点资产说明。 +- [ ] 7.3 节点资产已重建,Blender 资产验证和 69 项相关测试通过;全量测试受自动布局工具 access violation 阻塞,待该工具修复后复验。 diff --git a/pyproject.toml b/pyproject.toml index 921b33c..3e177e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,8 +27,8 @@ dev = [ [project.scripts] pack = "tools.pack:main" -build_node = "tools.build_node:main" -sync_models = "tools.sync_models:main" +build_node = "tools.nodes.cli:main" +sync_models = "tools.models.cli:main" docs = "tools.docs:main" [build-system] diff --git a/src/anyimage/assets/O_AnyImage.blend b/src/anyimage/assets/O_AnyImage.blend index 3b27fdc..8425327 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:1d0756186a5cb1a36b829f80f9079981148207c540ea1075fbdb2f6f5c76859a -size 3215097 +oid sha256:c017781329942ef15dbe77eaf3d5678bdbba0349af30e994c192ace366e0bc36 +size 4293682 diff --git a/src/anyimage/common/material.py b/src/anyimage/common/material.py index e9194df..9dba4b9 100644 --- a/src/anyimage/common/material.py +++ b/src/anyimage/common/material.py @@ -91,9 +91,9 @@ def material_node_group(depth_plane=False): def create_image_material( - source_object, + source_image, color_image, - depth_scale, + depth_scale=0.0, displacement_image=None, displacement_midlevel=0.0, displacement_scale=1.0, @@ -107,11 +107,10 @@ def create_image_material( use_cyclic=None, normal_image=None, normal_space="TANGENT", - ior=None, scene=None, ): material_image = configure_material_color_image(color_image, scene) - material = bpy.data.materials.new(image_base_name(source_object.data)) + material = bpy.data.materials.new(image_base_name(source_image)) material.use_nodes = True if hasattr(material, "surface_render_method"): material.surface_render_method = "DITHERED" @@ -155,6 +154,10 @@ def create_image_material( material_inputs.node_tree = material_node_group(is_depth_plane) material_inputs.location = (-300.0, 350.0) material_inputs.inputs["Bump Scale"].default_value = depth_scale + if not is_depth_plane: + material_inputs.inputs["Alpha Fix"].default_value = float( + configured_material_view_adaptation() + ) if "Object Space" in material_inputs.inputs: if normal_space not in {"TANGENT", "OBJECT"}: raise ValueError(f"Unsupported normal space: {normal_space}") @@ -201,8 +204,7 @@ def create_image_material( else: shader = nodes.new("ShaderNodeBsdfPrincipled") shader.location = (250.0, 400.0) - if ior is not None: - shader.inputs["IOR"].default_value = float(ior) + shader.inputs["IOR"].default_value = 1.2 links.new( material_inputs.outputs["Color"], diff --git a/src/anyimage/menu.py b/src/anyimage/menu.py index aa9731a..da43660 100644 --- a/src/anyimage/menu.py +++ b/src/anyimage/menu.py @@ -23,6 +23,11 @@ class AnyImageImageMenu(bpy.types.Menu): text="Convert to Depth Plane", icon="MOD_DISPLACE", ) + layout.operator( + "anyimage.convert_to_relief_plane", + text="Convert to Relief Plane", + icon="MOD_DISPLACE", + ) layout.separator() layout.operator( "anyimage.remove_image_background", diff --git a/src/anyimage/operators/__init__.py b/src/anyimage/operators/__init__.py index 1b90ef1..99dc313 100644 --- a/src/anyimage/operators/__init__.py +++ b/src/anyimage/operators/__init__.py @@ -8,6 +8,7 @@ from .mask_tool import EditImageAlpha from .rectify_tool import RectifyImagePerspective from .convert_to_plane import ( ConvertToDepthPlane, + ConvertToReliefPlane, ConvertToPlane, GenerateDepthPlane, ) @@ -38,4 +39,5 @@ CLASSES = ( UpscaleImage, ConvertToPlane, ConvertToDepthPlane, + ConvertToReliefPlane, ) diff --git a/src/anyimage/operators/clipboard_image/actions.py b/src/anyimage/operators/clipboard_image/actions.py index 2e58d3a..7526f38 100644 --- a/src/anyimage/operators/clipboard_image/actions.py +++ b/src/anyimage/operators/clipboard_image/actions.py @@ -4,7 +4,7 @@ import tempfile import bpy -from ...common.material import configure_material_color_image +from ...common.material import create_image_material CLIPBOARD_HASH_PROPERTY = "anyimage_clipboard_sha256" CLIPBOARD_TEXTURE_PROPERTY = "anyimage_clipboard_texture" @@ -150,7 +150,9 @@ def add_image_plane( from ..convert_to_plane.image_plane import create_image_plane_object half_width, half_height = _plane_half_dimensions(width, height) - material = _create_image_material(image, shadeless, scene=context.scene) + material = create_image_material( + image, image, shadeless=shadeless, scene=context.scene + ) plane = create_image_plane_object( context, image.name, @@ -198,52 +200,6 @@ def _plane_half_dimensions(width, height): return width / height, 1.0 -def _create_image_material(image, shadeless=False, *, scene=None): - material_image = configure_material_color_image(image, scene) - material = bpy.data.materials.new(name=f"{image.name} Material") - material.use_nodes = True - if hasattr(material, "surface_render_method"): - material.surface_render_method = "DITHERED" - - nodes = material.node_tree.nodes - nodes.clear() - image_node = nodes.new("ShaderNodeTexImage") - image_node.image = material_image - image_node.location = (-620.0, 80.0) - - output_node = nodes.new("ShaderNodeOutputMaterial") - output_node.location = (220.0, 80.0) - - links = material.node_tree.links - if shadeless: - _create_shadeless_nodes(nodes, links, image_node, output_node) - return material - - shader_node = nodes.new("ShaderNodeBsdfPrincipled") - shader_node.location = (-160.0, 80.0) - links.new(image_node.outputs["Color"], shader_node.inputs["Base Color"]) - links.new(image_node.outputs["Alpha"], shader_node.inputs["Alpha"]) - links.new(shader_node.outputs["BSDF"], output_node.inputs["Surface"]) - return material - - -def _create_shadeless_nodes(nodes, links, image_node, output_node): - transparent_node = nodes.new("ShaderNodeBsdfTransparent") - transparent_node.location = (-360.0, -100.0) - - emission_node = nodes.new("ShaderNodeEmission") - emission_node.location = (-360.0, 120.0) - - mix_node = nodes.new("ShaderNodeMixShader") - mix_node.location = (-80.0, 80.0) - - links.new(image_node.outputs["Color"], emission_node.inputs["Color"]) - links.new(image_node.outputs["Alpha"], mix_node.inputs[0]) - links.new(transparent_node.outputs["BSDF"], mix_node.inputs[1]) - links.new(emission_node.outputs["Emission"], mix_node.inputs[2]) - links.new(mix_node.outputs["Shader"], output_node.inputs["Surface"]) - - def _select_only(context, plane): for selected_object in getattr(context, "selected_objects", ()): selected_object.select_set(False) diff --git a/src/anyimage/operators/convert_to_plane/__init__.py b/src/anyimage/operators/convert_to_plane/__init__.py index e3ff2ed..332a97c 100644 --- a/src/anyimage/operators/convert_to_plane/__init__.py +++ b/src/anyimage/operators/convert_to_plane/__init__.py @@ -2,12 +2,14 @@ from .operators import ( ConvertToDepthPlane, + ConvertToReliefPlane, ConvertToPlane, GenerateDepthPlane, ) __all__ = ( "ConvertToDepthPlane", + "ConvertToReliefPlane", "ConvertToPlane", "GenerateDepthPlane", ) diff --git a/src/anyimage/operators/convert_to_plane/object.py b/src/anyimage/operators/convert_to_plane/object.py index 920411d..d5c5b48 100644 --- a/src/anyimage/operators/convert_to_plane/object.py +++ b/src/anyimage/operators/convert_to_plane/object.py @@ -67,8 +67,12 @@ def create_depth_plane_object( material, depth_image, depth_metadata, + plane_type, ): - node_group = load_node_group("O Image Depth Plane") + node_group = load_node_group({ + "DEPTH": "O Image Depth Plane", + "RELIEF": "O Image Relief Plane", + }[plane_type]) source_name = source_object.name bounds = image_empty_bounds(source_object) mesh = create_image_plane_mesh(source_name, bounds) @@ -80,7 +84,7 @@ def create_depth_plane_object( plane = bpy.data.objects.new(source_name, mesh) context.collection.objects.link(plane) plane.matrix_world = build_image_plane_matrix(source_object.matrix_world, bounds) - plane["anyimage_mesh_shape"] = "DEPTH_PLANE" + plane["anyimage_mesh_shape"] = f"{plane_type}_PLANE" modifier = plane.modifiers.new(name=node_group.name, type="NODES") modifier.node_group = node_group @@ -108,6 +112,7 @@ def create_depth_plane_object( def create_depth_plane_from_result(context, result, operator): + normal_space = {"DEPTH": "OBJECT", "RELIEF": "TANGENT"}[operator.plane_type] source_object = require_image_empty(operator.source_object_name) color_image = load_color_result_image(source_object, result.directory) color_image.name = color_data_name(source_object) @@ -122,16 +127,14 @@ def create_depth_plane_from_result(context, result, operator): depth_metadata, ) normal_image = load_normal_result_image( - result.file("tangent_normal"), + result.file(f"{normal_space.lower()}_normal"), source_object, ) material = create_image_material( - source_object, + source_object.data, color_image, - 0.0, normal_image=normal_image, - normal_space="TANGENT", - ior=1.2, + normal_space=normal_space, scene=context.scene, ) create_depth_plane_object( @@ -141,6 +144,7 @@ def create_depth_plane_from_result(context, result, operator): material, depth_image, depth_metadata, + operator.plane_type, ) except Exception: if material is not None and material.users == 0: @@ -149,6 +153,6 @@ def create_depth_plane_from_result(context, result, operator): if image is not None and image.users == 0: bpy.data.images.remove(image, do_unlink=True) raise - label = "Convert to Depth Plane" + label = f"Convert to {operator.plane_type.title()} Plane" if "FINISHED" not in bpy.ops.ed.undo_push(message=label): raise RuntimeError(f"Unable to create the {label} Undo step") diff --git a/src/anyimage/operators/convert_to_plane/operators.py b/src/anyimage/operators/convert_to_plane/operators.py index 16a1dcf..ba78c0a 100644 --- a/src/anyimage/operators/convert_to_plane/operators.py +++ b/src/anyimage/operators/convert_to_plane/operators.py @@ -50,6 +50,11 @@ class GenerateDepthPlane(JobOperatorBase, bpy.types.Operator): input_path: bpy.props.StringProperty(options=_HIDDEN) source_object_name: bpy.props.StringProperty(options=_HIDDEN) + plane_type: bpy.props.EnumProperty( + items=(("DEPTH", "Depth Plane", ""), ("RELIEF", "Relief Plane", "")), + default="DEPTH", + options=_HIDDEN, + ) mesh_detail: bpy.props.IntProperty(default=6, min=0, max=10, options=_HIDDEN) model: bpy.props.StringProperty(default=DEFAULT_MOGE2_MODEL_KEY, options=_HIDDEN) resolution_level: bpy.props.IntProperty(default=5, min=0, max=9, options=_HIDDEN) @@ -71,6 +76,7 @@ class GenerateDepthPlane(JobOperatorBase, bpy.types.Operator): "video_frames": 1, "start_frame": source_start_frame(self.source_object_name), "max_input_size": int(self.max_input_size), + "plane_type": self.plane_type, } parameters.update( moge2_parameters( @@ -85,13 +91,13 @@ class GenerateDepthPlane(JobOperatorBase, bpy.types.Operator): def response(self, context, result): create_depth_plane_from_result(context, result, self) - return "Depth Plane is ready" + return f"{self.plane_type.title()} Plane is ready" def cleanup(self): cleanup_image_input(self.input_path, self.delete_input) -def start_depth_plane_job(operator, context): +def start_depth_plane_job(operator, context, plane_type): source_object = context.object if video_total_frames(source_object.data) != 1: operator.report({"ERROR"}, "Depth conversion currently supports still images") @@ -112,6 +118,7 @@ def start_depth_plane_job(operator, context): input_path=str(source_path), source_object_name=source_object.name, mesh_detail=operator.mesh_detail, + plane_type=plane_type, model=model, resolution_level=resolution_level, max_input_size=configured_max_ai_input_size(context), @@ -141,9 +148,8 @@ class ConvertToPlane(bpy.types.Operator): source_object ) material = create_image_material( - source_object, + source_object.data, color_image, - 1.0, frame_start=frame_start, color_frame_offset=frame_offset, color_frame_duration=frame_duration, @@ -157,7 +163,7 @@ class ConvertToPlane(bpy.types.Operator): class ConvertToDepthPlane(bpy.types.Operator): bl_idname = "anyimage.convert_to_depth_plane" bl_label = "Convert to Depth Plane" - bl_description = "Convert the complete Image Empty into a Relief or Camera depth plane" + bl_description = "Convert the complete Image Empty into a camera-projected depth plane" bl_options = {"REGISTER", "UNDO"} mesh_detail: bpy.props.IntProperty(name="Subdivide", default=6, min=0, max=10) @@ -168,8 +174,23 @@ class ConvertToDepthPlane(bpy.types.Operator): return is_image_empty(context.object) and not runtime.server_busy() - def invoke(self, context, _event): - return self.execute(context) + def execute(self, context): + return start_depth_plane_job(self, context, "DEPTH") + + +class ConvertToReliefPlane(bpy.types.Operator): + bl_idname = "anyimage.convert_to_relief_plane" + bl_label = "Convert to Relief Plane" + bl_description = "Convert the complete Image Empty into a fixed-base relief plane" + bl_options = {"REGISTER", "UNDO"} + + mesh_detail: bpy.props.IntProperty(name="Subdivide", default=6, min=0, max=10) + + @classmethod + def poll(cls, context): + from ...runtime import runtime + + return is_image_empty(context.object) and not runtime.server_busy() def execute(self, context): - return start_depth_plane_job(self, context) + return start_depth_plane_job(self, context, "RELIEF") diff --git a/src/anyimage/operators/cutout_tool/object.py b/src/anyimage/operators/cutout_tool/object.py index 0326124..86cd703 100644 --- a/src/anyimage/operators/cutout_tool/object.py +++ b/src/anyimage/operators/cutout_tool/object.py @@ -192,16 +192,14 @@ def create_shape_object( mesh_object = None try: material = create_image_material( - source_object, + source_object.data, color_image, - 0.0, normal_image=normal_image, normal_space=( NormalMode.TANGENT.value if normal_mode == NormalMode.NONE else normal_mode.value ), - ior=1.2, scene=context.scene, ) depth_reference = None diff --git a/src/anyimage/server/geometry/depth_texture.py b/src/anyimage/server/geometry/depth_texture.py index ddcf72e..565d167 100644 --- a/src/anyimage/server/geometry/depth_texture.py +++ b/src/anyimage/server/geometry/depth_texture.py @@ -71,19 +71,22 @@ def _write_float_exr(rgba, output_path): return output_path -def write_depth_texture(frame, output_path): - """Write a frame's camera-space XYZ and validity as float RGBA.""" +def write_depth_texture(frame, output_path, *, alpha): + """Write camera-space XYZ and alpha-weighted model validity as float RGBA.""" import numpy as np if frame.points is None: raise ValueError("Depth requires a point field") points = np.asarray(frame.points, dtype=np.float32) validity = np.asarray(frame.validity, dtype=np.float32) + alpha = np.asarray(alpha, dtype=np.float32) + if alpha.shape != validity.shape: + raise ValueError("Depth Alpha must match the validity dimensions") if not np.isfinite(points).all(): raise ValueError("Point field contains non-finite values") rgba = np.empty((*validity.shape, 4), dtype=np.float32) rgba[..., :3] = points - rgba[..., 3] = validity + rgba[..., 3] = alpha * validity return _write_float_exr(rgba, output_path) @@ -101,7 +104,7 @@ def write_depth_metadata(frame, reference_valid, output_path): raise ValueError("Depth metadata reference mask dimensions do not match") if intrinsics.shape != (3, 3): raise ValueError("Depth metadata requires 3x3 camera intrinsics") - valid = (validity > 0.0) & np.isfinite(depth) & (depth > 0.0) + valid = (validity > 0.5) & np.isfinite(depth) & (depth > 0.0) reference_valid &= valid if not valid.any(): raise ValueError("Geometry frame contains no valid depth pixels") diff --git a/src/anyimage/server/geometry/edge_depth.py b/src/anyimage/server/geometry/edge_depth.py index fb61d30..c158973 100644 --- a/src/anyimage/server/geometry/edge_depth.py +++ b/src/anyimage/server/geometry/edge_depth.py @@ -1,4 +1,4 @@ -"""Extend reliable Cutout depth along each visible component's silhouette.""" +"""Extend reliable depth along each visible component's silhouette.""" from dataclasses import replace @@ -18,19 +18,20 @@ def extend_edge_depth(frame, alpha, alpha_threshold=0.9): return frame visible = (alpha > 0.0) & (alpha >= alpha_threshold) + opaque = visible & (alpha >= 0.95) candidates = ( - visible - & (alpha >= 0.95) - & (frame.validity > 0.0) + opaque + & (frame.validity > 0.5) & np.isfinite(depth) & (depth > 0.0) ) if not candidates.any(): return frame interior_distance = ndimage.distance_transform_edt( - np.pad(candidates, 3, mode="edge") + np.pad(opaque, 3, mode="edge") )[3:-3, 3:-3] - core = candidates & (interior_distance > 2.0) + interior = opaque & (interior_distance > 2.0) + core = candidates & interior del interior_distance labels, _ = ndimage.label(visible, structure=np.ones((3, 3), dtype=bool)) corrected = depth.copy() @@ -59,7 +60,7 @@ def extend_edge_depth(frame, alpha, alpha_threshold=0.9): & (distance <= 4.0) & (distance < background_distance[region]) ) - target = (component & ~donors) | background + target = (component & ~interior[region] & ~donors) | background corrected[region][target] = smoothed[target] background_distance[region][background] = distance[background] diff --git a/src/anyimage/server/geometry/prediction_artifacts.py b/src/anyimage/server/geometry/prediction_artifacts.py index bb6bbd8..833bc85 100644 --- a/src/anyimage/server/geometry/prediction_artifacts.py +++ b/src/anyimage/server/geometry/prediction_artifacts.py @@ -14,10 +14,8 @@ def _limited_model_input(source_path, output_path, max_input_size): from PIL import Image with Image.open(source_path) as opened: - limited = limit_image(opened.convert("RGBA"), max_input_size) - background = Image.new("RGBA", limited.size, (0, 0, 0, 255)) - normalized = Image.alpha_composite(background, limited).convert("RGB") - normalized.save(output_path, format="PNG", compress_level=1) + limited = limit_image(opened.convert("RGB"), max_input_size) + limited.save(output_path, format="PNG", compress_level=1) return output_path @@ -58,7 +56,6 @@ def generate_moge2_artifacts( *, generate_depth=False, normal_mode="NONE", - correct_edge_depth=False, alpha_threshold=0.9, ): """Run MoGe-2 once and write only the requested file artifacts.""" @@ -85,17 +82,13 @@ def generate_moge2_artifacts( if generate_depth: context.progress(0.45, "Generating depth texture") alpha = _image_alpha(input_path, frame.depth.shape) - depth_frame = ( - extend_edge_depth(frame, alpha, alpha_threshold) - if correct_edge_depth else frame - ) + depth_frame = extend_edge_depth(frame, alpha, alpha_threshold) result["depth"] = write_depth_texture( - depth_frame, context.directory / "depth.exr", + depth_frame, context.directory / "depth.exr", alpha=alpha, ) result["depth_metadata"] = write_depth_metadata( depth_frame, - (alpha > 0.0) & (alpha >= alpha_threshold) - if correct_edge_depth else alpha >= 0.1, + (alpha > 0.0) & (alpha >= alpha_threshold), context.directory / "depth.json", ) diff --git a/src/anyimage/server/jobs/cutout.py b/src/anyimage/server/jobs/cutout.py index 6c4b61d..0ff18e3 100644 --- a/src/anyimage/server/jobs/cutout.py +++ b/src/anyimage/server/jobs/cutout.py @@ -19,7 +19,6 @@ def run(context, parameters): input_path, generate_depth=generate_depth, normal_mode=normal_mode, - correct_edge_depth=generate_depth, alpha_threshold=float(parameters.get("alpha_threshold", 0.9)), ) result.update({key: path.name for key, path in artifacts.items()}) diff --git a/src/anyimage/server/jobs/depth_plane.py b/src/anyimage/server/jobs/depth_plane.py index 3f1a90c..6e85dd9 100644 --- a/src/anyimage/server/jobs/depth_plane.py +++ b/src/anyimage/server/jobs/depth_plane.py @@ -5,6 +5,9 @@ from ..media import copy_color_images, input_frames def run(context, parameters): + plane_type = parameters["plane_type"] + if plane_type not in {"DEPTH", "RELIEF"}: + raise ValueError(f"Unsupported plane type: {plane_type}") context.progress(0.05, "Preparing depth conversion") context.directory.mkdir(parents=True, exist_ok=True) with input_frames(context, parameters) as images: @@ -16,7 +19,7 @@ def run(context, parameters): parameters, images[0], generate_depth=True, - normal_mode="TANGENT", + normal_mode="OBJECT" if plane_type == "DEPTH" else "TANGENT", ) context.check_cancelled() context.progress(1.0, "Depth conversion data is ready") diff --git a/src/anyimage/server/models/onnx_moge2.py b/src/anyimage/server/models/onnx_moge2.py index 6cdd04f..1759331 100644 --- a/src/anyimage/server/models/onnx_moge2.py +++ b/src/anyimage/server/models/onnx_moge2.py @@ -114,11 +114,11 @@ def postprocess(raw_output): points = np.asarray(raw_output["points"][0], dtype=np.float32) normal = np.asarray(raw_output["normal"][0], dtype=np.float32) - mask = np.asarray(raw_output["mask"][0] > 0.5) + mask = np.asarray(raw_output["mask"][0], dtype=np.float32) metric_scale = np.float32(np.asarray(raw_output["metric_scale"]).reshape(-1)[0]) height, width = mask.shape aspect_ratio = width / height - focal, shift = recover_focal_shift(points, mask) + focal, shift = recover_focal_shift(points, mask > 0.5) diagonal_factor = (1.0 + aspect_ratio**2) ** 0.5 fx = focal / 2.0 * diagonal_factor / aspect_ratio fy = focal / 2.0 * diagonal_factor @@ -127,7 +127,6 @@ def postprocess(raw_output): dtype=np.float32, ) depth = points[..., 2] + shift - mask &= depth > 0.0 depth = depth * metric_scale points = depth_to_points(depth, intrinsics) return { diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e3bf2ef --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,5 @@ +"""Expose project source packages to the test suite.""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) diff --git a/tests/depth_surface_test_support.py b/tests/depth_surface_test_support.py index cf727d3..693409a 100644 --- a/tests/depth_surface_test_support.py +++ b/tests/depth_surface_test_support.py @@ -11,14 +11,13 @@ ROOT = Path(__file__).parents[1] sys.path.insert(0, str(ROOT / "src")) -sys.path.insert(0, str(ROOT / "tools" / "node_assets")) -from cutout import build_cutout_group +from node_asset_test_support import CUTOUT_BUILDERS def surface(*, step=6.0, size=1.0, resolution=2, wrapper=False): bpy.ops.wm.read_factory_settings(use_empty=True) - group = build_cutout_group("DEPTH_SOLID") + group = CUTOUT_BUILDERS["DEPTH_SOLID"]() # Two columns with a complete depth jump along their common boundary. points = [ (x * size / resolution, y * size / resolution, 0) diff --git a/tests/node_asset_test_support.py b/tests/node_asset_test_support.py new file mode 100644 index 0000000..af55ded --- /dev/null +++ b/tests/node_asset_test_support.py @@ -0,0 +1,22 @@ +"""Shared assertions and asset selection for node tests.""" +from tools.nodes.groups.image_cutout import build_image_cutout_group +from tools.nodes.groups.image_depth_cutout import build_image_depth_cutout_group +from tools.nodes.groups.image_depth_balloon import build_image_depth_balloon_group + +CUTOUT_BUILDERS = { + "FLAT": build_image_cutout_group, + "SOLID": build_image_cutout_group, + "DEPTH_SOLID": build_image_depth_cutout_group, + "DEPTH_BALLOON": build_image_depth_balloon_group, +} + + +def validate_node_structure(node_group): + """Check visible sockets, default titles, and paired execution endpoints.""" + nodes = [node for node in node_group.nodes if node.bl_idname not in {"NodeReroute", "NodeFrame"}] + assert all(not node.label for node in nodes), node_group.name + assert not any(node.bl_idname == "FunctionNodeInputString" for node in nodes), node_group.name + assert all(socket.is_linked or socket.hide for node in node_group.nodes for socket in node.outputs), node_group.name + for node in nodes: + if node.bl_idname in {"GeometryNodeRepeatInput", "GeometryNodeSimulationInput", "GeometryNodeForeachGeometryElementInput"}: + assert node.paired_output in node_group.nodes.values(), (node_group.name, node.name) diff --git a/tests/test_addon_registration.py b/tests/test_addon_registration.py index 90efb9e..de9e47e 100644 --- a/tests/test_addon_registration.py +++ b/tests/test_addon_registration.py @@ -21,6 +21,7 @@ class AddonRegistrationTest(BlenderTestCase): operator = self.convert_to_plane.ConvertToPlane self.assertIn(operator, self.anyimage.CLASSES) self.assertIn(self.convert_to_plane.ConvertToDepthPlane, self.anyimage.CLASSES) + self.assertIn(self.convert_to_plane.ConvertToReliefPlane, self.anyimage.CLASSES) self.assertFalse(hasattr(self.convert_to_plane, "ConvertToDepthSurface")) self.assertIn(self.cutout_main.CutoutSelectionToShape, self.anyimage.CLASSES) self.assertEqual(operator.bl_idname, "anyimage.convert_to_plane") diff --git a/tests/test_clipboard_image.py b/tests/test_clipboard_image.py index 1aec8f3..9535d37 100644 --- a/tests/test_clipboard_image.py +++ b/tests/test_clipboard_image.py @@ -396,43 +396,23 @@ class ClipboardImageTest(unittest.TestCase): finally: clipboard_image._native_copy_token = original_token - def test_shadeless_nodes_mix_emission_with_transparency(self): - created_nodes = {} + def test_plane_paste_uses_shared_material_for_both_shading_modes(self): + from anyimage.operators.convert_to_plane import image_plane - def create_node(node_type): - node = SimpleNamespace(location=None, inputs={}, outputs={}) - if node_type == "ShaderNodeBsdfTransparent": - node.outputs["BSDF"] = object() - elif node_type == "ShaderNodeEmission": - node.inputs["Color"] = object() - node.outputs["Emission"] = object() - else: - node.inputs = [object(), object(), object()] - node.outputs["Shader"] = object() - created_nodes[node_type] = node - return node - - nodes = SimpleNamespace(new=Mock(side_effect=create_node)) - links = SimpleNamespace(new=Mock()) - image_node = SimpleNamespace( - outputs={"Color": object(), "Alpha": object()}, - ) - output_node = SimpleNamespace(inputs={"Surface": object()}) - - self.actions._create_shadeless_nodes( - nodes, - links, - image_node, - output_node, - ) - - mix_node = created_nodes["ShaderNodeMixShader"] - links.new.assert_any_call(image_node.outputs["Alpha"], mix_node.inputs[0]) - links.new.assert_any_call( - created_nodes["ShaderNodeEmission"].outputs["Emission"], - mix_node.inputs[2], - ) - self.assertEqual(links.new.call_count, 5) + image = SimpleNamespace(name="Clipboard", size=(16, 8)) + context = SimpleNamespace(mode="OBJECT", scene=object()) + for shadeless in (False, True): + with ( + patch.object(self.actions, "create_image_material", return_value="Material") as create_material, + patch.object(image_plane, "create_image_plane_object") as create_plane, + patch.object(self.actions, "_place_in_view"), + patch.object(self.actions, "_select_only"), + ): + self.actions.add_image_plane(context, image, shadeless=shadeless) + create_material.assert_called_once_with( + image, image, shadeless=shadeless, scene=context.scene, + ) + self.assertEqual(create_plane.call_args.args[3], "Material") def test_reference_import_routes_to_image_empty(self): context = self.make_context("VIEW_3D") diff --git a/tests/test_cutout_objects.py b/tests/test_cutout_objects.py index e8c1eaa..a1939a9 100644 --- a/tests/test_cutout_objects.py +++ b/tests/test_cutout_objects.py @@ -123,7 +123,8 @@ def test_depth_result_image_loads_server_vector_exr_and_keeps_data_name(tmp_path points=points, ) path = tmp_path / "depth.exr" - write_depth_texture(frame, path) + alpha = np.asarray(((0.8, 1.0), (0.75, 0.0)), dtype=np.float32) + write_depth_texture(frame, path, alpha=alpha) metadata = _depth_metadata(np.eye(3), (2, 2), 1.0) image = depth_data.load_depth_result_image(path, source, metadata) @@ -132,7 +133,10 @@ def test_depth_result_image_loads_server_vector_exr_and_keeps_data_name(tmp_path image.pixels.foreach_get(packed) packed = np.flipud(packed.reshape((2, 2, 4))) assert np.array_equal(packed[..., :3], points) - assert np.array_equal(packed[..., 3], validity) + assert np.array_equal(packed[..., 3], alpha * validity) + camera_depth, valid = depth_data.camera_depth_values(image) + np.testing.assert_array_equal(camera_depth, points[..., 2]) + np.testing.assert_array_equal(valid, alpha * validity > 0.5) assert image.alpha_mode == "CHANNEL_PACKED" assert image.name == "Source_depth.exr" @@ -149,7 +153,7 @@ def test_depth_texture_keeps_points_outside_positive_validity(tmp_path): intrinsics=np.eye(3, dtype=np.float32), ) path = tmp_path / "depth.exr" - write_depth_texture(frame, path) + write_depth_texture(frame, path, alpha=np.ones_like(frame.validity)) image = depth_data.load_depth_result_image( path, diff --git a/tests/test_cutout_volume.py b/tests/test_cutout_volume.py index 5ff8eeb..e075b4e 100644 --- a/tests/test_cutout_volume.py +++ b/tests/test_cutout_volume.py @@ -7,10 +7,9 @@ import pytest ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "src")) -sys.path.insert(0, str(ROOT / "tools/node_assets")) -from cutout import build_cutout_group -from cutout_volume import build_volume_fields -from cutout_rounding import build_volume_rounding +from node_asset_test_support import CUTOUT_BUILDERS +from tools.nodes.groups.image_depth_cutout import build_volume_fields +from tools.nodes.groups.image_depth_cutout import build_volume_rounding from anyimage.operators.cutout_tool.object import load_cutout_node_group from anyimage.operators.cutout_tool.operators import normal_mode_for_shape from anyimage.operators.cutout_tool.geometry import refine_balloon_triangles, poisson_balloon_profile @@ -47,7 +46,7 @@ def test_refined_boundary_ears_have_interior_balloon_samples(): def test_depth_rounding_options_are_consolidated(): bpy.ops.wm.read_factory_settings(use_empty=True) - group = build_cutout_group("DEPTH_SOLID") + group = CUTOUT_BUILDERS["DEPTH_SOLID"]() inputs = {s.name: s for s in group.interface.items_tree if s.item_type == "SOCKET" and s.in_out == "INPUT"} for name, default in (("Edge Round", 0.2), ("Front Inflation", 1), ("Normal Smooth", 50)): assert inputs[name].default_value == pytest.approx(default) @@ -131,7 +130,7 @@ def test_rounding_shares_front_and_back_falloff(amount): group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") source = group.nodes.new("NodeGroupInput") result = build_volume_fields(group, source.outputs["Geometry"]) - result = build_volume_rounding(group, result, (5000, 0)) + result = build_volume_rounding(group, result) group.links.new(result, group.nodes.new("NodeGroupOutput").inputs["Geometry"]) obj.modifiers.new("Round", "NODES").node_group = group bpy.context.view_layer.update() diff --git a/tests/test_depth_plane_modes.py b/tests/test_depth_relief_planes.py similarity index 59% rename from tests/test_depth_plane_modes.py rename to tests/test_depth_relief_planes.py index b3c57c0..eeeeb26 100644 --- a/tests/test_depth_plane_modes.py +++ b/tests/test_depth_relief_planes.py @@ -1,6 +1,5 @@ """Evaluate complete-image projection and rectangular surface topology.""" -import sys from collections import Counter from pathlib import Path import bpy @@ -8,23 +7,35 @@ import numpy as np import pytest ROOT = Path(__file__).parents[1] -sys.path[:0] = [str(ROOT / "src"), str(ROOT / "tools" / "node_assets")] -from image_plane import build_image_plane_group -from depth_plane import build_depth_plane_group -from node_layout import validate_node_layout +from tools.nodes.groups.image_plane import build_image_plane_group +from tools.nodes.groups.image_depth_plane import build_image_depth_plane_group +from tools.nodes.groups.image_relief_plane import build_image_relief_plane_group +from node_asset_test_support import validate_node_structure +from tools.nodes.arrangement.flow import origin_socket @pytest.fixture(params=("definition", "asset")) def surface(request): + return create_surface(request.param, "DEPTH") + + +@pytest.fixture(params=("definition", "asset")) +def relief(request): + return create_surface(request.param, "RELIEF") + + +def create_surface(source, plane_type): bpy.ops.wm.read_factory_settings(use_empty=True) - if request.param == "asset": - with bpy.data.libraries.load(str(ROOT / "src/anyimage/assets/O_AnyImage.blend")) as (_, target): - target.node_groups = ["O Image Depth Plane"] + group_name = f"O Image {plane_type.title()} Plane" + if source == "asset": + with bpy.data.libraries.load(str(ROOT / "src/anyimage/assets/O_AnyImage.blend")) as (library, target): + target.node_groups = [group_name] group = target.node_groups[0] plane = group.nodes["Group"].node_tree else: plane = build_image_plane_group() - group = build_depth_plane_group(plane) + builder = build_image_depth_plane_group if plane_type == "DEPTH" else build_image_relief_plane_group + group = builder(plane) mesh = bpy.data.meshes.new("Rectangle") mesh.from_pydata([(-2, -1, 0), (2, -1, 0), (2, 1, 0), (-2, 1, 0)], [], [(0, 1, 2, 3)]) material = bpy.data.materials.new("Image") @@ -42,21 +53,16 @@ def surface(request): pixels[..., 0] = ((xx + 0.5) - 5) / 4 pixels[..., 1] = (3 - (yy + 0.5)) / 4 pixels[..., 2] = 2 + (xx + 0.5) / 16 - pixels[..., 3] = xx >= 8 + pixels[..., 3] = 1 image.pixels.foreach_set(pixels.ravel()) inputs = {item.name: item for item in group.interface.items_tree if item.item_type == "SOCKET" and item.in_out == "INPUT"} - thickness_inputs = [item for item in group.interface.items_tree - if item.item_type == "SOCKET" and item.in_out == "INPUT" - and item.name == "Thickness"] - inputs["Relief Thickness"] = thickness_inputs[0] - def set_value(name, value): modifier[inputs[name].identifier] = value obj.update_tag(refresh={"DATA"}) bpy.context.view_layer.update() - for name, value in (("Mode", 1), ("Subdivide", 2), ("Depth Image", image), + for name, value in (("Subdivide", 2), ("Depth Image", image), ("Uniform Scale", 0.5), ("Reference Depth", 1.2), ("Depth Scale", 0.0), ("Thickness", 0.0)): set_value(name, value) @@ -70,7 +76,7 @@ def evaluated(obj): return ( np.array([v.co[:] for v in mesh.vertices]), [tuple(face.vertices) for face in mesh.polygons], - np.array([value.uv[:] for value in mesh.uv_layers["UVMap"].data]), + np.array([value.uv[:] for value in mesh.uv_layers["UVMap"].data]) if mesh.uv_layers else np.empty((0, 2)), [material.name if material else None for material in mesh.materials], [face.material_index for face in mesh.polygons], [attr.name for attr in mesh.attributes], @@ -157,10 +163,9 @@ def test_depth_jump_keeps_rectangular_faces(surface): @pytest.mark.parametrize("thickness", [0.0, 0.4]) -def test_relief_reads_z_and_keeps_the_base(surface, thickness): - obj, set_value, _, inputs, image = surface - set_value("Mode", 0) - set_value("Relief Thickness", thickness) +def test_relief_reads_z_and_keeps_the_base(relief, thickness): + obj, set_value, _, inputs, image = relief + set_value("Thickness", thickness) set_value("Depth Scale", 1.0) set_value("Reference Depth", 2.0) pixels = np.ones((8, 16, 4), dtype=np.float32) @@ -174,16 +179,9 @@ def test_relief_reads_z_and_keeps_the_base(surface, thickness): pixels[..., 0], pixels[..., 1] = 31, -22 image.pixels.foreach_set(pixels.ravel()) image.update() - set_value("Normal Smooth", 0) + set_value("Uniform Scale", 0.5) np.testing.assert_allclose(evaluated(obj)[0], first[0]) - original_images = set(bpy.data.images) - set_value("Mode", 1) - camera_points = evaluated(obj)[0] - assert camera_points.shape != first[0].shape or not np.allclose(camera_points, first[0]) - set_value("Mode", 0) - np.testing.assert_allclose(evaluated(obj)[0], first[0]) - assert set(bpy.data.images) == original_images - assert obj.modifiers[0][inputs["Relief Thickness"].identifier] == pytest.approx(thickness) + assert obj.modifiers[0][inputs["Thickness"].identifier] == pytest.approx(thickness) @pytest.mark.parametrize("normal_smooth", [0, 2, 50]) @@ -228,48 +226,43 @@ def test_curved_shell_smooths_normals_without_shrinking_thickness(surface, norma assert roughness(normals) < roughness(raw_normals) -@pytest.mark.parametrize("mode,active,inactive", [ - (0, "Relief Thickness", "Thickness"), - (1, "Thickness", "Relief Thickness"), -]) -def test_modes_keep_independent_thickness_values(surface, mode, active, inactive): - obj, set_value, _, inputs, _ = surface - set_value("Depth Scale", 1.0) - set_value("Mode", mode) - set_value(active, 0.2) - before = evaluated(obj) - set_value(inactive, 0.7) - np.testing.assert_allclose(evaluated(obj)[0], before[0]) - assert evaluated(obj)[1] == before[1] - set_value("Mode", 1 - mode) - set_value("Mode", mode) - np.testing.assert_allclose(evaluated(obj)[0], before[0]) - assert obj.modifiers[0][inputs[active].identifier] == pytest.approx(0.2) - assert obj.modifiers[0][inputs[inactive].identifier] == pytest.approx(0.7) - - -def test_surface_interface_and_authored_layout(surface): +def test_surface_interface_and_node_structure(surface): obj, _, _, inputs, _ = surface - assert list(inputs) == ["Geometry", "Mode", "Subdivide", "Thickness", "Depth Scale", - "Reference Depth", "Normal Smooth", "Uniform Scale", "Depth Image", "Relief Thickness"] - assert [name for name, item in inputs.items() if not item.hide_in_modifier][1:] == [ - "Mode", "Subdivide", "Thickness", "Depth Scale", "Reference Depth", "Normal Smooth", "Relief Thickness"] + assert list(inputs) == ["Geometry", "Subdivide", "Thickness", "Depth Scale", "Smooth", "Valid Only", + "Reference Depth", "Normal Smooth", "Smooth Weight", "Validity Threshold", "Uniform Scale", "Depth Image"] + assert inputs["Validity Threshold"].default_value == pytest.approx(0.9) + assert inputs["Validity Threshold"].parent.name == "Options" + assert inputs["Validity Threshold"].subtype == "FACTOR" + assert inputs["Valid Only"].default_value is True + assert inputs["Smooth"].default_value == 0 + assert inputs["Smooth"].min_value == 0 and inputs["Smooth"].max_value == 20 + assert inputs["Smooth Weight"].default_value == 1 + assert inputs["Smooth Weight"].subtype == "FACTOR" + assert inputs["Smooth Weight"].parent.name == "Options" assert inputs["Normal Smooth"].default_value == 50 assert inputs["Normal Smooth"].min_value == 0 assert inputs["Normal Smooth"].max_value == 50 assert inputs["Normal Smooth"].parent.name == "Options" - assert inputs["Thickness"].default_value == inputs["Relief Thickness"].default_value == 0 - assert inputs["Thickness"].identifier != inputs["Relief Thickness"].identifier - actual_inputs = [item.name for item in obj.modifiers[0].node_group.interface.items_tree - if item.item_type == "SOCKET" and item.in_out == "INPUT"] - assert actual_inputs == ["Geometry", "Mode", "Subdivide", "Thickness", "Thickness", - "Depth Scale", "Reference Depth", "Normal Smooth", "Uniform Scale", "Depth Image"] - assert inputs["Mode"].default_value == "Relief" + assert inputs["Thickness"].default_value == 0 assert inputs["Subdivide"].default_value == 6 assert inputs["Depth Scale"].default_value == 1 assert all(item.structure_type == "SINGLE" for item in inputs.values()) assert inputs["Thickness"].subtype == inputs["Reference Depth"].subtype == "DISTANCE" - validate_node_layout(obj.modifiers[0].node_group) + assert inputs["Uniform Scale"].hide_in_modifier and inputs["Depth Image"].hide_in_modifier + validate_node_structure(obj.modifiers[0].node_group) + + +def test_relief_interface_and_node_structure(relief): + obj, _, _, inputs, _ = relief + assert list(inputs) == ["Geometry", "Subdivide", "Thickness", "Depth Scale", + "Reference Depth", "Uniform Scale", "Depth Image"] + assert inputs["Thickness"].default_value == 0 + assert inputs["Subdivide"].default_value == 6 + assert inputs["Depth Scale"].default_value == 1 + assert all(item.structure_type == "SINGLE" for item in inputs.values()) + assert inputs["Thickness"].subtype == inputs["Reference Depth"].subtype == "DISTANCE" + assert inputs["Uniform Scale"].hide_in_modifier and inputs["Depth Image"].hide_in_modifier + validate_node_structure(obj.modifiers[0].node_group) def test_conversion_preserves_offset_world_placement_and_initializes_inputs(surface, monkeypatch): @@ -293,7 +286,7 @@ def test_conversion_preserves_offset_world_placement_and_initializes_inputs(surf metadata = {"image_size": (16, 8), "reference_depth": 2.0, "intrinsics": ((4, 0, 5), (0, 4, 3), (0, 0, 1))} result = conversion.create_depth_plane_object( - bpy.context, source, 2, material, image, metadata) + bpy.context, source, 2, material, image, metadata, "DEPTH") assert result.name == "Offset Image" assert result["anyimage_mesh_shape"] == "DEPTH_PLANE" assert bpy.context.view_layer.objects.active == result @@ -305,8 +298,8 @@ def test_conversion_preserves_offset_world_placement_and_initializes_inputs(surf np.testing.assert_allclose(result.matrix_world.translation, center, atol=1e-6) modifier = result.modifiers[0] assert modifier[inputs["Thickness"].identifier] == 0 - assert modifier[inputs["Relief Thickness"].identifier] == 0 assert modifier[inputs["Depth Image"].identifier] == image + assert modifier[inputs["Valid Only"].identifier] is True assert modifier[inputs["Reference Depth"].identifier] == pytest.approx( 2 * modifier[inputs["Uniform Scale"].identifier]) assert result.data.materials[0] == material @@ -331,7 +324,109 @@ def test_failed_modifier_initialization_removes_partial_object(surface, monkeypa metadata = {"image_size": (16, 8), "reference_depth": 2, "intrinsics": ((4, 0, 5), (0, 4, 3), (0, 0, 1))} with pytest.raises(RuntimeError, match="input failed"): - conversion.create_depth_plane_object(bpy.context, source, 2, material, image, metadata) + conversion.create_depth_plane_object(bpy.context, source, 2, material, image, metadata, "DEPTH") assert set(bpy.data.objects) == before_objects assert set(bpy.data.meshes) == before_meshes assert material.users == 0 + + +@pytest.mark.parametrize("threshold", [0.0, 0.4, 0.9, 1.0]) +@pytest.mark.parametrize("pattern", ["valid", "invalid", "partial", "half", "stripe"]) +def test_valid_only_recomputes_per_point_and_preserves_boundary_faces(surface, pattern, threshold): + obj, set_value, _, inputs, _ = surface + set_value("Validity Threshold", threshold) + depth_image = obj.modifiers[0][inputs["Depth Image"].identifier] + pixels = np.ones((8, 16, 4), dtype=np.float32) + alpha = np.ones(16, dtype=np.float32) + if pattern == "invalid": + alpha[:] = 0 + elif pattern == "partial": + alpha[:] = 0.5 + elif pattern == "half": + alpha[:8] = 0 + elif pattern == "stripe": + alpha[:] = 0 + alpha[8] = 1 + pixels[..., 3] = alpha + depth_image.pixels.foreach_set(pixels.ravel()) + depth_image.update() + for subdivisions in (0, 2, 5): + set_value("Subdivide", subdivisions) + set_value("Valid Only", False) + base = evaluated(obj) + u = (base[0][:, 0] + 2) / 4 + sampled = np.interp(u * 16 - 0.5, np.arange(16), alpha) + expected = [face for face in base[1] if any(sampled[index] >= threshold for index in face)] + set_value("Valid Only", True) + culled = evaluated(obj) + assert len(culled[1]) == len(expected) + expected_points = {tuple(base[0][index]) for face in expected for index in face} + assert {tuple(point) for point in culled[0]} == expected_points + assert np.isfinite(culled[2]).all() + if pattern == "half" and subdivisions >= 2 and threshold == 1.0: + assert 0 <= min(point[0] for point in culled[0]) < 0.125 + set_value("Valid Only", False) + np.testing.assert_allclose(evaluated(obj)[0], base[0]) + + +def test_valid_only_precedes_closed_shell_generation(surface): + obj, set_value, _, inputs, _ = surface + depth_image = obj.modifiers[0][inputs["Depth Image"].identifier] + pixels = np.asarray(depth_image.pixels[:], dtype=np.float32).reshape(8, 16, 4) + pixels[:, :8, 3] = 0 + depth_image.pixels.foreach_set(pixels.ravel()) + depth_image.update() + set_value("Depth Scale", 1.0) + front = evaluated(obj)[0] + set_value("Thickness", 0.2) + points, faces, uv, *_ = evaluated(obj) + assert len(points) == 2 * len(front) + edges = Counter(tuple(sorted((a, b))) for face in faces for a, b in zip(face, (*face[1:], face[0]))) + assert set(edges.values()) == {2} + assert np.isfinite(points).all() and np.isfinite(uv).all() + + +def test_validity_graph_matches_reference_domains_and_connections(surface): + obj, _, _, _, _ = surface + group = obj.modifiers[0].node_group + delete = next(node for node in group.nodes if node.bl_idname == "GeometryNodeDeleteGeometry") + assert (delete.domain, delete.mode) == ("FACE", "ALL") + points = origin_socket(delete.inputs["Selection"].links[0].from_socket).node + assert (points.bl_idname, points.data_type, points.domain) == ("GeometryNodeFieldOnDomain", "BOOLEAN", "POINT") + compare = origin_socket(points.inputs["Value"].links[0].from_socket).node + assert (compare.bl_idname, compare.data_type, compare.operation) == ("FunctionNodeCompare", "FLOAT", "LESS_THAN") + assert origin_socket(compare.inputs["B"].links[0].from_socket).name == "Validity Threshold" + alpha = origin_socket(compare.inputs["A"].links[0].from_socket) + assert alpha.name == "Alpha" and alpha.node.bl_idname == "FunctionNodeSeparateColor" + color = origin_socket(alpha.node.inputs["Color"].links[0].from_socket).node + assert origin_socket(color.inputs["Image"].links[0].from_socket).name == "Depth Image" + assert len([node for node in color.id_data.nodes if node.bl_idname == "GeometryNodeImageTexture"]) == 1 + consumers = [link for link in group.links if link.to_node.bl_idname != "NodeReroute" + and origin_socket(link.from_socket) == color.outputs["Color"]] + assert len(consumers) == 2 + assert origin_socket(delete.inputs["Geometry"].links[0].from_socket).node.node_tree.name == "O Image Plane" + + +@pytest.mark.parametrize("thickness", [0.0, 0.2]) +def test_depth_smooth_changes_positions_and_preserves_topology(surface, thickness): + obj, set_value, _, _, depth_image = surface + pixels = np.asarray(depth_image.pixels[:], dtype=np.float32).reshape(8, 16, 4) + pixels[..., 2] += (np.arange(16) % 2) * 0.3 + pixels[:, :4, 3] = 0 + depth_image.pixels.foreach_set(pixels.ravel()) + depth_image.update() + set_value("Depth Scale", 1.0) + set_value("Thickness", thickness) + base = evaluated(obj) + set_value("Smooth", 3) + smoothed = evaluated(obj) + assert smoothed[1] == base[1] + np.testing.assert_allclose(smoothed[2], base[2]) + assert smoothed[3:5] == base[3:5] + assert np.isfinite(smoothed[0]).all() + assert not np.allclose(smoothed[0], base[0]) + set_value("Smooth Weight", 0.0) + np.testing.assert_allclose(evaluated(obj)[0], base[0], atol=1e-6) + set_value("Smooth Weight", 1.0) + set_value("Smooth", 0) + np.testing.assert_allclose(evaluated(obj)[0], base[0], atol=1e-6) diff --git a/tests/test_depth_surface_split.py b/tests/test_depth_surface_split.py index 22051c5..9f968dd 100644 --- a/tests/test_depth_surface_split.py +++ b/tests/test_depth_surface_split.py @@ -12,9 +12,8 @@ ROOT = Path(__file__).parents[1] sys.path.insert(0, str(ROOT / "src")) -sys.path.insert(0, str(ROOT / "tools" / "node_assets")) -from cutout import build_cutout_group +from node_asset_test_support import CUTOUT_BUILDERS from depth_surface_test_support import assert_closed, component_count, evaluated, surface @@ -114,7 +113,7 @@ def test_split_topology_uses_depth_before_display_scaling(depth_scale): @pytest.mark.parametrize("shape", ["SOLID", "DEPTH_SOLID", "DEPTH_BALLOON"]) def test_each_asset_cleans_the_original_geometry_before_shaping(shape): bpy.ops.wm.read_factory_settings(use_empty=True) - group = build_cutout_group(shape) + group = CUTOUT_BUILDERS[shape]() links = [link for link in group.links if link.from_node.bl_idname == "NodeGroupInput" and link.from_socket.name == "Geometry"] assert len(links) == 1 assert links[0].to_node.bl_idname == "GeometryNodeDeleteGeometry" diff --git a/tests/test_edge_depth.py b/tests/test_edge_depth.py index 8cef2b9..eec6f29 100644 --- a/tests/test_edge_depth.py +++ b/tests/test_edge_depth.py @@ -120,13 +120,38 @@ def test_diagonal_pixels_share_a_component(): assert result.depth[5, 5] == 2 -def test_invalid_donors_are_excluded(): +@pytest.mark.parametrize("validity", [0.0, 0.2, 0.5]) +def test_invalid_donors_are_excluded(validity): frame, alpha = silhouette() - frame.depth[10, 12] = 100 - frame.validity[10, 12] = 0 + frame.depth[10, 6] = 100 + frame.validity[10, 6] = validity result = extend_edge_depth(frame, alpha) - assert result.depth[10, 12] == 2 - assert result.validity[10, 12] == 0 + assert result.depth[10, 4] == 2 + assert result.depth[10, 6] == 100 + assert result.validity[10, 6] == np.float32(validity) + + +def test_internal_mask_hole_does_not_expand_alpha_repair(): + frame, alpha = silhouette() + frame.depth[9:14, 10:15] = 100 + frame.validity[9:14, 10:15] = 0 + original = frame.depth.copy() + result = extend_edge_depth(frame, alpha) + np.testing.assert_array_equal(result.depth[6:19, 6:20], original[6:19, 6:20]) + assert result.depth[12, 24] == 2 + + +def test_opaque_image_keeps_low_mask_depth(): + frame = make_frame(np.ones((10, 10))) + frame.depth[5, 5] = 100 + frame.validity[5, 5] = 0 + assert extend_edge_depth(frame, np.ones((10, 10))) is frame + + +def test_component_without_valid_donors_keeps_its_depth(): + frame, alpha = silhouette() + frame.validity[alpha > 0] = 0 + assert extend_edge_depth(frame, alpha) is frame def test_local_smoothing_reduces_donor_step_and_preserves_core(): @@ -159,16 +184,16 @@ def test_alpha_resize_retains_threshold_values(tmp_path): @pytest.mark.parametrize( - "generate_depth,normal_mode,correct", + "generate_depth,normal_mode", [ - (True, "OBJECT", True), - (True, "TANGENT", True), - (True, "TANGENT", False), - (False, "OBJECT", True), + (True, "OBJECT"), + (True, "TANGENT"), + (True, "NONE"), + (False, "OBJECT"), ], ) def test_artifacts_use_corrected_depth_and_original_normals( - tmp_path, generate_depth, normal_mode, correct + tmp_path, generate_depth, normal_mode ): frame, alpha = silhouette() rgba = np.full((*alpha.shape, 4), 255, np.uint8) @@ -197,17 +222,18 @@ def test_artifacts_use_corrected_depth_and_original_normals( ): artifacts.generate_moge2_artifacts( context, {}, path, generate_depth=generate_depth, normal_mode=normal_mode, - correct_edge_depth=correct, ) - assert normal.call_args.args[0] is frame - assert repair.call_count == int(correct and generate_depth) + if normal_mode != "NONE": + assert normal.call_args.args[0] is frame + else: + normal.assert_not_called() + assert repair.call_count == int(generate_depth) if generate_depth: written_frame = depth.call_args.args[0] + np.testing.assert_array_equal(depth.call_args.kwargs["alpha"], alpha) assert metadata.call_args.args[0] is written_frame - if correct: - assert written_frame.depth[12, 24] == 2 - else: - assert written_frame is frame + assert written_frame.depth[12, 24] == 2 + assert written_frame.validity is frame.validity else: metadata.assert_not_called() depth.assert_not_called() diff --git a/tests/test_geometry_artifacts.py b/tests/test_geometry_artifacts.py index 1878616..4b60a90 100644 --- a/tests/test_geometry_artifacts.py +++ b/tests/test_geometry_artifacts.py @@ -6,6 +6,30 @@ from unittest.mock import patch from server_runtime_test_support import ServerTestCase class GeometryArtifactsTest(ServerTestCase): + def test_depth_alpha_multiplies_coverage_without_changing_geometry(self): + import numpy as np + + depth = importlib.import_module("server.geometry.depth_texture") + GeometryFrame = importlib.import_module("server.models.geometry").GeometryFrame + validity = np.asarray([[0.75, 0, 1, 1]], dtype=np.float32) + alpha = np.asarray([[0.8, 1, 0, 1]], dtype=np.float32) + points = np.arange(12, dtype=np.float32).reshape(1, 4, 3) + frame = GeometryFrame( + depth=points[..., 2], points=points, + validity=validity.copy(), intrinsics=np.eye(3, dtype=np.float32), + ) + output = self.storage_root / "depth.exr" + with patch.object(depth, "_write_float_exr") as write: + depth.write_depth_texture(frame, output, alpha=alpha) + packed = write.call_args.args[0] + np.testing.assert_allclose(packed[..., 3], [[0.6, 0, 0, 1]]) + np.testing.assert_array_equal(packed[..., :3], points) + np.testing.assert_array_equal(frame.validity, validity) + self.assertEqual(packed.dtype, np.float32) + with self.assertRaisesRegex(ValueError, "Alpha.*dimensions"): + depth.write_depth_texture(frame, output, alpha=np.ones((4, 1))) + self.assertEqual(write.call_count, 1) + def test_depth_artifacts_store_texture_and_compact_metadata(self): import numpy as np @@ -30,7 +54,7 @@ class GeometryArtifactsTest(ServerTestCase): intrinsics=np.eye(3, dtype=np.float32), ) - depth.write_depth_texture(frame, texture_path) + depth.write_depth_texture(frame, texture_path, alpha=np.ones_like(validity)) depth.write_depth_metadata(frame, calibration, metadata_path) self.assertTrue(texture_path.is_file()) @@ -93,7 +117,7 @@ class GeometryArtifactsTest(ServerTestCase): ) - def test_moge_model_input_composites_rgba_over_black(self): + def test_moge_model_input_preserves_rgb_independently_of_alpha(self): import numpy as np from PIL import Image @@ -105,30 +129,24 @@ class GeometryArtifactsTest(ServerTestCase): dtype=np.uint8, ) second = first.copy() - second[0, 0, :3] = (1, 2, 3) + second[..., 3] = 255 Image.fromarray(first, mode="RGBA").save(first_path) Image.fromarray(second, mode="RGBA").save(second_path) - first_output = artifacts._limited_model_input( - first_path, - self.storage_root / "first-model.png", - 2048, - ) - second_output = artifacts._limited_model_input( - second_path, - self.storage_root / "second-model.png", - 2048, - ) - - with Image.open(first_output) as opened: - first_rgb = np.asarray(opened, dtype=np.uint8) - with Image.open(second_output) as opened: - second_rgb = np.asarray(opened, dtype=np.uint8) - np.testing.assert_array_equal(first_rgb, second_rgb) - np.testing.assert_array_equal( - first_rgb, - np.asarray([[(0, 0, 0), (100, 50, 25), (9, 8, 7)]], dtype=np.uint8), - ) + for max_size in (2048, 2): + with self.subTest(max_size=max_size): + outputs = [] + for source in (first_path, second_path): + output = artifacts._limited_model_input( + source, source.with_name(f"model-{source.name}"), max_size, + ) + with Image.open(output) as opened: + self.assertEqual(opened.mode, "RGB") + outputs.append(np.asarray(opened)) + np.testing.assert_array_equal(*outputs) + self.assertEqual(outputs[0].shape[1], min(3, max_size)) + if max_size == 2048: + np.testing.assert_array_equal(outputs[0], first[..., :3]) def test_geometry_writers_reject_missing_or_non_finite_fields(self): @@ -148,6 +166,7 @@ class GeometryArtifactsTest(ServerTestCase): depth.write_depth_texture( GeometryFrame(**base), self.storage_root / "missing-points.exr", + alpha=base["validity"], ) point_frame = GeometryFrame( **base, @@ -157,6 +176,7 @@ class GeometryArtifactsTest(ServerTestCase): depth.write_depth_texture( point_frame, self.storage_root / "nan-points.exr", + alpha=base["validity"], ) depth_frame = GeometryFrame( **base, points=np.asarray([[(0, 0, 1), (0, 0, np.inf)]], dtype=np.float32), @@ -165,6 +185,7 @@ class GeometryArtifactsTest(ServerTestCase): depth.write_depth_texture( depth_frame, self.storage_root / "inf-depth.exr", + alpha=base["validity"], ) with self.assertRaisesRegex(ValueError, "normal field"): normal.write_camera_normal_texture( @@ -204,7 +225,9 @@ class GeometryArtifactsTest(ServerTestCase): "server.models.geometry" ).GeometryFrame input_path = self.storage_root / "selection.png" - Image.new("RGBA", (2, 2), (255, 128, 64, 255)).save(input_path) + source = Image.new("RGBA", (4, 4), (255, 128, 64, 255)) + source.paste((255, 128, 64, 0), (2, 0, 4, 2)) + source.save(input_path) output_directory = self.storage_root / "job" output_directory.mkdir() context = SimpleNamespace( @@ -221,7 +244,11 @@ class GeometryArtifactsTest(ServerTestCase): points=np.ones((2, 2, 3), dtype=np.float32), ) - with patch.object(artifacts.moge2, "infer", return_value=[frame]) as infer: + texture = importlib.import_module("server.geometry.depth_texture") + with ( + patch.object(artifacts.moge2, "infer", return_value=[frame]) as infer, + patch.object(texture, "_write_float_exr", wraps=texture._write_float_exr) as write, + ): result = artifacts.generate_moge2_artifacts( context, {"max_input_size": 2048}, @@ -230,6 +257,9 @@ class GeometryArtifactsTest(ServerTestCase): normal_mode="OBJECT", ) + np.testing.assert_array_equal(write.call_args.args[0][..., 3], [[1, 0], [1, 1]]) + np.testing.assert_array_equal(frame.validity, np.ones((2, 2))) + self.assertEqual(json.loads(result["depth_metadata"].read_text())["reference_depth"], 1) infer.assert_called_once() self.assertEqual( set(result), @@ -288,6 +318,5 @@ class GeometryArtifactsTest(ServerTestCase): input_path, generate_depth=True, normal_mode="OBJECT", - correct_edge_depth=True, alpha_threshold=0.9, ) diff --git a/tests/test_image_layer_alpha.py b/tests/test_image_layer_alpha.py new file mode 100644 index 0000000..6be6064 --- /dev/null +++ b/tests/test_image_layer_alpha.py @@ -0,0 +1,139 @@ +import sys +from pathlib import Path +from types import SimpleNamespace + +import bpy +import numpy as np +import pytest + +sys.path.insert(0, str(Path(__file__).parents[1] / "src")) + +from anyimage.common import material +from tools.nodes.groups.image_layer import build_image_layer_group +from tools.nodes.groups.image_depth_layer import build_image_depth_layer_group + + +@pytest.fixture +def image_layer(): + bpy.ops.wm.read_factory_settings(use_empty=True) + group = build_image_layer_group() + yield group + bpy.ops.wm.read_factory_settings(use_empty=True) + + +def test_alpha_curve_preserves_endpoints_and_suppresses_soft_edges(image_layer): + inputs = { + item.name: item for item in image_layer.interface.items_tree + if item.item_type == "SOCKET" and item.in_out == "INPUT" + } + assert list(inputs) == [ + "Color", "Alpha", "Normal", "Bump Scale", "Alpha Fix", + "Normal Scale", "Object Space", + ] + panels = [item for item in image_layer.interface.items_tree if item.item_type == "PANEL"] + assert len(panels) == 1 + assert panels[0].name == "Options" and panels[0].default_closed + assert [item.name for item in panels[0].interface_items] == ["Alpha Fix", "Normal Scale", "Object Space"] + strength = inputs["Alpha Fix"] + assert (strength.default_value, strength.min_value, strength.max_value) == (0, 0, 1) + assert strength.subtype == "FACTOR" + output = next(node for node in image_layer.nodes if node.type == "GROUP_OUTPUT") + curve = output.inputs["Alpha"].links[0].from_node + assert curve.type == "CURVE_FLOAT" + assert curve.inputs["Value"].links[0].from_socket.name == "Alpha" + assert curve.inputs["Factor"].links[0].from_socket.name == "Alpha Fix" + mapping = curve.mapping + np.testing.assert_allclose( + [point.location[:] for point in mapping.curves[0].points], + [(0, 0), (0.8, 0), (0.925, 0.925), (1, 1)], + atol=1e-6, + ) + samples = np.array([ + mapping.evaluate(mapping.curves[0], float(alpha)) + for alpha in np.linspace(0, 1, 1001) + ]) + np.testing.assert_allclose(samples[:801], 0, atol=3e-4) + assert samples[-1] == pytest.approx(1) + assert np.all(np.diff(samples) >= -1e-6) + assert np.all((samples >= 0) & (samples <= 1)) + assert mapping.evaluate(mapping.curves[0], 0.85) == pytest.approx(0.1593, abs=1e-3) + assert mapping.evaluate(mapping.curves[0], 0.925) == pytest.approx(0.925, abs=5e-4) + assert all(socket.is_linked or socket.hide for node in image_layer.nodes for socket in node.outputs) + + +@pytest.mark.parametrize("enabled", [False, True]) +@pytest.mark.parametrize("shadeless", [False, True]) +@pytest.mark.parametrize("view", ["AgX", "Unknown"]) +def test_material_alpha_strength_follows_preference(image_layer, monkeypatch, enabled, shadeless, view): + monkeypatch.setattr(material, "material_node_group", lambda depth_plane=False: image_layer) + monkeypatch.setattr(material, "configured_material_view_adaptation", lambda: enabled) + color = bpy.data.images.new("Alpha edge", width=2, height=2, alpha=True) + scene = SimpleNamespace(view_settings=SimpleNamespace(view_transform=view)) + result = material.create_image_material(color, color, shadeless=shadeless, scene=scene) + layer = next(node for node in result.node_tree.nodes if node.type == "GROUP" and node.node_tree == image_layer) + assert layer.inputs["Alpha Fix"].default_value == float(enabled) + alpha_link = layer.outputs["Alpha"].links[0] + assert alpha_link.to_node.type == ("MIX_SHADER" if shadeless else "BSDF_PRINCIPLED") + assert alpha_link.to_socket.name == ("Fac" if shadeless else "Alpha") + layer.inputs["Alpha Fix"].default_value = 0.5 + monkeypatch.setattr(material, "configured_material_view_adaptation", lambda: not enabled) + second = material.create_image_material(color, color, shadeless=shadeless, scene=scene) + second_layer = next(node for node in second.node_tree.nodes if node.type == "GROUP" and node.node_tree == image_layer) + assert second_layer.inputs["Alpha Fix"].default_value == float(not enabled) + assert layer.inputs["Alpha Fix"].default_value == 0.5 + + +def test_depth_layer_keeps_foreground_alpha_threshold(image_layer): + depth = build_image_depth_layer_group() + assert "Alpha Fix" not in { + item.name for item in depth.interface.items_tree if item.item_type == "SOCKET" + } + output = next(node for node in depth.nodes if node.type == "GROUP_OUTPUT") + threshold = output.inputs["Alpha"].links[0].from_node + assert threshold.operation == "GREATER_THAN" + assert threshold.inputs[1].default_value == 0.5 + assert threshold.inputs[0].links[0].from_socket.name == "Alpha" + + +def test_alpha_adjustment_shader_blends_curve_strength(image_layer, tmp_path): + scene = bpy.context.scene + scene.render.engine = "CYCLES" + scene.cycles.device = "CPU" + scene.cycles.samples = 1 + scene.cycles.use_denoising = False + scene.render.resolution_x = 96 + scene.render.resolution_y = 32 + scene.render.resolution_percentage = 100 + scene.render.image_settings.file_format = "OPEN_EXR" + scene.render.image_settings.color_depth = "32" + scene.render.filepath = str(tmp_path / "alpha-strength.exr") + bpy.ops.object.camera_add(location=(0, 0, 5)) + camera = bpy.context.object + camera.data.type = "ORTHO" + camera.data.ortho_scale = 3 + scene.camera = camera + for index, factor in enumerate((0, 0.5, 1)): + bpy.ops.mesh.primitive_plane_add(size=1, location=(index - 1, 0, 0)) + result = bpy.data.materials.new(f"Alpha {factor}") + result.use_nodes = True + nodes = result.node_tree.nodes + nodes.clear() + layer = nodes.new("ShaderNodeGroup") + layer.node_tree = image_layer + layer.inputs["Alpha"].default_value = 0.9 + layer.inputs["Alpha Fix"].default_value = factor + emission = nodes.new("ShaderNodeEmission") + output = nodes.new("ShaderNodeOutputMaterial") + result.node_tree.links.new(layer.outputs["Alpha"], emission.inputs["Color"]) + result.node_tree.links.new(emission.outputs[0], output.inputs["Surface"]) + bpy.context.object.data.materials.append(result) + bpy.ops.render.render(write_still=True) + rendered = bpy.data.images.load(scene.render.filepath, check_existing=False) + try: + pixels = np.array(rendered.pixels[:]).reshape(32, 96, 4) + values = pixels[16, [16, 48, 80], 0] + assert values[0] == pytest.approx(0.9, abs=1e-4) + assert values[2] == pytest.approx(0.77396, abs=1e-3) + assert values[1] == pytest.approx((values[0] + values[2]) / 2, abs=1e-4) + finally: + bpy.data.images.remove(rendered) diff --git a/tests/test_image_materials.py b/tests/test_image_materials.py index d78f48c..01b762e 100644 --- a/tests/test_image_materials.py +++ b/tests/test_image_materials.py @@ -68,7 +68,7 @@ def test_image_layer_switches_between_tangent_and_object_space_normal(): color = bpy.data.images.new("Color", width=2, height=2) normal_image = bpy.data.images.new("Normal", width=2, height=2) object_material = material_data.create_image_material( - SimpleNamespace(data=SimpleNamespace(name="Object Normal")), + SimpleNamespace(name="Object Normal"), color, 1.0, normal_image=normal_image, @@ -78,7 +78,7 @@ def test_image_layer_switches_between_tangent_and_object_space_normal(): assert layer.inputs["Object Space"].default_value is True tangent_material = material_data.create_image_material( - SimpleNamespace(data=SimpleNamespace(name="Tangent Normal")), + SimpleNamespace(name="Tangent Normal"), color, 1.0, normal_image=normal_image, @@ -127,13 +127,15 @@ def test_plane_material_color_drives_ior_level_but_shadeless_does_not(): image = bpy.data.images.new("Plane", width=2, height=2, alpha=True) source = SimpleNamespace(data=SimpleNamespace(name="Plane")) - material = material_data.create_image_material(source, image, 0.0) + material = material_data.create_image_material(source.data, image) shader = next( node for node in material.node_tree.nodes if node.bl_idname == "ShaderNodeBsdfPrincipled" ) + assert np.isclose(shader.inputs["IOR"].default_value, 1.2) image_layer = _group_node(material_data.material_node_group(), material.node_tree) + assert image_layer.inputs["Bump Scale"].default_value == 0.0 assert any( link.from_node == image_layer and link.from_socket == image_layer.outputs["Color"] @@ -143,7 +145,7 @@ def test_plane_material_color_drives_ior_level_but_shadeless_does_not(): ) shadeless = material_data.create_image_material( - source, + source.data, image, 0.0, shadeless=True, @@ -163,7 +165,7 @@ def test_cutout_material_uses_flat_tangent_normal(): normal = bpy.data.images.new("Normal", width=1, height=1) result = material_data.create_image_material( - source, + source.data, color, 0.0, normal_image=normal, diff --git a/tests/test_image_menu.py b/tests/test_image_menu.py index 548a5fd..b74cbcd 100644 --- a/tests/test_image_menu.py +++ b/tests/test_image_menu.py @@ -45,6 +45,7 @@ class ImageMenuTest(BlenderTestCase): [ ("anyimage.convert_to_plane", True), ("anyimage.convert_to_depth_plane", True), + ("anyimage.convert_to_relief_plane", True), ("separator", True), ("anyimage.remove_image_background", True), ("anyimage.upscale_image", True), @@ -101,6 +102,7 @@ class ImageMenuTest(BlenderTestCase): ) operators = ( (self.convert_to_plane, self.convert_to_plane.ConvertToDepthPlane), + (self.convert_to_plane, self.convert_to_plane.ConvertToReliefPlane), (self.remove_background, self.remove_background.RemoveImageBackground), ) diff --git a/tests/test_material_view_adaptation.py b/tests/test_material_view_adaptation.py index d2da49a..1491a58 100644 --- a/tests/test_material_view_adaptation.py +++ b/tests/test_material_view_adaptation.py @@ -145,7 +145,7 @@ def test_new_material_uses_current_preference_and_preserves_existing(color_data, } with patch.object(preferences, "addon_preferences", return_value=settings): if entry == "clipboard": - first = clipboard._create_image_material(source, shadeless, scene=scene) + first = clipboard.create_image_material(source, source, shadeless=shadeless, scene=scene) else: if entry == "plane": color = create_color_image(owner) @@ -153,16 +153,16 @@ def test_new_material_uses_current_preference_and_preserves_existing(color_data, color = create_cropped_color_image(owner, (0, 0, 2, 2), images.image_rgba(source)) else: color = source - first = material.create_image_material(owner, color, 0, shadeless=shadeless, scene=scene, **data_textures) + first = material.create_image_material(owner.data, color, 0, shadeless=shadeless, scene=scene, **data_textures) original_color = color_texture(first) assert original_color.colorspace_settings.name == "AgX Base sRGB" assert original_color.alpha_mode == "STRAIGHT" settings.adapt_material_to_view_transform = False assert original_color.alpha_mode == "STRAIGHT" if entry == "clipboard": - second = clipboard._create_image_material(original_color, shadeless, scene=scene) + second = clipboard.create_image_material(original_color, original_color, shadeless=shadeless, scene=scene) else: - second = material.create_image_material(owner, original_color, 0, shadeless=shadeless, scene=scene) + second = material.create_image_material(owner.data, original_color, 0, shadeless=shadeless, scene=scene) new_color = color_texture(second) assert new_color != original_color assert new_color.colorspace_settings.name == "sRGB" diff --git a/tests/test_model_geometry.py b/tests/test_model_geometry.py index 90d802d..6791cc8 100644 --- a/tests/test_model_geometry.py +++ b/tests/test_model_geometry.py @@ -31,7 +31,7 @@ def test_geometry_frame_requires_matching_float32_fields(): def test_moge_infer_returns_geometry_frames_with_model_validity(): - mask = np.asarray(((True, False), (True, True))) + mask = np.asarray(((1.0, 0.2), (0.7, 0.0)), dtype=np.float32) prediction = { "depth": np.ones((2, 2), dtype=np.float32), "normal": np.zeros((2, 2, 3), dtype=np.float32), diff --git a/tests/test_model_moge2_onnx.py b/tests/test_model_moge2_onnx.py index 2d9e675..9d70346 100644 --- a/tests/test_model_moge2_onnx.py +++ b/tests/test_model_moge2_onnx.py @@ -184,11 +184,26 @@ class Moge2OnnxTest(unittest.TestCase): "metric_scale": np.array((metric_scale,), dtype=np.float32), } + raw["mask"][0, 0, :4] = [0.0, 0.2, 0.7, 1.0] result = self.moge.postprocess(raw) np.testing.assert_allclose(result["depth"], depth * metric_scale, rtol=1e-3) np.testing.assert_allclose(result["points"][..., 2], result["depth"], rtol=1e-5) - self.assertTrue(result["mask"].all()) + np.testing.assert_array_equal(result["mask"], raw["mask"][0]) + + def test_postprocess_preserves_mask_for_nonpositive_depth(self): + mask = np.array([[[0.2, 0.7], [0.0, 1.0]]], dtype=np.float32) + raw = { + "points": np.zeros((1, 2, 2, 3), dtype=np.float32), + "normal": np.zeros((1, 2, 2, 3), dtype=np.float32), + "mask": mask, + "metric_scale": np.ones(1, dtype=np.float32), + } + with patch.object(self.moge, "recover_focal_shift", return_value=(1.0, -1.0)) as recover: + result = self.moge.postprocess(raw) + np.testing.assert_array_equal(recover.call_args.args[1], mask[0] > 0.5) + np.testing.assert_array_equal(result["mask"], mask[0]) + self.assertTrue((result["depth"] < 0).all()) if __name__ == "__main__": diff --git a/tests/test_node_arrangement.py b/tests/test_node_arrangement.py new file mode 100644 index 0000000..019232d --- /dev/null +++ b/tests/test_node_arrangement.py @@ -0,0 +1,683 @@ +"""Exercise automatic arrangement without changing geometry or publishing assets.""" + +import bpy +import numpy as np +import pytest + + +from tools.nodes.groups.image_plane import build_image_plane_group +from tools.nodes.groups.image_depth_plane import build_image_depth_plane_group +from tools.nodes.groups.image_relief_plane import build_image_relief_plane_group +from node_asset_test_support import CUTOUT_BUILDERS +from tools.nodes.arrangement.arrange import BRANCH_GAP, FIELD_HEIGHT, ROUTE_RUN, arrange_nodes +from tools.nodes.arrangement.flow import geometry_paths, origin_socket, peer_links +from tools.nodes.arrangement.dimensions import RULES, estimate_node_dimensions + + +@pytest.fixture(params=("plane", "depth_plane", "relief_plane", "SOLID", "DEPTH_SOLID", "DEPTH_BALLOON")) +def node_group(request): + bpy.ops.wm.read_factory_settings(use_empty=True) + if request.param == "plane": + return build_image_plane_group() + if request.param == "depth_plane": + return build_image_depth_plane_group(build_image_plane_group()) + if request.param == "relief_plane": + return build_image_relief_plane_group(build_image_plane_group()) + return CUTOUT_BUILDERS[request.param]() + + +def _source(socket): + while socket.node.bl_idname == "NodeReroute": + socket = socket.node.inputs[0].links[0].from_socket + node = socket.node + return ("INPUT" if node.bl_idname == "NodeGroupInput" else node.name, socket.identifier) + + +def _value(value): + if isinstance(value, (str, int, float, bool)) or value is None: + return value + if isinstance(value, bpy.types.ID): + return value.as_pointer() + return tuple(value) + + +def _program(node_group): + nodes = {} + for node in node_group.nodes: + if node.bl_idname in {"NodeReroute", "NodeGroupInput"}: + continue + settings = { + name: _value(getattr(node, name)) + for name in ("operation", "data_type", "input_type", "domain", "mode", "clamp", + "interpolation", "interpolation_type", "extension", "pattern_mode") + if hasattr(node, name) + } + inputs = [ + (socket.identifier, _value(socket.default_value) if hasattr(socket, "default_value") else None, + [_source(link.from_socket) for link in sorted( + socket.links, key=lambda link: link.multi_input_sort_id, reverse=True)]) + for socket in node.inputs + ] + paired = getattr(node, "paired_output", None) + nodes[node.name] = (node.bl_idname, settings, inputs, paired.name if paired else None) + interface = [ + (item.identifier, item.name, item.in_out, item.socket_type, + _value(item.default_value) if hasattr(item, "default_value") else None) + for item in node_group.interface.items_tree if item.item_type == "SOCKET" + ] + return nodes, interface + + +def _surface(node_group): + mesh = bpy.data.meshes.new("Layout Surface") + mesh.from_pydata( + [(x - 2, y - 1, 0) for y in range(3) for x in range(5)], [], + [(y * 5 + x, y * 5 + x + 1, (y + 1) * 5 + x + 1, (y + 1) * 5 + x) + for y in range(2) for x in range(4)], + ) + uv = mesh.uv_layers.new(name="UVMap") + for loop in mesh.loops: + x, y, _ = mesh.vertices[loop.vertex_index].co + uv.data[loop.index].uv = ((x + 2) / 4, (y + 1) / 2) + profile = mesh.attributes.new("o_balloon", "FLOAT", "POINT") + for vertex, value in zip(mesh.vertices, profile.data): + value.value = 0.25 * (1 - abs(vertex.co.x) / 2) * (1 - abs(vertex.co.y)) + mesh.materials.append(bpy.data.materials.new("Image")) + obj = bpy.data.objects.new("Layout Surface", mesh) + bpy.context.collection.objects.link(obj) + modifier = obj.modifiers.new("Surface", "NODES") + modifier.node_group = node_group + image = bpy.data.images.new("Layout Depth", width=16, height=8, float_buffer=True) + image.colorspace_settings.name = "Non-Color" + image.alpha_mode = "CHANNEL_PACKED" + yy, xx = np.mgrid[:8, :16] + pixels = np.ones((8, 16, 4), dtype=np.float32) + pixels[..., 0] = ((xx + 0.5) / 16 - 0.5) * 4 + pixels[..., 1] = -((yy + 0.5) / 8 - 0.5) * 2 + pixels[..., 2] = 2 + xx * 0.02 + image.pixels.foreach_set(pixels.ravel()) + values = {"Subdivide": 1, "Thickness": 0.2, "Depth Image": image, + "Uniform Scale": 1.0, "Reference Depth": 2.0, "Depth Scale": 0.8, + "Smooth": 1, "Normal Smooth": 2, "Split Threshold": 0.25} + mode = None + for item in node_group.interface.items_tree: + if item.item_type != "SOCKET" or item.in_out != "INPUT": + continue + if item.name in values: + modifier[item.identifier] = values[item.name] + elif item.name == "Mode": + mode = item.identifier + + def evaluate(): + results = [] + for choice in range(2 if mode else 1): + if mode: + modifier[mode] = choice + obj.update_tag(refresh={"DATA"}) + bpy.context.view_layer.update() + result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) + evaluated = result.to_mesh() + try: + assert len(evaluated.vertices) > 0 + results.append((np.array([vertex.co[:] for vertex in evaluated.vertices]), + [tuple(face.vertices) for face in evaluated.polygons])) + finally: + result.to_mesh_clear() + return results + + return evaluate + + +def test_arrangement_preserves_each_asset_program(node_group): + program = _program(node_group) + evaluate = _surface(node_group) + geometry = evaluate() + arrange_nodes(node_group) + assert _program(node_group) == program + + for node in node_group.nodes: + if node.bl_idname == "NodeGroupInput": + targets = {link.to_node for socket in node.outputs for link in socket.links} + assert len(targets) == 1 + assert all(socket.hide for socket in node.outputs if not socket.is_linked) + + for (before_points, before_faces), (after_points, after_faces) in zip(geometry, evaluate()): + np.testing.assert_allclose(after_points, before_points, atol=1e-6) + assert after_faces == before_faces + positions = { + node.name: tuple(node.location) for node in node_group.nodes + if node.bl_idname not in {"NodeReroute", "NodeGroupInput"} + } + arrange_nodes(node_group) + assert _program(node_group) == program + assert {name: tuple(node_group.nodes[name].location) for name in positions} == positions + for node in node_group.nodes: + sources = [link.from_socket for link in peer_links(node)] + if len(sources) < 2: + continue + levels = [socket.node.location.y - estimate_node_dimensions(socket.node).sockets[socket] for socket in sources] + size = estimate_node_dimensions(node) + output = next(socket for socket in node.outputs if socket in size.sockets) + assert node.location.y - size.sockets[output] == pytest.approx((min(levels) + max(levels)) / 2, abs=0.001), node.name + assert all(first >= second - 0.001 for first, second in zip(levels, levels[1:])), node.name + + +def test_dimensions_include_unlinked_vector_controls_and_collapsed_sockets(): + bpy.ops.wm.read_factory_settings(use_empty=True) + node_group = bpy.data.node_groups.new("Dimensions", "GeometryNodeTree") + source = node_group.nodes.new("GeometryNodeInputPosition") + vector = node_group.nodes.new("ShaderNodeVectorMath") + vector.operation = "ADD" + expanded = estimate_node_dimensions(vector) + node_group.links.new(source.outputs[0], vector.inputs[0]) + linked = estimate_node_dimensions(vector) + assert expanded.height - linked.height == 3 * RULES["row"] + vector.hide = True + collapsed = estimate_node_dimensions(vector) + assert collapsed.height < linked.height + assert all(0 < offset < collapsed.height for offset in collapsed.sockets.values()) + + +def test_local_inputs_preserve_identifiers_with_duplicate_display_names(): + bpy.ops.wm.read_factory_settings(use_empty=True) + node_group = bpy.data.node_groups.new("Controls", "GeometryNodeTree") + first = node_group.interface.new_socket(name="Thickness", in_out="INPUT", socket_type="NodeSocketFloat") + second = node_group.interface.new_socket(name="Thickness", in_out="INPUT", socket_type="NodeSocketFloat") + first.default_value, second.default_value = 0.25, 0.75 + node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") + controls = node_group.nodes.new("NodeGroupInput") + grid = node_group.nodes.new("GeometryNodeMeshGrid") + output = node_group.nodes.new("NodeGroupOutput") + node_group.links.new(controls.outputs[first.identifier], grid.inputs["Size X"]) + node_group.links.new(controls.outputs[second.identifier], grid.inputs["Size Y"]) + node_group.links.new(grid.outputs["Mesh"], output.inputs["Geometry"]) + program = _program(node_group) + arrange_nodes(node_group) + assert _program(node_group) == program + + +@pytest.mark.parametrize("branch_lengths", [(1, 1), (1, 3), (3, 1), (1, 2, 3)]) +def test_geometry_paths_order_join_branches_and_separate_their_shared_input(branch_lengths): + bpy.ops.wm.read_factory_settings(use_empty=True) + node_group = bpy.data.node_groups.new("Parallel Geometry", "GeometryNodeTree") + node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") + source = node_group.nodes.new("GeometryNodeMeshGrid") + join = node_group.nodes.new("GeometryNodeJoinGeometry") + output = node_group.nodes.new("NodeGroupOutput") + branches = [] + for length in branch_lengths: + geometry = source.outputs["Mesh"] + branch = [] + for _ in range(length): + position = node_group.nodes.new("GeometryNodeSetPosition") + node_group.links.new(geometry, position.inputs["Geometry"]) + geometry = position.outputs["Geometry"] + branch.append(position) + node_group.links.new(geometry, join.inputs["Geometry"]) + branches.append(branch) + node_group.links.new(source.outputs["Mesh"], join.inputs["Geometry"]) + node_group.links.new(join.outputs["Geometry"], output.inputs["Geometry"]) + + geometry_parents, paths = geometry_paths(node_group) + assert set(geometry_parents) == {join, output, *(node for branch in branches for node in branch)} + assert [join, output] in paths + assert all(branch in paths for branch in branches) + assert source not in geometry_parents + assert sum(len(path) for path in paths) == len(geometry_parents) + program = _program(node_group) + arrange_nodes(node_group) + assert _program(node_group) == program + levels = [link.from_node.location.y - estimate_node_dimensions(link.from_node).sockets[link.from_socket] + for link in peer_links(join)] + assert levels == sorted(levels, reverse=True) + + if branch_lengths == (1, 2, 3): + heights = {node: node.location.y for node in geometry_parents} + field = node_group.nodes.new("GeometryNodeInputPosition").outputs["Position"] + calculations = [field.node] + for _ in range(30): + operation = node_group.nodes.new("ShaderNodeVectorMath") + operation.operation = "ADD" + node_group.links.new(field, operation.inputs[0]) + field = operation.outputs["Vector"] + calculations.append(operation) + node_group.links.new(field, branches[0][0].inputs["Offset"]) + program = _program(node_group) + arrange_nodes(node_group) + assert _program(node_group) == program + assert {node: node.location.y for node in geometry_parents} == heights + assert max(node.location.y for node in calculations) - min( + node.location.y - estimate_node_dimensions(node).height for node in calculations + ) <= FIELD_HEIGHT + + +def test_geometry_used_for_field_sampling_stays_with_the_field_branch(): + from tools.nodes.preview.export import _node_group_data + bpy.ops.wm.read_factory_settings(use_empty=True) + node_group = bpy.data.node_groups.new("Sampled Geometry", "GeometryNodeTree") + node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") + source = node_group.nodes.new("GeometryNodeMeshGrid") + separate = node_group.nodes.new("GeometryNodeSeparateGeometry") + sample = node_group.nodes.new("GeometryNodeSampleIndex") + sample.data_type = "FLOAT_VECTOR" + position = node_group.nodes.new("GeometryNodeSetPosition") + output = node_group.nodes.new("NodeGroupOutput") + node_group.links.new(source.outputs["Mesh"], separate.inputs["Geometry"]) + node_group.links.new(separate.outputs["Selection"], sample.inputs["Geometry"]) + node_group.links.new(sample.outputs["Value"], position.inputs["Position"]) + node_group.links.new(source.outputs["Mesh"], position.inputs["Geometry"]) + node_group.links.new(position.outputs["Geometry"], output.inputs["Geometry"]) + + geometry_parents, paths = geometry_paths(node_group) + assert set(geometry_parents) == {source, position, output} + assert paths == [[source, position, output]] + program = _program(node_group) + arrange_nodes(node_group) + assert _program(node_group) == program + roles = {node["name"]: node["role"] for node in _node_group_data(node_group)["nodes"]} + assert roles[separate.name] == roles[sample.name] == "field" + assert roles[position.name] == "geometry" + node_group.links.new(separate.outputs["Selection"], output.inputs["Geometry"]) + roles = {node["name"]: node["role"] for node in _node_group_data(node_group)["nodes"]} + assert roles[separate.name] == "geometry" + + +def test_shared_field_references_branch_near_their_consumers(): + bpy.ops.wm.read_factory_settings(use_empty=True) + node_group = bpy.data.node_groups.new("Shared Field", "GeometryNodeTree") + node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") + source = node_group.nodes.new("GeometryNodeInputPosition") + geometry = node_group.nodes.new("GeometryNodeMeshGrid").outputs["Mesh"] + consumers = [] + for index in range(8): + node = node_group.nodes.new("GeometryNodeSetPosition") + node_group.links.new(geometry, node.inputs["Geometry"]) + if index in (0, 4, 7): + node_group.links.new(source.outputs["Position"], node.inputs["Offset"]) + consumers.append(node) + geometry = node.outputs["Geometry"] + output = node_group.nodes.new("NodeGroupOutput") + node_group.links.new(geometry, output.inputs["Geometry"]) + program = _program(node_group) + arrange_nodes(node_group) + assert _program(node_group) == program + for node in consumers[1:]: + branch = node.inputs["Offset"].links[0].from_node + assert branch.bl_idname == "NodeReroute" + assert node.location.x - branch.location.x >= ROUTE_RUN + assert any(node.bl_idname == "NodeReroute" and len(node.outputs[0].links) > 1 + for node in node_group.nodes) + assert all(link.to_node.location.x > link.from_node.location.x + for link in node_group.links + if link.from_node.bl_idname == link.to_node.bl_idname == "NodeReroute") + + +@pytest.mark.parametrize("side", ["upper", "lower"]) +def test_field_modules_reserve_lane_and_exit_clearance(side): + bpy.ops.wm.read_factory_settings(use_empty=True) + node_group = bpy.data.node_groups.new("Branch Rectangles", "GeometryNodeTree") + node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") + source = node_group.nodes.new("GeometryNodeMeshGrid") + join = node_group.nodes.new("GeometryNodeJoinGeometry") + output = node_group.nodes.new("NodeGroupOutput") + branches = [] + for _ in range(2): + geometry, branch = source.outputs["Mesh"], [] + for _ in range(5): + node = node_group.nodes.new("GeometryNodeSetPosition") + node_group.links.new(geometry, node.inputs["Geometry"]) + geometry = node.outputs["Geometry"] + branch.append(node) + branches.append(branch) + node_group.links.new(geometry, join.inputs["Geometry"]) + node_group.links.new(join.outputs["Geometry"], output.inputs["Geometry"]) + arrange_nodes(node_group) + branches.sort(key=lambda branch: branch[0].location.y, reverse=True) + lane = branches[0 if side == "upper" else -1] + heights = [node.location.y for branch in branches for node in branch] + fields = [] + for target in (lane[1], lane[3]): + value = node_group.nodes.new("GeometryNodeInputPosition").outputs[0] + branch = [value.node] + for _ in range(8): + node = node_group.nodes.new("ShaderNodeVectorMath") + node.operation = "ADD" + node_group.links.new(value, node.inputs[0]) + value = node.outputs["Vector"] + branch.append(node) + node_group.links.new(value, target.inputs["Offset"]) + fields.append(branch) + program = _program(node_group) + arrange_nodes(node_group) + assert _program(node_group) == program + assert [node.location.y for branch in branches for node in branch] == heights + boxes = [(min(node.location.x for node in branch), + max(node.location.x + estimate_node_dimensions(node).width for node in branch), + max(node.location.y for node in branch), + min(node.location.y - estimate_node_dimensions(node).height for node in branch)) for branch in fields] + core, _ = geometry_paths(node_group) + core_left = min(node.location.x for node in core) + for _, right, top, bottom in boxes: + if right + BRANCH_GAP <= core_left + 0.001: + continue + if side == "upper": + assert bottom >= lane[0].location.y + BRANCH_GAP - 0.001 + else: + assert top <= lane[0].location.y - estimate_node_dimensions(lane[0]).height - BRANCH_GAP + 0.001 + first, second = boxes + assert (first[1] + BRANCH_GAP <= second[0] + 0.001 or second[1] + BRANCH_GAP <= first[0] + 0.001 + or first[3] >= second[2] + BRANCH_GAP - 0.001 or second[3] >= first[2] + BRANCH_GAP - 0.001) + if side == "lower": + assert any(right + BRANCH_GAP <= core_left + 0.001 for _, right, _, _ in boxes) + for branch, target, other in zip(fields, (lane[1], lane[3]), reversed(boxes)): + terminal = branch[-1] + assert terminal.location.x + terminal.width < target.location.x + socket = terminal.outputs["Vector"] + x1 = terminal.location.x + terminal.width + y1 = terminal.location.y - estimate_node_dimensions(terminal).sockets[socket] + x2 = target.location.x + y2 = target.location.y - estimate_node_dimensions(target).sockets[target.inputs["Offset"]] + bend = max(40, (x2 - x1) * 0.42) + t = np.linspace(0, 1, 200) + x = (1-t)**3*x1 + 3*(1-t)**2*t*(x1+bend) + 3*(1-t)*t**2*(x2-bend) + t**3*x2 + y = (1-t)**3*y1 + 3*(1-t)**2*t*y1 + 3*(1-t)*t**2*y2 + t**3*y2 + left, right, top, bottom = other + assert not np.any((left < x) & (x < right) & (bottom < y) & (y < top)) + + +def test_boolean_operands_follow_socket_order_and_center_the_junction(): + bpy.ops.wm.read_factory_settings(use_empty=True) + node_group = bpy.data.node_groups.new("Boolean Operands", "GeometryNodeTree") + node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") + comparisons = [node_group.nodes.new("FunctionNodeCompare") for _ in range(2)] + operation = node_group.nodes.new("FunctionNodeBooleanMath") + operation.operation = "OR" + grid = node_group.nodes.new("GeometryNodeMeshGrid") + output = node_group.nodes.new("NodeGroupOutput") + for source, socket in zip(comparisons, operation.inputs): + node_group.links.new(source.outputs[0], socket) + node_group.links.new(operation.outputs[0], grid.inputs["Size X"]) + node_group.links.new(grid.outputs["Mesh"], output.inputs["Geometry"]) + program = _program(node_group) + arrange_nodes(node_group) + assert _program(node_group) == program + levels = [node.location.y - estimate_node_dimensions(node).sockets[node.outputs[0]] for node in comparisons] + assert levels[0] > levels[1] + assert comparisons[0].location.x + comparisons[0].width == comparisons[1].location.x + comparisons[1].width + assert operation.location.y - estimate_node_dimensions(operation).sockets[operation.outputs[0]] == pytest.approx(sum(levels) / 2) + + +def test_preview_is_read_only_and_escapes_node_names(tmp_path): + from tools.nodes.preview.export import write_node_preview + + bpy.ops.wm.read_factory_settings(use_empty=True) + node_group = build_image_plane_group() + arrange_nodes(node_group) + node_group.nodes[0].name = '' + program = _program(node_group) + positions = {node.name: tuple(node.location) for node in node_group.nodes} + destination = write_node_preview([node_group], tmp_path / "nodes.html") + markup = destination.read_text(encoding="utf-8") + assert markup.startswith("") + assert '__NODE_DATA__' not in markup + assert '' not in markup + assert '\\u003c/script>' in markup + assert _program(node_group) == program + assert {node.name: tuple(node.location) for node in node_group.nodes} == positions + + +def test_geometry_bypass_has_two_level_corners_next_to_processing(): + bpy.ops.wm.read_factory_settings(use_empty=True) + node_group = bpy.data.node_groups.new("Geometry Bypass", "GeometryNodeTree") + node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") + source = node_group.nodes.new("GeometryNodeMeshGrid") + transform = node_group.nodes.new("GeometryNodeTransform") + switch = node_group.nodes.new("GeometryNodeSwitch") + switch.input_type = "GEOMETRY" + output = node_group.nodes.new("NodeGroupOutput") + node_group.links.new(source.outputs["Mesh"], transform.inputs["Geometry"]) + node_group.links.new(source.outputs["Mesh"], switch.inputs["False"]) + node_group.links.new(transform.outputs["Geometry"], switch.inputs["True"]) + node_group.links.new(switch.outputs["Output"], output.inputs["Geometry"]) + program = _program(node_group) + arrange_nodes(node_group) + exit_port = switch.inputs["False"].links[0].from_node + entry_port = exit_port.inputs[0].links[0].from_node + assert entry_port.bl_idname == exit_port.bl_idname == "NodeReroute" + assert entry_port.location.y == exit_port.location.y + assert source.location.x + source.width < entry_port.location.x < exit_port.location.x < switch.location.x + assert exit_port.location.x + 16 == pytest.approx(transform.location.x + transform.width) + assert _program(node_group) == program + + +@pytest.mark.parametrize("zone_type", ["Repeat", "Simulation", "ForeachGeometryElement"]) +def test_preview_zone_contains_body_and_excludes_shared_external_sources(zone_type): + from tools.nodes.preview.export import _node_group_data + + bpy.ops.wm.read_factory_settings(use_empty=True) + node_group = bpy.data.node_groups.new("Zone Preview", "GeometryNodeTree") + source = node_group.nodes.new("GeometryNodeMeshGrid") + entry = node_group.nodes.new(f"GeometryNode{zone_type}Input") + end = node_group.nodes.new(f"GeometryNode{zone_type}Output") + entry.pair_with_output(end) + body = node_group.nodes.new("GeometryNodeSetPosition") + node_group.links.new(source.outputs["Mesh"], entry.inputs["Geometry"]) + geometry = next(socket for socket in entry.outputs if socket.type == "GEOMETRY") + node_group.links.new(geometry, body.inputs["Geometry"]) + geometry_input = next(socket for socket in end.inputs if socket.type == "GEOMETRY") + node_group.links.new(body.outputs["Geometry"], geometry_input) + entry.location, body.location, end.location = (0, 0), (300, 0), (700, 0) + data = _node_group_data(node_group) + zone = data["zones"][0] + names = {data["nodes"][index]["name"] for index in zone["nodes"]} + assert names == {entry.name, body.name, end.name} + for index in zone["nodes"]: + node = data["nodes"][index] + assert zone["x"] <= node["x"] and node["x"] + node["w"] <= zone["x"] + zone["w"] + assert zone["y"] <= node["y"] and node["y"] + node["h"] <= zone["y"] + zone["h"] + + +def test_repeat_geometry_collector_stays_in_the_local_body(): + from tools.nodes.arrangement.zones import node_zones + + bpy.ops.wm.read_factory_settings(use_empty=True) + node_group = CUTOUT_BUILDERS["SOLID"]() + arrange_nodes(node_group) + entry = node_group.nodes["Repeat Input"] + end = entry.paired_output + collector = node_group.nodes["Join Geometry.001"] + assert collector in next(members for start, _, members in node_zones(node_group) if start == entry) + assert entry.location.x < collector.location.x < end.location.x + assert abs(collector.location.y - entry.location.y) <= FIELD_HEIGHT + BRANCH_GAP + assert entry.location.y == end.location.y + + +def test_shared_geometry_uses_one_forward_trunk_with_late_branches(): + bpy.ops.wm.read_factory_settings(use_empty=True) + node_group = bpy.data.node_groups.new("Shared Geometry References", "GeometryNodeTree") + node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") + shared = node_group.nodes.new("GeometryNodeMeshGrid") + geometry = node_group.nodes.new("GeometryNodeMeshGrid").outputs["Mesh"] + samplers = [] + for index in range(10): + node = node_group.nodes.new("GeometryNodeSetPosition") + node_group.links.new(geometry, node.inputs["Geometry"]) + geometry = node.outputs["Geometry"] + if index in (0, 5, 9): + sample = node_group.nodes.new("GeometryNodeSampleIndex") + sample.data_type = "FLOAT_VECTOR" + node_group.links.new(shared.outputs["Mesh"], sample.inputs["Geometry"]) + node_group.links.new(sample.outputs["Value"], node.inputs["Offset"]) + samplers.append(sample) + output = node_group.nodes.new("NodeGroupOutput") + node_group.links.new(geometry, output.inputs["Geometry"]) + program = _program(node_group) + arrange_nodes(node_group) + for sample in samplers[1:]: + branch = sample.inputs["Geometry"].links[0].from_node + assert branch.bl_idname == "NodeReroute" + assert branch.location.x > shared.location.x + (sample.location.x - shared.location.x) / 2 + assert any(node.bl_idname == "NodeReroute" and len(node.outputs[0].links) > 1 + for node in node_group.nodes if node.inputs and node.inputs[0].is_linked + and origin_socket(node.inputs[0].links[0].from_socket).node == shared) + assert _program(node_group) == program + + +def test_reused_geometry_processing_keeps_its_upstream_result_path(): + from tools.nodes.arrangement.flow import node_roles + + bpy.ops.wm.read_factory_settings(use_empty=True) + node_group = bpy.data.node_groups.new("Reused Processing", "GeometryNodeTree") + node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") + grid = node_group.nodes.new("GeometryNodeMeshGrid") + transform = node_group.nodes.new("GeometryNodeTransform") + position = node_group.nodes.new("GeometryNodeSetPosition") + output = node_group.nodes.new("NodeGroupOutput") + node_group.links.new(grid.outputs["Mesh"], transform.inputs["Geometry"]) + node_group.links.new(transform.outputs["Geometry"], position.inputs["Geometry"]) + node_group.links.new(position.outputs["Geometry"], output.inputs["Geometry"]) + for name in ("Selection", "Offset"): + separate = node_group.nodes.new("GeometryNodeSeparateGeometry") + sample = node_group.nodes.new("GeometryNodeSampleIndex") + node_group.links.new(transform.outputs["Geometry"], separate.inputs["Geometry"]) + node_group.links.new(separate.outputs["Selection"], sample.inputs["Geometry"]) + node_group.links.new(sample.outputs["Value"], position.inputs[name]) + arrange_nodes(node_group) + roles = node_roles(node_group) + assert all(roles[node] == "geometry" for node in (grid, transform, position, output)) + assert grid.location.y == transform.location.y == position.location.y == output.location.y + assert all(roles[node] == "field" for node in node_group.nodes + if node.bl_idname in {"GeometryNodeSampleIndex", "GeometryNodeSeparateGeometry"}) + + +def test_geometry_preparation_shared_by_independent_paths_becomes_a_public_input(): + from tools.nodes.arrangement.flow import node_roles + + bpy.ops.wm.read_factory_settings(use_empty=True) + node_group = bpy.data.node_groups.new("Public Prepared Geometry", "GeometryNodeTree") + node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") + grid = node_group.nodes.new("GeometryNodeMeshGrid") + prepare = node_group.nodes.new("GeometryNodeTransform") + join = node_group.nodes.new("GeometryNodeJoinGeometry") + output = node_group.nodes.new("NodeGroupOutput") + node_group.links.new(grid.outputs["Mesh"], prepare.inputs["Geometry"]) + branches = [] + for _ in range(3): + node = node_group.nodes.new("GeometryNodeSetPosition") + node_group.links.new(prepare.outputs["Geometry"], node.inputs["Geometry"]) + node_group.links.new(node.outputs["Geometry"], join.inputs["Geometry"]) + branches.append(node) + node_group.links.new(join.outputs["Geometry"], output.inputs["Geometry"]) + program = _program(node_group) + arrange_nodes(node_group) + assert _program(node_group) == program + roles = node_roles(node_group) + assert roles[grid] == roles[prepare] == "field" + assert all(roles[node] == "geometry" for node in branches) + assert prepare.location.x + prepare.width < min(node.location.x for node in branches) + + +def test_repeat_field_connectors_reserve_horizontal_run_at_both_ends(): + from tools.nodes.arrangement.connections import link_endpoints + from tools.nodes.arrangement.zones import node_zones + + bpy.ops.wm.read_factory_settings(use_empty=True) + node_group = CUTOUT_BUILDERS["SOLID"]() + arrange_nodes(node_group) + core, _ = geometry_paths(node_group) + sizes = {node: estimate_node_dimensions(node) for node in node_group.nodes} + positions = {node: tuple(node.location) for node in node_group.nodes} + checked = 0 + for _, _, members in node_zones(node_group): + for link in node_group.links: + if link.from_node not in members or link.to_node not in members: + continue + if (link.from_node in core) == (link.to_node in core): + continue + start, end = link_endpoints(link, positions, sizes) + assert end[0] - start[0] >= abs(end[1] - start[1]) * 0.8 - 0.01, (link.from_node.name, link.to_node.name) + checked += 1 + assert checked > 0 + entry = node_group.nodes["Repeat Input.001"] + end = entry.paired_output + assert end.location.x - entry.location.x < 6500 + source = entry.outputs["Geometry"] + assert len(source.links) == 1 + branch = source.links[0].to_node + while branch.bl_idname == "NodeReroute" and len(branch.outputs[0].links) == 1: + branch = branch.outputs[0].links[0].to_node + assert branch.bl_idname == "NodeReroute" + assert len(branch.outputs[0].links) > 1 + deliveries = [link for link in branch.outputs[0].links + if link.to_node not in core and link.to_node.bl_idname != "NodeReroute"] + assert deliveries + for link in deliveries: + start, target = link_endpoints(link, positions, sizes) + assert target[0] - start[0] == pytest.approx(max(ROUTE_RUN, abs(target[1] - start[1]) * 0.8), abs=0.01) + for port in core: + if not port.get("arrangement_input") or port.outputs[0].type != "GEOMETRY": + continue + source = origin_socket(port.outputs[0]).node + if source not in core: + continue + left = source.location.x + sizes[source].width + right, level = port.location.x + 8, port.location.y - 8 + for node in node_group.nodes: + if node in core or node.bl_idname == "NodeReroute": + continue + if left < node.location.x + sizes[node].width and node.location.x < right: + assert not node.location.y - sizes[node].height < level < node.location.y, node.name + for link in node_group.links: + if link.from_socket.type != "GEOMETRY": + continue + start, target = link_endpoints(link, positions, sizes) + if abs(start[1] - target[1]) > 0.01: + continue + for node in node_group.nodes: + if node in core or node.bl_idname == "NodeReroute" or node in {link.from_node, link.to_node}: + continue + if start[0] < node.location.x + sizes[node].width and node.location.x < target[0]: + assert not node.location.y - sizes[node].height < start[1] < node.location.y, node.name + + +def test_expression_skip_links_have_passages_between_intermediate_nodes(): + from tools.nodes.arrangement.connections import link_endpoints + + bpy.ops.wm.read_factory_settings(use_empty=True) + node_group = bpy.data.node_groups.new("Expression Passages", "GeometryNodeTree") + node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") + grid = node_group.nodes.new("GeometryNodeMeshGrid") + position = node_group.nodes.new("GeometryNodeSetPosition") + output = node_group.nodes.new("NodeGroupOutput") + seed = node_group.nodes.new("GeometryNodeInputIndex") + previous = node_group.nodes.new("GeometryNodeInputMeshFaceArea").outputs[0] + fields = [seed, previous.node] + for _ in range(7): + operation = node_group.nodes.new("ShaderNodeMath") + operation.operation = "ADD" + node_group.links.new(previous, operation.inputs[0]) + node_group.links.new(seed.outputs[0], operation.inputs[1]) + previous = operation.outputs[0] + fields.append(operation) + node_group.links.new(grid.outputs["Mesh"], position.inputs["Geometry"]) + node_group.links.new(previous, position.inputs["Offset"]) + node_group.links.new(position.outputs["Geometry"], output.inputs["Geometry"]) + program = _program(node_group) + arrange_nodes(node_group) + assert _program(node_group) == program + sizes = {node: estimate_node_dimensions(node) for node in node_group.nodes} + positions = {node: tuple(node.location) for node in node_group.nodes} + assert any(node.get("arrangement_branch") for node in node_group.nodes) + for link in node_group.links: + if origin_socket(link.from_socket).node not in fields: + continue + (x1, y1), (x2, y2) = link_endpoints(link, positions, sizes) + bend = max(40, abs(x2 - x1) * 0.42) + for t in np.linspace(0, 1, 100): + x = (1-t)**3*x1 + 3*(1-t)**2*t*(x1+bend) + 3*(1-t)*t*t*(x2-bend) + t**3*x2 + y = (1-t)**3*y1 + 3*(1-t)**2*t*y1 + 3*(1-t)*t*t*y2 + t**3*y2 + for node in fields: + if node in {link.from_node, link.to_node}: + continue + left, top = positions[node] + assert not (left < x < left + sizes[node].width and top - sizes[node].height < y < top), node.name diff --git a/tools/node_assets/asset_validation.py b/tests/test_node_asset_contracts.py similarity index 51% rename from tools/node_assets/asset_validation.py rename to tests/test_node_asset_contracts.py index eaff6d1..23ab4a1 100644 --- a/tools/node_assets/asset_validation.py +++ b/tests/test_node_asset_contracts.py @@ -1,29 +1,22 @@ -import sys -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_asset_test_support import validate_node_structure -from node_layout import validate_node_layout from anyimage.common.material import ( DEPTH_MATERIAL_NODE_GROUP_NAME, IMAGE_MATERIAL_NODE_GROUP_NAME, ) + from anyimage.common.node import load_node_group, node_asset_path + from anyimage.operators.cutout_tool.shape import ( CUTOUT_NODE_GROUP_NAMES, ) - DEPTH_PLANE_NODE_GROUP_NAME = "O Image Depth Plane" +RELIEF_PLANE_NODE_GROUP_NAME = "O Image Relief Plane" + def input_names(group): return [ @@ -33,10 +26,11 @@ def input_names(group): ] -def main(): +def test_saved_asset_contracts(): bpy.ops.wm.read_factory_settings(use_empty=True) image_plane = load_node_group("O Image Plane") depth_plane = load_node_group(DEPTH_PLANE_NODE_GROUP_NAME) + relief_plane = load_node_group(RELIEF_PLANE_NODE_GROUP_NAME) image_layer = load_node_group(IMAGE_MATERIAL_NODE_GROUP_NAME) depth_layer = load_node_group(DEPTH_MATERIAL_NODE_GROUP_NAME) shadeless = load_node_group("O Shadeless") @@ -46,6 +40,7 @@ def main(): expected_names = { "O Image Plane", DEPTH_PLANE_NODE_GROUP_NAME, + RELIEF_PLANE_NODE_GROUP_NAME, IMAGE_MATERIAL_NODE_GROUP_NAME, DEPTH_MATERIAL_NODE_GROUP_NAME, "O Shadeless", @@ -62,55 +57,55 @@ def main(): "Subdivide", "Thickness", ] - assert depth_plane.bl_idname == "GeometryNodeTree" - assert depth_plane.is_modifier - assert input_names(depth_plane) == [ - "Geometry", - "Mode", - "Subdivide", - "Thickness", - "Thickness", - "Depth Scale", - "Reference Depth", - "Normal Smooth", - "Uniform Scale", - "Depth Image", - ] - thickness_inputs = [ - item for item in depth_plane.interface.items_tree - if item.item_type == "SOCKET" and item.in_out == "INPUT" and item.name == "Thickness" - ] - assert len({item.identifier for item in thickness_inputs}) == 2 - assert all(item.default_value == 0 and item.subtype == "DISTANCE" for item in thickness_inputs) - depth_panels = [ - item for item in depth_plane.interface.items_tree if item.item_type == "PANEL" - ] - assert [panel.name for panel in depth_panels] == ["Options", "Data"] - assert [item.name for item in depth_panels[0].interface_items] == [ - "Reference Depth", "Normal Smooth" - ] - assert [item.name for item in depth_panels[1].interface_items] == [ - "Uniform Scale", - "Depth Image", - ] - assert all(panel.default_closed for panel in depth_panels) - assert all(item.hide_in_modifier for item in depth_panels[1].interface_items) - depth_subdivide = next( - item - for item in depth_plane.interface.items_tree - if item.item_type == "SOCKET" and item.name == "Subdivide" - ) - assert depth_subdivide.default_value == 6 + for group, normal_controls in ((depth_plane, ["Normal Smooth", "Smooth Weight", "Validity Threshold"]), (relief_plane, [])): + assert group.bl_idname == "GeometryNodeTree" and group.is_modifier + assert input_names(group) == [ + "Geometry", "Subdivide", "Thickness", "Depth Scale", + *(["Smooth", "Valid Only"] if group == depth_plane else []), "Reference Depth", + *normal_controls, "Uniform Scale", "Depth Image", + ] + inputs = {item.name: item for item in group.interface.items_tree + if item.item_type == "SOCKET" and item.in_out == "INPUT"} + assert inputs["Thickness"].default_value == 0 + assert inputs["Thickness"].subtype == inputs["Reference Depth"].subtype == "DISTANCE" + assert inputs["Subdivide"].default_value == 6 + assert all(item.structure_type == "SINGLE" for item in inputs.values()) + panels = [item for item in group.interface.items_tree if item.item_type == "PANEL"] + assert [panel.name for panel in panels] == ["Options", "Data"] + assert [item.name for item in panels[0].interface_items] == ["Reference Depth", *normal_controls] + assert [item.name for item in panels[1].interface_items] == ["Uniform Scale", "Depth Image"] + assert all(panel.default_closed for panel in panels) + assert all(item.hide_in_modifier for item in panels[1].interface_items) assert image_layer.bl_idname == "ShaderNodeTree" assert input_names(image_layer) == [ "Color", "Alpha", "Normal", + "Bump Scale", + "Alpha Fix", "Normal Scale", "Object Space", - "Bump Scale", ] assert depth_layer.bl_idname == "ShaderNodeTree" + adjustment = next( + item for item in image_layer.interface.items_tree + if item.item_type == "SOCKET" and item.in_out == "INPUT" + and item.name == "Alpha Fix" + ) + assert adjustment.default_value == 0.0 + assert (adjustment.min_value, adjustment.max_value, adjustment.subtype) == (0, 1, "FACTOR") + image_output = next(node for node in image_layer.nodes if node.type == "GROUP_OUTPUT") + alpha_curve = image_output.inputs["Alpha"].links[0].from_node + assert alpha_curve.bl_idname == "ShaderNodeFloatCurve" + assert alpha_curve.inputs["Value"].links[0].from_socket.name == "Alpha" + assert alpha_curve.inputs["Factor"].links[0].from_socket.name == "Alpha Fix" + panels = [item for item in image_layer.interface.items_tree if item.item_type == "PANEL"] + assert len(panels) == 1 and panels[0].name == "Options" and panels[0].default_closed + assert [item.name for item in panels[0].interface_items] == ["Alpha Fix", "Normal Scale", "Object Space"] + alpha_points = alpha_curve.mapping.curves[0].points + assert len(alpha_points) == 4 + for point, expected in zip(alpha_points, ((0, 0), (0.8, 0), (0.925, 0.925), (1, 1))): + assert all(abs(actual - value) < 1e-6 for actual, value in zip(point.location, expected)) assert input_names(depth_layer) == [ "Color", "Alpha", @@ -130,6 +125,7 @@ def main(): public_groups = { image_plane, depth_plane, + relief_plane, image_layer, depth_layer, shadeless, @@ -138,7 +134,7 @@ def main(): loaded = {*public_groups, *cutout_groups.values()} for group in loaded: if group.bl_idname == "GeometryNodeTree": - validate_node_layout(group) + validate_node_structure(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): @@ -147,7 +143,3 @@ def main(): f"Validated Blender {bpy.app.version_string}: " + ", ".join(sorted(group.name for group in loaded)) ) - - -if __name__ == "__main__": - main() diff --git a/tools/node_assets/cutout_validation.py b/tests/test_node_asset_cutouts.py similarity index 91% rename from tools/node_assets/cutout_validation.py rename to tests/test_node_asset_cutouts.py index b5b192d..bd72584 100644 --- a/tools/node_assets/cutout_validation.py +++ b/tests/test_node_asset_cutouts.py @@ -1,29 +1,23 @@ -"""Validate direct Cutout assets and their evaluated defaults.""" -from pathlib import Path -import sys from collections import Counter + import bpy + import numpy as np -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT / "src")) -sys.path.insert(0, str(Path(__file__).parent)) -sys.path.insert( - 0, - str(next((ROOT / "src" / "anyimage" / "wheels").glob("blendjob-*.whl"))), -) from anyimage.operators.cutout_tool.object import load_cutout_node_group + from anyimage.common.object import modifier_input_identifier, set_modifier_input -from node_layout import validate_node_layout + +from node_asset_test_support import validate_node_structure -def main(): +def test_saved_cutout_defaults(): bpy.ops.wm.read_factory_settings(use_empty=True) groups = {shape: load_cutout_node_group(shape) for shape in ("FLAT", "SOLID", "DEPTH_SOLID", "DEPTH_BALLOON")} assert groups["FLAT"] == groups["SOLID"] assert len(set(groups.values())) == 3 for group in set(groups.values()): - validate_node_layout(group) + validate_node_structure(group) assert group.is_modifier and group.get("anyimage_asset_revision") == 11 assert not any(node.bl_idname == "GeometryNodeGroup" for node in group.nodes) inputs = {item.name: item for item in group.interface.items_tree if item.item_type == "SOCKET" and item.in_out == "INPUT"} @@ -87,7 +81,3 @@ def main(): finally: evaluated.to_mesh_clear() print("Validated three Cutout assets, four presets, both thickness modes and closed defaults") - - -if __name__ == "__main__": - main() diff --git a/tests/test_node_asset_launcher.py b/tests/test_node_asset_launcher.py index f79df09..728af90 100644 --- a/tests/test_node_asset_launcher.py +++ b/tests/test_node_asset_launcher.py @@ -5,7 +5,7 @@ import pytest PROJECT_ROOT = Path(__file__).resolve().parents[1] -SCRIPT_PATH = PROJECT_ROOT / "tools" / "build_node.py" +SCRIPT_PATH = PROJECT_ROOT / "tools" / "nodes" / "cli.py" def load_launcher(): @@ -82,11 +82,8 @@ def test_launcher_runs_asset_build_and_validations_before_tests(monkeypatch, tmp launcher.main() - scripts = [Path(command[-1]) for command in calls[:-1]] - assert [path.name for path in scripts] == [ - "asset_library.py", "asset_validation.py", - "image_plane_validation.py", "cutout_validation.py", - ] - assert all(path.is_file() for path in scripts) - assert all(command[1:4] == ("--factory-startup", "--background", "--python") for command in calls[:-1]) + assert len(calls) == 3 + assert "runpy.run_module('tools.nodes.build', run_name='__main__')" in calls[0][-1] + assert "runpy.run_module('tools.nodes.check', run_name='__main__')" in calls[1][-1] + assert all(command[1:6] == ("--factory-startup", "--background", "--python-exit-code", "1", "--python-expr") for command in calls[:-1]) assert calls[-1][1:] == ("-m", "pytest") diff --git a/tools/node_assets/image_plane_validation.py b/tests/test_node_asset_plane.py similarity index 93% rename from tools/node_assets/image_plane_validation.py rename to tests/test_node_asset_plane.py index 8501e7d..62ee208 100644 --- a/tools/node_assets/image_plane_validation.py +++ b/tests/test_node_asset_plane.py @@ -1,20 +1,10 @@ -from pathlib import Path -import sys - import bpy - -PROJECT_ROOT = Path(__file__).parents[2] -sys.path.insert(0, str(PROJECT_ROOT / "src")) -sys.path.insert( - 0, - str(next((PROJECT_ROOT / "src" / "anyimage" / "wheels").glob("blendjob-*.whl"))), -) - from anyimage.common.object import ( modifier_input_identifier, set_modifier_input, ) + from anyimage.operators.convert_to_plane.image_plane import ( add_image_plane_processing, create_image_plane_mesh, @@ -27,7 +17,7 @@ def evaluated_mesh(obj): return evaluated, evaluated.to_mesh() -def main(): +def test_saved_image_plane_evaluation(): bpy.ops.wm.read_factory_settings(use_empty=True) mesh = create_image_plane_mesh("Image Plane", (-1.0, 1.0, -0.5, 0.5)) assert len(mesh.polygons) == 1 @@ -114,7 +104,3 @@ def main(): if bpy.app.version >= (5, 0, 0): assert group.library is not None - - -if __name__ == "__main__": - main() diff --git a/tests/test_node_assets.py b/tests/test_node_assets.py index d1a9b5c..668c341 100644 --- a/tests/test_node_assets.py +++ b/tests/test_node_assets.py @@ -1,5 +1,4 @@ import unittest -import sys from pathlib import Path @@ -8,11 +7,11 @@ SOURCE_ROOT = PROJECT_ROOT / "src" / "anyimage" class NodeAssetsTest(unittest.TestCase): - def test_geometry_assets_pass_layout_overlap_checks(self): + def test_geometry_assets_preserve_node_structure(self): import bpy - sys.path.insert(0, str(PROJECT_ROOT / "tools" / "node_assets")) - from node_layout import validate_node_layout + from node_asset_test_support import validate_node_structure + from tools.nodes.arrangement.arrange import arrange_nodes bpy.ops.wm.read_factory_settings(use_empty=True) with bpy.data.libraries.load( @@ -24,9 +23,15 @@ class NodeAssetsTest(unittest.TestCase): for group in target.node_groups if group.bl_idname == "GeometryNodeTree" ] - self.assertEqual(len(groups), 5) + self.assertEqual(len(groups), 6) for group in groups: - validate_node_layout(group) + validate_node_structure(group) + positions = {node.name: tuple(node.location) for node in group.nodes + if node.bl_idname not in {"NodeReroute", "NodeGroupInput"}} + arrange_nodes(group) + for name, position in positions.items(): + for actual, expected in zip(group.nodes[name].location, position): + self.assertAlmostEqual(actual, expected, delta=0.01, msg=f"{group.name}: {name}") def test_production_code_does_not_create_node_groups(self): offenders = [] diff --git a/tests/test_plane_objects.py b/tests/test_plane_objects.py index 9c47cfd..d6d3e08 100644 --- a/tests/test_plane_objects.py +++ b/tests/test_plane_objects.py @@ -16,6 +16,9 @@ from anyimage.operators.clipboard_image import actions as clipboard_actions from anyimage.operators.convert_to_plane import object as plane_object from anyimage.operators.cutout_tool import shape as cutout_shape from image_object_test_support import _depth_image, _depth_metadata, _evaluated_positions, _inputs +from tools.nodes.groups.image_plane import build_image_plane_group +from tools.nodes.groups.image_depth_plane import build_image_depth_plane_group +from tools.nodes.groups.image_relief_plane import build_image_relief_plane_group def test_build_plane_keeps_one_quad_and_generates_topology_in_geometry_nodes(): @@ -76,6 +79,7 @@ def test_clipboard_plane_uses_the_mesh_material_slot(): def test_converted_planes_center_the_origin_without_moving_world_corners(): bpy.ops.wm.read_factory_settings(use_empty=True) + build_image_depth_plane_group(build_image_plane_group()) def create_source(name): image = bpy.data.images.new(name, width=200, height=100) @@ -146,6 +150,7 @@ def test_converted_planes_center_the_origin_without_moving_world_corners(): (2, 2), 1.0, ), + "DEPTH", ) assert_centered(depth_plane, depth_bounds, depth_matrix) assert depth_plane["anyimage_mesh_shape"] == "DEPTH_PLANE" @@ -167,8 +172,9 @@ def test_converted_planes_center_the_origin_without_moving_world_corners(): ) -def test_depth_plane_builds_a_fixed_base_terrain_block(): +def test_relief_plane_builds_a_fixed_base_terrain_block(): bpy.ops.wm.read_factory_settings(use_empty=True) + build_image_relief_plane_group(build_image_plane_group()) image = bpy.data.images.new("Terrain", width=4, height=4) source = bpy.data.objects.new("Terrain", None) source.empty_display_type = "IMAGE" @@ -191,7 +197,10 @@ def test_depth_plane_builds_a_fixed_base_terrain_block(): (4, 4), 1.0, ), + "RELIEF", ) + assert result["anyimage_mesh_shape"] == "RELIEF_PLANE" + assert result.modifiers[0].node_group.name == "O Image Relief Plane" modifier = result.modifiers[0] group = modifier.node_group diff --git a/tests/test_plane_operators.py b/tests/test_plane_operators.py index c13efae..9a8a107 100644 --- a/tests/test_plane_operators.py +++ b/tests/test_plane_operators.py @@ -55,88 +55,90 @@ class PlaneOperatorsTest(BlenderTestCase): prepare.assert_not_called() - def test_depth_plane_starts_one_job_without_a_format_selection(self): - module = self.convert_to_plane - source = SimpleNamespace(name="Source", data=object()) - context = SimpleNamespace(object=source, scene=object()) - operator = SimpleNamespace(mesh_detail=4, report=Mock()) - generate = Mock(return_value={"FINISHED"}) - with ( - patch.object(module, "video_total_frames", return_value=1), - patch.object(module, "invoke_ai_setup_if_needed", return_value=None), - patch.object(module, "prepare_image_input", return_value=(Path("input.png"), True)), - patch.object(module, "depth_plane_settings", return_value=("moge", 5)), - patch.object(module, "configured_max_ai_input_size", return_value=1024), - patch.object(module.bpy, "ops", SimpleNamespace(anyimage=SimpleNamespace(generate_depth_plane=generate)), create=True), - ): - self.assertEqual(module.ConvertToDepthPlane.execute(operator, context), {"FINISHED"}) - self.assertNotIn("depth_mode", generate.call_args.kwargs) - self.assertEqual(generate.call_args.kwargs["source_object_name"], "Source") + def test_depth_and_relief_start_jobs_with_their_plane_type(self): + for plane_type, operator_class in (("DEPTH", self.convert_to_plane.ConvertToDepthPlane), ("RELIEF", self.convert_to_plane.ConvertToReliefPlane)): + module = self.convert_to_plane + source = SimpleNamespace(name="Source", data=object()) + context = SimpleNamespace(object=source, scene=object()) + operator = SimpleNamespace(mesh_detail=4, report=Mock()) + generate = Mock(return_value={"FINISHED"}) + with ( + patch.object(module, "video_total_frames", return_value=1), + patch.object(module, "invoke_ai_setup_if_needed", return_value=None), + patch.object(module, "prepare_image_input", return_value=(Path("input.png"), True)), + patch.object(module, "depth_plane_settings", return_value=("moge", 5)), + patch.object(module, "configured_max_ai_input_size", return_value=1024), + patch.object(module.bpy, "ops", SimpleNamespace(anyimage=SimpleNamespace(generate_depth_plane=generate)), create=True), + ): + self.assertEqual(operator_class.execute(operator, context), {"FINISHED"}) + self.assertNotIn("depth_mode", generate.call_args.kwargs) + self.assertEqual(generate.call_args.kwargs["source_object_name"], "Source") + self.assertEqual(generate.call_args.kwargs["plane_type"], plane_type) + def test_depth_and_relief_results_use_their_normal_space(self): + for plane_type, normal_space in (("DEPTH", "OBJECT"), ("RELIEF", "TANGENT")): + normal_key = f"{normal_space.lower()}_normal" + module = self.convert_to_plane_object + source = SimpleNamespace(data=SimpleNamespace(name="Source")) + color_image = SimpleNamespace(name="", users=0) + depth_image = SimpleNamespace(users=0) + normal_image = SimpleNamespace(users=0) + material = SimpleNamespace(users=0) + metadata = {"reference_depth": 1.0} + files = { + "depth_metadata": Path("depth.json"), + "depth": Path("depth.exr"), + normal_key: Path(f"{normal_key}.png"), + } + result = SimpleNamespace( + directory=Path("result"), + file=lambda key: files[key], + ) + operator = SimpleNamespace(source_object_name="Source", mesh_detail=6, plane_type=plane_type) + context = SimpleNamespace(scene=object()) + ops = SimpleNamespace( + ed=SimpleNamespace(undo_push=Mock(return_value={"FINISHED"})) + ) - def test_depth_plane_result_connects_the_tangent_normal_material(self): - module = self.convert_to_plane_object - source = SimpleNamespace(data=SimpleNamespace(name="Source")) - color_image = SimpleNamespace(name="", users=0) - depth_image = SimpleNamespace(users=0) - normal_image = SimpleNamespace(users=0) - material = SimpleNamespace(users=0) - metadata = {"reference_depth": 1.0} - files = { - "depth_metadata": Path("depth.json"), - "depth": Path("depth.exr"), - "tangent_normal": Path("tangent-normal.png"), - } - result = SimpleNamespace( - directory=Path("result"), - file=lambda key: files[key], - ) - operator = SimpleNamespace(source_object_name="Source", mesh_detail=6, generate_depth=True) - context = SimpleNamespace(scene=object()) - ops = SimpleNamespace( - ed=SimpleNamespace(undo_push=Mock(return_value={"FINISHED"})) - ) + with ( + patch.object(module, "require_image_empty", return_value=source), + patch.object(module, "load_color_result_image", return_value=color_image), + patch.object(module, "load_depth_metadata", return_value=metadata), + patch.object(module, "load_depth_result_image", return_value=depth_image) as load_depth, + patch.object( + module, + "load_normal_result_image", + return_value=normal_image, + ) as load_normal, + patch.object( + module, + "create_image_material", + return_value=material, + ) as create_material, + patch.object(module, "create_depth_plane_object") as create_object, + patch.object(module.bpy, "ops", ops, create=True), + ): + module.create_depth_plane_from_result(context, result, operator) - with ( - patch.object(module, "require_image_empty", return_value=source), - patch.object(module, "load_color_result_image", return_value=color_image), - patch.object(module, "load_depth_metadata", return_value=metadata), - patch.object(module, "load_depth_result_image", return_value=depth_image) as load_depth, - patch.object( - module, - "load_normal_result_image", - return_value=normal_image, - ) as load_normal, - patch.object( - module, - "create_image_material", - return_value=material, - ) as create_material, - patch.object(module, "create_depth_plane_object") as create_object, - patch.object(module.bpy, "ops", ops, create=True), - ): - module.create_depth_plane_from_result(context, result, operator) - - load_depth.assert_called_once_with(files["depth"], source, metadata) - ops.ed.undo_push.assert_called_once_with(message="Convert to Depth Plane") - load_normal.assert_called_once_with(files["tangent_normal"], source) - create_material.assert_called_once_with( - source, - color_image, - 0.0, - normal_image=normal_image, - normal_space="TANGENT", - ior=1.2, - scene=context.scene, - ) - create_object.assert_called_once_with( - context, - source, - 6, - material, - depth_image, - metadata, - ) + load_depth.assert_called_once_with(files["depth"], source, metadata) + ops.ed.undo_push.assert_called_once_with(message=f"Convert to {plane_type.title()} Plane") + load_normal.assert_called_once_with(files[normal_key], source) + create_material.assert_called_once_with( + source.data, + color_image, + normal_image=normal_image, + normal_space=normal_space, + scene=context.scene, + ) + create_object.assert_called_once_with( + context, + source, + 6, + material, + depth_image, + metadata, + plane_type, + ) def test_depth_plane_result_cleans_unused_depth_and_normal_on_failure(self): @@ -149,7 +151,7 @@ class PlaneOperatorsTest(BlenderTestCase): files = { "depth_metadata": Path("depth.json"), "depth": Path("depth.exr"), - "tangent_normal": Path("tangent-normal.png"), + "object_normal": Path("object-normal.png"), } result = SimpleNamespace( directory=Path("result"), @@ -184,7 +186,7 @@ class PlaneOperatorsTest(BlenderTestCase): module.create_depth_plane_from_result( SimpleNamespace(scene=object()), result, - SimpleNamespace(source_object_name="Source", mesh_detail=6, generate_depth=True), + SimpleNamespace(source_object_name="Source", mesh_detail=6, plane_type="DEPTH"), ) remove_material.assert_called_once_with(material, do_unlink=True) diff --git a/tests/test_preferences.py b/tests/test_preferences.py index b60eb58..15eebb2 100644 --- a/tests/test_preferences.py +++ b/tests/test_preferences.py @@ -404,6 +404,7 @@ class PreferencesTest(BlenderTestCase): def test_depth_plane_request_includes_the_global_ai_input_limit(self): operator = self.convert_to_plane.GenerateDepthPlane() + operator.plane_type = "DEPTH" operator.input_path = __file__ operator.analysis_input_path = __file__ operator.source_object_name = "Source" diff --git a/tests/test_server_jobs.py b/tests/test_server_jobs.py index c5f1eaf..e746dab 100644 --- a/tests/test_server_jobs.py +++ b/tests/test_server_jobs.py @@ -36,50 +36,52 @@ class ServerJobsTest(unittest.TestCase): ) - def test_depth_plane_job_requests_depth_texture_and_tangent_normal_together(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - input_path = root / "input.png" - input_path.touch() - context = FakeJobContext(root / "result", "generate-depth-plane-geometry") - depth_texture = context.directory / "depth.exr" - metadata = context.directory / "depth.json" - tangent_normal = context.directory / "tangent-normal.png" - parameters = {"input": str(input_path), "generate_depth": True} - with ( - patch.object( - self.depth_plane_job, - "input_frames", - return_value=nullcontext([input_path]), - ), - patch.object(self.depth_plane_job, "copy_color_images"), - patch.object( - self.depth_plane_job, - "generate_moge2_artifacts", - return_value={ - "depth": depth_texture, - "depth_metadata": metadata, - "tangent_normal": tangent_normal, - }, - ) as generate, - ): - result = self.depth_plane_job.run(context, parameters) + def test_plane_job_requests_depth_and_matching_normal_space(self): + for plane_type, normal_space in (("DEPTH", "OBJECT"), ("RELIEF", "TANGENT")): + normal_key = f"{normal_space.lower()}_normal" + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + input_path = root / "input.png" + input_path.touch() + context = FakeJobContext(root / "result", "generate-depth-plane-geometry") + depth_texture = context.directory / "depth.exr" + metadata = context.directory / "depth.json" + normal_texture = context.directory / f"{normal_space.lower()}-normal.png" + parameters = {"input": str(input_path), "plane_type": plane_type} + with ( + patch.object( + self.depth_plane_job, + "input_frames", + return_value=nullcontext([input_path]), + ), + patch.object(self.depth_plane_job, "copy_color_images"), + patch.object( + self.depth_plane_job, + "generate_moge2_artifacts", + return_value={ + "depth": depth_texture, + "depth_metadata": metadata, + normal_key: normal_texture, + }, + ) as generate, + ): + result = self.depth_plane_job.run(context, parameters) - self.assertEqual( - result, - { - "depth": "depth.exr", - "depth_metadata": "depth.json", - "tangent_normal": "tangent-normal.png", - }, - ) - generate.assert_called_once_with( - context, - parameters, - input_path, - generate_depth=True, - normal_mode="TANGENT", - ) + self.assertEqual( + result, + { + "depth": "depth.exr", + "depth_metadata": "depth.json", + normal_key: f"{normal_space.lower()}-normal.png", + }, + ) + generate.assert_called_once_with( + context, + parameters, + input_path, + generate_depth=True, + normal_mode=normal_space, + ) def test_ben2_background_removal_writes_fast_transparent_png(self): diff --git a/tests/test_sync_models.py b/tests/test_sync_models.py index 4663f82..c3e746d 100644 --- a/tests/test_sync_models.py +++ b/tests/test_sync_models.py @@ -9,7 +9,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1] def load_sync_models(): - path = PROJECT_ROOT / "tools" / "sync_models.py" + path = PROJECT_ROOT / "tools" / "models" / "cli.py" spec = importlib.util.spec_from_file_location("sync_models", path) module = importlib.util.module_from_spec(spec) sys.modules[spec.name] = module @@ -87,7 +87,7 @@ class SyncModelsTests(unittest.TestCase): self.assertEqual(command[-2], model.remote) def test_verify_file_checks_size_and_sha256(self): - path = PROJECT_ROOT / "tools" / "sync_models.py" + path = PROJECT_ROOT / "tools" / "models" / "cli.py" size = path.stat().st_size digest = self.sync.file_sha256(path) self.assertTrue(self.sync.verify_file(path, size, digest)) diff --git a/tests/test_tool_commands.py b/tests/test_tool_commands.py index f7eb62d..16c96b3 100644 --- a/tests/test_tool_commands.py +++ b/tests/test_tool_commands.py @@ -15,8 +15,8 @@ class ToolCommandsTests(unittest.TestCase): project["scripts"], { "pack": "tools.pack:main", - "build_node": "tools.build_node:main", - "sync_models": "tools.sync_models:main", + "build_node": "tools.nodes.cli:main", + "sync_models": "tools.models.cli:main", "docs": "tools.docs:main", }, ) diff --git a/tools/models/__init__.py b/tools/models/__init__.py new file mode 100644 index 0000000..3e1e862 --- /dev/null +++ b/tools/models/__init__.py @@ -0,0 +1 @@ +"""Model preparation, export, and upload tools.""" diff --git a/tools/sync_models.py b/tools/models/cli.py similarity index 97% rename from tools/sync_models.py rename to tools/models/cli.py index d2d053e..0ca9de3 100644 --- a/tools/sync_models.py +++ b/tools/models/cli.py @@ -10,7 +10,7 @@ from pathlib import Path from urllib.request import Request, urlopen -PROJECT_ROOT = Path(__file__).resolve().parents[1] +PROJECT_ROOT = Path(__file__).resolve().parents[2] MODELS_DIR = PROJECT_ROOT / "models" MODEL_CACHE_DIR = PROJECT_ROOT / ".model-cache" SOURCE_ROOT = PROJECT_ROOT / "src" / "anyimage" @@ -169,7 +169,7 @@ def prepare_source(build): def build_model(model): - from tools.model_exports import export_model + from tools.models.exports import export_model source = prepare_source(model.build) destination = model.destination @@ -195,7 +195,7 @@ def prepare_model(model): return if model.key == "PISA_SR": subprocess.run( - ["uv", "run", "--script", str(PROJECT_ROOT / "tools/pisa_sr_export.py")], + ["uv", "run", "--script", str(Path(__file__).with_name("pisa_sr_export.py"))], cwd=PROJECT_ROOT, check=True, ) if not verify_file(model.destination, model.size, model.sha256): diff --git a/tools/model_exports.py b/tools/models/exports.py similarity index 100% rename from tools/model_exports.py rename to tools/models/exports.py diff --git a/tools/model_licenses/pisa-sr/LICENSE-APACHE.txt b/tools/models/licenses/pisa-sr/LICENSE-APACHE.txt similarity index 100% rename from tools/model_licenses/pisa-sr/LICENSE-APACHE.txt rename to tools/models/licenses/pisa-sr/LICENSE-APACHE.txt diff --git a/tools/model_licenses/pisa-sr/LICENSE-MODEL.txt b/tools/models/licenses/pisa-sr/LICENSE-MODEL.txt similarity index 100% rename from tools/model_licenses/pisa-sr/LICENSE-MODEL.txt rename to tools/models/licenses/pisa-sr/LICENSE-MODEL.txt diff --git a/tools/model_licenses/pisa-sr/NOTICE.txt b/tools/models/licenses/pisa-sr/NOTICE.txt similarity index 100% rename from tools/model_licenses/pisa-sr/NOTICE.txt rename to tools/models/licenses/pisa-sr/NOTICE.txt diff --git a/tools/pisa_sr_export.py b/tools/models/pisa_sr_export.py similarity index 99% rename from tools/pisa_sr_export.py rename to tools/models/pisa_sr_export.py index ec58eeb..15531d4 100644 --- a/tools/pisa_sr_export.py +++ b/tools/models/pisa_sr_export.py @@ -40,7 +40,7 @@ def finalize_assets(destination): "source_commit": SOURCE_COMMIT, }) onnx.save(model, path) - for path in (Path(__file__).parent / "model_licenses/pisa-sr").glob("*.txt"): + for path in (Path(__file__).parent / "licenses/pisa-sr").glob("*.txt"): shutil.copyfile(path, destination / path.name) diff --git a/tools/node_assets/asset_library.py b/tools/node_assets/asset_library.py deleted file mode 100644 index 7122ae5..0000000 --- a/tools/node_assets/asset_library.py +++ /dev/null @@ -1,47 +0,0 @@ -from pathlib import Path -import sys - -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 cutout import ( - CUTOUT_NODE_GROUP_NAMES, - build_cutout_group, -) -from depth_plane import build_depth_plane_group -from material_layers import build_material_layer_group -from image_plane import build_image_plane_group -from shadeless import build_shadeless_group -OUTPUT_PATH = ( - PROJECT_ROOT - / "src" - / "anyimage" - / "assets" - / "O_AnyImage.blend" -) - - -def main(): - bpy.ops.wm.read_factory_settings(use_empty=True) - bpy.context.preferences.filepaths.save_version = 0 - image_plane = build_image_plane_group() - build_depth_plane_group(image_plane) - build_material_layer_group() - build_material_layer_group(depth_plane=True) - build_shadeless_group() - for shape in CUTOUT_NODE_GROUP_NAMES: - build_cutout_group(shape) - OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True) - bpy.ops.wm.save_as_mainfile(filepath=str(OUTPUT_PATH), check_existing=False) - - -if __name__ == "__main__": - main() diff --git a/tools/node_assets/cutout.py b/tools/node_assets/cutout.py deleted file mode 100644 index 00a22a3..0000000 --- a/tools/node_assets/cutout.py +++ /dev/null @@ -1,252 +0,0 @@ -"""Build the three directly editable Cutout modifier assets.""" -import bpy -from types import SimpleNamespace -from mathutils import Matrix - -from anyimage.operators.cutout_tool.shape import CUTOUT_NODE_GROUP_NAMES, CUTOUT_Z_TO_X, NORMAL_SCALE_ATTRIBUTE_NAME -from cutout_shapes import ( - _socket, _float_input, _bool_input, _vector_input, _group_input, _math, - _island_cleanup, _build_cutout, _build_balloon, _build_depth_balloon, - _build_depth_surface, _shade_output, -) -from cutout_shape_layouts import LAYOUTS -from depth_cutout_layout import POSITIONS, INPUT_REGIONS, layout_geometry_references -from cutout_layout import layout_stage, finish_layout -from cutout_volume import build_volume_fields, shift_stage -from cutout_rounding import build_volume_rounding -from cutout_inflation import build_front_fields -from cutout_shell import build_front_normals, build_shell_position -from node_graph import expand_smooth - - -def _menu(group, control, names, kind, location): - selector = next((node for node in group.nodes if node.bl_idname == "GeometryNodeMenuSwitch" and node.inputs["Menu"].is_linked and node.inputs["Menu"].links[0].from_socket.name == control), None) - if selector is None: - selector = group.nodes.new("GeometryNodeMenuSwitch") - selector.data_type = "BOOLEAN" - selector.location = (location[0] - 240, location[1] + 450) - for item, name in zip(selector.enum_items, names): - item.name = name - selector.inputs[names[0]].default_value = False - selector.inputs[names[1]].default_value = True - local = _group_input(group.nodes, (location[0] - 480, location[1] + 450), {control}) - group.links.new(local.outputs[control], selector.inputs["Menu"]) - node = group.nodes.new("GeometryNodeSwitch") - node.input_type = kind - node.location = location - group.links.new(selector.outputs[0], node.inputs["Switch"]) - return SimpleNamespace(inputs={names[0]: node.inputs["False"], names[1]: node.inputs["True"]}, outputs=node.outputs) - - -def _read(group, name, location): - node = group.nodes.new("GeometryNodeInputNamedAttribute") - node.data_type = "FLOAT" - node.inputs["Name"].default_value = name - node.location = location - return node.outputs["Attribute"] - - -def _store(group, geometry, name, value, location, domain="POINT"): - node = group.nodes.new("GeometryNodeStoreNamedAttribute") - node.domain, node.data_type = domain, "FLOAT" - node.location = location - node.inputs["Name"].default_value = name - group.links.new(geometry, node.inputs["Geometry"]) - if isinstance(value, (int, float)): - node.inputs["Value"].default_value = value - else: - group.links.new(value, node.inputs["Value"]) - return node.outputs["Geometry"] - - -def _depth_volume(group, stage): - """Insert the volume field ahead of identity-based shell construction.""" - links = group.links - projection = stage["Set Position"].outputs["Geometry"] - shell_input = stage["Store Named Attribute.005"].inputs["Geometry"] - for node in group.nodes: - if node.location.x >= 4150: - node.location.x += 5100 - projection = build_front_normals(group, projection) - volume = build_volume_fields(group, projection, (4150, 0)) - shared = build_front_fields(group, volume.node.inputs["Geometry"].links[0].from_socket) - links.new(shared, volume.node.inputs["Geometry"]) - uniform_control = _group_input(group.nodes, (8210, -330), {"Uniform Thickness"}) - uniform = _store(group, shared, "o_balloon_surface_thickness", uniform_control.outputs["Uniform Thickness"], (8490, -330)) - volume.node.location.x = uniform.node.location.x - choose = _menu(group, "Mode", ("Balloon", "Uniform"), "GEOMETRY", (9000, 0)) - links.new(volume, choose.inputs["Balloon"]) - links.new(uniform, choose.inputs["Uniform"]) - links.new(choose.outputs[0], shell_input) - thickness = _read(group, "o_balloon_surface_thickness", (12000, -1100)) - links.new(thickness, stage["Math.018"].inputs[1]) - control = _group_input(group.nodes, (13300, 700), {"Thickness", "Uniform Thickness"}) - amount = _menu(group, "Mode", ("Balloon", "Uniform"), "FLOAT", (13600, 500)) - links.new(control.outputs["Thickness"], amount.inputs["Balloon"]) - links.new(control.outputs["Uniform Thickness"], amount.inputs["Uniform"]) - links.new(amount.outputs[0], stage["Math.019"].inputs[0]) - identity_thickness = _read(group, "o_balloon_surface_thickness", (11100, 1500)) - positive = _math(group.nodes, "GREATER_THAN", (11340, 1500)) - positive.inputs[1].default_value = 1e-12 - links.new(identity_thickness, positive.inputs[0]) - layer = _math(group.nodes, "MULTIPLY", (11570, 1200)) - links.new(positive.outputs[0], layer.inputs[0]) - links.new(stage["Separate XYZ.006"].outputs["Z"], layer.inputs[1]) - links.new(layer.outputs[0], stage["Combine XYZ.002"].inputs["Z"]) - for node in group.nodes: - if node.location.x >= 12070: - node.location.x += 400 - for name in ("Flip Faces", "Flip Faces.001"): - source = stage[name].outputs["Mesh"] - outgoing = [link for link in links if link.from_socket == source] - targets = [link.to_socket for link in outgoing] - for link in outgoing: - links.remove(link) - masked = _store(group, source, NORMAL_SCALE_ATTRIBUTE_NAME, 0.0, (12100, stage[name].location.y), domain="FACE") - for target in targets: - links.new(masked, target) - - for node in group.nodes: - if node.location.x >= 8490: - node.location.x += 3200 - volume.node.location.x = 8290 - rounded = build_volume_rounding(group, volume, (8600, 0)) - rounded.node.location.x = uniform.node.location.x + 40 - links.new(rounded, choose.inputs["Balloon"]) - restored = stage["Vector Math.004"].outputs[0] - targets = [link.to_socket for link in restored.links] - uniform_mode = choose.outputs[0].node.inputs["Switch"].links[0].from_socket - oriented = build_shell_position(group, restored, uniform_mode, (14100, -2100)) - for target in targets: - links.new(oriented, target) - - -def build_cutout_group(shape): - name = CUTOUT_NODE_GROUP_NAMES[shape] - existing = bpy.data.node_groups.get(name) - if existing is not None: - return existing - group = bpy.data.node_groups.new(name, "GeometryNodeTree") - group.is_modifier, group.use_fake_user = True, True - group.color_tag = "GEOMETRY" - group["anyimage_asset_revision"] = 11 - depth = shape in {"DEPTH_SOLID", "DEPTH_BALLOON"} - dual = shape != "DEPTH_BALLOON" - _socket(group, "Geometry", "INPUT", "NodeSocketGeometry") - if dual: - _socket(group, "Mode", "INPUT", "NodeSocketMenu") - _float_input(group, "Thickness", 1.0).description = "Balloon profile multiplier; zero returns the sheet" - if dual: - _float_input(group, "Uniform Thickness", 0.0, subtype="DISTANCE").description = "Distance used only by Uniform thickness mode" - if depth: - _float_input(group, "Depth Scale", 1.0) - if shape == "DEPTH_BALLOON": - _bool_input(group, "Double Sided", True) - iterations = _socket(group, "Smooth", "INPUT", "NodeSocketInt") - iterations.default_value = 0 - iterations.min_value, iterations.max_value = 0, 20 - options = group.interface.new_panel(name="Options", default_closed=True) - _float_input(group, "Cleanup Threshold", 0.0, parent=options) - if shape == "DEPTH_SOLID": - _float_input(group, "Split Threshold", 0.0, maximum=1, subtype="FACTOR", parent=options) - if depth: - _float_input(group, "Reference Depth", 1.0, subtype="DISTANCE", parent=options) - if shape == "DEPTH_SOLID": - _float_input(group, "Edge Round", 0.2, maximum=1, subtype="FACTOR", parent=options) - _float_input(group, "Front Inflation", 1.0, maximum=32, parent=options) - normal_iterations = _socket(group, "Normal Smooth", "INPUT", "NodeSocketInt", options) - normal_iterations.default_value = 50 - normal_iterations.min_value, normal_iterations.max_value = 0, 50 - _float_input(group, "Smooth Weight", 1.0, maximum=1, subtype="FACTOR", parent=options) - if shape == "DEPTH_BALLOON": - _vector_input(group, "Depth Direction", (0, 0, 1), subtype="DIRECTION", parent=options) - data = group.interface.new_panel(name="Data", default_closed=True) - _socket(group, "Inward Axis", "INPUT", "NodeSocketMenu", data).hide_in_modifier = True - if depth: - _float_input(group, "Uniform Scale", 1.0, parent=data).hide_in_modifier = True - _socket(group, "Depth Image", "INPUT", "NodeSocketImage", data).hide_in_modifier = True - _socket(group, "Geometry", "OUTPUT", "NodeSocketGeometry") - controls = _group_input(group.nodes, (-5000, 0), set()) - previous = set(group.nodes) - if shape == "DEPTH_SOLID": - geometry = _build_depth_surface(group, controls.outputs["Geometry"], controls.outputs["Thickness"], controls.outputs["Depth Scale"], controls.outputs["Split Threshold"], controls.outputs["Uniform Scale"], controls.outputs["Reference Depth"], controls.outputs["Depth Image"]) - stage = layout_stage(group, previous, POSITIONS, INPUT_REGIONS) - _depth_volume(group, stage) - end = 18000 - elif shape == "DEPTH_BALLOON": - geometry = _build_depth_balloon(group, controls.outputs["Geometry"], controls.outputs["Thickness"], controls.outputs["Depth Scale"], controls.outputs["Double Sided"], controls.outputs["Uniform Scale"], controls.outputs["Depth Direction"], controls.outputs["Depth Image"], controls.outputs["Reference Depth"]) - layout_stage(group, previous, *LAYOUTS["DEPTH_BALLOON"]) - end = 3000 - else: - balloon = _build_balloon(group, controls.outputs["Geometry"], controls.outputs["Thickness"]) - layout_stage(group, previous, *LAYOUTS["BALLOON"]) - previous = set(group.nodes) - uniform = _build_cutout(group, controls.outputs["Geometry"], controls.outputs["Uniform Thickness"]) - layout_stage(group, previous, *LAYOUTS["UNIFORM"], offset=(0, -2400)) - balloon.node.location.x = uniform.node.location.x - choose = _menu(group, "Mode", ("Balloon", "Uniform"), "GEOMETRY", (3200, 0)) - group.links.new(balloon, choose.inputs["Balloon"]) - group.links.new(uniform, choose.inputs["Uniform"]) - geometry, end = choose.outputs[0], 3700 - planar_inputs = [link.to_socket for link in group.links if link.from_node.bl_idname == "NodeGroupInput" and link.from_socket.name == "Geometry"] - base = _group_input(group.nodes, (-4800, 0), {"Geometry", "Cleanup Threshold"}) - area = _math(group.nodes, "MULTIPLY", (-4520, 500)) - area.inputs[1].default_value = 0.01 - group.links.new(base.outputs["Cleanup Threshold"], area.inputs[0]) - cleaned = _island_cleanup(group, base.outputs["Geometry"], area.outputs[0], (-4520, 0)) - if shape == "DEPTH_SOLID": - cleaned = _store(group, cleaned, NORMAL_SCALE_ATTRIBUTE_NAME, 1.0, (-3540, 0), domain="FACE") - for target in planar_inputs: - group.links.new(cleaned, target) - previous = set(group.nodes) - geometry = expand_smooth(group, geometry, iterations_name="Smooth", weight_name="Smooth Weight") - shift_stage(group, previous, (end, -550)) - end += 3450 - if shape == "DEPTH_SOLID": - remove = group.nodes.new("GeometryNodeRemoveAttribute") - remove.pattern_mode = "WILDCARD" - remove.inputs["Name"].default_value = "o_balloon_surface_*" - remove.location = (end, 0) - group.links.new(geometry, remove.inputs["Geometry"]) - geometry, end = remove.outputs[0], end + 300 - else: - profile = _read(group, "o_balloon", (end, 800)) - scale = profile - if dual: - amount = _group_input(group.nodes, (end, 1100), {"Thickness"}) - enabled = _math(group.nodes, "GREATER_THAN", (end + 250, 1100)) - enabled.inputs[1].default_value = 1e-6 - group.links.new(amount.outputs["Thickness"], enabled.inputs[0]) - inflated = group.nodes.new("GeometryNodeSwitch") - inflated.input_type = "FLOAT" - inflated.location = (end + 480, 800) - inflated.inputs["False"].default_value = 1 - group.links.new(enabled.outputs[0], inflated.inputs["Switch"]) - group.links.new(profile, inflated.inputs["True"]) - mode = _menu(group, "Mode", ("Balloon", "Uniform"), "FLOAT", (end + 750, 800)) - mode.inputs["Uniform"].default_value = 1 - group.links.new(inflated.outputs[0], mode.inputs["Balloon"]) - scale = mode.outputs[0] - geometry = _store(group, geometry, NORMAL_SCALE_ATTRIBUTE_NAME, scale, (end + 1050, 0)) - end += 1350 - orient = group.nodes.new("GeometryNodeTransform") - orient.location = (end, -350) - orient.inputs["Rotation"].default_value = Matrix(CUTOUT_Z_TO_X).to_euler() - group.links.new(geometry, orient.inputs["Geometry"]) - axis = _menu(group, "Inward Axis", ("-Z", "+X"), "GEOMETRY", (end + 300, 0)) - group.links.new(geometry, axis.inputs["-Z"]) - group.links.new(orient.outputs[0], axis.inputs["+X"]) - _shade_output(group, axis.outputs[0], (end + 600, 0)) - if shape == "DEPTH_SOLID": - layout_geometry_references(group, stage) - finish_layout(group) - for item in group.interface.items_tree: - if item.item_type != "SOCKET" or item.in_out != "INPUT": - continue - if item.name == "Mode": - item.default_value = "Balloon" - elif item.name == "Inward Axis": - item.default_value = "-Z" - elif item.name == "Uniform Thickness": - item.name = "Thickness" - return group diff --git a/tools/node_assets/cutout_inflation.py b/tools/node_assets/cutout_inflation.py deleted file mode 100644 index 0c32ec3..0000000 --- a/tools/node_assets/cutout_inflation.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Estimate existing front volume and add continuous normal-directed inflation.""" -from cutout_volume import node - - -def _math(group, operation, a, b, location): - result = node(group, "ShaderNodeMath", location) - result.operation = operation - for index, value in enumerate((a, b)): - if hasattr(value, "node"): - group.links.new(value, result.inputs[index]) - else: - result.inputs[index].default_value = value - return result.outputs[0] - - -def _read(group, name, location, kind="FLOAT"): - result = node(group, "GeometryNodeInputNamedAttribute", location) - result.data_type = kind - result.inputs["Name"].default_value = "o_balloon_surface_" + name - return result.outputs["Attribute"] - - -def _store(group, geometry, name, value, location, kind="FLOAT"): - result = node(group, "GeometryNodeStoreNamedAttribute", location) - result.data_type, result.domain = kind, "POINT" - result.inputs["Name"].default_value = "o_balloon_surface_" + name - group.links.new(geometry, result.inputs["Geometry"]) - group.links.new(value, result.inputs["Value"]) - return result.outputs["Geometry"] - - -def build_front_fields(group, geometry): - """Fit structural amplitude over neighbouring points before creating the shell.""" - links = group.links - - def math(operation, a, b, x, y): - return _math(group, operation, a, b, (4800 + x, -2500 + y)) - - def blur(value, x, y, weight=None): - result = node(group, "GeometryNodeBlurAttribute", (4800 + x, -2500 + y)) - result.data_type = "FLOAT" - result.inputs["Iterations"].default_value = 256 - links.new(value, result.inputs["Value"]) - if weight is not None: - links.new(weight, result.inputs["Weight"]) - return result.outputs[0] - - profile_node = node(group, "GeometryNodeInputNamedAttribute", (4800, -2500)) - profile_node.data_type = "FLOAT" - profile_node.inputs["Name"].default_value = "o_balloon" - profile = profile_node.outputs["Attribute"] - rim = _read(group, "rim", (4800, -3050)) - interior = math("SUBTRACT", 1, rim, 240, -550) - position = node(group, "GeometryNodeInputPosition", (4800, -1900)) - axes = node(group, "ShaderNodeSeparateXYZ", (5040, -1900)) - links.new(position.outputs[0], axes.inputs[0]) - depth = axes.outputs["Z"] - depth_slow = blur(depth, 480, 600, interior) - profile_slow = blur(profile, 480, 0, interior) - depth_bump = math("SUBTRACT", depth, depth_slow, 720, 600) - profile_bump = math("SUBTRACT", profile, profile_slow, 720, 0) - numerator = math("MULTIPLY", depth_bump, profile_bump, 960, 600) - denominator = math("MULTIPLY", profile_bump, profile_bump, 960, 0) - numerator = blur(numerator, 1200, 600) - denominator = blur(denominator, 1200, 0) - denominator = math("MAXIMUM", denominator, 1e-10, 1440, 0) - structure = math("DIVIDE", numerator, denominator, 1680, 600) - structure = math("MAXIMUM", structure, 0, 1920, 600) - carrier = _store(group, geometry, "structure", structure, (7300, 0)) - - normal = _read(group, "front_normal", (7100, -600), "FLOAT_VECTOR") - controls = node(group, "NodeGroupInput", (6860, -1100)) - # Keep the accepted broad front-direction filter at the default of 50; - # zero disables both this filter and the initial front/back normal filters. - spread = _math(group, "MULTIPLY", controls.outputs["Normal Smooth"], 256 / 50, (7100, -1100)) - smooth = node(group, "GeometryNodeBlurAttribute", (7340, -600)) - smooth.data_type = "FLOAT_VECTOR" - links.new(normal, smooth.inputs["Value"]) - links.new(spread, smooth.inputs["Iterations"]) - normalize = node(group, "ShaderNodeVectorMath", (7580, -600)) - normalize.operation = "NORMALIZE" - links.new(smooth.outputs[0], normalize.inputs[0]) - carrier = _store(group, carrier, "inflation_normal", normalize.outputs[0], (7770, 0), "FLOAT_VECTOR") - return carrier - - -def build_inflation_position(group, position, distance, uniform): - """Grow both modes continuously while leaving the rear cap in place.""" - previous = set(group.nodes) - links = group.links - - def math(operation, a, b, x, y): - return _math(group, operation, a, b, (x, y)) - - structure = _read(group, "structure", (14000, -4300)) - control = node(group, "NodeGroupInput", (13760, -5000)) - a = math("MULTIPLY", control.outputs["Thickness"], control.outputs["Front Inflation"], 14240, -5000) - # A shifted cubic blend preserves continuous growth and height order. - shifted = math("ADD", a, 0.75, 14480, -5000) - quadratic = math("MULTIPLY", a, shifted, 14720, -5000) - quadratic = math("ADD", quadratic, 0.1875, 14960, -5000) - q = math("MULTIPLY", a, quadratic, 15200, -5000) - k = math("ADD", structure, 0.25, 14240, -4300) - square = math("MULTIPLY", k, k, 14480, -4300) - cube = math("MULTIPLY", square, k, 14720, -4300) - total = math("ADD", cube, q, 14960, -4300) - root = math("POWER", total, 1 / 3, 15200, -4300) - root_square = math("MULTIPLY", root, root, 15440, -4000) - product = math("MULTIPLY", root, k, 15440, -4300) - denominator = math("ADD", root_square, product, 15680, -4300) - denominator = math("ADD", denominator, square, 15920, -4300) - response = math("DIVIDE", q, denominator, 15920, -4800) - profile = node(group, "GeometryNodeInputNamedAttribute", (15920, -3700)) - profile.data_type = "FLOAT" - profile.inputs["Name"].default_value = "o_balloon" - balloon = math("MULTIPLY", profile.outputs["Attribute"], response, 16160, -4250) - uniform_control = node(group, "NodeGroupInput", (15920, -5100)) - uniform_amount = math("MULTIPLY", uniform_control.outputs["Uniform Thickness"], uniform_control.outputs["Front Inflation"], 16160, -5100) - selected = node(group, "GeometryNodeSwitch", (16400, -4550)) - selected.input_type = "FLOAT" - links.new(uniform, selected.inputs["Switch"]) - links.new(balloon, selected.inputs["False"]) - links.new(uniform_amount, selected.inputs["True"]) - displacement = selected.outputs[0] - height = _read(group, "thickness", (14000, -5500)) - layer = math("DIVIDE", distance, height, 14240, -5500) - layer = math("ADD", 1, layer, 14480, -5500) - layer = math("MAXIMUM", layer, 0, 14720, -5500) - layer = math("MINIMUM", layer, 1, 14960, -5500) - displacement = math("MULTIPLY", displacement, layer, 16640, -4550) - normal = _read(group, "inflation_normal", (16640, -4000), "FLOAT_VECTOR") - offset = node(group, "ShaderNodeVectorMath", (16880, -4550)) - offset.operation = "SCALE" - links.new(normal, offset.inputs[0]) - links.new(displacement, offset.inputs[3]) - result = node(group, "ShaderNodeVectorMath", (17120, -4550)) - result.operation = "ADD" - links.new(position, result.inputs[0]) - links.new(offset.outputs[0], result.inputs[1]) - for item in set(group.nodes) - previous: - item.location.y = -3550 + (item.location.y + 3700) * 0.7 - return result.outputs[0] diff --git a/tools/node_assets/cutout_layout.py b/tools/node_assets/cutout_layout.py deleted file mode 100644 index c715844..0000000 --- a/tools/node_assets/cutout_layout.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Place independent Cutout stages using authored local layouts.""" -from collections import Counter - - -def route_geometry(group, source, positions): - """Guide a shared forward geometry reference through authored waypoints.""" - for position in positions: - reroute = group.nodes.new("NodeReroute") - reroute.location = position - group.links.new(source, reroute.inputs[0]) - source = reroute.outputs[0] - return source - - -def layout_stage(group, previous, positions, input_regions, offset=(0, 0)): - """Map stage-local default names without renaming Blender nodes.""" - counts, stage = Counter(), {} - for node in group.nodes: - if node in previous or node.bl_idname == "NodeGroupInput": - continue - base, dot, suffix = node.name.rpartition(".") - base = base if dot and suffix.isdigit() else node.name - index = counts[base] - counts[base] += 1 - stage[base if index == 0 else f"{base}.{index:03d}"] = node - assert set(stage) == set(positions) - {"Set Shade Smooth", "Group Output"}, (group.name, set(stage) ^ set(positions)) - for name, node in stage.items(): - x, y = positions[name] - node.location = (x + offset[0], y + offset[1]) - inputs = [(link.to_socket, link.from_socket.name) for link in group.links if link.to_node in stage.values() and link.from_node.bl_idname == "NodeGroupInput"] - for targets, (x, y) in input_regions: - sockets = [(socket, name) for socket, name in inputs if socket.node in [stage[key] for key in targets if key in stage]] - if not sockets: - continue - control = group.nodes.new("NodeGroupInput") - control.location = (x + offset[0], y + offset[1]) - for socket, name in sockets: - group.links.new(control.outputs[name], socket) - return stage - - -def finish_layout(group): - """Expose linked fields and retain readable constants.""" - for node in group.nodes: - node.select = False - node.width = 200 if "Attribute" in node.bl_idname else 160 - if node.bl_idname in {"ShaderNodeMath", "ShaderNodeVectorMath"}: - node.hide = 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 - for node in list(group.nodes): - if node.bl_idname == "NodeGroupInput" and not any(socket.is_linked for socket in node.outputs): - group.nodes.remove(node) diff --git a/tools/node_assets/cutout_rounding.py b/tools/node_assets/cutout_rounding.py deleted file mode 100644 index 9e09423..0000000 --- a/tools/node_assets/cutout_rounding.py +++ /dev/null @@ -1,101 +0,0 @@ -"""Round depth volume around the smoothed center-surface normals.""" -from cutout_volume import node, shift_stage - - -def build_volume_rounding(group, volume, offset): - previous = set(group.nodes) - links = group.links - - def math(operation, a, b, location): - result = node(group, "ShaderNodeMath", location) - result.operation = operation - for index, value in enumerate((a, b)): - if isinstance(value, (int, float)): - result.inputs[index].default_value = value - else: - links.new(value, result.inputs[index]) - return result.outputs[0] - - def read(name, location, kind="FLOAT"): - result = node(group, "GeometryNodeInputNamedAttribute", location) - result.data_type = kind - result.inputs["Name"].default_value = "o_balloon_surface_" + name - return result.outputs["Attribute"] - - def store(geometry, name, value, location, kind="FLOAT"): - result = node(group, "GeometryNodeStoreNamedAttribute", location) - result.data_type, result.domain = kind, "POINT" - result.inputs["Name"].default_value = "o_balloon_surface_" + name - links.new(geometry, result.inputs["Geometry"]) - links.new(value, result.inputs["Value"]) - return result.outputs["Geometry"] - - control = node(group, "NodeGroupInput", (0, 1250)) - profile = node(group, "GeometryNodeInputNamedAttribute", (0, 850)) - profile.data_type = "FLOAT" - profile.inputs["Name"].default_value = "o_balloon" - balloon = profile.outputs["Attribute"] - maximum = node(group, "GeometryNodeAttributeStatistic", (240, 850)) - maximum.data_type, maximum.domain = "FLOAT", "POINT" - links.new(volume, maximum.inputs["Geometry"]) - links.new(balloon, maximum.inputs["Attribute"]) - limit = math("MAXIMUM", maximum.outputs["Max"], 1e-8, (480, 850)) - falloff = node(group, "ShaderNodeMapRange", (720, 850)) - falloff.interpolation_type = "SMOOTHERSTEP" - falloff.inputs["To Min"].default_value = 1 - falloff.inputs["To Max"].default_value = 0 - links.new(balloon, falloff.inputs["Value"]) - links.new(limit, falloff.inputs["From Max"]) - weight = math("MULTIPLY", falloff.outputs[0], control.outputs["Edge Round"], (960, 850)) - center = read("center", (0, 1900)) - position = node(group, "GeometryNodeInputPosition", (0, 2250)) - axes = node(group, "ShaderNodeSeparateXYZ", (240, 2250)) - links.new(position.outputs[0], axes.inputs[0]) - target = math("ADD", center, balloon, (480, 1900)) - delta = math("SUBTRACT", target, axes.outputs["Z"], (720, 1900)) - delta = math("MULTIPLY", delta, weight, (960, 1900)) - enabled = math("MINIMUM", control.outputs["Thickness"], 1, (480, 1300)) - enabled = math("MAXIMUM", enabled, 0, (720, 1300)) - delta = math("MULTIPLY", delta, enabled, (1200, 1900)) - carrier = store(volume, "edge_weight", weight, (1200, 0)) - carrier = store(carrier, "front_delta", delta, (1440, 0)) - rear = math("MULTIPLY", balloon, weight, (1200, 850)) - rear = math("MULTIPLY", rear, control.outputs["Thickness"], (1440, 850)) - thickness = read("thickness", (1440, 1250)) - total = math("ADD", thickness, delta, (1680, 1250)) - total = math("ADD", total, rear, (1920, 1250)) - carrier = store(carrier, "thickness", total, (1920, 0)) - - # Evaluate normals on a separate center surface with identical point indices. - center_position = node(group, "ShaderNodeCombineXYZ", (480, -550)) - for axis in ("X", "Y"): - links.new(axes.outputs[axis], center_position.inputs[axis]) - links.new(center, center_position.inputs["Z"]) - surface = node(group, "GeometryNodeSetPosition", (720, -550)) - links.new(volume, surface.inputs["Geometry"]) - links.new(center_position.outputs[0], surface.inputs["Position"]) - normal = node(group, "GeometryNodeInputNormal", (720, -1000)) - blur = node(group, "GeometryNodeBlurAttribute", (960, -1000)) - blur.data_type = "FLOAT_VECTOR" - links.new(normal.outputs[0], blur.inputs["Value"]) - local = node(group, "NodeGroupInput", (720, -1300)) - links.new(local.outputs["Normal Smooth"], blur.inputs["Iterations"]) - normalize = node(group, "ShaderNodeVectorMath", (1200, -1000)) - normalize.operation = "NORMALIZE" - links.new(blur.outputs[0], normalize.inputs[0]) - index = node(group, "GeometryNodeInputIndex", (1200, -1350)) - sample = node(group, "GeometryNodeSampleIndex", (1440, -550)) - sample.data_type, sample.domain = "FLOAT_VECTOR", "POINT" - links.new(surface.outputs[0], sample.inputs["Geometry"]) - links.new(normalize.outputs[0], sample.inputs["Value"]) - links.new(index.outputs[0], sample.inputs["Index"]) - carrier = store(carrier, "normal", sample.outputs[0], (2160, 0), "FLOAT_VECTOR") - captured = read("front_delta", (2160, 850)) - displacement = node(group, "ShaderNodeCombineXYZ", (2400, 850)) - links.new(captured, displacement.inputs["Z"]) - front = node(group, "GeometryNodeSetPosition", (2640, 0)) - links.new(carrier, front.inputs["Geometry"]) - links.new(displacement.outputs[0], front.inputs["Offset"]) - shift_stage(group, previous, offset) - return front.outputs[0] - diff --git a/tools/node_assets/cutout_shape_layouts.py b/tools/node_assets/cutout_shape_layouts.py deleted file mode 100644 index e3703e5..0000000 --- a/tools/node_assets/cutout_shape_layouts.py +++ /dev/null @@ -1,141 +0,0 @@ -"""Authored local positions for Cutout shape stages.""" - -LAYOUTS = {'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)))), - 'UNIFORM': ({'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)))), - '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))))} diff --git a/tools/node_assets/cutout_shapes.py b/tools/node_assets/cutout_shapes.py deleted file mode 100644 index a4e8e8f..0000000 --- a/tools/node_assets/cutout_shapes.py +++ /dev/null @@ -1,571 +0,0 @@ -"""Construct the planar and depth-based Cutout shape stages.""" -from depth_surface import project_depth_surface, store_surface_camera, remove_surface_attributes, split_depth_surface, thicken_depth_surface -from anyimage.operators.cutout_tool.shape import BALLOON_ATTRIBUTE_NAME - - -def _socket(group, name, in_out, socket_type, parent=None): - socket = group.interface.new_socket( - name=name, - in_out=in_out, - socket_type=socket_type, - parent=parent, - ) - if in_out == "INPUT" and hasattr(socket, "structure_type"): - socket.structure_type = "SINGLE" - return socket - - -def _group_input(nodes, location, visible_names): - node = nodes.new("NodeGroupInput") - node.location = location - visible = set(visible_names) - for output in node.outputs: - output.hide = output.name not in visible - return node - - -def _float_input( - group, - name, - default, - minimum=0.0, - maximum=None, - subtype=None, - parent=None, -): - socket = _socket(group, name, "INPUT", "NodeSocketFloat", parent) - socket.default_value = default - socket.min_value = minimum - if maximum is not None: - socket.max_value = maximum - if subtype is not None: - socket.subtype = subtype - return socket - - -def _bool_input(group, name, default=False, parent=None): - socket = _socket(group, name, "INPUT", "NodeSocketBool", parent) - socket.default_value = default - return socket - - -def _vector_input(group, name, default, subtype=None, parent=None): - socket = _socket(group, name, "INPUT", "NodeSocketVector", parent) - socket.default_value = default - if subtype is not None: - socket.subtype = subtype - return socket - - -def _math(nodes, operation, location): - node = nodes.new("ShaderNodeMath") - node.operation = operation - node.location = location - return node - - -def _island_cleanup(group, geometry, cleanup, location): - nodes = group.nodes - links = group.links - x, y = location - mesh_island = nodes.new("GeometryNodeInputMeshIsland") - mesh_island.location = (x, y + 250.0) - face_area = nodes.new("GeometryNodeInputMeshFaceArea") - face_area.location = (x, y + 100.0) - island_area = nodes.new("GeometryNodeAccumulateField") - island_area.data_type = "FLOAT" - island_area.domain = "FACE" - island_area.location = (x + 220.0, y + 200.0) - links.new(face_area.outputs["Area"], island_area.inputs["Value"]) - links.new(mesh_island.outputs["Island Index"], island_area.inputs["Group ID"]) - - tiny = _math(nodes, "LESS_THAN", (x + 450.0, y + 200.0)) - links.new(island_area.outputs["Total"], tiny.inputs[0]) - links.new(cleanup, tiny.inputs[1]) - delete = nodes.new("GeometryNodeDeleteGeometry") - delete.domain = "FACE" - delete.location = (x + 700.0, y) - links.new(geometry, delete.inputs["Geometry"]) - links.new(tiny.outputs[0], delete.inputs["Selection"]) - return delete.outputs["Geometry"] - - -def _profile_field(group, geometry, location): - nodes = group.nodes - links = group.links - x, y = location - profile = nodes.new("GeometryNodeInputNamedAttribute") - profile.data_type = "FLOAT" - profile.inputs["Name"].default_value = BALLOON_ATTRIBUTE_NAME - profile.location = (x, y + 250.0) - profile_max = nodes.new("GeometryNodeAttributeStatistic") - profile_max.data_type = "FLOAT" - profile_max.domain = "POINT" - profile_max.location = (x + 220.0, y) - links.new(geometry, profile_max.inputs["Geometry"]) - links.new(profile.outputs["Attribute"], profile_max.inputs["Attribute"]) - safe_max = _math(nodes, "MAXIMUM", (x + 450.0, y)) - safe_max.inputs[1].default_value = 1e-6 - links.new(profile_max.outputs["Max"], safe_max.inputs[0]) - normalized = _math(nodes, "DIVIDE", (x + 450.0, y + 250.0)) - links.new(profile.outputs["Attribute"], normalized.inputs[0]) - links.new(safe_max.outputs[0], normalized.inputs[1]) - smooth = nodes.new("ShaderNodeMapRange") - smooth.data_type = "FLOAT" - smooth.interpolation_type = "SMOOTHERSTEP" - smooth.clamp = True - smooth.inputs["From Min"].default_value = 0.0 - smooth.inputs["From Max"].default_value = 1.0 - smooth.inputs["To Min"].default_value = 0.0 - smooth.inputs["To Max"].default_value = 1.0 - smooth.location = (x + 650.0, y + 250.0) - links.new(normalized.outputs[0], smooth.inputs["Value"]) - return profile.outputs["Attribute"], smooth.outputs["Result"] - - -def _extrude_layers(group, geometry, location): - node = group.nodes.new("GeometryNodeExtrudeMesh") - node.mode = "FACES" - node.inputs["Individual"].default_value = False - node.inputs["Offset"].default_value = (0.0, 0.0, 1.0) - # Keep distinct front and back vertices before overriding their Z positions. - node.inputs["Offset Scale"].default_value = 1.0 - node.location = location - group.links.new(geometry, node.inputs["Mesh"]) - return node - - -def _offset_geometry_z(group, geometry, z_value, location): - nodes = group.nodes - links = group.links - offset = nodes.new("ShaderNodeCombineXYZ") - offset.location = location - links.new(z_value, offset.inputs["Z"]) - set_position = nodes.new("GeometryNodeSetPosition") - set_position.location = (location[0] + 200.0, location[1]) - links.new(geometry, set_position.inputs["Geometry"]) - links.new(offset.outputs["Vector"], set_position.inputs["Offset"]) - return set_position.outputs["Geometry"] - - -def _set_layer_z(group, geometry, extrude, front_z, back_z, location): - nodes = group.nodes - links = group.links - choose_z = nodes.new("GeometryNodeSwitch") - choose_z.input_type = "FLOAT" - choose_z.location = (location[0], location[1] - 200.0) - links.new(extrude.outputs["Top"], choose_z.inputs["Switch"]) - links.new(front_z, choose_z.inputs["False"]) - links.new(back_z, choose_z.inputs["True"]) - position = nodes.new("GeometryNodeInputPosition") - position.location = (location[0], location[1] + 150.0) - separate = nodes.new("ShaderNodeSeparateXYZ") - separate.location = (location[0] + 200.0, location[1] + 150.0) - links.new(position.outputs["Position"], separate.inputs["Vector"]) - combine = nodes.new("ShaderNodeCombineXYZ") - combine.location = (location[0] + 400.0, location[1]) - links.new(separate.outputs["X"], combine.inputs["X"]) - links.new(separate.outputs["Y"], combine.inputs["Y"]) - links.new(choose_z.outputs["Output"], combine.inputs["Z"]) - set_position = nodes.new("GeometryNodeSetPosition") - set_position.location = (location[0] + 600.0, location[1]) - links.new(extrude.outputs["Mesh"], set_position.inputs["Geometry"]) - links.new(combine.outputs["Vector"], set_position.inputs["Position"]) - - side = nodes.new("GeometryNodeSeparateGeometry") - side.domain = "FACE" - side.location = (location[0] + 850.0, location[1] + 200.0) - links.new(set_position.outputs["Geometry"], side.inputs["Geometry"]) - links.new(extrude.outputs["Side"], side.inputs["Selection"]) - flip_side = nodes.new("GeometryNodeFlipFaces") - flip_side.location = (location[0] + 850.0, location[1] + 400.0) - links.new(side.outputs["Selection"], flip_side.inputs["Mesh"]) - front = _offset_geometry_z( - group, geometry, front_z, (location[0], location[1] - 450.0) - ) - back = _offset_geometry_z( - group, geometry, back_z, (location[0], location[1] - 700.0) - ) - flip_back = nodes.new("GeometryNodeFlipFaces") - flip_back.location = (location[0] + 500.0, location[1] - 700.0) - links.new(back, flip_back.inputs["Mesh"]) - - join = nodes.new("GeometryNodeJoinGeometry") - join.location = (location[0] + 1100.0, location[1]) - links.new(front, join.inputs["Geometry"]) - links.new(flip_back.outputs["Mesh"], join.inputs["Geometry"]) - links.new(flip_side.outputs["Mesh"], join.inputs["Geometry"]) - merge = nodes.new("GeometryNodeMergeByDistance") - merge.mode = "ALL" - merge.inputs["Distance"].default_value = 1e-6 - merge.location = (location[0] + 1300.0, location[1]) - links.new(join.outputs["Geometry"], merge.inputs["Geometry"]) - return merge.outputs["Geometry"] - - -def _set_segmented_layer_z( - group, - geometry, - front_z, - back_z, - thickness, - location, -): - nodes = group.nodes - links = group.links - x, y = location - - edge_vertices = nodes.new("GeometryNodeInputMeshEdgeVertices") - edge_vertices.location = (x, y + 850.0) - edge_length = nodes.new("ShaderNodeVectorMath") - edge_length.operation = "DISTANCE" - edge_length.location = (x + 220.0, y + 850.0) - links.new(edge_vertices.outputs["Position 1"], edge_length.inputs[0]) - links.new(edge_vertices.outputs["Position 2"], edge_length.inputs[1]) - edge_neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors") - edge_neighbors.location = (x, y + 650.0) - boundary = _math(nodes, "COMPARE", (x + 220.0, y + 650.0)) - boundary.inputs[1].default_value = 1.0 - boundary.inputs[2].default_value = 0.1 - links.new(edge_neighbors.outputs["Face Count"], boundary.inputs[0]) - statistics = nodes.new("GeometryNodeAttributeStatistic") - statistics.data_type = "FLOAT" - statistics.domain = "EDGE" - statistics.location = (x + 450.0, y + 750.0) - links.new(geometry, statistics.inputs["Geometry"]) - links.new(boundary.outputs[0], statistics.inputs["Selection"]) - links.new(edge_length.outputs["Value"], statistics.inputs["Attribute"]) - safe_edge = _math(nodes, "MAXIMUM", (x + 680.0, y + 750.0)) - safe_edge.inputs[1].default_value = 1e-6 - links.new(statistics.outputs["Median"], safe_edge.inputs[0]) - segment_ratio = _math(nodes, "DIVIDE", (x + 900.0, y + 750.0)) - links.new(thickness, segment_ratio.inputs[0]) - links.new(safe_edge.outputs[0], segment_ratio.inputs[1]) - segment_count = nodes.new("FunctionNodeFloatToInt") - segment_count.rounding_mode = "CEILING" - segment_count.location = (x + 1120.0, y + 750.0) - links.new(segment_ratio.outputs[0], segment_count.inputs["Float"]) - at_least_one = nodes.new("FunctionNodeIntegerMath") - at_least_one.operation = "MAXIMUM" - at_least_one.inputs[1].default_value = 1 - at_least_one.location = (x + 1340.0, y + 750.0) - links.new(segment_count.outputs["Integer"], at_least_one.inputs[0]) - segment_limit = nodes.new("FunctionNodeIntegerMath") - segment_limit.operation = "MINIMUM" - segment_limit.inputs[1].default_value = 128 - segment_limit.location = (x + 1560.0, y + 750.0) - links.new(at_least_one.outputs[0], segment_limit.inputs[0]) - step = _math(nodes, "DIVIDE", (x + 1560.0, y + 500.0)) - links.new(thickness, step.inputs[0]) - links.new(segment_limit.outputs[0], step.inputs[1]) - - front = _offset_geometry_z(group, geometry, front_z, (x, y + 100.0)) - repeat_output = nodes.new("GeometryNodeRepeatOutput") - repeat_output.location = (x + 2380.0, y + 100.0) - repeat_input = nodes.new("GeometryNodeRepeatInput") - repeat_input.pair_with_output(repeat_output) - repeat_output.repeat_items.new("GEOMETRY", "Geometry") - repeat_output.repeat_items.new("GEOMETRY", "Sides") - repeat_input.location = (x + 1560.0, y + 100.0) - links.new(segment_limit.outputs[0], repeat_input.inputs["Iterations"]) - links.new(geometry, repeat_input.inputs["Geometry"]) - negative_step = _math(nodes, "MULTIPLY", (x + 1760.0, y - 100.0)) - negative_step.inputs[1].default_value = -1.0 - links.new(step.outputs[0], negative_step.inputs[0]) - extrude = nodes.new("GeometryNodeExtrudeMesh") - extrude.mode = "FACES" - extrude.inputs["Individual"].default_value = False - extrude.inputs["Selection"].default_value = True - extrude.inputs["Offset"].default_value = (0.0, 0.0, 0.0) - extrude.location = (x + 1960.0, y + 100.0) - links.new(negative_step.outputs[0], extrude.inputs["Offset Scale"]) - links.new(repeat_input.outputs["Geometry"], extrude.inputs["Mesh"]) - split_extrude = nodes.new("GeometryNodeSeparateGeometry") - split_extrude.domain = "FACE" - split_extrude.location = (x + 2160.0, y + 100.0) - links.new(extrude.outputs["Mesh"], split_extrude.inputs["Geometry"]) - links.new(extrude.outputs["Side"], split_extrude.inputs["Selection"]) - join_sides = nodes.new("GeometryNodeJoinGeometry") - join_sides.location = (x + 2160.0, y - 150.0) - links.new(repeat_input.outputs["Sides"], join_sides.inputs["Geometry"]) - links.new(split_extrude.outputs["Selection"], join_sides.inputs["Geometry"]) - links.new(split_extrude.outputs["Inverted"], repeat_output.inputs["Geometry"]) - links.new(join_sides.outputs["Geometry"], repeat_output.inputs["Sides"]) - - positioned_side = _offset_geometry_z( - group, - repeat_output.outputs["Sides"], - front_z, - (x + 2450.0, y + 350.0), - ) - flip_side = nodes.new("GeometryNodeFlipFaces") - flip_side.location = (x + 2850.0, y + 350.0) - links.new(positioned_side, flip_side.inputs["Mesh"]) - - back = _offset_geometry_z(group, geometry, back_z, (x + 2450.0, y - 400.0)) - flip_back = nodes.new("GeometryNodeFlipFaces") - flip_back.location = (x + 2850.0, y - 400.0) - links.new(back, flip_back.inputs["Mesh"]) - join = nodes.new("GeometryNodeJoinGeometry") - join.location = (x + 3050.0, y + 50.0) - links.new(front, join.inputs["Geometry"]) - links.new(flip_back.outputs["Mesh"], join.inputs["Geometry"]) - links.new(flip_side.outputs["Mesh"], join.inputs["Geometry"]) - merge = nodes.new("GeometryNodeMergeByDistance") - merge.mode = "ALL" - merge.inputs["Distance"].default_value = 1e-6 - merge.location = (x + 3250.0, y + 50.0) - links.new(join.outputs["Geometry"], merge.inputs["Geometry"]) - return merge.outputs["Geometry"] - - -def _set_geometry_z(group, geometry, z_value, location): - nodes = group.nodes - links = group.links - position = nodes.new("GeometryNodeInputPosition") - position.location = location - separate = nodes.new("ShaderNodeSeparateXYZ") - separate.location = (location[0] + 200.0, location[1]) - links.new(position.outputs["Position"], separate.inputs["Vector"]) - combine = nodes.new("ShaderNodeCombineXYZ") - combine.location = (location[0] + 400.0, location[1]) - links.new(separate.outputs["X"], combine.inputs["X"]) - links.new(separate.outputs["Y"], combine.inputs["Y"]) - links.new(z_value, combine.inputs["Z"]) - set_position = nodes.new("GeometryNodeSetPosition") - set_position.location = (location[0] + 600.0, location[1]) - links.new(geometry, set_position.inputs["Geometry"]) - links.new(combine.outputs["Vector"], set_position.inputs["Position"]) - return set_position.outputs["Geometry"] - - -def _remove_zero_thickness( - group, - zero_geometry, - shaped_geometry, - thickness, - location, -): - nodes = group.nodes - links = group.links - zero = _math(nodes, "LESS_THAN", (location[0], location[1] - 200.0)) - zero.inputs[1].default_value = 1e-6 - links.new(thickness, zero.inputs[0]) - choose = nodes.new("GeometryNodeSwitch") - choose.input_type = "GEOMETRY" - choose.location = location - links.new(zero.outputs[0], choose.inputs["Switch"]) - links.new(shaped_geometry, choose.inputs["False"]) - links.new(zero_geometry, choose.inputs["True"]) - return choose.outputs["Output"] - - -def _position_field(group, depth_image, location): - nodes = group.nodes - links = group.links - uv = nodes.new("GeometryNodeInputNamedAttribute") - uv.data_type = "FLOAT_VECTOR" - uv.inputs["Name"].default_value = "UVMap" - uv.location = location - position_texture = nodes.new("GeometryNodeImageTexture") - position_texture.interpolation = "Linear" - position_texture.extension = "EXTEND" - position_texture.location = (location[0] + 220.0, location[1]) - links.new(depth_image, position_texture.inputs["Image"]) - links.new(uv.outputs["Attribute"], position_texture.inputs["Vector"]) - return position_texture.outputs["Color"] - - -def _plane_depth(group, direction, reference_depth, location): - nodes = group.nodes - links = group.links - normal = nodes.new("ShaderNodeVectorMath") - normal.operation = "NORMALIZE" - normal.location = location - links.new(direction, normal.inputs[0]) - normal_components = nodes.new("ShaderNodeSeparateXYZ") - normal_components.location = (location[0] + 220.0, location[1]) - links.new(normal.outputs["Vector"], normal_components.inputs["Vector"]) - position = nodes.new("GeometryNodeInputPosition") - position.location = (location[0], location[1] + 450.0) - position_components = nodes.new("ShaderNodeSeparateXYZ") - position_components.location = (location[0] + 220.0, location[1] + 450.0) - links.new(position.outputs["Position"], position_components.inputs["Vector"]) - x_term = _math(nodes, "MULTIPLY", (location[0] + 440.0, location[1] + 850.0)) - links.new(position_components.outputs["X"], x_term.inputs[0]) - links.new(normal_components.outputs["X"], x_term.inputs[1]) - y_term = _math(nodes, "MULTIPLY", (location[0] + 440.0, location[1] + 350.0)) - links.new(position_components.outputs["Y"], y_term.inputs[0]) - links.new(normal_components.outputs["Y"], y_term.inputs[1]) - point_dot = _math(nodes, "ADD", (location[0] + 660.0, location[1] + 400.0)) - links.new(x_term.outputs[0], point_dot.inputs[0]) - links.new(y_term.outputs[0], point_dot.inputs[1]) - divide = _math(nodes, "DIVIDE", (location[0] + 880.0, location[1] + 200.0)) - links.new(point_dot.outputs[0], divide.inputs[0]) - links.new(normal_components.outputs["Z"], divide.inputs[1]) - plane = _math(nodes, "ADD", (location[0] + 1100.0, location[1] + 200.0)) - links.new(reference_depth, plane.inputs[0]) - links.new(divide.outputs[0], plane.inputs[1]) - return plane.outputs[0] - - -def _shade_output(group, geometry, location): - shade = group.nodes.new("GeometryNodeSetShadeSmooth") - shade.domain = "FACE" - shade.inputs["Shade Smooth"].default_value = True - shade.location = location - output = group.nodes.new("NodeGroupOutput") - output.location = (location[0] + 250.0, location[1]) - group.links.new(geometry, shade.inputs["Geometry"]) - group.links.new(shade.outputs["Geometry"], output.inputs["Geometry"]) - - -def _build_cutout(group, geometry, thickness): - nodes = group.nodes - links = group.links - half = _math(nodes, "MULTIPLY", (-200.0, 50.0)) - half.inputs[1].default_value = 0.5 - links.new(thickness, half.inputs[0]) - back = _math(nodes, "MULTIPLY", (20.0, 0.0)) - back.inputs[1].default_value = -1.0 - links.new(half.outputs[0], back.inputs[0]) - shaped = _set_segmented_layer_z( - group, - geometry, - half.outputs[0], - back.outputs[0], - thickness, - (250.0, 300.0), - ) - return _remove_zero_thickness(group, geometry, shaped, thickness, (3750.0, 300.0)) - - -def _build_balloon(group, geometry, thickness): - nodes = group.nodes - links = group.links - profile, _profile_factor = _profile_field(group, geometry, (-200.0, 0.0)) - front_scale = _math(nodes, "MULTIPLY", (2600.0, 500.0)) - links.new(profile, front_scale.inputs[0]) - links.new(thickness, front_scale.inputs[1]) - back = _math(nodes, "MULTIPLY", (2600.0, 300.0)) - links.new(profile, back.inputs[0]) - links.new(thickness, back.inputs[1]) - negative_back = _math(nodes, "MULTIPLY", (2800.0, 300.0)) - negative_back.inputs[1].default_value = -1.0 - links.new(back.outputs[0], negative_back.inputs[0]) - extrude = _extrude_layers(group, geometry, (2400.0, 750.0)) - shaped = _set_layer_z( - group, - geometry, - extrude, - front_scale.outputs[0], - negative_back.outputs[0], - (2850.0, 650.0), - ) - return _remove_zero_thickness(group, geometry, shaped, thickness, (4400.0, 650.0)) - - -def _build_depth_balloon( - group, - geometry, - thickness, - depth_scale, - double_sided, - uniform_scale, - depth_direction, - depth_image, - depth_reference, -): - nodes = group.nodes - links = group.links - profile, profile_factor = _profile_field(group, geometry, (-200.0, 250.0)) - camera_position = _position_field(group, depth_image, (200.0, -250.0)) - separate_camera = nodes.new("ShaderNodeSeparateXYZ") - separate_camera.location = (650.0, -250.0) - links.new(camera_position, separate_camera.inputs["Vector"]) - metric_depth = _math(nodes, "MULTIPLY", (900.0, -250.0)) - links.new(separate_camera.outputs["Z"], metric_depth.inputs[0]) - links.new(uniform_scale, metric_depth.inputs[1]) - plane_depth = _plane_depth( - group, - depth_direction, - depth_reference, - (600.0, -850.0), - ) - corrected_depth = _math(nodes, "SUBTRACT", (1750.0, -250.0)) - links.new(plane_depth, corrected_depth.inputs[0]) - links.new(metric_depth.outputs[0], corrected_depth.inputs[1]) - nonnegative_depth = _math(nodes, "MAXIMUM", (1950.0, -250.0)) - nonnegative_depth.inputs[1].default_value = 0.0 - links.new(corrected_depth.outputs[0], nonnegative_depth.inputs[0]) - scaled_depth = _math(nodes, "MULTIPLY", (2150.0, -250.0)) - links.new(nonnegative_depth.outputs[0], scaled_depth.inputs[0]) - links.new(depth_scale, scaled_depth.inputs[1]) - front_depth = scaled_depth.outputs[0] - base_front = _math(nodes, "MULTIPLY", (2400.0, 500.0)) - links.new(profile, base_front.inputs[0]) - links.new(thickness, base_front.inputs[1]) - base_back = _math(nodes, "MULTIPLY", (2600.0, 300.0)) - links.new(profile, base_back.inputs[0]) - links.new(thickness, base_back.inputs[1]) - negate_back = _math(nodes, "MULTIPLY", (2800.0, 300.0)) - negate_back.inputs[1].default_value = -1.0 - links.new(base_back.outputs[0], negate_back.inputs[0]) - front_mix = nodes.new("ShaderNodeMix") - front_mix.data_type = "FLOAT" - front_mix.clamp_factor = True - front_mix.location = (2600.0, 50.0) - links.new(profile_factor, front_mix.inputs["Factor"]) - links.new(base_front.outputs[0], front_mix.inputs["A"]) - links.new(front_depth, front_mix.inputs["B"]) - mirrored_front = _math(nodes, "MULTIPLY", (2850.0, -150.0)) - mirrored_front.inputs[1].default_value = -1.0 - links.new(front_mix.outputs["Result"], mirrored_front.inputs[0]) - back_choice = nodes.new("GeometryNodeSwitch") - back_choice.input_type = "FLOAT" - back_choice.location = (3050.0, 100.0) - links.new(double_sided, back_choice.inputs["Switch"]) - links.new(negate_back.outputs[0], back_choice.inputs["False"]) - links.new(mirrored_front.outputs[0], back_choice.inputs["True"]) - extrude = _extrude_layers(group, geometry, (2400.0, 750.0)) - shaped = _set_layer_z( - group, - geometry, - extrude, - front_mix.outputs["Result"], - back_choice.outputs["Output"], - (3300.0, 650.0), - ) - front_only = _set_geometry_z( - group, geometry, front_mix.outputs["Result"], (3500.0, 100.0) - ) - return _remove_zero_thickness(group, front_only, shaped, thickness, (4850.0, 600.0)) - - -def _build_depth_surface( - group, - geometry, - thickness, - depth_scale, - split, - uniform_scale, - reference_depth, - depth_image, -): - nodes = group.nodes - links = group.links - geometry = split_depth_surface( - group, geometry, depth_image, split, uniform_scale - ) - original_camera = _position_field(group, depth_image, (-200.0, -200.0)) - geometry, camera_position = store_surface_camera( - group, geometry, original_camera, split - ) - geometry = project_depth_surface( - group, geometry, camera_position, uniform_scale, reference_depth, depth_scale - ) - shaped = thicken_depth_surface(group, geometry, thickness) - geometry = _remove_zero_thickness( - group, geometry, shaped, thickness, (11800.0, 400.0) - ) - return remove_surface_attributes(group, geometry) diff --git a/tools/node_assets/cutout_shell.py b/tools/node_assets/cutout_shell.py deleted file mode 100644 index ae397e2..0000000 --- a/tools/node_assets/cutout_shell.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Continue a front-normal thin shell into the rounded depth volume.""" -from cutout_inflation import build_inflation_position -from cutout_volume import node, shift_stage - - -def build_front_normals(group, geometry): - links = group.links - normal = node(group, "GeometryNodeInputNormal", (3400, -650)) - control = node(group, "NodeGroupInput", (3400, -950)) - blur = node(group, "GeometryNodeBlurAttribute", (3640, -650)) - blur.data_type = "FLOAT_VECTOR" - links.new(normal.outputs[0], blur.inputs["Value"]) - links.new(control.outputs["Normal Smooth"], blur.inputs["Iterations"]) - normalize = node(group, "ShaderNodeVectorMath", (3880, -650)) - normalize.operation = "NORMALIZE" - links.new(blur.outputs[0], normalize.inputs[0]) - store = node(group, "GeometryNodeStoreNamedAttribute", (4110, 0)) - store.data_type, store.domain = "FLOAT_VECTOR", "POINT" - store.inputs["Name"].default_value = "o_balloon_surface_front_normal" - links.new(geometry, store.inputs["Geometry"]) - links.new(normalize.outputs[0], store.inputs["Value"]) - return store.outputs[0] - - -def build_shell_position(group, position, uniform, offset): - previous = set(group.nodes) - links = group.links - - def connect(value, target): - if hasattr(value, "node"): - links.new(value, target) - else: - target.default_value = value - - def math(operation, a, b, location): - result = node(group, "ShaderNodeMath", location) - result.operation = operation - connect(a, result.inputs[0]) - connect(b, result.inputs[1]) - return result.outputs[0] - - def vector(operation, a, b, location): - result = node(group, "ShaderNodeVectorMath", location) - result.operation = operation - connect(a, result.inputs[0]) - connect(b, result.inputs[3 if operation == "SCALE" else 1]) - return result.outputs[0] - - def read(name, location, kind="FLOAT"): - result = node(group, "GeometryNodeInputNamedAttribute", location) - result.data_type = kind - result.inputs["Name"].default_value = name - return result.outputs["Attribute"] - - def z(value, location): - result = node(group, "ShaderNodeSeparateXYZ", location) - links.new(value, result.inputs[0]) - return result.outputs["Z"] - - # Uniform and the thin end of Balloon share the actual front normal. - front = read("o_depth_surface_position", (0, 0), "FLOAT_VECTOR") - front_z = z(front, (240, 0)) - raw_z = z(position, (240, 250)) - distance = math("SUBTRACT", raw_z, front_z, (480, 250)) - normal = read("o_balloon_surface_front_normal", (480, 550), "FLOAT_VECTOR") - thin_offset = vector("SCALE", normal, distance, (720, 550)) - uniform_position = vector("ADD", front, thin_offset, (3120, 550)) - - # R = t * (front_z - center) + (1 - t) * front_delta, with t clamped. - # This avoids division by Thickness and removes the old zero-thickness drift. - control = node(group, "NodeGroupInput", (0, -400)) - clamp = node(group, "ShaderNodeClamp", (240, -400)) - links.new(control.outputs["Thickness"], clamp.inputs["Value"]) - t = clamp.outputs[0] - center = read("o_balloon_surface_center", (0, -750)) - delta = read("o_balloon_surface_front_delta", (480, -1050)) - radius = math("SUBTRACT", front_z, center, (480, -750)) - radius = math("MULTIPLY", radius, t, (720, -750)) - remaining = math("SUBTRACT", 1, t, (480, -400)) - remainder = math("MULTIPLY", remaining, delta, (720, -1050)) - radius = math("ADD", radius, remainder, (960, -750)) - - # Front and rear use the same edge field, evaluated on their own caps. - edge = read("o_balloon_surface_edge_weight", (0, -1450)) - mask = read("o_normal_scale", (240, -1800)) - remaining = math("SUBTRACT", 1, edge, (240, -1450)) - remaining = math("MULTIPLY", remaining, mask, (480, -1450)) - weight = math("SUBTRACT", 1, remaining, (720, -1450)) - difference = math("SUBTRACT", weight, edge, (960, -1450)) - base = math("MULTIPLY", difference, radius, (1200, -1450)) - layer = math("MULTIPLY", weight, distance, (1200, -1800)) - rear_radius = math("ADD", base, layer, (1440, -1450)) - front_radius = math("MULTIPLY", edge, radius, (1440, -750)) - normal = read("o_balloon_surface_normal", (1200, -300), "FLOAT_VECTOR") - direction = vector("SUBTRACT", normal, (0, 0, 1), (1440, -300)) - front_shift = vector("SCALE", direction, front_radius, (1680, -750)) - front_position = vector("ADD", front, front_shift, (1920, -750)) - rear_shift = vector("SCALE", direction, rear_radius, (1680, -1450)) - axis_offset = node(group, "ShaderNodeCombineXYZ", (1680, -1800)) - links.new(distance, axis_offset.inputs["Z"]) - full_offset = vector("ADD", axis_offset.outputs[0], rear_shift, (1920, -1450)) - - # Smoothly reach the existing full-volume shape at Thickness >= 1. - transition = node(group, "ShaderNodeMapRange", (1920, 0)) - transition.interpolation_type = "SMOOTHERSTEP" - links.new(t, transition.inputs["Value"]) - mix = node(group, "ShaderNodeMix", (2400, -1100)) - mix.data_type = "VECTOR" - links.new(transition.outputs[0], mix.inputs[0]) - links.new(thin_offset, mix.inputs[4]) - links.new(full_offset, mix.inputs[5]) - balloon_position = vector("ADD", front_position, mix.outputs["Result"], (3120, -750)) - choose = node(group, "GeometryNodeSwitch", (3400, 0)) - choose.input_type = "VECTOR" - links.new(uniform, choose.inputs["Switch"]) - links.new(balloon_position, choose.inputs["False"]) - links.new(uniform_position, choose.inputs["True"]) - for item in set(group.nodes) - previous: - item.location.x *= 0.9 - item.location.y *= 0.65 - shift_stage(group, previous, offset) - return build_inflation_position(group, choose.outputs[0], distance, uniform) diff --git a/tools/node_assets/cutout_volume.py b/tools/node_assets/cutout_volume.py deleted file mode 100644 index ff68a6f..0000000 --- a/tools/node_assets/cutout_volume.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Build the smooth center field for depth volume.""" -from node_graph import edge_boundary_field - - -def node(group, kind, location): - result = group.nodes.new(kind) - result.location = location - return result - - -def shift_stage(group, previous, offset): - for item in group.nodes: - if item not in previous: - item.location.x += offset[0] - item.location.y += offset[1] - - -def build_volume_fields(group, geometry, offset=(0, 0)): - previous = set(group.nodes) - boundary = edge_boundary_field(group.nodes, group.links, (0, -400)) - store = node(group, "GeometryNodeStoreNamedAttribute", (860, 0)) - store.domain, store.data_type = "POINT", "FLOAT" - store.inputs["Name"].default_value = "o_balloon_surface_rim" - group.links.new(geometry, store.inputs["Geometry"]) - group.links.new(boundary, store.inputs["Value"]) - profile = node(group, "GeometryNodeInputNamedAttribute", (860, 700)) - profile.data_type = "FLOAT" - profile.inputs["Name"].default_value = "o_balloon" - links = group.links - def math(operation, a, b, location): - result = node(group, "ShaderNodeMath", location) - result.operation = operation - for index, value in enumerate((a, b)): - if isinstance(value, (int, float)): - result.inputs[index].default_value = value - else: - links.new(value, result.inputs[index]) - return result.outputs[0] - position = node(group, "GeometryNodeInputPosition", (860, 1100)) - axes = node(group, "ShaderNodeSeparateXYZ", (1100, 1100)) - links.new(position.outputs[0], axes.inputs[0]) - seed = math("SUBTRACT", axes.outputs["Z"], profile.outputs["Attribute"], (1340, 900)) - center = node(group, "GeometryNodeStoreNamedAttribute", (1340, 0)) - center.domain, center.data_type = "POINT", "FLOAT" - center.inputs["Name"].default_value = "o_balloon_surface_center" - links.new(store.outputs["Geometry"], center.inputs["Geometry"]) - links.new(seed, center.inputs["Value"]) - current = node(group, "GeometryNodeInputNamedAttribute", (1660, -330)) - current.data_type = "FLOAT" - current.inputs["Name"].default_value = "o_balloon_surface_center" - rim = node(group, "GeometryNodeInputNamedAttribute", (1660, -660)) - rim.data_type = "FLOAT" - rim.inputs["Name"].default_value = "o_balloon_surface_rim" - interior = math("SUBTRACT", 1, rim.outputs["Attribute"], (1890, -660)) - blur = node(group, "GeometryNodeBlurAttribute", (2120, -330)) - blur.data_type = "FLOAT" - blur.inputs["Iterations"].default_value = 256 - links.new(current.outputs["Attribute"], blur.inputs["Value"]) - links.new(interior, blur.inputs["Weight"]) - update = node(group, "GeometryNodeStoreNamedAttribute", (2370, 0)) - update.domain, update.data_type = "POINT", "FLOAT" - update.inputs["Name"].default_value = "o_balloon_surface_center" - links.new(center.outputs["Geometry"], update.inputs["Geometry"]) - links.new(blur.outputs[0], update.inputs["Value"]) - baseline = node(group, "GeometryNodeInputNamedAttribute", (2940, 1000)) - baseline.data_type = "FLOAT" - baseline.inputs["Name"].default_value = "o_balloon_surface_center" - profile_end = node(group, "GeometryNodeInputNamedAttribute", (2940, 700)) - profile_end.data_type = "FLOAT" - profile_end.inputs["Name"].default_value = "o_balloon" - position_end = node(group, "GeometryNodeInputPosition", (2940, 1360)) - axes_end = node(group, "ShaderNodeSeparateXYZ", (3180, 1360)) - links.new(position_end.outputs[0], axes_end.inputs[0]) - residual = math("SUBTRACT", axes_end.outputs["Z"], baseline.outputs["Attribute"], (3420, 1000)) - full = math("ADD", residual, profile_end.outputs["Attribute"], (3660, 1000)) - minimum = math("MULTIPLY", profile_end.outputs["Attribute"], 0.05, (3420, 700)) - positive = math("MAXIMUM", full, minimum, (3900, 1000)) - control = node(group, "NodeGroupInput", (3900, 700)) - scaled = math("MULTIPLY", positive, control.outputs["Thickness"], (4140, 1000)) - thickness = node(group, "GeometryNodeStoreNamedAttribute", (4140, 0)) - thickness.domain, thickness.data_type = "POINT", "FLOAT" - thickness.inputs["Name"].default_value = "o_balloon_surface_thickness" - group.links.new(update.outputs["Geometry"], thickness.inputs["Geometry"]) - group.links.new(scaled, thickness.inputs["Value"]) - shift_stage(group, previous, offset) - return thickness.outputs["Geometry"] - - diff --git a/tools/node_assets/depth_cutout_layout.py b/tools/node_assets/depth_cutout_layout.py deleted file mode 100644 index ccd7095..0000000 --- a/tools/node_assets/depth_cutout_layout.py +++ /dev/null @@ -1,145 +0,0 @@ -"""Arrange staggered calculation branches on both sides of the geometry path.""" - -from cutout_layout import route_geometry - -# 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": (-240, 110), - "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": (-440, 380), - "Evaluate on Domain": (-220, 380), - "Image Texture": (20, 380), - "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": (190, 1040), - "Math.004": (190, 790), - "Math.005": (370, 980), - "Math.006": (190, 620), - "Math.007": (550, 910), - "Edge Neighbors": (370, 590), - "Math.008": (550, 590), - "Math.009": (730, 810), - "Evaluate on Domain.001": (730, 670), - "Evaluate on Domain.002": (910, 670), - "Math.010": (1090, 670), - "Math.011": (1360, 280), - # 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.014": (1960, -440), - "Vector Math.001": (2180, -440), - "Named Attribute.006": (1740, -940), - "Math.012": (1510, -940), - "Math.013": (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.015": (3100, 730), - "Math.016": (3280, 730), - "Math.017": (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), - # Front and rear caps share a single extrusion along the geometry axis. - "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), - "Extrude Mesh": (5500, 0), - "Combine XYZ.003": (5260, 350), - "Separate Geometry": (5780, 0), - "Flip Faces.001": (6300, 0), - "Join Geometry": (6970, 0), - "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), - "Math.018": (7380, -750), - "Combine XYZ.005": (7600, -680), - "Named Attribute.010": (7600, -370), - "Vector Math.004": (7830, -340), - "Set Position.004": (8080, 0), - "Math.019": (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",), (30, 520)), - (("Math.002",), (-350, 1140)), - (("Math.003",), (10, 1140)), - (("Math.011",), (1100, 320)), - (("Image Texture.001",), (1290, -270)), - (("Math.012",), (1290, -940)), - (("Vector Math.002",), (2340, 1200)), - (("Math.015", "Math.016"), (2920, 680)), - (("Mix",), (2920, 900)), - (("Math.019",), (8080, 300)), -) - - -def layout_geometry_references(group, stage): - """Separate the split bypass and long projection references from fields.""" - links = group.links - entry = stage["Store Named Attribute"].inputs["Geometry"] - source = entry.links[0].from_socket - fork = route_geometry(group, source, [(-3250, -40), (-1450, -40), (-290, -40)]) - links.new(fork, entry) - bypass = route_geometry(group, fork, [(-210, -280), (500, -280), (1100, -280)]) - links.new(bypass, stage["Switch"].inputs["False"]) - - projection = next(n.outputs["Geometry"] for n in group.nodes if n.bl_idname == "GeometryNodeStoreNamedAttribute" and n.inputs["Name"].default_value == "o_balloon_surface_front_normal") - shared = next(n.outputs["Geometry"] for n in group.nodes if n.bl_idname == "GeometryNodeStoreNamedAttribute" and n.inputs["Name"].default_value == "o_balloon_surface_inflation_normal") - uniform = next(link.to_socket for link in shared.links if link.to_node.bl_idname == "GeometryNodeStoreNamedAttribute" and link.to_node.inputs["Name"].default_value == "o_balloon_surface_thickness") - fork = route_geometry(group, projection, [(4360, -40), (4650, -1700), (8500, -1700), (11400, -1700)]) - uniform_route = route_geometry(group, shared, [(8250, -40), (8460, -1450), (11400, -1450), (11420, -370)]) - links.new(uniform_route, uniform) - surface_route = route_geometry(group, fork, [(13500, -1700), (16800, -1700)]) - links.new(surface_route, stage["Switch.002"].inputs["True"]) diff --git a/tools/node_assets/depth_plane_camera.py b/tools/node_assets/depth_plane_camera.py deleted file mode 100644 index 724e75f..0000000 --- a/tools/node_assets/depth_plane_camera.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Build the camera-projected branch of the complete-image depth plane.""" - -from node_graph import group_input as _group_input -from depth_surface import project_depth_surface, thicken_depth_surface, remove_surface_attributes - - -def build_camera_plane(group, image_plane_group): - nodes, links = group.nodes, group.links - controls = _group_input(nodes, (-300, 0), {"Geometry", "Subdivide"}) - plane = nodes.new("GeometryNodeGroup") - plane.node_tree = image_plane_group - plane.inputs["Thickness"].default_value = 0 - links.new(controls.outputs["Geometry"], plane.inputs["Geometry"]) - links.new(controls.outputs["Subdivide"], plane.inputs["Subdivide"]) - texture_input = _group_input(nodes, (-300, 700), {"Depth Image", "Uniform Scale"}) - uv = nodes.new("GeometryNodeInputNamedAttribute") - uv.data_type = "FLOAT_VECTOR" - uv.inputs["Name"].default_value = "UVMap" - texture = nodes.new("GeometryNodeImageTexture") - texture.interpolation, texture.extension = "Linear", "EXTEND" - links.new(uv.outputs["Attribute"], texture.inputs["Vector"]) - links.new(texture_input.outputs["Depth Image"], texture.inputs["Image"]) - depth_input = _group_input(nodes, (500, 1000), {"Reference Depth", "Depth Scale"}) - surface = project_depth_surface( - group, plane.outputs["Geometry"], texture.outputs["Color"], - texture_input.outputs["Uniform Scale"], depth_input.outputs["Reference Depth"], - depth_input.outputs["Depth Scale"], - ) - shell_input = _group_input(nodes, (2000, 500), {"Camera Thickness", "Normal Smooth"}) - shell = thicken_depth_surface( - group, surface, shell_input.outputs["Camera Thickness"], - normal_smooth=shell_input.outputs["Normal Smooth"], - ) - positive = nodes.new("ShaderNodeMath") - positive.operation = "GREATER_THAN" - links.new(shell_input.outputs["Camera Thickness"], positive.inputs[0]) - positive.inputs[1].default_value = 0 - choose = nodes.new("GeometryNodeSwitch") - choose.input_type = "GEOMETRY" - links.new(positive.outputs[0], choose.inputs["Switch"]) - links.new(surface, choose.inputs["False"]) - links.new(shell, choose.inputs["True"]) - clean = remove_surface_attributes(group, choose.outputs["Output"]) - return clean diff --git a/tools/node_assets/depth_plane_layout.py b/tools/node_assets/depth_plane_layout.py deleted file mode 100644 index 89749ee..0000000 --- a/tools/node_assets/depth_plane_layout.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Place projection fields above the rectangular surface and shell stages.""" - -from cutout_layout import route_geometry, layout_stage - - -def layout_camera_plane(group, previous): - stage = layout_stage(group, previous, { - "Group": (0, 0), - "Named Attribute": (-240, 760), - "Image Texture": (0, 760), - "Vector Math": (220, 760), - "Separate XYZ": (440, 760), - "Math": (660, 980), - "Math.001": (1320, 980), - "Math.002": (660, 620), - "Combine XYZ": (880, 760), - "Position": (880, 420), - "Mix": (1100, 760), - "Separate XYZ.001": (1320, 760), - "Combine XYZ.001": (1540, 880), - "Set Position": (1760, 0), - "Position.001": (1760, 400), - "Store Named Attribute": (2020, 0), - "Vector Math.001": (2020, 400), - "Set Position.001": (2280, 0), - "Index": (2280, 400), - "Store Named Attribute.001": (2540, 0), - "Combine XYZ.002": (3970, 820), - "Integer Math": (3750, 1220), - "Named Attribute.001": (3500, 1220), - "Integer Math.001": (3750, 980), - "Named Attribute.002": (3500, 980), - "Extrude Mesh": (2820, 0), - "Combine XYZ.003": (2540, 440), - "Separate Geometry": (3090, 0), - "Combine XYZ.004": (2820, -350), - "Set Position.002": (3090, -350), - "Flip Faces": (3370, -350), - "Flip Faces.001": (3370, 0), - "Join Geometry": (3650, 0), - "Set Position.003": (3970, 0), - "Merge by Distance": (4230, 0), - "Normal": (1540, -340), - "Blur Attribute": (1760, -340), - "Vector Math.003": (2020, -340), - "Store Named Attribute.002": (2020, 0), - "Vector Math.004": (4990, 700), - "Named Attribute.004": (4710, 1080), - "Position.002": (4230, 700), - "Separate XYZ.002": (4450, 700), - "Math.003": (4710, 700), - "Vector Math.002": (5210, 700), - "Named Attribute.003": (4990, 1080), - "Set Position.004": (5430, 0), - "Position.003": (3500, 620), - "Separate XYZ.003": (3750, 620), - "Math.004": (5430, 500), - "Switch": (5700, 0), - "Remove Named Attribute": (5960, 0), - "Set Shade Smooth": (6220, 0), - "Group Output": (6480, 0), - }, ( - (("Group",), (-250, 0)), - (("Image Texture",), (-240, 1100)), - (("Vector Math",), (0, 1100)), - (("Math",), (440, 1120)), - (("Math.001", "Mix"), (880, 1160)), - (("Math.003",), (4450, 1040)), - (("Math.004",), (5210, 500)), - (("Blur Attribute",), (1540, -540)), - )) - for name, node in stage.items(): - if node.location.x >= 2020 and name not in { - "Store Named Attribute.002", "Vector Math.003" - }: - node.location.x += 260 - base = route_geometry(group, stage["Store Named Attribute.001"].outputs["Geometry"], - ((2770, 180), (3600, 180))) - group.links.new(base, stage["Join Geometry"].inputs["Geometry"]) - bypass = route_geometry(group, stage["Set Position"].outputs["Geometry"], - ((1970, -760), (5650, -760))) - group.links.new(bypass, stage["Switch"].inputs["False"]) diff --git a/tools/node_assets/material_layers.py b/tools/node_assets/material_layers.py deleted file mode 100644 index 67c16f6..0000000 --- a/tools/node_assets/material_layers.py +++ /dev/null @@ -1,195 +0,0 @@ -"""Build the image material Shader node groups.""" - -import bpy - -from anyimage.common.material import ( - DEPTH_MATERIAL_NODE_GROUP_NAME, - IMAGE_MATERIAL_NODE_GROUP_NAME, -) - - -BUMP_BASE_DISTANCE = 0.001 -MATERIAL_DISPLACEMENT_ATTRIBUTE = "o_material_displacement" -BACKGROUND_DEPTH_ATTRIBUTE = "o_background_depth" -DEPTH_SCALE_ATTRIBUTE = "o_depth_scale" - - -def build_material_layer_group(depth_plane=False): - group_name = ( - DEPTH_MATERIAL_NODE_GROUP_NAME - if depth_plane - else IMAGE_MATERIAL_NODE_GROUP_NAME - ) - existing = bpy.data.node_groups.get(group_name) - if existing is not None: - return existing - group = bpy.data.node_groups.new(group_name, "ShaderNodeTree") - interface = group.interface - input_sockets = [ - ("Color", "NodeSocketColor"), - ("Alpha", "NodeSocketFloat"), - ("Bump Scale", "NodeSocketFloat"), - ] - normal_group = not depth_plane - if normal_group: - input_sockets.insert(2, ("Normal", "NodeSocketColor")) - input_sockets.insert(3, ("Normal Scale", "NodeSocketFloat")) - input_sockets.insert(4, ("Object Space", "NodeSocketBool")) - if depth_plane: - input_sockets.insert(2, ("Depth", "NodeSocketFloat")) - for name, socket_type in input_sockets: - socket = interface.new_socket( - name=name, - in_out="INPUT", - socket_type=socket_type, - ) - if name == "Alpha": - socket.default_value = 1.0 - socket.min_value = 0.0 - socket.max_value = 1.0 - socket.subtype = "FACTOR" - elif name == "Bump Scale": - socket.default_value = 0.0 - socket.min_value = -10000.0 - socket.max_value = 10000.0 - elif name == "Normal": - socket.default_value = (0.5, 0.5, 1.0, 1.0) - elif name == "Normal Scale": - socket.default_value = 1.0 - socket.min_value = 0.0 - socket.max_value = 10.0 - elif name == "Object Space": - socket.default_value = False - output_sockets = [ - ("Color", "NodeSocketColor"), - ("Roughness", "NodeSocketFloat"), - ("Alpha", "NodeSocketFloat"), - ("Normal", "NodeSocketVector"), - ] - if depth_plane: - output_sockets.append(("Displacement", "NodeSocketVector")) - for name, socket_type in output_sockets: - interface.new_socket( - name=name, - in_out="OUTPUT", - socket_type=socket_type, - ) - - nodes = group.nodes - links = group.links - group_input = nodes.new("NodeGroupInput") - group_input.location = (-500.0, 100.0) - group_output = nodes.new("NodeGroupOutput") - group_output.location = (300.0, 100.0) - roughness_curve = nodes.new("ShaderNodeFloatCurve") - roughness_curve.mapping.initialize() - curve_points = roughness_curve.mapping.curves[0].points - curve_points[0].location = (0.0, 0.5) - curve_points[-1].location = (1.0, 0.1) - roughness_curve.mapping.update() - roughness_curve.location = (-250.0, 350.0) - bump = nodes.new("ShaderNodeBump") - bump.location = (100.0, 0.0) - if normal_group: - tangent_normal = nodes.new("ShaderNodeNormalMap") - tangent_normal.space = "TANGENT" - tangent_normal.location = (-500.0, -100.0) - object_normal = nodes.new("ShaderNodeNormalMap") - object_normal.space = "OBJECT" - object_normal.location = (-500.0, -350.0) - choose_normal = nodes.new("ShaderNodeMix") - choose_normal.data_type = "VECTOR" - choose_normal.location = (-150.0, -150.0) - for normal_map in (tangent_normal, object_normal): - links.new(group_input.outputs["Normal"], normal_map.inputs["Color"]) - links.new( - group_input.outputs["Normal Scale"], - normal_map.inputs["Strength"], - ) - links.new(group_input.outputs["Object Space"], choose_normal.inputs[0]) - links.new(tangent_normal.outputs["Normal"], choose_normal.inputs[4]) - links.new(object_normal.outputs["Normal"], choose_normal.inputs[5]) - links.new(choose_normal.outputs["Result"], bump.inputs["Normal"]) - displacement = None - - processed_alpha = group_input.outputs["Alpha"] - bump_depth = group_input.outputs["Color"] - if depth_plane: - displacement = nodes.new("ShaderNodeDisplacement") - displacement.inputs["Midlevel"].default_value = 0.0 - displacement.inputs["Scale"].default_value = 1.0 - group_input.location = (-1200.0, 100.0) - group_output.location = (1050.0, 100.0) - roughness_curve.location = (-300.0, 450.0) - bump.location = (750.0, 50.0) - displacement.location = (750.0, -300.0) - background_attribute = nodes.new("ShaderNodeAttribute") - background_attribute.attribute_type = "GEOMETRY" - background_attribute.attribute_name = BACKGROUND_DEPTH_ATTRIBUTE - background_attribute.location = (-950.0, -500.0) - material_attribute = nodes.new("ShaderNodeAttribute") - material_attribute.attribute_type = "GEOMETRY" - material_attribute.attribute_name = MATERIAL_DISPLACEMENT_ATTRIBUTE - material_attribute.location = (-50.0, -650.0) - strength_attribute = nodes.new("ShaderNodeAttribute") - strength_attribute.attribute_type = "GEOMETRY" - strength_attribute.attribute_name = DEPTH_SCALE_ATTRIBUTE - strength_attribute.location = (200.0, -650.0) - foreground_mask = nodes.new("ShaderNodeMath") - foreground_mask.operation = "GREATER_THAN" - foreground_mask.inputs[1].default_value = 0.5 - foreground_mask.location = (-950.0, -250.0) - clamp_foreground = nodes.new("ShaderNodeMath") - clamp_foreground.operation = "MAXIMUM" - clamp_foreground.location = (-650.0, -400.0) - masked_depth = nodes.new("ShaderNodeMix") - masked_depth.data_type = "FLOAT" - masked_depth.location = (-350.0, -350.0) - enabled_depth = nodes.new("ShaderNodeMath") - enabled_depth.operation = "MULTIPLY" - enabled_depth.location = (250.0, -350.0) - scaled_depth = nodes.new("ShaderNodeMath") - scaled_depth.operation = "MULTIPLY" - scaled_depth.location = (450.0, -350.0) - links.new(group_input.outputs["Alpha"], foreground_mask.inputs[0]) - links.new(group_input.outputs["Depth"], clamp_foreground.inputs[0]) - links.new(background_attribute.outputs["Fac"], clamp_foreground.inputs[1]) - links.new(foreground_mask.outputs[0], masked_depth.inputs[0]) - links.new(background_attribute.outputs["Fac"], masked_depth.inputs[2]) - links.new(clamp_foreground.outputs[0], masked_depth.inputs[3]) - links.new(masked_depth.outputs["Result"], enabled_depth.inputs[0]) - links.new(material_attribute.outputs["Fac"], enabled_depth.inputs[1]) - links.new(enabled_depth.outputs[0], scaled_depth.inputs[0]) - links.new(strength_attribute.outputs["Fac"], scaled_depth.inputs[1]) - processed_alpha = foreground_mask.outputs[0] - processed_depth = scaled_depth.outputs[0] - - links.new(group_input.outputs["Color"], group_output.inputs["Color"]) - links.new(group_input.outputs["Color"], roughness_curve.inputs["Value"]) - links.new( - roughness_curve.outputs["Value"], - group_output.inputs["Roughness"], - ) - links.new(processed_alpha, group_output.inputs["Alpha"]) - links.new(bump_depth, bump.inputs["Height"]) - bump_distance = nodes.new("ShaderNodeMath") - bump_distance.operation = "MULTIPLY" - bump_distance.inputs[1].default_value = BUMP_BASE_DISTANCE - bump_distance.location = (500.0, 100.0) if depth_plane else (-250.0, 150.0) - links.new(group_input.outputs["Bump Scale"], bump_distance.inputs[0]) - links.new(bump_distance.outputs[0], bump.inputs["Distance"]) - links.new(bump.outputs["Normal"], group_output.inputs["Normal"]) - if displacement is not None: - links.new(processed_depth, displacement.inputs["Height"]) - links.new( - displacement.outputs["Displacement"], - group_output.inputs["Displacement"], - ) - group.color_tag = "SHADER" - group.description = ( - "Prepare image depth material outputs." - if depth_plane - else f"Prepare {group_name} material outputs." - ) - group.use_fake_user = True - return group diff --git a/tools/node_assets/node_layout.py b/tools/node_assets/node_layout.py deleted file mode 100644 index 7cf4e3e..0000000 --- a/tools/node_assets/node_layout.py +++ /dev/null @@ -1,138 +0,0 @@ -"""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/plane_layouts.py b/tools/node_assets/plane_layouts.py deleted file mode 100644 index 00ae0c3..0000000 --- a/tools/node_assets/plane_layouts.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Authored processing stages for the shared image geometry assets.""" - -from node_layout import arrange_nodes - -LAYOUTS = { - "O Image 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 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)), - ), - ), -} - - -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/nodes/__init__.py b/tools/nodes/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tools/nodes/__init__.py @@ -0,0 +1 @@ + diff --git a/tools/nodes/arrangement/__init__.py b/tools/nodes/arrangement/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tools/nodes/arrangement/__init__.py @@ -0,0 +1 @@ + diff --git a/tools/nodes/arrangement/arrange.py b/tools/nodes/arrangement/arrange.py new file mode 100644 index 0000000..841f7fc --- /dev/null +++ b/tools/nodes/arrangement/arrange.py @@ -0,0 +1,769 @@ +"""Arrange geometry paths, local fields, and shared references after construction.""" + +from collections import defaultdict +from dataclasses import dataclass, field +from graphlib import TopologicalSorter + +import numpy as np +from scipy.optimize import LinearConstraint, minimize + +from .dimensions import estimate_node_dimensions, visible_socket +from .zones import node_zones +from .flow import graph, geometry_paths, origin_socket, peer_links, shared_geometry_outputs, socket_links +from .connections import curve_footprint, link_endpoints +from .fields import field_layout + + +GEOMETRY_GAP = 100 +FIELD_GAP = 40 +BRANCH_GAP = 160 +SECTION_GAP = 200 +FIELD_HEIGHT = 720 +ROUTE_GAP = 40 +ROUTE_RUN = 120 + + +def _replace_link(node_group, link, source): + """Replace a source without changing a multi-input socket's branch order.""" + multi = link.to_socket.is_multi_input + replacement = node_group.links.new(source, link.to_socket) + if multi: + replacement.swap_multi_input_sort_id(link) + node_group.links.remove(link) + + +def _remove_routes(node_group): + previous = [node for node in node_group.nodes if node.bl_idname == "NodeReroute"] + + for node in [node for node in node_group.nodes if node not in previous]: + for socket in node.inputs: + links = socket_links(socket) + if not any(link.from_node in previous for link in links): + continue + if not socket.is_multi_input: + _replace_link(node_group, links[0], origin_socket(links[0].from_socket)) + continue + sources = [origin_socket(link.from_socket) for link in links] + for link in links: + node_group.links.remove(link) + seen = set() + for source in reversed(sources): + original = source + if source in seen: + # Blender coalesces identical direct links; retain repeated inputs. + duplicate = node_group.nodes.new("NodeReroute") + node_group.links.new(source, duplicate.inputs[0]) + source = duplicate.outputs[0] + seen.add(original) + node_group.links.new(source, socket) + for node in previous: + node_group.nodes.remove(node) + + +def _peer_inputs(node, ancestors): + sources = list(dict.fromkeys( + link.from_node for link in peer_links(node) + if link.from_node.bl_idname != "NodeGroupInput" + )) + upstream = set().union(*(ancestors[source] for source in sources)) + return [source for source in sources if source not in upstream] + + +def _localize_peer_inputs(node_group, ancestors, children): + """Give shared inputs local ports without moving their public source.""" + for node in list(node_group.nodes): + links = peer_links(node) + if len(links) < 2: + continue + sources = [link.from_node for link in links] + upstream = set().union(*(ancestors[source] for source in sources)) + for link in links: + source = link.from_node + if source.bl_idname == "NodeReroute": + source["arrangement_input"] = True + continue + if source.bl_idname != "NodeGroupInput" and len(children[source]) <= 1 and sources.count(source) == 1 and source not in upstream: + continue + port = node_group.nodes.new("NodeReroute") + port["arrangement_input"] = True + node_group.links.new(link.from_socket, port.inputs[0]) + _replace_link(node_group, link, port.outputs[0]) + + +def _columns(nodes, parents, peers, sizes, gap, spacing=None): + """Allocate columns with equal right edges for independent peer inputs.""" + roots = {node: node for node in nodes} + + def root(node): + while roots[node] != node: + node = roots[node] + return node + + for sources in peers: + members = [source for source in sources if source in roots] + for source in members[1:]: + roots[root(source)] = root(members[0]) + dependencies = {root(node): [] for node in nodes} + for node in nodes: + for parent in parents[node]: + if parent in roots and root(parent) not in dependencies[root(node)]: + dependencies[root(node)].append(root(parent)) + levels = {} + for component in TopologicalSorter(dependencies).static_order(): + levels[component] = max((levels[p] + 1 for p in dependencies[component]), default=0) + columns = defaultdict(list) + for node in nodes: + columns[levels[root(node)]].append(node) + right, positions = 0, {} + for level in sorted(columns): + right += max(((spacing or {}).get(node, 0) for node in columns[level]), default=0) + right += max(sizes[node].width for node in columns[level]) + for node in columns[level]: + positions[node] = right - sizes[node].width + right += gap + return positions, columns + + +def _field_regions(nodes, core, children, order): + """Group calculations by their first geometry consumers and shared scope.""" + targets = {} + fields = set(nodes) - core + for node in reversed(order): + targets[node] = frozenset({node}) if node in core else frozenset().union( + *(targets[child] for child in children[node]) + ) + for node in order: + if node.get("arrangement_exit"): + targets[node] = targets[origin_socket(node.outputs[0]).node] + regions = defaultdict(list) + for node in order: + if node in fields: + regions[targets[node]].append(node) + return [(region, owners) for owners, region in regions.items()] + + +def _localize_module_outputs(node_group, core, children): + """Expose reused results at the boundary of their calculation module.""" + shared = set(shared_geometry_outputs(node_group.nodes)) + for link in list(node_group.links): + source = link.from_node + if link.to_node not in core or source in core or source.bl_idname == "NodeGroupInput": + continue + if link.from_socket in shared or not any(node not in core for node in children[source]): + continue + port = node_group.nodes.new("NodeReroute") + port["arrangement_exit"] = True + node_group.links.new(link.from_socket, port.inputs[0]) + _replace_link(node_group, link, port.outputs[0]) + + +def _localize_inputs(node_group): + """Give each operation local interface reads, separating geometry from values.""" + controls = [node for node in node_group.nodes if node.bl_idname == "NodeGroupInput"] + uses = defaultdict(list) + for link in node_group.links: + if link.from_node in controls: + uses[link.to_node, link.from_socket.type == "GEOMETRY"].append((link, link.from_socket.identifier)) + for inputs in uses.values(): + local = node_group.nodes.new("NodeGroupInput") + for link, identifier in inputs: + source = next(socket for socket in local.outputs if socket.identifier == identifier) + _replace_link(node_group, link, source) + for node in controls: + node_group.nodes.remove(node) + + +def _presentation(node_group): + for node in node_group.nodes: + node.select = False + for socket in node.outputs: + socket.hide = not socket.is_linked + if node.bl_idname in {"ShaderNodeMath", "ShaderNodeVectorMath"}: + node.hide = all(socket.is_linked for socket in node.inputs if visible_socket(socket)) + + +def _place_geometry_paths(paths, geometry_x, sizes, order): + """Reuse fixed horizontal lanes; field sizes never participate in lane choice.""" + paths = [list(path) for path in paths] + for path in list(paths): + for node in path: + paired = getattr(node, "paired_output", None) + if paired is None or paired not in geometry_x: + continue + first = next(nodes for nodes in paths if node in nodes) + last = next(nodes for nodes in paths if paired in nodes) + if first is not last: + first.extend(last) + paths.remove(last) + pitch = max(sizes[node].height for path in paths for node in path) + SECTION_GAP + path_of = {node: index for index, path in enumerate(paths) for node in path} + preceding = {index: set() for index in range(len(paths))} + for node in order: + inputs = list(dict.fromkeys(path_of[link.from_node] for link in peer_links(node) + if link.from_node in path_of)) + for first, second in zip(inputs, inputs[1:]): + pending, seen = [first], set() + while pending: + current = pending.pop() + if current not in seen: + seen.add(current) + pending.extend(preceding[current]) + if second not in seen: + preceding[second].add(first) + lanes, positions, rows = defaultdict(list), {}, {} + pending = set(preceding) + while pending: + ready = [index for index in pending if not preceding[index] & pending] + index = min(ready, key=lambda index: (-len(paths[index]), geometry_x[paths[index][0]], index)) + path = paths[index] + left = min(geometry_x[node] for node in path) + right = max(geometry_x[node] + sizes[node].width for node in path) + minimum = max((rows[previous] + 1 for previous in preceding[index]), default=-len(paths)) + for lane in sorted(range(minimum, len(paths) * 2), key=lambda value: (abs(value), value)): + if all(right + FIELD_GAP <= a or left >= b + FIELD_GAP for a, b in lanes[lane]): + break + lanes[lane].append((left, right)) + rows[index] = lane + pending.remove(index) + for node in path: + positions[node] = (geometry_x[node], -lane * pitch) + # Solve the lane heights together so successive merges cannot create a staircase. + equalities, centers, inequalities, separations = [], [], [], [] + for node in order: + sources = [link.from_socket for link in peer_links(node)] + if node not in path_of or len(sources) < 2 or any(socket.node not in path_of for socket in sources): + continue + output = next(socket for socket in node.outputs if socket in sizes[node].sockets) + first, last = sources[0], sources[-1] + row = np.zeros(len(paths)) + row[path_of[node]] += 2 + row[path_of[first.node]] -= 1 + row[path_of[last.node]] -= 1 + equalities.append(row) + centers.append(2 * sizes[node].sockets[output] - sizes[first.node].sockets[first] - sizes[last.node].sockets[last]) + spans = [(min(geometry_x[node] for node in path), max(geometry_x[node] + sizes[node].width for node in path)) + for path in paths] + local_ports = {index for index, path in enumerate(paths) + if all(node.get("arrangement_input") for node in path)} + pairs = {(first, second) for second, sources in preceding.items() for first in sources} + for first in range(len(paths)): + for second in range(first + 1, len(paths)): + if first in local_ports or second in local_ports: + continue + if spans[first][0] < spans[second][1] + FIELD_GAP and spans[first][1] + FIELD_GAP > spans[second][0]: + pairs.add((first, second) if rows[first] < rows[second] else (second, first)) + for first, second in sorted(pairs): + row = np.zeros(len(paths)) + row[first], row[second] = 1, -1 + inequalities.append(row) + gap = FIELD_GAP if first in local_ports or second in local_ports else SECTION_GAP + separations.append(max(sizes[node].height for node in paths[first]) + gap) + weights = np.array([max(1, len(path)) ** 2 for path in paths], dtype=float) + weights /= weights.max() + constraints = [] + if equalities: + constraints.append(LinearConstraint(np.array(equalities), centers, centers)) + if inequalities: + constraints.append(LinearConstraint(np.array(inequalities), separations, np.inf)) + compact = [] + for node in order: + if node not in path_of: + continue + for link in peer_links(node): + if link.from_node in path_of and path_of[link.from_node] in local_ports: + row = np.zeros(len(paths)) + row[path_of[link.from_node]], row[path_of[node]] = 1, -1 + compact.append(row) + metric = np.diag(weights) + if compact: + matrix = np.array(compact) + metric += matrix.T @ matrix * 4 + solution = minimize(lambda values: values @ metric @ values, + np.array([-rows[index] * pitch for index in range(len(paths))], dtype=float), + jac=lambda values: 2 * metric @ values, constraints=constraints, + method="SLSQP", options={"ftol": 1e-5, "maxiter": 300}) + if not solution.success: + raise ValueError(f"Geometry lane constraints could not be placed: {solution.message}") + for index, path in enumerate(paths): + for node in path: + positions[node] = (positions[node][0], float(solution.x[index])) + for node in order: + if node not in positions: + continue + links = [link for socket in node.inputs if socket.type == "GEOMETRY" for link in socket.links + if link.from_node in positions] + if not links: + continue + x = positions[node][0] + required = [] + for link in links: + source = link.from_socket + source_y = positions[source.node][1] - sizes[source.node].sockets[source] + target_y = positions[node][1] - sizes[node].sockets[link.to_socket] + required.append(positions[source.node][0] + sizes[source.node].width + + max(GEOMETRY_GAP, abs(target_y - source_y) * 0.8)) + extra = max(0, max(required) - x) + if extra: + for member, (left, y) in positions.items(): + if left >= x: + positions[member] = (left + extra, y) + return positions + + +def _geometry_corridors(paths, positions, sizes): + """Reserve continuous breathing room along every horizontal geometry path.""" + corridors = [] + for path in paths: + if all(node.get("arrangement_input") for node in path): + continue + boxes = [_node_box(node, positions, sizes) for node in path] + corridors.append((min(box[0] for box in boxes) - GEOMETRY_GAP / 2, + max(box[1] for box in boxes) + GEOMETRY_GAP / 2, + max(box[2] for box in boxes), min(box[3] for box in boxes))) + for port in positions: + if not port.get("arrangement_input") or port.outputs[0].type != "GEOMETRY": + continue + source = origin_socket(port.outputs[0]).node + if source not in positions: + continue + left = positions[source][0] + sizes[source].width + right, level = positions[port][0] + 8, positions[port][1] - 8 + if right > left: + corridors.append((left, right, level + ROUTE_GAP, level - ROUTE_GAP)) + return corridors + + +def _node_box(node, positions, sizes): + x, y = positions[node] + return x, x + sizes[node].width, y, y - sizes[node].height + + +def _overlap(first, second, gap): + a, b, top, bottom = first + c, d, upper, lower = second + return a < d + gap and b > c - gap and top > lower - gap and bottom < upper + gap + + +@dataclass +class BranchSpace: + """Keep module bodies and their exit channels in one placement reservation.""" + + bodies: list = field(default_factory=list) + exits: list = field(default_factory=list) + routes: list = field(default_factory=list) + + def connections(self, outlets, placed, sizes): + exits, routes = [], [] + for links in outlets: + for link in links: + start, end = link_endpoints(link, placed, sizes) + corners = [] + run = max(ROUTE_RUN, abs(end[1] - start[1]) * 0.8) + if end[0] - start[0] > max(ROUTE_RUN * 4, run + ROUTE_RUN * 2): + corners = [(start[0] + ROUTE_RUN, start[1]), + (max(start[0] + ROUTE_RUN, end[0] - run), start[1])] + points = [start, *dict.fromkeys(corners), end] + boxes = [box for a, b in zip(points, points[1:]) for box in curve_footprint(a, b)] + exits.append((link, boxes)) + routes.append((link, list(dict.fromkeys(corners)))) + return exits, routes + + def fits(self, body, exits): + if any(_overlap(body, box, ROUTE_GAP) for _, boxes in self.exits for box in boxes): + return False + for link, boxes in exits: + if any(_overlap(box, rectangle, ROUTE_GAP) for nodes, rectangle in self.bodies + if link.to_node not in nodes for box in boxes): + return False + return True + + def crossings(self, exits): + return sum(any(_overlap(box, previous, 8) for box in boxes for previous in channel) + for link, boxes in exits for other, channel in self.exits + if link.to_node != other.to_node and link.from_socket != other.from_socket) + + +def _place_field_region(local, width, height, destinations, owners, positions, sizes, occupied, limits, core, trunks, space, horizontal=None): + """Place an upstream module together with its forward-facing exit channels.""" + right = min(positions[node][0] for node in destinations) - FIELD_GAP + baseline = sum(positions[node][1] for node in owners) / len(owners) + output = next(node for node in core if node.bl_idname == "NodeGroupOutput" and node.is_active_output) + ceiling = max(positions[node][1] for node in owners) + floor = min(positions[node][1] - sizes[node].height for node in owners) + axis_top = positions[output][1] + axis_bottom = axis_top - sizes[output].height + core_left = min(positions[node][0] for node in core) + side = 1 if floor > axis_top else -1 if ceiling < axis_bottom else 0 + desired = right - width + front_allowed = not horizontal and all(link.from_node in local + for node in local for socket in node.inputs for link in socket.links) + shared = [] if horizontal else shared_geometry_outputs(local) + references = set(shared_geometry_outputs(local)) + outlets, constraints, entries = [], [], [] + for node, (x, y) in local.items(): + if horizontal: + for socket in node.inputs: + for link in socket.links: + if link.from_node in positions: + source_x, source_y = positions[link.from_node] + entries.append((source_x + sizes[link.from_node].width - x, + source_y - sizes[link.from_node].sockets[link.from_socket], + y - sizes[node].sockets[socket])) + for socket in node.outputs: + links = list(socket.links) + if socket not in references: + returns = [link for link in links if link.to_node in core] + if returns: + outlets.append(returns) + for link in links: + if link.to_node in positions and link.to_node not in local: + target_x, target_y = positions[link.to_node] + constraints.append((target_x - x - sizes[node].width, + y - sizes[node].sockets[socket], + target_y - sizes[link.to_node].sockets[link.to_socket])) + trunk_end = max(positions[node][0] for node in {*owners, *destinations}) + candidates = {desired, *(left - BRANCH_GAP - width for left, _, _, _ in occupied), + *(end + BRANCH_GAP for _, end, _, _ in occupied)} + if horizontal: + candidates = {left for left in candidates if horizontal[0] <= left and left + width <= horizontal[1]} + candidates.add(horizontal[0]) + best, seen = None, set() + for left in sorted(candidates, key=lambda x: (abs(x - desired), x)): + tops = {ceiling + BRANCH_GAP + height, floor - BRANCH_GAP, + limits[0], limits[1] + height, + *(top + BRANCH_GAP + height for _, _, top, _ in occupied), + *(bottom - BRANCH_GAP for _, _, _, bottom in occupied)} + if front_allowed: + tops.add(baseline + height / 2) + for top in sorted(tops, key=lambda y: abs(y - height / 2 - baseline)): + if top > limits[0] or top - height < limits[1]: + continue + minimum = max([horizontal[0], *(start + max(GEOMETRY_GAP, abs(top + target_y - source_y) * 0.8) + for start, source_y, target_y in entries)]) if horizontal else -float("inf") + placed_left = max(left, minimum) if horizontal else min(left, desired) + placed_left = min([placed_left, *(end - max(GEOMETRY_GAP, abs(target_y - top - source_y) * 0.8) + for end, source_y, target_y in constraints)]) + if horizontal and not minimum <= placed_left <= horizontal[1] - width: + continue + in_front = placed_left + width + BRANCH_GAP <= core_left + if not (in_front and front_allowed) and ((side == 1 and top - height < ceiling + BRANCH_GAP - 1e-5) + or (side == -1 and top > floor - BRANCH_GAP + 1e-5)): + continue + point = (placed_left, top) + if point in seen: + continue + seen.add(point) + distance = abs(placed_left - desired) + base_score = distance + abs(top - height / 2 - baseline) * 1.5 + if best is not None and base_score > best[0]: + continue + overlapping = [box for box in occupied + if placed_left < box[1] + BRANCH_GAP and placed_left + width > box[0] - BRANCH_GAP] + if any(top > bottom - BRANCH_GAP + 1e-5 and top - height < upper + BRANCH_GAP - 1e-5 + for _, _, upper, bottom in overlapping): + continue + channels = [(placed_left + local[socket.node][0] + sizes[socket.node].width, + top + local[socket.node][1] - sizes[socket.node].sockets[socket]) for socket in shared] + if any(a < trunk_end and b > start and bottom - ROUTE_GAP < level < upper + ROUTE_GAP + for start, level in channels for a, b, upper, bottom in occupied): + continue + placed = positions | {node: (x + placed_left, y + top) for node, (x, y) in local.items()} + exits, routes = space.connections(outlets, placed, sizes) + body = (placed_left, placed_left + width, top, top - height) + if not space.fits(body, exits): + continue + score = base_score + space.crossings(exits) * BRANCH_GAP * 4 + candidate = (score, distance, -top, placed_left, top) + if best is None or candidate < best: + best = candidate + best_exits, best_routes = exits, routes + if best is None: + raise ValueError("The field region does not fit its reserved scope") + _, _, _, left, top = best + for node, (x, y) in local.items(): + positions[node] = (x + left, y + top) + body = (left, left + width, top, top - height) + occupied.append(body) + space.bodies.append((set(local), body)) + space.exits.extend(best_exits) + space.routes.extend(best_routes) + for socket in shared: + start = positions[socket.node][0] + sizes[socket.node].width + level = positions[socket.node][1] - sizes[socket.node].sockets[socket] + trunks[socket] = level + occupied.append((start, trunk_end, level + ROUTE_GAP, level - ROUTE_GAP)) + + +def _route_field_links(node_group, layouts, positions): + """Materialize the passages reserved between expression columns.""" + protected = set() + for layout in layouts: + anchor = next(iter(layout.positions)) + dx = positions[anchor][0] - layout.positions[anchor][0] + dy = positions[anchor][1] - layout.positions[anchor][1] + for link, points in layout.routes: + source = link.from_socket + protected.add((source, link.to_socket)) + for x, y in points: + node = node_group.nodes.new("NodeReroute") + node["arrangement_branch"] = True + node.location = (x + dx - 8, y + dy + 8) + node_group.links.new(source, node.inputs[0]) + source = node.outputs[0] + _replace_link(node_group, link, source) + return protected + + +def _route_branch_exits(node_group, routes): + """Materialize the channels reserved during placement, sharing trunk corners.""" + deliveries, protected = defaultdict(list), set() + for link, points in routes: + protected.add((link.from_socket, link.to_socket)) + deliveries[link.from_socket].append((link, points)) + for source, branches in deliveries.items(): + corners = {} + for point in sorted({point for _, points in branches for point in points}): + node = node_group.nodes.new("NodeReroute") + node["arrangement_branch"] = True + node.location = (point[0] - 8, point[1] + 8) + node_group.links.new(source, node.inputs[0]) + source = corners[point] = node.outputs[0] + for link, points in branches: + if points: + _replace_link(node_group, link, corners[points[-1]]) + return protected + + +def _route_peer_bypasses(node_group, sizes, positions, ancestors): + """Use an entry and an exit corner for a branch that bypasses processing.""" + for port in list(node_group.nodes): + if not port.get("arrangement_input") or port.outputs[0].type != "GEOMETRY": + continue + source = origin_socket(port.outputs[0]) + junction = port.outputs[0].links[0].to_node + processed = [origin_socket(link.from_socket).node for link in peer_links(junction) + if source.node in ancestors[origin_socket(link.from_socket).node]] + if not processed: + continue + left = positions[source.node][0] + sizes[source.node].width + right, level = positions[port][0] + 8, positions[port][1] - 8 + if right - left < ROUTE_GAP * 2: + continue + branch_nodes = {node for end in processed for node in ancestors[end] | {end} + if source.node in ancestors[node] and any(socket.type == "GEOMETRY" for socket in node.outputs) + and node.bl_idname != "NodeReroute"} + first = min(positions[node][0] for node in branch_nodes) + run = max(ROUTE_RUN, abs(level - positions[source.node][1] + sizes[source.node].sockets[source]) * 0.8) + x = min(right - ROUTE_GAP, max(left + min(run, (right - left) / 2), first - ROUTE_GAP)) + entry = node_group.nodes.new("NodeReroute") + entry["arrangement_bypass"] = True + node_group.links.new(source, entry.inputs[0]) + _replace_link(node_group, port.inputs[0].links[0], entry.outputs[0]) + positions[entry] = (x - 8, level + 8) + sizes[entry] = estimate_node_dimensions(entry) + + +def _route_references(node_group, sizes, positions, regions, paths, trunks, protected): + """Share forward trunks and branch next to each consumer for every socket type.""" + references = defaultdict(list) + path_of = {node: index for index, path in enumerate(paths) for node in path} + peer_edges = {link for node in node_group.nodes if len(links := peer_links(node)) > 1 for link in links} + for link in list(node_group.links): + if (link.from_socket, link.to_socket) in protected or link.from_node.get("arrangement_branch") or link.to_node.get("arrangement_branch"): + continue + if link.from_node.get("arrangement_bypass") or link.to_node.get("arrangement_bypass"): + continue + if (link.from_node in path_of and link.to_node in path_of + and path_of[link.from_node] == path_of[link.to_node] and len(link.from_socket.links) == 1): + continue + if link in peer_edges: + continue + if positions[link.to_node][0] - positions[link.from_node][0] - sizes[link.from_node].width > ROUTE_RUN * 4: + references[link.from_socket].append(link) + boxes = {node: _node_box(node, positions, sizes) for node in positions} + tracks = [] + + def route(source, x, y): + node = node_group.nodes.new("NodeReroute") + node.location = (x - 8, y + 8) + node_group.links.new(source, node.inputs[0]) + return node.outputs[0] + + for source, links in references.items(): + if len(links) < 2 and source.type != "GEOMETRY": + continue + left = positions[source.node][0] + sizes[source.node].width + right = max(positions[link.to_node][0] for link in links) + start_y = positions[source.node][1] - sizes[source.node].sockets[source] + destinations = {link.to_node for link in links} + obstacles = [(bottom - ROUTE_GAP, top + ROUTE_GAP) for node, (a, b, top, bottom) in boxes.items() + if node not in destinations and a < right and b > left] + for a, b, top, bottom in regions: + if a >= right or b <= left: + continue + endpoints = destinations | {source.node} + if any(a <= positions[node][0] <= b and bottom <= positions[node][1] <= top for node in endpoints): + continue + obstacles.append((bottom - ROUTE_GAP, top + ROUTE_GAP)) + obstacles += [(y - ROUTE_GAP, y + ROUTE_GAP) for a, b, y in tracks if a < right and b > left] + if len(links) == 1: + target = links[0] + target_y = positions[target.to_node][1] - sizes[target.to_node].sockets[target.to_socket] + if abs(target_y - start_y) < ROUTE_GAP and all(not lower < start_y < upper for lower, upper in obstacles): + continue + first_target = min(positions[link.to_node][0] for link in links) + reach = (first_target - left) / 2.4 + lower, upper = start_y - reach, start_y + reach + for link in links: + y = positions[link.to_node][1] - sizes[link.to_node].sockets[link.to_socket] + reach = (positions[link.to_node][0] - left) / 2.4 + lower, upper = max(lower, y - reach), min(upper, y + reach) + candidates = [start_y, lower, upper, *(edge for interval in obstacles for edge in interval)] + available = [y for y in candidates if lower <= y <= upper and all(not a < y < b for a, b in obstacles)] + if source in trunks: + level = trunks[source] + elif not available: + continue + else: + level = min(available, key=lambda y: (abs(y - start_y) + sum( + abs(y - positions[link.to_node][1] + sizes[link.to_node].sockets[link.to_socket]) + for link in links) / len(links), -y)) + entry_run = min(max(ROUTE_RUN, abs(level - start_y) * 0.8), (first_target - left) / 3) + trunk = route(source, left + entry_run, level) + tracks.append((left + entry_run, right, level)) + previous_x = left + entry_run + branches = [] + for link in links: + target_x = positions[link.to_node][0] + target_y = positions[link.to_node][1] - sizes[link.to_node].sockets[link.to_socket] + exit_run = min(max(ROUTE_RUN, abs(target_y - level) * 0.8), (target_x - left) / 3) + branches.append((target_x - exit_run, link)) + for x, link in sorted(branches, key=lambda item: item[0]): + if x != previous_x: + trunk = route(trunk, x, level) + previous_x = x + _replace_link(node_group, link, trunk) + + +def _zone_width(entry, interior, layout, sizes): + """Reserve the body and the actual socket runs at each end of a paired scope.""" + body_height = max(sizes[node].height for node in interior) + spans = [] + for top in (layout.height + BRANCH_GAP, -body_height - BRANCH_GAP): + left, right = FIELD_GAP, layout.width + FIELD_GAP + for node, (x, y) in layout.positions.items(): + for socket in node.inputs: + for link in socket.links: + if link.from_node == entry: + delta = top + y - sizes[node].sockets[socket] + sizes[entry].sockets[link.from_socket] + left = max(left, max(GEOMETRY_GAP, abs(delta) * 0.8) - x) + for socket in node.outputs: + for link in socket.links: + if link.to_node in interior: + delta = top + y - sizes[node].sockets[socket] + sizes[link.to_node].sockets[link.to_socket] + right = max(right, x + sizes[node].width + max(GEOMETRY_GAP, abs(delta) * 0.8)) + spans.append(left + right) + return max(spans) + + +def arrange_nodes(node_group): + """Fix geometry lanes, pack shallow field regions, and route shared references.""" + _remove_routes(node_group) + parents, children, order, ancestors = graph(node_group) + _localize_peer_inputs(node_group, ancestors, children) + parents, children, order, ancestors = graph(node_group) + geometry_parents, paths = geometry_paths(node_group) + core = set(geometry_parents) + _localize_module_outputs(node_group, core, children) + _localize_inputs(node_group) + _presentation(node_group) + parents, children, order, ancestors = graph(node_group) + sizes = {node: estimate_node_dimensions(node) for node in order} + for node, size in sizes.items(): + node.width = size.width + peers = [_peer_inputs(node, ancestors) for node in order] + regions = _field_regions(node_group.nodes, core, children, order) + for region, _ in regions: + outlets = [link.from_node for node in order if node in core for socket in node.inputs + for link in socket_links(socket) if link.from_node in region] + peers.append(list(dict.fromkeys(outlets))) + spacing, zones, field_layouts = {}, [], [] + scoped = set() + for entry, end, members in reversed(node_zones(node_group)): + if entry not in core or end not in core: + continue + interior = (members & core) - {entry} + fields = [node for node in order if node in members - core - scoped] + if fields: + scoped.update(fields) + layout = field_layout(fields, parents, peers, sizes, gap=FIELD_GAP, target_height=FIELD_HEIGHT) + field_layouts.append(layout) + local, width, height = layout.positions, layout.width, layout.height + span = _zone_width(entry, interior, layout, sizes) + for node in core: + if entry in geometry_parents[node]: + spacing[node] = max(spacing.get(node, 0), span) + zones.append((entry, end, interior, fields, local, width, height)) + column_parents = {node: list(sources) for node, sources in geometry_parents.items()} + for node in core: + if node.get("arrangement_input"): + source = origin_socket(node.outputs[0]).node + if source in core: + column_parents[node] = [source] + geometry_x, _ = _columns([node for node in order if node in core], column_parents, peers, sizes, GEOMETRY_GAP, spacing) + positions = _place_geometry_paths(paths, geometry_x, sizes, order) + occupied = _geometry_corridors(paths, positions, sizes) + trunks = {} + space = BranchSpace() + layouts = [] + for region, targets in regions: + remaining = [node for node in region if node not in scoped] + if remaining: + layout = field_layout(remaining, parents, peers, sizes, gap=FIELD_GAP, target_height=FIELD_HEIGHT) + field_layouts.append(layout) + layouts.append((remaining, targets, layout.positions, layout.width, layout.height)) + reserve = max([FIELD_HEIGHT, *(height for _, _, _, _, height in layouts), + *(height for _, _, _, _, _, _, height in zones)]) * 2 + BRANCH_GAP * 2 + limits = (max(y for _, y in positions.values()) + reserve, + min(y - sizes[node].height for node, (_, y) in positions.items()) - reserve) + for entry, end, interior, fields, local, width, height in zones: + destinations = [min((node for node in interior if node.bl_idname != "NodeReroute"), + key=lambda node: (positions[node][0], node.name))] + horizontal = (positions[entry][0] + sizes[entry].width + FIELD_GAP, positions[end][0] - FIELD_GAP) + _place_field_region(local, width, height, destinations, destinations, positions, sizes, occupied, limits, core, trunks, space, horizontal) + members = interior | {entry} | set(fields) + boxes = [_node_box(node, positions, sizes) for node in members] + occupied.append((min(box[0] for box in boxes), max(box[1] for box in boxes), + max(box[2] for box in boxes), min(box[3] for box in boxes))) + pending = list(layouts) + shared_nodes = {socket.node for socket in shared_geometry_outputs(node_group.nodes)} + def delivery_order(layout): + ports = [link.to_socket for node in layout[0] for socket in node.outputs + for link in socket.links if link.to_node in core] + return min(((positions[socket.node][0], list(socket.node.inputs).index(socket)) + for socket in ports), default=(float("inf"), 0)) + + while pending: + ready = [layout for layout in pending if all( + link.to_node in positions or link.to_node in layout[0] + for node in layout[0] for socket in node.outputs for link in socket.links)] + layout = min(ready, key=lambda item: (any(node in shared_nodes for node in item[0]), + delivery_order(item), order.index(item[0][0]))) + pending.remove(layout) + region, targets, local, width, height = layout + consumers = list(dict.fromkeys(link.to_node for node in region for socket in node.outputs + for link in socket.links if link.to_node not in region and link.to_node in positions)) + destinations = consumers or [node for node in order if node in targets] or [paths[0][-1]] + owners = [node for node in order if node in targets] or [paths[0][-1]] + _place_field_region(local, width, height, destinations, owners, positions, sizes, occupied, limits, core, trunks, space) + protected = _route_field_links(node_group, field_layouts, positions) + protected.update(_route_branch_exits(node_group, space.routes)) + _route_peer_bypasses(node_group, sizes, positions, ancestors) + for node, position in positions.items(): + node.location = position + _route_references(node_group, sizes, positions, occupied, paths, trunks, protected) + while unused := [node for node in node_group.nodes + if node.bl_idname == "NodeReroute" and not node.outputs[0].is_linked]: + for node in unused: + node_group.nodes.remove(node) + _presentation(node_group) diff --git a/tools/nodes/arrangement/connections.py b/tools/nodes/arrangement/connections.py new file mode 100644 index 0000000..484f08f --- /dev/null +++ b/tools/nodes/arrangement/connections.py @@ -0,0 +1,39 @@ +"""Socket endpoints and conservative curve footprints for placement and preview.""" + +from .flow import socket_links + + +def socket_position(socket, positions, sizes): + node = socket.node + x, y = positions[node] + if node.bl_idname == "NodeReroute": + return x + 8, y - 8 + return x + (sizes[node].width if socket.is_output else 0), y - sizes[node].sockets[socket] + + +def link_endpoints(link, positions, sizes): + start = socket_position(link.from_socket, positions, sizes) + x, y = socket_position(link.to_socket, positions, sizes) + if link.to_socket.is_multi_input: + siblings = socket_links(link.to_socket) + y -= (siblings.index(link) - (len(siblings) - 1) / 2) * 8 + return start, (x, y) + + +def curve_footprint(start, end): + """Reserve eight convex hulls covering a horizontal-tangent Bezier curve.""" + x1, y1 = start + x2, y2 = end + bend = max(40, abs(x2 - x1) * 0.42) + curves = [(start, (x1 + bend, y1), (x2 - bend, y2), end)] + for _ in range(3): + divided = [] + for points in curves: + rows = [points] + for _ in range(3): + rows.append([((a[0] + b[0]) / 2, (a[1] + b[1]) / 2) + for a, b in zip(rows[-1], rows[-1][1:])]) + divided.extend((tuple(row[0] for row in rows), tuple(row[-1] for row in reversed(rows)))) + curves = divided + return [(min(x for x, _ in curve), max(x for x, _ in curve), + max(y for _, y in curve), min(y for _, y in curve)) for curve in curves] diff --git a/tools/nodes/arrangement/dimensions.json b/tools/nodes/arrangement/dimensions.json new file mode 100644 index 0000000..e0ff9f8 --- /dev/null +++ b/tools/nodes/arrangement/dimensions.json @@ -0,0 +1,70 @@ +{ + "blender_version": "4.5", + "header": 32, + "row": 24, + "padding": 12, + "width": 180, + "wide_width": 220, + "collapsed_height": 36, + "controls": { + "FunctionNodeBooleanMath": 1, + "FunctionNodeCompare": 2, + "FunctionNodeFloatToInt": 1, + "FunctionNodeIntegerMath": 1, + "FunctionNodeSeparateColor": 1, + "GeometryNodeAccumulateField": 2, + "GeometryNodeAttributeStatistic": 2, + "GeometryNodeBlurAttribute": 1, + "GeometryNodeBoundBox": 0, + "GeometryNodeCaptureAttribute": 3, + "GeometryNodeCornersOfEdge": 0, + "GeometryNodeDeleteGeometry": 2, + "GeometryNodeExtrudeMesh": 1, + "GeometryNodeFaceOfCorner": 0, + "GeometryNodeFieldAtIndex": 2, + "GeometryNodeFieldOnDomain": 2, + "GeometryNodeForeachGeometryElementInput": 0, + "GeometryNodeForeachGeometryElementOutput": 4, + "GeometryNodeFlipFaces": 0, + "GeometryNodeGroup": 1, + "GeometryNodeImageTexture": 2, + "GeometryNodeInputIndex": 0, + "GeometryNodeInputMeshEdgeNeighbors": 0, + "GeometryNodeInputMeshEdgeVertices": 0, + "GeometryNodeInputMeshFaceArea": 0, + "GeometryNodeInputMeshIsland": 0, + "GeometryNodeInputMeshVertexNeighbors": 0, + "GeometryNodeInputNamedAttribute": 1, + "GeometryNodeInputNormal": 0, + "GeometryNodeInputPosition": 0, + "GeometryNodeJoinGeometry": 0, + "GeometryNodeMenuSwitch": 3, + "GeometryNodeMergeByDistance": 1, + "GeometryNodeMeshCube": 0, + "GeometryNodeMeshGrid": 0, + "GeometryNodeRemoveAttribute": 1, + "GeometryNodeRepeatInput": 0, + "GeometryNodeRepeatOutput": 3, + "GeometryNodeSampleIndex": 3, + "GeometryNodeSampleNearest": 1, + "GeometryNodeSeparateGeometry": 1, + "GeometryNodeSetMaterialIndex": 0, + "GeometryNodeSetPosition": 0, + "GeometryNodeSetShadeSmooth": 1, + "GeometryNodeSimulationInput": 0, + "GeometryNodeSimulationOutput": 3, + "GeometryNodeSplitEdges": 0, + "GeometryNodeStoreNamedAttribute": 2, + "GeometryNodeSwitch": 1, + "GeometryNodeTransform": 1, + "NodeGroupInput": 0, + "NodeGroupOutput": 0, + "ShaderNodeClamp": 1, + "ShaderNodeCombineXYZ": 0, + "ShaderNodeMapRange": 3, + "ShaderNodeMath": 2, + "ShaderNodeMix": 3, + "ShaderNodeSeparateXYZ": 0, + "ShaderNodeVectorMath": 1 + } +} diff --git a/tools/nodes/arrangement/dimensions.py b/tools/nodes/arrangement/dimensions.py new file mode 100644 index 0000000..b56980e --- /dev/null +++ b/tools/nodes/arrangement/dimensions.py @@ -0,0 +1,63 @@ +"""Estimate node bounds and socket offsets in unscaled editor units.""" + +import json +from dataclasses import dataclass +from pathlib import Path + + +RULES = json.loads(Path(__file__).with_suffix(".json").read_text(encoding="utf-8")) + + +@dataclass(frozen=True) +class NodeDimensions: + width: float + height: float + sockets: dict + + +def visible_socket(socket): + """Include only sockets drawn for the node's current operation.""" + return socket.enabled and not socket.is_unavailable and not socket.hide + + +def estimate_node_dimensions(node): + """Reserve socket rows, expanded vector values, and node-specific controls.""" + if node.bl_idname == "NodeReroute": + return NodeDimensions(16, 16, {socket: 8 for socket in (*node.inputs, *node.outputs)}) + inputs = [s for s in node.inputs if visible_socket(s)] + outputs = [s for s in node.outputs if visible_socket(s)] + width = RULES["wide_width"] if ( + "Attribute" in node.bl_idname or node.bl_idname == "GeometryNodeGroup" + ) else RULES["width"] + if node.hide: + height = max(RULES["collapsed_height"], 16 + 12 * max(len(inputs), len(outputs))) + sockets = { + socket: (index + 1) * height / (len(side) + 1) + for side in (inputs, outputs) + for index, socket in enumerate(side) + } + return NodeDimensions(width, height, sockets) + + # Conservative reserves include spacing between UI sections. + controls = RULES["controls"][node.bl_idname] + for collection in ("repeat_items", "state_items", "input_items", "main_items", "generation_items", "capture_items", "enum_items"): + controls += len(getattr(node, collection, ())) + if node.bl_idname == "GeometryNodeGroup" and node.node_tree: + controls += sum(item.item_type == "PANEL" for item in node.node_tree.interface.items_tree) + cursor = RULES["header"] + RULES["padding"] + sockets = {} + for socket in outputs: + sockets[socket] = cursor + RULES["row"] / 2 + cursor += RULES["row"] + cursor += controls * RULES["row"] + RULES["padding"] + for socket in inputs: + rows = 1 + if not socket.is_linked and not socket.hide_value: + if socket.type in {"VECTOR", "ROTATION"}: + rows = 4 + elif socket.type == "MATRIX": + rows = 5 + extra = max(0, len(socket.links) - 1) * 8 if socket.is_multi_input else 0 + sockets[socket] = cursor + RULES["row"] / 2 + extra / 2 + cursor += rows * RULES["row"] + extra + return NodeDimensions(width, cursor + RULES["padding"], sockets) diff --git a/tools/nodes/arrangement/fields.py b/tools/nodes/arrangement/fields.py new file mode 100644 index 0000000..c39333b --- /dev/null +++ b/tools/nodes/arrangement/fields.py @@ -0,0 +1,196 @@ +"""Lay out expression dependencies with ordered operands and reserved wire passages.""" + +from collections import defaultdict +from dataclasses import dataclass +from graphlib import TopologicalSorter + +import numpy as np +from scipy.optimize import linprog + +from .flow import peer_links + + +@dataclass +class FieldLayout: + positions: dict + width: float + height: float + routes: list + + +def field_layout(nodes, parents, peers, sizes, *, gap, target_height): + """Order dependency layers, reserve long-link passages, and solve socket heights.""" + roots = {node: node for node in nodes} + + def root(node): + while roots[node] != node: + node = roots[node] + return node + + for sources in peers: + members = [node for node in sources if node in roots] + if sum(sizes[node].height for node in members) + gap * (len(members) - 1) > target_height: + continue + for node in members[1:]: + roots[root(node)] = root(members[0]) + groups = defaultdict(list) + for node in nodes: + groups[root(node)].append(node) + for sources in peers: + members = [node for node in sources if node in roots] + if members and all(root(node) == root(members[0]) for node in members): + groups[root(members[0])].sort(key=lambda node: sources.index(node) if node in sources else len(sources)) + blocks = list(groups.values()) + block_of = {node: index for index, block in enumerate(blocks) for node in block} + children = {index: set() for index in range(len(blocks))} + for node in nodes: + for parent in parents[node]: + if parent in block_of and block_of[parent] != block_of[node]: + children[block_of[parent]].add(block_of[node]) + ranks = {} + for block in TopologicalSorter(children).static_order(): + ranks[block] = max((ranks[child] + 1 for child in children[block]), default=0) + depth = max(ranks.values()) + ranks = {block: depth - rank for block, rank in ranks.items()} + heights = [sum(sizes[node].height for node in block) + gap * (len(block) - 1) for block in blocks] + offsets = {} + for index, block in enumerate(blocks): + cursor = -heights[index] / 2 + for node in block: + offsets[node] = cursor + cursor += sizes[node].height + gap + layers = defaultdict(list) + for block, rank in ranks.items(): + layers[rank].append(block) + operand_columns = {rank for node in nodes for link in peer_links(node) + if link.from_node in block_of + for rank in range(ranks[block_of[link.from_node]], ranks[block_of[node]] + 1)} + # Keep an operand block and its junction together when spreading tall layers. + columns, column = defaultdict(list), 0 + for rank in sorted(layers): + used = 0 + for block in layers[rank]: + if rank not in operand_columns and used and used + gap + heights[block] > target_height: + column += 1 + used = 0 + columns[column].append(block) + ranks[block] = column + used += heights[block] + (gap if used else 0) + column += 1 + layers = columns + depth = column - 1 + links = [link for node in nodes for socket in node.inputs for link in socket.links if link.from_node in block_of] + edges, passages = [], [] + for link in links: + first, last = block_of[link.from_node], block_of[link.to_node] + if first == last: + raise ValueError("Equal operands must not contain an internal dependency") + start_offset = offsets[link.from_node] + sizes[link.from_node].sockets[link.from_socket] + end_offset = offsets[link.to_node] + sizes[link.to_node].sockets[link.to_socket] + previous, previous_offset, path = first, start_offset, [] + for rank in range(ranks[first] + 1, ranks[last]): + point = len(heights) + heights.append(16) + ranks[point] = rank + layers[rank].append(point) + edges.append((previous, point, previous_offset, 0)) + previous, previous_offset = point, 0 + path.append(point) + edges.append((previous, last, previous_offset, end_offset)) + if path: + passages.append((link, path)) + incoming, outgoing = defaultdict(list), defaultdict(list) + for first, last, a, b in edges: + outgoing[first].append((last, a, b)) + incoming[last].append((first, b, a)) + centers = {} + + def stack(layer): + height = sum(heights[item] for item in layer) + sum(separation(a, b) for a, b in zip(layer, layer[1:])) + cursor = -height / 2 + for index, item in enumerate(layer): + centers[item] = cursor + heights[item] / 2 + cursor += heights[item] + (separation(item, layer[index + 1]) if index + 1 < len(layer) else 0) + + def separation(first, last): + return gap if first < len(blocks) and last < len(blocks) else gap / 2 + + for layer in layers.values(): + stack(layer) + for _ in range(4): + for ranks_in_order, adjacent in ((range(depth + 1), incoming), (range(depth, -1, -1), outgoing)): + for rank in ranks_in_order: + layer = layers[rank] + def desired(item): + neighbors = adjacent[item] + return sum(centers[other] + their_offset - own_offset + for other, own_offset, their_offset in neighbors) / len(neighbors) if neighbors else centers[item] + layer.sort(key=lambda item: (desired(item), item)) + stack(layer) + + count = len(heights) + variable_count = count + 1 + len(edges) + inequalities, bounds, equalities, values = [], [], [], [] + + def constraint(terms, maximum): + row = np.zeros(variable_count) + for index, coefficient in terms: + row[index] += coefficient + inequalities.append(row) + bounds.append(maximum) + + for index, height in enumerate(heights): + constraint([(index, -1)], -height / 2) + constraint([(index, 1), (count, -1)], -height / 2) + for layer in layers.values(): + for first, last in zip(layer, layer[1:]): + constraint([(first, 1), (last, -1)], -(heights[first] + heights[last]) / 2 - separation(first, last)) + for index, (first, last, a, b) in enumerate(edges): + slack = count + 1 + index + constraint([(first, 1), (last, -1), (slack, -1)], b - a) + constraint([(last, 1), (first, -1), (slack, -1)], a - b) + for node in nodes: + inputs = [link.from_socket for link in peer_links(node)] + if len(inputs) < 2 or any(socket.node not in block_of for socket in inputs): + continue + output = next(socket for socket in node.outputs if socket in sizes[node].sockets) + first, last = inputs[0], inputs[-1] + row = np.zeros(variable_count) + row[block_of[node]] += 2 + row[block_of[first.node]] -= 1 + row[block_of[last.node]] -= 1 + equalities.append(row) + values.append(offsets[first.node] + sizes[first.node].sockets[first] + + offsets[last.node] + sizes[last.node].sockets[last] + - 2 * (offsets[node] + sizes[node].sockets[output])) + objective = np.zeros(variable_count) + objective[count] = len(edges) + 1 + objective[count + 1:] = 1 + result = linprog(objective, A_ub=np.array(inequalities), b_ub=bounds, + A_eq=np.array(equalities) if equalities else None, b_eq=values or None, + bounds=[(None, None)] * count + [(0, None)] * (1 + len(edges)), method="highs") + if not result.success: + raise ValueError(f"Expression layers could not be placed: {result.message}") + centers = result.x[:count] + positions, point_positions, right = {}, {}, 0 + for rank in range(depth + 1): + layer = layers[rank] + width = max(max(sizes[node].width for node in blocks[item]) if item < len(blocks) else 16 for item in layer) + if rank: + run = max([gap, *(abs(centers[first] + a - centers[last] - b) * 0.8 + for first, last, a, b in edges if ranks[last] == rank)]) + right += run + right += width + for item in layer: + if item < len(blocks): + for node in blocks[item]: + positions[node] = (right - sizes[node].width, -centers[item] - offsets[node]) + else: + point_positions[item] = (right, -centers[item]) + routes = [] + for link, path in passages: + points = [point_positions[item] for item in path] + points = [point for index, point in enumerate(points) if index == 0 or index == len(points)-1 + or not abs(points[index-1][1] - point[1]) < 1e-6 or not abs(points[index+1][1] - point[1]) < 1e-6] + routes.append((link, points)) + return FieldLayout(positions, right, result.x[count], routes) diff --git a/tools/nodes/arrangement/flow.py b/tools/nodes/arrangement/flow.py new file mode 100644 index 0000000..e904182 --- /dev/null +++ b/tools/nodes/arrangement/flow.py @@ -0,0 +1,151 @@ +"""Infer geometry flow and socket relationships for layout and preview.""" + +import json +from collections import defaultdict +from graphlib import TopologicalSorter +from pathlib import Path + +from .dimensions import visible_socket + +SEMANTICS = json.loads(Path(__file__).with_name("semantics.json").read_text(encoding="utf-8")) +SHARED_GEOMETRY_USES = 3 + + +def socket_links(socket): + return sorted(socket.links, key=lambda link: link.multi_input_sort_id, reverse=True) + + + +def origin_socket(socket): + while socket.node.bl_idname == "NodeReroute": + socket = socket.node.inputs[0].links[0].from_socket + return socket + + + +def graph(node_group): + def key(node): + if node.bl_idname in {"NodeGroupInput", "NodeReroute"}: + return (1, tuple(sorted((link.to_node.name, link.to_socket.identifier, + socket_links(link.to_socket).index(link)) + for socket in node.outputs for link in socket.links))) + return (0, node.name) + + nodes = sorted(node_group.nodes, key=key) + parents = {node: [] for node in nodes} + children = {node: set() for node in nodes} + for link in node_group.links: + if link.from_node not in parents[link.to_node]: + parents[link.to_node].append(link.from_node) + children[link.from_node].add(link.to_node) + for sources in parents.values(): + sources.sort(key=key) + order = list(TopologicalSorter(parents).static_order()) + ancestors = {} + for node in order: + ancestors[node] = set(parents[node]) | set().union(*(ancestors[p] for p in parents[node])) + return parents, children, order, ancestors + + + +def geometry_paths(node_group): + """Trace every geometry input and split paths at forks and merges.""" + output = next((node for node in node_group.nodes if node.bl_idname == "NodeGroupOutput" and node.is_active_output), None) + parents = {} + uses = defaultdict(set) + for link in node_group.links: + if link.from_socket.type == "GEOMETRY" and link.to_node.bl_idname != "NodeReroute": + uses[origin_socket(link.from_socket).node].add(link.to_node) + paired = {member for node in node_group.nodes if getattr(node, "paired_output", None) is not None + for member in (node, node.paired_output)} + result_nodes, pending = set(), [*paired, *([output] if output else [])] + while pending: + node = pending.pop() + if node in result_nodes: + continue + result_nodes.add(node) + pending.extend(origin_socket(link.from_socket).node + for socket in node.inputs if socket.type == "GEOMETRY" for link in socket.links) + shared = {node for node, targets in uses.items() + if len(targets) >= SHARED_GEOMETRY_USES and node not in paired + and (not any(socket.type == "GEOMETRY" and socket.is_linked for socket in node.inputs) + or len(targets & result_nodes) >= SHARED_GEOMETRY_USES)} + + def source_node(link): + return link.from_node if link.from_node.get("arrangement_input") else origin_socket(link.from_socket).node + + def collect(node): + if node in parents: + return + parents[node] = list(dict.fromkeys( + source_node(link) for socket in node.inputs if socket.type == "GEOMETRY" + for link in socket_links(socket) + if source_node(link).bl_idname != "NodeGroupInput" and source_node(link) not in shared + )) if not node.get("arrangement_input") else [] + for parent in parents[node]: + collect(parent) + + roots = [] + if output is not None: + collect(output) + roots.append(output) + for node in node_group.nodes: + if node in paired and node not in parents: + collect(node) + roots.append(node) + children = {node: set() for node in parents} + for node, sources in parents.items(): + for source in sources: + children[source].add(node) + paths, assigned, pending = [], set(), roots + while pending: + node = pending.pop(0) + path = [] + while node not in assigned: + path.append(node) + assigned.add(node) + sources = parents[node] + if len(sources) != 1 or len(children[sources[0]]) != 1: + pending.extend(source for source in sources if source not in assigned) + break + node = sources[0] + if path: + paths.append(list(reversed(path))) + return parents, paths + + + +def shared_geometry_outputs(nodes): + """Find geometry outputs reused by several distinct operations.""" + shared = [] + for node in nodes: + for socket in node.outputs: + if socket.type != "GEOMETRY": + continue + targets, pending = set(), [link.to_node for link in socket.links] + while pending: + target = pending.pop() + if target.bl_idname == "NodeReroute": + pending.extend(link.to_node for link in target.outputs[0].links) + else: + targets.add(target) + if len(targets) >= SHARED_GEOMETRY_USES: + shared.append(socket) + return shared + + + +def peer_links(node): + """Read ordered data operands from the semantic table, excluding controls.""" + rule = SEMANTICS.get(node.bl_idname) + if rule is None or ("operations" in rule and node.operation not in rule["operations"]): + return [] + return [link for socket in list(node.inputs)[rule["peer_inputs_from"]:] + if visible_socket(socket) and socket.type != "CUSTOM" + for link in socket_links(socket)] + + +def node_roles(node_group): + """Classify by contribution to the result, not by available socket types.""" + core, _ = geometry_paths(node_group) + return {node: "geometry" if node in core else "field" for node in node_group.nodes} diff --git a/tools/nodes/arrangement/semantics.json b/tools/nodes/arrangement/semantics.json new file mode 100644 index 0000000..f37dab2 --- /dev/null +++ b/tools/nodes/arrangement/semantics.json @@ -0,0 +1,10 @@ +{ + "GeometryNodeJoinGeometry": {"peer_inputs_from": 0}, + "GeometryNodeMenuSwitch": {"peer_inputs_from": 1}, + "GeometryNodeSwitch": {"peer_inputs_from": 1}, + "ShaderNodeCombineXYZ": {"peer_inputs_from": 0}, + "FunctionNodeBooleanMath": { + "peer_inputs_from": 0, + "operations": ["AND", "OR", "NAND", "NOR", "XNOR", "XOR", "IMPLY", "NIMPLY"] + } +} diff --git a/tools/nodes/arrangement/zones.py b/tools/nodes/arrangement/zones.py new file mode 100644 index 0000000..9eed810 --- /dev/null +++ b/tools/nodes/arrangement/zones.py @@ -0,0 +1,28 @@ +"""Identify paired execution zones and their exclusive input calculations.""" + +from graphlib import TopologicalSorter + + +def node_zones(node_group): + """Return each paired entry, exit, and its data-dependent node membership.""" + parents = {node: set() for node in node_group.nodes} + children = {node: set() for node in node_group.nodes} + for link in node_group.links: + parents[link.to_node].add(link.from_node) + children[link.from_node].add(link.to_node) + order = list(TopologicalSorter(parents).static_order()) + ancestors = {} + for node in order: + ancestors[node] = parents[node] | set().union(*(ancestors[parent] for parent in parents[node])) + zones = [] + for entry in order: + end = getattr(entry, "paired_output", None) + if end is None: + continue + members = {node for node in ancestors[end] if entry in ancestors[node]} | {entry, end} + for node in reversed(order): + if (node not in members and node in ancestors[end] and children[node] + and children[node] <= members and entry not in children[node]): + members.add(node) + zones.append((entry, end, members)) + return zones diff --git a/tools/nodes/build.py b/tools/nodes/build.py new file mode 100644 index 0000000..13ffef6 --- /dev/null +++ b/tools/nodes/build.py @@ -0,0 +1,48 @@ +"""Build, arrange, and save the bundled node library.""" +from pathlib import Path + +import bpy + +from .arrangement.arrange import arrange_nodes +from .groups.image_plane import build_image_plane_group +from .groups.image_depth_plane import build_image_depth_plane_group +from .groups.image_relief_plane import build_image_relief_plane_group +from .groups.image_cutout import build_image_cutout_group +from .groups.image_depth_cutout import build_image_depth_cutout_group +from .groups.image_depth_balloon import build_image_depth_balloon_group +from .groups.image_layer import build_image_layer_group +from .groups.image_depth_layer import build_image_depth_layer_group +from .groups.shadeless import build_shadeless_group + + +OUTPUT_PATH = Path(__file__).resolve().parents[2] / "src/anyimage/assets/O_AnyImage.blend" + + +def build_node_groups(): + """Build each asset once in dependency order.""" + image_plane = build_image_plane_group() + return [ + image_plane, + build_image_depth_plane_group(image_plane), + build_image_relief_plane_group(image_plane), + build_image_cutout_group(), + build_image_depth_cutout_group(), + build_image_depth_balloon_group(), + build_image_layer_group(), + build_image_depth_layer_group(), + build_shadeless_group(), + ] + + +def main(): + bpy.ops.wm.read_factory_settings(use_empty=True) + bpy.context.preferences.filepaths.save_version = 0 + for node_group in build_node_groups(): + if node_group.bl_idname == "GeometryNodeTree": + arrange_nodes(node_group) + OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True) + bpy.ops.wm.save_as_mainfile(filepath=str(OUTPUT_PATH), check_existing=False) + + +if __name__ == "__main__": + main() diff --git a/tools/nodes/check.py b/tools/nodes/check.py new file mode 100644 index 0000000..c7f964d --- /dev/null +++ b/tools/nodes/check.py @@ -0,0 +1,71 @@ +"""Reload saved assets and evaluate geometry in the target Blender process.""" +from math import isfinite +from pathlib import Path + +import bpy + + +ASSET_PATH = Path(__file__).resolve().parents[2] / "src/anyimage/assets/O_AnyImage.blend" +GEOMETRY_NAMES = { + "O Image Plane", "O Image Depth Plane", "O Image Relief Plane", + "O Image Cutout", "O Image Depth Cutout", "O Image Depth Balloon", +} +SHADER_NAMES = {"O Image Layer", "O Image Depth Layer", "O Shadeless"} + + +def check_asset_library(asset_path): + """Check library completeness and finite, nonempty evaluated geometry.""" + with bpy.data.libraries.load(str(asset_path), link=True) as (source, target): + assert set(source.node_groups) == GEOMETRY_NAMES | SHADER_NAMES + target.node_groups = list(source.node_groups) + depth_image = bpy.data.images.new("Asset Check Depth", width=8, height=8, float_buffer=True) + depth_image.colorspace_settings.name = "Non-Color" + depth_image.pixels.foreach_set([ + value for y in range(8) for x in range(8) + for value in ((x + 0.5) / 8 - 0.5, (y + 0.5) / 8 - 0.5, 2.0, 1.0) + ]) + for node_group in target.node_groups: + expected_type = "GeometryNodeTree" if node_group.name in GEOMETRY_NAMES else "ShaderNodeTree" + assert node_group.bl_idname == expected_type and node_group.library is not None, node_group.name + if expected_type == "ShaderNodeTree": + continue + mesh = bpy.data.meshes.new("Asset Check Plane") + mesh.from_pydata([(-0.5, -0.5, 0), (0.5, -0.5, 0), (0.5, 0.5, 0), (-0.5, 0.5, 0)], [], [(0, 1, 2, 3)]) + uv = mesh.uv_layers.new(name="UVMap") + for loop in mesh.loops: + position = mesh.vertices[loop.vertex_index].co + uv.data[loop.index].uv = (position.x + 0.5, position.y + 0.5) + profile = mesh.attributes.new("o_balloon", "FLOAT", "POINT") + for value in profile.data: + value.value = 0.2 + obj = bpy.data.objects.new("Asset Check", mesh) + bpy.context.collection.objects.link(obj) + modifier = obj.modifiers.new("Asset Check", "NODES") + modifier.node_group = node_group + values = {"Subdivide": 0, "Thickness": 0.2, "Reference Depth": 2.0, + "Depth Image": depth_image, "Uniform Scale": 1.0, "Normal Smooth": 0} + for item in node_group.interface.items_tree: + if item.item_type == "SOCKET" and item.in_out == "INPUT" and item.name in values: + modifier[item.identifier] = values[item.name] + obj.update_tag(refresh={"DATA"}) + bpy.context.view_layer.update() + evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) + result = evaluated.to_mesh() + try: + assert len(result.vertices) and len(result.polygons), node_group.name + assert all(isfinite(value) for vertex in result.vertices for value in vertex.co), node_group.name + finally: + evaluated.to_mesh_clear() + bpy.data.objects.remove(obj, do_unlink=True) + bpy.data.meshes.remove(mesh) + bpy.data.images.remove(depth_image) + print(f"Checked {len(target.node_groups)} saved node groups in Blender {bpy.app.version_string}") + + +def main(): + bpy.ops.wm.read_factory_settings(use_empty=True) + check_asset_library(ASSET_PATH) + + +if __name__ == "__main__": + main() diff --git a/tools/build_node.py b/tools/nodes/cli.py similarity index 63% rename from tools/build_node.py rename to tools/nodes/cli.py index 24fa78f..f18de1d 100644 --- a/tools/build_node.py +++ b/tools/nodes/cli.py @@ -4,14 +4,14 @@ import shutil import subprocess import sys from pathlib import Path +from tempfile import TemporaryDirectory +from zipfile import ZipFile -PROJECT_ROOT = Path(__file__).resolve().parents[1] -BLENDER_SCRIPTS = ( - "asset_library.py", - "asset_validation.py", - "image_plane_validation.py", - "cutout_validation.py", +PROJECT_ROOT = Path(__file__).resolve().parents[2] +BLENDER_MODULES = ( + "tools.nodes.build", + "tools.nodes.check", ) @@ -73,16 +73,26 @@ def main(): options = parser.parse_args() executable = blender_path(options.blender) - for script_name in BLENDER_SCRIPTS: - run( - ( - executable, - "--factory-startup", - "--background", - "--python", - PROJECT_ROOT / "tools" / "node_assets" / script_name, - ) + with TemporaryDirectory(prefix="anyimage-node-dependencies-") as directory: + for wheel in (PROJECT_ROOT / "src" / "anyimage" / "wheels").glob("scipy-*.whl"): + with ZipFile(wheel) as archive: + archive.extractall(Path(directory) / wheel.name.split("-")[2]) + paths = [str(PROJECT_ROOT), str(PROJECT_ROOT / "src"), + str(next((PROJECT_ROOT / "src/anyimage/wheels").glob("blendjob-*.whl")))] + bootstrap = ( + f"import sys, runpy; sys.path[:0] = {paths!r}; " + f"sys.path.insert(0, {directory!r} + '/cp' + str(sys.version_info.major) + str(sys.version_info.minor)); " ) + for module_name in BLENDER_MODULES: + run( + ( + executable, + "--factory-startup", + "--background", + "--python-exit-code", "1", + "--python-expr", bootstrap + f"runpy.run_module({module_name!r}, run_name='__main__')", + ) + ) if not options.skip_tests: run((sys.executable, "-m", "pytest")) diff --git a/tools/nodes/common/__init__.py b/tools/nodes/common/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tools/nodes/common/__init__.py @@ -0,0 +1 @@ + diff --git a/tools/nodes/common/cutout.py b/tools/nodes/common/cutout.py new file mode 100644 index 0000000..c01ffce --- /dev/null +++ b/tools/nodes/common/cutout.py @@ -0,0 +1,162 @@ +"""Build geometry calculations shared by Cutout assets.""" +from .nodes import math_node +from anyimage.operators.cutout_tool.shape import BALLOON_ATTRIBUTE_NAME + + +def _island_cleanup(group, geometry, cleanup): + nodes = group.nodes + links = group.links + mesh_island = nodes.new("GeometryNodeInputMeshIsland") + face_area = nodes.new("GeometryNodeInputMeshFaceArea") + island_area = nodes.new("GeometryNodeAccumulateField") + island_area.data_type = "FLOAT" + island_area.domain = "FACE" + links.new(face_area.outputs["Area"], island_area.inputs["Value"]) + links.new(mesh_island.outputs["Island Index"], island_area.inputs["Group ID"]) + + tiny = math_node(nodes, "LESS_THAN") + links.new(island_area.outputs["Total"], tiny.inputs[0]) + links.new(cleanup, tiny.inputs[1]) + delete = nodes.new("GeometryNodeDeleteGeometry") + delete.domain = "FACE" + links.new(geometry, delete.inputs["Geometry"]) + links.new(tiny.outputs[0], delete.inputs["Selection"]) + return delete.outputs["Geometry"] + + +def _profile_field(group, geometry): + nodes = group.nodes + links = group.links + profile = nodes.new("GeometryNodeInputNamedAttribute") + profile.data_type = "FLOAT" + profile.inputs["Name"].default_value = BALLOON_ATTRIBUTE_NAME + profile_max = nodes.new("GeometryNodeAttributeStatistic") + profile_max.data_type = "FLOAT" + profile_max.domain = "POINT" + links.new(geometry, profile_max.inputs["Geometry"]) + links.new(profile.outputs["Attribute"], profile_max.inputs["Attribute"]) + safe_max = math_node(nodes, "MAXIMUM") + safe_max.inputs[1].default_value = 1e-6 + links.new(profile_max.outputs["Max"], safe_max.inputs[0]) + normalized = math_node(nodes, "DIVIDE") + links.new(profile.outputs["Attribute"], normalized.inputs[0]) + links.new(safe_max.outputs[0], normalized.inputs[1]) + smooth = nodes.new("ShaderNodeMapRange") + smooth.data_type = "FLOAT" + smooth.interpolation_type = "SMOOTHERSTEP" + smooth.clamp = True + smooth.inputs["From Min"].default_value = 0.0 + smooth.inputs["From Max"].default_value = 1.0 + smooth.inputs["To Min"].default_value = 0.0 + smooth.inputs["To Max"].default_value = 1.0 + links.new(normalized.outputs[0], smooth.inputs["Value"]) + return profile.outputs["Attribute"], smooth.outputs["Result"] + + +def _extrude_layers(group, geometry): + node = group.nodes.new("GeometryNodeExtrudeMesh") + node.mode = "FACES" + node.inputs["Individual"].default_value = False + node.inputs["Offset"].default_value = (0.0, 0.0, 1.0) + # Keep distinct front and back vertices before overriding their Z positions. + node.inputs["Offset Scale"].default_value = 1.0 + group.links.new(geometry, node.inputs["Mesh"]) + return node + + +def _offset_geometry_z(group, geometry, z_value): + nodes = group.nodes + links = group.links + offset = nodes.new("ShaderNodeCombineXYZ") + links.new(z_value, offset.inputs["Z"]) + set_position = nodes.new("GeometryNodeSetPosition") + links.new(geometry, set_position.inputs["Geometry"]) + links.new(offset.outputs["Vector"], set_position.inputs["Offset"]) + return set_position.outputs["Geometry"] + + +def _set_layer_z(group, geometry, extrude, front_z, back_z): + nodes = group.nodes + links = group.links + choose_z = nodes.new("GeometryNodeSwitch") + choose_z.input_type = "FLOAT" + links.new(extrude.outputs["Top"], choose_z.inputs["Switch"]) + links.new(front_z, choose_z.inputs["False"]) + links.new(back_z, choose_z.inputs["True"]) + position = nodes.new("GeometryNodeInputPosition") + separate = nodes.new("ShaderNodeSeparateXYZ") + links.new(position.outputs["Position"], separate.inputs["Vector"]) + combine = nodes.new("ShaderNodeCombineXYZ") + links.new(separate.outputs["X"], combine.inputs["X"]) + links.new(separate.outputs["Y"], combine.inputs["Y"]) + links.new(choose_z.outputs["Output"], combine.inputs["Z"]) + set_position = nodes.new("GeometryNodeSetPosition") + links.new(extrude.outputs["Mesh"], set_position.inputs["Geometry"]) + links.new(combine.outputs["Vector"], set_position.inputs["Position"]) + + side = nodes.new("GeometryNodeSeparateGeometry") + side.domain = "FACE" + links.new(set_position.outputs["Geometry"], side.inputs["Geometry"]) + links.new(extrude.outputs["Side"], side.inputs["Selection"]) + flip_side = nodes.new("GeometryNodeFlipFaces") + links.new(side.outputs["Selection"], flip_side.inputs["Mesh"]) + front = _offset_geometry_z( + group, geometry, front_z + ) + back = _offset_geometry_z( + group, geometry, back_z + ) + flip_back = nodes.new("GeometryNodeFlipFaces") + links.new(back, flip_back.inputs["Mesh"]) + + join = nodes.new("GeometryNodeJoinGeometry") + links.new(front, join.inputs["Geometry"]) + links.new(flip_back.outputs["Mesh"], join.inputs["Geometry"]) + links.new(flip_side.outputs["Mesh"], join.inputs["Geometry"]) + merge = nodes.new("GeometryNodeMergeByDistance") + merge.mode = "ALL" + merge.inputs["Distance"].default_value = 1e-6 + links.new(join.outputs["Geometry"], merge.inputs["Geometry"]) + return merge.outputs["Geometry"] + + +def _remove_zero_thickness( + group, + zero_geometry, + shaped_geometry, + thickness, +): + nodes = group.nodes + links = group.links + zero = math_node(nodes, "LESS_THAN") + zero.inputs[1].default_value = 1e-6 + links.new(thickness, zero.inputs[0]) + choose = nodes.new("GeometryNodeSwitch") + choose.input_type = "GEOMETRY" + links.new(zero.outputs[0], choose.inputs["Switch"]) + links.new(shaped_geometry, choose.inputs["False"]) + links.new(zero_geometry, choose.inputs["True"]) + return choose.outputs["Output"] + + +def _position_field(group, depth_image): + nodes = group.nodes + links = group.links + uv = nodes.new("GeometryNodeInputNamedAttribute") + uv.data_type = "FLOAT_VECTOR" + uv.inputs["Name"].default_value = "UVMap" + position_texture = nodes.new("GeometryNodeImageTexture") + position_texture.interpolation = "Linear" + position_texture.extension = "EXTEND" + links.new(depth_image, position_texture.inputs["Image"]) + links.new(uv.outputs["Attribute"], position_texture.inputs["Vector"]) + return position_texture.outputs["Color"] + + +def _shade_output(group, geometry): + shade = group.nodes.new("GeometryNodeSetShadeSmooth") + shade.domain = "FACE" + shade.inputs["Shade Smooth"].default_value = True + output = group.nodes.new("NodeGroupOutput") + group.links.new(geometry, shade.inputs["Geometry"]) + group.links.new(shade.outputs["Geometry"], output.inputs["Geometry"]) diff --git a/tools/node_assets/depth_surface.py b/tools/nodes/common/depth_surface.py similarity index 95% rename from tools/node_assets/depth_surface.py rename to tools/nodes/common/depth_surface.py index 688f7fd..09374e2 100644 --- a/tools/node_assets/depth_surface.py +++ b/tools/nodes/common/depth_surface.py @@ -272,10 +272,9 @@ def thicken_depth_surface(group, geometry, thickness, normal_smooth=None): return result.outputs["Geometry"] -def _projection_math(nodes, operation, location): +def _projection_math(nodes, operation): node = nodes.new("ShaderNodeMath") node.operation = operation - node.location = location return node @@ -286,45 +285,37 @@ def project_depth_surface( nodes, links = group.nodes, group.links scaled = nodes.new("ShaderNodeVectorMath") scaled.operation = "SCALE" - scaled.location = (3100.0, -100.0) links.new(camera_position, scaled.inputs[0]) links.new(uniform_scale, scaled.inputs[3]) separate = nodes.new("ShaderNodeSeparateXYZ") - separate.location = (3300.0, -100.0) links.new(scaled.outputs["Vector"], separate.inputs["Vector"]) - relative_depth = _projection_math(nodes, "SUBTRACT", (3500.0, -100.0)) + relative_depth = _projection_math(nodes, "SUBTRACT") links.new(reference_depth, relative_depth.inputs[0]) links.new(separate.outputs["Z"], relative_depth.inputs[1]) - front = _projection_math(nodes, "MULTIPLY", (3700.0, -100.0)) + front = _projection_math(nodes, "MULTIPLY") links.new(relative_depth.outputs[0], front.inputs[0]) links.new(depth_scale, front.inputs[1]) - negative_y = _projection_math(nodes, "MULTIPLY", (3500.0, -600.0)) + negative_y = _projection_math(nodes, "MULTIPLY") negative_y.inputs[1].default_value = -1.0 links.new(separate.outputs["Y"], negative_y.inputs[0]) projected = nodes.new("ShaderNodeCombineXYZ") - projected.location = (3700.0, -450.0) links.new(separate.outputs["X"], projected.inputs["X"]) links.new(negative_y.outputs[0], projected.inputs["Y"]) current_position = nodes.new("GeometryNodeInputPosition") - current_position.location = (3500.0, -850.0) blend = nodes.new("ShaderNodeMix") blend.data_type = "VECTOR" blend.clamp_factor = False - blend.location = (3900.0, -450.0) links.new(depth_scale, blend.inputs[0]) links.new(current_position.outputs["Position"], blend.inputs[4]) links.new(projected.outputs["Vector"], blend.inputs[5]) blended_axes = nodes.new("ShaderNodeSeparateXYZ") - blended_axes.location = (4100.0, -500.0) links.new(blend.outputs["Result"], blended_axes.inputs["Vector"]) surface_position = nodes.new("ShaderNodeCombineXYZ") - surface_position.location = (4100.0, -200.0) links.new(blended_axes.outputs["X"], surface_position.inputs["X"]) links.new(blended_axes.outputs["Y"], surface_position.inputs["Y"]) links.new(front.outputs[0], surface_position.inputs["Z"]) project_geometry = nodes.new("GeometryNodeSetPosition") - project_geometry.location = (4400.0, 400.0) links.new(geometry, project_geometry.inputs["Geometry"]) links.new(surface_position.outputs["Vector"], project_geometry.inputs["Position"]) diff --git a/tools/nodes/common/materials.py b/tools/nodes/common/materials.py new file mode 100644 index 0000000..729ad5e --- /dev/null +++ b/tools/nodes/common/materials.py @@ -0,0 +1,39 @@ +"""Build shared material inputs, roughness, and bump outputs.""" +BUMP_BASE_DISTANCE = 0.001 + + +def build_material_nodes(group): + nodes = group.nodes + group_input = nodes.new("NodeGroupInput") + group_input.location = (-500.0, 100.0) + group_output = nodes.new("NodeGroupOutput") + group_output.location = (300.0, 100.0) + roughness_curve = nodes.new("ShaderNodeFloatCurve") + roughness_curve.mapping.initialize() + curve_points = roughness_curve.mapping.curves[0].points + curve_points[0].location = (0.0, 0.5) + curve_points[-1].location = (1.0, 0.1) + roughness_curve.mapping.update() + roughness_curve.location = (-250.0, 350.0) + bump = nodes.new("ShaderNodeBump") + bump.location = (100.0, 0.0) + return group_input, group_output, roughness_curve, bump + + +def connect_material_outputs(group, group_input, group_output, roughness_curve, bump, processed_alpha, bump_location): + nodes, links = group.nodes, group.links + links.new(group_input.outputs["Color"], group_output.inputs["Color"]) + links.new(group_input.outputs["Color"], roughness_curve.inputs["Value"]) + links.new( + roughness_curve.outputs["Value"], + group_output.inputs["Roughness"], + ) + links.new(processed_alpha, group_output.inputs["Alpha"]) + links.new(group_input.outputs["Color"], bump.inputs["Height"]) + bump_distance = nodes.new("ShaderNodeMath") + bump_distance.operation = "MULTIPLY" + bump_distance.inputs[1].default_value = BUMP_BASE_DISTANCE + bump_distance.location = bump_location + links.new(group_input.outputs["Bump Scale"], bump_distance.inputs[0]) + links.new(bump_distance.outputs[0], bump.inputs["Distance"]) + links.new(bump.outputs["Normal"], group_output.inputs["Normal"]) diff --git a/tools/nodes/common/nodes.py b/tools/nodes/common/nodes.py new file mode 100644 index 0000000..f83be84 --- /dev/null +++ b/tools/nodes/common/nodes.py @@ -0,0 +1,106 @@ +"""Build interfaces and basic nodes shared by asset definitions.""" +from types import SimpleNamespace + + +def prepare_node_group(node_group): + """Hide unused outputs and discard disconnected interface nodes.""" + for node in list(node_group.nodes): + for socket in node.outputs: + socket.hide = not socket.is_linked + if node.bl_idname == "NodeGroupInput" and not any(socket.is_linked for socket in node.outputs): + node_group.nodes.remove(node) + + +def interface_socket(group, name, in_out, socket_type, parent=None): + socket = group.interface.new_socket( + name=name, + in_out=in_out, + socket_type=socket_type, + parent=parent, + ) + if in_out == "INPUT" and hasattr(socket, "structure_type"): + socket.structure_type = "SINGLE" + return socket + + +def group_input(nodes, visible_names): + node = nodes.new("NodeGroupInput") + visible = set(visible_names) + for output in node.outputs: + output.hide = output.name not in visible + return node + + +def float_input( + group, + name, + default, + minimum=0.0, + maximum=None, + subtype=None, + parent=None, +): + socket = interface_socket(group, name, "INPUT", "NodeSocketFloat", parent) + socket.default_value = default + socket.min_value = minimum + if maximum is not None: + socket.max_value = maximum + if subtype is not None: + socket.subtype = subtype + return socket + + +def bool_input(group, name, default=False, parent=None): + socket = interface_socket(group, name, "INPUT", "NodeSocketBool", parent) + socket.default_value = default + return socket + + +def vector_input(group, name, default, subtype=None, parent=None): + socket = interface_socket(group, name, "INPUT", "NodeSocketVector", parent) + socket.default_value = default + if subtype is not None: + socket.subtype = subtype + return socket + + +def math_node(nodes, operation): + node = nodes.new("ShaderNodeMath") + node.operation = operation + return node + + +def menu_switch(group, control, names, kind): + selector = next((node for node in group.nodes if node.bl_idname == "GeometryNodeMenuSwitch" and node.inputs["Menu"].is_linked and node.inputs["Menu"].links[0].from_socket.name == control), None) + if selector is None: + selector = group.nodes.new("GeometryNodeMenuSwitch") + selector.data_type = "BOOLEAN" + for item, name in zip(selector.enum_items, names): + item.name = name + selector.inputs[names[0]].default_value = False + selector.inputs[names[1]].default_value = True + local = group_input(group.nodes, {control}) + group.links.new(local.outputs[control], selector.inputs["Menu"]) + node = group.nodes.new("GeometryNodeSwitch") + node.input_type = kind + group.links.new(selector.outputs[0], node.inputs["Switch"]) + return SimpleNamespace(inputs={names[0]: node.inputs["False"], names[1]: node.inputs["True"]}, outputs=node.outputs) + + +def read_float_attribute(group, name): + node = group.nodes.new("GeometryNodeInputNamedAttribute") + node.data_type = "FLOAT" + node.inputs["Name"].default_value = name + return node.outputs["Attribute"] + + +def store_float_attribute(group, geometry, name, value, domain="POINT"): + node = group.nodes.new("GeometryNodeStoreNamedAttribute") + node.domain, node.data_type = domain, "FLOAT" + node.inputs["Name"].default_value = name + group.links.new(geometry, node.inputs["Geometry"]) + if isinstance(value, (int, float)): + node.inputs["Value"].default_value = value + else: + group.links.new(value, node.inputs["Value"]) + return node.outputs["Geometry"] diff --git a/tools/node_assets/node_graph.py b/tools/nodes/common/smoothing.py similarity index 75% rename from tools/node_assets/node_graph.py rename to tools/nodes/common/smoothing.py index ea417ec..91ca6fa 100644 --- a/tools/node_assets/node_graph.py +++ b/tools/nodes/common/smoothing.py @@ -1,19 +1,9 @@ -"""Node graph builders used only while generating bundled assets.""" +"""Build shared boundary fields and iterative smoothing.""" +from .nodes import group_input -def group_input(nodes, location, visible_names): - node = nodes.new("NodeGroupInput") - node.location = location - visible = set(visible_names) - for output in node.outputs: - output.hide = output.name not in visible - return node - - -def edge_boundary_field(nodes, links, location): - x, y = location +def edge_boundary_field(nodes, links): vertex_neighbors = nodes.new("GeometryNodeInputMeshVertexNeighbors") - vertex_neighbors.location = (x, y + 150.0) loose_vertex = nodes.new("FunctionNodeCompare") loose_vertex.data_type = "INT" loose_vertex.operation = "EQUAL" @@ -24,11 +14,9 @@ def edge_boundary_field(nodes, links, location): socket for socket in loose_vertex.inputs if socket.identifier == "B_INT" ) loose_vertex_b.default_value = 1 - loose_vertex.location = (x + 200.0, y + 150.0) links.new(vertex_neighbors.outputs["Vertex Count"], loose_vertex_a) edge_neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors") - edge_neighbors.location = (x, y - 150.0) boundary_edge = nodes.new("FunctionNodeCompare") boundary_edge.data_type = "INT" boundary_edge.operation = "EQUAL" @@ -39,142 +27,115 @@ def edge_boundary_field(nodes, links, location): socket for socket in boundary_edge.inputs if socket.identifier == "B_INT" ) boundary_edge_b.default_value = 1 - boundary_edge.location = (x + 200.0, y - 150.0) links.new(edge_neighbors.outputs["Face Count"], boundary_edge_a) edge_domain = nodes.new("GeometryNodeFieldOnDomain") edge_domain.data_type = "BOOLEAN" edge_domain.domain = "EDGE" - edge_domain.location = (x + 400.0, y - 150.0) links.new(boundary_edge.outputs["Result"], edge_domain.inputs["Value"]) is_boundary = nodes.new("FunctionNodeBooleanMath") is_boundary.operation = "OR" - is_boundary.location = (x + 600.0, y) links.new(edge_domain.outputs["Value"], is_boundary.inputs[0]) links.new(loose_vertex.outputs["Result"], is_boundary.inputs[1]) return is_boundary.outputs["Boolean"] def expand_smooth( - group, geometry, x_offset=0.0, iterations_name="Iterations", weight_name="Smooth" + group, geometry, iterations_name="Iterations", weight_name="Smooth" ): nodes = group.nodes links = group.links - existing_nodes = set(nodes) - iteration_controls = group_input(nodes, (550.0, 1150.0), {iterations_name}) - weight_controls = group_input(nodes, (2300.0, 1450.0), {weight_name}) + iteration_controls = group_input(nodes, {iterations_name}) + weight_controls = group_input(nodes, {weight_name}) repeat_output = nodes.new("GeometryNodeRepeatOutput") - repeat_output.location = (3050.0, 550.0) repeat_input = nodes.new("GeometryNodeRepeatInput") repeat_input.pair_with_output(repeat_output) repeat_output.repeat_items.new("GEOMETRY", "Geometry") - repeat_input.location = (550.0, 550.0) links.new( iteration_controls.outputs[iterations_name], repeat_input.inputs["Iterations"], ) links.new(geometry, repeat_input.inputs["Geometry"]) - boundary_field = edge_boundary_field(nodes, links, (750.0, -350.0)) + boundary_field = edge_boundary_field(nodes, links) boundary_points = nodes.new("GeometryNodeFieldOnDomain") boundary_points.data_type = "BOOLEAN" boundary_points.domain = "POINT" - boundary_points.location = (1550.0, -350.0) links.new(boundary_field, boundary_points.inputs["Value"]) boundary_geometry = nodes.new("GeometryNodeSeparateGeometry") boundary_geometry.domain = "POINT" - boundary_geometry.location = (1750.0, -150.0) links.new(repeat_input.outputs["Geometry"], boundary_geometry.inputs["Geometry"]) links.new(boundary_points.outputs["Value"], boundary_geometry.inputs["Selection"]) position = nodes.new("GeometryNodeInputPosition") - position.location = (750.0, 850.0) smooth_all = nodes.new("GeometryNodeBlurAttribute") smooth_all.data_type = "FLOAT_VECTOR" smooth_all.inputs["Iterations"].default_value = 1 smooth_all.inputs["Weight"].default_value = 1.0 - smooth_all.location = (950.0, 850.0) links.new(position.outputs["Position"], smooth_all.inputs["Value"]) boundary_position = nodes.new("GeometryNodeInputPosition") - boundary_position.location = (1350.0, 250.0) smooth_boundary = nodes.new("GeometryNodeBlurAttribute") smooth_boundary.data_type = "FLOAT_VECTOR" smooth_boundary.inputs["Iterations"].default_value = 1 smooth_boundary.inputs["Weight"].default_value = 0.5 - smooth_boundary.location = (1550.0, 250.0) links.new(boundary_position.outputs["Position"], smooth_boundary.inputs["Value"]) nearest_boundary = nodes.new("GeometryNodeSampleNearest") nearest_boundary.domain = "POINT" - nearest_boundary.location = (1800.0, 150.0) links.new(boundary_geometry.outputs["Selection"], nearest_boundary.inputs["Geometry"]) sample_boundary = nodes.new("GeometryNodeSampleIndex") sample_boundary.data_type = "FLOAT_VECTOR" sample_boundary.domain = "POINT" sample_boundary.clamp = False - sample_boundary.location = (2050.0, 250.0) links.new(boundary_geometry.outputs["Selection"], sample_boundary.inputs["Geometry"]) links.new(smooth_boundary.outputs["Value"], sample_boundary.inputs["Value"]) links.new(nearest_boundary.outputs["Index"], sample_boundary.inputs["Index"]) choose_boundary = nodes.new("GeometryNodeSwitch") choose_boundary.input_type = "VECTOR" - choose_boundary.location = (2300.0, 350.0) - direct_boundary = edge_boundary_field(nodes, links, (750.0, 450.0)) + direct_boundary = edge_boundary_field(nodes, links) links.new(direct_boundary, choose_boundary.inputs["Switch"]) links.new(smooth_all.outputs["Value"], choose_boundary.inputs["False"]) links.new(sample_boundary.outputs["Value"], choose_boundary.inputs["True"]) boundary_mix = nodes.new("ShaderNodeMix") boundary_mix.data_type = "VECTOR" boundary_mix.inputs[0].default_value = 1.0 - boundary_mix.location = (2550.0, 400.0) links.new(smooth_all.outputs["Value"], boundary_mix.inputs[4]) links.new(choose_boundary.outputs["Output"], boundary_mix.inputs[5]) normal = nodes.new("GeometryNodeInputNormal") - normal.location = (1350.0, 1050.0) smooth_normal = nodes.new("GeometryNodeBlurAttribute") smooth_normal.data_type = "FLOAT_VECTOR" smooth_normal.inputs["Iterations"].default_value = 10 smooth_normal.inputs["Weight"].default_value = 1.0 - smooth_normal.location = (1550.0, 1050.0) links.new(normal.outputs["Normal"], smooth_normal.inputs["Value"]) normal_coherence = nodes.new("ShaderNodeVectorMath") normal_coherence.operation = "LENGTH" - normal_coherence.location = (1800.0, 1050.0) links.new(smooth_normal.outputs["Value"], normal_coherence.inputs[0]) sharp_switch = nodes.new("GeometryNodeSwitch") sharp_switch.input_type = "FLOAT" sharp_switch.inputs["Switch"].default_value = True sharp_switch.inputs["False"].default_value = 1.0 - sharp_switch.location = (2050.0, 1050.0) links.new(normal_coherence.outputs["Value"], sharp_switch.inputs["True"]) smooth_weight = nodes.new("ShaderNodeMath") smooth_weight.operation = "MULTIPLY" - smooth_weight.location = (2300.0, 1050.0) links.new(weight_controls.outputs[weight_name], smooth_weight.inputs[0]) links.new(sharp_switch.outputs["Output"], smooth_weight.inputs[1]) target_position = nodes.new("GeometryNodeSwitch") target_position.input_type = "VECTOR" target_position.inputs["Switch"].default_value = True - target_position.location = (2550.0, 700.0) links.new(smooth_all.outputs["Value"], target_position.inputs["False"]) links.new(boundary_mix.outputs["Result"], target_position.inputs["True"]) current_position = nodes.new("GeometryNodeInputPosition") - current_position.location = (2300.0, 850.0) mix_position = nodes.new("ShaderNodeMix") mix_position.data_type = "VECTOR" - mix_position.location = (2800.0, 850.0) links.new(smooth_weight.outputs[0], mix_position.inputs[0]) links.new(current_position.outputs["Position"], mix_position.inputs[4]) links.new(target_position.outputs["Output"], mix_position.inputs[5]) set_position = nodes.new("GeometryNodeSetPosition") - set_position.location = (2800.0, 550.0) links.new(repeat_input.outputs["Geometry"], set_position.inputs["Geometry"]) links.new(mix_position.outputs["Result"], set_position.inputs["Position"]) links.new(set_position.outputs["Geometry"], repeat_output.inputs["Geometry"]) - for node in set(nodes) - existing_nodes: - node.location.x += x_offset return repeat_output.outputs["Geometry"] diff --git a/tools/nodes/groups/__init__.py b/tools/nodes/groups/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tools/nodes/groups/__init__.py @@ -0,0 +1 @@ + diff --git a/tools/nodes/groups/image_cutout.py b/tools/nodes/groups/image_cutout.py new file mode 100644 index 0000000..e885d7e --- /dev/null +++ b/tools/nodes/groups/image_cutout.py @@ -0,0 +1,223 @@ +"""Build O Image Cutout.""" +import bpy +from mathutils import Matrix +from anyimage.operators.cutout_tool.shape import CUTOUT_Z_TO_X, NORMAL_SCALE_ATTRIBUTE_NAME +from ..common.nodes import interface_socket, group_input, math_node, float_input, menu_switch, read_float_attribute, store_float_attribute, prepare_node_group +from ..common.smoothing import expand_smooth +from ..common.cutout import _extrude_layers, _island_cleanup, _offset_geometry_z, _profile_field, _remove_zero_thickness, _set_layer_z, _shade_output + + +NODE_GROUP_NAME = "O Image Cutout" + + +def _set_segmented_layer_z( + group, + geometry, + front_z, + back_z, + thickness, +): + nodes = group.nodes + links = group.links + + edge_vertices = nodes.new("GeometryNodeInputMeshEdgeVertices") + edge_length = nodes.new("ShaderNodeVectorMath") + edge_length.operation = "DISTANCE" + links.new(edge_vertices.outputs["Position 1"], edge_length.inputs[0]) + links.new(edge_vertices.outputs["Position 2"], edge_length.inputs[1]) + edge_neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors") + boundary = math_node(nodes, "COMPARE") + boundary.inputs[1].default_value = 1.0 + boundary.inputs[2].default_value = 0.1 + links.new(edge_neighbors.outputs["Face Count"], boundary.inputs[0]) + statistics = nodes.new("GeometryNodeAttributeStatistic") + statistics.data_type = "FLOAT" + statistics.domain = "EDGE" + links.new(geometry, statistics.inputs["Geometry"]) + links.new(boundary.outputs[0], statistics.inputs["Selection"]) + links.new(edge_length.outputs["Value"], statistics.inputs["Attribute"]) + safe_edge = math_node(nodes, "MAXIMUM") + safe_edge.inputs[1].default_value = 1e-6 + links.new(statistics.outputs["Median"], safe_edge.inputs[0]) + segment_ratio = math_node(nodes, "DIVIDE") + links.new(thickness, segment_ratio.inputs[0]) + links.new(safe_edge.outputs[0], segment_ratio.inputs[1]) + segment_count = nodes.new("FunctionNodeFloatToInt") + segment_count.rounding_mode = "CEILING" + links.new(segment_ratio.outputs[0], segment_count.inputs["Float"]) + at_least_one = nodes.new("FunctionNodeIntegerMath") + at_least_one.operation = "MAXIMUM" + at_least_one.inputs[1].default_value = 1 + links.new(segment_count.outputs["Integer"], at_least_one.inputs[0]) + segment_limit = nodes.new("FunctionNodeIntegerMath") + segment_limit.operation = "MINIMUM" + segment_limit.inputs[1].default_value = 128 + links.new(at_least_one.outputs[0], segment_limit.inputs[0]) + step = math_node(nodes, "DIVIDE") + links.new(thickness, step.inputs[0]) + links.new(segment_limit.outputs[0], step.inputs[1]) + + front = _offset_geometry_z(group, geometry, front_z) + repeat_output = nodes.new("GeometryNodeRepeatOutput") + repeat_input = nodes.new("GeometryNodeRepeatInput") + repeat_input.pair_with_output(repeat_output) + repeat_output.repeat_items.new("GEOMETRY", "Geometry") + repeat_output.repeat_items.new("GEOMETRY", "Sides") + links.new(segment_limit.outputs[0], repeat_input.inputs["Iterations"]) + links.new(geometry, repeat_input.inputs["Geometry"]) + negative_step = math_node(nodes, "MULTIPLY") + negative_step.inputs[1].default_value = -1.0 + links.new(step.outputs[0], negative_step.inputs[0]) + extrude = nodes.new("GeometryNodeExtrudeMesh") + extrude.mode = "FACES" + extrude.inputs["Individual"].default_value = False + extrude.inputs["Selection"].default_value = True + extrude.inputs["Offset"].default_value = (0.0, 0.0, 0.0) + links.new(negative_step.outputs[0], extrude.inputs["Offset Scale"]) + links.new(repeat_input.outputs["Geometry"], extrude.inputs["Mesh"]) + split_extrude = nodes.new("GeometryNodeSeparateGeometry") + split_extrude.domain = "FACE" + links.new(extrude.outputs["Mesh"], split_extrude.inputs["Geometry"]) + links.new(extrude.outputs["Side"], split_extrude.inputs["Selection"]) + join_sides = nodes.new("GeometryNodeJoinGeometry") + links.new(repeat_input.outputs["Sides"], join_sides.inputs["Geometry"]) + links.new(split_extrude.outputs["Selection"], join_sides.inputs["Geometry"]) + links.new(split_extrude.outputs["Inverted"], repeat_output.inputs["Geometry"]) + links.new(join_sides.outputs["Geometry"], repeat_output.inputs["Sides"]) + + positioned_side = _offset_geometry_z( + group, + repeat_output.outputs["Sides"], + front_z, + ) + flip_side = nodes.new("GeometryNodeFlipFaces") + links.new(positioned_side, flip_side.inputs["Mesh"]) + + back = _offset_geometry_z(group, geometry, back_z) + flip_back = nodes.new("GeometryNodeFlipFaces") + links.new(back, flip_back.inputs["Mesh"]) + join = nodes.new("GeometryNodeJoinGeometry") + links.new(front, join.inputs["Geometry"]) + links.new(flip_back.outputs["Mesh"], join.inputs["Geometry"]) + links.new(flip_side.outputs["Mesh"], join.inputs["Geometry"]) + merge = nodes.new("GeometryNodeMergeByDistance") + merge.mode = "ALL" + merge.inputs["Distance"].default_value = 1e-6 + links.new(join.outputs["Geometry"], merge.inputs["Geometry"]) + return merge.outputs["Geometry"] + + +def _build_cutout(group, geometry, thickness): + nodes = group.nodes + links = group.links + half = math_node(nodes, "MULTIPLY") + half.inputs[1].default_value = 0.5 + links.new(thickness, half.inputs[0]) + back = math_node(nodes, "MULTIPLY") + back.inputs[1].default_value = -1.0 + links.new(half.outputs[0], back.inputs[0]) + shaped = _set_segmented_layer_z( + group, + geometry, + half.outputs[0], + back.outputs[0], + thickness, + ) + return _remove_zero_thickness(group, geometry, shaped, thickness) + + +def _build_balloon(group, geometry, thickness): + nodes = group.nodes + links = group.links + profile, _profile_factor = _profile_field(group, geometry) + front_scale = math_node(nodes, "MULTIPLY") + links.new(profile, front_scale.inputs[0]) + links.new(thickness, front_scale.inputs[1]) + back = math_node(nodes, "MULTIPLY") + links.new(profile, back.inputs[0]) + links.new(thickness, back.inputs[1]) + negative_back = math_node(nodes, "MULTIPLY") + negative_back.inputs[1].default_value = -1.0 + links.new(back.outputs[0], negative_back.inputs[0]) + extrude = _extrude_layers(group, geometry) + shaped = _set_layer_z( + group, + geometry, + extrude, + front_scale.outputs[0], + negative_back.outputs[0], + ) + return _remove_zero_thickness(group, geometry, shaped, thickness) + + +def build_image_cutout_group(): + name = NODE_GROUP_NAME + existing = bpy.data.node_groups.get(name) + if existing is not None: + return existing + group = bpy.data.node_groups.new(name, 'GeometryNodeTree') + group.is_modifier, group.use_fake_user = (True, True) + group.color_tag = 'GEOMETRY' + group['anyimage_asset_revision'] = 11 + interface_socket(group, 'Geometry', 'INPUT', 'NodeSocketGeometry') + interface_socket(group, 'Mode', 'INPUT', 'NodeSocketMenu') + float_input(group, 'Thickness', 1.0).description = 'Balloon profile multiplier; zero returns the sheet' + float_input(group, 'Uniform Thickness', 0.0, subtype='DISTANCE').description = 'Distance used only by Uniform thickness mode' + iterations = interface_socket(group, 'Smooth', 'INPUT', 'NodeSocketInt') + iterations.default_value = 0 + iterations.min_value, iterations.max_value = (0, 20) + options = group.interface.new_panel(name='Options', default_closed=True) + float_input(group, 'Cleanup Threshold', 0.0, parent=options) + float_input(group, 'Smooth Weight', 1.0, maximum=1, subtype='FACTOR', parent=options) + data = group.interface.new_panel(name='Data', default_closed=True) + interface_socket(group, 'Inward Axis', 'INPUT', 'NodeSocketMenu', data).hide_in_modifier = True + interface_socket(group, 'Geometry', 'OUTPUT', 'NodeSocketGeometry') + controls = group_input(group.nodes, set()) + balloon = _build_balloon(group, controls.outputs['Geometry'], controls.outputs['Thickness']) + uniform = _build_cutout(group, controls.outputs['Geometry'], controls.outputs['Uniform Thickness']) + choose = menu_switch(group, 'Mode', ('Balloon', 'Uniform'), 'GEOMETRY') + group.links.new(balloon, choose.inputs['Balloon']) + group.links.new(uniform, choose.inputs['Uniform']) + geometry = choose.outputs[0] + planar_inputs = [link.to_socket for link in group.links if link.from_node.bl_idname == 'NodeGroupInput' and link.from_socket.name == 'Geometry'] + base = group_input(group.nodes, {'Geometry', 'Cleanup Threshold'}) + area = math_node(group.nodes, 'MULTIPLY') + area.inputs[1].default_value = 0.01 + group.links.new(base.outputs['Cleanup Threshold'], area.inputs[0]) + cleaned = _island_cleanup(group, base.outputs['Geometry'], area.outputs[0]) + for target in planar_inputs: + group.links.new(cleaned, target) + geometry = expand_smooth(group, geometry, iterations_name='Smooth', weight_name='Smooth Weight') + profile = read_float_attribute(group, 'o_balloon') + amount = group_input(group.nodes, {'Thickness'}) + enabled = math_node(group.nodes, 'GREATER_THAN') + enabled.inputs[1].default_value = 1e-06 + group.links.new(amount.outputs['Thickness'], enabled.inputs[0]) + inflated = group.nodes.new('GeometryNodeSwitch') + inflated.input_type = 'FLOAT' + inflated.inputs['False'].default_value = 1 + group.links.new(enabled.outputs[0], inflated.inputs['Switch']) + group.links.new(profile, inflated.inputs['True']) + mode = menu_switch(group, 'Mode', ('Balloon', 'Uniform'), 'FLOAT') + mode.inputs['Uniform'].default_value = 1 + group.links.new(inflated.outputs[0], mode.inputs['Balloon']) + scale = mode.outputs[0] + geometry = store_float_attribute(group, geometry, NORMAL_SCALE_ATTRIBUTE_NAME, scale) + orient = group.nodes.new('GeometryNodeTransform') + orient.inputs['Rotation'].default_value = Matrix(CUTOUT_Z_TO_X).to_euler() + group.links.new(geometry, orient.inputs['Geometry']) + axis = menu_switch(group, 'Inward Axis', ('-Z', '+X'), 'GEOMETRY') + group.links.new(geometry, axis.inputs['-Z']) + group.links.new(orient.outputs[0], axis.inputs['+X']) + _shade_output(group, axis.outputs[0]) + prepare_node_group(group) + for item in group.interface.items_tree: + if item.item_type != 'SOCKET' or item.in_out != 'INPUT': + continue + if item.name == 'Mode': + item.default_value = 'Balloon' + elif item.name == 'Inward Axis': + item.default_value = '-Z' + elif item.name == 'Uniform Thickness': + item.name = 'Thickness' + return group diff --git a/tools/nodes/groups/image_depth_balloon.py b/tools/nodes/groups/image_depth_balloon.py new file mode 100644 index 0000000..758c934 --- /dev/null +++ b/tools/nodes/groups/image_depth_balloon.py @@ -0,0 +1,190 @@ +"""Build O Image Depth Balloon.""" +import bpy +from mathutils import Matrix +from anyimage.operators.cutout_tool.shape import CUTOUT_Z_TO_X, NORMAL_SCALE_ATTRIBUTE_NAME +from ..common.nodes import ( + interface_socket, group_input, math_node, float_input, bool_input, vector_input, + menu_switch, read_float_attribute, store_float_attribute, prepare_node_group, +) +from ..common.smoothing import expand_smooth +from ..common.cutout import _extrude_layers, _island_cleanup, _position_field, _profile_field, _remove_zero_thickness, _set_layer_z, _shade_output + + +NODE_GROUP_NAME = "O Image Depth Balloon" + + +def _set_geometry_z(group, geometry, z_value): + nodes = group.nodes + links = group.links + position = nodes.new("GeometryNodeInputPosition") + separate = nodes.new("ShaderNodeSeparateXYZ") + links.new(position.outputs["Position"], separate.inputs["Vector"]) + combine = nodes.new("ShaderNodeCombineXYZ") + links.new(separate.outputs["X"], combine.inputs["X"]) + links.new(separate.outputs["Y"], combine.inputs["Y"]) + links.new(z_value, combine.inputs["Z"]) + set_position = nodes.new("GeometryNodeSetPosition") + links.new(geometry, set_position.inputs["Geometry"]) + links.new(combine.outputs["Vector"], set_position.inputs["Position"]) + return set_position.outputs["Geometry"] + + +def _plane_depth(group, direction, reference_depth): + nodes = group.nodes + links = group.links + normal = nodes.new("ShaderNodeVectorMath") + normal.operation = "NORMALIZE" + links.new(direction, normal.inputs[0]) + normal_components = nodes.new("ShaderNodeSeparateXYZ") + links.new(normal.outputs["Vector"], normal_components.inputs["Vector"]) + position = nodes.new("GeometryNodeInputPosition") + position_components = nodes.new("ShaderNodeSeparateXYZ") + links.new(position.outputs["Position"], position_components.inputs["Vector"]) + x_term = math_node(nodes, "MULTIPLY") + links.new(position_components.outputs["X"], x_term.inputs[0]) + links.new(normal_components.outputs["X"], x_term.inputs[1]) + y_term = math_node(nodes, "MULTIPLY") + links.new(position_components.outputs["Y"], y_term.inputs[0]) + links.new(normal_components.outputs["Y"], y_term.inputs[1]) + point_dot = math_node(nodes, "ADD") + links.new(x_term.outputs[0], point_dot.inputs[0]) + links.new(y_term.outputs[0], point_dot.inputs[1]) + divide = math_node(nodes, "DIVIDE") + links.new(point_dot.outputs[0], divide.inputs[0]) + links.new(normal_components.outputs["Z"], divide.inputs[1]) + plane = math_node(nodes, "ADD") + links.new(reference_depth, plane.inputs[0]) + links.new(divide.outputs[0], plane.inputs[1]) + return plane.outputs[0] + + +def _build_depth_balloon( + group, + geometry, + thickness, + depth_scale, + double_sided, + uniform_scale, + depth_direction, + depth_image, + depth_reference, +): + nodes = group.nodes + links = group.links + profile, profile_factor = _profile_field(group, geometry) + camera_position = _position_field(group, depth_image) + separate_camera = nodes.new("ShaderNodeSeparateXYZ") + links.new(camera_position, separate_camera.inputs["Vector"]) + metric_depth = math_node(nodes, "MULTIPLY") + links.new(separate_camera.outputs["Z"], metric_depth.inputs[0]) + links.new(uniform_scale, metric_depth.inputs[1]) + plane_depth = _plane_depth( + group, + depth_direction, + depth_reference, + ) + corrected_depth = math_node(nodes, "SUBTRACT") + links.new(plane_depth, corrected_depth.inputs[0]) + links.new(metric_depth.outputs[0], corrected_depth.inputs[1]) + nonnegative_depth = math_node(nodes, "MAXIMUM") + nonnegative_depth.inputs[1].default_value = 0.0 + links.new(corrected_depth.outputs[0], nonnegative_depth.inputs[0]) + scaled_depth = math_node(nodes, "MULTIPLY") + links.new(nonnegative_depth.outputs[0], scaled_depth.inputs[0]) + links.new(depth_scale, scaled_depth.inputs[1]) + front_depth = scaled_depth.outputs[0] + base_front = math_node(nodes, "MULTIPLY") + links.new(profile, base_front.inputs[0]) + links.new(thickness, base_front.inputs[1]) + base_back = math_node(nodes, "MULTIPLY") + links.new(profile, base_back.inputs[0]) + links.new(thickness, base_back.inputs[1]) + negate_back = math_node(nodes, "MULTIPLY") + negate_back.inputs[1].default_value = -1.0 + links.new(base_back.outputs[0], negate_back.inputs[0]) + front_mix = nodes.new("ShaderNodeMix") + front_mix.data_type = "FLOAT" + front_mix.clamp_factor = True + links.new(profile_factor, front_mix.inputs["Factor"]) + links.new(base_front.outputs[0], front_mix.inputs["A"]) + links.new(front_depth, front_mix.inputs["B"]) + mirrored_front = math_node(nodes, "MULTIPLY") + mirrored_front.inputs[1].default_value = -1.0 + links.new(front_mix.outputs["Result"], mirrored_front.inputs[0]) + back_choice = nodes.new("GeometryNodeSwitch") + back_choice.input_type = "FLOAT" + links.new(double_sided, back_choice.inputs["Switch"]) + links.new(negate_back.outputs[0], back_choice.inputs["False"]) + links.new(mirrored_front.outputs[0], back_choice.inputs["True"]) + extrude = _extrude_layers(group, geometry) + shaped = _set_layer_z( + group, + geometry, + extrude, + front_mix.outputs["Result"], + back_choice.outputs["Output"], + ) + front_only = _set_geometry_z( + group, geometry, front_mix.outputs["Result"] + ) + return _remove_zero_thickness(group, front_only, shaped, thickness) + + +def build_image_depth_balloon_group(): + name = NODE_GROUP_NAME + existing = bpy.data.node_groups.get(name) + if existing is not None: + return existing + group = bpy.data.node_groups.new(name, 'GeometryNodeTree') + group.is_modifier, group.use_fake_user = (True, True) + group.color_tag = 'GEOMETRY' + group['anyimage_asset_revision'] = 11 + interface_socket(group, 'Geometry', 'INPUT', 'NodeSocketGeometry') + float_input(group, 'Thickness', 1.0).description = 'Balloon profile multiplier; zero returns the sheet' + float_input(group, 'Depth Scale', 1.0) + bool_input(group, 'Double Sided', True) + iterations = interface_socket(group, 'Smooth', 'INPUT', 'NodeSocketInt') + iterations.default_value = 0 + iterations.min_value, iterations.max_value = (0, 20) + options = group.interface.new_panel(name='Options', default_closed=True) + float_input(group, 'Cleanup Threshold', 0.0, parent=options) + float_input(group, 'Reference Depth', 1.0, subtype='DISTANCE', parent=options) + float_input(group, 'Smooth Weight', 1.0, maximum=1, subtype='FACTOR', parent=options) + vector_input(group, 'Depth Direction', (0, 0, 1), subtype='DIRECTION', parent=options) + data = group.interface.new_panel(name='Data', default_closed=True) + interface_socket(group, 'Inward Axis', 'INPUT', 'NodeSocketMenu', data).hide_in_modifier = True + float_input(group, 'Uniform Scale', 1.0, parent=data).hide_in_modifier = True + interface_socket(group, 'Depth Image', 'INPUT', 'NodeSocketImage', data).hide_in_modifier = True + interface_socket(group, 'Geometry', 'OUTPUT', 'NodeSocketGeometry') + controls = group_input(group.nodes, set()) + geometry = _build_depth_balloon(group, controls.outputs['Geometry'], controls.outputs['Thickness'], controls.outputs['Depth Scale'], controls.outputs['Double Sided'], controls.outputs['Uniform Scale'], controls.outputs['Depth Direction'], controls.outputs['Depth Image'], controls.outputs['Reference Depth']) + planar_inputs = [link.to_socket for link in group.links if link.from_node.bl_idname == 'NodeGroupInput' and link.from_socket.name == 'Geometry'] + base = group_input(group.nodes, {'Geometry', 'Cleanup Threshold'}) + area = math_node(group.nodes, 'MULTIPLY') + area.inputs[1].default_value = 0.01 + group.links.new(base.outputs['Cleanup Threshold'], area.inputs[0]) + cleaned = _island_cleanup(group, base.outputs['Geometry'], area.outputs[0]) + for target in planar_inputs: + group.links.new(cleaned, target) + geometry = expand_smooth(group, geometry, iterations_name='Smooth', weight_name='Smooth Weight') + profile = read_float_attribute(group, 'o_balloon') + scale = profile + geometry = store_float_attribute(group, geometry, NORMAL_SCALE_ATTRIBUTE_NAME, scale) + orient = group.nodes.new('GeometryNodeTransform') + orient.inputs['Rotation'].default_value = Matrix(CUTOUT_Z_TO_X).to_euler() + group.links.new(geometry, orient.inputs['Geometry']) + axis = menu_switch(group, 'Inward Axis', ('-Z', '+X'), 'GEOMETRY') + group.links.new(geometry, axis.inputs['-Z']) + group.links.new(orient.outputs[0], axis.inputs['+X']) + _shade_output(group, axis.outputs[0]) + prepare_node_group(group) + for item in group.interface.items_tree: + if item.item_type != 'SOCKET' or item.in_out != 'INPUT': + continue + if item.name == 'Mode': + item.default_value = 'Balloon' + elif item.name == 'Inward Axis': + item.default_value = '-Z' + elif item.name == 'Uniform Thickness': + item.name = 'Thickness' + return group diff --git a/tools/nodes/groups/image_depth_cutout.py b/tools/nodes/groups/image_depth_cutout.py new file mode 100644 index 0000000..7877209 --- /dev/null +++ b/tools/nodes/groups/image_depth_cutout.py @@ -0,0 +1,575 @@ +"""Build O Image Depth Cutout.""" +import bpy +from mathutils import Matrix +from anyimage.operators.cutout_tool.shape import CUTOUT_Z_TO_X, NORMAL_SCALE_ATTRIBUTE_NAME +from ..common.nodes import interface_socket, group_input, math_node, float_input, menu_switch, read_float_attribute, store_float_attribute, prepare_node_group +from ..common.smoothing import expand_smooth +from ..common.cutout import _island_cleanup, _position_field, _remove_zero_thickness, _shade_output +from ..common.depth_surface import project_depth_surface, store_surface_camera, remove_surface_attributes, split_depth_surface, thicken_depth_surface +from ..common.smoothing import edge_boundary_field + + +NODE_GROUP_NAME = "O Image Depth Cutout" + + +def build_volume_fields(group, geometry): + boundary = edge_boundary_field(group.nodes, group.links) + store = group.nodes.new("GeometryNodeStoreNamedAttribute") + store.domain, store.data_type = "POINT", "FLOAT" + store.inputs["Name"].default_value = "o_balloon_surface_rim" + group.links.new(geometry, store.inputs["Geometry"]) + group.links.new(boundary, store.inputs["Value"]) + profile = group.nodes.new("GeometryNodeInputNamedAttribute") + profile.data_type = "FLOAT" + profile.inputs["Name"].default_value = "o_balloon" + links = group.links + def math(operation, a, b): + result = group.nodes.new("ShaderNodeMath") + result.operation = operation + for index, value in enumerate((a, b)): + if isinstance(value, (int, float)): + result.inputs[index].default_value = value + else: + links.new(value, result.inputs[index]) + return result.outputs[0] + position = group.nodes.new("GeometryNodeInputPosition") + axes = group.nodes.new("ShaderNodeSeparateXYZ") + links.new(position.outputs[0], axes.inputs[0]) + seed = math("SUBTRACT", axes.outputs["Z"], profile.outputs["Attribute"]) + center = group.nodes.new("GeometryNodeStoreNamedAttribute") + center.domain, center.data_type = "POINT", "FLOAT" + center.inputs["Name"].default_value = "o_balloon_surface_center" + links.new(store.outputs["Geometry"], center.inputs["Geometry"]) + links.new(seed, center.inputs["Value"]) + current = group.nodes.new("GeometryNodeInputNamedAttribute") + current.data_type = "FLOAT" + current.inputs["Name"].default_value = "o_balloon_surface_center" + rim = group.nodes.new("GeometryNodeInputNamedAttribute") + rim.data_type = "FLOAT" + rim.inputs["Name"].default_value = "o_balloon_surface_rim" + interior = math("SUBTRACT", 1, rim.outputs["Attribute"]) + blur = group.nodes.new("GeometryNodeBlurAttribute") + blur.data_type = "FLOAT" + blur.inputs["Iterations"].default_value = 256 + links.new(current.outputs["Attribute"], blur.inputs["Value"]) + links.new(interior, blur.inputs["Weight"]) + update = group.nodes.new("GeometryNodeStoreNamedAttribute") + update.domain, update.data_type = "POINT", "FLOAT" + update.inputs["Name"].default_value = "o_balloon_surface_center" + links.new(center.outputs["Geometry"], update.inputs["Geometry"]) + links.new(blur.outputs[0], update.inputs["Value"]) + baseline = group.nodes.new("GeometryNodeInputNamedAttribute") + baseline.data_type = "FLOAT" + baseline.inputs["Name"].default_value = "o_balloon_surface_center" + profile_end = group.nodes.new("GeometryNodeInputNamedAttribute") + profile_end.data_type = "FLOAT" + profile_end.inputs["Name"].default_value = "o_balloon" + position_end = group.nodes.new("GeometryNodeInputPosition") + axes_end = group.nodes.new("ShaderNodeSeparateXYZ") + links.new(position_end.outputs[0], axes_end.inputs[0]) + residual = math("SUBTRACT", axes_end.outputs["Z"], baseline.outputs["Attribute"]) + full = math("ADD", residual, profile_end.outputs["Attribute"]) + minimum = math("MULTIPLY", profile_end.outputs["Attribute"], 0.05) + positive = math("MAXIMUM", full, minimum) + control = group.nodes.new("NodeGroupInput") + scaled = math("MULTIPLY", positive, control.outputs["Thickness"]) + thickness = group.nodes.new("GeometryNodeStoreNamedAttribute") + thickness.domain, thickness.data_type = "POINT", "FLOAT" + thickness.inputs["Name"].default_value = "o_balloon_surface_thickness" + group.links.new(update.outputs["Geometry"], thickness.inputs["Geometry"]) + group.links.new(scaled, thickness.inputs["Value"]) + return thickness.outputs["Geometry"] + + +def build_volume_rounding(group, volume): + links = group.links + + def math(operation, a, b): + result = group.nodes.new("ShaderNodeMath") + result.operation = operation + for index, value in enumerate((a, b)): + if isinstance(value, (int, float)): + result.inputs[index].default_value = value + else: + links.new(value, result.inputs[index]) + return result.outputs[0] + + def read(name, kind="FLOAT"): + result = group.nodes.new("GeometryNodeInputNamedAttribute") + result.data_type = kind + result.inputs["Name"].default_value = "o_balloon_surface_" + name + return result.outputs["Attribute"] + + def store(geometry, name, value, kind="FLOAT"): + result = group.nodes.new("GeometryNodeStoreNamedAttribute") + result.data_type, result.domain = kind, "POINT" + result.inputs["Name"].default_value = "o_balloon_surface_" + name + links.new(geometry, result.inputs["Geometry"]) + links.new(value, result.inputs["Value"]) + return result.outputs["Geometry"] + + control = group.nodes.new("NodeGroupInput") + profile = group.nodes.new("GeometryNodeInputNamedAttribute") + profile.data_type = "FLOAT" + profile.inputs["Name"].default_value = "o_balloon" + balloon = profile.outputs["Attribute"] + maximum = group.nodes.new("GeometryNodeAttributeStatistic") + maximum.data_type, maximum.domain = "FLOAT", "POINT" + links.new(volume, maximum.inputs["Geometry"]) + links.new(balloon, maximum.inputs["Attribute"]) + limit = math("MAXIMUM", maximum.outputs["Max"], 1e-8) + falloff = group.nodes.new("ShaderNodeMapRange") + falloff.interpolation_type = "SMOOTHERSTEP" + falloff.inputs["To Min"].default_value = 1 + falloff.inputs["To Max"].default_value = 0 + links.new(balloon, falloff.inputs["Value"]) + links.new(limit, falloff.inputs["From Max"]) + weight = math("MULTIPLY", falloff.outputs[0], control.outputs["Edge Round"]) + center = read("center") + position = group.nodes.new("GeometryNodeInputPosition") + axes = group.nodes.new("ShaderNodeSeparateXYZ") + links.new(position.outputs[0], axes.inputs[0]) + target = math("ADD", center, balloon) + delta = math("SUBTRACT", target, axes.outputs["Z"]) + delta = math("MULTIPLY", delta, weight) + enabled = math("MINIMUM", control.outputs["Thickness"], 1) + enabled = math("MAXIMUM", enabled, 0) + delta = math("MULTIPLY", delta, enabled) + carrier = store(volume, "edge_weight", weight) + carrier = store(carrier, "front_delta", delta) + rear = math("MULTIPLY", balloon, weight) + rear = math("MULTIPLY", rear, control.outputs["Thickness"]) + thickness = read("thickness") + total = math("ADD", thickness, delta) + total = math("ADD", total, rear) + carrier = store(carrier, "thickness", total) + + # Evaluate normals on a separate center surface with identical point indices. + center_position = group.nodes.new("ShaderNodeCombineXYZ") + for axis in ("X", "Y"): + links.new(axes.outputs[axis], center_position.inputs[axis]) + links.new(center, center_position.inputs["Z"]) + surface = group.nodes.new("GeometryNodeSetPosition") + links.new(volume, surface.inputs["Geometry"]) + links.new(center_position.outputs[0], surface.inputs["Position"]) + normal = group.nodes.new("GeometryNodeInputNormal") + blur = group.nodes.new("GeometryNodeBlurAttribute") + blur.data_type = "FLOAT_VECTOR" + links.new(normal.outputs[0], blur.inputs["Value"]) + local = group.nodes.new("NodeGroupInput") + links.new(local.outputs["Normal Smooth"], blur.inputs["Iterations"]) + normalize = group.nodes.new("ShaderNodeVectorMath") + normalize.operation = "NORMALIZE" + links.new(blur.outputs[0], normalize.inputs[0]) + index = group.nodes.new("GeometryNodeInputIndex") + sample = group.nodes.new("GeometryNodeSampleIndex") + sample.data_type, sample.domain = "FLOAT_VECTOR", "POINT" + links.new(surface.outputs[0], sample.inputs["Geometry"]) + links.new(normalize.outputs[0], sample.inputs["Value"]) + links.new(index.outputs[0], sample.inputs["Index"]) + carrier = store(carrier, "normal", sample.outputs[0], "FLOAT_VECTOR") + captured = read("front_delta") + displacement = group.nodes.new("ShaderNodeCombineXYZ") + links.new(captured, displacement.inputs["Z"]) + front = group.nodes.new("GeometryNodeSetPosition") + links.new(carrier, front.inputs["Geometry"]) + links.new(displacement.outputs[0], front.inputs["Offset"]) + return front.outputs[0] + + +def _inflation_math(group, operation, a, b): + result = group.nodes.new("ShaderNodeMath") + result.operation = operation + for index, value in enumerate((a, b)): + if hasattr(value, "node"): + group.links.new(value, result.inputs[index]) + else: + result.inputs[index].default_value = value + return result.outputs[0] + + +def _inflation_read(group, name, kind="FLOAT"): + result = group.nodes.new("GeometryNodeInputNamedAttribute") + result.data_type = kind + result.inputs["Name"].default_value = "o_balloon_surface_" + name + return result.outputs["Attribute"] + + +def _inflation_store(group, geometry, name, value, kind="FLOAT"): + result = group.nodes.new("GeometryNodeStoreNamedAttribute") + result.data_type, result.domain = kind, "POINT" + result.inputs["Name"].default_value = "o_balloon_surface_" + name + group.links.new(geometry, result.inputs["Geometry"]) + group.links.new(value, result.inputs["Value"]) + return result.outputs["Geometry"] + + +def build_front_fields(group, geometry): + """Fit structural amplitude over neighbouring points before creating the shell.""" + links = group.links + + def blur(value, weight=None): + result = group.nodes.new("GeometryNodeBlurAttribute") + result.data_type = "FLOAT" + result.inputs["Iterations"].default_value = 256 + links.new(value, result.inputs["Value"]) + if weight is not None: + links.new(weight, result.inputs["Weight"]) + return result.outputs[0] + + profile_node = group.nodes.new("GeometryNodeInputNamedAttribute") + profile_node.data_type = "FLOAT" + profile_node.inputs["Name"].default_value = "o_balloon" + profile = profile_node.outputs["Attribute"] + rim = _inflation_read(group, "rim") + interior = _inflation_math(group, "SUBTRACT", 1, rim) + position = group.nodes.new("GeometryNodeInputPosition") + axes = group.nodes.new("ShaderNodeSeparateXYZ") + links.new(position.outputs[0], axes.inputs[0]) + depth = axes.outputs["Z"] + depth_slow = blur(depth, interior) + profile_slow = blur(profile, interior) + depth_bump = _inflation_math(group, "SUBTRACT", depth, depth_slow) + profile_bump = _inflation_math(group, "SUBTRACT", profile, profile_slow) + numerator = _inflation_math(group, "MULTIPLY", depth_bump, profile_bump) + denominator = _inflation_math(group, "MULTIPLY", profile_bump, profile_bump) + numerator = blur(numerator) + denominator = blur(denominator) + denominator = _inflation_math(group, "MAXIMUM", denominator, 1e-10) + structure = _inflation_math(group, "DIVIDE", numerator, denominator) + structure = _inflation_math(group, "MAXIMUM", structure, 0) + carrier = _inflation_store(group, geometry, "structure", structure) + + normal = _inflation_read(group, "front_normal", "FLOAT_VECTOR") + controls = group.nodes.new("NodeGroupInput") + # Keep the accepted broad front-direction filter at the default of 50; + # zero disables both this filter and the initial front/back normal filters. + spread = _inflation_math(group, "MULTIPLY", controls.outputs["Normal Smooth"], 256 / 50) + smooth = group.nodes.new("GeometryNodeBlurAttribute") + smooth.data_type = "FLOAT_VECTOR" + links.new(normal, smooth.inputs["Value"]) + links.new(spread, smooth.inputs["Iterations"]) + normalize = group.nodes.new("ShaderNodeVectorMath") + normalize.operation = "NORMALIZE" + links.new(smooth.outputs[0], normalize.inputs[0]) + carrier = _inflation_store(group, carrier, "inflation_normal", normalize.outputs[0], "FLOAT_VECTOR") + return carrier + + +def build_inflation_position(group, position, distance, uniform): + """Grow both modes continuously while leaving the rear cap in place.""" + links = group.links + + + structure = _inflation_read(group, "structure") + control = group.nodes.new("NodeGroupInput") + a = _inflation_math(group, "MULTIPLY", control.outputs["Thickness"], control.outputs["Front Inflation"]) + # A shifted cubic blend preserves continuous growth and height order. + shifted = _inflation_math(group, "ADD", a, 0.75) + quadratic = _inflation_math(group, "MULTIPLY", a, shifted) + quadratic = _inflation_math(group, "ADD", quadratic, 0.1875) + q = _inflation_math(group, "MULTIPLY", a, quadratic) + k = _inflation_math(group, "ADD", structure, 0.25) + square = _inflation_math(group, "MULTIPLY", k, k) + cube = _inflation_math(group, "MULTIPLY", square, k) + total = _inflation_math(group, "ADD", cube, q) + root = _inflation_math(group, "POWER", total, 1 / 3) + root_square = _inflation_math(group, "MULTIPLY", root, root) + product = _inflation_math(group, "MULTIPLY", root, k) + denominator = _inflation_math(group, "ADD", root_square, product) + denominator = _inflation_math(group, "ADD", denominator, square) + response = _inflation_math(group, "DIVIDE", q, denominator) + profile = group.nodes.new("GeometryNodeInputNamedAttribute") + profile.data_type = "FLOAT" + profile.inputs["Name"].default_value = "o_balloon" + balloon = _inflation_math(group, "MULTIPLY", profile.outputs["Attribute"], response) + uniform_control = group.nodes.new("NodeGroupInput") + uniform_amount = _inflation_math(group, "MULTIPLY", uniform_control.outputs["Uniform Thickness"], uniform_control.outputs["Front Inflation"]) + selected = group.nodes.new("GeometryNodeSwitch") + selected.input_type = "FLOAT" + links.new(uniform, selected.inputs["Switch"]) + links.new(balloon, selected.inputs["False"]) + links.new(uniform_amount, selected.inputs["True"]) + displacement = selected.outputs[0] + height = _inflation_read(group, "thickness") + layer = _inflation_math(group, "DIVIDE", distance, height) + layer = _inflation_math(group, "ADD", 1, layer) + layer = _inflation_math(group, "MAXIMUM", layer, 0) + layer = _inflation_math(group, "MINIMUM", layer, 1) + displacement = _inflation_math(group, "MULTIPLY", displacement, layer) + normal = _inflation_read(group, "inflation_normal", "FLOAT_VECTOR") + offset = group.nodes.new("ShaderNodeVectorMath") + offset.operation = "SCALE" + links.new(normal, offset.inputs[0]) + links.new(displacement, offset.inputs[3]) + result = group.nodes.new("ShaderNodeVectorMath") + result.operation = "ADD" + links.new(position, result.inputs[0]) + links.new(offset.outputs[0], result.inputs[1]) + return result.outputs[0] + + +def build_front_normals(group, geometry): + links = group.links + normal = group.nodes.new("GeometryNodeInputNormal") + control = group.nodes.new("NodeGroupInput") + blur = group.nodes.new("GeometryNodeBlurAttribute") + blur.data_type = "FLOAT_VECTOR" + links.new(normal.outputs[0], blur.inputs["Value"]) + links.new(control.outputs["Normal Smooth"], blur.inputs["Iterations"]) + normalize = group.nodes.new("ShaderNodeVectorMath") + normalize.operation = "NORMALIZE" + links.new(blur.outputs[0], normalize.inputs[0]) + store = group.nodes.new("GeometryNodeStoreNamedAttribute") + store.data_type, store.domain = "FLOAT_VECTOR", "POINT" + store.inputs["Name"].default_value = "o_balloon_surface_front_normal" + links.new(geometry, store.inputs["Geometry"]) + links.new(normalize.outputs[0], store.inputs["Value"]) + return store.outputs[0] + + +def build_shell_position(group, position, uniform): + links = group.links + + def connect(value, target): + if hasattr(value, "node"): + links.new(value, target) + else: + target.default_value = value + + def math(operation, a, b): + result = group.nodes.new("ShaderNodeMath") + result.operation = operation + connect(a, result.inputs[0]) + connect(b, result.inputs[1]) + return result.outputs[0] + + def vector(operation, a, b): + result = group.nodes.new("ShaderNodeVectorMath") + result.operation = operation + connect(a, result.inputs[0]) + connect(b, result.inputs[3 if operation == "SCALE" else 1]) + return result.outputs[0] + + def read(name, kind="FLOAT"): + result = group.nodes.new("GeometryNodeInputNamedAttribute") + result.data_type = kind + result.inputs["Name"].default_value = name + return result.outputs["Attribute"] + + def z(value): + result = group.nodes.new("ShaderNodeSeparateXYZ") + links.new(value, result.inputs[0]) + return result.outputs["Z"] + + # Uniform and the thin end of Balloon share the actual front normal. + front = read("o_depth_surface_position", "FLOAT_VECTOR") + front_z = z(front) + raw_z = z(position) + distance = math("SUBTRACT", raw_z, front_z) + normal = read("o_balloon_surface_front_normal", "FLOAT_VECTOR") + thin_offset = vector("SCALE", normal, distance) + uniform_position = vector("ADD", front, thin_offset) + + # R = t * (front_z - center) + (1 - t) * front_delta, with t clamped. + # This avoids division by Thickness and removes the old zero-thickness drift. + control = group.nodes.new("NodeGroupInput") + clamp = group.nodes.new("ShaderNodeClamp") + links.new(control.outputs["Thickness"], clamp.inputs["Value"]) + t = clamp.outputs[0] + center = read("o_balloon_surface_center") + delta = read("o_balloon_surface_front_delta") + radius = math("SUBTRACT", front_z, center) + radius = math("MULTIPLY", radius, t) + remaining = math("SUBTRACT", 1, t) + remainder = math("MULTIPLY", remaining, delta) + radius = math("ADD", radius, remainder) + + # Front and rear use the same edge field, evaluated on their own caps. + edge = read("o_balloon_surface_edge_weight") + mask = read("o_normal_scale") + remaining = math("SUBTRACT", 1, edge) + remaining = math("MULTIPLY", remaining, mask) + weight = math("SUBTRACT", 1, remaining) + difference = math("SUBTRACT", weight, edge) + base = math("MULTIPLY", difference, radius) + layer = math("MULTIPLY", weight, distance) + rear_radius = math("ADD", base, layer) + front_radius = math("MULTIPLY", edge, radius) + normal = read("o_balloon_surface_normal", "FLOAT_VECTOR") + direction = vector("SUBTRACT", normal, (0, 0, 1)) + front_shift = vector("SCALE", direction, front_radius) + front_position = vector("ADD", front, front_shift) + rear_shift = vector("SCALE", direction, rear_radius) + axis_offset = group.nodes.new("ShaderNodeCombineXYZ") + links.new(distance, axis_offset.inputs["Z"]) + full_offset = vector("ADD", axis_offset.outputs[0], rear_shift) + + # Smoothly reach the existing full-volume shape at Thickness >= 1. + transition = group.nodes.new("ShaderNodeMapRange") + transition.interpolation_type = "SMOOTHERSTEP" + links.new(t, transition.inputs["Value"]) + mix = group.nodes.new("ShaderNodeMix") + mix.data_type = "VECTOR" + links.new(transition.outputs[0], mix.inputs[0]) + links.new(thin_offset, mix.inputs[4]) + links.new(full_offset, mix.inputs[5]) + balloon_position = vector("ADD", front_position, mix.outputs["Result"]) + choose = group.nodes.new("GeometryNodeSwitch") + choose.input_type = "VECTOR" + links.new(uniform, choose.inputs["Switch"]) + links.new(balloon_position, choose.inputs["False"]) + links.new(uniform_position, choose.inputs["True"]) + return build_inflation_position(group, choose.outputs[0], distance, uniform) + + +def _build_depth_surface( + group, + geometry, + thickness, + depth_scale, + split, + uniform_scale, + reference_depth, + depth_image, +): + nodes = group.nodes + links = group.links + geometry = split_depth_surface( + group, geometry, depth_image, split, uniform_scale + ) + original_camera = _position_field(group, depth_image) + geometry, camera_position = store_surface_camera( + group, geometry, original_camera, split + ) + geometry = project_depth_surface( + group, geometry, camera_position, uniform_scale, reference_depth, depth_scale + ) + shaped = thicken_depth_surface(group, geometry, thickness) + geometry = _remove_zero_thickness( + group, geometry, shaped, thickness + ) + return remove_surface_attributes(group, geometry) + + +def _depth_volume(group): + """Insert the volume field ahead of identity-based shell construction.""" + links = group.links + nodes = group.nodes + projection = nodes["Set Position"].outputs["Geometry"] + shell_input = nodes["Store Named Attribute.005"].inputs["Geometry"] + projection = build_front_normals(group, projection) + volume = build_volume_fields(group, projection) + shared = build_front_fields(group, volume.node.inputs["Geometry"].links[0].from_socket) + links.new(shared, volume.node.inputs["Geometry"]) + uniform_control = group_input(group.nodes, {"Uniform Thickness"}) + uniform = store_float_attribute(group, shared, "o_balloon_surface_thickness", uniform_control.outputs["Uniform Thickness"]) + choose = menu_switch(group, "Mode", ("Balloon", "Uniform"), "GEOMETRY") + links.new(volume, choose.inputs["Balloon"]) + links.new(uniform, choose.inputs["Uniform"]) + links.new(choose.outputs[0], shell_input) + thickness = read_float_attribute(group, "o_balloon_surface_thickness") + links.new(thickness, nodes["Math.018"].inputs[1]) + control = group_input(group.nodes, {"Thickness", "Uniform Thickness"}) + amount = menu_switch(group, "Mode", ("Balloon", "Uniform"), "FLOAT") + links.new(control.outputs["Thickness"], amount.inputs["Balloon"]) + links.new(control.outputs["Uniform Thickness"], amount.inputs["Uniform"]) + links.new(amount.outputs[0], nodes["Math.019"].inputs[0]) + identity_thickness = read_float_attribute(group, "o_balloon_surface_thickness") + positive = math_node(group.nodes, "GREATER_THAN") + positive.inputs[1].default_value = 1e-12 + links.new(identity_thickness, positive.inputs[0]) + layer = math_node(group.nodes, "MULTIPLY") + links.new(positive.outputs[0], layer.inputs[0]) + links.new(nodes["Separate XYZ.006"].outputs["Z"], layer.inputs[1]) + links.new(layer.outputs[0], nodes["Combine XYZ.002"].inputs["Z"]) + for name in ("Flip Faces", "Flip Faces.001"): + source = nodes[name].outputs["Mesh"] + outgoing = [link for link in links if link.from_socket == source] + targets = [link.to_socket for link in outgoing] + for link in outgoing: + links.remove(link) + masked = store_float_attribute(group, source, NORMAL_SCALE_ATTRIBUTE_NAME, 0.0, domain="FACE") + for target in targets: + links.new(masked, target) + + rounded = build_volume_rounding(group, volume) + links.new(rounded, choose.inputs["Balloon"]) + restored = nodes["Vector Math.004"].outputs[0] + targets = [link.to_socket for link in restored.links] + uniform_mode = choose.outputs[0].node.inputs["Switch"].links[0].from_socket + oriented = build_shell_position(group, restored, uniform_mode) + for target in targets: + links.new(oriented, target) + + +def build_image_depth_cutout_group(): + name = NODE_GROUP_NAME + existing = bpy.data.node_groups.get(name) + if existing is not None: + return existing + group = bpy.data.node_groups.new(name, 'GeometryNodeTree') + group.is_modifier, group.use_fake_user = (True, True) + group.color_tag = 'GEOMETRY' + group['anyimage_asset_revision'] = 11 + interface_socket(group, 'Geometry', 'INPUT', 'NodeSocketGeometry') + interface_socket(group, 'Mode', 'INPUT', 'NodeSocketMenu') + float_input(group, 'Thickness', 1.0).description = 'Balloon profile multiplier; zero returns the sheet' + float_input(group, 'Uniform Thickness', 0.0, subtype='DISTANCE').description = 'Distance used only by Uniform thickness mode' + float_input(group, 'Depth Scale', 1.0) + iterations = interface_socket(group, 'Smooth', 'INPUT', 'NodeSocketInt') + iterations.default_value = 0 + iterations.min_value, iterations.max_value = (0, 20) + options = group.interface.new_panel(name='Options', default_closed=True) + float_input(group, 'Cleanup Threshold', 0.0, parent=options) + float_input(group, 'Split Threshold', 0.0, maximum=1, subtype='FACTOR', parent=options) + float_input(group, 'Reference Depth', 1.0, subtype='DISTANCE', parent=options) + float_input(group, 'Edge Round', 0.2, maximum=1, subtype='FACTOR', parent=options) + float_input(group, 'Front Inflation', 1.0, maximum=32, parent=options) + normal_iterations = interface_socket(group, 'Normal Smooth', 'INPUT', 'NodeSocketInt', options) + normal_iterations.default_value = 50 + normal_iterations.min_value, normal_iterations.max_value = (0, 50) + float_input(group, 'Smooth Weight', 1.0, maximum=1, subtype='FACTOR', parent=options) + data = group.interface.new_panel(name='Data', default_closed=True) + interface_socket(group, 'Inward Axis', 'INPUT', 'NodeSocketMenu', data).hide_in_modifier = True + float_input(group, 'Uniform Scale', 1.0, parent=data).hide_in_modifier = True + interface_socket(group, 'Depth Image', 'INPUT', 'NodeSocketImage', data).hide_in_modifier = True + interface_socket(group, 'Geometry', 'OUTPUT', 'NodeSocketGeometry') + controls = group_input(group.nodes, set()) + geometry = _build_depth_surface(group, controls.outputs['Geometry'], controls.outputs['Thickness'], controls.outputs['Depth Scale'], controls.outputs['Split Threshold'], controls.outputs['Uniform Scale'], controls.outputs['Reference Depth'], controls.outputs['Depth Image']) + _depth_volume(group) + planar_inputs = [link.to_socket for link in group.links if link.from_node.bl_idname == 'NodeGroupInput' and link.from_socket.name == 'Geometry'] + base = group_input(group.nodes, {'Geometry', 'Cleanup Threshold'}) + area = math_node(group.nodes, 'MULTIPLY') + area.inputs[1].default_value = 0.01 + group.links.new(base.outputs['Cleanup Threshold'], area.inputs[0]) + cleaned = _island_cleanup(group, base.outputs['Geometry'], area.outputs[0]) + cleaned = store_float_attribute(group, cleaned, NORMAL_SCALE_ATTRIBUTE_NAME, 1.0, domain='FACE') + for target in planar_inputs: + group.links.new(cleaned, target) + geometry = expand_smooth(group, geometry, iterations_name='Smooth', weight_name='Smooth Weight') + remove = group.nodes.new('GeometryNodeRemoveAttribute') + remove.pattern_mode = 'WILDCARD' + remove.inputs['Name'].default_value = 'o_balloon_surface_*' + group.links.new(geometry, remove.inputs['Geometry']) + geometry = remove.outputs[0] + orient = group.nodes.new('GeometryNodeTransform') + orient.inputs['Rotation'].default_value = Matrix(CUTOUT_Z_TO_X).to_euler() + group.links.new(geometry, orient.inputs['Geometry']) + axis = menu_switch(group, 'Inward Axis', ('-Z', '+X'), 'GEOMETRY') + group.links.new(geometry, axis.inputs['-Z']) + group.links.new(orient.outputs[0], axis.inputs['+X']) + _shade_output(group, axis.outputs[0]) + projection = next((node.outputs['Geometry'] for node in group.nodes if node.bl_idname == 'GeometryNodeStoreNamedAttribute' and node.inputs['Name'].default_value == 'o_balloon_surface_front_normal')) + group.links.new(projection, group.nodes['Switch.002'].inputs['True']) + prepare_node_group(group) + for item in group.interface.items_tree: + if item.item_type != 'SOCKET' or item.in_out != 'INPUT': + continue + if item.name == 'Mode': + item.default_value = 'Balloon' + elif item.name == 'Inward Axis': + item.default_value = '-Z' + elif item.name == 'Uniform Thickness': + item.name = 'Thickness' + return group diff --git a/tools/nodes/groups/image_depth_layer.py b/tools/nodes/groups/image_depth_layer.py new file mode 100644 index 0000000..87d9a65 --- /dev/null +++ b/tools/nodes/groups/image_depth_layer.py @@ -0,0 +1,102 @@ +"""Build O Image Depth Layer.""" +import bpy +from anyimage.common.material import DEPTH_MATERIAL_NODE_GROUP_NAME +from ..common.materials import build_material_nodes, connect_material_outputs + +MATERIAL_DISPLACEMENT_ATTRIBUTE = "o_material_displacement" +BACKGROUND_DEPTH_ATTRIBUTE = "o_background_depth" +DEPTH_SCALE_ATTRIBUTE = "o_depth_scale" + + +def build_image_depth_layer_group(): + group_name = DEPTH_MATERIAL_NODE_GROUP_NAME + existing = bpy.data.node_groups.get(group_name) + if existing is not None: + return existing + group = bpy.data.node_groups.new(group_name, 'ShaderNodeTree') + interface = group.interface + input_sockets = [('Color', 'NodeSocketColor'), ('Alpha', 'NodeSocketFloat'), ('Bump Scale', 'NodeSocketFloat')] + input_sockets.insert(2, ('Depth', 'NodeSocketFloat')) + options = None + option_names = {'Alpha Fix', 'Normal Scale', 'Object Space'} + for name, socket_type in input_sockets: + socket = interface.new_socket(name=name, in_out='INPUT', socket_type=socket_type, parent=options if name in option_names else None) + if name in {'Alpha', 'Alpha Fix'}: + socket.default_value = 1.0 if name == 'Alpha' else 0.0 + socket.min_value = 0.0 + socket.max_value = 1.0 + socket.subtype = 'FACTOR' + elif name == 'Bump Scale': + socket.default_value = 0.0 + socket.min_value = -10000.0 + socket.max_value = 10000.0 + elif name == 'Normal': + socket.default_value = (0.5, 0.5, 1.0, 1.0) + elif name == 'Normal Scale': + socket.default_value = 1.0 + socket.min_value = 0.0 + socket.max_value = 10.0 + elif name == 'Object Space': + socket.default_value = False + output_sockets = [('Color', 'NodeSocketColor'), ('Roughness', 'NodeSocketFloat'), ('Alpha', 'NodeSocketFloat'), ('Normal', 'NodeSocketVector')] + output_sockets.append(('Displacement', 'NodeSocketVector')) + for name, socket_type in output_sockets: + interface.new_socket(name=name, in_out='OUTPUT', socket_type=socket_type) + nodes, links = group.nodes, group.links + group_input, group_output, roughness_curve, bump = build_material_nodes(group) + processed_alpha = group_input.outputs['Alpha'] + displacement = nodes.new('ShaderNodeDisplacement') + displacement.inputs['Midlevel'].default_value = 0.0 + displacement.inputs['Scale'].default_value = 1.0 + group_input.location = (-1200.0, 100.0) + group_output.location = (1050.0, 100.0) + roughness_curve.location = (-300.0, 450.0) + bump.location = (750.0, 50.0) + displacement.location = (750.0, -300.0) + background_attribute = nodes.new('ShaderNodeAttribute') + background_attribute.attribute_type = 'GEOMETRY' + background_attribute.attribute_name = BACKGROUND_DEPTH_ATTRIBUTE + background_attribute.location = (-950.0, -500.0) + material_attribute = nodes.new('ShaderNodeAttribute') + material_attribute.attribute_type = 'GEOMETRY' + material_attribute.attribute_name = MATERIAL_DISPLACEMENT_ATTRIBUTE + material_attribute.location = (-50.0, -650.0) + strength_attribute = nodes.new('ShaderNodeAttribute') + strength_attribute.attribute_type = 'GEOMETRY' + strength_attribute.attribute_name = DEPTH_SCALE_ATTRIBUTE + strength_attribute.location = (200.0, -650.0) + foreground_mask = nodes.new('ShaderNodeMath') + foreground_mask.operation = 'GREATER_THAN' + foreground_mask.inputs[1].default_value = 0.5 + foreground_mask.location = (-950.0, -250.0) + clamp_foreground = nodes.new('ShaderNodeMath') + clamp_foreground.operation = 'MAXIMUM' + clamp_foreground.location = (-650.0, -400.0) + masked_depth = nodes.new('ShaderNodeMix') + masked_depth.data_type = 'FLOAT' + masked_depth.location = (-350.0, -350.0) + enabled_depth = nodes.new('ShaderNodeMath') + enabled_depth.operation = 'MULTIPLY' + enabled_depth.location = (250.0, -350.0) + scaled_depth = nodes.new('ShaderNodeMath') + scaled_depth.operation = 'MULTIPLY' + scaled_depth.location = (450.0, -350.0) + links.new(group_input.outputs['Alpha'], foreground_mask.inputs[0]) + links.new(group_input.outputs['Depth'], clamp_foreground.inputs[0]) + links.new(background_attribute.outputs['Fac'], clamp_foreground.inputs[1]) + links.new(foreground_mask.outputs[0], masked_depth.inputs[0]) + links.new(background_attribute.outputs['Fac'], masked_depth.inputs[2]) + links.new(clamp_foreground.outputs[0], masked_depth.inputs[3]) + links.new(masked_depth.outputs['Result'], enabled_depth.inputs[0]) + links.new(material_attribute.outputs['Fac'], enabled_depth.inputs[1]) + links.new(enabled_depth.outputs[0], scaled_depth.inputs[0]) + links.new(strength_attribute.outputs['Fac'], scaled_depth.inputs[1]) + processed_alpha = foreground_mask.outputs[0] + processed_depth = scaled_depth.outputs[0] + connect_material_outputs(group, group_input, group_output, roughness_curve, bump, processed_alpha, (500.0, 100.0)) + links.new(processed_depth, displacement.inputs['Height']) + links.new(displacement.outputs['Displacement'], group_output.inputs['Displacement']) + group.color_tag = 'SHADER' + group.description = 'Prepare image depth material outputs.' + group.use_fake_user = True + return group diff --git a/tools/nodes/groups/image_depth_plane.py b/tools/nodes/groups/image_depth_plane.py new file mode 100644 index 0000000..606f28e --- /dev/null +++ b/tools/nodes/groups/image_depth_plane.py @@ -0,0 +1,163 @@ +"""Build the camera-projected Depth Plane Geometry Nodes group.""" + +import bpy + +from ..common.nodes import group_input as _group_input, interface_socket as _interface_socket +from ..common.nodes import prepare_node_group +from ..common.smoothing import expand_smooth +from ..common.depth_surface import project_depth_surface, thicken_depth_surface, remove_surface_attributes + +DEPTH_PLANE_NODE_GROUP_NAME = "O Image Depth Plane" + + +def build_image_depth_plane_group(image_plane_group): + group = bpy.data.node_groups.get(DEPTH_PLANE_NODE_GROUP_NAME) + if group is not None: + return group + group = bpy.data.node_groups.new( + DEPTH_PLANE_NODE_GROUP_NAME, + "GeometryNodeTree", + ) + group.is_modifier = True + geometry = _interface_socket(group, "Geometry", "INPUT", "NodeSocketGeometry") + geometry.structure_type = "SINGLE" + subdivide = _interface_socket(group, "Subdivide", "INPUT", "NodeSocketInt") + subdivide.default_value = 6 + subdivide.min_value = 0 + subdivide.max_value = 10 + thickness = _interface_socket(group, "Thickness", "INPUT", "NodeSocketFloat") + thickness.default_value = 0.0 + thickness.min_value = 0.0 + thickness.subtype = "DISTANCE" + thickness.description = "Inward shell distance" + depth_scale = _interface_socket(group, "Depth Scale", "INPUT", "NodeSocketFloat") + depth_scale.default_value = 1.0 + depth_scale.min_value = 0.0 + iterations = _interface_socket(group, "Smooth", "INPUT", "NodeSocketInt") + iterations.default_value = 0 + iterations.min_value, iterations.max_value = 0, 20 + valid_only = _interface_socket(group, "Valid Only", "INPUT", "NodeSocketBool") + valid_only.default_value = True + options = group.interface.new_panel(name="Options", default_closed=True) + base_plane_depth = _interface_socket( + group, + "Reference Depth", + "INPUT", + "NodeSocketFloat", + parent=options, + ) + base_plane_depth.default_value = 1.0 + base_plane_depth.min_value = 0.0 + base_plane_depth.subtype = "DISTANCE" + normal_smooth = _interface_socket( + group, "Normal Smooth", "INPUT", "NodeSocketInt", parent=options, + ) + normal_smooth.default_value, normal_smooth.min_value, normal_smooth.max_value = 50, 0, 50 + normal_smooth.description = "Smooth the shell thickness direction" + smooth_weight = _interface_socket(group, "Smooth Weight", "INPUT", "NodeSocketFloat", parent=options) + smooth_weight.default_value = 1.0 + smooth_weight.min_value, smooth_weight.max_value = 0.0, 1.0 + smooth_weight.subtype = "FACTOR" + validity_threshold = _interface_socket(group, "Validity Threshold", "INPUT", "NodeSocketFloat", parent=options) + validity_threshold.default_value = 0.9 + validity_threshold.min_value, validity_threshold.max_value = 0.0, 1.0 + validity_threshold.subtype = "FACTOR" + data = group.interface.new_panel(name="Data", default_closed=True) + uniform_scale = _interface_socket( + group, + "Uniform Scale", + "INPUT", + "NodeSocketFloat", + parent=data, + ) + uniform_scale.default_value = 1.0 + uniform_scale.min_value = 0.0 + uniform_scale.hide_in_modifier = True + depth_image = _interface_socket( + group, + "Depth Image", + "INPUT", + "NodeSocketImage", + parent=data, + ) + depth_image.hide_in_modifier = True + _interface_socket(group, "Geometry", "OUTPUT", "NodeSocketGeometry") + + nodes, links = group.nodes, group.links + controls = _group_input(nodes, {"Geometry", "Subdivide"}) + plane = nodes.new("GeometryNodeGroup") + plane.node_tree = image_plane_group + plane.inputs["Thickness"].default_value = 0 + links.new(controls.outputs["Geometry"], plane.inputs["Geometry"]) + links.new(controls.outputs["Subdivide"], plane.inputs["Subdivide"]) + texture_input = _group_input(nodes, {"Depth Image"}) + scale_input = _group_input(nodes, {"Uniform Scale"}) + uv = nodes.new("GeometryNodeInputNamedAttribute") + uv.data_type = "FLOAT_VECTOR" + uv.inputs["Name"].default_value = "UVMap" + texture = nodes.new("GeometryNodeImageTexture") + texture.interpolation, texture.extension = "Linear", "EXTEND" + links.new(uv.outputs["Attribute"], texture.inputs["Vector"]) + links.new(texture_input.outputs["Depth Image"], texture.inputs["Image"]) + depth_input = _group_input(nodes, {"Reference Depth", "Depth Scale"}) + surface = project_depth_surface( + group, plane.outputs["Geometry"], texture.outputs["Color"], + scale_input.outputs["Uniform Scale"], depth_input.outputs["Reference Depth"], + depth_input.outputs["Depth Scale"], + ) + shell_input = _group_input(nodes, {"Thickness", "Normal Smooth"}) + shell = thicken_depth_surface( + group, surface, shell_input.outputs["Thickness"], + normal_smooth=shell_input.outputs["Normal Smooth"], + ) + positive = nodes.new("ShaderNodeMath") + positive.operation = "GREATER_THAN" + links.new(shell_input.outputs["Thickness"], positive.inputs[0]) + positive.inputs[1].default_value = 0 + choose = nodes.new("GeometryNodeSwitch") + choose.input_type = "GEOMETRY" + links.new(positive.outputs[0], choose.inputs["Switch"]) + links.new(surface, choose.inputs["False"]) + links.new(shell, choose.inputs["True"]) + clean = remove_surface_attributes(group, choose.outputs["Output"]) + # Preserve the repeated cap input independently of its display routing. + cap = nodes.new("NodeReroute") + links.new(nodes["Store Named Attribute.001"].outputs["Geometry"], cap.inputs[0]) + links.new(cap.outputs[0], nodes["Join Geometry"].inputs["Geometry"]) + projection_link = next(link for link in links if link.from_socket == plane.outputs["Geometry"]) + projection_input = projection_link.to_socket + threshold_input = _group_input(nodes, {"Validity Threshold"}) + channels = nodes.new("FunctionNodeSeparateColor") + channels.mode = "RGB" + links.new(texture.outputs["Color"], channels.inputs["Color"]) + invalid = nodes.new("FunctionNodeCompare") + invalid.data_type, invalid.operation = "FLOAT", "LESS_THAN" + links.new(threshold_input.outputs["Validity Threshold"], invalid.inputs["B"]) + links.new(channels.outputs["Alpha"], invalid.inputs["A"]) + points = nodes.new("GeometryNodeFieldOnDomain") + points.data_type, points.domain = "BOOLEAN", "POINT" + links.new(invalid.outputs["Result"], points.inputs["Value"]) + delete = nodes.new("GeometryNodeDeleteGeometry") + delete.domain, delete.mode = "FACE", "ALL" + links.new(plane.outputs["Geometry"], delete.inputs["Geometry"]) + links.new(points.outputs["Value"], delete.inputs["Selection"]) + cull_controls = _group_input(nodes, {"Valid Only"}) + cull = nodes.new("GeometryNodeSwitch") + cull.input_type = "GEOMETRY" + links.new(cull_controls.outputs["Valid Only"], cull.inputs["Switch"]) + links.new(delete.outputs["Geometry"], cull.inputs["True"]) + links.new(plane.outputs["Geometry"], cull.inputs["False"]) + links.new(cull.outputs["Output"], projection_input) + geometry = expand_smooth( + group, clean, iterations_name="Smooth", weight_name="Smooth Weight", + ) + smooth = nodes.new("GeometryNodeSetShadeSmooth") + smooth.domain = "FACE" + links.new(geometry, smooth.inputs["Geometry"]) + output = nodes.new("NodeGroupOutput") + links.new(smooth.outputs["Geometry"], output.inputs["Geometry"]) + group.color_tag = "GEOMETRY" + group.description = "Build a camera-projected image shell." + group.use_fake_user = True + prepare_node_group(group) + return group diff --git a/tools/nodes/groups/image_layer.py b/tools/nodes/groups/image_layer.py new file mode 100644 index 0000000..7204d8a --- /dev/null +++ b/tools/nodes/groups/image_layer.py @@ -0,0 +1,88 @@ +"""Build O Image Layer.""" +import bpy +from anyimage.common.material import IMAGE_MATERIAL_NODE_GROUP_NAME +from ..common.materials import build_material_nodes, connect_material_outputs + + +def build_image_layer_group(): + group_name = IMAGE_MATERIAL_NODE_GROUP_NAME + existing = bpy.data.node_groups.get(group_name) + if existing is not None: + return existing + group = bpy.data.node_groups.new(group_name, 'ShaderNodeTree') + interface = group.interface + input_sockets = [('Color', 'NodeSocketColor'), ('Alpha', 'NodeSocketFloat'), ('Bump Scale', 'NodeSocketFloat')] + input_sockets.insert(2, ('Normal', 'NodeSocketColor')) + input_sockets.insert(3, ('Normal Scale', 'NodeSocketFloat')) + input_sockets.insert(4, ('Object Space', 'NodeSocketBool')) + input_sockets.insert(2, ('Alpha Fix', 'NodeSocketFloat')) + options = interface.new_panel(name='Options', default_closed=True) + option_names = {'Alpha Fix', 'Normal Scale', 'Object Space'} + for name, socket_type in input_sockets: + socket = interface.new_socket(name=name, in_out='INPUT', socket_type=socket_type, parent=options if name in option_names else None) + if name in {'Alpha', 'Alpha Fix'}: + socket.default_value = 1.0 if name == 'Alpha' else 0.0 + socket.min_value = 0.0 + socket.max_value = 1.0 + socket.subtype = 'FACTOR' + elif name == 'Bump Scale': + socket.default_value = 0.0 + socket.min_value = -10000.0 + socket.max_value = 10000.0 + elif name == 'Normal': + socket.default_value = (0.5, 0.5, 1.0, 1.0) + elif name == 'Normal Scale': + socket.default_value = 1.0 + socket.min_value = 0.0 + socket.max_value = 10.0 + elif name == 'Object Space': + socket.default_value = False + output_sockets = [('Color', 'NodeSocketColor'), ('Roughness', 'NodeSocketFloat'), ('Alpha', 'NodeSocketFloat'), ('Normal', 'NodeSocketVector')] + for name, socket_type in output_sockets: + interface.new_socket(name=name, in_out='OUTPUT', socket_type=socket_type) + nodes, links = group.nodes, group.links + group_input, group_output, roughness_curve, bump = build_material_nodes(group) + tangent_normal = nodes.new('ShaderNodeNormalMap') + tangent_normal.space = 'TANGENT' + tangent_normal.location = (-500.0, -100.0) + object_normal = nodes.new('ShaderNodeNormalMap') + object_normal.space = 'OBJECT' + object_normal.location = (-500.0, -350.0) + choose_normal = nodes.new('ShaderNodeMix') + choose_normal.data_type = 'VECTOR' + choose_normal.location = (-150.0, -150.0) + for normal_map in (tangent_normal, object_normal): + links.new(group_input.outputs['Normal'], normal_map.inputs['Color']) + links.new(group_input.outputs['Normal Scale'], normal_map.inputs['Strength']) + links.new(group_input.outputs['Object Space'], choose_normal.inputs[0]) + links.new(tangent_normal.outputs['Normal'], choose_normal.inputs[4]) + links.new(object_normal.outputs['Normal'], choose_normal.inputs[5]) + links.new(choose_normal.outputs['Result'], bump.inputs['Normal']) + processed_alpha = group_input.outputs['Alpha'] + alpha_curve = nodes.new('ShaderNodeFloatCurve') + alpha_curve.mapping.initialize() + alpha_curve.mapping.extend = 'HORIZONTAL' + alpha_curve.mapping.use_clip = True + alpha_points = alpha_curve.mapping.curves[0].points + alpha_points[0].location = (0.0, 0.0) + alpha_points[-1].location = (1.0, 1.0) + alpha_points.new(0.8, 0.0) + alpha_points.new(0.925, 0.925) + for point in alpha_points: + point.handle_type = 'AUTO_CLAMPED' + alpha_curve.mapping.update() + group_input.location = (-750.0, 150.0) + group_output.location = (350.0, 450.0) + alpha_curve.location = (-350.0, 1100.0) + roughness_curve.location = (-350.0, 650.0) + links.new(group_input.outputs['Alpha'], alpha_curve.inputs['Value']) + links.new(group_input.outputs['Alpha Fix'], alpha_curve.inputs['Factor']) + processed_alpha = alpha_curve.outputs['Value'] + connect_material_outputs(group, group_input, group_output, roughness_curve, bump, processed_alpha, (-250.0, 150.0)) + group.color_tag = 'SHADER' + group.description = f'Prepare {group_name} material outputs.' + group.use_fake_user = True + for node in nodes: + for socket in node.outputs: + socket.hide = not socket.is_linked + return group diff --git a/tools/node_assets/image_plane.py b/tools/nodes/groups/image_plane.py similarity index 82% rename from tools/node_assets/image_plane.py rename to tools/nodes/groups/image_plane.py index e293757..12152e2 100644 --- a/tools/node_assets/image_plane.py +++ b/tools/nodes/groups/image_plane.py @@ -2,7 +2,7 @@ import bpy -from plane_layouts import layout_geometry +from ..common.nodes import prepare_node_group IMAGE_PLANE_GROUP_NAME = "O Image Plane" @@ -20,17 +20,16 @@ def new_socket(interface, name, in_out, socket_type, parent=None): return socket -def new_group_input(nodes, location, visible_names): +def new_group_input(nodes, visible_names): node = nodes.new("NodeGroupInput") - node.location = location visible = set(visible_names) for output in node.outputs: output.hide = output.name not in visible return node -def localize_dimensions(group): - """Repeat the small source-bounds calculation near UV and mesh consumers.""" +def build_dimension_fields(group): + """Build independent source bounds for UV and mesh calculations.""" sources = [ group.nodes[name] for name in ("Bounding Box", "Vector Math", "Separate XYZ") ] @@ -73,86 +72,68 @@ def build_image_plane_group(): nodes = group.nodes links = group.links - geometry_input = new_group_input(nodes, (-1800.0, 500.0), {"Geometry"}) - subdivide_input = new_group_input(nodes, (-1800.0, 0.0), {"Subdivide"}) - thickness_input = new_group_input(nodes, (100.0, -250.0), {"Thickness"}) + geometry_input = new_group_input(nodes, {"Geometry"}) + subdivide_input = new_group_input(nodes, {"Subdivide"}) + thickness_input = new_group_input(nodes, {"Thickness"}) output = nodes.new("NodeGroupOutput") - output.location = (1750.0, 500.0) bounds = nodes.new("GeometryNodeBoundBox") - bounds.location = (-1600.0, 500.0) links.new(geometry_input.outputs["Geometry"], bounds.inputs["Geometry"]) dimensions = nodes.new("ShaderNodeVectorMath") dimensions.operation = "SUBTRACT" - dimensions.location = (-1400.0, 500.0) links.new(bounds.outputs["Max"], dimensions.inputs[0]) links.new(bounds.outputs["Min"], dimensions.inputs[1]) separate_dimensions = nodes.new("ShaderNodeSeparateXYZ") - separate_dimensions.location = (-1200.0, 500.0) links.new(dimensions.outputs["Vector"], separate_dimensions.inputs[0]) short_segments = nodes.new("ShaderNodeMath") short_segments.operation = "POWER" short_segments.inputs[0].default_value = 2.0 - short_segments.location = (-1400.0, 0.0) links.new(subdivide_input.outputs["Subdivide"], short_segments.inputs[1]) vertex_counts = [] - for index, (axis, other_axis) in enumerate((("X", "Y"), ("Y", "X"))): - y = 300.0 - index * 300.0 + for axis, other_axis in (("X", "Y"), ("Y", "X")): ratio = nodes.new("ShaderNodeMath") ratio.operation = "DIVIDE" - ratio.location = (-1000.0, y) links.new(separate_dimensions.outputs[axis], ratio.inputs[0]) links.new(separate_dimensions.outputs[other_axis], ratio.inputs[1]) at_least_one = nodes.new("ShaderNodeMath") at_least_one.operation = "MAXIMUM" at_least_one.inputs[1].default_value = 1.0 - at_least_one.location = (-800.0, y) links.new(ratio.outputs[0], at_least_one.inputs[0]) scaled_segments = nodes.new("ShaderNodeMath") scaled_segments.operation = "MULTIPLY" - scaled_segments.location = (-600.0, y) links.new(short_segments.outputs[0], scaled_segments.inputs[0]) links.new(at_least_one.outputs[0], scaled_segments.inputs[1]) rounded_segments = nodes.new("ShaderNodeMath") rounded_segments.operation = "ROUND" - rounded_segments.location = (-400.0, y) links.new(scaled_segments.outputs[0], rounded_segments.inputs[0]) vertices = nodes.new("ShaderNodeMath") vertices.operation = "ADD" vertices.inputs[1].default_value = 1.0 - vertices.location = (-200.0, y) links.new(rounded_segments.outputs[0], vertices.inputs[0]) vertex_counts.append(vertices.outputs[0]) grid = nodes.new("GeometryNodeMeshGrid") - grid.location = (50.0, 500.0) links.new(separate_dimensions.outputs["X"], grid.inputs["Size X"]) links.new(separate_dimensions.outputs["Y"], grid.inputs["Size Y"]) links.new(vertex_counts[0], grid.inputs["Vertices X"]) links.new(vertex_counts[1], grid.inputs["Vertices Y"]) position = nodes.new("GeometryNodeInputPosition") - position.location = (-200.0, 900.0) separate_position = nodes.new("ShaderNodeSeparateXYZ") - separate_position.location = (0.0, 900.0) links.new(position.outputs["Position"], separate_position.inputs[0]) uv_components = [] - for index, axis in enumerate(("X", "Y")): - y = 900.0 - index * 180.0 + for axis in ("X", "Y"): normalize = nodes.new("ShaderNodeMath") normalize.operation = "DIVIDE" - normalize.location = (200.0, y) links.new(separate_position.outputs[axis], normalize.inputs[0]) links.new(separate_dimensions.outputs[axis], normalize.inputs[1]) offset_uv = nodes.new("ShaderNodeMath") offset_uv.operation = "ADD" offset_uv.inputs[1].default_value = 0.5 - offset_uv.location = (400.0, y) links.new(normalize.outputs[0], offset_uv.inputs[0]) uv_components.append(offset_uv.outputs[0]) uv_coordinates = nodes.new("ShaderNodeCombineXYZ") - uv_coordinates.location = (600.0, 800.0) links.new(uv_components[0], uv_coordinates.inputs["X"]) links.new(uv_components[1], uv_coordinates.inputs["Y"]) @@ -160,75 +141,60 @@ def build_image_plane_group(): store_grid_uv.data_type = "FLOAT2" store_grid_uv.domain = "CORNER" store_grid_uv.inputs["Name"].default_value = "UVMap" - store_grid_uv.location = (250.0, 500.0) links.new(grid.outputs["Mesh"], store_grid_uv.inputs["Geometry"]) links.new(uv_coordinates.outputs["Vector"], store_grid_uv.inputs["Value"]) center_sum = nodes.new("ShaderNodeVectorMath") center_sum.operation = "ADD" - center_sum.location = (-200.0, 1150.0) links.new(bounds.outputs["Min"], center_sum.inputs[0]) links.new(bounds.outputs["Max"], center_sum.inputs[1]) center = nodes.new("ShaderNodeVectorMath") center.operation = "SCALE" center.inputs[3].default_value = 0.5 - center.location = (50.0, 1150.0) links.new(center_sum.outputs["Vector"], center.inputs[0]) place_grid = nodes.new("GeometryNodeTransform") - place_grid.location = (500.0, 500.0) links.new(store_grid_uv.outputs["Geometry"], place_grid.inputs["Geometry"]) links.new(center.outputs["Vector"], place_grid.inputs["Translation"]) cell_sizes = [] for index, axis in enumerate(("X", "Y")): - y = 100.0 - index * 180.0 segments = nodes.new("ShaderNodeMath") segments.operation = "SUBTRACT" segments.inputs[1].default_value = 1.0 - segments.location = (-100.0, y) links.new(vertex_counts[index], segments.inputs[0]) cell_size = nodes.new("ShaderNodeMath") cell_size.operation = "DIVIDE" - cell_size.location = (100.0, y) links.new(separate_dimensions.outputs[axis], cell_size.inputs[0]) links.new(segments.outputs[0], cell_size.inputs[1]) cell_sizes.append(cell_size.outputs[0]) cell_area = nodes.new("ShaderNodeMath") cell_area.operation = "MULTIPLY" - cell_area.location = (300.0, 100.0) links.new(cell_sizes[0], cell_area.inputs[0]) links.new(cell_sizes[1], cell_area.inputs[1]) target_step = nodes.new("ShaderNodeMath") target_step.operation = "SQRT" - target_step.location = (500.0, 100.0) links.new(cell_area.outputs[0], target_step.inputs[0]) depth_ratio = nodes.new("ShaderNodeMath") depth_ratio.operation = "DIVIDE" - depth_ratio.location = (300.0, -100.0) links.new(thickness_input.outputs["Thickness"], depth_ratio.inputs[0]) links.new(target_step.outputs[0], depth_ratio.inputs[1]) rounded_depth = nodes.new("ShaderNodeMath") rounded_depth.operation = "ROUND" - rounded_depth.location = (500.0, -100.0) links.new(depth_ratio.outputs[0], rounded_depth.inputs[0]) depth_segments = nodes.new("ShaderNodeMath") depth_segments.operation = "MAXIMUM" depth_segments.inputs[1].default_value = 3.0 - depth_segments.location = (700.0, -100.0) links.new(rounded_depth.outputs[0], depth_segments.inputs[0]) depth_vertices = nodes.new("ShaderNodeMath") depth_vertices.operation = "ADD" depth_vertices.inputs[1].default_value = 1.0 - depth_vertices.location = (900.0, -100.0) links.new(depth_segments.outputs[0], depth_vertices.inputs[0]) cube_size = nodes.new("ShaderNodeCombineXYZ") - cube_size.location = (700.0, 250.0) links.new(separate_dimensions.outputs["X"], cube_size.inputs["X"]) links.new(separate_dimensions.outputs["Y"], cube_size.inputs["Y"]) links.new(thickness_input.outputs["Thickness"], cube_size.inputs["Z"]) cube = nodes.new("GeometryNodeMeshCube") - cube.location = (900.0, 250.0) links.new(cube_size.outputs["Vector"], cube.inputs["Size"]) links.new(vertex_counts[0], cube.inputs["Vertices X"]) links.new(vertex_counts[1], cube.inputs["Vertices Y"]) @@ -237,21 +203,17 @@ def build_image_plane_group(): store_cube_uv.data_type = "FLOAT2" store_cube_uv.domain = "CORNER" store_cube_uv.inputs["Name"].default_value = "UVMap" - store_cube_uv.location = (1100.0, 250.0) links.new(cube.outputs["Mesh"], store_cube_uv.inputs["Geometry"]) links.new(uv_coordinates.outputs["Vector"], store_cube_uv.inputs["Value"]) place_cube = nodes.new("GeometryNodeTransform") - place_cube.location = (1300.0, 250.0) links.new(store_cube_uv.outputs["Geometry"], place_cube.inputs["Geometry"]) links.new(center.outputs["Vector"], place_cube.inputs["Translation"]) has_thickness = nodes.new("FunctionNodeCompare") has_thickness.data_type = "FLOAT" has_thickness.operation = "GREATER_THAN" - has_thickness.location = (450.0, -350.0) links.new(thickness_input.outputs["Thickness"], has_thickness.inputs[0]) geometry_switch = nodes.new("GeometryNodeSwitch") geometry_switch.input_type = "GEOMETRY" - geometry_switch.location = (750.0, 500.0) links.new(has_thickness.outputs["Result"], geometry_switch.inputs["Switch"]) links.new(place_grid.outputs["Geometry"], geometry_switch.inputs["False"]) links.new(place_cube.outputs["Geometry"], geometry_switch.inputs["True"]) @@ -261,19 +223,15 @@ def build_image_plane_group(): capture_source.capture_items.clear() capture_source.capture_items.new("BOOLEAN", "Source") capture_source.inputs["Source"].default_value = True - capture_source.location = (750.0, 750.0) links.new(geometry_input.outputs["Geometry"], capture_source.inputs["Geometry"]) join_material = nodes.new("GeometryNodeJoinGeometry") - join_material.location = (1000.0, 500.0) links.new(geometry_switch.outputs["Output"], join_material.inputs["Geometry"]) links.new(capture_source.outputs["Geometry"], join_material.inputs["Geometry"]) set_material_index = nodes.new("GeometryNodeSetMaterialIndex") set_material_index.inputs["Material Index"].default_value = 0 - set_material_index.location = (1250.0, 500.0) links.new(join_material.outputs["Geometry"], set_material_index.inputs["Geometry"]) delete_source = nodes.new("GeometryNodeDeleteGeometry") delete_source.domain = "POINT" - delete_source.location = (1500.0, 500.0) links.new(set_material_index.outputs["Geometry"], delete_source.inputs["Geometry"]) links.new(capture_source.outputs["Source"], delete_source.inputs["Selection"]) links.new(delete_source.outputs["Geometry"], output.inputs["Geometry"]) @@ -283,6 +241,6 @@ def build_image_plane_group(): "Build an aspect-adaptive image plane and add optional centered thickness." ) group.use_fake_user = True - localize_dimensions(group) - layout_geometry(group) + build_dimension_fields(group) + prepare_node_group(group) return group diff --git a/tools/node_assets/depth_plane.py b/tools/nodes/groups/image_relief_plane.py similarity index 58% rename from tools/node_assets/depth_plane.py rename to tools/nodes/groups/image_relief_plane.py index c9ee6d9..dca737c 100644 --- a/tools/node_assets/depth_plane.py +++ b/tools/nodes/groups/image_relief_plane.py @@ -1,56 +1,31 @@ -"""Build the Depth Plane Geometry Nodes group.""" +"""Build the Relief Plane Geometry Nodes group.""" import bpy -from plane_layouts import layout_geometry -from cutout_layout import finish_layout -from depth_plane_camera import build_camera_plane -from depth_plane_layout import layout_camera_plane +from ..common.nodes import group_input as _group_input, interface_socket as _interface_socket +from ..common.nodes import prepare_node_group -DEPTH_PLANE_NODE_GROUP_NAME = "O Image Depth Plane" +RELIEF_PLANE_NODE_GROUP_NAME = "O Image Relief Plane" -def _interface_socket(group, name, in_out, socket_type, parent=None): - socket = group.interface.new_socket( - name=name, - in_out=in_out, - socket_type=socket_type, - parent=parent, - ) - if in_out == "INPUT" and hasattr(socket, "structure_type"): - socket.structure_type = "SINGLE" - return socket - - -def _group_input(nodes, location, visible_names): - node = nodes.new("NodeGroupInput") - node.location = location - visible = set(visible_names) - for output in node.outputs: - output.hide = output.name not in visible - return node - - -def _math(nodes, operation, location): +def _math(nodes, operation): node = nodes.new("ShaderNodeMath") node.operation = operation - node.location = location return node -def build_depth_plane_group(image_plane_group): - group = bpy.data.node_groups.get(DEPTH_PLANE_NODE_GROUP_NAME) +def build_image_relief_plane_group(image_plane_group): + group = bpy.data.node_groups.get(RELIEF_PLANE_NODE_GROUP_NAME) if group is not None: return group group = bpy.data.node_groups.new( - DEPTH_PLANE_NODE_GROUP_NAME, + RELIEF_PLANE_NODE_GROUP_NAME, "GeometryNodeTree", ) group.is_modifier = True geometry = _interface_socket(group, "Geometry", "INPUT", "NodeSocketGeometry") geometry.structure_type = "SINGLE" - mode = _interface_socket(group, "Mode", "INPUT", "NodeSocketMenu") subdivide = _interface_socket(group, "Subdivide", "INPUT", "NodeSocketInt") subdivide.default_value = 6 subdivide.min_value = 0 @@ -59,12 +34,7 @@ def build_depth_plane_group(image_plane_group): thickness.default_value = 0.0 thickness.min_value = 0.0 thickness.subtype = "DISTANCE" - thickness.description = "Base thickness used only by Relief mode" - camera_thickness = _interface_socket(group, "Camera Thickness", "INPUT", "NodeSocketFloat") - camera_thickness.default_value = 0.0 - camera_thickness.min_value = 0.0 - camera_thickness.subtype = "DISTANCE" - camera_thickness.description = "Inward shell distance used only by Camera mode" + thickness.description = "Fixed relief base thickness" depth_scale = _interface_socket(group, "Depth Scale", "INPUT", "NodeSocketFloat") depth_scale.default_value = 1.0 depth_scale.min_value = 0.0 @@ -79,11 +49,6 @@ def build_depth_plane_group(image_plane_group): base_plane_depth.default_value = 1.0 base_plane_depth.min_value = 0.0 base_plane_depth.subtype = "DISTANCE" - normal_smooth = _interface_socket( - group, "Normal Smooth", "INPUT", "NodeSocketInt", parent=options, - ) - normal_smooth.default_value, normal_smooth.min_value, normal_smooth.max_value = 50, 0, 50 - normal_smooth.description = "Smooth the Camera shell thickness direction" data = group.interface.new_panel(name="Data", default_closed=True) uniform_scale = _interface_socket( group, @@ -109,146 +74,102 @@ def build_depth_plane_group(image_plane_group): links = group.links geometry_input = _group_input( nodes, - (-1900.0, 500.0), {"Geometry", "Subdivide"}, ) shape_input = _group_input( nodes, - (-1550.0, 950.0), {"Thickness", "Depth Scale", "Reference Depth"}, ) image_input = _group_input( nodes, - (-1200.0, -350.0), {"Depth Image", "Uniform Scale"}, ) group_output = nodes.new("NodeGroupOutput") - group_output.location = (1550.0, 500.0) image_plane = nodes.new("GeometryNodeGroup") image_plane.node_tree = image_plane_group - image_plane.location = (-1600.0, 500.0) links.new(geometry_input.outputs["Geometry"], image_plane.inputs["Geometry"]) links.new(geometry_input.outputs["Subdivide"], image_plane.inputs["Subdivide"]) links.new(shape_input.outputs["Thickness"], image_plane.inputs["Thickness"]) - positive_half = _math(nodes, "MULTIPLY", (-1300.0, 300.0)) + positive_half = _math(nodes, "MULTIPLY") positive_half.inputs[1].default_value = 0.5 links.new(shape_input.outputs["Thickness"], positive_half.inputs[0]) translation = nodes.new("ShaderNodeCombineXYZ") - translation.location = (-1100.0, 300.0) links.new(positive_half.outputs[0], translation.inputs["Z"]) place_block = nodes.new("GeometryNodeTransform") - place_block.location = (-900.0, 500.0) links.new(image_plane.outputs["Geometry"], place_block.inputs["Geometry"]) links.new(translation.outputs["Vector"], place_block.inputs["Translation"]) uv = nodes.new("GeometryNodeInputNamedAttribute") uv.data_type = "FLOAT_VECTOR" uv.inputs["Name"].default_value = "UVMap" - uv.location = (-950.0, -350.0) texture = nodes.new("GeometryNodeImageTexture") texture.interpolation = "Linear" texture.extension = "EXTEND" - texture.location = (-700.0, -350.0) links.new(image_input.outputs["Depth Image"], texture.inputs["Image"]) links.new(uv.outputs["Attribute"], texture.inputs["Vector"]) depth_components = nodes.new("ShaderNodeSeparateXYZ") - depth_components.location = (-450.0, -350.0) links.new(texture.outputs["Color"], depth_components.inputs["Vector"]) - metric_depth = _math(nodes, "MULTIPLY", (-200.0, -350.0)) + metric_depth = _math(nodes, "MULTIPLY") links.new(depth_components.outputs["Z"], metric_depth.inputs[0]) links.new(image_input.outputs["Uniform Scale"], metric_depth.inputs[1]) - relative_depth = _math(nodes, "SUBTRACT", (50.0, -350.0)) + relative_depth = _math(nodes, "SUBTRACT") links.new(shape_input.outputs["Reference Depth"], relative_depth.inputs[0]) links.new(metric_depth.outputs[0], relative_depth.inputs[1]) - full_offset = _math(nodes, "MULTIPLY", (300.0, -350.0)) + full_offset = _math(nodes, "MULTIPLY") links.new(relative_depth.outputs[0], full_offset.inputs[0]) links.new(shape_input.outputs["Depth Scale"], full_offset.inputs[1]) - inward_limit = _math(nodes, "MULTIPLY", (50.0, -600.0)) + inward_limit = _math(nodes, "MULTIPLY") inward_limit.inputs[1].default_value = -0.999 links.new(shape_input.outputs["Thickness"], inward_limit.inputs[0]) - limited_offset = _math(nodes, "MAXIMUM", (300.0, -600.0)) + limited_offset = _math(nodes, "MAXIMUM") links.new(full_offset.outputs[0], limited_offset.inputs[0]) links.new(inward_limit.outputs[0], limited_offset.inputs[1]) - has_thickness = _math(nodes, "GREATER_THAN", (50.0, -850.0)) + has_thickness = _math(nodes, "GREATER_THAN") has_thickness.inputs[1].default_value = 1e-6 links.new(shape_input.outputs["Thickness"], has_thickness.inputs[0]) safe_offset = nodes.new("GeometryNodeSwitch") safe_offset.input_type = "FLOAT" - safe_offset.location = (550.0, -450.0) links.new(has_thickness.outputs[0], safe_offset.inputs["Switch"]) links.new(full_offset.outputs[0], safe_offset.inputs["False"]) links.new(limited_offset.outputs[0], safe_offset.inputs["True"]) position = nodes.new("GeometryNodeInputPosition") - position.location = (-650.0, 50.0) position_components = nodes.new("ShaderNodeSeparateXYZ") - position_components.location = (-400.0, 50.0) links.new(position.outputs["Position"], position_components.inputs["Vector"]) - layer_ratio = _math(nodes, "DIVIDE", (-150.0, 50.0)) + layer_ratio = _math(nodes, "DIVIDE") links.new(position_components.outputs["Z"], layer_ratio.inputs[0]) links.new(shape_input.outputs["Thickness"], layer_ratio.inputs[1]) - nonnegative_ratio = _math(nodes, "MAXIMUM", (350.0, 50.0)) + nonnegative_ratio = _math(nodes, "MAXIMUM") nonnegative_ratio.inputs[1].default_value = 0.0 links.new(layer_ratio.outputs[0], nonnegative_ratio.inputs[0]) - clamped_ratio = _math(nodes, "MINIMUM", (600.0, 50.0)) + clamped_ratio = _math(nodes, "MINIMUM") clamped_ratio.inputs[1].default_value = 1.0 links.new(nonnegative_ratio.outputs[0], clamped_ratio.inputs[0]) layer_weight = nodes.new("GeometryNodeSwitch") layer_weight.input_type = "FLOAT" - layer_weight.location = (850.0, 50.0) layer_weight.inputs["False"].default_value = 1.0 links.new(has_thickness.outputs[0], layer_weight.inputs["Switch"]) links.new(clamped_ratio.outputs[0], layer_weight.inputs["True"]) - weighted_offset = _math(nodes, "MULTIPLY", (850.0, -350.0)) + weighted_offset = _math(nodes, "MULTIPLY") links.new(safe_offset.outputs["Output"], weighted_offset.inputs[0]) links.new(layer_weight.outputs["Output"], weighted_offset.inputs[1]) offset = nodes.new("ShaderNodeCombineXYZ") - offset.location = (1050.0, -200.0) links.new(weighted_offset.outputs[0], offset.inputs["Z"]) set_position = nodes.new("GeometryNodeSetPosition") - set_position.location = (1050.0, 500.0) links.new(place_block.outputs["Geometry"], set_position.inputs["Geometry"]) links.new(offset.outputs["Vector"], set_position.inputs["Offset"]) shade_smooth = nodes.new("GeometryNodeSetShadeSmooth") shade_smooth.domain = "FACE" shade_smooth.inputs["Shade Smooth"].default_value = True - shade_smooth.location = (1300.0, 500.0) links.new(set_position.outputs["Geometry"], shade_smooth.inputs["Geometry"]) links.new(shade_smooth.outputs["Geometry"], group_output.inputs["Geometry"]) group.color_tag = "GEOMETRY" group.description = ( - "Build a fixed-base relief or camera-projected image shell." + "Build a fixed-base image relief." ) group.use_fake_user = True - layout_geometry(group) - for node in nodes: - node.location.x += 3750 - node.location.y += 2400 - previous = set(nodes) - camera = build_camera_plane(group, image_plane_group) - layout_camera_plane(group, previous) - nodes.remove(shade_smooth) - # Align the Relief and Camera branch ends in menu order. - set_position.location.x = camera.node.location.x + 40 - selector = nodes.new("GeometryNodeMenuSwitch") - selector.data_type = "GEOMETRY" - for item, name in zip(selector.enum_items, ("Relief", "Camera")): - item.name = name - selector.location = (6500, 1300) - controls = _group_input(nodes, (6250, 1550), {"Mode"}) - links.new(controls.outputs["Mode"], selector.inputs["Menu"]) - links.new(set_position.outputs["Geometry"], selector.inputs["Relief"]) - links.new(camera, selector.inputs["Camera"]) - mode.default_value = "Relief" - smooth = nodes.new("GeometryNodeSetShadeSmooth") - smooth.domain = "FACE" - smooth.location = (6750, 1300) - links.new(selector.outputs[0], smooth.inputs["Geometry"]) - group_output.location = (7000, 1300) - links.new(smooth.outputs["Geometry"], group_output.inputs["Geometry"]) - finish_layout(group) - camera_thickness.name = "Thickness" + prepare_node_group(group) return group diff --git a/tools/node_assets/shadeless.py b/tools/nodes/groups/shadeless.py similarity index 100% rename from tools/node_assets/shadeless.py rename to tools/nodes/groups/shadeless.py diff --git a/tools/nodes/preview/__init__.py b/tools/nodes/preview/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tools/nodes/preview/__init__.py @@ -0,0 +1 @@ + diff --git a/tools/nodes/preview/export.py b/tools/nodes/preview/export.py new file mode 100644 index 0000000..4f89319 --- /dev/null +++ b/tools/nodes/preview/export.py @@ -0,0 +1,129 @@ +"""Preview node groups with the socket dimension model in a self-contained HTML file.""" + +import argparse +import json +from pathlib import Path + +import bpy + +from ..arrangement.dimensions import estimate_node_dimensions +from ..arrangement.flow import node_roles +from ..arrangement.connections import link_endpoints +from ..arrangement.zones import node_zones + + +def _display_value(value): + if isinstance(value, bpy.types.ID): + return value.name + if isinstance(value, str): + return value + if isinstance(value, bool): + return str(value) + if isinstance(value, (int, float)): + return str(round(value, 4)) + if value is None: + return "" + try: + return ", ".join(str(round(x, 3)) for x in value) + except TypeError: + return str(value) + + +def _node_group_data(node_group): + nodes = list(node_group.nodes) + ids = {node: index for index, node in enumerate(nodes)} + dimensions = {node: estimate_node_dimensions(node) for node in nodes} + roles = node_roles(node_group) + records = [] + for node in nodes: + size = dimensions[node] + settings = [] + for name in ("operation", "data_type", "input_type", "domain", "mode", "pattern_mode"): + if not hasattr(node, name): + continue + value = getattr(node, name) + prop = node.bl_rna.properties[name] + label = prop.enum_items[value].name if prop.type == "ENUM" and value in prop.enum_items else str(value) + settings.append(label) + title = node.bl_label + if node.bl_idname == "GeometryNodeGroup": + title = node.node_tree.name + elif hasattr(node, "operation") and settings: + title = settings[0] + sockets = [] + for socket, offset in size.sockets.items(): + default = _display_value(socket.default_value) if not socket.is_linked and hasattr(socket, "default_value") else "" + sockets.append([socket.name, round(offset, 2), int(socket.is_output), socket.type, default]) + records.append({"id": ids[node], "name": node.name, "title": title, + "type": node.bl_idname, "role": roles[node], + "x": round(node.location.x, 2), "y": round(-node.location.y, 2), + "w": size.width, "h": size.height, "folded": bool(node.hide), + "settings": settings, "sockets": sockets}) + links = [] + positions = {node: tuple(node.location) for node in nodes} + for link in node_group.links: + source, target = link.from_node, link.to_node + (x1, y1), (x2, y2) = link_endpoints(link, positions, dimensions) + links.append([ids[source], ids[target], *[round(v, 2) for v in (x1, -y1, x2, -y2)], link.from_socket.type]) + zones = [] + for entry, end, members in node_zones(node_group): + boxes = [records[ids[node]] for node in members] + left = min(node["x"] for node in boxes) - 40 + top = min(node["y"] for node in boxes) - 60 + right = max(node["x"] + node["w"] for node in boxes) + 40 + bottom = max(node["y"] + node["h"] for node in boxes) + 40 + zones.append({"title": entry.bl_label.removesuffix(" Input"), + "type": entry.bl_idname, "entry": ids[entry], "exit": ids[end], + "nodes": sorted(ids[node] for node in members), + "x": left, "y": top, "w": right - left, "h": bottom - top}) + zones.sort(key=lambda zone: zone["w"] * zone["h"], reverse=True) + return {"name": node_group.name, "nodes": records, "links": links, "zones": zones} + + +def write_node_preview(node_groups, output_path, *, fragment=False): + """Write an interactive preview without changing the supplied node groups.""" + data = [_node_group_data(node_group) for node_group in node_groups] + if not data or any(not node_group["nodes"] for node_group in data): + raise ValueError("The preview requires nonempty node groups") + payload = json.dumps(data, ensure_ascii=False, separators=(",", ":")).replace("<", "\\u003c") + markup = Path(__file__).with_name("template.html").read_text(encoding="utf-8").replace("__NODE_DATA__", payload) + if not fragment: + markup = """ + +节点排列预览 + +""" + markup + "\n" + output_path = Path(output_path).resolve() + output_path.write_text(markup, encoding="utf-8") + return output_path + + +def main(): + """Build and arrange geometry assets in memory for preview.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, required=True, help="Output HTML path") + parser.add_argument("--fragment", action="store_true", help="Write a fragment for an inline preview") + args = parser.parse_args() + output_path = args.output.resolve() + from ..build import build_node_groups + from ..arrangement.arrange import arrange_nodes + + bpy.ops.wm.read_factory_settings(use_empty=True) + node_groups = [node_group for node_group in build_node_groups() if node_group.bl_idname == "GeometryNodeTree"] + for node_group in node_groups: + arrange_nodes(node_group) + print(write_node_preview(node_groups, output_path, fragment=args.fragment)) + + +if __name__ == "__main__": + main() diff --git a/tools/nodes/preview/template.html b/tools/nodes/preview/template.html new file mode 100644 index 0000000..8c0c94b --- /dev/null +++ b/tools/nodes/preview/template.html @@ -0,0 +1,279 @@ +
+

节点排列 · 尺寸模型预览

+
+ + +
+
+ + + + 拖动平移 · 滚轮缩放 · 点击节点查看名称 +
+ +
+ 几何连接 + 字段与参数连接 + +
+
完整排列
+
+ + +