From 14d63001d2a94c8fc7340f400306f1fbc6d4fa99 Mon Sep 17 00:00:00 2001 From: nan Date: Mon, 31 Aug 2026 16:10:08 +0800 Subject: [PATCH] feat: Enhance AI integration for Cutout, Image Box, and Image Lasso tools with unified settings and improved shape selection --- .../gate-cutout-ai-options/.openspec.yaml | 2 + .../changes/gate-cutout-ai-options/design.md | 46 ++++++ .../gate-cutout-ai-options/proposal.md | 26 +++ .../specs/image-tool-ai-availability/spec.md | 53 +++++++ .../changes/gate-cutout-ai-options/tasks.md | 21 +++ src/anyimage/common/image_interaction.py | 41 ++--- src/anyimage/common/object.py | 6 +- .../operators/clipboard_image/actions.py | 2 +- .../operators/cutout_tool/interaction.py | 71 +++------ src/anyimage/operators/cutout_tool/main.py | 8 +- .../perspective_tool/edit_perspective.py | 34 +++- tests/test_blender_addon.py | 150 +++++++++++++++++- tests/test_clipboard_image.py | 40 +++++ tests/test_cutout_interaction.py | 19 ++- tests/test_image_data.py | 43 +++++ 15 files changed, 473 insertions(+), 89 deletions(-) create mode 100644 openspec/changes/gate-cutout-ai-options/.openspec.yaml create mode 100644 openspec/changes/gate-cutout-ai-options/design.md create mode 100644 openspec/changes/gate-cutout-ai-options/proposal.md create mode 100644 openspec/changes/gate-cutout-ai-options/specs/image-tool-ai-availability/spec.md create mode 100644 openspec/changes/gate-cutout-ai-options/tasks.md diff --git a/openspec/changes/gate-cutout-ai-options/.openspec.yaml b/openspec/changes/gate-cutout-ai-options/.openspec.yaml new file mode 100644 index 0000000..ecf3b45 --- /dev/null +++ b/openspec/changes/gate-cutout-ai-options/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-31 diff --git a/openspec/changes/gate-cutout-ai-options/design.md b/openspec/changes/gate-cutout-ai-options/design.md new file mode 100644 index 0000000..0abf768 --- /dev/null +++ b/openspec/changes/gate-cutout-ai-options/design.md @@ -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()` 合取,不依赖用户手动清除。 diff --git a/openspec/changes/gate-cutout-ai-options/proposal.md b/openspec/changes/gate-cutout-ai-options/proposal.md new file mode 100644 index 0000000..465b648 --- /dev/null +++ b/openspec/changes/gate-cutout-ai-options/proposal.md @@ -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 实现。 diff --git a/openspec/changes/gate-cutout-ai-options/specs/image-tool-ai-availability/spec.md b/openspec/changes/gate-cutout-ai-options/specs/image-tool-ai-availability/spec.md new file mode 100644 index 0000000..4a6f711 --- /dev/null +++ b/openspec/changes/gate-cutout-ai-options/specs/image-tool-ai-availability/spec.md @@ -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 设置流程 diff --git a/openspec/changes/gate-cutout-ai-options/tasks.md b/openspec/changes/gate-cutout-ai-options/tasks.md new file mode 100644 index 0000000..a4a8cd2 --- /dev/null +++ b/openspec/changes/gate-cutout-ai-options/tasks.md @@ -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 diff --git a/src/anyimage/common/image_interaction.py b/src/anyimage/common/image_interaction.py index 8cd573b..3b1196d 100644 --- a/src/anyimage/common/image_interaction.py +++ b/src/anyimage/common/image_interaction.py @@ -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): diff --git a/src/anyimage/common/object.py b/src/anyimage/common/object.py index b3cb7f7..2778911 100644 --- a/src/anyimage/common/object.py +++ b/src/anyimage/common/object.py @@ -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 diff --git a/src/anyimage/operators/clipboard_image/actions.py b/src/anyimage/operators/clipboard_image/actions.py index 5ded87a..e7c6dff 100644 --- a/src/anyimage/operators/clipboard_image/actions.py +++ b/src/anyimage/operators/clipboard_image/actions.py @@ -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 diff --git a/src/anyimage/operators/cutout_tool/interaction.py b/src/anyimage/operators/cutout_tool/interaction.py index 0870730..0de7e7d 100644 --- a/src/anyimage/operators/cutout_tool/interaction.py +++ b/src/anyimage/operators/cutout_tool/interaction.py @@ -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, diff --git a/src/anyimage/operators/cutout_tool/main.py b/src/anyimage/operators/cutout_tool/main.py index 9e4cccd..50f0ccd 100644 --- a/src/anyimage/operators/cutout_tool/main.py +++ b/src/anyimage/operators/cutout_tool/main.py @@ -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) diff --git a/src/anyimage/operators/perspective_tool/edit_perspective.py b/src/anyimage/operators/perspective_tool/edit_perspective.py index 048f0c2..ea04d10 100644 --- a/src/anyimage/operators/perspective_tool/edit_perspective.py +++ b/src/anyimage/operators/perspective_tool/edit_perspective.py @@ -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]) diff --git a/tests/test_blender_addon.py b/tests/test_blender_addon.py index 0c36426..b12e62a 100644 --- a/tests/test_blender_addon.py +++ b/tests/test_blender_addon.py @@ -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 diff --git a/tests/test_clipboard_image.py b/tests/test_clipboard_image.py index ba48fbe..40f09ad 100644 --- a/tests/test_clipboard_image.py +++ b/tests/test_clipboard_image.py @@ -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__() diff --git a/tests/test_cutout_interaction.py b/tests/test_cutout_interaction.py index 3b007d9..68552f7 100644 --- a/tests/test_cutout_interaction.py +++ b/tests/test_cutout_interaction.py @@ -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( diff --git a/tests/test_image_data.py b/tests/test_image_data.py index 5353cf5..7b6cdc6 100644 --- a/tests/test_image_data.py +++ b/tests/test_image_data.py @@ -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",