diff --git a/README.md b/README.md
index 7caf19b..9bcca3f 100644
--- a/README.md
+++ b/README.md
@@ -8,5 +8,5 @@ AnyImage 是一款 Blender 扩展,可以粘贴和编辑图片、创建普通 P
- 用 **Plane** 创建可调厚度和细分的图片网格
- 用 **Depth Plane** 把静态图片转换为可调节的立体面片
- 用 **Cutout** 从套索选区生成 Surface、Balloon 与两种 Depth Shape
-- 用 **Image Tools / Perspective** 直接清理或矫正 Image Empty
-- 用 **Remove Background / Upscale** 处理单图、视频和序列帧
\ No newline at end of file
+- 用 **Crop Box / Lasso / Polyline / Perspective** 清理或矫正 Image Empty
+- 用 **Remove Background / Upscale** 处理单图、视频和序列帧
diff --git a/docs/index.md b/docs/index.md
index 432c443..9bcca3f 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -8,5 +8,5 @@ AnyImage 是一款 Blender 扩展,可以粘贴和编辑图片、创建普通 P
- 用 **Plane** 创建可调厚度和细分的图片网格
- 用 **Depth Plane** 把静态图片转换为可调节的立体面片
- 用 **Cutout** 从套索选区生成 Surface、Balloon 与两种 Depth Shape
-- 用 **Image Tools / Perspective** 直接清理或矫正 Image Empty
+- 用 **Crop Box / Lasso / Polyline / Perspective** 清理或矫正 Image Empty
- 用 **Remove Background / Upscale** 处理单图、视频和序列帧
diff --git a/docs/internals/architecture.md b/docs/internals/architecture.md
index 6a84da4..166142a 100644
--- a/docs/internals/architecture.md
+++ b/docs/internals/architecture.md
@@ -32,7 +32,7 @@ Operator 向 Server 发送普通字典。Server 返回文件名和 Job 目录;
```mermaid
flowchart TD
Register["anyimage.register()"] --> Types["注册 Preferences / PropertyGroup / Operator / Panel"]
- Types --> Tools["注册 Image Tools 与 Cutout WorkspaceTool"]
+ Types --> Tools["注册 Crop Tool 与 Cutout WorkspaceTool"]
Tools --> RNA["挂载 Scene 与 Image Property"]
RNA --> Menu["挂载上下文菜单和剪贴板快捷键"]
Menu --> Runtime["注册 JobRuntime"]
@@ -42,7 +42,7 @@ flowchart TD
## 同步与异步路径
-无需 AI 或媒体处理的功能直接在 Blender 中完成,例如 Plane、普通 Image Mask、Perspective,以及未启用 Refine Selection / Normal Map 的 Surface 和 Balloon。需要模型或独立文件处理时,功能 Operator 派生自 `JobOperatorBase`:
+无需 AI 或媒体处理的功能直接在 Blender 中完成,例如 Plane、普通 Crop、Crop Perspective,以及未启用 Refine Selection / Normal Map 的 Surface 和 Balloon。需要模型或独立文件处理时,功能 Operator 派生自 `JobOperatorBase`:
```mermaid
sequenceDiagram
diff --git a/docs/internals/common.md b/docs/internals/common.md
index 1d089bd..800aa9d 100644
--- a/docs/internals/common.md
+++ b/docs/internals/common.md
@@ -27,6 +27,7 @@
| `image.load_packed_rgba_image()` | 加载已有 RGBA 文件,继承源图颜色空间和 Alpha mode,命名并 Pack。 |
| `image.create_pixel_result_image()` | 从本地编辑后的 RGBA 像素创建 Packed Image;不修改 Empty。 |
| `image.replace_empty_image()` | 执行 Image Empty 结果替换、共享引用分流、旧数据清理、帧设置继承和可选画布对齐。 |
+| `image.place_empty_image_result()` | 按 Keep Original 决定替换源 Empty 或复制结果,并选择、激活结果 Empty。 |
| `image.image_empty_bounds()` | 计算 Image Empty 在对象局部坐标中的实际显示边界。 |
## Image Selection 与视图交互
@@ -38,23 +39,23 @@
- Mask 表示二维像素权重或 Alpha 运算。
- Blender 对象 Selection 由上下文明确区分;图片选区细化动作称为 Refine Selection。
-`viewport.py` 处理 View3D 中的点击、坐标投影、`ImageSelectionGesture`、工具设置和 Overlay 绘制;手势完成时只把内存中的 `SelectionPath` 交给实际工具。`selection.py` 负责图片空间 Selection 的值、栅格化和 Alpha 裁切;`SelectionPath → SelectionMask` 只在图片编辑或 Shape 生成阶段执行。
+`viewport.py` 处理 View3D 中的点击、坐标投影、`CropSelectionGesture`、工具设置和 Overlay 绘制;手势完成时只把内存中的 `SelectionPath` 交给实际工具。`selection.py` 负责图片空间 Selection 的值、栅格化和 Alpha 裁切;`SelectionPath → SelectionMask` 只在图片编辑或 Shape 生成阶段执行。
-所有 Path 使用 non-zero winding 填充,同方向重复绕行的重叠区域保持选中。栅格器按路径边与像素中心扫描行的交点累计 winding:非反选只建立 Path Bounds 的局部画布,抗锯齿时增加 2 px 边缘;反选使用完整图片范围。栅格阶段不接收图片 Alpha 或颜色,内容相交、空内容和内容 bounds 由 Image Tool 或 Cutout 业务处理。
+所有 Path 使用 non-zero winding 填充,同方向重复绕行的重叠区域保持选中。栅格器按路径边与像素中心扫描行的交点累计 winding:非反选只建立 Path Bounds 的局部画布,抗锯齿时增加 2 px 边缘;反选使用完整图片范围。栅格阶段不接收图片 Alpha 或颜色,内容相交、空内容和内容 bounds 由 Crop Tool 或 Cutout 业务处理。
### Viewport 交互
| 函数 | 职责 |
| --- | --- |
| `viewport.report_image_edit_exception()` | 将可预期的 Selection 警告和异常映射为 Operator Report。 |
-| `viewport.image_edit_poll()` | 判断当前上下文是否允许启动 Image 工具。 |
+| `viewport.image_edit_poll()` | 判断当前上下文是否允许启动图片编辑工具。 |
| `viewport.resolve_image_edit_click()` | 解析点击到 Image Empty,并保持或恢复选择状态。 |
| `viewport.active_view3d_tool_id()` | 取得当前 View3D Workspace Tool ID。 |
-| `viewport.apply_selection_settings()` | 将当前 Selection 工具的 Refine Selection 设置同步到手势 Operator。 |
-| `viewport.draw_selection_settings()` | 绘制 Selection 工具的公共 Refine Selection 设置。 |
+| `viewport.apply_crop_settings()` | 将当前 Crop 子工具的持久选项同步到手势 Operator。 |
+| `viewport.draw_crop_settings()` | 绘制当前 Crop 子工具的独立选项。 |
| `viewport.draw_dashed_line()` | 绘制手势或透视框虚线。 |
| `viewport.draw_polygon_fill()` | 绘制手势或透视框填充预览。 |
-| `viewport.gesture_property()` | 声明统一的 Box、Lasso、Line 和 Polyline RNA 属性。 |
+| `viewport.gesture_property()` | 声明统一的 Box、Lasso 和 Polyline RNA 属性。 |
| `viewport.screen_path_to_image_pixels()` | 将屏幕路径投影为 Image Empty 像素坐标。 |
| `viewport.serialize_matrix()` | 将 4×4 Matrix 序列化为 Operator 可保存的字符串。 |
| `viewport.deserialize_matrix()` | 恢复已序列化的 4×4 Matrix。 |
diff --git a/docs/internals/node-assets.md b/docs/internals/node-assets.md
index 723c179..2392d82 100644
--- a/docs/internals/node-assets.md
+++ b/docs/internals/node-assets.md
@@ -39,6 +39,8 @@ uv run build_node --skip-tests
构建从空 Factory 文件生成全部节点组,关闭 `.blend1` 备份,并覆盖唯一发布资产。
+`O Image Cutout` 在四个 Shape 分支与平滑之后统一处理 **Inward Axis**。Shape 子节点保持内部 `+X` 朝向;默认 `-Z` 分支执行一次轴转换,`+X` 分支保留内部坐标。运行时对象矩阵与该接口配对,保证两种模式落在相同世界空间位置。
+
## 修改节点组
1. 修改 `tools/node_assets/build_*.py`;
diff --git a/docs/internals/operators/crop-tool.md b/docs/internals/operators/crop-tool.md
new file mode 100644
index 0000000..1ec1ea9
--- /dev/null
+++ b/docs/internals/operators/crop-tool.md
@@ -0,0 +1,41 @@
+# Crop Tool
+
+工具栏保留两个独立 AnyImage 入口:四合一 Crop Tool 组与紧接其后的 Cutout Tool。Crop Tool 依次包含 Crop Box、Crop Lasso、Crop Polyline 和 Crop Perspective。
+
+## 工具设置
+
+四个子工具各自保存 **Keep Original**,默认关闭。关闭时结果替换源 Image Empty;开启时复制源对象承载结果,保留但取消选择原 Empty,并选择、激活新结果。
+
+Crop Box、Crop Lasso 和 Crop Polyline 还分别保存 **Invert**;Box 与 Lasso 分别保存 **Refine Selection**。这些状态互不共享。Crop Perspective 没有 Invert 或 Refine Selection。Crop 不注册选项字母快捷键。
+
+## 执行过程
+
+```mermaid
+flowchart TD
+ Group["Crop Box / Lasso / Polyline / Perspective"]
+ Group --> Kind{"Selection Crop?"}
+ Kind -- "Box / Lasso / Polyline" --> Path["采集 SelectionPath"]
+ Path --> Mask["栅格化 SelectionMask"]
+ Mask --> Refine{"Box / Lasso Refine Selection?"}
+ Refine -- "否" --> Local["源 Alpha × Selection"]
+ Refine -- "是" --> Job["refine-image-selection"]
+ Kind -- "Perspective" --> Quad["四点 Quad 与 Homography"]
+ Local --> Result["创建结果 Image"]
+ Job --> Result
+ Quad --> Result
+ Result --> Keep{"当前子工具 Keep Original?"}
+ Keep -- "否" --> Replace["替换源 Empty"]
+ Keep -- "是" --> Copy["保留源 Empty
选择并激活结果 Empty"]
+```
+
+## Selection Crop
+
+`box.py`、`lasso.py` 和 `polyline.py` 定义 WorkspaceTool,交互与落地集中在 `selection.py`。公共 `CropSelectionGesture` 从 `common/viewport.py` 读取当前子工具的独立设置,并把内存中的 `SelectionPath` 交给 `CropImageSelection`。
+
+本地路径在几何 bounds 内把 Selection 值乘入源 Alpha,再按可见内容紧裁切。Box 或 Lasso 开启 Refine Selection 时,Operator 写出保留源 Alpha 的 bounds 矩形并提交 `server/jobs/refine_image_selection.py`;响应直接加载 BEN2 RGBA。Job request 不包含 Keep Original,保留策略只在 Blender 主线程落地结果时执行。
+
+## Crop Perspective
+
+`perspective.py` 接受任意顺序的四个角点并规范为凸 Quad,创建 512 × 512 预览,随后按目标宽高比执行分块 Homography。输出最长边不超过 8192 px,总像素不超过 16,777,216;插值使用预乘 Alpha,结果按可见 Alpha 紧裁切。
+
+四种 Crop 最终都调用 `common/image.py` 的 `place_empty_image_result()`。该入口负责替换或复制 Image Empty、画布对齐、Collection 归属,以及 Keep Original 下选择并激活结果对象。
diff --git a/docs/internals/operators/cutout-tool.md b/docs/internals/operators/cutout-tool.md
index 40f9536..f21c2cf 100644
--- a/docs/internals/operators/cutout-tool.md
+++ b/docs/internals/operators/cutout-tool.md
@@ -33,7 +33,7 @@ Surface 与 Balloon 在本地组合源 Alpha 与几何 Selection 后构建 `Base
Cutout 工具由 `__init__.py` 注册,交互从 `main.py` 中的 `SelectCutoutSelection` 开始。它接收 Lasso 或双击整图手势,只构建内存中的 `SelectionPath`,然后调用 `interaction.py` 打开 Shape 饼菜单。此时不读取源图像素、不栅格化 Mask,也不扫描可见 Alpha。
-`interaction.py` 读取工具选项,并在构建菜单按钮时把只含 Points 与 Invert 的完整 `SelectionPath` 编码一次,将同一个 JSON 值直接设置给各 `CutoutSelectionToShape`。这次编码只用于跨 Blender Operator 传值,不属于 Selection Mask 构建。**Mesh Detail** 的 Low、Medium、High、Ultra 分别映射到 32、16、8、4 px 的采样间距,再换算期望网格分辨率;档位越高,采样越密。菜单中的 Surface、Balloon、Depth Balloon 和 Depth Surface,以及它们对应的节点分支索引,统一来自 `shape.py`。
+`interaction.py` 读取工具选项,并在构建菜单按钮时把只含 Points 与 Invert 的完整 `SelectionPath` 编码一次,同时把当前 **Inward Axis** 固定到各 `CutoutSelectionToShape`。这次编码只用于跨 Blender Operator 传值,不属于 Selection Mask 构建。**Mesh Detail** 的 Low、Medium、High、Ultra 分别映射到 32、16、8、4 px 的采样间距,再换算期望网格分辨率;档位越高,采样越密。菜单中的 Surface、Balloon、Depth Balloon 和 Depth Surface,以及它们对应的节点分支索引,统一来自 `shape.py`。
### 栅格化 Selection
@@ -92,4 +92,20 @@ Depth Shape 的 Base Plane Depth,以及 Depth Balloon 的 Direction,都由 `
Normal Map 关闭时材质使用几何法线;开启时,`object.py` 把生成的图片和已经确定的 Normal Space 交给材质节点。最后 `main.py` 提交 Undo,整条 Cutout 流程结束。
+### Inward Axis
+
+```mermaid
+flowchart TD
+ Base["四种 Shape
内部几何 +X"] --> Switch{"Inward Axis"}
+ Switch -- "+X" --> Legacy["节点几何不转换
对象矩阵追加轴变换"]
+ Switch -- "-Z(默认)" --> Orient["O Image Cutout 最终几何轴转换
对象沿用源 Empty local transform"]
+ Legacy --> World["相同世界空间结果"]
+ Orient --> World
+ Normal["Depth Surface
Object Space Normal"] --> Mode{"Inward Axis"}
+ Mode -- "+X" --> Keep["保持原通道"]
+ Mode -- "-Z" --> Swizzle["(-y, z, -x)"]
+```
+
+Shape 子节点始终按内部 `+X` 建模。统一的 `O Image Cutout` Modifier 在最终输出处按选项切换轴转换;`object.py` 同步选择对象矩阵,因此默认 `-Z` 与源 Image Empty 的 local transform 一致,旧 `+X` 模式仍保持相同世界空间形状。Depth Surface 的 Object Space Normal 仅在 `-Z` 模式执行通道转换;Tangent Space Normal 不变。
+
Balloon 与 Depth Balloon 的材质会创建 Geometry Attribute 节点,把 `o_balloon` 的 Float 输出直接连接到 `O Image Layer` 已有的 **Normal Scale**;Surface 与 Depth Surface 不建立这条连接。
diff --git a/docs/internals/operators/image-tool.md b/docs/internals/operators/image-tool.md
deleted file mode 100644
index 57fa87d..0000000
--- a/docs/internals/operators/image-tool.md
+++ /dev/null
@@ -1,47 +0,0 @@
-# Image Tool
-
-Image Tool 从 Box、Lasso、Line 或 Polyline 手势得到一个图片 Selection,再用它修改当前 Image Empty 的 Alpha。
-
-## 执行过程
-
-```mermaid
-flowchart TD
- Tool["Box / Lasso / Line / Polyline"] --> Gesture["采集图片空间手势"]
- Gesture --> Path["构建 SelectionPath"]
- Path --> Selection["SelectionPath → 几何 SelectionMask"]
- Selection --> Refine{"Refine Selection?"}
- Refine -- "否" --> Alpha["读取局部 RGBA
源 Alpha × Selection 值"]
- Alpha --> Crop["计算内容 bounds
创建并写回 Image"]
- Refine -- "是" --> Bounds["读取 RGBA
写出几何 bounds 矩形"]
- Bounds --> Job["refine-image-selection"]
- Job --> Result["加载并 Pack BEN2 RGBA"]
- Result --> Replace["按响应内容 bounds 替换 Empty"]
-```
-
-四个工具共用 `EditImageMask`。Box 和 Lasso 可以执行 Refine Selection;Line 与 Polyline 始终保留几何边界,直接使用本地 `SelectionMask`。
-
-## 节点说明
-
-### 选择工具并采集手势
-
-`operators/image_tool/__init__.py` 汇总注册类型;`box.py`、`lasso.py`、`line.py` 和 `polyline.py` 分别定义四个 WorkspaceTool。它们都把操作交给 `edit_mask.py` 中的 `EditImageMask`。
-
-屏幕坐标投射、点击选择、轨迹预览和状态切换来自 `common/viewport.py`。Lasso 按移动距离采点并合并近似共线点;Line 扩展为有向半平面;Polyline 在确认时闭合路径。
-
-### SelectionPath → SelectionMask
-
-`ImageSelectionGesture` 把手势整理为内存中的 `SelectionPath`,其中 Points 与 Invert 共同定义 Selection,并直接交给同一个 `EditImageMask` Operator,不经过 JSON。预览和最终 Mask 统一使用 non-zero winding,同方向重复绕行的重叠区域保持选中。实际图片编辑开始后,`EditImageMask` 通过 `common/selection.py` 在必要画布内把路径栅格化为一次纯几何 `SelectionMask`;这一阶段不读取或接收源图 Alpha。预览缓存只在轨迹变化时更新。
-
-Box 和 Lasso 分别读取自己的 **Refine Selection** 设置。Line 与 Polyline 直接使用几何边界。
-
-### Refine Selection
-
-未开启 Refine Selection 时,`edit_mask.py` 在实际编辑阶段读取 RGBA 并使用本地几何 `SelectionMask`。开启后,它按 Selection 几何 bounds 写出完整矩形 RGBA 并提交给 `server/jobs/refine_image_selection.py`;矩形内、Lasso 外的源像素与 Alpha 保持原样。Job 调用 `server/selection/refine_selection.py` 运行 BEN2,并返回按结果 Alpha 收紧后的图片和局部内容 `bounds`。
-
-Lasso 在 Refine 中只提供矩形上下文,不再作为 BEN2 输出的硬 Mask。响应阶段把局部内容 bounds 映射回源图,并直接加载、配置和 Pack BEN2 已编码的 RGBA 后替换 Empty;不再提取输出 Alpha、重读源图或重新创建同一颜色图片。
-
-### 源 Alpha × SelectionMask
-
-`edit_mask.py` 在 `SelectionMask.bounds` 内复用源 RGBA,只把几何 Selection 值乘入源 Alpha,再按行列可见范围计算内容 bounds 并紧裁切。几何 SelectionMask 在合并前后保持不变。像素读取、结果 Image 创建和共享引用处理使用 `common/image.py`。
-
-公共 Image Empty 替换入口同步更新 `empty_display_size` 和 `empty_image_offset`,使裁切后的内容对齐原图片平面并保持对象 `matrix_world`。
diff --git a/docs/internals/operators/index.md b/docs/internals/operators/index.md
index 9a82de4..b2b0a6b 100644
--- a/docs/internals/operators/index.md
+++ b/docs/internals/operators/index.md
@@ -9,9 +9,8 @@
| `operators/clipboard_image/` | [Clipboard Image](clipboard-image.md) | 系统剪贴板读取、粘贴与快捷键 |
| `operators/convert_to_plane/` | [Convert to Plane](convert-to-plane.md) | Mesh Plane 与 Depth Plane 创建 |
| `operators/cutout_tool/` | [Cutout Tool](cutout-tool.md) | Selection、基础 Shape 构建与 Blender 对象创建 |
+| `operators/crop_tool/` | [Crop Tool](crop-tool.md) | Box、Lasso、Polyline 与 Perspective 图片裁切 |
| `operators/debug.py` | [Debug Operator](debug.md) | 模型接口与原始产物验证 |
-| `operators/image_tool/` | [Image Tool](image-tool.md) | Box、Lasso、Line 与 Polyline 编辑 |
-| `operators/perspective_tool/` | [Perspective Tool](perspective-tool.md) | 四点透视框与透视校正 |
| `operators/remove_background.py` | [Remove Background](remove-background.md) | BEN2 去背与结果替换 |
| `operators/upscale.py` | [Upscale](upscale.md) | 当前 Image 的 4× 高清与输入长边检查 |
diff --git a/docs/internals/operators/perspective-tool.md b/docs/internals/operators/perspective-tool.md
deleted file mode 100644
index ee0969e..0000000
--- a/docs/internals/operators/perspective-tool.md
+++ /dev/null
@@ -1,40 +0,0 @@
-# Perspective Tool
-
-Perspective Tool 从图片上的四个角点建立透视四边形,把其中内容重采样为指定宽高比的矩形并写回当前 Image Empty。
-
-## 执行过程
-
-```mermaid
-flowchart TD
- Click["点击四个角点"] --> Quad["规范为凸四边形"]
- Quad --> Preview["生成 512 × 512 预览"]
- Preview --> Ratio["调整目标宽高比"]
- Ratio --> Size["计算受限输出尺寸"]
- Size --> Warp["全分辨率 Homography"]
- Warp --> Crop["按可见 Alpha 紧裁切"]
- Crop --> Write["Pack 并写回 Empty"]
-```
-
-第四点确认后生成预览,拖动鼠标调整目标宽高比,用户确认后执行全分辨率 Warp。整个流程在 Blender 进程内完成。
-
-## 节点说明
-
-### 采集并规范四个角点
-
-`operators/perspective_tool/__init__.py` 导出注册类型,交互和计算集中在 `edit_perspective.py`。`ImagePerspectiveTool` 启动 `EditImagePerspective`,后者接受任意顺序的四个图片角点,再根据源图 X/Y 方向整理成不交错的凸 Quad。
-
-图片坐标投射和首次点击选择复用 `common/viewport.py`。四个角点在该流程中统一表示为 Quad。
-
-### 预览并调整比例
-
-第四点确定后,`edit_perspective.py` 创建 512 × 512 预览纹理。鼠标移动更新目标宽高比和 HUD,并可吸附到常用比例。
-
-输出尺寸根据 Quad 边长和目标比例计算,最长边不超过 8192 px,总像素不超过 16,777,216。
-
-### 执行 Homography
-
-确认后,`edit_perspective.py` 计算 Homography,并按 256 行分块处理全分辨率像素。插值使用预乘 Alpha,避免透明边缘混入黑色。
-
-### 裁切并写回
-
-Warp 结果按可见 Alpha 紧裁切,再通过 `common/image.py` 创建 Packed Image 并替换当前 Empty。写回时保持中心和覆盖尺度近似一致。
diff --git a/openspec/changes/persist-image-tool-options/.openspec.yaml b/openspec/changes/persist-image-tool-options/.openspec.yaml
new file mode 100644
index 0000000..032461f
--- /dev/null
+++ b/openspec/changes/persist-image-tool-options/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-02
diff --git a/openspec/changes/persist-image-tool-options/design.md b/openspec/changes/persist-image-tool-options/design.md
new file mode 100644
index 0000000..6c7350f
--- /dev/null
+++ b/openspec/changes/persist-image-tool-options/design.md
@@ -0,0 +1,102 @@
+## Context
+
+当前 `ImageBoxTool` 以 `group=True` 注册,Lasso、Line、Polyline 依次通过 `after` 加入同组;`ImagePerspectiveTool` 来自独立的 `perspective_tool/`,单独注册在该组之后;Cutout 再注册到 Perspective 后并带分隔。图片裁切因此在代码、工具栏和文档中分成 Image Tool 与 Perspective Tool 两套概念。
+
+Box、Lasso、Line、Polyline 共用 `ImageSelectionGesture` 与 `EditImageMask`。Perspective 使用自己的 modal Operator,但最终同样创建裁切图片并调用 `replace_empty_image()`。Line 额外依赖共享 Viewport 中把一条直线转换为半平面多边形的分支。
+
+Crop 选项尚未持久化。本地 Selection Crop 与 Perspective 都同步替换源对象;Box/Lasso 的 Refine Selection 在异步 Job 响应中替换源对象。Keep Original 必须覆盖四个最终 Crop 子工具的同步结果和两个可 Refine 子工具的异步结果。
+
+Cutout 的 `BaseShape` 和 `O Image Cutout` 统一以局部 `+X` 为向内轴、YZ 为纸面;`CUTOUT_AXIS_TO_SOURCE` 把这套坐标映射到 Image Empty 的 XY 图片平面和 `-Z` 向内方向。结果世界位置正确,但 Mesh Object 的 local transform 与源 Empty 不同。Depth Surface 的 Object Space Normal 也按 `+X` 坐标编码。
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- 用一个 `crop_tool/` 业务边界承载 Box、Lasso、Polyline 与 Perspective,并在工具栏显示为一个四合一 Crop Tool 组。
+- 彻底删除 Line 裁切和 Image / Perspective 的旧工具命名。
+- 让 Crop Tool 组与 Cutout Tool 作为两个独立工具栏入口相邻。
+- 分别保存四个 Crop 子工具的 Keep Original、三个 Selection Crop 子工具的 Invert 和 Box/Lasso 的 Refine Selection。
+- 让 Selection Crop 从各自工具选项读取持久 Invert,不占用字母快捷键。
+- 让本地和 AI Crop 共享一种 Keep Original 结果落地方式,并明确选择和 active object 状态。
+- 让 Cutout 在不改变世界空间成品的前提下选择 `-Z` 或 `+X` 局部向内轴。
+
+**Non-Goals:**
+
+- 不为 Invert、Refine Selection、Keep Original 或 Inward Axis 增加 Crop 或 Cutout 字母快捷键。
+- 不为 Crop Perspective 增加 Invert、Selection 或 Refine Selection 语义。
+- 不改变 Crop 的 Selection 栅格化、Perspective 重采样、裁切 bounds 或结果图片内容。
+- 不改变 Cutout 的 Shape Pie 或结果对象保留行为。
+- 不把工具设置迁移为跨 `.blend` 文件的 Addon Preferences。
+
+## Decisions
+
+### 1. 统一迁移到 `crop_tool/` 并删除旧入口
+
+将 Box、Lasso、Polyline、共享 Selection Crop Operator 与 Perspective 实现收拢到 `operators/crop_tool/`。WorkSpaceTool 使用 `CropBoxTool`、`CropLassoTool`、`CropPolylineTool`、`CropPerspectiveTool`,Tool ID 使用 `anyimage.crop_box`、`anyimage.crop_lasso`、`anyimage.crop_polyline`、`anyimage.crop_perspective`;Operator 和激活入口同步使用 Crop 动词与 `anyimage` 前缀。
+
+删除 `image_tool/line.py`、`LINE` gesture、直线半平面构建函数及所有注册和文档。删除旧 `image_tool/`、`perspective_tool/` 路径、旧 Tool / Operator ID 和兼容导出。菜单中的 Image Tool 入口改为 Crop Tool,并默认激活 Crop Box。
+
+相比只改 UI Label,这能让用户概念、源码目录、类名、公开 ID 和文档保持同一边界,符合项目不保留兼容层的约束。
+
+### 2. 四个 Crop 子工具连续注册为一组,Cutout 作为第二个入口紧随其后
+
+以 Crop Box 作为 `group=True` 的首项,Crop Lasso、Crop Polyline、Crop Perspective 依次通过前一个 Crop Tool ID 注册且不加 separator。完成 Crop 组注册后,直接调用 `register_tool(CutoutTool)`:不传 `after`、`group` 或 `separator`,使 Cutout 作为普通单项 ToolDef 追加在 Crop 组后,而不是 singleton tuple 或带分割线的入口。Cutout 使用 Blender Add UV Sphere 的 `ops.mesh.primitive_sphere_add_gizmo` 图标。卸载严格按 Cutout、Perspective、Polyline、Lasso、Box 的逆序执行。
+
+注册测试直接验证工具类型、`after`、`group`、`separator` 和逆序注销,避免 Perspective 再次成为独立工具或 Cutout 被其他入口隔开。
+
+### 3. 每个 Crop 子工具使用固定且唯一的 Scene 设置
+
+`AnyImageSettings` 增加四个 `crop_*_keep_original`,以及 Box、Lasso、Polyline 三个 `crop_*_invert`;Box 与 Lasso 的 Refine Selection 设置同步改为 Crop 名称。所有布尔值默认关闭。Crop Perspective 只读取自己的 Keep Original,不声明或绘制 Invert / Refine Selection。
+
+Selection Crop Operator 按 gesture 映射读取对应设置;Perspective Operator 直接读取自己的设置。固定映射比共享一个当前 Crop 状态更能保证切换子工具后恢复各自值,也便于逐项测试没有串用。
+
+### 4. Crop Invert 只由工具选项控制,Cutout 不使用字母快捷键
+
+Crop Box、Lasso、Polyline 的 WorkSpaceTool keymap 只保留鼠标启动操作,不注册 `F` 或其他字母键。共享 Crop Selection modal 删除现有 `F` 切换分支,每次开始手势时只读取当前子工具保存的 Invert。Crop Perspective 同样不注册选项快捷键;Cutout 删除现有 modal `F` 分支和对应状态提示。
+
+### 5. 四个 Crop 子工具共用结果落地 helper
+
+Keep Original 关闭时保持 `replace_empty_image(source_object, result_image, ...)` 路径。开启时,在结果就绪的 Blender 主线程复制源 Image Empty,将复制对象链接到源对象所在 Collection,再对复制对象调用同一图片替换逻辑。随后清理选择状态,选择并激活结果对象;未改变的源 Image Empty 保留但不继续选择。
+
+Box、Lasso、Polyline 与 Perspective 都调用这个 helper,不各自实现对象复制。对象复制继承源变换和 Image Empty 显示属性;既有替换逻辑继续负责裁切尺寸、offset、图片用户数与命名。
+
+### 6. 每次 Crop 冻结所属子工具的 Keep Original
+
+Selection Crop 本地编辑与 Perspective 本地编辑分别把当前子工具的 Keep Original 传给结果落地 helper。Box/Lasso 启动 Refine Selection Job 时把该布尔值作为隐藏 Blender Operator Property 传入,并在响应阶段使用;不修改跨进程 Job request。
+
+这保证异步执行期间切换工具设置不会改变已提交结果,也不会让一个 Crop 子工具读取另一个子工具的状态。
+
+### 7. Cutout 在最终几何边界转换 Inward Axis
+
+为 Cutout 增加 `NEGATIVE_Z` / `POSITIVE_X` Scene EnumProperty。保留 `BaseShape`、Depth 标定与 `O Image Cutout` 内部现有 `+X` 计算约定,只在节点组最终 Geometry 输出和对象矩阵边界应用选项:
+
+- `+X` 使用现有 Geometry 输出与 `source_matrix @ crop_translation @ CUTOUT_AXIS_TO_SOURCE` 对象矩阵;
+- `-Z` 在节点组末端用 `CUTOUT_AXIS_TO_SOURCE` 把最终 Geometry 转为 XY 纸面、`-Z` 向内,再使用 `source_matrix @ crop_translation` 对象矩阵。
+
+两条路径的世界坐标都等于 `source_matrix @ crop_translation @ CUTOUT_AXIS_TO_SOURCE @ internal_geometry`,因此位置、大小、UV 和正反面保持一致。节点组增加由创建代码设置的 Inward Axis 输入,构建脚本、资产验证和接口测试同步更新;修改器输入保持 `SINGLE`。
+
+### 8. Object Space Normal 随 Cutout 局部坐标转换
+
+Depth Surface 的 Object Space Normal 文件继续由 Server 按内部 `+X` 约定生成,不扩展 Job 协议。选择 `-Z` 时,Blender 在结果导入边界把解码后的法线从 `(x, y, z)` 转为 `(-y, z, -x)` 并重新编码后 Pack;`+X` 直接使用现有图片。Tangent Space Normal 不依赖对象局部坐标轴,不执行转换。
+
+### 9. UI 与状态文本只描述当前 Crop 子工具
+
+Crop Box、Lasso、Polyline 的状态文本描述各自 Invert On/Off,但不提示字母快捷键;Crop Perspective 保留四点与宽高比交互提示,不显示 Invert。Invert、Refine Selection、Keep Original 与 Inward Axis 只通过工具设置 UI 修改。README 和内部 Operator 索引统一使用 Crop Tool;原 Image Tool 与 Perspective Tool 文档合并为 `crop-tool.md`。
+
+## Risks / Trade-offs
+
+- [旧 Workspace 保存了已删除的 Tool ID] → 不保留兼容 Tool;扩展注册后由 Blender 回退到可用工具,用户重新选择 Crop Tool。
+- [Perspective 加入分组后入口发现性变化] → 使用明确的 Crop Perspective 名称和原有图标,并在 Crop Tool 文档中与三个 Selection Crop 并列说明。
+- [删除 Line 影响既有使用习惯] → 这是明确的产品删除;不模拟半平面兼容行为,Polyline 继续承担自定义边界裁切。
+- [四个 Keep Original 或三个 Invert 设置发生串用] → 每个 WorkSpaceTool 使用固定唯一的 Scene Property,参数化测试逐项切换并验证其余值不变。
+- [复制对象可能遗漏 Collection 归属] → 明确链接到源对象的 Collection,并用测试验证结果与源位于预期 Collection。
+- [异步 Refine 完成时源对象已被用户删除] → 沿用现有按名称解析源对象的失败处理,不创建失去来源的结果对象。
+- [Keep Original 连续使用会创建大量对象和图片] → 这是选项的显式产物语义,默认关闭,不增加自动清理。
+- [Inward Axis 同时影响 Geometry、对象矩阵和 Object Space Normal] → 在创建入口冻结一次选项值,并以相同值设置三处;覆盖四种 Shape、非均匀源变换和 Normal 的世界空间对照测试。
+- [节点资产接口变化导致旧资产与代码不匹配] → 同步构建脚本、资产文件、结构验证与打包测试,不保留缺少新输入的兼容分支。
+
+## Migration Plan
+
+实施时先迁移 Crop 模块与名称、删除 Line,再切换注册和菜单入口,避免同一版本同时注册新旧 Tool ID。四个 Crop Keep Original 和三个 Invert 使用关闭默认值,因此旧文件加载后保持原有裁切结果语义,但不会迁移尚不存在的旧设置名称。Cutout Inward Axis 默认 `-Z`,会有意改变新建 Cutout 的 local transform;选择 `+X` 可取得旧坐标约定。
+
+部署时同步代码与重建后的 `assets/O_AnyImage.blend`,不支持新代码搭配旧节点资产。回滚需要恢复旧模块、注册与节点资产;已创建的 Crop 结果和 Cutout Mesh 不会被自动迁移。
diff --git a/openspec/changes/persist-image-tool-options/proposal.md b/openspec/changes/persist-image-tool-options/proposal.md
new file mode 100644
index 0000000..107427f
--- /dev/null
+++ b/openspec/changes/persist-image-tool-options/proposal.md
@@ -0,0 +1,34 @@
+## Why
+
+现有图片编辑入口分散为四个 Image Selection Tool 和一个独立 Perspective Tool,其中 Line 与 Polyline 功能重叠,工具名称也没有直接表达裁切产物;同时编辑选项缺少稳定状态,Cutout 的局部向内轴还与源 Image Empty 不一致。将图片裁切工具收拢为一个明确的 Crop Tool 组,并统一持久选项与坐标约定,可以减少工具栏占用和后续编辑歧义。
+
+## What Changes
+
+- **BREAKING**:将 Image Box、Image Lasso、Image Polyline 与 Image Perspective 重命名并重组为同一个四合一 **Crop Tool** 工具组,子工具依次命名为 **Crop Box**、**Crop Lasso**、**Crop Polyline**、**Crop Perspective**。
+- **BREAKING**:删除 Image Line WorkSpaceTool、对应半平面手势分支、公开 Tool ID、源码入口、测试与文档,不保留兼容入口。
+- **BREAKING**:将原 `image_tool/` 与 `perspective_tool/` 的用户概念、Tool ID、Operator 名称和源码边界统一迁移为 `crop_tool/` 与 Crop 术语,不保留旧名称转发。
+- 将 Crop Tool 注册为一个工具栏组,并让使用 Add UV Sphere 图标的 Cutout Tool 作为第二个独立工具栏入口紧接在该组之后;两个工具之间不显示分割线。
+- 为 Crop Box、Crop Lasso 与 Crop Polyline 增加彼此独立保存的 **Invert** 工具选项,默认关闭;Crop Perspective 不提供 Invert,Crop Tool 不为选项注册字母快捷键。
+- 为四个 Crop 子工具增加彼此独立保存的 **Keep Original** 工具选项,默认关闭;开启后每次裁切创建并激活结果 Image Empty,同时保留但不继续选择源 Image Empty。
+- 为 Cutout 增加 **Inward Axis** 工具选项,可选择 `-Z` 或 `+X`;默认 `-Z`,使生成 Mesh 的局部坐标方向与源 Image Empty 一致,`+X` 保留当前 Cutout 坐标约定。
+- 让 Cutout 的四种 Shape、Depth 几何与 Object Space Normal Map 统一遵循所选 Inward Axis,同时保持世界空间图片位置和正反面语义不变。
+- 同步菜单、工具提示、状态栏、节点资产、测试、README 与内部文档。
+
+## Capabilities
+
+### New Capabilities
+
+- `crop-tool-options`: 规定四合一 Crop Tool 的组成、命名、注册顺序、独立 Keep Original、适用子工具的独立 Invert,以及 Cutout 的相邻位置和 Inward Axis 坐标约定。
+
+### Modified Capabilities
+
+无。
+
+## Impact
+
+- 影响 `operators/image_tool/`、`operators/perspective_tool/`、共享 Viewport 手势、Scene 工具设置、菜单与扩展注册;实现迁移到统一的 `operators/crop_tool/` 边界。
+- 旧 Image Tool / Perspective Tool 的 Tool ID、Operator ID、类名、模块路径和 Image Line 行为不再可用。
+- Crop Selection 的本地与 Refine Selection 异步结果路径、Crop Perspective 的本地结果路径都需要支持保留源对象,并选择、激活结果对象。
+- Cutout 的对象矩阵、统一 Geometry Nodes 资产和 Object Space Normal 转换需要接受所选 Inward Axis;本地与异步 Shape 路径必须传递同一选项。
+- 需要更新 Blender 注册、交互、裁切、Cutout 对象与节点资产测试,以及用户文档、Crop Tool、Cutout 和节点资产内部文档。
+- 不新增依赖,不改变 Cutout 的 Selection 栅格化或 AI Job 输入产物协议;删除其临时 `F` Invert 交互。
diff --git a/openspec/changes/persist-image-tool-options/specs/crop-tool-options/spec.md b/openspec/changes/persist-image-tool-options/specs/crop-tool-options/spec.md
new file mode 100644
index 0000000..7acf4d3
--- /dev/null
+++ b/openspec/changes/persist-image-tool-options/specs/crop-tool-options/spec.md
@@ -0,0 +1,118 @@
+## Purpose
+
+规定四合一 Crop Tool 的工具栏结构、裁切工具命名和独立编辑选项,以及相邻 Cutout Tool 成品相对源图片平面的局部向内轴约定。
+
+## ADDED Requirements
+
+### Requirement: Crop Tool is one four-tool toolbar group
+系统 SHALL 在 3D Viewport 工具栏中提供两个独立 AnyImage 入口:一个 **Crop Tool** 工具组和一个普通单项 **Cutout Tool**。Crop Tool SHALL 按 **Crop Box**、**Crop Lasso**、**Crop Polyline**、**Crop Perspective** 的顺序包含四个子工具;Cutout Tool SHALL 不属于任何工具组,并紧接在 Crop Tool 组之后。
+
+#### Scenario: User opens the Crop Tool group
+- **WHEN** 用户展开 Crop Tool
+- **THEN** 工具组只显示 Crop Box、Crop Lasso、Crop Polyline 与 Crop Perspective
+- **AND** Crop Box 是该组的首个子工具
+
+#### Scenario: User views adjacent AnyImage tools
+- **WHEN** Crop Tool 组和 Cutout Tool 已注册到 3D Viewport
+- **THEN** 工具栏显示 Crop Tool 组与 Cutout Tool 两个独立入口
+- **AND** Cutout Tool 紧接在 Crop Tool 组之后
+- **AND** Cutout Tool 是单个 WorkSpaceTool,不是单项工具组
+- **AND** 两个入口之间没有分割线
+- **AND** Cutout Tool 使用 Add UV Sphere 图标
+
+### Requirement: Crop terminology replaces Image Tool terminology
+系统 SHALL 对四个裁切子工具、激活入口、Tool ID、Operator、设置属性、源码边界和文档统一使用 Crop 术语,不得保留旧 Image Tool 或独立 Perspective Tool 的兼容名称和转发入口。
+
+#### Scenario: User activates the crop entry
+- **WHEN** 用户从 AnyImage 入口激活图片裁切功能
+- **THEN** 系统激活 Crop Box 并以 Crop Tool 命名该功能
+
+#### Scenario: Extension registers crop tools
+- **WHEN** AnyImage 扩展完成注册
+- **THEN** 四个 Crop 子工具使用 `anyimage` 前缀的 Crop Tool ID 注册为同一组
+- **AND** 旧 Image Tool 与独立 Perspective Tool ID 不再注册
+
+### Requirement: Image Line is removed
+系统 SHALL 删除 Image Line 及其按直线划分图片半平面的裁切行为,不得在 Crop Tool、菜单、快捷入口或公开 Operator 中提供该工具。
+
+#### Scenario: User opens Crop Tool
+- **WHEN** 用户查看 Crop Tool 的子工具
+- **THEN** 列表中没有 Image Line 或 Crop Line
+
+### Requirement: Crop options persist independently per sub-tool
+系统 SHALL 为 Crop Box、Crop Lasso、Crop Polyline 与 Crop Perspective 分别保存彼此独立的 **Keep Original**,并为前三个 Selection Crop Tool 分别保存彼此独立的 **Invert**;所有选项默认关闭,切换一个子工具的值不得改变其他子工具的值,Crop Perspective SHALL NOT 提供 Invert。
+
+#### Scenario: User returns to a Crop sub-tool
+- **WHEN** 用户修改一个子工具支持的 Invert 或 Keep Original,切换到其他 Crop 子工具后再返回
+- **THEN** 该子工具恢复自己上次保存的选项值
+
+#### Scenario: User uses a Crop sub-tool for the first time
+- **WHEN** 当前文件尚未修改该子工具支持的 Invert 或 Keep Original
+- **THEN** 该子工具支持的选项均为关闭
+
+#### Scenario: User opens Crop Perspective settings
+- **WHEN** 用户激活 Crop Perspective
+- **THEN** 工具设置提供独立的 Keep Original
+- **AND** 工具设置不提供 Invert 或 Refine Selection
+
+### Requirement: Invert follows the persistent Crop sub-tool option
+Crop Box、Crop Lasso 与 Crop Polyline SHALL 使用当前子工具保存的 **Invert** 值决定保留 Selection 内部还是外部,并只通过工具选项修改该值;Crop Perspective 不参与该行为,Crop Tool 的 keymap 与 modal 交互 SHALL NOT 将 `F` 或其他字母键绑定为选项快捷键。
+
+#### Scenario: User changes Invert in tool settings
+- **WHEN** 用户在任一 Selection Crop Tool 的工具设置中修改 Invert
+- **THEN** 系统保存当前子工具的 Invert,下一次裁切使用新值
+
+#### Scenario: User operates a Crop tool with a letter key
+- **WHEN** Crop Tool 空闲或正在执行 modal 交互
+- **THEN** Crop Tool 不使用 `F` 或其他字母键切换 Invert、Keep Original 或 Refine Selection
+
+### Requirement: Cutout has no letter option shortcuts
+Cutout Tool 的 keymap 与 modal 交互 SHALL NOT 使用 `F` 或其他字母键切换选项。
+
+#### Scenario: User presses F during Cutout selection
+- **WHEN** Cutout Tool 正在执行 Selection modal 交互
+- **THEN** `F` 不切换 Invert 或其他选项
+
+### Requirement: Keep Original preserves the crop source
+四个 Crop 子工具 SHALL 在各自的 **Keep Original** 关闭时以裁切结果替换源 Image Empty;开启时 SHALL 为本次裁切创建一个结果 Image Empty,并保留源 Image Empty 的对象、图片、变换和显示设置。
+
+#### Scenario: User crops with Keep Original disabled
+- **WHEN** 用户在 Keep Original 关闭时完成任一 Crop 操作
+- **THEN** 裁切结果替换源 Image Empty,场景中不额外保留操作前的源对象
+
+#### Scenario: User crops with Keep Original enabled
+- **WHEN** 用户在 Keep Original 开启时完成任一 Crop 操作
+- **THEN** 场景同时包含未改变的源 Image Empty 和本次裁切的结果 Image Empty
+- **AND** 结果 Image Empty 被选中并作为 active object
+- **AND** 源 Image Empty 不再保持选中
+
+#### Scenario: User continues cropping the result
+- **WHEN** 用户在 Keep Original 开启时完成一次裁切后继续操作当前 active object
+- **THEN** 下一次裁切以刚创建的结果 Image Empty 作为输入
+- **AND** 前一次源 Image Empty 仍保留在场景中
+
+### Requirement: Cutout exposes the inward local axis
+Cutout Tool SHALL 提供持久保存的 **Inward Axis** 选项,可选 `-Z` 与 `+X`,默认值 SHALL 为 `-Z`;该选项 SHALL 决定生成 Mesh Object 在局部坐标中从纸面指向内部的轴向。
+
+#### Scenario: User creates a Cutout with the default axis
+- **WHEN** 用户未修改 Inward Axis 并创建任一 Cutout Shape
+- **THEN** 结果 Mesh 的纸面位于局部 XY 平面,向内方向为局部 `-Z`
+- **AND** 结果的局部坐标轴与源 Image Empty 的局部坐标轴一致
+
+#### Scenario: User chooses the legacy axis
+- **WHEN** 用户把 Inward Axis 设为 `+X` 并创建任一 Cutout Shape
+- **THEN** 结果 Mesh 的纸面位于局部 YZ 平面,向内方向为局部 `+X`
+- **AND** 结果保持变更前的 Cutout 局部坐标约定
+
+### Requirement: Cutout orientation preserves world-space output
+Cutout SHALL 对 Surface、Balloon、Depth Balloon 与 Depth Surface 一致应用所选 Inward Axis,同时保持结果在源 Image Empty 图片平面上的世界空间位置、尺寸、纹理方向和纸面正反语义不变。
+
+#### Scenario: Equivalent shapes use different local axes
+- **WHEN** 用户从同一源 Image Empty 和 Selection 分别以 `-Z` 与 `+X` 创建相同 Shape
+- **THEN** 两个结果的对应表面点在世界空间重合
+- **AND** 两个结果只在 Mesh Object 的局部坐标与对象变换约定上不同
+
+#### Scenario: Oriented Depth Surface uses an Object Space Normal Map
+- **WHEN** 用户以任一 Inward Axis 创建启用 Normal Map 的 Depth Surface
+- **THEN** Object Space Normal Map 按所选局部坐标约定着色
+- **AND** 可见表面法线方向与对应几何法线一致
diff --git a/openspec/changes/persist-image-tool-options/tasks.md b/openspec/changes/persist-image-tool-options/tasks.md
new file mode 100644
index 0000000..e417848
--- /dev/null
+++ b/openspec/changes/persist-image-tool-options/tasks.md
@@ -0,0 +1,34 @@
+## 1. Crop Tool 重组
+
+- [x] 1.1 将 Box、Lasso、Polyline、共享 Selection Crop 与 Perspective 实现迁移到 `operators/crop_tool/`,统一类名、Tool ID、Operator ID、激活入口和导出为 Crop 术语;用导入、注册和 ID 测试验证旧 `image_tool/`、`perspective_tool/` 路径及旧公开 ID 不再存在
+- [x] 1.2 删除 Image Line WorkSpaceTool、`LINE` gesture、半平面多边形函数及对应状态和测试分支;用搜索与交互测试验证 Crop Tool、菜单和公开 Operator 不再提供 Line
+- [x] 1.3 按 Crop Box、Crop Lasso、Crop Polyline、Crop Perspective 注册一个连续 WorkSpaceTool 组,再将不传 `after`、`group`、`separator` 的普通单项 Cutout Tool 紧接该组并使用 Add UV Sphere 图标;用 Blender 实际工具结构和注册测试验证工具类型、图标、顺序及严格逆序注销
+- [x] 1.4 将 AnyImage 图片编辑入口改名为 Crop Tool 并默认激活 Crop Box;用菜单和 Operator 测试验证显示名、描述与激活目标
+
+## 2. 独立工具设置与 Invert
+
+- [x] 2.1 为四个 Crop 子工具增加唯一的 Keep Original Scene Property,为 Crop Box、Lasso、Polyline 增加唯一的 Invert,为 Box/Lasso 保留改为 Crop 名称的 Refine Selection,并为 Cutout 增加默认 `-Z` 的 Inward Axis;用属性测试验证默认值、保存状态、四个 Keep Original 与三个 Invert 互不影响且 Perspective 没有 Invert
+- [x] 2.2 扩展设置读取与工具栏绘制,让每个 Crop 子工具只显示自己的 Keep Original,三个 Selection Crop 显示各自 Invert,Box/Lasso 额外显示 Refine Selection,Crop Perspective 不显示 Selection 选项,Cutout 显示 Inward Axis;用工具设置测试验证属性顺序、名称与映射
+- [x] 2.3 删除 Crop 与 Cutout Selection modal 的 `F` 切换分支,不为两个工具栏入口注册任何选项字母快捷键;用 keymap 与交互测试验证 `F` 不再切换选项
+- [x] 2.4 更新 Crop 与 Cutout 的状态文本,不提示字母快捷键;用状态文本测试验证各工具的交互提示边界
+
+## 3. 四个 Crop 子工具的 Keep Original
+
+- [x] 3.1 增加 Crop 共用的 Image Empty 结果落地逻辑:关闭时替换源对象,开启时复制源对象承载结果、保留但取消选择源对象,并选择和激活结果对象;用对象与图片生命周期测试验证变换、显示设置、Collection、选择和命名行为
+- [x] 3.2 将各自 Keep Original 接入 Box、Lasso、Polyline 本地裁切路径,并用参数化编辑测试验证默认替换、开启后保留源对象、连续裁切以新结果为输入且子工具状态互不串用
+- [x] 3.3 将提交时的 Box/Lasso Keep Original 传入 Refine Selection Blender Operator 并在异步响应落地结果,不改变 Job request;用响应和 Undo 测试验证源对象保留、结果激活状态与异常清理
+- [x] 3.4 将 Crop Perspective 的独立 Keep Original 接入透视结果落地;用 Perspective 交互与执行测试验证默认替换、开启后保留源 Empty 并激活结果,且不改变其他三个 Keep Original
+
+## 4. Cutout Inward Axis
+
+- [x] 4.1 将提交 Shape 时的 Inward Axis 固定到 Cutout Operator,并贯通本地与异步创建路径;用 Pie、Operator 和响应测试验证四种 Shape 都取得提交时的同一选项且 Job request 不增加该字段
+- [x] 4.2 扩展 `O Image Cutout` 构建脚本,在最终 Geometry 边界按 Inward Axis 切换 `-Z` 与 `+X` 坐标约定,并同步 `assets/O_AnyImage.blend`、结构验证和节点资产文档;运行 `uv run build_node` 验证资产
+- [x] 4.3 调整 Cutout 对象矩阵,使默认 `-Z` 使用源 Image Empty 的 local transform、`+X` 保留现有轴变换;用四种 Shape、裁切范围及旋转和非均匀缩放源对象测试验证两种选项的对应世界空间点重合
+- [x] 4.4 在 `-Z` 模式导入 Depth Surface Object Space Normal 时执行 `(-y, z, -x)` 通道转换,保持 `+X` 与 Tangent Space 路径不变;用法线像素和材质着色方向测试验证两种坐标约定
+
+## 5. 文档与回归验证
+
+- [x] 5.1 将原 Image Tool 与 Perspective Tool 内部文档合并为 `docs/internals/operators/crop-tool.md`,用流程图说明四合一工具组、三个独立 Invert、四个独立 Keep Original 及同步和异步结果分支,并更新 Operator 索引与公共 Viewport 文档
+- [x] 5.2 更新 README、`docs/index.md`、架构和相关用户表达,统一使用 Crop Tool / Crop Box / Crop Lasso / Crop Polyline / Crop Perspective,确认没有 Image Line、Image Tool 或独立 Perspective Tool 的当前功能描述
+- [x] 5.3 更新 `docs/internals/operators/cutout-tool.md`,使用流程图说明内部 `+X` 几何、最终 Inward Axis 转换、对象矩阵与 Object Space Normal 的对应关系
+- [x] 5.4 运行 Crop、Cutout 交互与对象、属性、菜单、注册、节点资产、打包和 Blender Addon 相关测试,再运行 `uv run pytest`,确认完整测试集通过
diff --git a/pyproject.toml b/pyproject.toml
index 8effd5e..e12dac8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,22 +1,12 @@
[project]
name = "anyimage"
-version = "0.12.15"
+version = "0.13.0"
description = "Fast image-guided structure building tools for Blender"
readme = "README.md"
requires-python = ">=3.11,<3.12"
license = "GPL-3.0-or-later"
dependencies = ["blendjob==0.1.16"]
-[project.scripts]
-pack = "tools.pack:main"
-build_node = "tools.build_node:main"
-sync_models = "tools.sync_models:main"
-docs = "tools.docs:main"
-
-[build-system]
-requires = ["setuptools>=77"]
-build-backend = "setuptools.build_meta"
-
[dependency-groups]
dev = [
"av==17.0.0",
@@ -35,5 +25,16 @@ dev = [
"torch==2.13.0",
"trimesh==4.11.2",
]
-[tool.setuptools]
+
+[project.scripts]
+pack = "tools.pack:main"
+build_node = "tools.build_node:main"
+sync_models = "tools.sync_models:main"
+docs = "tools.docs:main"
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
packages = ["tools"]
diff --git a/src/anyimage/__init__.py b/src/anyimage/__init__.py
index c7b245a..124f44c 100644
--- a/src/anyimage/__init__.py
+++ b/src/anyimage/__init__.py
@@ -5,13 +5,12 @@ from .operators.clipboard_image import CLASSES as CLIPBOARD_CLASSES
from .menu import AnyImageImageMenu, draw_image_context_menu
from .operators import CLASSES as OPERATOR_CLASSES
from .operators.cutout_tool import CutoutTool
-from .operators.image_tool import (
- ImageBoxTool,
- ImageLassoTool,
- ImageLineTool,
- ImagePolylineTool,
+from .operators.crop_tool import (
+ CropBoxTool,
+ CropLassoTool,
+ CropPerspectiveTool,
+ CropPolylineTool,
)
-from .operators.perspective_tool import ImagePerspectiveTool
from .panel import (
Ben2RemovalDebugPanel,
Da3OutputDebugPanel,
@@ -43,28 +42,23 @@ CLASSES = (
def register():
for class_type in CLASSES:
bpy.utils.register_class(class_type)
- bpy.utils.register_tool(ImageBoxTool, separator=True, group=True)
+ bpy.utils.register_tool(CropBoxTool, separator=True, group=True)
bpy.utils.register_tool(
- ImageLassoTool,
- after={ImageBoxTool.bl_idname},
+ CropLassoTool,
+ after={CropBoxTool.bl_idname},
separator=False,
)
bpy.utils.register_tool(
- ImageLineTool,
- after={ImageLassoTool.bl_idname},
+ CropPolylineTool,
+ after={CropLassoTool.bl_idname},
separator=False,
)
bpy.utils.register_tool(
- ImagePolylineTool,
- after={ImageLineTool.bl_idname},
+ CropPerspectiveTool,
+ after={CropPolylineTool.bl_idname},
separator=False,
)
- bpy.utils.register_tool(ImagePerspectiveTool, separator=False)
- bpy.utils.register_tool(
- CutoutTool,
- after={ImagePerspectiveTool.bl_idname},
- separator=True,
- )
+ bpy.utils.register_tool(CutoutTool)
bpy.types.Scene.anyimage_settings = bpy.props.PointerProperty(type=AnyImageSettings)
bpy.types.VIEW3D_MT_object_context_menu.prepend(draw_image_context_menu)
bpy.types.OUTLINER_MT_object.prepend(draw_image_context_menu)
@@ -76,11 +70,10 @@ def unregister():
runtime.unregister()
clipboard_image.unregister_keymaps()
bpy.utils.unregister_tool(CutoutTool)
- bpy.utils.unregister_tool(ImagePerspectiveTool)
- bpy.utils.unregister_tool(ImagePolylineTool)
- bpy.utils.unregister_tool(ImageLineTool)
- bpy.utils.unregister_tool(ImageLassoTool)
- bpy.utils.unregister_tool(ImageBoxTool)
+ bpy.utils.unregister_tool(CropPerspectiveTool)
+ bpy.utils.unregister_tool(CropPolylineTool)
+ bpy.utils.unregister_tool(CropLassoTool)
+ bpy.utils.unregister_tool(CropBoxTool)
bpy.types.VIEW3D_MT_object_context_menu.remove(draw_image_context_menu)
bpy.types.OUTLINER_MT_object.remove(draw_image_context_menu)
if hasattr(bpy.types.Scene, "anyimage_settings"):
diff --git a/src/anyimage/assets/O_AnyImage.blend b/src/anyimage/assets/O_AnyImage.blend
index d0a8a3a..f0ea4da 100644
--- a/src/anyimage/assets/O_AnyImage.blend
+++ b/src/anyimage/assets/O_AnyImage.blend
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:ac265c4cbd1e5ca1ad359e060ddb7d164474690db2176b1136828062b17aa5c6
-size 1642604
+oid sha256:7242cfe77e3ea8be3699bcf4ecb8932448997d73830204333a4d84bdfc50808b
+size 1664356
diff --git a/src/anyimage/blender_manifest.toml b/src/anyimage/blender_manifest.toml
index dfd06b6..88b6659 100644
--- a/src/anyimage/blender_manifest.toml
+++ b/src/anyimage/blender_manifest.toml
@@ -1,7 +1,7 @@
schema_version = "1.0.0"
id = "anyimage"
-version = "0.12.15"
+version = "0.13.0"
name = "AnyImage"
tagline = "Build spatial structures from images, fast"
maintainer = "OmooLab"
diff --git a/src/anyimage/common/image.py b/src/anyimage/common/image.py
index d632551..87e2443 100644
--- a/src/anyimage/common/image.py
+++ b/src/anyimage/common/image.py
@@ -484,6 +484,46 @@ def replace_empty_image(source_object, result_image, *, placement_bounds=None):
return result_image
+def place_empty_image_result(
+ context,
+ source_object,
+ result_image,
+ *,
+ placement_bounds=None,
+ keep_original=False,
+):
+ """Place one crop result and optionally preserve its source."""
+ if not keep_original:
+ replace_empty_image(
+ source_object,
+ result_image,
+ placement_bounds=placement_bounds,
+ )
+ return source_object
+
+ result_object = source_object.copy()
+ collections = tuple(source_object.users_collection)
+ if not collections:
+ collections = (context.collection,)
+ try:
+ for collection in collections:
+ collection.objects.link(result_object)
+ replace_empty_image(
+ result_object,
+ result_image,
+ placement_bounds=placement_bounds,
+ )
+ except Exception:
+ bpy.data.objects.remove(result_object, do_unlink=True)
+ raise
+
+ for selected in context.selected_objects:
+ selected.select_set(False)
+ result_object.select_set(True)
+ context.view_layer.objects.active = result_object
+ return result_object
+
+
def load_color_result_image(
source_object,
diff --git a/src/anyimage/common/viewport.py b/src/anyimage/common/viewport.py
index 07d0251..bd52445 100644
--- a/src/anyimage/common/viewport.py
+++ b/src/anyimage/common/viewport.py
@@ -135,7 +135,7 @@ def resolve_image_edit_click(context, event, report):
if is_image_empty(active_object):
_restore_selection(context, active_object, selected_objects)
return active_object, True
- report({"WARNING"}, "Click an Image Empty to use an Image tool")
+ report({"WARNING"}, "Click an Image Empty to use a Crop tool")
return None, False
@@ -151,32 +151,50 @@ def active_view3d_tool_id(context):
return getattr(tool, "idname", None)
-def refine_selection_setting_name(operator):
- setting_names = getattr(operator, "refine_selection_settings", {})
+def _crop_setting_name(operator, mapping_name):
+ setting_names = getattr(operator, mapping_name, {})
return setting_names.get(operator.gesture)
-def apply_selection_settings(operator, context):
+def apply_crop_settings(operator, context):
settings = context.scene.anyimage_settings
- setting_name = refine_selection_setting_name(operator)
+ setting_name = _crop_setting_name(operator, "refine_selection_settings")
operator.refine_selection = (
setting_name is not None and ai_ready() and getattr(settings, setting_name)
)
+ setting_name = _crop_setting_name(operator, "invert_settings")
+ if setting_name is not None:
+ operator.invert = bool(getattr(settings, setting_name))
+ setting_name = _crop_setting_name(operator, "keep_original_settings")
+ if setting_name is not None:
+ operator.keep_original = bool(getattr(settings, setting_name))
-def draw_selection_operator_settings(layout, operator):
- if operator.gesture not in {"BOX", "LASSO"}:
+def draw_crop_operator_settings(layout, operator):
+ if _crop_setting_name(operator, "refine_selection_settings") is not None:
+ status = draw_ai_setup(layout)
+ draw_ai_property(layout, operator, "refine_selection", status)
+ if _crop_setting_name(operator, "invert_settings") is not None:
layout.prop(operator, "invert")
- return
- status = draw_ai_setup(layout)
- draw_ai_property(layout, operator, "refine_selection", status)
- layout.prop(operator, "invert")
+ if _crop_setting_name(operator, "keep_original_settings") is not None:
+ layout.prop(operator, "keep_original")
-def draw_selection_settings(context, layout, setting_name):
+def draw_crop_settings(
+ context,
+ layout,
+ *,
+ keep_original,
+ invert=None,
+ refine_selection=None,
+):
settings = context.scene.anyimage_settings
- status = draw_ai_setup(layout)
- draw_ai_property(layout, settings, setting_name, status)
+ if refine_selection is not None:
+ status = draw_ai_setup(layout)
+ draw_ai_property(layout, settings, refine_selection, status)
+ if invert is not None:
+ layout.prop(settings, invert)
+ layout.prop(settings, keep_original)
def dashed_line_segments(vertices, dash_length=4.0, gap_length=4.0):
@@ -475,13 +493,11 @@ def draw_polygon_fill(
gpu.state.blend_set("NONE")
-GESTURE_TOOL_IDS = {
- "BOX": "anyimage.image_box",
- "LASSO": "anyimage.image_lasso",
- "LINE": "anyimage.image_line",
- "POLYLINE": "anyimage.image_polyline",
+CROP_TOOL_IDS = {
+ "BOX": "anyimage.crop_box",
+ "LASSO": "anyimage.crop_lasso",
+ "POLYLINE": "anyimage.crop_polyline",
}
-LINE_SNAP_RADIANS = math.radians(5.0)
POLYLINE_CLOSE_RADIUS = 8.0
LASSO_POINT_DISTANCE = 2.0
LASSO_SIMPLIFY_TOLERANCE = 0.75
@@ -528,7 +544,6 @@ def gesture_property():
items=(
("BOX", "Box", "Edit with a rectangular gesture"),
("LASSO", "Lasso", "Edit with a freehand gesture"),
- ("LINE", "Line", "Edit one side of a line"),
("POLYLINE", "Polyline", "Edit with a clicked polygon"),
),
default="BOX",
@@ -544,62 +559,7 @@ def box_screen_path(start, end):
return ((x0, y0), (x1, y0), (x1, y1), (x0, y1))
-def snapped_line_endpoint(start, end, snap):
- if not snap:
- return tuple(float(value) for value in end)
- delta_x = float(end[0]) - float(start[0])
- delta_y = float(end[1]) - float(start[1])
- length = math.hypot(delta_x, delta_y)
- if length <= 0.0:
- return tuple(float(value) for value in end)
- angle = round(math.atan2(delta_y, delta_x) / LINE_SNAP_RADIANS)
- angle *= LINE_SNAP_RADIANS
- return (
- float(start[0]) + math.cos(angle) * length,
- float(start[1]) + math.sin(angle) * length,
- )
-
-
-def line_half_plane_polygon(start, end, rectangle_size):
- """Clip a rectangle to the left side of one directed line."""
- width, height = (float(value) for value in rectangle_size)
- if math.dist(start, end) < 2.0:
- return ()
- vertices = [(0.0, 0.0), (width, 0.0), (width, height), (0.0, height)]
-
- def side(point):
- return (end[0] - start[0]) * (point[1] - start[1]) - (end[1] - start[1]) * (
- point[0] - start[0]
- )
-
- result = []
- previous = vertices[-1]
- previous_side = side(previous)
- for current in vertices:
- current_side = side(current)
- if (current_side >= 0.0) != (previous_side >= 0.0):
- factor = previous_side / (previous_side - current_side)
- result.append(
- (
- previous[0] + (current[0] - previous[0]) * factor,
- previous[1] + (current[1] - previous[1]) * factor,
- )
- )
- if current_side >= 0.0:
- result.append(current)
- previous = current
- previous_side = current_side
- return tuple(result)
-
-
-def image_line_half_plane_polygon(line, image_size):
- """Keep the image-space half-plane shown by the screen-space preview."""
- if len(line) != 2:
- raise ValueError("Drag a longer line across the image plane")
- return line_half_plane_polygon(line[1], line[0], image_size)
-
-
-class ImageSelectionGesture:
+class CropSelectionGesture:
active_tool_id = None
_path = None
_cursor = None
@@ -613,7 +573,7 @@ class ImageSelectionGesture:
_region_size = (0, 0)
def draw(self, _context):
- draw_selection_operator_settings(self.layout, self)
+ draw_crop_operator_settings(self.layout, self)
@classmethod
def poll(cls, context):
@@ -624,10 +584,6 @@ class ImageSelectionGesture:
return box_screen_path(self._path[0], self._cursor)
if self.gesture == "LASSO":
return tuple(self._path)
- if self.gesture == "LINE":
- return line_half_plane_polygon(
- self._path[0], self._cursor, self._region_size
- )
vertices = list(self._path)
if self._cursor is not None:
vertices.append(self._cursor)
@@ -641,10 +597,7 @@ class ImageSelectionGesture:
polygon = self._screen_path()
if polygon != self._outline_polygon:
self._outline_polygon = polygon
- if self.gesture == "LINE":
- outline = (self._path[0], self._cursor)
- else:
- outline = (*polygon, polygon[0]) if len(polygon) > 1 else ()
+ outline = (*polygon, polygon[0]) if len(polygon) > 1 else ()
self._outline_segments = dashed_line_segments(outline)
if len(polygon) >= 3:
if polygon != self._fill_polygon:
@@ -655,9 +608,6 @@ class ImageSelectionGesture:
self._fill_triangles,
(1.0, 0.25, 0.2, 0.16) if self.invert else (1.0, 1.0, 1.0, 0.16),
)
- if self.gesture == "LINE":
- draw_dashed_line((), segments=self._outline_segments)
- return
if len(polygon) > 1:
draw_dashed_line((), segments=self._outline_segments)
if (
@@ -694,12 +644,10 @@ class ImageSelectionGesture:
invert = "On" if self.invert else "Off"
if self.gesture == "POLYLINE":
action = "click points; click start or Enter to finish; Backspace removes"
- elif self.gesture == "LINE":
- action = "drag line; Ctrl snaps 5°; release to edit"
else:
action = "drag and release to edit"
context.workspace.status_text_set(
- f"Image {self.gesture.title()}: {action}; F toggles Invert ({invert}); "
+ f"Crop {self.gesture.title()}: {action}; Invert {invert}; "
"RMB or Esc cancels"
)
@@ -726,10 +674,9 @@ class ImageSelectionGesture:
if source_object is None:
return {"CANCELLED"}
if video_total_frames(source_object.data) > 1:
- self.report({"WARNING"}, "Image tools only support a still image")
+ self.report({"WARNING"}, "Crop tools only support a still image")
return {"CANCELLED"}
- apply_selection_settings(self, context)
- self.invert = False
+ apply_crop_settings(self, context)
self.source_object_name = source_object.name
self.source_matrix_data = serialize_matrix(source_object.matrix_world)
point = (float(event.mouse_region_x), float(event.mouse_region_y))
@@ -753,31 +700,18 @@ class ImageSelectionGesture:
def _complete(self, context):
source_object = require_image_empty(self.source_object_name)
matrix = deserialize_matrix(self.source_matrix_data)
- if self.gesture == "LINE":
- line = screen_path_to_image_pixels(
- (self._path[0], self._cursor),
- context.region,
- context.region_data,
- matrix,
- image_empty_bounds(source_object),
- tuple(source_object.data.size),
- )
- selection_path = image_line_half_plane_polygon(
- line, tuple(source_object.data.size)
- )
- else:
- path = self._screen_path()
- if len(path) < 3:
- self._finish(context)
- return {"CANCELLED"}
- selection_path = screen_path_to_image_pixels(
- path,
- context.region,
- context.region_data,
- matrix,
- image_empty_bounds(source_object),
- tuple(source_object.data.size),
- )
+ path = self._screen_path()
+ if len(path) < 3:
+ self._finish(context)
+ return {"CANCELLED"}
+ selection_path = screen_path_to_image_pixels(
+ path,
+ context.region,
+ context.region_data,
+ matrix,
+ image_empty_bounds(source_object),
+ tuple(source_object.data.size),
+ )
selection_path = SelectionPath(
points=selection_path,
invert=self.invert,
@@ -787,18 +721,13 @@ class ImageSelectionGesture:
def modal(self, context, event):
active_tool = active_view3d_tool_id(context)
- expected_tool = self.active_tool_id or GESTURE_TOOL_IDS[self.gesture]
+ expected_tool = self.active_tool_id or CROP_TOOL_IDS[self.gesture]
if active_tool is not None and active_tool != expected_tool:
self._finish(context)
return {"CANCELLED"}
if event.type in {"ESC", "RIGHTMOUSE"} and event.value == "PRESS":
self._finish(context)
return {"CANCELLED"}
- if event.type == "F" and event.value == "PRESS":
- self.invert = not self.invert
- self._set_status(context)
- context.area.tag_redraw()
- return {"RUNNING_MODAL"}
if self.gesture == "POLYLINE":
return self._modal_polyline(context, event)
if event.type == "MOUSEMOVE":
@@ -806,8 +735,6 @@ class ImageSelectionGesture:
if self.gesture == "LASSO":
append_lasso_point(self._path, point)
self._cursor = point
- elif self.gesture == "LINE":
- self._cursor = snapped_line_endpoint(self._path[0], point, event.ctrl)
else:
self._cursor = point
context.area.tag_redraw()
@@ -817,11 +744,6 @@ class ImageSelectionGesture:
if self.gesture == "LASSO":
append_lasso_point(self._path, point)
self._cursor = point
- elif self.gesture == "LINE":
- self._cursor = snapped_line_endpoint(self._path[0], point, event.ctrl)
- if math.dist(self._path[0], self._cursor) < 2.0:
- self._finish(context)
- return {"CANCELLED"}
else:
self._cursor = point
try:
diff --git a/src/anyimage/operators/__init__.py b/src/anyimage/operators/__init__.py
index 53ccd0f..5e05035 100644
--- a/src/anyimage/operators/__init__.py
+++ b/src/anyimage/operators/__init__.py
@@ -4,13 +4,12 @@ from .cutout_tool import (
SelectCutoutSelection,
)
from .debug import DebugBen2, DebugDa3, DebugMoge2, DebugUpscale
-from .image_tool import CLASSES as IMAGE_TOOL_CLASSES
+from .crop_tool import CLASSES as CROP_TOOL_CLASSES
from .convert_to_plane import (
ConvertToDepthPlane,
ConvertToPlane,
GenerateDepthPlane,
)
-from .perspective_tool import CLASSES as PERSPECTIVE_CLASSES
from .ai_setup import (
ClearModels,
DownloadModel,
@@ -35,8 +34,7 @@ CLASSES = (
SelectCutoutSelection,
RunBackgroundRemoval,
ClearModels,
- *IMAGE_TOOL_CLASSES,
- *PERSPECTIVE_CLASSES,
+ *CROP_TOOL_CLASSES,
RemoveImageBackground,
UpscaleImage,
ConvertToPlane,
diff --git a/src/anyimage/operators/crop_tool/__init__.py b/src/anyimage/operators/crop_tool/__init__.py
new file mode 100644
index 0000000..de82cbe
--- /dev/null
+++ b/src/anyimage/operators/crop_tool/__init__.py
@@ -0,0 +1,21 @@
+from .box import CropBoxTool
+from .lasso import CropLassoTool
+from .perspective import CropImagePerspective, CropPerspectiveTool
+from .polyline import CropPolylineTool
+from .selection import ActivateCropTool, CropImageSelection, RefineCropSelection
+
+
+CLASSES = (
+ CropImageSelection,
+ RefineCropSelection,
+ ActivateCropTool,
+ CropImagePerspective,
+)
+
+__all__ = (
+ "CLASSES",
+ "CropBoxTool",
+ "CropLassoTool",
+ "CropPerspectiveTool",
+ "CropPolylineTool",
+)
diff --git a/src/anyimage/operators/crop_tool/box.py b/src/anyimage/operators/crop_tool/box.py
new file mode 100644
index 0000000..0eb1183
--- /dev/null
+++ b/src/anyimage/operators/crop_tool/box.py
@@ -0,0 +1,30 @@
+import bpy
+
+from ...common.viewport import draw_crop_settings
+from .selection import CropImageSelection
+
+
+class CropBoxTool(bpy.types.WorkSpaceTool):
+ bl_space_type = "VIEW_3D"
+ bl_context_mode = "OBJECT"
+ bl_idname = "anyimage.crop_box"
+ bl_label = "Crop Box"
+ bl_description = "Crop an Image Empty with a rectangular selection"
+ bl_icon = "ops.sculpt.border_mask"
+ bl_operator = CropImageSelection.bl_idname
+ bl_keymap = (
+ (
+ CropImageSelection.bl_idname,
+ {"type": "LEFTMOUSE", "value": "PRESS"},
+ {"properties": [("gesture", "BOX")]},
+ ),
+ )
+
+ def draw_settings(context, layout, _tool):
+ draw_crop_settings(
+ context,
+ layout,
+ refine_selection="crop_box_refine_selection",
+ invert="crop_box_invert",
+ keep_original="crop_box_keep_original",
+ )
diff --git a/src/anyimage/operators/crop_tool/lasso.py b/src/anyimage/operators/crop_tool/lasso.py
new file mode 100644
index 0000000..afbe94d
--- /dev/null
+++ b/src/anyimage/operators/crop_tool/lasso.py
@@ -0,0 +1,30 @@
+import bpy
+
+from ...common.viewport import draw_crop_settings
+from .selection import CropImageSelection
+
+
+class CropLassoTool(bpy.types.WorkSpaceTool):
+ bl_space_type = "VIEW_3D"
+ bl_context_mode = "OBJECT"
+ bl_idname = "anyimage.crop_lasso"
+ bl_label = "Crop Lasso"
+ bl_description = "Crop an Image Empty with a freehand selection"
+ bl_icon = "ops.sculpt.lasso_mask"
+ bl_operator = CropImageSelection.bl_idname
+ bl_keymap = (
+ (
+ CropImageSelection.bl_idname,
+ {"type": "LEFTMOUSE", "value": "PRESS"},
+ {"properties": [("gesture", "LASSO")]},
+ ),
+ )
+
+ def draw_settings(context, layout, _tool):
+ draw_crop_settings(
+ context,
+ layout,
+ refine_selection="crop_lasso_refine_selection",
+ invert="crop_lasso_invert",
+ keep_original="crop_lasso_keep_original",
+ )
diff --git a/src/anyimage/operators/perspective_tool/edit_perspective.py b/src/anyimage/operators/crop_tool/perspective.py
similarity index 95%
rename from src/anyimage/operators/perspective_tool/edit_perspective.py
rename to src/anyimage/operators/crop_tool/perspective.py
index daef042..1f2462e 100644
--- a/src/anyimage/operators/perspective_tool/edit_perspective.py
+++ b/src/anyimage/operators/crop_tool/perspective.py
@@ -4,8 +4,15 @@ import math
import bpy
from ...common.viewport import (
+ active_view3d_tool_id,
deserialize_matrix,
+ draw_crop_settings,
+ draw_dashed_line,
+ draw_polygon_fill,
drawing_in_region,
+ image_edit_poll,
+ report_image_edit_exception,
+ resolve_image_edit_click,
screen_path_to_image_pixels,
serialize_matrix,
)
@@ -15,25 +22,15 @@ from ...common.image import (
image_base_name,
image_pixels,
image_empty_bounds,
- replace_empty_image,
+ place_empty_image_result,
require_image_empty,
video_total_frames,
)
-from ...common.viewport import (
- active_view3d_tool_id,
- draw_dashed_line,
- draw_polygon_fill,
- image_edit_poll,
- report_image_edit_exception,
- resolve_image_edit_click,
-)
-
-
MIN_QUAD_AREA = 16.0
MAX_OUTPUT_DIMENSION = 8192
MAX_OUTPUT_PIXELS = 16_777_216
WARP_CHUNK_ROWS = 256
-IMAGE_PERSPECTIVE_TOOL_ID = "anyimage.image_perspective"
+CROP_PERSPECTIVE_TOOL_ID = "anyimage.crop_perspective"
PREVIEW_TEXTURE_SIZE = 512
ASPECT_PIXELS_PER_DOUBLE = 240.0
MIN_INTERACTIVE_ASPECT = 0.05
@@ -497,14 +494,18 @@ def extract_perspective_pixels(
return pixels, cropped_size, placement_bounds
-class EditImagePerspective(bpy.types.Operator):
- bl_idname = "anyimage.edit_image_perspective"
- bl_label = "Edit Image Perspective"
- bl_description = "Deform four image corners into a rectangular image"
+class CropImagePerspective(bpy.types.Operator):
+ bl_idname = "anyimage.crop_image_perspective"
+ bl_label = "Crop Image Perspective"
+ bl_description = "Crop four image corners into a rectangular image"
bl_options = {"REGISTER", "UNDO"}
source_object_name: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
source_matrix_data: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
+ keep_original: bpy.props.BoolProperty(
+ default=False,
+ options={"HIDDEN", "SKIP_SAVE"},
+ )
quad_json: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
aspect_ratio: bpy.props.FloatProperty(
default=1.0,
@@ -576,14 +577,14 @@ class EditImagePerspective(bpy.types.Operator):
def _set_point_status(self, context):
remaining = 4 - len(self._points)
context.workspace.status_text_set(
- f"Image Perspective: click {remaining} more corner"
+ f"Crop Perspective: click {remaining} more corner"
f"{'s' if remaining != 1 else ''}; click order does not matter; "
"Backspace removes, RMB or Esc cancels"
)
def _set_aspect_status(self, context):
context.workspace.status_text_set(
- f"Image Perspective Aspect {self.aspect_ratio:.3f}: "
+ f"Crop Perspective Aspect {self.aspect_ratio:.3f}: "
"move mouse horizontally, LMB confirms, Backspace returns, "
"RMB or Esc cancels"
)
@@ -639,11 +640,14 @@ class EditImagePerspective(bpy.types.Operator):
if video_total_frames(source_object.data) > 1:
self.report(
{"WARNING"},
- "Image Perspective only supports a still image",
+ "Crop Perspective only supports a still image",
)
return {"CANCELLED"}
self.source_object_name = source_object.name
self.source_matrix_data = serialize_matrix(source_object.matrix_world)
+ self.keep_original = (
+ context.scene.anyimage_settings.crop_perspective_keep_original
+ )
self.quad_json = ""
self._phase = "POINTS"
self._preview_image = None
@@ -665,7 +669,7 @@ class EditImagePerspective(bpy.types.Operator):
def modal(self, context, event):
active_tool = active_view3d_tool_id(context)
- if active_tool is not None and active_tool != IMAGE_PERSPECTIVE_TOOL_ID:
+ if active_tool is not None and active_tool != CROP_PERSPECTIVE_TOOL_ID:
self._finish(context)
return {"CANCELLED"}
if event.type in {"ESC", "RIGHTMOUSE"} and event.value == "PRESS":
@@ -802,7 +806,7 @@ class EditImagePerspective(bpy.types.Operator):
return {"CANCELLED"}
try:
if video_total_frames(source_object.data) > 1:
- raise RuntimeError("Image Perspective only supports a still image")
+ raise RuntimeError("Crop Perspective only supports a still image")
quad = validate_perspective_quad(json.loads(self.quad_json))
if not perspective_quad_overlaps_image(
quad, tuple(source_object.data.size)
@@ -830,10 +834,12 @@ class EditImagePerspective(bpy.types.Operator):
pixels,
output_size,
)
- replace_empty_image(
+ place_empty_image_result(
+ context,
source_object,
result_image,
placement_bounds=placement_bounds,
+ keep_original=bool(getattr(self, "keep_original", False)),
)
except (
json.JSONDecodeError,
@@ -847,18 +853,25 @@ class EditImagePerspective(bpy.types.Operator):
return {"FINISHED"}
-class ImagePerspectiveTool(bpy.types.WorkSpaceTool):
+class CropPerspectiveTool(bpy.types.WorkSpaceTool):
bl_space_type = "VIEW_3D"
bl_context_mode = "OBJECT"
- bl_idname = IMAGE_PERSPECTIVE_TOOL_ID
- bl_label = "Image Perspective"
- bl_description = "Rectify four image corners on the source Image Empty plane"
+ bl_idname = CROP_PERSPECTIVE_TOOL_ID
+ bl_label = "Crop Perspective"
+ bl_description = "Crop four image corners to a rectangular perspective"
bl_icon = "ops.sculpt.line_project"
- bl_operator = EditImagePerspective.bl_idname
+ bl_operator = CropImagePerspective.bl_idname
bl_keymap = (
(
- EditImagePerspective.bl_idname,
+ CropImagePerspective.bl_idname,
{"type": "LEFTMOUSE", "value": "PRESS"},
None,
),
)
+
+ def draw_settings(context, layout, _tool):
+ draw_crop_settings(
+ context,
+ layout,
+ keep_original="crop_perspective_keep_original",
+ )
diff --git a/src/anyimage/operators/crop_tool/polyline.py b/src/anyimage/operators/crop_tool/polyline.py
new file mode 100644
index 0000000..f6e183b
--- /dev/null
+++ b/src/anyimage/operators/crop_tool/polyline.py
@@ -0,0 +1,29 @@
+import bpy
+
+from ...common.viewport import draw_crop_settings
+from .selection import CropImageSelection
+
+
+class CropPolylineTool(bpy.types.WorkSpaceTool):
+ bl_space_type = "VIEW_3D"
+ bl_context_mode = "OBJECT"
+ bl_idname = "anyimage.crop_polyline"
+ bl_label = "Crop Polyline"
+ bl_description = "Crop an Image Empty with a clicked polygon selection"
+ bl_icon = "ops.sculpt.polyline_mask"
+ bl_operator = CropImageSelection.bl_idname
+ bl_keymap = (
+ (
+ CropImageSelection.bl_idname,
+ {"type": "LEFTMOUSE", "value": "PRESS"},
+ {"properties": [("gesture", "POLYLINE")]},
+ ),
+ )
+
+ def draw_settings(context, layout, _tool):
+ draw_crop_settings(
+ context,
+ layout,
+ invert="crop_polyline_invert",
+ keep_original="crop_polyline_keep_original",
+ )
diff --git a/src/anyimage/operators/image_tool/edit_mask.py b/src/anyimage/operators/crop_tool/selection.py
similarity index 73%
rename from src/anyimage/operators/image_tool/edit_mask.py
rename to src/anyimage/operators/crop_tool/selection.py
index 96f66a3..756b3c7 100644
--- a/src/anyimage/operators/image_tool/edit_mask.py
+++ b/src/anyimage/operators/crop_tool/selection.py
@@ -15,13 +15,13 @@ from ...common.image import (
image_base_name,
image_rgba,
load_packed_rgba_image,
+ place_empty_image_result,
prepare_image_bounds_input,
- replace_empty_image,
require_image_empty,
video_total_frames,
)
from ...common.viewport import (
- ImageSelectionGesture,
+ CropSelectionGesture,
gesture_property,
image_edit_poll,
report_image_edit_exception,
@@ -64,7 +64,14 @@ def _edit_image_mask_pixels(
)
-def _edit_image_object(source_object, source_rgba, selection_mask):
+def _edit_image_object(
+ context,
+ source_object,
+ source_rgba,
+ selection_mask,
+ *,
+ keep_original=False,
+):
image = source_object.data
pixels, crop_bounds = _edit_image_mask_pixels(
source_rgba,
@@ -76,21 +83,33 @@ def _edit_image_object(source_object, source_rgba, selection_mask):
pixels,
(right - left, bottom - top),
)
- return replace_empty_image(
+ return place_empty_image_result(
+ context,
source_object,
result_image,
placement_bounds=crop_bounds,
+ keep_original=keep_original,
)
-class EditImageMask(ImageSelectionGesture, bpy.types.Operator):
+class CropImageSelection(CropSelectionGesture, bpy.types.Operator):
refine_selection_settings = {
- "BOX": "image_box_refine_selection",
- "LASSO": "image_lasso_refine_selection",
+ "BOX": "crop_box_refine_selection",
+ "LASSO": "crop_lasso_refine_selection",
}
- bl_idname = "anyimage.edit_image_mask"
- bl_label = "Edit Image Mask"
- bl_description = "Edit and tightly crop an Image Empty with a gesture"
+ invert_settings = {
+ "BOX": "crop_box_invert",
+ "LASSO": "crop_lasso_invert",
+ "POLYLINE": "crop_polyline_invert",
+ }
+ keep_original_settings = {
+ "BOX": "crop_box_keep_original",
+ "LASSO": "crop_lasso_keep_original",
+ "POLYLINE": "crop_polyline_keep_original",
+ }
+ bl_idname = "anyimage.crop_image_selection"
+ bl_label = "Crop Image Selection"
+ bl_description = "Crop an Image Empty with a selection"
bl_options = {"REGISTER", "UNDO"}
source_object_name: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
@@ -100,11 +119,13 @@ class EditImageMask(ImageSelectionGesture, bpy.types.Operator):
name="Invert",
description="Keep the opposite side of the refined gesture mask",
default=False,
- options={"SKIP_SAVE"},
+ options=_HIDDEN,
)
+ keep_original: bpy.props.BoolProperty(default=False, options=_HIDDEN)
gesture: gesture_property()
def _submit_selection_path(self, context, selection_path):
+ keep_original = bool(getattr(self, "keep_original", False))
try:
source_object = require_image_empty(self.source_object_name)
except RuntimeError as error:
@@ -112,7 +133,7 @@ class EditImageMask(ImageSelectionGesture, bpy.types.Operator):
return {"CANCELLED"}
try:
if video_total_frames(source_object.data) > 1:
- raise RuntimeError("Image tools only support a still image")
+ raise RuntimeError("Crop tools only support a still image")
selection_mask = rasterize_selection_path(
tuple(source_object.data.size),
selection_path,
@@ -124,19 +145,22 @@ class EditImageMask(ImageSelectionGesture, bpy.types.Operator):
source_rgba,
selection_mask.bounds,
)
- result = bpy.ops.anyimage.refine_image_selection(
+ result = bpy.ops.anyimage.refine_crop_selection(
input_path=str(source_path),
source_object_name=self.source_object_name,
selection_bounds_json=json.dumps(selection_mask.bounds),
+ keep_original=keep_original,
)
if "CANCELLED" in result:
cleanup_image_input(source_path, True)
return {"CANCELLED"}
return {"FINISHED"}
_edit_image_object(
+ context,
source_object,
source_rgba,
selection_mask,
+ keep_original=keep_original,
)
except (RuntimeError, TypeError, ValueError) as error:
report_image_edit_exception(self, error)
@@ -144,15 +168,16 @@ class EditImageMask(ImageSelectionGesture, bpy.types.Operator):
return {"FINISHED"}
-class RefineImageSelection(JobOperatorBase, bpy.types.Operator):
- bl_idname = "anyimage.refine_image_selection"
- bl_label = "Refine Image Selection"
+class RefineCropSelection(JobOperatorBase, bpy.types.Operator):
+ bl_idname = "anyimage.refine_crop_selection"
+ bl_label = "Refine Crop Selection"
job_type = "refine-image-selection"
starting_message = "Refining image selection"
input_path: bpy.props.StringProperty(options=_HIDDEN)
source_object_name: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
selection_bounds_json: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
+ keep_original: bpy.props.BoolProperty(default=False, options=_HIDDEN)
def request(self, _context):
require_environment()
@@ -163,7 +188,7 @@ class RefineImageSelection(JobOperatorBase, bpy.types.Operator):
"device": production_device().lower(),
}
- def response(self, _context, result):
+ def response(self, context, result):
source_object = require_image_empty(self.source_object_name)
source_bounds = tuple(json.loads(self.selection_bounds_json))
@@ -183,27 +208,29 @@ class RefineImageSelection(JobOperatorBase, bpy.types.Operator):
image_base_name(source_image),
)
try:
- replace_empty_image(
+ place_empty_image_result(
+ context,
source_object,
refined_image,
placement_bounds=bounds,
+ keep_original=bool(getattr(self, "keep_original", False)),
)
except Exception:
if refined_image.users == 0:
bpy.data.images.remove(refined_image, do_unlink=True)
raise
- if "FINISHED" not in bpy.ops.ed.undo_push(message="Edit Image Mask"):
- raise RuntimeError("Unable to create the Edit Image Mask Undo step")
- return "Image Selection refined"
+ if "FINISHED" not in bpy.ops.ed.undo_push(message="Crop Image Selection"):
+ raise RuntimeError("Unable to create the Crop Image Selection Undo step")
+ return "Crop Selection refined"
def cleanup(self):
cleanup_image_input(self.input_path, True)
-class ActivateImageTool(bpy.types.Operator):
- bl_idname = "anyimage.activate_image_tool"
- bl_label = "Image Tool"
- bl_description = "Activate the Image Box tool in the 3D Viewport"
+class ActivateCropTool(bpy.types.Operator):
+ bl_idname = "anyimage.activate_crop_tool"
+ bl_label = "Crop Tool"
+ bl_description = "Activate Crop Box in the 3D Viewport"
bl_options = {"INTERNAL"}
@classmethod
@@ -212,5 +239,5 @@ class ActivateImageTool(bpy.types.Operator):
def execute(self, _context):
return bpy.ops.wm.tool_set_by_id(
- name="anyimage.image_box", space_type="VIEW_3D"
+ name="anyimage.crop_box", space_type="VIEW_3D"
)
diff --git a/src/anyimage/operators/cutout_tool/interaction.py b/src/anyimage/operators/cutout_tool/interaction.py
index a9eb206..c265a8c 100644
--- a/src/anyimage/operators/cutout_tool/interaction.py
+++ b/src/anyimage/operators/cutout_tool/interaction.py
@@ -45,6 +45,7 @@ def _pie_button(
refine_selection,
generate_normal,
mesh_resolution,
+ inward_axis,
):
operator = pie.operator(
"anyimage.cutout_selection_to_shape",
@@ -57,6 +58,7 @@ def _pie_button(
operator.refine_selection = refine_selection
operator.generate_normal = generate_normal
operator.mesh_resolution = mesh_resolution
+ operator.inward_axis = inward_axis
def open_shape_pie(
@@ -76,6 +78,7 @@ def open_shape_pie(
getattr(settings, "cutout_generate_normal", False)
)
mesh_resolution = cutout_mesh_resolution(source_object.data.size, point_spacing)
+ inward_axis = str(getattr(settings, "cutout_inward_axis", "NEGATIVE_Z"))
def draw(menu, _context):
pie = menu.layout.menu_pie()
@@ -91,6 +94,7 @@ def open_shape_pie(
refine_selection,
generate_normal,
mesh_resolution,
+ inward_axis,
)
context.window_manager.popup_menu_pie(
diff --git a/src/anyimage/operators/cutout_tool/main.py b/src/anyimage/operators/cutout_tool/main.py
index 9430a9e..2a86db1 100644
--- a/src/anyimage/operators/cutout_tool/main.py
+++ b/src/anyimage/operators/cutout_tool/main.py
@@ -45,8 +45,8 @@ from ...common.image import (
)
from ...common.depth import load_depth_metadata, load_depth_result_image
from ...common.viewport import (
- ImageSelectionGesture,
- apply_selection_settings,
+ CropSelectionGesture,
+ apply_crop_settings,
gesture_property,
report_image_edit_exception,
resolve_image_edit_click,
@@ -64,9 +64,11 @@ from .geometry import (
)
from .interaction import open_shape_pie
from .object import (
+ CutoutInwardAxis,
NormalMode,
create_cropped_color_image,
create_shape_object,
+ orient_object_normal_image,
)
from .shape import CUTOUT_SHAPES, DEPTH_CUTOUT_SHAPES
@@ -101,6 +103,7 @@ def create_cutout_shape(
depth_metadata=None,
normal_image=None,
normal_mode=NormalMode.NONE,
+ inward_axis=CutoutInwardAxis.NEGATIVE_Z,
color_image,
):
try:
@@ -123,6 +126,7 @@ def create_cutout_shape(
depth_metadata=depth_metadata,
normal_image=normal_image,
normal_mode=normal_mode,
+ inward_axis=inward_axis,
color_image=color_image,
)
except Exception:
@@ -235,6 +239,10 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
)
generate_normal: bpy.props.BoolProperty(default=False, options=_HIDDEN)
refine_selection: bpy.props.BoolProperty(default=False, options=_HIDDEN)
+ inward_axis: bpy.props.StringProperty(
+ default=CutoutInwardAxis.NEGATIVE_Z.value,
+ options=_HIDDEN,
+ )
def _needs_generation(self):
return (
@@ -245,6 +253,11 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
def execute(self, context):
try:
+ self.inward_axis = getattr(
+ self,
+ "inward_axis",
+ CutoutInwardAxis.NEGATIVE_Z.value,
+ )
source_object = require_image_empty(self.source_object_name)
if video_total_frames(source_object.data) != 1:
raise ValueError("Cutout currently supports still images")
@@ -285,6 +298,7 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
self.mesh_resolution,
content_values,
selection_mask.bounds,
+ inward_axis=self.inward_axis,
color_image=color_image,
)
return {"FINISHED"}
@@ -355,6 +369,11 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
return parameters
def response(self, context, result):
+ inward_axis = getattr(
+ self,
+ "inward_axis",
+ CutoutInwardAxis.NEGATIVE_Z.value,
+ )
source_object = require_image_empty(self.source_object_name)
source_bounds = tuple(json.loads(self.selection_bounds_json))
color_image, source_alpha, color_bounds = _generated_color_result(
@@ -389,6 +408,8 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
result.file(normal_key),
source_object,
)
+ if normal_mode == NormalMode.OBJECT:
+ orient_object_normal_image(normal_image, inward_axis)
depth_metadata = None
depth_mode = depth_mode_for_shape(self.shape)
if depth_mode != "NONE":
@@ -410,6 +431,7 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
depth_metadata=depth_metadata,
normal_image=normal_image,
normal_mode=normal_mode,
+ inward_axis=inward_axis,
color_image=color_image,
)
except Exception:
@@ -426,7 +448,7 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
cleanup_image_input(self.input_path, True)
-class SelectCutoutSelection(ImageSelectionGesture, bpy.types.Operator):
+class SelectCutoutSelection(CropSelectionGesture, bpy.types.Operator):
bl_idname = "anyimage.select_cutout_selection"
bl_label = "Start Cutout Selection"
bl_description = "Lasso an Image Empty selection, then choose its mesh shape"
@@ -444,10 +466,8 @@ class SelectCutoutSelection(ImageSelectionGesture, bpy.types.Operator):
_pie_event = None
def _set_status(self, context):
- invert = "On" if self.invert else "Off"
context.workspace.status_text_set(
- f"Cutout Lasso: drag and release for shapes; "
- f"F toggles Invert ({invert}); RMB or Esc cancels"
+ "Cutout Lasso: drag and release for shapes; RMB or Esc cancels"
)
def invoke(self, context, event):
@@ -462,9 +482,9 @@ class SelectCutoutSelection(ImageSelectionGesture, bpy.types.Operator):
if source_object is None:
return {"CANCELLED"}
if video_total_frames(source_object.data) > 1:
- self.report({"WARNING"}, "Image tools only support a still image")
+ self.report({"WARNING"}, "Cutout only supports a still image")
return {"CANCELLED"}
- apply_selection_settings(self, context)
+ apply_crop_settings(self, context)
self.invert = False
self.source_object_name = source_object.name
self.source_matrix_data = serialize_matrix(source_object.matrix_world)
@@ -476,7 +496,7 @@ class SelectCutoutSelection(ImageSelectionGesture, bpy.types.Operator):
def modal(self, context, event):
self._pie_event = event
- return super().modal(context, event)
+ return CropSelectionGesture.modal(self, context, event)
def _submit_selection_path(self, context, selection_path):
try:
@@ -500,7 +520,7 @@ class CutoutTool(bpy.types.WorkSpaceTool):
bl_idname = "anyimage.cutout_lasso"
bl_label = "Cutout"
bl_description = "Lasso an Image Empty and choose a mesh shape"
- bl_icon = "ops.sculpt.lasso_mask"
+ bl_icon = "ops.mesh.primitive_sphere_add_gizmo"
bl_operator = SelectCutoutSelection.bl_idname
bl_keymap = (
(
@@ -521,3 +541,4 @@ class CutoutTool(bpy.types.WorkSpaceTool):
draw_ai_property(layout, settings, "cutout_refine_selection", status)
draw_ai_property(layout, settings, "cutout_generate_normal", status)
layout.prop(settings, "cutout_mesh_detail")
+ layout.prop(settings, "cutout_inward_axis")
diff --git a/src/anyimage/operators/cutout_tool/object.py b/src/anyimage/operators/cutout_tool/object.py
index 145adba..4e884cd 100644
--- a/src/anyimage/operators/cutout_tool/object.py
+++ b/src/anyimage/operators/cutout_tool/object.py
@@ -31,6 +31,7 @@ from .geometry import (
from .shape import (
BALLOON_ATTRIBUTE_NAME,
BALLOON_CUTOUT_SHAPES,
+ CUTOUT_AXIS_TO_SOURCE,
CUTOUT_NODE_GROUP_NAME,
CUTOUT_SHAPE_INDICES,
DEPTH_CUTOUT_SHAPES,
@@ -43,23 +44,38 @@ class NormalMode(StrEnum):
OBJECT = "OBJECT"
-CUTOUT_AXIS_TO_SOURCE = (
- (0.0, -1.0, 0.0, 0.0),
- (0.0, 0.0, 1.0, 0.0),
- (-1.0, 0.0, 0.0, 0.0),
- (0.0, 0.0, 0.0, 1.0),
-)
+class CutoutInwardAxis(StrEnum):
+ NEGATIVE_Z = "NEGATIVE_Z"
+ POSITIVE_X = "POSITIVE_X"
-def _cutout_world_matrix(source_matrix, crop_center):
+def _cutout_world_matrix(source_matrix, crop_center, inward_axis):
from mathutils import Matrix
center_x, center_y = (float(value) for value in crop_center)
- return (
- source_matrix
- @ Matrix.Translation((center_x, center_y, 0.0))
- @ Matrix(CUTOUT_AXIS_TO_SOURCE)
- )
+ matrix = source_matrix @ Matrix.Translation((center_x, center_y, 0.0))
+ if CutoutInwardAxis(inward_axis) == CutoutInwardAxis.POSITIVE_X:
+ matrix @= Matrix(CUTOUT_AXIS_TO_SOURCE)
+ return matrix
+
+
+def orient_object_normal_image(image, inward_axis):
+ """Convert internal +X Cutout normals to the selected object axes."""
+ if CutoutInwardAxis(inward_axis) == CutoutInwardAxis.POSITIVE_X:
+ return image
+ import numpy as np
+
+ pixels = np.empty(int(image.size[0]) * int(image.size[1]) * 4, dtype=np.float32)
+ image.pixels.foreach_get(pixels)
+ rgba = pixels.reshape((-1, 4))
+ normal = rgba[:, :3] * 2.0 - 1.0
+ rgba[:, :3] = np.column_stack(
+ (-normal[:, 1], normal[:, 2], -normal[:, 0])
+ ) * 0.5 + 0.5
+ image.pixels.foreach_set(pixels)
+ image.update()
+ image.pack()
+ return image
def _assign_uv(mesh, uv):
@@ -149,11 +165,13 @@ def create_shape_object(
depth_metadata=None,
normal_image=None,
normal_mode=NormalMode.NONE,
+ inward_axis=CutoutInwardAxis.NEGATIVE_Z,
color_image,
):
import numpy as np
normal_mode = NormalMode(normal_mode)
+ inward_axis = CutoutInwardAxis(inward_axis)
bounds = crop_plane_bounds(
source_object,
content_bounds,
@@ -245,6 +263,7 @@ def create_shape_object(
mesh_object.matrix_world = _cutout_world_matrix(
source_object.matrix_world,
crop_center,
+ inward_axis,
)
mesh_object["anyimage_mesh_shape"] = shape
node_group = load_cutout_node_group()
@@ -253,6 +272,10 @@ def create_shape_object(
modifier.show_group_selector = False
for name, value in (
("Shape", CUTOUT_SHAPE_INDICES[shape]),
+ (
+ "Inward Axis",
+ 0 if inward_axis == CutoutInwardAxis.NEGATIVE_Z else 1,
+ ),
("Cleanup", cleanup_value),
("Smooth Iterations", 0),
("Smooth", 1.0),
diff --git a/src/anyimage/operators/cutout_tool/shape.py b/src/anyimage/operators/cutout_tool/shape.py
index 1e33e69..6ea65e3 100644
--- a/src/anyimage/operators/cutout_tool/shape.py
+++ b/src/anyimage/operators/cutout_tool/shape.py
@@ -2,6 +2,12 @@
BALLOON_ATTRIBUTE_NAME = "o_balloon"
CUTOUT_NODE_GROUP_NAME = "O Image Cutout"
+CUTOUT_AXIS_TO_SOURCE = (
+ (0.0, -1.0, 0.0, 0.0),
+ (0.0, 0.0, 1.0, 0.0),
+ (-1.0, 0.0, 0.0, 0.0),
+ (0.0, 0.0, 0.0, 1.0),
+)
CUTOUT_NODE_GROUP_NAMES = {
"SURFACE": "O Image Surface",
"BALLOON": "O Image Balloon",
diff --git a/src/anyimage/operators/image_tool/__init__.py b/src/anyimage/operators/image_tool/__init__.py
deleted file mode 100644
index 9baae4d..0000000
--- a/src/anyimage/operators/image_tool/__init__.py
+++ /dev/null
@@ -1,19 +0,0 @@
-from .box import ImageBoxTool
-from .edit_mask import ActivateImageTool, EditImageMask, RefineImageSelection
-from .lasso import ImageLassoTool
-from .line import ImageLineTool
-from .polyline import ImagePolylineTool
-
-CLASSES = (
- EditImageMask,
- RefineImageSelection,
- ActivateImageTool,
-)
-
-__all__ = (
- "CLASSES",
- "ImageBoxTool",
- "ImageLassoTool",
- "ImageLineTool",
- "ImagePolylineTool",
-)
diff --git a/src/anyimage/operators/image_tool/box.py b/src/anyimage/operators/image_tool/box.py
deleted file mode 100644
index fa97865..0000000
--- a/src/anyimage/operators/image_tool/box.py
+++ /dev/null
@@ -1,24 +0,0 @@
-import bpy
-
-from ...common.viewport import draw_selection_settings
-from .edit_mask import EditImageMask
-
-
-class ImageBoxTool(bpy.types.WorkSpaceTool):
- bl_space_type = "VIEW_3D"
- bl_context_mode = "OBJECT"
- bl_idname = "anyimage.image_box"
- bl_label = "Image Box"
- bl_description = "Edit an Image Empty with a rectangular mask"
- bl_icon = "ops.sculpt.border_mask"
- bl_operator = EditImageMask.bl_idname
- bl_keymap = (
- (
- EditImageMask.bl_idname,
- {"type": "LEFTMOUSE", "value": "PRESS"},
- {"properties": [("gesture", "BOX")]},
- ),
- )
-
- def draw_settings(context, layout, _tool):
- draw_selection_settings(context, layout, "image_box_refine_selection")
diff --git a/src/anyimage/operators/image_tool/lasso.py b/src/anyimage/operators/image_tool/lasso.py
deleted file mode 100644
index 51a122d..0000000
--- a/src/anyimage/operators/image_tool/lasso.py
+++ /dev/null
@@ -1,24 +0,0 @@
-import bpy
-
-from ...common.viewport import draw_selection_settings
-from .edit_mask import EditImageMask
-
-
-class ImageLassoTool(bpy.types.WorkSpaceTool):
- bl_space_type = "VIEW_3D"
- bl_context_mode = "OBJECT"
- bl_idname = "anyimage.image_lasso"
- bl_label = "Image Lasso"
- bl_description = "Edit an Image Empty with a freehand mask"
- bl_icon = "ops.sculpt.lasso_mask"
- bl_operator = EditImageMask.bl_idname
- bl_keymap = (
- (
- EditImageMask.bl_idname,
- {"type": "LEFTMOUSE", "value": "PRESS"},
- {"properties": [("gesture", "LASSO")]},
- ),
- )
-
- def draw_settings(context, layout, _tool):
- draw_selection_settings(context, layout, "image_lasso_refine_selection")
diff --git a/src/anyimage/operators/image_tool/line.py b/src/anyimage/operators/image_tool/line.py
deleted file mode 100644
index f59edd0..0000000
--- a/src/anyimage/operators/image_tool/line.py
+++ /dev/null
@@ -1,20 +0,0 @@
-import bpy
-
-from .edit_mask import EditImageMask
-
-
-class ImageLineTool(bpy.types.WorkSpaceTool):
- bl_space_type = "VIEW_3D"
- bl_context_mode = "OBJECT"
- bl_idname = "anyimage.image_line"
- bl_label = "Image Line"
- bl_description = "Edit one side of an Image Empty with a line"
- bl_icon = "ops.sculpt.line_mask"
- bl_operator = EditImageMask.bl_idname
- bl_keymap = (
- (
- EditImageMask.bl_idname,
- {"type": "LEFTMOUSE", "value": "PRESS"},
- {"properties": [("gesture", "LINE")]},
- ),
- )
diff --git a/src/anyimage/operators/image_tool/polyline.py b/src/anyimage/operators/image_tool/polyline.py
deleted file mode 100644
index c59c195..0000000
--- a/src/anyimage/operators/image_tool/polyline.py
+++ /dev/null
@@ -1,20 +0,0 @@
-import bpy
-
-from .edit_mask import EditImageMask
-
-
-class ImagePolylineTool(bpy.types.WorkSpaceTool):
- bl_space_type = "VIEW_3D"
- bl_context_mode = "OBJECT"
- bl_idname = "anyimage.image_polyline"
- bl_label = "Image Polyline"
- bl_description = "Edit an Image Empty with a clicked polygon mask"
- bl_icon = "ops.sculpt.polyline_mask"
- bl_operator = EditImageMask.bl_idname
- bl_keymap = (
- (
- EditImageMask.bl_idname,
- {"type": "LEFTMOUSE", "value": "PRESS"},
- {"properties": [("gesture", "POLYLINE")]},
- ),
- )
diff --git a/src/anyimage/operators/perspective_tool/__init__.py b/src/anyimage/operators/perspective_tool/__init__.py
deleted file mode 100644
index 706c328..0000000
--- a/src/anyimage/operators/perspective_tool/__init__.py
+++ /dev/null
@@ -1,6 +0,0 @@
-from .edit_perspective import EditImagePerspective, ImagePerspectiveTool
-
-
-CLASSES = (EditImagePerspective,)
-
-__all__ = ("CLASSES", "ImagePerspectiveTool")
diff --git a/src/anyimage/properties.py b/src/anyimage/properties.py
index 51be2ff..2d8c6bb 100644
--- a/src/anyimage/properties.py
+++ b/src/anyimage/properties.py
@@ -200,6 +200,34 @@ def refine_selection_property():
)
+def crop_invert_property():
+ return bpy.props.BoolProperty(
+ name="Invert",
+ description="Keep the area outside the crop selection",
+ default=False,
+ )
+
+
+def keep_original_property():
+ return bpy.props.BoolProperty(
+ name="Keep Original",
+ description="Create a crop result while preserving the source Image Empty",
+ default=False,
+ )
+
+
+def cutout_inward_axis_property():
+ return bpy.props.EnumProperty(
+ name="Inward Axis",
+ description="Local axis pointing inward from the image sheet",
+ items=(
+ ("NEGATIVE_Z", "-Z", "Match the source Image Empty local axes"),
+ ("POSITIVE_X", "+X", "Use the original Cutout local axes"),
+ ),
+ default="NEGATIVE_Z",
+ )
+
+
def cutout_generate_normal_property():
return bpy.props.BoolProperty(
name="Normal Map",
@@ -229,8 +257,16 @@ class AnyImageSettings(bpy.types.PropertyGroup):
cutout_mesh_detail: cutout_mesh_detail_property()
cutout_generate_normal: cutout_generate_normal_property()
cutout_refine_selection: refine_selection_property()
- image_box_refine_selection: refine_selection_property()
- image_lasso_refine_selection: refine_selection_property()
+ cutout_inward_axis: cutout_inward_axis_property()
+ crop_box_refine_selection: refine_selection_property()
+ crop_box_invert: crop_invert_property()
+ crop_box_keep_original: keep_original_property()
+ crop_lasso_refine_selection: refine_selection_property()
+ crop_lasso_invert: crop_invert_property()
+ crop_lasso_keep_original: keep_original_property()
+ crop_polyline_invert: crop_invert_property()
+ crop_polyline_keep_original: keep_original_property()
+ crop_perspective_keep_original: keep_original_property()
input_path: bpy.props.StringProperty(
name="Image or Video",
description="A single image or video file used for reconstruction",
diff --git a/tests/test_blender_addon.py b/tests/test_blender_addon.py
index 11a34ab..86c4e0d 100644
--- a/tests/test_blender_addon.py
+++ b/tests/test_blender_addon.py
@@ -113,17 +113,16 @@ class BlenderAddonTest(unittest.TestCase):
cls.upscale = importlib.import_module("anyimage.operators.upscale")
cls.image_interaction = importlib.import_module("anyimage.common.viewport")
cls.edit_mask = importlib.import_module(
- "anyimage.operators.image_tool.edit_mask"
+ "anyimage.operators.crop_tool.selection"
)
cls.image_selection = importlib.import_module("anyimage.common.selection")
- cls.image_box = importlib.import_module("anyimage.operators.image_tool.box")
- cls.image_lasso = importlib.import_module("anyimage.operators.image_tool.lasso")
- cls.image_line = importlib.import_module("anyimage.operators.image_tool.line")
+ cls.image_box = importlib.import_module("anyimage.operators.crop_tool.box")
+ cls.image_lasso = importlib.import_module("anyimage.operators.crop_tool.lasso")
cls.image_polyline = importlib.import_module(
- "anyimage.operators.image_tool.polyline"
+ "anyimage.operators.crop_tool.polyline"
)
cls.image_perspective = importlib.import_module(
- "anyimage.operators.perspective_tool.edit_perspective"
+ "anyimage.operators.crop_tool.perspective"
)
cls.cutout_geometry = importlib.import_module(
"anyimage.operators.cutout_tool.geometry"
@@ -1304,6 +1303,7 @@ class BlenderAddonTest(unittest.TestCase):
64,
selection_mask.values,
selection_mask.bounds,
+ inward_axis="NEGATIVE_Z",
color_image="Color",
)
@@ -1852,17 +1852,16 @@ class BlenderAddonTest(unittest.TestCase):
)
def test_image_edit_tools_and_operators_are_registered(self):
- operator = self.edit_mask.EditImageMask
+ operator = self.edit_mask.CropImageSelection
self.assertIn(operator, self.anyimage.CLASSES)
- self.assertIn(self.edit_mask.RefineImageSelection, self.anyimage.CLASSES)
- self.assertIn(self.edit_mask.ActivateImageTool, self.anyimage.CLASSES)
+ self.assertIn(self.edit_mask.RefineCropSelection, self.anyimage.CLASSES)
+ self.assertIn(self.edit_mask.ActivateCropTool, self.anyimage.CLASSES)
self.assertNotIn("selection_path_json", operator.__annotations__)
tool_cases = (
- (self.image_box.ImageBoxTool, "BOX", "ops.sculpt.border_mask"),
- (self.image_lasso.ImageLassoTool, "LASSO", "ops.sculpt.lasso_mask"),
- (self.image_line.ImageLineTool, "LINE", "ops.sculpt.line_mask"),
+ (self.image_box.CropBoxTool, "BOX", "ops.sculpt.border_mask"),
+ (self.image_lasso.CropLassoTool, "LASSO", "ops.sculpt.lasso_mask"),
(
- self.image_polyline.ImagePolylineTool,
+ self.image_polyline.CropPolylineTool,
"POLYLINE",
"ops.sculpt.polyline_mask",
),
@@ -1873,22 +1872,38 @@ class BlenderAddonTest(unittest.TestCase):
self.assertEqual(tool.bl_icon, icon)
self.assertEqual(tool.bl_keymap[0][2]["properties"], [("gesture", gesture)])
self.assertNotIn("bl_cursor", tool.__dict__)
- self.assertIn("draw_settings", self.image_box.ImageBoxTool.__dict__)
- self.assertIn("draw_settings", self.image_lasso.ImageLassoTool.__dict__)
+ self.assertIn("draw_settings", self.image_box.CropBoxTool.__dict__)
+ self.assertIn("draw_settings", self.image_lasso.CropLassoTool.__dict__)
context = SimpleNamespace(scene=SimpleNamespace(anyimage_settings=object()))
drawn_settings = []
layout = SimpleNamespace(
prop=lambda _settings, name: drawn_settings.append(name)
)
- with patch.object(self.image_box, "draw_selection_settings") as draw:
- self.image_box.ImageBoxTool.draw_settings(context, layout, None)
- draw.assert_called_once_with(context, layout, "image_box_refine_selection")
- with patch.object(self.image_lasso, "draw_selection_settings") as draw:
- self.image_lasso.ImageLassoTool.draw_settings(context, layout, None)
- draw.assert_called_once_with(context, layout, "image_lasso_refine_selection")
+ with patch.object(self.image_box, "draw_crop_settings") as draw:
+ self.image_box.CropBoxTool.draw_settings(context, layout, None)
+ draw.assert_called_once_with(
+ context,
+ layout,
+ refine_selection="crop_box_refine_selection",
+ invert="crop_box_invert",
+ keep_original="crop_box_keep_original",
+ )
+ with patch.object(self.image_lasso, "draw_crop_settings") as draw:
+ self.image_lasso.CropLassoTool.draw_settings(context, layout, None)
+ draw.assert_called_once_with(
+ context,
+ layout,
+ refine_selection="crop_lasso_refine_selection",
+ invert="crop_lasso_invert",
+ keep_original="crop_lasso_keep_original",
+ )
cutout_tool = importlib.import_module(
"anyimage.operators.cutout_tool"
).CutoutTool
+ self.assertEqual(
+ cutout_tool.bl_icon,
+ "ops.mesh.primitive_sphere_add_gizmo",
+ )
cutout_module = sys.modules[cutout_tool.__module__]
self.assertNotIn(
"selection_path_json",
@@ -1922,12 +1937,10 @@ class BlenderAddonTest(unittest.TestCase):
"cutout_refine_selection",
"cutout_generate_normal",
"cutout_mesh_detail",
+ "cutout_inward_axis",
],
)
- self.assertNotIn("draw_settings", self.image_line.ImageLineTool.__dict__)
- self.assertNotIn(
- "draw_settings", self.image_polyline.ImagePolylineTool.__dict__
- )
+ self.assertIn("draw_settings", self.image_polyline.CropPolylineTool.__dict__)
self.assertEqual(operator.__annotations__["gesture"]["default"], "BOX")
self.assertIn("invert", operator.__annotations__)
self.assertNotIn("continuous", operator.__annotations__)
@@ -1935,8 +1948,8 @@ class BlenderAddonTest(unittest.TestCase):
settings = self.anyimage.properties.AnyImageSettings.__annotations__
for name in (
"cutout_refine_selection",
- "image_box_refine_selection",
- "image_lasso_refine_selection",
+ "crop_box_refine_selection",
+ "crop_lasso_refine_selection",
):
self.assertFalse(settings[name]["default"])
self.assertNotIn("refine_selection", settings)
@@ -1952,6 +1965,17 @@ class BlenderAddonTest(unittest.TestCase):
],
)
self.assertFalse(settings["cutout_generate_normal"]["default"])
+ self.assertEqual(settings["cutout_inward_axis"]["default"], "NEGATIVE_Z")
+ for name in (
+ "crop_box_invert",
+ "crop_lasso_invert",
+ "crop_polyline_invert",
+ "crop_box_keep_original",
+ "crop_lasso_keep_original",
+ "crop_polyline_keep_original",
+ "crop_perspective_keep_original",
+ ):
+ self.assertFalse(settings[name]["default"])
self.assertEqual(
settings["cutout_refine_selection"]["name"], "Refine Selection"
)
@@ -1999,14 +2023,27 @@ class BlenderAddonTest(unittest.TestCase):
setup = ("operator", "anyimage.setup_ai_environment", True)
with patch.object(self.ai, "ai_status", return_value=missing):
for tool, setting_name in (
- (self.image_box.ImageBoxTool, "image_box_refine_selection"),
- (self.image_lasso.ImageLassoTool, "image_lasso_refine_selection"),
+ (self.image_box.CropBoxTool, "crop_box_refine_selection"),
+ (self.image_lasso.CropLassoTool, "crop_lasso_refine_selection"),
):
layout = Layout()
tool.draw_settings(context, layout, None)
self.assertEqual(
layout.events,
- [setup, ("prop", setting_name, False)],
+ [
+ setup,
+ ("prop", setting_name, False),
+ (
+ "prop",
+ setting_name.replace("refine_selection", "invert"),
+ True,
+ ),
+ (
+ "prop",
+ setting_name.replace("refine_selection", "keep_original"),
+ True,
+ ),
+ ],
)
cutout_tool = importlib.import_module(
@@ -2021,18 +2058,25 @@ class BlenderAddonTest(unittest.TestCase):
("prop", "cutout_refine_selection", False),
("prop", "cutout_generate_normal", False),
("prop", "cutout_mesh_detail", True),
+ ("prop", "cutout_inward_axis", True),
],
)
layout = Layout()
- operator = SimpleNamespace(gesture="BOX")
- self.image_interaction.draw_selection_operator_settings(layout, operator)
+ operator = SimpleNamespace(
+ gesture="BOX",
+ refine_selection_settings={"BOX": "crop_box_refine_selection"},
+ invert_settings={"BOX": "crop_box_invert"},
+ keep_original_settings={"BOX": "crop_box_keep_original"},
+ )
+ self.image_interaction.draw_crop_operator_settings(layout, operator)
self.assertEqual(
layout.events,
[
setup,
("prop", "refine_selection", False),
("prop", "invert", True),
+ ("prop", "keep_original", True),
],
)
@@ -2046,34 +2090,57 @@ class BlenderAddonTest(unittest.TestCase):
def test_refine_selection_is_independent_for_each_area_tool(self):
settings = SimpleNamespace(
cutout_refine_selection=True,
- image_box_refine_selection=False,
- image_lasso_refine_selection=True,
+ crop_box_refine_selection=False,
+ crop_lasso_refine_selection=True,
+ crop_box_invert=False,
+ crop_lasso_invert=True,
+ crop_polyline_invert=False,
+ crop_box_keep_original=True,
+ crop_lasso_keep_original=False,
+ crop_polyline_keep_original=True,
)
context = SimpleNamespace(scene=SimpleNamespace(anyimage_settings=settings))
with patch.object(self.image_interaction, "ai_ready", return_value=True):
- for gesture in ("LINE", "POLYLINE"):
- operator = SimpleNamespace(
- gesture=gesture,
- refine_selection=True,
- )
- self.image_interaction.apply_selection_settings(operator, context)
- self.assertFalse(operator.refine_selection)
-
box = SimpleNamespace(
gesture="BOX",
refine_selection=True,
- refine_selection_settings={"BOX": "image_box_refine_selection"},
+ invert=True,
+ keep_original=False,
+ refine_selection_settings={"BOX": "crop_box_refine_selection"},
+ invert_settings={"BOX": "crop_box_invert"},
+ keep_original_settings={"BOX": "crop_box_keep_original"},
)
- self.image_interaction.apply_selection_settings(box, context)
+ self.image_interaction.apply_crop_settings(box, context)
self.assertFalse(box.refine_selection)
+ self.assertFalse(box.invert)
+ self.assertTrue(box.keep_original)
lasso = SimpleNamespace(
gesture="LASSO",
refine_selection=False,
- refine_selection_settings={"LASSO": "image_lasso_refine_selection"},
+ invert=False,
+ keep_original=True,
+ refine_selection_settings={"LASSO": "crop_lasso_refine_selection"},
+ invert_settings={"LASSO": "crop_lasso_invert"},
+ keep_original_settings={"LASSO": "crop_lasso_keep_original"},
)
- self.image_interaction.apply_selection_settings(lasso, context)
+ self.image_interaction.apply_crop_settings(lasso, context)
self.assertTrue(lasso.refine_selection)
+ self.assertTrue(lasso.invert)
+ self.assertFalse(lasso.keep_original)
+
+ polyline = SimpleNamespace(
+ gesture="POLYLINE",
+ refine_selection=True,
+ invert=True,
+ keep_original=False,
+ invert_settings={"POLYLINE": "crop_polyline_invert"},
+ keep_original_settings={"POLYLINE": "crop_polyline_keep_original"},
+ )
+ self.image_interaction.apply_crop_settings(polyline, context)
+ self.assertFalse(polyline.refine_selection)
+ self.assertFalse(polyline.invert)
+ self.assertTrue(polyline.keep_original)
cutout = SimpleNamespace(
gesture="LASSO",
@@ -2081,9 +2148,38 @@ class BlenderAddonTest(unittest.TestCase):
refine_selection=False,
refine_selection_settings={"LASSO": "cutout_refine_selection"},
)
- self.image_interaction.apply_selection_settings(cutout, context)
+ self.image_interaction.apply_crop_settings(cutout, context)
self.assertTrue(cutout.refine_selection)
+ def test_crop_f_does_not_toggle_invert_or_appear_in_status(self):
+ operator = SimpleNamespace(
+ gesture="BOX",
+ active_tool_id="",
+ invert=True,
+ )
+ context = SimpleNamespace(
+ workspace=SimpleNamespace(status_text_set=Mock())
+ )
+ event = SimpleNamespace(type="F", value="PRESS")
+
+ with patch.object(
+ self.image_interaction,
+ "active_view3d_tool_id",
+ return_value=None,
+ ):
+ result = self.image_interaction.CropSelectionGesture.modal(
+ operator,
+ context,
+ event,
+ )
+ self.image_interaction.CropSelectionGesture._set_status(operator, context)
+
+ self.assertEqual(result, {"PASS_THROUGH"})
+ self.assertTrue(operator.invert)
+ status = context.workspace.status_text_set.call_args.args[0]
+ self.assertIn("Invert On", status)
+ self.assertNotIn("F", status)
+
def test_image_edit_first_click_selects_another_image_without_editing(self):
selected = []
@@ -2421,7 +2517,7 @@ class BlenderAddonTest(unittest.TestCase):
self.assertAlmostEqual(ratio, 2.0)
def test_image_perspective_ignores_an_already_removed_preview(self):
- operator = self.image_perspective.EditImagePerspective()
+ operator = self.image_perspective.CropImagePerspective()
operator._preview_image = object()
def remove(*_args, **_options):
@@ -2442,8 +2538,8 @@ class BlenderAddonTest(unittest.TestCase):
self.assertEqual(bounds, (8.0, 18.0, 32.0, 42.0))
def test_image_perspective_tool_registration_and_properties(self):
- operator = self.image_perspective.EditImagePerspective
- tool = self.image_perspective.ImagePerspectiveTool
+ operator = self.image_perspective.CropImagePerspective
+ tool = self.image_perspective.CropPerspectiveTool
settings = self.anyimage.properties.AnyImageSettings.__annotations__
self.assertIn(operator, self.anyimage.CLASSES)
@@ -2451,7 +2547,8 @@ class BlenderAddonTest(unittest.TestCase):
self.assertEqual(tool.bl_operator, operator.bl_idname)
self.assertEqual(tool.bl_icon, "ops.sculpt.line_project")
self.assertNotIn("bl_cursor", tool.__dict__)
- self.assertNotIn("draw_settings", tool.__dict__)
+ self.assertIn("draw_settings", tool.__dict__)
+ self.assertIn("keep_original", operator.__annotations__)
self.assertNotIn("image_perspective_aspect_mode", settings)
self.assertNotIn("image_perspective_aspect_ratio", settings)
self.assertNotIn("aspect_mode", operator.__annotations__)
@@ -2500,7 +2597,7 @@ class BlenderAddonTest(unittest.TestCase):
return_value=polygon,
),
):
- result = self.image_interaction.ImageSelectionGesture._complete(operator, context)
+ result = self.image_interaction.CropSelectionGesture._complete(operator, context)
self.assertEqual(result, {"FINISHED"})
self.assertEqual(submitted[0][0], context)
@@ -2544,7 +2641,7 @@ class BlenderAddonTest(unittest.TestCase):
) as rasterize,
patch.object(self.edit_mask, "_edit_image_object") as edit,
):
- result = self.edit_mask.EditImageMask._submit_selection_path(
+ result = self.edit_mask.CropImageSelection._submit_selection_path(
operator,
SimpleNamespace(),
selection_path,
@@ -2553,7 +2650,13 @@ class BlenderAddonTest(unittest.TestCase):
self.assertEqual(result, {"FINISHED"})
read_rgba.assert_called_once_with(source.data)
rasterize.assert_called_once()
- edit.assert_called_once_with(source, source_rgba, selection_mask)
+ edit.assert_called_once_with(
+ unittest.mock.ANY,
+ source,
+ source_rgba,
+ selection_mask,
+ keep_original=False,
+ )
def test_image_edit_applies_the_local_selection_mask(self):
import numpy as np
@@ -2597,7 +2700,7 @@ class BlenderAddonTest(unittest.TestCase):
)
refine = Mock(return_value={"FINISHED"})
ops = SimpleNamespace(
- anyimage=SimpleNamespace(refine_image_selection=refine)
+ anyimage=SimpleNamespace(refine_crop_selection=refine)
)
with (
@@ -2624,7 +2727,7 @@ class BlenderAddonTest(unittest.TestCase):
) as prepare_input,
patch.object(self.edit_mask.bpy, "ops", ops, create=True),
):
- result = self.edit_mask.EditImageMask._submit_selection_path(
+ result = self.edit_mask.CropImageSelection._submit_selection_path(
operator,
SimpleNamespace(),
selection_path,
@@ -2655,7 +2758,7 @@ class BlenderAddonTest(unittest.TestCase):
"selection_image": Path(__file__),
}
result = SimpleNamespace(file=lambda key: files[key])
- operator = self.edit_mask.RefineImageSelection()
+ operator = self.edit_mask.RefineCropSelection()
operator.source_object_name = "Source"
operator.selection_bounds_json = "[10,20,18,26]"
ops = SimpleNamespace(
@@ -2673,7 +2776,7 @@ class BlenderAddonTest(unittest.TestCase):
"load_packed_rgba_image",
return_value=refined,
) as load_result,
- patch.object(self.edit_mask, "replace_empty_image") as replace,
+ patch.object(self.edit_mask, "place_empty_image_result") as place,
patch.object(self.edit_mask, "image_rgba") as read_rgba,
patch.object(
self.edit_mask,
@@ -2683,16 +2786,18 @@ class BlenderAddonTest(unittest.TestCase):
):
message = operator.response(None, result)
- self.assertEqual(message, "Image Selection refined")
+ self.assertEqual(message, "Crop Selection refined")
load_result.assert_called_once_with(
Path(__file__),
source_image,
"Poster",
)
- replace.assert_called_once_with(
+ place.assert_called_once_with(
+ None,
source,
refined,
placement_bounds=(12, 21, 15, 24),
+ keep_original=False,
)
read_rgba.assert_not_called()
create_pixels.assert_not_called()
@@ -2832,8 +2937,8 @@ class BlenderAddonTest(unittest.TestCase):
patch.object(self.image_interaction, "draw_polygon_fill") as draw_fill,
patch.object(self.image_interaction, "draw_dashed_line") as draw_outline,
):
- self.image_interaction.ImageSelectionGesture._draw_selection(operator)
- self.image_interaction.ImageSelectionGesture._draw_selection(operator)
+ self.image_interaction.CropSelectionGesture._draw_selection(operator)
+ self.image_interaction.CropSelectionGesture._draw_selection(operator)
build_fill.assert_called_once_with(polygon)
build_outline.assert_called_once_with((*polygon, polygon[0]))
@@ -2867,7 +2972,7 @@ class BlenderAddonTest(unittest.TestCase):
)
)
try:
- result = self.edit_mask.ActivateImageTool().execute(None)
+ result = self.edit_mask.ActivateCropTool().execute(None)
finally:
if previous_ops is None:
del self.fake_bpy.ops
@@ -2877,25 +2982,7 @@ class BlenderAddonTest(unittest.TestCase):
self.assertEqual(result, {"FINISHED"})
self.assertEqual(
calls,
- [{"name": "anyimage.image_box", "space_type": "VIEW_3D"}],
- )
-
- def test_image_line_snaps_and_keeps_the_previewed_half_plane(self):
- import math
-
- endpoint = self.image_interaction.snapped_line_endpoint((0, 0), (10, 1), True)
- self.assertAlmostEqual(math.degrees(math.atan2(endpoint[1], endpoint[0])), 5.0)
- preview = self.image_interaction.line_half_plane_polygon((0, 5), (10, 5), (10, 10))
- image = self.image_interaction.image_line_half_plane_polygon(
- ((0, 5), (10, 5)), (10, 10)
- )
- self.assertEqual(
- self.image_selection.selection_path_bounds(preview),
- (0, 5, 10, 10),
- )
- self.assertEqual(
- self.image_selection.selection_path_bounds(image),
- (0, 0, 10, 5),
+ [{"name": "anyimage.crop_box", "space_type": "VIEW_3D"}],
)
def test_image_operators_stay_clickable_until_server_is_busy(self):
@@ -3043,47 +3130,36 @@ class BlenderAddonTest(unittest.TestCase):
self.registered_tools,
[
(
- self.image_box.ImageBoxTool,
+ self.image_box.CropBoxTool,
{
"group": True,
"separator": True,
},
),
(
- self.image_lasso.ImageLassoTool,
+ self.image_lasso.CropLassoTool,
{
- "after": {self.image_box.ImageBoxTool.bl_idname},
+ "after": {self.image_box.CropBoxTool.bl_idname},
"separator": False,
},
),
(
- self.image_line.ImageLineTool,
+ self.image_polyline.CropPolylineTool,
{
- "after": {self.image_lasso.ImageLassoTool.bl_idname},
+ "after": {self.image_lasso.CropLassoTool.bl_idname},
"separator": False,
},
),
(
- self.image_polyline.ImagePolylineTool,
- {
- "after": {self.image_line.ImageLineTool.bl_idname},
- "separator": False,
- },
- ),
- (
- self.image_perspective.ImagePerspectiveTool,
+ self.image_perspective.CropPerspectiveTool,
{
+ "after": {self.image_polyline.CropPolylineTool.bl_idname},
"separator": False,
},
),
(
self.anyimage.CutoutTool,
- {
- "after": {
- self.image_perspective.ImagePerspectiveTool.bl_idname
- },
- "separator": True,
- },
+ {},
),
],
)
diff --git a/tests/test_cutout_interaction.py b/tests/test_cutout_interaction.py
index cdaa17a..51c2c60 100644
--- a/tests/test_cutout_interaction.py
+++ b/tests/test_cutout_interaction.py
@@ -7,7 +7,8 @@ SOURCE_ROOT = Path(__file__).parents[1] / "src"
sys.path.insert(0, str(SOURCE_ROOT))
from anyimage.common.selection import SelectionPath
-from anyimage.common.viewport import ImageSelectionGesture
+from anyimage.common import viewport
+from anyimage.common.viewport import CropSelectionGesture
from anyimage.operators.cutout_tool import interaction
from anyimage.operators.cutout_tool import main as cutout_main
@@ -31,10 +32,16 @@ class WindowManager:
draw(menu, None)
-def cutout_context(window_manager, mesh_detail="LOW", generate_normal=False):
+def cutout_context(
+ window_manager,
+ mesh_detail="LOW",
+ generate_normal=False,
+ inward_axis="NEGATIVE_Z",
+):
settings = SimpleNamespace(
cutout_mesh_detail=mesh_detail,
cutout_generate_normal=generate_normal,
+ cutout_inward_axis=inward_axis,
)
return SimpleNamespace(
window_manager=window_manager,
@@ -75,11 +82,17 @@ def test_cutout_pie_uses_two_local_shapes_without_ai(monkeypatch):
assert not any(call[2].refine_selection for call in window_manager.pie.calls)
assert not any(call[2].generate_normal for call in window_manager.pie.calls)
assert all(call[2].mesh_resolution == 64 for call in window_manager.pie.calls)
+ assert all(call[2].inward_axis == "NEGATIVE_Z" for call in window_manager.pie.calls)
def test_cutout_pie_passes_image_lasso_options_to_all_ai_shapes(monkeypatch):
window_manager = WindowManager()
- context = cutout_context(window_manager, mesh_detail="MEDIUM", generate_normal=True)
+ context = cutout_context(
+ window_manager,
+ mesh_detail="MEDIUM",
+ generate_normal=True,
+ inward_axis="POSITIVE_X",
+ )
source = cutout_source(size=(4096, 2048))
monkeypatch.setattr(interaction, "ai_ready", lambda: True)
serialized = []
@@ -122,6 +135,7 @@ def test_cutout_pie_passes_image_lasso_options_to_all_ai_shapes(monkeypatch):
for call in window_manager.pie.calls
)
assert all(call[2].mesh_resolution == 256 for call in window_manager.pie.calls)
+ assert all(call[2].inward_axis == "POSITIVE_X" for call in window_manager.pie.calls)
def test_cutout_normal_space_is_derived_from_shape():
@@ -155,7 +169,7 @@ def test_cutout_reuses_image_lasso_gesture_and_double_click_selects_all(monkeypa
)
monkeypatch.setattr(cutout_main, "video_total_frames", lambda _image: 1)
monkeypatch.setattr(cutout_main, "require_image_empty", lambda _name: source)
- monkeypatch.setattr(cutout_main, "apply_selection_settings", lambda *_args: None)
+ monkeypatch.setattr(cutout_main, "apply_crop_settings", lambda *_args: None)
monkeypatch.setattr(cutout_main, "serialize_matrix", lambda _matrix: "matrix")
monkeypatch.setattr(
cutout_main,
@@ -184,7 +198,7 @@ def test_cutout_reuses_image_lasso_gesture_and_double_click_selects_all(monkeypa
operator, context, SimpleNamespace(value="DOUBLE_CLICK")
)
- assert issubclass(cutout_main.SelectCutoutSelection, ImageSelectionGesture)
+ assert issubclass(cutout_main.SelectCutoutSelection, CropSelectionGesture)
assert result == {"FINISHED"}
selection_path = SelectionPath.from_json(window_manager.pie.calls[0][2].selection_path_json)
assert selection_path.points == (
@@ -200,3 +214,36 @@ def test_cutout_reuses_image_lasso_gesture_and_double_click_selects_all(monkeypa
def test_cutout_tool_prioritizes_double_click_before_lasso_press():
assert cutout_main.CutoutTool.bl_keymap[0][1]["value"] == "DOUBLE_CLICK"
assert cutout_main.CutoutTool.bl_keymap[1][1]["value"] == "PRESS"
+
+
+def test_cutout_f_does_not_toggle_invert(monkeypatch):
+ operator = SimpleNamespace(
+ gesture="LASSO",
+ active_tool_id="anyimage.cutout_lasso",
+ invert=False,
+ )
+ monkeypatch.setattr(viewport, "active_view3d_tool_id", lambda _context: None)
+
+ result = cutout_main.SelectCutoutSelection.modal(
+ operator,
+ SimpleNamespace(),
+ SimpleNamespace(type="F", value="PRESS"),
+ )
+
+ assert result == {"PASS_THROUGH"}
+ assert operator.invert is False
+
+
+def test_cutout_status_has_no_letter_shortcut():
+ status = []
+ operator = SimpleNamespace()
+ context = SimpleNamespace(
+ workspace=SimpleNamespace(status_text_set=status.append)
+ )
+
+ cutout_main.SelectCutoutSelection._set_status(operator, context)
+
+ assert status == [
+ "Cutout Lasso: drag and release for shapes; RMB or Esc cancels"
+ ]
+ assert "F" not in status[0]
diff --git a/tests/test_cutout_objects.py b/tests/test_cutout_objects.py
index 6b8b62d..dd8caf7 100644
--- a/tests/test_cutout_objects.py
+++ b/tests/test_cutout_objects.py
@@ -60,6 +60,7 @@ def _create_cutout_shape(
depth_image=None,
depth_metadata=None,
selection_values=None,
+ inward_axis=cutout_object.CutoutInwardAxis.POSITIVE_X,
):
if bounds is None:
bounds = (0, 0, *tuple(int(value) for value in source.data.size))
@@ -90,6 +91,7 @@ def _create_cutout_shape(
base_shape,
depth_image=depth_image,
depth_metadata=depth_metadata,
+ inward_axis=inward_axis,
color_image=color_image,
)
@@ -123,6 +125,101 @@ def _depth_metadata(intrinsics, image_size, reference_depth):
}
+def test_keep_original_places_and_activates_result_copy():
+ bpy.ops.wm.read_factory_settings(use_empty=True)
+ source_image = bpy.data.images.new("Crop Source", width=8, height=4, alpha=True)
+ result_image = bpy.data.images.new("Crop Result", width=4, height=2, alpha=True)
+ source = bpy.data.objects.new("Crop Source", None)
+ source.empty_display_type = "IMAGE"
+ source.data = source_image
+ source.empty_display_size = 3.0
+ source.empty_image_offset = (0.2, -0.1)
+ source.matrix_world = Matrix.Translation((1.0, 2.0, 3.0))
+ bpy.context.collection.objects.link(source)
+ source.select_set(True)
+ bpy.context.view_layer.objects.active = source
+
+ placed = image_data.place_empty_image_result(
+ bpy.context,
+ source,
+ result_image,
+ placement_bounds=(2, 1, 6, 3),
+ keep_original=True,
+ )
+
+ assert placed is not source
+ assert source.data == source_image
+ assert placed.data == result_image
+ assert placed.users_collection[:] == source.users_collection[:]
+ assert np.allclose(placed.matrix_world, source.matrix_world)
+ assert not source.select_get()
+ assert placed.select_get()
+ assert bpy.context.view_layer.objects.active == placed
+
+
+def test_object_normal_image_follows_cutout_inward_axis():
+ bpy.ops.wm.read_factory_settings(use_empty=True)
+ image = bpy.data.images.new(
+ "Object Normal",
+ width=1,
+ height=1,
+ alpha=True,
+ float_buffer=True,
+ )
+ decoded = np.asarray((0.2, -0.4, 0.8), dtype=np.float32)
+ encoded = decoded * 0.5 + 0.5
+ image.pixels.foreach_set((*encoded, 0.3))
+
+ cutout_object.orient_object_normal_image(
+ image,
+ cutout_object.CutoutInwardAxis.NEGATIVE_Z,
+ )
+
+ actual = np.empty(4, dtype=np.float32)
+ image.pixels.foreach_get(actual)
+ expected = np.asarray((0.4, 0.8, -0.2), dtype=np.float32) * 0.5 + 0.5
+ assert np.allclose(actual[:3], expected, atol=1e-6)
+ assert np.isclose(actual[3], 0.3)
+
+
+@pytest.mark.parametrize("shape", ("SURFACE", "BALLOON"))
+def test_cutout_axes_produce_the_same_world_geometry(shape):
+ bpy.ops.wm.read_factory_settings(use_empty=True)
+ image = bpy.data.images.new("Axis Source", width=32, height=16, alpha=True)
+ source = bpy.data.objects.new("Axis Source", None)
+ source.empty_display_type = "IMAGE"
+ source.data = image
+ source.empty_display_size = 2.0
+ source.matrix_world = (
+ Matrix.Translation((2.0, -3.0, 1.0))
+ @ Matrix.Rotation(0.35, 4, "Y")
+ @ Matrix.Diagonal((1.5, 0.75, 2.0, 1.0))
+ )
+ bpy.context.collection.objects.link(source)
+
+ positive_x = _create_cutout_shape(
+ source,
+ shape,
+ 8,
+ inward_axis=cutout_object.CutoutInwardAxis.POSITIVE_X,
+ )
+ negative_z = _create_cutout_shape(
+ source,
+ shape,
+ 8,
+ inward_axis=cutout_object.CutoutInwardAxis.NEGATIVE_Z,
+ )
+
+ def world_positions(obj):
+ local = _evaluated_positions(obj)
+ return np.asarray([tuple(obj.matrix_world @ Vector(point)) for point in local])
+
+ expected = np.asarray(sorted(map(tuple, world_positions(positive_x))))
+ actual = np.asarray(sorted(map(tuple, world_positions(negative_z))))
+ assert np.allclose(actual, expected, atol=2e-6)
+ assert np.allclose(negative_z.matrix_world, source.matrix_world)
+
+
def test_depth_result_image_loads_server_vector_exr_and_keeps_data_name(tmp_path):
bpy.ops.wm.read_factory_settings(use_empty=True)
source_image = bpy.data.images.new("Source.png", width=2, height=2)
@@ -202,6 +299,7 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups():
assert _inputs(cutout) == [
"Geometry",
"Shape",
+ "Inward Axis",
"Thickness",
"Depth Scale",
"Double Sided",
@@ -1024,6 +1122,11 @@ def _add_cutout_modifier(obj, shape):
object_data.modifier_input_identifier(group, "Shape"),
cutout_shape.CUTOUT_SHAPE_INDICES[shape],
)
+ object_data.set_modifier_input(
+ modifier,
+ object_data.modifier_input_identifier(group, "Inward Axis"),
+ 1,
+ )
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(group, "Cleanup"),
diff --git a/tests/test_extension_packaging.py b/tests/test_extension_packaging.py
index 98e681c..d6dc93f 100644
--- a/tests/test_extension_packaging.py
+++ b/tests/test_extension_packaging.py
@@ -77,14 +77,12 @@ class ExtensionPackagingTest(unittest.TestCase):
"operators/convert_to_plane/object.py",
"operators/convert_to_plane/conversion.py",
"operators/convert_to_plane/mesh_plane.py",
- "operators/image_tool/__init__.py",
- "operators/image_tool/edit_mask.py",
- "operators/image_tool/box.py",
- "operators/image_tool/lasso.py",
- "operators/image_tool/line.py",
- "operators/image_tool/polyline.py",
- "operators/perspective_tool/__init__.py",
- "operators/perspective_tool/edit_perspective.py",
+ "operators/crop_tool/__init__.py",
+ "operators/crop_tool/selection.py",
+ "operators/crop_tool/box.py",
+ "operators/crop_tool/lasso.py",
+ "operators/crop_tool/polyline.py",
+ "operators/crop_tool/perspective.py",
"operators/ai_setup.py",
"operators/remove_background.py",
):
diff --git a/tools/node_assets/build_cutout.py b/tools/node_assets/build_cutout.py
index ac2a864..0f94e62 100644
--- a/tools/node_assets/build_cutout.py
+++ b/tools/node_assets/build_cutout.py
@@ -6,6 +6,7 @@ from node_graph import expand_smooth
from anyimage.operators.cutout_tool.shape import (
BALLOON_ATTRIBUTE_NAME,
+ CUTOUT_AXIS_TO_SOURCE,
CUTOUT_NODE_GROUP_NAME,
CUTOUT_NODE_GROUP_NAMES,
CUTOUT_SHAPE_LABELS,
@@ -834,6 +835,8 @@ def build_cutout_modifier_group(shape_groups):
group.is_modifier = True
_socket(group, "Geometry", "INPUT", "NodeSocketGeometry")
_socket(group, "Shape", "INPUT", "NodeSocketMenu")
+ inward_axis = _socket(group, "Inward Axis", "INPUT", "NodeSocketMenu")
+ inward_axis.hide_in_modifier = True
thickness = _float_input(group, "Thickness", 0.0, 0.0)
thickness.description = (
@@ -968,7 +971,27 @@ def build_cutout_modifier_group(shape_groups):
menu_switch.outputs["Output"],
iterations_name="Smooth Iterations",
)
- _shade_output(group, geometry, (3350.0, 550.0))
+ from mathutils import Matrix
+
+ orient = group.nodes.new("GeometryNodeTransform")
+ orient.inputs["Rotation"].default_value = Matrix(CUTOUT_AXIS_TO_SOURCE).to_euler()
+ orient.location = (3300.0, 350.0)
+ group.links.new(geometry, orient.inputs["Geometry"])
+
+ axis_switch = group.nodes.new("GeometryNodeMenuSwitch")
+ axis_switch.data_type = "GEOMETRY"
+ axis_switch.enum_items[0].name = "-Z"
+ axis_switch.enum_items[1].name = "+X"
+ axis_switch.location = (3550.0, 550.0)
+ axis_controls = _group_input(
+ group.nodes,
+ (3300.0, -650.0),
+ {"Inward Axis"},
+ )
+ group.links.new(axis_controls.outputs["Inward Axis"], axis_switch.inputs["Menu"])
+ group.links.new(orient.outputs["Geometry"], axis_switch.inputs["-Z"])
+ group.links.new(geometry, axis_switch.inputs["+X"])
+ _shade_output(group, axis_switch.outputs["Output"], (3850.0, 550.0))
group.color_tag = "GEOMETRY"
group.description = (
"Build Surface, Balloon, Depth Balloon, or Depth Surface from one "
diff --git a/tools/node_assets/validate.py b/tools/node_assets/validate.py
index fdffe2a..91d8fe1 100644
--- a/tools/node_assets/validate.py
+++ b/tools/node_assets/validate.py
@@ -116,7 +116,12 @@ def main():
assert cutout.name == CUTOUT_NODE_GROUP_NAME
assert cutout.bl_idname == "GeometryNodeTree"
assert cutout.is_modifier
- assert input_names(cutout)[:3] == ["Geometry", "Shape", "Thickness"]
+ assert input_names(cutout)[:4] == [
+ "Geometry",
+ "Shape",
+ "Inward Axis",
+ "Thickness",
+ ]
assert set(cutout_groups) == set(CUTOUT_NODE_GROUP_NAMES.values())
for group in cutout_groups.values():
assert group.bl_idname == "GeometryNodeTree"
diff --git a/tools/node_assets/validate_cutout.py b/tools/node_assets/validate_cutout.py
index babf52e..78918a1 100644
--- a/tools/node_assets/validate_cutout.py
+++ b/tools/node_assets/validate_cutout.py
@@ -56,6 +56,7 @@ def add_modifier(obj, shape, thickness):
modifier.node_group = group
for name, value in (
("Shape", shapes.CUTOUT_SHAPE_INDICES[shape]),
+ ("Inward Axis", 1),
("Cleanup", 0.0),
("Smooth", 0.0),
("Smooth Iterations", 0),
@@ -96,6 +97,25 @@ def main():
and link.to_socket.name == "Menu"
for link in wrapper.links
)
+ axis_switch = next(
+ node
+ for node in wrapper.nodes
+ if node.bl_idname == "GeometryNodeMenuSwitch"
+ and [item.name for item in node.enum_items] == ["-Z", "+X"]
+ )
+ assert any(
+ link.from_socket.name == "Inward Axis"
+ and link.to_node == axis_switch
+ and link.to_socket.name == "Menu"
+ for link in wrapper.links
+ )
+ assert all(
+ any(
+ link.to_node == axis_switch and link.to_socket.name == axis
+ for link in wrapper.links
+ )
+ for axis in ("-Z", "+X")
+ )
child_nodes = {
node.node_tree: node
for node in wrapper.nodes
@@ -113,6 +133,7 @@ def main():
assert input_names(wrapper) == [
"Geometry",
"Shape",
+ "Inward Axis",
"Thickness",
"Depth Scale",
"Double Sided",
diff --git a/uv.lock b/uv.lock
index 3d3fac2..5c39b8a 100644
--- a/uv.lock
+++ b/uv.lock
@@ -13,7 +13,7 @@ wheels = [
[[package]]
name = "anyimage"
-version = "0.12.15"
+version = "0.13.0"
source = { editable = "." }
dependencies = [
{ name = "blendjob" },
@@ -111,9 +111,10 @@ wheels = [
[[package]]
name = "blendjob"
version = "0.1.16"
-source = { registry = "src/anyimage/wheels" }
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/33/97/bcbf9be2effd238a3caaa647fc202d7e29ff7370c1ca22d6a78e48fa4134/blendjob-0.1.16.tar.gz", hash = "sha256:16ead42a046f9c8eb91bc4c3d4803af366f2b56b63bd16f302190ec60ef23a99", size = 21571, upload-time = "2026-09-02T02:50:03.715Z" }
wheels = [
- { path = "blendjob-0.1.16-py3-none-any.whl" },
+ { url = "https://files.pythonhosted.org/packages/db/ac/142988a33d0a2df6335a0e08f5e9a0a9215996810a77f9724325f9174301/blendjob-0.1.16-py3-none-any.whl", hash = "sha256:39fb736f0c2b77321a672b2b023e4b1d0d49c2d567bdd8f6095b6efe5080f982", size = 26931, upload-time = "2026-09-02T02:50:02.777Z" },
]
[[package]]