Add comprehensive tests for image processing nodes and refactor node imports

- Introduced new test files for image layer, image plane, normal scale, object normal rotation, shadeless, and temporary attributes.
- Implemented tests to validate the behavior of alpha curves, depth layers, and normal scales in various rendering engines.
- Refactored import paths for node groups to align with the new directory structure.
- Added checks for temporary attribute lifetimes and ensured proper cleanup without warnings.
- Enhanced CLI commands to require the Blender group and validate the presence of generated node assets before packaging.
- Updated packaging scripts to enforce the existence of necessary assets, preventing build failures.
This commit is contained in:
2026-09-19 21:23:19 +08:00
parent 2b3c0194a6
commit 9debab5c0c
73 changed files with 500 additions and 217 deletions

View File

@@ -1,3 +0,0 @@
# 复制为 .env,并填写本机 Blender 可执行文件路径。
# Windows 路径可使用正斜杠。
BLENDER_BIN="C:/Program Files/Blender Foundation/Blender 4.5/blender.exe"

View File

@@ -47,8 +47,11 @@ jobs:
with:
python-version: "3.11"
- name: Build node assets
run: uv run --group blender node-group build --skip-tests
- name: Run tests
run: uv run --group blender pytest tests/anyimage tests/tools
run: uv run --group blender pytest tests/anyimage tests/nodes tests/tools
- name: Pack extensions
run: uv run pack

View File

@@ -22,5 +22,8 @@ jobs:
with:
python-version: "3.11"
- name: Build node assets
run: uv run --group blender node-group build --skip-tests
- name: Run tests
run: uv run --group blender pytest tests/anyimage tests/tools
run: uv run --group blender pytest tests/anyimage tests/nodes tests/tools

3
.gitignore vendored
View File

@@ -42,6 +42,7 @@ secrets.bak/
*.blend1
blender_assets.cats.txt~
blendcache_*
/src/anyimage/assets/O_AnyImage.blend
# Byte-compiled / optimized / DLL files
__pycache__/
@@ -391,4 +392,4 @@ vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.vite/
models/
models/

View File

