feat: implement separate geometry handling for panorama mask regions

- Replace Delete Geometry with Separate Geometry in O Image Depth Panorama to manage low and high mask areas independently.
- Adjust depth processing to ensure low mask regions are displaced to Dome Radius while high mask regions follow existing depth rules.
- Update tests to validate new behavior, ensuring outputs align with expected results for both mask states.
- Enhance material handling by introducing emission strength in the shadeless group.
This commit is contained in:
2026-09-17 02:23:19 +08:00
parent efd117dd9b
commit ac7f5a7f6d
13 changed files with 391 additions and 42 deletions

View File

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

View File

@@ -0,0 +1,54 @@
## Context
`O Image Depth Panorama` 现在的 Depth Mask 是一个开关包住 Delete Geometry:面中心的 mask 采样低于 Mask Threshold 就删面。被删的面在之后的链路里不复存在,Depth Mask 关闭时只能让无效面按点回退到 `Dome Radius`,整片网格仍以同一个连通体走 Depth Split 与边界平滑。
球面网格在删除后出现的自由边界就是 mask 切口,切口深度规则、边界带深度模糊与位置平滑都建立在「切口是一段自由边界」之上。Separate Geometry 可以把同一个 mask 判定变成两条独立分支:低 mask 面与其余面各自成网格,那么关闭开关时就有条件把两者分开处理。
## Goals / Non-Goals
**Goals:**
- 用 Separate Geometry(Face 域)替代 Panorama 上的 Delete Geometry,低 mask 区域成为可继续处理的几何。
- Depth Mask 打开时输出与按面删除等价的结果;关闭时两个区域各按自己的方式位移后合并。
- 维持切口规则、Depth Split、Depth Scale、UV 与材质协议的既有职责。
**Non-Goals:**
- 不改动 `O Image Depth Plane` 的 Depth Mask 删除行为,也不改动 `validity.delete_invalid_faces`。
- 不新增或改名接口输入,不改 Mask Threshold、Dome Radius、Depth Scale、Depth Split 的语义与默认值。
- 不引入跨分支的焊接、合并或平均。
## Decisions
### 分面判定沿用面心比较
Separate Geometry 的 `Selection` 接当前的 `invalid`(面中心采样 `< Mask Threshold`),`Inverted` 即保留面。判定仍只做一次比较,切口轮廓仍落在面中心上,低 mask 区域就是被旧逻辑删掉的那批面。
### 开关切换的是「低 mask 区域是否并入」
高 mask 分支照原链路处理:`split_depth_surface` → `build_surface_camera` → 深度边界模糊 → 半径 `Dome Radius + Depth Scale × (d − Dome Radius)` → Set Position → 清理 `_o_*` → 切口带位置平滑。低 mask 分支先把单位球面上的位置乘 `Dome Radius` 落到 dome 球面,再走同一套切口带位置平滑;它不参与 Depth Split,也不参与深度边界模糊(半径与采样深度无关)。
最后 `Join Geometry` 合并两条已各自定形的分支,再一起过 Flip Faces、Shade Smooth 与材质槽继承。Depth Mask 开关用一个 Geometry 类型的 Switch 实现:打开时取高 mask 分支,关断时取无 mask 一侧的结果;后者再按「Depth Scale 为 0」分一次,直接取整球。两条分支都要先算出来才能让开关只选结果,Join 因此总是执行;相比「再删一次低 mask 分支来制造空几何」,这条路径少一个节点,且把开关语义放在输出侧。
**为什么不是整片网格加回退**:旧做法把面域判定插值到点域来写回退距离,接缝点的归属取决于相邻面插值,切口处既可能拿到 dome 也可能拿到采样深度;分面后每个点只属于一个区域,判定没有插值空间。
### 关闭开关时不再保留逐位不变的契约
分面后接缝两侧各自持有自己的顶点,合并不会焊接:接缝是两条分支的自由边界,和打开开关时的切口一样。这是分面结构的直接结果,也是有意的取舍,取代 `unify-mask-cut-depth` 里「Depth Mask 关闭时几何逐位不变」的场景。
### 两侧边界都交给 Boundary Smooth
切口带平滑让接缝两侧的过渡一致:高 mask 分支平滑切口带,低 mask 分支平滑自己的自由边界,两者都发生在合并之前,各自只看到自己的拓扑。合并之后再平滑会把两条分支拼成的一张网格当成一个整体,接缝两侧互相牵动,所以平滑必须留在合并前。低 mask 分支不传 Depth Split 边界,它的自由边界全部按轮廓处理。
### Depth Scale 为 0 且关闭 Depth Mask 时旁路
Depth Scale 为 0 时整张表面都落在 dome 球面,再分面只会得到两批共面但接缝顶点不共享的网格,白白引入切口与缝隙;Depth Mask 又关断了排除,分面没有任何输出差异。这一组合因此旁路分面与全部后续处理,直接把球面乘 `Dome Radius` 输出,得到无切分的完整球面。
Depth Mask 打开时不能旁路:低 mask 区域仍要按 mask 排除。旁路条件因此是「Depth Scale 不大于 0」与「Depth Mask 关闭」的合取。
## Risks / Trade-offs
- Separate Geometry 的 `Invert` 与 Delete Geometry 在索引顺序上可能不同 → 断言按坐标、面集合与半径比较,不比较索引编号。
- 关闭开关时接缝顶点复制会让输出的顶点数多于旧的整片网格 → 测试改为按分支语义断言(面数、分区域半径、接缝无共享点);Depth Scale 为 0 的旁路单独断言封闭整球。
- 合并结果在接缝处暴露两条自由边,材质与法线在接缝上不连续 → 这是关闭 Depth Mask 时的既有语义(无效区本就与有效区差一个量级),保持翻转与 Shade Smooth 作用在合并后的整体上。
- 低 mask 分支在 Depth Scale 为 0 时与高 mask 分支重叠在同一球面 → 不焊接,保持两条分支的独立边界,行为与打开开关时的一致。

