From 18799e88288391daf1d9421fbc58417a192942d0 Mon Sep 17 00:00:00 2001 From: nan Date: Sat, 12 Sep 2026 23:03:24 +0800 Subject: [PATCH] Refactor Cutout Tool to Use World Space Edge Length - Replaced the Mesh Detail parameter with a new Edge Length property to define cutout sampling in physical units. - Introduced Minimum Relative Edge Length preference to control effective spacing based on the bounding box of the cutout content. - Updated tests to validate the new edge length functionality and ensure consistent behavior across different resolutions and canvas sizes. - Removed deprecated parameters and adjusted related properties to align with the new edge length implementation. - Enhanced geometry sampling logic to utilize effective world spacing for both standard and fine outline modes. --- .../.openspec.yaml | 2 + .../design.md | 38 +++++ .../proposal.md | 25 ++++ .../specs/cutout-edge-length/spec.md | 65 +++++++++ .../tasks.md | 17 +++ src/anyimage/assets/O_AnyImage.blend | 4 +- .../operators/cutout_tool/fine_mesh.py | 34 +++-- .../operators/cutout_tool/geometry.py | 71 +++++----- .../operators/cutout_tool/interaction.py | 19 +-- src/anyimage/operators/cutout_tool/object.py | 5 +- .../operators/cutout_tool/operators.py | 42 +++--- src/anyimage/preferences.py | 39 ++---- src/anyimage/properties.py | 39 ++++-- tests/anyimage/common/test_image_materials.py | 2 +- .../operators/test_cutout_edge_length.py | 132 ++++++++++++++++++ .../operators/test_cutout_fine_mesh.py | 8 +- .../operators/test_cutout_geometry.py | 51 +------ .../operators/test_cutout_interaction.py | 17 +-- .../anyimage/operators/test_cutout_objects.py | 20 +-- .../operators/test_cutout_operators.py | 25 ++-- tests/anyimage/test_addon_registration.py | 18 +-- tests/anyimage/test_preferences.py | 26 ++-- tests/support/image_objects.py | 6 +- tests/tools/nodes/test_depth_cutout.py | 4 +- tests/tools/nodes/test_inventory.py | 31 +++- tools/nodes/groups/image_cutout.py | 9 +- tools/nodes/groups/image_depth_balloon.py | 5 +- tools/nodes/groups/image_depth_cutout.py | 30 ++-- 28 files changed, 505 insertions(+), 279 deletions(-) create mode 100644 openspec/changes/use-world-space-cutout-edge-length/.openspec.yaml create mode 100644 openspec/changes/use-world-space-cutout-edge-length/design.md create mode 100644 openspec/changes/use-world-space-cutout-edge-length/proposal.md create mode 100644 openspec/changes/use-world-space-cutout-edge-length/specs/cutout-edge-length/spec.md create mode 100644 openspec/changes/use-world-space-cutout-edge-length/tasks.md create mode 100644 tests/anyimage/operators/test_cutout_edge_length.py diff --git a/openspec/changes/use-world-space-cutout-edge-length/.openspec.yaml b/openspec/changes/use-world-space-cutout-edge-length/.openspec.yaml new file mode 100644 index 0000000..2b596d1 --- /dev/null +++ b/openspec/changes/use-world-space-cutout-edge-length/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-12 diff --git a/openspec/changes/use-world-space-cutout-edge-length/design.md b/openspec/changes/use-world-space-cutout-edge-length/design.md new file mode 100644 index 0000000..7d9421e --- /dev/null +++ b/openspec/changes/use-world-space-cutout-edge-length/design.md @@ -0,0 +1,38 @@ +## Context + +当前工具栏以 Mesh Detail 枚举映射像素间距,Operator 按裁切像素长边和 Maximum Cutout Mesh Resolution 限制间距。普通几何随后将像素间距换算到世界度量,Fine Outline 在像素空间生成网格。两条路径需要统一为物理长度驱动。 + +## Goals / Non-Goals + +本变更负责 Cutout 的目标采样长度、场景单位换算、相对密度限制,以及普通轮廓和 Fine Outline 的一致性。实施范围为工具属性、偏好、几何采样链和相关测试。 + +## Decisions + +### 参数与单位 + +工具栏使用 `cutout_edge_length`,标签 Edge Length,正值 FloatProperty,使用 Blender 长度单位显示;通过公共属性工厂提供一致的 Operator 参数。初始物理默认值为 0.1 m。内部明确区分 Blender 世界单位与米:世界长度乘 `scene.unit_settings.scale_length` 得到米。场景属性以米存储,通过读写回调转换 Blender 世界单位;Operator 使用原生长度属性接收菜单传入的世界单位值,确保 Blender 复制参数时保留输入。属性初始化和显示遵循 Blender 的长度属性语义,避免重复换算;用实际 Blender 测试覆盖非默认 Unit Scale 下的初始值与输入值。无单位场景按 Unit Scale 换算,默认比例 1。 + +### 有效间距 + +在裁切内容确定后,使用参与建网的内容范围计算局部包围盒,将其图像平面两条轴向边向量应用世界变换,取较长者作为 L。该定义包含非均匀缩放,旋转前后保持一致;多块内容共用整体包围盒,孔洞不改变外部范围。范围依据有效 Alpha 与选区的交集;透明留白不扩大基准。 + +Preferences 增加 `min_cutout_relative_edge_length`,标签 Minimum Relative Edge Length,百分比显示,Blender 百分比属性保存百分数(默认 1,范围 0.001 到 100),preferences.py 读取入口换算为比例(默认 0.01)。 + +以相同长度单位计算 `effective_edge_length = max(edge_length, L * min_relative_edge_length)`。这里限制的是目标点间距;相对限制生效时,界面可见反馈使用场景长度格式显示 Effective Edge Length。操作前尚无有效内容范围时,在执行得到范围后通过 Operator 报告显示有效值。用户请求值和实际值分别保存,避免执行改写工具参数。 + +### 采样链 + +普通轮廓与 Fine Outline 共用同一有效世界间距和世界平面度量。轮廓读取继续使用 Alpha 数据;基础内部采样、边界重采样及相关容差统一从有效间距派生。Fine Outline 将轮廓变换到世界平面度量后,按有效间距归一化为目标间距 32 的计算坐标,统一既有轮廓容差和约束采样尺度。非均匀缩放下距离判断保持一致;Alpha 宽度样本使用最小奇异值保守换算,仅辅助细枝支撑判断,输出映射回像素坐标用于 UV。沿现有真实调用链检查清理容差等间距消费者。 + +同步与 AI 异步路径使用相同解析入口,在 Blender 主线程读取场景和对象数据,后台参数保持普通可序列化值。AI 内容返回后根据实际建网内容范围解析最终有效间距。 + +### 替换与验证 + +删除旧 Mesh Detail 枚举、档位映射、以源像素为单位的 Operator 间距参数,以及 Maximum Cutout Mesh Resolution 属性和读取函数。相关保存属性直接使用新默认值,不保留转发或兼容层。更新注册清单检查与相关测试。 + +## Risks / Trade-offs + +- 相对限制生效后,放大对象可能提高实际间距 → 展示有效长度,并测试限制两侧的行为。 +- Fine Outline 和三角剖分会产生短边,1% 不等同于严格顶点预算 → 保留细轮廓语义,验证整体采样密度与细小部件存活。 +- Alpha 像素化使不同分辨率的边界略有差异 → 分辨率不影响目标间距,测试比较几何密度容差而非要求任意图片顶点完全相同。 +- 单位换算或非均匀缩放可能导致两条采样路径分歧 → 使用不同 Unit Scale、旋转和非均匀缩放的几何用例验证。 diff --git a/openspec/changes/use-world-space-cutout-edge-length/proposal.md b/openspec/changes/use-world-space-cutout-edge-length/proposal.md new file mode 100644 index 0000000..eddb188 --- /dev/null +++ b/openspec/changes/use-world-space-cutout-edge-length/proposal.md @@ -0,0 +1,25 @@ +## Why + +Cutout 的 Mesh Detail 使用源图像像素间距,用户难以从 Image Empty 的外观预判生成密度。改用场景物理长度,让网格密度由明确的目标边长控制,并通过裁切区域的相对尺寸限制生成成本。 + +## What Changes + +- **BREAKING**:将 Mesh Detail 枚举替换为带长度单位的 Edge Length 浮点参数,初始默认值为 0.1 m。 +- 按世界变换和场景 Unit Scale 计算采样间距,图片分辨率与画布大小不参与密度定义。 +- Preferences 使用 Minimum Relative Edge Length 百分比替换 Maximum Cutout Mesh Resolution,默认 1%;实际间距取用户长度与裁切包围盒世界长边乘比例的较大值。 +- 普通轮廓与 Fine Outline 共用有效间距语义;相对限制作用于目标采样间距,轮廓适配允许更短的边。 +- 限制生效时展示实际采用的目标长度。 + +## Capabilities + +### New Capabilities + +- `cutout-edge-length`: Cutout 的物理目标边长、相对密度限制及一致的采样与界面行为。 + +### Modified Capabilities + +无。当前 openspec/specs 下无已归档能力规范。 + +## Impact + +涉及 properties.py、preferences.py、cutout_tool 的交互、Operator、几何与 Fine Outline 采样链,以及相关注册、偏好与几何测试。移除旧枚举映射、像素间距协议和旧密度上限入口;无需新增依赖。 diff --git a/openspec/changes/use-world-space-cutout-edge-length/specs/cutout-edge-length/spec.md b/openspec/changes/use-world-space-cutout-edge-length/specs/cutout-edge-length/spec.md new file mode 100644 index 0000000..750c0ee --- /dev/null +++ b/openspec/changes/use-world-space-cutout-edge-length/specs/cutout-edge-length/spec.md @@ -0,0 +1,65 @@ +## ADDED Requirements + +### Requirement: Physical edge length control + +系统 SHALL 提供正值浮点长度参数 Edge Length,初始物理默认值为 0.1 m,遵循 Blender 场景单位显示与换算。 + +#### Scenario: Default length +- **WHEN** 用户首次使用 Cutout +- **THEN** Edge Length 的初始值表示 0.1 m,并可连续调整 + +#### Scenario: Scene unit conversion +- **WHEN** 场景 Unit Scale 为 0.01,用户输入 0.1 m 且相对限制未触发 +- **THEN** 建网目标间距为 10 个 Blender 世界单位 + +### Requirement: Relative spacing floor + +系统 SHALL 在 Preferences 提供 Minimum Relative Edge Length 百分比,默认 1%,并使用 `max(请求边长, 有效裁切包围盒世界长边 × 最小比例)` 计算有效目标间距。包围盒 SHALL 来自参与建网的有效内容,包含所有组件,采用图像平面轴向边经过世界变换后的较长边。 + +#### Scenario: Floor is applied +- **WHEN** 包围盒长边为 1 m,最小比例为 1%,请求边长为 0.001 m +- **THEN** 有效目标间距为 0.01 m + +#### Scenario: Requested spacing is retained +- **WHEN** 包围盒长边为 1 m,最小比例为 1%,请求边长为 0.1 m +- **THEN** 有效目标间距为 0.1 m + +#### Scenario: Preference changes +- **WHEN** 用户将最小比例改为 2%,包围盒长边为 1 m,请求边长为 0.001 m +- **THEN** 有效目标间距为 0.02 m + +### Requirement: Stable world density + +系统 SHALL 根据有效世界间距驱动普通轮廓与 Fine Outline 的基础采样。图片分辨率与有效内容外的画布 SHALL 不参与目标间距定义;对象世界变换 SHALL 参与空间长度计算。 + +#### Scenario: Resolution and canvas changes +- **WHEN** 相同物理尺寸和轮廓的主体更换图像分辨率或增加透明画布,参数保持一致 +- **THEN** 两种建网模式采用相同的有效目标间距 + +#### Scenario: Object scaling below the floor threshold +- **WHEN** 用户放大 Image Empty 且相对限制仍未触发 +- **THEN** 目标边长保持一致,放大的区域获得更多采样点 + +#### Scenario: Rotation and nonuniform scale +- **WHEN** 对象旋转或非均匀缩放 +- **THEN** 两种建网模式在世界平面度量中应用有效间距,旋转不改变包围盒长边基准 + +### Requirement: Detail preserving spacing semantics + +系统 SHALL 将长度限制解释为基础采样目标,允许轮廓适配和三角剖分产生更短的边,并保持 Fine Outline 的细小部件支持。 + +#### Scenario: Thin outline feature +- **WHEN** Fine Outline 处理宽度小于有效间距的可用细小轮廓 +- **THEN** 建网可增加局部采样并生成较短边,以保留该轮廓 + +### Requirement: Effective length feedback and parameter replacement + +系统 SHALL 在相对限制生效后显示有效目标长度,并保留用户请求长度。系统 SHALL 使用新长度属性和相对偏好替换旧 Mesh Detail 枚举与最大网格分辨率设置。 + +#### Scenario: Visible clamp result +- **WHEN** 用户请求 0.1 m,但实际采用 0.2 m +- **THEN** 界面反馈显示有效长度 0.2 m,用户设置仍为 0.1 m + +#### Scenario: Consistent execution paths +- **WHEN** 用户通过同步或 AI 异步路径生成 Cutout +- **THEN** 系统依据最终建网内容采用同一套有效边长规则 diff --git a/openspec/changes/use-world-space-cutout-edge-length/tasks.md b/openspec/changes/use-world-space-cutout-edge-length/tasks.md new file mode 100644 index 0000000..ad87fbf --- /dev/null +++ b/openspec/changes/use-world-space-cutout-edge-length/tasks.md @@ -0,0 +1,17 @@ +## 1. 参数与偏好 + +- [x] 1.1 将 Mesh Detail 替换为 Edge Length 长度属性,接通工具栏、菜单与 Operator,验证默认 0.1 m 和场景单位语义。 +- [x] 1.2 将最大网格分辨率偏好替换为默认 1% 的 Minimum Relative Edge Length,统一 preferences.py 读取入口,更新属性注册与相关测试。 + +## 2. 世界空间采样 + +- [x] 2.1 从有效裁切内容求世界平面轴向包围盒长边,实现包含 Unit Scale 的有效间距计算,覆盖比例限制、旋转、非均匀缩放与透明留白。 +- [x] 2.2 将普通轮廓的重采样、内部采样和间距衍生容差接入有效世界间距,移除源像素密度换算链。 +- [x] 2.3 将 Fine Outline 的密度判断接入统一世界度量,保持 Alpha 取样、UV 和细小轮廓支持,验证非均匀缩放下的目标间距。 +- [x] 2.4 同步与 AI 异步执行路径使用最终内容范围计算有效间距;保持 Blender 数据读取在主线程,分别保留请求值和有效值。 + +## 3. 反馈与验证 + +- [x] 3.1 相对限制生效时使用场景长度格式反馈有效值,补充设置值保持不变的交互测试。 +- [x] 3.2 更新相关几何测试,覆盖分辨率和画布变化、缩放、两种轮廓模式及细小部件;补充实际 Blender 非默认 Unit Scale 的长度输入与初始化验证。 +- [x] 3.3 检查并移除旧档位、像素间距参数和旧偏好引用,运行相关测试并确认正常退出,检查最终差异。 diff --git a/src/anyimage/assets/O_AnyImage.blend b/src/anyimage/assets/O_AnyImage.blend index 224309f..0e85b93 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:6aaeb2094a859dc07e2d08fabb6357bb0c60f00c3becf2e7a756028fe1c1bede -size 9838587 +oid sha256:ee7629693ef157c5a77ba6319637cfbab3f775a69f7e68831fde647a4ad23b8a +size 9733198 diff --git a/src/anyimage/operators/cutout_tool/fine_mesh.py b/src/anyimage/operators/cutout_tool/fine_mesh.py index 01fcd1d..9994ca8 100644 --- a/src/anyimage/operators/cutout_tool/fine_mesh.py +++ b/src/anyimage/operators/cutout_tool/fine_mesh.py @@ -78,7 +78,7 @@ def support_flat_regions(points, faces): return np.asarray(vertices), np.asarray(triangles, dtype=np.int32) -def build_fine_mesh(alpha, spacing, threshold): +def build_fine_mesh(alpha, spacing, threshold, *, pixel_transform=None): """Return pixel-space triangles and component ranges for height solving.""" alpha = np.asarray(alpha, dtype=float) if alpha.ndim != 2 or not alpha.size or not np.isfinite(alpha).all(): @@ -92,18 +92,24 @@ def build_fine_mesh(alpha, spacing, threshold): reference = g.extract_selection_contours( filtered, max_resolution=max(mask.shape), threshold=threshold ) - width_sizes = WidthSamples(mask) + # Normalize geometric tolerances and support sampling to the target length. + transform = ( + np.eye(2) if pixel_transform is None else np.asarray(pixel_transform) + ) * (32 / spacing) + inverse = np.linalg.inv(transform) + width_sizes = WidthSamples(mask, transform=transform) + spacing = 32.0 points, faces, components = [], [], [] offset = 0 for component in reference.components: - contours = contours_for_component(component, spacing) + contours = contours_for_component([np.asarray(c) @ transform for c in component], spacing) spine, path_edges = interior_paths( - contours, min(32, max(mask.shape) / 6), width_sizes + contours, spacing, width_sizes ) contours, spine, path_edges = subdivide_constraints( contours, spine, path_edges, spacing ) - free = relax_points(contours, spine, mask.shape, spacing) + free = relax_points(contours, spine, spacing) vertices, triangles, constraints = triangulate( contours, spine, path_edges, free, spacing ) @@ -117,9 +123,9 @@ def build_fine_mesh(alpha, spacing, threshold): raise ValueError( "Fine Outline placed an interior constraint on the boundary" ) - points.append(vertices) + points.append(vertices @ inverse) faces.append(triangles + offset) - components.append((offset, offset + len(vertices), triangles, contours[0])) + components.append((offset, offset + len(vertices), triangles, contours[0] @ inverse)) offset += len(vertices) points, faces = np.concatenate(points), np.concatenate(faces) return points, faces, g.boundary_vertices_from_faces(len(points), faces), components @@ -181,7 +187,10 @@ def width_ridge_samples(image): class WidthSamples: """Estimate local width from indexed distance-ridge disks.""" - def __init__(self, mask): + def __init__(self, mask, transform=None): + transform = np.eye(2) if transform is None else transform + self.inverse = np.linalg.inv(transform) + self.size_scale = np.linalg.svd(transform, compute_uv=False).min() ridge, distance = width_ridge_samples(np.pad(mask, 1)) ridge = ridge[1:-1, 1:-1] self.distance = np.maximum(distance[1:-1, 1:-1] - 0.5, 0.3) @@ -198,6 +207,7 @@ class WidthSamples: ) def __call__(self, points): + points = points @ self.inverse radius = np.full(len(points), 0.5) pending = np.arange(len(points)) for ids, tree, reach in self.groups: @@ -226,7 +236,7 @@ class WidthSamples: mode="nearest", ) return np.maximum( - 0.7, np.minimum(32, np.minimum(16, radius * 0.9) + 0.4 * distance) + 0.7, np.minimum(32, np.minimum(16, radius * self.size_scale * 0.9) + 0.4 * distance * self.size_scale) ) @@ -486,7 +496,7 @@ def interior_paths(contours, spacing, width_sizes): return (np.asarray(points).reshape(-1, 2), edges) -def relax_points(contours, spine, shape, spacing): +def relax_points(contours, spine, spacing): free = g.uniform_interior_points_for_contours(contours, spacing) if len(free) and len(spine): free = free[cKDTree(spine).query(free)[0] > spacing * 0.55] @@ -495,8 +505,8 @@ def relax_points(contours, spine, shape, spacing): points = np.concatenate(contours) minimum = points.min(axis=0) maximum = points.max(axis=0) - xx = np.arange(phase, shape[1], step) + 0.5 - yy = np.arange(phase, shape[0], step) + 0.5 + xx = np.arange(minimum[0] + phase, maximum[0], step) + 0.5 + yy = np.arange(minimum[1] + phase, maximum[1], step) + 0.5 xx = xx[(xx >= minimum[0]) & (xx <= maximum[0])] yy = yy[(yy >= minimum[1]) & (yy <= maximum[1])] xx, yy = np.meshgrid(xx, yy) diff --git a/src/anyimage/operators/cutout_tool/geometry.py b/src/anyimage/operators/cutout_tool/geometry.py index b641e81..45fc0c0 100644 --- a/src/anyimage/operators/cutout_tool/geometry.py +++ b/src/anyimage/operators/cutout_tool/geometry.py @@ -3,7 +3,7 @@ import math from dataclasses import dataclass -from ...preferences import addon_preferences +from ...preferences import addon_preferences, configured_min_cutout_relative_edge_length from ...common.image import image_empty_bounds from . import balloon @@ -824,11 +824,9 @@ def crop_plane_bounds(source_object, crop_bounds, source_size): def extract_cutout_contours( content_values, - point_spacing, + sampling_resolution, threshold=CUTOUT_ALPHA_THRESHOLD, ): - height, width = content_values.shape - sampling_resolution = max(width, height) / float(point_spacing) contour_resolution = max(512, min(round(sampling_resolution * 8), 1024)) return extract_selection_contours( content_values, @@ -837,28 +835,28 @@ def extract_cutout_contours( ) -def limit_cutout_point_spacing(point_spacing, crop_size, max_mesh_resolution): - crop_width, crop_height = (int(value) for value in crop_size) - spacing = float(point_spacing) - if crop_width <= 0 or crop_height <= 0: - raise ValueError("The Cutout bounds must be positive") - if spacing <= 0.0: - raise ValueError("Cutout point spacing must be greater than zero") - maximum = int(max_mesh_resolution) - if maximum <= 0: - raise ValueError("Maximum Cutout mesh resolution must be positive") - return max(spacing, max(crop_width, crop_height) / maximum) +def effective_cutout_edge_length( + context, source_object, edge_length, content_values, content_bounds, + threshold=CUTOUT_ALPHA_THRESHOLD, +): + """Resolve target spacing in Blender world units from visible content bounds.""" + import numpy as np - -def cutout_sampling_spacing(source_object, point_spacing): - import math - - x_min, x_max, y_min, y_max = image_empty_bounds(source_object) - metric, _world_z_scale = world_plane_metric(source_object.matrix_world) - width = (x_max - x_min) * math.sqrt(max(metric[0][0], 0.0)) - height = (y_max - y_min) * math.sqrt(max(metric[1][1], 0.0)) - image_width, image_height = (int(value) for value in source_object.data.size) - return max(width / image_width, height / image_height) * float(point_spacing) + if not math.isfinite(edge_length) or edge_length <= 0: + raise ValueError("Cutout edge length must be greater than zero") + values = np.asarray(content_values) + visible = (values > 0) & (values >= threshold) + rows = np.flatnonzero(visible.any(axis=1)) + columns = np.flatnonzero(visible.any(axis=0)) + if not len(rows): + raise ValueError("The Cutout selection contains no visible image pixels") + bounds = crop_plane_bounds(source_object, content_bounds, tuple(source_object.data.size)) + metric, _ = world_plane_metric(source_object.matrix_world) + metric_transform(metric) + width = (bounds[1] - bounds[0]) * (columns.max() + 1 - columns.min()) / values.shape[1] + height = (bounds[3] - bounds[2]) * (rows.max() + 1 - rows.min()) / values.shape[0] + longest = max(width * math.sqrt(metric[0][0]), height * math.sqrt(metric[1][1])) + return max(float(edge_length), longest * configured_min_cutout_relative_edge_length(context)) def selection_calibration(content_values, target_shape, threshold=CUTOUT_ALPHA_THRESHOLD): @@ -885,7 +883,7 @@ def selection_calibration(content_values, target_shape, threshold=CUTOUT_ALPHA_T def build_base_shape( context, source_object, - point_spacing, + edge_length, content_values, content_bounds, *, @@ -899,31 +897,38 @@ def build_base_shape( content_bounds, tuple(source_object.data.size), ) + world_metric, world_z_scale = world_plane_metric(source_object.matrix_world) + if world_z_scale <= 1e-10: + raise ValueError("The Image Empty has a degenerate world transform") + pixel_transform = np.diag(( + (bounds[1] - bounds[0]) / content_values.shape[1], + -(bounds[3] - bounds[2]) / content_values.shape[0], + )) @ metric_transform(world_metric) + longest = max( + np.linalg.norm(pixel_transform[0]) * content_values.shape[1], + np.linalg.norm(pixel_transform[1]) * content_values.shape[0], + ) fine_mesh = None selection_contours = None if fine_outline: from .fine_mesh import build_fine_mesh fine_mesh = ( - *build_fine_mesh(content_values, point_spacing, alpha_threshold), + *build_fine_mesh(content_values, edge_length, alpha_threshold, pixel_transform=pixel_transform), (content_values.shape[1], content_values.shape[0]), ) else: selection_contours = extract_cutout_contours( content_values, - point_spacing, + longest / edge_length, threshold=alpha_threshold, ) - world_metric, world_z_scale = world_plane_metric(source_object.matrix_world) - if world_z_scale <= 1e-10: - raise ValueError("The Image Empty has a degenerate world transform") preferences = addon_preferences(context) balloon_algorithm = getattr(preferences, "balloon_profile_algorithm", "POISSON") - point_spacing = cutout_sampling_spacing(source_object, point_spacing) domain = cutout_domain( selection_contours, bounds, - point_spacing, + edge_length, world_metric, balloon_algorithm, fine_mesh=fine_mesh, diff --git a/src/anyimage/operators/cutout_tool/interaction.py b/src/anyimage/operators/cutout_tool/interaction.py index 13aed04..b5f334c 100644 --- a/src/anyimage/operators/cutout_tool/interaction.py +++ b/src/anyimage/operators/cutout_tool/interaction.py @@ -10,17 +10,6 @@ CUTOUT_SHAPE_BUTTONS = ( ("DEPTH_SOLID", "SURFACE_NCURVE"), ) -CUTOUT_MESH_DETAIL_SPACING = { - "LOW": 32, - "MEDIUM": 16, - "HIGH": 8, - "ULTRA": 4, -} - - -def cutout_mesh_detail_spacing(mesh_detail): - return CUTOUT_MESH_DETAIL_SPACING[str(mesh_detail)] - def _pie_button( pie, @@ -30,7 +19,7 @@ def _pie_button( source_name, selection_path_json, generate_normal, - point_spacing, + edge_length, alpha_threshold, fine_outline, gesture, @@ -44,7 +33,7 @@ def _pie_button( operator.selection_path_json = selection_path_json operator.shape = shape operator.generate_normal = generate_normal - operator.point_spacing = point_spacing + operator.edge_length = edge_length operator.alpha_threshold = alpha_threshold operator.fine_outline = fine_outline operator.gesture = gesture @@ -61,7 +50,7 @@ def open_shape_pie( source_name = source_object.name selection_path_json = selection_path.to_json() settings = context.scene.anyimage_settings - point_spacing = cutout_mesh_detail_spacing(settings.cutout_mesh_detail) + edge_length = settings.cutout_edge_length ready = ai_ready() generate_normal = ready and bool( getattr(settings, "cutout_generate_normal", False) @@ -81,7 +70,7 @@ def open_shape_pie( source_name, selection_path_json, generate_normal, - point_spacing, + edge_length, alpha_threshold, fine_outline, gesture, diff --git a/src/anyimage/operators/cutout_tool/object.py b/src/anyimage/operators/cutout_tool/object.py index 8fd4aa4..d42e1e9 100644 --- a/src/anyimage/operators/cutout_tool/object.py +++ b/src/anyimage/operators/cutout_tool/object.py @@ -26,7 +26,6 @@ from .geometry import ( CUTOUT_ALPHA_THRESHOLD, crop_plane_bounds, cutout_cleanup_value, - cutout_sampling_spacing, selection_calibration, world_plane_metric, ) @@ -98,7 +97,7 @@ def create_shape_object( context, source_object, shape, - point_spacing, + edge_length, content_values, content_bounds, base_shape, @@ -125,7 +124,7 @@ def create_shape_object( ) world_metric, _world_z_scale = world_plane_metric(source_object.matrix_world) cleanup_value = cutout_cleanup_value( - cutout_sampling_spacing(source_object, point_spacing), + edge_length, world_metric, ) material = None diff --git a/src/anyimage/operators/cutout_tool/operators.py b/src/anyimage/operators/cutout_tool/operators.py index 0ce6902..7a63651 100644 --- a/src/anyimage/operators/cutout_tool/operators.py +++ b/src/anyimage/operators/cutout_tool/operators.py @@ -8,12 +8,12 @@ from ...preferences import ( addon_preferences, configured_geometry_model, configured_max_ai_input_size, - configured_max_cutout_mesh_resolution, ) from ...properties import ( cutout_alpha_threshold_property, cutout_fine_outline_property, cutout_gesture_property, + cutout_edge_length_property, ) from ...runtime import JobOperatorBase from ...server.model_catalog import DEFAULT_GEOMETRY_MODEL_KEY @@ -59,7 +59,7 @@ from ...common.selection import ( from .geometry import ( CUTOUT_ALPHA_THRESHOLD, build_base_shape, - limit_cutout_point_spacing, + effective_cutout_edge_length, ) from .interaction import open_shape_pie from .object import ( @@ -89,7 +89,7 @@ def create_cutout_shape( context, source_object, shape, - point_spacing, + edge_length, content_values, content_bounds, *, @@ -101,11 +101,22 @@ def create_cutout_shape( normal_mode=NormalMode.NONE, color_image, gesture="LASSO", + report=None, ): + effective_length = effective_cutout_edge_length( + context, source_object, edge_length, content_values, content_bounds, alpha_threshold + ) + if report is not None and effective_length > edge_length * (1 + 1e-6): + units = context.scene.unit_settings + label = bpy.utils.units.to_string( + units.system if units.system != "NONE" else "METRIC", "LENGTH", + effective_length * units.scale_length, precision=4, + ) + report({"INFO"}, f"Effective Edge Length: {label}") base_shape = build_base_shape( context, source_object, - point_spacing, + effective_length, content_values, content_bounds, alpha_threshold=alpha_threshold, @@ -115,7 +126,7 @@ def create_cutout_shape( context, source_object, shape, - point_spacing, + effective_length, content_values, content_bounds, base_shape, @@ -191,11 +202,7 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator): selection_bounds_json: bpy.props.StringProperty(options=_HIDDEN) shape: bpy.props.StringProperty(default="SOLID", options=_HIDDEN) gesture: bpy.props.StringProperty(default="LASSO", options=_HIDDEN) - point_spacing: bpy.props.FloatProperty( - default=32.0, - min=0.01, - options=_HIDDEN, - ) + edge_length: cutout_edge_length_property(options=_HIDDEN) model: bpy.props.StringProperty( default=DEFAULT_GEOMETRY_MODEL_KEY, options=_HIDDEN, @@ -234,13 +241,6 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator): source_rgba = image_rgba(source_object.data) context.view_layer.objects.active = source_object source_object.select_set(True) - crop_width = selection_mask.bounds[2] - selection_mask.bounds[0] - crop_height = selection_mask.bounds[3] - selection_mask.bounds[1] - self.point_spacing = limit_cutout_point_spacing( - self.point_spacing, - (crop_width, crop_height), - configured_max_cutout_mesh_resolution(context), - ) self.max_ai_input_size = configured_max_ai_input_size(context) if not self._needs_generation(): left, top, right, bottom = selection_mask.bounds @@ -251,11 +251,12 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator): ) with material_color_image(source_object.data, bounds=selection_mask.bounds, rgba=source_rgba) as color_image: create_cutout_shape( - context, source_object, self.shape, self.point_spacing, + context, source_object, self.shape, self.edge_length, content_values, selection_mask.bounds, alpha_threshold=self.alpha_threshold, fine_outline=self.fine_outline, color_image=color_image, gesture=self.gesture, + report=self.report, ) return {"FINISHED"} setup_result = invoke_ai_setup_if_needed(self) @@ -354,7 +355,7 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator): context, source_object, self.shape, - self.point_spacing, + self.edge_length, content_values, content_bounds, depth_image=depth_image, @@ -365,6 +366,7 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator): fine_outline=self.fine_outline, color_image=color_image, gesture=self.gesture, + report=self.report, ) except Exception: if normal_image is not None and normal_image.users == 0: @@ -460,7 +462,7 @@ class CutoutTool(bpy.types.WorkSpaceTool): def draw_settings(context, layout, _tool): settings = context.scene.anyimage_settings layout.prop(settings, "cutout_gesture") - layout.prop(settings, "cutout_mesh_detail") + layout.prop(settings, "cutout_edge_length") layout.prop(settings, "cutout_fine_outline") layout.prop(settings, "cutout_alpha_threshold") status = draw_ai_setup(layout) diff --git a/src/anyimage/preferences.py b/src/anyimage/preferences.py index 518b152..d8534eb 100644 --- a/src/anyimage/preferences.py +++ b/src/anyimage/preferences.py @@ -16,9 +16,6 @@ from .server.model_catalog import ( UPSCALE_MODELS, ) -DEFAULT_MAX_CUTOUT_MESH_RESOLUTION = 1024 -MIN_CUTOUT_MESH_RESOLUTION = 4 -MAX_CUTOUT_MESH_RESOLUTION = 1024 DEFAULT_MAX_FRAME_RESOLUTION = 2048 MIN_FRAME_RESOLUTION = 64 MAX_FRAME_RESOLUTION = 8192 @@ -87,19 +84,9 @@ def configured_material_view_adaptation(context=None): ) -def configured_max_cutout_mesh_resolution(context=None): - preferences = addon_preferences(context) - value = int( - getattr( - preferences, - "max_cutout_mesh_resolution", - DEFAULT_MAX_CUTOUT_MESH_RESOLUTION, - ) - ) - return min( - max(value, MIN_CUTOUT_MESH_RESOLUTION), - MAX_CUTOUT_MESH_RESOLUTION, - ) +def configured_min_cutout_relative_edge_length(context=None): + percent = float(getattr(addon_preferences(context), "min_cutout_relative_edge_length", 1.0)) + return min(max(percent, 0.001), 100.0) / 100.0 def configured_max_frame_resolution(context=None): @@ -177,17 +164,13 @@ class AnyImagePreferences(bpy.types.AddonPreferences): step=256, subtype="PIXEL", ) - max_cutout_mesh_resolution: bpy.props.IntProperty( - name="Maximum Cutout Mesh Resolution", - description=( - "Limit the longest image edge used to sample the Cutout mesh. " - "Higher values allow finer outlines and denser meshes." - ), - default=DEFAULT_MAX_CUTOUT_MESH_RESOLUTION, - min=MIN_CUTOUT_MESH_RESOLUTION, - max=MAX_CUTOUT_MESH_RESOLUTION, - step=64, - subtype="PIXEL", + min_cutout_relative_edge_length: bpy.props.FloatProperty( + name="Minimum Relative Edge Length", + description="Minimum target edge length as a percentage of the Cutout bounds' longest side", + default=1.0, + min=0.001, + max=100.0, + subtype="PERCENTAGE", ) max_frame_resolution: bpy.props.IntProperty( name="Maximum Frame Resolution", @@ -292,7 +275,7 @@ class AnyImagePreferences(bpy.types.AddonPreferences): tools.prop(self, "geometry_resolution_level") tools.separator(type="LINE") tools.label(text="Cutout Tool") - tools.prop(self, "max_cutout_mesh_resolution") + tools.prop(self, "min_cutout_relative_edge_length") tools.prop(self, "balloon_profile_algorithm") @staticmethod diff --git a/src/anyimage/properties.py b/src/anyimage/properties.py index 892d890..9d52eec 100644 --- a/src/anyimage/properties.py +++ b/src/anyimage/properties.py @@ -242,24 +242,39 @@ def cutout_fine_outline_property(): ) -def cutout_mesh_detail_property(): - return bpy.props.EnumProperty( - name="Mesh Detail", - description="Control mesh density. Higher levels use more vertices to represent finer shapes.", - items=( - ("LOW", "Low", "32 source image pixels between neighboring mesh points"), - ("MEDIUM", "Medium", "16 source image pixels between neighboring mesh points"), - ("HIGH", "High", "8 source image pixels between neighboring mesh points"), - ("ULTRA", "Ultra", "4 source image pixels between neighboring mesh points"), - ), - default="LOW", +def _cutout_length_scale(owner): + scene = getattr(owner, "id_data", None) + if not isinstance(scene, bpy.types.Scene): + scene = bpy.context.scene + return scene.unit_settings.scale_length + + +def _get_cutout_edge_length(owner): + return owner.get("cutout_edge_length_meters", 0.1) / _cutout_length_scale(owner) + + +def _set_cutout_edge_length(owner, value): + owner["cutout_edge_length_meters"] = value * _cutout_length_scale(owner) + + +def cutout_edge_length_property(*, store_meters=False, **options): + if store_meters: + options.update(get=_get_cutout_edge_length, set=_set_cutout_edge_length) + return bpy.props.FloatProperty( + name="Edge Length", + description="Target mesh edge length in world space", + default=0.1, + min=1e-6, + unit="LENGTH", + subtype="DISTANCE", + **options, ) class AnyImageSettings(bpy.types.PropertyGroup): cutout_gesture: cutout_gesture_property() cutout_alpha_threshold: cutout_alpha_threshold_property() - cutout_mesh_detail: cutout_mesh_detail_property() + cutout_edge_length: cutout_edge_length_property(store_meters=True) cutout_fine_outline: cutout_fine_outline_property() cutout_generate_normal: cutout_generate_normal_property() mask_gesture: mask_gesture_property() diff --git a/tests/anyimage/common/test_image_materials.py b/tests/anyimage/common/test_image_materials.py index bec5d6c..fbb38bb 100644 --- a/tests/anyimage/common/test_image_materials.py +++ b/tests/anyimage/common/test_image_materials.py @@ -92,7 +92,7 @@ def test_cutout_material_uses_ior_1_2(): source.empty_display_size = 2.0 bpy.context.collection.objects.link(source) - result = _create_cutout_shape(source, "FLAT", 8) + result = _create_cutout_shape(source, "FLAT", 0.1) shader = next( node diff --git a/tests/anyimage/operators/test_cutout_edge_length.py b/tests/anyimage/operators/test_cutout_edge_length.py new file mode 100644 index 0000000..1cb3e5b --- /dev/null +++ b/tests/anyimage/operators/test_cutout_edge_length.py @@ -0,0 +1,132 @@ +from types import SimpleNamespace +from unittest.mock import Mock, patch + +import bpy +import numpy as np +import pytest +from mathutils import Matrix + +from anyimage import properties +from anyimage.operators.cutout_tool import geometry as g +from anyimage.operators.cutout_tool import operators + + +def source_plane(size=(100, 100), display_size=1, matrix=None): + return SimpleNamespace( + data=SimpleNamespace(size=size), + empty_display_size=display_size, + empty_image_offset=(-0.5, -0.5), + matrix_world=Matrix.Identity(4) if matrix is None else matrix, + ) + + +def test_relative_floor_uses_visible_world_bounds(monkeypatch): + monkeypatch.setattr(g, "configured_min_cutout_relative_edge_length", lambda _: 0.01) + alpha = np.zeros((100, 100)) + alpha[25:75, 25:75] = 1 + source = source_plane(display_size=2) + resolve = lambda length: g.effective_cutout_edge_length(None, source, length, alpha, (0, 0, 100, 100)) + assert resolve(0.001) == 0.01 + assert resolve(0.1) == 0.1 + source.matrix_world = Matrix.Rotation(0.7, 4, "Y") @ Matrix.Diagonal((2, 3, 1, 1)) + assert resolve(0.001) == pytest.approx(0.03) + monkeypatch.setattr(g, "configured_min_cutout_relative_edge_length", lambda _: 0.02) + assert resolve(0.001) == pytest.approx(0.06) + + +@pytest.mark.parametrize("fine_outline", (False, True)) +def test_mesh_density_survives_resolution_and_canvas_changes(fine_outline): + meshes = [] + for size, display_size, inset in ((100, 1, 0), (200, 1, 0), (200, 2, 50)): + source = source_plane((size, size), display_size) + alpha = np.zeros((size, size)) + alpha[inset:size-inset, inset:size-inset] = 1 + spacing = g.effective_cutout_edge_length(bpy.context, source, 0.1, alpha, (0, 0, size, size)) + assert spacing == 0.1 + meshes.append(g.build_base_shape(bpy.context, source, spacing, alpha, (0, 0, size, size), fine_outline=fine_outline)) + counts = [len(mesh.vertices) for mesh in meshes] + assert max(counts) / min(counts) < 1.05 + for mesh in meshes: + assert np.isfinite(mesh.vertices).all() + np.testing.assert_allclose(np.ptp(mesh.vertices[:, :2], axis=0), (1, 1), atol=0.03) + + +@pytest.mark.parametrize("fine_outline", (False, True)) +def test_world_scaling_adds_samples_and_rotation_preserves_density(fine_outline): + alpha = np.ones((100, 100)) + counts = [] + for matrix in (Matrix.Identity(4), Matrix.Diagonal((2, 3, 1, 1)), + Matrix.Rotation(0.6, 4, "Z") @ Matrix.Diagonal((2, 3, 1, 1))): + source = source_plane(matrix=matrix) + mesh = g.build_base_shape(bpy.context, source, 0.1, alpha, (0, 0, 100, 100), fine_outline=fine_outline) + counts.append(len(mesh.vertices)) + planar = mesh.vertices[:, :2] @ g.metric_transform(g.world_plane_metric(matrix)[0]) + edges = np.unique(np.sort(np.concatenate([mesh.faces[:, [0, 1]], mesh.faces[:, [1, 2]], mesh.faces[:, [2, 0]]]), axis=1), axis=0) + lengths = np.linalg.norm(planar[edges[:, 0]] - planar[edges[:, 1]], axis=1) + assert 0.06 < np.median(lengths) < 0.14 + assert counts[1] > counts[0] * 4 + assert abs(counts[2] - counts[1]) < counts[1] * 0.02 + + +def test_length_property_uses_scene_units_and_preserves_physical_value(): + class CutoutLengthTestSettings(bpy.types.PropertyGroup): + edge_length: properties.cutout_edge_length_property(store_meters=True) + + scene = bpy.context.scene + previous_scale, previous_system = scene.unit_settings.scale_length, scene.unit_settings.system + bpy.utils.register_class(CutoutLengthTestSettings) + bpy.types.Scene.cutout_length_test = bpy.props.PointerProperty(type=CutoutLengthTestSettings) + try: + scene.unit_settings.system = "METRIC" + scene.unit_settings.scale_length = 0.01 + settings = scene.cutout_length_test + assert settings.edge_length == pytest.approx(10) + settings.edge_length = bpy.utils.units.to_value("METRIC", "LENGTH", "0.2m") / scene.unit_settings.scale_length + assert settings.edge_length == pytest.approx(20) + scene.unit_settings.scale_length = 1 + assert settings.edge_length == pytest.approx(0.2) + finally: + scene.unit_settings.scale_length = previous_scale + scene.unit_settings.system = previous_system + if "cutout_length_test" in scene: + del scene["cutout_length_test"] + del bpy.types.Scene.cutout_length_test + bpy.utils.unregister_class(CutoutLengthTestSettings) + + +def test_operator_length_property_accepts_world_units(): + captured = [] + + class CutoutLengthTestOperator(bpy.types.Operator): + bl_idname = "anyimage.test_cutout_length" + bl_label = "Test Cutout Length" + edge_length: properties.cutout_edge_length_property() + + def execute(self, context): + captured.append(self.edge_length) + return {"FINISHED"} + + units = bpy.context.scene.unit_settings + previous = units.scale_length + bpy.utils.register_class(CutoutLengthTestOperator) + try: + units.scale_length = 0.01 + bpy.ops.anyimage.test_cutout_length(edge_length=20) + assert captured == pytest.approx([20]) + finally: + units.scale_length = previous + bpy.utils.unregister_class(CutoutLengthTestOperator) + + +def test_effective_length_report_and_geometry_share_value(): + report = Mock() + context = SimpleNamespace(scene=SimpleNamespace(unit_settings=SimpleNamespace(system="METRIC", scale_length=0.01))) + with patch.object(operators, "effective_cutout_edge_length", return_value=20), \ + patch.object(operators, "build_base_shape") as build, \ + patch.object(operators, "create_shape_object") as create, \ + patch.object(operators.bpy.ops.ed, "undo_push", return_value={"FINISHED"}): + operators.create_cutout_shape(context, object(), "FLAT", 10, np.ones((10, 10)), (0, 0, 10, 10), color_image=object(), report=report) + assert build.call_args.args[2] == 20 + assert create.call_args.args[3] == 20 + assert report.call_args.args[0] == {"INFO"} + assert "20 cm" in report.call_args.args[1] diff --git a/tests/anyimage/operators/test_cutout_fine_mesh.py b/tests/anyimage/operators/test_cutout_fine_mesh.py index 170f85a..3d73e6a 100644 --- a/tests/anyimage/operators/test_cutout_fine_mesh.py +++ b/tests/anyimage/operators/test_cutout_fine_mesh.py @@ -156,13 +156,13 @@ def test_closed_mode_uses_original_geometry_without_fine_import(monkeypatch): ) alpha = np.ones((60, 80)) actual = g.build_base_shape( - None, source, 16, alpha, (0, 0, 80, 60), fine_outline=False + None, source, 0.4, alpha, (0, 0, 80, 60), fine_outline=False ) - contours = g.extract_cutout_contours(alpha, 16, threshold=0.9) + contours = g.extract_cutout_contours(alpha, 5, threshold=0.9) expected = g.cutout_domain( contours, image_empty_bounds(source), - g.cutout_sampling_spacing(source, 16), + 0.4, ((1.0, 0.0), (0.0, 1.0)), ) np.testing.assert_array_equal(actual.vertices, expected[0]) @@ -195,7 +195,7 @@ def test_relaxation_reduces_sampling_error_without_adding_points(): contours = [np.asarray(((4.0, 4.0), (194.0, 4.0), (194.0, 104.0), (4.0, 104.0)))] spine = np.empty((0, 2)) original = g.uniform_interior_points_for_contours(contours, 16) - relaxed = fine.relax_points(contours, spine, (110, 200), 16) + relaxed = fine.relax_points(contours, spine, 16) yy, xx = np.mgrid[6:104:4, 6:194:4] samples = np.column_stack((xx.ravel() + 0.5, yy.ravel() + 0.5)) diff --git a/tests/anyimage/operators/test_cutout_geometry.py b/tests/anyimage/operators/test_cutout_geometry.py index 62056cb..553408d 100644 --- a/tests/anyimage/operators/test_cutout_geometry.py +++ b/tests/anyimage/operators/test_cutout_geometry.py @@ -33,12 +33,6 @@ def mesh_component_count(faces): return count -def test_cutout_mesh_limit_caps_actual_sampling_resolution(): - assert cutout_geometry.limit_cutout_point_spacing(32, (4096, 1024), 1024) == 32 - assert cutout_geometry.limit_cutout_point_spacing(32, (4096, 1024), 64) == 64 - assert cutout_geometry.limit_cutout_point_spacing(4, (8192, 4096), 1024) == 8 - - def test_cutout_node_groups_are_loaded_from_the_asset(): loaded = object() with patch.object( @@ -147,7 +141,7 @@ def test_blender_cutout_alpha_pipeline_does_not_import_pillow(): assert np.any((antialiased > 0.0) & (antialiased < 1.0)) -def test_blender_cutout_contour_resolution_tracks_mesh_detail(): +def test_blender_cutout_contour_resolution_tracks_sampling_resolution(): expected = SelectionContours( components=(((((0, 0), (1, 0), (0, 1))),),), image_size=(1, 1), @@ -155,53 +149,12 @@ def test_blender_cutout_contour_resolution_tracks_mesh_detail(): values = SimpleNamespace(shape=(1024, 1024)) with patch.object(cutout_geometry, "extract_selection_contours", return_value=expected) as trace: assert cutout_geometry.extract_cutout_contours(values, 32) == expected - assert cutout_geometry.extract_cutout_contours(values, 4) == expected + assert cutout_geometry.extract_cutout_contours(values, 256) == expected assert trace.call_args_list[0].kwargs["max_resolution"] == 512 assert trace.call_args_list[1].kwargs["max_resolution"] == 1024 -def test_cutout_sampling_spacing_uses_the_complete_source_plane(): - source = SimpleNamespace( - data=SimpleNamespace(size=(400, 200)), - matrix_world=object(), - ) - with ( - patch.object(cutout_geometry, "image_empty_bounds", return_value=(-2.0, 2.0, -1.0, 1.0)), - patch.object( - cutout_geometry, - "world_plane_metric", - return_value=(((1.0, 0.0), (0.0, 1.0)), 1.0), - ), - ): - spacing = cutout_geometry.cutout_sampling_spacing(source, 32) - - assert spacing == 0.32 - - -def test_cutout_sampling_spacing_tracks_upscaled_source_pixels(): - original = SimpleNamespace( - data=SimpleNamespace(size=(2048, 1024)), - matrix_world=object(), - ) - upscaled = SimpleNamespace( - data=SimpleNamespace(size=(8192, 4096)), - matrix_world=object(), - ) - with ( - patch.object(cutout_geometry, "image_empty_bounds", return_value=(-2.0, 2.0, -1.0, 1.0)), - patch.object( - cutout_geometry, - "world_plane_metric", - return_value=(((1.0, 0.0), (0.0, 1.0)), 1.0), - ), - ): - original_spacing = cutout_geometry.cutout_sampling_spacing(original, 32) - upscaled_spacing = cutout_geometry.cutout_sampling_spacing(upscaled, 32) - - assert upscaled_spacing == original_spacing / 4 - - def test_cutout_cleanup_value_scales_the_ui_without_changing_node_area(): assert np.isclose( cutout_geometry.cutout_cleanup_value( diff --git a/tests/anyimage/operators/test_cutout_interaction.py b/tests/anyimage/operators/test_cutout_interaction.py index eb598a8..5fbd21d 100644 --- a/tests/anyimage/operators/test_cutout_interaction.py +++ b/tests/anyimage/operators/test_cutout_interaction.py @@ -35,13 +35,13 @@ class WindowManager: def cutout_context( window_manager, - mesh_detail="LOW", + edge_length=0.1, generate_normal=False, alpha_threshold=0.9, fine_outline=True, ): settings = SimpleNamespace( - cutout_mesh_detail=mesh_detail, + cutout_edge_length=edge_length, cutout_generate_normal=generate_normal, cutout_alpha_threshold=alpha_threshold, cutout_fine_outline=fine_outline, @@ -97,14 +97,14 @@ def test_cutout_pie_uses_two_local_shapes_without_ai(monkeypatch): 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].point_spacing == 32 for call in window_manager.pie.calls) + assert all(call[2].edge_length == 0.1 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", + edge_length=0.05, generate_normal=True, alpha_threshold=0.1, fine_outline=False, @@ -150,7 +150,7 @@ def test_cutout_pie_passes_image_lasso_options_to_all_ai_shapes(monkeypatch): == ((0.0, 0.0), (10.0, 0.0), (0.0, 10.0)) for call in window_manager.pie.calls ) - assert all(call[2].point_spacing == 16 for call in window_manager.pie.calls) + assert all(call[2].edge_length == 0.05 for call in window_manager.pie.calls) def test_cutout_normal_space_is_derived_from_shape(): @@ -162,13 +162,6 @@ def test_cutout_normal_space_is_derived_from_shape(): assert cutout_main.normal_mode_for_shape("DEPTH_SOLID", True) == "OBJECT" -def test_cutout_mesh_detail_maps_to_denser_sampling(): - assert [ - interaction.cutout_mesh_detail_spacing(detail) - for detail in ("LOW", "MEDIUM", "HIGH", "ULTRA") - ] == [32, 16, 8, 4] - - def test_cutout_lasso_submission_passes_only_the_selected_path(monkeypatch): source = cutout_source() window_manager = WindowManager() diff --git a/tests/anyimage/operators/test_cutout_objects.py b/tests/anyimage/operators/test_cutout_objects.py index fc9be60..a0c5c62 100644 --- a/tests/anyimage/operators/test_cutout_objects.py +++ b/tests/anyimage/operators/test_cutout_objects.py @@ -43,13 +43,13 @@ def test_cutout_depth_axis_is_adjusted_only_in_modifier(shape, gesture): positive_x = _create_cutout_shape( source, shape, - 8, + 0.1, gesture=gesture, ) negative_z = _create_cutout_shape( source, shape, - 8, + 0.1, gesture=gesture, ) @@ -160,7 +160,7 @@ def test_balloon_build_starts_from_the_unified_single_sheet(): result = _create_cutout_shape( source, "SOLID", - 8, + 0.1, ) assert all(abs(vertex.co.z) < 1e-8 for vertex in result.data.vertices) @@ -211,7 +211,7 @@ def test_teddy_preference_writes_the_shared_balloon_attribute(): result = _create_cutout_shape( source, "FLAT", - 8, + 0.1, ) attribute = result.data.attributes[cutout_shape.BALLOON_ATTRIBUTE_NAME] @@ -251,7 +251,7 @@ def test_depth_surface_protected_smooth_evaluates(gesture): result = _create_cutout_shape( source, "DEPTH_SOLID", - 8, + 0.25, depth_image=depth_image, depth_metadata=depth_metadata, gesture=gesture, @@ -270,7 +270,7 @@ def test_depth_surface_protected_smooth_evaluates(gesture): 0.0, ) expected_cleanup = cutout_geometry.cutout_cleanup_value( - cutout_geometry.cutout_sampling_spacing(source, 8), + 0.25, cutout_geometry.world_plane_metric(source.matrix_world)[0], ) assert np.isclose( @@ -374,7 +374,7 @@ def test_depth_surface_keeps_continuous_points_across_zero_validity_edge(): result = _create_cutout_shape( source, "DEPTH_SOLID", - 8, + 0.1, depth_image=_depth_image(points, validity), depth_metadata=_depth_metadata(intrinsics, (size, size), 1.0), ) @@ -621,7 +621,7 @@ def test_depth_balloon_uses_metric_plane_inputs(): result = _create_cutout_shape( source, "DEPTH_BALLOON", - 8, + 0.1, depth_image=depth_image, depth_metadata=depth_metadata, bounds=crop_bounds, @@ -869,7 +869,7 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job(): bpy.context, source, "FLAT", - 16, + 0.1, content_values, selection_mask.bounds, alpha_threshold=0.5, @@ -885,7 +885,7 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job(): modifier = result.modifiers[0] assert modifier.node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAMES["FLAT"] expected_cleanup = cutout_geometry.cutout_cleanup_value( - cutout_geometry.cutout_sampling_spacing(source, 16), + 0.1, cutout_geometry.world_plane_metric(source.matrix_world)[0], ) assert np.isclose( diff --git a/tests/anyimage/operators/test_cutout_operators.py b/tests/anyimage/operators/test_cutout_operators.py index 7b95924..e29af11 100644 --- a/tests/anyimage/operators/test_cutout_operators.py +++ b/tests/anyimage/operators/test_cutout_operators.py @@ -69,7 +69,7 @@ class CutoutOperatorsTest(BlenderTestCase): ), shape=shape, gesture="POLYLINE", - point_spacing=32, + edge_length=0.1, alpha_threshold=0.25, fine_outline=False, generate_normal=False, @@ -98,11 +98,6 @@ class CutoutOperatorsTest(BlenderTestCase): "configured_max_ai_input_size", return_value=2048, ), - patch.object( - self.cutout_main, - "configured_max_cutout_mesh_resolution", - return_value=1024, - ), patch.object( self.cutout_main, "_cutout_content_values", @@ -128,13 +123,14 @@ class CutoutOperatorsTest(BlenderTestCase): context, source, shape, - 32.0, + 0.1, selection_mask.values, selection_mask.bounds, alpha_threshold=0.25, fine_outline=False, color_image="Color", gesture="POLYLINE", + report=operator.report, ) @@ -161,7 +157,8 @@ class CutoutOperatorsTest(BlenderTestCase): {"points": [[1, 1], [7, 1], [7, 3], [1, 3]]} ) operator.shape = "DEPTH_SOLID" - operator.point_spacing = 32 + operator.report = lambda *_args: None + operator.edge_length = 0.1 operator.generate_normal = False operator.report = lambda *_args: None @@ -187,11 +184,6 @@ class CutoutOperatorsTest(BlenderTestCase): "configured_max_ai_input_size", return_value=2048, ), - patch.object( - self.cutout_main, - "configured_max_cutout_mesh_resolution", - return_value=1024, - ), patch.object( self.cutout_main, "invoke_ai_setup_if_needed", @@ -241,7 +233,7 @@ class CutoutOperatorsTest(BlenderTestCase): {"points": [[1, 1], [7, 1], [7, 3], [1, 3]]} ), shape="FLAT", - point_spacing=32, + edge_length=0.1, generate_normal=False, report=lambda level, message: reports.append((level, message)), ) @@ -377,7 +369,7 @@ class CutoutOperatorsTest(BlenderTestCase): layout.events, [ ("prop", "cutout_gesture", True), - ("prop", "cutout_mesh_detail", True), + ("prop", "cutout_edge_length", True), ("prop", "cutout_fine_outline", True), ("prop", "cutout_alpha_threshold", True), setup, @@ -413,7 +405,8 @@ class CutoutOperatorsTest(BlenderTestCase): operator.color_path = __file__ operator.shape = "FLAT" operator.gesture = "POLYLINE" - operator.point_spacing = 32 + operator.report = lambda *_args: None + operator.edge_length = 0.1 operator.generate_normal = True operator._selection_mask = selection_mask result = SimpleNamespace(value={}, file=lambda _key: Path(__file__)) diff --git a/tests/anyimage/test_addon_registration.py b/tests/anyimage/test_addon_registration.py index 259446c..40ccafd 100644 --- a/tests/anyimage/test_addon_registration.py +++ b/tests/anyimage/test_addon_registration.py @@ -178,7 +178,7 @@ class AddonRegistrationTest(BlenderTestCase): drawn_settings, [ "cutout_gesture", - "cutout_mesh_detail", + "cutout_edge_length", "cutout_fine_outline", "cutout_alpha_threshold", "cutout_generate_normal", @@ -226,7 +226,7 @@ class AddonRegistrationTest(BlenderTestCase): "radius", }, ) - self.assertEqual(settings["cutout_mesh_detail"]["default"], "LOW") + self.assertEqual(settings["cutout_edge_length"]["default"], 0.1) self.assertFalse(settings["cutout_fine_outline"]["default"]) self.assertFalse(cutout_module.CutoutSelectionToShape.__annotations__["fine_outline"]["default"]) self.assertEqual(settings["cutout_alpha_threshold"]["default"], 0.9) @@ -235,18 +235,10 @@ class AddonRegistrationTest(BlenderTestCase): self.assertEqual(settings["cutout_alpha_threshold"]["soft_min"], 0.1) self.assertEqual(settings["cutout_alpha_threshold"]["soft_max"], 0.9) self.assertEqual(settings["cutout_alpha_threshold"]["subtype"], "FACTOR") - self.assertEqual( - [item[:2] for item in settings["cutout_mesh_detail"]["items"]], - [ - ("LOW", "Low"), - ("MEDIUM", "Medium"), - ("HIGH", "High"), - ("ULTRA", "Ultra"), - ], - ) + self.assertEqual(settings["cutout_edge_length"]["unit"], "LENGTH") self.assertFalse(settings["cutout_generate_normal"]["default"]) self.assertEqual( - settings["cutout_mesh_detail"]["name"], "Mesh Detail" + settings["cutout_edge_length"]["name"], "Edge Length" ) self.assertEqual( settings["cutout_generate_normal"]["name"], @@ -287,7 +279,7 @@ class AddonRegistrationTest(BlenderTestCase): settings = self.anyimage.properties.AnyImageSettings.__annotations__ self.assertEqual(set(settings), { - "cutout_gesture", "cutout_alpha_threshold", "cutout_mesh_detail", "cutout_fine_outline", + "cutout_gesture", "cutout_alpha_threshold", "cutout_edge_length", "cutout_fine_outline", "cutout_generate_normal", "mask_gesture", "mask_mode", "mask_radius", }) diff --git a/tests/anyimage/test_preferences.py b/tests/anyimage/test_preferences.py index b23114d..a2021f7 100644 --- a/tests/anyimage/test_preferences.py +++ b/tests/anyimage/test_preferences.py @@ -132,24 +132,16 @@ class PreferencesTest(BlenderTestCase): ) - def test_cutout_mesh_resolution_defaults_and_clamps_independently(self): - preference_module = self.anyimage.preferences + def test_cutout_relative_edge_length_defaults_and_clamps(self): + module = self.anyimage.preferences preferences = self.fake_bpy.context.preferences.addons["anyimage"].preferences - - self.assertEqual( - preference_module.configured_max_cutout_mesh_resolution(), - 1024, - ) - preferences.max_cutout_mesh_resolution = 2048 - self.assertEqual( - preference_module.configured_max_cutout_mesh_resolution(), - 1024, - ) - preferences.max_cutout_mesh_resolution = 1 - self.assertEqual( - preference_module.configured_max_cutout_mesh_resolution(), - 4, - ) + self.assertEqual(module.configured_min_cutout_relative_edge_length(), 0.01) + preferences.min_cutout_relative_edge_length = 2 + self.assertEqual(module.configured_min_cutout_relative_edge_length(), 0.02) + preferences.min_cutout_relative_edge_length = 200 + self.assertEqual(module.configured_min_cutout_relative_edge_length(), 1) + preferences.min_cutout_relative_edge_length = 0 + self.assertAlmostEqual(module.configured_min_cutout_relative_edge_length(), 0.00001) def test_frame_resolution_defaults_and_clamps_independently(self): diff --git a/tests/support/image_objects.py b/tests/support/image_objects.py index 9b78336..d7279ef 100644 --- a/tests/support/image_objects.py +++ b/tests/support/image_objects.py @@ -29,7 +29,7 @@ def _group_node(group, node_tree): def _create_cutout_shape( source, shape, - detail, + edge_length, *, bounds=None, depth_image=None, @@ -52,7 +52,7 @@ def _create_cutout_shape( base_shape = cutout_geometry.build_base_shape( bpy.context, source, - detail, + edge_length, selection_mask.values, selection_mask.bounds, ) @@ -60,7 +60,7 @@ def _create_cutout_shape( bpy.context, source, shape, - detail, + edge_length, selection_mask.values, selection_mask.bounds, base_shape, diff --git a/tests/tools/nodes/test_depth_cutout.py b/tests/tools/nodes/test_depth_cutout.py index 98cb608..8582953 100644 --- a/tests/tools/nodes/test_depth_cutout.py +++ b/tests/tools/nodes/test_depth_cutout.py @@ -25,8 +25,8 @@ def set_test_structure(monkeypatch, value): from tools.nodes.groups import image_depth_cutout build = image_depth_cutout.build_front_fields - def fields(group, rim, normal, normal_weight): - result = build(group, rim, normal, normal_weight) + def fields(group, volume, normal, normal_weight): + result = build(group, volume, normal, normal_weight) structure = group.nodes.new("ShaderNodeValue") structure.outputs[0].default_value = value result["structure"] = structure.outputs[0] diff --git a/tests/tools/nodes/test_inventory.py b/tests/tools/nodes/test_inventory.py index 84fdb27..21c52ca 100644 --- a/tests/tools/nodes/test_inventory.py +++ b/tests/tools/nodes/test_inventory.py @@ -67,7 +67,7 @@ def test_saved_parameter_descriptions_match_definitions(): assert len(thickness) == 2 and len(set(thickness)) == 2 if group.name == "O Image Depth Cutout": inflation = next(item for item in sockets if item.name == "Front Inflation") - assert inflation.default_value == 0.5 + assert inflation.default_value == 0.0 result[group.name] = [ (item.identifier, item.name, item.description) for item in sockets ] @@ -82,6 +82,35 @@ def test_saved_parameter_descriptions_match_definitions(): assert descriptions(target.node_groups) == expected +@pytest.mark.parametrize("source", ["definition", "asset"]) +def test_geometry_nodes_contribute_to_output(source): + from tools.nodes.build import build_node_groups + + bpy.ops.wm.read_factory_settings(use_empty=True) + if source == "definition": + groups = build_node_groups() + else: + with bpy.data.libraries.load(str(node_asset_path())) as (library, target): + target.node_groups = list(library.node_groups) + groups = target.node_groups + for group in groups: + if group.bl_idname != "GeometryNodeTree": + continue + pending = [node for node in group.nodes if node.bl_idname == "NodeGroupOutput"] + used = set() + while pending: + node = pending.pop() + if node in used: + continue + used.add(node) + pending.extend(link.from_node for socket in node.inputs for link in socket.links) + pending.extend(candidate for candidate in group.nodes + if getattr(candidate, "paired_output", None) == node) + unused = [node.name for node in group.nodes + if node not in used and node.bl_idname != "NodeFrame"] + assert not unused, (group.name, unused) + + @pytest.mark.parametrize("source", ["definition", "asset"]) def test_geometry_attribute_contract_includes_nested_groups(source): from tools.nodes.build import build_node_groups diff --git a/tools/nodes/groups/image_cutout.py b/tools/nodes/groups/image_cutout.py index f655b64..a9762b3 100644 --- a/tools/nodes/groups/image_cutout.py +++ b/tools/nodes/groups/image_cutout.py @@ -4,7 +4,7 @@ from mathutils import Matrix from anyimage.operators.cutout_tool.shape import CUTOUT_Z_TO_X, NORMAL_SCALE_ATTRIBUTE_NAME from ..common.nodes import interface_socket, group_input, math_node, float_input, menu_switch, read_float_attribute, store_float_attribute, prepare_node_group from ..common.smoothing import expand_smooth -from ..common.cutout import _extrude_layers, _island_cleanup, _offset_geometry_z, _profile_field, _remove_zero_thickness, _set_layer_z, _shade_output +from ..common.cutout import _extrude_layers, _island_cleanup, _offset_geometry_z, _remove_zero_thickness, _set_layer_z, _shade_output NODE_GROUP_NAME = "O Image Cutout" @@ -129,16 +129,13 @@ def _build_shell(group, geometry, thickness): def _build_balloon(group, geometry, thickness): nodes = group.nodes links = group.links - profile, _profile_factor = _profile_field(group, geometry) + profile = read_float_attribute(group, 'o_balloon') front_scale = math_node(nodes, "MULTIPLY") links.new(profile, front_scale.inputs[0]) links.new(thickness, front_scale.inputs[1]) - back = math_node(nodes, "MULTIPLY") - links.new(profile, back.inputs[0]) - links.new(thickness, back.inputs[1]) negative_back = math_node(nodes, "MULTIPLY") negative_back.inputs[1].default_value = -1.0 - links.new(back.outputs[0], negative_back.inputs[0]) + links.new(front_scale.outputs[0], negative_back.inputs[0]) extrude = _extrude_layers(group, geometry) shaped = _set_layer_z( group, diff --git a/tools/nodes/groups/image_depth_balloon.py b/tools/nodes/groups/image_depth_balloon.py index b8d4d38..c4f22ed 100644 --- a/tools/nodes/groups/image_depth_balloon.py +++ b/tools/nodes/groups/image_depth_balloon.py @@ -68,12 +68,9 @@ def _build_depth_balloon( base_front = math_node(nodes, "MULTIPLY") links.new(profile, base_front.inputs[0]) links.new(thickness, base_front.inputs[1]) - base_back = math_node(nodes, "MULTIPLY") - links.new(profile, base_back.inputs[0]) - links.new(thickness, base_back.inputs[1]) negate_back = math_node(nodes, "MULTIPLY") negate_back.inputs[1].default_value = -1.0 - links.new(base_back.outputs[0], negate_back.inputs[0]) + links.new(base_front.outputs[0], negate_back.inputs[0]) front_mix = nodes.new("ShaderNodeMix") front_mix.data_type = "FLOAT" front_mix.clamp_factor = True diff --git a/tools/nodes/groups/image_depth_cutout.py b/tools/nodes/groups/image_depth_cutout.py index e69adab..46f7947 100644 --- a/tools/nodes/groups/image_depth_cutout.py +++ b/tools/nodes/groups/image_depth_cutout.py @@ -70,7 +70,10 @@ def build_volume_fields(group): positive = _math(group, "MAXIMUM", full, minimum) control = group_input(group.nodes, {"Thickness"}) thickness = _math(group, "MULTIPLY", positive, control.outputs["Thickness"]) - return {"rim": rim, "center": center, "thickness": thickness} + return { + "profile": profile, "depth": axes.outputs["Z"], "interior": interior, + "center": center, "thickness": thickness, + } def build_shell_fields(group, geometry, fields, is_shell, normal_weight=1): @@ -102,7 +105,7 @@ def build_shell_fields(group, geometry, fields, is_shell, normal_weight=1): return {"thickness": thickness.outputs[0], "normal": rear_normal} -def build_front_fields(group, rim, front_normal, normal_weight=1): +def build_front_fields(group, volume, front_normal, normal_weight=1): """Fit structural amplitude over neighbouring points before creating the shell.""" links = group.links @@ -115,15 +118,7 @@ def build_front_fields(group, rim, front_normal, normal_weight=1): links.new(weight, result.inputs["Weight"]) return result.outputs[0] - profile_node = group.nodes.new("GeometryNodeInputNamedAttribute") - profile_node.data_type = "FLOAT" - profile_node.inputs["Name"].default_value = "o_balloon" - profile = profile_node.outputs["Attribute"] - interior = _math(group, "SUBTRACT", 1, rim) - position = group.nodes.new("GeometryNodeInputPosition") - axes = group.nodes.new("ShaderNodeSeparateXYZ") - links.new(position.outputs[0], axes.inputs[0]) - depth = axes.outputs["Z"] + profile, depth, interior = volume["profile"], volume["depth"], volume["interior"] depth_slow = blur(depth, interior) profile_slow = blur(profile, interior) depth_bump = _math(group, "SUBTRACT", depth, depth_slow) @@ -285,18 +280,21 @@ def _build_depth_surface(group, geometry, controls): projection = smooth_cut_boundary(group, projection) projection = taper_split_profile(group, original, projection) # The mode field is shared by the geometry and thickness choices. - mode = menu_switch(group, "Mode", ("Balloon", "Shell"), "BOOLEAN") + mode = group.nodes.new("GeometryNodeMenuSwitch") + mode.data_type = "BOOLEAN" + for item, name in zip(mode.enum_items, ("Balloon", "Shell")): + item.name = name + links.new(controls.outputs["Mode"], mode.inputs["Menu"]) mode.inputs["Balloon"].default_value = False mode.inputs["Shell"].default_value = True is_shell = mode.outputs[0] volume = build_volume_fields(group) normal_weight = build_normal_weight(group, is_shell) normal = build_front_normal(group, normal_weight) - front = build_front_fields(group, volume["rim"], normal, normal_weight) + front = build_front_fields(group, volume, normal, normal_weight) shell_fields = build_shell_fields(group, projection, volume, is_shell, normal_weight) - thickness = sample_field(group, projection, shell_fields["thickness"], "FLOAT") shell = thicken_depth_surface( - group, projection, thickness, back_mask=NORMAL_SCALE_ATTRIBUTE_NAME, weld_zero_thickness=True, + group, projection, shell_fields["thickness"], back_mask=NORMAL_SCALE_ATTRIBUTE_NAME, weld_zero_thickness=True, ) fields = { name: sample_field(group, projection, value, data_type, index=shell["source_index"]) @@ -350,7 +348,7 @@ def build_image_depth_cutout_group(): outline_smooth = interface_socket(group, 'Outline Depth Fix', 'INPUT', 'NodeSocketInt', options) outline_smooth.default_value, outline_smooth.min_value, outline_smooth.max_value = 16, 0, 200 outline_smooth.description = 'Reduce abrupt depth changes near the original Cutout outline. Higher values apply more correction passes. Zero disables correction.' - float_input(group, 'Front Inflation', 0.5, maximum=1, subtype='FACTOR', parent=options).description = 'Control how much the front surface bulges in Balloon mode. Zero keeps the original front surface.' + float_input(group, 'Front Inflation', 0.0, maximum=1, subtype='FACTOR', parent=options).description = 'Control how much the front surface bulges in Balloon mode. Zero keeps the original front surface.' data = group.interface.new_panel(name='Data', default_closed=True) float_input(group, 'Uniform Scale', 1.0, parent=data).hide_in_modifier = True interface_socket(group, 'Depth Image', 'INPUT', 'NodeSocketImage', data).hide_in_modifier = True