feat: Enhance AI integration for Cutout, Image Box, and Image Lasso tools with unified settings and improved shape selection

This commit is contained in:
2026-08-31 16:10:08 +08:00
parent e7aeeb21d4
commit 14d63001d2
15 changed files with 473 additions and 89 deletions

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-31

View 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()` 合取,不依赖用户手动清除。

View 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 实现。

View File

@@ -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 设置流程

View 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

View File

@@ -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):

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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)

View File

@@ -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])

View File

@@ -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

View File

@@ -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__()

View File

@@ -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(

View File

@@ -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",