From 9debab5c0ceb60a553f7912a7ce915712913619c Mon Sep 17 00:00:00 2001 From: nan Date: Sat, 19 Sep 2026 21:23:19 +0800 Subject: [PATCH] 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. --- .env.example | 3 - .github/workflows/release.yml | 5 +- .github/workflows/test.yml | 5 +- .gitignore | 3 +- AGENTS.md | 19 +-- docs/index.md | 37 ++++-- docs/internals/development.md | 2 +- docs/internals/node-assets.md | 22 ++-- nodes/__init__.py | 1 + nodes/common/__init__.py | 1 + .../common/boundary_smoothing.py | 0 {tools/nodes => nodes}/common/cutout.py | 0 .../nodes => nodes}/common/cutout_boundary.py | 0 .../nodes => nodes}/common/depth_surface.py | 0 {tools/nodes => nodes}/common/direction.py | 0 .../nodes => nodes}/common/material_slots.py | 0 {tools/nodes => nodes}/common/nodes.py | 0 {tools/nodes => nodes}/common/normal_map.py | 0 .../nodes => nodes}/common/reference_plane.py | 0 {tools/nodes => nodes}/common/shader.py | 0 {tools/nodes => nodes}/common/smoothing.py | 0 {tools/nodes => nodes}/common/validity.py | 0 nodes/groups/__init__.py | 1 + {tools/nodes => nodes}/groups/image_cutout.py | 0 .../groups/image_cutout_symmetry.py | 0 .../groups/image_depth_cutout.py | 0 .../groups/image_depth_layer.py | 0 .../groups/image_depth_panorama.py | 0 .../groups/image_depth_plane.py | 0 {tools/nodes => nodes}/groups/image_layer.py | 0 {tools/nodes => nodes}/groups/image_plane.py | 0 .../groups/image_relief_plane.py | 0 {tools/nodes => nodes}/groups/shadeless.py | 0 .../.openspec.yaml | 2 + .../separate-node-sources-and-tools/design.md | 112 +++++++++++++++++ .../proposal.md | 35 ++++++ .../specs/developer-tool-cli/spec.md | 41 +++++++ .../specs/generated-node-assets/spec.md | 37 ++++++ .../maintainable-project-structure/spec.md | 21 ++++ .../separate-node-sources-and-tools/tasks.md | 36 ++++++ pyproject.toml | 2 +- src/anyimage/assets/O_AnyImage.blend | 3 - .../anyimage/operators/test_plane_objects.py | 6 +- tests/nodes/__init__.py | 1 + .../{tools => }/nodes/test_boundary_depth.py | 4 +- .../nodes/test_boundary_smoothing.py | 4 +- .../{tools => }/nodes/test_cutout_boundary.py | 2 +- .../{tools => }/nodes/test_cutout_symmetry.py | 6 +- tests/{tools => }/nodes/test_cutouts.py | 2 +- tests/{tools => }/nodes/test_depth_cutout.py | 0 .../nodes/test_depth_relief_planes.py | 0 .../nodes/test_depth_strip_cleanup.py | 1 - .../nodes/test_depth_surface_split.py | 0 .../nodes/test_image_depth_panorama.py | 4 +- tests/{tools => }/nodes/test_image_layer.py | 2 +- tests/{tools => }/nodes/test_image_plane.py | 2 +- tests/{tools => }/nodes/test_normal_scale.py | 0 .../nodes/test_object_normal_rotation.py | 2 +- tests/{tools => }/nodes/test_shadeless.py | 0 .../nodes/test_temporary_attributes.py | 2 +- tests/support/materials.py | 2 +- tests/support/nodes.py | 6 +- tests/support/planes.py | 6 +- tests/tools/nodes/test_arrangement.py | 8 +- tests/tools/nodes/test_cli.py | 114 ++++++++---------- tests/tools/test_environments.py | 29 ++++- tests/tools/test_pack.py | 10 ++ tools/nodes/arrangement/arrange.py | 2 +- tools/nodes/build.py | 20 +-- tools/nodes/cli.py | 82 +++---------- tools/nodes/common/__init__.py | 1 - tools/nodes/groups/__init__.py | 1 - tools/pack.py | 10 ++ 73 files changed, 500 insertions(+), 217 deletions(-) delete mode 100644 .env.example create mode 100644 nodes/__init__.py create mode 100644 nodes/common/__init__.py rename {tools/nodes => nodes}/common/boundary_smoothing.py (100%) rename {tools/nodes => nodes}/common/cutout.py (100%) rename {tools/nodes => nodes}/common/cutout_boundary.py (100%) rename {tools/nodes => nodes}/common/depth_surface.py (100%) rename {tools/nodes => nodes}/common/direction.py (100%) rename {tools/nodes => nodes}/common/material_slots.py (100%) rename {tools/nodes => nodes}/common/nodes.py (100%) rename {tools/nodes => nodes}/common/normal_map.py (100%) rename {tools/nodes => nodes}/common/reference_plane.py (100%) rename {tools/nodes => nodes}/common/shader.py (100%) rename {tools/nodes => nodes}/common/smoothing.py (100%) rename {tools/nodes => nodes}/common/validity.py (100%) create mode 100644 nodes/groups/__init__.py rename {tools/nodes => nodes}/groups/image_cutout.py (100%) rename {tools/nodes => nodes}/groups/image_cutout_symmetry.py (100%) rename {tools/nodes => nodes}/groups/image_depth_cutout.py (100%) rename {tools/nodes => nodes}/groups/image_depth_layer.py (100%) rename {tools/nodes => nodes}/groups/image_depth_panorama.py (100%) rename {tools/nodes => nodes}/groups/image_depth_plane.py (100%) rename {tools/nodes => nodes}/groups/image_layer.py (100%) rename {tools/nodes => nodes}/groups/image_plane.py (100%) rename {tools/nodes => nodes}/groups/image_relief_plane.py (100%) rename {tools/nodes => nodes}/groups/shadeless.py (100%) create mode 100644 openspec/changes/separate-node-sources-and-tools/.openspec.yaml create mode 100644 openspec/changes/separate-node-sources-and-tools/design.md create mode 100644 openspec/changes/separate-node-sources-and-tools/proposal.md create mode 100644 openspec/changes/separate-node-sources-and-tools/specs/developer-tool-cli/spec.md create mode 100644 openspec/changes/separate-node-sources-and-tools/specs/generated-node-assets/spec.md create mode 100644 openspec/changes/separate-node-sources-and-tools/specs/maintainable-project-structure/spec.md create mode 100644 openspec/changes/separate-node-sources-and-tools/tasks.md delete mode 100644 src/anyimage/assets/O_AnyImage.blend create mode 100644 tests/nodes/__init__.py rename tests/{tools => }/nodes/test_boundary_depth.py (96%) rename tests/{tools => }/nodes/test_boundary_smoothing.py (97%) rename tests/{tools => }/nodes/test_cutout_boundary.py (97%) rename tests/{tools => }/nodes/test_cutout_symmetry.py (97%) rename tests/{tools => }/nodes/test_cutouts.py (97%) rename tests/{tools => }/nodes/test_depth_cutout.py (100%) rename tests/{tools => }/nodes/test_depth_relief_planes.py (100%) rename tests/{tools => }/nodes/test_depth_strip_cleanup.py (99%) rename tests/{tools => }/nodes/test_depth_surface_split.py (100%) rename tests/{tools => }/nodes/test_image_depth_panorama.py (99%) rename tests/{tools => }/nodes/test_image_layer.py (94%) rename tests/{tools => }/nodes/test_image_plane.py (98%) rename tests/{tools => }/nodes/test_normal_scale.py (100%) rename tests/{tools => }/nodes/test_object_normal_rotation.py (97%) rename tests/{tools => }/nodes/test_shadeless.py (100%) rename tests/{tools => }/nodes/test_temporary_attributes.py (95%) delete mode 100644 tools/nodes/common/__init__.py delete mode 100644 tools/nodes/groups/__init__.py 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)