@@ -41,25 +41,25 @@ uv sync
uv lock --refresh
# 运行全部测试
uv run pytest
uv run --group blender --group ai pytest
# 构建 Windows、macOS、Linux 扩展包到 dist/
uv run pack
# 本地构建多版本静态页面(只提交到本地 gh-pages 分支,不推送)
uv run docs build
uv run --group docs docs build
# 本地热更新预览当前文档
uv run docs dev
uv run --group docs docs dev
# 用 pyproject.toml 中的 major.minor.x 发布文档并让 latest 指向它
uv run docs deploy
uv run --group docs docs deploy
# 构建并验证 Blender 节点资产
uv run node-group build
uv run --group blender node-group build
# 准备、校验并同步 ONNX 模型到 R2
uv run models sync
uv run --group models model sync
```
## 命名规范
@@ -98,13 +98,14 @@ uv run models sync
## Blender 节点组规范
- 节点组统一保存于 `src/anyimage/assets/O_AnyImage.blend`。
- 节点组源码位于顶层 `nodes/`,`tools/nodes` 只负责构建、校验、布局和预览。
- `src/anyimage/assets/O_AnyImage.blend` 是发布构建产物,由节点源码生成且不得提交到 Git。
- 插件运行时必须使用 `load_node_group(group_name)` 从资产加载,禁止调用 `bpy.data.node_groups.new()` 创建节点组。
- `tools/nodes` 构建脚本和测试允许通过代码创建节点组。
- `nodes/` 构建代码和节点测试允许通过代码创建节点组。
- 字段默认直接连接,按消费位置的几何、域、类型及输入属性版本验证求值等价性;多个消费者可复用同一输出。
- 所有几何节点、嵌套组及测试辅助图禁止使用 Capture Attribute。域转换显式表达;跨几何变化优先调整消费顺序或在明确源几何上采样。
- Store Named Attribute 仅用于实际命名协议或经论证仍需跨几何变化保存的最少数据;明确名称、类型、域、Selection、写入阶段及消费者。多个消费者或跨区域连线不构成存值理由。
- 内部临时命名属性使用专用名称,在最后消费者之后精确清理,保留用户属性;源码和保存资产均检查 Capture 为零。
- 节点布局通过区域内输入、字段排列和必要的 Reroute 保持可读;存储须有数据生命周期依据,构建阶段通过明确的节点或 socket 引用接线。
- 修改节点组的逻辑、接口、布局或共用构建函数后,必须运行 `uv run node-group build`,无需另行确认;源码、测试与 `.blend` 资产均更新且验证通过,才算完成。
- 修改节点组的逻辑、接口、布局或共用构建函数后,必须运行 `uv run --group blender node-group build`,无需另行确认;生成资产并验证通过才算完成。
- 具体构建规范与操作步骤见[节点资产](docs/internals/node-assets.md)。

View File

@@ -1,15 +1,25 @@
# AnyImage
# 🖼️ AnyImage
把图片里的空间感带进 Blender。
**IMAGE is All You Need**
*图,就够了~*
AnyImage 是一款 Blender 扩展,可以粘贴和编辑图片、创建普通 Plane 或 Depth Plane、从局部选区生成 Cutout Shape,并在独立 AI Environment 中完成去背景与高清放大。
AnyImage 是 OmooLab 为 Blender 准备的图片工具。它让图片不再只是参考图:粘贴进来,选出需要的部分,再把它变成带材质、轮廓与深度的场景元素。
- 按 **Ctrl+V**(macOS 为 **Cmd+V**)把剪贴板图片粘贴到当前编辑器
- 用 **Plane** 创建可调厚度和细分的图片网格
- 用 **Depth Plane** 把静态图片转换为可调节的立体面片
- 用 **Cutout** 从套索选区生成 Surface、Balloon 与两种 Depth Shape
- 用 **Frame** 从当前视角重新取景并合并 Image Empty,用 **Mask** 的 Lasso、Brush 或 Polyline 编辑 Alpha;Brush 拖动时以红色或灰色填充预览准确范围,释放后提交;用 **Rectify** 校正透视
- 用 **Remove Background / Upscale** 处理单图、视频和序列帧
## 从图片直接开始
在 3D View 或 Node Editor 中按 **Ctrl+V**(macOS 为 **Cmd+V**),剪贴板里的图片会直接进入当前工作区。不必先保存文件,也不用中断正在进行的搭建。
已有图片同样可以继续使用。选中 Image Empty 后,右键打开 **AnyImage** 菜单,常用操作都在这里。
## 一张图,多种空间表达
- **Plane / Depth Plane**:把图片变成平面或立体面片。
- **Cutout**:圈选主体,创建 Surface、Balloon 或 Depth Shape。
- **图片工具**:重新取景、编辑透明区域、校正透视、去背景和高清放大。
## 快速开始
按住下方按钮并拖到 Blender 窗口即可安装。首次拖入会提示添加 AnyImage 扩展仓库,确认后再拖入一次。
<div class="anyimage-install">
@@ -40,4 +50,11 @@ AnyImage 是一款 Blender 扩展,可以粘贴和编辑图片、创建普通 P
</div>
<small class="anyimage-install-note">…或<a href="https://extensions.omoolab.xyz/AnyImage.v1.0.1.linux-x64.zip" download="AnyImage.v1.0.1.linux-x64.zip">下载</a>后通过 <a href="https://docs.blender.org/manual/zh-hans/latest/editors/preferences/extensions.html#install" target="_blank">Install from Disk</a> 安装</small>
</div>
</div>
</div>
复制一张图片。在 Blender 的 3D View 中按 **Ctrl+V**(macOS 为 **Cmd+V**)。
选中图片并右键,打开 **AnyImage**,选择 **Convert to Plane**。
使用 Depth Plane、Remove Background 或 Upscale 时,按提示安装 AI Environment 即可。

View File

@@ -27,7 +27,7 @@ Blender Stub 共用 `tests/support/blender.py`;Server 和实际 Blender 几何
| --- | --- |
| `uv run pack` | 生成 Windows、macOS arm64、Linux 扩展包 |
| `uv run pack --prepare` | 为本机同步扩展 wheel 与 Manifest |
| `uv run node-group build` | 构建、验证节点资产并运行测试 |
| `uv run --group blender node-group build` | 构建、验证节点资产并运行节点测试 |
| `uv run model sync` | 准备、校验并上传模型声明的文件 |
| `uv run docs -dev` | 本地预览文档 |
| `uv run docs` | 通过 Mike 发布项目版本并更新 latest |

View File

@@ -2,7 +2,7 @@
## 资产清单
节点组由 `tools/nodes/groups/` 中的构建模块定义。
节点组由 `nodes/groups/` 中的构建模块定义,共用构建逻辑位于 `nodes/common/`。
| 节点组 | 构建模块 |
| ------------------------- | --------------------------- |
@@ -19,7 +19,7 @@
## 构建节点资产
生产节点组由 `uv run node-group build` 构建到 `src/anyimage/assets/O_AnyImage.blend`
生产节点组由 `uv run --group blender node-group build` 构建到 `src/anyimage/assets/O_AnyImage.blend`。该文件是 Git 忽略的发布构建产物;CI 与本地发布都从节点源码重新生成。
节点组规则:
@@ -38,19 +38,13 @@
## 构建资产脚本
`tools/nodes/build.py` 按依赖顺序构建资产,调用 `arrangement/` 统一排列几何节点组后保存。`common/` 提供共用构建函数:`nodes.py` 创建接口与节点,`depth_surface.py`、`cutout.py` 等组装几何,`shader.py` 构建材质着色节点,`material_slots.py` 继承几何材质槽;`tools/nodes/preview/` 导出交互预览。
`tools/nodes/build.py` 按依赖顺序加载 `nodes/groups/` 的定义,调用 `arrangement/` 统一排列几何节点组后保存。`nodes/common/` 提供共用构建函数:`nodes.py` 创建接口与节点,`depth_surface.py`、`cutout.py` 等组装几何,`shader.py` 构建材质着色节点,`material_slots.py` 继承几何材质槽;`tools/nodes/preview/` 导出交互预览。
保存后,`check.py` 在独立 Blender 进程中重新加载资产并做基本几何求值。接口、默认值和详细几何行为由 `tests/` 检查。
命令通过当前 uv Python 的独立子进程执行构建、检查和预览,`bpy` 由 `blender` dependency group 提供。保存后,`check.py` 重新加载资产并做基本几何求值;`tests/nodes/` 覆盖节点定义行为,`tests/tools/nodes/` 覆盖工具行为。CI 使用同一入口先生成资产,再运行常规测试和打包。
```bash
uv run node-group build --blender "/path/to/blender"
uv run node-group build --skip-tests
uv run node-group check
uv run node-group preview
```
脚本按 `--blender`、环境变量 `BLENDER_BIN`、项目 `.env`、`PATH` 的顺序查找 Blender。无法自动找到时,在项目根目录的 `.env` 中配置:
```dotenv
BLENDER_BIN="/absolute/path/to/blender"
uv run --group blender node-group build
uv run --group blender node-group build --skip-tests
uv run --group blender node-group check
uv run --group blender node-group preview
```

1
nodes/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Node group definitions used to build the bundled asset."""

1
nodes/common/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Shared node graph builders."""

1
nodes/groups/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Named node group definitions."""

View File

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

View File

@@ -0,0 +1,112 @@
## Context
节点组的可维护源码位于 `tools/nodes/groups` 和 `tools/nodes/common`,构建入口、布局、校验与预览也位于同一包中。生成后的 `src/anyimage/assets/O_AnyImage.blend` 被扩展运行时读取并进入发布包,但它同时作为 Git LFS 文件提交,历史中已累积多个 1–10 MB 的版本。
项目通过 `blender` dependency group 安装 `bpy==4.5.3`,现有测试也使用该 wheel 创建节点、求值几何及写入 `.blend`。因此本地和 CI 都不需要额外安装或定位完整 Blender 应用程序。
## Goals / Non-Goals
**Goals:**
- 根目录 `nodes/` 只表达节点组及共用节点构建定义。
- `tools/nodes/` 只表达构建、布局、校验和预览工具。
- 干净 CI 从源码生成、验证并打包节点资产。
- 本地与 CI 使用同一个 `node-group` 执行路径,不需要 `.env` 或 `BLENDER_BIN`。
- Git 当前树不再包含生成的 `.blend` 或对应 LFS pointer。
- 节点组的接口、默认值、求值和最终发布包保持不变。
**Non-Goals:**
- 不在本变更中重写 Git 历史或清理远程 LFS 对象。
- 不改变节点组业务名称和实现逻辑。
- 不把节点资产改为扩展运行时动态创建。
- 不提供选择其他 Blender 可执行程序或版本的兼容路径。
## Decisions
### 节点定义成为独立顶层包
建立 `nodes/` Python package:
```text
nodes/
├── common/
└── groups/
tools/nodes/
├── arrangement/
├── preview/
├── build.py
├── check.py
└── cli.py
```
`nodes/groups` 保存各命名节点组定义,`nodes/common` 保存这些定义共享的节点图构建逻辑。布局属于产物呈现和构建工具,继续位于 `tools/nodes/arrangement`。旧 `tools.nodes.groups` 与 `tools.nodes.common` 路径直接删除,不提供转发模块。
Hatch 的工具 wheel同时包含 `tools` 和 `nodes`,确保安装后的 `node-group` 入口能导入节点定义。相应测试按同一职责拆分:节点行为测试进入 `tests/nodes`,工具入口、布局、保存资产和校验测试保留在 `tests/tools/nodes`。
### `.blend` 是构建产物而不是源码
`src/anyimage/assets/O_AnyImage.blend` 加入 `.gitignore` 并从 Git 索引移除。源码检出不承诺该文件存在;开发者使用 `node-group build` 生成它。扩展运行时仍只通过 `load_node_group` 加载生成资产,不在用户运行时创建节点组。
保留 `.gitattributes` 的通用 Blender LFS 规则,因为它可能服务其他手工资产;忽略规则确保本生成物不再加入 Git。删除当前文件只影响未来提交,历史容量将在后续仓库历史整理时处理。
### node-group 统一使用 bpy wheel
`node-group build`、`check` 和 `preview` 都在 `uv run --group blender` 提供的 Python 环境中工作。CLI 不再解析 `--blender`,也不再读取 `BLENDER_BIN`、项目 `.env` 或系统 `PATH`。
为保持构建与保存后检查的进程隔离,CLI 使用 `sys.executable -m` 顺序启动模块:
```text
node-group build
├── python -m tools.nodes.build
├── python -m tools.nodes.check
└── python -m pytest tests/nodes tests/tools/nodes
```
`check` 只运行后两步,`preview` 使用同一 Python 运行预览导出模块。节点命令只运行与节点定义和工具直接相关的测试;项目常规与 AI 测试由各自命令负责。缺少 `bpy` 时命令明确提示使用 `uv run --group blender node-group ...`,不回退到外部 Blender。
相比保留完整 Blender 启动器,这一方案删除了路径发现、`.env` 解析、wheel 解压和 `--python-expr` bootstrap,且让本地与 CI 覆盖同一入口。
### CI 通过 node-group 构建
测试工作流在 pytest 前执行:
```bash
uv run --group blender node-group build --skip-tests
```
该命令使用 CI 已安装的 `bpy==4.5.3`,顺序生成并检查资产。随后运行常规测试目录,节点资产测试读取刚生成的文件。
发布工作流采用同样顺序,在测试成功后执行 `uv run pack`。生成文件留在 job 工作区,由打包器收入扩展压缩包,不作为工作流 artifact 单独传递。
备选方案是在本地或 CI 查找完整 Blender。它下载更大、存在第二套 Python 和依赖 bootstrap,且现有 bpy wheel 已覆盖所需 API,因此删除而不是保留兼容路径。
### 缺失资产时打包明确失败
打包器在创建任何平台包前检查生成资产存在且为普通文件;缺失时给出先构建节点资产的错误。打包测试既验证缺失失败,也验证每个平台归档包含 `assets/O_AnyImage.blend`。
这避免生成步骤遗漏后发布一个运行时才报错的不完整扩展。
## Risks / Trade-offs
- [bpy wheel 的资产兼容性发生变化] → 固定 `bpy==4.5.3`,构建后在独立 Python 进程运行资产检查和现有求值测试。
- [开发者从源码直接启用扩展时缺少资产] → `node-group build` 成为源码使用和打包前置步骤,缺失资产由工具给出明确错误。
- [测试路径重组造成覆盖遗漏] → 沿真实导入路径迁移测试并运行节点构建、常规测试和全量测试。
- [生成文件被误提交] → `.gitignore` 固定忽略具体资产路径,并增加版本控制边界测试。
- [从 Git 删除文件不会立即释放现有远程 LFS 容量] → 本变更只阻止继续增长;后续创建精简根历史时排除旧 LFS 对象。
## Migration Plan
1. 移动节点定义及对应测试,更新全部导入与 wheel 包清单。
2. 将 `node-group` 改为通过当前 Python 的独立子进程运行构建、检查和预览,删除完整 Blender 定位与 bootstrap。
3. 在保留现有资产的情况下构建并验证,确认重组没有改变节点行为。
4. 为测试和发布工作流加入统一的 `node-group` 生成步骤,补充缺失资产打包测试。
5. 将生成资产加入 `.gitignore` 并从 Git 索引移除。
6. 从无节点资产的干净状态执行 CI 等价流程,确认最终发布包包含重新生成的资产。
回滚时恢复节点定义路径、工作流和已提交资产即可;Git 历史清理不属于本变更,因此回滚不需要恢复远程历史。
## Open Questions
无。

View File

@@ -0,0 +1,35 @@
## Why
`O_AnyImage.blend` 可以由 Python 节点定义完整重建,却长期作为 Git LFS 资产提交,每次更新都会累积新的二进制历史。与此同时,节点组业务定义与构建、校验、预览工具都位于 `tools/nodes`,源码边界没有体现“节点组”和“节点构建工具”的区别。
## What Changes
- 将 `tools/nodes/groups` 和 `tools/nodes/common` 移至根目录 `nodes/groups` 和 `nodes/common`,作为节点组及其共用构建定义。
- `tools/nodes` 仅保留构建入口、资产校验、布局和预览等开发工具。
- 更新源码、测试、打包配置和工具入口中的导入路径,不保留旧路径兼容层。
- `node-group` 统一使用 `blender` dependency group 提供的 `bpy` wheel,不再查找或启动本地 Blender 应用程序。
- 删除 `--blender` 参数、`BLENDER_BIN` 环境变量、项目 `.env` 读取及其相关测试和说明。
- **BREAKING**:`src/anyimage/assets/O_AnyImage.blend` 不再纳入 Git/LFS;干净源码检出必须先构建节点资产,才能直接加载扩展或打包。
- 测试 CI 和发布 CI 使用 `bpy` wheel 在运行测试或打包前生成并校验节点资产。
- 打包流程继续要求并包含生成后的 `O_AnyImage.blend`,缺少资产时明确失败。
- 更新节点资产内部文档和项目规范,使源码、构建产物及 CI 职责一致。
## Capabilities
### New Capabilities
- `generated-node-assets`: 定义节点资产从源码生成、在 CI 中验证并进入发布包的完整契约。
### Modified Capabilities
- `maintainable-project-structure`: 将节点组业务定义与节点资产开发工具拆分为 `nodes/` 和 `tools/nodes/` 两个职责边界。
- `developer-tool-cli`: 节点工具改为通过当前 uv Python 的 `bpy` wheel 构建、校验和预览,不再依赖外部 Blender 可执行程序。
## Impact
- 移动 `tools/nodes/groups`、`tools/nodes/common` 及其全部导入引用,并将根目录 `nodes` 纳入开发工具 wheel。
- 修改 `.github/workflows/test.yml`、`.github/workflows/release.yml`、节点工具测试和打包测试。
- 简化 `tools/nodes/cli.py`,删除 Blender 路径发现、`.env` 配置和外部 Blender bootstrap。
- 从版本控制移除 `src/anyimage/assets/O_AnyImage.blend`,更新 `.gitignore` 和 LFS 跟踪状态。
- 修改 `AGENTS.md` 与 `docs/internals/node-assets.md` 中关于节点资产提交和构建的规则。
- 不改变节点组名称、接口、求值行为或最终扩展包内容。

View File

@@ -0,0 +1,41 @@
## MODIFIED Requirements
### Requirement: Build and check node groups
`node-group build` SHALL 使用当前 Python 环境中的 `bpy`,通过独立 Python 子进程依次构建资产、校验保存的资产、运行 `tests/nodes` 与 `tests/tools/nodes`;`node-group check` SHALL 仅执行后两步。两者 SHALL 支持 `--skip-tests` 仅跳过节点相关 pytest,且 SHALL 不查找或启动外部 Blender 可执行程序。
#### Scenario: Build assets
- **WHEN** 用户运行 `uv run --group blender node-group build`
- **THEN** 使用固定 bpy 环境更新 O_AnyImage.blend,并在独立进程的资产校验和节点相关测试成功后成功退出
#### Scenario: Check existing assets
- **WHEN** 用户运行 `uv run --group blender node-group check`
- **THEN** 使用固定 bpy 环境检查已有资产并运行节点相关测试,资产文件内容保持原样
#### Scenario: Skip Python tests
- **WHEN** build 或 check 指定 `--skip-tests`
- **THEN** 仍执行独立进程的资产校验,跳过 pytest
#### Scenario: Stop after failure
- **WHEN** 构建、校验或测试任一步失败
- **THEN** 命令非零退出且不执行后续步骤
#### Scenario: Missing bpy environment
- **WHEN** 当前 Python 环境无法导入 bpy
- **THEN** 命令非零退出并提示使用 `--group blender`,不尝试查找外部 Blender
### Requirement: Preview node groups
`node-group preview` SHALL 使用当前 Python 环境中的 `bpy`,从当前源码在独立 Python 子进程中构建并排列几何节点组,生成 HTML,输出绝对路径并默认打开浏览器,保持资产文件内容原样。默认文件 SHALL 保存于系统临时目录并在命令退出后可供查看。命令 SHALL 支持 `--output PATH`、`--no-open` 和 `--fragment`,不支持选择外部 Blender 可执行程序。
#### Scenario: Preview current source
- **WHEN** 用户运行 `uv run --group blender node-group preview`
- **THEN** 生成当前源码的可查看 HTML 并尝试打开浏览器
#### Scenario: Save without opening
- **WHEN** 用户指定 `--output PATH --no-open`
- **THEN** HTML 写入指定路径且不启动浏览器
#### Scenario: Export fragment
- **WHEN** 用户指定 `--fragment`
- **THEN** 输出 HTML 片段及其路径,不自动打开浏览器

View File

@@ -0,0 +1,37 @@
## ADDED Requirements
### Requirement: Node asset is generated from source
系统 SHALL 能够仅根据版本控制中的 `nodes/` 定义和 `tools/nodes` 构建工具生成 `src/anyimage/assets/O_AnyImage.blend`。生成文件 SHALL 不纳入 Git 版本控制。
#### Scenario: Build from a clean checkout
- **WHEN** 干净源码检出中不存在 `O_AnyImage.blend` 并执行节点资产构建
- **THEN** 构建成功生成包含全部既定节点组的资产文件
#### Scenario: Rebuild an existing asset
- **WHEN** 输出路径已有本地生成的节点资产并再次执行构建
- **THEN** 文件被当前源码生成的完整资产替换,不产生 `.blend1` 备份
### Requirement: CI validates generated node assets
测试 CI SHALL 通过统一的 `node-group` 入口和固定版本的 `bpy` wheel,在运行节点资产相关测试前生成并独立校验节点资产,不得要求额外的 Blender 应用程序安装。
#### Scenario: Validate a pull request
- **WHEN** 测试工作流检出不含节点资产的源码
- **THEN** 工作流生成资产、校验保存结果并运行读取该资产的测试
#### Scenario: Reject an invalid generated asset
- **WHEN** 节点源码生成的资产未通过接口、结构或基本求值检查
- **THEN** CI 在运行后续发布步骤前失败
### Requirement: Release packages contain the generated asset
发布 CI SHALL 在测试和打包前生成并验证节点资产。每个平台扩展归档 MUST 包含 `assets/O_AnyImage.blend`,打包器 MUST 在资产缺失时明确失败。
#### Scenario: Package a release
- **WHEN** 发布工作流从干净源码构建扩展
- **THEN** 每个平台归档包含本次工作流生成并验证的节点资产
#### Scenario: Package without building assets
- **WHEN** 打包器运行时 `O_AnyImage.blend` 不存在
- **THEN** 打包立即失败并提示先构建节点资产

View File

@@ -0,0 +1,21 @@
## MODIFIED Requirements
### Requirement: Node asset modules reflect responsibilities
根目录 `nodes/` 的模块 MUST 只包含节点组及其共用构建定义,`tools/nodes/` MUST 只包含构建入口、布局、资产校验和预览工具。所有移动 MUST 同步导入、wheel 包清单、构建与验证入口及测试路径,并删除旧路径和转发层。
#### Scenario: Load reorganized node definitions
- **WHEN** 构建工具和节点行为测试导入节点组定义
- **THEN** 导入指向 `nodes.groups` 或 `nodes.common`,不存在 `tools.nodes.groups` 或 `tools.nodes.common` 兼容路径
#### Scenario: Load node asset tools
- **WHEN** 启动器加载节点构建、布局、验证或预览模块
- **THEN** 工具从 `tools.nodes` 加载,并从顶层 `nodes` 包使用节点定义
#### Scenario: Preserve node behavior
- **WHEN** 对重组后的节点构建函数运行针对性测试
- **THEN** 节点组名称、接口、默认值、运算、连接、布局和求值与重组前一致
#### Scenario: Locate node tests
- **WHEN** 维护者按测试路径查找节点相关覆盖
- **THEN** 节点定义行为测试位于 `tests/nodes`,构建与资产工具测试位于 `tests/tools/nodes`

View File

@@ -0,0 +1,36 @@
## 1. 拆分节点源码与工具
- [x] 1.1 创建顶层 `nodes` package,将 `tools/nodes/groups` 和 `tools/nodes/common` 移入并删除旧路径
- [x] 1.2 更新构建、布局、预览、测试和辅助代码的导入,使节点定义只从 `nodes.groups` 与 `nodes.common` 加载
- [x] 1.3 将 `nodes` 加入 Hatch wheel 包清单,验证安装后的 `node-group` 入口可加载节点定义
- [x] 1.4 将节点定义行为测试移动到 `tests/nodes`,仅将构建、布局、校验和预览工具测试保留在 `tests/tools/nodes`
## 2. 将节点资产改为构建产物
- [x] 2.1 将 `src/anyimage/assets/O_AnyImage.blend` 加入 `.gitignore` 并从 Git 索引移除,不改变扩展运行时资产路径
- [x] 2.2 为打包器增加节点资产存在性检查,并覆盖缺失失败与归档包含资产的行为
- [x] 2.3 增加版本控制边界测试,确认生成的节点资产不会再次进入 Git
## 3. 统一节点工具运行环境
- [x] 3.1 删除 `--blender`、`BLENDER_BIN`、`.env` 读取、Blender 路径发现及外部进程 bootstrap
- [x] 3.2 让 build、check 和 preview 通过当前 Python 的独立子进程运行,让 build/check 只运行节点相关测试,并在缺少 bpy 时给出 `--group blender` 提示
- [x] 3.3 更新节点 CLI 测试,覆盖命令顺序、参数传递、失败停止和不再支持外部 Blender 配置
## 4. 接入 CI 构建
- [x] 4.1 修改测试工作流,通过统一的 `node-group` 入口从干净源码生成并校验节点资产后再运行常规测试
- [x] 4.2 修改发布工作流,在测试和打包前生成并校验节点资产,确保生成文件进入全部平台归档
- [x] 4.3 更新工作流测试,验证 CI 不要求 `BLENDER_BIN` 或额外下载完整 Blender
## 5. 同步规范与文档
- [x] 5.1 更新 `AGENTS.md`,将节点资产定义为必须由源码构建、不得提交的发布构建产物,并删除外部 Blender 配置规则
- [x] 5.2 更新 `docs/internals/node-assets.md` 及相关内部引用,说明 `nodes/`、`tools/nodes/`、bpy 环境和 CI 生成链
## 6. 验证
- [x] 6.1 从不存在 `O_AnyImage.blend` 的状态运行统一的节点资产构建与独立检查
- [x] 6.2 运行节点定义、节点工具、常规及全量测试,确认重组前后节点行为一致
- [x] 6.3 运行打包相关测试并检查生成归档,确认包含节点资产且源码树未重新跟踪该文件
- [x] 6.4 搜索旧导入、外部 Blender 配置和路径引用,检查最终差异并严格验证 OpenSpec 变更

View File

@@ -46,7 +46,7 @@ requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["tools"]
packages = ["src/anyimage", "tools", "nodes"]
[tool.pytest.ini_options]
# Keep native asserts: bpy 4.5 can crash at shutdown after assertion rewriting.

Binary file not shown.

View File

@@ -11,9 +11,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 tests.support.image_objects 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
from nodes.groups.image_plane import build_image_plane_group
from nodes.groups.image_depth_plane import build_image_depth_plane_group
from nodes.groups.image_relief_plane import build_image_relief_plane_group
def test_build_plane_keeps_one_quad_and_generates_topology_in_geometry_nodes():

1
tests/nodes/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Node group behavior tests."""

View File

@@ -5,7 +5,7 @@ import numpy as np
import pytest
from tests.support.depth_surface import evaluated
from tests.tools.nodes.test_boundary_smoothing import boundary_neighbors, edge_band
from tests.nodes.test_boundary_smoothing import boundary_neighbors, edge_band
@@ -76,7 +76,7 @@ def test_plane_validity_hole_blurs_depth_and_protects_rectangle():
@pytest.mark.parametrize("region", ["seam", "pole"])
def test_panorama_blurs_opening_radially(region):
from tests.tools.nodes.test_image_depth_panorama import panorama, evaluated as panorama_mesh
from tests.nodes.test_image_depth_panorama import panorama, evaluated as panorama_mesh
yy, xx = np.mgrid[:64, :128]
distance = np.minimum(xx, 128 - xx)

View File

@@ -108,7 +108,7 @@ def test_smoothing_without_split_moves_outline_only_within_boundary_band():
def test_panorama_smoothing_preserves_interior_and_uvs():
from tests.tools.nodes.test_image_depth_panorama import panorama, evaluated as panorama_mesh
from tests.nodes.test_image_depth_panorama import panorama, evaluated as panorama_mesh
yy, xx = np.mgrid[:64, :128]
depth = np.where(xx > 35 + yy * 0.6, 8.0, 2.0)
@@ -192,7 +192,7 @@ def test_validity_cut_smoothing_preserves_original_outline_and_interior():
def test_panorama_validity_cut_smoothing_stays_in_two_ring_band():
from tests.tools.nodes.test_image_depth_panorama import panorama, evaluated as panorama_mesh
from tests.nodes.test_image_depth_panorama import panorama, evaluated as panorama_mesh
yy, xx = np.mgrid[:64, :128]
alpha = ((xx-64)**2 + (yy-32)**2 > 15**2).astype(np.float32)

View File

@@ -5,7 +5,7 @@ import numpy as np
import pytest
from tests.support.depth_surface import surface, evaluated
from tests.tools.nodes.test_boundary_smoothing import diagonal_surface
from tests.nodes.test_boundary_smoothing import diagonal_surface
def profile_values(obj):

View File

@@ -9,9 +9,9 @@ from scipy.spatial import cKDTree
from anyimage.common.object import modifier_input_identifier, set_modifier_input
from tests.support.depth_surface import assert_closed, evaluated
from tools.nodes.common.normal_map import AXIS_ATTRIBUTE, FACE_ATTRIBUTE, ROTATION_ATTRIBUTE
from tools.nodes.groups.image_depth_cutout import build_image_depth_cutout_group
from tools.nodes.groups.image_cutout_symmetry import build_image_cutout_symmetry_group
from nodes.common.normal_map import AXIS_ATTRIBUTE, FACE_ATTRIBUTE, ROTATION_ATTRIBUTE
from nodes.groups.image_depth_cutout import build_image_depth_cutout_group
from nodes.groups.image_cutout_symmetry import build_image_cutout_symmetry_group
def _set_value(obj, modifier, group):

View File

@@ -4,7 +4,7 @@ import bpy
import numpy as np
import pytest
from tools.nodes.groups.image_cutout import build_image_cutout_group
from nodes.groups.image_cutout import build_image_cutout_group
from tests.support.nodes import CUTOUT_BUILDERS

View File

@@ -60,4 +60,3 @@ def test_zero_split_bypasses_cleanup_and_positive_split_can_empty_mesh():
restored, restored_faces = evaluated(obj)
assert restored_faces == faces
np.testing.assert_array_equal(restored, original)

View File

@@ -7,7 +7,7 @@ import numpy as np
import pytest
from anyimage.common.object import modifier_input_identifier, set_modifier_input
from tools.nodes.groups.image_depth_panorama import build_image_depth_panorama_group
from nodes.groups.image_depth_panorama import build_image_depth_panorama_group
def panorama(depth=3, alpha=1, subdivide=3):
@@ -333,7 +333,7 @@ def test_split_corners_keep_their_rays_and_own_face_distance():
def test_zero_split_bypasses_single_snapshot_strip_cleanup(monkeypatch):
from tools.nodes.common import depth_surface
from nodes.common import depth_surface
depth = np.broadcast_to(1 + 6 * (np.arange(128) % 16 < 8), (64, 128))
with monkeypatch.context() as patch:

View File

@@ -3,7 +3,7 @@ import pytest
from tests.support.materials import image_layer
from tools.nodes.groups.image_depth_layer import build_image_depth_layer_group
from nodes.groups.image_depth_layer import build_image_depth_layer_group
def test_alpha_curve_preserves_endpoints_and_suppresses_soft_edges(image_layer):

View File

@@ -18,7 +18,7 @@ def evaluated_mesh(obj):
def test_material_inheritance_removes_only_source_vertices():
from tools.nodes.common.material_slots import inherit_material_slots
from nodes.common.material_slots import inherit_material_slots
bpy.ops.wm.read_factory_settings(use_empty=True)
source_count = 4

View File

@@ -6,7 +6,7 @@ from mathutils import Euler, Vector, Matrix
from tests.support.materials import image_layer
from anyimage.common.object import blender_frame_rotation
from tools.nodes.common.normal_map import ROTATION_ATTRIBUTE, FACE_ATTRIBUTE, AXIS_ATTRIBUTE
from nodes.common.normal_map import ROTATION_ATTRIBUTE, FACE_ATTRIBUTE, AXIS_ATTRIBUTE
SYMMETRY_Y_TO_X = Matrix.Rotation(1.5707963267948966, 4, "Z").to_3x3()

View File

@@ -5,7 +5,7 @@ import pytest
from tests.support.depth_surface import surface
from tests.support.planes import create_surface
from tests.tools.nodes.test_image_depth_panorama import panorama
from tests.nodes.test_image_depth_panorama import panorama
@pytest.mark.parametrize("kind", ["cutout", "plane", "panorama"])

View File

@@ -1,6 +1,6 @@
import bpy
import pytest
from tools.nodes.groups.image_layer import build_image_layer_group
from nodes.groups.image_layer import build_image_layer_group
@pytest.fixture

View File

@@ -1,7 +1,7 @@
"""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_cutout_symmetry import build_image_cutout_symmetry_group
from nodes.groups.image_cutout import build_image_cutout_group
from nodes.groups.image_depth_cutout import build_image_depth_cutout_group
from nodes.groups.image_cutout_symmetry import build_image_cutout_symmetry_group
CUTOUT_BUILDERS = {
"FLAT": build_image_cutout_group,

View File

@@ -4,9 +4,9 @@ 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 nodes.groups.image_plane import build_image_plane_group
from nodes.groups.image_depth_plane import build_image_depth_plane_group
from nodes.groups.image_relief_plane import build_image_relief_plane_group
@pytest.fixture

View File

@@ -8,10 +8,10 @@ import pytest
from tools.nodes.arrangement.arrange import arrange_nodes
from tools.nodes.arrangement.dimensions import estimate_node_dimensions
from tools.nodes.check import ASSET_PATH, SHADER_NAMES
from tools.nodes.groups.image_depth_cutout import build_image_depth_cutout_group
from tools.nodes.groups.image_depth_layer import build_image_depth_layer_group
from tools.nodes.groups.image_layer import build_image_layer_group
from tools.nodes.groups.shadeless import build_shadeless_group
from nodes.groups.image_depth_cutout import build_image_depth_cutout_group
from nodes.groups.image_depth_layer import build_image_depth_layer_group
from nodes.groups.image_layer import build_image_layer_group
from nodes.groups.shadeless import build_shadeless_group
BUILDERS = (

View File

@@ -1,5 +1,6 @@
from tests.support.paths import PROJECT_ROOT
import importlib.util
import subprocess
from unittest.mock import patch
import pytest
@@ -17,84 +18,75 @@ def load_launcher():
return module
def test_blender_path_prefers_explicit_path(tmp_path):
def test_commands_require_the_blender_group(monkeypatch):
launcher = load_launcher()
executable = tmp_path / "blender"
executable.touch()
with patch.dict("os.environ", {"BLENDER_BIN": "ignored"}):
assert launcher.blender_path(executable) == executable
monkeypatch.setattr("sys.argv", ["node-group", "check"])
with patch.object(launcher.importlib.util, "find_spec", return_value=None):
with pytest.raises(RuntimeError, match="--group blender"):
launcher.main()
def test_blender_path_reads_environment(tmp_path):
launcher = load_launcher()
executable = tmp_path / "blender"
executable.touch()
with patch.dict("os.environ", {"BLENDER_BIN": str(executable)}):
assert launcher.blender_path() == executable
def test_blender_path_reads_project_dotenv(tmp_path):
launcher = load_launcher()
executable = tmp_path / "Blender 4.5" / "blender.exe"
executable.parent.mkdir()
executable.touch()
env_path = tmp_path / ".env"
env_path.write_text(
f'BLENDER_BIN="{executable}"\n',
encoding="utf-8",
)
with patch.dict("os.environ", {}, clear=True), patch.object(
launcher,
"PROJECT_ROOT",
tmp_path,
):
assert launcher.blender_path() == executable
def test_blender_path_reports_missing_configuration(tmp_path):
launcher = load_launcher()
with patch.dict("os.environ", {}, clear=True), patch.object(
launcher.shutil,
"which",
return_value=None,
), patch.object(
launcher,
"PROJECT_ROOT",
tmp_path,
):
with pytest.raises(RuntimeError, match="Blender not found"):
launcher.blender_path()
@pytest.mark.parametrize("arguments,modules,run_tests", [
(["build"], ("tools.nodes.build", "tools.nodes.check"), True),
(["check", "--skip-tests"], ("tools.nodes.check",), False),
(["preview", "--fragment"], ("tools.nodes.preview.export",), False),
@pytest.mark.parametrize("arguments,commands", [
(["build"], [
("tools.nodes.build", ()),
("tools.nodes.check", ()),
("pytest", ("tests/nodes", "tests/tools/nodes")),
]),
(["check", "--skip-tests"], [("tools.nodes.check", ())]),
(["preview", "--fragment"], [
("tools.nodes.preview.export", "preview"),
]),
])
def test_commands_dispatch_requested_work(monkeypatch, tmp_path, arguments, modules, run_tests):
def test_commands_dispatch_requested_work(monkeypatch, tmp_path, arguments, commands):
launcher = load_launcher()
output = tmp_path / "node groups.html"
command = ["node-group", *arguments]
if arguments[0] == "preview":
command += ["--output", str(output)]
monkeypatch.setattr("sys.argv", command)
monkeypatch.setattr(launcher, "blender_path", lambda _: "blender")
with (
patch.object(launcher, "run_blender_modules") as blender,
patch.object(launcher, "require_bpy"),
patch.object(launcher, "run_module") as run_module,
patch.object(launcher, "run") as run,
patch.object(launcher.webbrowser, "open") as open_browser,
):
launcher.main()
if arguments[0] == "preview":
blender.assert_called_once_with("blender", modules, ["--output", str(output), "--fragment"])
run_module.assert_called_once_with(
"tools.nodes.preview.export",
["--output", str(output), "--fragment"],
)
open_browser.assert_not_called()
else:
blender.assert_called_once_with("blender", modules)
if run_tests:
run.assert_called_once_with((launcher.sys.executable, "-m", "pytest"))
expected_modules = [item[0] for item in commands if item[0] != "pytest"]
assert [call.args[0] for call in run_module.call_args_list] == expected_modules
if any(item[0] == "pytest" for item in commands):
run.assert_called_once_with(
(
launcher.sys.executable,
"-m",
"pytest",
"tests/nodes",
"tests/tools/nodes",
)
)
else:
run.assert_not_called()
def test_module_failure_stops_later_steps(monkeypatch):
launcher = load_launcher()
monkeypatch.setattr("sys.argv", ["node-group", "build"])
with (
patch.object(launcher, "require_bpy"),
patch.object(
launcher,
"run_module",
side_effect=subprocess.CalledProcessError(1, "build"),
) as run_module,
patch.object(launcher, "run") as run,
):
with pytest.raises(subprocess.CalledProcessError):
launcher.main()
run_module.assert_called_once_with("tools.nodes.build")
run.assert_not_called()

View File

@@ -1,3 +1,4 @@
import subprocess
import tomllib
from tests.support.paths import PROJECT_ROOT
@@ -43,14 +44,38 @@ def test_ai_and_model_groups_own_their_runtime_dependencies():
def test_workflows_run_the_regular_blender_suite():
command = "uv run --group blender pytest tests/anyimage tests/tools"
build_command = "uv run --group blender node-group build --skip-tests"
test_command = (
"uv run --group blender pytest tests/anyimage tests/nodes tests/tools"
)
for name in ("test.yml", "release.yml"):
workflow = (PROJECT_ROOT / ".github" / "workflows" / name).read_text(
encoding="utf-8"
)
assert command in workflow
assert build_command in workflow
assert test_command in workflow
assert workflow.index(build_command) < workflow.index(test_command)
assert "BLENDER_BIN" not in workflow
assert "download" not in workflow.lower()
release = (PROJECT_ROOT / ".github" / "workflows" / "release.yml").read_text(
encoding="utf-8"
)
assert "uv run pack" in release
def test_generated_node_asset_is_ignored_and_untracked():
asset = "src/anyimage/assets/O_AnyImage.blend"
ignored = subprocess.run(
["git", "check-ignore", "--quiet", asset],
cwd=PROJECT_ROOT,
check=False,
)
tracked = subprocess.run(
["git", "ls-files", "--error-unmatch", asset],
cwd=PROJECT_ROOT,
check=False,
capture_output=True,
)
assert ignored.returncode == 0
assert tracked.returncode != 0

View File

@@ -42,6 +42,16 @@ class ArchiveContents:
class ExtensionPackagingTest(unittest.TestCase):
def test_rejects_missing_generated_node_asset(self):
pack = load_pack()
with tempfile.TemporaryDirectory() as directory:
with patch.object(pack, "SOURCE_DIR", Path(directory)):
with self.assertRaisesRegex(
RuntimeError,
"uv run --group blender node-group build",
):
pack._build_extension("windows-x64", [])
def test_selects_platform_archive_contents_and_manifest(self):
pack = load_pack()
records = {}

View File

@@ -4,7 +4,7 @@ from .branches import calculation_blocks
from .dimensions import estimate_node_dimensions, visible_socket
from .flow import origin_socket, socket_links
from .layout import plan_layout
from ..common.nodes import prepare_node_group, replace_link_source
from nodes.common.nodes import prepare_node_group, replace_link_source
def _remove_routes(node_group):

View File

@@ -4,16 +4,16 @@ 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_cutout_symmetry import build_image_cutout_symmetry_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
from .groups.image_depth_panorama import build_image_depth_panorama_group
from nodes.groups.image_plane import build_image_plane_group
from nodes.groups.image_depth_plane import build_image_depth_plane_group
from nodes.groups.image_relief_plane import build_image_relief_plane_group
from nodes.groups.image_cutout import build_image_cutout_group
from nodes.groups.image_depth_cutout import build_image_depth_cutout_group
from nodes.groups.image_cutout_symmetry import build_image_cutout_symmetry_group
from nodes.groups.image_layer import build_image_layer_group
from nodes.groups.image_depth_layer import build_image_depth_layer_group
from nodes.groups.shadeless import build_shadeless_group
from nodes.groups.image_depth_panorama import build_image_depth_panorama_group
OUTPUT_PATH = Path(__file__).resolve().parents[2] / "src/anyimage/assets/O_AnyImage.blend"

View File

@@ -1,81 +1,29 @@
import argparse
import os
import shutil
import importlib.util
import subprocess
import sys
import webbrowser
from pathlib import Path
from tempfile import TemporaryDirectory, mkdtemp
from zipfile import ZipFile
from tempfile import mkdtemp
PROJECT_ROOT = Path(__file__).resolve().parents[2]
def project_env_value(name, path=None):
env_path = path or PROJECT_ROOT / ".env"
if not env_path.is_file():
return None
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if line.startswith("export "):
line = line.removeprefix("export ").lstrip()
key, separator, value = line.partition("=")
if separator and key.strip() == name:
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
value = value[1:-1]
return value or None
return None
def blender_path(explicit=None):
candidate = (
explicit
or os.environ.get("BLENDER_BIN")
or project_env_value("BLENDER_BIN")
or shutil.which("blender")
)
if not candidate:
raise RuntimeError(
"Blender not found: pass --blender, set BLENDER_BIN, add it to .env, "
"or add blender to PATH"
)
path = Path(candidate).expanduser()
if not path.is_file():
raise RuntimeError(f"Blender executable does not exist: {path}")
return path
def run(command):
print("+", subprocess.list2cmdline([str(part) for part in command]), flush=True)
subprocess.run(command, cwd=PROJECT_ROOT, check=True)
def run_blender_modules(executable, modules, arguments=()):
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)); "
def require_bpy():
if importlib.util.find_spec("bpy") is None:
raise RuntimeError(
"bpy is required; run with: uv run --group blender node-group ..."
)
for module_name in modules:
run(
(
executable,
"--factory-startup",
"--background",
"--python-exit-code", "1",
"--python-expr", bootstrap + f"sys.argv = {[module_name, *arguments]!r}; "
+ f"runpy.run_module({module_name!r}, run_name='__main__')",
)
)
def run_module(module_name, arguments=()):
run((sys.executable, "-m", module_name, *arguments))
def main():
@@ -87,7 +35,6 @@ def main():
("preview", "Preview node groups from current source"),
):
command = commands.add_parser(name, help=help_text)
command.add_argument("--blender", help="Blender executable; overrides BLENDER_BIN, .env, and PATH")
if name == "preview":
command.add_argument("--output", type=Path, help="Output HTML path; defaults to a temporary directory")
command.add_argument("--no-open", action="store_true", help="Save without opening a browser")
@@ -98,21 +45,22 @@ def main():
if options.command is None:
parser.print_help()
return
executable = blender_path(options.blender)
require_bpy()
if options.command == "preview":
output = (options.output or Path(mkdtemp(prefix="anyimage-node-preview-")) / "nodes.html").resolve()
arguments = ["--output", str(output)]
if options.fragment:
arguments.append("--fragment")
run_blender_modules(executable, ("tools.nodes.preview.export",), arguments)
run_module("tools.nodes.preview.export", arguments)
print(output)
if not options.no_open and not options.fragment:
webbrowser.open(output.as_uri())
return
modules = ("tools.nodes.build", "tools.nodes.check") if options.command == "build" else ("tools.nodes.check",)
run_blender_modules(executable, modules)
for module_name in modules:
run_module(module_name)
if not options.skip_tests:
run((sys.executable, "-m", "pytest"))
run((sys.executable, "-m", "pytest", "tests/nodes", "tests/tools/nodes"))
if __name__ == "__main__":

View File

@@ -1 +0,0 @@

View File

@@ -1 +0,0 @@

View File

@@ -35,6 +35,15 @@ PLATFORM_CONFIGS = {
PLATFORMS = tuple(PLATFORM_CONFIGS)
def require_node_asset():
"""Fail before packaging when the generated node asset is absent."""
asset_path = SOURCE_DIR / "assets" / "O_AnyImage.blend"
if not asset_path.is_file():
raise RuntimeError(
"Node asset is missing; run: uv run --group blender node-group build"
)
def project_dependencies():
"""Return Extension wheel requirements declared by the project."""
project = tomllib.loads(
@@ -171,6 +180,7 @@ def manifest_for_platform(platform, wheels):
def _build_extension(platform, wheels):
require_node_asset()
wheels = extension_wheels(wheels)
manifest_text = manifest_for_platform(platform, wheels)
manifest = tomllib.loads(manifest_text)