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.
This commit is contained in:
2026-09-12 23:03:24 +08:00
parent 938d95db0f
commit 18799e8828
28 changed files with 505 additions and 279 deletions

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-12

View File

@@ -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、旋转和非均匀缩放的几何用例验证。

View File

@@ -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 采样链,以及相关注册、偏好与几何测试。移除旧枚举映射、像素间距协议和旧密度上限入口;无需新增依赖。

View File

@@ -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** 系统依据最终建网内容采用同一套有效边长规则

View File

@@ -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 检查并移除旧档位、像素间距参数和旧偏好引用,运行相关测试并确认正常退出,检查最终差异。

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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