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:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-12
|
||||
@@ -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、旋转和非均匀缩放的几何用例验证。
|
||||
@@ -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 采样链,以及相关注册、偏好与几何测试。移除旧枚举映射、像素间距协议和旧密度上限入口;无需新增依赖。
|
||||
@@ -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** 系统依据最终建网内容采用同一套有效边长规则
|
||||
17
openspec/changes/use-world-space-cutout-edge-length/tasks.md
Normal file
17
openspec/changes/use-world-space-cutout-edge-length/tasks.md
Normal 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.
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
132
tests/anyimage/operators/test_cutout_edge_length.py
Normal file
132
tests/anyimage/operators/test_cutout_edge_length.py
Normal 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]
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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__))
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user