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:
@@ -1,3 +0,0 @@
|
||||
# 复制为 .env,并填写本机 Blender 可执行文件路径。
|
||||
# Windows 路径可使用正斜杠。
|
||||
BLENDER_BIN="C:/Program Files/Blender Foundation/Blender 4.5/blender.exe"
|
||||
5
.github/workflows/release.yml
vendored
5
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
5
.github/workflows/test.yml
vendored
5
.github/workflows/test.yml
vendored
@@ -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
3
.gitignore
vendored
@@ -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/
|
||||
|
||||
19
AGENTS.md
19
AGENTS.md
@@ -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)。
|
||||
|
||||
@@ -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 即可。
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
1
nodes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Node group definitions used to build the bundled asset."""
|
||||
1
nodes/common/__init__.py
Normal file
1
nodes/common/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Shared node graph builders."""
|
||||
1
nodes/groups/__init__.py
Normal file
1
nodes/groups/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Named node group definitions."""
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-19
|
||||
112
openspec/changes/separate-node-sources-and-tools/design.md
Normal file
112
openspec/changes/separate-node-sources-and-tools/design.md
Normal 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
|
||||
|
||||
无。
|
||||
35
openspec/changes/separate-node-sources-and-tools/proposal.md
Normal file
35
openspec/changes/separate-node-sources-and-tools/proposal.md
Normal 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` 中关于节点资产提交和构建的规则。
|
||||
- 不改变节点组名称、接口、求值行为或最终扩展包内容。
|
||||
@@ -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 片段及其路径,不自动打开浏览器
|
||||
@@ -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** 打包立即失败并提示先构建节点资产
|
||||
@@ -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`
|
||||
36
openspec/changes/separate-node-sources-and-tools/tasks.md
Normal file
36
openspec/changes/separate-node-sources-and-tools/tasks.md
Normal 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 变更
|
||||
@@ -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.
@@ -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
1
tests/nodes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Node group behavior tests."""
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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):
|
||||
@@ -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):
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
@@ -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):
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"])
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user