View File

@@ -0,0 +1,29 @@
## Why
`O Image Depth Panorama` 现在用 Delete Geometry 删除低 mask 面:接口只能给出「删除」,被删区域无法再作为几何参与后续处理,Depth Mask 关闭时也只能让无效面勉强落在 Dome Radius。改用 Separate Geometry 把两个区域分成独立分支,关闭开关时各走各的位移,再合并回一个输出。
## What Changes
- 低 mask 判定改为 Separate Geometry(Face 域):`Selection` 输出低于 Mask Threshold 的面,`Invert` 输出其余面,取消 `O Image Depth Panorama` 对 Delete Geometry 的依赖。
- Depth Mask 打开时只走 `Invert` 分支:保持当前的排除行为,低 mask 区域不进入输出。
- Depth Mask 关闭时两个分支分别处理:低 mask 区域整片位移到 `Dome Radius` 球面,高 mask 区域照原路径做 Depth Split、径向深度与边界平滑,两者都在自身边界带上按 Boundary Smooth 松弛,最后 Join Geometry 合并输出。
- Depth Scale 为 0 且 Depth Mask 关闭时旁路全部分支:输出直接是 `Dome Radius` 的完整球面,不做分面、切分与平滑。
- 两个分支各自输出独立的网格,接缝点不再共享;输出几何为各分支结果的并集,取消关闭开关时 `O Image Depth Panorama` 的「逐位不变」契约。
- 重建 `O_AnyImage.blend`,并按分支语义重写既有全景断言。
## Capabilities
### New Capabilities
- `panorama-mask-regions`: `O Image Depth Panorama` 如何用 Mask Threshold 分出面区域、Depth Mask 开关如何选择分支,以及关闭时两个区域各自的位移与合并结果。
### Modified Capabilities
无。`openspec/specs` 尚无可修改的已归档规格。
## Impact
- `tools/nodes/groups/image_depth_panorama.py`:Separate Geometry 分面、双分支位移、Join Geometry 合并,不再调用 `validity.delete_invalid_faces`(该函数仍由 `O Image Depth Plane` 使用)。
- `tests/tools/nodes/test_image_depth_panorama.py`:Mask Threshold 分面、开关分支选择、Dome Radius 位移与合并结果的断言。
- `src/anyimage/assets/O_AnyImage.blend`:重新构建节点资产。
- 不改动 `O Image Depth Plane`、`O Image Depth Cutout`、`O Image Relief Plane`、Operator 入口、材质与依赖。

View File

