feat: Enhance AI integration for Cutout, Image Box, and Image Lasso tools with unified settings and improved shape selection
This commit is contained in:
2
openspec/changes/gate-cutout-ai-options/.openspec.yaml
Normal file
2
openspec/changes/gate-cutout-ai-options/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-31
|
||||
46
openspec/changes/gate-cutout-ai-options/design.md
Normal file
46
openspec/changes/gate-cutout-ai-options/design.md
Normal file
@@ -0,0 +1,46 @@
|
||||
## Context
|
||||
|
||||
Cutout、Image Box 和 Image Lasso 都通过 View3D WorkSpaceTool 设置暴露 `Fit to Foreground`。共享绘制函数当前先绘制禁用的属性,再在末尾追加 AI 设置按钮;Cutout 还直接绘制 `Generate Normal Map`,并始终在 shape pie 中提供两个本地 shape 和两个 Depth shape。
|
||||
|
||||
AI 可用性已经由 `ai_status()` / `ai_ready()` 统一表示,包含运行环境和所需模型状态。实现应继续复用该状态,不引入第二套环境判定。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- 三个工具在 AI 未就绪时都将 AI 设置入口放在工具设置开头。
|
||||
- 所有依赖 AI 的属性在未就绪时不可编辑,且遗留值不会触发 AI job。
|
||||
- Cutout shape pie 根据 AI 可用性展示两个或四个候选。
|
||||
- 保持共享绘制和状态判定逻辑集中,便于 Image Box、Image Lasso 与 Cutout 复用。
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- 不删除或重构四个 Cutout shape 及其节点资产。
|
||||
- 不改变 AI 环境安装流程、模型目录、job 协议或生成算法。
|
||||
- 不调整 Image Line 和 Image Polyline;它们没有 `Fit to Foreground` 设置。
|
||||
|
||||
## Decisions
|
||||
|
||||
### 复用单一 AI 状态绘制一组设置
|
||||
|
||||
在共享图像交互模块中拆出小型绘制函数:同一个 `ai_status()` 结果先决定是否绘制设置按钮,再决定相关属性行是否启用。Cutout 使用这些函数组合 `Fit to Foreground`、本地 mesh spacing 和 `Generate Normal Map`;Image Box 与 Image Lasso 继续通过共享区域设置入口绘制。
|
||||
|
||||
相比每个工具分别查询状态和创建按钮,这能保证按钮顺序、文案与禁用条件一致,也避免一次绘制中状态变化造成不一致。
|
||||
|
||||
### shape pie 在创建时按 AI 可用性过滤
|
||||
|
||||
`open_shape_pie` 在读取 Cutout 工具设置时查询一次 `ai_ready()`:未就绪时只创建 `Surface` 和 `Balloon` 按钮;就绪时再追加 `Depth Balloon` 和 `Depth Surface`。四个 shape 的定义、索引和执行支持保持不变。
|
||||
|
||||
相比禁用两个 Depth 按钮,直接隐藏能把无 AI 环境时的选择明确收敛为两个可执行结果,并符合用户期望的两项菜单。
|
||||
|
||||
### 在交互边界清除不可用的 AI 选项
|
||||
|
||||
传给 Cutout operator 的 `generate_normal` 必须同时满足设置为真和 `ai_ready()`。`Fit to Foreground` 继续通过共享 `apply_region_settings` 做相同门控。这样即使 `.blend` 文件或先前会话保留了真值,选择本地 shape 也不会弹出 AI 安装流程。
|
||||
|
||||
相比在 operator 执行阶段静默改写参数,在设置读取边界门控更直接,并使 pie 按钮携带的参数准确表达实际操作。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [AI 状态查询可能涉及模型状态扫描] → 每个设置绘制或 shape pie 打开过程只查询一次并复用结果。
|
||||
- [隐藏 Depth shape 后用户可能不知道完整能力] → 设置开头持续显示带有当前缺失状态文案的 AI 设置按钮。
|
||||
- [旧文件保留已勾选的 AI 属性] → UI 置灰且交互参数显式与 `ai_ready()` 合取,不依赖用户手动清除。
|
||||
26
openspec/changes/gate-cutout-ai-options/proposal.md
Normal file
26
openspec/changes/gate-cutout-ai-options/proposal.md
Normal file
@@ -0,0 +1,26 @@
|
||||
## Why
|
||||
|
||||
Cutout 的 AI 依赖目前只在 `Fit to Foreground` 附近提示,无法覆盖 `Generate Normal Map` 和两个 Depth shape,用户可能在选择后才发现环境不可用。Image Box 与 Image Lasso 的同类入口位置也不一致,需要统一为可预期的工具设置体验。
|
||||
|
||||
## What Changes
|
||||
|
||||
- AI 环境未就绪时,在 Cutout、Image Box 和 Image Lasso 的工具设置开头显示 AI 环境设置按钮。
|
||||
- AI 环境未就绪时,禁用 `Fit to Foreground`;Cutout 同时禁用 `Generate Normal Map`。
|
||||
- AI 环境未就绪时,Cutout shape 菜单只显示不依赖 AI 的 `Surface` 和 `Balloon`;环境就绪后显示包含两个 Depth 模式的四个 shape。
|
||||
- Cutout 读取设置时忽略 AI 环境未就绪状态下遗留的 AI 选项值,确保本地 shape 不触发 AI job。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `image-tool-ai-availability`: 定义 Cutout、Image Box 和 Image Lasso 根据 AI 环境可用性呈现设置与可执行模式的行为。
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
无。
|
||||
|
||||
## Impact
|
||||
|
||||
- 影响 Blender View3D 工具设置绘制与区域工具共享交互逻辑。
|
||||
- 影响 Cutout shape pie 的候选项与设置值传递。
|
||||
- 不新增依赖,不改变服务器协议、节点资产或已有 shape 实现。
|
||||
@@ -0,0 +1,53 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: AI setup entry precedes dependent tool settings
|
||||
当 AI 环境或所需模型未就绪时,系统 SHALL 在 Cutout、Image Box 和 Image Lasso 的工具设置开头显示 AI 设置按钮,并使用当前 AI 状态对应的设置文案。
|
||||
|
||||
#### Scenario: Cutout AI setup entry is first
|
||||
- **WHEN** AI 未就绪且用户激活 Cutout 工具
|
||||
- **THEN** 工具设置 SHALL 先显示 AI 设置按钮,再显示 `Fit to Foreground`、`Mesh Sampling Spacing` 和 `Generate Normal Map`
|
||||
|
||||
#### Scenario: Image region tool AI setup entry is first
|
||||
- **WHEN** AI 未就绪且用户激活 Image Box 或 Image Lasso
|
||||
- **THEN** 工具设置 SHALL 先显示 AI 设置按钮,再显示 `Fit to Foreground`
|
||||
|
||||
#### Scenario: AI setup entry is omitted when ready
|
||||
- **WHEN** AI 环境和所需模型均已就绪
|
||||
- **THEN** Cutout、Image Box 和 Image Lasso 的工具设置 SHALL 不显示 AI 设置按钮
|
||||
|
||||
### Requirement: AI-dependent settings follow AI availability
|
||||
系统 SHALL 仅在 AI 环境和所需模型均已就绪时允许启用图像区域工具的 AI 依赖设置。
|
||||
|
||||
#### Scenario: Dependent settings are disabled without AI
|
||||
- **WHEN** AI 未就绪
|
||||
- **THEN** Cutout 的 `Fit to Foreground` 和 `Generate Normal Map` SHALL 不可编辑
|
||||
- **THEN** Image Box 和 Image Lasso 的 `Fit to Foreground` SHALL 不可编辑
|
||||
- **THEN** Cutout 的 `Mesh Sampling Spacing` SHALL 保持可编辑
|
||||
|
||||
#### Scenario: Dependent settings are enabled with AI
|
||||
- **WHEN** AI 环境和所需模型均已就绪
|
||||
- **THEN** Cutout 的 `Fit to Foreground` 和 `Generate Normal Map` SHALL 可编辑
|
||||
- **THEN** Image Box 和 Image Lasso 的 `Fit to Foreground` SHALL 可编辑
|
||||
|
||||
### Requirement: Cutout shape choices follow AI availability
|
||||
系统 SHALL 根据 AI 可用性限制 Cutout shape pie 中的可选 shape,同时保留四种 shape 的底层实现。
|
||||
|
||||
#### Scenario: Two local shapes without AI
|
||||
- **WHEN** AI 未就绪且用户完成 Cutout 区域选择
|
||||
- **THEN** shape pie SHALL 只显示 `Surface` 和 `Balloon`
|
||||
|
||||
#### Scenario: Four shapes with AI
|
||||
- **WHEN** AI 环境和所需模型均已就绪且用户完成 Cutout 区域选择
|
||||
- **THEN** shape pie SHALL 显示 `Surface`、`Balloon`、`Depth Balloon` 和 `Depth Surface`
|
||||
|
||||
#### Scenario: Runtime RNA menu shape assignment
|
||||
- **WHEN** a displayed Cutout shape is selected on a Blender version that exposes modifier inputs through runtime RNA
|
||||
- **THEN** the system SHALL assign the matching string enum identifier and create the shape successfully
|
||||
|
||||
### Requirement: Stale AI settings do not trigger generation
|
||||
系统 MUST 在区域交互读取设置时忽略 AI 未就绪状态下遗留为真的 AI 依赖选项。
|
||||
|
||||
#### Scenario: Local Cutout remains local with stale settings
|
||||
- **WHEN** AI 未就绪、`Fit to Foreground` 或 `Generate Normal Map` 的保存值为真,且用户选择 `Surface` 或 `Balloon`
|
||||
- **THEN** Cutout operator SHALL 接收关闭的 AI 依赖选项
|
||||
- **THEN** 操作 SHALL 不因这些遗留值启动 AI job 或打开 AI 设置流程
|
||||
21
openspec/changes/gate-cutout-ai-options/tasks.md
Normal file
21
openspec/changes/gate-cutout-ai-options/tasks.md
Normal file
@@ -0,0 +1,21 @@
|
||||
## 1. 统一 AI 设置绘制
|
||||
|
||||
- [x] 1.1 在共享图像交互模块中提取可复用的 AI 设置入口和 AI 依赖属性绘制函数,单次绘制复用同一状态
|
||||
- [x] 1.2 调整 Image Box、Image Lasso 及其区域 operator 设置,使未就绪时的 AI 设置按钮位于依赖属性之前
|
||||
- [x] 1.3 调整 Cutout 工具设置顺序,使 AI 设置入口开头只出现一次,并分别门控 `Fit to Foreground` 与 `Generate Normal Map`,保持 mesh spacing 可用
|
||||
|
||||
## 2. 门控 Cutout 交互
|
||||
|
||||
- [x] 2.1 根据 `ai_ready()` 在 Cutout shape pie 中选择两个本地 shape 或完整四个 shape
|
||||
- [x] 2.2 在 Cutout 交互读取边界忽略 AI 未就绪时遗留的 `Generate Normal Map` 真值,并保持 `Fit to Foreground` 的既有门控
|
||||
|
||||
## 3. 验证行为
|
||||
|
||||
- [x] 3.1 补充共享工具设置测试,验证 AI 设置按钮顺序、依赖属性启用状态以及 Cutout 非 AI 属性可用性
|
||||
- [x] 3.2 补充 Cutout pie 测试,验证 AI 未就绪时两个 shape、就绪时四个 shape及遗留 AI 设置不会传入 operator
|
||||
- [x] 3.3 运行相关 Blender addon 与 Cutout interaction 测试并确认通过
|
||||
|
||||
## 4. Blender 5.2 Enum Compatibility
|
||||
|
||||
- [x] 4.1 Convert integer menu indices to string enum identifiers for runtime RNA modifier inputs while preserving the legacy ID property path
|
||||
- [x] 4.2 Add regression tests for runtime enum and non-enum modifier inputs, then run the related test suite
|
||||
@@ -161,35 +161,36 @@ def apply_region_settings(operator, context):
|
||||
)
|
||||
|
||||
|
||||
def draw_ai_setup(layout):
|
||||
status = ai_status()
|
||||
if not status["ready"]:
|
||||
layout.operator(
|
||||
"anyimage.setup_ai_environment",
|
||||
text=ai_setup_label(status),
|
||||
icon="IMPORT",
|
||||
)
|
||||
return status
|
||||
|
||||
|
||||
def draw_ai_property(layout, owner, name, status):
|
||||
row = layout.row()
|
||||
row.enabled = status["ready"]
|
||||
row.prop(owner, name)
|
||||
|
||||
|
||||
def draw_region_operator_settings(layout, operator):
|
||||
if operator.gesture not in {"BOX", "LASSO"}:
|
||||
layout.prop(operator, "invert")
|
||||
return
|
||||
status = ai_status()
|
||||
row = layout.row()
|
||||
row.enabled = status["ready"]
|
||||
row.prop(operator, "refine_region")
|
||||
status = draw_ai_setup(layout)
|
||||
draw_ai_property(layout, operator, "refine_region", status)
|
||||
layout.prop(operator, "invert")
|
||||
if not status["ready"]:
|
||||
layout.operator(
|
||||
"anyimage.setup_ai_environment",
|
||||
text=ai_setup_label(status),
|
||||
icon="IMPORT",
|
||||
)
|
||||
|
||||
|
||||
def draw_region_settings(context, layout, setting_name):
|
||||
settings = context.scene.anyimage_settings
|
||||
status = ai_status()
|
||||
row = layout.row()
|
||||
row.enabled = status["ready"]
|
||||
row.prop(settings, setting_name)
|
||||
if not status["ready"]:
|
||||
layout.operator(
|
||||
"anyimage.setup_ai_environment",
|
||||
text=ai_setup_label(status),
|
||||
icon="IMPORT",
|
||||
)
|
||||
status = draw_ai_setup(layout)
|
||||
draw_ai_property(layout, settings, setting_name, status)
|
||||
|
||||
|
||||
def dashed_line_segments(vertices, dash_length=4.0, gap_length=4.0):
|
||||
|
||||
@@ -31,7 +31,11 @@ def set_modifier_input(modifier, identifier, value):
|
||||
inputs = _modifier_input_slots(modifier)
|
||||
if inputs is not None:
|
||||
# Blender 5.2+ exposes inputs as runtime RNA keyed by socket identifier.
|
||||
getattr(inputs, identifier).value = value
|
||||
input_slot = getattr(inputs, identifier)
|
||||
value_property = input_slot.bl_rna.properties["value"]
|
||||
if value_property.type == "ENUM" and type(value) is int:
|
||||
value = value_property.enum_items[value].identifier
|
||||
input_slot.value = value
|
||||
else:
|
||||
# Blender 4.x exposes inputs as modifier ID properties.
|
||||
modifier[identifier] = value
|
||||
|
||||
@@ -75,7 +75,7 @@ def acquire_packed_image(image_data, suffix):
|
||||
with os.fdopen(file_descriptor, "wb") as temporary_file:
|
||||
temporary_file.write(image_data)
|
||||
image = bpy.data.images.load(temporary_path, check_existing=False)
|
||||
image.name = "Clipboard Image"
|
||||
image.name = "Clipboard"
|
||||
image.pack()
|
||||
image[CLIPBOARD_HASH_PROPERTY] = image_hash
|
||||
return image, False
|
||||
|
||||
@@ -4,11 +4,19 @@ from ...preferences import (
|
||||
MAX_CUTOUT_MESH_RESOLUTION,
|
||||
MIN_CUTOUT_MESH_RESOLUTION,
|
||||
)
|
||||
from ...properties import ai_ready
|
||||
from ...common.image_data import image_rgba
|
||||
from ...common.image_region import rasterize_region_path
|
||||
from .geometry import CUTOUT_ALPHA_THRESHOLD
|
||||
from .shape import CUTOUT_SHAPE_LABELS
|
||||
|
||||
CUTOUT_SHAPE_BUTTONS = (
|
||||
("SURFACE", "MESH_GRID"),
|
||||
("BALLOON", "MESH_UVSPHERE"),
|
||||
("DEPTH_BALLOON", "META_BALL"),
|
||||
("DEPTH_SURFACE", "SURFACE_NCURVE"),
|
||||
)
|
||||
|
||||
|
||||
def validate_cutout_region(
|
||||
source_object,
|
||||
@@ -69,55 +77,28 @@ def open_shape_pie(
|
||||
region_path_json = region_path.to_json()
|
||||
settings = context.scene.anyimage_settings
|
||||
point_spacing = settings.cutout_point_spacing
|
||||
generate_normal = bool(getattr(settings, "cutout_generate_normal", False))
|
||||
ready = ai_ready()
|
||||
refine_region = ready and refine_region
|
||||
generate_normal = ready and bool(
|
||||
getattr(settings, "cutout_generate_normal", False)
|
||||
)
|
||||
mesh_resolution = cutout_mesh_resolution(source_object.data.size, point_spacing)
|
||||
|
||||
def draw(menu, _context):
|
||||
pie = menu.layout.menu_pie()
|
||||
_pie_button(
|
||||
pie,
|
||||
"SURFACE",
|
||||
CUTOUT_SHAPE_LABELS["SURFACE"],
|
||||
"MESH_GRID",
|
||||
source_name,
|
||||
region_path_json,
|
||||
refine_region,
|
||||
generate_normal,
|
||||
mesh_resolution,
|
||||
)
|
||||
_pie_button(
|
||||
pie,
|
||||
"BALLOON",
|
||||
CUTOUT_SHAPE_LABELS["BALLOON"],
|
||||
"MESH_UVSPHERE",
|
||||
source_name,
|
||||
region_path_json,
|
||||
refine_region,
|
||||
generate_normal,
|
||||
mesh_resolution,
|
||||
)
|
||||
_pie_button(
|
||||
pie,
|
||||
"DEPTH_BALLOON",
|
||||
CUTOUT_SHAPE_LABELS["DEPTH_BALLOON"],
|
||||
"META_BALL",
|
||||
source_name,
|
||||
region_path_json,
|
||||
refine_region,
|
||||
generate_normal,
|
||||
mesh_resolution,
|
||||
)
|
||||
_pie_button(
|
||||
pie,
|
||||
"DEPTH_SURFACE",
|
||||
CUTOUT_SHAPE_LABELS["DEPTH_SURFACE"],
|
||||
"SURFACE_NCURVE",
|
||||
source_name,
|
||||
region_path_json,
|
||||
refine_region,
|
||||
generate_normal,
|
||||
mesh_resolution,
|
||||
)
|
||||
buttons = CUTOUT_SHAPE_BUTTONS if ready else CUTOUT_SHAPE_BUTTONS[:2]
|
||||
for shape, icon in buttons:
|
||||
_pie_button(
|
||||
pie,
|
||||
shape,
|
||||
CUTOUT_SHAPE_LABELS[shape],
|
||||
icon,
|
||||
source_name,
|
||||
region_path_json,
|
||||
refine_region,
|
||||
generate_normal,
|
||||
mesh_resolution,
|
||||
)
|
||||
|
||||
context.window_manager.popup_menu_pie(
|
||||
event,
|
||||
|
||||
@@ -43,7 +43,8 @@ from ...common.depth_data import load_depth_metadata, load_depth_result_image
|
||||
from ...common.image_interaction import (
|
||||
ImageRegionGesture,
|
||||
apply_region_settings,
|
||||
draw_region_settings,
|
||||
draw_ai_property,
|
||||
draw_ai_setup,
|
||||
gesture_property,
|
||||
report_image_edit_exception,
|
||||
resolve_image_edit_click,
|
||||
@@ -450,6 +451,7 @@ class CutoutTool(bpy.types.WorkSpaceTool):
|
||||
|
||||
def draw_settings(context, layout, _tool):
|
||||
settings = context.scene.anyimage_settings
|
||||
draw_region_settings(context, layout, "cutout_refine_region")
|
||||
status = draw_ai_setup(layout)
|
||||
draw_ai_property(layout, settings, "cutout_refine_region", status)
|
||||
layout.prop(settings, "cutout_point_spacing")
|
||||
layout.prop(settings, "cutout_generate_normal")
|
||||
draw_ai_property(layout, settings, "cutout_generate_normal", status)
|
||||
|
||||
@@ -109,6 +109,19 @@ def preview_polygon(points):
|
||||
return points[np.argsort(np.arctan2(points[:, 1] - center[1], points[:, 0] - center[0]))]
|
||||
|
||||
|
||||
def interactive_preview_polygon(points, cursor):
|
||||
"""Return the clicked points plus one distinct moving cursor point."""
|
||||
vertices = list(points)
|
||||
if (
|
||||
cursor is not None
|
||||
and vertices
|
||||
and len(vertices) < 4
|
||||
and math.dist(vertices[-1], cursor) >= 2.0
|
||||
):
|
||||
vertices.append(cursor)
|
||||
return preview_polygon(vertices)
|
||||
|
||||
|
||||
def clip_polygon_to_image(points, image_size):
|
||||
"""Clip one polygon to the source image rectangle."""
|
||||
polygon = [tuple(float(value) for value in point) for point in points]
|
||||
@@ -283,7 +296,7 @@ def create_perspective_preview_image(source_image, pixels):
|
||||
alpha=True,
|
||||
)
|
||||
try:
|
||||
image.colorspace_settings.name = source_image.colorspace_settings.name
|
||||
image.colorspace_settings.name = "sRGB"
|
||||
image.alpha_mode = "STRAIGHT"
|
||||
image.pixels.foreach_set(pixels)
|
||||
image.update()
|
||||
@@ -293,6 +306,12 @@ def create_perspective_preview_image(source_image, pixels):
|
||||
return image
|
||||
|
||||
|
||||
def preview_texture_draw_options(blender_version):
|
||||
if tuple(blender_version) >= (5, 0, 0):
|
||||
return {"is_scene_linear_with_rec709_srgb_target": True}
|
||||
return {}
|
||||
|
||||
|
||||
def homography_from_points(source, target):
|
||||
"""Return a projective transform mapping four source points to target."""
|
||||
import numpy as np
|
||||
@@ -526,7 +545,13 @@ class EditImagePerspective(bpy.types.Operator):
|
||||
texture = gpu.texture.from_image(self._preview_image)
|
||||
gpu.state.blend_set("ALPHA")
|
||||
try:
|
||||
draw_texture_2d(texture, (left, bottom), width, height)
|
||||
draw_texture_2d(
|
||||
texture,
|
||||
(left, bottom),
|
||||
width,
|
||||
height,
|
||||
**preview_texture_draw_options(bpy.app.version),
|
||||
)
|
||||
finally:
|
||||
gpu.state.blend_set("NONE")
|
||||
draw_preview_frame(bounds)
|
||||
@@ -542,10 +567,7 @@ class EditImagePerspective(bpy.types.Operator):
|
||||
if self._phase == "ASPECT":
|
||||
self._draw_preview()
|
||||
return
|
||||
vertices = list(self._points)
|
||||
if self._cursor is not None and len(vertices) < 4:
|
||||
vertices.append(self._cursor)
|
||||
vertices = list(preview_polygon(vertices))
|
||||
vertices = list(interactive_preview_polygon(self._points, self._cursor))
|
||||
draw_polygon_fill(vertices)
|
||||
if len(vertices) > 2:
|
||||
vertices.append(vertices[0])
|
||||
|
||||
@@ -1433,13 +1433,23 @@ class BlenderAddonTest(unittest.TestCase):
|
||||
).CutoutTool
|
||||
cutout_module = sys.modules[cutout_tool.__module__]
|
||||
drawn_settings.clear()
|
||||
with patch.object(
|
||||
cutout_module,
|
||||
"draw_region_settings",
|
||||
side_effect=lambda _context, _layout, name: drawn_settings.append(name),
|
||||
) as draw:
|
||||
with (
|
||||
patch.object(
|
||||
cutout_module,
|
||||
"draw_ai_setup",
|
||||
return_value={"ready": True},
|
||||
) as setup,
|
||||
patch.object(
|
||||
cutout_module,
|
||||
"draw_ai_property",
|
||||
side_effect=lambda _layout, _settings, name, _status: (
|
||||
drawn_settings.append(name)
|
||||
),
|
||||
) as draw,
|
||||
):
|
||||
cutout_tool.draw_settings(context, layout, None)
|
||||
draw.assert_called_once_with(context, layout, "cutout_refine_region")
|
||||
setup.assert_called_once_with(layout)
|
||||
self.assertEqual(draw.call_count, 2)
|
||||
self.assertEqual(
|
||||
drawn_settings,
|
||||
[
|
||||
@@ -1481,6 +1491,83 @@ class BlenderAddonTest(unittest.TestCase):
|
||||
self.assertNotIn("image_extract_padding", settings)
|
||||
self.assertNotIn("image_extract_remove_background", settings)
|
||||
|
||||
def test_image_area_tool_settings_gate_ai_options(self):
|
||||
class Row:
|
||||
def __init__(self, events):
|
||||
self.events = events
|
||||
self.enabled = True
|
||||
|
||||
def prop(self, _owner, name):
|
||||
self.events.append(("prop", name, self.enabled))
|
||||
|
||||
class Layout:
|
||||
def __init__(self):
|
||||
self.events = []
|
||||
|
||||
def operator(self, identifier, **_options):
|
||||
self.events.append(("operator", identifier, True))
|
||||
|
||||
def row(self):
|
||||
return Row(self.events)
|
||||
|
||||
def prop(self, _owner, name):
|
||||
self.events.append(("prop", name, True))
|
||||
|
||||
context = SimpleNamespace(
|
||||
scene=SimpleNamespace(anyimage_settings=SimpleNamespace())
|
||||
)
|
||||
missing = {
|
||||
"environment_ready": False,
|
||||
"missing_models": (),
|
||||
"ready": False,
|
||||
}
|
||||
setup = ("operator", "anyimage.setup_ai_environment", True)
|
||||
with patch.object(self.image_interaction, "ai_status", return_value=missing):
|
||||
for tool, setting_name in (
|
||||
(self.image_box.ImageBoxTool, "image_box_refine_region"),
|
||||
(self.image_lasso.ImageLassoTool, "image_lasso_refine_region"),
|
||||
):
|
||||
layout = Layout()
|
||||
tool.draw_settings(context, layout, None)
|
||||
self.assertEqual(
|
||||
layout.events,
|
||||
[setup, ("prop", setting_name, False)],
|
||||
)
|
||||
|
||||
cutout_tool = importlib.import_module(
|
||||
"anyimage.operators.cutout_tool"
|
||||
).CutoutTool
|
||||
layout = Layout()
|
||||
cutout_tool.draw_settings(context, layout, None)
|
||||
self.assertEqual(
|
||||
layout.events,
|
||||
[
|
||||
setup,
|
||||
("prop", "cutout_refine_region", False),
|
||||
("prop", "cutout_point_spacing", True),
|
||||
("prop", "cutout_generate_normal", False),
|
||||
],
|
||||
)
|
||||
|
||||
layout = Layout()
|
||||
operator = SimpleNamespace(gesture="BOX")
|
||||
self.image_interaction.draw_region_operator_settings(layout, operator)
|
||||
self.assertEqual(
|
||||
layout.events,
|
||||
[
|
||||
setup,
|
||||
("prop", "refine_region", False),
|
||||
("prop", "invert", True),
|
||||
],
|
||||
)
|
||||
|
||||
ready = {"environment_ready": True, "missing_models": (), "ready": True}
|
||||
with patch.object(self.image_interaction, "ai_status", return_value=ready):
|
||||
layout = Layout()
|
||||
cutout_tool.draw_settings(context, layout, None)
|
||||
self.assertNotIn(setup, layout.events)
|
||||
self.assertTrue(all(event[2] for event in layout.events))
|
||||
|
||||
def test_refine_region_is_independent_for_each_area_tool(self):
|
||||
settings = SimpleNamespace(
|
||||
cutout_refine_region=True,
|
||||
@@ -1636,6 +1723,57 @@ class BlenderAddonTest(unittest.TestCase):
|
||||
atol=1e-6,
|
||||
)
|
||||
|
||||
def test_image_perspective_preview_uses_srgb_without_changing_pixels(self):
|
||||
written = []
|
||||
preview = SimpleNamespace(
|
||||
colorspace_settings=SimpleNamespace(name="Non-Color"),
|
||||
alpha_mode=None,
|
||||
pixels=SimpleNamespace(foreach_set=written.extend),
|
||||
update=lambda: None,
|
||||
)
|
||||
previous_data = self.fake_bpy.data
|
||||
self.fake_bpy.data = SimpleNamespace(
|
||||
images=SimpleNamespace(new=lambda *_args, **_options: preview)
|
||||
)
|
||||
pixels = (0.1, 0.2, 0.3, 0.4)
|
||||
source = SimpleNamespace(
|
||||
name="Source.png",
|
||||
colorspace_settings=SimpleNamespace(name="Non-Color"),
|
||||
)
|
||||
try:
|
||||
result = self.image_perspective.create_perspective_preview_image(
|
||||
source, pixels
|
||||
)
|
||||
finally:
|
||||
self.fake_bpy.data = previous_data
|
||||
|
||||
self.assertIs(result, preview)
|
||||
self.assertEqual(preview.colorspace_settings.name, "sRGB")
|
||||
self.assertEqual(preview.alpha_mode, "STRAIGHT")
|
||||
self.assertEqual(written, list(pixels))
|
||||
|
||||
def test_image_perspective_preview_skips_cursor_at_last_clicked_point(self):
|
||||
points = ((10.0, 10.0), (80.0, 10.0), (70.0, 60.0))
|
||||
|
||||
stationary = self.image_perspective.interactive_preview_polygon(
|
||||
points, points[-1]
|
||||
)
|
||||
moving = self.image_perspective.interactive_preview_polygon(
|
||||
points, (20.0, 70.0)
|
||||
)
|
||||
|
||||
self.assertEqual(len(stationary), 3)
|
||||
self.assertEqual(len(moving), 4)
|
||||
|
||||
def test_image_perspective_draws_scene_linear_texture_as_srgb_in_blender_5(self):
|
||||
draw_options = self.image_perspective.preview_texture_draw_options
|
||||
|
||||
self.assertEqual(draw_options((4, 5, 3)), {})
|
||||
self.assertEqual(
|
||||
draw_options((5, 0, 0)),
|
||||
{"is_scene_linear_with_rec709_srgb_target": True},
|
||||
)
|
||||
|
||||
def test_image_perspective_tightly_crops_alpha(self):
|
||||
import numpy as np
|
||||
|
||||
|
||||
@@ -574,6 +574,24 @@ class ClipboardImageTest(unittest.TestCase):
|
||||
finally:
|
||||
self.actions.bpy.data = original_data
|
||||
|
||||
def test_names_new_packed_image_clipboard(self):
|
||||
image = FakeLoadedImage()
|
||||
images = FakeLoadedImages(image)
|
||||
original_data = self.actions.bpy.data
|
||||
self.actions.bpy.data = SimpleNamespace(images=images)
|
||||
try:
|
||||
loaded_image, reused = self.actions.acquire_packed_image(
|
||||
b"image-data",
|
||||
".png",
|
||||
)
|
||||
finally:
|
||||
self.actions.bpy.data = original_data
|
||||
|
||||
self.assertIs(loaded_image, image)
|
||||
self.assertFalse(reused)
|
||||
self.assertEqual(image.name, "Clipboard")
|
||||
self.assertTrue(image.packed)
|
||||
|
||||
def test_sets_and_reuses_stencil_brush_texture(self):
|
||||
textures = FakeTextures()
|
||||
original_data = self.actions.bpy.data
|
||||
@@ -865,6 +883,28 @@ class FakeImage:
|
||||
return self.properties.get(property_name, default)
|
||||
|
||||
|
||||
class FakeLoadedImage(dict):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = None
|
||||
self.packed = False
|
||||
|
||||
def pack(self):
|
||||
self.packed = True
|
||||
|
||||
|
||||
class FakeLoadedImages(list):
|
||||
def __init__(self, loaded_image):
|
||||
super().__init__()
|
||||
self.loaded_image = loaded_image
|
||||
|
||||
def load(self, _path, check_existing):
|
||||
return self.loaded_image
|
||||
|
||||
def remove(self, image):
|
||||
super().remove(image)
|
||||
|
||||
|
||||
class FakeTexture(dict):
|
||||
def __init__(self, name, texture_type):
|
||||
super().__init__()
|
||||
|
||||
@@ -47,29 +47,27 @@ def cutout_source(name="Source", size=(2048, 1024)):
|
||||
return SimpleNamespace(name=name, data=SimpleNamespace(size=size))
|
||||
|
||||
|
||||
def test_cutout_pie_uses_four_semantic_shapes_in_cardinal_order():
|
||||
def test_cutout_pie_uses_two_local_shapes_without_ai(monkeypatch):
|
||||
window_manager = WindowManager()
|
||||
context = cutout_context(window_manager)
|
||||
context = cutout_context(window_manager, generate_normal=True)
|
||||
source = cutout_source()
|
||||
monkeypatch.setattr(interaction, "ai_ready", lambda: False)
|
||||
|
||||
interaction.open_shape_pie(
|
||||
context,
|
||||
object(),
|
||||
source,
|
||||
RegionPath(points=((0, 0), (10, 0), (0, 10))),
|
||||
refine_region=True,
|
||||
)
|
||||
|
||||
assert [call[2].shape for call in window_manager.pie.calls] == [
|
||||
"SURFACE",
|
||||
"BALLOON",
|
||||
"DEPTH_BALLOON",
|
||||
"DEPTH_SURFACE",
|
||||
]
|
||||
assert [call[1]["text"] for call in window_manager.pie.calls] == [
|
||||
"Surface",
|
||||
"Balloon",
|
||||
"Depth Balloon",
|
||||
"Depth Surface",
|
||||
]
|
||||
assert all(
|
||||
call[0] == "anyimage.cutout_region_to_shape"
|
||||
@@ -80,10 +78,11 @@ def test_cutout_pie_uses_four_semantic_shapes_in_cardinal_order():
|
||||
assert all(call[2].mesh_resolution == 64 for call in window_manager.pie.calls)
|
||||
|
||||
|
||||
def test_cutout_pie_passes_image_lasso_options_to_every_shape():
|
||||
def test_cutout_pie_passes_image_lasso_options_to_all_ai_shapes(monkeypatch):
|
||||
window_manager = WindowManager()
|
||||
context = cutout_context(window_manager, point_spacing=16, generate_normal=True)
|
||||
source = cutout_source(size=(4096, 2048))
|
||||
monkeypatch.setattr(interaction, "ai_ready", lambda: True)
|
||||
|
||||
interaction.open_shape_pie(
|
||||
context,
|
||||
@@ -96,6 +95,12 @@ def test_cutout_pie_passes_image_lasso_options_to_every_shape():
|
||||
refine_region=True,
|
||||
)
|
||||
|
||||
assert [call[2].shape for call in window_manager.pie.calls] == [
|
||||
"SURFACE",
|
||||
"BALLOON",
|
||||
"DEPTH_BALLOON",
|
||||
"DEPTH_SURFACE",
|
||||
]
|
||||
assert all(call[2].refine_region for call in window_manager.pie.calls)
|
||||
assert all(call[2].generate_normal for call in window_manager.pie.calls)
|
||||
assert all(
|
||||
|
||||
@@ -91,6 +91,49 @@ class ImageDataTest(unittest.TestCase):
|
||||
PROJECT_ROOT / "src" / "anyimage" / "assets" / "O_AnyImage.blend",
|
||||
)
|
||||
|
||||
def test_runtime_modifier_enum_input_uses_string_identifier(self):
|
||||
enum_items = (
|
||||
SimpleNamespace(identifier="SURFACE"),
|
||||
SimpleNamespace(identifier="BALLOON"),
|
||||
)
|
||||
value_property = SimpleNamespace(type="ENUM", enum_items=enum_items)
|
||||
input_slot = SimpleNamespace(
|
||||
value=None,
|
||||
bl_rna=SimpleNamespace(properties={"value": value_property}),
|
||||
)
|
||||
modifier = SimpleNamespace(
|
||||
properties=SimpleNamespace(
|
||||
inputs=SimpleNamespace(Shape=input_slot),
|
||||
)
|
||||
)
|
||||
|
||||
self.image_depth.set_modifier_input(modifier, "Shape", 1)
|
||||
|
||||
self.assertEqual(input_slot.value, "BALLOON")
|
||||
|
||||
def test_runtime_modifier_non_enum_and_legacy_inputs_keep_values(self):
|
||||
value_property = SimpleNamespace(type="FLOAT")
|
||||
input_slot = SimpleNamespace(
|
||||
value=None,
|
||||
bl_rna=SimpleNamespace(properties={"value": value_property}),
|
||||
)
|
||||
runtime_modifier = SimpleNamespace(
|
||||
properties=SimpleNamespace(
|
||||
inputs=SimpleNamespace(Thickness=input_slot),
|
||||
)
|
||||
)
|
||||
|
||||
self.image_depth.set_modifier_input(runtime_modifier, "Thickness", 2.0)
|
||||
|
||||
self.assertEqual(input_slot.value, 2.0)
|
||||
|
||||
class LegacyModifier(dict):
|
||||
properties = None
|
||||
|
||||
legacy_modifier = LegacyModifier()
|
||||
self.image_depth.set_modifier_input(legacy_modifier, "Shape", 1)
|
||||
self.assertEqual(legacy_modifier["Shape"], 1)
|
||||
|
||||
def test_material_color_space_matches_scene_view_transform(self):
|
||||
mappings = {
|
||||
"ACES 1.3": "ACES 1.3 sRGB",
|
||||
|
||||
Reference in New Issue
Block a user