diff --git a/openspec/changes/separate-panorama-mask-regions/.openspec.yaml b/openspec/changes/separate-panorama-mask-regions/.openspec.yaml new file mode 100644 index 0000000..f080778 --- /dev/null +++ b/openspec/changes/separate-panorama-mask-regions/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-16 diff --git a/openspec/changes/separate-panorama-mask-regions/design.md b/openspec/changes/separate-panorama-mask-regions/design.md new file mode 100644 index 0000000..3053273 --- /dev/null +++ b/openspec/changes/separate-panorama-mask-regions/design.md @@ -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 分支重叠在同一球面 → 不焊接,保持两条分支的独立边界,行为与打开开关时的一致。 diff --git a/openspec/changes/separate-panorama-mask-regions/proposal.md b/openspec/changes/separate-panorama-mask-regions/proposal.md new file mode 100644 index 0000000..a259acd --- /dev/null +++ b/openspec/changes/separate-panorama-mask-regions/proposal.md @@ -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 入口、材质与依赖。 diff --git a/openspec/changes/separate-panorama-mask-regions/specs/panorama-mask-regions/spec.md b/openspec/changes/separate-panorama-mask-regions/specs/panorama-mask-regions/spec.md new file mode 100644 index 0000000..6c68568 --- /dev/null +++ b/openspec/changes/separate-panorama-mask-regions/specs/panorama-mask-regions/spec.md @@ -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** 输出与本次改动前逐位一致 diff --git a/openspec/changes/separate-panorama-mask-regions/tasks.md b/openspec/changes/separate-panorama-mask-regions/tasks.md new file mode 100644 index 0000000..90c3f86 --- /dev/null +++ b/openspec/changes/separate-panorama-mask-regions/tasks.md @@ -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`,确认资产与测试通过 diff --git a/src/anyimage/assets/O_AnyImage.blend b/src/anyimage/assets/O_AnyImage.blend index 86dcae0..271736f 100644 --- a/src/anyimage/assets/O_AnyImage.blend +++ b/src/anyimage/assets/O_AnyImage.blend @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a37ec3dca7a21366601a66e01ca2bb94e1e8665375c2a311162dc438cebfd37e -size 8278599 +oid sha256:9b720189a5732881306cf7de80ab10f78ab0433cfb30273235688f8720d85313 +size 8658671 diff --git a/src/anyimage/common/material.py b/src/anyimage/common/material.py index 5032213..008463e 100644 --- a/src/anyimage/common/material.py +++ b/src/anyimage/common/material.py @@ -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, diff --git a/src/anyimage/operators/convert_to_panorama/object.py b/src/anyimage/operators/convert_to_panorama/object.py index 6eba5ec..e43bd57 100644 --- a/src/anyimage/operators/convert_to_panorama/object.py +++ b/src/anyimage/operators/convert_to_panorama/object.py @@ -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) diff --git a/tests/anyimage/operators/test_panorama_conversion.py b/tests/anyimage/operators/test_panorama_conversion.py index 06e7c10..a5b3290 100644 --- a/tests/anyimage/operators/test_panorama_conversion.py +++ b/tests/anyimage/operators/test_panorama_conversion.py @@ -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() diff --git a/tests/tools/nodes/test_image_depth_panorama.py b/tests/tools/nodes/test_image_depth_panorama.py index add082f..cbc8cee 100644 --- a/tests/tools/nodes/test_image_depth_panorama.py +++ b/tests/tools/nodes/test_image_depth_panorama.py @@ -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 diff --git a/tests/tools/nodes/test_shadeless.py b/tests/tools/nodes/test_shadeless.py index 794e849..6ec42ed 100644 --- a/tests/tools/nodes/test_shadeless.py +++ b/tests/tools/nodes/test_shadeless.py @@ -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) diff --git a/tools/nodes/groups/image_depth_panorama.py b/tools/nodes/groups/image_depth_panorama.py index e27e4e4..0f0debb 100644 --- a/tools/nodes/groups/image_depth_panorama.py +++ b/tools/nodes/groups/image_depth_panorama.py @@ -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 diff --git a/tools/nodes/groups/shadeless.py b/tools/nodes/groups/shadeless.py index 4023540..177069b 100644 --- a/tools/nodes/groups/shadeless.py +++ b/tools/nodes/groups/shadeless.py @@ -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")