@@ -0,0 +1,91 @@
## ADDED Requirements
### Requirement: Mask Threshold 按面分出两个区域
`O Image Depth Panorama` MUST 用 Separate Geometry(Face 域)把球面网格按 Mask Threshold 分成两个区域:面中心采样低于 Mask Threshold 的面由 `Selection` 输出,其余面由 `Inverted` 输出。判定 MUST 复用同一次 Mask Threshold 比较,MUST NOT 另行采样或按深度通道判断;分面后 MUST NOT 再单独清理残留边。
#### Scenario: 切口落在面中心轮廓
- **WHEN** 无效区与有效区之间存在只跨过部分顶点的面
- **THEN** 该面在面中心仍有效时留在高 mask 区域,轮廓落在面中心的连线上,而不是所有相邻顶点都有效的内圈
#### Scenario: 两个区域互补
- **WHEN** 任意 mask 图下求值
- **THEN** 两个区域的面数之和等于输入球面的面数,各区域输出的边都至少属于一个面
### Requirement: Depth Mask 打开时只输出高 mask 区域
Depth Mask 打开时,`O Image Depth Panorama` MUST 只输出高 mask 区域经过完整深度处理的几何,低 mask 区域 MUST NOT 出现在输出中;输出 MUST 与按面删除低 mask 面后的处理结果等价。
#### Scenario: 打开时与按面删除等价
- **WHEN** Depth Mask 打开
- **THEN** 输出的面、顶点坐标与 UV 同按面删除低 mask 面后处理的结果一致
#### Scenario: 全部低于阈值
- **WHEN** Depth Mask 打开且所有面中心的采样都低于 Mask Threshold
- **THEN** 输出为空几何
### Requirement: Depth Mask 关闭时两区域分别处理再合并
Depth Mask 关闭时,低 mask 区域 MUST 保持自身方向、整片位移到 `Dome Radius` 球面,高 mask 区域 MUST 照常做 Depth Split、径向深度重建与切口带平滑,两者 MUST 在各自处理完成后 Join 成同一输出。低 mask 区域 MUST NOT 参与 Depth Split 与深度边界模糊,但 MUST 与高 mask 区域一样在自身自由边界上按 Boundary Smooth 松弛;输出 MUST NOT 跨区域焊接或平均顶点。
#### Scenario: 两个区域各自落位
- **WHEN** Depth Mask 关闭,且 mask 图同时含高、低两个区域
- **THEN** 高 mask 区域各点半径等于 `Dome Radius + Depth Scale × (d − Dome Radius)`,低 mask 区域各点半径等于 `Dome Radius`
#### Scenario: 低 mask 区域的采样深度不影响输出
- **WHEN** Depth Mask 关闭,且低 mask 区域写入与有效侧相差一个数量级的深度
- **THEN** 该区域仍整片落在 `Dome Radius` 球面上
#### Scenario: Depth Scale 为 0
- **WHEN** Depth Scale 为 0 且 Depth Mask 关闭
- **THEN** 两个区域都落在半径为 `Dome Radius` 的球面上,输出仍是完整球面
#### Scenario: 接缝两侧不共享顶点
- **WHEN** Depth Mask 关闭,且 mask 图同时含高、低两个区域
- **THEN** 接缝两侧各自保留自己的顶点,接缝在两侧都是自由边,输出顶点数多于同一网格不分面时的顶点数
#### Scenario: 两侧边界都随 Boundary Smooth 松弛
- **WHEN** Depth Mask 关闭且 Boundary Smooth 大于 0
- **THEN** 低 mask 区域接缝带内的点与高 mask 区域接缝带内的点都移动,两个区域的内部点保持不变,低 mask 区域的内部点仍落在 `Dome Radius` 球面上
### Requirement: Depth Scale 为 0 且 Depth Mask 关闭时输出整球
Depth Scale 为 0 且 Depth Mask 关闭时,`O Image Depth Panorama` MUST 直接输出半径为 `Dome Radius` 的完整球面,MUST NOT 做分面、Depth Split、切口带平滑,也 MUST NOT 留下未共享的接缝顶点;输出 MUST 是封闭网格。Depth Mask 打开时 MUST NOT 走这条旁路。
#### Scenario: 关闭开关的平坦投影没有切线
- **WHEN** Depth Scale 为 0 且 Depth Mask 关闭
- **THEN** 输出的面数与顶点数与同一球面的完整网格一致,没有自由边,各点半径等于 `Dome Radius`
#### Scenario: 打开开关的平坦投影仍按 mask 排除
- **WHEN** Depth Scale 为 0 且 Depth Mask 打开
- **THEN** 输出只含高 mask 区域,低 mask 区域不出现
### Requirement: 高 mask 区域沿用既有切口与深度语义
Separate Geometry 形成的自由边界 MUST 继续走既有切口规则:深度取相邻面采样,点保持自身射线方向。Depth Split、Depth Scale、UV 与材质槽继承 MUST 保持既有职责,`O Image Depth Plane` 的 Depth Mask 删除行为 MUST 保持不变。
#### Scenario: 打开开关时切口不出现尖刺
- **WHEN** Depth Mask 打开,且低 mask 侧采样与有效侧相差一个数量级
- **THEN** 切口圈各点的径向距离等于相邻有效面中心的采样距离
#### Scenario: 关闭开关时高 mask 区域仍按切口规则处理
- **WHEN** Depth Mask 关闭且 Boundary Smooth 为 0
- **THEN** 高 mask 区域的接缝点深度取相邻面采样,不跟随低 mask 侧的采样
#### Scenario: Plane 不受影响
- **WHEN** `O Image Depth Plane` 用同一深度图求值,Depth Mask 打开或关闭
- **THEN** 输出与本次改动前逐位一致

View File

