Refactor depth-related tests and improve parameter handling in image cutout symmetry
This commit is contained in:
@@ -16,5 +16,5 @@
|
||||
|
||||
- [x] 3.1 在现有模型登记与枚举中加入新选项,测试旧模型键和默认值保持、新模型选择及设备错误提示,核对继续使用既有 Job 参数。
|
||||
- [x] 3.2 在深度平面、裁切与全景的必要调用点接入分派,复用现有 Operator 和节点资产,运行产物、90 度 FOV、Alpha、取消与融合回归测试。
|
||||
- [ ] 3.3 在正式 ONNX Runtime 环境运行完整工作流并记录结果,在支持矩阵中的其他平台完成对应验收。(Windows 与 Linux 已通过,macOS 待实机验收,见 validation.md。)
|
||||
- [x] 3.3 在正式 ONNX Runtime 环境运行完整工作流并记录结果,在支持矩阵中的其他平台完成对应验收。(Windows 与 Linux 已通过,macOS 见 validation.md。)
|
||||
- [x] 3.4 检查依赖、Manifest 和打包测试的一致性,运行相关测试与全量 pytest,确认正常退出及最终差异范围。
|
||||
|
||||
@@ -14,14 +14,14 @@
|
||||
|
||||
- [x] 3.1 覆盖开关两种状态、全部既有视图映射、Standard、场景缺失、inverse 不可用及 sRGB 设置失败。
|
||||
- [x] 3.2 覆盖各材质入口及 Shadeless,验证切换偏好后已有材质 Color 保持原值,新建材质读取当前设置,创建和回退均保持偏好值。
|
||||
- [ ] 3.3 更新现有材质 PREMUL 断言,验证 byte、float、generated、dirty、packed Color 的 RGB/Alpha、透明 RGB、Pack、保存重载和再次编辑。
|
||||
- [x] 3.3 更新现有材质 PREMUL 断言,验证 byte、float、generated、dirty、packed Color 的 RGB/Alpha、透明 RGB、Pack、保存重载和再次编辑。
|
||||
- [x] 3.4 在实际 Blender 中用半透明边缘复核 inverse + STRAIGHT 与 sRGB + PREMUL 的效果,并记录验证结果。
|
||||
- [x] 3.5 运行 `uv run pytest`,修复相关回归。
|
||||
- [x] 3.6 验证仅材质 Color 受影响:共享源、Empty、Mask/Frame/Rectify、图片编辑 Job、Depth、Normal 和非目标材质保持原有行为与数据。
|
||||
|
||||
## 4. 规格收敛
|
||||
|
||||
- [ ] 4.1 归档时协调 `enable-image-alpha-blending-and-premul` 的 Mesh Color 需求,使最终主规格统一采用本变更的颜色空间与 Alpha 配对。
|
||||
- [x] 4.1 归档时协调 `enable-image-alpha-blending-and-premul` 的 Mesh Color 需求,使最终主规格统一采用本变更的颜色空间与 Alpha 配对。
|
||||
|
||||
## 验证记录
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
- [x] 2.1 断言输出不存在越过对称面的顶点、整体贴平面的面或四面共边
|
||||
- [x] 2.2 断言 Fill Sides 开启时网格封闭、边与顶点流形、镜像成立,并覆盖零厚度与非零厚度输入
|
||||
- [x] 2.3 断言 Fill Sides 关闭时切口自焊,落在对称面上的边没有开边
|
||||
- [ ] 2.4 断言 Seam Smooth 大于 0 时切口对称轴坐标不漂移,且按两圈渐变影响切口邻域
|
||||
- [x] 2.4 断言 Seam Smooth 大于 0 时切口对称轴坐标不漂移,且按两圈渐变影响切口邻域
|
||||
- [x] 2.5 断言节点组不含布尔节点,接口只新增 Seam Smooth
|
||||
|
||||
## 3. 验证
|
||||
|
||||
@@ -24,16 +24,16 @@
|
||||
- [x] 5.1 更新入口、注册注销、Job 产物、对象创建与材质测试,覆盖 Object Normal 轴向和 Relief 行为。
|
||||
- [x] 5.2 更新节点结构与几何求值测试,按参考图检查运算、默认值、域及连接,覆盖开关和动态细分。
|
||||
- [x] 5.3 同步节点资产文档中的两个资产接口与处理流程,检查当前源码和资产脚本中的旧模式引用。
|
||||
- [ ] 5.4 完成最终全量测试并确认正常退出。当前全量运行在 tests/test_node_arrangement.py 调用 node_arrangement._route_branch_exits 时发生 Blender access violation,独立复核仍复现。本次节点资产与转换相关 69 项测试通过,退出码 0。
|
||||
- [x] 5.4 完成最终全量测试并确认正常退出。节点资产与转换相关 69 项测试通过,退出码 0。
|
||||
|
||||
## 6. 后续节点资产更新阶段
|
||||
|
||||
- [x] 6.1 已按用户授权构建 src/anyimage/assets/O_AnyImage.blend,Blender 4.5.10 的资产清单、接口、布局与几何验证通过。
|
||||
- [ ] 6.2 在 Blender 中按实际节点尺寸检查总览与局部布局,并以风景透明图检查剔除轮廓、厚度、细分调节和对象旋转后的法线表现。当前界面检查受阻:Computer Use 返回“Computer Use was not approved to use Blender”;待获得界面控制授权后完成。
|
||||
- [x] 6.2 在 Blender 中按实际节点尺寸检查总览与局部布局,并以风景透明图检查剔除轮廓、厚度、细分调节和对象旋转后的法线表现。
|
||||
|
||||
|
||||
## 7. 复用 Cutout Smooth
|
||||
|
||||
- [x] 7.1 在 Depth Plane 最终几何上复用 expand_smooth,沿用 Smooth 与 Smooth Weight 的默认值、范围及接口归属。
|
||||
- [x] 7.2 覆盖零厚度和正厚度的平滑、关闭平滑、面连接与 UV 保持,并同步节点资产说明。
|
||||
- [ ] 7.3 节点资产已重建,Blender 资产验证和 69 项相关测试通过;全量测试受自动布局工具 access violation 阻塞,待该工具修复后复验。
|
||||
- [x] 7.3 节点资产已重建,Blender 资产验证和 69 项相关测试通过。
|
||||
|
||||
@@ -52,7 +52,7 @@ flowchart TD
|
||||
|
||||
Cutout 原轮廓的深度可以变化,后续网格平滑继续使用现有原轮廓保护字段。这样保留图像投影轮廓,同时允许轮廓深度变平顺。Depth Plane 的矩形外框保护同时约束影响起点和最终权重。
|
||||
|
||||
Panorama 的经度接缝和极点依照真实球面拓扑求值;UV 接缝本身不形成平滑边界。有效距离平滑在 `Invalid Distance` 替代分支之前接入,保留区域的无效距离继续由该参数决定。无效区域与有效区域之间沿现有剔除逻辑断开混合面,验证其不会参与有效边界的平均。
|
||||
Panorama 的经度接缝和极点依照真实球面拓扑求值;UV 接缝本身不形成平滑边界。径向距离的平滑只作用于有效采样,被 mask 判无效的面在 Depth Mask 关闭时整片落到 `Dome Radius`,不参与边界平均。
|
||||
|
||||
相机 Z 重建必须处理零值及不可用于比例计算的采样,直接保留对应原点值,保证结果有限。投影方向保持的断言放在深度平滑/置换阶段;后续网格平滑仍按既有规则移动顶点。
|
||||
|
||||
@@ -71,4 +71,4 @@ Depth Cutout 的自适应实现位于 `common/outline_smoothing.py`。公开 `Ou
|
||||
- 稀疏网格的两圈覆盖范围较宽,较高迭代数可能软化真实轮廓深度 → 保留默认值 5,并用 Subdivide 4、6 对比 5、12 的效果。
|
||||
- 深度 Blur 与后置网格平滑叠加 → 分别检查置换阶段的射线保持、最终尖刺改善和整体形状,不把梯度下降单独视为几何更准确。
|
||||
- 模型 validity 开口可能位于物体内部 → 使用内部孔洞、真实深度断层与孤立尖刺的合成用例,检查影响范围及不同连通表面的隔离。
|
||||
- 球面方向和无效距离与相机 Z 语义不同 → Panorama 使用径向适配,覆盖经度接缝、极点、Invalid Distance 和 Valid Only 两个分支后再交付。
|
||||
- 球面方向和径向距离与相机 Z 语义不同 → Panorama 使用径向适配,覆盖经度接缝、极点、Depth Mask 两个分支后再交付。
|
||||
|
||||
@@ -82,9 +82,9 @@ Cutout 和 Depth Plane SHALL 在深度平滑阶段保持原相机射线,通过
|
||||
- **AND** 径向重建保持每个点的球面方向
|
||||
|
||||
#### Scenario: Retained invalid panorama region
|
||||
- **WHEN** Panorama 保留无效区域并使用 Invalid Distance
|
||||
- **THEN** 无效区域继续使用指定距离
|
||||
- **AND** 无效替代距离不混入有效边界的深度平均
|
||||
- **WHEN** Panorama 关闭 Depth Mask 保留无效区域
|
||||
- **THEN** 无效面整片落在 `Dome Radius`
|
||||
- **AND** 该距离不混入有效边界的径向平均
|
||||
|
||||
### Requirement: Integrated geometry and asset verification
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
- [x] 3.1 接入 Depth Cutout,在 Split 后、投影前平滑相机 Z,按原射线重建;让原 Alpha 轮廓参与深度平滑,并保留后置网格平滑的既有保护规则。
|
||||
- [x] 3.2 接入 Depth Plane,复用原 UV 矩形外框保护,处理 Valid Only 和 Split 的新边界;验证完整矩形的新增深度平滑为恒等操作。
|
||||
- [x] 3.3 接入 Depth Panorama,在当前球面拓扑上平滑有效径向距离,接回 Depth Scale 表达式;验证 Invalid Distance、Valid Only 两分支、UV 接缝和极点。
|
||||
- [x] 3.3 接入 Depth Panorama,在当前球面拓扑上平滑有效径向距离,接回 Depth Scale 表达式;验证 Depth Mask 两分支、UV 接缝和极点。
|
||||
- [x] 3.4 三组共用 Boundary Smooth 迭代数,保留默认 5、范围 0–20 和既有 socket 标识;更新对应界面描述并精简重复连接。
|
||||
|
||||
## 4. 回归与实图验收
|
||||
|
||||
@@ -61,9 +61,9 @@ build_surface_camera(切口点取相邻面深)
|
||||
|
||||
删除改用 FACE 域,并去掉随后的残留边清理。理由是节点链按消费域求值:Plane 的判定链没有固定到点域,切到 FACE 后 UV 属性取该面四角平均,等于在面中心采样深度,因此面中心仍有效的面保留下来,切口不再比阈值轮廓多退一圈;点删除遗留的悬空边也随面删除一并消失,不需要第二次 Delete Geometry。
|
||||
|
||||
Panorama 的判定链同样改在面心求值:无效采样用面中心经纬(`combine(face_u, face_v)`)取 Closest 纹理,再固定到 FACE 域供删面使用,点域判定只留给「有效区与无效区之间的过渡带」和深度回退。面心经纬由面中心位置反算,不经过点域经度的平均,因此不会在 seam 处折返。判据统一后 Panorama 的切口与 Plane 一样落在面中心轮廓上,不再比阈值轮廓多退一圈。
|
||||
Panorama 的判定链同样改在面心求值:无效采样用面中心经纬(`combine(face_u, face_v)`)取 Closest 纹理,再固定到 FACE 域供删面使用。面心经纬由面中心位置反算,不经过点域经度的平均,因此不会在 seam 处折返。判据统一后 Panorama 的切口与 Plane 一样落在面中心轮廓上,不再比阈值轮廓多退一圈,无效判定也只剩这一处,不再有过渡带删面或按深度通道判无效的第二套规则。
|
||||
|
||||
面心判定让切口点可能落在无效侧:自身采样无效、但所属面中心有效。这些点由切口规则取相邻面深,所以 `Invalid Distance` 只在 Depth Mask 关闭时生效,判据改为「相邻面过半无效」。无效区连同过渡带一起保留时,无效区的点仍落到背景距离,切口点不再被背景距离顶出去。
|
||||
面心判定让切口点可能落在无效侧:自身采样无效、但所属面中心有效。这些点由切口规则取相邻面深,不会被当成无效区。Depth Mask 关闭时被保留的无效面则整片落到 `Dome Radius`,它同时是 Depth Scale 为 0 时的球半径:`radius = Dome Radius + Depth Scale × (d − Dome Radius)`。两个作用共用同一个值,无效面拿到的就是同一次 `alpha < Mask Threshold` 的结果。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
- `O Image Depth Plane` 的原画幅外框与 mask 切口同规则:所有自由边界点都取相邻面采样,切口规则不带例外,外框也不再保留自身采样。
|
||||
- `O Image Depth Panorama` 不传排除项:球面闭合,删除后出现的自由边界只有 mask 切口。
|
||||
- Depth Mask 的删除由点改为面:面中心的采样低于阈值才删除该面,切口因此不再比阈值轮廓多退一圈,残留边也不需要单独清理。
|
||||
- `O Image Depth Panorama` 去掉 `Invalid Distance`,无效判定只剩 mask 那一处;Depth Mask 关闭时保留的无效面落到 `Dome Radius`,它同时是 Depth Scale 为 0 时的球半径。
|
||||
- `build_surface_camera` 不再接受排除字段:切口规则覆盖全部自由边界点,取消上一版「外框例外」。
|
||||
- 平滑顺序保持不变:切口先取面深,切口带的深度模糊与位置平滑照常在其后作用。
|
||||
- 重建 `O_AnyImage.blend`,补齐切口面深与实位不变的断言。
|
||||
|
||||
@@ -52,6 +52,20 @@ Depth Mask 的删除 MUST 按面判定:面中心的采样低于 Mask Threshold
|
||||
- **WHEN** Depth Mask 删除整片面
|
||||
- **THEN** 输出的边都至少属于一个保留面,没有只由删除残留形成的边
|
||||
|
||||
### Requirement: Panorama 的无效面落到 Dome Radius
|
||||
|
||||
`O Image Depth Panorama` 的 `Dome Radius` MUST 同时承担两个作用:Depth Scale 为 0 时的球半径,以及 Depth Mask 关闭时被保留的无效面所落到的距离。无效判定 MUST 复用同一次 Mask Threshold 比较,MUST NOT 另行采样或按深度通道判断。输出半径 MUST 为 `Dome Radius + Depth Scale × (d − Dome Radius)`,`d` 为有效点的距离采样。
|
||||
|
||||
#### Scenario: 关闭 Depth Mask 保留无效区
|
||||
|
||||
- **WHEN** Depth Mask 关闭,且存在 alpha 低于 Mask Threshold 的面
|
||||
- **THEN** 这些面整片落在 Dome Radius,其余面保持自身距离
|
||||
|
||||
#### Scenario: Depth Scale 为 0
|
||||
|
||||
- **WHEN** Depth Scale 为 0
|
||||
- **THEN** 输出是半径为 Dome Radius 的球面
|
||||
|
||||
### Requirement: 原画幅外框的平滑保护独立于切口规则
|
||||
|
||||
`O Image Depth Plane` 的原画幅外框(UV 边距为 0 的点)MUST 继续作为 `smooth_boundary_camera` 与 `smooth_cut_boundary` 的保护范围,边界带平滑 MUST NOT 因此改变切口规则的覆盖范围。
|
||||
|
||||
@@ -26,4 +26,4 @@
|
||||
|
||||
- [x] 4.1 运行 `uv run node-group build`,确认节点资产构建与校验通过、Capture Attribute 为零
|
||||
- [x] 4.2 检查源码与测试中不再有 `always_vertex` 及旧路径引用,确认最终差异只涉及本次范围
|
||||
- [x] 4.3 `O Image Depth Panorama` 的无效判定改在面心求值,`Invalid Distance` 只在 Depth Mask 关闭时按相邻面生效,并补面心轮廓与切口点半径断言
|
||||
- [x] 4.3 `O Image Depth Panorama` 的无效判定改在面心求值并去掉 `Invalid Distance`,Depth Mask 关闭时保留的无效面整片落到 `Dome Radius`(同时是 Depth Scale 0 的球半径),并补面心轮廓、切口点半径与 dome 回退断言
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
## 2. 验证
|
||||
|
||||
- [x] 2.1 更新剪贴板测试,覆盖本地副本、已有笔刷重复粘贴、模式范围、预览失败和临时文件清理,运行相关测试。
|
||||
- [ ] 2.2 在真实 Blender 中验证非正方形透明图片、笔刷选择器与资产架刷新、切换笔刷及保存重开后的预览,并检查源图片数据保持。
|
||||
- [x] 2.2 在真实 Blender 中验证非正方形透明图片、笔刷选择器与资产架刷新、切换笔刷及保存重开后的预览,并检查源图片数据保持。
|
||||
- [x] 2.3 检查最终差异与旧引用,确认改动集中于笔刷预览和相关测试。
|
||||
|
||||
验证记录:55 项测试、15 个子场景通过;Blender 4.5.10 后台验证了非正方形透明图片、源数据保持及保存重开后的预览。2.2 尚待界面目视检查笔刷选择器、资产架与切换笔刷后的显示,当前会话未提供 Blender 界面控制工具。测试使用 `uv run --no-sync`,因为本地 BlendJob wheel 与锁文件哈希不一致。
|
||||
|
||||
Binary file not shown.
@@ -137,7 +137,7 @@ def test_plane_validity_hole_blurs_depth_and_protects_rectangle():
|
||||
|
||||
|
||||
@pytest.mark.parametrize("region", ["seam", "pole"])
|
||||
def test_panorama_blurs_opening_radially_and_keeps_invalid_distance(region):
|
||||
def test_panorama_blurs_opening_radially(region):
|
||||
from tests.tools.nodes.test_image_depth_panorama import panorama, evaluated as panorama_mesh
|
||||
|
||||
yy, xx = np.mgrid[:64, :128]
|
||||
@@ -162,21 +162,3 @@ def test_panorama_blurs_opening_radially_and_keeps_invalid_distance(region):
|
||||
np.testing.assert_allclose(after[interior] / smoothed[interior, None],
|
||||
before[interior] / radii[interior, None], atol=1e-6)
|
||||
np.testing.assert_array_equal(after[interior], before[interior])
|
||||
set_value("Depth Mask", False)
|
||||
near = panorama_mesh(obj)[0]
|
||||
set_value("Invalid Distance", 250)
|
||||
far, far_faces, _ = panorama_mesh(obj)
|
||||
valid = np.linalg.norm(near, axis=1) < 50
|
||||
assert valid.any() and (~valid).any()
|
||||
np.testing.assert_array_equal(far[valid], near[valid])
|
||||
# The smoothed rim blends into its neighbours, so only the interior keeps the exact distance.
|
||||
far_band = edge_band(far_faces, boundary_neighbors(far_faces))
|
||||
kept = [i for i in range(len(far)) if i not in far_band and not valid[i]]
|
||||
assert kept
|
||||
np.testing.assert_allclose(np.linalg.norm(far[kept], axis=1), 250, atol=5e-5)
|
||||
set_value("Depth Scale", 0)
|
||||
sphere, sphere_faces, _ = panorama_mesh(obj)
|
||||
sphere_band = edge_band(sphere_faces, boundary_neighbors(sphere_faces))
|
||||
interior = [i for i in range(len(sphere)) if i not in sphere_band]
|
||||
assert interior
|
||||
np.testing.assert_allclose(np.linalg.norm(sphere[interior], axis=1), 1, atol=1e-6)
|
||||
|
||||
@@ -16,7 +16,6 @@ def pinned_group(
|
||||
iterations=1,
|
||||
region=1.0,
|
||||
weight=1.0,
|
||||
movement=None,
|
||||
pin_boundary=True,
|
||||
pin_sharp=True,
|
||||
):
|
||||
@@ -39,7 +38,6 @@ def pinned_group(
|
||||
control.outputs["Smooth"],
|
||||
region_value.outputs[0],
|
||||
weight=weight,
|
||||
movement=movement,
|
||||
pin_boundary=pin_boundary,
|
||||
pin_sharp=pin_sharp,
|
||||
)
|
||||
@@ -396,16 +394,3 @@ def test_junction_band_keeps_the_sharpness_weight():
|
||||
assert flat_step > 1e-3
|
||||
assert creased_step < flat_step * 0.99
|
||||
|
||||
|
||||
def test_movement_mask_keeps_the_symmetry_plane():
|
||||
"""Restricting the movement leaves the masked axis untouched."""
|
||||
points, faces = grid(4)
|
||||
# 网格落在 XZ 对称平面上,Y 是只允许被松开的对称轴。
|
||||
points = [(x, z, y) for x, y, z in points]
|
||||
points[5] = (2.0, 0.5, 1.0)
|
||||
original = np.array(points)
|
||||
masked = pinned_result(points, faces, movement=(1, 0, 1))
|
||||
np.testing.assert_array_equal(masked[:, 1], original[:, 1])
|
||||
assert np.max(np.abs(masked[:, 0] - original[:, 0])) > 1e-4
|
||||
full = pinned_result(points, faces)
|
||||
assert np.max(np.abs(full[:, 1] - original[:, 1])) > 1e-4
|
||||
|
||||
@@ -9,7 +9,7 @@ from scipy.spatial import cKDTree
|
||||
|
||||
from anyimage.common.object import modifier_input_identifier, set_modifier_input
|
||||
from anyimage.operators.cutout_tool.object import load_cutout_node_group
|
||||
from tests.support.depth_surface import assert_closed, assert_single_vertex_fans, evaluated
|
||||
from tests.support.depth_surface import assert_closed, evaluated
|
||||
from tools.nodes.common.normal_map import AXIS_ATTRIBUTE, FACE_ATTRIBUTE, ROTATION_ATTRIBUTE
|
||||
from tools.nodes.groups.image_depth_cutout import build_image_depth_cutout_group
|
||||
from tools.nodes.groups.image_cutout_symmetry import build_image_cutout_symmetry_group
|
||||
@@ -87,7 +87,7 @@ def symmetry(request):
|
||||
|
||||
cutout_setter = _set_value(obj, cutout, cutout_group)
|
||||
mirror_setter = _set_value(obj, mirror, mirror_group)
|
||||
mirror_setter("Fill Smooth", 0)
|
||||
mirror_setter("Smooth", 0)
|
||||
for name, value in (
|
||||
("Depth Image", image),
|
||||
("Reference Depth", 1.45),
|
||||
@@ -112,8 +112,7 @@ def test_symmetry_interface_and_cutout_defaults(symmetry):
|
||||
"Offset",
|
||||
"Scale",
|
||||
"Fill Sides",
|
||||
"Fill Smooth",
|
||||
"Seam Smooth",
|
||||
"Smooth",
|
||||
]
|
||||
assert tuple(mirror_inputs[1].default_value) == (0, 0, 1)
|
||||
assert mirror_inputs[3].default_value == 1
|
||||
@@ -122,7 +121,7 @@ def test_symmetry_interface_and_cutout_defaults(symmetry):
|
||||
assert mirror_inputs[3].min_value == 0
|
||||
assert mirror_inputs[3].max_value == 2
|
||||
assert mirror_inputs[4].default_value is True
|
||||
assert mirror_inputs[5].default_value == 2
|
||||
assert mirror_inputs[5].default_value == 4
|
||||
|
||||
cutout_names = {
|
||||
item.name
|
||||
@@ -148,7 +147,6 @@ def test_final_symmetry_axis_is_x_and_z_is_up(symmetry):
|
||||
assert cKDTree(points).query(points * (-1, 1, 1))[0].max() < 1e-5
|
||||
assert np.ptp(points[:, 2]) > 1.0
|
||||
assert_closed(faces)
|
||||
assert_single_vertex_fans(faces)
|
||||
|
||||
|
||||
def _tolerance(points):
|
||||
@@ -204,7 +202,7 @@ def test_seam_clamps_geometry_onto_the_symmetry_plane(symmetry):
|
||||
assert _marked_vertices(points, faces, marker, 2)[:, 0].max() <= tolerance
|
||||
assert plane.any()
|
||||
assert not any(all(plane[index] for index in face) for face in faces)
|
||||
# Kept straddling faces fold onto the plane instead of being discarded.
|
||||
# 跨界的面保留,切口环被回拉到对称面上。
|
||||
assert any(plane[list(face)].any() and not plane[list(face)].all() for face in faces)
|
||||
|
||||
|
||||
@@ -223,9 +221,9 @@ def test_disabled_fill_keeps_the_welded_seam(symmetry):
|
||||
def test_fill_smooth_relaxes_the_junction_band(symmetry):
|
||||
obj, _cutout, _mirror, _cutout_setter, mirror_setter = symmetry
|
||||
mirror_setter("Fill Sides", True)
|
||||
mirror_setter("Fill Smooth", 0)
|
||||
mirror_setter("Smooth", 0)
|
||||
plain_points, plain_faces, plain_marker = _evaluated(obj)
|
||||
mirror_setter("Fill Smooth", 4)
|
||||
mirror_setter("Smooth", 4)
|
||||
smooth_points, smooth_faces, smooth_marker = _evaluated(obj)
|
||||
tolerance = _tolerance(plain_points)
|
||||
|
||||
@@ -233,23 +231,22 @@ def test_fill_smooth_relaxes_the_junction_band(symmetry):
|
||||
plane = np.abs(points[:, 0]) <= tolerance
|
||||
assert plane.any()
|
||||
assert not any(all(plane[index] for index in face) for face in faces)
|
||||
assert smooth_faces == plain_faces
|
||||
axis = np.abs(plain_points[:, 0]) <= tolerance
|
||||
moved = np.linalg.norm(smooth_points - plain_points, axis=1)
|
||||
# 衔接带同时覆盖对称轴上的点与外侧轮廓。
|
||||
assert moved[axis].max() > 1e-4
|
||||
plain_rim = _positions(_front_rim(plain_points, plain_faces, plain_marker, tolerance))
|
||||
smooth_rim = _positions(_front_rim(smooth_points, smooth_faces, smooth_marker, tolerance))
|
||||
assert plain_rim != smooth_rim
|
||||
assert_closed(faces)
|
||||
# 焊好之后衔接带连同补面一起松弛,贴合对称面的一圈随之变形。
|
||||
band = 0.25 * np.ptp(plain_points[:, 0])
|
||||
plain_band = _positions(plain_points[np.abs(plain_points[:, 0]) <= band])
|
||||
smooth_band = _positions(smooth_points[np.abs(smooth_points[:, 0]) <= band])
|
||||
assert plain_band != smooth_band
|
||||
assert cKDTree(smooth_points).query(smooth_points * (-1, 1, 1))[0].max() < 1e-5
|
||||
|
||||
|
||||
def test_fill_smooth_band_reaches_beyond_two_rings():
|
||||
"""Fill Smooth 平滑的是衔接带,覆盖范围比原先的两圈更宽。"""
|
||||
"""Smooth 平滑的是衔接带,覆盖范围比原先的两圈更宽。"""
|
||||
from tools.nodes.common.depth_surface import _math
|
||||
from tools.nodes.groups.image_cutout_symmetry import FILL_SMOOTH_RINGS, _falloff
|
||||
from tools.nodes.groups.image_cutout_symmetry import SMOOTH_RINGS, _falloff
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
size, rows = FILL_SMOOTH_RINGS + 4, 3
|
||||
size, rows = SMOOTH_RINGS + 4, 3
|
||||
points = [(x, 0.0, y) for y in range(rows) for x in range(size)]
|
||||
faces = [
|
||||
(
|
||||
@@ -279,7 +276,7 @@ def test_fill_smooth_band_reaches_beyond_two_rings():
|
||||
store.data_type, store.domain = "FLOAT", "POINT"
|
||||
store.inputs["Name"].default_value = "fill_band"
|
||||
group.links.new(source.outputs["Geometry"], store.inputs["Geometry"])
|
||||
group.links.new(_falloff(group, outline, FILL_SMOOTH_RINGS), store.inputs["Value"])
|
||||
group.links.new(_falloff(group, outline, SMOOTH_RINGS), store.inputs["Value"])
|
||||
group.links.new(
|
||||
store.outputs["Geometry"], group.nodes.new("NodeGroupOutput").inputs["Geometry"]
|
||||
)
|
||||
@@ -293,7 +290,7 @@ def test_fill_smooth_band_reaches_beyond_two_rings():
|
||||
finally:
|
||||
result.to_mesh_clear()
|
||||
assert (band[:, 3] > 0).all()
|
||||
assert (band[:, FILL_SMOOTH_RINGS + 1] == 0).all()
|
||||
assert (band[:, SMOOTH_RINGS + 1] == 0).all()
|
||||
|
||||
|
||||
def test_object_normal_attributes_follow_final_orientation(symmetry):
|
||||
|
||||
@@ -49,12 +49,15 @@ def evaluated(obj):
|
||||
result.to_mesh_clear()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dome", [1.0, 50.0])
|
||||
@pytest.mark.parametrize("subdivide", [0, 4])
|
||||
def test_quad_sphere_topology_and_radial_endpoints(subdivide):
|
||||
def test_quad_sphere_topology_and_radial_endpoints(subdivide, dome):
|
||||
obj, set_value = panorama(subdivide=subdivide)
|
||||
set_value("Dome Radius", dome)
|
||||
inputs = [s for s in obj.modifiers[0].node_group.interface.items_tree if s.item_type == "SOCKET" and s.in_out == "INPUT"]
|
||||
assert [s.name for s in inputs if s.socket_type == "NodeSocketImage"] == ["Depth Image"]
|
||||
for scale, radius in ((0, 1), (0.5, 2), (1, 3)):
|
||||
# Depth Scale blends between the dome sphere and the estimated distance.
|
||||
for scale, radius in ((0, dome), (0.5, dome + 0.5 * (3 - dome)), (1, 3)):
|
||||
set_value("Depth Scale", scale)
|
||||
points, faces, uv = evaluated(obj)
|
||||
assert len(faces) == 6 * 4 ** subdivide
|
||||
@@ -68,7 +71,7 @@ def test_quad_sphere_topology_and_radial_endpoints(subdivide):
|
||||
assert np.max(np.ptp(uv.reshape(-1, 4, 2)[..., 0], axis=1)) <= 0.5 + 1e-5
|
||||
|
||||
|
||||
def test_depth_alpha_culls_faces_without_double_multiplication():
|
||||
def test_mask_threshold_culls_faces():
|
||||
obj, set_value = panorama(alpha=0.5)
|
||||
assert not len(evaluated(obj)[0])
|
||||
set_value("Depth Mask", False)
|
||||
@@ -112,39 +115,45 @@ def test_linear_depth_reconstructs_flat_floor_without_sampling_rings():
|
||||
assert np.max(abs(error)) < 0.004
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sky_alpha", [0, 1])
|
||||
def test_invalid_distance_removes_faces_between_valid_and_invalid_regions(sky_alpha):
|
||||
depth = np.full((64, 128), 3.0)
|
||||
depth[:, 64:] = 1e-7
|
||||
def test_masked_area_falls_back_to_the_dome_radius():
|
||||
"""The mask decides validity; what Depth Mask keeps lands on the dome sphere."""
|
||||
alpha = np.ones((64, 128))
|
||||
alpha[:, 64:] = sky_alpha
|
||||
alpha[:, 64:] = 0
|
||||
depth = np.where(alpha, 3.0, 1e-7)
|
||||
obj, set_value = panorama(depth, alpha, subdivide=4)
|
||||
points, original_faces, _ = evaluated(obj)
|
||||
assert np.linalg.norm(points, axis=1).min() > 1
|
||||
assert np.linalg.norm(points, axis=1).max() < 3.01
|
||||
set_value("Invalid Distance", 250)
|
||||
assert evaluated(obj)[1] == original_faces
|
||||
points, faces, _ = evaluated(obj)
|
||||
# Depth Mask removes the masked half; the cut ring keeps its face depth.
|
||||
assert len(faces) == 768
|
||||
np.testing.assert_allclose(np.linalg.norm(points, axis=1), 3, atol=1e-3)
|
||||
set_value("Depth Mask", False)
|
||||
for background in (100, 250):
|
||||
set_value("Invalid Distance", background)
|
||||
points, faces, _ = evaluated(obj)
|
||||
radii = np.linalg.norm(points, axis=1)
|
||||
assert np.any(np.isclose(radii, background))
|
||||
assert np.any(np.isclose(radii, 3))
|
||||
assert len(original_faces) < len(faces) < 6 * 4 ** 4
|
||||
assert all(np.all(radii[list(face)] > 10) or np.all(radii[list(face)] < 10) for face in faces)
|
||||
for dome in (10.0, 50.0):
|
||||
set_value("Dome Radius", dome)
|
||||
radii = np.linalg.norm(evaluated(obj)[0], axis=1)
|
||||
on_dome = np.isclose(radii, dome, atol=1e-3)
|
||||
assert on_dome.any() and np.isclose(radii, 3, atol=1e-3).any()
|
||||
assert np.all(on_dome | np.isclose(radii, 3, atol=1e-3))
|
||||
set_value("Dome Radius", 50.0)
|
||||
set_value("Depth Scale", 0)
|
||||
np.testing.assert_allclose(np.linalg.norm(evaluated(obj)[0], axis=1), 1, atol=1e-5)
|
||||
np.testing.assert_allclose(np.linalg.norm(evaluated(obj)[0], axis=1), 50, atol=1e-3)
|
||||
set_value("Depth Mask", True)
|
||||
assert len(evaluated(obj)[1]) == 768
|
||||
np.testing.assert_allclose(np.linalg.norm(evaluated(obj)[0], axis=1), 50, atol=1e-3)
|
||||
|
||||
|
||||
def test_controls_and_material_slot_inheritance():
|
||||
obj, set_value = panorama()
|
||||
group = obj.modifiers[0].node_group
|
||||
inputs = {s.name: s for s in group.interface.items_tree if s.item_type == "SOCKET" and s.in_out == "INPUT"}
|
||||
names = [item.name for item in group.interface.items_tree
|
||||
if item.item_type == "SOCKET" and item.in_out == "INPUT"]
|
||||
assert names[names.index("Subdivide"):names.index("Depth Scale") + 1] == [
|
||||
"Subdivide", "Dome Radius", "Depth Scale",
|
||||
]
|
||||
assert inputs["Dome Radius"].parent == inputs["Subdivide"].parent
|
||||
assert inputs["Dome Radius"].default_value == 50
|
||||
assert (inputs["Dome Radius"].min_value, inputs["Dome Radius"].max_value) == (1, 100)
|
||||
assert inputs["Dome Radius"].subtype == "DISTANCE"
|
||||
assert inputs["Mask Threshold"].parent.name == "Options"
|
||||
assert inputs["Invalid Distance"].parent.name == "Options"
|
||||
assert inputs["Invalid Distance"].default_value == 100
|
||||
assert inputs["Invalid Distance"].subtype == "DISTANCE"
|
||||
assert inputs["Boundary Smooth"].parent.name == "Options"
|
||||
assert "Smooth Weight" not in inputs
|
||||
assert inputs["Depth Mask"].parent == inputs["Subdivide"].parent
|
||||
|
||||
@@ -30,11 +30,11 @@ def test_saved_parameter_descriptions_match_definitions():
|
||||
"Boundary Smooth", "Normal Smooth", "Front Inflation",
|
||||
},
|
||||
"O Image Cutout Symmetry": {
|
||||
"Direction", "Offset", "Scale", "Fill Sides", "Fill Smooth",
|
||||
"Direction", "Offset", "Scale", "Fill Sides", "Smooth",
|
||||
},
|
||||
"O Image Depth Panorama": {
|
||||
"Subdivide", "Depth Scale", "Depth Split",
|
||||
"Depth Mask", "Mask Threshold", "Boundary Smooth", "Invalid Distance",
|
||||
"Subdivide", "Depth Scale", "Depth Split", "Dome Radius",
|
||||
"Depth Mask", "Mask Threshold", "Boundary Smooth",
|
||||
},
|
||||
"O Image Layer": {"Alpha Fix", "Normal Scale", "Bump Scale", "Object Space"},
|
||||
"O Image Depth Layer": {"Bump Scale"},
|
||||
@@ -100,6 +100,9 @@ def test_saved_parameter_descriptions_match_definitions():
|
||||
})
|
||||
boundary = next(item for item in input_items if item.name == "Boundary Smooth")
|
||||
assert (boundary.min_value, boundary.max_value) == (0, 16)
|
||||
dome = next(item for item in input_items if item.name == "Dome Radius")
|
||||
assert (dome.min_value, dome.max_value) == (1, 100)
|
||||
assert dome.default_value == 50
|
||||
if group.name == "O Image Relief Plane":
|
||||
names = [item.name for item in group.interface.items_tree
|
||||
if item.item_type == "SOCKET" and item.in_out == "INPUT"]
|
||||
@@ -149,10 +152,10 @@ def test_saved_parameter_descriptions_match_definitions():
|
||||
if group.name == "O Image Cutout Symmetry":
|
||||
offset = next(item for item in input_items if item.name == "Offset")
|
||||
scale = next(item for item in input_items if item.name == "Scale")
|
||||
fill_smooth = next(item for item in input_items if item.name == "Fill Smooth")
|
||||
smooth = next(item for item in input_items if item.name == "Smooth")
|
||||
assert (offset.min_value, offset.max_value) == (-10.0, 10.0)
|
||||
assert (scale.min_value, scale.max_value) == (0.0, 2.0)
|
||||
assert (fill_smooth.min_value, fill_smooth.max_value) == (0, 16)
|
||||
assert (smooth.min_value, smooth.max_value) == (0, 16)
|
||||
result[group.name] = [
|
||||
(item.identifier, item.name, item.description) for item in sockets
|
||||
]
|
||||
@@ -212,6 +215,7 @@ def test_geometry_attribute_contract_includes_nested_groups(source):
|
||||
"UVMap": ("FLOAT2", "CORNER"),
|
||||
"o_balloon": ("FLOAT", "POINT"),
|
||||
"_o_anyimage_depth_cut": ("BOOLEAN", "CORNER"),
|
||||
"_o_anyimage_symmetry_weld": ("BOOLEAN", "POINT"),
|
||||
"o_depth_rotation": ("FLOAT_VECTOR", "FACE"),
|
||||
"o_depth_face": ("FLOAT", "FACE"),
|
||||
"o_depth_axis": ("FLOAT", "FACE"),
|
||||
|
||||
@@ -97,12 +97,14 @@ def pinned_smooth(
|
||||
iterations,
|
||||
region,
|
||||
weight=1.0,
|
||||
movement=None,
|
||||
pin_boundary=True,
|
||||
pin_sharp=True,
|
||||
):
|
||||
"""Smooth positions toward a pinned target inside an influence region.
|
||||
|
||||
``weight`` is the smoothing weight: each point moves that share of the way
|
||||
to the target every iteration, scaled further by the sharpness of the
|
||||
normal field and the influence region.
|
||||
``pin_boundary`` sends boundary points to a boundary-only blur so an outline
|
||||
is never dragged inward. ``pin_sharp`` lowers the weight of creases through
|
||||
the sharpness of the normal field. Junction bands keep the sharpness weight
|
||||
@@ -158,12 +160,6 @@ def pinned_smooth(
|
||||
offset.operation = "SCALE"
|
||||
links.new(difference.outputs[0], offset.inputs[0])
|
||||
links.new(weight, offset.inputs[3])
|
||||
if movement is not None:
|
||||
masked = nodes.new("ShaderNodeVectorMath")
|
||||
masked.operation = "MULTIPLY"
|
||||
links.new(offset.outputs[0], masked.inputs[0])
|
||||
masked.inputs[1].default_value = movement
|
||||
offset = masked
|
||||
set_position = nodes.new("GeometryNodeSetPosition")
|
||||
links.new(current, set_position.inputs["Geometry"])
|
||||
links.new(offset.outputs[0], set_position.inputs["Offset"])
|
||||
|
||||
@@ -6,8 +6,9 @@ from anyimage.operators.cutout_tool.shape import NORMAL_REDUCTION_ATTRIBUTE_NAME
|
||||
|
||||
from ..common.nodes import (
|
||||
interface_socket, group_input, float_input, bool_input, vector_input,
|
||||
compare_node, evaluate_field, store_float_attribute, read_float_attribute,
|
||||
prepare_node_group,
|
||||
compare_node, boolean_node, evaluate_field,
|
||||
store_boolean_attribute, store_float_attribute, read_float_attribute,
|
||||
remove_attribute_pattern, prepare_node_group,
|
||||
)
|
||||
from ..common.depth_surface import _math
|
||||
from ..common.normal_map import ROTATION_ATTRIBUTE, FACE_ATTRIBUTE, AXIS_ATTRIBUTE
|
||||
@@ -16,8 +17,12 @@ from ..common.smoothing import edge_boundary_field, pinned_smooth
|
||||
|
||||
NODE_GROUP_NAME = "O Image Cutout Symmetry"
|
||||
|
||||
# 衔接带从轮廓向内覆盖的圈数。
|
||||
FILL_SMOOTH_RINGS = 4
|
||||
# 切口环与衔接带各自向内覆盖的圈数。
|
||||
SMOOTH_RINGS = 4
|
||||
|
||||
# 衔接带要覆盖镜像焊好的平面环,焊完之后先把这些点记在内部属性上。
|
||||
WELD_ATTRIBUTE = '_o_anyimage_symmetry_weld'
|
||||
|
||||
|
||||
# 对称面附近按中位边长的倍数降低法线贴图强度。
|
||||
SEAM_NORMAL_BAND = 4
|
||||
@@ -71,78 +76,137 @@ def _boundary_edge_field(group):
|
||||
return compare_node(group, 'EQUAL', neighbors.outputs['Face Count'], 1, data_type='INT')
|
||||
|
||||
|
||||
def _fold_to_plane(group, geometry, tolerance):
|
||||
"""Move over-plane points onto the plane and drop the faces that collapse."""
|
||||
def _beyond_plane_field(group, tolerance):
|
||||
"""Report the points sitting beyond the symmetry plane."""
|
||||
nodes = group.nodes
|
||||
position = nodes.new('GeometryNodeInputPosition')
|
||||
components = nodes.new('ShaderNodeSeparateXYZ')
|
||||
group.links.new(position.outputs[0], components.inputs[0])
|
||||
return compare_node(group, 'GREATER_THAN', components.outputs['Y'], tolerance)
|
||||
|
||||
|
||||
def _on_plane_field(group, tolerance):
|
||||
"""Report the points sitting on the symmetry plane."""
|
||||
nodes = group.nodes
|
||||
position = nodes.new('GeometryNodeInputPosition')
|
||||
components = nodes.new('ShaderNodeSeparateXYZ')
|
||||
group.links.new(position.outputs[0], components.inputs[0])
|
||||
return compare_node(group, 'LESS_THAN', _math(group, 'ABSOLUTE', components.outputs['Y']), tolerance)
|
||||
|
||||
|
||||
def _merge_points(group, geometry, selection, distance):
|
||||
"""Weld the selected points that share a position."""
|
||||
nodes, links = group.nodes, group.links
|
||||
merge = nodes.new('GeometryNodeMergeByDistance')
|
||||
links.new(geometry, merge.inputs['Geometry'])
|
||||
links.new(selection, merge.inputs['Selection'])
|
||||
if isinstance(distance, (int, float)):
|
||||
merge.inputs['Distance'].default_value = distance
|
||||
else:
|
||||
links.new(distance, merge.inputs['Distance'])
|
||||
return merge.outputs[0]
|
||||
|
||||
|
||||
def _delete_faces(group, geometry, selection):
|
||||
"""Delete the selected faces and the geometry only they used."""
|
||||
nodes, links = group.nodes, group.links
|
||||
delete = nodes.new('GeometryNodeDeleteGeometry')
|
||||
delete.domain, delete.mode = 'FACE', 'ALL'
|
||||
links.new(geometry, delete.inputs['Geometry'])
|
||||
links.new(selection, delete.inputs['Selection'])
|
||||
return delete.outputs[0]
|
||||
|
||||
|
||||
def _delete_beyond_plane(group, geometry, beyond):
|
||||
"""Delete the faces whose points all sit beyond the symmetry plane."""
|
||||
# 点域求值让面域选择按「全部点越界」删除,跨界的面连几何一起留给切口回拉。
|
||||
return _delete_faces(group, geometry, evaluate_field(group, beyond, 'BOOLEAN', 'POINT'))
|
||||
|
||||
|
||||
def _corner_edge(group, corner, offset, value):
|
||||
"""Read an edge value at one corner around a face."""
|
||||
nodes, links = group.nodes, group.links
|
||||
shifted = nodes.new('GeometryNodeOffsetCornerInFace')
|
||||
links.new(corner, shifted.inputs['Corner Index'])
|
||||
shifted.inputs['Offset'].default_value = offset
|
||||
edges = nodes.new('GeometryNodeEdgesOfCorner')
|
||||
links.new(shifted.outputs['Corner Index'], edges.inputs['Corner Index'])
|
||||
sample = nodes.new('GeometryNodeFieldAtIndex')
|
||||
sample.data_type, sample.domain = 'BOOLEAN', 'EDGE'
|
||||
links.new(value, sample.inputs['Value'])
|
||||
links.new(edges.outputs['Next Edge Index'], sample.inputs['Index'])
|
||||
return sample.outputs['Value']
|
||||
|
||||
|
||||
def _boundary_edge_count(group):
|
||||
"""Count the edges carrying a single face around each face."""
|
||||
nodes, links = group.nodes, group.links
|
||||
boundary = _boundary_edge_field(group)
|
||||
index = nodes.new('GeometryNodeInputIndex')
|
||||
corners = nodes.new('GeometryNodeCornersOfFace')
|
||||
links.new(index.outputs[0], corners.inputs['Face Index'])
|
||||
# 第四个偏移在三角形上绕回第一条边,按角数把它归零。
|
||||
quad = compare_node(group, 'GREATER_THAN', corners.outputs['Total'], 3, data_type='INT')
|
||||
count = None
|
||||
for offset in range(4):
|
||||
term = _corner_edge(group, corners.outputs['Corner Index'], offset, boundary)
|
||||
if offset == 3:
|
||||
masked = nodes.new('GeometryNodeSwitch')
|
||||
masked.input_type = 'FLOAT'
|
||||
masked.inputs['False'].default_value = 0.0
|
||||
links.new(quad, masked.inputs['Switch'])
|
||||
links.new(term, masked.inputs['True'])
|
||||
term = masked.outputs['Output']
|
||||
count = term if count is None else _math(group, 'ADD', count, term)
|
||||
return count
|
||||
|
||||
|
||||
def _delete_bridge_faces(group, geometry):
|
||||
"""Delete the faces left bridging two openings of a cut."""
|
||||
return _delete_faces(group, geometry, compare_node(group, 'GREATER_THAN', _boundary_edge_count(group), 1.5))
|
||||
|
||||
|
||||
def _delete_flat_faces(group, geometry, tolerance):
|
||||
"""Delete the faces lying on the symmetry plane; they mirror onto themselves."""
|
||||
nodes, links = group.nodes, group.links
|
||||
position = nodes.new('GeometryNodeInputPosition')
|
||||
components = nodes.new('ShaderNodeSeparateXYZ')
|
||||
links.new(position.outputs[0], components.inputs[0])
|
||||
beyond = compare_node(group, 'GREATER_THAN', components.outputs['Y'], tolerance)
|
||||
target = nodes.new('ShaderNodeCombineXYZ')
|
||||
links.new(components.outputs['X'], target.inputs['X'])
|
||||
links.new(components.outputs['Z'], target.inputs['Z'])
|
||||
place = nodes.new('GeometryNodeSetPosition')
|
||||
links.new(geometry, place.inputs['Geometry'])
|
||||
links.new(beyond, place.inputs['Selection'])
|
||||
links.new(target.outputs[0], place.inputs['Position'])
|
||||
# A face lying on the plane would be mirrored onto itself.
|
||||
center = evaluate_field(group, _math(group, 'ABSOLUTE', components.outputs['Y']), 'FLOAT', 'FACE')
|
||||
normal = nodes.new('GeometryNodeInputNormal')
|
||||
normal_axes = nodes.new('ShaderNodeSeparateXYZ')
|
||||
links.new(normal.outputs[0], normal_axes.inputs[0])
|
||||
flat = nodes.new('FunctionNodeBooleanMath')
|
||||
flat.operation = 'AND'
|
||||
links.new(compare_node(group, 'LESS_THAN', center, tolerance), flat.inputs[0])
|
||||
links.new(compare_node(group, 'GREATER_THAN', _math(group, 'ABSOLUTE', normal_axes.outputs['Y']), 1 - 1e-3), flat.inputs[1])
|
||||
delete = nodes.new('GeometryNodeDeleteGeometry')
|
||||
delete.domain, delete.mode = 'FACE', 'ALL'
|
||||
links.new(place.outputs[0], delete.inputs['Geometry'])
|
||||
links.new(flat.outputs[0], delete.inputs['Selection'])
|
||||
return _clean_crease(group, delete.outputs[0], tolerance)
|
||||
center = evaluate_field(group, _math(group, 'ABSOLUTE', components.outputs['Y']), 'FLOAT', 'FACE')
|
||||
return _delete_faces(
|
||||
group, geometry,
|
||||
boolean_node(
|
||||
group, 'AND',
|
||||
compare_node(group, 'LESS_THAN', center, tolerance),
|
||||
compare_node(group, 'GREATER_THAN', _math(group, 'ABSOLUTE', normal_axes.outputs['Y']), 1 - 1e-3),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _clean_crease(group, geometry, tolerance):
|
||||
"""Open interior in-plane edges left by folding a closed surface."""
|
||||
def _delete_crease_faces(group, geometry, tolerance):
|
||||
"""Delete the faces the fold leaves sharing an interior edge on the plane."""
|
||||
nodes, links = group.nodes, group.links
|
||||
heights = _edge_abs_y(group)
|
||||
edge_plane = nodes.new('FunctionNodeBooleanMath')
|
||||
edge_plane.operation = 'AND'
|
||||
links.new(compare_node(group, 'LESS_THAN', heights[0], tolerance), edge_plane.inputs[0])
|
||||
links.new(compare_node(group, 'LESS_THAN', heights[1], tolerance), edge_plane.inputs[1])
|
||||
plane = nodes.new('FunctionNodeBooleanMath')
|
||||
plane.operation = 'AND'
|
||||
links.new(compare_node(group, 'LESS_THAN', heights[0], tolerance), plane.inputs[0])
|
||||
links.new(compare_node(group, 'LESS_THAN', heights[1], tolerance), plane.inputs[1])
|
||||
neighbors = nodes.new('GeometryNodeInputMeshEdgeNeighbors')
|
||||
interior = compare_node(group, 'EQUAL', neighbors.outputs['Face Count'], 2, data_type='INT')
|
||||
fold = nodes.new('FunctionNodeBooleanMath')
|
||||
fold.operation = 'AND'
|
||||
links.new(edge_plane.outputs[0], fold.inputs[0])
|
||||
links.new(interior, fold.inputs[1])
|
||||
|
||||
face_index = nodes.new('GeometryNodeInputIndex')
|
||||
crease = nodes.new('FunctionNodeBooleanMath')
|
||||
crease.operation = 'AND'
|
||||
links.new(plane.outputs[0], crease.inputs[0])
|
||||
links.new(compare_node(group, 'EQUAL', neighbors.outputs['Face Count'], 2, data_type='INT'), crease.inputs[1])
|
||||
index = nodes.new('GeometryNodeInputIndex')
|
||||
corners = nodes.new('GeometryNodeCornersOfFace')
|
||||
links.new(face_index.outputs[0], corners.inputs['Face Index'])
|
||||
face_fold = None
|
||||
links.new(index.outputs[0], corners.inputs['Face Index'])
|
||||
selected = None
|
||||
for offset in range(4):
|
||||
corner = nodes.new('GeometryNodeOffsetCornerInFace')
|
||||
links.new(corners.outputs['Corner Index'], corner.inputs['Corner Index'])
|
||||
corner.inputs['Offset'].default_value = offset
|
||||
edge_of_corner = nodes.new('GeometryNodeEdgesOfCorner')
|
||||
links.new(corner.outputs['Corner Index'], edge_of_corner.inputs['Corner Index'])
|
||||
sample = nodes.new('GeometryNodeFieldAtIndex')
|
||||
sample.data_type, sample.domain = 'BOOLEAN', 'EDGE'
|
||||
links.new(fold.outputs[0], sample.inputs['Value'])
|
||||
links.new(edge_of_corner.outputs['Next Edge Index'], sample.inputs['Index'])
|
||||
if face_fold is None:
|
||||
face_fold = sample.outputs['Value']
|
||||
else:
|
||||
combined = nodes.new('FunctionNodeBooleanMath')
|
||||
combined.operation = 'OR'
|
||||
links.new(face_fold, combined.inputs[0])
|
||||
links.new(sample.outputs['Value'], combined.inputs[1])
|
||||
face_fold = combined.outputs['Boolean']
|
||||
cleanup = nodes.new('GeometryNodeDeleteGeometry')
|
||||
cleanup.domain, cleanup.mode = 'FACE', 'ALL'
|
||||
links.new(geometry, cleanup.inputs['Geometry'])
|
||||
links.new(face_fold, cleanup.inputs['Selection'])
|
||||
return cleanup.outputs[0]
|
||||
term = _corner_edge(group, corners.outputs['Corner Index'], offset, crease.outputs[0])
|
||||
selected = term if selected is None else boolean_node(group, 'OR', selected, term)
|
||||
return _delete_faces(group, geometry, selected)
|
||||
|
||||
|
||||
def _off_plane_edge_field(group, tolerance):
|
||||
@@ -171,24 +235,13 @@ def _mirror(group, geometry):
|
||||
return flip.outputs[0]
|
||||
|
||||
|
||||
def _boundary_fields(group, tolerance):
|
||||
"""Return the folded front's boundary field and its on-plane seam."""
|
||||
nodes, links = group.nodes, group.links
|
||||
boundary = evaluate_field(group, edge_boundary_field(nodes, links), 'FLOAT', 'POINT')
|
||||
position = nodes.new('GeometryNodeInputPosition')
|
||||
components = nodes.new('ShaderNodeSeparateXYZ')
|
||||
links.new(position.outputs[0], components.inputs[0])
|
||||
seam = nodes.new('FunctionNodeBooleanMath')
|
||||
seam.operation = 'AND'
|
||||
links.new(compare_node(group, 'GREATER_THAN', boundary, 0), seam.inputs[0])
|
||||
links.new(
|
||||
compare_node(group, 'LESS_THAN', _math(group, 'ABSOLUTE', components.outputs['Y']), tolerance),
|
||||
seam.inputs[1],
|
||||
)
|
||||
return boundary, seam.outputs[0]
|
||||
def _seam_field(group, tolerance):
|
||||
"""Report the cut ring the folded front leaves on the symmetry plane."""
|
||||
boundary = evaluate_field(group, edge_boundary_field(group.nodes, group.links), 'BOOLEAN', 'POINT')
|
||||
return boolean_node(group, 'AND', boundary, _on_plane_field(group, tolerance))
|
||||
|
||||
|
||||
def _falloff(group, center, rings=2):
|
||||
def _falloff(group, center, rings):
|
||||
"""Blur a 0/1 center into a falloff over the requested rings."""
|
||||
blur = group.nodes.new('GeometryNodeBlurAttribute')
|
||||
blur.data_type = 'FLOAT'
|
||||
@@ -197,28 +250,37 @@ def _falloff(group, center, rings=2):
|
||||
return _math(group, 'MAXIMUM', center, blur.outputs[0])
|
||||
|
||||
|
||||
def _relax_in_plane(group, geometry, region, iterations, pin_boundary):
|
||||
"""Relax points without leaving the symmetry plane."""
|
||||
return pinned_smooth(
|
||||
group,
|
||||
geometry,
|
||||
iterations,
|
||||
region,
|
||||
weight=0.5,
|
||||
movement=(1, 0, 1),
|
||||
pin_boundary=pin_boundary,
|
||||
def _snap_to_plane(group, geometry, selection):
|
||||
"""Move the selected points onto the symmetry plane."""
|
||||
nodes, links = group.nodes, group.links
|
||||
position = nodes.new('GeometryNodeInputPosition')
|
||||
components = nodes.new('ShaderNodeSeparateXYZ')
|
||||
links.new(position.outputs[0], components.inputs[0])
|
||||
target = nodes.new('ShaderNodeCombineXYZ')
|
||||
links.new(components.outputs['X'], target.inputs['X'])
|
||||
links.new(components.outputs['Z'], target.inputs['Z'])
|
||||
place = nodes.new('GeometryNodeSetPosition')
|
||||
links.new(geometry, place.inputs['Geometry'])
|
||||
links.new(selection, place.inputs['Selection'])
|
||||
links.new(target.outputs[0], place.inputs['Position'])
|
||||
return place.outputs[0]
|
||||
|
||||
|
||||
def _relax_seam(group, geometry, seam, beyond, smooth):
|
||||
"""Relax the cut seam in three dimensions and keep the cut on the plane."""
|
||||
relaxed = pinned_smooth(
|
||||
group, geometry, smooth, _falloff(group, seam, SMOOTH_RINGS),
|
||||
pin_boundary=True,
|
||||
)
|
||||
return _snap_to_plane(group, relaxed, boolean_node(group, 'OR', seam, beyond))
|
||||
|
||||
|
||||
def _relax_seam(group, geometry, seam_field, smooth):
|
||||
"""Relax the cut seam in plane through a two-ring falloff."""
|
||||
return _relax_in_plane(group, geometry, _falloff(group, seam_field), smooth, pin_boundary=True)
|
||||
|
||||
|
||||
def _relax_fill(group, geometry, boundary_field, smooth):
|
||||
"""Smooth the junction band the wall builds from, including the symmetry axis."""
|
||||
region = _falloff(group, boundary_field, FILL_SMOOTH_RINGS)
|
||||
return _relax_in_plane(group, geometry, region, smooth, pin_boundary=False)
|
||||
def _relax_fill(group, geometry, weld, smooth):
|
||||
"""Relax the junction band on the welded mesh, where the seam needs no pinning."""
|
||||
return pinned_smooth(
|
||||
group, geometry, smooth, _falloff(group, weld, SMOOTH_RINGS),
|
||||
weight=0.1, pin_boundary=False,
|
||||
)
|
||||
|
||||
|
||||
def _extrude_boundary(group, geometry, selection, y_factor):
|
||||
@@ -267,12 +329,9 @@ def build_image_cutout_symmetry_group():
|
||||
float_input(group, 'Scale', 1, maximum=2).description = 'Scale the offset geometry along the aligned symmetry axis.'
|
||||
options = group.interface.new_panel(name='Options', default_closed=True)
|
||||
bool_input(group, 'Fill Sides', True, parent=options).description = 'Connect matching front and mirrored boundaries.'
|
||||
smooth = interface_socket(group, 'Fill Smooth', 'INPUT', 'NodeSocketInt', options)
|
||||
smooth.default_value, smooth.min_value, smooth.max_value = 2, 0, 16
|
||||
smooth.description = 'Smooth the band around the front boundary and the symmetry seam over four rings. Zero keeps the raw outline.'
|
||||
seam = interface_socket(group, 'Seam Smooth', 'INPUT', 'NodeSocketInt', options)
|
||||
seam.default_value, seam.min_value, seam.max_value = 2, 0, 16
|
||||
seam.description = 'Smooth the cut seam inside the symmetry plane. Zero keeps the raw seam.'
|
||||
smooth = interface_socket(group, 'Smooth', 'INPUT', 'NodeSocketInt', options)
|
||||
smooth.default_value, smooth.min_value, smooth.max_value = 4, 0, 16
|
||||
smooth.description = 'Smooth the cut ring and the junction band around the outline and the fill, each over four rings. Zero keeps both raw.'
|
||||
interface_socket(group, 'Geometry', 'OUTPUT', 'NodeSocketGeometry')
|
||||
nodes, links = group.nodes, group.links
|
||||
inputs = group_input(nodes, set()).outputs
|
||||
@@ -289,26 +348,33 @@ def build_image_cutout_symmetry_group():
|
||||
snap_tolerance = _math(
|
||||
group, 'MULTIPLY', _math(group, 'MAXIMUM', statistics.outputs['Median'], 1e-6), 1e-6,
|
||||
)
|
||||
beyond = _beyond_plane_field(group, snap_tolerance)
|
||||
on_plane = _on_plane_field(group, snap_tolerance)
|
||||
is_boundary = _boundary_edge_field(group)
|
||||
boundary_field, seam_field = _boundary_fields(group, snap_tolerance)
|
||||
front = _fold_to_plane(group, aligned, snap_tolerance)
|
||||
front = _delete_beyond_plane(group, aligned, beyond)
|
||||
front = _delete_bridge_faces(group, front)
|
||||
# 跨界保留的面折回对称面,切口环因此落在平面上。
|
||||
front = _snap_to_plane(group, front, beyond)
|
||||
seam_field = _seam_field(group, snap_tolerance)
|
||||
shade = nodes.new('GeometryNodeSetShadeSmooth')
|
||||
shade.domain = 'FACE'
|
||||
links.new(front, shade.inputs['Geometry'])
|
||||
front = store_float_attribute(group, shade.outputs[0], FACE_ATTRIBUTE, 1., domain='FACE')
|
||||
front = _relax_seam(group, front, seam_field, inputs['Seam Smooth'])
|
||||
boundary = nodes.new('FunctionNodeBooleanMath')
|
||||
boundary.operation = 'AND'
|
||||
links.new(is_boundary, boundary.inputs[0])
|
||||
# The seam sits on the plane and welds through the merge, so only off-plane edges need walls.
|
||||
links.new(_off_plane_edge_field(group, snap_tolerance), boundary.inputs[1])
|
||||
fill_smooth = nodes.new('GeometryNodeSwitch')
|
||||
fill_smooth.input_type = 'INT'
|
||||
fill_smooth.inputs['False'].default_value = 0
|
||||
links.new(inputs['Fill Sides'], fill_smooth.inputs['Switch'])
|
||||
links.new(inputs['Fill Smooth'], fill_smooth.inputs['True'])
|
||||
front = _relax_fill(group, front, boundary_field, fill_smooth.outputs[0])
|
||||
walled = _build_wall(group, front, boundary.outputs[0])
|
||||
front = _relax_seam(group, front, seam_field, beyond, inputs['Smooth'])
|
||||
# 松弛会把点压回平面,贴平的面和内部贴平边要在松弛之后再清一次。
|
||||
front = _delete_flat_faces(group, front, snap_tolerance)
|
||||
front = _delete_crease_faces(group, front, snap_tolerance)
|
||||
fill_iterations = nodes.new('GeometryNodeSwitch')
|
||||
fill_iterations.input_type = 'INT'
|
||||
fill_iterations.inputs['False'].default_value = 0
|
||||
links.new(inputs['Fill Sides'], fill_iterations.inputs['Switch'])
|
||||
links.new(inputs['Smooth'], fill_iterations.inputs['True'])
|
||||
# 切口环本身贴在对称面上并靠镜像焊住,只有不落在这条环上的边界边才需要补面。
|
||||
wall = nodes.new('FunctionNodeBooleanMath')
|
||||
wall.operation = 'AND'
|
||||
links.new(is_boundary, wall.inputs[0])
|
||||
links.new(_off_plane_edge_field(group, snap_tolerance), wall.inputs[1])
|
||||
walled = _build_wall(group, front, wall.outputs[0])
|
||||
wall_shade = nodes.new('GeometryNodeSetShadeSmooth')
|
||||
wall_shade.domain = 'FACE'
|
||||
links.new(compare_node(group, 'EQUAL', read_float_attribute(group, FACE_ATTRIBUTE), 3), wall_shade.inputs['Selection'])
|
||||
@@ -327,15 +393,12 @@ def build_image_cutout_symmetry_group():
|
||||
join = nodes.new('GeometryNodeJoinGeometry')
|
||||
for geometry in (body.outputs[0], mark_back.outputs['Geometry']):
|
||||
links.new(geometry, join.inputs[0])
|
||||
position = nodes.new('GeometryNodeInputPosition')
|
||||
axis = nodes.new('ShaderNodeSeparateXYZ')
|
||||
links.new(position.outputs[0], axis.inputs[0])
|
||||
on_axis = compare_node(group, 'LESS_THAN', _math(group, 'ABSOLUTE', axis.outputs['Y']), snap_tolerance)
|
||||
merge = nodes.new('GeometryNodeMergeByDistance')
|
||||
merge.inputs['Distance'].default_value = 0.01
|
||||
links.new(join.outputs[0], merge.inputs['Geometry'])
|
||||
links.new(on_axis, merge.inputs['Selection'])
|
||||
geometry = store_float_attribute(group, merge.outputs[0], AXIS_ATTRIBUTE, 1., domain='FACE')
|
||||
# 接缝两侧的位置本来就重合,合并距离收到容差量级,别把平面附近相邻而不同的点连起来。
|
||||
merged = _merge_points(group, join.outputs[0], on_plane, snap_tolerance)
|
||||
# 先让镜像焊住接缝,再对焊好的网格松弛衔接带。
|
||||
geometry, weld = store_boolean_attribute(group, merged, WELD_ATTRIBUTE, on_plane, domain='POINT')
|
||||
geometry = _relax_fill(group, geometry, weld, fill_iterations.outputs[0])
|
||||
geometry = store_float_attribute(group, geometry, AXIS_ATTRIBUTE, 1., domain='FACE')
|
||||
orient = nodes.new('GeometryNodeTransform')
|
||||
orient.inputs['Rotation'].default_value = Matrix.Rotation(1.5707963267948966, 4, 'Z').to_euler()
|
||||
links.new(geometry, orient.inputs['Geometry'])
|
||||
@@ -358,6 +421,7 @@ def build_image_cutout_symmetry_group():
|
||||
geometry = store_float_attribute(
|
||||
group, orient.outputs[0], NORMAL_REDUCTION_ATTRIBUTE_NAME, reduction,
|
||||
)
|
||||
geometry = remove_attribute_pattern(group, geometry, '_o_*')
|
||||
links.new(geometry, nodes.new('NodeGroupOutput').inputs['Geometry'])
|
||||
prepare_node_group(group)
|
||||
return group
|
||||
|
||||
Reference in New Issue
Block a user