diff --git a/.env.example b/.env.example
deleted file mode 100644
index 44abbd0..0000000
--- a/.env.example
+++ /dev/null
@@ -1,3 +0,0 @@
-# 复制为 .env,并填写本机 Blender 可执行文件路径。
-# Windows 路径可使用正斜杠。
-BLENDER_BIN="C:/Program Files/Blender Foundation/Blender 4.5/blender.exe"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 42c4d96..bfb09c6 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -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
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index f45b061..3419da0 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -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
diff --git a/.gitignore b/.gitignore
index 7787736..654553f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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/
\ No newline at end of file
+models/
diff --git a/AGENTS.md b/AGENTS.md
index 69e0741..7bcc6bb 100644
--- a/AGENTS.md
+++ b/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)。
diff --git a/docs/index.md b/docs/index.md
index 59ad437..d730277 100644
--- a/docs/index.md
+++ b/docs/index.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 扩展仓库,确认后再拖入一次。
@@ -40,4 +50,11 @@ AnyImage 是一款 Blender 扩展,可以粘贴和编辑图片、创建普通 P
…或下载后通过 Install from Disk 安装
-
\ No newline at end of file
+
+
+
+复制一张图片。在 Blender 的 3D View 中按 **Ctrl+V**(macOS 为 **Cmd+V**)。
+
+选中图片并右键,打开 **AnyImage**,选择 **Convert to Plane**。
+
+使用 Depth Plane、Remove Background 或 Upscale 时,按提示安装 AI Environment 即可。
diff --git a/docs/internals/development.md b/docs/internals/development.md
index 74f17c0..2edd97c 100644
--- a/docs/internals/development.md
+++ b/docs/internals/development.md
@@ -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 |
diff --git a/docs/internals/node-assets.md b/docs/internals/node-assets.md
index 18b6dd5..825c738 100644
--- a/docs/internals/node-assets.md
+++ b/docs/internals/node-assets.md
@@ -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
```
diff --git a/nodes/__init__.py b/nodes/__init__.py
new file mode 100644
index 0000000..38de087
--- /dev/null
+++ b/nodes/__init__.py
@@ -0,0 +1 @@
+"""Node group definitions used to build the bundled asset."""
diff --git a/nodes/common/__init__.py b/nodes/common/__init__.py
new file mode 100644
index 0000000..4ffa065
--- /dev/null
+++ b/nodes/common/__init__.py
@@ -0,0 +1 @@
+"""Shared node graph builders."""
diff --git a/tools/nodes/common/boundary_smoothing.py b/nodes/common/boundary_smoothing.py
similarity index 100%
rename from tools/nodes/common/boundary_smoothing.py
rename to nodes/common/boundary_smoothing.py
diff --git a/tools/nodes/common/cutout.py b/nodes/common/cutout.py
similarity index 100%
rename from tools/nodes/common/cutout.py
rename to nodes/common/cutout.py
diff --git a/tools/nodes/common/cutout_boundary.py b/nodes/common/cutout_boundary.py
similarity index 100%
rename from tools/nodes/common/cutout_boundary.py
rename to nodes/common/cutout_boundary.py
diff --git a/tools/nodes/common/depth_surface.py b/nodes/common/depth_surface.py
similarity index 100%
rename from tools/nodes/common/depth_surface.py
rename to nodes/common/depth_surface.py
diff --git a/tools/nodes/common/direction.py b/nodes/common/direction.py
similarity index 100%
rename from tools/nodes/common/direction.py
rename to nodes/common/direction.py
diff --git a/tools/nodes/common/material_slots.py b/nodes/common/material_slots.py
similarity index 100%
rename from tools/nodes/common/material_slots.py
rename to nodes/common/material_slots.py
diff --git a/tools/nodes/common/nodes.py b/nodes/common/nodes.py
similarity index 100%
rename from tools/nodes/common/nodes.py
rename to nodes/common/nodes.py
diff --git a/tools/nodes/common/normal_map.py b/nodes/common/normal_map.py
similarity index 100%
rename from tools/nodes/common/normal_map.py
rename to nodes/common/normal_map.py
diff --git a/tools/nodes/common/reference_plane.py b/nodes/common/reference_plane.py
similarity index 100%
rename from tools/nodes/common/reference_plane.py
rename to nodes/common/reference_plane.py
diff --git a/tools/nodes/common/shader.py b/nodes/common/shader.py
similarity index 100%
rename from tools/nodes/common/shader.py
rename to nodes/common/shader.py
diff --git a/tools/nodes/common/smoothing.py b/nodes/common/smoothing.py
similarity index 100%
rename from tools/nodes/common/smoothing.py
rename to nodes/common/smoothing.py
diff --git a/tools/nodes/common/validity.py b/nodes/common/validity.py
similarity index 100%
rename from tools/nodes/common/validity.py
rename to nodes/common/validity.py
diff --git a/nodes/groups/__init__.py b/nodes/groups/__init__.py
new file mode 100644
index 0000000..5cdf000
--- /dev/null
+++ b/nodes/groups/__init__.py
@@ -0,0 +1 @@
+"""Named node group definitions."""
diff --git a/tools/nodes/groups/image_cutout.py b/nodes/groups/image_cutout.py
similarity index 100%
rename from tools/nodes/groups/image_cutout.py
rename to nodes/groups/image_cutout.py
diff --git a/tools/nodes/groups/image_cutout_symmetry.py b/nodes/groups/image_cutout_symmetry.py
similarity index 100%
rename from tools/nodes/groups/image_cutout_symmetry.py
rename to nodes/groups/image_cutout_symmetry.py
diff --git a/tools/nodes/groups/image_depth_cutout.py b/nodes/groups/image_depth_cutout.py
similarity index 100%
rename from tools/nodes/groups/image_depth_cutout.py
rename to nodes/groups/image_depth_cutout.py
diff --git a/tools/nodes/groups/image_depth_layer.py b/nodes/groups/image_depth_layer.py
similarity index 100%
rename from tools/nodes/groups/image_depth_layer.py
rename to nodes/groups/image_depth_layer.py
diff --git a/tools/nodes/groups/image_depth_panorama.py b/nodes/groups/image_depth_panorama.py
similarity index 100%
rename from tools/nodes/groups/image_depth_panorama.py
rename to nodes/groups/image_depth_panorama.py
diff --git a/tools/nodes/groups/image_depth_plane.py b/nodes/groups/image_depth_plane.py
similarity index 100%
rename from tools/nodes/groups/image_depth_plane.py
rename to nodes/groups/image_depth_plane.py
diff --git a/tools/nodes/groups/image_layer.py b/nodes/groups/image_layer.py
similarity index 100%
rename from tools/nodes/groups/image_layer.py
rename to nodes/groups/image_layer.py
diff --git a/tools/nodes/groups/image_plane.py b/nodes/groups/image_plane.py
similarity index 100%
rename from tools/nodes/groups/image_plane.py
rename to nodes/groups/image_plane.py
diff --git a/tools/nodes/groups/image_relief_plane.py b/nodes/groups/image_relief_plane.py
similarity index 100%
rename from tools/nodes/groups/image_relief_plane.py
rename to nodes/groups/image_relief_plane.py
diff --git a/tools/nodes/groups/shadeless.py b/nodes/groups/shadeless.py
similarity index 100%
rename from tools/nodes/groups/shadeless.py
rename to nodes/groups/shadeless.py
diff --git a/openspec/changes/separate-node-sources-and-tools/.openspec.yaml b/openspec/changes/separate-node-sources-and-tools/.openspec.yaml
new file mode 100644
index 0000000..eaa6b1c
--- /dev/null
+++ b/openspec/changes/separate-node-sources-and-tools/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-19
diff --git a/openspec/changes/separate-node-sources-and-tools/design.md b/openspec/changes/separate-node-sources-and-tools/design.md
new file mode 100644
index 0000000..8508659
--- /dev/null
+++ b/openspec/changes/separate-node-sources-and-tools/design.md
@@ -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
+
+无。
diff --git a/openspec/changes/separate-node-sources-and-tools/proposal.md b/openspec/changes/separate-node-sources-and-tools/proposal.md
new file mode 100644
index 0000000..3f1cb47
--- /dev/null
+++ b/openspec/changes/separate-node-sources-and-tools/proposal.md
@@ -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` 中关于节点资产提交和构建的规则。
+- 不改变节点组名称、接口、求值行为或最终扩展包内容。
diff --git a/openspec/changes/separate-node-sources-and-tools/specs/developer-tool-cli/spec.md b/openspec/changes/separate-node-sources-and-tools/specs/developer-tool-cli/spec.md
new file mode 100644
index 0000000..62b5488
--- /dev/null
+++ b/openspec/changes/separate-node-sources-and-tools/specs/developer-tool-cli/spec.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 片段及其路径,不自动打开浏览器
diff --git a/openspec/changes/separate-node-sources-and-tools/specs/generated-node-assets/spec.md b/openspec/changes/separate-node-sources-and-tools/specs/generated-node-assets/spec.md
new file mode 100644
index 0000000..0312958
--- /dev/null
+++ b/openspec/changes/separate-node-sources-and-tools/specs/generated-node-assets/spec.md
@@ -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** 打包立即失败并提示先构建节点资产
diff --git a/openspec/changes/separate-node-sources-and-tools/specs/maintainable-project-structure/spec.md b/openspec/changes/separate-node-sources-and-tools/specs/maintainable-project-structure/spec.md
new file mode 100644
index 0000000..6a43176
--- /dev/null
+++ b/openspec/changes/separate-node-sources-and-tools/specs/maintainable-project-structure/spec.md
@@ -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`
diff --git a/openspec/changes/separate-node-sources-and-tools/tasks.md b/openspec/changes/separate-node-sources-and-tools/tasks.md
new file mode 100644
index 0000000..2977d3b
--- /dev/null
+++ b/openspec/changes/separate-node-sources-and-tools/tasks.md
@@ -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 变更
diff --git a/pyproject.toml b/pyproject.toml
index f06171f..17e7832 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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.
diff --git a/src/anyimage/assets/O_AnyImage.blend b/src/anyimage/assets/O_AnyImage.blend
deleted file mode 100644
index 37d15ca..0000000
--- a/src/anyimage/assets/O_AnyImage.blend
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:c49b4737db174008d4fb470de4179e2e4ef0456f96f44406b5f331f5626249ba
-size 7934350
diff --git a/tests/anyimage/operators/test_plane_objects.py b/tests/anyimage/operators/test_plane_objects.py
index b334d41..192c9ef 100644
--- a/tests/anyimage/operators/test_plane_objects.py
+++ b/tests/anyimage/operators/test_plane_objects.py
@@ -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():
diff --git a/tests/nodes/__init__.py b/tests/nodes/__init__.py
new file mode 100644
index 0000000..37fd440
--- /dev/null
+++ b/tests/nodes/__init__.py
@@ -0,0 +1 @@
+"""Node group behavior tests."""
diff --git a/tests/tools/nodes/test_boundary_depth.py b/tests/nodes/test_boundary_depth.py
similarity index 96%
rename from tests/tools/nodes/test_boundary_depth.py
rename to tests/nodes/test_boundary_depth.py
index 3cd5a87..917cd99 100644
--- a/tests/tools/nodes/test_boundary_depth.py
+++ b/tests/nodes/test_boundary_depth.py
@@ -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)
diff --git a/tests/tools/nodes/test_boundary_smoothing.py b/tests/nodes/test_boundary_smoothing.py
similarity index 97%
rename from tests/tools/nodes/test_boundary_smoothing.py
rename to tests/nodes/test_boundary_smoothing.py
index ef9e7c5..c46ec03 100644
--- a/tests/tools/nodes/test_boundary_smoothing.py
+++ b/tests/nodes/test_boundary_smoothing.py
@@ -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)
diff --git a/tests/tools/nodes/test_cutout_boundary.py b/tests/nodes/test_cutout_boundary.py
similarity index 97%
rename from tests/tools/nodes/test_cutout_boundary.py
rename to tests/nodes/test_cutout_boundary.py
index da2a5b7..e107f61 100644
--- a/tests/tools/nodes/test_cutout_boundary.py
+++ b/tests/nodes/test_cutout_boundary.py
@@ -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):
diff --git a/tests/tools/nodes/test_cutout_symmetry.py b/tests/nodes/test_cutout_symmetry.py
similarity index 97%
rename from tests/tools/nodes/test_cutout_symmetry.py
rename to tests/nodes/test_cutout_symmetry.py
index 2c15e9d..526f138 100644
--- a/tests/tools/nodes/test_cutout_symmetry.py
+++ b/tests/nodes/test_cutout_symmetry.py
@@ -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):
diff --git a/tests/tools/nodes/test_cutouts.py b/tests/nodes/test_cutouts.py
similarity index 97%
rename from tests/tools/nodes/test_cutouts.py
rename to tests/nodes/test_cutouts.py
index 9208896..2ab1204 100644
--- a/tests/tools/nodes/test_cutouts.py
+++ b/tests/nodes/test_cutouts.py
@@ -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
diff --git a/tests/tools/nodes/test_depth_cutout.py b/tests/nodes/test_depth_cutout.py
similarity index 100%
rename from tests/tools/nodes/test_depth_cutout.py
rename to tests/nodes/test_depth_cutout.py
diff --git a/tests/tools/nodes/test_depth_relief_planes.py b/tests/nodes/test_depth_relief_planes.py
similarity index 100%
rename from tests/tools/nodes/test_depth_relief_planes.py
rename to tests/nodes/test_depth_relief_planes.py
diff --git a/tests/tools/nodes/test_depth_strip_cleanup.py b/tests/nodes/test_depth_strip_cleanup.py
similarity index 99%
rename from tests/tools/nodes/test_depth_strip_cleanup.py
rename to tests/nodes/test_depth_strip_cleanup.py
index 44875b0..7f60f26 100644
--- a/tests/tools/nodes/test_depth_strip_cleanup.py
+++ b/tests/nodes/test_depth_strip_cleanup.py
@@ -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)
-
diff --git a/tests/tools/nodes/test_depth_surface_split.py b/tests/nodes/test_depth_surface_split.py
similarity index 100%
rename from tests/tools/nodes/test_depth_surface_split.py
rename to tests/nodes/test_depth_surface_split.py
diff --git a/tests/tools/nodes/test_image_depth_panorama.py b/tests/nodes/test_image_depth_panorama.py
similarity index 99%
rename from tests/tools/nodes/test_image_depth_panorama.py
rename to tests/nodes/test_image_depth_panorama.py
index 55aeef9..7e6304d 100644
--- a/tests/tools/nodes/test_image_depth_panorama.py
+++ b/tests/nodes/test_image_depth_panorama.py
@@ -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:
diff --git a/tests/tools/nodes/test_image_layer.py b/tests/nodes/test_image_layer.py
similarity index 94%
rename from tests/tools/nodes/test_image_layer.py
rename to tests/nodes/test_image_layer.py
index 6aee085..7dc96f0 100644
--- a/tests/tools/nodes/test_image_layer.py
+++ b/tests/nodes/test_image_layer.py
@@ -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):
diff --git a/tests/tools/nodes/test_image_plane.py b/tests/nodes/test_image_plane.py
similarity index 98%
rename from tests/tools/nodes/test_image_plane.py
rename to tests/nodes/test_image_plane.py
index 4fab3e4..5532c1c 100644
--- a/tests/tools/nodes/test_image_plane.py
+++ b/tests/nodes/test_image_plane.py
@@ -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
diff --git a/tests/tools/nodes/test_normal_scale.py b/tests/nodes/test_normal_scale.py
similarity index 100%
rename from tests/tools/nodes/test_normal_scale.py
rename to tests/nodes/test_normal_scale.py
diff --git a/tests/tools/nodes/test_object_normal_rotation.py b/tests/nodes/test_object_normal_rotation.py
similarity index 97%
rename from tests/tools/nodes/test_object_normal_rotation.py
rename to tests/nodes/test_object_normal_rotation.py
index b71c0d0..d88fa24 100644
--- a/tests/tools/nodes/test_object_normal_rotation.py
+++ b/tests/nodes/test_object_normal_rotation.py
@@ -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()
diff --git a/tests/tools/nodes/test_shadeless.py b/tests/nodes/test_shadeless.py
similarity index 100%
rename from tests/tools/nodes/test_shadeless.py
rename to tests/nodes/test_shadeless.py
diff --git a/tests/tools/nodes/test_temporary_attributes.py b/tests/nodes/test_temporary_attributes.py
similarity index 95%
rename from tests/tools/nodes/test_temporary_attributes.py
rename to tests/nodes/test_temporary_attributes.py
index dcc74f3..02e3567 100644
--- a/tests/tools/nodes/test_temporary_attributes.py
+++ b/tests/nodes/test_temporary_attributes.py
@@ -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"])
diff --git a/tests/support/materials.py b/tests/support/materials.py
index cbb603e..1aec0c0 100644
--- a/tests/support/materials.py
+++ b/tests/support/materials.py
@@ -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
diff --git a/tests/support/nodes.py b/tests/support/nodes.py
index 4cd42fa..88b1055 100644
--- a/tests/support/nodes.py
+++ b/tests/support/nodes.py
@@ -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,
diff --git a/tests/support/planes.py b/tests/support/planes.py
index 113b01b..0f0050d 100644
--- a/tests/support/planes.py
+++ b/tests/support/planes.py
@@ -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
diff --git a/tests/tools/nodes/test_arrangement.py b/tests/tools/nodes/test_arrangement.py
index b360b85..2d3ceca 100644
--- a/tests/tools/nodes/test_arrangement.py
+++ b/tests/tools/nodes/test_arrangement.py
@@ -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 = (
diff --git a/tests/tools/nodes/test_cli.py b/tests/tools/nodes/test_cli.py
index 8c3736a..879191d 100644
--- a/tests/tools/nodes/test_cli.py
+++ b/tests/tools/nodes/test_cli.py
@@ -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()
diff --git a/tests/tools/test_environments.py b/tests/tools/test_environments.py
index f059262..e12e18e 100644
--- a/tests/tools/test_environments.py
+++ b/tests/tools/test_environments.py
@@ -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
diff --git a/tests/tools/test_pack.py b/tests/tools/test_pack.py
index 677f3f3..2e3b92d 100644
--- a/tests/tools/test_pack.py
+++ b/tests/tools/test_pack.py
@@ -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 = {}
diff --git a/tools/nodes/arrangement/arrange.py b/tools/nodes/arrangement/arrange.py
index 6cd3915..82e52a5 100644
--- a/tools/nodes/arrangement/arrange.py
+++ b/tools/nodes/arrangement/arrange.py
@@ -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):
diff --git a/tools/nodes/build.py b/tools/nodes/build.py
index 7e532bd..0fd06cf 100644
--- a/tools/nodes/build.py
+++ b/tools/nodes/build.py
@@ -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"
diff --git a/tools/nodes/cli.py b/tools/nodes/cli.py
index c742f54..601d6c6 100644
--- a/tools/nodes/cli.py
+++ b/tools/nodes/cli.py
@@ -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__":
diff --git a/tools/nodes/common/__init__.py b/tools/nodes/common/__init__.py
deleted file mode 100644
index 8b13789..0000000
--- a/tools/nodes/common/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/tools/nodes/groups/__init__.py b/tools/nodes/groups/__init__.py
deleted file mode 100644
index 8b13789..0000000
--- a/tools/nodes/groups/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/tools/pack.py b/tools/pack.py
index 5563ef0..dfd2da5 100644
--- a/tools/pack.py
+++ b/tools/pack.py
@@ -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)