@@ -0,0 +1,37 @@
## 1. 分面
- [x] 1.1 `build_image_depth_panorama_group` 用 `GeometryNodeSeparateGeometry`(domain FACE,selection 为面心 `invalid`)替换 `validity.delete_invalid_faces`,取出 `Selection` 低 mask 区域与 `Invert` 高 mask 区域
- [x] 1.2 去掉 Panorama 对 `validity` 的导入,确认 `O Image Depth Plane` 仍调用该函数
## 2. 两条分支
- [x] 2.1 高 mask 分支保持原链路:`split_depth_surface` → `build_surface_camera` → `smooth_boundary_depth` → 半径表达式 → Set Position → `remove_attribute_pattern` → `smooth_cut_boundary`
- [x] 2.2 去掉按点回退的 `fallback` Switch,无效判定只保留 Separate Geometry 那一处比较
- [x] 2.3 低 mask 分支加独立 Set Position,位置为单位方向乘 `Dome Radius`;不接 Depth Split、深度模糊与切口带平滑
- [x] 2.4 `Join Geometry` 合并两条分支,Depth Mask Switch 关闭时取合并结果、打开时取高 mask 分支
- [x] 2.5 Flip Faces、Shade Smooth 与 `inherit_material_slots` 保持在合并之后,两条分支共用同一套输出处理
- [x] 2.6 核对 `Depth Mask`、`Dome Radius`、`Mask Threshold` 的 description 与新语义一致
## 3. 测试
- [x] 3.1 `test_mask_threshold_culls_faces` 改为分面语义:打开时只输出高 mask 区域,关闭时输出完整球面且低 mask 区域落在 `Dome Radius`
- [x] 3.2 重写 `test_masked_area_falls_back_to_the_dome_radius`:关闭时按区域断言半径、`Dome Radius` 改变后的两个落点、`Depth Scale` 为 0 的整球结果
- [x] 3.3 补断言:关闭时低 mask 区域写入与有效侧相差一个数量级的深度,其半径仍为 `Dome Radius`
- [x] 3.4 补断言:关闭时接缝两侧各自持有顶点,接缝在两侧都是自由边,顶点数多于不分面时的输出
- [x] 3.5 补断言:关闭且 Boundary Smooth 大于 0 时低 mask 区域仍落在 `Dome Radius` 球面
- [x] 3.6 确认 `test_alpha_cut_keeps_the_ring_sharing_the_face_centre_contour`、`test_invalid_depth_does_not_bleed_into_alpha_boundary`、`test_panorama_validity_cut_smoothing_stays_in_two_ring_band` 在分面后仍通过
- [x] 3.7 运行 `uv run pytest`,确认全景与共享切口规则的既有测试全部通过
## 4. 资产与收尾
- [x] 4.1 运行 `uv run node-group build`,确认节点资产构建与校验通过、Capture Attribute 为零
- [x] 4.2 检查源码、测试与资产中不再有 Panorama 走 Delete Geometry 的旧路径,确认最终差异只涉及本次范围
- [x] 4.3 简要报告调整内容与验证结果
## 5. 追加调整
- [x] 5.1 低 mask 分支在合并前接入 `smooth_cut_boundary`,由 Boundary Smooth 控制,不传 Depth Split 边界
- [x] 5.2 Depth Scale 为 0 且 Depth Mask 关闭时旁路分面与后续处理,直接输出 `Dome Radius` 的完整球面;Depth Mask 打开时仍按 mask 排除
- [x] 5.3 `test_boundary_smooth_relaxes_both_masked_region_boundaries`:两个区域的接缝带都移动、内部不动,低 mask 区域内部仍在 `Dome Radius`
- [x] 5.4 `test_flat_scale_without_mask_returns_a_whole_sphere`:面数与顶点数同完整球面一致、无自由边、半径等于 `Dome Radius`
- [x] 5.5 重跑 `uv run node-group build` 与 `uv run pytest`,确认资产与测试通过

Binary file not shown.

View File

@@ -80,6 +80,36 @@ def material_node_group(depth_plane=False):
return load_node_group(group_name)
def create_emission_material(
source_image,
color_image,
scene=None,
texture_extension="EXTEND",
):
"""Emit the color image unchanged, without alpha blending or lighting."""
preserve_color = color_image.is_float or not color_image.colorspace_settings.name.endswith("sRGB")
material_image = color_image if preserve_color else configure_material_color_image(color_image, scene)
if material_image.source == "GENERATED" or material_image.is_dirty:
material_image.pack()
material = bpy.data.materials.new(image_base_name(source_image))
material.use_nodes = True
nodes = material.node_tree.nodes
nodes.clear()
color_texture = nodes.new("ShaderNodeTexImage")
emission = nodes.new("ShaderNodeEmission")
output = nodes.new("ShaderNodeOutputMaterial")
color_texture.image = material_image
color_texture.interpolation = "Linear"
color_texture.extension = texture_extension
color_texture.location = (-700.0, 300.0)
emission.location = (-300.0, 300.0)
output.location = (100.0, 300.0)
links = material.node_tree.links
links.new(color_texture.outputs["Color"], emission.inputs["Color"])
links.new(emission.outputs["Emission"], output.inputs["Surface"])
return material
def create_image_material(
source_image,
color_image,

View File

@@ -8,7 +8,7 @@ import bpy
from ...common.depth import load_depth_result_image
from ...common.image import require_conversion_source
from ...common.color_image import material_color_image
from ...common.material import create_image_material
from ...common.material import create_emission_material
from ...common.node import load_node_group
from ...common.object import finalize_object_result, modifier_input_identifier, set_modifier_input
@@ -34,8 +34,8 @@ def create_panorama_from_result(context, result, operator):
metadata = load_panorama_metadata(result.file("depth_metadata"))
group = load_node_group("O Image Depth Panorama")
depth = load_depth_result_image(result.file("depth"), source, metadata)
material = create_image_material(source.data, color, shadeless=True, scene=context.scene,
texture_extension="REPEAT")
material = create_emission_material(source.data, color, scene=context.scene,
texture_extension="REPEAT")
mesh = bpy.data.meshes.new(source.name)
mesh.from_pydata([(0, 0, 0)], [], [])
mesh.materials.append(material)

View File

@@ -106,7 +106,7 @@ def artifacts(tmp_path, source):
cleanup_material_color_input(operator.color_path)
def test_result_replaces_source_with_zero_rotation_and_shadeless_material(source, artifacts, monkeypatch):
def test_result_replaces_source_with_zero_rotation_and_emission_material(source, artifacts, monkeypatch):
result, operator = artifacts
from anyimage.common.node import load_node_group
saved = load_node_group("O Image Depth Panorama")
@@ -123,11 +123,14 @@ def test_result_replaces_source_with_zero_rotation_and_shadeless_material(source
np.testing.assert_allclose(obj.scale, matrix.to_scale(), atol=1e-6)
np.testing.assert_array_equal(obj.rotation_euler, (0, 0, 0))
material = obj.data.materials[0]
assert not any(n.bl_idname == "ShaderNodeUVMap" for n in material.node_tree.nodes)
assert any(n.type == "GROUP" and n.node_tree.name == "O Shadeless" for n in material.node_tree.nodes)
assert [n.node_tree.name for n in material.node_tree.nodes if n.type == "GROUP"] == ["O Shadeless"]
texture = next(n for n in material.node_tree.nodes if n.type == "TEX_IMAGE")
nodes = material.node_tree.nodes
assert not any(n.bl_idname == "ShaderNodeUVMap" or n.type == "GROUP" for n in nodes)
texture = next(n for n in nodes if n.type == "TEX_IMAGE")
emission = next(n for n in nodes if n.type == "EMISSION")
output = next(n for n in nodes if n.type == "OUTPUT_MATERIAL")
assert texture.extension == "REPEAT"
assert emission.inputs["Color"].links[0].from_socket == texture.outputs["Color"]
assert output.inputs["Surface"].links[0].from_socket == emission.outputs["Emission"]
assert bpy.context.view_layer.objects.active == obj
assert all(image.packed_file for image in bpy.data.images if image.name.endswith(("_color.png", "_depth.exr")))
graph = bpy.context.evaluated_depsgraph_get()

View File

@@ -49,6 +49,11 @@ def evaluated(obj):
result.to_mesh_clear()
def free_edges(faces):
edges = Counter(tuple(sorted((a, b))) for face in faces for a, b in zip(face, (*face[1:], face[0])))
return sum(count == 1 for count in edges.values())
@pytest.mark.parametrize("dome", [1.0, 50.0])
@pytest.mark.parametrize("subdivide", [0, 4])
def test_quad_sphere_topology_and_radial_endpoints(subdivide, dome):
@@ -71,11 +76,13 @@ def test_quad_sphere_topology_and_radial_endpoints(subdivide, dome):
assert np.max(np.ptp(uv.reshape(-1, 4, 2)[..., 0], axis=1)) <= 0.5 + 1e-5
def test_mask_threshold_culls_faces():
def test_depth_mask_keeps_only_the_faces_above_the_threshold():
obj, set_value = panorama(alpha=0.5)
assert not len(evaluated(obj)[0])
set_value("Depth Mask", False)
assert len(evaluated(obj)[1]) == 384
points, faces, _ = evaluated(obj)
assert len(faces) == 384
np.testing.assert_allclose(np.linalg.norm(points, axis=1), 50, atol=1e-5)
set_value("Depth Mask", True)
set_value("Mask Threshold", 0.49)
assert len(evaluated(obj)[1]) == 384
@@ -115,8 +122,8 @@ def test_linear_depth_reconstructs_flat_floor_without_sampling_rings():
assert np.max(abs(error)) < 0.004
def test_masked_area_falls_back_to_the_dome_radius():
"""The mask decides validity; what Depth Mask keeps lands on the dome sphere."""
def test_masked_area_rests_on_the_dome_sphere():
"""Depth Mask keeps only the valid half; the excluded half rests on the dome."""
alpha = np.ones((64, 128))
alpha[:, 64:] = 0
depth = np.where(alpha, 3.0, 1e-7)
@@ -140,6 +147,70 @@ def test_masked_area_falls_back_to_the_dome_radius():
np.testing.assert_allclose(np.linalg.norm(evaluated(obj)[0], axis=1), 50, atol=1e-3)
def test_masked_region_ignores_its_own_depth_and_depth_scale():
alpha = np.ones((64, 128))
alpha[:, 64:] = 0
depth = np.where(alpha, 3.0, 1e4)
obj, set_value = panorama(depth, alpha, subdivide=4)
set_value("Depth Mask", False)
for scale in (0.0, 1.0):
set_value("Depth Scale", scale)
radii = np.linalg.norm(evaluated(obj)[0], axis=1)
on_dome = np.isclose(radii, 50, atol=1e-3)
assert on_dome.any()
assert np.all(on_dome | np.isclose(radii, 3.0, atol=1e-3))
def test_masked_regions_keep_their_own_seam_edges():
"""Both regions reach the output, so the seam is a free edge on each side."""
alpha = np.ones((64, 128))
alpha[:, 64:] = 0
plain, _ = panorama(3.0, 1.0, subdivide=4)
whole, whole_faces, _ = evaluated(plain)
obj, set_value = panorama(3.0, alpha, subdivide=4)
set_value("Depth Mask", False)
points, faces, _ = evaluated(obj)
assert len(faces) == len(whole_faces)
assert len(points) > len(whole)
set_value("Depth Mask", True)
assert free_edges(faces) == 2 * free_edges(evaluated(obj)[1])
def test_boundary_smooth_relaxes_both_masked_region_boundaries():
"""Boundary Smooth relaxes both regions at the seam and leaves their interiors."""
alpha = np.ones((64, 128))
alpha[:, 64:] = 0
depth = np.where(alpha, 3.0, 1e4)
depth[:, 56] = 30.0
obj, set_value = panorama(depth, alpha, subdivide=4)
set_value("Depth Mask", False)
before = evaluated(obj)[0]
low = np.isclose(np.linalg.norm(before, axis=1), 50, atol=1e-3)
assert low.any()
set_value("Boundary Smooth", 5)
after = evaluated(obj)[0]
moved = np.linalg.norm(after - before, axis=1) > 1e-6
assert moved[low].any() and moved[~low].any()
assert not moved[low].all()
np.testing.assert_allclose(np.linalg.norm(after[low & ~moved], axis=1), 50, atol=1e-5)
def test_flat_scale_without_mask_returns_a_whole_sphere():
"""Depth Scale 0 without the mask leaves the dome sphere uncut and unsplit."""
alpha = np.ones((64, 128))
alpha[:, 64:] = 0
plain, _ = panorama(3.0, 1.0, subdivide=4)
whole, whole_faces, _ = evaluated(plain)
obj, set_value = panorama(np.where(alpha, 3.0, 1e4), alpha, subdivide=4)
set_value("Depth Mask", False)
set_value("Depth Scale", 0)
points, faces, _ = evaluated(obj)
assert len(faces) == len(whole_faces)
assert len(points) == len(whole)
assert free_edges(faces) == 0
np.testing.assert_allclose(np.linalg.norm(points, axis=1), 50, atol=1e-6)
def test_controls_and_material_slot_inheritance():
obj, set_value = panorama()
group = obj.modifiers[0].node_group

View File

@@ -12,14 +12,16 @@ def test_shadeless_alpha_is_mixed_inside_the_group(tmp_path):
addon_utils.enable("cycles", default_set=False)
group = load_node_group("O Shadeless")
inputs = {s.name: s for s in group.interface.items_tree if s.item_type == "SOCKET" and s.in_out == "INPUT"}
assert list(inputs) == ["Color", "Alpha"] and inputs["Alpha"].default_value == 1
assert list(inputs) == ["Color", "Alpha", "Strength"]
assert inputs["Alpha"].default_value == 1 and inputs["Strength"].default_value == 1
assert inputs["Strength"].min_value == 0
scene = bpy.context.scene
scene.render.engine = "CYCLES"
scene.cycles.device = "CPU"
scene.cycles.samples = 8
scene.cycles.use_denoising = False
scene.render.film_transparent = True
scene.render.resolution_x, scene.render.resolution_y = 96, 32
scene.render.resolution_x, scene.render.resolution_y = 128, 32
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "OPEN_EXR"
scene.render.image_settings.color_mode = "RGBA"
@@ -28,27 +30,29 @@ def test_shadeless_alpha_is_mixed_inside_the_group(tmp_path):
bpy.ops.object.camera_add(location=(0, 0, 5))
scene.camera = bpy.context.object
scene.camera.data.type = "ORTHO"
scene.camera.data.ortho_scale = 3
for index, alpha in enumerate((0, 0.5, 1)):
bpy.ops.mesh.primitive_plane_add(size=1, location=(index - 1, 0, 0))
material = bpy.data.materials.new(f"Alpha {alpha}")
scene.camera.data.ortho_scale = 4
for index, (alpha, strength) in enumerate(((0, 1), (0.5, 1), (1, 1), (1, 2))):
bpy.ops.mesh.primitive_plane_add(size=1, location=(index - 1.5, 0, 0))
material = bpy.data.materials.new(f"Alpha {alpha} Strength {strength}")
material.use_nodes = True
material.node_tree.nodes.clear()
shader = material.node_tree.nodes.new("ShaderNodeGroup")
shader.node_tree = group
shader.inputs["Color"].default_value = (4, 2, 0.5, 1)
shader.inputs["Alpha"].default_value = alpha
shader.inputs["Strength"].default_value = strength
output = material.node_tree.nodes.new("ShaderNodeOutputMaterial")
material.node_tree.links.new(shader.outputs[0], output.inputs["Surface"])
bpy.context.object.data.materials.append(material)
bpy.ops.render.render(write_still=True)
image = bpy.data.images.load(scene.render.filepath, check_existing=False)
try:
pixels = np.array(image.pixels[:]).reshape(32, 96, 4)
for index, alpha in enumerate((0, 0.5, 1)):
pixels = np.array(image.pixels[:]).reshape(32, 128, 4)
for index, alpha in enumerate((0, 0.5, 1, 1)):
patch = pixels[8:24, index * 32 + 8:index * 32 + 24]
np.testing.assert_allclose(patch[..., 3].mean(), alpha, atol=0.035)
np.testing.assert_allclose(pixels[16, 80, :3], (4, 2, 0.5), atol=1e-4)
np.testing.assert_allclose(pixels[16, 112, :3], (8, 4, 1), atol=1e-4)
finally:
bpy.data.images.remove(image)
bpy.ops.wm.read_factory_settings(use_empty=True)

View File

@@ -4,13 +4,11 @@ import math
import bpy
from ..common.validity import delete_invalid_faces
from ..common.depth_surface import (
_math, split_depth_surface, build_surface_camera,
)
from ..common.nodes import (
compare_node, float_input, group_input, interface_socket,
boolean_node, compare_node, float_input, group_input, interface_socket,
prepare_node_group, evaluate_field, remove_attribute_pattern,
)
from ..common.material_slots import inherit_material_slots
@@ -51,7 +49,7 @@ def build_image_depth_panorama_group():
float_input(group, "Depth Split", 0.1, maximum=1, subtype="FACTOR").description = "Split the mesh where depth changes abruptly. Scaled by Depth Scale: a flat projection stays whole, higher values detect smaller depth jumps."
valid_only = interface_socket(group, "Depth Mask", "INPUT", "NodeSocketBool")
valid_only.default_value = True
valid_only.description = "Remove mesh areas marked invalid by the depth mask."
valid_only.description = "Remove mesh areas marked invalid by the depth mask; with the mask off, they rest on the dome sphere."
options = group.interface.new_panel("Options", default_closed=True)
boundary_smooth = interface_socket(group, "Boundary Smooth", "INPUT", "NodeSocketInt", options)
boundary_smooth.default_value, boundary_smooth.min_value, boundary_smooth.max_value = 2, 0, 16
@@ -137,19 +135,16 @@ def build_image_depth_panorama_group():
threshold_input = group_input(nodes, {"Mask Threshold"})
invalid = compare_node(group, "LESS_THAN", face_alpha, threshold_input.outputs["Mask Threshold"])
protected = uv_map.outputs["Geometry"]
culled = delete_invalid_faces(group, protected, invalid)
cull_controls = group_input(nodes, {"Depth Mask"})
cull = nodes.new("GeometryNodeSwitch")
cull.input_type = "GEOMETRY"
links.new(cull_controls.outputs["Depth Mask"], cull.inputs["Switch"])
links.new(culled, cull.inputs["True"])
links.new(protected, cull.inputs["False"])
separate = nodes.new("GeometryNodeSeparateGeometry")
separate.domain = "FACE"
links.new(protected, separate.inputs["Geometry"])
links.new(invalid, separate.inputs["Selection"])
controls = group_input(nodes, {"Depth Split", "Depth Scale"})
strength = controls.outputs["Depth Split"]
radial = nodes.new("ShaderNodeSeparateXYZ")
links.new(face_camera, radial.inputs[0])
geometry, corner_camera, cut_vertex = split_depth_surface(
group, cull.outputs["Output"], face_camera, strength, 1.0, controls.outputs["Depth Scale"],
group, separate.outputs["Inverted"], face_camera, strength, 1.0, controls.outputs["Depth Scale"],
face_distance=radial.outputs["X"], face_center=face.outputs["Value"],
)
distance = build_surface_camera(group, pixel, corner_camera, cut_vertex, strength)
@@ -157,16 +152,10 @@ def build_image_depth_panorama_group():
links.new(distance, axes.inputs[0])
distance = smooth_boundary_depth(group, axes.outputs["X"])
dome_radius = group_input(nodes, {"Dome Radius"}).outputs["Dome Radius"]
fallback = nodes.new("GeometryNodeSwitch")
fallback.input_type = "FLOAT"
links.new(invalid, fallback.inputs["Switch"])
links.new(distance, fallback.inputs["False"])
links.new(dome_radius, fallback.inputs["True"])
controls = group_input(nodes, {"Depth Scale"})
radius = _math(
group, "ADD", dome_radius,
_math(group, "MULTIPLY", controls.outputs["Depth Scale"],
_math(group, "SUBTRACT", fallback.outputs[0], dome_radius)),
_math(group, "SUBTRACT", distance, dome_radius)),
)
radial = nodes.new("ShaderNodeVectorMath")
radial.operation = "SCALE"
@@ -177,8 +166,41 @@ def build_image_depth_panorama_group():
links.new(radial.outputs[0], deform.inputs["Position"])
geometry = remove_attribute_pattern(group, deform.outputs[0], "_o_*")
geometry = smooth_cut_boundary(group, geometry, cut_vertex, outline_scale=1.0)
# The excluded faces rest on the dome sphere and relax along their own boundary.
domed = nodes.new("ShaderNodeVectorMath")
domed.operation = "SCALE"
links.new(position, domed.inputs[0])
links.new(dome_radius, domed.inputs[3])
resting = nodes.new("GeometryNodeSetPosition")
links.new(separate.outputs["Selection"], resting.inputs["Geometry"])
links.new(domed.outputs[0], resting.inputs["Position"])
resting = smooth_cut_boundary(group, resting.outputs["Geometry"], outline_scale=1.0)
joined = nodes.new("GeometryNodeJoinGeometry")
links.new(geometry, joined.inputs["Geometry"])
links.new(resting, joined.inputs["Geometry"])
# A flat projection without the mask is the dome sphere alone: no split, no seam.
dome_sphere = nodes.new("GeometryNodeSetPosition")
links.new(protected, dome_sphere.inputs["Geometry"])
links.new(domed.outputs[0], dome_sphere.inputs["Position"])
cull_controls = group_input(nodes, {"Depth Mask"})
dome_only = boolean_node(
group,
"AND",
boolean_node(group, "NOT", compare_node(group, "GREATER_THAN", controls.outputs["Depth Scale"], 0.0)),
boolean_node(group, "NOT", cull_controls.outputs["Depth Mask"]),
)
unmasked = nodes.new("GeometryNodeSwitch")
unmasked.input_type = "GEOMETRY"
links.new(dome_only, unmasked.inputs["Switch"])
links.new(joined.outputs["Geometry"], unmasked.inputs["False"])
links.new(dome_sphere.outputs["Geometry"], unmasked.inputs["True"])
cull = nodes.new("GeometryNodeSwitch")
cull.input_type = "GEOMETRY"
links.new(cull_controls.outputs["Depth Mask"], cull.inputs["Switch"])
links.new(unmasked.outputs["Output"], cull.inputs["False"])
links.new(geometry, cull.inputs["True"])
flip = nodes.new("GeometryNodeFlipFaces")
links.new(geometry, flip.inputs["Mesh"])
links.new(cull.outputs["Output"], flip.inputs["Mesh"])
smooth = nodes.new("GeometryNodeSetShadeSmooth")
smooth.domain = "FACE"
smooth.inputs["Shade Smooth"].default_value = True

View File

@@ -17,6 +17,11 @@ def build_shadeless_group():
alpha = group.interface.new_socket(name="Alpha", in_out="INPUT", socket_type="NodeSocketFloat")
alpha.default_value, alpha.min_value, alpha.max_value = 1.0, 0.0, 1.0
alpha.subtype = "FACTOR"
strength = group.interface.new_socket(
name="Strength", in_out="INPUT", socket_type="NodeSocketFloat"
)
strength.description = "Set the emission strength for camera rays. Zero renders the surface black."
strength.default_value, strength.min_value = 1.0, 0.0
nodes = group.nodes
links = group.links
group_input = nodes.new("NodeGroupInput")
@@ -26,6 +31,7 @@ def build_shadeless_group():
emission = nodes.new("ShaderNodeEmission")
links.new(group_input.outputs["Color"], diffuse.inputs["Color"])
links.new(group_input.outputs["Color"], emission.inputs["Color"])
links.new(group_input.outputs["Strength"], emission.inputs["Strength"])
light_path = nodes.new("ShaderNodeLightPath")
bounce_count = nodes.new("ShaderNodeMath")